Compare commits

..
6 Commits
Author SHA1 Message Date
manzolo 721df30937 Versione 0.1.59 2026-10-30 23:06:42 +01:00
manzoloandClaude Opus 5.5 40d5780255 L'Ingorgo più da discoteca: fasci più forti e numerosi, colpo di colore a ogni battuta, strobo a raffica, macchie di luce sulla folla, laser anche in piazza, palla a specchi su tutta la scena; alla festa si clicca solo per spostarsi; il manifesto più facile da toccare col dito; il video breve dell'Ingorgo (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:59:54 +01:00
manzolo 9f2b045ab0 Versione 0.1.58 2026-10-30 22:31:40 +01:00
manzoloandClaude Opus 5.5 187b4c48cb Le luci da palco dell'Ingorgo: fasci colorati che spazzano dalle teste mobili, bagliore a tempo, strobo, laser verdi alla tecno di Cambray Digny, palla a specchi all'underground; solo alla festa (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:24:52 +01:00
manzoloandClaude Opus 5.5 c132e94ed8 L'Ingorgo Sonoro: dal manifesto in piazza, ingrandito e toccato, «Quanto darei per essere all'Ingorgo stasera?!?» e l'effetto speciale; il centro di notte pieno zeppo di gente (sfondi di Codex), le console di Andrea (Suno) che si mescolano camminando, Andrea più grande alla festa, uscendo si torna a maggio. Baldoria al campo sportivo col bandierone della Sampdoria (Codex) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:17:21 +01:00
manzoloandClaude Opus 5.5 9cd8027256 Il trailer extended rifatto senza dire dove sta il Regolo: la notte prima del Palio è la piazza scurita, nel finale solo due occhi gialli nel buio, il titolo sopra il panorama; in più Condor, campana, pesca, basket e pokerino giocati da soli (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:17:21 +01:00
52 changed files with 1381 additions and 96 deletions
+2 -2
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+70
View File
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+60
View File
@@ -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
+2
View File
@@ -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 |
+20
View File
@@ -18,6 +18,26 @@ 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.
Alla festa si clicca solo per spostarsi (zona con `exitsOnly`: panchine, porte e statue non rispondono).
Le luci da discoteca animate solo alla festa (`stagelights`, `lasers`, `mirrorball`, `floorspots` in render.js), rinforzate
su richiesta di Andrea: colpo di colore a ogni battuta, strobo a raffica a fine frase, macchie che corrono sulla folla. 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
`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
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.57",
"version": "0.1.59",
"private": true,
"type": "module",
"scripts": {
@@ -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",
+50 -4
View File
@@ -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 } = {}) {
@@ -199,7 +203,13 @@ export class Game {
return { scene, x: Math.round(x), y: Math.round(y), facing, inventory: [...inventory], flags: { ...flags }, used: { ...used }, ended };
}
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); }
// In una zona accesa con `exitsOnly` (la festa dell'Ingorgo: Andrea, 30 ottobre) si clicca solo per andare da un
// posto all'altro: panchine, porte e statue restano dove sono, ma non rispondono.
hotspots() {
const list = (this.scene.hotspots ?? []).filter(h => check(h.when, this.state));
const busy = (this.story.zones ?? []).some(z => z.exitsOnly && this.state.flags[z.flag] && z.scenes.includes(this.state.scene));
return busy ? list.filter(h => h.exit) : list;
}
hotspot(id) { return this.hotspots().find(h => h.id === id); }
people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); }
// Col mouse vale il punto preciso (l'ultimo della lista sta sopra). Col dito, `reach` allarga l'area: prima gli
@@ -295,6 +305,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 +346,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 +453,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 +521,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 +570,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 +625,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
}
}
+83
View File
@@ -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();
}
+44 -21
View File
@@ -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() {
+130 -2
View File
@@ -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.
@@ -484,6 +487,108 @@ 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.16 + 0.3 * 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 ?? 3); i++) {
const n = k * 3 + i, swing = Math.sin(time / (700 + n * 150) + n * 1.9) * (e.spread ?? 0.85);
const a = -Math.PI / 2 + swing + (k % 2 ? 0.25 : -0.25), len = e.len ?? 340, half = 0.055;
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.38 + 0.2 * hit})`);
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);
}
}
// Il colpo di colore su tutta la scena a ogni battuta (`pulse`: quanto forte).
if (e.pulse) {
ctx.fillStyle = `rgba(${colors[Math.floor(beat) % colors.length]},${e.pulse * hit ** 3})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
ctx.restore();
// Lo strobo: un lampo bianco brevissimo ogni `strobe` battute, a raffica nell'ultima battuta di ogni frase.
const roll = Math.floor(beat) % 16 === 15 && (beat * 4) % 1 < 0.3;
if (e.strobe && ((Math.floor(beat) % e.strobe === 0 && beat % 1 < 0.12) || roll)) {
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
},
// Le macchie di luce che corrono sulla folla (`area`), colorate, a tempo: la pista da ballo.
floorspots(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255'];
const beat = time / (60000 / (e.bpm ?? 124)), hit = 1 - (beat % 1);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < (e.count ?? 6); i++) {
const u = 0.5 + 0.5 * Math.sin(time / (1300 + i * 230) + i * 2.1), v = 0.5 + 0.5 * Math.sin(time / (1700 + i * 190) + i * 1.3);
const x = ax + u * aw, y = ay + v * ah, r = (e.size ?? 34) * (0.7 + 0.5 * v);
const c = colors[(i + Math.floor(beat / 2)) % colors.length];
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${c},${0.22 + 0.2 * hit})`);
g.addColorStop(1, `rgba(${c},0)`);
ctx.fillStyle = g;
ctx.save();
ctx.translate(x, y); ctx.scale(1, 0.45); ctx.translate(-x, -y);
ctx.fillRect(x - r, y - r, r * 2, r * 2);
ctx.restore();
}
ctx.restore();
},
// 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.45 + 0.25 * 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();
@@ -698,6 +803,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));
+3
View File
@@ -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 }); }
+3 -2
View File
@@ -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 });
+134 -5
View File
@@ -72,6 +72,22 @@ 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'],
exitsOnly: true, // alla festa si clicca solo per spostarsi (Andrea)
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 +328,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 +439,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 +582,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: 'stagelights', phase: 'ingorgo', from: [[116, 146], [276, 146], [196, 140]], wash: [200, 215, 170], bpm: 124, beams: 3, strobe: 4, pulse: 0.08 }, { type: 'lasers', phase: 'ingorgo', at: [196, 150], count: 7, color: '255,60,200', len: 360 }, { type: 'floorspots', phase: 'ingorgo', area: [40, 240, 560, 110], bpm: 124, count: 7 }, { 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 +660,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 +671,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: [223, 195, 22, 26], 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 +1622,15 @@ 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.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'lasers', phase: 'ingorgo', at: [400, 198], count: 13, len: 460 }, { type: 'lasers', phase: 'ingorgo', at: [370, 200], count: 7, color: '60,220,255' }, { type: 'stagelights', phase: 'ingorgo', from: [[356, 192], [460, 192], [408, 186]], wash: [408, 225, 180], bpm: 132, beams: 3, strobe: 2, pulse: 0.1, colors: ['90,255,110', '255,255,255', '60,220,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 250, 560, 100], bpm: 132, count: 8, 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 },
],
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 +1668,16 @@ 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.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'stagelights', phase: 'ingorgo', wash: [90, 190, 130], bpm: 98, colors: ['255,170,60', '255,90,160'], pulse: 0.05 }, { type: 'stagelights', phase: 'ingorgo', from: [[430, 205], [130, 150]], beams: 2, len: 260, bpm: 98 }, { type: 'floorspots', phase: 'ingorgo', area: [40, 230, 560, 120], bpm: 98, count: 5 }],
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 +1717,15 @@ 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.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'stagelights', phase: 'ingorgo', from: [[232, 212], [332, 212], [282, 205]], wash: [282, 228, 170], bpm: 98, beams: 3, strobe: 8, pulse: 0.08, colors: ['255,120,40', '255,60,120', '255,200,60', '200,80,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 250, 560, 100], bpm: 98, count: 7, 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 },
],
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 +1773,16 @@ 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.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'mirrorball', phase: 'ingorgo', at: [550, 131], area: [0, 20, 640, 300], count: 110 }, { type: 'stagelights', phase: 'ingorgo', from: [[520, 200], [600, 200]], wash: [552, 205, 160], bpm: 122, beams: 2, strobe: 8, pulse: 0.07, colors: ['170,60,255', '90,40,220', '220,60,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 240, 560, 110], bpm: 122, count: 6, colors: ['170,60,255', '60,120,255', '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 },
{ 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 +2095,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 +4083,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: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.57';
export const VERSION = '0.1.59';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+34
View File
@@ -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);
});
+58
View File
@@ -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(); }
+156
View File
@@ -0,0 +1,156 @@
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';
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.
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('col dito il manifesto dell\'Ingorgo si prende anche toccando vicino, e non si scambia con quello del Palio', () => {
const g = new Game(story); g.go('piazza'); settle(g);
const [x, y, w, h] = g.hotspot('manifesto_ingorgo').box, reach = 28; // 22 pixel di schermo su un iPhone in orizzontale
for (const p of [[x + w / 2, y + h / 2], [x - 6, y + h / 2], [x + w + 4, y + h / 2], [x + w / 2, y + h + 6]])
assert.equal(g.hotspotAt(p, reach)?.id, 'manifesto_ingorgo', `tocco in ${p}`);
const palio = g.hotspot('manifesto').box;
assert.equal(g.hotspotAt([palio[0] + palio[2] / 2, palio[1] + palio[3] / 2], reach)?.id, 'manifesto');
});
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('alla festa si clicca solo per andare da un posto all\'altro', () => {
const g = fromPiazza();
for (const scene of ZONE.scenes) {
g.go(scene); settle(g);
const list = g.hotspots();
assert.ok(list.length && list.every(h => h.exit), `${scene}: solo le uscite`);
assert.ok(story.scenes[scene].hotspots.some(h => !h.exit), `${scene}: di giorno c'è anche altro`);
}
g.go('piazza'); settle(g);
assert.equal(g.hotspotAt([505, 200]), null, 'la colonna, alla festa, non risponde');
});
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) {
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}`);
});
+2 -1
View File
@@ -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 });
+261
View File
@@ -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]())
+6 -2
View File
@@ -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()):
+2 -1
View File
@@ -19,5 +19,6 @@
"bluffava": "bleffava",
"Imposto": "Impòsto",
"Libici": "Lìbici",
"mancano": "màncano"
"mancano": "màncano",
"console": "consòl"
}
+3
View File
@@ -73,5 +73,8 @@
},
"taglia": {
"speaker": "Aaron Dreschner"
},
"baldoria": {
"speaker": "Marcos Rudaski"
}
}
+5
View File
@@ -74,6 +74,11 @@ Copioni `video/trailer.mjs` (circa 1 min 30 s) e `video/trailer-extended.mjs` (c
del paese e i giochi giocati da soli). Niente clic: la scena è a tutto schermo, le scritte e gli stacchi al nero
sono elementi aggiunti nella pagina, e la musica la sceglie il copione (`out/<clip>-audio.json`). Nel finale si
cercano solo gli occhi gialli: il palio non si mostra (Andrea: si capirebbe dov'è).
La extended rifatta il 30 ottobre (circa 4 min 15 s), su richiesta di Andrea: non deve mai far capire che il Regolo è
in Fortezza. Quindi niente impronte e niente fessura: «la notte prima del Palio» è la piazza scurita con le finestre
accese, il finale ha due occhi gialli nel nero, e il titolo sta sopra il panorama, perché l'illustrazione del titolo ha
il Regolo fra le mura. In più ci sono Condor, campana, pesca, basket e pokerino: a ogni fotogramma gioca da solo
un giocatore automatico (`gioca(…)`).
- Prova locale, senza VM: `OUT=/tmp/prova COPIONE=./trailer.mjs node video/prova-locale.mjs` (una schermata per
battuta, e i tempi).
+165
View File
@@ -0,0 +1,165 @@
// Copione del video breve dell'Ingorgo Sonoro (Andrea, 30 ottobre: «45 secondi, che parta come... ci sono cose che non
// tornano..... forse..., e fai vedere il manifesto dell'Ingorgo e girare tra la musica»). Come i trailer
// (video/trailer.mjs): la scena a tutto schermo, scritte e stacchi al nero nella pagina, niente clic; la musica la
// sceglie il copione, una console per posto (out/ingorgo-sonoro-audio.json).
// Prova: OUT=/tmp/prova COPIONE=./ingorgo-sonoro.mjs node video/prova-locale.mjs
// Ripresa come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py <cartella> --trailer .
import { mkdirSync, writeFileSync } from "node:fs";
const c = (id, it) => ({ id, it, en: it, top: true });
export const title = { it: "La Leggenda del Regolo · l'Ingorgo Sonoro", en: "La Leggenda del Regolo · l'Ingorgo Sonoro" };
export const cues = [
c("dubbio", "Ci sono cose che non tornano…"),
c("manifesto", "il manifesto"),
c("incanto", "l'incantesimo"),
c("festa", "la festa"),
c("titolo", "La Leggenda del Regolo"),
];
const URL_GIOCO = "http://127.0.0.1:8000/";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const H = "assets/audio/ingorgo-house.mp3", L = "assets/audio/ingorgo-latino.mp3", U = "assets/audio/ingorgo-underground.mp3",
T = "assets/audio/ingorgo-techno.mp3";
export async function setup(d) {
const p = d.page;
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])");
await p.click("#new-game");
await p.click('#rione [data-rione="campomigliaio"]');
await p.click("#skip-intro");
await p.addStyleTag({ content: `
#side, #corner, #sound, #closeup-close, #bocce-controls, #briscola-controls, #tressette-controls, #scopa-controls,
#map-button, #pause-button, #spots-button, #notebook-button, #sentence { display: none !important; }
body { background: #000 !important; overflow: hidden; }
#app { position: fixed !important; inset: 0 !important; width: 100vw !important; margin: 0 !important; }
#stage, #stage.biliardo, #stage.briscola { width: 100vw !important; height: 100vh !important; }
#scene { width: 100vw !important; height: 100vh !important; }
#title .buttons, #title .version, #title .dedication { display: none !important; }
#title::before { background-image: url(assets/scenes/ingorgo-piazza.png) !important; }
#title::after { background: linear-gradient(rgba(20, 12, 30, .6), rgba(20, 12, 30, .1) 45%, rgba(20, 12, 30, .75)),
url(assets/scenes/ingorgo-piazza.png) center / contain no-repeat !important; }
#tr-nero { position: fixed; inset: 0; background: #000; opacity: 1; transition: opacity .7s; z-index: 50; pointer-events: none; }
#tr-scritta { position: fixed; left: 0; right: 0; top: 50%; transform: translateY(-50%); text-align: center; z-index: 51;
font-family: "Press Start 2P", monospace; font-size: 40px; line-height: 1.7; color: #ffe9b0; opacity: 0; white-space: pre-line;
transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; }
#tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 30px; }
` });
await p.evaluate(() => {
for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
});
await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000));
await p.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); window.voices?.stop?.(); });
d.vm("DISPLAY=:0 ~/lab/video/mv.py 1599 899 0.2");
await sleep(1200);
}
export async function run(d) {
const p = d.page;
// L'orologio dell'audio, come nei trailer: le voci che partono; la musica la decide il copione.
await p.evaluate(() => {
window.__t0 = Date.now(); window.__audio = [];
const t = () => (Date.now() - window.__t0) / 1000;
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
let voice = null;
window.__musica = (src) => window.__audio.push({ t: t(), music: src });
const look = () => {
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
if (now !== voice) {
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
voice = now;
}
requestAnimationFrame(look);
};
look();
});
const musica = (src) => p.evaluate((s) => window.__musica(s), src);
const nero = (on, s = 0.7) => p.evaluate(([on, s]) => { const e = document.getElementById("tr-nero"); e.style.transition = `opacity ${s}s`; e.style.opacity = on ? 1 : 0; }, [on, s]);
const scritta = (testo, basso = false) => p.evaluate(([t, b]) => {
const e = document.getElementById("tr-scritta");
if (t) { e.textContent = t; e.classList.toggle("basso", b); }
e.style.opacity = t ? 1 : 0;
}, [testo, basso]);
const vai = (scene, at, flags = {}) => p.evaluate(async ([scene, at, flags]) => {
const { settle } = await import('/tests/helpers.mjs');
game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.path = []; game.walking = false; game.task = null;
Object.assign(game.state.flags, flags);
game.go(scene, at); settle(game);
}, [scene, at, flags]);
const cammina = (a) => p.evaluate((a) => game.walkTo(a), a);
// 1. Il nero e il dubbio.
await musica("assets/audio/sera.mp3");
await d.cue("dubbio");
await sleep(500);
await scritta("Ci sono cose\nche non tornano…");
await sleep(2400);
await scritta(null);
await sleep(600);
await scritta("…forse…");
await sleep(1500);
await scritta(null);
await sleep(400);
// 2. La piazza di giorno: Andrea va al manifesto, che si apre ingrandito.
await d.cue("manifesto");
await musica("assets/audio/paese.mp3");
await vai("piazza", [380, 320]);
await nero(false);
await sleep(700);
await p.evaluate(() => game.act("look", "manifesto_ingorgo"));
await p.waitForFunction(() => game.closeup?.id === "manifesto_ingorgo" && game.closeup.t > 0.5, null, { timeout: 15000 });
await sleep(2000);
// Il tocco sul manifesto: la battuta sul manifesto si salta, resta il desiderio, a voce.
await p.evaluate(() => game.closeupTap());
for (let k = 0; k < 200 && !(await p.evaluate(() => game.speech?.text.startsWith("Quanto darei"))); k++) {
await p.evaluate(() => { if (game.speech) game.skip(); });
await sleep(40);
}
// 3. L'incantesimo: nel lampo parte la house della piazza.
await d.cue("incanto");
await p.waitForFunction(() => game.closeup?.id === "ingorgo", null, { timeout: 20000 });
await p.waitForFunction(() => game.closeup?.swapped, null, { timeout: 10000 });
await musica(H);
// 4. Il giro della festa: Andrea si accorge della festa camminando fra la gente (la seconda battuta si salta), poi una
// console per posto, a stacchi veloci.
await p.waitForFunction(() => !game.closeup && game.speech?.text.includes("Ingorgo"), null, { timeout: 10000 });
await d.cue("festa");
await p.evaluate(() => { game.queue.length = 0; game.walkTo([200, 300]); });
await p.waitForFunction(() => !game.speech?.text.includes("Ingorgo"), null, { timeout: 20000 });
await p.evaluate(() => { game.queue.length = 0; game.speech = null; });
await sleep(300);
for (const [scene, da, a, brano] of [["via_antica_posta", [330, 345], [300, 290], H], ["piazzetta_comune", [260, 350], [300, 300], L],
["antica_posta_alta", [240, 350], [380, 290], U], ["piazzetta", [330, 352], [420, 290], T]]) {
await nero(true, 0.2); await sleep(220);
await musica(brano);
await vai(scene, da);
await nero(false, 0.2);
await cammina(a);
await sleep(3200);
}
await sleep(300);
// 5. Il titolo sopra la piazza della festa.
await d.cue("titolo");
await nero(true, 0.9); await sleep(1000);
await p.evaluate(() => { game.closeup = null; document.getElementById("title").hidden = false; });
await nero(false, 1.2);
await sleep(1200);
await scritta("l'Ingorgo Sonoro", true);
await sleep(3000);
await scritta(null);
await nero(true, 1.4);
await sleep(1600);
const audio = await p.evaluate(() => window.__audio);
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL("ingorgo-sonoro-audio.json", out), JSON.stringify({ eventi: audio }, null, 2));
}
+1 -1
View File
@@ -12,7 +12,7 @@ try {
page.goto = url => orig(url.replace('http://127.0.0.1:8000/', `http://127.0.0.1:${server.port}/`));
const mod = await import(process.env.COPIONE ?? './trailer.mjs');
const t0 = Date.now(); let n = 0;
const d = { page, vm: () => {}, cue: async id => { console.log(((Date.now() - t0) / 1000).toFixed(1), id); setTimeout(() => page.screenshot({ path: `${out}/tr-${String(++n).padStart(2, '0')}-${id}.png` }), id === 'buio' ? 5000 : 3000); } };
const d = { page, vm: () => {}, cue: async id => { console.log(((Date.now() - t0) / 1000).toFixed(1), id); setTimeout(() => page.screenshot({ path: `${out}/tr-${String(++n).padStart(2, '0')}-${id}.png` }), id === 'buio' ? 5000 : Number(process.env.SCATTO ?? 3000)); } };
await mod.setup(d);
await mod.run(d);
console.log('fine', ((Date.now() - t0) / 1000).toFixed(1));
+84 -53
View File
@@ -1,6 +1,8 @@
// Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa tre
// minuti, come il trailer (video/trailer.mjs) ma col giro completo del paese e i giochi giocati per qualche secondo:
// bocce, biliardo, GP di Fortezza, briscola, macchinine, cerbottana.
// Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa
// quattro minuti, come il trailer (video/trailer.mjs) ma col giro completo del paese e i giochi giocati per qualche
// secondo: bocce, biliardo, GP di Fortezza, briscola, macchinine, cerbottana, Condor, campana, pesca, basket, pokerino.
// Rifatto il 30 ottobre (Andrea): niente che dica che il Regolo è in Fortezza. La notte prima del Palio è la piazza
// con le finestre accese, e nel finale ci sono solo due occhi gialli nel buio, senza muro e senza fessura.
// Si gira come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py <cartella> --trailer .
import { mkdirSync, writeFileSync } from "node:fs";
@@ -18,8 +20,13 @@ export const cues = [
c("carte", "La briscola all'oratorio"),
c("macchinine", "Le macchinine col gesso"),
c("cerbottana", "La cerbottana"),
c("condor", "La Condor col Taglia"),
c("campana", "La campana"),
c("pesca", "La pesca al lago"),
c("basket", "Fai due tiri"),
c("poker", "Il pokerino del Barrettino"),
c("amici", "…e di vecchie conoscenze"),
c("buio", "la fessura"),
c("buio", "due occhi gialli"),
c("titolo", "La Leggenda del Regolo"),
];
@@ -52,9 +59,19 @@ export async function setup(d) {
font-family: "Press Start 2P", monospace; font-size: 42px; line-height: 1.6; color: #ffe9b0; opacity: 0;
transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; }
#tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 32px; }
#scene.notte { filter: brightness(.5) saturate(.85) hue-rotate(-8deg); }
/* Il titolo sopra il panorama: l'illustrazione del gioco ha il Regolo fra le mura della Fortezza. */
#title::before { background-image: url(assets/scenes/panorama-intro.png) !important; }
#title::after { background: linear-gradient(rgba(20, 12, 30, .55), rgba(20, 12, 30, .05) 45%, rgba(20, 12, 30, .7)),
url(assets/scenes/panorama-intro.png) center / contain no-repeat !important; }
#tr-occhi { position: fixed; left: 50%; top: 44%; z-index: 51; display: flex; gap: 46px; transform: translateX(-50%);
opacity: 0; transition: opacity 1.4s; pointer-events: none; }
#tr-occhi i { width: 22px; height: 22px; background: #ffe14a; box-shadow: 0 0 18px 6px rgba(255, 210, 40, .55); }
#tr-occhi.chiusi i { height: 3px; margin-top: 10px; }
` });
await p.evaluate(() => {
for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
for (const id of ["tr-nero", "tr-scritta", "tr-occhi"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
document.getElementById("tr-occhi").innerHTML = "<i></i><i></i>";
});
// Senza nessun tocco il gioco va in pausa da solo dopo un minuto: un finto movimento ogni tanto lo tiene sveglio.
await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000));
@@ -122,16 +139,16 @@ export async function run(d) {
await fuoco(vai("via_cafaggio"));
await sleep(3200);
// 2. La Fortezza al tramonto, le impronte.
// 2. La piazza di sera, con le finestre accese (niente Fortezza e niente impronte: si capirebbe dov'è il Regolo).
await d.cue("notte");
await fuoco(vai("fortezza", null, { indizio_regolo: true }));
await fuoco(`async () => { await (${vai("piazza", null, { casetta_aperta: true })})(); document.getElementById("scene").classList.add("notte"); }`);
await scritta("La notte prima del Palio…", true);
await sleep(3000);
await scritta(null);
// 3. La scuola: il palio non c'è più.
await d.cue("sparito");
await fuoco(vai("scuola", [300, 330], { indizio_regolo: false }));
await fuoco(`async () => { await (${vai("scuola", [300, 330], { casetta_aperta: false })})(); document.getElementById("scene").classList.remove("notte"); }`);
await scritta("…il palio è sparito.", true);
await sleep(3200);
await scritta(null);
@@ -145,7 +162,7 @@ export async function run(d) {
["orti_zio_mario", [400, 352], [330, 260]], ["stazione", [320, 350], [450, 290]], ["cardetole", [300, 350], [470, 300]], ["terrazza", [300, 330], [420, 320]]]) {
await fuoco(vai(scene, da), true);
await p.evaluate((a) => game.walkTo(a), a);
await sleep(3300);
await sleep(2800);
}
// 5. Le sfide, giocate davvero per qualche secondo: ogni gioco col suo nome in basso e la sua musica.
@@ -155,7 +172,8 @@ export async function run(d) {
await scritta("Un paese pieno di sfide");
await sleep(2000);
await scritta(null);
// Apre il primo piano, poi ogni 150 ms chiama `mossa` (il giocatore che gioca da solo) finché passano i secondi.
// Apre il primo piano, poi a ogni fotogramma chiama `mossa` (il giocatore che gioca da solo) finché passano i
// secondi. Dentro `mossa`, `pronto(ms)` dice se dall'ultima mossa è passato abbastanza: così non gioca a raffica.
const gioca = async (cue, nome, brano, closeup, level, modulo, prima, mossa, secondi) => {
await d.cue(cue);
await musica(brano);
@@ -165,27 +183,51 @@ export async function run(d) {
game.run({ closeup: '${closeup}', level: '${level}' }); settle(game);
window.__m = await import('${modulo}');
const M = window.__m, c = game.closeup; ${prima}
const mossa = new Function('M', 'c', 'pronto', ${JSON.stringify(mossa)});
let ultima = performance.now();
const pronto = (ms) => performance.now() - ultima > ms && (ultima = performance.now(), true);
const giro = () => { if (game.closeup !== c) return; mossa(M, c, pronto); requestAnimationFrame(giro); };
giro();
}`);
await scritta(nome, true);
const fine = Date.now() + secondi * 1000;
while (Date.now() < fine) {
await p.evaluate(`(() => { const M = window.__m, c = game.closeup; if (!c) return; ${mossa} })()`);
if (Date.now() > fine - (secondi - 3) * 1000) await scritta(null);
await sleep(150);
}
await sleep(3000);
await scritta(null);
await sleep((secondi - 3) * 1000);
};
await gioca("bocce", "Le bocce al pallaio", "assets/audio/bocce.mp3", "bocce", "seria", "/src/engine/bocce.js", "",
"if (M.canThrow(c)) { c.aim = { u: c.jack.u, w: c.jack.w }; c.power = M.powerFor(Math.hypot(c.jack.u, c.jack.w) - 1.5); M.shoot(c); }", 14);
"if (M.canThrow(c) && pronto(500)) { c.aim = { u: c.jack.u, w: c.jack.w }; c.power = M.powerFor(Math.hypot(c.jack.u, c.jack.w) - 1.5); M.shoot(c); }", 11);
await gioca("biliardo", "Il biliardo del Circolo", "assets/audio/biliardo.mp3", "biliardo", "seria", "/src/engine/biliardo.js", "",
"if (c.step === 'aim' && c.current === 'player' && !c.holding) M.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x) + 0.04, 0.75);", 14);
"if (c.step === 'aim' && c.current === 'player' && !c.holding && pronto(400)) M.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x) + 0.04, 0.75);", 11);
await gioca("gara", "Il GP di Fortezza", "assets/audio/gara.mp3", "gara", "bona", "/src/engine/gara.js",
"while (c.step === 'intro') M.press(c); M.release(c);", "c.holding = c.step === 'race' && !M.mustLift(c);", 16);
"while (c.step === 'intro') M.press(c); M.release(c);", "c.holding = c.step === 'race' && !M.mustLift(c);", 13);
await gioca("carte", "La briscola all'oratorio", "assets/audio/carte.mp3", "briscola", "seria", "/src/engine/briscola.js", "",
"if (M.canPlay(c)) M.play(c, 0);", 12);
"if (M.canPlay(c) && pronto(700)) M.play(c, 0);", 10);
await gioca("macchinine", "Le macchinine col gesso", "assets/audio/macchinine.mp3", "micro", "bona", "/src/engine/micro.js", "M.pick(c, 0);",
"if (M.canThrow(c)) { const car = c.cars.player, i = (car.idx + Math.round(110 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; M.strike(c, 'player', Math.atan2(y - car.y, x - car.x), M.powerFor(Math.hypot(x - car.x, y - car.y))); }", 12);
"if (M.canThrow(c) && pronto(400)) { const car = c.cars.player, i = (car.idx + Math.round(110 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; M.strike(c, 'player', Math.atan2(y - car.y, x - car.x), M.powerFor(Math.hypot(x - car.x, y - car.y))); }", 10);
await gioca("cerbottana", "La cerbottana", "assets/audio/gara.mp3", "cerbottana", "seria", "/src/engine/cerbottana.js", "",
"if (M.canThrow(c)) { const k = c.cans.find(k => !k.down); c.aim = k && c.left % 2 ? { x: k.x, y: M.LEDGE - 8 } : { x: 320 + (c.left % 3 - 1) * 6, y: 118 }; c.power = M.BEST; M.shoot(c); }", 10);
"if (M.canThrow(c) && pronto(400)) { const k = c.cans.find(k => !k.down); c.aim = k && c.left % 2 ? { x: k.x, y: M.LEDGE - 8 } : { x: 320 + (c.left % 3 - 1) * 6, y: 118 }; c.power = M.BEST; M.shoot(c); }", 9);
// La Condor: le lattine sulla panchina, le bottiglie, il palloncino; il pallino cala, si mira un po' sopra.
await gioca("condor", "La Condor col Taglia", "assets/audio/gara.mp3", "condor", "bona", "/src/engine/condor.js", "",
"if (!M.canThrow(c) || c.holding || !pronto(800)) return; const su = x => M.DROP * M.far(x) ** 2; const k = c.cans.find(k => !k.down), b = c.bottles.find(b => !b.down);" +
" if (k) c.aim = { x: k.x, y: M.BENCH - M.CAN.h / 2 - su(k.x) }; else if (b) c.aim = { x: b.x, y: M.BAR - M.BOTTLE.h / 2 - su(b.x) };" +
" else if (!c.balloon.popped) { const p = M.balloon(c); c.aim = { x: p.x, y: p.y - su(p.x) }; } else c.aim = { x: M.CARTONE.x, y: M.CARTONE.y - su(M.CARTONE.x) }; M.shoot(c);", 10);
// La campana: il sassolino nella casella giusta, poi i salti quando la lancetta passa nel verde; poi tocca al Taglia.
await gioca("campana", "La campana", "assets/audio/macchinine.mp3", "campana", "bona", "/src/engine/campana.js", "",
"if (c.step === 'aim' && c.current === 'player' && pronto(700)) { const p = M.center(c.target.player); c.skill.scatter = 0; c.aim = { x: p.x, y: p.y }; c.power = Math.hypot(p.x - M.FROM.x, p.y - M.FROM.y) / M.REACH; M.shoot(c); }" +
" else if (c.step === 'hop' && (!c.hop || c.hop.t >= M.HOP) && c.meter?.t > 0 && Math.abs(M.needle(c)) < 0.08) M.press(c, 1);", 15);
// La pesca: il galleggiante abbocca presto (nel trailer non si aspetta), si ferra e si recupera senza strappare.
await gioca("pesca", "La pesca al lago", "assets/audio/paese.mp3", "pesca", "bona", "/src/engine/pesca.js", "",
"const f = c.float; if (c.step === 'aim' && pronto(800)) { c.power = 0.45; M.shoot(c); }" +
" else if (c.step === 'wait' && f) { if (!f.svelto && f.biteAt) { const prima = Math.max(0, f.biteAt - f.t - 1.6); f.biteAt -= prima; f.nibbles.forEach(n => n.at -= prima); f.svelto = true; }" +
" if (M.biting(c)) { M.strike(c); c.reeling = c.step === 'fight'; } } else if (c.step === 'fight') c.reeling = c.tension < 0.8;", 15);
// Fai due tiri: per ogni postazione si cerca prima, su una copia, un tiro che entra.
await gioca("basket", "Fai due tiri", "assets/audio/gara.mp3", "basket", "bona", "/src/engine/basket.js", "",
"if (!M.canThrow(c) || c.holding || !pronto(600)) return; let best = { angle: 55, power: 0.7 };" +
" cerca: for (let angle = 46; angle <= 66; angle += 4) for (let power = 0.3; power <= 1; power += 0.02) { const p = structuredClone(c); M.setAngle(p, angle); M.setPower(p, power); M.shoot(p);" +
" for (let i = 0; i < 2000 && p.step === 'flight'; i++) M.update(p, 1 / 120); if (p.ball.scored) { best = { angle, power }; break cerca; } }" +
" M.setAngle(c, best.angle); M.setPower(c, best.power); M.shoot(c);", 11);
await gioca("poker", "Il pokerino del Barrettino", "assets/audio/carte.mp3", "poker", "seria", "/src/engine/poker.js", "",
"if (M.canDraw(c) && pronto(900)) M.choose(c, 'cambia'); else if (M.myTurn(c) && pronto(900)) M.choose(c, 'vedo'); else if (c.step === 'show' && pronto(2500)) M.advance(c);", 11);
await musica("assets/audio/paese.mp3");
// 6. Le vecchie conoscenze, con le loro battute.
@@ -207,40 +249,29 @@ export async function run(d) {
await finitoDiDire();
await sleep(400);
// 7. Il buio: la sera, la torcia nella fessura, gli occhi gialli.
// 7. Il buio: solo due occhi gialli che si aprono, guardano e sbattono (Andrea, 30 ottobre: niente Fortezza, niente
// fessura e niente palio, sennò si capisce dov'è il Regolo).
await d.cue("buio");
// Sotto il buio una musica bassa di suspense (Andrea: «un po' di musichetta nel finale»).
await musica("assets/audio/sera.mp3");
await fuoco(vai("fortezza", [462, 310], { indizio_regolo: true, casetta_aperta: true }));
await sleep(900);
await p.evaluate(() => { game.state.inventory.push("torcia"); game.act("use", "fessura", "torcia"); });
// «Accendo la torcia e la punto dentro…» si salta: il primo piano si apre subito.
// Il palio non si vede (Andrea: si capirebbe che è in Fortezza): la luce parte da destra, nel buio, e i primi due
// ritrovamenti si danno per fatti; si cercano solo gli occhi.
for (let k = 0; k < 60 && await p.evaluate(() => game.closeup?.id !== "fessura"); k++) { await p.evaluate(() => game.skip()); await sleep(50); }
await p.evaluate(() => { const c = game.closeup; c.found = 2; c.beam = [600, 300]; });
await sleep(1600);
const targets = await p.evaluate(() => game.story.closeups.fessura.targets.map((t) => t.at));
for (let i = 2; i < targets.length; i++) {
if (i === 2) await musica("assets/audio/cartello.mp3");
// La luce va piano verso il punto, come una mano che cerca: prima in basso, poi su verso gli occhi.
const from = await p.evaluate(() => game.closeup.beam);
for (let k = 1; k <= 30; k++) {
const x = from[0] + (targets[i][0] - from[0]) * k / 30 + Math.sin(k / 4) * 12, y = from[1] + (targets[i][1] - from[1]) * k / 30;
await p.evaluate(([x, y]) => game.closeupAim([x, y]), [x, y]);
await sleep(50);
}
// Le battute dei primi ritrovamenti si saltano (partono prima che il ritrovamento conti, e lo fermano); resta solo
// l'ultima: «…sono due occhi gialli!».
for (let k = 0; k < 300 && await p.evaluate((i) => !(game.closeup?.found > i), i); k++) {
if (i < 2) await p.evaluate(() => game.skip());
await sleep(100);
}
await p.waitForFunction(() => !game.speech && !game.closeup?.lines?.length, null, { timeout: 30000 });
await sleep(i === 2 ? 1400 : 100);
}
await p.evaluate(() => { document.getElementById("tr-nero").style.transition = "opacity .9s"; });
await nero(true);
await sleep(1400);
await prepara(`async () => { game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.state.x = -400; }`);
await sleep(1800);
await musica("assets/audio/cartello.mp3");
const occhi = (fn) => p.evaluate(fn);
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 1; });
await sleep(2200);
for (const pausa of [140, 2200, 110, 140, 110]) {
await occhi(() => document.getElementById("tr-occhi").classList.toggle("chiusi"));
await sleep(pausa);
}
await dice(null, "E quelli... sono due occhi gialli!");
await finitoDiDire();
await sleep(900);
await occhi(() => { document.getElementById("tr-occhi").classList.add("chiusi"); });
await sleep(150);
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 0; });
await sleep(1500);
// 8. Il titolo.
await d.cue("titolo");