Compare commits

..
17 Commits
Author SHA1 Message Date
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
manzolo 4d13a4ef25 Versione 0.1.39 2026-10-29 09:24:43 +01:00
manzoloandClaude Opus 5.5 22f408cc60 Il clic sul Chiaru è suo: stava sotto ai riquadri del parcheggio e della casa (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 09:18:13 +01:00
manzolo 927459344b Versione 0.1.38 2026-10-29 08:53:31 +01:00
manzoloandClaude Opus 5.5 08ab9ba069 La pista col gesso: le macchinine sul parcheggio di Cardetole contro il Chiaru (consenso del 29 ottobre, sprite di Codex dalla foto), un giro a spinte con le penalità alla francese (fuori, tagliè, cappottè, accompagnè: si torna dov'eri); Bertovo diventa Trappizzio Berti; Fabrizio Berti e il suo libro nei Ringraziamenti (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 08:47:03 +01:00
manzolo 1918c8d9c7 Versione 0.1.37 2026-10-29 00:17:30 +01:00
manzoloandClaude Opus 5.5 381254ed4d Biliardo dell'Ing. Tozzi: il tiro pieno va più forte (720 cm/s; i rinquarti restano a 520) e il test nel browser segue i comandi nuovi (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-29 00:11:02 +01:00
80 changed files with 1587 additions and 130 deletions
+70
View File
@@ -0,0 +1,70 @@
{
"image": "chiaru.png",
"height": 120,
"frames": {
"explain": {
"x": 0,
"y": 0,
"w": 56,
"h": 120,
"ax": 31.5,
"ay": 120
},
"talk": {
"x": 58,
"y": 0,
"w": 64,
"h": 119,
"ax": 38.5,
"ay": 119
},
"blink": {
"x": 124,
"y": 0,
"w": 44,
"h": 120,
"ax": 24.2,
"ay": 120
},
"stand": {
"x": 170,
"y": 0,
"w": 44,
"h": 120,
"ax": 24.2,
"ay": 120
},
"cheer": {
"x": 216,
"y": 0,
"w": 66,
"h": 118,
"ax": 31.5,
"ay": 118
},
"shrug": {
"x": 284,
"y": 0,
"w": 79,
"h": 119,
"ax": 42.2,
"ay": 119
},
"watch": {
"x": 365,
"y": 0,
"w": 63,
"h": 98,
"ax": 30.3,
"ay": 98
},
"laugh": {
"x": 430,
"y": 0,
"w": 52,
"h": 120,
"ax": 30.3,
"ay": 120
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 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.
+109 -17
View File
@@ -935,9 +935,9 @@
"file": "andrea-127a90af53.mp3",
"sec": 9.37
},
"andrea|Bertovo, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.": {
"file": "andrea-0b28b74cdb.mp3",
"sec": 6.78
"andrea|Trappizzio Berti, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.": {
"file": "andrea-089ce1d608.mp3",
"sec": 7.92
},
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
"file": "andrea-1eae1a8577.mp3",
@@ -1187,14 +1187,6 @@
"file": "andrea-00f2e70a4d.mp3",
"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.": {
"file": "andrea-90bbad0de0.mp3",
"sec": 9.59
@@ -1627,6 +1619,14 @@
"file": "andrea-7c17aa384c.mp3",
"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.": {
"file": "andrea-3e85200509.mp3",
"sec": 2.93
@@ -1639,6 +1639,10 @@
"file": "andrea-3d15dd47bb.mp3",
"sec": 4.82
},
"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",
"sec": 7.6
},
"andrea|Casa mia. Secondo piano: quella con la tapparella a metà è la camera di mia sorella, e a destra quella dei miei.": {
"file": "andrea-6c9e7f684e.mp3",
"sec": 8.72
@@ -1667,6 +1671,10 @@
"file": "andrea-f6c10fd559.mp3",
"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|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": {
"file": "andrea-0a073729fd.mp3",
"sec": 7.3
@@ -2427,9 +2435,77 @@
"file": "mariano-d65fb6e871.mp3",
"sec": 3.42
},
"andrea|Bertovo! Che fai, giochi da solo?": {
"file": "andrea-2eaaaffa37.mp3",
"sec": 3
"andrea|Si fa una gara alle macchinine?!?!": {
"file": "andrea-17216997e2.mp3",
"sec": 2.74
},
"andrea|Che pista hai disegnato?": {
"file": "andrea-3dd737ed1b.mp3",
"sec": 1.46
},
"andrea|Le regole sono quelle solite?": {
"file": "andrea-ee03433b9c.mp3",
"sec": 1.6
},
"andrea|Vado a fare un girino alle case rosse.": {
"file": "andrea-cb2fa74266.mp3",
"sec": 1.97
},
"chiaru|Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?": {
"file": "chiaru-df4b1e3a0e.mp3",
"sec": 7.32
},
"chiaru|Rettilineo, curvone, rettilineo, e in fondo la esse. Nella esse chi ha fretta esce.": {
"file": "chiaru-a7d83e9454.mp3",
"sec": 7.35
},
"chiaru|Il tombino l'ho lasciato in pista: porta fortuna.": {
"file": "chiaru-3810b7d228.mp3",
"sec": 3.56
},
"chiaru|Le solite. Chi accompagna la macchinina col dito: accompagnè.": {
"file": "chiaru-bb17a500b5.mp3",
"sec": 4.39
},
"chiaru|Chi taglia la strada: tagliè. Chi si ribalta: cappottè. E chi esce tutto dal gesso, fuori.": {
"file": "chiaru-1db3c53c04.mp3",
"sec": 7.9
},
"chiaru|In tutti i casi, si torna dov'eri prima.": {
"file": "chiaru-a7ab8d241f.mp3",
"sec": 2.01
},
"chiaru|Vorrai dire un giochino!": {
"file": "chiaru-c106f9e9c4.mp3",
"sec": 1.14
},
"andrea|Ciao Mattè.": {
"file": "andrea-fc5a08bbd6.mp3",
"sec": 0.73
},
"andrea|No, aspetta.": {
"file": "andrea-04890c1d8e.mp3",
"sec": 1.39
},
"chiaru|Alla bona: ti faccio vedere dove si ferma. E io vado piano.": {
"file": "chiaru-d31cc223f6.mp3",
"sec": 5.08
},
"chiaru|Seria. Così mi piace.": {
"file": "chiaru-89bf2bb9f9.mp3",
"sec": 2.31
},
"chiaru|A fassi male. Poi non piangere.": {
"file": "chiaru-f551f6a268.mp3",
"sec": 2.5
},
"chiaru|Io intanto ripasso il gesso.": {
"file": "chiaru-a0d7dbb4c1.mp3",
"sec": 2.15
},
"andrea|Fabri! Che fai, giochi da solo?": {
"file": "andrea-44037129d8.mp3",
"sec": 2.71
},
"andrea|Come fai a prendere le biglie dove nessuno ci arriva?": {
"file": "andrea-c6670ffb48.mp3",
@@ -2443,9 +2519,9 @@
"file": "andrea-fe39c8f30c.mp3",
"sec": 1.49
},
"andrea|Ci si vede, Bertovo.": {
"file": "andrea-a3364fd72b.mp3",
"sec": 1.78
"andrea|Ci si vede, Fabri.": {
"file": "andrea-d48ea04521.mp3",
"sec": 1.37
},
"bertovo|Da solo? Io gioco contro le sponde. E vinco quasi sempre.": {
"file": "bertovo-5e771069ff.mp3",
@@ -2775,6 +2851,22 @@
"file": "bertovo-ca941bee91.mp3",
"sec": 5.41
},
"chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": {
"file": "chiaru-91a4b23b22.mp3",
"sec": 4.44
},
"chiaru|Visto? La blu non perde mai. Mai.": {
"file": "chiaru-f8afa97da1.mp3",
"sec": 4.95
},
"chiaru|Ti ritiri? Allora la vinco a tavolino.": {
"file": "chiaru-3e949c1519.mp3",
"sec": 4.06
},
"chiaru|Pari. Si rifà, ma la pista la ridisegno io.": {
"file": "chiaru-91c549bb83.mp3",
"sec": 3.39
},
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
"file": "andrea-6484641c58.mp3",
"sec": 8.67
+9
View File
@@ -166,3 +166,12 @@ spinte a turno e la macchinina doveva restare in pista: tutta fuori, si torna da
Tozzino. Le macchinine: Micro Machines, le Formula 1 degli anni 80/90 con gli alettoni e i loro colori (senza marchi
né nomi veri). Proposta: vista dall'alto sull'asfalto, mira e spinta come al biliardo (riusarne la fisica), a turno,
vince chi taglia per primo il traguardo. Il quadro della pista e le macchinine da Codex (crediti finiti il 28: domani).
## Il libro di Fabrizio Berti: le cartoline dei vecchi posti (idea di Fabrizio Berti, 29 ottobre)
Fabrizio Berti (Bertovo nel gioco) ha scritto «Memorie di sport e di paese». Nel gioco il libro diventa un oggetto
dell'inventario: aprendolo, a seconda della scena in cui si è, compare la cartolina d'epoca di quel posto (San Piero
com'era). Si può riusare il primo piano del libro che c'è già (`libro-chiuso.png` / `libro-aperto.png`, effetto
`closeup`). Servono le cartoline **[DA ANDREA]** (dal libro, con il permesso dell'autore) e la scelta di quali scene ne
hanno una; dove non c'è, una battuta («Questo posto nel libro non c'è»). Come lo si ottiene: proposta, lo regala
Bertovo dopo una sfida ai rinquarti. Fabrizio Berti e il libro sono già nei Ringraziamenti.
+3
View File
@@ -29,6 +29,9 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
| `polisportiva-*.png`, `ponte-sieve.png`, `area-feste.png`, `via-provinciale.png`, `comune-asilo.png`, `giardini-poste.png`, `stazione.png` | Codex | foto omonime in `assets/raw/foto/` | Andrea (EXIF, 23 ottobre 2026) | sì |
| `via-del-pignone.png` | Codex | `via-del-pignone.png` (murale: `riferimenti/cistola.png`, `murale-maradona.png`) | Andrea | sì (Cistola consenziente) |
| `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ì |
| `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 |
| `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 |
| `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce |
+26
View File
@@ -18,6 +18,31 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati
- **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()`). [DA ANDREA] lo zio Mario che guarda
come tirarla fuori, e la battuta 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
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,
una spinta a testa (mira e carica come al biliardo), le macchinine di Codex (`assets/closeups/micro/`). Penalità
alla francese, per tutte si torna dov'eri: fuori, tagliè (curva presa dalla parte di dentro o erba saltata),
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
[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 in prestito dal GP (gara.mp3): [DA ANDREA] la sua, con Suno.
- **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`.
- **I rinquarti di Bertovo (28 ottobre, modifiche locali)**: variante del biliardo in `rinquarto.js` e
`rinquarto-render.js`, con fisica, mira, carica e suoni del biliardo. Sei posizioni riproducibili dal seme,
diretto coperto da una biglia e almeno una soluzione di sponda verificata con `simulate` per ciascuna.
@@ -306,6 +331,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
6. Il nonno: confermare il nome (Settimo Villani) e decidere come usare la sua leggenda nel finale.
7. Il Lele: come e quanto farlo comparire (con calma).
8. Battute e modi di dire del paese.
9. ~~Consenso del Chiaru~~ Dato il 29 ottobre. Le sue battute vanno bene?
### Grafica
- Sfondi puliti 16:9 di Pieve, Cardetole, Campomigliaio, interno della Fortezza (sotterranei).
+21 -3
View File
@@ -225,7 +225,13 @@
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
</details>
<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 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>
@@ -249,14 +255,26 @@
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>
</details>
<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
lui). La tua macchinina è la rossa. Vince chi taglia per primo il traguardo a scacchi; se ci arrivate tutti e due
nello stesso giro di spinte, vince chi è andato più in là.</p>
<p>Se durante la corsa la macchinina esce <b>tutta</b> dal gesso, torna dov'era prima della spinta: vale anche se
ce la manda l'altra urtandola, e vale per i tornanti, dove non si salta dall'altra parte. Alla bona vedi dove si
ferma la spinta (verde dentro, rossa fuori), Seria solo la direzione, A fassi male una freccina. Il Chiaru è più
preciso a ogni livello.</p>
<p>Muovi il mouse o tocca il parcheggio per mirare: un tocco fissa solo la mira. Per spingere tieni premuto (o
Spazio) finché il dito si carica e lascia, oppure regola la forza e premi Spingi. Frecce ← → o i pulsanti Mira
girano la mira (Maiusc per i passi fini); Esc esce.</p>
</details>
<details id="rinquarto-help" hidden><summary>Come si gioca</summary>
<p>I rinquarti di Bertovo: sei posizioni. Su ognuna tiri prima tu, poi il tavolo torna com'era e tira Bertovo; un
<p>I rinquarti di Trappizzio Berti: sei posizioni. Su ognuna tiri prima tu, poi il tavolo torna com'era e tira Trappizzio Berti; un
punto per ogni rinquarto riuscito, vince chi ne fa di più (si può anche pareggiare). La tua biglia è la bianca, il
bersaglio la gialla con l'anello. Le biglie viola con la croce e i birilli sono disturbi: toccarne uno prima del
bersaglio annulla il tiro. La diretta è sempre coperta, ma c'è sempre un rinquarto possibile.</p>
<p>Punto se la bianca tocca la gialla dopo almeno le sponde richieste: conta il primo contatto. Alla bona una
sponda e la guida fino al secondo rimbalzo, Seria due sponde e la guida corta, A fassi male tre sponde e niente
guida. Le posizioni si fanno più strette man mano, e Bertovo è più preciso a ogni livello. La forza è sempre piena:
guida. Le posizioni si fanno più strette man mano, e Trappizzio Berti è più preciso a ogni livello. La forza è sempre piena:
è un rompicapo di angoli. Nell'allenamento («Fammi vedere un rinquarto») tira prima lui, poi lo rifai tu.</p>
<p>Muovi il mouse o tocca il tavolo per mirare: un tocco fissa solo la mira. Per tirare premi Tira, oppure tieni
premuto sul tavolo (o Spazio) finché la stecca si carica e lascia. Frecce ← → o i pulsanti Mira (tenuti
+3 -1
View File
@@ -1,12 +1,14 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.36",
"version": "0.1.44",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:micro": "node tests/micro.browser.mjs",
"browser:gara": "node tests/gara.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs",
+8 -5
View File
@@ -30,7 +30,10 @@ export function castle() {
}
export const powerAt = time => clamp(time / SWING, 0, 1);
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * power;
// Il tiro più forte: al biliardo dell'Ing. Tozzi si arriva a STRONG (Andrea, 29 ottobre: «più potenza»); i rinquarti
// restano a MAX_SPEED, perché le loro posizioni sono misurate a quella forza.
export const STRONG = 720;
export const speedFor = (power, max = MAX_SPEED) => MIN_SPEED + (max - MIN_SPEED) * power;
// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza).
export function reach(v) {
const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG;
@@ -48,7 +51,7 @@ export const LEVELS = {
};
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, maxSpeed: def.maxSpeed ?? STRONG, power: 0.45, angle: 0,
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) };
c.pins = castle();
@@ -171,7 +174,7 @@ function step(w, dt) {
export function strike(c, who, angle, power) {
c.turn = null;
const b = c.balls[who], v = speedFor(power);
const b = c.balls[who], v = speedFor(power, c.maxSpeed);
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
c.windup = WINDUP;
@@ -183,7 +186,7 @@ export function strike(c, who, angle, power) {
export function simulate(c, who, angle, power, { onContact, stop } = {}) {
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
const w = { balls, pins, onContact, shot: { shooter: who, first: null, pallino: false, pins: [] } };
const b = balls.find(x => x.role === who), v = speedFor(power);
const b = balls.find(x => x.role === who), v = speedFor(power, c.maxSpeed);
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
for (let i = 0; i < 15 / FIXED_STEP && !stop?.() && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
return judge(w.shot);
@@ -343,7 +346,7 @@ export function predict(c) {
const cached = predictions.get(c);
if (cached?.key === key) return cached.result;
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
const v = speedFor(c.power);
const v = speedFor(c.power, c.maxSpeed);
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
const tracks = [];
+5
View File
@@ -10,6 +10,7 @@ import * as briscola from './briscola.js';
import * as tressette from './tressette.js';
import * as scopa from './scopa.js';
import * as gara from './gara.js';
import * as micro from './micro.js';
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = {
@@ -21,6 +22,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 },
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 },
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 },
};
@@ -160,6 +162,9 @@ export class Game {
}
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
// 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); }
+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 { 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) {
ctx.save();
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);
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');
// 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);
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: '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 = {
bona: { rivals: 0.74, stun: 0.4, guide: 'zone' },
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
bona: { rivals: 0.74, stun: 0.4, guide: 'zone', simple: true },
// 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 },
};
// `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) {
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');
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,
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).
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,
// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto
// si fida troppo.
function drive(c, r) {
const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6);
r.steer = clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1);
r.steer = follow(r);
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; }
const horizon = (r.v * r.v) / (2 * BRAKE) + 20;
@@ -283,22 +354,28 @@ function drive(c, r) {
}
function stepRider(c, r, dt) {
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 drive(c, r);
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);
// Il freno vince sul gas: chi li tiene tutti e due frena.
if (r.brake) r.v -= BRAKE * dt;
else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1);
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) * (r.push > 0 ? c.tune.clean.accel : 1);
else r.v -= COAST * dt;
if (r.v > top) r.v = Math.max(top, r.v - 120 * dt);
r.v = Math.max(0, r.v);
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.
const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt;
r.a = wrapAngle(r.a + turn);
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.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;
@@ -307,7 +384,8 @@ function stepRider(c, r, dt) {
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);
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;
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);
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') {
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}.`;
@@ -414,6 +492,9 @@ export function status(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.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.'}`;
}
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';
+150
View File
@@ -0,0 +1,150 @@
import { HALF, CAR, CARS, canThrow, guide, done, overHeld, carImage, choiceAt, why, rank } from './micro.js';
import { text, panel } from './bocce-render.js';
const NAME = { player: 'Andrea', cpu: 'Chiaru' };
// Il gesso: i due bordi della pista e il traguardo, calcolati una volta per pista. Il tratto ha dei buchi, come il
// gesso vero sull'asfalto ruvido (sempre gli stessi, da un piccolo hash: non tremano da un fotogramma all'altro).
const chalks = new WeakMap();
const hash = i => { let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); h ^= h >>> 13; return ((Math.imul(h, 0xc2b2ae35) ^ (h >>> 16)) >>> 0) / 4294967296; };
function chalk(track) {
if (chalks.has(track)) return chalks.get(track);
const edges = [-1, 1].map(side => track.pts.map(([x, y], i) => [x - track.tan[i][1] * HALF * side, y + track.tan[i][0] * HALF * side]));
const [tx, ty] = track.tan[0], [x0, y0] = track.pts[0];
const out = { edges, finish: { x: x0, y: y0, nx: -ty, ny: tx, tx, ty } };
chalks.set(track, out);
return out;
}
function drawChalk(ctx, track) {
const { edges, finish } = chalk(track);
for (const edge of edges) for (const [width, color, jitter] of [[3, 'rgba(236,236,226,0.28)', 0.8], [1.5, 'rgba(246,246,238,0.85)', 0]]) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath();
let open = false;
edge.forEach(([x, y], i) => {
if (hash(i + width * 1000) < 0.06) { open = false; return; } // un buco nel gesso
const j = jitter * (hash(i * 7 + 3) - 0.5);
if (!open) { ctx.moveTo(x + j, y - j); open = true; } else ctx.lineTo(x + j, y - j);
});
ctx.closePath(); ctx.stroke();
}
// Il traguardo: la scacchiera di gesso di traverso alla pista, due file.
const { x, y, nx, ny, tx, ty } = finish, cell = 4;
for (let k = -HALF; k < HALF; k += cell) for (let row = 0; row < 2; row++) {
if ((Math.round(k / cell) + row) % 2) continue;
const cx = x + nx * (k + cell / 2) + tx * (row - 0.5) * cell, cy = y + ny * (k + cell / 2) + ty * (row - 0.5) * cell;
ctx.fillStyle = 'rgba(246,246,238,0.85)'; ctx.fillRect(Math.round(cx - cell / 2), Math.round(cy - cell / 2), cell, cell);
}
}
function car(ctx, img, x, y, heading, alpha = 1, flipped = false) {
ctx.save(); ctx.globalAlpha = alpha; ctx.translate(x, y); ctx.rotate(heading);
if (flipped) ctx.scale(1, -1);
ctx.fillStyle = 'rgba(10,12,16,0.35)'; ctx.fillRect(-CAR.len / 2 + 1, -CAR.wid / 2 + 2, CAR.len, CAR.wid); // l'ombra
if (img) ctx.drawImage(img, -CAR.len / 2, -CAR.wid / 2, CAR.len, CAR.wid);
else { ctx.fillStyle = '#d8402f'; ctx.fillRect(-CAR.len / 2, -CAR.wid / 2, CAR.len, CAR.wid); }
ctx.restore();
}
function dashed(ctx, x0, y0, x1, y1, color) {
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.setLineDash([]);
}
// La mira: alla bona tutto il tiro, con la macchinina fantasma dove si ferma (rossa se esce); seria solo la linea; a
// fassi male una freccina. Quando si carica, il dito si tira indietro; se resta troppo alla carica piena diventa rosso
// (sta per essere un accompagnè).
function aim(ctx, c, img) {
const me = c.cars.player, ux = Math.cos(c.angle), uy = Math.sin(c.angle), kind = c.skill.guide;
if (kind === 'full') {
const g = guide(c), color = g.out ? '#ff7a5c' : '#b9f0a8';
dashed(ctx, me.x, me.y, g.x, g.y, color);
car(ctx, img, g.x, g.y, c.angle, 0.35);
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(g.x, g.y, CAR.r + 3, 0, Math.PI * 2); ctx.stroke();
} else {
const length = kind === 'line' ? 70 : 24;
dashed(ctx, me.x, me.y, me.x + ux * length, me.y + uy * length, '#fff8dc');
const hx = me.x + ux * length, hy = me.y + uy * length;
ctx.fillStyle = '#fff8dc'; ctx.beginPath();
ctx.moveTo(hx + ux * 4, hy + uy * 4); ctx.lineTo(hx - uy * 3, hy + ux * 3); ctx.lineTo(hx + uy * 3, hy - ux * 3); ctx.fill();
}
const back = CAR.len / 2 + 4 + (c.charging ? c.power * 14 : 0), fx = me.x - ux * back, fy = me.y - uy * back;
const over = overHeld(c);
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.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)
// che sparisce per tornare al suo posto.
function returns(ctx, c, images) {
for (const r of c.returns) {
const age = c.visual.time - r.at;
if (age > 3) continue;
const a = 1 - age / 3;
ctx.strokeStyle = `rgba(255,110,80,${a})`; ctx.lineWidth = 2; ctx.beginPath();
ctx.moveTo(r.x - 5, r.y - 5); ctx.lineTo(r.x + 5, r.y + 5); ctx.moveTo(r.x + 5, r.y - 5); ctx.lineTo(r.x - 5, r.y + 5); ctx.stroke();
const fade = r.fault === 'cappotte' ? 1.2 : 0.6;
if (age < fade) car(ctx, images[r.role], r.x, r.y, r.heading + (r.fault === 'cappotte' ? 0.5 : 0), 0.7 * (1 - age / fade), r.fault === 'cappotte');
}
}
// 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) {
ctx.save(); ctx.imageSmoothingEnabled = false;
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4b4d50'; ctx.fillRect(0, 0, 640, 360); }
drawChalk(ctx, c.track);
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) {
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);
ctx.stroke();
}
returns(ctx, c, images);
if (canThrow(c)) aim(ctx, c, images.player);
for (const who of ['cpu', 'player']) { const x = c.cars[who]; car(ctx, images[who], x.x, x.y, x.heading); }
// In alto: quanta pista ha fatto ciascuno (una barra col colore della macchinina) e cosa succede.
ctx.fillStyle = '#17160fe8'; ctx.fillRect(0, 0, 640, 22);
text(ctx, 'LA PISTA COL GESSO', 8, 7, 8, '#efd49e');
const level = { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level];
text(ctx, `${level} · spinte ${c.flicks.player}`, 8, 16, 7, '#d9ccba');
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');
ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 140, 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, 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 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 (c.step === 'result') {
const w = c.result.winner, title = w === 'player' ? 'HAI BATTUTO IL CHIARU!' : w === 'cpu' ? 'HA VINTO IL CHIARU' : 'ARRIVATI PARI';
// Il perché, in due righe: chi è arrivato a quale spinta, e di quanto è andato più in là.
const [first, second] = why(c);
panel(ctx, 130, 132, 380, 92);
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();
}
+415
View File
@@ -0,0 +1,415 @@
// Le macchinine sulla pista col gesso (Andrea, 28 ottobre): sul parcheggio sotto casa, a Cardetole, si disegnava col
// gesso la pista e si davano spinte a turno alle Micro Machines; la macchinina doveva restare in pista: tutta fuori, si
// torna da dove si era partiti. Si gioca contro il Chiaru, un giro; vince chi taglia per primo il traguardo.
//
// Regole: una spinta a testa, prima Andrea e poi il Chiaru, così a fine giro hanno spinto lo stesso numero di volte.
// Se tagliano il traguardo tutti e due nello stesso giro di spinte vince chi è andato più in là. Le penalità, coi nomi
// alla francese che si usavano (Andrea, 29 ottobre), e per tutte si torna dove si era prima della spinta:
// - fuori: la macchinina esce tutta dal gesso (anche se ce la manda l'altra, urtandola);
// - tagliè: esce dalla parte di dentro di una curva, o salta l'erba di mezzo per tagliare la strada;
// - cappottè: un urto forte la ribalta;
// - accompagnè: il dito l'ha accompagnata invece di darle il colpetto (tenuto premuto oltre la carica piena).
// Unità: i pixel del primo piano 640×360; la pista si disegna sopra al parcheggio di Codex.
// La linea di mezzo, in senso orario a partire dal traguardo; la pista è larga 2·HALF, col gesso sui due bordi.
export const CONTROL = [
[150, 68], [300, 62], [450, 66], [540, 80], [586, 122], [580, 172], [536, 196], [470, 196], [414, 178],
[356, 168], [318, 196], [326, 240], [388, 262], [480, 260], [556, 262], [598, 290], [588, 324], [530, 334],
[400, 334], [250, 332], [130, 326], [74, 296], [58, 230], [62, 150], [88, 96],
];
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².
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;
// 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è.
export const FLIP = 260, ACCOMPAGNA = 0.45;
export const FAULTS = { fuori: 'Fuori', taglie: 'Tagliè', cappotte: 'Cappottè', accompagne: 'Accompagnè' };
export const OTHER = { player: 'cpu', cpu: 'player' };
// Quanto è bravo il Chiaru: quanto gli trema il dito (radianti e forza) e fin dove osa spingere (px). `guide`: cosa
// vede Andrea mentre mira (tutto il tiro col punto d'arrivo, solo la linea, solo la freccia).
export const LEVELS = {
bona: { wobble: 0.1, powerNoise: 0.16, ahead: 120, guide: 'full' },
seria: { wobble: 0.05, powerNoise: 0.08, ahead: 170, guide: 'line' },
male: { wobble: 0.022, powerNoise: 0.035, ahead: 200, guide: 'arrow' },
};
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; }
const deviation = c => random(c) + random(c) + random(c) - 1.5;
export const powerAt = time => clamp(time / SWING, 0, 1);
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * clamp(power, 0, 1);
// Quanta strada fa una macchinina spinta a velocità v (l'asfalto frena sempre uguale).
export const reach = v => v * v / (2 * DECEL);
export const powerFor = distance => clamp((Math.sqrt(2 * DECEL * Math.max(0, distance)) - MIN_SPEED) / (MAX_SPEED - MIN_SPEED), 0, 1);
// --- La pista: una curva chiusa (Catmull-Rom sui punti di controllo), ricampionata a passo costante.
export function buildTrack(control = CONTROL, step = 2) {
const n = control.length, dense = [];
for (let i = 0; i < n; i++) {
const [p0, p1, p2, p3] = [control[(i - 1 + n) % n], control[i], control[(i + 1) % n], control[(i + 2) % n]];
for (let k = 0; k < 24; k++) {
const t = k / 24, t2 = t * t, t3 = t2 * t;
const f = j => 0.5 * (2 * p1[j] + (p2[j] - p0[j]) * t + (2 * p0[j] - 5 * p1[j] + 4 * p2[j] - p3[j]) * t2 + (3 * p1[j] - p0[j] - 3 * p2[j] + p3[j]) * t3);
dense.push([f(0), f(1)]);
}
}
const cum = [0];
for (let i = 1; i <= dense.length; i++) cum.push(cum[i - 1] + Math.hypot(dense[i % dense.length][0] - dense[i - 1][0], dense[i % dense.length][1] - dense[i - 1][1]));
const total = cum.at(-1), count = Math.round(total / step), pts = [];
for (let j = 0, i = 0; j < count; j++) {
const at = j * total / count;
while (cum[i + 1] < at) i++;
const a = dense[i], b = dense[(i + 1) % dense.length], t = (at - cum[i]) / (cum[i + 1] - cum[i] || 1);
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
const tan = pts.map((p, i) => {
const a = pts[(i - 1 + count) % count], b = pts[(i + 1) % count], d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
return [(b[0] - a[0]) / d, (b[1] - a[1]) / d];
});
return { pts, tan, n: count, step: total / count, length: total };
}
export const TRACK = buildTrack();
// Il punto della linea di mezzo più vicino: vicino all'ultimo noto (`hint`), così nei tornanti non si salta dall'altra
// parte; senza `hint`, su tutta la pista. `s` è la distanza lungo la pista dal traguardo, `d` quella dalla linea.
export function project(track, x, y, hint) {
const { pts, n } = track;
const from = hint === undefined ? 0 : hint - WINDOW, to = hint === undefined ? n : hint + WINDOW;
let best = null;
for (let k = from; k < to; k++) {
const i = ((k % n) + n) % n, a = pts[i], b = pts[(i + 1) % n];
const dx = b[0] - a[0], dy = b[1] - a[1], len2 = dx * dx + dy * dy || 1;
const t = clamp(((x - a[0]) * dx + (y - a[1]) * dy) / len2, 0, 1);
const d = Math.hypot(x - a[0] - dx * t, y - a[1] - dy * t);
if (!best || d < best.d) best = { i, t, d };
}
return { i: best.i, d: best.d, s: (best.i + best.t) * track.step };
}
// Tutta fuori: nessun pezzo della macchinina tocca più la pista.
export const isOut = d => d > OUT;
// Uscita dalla parte di dentro di una curva (verso dove gira la pista): è un tagliè.
function inside(track, i, x, y) {
const a = track.tan[(i - 6 + track.n) % track.n], b = track.tan[(i + 6) % track.n], turn = a[0] * b[1] - a[1] * b[0];
const [px, py] = track.pts[i], [tx, ty] = track.tan[i], side = tx * (y - py) - ty * (x - px);
return Math.abs(turn) > 0.05 && Math.sign(turn) === Math.sign(side);
}
// La macchinina tiene il conto della strada fatta (`progress`, anche oltre il giro) seguendo la pista da vicino.
function follow(track, car) {
const p = project(track, car.x, car.y, car.idx);
let delta = p.s - car.s;
if (delta > track.length / 2) delta -= track.length; else if (delta < -track.length / 2) delta += track.length;
car.progress += delta; car.s = p.s; car.idx = p.i;
if (isOut(p.d) && !car.fault) car.fault = inside(track, p.i, car.x, car.y) ? 'taglie' : 'fuori';
}
function newCar(track, role, back, side) {
const i = (track.n - Math.round(back / track.step)) % track.n, [x, y] = track.pts[i], [tx, ty] = track.tan[i];
const car = { role, x: x - ty * side, y: y + tx * side, vx: 0, vy: 0, heading: Math.atan2(ty, tx), idx: i, s: 0, progress: 0, fault: null };
const p = project(track, car.x, car.y);
car.idx = p.i; car.s = p.s; car.progress = p.s > track.length / 2 ? p.s - track.length : p.s;
return car;
}
const cars = c => [c.cars.player, c.cars.cpu];
export const moving = c => cars(c).some(car => car.vx || car.vy);
// L'urto fra le due macchinine: due dischi uguali, un po' di rimbalzo. Se l'urto è forte, quella colpita (la più
// lenta delle due) si ribalta.
function collide(a, b, events) {
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy), min = CAR.r * 2;
if (d >= min || d < 1e-6) return;
const nx = dx / d, ny = dy / d, push = (min - d) / 2;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const rel = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (rel <= 0) return;
const j = rel * (1 + RESTITUTION) / 2;
const hit = Math.hypot(a.vx, a.vy) < Math.hypot(b.vx, b.vy) ? a : b;
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
if (rel > FLIP) hit.fault = 'cappotte';
events?.push({ kind: 'micro-urto', power: Math.min(1, rel / 300) });
}
function step(track, list, dt, events) {
for (const car of list) {
const v = Math.hypot(car.vx, car.vy);
if (!v) continue;
const nv = Math.max(0, v - DECEL * dt);
if (nv < 2) { car.vx = car.vy = 0; continue; }
car.vx *= nv / v; car.vy *= nv / v;
car.x += car.vx * dt; car.y += car.vy * dt;
}
collide(list[0], list[1], events);
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 = {}) {
const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK;
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 },
line: null, trail: [], returns: [], before: null, plan: null, visual: { time: 0 } };
// 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.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;
}
export const canThrow = c => c.step === 'aim' && c.current === 'player';
export const finished = (c, car) => car.progress >= c.laps * c.track.length;
const snapshot = car => ({ x: car.x, y: car.y, heading: car.heading, idx: car.idx, s: car.s, progress: car.progress });
export function strike(c, who, angle, power) {
c.turn = null;
const car = c.cars[who], v = speedFor(power);
c.before = { player: snapshot(c.cars.player), cpu: snapshot(c.cars.cpu) };
for (const x of cars(c)) x.fault = null;
car.vx = Math.cos(angle) * v; car.vy = Math.sin(angle) * v; car.heading = angle;
c.returns = c.returns.filter(r => c.visual.time - r.at < 4);
if (who === 'player') c.line = null;
c.shooter = who; c.angle = angle; c.power = power; c.plan = null; c.trail = [[car.x, car.y]];
c.step = 'rolling'; c.t = c.accumulator = 0; c.flicks[who]++;
c.events.push({ kind: 'micro-spinta', power }, { kind: 'micro-strisciata', power });
return true;
}
// La spinta provata a vuoto su una copia: dove arriva ciascuna macchinina e se qualcuna esce.
export function simulate(c, who, angle, power) {
const list = cars(c).map(x => ({ ...x, fault: null })), car = list.find(x => x.role === who), v = speedFor(power);
car.vx = Math.cos(angle) * v; car.vy = Math.sin(angle) * v;
for (let i = 0; i < 6 / FIXED_STEP && list.some(x => x.vx || x.vy); i++) step(c.track, list, FIXED_STEP);
return Object.fromEntries(list.map(x => [x.role, x]));
}
// Il Chiaru guarda la pista davanti a sé: prova dei punti più o meno lontani, al centro e verso i bordi, e per ognuno
// anche le spinte storte che gli possono scappare; sceglie quello che in media lo porta più avanti senza uscire.
// Le prove si fanno a pezzi (BUDGET per fotogramma): tutte insieme fermerebbero il gioco per un decimo di secondo.
const BUDGET = 20;
function cpuCandidates(c) {
const me = c.cars.cpu, k = c.skill, list = [];
for (let ahead = 30; ahead <= k.ahead; ahead += 10) for (const side of [-10, 0, 10]) {
const i = (me.idx + Math.round(ahead / c.track.step)) % c.track.n, [px, py] = c.track.pts[i], [tx, ty] = c.track.tan[i];
const x = px - ty * side, y = py + tx * side, angle = Math.atan2(y - me.y, x - me.x), power = powerFor(Math.hypot(x - me.x, y - me.y));
for (const [da, dp] of [[0, 0], [k.wobble, 0], [-k.wobble, 0], [0, k.powerNoise], [0, -k.powerNoise]])
list.push({ angle, power, try: [angle + da, clamp(power + dp, 0, 1)] });
}
return list;
}
export function cpuThink(c, budget = BUDGET) {
const t = c.thinking ??= { todo: cpuCandidates(c), scores: new Map() }, me = c.cars.cpu;
for (let i = 0; i < budget && t.todo.length; i++) {
const x = t.todo.pop(), r = simulate(c, 'cpu', ...x.try).cpu, key = x.angle + '|' + x.power;
t.scores.set(key, { angle: x.angle, power: x.power, score: (t.scores.get(key)?.score ?? 0) + (r.fault ? -80 : r.progress - me.progress) });
}
if (t.todo.length) return false;
const best = [...t.scores.values()].reduce((a, b) => b.score > a.score ? b : a, { score: -Infinity, angle: me.heading, power: 0.3 });
const k = c.skill;
c.plan = { angle: best.angle + deviation(c) * k.wobble, power: clamp(best.power + deviation(c) * k.powerNoise, 0.02, 1) };
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
return true;
}
// Finita la spinta: chi ha fatto un fallo torna al suo posto, e alla fine di ogni giro di spinte si guarda il traguardo.
function settle(c) {
const names = { player: 'Andrea', cpu: 'il Chiaru' }, outs = cars(c).filter(car => car.fault);
// Uscita e ripresa la pista più in là: ha saltato l'erba di mezzo, è un tagliè.
for (const car of outs) if (car.fault === 'fuori' && !isOut(project(c.track, car.x, car.y).d)) car.fault = 'taglie';
// Le scritte di un giro di spinte restano insieme fino alla spinta dopo di Andrea: così vede anche le sue.
const said = outs.map(car => `${FAULTS[car.fault]}! ${names[car.role][0].toUpperCase() + names[car.role].slice(1)} torna dov'era.`);
// Dove è uscita resta una croce, e la macchinina si vede tornare indietro (solo disegno).
for (const car of outs) {
c.returns.push({ role: car.role, x: car.x, y: car.y, heading: car.heading, fault: car.fault, at: c.visual.time });
Object.assign(car, c.before[car.role], { vx: 0, vy: 0, fault: null });
c.events.push({ kind: 'micro-fuori', power: car.role === 'player' ? 1 : 0.5 });
}
// Rimessa al suo posto, non deve stare sopra all'altra.
const [a, b] = cars(c), d = Math.hypot(b.x - a.x, b.y - a.y);
if (outs.length === 1 && d < CAR.r * 2) {
const back = outs[0], other = back === a ? b : a, nx = d ? (back.x - other.x) / d : 1, ny = d ? (back.y - other.y) / d : 0;
back.x = other.x + nx * CAR.r * 2; back.y = other.y + ny * CAR.r * 2;
}
const mine = c.cars[c.shooter];
if (!outs.length && finished(c, mine)) said.push(`${c.shooter === 'player' ? 'Andrea' : 'Il Chiaru'} taglia il traguardo!`);
c.line = [c.line, ...said].filter(Boolean).join(' ') || null;
if (!outs.includes(mine) && finished(c, mine)) c.events.push({ kind: 'micro-arrivo', power: c.shooter === 'player' ? 1 : 0.5 });
// Il giro di spinte si chiude col Chiaru: solo allora si decide, così hanno spinto tutti e due lo stesso numero di volte.
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)) {
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 }, over };
return;
}
// Chi ha già tagliato il traguardo aspetta; spinge l'altro.
c.current = OTHER[c.shooter];
c.step = 'aim'; c.t = 0;
if (c.current === 'player') c.angle = aimAhead(c);
}
// Quando tocca ad Andrea la mira parte già lungo la pista, un po' più avanti.
function aimAhead(c) {
const car = c.cars.player, i = (car.idx + Math.round(60 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i];
return Math.atan2(y - car.y, x - car.x);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
if (c.turn && canThrow(c) && !c.holding) {
c.turn.time += dt;
c.angle += c.turn.dir * dt * (c.turn.fine ? 0.05 : 0.6 + 1.8 * Math.min(1, c.turn.time / 1.2));
}
if (c.holding) {
c.holdTime += dt;
c.charging = c.holdTime >= c.holdDelay;
if (c.charging) c.power = c.holdDelay ? Math.min(1, c.previousPower + powerAt(c.holdTime - c.holdDelay)) : powerAt(c.holdTime);
// Alla carica piena il dito non deve restare: si conta quanto ci sta, per l'accompagnè.
c.overHold = c.charging && c.power >= 1 ? (c.overHold ?? 0) + dt : 0;
}
if (c.step === 'rolling') {
c.accumulator += dt;
const list = cars(c), car = c.cars[c.shooter];
while (c.accumulator >= FIXED_STEP) { step(c.track, list, FIXED_STEP, c.events); c.accumulator -= FIXED_STEP; }
const last = c.trail.at(-1);
if (!last || Math.hypot(last[0] - car.x, last[1] - car.y) > 3) c.trail.push([car.x, car.y]);
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
} else if (c.step === 'settle' && c.t >= REST) settle(c);
else if (c.step === 'aim' && c.current === 'cpu') {
if (!c.plan && c.t >= THINK * 0.3) cpuThink(c);
if (c.plan && c.t >= THINK) strike(c, 'cpu', c.plan.angle, c.plan.power);
}
}
// --- Comandi del giocatore: gli stessi del biliardo (mira col mouse o col dito, si tiene premuto per la forza).
export function aimAt(c, x, y) {
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const car = c.cars.player;
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;
}
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) {
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
c.angle += dir * (fine ? 0.004 : 0.03); 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 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) {
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;
c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.004 : 0.03);
c.aimLocked = true; return true;
}
export function stopTurn(c, owner) {
if (c.turn?.owner !== owner) return false;
c.turn = null; return true;
}
export function key(c, key, down, fine = false) {
if (!['ArrowLeft', 'ArrowRight'].includes(key)) return false;
if (down) { if (c.turn?.owner === key) c.turn.fine = fine; else turn(c, key === 'ArrowLeft' ? -1 : 1, key, fine); }
else stopTurn(c, key);
return true;
}
export function press(c, pointerId) {
if (c.step === 'choose') return pick(c);
if (!canThrow(c) || c.holding) return false;
// Premuto, la forza parte da zero e sale finché non si lascia (Andrea, 29 ottobre): come il dito che si carica.
c.turn = null; c.pointerId = pointerId; c.holdDelay = 0;
c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 0;
return true;
}
export function cancel(c, pointerId) {
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
c.turn = null; c.charging = false;
if (!c.holding) return false;
c.holding = 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
const accompagne = (c.overHold ?? 0) > ACCOMPAGNA;
c.holding = c.charging = false; c.overHold = 0;
strike(c, 'player', c.angle, c.power);
if (accompagne) c.cars.player.fault = 'accompagne';
return true;
}
// Sta tenendo il dito oltre la carica piena: il disegno lo avvisa prima che diventi un accompagnè.
export const overHeld = c => c.holding && (c.overHold ?? 0) > 0;
// La guida: dove si fermerebbe la macchinina, se nessuno la tocca, e se ci arriva restando in pista.
export function guide(c) {
const car = c.cars.player, length = reach(speedFor(c.power)), ux = Math.cos(c.angle), uy = Math.sin(c.angle);
let idx = car.idx, out = false;
for (let d = 0; d <= length; d += 2) {
const p = project(c.track, car.x + ux * d, car.y + uy * d, idx);
idx = p.i; if (isOut(p.d)) { out = true; break; }
}
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) {
if (c.step === 'choose') return 'Scegli';
if (c.step === 'result') return 'Finita';
if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…';
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.
export const done = (c, who) => clamp(c.cars[who].progress / (c.laps * c.track.length), 0, 1);
export function status(c) {
if (c.step === 'choose') return `Scegli la macchinina: ${CARS[c.choice].name}. Tocca quella che vuoi, o frecce e Scegli.`;
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.current === 'cpu') return `${where} Spinge il Chiaru…`;
return `${where} ${c.line ? c.line + ' ' : ''}Tocca a te: mira e spingi.`;
}
+12 -6
View File
@@ -9,6 +9,7 @@ import { drawRinquarto } from './rinquarto-render.js';
import { drawBasket } from './basket-render.js';
import { drawPesca } from './pesca-render.js';
import { drawGara } from './gara-render.js';
import { drawMicro } from './micro-render.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
@@ -130,7 +131,7 @@ export function backgroundOrder(story, first) {
for (const h of story.scenes[queue[i]]?.hotspots ?? [])
if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); }
for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id);
return [...new Set(queue.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.
@@ -152,8 +153,8 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
}
export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) {
const scene = game.scene, s = game.state, bg = backgrounds.get(scene.background);
if (!bg && scene.background) loadBackground(scene.background);
const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
if (!bg && backdrop) loadBackground(backdrop);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, WIDTH, HEIGHT);
if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT);
@@ -394,7 +395,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
// due onde lente sovrapposte, così il movimento è morbido e non a scatti.
sway(ctx, e, time, game) {
const bg = backgrounds.get(game.scene.background);
const bg = backgrounds.get(game.backdrop());
if (!bg) return;
const k = bg.width / WIDTH, amount = e.amount ?? 3.2;
for (const [i, [x, y, w, h]] of e.rects.entries()) {
@@ -411,7 +412,7 @@ export const EFFECTS = {
// 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`).
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;
const layer = scratch(), lc = layer.getContext('2d'), [ax, ay, aw, ah] = e.area;
const pool = e.big ? CLOUDS : CLOUDS.slice(2);
@@ -451,7 +452,7 @@ export const EFFECTS = {
// 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.
cloth(ctx, e, time, game) {
const bg = backgrounds.get(game.scene.background);
const bg = backgrounds.get(game.backdrop());
if (!bg) return;
const k = bg.width / WIDTH;
for (const [i, [x, y, w, h]] of e.rects.entries()) {
@@ -686,6 +687,11 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return;
}
if (def.type === 'micro') {
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); });
return;
}
if (def.type === 'gara') {
if (def.picture && !backgrounds.has(def.picture)) loadBackground(def.picture);
drawGara(ctx, c, def, def.picture ? backgrounds.get(def.picture) : null,
+7 -7
View File
@@ -4,7 +4,7 @@ import { guide, canThrow, shooter } from './rinquarto.js';
import { text, panel } from './bocce-render.js';
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
const NAME = { andrea: 'Andrea', bertovo: 'Bertovo' };
const NAME = { andrea: 'Andrea', bertovo: 'Trappizzio Berti' };
// La traiettoria del tiro: resta sul tavolo finché non si va avanti, con le sponde numerate e il contatto che decide.
function trail(ctx, c) {
if (c.trail.length < 2) return;
@@ -59,19 +59,19 @@ export function drawRinquarto(ctx, c, def, bg) {
ctx.restore();
// In alto: chi tira, il punteggio, le sponde; l'esito sta qui, così non copre il tavolo.
ctx.fillStyle = '#17160ff0'; ctx.fillRect(54, 4, 532, 64);
text(ctx, c.skill.training ? 'I RINQUARTI DI BERTOVO · ALLENAMENTO' : 'I RINQUARTI DI BERTOVO', 320, 13, 9, '#efd49e', 'center');
const score = c.skill.training ? 'senza punti' : `Andrea ${c.score.andrea} Bertovo ${c.score.bertovo}`;
text(ctx, c.skill.training ? 'I RINQUARTI DI TRAPPIZZIO BERTI · ALLENAMENTO' : 'I RINQUARTI DI TRAPPIZZIO BERTI', 320, 13, 9, '#efd49e', 'center');
const score = c.skill.training ? 'senza punti' : `Andrea ${c.score.andrea} Trappizzio Berti ${c.score.bertovo}`;
text(ctx, `Posizione ${c.position}/${c.skill.positions} ${score} Sponde ${c.attempt.cushions}/${c.skill.cushions}`, 320, 29, 9, '#fff0c8', 'center');
const who = NAME[shooter(c)];
const line = c.step === 'review' ? c.line : c.step === 'think' ? 'Tira Bertovo…' : c.step === 'rolling' ? `Tira ${who}…` : `Tocca ad ${who}: «Oh tirami questo rinquarto!?»`;
const line = c.step === 'review' ? c.line : c.step === 'think' ? 'Tira Trappizzio Berti…' : c.step === 'rolling' ? `Tira ${who}…` : `Tocca ad ${who}: «Oh tirami questo rinquarto!?»`;
text(ctx, line, 320, 45, 8, c.step === 'review' ? (c.attempt.point ? '#b9e5b3' : '#ffbf9f') : '#9fd0ff', 'center');
if (c.step === 'review') text(ctx, c.last ? 'Tocca per il risultato' : 'Tocca o premi Prossima', 320, 59, 7, '#d9ccba', 'center');
if (c.step === 'result') {
const r = c.result, title = r.training ? 'ALLENAMENTO FINITO' : r.winner === 'player' ? 'HAI BATTUTO BERTOVO!' : r.winner === 'cpu' ? 'HA VINTO BERTOVO' : 'PARI';
const r = c.result, title = r.training ? 'ALLENAMENTO FINITO' : r.winner === 'player' ? 'HAI BATTUTO TRAPPIZZIO BERTI!' : r.winner === 'cpu' ? 'HA VINTO TRAPPIZZIO BERTI' : 'PARI';
panel(ctx, 155, 145, 330, 80);
text(ctx, title, 320, 166, 12, r.winner === 'player' ? '#b9e5b3' : '#efd49e', 'center');
if (!r.training) text(ctx, `Andrea ${r.points.andrea} · Bertovo ${r.points.bertovo}, su ${c.skill.positions} posizioni`, 320, 190, 8, '#efd49e', 'center');
text(ctx, 'Torna da Bertovo per uscire.', 320, 210, 8, '#d9ccba', 'center');
if (!r.training) text(ctx, `Andrea ${r.points.andrea} · Trappizzio Berti ${r.points.bertovo}, su ${c.skill.positions} posizioni`, 320, 190, 8, '#efd49e', 'center');
text(ctx, 'Torna da Trappizzio Berti per uscire.', 320, 210, 8, '#d9ccba', 'center');
}
ctx.restore();
}
+12 -12
View File
@@ -1,7 +1,7 @@
// I rinquarti di Bertovo (Andrea, 28 ottobre; revisione di Codex): Bertovo mette le biglie in modo che la diretta sia
// I rinquarti di Trappizzio Berti (Andrea, 28 ottobre; revisione di Codex): Trappizzio Berti mette le biglie in modo che la diretta sia
// coperta, e si deve arrivare al bersaglio (la gialla) dopo almeno tante sponde senza toccare prima i disturbi.
// Sulla stessa posizione tira prima Andrea, poi il tavolo torna com'era e tira Bertovo: un punto per ogni rinquarto
// riuscito, sei posizioni, vince chi ne fa di più. Nell'allenamento tira prima Bertovo, per far vedere, e senza punti.
// Sulla stessa posizione tira prima Andrea, poi il tavolo torna com'era e tira Trappizzio Berti: un punto per ogni rinquarto
// riuscito, sei posizioni, vince chi ne fa di più. Nell'allenamento tira prima Trappizzio Berti, per far vedere, e senza punti.
//
// La forza è sempre piena: conta solo il primo contatto, quindi tirare piano non serve mai (revisione di Codex,
// rilievo 5). Il gioco è un rompicapo di angoli.
@@ -23,7 +23,7 @@ const SCAN = 720, STEP_ANGLE = Math.PI * 2 / SCAN, BUDGET = 15, THINK = 1.1;
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
const gauss = c => Math.sqrt(-2 * Math.log(1 - random(c))) * Math.cos(2 * Math.PI * random(c));
// z tale che una normale stia entro ±z con probabilità p: quanto deve essere stretto l'errore di Bertovo.
// z tale che una normale stia entro ±z con probabilità p: quanto deve essere stretto l'errore di Trappizzio Berti.
const zFor = p => p >= 1 ? Infinity : Math.sqrt(2) * erfinv(p);
function erfinv(x) {
const a = 0.147, l = Math.log(1 - x * x), t = 2 / (Math.PI * a) + l / 2;
@@ -40,7 +40,7 @@ export const canPress = c => canThrow(c) || c.step === 'review';
export const aimFree = c => !canThrow(c);
export const setPower = () => false; // la forza è sempre piena
export const button = c => c.step === 'review' ? (c.last ? 'Finita' : 'Prossima') : c.step === 'result' ? 'Finita'
: c.step === 'think' ? 'Tira Bertovo…' : c.step === 'aim' ? 'Tira' : 'In movimento…';
: c.step === 'think' ? 'Tira Trappizzio Berti…' : c.step === 'aim' ? 'Tira' : 'In movimento…';
// Conta solo le sponde della bianca prima del primo ostacolo; il primo contatto decide.
function contact(result, required, { kind, a, b }) {
@@ -141,7 +141,7 @@ function setTable(c) {
c.shot = c.pending = c.visual.shot = null; c.windup = 0; c.trail = [];
c.angle = Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x);
c.attempt = attempt();
c.line = shooter(c) === 'andrea' ? 'Bertovo: «Oh tirami questo rinquarto!?»' : 'Tira Bertovo…';
c.line = shooter(c) === 'andrea' ? 'Trappizzio Berti: «Oh tirami questo rinquarto!?»' : 'Tira Trappizzio Berti…';
}
function nextPosition(c) {
while (!c.ready.length) { const p = generate(c, 4000); if (p) c.ready.push(p); }
@@ -152,13 +152,13 @@ function nextPosition(c) {
export function newRinquarto(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { ...billiard.newBiliardo(seed), level, skill: LEVELS[level], position: 0, score: { andrea: 0, bertovo: 0 }, history: [],
const c = { ...billiard.newBiliardo(seed, { maxSpeed: billiard.MAX_SPEED }), level, skill: LEVELS[level], position: 0, score: { andrea: 0, bertovo: 0 }, history: [],
order: LEVELS[level].training ? ['bertovo', 'andrea'] : ['andrea', 'bertovo'], ready: [], gen: null, fixedPower: 1, trail: [], last: false };
nextPosition(c);
return c;
}
// Bertovo mira al centro della finestra buona, con un errore che dipende dal livello: dentro la finestra con la
// Trappizzio Berti mira al centro della finestra buona, con un errore che dipende dal livello: dentro la finestra con la
// probabilità `bertovo`. Il tiro poi si fa davvero: se sbaglia, sbaglia sul panno.
export function bertovoPlan(c) {
const half = c.solution.width / 2 * Math.PI / 180, z = zFor(c.skill.bertovo);
@@ -206,7 +206,7 @@ export function update(c, dt) {
const who = shooter(c);
if (!c.skill.training) c.score[who] += Number(c.attempt.point);
c.history.push({ position: c.position, who, ...c.attempt });
c.line = `${who === 'andrea' ? 'Andrea' : 'Bertovo'}: ${c.attempt.point ? 'punto!' : `niente, ${c.attempt.reason}.`}`;
c.line = `${who === 'andrea' ? 'Andrea' : 'Trappizzio Berti'}: ${c.attempt.point ? 'punto!' : `niente, ${c.attempt.reason}.`}`;
c.step = 'review';
c.last = c.round === c.order.length - 1 && c.position >= c.skill.positions;
}
@@ -226,14 +226,14 @@ export function guide(c) {
const need = c => c.skill.cushions === 1 ? 'Serve almeno 1 sponda.' : `Servono almeno ${c.skill.cushions} sponde.`;
export function status(c) {
const score = c.skill.training ? 'Allenamento, senza punti.' : `Andrea ${c.score.andrea}, Bertovo ${c.score.bertovo}.`;
const score = c.skill.training ? 'Allenamento, senza punti.' : `Andrea ${c.score.andrea}, Trappizzio Berti ${c.score.bertovo}.`;
const prefix = `Posizione ${c.position} di ${c.skill.positions}. ${score}`;
if (c.step === 'result') {
if (c.result.training) return 'Allenamento finito. Quando vuoi, la sfida vera.';
return `${prefix} ${c.result.winner === 'player' ? 'Hai battuto Bertovo!' : c.result.winner === 'cpu' ? 'Ha vinto Bertovo.' : 'Pari.'}`;
return `${prefix} ${c.result.winner === 'player' ? 'Hai battuto Trappizzio Berti!' : c.result.winner === 'cpu' ? 'Ha vinto Trappizzio Berti.' : 'Pari.'}`;
}
if (c.step === 'review') return `${prefix} ${c.line} ${c.last ? 'Tocca per il risultato.' : 'Tocca o premi Prossima.'}`;
if (c.step === 'think') return `${prefix} Tira Bertovo…`;
if (c.step === 'think') return `${prefix} Tira Trappizzio Berti…`;
if (c.step === 'rolling') return `${prefix} Sponde: ${c.attempt.cushions}/${c.skill.cushions}.`;
return `${prefix} ${c.line} ${need(c)}`;
}
+6
View File
@@ -136,6 +136,12 @@ export class Soundscape {
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-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); }
// 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-strisciata') this.noiseSound(0.3 + strength * 0.7, 900, 0.03 + strength * 0.04, { pan });
else if (kind === 'micro-urto') { this.tone(3000, 1800, 0.03, 0.03 + strength * 0.06, 0, pan); this.noiseSound(0.03, 3600, 0.03, { pan }); }
else if (kind === 'micro-fuori') { this.tone(420, 160, 0.25, 0.05, 0, pan); this.tone(300, 110, 0.3, 0.04, 0.12, pan); }
else if (kind === 'micro-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 pesca: il lancio, il tuffo del galleggiante, il tocco, l'abboccata, la ferrata, la lenza che tira, lo strappo, il pesce a riva.
else if (kind === 'pesca-lancio') this.noiseSound(0.22, 1800, 0.05 + strength * 0.06, { pan });
else if (kind === 'pesca-tuffo') { this.tone(420, 180, 0.07, 0.07, 0, pan); this.noiseSound(0.12, 900, 0.06, { pan }); }
+33 -13
View File
@@ -11,6 +11,7 @@ import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
import * as rinquarto from './engine/rinquarto.js';
import * as micro from './engine/micro.js';
import * as basket from './engine/basket.js';
import * as pesca from './engine/pesca.js';
import * as gara from './engine/gara.js';
@@ -509,7 +510,7 @@ canvas.addEventListener('pointerdown', e => {
}
if (press?.closeup) return;
const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'rinquarto', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (['biliardo', 'rinquarto', 'basket', 'micro'].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.closeup.aimLocked = true; // il clic fissa la mira
}
@@ -581,7 +582,7 @@ canvas.addEventListener('pointermove', e => {
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
return;
}
const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type);
const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro'].includes(story.closeups[game.closeup.id].type);
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));
});
@@ -601,10 +602,11 @@ const MINI = {
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
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.' },
rinquarto: { ...rinquarto, back: 'Torna da Bertovo', label: 'I rinquarti di Bertovo: 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.' },
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.' },
// 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.' },
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
@@ -632,7 +634,17 @@ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('gara-p
padPointer = null; $('gara-pad').classList.remove('active');
});
$('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.
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
for (let i = 0; i < 3; i++) {
@@ -933,7 +945,10 @@ function updateUi() {
updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
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($('briscola-controls'), 'hidden', def?.type !== 'briscola');
setProp($('briscola-controls'), 'inert', !running);
@@ -998,7 +1013,7 @@ function updateUi() {
}
}
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' }[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' }[def.type] ?? 'Comandi delle bocce');
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
@@ -1009,24 +1024,29 @@ function updateUi() {
// 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).
const powerLabel = $('bocce-power').closest('label');
// Ai rinquarti la forza è sempre piena: il cursore non serve.
setProp(powerLabel, 'hidden', def.type === 'rinquarto');
// Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si
// usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
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à ' : 'Forza ';
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');
setProp($('gara-pad'), 'hidden', def.type !== 'gara');
setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy);
if (def.type === 'gara' && c.sensLoaded === undefined) {
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 */ }
}
setText($('bocce-left'), 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);
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($('biliardo-help'), 'hidden', def.type !== 'biliardo');
setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto');
setProp($('micro-help'), 'hidden', def.type !== 'micro');
setProp($('basket-help'), 'hidden', def.type !== 'basket');
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
setProp($('gara-help'), 'hidden', def.type !== 'gara');
@@ -1044,7 +1064,7 @@ function updateUi() {
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
// 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'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro'].includes(story.closeups[c.id].type));
$('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");
// La pagina che comincia a girare fa il suo fruscio.
@@ -1079,7 +1099,7 @@ function frame(now) {
ambience.update(soundscapeFor(game?.state.scene, game?.closeup?.id), game?.phase()?.id, dt);
// A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del
// telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava.
const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`;
const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
if (game && (!still || still !== drawnStill)) {
if (running) game.update(dt);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
+89 -26
View File
@@ -97,7 +97,7 @@ export const story = {
credits: [
{ role: 'Un gioco di', names: [{ name: 'Manzolo (Andrea Manzi)', face: 'assets/credits/manzolo.png' }] },
{ role: 'Idee e consigli', names: [{ name: 'Baldoria (Yuri Baldi)', face: 'assets/credits/baldoria.png' }, { name: 'BaronDeDuke (Gabriele Rossi)', face: 'assets/credits/barondeduke.png' }, { name: 'Tozzino (Francesco Tozzi)', face: 'assets/credits/tozzino.png' }, { name: 'l\'Arcu (Marco Arcuri)', face: 'assets/credits/arcu.png' }, { name: 'Eriketta (Erika Fenaroli)', face: 'assets/credits/eriketta.png' }] },
{ role: 'Storia e aneddoti di San Piero', names: [{ name: 'Francesco Bacci', face: 'assets/credits/bacci.png' }] },
{ role: 'Storia e aneddoti di San Piero', names: [{ name: 'Francesco Bacci', face: 'assets/credits/bacci.png' }, 'Fabrizio Berti, «Memorie di sport e di paese»'] },
{ role: 'Musica', names: ['Manzolo, con Suno'] },
{ role: 'Sfondi', names: ['dalle fotografie di San Piero di Andrea'] },
// Chi ci ha mandato foto, ricordi o idee: una riga per contributo, si allunga man mano.
@@ -226,13 +226,24 @@ export const story = {
rinquarto: {
type: 'rinquarto',
picture: 'assets/closeups/biliardo-tavolo.png',
names: { player: 'Andrea', cpu: 'Bertovo' },
// Andrea contro Bertovo, sulle stesse posizioni; nell'allenamento il risultato è sempre «pari». [DA ANDREA: le battute]
names: { player: 'Andrea', cpu: 'Trappizzio Berti' },
// Andrea contro Trappizzio Berti, sulle stesse posizioni; nell'allenamento il risultato è sempre «pari». [DA ANDREA: le battute]
win: [{ say: 'M\'hai battuto ai rinquarti. Però un lo dire a nessuno.', who: 'bertovo', pose: 'shrug' }, { set: 'rinquarti_vinti' }],
lose: [{ say: 'Le sponde ci sono per qualcosa! Basta chiederglielo per bene.', who: 'bertovo', pose: 'laugh' }],
quit: [{ say: 'Questo me lo tengo per la prossima.' }],
draw: [{ say: 'Visto? Geometrie celestiali. Si riprova quando vuoi.', who: 'bertovo', pose: 'explain' }],
},
// Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre).
// [DA ANDREA: le battute sono provvisorie]
micro: {
type: 'micro', music: 'assets/audio/gara.mp3', // in prestito dal GP di Fortezza, finché non c'è la sua
picture: 'assets/closeups/micro-parcheggio.png',
names: { player: 'Andrea', cpu: 'Chiaru' },
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' }],
quit: [{ say: 'Ti ritiri? Allora la vinco a tavolino.', who: 'chiaru', pose: 'laugh' }],
draw: [{ say: 'Pari. Si rifà, ma la pista la ridisegno io.', who: 'chiaru', pose: 'shrug' }],
},
fessura: {
type: 'torch',
picture: 'assets/closeups/palio-2007.jpg',
@@ -295,10 +306,13 @@ export const story = {
tosca: { name: 'La Tosca', color: '#ffb0d0' },
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
// L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt).
// Bertovo (Fabrizio Berti, consenso dato ad Andrea il 28 ottobre), il re dei rinquarti (sprite di Codex dalla sua foto).
bertovo: { name: 'Bertovo', color: '#9fd0ff', sprite: 'assets/characters/bertovo.json' },
// Trappizzio Berti (Fabrizio Berti, consenso dato ad Andrea il 28 ottobre), il re dei rinquarti (sprite di Codex dalla sua foto).
bertovo: { name: 'Trappizzio Berti', color: '#9fd0ff', sprite: 'assets/characters/bertovo.json' },
ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a', sprite: 'assets/characters/ing-tozzi.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
// Codex dalla foto di Andrea.
chiaru: { name: 'Il Chiaru', color: '#ffd6a0', sprite: 'assets/characters/chiaru.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
@@ -1683,7 +1697,7 @@ export const story = {
// L'Ing. Tozzi aspetta accanto al primo tavolo, con la stecca: si parla con lui per giocare, come con Mariano.
people: [
{ who: 'ing_tozzi', at: [452, 296], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 },
// Bertovo al tavolo di mezzo, coi suoi rinquarti (Andrea, 28 ottobre).
// Trappizzio Berti al tavolo di mezzo, coi suoi rinquarti (Andrea, 28 ottobre).
{ who: 'bertovo', at: [396, 238], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 5.1 },
],
enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }],
@@ -1705,9 +1719,9 @@ export const story = {
},
},
{
id: 'bertovo', name: 'Bertovo', box: [382, 162, 30, 76], at: [360, 244], face: 'right', default: 'talk',
id: 'bertovo', name: 'Trappizzio Berti', box: [382, 162, 30, 76], at: [360, 244], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Bertovo, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.' }],
look: [{ say: 'Trappizzio Berti, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.' }],
talk: [{ dialogue: 'bertovo' }],
},
},
@@ -2102,23 +2116,11 @@ export const story = {
entry: [320, 330],
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
speakers: { arcu: [418, 214] },
// La Ghiacciomobile non è più qui: sta nel muro di Via Liguri Magelli (Andrea, 29 ottobre).
people: [
{ 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: [
{
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',
actions: {
@@ -2696,6 +2698,9 @@ export const story = {
name: 'Via Liguri Magelli',
map: [76, 97],
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.
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]],
@@ -2731,11 +2736,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: {
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).
id: 'betino', name: 'il Betino dell\'Arcu', box: [232, 172, 40, 24], at: [250, 206], face: 'up', default: 'look',
@@ -2750,7 +2765,7 @@ export const story = {
},
{
// 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, 100], at: [520, 306], exit: { scene: 'campo_giannini', at: [60, 250], face: 'right' },
},
],
},
@@ -2762,7 +2777,16 @@ export const story = {
walk: [[0, 360], [0, 344], [120, 332], [200, 304], [262, 292], [330, 292], [372, 296], [640, 266], [640, 360]],
depth: [[266, 0.55], [360, 1.15]],
entry: [300, 350],
// Il Chiaru (Matteo Chiarusi) accanto alla pista col gesso, sul parcheggio sotto casa.
speakers: { chiaru: [520, 214] },
people: [{ who: 'chiaru', at: [520, 318], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 3.7 }],
hotspots: [
{
id: 'parcheggio', name: 'parcheggio', box: [356, 262, 280, 60], at: [450, 330], face: 'right',
actions: {
look: [{ say: 'Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.' }],
},
},
{
id: 'casa', name: 'casa di Andrea', box: [330, 20, 220, 212], at: [470, 290], face: 'up',
actions: {
@@ -2799,6 +2823,14 @@ export const story = {
{
id: 'al_trappola', name: 'a sinistra, dal Trappola', box: [0, 236, 50, 104], at: [20, 352], exit: { scene: 'via_liguri_magelli', at: [264, 202], face: 'down' },
},
// In fondo all'elenco, così sta sopra a parcheggio e casa: il clic sul Chiaru è suo (l'ultimo della lista vince).
{
id: 'chiaru', name: 'il Chiaru', box: [498, 212, 46, 106], at: [470, 330], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.' }],
talk: [{ dialogue: 'chiaru' }],
},
},
],
},
// L'ingresso della scuola, giù per la strada dalla salita di Villa Adami: dal cancello alle porte a vetri.
@@ -3217,13 +3249,44 @@ export const story = {
},
},
},
// Bertovo al tavolo di mezzo: le biglie nascoste e «Oh tirami questo rinquarto!?» (Andrea, 28 ottobre).
// Il Chiaru al parcheggio di Cardetole, accanto alla pista col gesso (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie]
chiaru: {
nodes: {
start: {
options: [
{ 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: [
{ 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' },
] },
// I nomi alla francese delle penalità (Andrea, 29 ottobre): per tutte si torna indietro.
{ text: 'Le regole sono quelle solite?', then: [
{ say: 'Le solite. Chi accompagna la macchinina col dito: accompagnè.', who: 'chiaru', pose: 'explain' },
{ 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' },
] },
// 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: {
options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: ti faccio vedere dove si ferma. E io vado piano.', who: 'chiaru', pose: 'talk' }, { closeup: 'micro', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Così mi piace.', who: 'chiaru', pose: 'explain' }, { closeup: 'micro', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male. Poi non piangere.', who: 'chiaru', pose: 'laugh' }, { closeup: 'micro', level: 'male' }] },
{ text: 'No, aspetta.', next: 'start', then: [{ say: 'Io intanto ripasso il gesso.', who: 'chiaru', pose: 'stand' }] },
],
},
},
},
// 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]
// Andrea, parlandogli, lo chiama Fabri (29 ottobre).
bertovo: {
nodes: {
start: {
options: [
{ text: 'Bertovo! 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' },
] },
{ text: 'Come fai a prendere le biglie dove nessuno ci arriva?', then: [
@@ -3238,7 +3301,7 @@ export const story = {
{ 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' },
] },
{ text: 'Ci si vede, Bertovo.', 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: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.36';
export const VERSION = '0.1.44';
// 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).
+7 -1
View File
@@ -56,7 +56,11 @@ 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; }
#gara-pad.active { border-style: solid; color: #f3dcac; }
#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-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-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; }
@@ -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; }
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#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); }
#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. */
+6 -3
View File
@@ -128,11 +128,13 @@ try {
const fixed = (await state()).angle;
await page.mouse.move(target.r.x + target.r.width * 0.55, target.r.y + target.r.height * 0.8, { steps: 8 });
assert.equal((await state()).angle, fixed, 'spostarsi verso Tira non deve cambiare la mira');
// Le frecce girano finché sono giù (sempre più svelte); un colpo secco è un passo, con Maiusc un passo fine.
await page.keyboard.press('ArrowRight');
const nudged = (await state()).angle;
assert.ok(Math.abs(nudged - fixed - 0.002) < 1e-10);
assert.ok(nudged - fixed >= 0.03 - 1e-9 && nudged - fixed < 0.1, `passo: ${nudged - fixed}`);
await page.keyboard.press('Shift+ArrowLeft');
assert.ok(Math.abs((await state()).angle - nudged + 0.0005) < 1e-10);
const fine = nudged - (await state()).angle;
assert.ok(fine >= 0.0005 - 1e-9 && fine < 0.01, `passo fine: ${fine}`);
await page.locator('#bocce-power').fill('60');
const prepared = await state();
await page.screenshot({ path: `${out}/${name}-mira.png` });
@@ -158,7 +160,8 @@ try {
const cdp = mobile ? await page.context().newCDPSession(page) : null;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.25);
// Si tiene premuto: dopo LONG_PRESS la stecca comincia a caricare.
await page.waitForFunction(() => game.closeup.holding && game.closeup.charging);
const heldAngle = (await state()).angle;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: hold.x - 30, y: hold.y + 20 }] });
else await page.mouse.move(hold.x - 30, hold.y + 20, { steps: 4 });
+6 -3
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP } from '../src/engine/biliardo.js';
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP, STRONG } from '../src/engine/biliardo.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
@@ -34,6 +34,9 @@ test('la forza: sale finché si tiene premuto; a metà la biglia fa circa un tav
assert.ok(reach(speedFor(0.5)) > LEN * 0.9 && reach(speedFor(0.5)) < LEN * 1.3, `a metà forza: ${reach(speedFor(0.5)).toFixed(0)} cm`);
assert.ok(reach(speedFor(1)) > LEN * 2);
assert.ok(reach(speedFor(0)) < 30, 'piano piano si sposta appena');
// All'Ing. Tozzi il tiro pieno va più forte (Andrea, 29 ottobre); i rinquarti restano alla forza di prima.
assert.ok(reach(speedFor(1, STRONG)) > reach(speedFor(1)) * 1.3);
assert.equal(newBiliardo(1).maxSpeed, STRONG);
});
test('un colpo dritto sulla biglia avversaria la manda avanti, poi tocca all\'altro', () => {
@@ -126,14 +129,14 @@ test('la guida e il tiro finiscono nello stesso punto, anche dopo sponde oblique
const end = p.points.at(-1);
shoot(c); roll(c);
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power)), 0.01);
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power, c.maxSpeed)), 0.01);
}
});
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
const c = emptyTable();
Object.assign(c.balls.player, { x: 270, y: 30 });
c.angle = Math.PI / 4; c.power = 0.3;
c.angle = Math.PI / 4; c.power = 0.2; // la stessa velocità di 0,3 prima del tiro più forte
const p = predict(c), [start, rail, end] = p.points;
assert.equal(p.points.length, 3);
near(rail[0], LEN - R);
+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 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 { story } from '../src/story/story.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', () => {
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);
assert.equal(c.step, 'start', 'un tocco per pilota e la presentazione è finita');
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', () => {
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');
for (let t = 0; t < 180 && c.step === 'race'; t += 1 / 60) {
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', () => {
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);
run(wild, COUNTDOWN + 60, () => true);
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');
const heard = new Set();
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
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}`);
@@ -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', () => {
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);
run(c, COUNTDOWN + 120, careful);
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.
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!');
});
+100
View File
@@ -0,0 +1,100 @@
// La pista col gesso nel browser vero, su desktop e touch: la scena di Cardetole col Chiaru, la mira, la spinta
// tenendo premuto e i comandi del pannello. npm run browser:micro — schermate in /tmp/regolo-micro.
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-micro';
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 }],
['portrait', true, { width: 390, height: 844 }],
]) {
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('cardetole'); settle(game);
});
await page.waitForTimeout(1200);
if (name === 'desktop') await page.screenshot({ path: `${out}/${name}-cardetole.png` });
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
game.act('talk', 'chiaru'); 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);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => {
const { hasBackground } = await import('/src/engine/render.js');
return hasBackground('assets/closeups/micro-parcheggio.png');
});
// 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');
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');
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 }));
// 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 target = { x: canvas.x + ahead[0] / 640 * canvas.width, y: canvas.y + ahead[1] / 360 * canvas.height };
if (!mobile) {
const before = (await state()).angle;
await page.mouse.move(target.x, target.y + 30, { steps: 4 });
assert.notEqual((await state()).angle, before, 'senza clic la mira segue il mouse');
await page.mouse.click(target.x, target.y);
} else await page.touchscreen.tap(target.x, target.y);
const aimed = await state();
assert.equal(aimed.step, 'aim', 'un tocco mira senza spingere');
await page.screenshot({ path: `${out}/${name}-mira.png` });
// Si spinge: col mouse tenendo premuto, col dito dal pulsante dopo aver scelto la forza.
if (!mobile) {
// Si lascia appena la carica arriva: restando troppo alla carica piena sarebbe un accompagnè.
await page.mouse.move(target.x, target.y); await page.mouse.down();
await page.waitForFunction(() => game.closeup.charging && game.closeup.power >= 0.75);
await page.mouse.up();
} else {
await page.locator('#bocce-power').fill('70');
await page.locator('#bocce-throw').tap();
}
await page.waitForTimeout(250);
const rolling = await state();
assert.equal(rolling.step, 'rolling', 'la macchinina parte');
await page.screenshot({ path: `${out}/${name}-corsa.png` });
// Poi spinge il Chiaru, e si torna a mirare.
await page.waitForFunction(() => game.closeup.current === 'player' && game.closeup.step === 'aim', null, { timeout: 15000 });
assert.notEqual((await state()).x, aimed.x, 'la macchinina di Andrea si è mossa');
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();
}
+221
View File
@@ -0,0 +1,221 @@
import test from 'node:test';
import { existsSync } from 'node:fs';
import assert from 'node:assert/strict';
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 { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
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.
function run(c, limit = 20000) {
for (let i = 0; i < limit && !(m.canThrow(c) || c.step === 'result'); i++) m.update(c, 1 / 60);
}
// Andrea «prudente»: mira a un punto della linea di mezzo `ahead` pixel più avanti, con la forza giusta per arrivarci.
function flick(c, ahead = 60) {
const car = c.cars.player, i = (car.idx + Math.round(ahead / c.track.step)) % c.track.n, [x, y] = c.track.pts[i];
c.angle = Math.atan2(y - car.y, x - car.x); c.power = m.powerFor(Math.hypot(x - car.x, y - car.y));
assert.ok(m.shoot(c));
}
// Andrea alla bona: la spinta più lunga che la guida dà in pista, puntando avanti lungo la linea di mezzo.
function guided(c) {
for (let ahead = 190; ahead >= 20; ahead -= 10) {
flickAim(c, ahead);
if (!m.guide(c).out) break;
}
assert.ok(m.shoot(c));
}
function flickAim(c, ahead) {
const car = c.cars.player, i = (car.idx + Math.round(ahead / c.track.step)) % c.track.n, [x, y] = c.track.pts[i];
c.angle = Math.atan2(y - car.y, x - car.x); c.power = m.powerFor(Math.hypot(x - car.x, y - car.y));
}
test('la pista: chiusa, larga uguale, e le macchinine partono in pista poco prima del traguardo', () => {
const t = m.TRACK;
assert.ok(t.length > 1500 && t.length < 2500);
for (let i = 0; i < t.n; i++) {
const [x, y] = t.pts[i];
assert.ok(x - m.HALF > 0 && x + m.HALF < 640 && y - m.HALF > 22 && y + m.HALF < 360, `la pista sta nel quadro (${x}, ${y})`);
}
// I tratti lontani lungo la pista non si toccano: fra i due gessi c'è sempre asfalto.
for (let i = 0; i < t.n; i += 3) for (let j = i + 3; j < t.n; j += 3) {
const along = Math.min(j - i, t.n - j + i) * t.step;
if (along < 120) continue;
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)`);
}
const c = newMicro(1);
for (const car of Object.values(c.cars)) {
assert.ok(!m.isOut(m.project(t, car.x, car.y).d));
assert.ok(car.progress < 0 && car.progress > -30, 'prima del traguardo');
}
});
test('stesso seme: stessa partita; la spinta va lontano quanto dice la guida', () => {
const a = newMicro(4, { level: 'male' }), b = newMicro(4, { level: 'male' });
for (const c of [a, b]) { flick(c); run(c); }
assert.deepEqual(a.cars, b.cars);
const c = newMicro(2, { level: 'bona' }), start = { ...c.cars.player };
c.power = 0.6; const g = m.guide(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).
assert.ok(Math.hypot(c.cars.player.x - g.x, c.cars.player.y - g.y) < 3, 'arriva dove diceva la guida');
assert.ok(Math.abs(Math.hypot(g.x - start.x, g.y - start.y) - m.reach(m.speedFor(0.6))) < 0.01);
});
test('tutta fuori: si torna dove si era prima della spinta, e la guida lo diceva', () => {
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
assert.equal(m.guide(c).out, true);
assert.ok(m.shoot(c));
for (let i = 0; i < 2000 && c.step !== 'settle'; i++) m.update(c, 1 / 60);
assert.equal(c.cars.player.fault, 'fuori');
for (let i = 0; i < 200 && c.current === 'player'; i++) m.update(c, 1 / 60);
assert.deepEqual([c.cars.player.x, c.cars.player.y], [start.x, start.y]);
assert.match(c.line, /^Fuori! Andrea torna/);
assert.equal(c.returns.length, 1);
// Nel tornante non si salta dall'altra parte: si esce lo stesso.
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', () => {
// Tagliè: dal tratto di mezzo della esse, dritti in su attraverso l'erba verso il tratto di sopra.
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 });
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 < 200 && t.current === 'player'; i++) m.update(t, 1 / 60);
assert.match(t.line, /^Tagliè! Andrea torna dov'era\./);
assert.deepEqual([t.cars.player.x, t.cars.player.y], [sx, sy]);
// Cappottè: Andrea a tutta forza contro il Chiaru fermo davanti.
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));
for (let i = 0; i < 2000 && k.step !== 'settle'; i++) m.update(k, 1 / 60);
assert.equal(him.fault, 'cappotte');
for (let i = 0; i < 200 && k.current === 'player'; i++) m.update(k, 1 / 60);
assert.match(k.line, /Cappottè! Il Chiaru torna dov'era\./);
// 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);
// Accompagnè: tenuto premuto ben oltre la carica piena.
const a = newMicro(3), start = { x: a.cars.player.x, y: a.cars.player.y };
assert.ok(m.press(a));
for (let i = 0; i < 120; i++) m.update(a, 1 / 60);
assert.ok(m.overHeld(a));
assert.ok(m.release(a));
for (let i = 0; i < 2000 && a.current === 'player'; i++) m.update(a, 1 / 60);
assert.match(a.line, /^Accompagnè! Andrea torna dov'era\./);
assert.deepEqual([a.cars.player.x, a.cars.player.y], [start.x, start.y]);
// Lasciando in tempo, la spinta piena vale.
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);
m.release(ok); assert.equal(ok.cars.player.fault, null);
});
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 };
for (const seed of [1, 2, 3]) {
const c = newMicro(seed, { level: 'bona' });
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.result.flicks.player, c.result.flicks.cpu);
assert.ok(m.finished(c, c.cars.player) || m.finished(c, c.cars.cpu));
wins[c.result.winner]++;
}
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.
const flicks = 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); }
return c.flicks.cpu;
};
assert.ok(flicks('male') < flicks('bona'));
});
test('i comandi: un tocco mira soltanto, tenere premuto carica, lasciare spinge; la mira si gira con le frecce', () => {
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');
// 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;
assert.ok(m.release(c, 1)); assert.equal(c.step, 'rolling');
m.update(c, 0.2); assert.notEqual(c.cars.player.x, before);
assert.equal(m.press(c, 2), false, 'mentre corre non si spinge');
const d = newMicro(5), a0 = d.angle;
m.key(d, 'ArrowRight', true); m.update(d, 0.5); m.key(d, 'ArrowRight', false);
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.setPower(d, 0.25)); assert.equal(d.power, 0.25);
});
test('il Chiaru pensa a pezzi: nessun fotogramma lo ferma troppo', () => {
const c = newMicro(6, { level: 'male' });
flick(c);
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); }
assert.ok(worst < 40, `fotogramma da ${worst.toFixed(1)} ms`);
});
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 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); };
draw();
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.some(([op, t]) => op === 'fillText' && t === 'Chiaru'));
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'));
});
test('il Chiaru al parcheggio di Cardetole: si chiacchiera, si sceglie il livello e si corre', () => {
assert.ok(MINIGAMES.micro);
const g = new Game(story); g.go('cardetole'); settle(g);
// Il clic su tutto il Chiaru (testa, pancia, gambe) è suo, anche dove sta sopra al parcheggio.
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);
assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito');
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);
assert.equal(g.closeup, null);
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);
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 }, over: { player: 4, cpu: -30 } };
g.closeupClose(); settle(g);
assert.ok(g.state.flags.micro_vinto);
});
+9 -9
View File
@@ -7,7 +7,7 @@ import { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
// Tira (Andrea col tiro dato, o Bertovo da sé) e aspetta l'esito.
// Tira (Andrea col tiro dato, o Trappizzio Berti da sé) e aspetta l'esito.
function roll(c) {
for (let i = 0; i < 20000 && c.step !== 'review' && c.step !== 'result'; i++) r.update(c, 1 / 60);
assert.ok(['review', 'result'].includes(c.step), 'il tiro si deve fermare');
@@ -55,7 +55,7 @@ test('la diretta tocca il disturbo; meno sponde del richiesto non valgono', () =
assert.equal(r.tryShot({ ...bona, skill: r.LEVELS.male }, bona.solution.angle).point, one.cushions >= 3);
});
test('a turno: prima Andrea, poi il tavolo torna com\'era e tira Bertovo; l\'esito resta finché non si va avanti', () => {
test('a turno: prima Andrea, poi il tavolo torna com\'era e tira Trappizzio Berti; l\'esito resta finché non si va avanti', () => {
const c = r.newRinquarto(8, { level: 'bona' });
assert.equal(r.shooter(c), 'andrea'); assert.ok(r.canThrow(c)); assert.equal(r.button(c), 'Tira');
const mine = andrea(c);
@@ -68,7 +68,7 @@ test('a turno: prima Andrea, poi il tavolo torna com\'era e tira Bertovo; l\'esi
assert.equal(r.shooter(c), 'bertovo'); assert.equal(c.step, 'think');
assert.ok(samePlace(c, c.start), 'il tavolo è tornato com\'era');
assert.equal(r.canThrow(c), false, 'mentre tira lui i comandi sono fermi'); assert.equal(r.shoot(c), false);
assert.match(r.status(c), /Tira Bertovo/);
assert.match(r.status(c), /Tira Trappizzio Berti/);
roll(c);
assert.equal(c.history.at(-1).who, 'bertovo'); assert.equal(c.score.bertovo, Number(c.history.at(-1).point));
r.advance(c);
@@ -81,18 +81,18 @@ test('alla fine vince chi ha più punti; può finire pari', () => {
assert.equal(c.step, 'result');
assert.equal(c.score.andrea, 0);
assert.equal(c.result.winner, c.score.bertovo > 0 ? 'cpu' : 'draw');
assert.match(r.status(c), c.score.bertovo > 0 ? /Ha vinto Bertovo/ : /Pari/);
assert.match(r.status(c), c.score.bertovo > 0 ? /Ha vinto Trappizzio Berti/ : /Pari/);
for (const [a, b, w] of [[3, 2, 'player'], [2, 3, 'cpu'], [2, 2, 'draw']]) {
const d = { ...c, step: 'review', last: true, round: 1, position: c.skill.positions, score: { andrea: a, bertovo: b } };
r.advance(d); assert.equal(d.result.winner, w);
}
});
test('l\'allenamento: tira prima Bertovo, sempre giusto, poi Andrea; niente punti, finisce pari', () => {
test('l\'allenamento: tira prima Trappizzio Berti, sempre giusto, poi Andrea; niente punti, finisce pari', () => {
const c = r.newRinquarto(2, { level: 'allenamento' });
assert.equal(r.shooter(c), 'bertovo'); assert.equal(c.step, 'think');
for (let p = 0; p < c.skill.positions; p++) {
roll(c); assert.ok(c.history.at(-1).point, 'Bertovo fa vedere il rinquarto');
roll(c); assert.ok(c.history.at(-1).point, 'Trappizzio Berti fa vedere il rinquarto');
r.advance(c); assert.equal(r.shooter(c), 'andrea');
andrea(c); r.advance(c);
}
@@ -100,7 +100,7 @@ test('l\'allenamento: tira prima Bertovo, sempre giusto, poi Andrea; niente punt
assert.equal(c.result.winner, 'draw'); assert.ok(c.result.training);
});
test('Bertovo è più preciso salendo di livello', () => {
test('Trappizzio Berti è più preciso salendo di livello', () => {
const spread = level => {
const c = r.newRinquarto(4, { level: 'bona' }); c.skill = r.LEVELS[level];
const errors = Array.from({ length: 400 }, () => r.bertovoPlan(c).angle - c.solution.angle);
@@ -131,7 +131,7 @@ test('i comandi: un tocco mira e basta, tenendo premuto si tira; il secondo dito
assert.equal(r.setPower(d, 0.2), false); assert.equal(d.power, 1);
});
test('dialogo di Bertovo: si chiacchiera, la sfida o l\'allenamento; lui sta al tavolo di mezzo del Circolo', () => {
test('dialogo di Trappizzio Berti: si chiacchiera, la sfida o l\'allenamento; lui sta al tavolo di mezzo del Circolo', () => {
assert.ok(MINIGAMES.rinquarto);
assert.equal(story.characters.bertovo.sprite, 'assets/characters/bertovo.json');
const where = Object.entries(story.scenes).filter(([, sc]) => JSON.stringify(sc).includes('bertovo')).map(([id]) => id);
@@ -169,7 +169,7 @@ test('il disegno: traiettoria con le sponde numerate, riepilogo, e lo stato non
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === '1'), 'la prima sponda è numerata');
assert.ok(calls.some(([op, t]) => op === 'fillText' && /punto!/.test(t)));
c.step = 'result'; c.result = { winner: 'cpu', points: { andrea: 1, bertovo: 3 } }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO BERTOVO'));
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO TRAPPIZZIO BERTI'));
} finally {
if (previous === undefined) delete globalThis.document; else globalThis.document = previous;
}
+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', () => {
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.deepEqual(new Set(order), all);
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 { settle } from './helpers.mjs';
// La Ghiacciomobile al circolino è uno sprite; il Betino in cima alla salita di Via Liguri Magelli è dipinto nel quadro.
const SPRITES = [['campomigliaio_circolo', 'ghiacciomobile', [150, 170]]];
const VEHICLES = [...SPRITES, ['via_liguri_magelli', 'betino']];
// I veicoli sono dipinti nel quadro: il Betino in cima alla salita di Via Liguri Magelli e, prima del tramonto, la
// Ghiacciomobile dentro il muro giallo (Andrea, 29 ottobre). Nessuno è più uno sprite.
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', () => {
for (const [scene, id, [lo, hi]] of SPRITES) {
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
@@ -1,6 +1,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».",
"celo": "ce l'ho",
"Trappizzio": "Trappìzzio",
"Cosimo I": "Cosimo primo",
"padel": "pàdel",
"l'A. Bettini": "l'Abbettini",
+3
View File
@@ -35,6 +35,9 @@
"speaker": "Xavier Hayasaka",
"filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92"
},
"chiaru": {
"speaker": "Luis Moray"
},
"barondeduke": {
"speaker": "Craig Gutsy"
},