Compare commits

...
6 Commits
Author SHA1 Message Date
manzolo 447c150974 Versione 0.1.5 2026-10-25 18:26:34 +01:00
manzoloandClaude Opus 5.5 95e7777dbc La vecchietta: «Secondo me va in Fortezza solo per farsi le canne»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 18:22:47 +01:00
manzoloandClaude Opus 5.5 26697cd9c2 Il vecchietto nuovo, dalla foto di Andrea: giacca blu, camicia azzurra e cravatta, stesse otto pose (Codex)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 18:21:42 +01:00
manzoloandClaude Opus 5.5 de303c5825 Suoni d'ambiente sintetizzati (vento, acqua, uccelli, grilli, campane) col loro volume in Pausa; la fessura in primo piano: si cerca col fascio della torcia il quadro e gli occhi (lavoro di Codex). La foto del palio leggibile da nginx, e un test che lo controlla per tutti i file serviti
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 18:17:01 +01:00
manzoloandClaude Opus 5.5 1fabb6e543 Video della partita completa (pomeriggio → sera) con voci e musiche rimontate dal registro audio del copione; inventario: nomi lunghi su due righe dentro il pulsante
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 17:36:06 +01:00
manzoloandClaude Opus 5.5 32a3d8a7c5 Risposte su misura ai tentativi sbagliati (squama, torcia, ghiacciolo, libro a vecchietti, leone, San Pietro, Tosca, torre, amici); il pettegolezzo che cambia con le scoperte (vecchietti, Arcu, Tosca); l'Arcu senza memoria; le altre idee di Codex in IDEE.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:56:02 +01:00
89 changed files with 1458 additions and 139 deletions
+7
View File
@@ -41,6 +41,12 @@ python3 -m http.server 8000 # poi http://localhost:8000
Clic = azione più sensata (parla, guarda, vai); clic destro o pressione lunga = guarda; un verbo scelto vale per il
clic successivo. Spazio salta la battuta, i numeri scelgono nel dialogo, Esc annulla il verbo.
Il libro dell'oratorio si apre e si sfoglia con clic o tocco; Esc lo chiude. Nella fessura della Fortezza,
la torcia segue mouse o dito (anche trascinando): si cercano i dettagli tenendo brevemente la luce su di essi.
Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio. «Torna fuori» permette di
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
## In Docker
```zsh
@@ -64,5 +70,6 @@ make down
npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine
npm install && npx playwright install chromium-headless-shell
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
```
+25 -25
View File
@@ -5,65 +5,65 @@
"sit": {
"x": 0,
"y": 0,
"w": 76,
"w": 77,
"h": 96,
"ax": 31.4,
"ax": 32.9,
"ay": 96
},
"doze": {
"x": 78,
"x": 79,
"y": 0,
"w": 76,
"w": 77,
"h": 96,
"ax": 31.5,
"ax": 33.0,
"ay": 96
},
"talk": {
"x": 156,
"x": 158,
"y": 0,
"w": 67,
"w": 68,
"h": 96,
"ax": 25.1,
"ax": 26.3,
"ay": 96
},
"explain": {
"x": 225,
"x": 228,
"y": 0,
"w": 62,
"w": 68,
"h": 96,
"ax": 27.4,
"ax": 29.9,
"ay": 96
},
"point": {
"x": 289,
"x": 298,
"y": 0,
"w": 74,
"w": 78,
"h": 97,
"ax": 31.5,
"ax": 33.3,
"ay": 97
},
"laugh": {
"x": 365,
"x": 378,
"y": 0,
"w": 70,
"w": 71,
"h": 97,
"ax": 23.2,
"ax": 22.8,
"ay": 97
},
"cat": {
"x": 437,
"x": 451,
"y": 0,
"w": 67,
"h": 95,
"ax": 24.1,
"ay": 95
"w": 68,
"h": 96,
"ax": 26.1,
"ay": 96
},
"shrug": {
"x": 506,
"x": 521,
"y": 0,
"w": 81,
"w": 83,
"h": 97,
"ax": 38.9,
"ax": 40.2,
"ay": 97
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+264 -28
View File
@@ -91,6 +91,22 @@
"file": "andrea-af4d717814.mp3",
"sec": 6.44
},
"vecchietto|Dice che Andrea ha trovato un serpente lungo dodici metri.": {
"file": "vecchietto-cc1d61420b.mp3",
"sec": 3.64
},
"vecchietta|Ma quale serpente. Secondo me va in Fortezza solo per farsi le canne: torna sempre con gli occhi rossi!": {
"file": "vecchietta-c11979f5e8.mp3",
"sec": 6.73
},
"andrea|Sono qui, eh. Vi sento.": {
"file": "andrea-5debd43bf6.mp3",
"sec": 2.64
},
"vecchietta|Oh! Andrea! Si parlava d'un altro.": {
"file": "vecchietta-1cbc047e44.mp3",
"sec": 4.9
},
"andrea|Se li porto via, il paese resta senza notizie.": {
"file": "andrea-566475abe2.mp3",
"sec": 3.48
@@ -111,6 +127,26 @@
"file": "andrea-eeb5a2273c.mp3",
"sec": 1.3
},
"vecchietta|Al limone! Da ragazza me ne mangiavo tre di fila.": {
"file": "vecchietta-15dbd36313.mp3",
"sec": 3.24
},
"vecchietto|E poi tre giorni a letto.": {
"file": "vecchietto-6a0dcb4430.mp3",
"sec": 1.37
},
"andrea|Me lo tengo io, allora.": {
"file": "andrea-d5aeb501d8.mp3",
"sec": 1.16
},
"vecchietto|«La storia infinita»? Alla mia età, figliolo, meglio qualcosa di corto.": {
"file": "vecchietto-27843b8226.mp3",
"sec": 5.02
},
"andrea|Fidati: è più corto di quel che sembra.": {
"file": "andrea-20c5ab3680.mp3",
"sec": 2.41
},
"andrea|Qualcosa luccica, proprio sotto i piedi dei vecchietti.": {
"file": "andrea-9a8732a3aa.mp3",
"sec": 3.97
@@ -175,6 +211,10 @@
"file": "andrea-fd10e3f083.mp3",
"sec": 3.04
},
"andrea|Di giorno la torcia non serve: le impronte si vedono benissimo. È il buio della fessura, il problema.": {
"file": "andrea-36648fc8d5.mp3",
"sec": 7.66
},
"andrea|Ormai lo so: è passato di qui. E le impronte piegano verso il bastione.": {
"file": "andrea-5b0cf4f9cf.mp3",
"sec": 5.03
@@ -231,38 +271,14 @@
"file": "andrea-aa9b313a43.mp3",
"sec": 2.22
},
"andrea|Troppo buio: non vedo nemmeno dove lo infilo. Prima ci vuole una luce.": {
"file": "andrea-59df1ada58.mp3",
"sec": 4.78
},
"andrea|Accendo la torcia e la punto dentro...": {
"file": "andrea-68a845881b.mp3",
"sec": 1.94
},
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
"file": "andrea-6484641c58.mp3",
"sec": 8.67
},
"andrea|È il palio! Il quadro dei ragazzi delle medie!": {
"file": "andrea-51ec04c16e.mp3",
"sec": 3.54
},
"andrea|E quelli... sono due occhi gialli!": {
"file": "andrea-0f5f2a4b2f.mp3",
"sec": 2.63
},
"andrea|Spariti nel buio.": {
"file": "andrea-f34cc00733.mp3",
"sec": 1.17
},
"andrea|Il Regolo. E ha il nostro palio.": {
"file": "andrea-1b2601be96.mp3",
"sec": 2.77
},
"andrea|Però non l'ha portato via: l'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.": {
"file": "andrea-abeac78e68.mp3",
"sec": 7.13
},
"andrea|Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!": {
"file": "andrea-28f672ef5f.mp3",
"sec": 5.42
},
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
"file": "andrea-43fd5c2b27.mp3",
"sec": 4.46
@@ -279,6 +295,14 @@
"file": "andrea-6012288000.mp3",
"sec": 7.97
},
"san_pietro|Figliolo, io tengo le chiavi, non le squame. Portala a chi l'ha persa.": {
"file": "san_pietro-28fac8029b.mp3",
"sec": 5.6
},
"san_pietro|Te l'ho data io: usala, non restituirla. Le chiavi servono alle porte.": {
"file": "san_pietro-72373bd589.mp3",
"sec": 6.13
},
"andrea|La statua di San Pietro, con le chiavi in mano. Lui il palio l'ha visto passare di sicuro.": {
"file": "andrea-0663f64ddf.mp3",
"sec": 5.97
@@ -587,6 +611,10 @@
"file": "andrea-a8dfddbe65.mp3",
"sec": 3.45
},
"andrea|Un lucchetto non si pesca. Ci vuole la sua chiave.": {
"file": "andrea-d193b44288.mp3",
"sec": 3.65
},
"andrea|Il tabernacolo prima del ponte. Chi passa per andare via dal paese, un saluto glielo fa sempre.": {
"file": "andrea-78a5ef65f0.mp3",
"sec": 7.84
@@ -627,6 +655,18 @@
"file": "andrea-e47b3cebb5.mp3",
"sec": 4.87
},
"barondeduke|Me la giochi? Ho un sette e un due, ma un sacco di fiducia.": {
"file": "barondeduke-52184c7c05.mp3",
"sec": 5.58
},
"andrea|No, Barone. A poker le squame non valgono.": {
"file": "andrea-40651c0ea4.mp3",
"sec": 4.39
},
"barondeduke|A questo giro non punto niente. Nemmeno un ghiacciolo.": {
"file": "barondeduke-eff8685095.mp3",
"sec": 4.28
},
"andrea|Il BaronDeDuke, col cappello da cowboy. Ha l'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.": {
"file": "andrea-0bd1b0f32e.mp3",
"sec": 8.7
@@ -803,6 +843,18 @@
"file": "andrea-bb70dea2a6.mp3",
"sec": 2.15
},
"arcu|Che è, la mancia? Una squama? Grazie, la metto in cassaforte.": {
"file": "arcu-8c7f1850a3.mp3",
"sec": 5.84
},
"arcu|Tienila te, che io di roba verde ne ho già abbastanza sul lavoro.": {
"file": "arcu-bb69d88654.mp3",
"sec": 3.23
},
"arcu|Un libro? A me? Lo sai che io leggo solo le etichette degli armadi.": {
"file": "arcu-1da81f6f98.mp3",
"sec": 6.44
},
"andrea|L'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.": {
"file": "andrea-8ddab2d024.mp3",
"sec": 5.79
@@ -839,6 +891,18 @@
"file": "andrea-455ac4031a.mp3",
"sec": 4.96
},
"andrea|Tosca, guarda cosa ho trovato.": {
"file": "andrea-f5ff3c0ef9.mp3",
"sec": 2.27
},
"tosca|Bella. Ma nella cedrata questa non ce la metto.": {
"file": "tosca-7451ea2ef5.mp3",
"sec": 3.81
},
"tosca|Un libro? Andrea, qui si legge solo il listino. E costa meno.": {
"file": "tosca-ffc7cea9af.mp3",
"sec": 6.46
},
"andrea|Il chiosco del tennis. D'estate qui c'è la Tosca coi ghiaccioli.": {
"file": "andrea-24214bad4e.mp3",
"sec": 5.6
@@ -959,6 +1023,26 @@
"file": "andrea-468fe91000.mp3",
"sec": 3.28
},
"leone|Ehi! Mi abbagli! Sono di terracotta, mica cieco.": {
"file": "leone-4addbac9c7.mp3",
"sec": 6
},
"andrea|Scusa, leone.": {
"file": "andrea-66a622cd5e.mp3",
"sec": 0.92
},
"leone|Quella tienila per il buio. Qui c'è il sole, se non l'hai notato.": {
"file": "leone-19d904d4e1.mp3",
"sec": 5.66
},
"leone|Una squama? Io di squame ne ho viste... mai. Io faccio la guardia alla salita.": {
"file": "leone-7bd7f55e53.mp3",
"sec": 8.73
},
"andrea|Lo so, lo so. Alla salita.": {
"file": "andrea-62c970e189.mp3",
"sec": 2.65
},
"andrea|Di là si va alla scuola. Ci ho passato abbastanza anni, grazie.": {
"file": "andrea-f6fa5d6d6d.mp3",
"sec": 4.03
@@ -975,6 +1059,14 @@
"file": "andrea-f5d55187e8.mp3",
"sec": 5.11
},
"tozzino|Grazie, ma prima della partita niente zuccheri. Federer non li tocca.": {
"file": "tozzino-cfc1b1fa67.mp3",
"sec": 4.8
},
"tozzino|Una torcia? Se Federer arriva di notte, magari. Per ora tienila te.": {
"file": "tozzino-18e6d6edff.mp3",
"sec": 5.58
},
"andrea|Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l'orologio ogni dieci secondi.": {
"file": "andrea-3c32888a15.mp3",
"sec": 8.1
@@ -1135,6 +1227,14 @@
"file": "andrea-a1dab4073c.mp3",
"sec": 3.82
},
"castello|Luce? In seicento anni ne ho vista tanta. Tienila per il buio, che quello non manca mai.": {
"file": "castello-c2cc9224c5.mp3",
"sec": 9.1
},
"castello|Non a me. Portala vicino a chi l'ha persa: le cose parlano solo dove sono cadute.": {
"file": "castello-3267e20838.mp3",
"sec": 7.74
},
"andrea|La torre del Trebbio. Da ragazzo ci si rifugiò Amerigo Vespucci, per scappare dalla peste di Firenze.": {
"file": "andrea-14cbceece4.mp3",
"sec": 6.99
@@ -1295,6 +1395,10 @@
"file": "andrea-2d0e3c759e.mp3",
"sec": 1.39
},
"andrea|Tosca, cosa si dice in giro?": {
"file": "andrea-be96b05f57.mp3",
"sec": 2.04
},
"andrea|Niente, grazie Tosca.": {
"file": "andrea-2637918e77.mp3",
"sec": 1.32
@@ -1319,6 +1423,14 @@
"file": "tosca-376fbb9f17.mp3",
"sec": 2.41
},
"tosca|Che sei andato sotto la Fortezza da solo. Il paese parla, Andrea.": {
"file": "tosca-ad26d2c8dc.mp3",
"sec": 5.16
},
"tosca|Prenditi una cedrata, che le chiacchiere mettono sete.": {
"file": "tosca-940ffeb62c.mp3",
"sec": 2.98
},
"tosca|Quando ti viene sete, sai dove trovarmi.": {
"file": "tosca-db2da31cef.mp3",
"sec": 2.31
@@ -1383,6 +1495,18 @@
"file": "andrea-379131645c.mp3",
"sec": 1.98
},
"andrea|Ho trovato una squama, sotto la panchina.": {
"file": "andrea-5a36e0de20.mp3",
"sec": 2.57
},
"andrea|Sotto la Fortezza ci sono impronte, e vanno verso il bastione.": {
"file": "andrea-dc9addb491.mp3",
"sec": 3.56
},
"andrea|Ho una torcia. Stasera salgo alla Fortezza.": {
"file": "andrea-c458e32a56.mp3",
"sec": 4.28
},
"andrea|Ci vediamo dopo.": {
"file": "andrea-7e9841c23a.mp3",
"sec": 0.95
@@ -1423,6 +1547,42 @@
"file": "vecchietta-fe3c07afa2.mp3",
"sec": 5.11
},
"vecchietta|L'avevo detto io, che luccicava!": {
"file": "vecchietta-8bcd369812.mp3",
"sec": 2.3
},
"vecchietto|Una squama a maggio? Allora il Regolo fa la muta, come le bisce.": {
"file": "vecchietto-33b2aadc49.mp3",
"sec": 4.64
},
"vecchietta|O l'ha persa uno della sfilata, col costume. Che ne sai.": {
"file": "vecchietta-51d10531a0.mp3",
"sec": 4.34
},
"vecchietto|Lo sapevo. Stanotte ho sentito raspare, dalla parte della Fortezza.": {
"file": "vecchietto-7204d5b851.mp3",
"sec": 5.29
},
"vecchietta|Tu stanotte dormivi. L'hai detto tu.": {
"file": "vecchietta-8f618fae8c.mp3",
"sec": 2.75
},
"vecchietto|Si può dormire e sentire. Alla mia età si fa tutto insieme.": {
"file": "vecchietto-320caa6792.mp3",
"sec": 4.92
},
"vecchietto|Di sera? Lassù? Allora salutaci il Regolo.": {
"file": "vecchietto-9d2e14790f.mp3",
"sec": 4.08
},
"vecchietta|E se ti mangia, almeno la festa ha un argomento.": {
"file": "vecchietta-7baa7686cd.mp3",
"sec": 2.94
},
"andrea|Grazie dell'incoraggiamento.": {
"file": "andrea-f845779a10.mp3",
"sec": 1.57
},
"vecchietta|Salutaci i tuoi!": {
"file": "vecchietta-814e558af5.mp3",
"sec": 1.47
@@ -1439,6 +1599,22 @@
"file": "andrea-413cf7673b.mp3",
"sec": 1.56
},
"andrea|Ho trovato una squama del Regolo.": {
"file": "andrea-51314c3413.mp3",
"sec": 1.69
},
"andrea|Sotto la Fortezza ci sono le sue impronte.": {
"file": "andrea-898cf87dd0.mp3",
"sec": 2.49
},
"andrea|Ho la torcia. Stasera entro nella fessura.": {
"file": "andrea-343fb5c806.mp3",
"sec": 3.33
},
"andrea|Cosa stavamo dicendo???": {
"file": "andrea-06f5050907.mp3",
"sec": 1.24
},
"andrea|Ci si becca da i' Trappola, ai' Barrettino!": {
"file": "andrea-fca6f59a70.mp3",
"sec": 3.41
@@ -1451,6 +1627,38 @@
"file": "arcu-5d07c15776.mp3",
"sec": 2.89
},
"arcu|Fammi vedere... Verde. Come la Panda di mi' zio.": {
"file": "arcu-af7afda339.mp3",
"sec": 4.18
},
"arcu|Verde! Allora il Regolo tifa Cardetole. Tradimento.": {
"file": "arcu-85c7d0b8f6.mp3",
"sec": 5.57
},
"arcu|E te ci vai da solo? Io ti aspetto qui.": {
"file": "arcu-e157a4b70b.mp3",
"sec": 2.73
},
"arcu|Qualcuno deve raccontarlo, dopo.": {
"file": "arcu-5d1fa73d32.mp3",
"sec": 1.77
},
"arcu|Fai una cosa: se vedi due occhi gialli, un sono i fari della Provinciale.": {
"file": "arcu-fdd1ab5f5b.mp3",
"sec": 4.37
},
"andrea|Grazie, Arcu. Molto utile.": {
"file": "andrea-2caf0f40f2.mp3",
"sec": 2.73
},
"andrea|Ma ti si è bruciata la memoria, fratello???!?": {
"file": "andrea-bf8a51895b.mp3",
"sec": 2.98
},
"arcu|...I pompieri per cosa?": {
"file": "arcu-5148dffe36.mp3",
"sec": 1.7
},
"andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3",
"sec": 3.9
@@ -1527,6 +1735,34 @@
"file": "castello-dc8a873d86.mp3",
"sec": 4.36
},
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
"file": "andrea-6484641c58.mp3",
"sec": 8.67
},
"andrea|È il palio! Il quadro dei ragazzi delle medie!": {
"file": "andrea-51ec04c16e.mp3",
"sec": 3.54
},
"andrea|E quelli... sono due occhi gialli!": {
"file": "andrea-0f5f2a4b2f.mp3",
"sec": 2.63
},
"andrea|Spariti nel buio.": {
"file": "andrea-f34cc00733.mp3",
"sec": 1.17
},
"andrea|Il Regolo. E ha il nostro palio.": {
"file": "andrea-1b2601be96.mp3",
"sec": 2.77
},
"andrea|Però non l'ha portato via: l'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.": {
"file": "andrea-abeac78e68.mp3",
"sec": 7.13
},
"andrea|Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!": {
"file": "andrea-28f672ef5f.mp3",
"sec": 5.42
},
"andrea|Mi ricordavo un finale diverso.": {
"file": "andrea-d9ff79ed54.mp3",
"sec": 1.73
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.
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.
+9
View File
@@ -96,3 +96,12 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
- **La racchetta del Tozzino** (Andrea, 25 ottobre): non la trova, Federer sta arrivando, ha paura di perdere e chiede
aiuto ad Andrea. Si potrebbe legare al poker del Barone (persa la stessa sera?). Serve un foglio sprite del Tozzino
senza racchetta, più una posa con la racchetta ritrovata.
- **Consigli di Codex (25 ottobre), da valutare dopo 5 e 1 (risposte ai tentativi sbagliati e pettegolezzo che
cambia, fatti):**
- *Colpo di scena a metà*: la squama somiglia al Regolo della sfilata... ma allora chi ha lasciato le impronte?
Solo con dialoghi e una riga nel taccuino, senza toccare la catena degli enigmi.
- *Un enigma con l'Arcu*: tiene qualcosa, distrae qualcuno o fraintende un'istruzione (gag). Uno solo: tocca
soluzione, test, voci e la sua posizione in ogni scena dove compare (le pose ci sono).
- *Regia alla fessura*: col meccanismo del primo piano (`closeup`) il buio, la torcia, un istante di silenzio,
poi gli occhi. Due immagini di Codex.
- *Segreti facoltativi del paese* con pagine illustrate nel taccuino: funzione nuova, dopo la storia principale.
+4
View File
@@ -1,5 +1,9 @@
# Da dove vengono le immagini
- `assets/closeups/palio-2007.jpg`: copia della foto `assets/raw/palio-2007-foto.jpg` fornita da Andrea
il 24 ottobre 2026. Usata nel primo piano della fessura; il renderer mostra soltanto la tela tramite
le coordinate `story.closeups.fessura.crop`, senza modificare la foto originale.
Da controllare **prima del primo push**: quello che è nella storia di git, una volta pubblicato, resta pubblico
anche se poi lo si cancella. Le foto originali (`assets/raw/`) e i prompt (`prompts/`) sono esclusi da git e non sono
mai stati committati (verificato il 2026-10-23); qui si parla degli sfondi ridisegnati in `assets/scenes/`, che invece
+17
View File
@@ -18,6 +18,23 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
Nuovi paesaggi sonori sintetizzati con Web Audio (`src/engine/soundscape.js`, profili in
`src/story/soundscapes.js`): vento, acqua, uccelli di giorno e grilli di sera, campane, bicchieri,
gocce e scricchiolii distanziati. In **Pausa → Ambiente ed effetti** si regola il volume, salvato nel browser.
Muto, pausa e pagina nascosta fermano anche i suoni già avviati.
La **torcia nella fessura** apre un primo piano: mouse, tocco/trascinamento o frecce spostano la luce;
Invio aiuta a cercare il prossimo dettaglio. Pietra → palio → occhi; la musica si abbassa durante la ricerca.
«Torna fuori»/Esc permette di riprovare; soltanto dopo gli occhi «Continua» avvia la conclusione.
Nessun progresso parziale viene salvato. La tela usa la foto vera del 2007, ritagliata durante il disegno.
Controllo mirato: `npm run browser -- --media-only`; il collaudo normale include anche queste prove.
Verificati `npm test` e `npm run browser`: soluzione completa, salvataggi, libro su desktop/telefono,
torcia con mouse/tastiera/tocco/trascinamento, pausa e muto, segnale Web Audio senza saturazione.
Schermate in `/tmp/regolo-shots/media-*.png` e `/tmp/regolo-shots/05b-fessura-*.png`.
I copioni video sono aggiornati ai gesti richiesti dalla torcia; il montaggio audio da file continua a
includere solo musica e voci (gli effetti Web Audio non vengono esportati dal copione).
- **Ambienti animati (25 ottobre)**: alle prove già presenti (bandiere del campeggio, fascio di luce nella Pieve,
ombre al Bosco ai Frati) si aggiungono 51 dettagli nelle altre 35 scene. Configurazione in
`src/story/ambience.js`, richiamata da `story.js`; nuovi disegni in `src/engine/ambient.js`:
+4 -1
View File
@@ -19,7 +19,7 @@
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
</div>
<div id="sound">
<button id="mute" aria-label="Musica: togli l'audio" title="Musica (M)">♪</button>
<button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button>
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica">
<label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label>
<input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
@@ -73,6 +73,9 @@
</section>
<section id="pause" class="overlay" hidden aria-label="Pausa">
<h2>Pausa</h2>
<label class="effects-control" for="effects-volume">Ambiente ed effetti
<input id="effects-volume" type="range" min="0" max="100" step="5" aria-label="Volume ambiente ed effetti">
</label>
<div class="buttons pause-buttons">
<button id="resume">Riprendi</button>
<button id="pause-map" class="secondary">Mappa</button>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"type": "module",
"scripts": {
+41 -2
View File
@@ -229,6 +229,7 @@ export class Game {
closeupTap() {
const c = this.closeup;
if (!c || this.speech) return false;
if (this.story.closeups[c.id].type === 'torch') return false;
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
else return false;
@@ -241,11 +242,46 @@ export class Game {
if (!c) return false;
this.closeup = null;
this.speech = null;
const def = this.story.closeups?.[c.id];
if (def?.type === 'torch') {
if (c.step === 'done') this.queue.unshift(...def.complete);
return true;
}
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
return true;
}
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) {
const c = this.closeup;
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true;
}
updateTorch(dt) {
const c = this.closeup, def = this.story.closeups[c.id];
c.t += dt;
if (c.lines?.length) { this.run(c.lines.shift()); return; }
if (c.step === 'reveal') {
if (c.t < 1.4) return;
c.found++;
c.step = c.found === def.targets.length ? 'done' : 'search';
c.t = c.dwell = 0;
return;
}
const target = def.targets[c.found];
if (c.step !== 'search' || !target) return;
c.dwell = Math.hypot(c.beam[0] - target.at[0], c.beam[1] - target.at[1]) < target.radius ? c.dwell + dt : 0;
if (c.dwell >= 0.45) {
c.step = 'reveal';
c.t = 0;
c.lines = [...(target.lines ?? [])];
}
}
options() {
if (!this.dialogue || this.busy) return [];
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
@@ -341,6 +377,7 @@ export class Game {
// Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento.
const c = this.closeup;
if (c) {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
c.t += dt;
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 });
if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
@@ -348,7 +385,7 @@ export class Game {
return;
}
// Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo.
while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift());
while (!this.speech && !this.walking && !this.closeup && this.queue.length) this.run(this.queue.shift());
this.commit();
}
@@ -372,7 +409,9 @@ export class Game {
else if (e.node) this.dialogue.node = e.node;
else if (e.endDialogue) this.dialogue = null;
else if (e.end) s.ended = true;
else if (e.closeup) this.closeup = { id: e.closeup, step: 'cover', t: 0, age: 0 };
else if (e.closeup) this.closeup = this.story.closeups[e.closeup].type === 'torch'
? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
}
+6
View File
@@ -2,6 +2,7 @@
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
@@ -663,6 +664,11 @@ const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [ti
function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return;
if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture));
return;
}
ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
+132
View File
@@ -0,0 +1,132 @@
// Web Audio: vento/acqua filtrati e piccoli eventi sintetizzati. Nessun download,
// nessun timer in sottofondo; il ciclo del gioco decide quando suonare e quando fermarsi.
import { soundscapes } from '../story/soundscapes.js';
export class Soundscape {
constructor({ volume = 0.45, createContext = () => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)(), random = Math.random } = {}) {
this.volume = volume;
this.createContext = createContext;
this.random = random;
this.context = null;
this.nodes = new Set();
this.active = false;
this.muted = false;
this.key = null;
this.elapsed = 0;
this.next = 4;
this.bed = null;
}
unlock() {
try {
if (!this.context) {
this.context = this.createContext();
this.master = this.context.createGain();
this.master.gain.value = 0;
this.master.connect(this.context.destination);
const rate = this.context.sampleRate;
this.noise = this.context.createBuffer(1, rate * 4, rate);
const data = this.noise.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = this.random() * 2 - 1;
}
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
} catch {
// Anche un'inizializzazione parziale fallita deve lasciare giocabile l'avventura.
this.master?.disconnect(); this.context = null; this.master = null; this.noise = null;
}
}
get audible() { return !!this.context && this.active && !this.muted && this.volume > 0; }
configure({ active = this.active, muted = this.muted, volume = this.volume, duck = 1 } = {}) {
this.active = active;
this.muted = muted;
this.volume = Math.max(0, Math.min(1, volume));
if (!this.context) return;
const gain = this.audible ? this.volume * duck : 0;
this.master.gain.cancelScheduledValues(this.context.currentTime);
if (!gain) {
this.master.gain.setValueAtTime(0, this.context.currentTime);
this.clear();
} else this.master.gain.setTargetAtTime(gain, this.context.currentTime, 0.12);
}
clear() {
for (const node of [...this.nodes]) node.stop();
this.bed = null;
}
// Tutti i nodi, inclusi filtro e gain, vengono scollegati alla fine del suono.
connect(source, gain, filter = null, pan = 0) {
const panner = this.context.createStereoPanner?.();
if (panner) panner.pan.value = pan;
const chain = [source, filter, gain, panner, this.master].filter(Boolean);
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
const node = { stop: () => {
if (!this.nodes.delete(node)) return;
try { source.stop(); } catch { /* già finito */ }
chain.slice(0, -1).forEach(n => n.disconnect());
} };
source.onended = node.stop;
this.nodes.add(node);
return node;
}
noiseSound(duration, frequency, level, { loop = false, pan = 0, paper = false } = {}) {
const c = this.context, now = c.currentTime;
const source = c.createBufferSource(), filter = c.createBiquadFilter(), gain = c.createGain();
source.buffer = this.noise; source.loop = loop;
filter.type = paper ? 'bandpass' : 'lowpass'; filter.frequency.value = frequency; filter.Q.value = 0.5;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(level, now + (loop ? 1.3 : duration * 0.15));
if (!loop) gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
const node = this.connect(source, gain, filter, pan);
source.start();
if (!loop) source.stop(now + duration);
return { node, gain, level };
}
tone(frequency, end, duration, level, delay = 0, pan = 0) {
const c = this.context, at = c.currentTime + delay, source = c.createOscillator(), gain = c.createGain();
source.type = 'sine';
source.frequency.setValueAtTime(frequency, at);
source.frequency.exponentialRampToValueAtTime(end, at + duration);
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(level, at + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, at + duration);
this.connect(source, gain, null, pan);
source.start(at); source.stop(at + duration + 0.03);
}
play(kind, { pan = (this.random() - 0.5) * 1.3 } = {}) {
if (!this.audible) return;
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') {
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
} else if (kind === 'cricket') {
for (let i = 0; i < 5; i++) this.tone(3900, 4200, 0.055, 0.016, i * 0.12, pan);
} else if (kind === 'bell' || kind === 'glass') {
const base = kind === 'bell' ? 440 : 1420, duration = kind === 'bell' ? 3.8 : 0.65;
for (const [ratio, level] of [[1, 0.035], [2.76, 0.012], [4.07, 0.006]])
this.tone(base * ratio, base * ratio, duration, level, 0, pan);
}
}
update(profile, phase, dt) {
if (!this.audible) return;
const key = `${profile}:${phase}`, def = soundscapes[profile];
if (!def) return;
if (this.key !== key) {
this.clear(); this.key = key; this.elapsed = 0; this.next = 4 + this.random() * 5;
}
if (!this.bed && def.bed) this.bed = this.noiseSound(0, def.bed === 'water' ? 1700 : 340, def.level, { loop: true });
this.elapsed += dt;
if (this.bed && this.elapsed > 1.5) this.bed.gain.gain.setTargetAtTime(def.level * (0.8 + 0.2 * Math.sin(this.elapsed * 0.7)), this.context.currentTime, 0.6);
if (this.elapsed < this.next) return;
const events = phase === 'sera' ? def.night : def.day;
this.play(events[Math.floor(this.random() * events.length)]);
this.next = this.elapsed + def.gap[0] + this.random() * (def.gap[1] - def.gap[0]);
}
}
+2 -1
View File
@@ -60,7 +60,8 @@ export function readAudio(storage) {
try {
const a = JSON.parse(storage.getItem(AUDIO_KEY));
if (!a || typeof a.volume !== 'number') return null;
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }) };
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }),
...(typeof a.effects === 'number' && Number.isFinite(a.effects) && { effects: Math.max(0, Math.min(1, a.effects)) }) };
} catch {
return null;
}
+89
View File
@@ -0,0 +1,89 @@
// Primo piano della fessura, disegnato alla risoluzione del gioco. Il quadro è la tela
// della foto del 2007 fornita da Andrea; il ritaglio viene eseguito solo nel disegno.
import { ambientHash as hash } from './ambient.js';
export function drawTorch(ctx, c, def, picture) {
const [x, y] = c.beam;
ctx.save();
ctx.fillStyle = '#090c10';
ctx.fillRect(0, 0, 640, 360);
// Muratura irregolare, muschio e giunti profondi.
for (let row = 0; row < 7; row++) for (let col = 0; col < 10; col++) {
const n = row * 10 + col, px = col * 73 - (row % 2) * 36, py = 24 + row * 47;
ctx.fillStyle = ['#4b4940', '#555247', '#3e443c', '#615b4b'][Math.floor(hash(n + 1) * 4)];
ctx.fillRect(px + 2, py + 2, 68, 42);
ctx.fillStyle = '#73705b';
ctx.fillRect(px + 5, py + 3, 61, 2);
ctx.fillStyle = '#282e29';
ctx.fillRect(px + 3, py + 39, 65, 4);
for (let k = 0; k < 6; k++) {
ctx.fillStyle = k % 2 ? '#424b35' : '#77715a';
ctx.fillRect(px + 7 + Math.floor(hash(n * 9 + k) * 54), py + 7 + Math.floor(hash(n * 13 + k) * 28), 3, 2);
}
}
// Il vano si perde dietro il muro; il quadro è appoggiato a terra, senza cornice dorata.
ctx.fillStyle = '#111617';
ctx.beginPath();
[[250, 84], [470, 67], [533, 135], [527, 288], [247, 300], [271, 231]].forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b));
ctx.closePath(); ctx.fill();
if (c.found >= 1) {
ctx.save();
ctx.translate(350, 224); ctx.rotate(-0.09);
ctx.fillStyle = '#242320'; ctx.fillRect(-48, -79, 98, 144);
if (picture) ctx.drawImage(picture, ...def.crop, -46, -77, 92, 138);
else {
// Anche se l'immagine non arriva il ritrovamento resta comprensibile e giocabile.
ctx.fillStyle = '#a6a67c'; ctx.fillRect(-46, -77, 92, 138);
ctx.fillStyle = '#e4d8ac'; ctx.beginPath(); ctx.moveTo(-43, 54); ctx.lineTo(0, -50); ctx.lineTo(10, -50); ctx.lineTo(25, 54); ctx.fill();
ctx.fillStyle = '#a83e2f'; ctx.fillRect(-6, 0, 12, 21); ctx.fillRect(-17, 0, 34, 5);
}
ctx.restore();
}
const eyes = c.found === 2 && c.step === 'reveal';
if (eyes) {
ctx.fillStyle = '#e5bd51';
for (const ex of [447, 470]) {
ctx.fillRect(ex, 134, 13, 8); ctx.fillRect(ex + 3, 132, 7, 12);
ctx.fillStyle = '#151912'; ctx.fillRect(ex + 6, 133, 2, 10);
ctx.fillStyle = '#fff1ad'; ctx.fillRect(ex + 3, 135, 2, 2);
ctx.fillStyle = '#e5bd51';
}
}
// Oscuramento radiale: il centro caldo lascia leggibile il dettaglio, fuori è quasi nero.
const light = ctx.createRadialGradient(x, y, 16, x, y, 112);
light.addColorStop(0, 'rgba(8,10,15,0)');
light.addColorStop(0.42, 'rgba(8,10,15,0.12)');
light.addColorStop(0.75, 'rgba(4,6,10,0.8)');
light.addColorStop(1, 'rgba(3,5,9,0.985)');
ctx.fillStyle = light; ctx.fillRect(0, 0, 640, 360);
// Qualche granello di polvere attraversa il fascio, senza lampeggiamenti.
for (let i = 0; i < 15; i++) {
const px = (hash(i + 100) * 640 + c.age * (2 + hash(i) * 3)) % 640;
const py = (hash(i + 200) * 360 + c.age * 2) % 360;
const distance = Math.hypot(px - x, py - y);
if (distance < 83) { ctx.fillStyle = `rgba(233,216,165,${0.35 * (1 - distance / 83)})`; ctx.fillRect(Math.round(px), Math.round(py), 1, 1); }
}
// Pietra vicina ai bordi: il giocatore guarda attraverso una fessura.
ctx.fillStyle = '#181b1c';
for (const points of [[[0, 0], [77, 0], [60, 97], [85, 178], [53, 278], [95, 360], [0, 360]],
[[640, 0], [568, 0], [594, 104], [564, 194], [583, 265], [552, 360], [640, 360]]]) {
ctx.beginPath(); points.forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b)); ctx.closePath(); ctx.fill();
}
// Torcia impugnata sul bordo inferiore, il puntatore resta libero per cercare.
ctx.save();
ctx.translate(430, 328); ctx.rotate((x - 320) / 1100);
const rect = (color, ...box) => { ctx.fillStyle = color; ctx.fillRect(...box); };
rect('#372722', 7, -2, 30, 44); rect('#9b6849', 10, 0, 24, 42);
rect('#c18c61', 11, 0, 11, 38); rect('#50382e', 29, 10, 5, 32);
// Corpo rosso e bordo metallico, come l'icona della torcia nell'inventario.
rect('#171c20', -16, -28, 28, 62); rect('#762e27', -13, -18, 21, 50);
rect('#a74330', -11, -17, 7, 46); rect('#4a2925', 4, -17, 4, 49);
for (let i = 0; i < 5; i++) rect('#482924', -12, 10 + i * 4, 20, 1);
rect('#343b3b', -19, -29, 34, 12); rect('#93998a', -17, -29, 30, 3);
rect('#d8ce9f', -15, -31, 26, 3); rect('#777c71', -17, -24, 3, 6);
rect('#d0a078', 4, -5, 14, 8); rect('#a37350', 4, 2, 14, 2);
for (let i = 0; i < 3; i++) { rect('#bf8b60', 4, 6 + i * 8, 17, 6); rect('#71503c', 5, 12 + i * 8, 16, 2); }
rect('#dfb58b', 6, -4, 5, 3);
ctx.restore();
ctx.restore();
}
+62 -33
View File
@@ -6,6 +6,8 @@ import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/rende
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js';
import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id);
@@ -22,22 +24,26 @@ let game = null, command = null, hoverTarget = null, mouse = null, running = fal
// --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volumi e muto si ricordano.
const audioSettings = (storage && readAudio(storage)) ?? {};
const music = new Music(audioSettings);
const ambience = new Soundscape({ volume: audioSettings.effects });
window.ambience = ambience;
// --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica,
// ma hanno il loro volume.
const voices = new Voices({}, undefined, audioSettings.voices);
const saveAudio = () => storage && writeAudio(storage, { ...music.settings(), voices: voices.volume });
const saveAudio = () => storage && writeAudio(storage, { ...music.settings(), voices: voices.volume, effects: ambience.volume });
function showSound() {
$('mute').setAttribute('aria-pressed', String(music.muted));
$('mute').setAttribute('aria-label', music.muted ? "Musica: rimetti l'audio" : "Musica: togli l'audio");
$('mute').setAttribute('aria-label', music.muted ? "Rimetti l'audio" : "Togli l'audio");
$('volume').value = Math.round(music.volume * 100);
$('voice-volume').value = Math.round(voices.volume * 100);
$('effects-volume').value = Math.round(ambience.volume * 100);
}
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => music.unlock(), { capture: true });
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => { music.unlock(); ambience.unlock(); }, { capture: true });
// Il muto zittisce subito anche la voce che sta parlando.
const toggleMute = () => { if (music.toggle()) voices.stop(); saveAudio(); showSound(); };
const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); };
$('mute').addEventListener('click', toggleMute);
$('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); });
$('voice-volume').addEventListener('input', e => { voices.setVolume(e.target.value / 100); saveAudio(); showSound(); });
$('effects-volume').addEventListener('input', e => { ambience.configure({ volume: e.target.value / 100 }); saveAudio(); showSound(); });
showSound();
window.music = music; // per i test nel browser
@@ -171,7 +177,7 @@ $('close-credits').addEventListener('click', () => { $('credits').hidden = true;
// dalla Fortezza ai campi), e in ogni gradino da sinistra a destra. Le strade si disegnano dopo, fra le schede.
const MAP_BANDS = [25, 50, 75, Infinity];
function openMap() {
if (!running || game.dialogue) return;
if (!running || game.dialogue || game.closeup) return;
const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces();
const bands = MAP_BANDS.map(() => []);
for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p);
@@ -261,8 +267,9 @@ function openPause() {
running = false;
music.hold = true;
voices.pause();
ambience.configure({ active: false });
command.reset();
$('pause-map').hidden = game.mapPlaces().length < 2;
$('pause-map').hidden = !!game.closeup || game.mapPlaces().length < 2;
$('pause').hidden = false;
}
function closePause() {
@@ -278,6 +285,7 @@ $('pause-button').addEventListener('click', openPause);
function leave(hidden) {
music.setAway(hidden);
if (hidden) {
ambience.configure({ active: false });
voices.pause();
window.jingle?.pause();
if (running) openPause();
@@ -301,7 +309,7 @@ $('close-map').addEventListener('click', () => { $('map').hidden = true; });
// --- Taccuino: le tappe fatte, barrate, e quella di adesso; in fondo dove chiedere aiuto.
function openNotebook() {
if (!running) return;
if (!running || game.closeup) return;
const { done, now } = game.notebook();
$('notebook-list').replaceChildren(...done.map(t => Object.assign(document.createElement('li'), { textContent: t, className: 'done' })),
...(now ? [Object.assign(document.createElement('li'), { textContent: now, className: 'now' })] : []));
@@ -346,6 +354,7 @@ $('load-file').addEventListener('change', async e => {
function showTitle() {
running = false;
ambience.configure({ active: false });
const saved = storage && readSave(storage);
$('continue').hidden = !saved || saved.ended;
$('load-error').hidden = true;
@@ -386,13 +395,18 @@ canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { press = { closeup: true }; return; }
if (game.closeup) {
press = { closeup: true };
game.closeupAim(scenePoint(e));
canvas.setPointerCapture(e.pointerId);
return;
}
const point = scenePoint(e), reach = reachOf(e);
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
});
canvas.addEventListener('pointerup', e => {
if (!press) return;
if (press.closeup) { press = null; if (!game.skip()) game.closeupTap(); return; }
if (press.closeup) { press = null; if (running && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) {
@@ -411,6 +425,7 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => {
if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
@@ -447,7 +462,12 @@ function updateInventory() {
icon = document.createElement('i');
icon.style.background = item.icon?.fill ?? '#ccc';
}
b.append(icon, item.name);
// Il nome in uno span: i nomi lunghi («stecco e gomma») vanno a capo su due righe strette, non su tre.
const name = document.createElement('span');
// Dopo l'apostrofo si può andare a capo («chiave dell'» / «oratorio»): le parole restano intere.
name.textContent = item.name.replace(/'/g, "'\u200b");
if (item.name.length > 10) name.className = 'long';
b.append(icon, name);
let timer = null, long = false;
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
b.addEventListener('pointerup', () => clearTimeout(timer));
@@ -481,7 +501,7 @@ function updateChoices() {
}
window.addEventListener('keydown', e => {
if (e.target === $('volume') || e.target === $('voice-volume')) return;
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key !== 'Escape') return;
// Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude.
if (e.target === $('map-search')) {
if (e.key === 'Escape') { if (e.target.value) { e.target.value = ''; filterMap(); } else $('map').hidden = true; }
@@ -494,6 +514,20 @@ window.addEventListener('keydown', e => {
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) {
if (story.closeups[game.closeup.id].type === 'torch' && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
// Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore.
e.preventDefault();
const c = game.closeup, target = story.closeups[c.id].targets[c.found];
if (e.key === 'Enter') {
if (game.speech) game.skip();
else if (c.step === 'done') game.closeupClose();
else if (target) game.closeupAim(target.at);
} else {
const move = { ArrowLeft: [-24, 0], ArrowRight: [24, 0], ArrowUp: [0, -24], ArrowDown: [0, 24] }[e.key];
game.closeupAim([c.beam[0] + move[0], c.beam[1] + move[1]]);
}
return;
}
if (e.key === 'Escape') game.closeupClose();
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
else if (e.key === 'm' || e.key === 'M') toggleMute();
@@ -514,25 +548,7 @@ window.addEventListener('keydown', e => {
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose());
let turning = null, audioCtx = null;
function rustle() {
if (music.muted || voices.volume <= 0) return;
try {
audioCtx ??= new AudioContext();
const sr = audioCtx.sampleRate, len = Math.floor(sr * 0.42), buf = audioCtx.createBuffer(1, len, sr), d = buf.getChannelData(0);
// Un fruscio a strappi: rumore che si accende e si spegne, come la carta che striscia.
for (let i = 0; i < len; i++) { const t = i / len; d[i] = (Math.random() * 2 - 1) * Math.sin(Math.PI * t) * (0.55 + 0.45 * Math.sin(t * 38)); }
const src = audioCtx.createBufferSource(), filter = audioCtx.createBiquadFilter(), gain = audioCtx.createGain();
src.buffer = buf;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(2400, audioCtx.currentTime);
filter.frequency.linearRampToValueAtTime(5200, audioCtx.currentTime + 0.42);
filter.Q.value = 0.8;
gain.gain.value = 0.5 * voices.volume;
src.connect(filter).connect(gain).connect(audioCtx.destination);
src.start();
} catch { /* senza audio si gira la pagina in silenzio */ }
}
let turning = null, reveal = null;
// --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
function updateSpeech() {
@@ -560,7 +576,10 @@ function updateUi() {
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
const text = !running || game.speech || game.dialogue || game.closeup ? '' : command.sentence(hoverTarget);
const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch';
const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.'
: `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.`
: !running || game.speech || game.dialogue || c ? '' : command.sentence(hoverTarget);
if ($('sentence').textContent !== text) $('sentence').textContent = text;
updateInventory();
updateChoices();
@@ -571,10 +590,17 @@ function updateUi() {
$('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup;
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : 'Chiudi';
$('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') rustle();
if (step === 'turn' && turning !== 'turn') ambience.play('page');
turning = step;
const nextReveal = torch ? c.found : null;
if (nextReveal === 2 && reveal !== 2) ambience.play('wood', { pan: 0.7 });
reveal = nextReveal;
if (game.state.ended && !game.speech && !game.queue.length) {
running = false;
$('ending').hidden = false;
@@ -591,10 +617,13 @@ function frame(now) {
const screen = !$('title').hidden ? 'title' : !$('splash').hidden ? 'splash' : !$('intro').hidden || !$('rione').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game';
document.body.classList.toggle('full', screen === 'title' || screen === 'splash' || screen === 'intro' || !$('credits').hidden);
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
music.duck = game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
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() }), 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 });
ambience.update(soundscapeFor(game?.state.scene, game?.closeup?.id), game?.phase()?.id, dt);
// A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del
// telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava.
const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`;
+23
View File
@@ -0,0 +1,23 @@
// Paesaggi sonori discreti: nessun parlato aggiunto e lunghe pause fra gli eventi.
export const soundscapes = {
paese: { bed: 'wind', level: 0.025, day: ['bird', 'bell'], night: ['cricket'], gap: [12, 24] },
bosco: { bed: 'wind', level: 0.065, day: ['bird', 'wood'], night: ['cricket', 'wood'], gap: [9, 19] },
acqua: { bed: 'water', level: 0.1, day: ['bird', 'drip'], night: ['cricket', 'drip'], gap: [9, 18] },
pieve: { bed: null, level: 0, day: ['bell'], night: ['bell'], gap: [24, 38] },
circolo: { bed: null, level: 0, day: ['glass'], night: ['glass'], gap: [12, 22] },
fortezza: { bed: 'wind', level: 0.085, day: ['wood', 'bird'], night: ['wood', 'cricket'], gap: [14, 25] },
fessura: { bed: 'wind', level: 0.045, day: ['drip'], night: ['drip'], gap: [10, 18] },
};
const groups = {
bosco: ['bosco_ai_frati', 'viale_cipressi', 'trebbio', 'salita_fortezza', 'casa_maestro_bacci', 'campo_giannini', 'area_camper', 'giardini_poste'],
acqua: ['ponte_sieve', 'campomigliaio_ponte', 'verso_luna'],
pieve: ['pieve', 'pieve_interno', 'oratorio'],
circolo: ['campomigliaio_circolo', 'tennis_chiesa', 'area_feste_festa'],
fortezza: ['fortezza'],
};
export function soundscapeFor(scene, closeup) {
if (closeup === 'fessura') return 'fessura';
return Object.keys(groups).find(id => groups[id].includes(scene)) ?? 'paese';
}
+137 -15
View File
@@ -104,9 +104,32 @@ export const story = {
{ role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] },
{ role: 'Programmazione', names: ['Manzolo, con Claude e Codex'] },
],
// I primi piani a tutto schermo (effetto `closeup`, disegnati da render.js): per ora il libro dell'oratorio.
// Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura.
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
closeups: {
fessura: {
type: 'torch',
picture: 'assets/closeups/palio-2007.jpg',
crop: [82, 36, 220, 331], // la tela nella foto fornita da Andrea, senza il muro intorno
targets: [
{ at: [185, 195], radius: 52, hint: 'Esamina la pietra a sinistra…', lines: [] },
{ at: [348, 225], radius: 55, hint: 'Qualcosa riflette la luce, più in basso…', lines: [
{ say: 'Laggiù, appoggiato al muro, c\'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.' },
{ say: 'È il palio! Il quadro dei ragazzi delle medie!' },
] },
{ at: [462, 138], radius: 45, hint: 'Un rumore nel buio, a destra…', lines: [
{ say: 'E quelli... sono due occhi gialli!' },
] },
],
complete: [
{ say: 'Spariti nel buio.' },
{ set: 'palio_trovato' },
{ say: 'Il Regolo. E ha il nostro palio.' },
{ say: 'Però non l\'ha portato via: l\'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.' },
{ say: 'Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!' },
{ end: true },
],
},
libro: {
closed: 'assets/closeups/libro-chiuso.png',
open: 'assets/closeups/libro-aperto.png',
@@ -316,11 +339,25 @@ export const story = {
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk',
actions: {
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }],
talk: [{ dialogue: 'vecchietti' }],
talk: [
{
if: ['indizio_regolo', '!chiacchiere'],
then: [
{ set: 'chiacchiere' },
{ say: 'Dice che Andrea ha trovato un serpente lungo dodici metri.', who: 'vecchietto', pose: 'point' },
{ say: 'Ma quale serpente. Secondo me va in Fortezza solo per farsi le canne: torna sempre con gli occhi rossi!', who: 'vecchietta' },
{ say: 'Sono qui, eh. Vi sento.' },
{ say: 'Oh! Andrea! Si parlava d\'un altro.', who: 'vecchietta', pose: 'laugh' },
],
},
{ dialogue: 'vecchietti' },
],
take: [{ say: 'Se li porto via, il paese resta senza notizie.' }],
},
give: {
squama: [{ say: 'Guardate cosa ho trovato.' }, { say: 'Bella! È una patatina?', who: 'vecchietta', pose: 'laugh' }, { say: 'Ma che patatina, è il Regolo che fa la muta!', who: 'vecchietto' }, { say: 'Me la tengo io, grazie.' }],
ghiacciolo: [{ say: 'Al limone! Da ragazza me ne mangiavo tre di fila.', who: 'vecchietta', pose: 'laugh' }, { say: 'E poi tre giorni a letto.', who: 'vecchietto' }, { say: 'Me lo tengo io, allora.' }],
libro: [{ say: '«La storia infinita»? Alla mia età, figliolo, meglio qualcosa di corto.', who: 'vecchietto' }, { say: 'Fidati: è più corto di quel che sembra.' }],
},
},
{
@@ -391,6 +428,7 @@ export const story = {
take: [{ say: 'Le impronte non si prendono. Si seguono.' }],
},
with: {
torcia: [{ say: 'Di giorno la torcia non serve: le impronte si vedono benissimo. È il buio della fessura, il problema.' }],
squama: [{
if: 'indizio_regolo',
then: [{ say: 'Ormai lo so: è passato di qui. E le impronte piegano verso il bastione.' }],
@@ -425,21 +463,10 @@ export const story = {
talk: [{ say: 'C\'è nessuno?' }, { say: '...' }, { say: 'Solo l\'eco. Spero.' }],
},
with: {
stecco_gomma: [{ say: 'Troppo buio: non vedo nemmeno dove lo infilo. Prima ci vuole una luce.' }],
torcia: [
{ say: 'Accendo la torcia e la punto dentro...' },
{ set: 'luce_fessura' },
{ say: 'Laggiù, appoggiato al muro, c\'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.' },
{ say: 'È il palio! Il quadro dei ragazzi delle medie!' },
{ set: 'occhi_regolo' },
{ say: 'E quelli... sono due occhi gialli!' },
{ unset: 'occhi_regolo' },
{ say: 'Spariti nel buio.' },
{ unset: 'luce_fessura' },
{ set: 'palio_trovato' },
{ say: 'Il Regolo. E ha il nostro palio.' },
{ say: 'Però non l\'ha portato via: l\'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.' },
{ say: 'Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!' },
{ end: true },
{ closeup: 'fessura' },
],
},
},
@@ -483,6 +510,10 @@ export const story = {
{
// San Pietro, quello delle chiavi: a chi risolve l'indovinello dà la chiave dell'oratorio.
id: 'statua', name: 'statua di San Pietro', box: [330, 170, 26, 56], at: [345, 238], face: 'up', default: 'talk',
give: {
squama: [{ say: 'Figliolo, io tengo le chiavi, non le squame. Portala a chi l\'ha persa.', who: 'san_pietro' }],
chiave_oratorio: [{ say: 'Te l\'ho data io: usala, non restituirla. Le chiavi servono alle porte.', who: 'san_pietro' }],
},
actions: {
look: [{ say: 'La statua di San Pietro, con le chiavi in mano. Lui il palio l\'ha visto passare di sicuro.' }],
talk: [{
@@ -947,6 +978,7 @@ export const story = {
open: [{ if: 'casetta_aperta', then: [{ say: 'È già aperta.' }], else: [{ if: 'has:chiavetta', then: [{ drop: 'chiavetta' }, { set: 'casetta_aperta' }, { say: 'La chiavetta entra nel lucchetto... clic!' }, { say: 'Prolunghe, prolunghe, prolunghe... e una torcia.' }, { give: 'torcia' }], else: [{ say: 'Chiusa col lucchetto. Il mistero resta.' }] }] }],
},
with: {
stecco_gomma: [{ say: 'Un lucchetto non si pesca. Ci vuole la sua chiave.' }],
chiavetta: [{ if: 'casetta_aperta', then: [{ say: 'È già aperta.' }], else: [{ drop: 'chiavetta' }, { set: 'casetta_aperta' }, { say: 'La chiavetta entra nel lucchetto... clic!' }, { say: 'Prolunghe, prolunghe, prolunghe... e una torcia.' }, { give: 'torcia' }] }],
},
},
@@ -1033,6 +1065,10 @@ export const story = {
},
{
id: 'barondeduke', name: 'il BaronDeDuke', box: [212, 245, 36, 55], at: [190, 316], face: 'right', default: 'talk',
give: {
squama: [{ say: 'Me la giochi? Ho un sette e un due, ma un sacco di fiducia.', who: 'barondeduke', pose: 'cards' }, { say: 'No, Barone. A poker le squame non valgono.' }],
ghiacciolo: [{ say: 'A questo giro non punto niente. Nemmeno un ghiacciolo.', who: 'barondeduke', pose: 'facepalm' }],
},
actions: {
look: [{ say: 'Il BaronDeDuke, col cappello da cowboy. Ha l\'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.' }],
talk: [
@@ -1337,6 +1373,10 @@ export const story = {
},
{
id: 'arcu', name: 'l\'Arcu', box: [400, 216, 38, 76], at: [362, 300], face: 'right', default: 'talk',
give: {
squama: [{ say: 'Che è, la mancia? Una squama? Grazie, la metto in cassaforte.', who: 'arcu', pose: 'laugh' }, { say: 'Tienila te, che io di roba verde ne ho già abbastanza sul lavoro.', who: 'arcu' }],
libro: [{ say: 'Un libro? A me? Lo sai che io leggo solo le etichette degli armadi.', who: 'arcu', pose: 'shrug' }],
},
actions: {
look: [{ say: 'L\'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.' }],
talk: [{ dialogue: 'arcu' }],
@@ -1403,6 +1443,10 @@ export const story = {
},
{
id: 'chiosco', name: 'chiosco', box: [240, 196, 106, 72], at: [300, 296], face: 'up', default: 'talk',
give: {
squama: [{ say: 'Tosca, guarda cosa ho trovato.' }, { say: 'Bella. Ma nella cedrata questa non ce la metto.', who: 'tosca' }],
libro: [{ say: 'Un libro? Andrea, qui si legge solo il listino. E costa meno.', who: 'tosca' }],
},
actions: {
look: [{ say: 'Il chiosco del tennis. D\'estate qui c\'è la Tosca coi ghiaccioli.' }],
talk: [{ say: 'Tosca! C\'è la Tosca?' }, { say: 'Eccomi, Andrea! Cosa ti do? Fanta, Coca, cedrata, spuma bionda... o un ghiacciolo?', who: 'tosca' }, { dialogue: 'tosca' }],
@@ -1582,6 +1626,12 @@ export const story = {
talk: [{ say: 'Ciao leone. Visto passare un serpente con le ali?' }, { say: 'Io fo la guardia alla salita, mica ai serpenti.', who: 'leone' }, { say: 'E poi di notte dormo. Come tutti, in questo paese.', who: 'leone' }, { say: 'Un leone di terracotta che parla. Devo dormire di più anch\'io.' }],
take: [{ say: 'Pesa più di me. E poi ha un\'aria permalosa.' }],
},
with: {
torcia: [{ say: 'Ehi! Mi abbagli! Sono di terracotta, mica cieco.', who: 'leone' }, { say: 'Scusa, leone.' }, { say: 'Quella tienila per il buio. Qui c\'è il sole, se non l\'hai notato.', who: 'leone' }],
},
give: {
squama: [{ say: 'Una squama? Io di squame ne ho viste... mai. Io faccio la guardia alla salita.', who: 'leone' }, { say: 'Lo so, lo so. Alla salita.' }],
},
},
{
id: 'verso_scuola', name: 'strada verso la scuola', box: [0, 286, 120, 44], at: [40, 336], face: 'left', exit: { scene: 'scuola', at: [128, 340], face: 'right' },
@@ -1630,6 +1680,10 @@ export const story = {
},
{
id: 'tozzino', name: 'il Tozzino', box: [422, 224, 36, 76], at: [392, 312], face: 'right', default: 'talk',
give: {
ghiacciolo: [{ say: 'Grazie, ma prima della partita niente zuccheri. Federer non li tocca.', who: 'tozzino', pose: 'explain' }],
torcia: [{ say: 'Una torcia? Se Federer arriva di notte, magari. Per ora tienila te.', who: 'tozzino', pose: 'watch' }],
},
actions: {
look: [{ say: 'Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l\'orologio ogni dieci secondi.' }],
talk: [
@@ -1862,6 +1916,12 @@ export const story = {
hotspots: [
{
id: 'torre', name: 'torre del castello', box: [345, 26, 88, 196], at: [330, 312], face: 'up', default: 'talk',
with: {
torcia: [{ say: 'Luce? In seicento anni ne ho vista tanta. Tienila per il buio, che quello non manca mai.', who: 'castello' }],
},
give: {
squama: [{ say: 'Non a me. Portala vicino a chi l\'ha persa: le cose parlano solo dove sono cadute.', who: 'castello' }],
},
actions: {
look: [{ say: 'La torre del Trebbio. Da ragazzo ci si rifugiò Amerigo Vespucci, per scappare dalla peste di Firenze.' }, { say: 'Poi ha trovato l\'America. Anche lui partendo da un posto tranquillo.' }],
talk: [{ dialogue: 'castello' }],
@@ -1899,6 +1959,13 @@ export const story = {
{ text: 'Una spuma bionda.', when: '!has:spuma', then: [{ say: 'La spuma bionda! Ormai la chiedono solo i nonni.', who: 'tosca' }, { give: 'spuma' }] },
{ text: 'Una cedrata, da portare via.', when: ['!has:cedrata', '!stecco_fatto'], then: [{ say: 'Cedrata. Bella fresca.', who: 'tosca' }, { give: 'cedrata' }] },
{ text: 'Un ghiacciolo al limone.', when: ['!has:ghiacciolo', '!stecco_fatto'], then: [{ say: 'Al limone, l\'ultimo della cassetta.', who: 'tosca' }, { give: 'ghiacciolo' }] },
{
id: 'tosca_voci', once: true, when: 'indizio_regolo', text: 'Tosca, cosa si dice in giro?',
then: [
{ say: 'Che sei andato sotto la Fortezza da solo. Il paese parla, Andrea.', who: 'tosca' },
{ say: 'Prenditi una cedrata, che le chiacchiere mettono sete.', who: 'tosca' },
],
},
{ text: 'Niente, grazie Tosca.', next: 'end', then: [{ say: 'Quando ti viene sete, sai dove trovarmi.', who: 'tosca' }] },
],
},
@@ -1955,6 +2022,31 @@ export const story = {
{ say: 'Nel mio, di Palio, si correva con le carriole vere. Mica come voi.', who: 'vecchietta' },
],
},
// Il pettegolezzo cambia con le scoperte di Andrea (consiglio di Codex, 25 ottobre).
{
id: 'news_squama', once: true, when: 'squama_presa', text: 'Ho trovato una squama, sotto la panchina.',
then: [
{ say: 'L\'avevo detto io, che luccicava!', who: 'vecchietta', pose: 'point' },
{ say: 'Una squama a maggio? Allora il Regolo fa la muta, come le bisce.', who: 'vecchietto' },
{ say: 'O l\'ha persa uno della sfilata, col costume. Che ne sai.', who: 'vecchietta', pose: 'arms_crossed' },
],
},
{
id: 'news_impronte', once: true, when: 'indizio_regolo', text: 'Sotto la Fortezza ci sono impronte, e vanno verso il bastione.',
then: [
{ say: 'Lo sapevo. Stanotte ho sentito raspare, dalla parte della Fortezza.', who: 'vecchietto', pose: 'point' },
{ say: 'Tu stanotte dormivi. L\'hai detto tu.', who: 'vecchietta' },
{ say: 'Si può dormire e sentire. Alla mia età si fa tutto insieme.', who: 'vecchietto', pose: 'shrug' },
],
},
{
id: 'news_torcia', once: true, when: 'casetta_aperta', text: 'Ho una torcia. Stasera salgo alla Fortezza.',
then: [
{ say: 'Di sera? Lassù? Allora salutaci il Regolo.', who: 'vecchietto', pose: 'doze' },
{ say: 'E se ti mangia, almeno la festa ha un argomento.', who: 'vecchietta', pose: 'laugh' },
{ say: 'Grazie dell\'incoraggiamento.' },
],
},
{ text: 'Ci vediamo dopo.', next: 'end', then: [{ say: 'Salutaci i tuoi!', who: 'vecchietta' }, { say: 'E se ti perdi, sali al Trebbio e chiedi alla torre. Ne sa più di noi due messi insieme.', who: 'vecchietto' }] },
],
},
@@ -1967,6 +2059,36 @@ export const story = {
options: [
{ text: 'Ma che è sempre là, Borgo?', then: [{ say: 'E chi lo sposta???', who: 'arcu', pose: 'shrug' }] },
{ text: 'Come va alla Casa dell\'Armando?', then: [{ say: 'Mah... un si fa una sega...', who: 'arcu', pose: 'explain' }] },
// Anche l'Arcu segue le scoperte di Andrea.
{
id: 'arcu_squama', once: true, when: 'squama_presa', text: 'Ho trovato una squama del Regolo.',
then: [
{ say: 'Fammi vedere... Verde. Come la Panda di mi\' zio.', who: 'arcu', pose: 'laugh' },
{ say: 'Verde! Allora il Regolo tifa Cardetole. Tradimento.', who: 'arcu', pose: 'point' },
],
},
{
id: 'arcu_impronte', once: true, when: 'indizio_regolo', text: 'Sotto la Fortezza ci sono le sue impronte.',
then: [
{ say: 'E te ci vai da solo? Io ti aspetto qui.', who: 'arcu', pose: 'shrug' },
{ say: 'Qualcuno deve raccontarlo, dopo.', who: 'arcu', pose: 'explain' },
],
},
{
id: 'arcu_torcia', once: true, when: 'casetta_aperta', text: 'Ho la torcia. Stasera entro nella fessura.',
then: [
{ say: 'Fai una cosa: se vedi due occhi gialli, un sono i fari della Provinciale.', who: 'arcu', pose: 'explain' },
{ say: 'Grazie, Arcu. Molto utile.' },
],
},
{
text: 'Cosa stavamo dicendo???',
then: [
{ say: '.............', who: 'arcu' },
{ say: 'Ma ti si è bruciata la memoria, fratello???!?' },
{ say: '...I pompieri per cosa?', who: 'arcu', pose: 'shrug' },
],
},
{ text: 'Ci si becca da i\' Trappola, ai\' Barrettino!', next: 'end' },
],
},
+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.4';
export const VERSION = '0.1.5';
// 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).
+6 -1
View File
@@ -24,6 +24,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; }
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
#effects-volume { width: 130px; accent-color: var(--accent); }
/* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */
#speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
@@ -51,13 +54,15 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
padding: .35em .2em; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; cursor: pointer; }
#verbs button:hover, #inventory button:hover { color: var(--ink); }
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
#inventory { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start;
#inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start;
min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
/* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */
contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; }
#inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45;
font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; letter-spacing: .08em; }
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
#inventory button { min-width: 0; overflow: hidden; }
#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; }
#inventory button[aria-pressed="true"] { border-color: var(--accent); }
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
+35 -4
View File
@@ -6,6 +6,7 @@ import { staticServer } from './helpers.mjs';
import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { solution, route } from '../src/story/solution.js';
import { checkMultimedia } from './multimedia.browser.mjs';
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true });
@@ -47,6 +48,8 @@ const hotspotCenter = async (page, id) => {
};
try {
await checkMultimedia(browser, PORT, OUT, errors);
if (!process.argv.includes('--media-only')) {
const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
@@ -66,10 +69,22 @@ try {
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
{
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
await slow.addInitScript(() => {
const observer = new MutationObserver(() => {
const title = document.getElementById('title'), splash = document.getElementById('splash');
if (title && !title.hidden && splash) {
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
observer.disconnect();
}
});
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
});
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => Number(getComputedStyle(document.getElementById('splash')).opacity));
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
await slow.close();
}
@@ -182,12 +197,13 @@ try {
await page.click('#mute');
await page.locator('#volume').fill('30');
await page.locator('#voice-volume').fill('70');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7 });
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
assert.equal(await page.evaluate(() => voices.volume), 0.7);
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
await hide(true);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
await hide(false);
@@ -211,8 +227,21 @@ try {
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
if (target === 'fessura' && item) {
await page.waitForFunction(() => game.state.flags.occhi_regolo, null, { timeout: 30000 });
await page.screenshot({ path: `${OUT}/05b-occhi.png` });
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
for (let i = 0; i < 3; i++) {
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const box = await page.locator('#scene').boundingBox();
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
for (let n = 0; n < 200; n++) {
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(30);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
await page.click('#closeup-close');
}
await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
@@ -386,6 +415,8 @@ try {
assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
}
assert.deepEqual(errors, [], 'errori nella console');
} finally {
await browser.close();
server.kill();
+13 -2
View File
@@ -1,5 +1,5 @@
// Fa avanzare il gioco come farebbe un giocatore impaziente: tempo che scorre e battute saltate.
export function settle(game, { maxSteps = 20000 } = {}) {
export function settle(game, { maxSteps = 20000, explore = false } = {}) {
const said = [];
for (let i = 0; i < maxSteps; i++) {
if (game.speech) {
@@ -7,7 +7,10 @@ export function settle(game, { maxSteps = 20000 } = {}) {
game.skip();
}
// Un primo piano aperto aspetta il giocatore (sfogliare, chiudere): qui ci si ferma e decide il test.
if (game.closeup && !game.speech && game.reach <= 0) return said;
if (game.closeup && !game.speech && game.reach <= 0) {
if (!explore || game.story.closeups[game.closeup.id].type !== 'torch') return said;
exploreTorch(game);
}
// Un ultimo fotogramma, come nel gioco vero: a sequenza finita il motore prende il punto fermo e salva.
if (!game.busy && !game.walking && !game.queue.length && !game.task) { game.update(0); return said; }
game.update(0.05);
@@ -15,6 +18,14 @@ export function settle(game, { maxSteps = 20000 } = {}) {
throw new Error('Il gioco non si ferma: ' + JSON.stringify(game.snapshot()));
}
// Il collaudo della soluzione compie anche i gesti richiesti dal finale interattivo.
export function exploreTorch(game) {
const c = game.closeup, def = c && game.story.closeups[c.id];
if (def?.type !== 'torch') return;
if (c.step === 'done') game.closeupClose();
else if (c.step === 'search') game.closeupAim(def.targets[c.found].at);
}
export function memoryStorage() {
return { data: {}, getItem(k) { return this.data[k] ?? null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } };
}
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
export async function checkMultimedia(browser, port, out, errors) {
for (const mobile of [false, true]) {
const page = await browser.newPage({ viewport: mobile ? { width: 844, height: 390 } : { width: 1280, height: 860 }, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
await page.goto(`http://127.0.0.1:${port}/`);
await page.click('#splash'); await page.click('#new-game');
await page.click('[data-rione="campomigliaio"]'); await page.click('#skip-intro');
const settle = async () => {
for (let i = 0; i < 200; i++) {
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
if (await page.evaluate(() => game.closeup || game.idle)) return;
await page.waitForTimeout(30);
}
throw new Error('la sequenza non si ferma');
};
await settle();
await page.waitForFunction(() => ambience.audible && ambience.context.state === 'running');
await page.click('#mute');
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'muto immediato');
await page.click('#mute');
await page.click('#pause-button');
await page.locator('#effects-volume').fill('65');
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio')).effects), 0.65);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'in pausa nessun suono');
await page.screenshot({ path: `${out}/media-pausa-${mobile ? 'telefono' : 'desktop'}.png` });
await page.click('#resume');
if (mobile) {
// Il libro già esistente deve continuare a sfogliarsi anche sul telefono.
await page.evaluate(() => { game.state.inventory.push('libro'); game.act('open', 'libro'); });
await page.waitForFunction(() => game.closeup?.step === 'cover');
const r = await page.locator('#scene').boundingBox();
await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height * 0.6);
await page.waitForFunction(() => game.closeup.step === 'title');
await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height * 0.6);
await page.waitForFunction(() => game.closeup.step === 'end');
await page.screenshot({ path: `${out}/media-libro-telefono.png` });
await page.tap('#closeup-close'); await settle();
}
await page.evaluate(() => {
game.state.flags.indizio_regolo = true;
game.state.flags.casetta_aperta = true;
game.state.inventory.push('torcia');
game.go('fortezza', [462, 310]);
});
await settle();
const at = async ([x, y]) => {
const r = await page.locator('#scene').boundingBox();
return [r.x + x / 640 * r.width, r.y + y / 360 * r.height];
};
const openTorch = async () => {
await page.click('#verbs [data-verb="use"]'); await page.click('#inventory [data-item="torcia"]');
await page.mouse.click(...await at([501, 285]));
await settle();
await page.waitForFunction(() => game.closeup?.id === 'fessura');
};
await openTorch();
const saved = await page.evaluate(() => game.savable());
await page.keyboard.press('Escape'); await settle();
assert.equal(await page.evaluate(() => game.state.ended), false, 'uscire presto non vince');
await openTorch();
await page.click('#pause-button');
const age = await page.evaluate(() => game.closeup.age);
await page.waitForTimeout(150);
assert.equal(await page.evaluate(() => game.closeup.age), age, 'il primo piano è fermo in pausa');
await page.click('#resume');
const origin = await page.evaluate(() => [game.state.x, game.state.y]);
if (mobile) {
const cdp = await page.context().newCDPSession(page);
const [x, y] = await at([320, 180]), [tx, ty] = await at([300, 90]);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: tx, y: ty }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const beam = await page.evaluate(() => game.closeup.beam);
assert.ok(Math.abs(beam[0] - 300) < 2 && Math.abs(beam[1] - 90) < 2, 'trascinare il dito sposta la torcia');
await cdp.detach();
}
for (let i = 0; i < 3; i++) {
const target = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
if (mobile) await page.touchscreen.tap(...await at(target));
else if (i === 0) await page.keyboard.press('Enter');
else await page.mouse.move(...await at(target));
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` });
for (let n = 0; n < 200; n++) {
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(25);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
assert.deepEqual(await page.evaluate(() => [game.state.x, game.state.y]), origin, 'il puntatore non muove Andrea');
assert.deepEqual(await page.evaluate(() => game.savable()), saved, 'durante il primo piano resta il punto fermo');
if (mobile) await page.tap('#closeup-close'); else await page.keyboard.press('Enter');
await settle();
await page.waitForSelector('#ending:not([hidden])');
assert.ok(await page.evaluate(() => game.checkpoint.ended));
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'alla fine non resta il vento della fessura');
// Misura il segnale realmente prodotto da Web Audio: udibile, finito, senza saturazione.
if (!mobile) {
const levels = await page.evaluate(async () => {
const { Soundscape } = await import('/src/engine/soundscape.js');
const results = {};
for (const kind of ['page', 'wood', 'drip', 'bird', 'cricket', 'bell', 'glass', 'water', 'wind']) {
const ctx = new OfflineAudioContext(2, 44100 * 2, 44100);
const s = new Soundscape({ createContext: () => ctx });
s.unlock(); s.configure({ active: true });
if (kind === 'water' || kind === 'wind') s.update(kind === 'water' ? 'acqua' : 'fortezza', 'sera', 0.1);
else s.play(kind);
const data = (await ctx.startRendering()).getChannelData(0);
let peak = 0, sum = 0;
for (const v of data) { peak = Math.max(peak, Math.abs(v)); sum += v * v; }
results[kind] = { peak, rms: Math.sqrt(sum / data.length) };
}
return results;
});
for (const [kind, { peak, rms }] of Object.entries(levels)) {
assert.ok(rms > 0.00001 && peak < 0.5, `${kind}: audio non valido ${JSON.stringify({ peak, rms })}`);
}
}
await page.reload();
await page.waitForFunction(() => window.ambience?.volume === 0.65);
await page.close();
}
console.log('Multimedia OK: torcia con mouse, tastiera e touch; uscita e pausa; audio sintetizzato e volume salvato.');
}
+102
View File
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
import { Soundscape } from '../src/engine/soundscape.js';
import { soundscapes, soundscapeFor } from '../src/story/soundscapes.js';
function finale() {
const g = new Game(story, { state: { scene: 'fortezza', x: 462, y: 310, facing: 1, inventory: ['torcia'],
flags: { indizio_regolo: true, casetta_aperta: true }, used: {}, ended: false } });
g.act('use', 'fessura', 'torcia'); settle(g);
return g;
}
test('la torcia aspetta la ricerca, richiede una sosta e non scopre gli occhi prima del quadro', () => {
const g = finale();
assert.equal(g.closeup.step, 'search');
assert.equal(g.act('look', 'bastione'), false);
assert.equal(g.walkTo([100, 300]), false);
assert.equal(g.travel('piazza'), false);
const eyes = story.closeups.fessura.targets[2].at;
g.closeupAim(eyes); g.update(2);
assert.equal(g.closeup.found, 0);
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(0.3);
g.closeupAim(eyes); g.update(0.2);
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(0.3);
assert.equal(g.closeup.step, 'search', 'la sosta riparte quando si sposta il fascio');
g.update(0.2);
assert.equal(g.closeup.step, 'reveal');
assert.equal(g.speech, null, 'la pietra non rivela il testo del quadro');
assert.equal(g.state.flags.palio_trovato, undefined);
const lines = settle(g, { explore: true }).map(s => s.text);
assert.ok(lines.indexOf('È il palio! Il quadro dei ragazzi delle medie!') < lines.indexOf('E quelli... sono due occhi gialli!'));
assert.ok(g.checkpoint.ended && g.checkpoint.flags.palio_trovato);
});
test('uscire dalla fessura o ricaricare durante la ricerca non finisce la partita e permette di riprovare', () => {
const g = finale(), saved = g.savable();
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(1);
assert.deepEqual(g.savable(), saved);
assert.ok(g.closeupClose()); settle(g);
assert.ok(!g.state.ended && !g.state.flags.palio_trovato);
assert.ok(g.state.inventory.includes('torcia'));
const reload = new Game(story, { state: g.savable() });
reload.act('use', 'fessura', 'torcia'); settle(reload);
assert.equal(reload.closeup.found, 0);
settle(reload, { explore: true });
assert.ok(reload.state.ended);
});
test('aprire un primo piano ferma gli effetti successivi fino alla sua chiusura', () => {
const g = new Game(story);
g.queue = [{ closeup: 'libro' }, { set: 'dopo_libro' }];
g.update(0);
assert.equal(g.state.flags.dopo_libro, undefined);
g.closeupClose(); settle(g);
assert.equal(g.state.flags.dopo_libro, true);
});
function audioContext() {
const param = () => ({ value: 0, setValueAtTime(v) { this.value = v; }, linearRampToValueAtTime(v) { this.value = v; },
exponentialRampToValueAtTime(v) { this.value = v; }, setTargetAtTime(v) { this.value = v; }, cancelScheduledValues() {} });
const node = () => ({ gain: param(), frequency: param(), Q: param(), pan: param(), connect() {}, disconnect() {}, start() {}, stop() {} });
return { currentTime: 0, sampleRate: 100, destination: {}, state: 'running',
createGain: node, createBufferSource: node, createBiquadFilter: node, createStereoPanner: node, createOscillator: node,
createBuffer: (_, length) => ({ getChannelData: () => new Float32Array(length) }) };
}
test('suoni: muto e pausa fermano anche gli eventi già avviati, poi il fondo riparte', () => {
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true }); s.update('acqua', 'pomeriggio', 0.1); s.play('page');
assert.ok(s.nodes.size > 1);
s.configure({ muted: true });
assert.equal(s.nodes.size, 0); assert.equal(s.master.gain.value, 0);
s.play('bell'); assert.equal(s.nodes.size, 0);
s.configure({ muted: false }); s.update('acqua', 'pomeriggio', 0.1);
assert.ok(s.bed);
s.configure({ active: false }); assert.equal(s.nodes.size, 0);
s.configure({ active: true, volume: 0 }); s.play('page'); assert.equal(s.nodes.size, 0);
});
test('suoni: profili distinti, niente uccelli di sera, nessun accumulo ai cambi di luogo', () => {
assert.equal(soundscapeFor('fortezza', 'fessura'), 'fessura');
assert.notEqual(soundscapeFor('campomigliaio_ponte'), soundscapeFor('pieve_interno'));
for (const def of Object.values(soundscapes)) assert.ok(!def.night.includes('bird'));
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true });
for (let i = 0; i < 30; i++) {
s.update(i % 2 ? 'acqua' : 'bosco', 'sera', 0.1);
assert.equal(s.nodes.size, 1);
}
const events = []; s.play = kind => events.push(kind);
s.update('paese', 'sera', 0.1); s.update('paese', 'sera', 10);
assert.deepEqual(events, ['cricket']);
s.update('pieve', 'sera', 0.1); assert.equal(s.nodes.size, 0);
});
test('Web Audio mancante non interrompe la partita', () => {
const s = new Soundscape({ createContext: () => { throw new Error('non disponibile'); } });
assert.doesNotThrow(() => { s.unlock(); s.configure({ active: true }); s.update('paese', 'sera', 1); s.play('page'); });
});
+2
View File
@@ -27,6 +27,8 @@ test('volume della musica, volume delle voci e muto si ricordano', () => {
assert.equal(readAudio(s), null);
assert.ok(writeAudio(s, { volume: 0.3, muted: true, voices: 0.4 }));
assert.deepEqual(readAudio(s), { volume: 0.3, muted: true, voices: 0.4 });
writeAudio(s, { volume: 0.3, muted: false, voices: 0.4, effects: 0.65 });
assert.deepEqual(readAudio(s), { volume: 0.3, muted: false, voices: 0.4, effects: 0.65 });
writeAudio(s, { volume: 0.3, muted: false });
assert.deepEqual(readAudio(s), { volume: 0.3, muted: false }, 'un salvataggio di prima delle voci resta valido');
s.setItem('leggenda-regolo-audio', '{rotto');
+89 -5
View File
@@ -6,12 +6,16 @@ import { solution } from '../src/story/solution.js';
import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js';
import { existsSync, readFileSync } from 'node:fs';
import { settle } from './helpers.mjs';
import { settle, exploreTorch } from './helpers.mjs';
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end', 'closeup'];
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() {
for (const [id, c] of Object.entries(story.closeups ?? {})) {
for (const k of ['comment', 'closing', 'complete']) yield [`${id}.${k}`, c[k] ?? []];
for (const t of c.targets ?? []) yield [`${id}.target`, t.lines ?? []];
}
for (const [id, scene] of Object.entries(story.scenes)) {
yield [`${id}.enter`, scene.enter ?? []];
for (const h of scene.hotspots ?? [])
@@ -66,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
if (e.closeup) {
const c = story.closeups?.[e.closeup];
assert.ok(c, `${where}: primo piano ${e.closeup}`);
for (const src of [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
for (const src of c.type === 'torch' ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
}
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
}
@@ -118,7 +122,7 @@ test('il taccuino: lungo la soluzione le tappe si spuntano in ordine, una alla v
let last = 0;
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);
settle(g, { explore: true });
const n = g.notebook().done.length;
assert.ok(n === last || n === last + 1, `tappe saltate dopo ${verb} ${target}`);
last = n;
@@ -134,7 +138,7 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
const ids = story.phases.map(p => p.id), 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);
settle(g, { explore: true });
if (seen.at(-1) !== g.phase().id) seen.push(g.phase().id);
}
assert.deepEqual(seen, ids);
@@ -166,7 +170,7 @@ test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
if (item) assert.ok(g.state.inventory.includes(item), `manca ${item}`);
assert.ok(g.act(verb, target, item), `comando rifiutato: ${verb} ${target}`);
}
settle(g);
settle(g, { explore: true });
}
assert.ok(g.state.ended, 'la partita non è finita');
assert.ok(g.state.flags.indizio_regolo && g.state.flags.palio_trovato, 'il palio visto nella fessura');
@@ -367,6 +371,7 @@ function stepChecked(g, label, step) {
for (let i = 0; i < 20000; i++) {
seen.push(essence(g.savable()));
if (g.speech) g.skip();
exploreTorch(g);
if (!g.busy && !g.walking && !g.queue.length && !g.task) break;
g.update(0.05);
}
@@ -519,3 +524,82 @@ test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis
assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.');
});
test('i tentativi sbagliati hanno una risposta loro, non quella generica', () => {
const g = new Game(story);
settle(g);
g.state.inventory.push('squama', 'ghiacciolo', 'libro', 'torcia', 'stecco_gomma');
const generic = ['Non funziona.', 'Non credo che lo voglia.'];
for (const [scene, verb, target, item, who] of [
['piazza', 'give', 'vecchietti', 'ghiacciolo', 'vecchietta'], ['piazza', 'give', 'vecchietti', 'libro', 'vecchietto'],
['salita_villa_adami', 'use', 'leone', 'torcia', 'leone'], ['salita_villa_adami', 'give', 'leone', 'squama', 'leone'],
['pieve', 'give', 'statua', 'squama', 'san_pietro'], ['tennis_chiesa', 'give', 'chiosco', 'squama', 'tosca'],
['trebbio', 'use', 'torre', 'torcia', 'castello'], ['campomigliaio_circolo', 'give', 'arcu', 'libro', 'arcu'],
['carza', 'give', 'tozzino', 'ghiacciolo', 'tozzino'], ['polisportiva', 'give', 'barondeduke', 'squama', 'barondeduke'],
['area_feste', 'use', 'casetta', 'stecco_gomma', 'andrea'], ['fortezza', 'use', 'impronte', 'torcia', 'andrea'],
]) {
g.go(scene); settle(g);
assert.ok(g.act(verb, target, item), `${scene}: ${verb} ${target} con ${item}`);
const lines = settle(g);
assert.ok(lines.length && !lines.some(l => generic.includes(l.text)), `${scene}: ${target} con ${item} risponde con la frase generica`);
assert.ok(lines.some(l => (l.who ?? 'andrea') === who), `${scene}: ${target} con ${item}: manca la risposta di ${who}`);
}
});
test('il pettegolezzo cambia con le scoperte: vecchietti, Arcu e Tosca', () => {
const g = new Game(story);
settle(g);
const texts = () => g.options().map(o => o.text);
const talk = (scene, who) => { g.go(scene); settle(g); g.act('talk', who); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); };
const leave = () => { g.choose(g.options().findIndex(o => o.next === 'end')); settle(g); };
// All'inizio niente notizie.
talk('piazza', 'vecchietti');
assert.ok(!texts().some(t => t.startsWith('Ho trovato una squama')), 'senza squama non c\'è la notizia');
leave();
// Trovata la squama: una notizia nuova, una volta sola.
g.state.flags.squama_presa = true;
talk('piazza', 'vecchietti');
assert.ok(texts().includes('Ho trovato una squama, sotto la panchina.'));
g.choose(texts().indexOf('Ho trovato una squama, sotto la panchina.'));
assert.ok(settle(g).some(s => s.who === 'vecchietto' && s.text.includes('muta')));
assert.ok(!texts().includes('Ho trovato una squama, sotto la panchina.'), 'detta una volta, basta');
leave();
// Le impronte: prima di parlare, i vecchietti sparlano di Andrea (una volta sola); poi la notizia delle impronte.
g.state.flags.indizio_regolo = true;
let said = talk('piazza', 'vecchietti');
assert.ok(said.includes('vecchietto: Dice che Andrea ha trovato un serpente lungo dodici metri.'));
assert.ok(said.includes('andrea: Sono qui, eh. Vi sento.'));
assert.ok(texts().some(t => t.startsWith('Sotto la Fortezza ci sono impronte')));
leave();
said = talk('piazza', 'vecchietti');
assert.ok(!said.some(t => t.includes('dodici metri')), 'le chiacchiere si origliano una volta');
leave();
// L'Arcu e la Tosca seguono le stesse tappe.
talk('campomigliaio_circolo', 'arcu');
assert.ok(texts().includes('Sotto la Fortezza ci sono le sue impronte.') && texts().includes('Ho trovato una squama del Regolo.'));
assert.ok(!texts().includes('Ho la torcia. Stasera entro nella fessura.'), 'senza torcia non si annuncia la fessura');
g.choose(texts().indexOf('Cosa stavamo dicendo???'));
assert.deepEqual(settle(g).map(s => s.text).slice(-2), ['Ma ti si è bruciata la memoria, fratello???!?', '...I pompieri per cosa?']);
leave();
talk('tennis_chiesa', 'chiosco');
assert.ok(texts().includes('Tosca, cosa si dice in giro?'));
leave();
g.state.flags.casetta_aperta = true;
talk('campomigliaio_circolo', 'arcu');
assert.ok(texts().includes('Ho la torcia. Stasera entro nella fessura.'));
leave();
});
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
const { readdirSync, statSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const dirs = readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY (assets\/\S+|src)\s/gm).map(l => l.split(' ')[1]);
const closed = [];
const walk = dir => { for (const e of readdirSync(new URL(dir + '/', root), { withFileTypes: true })) {
const path = `${dir}/${e.name}`;
if (e.isDirectory()) walk(path);
else if (!(statSync(new URL(path, root)).mode & 0o004)) closed.push(path);
} };
dirs.forEach(walk);
assert.deepEqual(closed, [], 'file non leggibili da nginx (chmod 644)');
});
+18
View File
@@ -49,3 +49,21 @@ e scrive `video/out/demo-prototipo.mp4` (escluso da git).
- Le battute del gioco durano `sayTime` (circa 18 caratteri al secondo): il copione non le salta, aspetta che finiscano.
- Una pressione più lunga di 450 ms sulla scena vale «guarda»: i clic di `mv.py` sono brevi, non usare trascinamenti.
- Il salvataggio sta nel `localStorage` del profilo della VM: il `setup` del copione lo svuota, sennò compare «Continua».
## La partita completa (25 ottobre 2026)
Copione `video/partita-completa.mjs`: il primo capitolo intero, dal pomeriggio alla sera (circa 10 minuti e mezzo),
seguendo `src/story/solution.js` capitolo per capitolo, più il libro dell'oratorio in primo piano e i vecchietti
che a sera sparlano di Andrea. Si gira con gli stessi comandi della demo (`mbot studio up`, `mbot studio session`,
`prepara-ripresa.sh`, `rec.mjs … --dry`, di nuovo `prepara-ripresa.sh`, `rec.mjs …`), poi:
```zsh
python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita-completa
```
- **L'audio**: la VM registra solo lo schermo. Il copione annota dentro la pagina ogni cambio di musica e ogni voce
che parte (con l'orologio della VM) in `video/out/partita-completa-audio.json`; `monta.py` rimonta la colonna
sonora dai file del gioco: musica bassa (`VOLUME_SOTTO`), voci piene. Il fruscio della pagina non c'è.
- **Il ritaglio** (`TAGLIO` in `monta.py`) va rimisurato quando cambia l'impaginazione: `convert -trim` su una
schermata della prova a vuoto.
- **Per Telegram** (50 MB al massimo): due passate x264 `-tune animation` a 470 kbit/s → circa 45 MB.
+10
View File
@@ -81,6 +81,16 @@ async function giro(modo) {
const fermo = async passo => {
let ultima = null;
for (let i = 0; i < 600; i++) {
const search = await page.evaluate(() => {
const c = game.closeup;
if (c?.id !== 'fessura' || game.speech) return null;
return c.step === 'done' ? { done: true } : c.step === 'search' ? { at: game.story.closeups.fessura.targets[c.found].at } : null;
});
if (search?.done) await premi('#closeup-close');
else if (search?.at) {
const r = await page.locator('#scene').boundingBox(), [x, y] = search.at;
await tocca(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
}
const s = await page.evaluate(() => ({ t: game.speech?.text ?? null, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
if (s.t && s.t !== ultima) { ultima = s.t; await page.waitForTimeout(60); await foto(`battuta-${s.t}`, passo); await page.evaluate(() => game.skip()); }
else if (!s.t && s.idle) return;
+55 -20
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
"""Montaggio della demo (video/SCALETTA.md): raw.mkv + cues.json della ripresa e video/out/musica.json
-> video/out/demo-prototipo.mp4.
"""Montaggio di una ripresa del gioco (video/SCALETTA.md): raw.mkv + cues.json della ripresa e l'audio annotato dal
copione -> video/out/<clip>.mp4.
python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/demo-prototipo
python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita-completa
- ritaglia lo schermo 1600x900 della VM sull'area del gioco e lascia sopra una fascia per le didascalie;
- le didascalie (cues.json, solo «it») restano a schermo il tempo di leggerle, non fino alla successiva;
- la colonna sonora si rimonta dai cambi di brano registrati dal copione, con le dissolvenze del gioco (1,2 s).
- la colonna sonora si rimonta dai cambi di brano registrati dal copione, con le dissolvenze del gioco (1,2 s);
- le voci (solo in video/out/<clip>-audio.json) tornano al loro istante, sopra la musica tenuta bassa.
La ripresa parte qualche secondo prima del copione (ffmpeg si avvia prima): lo scarto si misura dalla durata.
"""
import json
@@ -18,10 +20,12 @@ VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent
OUT = VIDEO / "out"
# Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim».
TAGLIO = "crop=1076:780:262:60"
TAGLIO = "crop=1044:760:278:70"
FASCIA = 110 # px sopra il gioco per le didascalie
FONDO = "0x1B1420" # il colore di fondo della pagina del gioco
VOLUME = 0.5
VOLUME = 0.5 # musica della demo-prototipo (senza voci)
VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre)
VOLUME_VOCI = 1.0
FADE = 1.2
@@ -39,7 +43,25 @@ def ts(s: float) -> str:
def main() -> None:
d = Path(sys.argv[1]).expanduser()
meta = json.loads((d / "cues.json").read_text())
musica = json.loads((OUT / "musica.json").read_text())
nome = meta["clip"]
registro = OUT / f"{nome}-audio.json"
voci = []
if registro.exists():
# Il registro del copione: {t, music} a ogni cambio di brano, {t, voice} e {t, voiceEnd} per le voci.
eventi = json.loads(registro.read_text())["eventi"]
musica = {"cambi": [{"t": e["t"], "src": e["music"]} for e in eventi if "music" in e]}
aperta = None
for e in eventi:
if "voice" in e:
aperta = {"t": e["t"], "src": e["voice"].split("?")[0]}
voci.append(aperta)
elif "voiceEnd" in e and aperta:
aperta["fine"] = e["t"]
aperta = None
volume = VOLUME_SOTTO
else:
musica = json.loads((OUT / "musica.json").read_text())
volume = VOLUME
raw = d / "raw.mkv"
totale = durata(raw)
cues = meta["cues"]
@@ -54,26 +76,39 @@ def main() -> None:
fine = min(c["end"] + scarto if c["end"] is not None else inizio + lettura, inizio + lettura)
righe += [str(i + 1), f"{ts(inizio)} --> {ts(fine)}", "{\\an8}" + c["it"], ""]
OUT.mkdir(exist_ok=True)
srt = OUT / "demo-prototipo.it.srt"
srt = OUT / f"{nome}.it.srt"
srt.write_text("\n".join(righe), encoding="utf-8")
# Un segmento per brano: dall'istante del cambio al successivo, più la dissolvenza in uscita.
# Un segmento per brano: dall'istante del cambio al cambio successivo (anche al silenzio), più la dissolvenza.
# Il primo brano parte da zero (nel browser aspetta il primo clic, nel video no).
cambi = [c for c in musica["cambi"] if c["src"]]
cambi[0]["t"] = -scarto
ingressi, catene = [], []
for i, c in enumerate(cambi):
a = c["t"] + scarto
b = (cambi[i + 1]["t"] + scarto) if i + 1 < len(cambi) else totale
lungo = b - a + (FADE if i + 1 < len(cambi) else 0)
tutti = musica["cambi"]
primo = next(i for i, c in enumerate(tutti) if c["src"])
tutti[primo]["t"] = -scarto
ingressi, catene, pezzi = [], [], []
for i, c in enumerate(tutti):
if not c["src"]:
continue
a = max(0.0, c["t"] + scarto)
ultimo = i + 1 >= len(tutti)
b = totale if ultimo else tutti[i + 1]["t"] + scarto
lungo = max(0.5, b - a + (0 if ultimo else FADE))
ingressi += ["-stream_loop", "-1", "-i", str(GIOCO / c["src"])]
uscita = f",afade=t=out:st={lungo - FADE:.3f}:d={FADE}" if i + 1 < len(cambi) else f",afade=t=out:st={lungo - 3:.3f}:d=3"
catene.append(f"[{i + 1}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={VOLUME},"
f"afade=t=in:d={FADE if i else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{i}]")
mix = "".join(f"[m{i}]" for i in range(len(cambi))) + f"amix=inputs={len(cambi)}:normalize=0:duration=longest[a]"
n = ingressi.count("-i") # l'ingresso 0 è il video
uscita = f",afade=t=out:st={max(0.0, lungo - 3):.3f}:d=3" if ultimo else f",afade=t=out:st={max(0.0, lungo - FADE):.3f}:d={FADE}"
catene.append(f"[{n}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={volume},"
f"afade=t=in:d={FADE if i > primo else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{len(pezzi)}]")
pezzi.append(f"[m{len(pezzi)}]")
# Le voci: ognuna dal suo istante, tagliata dove il gioco l'ha fermata (battuta saltata o finita).
for v in voci:
ingressi += ["-i", str(GIOCO / v["src"])]
n = ingressi.count("-i")
lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else ""
catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]")
pezzi.append(f"[v{len(pezzi)}]")
mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]"
stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF"
video = f"[0:v]{TAGLIO},pad=iw:ih+{FASCIA}:0:{FASCIA}:{FONDO},subtitles={srt}:force_style='{stile}'[v]"
finale = OUT / "demo-prototipo.mp4"
finale = OUT / f"{nome}.mp4"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi,
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
"-t", f"{totale:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
+177
View File
@@ -0,0 +1,177 @@
// Copione del video «La Leggenda del Regolo · la partita completa» (primo capitolo, dal pomeriggio alla sera).
// Si gira come demo-prototipo.mjs (video/SCALETTA.md, «Come si rigira»), poi: python3 video/monta.py <cartella> .
// Segue la soluzione vera (src/story/solution.js), capitolo per capitolo, con due aggiunte: il libro dell'oratorio
// in primo piano e, a sera, i vecchietti che sparlano di Andrea.
// L'audio non si registra (la VM prende solo lo schermo): la pagina annota ogni cambio di musica e ogni voce che
// parte, col suo tempo, in out/partita-completa-audio.json; monta.py rimonta la colonna sonora dai file del gioco.
import { mkdirSync, writeFileSync } from "node:fs";
import { solution, route } from "../src/story/solution.js";
const c = (id, it) => ({ id, it, en: it, top: true });
export const title = { it: "La Leggenda del Regolo · la partita completa", en: "La Leggenda del Regolo · la partita completa" };
export const cues = [
c("titolo", "La Leggenda del Regolo — il primo capitolo, dall'inizio alla fine"),
c("intro", "Si corre per Campomigliaio, i blu. L'introduzione, sopra il panorama del Mugello"),
c("piazza", "Pomeriggio, Piazza Colonna. I vecchietti della panchina sanno sempre tutto"),
c("fortezza", "Sotto la Fortezza, impronte strane. E il sole comincia a calare"),
c("pieve", "La Pieve: l'indovinello di San Pietro apre la porta dell'oratorio"),
c("libro", "Nell'oratorio, sulle tavolate, un libro dimenticato. Bello spesso"),
c("tosca", "Al tennis della chiesa c'è la Tosca: ghiacciolo al limone, inzuppato nella cedrata"),
c("chiavetta", "Stecco e gomma ripescano una chiavetta. Nella casetta dell'area feste, una torcia"),
c("sera", "È sera. In piazza, intanto, si chiacchiera"),
c("finale", "Di corsa alla Fortezza: la torcia nella fessura del bastione"),
c("fine", "Fine del primo capitolo"),
];
const URL_GIOCO = "http://127.0.0.1:8000/";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const at = (verb, target) => solution.findIndex(([v, t]) => v === verb && t === target);
export async function setup(d) {
const p = d.page;
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
await p.waitForSelector("#title:not([hidden])");
await sleep(1500);
}
export async function run(d) {
const p = d.page;
// --- L'audio, annotato dentro la pagina (orologio della VM, a ogni fotogramma): musica voluta e voce in corso.
await p.evaluate(() => {
window.__t0 = Date.now();
window.__audio = [];
let music = undefined, voice = null;
const t = () => (Date.now() - window.__t0) / 1000;
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
const look = () => {
if (window.music && window.music.want !== music) { music = window.music.want; window.__audio.push({ t: t(), music: music ?? null }); }
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();
});
// --- Puntatore vero sulla scena (coordinate 640×360) e sui pulsanti.
const punto = async (x, y) => {
const r = await p.locator("#scene").boundingBox(), k = await p.evaluate(() => devicePixelRatio);
return [(r.x + (x / 640) * r.width) * k, (r.y + (y / 360) * r.height) * k];
};
const clicca = async ([x, y], dur = 0.8) => { const [a, b] = await punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur} click`); await sleep(300); };
const centro = (id) => p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
// Aspetta che l'omino abbia finito di camminare e di parlare (le battute non si saltano: si ascoltano).
// Un dialogo aperto o un primo piano fermo aspettano il giocatore: lì ci si ferma.
const fermi = async (pausa = 600) => {
await sleep(150);
for (let i = 0; i < 1500; i++) {
const ok = await p.evaluate(() => {
if (game.speech || game.queue.length && !game.closeup || game.walking || game.reach > 0) return false;
return !!game.dialogue || !!game.closeup || (!game.busy && !game.task);
});
if (ok) break;
await sleep(100);
}
await sleep(pausa);
};
const verbo = (v) => d.click(p.locator(`#verbs [data-verb="${v}"]`), 0.6);
const oggetto = (id) => d.click(p.locator(`#inventory [data-item="${id}"]`), 0.6);
const scegli = async (testo) => { await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await fermi(); };
// Un passo della soluzione, come lo farebbe un giocatore: il verbo (se il clic da solo non basta), l'oggetto, la cosa.
const passo = async ([verb, target, item]) => {
if (verb === "choose") return scegli(target);
const smart = await p.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) { await verbo(verb); await oggetto(item); }
else if (!smart) await verbo(verb);
if (await p.evaluate((t) => !!game.story.items[t], target)) await oggetto(target);
else await clicca(await centro(target));
await fermi(verb === "walk" ? 900 : 700);
if (await p.evaluate(() => game.closeup?.id === 'fessura')) {
for (let i = 0; i < 3; i++) {
const target = await p.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const [x, y] = await punto(...target);
d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${x} ${y} 1.2`);
await p.waitForFunction(i => game.closeup?.found > i, i, { timeout: 60000 });
}
await d.click(p.locator('#closeup-close'), 0.6);
await fermi();
}
};
const passi = async (lista) => { for (const s of lista) await passo(s); };
// 1. Titolo, rione, introduzione (una riga per clic).
await d.cue("titolo");
await d.hover(p.locator("#title .dedication"), 1.2);
await sleep(4000);
await d.click(p.locator("#new-game"));
await sleep(2500);
await d.click(p.locator('#rione [data-rione="campomigliaio"]'));
await d.cue("intro");
for (let i = 0; i < 8 && (await p.isVisible("#intro")); i++) {
await sleep(4200);
if (await p.isVisible("#intro")) await d.click(p.locator("#intro-text"), 0.5);
}
await fermi(800);
// 2-7. La soluzione, a capitoli.
const capitoli = [
["piazza", at("take", "cosa_luccicante")],
["fortezza", at("look", "fessura")],
["pieve", at("take", "gomma_terra")],
];
let da = 0;
for (const [cue, fino] of capitoli) {
await d.cue(cue);
await passi(solution.slice(da, fino + 1));
da = fino + 1;
}
// Il libro dell'oratorio: preso in mano, si apre, si gira la pagina, «FINE»; poi si chiude.
await d.cue("libro");
await verbo("take");
await clicca(await centro("libro_dimenticato"));
await p.waitForFunction(() => game.closeup?.step === "cover", null, { timeout: 30000 });
await sleep(2800);
await clicca([320, 200]);
await sleep(2600);
await clicca([320, 200]);
await p.waitForFunction(() => game.closeup?.lines && !game.closeup.lines.length && !game.speech, null, { timeout: 30000 });
await sleep(1500);
await d.click(p.locator("#closeup-close"), 0.6);
await fermi();
for (const [cue, fino] of [["tosca", at("use", "gomma", "stecco")], ["chiavetta", at("open", "casetta")]]) {
await d.cue(cue);
await passi(solution.slice(da, fino + 1));
da = fino + 1;
}
// 8. Sera: una deviazione in piazza, dove i vecchietti parlano di Andrea prima di accorgersi che è lì.
await d.cue("sera");
await passi(route("area_feste", "piazza"));
await passo(["talk", "vecchietti"]);
await scegli("Ho una torcia");
await scegli("Ci vediamo dopo");
// 9. Il finale: la torcia nella fessura.
await d.cue("finale");
await passi(route("piazza", "fortezza"));
await passi(solution.slice(-1));
await p.waitForSelector("#ending:not([hidden])", { timeout: 60000 });
await d.cue("fine");
await d.hover(p.locator("#ending .dedication"), 1.2);
await sleep(7000);
const audio = await p.evaluate(() => window.__audio);
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL("partita-completa-audio.json", out), JSON.stringify({ eventi: audio }, null, 2));
}