Compare commits

...
12 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
manzolo 4bda52d9ac Versione 0.1.4 2026-10-25 14:28:24 +01:00
manzoloandClaude Opus 5.5 ec008b5ea1 Salutando l'Arcu: «Ci si becca da i' Trappola, ai' Barrettino!»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:24:53 +01:00
manzoloandClaude Opus 5.5 73f3418bab Il cancello dell'oratorio: «se chiudo gli occhi vedo ancora l'A. Bettini», letto «Abbettini»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:14:37 +01:00
manzoloandClaude Opus 5.5 ce4e47a2ed Chiudendo il libro: il «Nulla» viaggia troppo velocemente nell'animo umano
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:13:00 +01:00
manzoloandClaude Opus 5.5 f6fd2fdfdc Il libro dell'oratorio in primo piano: copertina, frontespizio, una pagina che gira col fruscio e «FINE» (immagini di Codex, testi disegnati dal codice); l'Arcu a scelte (Borgo e la Casa dell'Armando); il cancello dell'oratorio e l'A. Bettini
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:06:12 +01:00
manzoloandClaude Opus 5.5 9d9dc81bcb Idee: le mutande del Barone e la racchetta del Tozzino
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 13:21:59 +01:00
111 changed files with 1799 additions and 166 deletions
+1
View File
@@ -12,6 +12,7 @@ COPY assets/fonts /usr/share/nginx/html/assets/fonts
COPY assets/voices /usr/share/nginx/html/assets/voices COPY assets/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects COPY assets/effects /usr/share/nginx/html/assets/effects
COPY assets/closeups /usr/share/nginx/html/assets/closeups
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice. # Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
RUN cd /usr/share/nginx/html \ RUN cd /usr/share/nginx/html \
+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 = 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. 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 ## In Docker
```zsh ```zsh
@@ -64,5 +70,6 @@ make down
npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine
npm install && npx playwright install chromium-headless-shell npm install && npx playwright install chromium-headless-shell
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono 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": { "sit": {
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 76, "w": 77,
"h": 96, "h": 96,
"ax": 31.4, "ax": 32.9,
"ay": 96 "ay": 96
}, },
"doze": { "doze": {
"x": 78, "x": 79,
"y": 0, "y": 0,
"w": 76, "w": 77,
"h": 96, "h": 96,
"ax": 31.5, "ax": 33.0,
"ay": 96 "ay": 96
}, },
"talk": { "talk": {
"x": 156, "x": 158,
"y": 0, "y": 0,
"w": 67, "w": 68,
"h": 96, "h": 96,
"ax": 25.1, "ax": 26.3,
"ay": 96 "ay": 96
}, },
"explain": { "explain": {
"x": 225, "x": 228,
"y": 0, "y": 0,
"w": 62, "w": 68,
"h": 96, "h": 96,
"ax": 27.4, "ax": 29.9,
"ay": 96 "ay": 96
}, },
"point": { "point": {
"x": 289, "x": 298,
"y": 0, "y": 0,
"w": 74, "w": 78,
"h": 97, "h": 97,
"ax": 31.5, "ax": 33.3,
"ay": 97 "ay": 97
}, },
"laugh": { "laugh": {
"x": 365, "x": 378,
"y": 0, "y": 0,
"w": 70, "w": 71,
"h": 97, "h": 97,
"ax": 23.2, "ax": 22.8,
"ay": 97 "ay": 97
}, },
"cat": { "cat": {
"x": 437, "x": 451,
"y": 0, "y": 0,
"w": 67, "w": 68,
"h": 95, "h": 96,
"ax": 24.1, "ax": 26.1,
"ay": 95 "ay": 96
}, },
"shrug": { "shrug": {
"x": 506, "x": 521,
"y": 0, "y": 0,
"w": 81, "w": 83,
"h": 97, "h": 97,
"ax": 38.9, "ax": 40.2,
"ay": 97 "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: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 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.
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.
+302 -58
View File
@@ -91,6 +91,22 @@
"file": "andrea-af4d717814.mp3", "file": "andrea-af4d717814.mp3",
"sec": 6.44 "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.": { "andrea|Se li porto via, il paese resta senza notizie.": {
"file": "andrea-566475abe2.mp3", "file": "andrea-566475abe2.mp3",
"sec": 3.48 "sec": 3.48
@@ -111,6 +127,26 @@
"file": "andrea-eeb5a2273c.mp3", "file": "andrea-eeb5a2273c.mp3",
"sec": 1.3 "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.": { "andrea|Qualcosa luccica, proprio sotto i piedi dei vecchietti.": {
"file": "andrea-9a8732a3aa.mp3", "file": "andrea-9a8732a3aa.mp3",
"sec": 3.97 "sec": 3.97
@@ -175,6 +211,10 @@
"file": "andrea-fd10e3f083.mp3", "file": "andrea-fd10e3f083.mp3",
"sec": 3.04 "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.": { "andrea|Ormai lo so: è passato di qui. E le impronte piegano verso il bastione.": {
"file": "andrea-5b0cf4f9cf.mp3", "file": "andrea-5b0cf4f9cf.mp3",
"sec": 5.03 "sec": 5.03
@@ -231,38 +271,14 @@
"file": "andrea-aa9b313a43.mp3", "file": "andrea-aa9b313a43.mp3",
"sec": 2.22 "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...": { "andrea|Accendo la torcia e la punto dentro...": {
"file": "andrea-68a845881b.mp3", "file": "andrea-68a845881b.mp3",
"sec": 1.94 "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.": { "andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
"file": "andrea-43fd5c2b27.mp3", "file": "andrea-43fd5c2b27.mp3",
"sec": 4.46 "sec": 4.46
@@ -279,6 +295,14 @@
"file": "andrea-6012288000.mp3", "file": "andrea-6012288000.mp3",
"sec": 7.97 "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.": { "andrea|La statua di San Pietro, con le chiavi in mano. Lui il palio l'ha visto passare di sicuro.": {
"file": "andrea-0663f64ddf.mp3", "file": "andrea-0663f64ddf.mp3",
"sec": 5.97 "sec": 5.97
@@ -427,13 +451,17 @@
"file": "andrea-42e270af3d.mp3", "file": "andrea-42e270af3d.mp3",
"sec": 6.21 "sec": 6.21
}, },
"andrea|Me lo prendo. Se è suo, il proprietario lo ritrova... prima o poi.": { "andrea|Ricordo di aver visto il film da piccolo... era stupendo!": {
"file": "andrea-78a0a84b84.mp3", "file": "andrea-5528e4c7b0.mp3",
"sec": 5.95 "sec": 4.06
}, },
"andrea|Il cancello di dietro.": { "andrea|Il cancello di dietro. Se chiudo gli occhi vedo ancora l'A. Bettini, il campo da calcio di quando eravamo piccoli: lassù ci sono ancora i riflettori.": {
"file": "andrea-a2f0124f4d.mp3", "file": "andrea-bc60e0ce13.mp3",
"sec": 1.41 "sec": 9.9
},
"andrea|Il primo vero campo del paese: inaugurato nel 1964 e intitolato ad Alberto Bettini, un giovane consigliere della società scomparso troppo presto.": {
"file": "andrea-f035c38acc.mp3",
"sec": 11.19
}, },
"andrea|Chiuso. Si apre solo quando c'è la festa.": { "andrea|Chiuso. Si apre solo quando c'è la festa.": {
"file": "andrea-5e1cd26d46.mp3", "file": "andrea-5e1cd26d46.mp3",
@@ -583,6 +611,10 @@
"file": "andrea-a8dfddbe65.mp3", "file": "andrea-a8dfddbe65.mp3",
"sec": 3.45 "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.": { "andrea|Il tabernacolo prima del ponte. Chi passa per andare via dal paese, un saluto glielo fa sempre.": {
"file": "andrea-78a5ef65f0.mp3", "file": "andrea-78a5ef65f0.mp3",
"sec": 7.84 "sec": 7.84
@@ -623,6 +655,18 @@
"file": "andrea-e47b3cebb5.mp3", "file": "andrea-e47b3cebb5.mp3",
"sec": 4.87 "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.": { "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", "file": "andrea-0bd1b0f32e.mp3",
"sec": 8.7 "sec": 8.7
@@ -799,18 +843,22 @@
"file": "andrea-bb70dea2a6.mp3", "file": "andrea-bb70dea2a6.mp3",
"sec": 2.15 "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.": { "andrea|L'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.": {
"file": "andrea-8ddab2d024.mp3", "file": "andrea-8ddab2d024.mp3",
"sec": 5.79 "sec": 5.79
}, },
"andrea|Arcu! Ma che è sempre là, Borgo?": {
"file": "andrea-aaeb40d873.mp3",
"sec": 3.42
},
"arcu|E chi lo sposta???": {
"file": "arcu-64735a5925.mp3",
"sec": 0.97
},
"andrea|Il ponte vecchio, coi suoi due archi. Chissà quante piene ha visto passare: io al primo temporale cerco riparo.": { "andrea|Il ponte vecchio, coi suoi due archi. Chissà quante piene ha visto passare: io al primo temporale cerco riparo.": {
"file": "andrea-01ec1d03ec.mp3", "file": "andrea-01ec1d03ec.mp3",
"sec": 7.67 "sec": 7.67
@@ -843,6 +891,18 @@
"file": "andrea-455ac4031a.mp3", "file": "andrea-455ac4031a.mp3",
"sec": 4.96 "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.": { "andrea|Il chiosco del tennis. D'estate qui c'è la Tosca coi ghiaccioli.": {
"file": "andrea-24214bad4e.mp3", "file": "andrea-24214bad4e.mp3",
"sec": 5.6 "sec": 5.6
@@ -963,6 +1023,26 @@
"file": "andrea-468fe91000.mp3", "file": "andrea-468fe91000.mp3",
"sec": 3.28 "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.": { "andrea|Di là si va alla scuola. Ci ho passato abbastanza anni, grazie.": {
"file": "andrea-f6fa5d6d6d.mp3", "file": "andrea-f6fa5d6d6d.mp3",
"sec": 4.03 "sec": 4.03
@@ -979,6 +1059,14 @@
"file": "andrea-f5d55187e8.mp3", "file": "andrea-f5d55187e8.mp3",
"sec": 5.11 "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.": { "andrea|Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l'orologio ogni dieci secondi.": {
"file": "andrea-3c32888a15.mp3", "file": "andrea-3c32888a15.mp3",
"sec": 8.1 "sec": 8.1
@@ -1139,6 +1227,14 @@
"file": "andrea-a1dab4073c.mp3", "file": "andrea-a1dab4073c.mp3",
"sec": 3.82 "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.": { "andrea|La torre del Trebbio. Da ragazzo ci si rifugiò Amerigo Vespucci, per scappare dalla peste di Firenze.": {
"file": "andrea-14cbceece4.mp3", "file": "andrea-14cbceece4.mp3",
"sec": 6.99 "sec": 6.99
@@ -1183,22 +1279,6 @@
"file": "andrea-23e40a2495.mp3", "file": "andrea-23e40a2495.mp3",
"sec": 5.74 "sec": 5.74
}, },
"andrea|Vediamo da dove comincia...": {
"file": "andrea-239bf733a6.mp3",
"sec": 1.26
},
"andrea|C'è una pagina sola. Con scritto: «FINE».": {
"file": "andrea-1d80123a55.mp3",
"sec": 3.93
},
"andrea|Infinita, dicevano.": {
"file": "andrea-77f1348d3a.mp3",
"sec": 2.13
},
"andrea|Leggere adesso? Prima il palio. E poi, a quanto pare, si fa presto.": {
"file": "andrea-3a6af3897f.mp3",
"sec": 6.06
},
"andrea|Un ghiacciolo al limone. Si scioglie a guardarlo.": { "andrea|Un ghiacciolo al limone. Si scioglie a guardarlo.": {
"file": "andrea-8c9713081a.mp3", "file": "andrea-8c9713081a.mp3",
"sec": 3.44 "sec": 3.44
@@ -1315,6 +1395,10 @@
"file": "andrea-2d0e3c759e.mp3", "file": "andrea-2d0e3c759e.mp3",
"sec": 1.39 "sec": 1.39
}, },
"andrea|Tosca, cosa si dice in giro?": {
"file": "andrea-be96b05f57.mp3",
"sec": 2.04
},
"andrea|Niente, grazie Tosca.": { "andrea|Niente, grazie Tosca.": {
"file": "andrea-2637918e77.mp3", "file": "andrea-2637918e77.mp3",
"sec": 1.32 "sec": 1.32
@@ -1339,6 +1423,14 @@
"file": "tosca-376fbb9f17.mp3", "file": "tosca-376fbb9f17.mp3",
"sec": 2.41 "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.": { "tosca|Quando ti viene sete, sai dove trovarmi.": {
"file": "tosca-db2da31cef.mp3", "file": "tosca-db2da31cef.mp3",
"sec": 2.31 "sec": 2.31
@@ -1403,6 +1495,18 @@
"file": "andrea-379131645c.mp3", "file": "andrea-379131645c.mp3",
"sec": 1.98 "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.": { "andrea|Ci vediamo dopo.": {
"file": "andrea-7e9841c23a.mp3", "file": "andrea-7e9841c23a.mp3",
"sec": 0.95 "sec": 0.95
@@ -1443,6 +1547,42 @@
"file": "vecchietta-fe3c07afa2.mp3", "file": "vecchietta-fe3c07afa2.mp3",
"sec": 5.11 "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!": { "vecchietta|Salutaci i tuoi!": {
"file": "vecchietta-814e558af5.mp3", "file": "vecchietta-814e558af5.mp3",
"sec": 1.47 "sec": 1.47
@@ -1451,6 +1591,74 @@
"file": "vecchietto-c8eae2e59e.mp3", "file": "vecchietto-c8eae2e59e.mp3",
"sec": 4.88 "sec": 4.88
}, },
"andrea|Ma che è sempre là, Borgo?": {
"file": "andrea-73c12c835b.mp3",
"sec": 2.34
},
"andrea|Come va alla Casa dell'Armando?": {
"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
},
"arcu|E chi lo sposta???": {
"file": "arcu-64735a5925.mp3",
"sec": 0.97
},
"arcu|Mah... un si fa una sega...": {
"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?": { "andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3", "file": "andrea-42b02eb9ea.mp3",
"sec": 3.9 "sec": 3.9
@@ -1527,6 +1735,42 @@
"file": "castello-dc8a873d86.mp3", "file": "castello-dc8a873d86.mp3",
"sec": 4.36 "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
},
"andrea|Il «Nulla» sta viaggiando troppo velocemente nell'animo umano, devo darmi una mossa!": {
"file": "andrea-71dc4628cc.mp3",
"sec": 4.81
},
"andrea|Niente di speciale.": { "andrea|Niente di speciale.": {
"file": "andrea-6ebcf8e5de.mp3", "file": "andrea-6ebcf8e5de.mp3",
"sec": 1.04 "sec": 1.04
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.
+15
View File
@@ -90,3 +90,18 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
gioco, come i nove bastioni. gioco, come i nove bastioni.
- **Regole perché resti divertente**: le chiavi importanti non si buttano, una porta aperta resta aperta, al Trebbio - **Regole perché resti divertente**: le chiavi importanti non si buttano, una porta aperta resta aperta, al Trebbio
c'è sempre l'aiuto. c'è sempre l'aiuto.
- **Le mutande del Barone** (Andrea, 25 ottobre; ci pensa ancora): si ritrovano e gli si riportano, e lui in cambio
dà qualcosa che serve alla storia. Proposta: appese come trofeo all'insegna del circolino di Campomigliaio
(l'Arcu ci scherza), e in cambio due pile per la torcia della casetta, scarica.
- **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 # 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 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 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 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 ## 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, - **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 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`: `src/story/ambience.js`, richiamata da `story.js`; nuovi disegni in `src/engine/ambient.js`:
+5 -1
View File
@@ -11,6 +11,7 @@
<div id="stage"> <div id="stage">
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas> <canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
<div id="speech" aria-live="polite"></div> <div id="speech" aria-live="polite"></div>
<button id="closeup-close" hidden title="Chiudi (Esc)">Chiudi</button>
<div id="corner"> <div id="corner">
<button id="pause-button" hidden title="Pausa (Esc)">Pausa</button> <button id="pause-button" hidden title="Pausa (Esc)">Pausa</button>
<button id="map-button" hidden title="Mappa (P)">Mappa</button> <button id="map-button" hidden title="Mappa (P)">Mappa</button>
@@ -18,7 +19,7 @@
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button> <button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
</div> </div>
<div id="sound"> <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"> <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> <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"> <input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
@@ -72,6 +73,9 @@
</section> </section>
<section id="pause" class="overlay" hidden aria-label="Pausa"> <section id="pause" class="overlay" hidden aria-label="Pausa">
<h2>Pausa</h2> <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"> <div class="buttons pause-buttons">
<button id="resume">Riprendi</button> <button id="resume">Riprendi</button>
<button id="pause-map" class="secondary">Mappa</button> <button id="pause-map" class="secondary">Mappa</button>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.3", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+80 -3
View File
@@ -22,6 +22,9 @@ const SPEED = 95;
// Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è. // Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è.
const FACING = { left: -1, right: 1 }; const FACING = { left: -1, right: 1 };
const WALK = ['walk_1', 'walk_2', 'walk_3', 'walk_4'], STEP = 15, REACH = 0.45; const WALK = ['walk_1', 'walk_2', 'walk_3', 'walk_4'], STEP = 15, REACH = 0.45;
// Il primo piano di un libro (story.closeups, effetto `closeup`): copertina → frontespizio → una pagina girata →
// l'ultima pagina, poi dopo una pausa il commento. Tempi in secondi.
export const PAGE_TURN = 0.7, CLOSEUP_PAUSE = 1.4;
// Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere). // Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere).
export function check(cond, state) { export function check(cond, state) {
@@ -115,15 +118,16 @@ export class Game {
this.hurry = false; // doppio clic: si cammina al doppio della velocità this.hurry = false; // doppio clic: si cammina al doppio della velocità
this.reach = 0; // secondi di braccio teso quando prende qualcosa this.reach = 0; // secondi di braccio teso quando prende qualcosa
this.back = false; // di spalle davanti a una porta o a una bacheca this.back = false; // di spalle davanti a una porta o a una bacheca
this.closeup = null; // il primo piano aperto: { id, step: 'cover' | 'title' | 'turn' | 'end', t, age }
} }
get scene() { return this.story.scenes[this.state.scene]; } get scene() { return this.story.scenes[this.state.scene]; }
// Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto // Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto
// o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata. // o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata.
get busy() { return !!this.speech || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); } get busy() { return !!this.speech || !!this.closeup || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); }
get idle() { return !this.busy && !this.task && !this.dialogue; } get idle() { return !this.busy && !this.task && !this.dialogue; }
// Fermo: nessuna battuta, nessun effetto in coda, nessuna camminata che fa parte di una sequenza. // Fermo: nessuna battuta, nessun effetto in coda, nessuna camminata che fa parte di una sequenza.
get steady() { return !this.speech && this.reach <= 0 && !this.queue.length && !(this.task && !this.task.interruptible); } get steady() { return !this.speech && !this.closeup && this.reach <= 0 && !this.queue.length && !(this.task && !this.task.interruptible); }
// Lo stato da salvare adesso: quello attuale se è fermo, altrimenti l'ultimo punto fermo. // Lo stato da salvare adesso: quello attuale se è fermo, altrimenti l'ultimo punto fermo.
savable() { return this.steady ? this.snapshot() : this.checkpoint; } savable() { return this.steady ? this.snapshot() : this.checkpoint; }
// A sequenza finita, se qualcosa è cambiato, nuovo punto fermo e salvataggio. // A sequenza finita, se qualcosa è cambiato, nuovo punto fermo e salvataggio.
@@ -221,6 +225,63 @@ export class Game {
return false; return false;
} }
// Il primo piano: un tocco sfoglia (copertina → frontespizio → si gira la pagina); all'ultima pagina non fa niente.
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;
return true;
}
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
closeupClose() {
const c = this.closeup;
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() { options() {
if (!this.dialogue || this.busy) return []; if (!this.dialogue || this.busy) return [];
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node]; const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
@@ -271,6 +332,8 @@ export class Game {
} }
speakerPoint(who) { speakerPoint(who) {
// Nel primo piano Andrea parla da sopra il libro, non dalla scena oscurata dietro.
if (this.closeup && (!who || who === 'andrea')) return [320, 34];
if (!who || who === 'andrea') return [this.state.x, this.state.y - 62 * this.scaleAt(this.state.y)]; if (!who || who === 'andrea') return [this.state.x, this.state.y - 62 * this.scaleAt(this.state.y)];
if (this.scene.speakers?.[who]) return this.scene.speakers[who]; if (this.scene.speakers?.[who]) return this.scene.speakers[who];
const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who); const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who);
@@ -280,6 +343,7 @@ export class Game {
update(dt) { update(dt) {
this.time += dt; this.time += dt;
if (this.closeup) this.closeup.age += dt;
if (this.speech) { if (this.speech) {
this.speech.left -= dt; this.speech.left -= dt;
if (this.speech.left > 0) return; if (this.speech.left > 0) return;
@@ -310,8 +374,18 @@ export class Game {
this.walking = false; this.walking = false;
this.task = null; this.task = null;
} }
// 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 ?? [])];
if (c.lines?.length) this.run(c.lines.shift());
return;
}
// Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo. // 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(); this.commit();
} }
@@ -335,6 +409,9 @@ export class Game {
else if (e.node) this.dialogue.node = e.node; else if (e.node) this.dialogue.node = e.node;
else if (e.endDialogue) this.dialogue = null; else if (e.endDialogue) this.dialogue = null;
else if (e.end) s.ended = true; else if (e.end) s.ended = true;
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)); else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
} }
+86 -1
View File
@@ -1,7 +1,8 @@
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura. // Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art. // Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
import { check } from './game.js'; import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js'; import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { versioned } from '../version.js'; import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; export const WIDTH = 640, HEIGHT = 360;
@@ -170,6 +171,7 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
drawLights(ctx, game, time); drawLights(ctx, game, time);
for (const shape of art) if (shape.top) drawShape(ctx, shape); for (const shape of art) if (shape.top) drawShape(ctx, shape);
if (spots) drawSpots(ctx, game); if (spots) drawSpots(ctx, game);
if (game.closeup) drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug); if (debug) drawDebug(ctx, game, debug);
if (hover) { if (hover) {
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.strokeStyle = 'rgba(255,255,255,0.35)';
@@ -644,3 +646,86 @@ function drawDebug(ctx, game, { mouse, points = [], help }) {
label(`${game.state.scene} ${where} ${help ?? ''}`, 2, HEIGHT - 3, '#fff'); label(`${game.state.scene} ${where} ${help ?? ''}`, 2, HEIGHT - 3, '#fff');
ctx.restore(); ctx.restore();
} }
// --- Il primo piano di un libro (story.closeups, game.closeup): la scena si oscura, le mani alzano il libro dal basso.
// Le immagini hanno etichetta e pagine vuote: i testi si scrivono qui, nitidi. Una pagina sola gira, a fisarmonica
// sulla piega; sotto c'è già l'ultima pagina.
const ink = '#3a1f12', FONT = '"Press Start 2P", monospace';
function pageText(ctx, lines, [x, y, w, h], size, at = 0.5, color = ink) {
ctx.font = `${size}px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
const gap = size * 1.7, top = y + h * at - (lines.length - 1) * gap / 2;
lines.forEach((line, i) => ctx.fillText(line, Math.round(x + w / 2), Math.round(top + i * gap)));
}
const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [title.slice(0, i), title.slice(i + 1)] : [title]; };
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);
const src = c.step === 'cover' ? def.closed : def.open, img = backgrounds.get(src);
if (!img) { loadBackground(src); loadBackground(c.step === 'cover' ? def.open : def.closed); ctx.restore(); return; }
// Il libro sale dal basso (solo all'inizio), con le braccia che entrano dal bordo.
const rise = 1 - Math.min(1, c.age / 0.45), ox = Math.round((WIDTH - img.width) / 2);
const oy = Math.round(HEIGHT - img.height + 140 * rise * rise);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, ox, oy);
ctx.translate(ox, oy);
const lines = twoLines(def.title), { left, right, spine } = def.pages;
if (c.step === 'cover') pageText(ctx, lines, def.label, 8);
else {
const flip = c.step === 'turn' ? Math.min(1, c.t / PAGE_TURN) : c.step === 'end' ? 1 : 0;
// Sotto la pagina che gira c'è già l'ultima: «FINE», e nient'altro.
if (flip > 0) pageText(ctx, [def.last], right, 20, 0.46);
else pageText(ctx, lines, right, 8, 0.32);
if (flip > 0 && flip < 1) {
// La pagina che gira: si stringe sulla piega e passa a sinistra, col dorso un po' più scuro.
const e = (1 - Math.cos(Math.PI * flip)) / 2, k = Math.cos(Math.PI * e);
const lift = Math.round(Math.sin(Math.PI * e) * 10), [, y, , h] = right, pw = right[0] + right[2] - spine;
ctx.save();
ctx.translate(spine, 0);
ctx.scale(k || 0.001, 1);
ctx.fillStyle = k > 0 ? def.paper : '#e6cc9c';
ctx.beginPath();
ctx.moveTo(0, y); ctx.lineTo(pw, y - lift); ctx.lineTo(pw, y + h - lift); ctx.lineTo(0, y + h);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(58, 31, 18, 0.55)';
ctx.lineWidth = 1 / Math.max(0.2, Math.abs(k));
ctx.stroke();
if (k > 0) pageText(ctx, lines, [right[0] - spine, y - lift, right[2], h], 8, 0.32);
ctx.restore();
}
// Dove toccare per sfogliare: l'angolo della pagina che pulsa.
if (c.step === 'title' && !game.speech) {
const pulse = 0.5 + 0.5 * Math.sin(time / 260), cx = right[0] + right[2] - 2, cy = right[1] + right[3] - 2, d = 12 + 3 * pulse;
ctx.fillStyle = `rgba(232, 194, 90, ${0.55 + 0.4 * pulse})`;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx - d, cy); ctx.lineTo(cx, cy - d); ctx.closePath(); ctx.fill();
}
}
ctx.restore();
// Il suggerimento in alto, finché c'è qualcosa da sfogliare.
const hint = c.step === 'cover' ? 'Clic o tocco: apri il libro' : c.step === 'title' ? 'Clic o tocco: gira la pagina' : null;
if (hint && !game.speech && c.age > 0.5) {
ctx.save();
ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300);
ctx.font = `7px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
const tw = ctx.measureText(hint).width;
ctx.fillRect(WIDTH / 2 - tw / 2 - 6, 8, tw + 12, 14);
ctx.fillStyle = '#ffd27a';
ctx.fillText(hint, WIDTH / 2, 15);
ctx.restore();
}
}
+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 { try {
const a = JSON.parse(storage.getItem(AUDIO_KEY)); const a = JSON.parse(storage.getItem(AUDIO_KEY));
if (!a || typeof a.volume !== 'number') return null; 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 { } catch {
return null; 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();
}
+1
View File
@@ -23,6 +23,7 @@ export function voiceLines(story) {
visit(story.scenes); visit(story.scenes);
visit(story.items); visit(story.items);
visit(story.dialogues); visit(story.dialogues);
visit(story.closeups);
for (const text of ENGINE_LINES) add('andrea', text); for (const text of ENGINE_LINES) add('andrea', text);
return [...seen.values()]; return [...seen.values()];
} }
+81 -15
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 { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js'; import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.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'; import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id); 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. // --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volumi e muto si ricordano.
const audioSettings = (storage && readAudio(storage)) ?? {}; const audioSettings = (storage && readAudio(storage)) ?? {};
const music = new Music(audioSettings); 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, // --- 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. // ma hanno il loro volume.
const voices = new Voices({}, undefined, audioSettings.voices); 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() { function showSound() {
$('mute').setAttribute('aria-pressed', String(music.muted)); $('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); $('volume').value = Math.round(music.volume * 100);
$('voice-volume').value = Math.round(voices.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. // 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); $('mute').addEventListener('click', toggleMute);
$('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); }); $('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(); }); $('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(); showSound();
window.music = music; // per i test nel browser 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. // 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]; const MAP_BANDS = [25, 50, 75, Infinity];
function openMap() { 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 board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces();
const bands = MAP_BANDS.map(() => []); const bands = MAP_BANDS.map(() => []);
for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p); for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p);
@@ -261,8 +267,9 @@ function openPause() {
running = false; running = false;
music.hold = true; music.hold = true;
voices.pause(); voices.pause();
ambience.configure({ active: false });
command.reset(); command.reset();
$('pause-map').hidden = game.mapPlaces().length < 2; $('pause-map').hidden = !!game.closeup || game.mapPlaces().length < 2;
$('pause').hidden = false; $('pause').hidden = false;
} }
function closePause() { function closePause() {
@@ -278,6 +285,7 @@ $('pause-button').addEventListener('click', openPause);
function leave(hidden) { function leave(hidden) {
music.setAway(hidden); music.setAway(hidden);
if (hidden) { if (hidden) {
ambience.configure({ active: false });
voices.pause(); voices.pause();
window.jingle?.pause(); window.jingle?.pause();
if (running) openPause(); 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. // --- Taccuino: le tappe fatte, barrate, e quella di adesso; in fondo dove chiedere aiuto.
function openNotebook() { function openNotebook() {
if (!running) return; if (!running || game.closeup) return;
const { done, now } = game.notebook(); const { done, now } = game.notebook();
$('notebook-list').replaceChildren(...done.map(t => Object.assign(document.createElement('li'), { textContent: t, className: 'done' })), $('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' })] : [])); ...(now ? [Object.assign(document.createElement('li'), { textContent: now, className: 'now' })] : []));
@@ -346,6 +354,7 @@ $('load-file').addEventListener('change', async e => {
function showTitle() { function showTitle() {
running = false; running = false;
ambience.configure({ active: false });
const saved = storage && readSave(storage); const saved = storage && readSave(storage);
$('continue').hidden = !saved || saved.ended; $('continue').hidden = !saved || saved.ended;
$('load-error').hidden = true; $('load-error').hidden = true;
@@ -385,11 +394,19 @@ let press = null;
canvas.addEventListener('pointerdown', e => { canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; } if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
if (!running || e.button > 0) 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 };
game.closeupAim(scenePoint(e));
canvas.setPointerCapture(e.pointerId);
return;
}
const point = scenePoint(e), reach = reachOf(e); 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) }; press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
}); });
canvas.addEventListener('pointerup', e => { canvas.addEventListener('pointerup', e => {
if (!press) return; if (!press) return;
if (press.closeup) { press = null; if (running && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer); clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità. // Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) { if (!press.long) {
@@ -403,11 +420,12 @@ canvas.addEventListener('pointerup', e => {
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; }); canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
}); });
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
}); });
canvas.addEventListener('pointerleave', () => { mouse = null; }); canvas.addEventListener('pointerleave', () => { mouse = null; });
@@ -444,7 +462,12 @@ function updateInventory() {
icon = document.createElement('i'); icon = document.createElement('i');
icon.style.background = item.icon?.fill ?? '#ccc'; 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; let timer = null, long = false;
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); }); b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
b.addEventListener('pointerup', () => clearTimeout(timer)); b.addEventListener('pointerup', () => clearTimeout(timer));
@@ -478,7 +501,7 @@ function updateChoices() {
} }
window.addEventListener('keydown', e => { 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. // Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude.
if (e.target === $('map-search')) { if (e.target === $('map-search')) {
if (e.key === 'Escape') { if (e.target.value) { e.target.value = ''; filterMap(); } else $('map').hidden = true; } if (e.key === 'Escape') { if (e.target.value) { e.target.value = ''; filterMap(); } else $('map').hidden = true; }
@@ -489,6 +512,27 @@ window.addEventListener('keydown', e => {
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; } if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; } if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; } 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();
return;
}
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; } if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; } if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; }
if ((e.key === 'n' || e.key === 'N') && running) { openNotebook(); return; } if ((e.key === 'n' || e.key === 'N') && running) { openNotebook(); return; }
@@ -502,6 +546,10 @@ window.addEventListener('keydown', e => {
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1); if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
}); });
// --- 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, reveal = null;
// --- Fumetto sopra chi parla, tenuto dentro i bordi della scena. // --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
function updateSpeech() { function updateSpeech() {
const box = $('speech'), sp = game.speech; const box = $('speech'), sp = game.speech;
@@ -528,16 +576,31 @@ function updateUi() {
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo. // 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; 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)); for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
const text = !running || game.speech || game.dialogue ? '' : 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; if ($('sentence').textContent !== text) $('sentence').textContent = text;
updateInventory(); updateInventory();
updateChoices(); updateChoices();
updateSpeech(); updateSpeech();
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare. // Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || game.mapPlaces().length < 2; $('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running; $('pause-button').hidden = !running;
$('spots-button').hidden = !running; $('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook; $('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') 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) { if (game.state.ended && !game.speech && !game.queue.length) {
running = false; running = false;
$('ending').hidden = false; $('ending').hidden = false;
@@ -554,10 +617,13 @@ function frame(now) {
const screen = !$('title').hidden ? 'title' : !$('splash').hidden ? 'splash' : !$('intro').hidden || !$('rione').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game'; 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); 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. // 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); music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase() }), dt);
// Un minuto senza toccare niente, a gioco fermo: pausa da sola. // 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(); 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 // 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. // telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava.
const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`; 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';
}
+175 -23
View File
@@ -104,6 +104,46 @@ export const story = {
{ role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] }, { role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] },
{ role: 'Programmazione', names: ['Manzolo, con Claude e Codex'] }, { role: 'Programmazione', names: ['Manzolo, con Claude e Codex'] },
], ],
// 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',
title: 'La storia infinita',
last: 'FINE',
comment: [{ say: 'Mi ricordavo un finale diverso.' }],
// Chiuso dopo aver visto l'ultima pagina (idea di Andrea, 25 ottobre).
closing: [{ say: 'Il «Nulla» sta viaggiando troppo velocemente nell\'animo umano, devo darmi una mossa!' }],
// Dove scrivere, in pixel delle due immagini: l'etichetta della copertina, le due pagine, la piega.
label: [78, 48, 104, 54],
pages: { left: [56, 26, 96, 194], right: [163, 12, 101, 204], spine: 157 },
paper: '#fbe6bc',
},
},
characters: { characters: {
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' }, andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' }, vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
@@ -133,12 +173,9 @@ export const story = {
actions: { actions: {
drop: [{ say: 'Una pagina sola, e pure con la fine. Lo lascio qui per il prossimo lettore.' }, { drop: 'libro' }], drop: [{ say: 'Una pagina sola, e pure con la fine. Lo lascio qui per il prossimo lettore.' }, { drop: 'libro' }],
look: [{ say: '«La storia infinita». Bello spesso: questo mi tiene compagnia fino a settembre.' }], look: [{ say: '«La storia infinita». Bello spesso: questo mi tiene compagnia fino a settembre.' }],
open: [ // Il primo piano (story.closeups.libro): la battuta la scopre chi guarda, nessuno la racconta.
{ say: 'Vediamo da dove comincia...' }, open: [{ closeup: 'libro' }],
{ say: 'C\'è una pagina sola. Con scritto: «FINE».' }, use: [{ closeup: 'libro' }],
{ say: 'Infinita, dicevano.' },
],
use: [{ say: 'Leggere adesso? Prima il palio. E poi, a quanto pare, si fa presto.' }],
}, },
}, },
// La catena del ghiacciolo (idea di Andrea, 2026-10-24): la Tosca dà ghiacciolo e cedrata; il ghiacciolo inzuppato // La catena del ghiacciolo (idea di Andrea, 2026-10-24): la Tosca dà ghiacciolo e cedrata; il ghiacciolo inzuppato
@@ -302,11 +339,25 @@ export const story = {
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk', id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk',
actions: { actions: {
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }], 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.' }], take: [{ say: 'Se li porto via, il paese resta senza notizie.' }],
}, },
give: { 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.' }], 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.' }],
}, },
}, },
{ {
@@ -377,6 +428,7 @@ export const story = {
take: [{ say: 'Le impronte non si prendono. Si seguono.' }], take: [{ say: 'Le impronte non si prendono. Si seguono.' }],
}, },
with: { with: {
torcia: [{ say: 'Di giorno la torcia non serve: le impronte si vedono benissimo. È il buio della fessura, il problema.' }],
squama: [{ squama: [{
if: 'indizio_regolo', if: 'indizio_regolo',
then: [{ say: 'Ormai lo so: è passato di qui. E le impronte piegano verso il bastione.' }], then: [{ say: 'Ormai lo so: è passato di qui. E le impronte piegano verso il bastione.' }],
@@ -411,21 +463,10 @@ export const story = {
talk: [{ say: 'C\'è nessuno?' }, { say: '...' }, { say: 'Solo l\'eco. Spero.' }], talk: [{ say: 'C\'è nessuno?' }, { say: '...' }, { say: 'Solo l\'eco. Spero.' }],
}, },
with: { with: {
stecco_gomma: [{ say: 'Troppo buio: non vedo nemmeno dove lo infilo. Prima ci vuole una luce.' }],
torcia: [ torcia: [
{ say: 'Accendo la torcia e la punto dentro...' }, { say: 'Accendo la torcia e la punto dentro...' },
{ set: 'luce_fessura' }, { closeup: '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 },
], ],
}, },
}, },
@@ -469,6 +510,10 @@ export const story = {
{ {
// San Pietro, quello delle chiavi: a chi risolve l'indovinello dà la chiave dell'oratorio. // 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', 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: { actions: {
look: [{ say: 'La statua di San Pietro, con le chiavi in mano. Lui il palio l\'ha visto passare di sicuro.' }], look: [{ say: 'La statua di San Pietro, con le chiavi in mano. Lui il palio l\'ha visto passare di sicuro.' }],
talk: [{ talk: [{
@@ -656,13 +701,17 @@ export const story = {
art: [{ rect: [565, 203, 11, 5], fill: '#c46a3a' }, { rect: [565, 207, 11, 1], fill: '#f4efe4' }, { rect: [569, 204, 3, 3], fill: '#e8c25a' }], art: [{ rect: [565, 203, 11, 5], fill: '#c46a3a' }, { rect: [565, 207, 11, 1], fill: '#f4efe4' }, { rect: [569, 204, 3, 3], fill: '#e8c25a' }],
actions: { actions: {
look: [{ say: 'Un libro dimenticato sulle tavolate. Sulla copertina c\'è scritto «La storia infinita».' }], look: [{ say: 'Un libro dimenticato sulle tavolate. Sulla copertina c\'è scritto «La storia infinita».' }],
take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Me lo prendo. Se è suo, il proprietario lo ritrova... prima o poi.' }], take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Ricordo di aver visto il film da piccolo... era stupendo!' }, { closeup: 'libro' }],
}, },
}, },
{ {
id: 'cancello', name: 'cancello', box: [477, 166, 23, 52], at: [488, 236], face: 'up', id: 'cancello', name: 'cancello', box: [477, 166, 23, 52], at: [488, 236], face: 'up',
actions: { actions: {
look: [{ say: 'Il cancello di dietro.' }], // Il vecchio campo «Alberto Bettini» (Andrea, 25 ottobre; www.polisportivasanpieroasieve.it).
look: [
{ say: 'Il cancello di dietro. Se chiudo gli occhi vedo ancora l\'A. Bettini, il campo da calcio di quando eravamo piccoli: lassù ci sono ancora i riflettori.' },
{ say: 'Il primo vero campo del paese: inaugurato nel 1964 e intitolato ad Alberto Bettini, un giovane consigliere della società scomparso troppo presto.' },
],
open: [{ say: 'Chiuso. Si apre solo quando c\'è la festa.' }], open: [{ say: 'Chiuso. Si apre solo quando c\'è la festa.' }],
}, },
}, },
@@ -929,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.' }] }] }], 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: { 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' }] }], 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' }] }],
}, },
}, },
@@ -1015,6 +1065,10 @@ export const story = {
}, },
{ {
id: 'barondeduke', name: 'il BaronDeDuke', box: [212, 245, 36, 55], at: [190, 316], face: 'right', default: 'talk', 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: { actions: {
look: [{ say: 'Il BaronDeDuke, col cappello da cowboy. Ha l\'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.' }], look: [{ say: 'Il BaronDeDuke, col cappello da cowboy. Ha l\'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.' }],
talk: [ talk: [
@@ -1319,9 +1373,13 @@ export const story = {
}, },
{ {
id: 'arcu', name: 'l\'Arcu', box: [400, 216, 38, 76], at: [362, 300], face: 'right', default: 'talk', 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: { actions: {
look: [{ say: 'L\'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.' }], look: [{ say: 'L\'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.' }],
talk: [{ say: 'Arcu! Ma che è sempre là, Borgo?' }, { say: 'E chi lo sposta???', who: 'arcu', pose: 'shrug' }], talk: [{ dialogue: 'arcu' }],
}, },
}, },
], ],
@@ -1385,6 +1443,10 @@ export const story = {
}, },
{ {
id: 'chiosco', name: 'chiosco', box: [240, 196, 106, 72], at: [300, 296], face: 'up', default: 'talk', 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: { actions: {
look: [{ say: 'Il chiosco del tennis. D\'estate qui c\'è la Tosca coi ghiaccioli.' }], 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' }], talk: [{ say: 'Tosca! C\'è la Tosca?' }, { say: 'Eccomi, Andrea! Cosa ti do? Fanta, Coca, cedrata, spuma bionda... o un ghiacciolo?', who: 'tosca' }, { dialogue: 'tosca' }],
@@ -1564,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.' }], 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.' }], 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' }, 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' },
@@ -1612,6 +1680,10 @@ export const story = {
}, },
{ {
id: 'tozzino', name: 'il Tozzino', box: [422, 224, 36, 76], at: [392, 312], face: 'right', default: 'talk', 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: { actions: {
look: [{ say: 'Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l\'orologio ogni dieci secondi.' }], look: [{ say: 'Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l\'orologio ogni dieci secondi.' }],
talk: [ talk: [
@@ -1844,6 +1916,12 @@ export const story = {
hotspots: [ hotspots: [
{ {
id: 'torre', name: 'torre del castello', box: [345, 26, 88, 196], at: [330, 312], face: 'up', default: 'talk', 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: { 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.' }], 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' }], talk: [{ dialogue: 'castello' }],
@@ -1881,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 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: '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' }] }, { 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' }] }, { text: 'Niente, grazie Tosca.', next: 'end', then: [{ say: 'Quando ti viene sete, sai dove trovarmi.', who: 'tosca' }] },
], ],
}, },
@@ -1937,11 +2022,78 @@ export const story = {
{ say: 'Nel mio, di Palio, si correva con le carriole vere. Mica come voi.', who: 'vecchietta' }, { 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' }] }, { 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' }] },
], ],
}, },
}, },
}, },
// L'Arcu davanti al circolino (battute di Andrea, 25 ottobre). Lavora alla Casa dell'Armadio: «Armando» è apposta.
arcu: {
nodes: {
start: {
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' },
],
},
},
},
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca. // Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
castello: { castello: {
nodes: { nodes: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.3'; export const VERSION = '0.1.5';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // 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). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+9 -1
View File
@@ -24,12 +24,18 @@ 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; } #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 { 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). */ /* 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; #speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center; font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center;
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; } text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
#speech:empty { display: none; } #speech:empty { display: none; }
/* Il primo piano del libro si chiude da qui (o con Esc). */
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
/* Audio: nota per il muto, cursore della musica e cursore delle voci («bla»), in alto a destra sopra la scena (anche sul titolo). */ /* Audio: nota per il muto, cursore della musica e cursore delle voci («bla»), in alto a destra sopra la scena (anche sul titolo). */
#sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px; #sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
@@ -48,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; } 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:hover, #inventory button:hover { color: var(--ink); }
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); } #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; 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. */ /* 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; } 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; #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; } 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 { 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 button[aria-pressed="true"] { border-color: var(--accent); }
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; } #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; } #inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
+75 -4
View File
@@ -6,6 +6,7 @@ import { staticServer } from './helpers.mjs';
import { mkdirSync, readFileSync } from 'node:fs'; import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { solution, route } from '../src/story/solution.js'; import { solution, route } from '../src/story/solution.js';
import { checkMultimedia } from './multimedia.browser.mjs';
const OUT = process.env.OUT ?? '/tmp/regolo-shots'; const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
@@ -47,6 +48,8 @@ const hotspotCenter = async (page, id) => {
}; };
try { try {
await checkMultimedia(browser, PORT, OUT, errors);
if (!process.argv.includes('--media-only')) {
const page = await open({ width: 1280, height: 860 }); const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo. // Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/); 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. // 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 } }); 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.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.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 }); 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`); assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
await slow.close(); await slow.close();
} }
@@ -182,12 +197,13 @@ try {
await page.click('#mute'); await page.click('#mute');
await page.locator('#volume').fill('30'); await page.locator('#volume').fill('30');
await page.locator('#voice-volume').fill('70'); 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); 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 // 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). // 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); const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
await hide(true); 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.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa'); assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
await hide(false); await hide(false);
@@ -211,8 +227,21 @@ try {
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` }); 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. // La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
if (target === 'fessura' && item) { if (target === 'fessura' && item) {
await page.waitForFunction(() => game.state.flags.occhi_regolo, null, { timeout: 30000 }); await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
await page.screenshot({ path: `${OUT}/05b-occhi.png` }); 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); await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` }); if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
@@ -293,6 +322,46 @@ try {
await page.click('#close-map'); await page.click('#close-map');
await page.setViewportSize({ width: 1280, height: 860 }); await page.setViewportSize({ width: 1280, height: 860 });
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
{
const book = await open({ width: 1280, height: 860 });
await book.click('#splash');
await book.click('#new-game');
await book.click('#rione [data-rione="campomigliaio"]');
await book.click('#skip-intro');
await settle(book);
await book.evaluate(() => game.go('oratorio', [540, 300]));
await settle(book);
await book.click('#verbs [data-verb="take"]');
await clickScene(book, await hotspotCenter(book, 'libro_dimenticato'));
await book.waitForFunction(() => game.closeup?.step === 'cover', null, { timeout: 15000 });
await book.waitForTimeout(600);
await book.screenshot({ path: `${OUT}/03a-libro-copertina.png` });
const where = await book.evaluate(() => [game.state.x, game.state.y]);
await clickScene(book, [320, 200]);
assert.equal(await book.evaluate(() => game.closeup.step), 'title', 'un clic apre il libro');
await clickScene(book, [320, 200]);
assert.equal(await book.evaluate(() => game.closeup.step), 'turn', 'un altro gira la pagina');
await book.waitForFunction(() => game.closeup?.step === 'end');
assert.equal(await book.evaluate(() => game.speech), null, '«FINE» non lo dice nessuno');
await book.screenshot({ path: `${OUT}/03b-libro-fine.png` });
await book.waitForFunction(() => game.speech?.text === 'Mi ricordavo un finale diverso.', null, { timeout: 5000 });
assert.deepEqual(await book.evaluate(() => [game.state.x, game.state.y]), where, 'i clic sul libro non muovono Andrea');
await book.keyboard.press('Space');
await book.keyboard.press('Escape');
assert.equal(await book.evaluate(() => game.closeup), null, 'Esc chiude il libro');
await book.waitForSelector('#closeup-close', { state: 'hidden' });
await settle(book);
await book.click('#verbs [data-verb="open"]');
await book.click('#inventory button');
await book.waitForFunction(() => game.closeup?.step === 'cover');
assert.ok(await book.isVisible('#closeup-close'), 'si vede il pulsante Chiudi');
await book.click('#closeup-close');
assert.equal(await book.evaluate(() => game.closeup), null, 'Chiudi chiude il libro');
await book.close();
}
const phone = await open({ width: 390, height: 844 }); const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo'); assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
@@ -346,6 +415,8 @@ try {
assert.deepEqual(errors, [], 'errori nella console'); assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`); console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
}
assert.deepEqual(errors, [], 'errori nella console');
} finally { } finally {
await browser.close(); await browser.close();
server.kill(); server.kill();

Some files were not shown because too many files have changed in this diff Show More