Compare commits

...
20 Commits
Author SHA1 Message Date
manzolo fde68603cb Versione 0.1.47 2026-10-29 15:27:10 +01:00
manzoloandClaude Opus 5.5 686fee043e La cerbottana: dalla bottega di Pierin di Frilli in piazza lo stucco verde, poi i pallini contro il muro della piazza (fiato, respiro, tosse, bersaglio di gesso, lattine, barattolo appeso; muro di Codex) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 15:16:40 +01:00
manzoloandClaude Opus 5.5 4b709ffd80 Il terrazzo di casa a Cardetole: la vista di Andrea dalla finestra col paese e la Fortezza (Codex dalla sua foto, col terrazzo davanti); ci si sale da casa di Andrea (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 14:52:06 +01:00
manzoloandClaude Opus 5.5 733680301c Lo zio Mario: «I problemi io gli avevo quando andavo a scuola!» (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 13:57:32 +01:00
manzoloandClaude Opus 5.5 0ba8cfc800 Lo zio Mario: il primo scambio («Zio, hai problemi?» / «I problemi io gli avevo a scuola!») (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 13:47:48 +01:00
manzolo 84ad19a47e Versione 0.1.46 2026-10-29 13:43:52 +01:00
manzoloandClaude Opus 5.5 058acd6325 Macchinine: la loro musica, di Andrea con Suno (al posto di quella del GP in prestito) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 13:35:47 +01:00
manzolo 497209fc9a Versione 0.1.45 2026-10-29 13:22:28 +01:00
manzoloandClaude Opus 5.5 2289480812 Lo zio Mario davanti alla Ghiacciomobile nel muro, che si gratta la testa (sprite di Codex dalla foto di Andrea); Via Liguri Magelli con le persone in proporzione (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 13:12:17 +01:00
manzolo de418c0da8 Versione 0.1.44 2026-10-29 12:50:32 +01:00
manzoloandClaude Opus 5.5 713e328138 La Ghiacciomobile schiantata nel muro giallo di Via Liguri Magelli, coi mattoni rotti e senza targa (ritocco di Codex), fino al tramonto; poi il muro rifatto con la targa. Non è più al circolino. Il motore ha le varianti di sfondo per scena (backdrops) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 12:44:00 +01:00
manzolo 6a5a80d7af Versione 0.1.43 2026-10-29 11:28:23 +01:00
manzoloandClaude Opus 5.5 88f0439f79 GP di Fortezza: la presentazione dei piloti a tutto schermo (i comandi arrivano col semaforo); nella guida semplice il pulsante Gas si tiene premuto, più alto e con la scritta fissa (sul telefono prendeva male) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 11:18:46 +01:00
manzolo c244424af9 Versione 0.1.42 2026-10-29 10:45:40 +01:00
manzoloandClaude Opus 5.5 c26d888263 GP di Fortezza, Seria semplice più difficile (parere di Codex, simulato): sopra il limite in curva si esce di sicuro, la curva pulita dà una spinta, rivali a 0,81; CURVA! e LASCIA! sopra il motorino (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 10:38:10 +01:00
manzolo f8dbd01358 Versione 0.1.41 2026-10-29 10:27:45 +01:00
manzoloandClaude Opus 5.5 e3d5d8b0d3 Macchinine: primo e secondo accanto ai nomi mentre si va; nel risultato il perché (chi è arrivato a quale spinta, e se ci arrivano tutti e due vince chi va più in là, di quanti centimetri) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 10:20:46 +01:00
manzoloandClaude Opus 5.5 1b7511f6ca GP di Fortezza: guida semplice alla bona e Seria (si tiene premuto per il gas, lasciando si frena, lo sterzo va da solo e troppo forte in curva si scivola nella paglia), per il telefono; Ginocchio in terra resta con lo sterzo vero; prova nel browser del GP (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 10:18:22 +01:00
manzolo 9d2cc2fcb3 Versione 0.1.40 2026-10-29 10:11:22 +01:00
manzoloandClaude Opus 5.5 44480221db Macchinine: si sceglie la macchinina, la forza sale da zero tenendo premuto e la spinta parte lasciando; il saluto col Chiaru («un girino alle case rosse» / «Vorrai dire un giochino!» / «Ciao Mattè»), «Si fa una gara alle macchinine?!?!», Andrea chiama Bertovo «Fabri»; musica del GP in prestito (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 10:04:46 +01:00
83 changed files with 1254 additions and 153 deletions
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
{
"image": "zio_mario.png",
"height": 120,
"frames": {
"explain": {
"x": 0,
"y": 0,
"w": 67,
"h": 120,
"ax": 33.8,
"ay": 120
},
"talk": {
"x": 69,
"y": 0,
"w": 79,
"h": 120,
"ax": 44.8,
"ay": 120
},
"blink": {
"x": 150,
"y": 0,
"w": 57,
"h": 120,
"ax": 28.1,
"ay": 120
},
"stand": {
"x": 209,
"y": 0,
"w": 57,
"h": 120,
"ax": 27.8,
"ay": 120
},
"shrug": {
"x": 268,
"y": 0,
"w": 94,
"h": 120,
"ax": 48.1,
"ay": 120
},
"point": {
"x": 364,
"y": 0,
"w": 87,
"h": 120,
"ax": 51.0,
"ay": 120
},
"think": {
"x": 453,
"y": 0,
"w": 58,
"h": 121,
"ax": 29.2,
"ay": 121
},
"laugh": {
"x": 513,
"y": 0,
"w": 59,
"h": 121,
"ax": 30.1,
"ay": 121
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+124 -32
View File
@@ -95,6 +95,10 @@
"file": "andrea-a9df2e6bbb.mp3", "file": "andrea-a9df2e6bbb.mp3",
"sec": 4.98 "sec": 4.98
}, },
"andrea|La bottega di Pierin di Frilli, sotto la tenda rossa. Da ragazzi ci si comprava lo stucco per la cerbottana.": {
"file": "andrea-cb15f8f919.mp3",
"sec": 7.29
},
"andrea|Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.": { "andrea|Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.": {
"file": "andrea-f0aef83c80.mp3", "file": "andrea-f0aef83c80.mp3",
"sec": 9.43 "sec": 9.43
@@ -1187,14 +1191,6 @@
"file": "andrea-00f2e70a4d.mp3", "file": "andrea-00f2e70a4d.mp3",
"sec": 2.95 "sec": 2.95
}, },
"andrea|La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": {
"file": "andrea-7be29434e0.mp3",
"sec": 7.25
},
"andrea|Con questo sole, di ghiaccio c'è rimasto giusto il nome. Vo a piedi.": {
"file": "andrea-def44809bc.mp3",
"sec": 5.08
},
"andrea|Il circolino: il Circolo SMS di Campomigliaio. Un'istituzione, qui i volontari hanno sempre fatto miracoli.": { "andrea|Il circolino: il Circolo SMS di Campomigliaio. Un'istituzione, qui i volontari hanno sempre fatto miracoli.": {
"file": "andrea-90bbad0de0.mp3", "file": "andrea-90bbad0de0.mp3",
"sec": 9.59 "sec": 9.59
@@ -1627,6 +1623,14 @@
"file": "andrea-7c17aa384c.mp3", "file": "andrea-7c17aa384c.mp3",
"sec": 3.2 "sec": 3.2
}, },
"andrea|La Ghiacciomobile nera, mezza dentro il muro. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": {
"file": "andrea-decbc1c807.mp3",
"sec": 9.49
},
"andrea|Per tirarla fuori di lì ci vuole altro che le mie braccia.": {
"file": "andrea-a193a43f3e.mp3",
"sec": 3.78
},
"andrea|Nessuno ha mai capito davvero il tuo immenso valore.": { "andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
"file": "andrea-3e85200509.mp3", "file": "andrea-3e85200509.mp3",
"sec": 2.93 "sec": 2.93
@@ -1639,9 +1643,17 @@
"file": "andrea-3d15dd47bb.mp3", "file": "andrea-3d15dd47bb.mp3",
"sec": 4.82 "sec": 4.82
}, },
"andrea|Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.": { "andrea|Lo zio Mario. Guarda il muro, guarda la macchina, e si gratta la testa sotto il berretto.": {
"file": "andrea-8d5911c8e7.mp3", "file": "andrea-9da3d936d3.mp3",
"sec": 4.53 "sec": 6.14
},
"andrea|Zio, hai problemi?": {
"file": "andrea-c36f0dd2c5.mp3",
"sec": 1.52
},
"zio_mario|I problemi io gli avevo quando andavo a scuola!": {
"file": "zio_mario-b3e99c241a.mp3",
"sec": 2.68
}, },
"andrea|Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.": { "andrea|Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.": {
"file": "andrea-8670e6ce99.mp3", "file": "andrea-8670e6ce99.mp3",
@@ -1651,10 +1663,6 @@
"file": "andrea-6c9e7f684e.mp3", "file": "andrea-6c9e7f684e.mp3",
"sec": 8.72 "sec": 8.72
}, },
"andrea|Se salgo, mi mettono ad apparecchiare. Prima il palio.": {
"file": "andrea-8945f1786d.mp3",
"sec": 3.89
},
"andrea|Il prato sotto casa. Qui le porte le facevamo con le maglie per terra: due maglie, un palo ciascuna.": { "andrea|Il prato sotto casa. Qui le porte le facevamo con le maglie per terra: due maglie, un palo ciascuna.": {
"file": "andrea-4708fda5ef.mp3", "file": "andrea-4708fda5ef.mp3",
"sec": 6.92 "sec": 6.92
@@ -1675,6 +1683,54 @@
"file": "andrea-f6c10fd559.mp3", "file": "andrea-f6c10fd559.mp3",
"sec": 6.28 "sec": 6.28
}, },
"andrea|Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.": {
"file": "andrea-8d5911c8e7.mp3",
"sec": 4.53
},
"andrea|La vista di casa. Da qui si vede mezzo paese, e lassù la Fortezza.": {
"file": "andrea-8a63e250ae.mp3",
"sec": 5.64
},
"andrea|Lassù in cima, fra gli alberi, la Fortezza. Da qui pare piccina.": {
"file": "andrea-6544c0d778.mp3",
"sec": 6.17
},
"andrea|Il paese che sale verso la collina, coi tetti uno sull'altro e il campanile in mezzo.": {
"file": "andrea-b082638d5a.mp3",
"sec": 5.93
},
"andrea|Il palazzo accanto, di mattoni come il nostro.": {
"file": "andrea-8d0507f514.mp3",
"sec": 2.45
},
"andrea|I gerani. Guai a chi li tocca.": {
"file": "andrea-54d8dfd381.mp3",
"sec": 2.86
},
"andrea|Se ne strappo uno, il palio lo guardo dal letto.": {
"file": "andrea-02fe387f5d.mp3",
"sec": 3.45
},
"andrea|L'edera nel vaso: cresce da sola, e si arrampica dove le pare.": {
"file": "andrea-ab2bb4cae8.mp3",
"sec": 4.68
},
"andrea|Una sedia di plastica, piegata.": {
"file": "andrea-8b314f556d.mp3",
"sec": 2.05
},
"andrea|Sedermi qui a guardare il paese? Prima il palio.": {
"file": "andrea-7593ef5d63.mp3",
"sec": 3.41
},
"andrea|La porta-finestra di casa, con la tapparella a metà.": {
"file": "andrea-be7c2b9a45.mp3",
"sec": 3.16
},
"andrea|Se rientro, mi mettono ad apparecchiare. Prima il palio.": {
"file": "andrea-2e64b2d5c2.mp3",
"sec": 4.8
},
"andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": { "andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": {
"file": "andrea-0a073729fd.mp3", "file": "andrea-0a073729fd.mp3",
"sec": 7.3 "sec": 7.3
@@ -2220,8 +2276,8 @@
"sec": 4.42 "sec": 4.42
}, },
"arcu|Fammi vedere... Verde. Come la Panda di mi' zio.": { "arcu|Fammi vedere... Verde. Come la Panda di mi' zio.": {
"file": "arcu-62e981592d.mp3", "file": "arcu-82526ee9fb.mp3",
"sec": 6.29 "sec": 4.52
}, },
"arcu|Verde! Allora il Regolo tifa Cardetole. Tradimento.": { "arcu|Verde! Allora il Regolo tifa Cardetole. Tradimento.": {
"file": "arcu-9e5b7c6e95.mp3", "file": "arcu-9e5b7c6e95.mp3",
@@ -2435,9 +2491,29 @@
"file": "mariano-d65fb6e871.mp3", "file": "mariano-d65fb6e871.mp3",
"sec": 3.42 "sec": 3.42
}, },
"andrea|Si corre?": { "andrea|Una cerbottana e un po' di stucco, Pierino!": {
"file": "andrea-8052393bfd.mp3", "file": "andrea-eaaf19eef0.mp3",
"sec": 0.7 "sec": 2.77
},
"andrea|Niente, grazie: guardavo.": {
"file": "andrea-12cbe6f9a8.mp3",
"sec": 2.26
},
"andrea|Lo stucco verde di Pierino: quell'odore non te lo levi più dalle dita.": {
"file": "andrea-2ede7e7324.mp3",
"sec": 3.82
},
"andrea|Ora un muro. Quello della piazza va benissimo.": {
"file": "andrea-9cc0991d39.mp3",
"sec": 3.32
},
"andrea|No, dopo.": {
"file": "andrea-78ad61a67a.mp3",
"sec": 1.22
},
"andrea|Si fa una gara alle macchinine?!?!": {
"file": "andrea-17216997e2.mp3",
"sec": 2.74
}, },
"andrea|Che pista hai disegnato?": { "andrea|Che pista hai disegnato?": {
"file": "andrea-3dd737ed1b.mp3", "file": "andrea-3dd737ed1b.mp3",
@@ -2447,9 +2523,9 @@
"file": "andrea-ee03433b9c.mp3", "file": "andrea-ee03433b9c.mp3",
"sec": 1.6 "sec": 1.6
}, },
"andrea|Dopo, Chiaru.": { "andrea|Vado a fare un girino alle case rosse.": {
"file": "andrea-75b58c99e6.mp3", "file": "andrea-cb2fa74266.mp3",
"sec": 1.32 "sec": 1.97
}, },
"chiaru|Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?": { "chiaru|Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?": {
"file": "chiaru-df4b1e3a0e.mp3", "file": "chiaru-df4b1e3a0e.mp3",
@@ -2475,9 +2551,13 @@
"file": "chiaru-a7ab8d241f.mp3", "file": "chiaru-a7ab8d241f.mp3",
"sec": 2.01 "sec": 2.01
}, },
"chiaru|La pista è qui. Finché non piove.": { "chiaru|Vorrai dire un giochino!": {
"file": "chiaru-841aa1efd7.mp3", "file": "chiaru-c106f9e9c4.mp3",
"sec": 2.39 "sec": 1.14
},
"andrea|Ciao Mattè.": {
"file": "andrea-fc5a08bbd6.mp3",
"sec": 0.73
}, },
"andrea|No, aspetta.": { "andrea|No, aspetta.": {
"file": "andrea-04890c1d8e.mp3", "file": "andrea-04890c1d8e.mp3",
@@ -2499,9 +2579,9 @@
"file": "chiaru-a0d7dbb4c1.mp3", "file": "chiaru-a0d7dbb4c1.mp3",
"sec": 2.15 "sec": 2.15
}, },
"andrea|Trappizzio Berti! Che fai, giochi da solo?": { "andrea|Fabri! Che fai, giochi da solo?": {
"file": "andrea-da5fb02d21.mp3", "file": "andrea-44037129d8.mp3",
"sec": 3.4 "sec": 2.71
}, },
"andrea|Come fai a prendere le biglie dove nessuno ci arriva?": { "andrea|Come fai a prendere le biglie dove nessuno ci arriva?": {
"file": "andrea-c6670ffb48.mp3", "file": "andrea-c6670ffb48.mp3",
@@ -2515,9 +2595,9 @@
"file": "andrea-fe39c8f30c.mp3", "file": "andrea-fe39c8f30c.mp3",
"sec": 1.49 "sec": 1.49
}, },
"andrea|Ci si vede, Trappizzio Berti.": { "andrea|Ci si vede, Fabri.": {
"file": "andrea-4089f348c4.mp3", "file": "andrea-d48ea04521.mp3",
"sec": 2.57 "sec": 1.37
}, },
"bertovo|Da solo? Io gioco contro le sponde. E vinco quasi sempre.": { "bertovo|Da solo? Io gioco contro le sponde. E vinco quasi sempre.": {
"file": "bertovo-5e771069ff.mp3", "file": "bertovo-5e771069ff.mp3",
@@ -2847,6 +2927,18 @@
"file": "bertovo-ca941bee91.mp3", "file": "bertovo-ca941bee91.mp3",
"sec": 5.41 "sec": 5.41
}, },
"andrea|Tutti sul muro, verdi in fila. Lo stucco di Pierino un tradisce mai.": {
"file": "andrea-585cd65f93.mp3",
"sec": 5.2
},
"andrea|Finito lo stucco. Domani si ripassa da Pierino.": {
"file": "andrea-62c5d74b82.mp3",
"sec": 3.29
},
"andrea|Il muro resta lì. E l'odore dello stucco, sulle dita, pure.": {
"file": "andrea-c7dcb11e66.mp3",
"sec": 5.52
},
"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.
+4
View File
@@ -31,6 +31,10 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
| `provinciale-pignone.png`, `piazzetta-cambray-digny.png`, `via-cipriani.png`, `via-massorondinaio.png`, `mozzete.png`, `ponticino-mozzete.png`, `coldaia.png`, `falegnameria-coldaia.png`, `gabbiano.png` | Codex | screenshot di Google Street View | Google | **no**: da rifare con foto di Andrea | | `provinciale-pignone.png`, `piazzetta-cambray-digny.png`, `via-cipriani.png`, `via-massorondinaio.png`, `mozzete.png`, `ponticino-mozzete.png`, `coldaia.png`, `falegnameria-coldaia.png`, `gabbiano.png` | Codex | screenshot di Google Street View | Google | **no**: da rifare con foto di Andrea |
| `closeups/micro-parcheggio.png`, `closeups/micro/*.png` (macchinine) | Codex, solo testo | nessuna | — | sì | | `closeups/micro-parcheggio.png`, `closeups/micro/*.png` (macchinine) | Codex, solo testo | nessuna | — | sì |
| `characters/chiaru.png` | Codex | `foto/chiaru.jpg` | Andrea (29 ottobre) | sì (il Chiaru consenziente) | | `characters/chiaru.png` | Codex | `foto/chiaru.jpg` | Andrea (29 ottobre) | sì (il Chiaru consenziente) |
| `via-liguri-magelli-muro.png` | Codex, ritocco di `via-liguri-magelli.png` con la Ghiacciomobile nel muro | come `via-liguri-magelli.png` | Street View | **no**: come lo sfondo da cui viene |
| `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 |
| `closeups/muro-cerbottana.png` | Codex, solo testo (stile dalla piazza) | nessuna | — | sì |
| `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 |
| `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti | | `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti |
| `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce | | `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce |
+27
View File
@@ -18,6 +18,30 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati ## Dove siamo arrivati
- **La cerbottana (29 ottobre)**: in piazza, la bottega di Pierin di Frilli sotto la tenda rossa; si compra lo stucco
verde e si tira al muro della piazza (`muro-cerbottana.png`, Codex). Motore `cerbottana.js`, disegno
`cerbottana-render.js`: si mira, si tiene premuto per il fiato (giusto al 70%: poco cade sotto, troppo va sopra), il
mirino ondeggia col respiro, troppo a fiato pieno si tossisce. Bersaglio di gesso (10/5/2), lattine sul muretto
(3), barattolo appeso (8, seria e a fassi male); 12 pallini, servono 30/40/50 punti; i pallini restano sul muro.
Test in `cerbottana.test.mjs` e `npm run browser:cerbottana`. [DA ANDREA] le battute, com'era la bottega, se far
comparire Pierino (è scomparso da anni: decide Andrea).
- **Il terrazzo di casa a Cardetole (29 ottobre)**: dalla foto di Andrea dalla finestra (il paese che sale e lassù
la Fortezza), Codex l'ha dipinta e ci ha messo davanti il terrazzo (`terrazza-cardetole.png`). Ci si sale da
«casa di Andrea» a Cardetole; la battuta dell'apparecchiare ora è sulla porta-finestra. Battute provvisorie
[DA ANDREA]. Come Cardetole, dice dove abita Andrea: da decidere prima di Pages.
- **La Ghiacciomobile nel muro (29 ottobre)**: non è più al circolino di Campomigliaio. In Via Liguri Magelli, prima
del tramonto (`!indizio_regolo`), è schiantata dentro il muro giallo coi mattoni rotti e senza targa
(`via-liguri-magelli-muro.png`, ritocco di Codex); dal tramonto il muro è rifatto e c'è la targa. Il motore ha le
varianti di sfondo per scena (`backdrops`, con `when`; `game.backdrop()`). Lo zio Mario (sprite di Codex dalla
foto di Andrea) davanti alla macchina che si gratta la testa; scala della scena rifatta (le persone erano troppo
piccole). [DA ANDREA] le battute dello zio (scherzava su tutto, anche sulla morte) e quella dello schianto.
- **GP di Fortezza, guida semplice (29 ottobre)**: alla bona e Seria si tiene premuto per il gas e lasciando si
frena; lo sterzo segue la strada da solo (scansa le buche, sorpassa, prende la miscela), ma oltre la velocità che la
curva regge il motorino scivola fuori verso la paglia (`SIMPLE` in `gara.js`). Ginocchio in terra resta con lo
sterzo vero. Il pannello nasconde Sterza, Frena, swipe e sensibilità. Test in `gara.test.mjs` e
`npm run browser:gara`. Seria più difficile (parere di Codex, simulato): sopra il 110% del limite in curva si esce
di sicuro (stun 1,2 s), la curva pulita dà una spinta, rivali a 0,81; CURVA! (preavviso) e LASCIA! sul motorino.
In simulazione: chi frena giusto 1° in 46 s, tutto gas 4°-5° in 55-59 s.
- **La pista col gesso (29 ottobre, modifiche locali)**: le Micro Machines sul parcheggio di Cardetole contro il - **La pista col gesso (29 ottobre, modifiche locali)**: le Micro Machines sul parcheggio di Cardetole contro il
Chiaru (Matteo Chiarusi, sprite di Codex dalla foto di Andrea, accanto alla pista). Motore in `micro.js`, disegno in Chiaru (Matteo Chiarusi, sprite di Codex dalla foto di Andrea, accanto alla pista). Motore in `micro.js`, disegno in
`micro-render.js`: pista disegnata a gesso dal codice sopra l'asfalto di Codex (`micro-parcheggio.png`), un giro, `micro-render.js`: pista disegnata a gesso dal codice sopra l'asfalto di Codex (`micro-parcheggio.png`), un giro,
@@ -26,6 +50,9 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
cappottè (urto forte), accompagnè (dito tenuto oltre la carica piena). Tre livelli: alla bona la guida mostra dove cappottè (urto forte), accompagnè (dito tenuto oltre la carica piena). Tre livelli: alla bona la guida mostra dove
ci si ferma. Test in `tests/micro.test.mjs` e nel browser (`npm run browser:micro`). Battute del Chiaru provvisorie ci si ferma. Test in `tests/micro.test.mjs` e nel browser (`npm run browser:micro`). Battute del Chiaru provvisorie
[DA ANDREA]; il Chiaru ha dato il consenso (29 ottobre). [DA ANDREA]; il Chiaru ha dato il consenso (29 ottobre).
Poi: si sceglie la macchinina (sei, il Chiaru tiene la blu), la forza parte da zero appena si preme e la spinta
parte quando si lascia; saluto «Vado a fare un girino alle case rosse» / «Vorrai dire un giochino!» / «Ciao Mattè»;
Andrea chiama Bertovo «Fabri». Musica sua: `assets/audio/macchinine.mp3` (Andrea con Suno).
- **Bertovo ora si chiama «Trappizzio Berti»** in tutte le scritte (Andrea, 29 ottobre); l'id resta `bertovo`. - **Bertovo ora si chiama «Trappizzio Berti»** in tutte le scritte (Andrea, 29 ottobre); l'id resta `bertovo`.
Fabrizio Berti e il suo libro «Memorie di sport e di paese» nei Ringraziamenti; l'idea delle cartoline in `IDEE.md`. Fabrizio Berti e il suo libro «Memorie di sport e di paese» nei Ringraziamenti; l'idea delle cartoline in `IDEE.md`.
- **I rinquarti di Bertovo (28 ottobre, modifiche locali)**: variante del biliardo in `rinquarto.js` e - **I rinquarti di Bertovo (28 ottobre, modifiche locali)**: variante del biliardo in `rinquarto.js` e
+16 -1
View File
@@ -225,7 +225,13 @@
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p> guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
</details> </details>
<details id="gara-help" hidden><summary>Come si gioca</summary> <details id="gara-help" hidden><summary>Come si gioca</summary>
<p>Il GP di Fortezza: tre giri attorno alle mura coi cinquantini. Con un solo dito: tieni premuto sulla scena per <p>Il GP di Fortezza: tre giri attorno alle mura coi cinquantini. <b>Alla bona e Seria</b> la guida è semplice:
tieni premuto sulla scena (o Spazio, o il pulsante Gas) per accelerare e lascia per frenare; lo sterzo segue la
strada da solo, scansa le buche e sorpassa. Ma se entri in curva troppo forte scivoli fuori, e nella paglia ti
fermi: molla prima delle curve. Sopra il motorino «CURVA!» avvisa che ne arriva una, «LASCIA!» dice quando
mollare. A Seria chi entra in curva troppo forte va fuori di sicuro, e chi la prende giusta fa la «curva pulita»
ed esce con una spinta in più.</p>
<p><b>Ginocchio in terra</b> si guida davvero. Con un solo dito: tieni premuto sulla scena per
il gas e trascina verso la direzione in cui vuoi andare sullo schermo. A destra vai verso destra, anche quando il gas e trascina verso la direzione in cui vuoi andare sullo schermo. A destra vai verso destra, anche quando
il motorino scende. I piccoli movimenti non sterzano; il cerchietto segue gli swipe lunghi. Alza il dito per il motorino scende. I piccoli movimenti non sterzano; il cerchietto segue gli swipe lunghi. Alza il dito per
mollare il gas e raddrizzare lo sterzo. Funziona anche col mouse premuto.</p> mollare il gas e raddrizzare lo sterzo. Funziona anche col mouse premuto.</p>
@@ -249,6 +255,15 @@
il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p> non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p>
</details> </details>
<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
di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria
e A fassi male) 8. Servono 30 punti alla bona, 40 Seria, 50 A fassi male. I pallini restano attaccati al muro.</p>
<p>Muovi il mouse o tocca il muro per mirare: il mirino ondeggia col respiro (di più salendo di livello). Tieni
premuto per prendere fiato e lascia per sparare: col fiato giusto il pallino va dove punti, con poco cade sotto,
con troppo va sopra. Alla bona la barra del fiato segna il punto giusto. Se resti troppo a fiato pieno tossisci, e
il pallino è perso. Si può anche regolare il fiato col cursore e premere Spara. Frecce per spostare il mirino.</p>
</details>
<details id="micro-help" hidden><summary>Come si gioca</summary> <details id="micro-help" hidden><summary>Come si gioca</summary>
<p>La pista col gesso sul parcheggio di Cardetole: un giro contro il Chiaru, una spinta a testa (prima tu, poi <p>La pista col gesso sul parcheggio di Cardetole: un giro contro il Chiaru, una spinta a testa (prima tu, poi
lui). La tua macchinina è la rossa. Vince chi taglia per primo il traguardo a scacchi; se ci arrivate tutti e due lui). La tua macchinina è la rossa. Vince chi taglia per primo il traguardo a scacchi; se ci arrivate tutti e due
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.39", "version": "0.1.47",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -8,6 +8,8 @@
"browser": "node tests/browser.mjs", "browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs", "browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:micro": "node tests/micro.browser.mjs", "browser:micro": "node tests/micro.browser.mjs",
"browser:gara": "node tests/gara.browser.mjs",
"browser:cerbottana": "node tests/cerbottana.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",
+102
View File
@@ -0,0 +1,102 @@
import { TARGET, CAN, SWINGER, LEDGE, PELLETS, FLIGHT, BEST, rings, reticle, swinger, canThrow, overHeld } from './cerbottana.js';
import { text, panel } from './bocce-render.js';
// Il gesso sul muro: tre cerchi un po' storti (sempre gli stessi) e i numeri dei punti, in alto, ognuno nel suo anello.
function chalkTarget(ctx, c) {
let inner = 0;
for (const [r, p] of rings(c)) {
ctx.strokeStyle = 'rgba(250,248,236,0.85)'; ctx.lineWidth = 1.5; ctx.beginPath();
for (let k = 0; k <= 48; k++) {
const a = k / 48 * Math.PI * 2, j = 0.6 * Math.sin(k * 2.3 + r);
const x = TARGET.x + Math.cos(a) * (r + j), y = TARGET.y + Math.sin(a) * (r + j);
if (k) ctx.lineTo(x, y); else ctx.moveTo(x, y);
}
ctx.stroke();
if (inner) text(ctx, String(p), TARGET.x, TARGET.y - (inner + r) / 2, 6, 'rgba(250,248,236,0.8)', 'center');
inner = r;
}
ctx.fillStyle = 'rgba(250,248,236,0.9)'; ctx.fillRect(TARGET.x - 1, TARGET.y - 1, 2, 2);
}
// Una lattina: rossa con la fascia chiara, i bordi d'alluminio.
function can(ctx, x, y, angle = 0, color = '#c8321e') {
ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.rotate(angle);
ctx.fillStyle = 'rgba(20,16,10,0.35)'; ctx.fillRect(-CAN.w / 2 + 2, -1, CAN.w, 2);
ctx.fillStyle = color; ctx.fillRect(-CAN.w / 2, -CAN.h, CAN.w, CAN.h);
ctx.fillStyle = '#f2e6c8'; ctx.fillRect(-CAN.w / 2, -CAN.h + 6, CAN.w, 4);
ctx.fillStyle = '#c9cdd2'; ctx.fillRect(-CAN.w / 2, -CAN.h, CAN.w, 2); ctx.fillRect(-CAN.w / 2, -2, CAN.w, 2);
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(-CAN.w / 2 + 2, -CAN.h + 2, 1, CAN.h - 4);
ctx.restore();
}
// Un pallino di stucco: verdastro, un po' schiacciato sul muro.
function pellet(ctx, x, y, r = 2.5) {
ctx.fillStyle = '#2b3a1c'; ctx.beginPath(); ctx.arc(x, y, r + 0.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#8fa05a'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#c3cf8e'; ctx.fillRect(Math.round(x - r / 2), Math.round(y - r / 2), 1, 1);
}
// La cerbottana in basso, in prospettiva: grossa vicino alla bocca, più fine in punta, girata verso il mirino, col
// pallino di stucco in punta.
function blowpipe(ctx, c, to) {
const from = { x: 320, y: 376 }, a = Math.atan2(to.y - from.y, to.x - from.x), len = 64;
const tip = { x: from.x + Math.cos(a) * len, y: from.y + Math.sin(a) * len }, nx = -Math.sin(a), ny = Math.cos(a);
const tube = (w0, w1, color) => {
ctx.fillStyle = color; ctx.beginPath();
ctx.moveTo(from.x + nx * w0, from.y + ny * w0); ctx.lineTo(tip.x + nx * w1, tip.y + ny * w1);
ctx.lineTo(tip.x - nx * w1, tip.y - ny * w1); ctx.lineTo(from.x - nx * w0, from.y - ny * w0); ctx.fill();
};
tube(9, 4, '#2a2018'); tube(7, 3, '#6b5236');
ctx.fillStyle = '#8a6a44'; ctx.fillRect(Math.round(tip.x - 1), Math.round(tip.y - 1), 2, 2);
if (canThrow(c)) pellet(ctx, tip.x, tip.y - 1, 3.5);
}
export function drawCerbottana(ctx, c, def, bg) {
ctx.save(); ctx.imageSmoothingEnabled = false;
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#d9a640'; ctx.fillRect(0, 0, 640, 360); }
chalkTarget(ctx, c);
for (const p of c.stuck) pellet(ctx, p.x, p.y);
// Le lattine sul muretto; colpite, cadono all'indietro.
for (const k of c.cans) { if (k.fall < 1) can(ctx, k.x, LEDGE + k.fall * 6, -k.fall * 1.4); }
if (c.skill.swinger) {
const s = swinger(c);
ctx.strokeStyle = '#3a3024'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(SWINGER.x, SWINGER.y); ctx.lineTo(s.x, s.y); ctx.stroke();
can(ctx, s.x, s.y + SWINGER.h, s.a + c.swing.spin * Math.sin(c.visual.time * 30) * 0.6, '#2f6fb0');
}
// Il pallino in volo: parte grande dalla cerbottana e rimpicciolisce andando verso il muro.
if (c.shot) {
const k = Math.min(1, c.shot.t / FLIGHT), { from, to } = c.shot;
pellet(ctx, from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k - Math.sin(k * Math.PI) * 18, 6 - 3.5 * k);
}
const r = reticle(c);
if (canThrow(c)) {
const over = overHeld(c);
ctx.strokeStyle = over ? '#ff6a4a' : '#fff8dc'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(r.x, r.y, 7, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(r.x - 11, r.y); ctx.lineTo(r.x - 3, r.y); ctx.moveTo(r.x + 3, r.y); ctx.lineTo(r.x + 11, r.y);
ctx.moveTo(r.x, r.y - 11); ctx.lineTo(r.x, r.y - 3); ctx.moveTo(r.x, r.y + 3); ctx.lineTo(r.x, r.y + 11); ctx.stroke();
}
blowpipe(ctx, c, r);
// Il fiato: una barra in basso a sinistra (alla bona col punto giusto segnato).
if (canThrow(c)) {
const x = 16, y = 300, h = 50;
ctx.fillStyle = '#17160fd0'; ctx.fillRect(x - 3, y - 3, 18, h + 6);
ctx.fillStyle = overHeld(c) ? '#ff6a4a' : c.power > BEST + 0.08 ? '#ffd23c' : '#9be07c';
const f = Math.round(h * c.power); ctx.fillRect(x, y + h - f, 12, f);
if (c.skill.sweet) { ctx.fillStyle = '#fff8dc'; ctx.fillRect(x - 3, Math.round(y + h - h * BEST) - 1, 18, 2); }
text(ctx, 'FIATO', x + 6, y - 9, 6, '#efd49e', 'center');
}
// In alto: punti, pallini rimasti e cosa è successo.
ctx.fillStyle = '#17160fe8'; ctx.fillRect(0, 0, 640, 22);
text(ctx, 'LA CERBOTTANA', 8, 7, 8, '#efd49e');
text(ctx, { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level], 8, 16, 7, '#d9ccba');
text(ctx, `Punti ${c.points} / ${c.skill.goal}`, 260, 11, 9, c.points >= c.skill.goal ? '#b9e5b3' : '#fff0c8', 'center');
for (let i = 0; i < PELLETS; i++) pellet(ctx, 430 + i * 15, 11, i < c.left ? 3 : 1.2);
const line = overHeld(c) ? 'Lascia, sennò tossisci!' : c.line;
if (line) { ctx.fillStyle = '#17160fd0'; ctx.fillRect(170, 24, 300, 14); text(ctx, line, 320, 31, 8, overHeld(c) || /corto|alto|Coff/.test(line) ? '#ffbf9f' : '#9fd0ff', 'center'); }
if (c.step === 'result') {
const won = c.result.winner === 'player';
panel(ctx, 170, 140, 300, 76);
text(ctx, won ? 'CENTRATO!' : 'LO STUCCO È FINITO', 320, 160, 12, won ? '#b9e5b3' : '#efd49e', 'center');
text(ctx, `${c.result.points} punti, ne servivano ${c.result.goal}`, 320, 182, 8, '#efd49e', 'center');
text(ctx, 'Torna in piazza per uscire.', 320, 200, 8, '#d9ccba', 'center');
}
ctx.restore();
}
+146
View File
@@ -0,0 +1,146 @@
// La cerbottana (idea di Andrea, 29 ottobre): da ragazzi in piazza si comprava da Pierin di Frilli lo stucco verde,
// dall'odore inconfondibile, lo si metteva intorno alla cerbottana, si facevano i pallini e si «sparava» al muro.
// Qui: un muro della piazza visto di fronte, un bersaglio fatto col gesso, le lattine sul muretto e (a seria e a fassi
// male) un barattolo appeso che dondola. Si mira, si tiene premuto per prendere fiato e si lascia: con poco fiato il
// pallino cade sotto, con troppo va sopra; il mirino ondeggia col respiro, e chi resta troppo a fiato pieno tossisce.
// I pallini di stucco restano attaccati al muro. Unità: i pixel del primo piano 640×360.
export const WALL_BOTTOM = 244; // sotto comincia il muretto: lì il pallino non attacca
export const LEDGE = 244; // dove poggiano le lattine
export const PELLETS = 12, SWING = 1, TAP = 0.14, COUGH = 0.6, FLIGHT = 0.3, REST = 0.5, BEST = 0.7, DROP = 300;
export const TARGET = { x: 320, y: 118, rings: [[8, 10], [18, 5], [30, 2]] };
export const CAN = { w: 10, h: 16, points: 3 }, SWINGER = { x: 470, y: 8, length: 92, w: 12, h: 16, points: 8 };
// I livelli: quanto ondeggia il mirino (px), quanti punti servono, se c'è il barattolo appeso, se la barra del fiato
// mostra il punto giusto, e quanto sono grandi i cerchi del bersaglio.
export const LEVELS = {
bona: { sway: 3, goal: 30, swinger: false, sweet: true, rings: 1.3 },
seria: { sway: 6, goal: 40, swinger: true, sweet: false, rings: 1 },
male: { sway: 10, goal: 50, swinger: true, sweet: false, rings: 0.85 },
};
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 newCerbottana(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
return { step: 'aim', t: 0, seed, level, skill: LEVELS[level], aim: { x: TARGET.x, y: TARGET.y }, power: BEST,
holding: false, holdTime: 0, charging: false, left: PELLETS, points: 0, events: [], shot: null, stuck: [], line: null,
cans: [200, 250, 390, 440].map(x => ({ x, down: false, fall: 0 })), swing: { hits: 0, spin: 0 }, visual: { time: 0 } };
}
export const canThrow = c => c.step === 'aim' && c.left > 0;
export const canAdjust = canThrow;
export const powerAt = time => clamp(time / SWING, 0, 1);
export const rings = c => TARGET.rings.map(([r, p]) => [r * c.skill.rings, p]);
// Il respiro: il mirino gira piano intorno al punto voluto (due onde, non si ripete subito); trattenendo il fiato pieno
// trema di più.
export function sway(c) {
const t = c.visual.time, a = c.skill.sway * (1 + (c.overHold ?? 0) * 2);
return { x: a * (Math.sin(t * 1.7) + 0.5 * Math.sin(t * 3.1 + 1)), y: a * 0.8 * (Math.sin(t * 1.3 + 2) + 0.5 * Math.sin(t * 2.9)) };
}
export function reticle(c) { const s = sway(c); return { x: c.aim.x + s.x, y: c.aim.y + s.y }; }
// Dove arriva il pallino: dove punta il mirino, più su o più giù secondo il fiato (BEST è il fiato giusto).
export const landing = (c, power = c.power) => { const r = reticle(c); return { x: r.x, y: r.y + (BEST - power) * DROP }; };
// Il barattolo appeso: un pendolo che dondola sotto la gronda (e gira su sé stesso se colpito).
export function swinger(c) {
const a = 0.5 * Math.sin(c.visual.time * 1.6);
return { x: SWINGER.x + Math.sin(a) * SWINGER.length, y: SWINGER.y + Math.cos(a) * SWINGER.length, a };
}
export function shoot(c) {
if (!canThrow(c) || c.holding) return false;
return fire(c, c.power);
}
function fire(c, power, cough = false) {
const from = { x: 320, y: 380 }, to = cough ? { x: 300 + random(c) * 40, y: 330 } : landing(c, power);
c.left--; c.power = power;
c.shot = { from, to, t: 0, cough };
c.step = 'flying'; c.line = null;
c.events.push({ kind: cough ? 'cerbottana-tosse' : 'cerbottana-pff', power });
return true;
}
// Arrivato: prima il barattolo appeso, poi le lattine, poi il bersaglio; sul muro il pallino resta attaccato.
function land(c) {
const { x, y } = c.shot.to;
let got = 0, what = null;
if (c.shot.cough) what = 'Coff! Il pallino è finito per terra.';
if (!what && c.skill.swinger) {
const s = swinger(c);
if (Math.abs(x - s.x) <= SWINGER.w / 2 + 1 && Math.abs(y - s.y - SWINGER.h / 2) <= SWINGER.h / 2 + 1) {
got = SWINGER.points; c.swing.hits++; c.swing.spin = 1; what = `Il barattolo appeso! ${got} punti.`;
c.events.push({ kind: 'cerbottana-latta', power: 1 });
}
}
if (!what) {
const can = c.cans.find(k => !k.down && Math.abs(x - k.x) <= CAN.w / 2 + 1 && y >= LEDGE - CAN.h - 1 && y <= LEDGE);
if (can) { can.down = true; got = CAN.points; what = `Lattina giù! ${got} punti.`; c.events.push({ kind: 'cerbottana-latta', power: 0.8 }); }
}
if (!what) {
if (y < WALL_BOTTOM && y > 4 && x > 4 && x < 636) {
c.stuck.push({ x, y });
const d = Math.hypot(x - TARGET.x, y - TARGET.y), ring = rings(c).find(([r]) => d <= r);
got = ring ? ring[1] : 0;
what = got ? `Nel bersaglio: ${got} ${got === 1 ? 'punto' : 'punti'}.` : y < TARGET.y - 40 ? 'Troppo fiato: alto.' : 'Sul muro, fuori dal bersaglio.';
c.events.push({ kind: 'cerbottana-muro', power: 0.6 });
} else what = 'Poco fiato: il pallino è caduto corto.';
}
c.points += got; c.line = what;
c.shot = null; c.step = 'rest'; c.t = 0;
}
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;
for (const k of c.cans) if (k.down) k.fall = Math.min(1, k.fall + dt * 3);
if (c.swing.spin) c.swing.spin = Math.max(0, c.swing.spin - dt);
if (c.holding) {
c.holdTime += dt; c.charging = c.holdTime >= TAP;
c.power = powerAt(c.holdTime);
c.overHold = c.power >= 1 ? (c.overHold ?? 0) + dt : 0;
// Troppo a fiato pieno: tosse, e il pallino è perso.
if (c.overHold > COUGH) { c.holding = c.charging = false; c.overHold = 0; fire(c, 1, true); }
}
if (c.step === 'flying') { c.shot.t += dt; if (c.shot.t >= FLIGHT) land(c); }
else if (c.step === 'rest' && c.t >= REST) {
if (c.left > 0) c.step = 'aim';
else { c.step = 'result'; c.result = { winner: c.points >= c.skill.goal ? 'player' : 'cpu', points: c.points, goal: c.skill.goal }; }
}
}
// --- Comandi: il mirino segue il mouse o il dito; tenere premuto prende fiato, lasciare spara.
export function aimScreen(c, [x, y]) {
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.aim = { x: clamp(x, 10, 630), y: clamp(y, 24, 300) }; return true;
}
export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.aim.x = clamp(c.aim.x + dir * 4, 10, 630); return true; }
export function setPower(c, value) { if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false; c.power = clamp(value, 0, 1); return true; }
export function key(c, key, down) {
const move = { ArrowLeft: [-4, 0], ArrowRight: [4, 0], ArrowUp: [0, -4], ArrowDown: [0, 4] }[key];
if (!move) return false;
if (down && canThrow(c) && !c.holding) c.aim = { x: clamp(c.aim.x + move[0], 10, 630), y: clamp(c.aim.y + move[1], 24, 300) };
return true;
}
export function press(c, pointerId) {
if (!canThrow(c) || c.holding) return false;
c.pointerId = pointerId; c.holding = true; c.charging = false; c.holdTime = 0; c.previousPower = c.power; c.power = 0; c.overHold = 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.power = c.previousPower; c.holdTime = 0; c.overHold = 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.overHold = 0;
return fire(c, c.power);
}
// Trattenendo il fiato pieno: il disegno avvisa prima della tosse.
export const overHeld = c => c.holding && (c.overHold ?? 0) > 0;
export function button(c) { return c.step === 'result' ? 'Finita' : c.step === 'aim' ? 'Spara' : 'In volo…'; }
export function status(c) {
const score = `Punti ${c.points} su ${c.skill.goal}, pallini ${c.left} di ${PELLETS}.`;
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Fatto! ' : 'Lo stucco è finito. '}${score}`;
return `${score} ${c.line ?? 'Mira, tieni premuto per prendere fiato e lascia.'}`;
}
+5
View File
@@ -11,6 +11,7 @@ import * as tressette from './tressette.js';
import * as scopa from './scopa.js'; 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';
// 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.
export const MINIGAMES = { export const MINIGAMES = {
@@ -22,6 +23,7 @@ export const MINIGAMES = {
rinquarto: { create: (seed, level) => rinquarto.newRinquarto(seed, { level }), update: rinquarto.update, press: rinquarto.press, release: rinquarto.release, cancel: rinquarto.cancel, aim: rinquarto.aimScreen }, rinquarto: { create: (seed, level) => rinquarto.newRinquarto(seed, { level }), update: rinquarto.update, press: rinquarto.press, release: rinquarto.release, cancel: rinquarto.cancel, aim: rinquarto.aimScreen },
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen }, basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen }, pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
cerbottana: { create: (seed, level) => cerbottana.newCerbottana(seed, { level }), update: cerbottana.update, press: cerbottana.press, release: cerbottana.release, cancel: cerbottana.cancel, aim: cerbottana.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 },
}; };
@@ -162,6 +164,9 @@ export class Game {
} }
get scene() { return this.story.scenes[this.state.scene]; } get scene() { return this.story.scenes[this.state.scene]; }
// Lo sfondo di adesso: la prima variante della scena (`backdrops`, ognuna con la sua condizione `when`) che vale,
// sennò il suo `background`. Così un posto cambia con la storia (la Ghiacciomobile nel muro, poi la targa).
backdrop(scene = this.scene) { return scene.backdrops?.find(b => check(b.when, this.state))?.src ?? scene.background; }
// Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto // Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto
// o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata. // o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata.
get busy() { return !!this.speech || !!this.closeup || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); } get busy() { return !!this.speech || !!this.closeup || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); }
+11 -1
View File
@@ -1,4 +1,4 @@
import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, SWIPE, pointAt, standings, player, lapOf, presenting, placeOf, INTRO } from './gara.js'; import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, SWIPE, pointAt, standings, player, lapOf, presenting, placeOf, INTRO, mustLift, PREAVVISO } from './gara.js';
import { text, panel } from './bocce-render.js'; import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js'; import { ambientHash as hash } from './ambient.js';
@@ -216,6 +216,12 @@ function result(ctx, c) {
}); });
} }
// Una scritta sopra il motorino di Andrea, su un fondo scuro perché si legga anche sullo sterrato e sul telefono.
function tag(ctx, line, r, color) {
const x = Math.round(r.x), y = Math.round(r.y) - 20, w = line.length * 9 + 10;
ctx.fillStyle = 'rgba(23,22,15,0.85)'; ctx.fillRect(x - w / 2, y - 7, w, 14);
text(ctx, line, x, y, 10, color, 'center');
}
export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) { export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) {
ctx.save(); ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
@@ -232,6 +238,10 @@ export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null)
const me = player(c); const me = player(c);
if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center'); if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center');
if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center'); if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center');
// La guida semplice: LASCIA! sopra il motorino quando serve frenare, e la curva pulita.
if (c.step === 'race' && c.holding && mustLift(c)) { if (Math.floor(c.age * 8) % 2) tag(ctx, 'LASCIA!', me, '#ff6a4a'); }
else if (c.step === 'race' && c.holding && mustLift(c, PREAVVISO)) tag(ctx, 'CURVA!', me, '#ffe28a');
if (c.step === 'race' && c.clean !== undefined && c.t - c.clean < 1.2) text(ctx, 'CURVA PULITA!', 320, 180, 9, '#b9e5b3', 'center');
if (c.step === 'result') result(ctx, c); if (c.step === 'result') result(ctx, c);
ctx.restore(); ctx.restore();
} }
+93 -12
View File
@@ -50,12 +50,20 @@ export const RIDERS = [
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' }, { id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' },
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' }, { id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' },
]; ];
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista. // I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista. `simple`: la
// guida semplice (Andrea, 29 ottobre: dal telefono lo sterzo era ingiocabile): si tiene premuto per il gas e lasciando
// si frena, lo sterzo segue la strada da solo, e chi entra in curva troppo forte scivola fuori. Ginocchio in terra
// tiene lo sterzo vero.
export const LEVELS = { export const LEVELS = {
bona: { rivals: 0.74, stun: 0.4, guide: 'zone' }, bona: { rivals: 0.74, stun: 0.4, guide: 'zone', simple: true },
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' }, // Seria (Andrea, 29 ottobre: «chi non rallenta in curva deve andare fuori»; valori proposti da Codex e simulati):
// si scivola di più, sopra il limite in curva si esce per forza, e la curva pulita dà una spinta.
seria: { rivals: 0.81, stun: 1.2, guide: 'coni', simple: true,
tune: { slide: 12, clean: { min: 0.85, slack: 2, time: 0.6, accel: 1.2 }, force: { over: 1.1, hold: 0.18, apex: 1.15, time: 0.3, guard: 0.6 } } },
male: { rivals: 0.93, stun: 1.4, guide: null }, male: { rivals: 0.93, stun: 1.4, guide: null },
}; };
// `def.simple` la forza (i test della guida vera la spengono anche alla bona).
export const simple = c => !!c.simple;
function catmull(p0, p1, p2, p3, t) { function catmull(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t; const t2 = t * t, t3 = t2 * t;
@@ -154,7 +162,7 @@ export function newGara(seed = Date.now() >>> 0, def = {}) {
}); });
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea'); if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
for (const r of riders) { r.grid = r.s; r.shown = false; } for (const r of riders) { r.grid = r.s; r.shown = false; }
return { step: 'intro', t: 0, age: 0, seed, level, skill, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false }, return { step: 'intro', t: 0, age: 0, seed, level, skill, simple: def.simple ?? !!skill.simple, tune: { ...SIMPLE, ...skill.tune }, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null, sens: SWIPE.sens, pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null, sens: SWIPE.sens,
power: SWIPE.sens, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} }; power: SWIPE.sens, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
} }
@@ -258,12 +266,75 @@ export function steering(c) {
// La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro). // La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro).
export const ahead = (from, to) => ((((to - from) % TRACK.length) + TRACK.length) % TRACK.length); export const ahead = (from, to) => ((((to - from) % TRACK.length) + TRACK.length) % TRACK.length);
// Lo sterzo che segue la strada: si guarda un punto più avanti sulla propria corsia e ci si gira (gli avversari, e
// Andrea nella guida semplice). Andando troppo forte in curva lo sterzo non basta e si scivola fuori.
const follow = r => { const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6); return clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1); };
// La guida semplice di Andrea: lo sterzo scansa le buche, sorpassa e va sulle lattine di miscela; ma oltre la velocità
// che la curva regge (la più stretta nei SIMPLE.window pixel davanti) il motorino scivola verso l'esterno, tanto più
// quanto si esagera (SIMPLE.slide): a tutto gas, nelle curve vere si finisce nella paglia.
// Ogni livello può stringere (LEVELS.*.tune). `clean`: il premio della curva pulita, presa senza mai passare il
// limite (`slack` px/s di tolleranza) e senza frenare troppo (almeno `min` del limite): per `time` secondi in uscita
// si accelera di più (`accel`), senza superare la velocità massima. `force`: l'uscita sicura, sopra `over` volte il
// limite per `hold` secondi (o `apex` volte nel punto più stretto) si va nella paglia in `time` secondi, una volta per
// curva; rientrati, `guard` secondi di tregua.
export const SIMPLE = { grip: 1, slide: 8, window: 50, clean: null, force: null };
export function simpleLimit(r, s = r.s, tune = SIMPLE) {
let low = Infinity;
for (let d = -10; d <= tune.window; d += STEP) low = Math.min(low, limitAt(s + d));
return low * r.grip * tune.grip;
}
// La curva in corso: dentro si tiene il peggio dell'eccesso e la velocità più bassa (per il premio), e si guarda se
// si è andati troppo forte abbastanza a lungo (per l'uscita sicura).
function judgeCurve(c, r, dt) {
const tune = c.tune, s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length;
const k = CURVES.find(k => s >= k.start - 20 && s <= k.end + 10);
if (k) {
const w = r.curve?.k === k ? r.curve : (r.curve = { k, excess: 0, slow: Infinity, over: 0, fault: false });
w.excess = Math.max(w.excess, r.excess); w.slow = Math.min(w.slow, r.v);
const f = tune.force, limit = limitAt(k.apex) * r.grip * tune.grip;
if (f && !w.fault && !(r.guard > 0) && r.out === 0) {
w.over = r.v > limit * f.over ? w.over + dt : 0;
if (w.over >= f.hold || (Math.abs(s - k.apex) < STEP * 2 && r.v > limit * f.apex)) { w.fault = true; r.forced = f.time; }
}
return;
}
const w = r.curve; r.curve = null;
if (!w || !tune.clean || w.fault) return;
const limit = limitAt(w.k.apex) * r.grip * tune.grip;
if (w.excess <= tune.clean.slack && w.slow >= limit * tune.clean.min && r.out === 0) {
r.push = tune.clean.time; r.cleans = (r.cleans ?? 0) + 1;
c.events.push({ kind: 'gara-giro', who: r.id, power: 1 }); c.clean = c.t;
}
}
// Andrea, nella guida semplice, deve lasciare entro `lead` secondi per arrivare alle curve davanti sotto il limite.
// Sopra il motorino: CURVA! in giallo con PREAVVISO secondi d'anticipo, poi LASCIA! in rosso quando è il momento (sul
// telefono non serve l'istante preciso, e lasciare troppo presto, che qui vuol dire frenare, non conviene).
export const PREAVVISO = 0.7, LIFT = 0;
export function mustLift(c, lead = LIFT) {
const r = player(c);
if (!simple(c) || c.step !== 'race' || r.out > 0) return false;
const horizon = r.v * r.v / (2 * BRAKE) + r.v * lead + 10;
for (let d = 0; d <= horizon; d += 4) {
const a = simpleLimit(r, r.s + d, c.tune), room = Math.max(0, d - r.v * lead);
if (r.v > a && r.v * r.v > a * a + 2 * BRAKE * room) return true;
}
return false;
}
function autopilot(c, r) {
const next = PICKUPS.find(p => p.kind !== 'boost' && ahead(r.s, p.s) < 30 + r.v * 0.6);
// Chi sta davanti più lento si passa dall'altra parte; le buche si scansano, la miscela si prende.
const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 26 && Math.abs(o.lat - r.lat) < 7 && o.v < r.v + 8);
if (next?.kind === 'buca') r.lane = next.lane > 0 ? -5 : 5;
else if (slow) r.lane = slow.lat > 0 ? -6 : 6;
else if (next?.kind === 'miscela') r.lane = next.lane;
r.excess = Math.max(0, r.v - simpleLimit(r, r.s, c.tune));
return follow(r);
}
// Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui, // Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui,
// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto // arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto
// si fida troppo. // si fida troppo.
function drive(c, r) { function drive(c, r) {
const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6); r.steer = follow(r);
r.steer = clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1);
const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; }); const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; });
if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; } if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; }
const horizon = (r.v * r.v) / (2 * BRAKE) + 20; const horizon = (r.v * r.v) / (2 * BRAKE) + 20;
@@ -283,22 +354,28 @@ function drive(c, r) {
} }
function stepRider(c, r, dt) { function stepRider(c, r, dt) {
if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; } if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; }
else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; return; } else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; if (!r.out && c.tune.force) r.guard = c.tune.force.guard; return; }
else if (r.id === 'andrea' && simple(c)) { r.gas = c.holding; r.brake = !c.holding; r.steer = autopilot(c, r); judgeCurve(c, r, dt); }
else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); } else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); }
else drive(c, r); else drive(c, r);
if (r.finished !== null) r.gas = false; if (r.finished !== null) r.gas = false;
const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1); const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1);
// Il freno vince sul gas: chi li tiene tutti e due frena. // Il freno vince sul gas: chi li tiene tutti e due frena.
if (r.brake) r.v -= BRAKE * dt; if (r.brake) { r.v -= BRAKE * dt; r.push = 0; }
else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1); else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1) * (r.push > 0 ? c.tune.clean.accel : 1);
else r.v -= COAST * dt; else r.v -= COAST * dt;
if (r.v > top) r.v = Math.max(top, r.v - 120 * dt); if (r.v > top) r.v = Math.max(top, r.v - 120 * dt);
r.v = Math.max(0, r.v); r.v = Math.max(0, r.v);
r.boost = Math.max(0, r.boost - dt); r.boost = Math.max(0, r.boost - dt);
if (r.push) r.push = Math.max(0, r.push - dt);
if (r.guard) r.guard = Math.max(0, r.guard - dt);
// Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori. // Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori.
const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt; const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt;
r.a = wrapAngle(r.a + turn); r.a = wrapAngle(r.a + turn);
r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip; r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip;
if (r.excess) r.u -= TRACK.side[r.idx] * r.excess * c.tune.slide * dt; // la guida semplice: troppo forte, si allarga
// L'uscita sicura: verso l'esterno della curva, e lo sterzo non la salva.
if (r.forced > 0) { r.forced = Math.max(0, r.forced - dt); r.u = -(r.curve?.k.side ?? TRACK.side[r.idx]) * 90; r.v = Math.max(r.v, 30); }
r.u *= Math.exp(-GRIP * r.grip * dt); r.u *= Math.exp(-GRIP * r.grip * dt);
r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt; r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt;
r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt; r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt;
@@ -307,7 +384,8 @@ function stepRider(c, r, dt) {
if (Math.abs(r.lat) > VERGE) { if (Math.abs(r.lat) > VERGE) {
const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1); const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1);
r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0; r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0;
if (r.v > 40 && r.finished === null) { if ((r.v > 40 || r.forced > 0) && r.finished === null) {
r.forced = 0;
r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1; r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1;
c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 }); c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 });
} }
@@ -406,7 +484,7 @@ export function status(c) {
const r = presenting(c); const r = presenting(c);
return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!'; return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!';
} }
if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, sterza con ← →.` : 'Via!'; if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, ${simple(c) ? 'lascia per frenare' : 'sterza con ← →'}.` : 'Via!';
if (c.step === 'result') { if (c.step === 'result') {
const who = c.riders.find(r => r.id === c.result.order[0]).name; const who = c.riders.find(r => r.id === c.result.order[0]).name;
return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`; return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`;
@@ -414,6 +492,9 @@ export function status(c) {
const me = player(c), place = placeOf(c); const me = player(c), place = placeOf(c);
if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…'; if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…';
if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`; if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`;
if (simple(c)) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.clean !== undefined && c.t - c.clean < 1.5 ? 'Curva pulita!' : mustLift(c) && c.holding ? 'Lascia!' : mustLift(c, PREAVVISO) && c.holding ? 'Curva!' : c.holding ? 'Gas!' : 'Freno.'}`;
return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`; return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`;
} }
export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : c.holding ? 'Molla' : 'Gas'; // Nella guida semplice il pulsante si tiene premuto: la scritta resta Gas (su iPhone un pulsante che cambia sotto il dito
// perde il tocco).
export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : simple(c) ? 'Gas' : c.holding ? 'Molla' : 'Gas';
+39 -10
View File
@@ -1,4 +1,4 @@
import { HALF, CAR, canThrow, guide, done, overHeld } from './micro.js'; import { HALF, CAR, CARS, canThrow, guide, done, overHeld, carImage, choiceAt, why, rank } from './micro.js';
import { text, panel } from './bocce-render.js'; import { text, panel } from './bocce-render.js';
const NAME = { player: 'Andrea', cpu: 'Chiaru' }; const NAME = { player: 'Andrea', cpu: 'Chiaru' };
@@ -68,6 +68,12 @@ function aim(ctx, c, img) {
ctx.fillStyle = over ? '#ff6a4a' : '#e8b48f'; ctx.strokeStyle = '#6b3d26'; ctx.lineWidth = 1; ctx.fillStyle = over ? '#ff6a4a' : '#e8b48f'; ctx.strokeStyle = '#6b3d26'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(fx, fy, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(fx, fy, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#f7d9c4'; ctx.fillRect(Math.round(fx + ux * 1.5) - 1, Math.round(fy + uy * 1.5) - 1, 2, 2); // l'unghia ctx.fillStyle = '#f7d9c4'; ctx.fillRect(Math.round(fx + ux * 1.5) - 1, Math.round(fy + uy * 1.5) - 1, 2, 2); // l'unghia
// La forza che sale mentre si tiene premuto: una barretta sopra la macchinina.
if (c.holding) {
const x = Math.round(me.x - 15), y = Math.round(me.y - 19);
ctx.fillStyle = '#17160fd0'; ctx.fillRect(x - 1, y - 1, 32, 6);
ctx.fillStyle = over ? '#ff6a4a' : c.power > 0.75 ? '#ffd23c' : '#9be07c'; ctx.fillRect(x, y, Math.round(30 * c.power), 4);
}
} }
// Dove una macchinina ha fatto il fallo: una croce di gesso rosso che sfuma, e la macchinina (ribaltata, se ha cappottato) // Dove una macchinina ha fatto il fallo: una croce di gesso rosso che sfuma, e la macchinina (ribaltata, se ha cappottato)
// che sparisce per tornare al suo posto. // che sparisce per tornare al suo posto.
@@ -83,11 +89,27 @@ function returns(ctx, c, images) {
} }
} }
// La scelta della macchinina: le sei in grande sul parcheggio, quella scelta cerchiata di gesso e un po' sollevata.
function choice(ctx, c, image) {
ctx.fillStyle = '#17160fc8'; ctx.fillRect(60, 56, 520, 250);
text(ctx, 'SCEGLI LA TUA MACCHININA', 320, 76, 12, '#efd49e', 'center');
CARS.forEach((car, i) => {
const [x, y] = choiceAt(i), on = i === c.choice, lift = on ? Math.sin(c.visual.time * 5) * 1.5 - 2 : 0, img = image?.(carImage(car.id, true));
if (on) { ctx.strokeStyle = 'rgba(246,246,238,0.9)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(x, y, 50, 28, 0, 0, Math.PI * 2); ctx.stroke(); }
ctx.fillStyle = 'rgba(10,12,16,0.35)'; ctx.fillRect(x - 37, y - 16, 78, 38); // l'ombra
if (img) ctx.drawImage(img, Math.round(x - img.width / 2), Math.round(y - img.height / 2 + lift));
text(ctx, car.name, x, y + 34, 8, on ? '#fff0c8' : '#a99d86', 'center');
});
const cpu = CARS[c.choice].id === 'blu' ? 'la rossa' : 'la blu e gialla';
text(ctx, `Il Chiaru prende ${cpu}. Tocca la tua, o frecce e Scegli.`, 320, 292, 8, '#d9ccba', 'center');
}
export function drawMicro(ctx, c, def, bg, image) { export function drawMicro(ctx, c, def, bg, image) {
ctx.save(); ctx.imageSmoothingEnabled = false; ctx.save(); ctx.imageSmoothingEnabled = false;
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4b4d50'; ctx.fillRect(0, 0, 640, 360); } if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4b4d50'; ctx.fillRect(0, 0, 640, 360); }
drawChalk(ctx, c.track); drawChalk(ctx, c.track);
const images = { player: image?.(def.cars?.player), cpu: image?.(def.cars?.cpu) }; if (c.step === 'choose') { choice(ctx, c, image); ctx.restore(); return; }
const images = { player: image?.(carImage(c.colors.player)), cpu: image?.(carImage(c.colors.cpu)) };
if (c.step === 'rolling' && c.trail.length > 1) { if (c.step === 'rolling' && c.trail.length > 1) {
ctx.strokeStyle = 'rgba(255,248,220,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(...c.trail[0]); ctx.strokeStyle = 'rgba(255,248,220,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(...c.trail[0]);
for (const p of c.trail.slice(1)) ctx.lineTo(...p); for (const p of c.trail.slice(1)) ctx.lineTo(...p);
@@ -101,21 +123,28 @@ export function drawMicro(ctx, c, def, bg, image) {
text(ctx, 'LA PISTA COL GESSO', 8, 7, 8, '#efd49e'); text(ctx, 'LA PISTA COL GESSO', 8, 7, 8, '#efd49e');
const level = { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level]; const level = { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level];
text(ctx, `${level} · spinte ${c.flicks.player}`, 8, 16, 7, '#d9ccba'); text(ctx, `${level} · spinte ${c.flicks.player}`, 8, 16, 7, '#d9ccba');
for (const [who, x0, color] of [['player', 168, '#e0543c'], ['cpu', 404, '#3c66d8']]) { const tint = id => CARS.find(x => x.id === id).color;
for (const [who, x0, color] of [['player', 200, tint(c.colors.player)], ['cpu', 430, tint(c.colors.cpu)]]) {
// Primo o secondo, mentre si va (Andrea, 29 ottobre).
const place = rank(c, who);
text(ctx, `${place}°`, x0 - 4, 11, 9, place === 1 ? '#ffe28a' : '#a99d86', 'right');
text(ctx, NAME[who], x0, 11, 8, '#fff0c8'); text(ctx, NAME[who], x0, 11, 8, '#fff0c8');
ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 150, 8); ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 140, 8);
ctx.fillStyle = color; ctx.fillRect(x0 + 52, 7, Math.round(150 * done(c, who)), 8); ctx.fillStyle = color; ctx.fillRect(x0 + 52, 7, Math.round(140 * done(c, who)), 8);
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 52.5, 7.5, 149, 7); ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 52.5, 7.5, 139, 7);
} }
const turn = c.step === 'result' ? null : c.current === 'cpu' ? 'Spinge il Chiaru…' : c.step === 'aim' ? 'Tocca a te: mira e spingi' : null; const turn = c.step === 'result' ? null : c.current === 'cpu' ? 'Spinge il Chiaru…' : c.step === 'aim' ? 'Tocca a te: mira e spingi' : null;
const line = overHeld(c) ? 'Lascia! Sennò è accompagnè' : c.line ?? turn; const line = overHeld(c) ? 'Lascia! Sennò è accompagnè' : c.line ?? turn;
if (line) { ctx.fillStyle = '#17160fd0'; ctx.fillRect(170, 24, 300, 14); text(ctx, line, 320, 31, 8, /^(Fuori|Tagliè|Cappottè|Accompagnè)!/.test(c.line ?? '') ? '#ffbf9f' : '#9fd0ff', 'center'); } if (line) { ctx.fillStyle = '#17160fd0'; ctx.fillRect(170, 24, 300, 14); text(ctx, line, 320, 31, 8, /^(Fuori|Tagliè|Cappottè|Accompagnè)!/.test(c.line ?? '') ? '#ffbf9f' : '#9fd0ff', 'center'); }
if (c.step === 'result') { if (c.step === 'result') {
const w = c.result.winner, title = w === 'player' ? 'HAI BATTUTO IL CHIARU!' : w === 'cpu' ? 'HA VINTO IL CHIARU' : 'ARRIVATI PARI'; const w = c.result.winner, title = w === 'player' ? 'HAI BATTUTO IL CHIARU!' : w === 'cpu' ? 'HA VINTO IL CHIARU' : 'ARRIVATI PARI';
panel(ctx, 170, 140, 300, 72); // Il perché, in due righe: chi è arrivato a quale spinta, e di quanto è andato più in là.
text(ctx, title, 320, 160, 12, w === 'player' ? '#b9e5b3' : '#efd49e', 'center'); const [first, second] = why(c);
text(ctx, `Un giro, ${c.result.flicks.player} spinte a testa`, 320, 182, 8, '#efd49e', 'center'); panel(ctx, 130, 132, 380, 92);
text(ctx, 'Torna dal Chiaru per uscire.', 320, 198, 8, '#d9ccba', 'center'); text(ctx, title, 320, 150, 12, w === 'player' ? '#b9e5b3' : '#efd49e', 'center');
text(ctx, first, 320, 172, 8, '#efd49e', 'center');
text(ctx, second, 320, 186, 8, '#efd49e', 'center');
text(ctx, 'Torna dal Chiaru per uscire.', 320, 208, 8, '#d9ccba', 'center');
} }
ctx.restore(); ctx.restore();
} }
+64 -11
View File
@@ -19,7 +19,7 @@ export const CONTROL = [
]; ];
export const HALF = 22, CAR = { len: 26, wid: 13, r: 6 }, LAPS = 1; export const HALF = 22, CAR = { len: 26, wid: 13, r: 6 }, LAPS = 1;
// La spinta: velocità di partenza da MIN_SPEED a MAX_SPEED, e l'asfalto che frena di DECEL px/s². // La spinta: velocità di partenza da MIN_SPEED a MAX_SPEED, e l'asfalto che frena di DECEL px/s².
export const MIN_SPEED = 60, MAX_SPEED = 400, DECEL = 420, SWING = 1.2, LONG_PRESS = 0.6, FIXED_STEP = 1 / 120; export const MIN_SPEED = 60, MAX_SPEED = 400, DECEL = 420, SWING = 1.2, TAP = 0.14, FIXED_STEP = 1 / 120;
const REST = 0.7, THINK = 1, RESTITUTION = 0.6, OUT = HALF + CAR.r, WINDOW = 30; const REST = 0.7, THINK = 1, RESTITUTION = 0.6, OUT = HALF + CAR.r, WINDOW = 30;
// FLIP: la velocità d'urto (px/s) che ribalta la macchinina colpita; ACCOMPAGNA: quanti secondi si può restare alla // FLIP: la velocità d'urto (px/s) che ribalta la macchinina colpita; ACCOMPAGNA: quanti secondi si può restare alla
// carica piena prima che diventi un accompagnè. // carica piena prima che diventi un accompagnè.
@@ -144,14 +144,43 @@ function step(track, list, dt, events) {
for (const car of list) if (car.vx || car.vy) follow(track, car); for (const car of list) if (car.vx || car.vy) follow(track, car);
} }
// Le macchinine fra cui scegliere (Andrea, 29 ottobre: «quando si parte fammi scegliere la macchinina»): il disegno di
// Codex in `assets/closeups/micro/<id>.png` (e `<id>-grande.png` per la scelta) e il colore della barra in alto.
// Il Chiaru tiene la sua blu e gialla; se la prende Andrea, lui prende la rossa.
export const CARS = [
{ id: 'rossa', name: 'Rossa e bianca', color: '#e0543c' },
{ id: 'blu', name: 'Blu e gialla', color: '#3c66d8' },
{ id: 'nera', name: 'Nera e oro', color: '#c9a23a' },
{ id: 'azzurra', name: 'Bianca e azzurra', color: '#6fb7ff' },
{ id: 'arancio', name: 'Arancione', color: '#f08a2c' },
{ id: 'viola', name: 'Viola e argento', color: '#9a5ad8' },
];
export const carImage = (id, big = false) => `assets/closeups/micro/${id}${big ? '-grande' : ''}.png`;
// Dove stanno le macchinine nella scelta: due file da tre, al centro del parcheggio.
export const choiceAt = i => [160 + (i % 3) * 160, 132 + Math.floor(i / 3) * 88];
export function pick(c, i = c.choice) {
if (c.step !== 'choose' || !CARS[i]) return false;
const mine = CARS[i].id;
c.colors = { player: mine, cpu: mine === 'blu' ? 'rossa' : 'blu' };
c.step = 'aim'; c.t = 0; c.aimLocked = false; // il clic che sceglie non deve fissare anche la mira
return true;
}
export function choose(c, i) {
if (c.step !== 'choose' || !Number.isFinite(i)) return false;
c.choice = (i + CARS.length) % CARS.length; return true;
}
const carUnder = (x, y) => CARS.findIndex((_, i) => { const [cx, cy] = choiceAt(i); return Math.abs(x - cx) < 44 && Math.abs(y - cy) < 30; });
export function newMicro(seed = Date.now() >>> 0, def = {}) { export function newMicro(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK; const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK;
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0.5, angle: 0, const c = { step: 'choose', choice: 0, colors: null, t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0.5, angle: 0,
holding: false, holdTime: 0, accumulator: 0, events: [], track, laps: LAPS, flicks: { player: 0, cpu: 0 }, holding: false, holdTime: 0, accumulator: 0, events: [], track, laps: LAPS, flicks: { player: 0, cpu: 0 },
line: null, trail: [], returns: [], before: null, plan: null, visual: { time: 0 } }; line: null, trail: [], returns: [], before: null, plan: null, visual: { time: 0 } };
// In griglia: Andrea all'esterno, il Chiaru all'interno, poco prima del traguardo. // In griglia: Andrea all'esterno, il Chiaru all'interno, poco prima del traguardo.
c.cars = { player: newCar(track, 'player', 18, -11), cpu: newCar(track, 'cpu', 18, 11) }; c.cars = { player: newCar(track, 'player', 18, -11), cpu: newCar(track, 'cpu', 18, 11) };
c.angle = c.cars.player.heading; c.angle = c.cars.player.heading;
// Si parte scegliendo la macchinina, se non è già data (`def.car`).
if (def.car) pick(c, CARS.findIndex(x => x.id === def.car));
return c; return c;
} }
@@ -236,7 +265,7 @@ function settle(c) {
const end = c.laps * c.track.length, over = { player: c.cars.player.progress - end, cpu: c.cars.cpu.progress - end }; const end = c.laps * c.track.length, over = { player: c.cars.player.progress - end, cpu: c.cars.cpu.progress - end };
if (c.shooter === 'cpu' && (over.player >= 0 || over.cpu >= 0)) { if (c.shooter === 'cpu' && (over.player >= 0 || over.cpu >= 0)) {
const winner = over.player >= 0 && over.cpu >= 0 ? (Math.abs(over.player - over.cpu) < 1 ? 'draw' : over.player > over.cpu ? 'player' : 'cpu') : over.player >= 0 ? 'player' : 'cpu'; const winner = over.player >= 0 && over.cpu >= 0 ? (Math.abs(over.player - over.cpu) < 1 ? 'draw' : over.player > over.cpu ? 'player' : 'cpu') : over.player >= 0 ? 'player' : 'cpu';
c.step = 'result'; c.result = { winner, flicks: { ...c.flicks } }; c.step = 'result'; c.result = { winner, flicks: { ...c.flicks }, over };
return; return;
} }
// Chi ha già tagliato il traguardo aspetta; spinge l'altro. // Chi ha già tagliato il traguardo aspetta; spinge l'altro.
@@ -285,7 +314,10 @@ export function aimAt(c, x, y) {
if (Math.hypot(x - car.x, y - car.y) < CAR.r) return false; if (Math.hypot(x - car.x, y - car.y) < CAR.r) return false;
c.angle = Math.atan2(y - car.y, x - car.x); return true; c.angle = Math.atan2(y - car.y, x - car.x); return true;
} }
export const aimScreen = (c, [x, y]) => x >= 0 && x <= 640 && y >= 24 && y <= 360 && aimAt(c, x, y); export function aimScreen(c, [x, y]) {
if (c.step === 'choose') { const i = carUnder(x, y); return i >= 0 && choose(c, i); }
return x >= 0 && x <= 640 && y >= 24 && y <= 360 && aimAt(c, x, y);
}
export function nudge(c, dir, fine = false) { export function nudge(c, dir, fine = false) {
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false; if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
c.angle += dir * (fine ? 0.004 : 0.03); return true; c.angle += dir * (fine ? 0.004 : 0.03); return true;
@@ -294,8 +326,9 @@ export function setPower(c, value) {
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false; if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
c.power = clamp(value, 0, 1); return true; c.power = clamp(value, 0, 1); return true;
} }
export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); } export function shoot(c) { return c.step === 'choose' ? pick(c) : canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
export function turn(c, dir, owner, fine = false) { export function turn(c, dir, owner, fine = false) {
if (c.step === 'choose') { choose(c, c.choice + dir); return false; } // nella scelta frecce e pulsanti cambiano macchinina
if (!canThrow(c) || c.holding || c.turn) return false; if (!canThrow(c) || c.holding || c.turn) return false;
c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.004 : 0.03); c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.004 : 0.03);
c.aimLocked = true; return true; c.aimLocked = true; return true;
@@ -311,10 +344,11 @@ export function key(c, key, down, fine = false) {
return true; return true;
} }
export function press(c, pointerId) { export function press(c, pointerId) {
if (c.step === 'choose') return pick(c);
if (!canThrow(c) || c.holding) return false; if (!canThrow(c) || c.holding) return false;
c.turn = null; c.pointerId = pointerId; c.holdDelay = pointerId === undefined ? 0 : LONG_PRESS; // Premuto, la forza parte da zero e sale finché non si lascia (Andrea, 29 ottobre): come il dito che si carica.
c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0; c.turn = null; c.pointerId = pointerId; c.holdDelay = 0;
if (!c.holdDelay) c.power = 0; c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 0;
return true; return true;
} }
export function cancel(c, pointerId) { export function cancel(c, pointerId) {
@@ -325,7 +359,7 @@ export function cancel(c, pointerId) {
} }
export function release(c, pointerId) { export function release(c, pointerId) {
if (c.pointerId !== pointerId || !c.holding) return false; if (c.pointerId !== pointerId || !c.holding) return false;
if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto if (c.holdTime < TAP) return cancel(c, pointerId); // il tocco secco mira soltanto
const accompagne = (c.overHold ?? 0) > ACCOMPAGNA; const accompagne = (c.overHold ?? 0) > ACCOMPAGNA;
c.holding = c.charging = false; c.overHold = 0; c.holding = c.charging = false; c.overHold = 0;
strike(c, 'player', c.angle, c.power); strike(c, 'player', c.angle, c.power);
@@ -346,16 +380,35 @@ export function guide(c) {
return { x: car.x + ux * length, y: car.y + uy * length, length, out }; return { x: car.x + ux * length, y: car.y + uy * length, length, out };
} }
// Il pulsante del pannello vale anche nella scelta della macchinina.
export const canPress = c => canThrow(c) || c.step === 'choose';
export const canAdjust = canPress;
export function button(c) { export function button(c) {
if (c.step === 'choose') return 'Scegli';
if (c.step === 'result') return 'Finita'; if (c.step === 'result') return 'Finita';
if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…'; if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…';
return c.current === 'cpu' ? 'Spinge il Chiaru…' : 'Spingi'; return c.current === 'cpu' ? 'Spinge il Chiaru…' : 'Spingi';
} }
// Il perché del risultato, da scrivere (Andrea, 29 ottobre: «ho tagliato prima io, ma ha vinto il Chiaru»). Le
// macchinine vere sono lunghe 4 centimetri e mezzo: un pixel è quasi due millimetri.
export const CM = 4.5 / CAR.len;
export function why(c) {
const { winner, flicks, over } = c.result, n = flicks.player;
if (over.player >= 0 && over.cpu >= 0) {
const cm = Math.max(1, Math.round(Math.abs(over.player - over.cpu) * CM));
if (winner === 'draw') return [`Tutti e due al traguardo alla spinta ${n},`, 'pari al millimetro.'];
return [`Tutti e due al traguardo alla spinta ${n}:`, `vince chi va più in là, ${winner === 'player' ? 'Andrea' : 'il Chiaru'} di ${cm} ${cm === 1 ? 'centimetro' : 'centimetri'}.`];
}
return winner === 'player' ? [`Andrea al traguardo alla spinta ${n};`, 'al Chiaru non è bastata la sua.'] : [`Il Chiaru al traguardo alla spinta ${n};`, 'Andrea non c\'è arrivato.'];
}
// Chi è davanti adesso (1 o 2), per strada fatta.
export const rank = (c, who) => c.cars[who].progress >= c.cars[OTHER[who]].progress ? 1 : 2;
// Quanta pista manca al traguardo, in percentuale, per il tabellone. // Quanta pista manca al traguardo, in percentuale, per il tabellone.
export const done = (c, who) => clamp(c.cars[who].progress / (c.laps * c.track.length), 0, 1); export const done = (c, who) => clamp(c.cars[who].progress / (c.laps * c.track.length), 0, 1);
export function status(c) { export function status(c) {
const where = `Andrea ${Math.round(done(c, 'player') * 100)}%, Chiaru ${Math.round(done(c, 'cpu') * 100)}% del giro.`; if (c.step === 'choose') return `Scegli la macchinina: ${CARS[c.choice].name}. Tocca quella che vuoi, o frecce e Scegli.`;
if (c.step === 'result') return `${where} ${c.result.winner === 'player' ? 'Hai battuto il Chiaru!' : c.result.winner === 'cpu' ? 'Ha vinto il Chiaru.' : 'Arrivati pari.'}`; const where = `Andrea ${rank(c, 'player')}° (${Math.round(done(c, 'player') * 100)}%), Chiaru ${rank(c, 'cpu')}° (${Math.round(done(c, 'cpu') * 100)}%).`;
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai battuto il Chiaru!' : c.result.winner === 'cpu' ? 'Ha vinto il Chiaru.' : 'Arrivati pari.'} ${why(c).join(' ')}`;
if (c.step === 'rolling' || c.step === 'settle') return `${where} ${c.line ?? ''}`.trim(); if (c.step === 'rolling' || c.step === 'settle') return `${where} ${c.line ?? ''}`.trim();
if (c.current === 'cpu') return `${where} Spinge il Chiaru…`; if (c.current === 'cpu') return `${where} Spinge il Chiaru…`;
return `${where} ${c.line ? c.line + ' ' : ''}Tocca a te: mira e spingi.`; return `${where} ${c.line ? c.line + ' ' : ''}Tocca a te: mira e spingi.`;
+12 -6
View File
@@ -10,6 +10,7 @@ import { drawBasket } from './basket-render.js';
import { drawPesca } from './pesca-render.js'; 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 { 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';
@@ -131,7 +132,7 @@ export function backgroundOrder(story, first) {
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.map(id => story.scenes[id].background).filter(Boolean))]; return [...new Set(queue.flatMap(id => [story.scenes[id].background, ...(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.
@@ -153,8 +154,8 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
} }
export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) { export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) {
const scene = game.scene, s = game.state, bg = backgrounds.get(scene.background); const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
if (!bg && scene.background) loadBackground(scene.background); if (!bg && backdrop) loadBackground(backdrop);
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, WIDTH, HEIGHT); ctx.clearRect(0, 0, WIDTH, HEIGHT);
if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT); if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT);
@@ -395,7 +396,7 @@ export const EFFECTS = {
// Alberi al vento (i cipressi): il pezzo di sfondo si piega tutto insieme dalla base, di frazioni di pixel e con // Alberi al vento (i cipressi): il pezzo di sfondo si piega tutto insieme dalla base, di frazioni di pixel e con
// due onde lente sovrapposte, così il movimento è morbido e non a scatti. // due onde lente sovrapposte, così il movimento è morbido e non a scatti.
sway(ctx, e, time, game) { sway(ctx, e, time, game) {
const bg = backgrounds.get(game.scene.background); const bg = backgrounds.get(game.backdrop());
if (!bg) return; if (!bg) return;
const k = bg.width / WIDTH, amount = e.amount ?? 3.2; const k = bg.width / WIDTH, amount = e.amount ?? 3.2;
for (const [i, [x, y, w, h]] of e.rects.entries()) { for (const [i, [x, y, w, h]] of e.rects.entries()) {
@@ -412,7 +413,7 @@ export const EFFECTS = {
// ognuna a una sua altezza e velocità, uscita da un lato rientra dall'altro. Si vedono solo dove lo sfondo è // ognuna a una sua altezza e velocità, uscita da un lato rientra dall'altro. Si vedono solo dove lo sfondo è
// cielo (skyMask): passano dietro alberi, torri e tetti. Le due grandi solo coi cieli larghi (`big`). // cielo (skyMask): passano dietro alberi, torri e tetti. Le due grandi solo coi cieli larghi (`big`).
clouds(ctx, e, time, game) { clouds(ctx, e, time, game) {
const bg = backgrounds.get(game.scene.background), mask = bg && skyMask(bg); const bg = backgrounds.get(game.backdrop()), mask = bg && skyMask(bg);
if (!mask) return; if (!mask) return;
const layer = scratch(), lc = layer.getContext('2d'), [ax, ay, aw, ah] = e.area; const layer = scratch(), lc = layer.getContext('2d'), [ax, ay, aw, ah] = e.area;
const pool = e.big ? CLOUDS : CLOUDS.slice(2); const pool = e.big ? CLOUDS : CLOUDS.slice(2);
@@ -452,7 +453,7 @@ export const EFFECTS = {
// Una bandiera dipinta nello sfondo che sventola: a strisce verticali che salgono e scendono, ferme dalla parte // Una bandiera dipinta nello sfondo che sventola: a strisce verticali che salgono e scendono, ferme dalla parte
// dell'asta (`pole`: 'left' o 'right') e sempre più mosse verso il lembo libero. // dell'asta (`pole`: 'left' o 'right') e sempre più mosse verso il lembo libero.
cloth(ctx, e, time, game) { cloth(ctx, e, time, game) {
const bg = backgrounds.get(game.scene.background); const bg = backgrounds.get(game.backdrop());
if (!bg) return; if (!bg) return;
const k = bg.width / WIDTH; const k = bg.width / WIDTH;
for (const [i, [x, y, w, h]] of e.rects.entries()) { for (const [i, [x, y, w, h]] of e.rects.entries()) {
@@ -687,6 +688,11 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time); drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return; return;
} }
if (def.type === 'cerbottana') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawCerbottana(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); });
+5
View File
@@ -136,6 +136,11 @@ export class Soundscape {
else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan); else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan);
else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan); else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan);
else if (kind === 'gara-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); } else if (kind === 'gara-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); }
// La cerbottana: il soffio, il pallino che si schiaccia sul muro, la lattina, la tosse.
else if (kind === 'cerbottana-pff') this.noiseSound(0.12, 2200, 0.05 + strength * 0.05, { pan });
else if (kind === 'cerbottana-muro') { this.tone(180, 90, 0.06, 0.05, 0, pan); this.noiseSound(0.05, 600, 0.04, { pan }); }
else if (kind === 'cerbottana-latta') { this.tone(2400, 1600, 0.08, 0.05 + strength * 0.04, 0, pan); this.tone(3400, 2600, 0.05, 0.03, 0.03, 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 }); }
else if (kind === 'micro-strisciata') this.noiseSound(0.3 + strength * 0.7, 900, 0.03 + strength * 0.04, { pan }); else if (kind === 'micro-strisciata') this.noiseSound(0.3 + strength * 0.7, 900, 0.03 + strength * 0.04, { pan });
+33 -13
View File
@@ -12,6 +12,7 @@ import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js'; import * as biliardo from './engine/biliardo.js';
import * as rinquarto from './engine/rinquarto.js'; 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 basket from './engine/basket.js'; import * as basket from './engine/basket.js';
import * as pesca from './engine/pesca.js'; import * as pesca from './engine/pesca.js';
import * as gara from './engine/gara.js'; import * as gara from './engine/gara.js';
@@ -510,7 +511,7 @@ canvas.addEventListener('pointerdown', e => {
} }
if (press?.closeup) return; if (press?.closeup) return;
const aimed = game.closeupAim(scenePoint(e)); const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'rinquarto', 'basket', 'micro'].includes(story.closeups[game.closeup.id].type)) { if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana'].includes(story.closeups[game.closeup.id].type)) {
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
} }
@@ -582,7 +583,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'].includes(story.closeups[game.closeup.id].type); const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana'].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));
}); });
@@ -603,10 +604,11 @@ const MINI = {
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' }, bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' }, biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
rinquarto: { ...rinquarto, back: 'Torna da Trappizzio Berti', label: 'I rinquarti di Trappizzio Berti: clic o tocco fissa la mira, frecce o pulsanti Mira per girare la stecca (Maiusc per i passi fini), Tira per tirare. Dopo l’esito, Prossima.' }, rinquarto: { ...rinquarto, back: 'Torna da Trappizzio Berti', label: 'I rinquarti di Trappizzio Berti: clic o tocco fissa la mira, frecce o pulsanti Mira per girare la stecca (Maiusc per i passi fini), Tira per tirare. Dopo l’esito, Prossima.' },
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.' },
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).
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza: tieni premuto sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' }, gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza. Alla bona e Seria: tieni premuto sulla scena (o Spazio) per il gas e lascia per frenare, lo sterzo va da solo; in curva troppo forte si esce. Ginocchio in terra: tieni premuto sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' },
pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' }, pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' },
}; };
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null; const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
@@ -634,7 +636,17 @@ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('gara-p
padPointer = null; $('gara-pad').classList.remove('active'); padPointer = null; $('gara-pad').classList.remove('active');
}); });
$('gara-pad').addEventListener('contextmenu', e => e.preventDefault()); $('gara-pad').addEventListener('contextmenu', e => e.preventDefault());
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); }); // GP, guida semplice: il pulsante Gas si tiene premuto (giù gas, su freno) invece di accendersi e spegnersi al clic;
// sul telefono, alla bona, il clic arrivava solo alzando il dito (Andrea, 29 ottobre).
const holdGas = c => !!c && story.closeups[c.id].type === 'gara' && gara.simple(c) && c.step !== 'intro' && c.step !== 'result';
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech && !holdGas(c)) mini().shoot?.(c); });
$('bocce-throw').addEventListener('pointerdown', e => {
const c = miniState(); if (!holdGas(c) || game.speech) return;
e.preventDefault();
if (gara.press(c, 'gas-button')) try { $('bocce-throw').setPointerCapture(e.pointerId); } catch { /* un tocco sintetico: va bene lo stesso */ }
});
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('bocce-throw').addEventListener(ev, () => { const c = miniState(); if (holdGas(c)) gara.release(c, 'gas-button'); });
$('bocce-throw').addEventListener('contextmenu', e => { if (holdGas(miniState())) e.preventDefault(); });
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco. // Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt }; const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@@ -935,7 +947,10 @@ function updateUi() {
updateSpeech(); updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id]; const mg = miniState() && mini(), def = mg && story.closeups[c.id];
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type); const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
setProp($('bocce-controls'), 'hidden', !mg || cards); // La presentazione dei piloti del GP a tutto schermo: i comandi arrivano col semaforo (Andrea, 29 ottobre).
const presenta = !!mg && story.closeups[c.id].type === 'gara' && c.step === 'intro';
$('app').classList.toggle('presenta', presenta);
setProp($('bocce-controls'), 'hidden', !mg || cards || presenta);
setProp($('bocce-controls'), 'inert', !running); setProp($('bocce-controls'), 'inert', !running);
setProp($('briscola-controls'), 'hidden', def?.type !== 'briscola'); setProp($('briscola-controls'), 'hidden', def?.type !== 'briscola');
setProp($('briscola-controls'), 'inert', !running); setProp($('briscola-controls'), 'inert', !running);
@@ -1000,7 +1015,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' }[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' }[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;
@@ -1011,25 +1026,30 @@ function updateUi() {
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti). // Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata). // Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
const powerLabel = $('bocce-power').closest('label'); const powerLabel = $('bocce-power').closest('label');
// Ai rinquarti la forza è sempre piena: il cursore non serve. // Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si
setProp(powerLabel, 'hidden', def.type === 'rinquarto'); // usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza '; const easy = def.type === 'gara' && gara.simple(c);
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || easy);
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy);
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');
setProp($('gara-pad'), 'hidden', def.type !== 'gara'); setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy);
if (def.type === 'gara' && c.sensLoaded === undefined) { if (def.type === 'gara' && c.sensLoaded === undefined) {
c.sensLoaded = true; c.sensLoaded = true;
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ } try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
} }
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira'); setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira');
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →'); setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →');
setProp($('gara-brake'), 'hidden', def.type !== 'gara'); setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy);
$('bocce-throw').classList.toggle('hold', easy);
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech); setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
setText($('bocce-throw'), mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira')); setText($('bocce-throw'), mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'));
setProp($('bocce-help'), 'hidden', def.type !== 'bocce'); setProp($('bocce-help'), 'hidden', def.type !== 'bocce');
setProp($('biliardo-help'), 'hidden', def.type !== 'biliardo'); setProp($('biliardo-help'), 'hidden', def.type !== 'biliardo');
setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto'); setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto');
setProp($('micro-help'), 'hidden', def.type !== 'micro'); setProp($('micro-help'), 'hidden', def.type !== 'micro');
setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana');
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');
@@ -1047,7 +1067,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'].includes(story.closeups[c.id].type)); $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana'].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.
@@ -1082,7 +1102,7 @@ function frame(now) {
ambience.update(soundscapeFor(game?.state.scene, game?.closeup?.id), game?.phase()?.id, dt); ambience.update(soundscapeFor(game?.state.scene, game?.closeup?.id), game?.phase()?.id, dt);
// A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del // A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del
// telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava. // telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava.
const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`; const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
if (game && (!still || still !== drawnStill)) { if (game && (!still || still !== drawnStill)) {
if (running) game.update(dt); if (running) game.update(dt);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() }); render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
+135 -27
View File
@@ -233,13 +233,23 @@ export const story = {
quit: [{ say: 'Questo me lo tengo per la prossima.' }], quit: [{ say: 'Questo me lo tengo per la prossima.' }],
draw: [{ say: 'Visto? Geometrie celestiali. Si riprova quando vuoi.', who: 'bertovo', pose: 'explain' }], draw: [{ say: 'Visto? Geometrie celestiali. Si riprova quando vuoi.', who: 'bertovo', pose: 'explain' }],
}, },
// La cerbottana con lo stucco verde di Pierin di Frilli, contro il muro della piazza (Andrea, 29 ottobre).
// [DA ANDREA: le battute sono provvisorie]
cerbottana: {
type: 'cerbottana',
picture: 'assets/closeups/muro-cerbottana.png',
names: { player: 'Andrea' },
win: [{ say: 'Tutti sul muro, verdi in fila. Lo stucco di Pierino un tradisce mai.' }, { set: 'cerbottana_vinta' }],
lose: [{ say: 'Finito lo stucco. Domani si ripassa da Pierino.' }],
quit: [{ say: 'Il muro resta lì. E l\'odore dello stucco, sulle dita, pure.' }],
draw: [],
},
// 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: {
type: 'micro', type: 'micro', music: 'assets/audio/macchinine.mp3', // di Andrea (Suno, 29 ottobre)
picture: 'assets/closeups/micro-parcheggio.png', picture: 'assets/closeups/micro-parcheggio.png',
names: { player: 'Andrea', cpu: 'Chiaru' }, names: { player: 'Andrea', cpu: 'Chiaru' },
cars: { player: 'assets/closeups/micro/rossa.png', cpu: 'assets/closeups/micro/blu.png' },
win: [{ say: 'Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.', who: 'chiaru', pose: 'shrug' }, { set: 'micro_vinto' }], win: [{ say: 'Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.', who: 'chiaru', pose: 'shrug' }, { set: 'micro_vinto' }],
lose: [{ say: 'Visto? La blu non perde mai. Mai.', who: 'chiaru', pose: 'cheer' }], lose: [{ say: 'Visto? La blu non perde mai. Mai.', who: 'chiaru', pose: 'cheer' }],
quit: [{ say: 'Ti ritiri? Allora la vinco a tavolino.', who: 'chiaru', pose: 'laugh' }], quit: [{ say: 'Ti ritiri? Allora la vinco a tavolino.', who: 'chiaru', pose: 'laugh' }],
@@ -313,6 +323,8 @@ export const story = {
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' }, tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
// Il Chiaru (Matteo Chiarusi, consenso dato ad Andrea il 29 ottobre), alla pista col gesso di Cardetole: sprite di // Il Chiaru (Matteo Chiarusi, consenso dato ad Andrea il 29 ottobre), alla pista col gesso di Cardetole: sprite di
// Codex dalla foto di Andrea. // Codex dalla foto di Andrea.
// Lo zio Mario di Andrea (sprite di Codex dalla foto di Andrea, 29 ottobre), alla Ghiacciomobile nel muro.
zio_mario: { name: 'Lo zio Mario', color: '#cfe3ff', sprite: 'assets/characters/zio_mario.json' },
chiaru: { name: 'Il Chiaru', color: '#ffd6a0', sprite: 'assets/characters/chiaru.json' }, chiaru: { name: 'Il Chiaru', color: '#ffd6a0', sprite: 'assets/characters/chiaru.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' }, barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' }, mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
@@ -504,6 +516,17 @@ export const story = {
look: [{ say: 'La strada che porta al Fiocco di Neve. Già sento il profumo di cialda.' }], look: [{ say: 'La strada che porta al Fiocco di Neve. Già sento il profumo di cialda.' }],
}, },
}, },
{
// La bottega di Pierin di Frilli sotto la tenda rossa: da ragazzi ci si comprava lo stucco verde per la
// cerbottana (Andrea, 29 ottobre). [DA ANDREA: com'era dentro, e cosa diceva Pierino]
id: 'pierin', name: 'la bottega di Pierin di Frilli', box: [598, 176, 42, 92], at: [612, 306], face: 'right', default: 'talk',
actions: {
look: [{ say: 'La bottega di Pierin di Frilli, sotto la tenda rossa. Da ragazzi ci si comprava lo stucco per la cerbottana.' }],
talk: [{ dialogue: 'pierin' }],
open: [{ dialogue: 'pierin' }],
use: [{ dialogue: 'pierin' }],
},
},
{ {
// Il Circolo 4 Maggio: il portone ad arco del palazzo arancione porta nella sala dei biliardi. // Il Circolo 4 Maggio: il portone ad arco del palazzo arancione porta nella sala dei biliardi.
id: 'circolo', name: 'Circolo 4 Maggio', box: [188, 190, 28, 58], at: [202, 252], face: 'up', id: 'circolo', name: 'Circolo 4 Maggio', box: [188, 190, 28, 58], at: [202, 252], face: 'up',
@@ -2117,23 +2140,11 @@ export const story = {
entry: [320, 330], entry: [320, 330],
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre). // L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
speakers: { arcu: [418, 214] }, speakers: { arcu: [418, 214] },
// La Ghiacciomobile non è più qui: sta nel muro di Via Liguri Magelli (Andrea, 29 ottobre).
people: [ people: [
{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }, { who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
// Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce.
// Con la depth della scena misura circa 160 px; resta sopra il bordo di walk. (Il Betino dell'Arcu sta in
// cima alla salita di Via Liguri Magelli, Andrea 28 ottobre.)
{ who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] },
], ],
hotspots: [ hotspots: [
{
id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look',
// La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde.
actions: {
look: [{ say: 'La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.' }],
talk: [{ dialogue: 'ghiacciomobile' }],
use: [{ say: 'Con questo sole, di ghiaccio c\'è rimasto giusto il nome. Vo a piedi.' }],
},
},
{ {
id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up', id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up',
actions: { actions: {
@@ -2711,14 +2722,21 @@ export const story = {
name: 'Via Liguri Magelli', name: 'Via Liguri Magelli',
map: [76, 97], map: [76, 97],
background: 'assets/scenes/via-liguri-magelli.png', background: 'assets/scenes/via-liguri-magelli.png',
// All'inizio la Ghiacciomobile è dentro il muro giallo, coi mattoni rotti (Codex, 29 ottobre); dal tramonto
// (le impronte del Regolo) il muro è rifatto e c'è la sua targa (Andrea, 29 ottobre).
backdrops: [{ when: '!indizio_regolo', src: 'assets/scenes/via-liguri-magelli-muro.png' }],
// La strada gira intorno all'Audi nera parcheggiata davanti al Trappola. // La strada gira intorno all'Audi nera parcheggiata davanti al Trappola.
walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [300, 242], [370, 246], [400, 252], [580, 318], [640, 330], [640, 360]], walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [300, 242], [370, 246], [400, 252], [580, 318], [640, 330], [640, 360]],
depth: [[198, 0.3], [360, 1.1]], // Le persone in proporzione con la Ghiacciomobile e l'Audi (Andrea, 29 ottobre: erano troppo piccole).
depth: [[198, 0.4], [360, 2.4]],
entry: [320, 340], entry: [320, 340],
speakers: { arcu: [374, 176] }, speakers: { arcu: [374, 176], zio_mario: [440, 196] },
people: [ people: [
// L'Arcu sotto la tenda del Trappola: due minuti fa era a Campomigliaio (Andrea, 28 ottobre). // L'Arcu sotto la tenda del Trappola: due minuti fa era a Campomigliaio (Andrea, 28 ottobre).
{ who: 'arcu', at: [374, 248], facing: -1, size: 1.3, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }, { who: 'arcu', at: [374, 248], facing: -1, size: 0.75, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
// Lo zio Mario davanti alla Ghiacciomobile nel muro, che si gratta la testa (sprite di Codex dalla sua foto,
// 29 ottobre). Solo finché la macchina è lì. [DA ANDREA: le sue battute]
{ who: 'zio_mario', when: '!indizio_regolo', at: [440, 330], facing: 1, idle: 'think', talk: ['talk', 'explain', 'point'] },
], ],
hotspots: [ hotspots: [
{ {
@@ -2746,11 +2764,21 @@ export const story = {
}, },
}, },
{ {
id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look', id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look', when: 'indizio_regolo',
actions: { actions: {
look: [{ say: '«Qui riposa la Ghiacciomobile, che si è schiantata lì.»' }, { say: 'C\'è anche la fotina. Pareva sempre nuova.' }], look: [{ say: '«Qui riposa la Ghiacciomobile, che si è schiantata lì.»' }, { say: 'C\'è anche la fotina. Pareva sempre nuova.' }],
}, },
}, },
{
// La Ghiacciomobile nel muro (prima del tramonto). Le battute erano del circolino (Andrea, 26 ottobre);
// [DA ANDREA] quella dello schianto. Parlandole, Andrea ricorda; lei non risponde.
id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [462, 192, 140, 92], at: [440, 300], face: 'right', default: 'look', when: '!indizio_regolo',
actions: {
look: [{ say: 'La Ghiacciomobile nera, mezza dentro il muro. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.' }],
talk: [{ dialogue: 'ghiacciomobile' }],
use: [{ say: 'Per tirarla fuori di lì ci vuole altro che le mie braccia.' }],
},
},
{ {
// La prima battuta è di Andrea (dal circolino, 26 ottobre). // La prima battuta è di Andrea (dal circolino, 26 ottobre).
id: 'betino', name: 'il Betino dell\'Arcu', box: [232, 172, 40, 24], at: [250, 206], face: 'up', default: 'look', id: 'betino', name: 'il Betino dell\'Arcu', box: [232, 172, 40, 24], at: [250, 206], face: 'up', default: 'look',
@@ -2765,7 +2793,16 @@ export const story = {
}, },
{ {
// Il campo del Giannini è laggiù a destra, oltre la siepe (Andrea, 28 ottobre). // Il campo del Giannini è laggiù a destra, oltre la siepe (Andrea, 28 ottobre).
id: 'al_giannini', name: 'verso il campo del Giannini', box: [430, 150, 132, 100], at: [520, 306], exit: { scene: 'campo_giannini', at: [60, 250], face: 'right' }, id: 'al_giannini', name: 'verso il campo del Giannini', box: [404, 150, 58, 50], at: [520, 306], exit: { scene: 'campo_giannini', at: [60, 250], face: 'right' },
},
// In fondo all'elenco, così sta sopra all'uscita e alla siepe.
{
id: 'zio_mario', name: 'lo zio Mario', box: [418, 206, 46, 124], at: [362, 334], face: 'right', default: 'look', when: '!indizio_regolo',
actions: {
look: [{ say: 'Lo zio Mario. Guarda il muro, guarda la macchina, e si gratta la testa sotto il berretto.' }],
// Il primo scambio con lo zio (Andrea, 29 ottobre).
talk: [{ say: 'Zio, hai problemi?' }, { say: 'I problemi io gli avevo quando andavo a scuola!', who: 'zio_mario', pose: 'laugh' }],
},
}, },
], ],
}, },
@@ -2788,11 +2825,10 @@ export const story = {
}, },
}, },
{ {
id: 'casa', name: 'casa di Andrea', box: [330, 20, 220, 212], at: [470, 290], face: 'up', // Si sale in terrazza (Andrea, 29 ottobre).
id: 'casa', name: 'casa di Andrea, su in terrazza', box: [330, 20, 220, 212], at: [470, 290], face: 'up', exit: { scene: 'terrazza', at: [300, 330], face: 'up' },
actions: { actions: {
look: [{ say: 'Casa mia. Secondo piano: quella con la tapparella a metà è la camera di mia sorella, e a destra quella dei miei.' }], look: [{ say: 'Casa mia. Secondo piano: quella con la tapparella a metà è la camera di mia sorella, e a destra quella dei miei.' }],
open: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }],
walk: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }],
}, },
}, },
{ {
@@ -2833,6 +2869,54 @@ export const story = {
}, },
], ],
}, },
// Il terrazzo di casa a Cardetole: la vista dalla finestra di Andrea (foto sua, 29 ottobre), il paese che sale e
// lassù la Fortezza. Codex l'ha dipinta dalla foto e ci ha messo il terrazzo davanti. [DA ANDREA: le battute]
terrazza: {
name: 'Il terrazzo di casa',
map: [92, 81],
background: 'assets/scenes/terrazza-cardetole.png',
walk: [[120, 298], [500, 298], [560, 330], [604, 360], [60, 360], [100, 322]],
depth: [[290, 1.8], [360, 2.3]],
entry: [300, 330],
enter: [{ if: '!terrazza_vista', then: [{ say: 'La vista di casa. Da qui si vede mezzo paese, e lassù la Fortezza.' }, { set: 'terrazza_vista' }] }],
hotspots: [
{
id: 'fortezza', name: 'la Fortezza, lassù', box: [176, 44, 52, 30], at: [220, 304], face: 'up',
actions: { look: [{ say: 'Lassù in cima, fra gli alberi, la Fortezza. Da qui pare piccina.' }] },
},
{
id: 'paese', name: 'il paese', box: [196, 96, 220, 60], at: [300, 304], face: 'up',
actions: { look: [{ say: 'Il paese che sale verso la collina, coi tetti uno sull\'altro e il campanile in mezzo.' }] },
},
{
id: 'palazzo', name: 'il palazzo accanto', box: [504, 54, 136, 140], at: [470, 304], face: 'up',
actions: { look: [{ say: 'Il palazzo accanto, di mattoni come il nostro.' }] },
},
{
id: 'gerani', name: 'gerani', box: [500, 226, 100, 94], at: [480, 330], face: 'right',
actions: { look: [{ say: 'I gerani. Guai a chi li tocca.' }], take: [{ say: 'Se ne strappo uno, il palio lo guardo dal letto.' }] },
},
{
id: 'edera', name: 'edera', box: [54, 176, 136, 134], at: [150, 330], face: 'left',
actions: { look: [{ say: 'L\'edera nel vaso: cresce da sola, e si arrampica dove le pare.' }] },
},
{
id: 'sedia', name: 'sedia di plastica', box: [592, 208, 48, 116], at: [560, 340], face: 'right',
actions: { look: [{ say: 'Una sedia di plastica, piegata.' }], use: [{ say: 'Sedermi qui a guardare il paese? Prima il palio.' }] },
},
{
id: 'in_casa', name: 'dentro casa', box: [0, 0, 54, 340], at: [112, 340], face: 'left',
actions: {
look: [{ say: 'La porta-finestra di casa, con la tapparella a metà.' }],
walk: [{ say: 'Se rientro, mi mettono ad apparecchiare. Prima il palio.' }],
open: [{ say: 'Se rientro, mi mettono ad apparecchiare. Prima il palio.' }],
},
},
{
id: 'giu_cardetole', name: 'giù, sotto casa', box: [120, 340, 400, 20], at: [300, 352], exit: { scene: 'cardetole', at: [470, 290], face: 'down' },
},
],
},
// L'ingresso della scuola, giù per la strada dalla salita di Villa Adami: dal cancello alle porte a vetri. // L'ingresso della scuola, giù per la strada dalla salita di Villa Adami: dal cancello alle porte a vetri.
scuola: { scuola: {
name: 'La scuola', name: 'La scuola',
@@ -3249,12 +3333,34 @@ export const story = {
}, },
}, },
}, },
// Da Pierin di Frilli: la cerbottana e lo stucco verde, poi il muro della piazza (Andrea, 29 ottobre).
pierin: {
nodes: {
start: {
options: [
{ text: 'Una cerbottana e un po\' di stucco, Pierino!', next: 'livello', then: [
{ say: 'Lo stucco verde di Pierino: quell\'odore non te lo levi più dalle dita.' },
{ say: 'Ora un muro. Quello della piazza va benissimo.' },
] },
{ text: 'Niente, grazie: guardavo.', next: 'end' },
],
},
livello: {
options: [
{ text: 'Alla bona.', next: 'end', then: [{ closeup: 'cerbottana', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ closeup: 'cerbottana', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ closeup: 'cerbottana', level: 'male' }] },
{ text: 'No, dopo.', next: 'end' },
],
},
},
},
// 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]
chiaru: { chiaru: {
nodes: { nodes: {
start: { start: {
options: [ options: [
{ text: 'Si corre?', next: 'livello', then: [{ say: 'Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?', who: 'chiaru', pose: 'explain' }] }, { text: 'Si fa una gara alle macchinine?!?!', next: 'livello', then: [{ say: 'Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?', who: 'chiaru', pose: 'explain' }] },
{ text: 'Che pista hai disegnato?', then: [ { text: 'Che pista hai disegnato?', then: [
{ say: 'Rettilineo, curvone, rettilineo, e in fondo la esse. Nella esse chi ha fretta esce.', who: 'chiaru', pose: 'explain' }, { say: 'Rettilineo, curvone, rettilineo, e in fondo la esse. Nella esse chi ha fretta esce.', who: 'chiaru', pose: 'explain' },
{ say: 'Il tombino l\'ho lasciato in pista: porta fortuna.', who: 'chiaru', pose: 'talk' }, { say: 'Il tombino l\'ho lasciato in pista: porta fortuna.', who: 'chiaru', pose: 'talk' },
@@ -3265,7 +3371,8 @@ export const story = {
{ say: 'Chi taglia la strada: tagliè. Chi si ribalta: cappottè. E chi esce tutto dal gesso, fuori.', who: 'chiaru', pose: 'talk' }, { say: 'Chi taglia la strada: tagliè. Chi si ribalta: cappottè. E chi esce tutto dal gesso, fuori.', who: 'chiaru', pose: 'talk' },
{ say: 'In tutti i casi, si torna dov\'eri prima.', who: 'chiaru', pose: 'shrug' }, { say: 'In tutti i casi, si torna dov\'eri prima.', who: 'chiaru', pose: 'shrug' },
] }, ] },
{ text: 'Dopo, Chiaru.', next: 'end', then: [{ say: 'La pista è qui. Finché non piove.', who: 'chiaru', pose: 'stand' }] }, // Il saluto (Andrea, 29 ottobre).
{ text: 'Vado a fare un girino alle case rosse.', next: 'end', then: [{ say: 'Vorrai dire un giochino!', who: 'chiaru', pose: 'laugh' }, { say: 'Ciao Mattè.' }] },
], ],
}, },
livello: { livello: {
@@ -3280,11 +3387,12 @@ export const story = {
}, },
// Trappizzio Berti al tavolo di mezzo: le biglie nascoste e «Oh tirami questo rinquarto!?» (Andrea, 28 ottobre). // Trappizzio Berti al tavolo di mezzo: le biglie nascoste e «Oh tirami questo rinquarto!?» (Andrea, 28 ottobre).
// [DA ANDREA: le battute sono provvisorie, tranne la sua sfida] // [DA ANDREA: le battute sono provvisorie, tranne la sua sfida]
// Andrea, parlandogli, lo chiama Fabri (29 ottobre).
bertovo: { bertovo: {
nodes: { nodes: {
start: { start: {
options: [ options: [
{ text: 'Trappizzio Berti! Che fai, giochi da solo?', then: [ { text: 'Fabri! Che fai, giochi da solo?', then: [
{ say: 'Da solo? Io gioco contro le sponde. E vinco quasi sempre.', who: 'bertovo', pose: 'explain' }, { say: 'Da solo? Io gioco contro le sponde. E vinco quasi sempre.', who: 'bertovo', pose: 'explain' },
] }, ] },
{ text: 'Come fai a prendere le biglie dove nessuno ci arriva?', then: [ { text: 'Come fai a prendere le biglie dove nessuno ci arriva?', then: [
@@ -3299,7 +3407,7 @@ export const story = {
{ text: 'Si fa una sfida a rinquarti?', next: 'sfida', then: [ { text: 'Si fa una sfida a rinquarti?', next: 'sfida', then: [
{ say: 'Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?', who: 'bertovo', pose: 'explain' }, { say: 'Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?', who: 'bertovo', pose: 'explain' },
] }, ] },
{ text: 'Ci si vede, Trappizzio Berti.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] }, { text: 'Ci si vede, Fabri.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] },
], ],
}, },
sfida: { sfida: {
+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.39'; export const VERSION = '0.1.47';
// 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).
+11 -5
View File
@@ -56,18 +56,22 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
text-align: center; color: #c5c6b6; border: 2px dashed #897b57; border-radius: 10px; background: #1d2320; } text-align: center; color: #c5c6b6; border: 2px dashed #897b57; border-radius: 10px; background: #1d2320; }
#gara-pad.active { border-style: solid; color: #f3dcac; } #gara-pad.active { border-style: solid; color: #f3dcac; }
#stage, #bocce-controls, #gara-pad { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } #stage, #bocce-controls, #gara-pad { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; } #bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; touch-action: none; }
/* La presentazione dei piloti del GP: la scena da sola, i comandi arrivano col semaforo. */
#app.presenta #side { display: none; }
/* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */
#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 { margin-top: 9px; color: #c5c6b6; font-size: 10px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-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 { 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 summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-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 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 { font-size: 9px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-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); }
@@ -318,6 +322,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#app { width: 100vw; flex-direction: row; } #app { width: 100vw; flex-direction: row; }
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; } #stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#app.presenta { justify-content: center; }
#app.presenta #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; } #briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */ /* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
+44
View File
@@ -0,0 +1,44 @@
// La cerbottana nel browser vero: dalla bottega di Pierin di Frilli al muro della piazza; mira, fiato e tiro col mouse
// e col dito. npm run browser:cerbottana — schermate in /tmp/regolo-cerbottana.
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-cerbottana';
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('piazza'); settle(game);
game.act('talk', 'pierin'); settle(game);
game.choose(game.options().findIndex(o => o.text.startsWith('Una cerbottana'))); settle(game);
game.choose(game.options().findIndex(o => o.text === 'Seria.')); settle(game);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/muro-cerbottana.png'));
assert.equal(await page.locator('#bocce-throw').textContent(), 'Spara');
assert.ok(await page.locator('#cerbottana-help').isVisible());
await page.waitForTimeout(1600);
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
if (mobile) await page.touchscreen.tap(...P(320, 118)); else { await page.mouse.move(...P(320, 118)); await page.mouse.click(...P(320, 118)); }
assert.equal(await page.evaluate(() => game.closeup.step), 'aim', 'un tocco mira soltanto');
await page.screenshot({ path: `${out}/${name}-mira.png` });
if (mobile) { await page.locator('#bocce-power').fill('70'); await page.locator('#bocce-throw').tap(); }
else { await page.mouse.down(); await page.waitForFunction(() => game.closeup.power >= 0.6); await page.mouse.up(); }
await page.waitForFunction(() => game.closeup.left === 11 && game.closeup.step === 'aim', null, { timeout: 10000 });
await page.screenshot({ path: `${out}/${name}-dopo.png` });
await page.close();
}
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+82
View File
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as m from '../src/engine/cerbottana.js';
import { drawCerbottana } from '../src/engine/cerbottana-render.js';
import { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
// Ferma il respiro (il mirino va dove si punta), spara col fiato dato e aspetta che il pallino arrivi.
function shot(c, x, y, power = m.BEST) {
c.skill = { ...c.skill, sway: 0 }; c.aim = { x, y };
assert.ok(m.setPower(c, power)); assert.ok(m.shoot(c));
for (let i = 0; i < 200 && c.step === 'flying'; i++) m.update(c, 1 / 60);
return c.line;
}
test('col fiato giusto il pallino va dove si punta; poco fiato cade sotto, troppo va sopra; resta attaccato al muro', () => {
const c = m.newCerbottana(1, { level: 'seria' });
assert.match(shot(c, m.TARGET.x, m.TARGET.y), /10 punti/);
assert.equal(c.points, 10); assert.deepEqual(c.stuck.at(-1), { x: m.TARGET.x, y: m.TARGET.y });
for (let i = 0; i < 60; i++) m.update(c, 1 / 60);
assert.ok(m.landing(c, 0.5).y > m.TARGET.y + 40, 'poco fiato: sotto');
assert.ok(m.landing(c, 0.9).y < m.TARGET.y - 40, 'troppo fiato: sopra');
shot(c, 320, 200, 0.2);
assert.match(c.line, /corto/); assert.equal(c.stuck.length, 1, 'il pallino corto non resta sul muro');
});
test('le lattine cadono, il barattolo appeso c\'è solo a seria e a fassi male; finito lo stucco si guarda il punteggio', () => {
const c = m.newCerbottana(2, { level: 'bona' });
const k = c.cans[0];
assert.match(shot(c, k.x, m.LEDGE - 8), /Lattina giù/);
assert.ok(k.down); assert.equal(c.points, m.CAN.points);
assert.equal(m.LEVELS.bona.swinger, false); assert.equal(m.LEVELS.seria.swinger, true);
// Il barattolo dondola mentre il pallino vola: si mira dove sarà all'arrivo.
const s = m.newCerbottana(3, { level: 'seria' }), p = m.swinger({ visual: { time: s.visual.time + m.FLIGHT + 1 / 120 } });
assert.match(shot(s, p.x, p.y + m.SWINGER.h / 2), /barattolo appeso/);
for (let i = 0; i < 60; i++) m.update(c, 1 / 60);
while (c.left > 0) { for (let i = 0; i < 60 && !m.canThrow(c); i++) m.update(c, 1 / 60); shot(c, m.TARGET.x, m.TARGET.y); }
for (let i = 0; i < 60; i++) m.update(c, 1 / 60);
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player');
assert.equal(c.stuck.length, m.PELLETS - 1);
});
test('i comandi: un tocco mira, tenere premuto prende fiato da zero, lasciare spara; troppo a fiato pieno si tossisce', () => {
const c = m.newCerbottana(4);
assert.ok(m.aimScreen(c, [200, 100])); assert.deepEqual(c.aim, { x: 200, y: 100 });
assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.equal(m.release(c, 1), true); assert.equal(c.step, 'aim', 'il tocco breve non spara');
assert.ok(m.press(c, 1)); assert.equal(c.power, 0);
for (let i = 0; i < 40; i++) m.update(c, 1 / 60);
assert.ok(c.charging && c.power > 0.5 && c.power < 0.8);
assert.ok(m.release(c, 1)); assert.equal(c.step, 'flying'); assert.equal(c.left, m.PELLETS - 1);
const d = m.newCerbottana(5); m.press(d, 1);
for (let i = 0; i < 60 * (m.SWING + m.COUGH + 0.2); i++) m.update(d, 1 / 60);
assert.equal(d.holding, false); assert.ok(d.events.some(e => e.kind === 'cerbottana-tosse'));
for (let i = 0; i < 60; i++) m.update(d, 1 / 60);
assert.match(d.line, /Coff/); assert.equal(d.left, m.PELLETS - 1);
assert.ok(m.key(d, 'ArrowUp', true)); assert.equal(d.aim.y, m.TARGET.y - 4);
});
test('il disegno: muro, gesso, lattine, pallini; lo stato non cambia', () => {
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
const c = m.newCerbottana(6, { level: 'seria' }), bg = {};
const draw = () => { const before = structuredClone(c); drawCerbottana(ctx, c, story.closeups.cerbottana, 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 CERBOTTANA'));
c.step = 'result'; c.result = { winner: 'player', points: 44, goal: 40 }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'CENTRATO!'));
});
test('da Pierin di Frilli, in piazza: lo stucco, il livello e il muro', () => {
assert.ok(MINIGAMES.cerbottana);
const g = new Game(story); g.go('piazza'); settle(g);
const h = g.hotspot('pierin');
assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, 'pierin');
g.act('talk', 'pierin'); settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('Una cerbottana'))); settle(g);
g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g);
assert.equal(g.closeup?.id, 'cerbottana'); assert.equal(g.closeup.level, 'male');
g.closeup.step = 'result'; g.closeup.result = { winner: 'player', points: 52, goal: 50 };
g.closeupClose(); settle(g);
assert.ok(g.state.flags.cerbottana_vinta);
});
+67
View File
@@ -0,0 +1,67 @@
// Il GP di Fortezza nel browser vero: alla bona e seria la guida semplice (solo gas, lasciando si frena), a ginocchio
// in terra lo sterzo vero. npm run browser:gara — schermate in /tmp/regolo-gara.
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-gara';
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, level] of [
['touch', true, { width: 844, height: 390 }, 'seria'],
['portrait', true, { width: 390, height: 844 }, 'bona'],
['desktop', false, { width: 1280, height: 860 }, 'male'],
]) {
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 level => {
const { settle } = await import('/tests/helpers.mjs');
settle(game); game.run({ closeup: 'gara', level }); settle(game);
}, level);
// La presentazione dei piloti a tutto schermo: niente comandi finché non c'è il semaforo.
await page.waitForFunction(() => game.closeup?.step === 'intro');
await page.waitForTimeout(300);
assert.equal(await page.locator('#bocce-controls').isVisible(), false, `${name}: comandi nascosti nella presentazione`);
assert.equal(await page.locator('#side').isVisible(), false, `${name}: la scena da sola`);
await page.screenshot({ path: `${out}/${name}-presentazione.png` });
// Salta la presentazione dei piloti e aspetta il via.
await page.evaluate(async () => { const g = await import('/src/engine/gara.js'); while (game.closeup.step === 'intro') g.press(game.closeup); });
await page.waitForFunction(() => game.closeup.step === 'race', null, { timeout: 15000 });
await page.waitForSelector('#bocce-controls:not([hidden])');
const easy = level !== 'male';
for (const id of ['#bocce-left', '#bocce-right', '#gara-brake', '#gara-pad'])
assert.equal(await page.locator(id).isVisible(), !easy, `${name}: ${id} ${easy ? 'nascosto' : 'visibile'}`);
assert.equal(await page.locator('#bocce-power').isVisible(), !easy, 'la sensibilità solo con lo sterzo vero');
// Nella guida semplice il pulsante Gas si tiene premuto (giù gas, su freno) e la scritta non cambia; con lo
// sterzo vero un tocco accende il gas e un altro lo spegne.
const gas = page.locator('#bocce-throw');
if (easy) await gas.dispatchEvent('pointerdown', { pointerId: 5, pointerType: mobile ? 'touch' : 'mouse', isPrimary: true });
else await gas.click();
await page.waitForFunction(() => game.closeup.riders.find(r => r.id === 'andrea').v > 40, null, { timeout: 15000 });
assert.equal(await gas.textContent(), easy ? 'Gas' : 'Molla');
await page.screenshot({ path: `${out}/${name}-${level}.png` });
if (easy) await gas.dispatchEvent('pointerup', { pointerId: 5, pointerType: mobile ? 'touch' : 'mouse', isPrimary: true });
else await gas.click();
const v = await page.evaluate(() => game.closeup.riders.find(r => r.id === 'andrea').v);
await page.waitForTimeout(300);
const after = await page.evaluate(() => game.closeup.riders.find(r => r.id === 'andrea').v);
assert.ok(after < v, `${name}: senza gas rallenta (${v.toFixed(0)} → ${after.toFixed(0)})`);
await page.close();
}
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally {
await browser?.close();
server.kill();
}
+67 -6
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { SWIPE, setPower, newGara, update, press, presenting, INTRO, release, cancel, shoot, key, steerButton, brakeButton, aimScreen, steering, status, button, standings, player, rider, limitAt, pointAt, TRACK, CURVES, PICKUPS, BRAKE, VERGE, LAPS, COUNTDOWN, RIDERS } from '../src/engine/gara.js'; import { SIMPLE, simple, simpleLimit, mustLift, PREAVVISO, SWIPE, setPower, newGara, update, press, presenting, INTRO, release, cancel, shoot, key, steerButton, brakeButton, aimScreen, steering, status, button, standings, player, rider, limitAt, pointAt, TRACK, CURVES, PICKUPS, BRAKE, VERGE, LAPS, COUNTDOWN, RIDERS } from '../src/engine/gara.js';
import { piloti } from '../src/story/piloti.js'; import { piloti } from '../src/story/piloti.js';
import { story } from '../src/story/story.js'; import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js'; import { Game } from '../src/engine/game.js';
@@ -58,7 +58,7 @@ test('i piloti sono dati: motore da 1 a 6 e testa da 1 a 5 danno velocità, acce
}); });
test('il semaforo, poi il gas: tenendo premuto si va, lasciando si rallenta, il freno vince sul gas', () => { test('il semaforo, poi il gas: tenendo premuto si va, lasciando si rallenta, il freno vince sul gas', () => {
const c = newGara(3, { level: 'seria', riders: RIDERS }); const c = newGara(3, { level: 'seria', riders: RIDERS, simple: false });
for (const r of RIDERS) assert.ok(press(c, 7), r.id); for (const r of RIDERS) assert.ok(press(c, 7), r.id);
assert.equal(c.step, 'start', 'un tocco per pilota e la presentazione è finita'); assert.equal(c.step, 'start', 'un tocco per pilota e la presentazione è finita');
assert.equal(c.holding, false, 'senza dare gas'); assert.equal(c.holding, false, 'senza dare gas');
@@ -161,7 +161,7 @@ test('l’origine segue gli swipe lunghi e si può correggere; rilascio e annull
}); });
test('alla bona si completano tre giri usando solo un dito, senza tasti o pulsanti', () => { test('alla bona si completano tre giri usando solo un dito, senza tasti o pulsanti', () => {
const c = race(newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) })); const c = race(newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false }));
aimScreen(c, [320, 180], 'bot'); press(c, 'bot'); aimScreen(c, [320, 180], 'bot'); press(c, 'bot');
for (let t = 0; t < 180 && c.step === 'race'; t += 1 / 60) { for (let t = 0; t < 180 && c.step === 'race'; t += 1 / 60) {
swipeTo(c, pointAt(player(c).s + 50)); swipeTo(c, pointAt(player(c).s + 50));
@@ -198,7 +198,7 @@ test('la presentazione: uno alla volta arrivano in griglia col motorino, poi il
}); });
test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si vince', () => { test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si vince', () => {
const wild = newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) }); const wild = newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false });
skip(wild); skip(wild);
run(wild, COUNTDOWN + 60, () => true); run(wild, COUNTDOWN + 60, () => true);
assert.ok(wild.heard.has('gara-fuori:andrea')); assert.ok(wild.heard.has('gara-fuori:andrea'));
@@ -206,7 +206,7 @@ test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si v
assert.equal(wild.step, 'race', 'dritto non si arriva'); assert.equal(wild.step, 'race', 'dritto non si arriva');
const heard = new Set(); const heard = new Set();
for (const seed of [1, 2, 3, 4]) { for (const seed of [1, 2, 3, 4]) {
const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) }); const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false });
skip(c); setPower(c, 1); // a sensibilità piena: il dito risponde subito skip(c); setPower(c, 1); // a sensibilità piena: il dito risponde subito
for (const k of run(c, COUNTDOWN + 120, careful)) heard.add(k); for (const k of run(c, COUNTDOWN + 120, careful)) heard.add(k);
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player', `seme ${seed}`); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player', `seme ${seed}`);
@@ -219,7 +219,7 @@ test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si v
test('alla sensibilità di partenza (bassa) lo stesso pilota non finisce mai nella paglia', () => { test('alla sensibilità di partenza (bassa) lo stesso pilota non finisce mai nella paglia', () => {
for (const seed of [1, 2, 3]) { for (const seed of [1, 2, 3]) {
const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) }); const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null), simple: false });
skip(c); assert.equal(c.sens, SWIPE.sens); skip(c); assert.equal(c.sens, SWIPE.sens);
run(c, COUNTDOWN + 120, careful); run(c, COUNTDOWN + 120, careful);
assert.equal(c.step, 'result'); assert.equal(c.result.offs, 0, `seme ${seed}`); assert.equal(c.step, 'result'); assert.equal(c.result.offs, 0, `seme ${seed}`);
@@ -270,3 +270,64 @@ test('nella storia: dal Trappola l\'Arcu scherza sul Betino, poi la sfida; prima
// Il Betino, lassù: solo Andrea ne parla. // Il Betino, lassù: solo Andrea ne parla.
g.act('use', 'betino'); assert.ok(settle(g).every(s => s.who === 'andrea')); g.act('use', 'betino'); assert.ok(settle(g).every(s => s.who === 'andrea'));
}); });
// La guida semplice (Andrea, 29 ottobre: dal telefono lo sterzo era ingiocabile): alla bona e seria si tiene premuto
// per il gas e lasciando si frena, lo sterzo va da solo; troppo forte in curva si scivola fuori. Ginocchio in terra no.
test('guida semplice: alla bona e seria sì, a ginocchio in terra no; lasciando si frena e lo sterzo va da solo', () => {
const riders = piloti.filter(p => p.consenso !== null);
assert.equal(simple(newGara(1, { level: 'bona', riders })), true);
assert.equal(simple(newGara(1, { level: 'seria', riders })), true);
assert.equal(simple(newGara(1, { level: 'male', riders })), false);
const c = race(newGara(1, { level: 'bona', riders }));
run(c, 2, () => true);
const v = player(c).v; assert.ok(v > 40);
run(c, 0.3, () => false);
assert.ok(player(c).v < v - BRAKE * 0.3 + 5, 'lasciando frena, non solo rallenta');
assert.match(status(c), /Freno\./);
// Le frecce non sterzano: lo sterzo segue la strada.
key(c, 'ArrowLeft', true); run(c, 2, () => true); key(c, 'ArrowLeft', false);
assert.ok(Math.abs(player(c).lat) < 9, 'resta in strada');
});
test('guida semplice: sempre a tutto gas si finisce nella paglia; mollando prima delle curve no, e alla bona si vince', () => {
const riders = piloti.filter(p => p.consenso !== null);
// Molla quando, frenando da qui, arriverebbe a una curva sopra il limite della guida semplice.
const wise = c => { const r = player(c), h = r.v * r.v / (2 * BRAKE) + 10; for (let d = 0; d <= h; d += 4) { const a = simpleLimit(r, r.s + d); if (r.v * r.v > a * a + 2 * BRAKE * d) return false; } return true; };
for (const seed of [1, 2]) {
const wild = race(newGara(seed, { level: 'bona', riders }));
run(wild, 120, () => true);
assert.ok(wild.result.offs > 0, 'a tutto gas si esce');
const c = race(newGara(seed, { level: 'bona', riders }));
run(c, 120, wise);
assert.equal(c.result.offs, 0); assert.equal(c.result.falls, 0, 'le buche si scansano');
assert.equal(c.result.winner, 'player', `seme ${seed}`);
}
assert.ok(SIMPLE.slide > 0);
});
test('Seria semplice: chi non rallenta in curva va fuori e perde, chi frena giusto fa le curve pulite e vince; LASCIA! dice quando', () => {
const riders = piloti.filter(p => p.consenso !== null);
const wise = c => { const r = player(c), h = r.v * r.v / (2 * BRAKE) + 10; for (let d = 0; d <= h; d += 4) { const a = simpleLimit(r, r.s + d, c.tune); if (r.v * r.v > a * a + 2 * BRAKE * d) return false; } return true; };
for (const seed of [1, 2]) {
const wild = race(newGara(seed, { level: 'seria', riders }));
run(wild, 120, () => true);
const good = race(newGara(seed, { level: 'seria', riders }));
run(good, 120, wise);
assert.ok(wild.result.offs >= 4, `a tutto gas fuori in quasi ogni curva (${wild.result.offs})`);
assert.ok(wild.result.place >= 3);
assert.equal(good.result.offs, 0); assert.equal(good.result.winner, 'player', `seme ${seed}`);
assert.ok(player(good).cleans >= 5, 'le curve pulite');
assert.ok(wild.result.times.andrea - good.result.times.andrea > 6, 'tutto gas costa più di 6 secondi');
}
// Chi segue LASCIA! guida come chi frena giusto; CURVA! arriva prima.
const c = race(newGara(1, { level: 'seria', riders }));
let early = 0, said = false;
for (let t = 0; t < 20; t += 1 / 60) {
c.holding = !mustLift(c);
if (mustLift(c, PREAVVISO) && !mustLift(c)) early++;
update(c, 1 / 60);
if (c.holding && mustLift(c)) said ||= /Lascia!/.test(status(c));
}
assert.ok(early > 20, `il preavviso CURVA! viene prima di LASCIA! (${early} fotogrammi)`);
assert.ok(said, 'il pannello dice Lascia!');
});
+16 -4
View File
@@ -34,19 +34,31 @@ try {
await page.evaluate(async () => { await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs'); const { settle } = await import('/tests/helpers.mjs');
game.act('talk', 'chiaru'); settle(game); game.act('talk', 'chiaru'); settle(game);
game.choose(game.options().findIndex(o => o.text === 'Si corre?')); settle(game); game.choose(game.options().findIndex(o => o.text === 'Si fa una gara alle macchinine?!?!')); settle(game);
game.choose(game.options().findIndex(o => o.text === 'Alla bona.')); settle(game); game.choose(game.options().findIndex(o => o.text === 'Alla bona.')); settle(game);
}); });
await page.waitForSelector('#bocce-controls:not([hidden])'); await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => { await page.waitForFunction(async () => {
const { hasBackground } = await import('/src/engine/render.js'); const { hasBackground } = await import('/src/engine/render.js');
return hasBackground('assets/closeups/micro-parcheggio.png') && hasBackground('assets/closeups/micro/rossa.png'); return hasBackground('assets/closeups/micro-parcheggio.png');
}); });
assert.equal(await page.locator('#bocce-throw').textContent(), 'Spingi'); // Prima si sceglie la macchinina: la viola, toccandola.
assert.equal(await page.locator('#bocce-throw').textContent(), 'Scegli');
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/micro/viola-grande.png'));
if (name === 'desktop') { await page.waitForTimeout(1600); await page.screenshot({ path: `${out}/${name}-sceglie.png` }); }
{
const box = await page.locator('#scene').boundingBox(), [vx, vy] = [480, 220];
const at = { x: box.x + vx / 640 * box.width, y: box.y + vy / 360 * box.height };
if (mobile) await page.touchscreen.tap(at.x, at.y); else await page.mouse.click(at.x, at.y);
}
assert.deepEqual(await page.evaluate(() => game.closeup.colors), { player: 'viola', cpu: 'blu' });
await page.waitForTimeout(1600);
if (name === 'desktop') await page.screenshot({ path: `${out}/${name}-scelta.png` });
await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Spingi');
assert.ok(await page.locator('#micro-help').isVisible(), 'il «Come si gioca» delle macchinine'); assert.ok(await page.locator('#micro-help').isVisible(), 'il «Come si gioca» delle macchinine');
const exit = await page.locator('#closeup-close').boundingBox(), canvas = await page.locator('#scene').boundingBox(); const exit = await page.locator('#closeup-close').boundingBox(), canvas = await page.locator('#scene').boundingBox();
assert.ok(exit.y >= canvas.y + canvas.height, 'l\'uscita sta fuori dalla pista'); assert.ok(exit.y >= canvas.y + canvas.height, 'l\'uscita sta fuori dalla pista');
await page.waitForTimeout(1600); await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/micro/viola.png'));
const state = () => page.evaluate(() => ({ angle: game.closeup.angle, power: game.closeup.power, step: game.closeup.step, x: game.closeup.cars.player.x })); const state = () => page.evaluate(() => ({ angle: game.closeup.angle, power: game.closeup.power, step: game.closeup.step, x: game.closeup.cars.player.x }));
// Un punto della pista davanti alla macchinina di Andrea, in pixel della pagina. // Un punto della pista davanti alla macchinina di Andrea, in pixel della pagina.
const ahead = await page.evaluate(() => { const c = game.closeup, i = (c.cars.player.idx + 50) % c.track.n; return c.track.pts[i]; }); const ahead = await page.evaluate(() => { const c = game.closeup, i = (c.cars.player.idx + 50) % c.track.n; return c.track.pts[i]; });
+55 -19
View File
@@ -1,11 +1,16 @@
import test from 'node:test'; import test from 'node:test';
import { existsSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as m from '../src/engine/micro.js'; import * as m from '../src/engine/micro.js';
import * as micro from '../src/engine/micro.js';
import { drawMicro } from '../src/engine/micro-render.js'; import { drawMicro } from '../src/engine/micro-render.js';
import { Game, MINIGAMES } from '../src/engine/game.js'; import { Game, MINIGAMES } 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';
// Una gara con la macchinina già scelta (la rossa), per provare il resto.
const newMicro = (seed, def = {}) => micro.newMicro(seed, { car: 'rossa', ...def });
// Fa andare la spinta in corso (e quella del Chiaru, se tocca a lui) finché non si torna a mirare o è finita. // Fa andare la spinta in corso (e quella del Chiaru, se tocca a lui) finché non si torna a mirare o è finita.
function run(c, limit = 20000) { function run(c, limit = 20000) {
for (let i = 0; i < limit && !(m.canThrow(c) || c.step === 'result'); i++) m.update(c, 1 / 60); for (let i = 0; i < limit && !(m.canThrow(c) || c.step === 'result'); i++) m.update(c, 1 / 60);
@@ -44,7 +49,7 @@ test('la pista: chiusa, larga uguale, e le macchinine partono in pista poco prim
const d = Math.hypot(t.pts[i][0] - t.pts[j][0], t.pts[i][1] - t.pts[j][1]); const d = Math.hypot(t.pts[i][0] - t.pts[j][0], t.pts[i][1] - t.pts[j][1]);
assert.ok(d > 2 * m.HALF + 2 * m.CAR.r, `tratti troppo vicini: ${i} e ${j} (${d.toFixed(1)} px)`); assert.ok(d > 2 * m.HALF + 2 * m.CAR.r, `tratti troppo vicini: ${i} e ${j} (${d.toFixed(1)} px)`);
} }
const c = m.newMicro(1); const c = newMicro(1);
for (const car of Object.values(c.cars)) { for (const car of Object.values(c.cars)) {
assert.ok(!m.isOut(m.project(t, car.x, car.y).d)); assert.ok(!m.isOut(m.project(t, car.x, car.y).d));
assert.ok(car.progress < 0 && car.progress > -30, 'prima del traguardo'); assert.ok(car.progress < 0 && car.progress > -30, 'prima del traguardo');
@@ -52,10 +57,10 @@ test('la pista: chiusa, larga uguale, e le macchinine partono in pista poco prim
}); });
test('stesso seme: stessa partita; la spinta va lontano quanto dice la guida', () => { test('stesso seme: stessa partita; la spinta va lontano quanto dice la guida', () => {
const a = m.newMicro(4, { level: 'male' }), b = m.newMicro(4, { level: 'male' }); const a = newMicro(4, { level: 'male' }), b = newMicro(4, { level: 'male' });
for (const c of [a, b]) { flick(c); run(c); } for (const c of [a, b]) { flick(c); run(c); }
assert.deepEqual(a.cars, b.cars); assert.deepEqual(a.cars, b.cars);
const c = m.newMicro(2, { level: 'bona' }), start = { ...c.cars.player }; const c = newMicro(2, { level: 'bona' }), start = { ...c.cars.player };
c.power = 0.6; const g = m.guide(c); c.power = 0.6; const g = m.guide(c);
assert.ok(m.shoot(c)); run(c); assert.ok(m.shoot(c)); run(c);
// Il Chiaru ha già spinto: la macchinina di Andrea sta dove diceva la guida (se lui non l'ha urtata). // Il Chiaru ha già spinto: la macchinina di Andrea sta dove diceva la guida (se lui non l'ha urtata).
@@ -64,7 +69,7 @@ test('stesso seme: stessa partita; la spinta va lontano quanto dice la guida', (
}); });
test('tutta fuori: si torna dove si era prima della spinta, e la guida lo diceva', () => { test('tutta fuori: si torna dove si era prima della spinta, e la guida lo diceva', () => {
const c = m.newMicro(3, { level: 'bona' }), start = { x: c.cars.player.x, y: c.cars.player.y }; const c = newMicro(3, { level: 'bona' }), start = { x: c.cars.player.x, y: c.cars.player.y };
c.angle = -Math.PI / 2; c.power = 1; // dritto verso l'erba in alto c.angle = -Math.PI / 2; c.power = 1; // dritto verso l'erba in alto
assert.equal(m.guide(c).out, true); assert.equal(m.guide(c).out, true);
assert.ok(m.shoot(c)); assert.ok(m.shoot(c));
@@ -78,9 +83,35 @@ test('tutta fuori: si torna dove si era prima della spinta, e la guida lo diceva
assert.ok(c.events.some(e => e.kind === 'micro-fuori')); assert.ok(c.events.some(e => e.kind === 'micro-fuori'));
}); });
test('si parte scegliendo la macchinina: tocco, frecce, pulsante; il Chiaru tiene la blu, o la rossa se la blu è presa', () => {
const c = micro.newMicro(1);
assert.equal(c.step, 'choose'); assert.equal(m.canThrow(c), false); assert.equal(m.button(c), 'Scegli');
assert.ok(m.aimScreen(c, m.choiceAt(4))); assert.equal(c.choice, 4);
assert.equal(m.turn(c, 1, 'x'), false); assert.equal(c.choice, 5, 'la freccia passa alla dopo');
m.turn(c, 1, 'x'); assert.equal(c.choice, 0, 'e ricomincia dalla prima');
assert.ok(m.shoot(c)); assert.deepEqual(c.colors, { player: 'rossa', cpu: 'blu' }); assert.equal(c.step, 'aim');
const d = micro.newMicro(1); m.aimScreen(d, m.choiceAt(1)); assert.ok(m.press(d, 7));
assert.deepEqual(d.colors, { player: 'blu', cpu: 'rossa' });
assert.equal(d.holding, false, 'il tocco che sceglie non carica la spinta');
for (const car of m.CARS) assert.ok(existsSync(new URL('../' + m.carImage(car.id, true), import.meta.url)) && existsSync(new URL('../' + m.carImage(car.id), import.meta.url)));
});
test('primo e secondo mentre si va; nel risultato il perché', () => {
const c = newMicro(1);
c.cars.player.progress = 100; c.cars.cpu.progress = 80;
assert.equal(m.rank(c, 'player'), 1); assert.equal(m.rank(c, 'cpu'), 2);
assert.match(m.status(c), /Andrea 1°.*Chiaru 2°/);
c.result = { winner: 'cpu', flicks: { player: 20, cpu: 20 }, over: { player: 5, cpu: 22 } };
assert.deepEqual(m.why(c), ['Tutti e due al traguardo alla spinta 20:', 'vince chi va più in là, il Chiaru di 3 centimetri.']);
c.result = { winner: 'player', flicks: { player: 9, cpu: 9 }, over: { player: 5, cpu: -40 } };
assert.deepEqual(m.why(c), ['Andrea al traguardo alla spinta 9;', 'al Chiaru non è bastata la sua.']);
c.result = { winner: 'draw', flicks: { player: 9, cpu: 9 }, over: { player: 5, cpu: 5.4 } };
assert.match(m.why(c).join(' '), /pari/);
});
test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si torna indietro', () => { test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si torna indietro', () => {
// Tagliè: dal tratto di mezzo della esse, dritti in su attraverso l'erba verso il tratto di sopra. // Tagliè: dal tratto di mezzo della esse, dritti in su attraverso l'erba verso il tratto di sopra.
const t = m.newMicro(1, { level: 'bona' }), [sx, sy] = t.track.pts[Math.round(t.track.n * 0.48)]; const t = newMicro(1, { level: 'bona' }), [sx, sy] = t.track.pts[Math.round(t.track.n * 0.48)];
const p = m.project(t.track, sx, sy); Object.assign(t.cars.player, { x: sx, y: sy, idx: p.i, s: p.s, progress: p.s }); const p = m.project(t.track, sx, sy); Object.assign(t.cars.player, { x: sx, y: sy, idx: p.i, s: p.s, progress: p.s });
t.angle = -Math.PI / 2; t.power = 0.6; assert.ok(m.shoot(t)); t.angle = -Math.PI / 2; t.power = 0.6; assert.ok(m.shoot(t));
for (let i = 0; i < 2000 && t.step !== 'settle'; i++) m.update(t, 1 / 60); for (let i = 0; i < 2000 && t.step !== 'settle'; i++) m.update(t, 1 / 60);
@@ -88,7 +119,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si
assert.match(t.line, /^Tagliè! Andrea torna dov'era\./); assert.match(t.line, /^Tagliè! Andrea torna dov'era\./);
assert.deepEqual([t.cars.player.x, t.cars.player.y], [sx, sy]); assert.deepEqual([t.cars.player.x, t.cars.player.y], [sx, sy]);
// Cappottè: Andrea a tutta forza contro il Chiaru fermo davanti. // Cappottè: Andrea a tutta forza contro il Chiaru fermo davanti.
const k = m.newMicro(2, { level: 'bona' }), me = k.cars.player, him = k.cars.cpu, before = { x: him.x, y: him.y }; const k = newMicro(2, { level: 'bona' }), me = k.cars.player, him = k.cars.cpu, before = { x: him.x, y: him.y };
k.angle = Math.atan2(him.y - me.y, him.x - me.x); k.power = 1; assert.ok(m.shoot(k)); k.angle = Math.atan2(him.y - me.y, him.x - me.x); k.power = 1; assert.ok(m.shoot(k));
for (let i = 0; i < 2000 && k.step !== 'settle'; i++) m.update(k, 1 / 60); for (let i = 0; i < 2000 && k.step !== 'settle'; i++) m.update(k, 1 / 60);
assert.equal(him.fault, 'cappotte'); assert.equal(him.fault, 'cappotte');
@@ -97,7 +128,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si
// Torna al suo posto; se lì c'è ora Andrea, si scosta appena quanto basta. // Torna al suo posto; se lì c'è ora Andrea, si scosta appena quanto basta.
assert.ok(Math.hypot(him.x - before.x, him.y - before.y) <= 2 * m.CAR.r + 1e-9); assert.ok(Math.hypot(him.x - before.x, him.y - before.y) <= 2 * m.CAR.r + 1e-9);
// Accompagnè: tenuto premuto ben oltre la carica piena. // Accompagnè: tenuto premuto ben oltre la carica piena.
const a = m.newMicro(3), start = { x: a.cars.player.x, y: a.cars.player.y }; const a = newMicro(3), start = { x: a.cars.player.x, y: a.cars.player.y };
assert.ok(m.press(a)); assert.ok(m.press(a));
for (let i = 0; i < 120; i++) m.update(a, 1 / 60); for (let i = 0; i < 120; i++) m.update(a, 1 / 60);
assert.ok(m.overHeld(a)); assert.ok(m.overHeld(a));
@@ -106,7 +137,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si
assert.match(a.line, /^Accompagnè! Andrea torna dov'era\./); assert.match(a.line, /^Accompagnè! Andrea torna dov'era\./);
assert.deepEqual([a.cars.player.x, a.cars.player.y], [start.x, start.y]); assert.deepEqual([a.cars.player.x, a.cars.player.y], [start.x, start.y]);
// Lasciando in tempo, la spinta piena vale. // Lasciando in tempo, la spinta piena vale.
const ok = m.newMicro(3); m.press(ok); const ok = newMicro(3); m.press(ok);
for (let i = 0; i < Math.round((m.SWING + 0.2) * 60); i++) m.update(ok, 1 / 60); for (let i = 0; i < Math.round((m.SWING + 0.2) * 60); i++) m.update(ok, 1 / 60);
m.release(ok); assert.equal(ok.cars.player.fault, null); m.release(ok); assert.equal(ok.cars.player.fault, null);
}); });
@@ -114,7 +145,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si
test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chiaru alla bona perde contro chi usa la guida', () => { test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chiaru alla bona perde contro chi usa la guida', () => {
const wins = { player: 0, cpu: 0, draw: 0 }; const wins = { player: 0, cpu: 0, draw: 0 };
for (const seed of [1, 2, 3]) { for (const seed of [1, 2, 3]) {
const c = m.newMicro(seed, { level: 'bona' }); const c = newMicro(seed, { level: 'bona' });
for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) guided(c); run(c); } for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) guided(c); run(c); }
assert.equal(c.step, 'result'); assert.equal(c.step, 'result');
assert.equal(c.result.flicks.player, c.result.flicks.cpu); assert.equal(c.result.flicks.player, c.result.flicks.cpu);
@@ -124,7 +155,7 @@ test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chia
assert.ok(wins.player >= 2, `alla bona Andrea, con la guida, deve vincere quasi sempre (${JSON.stringify(wins)})`); assert.ok(wins.player >= 2, `alla bona Andrea, con la guida, deve vincere quasi sempre (${JSON.stringify(wins)})`);
// A fassi male il Chiaru fa il giro con meno spinte che alla bona. // A fassi male il Chiaru fa il giro con meno spinte che alla bona.
const flicks = level => { const flicks = level => {
const c = m.newMicro(7, { level }); const c = newMicro(7, { level });
for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) { c.angle = c.cars.player.heading; c.power = 0; m.shoot(c); } run(c); } for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) { c.angle = c.cars.player.heading; c.power = 0; m.shoot(c); } run(c); }
return c.flicks.cpu; return c.flicks.cpu;
}; };
@@ -132,14 +163,18 @@ test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chia
}); });
test('i comandi: un tocco mira soltanto, tenere premuto carica, lasciare spinge; la mira si gira con le frecce', () => { test('i comandi: un tocco mira soltanto, tenere premuto carica, lasciare spinge; la mira si gira con le frecce', () => {
const c = m.newMicro(5); const c = newMicro(5);
assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.ok(m.release(c, 1) === true && c.step === 'aim', 'il tocco breve non spinge'); assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.ok(m.release(c, 1) === true && c.step === 'aim', 'il tocco breve non spinge');
assert.ok(m.press(c, 1)); for (let i = 0; i < 60; i++) m.update(c, 1 / 60); assert.ok(c.charging); // Premuto, la forza riparte da zero e sale; un secondo dopo è quasi piena.
assert.ok(m.press(c, 1)); assert.equal(c.power, 0);
m.update(c, 0.3); const early = c.power; assert.ok(c.charging && early > 0.2 && early < 0.3, `forza ${early}`);
for (let i = 0; i < 42; i++) m.update(c, 1 / 60);
assert.ok(c.power > early && c.power < 1);
const before = c.cars.player.x; const before = c.cars.player.x;
assert.ok(m.release(c, 1)); assert.equal(c.step, 'rolling'); assert.ok(m.release(c, 1)); assert.equal(c.step, 'rolling');
m.update(c, 0.2); assert.notEqual(c.cars.player.x, before); m.update(c, 0.2); assert.notEqual(c.cars.player.x, before);
assert.equal(m.press(c, 2), false, 'mentre corre non si spinge'); assert.equal(m.press(c, 2), false, 'mentre corre non si spinge');
const d = m.newMicro(5), a0 = d.angle; const d = newMicro(5), a0 = d.angle;
m.key(d, 'ArrowRight', true); m.update(d, 0.5); m.key(d, 'ArrowRight', false); m.key(d, 'ArrowRight', true); m.update(d, 0.5); m.key(d, 'ArrowRight', false);
assert.ok(d.angle > a0); assert.ok(d.angle > a0);
assert.ok(m.aimScreen(d, [320, 200])); assert.equal(m.aimScreen(d, [320, 10]), false, 'il tabellone non si mira'); assert.ok(m.aimScreen(d, [320, 200])); assert.equal(m.aimScreen(d, [320, 10]), false, 'il tabellone non si mira');
@@ -147,7 +182,7 @@ test('i comandi: un tocco mira soltanto, tenere premuto carica, lasciare spinge;
}); });
test('il Chiaru pensa a pezzi: nessun fotogramma lo ferma troppo', () => { test('il Chiaru pensa a pezzi: nessun fotogramma lo ferma troppo', () => {
const c = m.newMicro(6, { level: 'male' }); const c = newMicro(6, { level: 'male' });
flick(c); flick(c);
let worst = 0; let worst = 0;
for (let i = 0; i < 600 && !m.canThrow(c); i++) { const t = performance.now(); m.update(c, 1 / 60); worst = Math.max(worst, performance.now() - t); } for (let i = 0; i < 600 && !m.canThrow(c); i++) { const t = performance.now(); m.update(c, 1 / 60); worst = Math.max(worst, performance.now() - t); }
@@ -156,13 +191,14 @@ test('il Chiaru pensa a pezzi: nessun fotogramma lo ferma troppo', () => {
test('il disegno: gesso, macchinine, guida e risultato; lo stato non cambia', () => { test('il disegno: gesso, macchinine, guida e risultato; lo stato non cambia', () => {
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) }); const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
const c = m.newMicro(8, { level: 'bona' }), bg = {}, cars = { [story.closeups.micro.cars.player]: { car: 'player' } }; const c = newMicro(8, { level: 'bona' }), bg = {}, cars = { [m.carImage('rossa')]: { car: 'player' } };
const draw = () => { const before = structuredClone(c); drawMicro(ctx, c, story.closeups.micro, bg, src => cars[src]); assert.deepEqual(c, before); }; const draw = () => { const before = structuredClone(c); drawMicro(ctx, c, story.closeups.micro, bg, src => cars[src]); assert.deepEqual(c, before); };
draw(); draw();
assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.filter(([op, img]) => op === 'drawImage' && img?.car === 'player').length >= 2, 'la macchinina e il suo fantasma'); assert.ok(calls.filter(([op, img]) => op === 'drawImage' && img?.car === 'player').length >= 2, 'la macchinina e il suo fantasma');
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'Chiaru')); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'Chiaru'));
c.step = 'result'; c.result = { winner: 'cpu', flicks: { player: 12, cpu: 12 } }; calls.length = 0; draw(); c.step = 'result'; c.result = { winner: 'cpu', flicks: { player: 12, cpu: 12 }, over: { player: 3, cpu: 20 } }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'vince chi va più in là, il Chiaru di 3 centimetri.'));
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO IL CHIARU')); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO IL CHIARU'));
}); });
@@ -173,13 +209,13 @@ test('il Chiaru al parcheggio di Cardetole: si chiacchiera, si sceglie il livell
for (const y of [222, 260, 300, 314]) assert.equal(g.hotspotAt([520, y])?.id, 'chiaru', `clic a y=${y}`); for (const y of [222, 260, 300, 314]) assert.equal(g.hotspotAt([520, y])?.id, 'chiaru', `clic a y=${y}`);
g.act('talk', 'chiaru'); settle(g); g.act('talk', 'chiaru'); settle(g);
assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito'); assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito');
g.choose(g.options().findIndex(o => o.text === 'Si corre?')); settle(g); g.choose(g.options().findIndex(o => o.text === 'Si fa una gara alle macchinine?!?!')); settle(g);
g.choose(g.options().findIndex(o => o.text === 'No, aspetta.')); settle(g); g.choose(g.options().findIndex(o => o.text === 'No, aspetta.')); settle(g);
assert.equal(g.closeup, null); assert.equal(g.closeup, null);
g.choose(g.options().findIndex(o => o.text === 'Si corre?')); settle(g); g.choose(g.options().findIndex(o => o.text === 'Si fa una gara alle macchinine?!?!')); settle(g);
g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g);
assert.equal(g.closeup?.id, 'micro'); assert.equal(g.closeup.level, 'male'); assert.equal(g.closeup?.id, 'micro'); assert.equal(g.closeup.level, 'male');
g.closeup.step = 'result'; g.closeup.result = { winner: 'player', flicks: { player: 11, cpu: 11 } }; g.closeup.step = 'result'; g.closeup.result = { winner: 'player', flicks: { player: 11, cpu: 11 }, over: { player: 4, cpu: -30 } };
g.closeupClose(); settle(g); g.closeupClose(); settle(g);
assert.ok(g.state.flags.micro_vinto); assert.ok(g.state.flags.micro_vinto);
}); });
+1 -1
View File
@@ -437,7 +437,7 @@ test('interrompere la catena del ghiacciolo non perde la torcia né la chiavetta
test('gli sfondi si caricano dalla scena di partenza verso le più lontane, tutti e una volta sola', () => { test('gli sfondi si caricano dalla scena di partenza verso le più lontane, tutti e una volta sola', () => {
const order = backgroundOrder(story, story.start.scene); const order = backgroundOrder(story, story.start.scene);
const all = new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean)); const all = new Set(Object.values(story.scenes).flatMap(s => [s.background, ...(s.backdrops ?? []).map(b => b.src)]).filter(Boolean));
assert.equal(order.length, all.size); assert.equal(order.length, all.size);
assert.deepEqual(new Set(order), all); assert.deepEqual(new Set(order), all);
assert.equal(order[0], story.scenes[story.start.scene].background, 'prima la piazza'); assert.equal(order[0], story.scenes[story.start.scene].background, 'prima la piazza');
+16 -3
View File
@@ -6,9 +6,10 @@ import { Game } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js'; import { inside } from '../src/engine/geometry.js';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
// La Ghiacciomobile al circolino è uno sprite; il Betino in cima alla salita di Via Liguri Magelli è dipinto nel quadro. // I veicoli sono dipinti nel quadro: il Betino in cima alla salita di Via Liguri Magelli e, prima del tramonto, la
const SPRITES = [['campomigliaio_circolo', 'ghiacciomobile', [150, 170]]]; // Ghiacciomobile dentro il muro giallo (Andrea, 29 ottobre). Nessuno è più uno sprite.
const VEHICLES = [...SPRITES, ['via_liguri_magelli', 'betino']]; const SPRITES = [];
const VEHICLES = [['via_liguri_magelli', 'betino'], ['via_liguri_magelli', 'ghiacciomobile']];
test('i veicoli restano fuori dal percorso e lasciano liberi gli altri hotspot', () => { test('i veicoli restano fuori dal percorso e lasciano liberi gli altri hotspot', () => {
for (const [scene, id, [lo, hi]] of SPRITES) { for (const [scene, id, [lo, hi]] of SPRITES) {
const g = new Game(story); const g = new Game(story);
@@ -62,3 +63,15 @@ test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () =
} }
} }
}); });
test('Via Liguri Magelli: prima la Ghiacciomobile nel muro (senza targa), dal tramonto il muro rifatto con la targa', () => {
const g = new Game(story);
g.go('via_liguri_magelli'); settle(g);
assert.equal(g.backdrop(), 'assets/scenes/via-liguri-magelli-muro.png');
assert.ok(g.hotspot('ghiacciomobile') && !g.hotspot('targa'));
assert.ok(!story.scenes.campomigliaio_circolo.hotspots.some(h => h.id === 'ghiacciomobile'), 'al circolino non c\'è più');
g.state.flags.indizio_regolo = true;
assert.equal(g.backdrop(), 'assets/scenes/via-liguri-magelli.png');
assert.ok(g.hotspot('targa') && !g.hotspot('ghiacciomobile'));
assert.ok(existsSync(new URL('../assets/scenes/via-liguri-magelli-muro.png', import.meta.url)));
});
+1
View File
@@ -2,6 +2,7 @@
"_nota": "Parole che XTTS legge male in italiano: a sinistra come sono scritte nella storia (e nel fumetto), a destra come vanno lette dalla voce. Si cerca la parola intera, senza badare alle maiuscole. Cambiare una riga rigenera solo le battute che la contengono (make voci). Esempio: «celo» (ce l'ho, delle figurine) XTTS lo legge «cielo».", "_nota": "Parole che XTTS legge male in italiano: a sinistra come sono scritte nella storia (e nel fumetto), a destra come vanno lette dalla voce. Si cerca la parola intera, senza badare alle maiuscole. Cambiare una riga rigenera solo le battute che la contengono (make voci). Esempio: «celo» (ce l'ho, delle figurine) XTTS lo legge «cielo».",
"celo": "ce l'ho", "celo": "ce l'ho",
"Trappizzio": "Trappìzzio", "Trappizzio": "Trappìzzio",
"Zio": "Zìo",
"Cosimo I": "Cosimo primo", "Cosimo I": "Cosimo primo",
"padel": "pàdel", "padel": "pàdel",
"l'A. Bettini": "l'Abbettini", "l'A. Bettini": "l'Abbettini",
+4
View File
@@ -35,6 +35,10 @@
"speaker": "Xavier Hayasaka", "speaker": "Xavier Hayasaka",
"filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92"
}, },
"zio_mario": {
"speaker": "Viktor Menelaos",
"filtro": "asetrate=24000*0.95,aresample=24000,atempo=1.04"
},
"chiaru": { "chiaru": {
"speaker": "Luis Moray" "speaker": "Luis Moray"
}, },