diff --git a/Makefile b/Makefile index ba6977e..22a5cb1 100644 --- a/Makefile +++ b/Makefile @@ -87,11 +87,11 @@ background: ## Porta uno sfondo a 640x360 nel gioco (SRC=..., DST=..., CROP=x,y, @test -n "$(SRC)" || { echo "uso: make background SRC=assets/generated/nome.png [CROP=x,y,w,h]"; exit 2; } $(PYTHON) tools/prepare_background.py $(SRC) $(DST) $(if $(CROP),--crop $(CROP)) --colors $(COLORS) -character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand) +character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand, FIX=wave=1.3) $(PYTHON) tools/slice_sprites.py --sheet $(SHEET) --out /tmp/regolo-$(NAME) --cols 4 --rows 2 \ --despill --names $(FRAMES) --prefix $(NAME)_ $(PYTHON) tools/prepare_character.py /tmp/regolo-$(NAME) $(NAME) assets/characters --height $(HEIGHT) \ - --frames $(FRAMES) --ref $(REF) + --frames $(FRAMES) --ref $(REF) $(if $(FIX),--fix $(FIX)) ## ------------------------------------------------------------ docker diff --git a/assets/audio/ingorgo-house.mp3 b/assets/audio/ingorgo-house.mp3 new file mode 100644 index 0000000..af86e73 Binary files /dev/null and b/assets/audio/ingorgo-house.mp3 differ diff --git a/assets/audio/ingorgo-latino.mp3 b/assets/audio/ingorgo-latino.mp3 new file mode 100644 index 0000000..9b2dcef Binary files /dev/null and b/assets/audio/ingorgo-latino.mp3 differ diff --git a/assets/audio/ingorgo-techno.mp3 b/assets/audio/ingorgo-techno.mp3 new file mode 100644 index 0000000..28e8224 Binary files /dev/null and b/assets/audio/ingorgo-techno.mp3 differ diff --git a/assets/audio/ingorgo-underground.mp3 b/assets/audio/ingorgo-underground.mp3 new file mode 100644 index 0000000..09cb0e7 Binary files /dev/null and b/assets/audio/ingorgo-underground.mp3 differ diff --git a/assets/characters/baldoria.json b/assets/characters/baldoria.json new file mode 100644 index 0000000..d5393db --- /dev/null +++ b/assets/characters/baldoria.json @@ -0,0 +1,70 @@ +{ + "image": "baldoria.png", + "height": 120, + "frames": { + "stand": { + "x": 0, + "y": 0, + "w": 59, + "h": 120, + "ax": 28.6, + "ay": 120 + }, + "blink": { + "x": 61, + "y": 0, + "w": 59, + "h": 120, + "ax": 28.4, + "ay": 120 + }, + "talk": { + "x": 122, + "y": 0, + "w": 59, + "h": 120, + "ax": 28.6, + "ay": 120 + }, + "explain": { + "x": 183, + "y": 0, + "w": 67, + "h": 122, + "ax": 29.4, + "ay": 122 + }, + "wave": { + "x": 252, + "y": 0, + "w": 101, + "h": 156, + "ax": 48.9, + "ay": 156 + }, + "wave2": { + "x": 355, + "y": 0, + "w": 127, + "h": 156, + "ax": 81.8, + "ay": 156 + }, + "cheer": { + "x": 484, + "y": 0, + "w": 70, + "h": 120, + "ax": 33.6, + "ay": 120 + }, + "laugh": { + "x": 556, + "y": 0, + "w": 78, + "h": 113, + "ax": 31.1, + "ay": 113 + } + } +} diff --git a/assets/characters/baldoria.png b/assets/characters/baldoria.png new file mode 100644 index 0000000..a4ed217 Binary files /dev/null and b/assets/characters/baldoria.png differ diff --git a/assets/closeups/manifesto-ingorgo.png b/assets/closeups/manifesto-ingorgo.png new file mode 100644 index 0000000..371886b Binary files /dev/null and b/assets/closeups/manifesto-ingorgo.png differ diff --git a/assets/scenes/ingorgo-antica-posta-alta.png b/assets/scenes/ingorgo-antica-posta-alta.png new file mode 100644 index 0000000..75708d1 Binary files /dev/null and b/assets/scenes/ingorgo-antica-posta-alta.png differ diff --git a/assets/scenes/ingorgo-antica-posta.png b/assets/scenes/ingorgo-antica-posta.png new file mode 100644 index 0000000..20ec978 Binary files /dev/null and b/assets/scenes/ingorgo-antica-posta.png differ diff --git a/assets/scenes/ingorgo-piazza.png b/assets/scenes/ingorgo-piazza.png new file mode 100644 index 0000000..41ff404 Binary files /dev/null and b/assets/scenes/ingorgo-piazza.png differ diff --git a/assets/scenes/ingorgo-piazzetta-comune.png b/assets/scenes/ingorgo-piazzetta-comune.png new file mode 100644 index 0000000..966674d Binary files /dev/null and b/assets/scenes/ingorgo-piazzetta-comune.png differ diff --git a/assets/scenes/ingorgo-piazzetta.png b/assets/scenes/ingorgo-piazzetta.png new file mode 100644 index 0000000..dfd603d Binary files /dev/null and b/assets/scenes/ingorgo-piazzetta.png differ diff --git a/assets/voices/andrea-0c26e34815.mp3 b/assets/voices/andrea-0c26e34815.mp3 new file mode 100644 index 0000000..7bc81af Binary files /dev/null and b/assets/voices/andrea-0c26e34815.mp3 differ diff --git a/assets/voices/andrea-24e5ac53da.mp3 b/assets/voices/andrea-24e5ac53da.mp3 new file mode 100644 index 0000000..dd4b053 Binary files /dev/null and b/assets/voices/andrea-24e5ac53da.mp3 differ diff --git a/assets/voices/andrea-3433db09f3.mp3 b/assets/voices/andrea-3433db09f3.mp3 new file mode 100644 index 0000000..3c4473b Binary files /dev/null and b/assets/voices/andrea-3433db09f3.mp3 differ diff --git a/assets/voices/andrea-366c066d68.mp3 b/assets/voices/andrea-366c066d68.mp3 new file mode 100644 index 0000000..9b5d3a6 Binary files /dev/null and b/assets/voices/andrea-366c066d68.mp3 differ diff --git a/assets/voices/andrea-408197f33d.mp3 b/assets/voices/andrea-408197f33d.mp3 new file mode 100644 index 0000000..24fa559 Binary files /dev/null and b/assets/voices/andrea-408197f33d.mp3 differ diff --git a/assets/voices/andrea-4faef889c8.mp3 b/assets/voices/andrea-4faef889c8.mp3 new file mode 100644 index 0000000..7925a51 Binary files /dev/null and b/assets/voices/andrea-4faef889c8.mp3 differ diff --git a/assets/voices/andrea-6697e2ce25.mp3 b/assets/voices/andrea-6697e2ce25.mp3 new file mode 100644 index 0000000..64b0a77 Binary files /dev/null and b/assets/voices/andrea-6697e2ce25.mp3 differ diff --git a/assets/voices/andrea-9f315e91e5.mp3 b/assets/voices/andrea-9f315e91e5.mp3 new file mode 100644 index 0000000..fb5cca0 Binary files /dev/null and b/assets/voices/andrea-9f315e91e5.mp3 differ diff --git a/assets/voices/andrea-a0ac302eb3.mp3 b/assets/voices/andrea-a0ac302eb3.mp3 new file mode 100644 index 0000000..b48dde2 Binary files /dev/null and b/assets/voices/andrea-a0ac302eb3.mp3 differ diff --git a/assets/voices/andrea-a85f0e0a9a.mp3 b/assets/voices/andrea-a85f0e0a9a.mp3 new file mode 100644 index 0000000..02cad60 Binary files /dev/null and b/assets/voices/andrea-a85f0e0a9a.mp3 differ diff --git a/assets/voices/andrea-bc7d5f4dc0.mp3 b/assets/voices/andrea-bc7d5f4dc0.mp3 new file mode 100644 index 0000000..aa03553 Binary files /dev/null and b/assets/voices/andrea-bc7d5f4dc0.mp3 differ diff --git a/assets/voices/baldoria-0accd6c002.mp3 b/assets/voices/baldoria-0accd6c002.mp3 new file mode 100644 index 0000000..ba9ead2 Binary files /dev/null and b/assets/voices/baldoria-0accd6c002.mp3 differ diff --git a/assets/voices/baldoria-1eb6f19203.mp3 b/assets/voices/baldoria-1eb6f19203.mp3 new file mode 100644 index 0000000..b759cc8 Binary files /dev/null and b/assets/voices/baldoria-1eb6f19203.mp3 differ diff --git a/assets/voices/baldoria-954f26beb7.mp3 b/assets/voices/baldoria-954f26beb7.mp3 new file mode 100644 index 0000000..e66e0e6 Binary files /dev/null and b/assets/voices/baldoria-954f26beb7.mp3 differ diff --git a/assets/voices/baldoria-97a6835622.mp3 b/assets/voices/baldoria-97a6835622.mp3 new file mode 100644 index 0000000..ba40803 Binary files /dev/null and b/assets/voices/baldoria-97a6835622.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 970d45e..f3fc2de 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -119,6 +119,10 @@ "file": "andrea-559fbc00c4.mp3", "sec": 4.2 }, + "andrea|Lo lascio dov'è: è l'unica cosa che ricorda luglio, qui, a maggio.": { + "file": "andrea-9f315e91e5.mp3", + "sec": 4.4 + }, "andrea|I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.": { "file": "andrea-af4d717814.mp3", "sec": 6.44 @@ -1095,6 +1099,14 @@ "file": "barondeduke-238f525535.mp3", "sec": 1.65 }, + "andrea|Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.": { + "file": "andrea-a85f0e0a9a.mp3", + "sec": 6.47 + }, + "andrea|Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.": { + "file": "andrea-4faef889c8.mp3", + "sec": 5.63 + }, "andrea|La tribuna bianca e rossa. La domenica qui c'è più tifo che al Palio. Quasi.": { "file": "andrea-a50071085e.mp3", "sec": 6.84 @@ -2827,6 +2839,38 @@ "file": "pierino_parigi-8104163151.mp3", "sec": 3.3 }, + "andrea|Baldoria! Ma che ci fai col bandierone al campo?": { + "file": "andrea-3433db09f3.mp3", + "sec": 3.79 + }, + "andrea|Guarda che qui la Sampdoria non ci gioca.": { + "file": "andrea-408197f33d.mp3", + "sec": 3.85 + }, + "andrea|Hai visto passare il palio, per caso?": { + "file": "andrea-a0ac302eb3.mp3", + "sec": 1.76 + }, + "andrea|Ciao, Baldoria.": { + "file": "andrea-6697e2ce25.mp3", + "sec": 0.97 + }, + "baldoria|Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.": { + "file": "baldoria-954f26beb7.mp3", + "sec": 6.08 + }, + "baldoria|Forza Doria!": { + "file": "baldoria-97a6835622.mp3", + "sec": 1.03 + }, + "baldoria|La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.": { + "file": "baldoria-1eb6f19203.mp3", + "sec": 5.54 + }, + "baldoria|L'unico palio che conosco è blu, bianco, rosso e nero. E ce l'ho in mano.": { + "file": "baldoria-0accd6c002.mp3", + "sec": 6.1 + }, "andrea|Taglia! Si fa una gara con la Condor?": { "file": "andrea-99f683209e.mp3", "sec": 3.41 @@ -3387,6 +3431,22 @@ "file": "andrea-356a1afe8d.mp3", "sec": 5.91 }, + "andrea|Il manifesto dell'Ingorgo Sonoro dell'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.": { + "file": "andrea-0c26e34815.mp3", + "sec": 7.53 + }, + "andrea|Quanto darei per essere all'Ingorgo stasera?!?": { + "file": "andrea-366c066d68.mp3", + "sec": 2.28 + }, + "andrea|Ma... è l'Ingorgo! Le console, la gente, le spine della birra!": { + "file": "andrea-bc7d5f4dc0.mp3", + "sec": 7.5 + }, + "andrea|Il palio può aspettare una sera. O no?": { + "file": "andrea-24e5ac53da.mp3", + "sec": 3.07 + }, "chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": { "file": "chiaru-91a4b23b22.mp3", "sec": 4.44 diff --git a/docs/IMMAGINI.md b/docs/IMMAGINI.md index 535fbdd..a879a10 100644 --- a/docs/IMMAGINI.md +++ b/docs/IMMAGINI.md @@ -39,6 +39,8 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p | `closeups/macchina-dietro.png`, `macchina-lampi.png`, `macchina-davanti.png` | Codex, dallo sprite della macchina del tempo | nessuna | — | sì | | `closeups/campone-condor.png` | Codex, dallo sfondo del campone delle poste | nessuna | — | sì | | `closeups/campone-campana.png` | Codex, solo testo (stile dal campone delle poste) | nessuna | — | sì | +| `closeups/manifesto-ingorgo.png` | Codex, dal manifesto vero (2014) senza sponsor, loghi e data | `assets/raw/riferimenti/manifesto-ingorgo-2014.png` | Pro Loco San Piero a Sieve (manifesto), Andrea | sì | +| `ingorgo-piazza.png`, `ingorgo-antica-posta.png`, `ingorgo-piazzetta-comune.png`, `ingorgo-antica-posta-alta.png`, `ingorgo-piazzetta.png` | Codex, dagli sfondi di giorno delle stesse scene | foto della festa di Andrea (`assets/raw/foto/ingorgo-*.png`), solo per l'atmosfera | Andrea | sì | | `barrettino-1997.png`, `closeups/cruscotto-tempo.png` | Codex, solo testo | nessuna | — | sì | | `characters/vannino.png`, `characters/pierino_parigi.png` | Codex, dalla descrizione di Andrea (niente foto) | nessuna | — | sì (Pierino: consenso del figlio Marco) | | `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita | diff --git a/docs/STATO.md b/docs/STATO.md index 79e9e69..24ba21a 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -18,6 +18,23 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, ## Dove siamo arrivati +- **L'Ingorgo Sonoro (30 ottobre, extra)**: in piazza il vecchio manifesto dell'Ingorgo (`manifesto_ingorgo`); guardandolo + si apre ingrandito (primo piano `poster`, `manifesto-ingorgo.png`: Codex dal manifesto vero del 2014, senza sponsor né + data); toccandolo, «Quanto darei per essere all'Ingorgo stasera?!?», poi l'effetto speciale (primo piano `incanto`, `incanto.js`: fasci di + 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 + 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 + `assets/raw/ingorgo-v1/`. La voce legge «console» come «consòl» (`tools/pronuncia.json`). + [DA ANDREA] i generi veri per piazza, le battute, i bar a tema; da fare: spillare la birra (minigioco) e bere. +- **Baldoria al campo sportivo (30 ottobre)**: Yuri Baldi, dei Ringraziamenti, al Campo principale con la maglia + blucerchiata e un bandierone della Sampdoria grande come lui, che ogni tanto sventola (`flourish` delle persone). + Sprite di Codex (`assets/raw/baldoria-sheet.png`; `make character … FIX=wave=1.2,…` perché Codex ha disegnato più + piccole le figure col bandierone in alto). Un dialogo breve. [DA ANDREA: le battute; la somiglianza va rivista con + una sua foto] - **La campana al campone delle poste (30 ottobre)**: dal Taglia, «E una partita a campana, Taglia?», poi il livello. Primo piano `campana` visto dall'alto sul vialetto (`campone-campana.png`, Codex); la campana col gesso la disegna il codice (`campana.js`, `campana-render.js`): 1-2-3, coppia 4-5, 6, coppia 7-8 e in cima il mezzo cerchio con scritto diff --git a/package.json b/package.json index b1b0070..5e703b3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "browser:cerbottana": "node tests/cerbottana.browser.mjs", "browser:condor": "node tests/condor.browser.mjs", "browser:campana": "node tests/campana.browser.mjs", + "browser:ingorgo": "node tests/ingorgo.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs", "browser:scopa": "node tests/scopa.browser.mjs", "browser:briscola": "node tests/briscola.browser.mjs", diff --git a/src/engine/game.js b/src/engine/game.js index e5d7d0a..b8a7b25 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -13,6 +13,7 @@ import * as gara from './gara.js'; import * as micro from './micro.js'; import * as cerbottana from './cerbottana.js'; import { newCorsa, updateCorsa } from './corsa.js'; +import { newIncanto, updateIncanto } from './incanto.js'; import * as condor from './condor.js'; import * as campana from './campana.js'; import * as poker from './poker.js'; @@ -148,6 +149,9 @@ export function restoreState(story, data) { return { scene: data.scene, x, y, facing: data.facing === -1 ? -1 : 1, inventory, flags: pick(data.flags), used: pick(data.used), ended: data.ended === true }; } +// Il manifesto ingrandito si apre con uno zoom: un tocco prima che sia aperto non conta. +export const POSTER_READY = 0.35; + export class Game { // voiceTime(who, text): secondi della voce registrata per la battuta, se c'è (il fumetto non finisce prima). constructor(story, { state, onSave, voiceTime } = {}) { @@ -295,6 +299,13 @@ export class Game { closeupTap() { const c = this.closeup; if (!c || this.speech) return false; + // Un manifesto ingrandito (type 'poster'): toccarlo lo chiude e fa partire `tap` (il desiderio dell'Ingorgo). + if (this.story.closeups[c.id].type === 'poster') { + if (c.t < POSTER_READY) return false; + this.closeup = null; + this.queue.unshift(...(this.story.closeups[c.id].tap ?? [])); + return true; + } if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 }); else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 }); @@ -329,6 +340,12 @@ export class Game { } // Il viaggio si chiude da solo alla fine (o uscendo prima): si arriva comunque. if (def?.type === 'viaggio' || def?.type === 'corsa') { this.queue.unshift(...def.complete); return true; } + // L'incantesimo, chiuso prima del lampo, fa comunque il suo cambio. + if (def?.type === 'incanto') { + if (!c.swapped) for (const e of def.swap ?? []) this.run(e); + this.queue.unshift(...(def.complete ?? [])); + return true; + } // I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta // di chi ha vinto; uscendo prima, quella di chi lascia. if (MINIGAMES[def?.type]) { @@ -430,15 +447,23 @@ export class Game { const talk = p.talk ?? ['talk']; return talk[Math.floor(this.time / 0.3) % talk.length]; } + // `flourish`: ogni tanto un gesto lungo a più fotogrammi (Baldoria che sventola il bandierone). + const f = p.flourish; + if (f) { + const ft = (this.time + (p.offset ?? 0)) % f.every; + if (ft < f.for) return f.poses[Math.floor(ft / (f.step ?? 0.35)) % f.poses.length]; + } const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every; return p.blink && t > every - 0.25 ? p.blink : idle; } + // La grandezza a quella altezza della scena; una fase può ingrandire tutti (`scale`: alla notte dell'Ingorgo la folla + // dipinta è grande, e Andrea ci si perdeva in mezzo). scaleAt(y) { - const d = this.scene.depth; - if (!d) return 1; + const d = this.scene.depth, k = this.phase()?.scale ?? 1; + if (!d) return k; const [[y0, s0], [y1, s1]] = d, t = Math.max(0, Math.min(1, (y - y0) / (y1 - y0))); - return s0 + (s1 - s0) * t; + return (s0 + (s1 - s0) * t) * k; } speakerPoint(who) { @@ -490,6 +515,12 @@ export class Game { if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; } if (this.story.closeups[c.id].type === 'viaggio') { this.updateTrip(dt); return; } if (this.story.closeups[c.id].type === 'corsa') { if (updateCorsa(c, this.story.closeups[c.id], dt)) this.closeupClose(); return; } + if (this.story.closeups[c.id].type === 'incanto') { + const now = updateIncanto(c, dt); + if (now === 'swap') for (const e of this.story.closeups[c.id].swap ?? []) this.run(e); + if (now === 'end') this.closeupClose(); + return; + } const m = this.minigame(); if (m) { m.update(c, dt); @@ -533,6 +564,8 @@ export class Game { this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] } : type === 'viaggio' ? { id: e.closeup, t: 0, age: 0, events: [], typed: 0 } : type === 'corsa' ? newCorsa(e.closeup) + : type === 'incanto' ? newIncanto(e.closeup) + : type === 'poster' ? { id: e.closeup, step: 'poster', t: 0, age: 0 } // `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario. // `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane). : MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level, @@ -586,6 +619,13 @@ export class Game { this.back = false; this.state.flags['visto:' + scene] = true; // per la mappa this.queue.unshift(...(this.scene.enter ?? [])); + // Le zone (story.zones): un flag che vale solo in certi posti, come la festa dell'Ingorgo. Uscendo (a piedi o + // con la mappa) il flag si spegne e partono le battute del risveglio. + for (const z of this.story.zones ?? []) { + if (!this.state.flags[z.flag] || z.scenes.includes(scene)) continue; + delete this.state.flags[z.flag]; + this.queue.unshift(...(z.leave ?? [])); + } this.dirty = true; // si salva quando l'ingresso nella scena è finito } } diff --git a/src/engine/incanto.js b/src/engine/incanto.js new file mode 100644 index 0000000..d252346 --- /dev/null +++ b/src/engine/incanto.js @@ -0,0 +1,83 @@ +// L'incantesimo (story.closeups, type 'incanto'; Andrea, 30 ottobre: «Quanto darei per essere all'Ingorgo stasera?!? +// Effetto speciale e compare»). Sopra la scena di adesso, con Andrea dentro: i fasci delle luci da palco che girano +// sempre più svelti, i colpi di cassa che fanno pulsare l'aria, il lampo bianco; nel bianco cambia tutto (`swap`, di +// solito il flag della festa: lo sfondo diventa quello della notte), poi il bianco sfuma e piovono i coriandoli. +// Unità: i pixel della scena 640×360. + +// I tempi, in secondi: i fasci, il lampo che sale, il cambio (a bianco pieno), il bianco che sfuma, la fine. +export const T = { beams: 0.2, flash: 1.9, swap: 2.2, clear: 3.0, end: 3.8 }; +const COLORS = ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255']; +const clamp01 = k => Math.max(0, Math.min(1, k)); +// Un numero a caso ma sempre lo stesso per lo stesso indice: i coriandoli non tremano da un fotogramma all'altro. +const hash = n => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }; + +export function newIncanto(id) { return { id, t: 0, age: 0, events: [], beats: 0, swapped: false }; } + +// Il tempo che passa e i suoni (un colpo di cassa a ogni battito, sempre più fitti; il lampo). Torna 'swap' nel +// momento del cambio, 'end' alla fine. +export function updateIncanto(c, dt) { + c.t += dt; + const beats = Math.floor(beatAt(Math.min(c.t, T.flash))); + for (; c.beats < beats; c.beats++) c.events.push({ kind: 'incanto-cassa', power: 0.5 + 0.5 * c.beats / 8 }); + if (!c.boom && c.t >= T.flash) { c.boom = true; c.events.push({ kind: 'incanto-lampo', power: 1 }); } + if (!c.swapped && c.t >= T.swap) { c.swapped = true; return 'swap'; } + return c.t >= T.end ? 'end' : null; +} +// Quanti battiti sono passati: accelerano, da uno ogni mezzo secondo a uno ogni decimo. +export const beatAt = t => t <= 0 ? 0 : 2 * t + 1.6 * t * t; + +// Quanto è bianco lo schermo: sale fino al cambio, resta pieno un attimo, poi sfuma. +export function whiteness(t) { + if (t < T.flash) return 0; + if (t < T.swap) return clamp01((t - T.flash) / (T.swap - T.flash)); + return clamp01(1 - (t - T.swap) / (T.clear - T.swap)); +} + +export function drawIncanto(ctx, c) { + const t = c.t, before = t < T.swap; + ctx.save(); + // Il colpo di cassa: un velo scuro che si apre e si chiude, l'aria che pulsa. + if (before) { + const b = beatAt(t), pulse = 1 - (b - Math.floor(b)); + ctx.fillStyle = `rgba(10,0,30,${0.25 * clamp01(t / 0.6) * pulse})`; + ctx.fillRect(0, 0, 640, 360); + } + // I fasci: partono da sotto, ai lati, e spazzano il cielo; più passa il tempo, più sono e più girano svelti. + const strength = before ? clamp01((t - T.beams) / 0.5) : clamp01(1 - (t - T.swap) / 0.6); + if (strength > 0) { + ctx.globalCompositeOperation = 'lighter'; + const n = 2 + Math.min(6, Math.floor(t * 3)); + for (let i = 0; i < n; i++) { + const from = [i % 2 ? 560 : 80, 372], speed = 0.9 + i * 0.35 + t * 0.9; + const a = -Math.PI / 2 + Math.sin(t * speed + i * 1.7) * 0.9, len = 520, half = 0.07; + ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.22 * strength})`; + ctx.beginPath(); + ctx.moveTo(...from); + ctx.lineTo(from[0] + Math.cos(a - half) * len, from[1] + Math.sin(a - half) * len); + ctx.lineTo(from[0] + Math.cos(a + half) * len, from[1] + Math.sin(a + half) * len); + ctx.closePath(); + ctx.fill(); + } + // Le scintille che salgono da terra, a quadratini. + for (let i = 0; i < 40; i++) { + const life = (t * (0.5 + hash(i)) + hash(i + 50)) % 1, x = 20 + hash(i + 100) * 600, y = 360 - life * 300; + ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.9 * strength * (1 - life)})`; + ctx.fillRect(Math.round(x), Math.round(y), 2, 2); + } + ctx.globalCompositeOperation = 'source-over'; + } + // Il lampo. + const white = whiteness(t); + if (white > 0) { ctx.fillStyle = `rgba(255,255,250,${white})`; ctx.fillRect(0, 0, 640, 360); } + // Dopo il cambio, i coriandoli che cadono sulla festa. + if (!before) { + const k = (t - T.swap) / (T.end - T.swap); + for (let i = 0; i < 70; i++) { + const x = (hash(i) * 640 + Math.sin(t * 3 + i) * 8) % 640, y = -10 + (hash(i + 7) * 120 + k * 420 * (0.6 + hash(i + 3) * 0.6)); + if (y > 360) continue; + ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${clamp01(1.3 - k)})`; + ctx.fillRect(Math.round(x), Math.round(y), 3, 2); + } + } + ctx.restore(); +} diff --git a/src/engine/music.js b/src/engine/music.js index 221e83e..bcbbbca 100644 --- a/src/engine/music.js +++ b/src/engine/music.js @@ -9,22 +9,39 @@ import { versioned } from '../version.js'; // Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo); // altrimenti quello generale del paese. // Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto. -export function trackFor(story, { screen, scene, phase, closeup }) { +// Una scena con le console accese (`stages`, ognuna col suo `when`) non ha un brano solo ma una miscela: ogni console +// suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro: +// è la musica della piazza accanto, che arriva da lontano. +// `ok(cond)` dice se una condizione della storia vale adesso (game.js, check). +export function trackFor(story, { screen, scene, phase, closeup, at, ok = () => false }) { const m = story.music ?? {}; if (screen === 'splash') return null; if (screen === 'title' || screen === 'intro') return m.title ?? null; if (screen === 'ending') return m.ending ?? m.game ?? null; if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music; const sc = story.scenes[scene]; + const stages = (sc?.stages ?? []).filter(st => ok(st.when)); + if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]); return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null; } +// Il volume di ogni console: pieno fino a `near` pixel, poi cala fino a `far` (non sotto un filo, `floor`: la festa +// si sente sempre). La più forte per prima. +export function mix(stages, [x, y]) { + return stages.map(st => { + const d = Math.hypot(x - st.at[0], (y - st.at[1]) * 1.5), near = st.near ?? 90, far = st.far ?? 520; + const k = Math.max(0, Math.min(1, (d - near) / (far - near))); + return { src: st.music, gain: Math.max(st.floor ?? 0.08, (1 - k) ** 1.6) }; + }).sort((a, b) => b.gain - a.gain); +} + const FADE = 1.2; -// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta. -function setLevel(a) { +// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta. Lì, nella +// miscela delle console, suona solo la più forte (`alone`): le altre a volume pieno coprirebbero tutto. +function setLevel(a, alone = false) { a.volume = a.level; - a.muted = a.level === 0; + a.muted = a.level === 0 || (alone && a.level < 1 && Math.abs(a.volume - a.level) > 0.01); } export class Music { @@ -33,40 +50,46 @@ export class Music { this.volume = volume; this.blocked = null; this.want = null; - this.current = null; // { src, audio } - this.old = []; + this.tracks = new Map(); // src -> { src, audio, gain }: quelli voluti e quelli che sfumano + this.wanted = []; // i src voluti adesso, il più forte per primo this.duck = 1; // 1 = pieno; meno di 1 mentre parla qualcuno this.hold = false; // in pausa: il brano sfuma e si ferma, poi riprende da dov'era this.away = false; // pagina nascosta (sul telefono si è passati a un'altra app): tutto fermo subito } + // Il brano principale (il più forte) e quelli che stanno sfumando. + get current() { return this.tracks.get(this.wanted[0]) ?? null; } + get old() { return [...this.tracks.values()].filter(t => !this.wanted.includes(t.src)).map(t => t.audio); } + // Nascosta la pagina il browser non chiama più update (niente fotogrammi): i brani si fermano qui, di colpo. // Tornati, update li fa ripartire da dov'erano (se non c'è la pausa). setAway(away) { this.away = away; - if (away) for (const a of [this.current?.audio, ...this.old]) a?.pause(); + if (away) for (const t of this.tracks.values()) t.audio.pause(); } - // Chiamata a ogni fotogramma con il brano voluto: avvia, cambia e sfuma. - update(src, dt) { - this.want = src; - if (src !== this.current?.src) { - if (this.current) this.old.push(this.current.audio); - this.current = src ? { src, audio: this.start(src) } : null; + // Chiamata a ogni fotogramma con il brano voluto (o la miscela delle console, [{ src, gain }]): avvia, cambia e sfuma. + update(want, dt) { + this.want = want; + const list = want == null ? [] : typeof want === 'string' ? [{ src: want, gain: 1 }] : want; + this.wanted = list.map(w => w.src); + for (const w of list) { + const t = this.tracks.get(w.src) ?? { src: w.src, audio: this.start(w.src) }; + t.gain = w.gain; + this.tracks.set(w.src, t); } - // Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero, e lì si fermano. + // Ogni brano voluto sale (o scende) verso il suo volume; quelli lasciati sfumano fino a zero, e lì si fermano. // Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e // resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese). - const step = dt / FADE, target = this.muted || this.hold ? 0 : this.volume * this.duck; - if (this.current) { - const a = this.current.audio; + const step = dt / FADE, full = this.muted || this.hold ? 0 : this.volume * this.duck; + for (const t of [...this.tracks.values()]) { + const a = t.audio, on = this.wanted.includes(t.src), target = on ? full * t.gain : 0; a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step); - setLevel(a); + setLevel(a, list.length > 1 && t.src !== this.wanted[0]); + if (!on) { if (a.level === 0) { a.pause(); this.tracks.delete(t.src); } continue; } if (this.hold && a.level === 0 && !a.paused) a.pause(); else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; }); } - for (const a of this.old) { a.level = Math.max(0, a.level - step); setLevel(a); } - this.old = this.old.filter(a => a.level > 0 || (a.pause(), false)); } start(src) { @@ -81,7 +104,7 @@ export class Music { unlock() { const a = this.blocked; this.blocked = null; - if (a && a === this.current?.audio) a.play().catch(() => {}); + if (a && [...this.tracks.values()].some(t => t.audio === a && this.wanted.includes(t.src))) a.play().catch(() => {}); } toggle() { diff --git a/src/engine/render.js b/src/engine/render.js index 164566c..b527b1c 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -1,6 +1,6 @@ // Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura. // Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art. -import { check, PAGE_TURN } from './game.js'; +import { check, PAGE_TURN, POSTER_READY } from './game.js'; import { ambientEffects, ambientHash } from './ambient.js'; import { drawTorch } from './torch.js'; import { drawBocce } from './bocce-render.js'; @@ -16,6 +16,7 @@ import { drawCondor } from './condor-render.js'; import { drawCampana } from './campana-render.js'; import { drawPoker } from './poker-render.js'; import { drawViaggio } from './viaggio.js'; +import { drawIncanto } from './incanto.js'; import { drawBriscola } from './briscola-render.js'; import { drawTressette } from './tressette-render.js'; import { drawScopa } from './scopa-render.js'; @@ -131,13 +132,15 @@ export async function loadAssets(story, first = story.start.scene, { together = } // Gli sfondi in ordine di distanza dalla scena di partenza, seguendo le uscite; in fondo quelle non raggiungibili. +// Prima tutti gli sfondi veri, poi le varianti (`backdrops`, come la notte dell'Ingorgo): quando servono prima, la +// scena se le carica da sé. export function backgroundOrder(story, first) { const seen = new Set([first]), queue = [first]; for (let i = 0; i < queue.length; i++) for (const h of story.scenes[queue[i]]?.hotspots ?? []) if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); } for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id); - return [...new Set(queue.flatMap(id => [story.scenes[id].background, ...(story.scenes[id].backdrops ?? []).map(b => b.src)]).filter(Boolean))]; + return [...new Set([...queue.map(id => story.scenes[id].background), ...queue.flatMap(id => (story.scenes[id].backdrops ?? []).map(b => b.src))].filter(Boolean))]; } // Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia. @@ -698,6 +701,29 @@ function drawCloseup(ctx, game, time) { drawCorsa(ctx, c, def, { bg: backgrounds.get(bg), frames, tint: game.phase()?.tint }); return; } + // L'incantesimo va sopra la scena (con Andrea dentro), già disegnata. + if (def.type === 'incanto') { drawIncanto(ctx, c); return; } + // Il manifesto ingrandito: la scena si scurisce e lui viene avanti, poi l'invito a toccarlo. + if (def.type === 'poster') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + const img = backgrounds.get(def.picture), k = Math.min(1, c.t / POSTER_READY), ease = 1 - (1 - k) ** 3; + ctx.fillStyle = `rgba(8, 4, 16, ${0.75 * ease})`; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + if (img) { + const fit = Math.min(600 / img.width, 300 / img.height) * (0.55 + 0.45 * ease), w = img.width * fit, h = img.height * fit; + ctx.globalAlpha = ease; + ctx.drawImage(img, Math.round((WIDTH - w) / 2), Math.round((HEIGHT - 26 - h) / 2), Math.round(w), Math.round(h)); + ctx.globalAlpha = 1; + } + if (k >= 1 && Math.floor(time / 600) % 2 === 0) { + ctx.font = '8px "Press Start 2P", monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#ffe9b0'; + ctx.fillText(def.hint ?? 'Tocca il manifesto', WIDTH / 2, HEIGHT - 12); + ctx.textAlign = 'left'; + } + return; + } if (def.type === 'torch') { if (!backgrounds.has(def.picture)) loadBackground(def.picture); drawTorch(ctx, c, def, backgrounds.get(def.picture)); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 6621add..61de0f8 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -148,6 +148,9 @@ export class Soundscape { // La macchina del tempo: il tuono del fulmine e lo sfrigolio delle sgommate di fuoco. else if (kind === 'corsa-tuono') { this.noiseSound(0.9, 400, 0.16, { pan }); this.tone(90, 40, 0.8, 0.1, 0, pan); } else if (kind === 'corsa-fuoco') this.noiseSound(1.6, 2400, 0.05, { pan }); + // L'incantesimo dell'Ingorgo: i colpi di cassa che accelerano, poi il lampo con lo spazzolone del piatto. + else if (kind === 'incanto-cassa') this.tone(150, 45, 0.22, 0.12 + strength * 0.1, 0, pan); + else if (kind === 'incanto-lampo') { this.noiseSound(1.4, 5200, 0.12, { pan }); this.tone(60, 35, 1.2, 0.16, 0, pan); this.tone(880, 1760, 0.6, 0.05, 0.05, pan); } // La campana: il sassolino che vola e batte sull'asfalto, il salto, il piede giù. else if (kind === 'campana-lancio') this.noiseSound(0.1, 1500, 0.025, { pan }); else if (kind === 'campana-sasso') { this.tone(1900, 1100, 0.04, 0.05 * strength + 0.02, 0, pan); this.tone(1500, 900, 0.03, 0.03, 0.06, pan); this.noiseSound(0.04, 3000, 0.03, { pan }); } diff --git a/src/main.js b/src/main.js index f692513..3641c25 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ // Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti). import { story } from './story/story.js'; -import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js'; +import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js'; import { Command } from './engine/command.js'; import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js'; import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js'; @@ -1127,7 +1127,8 @@ function frame(now) { // Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa. const searching = game?.closeup?.id === 'fessura'; music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1; - music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt); + music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id, + at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), dt); // Un minuto senza toccare niente, a gioco fermo: pausa da sola. if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause(); ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 }); diff --git a/src/story/story.js b/src/story/story.js index ba1a853..604cec4 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -72,6 +72,21 @@ export const story = { { id: 'pomeriggio' }, { id: 'tramonto', when: 'indizio_regolo', tint: 'rgba(255, 110, 40, 0.16)' }, { id: 'sera', when: 'casetta_aperta', tint: 'rgba(24, 28, 90, 0.42)', music: 'assets/audio/sera.mp3' }, + // La notte dell'Ingorgo Sonoro (Andrea, 30 ottobre): gli sfondi sono già di notte, il velo serve solo ad Andrea. + // La musica la fanno le console delle scene (`stages`). + { id: 'ingorgo', when: 'ingorgo', tint: 'rgba(40, 20, 90, 0.16)', scale: 1.6 }, + ], + // Le zone: un flag che vale solo in certi posti. L'Ingorgo Sonoro (Andrea, 30 ottobre: «era la festa del paese + // fatta una delle prime sere di luglio, da piazza fino a Cambray Digny, compresa piazza del Comune») si accende + // col manifesto in piazza e si spegne uscendo dal centro. [DA ANDREA: le battute sono provvisorie] + zones: [ + { + flag: 'ingorgo', scenes: ['piazza', 'via_antica_posta', 'piazzetta_comune', 'antica_posta_alta', 'piazzetta'], + leave: [ + { say: 'Mi stropiccio gli occhi. La musica non c\'è più: è di nuovo maggio.' }, + { say: 'E il palio è sempre sparito. Però che serata.' }, + ], + }, ], // Il taccuino (pulsante «Taccuino», tasto N): le tappe del capitolo in ordine. Una tappa è fatta quando la sua // condizione `done` è vera; la prima non fatta è l'obiettivo di adesso. Solo testo: non si sente a voce. @@ -312,6 +327,27 @@ export const story = { draw: [], quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }], }, + // Il manifesto dell'Ingorgo, ingrandito (Andrea, 30 ottobre: «se mi avvicino compaia ingrandito, e poi parta + // l'Ingorgo se ci clicco»). [DA ANDREA: il manifesto vero; questo è di Codex, provvisorio] + manifesto_ingorgo: { + type: 'poster', + picture: 'assets/closeups/manifesto-ingorgo.png', + hint: 'Tocca il manifesto', + tap: [ + { say: 'Il manifesto dell\'Ingorgo Sonoro dell\'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.' }, + { say: 'Quanto darei per essere all\'Ingorgo stasera?!?' }, + { closeup: 'ingorgo' }, + ], + }, + // L'incantesimo dell'Ingorgo Sonoro, dal manifesto in piazza: luci, cassa, lampo, ed è la notte della festa. + ingorgo: { + type: 'incanto', + swap: [{ set: 'ingorgo' }], + complete: [ + { say: 'Ma... è l\'Ingorgo! Le console, la gente, le spine della birra!' }, + { say: 'Il palio può aspettare una sera. O no?' }, + ], + }, // Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre). // [DA ANDREA: le battute sono provvisorie] micro: { @@ -402,6 +438,8 @@ export const story = { // Andrea Tagliaferri, il Taglia (consenso suo, 29 ottobre): sprite di Codex dalla sua foto. taglia: { name: 'Il Taglia', color: '#ffe36a', sprite: 'assets/characters/taglia.json' }, barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' }, + // Baldoria (Yuri Baldi, nei Ringraziamenti), tifoso della Sampdoria col bandierone (Andrea, 30 ottobre; sprite di Codex). + baldoria: { name: 'Baldoria', color: '#7fb2ff', sprite: 'assets/characters/baldoria.json' }, mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' }, // Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea. fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' }, @@ -543,15 +581,22 @@ export const story = { name: 'Piazza Colonna', map: [32, 45], background: 'assets/scenes/piazza-colonna.png', - effects: [{ type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true }, { 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] }], + backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazza.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 grande davanti al palazzo; da Via dell'Antica Posta, a sinistra, arriva il latino del Comune. + stages: [ + { 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] }], 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], speakers: { vecchietta: [24, 250], vecchietto: [56, 244] }, // I vecchietti seduti sulla panchina: fotogrammi in assets/characters (pose e battute in game.personPose). people: [ - { who: 'vecchietta', at: [24, 317], size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 }, - { who: 'vecchietto', at: [56, 304], size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 }, + { who: 'vecchietta', at: [24, 317], when: '!ingorgo', size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 }, + { who: 'vecchietto', at: [56, 304], when: '!ingorgo', size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 }, ], hotspots: [ { @@ -614,7 +659,7 @@ export const story = { exit: { scene: 'circolo_4_maggio', at: [590, 202], face: 'left' }, }, { - id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up', + id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up', when: '!ingorgo', art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }], actions: { look: [ @@ -625,7 +670,20 @@ export const story = { }, }, { - id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk', + // Il manifesto dell'Ingorgo Sonoro dell'estate prima, mezzo strappato: guardandolo, Andrea esprime il + // desiderio, e la festa arriva (Andrea, 30 ottobre). [DA ANDREA: le battute sono provvisorie] + id: 'manifesto_ingorgo', name: 'vecchio manifesto dell\'Ingorgo Sonoro', box: [226, 198, 16, 22], at: [234, 250], face: 'up', when: '!ingorgo', + // Come quello vero (assets/raw/riferimenti/manifesto-ingorgo-2014.png): blu, il cartello bianco e rosso col DJ. + art: [{ rect: [226, 198, 16, 22], fill: '#1c2050' }, { rect: [233, 201, 2, 2], fill: '#d8262e' }, { rect: [231, 203, 6, 2], fill: '#d8262e' }, + { rect: [229, 205, 10, 4], fill: '#d8262e' }, { rect: [232, 205, 4, 3], fill: '#f4f1e8' }, { rect: [228, 211, 12, 2], fill: '#f4f1e8' }, + { rect: [229, 215, 7, 2], fill: '#ffd23a' }, { rect: [238, 198, 4, 4], fill: '#cdbb9a' }], + actions: { + look: [{ closeup: 'manifesto_ingorgo' }], + take: [{ say: 'Lo lascio dov\'è: è l\'unica cosa che ricorda luglio, qui, a maggio.' }], + }, + }, + { + id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk', when: '!ingorgo', actions: { look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }], talk: [ @@ -1563,6 +1621,13 @@ export const story = { name: 'Piazzetta Cambray Digny', map: [40, 38], background: 'assets/scenes/piazzetta-cambray-digny.png', + 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. + 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 }, + ], walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]], depth: [[268, 0.55], [360, 1.15]], entry: [320, 330], @@ -1600,6 +1665,14 @@ export const story = { name: 'Via dell\'Antica Posta', map: [35, 41], background: 'assets/scenes/via-antica-posta.png', + 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. + stages: [ + { when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-110, 250] }, + { when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [320, 560] }, + { when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [384, 40], far: 600 }, + ], walk: [[0, 360], [0, 300], [120, 290], [220, 252], [300, 240], [372, 226], [396, 226], [470, 252], [630, 300], [640, 360]], depth: [[226, 0.35], [360, 1.15]], entry: [320, 345], @@ -1639,6 +1712,13 @@ export const story = { name: 'Piazzetta del Comune', map: [32, 39], background: 'assets/scenes/piazzetta-comune.png', + 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. + 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 }, + ], walk: [[0, 360], [0, 318], [130, 292], [470, 288], [540, 300], [520, 360]], depth: [[288, 0.55], [360, 1.15]], entry: [260, 350], @@ -1686,6 +1766,14 @@ export const story = { name: 'In cima a Via dell\'Antica Posta', map: [38, 39], background: 'assets/scenes/via-antica-posta-alta.png', + 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. + 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 }, + { when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [240, 640], far: 750 }, + ], walk: [[0, 360], [0, 322], [190, 226], [283, 222], [470, 360]], depth: [[222, 0.35], [360, 1.15]], entry: [240, 350], @@ -1998,7 +2086,19 @@ export const story = { walk: [[0, 298], [640, 298], [640, 360], [0, 360]], depth: [[298, 1.0], [360, 1.2]], entry: [40, 330], + // Baldoria sotto la tribuna col bandierone della Sampdoria: ogni tanto lo sventola (Andrea, 30 ottobre). + speakers: { baldoria: [490, 196] }, + people: [{ who: 'baldoria', at: [490, 318], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.3, + flourish: { poses: ['wave', 'wave2'], every: 9, for: 2.1, step: 0.35 } }], hotspots: [ + { + id: 'baldoria', name: 'Baldoria', box: [462, 200, 56, 124], at: [430, 326], face: 'right', default: 'talk', + actions: { + look: [{ say: 'Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.' }], + talk: [{ dialogue: 'baldoria' }], + take: [{ say: 'Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.' }], + }, + }, { id: 'tribuna', name: 'tribuna', box: [254, 172, 116, 40], at: [310, 320], face: 'up', actions: { @@ -3974,6 +4074,26 @@ export const story = { }, }, // Il Taglia al campone delle poste, con la Condor (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie] + // Baldoria al campo sportivo. [DA ANDREA: le battute sono provvisorie] + baldoria: { + nodes: { + start: { + options: [ + { text: 'Baldoria! Ma che ci fai col bandierone al campo?', then: [ + { say: 'Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.', who: 'baldoria', pose: 'explain' }, + { say: 'Forza Doria!', who: 'baldoria', pose: 'wave' }, + ] }, + { text: 'Guarda che qui la Sampdoria non ci gioca.', then: [ + { say: 'La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.', who: 'baldoria', pose: 'cheer' }, + ] }, + { text: 'Hai visto passare il palio, per caso?', then: [ + { say: 'L\'unico palio che conosco è blu, bianco, rosso e nero. E ce l\'ho in mano.', who: 'baldoria', pose: 'laugh' }, + ] }, + { text: 'Ciao, Baldoria.', next: 'end' }, + ], + }, + }, + }, taglia: { nodes: { start: { diff --git a/tests/baldoria.test.mjs b/tests/baldoria.test.mjs new file mode 100644 index 0000000..17d4a98 --- /dev/null +++ b/tests/baldoria.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { Game } from '../src/engine/game.js'; +import { story } from '../src/story/story.js'; +import { settle } from './helpers.mjs'; + +test('Baldoria al campo principale: ogni tanto sventola il bandierone, sennò sta fermo', () => { + const g = new Game(story); g.go('polisportiva_principale'); settle(g); + const p = g.people().find(p => p.who === 'baldoria'); + assert.ok(p, 'Baldoria è in scena'); + const poses = new Set(); + for (let t = 0; t < 9; t += 0.05) { g.time = t; poses.add(g.personPose(p)); } + assert.ok(poses.has('wave') && poses.has('wave2') && poses.has('stand') && poses.has('blink')); + g.time = 3; assert.equal(g.personPose(p), 'stand', 'fuori dallo sventolio, fermo'); + const sheet = JSON.parse(readFileSync(new URL('../assets/characters/baldoria.json', import.meta.url))); + for (const f of ['stand', 'blink', 'talk', 'explain', 'wave', 'wave2', 'cheer', 'laugh']) assert.ok(sheet.frames[f], f); + assert.ok(existsSync(new URL('../assets/characters/baldoria.png', import.meta.url))); +}); + +test('si parla con Baldoria: le risposte e il saluto', () => { + const g = new Game(story); g.go('polisportiva_principale'); settle(g); + g.act('talk', 'baldoria'); settle(g); + assert.equal(g.dialogue?.id, 'baldoria'); + const said = []; + for (const text of ['Baldoria! Ma che ci fai col bandierone al campo?', 'Guarda che qui la Sampdoria non ci gioca.', 'Hai visto passare il palio, per caso?']) { + g.choose(g.options().findIndex(o => o.text === text)); + said.push(...settle(g).filter(s => s.who === 'baldoria').map(s => s.text)); + } + assert.ok(said.includes('Forza Doria!')); + assert.equal(said.length, 4); + g.choose(g.options().findIndex(o => o.text === 'Ciao, Baldoria.')); settle(g); + assert.equal(g.dialogue, null); +}); diff --git a/tests/ingorgo.browser.mjs b/tests/ingorgo.browser.mjs new file mode 100644 index 0000000..4dadd88 --- /dev/null +++ b/tests/ingorgo.browser.mjs @@ -0,0 +1,58 @@ +// L'Ingorgo Sonoro nel browser vero: il manifesto in piazza, l'effetto speciale, la festa nelle cinque scene con le +// console che si mescolano, e il ritorno a maggio uscendo dal centro. +// npm run browser:ingorgo — schermate in /tmp/regolo-ingorgo. +import { chromium } from 'playwright'; +import { mkdirSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { staticServer } from './helpers.mjs'; + +const out = process.env.OUT ?? '/tmp/regolo-ingorgo'; +mkdirSync(out, { recursive: true }); +const server = await staticServer(); +let browser; +const errors = []; +try { + browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); + const page = await browser.newPage({ viewport: { width: 1280, height: 860 } }); + page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); }); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro'); + await page.evaluate(async () => { + const { settle } = await import('/tests/helpers.mjs'); + window.settle = settle; + settle(game); game.go('piazza', [250, 300]); settle(game); + game.act('look', 'manifesto_ingorgo'); + }); + // Il manifesto si apre ingrandito; un clic sopra, e parte il desiderio. + await page.waitForFunction(() => game.closeup?.id === 'manifesto_ingorgo' && game.closeup.t > 0.6, null, { timeout: 15000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ c: game.closeup, sp: game.speech, q: game.queue, task: game.task, walking: game.walking, xy: [game.state.x, game.state.y] }))); throw e; }); + await page.waitForFunction(() => game.closeup.t > 1.4, null, { timeout: 5000 }); + await page.screenshot({ path: `${out}/0-manifesto.png` }); + const box = await page.locator('#scene').boundingBox(); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + // Le battute davanti al manifesto, poi l'effetto: a metà i fasci, poi il lampo, poi i coriandoli sulla festa. + await page.waitForFunction(() => game.speech?.text.startsWith('Quanto darei'), null, { timeout: 15000 }); + await page.screenshot({ path: `${out}/1-desiderio.png` }); + await page.waitForFunction(() => game.closeup?.id === 'ingorgo' && game.closeup.t > 1.2, null, { timeout: 15000 }); + await page.screenshot({ path: `${out}/2-fasci.png` }); + await page.waitForFunction(() => game.closeup?.t > 2.5, null, { timeout: 5000 }); + await page.screenshot({ path: `${out}/3-coriandoli.png` }); + await page.waitForFunction(() => !game.closeup && game.state.flags.ingorgo && game.speech?.text.includes('Ingorgo'), null, { timeout: 5000 }); + await page.screenshot({ path: `${out}/3b-festa.png` }); + await page.evaluate(() => settle(game)); + // La festa, scena per scena: lo sfondo di notte, e la musica di più console insieme. + for (const [scene, at] of [['piazza', [250, 300]], ['via_antica_posta', [300, 300]], ['piazzetta_comune', [300, 330]], ['antica_posta_alta', [330, 300]], ['piazzetta', [330, 320]]]) { + await page.evaluate(([s, a]) => { game.go(s, a); settle(game); }, [scene, at]); + await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground(game.backdrop()), null, { timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(1500); + const mixNow = await page.evaluate(() => [...music.tracks.values()].map(t => [t.src.split('/').pop(), Math.round(t.gain * 100)])); + console.log(scene, JSON.stringify(mixNow)); + assert.ok(mixNow.length >= 2, `${scene}: più console insieme`); + await page.screenshot({ path: `${out}/4-${scene}.png` }); + } + // Uscendo dal centro si torna a maggio. + await page.evaluate(() => { game.go('via_de_medici'); }); + await page.waitForFunction(() => !game.state.flags.ingorgo && game.speech?.text.startsWith('Mi stropiccio'), null, { timeout: 5000 }); + await page.screenshot({ path: `${out}/5-risveglio.png` }); + assert.deepEqual(errors, []); + console.log(`ok: schermate in ${out}`); +} finally { await browser?.close(); server.kill(); } diff --git a/tests/ingorgo.test.mjs b/tests/ingorgo.test.mjs new file mode 100644 index 0000000..92bac4e --- /dev/null +++ b/tests/ingorgo.test.mjs @@ -0,0 +1,115 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { trackFor, mix, Music } from '../src/engine/music.js'; +import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/engine/incanto.js'; +import { Game, check, POSTER_READY } from '../src/engine/game.js'; +import { story } from '../src/story/story.js'; +import { settle } from './helpers.mjs'; + +const ZONE = story.zones.find(z => z.flag === 'ingorgo'); +// Fa scorrere il tempo finché l'incantesimo non è finito. +const spell = g => { for (let i = 0; i < 600 && g.closeup?.id === 'ingorgo'; i++) g.update(1 / 60); settle(g); }; +const fromPiazza = () => { + const g = new Game(story); g.go('piazza'); settle(g); + g.act('look', 'manifesto_ingorgo'); settle(g); + assert.equal(g.closeup?.id, 'manifesto_ingorgo', 'il manifesto ingrandito'); + assert.ok(!g.closeupTap(), 'mentre si apre, un tocco non conta'); + g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g); + assert.equal(g.closeup?.id, 'ingorgo', 'l\'effetto speciale'); + spell(g); + return g; +}; + +test('la miscela delle console: vicino a una si sente quella, fra due tutte e due, mai il silenzio', () => { + const stages = [{ at: [100, 300], music: 'a.mp3' }, { at: [600, 300], music: 'b.mp3' }]; + const near = mix(stages, [110, 300]); + assert.equal(near[0].src, 'a.mp3'); assert.equal(near[0].gain, 1); + assert.ok(near[1].gain < 0.3 && near[1].gain >= 0.08, 'l\'altra arriva da lontano, ma c\'è'); + const mid = mix(stages, [350, 300]); + assert.ok(Math.abs(mid[0].gain - mid[1].gain) < 1e-9, 'a metà strada, pari'); + // Le console contano solo quando vale la loro condizione; spente, la scena torna al suo brano. + const s = { music: { game: 'g.mp3' }, scenes: { p: { stages: stages.map(st => ({ ...st, when: 'festa' })) } } }; + assert.equal(trackFor(s, { screen: 'game', scene: 'p', at: [110, 300] }), 'g.mp3'); + const on = trackFor(s, { screen: 'game', scene: 'p', at: [110, 300], ok: cond => cond === 'festa' }); + assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']); +}); + +test('la musica suona più brani insieme e li fa sfumare quando si cambia piazza', () => { + globalThis.Audio = class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } }; + const m = new Music({ volume: 0.5 }); + for (let i = 0; i < 40; i++) m.update([{ src: 'a.mp3', gain: 1 }, { src: 'b.mp3', gain: 0.2 }], 0.1); + const [a, b] = [m.tracks.get('a.mp3').audio, m.tracks.get('b.mp3').audio]; + assert.equal(a.volume, 0.5); assert.ok(Math.abs(b.volume - 0.1) < 1e-9); + assert.equal(m.current.src, 'a.mp3', 'la più forte è quella principale'); + for (let i = 0; i < 40; i++) m.update('c.mp3', 0.1); + assert.ok(a.paused && b.paused, 'le console lasciate sfumano e si fermano'); + assert.deepEqual([...m.tracks.keys()], ['c.mp3']); + delete globalThis.Audio; +}); + +test('l\'incantesimo: i battiti accelerano, il lampo, il cambio a bianco pieno, i coriandoli', () => { + const c = newIncanto('ingorgo'), seen = []; + let swapAt = null; + for (let i = 0; i < 400; i++) { + const now = updateIncanto(c, 1 / 60); + if (now === 'swap') swapAt = c.t; + if (now) seen.push(now); + if (now === 'end') break; + } + assert.deepEqual(seen, ['swap', 'end']); + assert.ok(Math.abs(swapAt - T.swap) < 0.02); + assert.equal(whiteness(T.swap), 1, 'si cambia quando non si vede niente'); + assert.equal(whiteness(T.end), 0); + const kicks = c.events.filter(e => e.kind === 'incanto-cassa'); + assert.ok(kicks.length >= 6 && c.events.some(e => e.kind === 'incanto-lampo')); + const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]), set: (o, k, v) => { o[k] = v; return true; } }); + for (const t of [0.5, 2.0, 3.2]) { calls.length = 0; drawIncanto(ctx, { ...c, t }); assert.ok(calls.some(([op]) => op === 'fillRect')); } +}); + +test('dal manifesto in piazza all\'Ingorgo: la battuta, l\'effetto, la festa; uscendo dalla zona si torna a maggio', () => { + const g = fromPiazza(); + assert.ok(g.state.flags.ingorgo, 'la festa c\'è'); + assert.equal(g.phase().id, 'ingorgo'); + assert.notEqual(g.backdrop(), story.scenes.piazza.background, 'la piazza della festa'); + assert.ok(!g.hotspots().some(h => h.id === 'manifesto_ingorgo'), 'il manifesto, alla festa, non serve'); + // Si gira la festa: in ogni posto della zona la festa resta, la musica è la miscela delle console. + for (const scene of ZONE.scenes) { + g.go(scene); settle(g); + assert.ok(g.state.flags.ingorgo, scene); + assert.notEqual(g.backdrop(), story.scenes[scene].background, `${scene}: lo sfondo della festa`); + const track = trackFor(story, { screen: 'game', scene, at: [g.state.x, g.state.y], ok: c => check(c, g.state) }); + assert.ok(Array.isArray(track) && track.length >= 2, `${scene}: più console insieme`); + } + g.go('via_cipriani'); settle(g); + assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce'); + assert.equal(g.backdrop(), story.scenes.via_cipriani.background); +}); + +test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => { + const g = new Game(story); g.go('piazza'); settle(g); + g.act('look', 'manifesto_ingorgo'); settle(g); + g.update(POSTER_READY); g.closeupTap(); settle(g); + assert.equal(g.closeup?.id, 'ingorgo'); + g.closeupClose(); settle(g); + assert.ok(g.state.flags.ingorgo); +}); + +test('il manifesto ingrandito si chiude senza fare niente: niente festa', () => { + const g = new Game(story); g.go('piazza'); settle(g); + g.act('look', 'manifesto_ingorgo'); settle(g); + g.update(1); g.closeupClose(); settle(g); + assert.ok(!g.state.flags.ingorgo && !g.closeup); +}); + +test('le musiche e gli sfondi della festa esistono', () => { + const files = new Set(); + for (const id of ZONE.scenes) { + const sc = story.scenes[id]; + for (const st of sc.stages ?? []) files.add(st.music); + for (const b of sc.backdrops ?? []) if (b.when === 'ingorgo') files.add(b.src); + } + files.add(story.closeups.manifesto_ingorgo.picture); + assert.ok(files.size >= 10); + for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`); +}); diff --git a/tests/story.test.mjs b/tests/story.test.mjs index cd99a92..b089cab 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -135,7 +135,8 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera, const g = new Game(story); g.go(story.start.scene, story.start.at); settle(g); - const ids = story.phases.map(p => p.id), seen = [g.phase().id]; + // La notte dell'Ingorgo è un extra fuori dalla soluzione: la storia non ci passa. + const ids = story.phases.map(p => p.id).filter(id => id !== 'ingorgo'), seen = [g.phase().id]; for (const [verb, target, item] of solution) { if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item); settle(g, { explore: true }); diff --git a/tools/ingorgo_musiche.py b/tools/ingorgo_musiche.py new file mode 100644 index 0000000..0cffd6a --- /dev/null +++ b/tools/ingorgo_musiche.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""I segnaposto delle console dell'Ingorgo Sonoro, sintetizzati da zero: quattro loop da sedici battute, uno per genere. +Nel gioco ora suonano i brani di Andrea (Suno, 30 ottobre: assets/raw/musica/ingorgo-*-suno.mp3, portati al volume +del gioco in assets/audio/ingorgo-*.mp3); questi restano in assets/raw/musica/ingorgo-*-sintesi.mp3, per prova. + python3 tools/ingorgo_musiche.py # tutti e quattro (serve ffmpeg) + python3 tools/ingorgo_musiche.py house # uno solo +Generi: latino (dembow e piano montuno), house (cassa dritta, accordi d'organo in levare), techno (cassa dura, +linea acida che si apre), underground (minimale scuro, basso che rotola e colpi lontani). +""" +import math +import random +import subprocess +import sys +import tempfile +import wave +from array import array +from pathlib import Path + +SR = 22050 +AUDIO = Path(__file__).resolve().parent.parent / "assets" / "raw" / "musica" +NAMES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} + + +def hz(note: str) -> float: + semis = NAMES[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0) + return 440 * 2 ** ((semis + 12 * (int(note[-1]) + 1) - 69) / 12) + + +class Track: + def __init__(self, bpm: int, bars: int = 16, seed: int = 1): + self.beat = 60 / bpm + self.buf = [0.0] * int(SR * bars * 4 * self.beat) + self.rng = random.Random(seed) + self.cache = {} + + def add(self, beat: float, samples): + # Il loop gira: quello che sborda oltre la fine rientra dall'inizio. + n, i0 = len(self.buf), int(beat * self.beat * SR) + for i, x in enumerate(samples): + self.buf[(i0 + i) % n] += x + + def once(self, key, make): + if key not in self.cache: + self.cache[key] = make() + return self.cache[key] + + +# --- I suoni: la batteria si calcola una volta e si timbra, le note una per una. +def kick(vol=0.9, top=150.0, bottom=45.0, length=0.32, punch=0.03): + out, ph = [], 0.0 + for i in range(int(length * SR)): + t = i / SR + f = bottom + (top - bottom) * math.exp(-t / punch) + ph += 2 * math.pi * f / SR + out.append(vol * math.exp(-t / (length / 3.2)) * math.sin(ph)) + return out + + +def noise(rng, vol, length, decay, bright=True): + out, prev = [], 0.0 + for i in range(int(length * SR)): + x = rng.uniform(-1, 1) + y = x - prev if bright else (prev + x) * 0.5 # differenza: solo alti; media: più corpo + prev = x + out.append(vol * y * math.exp(-i / SR / decay)) + return out + + +def clap(rng, vol=0.35): + # Tre colpi ravvicinati di rumore e una coda: il battimani. + out = [0.0] * int(0.25 * SR) + for k, d in ((0, 0.006), (0.011, 0.006), (0.022, 0.07)): + for i, x in enumerate(noise(rng, vol, 0.2, d, bright=False)): + j = int(k * SR) + i + if j < len(out): + out[j] += x + return out + + +def drum(freq, vol, decay, bend=0.0): + # Bongo, conga, tom: un seno che cala un poco. + out, ph = [], 0.0 + for i in range(int(decay * 5 * SR)): + t = i / SR + ph += 2 * math.pi * freq * (1 + bend * math.exp(-t / 0.02)) / SR + out.append(vol * math.exp(-t / decay) * math.sin(ph)) + return out + + +def synth(freq, length, vol, wave_="saw", cutoff=1200.0, sweep=0.0, decay=9.0, attack=0.004, release=0.03, detune=0.0, res=0.0): + """Un oscillatore (sega o quadra, o due appena stonati) dentro un filtro passa basso a due poli, che può aprirsi + e chiudersi (`sweep`, in Hz verso cui va il taglio durante la nota). `res` dà il fischio dell'acido.""" + n = int((length + release) * SR) + out, p1, p2 = [], 0.0, 0.0 + lo = bp = 0.0 + for i in range(n): + t = i / SR + env = min(1.0, t / attack) * math.exp(-t / decay) + if t > length: + env *= max(0.0, 1 - (t - length) / release) + p1 = (p1 + freq / SR) % 1 + p2 = (p2 + freq * (1 + detune) / SR) % 1 + if wave_ == "square": + x = (1 if p1 < 0.5 else -1) * 0.7 + else: + x = (2 * p1 - 1) + ((2 * p2 - 1) if detune else 0) + x *= 0.5 if detune else 1 + cut = cutoff + sweep * min(1.0, t / max(length, 1e-3)) + f = 2 * math.sin(math.pi * min(cut, SR / 6) / SR) + q = 1 - res + lo += f * bp + hi = x - lo - q * bp + bp += f * hi + out.append(vol * env * lo) + return out + + +def sine(freq, length, vol, decay=9.0, attack=0.004, release=0.05, partials=((1, 1.0),)): + n = int((length + release) * SR) + out = [] + for i in range(n): + t = i / SR + env = min(1.0, t / attack) * math.exp(-t / decay) + if t > length: + env *= max(0.0, 1 - (t - length) / release) + out.append(vol * env * sum(a * math.sin(2 * math.pi * freq * k * t) for k, a in partials)) + return out + + +# --- I quattro generi. +def house(): + tr = Track(124, seed=2) + K, H, C = kick(), tr.once("h", lambda: noise(tr.rng, 0.16, 0.08, 0.025)), clap(tr.rng, 0.32) + OH = tr.once("oh", lambda: noise(tr.rng, 0.12, 0.25, 0.09)) + chords = [["A3", "C4", "E4", "G4"], ["F3", "A3", "C4", "E4"], ["D3", "F3", "A3", "C4"], ["E3", "G#3", "B3", "D4"]] + roots = ["A1", "F1", "D2", "E1"] + for bar in range(16): + b0, ch, root = bar * 4, chords[bar // 2 % 4], roots[bar // 2 % 4] + for k in range(4): + tr.add(b0 + k, K) + tr.add(b0 + k + 0.5, OH) + for s in (0.25, 0.75): + tr.add(b0 + k + s, H) + for k in (1, 3): + tr.add(b0 + k, C) + # Gli accordi d'organo in levare, corti, il cuore della house. + for s in (0.5, 1.5, 2.75, 3.5): + for note in ch: + tr.add(b0 + s, synth(hz(note), tr.beat * 0.22, 0.05, cutoff=2600, decay=0.25, detune=0.006)) + # Il basso: in levare, che salta d'ottava. + for s, octv in ((0.5, 1), (1.5, 2), (2.5, 1), (3.25, 1), (3.5, 2)): + tr.add(b0 + s, synth(hz(root) * octv, tr.beat * 0.3, 0.32, wave_="square", cutoff=500, decay=0.3)) + return tr + + +def techno(): + tr = Track(132, seed=3) + K = kick(1.0, top=170, bottom=48, length=0.36, punch=0.022) + H = tr.once("h", lambda: noise(tr.rng, 0.13, 0.05, 0.012)) + R = tr.once("r", lambda: noise(tr.rng, 0.1, 0.6, 0.25, bright=False)) # il ride lungo, nella seconda metà + line = ["A1", "A1", "A2", "A1", "C2", "A1", "G2", "A1", "A1", "E2", "A1", "A2", "A1", "C2", "D2", "A1"] + for bar in range(16): + b0 = bar * 4 + for k in range(4): + tr.add(b0 + k, K) + tr.add(b0 + k + 0.5, H) + if bar >= 8: + tr.add(b0 + k + 0.5, R) + if bar % 4 == 3: + tr.add(b0 + 3.75, clap(tr.rng, 0.25)) + # La linea acida: sedicesimi, il filtro che si apre battuta dopo battuta e poi richiude. + open_ = 300 + 1700 * (0.5 - 0.5 * math.cos(2 * math.pi * bar / 16)) + for s, note in enumerate(line): + if s % 4 == 0 or tr.rng.random() < 0.8: + tr.add(b0 + s / 4, synth(hz(note), tr.beat * 0.2, 0.2, wave_="saw", cutoff=open_, sweep=-open_ * 0.6, decay=0.15, res=0.85)) + return tr + + +def underground(): + tr = Track(122, seed=4) + K = kick(0.95, top=110, bottom=42, length=0.42, punch=0.04) + H = tr.once("h", lambda: noise(tr.rng, 0.09, 0.04, 0.01)) + RIM = drum(1700, 0.12, 0.012) + for bar in range(16): + b0 = bar * 4 + for k in range(4): + tr.add(b0 + k, K) + for s in (0.5, 0.75): + tr.add(b0 + k + s, H) + for s in (1.75, 3.25) if bar % 2 else (2.75,): + tr.add(b0 + s, RIM) + # Il basso che rotola sui sedicesimi in levare, sempre la stessa nota: ipnotico. + for k in range(4): + for s in (0.25, 0.5, 0.75): + tr.add(b0 + k + s, synth(hz("F1"), tr.beat * 0.16, 0.24, cutoff=260, decay=0.12)) + # Un blip lontano ogni tanto, con l'eco. + if bar % 2 == 1: + note = ["C5", "Ab4", "Eb5", "F4"][bar // 2 % 4] + for rep, v in ((0, 0.07), (0.75, 0.04), (1.5, 0.02)): + tr.add(b0 + 2.5 + rep, sine(hz(note), 0.06, v, decay=0.08, partials=((1, 1), (3, 0.2)))) + # Il pad scuro che respira sotto, ogni quattro battute. + if bar % 4 == 0: + for note in ("F2", "Ab2", "C3"): + tr.add(b0, synth(hz(note), tr.beat * 15, 0.03, cutoff=420, sweep=300, decay=99, attack=1.5, release=1.0, detune=0.004)) + return tr + + +def latino(): + tr = Track(98, seed=5) + K = kick(0.85, top=130, bottom=50, length=0.3) + SN = tr.once("sn", lambda: [a + b for a, b in zip(noise(tr.rng, 0.22, 0.15, 0.05, bright=False), drum(220, 0.18, 0.05) + [0.0] * 9999)]) + H = tr.once("h", lambda: noise(tr.rng, 0.08, 0.05, 0.015)) + BONGO = (drum(380, 0.16, 0.06, bend=0.3), drum(520, 0.13, 0.05, bend=0.3)) + CONGA = drum(210, 0.2, 0.12, bend=0.2) + chords = [["A3", "C4", "E4"], ["D4", "F4", "A4"], ["E3", "G#3", "B3"], ["A3", "C4", "E4"]] + roots = ["A1", "D2", "E1", "A1"] + for bar in range(16): + b0, ch, root = bar * 4, chords[bar % 4], roots[bar % 4] + # Il dembow: cassa sull'uno e sul tre, rullante sul «tre sedicesimi» e sul due e mezzo. + for k in (0, 2): + tr.add(b0 + k, K) + tr.add(b0 + k + 0.75, SN) + tr.add(b0 + k + 1.5, SN) + for k in range(8): + tr.add(b0 + k / 2, H) + for s, b in ((0.5, 0), (1.0, 1), (2.5, 0), (3.0, 1), (3.5, 1)): + tr.add(b0 + s, BONGO[b]) + tr.add(b0 + 3.75, CONGA) + # Il piano montuno: accordi sincopati, voce alta raddoppiata all'ottava. + for s in (0, 0.75, 1.5, 2.5, 3.25): + for note in ch: + tr.add(b0 + s, sine(hz(note), tr.beat * 0.3, 0.05, decay=0.35, partials=((1, 1), (2, 0.4), (3, 0.15), (4, 0.08)))) + # Il basso del tumbao: in anticipo sul due e mezzo e sul quattro. + for s, f in ((0, 1), (1.5, 1.5), (3, 1)): + tr.add(b0 + s, synth(hz(root) * f, tr.beat * 0.8, 0.3, wave_="square", cutoff=380, decay=0.6)) + return tr + + +GENRES = {"house": house, "techno": techno, "underground": underground, "latino": latino} + + +def write(name: str, tr: Track): + peak = max(abs(x) for x in tr.buf) or 1 + AUDIO.mkdir(parents=True, exist_ok=True) + out = AUDIO / f"ingorgo-{name}-sintesi.mp3" + with tempfile.TemporaryDirectory() as tmp: + w = Path(tmp) / "loop.wav" + with wave.open(str(w), "wb") as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(SR) + f.writeframes(array("h", (int(x / peak * 0.9 * 32767) for x in tr.buf)).tobytes()) + # Una piazza all'aperto: un filo d'eco dai muri, e il compressore delle casse. + af = "acompressor=threshold=0.25:ratio=3:attack=5:release=80,aecho=0.85:0.4:90:0.12,loudnorm=I=-18:TP=-2:LRA=7" + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(out)], check=True) + print(f"-> {out} ({len(tr.buf) / SR:.0f} s)") + + +if __name__ == "__main__": + for name in sys.argv[1:] or GENRES: + write(name, GENRES[name]()) diff --git a/tools/prepare_character.py b/tools/prepare_character.py index 0dc9139..d5b36bb 100644 --- a/tools/prepare_character.py +++ b/tools/prepare_character.py @@ -36,7 +36,10 @@ def main(): p.add_argument('--height', type=int, default=120) p.add_argument('--frames', default=','.join(FRAMES), help='nomi dei fotogrammi, separati da virgola') p.add_argument('--ref', default='stand', help='fotogramma la cui altezza diventa --height') + p.add_argument('--fix', default='', help='correzioni di scala per fotogramma, quando nel foglio la figura è disegnata ' + 'più piccola o più grande: wave=1.3,cheer=1.05') a = p.parse_args() + fix = {k: float(v) for k, v in (kv.split('=') for kv in a.fix.split(',') if kv)} frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in a.frames.split(',')} ref = frames[a.ref].getchannel('A').getbbox() @@ -45,10 +48,11 @@ def main(): for f, im in frames.items(): im = im.crop(im.getchannel('A').getbbox()) cx, base = torso_center(im) - w, h = max(1, round(im.width * factor)), max(1, round(im.height * factor)) + k = factor * fix.get(f, 1) + w, h = max(1, round(im.width * k)), max(1, round(im.height * k)) im = im.resize((w, h), Image.LANCZOS) strip.append(im) - meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * factor, 1), 'ay': h} + meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * k, 1), 'ay': h} x += w + 2 sheet = Image.new('RGBA', (x, max(im.height for im in strip)), (0, 0, 0, 0)) for im, m in zip(strip, meta.values()): diff --git a/tools/pronuncia.json b/tools/pronuncia.json index af16043..d2ae71a 100644 --- a/tools/pronuncia.json +++ b/tools/pronuncia.json @@ -19,5 +19,6 @@ "bluffava": "bleffava", "Imposto": "Impòsto", "Libici": "Lìbici", - "mancano": "màncano" + "mancano": "màncano", + "console": "consòl" } diff --git a/tools/voci.json b/tools/voci.json index f4a6aa1..6f12a5e 100644 --- a/tools/voci.json +++ b/tools/voci.json @@ -73,5 +73,8 @@ }, "taglia": { "speaker": "Aaron Dreschner" + }, + "baldoria": { + "speaker": "Marcos Rudaski" } }