Compare commits

...
6 Commits
Author SHA1 Message Date
manzolo e1b92278a5 Versione 0.1.32 2026-10-28 21:07:24 +01:00
manzoloandClaude Opus 5.5 b3c50f4694 Le strade attorno a casa di Andrea: Cardetole (dal Giannini, il ponticino, il Trappola) e il prato del calcio; le villette del Giannini al posto dell'area camper, i camper dietro il campo; in Via Liguri Magelli il campo laggiù a destra e la strada di casa accanto al Betino; «Vai» senza la «a» in più (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 21:00:55 +01:00
manzolo 7dd05ac92f Versione 0.1.31 2026-10-28 20:01:43 +01:00
manzoloandClaude Opus 5.5 800a82045d Il GP di Fortezza: gara dei cinquantini attorno alle mura (pista di Codex dalla planimetria, sterzo, acceleratori, miscela, buche, presentazione dei piloti coi motorini dalle foto); Via Liguri Magelli col Trappola, la targa della Ghiacciomobile e il Betino in cima alla salita; l'Arcu lancia la sfida dal Trappola e ha la voce nuova (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 19:55:16 +01:00
manzoloandClaude Opus 5.5 c96dec4692 Docker: al ?v= degli asset l'impronta del contenuto di assets/, così uno sfondo rifatto senza bump non resta nella cache lunga del browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 19:55:16 +01:00
manzoloandClaude Opus 5.5 417c126777 IDEE: la gara dei cinquantini al Mugello Verde e la ricerca sul circuito (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 13:18:08 +01:00
89 changed files with 1302 additions and 83 deletions
+6
View File
@@ -17,9 +17,15 @@ COPY assets/favicon /usr/share/nginx/html/assets/favicon
COPY assets/video /usr/share/nginx/html/assets/video COPY assets/video /usr/share/nginx/html/assets/video
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice. # Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
# Alla versione si aggiunge l'impronta del contenuto di assets/: uno sfondo rifatto senza cambiare versione (in
# locale, fra un bump e l'altro) ha un indirizzo nuovo, e il browser non tiene quello vecchio dalla cache lunga.
RUN cd /usr/share/nginx/html \ RUN cd /usr/share/nginx/html \
&& V=$(sed -n "s/^export const VERSION = '\([0-9.]*\)';$/\1/p" src/version.js) \ && V=$(sed -n "s/^export const VERSION = '\([0-9.]*\)';$/\1/p" src/version.js) \
&& test -n "$V" \ && test -n "$V" \
&& H=$(find assets -type f -exec md5sum {} + | sort -k2 | md5sum | cut -c1-8) \
&& sed -i -E 's#v=\$\{VERSION\}#v=${VERSION}-'"$H"'#' src/version.js \
&& grep -q "VERSION}-$H" src/version.js \
&& V="$V-$H" \
&& sed -i -E "s#url\((assets/[^)]*)\)#url(\1?v=$V)#g" style.css \ && sed -i -E "s#url\((assets/[^)]*)\)#url(\1?v=$V)#g" style.css \
&& sed -i -E "s#src=\"(assets/[^\"]*)\"#src=\"\1?v=$V\"#g" index.html \ && sed -i -E "s#src=\"(assets/[^\"]*)\"#src=\"\1?v=$V\"#g" index.html \
&& grep -q "?v=$V" style.css && grep -q "?v=$V" index.html && grep -q "?v=$V" style.css && grep -q "?v=$V" index.html
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+155 -35
View File
@@ -1183,14 +1183,6 @@
"file": "andrea-00f2e70a4d.mp3", "file": "andrea-00f2e70a4d.mp3",
"sec": 2.95 "sec": 2.95
}, },
"andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
"file": "andrea-3e85200509.mp3",
"sec": 2.93
},
"andrea|O via, lascialo riposare. A pedalare oggi ci penso io.": {
"file": "andrea-34aad1ffe3.mp3",
"sec": 4.81
},
"andrea|La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": { "andrea|La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": {
"file": "andrea-7be29434e0.mp3", "file": "andrea-7be29434e0.mp3",
"sec": 7.25 "sec": 7.25
@@ -1244,16 +1236,16 @@
"sec": 2.15 "sec": 2.15
}, },
"arcu|Che è, la mancia? Una squama? Grazie, la metto in cassaforte.": { "arcu|Che è, la mancia? Una squama? Grazie, la metto in cassaforte.": {
"file": "arcu-8c7f1850a3.mp3", "file": "arcu-b1332b8d66.mp3",
"sec": 5.84 "sec": 6.34
}, },
"arcu|Tienila te, che io di roba verde ne ho già abbastanza sul lavoro.": { "arcu|Tienila te, che io di roba verde ne ho già abbastanza sul lavoro.": {
"file": "arcu-bb69d88654.mp3", "file": "arcu-7c69d6366a.mp3",
"sec": 3.23 "sec": 3.61
}, },
"arcu|Un libro? A me? Lo sai che io leggo solo le etichette degli armadi.": { "arcu|Un libro? A me? Lo sai che io leggo solo le etichette degli armadi.": {
"file": "arcu-1da81f6f98.mp3", "file": "arcu-f067aab43c.mp3",
"sec": 6.44 "sec": 6.47
}, },
"andrea|L'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.": { "andrea|L'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.": {
"file": "andrea-8ddab2d024.mp3", "file": "andrea-8ddab2d024.mp3",
@@ -1591,13 +1583,49 @@
"file": "andrea-ee455fdc33.mp3", "file": "andrea-ee455fdc33.mp3",
"sec": 3.67 "sec": 3.67
}, },
"andrea|Qui dietro ci stanno i camper. Ma per noi questo resta il campo del Giannini.": {
"file": "andrea-195cf2b022.mp3",
"sec": 4.86
},
"andrea|Gli scivoli dei bambini. Una volta il pallone finiva lì una volta su tre.": { "andrea|Gli scivoli dei bambini. Una volta il pallone finiva lì una volta su tre.": {
"file": "andrea-a3e36c12a1.mp3", "file": "andrea-a3e36c12a1.mp3",
"sec": 5.4 "sec": 5.4
}, },
"andrea|Casa mia. Terzo piano, quello con la tapparella a metà: è la mia camera.": { "andrea|L'Arcu, sotto la tenda del Trappola. Due minuti fa era a Campomigliaio.": {
"file": "andrea-acc7949f4c.mp3", "file": "andrea-a837c17bc7.mp3",
"sec": 5.91 "sec": 5.75
},
"andrea|La tenda è il Trappola: sotto c'è il Barrettino.": {
"file": "andrea-3648b1b08e.mp3",
"sec": 2.98
},
"andrea|Dal Trappola ci si passa dopo. Prima il palio.": {
"file": "andrea-a93c65234e.mp3",
"sec": 4.28
},
"andrea|«Qui riposa la Ghiacciomobile, che si è schiantata lì.»": {
"file": "andrea-a9e7f3f9ad.mp3",
"sec": 3.11
},
"andrea|C'è anche la fotina. Pareva sempre nuova.": {
"file": "andrea-7c17aa384c.mp3",
"sec": 3.2
},
"andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
"file": "andrea-3e85200509.mp3",
"sec": 2.93
},
"andrea|Lassù in cima, di traverso: per partire ha bisogno della discesa. E intanto ferma il traffico.": {
"file": "andrea-96de1a155d.mp3",
"sec": 7.66
},
"andrea|È il Betino dell'Arcu. Senza una discesa davanti, un si muove.": {
"file": "andrea-3d15dd47bb.mp3",
"sec": 4.82
},
"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
}, },
"andrea|Se salgo, mi mettono ad apparecchiare. Prima il palio.": { "andrea|Se salgo, mi mettono ad apparecchiare. Prima il palio.": {
"file": "andrea-8945f1786d.mp3", "file": "andrea-8945f1786d.mp3",
@@ -1615,6 +1643,14 @@
"file": "andrea-018183c4dd.mp3", "file": "andrea-018183c4dd.mp3",
"sec": 5.82 "sec": 5.82
}, },
"andrea|Su questo campo ci abbiamo passato trentacinque anni, giorno, pomeriggio e sera, a giocare a calcio.": {
"file": "andrea-5550b0890f.mp3",
"sec": 7.74
},
"andrea|Le maglie a fare da pali, e i palloni tagliati che entravano nelle terrazze, ovunque.": {
"file": "andrea-f6c10fd559.mp3",
"sec": 6.28
},
"andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": { "andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": {
"file": "andrea-0a073729fd.mp3", "file": "andrea-0a073729fd.mp3",
"sec": 7.3 "sec": 7.3
@@ -2152,32 +2188,32 @@
"sec": 3.41 "sec": 3.41
}, },
"arcu|E chi lo sposta???": { "arcu|E chi lo sposta???": {
"file": "arcu-64735a5925.mp3", "file": "arcu-6ecbd65952.mp3",
"sec": 0.97 "sec": 0.83
}, },
"arcu|Mah... un si fa una sega...": { "arcu|Mah... un si fa una sega...": {
"file": "arcu-5d07c15776.mp3", "file": "arcu-f086ae9a20.mp3",
"sec": 2.89 "sec": 4.42
}, },
"arcu|Fammi vedere... Verde. Come la Panda di mi' zio.": { "arcu|Fammi vedere... Verde. Come la Panda di mi' zio.": {
"file": "arcu-af7afda339.mp3", "file": "arcu-62e981592d.mp3",
"sec": 4.18 "sec": 6.29
}, },
"arcu|Verde! Allora il Regolo tifa Cardetole. Tradimento.": { "arcu|Verde! Allora il Regolo tifa Cardetole. Tradimento.": {
"file": "arcu-85c7d0b8f6.mp3", "file": "arcu-9e5b7c6e95.mp3",
"sec": 5.57 "sec": 4.75
}, },
"arcu|E te ci vai da solo? Io ti aspetto qui.": { "arcu|E te ci vai da solo? Io ti aspetto qui.": {
"file": "arcu-e157a4b70b.mp3", "file": "arcu-838aefc39e.mp3",
"sec": 2.73 "sec": 4.03
}, },
"arcu|Qualcuno deve raccontarlo, dopo.": { "arcu|Qualcuno deve raccontarlo, dopo.": {
"file": "arcu-5d1fa73d32.mp3", "file": "arcu-f19f64320f.mp3",
"sec": 1.77 "sec": 2.57
}, },
"arcu|Fai una cosa: se vedi due occhi gialli, un sono i fari della Provinciale.": { "arcu|Fai una cosa: se vedi due occhi gialli, un sono i fari della Provinciale.": {
"file": "arcu-fdd1ab5f5b.mp3", "file": "arcu-56966d2a60.mp3",
"sec": 4.37 "sec": 5.27
}, },
"andrea|Grazie, Arcu. Molto utile.": { "andrea|Grazie, Arcu. Molto utile.": {
"file": "andrea-2caf0f40f2.mp3", "file": "andrea-2caf0f40f2.mp3",
@@ -2188,12 +2224,12 @@
"sec": 2.98 "sec": 2.98
}, },
"arcu|...I pompieri per cosa?": { "arcu|...I pompieri per cosa?": {
"file": "arcu-5148dffe36.mp3", "file": "arcu-5fa87608b0.mp3",
"sec": 1.7 "sec": 1.26
}, },
"arcu|Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!": { "arcu|Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!": {
"file": "arcu-406cd02197.mp3", "file": "arcu-ac54e4bfa2.mp3",
"sec": 4.46 "sec": 4.81
}, },
"andrea|S'ha andà a fallo?!?": { "andrea|S'ha andà a fallo?!?": {
"file": "andrea-c80714aae7.mp3", "file": "andrea-c80714aae7.mp3",
@@ -2239,6 +2275,70 @@
"file": "pescatori-befa87eee8.mp3", "file": "pescatori-befa87eee8.mp3",
"sec": 4.17 "sec": 4.17
}, },
"andrea|Ma ti ho visto du' minuti fa a Campomigliaio!": {
"file": "andrea-c2dca9d75b.mp3",
"sec": 3.99
},
"andrea|Si fa una gara coi motorini? Il giro della Fortezza!": {
"file": "andrea-a960f89e7a.mp3",
"sec": 3.45
},
"andrea|Ci si vede dopo, Arcu.": {
"file": "andrea-191021ad7c.mp3",
"sec": 1.68
},
"arcu|E allora? Col Betino ci metto un attimo.": {
"file": "arcu-3b540fe807.mp3",
"sec": 3.02
},
"arcu|In discesa.": {
"file": "arcu-a17af74745.mp3",
"sec": 0.67
},
"andrea|E in salita?": {
"file": "andrea-75c55bf1e7.mp3",
"sec": 1.57
},
"arcu|In salita si spinge.": {
"file": "arcu-747a365229.mp3",
"sec": 1.6
},
"arcu|Col Betino? Io un perdo mai: un vo mai forte abbastanza da cadere.": {
"file": "arcu-d2b7d66066.mp3",
"sec": 6
},
"arcu|Come la si fa?": {
"file": "arcu-fcaee733df.mp3",
"sec": 0.92
},
"andrea|Alla bona, un giro di paese.": {
"file": "andrea-b35a4d8cc0.mp3",
"sec": 2.15
},
"andrea|Ginocchio in terra.": {
"file": "andrea-e2ed3ea2f2.mp3",
"sec": 1.19
},
"andrea|No, lascia perdere.": {
"file": "andrea-98f155a986.mp3",
"sec": 1.09
},
"arcu|Alla bona. Chi arriva ultimo paga le paste.": {
"file": "arcu-19aa71408a.mp3",
"sec": 3.33
},
"arcu|Aspettate: prendo la rincorsa, che il Betino s'accende solo in discesa.": {
"file": "arcu-1a64606a56.mp3",
"sec": 4.74
},
"arcu|Seria. Tre giri, e la paglia in curva è vera.": {
"file": "arcu-5990b22d1d.mp3",
"sec": 3.95
},
"arcu|Ginocchio in terra? Puntella un vede l'ora.": {
"file": "arcu-26a5fb0d0c.mp3",
"sec": 3.17
},
"andrea|Due tiri alla bona, tanto per scaldarmi.": { "andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3", "file": "andrea-d564365fc2.mp3",
"sec": 2.53 "sec": 2.53
@@ -2539,6 +2639,26 @@
"file": "andrea-72bf22a3f9.mp3", "file": "andrea-72bf22a3f9.mp3",
"sec": 4.49 "sec": 4.49
}, },
"andrea|Primo! E col Fifty di serie, o quasi.": {
"file": "andrea-f3d1a2d353.mp3",
"sec": 4.13
},
"arcu|Si rifà! Questa un vale, avevo la catena lenta.": {
"file": "arcu-98e193584b.mp3",
"sec": 5.34
},
"andrea|Ho preso tutta la polvere di quelli davanti.": {
"file": "andrea-4ca44c5405.mp3",
"sec": 2.18
},
"arcu|Il Fifty è bono. È chi ci sta sopra.": {
"file": "arcu-1c6016d941.mp3",
"sec": 3.2
},
"andrea|Lascio stare. Il Fifty mi serve intero, per il palio.": {
"file": "andrea-33e57caee9.mp3",
"sec": 4.61
},
"ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": { "ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
"file": "ing_tozzi-51f855478a.mp3", "file": "ing_tozzi-51f855478a.mp3",
"sec": 4.42 "sec": 4.42
+13
View File
@@ -145,3 +145,16 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
la lenza vibra, si tira al momento giusto. Serve la scena del lago, dietro Cardetole. la lenza vibra, si tira al momento giusto. Serve la scena del lago, dietro Cardetole.
- *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta; la briscola - *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta; la briscola
c'è già all'oratorio, qui conta la gag. c'è già all'oratorio, qui conta la gag.
## La gara dei cinquantini (idea di Andrea, 28 ottobre)
Una gara di motorini fra amici sul circuito che negli anni '70 c'era dentro il campeggio Mugello Verde (Via
Massorondinaio 39, nel gioco c'è già la scena del campeggio): Puntella (Marco Villani, **consenso da chiedere**, fuori
dai deploy finché non c'è) con lo scooter SR, il più truccato di tutti; il Betino dell'Arcu; il Fifty di Andrea. Il
circuito lo disegna Codex.
Ricerca del 28 ottobre: sul web la pista del campeggio non risulta; il sito del campeggio elenca tennis, pesca,
equitazione, minigolf. Risulta invece il circuito cittadino della «I Coppa di San Piero a Sieve», Formula 3,
11 giugno 1967: 5.600 m in senso orario attorno al poggio della Fortezza, partenza davanti alla scuola in Via
Provinciale, con la strada di Massorondinaio asfaltata apposta come bretella (fonte: CAMET, «storia circuito San
Piero»). Il disegno della pista del campeggio va preso dai ricordi di Andrea **[DA ANDREA]**.
+29 -1
View File
@@ -75,7 +75,7 @@ Si aggiorna con `make deploy`: pubblica l'ultimo commit, immagine costruita e pr
- **Altre scene** (ottobre 2026, dalle foto di Andrea con Codex): Via de' Medici, Oratorio, Ingresso del campeggio, - **Altre scene** (ottobre 2026, dalle foto di Andrea con Codex): Via de' Medici, Oratorio, Ingresso del campeggio,
Bosco ai Frati, viale dei cipressi. Schermata del titolo (Fortezza al tramonto col Regolo) e introduzione a righe sul Bosco ai Frati, viale dei cipressi. Schermata del titolo (Fortezza al tramonto col Regolo) e introduzione a righe sul
panorama del Mugello. panorama del Mugello.
- **Musica** di Andrea (Suno) in `assets/audio/`: titolo e introduzione, paese, ricordi (Trebbio e fine); volume e muto. - **Musica** di Andrea (Suno) in `assets/audio/`: titolo e introduzione, paese, ricordi (Trebbio e fine), il GP di Fortezza (`gara.mp3`); volume e muto.
- **Andrea vero** dal foglio sprite (`assets/characters/`, `tools/slice_sprites.py` + `tools/prepare_character.py`): - **Andrea vero** dal foglio sprite (`assets/characters/`, `tools/slice_sprites.py` + `tools/prepare_character.py`):
cammina, parla, si china a prendere, di spalle davanti alle porte. cammina, parla, si china a prendere, di spalle davanti alle porte.
- **Storia di prova** (`src/story/story.js`): il palio è sparito, i vecchietti litigano sulle due leggende, la squama - **Storia di prova** (`src/story/story.js`): il palio è sparito, i vecchietti litigano sulle due leggende, la squama
@@ -243,6 +243,34 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
risultato deciso all'ultimo pesce). Restano le sue idee: «Porta a casa la pescata» a gloria raggiunta; premio per risultato deciso all'ultimo pesce). Restano le sue idee: «Porta a casa la pescata» a gloria raggiunta; premio per
peso o varietà; i ragazzi che reagiscono (stivale, carpa, lenza rotta); paesaggio sonoro del lago (ora quello della peso o varietà; i ragazzi che reagiscono (stivale, carpa, lenza rotta); paesaggio sonoro del lago (ora quello della
Fortezza); abboccata e tensione anche nei comandi HTML e un richiamo grande sul telefono; Esc col fuoco sui pulsanti. Fortezza); abboccata e tensione anche nei comandi HTML e un richiamo grande sul telefono; Esc col fuoco sui pulsanti.
- Il GP di Fortezza (Andrea, 28 ottobre): la gara dei cinquantini attorno alle mura della Fortezza, vista dall'alto
come le vecchie gare da sala giochi. Dall'Arcu sotto la tenda del Trappola in Via Liguri Magelli «Si fa una gara coi
motorini?», poi Alla bona, Seria o Ginocchio in terra; prima del via l'Arcu prende la rincorsa per accendere il Betino
in discesa. Minigioco `src/engine/gara.js`: gas tenendo premuto (Spazio, ↑, mouse, dito), sterzo con ← →,
trascinando di lato o coi pulsanti Sterza, ↓ frena; erba che frena, paglia che ferma, acceleratori, lattine di miscela
(sprint), buche (forte si casca); gli avversari sorpassano e scansano le buche. Prima del semaforo i piloti
arrivano in griglia uno alla volta col cartellino: il personaggio (sprite del gioco, o ritratto: Cistola dal murale,
Puntella sagoma finché manca il consenso), il motorino dipinto da Codex dalle foto (`assets/closeups/gp/`, foglio
`assets/raw/motorini-gp-sheet.png`, prompt `prompts/personaggi/motorini-gp-sheet.txt`), motore e testa; un tocco la salta. Gas, freno e sterzo hanno
fonti indipendenti (tasti, un dito sulla scena, pulsanti Gas, Sterza e Frena), il freno vince sul gas. Quadro di Codex
`assets/closeups/gp-fortezza.png` (prompt `prompts/closeups/gp-fortezza.txt`, guida dalla planimetria
`assets/raw/fortezza-planimetria.png`, satellite in `assets/raw/fortezza-satellite.png`); il tracciato è ricalcato
sulla strada dipinta. Disegno `gara-render.js`, test `tests/gara.test.mjs`. I piloti sono dati in
`src/story/piloti.js`: una riga per amico, `motore` e `testa` da 1 a 5, colori dalle foto. Corrono Puntella (SR,
colori Aprilia da GP provvisori), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
scuro, di serie), l'Arcu (Betino). **Puntella (Marco Villani) non corre finché manca il suo consenso** (`consenso: null` in piloti.js: quando arriva, la data). Mancano: quanto erano
truccati gli altri, le battute di Andrea, il rumore del motore (c'è solo
quello dell'arrivo in griglia); i confini della paglia non si vedono sul quadro (revisione di Codex, punto 9).
- Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita
lui; i camper stanno dietro il suo campo). Cardetole: in basso a destra «dal Giannini» (le villette), a destra il
ponticino della Carza, a sinistra il Trappola. Il campo del Giannini: a destra le villette, a sinistra il Trappola;
dal ponticino non ci si va. Via Liguri Magelli: lassù vicino al Betino si torna a casa, laggiù a destra il campo.
Da sapere: dove si gira, in fondo al ponticino, per Cardetole [DA ANDREA].
- Via Liguri Magelli (Street View di Andrea, 28 ottobre), a sinistra dal campo del Giannini: a destra la casa salmone
con la tenda del Trappola (il Barrettino, «Vai dal Trappola»: niente interno, [DA ANDREA]), l'Arcu sotto la tenda
(scherzo: «ti ho visto du' minuti fa a Campomigliaio» col Betino, poi la sfida del GP), davanti la targa «Qui riposa la
Ghiacciomobile, che si è schiantata lì», in cima alla salita il Betino dell'Arcu (tolto dal circolino di
Campomigliaio), che per partire ha bisogno della discesa. Prompt `prompts/backgrounds/via-liguri-magelli.txt`.
- Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`). - Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`).
- Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea. - Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea.
+10
View File
@@ -199,6 +199,7 @@
</label> </label>
<button id="bocce-right" type="button" aria-label="Sposta la mira a destra">Mira →</button> <button id="bocce-right" type="button" aria-label="Sposta la mira a destra">Mira →</button>
<button id="bocce-throw" type="button">Lancia</button> <button id="bocce-throw" type="button">Lancia</button>
<button id="gara-brake" type="button" hidden>Frena</button>
</div> </div>
<details id="bocce-help"><summary>Come si gioca</summary> <details id="bocce-help"><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="bocce" hidden>Guarda il video</button> <button class="tutorial-open" type="button" data-tutorial="bocce" hidden>Guarda il video</button>
@@ -219,6 +220,15 @@
palla: entra anche sporca, ma non da sotto. La gloria: 3 canestri alla bona, 5 seria, 7 a fassi male. Alla bona la palla: entra anche sporca, ma non da sotto. La gloria: 3 canestri alla bona, 5 seria, 7 a fassi male. Alla bona la
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p> guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
</details> </details>
<details id="gara-help" hidden><summary>Come si gioca</summary>
<p>Il GP di Fortezza: tre giri attorno alle mura coi cinquantini. Tieni premuto sulla scena (o Spazio, o ↑) per il
gas; sterza con le frecce ← →, oppure tenendo premuto e trascinando di lato col dito o col mouse, oppure tenendo
premuti i pulsanti Sterza. ↓ (o il pulsante Frena, tenuto premuto) frena, anche col gas dato. Dal pannello il
pulsante alterna Gas e Molla. Durante la presentazione dei piloti un tocco passa al prossimo.</p>
<p>Le frecce gialle in terra sono acceleratori; le lattine rosse di miscela danno uno sprint (e ricompaiono); nelle
buche, se ci passi forte, caschi. Fuori strada l'erba frena, e nella paglia ci si ferma. Alla bona la pista mostra
in rosso dove frenare, Seria solo i coni, Ginocchio in terra niente. Esc esce.</p>
</details>
<details id="pesca-help" hidden><summary>Come si gioca</summary> <details id="pesca-help" hidden><summary>Come si gioca</summary>
<p>Un tocco sull’acqua sceglie dove lanciare (o regola la forza: più lontano, pesci più grossi, ma attese più <p>Un tocco sull’acqua sceglie dove lanciare (o regola la forza: più lontano, pesci più grossi, ma attese più
lunghe). Tieni premuto sulla scena (o Spazio): la forza riparte da zero e sale, lascia per lanciare; oppure premi lunghe). Tieni premuto sulla scena (o Spazio): la forza riparte da zero e sale, lascia per lanciare; oppure premi
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.30", "version": "0.1.32",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+6 -2
View File
@@ -2,6 +2,10 @@
// Senza verbo scelto vale il clic intelligente: l'azione più sensata per quel punto. // Senza verbo scelto vale il clic intelligente: l'azione più sensata per quel punto.
import { VERB_LABELS } from './game.js'; import { VERB_LABELS } from './game.js';
// «Vai a» davanti a un nome che dice già dove («dal Trappola», «a destra, …», «verso …», «indietro, …»): solo «Vai».
const WHERE = /^(da(l|lla|i|gli|ll')?|a|verso|indietro|su|giù|in|laggiù|lassù|dietro|oltre)(?=[\s,]|$)/i;
const verbLabel = (verb, name) => verb === 'walk' && WHERE.test(name ?? '') ? 'Vai' : VERB_LABELS[verb];
export class Command { export class Command {
constructor(game) { constructor(game) {
this.game = game; this.game = game;
@@ -21,10 +25,10 @@ export class Command {
const g = this.game, name = target && g.targetName(target); const g = this.game, name = target && g.targetName(target);
if (this.item && target === this.item && this.verb === 'use') return `${VERB_LABELS.use} ${g.itemName(this.item)}`; if (this.item && target === this.item && this.verb === 'use') return `${VERB_LABELS.use} ${g.itemName(this.item)}`;
if (this.item) return `${VERB_LABELS[this.verb]} ${g.itemName(this.item)} ${this.verb === 'give' ? 'a' : 'con'}${name ? ' ' + name : '…'}`; if (this.item) return `${VERB_LABELS[this.verb]} ${g.itemName(this.item)} ${this.verb === 'give' ? 'a' : 'con'}${name ? ' ' + name : '…'}`;
if (this.verb) return `${VERB_LABELS[this.verb]}${name ? ' ' + name : '…'}`; if (this.verb) return `${verbLabel(this.verb, name)}${name ? ' ' + name : '…'}`;
if (!target) return ''; if (!target) return '';
const isItem = !!g.story.items[target]; const isItem = !!g.story.items[target];
return isItem ? `Usa ${name} con…` : `${VERB_LABELS[g.defaultVerb(target)]} ${name}`; return isItem ? `Usa ${name} con…` : `${verbLabel(g.defaultVerb(target), name)} ${name}`;
} }
// Clic sulla scena: `look` per il clic destro o la pressione lunga, `hurry` per il doppio clic (passo doppio), // Clic sulla scena: `look` per il clic destro o la pressione lunga, `hurry` per il doppio clic (passo doppio),
+3 -1
View File
@@ -8,6 +8,7 @@ import * as pesca from './pesca.js';
import * as briscola from './briscola.js'; import * as briscola from './briscola.js';
import * as tressette from './tressette.js'; import * as tressette from './tressette.js';
import * as scopa from './scopa.js'; import * as scopa from './scopa.js';
import * as gara from './gara.js';
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = { export const MINIGAMES = {
@@ -18,6 +19,7 @@ export const MINIGAMES = {
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen }, biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen }, basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen }, pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
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 },
}; };
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop']; export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
@@ -486,7 +488,7 @@ export class Game {
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario. // `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane). // `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level, : MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]])) } Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]]), this.story.closeups[e.closeup]) }
: { id: e.closeup, step: 'cover', t: 0, age: 0 }; : { id: e.closeup, step: 'cover', t: 0, age: 0 };
} }
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e)); else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
+215
View File
@@ -0,0 +1,215 @@
import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, pointAt, standings, player, lapOf, presenting, placeOf, INTRO } from './gara.js';
import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
// Il GP di Fortezza visto dall'alto. Col quadro di Codex (def.picture) la strada e la Fortezza sono già dipinte; senza,
// si disegnano qui dai dati del tracciato. Sopra, sempre: la guida, gli acceleratori, le buche, le lattine, i motorini.
function stroke(ctx, from, to, width, color, lane = 0) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath();
for (let s = from; s <= to; s += 2) { const p = pointAt(s, lane); s === from ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); }
ctx.stroke();
}
function loop(ctx, width, color) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = 'round';
ctx.beginPath();
TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath(); ctx.stroke();
}
function ground(ctx) {
ctx.fillStyle = '#6f8f3e'; ctx.fillRect(0, 0, 640, 360);
for (let i = 0; i < 260; i++) {
ctx.fillStyle = hash(i * 13 + 1) > 0.5 ? '#7c9b47' : '#638236';
ctx.fillRect(Math.floor(hash(i * 13 + 2) * 640), Math.floor(hash(i * 13 + 3) * 360), 2 + Math.floor(hash(i * 13 + 4) * 6), 2);
}
// Dentro il giro, le mura della Fortezza e il prato dei bastioni.
ctx.beginPath();
TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fillStyle = '#8a7a5a'; ctx.fill();
loop(ctx, VERGE * 2 + 8, '#6f8f3e');
loop(ctx, VERGE * 2, '#7c9b47');
loop(ctx, ROAD * 2 + 2, '#7a6446');
loop(ctx, ROAD * 2, '#b59b72');
for (let i = 0; i < TRACK.pts.length; i += 3) {
const [x, y] = TRACK.pts[i], o = (hash(i) - 0.5) * ROAD * 1.6;
ctx.fillStyle = hash(i + 7) > 0.5 ? '#a88d64' : '#c2a87e'; ctx.fillRect(Math.round(x + o * 0.5), Math.round(y + o * 0.5), 2, 1);
}
// La paglia fuori dalle curve.
for (const k of CURVES) for (let s = k.start; s <= k.end; s += 8) {
const p = pointAt(s, -(VERGE + 3) * k.side);
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading);
ctx.fillStyle = '#d9b75a'; ctx.fillRect(-3.5, -2.5, 7, 5); ctx.fillStyle = '#b8953f'; ctx.fillRect(-3.5, -0.5, 7, 1);
ctx.restore();
}
const p = pointAt(0);
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading);
for (let i = -6; i < 6; i++) for (let j = 0; j < 2; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#f4f4f4'; ctx.fillRect(j * 2 - 2, i * 2, 2, 2); }
ctx.restore();
}
function guide(ctx, c) {
const v = player(c).vmax;
// Alla bona, in rosso dove si frena per arrivare giusti in curva; Seria, un cono all'inizio della frenata.
for (const k of CURVES) {
const from = k.apex - (v * v - k.speed * k.speed * 0.7) / (2 * BRAKE);
if (c.skill.guide === 'zone') stroke(ctx, from, k.apex, 4, 'rgba(214,64,47,0.6)');
if (c.skill.guide === 'coni') {
const p = pointAt(from, (ROAD + 3) * k.side);
ctx.fillStyle = '#f07a1a'; ctx.beginPath(); ctx.moveTo(p.x, p.y - 5); ctx.lineTo(p.x + 3, p.y + 2); ctx.lineTo(p.x - 3, p.y + 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.fillRect(p.x - 1.5, p.y - 2, 3, 1);
}
}
}
function things(ctx, c) {
for (const p of PICKUPS) {
ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.rotate(p.heading);
if (p.kind === 'boost') {
// L'acceleratore: tre frecce gialle che lampeggiano in fila.
for (let k = 0; k < 3; k++) {
ctx.fillStyle = Math.floor(c.age * 6 - k) % 3 === 0 ? '#fff27a' : '#e0b020';
ctx.beginPath(); ctx.moveTo(-6 + k * 5, -5); ctx.lineTo(-2 + k * 5, 0); ctx.lineTo(-6 + k * 5, 5); ctx.lineTo(-4 + k * 5, 0); ctx.fill();
}
} else if (p.kind === 'buca') {
ctx.fillStyle = '#5a4630'; ctx.beginPath(); ctx.ellipse(0, 0, 6, 4.5, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#2e2418'; ctx.beginPath(); ctx.ellipse(0.5, 0.5, 4.5, 3, 0, 0, Math.PI * 2); ctx.fill();
} else if ((c.taken[p.id] ?? -Infinity) <= c.t - CAN.respawn) {
// La lattina della miscela, che saltella.
const y = Math.round(Math.sin(c.age * 5 + p.id) * 1);
ctx.rotate(-p.heading);
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-3, -4 + y, 6, 8);
ctx.fillStyle = '#c8321e'; ctx.fillRect(-2, -3 + y, 4, 6);
ctx.fillStyle = '#f0c23a'; ctx.fillRect(-2, -1 + y, 4, 1); ctx.fillRect(-1, -5 + y, 2, 1);
}
ctx.restore();
}
}
function moped(ctx, r, c) {
const out = r.out > 0;
ctx.save(); ctx.translate(Math.round(r.x), Math.round(r.y)); ctx.rotate(r.a + (out ? Math.sin(c.age * 22) * 0.6 : 0));
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(-6, -1, 13, 4);
ctx.fillStyle = '#151515'; ctx.fillRect(-7, -1, 3, 2); ctx.fillRect(5, -1, 3, 2); // le ruote
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-5, -3, 11, 6); // il bordo chiaro: anche i motorini scuri si vedono
ctx.fillStyle = r.color; ctx.fillRect(-4, -2, 9, 4);
ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(-4, -0.5, 9, 1);
ctx.fillStyle = '#2a2a2a'; ctx.fillRect(4, -3, 1, 6); // il manubrio
ctx.fillStyle = r.id === 'andrea' ? '#f4e3c2' : '#2a2a2a'; ctx.beginPath(); ctx.arc(-1, 0, 2, 0, Math.PI * 2); ctx.fill(); // il casco
// La miscela: una fiammata dietro.
if (r.boost > 0 && Math.floor(c.age * 20) % 2) { ctx.fillStyle = '#ffb030'; ctx.fillRect(-10, -1, 3, 2); }
ctx.restore();
if (r.id === 'andrea') { ctx.strokeStyle = '#f4e3c2'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(r.x, r.y, 10, 0, Math.PI * 2); ctx.stroke(); }
}
// In basso a sinistra, lontano dalla pausa e dal pulsante per uscire: il giro, la classifica e il tachimetro.
function hud(ctx, c) {
const me = player(c), order = standings(c), h = 34 + order.length * 11, y = 354 - h;
panel(ctx, 4, y, 116, h);
text(ctx, `GIRO ${lapOf(me)}/${LAPS}`, 10, y + 9, 7);
order.forEach((r, i) => {
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(9, y + 18 + i * 11, 8, 8);
ctx.fillStyle = r.color; ctx.fillRect(10, y + 19 + i * 11, 6, 6);
text(ctx, `${i + 1}. ${r.name}`, 21, y + 23 + i * 11, 6, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
});
const k = Math.min(1, me.v / me.vmax), bar = y + h - 11;
ctx.fillStyle = '#2a2f2c'; ctx.fillRect(10, bar, 50, 5);
ctx.fillStyle = me.boost > 0 ? '#ffb030' : k > 0.9 ? '#e0583a' : '#b9d392'; ctx.fillRect(10, bar, Math.round(50 * k), 5);
text(ctx, `${Math.round(me.v * 0.5)} km/h`, 116, bar + 3, 6, '#f4e3c2', 'right');
}
// La presentazione: il cartellino di chi arriva. A sinistra il personaggio (lo sprite del gioco, o il ritratto), a
// destra il suo motorino dipinto, sotto quanto va (motore) e quanto rischia (testa). `look(src)` dà l'immagine caricata,
// `sheet(id)` lo sprite di un personaggio.
function figure(ctx, r, x, y, look, sheet) {
const s = r.sprite && sheet(r.sprite);
if (s) {
const f = s.atlas.frames.stand ?? Object.values(s.atlas.frames)[0], k = Math.min(1, 84 / f.h);
ctx.drawImage(s.img, f.x, f.y, f.w, f.h, Math.round(x - f.w * k / 2), Math.round(y + 88 - f.h * k), Math.round(f.w * k), Math.round(f.h * k));
return;
}
const img = r.face && look(r.face);
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(x - 31, y + 13, 62, 62);
if (img) { ctx.drawImage(img, x - 30, y + 14, 60, 60); return; }
// Nessun ritratto (ancora): la sagoma.
ctx.fillStyle = '#2a2f2c'; ctx.fillRect(x - 30, y + 14, 60, 60);
ctx.fillStyle = '#4a504c'; ctx.beginPath(); ctx.arc(x, y + 36, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillRect(x - 18, y + 52, 36, 22);
text(ctx, '?', x, y + 37, 10, '#8f9488', 'center');
}
function card(ctx, c, look, sheet) {
const r = presenting(c);
if (!r) { panel(ctx, 220, 130, 200, 40); text(ctx, 'GP DI FORTEZZA', 320, 142, 7, '#ffe28a', 'center'); text(ctx, 'TUTTI IN GRIGLIA!', 320, 158, 7, '#f4e3c2', 'center'); return; }
const x = 150, y = 92, w = 340, h = 150;
panel(ctx, x, y, w, h);
text(ctx, 'GP DI FORTEZZA', x + w / 2, y + 10, 6, '#ffe28a', 'center');
figure(ctx, r, x + 46, y + 14, look, sheet);
// Il motorino, entra da sinistra e si ferma (come in pista).
const moto = r.moto && look(r.moto), t = Math.min(1, (c.t % INTRO.each) / 0.6), e = 1 - (1 - t) ** 3;
if (moto) {
const k = Math.min(150 / moto.width, 80 / moto.height), mw = moto.width * k, mh = moto.height * k;
ctx.save(); ctx.beginPath(); ctx.rect(x + 92, y + 18, w - 100, 92); ctx.clip();
ctx.drawImage(moto, Math.round(x + 92 + (w - 100 - mw) / 2 - (1 - e) * 120), Math.round(y + 104 - mh), Math.round(mw), Math.round(mh));
ctx.restore();
} else {
ctx.fillStyle = r.color; ctx.fillRect(x + 160, y + 70, 100, 22);
ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(x + 160, y + 80, 100, 3);
}
text(ctx, r.name, x + 100, y + 118, 8, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
text(ctx, r.bike, x + 100, y + 130, 6, '#c5c6b6');
for (const [k, [label, value]] of [['MOTORE', r.motore], ['TESTA', r.testa]].entries()) {
const yy = y + 116 + k * 12;
text(ctx, label, x + 222, yy, 5, '#c5c6b6');
for (let i = 0; i < 5; i++) { ctx.fillStyle = i < value ? (k ? '#b9d392' : '#e0583a') : '#3a3f3c'; ctx.fillRect(x + 262 + i * 13, yy - 3, 10, 6); }
}
text(ctx, 'TOCCA: IL PROSSIMO', x + w / 2, y + h - 6, 5, '#8f9488', 'center');
}
// La posizione in corsa, grande in alto a sinistra, e un cartellino sopra il Fifty.
function position(ctx, c) {
const me = player(c), place = placeOf(c), n = c.riders.length, first = place === 1;
panel(ctx, 4, 4, 70, 40);
text(ctx, 'POSIZIONE', 39, 11, 5, '#c5c6b6', 'center');
text(ctx, `${place}°`, 11, 29, 14, first ? '#ffe28a' : '#f4e3c2');
text(ctx, `su ${n}`, 68, 33, 6, '#c5c6b6', 'right');
if (me.shown === false) return;
const x = Math.round(me.x), y = Math.round(me.y) - 17;
ctx.fillStyle = 'rgba(22,25,23,0.85)'; ctx.fillRect(x - 8, y - 5, 16, 10);
ctx.fillStyle = first ? '#ffe28a' : '#f4e3c2'; ctx.fillRect(x - 1, y + 5, 2, 2);
text(ctx, `${place}°`, x, y + 0.5, 6, first ? '#ffe28a' : '#f4e3c2', 'center');
}
function lights(ctx, c) {
const lit = Math.min(3, Math.floor(c.t) + 1);
panel(ctx, 270, 140, 100, 50);
text(ctx, 'GP DI FORTEZZA', 320, 150, 6, '#ffe28a', 'center');
for (let i = 0; i < 3; i++) {
ctx.fillStyle = i < lit ? '#e0402a' : '#3a2a28';
ctx.beginPath(); ctx.arc(300 + i * 20, 170, 7, 0, Math.PI * 2); ctx.fill();
}
if (c.t >= COUNTDOWN - 0.01) text(ctx, 'VIA!', 320, 200, 10, '#b9d392', 'center');
}
function result(ctx, c) {
const order = c.result.order.map(id => c.riders.find(r => r.id === id));
panel(ctx, 200, 96, 240, 44 + order.length * 14);
text(ctx, 'GP DI FORTEZZA', 320, 108, 6, '#c5c6b6', 'center');
text(ctx, c.result.place === 1 ? 'PRIMO!' : `${c.result.place}° POSTO`, 320, 122, 10, c.result.place === 1 ? '#ffe28a' : '#f4e3c2', 'center');
order.forEach((r, i) => {
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(213, 137 + i * 14, 10, 10);
ctx.fillStyle = r.color; ctx.fillRect(214, 138 + i * 14, 8, 8);
text(ctx, `${i + 1}. ${r.name}`, 228, 142 + i * 14, 7, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
text(ctx, `${c.result.times[r.id].toFixed(1)}s`, 428, 142 + i * 14, 7, '#c5c6b6', 'right');
});
}
export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) {
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else ground(ctx);
guide(ctx, c);
things(ctx, c);
// Prima chi è dietro, così chi è davanti resta sopra; Andrea sempre sopra a tutti.
for (const r of [...standings(c)].reverse().sort((a, b) => (a.id === 'andrea') - (b.id === 'andrea'))) if (r.shown !== false) moped(ctx, r, c);
if (c.step === 'intro') { card(ctx, c, look, sheet); ctx.restore(); return; }
hud(ctx, c);
if (c.step === 'race') position(ctx, c);
if (c.step === 'start') lights(ctx, c);
const me = player(c);
if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center');
if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center');
if (c.step === 'result') result(ctx, c);
ctx.restore();
}
+398
View File
@@ -0,0 +1,398 @@
// Il GP di Fortezza (idea di Andrea, 28 ottobre): la gara dei cinquantini sul giro delle mura della Fortezza di San
// Martino, vista dall'alto come le vecchie gare da sala giochi con tutta la pista in una schermata. Il tracciato segue
// la planimetria generale 1:500 che ha mandato Andrea (`assets/raw/fortezza-planimetria.png`), schiacciata nel
// formato del gioco (640x360): x = 90 + (x0 - 215)·0,7, y = 20 + (y0 - 42)·0,49.
//
// Si tiene premuto per il gas (mouse, dito, Spazio o ↑), si sterza con ← → (o trascinando di lato col dito, o coi
// pulsanti), ↓ frena. Sulla pista: acceleratori, buche (andandoci forte si casca) e lattine di miscela da raccogliere.
// Fuori strada c'è l'erba che frena, e poi la paglia: chi ci arriva forte si ferma.
// La linea di mezzo della strada, in senso orario a partire dal traguardo sul rettilineo delle mura a levante: prima
// tracciata sulla planimetria, poi ricalcata sulla strada del quadro di Codex (assets/closeups/gp-fortezza.png).
export const CONTROL = [
[570, 210], [572, 222], [574, 233], [577, 245], [580, 256], [583, 268], [585, 280], [589, 291], [589, 303], [589, 315],
[585, 327], [576, 335], [565, 340], [553, 341], [541, 340], [529, 339], [518, 336], [506, 334], [495, 330], [483, 327],
[471, 324], [460, 321], [448, 318], [436, 316], [424, 315], [412, 314], [400, 314], [389, 317], [378, 320], [372, 331],
[363, 339], [352, 345], [340, 344], [328, 341], [317, 337], [305, 334], [294, 329], [284, 324], [273, 319], [262, 314],
[251, 308], [241, 303], [231, 296], [220, 291], [210, 284], [199, 280], [188, 274], [178, 269], [167, 263], [157, 256],
[148, 248], [143, 237], [147, 225], [149, 214], [148, 203], [142, 192], [135, 182], [127, 174], [118, 166], [110, 156],
[104, 146], [100, 135], [98, 123], [101, 112], [106, 100], [113, 91], [124, 86], [136, 81], [147, 77], [157, 70],
[165, 61], [173, 53], [185, 49], [197, 47], [208, 45], [220, 41], [232, 39], [243, 36], [255, 33], [266, 30],
[278, 26], [290, 24], [301, 22], [313, 20], [325, 20], [337, 19], [349, 20], [361, 23], [372, 26], [384, 29],
[396, 32], [407, 36], [418, 40], [430, 43], [441, 47], [453, 50], [464, 55], [475, 59], [486, 63], [498, 67],
[509, 71], [521, 74], [532, 76], [544, 75], [557, 77], [566, 85], [570, 96], [572, 108], [574, 120], [574, 132],
[574, 144], [573, 156], [573, 168], [572, 180], [571, 192], [570, 204],
];
export const LAPS = 3, STEP = 2, ROAD = 9, VERGE = 15, COUNTDOWN = 3, FIXED_STEP = 1 / 120;
// La presentazione prima del semaforo: uno alla volta i piloti arrivano in griglia col loro motorino (INTRO.each
// secondi ciascuno, da INTRO.from pixel di strada più indietro, in INTRO.ride secondi); un tocco passa al prossimo.
export const INTRO = { each: 4, ride: 1.8, from: 110, after: 1 };
// Sterzo (rad/s a velocità piena di sterzo), freno, rallentamento senza gas, e quanto si scivola di lato in curva.
export const TURN = 3.4, BRAKE = 150, COAST = 45, GRIP = 9, SLIDE = 0.5;
export const BOOST = { speed: 1.35, time: 1.2 }, CAN = { time: 1.6, respawn: 6 }, HOLE = { fast: 45, out: 1.1 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
// I piloti sono dati (story.closeups.gara.riders, da piloti.js): `motore` da 1 (di serie) a 5 (truccato da far
// paura), `testa` da 1 (matto) a 5 (prudente). Ne vengono velocità massima e accelerazione in px/s, tenuta, e per gli
// altri quanto si fidano delle curve (`caution`) e quanto spesso rischiano troppo (`slip`).
export function rider({ motore = 3, testa = 3, ...r }) {
const m = clamp(motore, 1, 5), t = clamp(testa, 1, 5);
return { ...r, motore: m, testa: t, vmax: 74 + 6.5 * m, accel: 40 + 5 * m, grip: 1.1 - 0.03 * m, caution: 1.0025 - 0.0125 * t, slip: 0.4275 - 0.0775 * t };
}
// Il gruppo di prova, quando la storia non ne dà uno.
export const RIDERS = [
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' },
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' },
];
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista.
export const LEVELS = {
bona: { rivals: 0.78, stun: 0.4, guide: 'zone' },
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
male: { rivals: 0.93, stun: 1.4, guide: null },
};
function catmull(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return [0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3));
}
// Il tracciato campionato ogni STEP pixel: posizione, direzione, raggio di curva e velocità massima di ogni punto.
function buildTrack() {
const dense = [], n = CONTROL.length;
for (let i = 0; i < n; i++) {
const p = [CONTROL[(i - 1 + n) % n], CONTROL[i], CONTROL[(i + 1) % n], CONTROL[(i + 2) % n]];
for (let k = 0; k < 40; k++) dense.push(catmull(...p, k / 40));
}
const pts = [dense[0]];
let carry = 0;
for (let i = 1; i <= dense.length; i++) {
const a = dense[i - 1], b = dense[i % dense.length];
let seg = Math.hypot(b[0] - a[0], b[1] - a[1]), t = 0;
while (carry + seg >= STEP) {
const need = STEP - carry; t += need / Math.hypot(b[0] - a[0], b[1] - a[1]); seg -= need; carry = 0;
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
carry += seg;
}
if (Math.hypot(pts.at(-1)[0] - pts[0][0], pts.at(-1)[1] - pts[0][1]) < STEP * 0.5) pts.pop();
const m = pts.length, heading = pts.map((p, i) => { const q = pts[(i + 1) % m]; return Math.atan2(q[1] - p[1], q[0] - p[0]); });
const W = 6, radius = pts.map((_, i) => (2 * W * STEP) / Math.max(Math.abs(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])), 1e-6));
// A sterzo pieno si gira di TURN rad/s: in una curva di raggio R si passa al massimo a TURN·R.
const limit = radius.map(r => TURN * r);
// Da che parte gira (+1 destra, -1 sinistra).
const side = pts.map((_, i) => Math.sign(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])) || 1);
return { pts, heading, radius, limit, side, length: m * STEP };
}
export const TRACK = buildTrack();
const N = TRACK.pts.length;
const at = s => Math.floor((((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP) % N;
export const limitAt = s => TRACK.limit[at(s)];
// Il punto dello schermo a distanza s dalla partenza, spostato di lato (positivo: a destra della direzione di marcia).
export function pointAt(s, lane = 0) {
const f = (((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP, i = Math.floor(f) % N, t = f - Math.floor(f);
const a = TRACK.pts[i], b = TRACK.pts[(i + 1) % N], h = TRACK.heading[i];
return { x: a[0] + (b[0] - a[0]) * t - Math.sin(h) * lane, y: a[1] + (b[1] - a[1]) * t + Math.cos(h) * lane, heading: h };
}
// Le curve vere (dove il Fifty a tutto gas non passa): per la guida, i coni e la paglia.
export const CURVES = (() => {
const out = [], v = rider({ motore: 3 }).vmax * 0.85;
let inside = false, start = 0, min = Infinity, apex = 0;
TRACK.limit.forEach((l, i) => {
if (l < v && !inside) { inside = true; start = i; min = l; apex = i; }
else if (l < v) { if (l < min) { min = l; apex = i; } }
else if (inside) { inside = false; out.push({ start: start * STEP, apex: apex * STEP, end: i * STEP, speed: min, side: TRACK.side[apex] }); }
});
if (inside) out.push({ start: start * STEP, apex: apex * STEP, end: TRACK.length, speed: min, side: TRACK.side[apex] });
return out;
})();
// Le cose in pista, a distanza dal traguardo (s) e di lato (lane): acceleratori e buche fissi, lattine che ricompaiono.
// Sui rettilinei, lontano dalle curve, perché si vedano arrivare.
function straights() {
const out = [];
let from = 0;
for (const k of [...CURVES, { start: TRACK.length + (CURVES[0]?.start ?? 0) }]) { if (k.start - from > 70) out.push([from + 20, k.start - 20]); from = k.end ?? from; }
return out;
}
export const PICKUPS = (() => {
const list = [], parts = straights();
parts.forEach(([a, b], i) => {
const mid = (a + b) / 2, len = b - a;
if (i % 3 === 0) list.push({ kind: 'boost', s: a + len * 0.3, lane: 0 });
else if (i % 3 === 1) list.push({ kind: 'buca', s: mid, lane: i % 2 ? 4 : -4 });
else list.push({ kind: 'miscela', s: mid, lane: i % 2 ? -4 : 4 });
if (len > 160) list.push({ kind: i % 2 ? 'buca' : 'miscela', s: a + len * 0.75, lane: i % 2 ? -4 : 4 });
});
return list.map((p, k) => ({ ...p, s: ((p.s % TRACK.length) + TRACK.length) % TRACK.length, id: k, ...pointAt(p.s, p.lane) }));
})();
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
// La posizione sulla strada: il punto del tracciato più vicino (cercato vicino all'ultimo, così non si salta di lato).
function locate(r) {
let best = r.idx, bd = Infinity;
for (let d = -12; d <= 24; d++) {
const i = (r.idx + d + N) % N, p = TRACK.pts[i], q = (p[0] - r.x) ** 2 + (p[1] - r.y) ** 2;
if (q < bd) { bd = q; best = i; }
}
let delta = best - r.idx; if (delta > N / 2) delta -= N; if (delta < -N / 2) delta += N;
r.idx = best; r.s += delta * STEP;
const h = TRACK.heading[best], p = TRACK.pts[best];
r.lat = -Math.sin(h) * (r.x - p[0]) + Math.cos(h) * (r.y - p[1]);
}
export function newGara(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria', skill = LEVELS[level];
// In griglia nell'ordine della lista, due per fila, dietro il traguardo.
const riders = (def.riders ?? RIDERS).map((r, k) => {
const base = rider(r), factor = r.id === 'andrea' ? 1 : skill.rivals, s = -16 - 14 * Math.floor(k / 2), lane = k % 2 ? 4 : -4, p = pointAt(s, lane);
return { ...base, vmax: base.vmax * factor, accel: base.accel * factor, lane, x: p.x, y: p.y, a: p.heading, v: 0, u: 0, s, idx: at(s), lat: lane,
gas: false, brake: false, steer: 0, out: 0, boost: 0, laps: 0, finished: null, offs: 0, falls: 0, risk: 1 };
});
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
for (const r of riders) { r.grid = r.s; r.shown = false; }
return { step: 'intro', t: 0, age: 0, seed, level, skill, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null,
power: 0, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
}
export const player = c => c.riders.find(r => r.id === 'andrea');
export const lapOf = r => clamp(Math.floor(Math.max(0, r.s) / TRACK.length) + 1, 1, LAPS);
export const canThrow = c => c.step === 'intro' || c.step === 'start' || c.step === 'race';
export const canAdjust = c => c.step === 'race'; // lo sterzo del pannello
export const aimFree = () => true;
export const nudge = () => false;
export const setPower = () => false;
// Il gas ha più fonti, ognuna con la sua: i tasti tenuti giù (Spazio, Invio, ↑), il dito o il mouse premuto sulla
// scena (uno solo, col suo pointerId) e il pulsante Gas del pannello, che resta acceso finché non lo si ritocca.
// Lasciarne una non spegne le altre. `holding` è la loro somma.
function sync(c) {
c.holding = c.keys.gas.size > 0 || c.pointer !== null || c.toggle;
c.braking = c.keys.brake || c.brakeButton;
if (c.pointer === null) c.drag = 0;
}
// Il primo tocco (o tasto) salta la presentazione, senza dare gas.
export function press(c, id = 'press') {
if (!canThrow(c)) return false;
if (c.step === 'intro') return nextIntro(c);
if (c.pointer !== null && c.pointer !== id) return false; // un secondo dito sulla scena non conta
c.pointer = id; sync(c);
return true;
}
export function release(c, id) {
if (c.pointer === null || (id !== undefined && id !== c.pointer)) return false;
c.pointer = null; sync(c);
return true;
}
export function cancel(c) {
Object.assign(c, { pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0 });
Object.assign(c.keys, { left: false, right: false, brake: false }); c.keys.gas.clear();
sync(c);
return true;
}
// Un tocco durante la presentazione: chi arrivava è già al suo posto, e tocca al prossimo (dopo l'ultimo, il semaforo).
function nextIntro(c) {
const k = Math.floor(c.t / INTRO.each) + 1;
if (k >= c.riders.length) return skipIntro(c);
c.t = k * INTRO.each;
return true;
}
// Finita (o saltata) la presentazione tutti sono in griglia, e si va al semaforo.
function skipIntro(c) {
for (const r of c.riders) place(r, r.grid, true);
c.step = 'start'; c.t = 0;
return true;
}
function place(r, s, shown) {
const p = pointAt(s, r.lane);
Object.assign(r, { s, x: p.x, y: p.y, a: p.heading, idx: at(s), lat: r.lane, shown });
}
// Chi si sta presentando adesso (o null fra uno e l'altro, e alla fine).
export const presenting = c => c.step === 'intro' ? c.riders[Math.floor(c.t / INTRO.each)] ?? null : null;
// Il pulsante Gas del pannello: un tocco lo accende, un altro lo spegne (per chi non può tenere premuto).
export function shoot(c) { if (!canThrow(c)) return false; if (c.step === 'intro') return nextIntro(c); c.toggle = !c.toggle; sync(c); return true; }
// Il dito o il mouse premuto sulla scena: quanto ci si sposta di lato da dove si è premuto è lo sterzo. Il mouse che
// passa senza premere, o col gas dato da tastiera o dal pannello, non sterza.
export function aimScreen(c, [x], id = 'press') {
if (!Number.isFinite(x)) return false;
if (c.pointer === null) c.anchor = x;
else if (c.pointer === id) c.drag = clamp((x - c.anchor) / 28, -1, 1);
return false;
}
// La tastiera: ← → sterzano, ↑ Spazio e Invio sono il gas, ↓ il freno; finché si tengono giù.
const GAS_KEYS = ['ArrowUp', ' ', 'Enter'];
export function key(c, name, down) {
if (name === 'ArrowLeft') c.keys.left = down;
else if (name === 'ArrowRight') c.keys.right = down;
else if (name === 'ArrowDown') c.keys.brake = down;
else if (GAS_KEYS.includes(name)) {
if (down && c.step === 'intro') { nextIntro(c); return true; }
if (down && canThrow(c)) c.keys.gas.add(name); else c.keys.gas.delete(name);
} else return false;
sync(c);
return true;
}
// I pulsanti del pannello tenuti premuti: Sterza (-1, 0, 1) e Frena.
export function steerButton(c, dir) { c.buttons = dir; return true; }
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
export const steering = c => clamp((c.keys.right ? 1 : 0) - (c.keys.left ? 1 : 0) + c.buttons + c.drag, -1, 1);
// La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro).
export const ahead = (from, to) => ((((to - from) % TRACK.length) + TRACK.length) % TRACK.length);
// Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui,
// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto
// si fida troppo.
function drive(c, r) {
const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6);
r.steer = clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1);
const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; });
if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; }
const horizon = (r.v * r.v) / (2 * BRAKE) + 20;
r.gas = true; r.brake = false;
for (let d = 0; d <= horizon; d += STEP * 2) {
const allowed = limitAt(r.s + d) * 0.82 * r.grip * r.caution * r.risk;
if (r.v * r.v > allowed * allowed + 2 * BRAKE * d) { r.gas = false; r.brake = d < 30; break; }
}
// Le buche si scansano (chi ha testa); una lattina ogni tanto la vanno a prendere (deciso una volta per lattina).
const look = 30 + r.v * 0.6, next = PICKUPS.find(p => p.kind !== 'boost' && ahead(r.s, p.s) < look);
if (next && r.seen !== next.id) { r.seen = next.id; r.wants = random(c) < 0.35; }
// Chi ha davanti uno più lento lo passa dall'altra parte.
const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 22 && Math.abs(o.lat - r.lat) < 7 && o.v < r.v + 5);
if (slow) r.lane = slow.lat > 0 ? -5 : 5;
if (next?.kind === 'buca' && r.testa >= 3) r.lane = next.lane > 0 ? -5 : 5;
else if (next?.kind === 'miscela' && r.wants) r.lane = next.lane;
}
function stepRider(c, r, dt) {
if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; }
else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; return; }
else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); }
else drive(c, r);
if (r.finished !== null) r.gas = false;
const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1);
// Il freno vince sul gas: chi li tiene tutti e due frena.
if (r.brake) r.v -= BRAKE * dt;
else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1);
else r.v -= COAST * dt;
if (r.v > top) r.v = Math.max(top, r.v - 120 * dt);
r.v = Math.max(0, r.v);
r.boost = Math.max(0, r.boost - dt);
// Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori.
const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt;
r.a = wrapAngle(r.a + turn);
r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip;
r.u *= Math.exp(-GRIP * r.grip * dt);
r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt;
r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt;
locate(r);
// La paglia (fuori) e le mura (dentro): si rimbalza in strada; arrivandoci forte ci si ferma.
if (Math.abs(r.lat) > VERGE) {
const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1);
r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0;
if (r.v > 40 && r.finished === null) {
r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1;
c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 });
}
r.v *= 0.35; r.a = wrapAngle(h + clamp(wrapAngle(r.a - h), -0.5, 0.5));
}
if (r.finished === null) pickups(c, r);
const laps = Math.floor(Math.max(0, r.s) / TRACK.length);
if (laps > r.laps && r.finished === null) {
r.laps = laps;
if (laps >= LAPS) { r.finished = c.t; c.events.push({ kind: 'gara-arrivo', who: r.id, power: r.id === 'andrea' ? 1 : 0.5 }); }
else if (r.id === 'andrea') c.events.push({ kind: 'gara-giro', who: r.id, power: 0.6 });
}
}
// Acceleratori (una spinta), lattine di miscela (spinta più lunga, poi la lattina ricompare) e buche (forte si casca).
function pickups(c, r) {
for (const p of PICKUPS) {
if (Math.hypot(p.x - r.x, p.y - r.y) > 6) continue;
if (p.kind === 'boost') { if (r.boost < BOOST.time * 0.5) { r.boost = BOOST.time; c.events.push({ kind: 'gara-turbo', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 }); } }
else if (p.kind === 'miscela') {
if ((c.taken[p.id] ?? -Infinity) > c.t - CAN.respawn) continue;
c.taken[p.id] = c.t; r.boost = CAN.time;
c.events.push({ kind: 'gara-miscela', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 });
} else if (p.kind === 'buca' && r.lastHole !== p.id) {
r.lastHole = p.id;
if (r.v > HOLE.fast) { r.out = HOLE.out; r.falls++; r.v = 0; c.events.push({ kind: 'gara-buca', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 }); }
else { r.v *= 0.7; c.events.push({ kind: 'gara-sobbalzo', who: r.id, power: 0.3 }); }
}
}
if (r.lastHole !== undefined && Math.hypot(PICKUPS[r.lastHole].x - r.x, PICKUPS[r.lastHole].y - r.y) > 14) r.lastHole = undefined;
}
// I motorini si urtano: si allontanano un po', e chi tampona perde velocità.
function bump(c) {
for (let i = 0; i < c.riders.length; i++) for (let j = i + 1; j < c.riders.length; j++) {
const a = c.riders[i], b = c.riders[j], dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= 7 || d < 1e-6) continue;
const push = (7 - d) / 2, nx = dx / d, ny = dy / d;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const behind = a.s < b.s ? a : b; behind.v *= 0.97;
}
}
// La classifica: chi è arrivato, in ordine d'arrivo; poi chi corre ancora, per strada fatta.
export function standings(c) {
return [...c.riders].sort((a, b) => (a.finished ?? Infinity) - (b.finished ?? Infinity) || b.s - a.s);
}
export const placeOf = (c, id = 'andrea') => standings(c).findIndex(r => r.id === id) + 1;
export function update(c, dt) {
c.age += dt;
if (c.step === 'intro') {
c.t += dt;
c.riders.forEach((r, k) => {
const t = (c.t - k * INTRO.each) / INTRO.ride;
if (t < 0) return;
if (!r.shown) c.events.push({ kind: 'gara-motore', who: r.id, power: 0.3 + 0.14 * r.motore });
const e = 1 - (1 - Math.min(1, t)) ** 2; // arriva forte e rallenta
place(r, r.grid - INTRO.from * (1 - e), true);
r.v = t < 1 ? INTRO.from * 2 * (1 - Math.min(1, t)) / INTRO.ride : 0;
});
if (c.t >= c.riders.length * INTRO.each + INTRO.after) { skipIntro(c); for (const r of c.riders) r.v = 0; }
return;
}
if (c.step === 'start') {
const before = Math.ceil(COUNTDOWN - c.t);
c.t += dt;
const now = Math.ceil(COUNTDOWN - c.t);
if (now !== before && now > 0) c.events.push({ kind: 'gara-semaforo', power: 0.4 });
if (c.t >= COUNTDOWN) { c.step = 'race'; c.t = 0; c.events.push({ kind: 'gara-via', power: 1 }); }
return;
}
if (c.step !== 'race') return;
c.accumulator += Math.min(dt, 0.1);
while (c.accumulator >= FIXED_STEP) {
c.accumulator -= FIXED_STEP;
c.t += FIXED_STEP;
for (const r of c.riders) stepRider(c, r, FIXED_STEP);
bump(c);
for (const r of c.riders) locate(r);
if (player(c).finished !== null) { finish(c); return; }
}
c.order = standings(c).map(r => r.id);
}
function finish(c) {
const order = standings(c);
// Chi non è ancora arrivato: il tempo stimato con la sua media fin qui.
const times = Object.fromEntries(order.map(r => [r.id, r.finished ?? c.t + (LAPS * TRACK.length - r.s) / Math.max(20, r.s / Math.max(c.t, 1))]));
const ranked = [...order].sort((a, b) => times[a.id] - times[b.id]);
const place = ranked.findIndex(r => r.id === 'andrea') + 1;
c.step = 'result';
cancel(c);
c.order = ranked.map(r => r.id);
c.result = { winner: place === 1 ? 'player' : 'cpu', place, times, order: c.order, offs: player(c).offs, falls: player(c).falls };
}
export function status(c) {
if (c.step === 'intro') {
const r = presenting(c);
return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!';
}
if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, sterza con ← →.` : 'Via!';
if (c.step === 'result') {
const who = c.riders.find(r => r.id === c.result.order[0]).name;
return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`;
}
const me = player(c), place = placeOf(c);
if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…';
if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`;
return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`;
}
export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : c.holding ? 'Molla' : 'Gas';
+7
View File
@@ -7,6 +7,7 @@ import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js'; import { drawBiliardo } from './biliardo-render.js';
import { drawBasket } from './basket-render.js'; import { drawBasket } from './basket-render.js';
import { drawPesca } from './pesca-render.js'; import { drawPesca } from './pesca-render.js';
import { drawGara } from './gara-render.js';
import { drawBriscola } from './briscola-render.js'; import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js'; import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js'; import { drawScopa } from './scopa-render.js';
@@ -684,6 +685,12 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time); drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return; return;
} }
if (def.type === 'gara') {
if (def.picture && !backgrounds.has(def.picture)) loadBackground(def.picture);
drawGara(ctx, c, def, def.picture ? backgrounds.get(def.picture) : null,
src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); }, id => sprites.get(id));
return;
}
if (def.type === 'pesca') { if (def.type === 'pesca') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawPesca(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_pesca'), sprites.get('pescatori')); drawPesca(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_pesca'), sprites.get('pescatori'));
+11
View File
@@ -125,6 +125,17 @@ export class Soundscape {
else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); } else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); }
else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan }); else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan });
else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, pan }); } else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, pan }); }
// Il GP di Fortezza: il semaforo, il via, la paglia, acceleratori, miscela, buche, il giro, il traguardo.
else if (kind === 'gara-semaforo') this.tone(440, 440, 0.18, 0.06, 0, pan);
else if (kind === 'gara-via') this.tone(880, 880, 0.4, 0.08, 0, pan);
else if (kind === 'gara-fuori') { this.noiseSound(0.35, 700, 0.1, { pan }); this.tone(300, 120, 0.2, 0.05, 0, pan); }
else if (kind === 'gara-motore') { this.tone(110, 190 + strength * 160, 0.9, 0.03 + strength * 0.03, 0, pan); this.tone(220, 380 + strength * 320, 0.9, 0.015 + strength * 0.015, 0, pan); }
else if (kind === 'gara-turbo') this.tone(300, 900, 0.25, 0.04 + strength * 0.04, 0, pan);
else if (kind === 'gara-miscela') { this.tone(500, 1200, 0.2, 0.04 + strength * 0.04, 0, pan); this.tone(800, 1600, 0.2, 0.03 * strength, 0.12, pan); }
else if (kind === 'gara-buca') { this.tone(220, 60, 0.3, 0.05 + strength * 0.07, 0, pan); this.noiseSound(0.3, 500, 0.08 * strength + 0.02, { pan }); }
else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan);
else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan);
else if (kind === 'gara-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. // 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-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 }); } else if (kind === 'pesca-tuffo') { this.tone(420, 180, 0.07, 0.07, 0, pan); this.noiseSound(0.12, 900, 0.06, { pan }); }
+51 -8
View File
@@ -12,6 +12,7 @@ import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js'; import * as biliardo from './engine/biliardo.js';
import * as basket from './engine/basket.js'; import * as basket from './engine/basket.js';
import * as pesca from './engine/pesca.js'; import * as pesca from './engine/pesca.js';
import * as gara from './engine/gara.js';
import * as briscola from './engine/briscola.js'; import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js'; import { cardAt } from './engine/briscola-render.js';
import * as tressette from './engine/tressette.js'; import * as tressette from './engine/tressette.js';
@@ -497,6 +498,14 @@ canvas.addEventListener('pointerdown', e => {
canvas.setPointerCapture(e.pointerId); canvas.setPointerCapture(e.pointerId);
return; return;
} }
// Il GP di Fortezza: un dito solo sulla scena dà gas e sterza trascinando; gli altri tocchi non contano
// (sterzo e freno hanno i loro pulsanti, che si tengono insieme al gas).
if (type === 'gara') {
if (press || game.skip()) return;
gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
return;
}
const aimed = game.closeupAim(scenePoint(e)); const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) { if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
@@ -529,6 +538,10 @@ canvas.addEventListener('pointerup', e => {
if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card); if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
return; return;
} }
if (press.gara !== undefined) {
if (e.pointerId === press.gara) { if (game?.closeup) gara.release(game.closeup, e.pointerId); press = null; }
return;
}
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; } if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer); clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità. // Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
@@ -541,7 +554,7 @@ canvas.addEventListener('pointerup', e => {
press = null; press = null;
}); });
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } }); canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -552,6 +565,10 @@ canvas.addEventListener('pointermove', e => {
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo, // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera. // attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.) // (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
if (game.closeup && story.closeups[game.closeup.id].type === 'gara') {
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
return;
}
const aiming = game.closeup && ['biliardo', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type); const aiming = game.closeup && ['biliardo', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type);
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); } if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e)); else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
@@ -566,6 +583,8 @@ const MINI = {
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' }, bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' }, biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' }, basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza: tieni premuto (mouse, dito, Spazio o ↑) per il gas, sterza con ← → o trascinando di lato; ↓ frena.' },
pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' }, pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' },
}; };
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null; const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
@@ -594,8 +613,15 @@ for (let i = 0; i < 3; i++) {
button.addEventListener('click', () => game?.closeupPlay(i)); button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); }); button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
} }
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; }); $(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; });
// Al GP di Fortezza si sterza finché il pulsante resta premuto (anche col dito, insieme a Gas).
$(id).addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().steerButton?.(c, dir); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $(id).addEventListener(ev, () => { const c = miniState(); if (c) mini().steerButton?.(c, 0); });
}
// Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza).
$('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); });
addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); }); addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); });
// --- Verbi. // --- Verbi.
@@ -757,7 +783,12 @@ window.addEventListener('keydown', e => {
return; return;
} }
if (miniState()) { if (miniState()) {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; // Esc esce anche col fuoco sui pulsanti del pannello.
if (e.key === 'Escape') { game.closeupClose(); return; }
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true)) { e.preventDefault(); return; }
if (onControl) return;
const c = game.closeup, m = mini(); const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault(); e.preventDefault();
@@ -801,7 +832,11 @@ window.addEventListener('keydown', e => {
}); });
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro. // Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); }); window.addEventListener('keyup', e => {
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
if (miniState() && mini().key) { mini().key(game.closeup, e.key, false); return; }
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
});
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio). // --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose()); $('closeup-close').addEventListener('click', () => game?.closeupClose());
@@ -908,19 +943,26 @@ function updateUi() {
} }
} }
if (mg && !cards) { if (mg && !cards) {
$('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca' }[def.type] ?? 'Comandi delle bocce'); $('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara' }[def.type] ?? 'Comandi delle bocce');
$('panel').hidden = $('choices').hidden = true; $('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.names); const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
$('bocce-power').value = Math.round(c.power * 100); $('bocce-power').value = Math.round(c.power * 100);
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`; $('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) $(id).disabled = !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || c.holding || !!game.speech; for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) $(id).disabled = !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech;
$('bocce-throw').disabled = !mg.canThrow(c) || c.holding || !!game.speech; $('bocce-throw').disabled = !mg.canThrow(c) || (c.holding && !mg.toggle) || !!game.speech;
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
$('bocce-power').closest('label').hidden = def.type === 'gara';
$('bocce-left').textContent = def.type === 'gara' ? '← Sterza' : '← Mira';
$('bocce-right').textContent = def.type === 'gara' ? 'Sterza →' : 'Mira →';
$('gara-brake').hidden = def.type !== 'gara';
$('gara-brake').disabled = !(mg.canAdjust?.(c) ?? false) || !!game.speech;
$('bocce-throw').textContent = mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'); $('bocce-throw').textContent = mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira');
$('bocce-help').hidden = def.type !== 'bocce'; $('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo'; $('biliardo-help').hidden = def.type !== 'biliardo';
$('basket-help').hidden = def.type !== 'basket'; $('basket-help').hidden = def.type !== 'basket';
$('pesca-help').hidden = def.type !== 'pesca'; $('pesca-help').hidden = def.type !== 'pesca';
$('gara-help').hidden = def.type !== 'gara';
if (mg.aimFree?.(c) ?? !mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera if (mg.aimFree?.(c) ?? !mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
} }
if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power }); if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
@@ -934,7 +976,8 @@ function updateUi() {
$('panel').inert = !!c; $('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg); canvas.classList.toggle('bocce', !!mg);
$('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo'); // 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', 'gara'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards); $('stage').classList.toggle('briscola', !!cards);
canvas.setAttribute('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"); canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio. // La pagina che comincia a girare fa il suo fruscio.
+18
View File
@@ -0,0 +1,18 @@
// La gara dei cinquantini (idea di Andrea, 28 ottobre): chi corre, in ordine di griglia (due per fila).
// Ogni amico che manda la foto del suo motorino si aggiunge con una riga. `motore`: da 1 (di serie) a 5 (truccato da
// far paura); `testa`: da 1 (matto, in curva rischia) a 5 (prudente). `color` e `trim`: i colori del motorino in pista.
// Per la presentazione: `moto`, il disegno del motorino (Codex, dalla foto); `sprite`, il personaggio del gioco (un
// nome di story.characters), oppure `face`, un ritratto quadrato; senza nessuno dei due, una sagoma.
// Persone vere solo col loro consenso (`consenso`: chi l'ha dato e quando).
export const piloti = [
// Lo SR di Puntella era il più truccato di tutti; per ora i colori dell'Aprilia SR 50 da GP (foto di Andrea).
{ id: 'puntella', name: 'Puntella', bike: 'lo SR', motore: 5, testa: 1, color: '#5a3fb5', trim: '#e8442e', moto: 'assets/closeups/gp/sr.png', consenso: null }, // [DA ANDREA: consenso di Marco Villani]
// Cistola (Davide Poggi) col suo CR 50 Z, blu notte e argento, dalla foto che ha mandato; il volto dal suo murale.
{ id: 'cistola', name: 'Cistola', bike: 'il CR 50 Z', motore: 4, testa: 3, color: '#1d2440', trim: '#a9adb5', moto: 'assets/closeups/gp/cr50z.png', face: 'assets/closeups/gp/cistola.png', consenso: '2026-10-27' },
// Il Malaguti Fifty di Andrea: rosso con le rifiniture nere.
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c', moto: 'assets/closeups/gp/fifty.png', sprite: 'andrea' },
// Tozzino (Francesco Tozzi) col suo Booster blu scuro, di serie, dalle foto che ha mandato (l'adesivo sul manubrio compreso).
{ id: 'tozzino', name: 'Tozzino', bike: 'il Booster', motore: 1, testa: 3, color: '#1a2a52', trim: '#111318', moto: 'assets/closeups/gp/booster.png', sprite: 'tozzino' },
// Il Betino dell'Arcu: verde e crema, lento ma non cade mai.
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0', moto: 'assets/closeups/gp/betino.png', sprite: 'arcu' },
];
+156 -21
View File
@@ -2,10 +2,13 @@
// Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md). // Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md).
// Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite. // Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite.
import { ambience } from './ambience.js'; import { ambience } from './ambience.js';
import { piloti } from './piloti.js';
// Gli indizi del Trebbio per la catena della torcia (dopo le impronte, finché la torcia non c'è): uno per tappa, // Gli indizi del Trebbio per la catena della torcia (dopo le impronte, finché la torcia non c'è): uno per tappa,
// dalla più avanti alla prima. // dalla più avanti alla prima.
const castello = say => [{ say, who: 'castello' }]; const castello = say => [{ say, who: 'castello' }];
// Prima del GP di Fortezza l'Arcu va a prendere il Betino in cima alla salita (Andrea, 28 ottobre).
const rincorsa = level => [{ say: 'Aspettate: prendo la rincorsa, che il Betino s\'accende solo in discesa.', who: 'arcu', pose: 'point' }, { closeup: 'gara', level }];
const torchHints = [{ const torchHints = [{
if: 'has:chiavetta', if: 'has:chiavetta',
then: castello('Una chiavetta piccola apre una cosa piccola. All\'area feste c\'è una casetta che aspetta.'), then: castello('Una chiavetta piccola apre una cosa piccola. All\'area feste c\'è una casetta che aspetta.'),
@@ -184,6 +187,26 @@ export const story = {
quit: [{ say: 'Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.' }], quit: [{ say: 'Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.' }],
draw: [], draw: [],
}, },
// Il GP di Fortezza (idea di Andrea, 28 ottobre): tre giri coi cinquantini attorno alle mura, gas e sterzo come
// nelle vecchie gare da sala giochi viste dall'alto. Chi corre sta in piloti.js. [DA ANDREA: le battute; la musica]
gara: {
type: 'gara', music: 'assets/audio/gara.mp3', // di Andrea (Suno)
picture: 'assets/closeups/gp-fortezza.png', // Codex, dalla planimetria di Andrea; la strada è il tracciato
// Corre solo chi ha dato il consenso (`consenso: null` vuol dire «da chiedere»: per ora Puntella).
riders: piloti.filter(p => p.consenso !== null),
names: { player: 'Andrea', cpu: 'gli altri' },
win: [
{ say: 'Primo! E col Fifty di serie, o quasi.' },
{ say: 'Si rifà! Questa un vale, avevo la catena lenta.', who: 'arcu', pose: 'shrug' },
{ set: 'gara_vinta' },
],
lose: [
{ say: 'Ho preso tutta la polvere di quelli davanti.' },
{ say: 'Il Fifty è bono. È chi ci sta sopra.', who: 'arcu', pose: 'laugh' },
],
quit: [{ say: 'Lascio stare. Il Fifty mi serve intero, per il palio.' }],
draw: [],
},
// Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute] // Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute]
biliardo: { biliardo: {
type: 'biliardo', type: 'biliardo',
@@ -2059,19 +2082,11 @@ export const story = {
people: [ people: [
{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }, { who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
// Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce. // Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce.
// Con la depth della scena misurano circa 78 e 160 px; restano sopra il bordo di walk. // Con la depth della scena misura circa 160 px; resta sopra il bordo di walk. (Il Betino dell'Arcu sta in
{ who: 'betino', at: [480, 263], size: 0.767, idle: 'idle', talk: [] }, // cima alla salita di Via Liguri Magelli, Andrea 28 ottobre.)
{ who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] }, { who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] },
], ],
hotspots: [ hotspots: [
{
id: 'betino', name: 'il Betino', box: [441, 212, 78, 51], at: [480, 282], face: 'up', default: 'look',
// Il motorino di Andrea (sprite di Codex, 26 ottobre); la battuta è sua.
actions: {
look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }],
use: [{ say: 'O via, lascialo riposare. A pedalare oggi ci penso io.' }],
},
},
{ {
id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look', id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look',
// La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde. // La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde.
@@ -2563,10 +2578,11 @@ export const story = {
}, },
}, },
{ {
id: 'all_area_camper', name: 'a sinistra, verso l\'area camper', box: [320, 160, 110, 42], at: [442, 202], exit: { scene: 'area_camper', at: [600, 330], face: 'left' }, id: 'all_area_camper', name: 'a sinistra, verso le villette del Giannini', box: [320, 160, 110, 42], at: [442, 202], exit: { scene: 'area_camper', at: [600, 330], face: 'left' },
}, },
{ {
id: 'al_giannini', name: 'in fondo a sinistra, verso il campo del Giannini', box: [380, 165, 58, 30], at: [440, 200], exit: { scene: 'campo_giannini', at: [480, 218], face: 'left' }, // [DA ANDREA: dove si gira per Cardetole, in fondo al ponticino]
id: 'a_cardetole', name: 'in fondo a destra, verso Cardetole', box: [578, 80, 62, 90], at: [476, 204], exit: { scene: 'cardetole', at: [628, 290], face: 'left' },
}, },
{ {
id: 'alla_stazione', name: 'a destra, verso la stazione', box: [486, 160, 90, 40], at: [474, 202], exit: { scene: 'stazione', at: [20, 300], face: 'right' }, id: 'alla_stazione', name: 'a destra, verso la stazione', box: [486, 160, 90, 40], at: [474, 202], exit: { scene: 'stazione', at: [20, 300], face: 'right' },
@@ -2577,7 +2593,8 @@ export const story = {
], ],
}, },
area_camper: { area_camper: {
name: 'Area camper', // Le villette dove abita il Giannini: da qui si va al suo campo (Andrea, 28 ottobre). L'id resta area_camper.
name: 'Le villette del Giannini',
map: [70, 84], map: [70, 84],
background: 'assets/scenes/area-camper.png', background: 'assets/scenes/area-camper.png',
walk: [[0, 360], [0, 260], [200, 232], [380, 240], [450, 248], [640, 300], [640, 360]], walk: [[0, 360], [0, 260], [200, 232], [380, 240], [450, 248], [640, 300], [640, 360]],
@@ -2591,13 +2608,18 @@ export const story = {
}, },
}, },
{ {
id: 'villette', name: 'villette', box: [500, 80, 140, 120], at: [560, 280], face: 'right', id: 'villette', name: 'villette', box: [500, 80, 140, 106], at: [560, 280], face: 'right',
actions: { actions: {
look: [{ say: 'Le villette rosa coi balconi bianchi. Da qui a casa mia è un attimo.' }], look: [{ say: 'Le villette rosa coi balconi bianchi. Da qui a casa mia è un attimo.' }],
}, },
}, },
{ {
id: 'a_cardetole', name: 'verso casa, a Cardetole', box: [0, 230, 80, 130], at: [20, 300], exit: { scene: 'cardetole', at: [300, 350], face: 'up' }, // Casa è a destra, per la strada (Andrea, 28 ottobre).
id: 'a_cardetole', name: 'verso casa, a Cardetole', box: [572, 188, 68, 96], at: [618, 300], exit: { scene: 'cardetole', at: [560, 348], face: 'up' },
},
{
// Laggiù, in fondo al vialetto vicino alla siepe, il campo del Giannini (Andrea, 28 ottobre).
id: 'al_giannini', name: 'laggiù, il campo del Giannini', box: [330, 150, 96, 74], at: [380, 246], exit: { scene: 'campo_giannini', at: [480, 216], face: 'left' },
}, },
{ {
id: 'al_ponticino', name: 'indietro, il ponticino', box: [560, 300, 80, 60], at: [620, 330], exit: { scene: 'ponticino', at: [442, 202], face: 'down' }, id: 'al_ponticino', name: 'indietro, il ponticino', box: [560, 300, 80, 60], at: [620, 330], exit: { scene: 'ponticino', at: [442, 202], face: 'down' },
@@ -2605,7 +2627,8 @@ export const story = {
], ],
}, },
// Il prato del parco dei camper visto dall'altra parte: per i ragazzi, «il campo del Giannini». Ci si arriva dal // Il prato del parco dei camper visto dall'altra parte: per i ragazzi, «il campo del Giannini». Ci si arriva dal
// ponticino della Carza; niente uscita verso l'area camper, perché è già questa (Andrea, 28 ottobre). // lato delle villette dove abita il Giannini (fra gli alberi a destra) e dal Trappola; dal ponticino non si viene
// qui. L'area camper è il campo stesso: i camper stanno qui dietro (Andrea, 28 ottobre).
campo_giannini: { campo_giannini: {
name: 'Il campo del Giannini', name: 'Il campo del Giannini',
map: [82, 96], map: [82, 96],
@@ -2621,6 +2644,12 @@ export const story = {
use: [{ say: 'Senza pallone, e senza la squadra, non vale.' }], use: [{ say: 'Senza pallone, e senza la squadra, non vale.' }],
}, },
}, },
{
id: 'camper', name: 'qui dietro, i camper', box: [520, 320, 120, 40], at: [560, 340], face: 'down', default: 'look',
actions: {
look: [{ say: 'Qui dietro ci stanno i camper. Ma per noi questo resta il campo del Giannini.' }],
},
},
{ {
id: 'giochi', name: 'giochi dei bambini', box: [220, 170, 80, 40], at: [260, 216], face: 'up', id: 'giochi', name: 'giochi dei bambini', box: [220, 170, 80, 40], at: [260, 216], face: 'up',
actions: { actions: {
@@ -2628,7 +2657,67 @@ export const story = {
}, },
}, },
{ {
id: 'al_ponticino', name: 'verso il ponticino della Carza', box: [440, 150, 90, 60], at: [480, 216], exit: { scene: 'ponticino', at: [444, 204], face: 'right' }, // Fra gli alberi a destra si arriva alle villette del Giannini, in fondo al vialetto (Andrea, 28 ottobre).
id: 'all_area_camper', name: 'verso le villette del Giannini', box: [440, 150, 90, 60], at: [480, 216], exit: { scene: 'area_camper', at: [380, 246], face: 'down' },
},
{
id: 'via_liguri_magelli', name: 'dal Trappola, in Via Liguri Magelli', box: [0, 100, 70, 260], at: [30, 260], exit: { scene: 'via_liguri_magelli', at: [520, 306], face: 'left' },
},
],
},
// Via Liguri Magelli (Street View mandato da Andrea, 28 ottobre): a sinistra dal campo del Giannini. A destra la
// casa salmone con la tenda del Trappola (il Barrettino), sul muro giallo la targa della Ghiacciomobile con la sua
// fotina, e in cima alla salita il Betino dell'Arcu (dipinto da Codex nel quadro), di traverso verso la discesa:
// per partire ne ha bisogno, e intanto ferma il traffico.
via_liguri_magelli: {
name: 'Via Liguri Magelli',
map: [76, 97],
background: 'assets/scenes/via-liguri-magelli.png',
walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [400, 250], [580, 318], [640, 330], [640, 360]],
depth: [[198, 0.3], [360, 1.1]],
entry: [320, 340],
speakers: { arcu: [374, 176] },
people: [
// L'Arcu sotto la tenda del Trappola: due minuti fa era a Campomigliaio (Andrea, 28 ottobre).
{ who: 'arcu', at: [374, 248], facing: -1, size: 1.3, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
],
hotspots: [
{
id: 'arcu', name: 'l\'Arcu', box: [362, 180, 26, 68], at: [352, 256], face: 'right', default: 'talk',
actions: {
look: [{ say: 'L\'Arcu, sotto la tenda del Trappola. Due minuti fa era a Campomigliaio.' }],
talk: [{ dialogue: 'arcu_trappola' }],
},
},
{
// «Vai dal Trappola, la tenda è il Trappola» (Andrea). [DA ANDREA: com'è dentro, e chi c'è]
id: 'trappola', name: 'dal Trappola', box: [316, 150, 46, 72], at: [340, 238], face: 'right', default: 'walk',
actions: {
look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }],
walk: [{ say: 'Dal Trappola ci si passa dopo. Prima il palio.' }],
},
},
{
id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look',
actions: {
look: [{ say: '«Qui riposa la Ghiacciomobile, che si è schiantata lì.»' }, { say: 'C\'è anche la fotina. Pareva sempre nuova.' }],
},
},
{
// La prima battuta è di Andrea (dal circolino, 26 ottobre).
id: 'betino', name: 'il Betino dell\'Arcu', box: [232, 172, 40, 24], at: [250, 206], face: 'up', default: 'look',
actions: {
look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }, { say: 'Lassù in cima, di traverso: per partire ha bisogno della discesa. E intanto ferma il traffico.' }],
use: [{ say: 'È il Betino dell\'Arcu. Senza una discesa davanti, un si muove.' }],
},
},
{
// Casa di Andrea è lassù, in cima alla salita, a destra del Betino (Andrea, 28 ottobre).
id: 'a_cardetole', name: 'lassù, verso casa a Cardetole', box: [274, 148, 40, 50], at: [264, 202], exit: { scene: 'cardetole', at: [20, 352], face: 'right' },
},
{
// Il campo del Giannini è laggiù a destra, oltre la siepe (Andrea, 28 ottobre).
id: 'al_giannini', name: 'verso il campo del Giannini', box: [430, 150, 132, 100], at: [520, 306], exit: { scene: 'campo_giannini', at: [60, 250], face: 'right' },
}, },
], ],
}, },
@@ -2644,21 +2733,38 @@ export const story = {
{ {
id: 'casa', name: 'casa di Andrea', box: [330, 20, 220, 212], at: [470, 290], face: 'up', id: 'casa', name: 'casa di Andrea', box: [330, 20, 220, 212], at: [470, 290], face: 'up',
actions: { actions: {
look: [{ say: 'Casa mia. Terzo piano, quello con la tapparella a metà: è la mia camera.' }], look: [{ say: 'Casa mia. Secondo piano: quella con la tapparella a metà è la camera di mia sorella, e a destra quella dei miei.' }],
open: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }], open: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }],
walk: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }], walk: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }],
}, },
}, },
{ {
id: 'platano', name: 'platano', box: [90, 0, 170, 216], at: [200, 306], face: 'up', id: 'platano', name: 'platano', box: [90, 0, 170, 206], at: [200, 306], face: 'up',
actions: { actions: {
look: [{ say: 'Il prato sotto casa. Qui le porte le facevamo con le maglie per terra: due maglie, un palo ciascuna.' }, { say: 'E ogni gol era «Era fuori!», «No, era dentro!».' }], look: [{ say: 'Il prato sotto casa. Qui le porte le facevamo con le maglie per terra: due maglie, un palo ciascuna.' }, { say: 'E ogni gol era «Era fuori!», «No, era dentro!».' }],
use: [{ say: 'Arrampicarmi? Alla mia età, e davanti a tutto il condominio?' }], use: [{ say: 'Arrampicarmi? Alla mia età, e davanti a tutto il condominio?' }],
}, },
}, },
// Si compare a destra dell'area camper: inquadrature invertite rispetto alle foto (Andrea, 28 ottobre, va bene così).
{ {
id: 'alla_pieve', name: 'su verso la Pieve', box: [0, 330, 640, 30], at: [300, 354], exit: { scene: 'area_camper', at: [600, 330], face: 'left' }, // Il prato in salita sotto casa: le parole sono di Andrea (28 ottobre).
id: 'prato', name: 'prato', box: [52, 208, 278, 76], at: [262, 300], face: 'up', default: 'look',
actions: {
look: [
{ say: 'Su questo campo ci abbiamo passato trentacinque anni, giorno, pomeriggio e sera, a giocare a calcio.' },
{ say: 'Le maglie a fare da pali, e i palloni tagliati che entravano nelle terrazze, ovunque.' },
],
},
},
// Le uscite di casa (Andrea, 28 ottobre): in basso a destra il Giannini (le sue villette, dalla strada delle
// villette), a destra il ponticino della Carza, a sinistra il Trappola.
{
id: 'al_giannini', name: 'dal Giannini', box: [440, 322, 200, 38], at: [560, 348], exit: { scene: 'area_camper', at: [618, 300], face: 'left' },
},
{
id: 'al_ponticino', name: 'a destra, il ponticino della Carza', box: [596, 150, 44, 110], at: [628, 290], exit: { scene: 'ponticino', at: [476, 204], face: 'down' },
},
{
id: 'al_trappola', name: 'a sinistra, dal Trappola', box: [0, 236, 50, 104], at: [20, 352], exit: { scene: 'via_liguri_magelli', at: [264, 202], face: 'down' },
}, },
], ],
}, },
@@ -3009,6 +3115,35 @@ export const story = {
}, },
}, },
}, },
// L'Arcu dal Trappola (Andrea, 28 ottobre): lo scherzo sulla velocità del Betino, poi la sfida: «il giro della Fortezza» (Andrea), cioè il GP di Fortezza;
// prima del via prende la rincorsa per accenderlo in discesa. [DA ANDREA: le battute sono provvisorie]
arcu_trappola: {
nodes: {
start: {
options: [
{ text: 'Ma ti ho visto du\' minuti fa a Campomigliaio!', then: [
{ say: 'E allora? Col Betino ci metto un attimo.', who: 'arcu', pose: 'explain' },
{ say: 'In discesa.', who: 'arcu', pose: 'laugh' },
{ say: 'E in salita?' },
{ say: 'In salita si spinge.', who: 'arcu', pose: 'shrug' },
] },
{ text: 'Si fa una gara coi motorini? Il giro della Fortezza!', next: 'gara', then: [
{ say: 'Col Betino? Io un perdo mai: un vo mai forte abbastanza da cadere.', who: 'arcu', pose: 'explain' },
{ say: 'Come la si fa?', who: 'arcu', pose: 'talk' },
] },
{ text: 'Ci si vede dopo, Arcu.', next: 'end' },
],
},
gara: {
options: [
{ text: 'Alla bona, un giro di paese.', next: 'end', then: [{ say: 'Alla bona. Chi arriva ultimo paga le paste.', who: 'arcu', pose: 'laugh' }, ...rincorsa('bona')] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Tre giri, e la paglia in curva è vera.', who: 'arcu', pose: 'explain' }, ...rincorsa('seria')] },
{ text: 'Ginocchio in terra.', next: 'end', then: [{ say: 'Ginocchio in terra? Puntella un vede l\'ora.', who: 'arcu', pose: 'shrug' }, ...rincorsa('male')] },
{ text: 'No, lascia perdere.', next: 'start' },
],
},
},
},
// Da solo al campo del Circolo: Andrea decide quanto farsi male. // Da solo al campo del Circolo: Andrea decide quanto farsi male.
basket: { basket: {
nodes: { nodes: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.30'; export const VERSION = '0.1.32';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+4 -4
View File
@@ -53,16 +53,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; } .bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; } #bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; } .bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help, #biliardo-help, #basket-help, #pesca-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary { cursor: pointer; padding: 6px 0; } #bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p { max-width: 76em; line-height: 1.7; } #bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p { max-width: 76em; line-height: 1.7; }
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#bocce-throw { flex-basis: 100%; } #bocce-throw { flex-basis: 100%; }
#bocce-help, #biliardo-help, #basket-help, #pesca-help { font-size: 9px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help { font-size: 9px; }
} }
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); } .effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
#effects-volume { width: 130px; accent-color: var(--accent); } #effects-volume { width: 130px; accent-color: var(--accent); }
+7
View File
@@ -16,6 +16,13 @@ test('clic intelligente: parla con le persone, guarda le cose, attraversa le usc
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti'); assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio'); assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio');
assert.equal(c.sentence('verso_piazzetta'), 'Vai a Via dell\'Antica Posta, verso Piazzetta Cambray Digny'); assert.equal(c.sentence('verso_piazzetta'), 'Vai a Via dell\'Antica Posta, verso Piazzetta Cambray Digny');
// Quando il nome dice già dove, niente «a» in più: «Vai dal Trappola», «Vai a destra, …».
const casa = new Game(story), cc = new Command(casa); casa.go('cardetole');
assert.equal(cc.sentence('al_giannini'), 'Vai dal Giannini');
assert.equal(cc.sentence('al_ponticino'), 'Vai a destra, il ponticino della Carza');
casa.go('via_liguri_magelli');
assert.equal(cc.sentence('a_cardetole'), 'Vai lassù, verso casa a Cardetole');
assert.equal(cc.sentence('trappola'), 'Vai dal Trappola');
assert.equal(c.clickScene(at('vecchietti')), 'act'); assert.equal(c.clickScene(at('vecchietti')), 'act');
settle(g); settle(g);
assert.equal(g.dialogue?.id, 'vecchietti'); assert.equal(g.dialogue?.id, 'vecchietti');
+195
View File
@@ -0,0 +1,195 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newGara, update, press, presenting, INTRO, release, shoot, key, steerButton, aimScreen, steering, status, button, standings, player, rider, limitAt, pointAt, TRACK, CURVES, PICKUPS, BRAKE, VERGE, LAPS, COUNTDOWN, RIDERS } from '../src/engine/gara.js';
import { piloti } from '../src/story/piloti.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
// Gli eventi sonori di ogni gara si ricordano nella gara stessa (`c.heard`, «tipo» e «tipo:pilota»): un test non
// vede quelli degli altri.
const run = (c, seconds, drive, dt = 1 / 60) => {
c.heard ??= new Set();
for (let t = 0; t < seconds && c.step !== 'result'; t += dt) {
if (drive && c.step === 'race') c.holding = drive(c);
update(c, dt); for (const e of c.events.splice(0)) { c.heard.add(e.kind); c.heard.add(`${e.kind}:${e.who}`); }
}
return c.heard;
};
const skip = c => { while (c.step === 'intro') press(c); return c; };
const race = c => { skip(c); assert.equal(c.step, 'start'); run(c, COUNTDOWN + 0.01); assert.equal(c.step, 'race'); return c; };
// Il pilota di giudizio: guarda avanti e ci sterza (scansando buche e chi gli sta davanti), e dà gas finché,
// frenando, arriverebbe a ogni curva un po' sotto il limite. Usa i comandi del giocatore (il trascinamento).
const careful = c => {
const r = player(c), here = ((r.s % TRACK.length) + TRACK.length) % TRACK.length;
const next = PICKUPS.find(p => p.kind !== 'boost' && p.s - here > -8 && p.s - here < 60);
const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 26 && Math.abs(o.lat - r.lat) < 7);
const lane = next?.kind === 'buca' ? (next.lane > 0 ? -6 : 6) : slow ? (slow.lat > 0 ? -6 : 6) : next ? next.lane : 0;
const ahead = pointAt(r.s + 14 + r.v * 0.18, lane), want = Math.atan2(ahead.y - r.y, ahead.x - r.x) - r.a;
// Come un dito premuto che trascina: lo zero dove si è premuto, di lato quanto serve.
if (c.pointer === null) { aimScreen(c, [320, 180], 'bot'); press(c, 'bot'); }
aimScreen(c, [320 + 28 * Math.max(-1, Math.min(1, Math.atan2(Math.sin(want), Math.cos(want)) * 2.6)), 180], 'bot');
const horizon = r.v * r.v / (2 * BRAKE) + 20;
let brake = false;
for (let d = 0; d <= horizon; d += 4) { const a = limitAt(r.s + d) * 0.85; if (r.v * r.v > a * a + 2 * BRAKE * d) { brake = true; break; } }
key(c, 'ArrowDown', brake);
return c.holding;
};
test('i piloti sono dati: motore e testa da 1 a 5 danno velocità, accelerazione e prudenza', () => {
const slow = rider({ motore: 1, testa: 5 }), fast = rider({ motore: 5, testa: 1 });
assert.ok(fast.vmax > slow.vmax && fast.accel > slow.accel && fast.grip < slow.grip);
assert.ok(fast.slip > slow.slip && fast.caution > slow.caution);
assert.deepEqual(rider({ motore: 9, testa: -2 }).motore, 5, 'fuori scala si ferma ai bordi');
// Ogni pilota della storia ha nome, motorino e colore; Andrea c'è una volta sola.
for (const p of piloti) { assert.ok(p.id && p.name && p.bike && /^#[0-9a-f]{6}$/i.test(p.color), p.id); }
assert.equal(piloti.filter(p => p.id === 'andrea').length, 1);
assert.throws(() => newGara(1, { riders: [{ id: 'arcu', name: 'L\'Arcu' }] }), /manca Andrea/);
const c = newGara(1, { riders: piloti });
assert.equal(c.riders.length, piloti.length);
assert.deepEqual(c.riders.map(r => r.s), piloti.map((_, k) => -16 - 14 * Math.floor(k / 2)), 'due per fila, dietro il traguardo');
assert.deepEqual(c.riders.map(r => Math.sign(r.lat)), piloti.map((_, k) => k % 2 ? 1 : -1));
});
test('il semaforo, poi il gas: tenendo premuto si va, lasciando si rallenta, il freno vince sul gas', () => {
const c = newGara(3, { level: 'seria', riders: RIDERS });
for (const r of RIDERS) assert.ok(press(c, 7), r.id);
assert.equal(c.step, 'start', 'un tocco per pilota e la presentazione è finita');
assert.equal(c.holding, false, 'senza dare gas');
assert.ok(press(c, 7)); assert.equal(c.holding, true);
const start = player(c).s;
const heard = run(c, COUNTDOWN - 0.1);
assert.equal(c.step, 'start'); assert.equal(player(c).s, start, 'prima del via non si muove nessuno');
run(c, 0.15);
assert.equal(c.step, 'race'); assert.ok(heard.has('gara-semaforo') && heard.has('gara-via'));
run(c, 1);
const v = player(c).v; assert.ok(v > 20);
release(c, 7); run(c, 0.2);
assert.ok(player(c).v < v, 'senza gas rallenta');
assert.match(status(c), /Giro 1 di 3/); assert.equal(button(c), 'Gas');
// Gas e freno insieme: frena (P1 della revisione di Codex).
key(c, 'ArrowUp', true); run(c, 0.5); const fast = player(c).v;
key(c, 'ArrowDown', true); run(c, 0.1);
assert.ok(c.holding && c.braking); assert.ok(player(c).v < fast - 10, 'col gas tenuto il freno frena');
key(c, 'ArrowDown', false); key(c, 'ArrowUp', false);
shoot(c); assert.equal(c.holding, true); assert.equal(button(c), 'Molla');
shoot(c); assert.equal(c.holding, false);
});
test('le fonti del gas sono indipendenti: lasciare un tasto non toglie quello del dito o degli altri tasti', () => {
const c = race(newGara(3, { riders: RIDERS }));
key(c, ' ', true); key(c, 'ArrowUp', true); key(c, 'ArrowUp', false);
assert.equal(c.holding, true, 'Spazio ancora giù');
press(c, 1); key(c, ' ', false);
assert.equal(c.holding, true, 'il dito ancora giù');
assert.equal(press(c, 2), false, 'un secondo dito sulla scena non conta');
release(c, 2); assert.equal(c.holding, true, 'e lasciandolo non toglie il gas al primo');
release(c, 1); assert.equal(c.holding, false);
shoot(c); key(c, ' ', true); key(c, ' ', false); assert.equal(c.holding, true, 'il pulsante Gas resta acceso');
});
test('lo sterzo: frecce, pulsanti e il dito che trascina; il mouse che passa senza premere non sterza', () => {
const c = race(newGara(3, { riders: RIDERS }));
assert.ok(key(c, 'ArrowLeft', true)); assert.equal(steering(c), -1);
key(c, 'ArrowLeft', false); assert.equal(steering(c), 0);
steerButton(c, 1); assert.equal(steering(c), 1); steerButton(c, 0);
// Gas da tastiera e sinistra, il mouse si muove a destra senza premere: lo sterzo resta a sinistra (P1).
key(c, 'ArrowUp', true); key(c, 'ArrowLeft', true);
aimScreen(c, [300, 100], 5); aimScreen(c, [360, 100], 5);
assert.equal(steering(c), -1);
key(c, 'ArrowUp', false); key(c, 'ArrowLeft', false);
// Il dito premuto: il punto dove preme è lo zero, di lato si sterza; un altro puntatore non sposta niente.
aimScreen(c, [300, 100], 9); press(c, 9);
aimScreen(c, [314, 100], 9); assert.equal(steering(c), 0.5);
aimScreen(c, [250, 100], 4); assert.equal(steering(c), 0.5);
release(c, 9); assert.equal(steering(c), 0, 'lasciando il dito lo sterzo torna dritto');
const a = player(c).a; key(c, 'ArrowUp', true); key(c, 'ArrowRight', true); run(c, 0.4); assert.notEqual(player(c).a, a, 'sterzando si gira');
assert.equal(key(c, 'x', true), false, 'gli altri tasti non sono suoi');
});
test('la presentazione: uno alla volta arrivano in griglia col motorino, poi il semaforo', () => {
const c = newGara(2, { riders: piloti });
assert.equal(c.step, 'intro'); assert.equal(presenting(c).id, piloti[0].id);
assert.ok(c.riders.every(r => !r.shown), 'all\'inizio la griglia è vuota');
assert.match(status(c), new RegExp(`${piloti[0].name} con ${piloti[0].bike}`));
run(c, INTRO.each + INTRO.ride / 2);
assert.ok(c.riders[0].shown && c.riders[1].shown && !c.riders[2].shown);
assert.equal(c.riders[0].s, c.riders[0].grid, 'il primo è già al suo posto');
assert.ok(c.riders[1].s < c.riders[1].grid, 'il secondo sta arrivando');
assert.equal(presenting(c).id, piloti[1].id);
assert.ok(c.heard.has('gara-motore:' + piloti[0].id) && c.heard.has('gara-motore:' + piloti[1].id));
run(c, piloti.length * INTRO.each + INTRO.after - (INTRO.each + INTRO.ride / 2) + 0.05);
assert.equal(c.step, 'start');
assert.ok(c.riders.every(r => r.shown && r.s === r.grid && r.v === 0), 'tutti fermi in griglia');
// Un tocco: chi arriva è subito al suo posto, e tocca al prossimo.
const d = newGara(2, { riders: piloti });
run(d, 0.3); assert.ok(d.riders[0].s < d.riders[0].grid);
assert.ok(press(d)); run(d, 0.01);
assert.equal(presenting(d).id, piloti[1].id); assert.equal(d.riders[0].s, d.riders[0].grid);
assert.equal(d.step, 'intro'); assert.equal(button(d), 'Avanti');
});
test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si vince', () => {
const wild = newGara(5, { level: 'bona', riders: piloti });
skip(wild);
run(wild, COUNTDOWN + 60, () => true);
assert.ok(wild.heard.has('gara-fuori:andrea'));
assert.ok(Math.abs(player(wild).lat) <= VERGE, 'la paglia rimanda in strada');
assert.equal(wild.step, 'race', 'dritto non si arriva');
const heard = new Set();
for (const seed of [1, 2, 3, 4]) {
const c = newGara(seed, { level: 'bona', riders: piloti });
skip(c);
for (const k of run(c, COUNTDOWN + 120, careful)) heard.add(k);
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player', `seme ${seed}`);
assert.equal(c.result.order[0], 'andrea'); assert.match(status(c), /Primo al GP di Fortezza!/);
assert.equal(Object.keys(c.result.times).length, piloti.length);
}
assert.ok(['gara-giro:andrea', 'gara-arrivo:andrea', 'gara-turbo:andrea', 'gara-miscela:andrea'].every(k => heard.has(k)), [...heard].join());
assert.ok(CURVES.length >= 2 && TRACK.length > 600, 'una pista con delle curve vere');
});
test('le buche: piano si sobbalza, forte si casca; la lattina di miscela dà lo sprint', () => {
const hole = PICKUPS.find(p => p.kind === 'buca'), can = PICKUPS.find(p => p.kind === 'miscela');
for (const [speed, fell] of [[20, false], [80, true]]) {
const c = newGara(1, { riders: RIDERS }), r = player(c);
c.step = 'race'; Object.assign(r, { s: hole.s - 4, idx: Math.floor(hole.s / 2), v: speed, a: hole.heading });
const p = pointAt(hole.s - 4, hole.lane); r.x = p.x; r.y = p.y;
const heard = run(c, 0.2);
assert.equal(r.falls, fell ? 1 : 0, `a ${speed}`);
assert.ok(heard.has(fell ? 'gara-buca:andrea' : 'gara-sobbalzo:andrea'));
}
const c = newGara(1, { riders: RIDERS }), r = player(c);
c.step = 'race'; Object.assign(r, { s: can.s - 3, idx: Math.floor(can.s / 2), v: 40, a: can.heading });
const p = pointAt(can.s - 3, can.lane); r.x = p.x; r.y = p.y;
run(c, 0.2);
assert.ok(r.boost > 0 && c.taken[can.id] !== undefined, 'la miscela dà lo sprint');
});
test('la classifica: chi è arrivato davanti, poi per strada fatta', () => {
const c = newGara(1, { riders: piloti });
c.riders[0].s = 50; c.riders[1].s = 80; c.riders[2].finished = 30;
assert.deepEqual(standings(c).slice(0, 3).map(r => r.id), [c.riders[2].id, c.riders[1].id, c.riders[0].id]);
});
test('nella storia: dal Trappola l\'Arcu scherza sul Betino, poi la sfida; prima del via prende la rincorsa; la vittoria lascia il flag', () => {
const g = new Game(story);
g.go('via_liguri_magelli', [352, 256]); settle(g);
g.act('talk', 'arcu'); settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('Ma ti ho visto du')));
const joke = settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`);
assert.ok(joke.includes('arcu: In salita si spinge.'));
g.choose(g.options().findIndex(o => o.text.startsWith('Si fa una gara coi motorini?'))); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Seria.'));
const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('prendo la rincorsa')));
assert.equal(g.closeup?.id, 'gara'); assert.equal(g.closeup.level, 'seria');
assert.deepEqual(g.closeup.riders.map(r => r.id), piloti.filter(p => p.consenso !== null).map(p => p.id));
assert.ok(!g.closeup.riders.some(r => r.id === 'puntella'), 'senza consenso non corre');
g.closeup.result = { winner: 'player', place: 1, times: {}, order: ['andrea'], offs: 0 }; g.closeup.step = 'result';
g.closeupClose(); const after = settle(g).map(s => s.text);
assert.ok(g.state.flags.gara_vinta);
assert.ok(after.some(t => t.includes('Primo!')));
// Il Betino, lassù: solo Andrea ne parla.
g.act('use', 'betino'); assert.ok(settle(g).every(s => s.who === 'andrea'));
});
+15 -8
View File
@@ -6,12 +6,16 @@ import { Game } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js'; import { inside } from '../src/engine/geometry.js';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli altri hotspot', () => { // La Ghiacciomobile al circolino è uno sprite; il Betino in cima alla salita di Via Liguri Magelli è dipinto nel quadro.
const SPRITES = [['campomigliaio_circolo', 'ghiacciomobile', [150, 170]]];
const VEHICLES = [...SPRITES, ['via_liguri_magelli', 'betino']];
test('i veicoli restano fuori dal percorso e lasciano liberi gli altri hotspot', () => {
for (const [scene, id, [lo, hi]] of SPRITES) {
const g = new Game(story); const g = new Game(story);
g.go('campomigliaio_circolo'); g.go(scene);
settle(g); settle(g);
const vehicles = ['betino', 'ghiacciomobile']; const vehicles = [id];
for (const id of vehicles) { {
const person = g.people().find(p => p.who === id), h = g.hotspot(id); const person = g.people().find(p => p.who === id), h = g.hotspot(id);
const url = new URL('../' + story.characters[id].sprite, import.meta.url); const url = new URL('../' + story.characters[id].sprite, import.meta.url);
const atlas = JSON.parse(readFileSync(url, 'utf8')), frame = atlas.frames.idle; const atlas = JSON.parse(readFileSync(url, 'utf8')), frame = atlas.frames.idle;
@@ -19,7 +23,7 @@ test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli a
assert.deepEqual(Object.keys(atlas.frames), ['idle']); assert.deepEqual(Object.keys(atlas.frames), ['idle']);
const k = 62 * g.scaleAt(person.at[1]) * person.size / atlas.height; const k = 62 * g.scaleAt(person.at[1]) * person.size / atlas.height;
const width = frame.w * k; const width = frame.w * k;
assert.ok(id === 'betino' ? width >= 70 && width <= 80 : width >= 150 && width <= 170); assert.ok(width >= lo && width <= hi, `${id}: ${width}`);
// Tutto l'ingombro (non soltanto l'ancora) deve restare fuori dall'area calpestabile. // Tutto l'ingombro (non soltanto l'ancora) deve restare fuori dall'area calpestabile.
for (let x = h.box[0]; x <= h.box[0] + h.box[2]; x++) for (let x = h.box[0]; x <= h.box[0] + h.box[2]; x++)
for (let y = h.box[1]; y <= h.box[1] + h.box[3]; y++) for (let y = h.box[1]; y <= h.box[1] + h.box[3]; y++)
@@ -34,14 +38,16 @@ test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli a
assert.equal(g.personPose(person), 'idle'); assert.equal(g.personPose(person), 'idle');
} }
} }
}
}); });
test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () => { test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () => {
for (const [scene, id] of VEHICLES) {
const g = new Game(story); const g = new Game(story);
g.go('campomigliaio_circolo'); g.go(scene);
settle(g); settle(g);
const flags = { ...g.state.flags }, inventory = [...g.state.inventory]; const flags = { ...g.state.flags }, inventory = [...g.state.inventory];
for (const id of ['betino', 'ghiacciomobile']) { {
const h = g.hotspot(id); const h = g.hotspot(id);
assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, id); assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, id);
for (const verb of ['look', 'use']) { for (const verb of ['look', 'use']) {
@@ -51,7 +57,8 @@ test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () =
assert.deepEqual([g.state.x, g.state.y], h.at); assert.deepEqual([g.state.x, g.state.y], h.at);
assert.deepEqual(g.state.flags, flags); assert.deepEqual(g.state.flags, flags);
assert.deepEqual(g.state.inventory, inventory); assert.deepEqual(g.state.inventory, inventory);
assert.equal(g.state.scene, 'campomigliaio_circolo'); assert.equal(g.state.scene, scene);
} }
} }
}
}); });
+1 -1
View File
@@ -29,7 +29,7 @@
"speaker": "Gitta Nikolina" "speaker": "Gitta Nikolina"
}, },
"arcu": { "arcu": {
"speaker": "Luis Moray" "speaker": "Ferran Simen"
}, },
"tozzino": { "tozzino": {
"speaker": "Xavier Hayasaka", "speaker": "Xavier Hayasaka",