Compare commits

...
15 Commits
Author SHA1 Message Date
manzolo 721df30937 Versione 0.1.59 2026-10-30 23:06:42 +01:00
manzoloandClaude Opus 5.5 40d5780255 L'Ingorgo più da discoteca: fasci più forti e numerosi, colpo di colore a ogni battuta, strobo a raffica, macchie di luce sulla folla, laser anche in piazza, palla a specchi su tutta la scena; alla festa si clicca solo per spostarsi; il manifesto più facile da toccare col dito; il video breve dell'Ingorgo (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:59:54 +01:00
manzolo 9f2b045ab0 Versione 0.1.58 2026-10-30 22:31:40 +01:00
manzoloandClaude Opus 5.5 187b4c48cb Le luci da palco dell'Ingorgo: fasci colorati che spazzano dalle teste mobili, bagliore a tempo, strobo, laser verdi alla tecno di Cambray Digny, palla a specchi all'underground; solo alla festa (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:24:52 +01:00
manzoloandClaude Opus 5.5 c132e94ed8 L'Ingorgo Sonoro: dal manifesto in piazza, ingrandito e toccato, «Quanto darei per essere all'Ingorgo stasera?!?» e l'effetto speciale; il centro di notte pieno zeppo di gente (sfondi di Codex), le console di Andrea (Suno) che si mescolano camminando, Andrea più grande alla festa, uscendo si torna a maggio. Baldoria al campo sportivo col bandierone della Sampdoria (Codex) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:17:21 +01:00
manzoloandClaude Opus 5.5 9cd8027256 Il trailer extended rifatto senza dire dove sta il Regolo: la notte prima del Palio è la piazza scurita, nel finale solo due occhi gialli nel buio, il titolo sopra il panorama; in più Condor, campana, pesca, basket e pokerino giocati da soli (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 22:17:21 +01:00
manzolo 9f0f60d186 Versione 0.1.57 2026-10-30 11:27:15 +01:00
manzoloandClaude Opus 5.5 5135689293 La campana al campone delle poste col Taglia: il sassolino tirato con mira e forza, i salti a tempo con la lancetta dell'equilibrio, le caselle tinte coi gessi colorati e in cima la CAMPANA, sfondo del vialetto di Codex (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
2026-10-30 11:20:33 +01:00
manzolo 8693bff0c7 Versione 0.1.56 2026-10-30 00:59:08 +01:00
manzoloandClaude Opus 5.5 c6812db291 La macchina del tempo da fuori: la partenza giù per Via Liguri Magelli col fulmine e le sgommate di fuoco, e l'arrivo al ritorno dal 1997 (macchina da dietro e davanti di Codex) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-30 00:52:20 +01:00
manzolo 670626afa5 Versione 0.1.55 2026-10-30 00:44:15 +01:00
manzoloandClaude Opus 5.5 9bf41d01af Lo zio Mario nell'orto: «Zio, a me mi garba Michael Jackson!» «Mai col gesso!» (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-30 00:36:34 +01:00
manzoloandClaude Opus 5.5 16252fb04a Lo zio Mario nell'orto: la vitella di maiale, il Falcione e «No, dormo!» (battute di Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-30 00:34:21 +01:00
manzoloandClaude Opus 5.5 99d4dede80 Lo zio Mario al tramonto nel suo orto; negli orti le nuvole restano fuori dalle fronde che ondeggiano (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-30 00:29:38 +01:00
manzoloandClaude Opus 5.5 36eda83fe4 Vannino, se vinci il pokerino: «Tu m'ha pelato. La prossima volta porta il Barone.» (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-30 00:24:34 +01:00
88 changed files with 2354 additions and 116 deletions
+2 -2
View File
@@ -87,11 +87,11 @@ background: ## Porta uno sfondo a 640x360 nel gioco (SRC=..., DST=..., CROP=x,y,
@test -n "$(SRC)" || { echo "uso: make background SRC=assets/generated/nome.png [CROP=x,y,w,h]"; exit 2; } @test -n "$(SRC)" || { echo "uso: make background SRC=assets/generated/nome.png [CROP=x,y,w,h]"; exit 2; }
$(PYTHON) tools/prepare_background.py $(SRC) $(DST) $(if $(CROP),--crop $(CROP)) --colors $(COLORS) $(PYTHON) tools/prepare_background.py $(SRC) $(DST) $(if $(CROP),--crop $(CROP)) --colors $(COLORS)
character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand) character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand, FIX=wave=1.3)
$(PYTHON) tools/slice_sprites.py --sheet $(SHEET) --out /tmp/regolo-$(NAME) --cols 4 --rows 2 \ $(PYTHON) tools/slice_sprites.py --sheet $(SHEET) --out /tmp/regolo-$(NAME) --cols 4 --rows 2 \
--despill --names $(FRAMES) --prefix $(NAME)_ --despill --names $(FRAMES) --prefix $(NAME)_
$(PYTHON) tools/prepare_character.py /tmp/regolo-$(NAME) $(NAME) assets/characters --height $(HEIGHT) \ $(PYTHON) tools/prepare_character.py /tmp/regolo-$(NAME) $(NAME) assets/characters --height $(HEIGHT) \
--frames $(FRAMES) --ref $(REF) --frames $(FRAMES) --ref $(REF) $(if $(FIX),--fix $(FIX))
## ------------------------------------------------------------ docker ## ------------------------------------------------------------ docker
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
{
"image": "baldoria.png",
"height": 120,
"frames": {
"stand": {
"x": 0,
"y": 0,
"w": 59,
"h": 120,
"ax": 28.6,
"ay": 120
},
"blink": {
"x": 61,
"y": 0,
"w": 59,
"h": 120,
"ax": 28.4,
"ay": 120
},
"talk": {
"x": 122,
"y": 0,
"w": 59,
"h": 120,
"ax": 28.6,
"ay": 120
},
"explain": {
"x": 183,
"y": 0,
"w": 67,
"h": 122,
"ax": 29.4,
"ay": 122
},
"wave": {
"x": 252,
"y": 0,
"w": 101,
"h": 156,
"ax": 48.9,
"ay": 156
},
"wave2": {
"x": 355,
"y": 0,
"w": 127,
"h": 156,
"ax": 81.8,
"ay": 156
},
"cheer": {
"x": 484,
"y": 0,
"w": 70,
"h": 120,
"ax": 33.6,
"ay": 120
},
"laugh": {
"x": 556,
"y": 0,
"w": 78,
"h": 113,
"ax": 31.1,
"ay": 113
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
+139 -3
View File
@@ -119,6 +119,10 @@
"file": "andrea-559fbc00c4.mp3", "file": "andrea-559fbc00c4.mp3",
"sec": 4.2 "sec": 4.2
}, },
"andrea|Lo lascio dov'è: è l'unica cosa che ricorda luglio, qui, a maggio.": {
"file": "andrea-9f315e91e5.mp3",
"sec": 4.4
},
"andrea|I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.": { "andrea|I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.": {
"file": "andrea-af4d717814.mp3", "file": "andrea-af4d717814.mp3",
"sec": 6.44 "sec": 6.44
@@ -1095,6 +1099,14 @@
"file": "barondeduke-238f525535.mp3", "file": "barondeduke-238f525535.mp3",
"sec": 1.65 "sec": 1.65
}, },
"andrea|Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.": {
"file": "andrea-a85f0e0a9a.mp3",
"sec": 6.47
},
"andrea|Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.": {
"file": "andrea-4faef889c8.mp3",
"sec": 5.63
},
"andrea|La tribuna bianca e rossa. La domenica qui c'è più tifo che al Palio. Quasi.": { "andrea|La tribuna bianca e rossa. La domenica qui c'è più tifo che al Palio. Quasi.": {
"file": "andrea-a50071085e.mp3", "file": "andrea-a50071085e.mp3",
"sec": 6.84 "sec": 6.84
@@ -1887,6 +1899,38 @@
"file": "andrea-d38b6e49ef.mp3", "file": "andrea-d38b6e49ef.mp3",
"sec": 3.49 "sec": 3.49
}, },
"andrea|Lo zio Mario nel suo orto, col sole che va giù dietro la casa.": {
"file": "andrea-9309e50d5c.mp3",
"sec": 3.78
},
"zio_mario|Ti s'aspetta a cena, che c'è la vitella di maiale.": {
"file": "zio_mario-39ff0c666f.mp3",
"sec": 2.71
},
"andrea|Non ti avevo visto, pensavo tu fossi andato all'estero.": {
"file": "andrea-f847f224df.mp3",
"sec": 3.35
},
"zio_mario|Io ho girato tanto... il Falcione.": {
"file": "zio_mario-d0d592db52.mp3",
"sec": 2.73
},
"andrea|Zio, che sei stanco?": {
"file": "andrea-21195c6cac.mp3",
"sec": 2.31
},
"zio_mario|No, dormo!": {
"file": "zio_mario-a852e9c43f.mp3",
"sec": 1.19
},
"andrea|Zio, a me mi garba Michael Jackson!": {
"file": "andrea-599b75d7c7.mp3",
"sec": 2.6
},
"zio_mario|Mai col gesso!": {
"file": "zio_mario-5773e3350f.mp3",
"sec": 1.13
},
"andrea|Le case coi balconi pieni di vasi: qui ogni finestra è un giardino.": { "andrea|Le case coi balconi pieni di vasi: qui ogni finestra è un giardino.": {
"file": "andrea-ec81875c47.mp3", "file": "andrea-ec81875c47.mp3",
"sec": 5.22 "sec": 5.22
@@ -2795,6 +2839,38 @@
"file": "pierino_parigi-8104163151.mp3", "file": "pierino_parigi-8104163151.mp3",
"sec": 3.3 "sec": 3.3
}, },
"andrea|Baldoria! Ma che ci fai col bandierone al campo?": {
"file": "andrea-3433db09f3.mp3",
"sec": 3.79
},
"andrea|Guarda che qui la Sampdoria non ci gioca.": {
"file": "andrea-408197f33d.mp3",
"sec": 3.85
},
"andrea|Hai visto passare il palio, per caso?": {
"file": "andrea-a0ac302eb3.mp3",
"sec": 1.76
},
"andrea|Ciao, Baldoria.": {
"file": "andrea-6697e2ce25.mp3",
"sec": 0.97
},
"baldoria|Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.": {
"file": "baldoria-954f26beb7.mp3",
"sec": 6.08
},
"baldoria|Forza Doria!": {
"file": "baldoria-97a6835622.mp3",
"sec": 1.03
},
"baldoria|La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.": {
"file": "baldoria-1eb6f19203.mp3",
"sec": 5.54
},
"baldoria|L'unico palio che conosco è blu, bianco, rosso e nero. E ce l'ho in mano.": {
"file": "baldoria-0accd6c002.mp3",
"sec": 6.1
},
"andrea|Taglia! Si fa una gara con la Condor?": { "andrea|Taglia! Si fa una gara con la Condor?": {
"file": "andrea-99f683209e.mp3", "file": "andrea-99f683209e.mp3",
"sec": 3.41 "sec": 3.41
@@ -2803,6 +2879,10 @@
"file": "andrea-3f77b83f35.mp3", "file": "andrea-3f77b83f35.mp3",
"sec": 0.87 "sec": 0.87
}, },
"andrea|E una partita a campana, Taglia?": {
"file": "andrea-595df0a426.mp3",
"sec": 2.23
},
"andrea|Ma quanto sei alto, Taglia?": { "andrea|Ma quanto sei alto, Taglia?": {
"file": "andrea-01ecdf3ed8.mp3", "file": "andrea-01ecdf3ed8.mp3",
"sec": 1.96 "sec": 1.96
@@ -2827,6 +2907,14 @@
"file": "taglia-ac012a1066.mp3", "file": "taglia-ac012a1066.mp3",
"sec": 4.21 "sec": 4.21
}, },
"taglia|A campana? Alla nostra età?": {
"file": "taglia-0a7f69770d.mp3",
"sec": 2.77
},
"taglia|Va bene: il gesso ce l'ho in tasca, il sassolino lo scelgo io. Sul vialetto, dalla 1 alla 8, e chi pesta la riga passa la mano.": {
"file": "taglia-c00be84faf.mp3",
"sec": 11.54
},
"taglia|Abbastanza da vedere i pallini arrivare prima di te.": { "taglia|Abbastanza da vedere i pallini arrivare prima di te.": {
"file": "taglia-d99c875252.mp3", "file": "taglia-d99c875252.mp3",
"sec": 2.57 "sec": 2.57
@@ -2843,6 +2931,18 @@
"file": "taglia-4117f6613d.mp3", "file": "taglia-4117f6613d.mp3",
"sec": 4.84 "sec": 4.84
}, },
"taglia|Alla bona: le righe un contano. Comincia te.": {
"file": "taglia-328d930124.mp3",
"sec": 4.12
},
"taglia|Seria: chi tocca la riga è fuori. Comincia te.": {
"file": "taglia-1266463538.mp3",
"sec": 4.23
},
"taglia|A fassi male? Io la campana la facevo a occhi chiusi.": {
"file": "taglia-7938d8d5e4.mp3",
"sec": 5.05
},
"andrea|Si fa una gara alle macchinine?!?!": { "andrea|Si fa una gara alle macchinine?!?!": {
"file": "andrea-17216997e2.mp3", "file": "andrea-17216997e2.mp3",
"sec": 2.74 "sec": 2.74
@@ -3255,9 +3355,9 @@
"file": "bertovo-ca941bee91.mp3", "file": "bertovo-ca941bee91.mp3",
"sec": 5.41 "sec": 5.41
}, },
"vannino|M'hai pelato, ragazzo. Nel 1997 un ci si vedeva da queste parti, con quella faccia.": { "vannino|Tu m'ha pelato. La prossima volta porta il Barone.": {
"file": "vannino-e68fb40eba.mp3", "file": "vannino-2267bb5adf.mp3",
"sec": 7.85 "sec": 3.18
}, },
"pierino_parigi|Le fiches restano al banco. E tu torna quando hai imparato a bluffare.": { "pierino_parigi|Le fiches restano al banco. E tu torna quando hai imparato a bluffare.": {
"file": "pierino_parigi-a80d04fe5b.mp3", "file": "pierino_parigi-a80d04fe5b.mp3",
@@ -3311,6 +3411,42 @@
"file": "andrea-aba12c5241.mp3", "file": "andrea-aba12c5241.mp3",
"sec": 3.1 "sec": 3.1
}, },
"andrea|Dalla 1 fino in cima, e ritorno. A ventisett'anni, davanti alle poste.": {
"file": "andrea-68378e4c21.mp3",
"sec": 6.15
},
"taglia|Te lo dico io: se ti vede qualcuno, domani lo sa tutto il paese.": {
"file": "taglia-1e6660932d.mp3",
"sec": 3.34
},
"andrea|Il Taglia, su un piede solo, è più alto di me su due.": {
"file": "andrea-3ed6165062.mp3",
"sec": 3.31
},
"taglia|È questione di gambe lunghe.": {
"file": "taglia-9782816264.mp3",
"sec": 1.71
},
"andrea|Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.": {
"file": "andrea-356a1afe8d.mp3",
"sec": 5.91
},
"andrea|Il manifesto dell'Ingorgo Sonoro dell'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.": {
"file": "andrea-0c26e34815.mp3",
"sec": 7.53
},
"andrea|Quanto darei per essere all'Ingorgo stasera?!?": {
"file": "andrea-366c066d68.mp3",
"sec": 2.28
},
"andrea|Ma... è l'Ingorgo! Le console, la gente, le spine della birra!": {
"file": "andrea-bc7d5f4dc0.mp3",
"sec": 7.5
},
"andrea|Il palio può aspettare una sera. O no?": {
"file": "andrea-24e5ac53da.mp3",
"sec": 3.07
},
"chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": { "chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": {
"file": "chiaru-91a4b23b22.mp3", "file": "chiaru-91a4b23b22.mp3",
"sec": 4.44 "sec": 4.44
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.
+4
View File
@@ -36,7 +36,11 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
| `characters/zio_mario.png` | Codex | `foto/zio-mario.png` | Andrea (29 ottobre) | sì (famiglia di Andrea) | | `characters/zio_mario.png` | Codex | `foto/zio-mario.png` | Andrea (29 ottobre) | sì (famiglia di Andrea) |
| `terrazza-cardetole.png` | Codex (dalla foto, poi il terrazzo davanti) | `cardetole-finestra.jpg` | Andrea (29 ottobre) | **da decidere**: come `cardetole.png`, dice dove abita Andrea | | `terrazza-cardetole.png` | Codex (dalla foto, poi il terrazzo davanti) | `cardetole-finestra.jpg` | Andrea (29 ottobre) | **da decidere**: come `cardetole.png`, dice dove abita Andrea |
| `closeups/muro-cerbottana.png` | Codex, solo testo (stile dalla piazza) | nessuna | — | sì | | `closeups/muro-cerbottana.png` | Codex, solo testo (stile dalla piazza) | nessuna | — | sì |
| `closeups/macchina-dietro.png`, `macchina-lampi.png`, `macchina-davanti.png` | Codex, dallo sprite della macchina del tempo | nessuna | — | sì |
| `closeups/campone-condor.png` | Codex, dallo sfondo del campone delle poste | nessuna | — | sì | | `closeups/campone-condor.png` | Codex, dallo sfondo del campone delle poste | nessuna | — | sì |
| `closeups/campone-campana.png` | Codex, solo testo (stile dal campone delle poste) | nessuna | — | sì |
| `closeups/manifesto-ingorgo.png` | Codex, dal manifesto vero (2014) senza sponsor, loghi e data | `assets/raw/riferimenti/manifesto-ingorgo-2014.png` | Pro Loco San Piero a Sieve (manifesto), Andrea | sì |
| `ingorgo-piazza.png`, `ingorgo-antica-posta.png`, `ingorgo-piazzetta-comune.png`, `ingorgo-antica-posta-alta.png`, `ingorgo-piazzetta.png` | Codex, dagli sfondi di giorno delle stesse scene | foto della festa di Andrea (`assets/raw/foto/ingorgo-*.png`), solo per l'atmosfera | Andrea | sì |
| `barrettino-1997.png`, `closeups/cruscotto-tempo.png` | Codex, solo testo | nessuna | — | sì | | `barrettino-1997.png`, `closeups/cruscotto-tempo.png` | Codex, solo testo | nessuna | — | sì |
| `characters/vannino.png`, `characters/pierino_parigi.png` | Codex, dalla descrizione di Andrea (niente foto) | nessuna | — | sì (Pierino: consenso del figlio Marco) | | `characters/vannino.png`, `characters/pierino_parigi.png` | Codex, dalla descrizione di Andrea (niente foto) | nessuna | — | sì (Pierino: consenso del figlio Marco) |
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita | | `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
+36
View File
@@ -18,6 +18,42 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati ## Dove siamo arrivati
- **L'Ingorgo Sonoro (30 ottobre, extra)**: in piazza il vecchio manifesto dell'Ingorgo (`manifesto_ingorgo`); guardandolo
si apre ingrandito (primo piano `poster`, `manifesto-ingorgo.png`: Codex dal manifesto vero del 2014, senza sponsor né
data); toccandolo, «Quanto darei per essere all'Ingorgo stasera?!?», poi l'effetto speciale (primo piano `incanto`, `incanto.js`: fasci di
luce, cassa che accelera, lampo bianco, coriandoli) e il centro diventa la notte della festa (flag `ingorgo`, fase
`ingorgo`, sfondi di notte di Codex in `backdrops`). La zona (`story.zones`): Piazza, Via dell'Antica Posta, Piazzetta
del Comune, cima di Via dell'Antica Posta, Piazzetta Cambray Digny; uscendo (a piedi o con la mappa) si torna a maggio.
Alla festa si clicca solo per spostarsi (zona con `exitsOnly`: panchine, porte e statue non rispondono).
Le luci da discoteca animate solo alla festa (`stagelights`, `lasers`, `mirrorball`, `floorspots` in render.js), rinforzate
su richiesta di Andrea: colpo di colore a ogni battuta, strobo a raffica a fine frase, macchie che corrono sulla folla. Fasci che spazzano, bagliore a
tempo, strobo, laser alla tecno, palla a specchi all'underground. Le console (`stages` delle scene, `music.js` `mix`): più brani insieme, ognuno più forte vicino alla sua console, anche
da fuori quadro (la piazza accanto). Generi provvisori: house in Piazza, latino al Comune, underground in cima a Via
dell'Antica Posta, tecno a Cambray Digny; i brani sono di Andrea (Suno), portati al volume del gioco. Gli sfondi sono
pieni zeppi di gente fino in primo piano (Andrea); la prima versione, con la piazza vuota davanti, in
`assets/raw/ingorgo-v1/`. La voce legge «console» come «consòl» (`tools/pronuncia.json`).
[DA ANDREA] i generi veri per piazza, le battute, i bar a tema; da fare: spillare la birra (minigioco) e bere.
- **Baldoria al campo sportivo (30 ottobre)**: Yuri Baldi, dei Ringraziamenti, al Campo principale con la maglia
blucerchiata e un bandierone della Sampdoria grande come lui, che ogni tanto sventola (`flourish` delle persone).
Sprite di Codex (`assets/raw/baldoria-sheet.png`; `make character … FIX=wave=1.2,…` perché Codex ha disegnato più
piccole le figure col bandierone in alto). Un dialogo breve. [DA ANDREA: le battute; la somiglianza va rivista con
una sua foto]
- **La campana al campone delle poste (30 ottobre)**: dal Taglia, «E una partita a campana, Taglia?», poi il livello.
Primo piano `campana` visto dall'alto sul vialetto (`campone-campana.png`, Codex); la campana col gesso la disegna il
codice (`campana.js`, `campana-render.js`): 1-2-3, coppia 4-5, 6, coppia 7-8 e in cima il mezzo cerchio con scritto
CAMPANA (Andrea: non «cielo»), ogni casella tinta tenue col suo gesso. Il sassolino: la mira dà la direzione, tenendo
premuto la forza va e torna, si lascia; deve cadere nella casella che tocca senza toccare le righe (alla bona le
righe non contano e il puntino giallo mostra dove cade). I salti: la lancetta dell'equilibrio attraversa la barra e
si preme nel verde (presto: giù l'altro piede; tardi: pestata la riga); si scavalca la casella col sassolino e
tornando lo si raccoglie. Chi ce la fa passa alla casella dopo e ritira, chi sbaglia passa la mano; il Taglia gioca
da solo (in simulazione gli servono circa 22, 10 e 6 turni secondo il livello). Vince chi finisce la 8 (flag
`campana_vinta`). Test `campana.test.mjs`, prova `npm run browser:campana`. [DA ANDREA] le battute.
- **La macchina del tempo da fuori (30 ottobre)**: dopo il cruscotto, la partenza giù per Via Liguri Magelli
(primo piano `corsa`, `src/engine/corsa.js`): accelera, il fulmine la prende, il bianco, e restano due sgommate di
fuoco che bruciano e si spengono; al ritorno dal 1997 l'arrivo: il fulmine in fondo alla strada, lei viene avanti e
ci passa accanto lasciando le strisce. La macchina da dietro e davanti (coi lampi) è di Codex.
- **Lo zio Mario negli orti al tramonto (30 ottobre)**: dal tramonto (`indizio_regolo`) non è più davanti alla
Ghiacciomobile ma accanto al suo orto (`orti_zio_mario`), con una battuta provvisoria [DA ANDREA].
- **La Condor al campone delle poste (29 ottobre)**: la gara col Taglia (Andrea Tagliaferri, consenso dato) con la - **La Condor al campone delle poste (29 ottobre)**: la gara col Taglia (Andrea Tagliaferri, consenso dato) con la
pistola ad aria compressa coi pallini gialli (`condor.js`, `condor-render.js`, primo piano `condor`, sfondo di Codex). pistola ad aria compressa coi pallini gialli (`condor.js`, `condor-render.js`, primo piano `condor`, sfondo di Codex).
Come la cerbottana: il mirino ondeggia col respiro; tenere premuto trattiene il fiato (il mirino si ferma), lasciare Come la cerbottana: il mirino ondeggia col respiro; tenere premuto trattiene il fiato (il mirino si ferma), lasciare
+11
View File
@@ -283,6 +283,17 @@
mira un po' più sopra; alla bona il puntino giallo segna dove arriva. Il palloncino e la lattina in aria si mira un po' più sopra; alla bona il puntino giallo segna dove arriva. Il palloncino e la lattina in aria si
muovono: si mira dove saranno. Si può anche premere Spara, senza trattenere il fiato. Frecce per spostare il mirino.</p> muovono: si mira dove saranno. Si può anche premere Spara, senza trattenere il fiato. Frecce per spostare il mirino.</p>
</details> </details>
<details id="campana-help" hidden><summary>Come si gioca</summary>
<p>La campana col gesso sul vialetto del campone, a turni col Taglia. Si tira il sassolino nella casella che tocca,
dalla 1 alla 8: deve cadere dentro, senza toccare le righe (alla bona le righe non contano). Poi si salta tutta la
campana fino in cima (il mezzo cerchio) e indietro, su un piede nelle caselle sole e a gambe larghe nella 4-5 e nella 7-8, senza mai
mettere il piede dove sta il sassolino; tornando lo si raccoglie dalla casella prima. Chi ce la fa passa alla casella
dopo e ritira; chi sbaglia passa la mano. Vince chi finisce la 8.</p>
<p>Il tiro: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza (sale e scende) e lascia; oppure
regola la forza e premi Tira. Alla bona il puntino giallo segna dove cade. Frecce su e giù per la mira, destra e
sinistra per la forza. I salti: la lancetta dell'equilibrio attraversa la barra; premi (o Spazio, o Salta) quando è
nel verde. Troppo presto metti giù l'altro piede, troppo tardi pesti la riga.</p>
</details>
<details id="cerbottana-help" hidden><summary>Come si gioca</summary> <details id="cerbottana-help" hidden><summary>Come si gioca</summary>
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio <p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.54", "version": "0.1.59",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,6 +11,8 @@
"browser:gara": "node tests/gara.browser.mjs", "browser:gara": "node tests/gara.browser.mjs",
"browser:cerbottana": "node tests/cerbottana.browser.mjs", "browser:cerbottana": "node tests/cerbottana.browser.mjs",
"browser:condor": "node tests/condor.browser.mjs", "browser:condor": "node tests/condor.browser.mjs",
"browser:campana": "node tests/campana.browser.mjs",
"browser:ingorgo": "node tests/ingorgo.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs", "browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs", "browser:briscola": "node tests/briscola.browser.mjs",
+141
View File
@@ -0,0 +1,141 @@
import { W, CY, START, CIELO, FROM, BOXES, LAST, FLIGHT, HOP, center, landing, needle, canAdjust } from './campana.js';
import { text, panel } from './bocce-render.js';
function rect(ctx, x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); }
// Il gesso: tratti a puntini un po' storti, con dei buchi (sempre gli stessi, da un piccolo hash: non tremano).
const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
function chalkLine(ctx, x1, y1, x2, y2, seed) {
const n = Math.max(2, Math.round(Math.hypot(x2 - x1, y2 - y1)));
for (let i = 0; i <= n; i++) {
const k = i / n, h = hash(seed * 1000 + i);
if (h < 0.07) continue;
ctx.fillStyle = h > 0.8 ? 'rgba(255,255,250,0.95)' : 'rgba(240,238,228,0.78)';
ctx.fillRect(Math.round(x1 + (x2 - x1) * k + (hash(seed + i) - 0.5)), Math.round(y1 + (y2 - y1) * k + (hash(seed * 7 + i) - 0.5)), 2, 2);
}
}
// I gessi colorati: ogni casella ripassata tenue col suo colore, a puntini come il gesso sull'asfalto (idea di Andrea).
const TINTS = { 1: [244, 160, 180], 2: [250, 222, 120], 3: [150, 200, 240], 4: [160, 220, 150], 5: [200, 170, 230], 6: [250, 180, 120], 7: [140, 220, 210], 8: [240, 150, 150], top: [255, 240, 200] };
function tint(ctx, [r, g, b], x, y, w, h, seed, inside = () => true) {
ctx.fillStyle = `rgba(${r},${g},${b},0.16)`;
for (let yy = y; yy < y + h; yy += 2) for (let xx = x; xx < x + w; xx += 2) {
if (!inside(xx, yy)) continue;
const k = hash(seed * 7777 + xx * 13 + yy * 7);
if (k < 0.18) continue; // l'asfalto che spunta fra il gesso
ctx.fillStyle = `rgba(${r},${g},${b},${k > 0.85 ? 0.42 : 0.24})`; ctx.fillRect(xx, yy, 2, 2);
}
}
// Il gesso non cambia mai: tinte e righe si disegnano una volta su una tela a parte (a puntini, a ogni fotogramma,
// rallentavano la pagina). Fuori dal browser (nei test) si disegnano direttamente.
let layer = null;
function chalkwork(ctx) {
let s = 1;
for (const [id, b] of Object.entries(BOXES)) tint(ctx, TINTS[id], b.x + 1, b.y + 1, W - 2, W - 2, Number(id));
tint(ctx, TINTS.top, CIELO.x + 1, CY - CIELO.r, CIELO.r, CIELO.r * 2, 9, (x, y) => Math.hypot(x + 1 - CIELO.x, y + 1 - CY) < CIELO.r - 2);
for (const b of Object.values(BOXES)) {
chalkLine(ctx, b.x, b.y, b.x + W, b.y, s++); chalkLine(ctx, b.x, b.y + W, b.x + W, b.y + W, s++);
chalkLine(ctx, b.x, b.y, b.x, b.y + W, s++); chalkLine(ctx, b.x + W, b.y, b.x + W, b.y + W, s++);
}
// In cima la campana: il mezzo cerchio in fondo, dove ci si gira.
for (let i = 0; i < 40; i++) {
const a1 = -Math.PI / 2 + i / 40 * Math.PI, a2 = -Math.PI / 2 + (i + 1) / 40 * Math.PI;
chalkLine(ctx, CIELO.x + Math.cos(a1) * CIELO.r, CY + Math.sin(a1) * CIELO.r, CIELO.x + Math.cos(a2) * CIELO.r, CY + Math.sin(a2) * CIELO.r, 200 + i);
}
// La riga di partenza.
chalkLine(ctx, START - 14, CY - W, START - 14, CY + W, 300);
}
function grid(ctx, c) {
if (typeof document === 'undefined') chalkwork(ctx);
else {
if (!layer) { layer = document.createElement('canvas'); layer.width = 640; layer.height = 360; chalkwork(layer.getContext('2d')); }
ctx.drawImage(layer, 0, 0);
}
// Il numero in alto nella casella: il mezzo resta libero per il sassolino e i piedi. Quella che tocca ha il bordo giallo.
for (const [id, b] of Object.entries(BOXES)) {
const p = center(id), next = Number(id) === c.target[c.current] && c.step !== 'result';
if (next) { ctx.strokeStyle = 'rgba(255,236,140,0.55)'; ctx.lineWidth = 2; ctx.strokeRect(b.x + 4, b.y + 4, W - 8, W - 8); }
text(ctx, id, p.x, p.y - 17, 14, next ? '#fff3a8' : 'rgba(245,243,232,0.88)', 'center');
}
text(ctx, 'CAMPANA', CIELO.x + W * 0.53, CY - 26, 8, 'rgba(245,243,232,0.88)', 'center');
}
// Il sassolino: grigio, col lume in alto; in volo, più su e con l'ombra sotto.
function stone(ctx, x, y, lift = 0) {
ctx.fillStyle = 'rgba(30,25,20,0.35)'; ctx.beginPath(); ctx.ellipse(x + 1, y + 2, 5, 3, 0, 0, Math.PI * 2); ctx.fill();
const s = 1 + lift / 60, yy = y - lift;
ctx.fillStyle = '#4a4744'; ctx.beginPath(); ctx.ellipse(x, yy, 5 * s, 4 * s, 0.3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#8d8984'; ctx.beginPath(); ctx.ellipse(x - 0.5, yy - 0.5, 4 * s, 3 * s, 0.3, 0, Math.PI * 2); ctx.fill();
rect(ctx, x - 2 * s, yy - 2 * s, 2, 1, '#cfcac2');
}
// Dove stanno i piedi: a gambe larghe nelle coppie e nel cielo, su un piede nelle caselle sole, alla partenza tutti e due.
function feet(beat) {
if (!beat || beat.out) return [{ x: FROM.x + 2, y: CY - 9 }, { x: FROM.x + 2, y: CY + 9 }];
if (beat.boxes[0] === 'cielo') { const p = center('cielo'); return [{ x: p.x, y: p.y - 12 }, { x: p.x, y: p.y + 12 }]; }
return beat.boxes.map(id => center(id));
}
// Una scarpa vista dall'alto: la punta verso dove si va.
function shoe(ctx, x, y, dir, scale, [sole, upper, lace]) {
ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(dir * scale, scale);
ctx.fillStyle = 'rgba(30,25,20,0.3)'; ctx.fillRect(-9, -3, 21, 9);
ctx.fillStyle = sole; ctx.fillRect(-10, -5, 20, 10); ctx.fillRect(10, -4, 2, 8);
ctx.fillStyle = upper; ctx.fillRect(-9, -4, 18, 8);
ctx.fillStyle = lace; ctx.fillRect(-1, -2, 7, 1); ctx.fillRect(-1, 1, 7, 1);
ctx.fillStyle = '#2a2420'; ctx.fillRect(-8, -2, 4, 4); // la caviglia, il buco della scarpa
ctx.restore();
}
const SHOES = { player: ['#e8e6e0', '#3d6fb8', '#ffffff'], cpu: ['#2b2b2b', '#b8322a', '#f0d8a8'] };
function walker(ctx, c) {
const beat = c.beats?.[c.beat], prev = c.beat > 0 ? c.beats[c.beat - 1] : null, now = feet(c.beat >= 0 ? beat : null);
const k = c.hop ? Math.min(1, c.hop.t / HOP) : 1, from = feet(c.beat > 0 ? prev : null);
const mid = list => list.reduce((a, p) => ({ x: a.x + p.x / list.length, y: a.y + p.y / list.length }), { x: 0, y: 0 });
const a = mid(from), b = mid(now), off = { x: (a.x - b.x) * (1 - k), y: (a.y - b.y) * (1 - k) };
const scale = (c.current === 'cpu' ? 1.45 : 1.3) * (1 + 0.35 * Math.sin(Math.PI * k));
const dir = beat?.back ? -1 : 1;
for (const p of now) shoe(ctx, p.x + off.x, p.y + off.y, dir, scale, SHOES[c.current]);
}
export function drawCampana(ctx, c, def, bg) {
ctx.save(); ctx.imageSmoothingEnabled = false;
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#9a948a'; ctx.fillRect(0, 0, 640, 360); }
grid(ctx, c);
if (c.stone) stone(ctx, c.stone.x, c.stone.y);
walker(ctx, c);
if (c.shot) {
const k = Math.min(1, c.shot.t / FLIGHT), { from, to } = c.shot;
stone(ctx, from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k, 40 * Math.sin(Math.PI * k));
}
// La mira: un cerchietto di gesso dove si punta; alla bona, il puntino dove cade il sassolino con questa forza.
if (canAdjust(c)) {
ctx.strokeStyle = '#fff8dc'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(c.aim.x, c.aim.y, 6, 0, Math.PI * 2); ctx.stroke();
if (c.skill.guide) { const l = landing(c); rect(ctx, l.x - 2, l.y - 2, 4, 4, '#f5d02a'); rect(ctx, l.x - 1, l.y - 1, 2, 2, '#7a5a10'); }
// La forza: la barra in basso a sinistra.
const x = 16, y = 296, h = 50, f = Math.round(h * c.power);
rect(ctx, x - 3, y - 3, 18, h + 6, '#17160fd0');
rect(ctx, x, y + h - f, 12, f, c.holding ? '#ffd23c' : '#d9ccba');
text(ctx, 'FORZA', x + 6, y - 9, 6, '#efd49e', 'center');
}
// L'equilibrio: la lancetta attraversa la barra; si salta quando è nel verde.
if (c.step === 'hop' && c.meter) {
const x = 220, y = 318, w = 200, zone = c.skill.zone * w / 2, u = needle(c);
rect(ctx, x - 4, y - 14, w + 8, 30, '#17160fd8');
rect(ctx, x, y, w, 8, '#7a3a2a'); rect(ctx, x + w / 2 - zone, y, zone * 2, 8, '#5fae4a');
rect(ctx, x + w / 2 + u * w / 2 - 1, y - 3, 3, 14, '#fff3a8');
text(ctx, 'EQUILIBRIO', x + w / 2, y - 7, 6, '#efd49e', 'center');
}
// In alto: a che casella è ciascuno, di chi è il turno, cosa è successo.
rect(ctx, 0, 0, 640, 22, '#17160fe8');
text(ctx, 'LA CAMPANA', 8, 7, 8, '#efd49e');
text(ctx, { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level], 8, 16, 7, '#d9ccba');
const at = n => n > LAST ? 'finita' : `alla ${n}`;
text(ctx, `Andrea ${at(c.target.player)}`, 220, 11, 9, c.current === 'player' ? '#fff0c8' : '#a8a08c', 'center');
text(ctx, `Il Taglia ${at(c.target.cpu)}`, 360, 11, 9, c.current === 'cpu' ? '#9fd0ff' : '#7f95a8', 'center');
text(ctx, c.current === 'player' ? 'tocca a te' : 'tocca al Taglia', 560, 11, 8, '#d9ccba', 'center');
if (c.line) { rect(ctx, 150, 24, 340, 14, '#17160fd0'); text(ctx, c.line, 320, 31, 8, /riga|fuori|Troppo|equilibrio|piede|non nella/i.test(c.line) ? '#ffbf9f' : '#9fd0ff', 'center'); }
if (c.step === 'result') {
const won = c.result.winner === 'player';
panel(ctx, 170, 140, 300, 76);
text(ctx, won ? 'BATTUTO IL TAGLIA!' : 'HA VINTO IL TAGLIA', 320, 160, 12, won ? '#b9e5b3' : '#efd49e', 'center');
text(ctx, won ? 'Tutta la campana, dalla 1 alla 8.' : `Tu eri arrivato alla ${c.target.player}.`, 320, 182, 8, '#efd49e', 'center');
text(ctx, 'Torna al campone per uscire.', 320, 200, 8, '#d9ccba', 'center');
}
ctx.restore();
}
+220
View File
@@ -0,0 +1,220 @@
// La campana al campone delle poste (idea di Andrea, 30 ottobre): disegnata col gesso sul vialetto, a turni col Taglia.
// Si tira il sassolino nella casella che tocca (dalla 1 alla 8): se cade dentro, senza toccare le righe, si salta tutta
// la campana fino in cima e indietro, su un piede nelle caselle sole e a gambe larghe nelle coppie, senza mai mettere
// il piede dove sta il sassolino; tornando, lo si raccoglie dalla casella prima. Chi sbaglia (sassolino fuori o sulla
// riga, salto fuori tempo) passa la mano; chi ce la fa va avanti alla casella dopo e ritira. Vince chi finisce la 8.
// Il tiro: la mira dà la direzione, tenere premuto carica la forza (va e torna), lasciare tira. I salti: a ogni salto
// la lancetta dell'equilibrio attraversa la barra; si preme quando passa nel verde. Unità: i pixel del primo piano
// 640×360, visto dall'alto (sfondo di Codex: assets/closeups/campone-campana.png).
export const W = 66, CY = 181, START = 80, CIELO = { x: START + 6 * W, r: W }, FROM = { x: 40, y: CY }, REACH = 520;
export const TAP = 0.14, CHARGE = 1.3, FLIGHT = 0.55, HOP = 0.22, REST = 1.6, DONE = 1.1, CPU_WAIT = 0.9, CPU_BEAT = 0.42;
// Le caselle: 1, 2, 3 da sole; 4 e 5 in coppia (la 4 a sinistra di chi salta, cioè in alto); la 6; 7 e 8 in coppia;
// in fondo la campana (il mezzo cerchio dove ci si gira; nel codice «cielo», come la chiamano in altri posti).
export const BOXES = {
1: { x: START, y: CY - W / 2 }, 2: { x: START + W, y: CY - W / 2 }, 3: { x: START + 2 * W, y: CY - W / 2 },
4: { x: START + 3 * W, y: CY - W }, 5: { x: START + 3 * W, y: CY }, 6: { x: START + 4 * W, y: CY - W / 2 },
7: { x: START + 5 * W, y: CY - W }, 8: { x: START + 5 * W, y: CY },
};
export const GROUPS = [[1], [2], [3], [4, 5], [6], [7, 8], ['cielo']];
export const LAST = 8;
// I livelli: quanto si sparpaglia il sassolino (px), se si vede dove cade, quanto è larga la riga che non si può
// toccare, quanto ci mette la lancetta dell'equilibrio e quanto è largo il verde; il Taglia: quante volte il sassolino
// va dove vuole e quanto spesso, a ogni salto, mette giù il piede.
export const LEVELS = {
bona: { scatter: 4, guide: true, line: 0, sweep: 1.25, zone: 0.32, cpu: { throw: 0.5, slip: 0.045 } },
seria: { scatter: 7, guide: false, line: 3, sweep: 1, zone: 0.2, cpu: { throw: 0.7, slip: 0.025 } },
male: { scatter: 9, guide: false, line: 4, sweep: 0.8, zone: 0.15, cpu: { throw: 0.8, slip: 0.018 } },
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
export function center(id) {
if (id === 'cielo') return { x: CIELO.x + W * 0.42, y: CY };
const b = BOXES[id]; return { x: b.x + W / 2, y: b.y + W / 2 };
}
// In che casella cade un punto, e se tocca la riga (entro `tol` pixel da un bordo).
export function boxAt([x, y], tol = 0) {
for (const [id, b] of Object.entries(BOXES)) {
if (x < b.x || x > b.x + W || y < b.y || y > b.y + W) continue;
return { id: Number(id), line: Math.min(x - b.x, b.x + W - x, y - b.y, b.y + W - y) < tol };
}
const d = Math.hypot(x - CIELO.x, y - CY);
if (x >= CIELO.x && d <= CIELO.r) return { id: 'cielo', line: Math.min(x - CIELO.x, CIELO.r - d) < tol };
return null;
}
// I salti per la casella `n`: all'andata tutte le caselle, saltando quella col sassolino (nella coppia si mette un
// piede solo nell'altra); il cielo; al ritorno, dalla casella prima si raccoglie il sassolino; poi fuori.
export function beats(n) {
const step = (g, back) => g.includes(n) ? (g.length > 1 ? [{ boxes: g.filter(k => k !== n), back }] : []) : [{ boxes: g, back }];
const out = GROUPS.flatMap(g => step(g, false));
for (let i = GROUPS.length - 2; i >= 0; i--) {
if (GROUPS[i].includes(n)) out.push({ ...out[out.length - 1], back: true, pick: true });
out.push(...step(GROUPS[i], true));
}
out.push({ boxes: [], back: true, out: true });
return out;
}
export function newCampana(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
return { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: { player: 1, cpu: 1 },
aim: center(1), power: 0.2, holding: false, holdTime: 0, charging: false, stone: null, shot: null,
beats: null, beat: -1, meter: null, hop: null, line: null, next: null, events: [], visual: { time: 0 } };
}
export const canThrow = c => c.current === 'player' && (c.step === 'aim' || c.step === 'hop');
export const canAdjust = c => c.current === 'player' && c.step === 'aim';
export const aimFree = c => !canAdjust(c);
// Dove cade il sassolino tirato con questa forza, senza lo sparpaglio.
export function landing(c, power = c.power) {
const dx = c.aim.x - FROM.x, dy = c.aim.y - FROM.y, d = Math.hypot(dx, dy) || 1;
return { x: FROM.x + dx / d * power * REACH, y: FROM.y + dy / d * power * REACH };
}
function toss(c, to) {
c.shot = { from: { ...FROM }, to, t: 0 }; c.stone = null;
c.step = 'flying'; c.line = null;
c.events.push({ kind: 'campana-lancio', power: 0.6 });
}
function throwStone(c) {
const p = landing(c), s = c.skill.scatter, spread = () => (random(c) + random(c) + random(c) - 1.5) * s * 1.4;
toss(c, { x: p.x + spread(), y: p.y + spread() });
return true;
}
const who = c => c.current === 'cpu' ? 'Il Taglia: ' : '';
const cap = line => line[0].toUpperCase() + line.slice(1);
// Il sassolino è arrivato: nella casella giusta si salta, se no passa la mano.
function land(c) {
const to = c.shot.to, n = c.target[c.current], hit = boxAt([to.x, to.y], c.skill.line);
c.shot = null; c.stone = to;
c.events.push({ kind: 'campana-sasso', power: 0.8 });
if (hit && hit.id === n && !hit.line) {
c.line = cap(`${who(c)}il sassolino è nella ${n}. Salta!`);
c.beats = beats(n); c.beat = -1; c.hop = null; c.t = 0;
c.step = c.current === 'cpu' ? 'cpuHop' : 'hop';
if (c.step === 'hop') c.meter = { t: -0.5, dir: 1 };
return;
}
fail(c, `${who(c)}${hit?.line ? 'sulla riga!' : hit ? `nella ${hit.id === 'cielo' ? 'campana in cima' : hit.id}, non nella ${n}.` : 'fuori dalla campana.'}`);
}
function fail(c, line) {
c.line = cap(line); c.step = 'rest'; c.t = 0; c.meter = null;
c.next = c.current === 'player' ? 'cpu' : 'player';
c.events.push({ kind: 'campana-riga', power: 0.7 });
}
// Un salto riuscito: la casella dopo, o il sassolino raccolto, o fuori dalla campana (e la casella è fatta).
function advance(c) {
c.beat++;
const b = c.beats[c.beat];
c.hop = { t: 0 };
if (b.pick) { c.stone = null; c.line = cap(`${who(c)}preso il sassolino!`); c.events.push({ kind: 'campana-sasso', power: 0.5 }); }
else c.events.push({ kind: 'campana-salto', power: 0.6 });
if (b.out) {
const n = c.target[c.current]++;
if (n >= LAST) {
c.step = 'result'; c.meter = null;
c.result = { winner: c.current, player: c.target.player - 1, cpu: c.target.cpu - 1 };
return;
}
c.line = cap(`${who(c)}fatta la ${n}! Ora la ${n + 1}.`); c.step = 'done'; c.t = 0; c.meter = null;
return;
}
if (c.meter) c.meter = { t: 0, dir: -c.meter.dir };
}
// La lancetta dell'equilibrio, da -1 a 1 (o al contrario, un salto sì e uno no).
export function needle(c) {
if (!c.meter) return 0;
return c.meter.dir * (-1 + 2 * clamp(c.meter.t, 0, c.skill.sweep) / c.skill.sweep);
}
function jump(c) {
if (c.hop && c.hop.t < HOP) return true; // ancora in aria: il tocco non conta
const u = needle(c);
if (Math.abs(u) <= c.skill.zone) { advance(c); return true; }
const early = u * c.meter.dir < 0;
fail(c, early ? 'Troppo presto: giù l\'altro piede!' : 'Troppo tardi: pestata la riga!');
return true;
}
// Tocca all'altro, o allo stesso per la casella dopo: dalla partenza, senza sassolino.
function turn(c, current) {
Object.assign(c, { current, stone: null, beats: null, beat: -1, hop: null, meter: null, t: 0, next: null,
step: current === 'player' ? 'aim' : 'cpuAim' });
if (current === 'player') c.aim = center(c.target.player);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
if (c.hop) c.hop.t += dt;
if (c.holding) {
c.holdTime += dt; c.charging = c.holdTime >= TAP;
const k = (c.holdTime / CHARGE) % 2; c.power = k <= 1 ? k : 2 - k;
}
if (c.step === 'flying') { c.shot.t += dt; if (c.shot.t >= FLIGHT) land(c); }
else if (c.step === 'hop') {
if (!c.hop || c.hop.t >= HOP) c.meter.t += dt;
if (c.meter.t > c.skill.sweep) fail(c, 'Hai perso l\'equilibrio!');
} else if (c.step === 'cpuAim' && c.t >= CPU_WAIT) {
// Il Taglia: o nella casella, ben dentro, o mezza casella più in là (sulla riga o nell'altra).
const n = c.target.cpu, p = center(n), ok = random(c) < c.skill.cpu.throw, j = () => (random(c) - 0.5) * 20;
toss(c, ok ? { x: p.x + j(), y: p.y + j() } : { x: p.x + (random(c) < 0.5 ? -1 : 1) * W * (0.5 + random(c) * 0.3), y: p.y + j() });
} else if (c.step === 'cpuHop' && c.t >= CPU_BEAT) {
c.t = 0;
if (random(c) < c.skill.cpu.slip) fail(c, random(c) < 0.5 ? 'Il Taglia ha pestato la riga!' : 'Il Taglia ha messo giù l\'altro piede!');
else advance(c);
} else if (c.step === 'rest' && c.t >= REST) turn(c, c.next);
else if (c.step === 'done' && c.t >= DONE) turn(c, c.current);
}
// --- Comandi: la mira segue il mouse o il dito; tenere premuto carica la forza, lasciare tira; ai salti si preme.
export function aimScreen(c, [x, y]) {
if (!canAdjust(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.aim = { x: clamp(x, FROM.x + 30, 630), y: clamp(y, 80, 285) }; return true;
}
export function setPower(c, v) { if (!canAdjust(c) || c.holding) return false; c.power = clamp(v, 0, 1); return true; }
export function nudge(c, dir) { if (!canAdjust(c) || c.holding) return false; c.aim.y = clamp(c.aim.y + dir * 3, 80, 285); return true; }
// Frecce: su e giù la mira, destra e sinistra la forza (più in là, più in qua).
export function key(c, key, down) {
const move = { ArrowUp: [0, -3], ArrowDown: [0, 3], ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0] }[key];
if (!move) return false;
if (down && canAdjust(c) && !c.holding) { c.aim.y = clamp(c.aim.y + move[1], 80, 285); c.power = clamp(c.power + move[0], 0, 1); }
return true;
}
export function press(c, pointerId) {
if (c.current !== 'player') return false;
if (c.step === 'hop') return jump(c);
if (!canAdjust(c) || c.holding) return false;
c.pointerId = pointerId; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 0;
return true;
}
export function cancel(c, pointerId) {
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
if (!c.holding) return false;
c.holding = c.charging = false; c.holdTime = 0; return true;
}
export function release(c, pointerId) {
if (c.pointerId !== pointerId || !c.holding) return false;
if (c.holdTime < TAP) return cancel(c, pointerId); // il tocco secco mira soltanto
c.holding = c.charging = false; c.holdTime = 0;
return throwStone(c);
}
// Il pulsante: tira con la forza del cursore, o salta.
export function shoot(c) {
if (c.current !== 'player' || c.holding) return false;
if (c.step === 'hop') return jump(c);
return canAdjust(c) && throwStone(c);
}
const box = n => n > LAST ? 'finito' : `alla ${n}`;
export function button(c) {
if (c.step === 'result') return 'Finita';
if (c.current === 'cpu') return 'Tocca al Taglia…';
return { aim: 'Tira', flying: 'In volo…', hop: 'Salta' }[c.step] ?? 'Aspetta…';
}
export function status(c) {
const score = `Andrea ${box(c.target.player)}, il Taglia ${box(c.target.cpu)}.`;
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Campana finita: battuto il Taglia! ' : 'Il Taglia ha finito la campana. '}${score}`;
const hint = c.current === 'cpu' ? 'Tocca al Taglia.' : c.step === 'hop' ? 'Salta quando la lancetta è nel verde.'
: `Tira il sassolino nella ${c.target.player}: mira, tieni premuto per la forza e lascia.`;
return `${score} ${c.line ?? hint}`;
}
+121
View File
@@ -0,0 +1,121 @@
// La macchina del tempo vista da fuori (story.closeups, type 'corsa'; Andrea, 30 ottobre). In partenza ('parti') va via
// giù per la strada accelerando, il fulmine la prende, il bianco, e restano due sgommate di fuoco che bruciano e si
// spengono. All'arrivo ('arriva') il fulmine in fondo alla strada, e lei viene avanti e ci passa accanto, lasciando le
// stesse strisce (davanti a noi: dietro alla macchina non si vedrebbero). Sopra lo sfondo della scena (`scene`), coi fotogrammi di Codex (`frames`: dietro, dietro coi lampi,
// davanti coi lampi). Unità: i pixel della scena 640×360.
// I tempi, in secondi. Partenza: ferma che si carica, la corsa, il fulmine, il bianco, le fiamme, la fine.
export const PARTI = { charge: 0.6, run: 2.2, bolt: 2.3, flash: 0.35, burn: 4.6, end: 5.2 };
// Arrivo: il fulmine in fondo, la corsa verso di noi fin fuori dal quadro, le fiamme, la fine.
export const ARRIVA = { bolt: 0.3, flash: 0.35, run: 1.5, burn: 4, end: 4.6 };
const lerp = (a, b, k) => a + (b - a) * k;
const clamp01 = k => Math.max(0, Math.min(1, k));
// Dove sta la macchina a un punto `k` del percorso (0 vicina, 1 in fondo): posizione delle ruote e grandezza.
export function along(def, k) {
const [a, b] = def.path;
return { x: lerp(a[0], b[0], k), y: lerp(a[1], b[1], k), scale: lerp(a[2], b[2], k) };
}
// Il punto del percorso al tempo t: in partenza accelera (da fermo); all'arrivo viene avanti dal fondo fin oltre il
// punto 0 (fuori dal quadro, accanto a noi).
export function progress(c, def) {
if (def.mode === 'arriva') return 1 - clamp01((c.t - ARRIVA.bolt) / ARRIVA.run);
const k = clamp01((c.t - PARTI.charge) / (PARTI.run - PARTI.charge));
return Math.min(0.92, k * k);
}
// Si vede la macchina? In partenza fino al fulmine; all'arrivo dal fulmine in poi.
export function carShown(c, def) {
return def.mode === 'arriva' ? c.t >= ARRIVA.bolt + 0.05 && c.t < ARRIVA.bolt + ARRIVA.run : c.t < PARTI.bolt + 0.05;
}
// Dove arriva il fulmine: sulla macchina in partenza, in fondo alla strada all'arrivo.
export function boltAt(def) { return def.mode === 'arriva' ? along(def, 1) : along(def, progress({ t: PARTI.bolt }, def)); }
const T = def => def.mode === 'arriva' ? ARRIVA : PARTI;
export function newCorsa(id) { return { id, t: 0, age: 0, events: [] }; }
// Il tempo che passa e i suoni; vero quando è finita.
export function updateCorsa(c, def, dt) {
c.t += dt;
const times = T(def);
if (def.mode !== 'arriva' && !c.charge && c.t >= 0.1) { c.charge = true; c.events.push({ kind: 'gara-turbo', power: 1 }); }
if (!c.boom && c.t >= times.bolt) { c.boom = true; c.events.push({ kind: 'corsa-tuono', power: 1 }); }
if (!c.fire && c.t >= (def.mode === 'arriva' ? ARRIVA.bolt + 0.2 : PARTI.charge + 0.3)) { c.fire = true; c.events.push({ kind: 'corsa-fuoco', power: 1 }); }
return c.t >= times.end;
}
// Le due ruote posteriori (o anteriori) a un punto del percorso: una a sinistra e una a destra.
function wheels(def, k) {
const p = along(def, k), half = def.track * p.scale;
return [[p.x - half, p.y], [p.x + half, p.y]];
}
// Le sgommate di fuoco: da dove è partita a dove è arrivata, fiammelle che tremano, più piccole in fondo, che si
// spengono dopo `burn`.
function fire(ctx, c, def) {
const times = T(def), shown = def.mode === 'arriva' ? [Math.max(progress(c, def), 0), 1] : [0, progress({ t: Math.min(c.t, times.bolt) }, def)];
const fade = c.t < times.burn ? 1 : clamp01(1 - (c.t - times.burn) / (times.end - times.burn));
if (fade <= 0 || shown[1] - shown[0] < 0.005) return;
for (let side = 0; side < 2; side++) {
// Prima il segno nero bruciato, poi le fiamme sopra.
const [a, b] = [wheels(def, shown[0])[side], wheels(def, shown[1])[side]];
ctx.strokeStyle = `rgba(30,18,10,${0.7 * Math.max(fade, 0.5)})`; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
const n = 70;
for (let i = 0; i <= n; i++) {
const k = lerp(shown[0], shown[1], i / n), [x, y] = wheels(def, k)[side], s = Math.max(0.12, along(def, k).scale);
// Lingue di fuoco di altezza diversa, che tremano: rosso fuori, arancio, il cuore giallo.
const flick = 0.55 + 0.45 * Math.sin(c.t * 19 + i * 2.3 + side * 2) * Math.sin(c.t * 7 + i * 0.9);
const h = (16 + 22 * flick) * s * fade, w = Math.max(2, 9 * s);
if (h < 1.5) continue;
ctx.fillStyle = `rgba(255,120,30,${0.18 * fade})`; ctx.fillRect(Math.round(x - w), Math.round(y - h * 1.2), Math.ceil(w * 2), Math.ceil(h * 1.2));
ctx.fillStyle = `rgba(220,50,20,${0.9 * fade})`; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), Math.ceil(w), Math.ceil(h));
ctx.fillStyle = `rgba(255,${Math.round(140 + 60 * flick)},30,${0.95 * fade})`; ctx.fillRect(Math.round(x - w / 3), Math.round(y - h * 0.7), Math.ceil(w * 0.66), Math.ceil(h * 0.7));
ctx.fillStyle = `rgba(255,245,170,${fade})`; ctx.fillRect(Math.round(x - w / 6), Math.round(y - h * 0.35), Math.max(1, Math.ceil(w / 3)), Math.ceil(h * 0.35));
// Un filo di fumo che sale quando le fiamme calano.
if (c.t > times.burn - 1 && i % 8 === 0) {
ctx.fillStyle = `rgba(90,90,96,${0.25 * fade})`;
ctx.fillRect(Math.round(x - 2 * s), Math.round(y - h - 6 * s - ((c.t * 18 + i) % 14) * s), Math.ceil(4 * s), Math.ceil(4 * s));
}
}
}
}
// Il fulmine: una saetta spezzata dal cielo fino al punto, con due rami.
function bolt(ctx, c, def) {
const times = T(def), dt = c.t - times.bolt;
if (dt < -0.12 || dt > 0.28) return;
const p = boltAt(def), seed = Math.floor(c.t * 30);
ctx.save();
for (const [width, color] of [[6, 'rgba(140,200,255,0.5)'], [2, '#ffffff']]) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath();
let x = p.x + 40, y = 0; ctx.moveTo(x, y);
const steps = 9;
for (let i = 1; i <= steps; i++) {
x = lerp(p.x + 40, p.x, i / steps) + (i < steps ? ((seed * (i + 5) * 53) % 30) - 15 : 0);
y = lerp(0, p.y - 12 * p.scale, i / steps); ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.restore();
}
export function drawCorsa(ctx, c, def, { bg, frames, tint }) {
const times = T(def);
ctx.save(); ctx.imageSmoothingEnabled = false;
// La strada trema un po' quando la macchina si carica e quando arriva il fulmine.
const shake = (def.mode !== 'arriva' && c.t < PARTI.charge + 0.4 ? 1.5 : 0) + (Math.abs(c.t - times.bolt) < 0.25 ? 3 : 0);
ctx.translate(Math.round(Math.sin(c.t * 57) * shake), Math.round(Math.cos(c.t * 43) * shake));
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4a4a50'; ctx.fillRect(0, 0, 640, 360); }
if (tint) { ctx.fillStyle = tint; ctx.fillRect(0, 0, 640, 360); }
fire(ctx, c, def);
if (carShown(c, def)) {
const k = progress(c, def), p = along(def, k);
// Da dietro: coi lampi mentre si carica e quando corre forte; davanti (all'arrivo) coi lampi finché frena.
const img = def.mode === 'arriva' ? frames?.davanti : c.t > PARTI.charge * 0.5 && Math.floor(c.t * 14) % 3 !== 0 ? frames?.lampi : frames?.dietro;
if (img) {
const w = def.width * p.scale, h = w * img.height / img.width;
ctx.drawImage(img, Math.round(p.x - w / 2), Math.round(p.y - h), Math.round(w), Math.round(h));
} else { ctx.fillStyle = '#c8ccd2'; ctx.fillRect(Math.round(p.x - 40 * p.scale), Math.round(p.y - 40 * p.scale), Math.round(80 * p.scale), Math.round(40 * p.scale)); }
}
bolt(ctx, c, def);
ctx.restore();
// Il bianco del fulmine, che sfuma.
const f = c.t - times.bolt;
if (f >= 0 && f < times.flash) { ctx.fillStyle = `rgba(255,255,255,${1 - f / times.flash})`; ctx.fillRect(0, 0, 640, 360); }
}
+56 -5
View File
@@ -12,7 +12,10 @@ import * as scopa from './scopa.js';
import * as gara from './gara.js'; import * as gara from './gara.js';
import * as micro from './micro.js'; import * as micro from './micro.js';
import * as cerbottana from './cerbottana.js'; import * as cerbottana from './cerbottana.js';
import { newCorsa, updateCorsa } from './corsa.js';
import { newIncanto, updateIncanto } from './incanto.js';
import * as condor from './condor.js'; import * as condor from './condor.js';
import * as campana from './campana.js';
import * as poker from './poker.js'; import * as poker from './poker.js';
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
@@ -28,6 +31,7 @@ export const MINIGAMES = {
poker: { create: (seed, level) => poker.newPoker(seed, { level }), update: poker.update, play: poker.play }, poker: { create: (seed, level) => poker.newPoker(seed, { level }), update: poker.update, play: poker.play },
cerbottana: { create: (seed, level) => cerbottana.newCerbottana(seed, { level }), update: cerbottana.update, press: cerbottana.press, release: cerbottana.release, cancel: cerbottana.cancel, aim: cerbottana.aimScreen }, cerbottana: { create: (seed, level) => cerbottana.newCerbottana(seed, { level }), update: cerbottana.update, press: cerbottana.press, release: cerbottana.release, cancel: cerbottana.cancel, aim: cerbottana.aimScreen },
condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen }, condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen },
campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.aimScreen },
micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen }, micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen },
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen }, gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
}; };
@@ -145,6 +149,9 @@ export function restoreState(story, data) {
return { scene: data.scene, x, y, facing: data.facing === -1 ? -1 : 1, inventory, flags: pick(data.flags), used: pick(data.used), ended: data.ended === true }; return { scene: data.scene, x, y, facing: data.facing === -1 ? -1 : 1, inventory, flags: pick(data.flags), used: pick(data.used), ended: data.ended === true };
} }
// Il manifesto ingrandito si apre con uno zoom: un tocco prima che sia aperto non conta.
export const POSTER_READY = 0.35;
export class Game { export class Game {
// voiceTime(who, text): secondi della voce registrata per la battuta, se c'è (il fumetto non finisce prima). // voiceTime(who, text): secondi della voce registrata per la battuta, se c'è (il fumetto non finisce prima).
constructor(story, { state, onSave, voiceTime } = {}) { constructor(story, { state, onSave, voiceTime } = {}) {
@@ -196,7 +203,13 @@ export class Game {
return { scene, x: Math.round(x), y: Math.round(y), facing, inventory: [...inventory], flags: { ...flags }, used: { ...used }, ended }; return { scene, x: Math.round(x), y: Math.round(y), facing, inventory: [...inventory], flags: { ...flags }, used: { ...used }, ended };
} }
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); } // In una zona accesa con `exitsOnly` (la festa dell'Ingorgo: Andrea, 30 ottobre) si clicca solo per andare da un
// posto all'altro: panchine, porte e statue restano dove sono, ma non rispondono.
hotspots() {
const list = (this.scene.hotspots ?? []).filter(h => check(h.when, this.state));
const busy = (this.story.zones ?? []).some(z => z.exitsOnly && this.state.flags[z.flag] && z.scenes.includes(this.state.scene));
return busy ? list.filter(h => h.exit) : list;
}
hotspot(id) { return this.hotspots().find(h => h.id === id); } hotspot(id) { return this.hotspots().find(h => h.id === id); }
people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); } people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); }
// Col mouse vale il punto preciso (l'ultimo della lista sta sopra). Col dito, `reach` allarga l'area: prima gli // Col mouse vale il punto preciso (l'ultimo della lista sta sopra). Col dito, `reach` allarga l'area: prima gli
@@ -292,6 +305,13 @@ export class Game {
closeupTap() { closeupTap() {
const c = this.closeup; const c = this.closeup;
if (!c || this.speech) return false; if (!c || this.speech) return false;
// Un manifesto ingrandito (type 'poster'): toccarlo lo chiude e fa partire `tap` (il desiderio dell'Ingorgo).
if (this.story.closeups[c.id].type === 'poster') {
if (c.t < POSTER_READY) return false;
this.closeup = null;
this.queue.unshift(...(this.story.closeups[c.id].tap ?? []));
return true;
}
if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 }); if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 }); else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
@@ -325,7 +345,13 @@ export class Game {
return true; return true;
} }
// Il viaggio si chiude da solo alla fine (o uscendo prima): si arriva comunque. // Il viaggio si chiude da solo alla fine (o uscendo prima): si arriva comunque.
if (def?.type === 'viaggio') { this.queue.unshift(...def.complete); return true; } if (def?.type === 'viaggio' || def?.type === 'corsa') { this.queue.unshift(...def.complete); return true; }
// L'incantesimo, chiuso prima del lampo, fa comunque il suo cambio.
if (def?.type === 'incanto') {
if (!c.swapped) for (const e of def.swap ?? []) this.run(e);
this.queue.unshift(...(def.complete ?? []));
return true;
}
// I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta // I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
// di chi ha vinto; uscendo prima, quella di chi lascia. // di chi ha vinto; uscendo prima, quella di chi lascia.
if (MINIGAMES[def?.type]) { if (MINIGAMES[def?.type]) {
@@ -427,15 +453,23 @@ export class Game {
const talk = p.talk ?? ['talk']; const talk = p.talk ?? ['talk'];
return talk[Math.floor(this.time / 0.3) % talk.length]; return talk[Math.floor(this.time / 0.3) % talk.length];
} }
// `flourish`: ogni tanto un gesto lungo a più fotogrammi (Baldoria che sventola il bandierone).
const f = p.flourish;
if (f) {
const ft = (this.time + (p.offset ?? 0)) % f.every;
if (ft < f.for) return f.poses[Math.floor(ft / (f.step ?? 0.35)) % f.poses.length];
}
const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every; const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every;
return p.blink && t > every - 0.25 ? p.blink : idle; return p.blink && t > every - 0.25 ? p.blink : idle;
} }
// La grandezza a quella altezza della scena; una fase può ingrandire tutti (`scale`: alla notte dell'Ingorgo la folla
// dipinta è grande, e Andrea ci si perdeva in mezzo).
scaleAt(y) { scaleAt(y) {
const d = this.scene.depth; const d = this.scene.depth, k = this.phase()?.scale ?? 1;
if (!d) return 1; if (!d) return k;
const [[y0, s0], [y1, s1]] = d, t = Math.max(0, Math.min(1, (y - y0) / (y1 - y0))); const [[y0, s0], [y1, s1]] = d, t = Math.max(0, Math.min(1, (y - y0) / (y1 - y0)));
return s0 + (s1 - s0) * t; return (s0 + (s1 - s0) * t) * k;
} }
speakerPoint(who) { speakerPoint(who) {
@@ -486,6 +520,13 @@ export class Game {
if (c) { if (c) {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; } if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
if (this.story.closeups[c.id].type === 'viaggio') { this.updateTrip(dt); return; } if (this.story.closeups[c.id].type === 'viaggio') { this.updateTrip(dt); return; }
if (this.story.closeups[c.id].type === 'corsa') { if (updateCorsa(c, this.story.closeups[c.id], dt)) this.closeupClose(); return; }
if (this.story.closeups[c.id].type === 'incanto') {
const now = updateIncanto(c, dt);
if (now === 'swap') for (const e of this.story.closeups[c.id].swap ?? []) this.run(e);
if (now === 'end') this.closeupClose();
return;
}
const m = this.minigame(); const m = this.minigame();
if (m) { if (m) {
m.update(c, dt); m.update(c, dt);
@@ -528,6 +569,9 @@ export class Game {
const type = this.story.closeups[e.closeup].type; const type = this.story.closeups[e.closeup].type;
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] } this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: type === 'viaggio' ? { id: e.closeup, t: 0, age: 0, events: [], typed: 0 } : type === 'viaggio' ? { id: e.closeup, t: 0, age: 0, events: [], typed: 0 }
: type === 'corsa' ? newCorsa(e.closeup)
: type === 'incanto' ? newIncanto(e.closeup)
: type === 'poster' ? { id: e.closeup, step: 'poster', t: 0, age: 0 }
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario. // `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane). // `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level, : MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
@@ -581,6 +625,13 @@ export class Game {
this.back = false; this.back = false;
this.state.flags['visto:' + scene] = true; // per la mappa this.state.flags['visto:' + scene] = true; // per la mappa
this.queue.unshift(...(this.scene.enter ?? [])); this.queue.unshift(...(this.scene.enter ?? []));
// Le zone (story.zones): un flag che vale solo in certi posti, come la festa dell'Ingorgo. Uscendo (a piedi o
// con la mappa) il flag si spegne e partono le battute del risveglio.
for (const z of this.story.zones ?? []) {
if (!this.state.flags[z.flag] || z.scenes.includes(scene)) continue;
delete this.state.flags[z.flag];
this.queue.unshift(...(z.leave ?? []));
}
this.dirty = true; // si salva quando l'ingresso nella scena è finito this.dirty = true; // si salva quando l'ingresso nella scena è finito
} }
} }
+83
View File
@@ -0,0 +1,83 @@
// L'incantesimo (story.closeups, type 'incanto'; Andrea, 30 ottobre: «Quanto darei per essere all'Ingorgo stasera?!?
// Effetto speciale e compare»). Sopra la scena di adesso, con Andrea dentro: i fasci delle luci da palco che girano
// sempre più svelti, i colpi di cassa che fanno pulsare l'aria, il lampo bianco; nel bianco cambia tutto (`swap`, di
// solito il flag della festa: lo sfondo diventa quello della notte), poi il bianco sfuma e piovono i coriandoli.
// Unità: i pixel della scena 640×360.
// I tempi, in secondi: i fasci, il lampo che sale, il cambio (a bianco pieno), il bianco che sfuma, la fine.
export const T = { beams: 0.2, flash: 1.9, swap: 2.2, clear: 3.0, end: 3.8 };
const COLORS = ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255'];
const clamp01 = k => Math.max(0, Math.min(1, k));
// Un numero a caso ma sempre lo stesso per lo stesso indice: i coriandoli non tremano da un fotogramma all'altro.
const hash = n => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
export function newIncanto(id) { return { id, t: 0, age: 0, events: [], beats: 0, swapped: false }; }
// Il tempo che passa e i suoni (un colpo di cassa a ogni battito, sempre più fitti; il lampo). Torna 'swap' nel
// momento del cambio, 'end' alla fine.
export function updateIncanto(c, dt) {
c.t += dt;
const beats = Math.floor(beatAt(Math.min(c.t, T.flash)));
for (; c.beats < beats; c.beats++) c.events.push({ kind: 'incanto-cassa', power: 0.5 + 0.5 * c.beats / 8 });
if (!c.boom && c.t >= T.flash) { c.boom = true; c.events.push({ kind: 'incanto-lampo', power: 1 }); }
if (!c.swapped && c.t >= T.swap) { c.swapped = true; return 'swap'; }
return c.t >= T.end ? 'end' : null;
}
// Quanti battiti sono passati: accelerano, da uno ogni mezzo secondo a uno ogni decimo.
export const beatAt = t => t <= 0 ? 0 : 2 * t + 1.6 * t * t;
// Quanto è bianco lo schermo: sale fino al cambio, resta pieno un attimo, poi sfuma.
export function whiteness(t) {
if (t < T.flash) return 0;
if (t < T.swap) return clamp01((t - T.flash) / (T.swap - T.flash));
return clamp01(1 - (t - T.swap) / (T.clear - T.swap));
}
export function drawIncanto(ctx, c) {
const t = c.t, before = t < T.swap;
ctx.save();
// Il colpo di cassa: un velo scuro che si apre e si chiude, l'aria che pulsa.
if (before) {
const b = beatAt(t), pulse = 1 - (b - Math.floor(b));
ctx.fillStyle = `rgba(10,0,30,${0.25 * clamp01(t / 0.6) * pulse})`;
ctx.fillRect(0, 0, 640, 360);
}
// I fasci: partono da sotto, ai lati, e spazzano il cielo; più passa il tempo, più sono e più girano svelti.
const strength = before ? clamp01((t - T.beams) / 0.5) : clamp01(1 - (t - T.swap) / 0.6);
if (strength > 0) {
ctx.globalCompositeOperation = 'lighter';
const n = 2 + Math.min(6, Math.floor(t * 3));
for (let i = 0; i < n; i++) {
const from = [i % 2 ? 560 : 80, 372], speed = 0.9 + i * 0.35 + t * 0.9;
const a = -Math.PI / 2 + Math.sin(t * speed + i * 1.7) * 0.9, len = 520, half = 0.07;
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.22 * strength})`;
ctx.beginPath();
ctx.moveTo(...from);
ctx.lineTo(from[0] + Math.cos(a - half) * len, from[1] + Math.sin(a - half) * len);
ctx.lineTo(from[0] + Math.cos(a + half) * len, from[1] + Math.sin(a + half) * len);
ctx.closePath();
ctx.fill();
}
// Le scintille che salgono da terra, a quadratini.
for (let i = 0; i < 40; i++) {
const life = (t * (0.5 + hash(i)) + hash(i + 50)) % 1, x = 20 + hash(i + 100) * 600, y = 360 - life * 300;
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.9 * strength * (1 - life)})`;
ctx.fillRect(Math.round(x), Math.round(y), 2, 2);
}
ctx.globalCompositeOperation = 'source-over';
}
// Il lampo.
const white = whiteness(t);
if (white > 0) { ctx.fillStyle = `rgba(255,255,250,${white})`; ctx.fillRect(0, 0, 640, 360); }
// Dopo il cambio, i coriandoli che cadono sulla festa.
if (!before) {
const k = (t - T.swap) / (T.end - T.swap);
for (let i = 0; i < 70; i++) {
const x = (hash(i) * 640 + Math.sin(t * 3 + i) * 8) % 640, y = -10 + (hash(i + 7) * 120 + k * 420 * (0.6 + hash(i + 3) * 0.6));
if (y > 360) continue;
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${clamp01(1.3 - k)})`;
ctx.fillRect(Math.round(x), Math.round(y), 3, 2);
}
}
ctx.restore();
}
+44 -21
View File
@@ -9,22 +9,39 @@ import { versioned } from '../version.js';
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo); // Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
// altrimenti quello generale del paese. // altrimenti quello generale del paese.
// Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto. // Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto.
export function trackFor(story, { screen, scene, phase, closeup }) { // Una scena con le console accese (`stages`, ognuna col suo `when`) non ha un brano solo ma una miscela: ogni console
// suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro:
// è la musica della piazza accanto, che arriva da lontano.
// `ok(cond)` dice se una condizione della storia vale adesso (game.js, check).
export function trackFor(story, { screen, scene, phase, closeup, at, ok = () => false }) {
const m = story.music ?? {}; const m = story.music ?? {};
if (screen === 'splash') return null; if (screen === 'splash') return null;
if (screen === 'title' || screen === 'intro') return m.title ?? null; if (screen === 'title' || screen === 'intro') return m.title ?? null;
if (screen === 'ending') return m.ending ?? m.game ?? null; if (screen === 'ending') return m.ending ?? m.game ?? null;
if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music; if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music;
const sc = story.scenes[scene]; const sc = story.scenes[scene];
const stages = (sc?.stages ?? []).filter(st => ok(st.when));
if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]);
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null; return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
} }
// Il volume di ogni console: pieno fino a `near` pixel, poi cala fino a `far` (non sotto un filo, `floor`: la festa
// si sente sempre). La più forte per prima.
export function mix(stages, [x, y]) {
return stages.map(st => {
const d = Math.hypot(x - st.at[0], (y - st.at[1]) * 1.5), near = st.near ?? 90, far = st.far ?? 520;
const k = Math.max(0, Math.min(1, (d - near) / (far - near)));
return { src: st.music, gain: Math.max(st.floor ?? 0.08, (1 - k) ** 1.6) };
}).sort((a, b) => b.gain - a.gain);
}
const FADE = 1.2; const FADE = 1.2;
// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta. // Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta. Lì, nella
function setLevel(a) { // miscela delle console, suona solo la più forte (`alone`): le altre a volume pieno coprirebbero tutto.
function setLevel(a, alone = false) {
a.volume = a.level; a.volume = a.level;
a.muted = a.level === 0; a.muted = a.level === 0 || (alone && a.level < 1 && Math.abs(a.volume - a.level) > 0.01);
} }
export class Music { export class Music {
@@ -33,40 +50,46 @@ export class Music {
this.volume = volume; this.volume = volume;
this.blocked = null; this.blocked = null;
this.want = null; this.want = null;
this.current = null; // { src, audio } this.tracks = new Map(); // src -> { src, audio, gain }: quelli voluti e quelli che sfumano
this.old = []; this.wanted = []; // i src voluti adesso, il più forte per primo
this.duck = 1; // 1 = pieno; meno di 1 mentre parla qualcuno this.duck = 1; // 1 = pieno; meno di 1 mentre parla qualcuno
this.hold = false; // in pausa: il brano sfuma e si ferma, poi riprende da dov'era this.hold = false; // in pausa: il brano sfuma e si ferma, poi riprende da dov'era
this.away = false; // pagina nascosta (sul telefono si è passati a un'altra app): tutto fermo subito this.away = false; // pagina nascosta (sul telefono si è passati a un'altra app): tutto fermo subito
} }
// Il brano principale (il più forte) e quelli che stanno sfumando.
get current() { return this.tracks.get(this.wanted[0]) ?? null; }
get old() { return [...this.tracks.values()].filter(t => !this.wanted.includes(t.src)).map(t => t.audio); }
// Nascosta la pagina il browser non chiama più update (niente fotogrammi): i brani si fermano qui, di colpo. // Nascosta la pagina il browser non chiama più update (niente fotogrammi): i brani si fermano qui, di colpo.
// Tornati, update li fa ripartire da dov'erano (se non c'è la pausa). // Tornati, update li fa ripartire da dov'erano (se non c'è la pausa).
setAway(away) { setAway(away) {
this.away = away; this.away = away;
if (away) for (const a of [this.current?.audio, ...this.old]) a?.pause(); if (away) for (const t of this.tracks.values()) t.audio.pause();
} }
// Chiamata a ogni fotogramma con il brano voluto: avvia, cambia e sfuma. // Chiamata a ogni fotogramma con il brano voluto (o la miscela delle console, [{ src, gain }]): avvia, cambia e sfuma.
update(src, dt) { update(want, dt) {
this.want = src; this.want = want;
if (src !== this.current?.src) { const list = want == null ? [] : typeof want === 'string' ? [{ src: want, gain: 1 }] : want;
if (this.current) this.old.push(this.current.audio); this.wanted = list.map(w => w.src);
this.current = src ? { src, audio: this.start(src) } : null; for (const w of list) {
const t = this.tracks.get(w.src) ?? { src: w.src, audio: this.start(w.src) };
t.gain = w.gain;
this.tracks.set(w.src, t);
} }
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero, e lì si fermano. // Ogni brano voluto sale (o scende) verso il suo volume; quelli lasciati sfumano fino a zero, e lì si fermano.
// Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e // Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e
// resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese). // resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese).
const step = dt / FADE, target = this.muted || this.hold ? 0 : this.volume * this.duck; const step = dt / FADE, full = this.muted || this.hold ? 0 : this.volume * this.duck;
if (this.current) { for (const t of [...this.tracks.values()]) {
const a = this.current.audio; const a = t.audio, on = this.wanted.includes(t.src), target = on ? full * t.gain : 0;
a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step); a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step);
setLevel(a); setLevel(a, list.length > 1 && t.src !== this.wanted[0]);
if (!on) { if (a.level === 0) { a.pause(); this.tracks.delete(t.src); } continue; }
if (this.hold && a.level === 0 && !a.paused) a.pause(); if (this.hold && a.level === 0 && !a.paused) a.pause();
else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; }); else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; });
} }
for (const a of this.old) { a.level = Math.max(0, a.level - step); setLevel(a); }
this.old = this.old.filter(a => a.level > 0 || (a.pause(), false));
} }
start(src) { start(src) {
@@ -81,7 +104,7 @@ export class Music {
unlock() { unlock() {
const a = this.blocked; const a = this.blocked;
this.blocked = null; this.blocked = null;
if (a && a === this.current?.audio) a.play().catch(() => {}); if (a && [...this.tracks.values()].some(t => t.audio === a && this.wanted.includes(t.src))) a.play().catch(() => {});
} }
toggle() { toggle() {
+147 -2
View File
@@ -1,6 +1,6 @@
// 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, PAGE_TURN } from './game.js'; import { check, PAGE_TURN, POSTER_READY } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js'; import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js'; import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js'; import { drawBocce } from './bocce-render.js';
@@ -11,9 +11,12 @@ import { drawPesca } from './pesca-render.js';
import { drawGara } from './gara-render.js'; import { drawGara } from './gara-render.js';
import { drawMicro } from './micro-render.js'; import { drawMicro } from './micro-render.js';
import { drawCerbottana } from './cerbottana-render.js'; import { drawCerbottana } from './cerbottana-render.js';
import { drawCorsa } from './corsa.js';
import { drawCondor } from './condor-render.js'; import { drawCondor } from './condor-render.js';
import { drawCampana } from './campana-render.js';
import { drawPoker } from './poker-render.js'; import { drawPoker } from './poker-render.js';
import { drawViaggio } from './viaggio.js'; import { drawViaggio } from './viaggio.js';
import { drawIncanto } from './incanto.js';
import { drawBriscola } from './briscola-render.js'; import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js'; import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js'; import { drawScopa } from './scopa-render.js';
@@ -129,13 +132,15 @@ export async function loadAssets(story, first = story.start.scene, { together =
} }
// Gli sfondi in ordine di distanza dalla scena di partenza, seguendo le uscite; in fondo quelle non raggiungibili. // Gli sfondi in ordine di distanza dalla scena di partenza, seguendo le uscite; in fondo quelle non raggiungibili.
// Prima tutti gli sfondi veri, poi le varianti (`backdrops`, come la notte dell'Ingorgo): quando servono prima, la
// scena se le carica da sé.
export function backgroundOrder(story, first) { export function backgroundOrder(story, first) {
const seen = new Set([first]), queue = [first]; const seen = new Set([first]), queue = [first];
for (let i = 0; i < queue.length; i++) for (let i = 0; i < queue.length; i++)
for (const h of story.scenes[queue[i]]?.hotspots ?? []) for (const h of story.scenes[queue[i]]?.hotspots ?? [])
if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); } if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); }
for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id); for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id);
return [...new Set(queue.flatMap(id => [story.scenes[id].background, ...(story.scenes[id].backdrops ?? []).map(b => b.src)]).filter(Boolean))]; return [...new Set([...queue.map(id => story.scenes[id].background), ...queue.flatMap(id => (story.scenes[id].backdrops ?? []).map(b => b.src))].filter(Boolean))];
} }
// Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia. // Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia.
@@ -482,6 +487,108 @@ export const EFFECTS = {
ctx.fill(); ctx.fill();
ctx.restore(); ctx.restore();
}, },
// Le luci da palco dell'Ingorgo (Andrea, 30 ottobre): dalle teste mobili (`from`) i fasci colorati che spazzano il
// cielo; sul palco un bagliore che pulsa a tempo (`bpm`) e cambia colore a ogni battuta; ogni tanto lo strobo.
stagelights(ctx, e, time) {
const colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40'], beat = time / (60000 / (e.bpm ?? 124));
const hit = 1 - (beat % 1), bar = Math.floor(beat / 4);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
if (e.wash) {
const [x, y, r] = e.wash, c = colors[bar % colors.length];
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${c},${0.16 + 0.3 * hit ** 2})`);
g.addColorStop(1, `rgba(${c},0)`);
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
for (const [k, [x, y]] of (e.from ?? []).entries()) {
for (let i = 0; i < (e.beams ?? 3); i++) {
const n = k * 3 + i, swing = Math.sin(time / (700 + n * 150) + n * 1.9) * (e.spread ?? 0.85);
const a = -Math.PI / 2 + swing + (k % 2 ? 0.25 : -0.25), len = e.len ?? 340, half = 0.055;
const c = colors[(n + bar) % colors.length], tipX = x + Math.cos(a) * len, tipY = y + Math.sin(a) * len;
const g = ctx.createLinearGradient(x, y, tipX, tipY);
g.addColorStop(0, `rgba(${c},${0.38 + 0.2 * hit})`);
g.addColorStop(1, `rgba(${c},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(a - half) * len, y + Math.sin(a - half) * len);
ctx.lineTo(x + Math.cos(a + half) * len, y + Math.sin(a + half) * len);
ctx.closePath();
ctx.fill();
ctx.fillStyle = `rgba(${c},0.9)`;
ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 3, 3);
}
}
// Il colpo di colore su tutta la scena a ogni battuta (`pulse`: quanto forte).
if (e.pulse) {
ctx.fillStyle = `rgba(${colors[Math.floor(beat) % colors.length]},${e.pulse * hit ** 3})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
ctx.restore();
// Lo strobo: un lampo bianco brevissimo ogni `strobe` battute, a raffica nell'ultima battuta di ogni frase.
const roll = Math.floor(beat) % 16 === 15 && (beat * 4) % 1 < 0.3;
if (e.strobe && ((Math.floor(beat) % e.strobe === 0 && beat % 1 < 0.12) || roll)) {
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
},
// Le macchie di luce che corrono sulla folla (`area`), colorate, a tempo: la pista da ballo.
floorspots(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255'];
const beat = time / (60000 / (e.bpm ?? 124)), hit = 1 - (beat % 1);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < (e.count ?? 6); i++) {
const u = 0.5 + 0.5 * Math.sin(time / (1300 + i * 230) + i * 2.1), v = 0.5 + 0.5 * Math.sin(time / (1700 + i * 190) + i * 1.3);
const x = ax + u * aw, y = ay + v * ah, r = (e.size ?? 34) * (0.7 + 0.5 * v);
const c = colors[(i + Math.floor(beat / 2)) % colors.length];
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${c},${0.22 + 0.2 * hit})`);
g.addColorStop(1, `rgba(${c},0)`);
ctx.fillStyle = g;
ctx.save();
ctx.translate(x, y); ctx.scale(1, 0.45); ctx.translate(-x, -y);
ctx.fillRect(x - r, y - r, r * 2, r * 2);
ctx.restore();
}
ctx.restore();
},
// I laser della tecno: righe sottili verdi (o del colore dato) che si aprono a ventaglio e si richiudono.
lasers(ctx, e, time) {
const [x, y] = e.at, n = e.count ?? 7, open = 0.35 + 0.35 * Math.sin(time / 1300), tilt = Math.sin(time / 2100) * 0.4;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(${e.color ?? '90,255,110'},${0.45 + 0.25 * Math.sin(time / 90) ** 2})`;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + tilt + (i / (n - 1) - 0.5) * 2 * open * 1.4;
ctx.moveTo(x + 0.5, y + 0.5);
ctx.lineTo(x + Math.cos(a) * (e.len ?? 400), y + Math.sin(a) * (e.len ?? 400));
}
ctx.stroke();
ctx.restore();
},
// La palla a specchi: puntini di luce che girano sui muri intorno (`area`), più chiari vicino alla palla.
mirrorball(ctx, e, time) {
const [bx, by] = e.at, [ax, ay, aw, ah] = e.area, turn = time / 4200;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < (e.count ?? 40); i++) {
const u = (hash(i * 13) + turn) % 1, x = ax + Math.round(u * aw), y = ay + Math.round(hash(i * 7 + 3) * ah);
const glint = 0.5 + 0.5 * Math.sin(time / 160 + i * 2.3);
ctx.fillStyle = `rgba(${e.color ?? '220,200,255'},${0.35 + 0.5 * glint})`;
ctx.fillRect(x, y, 2, 2);
}
const g = ctx.createRadialGradient(bx, by, 0, bx, by, 9);
g.addColorStop(0, 'rgba(255,255,255,0.8)');
g.addColorStop(1, 'rgba(200,180,255,0)');
ctx.fillStyle = g;
ctx.fillRect(bx - 9, by - 9, 18, 18);
ctx.restore();
},
// Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`). // Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`).
lamps(ctx, e, time) { lamps(ctx, e, time) {
ctx.save(); ctx.save();
@@ -686,6 +793,39 @@ function drawCloseup(ctx, game, time) {
drawViaggio(ctx, c, def, backgrounds.get(def.picture)); drawViaggio(ctx, c, def, backgrounds.get(def.picture));
return; return;
} }
// La macchina del tempo da fuori: lo sfondo di adesso della scena, con la luce della fase del giorno.
if (def.type === 'corsa') {
const bg = game.backdrop(game.story.scenes[def.scene]), frames = {};
for (const [k, src] of [[bg, bg], ...Object.entries(def.frames)]) {
if (!backgrounds.has(src)) loadBackground(src);
if (k !== bg) frames[k] = backgrounds.get(src);
}
drawCorsa(ctx, c, def, { bg: backgrounds.get(bg), frames, tint: game.phase()?.tint });
return;
}
// L'incantesimo va sopra la scena (con Andrea dentro), già disegnata.
if (def.type === 'incanto') { drawIncanto(ctx, c); return; }
// Il manifesto ingrandito: la scena si scurisce e lui viene avanti, poi l'invito a toccarlo.
if (def.type === 'poster') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
const img = backgrounds.get(def.picture), k = Math.min(1, c.t / POSTER_READY), ease = 1 - (1 - k) ** 3;
ctx.fillStyle = `rgba(8, 4, 16, ${0.75 * ease})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
if (img) {
const fit = Math.min(600 / img.width, 300 / img.height) * (0.55 + 0.45 * ease), w = img.width * fit, h = img.height * fit;
ctx.globalAlpha = ease;
ctx.drawImage(img, Math.round((WIDTH - w) / 2), Math.round((HEIGHT - 26 - h) / 2), Math.round(w), Math.round(h));
ctx.globalAlpha = 1;
}
if (k >= 1 && Math.floor(time / 600) % 2 === 0) {
ctx.font = '8px "Press Start 2P", monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#ffe9b0';
ctx.fillText(def.hint ?? 'Tocca il manifesto', WIDTH / 2, HEIGHT - 12);
ctx.textAlign = 'left';
}
return;
}
if (def.type === 'torch') { if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture)); drawTorch(ctx, c, def, backgrounds.get(def.picture));
@@ -707,6 +847,11 @@ function drawCloseup(ctx, game, time) {
drawCondor(ctx, c, def, backgrounds.get(def.picture)); drawCondor(ctx, c, def, backgrounds.get(def.picture));
return; return;
} }
if (def.type === 'campana') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawCampana(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'micro') { if (def.type === 'micro') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawMicro(ctx, c, def, backgrounds.get(def.picture), src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); }); drawMicro(ctx, c, def, backgrounds.get(def.picture), src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); });
+11
View File
@@ -145,6 +145,17 @@ export class Soundscape {
else if (kind === 'condor-palloncino') { this.noiseSound(0.12, 1800, 0.14, { pan }); this.tone(140, 60, 0.08, 0.08, 0, pan); } else if (kind === 'condor-palloncino') { this.noiseSound(0.12, 1800, 0.14, { pan }); this.tone(140, 60, 0.08, 0.08, 0, pan); }
else if (kind === 'condor-plastica') { this.tone(1300, 900, 0.06, 0.05, 0, pan); this.noiseSound(0.05, 2600, 0.03, { pan }); } else if (kind === 'condor-plastica') { this.tone(1300, 900, 0.06, 0.05, 0, pan); this.noiseSound(0.05, 2600, 0.03, { pan }); }
else if (kind === 'condor-lancio') this.noiseSound(0.2, 700, 0.03, { pan }); else if (kind === 'condor-lancio') this.noiseSound(0.2, 700, 0.03, { pan });
// La macchina del tempo: il tuono del fulmine e lo sfrigolio delle sgommate di fuoco.
else if (kind === 'corsa-tuono') { this.noiseSound(0.9, 400, 0.16, { pan }); this.tone(90, 40, 0.8, 0.1, 0, pan); }
else if (kind === 'corsa-fuoco') this.noiseSound(1.6, 2400, 0.05, { pan });
// L'incantesimo dell'Ingorgo: i colpi di cassa che accelerano, poi il lampo con lo spazzolone del piatto.
else if (kind === 'incanto-cassa') this.tone(150, 45, 0.22, 0.12 + strength * 0.1, 0, pan);
else if (kind === 'incanto-lampo') { this.noiseSound(1.4, 5200, 0.12, { pan }); this.tone(60, 35, 1.2, 0.16, 0, pan); this.tone(880, 1760, 0.6, 0.05, 0.05, pan); }
// La campana: il sassolino che vola e batte sull'asfalto, il salto, il piede giù.
else if (kind === 'campana-lancio') this.noiseSound(0.1, 1500, 0.025, { pan });
else if (kind === 'campana-sasso') { this.tone(1900, 1100, 0.04, 0.05 * strength + 0.02, 0, pan); this.tone(1500, 900, 0.03, 0.03, 0.06, pan); this.noiseSound(0.04, 3000, 0.03, { pan }); }
else if (kind === 'campana-salto') { this.tone(110, 60, 0.07, 0.07, 0, pan); this.noiseSound(0.05, 500, 0.04, { pan }); }
else if (kind === 'campana-riga') { this.tone(300, 180, 0.12, 0.05, 0, pan); this.tone(220, 120, 0.16, 0.05, 0.1, pan); }
else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); }
// Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo. // Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo.
else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); } else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); }
+10 -6
View File
@@ -1,6 +1,6 @@
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti). // Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
import { story } from './story/story.js'; import { story } from './story/story.js';
import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js'; import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Command } from './engine/command.js'; import { Command } from './engine/command.js';
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js'; import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
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';
@@ -14,6 +14,7 @@ import * as rinquarto from './engine/rinquarto.js';
import * as micro from './engine/micro.js'; import * as micro from './engine/micro.js';
import * as cerbottana from './engine/cerbottana.js'; import * as cerbottana from './engine/cerbottana.js';
import * as condor from './engine/condor.js'; import * as condor from './engine/condor.js';
import * as campana from './engine/campana.js';
import * as poker from './engine/poker.js'; import * as poker from './engine/poker.js';
import { cardAt as pokerAt } from './engine/poker-render.js'; import { cardAt as pokerAt } from './engine/poker-render.js';
import * as basket from './engine/basket.js'; import * as basket from './engine/basket.js';
@@ -587,7 +588,7 @@ canvas.addEventListener('pointermove', e => {
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId); if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
return; return;
} }
const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type); const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana', 'condor', 'campana'].includes(story.closeups[game.closeup.id].type);
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); } if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e)); else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
}); });
@@ -611,6 +612,7 @@ const MINI = {
poker: { ...poker, back: 'Torna al Barrettino', label: 'Il pokerino: tocca le carte da cambiare, poi Cambia; nei giri di puntate Lascio, Vedo o Parola, Apro o Rilancio. Dopo la mostra un tocco va avanti.' }, poker: { ...poker, back: 'Torna al Barrettino', label: 'Il pokerino: tocca le carte da cambiare, poi Cambia; nei giri di puntate Lascio, Vedo o Parola, Apro o Rilancio. Dopo la mostra un tocco va avanti.' },
cerbottana: { ...cerbottana, back: 'Torna in piazza', label: 'La cerbottana: muovi il mouse o tocca il muro per mirare, tieni premuto per prendere fiato e lascia per sparare; oppure regola il fiato e premi Spara. Frecce per spostare il mirino.' }, cerbottana: { ...cerbottana, back: 'Torna in piazza', label: 'La cerbottana: muovi il mouse o tocca il muro per mirare, tieni premuto per prendere fiato e lascia per sparare; oppure regola il fiato e premi Spara. Frecce per spostare il mirino.' },
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' }, condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' }, micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' }, basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto). // La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
@@ -1040,7 +1042,7 @@ function updateUi() {
} }
} }
if (mg && !cards) { if (mg && !cards) {
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor' }[def.type] ?? 'Comandi delle bocce'); setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana' }[def.type] ?? 'Comandi delle bocce');
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true); setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = mg.status(c, def.names); const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
@@ -1055,7 +1057,7 @@ function updateUi() {
// usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe. // usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
const easy = def.type === 'gara' && gara.simple(c); const easy = def.type === 'gara' && gara.simple(c);
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || easy); setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || easy);
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy); for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana');
const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza '; const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza ';
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName; if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio'); setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
@@ -1076,6 +1078,7 @@ function updateUi() {
setProp($('micro-help'), 'hidden', def.type !== 'micro'); setProp($('micro-help'), 'hidden', def.type !== 'micro');
setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana'); setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana');
setProp($('condor-help'), 'hidden', def.type !== 'condor'); setProp($('condor-help'), 'hidden', def.type !== 'condor');
setProp($('campana-help'), 'hidden', def.type !== 'campana');
setProp($('basket-help'), 'hidden', def.type !== 'basket'); setProp($('basket-help'), 'hidden', def.type !== 'basket');
setProp($('pesca-help'), 'hidden', def.type !== 'pesca'); setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
setProp($('gara-help'), 'hidden', def.type !== 'gara'); setProp($('gara-help'), 'hidden', def.type !== 'gara');
@@ -1096,7 +1099,7 @@ function updateUi() {
canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg); canvas.classList.toggle('bocce', !!mg);
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli). // Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor'].includes(story.closeups[c.id].type)); $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards); $('stage').classList.toggle('briscola', !!cards);
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura"); setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio. // La pagina che comincia a girare fa il suo fruscio.
@@ -1124,7 +1127,8 @@ function frame(now) {
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa. // Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
const searching = game?.closeup?.id === 'fessura'; 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.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt); music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id,
at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), dt);
// Un minuto senza toccare niente, a gioco fermo: pausa da sola. // 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.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 });
+1 -1
View File
@@ -241,7 +241,7 @@ export const ambience = {
{ type: 'insect', kind: 'dragonfly', phase: day, area: [440, 228, 110, 30], every: 31000, duration: 8500, offset: 15000 }, { type: 'insect', kind: 'dragonfly', phase: day, area: [440, 228, 110, 30], every: 31000, duration: 8500, offset: 15000 },
], ],
orti_zio_mario: [ orti_zio_mario: [
{ type: 'clouds', area: [80, 20, 500, 90], count: 3, big: true }, { type: 'clouds', area: [60, 20, 330, 70], count: 3, big: true }, // solo fino all'abete: dentro le fronde che ondeggiano le nuvole si tagliano
{ type: 'birds', phase: day, area: [300, 30, 250, 60], count: 3, every: 24000, duration: 5000, offset: 2000 }, { type: 'birds', phase: day, area: [300, 30, 250, 60], count: 3, every: 24000, duration: 5000, offset: 2000 },
{ type: 'sway', rects: [[180, 10, 140, 180], [430, 10, 210, 240]], amount: 2 }, { type: 'sway', rects: [[180, 10, 140, 180], [430, 10, 210, 240]], amount: 2 },
butterfly([10, 240, 140, 50], 8000), butterfly([10, 240, 140, 50], 8000),
+198 -6
View File
@@ -43,11 +43,19 @@ const torchHints = [{
}], }],
}]; }];
// La macchina del tempo che corre, vista da dietro e (all'arrivo) davanti: larga `width` pixel da vicino, le ruote a
// `track` della larghezza dal centro.
const MACCHINA_CORSA = {
frames: { dietro: 'assets/closeups/macchina-dietro.png', lampi: 'assets/closeups/macchina-lampi.png', davanti: 'assets/closeups/macchina-davanti.png' },
width: 220, track: 75,
};
// Il ritorno dal 1997, dalla porta o dal pulsante «Torna al 2007»: la macchina del tempo sparisce, e per rifare il // Il ritorno dal 1997, dalla porta o dal pulsante «Torna al 2007»: la macchina del tempo sparisce, e per rifare il
// viaggio si richiede all'Arcu (Andrea, 29 ottobre). // viaggio si richiede all'Arcu (Andrea, 29 ottobre).
const RITORNO_2007 = [ const RITORNO_2007 = [
{ say: 'Si torna nel 2007. Ciao Vannino, ciao Pierino.' }, { say: 'Si torna nel 2007. Ciao Vannino, ciao Pierino.' },
{ go: 'via_liguri_magelli', at: [212, 280], face: 'down' }, { go: 'via_liguri_magelli', at: [212, 280], face: 'down' },
{ closeup: 'arrivo' },
{ unset: 'macchina_trappola' }, { unset: 'macchina_trappola' },
{ say: 'Riportata dove l\'ho presa. Se ti riviene voglia di pokerini, chiedi.', who: 'arcu', pose: 'explain' }, { say: 'Riportata dove l\'ho presa. Se ti riviene voglia di pokerini, chiedi.', who: 'arcu', pose: 'explain' },
]; ];
@@ -64,6 +72,22 @@ export const story = {
{ id: 'pomeriggio' }, { id: 'pomeriggio' },
{ id: 'tramonto', when: 'indizio_regolo', tint: 'rgba(255, 110, 40, 0.16)' }, { id: 'tramonto', when: 'indizio_regolo', tint: 'rgba(255, 110, 40, 0.16)' },
{ id: 'sera', when: 'casetta_aperta', tint: 'rgba(24, 28, 90, 0.42)', music: 'assets/audio/sera.mp3' }, { id: 'sera', when: 'casetta_aperta', tint: 'rgba(24, 28, 90, 0.42)', music: 'assets/audio/sera.mp3' },
// La notte dell'Ingorgo Sonoro (Andrea, 30 ottobre): gli sfondi sono già di notte, il velo serve solo ad Andrea.
// La musica la fanno le console delle scene (`stages`).
{ id: 'ingorgo', when: 'ingorgo', tint: 'rgba(40, 20, 90, 0.16)', scale: 1.6 },
],
// Le zone: un flag che vale solo in certi posti. L'Ingorgo Sonoro (Andrea, 30 ottobre: «era la festa del paese
// fatta una delle prime sere di luglio, da piazza fino a Cambray Digny, compresa piazza del Comune») si accende
// col manifesto in piazza e si spegne uscendo dal centro. [DA ANDREA: le battute sono provvisorie]
zones: [
{
flag: 'ingorgo', scenes: ['piazza', 'via_antica_posta', 'piazzetta_comune', 'antica_posta_alta', 'piazzetta'],
exitsOnly: true, // alla festa si clicca solo per spostarsi (Andrea)
leave: [
{ say: 'Mi stropiccio gli occhi. La musica non c\'è più: è di nuovo maggio.' },
{ say: 'E il palio è sempre sparito. Però che serata.' },
],
},
], ],
// Il taccuino (pulsante «Taccuino», tasto N): le tappe del capitolo in ordine. Una tappa è fatta quando la sua // Il taccuino (pulsante «Taccuino», tasto N): le tappe del capitolo in ordine. Una tappa è fatta quando la sua
// condizione `done` è vera; la prima non fatta è l'obiettivo di adesso. Solo testo: non si sente a voce. // condizione `done` è vera; la prima non fatta è l'obiettivo di adesso. Solo testo: non si sente a voce.
@@ -249,13 +273,25 @@ export const story = {
picture: 'assets/closeups/cruscotto-tempo.png', picture: 'assets/closeups/cruscotto-tempo.png',
display: [220, 158, 212, 62], display: [220, 158, 212, 62],
date: '10/07/1997 18:30', date: '10/07/1997 18:30',
complete: [{ closeup: 'partenza' }],
},
// La partenza vista da fuori: giù per Via Liguri Magelli, il fulmine e le sgommate di fuoco; e l'arrivo, al
// ritorno dal 1997 (Andrea, 30 ottobre; la macchina da dietro e davanti è di Codex).
partenza: {
type: 'corsa', mode: 'parti', scene: 'via_liguri_magelli', ...MACCHINA_CORSA,
path: [[215, 350, 1], [252, 192, 0.05]],
complete: [{ go: 'barrettino_1997', at: [320, 330], face: 'up' }], complete: [{ go: 'barrettino_1997', at: [320, 330], face: 'up' }],
}, },
arrivo: {
type: 'corsa', mode: 'arriva', scene: 'via_liguri_magelli', ...MACCHINA_CORSA,
path: [[110, 470, 2.2], [252, 192, 0.05]],
complete: [],
},
// Il pokerino del Barrettino con Vannino e Pierino (Andrea, 29 ottobre). [DA ANDREA: le battute] // Il pokerino del Barrettino con Vannino e Pierino (Andrea, 29 ottobre). [DA ANDREA: le battute]
poker: { poker: {
type: 'poker', type: 'poker',
names: { player: 'Andrea', cpu: 'Vannino e Pierino' }, names: { player: 'Andrea', cpu: 'Vannino e Pierino' },
win: [{ say: 'M\'hai pelato, ragazzo. Nel 1997 un ci si vedeva da queste parti, con quella faccia.', who: 'vannino', pose: 'shrug' }, { set: 'poker_vinto' }], win: [{ say: 'Tu m\'ha pelato. La prossima volta porta il Barone.', who: 'vannino', pose: 'shrug' }, { set: 'poker_vinto' }],
lose: [{ say: 'Le fiches restano al banco. E tu torna quando hai imparato a bluffare.', who: 'pierino_parigi', pose: 'laugh' }], lose: [{ say: 'Le fiches restano al banco. E tu torna quando hai imparato a bluffare.', who: 'pierino_parigi', pose: 'laugh' }],
quit: [{ say: 'Già finito? E il caffè chi lo paga?', who: 'vannino', pose: 'talk' }], quit: [{ say: 'Già finito? E il caffè chi lo paga?', who: 'vannino', pose: 'talk' }],
draw: [{ say: 'Pari. Si rifà domani sera, alla stess\'ora.', who: 'pierino_parigi', pose: 'explain' }], draw: [{ say: 'Pari. Si rifà domani sera, alla stess\'ora.', who: 'pierino_parigi', pose: 'explain' }],
@@ -281,6 +317,38 @@ export const story = {
draw: [{ say: 'Pari. Si rifà?' }, { say: 'Si rifà.', who: 'taglia', pose: 'explain' }], draw: [{ say: 'Pari. Si rifà?' }, { say: 'Si rifà.', who: 'taglia', pose: 'explain' }],
quit: [{ say: 'La Condor la rendo al Taglia. Per ora.' }], quit: [{ say: 'La Condor la rendo al Taglia. Per ora.' }],
}, },
// La campana col gesso sul vialetto del campone delle poste, a turni col Taglia (Andrea, 30 ottobre).
// [DA ANDREA: le battute sono provvisorie]
campana: {
type: 'campana',
picture: 'assets/closeups/campone-campana.png',
names: { player: 'Andrea', cpu: 'il Taglia' },
win: [{ say: 'Dalla 1 fino in cima, e ritorno. A ventisett\'anni, davanti alle poste.' }, { say: 'Te lo dico io: se ti vede qualcuno, domani lo sa tutto il paese.', who: 'taglia', pose: 'laugh' }, { set: 'campana_vinta' }],
lose: [{ say: 'Il Taglia, su un piede solo, è più alto di me su due.' }, { say: 'È questione di gambe lunghe.', who: 'taglia', pose: 'explain' }],
draw: [],
quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }],
},
// Il manifesto dell'Ingorgo, ingrandito (Andrea, 30 ottobre: «se mi avvicino compaia ingrandito, e poi parta
// l'Ingorgo se ci clicco»). [DA ANDREA: il manifesto vero; questo è di Codex, provvisorio]
manifesto_ingorgo: {
type: 'poster',
picture: 'assets/closeups/manifesto-ingorgo.png',
hint: 'Tocca il manifesto',
tap: [
{ say: 'Il manifesto dell\'Ingorgo Sonoro dell\'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.' },
{ say: 'Quanto darei per essere all\'Ingorgo stasera?!?' },
{ closeup: 'ingorgo' },
],
},
// L'incantesimo dell'Ingorgo Sonoro, dal manifesto in piazza: luci, cassa, lampo, ed è la notte della festa.
ingorgo: {
type: 'incanto',
swap: [{ set: 'ingorgo' }],
complete: [
{ say: 'Ma... è l\'Ingorgo! Le console, la gente, le spine della birra!' },
{ say: 'Il palio può aspettare una sera. O no?' },
],
},
// Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre). // Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre).
// [DA ANDREA: le battute sono provvisorie] // [DA ANDREA: le battute sono provvisorie]
micro: { micro: {
@@ -371,6 +439,8 @@ export const story = {
// Andrea Tagliaferri, il Taglia (consenso suo, 29 ottobre): sprite di Codex dalla sua foto. // Andrea Tagliaferri, il Taglia (consenso suo, 29 ottobre): sprite di Codex dalla sua foto.
taglia: { name: 'Il Taglia', color: '#ffe36a', sprite: 'assets/characters/taglia.json' }, taglia: { name: 'Il Taglia', color: '#ffe36a', sprite: 'assets/characters/taglia.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' }, barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
// Baldoria (Yuri Baldi, nei Ringraziamenti), tifoso della Sampdoria col bandierone (Andrea, 30 ottobre; sprite di Codex).
baldoria: { name: 'Baldoria', color: '#7fb2ff', sprite: 'assets/characters/baldoria.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' }, mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea. // Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' }, fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
@@ -512,15 +582,22 @@ export const story = {
name: 'Piazza Colonna', name: 'Piazza Colonna',
map: [32, 45], map: [32, 45],
background: 'assets/scenes/piazza-colonna.png', background: 'assets/scenes/piazza-colonna.png',
effects: [{ type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }], backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazza.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// La console grande davanti al palazzo; da Via dell'Antica Posta, a sinistra, arriva il latino del Comune.
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [195, 235] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-160, 250], far: 600 },
],
effects: [{ type: 'stagelights', phase: 'ingorgo', from: [[116, 146], [276, 146], [196, 140]], wash: [200, 215, 170], bpm: 124, beams: 3, strobe: 4, pulse: 0.08 }, { type: 'lasers', phase: 'ingorgo', at: [196, 150], count: 7, color: '255,60,200', len: 360 }, { type: 'floorspots', phase: 'ingorgo', area: [40, 240, 560, 110], bpm: 124, count: 7 }, { type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true, phase: ['pomeriggio', 'tramonto', 'sera'] }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }],
walk: [[84, 244], [330, 238], [345, 230], [362, 238], [462, 246], [466, 276], [544, 276], [548, 248], [588, 250], [600, 290], [640, 300], [640, 360], [0, 360], [0, 330], [84, 318]], walk: [[84, 244], [330, 238], [345, 230], [362, 238], [462, 246], [466, 276], [544, 276], [548, 248], [588, 250], [600, 290], [640, 300], [640, 360], [0, 360], [0, 330], [84, 318]],
depth: [[236, 0.5], [360, 1.35]], depth: [[236, 0.5], [360, 1.35]],
entry: [345, 240], entry: [345, 240],
speakers: { vecchietta: [24, 250], vecchietto: [56, 244] }, speakers: { vecchietta: [24, 250], vecchietto: [56, 244] },
// I vecchietti seduti sulla panchina: fotogrammi in assets/characters (pose e battute in game.personPose). // I vecchietti seduti sulla panchina: fotogrammi in assets/characters (pose e battute in game.personPose).
people: [ people: [
{ who: 'vecchietta', at: [24, 317], size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 }, { who: 'vecchietta', at: [24, 317], when: '!ingorgo', size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 },
{ who: 'vecchietto', at: [56, 304], size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 }, { who: 'vecchietto', at: [56, 304], when: '!ingorgo', size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 },
], ],
hotspots: [ hotspots: [
{ {
@@ -583,7 +660,7 @@ export const story = {
exit: { scene: 'circolo_4_maggio', at: [590, 202], face: 'left' }, exit: { scene: 'circolo_4_maggio', at: [590, 202], face: 'left' },
}, },
{ {
id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up', id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up', when: '!ingorgo',
art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }], art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }],
actions: { actions: {
look: [ look: [
@@ -594,7 +671,20 @@ export const story = {
}, },
}, },
{ {
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk', // Il manifesto dell'Ingorgo Sonoro dell'estate prima, mezzo strappato: guardandolo, Andrea esprime il
// desiderio, e la festa arriva (Andrea, 30 ottobre). [DA ANDREA: le battute sono provvisorie]
id: 'manifesto_ingorgo', name: 'vecchio manifesto dell\'Ingorgo Sonoro', box: [223, 195, 22, 26], at: [234, 250], face: 'up', when: '!ingorgo',
// Come quello vero (assets/raw/riferimenti/manifesto-ingorgo-2014.png): blu, il cartello bianco e rosso col DJ.
art: [{ rect: [226, 198, 16, 22], fill: '#1c2050' }, { rect: [233, 201, 2, 2], fill: '#d8262e' }, { rect: [231, 203, 6, 2], fill: '#d8262e' },
{ rect: [229, 205, 10, 4], fill: '#d8262e' }, { rect: [232, 205, 4, 3], fill: '#f4f1e8' }, { rect: [228, 211, 12, 2], fill: '#f4f1e8' },
{ rect: [229, 215, 7, 2], fill: '#ffd23a' }, { rect: [238, 198, 4, 4], fill: '#cdbb9a' }],
actions: {
look: [{ closeup: 'manifesto_ingorgo' }],
take: [{ say: 'Lo lascio dov\'è: è l\'unica cosa che ricorda luglio, qui, a maggio.' }],
},
},
{
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk', when: '!ingorgo',
actions: { 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: [ talk: [
@@ -1532,6 +1622,15 @@ export const story = {
name: 'Piazzetta Cambray Digny', name: 'Piazzetta Cambray Digny',
map: [40, 38], map: [40, 38],
background: 'assets/scenes/piazzetta-cambray-digny.png', background: 'assets/scenes/piazzetta-cambray-digny.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// La tecno davanti alla casetta bianca; l'underground arriva da giù, da Via dell'Antica Posta.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'lasers', phase: 'ingorgo', at: [400, 198], count: 13, len: 460 }, { type: 'lasers', phase: 'ingorgo', at: [370, 200], count: 7, color: '60,220,255' }, { type: 'stagelights', phase: 'ingorgo', from: [[356, 192], [460, 192], [408, 186]], wash: [408, 225, 180], bpm: 132, beams: 3, strobe: 2, pulse: 0.1, colors: ['90,255,110', '255,255,255', '60,220,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 250, 560, 100], bpm: 132, count: 8, colors: ['90,255,110', '255,255,255', '60,220,255'] }],
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [495, 245] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [700, 300], far: 650 },
],
walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]], walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]],
depth: [[268, 0.55], [360, 1.15]], depth: [[268, 0.55], [360, 1.15]],
entry: [320, 330], entry: [320, 330],
@@ -1569,6 +1668,16 @@ export const story = {
name: 'Via dell\'Antica Posta', name: 'Via dell\'Antica Posta',
map: [35, 41], map: [35, 41],
background: 'assets/scenes/via-antica-posta.png', background: 'assets/scenes/via-antica-posta.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// Il bar della birra sui gradini; il latino dal Comune a sinistra, la house giù dalla piazza, l'underground su in cima.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'stagelights', phase: 'ingorgo', wash: [90, 190, 130], bpm: 98, colors: ['255,170,60', '255,90,160'], pulse: 0.05 }, { type: 'stagelights', phase: 'ingorgo', from: [[430, 205], [130, 150]], beams: 2, len: 260, bpm: 98 }, { type: 'floorspots', phase: 'ingorgo', area: [40, 230, 560, 120], bpm: 98, count: 5 }],
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-110, 250] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [320, 560] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [384, 40], far: 600 },
],
walk: [[0, 360], [0, 300], [120, 290], [220, 252], [300, 240], [372, 226], [396, 226], [470, 252], [630, 300], [640, 360]], walk: [[0, 360], [0, 300], [120, 290], [220, 252], [300, 240], [372, 226], [396, 226], [470, 252], [630, 300], [640, 360]],
depth: [[226, 0.35], [360, 1.15]], depth: [[226, 0.35], [360, 1.15]],
entry: [320, 345], entry: [320, 345],
@@ -1608,6 +1717,15 @@ export const story = {
name: 'Piazzetta del Comune', name: 'Piazzetta del Comune',
map: [32, 39], map: [32, 39],
background: 'assets/scenes/piazzetta-comune.png', background: 'assets/scenes/piazzetta-comune.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta-comune.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// La console del latino davanti al Comune; la house arriva da giù, dalla piazza.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'stagelights', phase: 'ingorgo', from: [[232, 212], [332, 212], [282, 205]], wash: [282, 228, 170], bpm: 98, beams: 3, strobe: 8, pulse: 0.08, colors: ['255,120,40', '255,60,120', '255,200,60', '200,80,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 250, 560, 100], bpm: 98, count: 7, colors: ['255,120,40', '255,60,120', '255,200,60', '200,80,255'] }],
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [290, 235] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [260, 640], far: 700 },
],
walk: [[0, 360], [0, 318], [130, 292], [470, 288], [540, 300], [520, 360]], walk: [[0, 360], [0, 318], [130, 292], [470, 288], [540, 300], [520, 360]],
depth: [[288, 0.55], [360, 1.15]], depth: [[288, 0.55], [360, 1.15]],
entry: [260, 350], entry: [260, 350],
@@ -1655,6 +1773,16 @@ export const story = {
name: 'In cima a Via dell\'Antica Posta', name: 'In cima a Via dell\'Antica Posta',
map: [38, 39], map: [38, 39],
background: 'assets/scenes/via-antica-posta-alta.png', background: 'assets/scenes/via-antica-posta-alta.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta-alta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// L'underground sotto il muro della villa; su, in fondo, la tecno di Cambray Digny; giù, lontano, la house.
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
effects: [{ type: 'mirrorball', phase: 'ingorgo', at: [550, 131], area: [0, 20, 640, 300], count: 110 }, { type: 'stagelights', phase: 'ingorgo', from: [[520, 200], [600, 200]], wash: [552, 205, 160], bpm: 122, beams: 2, strobe: 8, pulse: 0.07, colors: ['170,60,255', '90,40,220', '220,60,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 240, 560, 110], bpm: 122, count: 6, colors: ['170,60,255', '60,120,255', '220,60,255'] }],
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [565, 235] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [270, 120], far: 600 },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [240, 640], far: 750 },
],
walk: [[0, 360], [0, 322], [190, 226], [283, 222], [470, 360]], walk: [[0, 360], [0, 322], [190, 226], [283, 222], [470, 360]],
depth: [[222, 0.35], [360, 1.15]], depth: [[222, 0.35], [360, 1.15]],
entry: [240, 350], entry: [240, 350],
@@ -1967,7 +2095,19 @@ export const story = {
walk: [[0, 298], [640, 298], [640, 360], [0, 360]], walk: [[0, 298], [640, 298], [640, 360], [0, 360]],
depth: [[298, 1.0], [360, 1.2]], depth: [[298, 1.0], [360, 1.2]],
entry: [40, 330], entry: [40, 330],
// Baldoria sotto la tribuna col bandierone della Sampdoria: ogni tanto lo sventola (Andrea, 30 ottobre).
speakers: { baldoria: [490, 196] },
people: [{ who: 'baldoria', at: [490, 318], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.3,
flourish: { poses: ['wave', 'wave2'], every: 9, for: 2.1, step: 0.35 } }],
hotspots: [ hotspots: [
{
id: 'baldoria', name: 'Baldoria', box: [462, 200, 56, 124], at: [430, 326], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.' }],
talk: [{ dialogue: 'baldoria' }],
take: [{ say: 'Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.' }],
},
},
{ {
id: 'tribuna', name: 'tribuna', box: [254, 172, 116, 40], at: [310, 320], face: 'up', id: 'tribuna', name: 'tribuna', box: [254, 172, 116, 40], at: [310, 320], face: 'up',
actions: { actions: {
@@ -3336,6 +3476,10 @@ export const story = {
walk: [[150, 360], [300, 250], [280, 236], [340, 236], [420, 248], [640, 330], [640, 360]], walk: [[150, 360], [300, 250], [280, 236], [340, 236], [420, 248], [640, 330], [640, 360]],
depth: [[236, 0.45], [360, 1.35]], depth: [[236, 0.45], [360, 1.35]],
entry: [400, 352], entry: [400, 352],
// Al tramonto, quando la Ghiacciomobile è già un ricordo con la targa, lo zio Mario è nel suo orto (Andrea,
// 30 ottobre), e ti aspetta a cena.
speakers: { zio_mario: [250, 240] },
people: [{ who: 'zio_mario', when: 'indizio_regolo', at: [250, 318], facing: -1, idle: 'stand', talk: ['talk', 'explain', 'point'] }],
hotspots: [ hotspots: [
{ {
id: 'casa_zio', name: 'casa dello zio Mario', box: [80, 90, 200, 90], at: [260, 290], face: 'left', id: 'casa_zio', name: 'casa dello zio Mario', box: [80, 90, 200, 90], at: [260, 290], face: 'left',
@@ -3355,6 +3499,22 @@ export const story = {
{ {
id: 'indietro', name: 'indietro, verso il ponte delle poste', box: [150, 336, 490, 24], at: [400, 352], exit: { scene: 'ponte_stazione', at: [158, 192], face: 'down' }, id: 'indietro', name: 'indietro, verso il ponte delle poste', box: [150, 336, 490, 24], at: [400, 352], exit: { scene: 'ponte_stazione', at: [158, 192], face: 'down' },
}, },
{
id: 'zio_mario', name: 'lo zio Mario', box: [228, 248, 44, 72], at: [300, 330], face: 'left', default: 'talk', when: 'indizio_regolo',
actions: {
look: [{ say: 'Lo zio Mario nel suo orto, col sole che va giù dietro la casa.' }],
// Le battute sono di Andrea (30 ottobre).
talk: [
{ say: 'Ti s\'aspetta a cena, che c\'è la vitella di maiale.', who: 'zio_mario', pose: 'explain' },
{ say: 'Non ti avevo visto, pensavo tu fossi andato all\'estero.' },
{ say: 'Io ho girato tanto... il Falcione.', who: 'zio_mario', pose: 'laugh' },
{ say: 'Zio, che sei stanco?' },
{ say: 'No, dormo!', who: 'zio_mario', pose: 'laugh' },
{ say: 'Zio, a me mi garba Michael Jackson!' },
{ say: 'Mai col gesso!', who: 'zio_mario', pose: 'laugh' },
],
},
},
], ],
}, },
// La strada fra il bivio della Provinciale (alle spalle) e la stazione (in fondo); a destra si va al campone delle // La strada fra il bivio della Provinciale (alle spalle) e la stazione (in fondo); a destra si va al campone delle
@@ -3923,6 +4083,26 @@ export const story = {
}, },
}, },
// Il Taglia al campone delle poste, con la Condor (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie] // Il Taglia al campone delle poste, con la Condor (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie]
// Baldoria al campo sportivo. [DA ANDREA: le battute sono provvisorie]
baldoria: {
nodes: {
start: {
options: [
{ text: 'Baldoria! Ma che ci fai col bandierone al campo?', then: [
{ say: 'Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.', who: 'baldoria', pose: 'explain' },
{ say: 'Forza Doria!', who: 'baldoria', pose: 'wave' },
] },
{ text: 'Guarda che qui la Sampdoria non ci gioca.', then: [
{ say: 'La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.', who: 'baldoria', pose: 'cheer' },
] },
{ text: 'Hai visto passare il palio, per caso?', then: [
{ say: 'L\'unico palio che conosco è blu, bianco, rosso e nero. E ce l\'ho in mano.', who: 'baldoria', pose: 'laugh' },
] },
{ text: 'Ciao, Baldoria.', next: 'end' },
],
},
},
},
taglia: { taglia: {
nodes: { nodes: {
start: { start: {
@@ -3935,6 +4115,10 @@ export const story = {
{ say: 'Il cartone sull\'albero, le lattine sulla panchina, le bottigliette sulla porta e il palloncino all\'altalena.', who: 'taglia', pose: 'explain' }, { say: 'Il cartone sull\'albero, le lattine sulla panchina, le bottigliette sulla porta e il palloncino all\'altalena.', who: 'taglia', pose: 'explain' },
{ say: 'E se la vuoi seria, ogni tanto ti tiro una lattina in aria.', who: 'taglia', pose: 'laugh' }, { say: 'E se la vuoi seria, ogni tanto ti tiro una lattina in aria.', who: 'taglia', pose: 'laugh' },
] }, ] },
{ text: 'E una partita a campana, Taglia?', next: 'campana', then: [
{ say: 'A campana? Alla nostra età?', who: 'taglia', pose: 'laugh' },
{ say: 'Va bene: il gesso ce l\'ho in tasca, il sassolino lo scelgo io. Sul vialetto, dalla 1 alla 8, e chi pesta la riga passa la mano.', who: 'taglia', pose: 'explain' },
] },
{ text: 'Ma quanto sei alto, Taglia?', then: [{ say: 'Abbastanza da vedere i pallini arrivare prima di te.', who: 'taglia', pose: 'laugh' }] }, { text: 'Ma quanto sei alto, Taglia?', then: [{ say: 'Abbastanza da vedere i pallini arrivare prima di te.', who: 'taglia', pose: 'laugh' }] },
{ text: 'Ci si vede, Taglia.', next: 'end' }, { text: 'Ci si vede, Taglia.', next: 'end' },
], ],
@@ -3947,6 +4131,14 @@ export const story = {
{ text: 'No, dopo.', next: 'start' }, { text: 'No, dopo.', next: 'start' },
], ],
}, },
campana: {
options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: le righe un contano. Comincia te.', who: 'taglia', pose: 'talk' }, { closeup: 'campana', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria: chi tocca la riga è fuori. Comincia te.', who: 'taglia', pose: 'explain' }, { closeup: 'campana', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Io la campana la facevo a occhi chiusi.', who: 'taglia', pose: 'laugh' }, { closeup: 'campana', level: 'male' }] },
{ text: 'No, dopo.', next: 'start' },
],
},
}, },
}, },
// Il Chiaru al parcheggio di Cardetole, accanto alla pista col gesso (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie] // Il Chiaru al parcheggio di Cardetole, accanto alla pista col gesso (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie]
+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.54'; export const VERSION = '0.1.59';
// 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).
+4 -4
View File
@@ -62,16 +62,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
/* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */ /* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */
#bocce-throw.hold { min-height: 72px; font-size: 14px; } #bocce-throw.hold { min-height: 72px; font-size: 14px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; } .bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary { cursor: pointer; padding: 6px 0; } #bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p { max-width: 76em; line-height: 1.7; } #bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p { max-width: 76em; line-height: 1.7; }
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#bocce-throw { flex-basis: 100%; } #bocce-throw { flex-basis: 100%; }
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help { font-size: 9px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help { font-size: 9px; }
} }
.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-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); } #effects-volume { width: 130px; accent-color: var(--accent); }
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
test('Baldoria al campo principale: ogni tanto sventola il bandierone, sennò sta fermo', () => {
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
const p = g.people().find(p => p.who === 'baldoria');
assert.ok(p, 'Baldoria è in scena');
const poses = new Set();
for (let t = 0; t < 9; t += 0.05) { g.time = t; poses.add(g.personPose(p)); }
assert.ok(poses.has('wave') && poses.has('wave2') && poses.has('stand') && poses.has('blink'));
g.time = 3; assert.equal(g.personPose(p), 'stand', 'fuori dallo sventolio, fermo');
const sheet = JSON.parse(readFileSync(new URL('../assets/characters/baldoria.json', import.meta.url)));
for (const f of ['stand', 'blink', 'talk', 'explain', 'wave', 'wave2', 'cheer', 'laugh']) assert.ok(sheet.frames[f], f);
assert.ok(existsSync(new URL('../assets/characters/baldoria.png', import.meta.url)));
});
test('si parla con Baldoria: le risposte e il saluto', () => {
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
g.act('talk', 'baldoria'); settle(g);
assert.equal(g.dialogue?.id, 'baldoria');
const said = [];
for (const text of ['Baldoria! Ma che ci fai col bandierone al campo?', 'Guarda che qui la Sampdoria non ci gioca.', 'Hai visto passare il palio, per caso?']) {
g.choose(g.options().findIndex(o => o.text === text));
said.push(...settle(g).filter(s => s.who === 'baldoria').map(s => s.text));
}
assert.ok(said.includes('Forza Doria!'));
assert.equal(said.length, 4);
g.choose(g.options().findIndex(o => o.text === 'Ciao, Baldoria.')); settle(g);
assert.equal(g.dialogue, null);
});
+75
View File
@@ -0,0 +1,75 @@
// La campana nel browser vero: dal Taglia al campone delle poste, il tiro col mouse e col dito, i salti.
// npm run browser:campana — schermate in /tmp/regolo-campana.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-campana';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
settle(game); game.go('giardini_poste'); settle(game);
game.act('talk', 'taglia'); settle(game);
game.choose(game.options().findIndex(o => o.text.startsWith('E una partita a campana'))); settle(game);
game.choose(game.options().findIndex(o => o.text === 'Alla bona.')); settle(game);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/campone-campana.png'));
assert.equal(await page.locator('#bocce-throw').textContent(), 'Tira');
assert.ok(await page.locator('#campana-help').isVisible());
assert.ok(!(await page.locator('#bocce-left').isVisible()), 'niente pulsanti Mira');
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
// Mira alla 1, poi la forza giusta: col mouse tenendo premuto, col dito il cursore e Tira.
const one = await page.evaluate(async () => { const m = window.campanaModule = await import('/src/engine/campana.js'); const p = m.center(1); return [p.x, p.y, Math.hypot(p.x - m.FROM.x, p.y - m.FROM.y) / m.REACH]; });
if (mobile) {
await page.touchscreen.tap(...P(one[0], one[1]));
assert.equal(await page.evaluate(() => game.closeup.step), 'aim', 'un tocco mira soltanto');
await page.evaluate(p => { game.closeup.power = p; game.closeup.skill.scatter = 0; }, one[2]);
await page.locator('#bocce-throw').tap();
} else {
await page.mouse.move(...P(one[0], one[1]));
await page.evaluate(() => { game.closeup.skill.scatter = 0; });
await page.mouse.down();
await page.waitForFunction(p => Math.abs(game.closeup.power - p) < 0.02, one[2], { polling: 'raf' });
await page.screenshot({ path: `${out}/${name}-forza.png` });
await page.mouse.up();
// Col mouse il momento giusto dipende da quanto è lesta la pagina: se è andato fuori, si ritira col pulsante.
await page.waitForFunction(() => !['aim', 'flying'].includes(game.closeup.step));
if (await page.evaluate(() => game.closeup.step) !== 'hop') {
await page.evaluate(p => { Object.assign(game.closeup, { step: 'aim', current: 'player', next: null, line: null, power: p }); }, one[2]);
await page.locator('#bocce-throw').click();
}
}
await page.waitForFunction(() => game.closeup.step === 'hop', null, { timeout: 5000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ ...game.closeup, events: 0 }))); await page.screenshot({ path: `${out}/${name}-errore.png` }); throw e; });
assert.equal(await page.locator('#bocce-throw').textContent(), 'Salta');
// Quanti salti riescono: la lancetta dopo una schermata (lenta, con swiftshader) può già essere scappata.
let hops = 0;
// I salti: si preme quando la lancetta passa nel verde (col dito sul pulsante Salta, col mouse sulla scena).
for (let i = 0; i < 4; i++) {
await page.waitForFunction(() => { const m = window.campanaModule, c = game.closeup; return c.step !== 'hop' || ((!c.hop || c.hop.t >= m.HOP) && c.meter.t > 0 && Math.abs(m.needle(c)) < 0.15); }, null, { polling: 'raf', timeout: 5000 });
if (await page.evaluate(() => game.closeup.step) !== 'hop') break;
if (mobile) await page.locator('#bocce-throw').tap(); else await page.mouse.click(...P(320, 200));
hops = Math.max(hops, await page.evaluate(() => game.closeup.beat + 1));
if (i === 1) await page.screenshot({ path: `${out}/${name}-salti.png` });
}
assert.ok(hops >= 2, `salti riusciti: ${hops}`);
// Poi tocca al Taglia: lo si fa sbagliare per vederlo saltare.
await page.evaluate(() => { const c = game.closeup; c.step = 'rest'; c.t = 0; c.next = 'cpu'; c.line = 'Prova: tocca al Taglia.'; });
await page.waitForFunction(() => game.closeup.step === 'cpuHop' && game.closeup.beat >= 3 || game.closeup.current === 'player' && game.closeup.step === 'aim', null, { timeout: 20000 });
await page.screenshot({ path: `${out}/${name}-taglia.png` });
await page.close();
}
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+125
View File
@@ -0,0 +1,125 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as m from '../src/engine/campana.js';
import { drawCampana } from '../src/engine/campana-render.js';
import { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
// Tira senza sparpaglio, con la forza giusta per arrivare al punto, e aspetta che il sassolino cada.
function toss(c, [x, y]) {
c.skill = { ...c.skill, scatter: 0 }; c.aim = { x, y };
c.power = Math.hypot(x - m.FROM.x, y - m.FROM.y) / m.REACH;
assert.ok(m.shoot(c));
for (let i = 0; i < 200 && c.step === 'flying'; i++) m.update(c, 1 / 60);
}
// Salta ogni volta che la lancetta passa nel mezzo.
function hopAll(c) {
for (let i = 0; i < 20000 && c.step === 'hop'; i++) {
m.update(c, 1 / 120);
if (c.step === 'hop' && (!c.hop || c.hop.t >= m.HOP) && Math.abs(m.needle(c)) < 0.03 && c.meter.t > 0) m.press(c, 1);
}
}
const at = id => { const p = m.center(id); return [p.x, p.y]; };
test('le caselle: 1-2-3, la coppia 4-5, la 6, la coppia 7-8 e il cielo; le righe', () => {
for (let n = 1; n <= 8; n++) assert.equal(m.boxAt(at(n), 3).id, n);
assert.equal(m.boxAt(at('cielo')).id, 'cielo');
assert.ok(m.center(4).y < m.center(5).y, 'la 4 a sinistra di chi salta');
const b = m.BOXES[2];
assert.ok(m.boxAt([b.x + 1, m.CY], 3).line, 'sul bordo si tocca la riga');
assert.ok(!m.boxAt([b.x + 1, m.CY], 0).line, 'alla bona le righe non contano');
assert.equal(m.boxAt([20, 20]), null);
});
test('i salti: si scavalca la casella col sassolino, nella coppia un piede solo; tornando lo si raccoglie', () => {
const ids = list => list.map(b => b.pick ? 'prendi' : b.out ? 'fuori' : b.boxes.join('+'));
assert.deepEqual(ids(m.beats(1)), ['2', '3', '4+5', '6', '7+8', 'cielo', '7+8', '6', '4+5', '3', '2', 'prendi', 'fuori']);
assert.deepEqual(ids(m.beats(5)), ['1', '2', '3', '4', '6', '7+8', 'cielo', '7+8', '6', 'prendi', '4', '3', '2', '1', 'fuori']);
assert.deepEqual(ids(m.beats(8)), ['1', '2', '3', '4+5', '6', '7', 'cielo', 'prendi', '7', '6', '4+5', '3', '2', '1', 'fuori']);
assert.ok(m.beats(3).every(b => !b.boxes.includes(3)), 'mai il piede dove sta il sassolino');
});
test('il sassolino nella casella giusta fa saltare; tutta la campana fatta, si passa alla dopo', () => {
const c = m.newCampana(1, { level: 'seria' });
toss(c, at(1));
assert.equal(c.step, 'hop'); assert.match(c.line, /nella 1/);
hopAll(c);
assert.equal(c.step, 'done'); assert.equal(c.target.player, 2); assert.equal(c.stone, null, 'raccolto');
for (let i = 0; i < 120 && c.step !== 'aim'; i++) m.update(c, 1 / 60);
assert.equal(c.current, 'player', 'chi ce la fa ritira');
});
test('fuori, sulla riga o nella casella sbagliata passa la mano al Taglia', () => {
for (const [point, why] of [[at(2), /Nella 2, non nella 1/], [[m.START + 1, m.CY], /riga/], [[60, 40], /fuori/i]]) {
const c = m.newCampana(2, { level: 'seria' });
toss(c, point);
assert.equal(c.step, 'rest'); assert.match(c.line, why);
for (let i = 0; i < 200 && c.step === 'rest'; i++) m.update(c, 1 / 60);
assert.equal(c.current, 'cpu'); assert.equal(c.target.player, 1);
}
});
test('l\'equilibrio: troppo presto o troppo tardi si sbaglia', () => {
const early = m.newCampana(3, { level: 'male' });
toss(early, at(1));
while (early.meter.t < 0.05) m.update(early, 1 / 120);
m.press(early, 1); assert.equal(early.step, 'rest'); assert.match(early.line, /presto/);
const late = m.newCampana(3, { level: 'male' });
toss(late, at(1));
for (let i = 0; i < 1000 && late.step === 'hop'; i++) m.update(late, 1 / 60);
assert.match(late.line, /equilibrio/);
});
test('il tiro col dito: un tocco secco mira soltanto; tenendo premuto la forza va e torna', () => {
const c = m.newCampana(4, { level: 'bona' });
assert.ok(m.aimScreen(c, [300, 150]));
m.press(c, 1); m.update(c, 0.05); m.release(c, 1); assert.equal(c.step, 'aim');
m.press(c, 1);
for (let i = 0; i < 60 * m.CHARGE; i++) m.update(c, 1 / 60);
assert.ok(c.power > 0.95);
for (let i = 0; i < 30 * m.CHARGE; i++) m.update(c, 1 / 60);
assert.ok(c.power < 0.6, 'e poi torna giù');
assert.ok(m.release(c, 1)); assert.equal(c.step, 'flying');
assert.ok(m.key(c, 'ArrowUp', true));
});
test('il Taglia gioca da solo; chi finisce la 8 vince', () => {
const c = m.newCampana(5, { level: 'seria' });
toss(c, [60, 40]);
let turns = 0;
for (let i = 0; i < 60 * 600 && c.step !== 'result'; i++) {
m.update(c, 1 / 60);
if (c.current === 'player' && c.step === 'aim') { turns++; toss(c, [60, 40]); }
}
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.equal(c.target.cpu, 9);
assert.ok(turns > 0, 'il Taglia ogni tanto sbaglia e ripassa la mano');
const w = m.newCampana(6, { level: 'bona' });
w.target.player = 8;
toss(w, at(8)); hopAll(w);
assert.equal(w.step, 'result'); assert.equal(w.result.winner, 'player');
});
test('il disegno della campana non cambia lo stato', () => {
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
const c = m.newCampana(7, { level: 'bona' }), bg = {};
const draw = () => { const before = structuredClone(c); drawCampana(ctx, c, story.closeups.campana, bg); assert.deepEqual(c, before); };
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'LA CAMPANA'));
toss(c, at(1)); for (let i = 0; i < 40; i++) m.update(c, 1 / 60); draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'EQUILIBRIO'));
c.step = 'result'; c.result = { winner: 'player' }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'BATTUTO IL TAGLIA!'));
assert.ok(MINIGAMES.campana);
});
test('dal Taglia, al campone delle poste: la campana, il livello e la vittoria', () => {
const g = new Game(story); g.go('giardini_poste'); settle(g);
g.act('talk', 'taglia'); settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('E una partita a campana'))); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
assert.equal(g.closeup?.id, 'campana'); assert.equal(g.closeup.level, 'seria');
g.closeup.step = 'result'; g.closeup.result = { winner: 'player' };
g.closeupClose(); settle(g);
assert.ok(g.state.flags.campana_vinta);
});
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import * as C from '../src/engine/corsa.js';
import { story } from '../src/story/story.js';
const run = (def, until) => { const c = C.newCorsa('x'); while (c.t < until) C.updateCorsa(c, def, 1 / 60); return c; };
test('la partenza: accelera giù per la strada, il fulmine la prende e sparisce; restano le sgommate', () => {
const def = story.closeups.partenza;
assert.equal(def.mode, 'parti');
for (const src of Object.values(def.frames)) assert.ok(existsSync(src), src);
const at = t => C.progress({ t }, def);
assert.equal(at(0), 0);
assert.ok(at(1.2) > 0 && at(1.6) - at(1.2) > at(1.2) - at(0.8), 'accelera');
assert.ok(C.carShown({ t: 2 }, def) && !C.carShown({ t: C.PARTI.bolt + 0.2 }, def));
const c = run(def, C.PARTI.bolt + 0.1);
assert.ok(c.events.some(e => e.kind === 'corsa-tuono') && c.events.some(e => e.kind === 'corsa-fuoco'));
assert.ok(!C.updateCorsa(c, def, 0.01));
assert.ok(C.updateCorsa(run(def, C.PARTI.end - 0.01), def, 0.05), 'finita');
});
test('l\'arrivo: il fulmine in fondo, poi viene avanti e ci passa accanto, fuori dal quadro', () => {
const def = story.closeups.arrivo;
const at = t => C.progress({ t }, def);
assert.equal(at(0), 1); assert.equal(at(C.ARRIVA.bolt + C.ARRIVA.run + 0.1), 0);
assert.ok(!C.carShown({ t: 0.1 }, def) && C.carShown({ t: 1 }, def));
assert.ok(!C.carShown({ t: C.ARRIVA.bolt + C.ARRIVA.run + 0.1 }, def), 'passata');
assert.ok(at(1.2) < at(0.8));
});
test('il disegno della corsa non cambia lo stato e usa lo sfondo e i fotogrammi', () => {
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
const bg = { width: 640, height: 360 }, frames = { dietro: { width: 220, height: 190 }, lampi: { width: 220, height: 190 }, davanti: { width: 220, height: 190 } };
for (const [id, t] of [['partenza', 1.4], ['partenza', 3.5], ['arrivo', 1.2], ['arrivo', 2.5]]) {
const c = { ...C.newCorsa(id), t }, before = structuredClone(c);
calls.length = 0;
C.drawCorsa(ctx, c, story.closeups[id], { bg, frames, tint: null });
assert.deepEqual(c, before);
assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.equal(calls.some(([op, img]) => op === 'drawImage' && Object.values(frames).includes(img)), t < 2.4, 'dopo il fulmine (o passata) la macchina non c\'è');
assert.ok(calls.filter(([op]) => op === 'fillRect').length > 20, 'le fiamme');
}
});
+58
View File
@@ -0,0 +1,58 @@
// L'Ingorgo Sonoro nel browser vero: il manifesto in piazza, l'effetto speciale, la festa nelle cinque scene con le
// console che si mescolano, e il ritorno a maggio uscendo dal centro.
// npm run browser:ingorgo — schermate in /tmp/regolo-ingorgo.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-ingorgo';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
window.settle = settle;
settle(game); game.go('piazza', [250, 300]); settle(game);
game.act('look', 'manifesto_ingorgo');
});
// Il manifesto si apre ingrandito; un clic sopra, e parte il desiderio.
await page.waitForFunction(() => game.closeup?.id === 'manifesto_ingorgo' && game.closeup.t > 0.6, null, { timeout: 15000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ c: game.closeup, sp: game.speech, q: game.queue, task: game.task, walking: game.walking, xy: [game.state.x, game.state.y] }))); throw e; });
await page.waitForFunction(() => game.closeup.t > 1.4, null, { timeout: 5000 });
await page.screenshot({ path: `${out}/0-manifesto.png` });
const box = await page.locator('#scene').boundingBox();
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
// Le battute davanti al manifesto, poi l'effetto: a metà i fasci, poi il lampo, poi i coriandoli sulla festa.
await page.waitForFunction(() => game.speech?.text.startsWith('Quanto darei'), null, { timeout: 15000 });
await page.screenshot({ path: `${out}/1-desiderio.png` });
await page.waitForFunction(() => game.closeup?.id === 'ingorgo' && game.closeup.t > 1.2, null, { timeout: 15000 });
await page.screenshot({ path: `${out}/2-fasci.png` });
await page.waitForFunction(() => game.closeup?.t > 2.5, null, { timeout: 5000 });
await page.screenshot({ path: `${out}/3-coriandoli.png` });
await page.waitForFunction(() => !game.closeup && game.state.flags.ingorgo && game.speech?.text.includes('Ingorgo'), null, { timeout: 5000 });
await page.screenshot({ path: `${out}/3b-festa.png` });
await page.evaluate(() => settle(game));
// La festa, scena per scena: lo sfondo di notte, e la musica di più console insieme.
for (const [scene, at] of [['piazza', [250, 300]], ['via_antica_posta', [300, 300]], ['piazzetta_comune', [300, 330]], ['antica_posta_alta', [330, 300]], ['piazzetta', [330, 320]]]) {
await page.evaluate(([s, a]) => { game.go(s, a); settle(game); }, [scene, at]);
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground(game.backdrop()), null, { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(1500);
const mixNow = await page.evaluate(() => [...music.tracks.values()].map(t => [t.src.split('/').pop(), Math.round(t.gain * 100)]));
console.log(scene, JSON.stringify(mixNow));
assert.ok(mixNow.length >= 2, `${scene}: più console insieme`);
await page.screenshot({ path: `${out}/4-${scene}.png` });
}
// Uscendo dal centro si torna a maggio.
await page.evaluate(() => { game.go('via_de_medici'); });
await page.waitForFunction(() => !game.state.flags.ingorgo && game.speech?.text.startsWith('Mi stropiccio'), null, { timeout: 5000 });
await page.screenshot({ path: `${out}/5-risveglio.png` });
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+156
View File
@@ -0,0 +1,156 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { trackFor, mix, Music } from '../src/engine/music.js';
import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/engine/incanto.js';
import { Game, check, POSTER_READY } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
import { render } from '../src/engine/render.js';
const ZONE = story.zones.find(z => z.flag === 'ingorgo');
// Fa scorrere il tempo finché l'incantesimo non è finito.
const spell = g => { for (let i = 0; i < 600 && g.closeup?.id === 'ingorgo'; i++) g.update(1 / 60); settle(g); };
const fromPiazza = () => {
const g = new Game(story); g.go('piazza'); settle(g);
g.act('look', 'manifesto_ingorgo'); settle(g);
assert.equal(g.closeup?.id, 'manifesto_ingorgo', 'il manifesto ingrandito');
assert.ok(!g.closeupTap(), 'mentre si apre, un tocco non conta');
g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g);
assert.equal(g.closeup?.id, 'ingorgo', 'l\'effetto speciale');
spell(g);
return g;
};
test('la miscela delle console: vicino a una si sente quella, fra due tutte e due, mai il silenzio', () => {
const stages = [{ at: [100, 300], music: 'a.mp3' }, { at: [600, 300], music: 'b.mp3' }];
const near = mix(stages, [110, 300]);
assert.equal(near[0].src, 'a.mp3'); assert.equal(near[0].gain, 1);
assert.ok(near[1].gain < 0.3 && near[1].gain >= 0.08, 'l\'altra arriva da lontano, ma c\'è');
const mid = mix(stages, [350, 300]);
assert.ok(Math.abs(mid[0].gain - mid[1].gain) < 1e-9, 'a metà strada, pari');
// Le console contano solo quando vale la loro condizione; spente, la scena torna al suo brano.
const s = { music: { game: 'g.mp3' }, scenes: { p: { stages: stages.map(st => ({ ...st, when: 'festa' })) } } };
assert.equal(trackFor(s, { screen: 'game', scene: 'p', at: [110, 300] }), 'g.mp3');
const on = trackFor(s, { screen: 'game', scene: 'p', at: [110, 300], ok: cond => cond === 'festa' });
assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']);
});
test('la musica suona più brani insieme e li fa sfumare quando si cambia piazza', () => {
globalThis.Audio = class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
const m = new Music({ volume: 0.5 });
for (let i = 0; i < 40; i++) m.update([{ src: 'a.mp3', gain: 1 }, { src: 'b.mp3', gain: 0.2 }], 0.1);
const [a, b] = [m.tracks.get('a.mp3').audio, m.tracks.get('b.mp3').audio];
assert.equal(a.volume, 0.5); assert.ok(Math.abs(b.volume - 0.1) < 1e-9);
assert.equal(m.current.src, 'a.mp3', 'la più forte è quella principale');
for (let i = 0; i < 40; i++) m.update('c.mp3', 0.1);
assert.ok(a.paused && b.paused, 'le console lasciate sfumano e si fermano');
assert.deepEqual([...m.tracks.keys()], ['c.mp3']);
delete globalThis.Audio;
});
test('l\'incantesimo: i battiti accelerano, il lampo, il cambio a bianco pieno, i coriandoli', () => {
const c = newIncanto('ingorgo'), seen = [];
let swapAt = null;
for (let i = 0; i < 400; i++) {
const now = updateIncanto(c, 1 / 60);
if (now === 'swap') swapAt = c.t;
if (now) seen.push(now);
if (now === 'end') break;
}
assert.deepEqual(seen, ['swap', 'end']);
assert.ok(Math.abs(swapAt - T.swap) < 0.02);
assert.equal(whiteness(T.swap), 1, 'si cambia quando non si vede niente');
assert.equal(whiteness(T.end), 0);
const kicks = c.events.filter(e => e.kind === 'incanto-cassa');
assert.ok(kicks.length >= 6 && c.events.some(e => e.kind === 'incanto-lampo'));
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]), set: (o, k, v) => { o[k] = v; return true; } });
for (const t of [0.5, 2.0, 3.2]) { calls.length = 0; drawIncanto(ctx, { ...c, t }); assert.ok(calls.some(([op]) => op === 'fillRect')); }
});
test('dal manifesto in piazza all\'Ingorgo: la battuta, l\'effetto, la festa; uscendo dalla zona si torna a maggio', () => {
const g = fromPiazza();
assert.ok(g.state.flags.ingorgo, 'la festa c\'è');
assert.equal(g.phase().id, 'ingorgo');
assert.notEqual(g.backdrop(), story.scenes.piazza.background, 'la piazza della festa');
assert.ok(!g.hotspots().some(h => h.id === 'manifesto_ingorgo'), 'il manifesto, alla festa, non serve');
// Si gira la festa: in ogni posto della zona la festa resta, la musica è la miscela delle console.
for (const scene of ZONE.scenes) {
g.go(scene); settle(g);
assert.ok(g.state.flags.ingorgo, scene);
assert.notEqual(g.backdrop(), story.scenes[scene].background, `${scene}: lo sfondo della festa`);
const track = trackFor(story, { screen: 'game', scene, at: [g.state.x, g.state.y], ok: c => check(c, g.state) });
assert.ok(Array.isArray(track) && track.length >= 2, `${scene}: più console insieme`);
}
g.go('via_cipriani'); settle(g);
assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce');
assert.equal(g.backdrop(), story.scenes.via_cipriani.background);
});
test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => {
const g = new Game(story); g.go('piazza'); settle(g);
g.act('look', 'manifesto_ingorgo'); settle(g);
g.update(POSTER_READY); g.closeupTap(); settle(g);
assert.equal(g.closeup?.id, 'ingorgo');
g.closeupClose(); settle(g);
assert.ok(g.state.flags.ingorgo);
});
test('col dito il manifesto dell\'Ingorgo si prende anche toccando vicino, e non si scambia con quello del Palio', () => {
const g = new Game(story); g.go('piazza'); settle(g);
const [x, y, w, h] = g.hotspot('manifesto_ingorgo').box, reach = 28; // 22 pixel di schermo su un iPhone in orizzontale
for (const p of [[x + w / 2, y + h / 2], [x - 6, y + h / 2], [x + w + 4, y + h / 2], [x + w / 2, y + h + 6]])
assert.equal(g.hotspotAt(p, reach)?.id, 'manifesto_ingorgo', `tocco in ${p}`);
const palio = g.hotspot('manifesto').box;
assert.equal(g.hotspotAt([palio[0] + palio[2] / 2, palio[1] + palio[3] / 2], reach)?.id, 'manifesto');
});
test('il manifesto ingrandito si chiude senza fare niente: niente festa', () => {
const g = new Game(story); g.go('piazza'); settle(g);
g.act('look', 'manifesto_ingorgo'); settle(g);
g.update(1); g.closeupClose(); settle(g);
assert.ok(!g.state.flags.ingorgo && !g.closeup);
});
test('alla festa si clicca solo per andare da un posto all\'altro', () => {
const g = fromPiazza();
for (const scene of ZONE.scenes) {
g.go(scene); settle(g);
const list = g.hotspots();
assert.ok(list.length && list.every(h => h.exit), `${scene}: solo le uscite`);
assert.ok(story.scenes[scene].hotspots.some(h => !h.exit), `${scene}: di giorno c'è anche altro`);
}
g.go('piazza'); settle(g);
assert.equal(g.hotspotAt([505, 200]), null, 'la colonna, alla festa, non risponde');
});
test('alla festa le luci da palco si muovono, e solo alla festa', () => {
globalThis.Image = class { set src(v) {} };
try {
const ops = [], gradient = { addColorStop() {} };
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { ops.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
const g = fromPiazza();
for (const scene of ZONE.scenes) {
g.go(scene); settle(g);
const lights = story.scenes[scene].effects.filter(e => e.phase === 'ingorgo');
assert.ok(lights.length, `${scene}: le luci`);
const frame = time => { ops.length = 0; render(ctx, g, { time }); return JSON.stringify(ops.filter(([op]) => op === 'lineTo' || op === 'fillRect')); };
assert.notEqual(frame(1000), frame(1700), `${scene}: le luci si muovono`);
}
g.go('via_cipriani'); settle(g); g.go('piazza'); settle(g);
ops.length = 0; render(ctx, g, { time: 1000 });
assert.ok(!ops.some(([op, x, y]) => op === 'moveTo' && x === 116 && y === 146), 'di giorno niente teste mobili');
} finally { delete globalThis.Image; }
});
test('le musiche e gli sfondi della festa esistono', () => {
const files = new Set();
for (const id of ZONE.scenes) {
const sc = story.scenes[id];
for (const st of sc.stages ?? []) files.add(st.music);
for (const b of sc.backdrops ?? []) if (b.when === 'ingorgo') files.add(b.src);
}
files.add(story.closeups.manifesto_ingorgo.picture);
assert.ok(files.size >= 10);
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
});
+17 -6
View File
@@ -5,6 +5,18 @@ import { drawPoker, cardAt, HAND } from '../src/engine/poker-render.js';
import { Game, MINIGAMES, TRIP } from '../src/engine/game.js'; import { Game, MINIGAMES, TRIP } from '../src/engine/game.js';
import { story } from '../src/story/story.js'; import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
import { PARTI, ARRIVA } from '../src/engine/corsa.js';
// I primi piani che vanno da soli (il cruscotto, la partenza, l'arrivo): si lascia scorrere il tempo finché finiscono.
function ride(g, seen = []) {
for (let t = 0; t < TRIP.end + PARTI.end + ARRIVA.end + 2; t += 0.05) {
if (g.closeup && seen.at(-1) !== g.closeup.id) seen.push(g.closeup.id);
if (!g.closeup && !g.queue.length && !g.speech) break;
if (g.speech) g.skip(); else g.update(0.05);
}
settle(g);
return seen;
}
const card = s => ({ rank: s.slice(0, -1), suit: { c: 'cuori', q: 'quadri', f: 'fiori', p: 'picche' }[s.at(-1)] }); const card = s => ({ rank: s.slice(0, -1), suit: { c: 'cuori', q: 'quadri', f: 'fiori', p: 'picche' }[s.at(-1)] });
const hand = s => s.split(' ').map(card); const hand = s => s.split(' ').map(card);
@@ -71,8 +83,7 @@ test('la macchina del tempo: l\'Arcu la fa comparire, il viaggio porta al Barret
assert.ok(!g.dialogue, 'finito il discorso, si è pronti a partire'); assert.ok(!g.dialogue, 'finito il discorso, si è pronti a partire');
g.act('use', 'macchina_tempo'); settle(g); g.act('use', 'macchina_tempo'); settle(g);
assert.equal(g.closeup?.id, 'viaggio'); assert.equal(g.closeup?.id, 'viaggio');
for (let t = 0; t < TRIP.end + 0.5 && g.closeup; t += 0.05) g.update(0.05); assert.deepEqual(ride(g), ['viaggio', 'partenza'], 'il cruscotto, poi la partenza da fuori');
settle(g);
assert.equal(g.state.scene, 'barrettino_1997'); assert.equal(g.state.scene, 'barrettino_1997');
g.act('talk', 'tavola'); settle(g); g.act('talk', 'tavola'); settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('C\'è posto'))); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('C\'è posto'))); settle(g);
@@ -82,7 +93,8 @@ test('la macchina del tempo: l\'Arcu la fa comparire, il viaggio porta al Barret
g.closeupClose(); settle(g); g.closeupClose(); settle(g);
assert.ok(g.state.flags.poker_vinto); assert.ok(g.state.flags.poker_vinto);
assert.ok(g.hotspot('arcu'), 'l\'Arcu è venuto anche lui'); assert.ok(g.hotspot('arcu'), 'l\'Arcu è venuto anche lui');
g.act('walk', 'porta'); settle(g); g.act('walk', 'porta');
assert.deepEqual(ride(g), ['arrivo'], 'al ritorno, l\'arrivo col fulmine');
assert.equal(g.state.scene, 'via_liguri_magelli'); assert.equal(g.state.scene, 'via_liguri_magelli');
assert.ok(!g.state.flags.macchina_trappola && !g.hotspot('macchina_tempo'), 'tornati, la macchina sparisce'); assert.ok(!g.state.flags.macchina_trappola && !g.hotspot('macchina_tempo'), 'tornati, la macchina sparisce');
assert.equal(g.goBack(), false, 'nel 2007 il pulsante non c\'è'); assert.equal(g.goBack(), false, 'nel 2007 il pulsante non c\'è');
@@ -91,11 +103,10 @@ test('la macchina del tempo: l\'Arcu la fa comparire, il viaggio porta al Barret
g.choose(g.options().findIndex(o => o.text.startsWith('Bei tempi'))); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Bei tempi'))); settle(g);
assert.ok(!g.dialogue, 'finito il discorso, si è pronti a partire'); assert.ok(!g.dialogue, 'finito il discorso, si è pronti a partire');
g.act('use', 'macchina_tempo'); settle(g); g.act('use', 'macchina_tempo'); settle(g);
for (let t = 0; t < TRIP.end + 0.5 && g.closeup; t += 0.05) g.update(0.05); ride(g);
settle(g);
assert.equal(g.state.scene, 'barrettino_1997'); assert.equal(g.state.scene, 'barrettino_1997');
assert.equal(g.scene.back.label, 'Torna al 2007'); assert.equal(g.scene.back.label, 'Torna al 2007');
assert.ok(g.goBack()); settle(g); assert.ok(g.goBack()); ride(g);
assert.equal(g.state.scene, 'via_liguri_magelli'); assert.equal(g.state.scene, 'via_liguri_magelli');
assert.ok(!g.hotspot('macchina_tempo')); assert.ok(!g.hotspot('macchina_tempo'));
}); });
+2 -1
View File
@@ -135,7 +135,8 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
const g = new Game(story); const g = new Game(story);
g.go(story.start.scene, story.start.at); g.go(story.start.scene, story.start.at);
settle(g); settle(g);
const ids = story.phases.map(p => p.id), seen = [g.phase().id]; // La notte dell'Ingorgo è un extra fuori dalla soluzione: la storia non ci passa.
const ids = story.phases.map(p => p.id).filter(id => id !== 'ingorgo'), seen = [g.phase().id];
for (const [verb, target, item] of solution) { for (const [verb, target, item] of solution) {
if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item); if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item);
settle(g, { explore: true }); settle(g, { explore: true });
+14
View File
@@ -75,3 +75,17 @@ test('Via Liguri Magelli: prima la Ghiacciomobile nel muro (senza targa), dal tr
assert.ok(g.hotspot('targa') && !g.hotspot('ghiacciomobile')); assert.ok(g.hotspot('targa') && !g.hotspot('ghiacciomobile'));
assert.ok(existsSync(new URL('../assets/scenes/via-liguri-magelli-muro.png', import.meta.url))); assert.ok(existsSync(new URL('../assets/scenes/via-liguri-magelli-muro.png', import.meta.url)));
}); });
test('lo zio Mario: davanti alla Ghiacciomobile fino al tramonto, poi nel suo orto', () => {
const g = new Game(story);
g.go('via_liguri_magelli'); settle(g);
assert.ok(g.hotspot('zio_mario'));
g.go('orti_zio_mario'); settle(g);
assert.ok(!g.hotspot('zio_mario'));
g.state.flags.indizio_regolo = true;
assert.ok(g.hotspot('zio_mario'));
assert.ok(inside(story.scenes.orti_zio_mario.walk, g.hotspot('zio_mario').at));
g.act('talk', 'zio_mario'); settle(g);
g.go('via_liguri_magelli'); settle(g);
assert.ok(!g.hotspot('zio_mario'));
});
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""I segnaposto delle console dell'Ingorgo Sonoro, sintetizzati da zero: quattro loop da sedici battute, uno per genere.
Nel gioco ora suonano i brani di Andrea (Suno, 30 ottobre: assets/raw/musica/ingorgo-*-suno.mp3, portati al volume
del gioco in assets/audio/ingorgo-*.mp3); questi restano in assets/raw/musica/ingorgo-*-sintesi.mp3, per prova.
python3 tools/ingorgo_musiche.py # tutti e quattro (serve ffmpeg)
python3 tools/ingorgo_musiche.py house # uno solo
Generi: latino (dembow e piano montuno), house (cassa dritta, accordi d'organo in levare), techno (cassa dura,
linea acida che si apre), underground (minimale scuro, basso che rotola e colpi lontani).
"""
import math
import random
import subprocess
import sys
import tempfile
import wave
from array import array
from pathlib import Path
SR = 22050
AUDIO = Path(__file__).resolve().parent.parent / "assets" / "raw" / "musica"
NAMES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
def hz(note: str) -> float:
semis = NAMES[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
return 440 * 2 ** ((semis + 12 * (int(note[-1]) + 1) - 69) / 12)
class Track:
def __init__(self, bpm: int, bars: int = 16, seed: int = 1):
self.beat = 60 / bpm
self.buf = [0.0] * int(SR * bars * 4 * self.beat)
self.rng = random.Random(seed)
self.cache = {}
def add(self, beat: float, samples):
# Il loop gira: quello che sborda oltre la fine rientra dall'inizio.
n, i0 = len(self.buf), int(beat * self.beat * SR)
for i, x in enumerate(samples):
self.buf[(i0 + i) % n] += x
def once(self, key, make):
if key not in self.cache:
self.cache[key] = make()
return self.cache[key]
# --- I suoni: la batteria si calcola una volta e si timbra, le note una per una.
def kick(vol=0.9, top=150.0, bottom=45.0, length=0.32, punch=0.03):
out, ph = [], 0.0
for i in range(int(length * SR)):
t = i / SR
f = bottom + (top - bottom) * math.exp(-t / punch)
ph += 2 * math.pi * f / SR
out.append(vol * math.exp(-t / (length / 3.2)) * math.sin(ph))
return out
def noise(rng, vol, length, decay, bright=True):
out, prev = [], 0.0
for i in range(int(length * SR)):
x = rng.uniform(-1, 1)
y = x - prev if bright else (prev + x) * 0.5 # differenza: solo alti; media: più corpo
prev = x
out.append(vol * y * math.exp(-i / SR / decay))
return out
def clap(rng, vol=0.35):
# Tre colpi ravvicinati di rumore e una coda: il battimani.
out = [0.0] * int(0.25 * SR)
for k, d in ((0, 0.006), (0.011, 0.006), (0.022, 0.07)):
for i, x in enumerate(noise(rng, vol, 0.2, d, bright=False)):
j = int(k * SR) + i
if j < len(out):
out[j] += x
return out
def drum(freq, vol, decay, bend=0.0):
# Bongo, conga, tom: un seno che cala un poco.
out, ph = [], 0.0
for i in range(int(decay * 5 * SR)):
t = i / SR
ph += 2 * math.pi * freq * (1 + bend * math.exp(-t / 0.02)) / SR
out.append(vol * math.exp(-t / decay) * math.sin(ph))
return out
def synth(freq, length, vol, wave_="saw", cutoff=1200.0, sweep=0.0, decay=9.0, attack=0.004, release=0.03, detune=0.0, res=0.0):
"""Un oscillatore (sega o quadra, o due appena stonati) dentro un filtro passa basso a due poli, che può aprirsi
e chiudersi (`sweep`, in Hz verso cui va il taglio durante la nota). `res` dà il fischio dell'acido."""
n = int((length + release) * SR)
out, p1, p2 = [], 0.0, 0.0
lo = bp = 0.0
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
p1 = (p1 + freq / SR) % 1
p2 = (p2 + freq * (1 + detune) / SR) % 1
if wave_ == "square":
x = (1 if p1 < 0.5 else -1) * 0.7
else:
x = (2 * p1 - 1) + ((2 * p2 - 1) if detune else 0)
x *= 0.5 if detune else 1
cut = cutoff + sweep * min(1.0, t / max(length, 1e-3))
f = 2 * math.sin(math.pi * min(cut, SR / 6) / SR)
q = 1 - res
lo += f * bp
hi = x - lo - q * bp
bp += f * hi
out.append(vol * env * lo)
return out
def sine(freq, length, vol, decay=9.0, attack=0.004, release=0.05, partials=((1, 1.0),)):
n = int((length + release) * SR)
out = []
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
out.append(vol * env * sum(a * math.sin(2 * math.pi * freq * k * t) for k, a in partials))
return out
# --- I quattro generi.
def house():
tr = Track(124, seed=2)
K, H, C = kick(), tr.once("h", lambda: noise(tr.rng, 0.16, 0.08, 0.025)), clap(tr.rng, 0.32)
OH = tr.once("oh", lambda: noise(tr.rng, 0.12, 0.25, 0.09))
chords = [["A3", "C4", "E4", "G4"], ["F3", "A3", "C4", "E4"], ["D3", "F3", "A3", "C4"], ["E3", "G#3", "B3", "D4"]]
roots = ["A1", "F1", "D2", "E1"]
for bar in range(16):
b0, ch, root = bar * 4, chords[bar // 2 % 4], roots[bar // 2 % 4]
for k in range(4):
tr.add(b0 + k, K)
tr.add(b0 + k + 0.5, OH)
for s in (0.25, 0.75):
tr.add(b0 + k + s, H)
for k in (1, 3):
tr.add(b0 + k, C)
# Gli accordi d'organo in levare, corti, il cuore della house.
for s in (0.5, 1.5, 2.75, 3.5):
for note in ch:
tr.add(b0 + s, synth(hz(note), tr.beat * 0.22, 0.05, cutoff=2600, decay=0.25, detune=0.006))
# Il basso: in levare, che salta d'ottava.
for s, octv in ((0.5, 1), (1.5, 2), (2.5, 1), (3.25, 1), (3.5, 2)):
tr.add(b0 + s, synth(hz(root) * octv, tr.beat * 0.3, 0.32, wave_="square", cutoff=500, decay=0.3))
return tr
def techno():
tr = Track(132, seed=3)
K = kick(1.0, top=170, bottom=48, length=0.36, punch=0.022)
H = tr.once("h", lambda: noise(tr.rng, 0.13, 0.05, 0.012))
R = tr.once("r", lambda: noise(tr.rng, 0.1, 0.6, 0.25, bright=False)) # il ride lungo, nella seconda metà
line = ["A1", "A1", "A2", "A1", "C2", "A1", "G2", "A1", "A1", "E2", "A1", "A2", "A1", "C2", "D2", "A1"]
for bar in range(16):
b0 = bar * 4
for k in range(4):
tr.add(b0 + k, K)
tr.add(b0 + k + 0.5, H)
if bar >= 8:
tr.add(b0 + k + 0.5, R)
if bar % 4 == 3:
tr.add(b0 + 3.75, clap(tr.rng, 0.25))
# La linea acida: sedicesimi, il filtro che si apre battuta dopo battuta e poi richiude.
open_ = 300 + 1700 * (0.5 - 0.5 * math.cos(2 * math.pi * bar / 16))
for s, note in enumerate(line):
if s % 4 == 0 or tr.rng.random() < 0.8:
tr.add(b0 + s / 4, synth(hz(note), tr.beat * 0.2, 0.2, wave_="saw", cutoff=open_, sweep=-open_ * 0.6, decay=0.15, res=0.85))
return tr
def underground():
tr = Track(122, seed=4)
K = kick(0.95, top=110, bottom=42, length=0.42, punch=0.04)
H = tr.once("h", lambda: noise(tr.rng, 0.09, 0.04, 0.01))
RIM = drum(1700, 0.12, 0.012)
for bar in range(16):
b0 = bar * 4
for k in range(4):
tr.add(b0 + k, K)
for s in (0.5, 0.75):
tr.add(b0 + k + s, H)
for s in (1.75, 3.25) if bar % 2 else (2.75,):
tr.add(b0 + s, RIM)
# Il basso che rotola sui sedicesimi in levare, sempre la stessa nota: ipnotico.
for k in range(4):
for s in (0.25, 0.5, 0.75):
tr.add(b0 + k + s, synth(hz("F1"), tr.beat * 0.16, 0.24, cutoff=260, decay=0.12))
# Un blip lontano ogni tanto, con l'eco.
if bar % 2 == 1:
note = ["C5", "Ab4", "Eb5", "F4"][bar // 2 % 4]
for rep, v in ((0, 0.07), (0.75, 0.04), (1.5, 0.02)):
tr.add(b0 + 2.5 + rep, sine(hz(note), 0.06, v, decay=0.08, partials=((1, 1), (3, 0.2))))
# Il pad scuro che respira sotto, ogni quattro battute.
if bar % 4 == 0:
for note in ("F2", "Ab2", "C3"):
tr.add(b0, synth(hz(note), tr.beat * 15, 0.03, cutoff=420, sweep=300, decay=99, attack=1.5, release=1.0, detune=0.004))
return tr
def latino():
tr = Track(98, seed=5)
K = kick(0.85, top=130, bottom=50, length=0.3)
SN = tr.once("sn", lambda: [a + b for a, b in zip(noise(tr.rng, 0.22, 0.15, 0.05, bright=False), drum(220, 0.18, 0.05) + [0.0] * 9999)])
H = tr.once("h", lambda: noise(tr.rng, 0.08, 0.05, 0.015))
BONGO = (drum(380, 0.16, 0.06, bend=0.3), drum(520, 0.13, 0.05, bend=0.3))
CONGA = drum(210, 0.2, 0.12, bend=0.2)
chords = [["A3", "C4", "E4"], ["D4", "F4", "A4"], ["E3", "G#3", "B3"], ["A3", "C4", "E4"]]
roots = ["A1", "D2", "E1", "A1"]
for bar in range(16):
b0, ch, root = bar * 4, chords[bar % 4], roots[bar % 4]
# Il dembow: cassa sull'uno e sul tre, rullante sul «tre sedicesimi» e sul due e mezzo.
for k in (0, 2):
tr.add(b0 + k, K)
tr.add(b0 + k + 0.75, SN)
tr.add(b0 + k + 1.5, SN)
for k in range(8):
tr.add(b0 + k / 2, H)
for s, b in ((0.5, 0), (1.0, 1), (2.5, 0), (3.0, 1), (3.5, 1)):
tr.add(b0 + s, BONGO[b])
tr.add(b0 + 3.75, CONGA)
# Il piano montuno: accordi sincopati, voce alta raddoppiata all'ottava.
for s in (0, 0.75, 1.5, 2.5, 3.25):
for note in ch:
tr.add(b0 + s, sine(hz(note), tr.beat * 0.3, 0.05, decay=0.35, partials=((1, 1), (2, 0.4), (3, 0.15), (4, 0.08))))
# Il basso del tumbao: in anticipo sul due e mezzo e sul quattro.
for s, f in ((0, 1), (1.5, 1.5), (3, 1)):
tr.add(b0 + s, synth(hz(root) * f, tr.beat * 0.8, 0.3, wave_="square", cutoff=380, decay=0.6))
return tr
GENRES = {"house": house, "techno": techno, "underground": underground, "latino": latino}
def write(name: str, tr: Track):
peak = max(abs(x) for x in tr.buf) or 1
AUDIO.mkdir(parents=True, exist_ok=True)
out = AUDIO / f"ingorgo-{name}-sintesi.mp3"
with tempfile.TemporaryDirectory() as tmp:
w = Path(tmp) / "loop.wav"
with wave.open(str(w), "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SR)
f.writeframes(array("h", (int(x / peak * 0.9 * 32767) for x in tr.buf)).tobytes())
# Una piazza all'aperto: un filo d'eco dai muri, e il compressore delle casse.
af = "acompressor=threshold=0.25:ratio=3:attack=5:release=80,aecho=0.85:0.4:90:0.12,loudnorm=I=-18:TP=-2:LRA=7"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(out)], check=True)
print(f"-> {out} ({len(tr.buf) / SR:.0f} s)")
if __name__ == "__main__":
for name in sys.argv[1:] or GENRES:
write(name, GENRES[name]())
+6 -2
View File
@@ -36,7 +36,10 @@ def main():
p.add_argument('--height', type=int, default=120) p.add_argument('--height', type=int, default=120)
p.add_argument('--frames', default=','.join(FRAMES), help='nomi dei fotogrammi, separati da virgola') p.add_argument('--frames', default=','.join(FRAMES), help='nomi dei fotogrammi, separati da virgola')
p.add_argument('--ref', default='stand', help='fotogramma la cui altezza diventa --height') p.add_argument('--ref', default='stand', help='fotogramma la cui altezza diventa --height')
p.add_argument('--fix', default='', help='correzioni di scala per fotogramma, quando nel foglio la figura è disegnata '
'più piccola o più grande: wave=1.3,cheer=1.05')
a = p.parse_args() a = p.parse_args()
fix = {k: float(v) for k, v in (kv.split('=') for kv in a.fix.split(',') if kv)}
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in a.frames.split(',')} frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in a.frames.split(',')}
ref = frames[a.ref].getchannel('A').getbbox() ref = frames[a.ref].getchannel('A').getbbox()
@@ -45,10 +48,11 @@ def main():
for f, im in frames.items(): for f, im in frames.items():
im = im.crop(im.getchannel('A').getbbox()) im = im.crop(im.getchannel('A').getbbox())
cx, base = torso_center(im) cx, base = torso_center(im)
w, h = max(1, round(im.width * factor)), max(1, round(im.height * factor)) k = factor * fix.get(f, 1)
w, h = max(1, round(im.width * k)), max(1, round(im.height * k))
im = im.resize((w, h), Image.LANCZOS) im = im.resize((w, h), Image.LANCZOS)
strip.append(im) strip.append(im)
meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * factor, 1), 'ay': h} meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * k, 1), 'ay': h}
x += w + 2 x += w + 2
sheet = Image.new('RGBA', (x, max(im.height for im in strip)), (0, 0, 0, 0)) sheet = Image.new('RGBA', (x, max(im.height for im in strip)), (0, 0, 0, 0))
for im, m in zip(strip, meta.values()): for im, m in zip(strip, meta.values()):
+2 -1
View File
@@ -19,5 +19,6 @@
"bluffava": "bleffava", "bluffava": "bleffava",
"Imposto": "Impòsto", "Imposto": "Impòsto",
"Libici": "Lìbici", "Libici": "Lìbici",
"mancano": "màncano" "mancano": "màncano",
"console": "consòl"
} }
+3
View File
@@ -73,5 +73,8 @@
}, },
"taglia": { "taglia": {
"speaker": "Aaron Dreschner" "speaker": "Aaron Dreschner"
},
"baldoria": {
"speaker": "Marcos Rudaski"
} }
} }
+5
View File
@@ -74,6 +74,11 @@ Copioni `video/trailer.mjs` (circa 1 min 30 s) e `video/trailer-extended.mjs` (c
del paese e i giochi giocati da soli). Niente clic: la scena è a tutto schermo, le scritte e gli stacchi al nero del paese e i giochi giocati da soli). Niente clic: la scena è a tutto schermo, le scritte e gli stacchi al nero
sono elementi aggiunti nella pagina, e la musica la sceglie il copione (`out/<clip>-audio.json`). Nel finale si sono elementi aggiunti nella pagina, e la musica la sceglie il copione (`out/<clip>-audio.json`). Nel finale si
cercano solo gli occhi gialli: il palio non si mostra (Andrea: si capirebbe dov'è). cercano solo gli occhi gialli: il palio non si mostra (Andrea: si capirebbe dov'è).
La extended rifatta il 30 ottobre (circa 4 min 15 s), su richiesta di Andrea: non deve mai far capire che il Regolo è
in Fortezza. Quindi niente impronte e niente fessura: «la notte prima del Palio» è la piazza scurita con le finestre
accese, il finale ha due occhi gialli nel nero, e il titolo sta sopra il panorama, perché l'illustrazione del titolo ha
il Regolo fra le mura. In più ci sono Condor, campana, pesca, basket e pokerino: a ogni fotogramma gioca da solo
un giocatore automatico (`gioca(…)`).
- Prova locale, senza VM: `OUT=/tmp/prova COPIONE=./trailer.mjs node video/prova-locale.mjs` (una schermata per - Prova locale, senza VM: `OUT=/tmp/prova COPIONE=./trailer.mjs node video/prova-locale.mjs` (una schermata per
battuta, e i tempi). battuta, e i tempi).
+165
View File
@@ -0,0 +1,165 @@
// Copione del video breve dell'Ingorgo Sonoro (Andrea, 30 ottobre: «45 secondi, che parta come... ci sono cose che non
// tornano..... forse..., e fai vedere il manifesto dell'Ingorgo e girare tra la musica»). Come i trailer
// (video/trailer.mjs): la scena a tutto schermo, scritte e stacchi al nero nella pagina, niente clic; la musica la
// sceglie il copione, una console per posto (out/ingorgo-sonoro-audio.json).
// Prova: OUT=/tmp/prova COPIONE=./ingorgo-sonoro.mjs node video/prova-locale.mjs
// Ripresa come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py <cartella> --trailer .
import { mkdirSync, writeFileSync } from "node:fs";
const c = (id, it) => ({ id, it, en: it, top: true });
export const title = { it: "La Leggenda del Regolo · l'Ingorgo Sonoro", en: "La Leggenda del Regolo · l'Ingorgo Sonoro" };
export const cues = [
c("dubbio", "Ci sono cose che non tornano…"),
c("manifesto", "il manifesto"),
c("incanto", "l'incantesimo"),
c("festa", "la festa"),
c("titolo", "La Leggenda del Regolo"),
];
const URL_GIOCO = "http://127.0.0.1:8000/";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const H = "assets/audio/ingorgo-house.mp3", L = "assets/audio/ingorgo-latino.mp3", U = "assets/audio/ingorgo-underground.mp3",
T = "assets/audio/ingorgo-techno.mp3";
export async function setup(d) {
const p = d.page;
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])");
await p.click("#new-game");
await p.click('#rione [data-rione="campomigliaio"]');
await p.click("#skip-intro");
await p.addStyleTag({ content: `
#side, #corner, #sound, #closeup-close, #bocce-controls, #briscola-controls, #tressette-controls, #scopa-controls,
#map-button, #pause-button, #spots-button, #notebook-button, #sentence { display: none !important; }
body { background: #000 !important; overflow: hidden; }
#app { position: fixed !important; inset: 0 !important; width: 100vw !important; margin: 0 !important; }
#stage, #stage.biliardo, #stage.briscola { width: 100vw !important; height: 100vh !important; }
#scene { width: 100vw !important; height: 100vh !important; }
#title .buttons, #title .version, #title .dedication { display: none !important; }
#title::before { background-image: url(assets/scenes/ingorgo-piazza.png) !important; }
#title::after { background: linear-gradient(rgba(20, 12, 30, .6), rgba(20, 12, 30, .1) 45%, rgba(20, 12, 30, .75)),
url(assets/scenes/ingorgo-piazza.png) center / contain no-repeat !important; }
#tr-nero { position: fixed; inset: 0; background: #000; opacity: 1; transition: opacity .7s; z-index: 50; pointer-events: none; }
#tr-scritta { position: fixed; left: 0; right: 0; top: 50%; transform: translateY(-50%); text-align: center; z-index: 51;
font-family: "Press Start 2P", monospace; font-size: 40px; line-height: 1.7; color: #ffe9b0; opacity: 0; white-space: pre-line;
transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; }
#tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 30px; }
` });
await p.evaluate(() => {
for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
});
await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000));
await p.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); window.voices?.stop?.(); });
d.vm("DISPLAY=:0 ~/lab/video/mv.py 1599 899 0.2");
await sleep(1200);
}
export async function run(d) {
const p = d.page;
// L'orologio dell'audio, come nei trailer: le voci che partono; la musica la decide il copione.
await p.evaluate(() => {
window.__t0 = Date.now(); window.__audio = [];
const t = () => (Date.now() - window.__t0) / 1000;
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
let voice = null;
window.__musica = (src) => window.__audio.push({ t: t(), music: src });
const look = () => {
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
if (now !== voice) {
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
voice = now;
}
requestAnimationFrame(look);
};
look();
});
const musica = (src) => p.evaluate((s) => window.__musica(s), src);
const nero = (on, s = 0.7) => p.evaluate(([on, s]) => { const e = document.getElementById("tr-nero"); e.style.transition = `opacity ${s}s`; e.style.opacity = on ? 1 : 0; }, [on, s]);
const scritta = (testo, basso = false) => p.evaluate(([t, b]) => {
const e = document.getElementById("tr-scritta");
if (t) { e.textContent = t; e.classList.toggle("basso", b); }
e.style.opacity = t ? 1 : 0;
}, [testo, basso]);
const vai = (scene, at, flags = {}) => p.evaluate(async ([scene, at, flags]) => {
const { settle } = await import('/tests/helpers.mjs');
game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.path = []; game.walking = false; game.task = null;
Object.assign(game.state.flags, flags);
game.go(scene, at); settle(game);
}, [scene, at, flags]);
const cammina = (a) => p.evaluate((a) => game.walkTo(a), a);
// 1. Il nero e il dubbio.
await musica("assets/audio/sera.mp3");
await d.cue("dubbio");
await sleep(500);
await scritta("Ci sono cose\nche non tornano…");
await sleep(2400);
await scritta(null);
await sleep(600);
await scritta("…forse…");
await sleep(1500);
await scritta(null);
await sleep(400);
// 2. La piazza di giorno: Andrea va al manifesto, che si apre ingrandito.
await d.cue("manifesto");
await musica("assets/audio/paese.mp3");
await vai("piazza", [380, 320]);
await nero(false);
await sleep(700);
await p.evaluate(() => game.act("look", "manifesto_ingorgo"));
await p.waitForFunction(() => game.closeup?.id === "manifesto_ingorgo" && game.closeup.t > 0.5, null, { timeout: 15000 });
await sleep(2000);
// Il tocco sul manifesto: la battuta sul manifesto si salta, resta il desiderio, a voce.
await p.evaluate(() => game.closeupTap());
for (let k = 0; k < 200 && !(await p.evaluate(() => game.speech?.text.startsWith("Quanto darei"))); k++) {
await p.evaluate(() => { if (game.speech) game.skip(); });
await sleep(40);
}
// 3. L'incantesimo: nel lampo parte la house della piazza.
await d.cue("incanto");
await p.waitForFunction(() => game.closeup?.id === "ingorgo", null, { timeout: 20000 });
await p.waitForFunction(() => game.closeup?.swapped, null, { timeout: 10000 });
await musica(H);
// 4. Il giro della festa: Andrea si accorge della festa camminando fra la gente (la seconda battuta si salta), poi una
// console per posto, a stacchi veloci.
await p.waitForFunction(() => !game.closeup && game.speech?.text.includes("Ingorgo"), null, { timeout: 10000 });
await d.cue("festa");
await p.evaluate(() => { game.queue.length = 0; game.walkTo([200, 300]); });
await p.waitForFunction(() => !game.speech?.text.includes("Ingorgo"), null, { timeout: 20000 });
await p.evaluate(() => { game.queue.length = 0; game.speech = null; });
await sleep(300);
for (const [scene, da, a, brano] of [["via_antica_posta", [330, 345], [300, 290], H], ["piazzetta_comune", [260, 350], [300, 300], L],
["antica_posta_alta", [240, 350], [380, 290], U], ["piazzetta", [330, 352], [420, 290], T]]) {
await nero(true, 0.2); await sleep(220);
await musica(brano);
await vai(scene, da);
await nero(false, 0.2);
await cammina(a);
await sleep(3200);
}
await sleep(300);
// 5. Il titolo sopra la piazza della festa.
await d.cue("titolo");
await nero(true, 0.9); await sleep(1000);
await p.evaluate(() => { game.closeup = null; document.getElementById("title").hidden = false; });
await nero(false, 1.2);
await sleep(1200);
await scritta("l'Ingorgo Sonoro", true);
await sleep(3000);
await scritta(null);
await nero(true, 1.4);
await sleep(1600);
const audio = await p.evaluate(() => window.__audio);
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL("ingorgo-sonoro-audio.json", out), JSON.stringify({ eventi: audio }, null, 2));
}
+1 -1
View File
@@ -12,7 +12,7 @@ try {
page.goto = url => orig(url.replace('http://127.0.0.1:8000/', `http://127.0.0.1:${server.port}/`)); page.goto = url => orig(url.replace('http://127.0.0.1:8000/', `http://127.0.0.1:${server.port}/`));
const mod = await import(process.env.COPIONE ?? './trailer.mjs'); const mod = await import(process.env.COPIONE ?? './trailer.mjs');
const t0 = Date.now(); let n = 0; const t0 = Date.now(); let n = 0;
const d = { page, vm: () => {}, cue: async id => { console.log(((Date.now() - t0) / 1000).toFixed(1), id); setTimeout(() => page.screenshot({ path: `${out}/tr-${String(++n).padStart(2, '0')}-${id}.png` }), id === 'buio' ? 5000 : 3000); } }; const d = { page, vm: () => {}, cue: async id => { console.log(((Date.now() - t0) / 1000).toFixed(1), id); setTimeout(() => page.screenshot({ path: `${out}/tr-${String(++n).padStart(2, '0')}-${id}.png` }), id === 'buio' ? 5000 : Number(process.env.SCATTO ?? 3000)); } };
await mod.setup(d); await mod.setup(d);
await mod.run(d); await mod.run(d);
console.log('fine', ((Date.now() - t0) / 1000).toFixed(1)); console.log('fine', ((Date.now() - t0) / 1000).toFixed(1));
+84 -53
View File
@@ -1,6 +1,8 @@
// Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa tre // Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa
// minuti, come il trailer (video/trailer.mjs) ma col giro completo del paese e i giochi giocati per qualche secondo: // quattro minuti, come il trailer (video/trailer.mjs) ma col giro completo del paese e i giochi giocati per qualche
// bocce, biliardo, GP di Fortezza, briscola, macchinine, cerbottana. // secondo: bocce, biliardo, GP di Fortezza, briscola, macchinine, cerbottana, Condor, campana, pesca, basket, pokerino.
// Rifatto il 30 ottobre (Andrea): niente che dica che il Regolo è in Fortezza. La notte prima del Palio è la piazza
// con le finestre accese, e nel finale ci sono solo due occhi gialli nel buio, senza muro e senza fessura.
// Si gira come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py <cartella> --trailer . // Si gira come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py <cartella> --trailer .
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
@@ -18,8 +20,13 @@ export const cues = [
c("carte", "La briscola all'oratorio"), c("carte", "La briscola all'oratorio"),
c("macchinine", "Le macchinine col gesso"), c("macchinine", "Le macchinine col gesso"),
c("cerbottana", "La cerbottana"), c("cerbottana", "La cerbottana"),
c("condor", "La Condor col Taglia"),
c("campana", "La campana"),
c("pesca", "La pesca al lago"),
c("basket", "Fai due tiri"),
c("poker", "Il pokerino del Barrettino"),
c("amici", "…e di vecchie conoscenze"), c("amici", "…e di vecchie conoscenze"),
c("buio", "la fessura"), c("buio", "due occhi gialli"),
c("titolo", "La Leggenda del Regolo"), c("titolo", "La Leggenda del Regolo"),
]; ];
@@ -52,9 +59,19 @@ export async function setup(d) {
font-family: "Press Start 2P", monospace; font-size: 42px; line-height: 1.6; color: #ffe9b0; opacity: 0; font-family: "Press Start 2P", monospace; font-size: 42px; line-height: 1.6; color: #ffe9b0; opacity: 0;
transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; } transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; }
#tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 32px; } #tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 32px; }
#scene.notte { filter: brightness(.5) saturate(.85) hue-rotate(-8deg); }
/* Il titolo sopra il panorama: l'illustrazione del gioco ha il Regolo fra le mura della Fortezza. */
#title::before { background-image: url(assets/scenes/panorama-intro.png) !important; }
#title::after { background: linear-gradient(rgba(20, 12, 30, .55), rgba(20, 12, 30, .05) 45%, rgba(20, 12, 30, .7)),
url(assets/scenes/panorama-intro.png) center / contain no-repeat !important; }
#tr-occhi { position: fixed; left: 50%; top: 44%; z-index: 51; display: flex; gap: 46px; transform: translateX(-50%);
opacity: 0; transition: opacity 1.4s; pointer-events: none; }
#tr-occhi i { width: 22px; height: 22px; background: #ffe14a; box-shadow: 0 0 18px 6px rgba(255, 210, 40, .55); }
#tr-occhi.chiusi i { height: 3px; margin-top: 10px; }
` }); ` });
await p.evaluate(() => { await p.evaluate(() => {
for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); } for (const id of ["tr-nero", "tr-scritta", "tr-occhi"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
document.getElementById("tr-occhi").innerHTML = "<i></i><i></i>";
}); });
// Senza nessun tocco il gioco va in pausa da solo dopo un minuto: un finto movimento ogni tanto lo tiene sveglio. // Senza nessun tocco il gioco va in pausa da solo dopo un minuto: un finto movimento ogni tanto lo tiene sveglio.
await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000)); await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000));
@@ -122,16 +139,16 @@ export async function run(d) {
await fuoco(vai("via_cafaggio")); await fuoco(vai("via_cafaggio"));
await sleep(3200); await sleep(3200);
// 2. La Fortezza al tramonto, le impronte. // 2. La piazza di sera, con le finestre accese (niente Fortezza e niente impronte: si capirebbe dov'è il Regolo).
await d.cue("notte"); await d.cue("notte");
await fuoco(vai("fortezza", null, { indizio_regolo: true })); await fuoco(`async () => { await (${vai("piazza", null, { casetta_aperta: true })})(); document.getElementById("scene").classList.add("notte"); }`);
await scritta("La notte prima del Palio…", true); await scritta("La notte prima del Palio…", true);
await sleep(3000); await sleep(3000);
await scritta(null); await scritta(null);
// 3. La scuola: il palio non c'è più. // 3. La scuola: il palio non c'è più.
await d.cue("sparito"); await d.cue("sparito");
await fuoco(vai("scuola", [300, 330], { indizio_regolo: false })); await fuoco(`async () => { await (${vai("scuola", [300, 330], { casetta_aperta: false })})(); document.getElementById("scene").classList.remove("notte"); }`);
await scritta("…il palio è sparito.", true); await scritta("…il palio è sparito.", true);
await sleep(3200); await sleep(3200);
await scritta(null); await scritta(null);
@@ -145,7 +162,7 @@ export async function run(d) {
["orti_zio_mario", [400, 352], [330, 260]], ["stazione", [320, 350], [450, 290]], ["cardetole", [300, 350], [470, 300]], ["terrazza", [300, 330], [420, 320]]]) { ["orti_zio_mario", [400, 352], [330, 260]], ["stazione", [320, 350], [450, 290]], ["cardetole", [300, 350], [470, 300]], ["terrazza", [300, 330], [420, 320]]]) {
await fuoco(vai(scene, da), true); await fuoco(vai(scene, da), true);
await p.evaluate((a) => game.walkTo(a), a); await p.evaluate((a) => game.walkTo(a), a);
await sleep(3300); await sleep(2800);
} }
// 5. Le sfide, giocate davvero per qualche secondo: ogni gioco col suo nome in basso e la sua musica. // 5. Le sfide, giocate davvero per qualche secondo: ogni gioco col suo nome in basso e la sua musica.
@@ -155,7 +172,8 @@ export async function run(d) {
await scritta("Un paese pieno di sfide"); await scritta("Un paese pieno di sfide");
await sleep(2000); await sleep(2000);
await scritta(null); await scritta(null);
// Apre il primo piano, poi ogni 150 ms chiama `mossa` (il giocatore che gioca da solo) finché passano i secondi. // Apre il primo piano, poi a ogni fotogramma chiama `mossa` (il giocatore che gioca da solo) finché passano i
// secondi. Dentro `mossa`, `pronto(ms)` dice se dall'ultima mossa è passato abbastanza: così non gioca a raffica.
const gioca = async (cue, nome, brano, closeup, level, modulo, prima, mossa, secondi) => { const gioca = async (cue, nome, brano, closeup, level, modulo, prima, mossa, secondi) => {
await d.cue(cue); await d.cue(cue);
await musica(brano); await musica(brano);
@@ -165,27 +183,51 @@ export async function run(d) {
game.run({ closeup: '${closeup}', level: '${level}' }); settle(game); game.run({ closeup: '${closeup}', level: '${level}' }); settle(game);
window.__m = await import('${modulo}'); window.__m = await import('${modulo}');
const M = window.__m, c = game.closeup; ${prima} const M = window.__m, c = game.closeup; ${prima}
const mossa = new Function('M', 'c', 'pronto', ${JSON.stringify(mossa)});
let ultima = performance.now();
const pronto = (ms) => performance.now() - ultima > ms && (ultima = performance.now(), true);
const giro = () => { if (game.closeup !== c) return; mossa(M, c, pronto); requestAnimationFrame(giro); };
giro();
}`); }`);
await scritta(nome, true); await scritta(nome, true);
const fine = Date.now() + secondi * 1000; await sleep(3000);
while (Date.now() < fine) { await scritta(null);
await p.evaluate(`(() => { const M = window.__m, c = game.closeup; if (!c) return; ${mossa} })()`); await sleep((secondi - 3) * 1000);
if (Date.now() > fine - (secondi - 3) * 1000) await scritta(null);
await sleep(150);
}
}; };
await gioca("bocce", "Le bocce al pallaio", "assets/audio/bocce.mp3", "bocce", "seria", "/src/engine/bocce.js", "", await gioca("bocce", "Le bocce al pallaio", "assets/audio/bocce.mp3", "bocce", "seria", "/src/engine/bocce.js", "",
"if (M.canThrow(c)) { c.aim = { u: c.jack.u, w: c.jack.w }; c.power = M.powerFor(Math.hypot(c.jack.u, c.jack.w) - 1.5); M.shoot(c); }", 14); "if (M.canThrow(c) && pronto(500)) { c.aim = { u: c.jack.u, w: c.jack.w }; c.power = M.powerFor(Math.hypot(c.jack.u, c.jack.w) - 1.5); M.shoot(c); }", 11);
await gioca("biliardo", "Il biliardo del Circolo", "assets/audio/biliardo.mp3", "biliardo", "seria", "/src/engine/biliardo.js", "", await gioca("biliardo", "Il biliardo del Circolo", "assets/audio/biliardo.mp3", "biliardo", "seria", "/src/engine/biliardo.js", "",
"if (c.step === 'aim' && c.current === 'player' && !c.holding) M.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x) + 0.04, 0.75);", 14); "if (c.step === 'aim' && c.current === 'player' && !c.holding && pronto(400)) M.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x) + 0.04, 0.75);", 11);
await gioca("gara", "Il GP di Fortezza", "assets/audio/gara.mp3", "gara", "bona", "/src/engine/gara.js", await gioca("gara", "Il GP di Fortezza", "assets/audio/gara.mp3", "gara", "bona", "/src/engine/gara.js",
"while (c.step === 'intro') M.press(c); M.release(c);", "c.holding = c.step === 'race' && !M.mustLift(c);", 16); "while (c.step === 'intro') M.press(c); M.release(c);", "c.holding = c.step === 'race' && !M.mustLift(c);", 13);
await gioca("carte", "La briscola all'oratorio", "assets/audio/carte.mp3", "briscola", "seria", "/src/engine/briscola.js", "", await gioca("carte", "La briscola all'oratorio", "assets/audio/carte.mp3", "briscola", "seria", "/src/engine/briscola.js", "",
"if (M.canPlay(c)) M.play(c, 0);", 12); "if (M.canPlay(c) && pronto(700)) M.play(c, 0);", 10);
await gioca("macchinine", "Le macchinine col gesso", "assets/audio/macchinine.mp3", "micro", "bona", "/src/engine/micro.js", "M.pick(c, 0);", await gioca("macchinine", "Le macchinine col gesso", "assets/audio/macchinine.mp3", "micro", "bona", "/src/engine/micro.js", "M.pick(c, 0);",
"if (M.canThrow(c)) { const car = c.cars.player, i = (car.idx + Math.round(110 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; M.strike(c, 'player', Math.atan2(y - car.y, x - car.x), M.powerFor(Math.hypot(x - car.x, y - car.y))); }", 12); "if (M.canThrow(c) && pronto(400)) { const car = c.cars.player, i = (car.idx + Math.round(110 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; M.strike(c, 'player', Math.atan2(y - car.y, x - car.x), M.powerFor(Math.hypot(x - car.x, y - car.y))); }", 10);
await gioca("cerbottana", "La cerbottana", "assets/audio/gara.mp3", "cerbottana", "seria", "/src/engine/cerbottana.js", "", await gioca("cerbottana", "La cerbottana", "assets/audio/gara.mp3", "cerbottana", "seria", "/src/engine/cerbottana.js", "",
"if (M.canThrow(c)) { const k = c.cans.find(k => !k.down); c.aim = k && c.left % 2 ? { x: k.x, y: M.LEDGE - 8 } : { x: 320 + (c.left % 3 - 1) * 6, y: 118 }; c.power = M.BEST; M.shoot(c); }", 10); "if (M.canThrow(c) && pronto(400)) { const k = c.cans.find(k => !k.down); c.aim = k && c.left % 2 ? { x: k.x, y: M.LEDGE - 8 } : { x: 320 + (c.left % 3 - 1) * 6, y: 118 }; c.power = M.BEST; M.shoot(c); }", 9);
// La Condor: le lattine sulla panchina, le bottiglie, il palloncino; il pallino cala, si mira un po' sopra.
await gioca("condor", "La Condor col Taglia", "assets/audio/gara.mp3", "condor", "bona", "/src/engine/condor.js", "",
"if (!M.canThrow(c) || c.holding || !pronto(800)) return; const su = x => M.DROP * M.far(x) ** 2; const k = c.cans.find(k => !k.down), b = c.bottles.find(b => !b.down);" +
" if (k) c.aim = { x: k.x, y: M.BENCH - M.CAN.h / 2 - su(k.x) }; else if (b) c.aim = { x: b.x, y: M.BAR - M.BOTTLE.h / 2 - su(b.x) };" +
" else if (!c.balloon.popped) { const p = M.balloon(c); c.aim = { x: p.x, y: p.y - su(p.x) }; } else c.aim = { x: M.CARTONE.x, y: M.CARTONE.y - su(M.CARTONE.x) }; M.shoot(c);", 10);
// La campana: il sassolino nella casella giusta, poi i salti quando la lancetta passa nel verde; poi tocca al Taglia.
await gioca("campana", "La campana", "assets/audio/macchinine.mp3", "campana", "bona", "/src/engine/campana.js", "",
"if (c.step === 'aim' && c.current === 'player' && pronto(700)) { const p = M.center(c.target.player); c.skill.scatter = 0; c.aim = { x: p.x, y: p.y }; c.power = Math.hypot(p.x - M.FROM.x, p.y - M.FROM.y) / M.REACH; M.shoot(c); }" +
" else if (c.step === 'hop' && (!c.hop || c.hop.t >= M.HOP) && c.meter?.t > 0 && Math.abs(M.needle(c)) < 0.08) M.press(c, 1);", 15);
// La pesca: il galleggiante abbocca presto (nel trailer non si aspetta), si ferra e si recupera senza strappare.
await gioca("pesca", "La pesca al lago", "assets/audio/paese.mp3", "pesca", "bona", "/src/engine/pesca.js", "",
"const f = c.float; if (c.step === 'aim' && pronto(800)) { c.power = 0.45; M.shoot(c); }" +
" else if (c.step === 'wait' && f) { if (!f.svelto && f.biteAt) { const prima = Math.max(0, f.biteAt - f.t - 1.6); f.biteAt -= prima; f.nibbles.forEach(n => n.at -= prima); f.svelto = true; }" +
" if (M.biting(c)) { M.strike(c); c.reeling = c.step === 'fight'; } } else if (c.step === 'fight') c.reeling = c.tension < 0.8;", 15);
// Fai due tiri: per ogni postazione si cerca prima, su una copia, un tiro che entra.
await gioca("basket", "Fai due tiri", "assets/audio/gara.mp3", "basket", "bona", "/src/engine/basket.js", "",
"if (!M.canThrow(c) || c.holding || !pronto(600)) return; let best = { angle: 55, power: 0.7 };" +
" cerca: for (let angle = 46; angle <= 66; angle += 4) for (let power = 0.3; power <= 1; power += 0.02) { const p = structuredClone(c); M.setAngle(p, angle); M.setPower(p, power); M.shoot(p);" +
" for (let i = 0; i < 2000 && p.step === 'flight'; i++) M.update(p, 1 / 120); if (p.ball.scored) { best = { angle, power }; break cerca; } }" +
" M.setAngle(c, best.angle); M.setPower(c, best.power); M.shoot(c);", 11);
await gioca("poker", "Il pokerino del Barrettino", "assets/audio/carte.mp3", "poker", "seria", "/src/engine/poker.js", "",
"if (M.canDraw(c) && pronto(900)) M.choose(c, 'cambia'); else if (M.myTurn(c) && pronto(900)) M.choose(c, 'vedo'); else if (c.step === 'show' && pronto(2500)) M.advance(c);", 11);
await musica("assets/audio/paese.mp3"); await musica("assets/audio/paese.mp3");
// 6. Le vecchie conoscenze, con le loro battute. // 6. Le vecchie conoscenze, con le loro battute.
@@ -207,40 +249,29 @@ export async function run(d) {
await finitoDiDire(); await finitoDiDire();
await sleep(400); await sleep(400);
// 7. Il buio: la sera, la torcia nella fessura, gli occhi gialli. // 7. Il buio: solo due occhi gialli che si aprono, guardano e sbattono (Andrea, 30 ottobre: niente Fortezza, niente
// fessura e niente palio, sennò si capisce dov'è il Regolo).
await d.cue("buio"); await d.cue("buio");
// Sotto il buio una musica bassa di suspense (Andrea: «un po' di musichetta nel finale»).
await musica("assets/audio/sera.mp3"); await musica("assets/audio/sera.mp3");
await fuoco(vai("fortezza", [462, 310], { indizio_regolo: true, casetta_aperta: true })); await p.evaluate(() => { document.getElementById("tr-nero").style.transition = "opacity .9s"; });
await sleep(900);
await p.evaluate(() => { game.state.inventory.push("torcia"); game.act("use", "fessura", "torcia"); });
// «Accendo la torcia e la punto dentro…» si salta: il primo piano si apre subito.
// Il palio non si vede (Andrea: si capirebbe che è in Fortezza): la luce parte da destra, nel buio, e i primi due
// ritrovamenti si danno per fatti; si cercano solo gli occhi.
for (let k = 0; k < 60 && await p.evaluate(() => game.closeup?.id !== "fessura"); k++) { await p.evaluate(() => game.skip()); await sleep(50); }
await p.evaluate(() => { const c = game.closeup; c.found = 2; c.beam = [600, 300]; });
await sleep(1600);
const targets = await p.evaluate(() => game.story.closeups.fessura.targets.map((t) => t.at));
for (let i = 2; i < targets.length; i++) {
if (i === 2) await musica("assets/audio/cartello.mp3");
// La luce va piano verso il punto, come una mano che cerca: prima in basso, poi su verso gli occhi.
const from = await p.evaluate(() => game.closeup.beam);
for (let k = 1; k <= 30; k++) {
const x = from[0] + (targets[i][0] - from[0]) * k / 30 + Math.sin(k / 4) * 12, y = from[1] + (targets[i][1] - from[1]) * k / 30;
await p.evaluate(([x, y]) => game.closeupAim([x, y]), [x, y]);
await sleep(50);
}
// Le battute dei primi ritrovamenti si saltano (partono prima che il ritrovamento conti, e lo fermano); resta solo
// l'ultima: «…sono due occhi gialli!».
for (let k = 0; k < 300 && await p.evaluate((i) => !(game.closeup?.found > i), i); k++) {
if (i < 2) await p.evaluate(() => game.skip());
await sleep(100);
}
await p.waitForFunction(() => !game.speech && !game.closeup?.lines?.length, null, { timeout: 30000 });
await sleep(i === 2 ? 1400 : 100);
}
await nero(true); await nero(true);
await sleep(1400); await prepara(`async () => { game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.state.x = -400; }`);
await sleep(1800);
await musica("assets/audio/cartello.mp3");
const occhi = (fn) => p.evaluate(fn);
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 1; });
await sleep(2200);
for (const pausa of [140, 2200, 110, 140, 110]) {
await occhi(() => document.getElementById("tr-occhi").classList.toggle("chiusi"));
await sleep(pausa);
}
await dice(null, "E quelli... sono due occhi gialli!");
await finitoDiDire();
await sleep(900);
await occhi(() => { document.getElementById("tr-occhi").classList.add("chiusi"); });
await sleep(150);
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 0; });
await sleep(1500);
// 8. Il titolo. // 8. Il titolo.
await d.cue("titolo"); await d.cue("titolo");