diff --git a/docs/STATO.md b/docs/STATO.md index 24ba21a..12e82d7 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -24,7 +24,8 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, luce, cassa che accelera, lampo bianco, coriandoli) e il centro diventa la notte della festa (flag `ingorgo`, fase `ingorgo`, sfondi di notte di Codex in `backdrops`). La zona (`story.zones`): Piazza, Via dell'Antica Posta, Piazzetta del Comune, cima di Via dell'Antica Posta, Piazzetta Cambray Digny; uscendo (a piedi o con la mappa) si torna a maggio. - Le console (`stages` delle scene, `music.js` `mix`): più brani insieme, ognuno più forte vicino alla sua console, anche + Le luci da palco animate solo alla festa (`stagelights`, `lasers`, `mirrorball` in render.js): fasci che spazzano, bagliore a + tempo, strobo, laser alla tecno, palla a specchi all'underground. Le console (`stages` delle scene, `music.js` `mix`): più brani insieme, ognuno più forte vicino alla sua console, anche da fuori quadro (la piazza accanto). Generi provvisori: house in Piazza, latino al Comune, underground in cima a Via dell'Antica Posta, tecno a Cambray Digny; i brani sono di Andrea (Suno), portati al volume del gioco. Gli sfondi sono pieni zeppi di gente fino in primo piano (Andrea); la prima versione, con la piazza vuota davanti, in diff --git a/src/engine/render.js b/src/engine/render.js index b527b1c..92e2d26 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -487,6 +487,81 @@ export const EFFECTS = { ctx.fill(); ctx.restore(); }, + // Le luci da palco dell'Ingorgo (Andrea, 30 ottobre): dalle teste mobili (`from`) i fasci colorati che spazzano il + // cielo; sul palco un bagliore che pulsa a tempo (`bpm`) e cambia colore a ogni battuta; ogni tanto lo strobo. + stagelights(ctx, e, time) { + const colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40'], beat = time / (60000 / (e.bpm ?? 124)); + const hit = 1 - (beat % 1), bar = Math.floor(beat / 4); + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + if (e.wash) { + const [x, y, r] = e.wash, c = colors[bar % colors.length]; + const g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, `rgba(${c},${0.1 + 0.18 * hit ** 2})`); + g.addColorStop(1, `rgba(${c},0)`); + ctx.fillStyle = g; + ctx.fillRect(x - r, y - r, r * 2, r * 2); + } + for (const [k, [x, y]] of (e.from ?? []).entries()) { + for (let i = 0; i < (e.beams ?? 2); i++) { + const n = k * 3 + i, swing = Math.sin(time / (900 + n * 170) + n * 1.9) * (e.spread ?? 0.75); + const a = -Math.PI / 2 + swing + (k % 2 ? 0.25 : -0.25), len = e.len ?? 260, half = 0.045; + const c = colors[(n + bar) % colors.length], tipX = x + Math.cos(a) * len, tipY = y + Math.sin(a) * len; + const g = ctx.createLinearGradient(x, y, tipX, tipY); + g.addColorStop(0, `rgba(${c},0.32)`); + g.addColorStop(1, `rgba(${c},0)`); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + Math.cos(a - half) * len, y + Math.sin(a - half) * len); + ctx.lineTo(x + Math.cos(a + half) * len, y + Math.sin(a + half) * len); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = `rgba(${c},0.9)`; + ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 3, 3); + } + } + ctx.restore(); + // Lo strobo: un lampo bianco brevissimo ogni `strobe` battute. + if (e.strobe && Math.floor(beat) % e.strobe === 0 && beat % 1 < 0.12) { + ctx.fillStyle = 'rgba(255,255,255,0.13)'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + } + }, + // I laser della tecno: righe sottili verdi (o del colore dato) che si aprono a ventaglio e si richiudono. + lasers(ctx, e, time) { + const [x, y] = e.at, n = e.count ?? 7, open = 0.35 + 0.35 * Math.sin(time / 1300), tilt = Math.sin(time / 2100) * 0.4; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = `rgba(${e.color ?? '90,255,110'},${0.35 + 0.2 * Math.sin(time / 90) ** 2})`; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const a = -Math.PI / 2 + tilt + (i / (n - 1) - 0.5) * 2 * open * 1.4; + ctx.moveTo(x + 0.5, y + 0.5); + ctx.lineTo(x + Math.cos(a) * (e.len ?? 400), y + Math.sin(a) * (e.len ?? 400)); + } + ctx.stroke(); + ctx.restore(); + }, + // La palla a specchi: puntini di luce che girano sui muri intorno (`area`), più chiari vicino alla palla. + mirrorball(ctx, e, time) { + const [bx, by] = e.at, [ax, ay, aw, ah] = e.area, turn = time / 4200; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (let i = 0; i < (e.count ?? 40); i++) { + const u = (hash(i * 13) + turn) % 1, x = ax + Math.round(u * aw), y = ay + Math.round(hash(i * 7 + 3) * ah); + const glint = 0.5 + 0.5 * Math.sin(time / 160 + i * 2.3); + ctx.fillStyle = `rgba(${e.color ?? '220,200,255'},${0.35 + 0.5 * glint})`; + ctx.fillRect(x, y, 2, 2); + } + const g = ctx.createRadialGradient(bx, by, 0, bx, by, 9); + g.addColorStop(0, 'rgba(255,255,255,0.8)'); + g.addColorStop(1, 'rgba(200,180,255,0)'); + ctx.fillStyle = g; + ctx.fillRect(bx - 9, by - 9, 18, 18); + ctx.restore(); + }, // Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`). lamps(ctx, e, time) { ctx.save(); diff --git a/src/story/story.js b/src/story/story.js index 604cec4..42af8e0 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -588,7 +588,7 @@ export const story = { { when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [195, 235] }, { when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-160, 250], far: 600 }, ], - effects: [{ type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true, phase: ['pomeriggio', 'tramonto', 'sera'] }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }], + effects: [{ type: 'stagelights', phase: 'ingorgo', from: [[116, 146], [276, 146]], wash: [200, 215, 150], bpm: 124, beams: 2, strobe: 8 }, { type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true, phase: ['pomeriggio', 'tramonto', 'sera'] }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }], walk: [[84, 244], [330, 238], [345, 230], [362, 238], [462, 246], [466, 276], [544, 276], [548, 248], [588, 250], [600, 290], [640, 300], [640, 360], [0, 360], [0, 330], [84, 318]], depth: [[236, 0.5], [360, 1.35]], entry: [345, 240], @@ -1624,6 +1624,8 @@ export const story = { backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre) // Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza] // La tecno davanti alla casetta bianca; l'underground arriva da giù, da Via dell'Antica Posta. + // Le luci da palco della festa (render.js: stagelights, lasers, mirrorball). + effects: [{ type: 'lasers', phase: 'ingorgo', at: [400, 198], count: 9 }, { type: 'stagelights', phase: 'ingorgo', from: [[356, 192], [460, 192]], wash: [408, 225, 150], bpm: 132, beams: 2, strobe: 4, colors: ['90,255,110', '255,255,255', '60,220,255'] }], stages: [ { when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [495, 245] }, { when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [700, 300], far: 650 }, @@ -1668,6 +1670,8 @@ export const story = { backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre) // Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza] // Il bar della birra sui gradini; il latino dal Comune a sinistra, la house giù dalla piazza, l'underground su in cima. + // Le luci da palco della festa (render.js: stagelights, lasers, mirrorball). + effects: [{ type: 'stagelights', phase: 'ingorgo', wash: [90, 190, 110], bpm: 98, colors: ['255,170,60', '255,90,160'] }, { type: 'stagelights', phase: 'ingorgo', from: [[430, 205]], beams: 1, len: 160, bpm: 98 }], stages: [ { when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-110, 250] }, { when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [320, 560] }, @@ -1715,6 +1719,8 @@ export const story = { backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta-comune.png' }], // Codex, la notte dell'Ingorgo (30 ottobre) // Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza] // La console del latino davanti al Comune; la house arriva da giù, dalla piazza. + // Le luci da palco della festa (render.js: stagelights, lasers, mirrorball). + effects: [{ type: 'stagelights', phase: 'ingorgo', from: [[232, 212], [332, 212]], wash: [282, 228, 140], bpm: 98, beams: 2, colors: ['255,120,40', '255,60,120', '255,200,60', '200,80,255'] }], stages: [ { when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [290, 235] }, { when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [260, 640], far: 700 }, @@ -1769,6 +1775,8 @@ export const story = { backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta-alta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre) // Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza] // L'underground sotto il muro della villa; su, in fondo, la tecno di Cambray Digny; giù, lontano, la house. + // Le luci da palco della festa (render.js: stagelights, lasers, mirrorball). + effects: [{ type: 'mirrorball', phase: 'ingorgo', at: [550, 131], area: [380, 40, 260, 200], count: 46 }, { type: 'stagelights', phase: 'ingorgo', wash: [552, 205, 120], bpm: 122, colors: ['170,60,255', '90,40,220', '220,60,255'] }], stages: [ { when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [565, 235] }, { when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [270, 120], far: 600 }, diff --git a/tests/ingorgo.test.mjs b/tests/ingorgo.test.mjs index 92bac4e..7ac3b4f 100644 --- a/tests/ingorgo.test.mjs +++ b/tests/ingorgo.test.mjs @@ -6,6 +6,7 @@ import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/eng import { Game, check, POSTER_READY } from '../src/engine/game.js'; import { story } from '../src/story/story.js'; import { settle } from './helpers.mjs'; +import { render } from '../src/engine/render.js'; const ZONE = story.zones.find(z => z.flag === 'ingorgo'); // Fa scorrere il tempo finché l'incantesimo non è finito. @@ -102,6 +103,25 @@ test('il manifesto ingrandito si chiude senza fare niente: niente festa', () => assert.ok(!g.state.flags.ingorgo && !g.closeup); }); +test('alla festa le luci da palco si muovono, e solo alla festa', () => { + globalThis.Image = class { set src(v) {} }; + try { + const ops = [], gradient = { addColorStop() {} }; + const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { ops.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } }); + const g = fromPiazza(); + for (const scene of ZONE.scenes) { + g.go(scene); settle(g); + const lights = story.scenes[scene].effects.filter(e => e.phase === 'ingorgo'); + assert.ok(lights.length, `${scene}: le luci`); + const frame = time => { ops.length = 0; render(ctx, g, { time }); return JSON.stringify(ops.filter(([op]) => op === 'lineTo' || op === 'fillRect')); }; + assert.notEqual(frame(1000), frame(1700), `${scene}: le luci si muovono`); + } + g.go('via_cipriani'); settle(g); g.go('piazza'); settle(g); + ops.length = 0; render(ctx, g, { time: 1000 }); + assert.ok(!ops.some(([op, x, y]) => op === 'moveTo' && x === 116 && y === 146), 'di giorno niente teste mobili'); + } finally { delete globalThis.Image; } +}); + test('le musiche e gli sfondi della festa esistono', () => { const files = new Set(); for (const id of ZONE.scenes) {