diff --git a/assets/characters/chiaru.json b/assets/characters/chiaru.json new file mode 100644 index 0000000..b563f56 --- /dev/null +++ b/assets/characters/chiaru.json @@ -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 + } + } +} diff --git a/assets/characters/chiaru.png b/assets/characters/chiaru.png new file mode 100644 index 0000000..c740574 Binary files /dev/null and b/assets/characters/chiaru.png differ diff --git a/assets/closeups/micro-parcheggio.png b/assets/closeups/micro-parcheggio.png new file mode 100644 index 0000000..33801e8 Binary files /dev/null and b/assets/closeups/micro-parcheggio.png differ diff --git a/assets/closeups/micro/arancio.png b/assets/closeups/micro/arancio.png new file mode 100644 index 0000000..cc92538 Binary files /dev/null and b/assets/closeups/micro/arancio.png differ diff --git a/assets/closeups/micro/azzurra.png b/assets/closeups/micro/azzurra.png new file mode 100644 index 0000000..20a8e76 Binary files /dev/null and b/assets/closeups/micro/azzurra.png differ diff --git a/assets/closeups/micro/blu.png b/assets/closeups/micro/blu.png new file mode 100644 index 0000000..a05e90f Binary files /dev/null and b/assets/closeups/micro/blu.png differ diff --git a/assets/closeups/micro/nera.png b/assets/closeups/micro/nera.png new file mode 100644 index 0000000..66bdd91 Binary files /dev/null and b/assets/closeups/micro/nera.png differ diff --git a/assets/closeups/micro/rossa.png b/assets/closeups/micro/rossa.png new file mode 100644 index 0000000..e08cd95 Binary files /dev/null and b/assets/closeups/micro/rossa.png differ diff --git a/assets/closeups/micro/viola.png b/assets/closeups/micro/viola.png new file mode 100644 index 0000000..9feb3d6 Binary files /dev/null and b/assets/closeups/micro/viola.png differ diff --git a/assets/voices/andrea-04890c1d8e.mp3 b/assets/voices/andrea-04890c1d8e.mp3 new file mode 100644 index 0000000..5b538e8 Binary files /dev/null and b/assets/voices/andrea-04890c1d8e.mp3 differ diff --git a/assets/voices/andrea-089ce1d608.mp3 b/assets/voices/andrea-089ce1d608.mp3 new file mode 100644 index 0000000..2778422 Binary files /dev/null and b/assets/voices/andrea-089ce1d608.mp3 differ diff --git a/assets/voices/andrea-0b28b74cdb.mp3 b/assets/voices/andrea-0b28b74cdb.mp3 deleted file mode 100644 index 89f8aeb..0000000 Binary files a/assets/voices/andrea-0b28b74cdb.mp3 and /dev/null differ diff --git a/assets/voices/andrea-2eaaaffa37.mp3 b/assets/voices/andrea-2eaaaffa37.mp3 deleted file mode 100644 index b1a0469..0000000 Binary files a/assets/voices/andrea-2eaaaffa37.mp3 and /dev/null differ diff --git a/assets/voices/andrea-3dd737ed1b.mp3 b/assets/voices/andrea-3dd737ed1b.mp3 new file mode 100644 index 0000000..35fb7b2 Binary files /dev/null and b/assets/voices/andrea-3dd737ed1b.mp3 differ diff --git a/assets/voices/andrea-4089f348c4.mp3 b/assets/voices/andrea-4089f348c4.mp3 new file mode 100644 index 0000000..7f90a5c Binary files /dev/null and b/assets/voices/andrea-4089f348c4.mp3 differ diff --git a/assets/voices/andrea-75b58c99e6.mp3 b/assets/voices/andrea-75b58c99e6.mp3 new file mode 100644 index 0000000..c293793 Binary files /dev/null and b/assets/voices/andrea-75b58c99e6.mp3 differ diff --git a/assets/voices/andrea-8052393bfd.mp3 b/assets/voices/andrea-8052393bfd.mp3 new file mode 100644 index 0000000..0d9f946 Binary files /dev/null and b/assets/voices/andrea-8052393bfd.mp3 differ diff --git a/assets/voices/andrea-8670e6ce99.mp3 b/assets/voices/andrea-8670e6ce99.mp3 new file mode 100644 index 0000000..5948607 Binary files /dev/null and b/assets/voices/andrea-8670e6ce99.mp3 differ diff --git a/assets/voices/andrea-8d5911c8e7.mp3 b/assets/voices/andrea-8d5911c8e7.mp3 new file mode 100644 index 0000000..d2aee90 Binary files /dev/null and b/assets/voices/andrea-8d5911c8e7.mp3 differ diff --git a/assets/voices/andrea-a3364fd72b.mp3 b/assets/voices/andrea-a3364fd72b.mp3 deleted file mode 100644 index 90ea925..0000000 Binary files a/assets/voices/andrea-a3364fd72b.mp3 and /dev/null differ diff --git a/assets/voices/andrea-da5fb02d21.mp3 b/assets/voices/andrea-da5fb02d21.mp3 new file mode 100644 index 0000000..2a13c37 Binary files /dev/null and b/assets/voices/andrea-da5fb02d21.mp3 differ diff --git a/assets/voices/andrea-ee03433b9c.mp3 b/assets/voices/andrea-ee03433b9c.mp3 new file mode 100644 index 0000000..d915dab Binary files /dev/null and b/assets/voices/andrea-ee03433b9c.mp3 differ diff --git a/assets/voices/chiaru-1db3c53c04.mp3 b/assets/voices/chiaru-1db3c53c04.mp3 new file mode 100644 index 0000000..a1a4e91 Binary files /dev/null and b/assets/voices/chiaru-1db3c53c04.mp3 differ diff --git a/assets/voices/chiaru-3810b7d228.mp3 b/assets/voices/chiaru-3810b7d228.mp3 new file mode 100644 index 0000000..79a9fe3 Binary files /dev/null and b/assets/voices/chiaru-3810b7d228.mp3 differ diff --git a/assets/voices/chiaru-3e949c1519.mp3 b/assets/voices/chiaru-3e949c1519.mp3 new file mode 100644 index 0000000..179ea2a Binary files /dev/null and b/assets/voices/chiaru-3e949c1519.mp3 differ diff --git a/assets/voices/chiaru-841aa1efd7.mp3 b/assets/voices/chiaru-841aa1efd7.mp3 new file mode 100644 index 0000000..3ae4331 Binary files /dev/null and b/assets/voices/chiaru-841aa1efd7.mp3 differ diff --git a/assets/voices/chiaru-89bf2bb9f9.mp3 b/assets/voices/chiaru-89bf2bb9f9.mp3 new file mode 100644 index 0000000..91d8005 Binary files /dev/null and b/assets/voices/chiaru-89bf2bb9f9.mp3 differ diff --git a/assets/voices/chiaru-91a4b23b22.mp3 b/assets/voices/chiaru-91a4b23b22.mp3 new file mode 100644 index 0000000..ca0d5a2 Binary files /dev/null and b/assets/voices/chiaru-91a4b23b22.mp3 differ diff --git a/assets/voices/chiaru-91c549bb83.mp3 b/assets/voices/chiaru-91c549bb83.mp3 new file mode 100644 index 0000000..cff8bfd Binary files /dev/null and b/assets/voices/chiaru-91c549bb83.mp3 differ diff --git a/assets/voices/chiaru-a0d7dbb4c1.mp3 b/assets/voices/chiaru-a0d7dbb4c1.mp3 new file mode 100644 index 0000000..3a6b72c Binary files /dev/null and b/assets/voices/chiaru-a0d7dbb4c1.mp3 differ diff --git a/assets/voices/chiaru-a7ab8d241f.mp3 b/assets/voices/chiaru-a7ab8d241f.mp3 new file mode 100644 index 0000000..149cf65 Binary files /dev/null and b/assets/voices/chiaru-a7ab8d241f.mp3 differ diff --git a/assets/voices/chiaru-a7d83e9454.mp3 b/assets/voices/chiaru-a7d83e9454.mp3 new file mode 100644 index 0000000..0ef31e4 Binary files /dev/null and b/assets/voices/chiaru-a7d83e9454.mp3 differ diff --git a/assets/voices/chiaru-bb17a500b5.mp3 b/assets/voices/chiaru-bb17a500b5.mp3 new file mode 100644 index 0000000..b0c618f Binary files /dev/null and b/assets/voices/chiaru-bb17a500b5.mp3 differ diff --git a/assets/voices/chiaru-d31cc223f6.mp3 b/assets/voices/chiaru-d31cc223f6.mp3 new file mode 100644 index 0000000..c34fdb4 Binary files /dev/null and b/assets/voices/chiaru-d31cc223f6.mp3 differ diff --git a/assets/voices/chiaru-df4b1e3a0e.mp3 b/assets/voices/chiaru-df4b1e3a0e.mp3 new file mode 100644 index 0000000..5a26c4f Binary files /dev/null and b/assets/voices/chiaru-df4b1e3a0e.mp3 differ diff --git a/assets/voices/chiaru-f551f6a268.mp3 b/assets/voices/chiaru-f551f6a268.mp3 new file mode 100644 index 0000000..bb0a966 Binary files /dev/null and b/assets/voices/chiaru-f551f6a268.mp3 differ diff --git a/assets/voices/chiaru-f8afa97da1.mp3 b/assets/voices/chiaru-f8afa97da1.mp3 new file mode 100644 index 0000000..549e850 Binary files /dev/null and b/assets/voices/chiaru-f8afa97da1.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index c95817f..9e7218e 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -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", @@ -1639,6 +1639,14 @@ "file": "andrea-3d15dd47bb.mp3", "sec": 4.82 }, + "andrea|Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.": { + "file": "andrea-8d5911c8e7.mp3", + "sec": 4.53 + }, + "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 @@ -2427,9 +2435,73 @@ "file": "mariano-d65fb6e871.mp3", "sec": 3.42 }, - "andrea|Bertovo! Che fai, giochi da solo?": { - "file": "andrea-2eaaaffa37.mp3", - "sec": 3 + "andrea|Si corre?": { + "file": "andrea-8052393bfd.mp3", + "sec": 0.7 + }, + "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|Dopo, Chiaru.": { + "file": "andrea-75b58c99e6.mp3", + "sec": 1.32 + }, + "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|La pista è qui. Finché non piove.": { + "file": "chiaru-841aa1efd7.mp3", + "sec": 2.39 + }, + "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|Trappizzio Berti! Che fai, giochi da solo?": { + "file": "andrea-da5fb02d21.mp3", + "sec": 3.4 }, "andrea|Come fai a prendere le biglie dove nessuno ci arriva?": { "file": "andrea-c6670ffb48.mp3", @@ -2443,9 +2515,9 @@ "file": "andrea-fe39c8f30c.mp3", "sec": 1.49 }, - "andrea|Ci si vede, Bertovo.": { - "file": "andrea-a3364fd72b.mp3", - "sec": 1.78 + "andrea|Ci si vede, Trappizzio Berti.": { + "file": "andrea-4089f348c4.mp3", + "sec": 2.57 }, "bertovo|Da solo? Io gioco contro le sponde. E vinco quasi sempre.": { "file": "bertovo-5e771069ff.mp3", @@ -2775,6 +2847,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 diff --git a/docs/IDEE.md b/docs/IDEE.md index 56ea2bf..4b743a1 100644 --- a/docs/IDEE.md +++ b/docs/IDEE.md @@ -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. diff --git a/docs/IMMAGINI.md b/docs/IMMAGINI.md index 3ea93f0..d62ccee 100644 --- a/docs/IMMAGINI.md +++ b/docs/IMMAGINI.md @@ -29,6 +29,8 @@ 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) | | `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 | diff --git a/docs/STATO.md b/docs/STATO.md index 0ab807a..faf2590 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -18,6 +18,16 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, ## Dove siamo arrivati +- **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). +- **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 +316,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). diff --git a/index.html b/index.html index 66aa620..8ad28d6 100644 --- a/index.html +++ b/index.html @@ -249,14 +249,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.
+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à.
+Se durante la corsa la macchinina esce tutta 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.
+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.
+I rinquarti di Bertovo: sei posizioni. Su ognuna tiri prima tu, poi il tavolo torna com'era e tira Bertovo; un +
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.
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.
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 diff --git a/package.json b/package.json index 11e0ca2..c001239 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "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:tressette": "node tests/tressette.browser.mjs", "browser:scopa": "node tests/scopa.browser.mjs", "browser:briscola": "node tests/briscola.browser.mjs", diff --git a/src/engine/game.js b/src/engine/game.js index 32eb60a..51b4b29 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -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 }, }; diff --git a/src/engine/micro-render.js b/src/engine/micro-render.js new file mode 100644 index 0000000..bb4e590 --- /dev/null +++ b/src/engine/micro-render.js @@ -0,0 +1,121 @@ +import { HALF, CAR, canThrow, guide, done, overHeld } 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 +} +// 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'); + } +} + +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); + const images = { player: image?.(def.cars?.player), cpu: image?.(def.cars?.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'); + for (const [who, x0, color] of [['player', 168, '#e0543c'], ['cpu', 404, '#3c66d8']]) { + text(ctx, NAME[who], x0, 11, 8, '#fff0c8'); + ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 150, 8); + ctx.fillStyle = color; ctx.fillRect(x0 + 52, 7, Math.round(150 * done(c, who)), 8); + ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 52.5, 7.5, 149, 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'; + panel(ctx, 170, 140, 300, 72); + text(ctx, title, 320, 160, 12, w === 'player' ? '#b9e5b3' : '#efd49e', 'center'); + text(ctx, `Un giro, ${c.result.flicks.player} spinte a testa`, 320, 182, 8, '#efd49e', 'center'); + text(ctx, 'Torna dal Chiaru per uscire.', 320, 198, 8, '#d9ccba', 'center'); + } + ctx.restore(); +} diff --git a/src/engine/micro.js b/src/engine/micro.js new file mode 100644 index 0000000..9c9df9c --- /dev/null +++ b/src/engine/micro.js @@ -0,0 +1,362 @@ +// 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, LONG_PRESS = 0.6, 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); +} + +export function newMicro(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK; + const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0.5, angle: 0, + 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; + 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 } }; + 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 const aimScreen = (c, [x, y]) => 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 canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); } +export function turn(c, dir, owner, fine = false) { + if (!canThrow(c) || c.holding || c.turn) return false; + c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.004 : 0.03); + c.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 (!canThrow(c) || c.holding) return false; + c.turn = null; c.pointerId = pointerId; c.holdDelay = pointerId === undefined ? 0 : LONG_PRESS; + c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0; + if (!c.holdDelay) 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 < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco 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 }; +} + +export function button(c) { + if (c.step === 'result') return 'Finita'; + if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…'; + return c.current === 'cpu' ? 'Spinge il Chiaru…' : 'Spingi'; +} +// 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) { + const where = `Andrea ${Math.round(done(c, 'player') * 100)}%, Chiaru ${Math.round(done(c, 'cpu') * 100)}% del giro.`; + if (c.step === 'result') return `${where} ${c.result.winner === 'player' ? 'Hai battuto il Chiaru!' : c.result.winner === 'cpu' ? 'Ha vinto il Chiaru.' : 'Arrivati pari.'}`; + 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.`; +} diff --git a/src/engine/render.js b/src/engine/render.js index 675e851..6276639 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -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'; @@ -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, diff --git a/src/engine/rinquarto-render.js b/src/engine/rinquarto-render.js index 71ac363..80915bb 100644 --- a/src/engine/rinquarto-render.js +++ b/src/engine/rinquarto-render.js @@ -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(); } diff --git a/src/engine/rinquarto.js b/src/engine/rinquarto.js index 0eb2401..bcb3ca3 100644 --- a/src/engine/rinquarto.js +++ b/src/engine/rinquarto.js @@ -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); } @@ -158,7 +158,7 @@ export function newRinquarto(seed = Date.now() >>> 0, def = {}) { 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)}`; } diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index e2f398f..10d9f83 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -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 }); } diff --git a/src/main.js b/src/main.js index 57b2cb4..5b76f7a 100644 --- a/src/main.js +++ b/src/main.js @@ -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,7 +602,8 @@ 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.' }, @@ -998,7 +1000,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; @@ -1027,6 +1029,7 @@ function updateUi() { 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 +1047,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. diff --git a/src/story/story.js b/src/story/story.js index 5f7f3bd..6ca374d 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -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,25 @@ 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', + picture: 'assets/closeups/micro-parcheggio.png', + names: { player: 'Andrea', cpu: 'Chiaru' }, + cars: { player: 'assets/closeups/micro/rossa.png', cpu: 'assets/closeups/micro/blu.png' }, + win: [{ say: 'Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.', who: 'chiaru', pose: 'shrug' }, { set: 'micro_vinto' }], + 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 +307,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 +1698,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 +1720,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' }], }, }, @@ -2762,7 +2777,23 @@ 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: 'chiaru', name: 'il Chiaru', box: [500, 240, 40, 78], 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' }], + }, + }, + { + 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: { @@ -3217,13 +3248,42 @@ 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 corre?', next: 'livello', then: [{ say: 'Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?', who: 'chiaru', pose: 'explain' }] }, + { text: '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' }, + ] }, + { text: 'Dopo, Chiaru.', next: 'end', then: [{ say: 'La pista è qui. Finché non piove.', who: 'chiaru', pose: 'stand' }] }, + ], + }, + 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] bertovo: { nodes: { start: { options: [ - { text: 'Bertovo! Che fai, giochi da solo?', then: [ + { text: 'Trappizzio Berti! 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 +3298,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, Trappizzio Berti.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] }, ], }, sfida: { diff --git a/tests/micro.browser.mjs b/tests/micro.browser.mjs new file mode 100644 index 0000000..5043f12 --- /dev/null +++ b/tests/micro.browser.mjs @@ -0,0 +1,88 @@ +// 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 corre?')); 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') && hasBackground('assets/closeups/micro/rossa.png'); + }); + assert.equal(await page.locator('#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.waitForTimeout(1600); + 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(); +} diff --git a/tests/micro.test.mjs b/tests/micro.test.mjs new file mode 100644 index 0000000..923ace6 --- /dev/null +++ b/tests/micro.test.mjs @@ -0,0 +1,183 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as m 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'; + +// 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 = m.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 = m.newMicro(4, { level: 'male' }), b = m.newMicro(4, { level: 'male' }); + for (const c of [a, b]) { flick(c); run(c); } + assert.deepEqual(a.cars, b.cars); + const c = m.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 = m.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('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 = m.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 = m.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 = m.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 = m.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 = m.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 = m.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 = m.newMicro(5); + assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.ok(m.release(c, 1) === true && c.step === 'aim', 'il tocco breve non spinge'); + assert.ok(m.press(c, 1)); for (let i = 0; i < 60; i++) m.update(c, 1 / 60); assert.ok(c.charging); + 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 = m.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 = m.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 = m.newMicro(8, { level: 'bona' }), bg = {}, cars = { [story.closeups.micro.cars.player]: { 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 } }; calls.length = 0; draw(); + 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); + 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 corre?')); 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 corre?')); 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 } }; + g.closeupClose(); settle(g); + assert.ok(g.state.flags.micro_vinto); +}); diff --git a/tests/rinquarto.test.mjs b/tests/rinquarto.test.mjs index 3d40e40..6299f5b 100644 --- a/tests/rinquarto.test.mjs +++ b/tests/rinquarto.test.mjs @@ -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; } diff --git a/tools/pronuncia.json b/tools/pronuncia.json index 5d2fd50..39e1015 100644 --- a/tools/pronuncia.json +++ b/tools/pronuncia.json @@ -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", diff --git a/tools/voci.json b/tools/voci.json index 8222f52..e3c5c90 100644 --- a/tools/voci.json +++ b/tools/voci.json @@ -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" },