Lavori in corso (non pubblicati): Bertovo al tavolo di mezzo coi rinquarti (Codex), Bini, Yuri, il Gianno e la Brauda in gara, l'Audi «Let it be», sensibilità e riquadro dello swipe, pulsanti su iPhone (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"image": "bertovo.png",
|
||||||
|
"height": 120,
|
||||||
|
"frames": {
|
||||||
|
"stand": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 53,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 27.4,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"x": 55,
|
||||||
|
"y": 0,
|
||||||
|
"w": 52,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 26.1,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"talk": {
|
||||||
|
"x": 109,
|
||||||
|
"y": 0,
|
||||||
|
"w": 79,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 31.3,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"x": 190,
|
||||||
|
"y": 0,
|
||||||
|
"w": 58,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 30.2,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"laugh": {
|
||||||
|
"x": 250,
|
||||||
|
"y": 0,
|
||||||
|
"w": 54,
|
||||||
|
"h": 122,
|
||||||
|
"ax": 28.6,
|
||||||
|
"ay": 122
|
||||||
|
},
|
||||||
|
"chalk": {
|
||||||
|
"x": 306,
|
||||||
|
"y": 0,
|
||||||
|
"w": 55,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 25.0,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"shrug": {
|
||||||
|
"x": 363,
|
||||||
|
"y": 0,
|
||||||
|
"w": 91,
|
||||||
|
"h": 122,
|
||||||
|
"ax": 45.4,
|
||||||
|
"ay": 122
|
||||||
|
},
|
||||||
|
"cheer": {
|
||||||
|
"x": 456,
|
||||||
|
"y": 0,
|
||||||
|
"w": 58,
|
||||||
|
"h": 125,
|
||||||
|
"ax": 31.0,
|
||||||
|
"ay": 125
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 235 KiB |
@@ -935,6 +935,10 @@
|
|||||||
"file": "andrea-127a90af53.mp3",
|
"file": "andrea-127a90af53.mp3",
|
||||||
"sec": 9.37
|
"sec": 9.37
|
||||||
},
|
},
|
||||||
|
"andrea|Bertovo, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.": {
|
||||||
|
"file": "andrea-0b28b74cdb.mp3",
|
||||||
|
"sec": 6.78
|
||||||
|
},
|
||||||
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
|
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
|
||||||
"file": "andrea-1eae1a8577.mp3",
|
"file": "andrea-1eae1a8577.mp3",
|
||||||
"sec": 6.1
|
"sec": 6.1
|
||||||
@@ -1603,6 +1607,18 @@
|
|||||||
"file": "andrea-a93c65234e.mp3",
|
"file": "andrea-a93c65234e.mp3",
|
||||||
"sec": 4.28
|
"sec": 4.28
|
||||||
},
|
},
|
||||||
|
"andrea|Di chi è quell'Audi nera?": {
|
||||||
|
"file": "andrea-b9a784137e.mp3",
|
||||||
|
"sec": 1.63
|
||||||
|
},
|
||||||
|
"arcu|Let it be.": {
|
||||||
|
"file": "arcu-6ba2f5285a.mp3",
|
||||||
|
"sec": 0.69
|
||||||
|
},
|
||||||
|
"andrea|Non è mica mia. Mi garba, però, nera fiammante.": {
|
||||||
|
"file": "andrea-ae928089b9.mp3",
|
||||||
|
"sec": 4.03
|
||||||
|
},
|
||||||
"andrea|«Qui riposa la Ghiacciomobile, che si è schiantata lì.»": {
|
"andrea|«Qui riposa la Ghiacciomobile, che si è schiantata lì.»": {
|
||||||
"file": "andrea-a9e7f3f9ad.mp3",
|
"file": "andrea-a9e7f3f9ad.mp3",
|
||||||
"sec": 3.11
|
"sec": 3.11
|
||||||
@@ -2411,6 +2427,70 @@
|
|||||||
"file": "mariano-d65fb6e871.mp3",
|
"file": "mariano-d65fb6e871.mp3",
|
||||||
"sec": 3.42
|
"sec": 3.42
|
||||||
},
|
},
|
||||||
|
"andrea|Bertovo! Che fai, giochi da solo?": {
|
||||||
|
"file": "andrea-2eaaaffa37.mp3",
|
||||||
|
"sec": 3
|
||||||
|
},
|
||||||
|
"andrea|Come fai a prendere le biglie dove nessuno ci arriva?": {
|
||||||
|
"file": "andrea-c6670ffb48.mp3",
|
||||||
|
"sec": 2.52
|
||||||
|
},
|
||||||
|
"andrea|Dai, fammi vedere un rinquarto.": {
|
||||||
|
"file": "andrea-728f589a77.mp3",
|
||||||
|
"sec": 1.68
|
||||||
|
},
|
||||||
|
"andrea|Ci si vede, Bertovo.": {
|
||||||
|
"file": "andrea-a3364fd72b.mp3",
|
||||||
|
"sec": 1.78
|
||||||
|
},
|
||||||
|
"bertovo|Da solo? Io gioco contro le sponde. E vinco quasi sempre.": {
|
||||||
|
"file": "bertovo-5e771069ff.mp3",
|
||||||
|
"sec": 5.67
|
||||||
|
},
|
||||||
|
"bertovo|Geometria. La biglia va dove le dici, basta chiederglielo per bene.": {
|
||||||
|
"file": "bertovo-914554e2ce.mp3",
|
||||||
|
"sec": 4.82
|
||||||
|
},
|
||||||
|
"bertovo|E con un po' di gesso.": {
|
||||||
|
"file": "bertovo-6c52f3fa0d.mp3",
|
||||||
|
"sec": 1.52
|
||||||
|
},
|
||||||
|
"bertovo|Te lo faccio vedere? No: lo tiri te.": {
|
||||||
|
"file": "bertovo-fde93190e9.mp3",
|
||||||
|
"sec": 3.12
|
||||||
|
},
|
||||||
|
"bertovo|Le biglie le metto io, nascoste per bene. Oh tirami questo rinquarto!?": {
|
||||||
|
"file": "bertovo-a6a09b5b87.mp3",
|
||||||
|
"sec": 5.83
|
||||||
|
},
|
||||||
|
"bertovo|Occhio alle sponde, fuori.": {
|
||||||
|
"file": "bertovo-4d4d630038.mp3",
|
||||||
|
"sec": 1.43
|
||||||
|
},
|
||||||
|
"andrea|Alla bona, una sponda e basta.": {
|
||||||
|
"file": "andrea-8b3082981a.mp3",
|
||||||
|
"sec": 2.47
|
||||||
|
},
|
||||||
|
"andrea|No, mi tremano le mani.": {
|
||||||
|
"file": "andrea-28610c8178.mp3",
|
||||||
|
"sec": 1.23
|
||||||
|
},
|
||||||
|
"bertovo|Una sponda. Roba da principianti, ma va bene.": {
|
||||||
|
"file": "bertovo-d14be7e027.mp3",
|
||||||
|
"sec": 3.6
|
||||||
|
},
|
||||||
|
"bertovo|Due sponde. Così mi piace.": {
|
||||||
|
"file": "bertovo-7e1952241d.mp3",
|
||||||
|
"sec": 2.55
|
||||||
|
},
|
||||||
|
"bertovo|Tre sponde e niente aiuti. Qui nascono le geometrie celestiali.": {
|
||||||
|
"file": "bertovo-b582e39e44.mp3",
|
||||||
|
"sec": 4.86
|
||||||
|
},
|
||||||
|
"bertovo|Il tavolo è qui. Le sponde non scappano.": {
|
||||||
|
"file": "bertovo-f0d200f449.mp3",
|
||||||
|
"sec": 2.92
|
||||||
|
},
|
||||||
"andrea|Ingegnere, una partita ai birilli?": {
|
"andrea|Ingegnere, una partita ai birilli?": {
|
||||||
"file": "andrea-dbcf4d1083.mp3",
|
"file": "andrea-dbcf4d1083.mp3",
|
||||||
"sec": 2.49
|
"sec": 2.49
|
||||||
@@ -2675,6 +2755,22 @@
|
|||||||
"file": "ing_tozzi-282062c119.mp3",
|
"file": "ing_tozzi-282062c119.mp3",
|
||||||
"sec": 3.64
|
"sec": 3.64
|
||||||
},
|
},
|
||||||
|
"bertovo|Questo rinquarto era bono!": {
|
||||||
|
"file": "bertovo-d71d01230d.mp3",
|
||||||
|
"sec": 1.26
|
||||||
|
},
|
||||||
|
"bertovo|Le sponde ci sono per qualcosa!": {
|
||||||
|
"file": "bertovo-a63d2da89d.mp3",
|
||||||
|
"sec": 1.44
|
||||||
|
},
|
||||||
|
"andrea|Questo me lo tengo per la prossima.": {
|
||||||
|
"file": "andrea-c64bb3be5a.mp3",
|
||||||
|
"sec": 1.82
|
||||||
|
},
|
||||||
|
"bertovo|Si riprova?": {
|
||||||
|
"file": "bertovo-03564e5a92.mp3",
|
||||||
|
"sec": 0.63
|
||||||
|
},
|
||||||
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
|
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
|
||||||
"file": "andrea-6484641c58.mp3",
|
"file": "andrea-6484641c58.mp3",
|
||||||
"sec": 8.67
|
"sec": 8.67
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
|||||||
|
|
||||||
## Dove siamo arrivati
|
## Dove siamo arrivati
|
||||||
|
|
||||||
|
- **I rinquarti di Bertovo (28 ottobre, modifiche locali)**: variante del biliardo in `rinquarto.js` e
|
||||||
|
`rinquarto-render.js`, con fisica, mira, carica e suoni del biliardo. Sei posizioni riproducibili dal seme,
|
||||||
|
diretto coperto da una biglia e almeno una soluzione di sponda verificata con `simulate` per ciascuna.
|
||||||
|
Un tiro per posizione: punto al primo contatto col bersaglio dopo almeno 1/2/3 sponde (bona/seria/male),
|
||||||
|
senza prima toccare biglie viola o birilli. Guide: fino alla seconda sponda / 65 cm e prima sponda / nessuna;
|
||||||
|
vittoria con 3/3/2 punti su 6. Esito del tiro, contatore delle sponde e riepilogo finale persistente.
|
||||||
|
Integrati primo piano, pannello, aiuto e dialogo `bertovo` con scelta del livello; Bertovo resta senza sprite
|
||||||
|
e senza scena: **Andrea decide dove collocarlo**. Battute provvisorie segnate `[DA ANDREA]`.
|
||||||
|
Ripresa verificata con `node --test tests/*.test.mjs` (22 file, nessun rosso, comprese le voci già presenti)
|
||||||
|
e 14 test mirati in `tests/rinquarto.test.mjs`. Completata l'animazione dei birilli durante l'esito
|
||||||
|
e nel riepilogo finale: anche un urto appena prima dell'arresto termina la caduta senza cambiare punti o risultato.
|
||||||
|
Verifica visiva Playwright/SwiftShader ancora da eseguire: in questa sessione la sandbox impedisce l'avvio
|
||||||
|
del server locale e di Chromium (`Operation not permitted`). Nessun `make voci`, commit o pubblicazione.
|
||||||
|
|
||||||
- **Briscola all'oratorio (26 ottobre)**: alle tavolate siede «Il giocatore di carte» (segnaposto, [DA ANDREA: chi è],
|
- **Briscola all'oratorio (26 ottobre)**: alle tavolate siede «Il giocatore di carte» (segnaposto, [DA ANDREA: chi è],
|
||||||
sprite e battute provvisorie di Codex). Parlandoci si sceglie il mazzo (toscane o napoletane) e il livello
|
sprite e battute provvisorie di Codex). Parlandoci si sceglie il mazzo (toscane o napoletane) e il livello
|
||||||
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
|
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
|
||||||
@@ -257,7 +271,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
|||||||
`assets/raw/fortezza-planimetria.png`, satellite in `assets/raw/fortezza-satellite.png`); il tracciato è ricalcato
|
`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
|
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
|
`src/story/piloti.js`: una riga per amico, `motore` e `testa` da 1 a 5, colori dalle foto. Corrono Puntella (SR
|
||||||
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), il Porno (Andrea
|
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), Bini (Andrea
|
||||||
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
|
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
|
||||||
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
|
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
|
||||||
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
|
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
|
||||||
@@ -268,6 +282,8 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
|||||||
ponticino della Carza, a sinistra il Trappola. Il campo del Giannini: a destra le villette, a sinistra il Trappola;
|
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.
|
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].
|
Da sapere: dove si gira, in fondo al ponticino, per Cardetole [DA ANDREA].
|
||||||
|
- Bertovo (Fabrizio Berti, consenso del 28 ottobre) al tavolo di mezzo del Circolo 4 Maggio, sprite di Codex dalla sua
|
||||||
|
foto (`prompts/personaggi/bertovo-sheet.txt`): parlandoci, i suoi rinquarti (`src/engine/rinquarto.js`, Codex).
|
||||||
- Via Liguri Magelli (Street View di Andrea, 28 ottobre), a sinistra dal campo del Giannini: a destra la casa salmone
|
- 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
|
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
|
(scherzo: «ti ho visto du' minuti fa a Campomigliaio» col Betino, poi la sfida del GP), davanti la targa «Qui riposa la
|
||||||
|
|||||||
@@ -192,6 +192,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
||||||
<p id="bocce-status" role="status"></p>
|
<p id="bocce-status" role="status"></p>
|
||||||
|
<!-- Il GP di Fortezza sul telefono: lo swipe qui, così il dito non copre la pista. -->
|
||||||
|
<div id="gara-pad" hidden role="application" aria-label="Zona dello swipe: tieni premuto per il gas e trascina verso dove vuoi andare">
|
||||||
|
Tieni premuto qui per il gas e trascina verso dove vuoi andare
|
||||||
|
</div>
|
||||||
<div class="bocce-adjust">
|
<div class="bocce-adjust">
|
||||||
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
|
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
|
||||||
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
|
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
|
||||||
@@ -228,6 +232,8 @@
|
|||||||
<p>Con la tastiera: Spazio, Invio o ↑ per il gas, ← → per sterzare; oppure tieni premuti i pulsanti Sterza.
|
<p>Con la tastiera: Spazio, Invio o ↑ per il gas, ← → per sterzare; oppure tieni premuti i pulsanti Sterza.
|
||||||
↓ (o il pulsante Frena, tenuto premuto) frena, anche col gas dato. Dal pannello il
|
↓ (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>
|
pulsante alterna Gas e Molla. Durante la presentazione dei piloti un tocco passa al prossimo.</p>
|
||||||
|
<p>Sul telefono lo swipe si fa anche nel riquadro sotto la pista, così il dito non la copre. Il cursore
|
||||||
|
Sensibilità decide quanto sterza lo swipe: parte basso, alzalo se il motorino gira poco.</p>
|
||||||
<p>Le frecce gialle in terra sono acceleratori; le lattine rosse di miscela danno uno sprint (e ricompaiono); nelle
|
<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
|
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>
|
in rosso dove frenare, Seria solo i coni, Ginocchio in terra niente. Esc esce.</p>
|
||||||
@@ -243,6 +249,21 @@
|
|||||||
il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale
|
il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale
|
||||||
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p>
|
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p>
|
||||||
</details>
|
</details>
|
||||||
|
<details id="rinquarto-help" hidden><summary>Come si gioca</summary>
|
||||||
|
<p>I rinquarti di Bertovo: sei posizioni, un tiro per posizione. La tua biglia è bianca, il bersaglio giallo.
|
||||||
|
Le biglie viola con la croce e i birilli sono disturbi: toccarne uno prima del bersaglio annulla il tiro.
|
||||||
|
La linea diretta è sempre coperta, ma esiste sempre almeno un tiro valido di sponda.</p>
|
||||||
|
<p>Un punto se la bianca tocca il bersaglio dopo almeno il numero di sponde richiesto.
|
||||||
|
Conta il primo contatto: arrivare al bersaglio troppo presto non vale, anche se lo ritocchi dopo.
|
||||||
|
Alla bona: una sponda, guida fino al secondo rimbalzo, gloria con 3 punti su 6.
|
||||||
|
Seria: due sponde, guida corta (65 cm al massimo, fino alla prima sponda), gloria con 3 punti su 6.
|
||||||
|
A fassi male: tre sponde, nessuna guida, gloria con 2 punti su 6.
|
||||||
|
La guida si ferma sempre al primo ostacolo o quando la biglia si arresta.</p>
|
||||||
|
<p>Muovi il mouse per mirare; clic o tocco fissa la mira. Frecce ← → per affinarla, Maiusc per passi più piccoli;
|
||||||
|
↑ ↓ o cursore per la forza, poi Tira. Oppure tieni premuto sul tavolo, Spazio o Invio e rilascia per tirare.
|
||||||
|
Durante la carica la mira resta ferma. Dopo il tiro leggi Punto! o Niente; la posizione seguente arriva da sé.
|
||||||
|
Esc esce; alla fine il riepilogo resta finché torni da Bertovo.</p>
|
||||||
|
</details>
|
||||||
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
||||||
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
||||||
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export function tablePatches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
|
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
|
||||||
function sphere(ctx, role, x, y, r, shadow = true) {
|
export function sphere(ctx, role, x, y, r, shadow = true) {
|
||||||
const [dark, mid, light] = BALL[role];
|
const [dark, mid, light] = BALL[role] ?? ['#43285f', '#bd83ef', '#f7e4ff'];
|
||||||
const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
|
const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
|
||||||
const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
|
const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
|
||||||
if (shadow) {
|
if (shadow) {
|
||||||
@@ -50,7 +50,7 @@ export function pinFall(p, time) {
|
|||||||
if (p.visualDownAt === undefined) return 1;
|
if (p.visualDownAt === undefined) return 1;
|
||||||
return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
|
return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
|
||||||
}
|
}
|
||||||
function pin(ctx, p, time) {
|
export function pin(ctx, p, time) {
|
||||||
const x = sx(p.x), y = sy(p.y), fall = pinFall(p, time);
|
const x = sx(p.x), y = sy(p.y), fall = pinFall(p, time);
|
||||||
const tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2);
|
const tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2);
|
||||||
const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
|
const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
|
||||||
@@ -65,7 +65,7 @@ function pin(ctx, p, time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lighting;
|
let lighting;
|
||||||
function table(ctx, bg) {
|
export function table(ctx, bg) {
|
||||||
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
|
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
|
||||||
else {
|
else {
|
||||||
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
|
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
|
||||||
@@ -117,7 +117,7 @@ function aimGuide(ctx, c) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cue(ctx, x, y, angle, back, alpha = 1) {
|
export function cue(ctx, x, y, angle, back, alpha = 1) {
|
||||||
ctx.save(); ctx.globalAlpha = alpha;
|
ctx.save(); ctx.globalAlpha = alpha;
|
||||||
ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
|
ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
|
||||||
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
|
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|||||||
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||||
const deviation = c => random(c) + random(c) + random(c) - 1.5;
|
const deviation = c => random(c) + random(c) + random(c) - 1.5;
|
||||||
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
||||||
const list = c => [c.balls.player, c.balls.cpu, c.balls.pallino];
|
const list = c => Object.values(c.balls);
|
||||||
const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x);
|
const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x);
|
||||||
|
|
||||||
// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno.
|
// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno.
|
||||||
@@ -179,12 +179,12 @@ function strike(c, who, angle, power) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
||||||
export function simulate(c, who, angle, power) {
|
export function simulate(c, who, angle, power, { onContact, stop } = {}) {
|
||||||
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
|
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
|
||||||
const w = { balls, pins, shot: { shooter: who, first: null, pallino: false, pins: [] } };
|
const w = { balls, pins, onContact, shot: { shooter: who, first: null, pallino: false, pins: [] } };
|
||||||
const b = balls.find(x => x.role === who), v = speedFor(power);
|
const b = balls.find(x => x.role === who), v = speedFor(power);
|
||||||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
||||||
for (let i = 0; i < 15 / FIXED_STEP && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
|
for (let i = 0; i < 15 / FIXED_STEP && !stop?.() && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
|
||||||
return judge(w.shot);
|
return judge(w.shot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ function cpuThink(c, budget = 12) {
|
|||||||
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function update(c, dt) {
|
export function update(c, dt, onContact) {
|
||||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||||
dt = Math.min(dt, 0.25); c.t += dt;
|
dt = Math.min(dt, 0.25); c.t += dt;
|
||||||
c.visual.time += dt;
|
c.visual.time += dt;
|
||||||
@@ -255,7 +255,7 @@ export function update(c, dt) {
|
|||||||
}
|
}
|
||||||
if (c.step === 'rolling' && !c.windup) {
|
if (c.step === 'rolling' && !c.windup) {
|
||||||
c.accumulator += dt;
|
c.accumulator += dt;
|
||||||
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
|
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events, onContact };
|
||||||
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||||
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||||
} else if (c.step === 'settle' && c.t >= REST) finish(c);
|
} else if (c.step === 'settle' && c.t >= REST) finish(c);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||||
import * as bocce from './bocce.js';
|
import * as bocce from './bocce.js';
|
||||||
import * as biliardo from './biliardo.js';
|
import * as biliardo from './biliardo.js';
|
||||||
|
import * as rinquarto from './rinquarto.js';
|
||||||
import * as basket from './basket.js';
|
import * as basket from './basket.js';
|
||||||
import * as pesca from './pesca.js';
|
import * as pesca from './pesca.js';
|
||||||
import * as briscola from './briscola.js';
|
import * as briscola from './briscola.js';
|
||||||
@@ -17,6 +18,7 @@ export const MINIGAMES = {
|
|||||||
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
||||||
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||||
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 },
|
||||||
|
rinquarto: { create: (seed, level) => rinquarto.newRinquarto(seed, { level }), update: rinquarto.update, press: rinquarto.press, release: rinquarto.release, cancel: rinquarto.cancel, aim: rinquarto.aimScreen },
|
||||||
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
|
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
|
||||||
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
|
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
|
||||||
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
|
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export const LAPS = 3, STEP = 2, ROAD = 9, VERGE = 15, COUNTDOWN = 3, FIXED_STEP
|
|||||||
export const INTRO = { each: 4, ride: 1.8, from: 110, after: 1 };
|
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.
|
// 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 TURN = 3.4, BRAKE = 150, COAST = 45, GRIP = 9, SLIDE = 0.5;
|
||||||
export const SWIPE = { dead: 10, reach: 60, gain: 2.6 };
|
// Lo swipe: zona morta e portata in pixel della scena; la sensibilità (0..1, cursore del pannello) va dal guadagno
|
||||||
|
// `soft` al `hard`, e lo sterzo del dito insegue quello voluto a `follow` al secondo (niente scatti). Sul telefono
|
||||||
|
// la prima versione era ingiocabile: si parte poco sensibili.
|
||||||
|
export const SWIPE = { dead: 14, reach: 60, soft: 1.2, hard: 3, follow: 7, sens: 0.3 };
|
||||||
export const BOOST = { speed: 1.35, time: 1.2 }, CAN = { time: 1.6, respawn: 6 }, HOLE = { fast: 45, out: 1.1 };
|
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 clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
|
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
|
||||||
@@ -49,7 +52,7 @@ export const RIDERS = [
|
|||||||
];
|
];
|
||||||
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista.
|
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista.
|
||||||
export const LEVELS = {
|
export const LEVELS = {
|
||||||
bona: { rivals: 0.78, stun: 0.4, guide: 'zone' },
|
bona: { rivals: 0.74, stun: 0.4, guide: 'zone' },
|
||||||
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
|
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
|
||||||
male: { rivals: 0.93, stun: 1.4, guide: null },
|
male: { rivals: 0.93, stun: 1.4, guide: null },
|
||||||
};
|
};
|
||||||
@@ -152,16 +155,18 @@ export function newGara(seed = Date.now() >>> 0, def = {}) {
|
|||||||
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
|
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
|
||||||
for (const r of riders) { r.grid = r.s; r.shown = false; }
|
for (const r of riders) { r.grid = r.s; r.shown = false; }
|
||||||
return { step: 'intro', t: 0, age: 0, seed, level, skill, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
|
return { step: 'intro', t: 0, age: 0, seed, level, skill, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
|
||||||
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null,
|
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null, sens: SWIPE.sens,
|
||||||
power: 0, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
|
power: SWIPE.sens, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
|
||||||
}
|
}
|
||||||
export const player = c => c.riders.find(r => r.id === 'andrea');
|
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 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 canThrow = c => c.step === 'intro' || c.step === 'start' || c.step === 'race';
|
||||||
export const canAdjust = c => c.step === 'race'; // lo sterzo del pannello
|
// Lo sterzo e il freno del pannello, e il cursore della sensibilità (regolabile già prima del via).
|
||||||
|
export const canAdjust = c => c.step !== 'result';
|
||||||
export const aimFree = () => true;
|
export const aimFree = () => true;
|
||||||
export const nudge = () => false;
|
export const nudge = () => false;
|
||||||
export const setPower = () => false;
|
// Il cursore del pannello, nella gara, è la sensibilità dello sterzo col dito.
|
||||||
|
export function setPower(c, value) { c.sens = c.power = clamp(Number(value) || 0, 0, 1); return true; }
|
||||||
|
|
||||||
// Il gas ha più fonti, ognuna con la sua: i tasti tenuti giù (Spazio, Invio, ↑), il dito o il mouse premuto sulla
|
// 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.
|
// scena (uno solo, col suo pointerId) e il pulsante Gas del pannello, che resta acceso finché non lo si ritocca.
|
||||||
@@ -238,12 +243,16 @@ export function key(c, name, down) {
|
|||||||
export function steerButton(c, dir) { c.buttons = dir; return true; }
|
export function steerButton(c, dir) { c.buttons = dir; return true; }
|
||||||
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
|
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
|
||||||
export function steering(c) {
|
export function steering(c) {
|
||||||
c.drag = 0;
|
let want = 0;
|
||||||
if (c.pointer !== null && c.anchor && c.finger) {
|
if (c.pointer !== null && c.anchor && c.finger) {
|
||||||
const dx = c.finger[0] - c.anchor[0], dy = c.finger[1] - c.anchor[1];
|
const dx = c.finger[0] - c.anchor[0], dy = c.finger[1] - c.anchor[1];
|
||||||
|
const gain = SWIPE.soft + (SWIPE.hard - SWIPE.soft) * (c.sens ?? SWIPE.sens);
|
||||||
// Si ricalcola anche col dito fermo: il motorino intanto cambia direzione.
|
// Si ricalcola anche col dito fermo: il motorino intanto cambia direzione.
|
||||||
if (Math.hypot(dx, dy) >= SWIPE.dead) c.drag = clamp(wrapAngle(Math.atan2(dy, dx) - player(c).a) * SWIPE.gain, -1, 1);
|
if (Math.hypot(dx, dy) >= SWIPE.dead) want = clamp(wrapAngle(Math.atan2(dy, dx) - player(c).a) * gain, -1, 1);
|
||||||
}
|
}
|
||||||
|
// Lo sterzo del dito si avvicina a quello voluto un po' per volta (a ogni passo fisso), e si azzera subito alzandolo.
|
||||||
|
c.drag = c.pointer === null ? 0 : c.drag + (want - c.drag) * Math.min(1, SWIPE.follow * FIXED_STEP);
|
||||||
|
if (Math.abs(want - c.drag) < 1e-6) c.drag = want;
|
||||||
return clamp((c.keys.right ? 1 : 0) - (c.keys.left ? 1 : 0) + c.buttons + c.drag, -1, 1);
|
return 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).
|
// La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ambientEffects, ambientHash } from './ambient.js';
|
|||||||
import { drawTorch } from './torch.js';
|
import { drawTorch } from './torch.js';
|
||||||
import { drawBocce } from './bocce-render.js';
|
import { drawBocce } from './bocce-render.js';
|
||||||
import { drawBiliardo } from './biliardo-render.js';
|
import { drawBiliardo } from './biliardo-render.js';
|
||||||
|
import { drawRinquarto } from './rinquarto-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 { drawGara } from './gara-render.js';
|
||||||
@@ -701,6 +702,11 @@ function drawCloseup(ctx, game, time) {
|
|||||||
drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket'));
|
drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (def.type === 'rinquarto') {
|
||||||
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
|
drawRinquarto(ctx, c, def, backgrounds.get(def.picture));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (def.type === 'biliardo') {
|
if (def.type === 'biliardo') {
|
||||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
|
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { LEN, WID, R, VIEW, WINDUP } from './biliardo.js';
|
||||||
|
import { table, sphere, pin, cue } from './biliardo-render.js';
|
||||||
|
import { guide, canThrow } from './rinquarto.js';
|
||||||
|
import { text, panel } from './bocce-render.js';
|
||||||
|
|
||||||
|
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
|
||||||
|
export function drawRinquarto(ctx, c, def, bg) {
|
||||||
|
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||||
|
table(ctx, bg);
|
||||||
|
ctx.save(); ctx.beginPath(); ctx.rect(VIEW.x - 15, VIEW.y - 15, LEN * VIEW.k + 30, WID * VIEW.k + 30); ctx.clip();
|
||||||
|
if (canThrow(c)) {
|
||||||
|
const points = guide(c);
|
||||||
|
if (points.length) {
|
||||||
|
ctx.strokeStyle = '#fff8dc'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx(points[0][0]), sy(points[0][1]));
|
||||||
|
for (const [x, y] of points.slice(1)) ctx.lineTo(sx(x), sy(y));
|
||||||
|
ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
const b = c.balls.player;
|
||||||
|
cue(ctx, b.x, b.y, c.angle, R * VIEW.k + 6 + c.power * 26);
|
||||||
|
} else if (c.visual.shot && c.windup > 0) {
|
||||||
|
const s = c.visual.shot;
|
||||||
|
cue(ctx, s.x, s.y, s.angle, R * VIEW.k + (6 + s.power * 26) * c.windup / WINDUP);
|
||||||
|
}
|
||||||
|
for (const p of c.pins) pin(ctx, p, c.visual.time);
|
||||||
|
for (const b of Object.values(c.balls)) {
|
||||||
|
sphere(ctx, b.role, sx(b.x), sy(b.y), R * VIEW.k);
|
||||||
|
// La croce distingue i disturbi anche senza riconoscere il colore viola.
|
||||||
|
if (!['player', 'cpu'].includes(b.role)) {
|
||||||
|
ctx.strokeStyle = '#2b163e'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx(b.x) - 2, sy(b.y) - 2); ctx.lineTo(sx(b.x) + 2, sy(b.y) + 2);
|
||||||
|
ctx.moveTo(sx(b.x) + 2, sy(b.y) - 2); ctx.lineTo(sx(b.x) - 2, sy(b.y) + 2); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
ctx.fillStyle = '#17160ff0'; ctx.fillRect(54, 4, 532, 64);
|
||||||
|
text(ctx, 'I RINQUARTI DI BERTOVO', 320, 13, 9, '#efd49e', 'center');
|
||||||
|
text(ctx, `Posizione ${c.position}/${c.skill.positions} Punti ${c.points}/${c.skill.glory} Sponde ${c.attempt.cushions}/${c.skill.cushions}`, 320, 28, 8, '#fff0c8', 'center');
|
||||||
|
text(ctx, canThrow(c) ? `Bianca: ${def.names.player} · Gialla: bersaglio · Viola e birilli: disturbi` : c.line, 320, 43, 7, '#d9ccba', 'center');
|
||||||
|
text(ctx, canThrow(c) ? `«Oh tirami questo rinquarto!?» Forza ${Math.round(c.power * 100)}%` : c.step === 'rolling' ? 'Biglie in movimento…' : 'Un tiro per posizione.', 320, 59, 8, '#9fd0ff', 'center');
|
||||||
|
if (c.step === 'settle' || c.step === 'result') {
|
||||||
|
const final = c.step === 'result';
|
||||||
|
panel(ctx, 155, 145, 330, final ? 96 : 58);
|
||||||
|
text(ctx, c.attempt.point ? 'Punto!' : 'Niente', 320, 164, 12, c.attempt.point ? '#b9e5b3' : '#ffbf9f', 'center');
|
||||||
|
text(ctx, final ? `${c.result.winner === 'player' ? 'GLORIA!' : 'SFIDA PERSA'} ${c.points} punti su ${c.skill.positions}` : c.attempt.point ? 'Rinquarto riuscito.' : c.attempt.reason, 320, 188, 8, '#efd49e', 'center');
|
||||||
|
if (final) text(ctx, `Obiettivo: ${c.skill.glory}. Torna da Bertovo per uscire.`, 320, 221, 7, '#d9ccba', 'center');
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// I rinquarti di Bertovo: posizioni coperte, un solo tentativo per posizione.
|
||||||
|
import * as billiard from './biliardo.js';
|
||||||
|
const { LEN, WID, R, PIN, simulate, predict } = billiard;
|
||||||
|
export { aimAt, aimScreen, nudge, setPower, press, release, cancel, shoot, canThrow } from './biliardo.js';
|
||||||
|
|
||||||
|
export const LEVELS = {
|
||||||
|
bona: { positions: 6, cushions: 1, glory: 3, guideBounces: 2, guideLength: Infinity },
|
||||||
|
seria: { positions: 6, cushions: 2, glory: 3, guideBounces: 1, guideLength: 65 },
|
||||||
|
male: { positions: 6, cushions: 3, glory: 2, guideBounces: 0, guideLength: 0 },
|
||||||
|
};
|
||||||
|
export const canAdjust = c => billiard.canThrow(c) && !c.holding;
|
||||||
|
export const aimFree = c => !billiard.canThrow(c);
|
||||||
|
export const button = c => c.step === 'result' ? 'Finita' : c.step === 'aim' ? 'Tira' : 'In movimento…';
|
||||||
|
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
||||||
|
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||||
|
const attempt = () => ({ cushions: 0, decided: false, point: false, reason: null });
|
||||||
|
|
||||||
|
// Conta solo le sponde della propria biglia prima del primo ostacolo.
|
||||||
|
function contact(result, required, { kind, a, b }) {
|
||||||
|
if (result.decided || (a.role !== 'player' && b?.role !== 'player')) return;
|
||||||
|
if (kind === 'rail') { result.cushions++; return; }
|
||||||
|
result.decided = true;
|
||||||
|
const other = a.role === 'player' ? b : a;
|
||||||
|
result.point = kind === 'ball' && other.role === 'cpu' && result.cushions >= required;
|
||||||
|
result.reason = result.point ? null : kind === 'ball' && other.role === 'cpu' ? 'poche sponde' : 'disturbo toccato';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryShot(c, angle, power) {
|
||||||
|
const result = attempt();
|
||||||
|
simulate(c, 'player', angle, power, {
|
||||||
|
onContact: event => contact(result, c.skill.cushions, event),
|
||||||
|
stop: () => result.decided,
|
||||||
|
});
|
||||||
|
if (!result.decided) result.reason = 'bersaglio mancato';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distanza dal segmento dei centri, con i raggi sommati (biglia in movimento inclusa).
|
||||||
|
export function directCovered(c) {
|
||||||
|
const a = c.balls.player, b = c.balls.cpu, dx = b.x - a.x, dy = b.y - a.y, d2 = dx * dx + dy * dy;
|
||||||
|
if (!d2) return false;
|
||||||
|
const obstacles = [...Object.values(c.balls).filter(b => !['player', 'cpu'].includes(b.role)).map(b => ({ ...b, radius: R })),
|
||||||
|
...c.pins.filter(p => !p.down).map(p => ({ ...p, radius: PIN }))];
|
||||||
|
return obstacles.some(p => {
|
||||||
|
const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / d2;
|
||||||
|
return t > 0 && t < 1 && Math.hypot(p.x - a.x - t * dx, p.y - a.y - t * dy) < R + p.radius;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function solve(c) {
|
||||||
|
// Angoli intercalati: si esplora subito tutto il tavolo, poi si affina a mezzo grado.
|
||||||
|
for (let offset = 0; offset < 8; offset++) for (let i = offset; i < 720; i += 8) {
|
||||||
|
const angle = i * Math.PI / 360;
|
||||||
|
for (const power of [1, 0.82, 0.65]) {
|
||||||
|
if (tryShot(c, angle, power).point) return { angle, power };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function position(c) {
|
||||||
|
for (;;) {
|
||||||
|
const place = role => ball(role, 20 + random(c) * (LEN - 40), 18 + random(c) * (WID - 36));
|
||||||
|
const player = place('player'), cpu = place('cpu');
|
||||||
|
if (Math.hypot(cpu.x - player.x, cpu.y - player.y) < 70) continue;
|
||||||
|
const t = 0.3 + random(c) * 0.4;
|
||||||
|
const blocker = ball('blocker', player.x + (cpu.x - player.x) * t, player.y + (cpu.y - player.y) * t);
|
||||||
|
c.balls = { player, cpu, blocker, extra: place('extra') };
|
||||||
|
c.pins = Array.from({ length: 2 }, () => ({ x: 20 + random(c) * (LEN - 40), y: 18 + random(c) * (WID - 36), red: false, down: false }));
|
||||||
|
const objects = [...Object.values(c.balls).map(b => ({ ...b, radius: R })), ...c.pins.map(p => ({ ...p, radius: PIN }))];
|
||||||
|
if (objects.some((a, i) => objects.slice(i + 1).some(b => Math.hypot(a.x - b.x, a.y - b.y) < a.radius + b.radius + 2))) continue;
|
||||||
|
if (!directCovered(c)) continue;
|
||||||
|
const solution = solve(c);
|
||||||
|
if (!solution) continue;
|
||||||
|
c.solution = solution;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
c.position++;
|
||||||
|
c.step = 'aim'; c.t = c.accumulator = 0; c.power = 0.75; c.holding = false; c.aimLocked = false;
|
||||||
|
c.shot = c.pending = c.visual.shot = null; c.windup = 0;
|
||||||
|
c.angle = Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x);
|
||||||
|
c.attempt = attempt();
|
||||||
|
c.line = 'Bertovo: «Oh tirami questo rinquarto!?»';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newRinquarto(seed = Date.now() >>> 0, def = {}) {
|
||||||
|
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||||
|
const c = { ...billiard.newBiliardo(seed), level, skill: LEVELS[level], position: 0, points: 0, history: [] };
|
||||||
|
position(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function update(c, dt) {
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||||
|
dt = Math.min(dt, 0.25);
|
||||||
|
if (c.step === 'result') {
|
||||||
|
// L’ultimo birillo può essere ancora in caduta: il riepilogo ferma la partita, non l’animazione.
|
||||||
|
c.visual.time += dt;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (c.step === 'settle') {
|
||||||
|
c.t += dt;
|
||||||
|
// I birilli finiscono di cadere anche mentre si legge l'esito del tiro.
|
||||||
|
c.visual.time += dt;
|
||||||
|
if (c.t >= 1.8) position(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
billiard.update(c, dt, event => contact(c.attempt, c.skill.cushions, event));
|
||||||
|
if (c.step !== 'settle') return;
|
||||||
|
if (!c.attempt.decided) c.attempt.reason = 'bersaglio mancato';
|
||||||
|
c.points += Number(c.attempt.point);
|
||||||
|
c.history.push({ ...c.attempt });
|
||||||
|
c.line = c.attempt.point ? 'Punto!' : `Niente: ${c.attempt.reason}.`;
|
||||||
|
if (c.position === c.skill.positions) {
|
||||||
|
c.step = 'result';
|
||||||
|
c.result = { winner: c.points >= c.skill.glory ? 'player' : 'cpu', points: c.points };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La guida si ferma al contatto, al limite di lunghezza o alla sponda consentita.
|
||||||
|
export function guide(c) {
|
||||||
|
if (!c.skill.guideLength) return [];
|
||||||
|
const source = predict(c).points, points = [source[0]];
|
||||||
|
let remaining = c.skill.guideLength;
|
||||||
|
for (const end of source.slice(1, c.skill.guideBounces + 1)) {
|
||||||
|
const start = points.at(-1), dx = end[0] - start[0], dy = end[1] - start[1], distance = Math.hypot(dx, dy);
|
||||||
|
if (distance > remaining) { points.push([start[0] + dx * remaining / distance, start[1] + dy * remaining / distance]); break; }
|
||||||
|
points.push(end); remaining -= distance;
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function status(c) {
|
||||||
|
const prefix = `Posizione ${c.position} di ${c.skill.positions}. Punti: ${c.points}.`;
|
||||||
|
if (c.step === 'result') return `${prefix} ${c.line} ${c.result.winner === 'player' ? 'Gloria!' : 'Sfida persa.'} Obiettivo: ${c.skill.glory}.`;
|
||||||
|
if (c.step === 'rolling') return `${prefix} Sponde: ${c.attempt.cushions}/${c.skill.cushions}.`;
|
||||||
|
return `${prefix} ${c.line} ${c.skill.cushions === 1 ? 'Serve almeno 1 sponda.' : `Servono almeno ${c.skill.cushions} sponde.`}`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Soundscape } from './engine/soundscape.js';
|
|||||||
import { soundscapeFor } from './story/soundscapes.js';
|
import { soundscapeFor } from './story/soundscapes.js';
|
||||||
import * as bocce from './engine/bocce.js';
|
import * as bocce from './engine/bocce.js';
|
||||||
import * as biliardo from './engine/biliardo.js';
|
import * as biliardo from './engine/biliardo.js';
|
||||||
|
import * as rinquarto from './engine/rinquarto.js';
|
||||||
import * as basket from './engine/basket.js';
|
import * as basket from './engine/basket.js';
|
||||||
import * as pesca from './engine/pesca.js';
|
import * as pesca from './engine/pesca.js';
|
||||||
import * as gara from './engine/gara.js';
|
import * as gara from './engine/gara.js';
|
||||||
@@ -507,7 +508,7 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
return;
|
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', 'rinquarto', '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
|
||||||
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
||||||
}
|
}
|
||||||
@@ -569,12 +570,19 @@ canvas.addEventListener('pointermove', e => {
|
|||||||
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
|
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const aiming = game.closeup && ['biliardo', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type);
|
const aiming = game.closeup && ['biliardo', 'rinquarto', '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));
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||||
|
|
||||||
|
// Il pannello si aggiorna a ogni fotogramma: si scrive nel DOM solo quando qualcosa cambia davvero. Su iPhone
|
||||||
|
// Safari, se la pagina cambia subito dopo un tocco, il tocco vale come «passaggio» e il clic non arriva: i pulsanti
|
||||||
|
// dei minigiochi, riscritti a ogni fotogramma, non rispondevano (un amico di Andrea, 28 ottobre).
|
||||||
|
const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
|
||||||
|
const setAttr = (el, name, value) => { if (el.getAttribute(name) !== value) el.setAttribute(name, value); };
|
||||||
|
const setValue = (el, value) => { if (el.value !== String(value)) el.value = value; };
|
||||||
|
const setProp = (el, name, value) => { if (typeof el[name] === 'boolean') value = !!value; if (el[name] !== value) el[name] = value; };
|
||||||
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
|
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
|
||||||
const MINI = {
|
const MINI = {
|
||||||
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
|
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
|
||||||
@@ -582,6 +590,7 @@ const MINI = {
|
|||||||
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
|
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
|
||||||
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
||||||
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
|
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
|
||||||
|
rinquarto: { ...rinquarto, back: 'Torna da Bertovo', label: 'I rinquarti di Bertovo: 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).
|
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
||||||
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza: tieni premuto sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' },
|
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza: tieni premuto sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' },
|
||||||
@@ -589,7 +598,29 @@ const MINI = {
|
|||||||
};
|
};
|
||||||
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;
|
||||||
const mini = () => MINI[story.closeups[game.closeup.id].type];
|
const mini = () => MINI[story.closeups[game.closeup.id].type];
|
||||||
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
|
$('bocce-power').addEventListener('input', e => {
|
||||||
|
const c = miniState(); if (!c) return;
|
||||||
|
mini().setPower?.(c, Number(e.target.value) / 100);
|
||||||
|
// Nel GP di Fortezza il cursore è la sensibilità dello sterzo: il gioco se la ricorda.
|
||||||
|
if (story.closeups[c.id].type === 'gara') try { localStorage.setItem(GARA_SENS, e.target.value); } catch { /* senza memoria, pazienza */ }
|
||||||
|
});
|
||||||
|
const GARA_SENS = 'regolo-gara-sensibilita';
|
||||||
|
// Il riquadro dello swipe sotto la pista: come il dito sulla scena, con le distanze in pixel della scena.
|
||||||
|
const padPoint = e => { const r = canvas.getBoundingClientRect(), k = 640 / r.width; return [(e.clientX - r.left) * k, (e.clientY - r.top) * k]; };
|
||||||
|
let padPointer = null;
|
||||||
|
$('gara-pad').addEventListener('pointerdown', e => {
|
||||||
|
const c = miniState(); if (!c || padPointer !== null || game.skip()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId);
|
||||||
|
if (gara.press(c, 'pad' + e.pointerId)) { padPointer = e.pointerId; $('gara-pad').setPointerCapture(e.pointerId); $('gara-pad').classList.add('active'); }
|
||||||
|
});
|
||||||
|
$('gara-pad').addEventListener('pointermove', e => { const c = miniState(); if (c && e.pointerId === padPointer) gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId); });
|
||||||
|
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('gara-pad').addEventListener(ev, e => {
|
||||||
|
if (e.pointerId !== padPointer) return;
|
||||||
|
const c = miniState(); if (c) gara.release(c, 'pad' + e.pointerId);
|
||||||
|
padPointer = null; $('gara-pad').classList.remove('active');
|
||||||
|
});
|
||||||
|
$('gara-pad').addEventListener('contextmenu', e => e.preventDefault());
|
||||||
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
|
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
|
||||||
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
|
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
|
||||||
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
|
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
|
||||||
@@ -680,8 +711,11 @@ function updateInventory() {
|
|||||||
let choicesKey = '';
|
let choicesKey = '';
|
||||||
function updateChoices() {
|
function updateChoices() {
|
||||||
const options = game.options(), inDialogue = !!game.dialogue;
|
const options = game.options(), inDialogue = !!game.dialogue;
|
||||||
$('panel').hidden = inDialogue;
|
// Con un minigioco aperto il suo pannello prende il posto di verbi e scelte: si decide qui, una volta sola, senza
|
||||||
$('choices').hidden = !inDialogue;
|
// mostrarli e nasconderli a ogni fotogramma.
|
||||||
|
const covered = !!miniState();
|
||||||
|
setProp($('panel'), 'hidden', inDialogue || covered);
|
||||||
|
setProp($('choices'), 'hidden', !inDialogue || covered);
|
||||||
const key = inDialogue + '|' + options.map(o => o.text).join('|');
|
const key = inDialogue + '|' + options.map(o => o.text).join('|');
|
||||||
if (key === choicesKey) return;
|
if (key === choicesKey) return;
|
||||||
choicesKey = key;
|
choicesKey = key;
|
||||||
@@ -853,21 +887,22 @@ function updateSpeech() {
|
|||||||
if (!sp) { if (box.textContent) box.textContent = ''; return; }
|
if (!sp) { if (box.textContent) box.textContent = ''; return; }
|
||||||
const [x, y] = game.speakerPoint(sp.who);
|
const [x, y] = game.speakerPoint(sp.who);
|
||||||
if (box.textContent !== sp.text) box.textContent = sp.text;
|
if (box.textContent !== sp.text) box.textContent = sp.text;
|
||||||
box.style.color = story.characters[sp.who]?.color ?? '#fff';
|
const color = story.characters[sp.who]?.color ?? '#fff';
|
||||||
|
if (box.style.color !== color && box.dataset.color !== color) { box.style.color = color; box.dataset.color = color; }
|
||||||
// Sopra chi parla, ma sempre dentro la scena e mai sotto i pulsanti in alto (Pausa, volume): sul telefono un
|
// Sopra chi parla, ma sempre dentro la scena e mai sotto i pulsanti in alto (Pausa, volume): sul telefono un
|
||||||
// fumetto lungo è alto quasi quanto la scena, e si misura in pixel.
|
// fumetto lungo è alto quasi quanto la scena, e si misura in pixel.
|
||||||
const stage = $('stage').getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight;
|
const stage = $('stage').getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight;
|
||||||
const roof = Math.max(...['sound', 'corner'].map(id => $(id).getBoundingClientRect().bottom - stage.top)) + 4;
|
const roof = Math.max(...['sound', 'corner'].map(id => $(id).getBoundingClientRect().bottom - stage.top)) + 4;
|
||||||
const px = Math.min(stage.width - w / 2 - 4, Math.max(w / 2 + 4, x / WIDTH * stage.width));
|
const px = Math.min(stage.width - w / 2 - 4, Math.max(w / 2 + 4, x / WIDTH * stage.width));
|
||||||
const py = Math.min(stage.height - 4, Math.max(roof + h, y / HEIGHT * stage.height));
|
const py = Math.min(stage.height - 4, Math.max(roof + h, y / HEIGHT * stage.height));
|
||||||
box.style.left = px + 'px';
|
if (box.style.left !== px + 'px') box.style.left = px + 'px';
|
||||||
box.style.top = py + 'px';
|
if (box.style.top !== py + 'px') box.style.top = py + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUi() {
|
function updateUi() {
|
||||||
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
|
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
|
||||||
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
|
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
|
||||||
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
|
for (const b of $('verbs').children) setAttr(b, 'aria-pressed', String(command.verb === b.dataset.verb));
|
||||||
const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch';
|
const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch';
|
||||||
const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.'
|
const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.'
|
||||||
: `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.`
|
: `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.`
|
||||||
@@ -878,22 +913,22 @@ function updateUi() {
|
|||||||
updateSpeech();
|
updateSpeech();
|
||||||
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
|
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
|
||||||
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
|
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
|
||||||
$('bocce-controls').hidden = !mg || cards;
|
setProp($('bocce-controls'), 'hidden', !mg || cards);
|
||||||
$('bocce-controls').inert = !running;
|
setProp($('bocce-controls'), 'inert', !running);
|
||||||
$('briscola-controls').hidden = def?.type !== 'briscola';
|
setProp($('briscola-controls'), 'hidden', def?.type !== 'briscola');
|
||||||
$('briscola-controls').inert = !running;
|
setProp($('briscola-controls'), 'inert', !running);
|
||||||
$('scopa-controls').hidden = def?.type !== 'scopa';
|
setProp($('scopa-controls'), 'hidden', def?.type !== 'scopa');
|
||||||
$('scopa-controls').inert = !running;
|
setProp($('scopa-controls'), 'inert', !running);
|
||||||
if (def?.type === 'scopa') {
|
if (def?.type === 'scopa') {
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = scopa.status(c, def.names);
|
const line = scopa.status(c, def.names);
|
||||||
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
|
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const button = $('scopa-card-' + i), card = c.hands.player[i];
|
const button = $('scopa-card-' + i), card = c.hands.player[i];
|
||||||
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
|
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
|
||||||
if (button.textContent !== label) button.textContent = label;
|
if (button.textContent !== label) button.textContent = label;
|
||||||
button.disabled = !scopa.canPlay(c) || !card || !!game.speech;
|
setProp(button, 'disabled', !scopa.canPlay(c) || !card || !!game.speech);
|
||||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
|
setAttr(button, 'aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
|
||||||
}
|
}
|
||||||
// Le prese fra cui scegliere, una per pulsante.
|
// Le prese fra cui scegliere, una per pulsante.
|
||||||
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
|
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
|
||||||
@@ -905,14 +940,14 @@ function updateUi() {
|
|||||||
b.dataset.option = k; return b;
|
b.dataset.option = k; return b;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
box.hidden = !options.length;
|
setProp(box, 'hidden', !options.length);
|
||||||
[...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k)));
|
[...box.children].forEach((b, k) => setAttr(b, 'aria-pressed', String(c.choice?.selected === k)));
|
||||||
$('scopa-next').hidden = c.step !== 'smazzata';
|
setProp($('scopa-next'), 'hidden', c.step !== 'smazzata');
|
||||||
}
|
}
|
||||||
$('tressette-controls').hidden = def?.type !== 'tressette';
|
setProp($('tressette-controls'), 'hidden', def?.type !== 'tressette');
|
||||||
$('tressette-controls').inert = !running;
|
setProp($('tressette-controls'), 'inert', !running);
|
||||||
if (def?.type === 'tressette') {
|
if (def?.type === 'tressette') {
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = tressette.status(c, def.seats);
|
const line = tressette.status(c, def.seats);
|
||||||
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
|
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
|
||||||
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
|
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
|
||||||
@@ -920,66 +955,76 @@ function updateUi() {
|
|||||||
const button = $('tressette-card-' + i), card = c.hands[0][i];
|
const button = $('tressette-card-' + i), card = c.hands[0][i];
|
||||||
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
|
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
|
||||||
if (button.textContent !== label) button.textContent = label;
|
if (button.textContent !== label) button.textContent = label;
|
||||||
button.hidden = !card;
|
setProp(button, 'hidden', !card);
|
||||||
button.disabled = !ok.includes(i);
|
setProp(button, 'disabled', !ok.includes(i));
|
||||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
|
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
|
||||||
}
|
}
|
||||||
for (const signal of tressette.SIGNALS) {
|
for (const signal of tressette.SIGNALS) {
|
||||||
$('tressette-' + signal).disabled = !mine || c.table.length > 0;
|
setProp($('tressette-' + signal), 'disabled', !mine || c.table.length > 0);
|
||||||
$('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal));
|
setAttr($('tressette-' + signal), 'aria-pressed', String(c.signal === signal));
|
||||||
}
|
}
|
||||||
$('tressette-next').hidden = c.step !== 'smazzata';
|
setProp($('tressette-next'), 'hidden', c.step !== 'smazzata');
|
||||||
}
|
}
|
||||||
if (def?.type === 'briscola') {
|
if (def?.type === 'briscola') {
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = briscola.status(c, def.names);
|
const line = briscola.status(c, def.names);
|
||||||
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
|
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const button = $('briscola-card-' + i), card = c.hands.player[i];
|
const button = $('briscola-card-' + i), card = c.hands.player[i];
|
||||||
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
|
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
|
||||||
if (button.textContent !== label) button.textContent = label;
|
if (button.textContent !== label) button.textContent = label;
|
||||||
button.disabled = !briscola.canPlay(c) || !card || !!game.speech;
|
setProp(button, 'disabled', !briscola.canPlay(c) || !card || !!game.speech);
|
||||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
|
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mg && !cards) {
|
if (mg && !cards) {
|
||||||
$('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');
|
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara' }[def.type] ?? 'Comandi delle bocce');
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = mg.status(c, def.names);
|
const line = mg.status(c, def.names);
|
||||||
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
||||||
$('bocce-power').value = Math.round(c.power * 100);
|
setValue($('bocce-power'), Math.round(c.power * 100));
|
||||||
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
|
setValue($('bocce-power-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 && !mg.toggle) || !!game.speech;
|
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
|
||||||
$('bocce-throw').disabled = !mg.canThrow(c) || (c.holding && !mg.toggle) || !!game.speech;
|
setProp($('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).
|
// 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';
|
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
|
||||||
$('bocce-left').textContent = def.type === 'gara' ? '← Sterza' : '← Mira';
|
const powerLabel = $('bocce-power').closest('label');
|
||||||
$('bocce-right').textContent = def.type === 'gara' ? 'Sterza →' : 'Mira →';
|
const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza ';
|
||||||
$('gara-brake').hidden = def.type !== 'gara';
|
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
|
||||||
$('gara-brake').disabled = !(mg.canAdjust?.(c) ?? false) || !!game.speech;
|
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
|
||||||
$('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');
|
setProp($('gara-pad'), 'hidden', def.type !== 'gara');
|
||||||
$('bocce-help').hidden = def.type !== 'bocce';
|
if (def.type === 'gara' && c.sensLoaded === undefined) {
|
||||||
$('biliardo-help').hidden = def.type !== 'biliardo';
|
c.sensLoaded = true;
|
||||||
$('basket-help').hidden = def.type !== 'basket';
|
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
|
||||||
$('pesca-help').hidden = def.type !== 'pesca';
|
}
|
||||||
$('gara-help').hidden = def.type !== 'gara';
|
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira');
|
||||||
|
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →');
|
||||||
|
setProp($('gara-brake'), 'hidden', def.type !== 'gara');
|
||||||
|
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
|
||||||
|
setText($('bocce-throw'), mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'));
|
||||||
|
setProp($('bocce-help'), 'hidden', def.type !== 'bocce');
|
||||||
|
setProp($('biliardo-help'), 'hidden', def.type !== 'biliardo');
|
||||||
|
setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto');
|
||||||
|
setProp($('basket-help'), 'hidden', def.type !== 'basket');
|
||||||
|
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
|
||||||
|
setProp($('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 });
|
||||||
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
||||||
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
|
setProp($('map-button'), 'hidden', !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2);
|
||||||
$('pause-button').hidden = !running;
|
setProp($('pause-button'), 'hidden', !running);
|
||||||
$('spots-button').hidden = !running || !!game.closeup;
|
setProp($('spots-button'), 'hidden', !running || !!game.closeup);
|
||||||
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
|
setProp($('notebook-button'), 'hidden', !running || !story.notebook || !!game.closeup);
|
||||||
$('closeup-close').hidden = !running || !game.closeup;
|
setProp($('closeup-close'), 'hidden', !running || !game.closeup);
|
||||||
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi';
|
setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi');
|
||||||
$('panel').inert = !!c;
|
setProp($('panel'), 'inert', !!c);
|
||||||
canvas.classList.toggle('torch', !!torch);
|
canvas.classList.toggle('torch', !!torch);
|
||||||
canvas.classList.toggle('bocce', !!mg);
|
canvas.classList.toggle('bocce', !!mg);
|
||||||
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
||||||
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'gara'].includes(story.closeups[c.id].type));
|
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', '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");
|
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
|
||||||
// La pagina che comincia a girare fa il suo fruscio.
|
// La pagina che comincia a girare fa il suo fruscio.
|
||||||
const step = game.closeup?.step ?? null;
|
const step = game.closeup?.step ?? null;
|
||||||
if (step === 'turn' && turning !== 'turn') ambience.play('page');
|
if (step === 'turn' && turning !== 'turn') ambience.play('page');
|
||||||
@@ -989,7 +1034,7 @@ function updateUi() {
|
|||||||
reveal = nextReveal;
|
reveal = nextReveal;
|
||||||
if (game.state.ended && !game.speech && !game.queue.length) {
|
if (game.state.ended && !game.speech && !game.queue.length) {
|
||||||
running = false;
|
running = false;
|
||||||
$('ending').hidden = false;
|
setProp($('ending'), 'hidden', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ export const piloti = [
|
|||||||
{ 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' },
|
{ 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.
|
// 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' },
|
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c', moto: 'assets/closeups/gp/fifty.png', sprite: 'andrea' },
|
||||||
// Il Porno (Andrea Bini, consenso del 28 ottobre) col suo Italjet Formula giallo, che va fortissimo (foto di Andrea);
|
// Bini (Andrea Bini, consenso del 28 ottobre; preferisce «Bini») col suo Italjet Formula giallo, che va fortissimo
|
||||||
// il ritratto dalla sua foto.
|
// (foto di Andrea); il ritratto dalla sua foto.
|
||||||
{ id: 'porno', name: 'Il Porno', bike: 'il Formula', motore: 5, testa: 3, color: '#f2c81e', trim: '#2a2a2e', moto: 'assets/closeups/gp/formula.png', face: 'assets/closeups/gp/porno.png', consenso: '2026-10-28' },
|
{ id: 'bini', name: 'Bini', bike: 'il Formula con la Zirri', motore: 5, testa: 3, color: '#f2c81e', trim: '#2a2a2e', moto: 'assets/closeups/gp/formula.png', face: 'assets/closeups/gp/bini.png', consenso: '2026-10-28' },
|
||||||
// Tozzino (Francesco Tozzi) col suo Booster blu scuro, di serie, dalle foto che ha mandato (l'adesivo sul manubrio compreso).
|
// 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',
|
{ id: 'tozzino', name: 'Tozzino', bike: 'il Booster', motore: 1, testa: 3, color: '#1a2a52', trim: '#111318', moto: 'assets/closeups/gp/booster.png', sprite: 'tozzino',
|
||||||
// Il casco alla Damon Hill: blu notte con poche pale di remo bianche, strette e a punta (foto di Andrea, 28 ottobre).
|
// Il casco alla Damon Hill: blu notte con poche pale di remo bianche, strette e a punta (foto di Andrea, 28 ottobre).
|
||||||
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
|
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
|
||||||
|
// Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
|
||||||
|
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
|
||||||
|
// Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
|
||||||
|
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
|
||||||
|
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
|
||||||
|
{ id: 'brauda', name: 'La Brauda', bike: 'il Rally', motore: 3, testa: 3, color: '#151518', trim: '#2c3f9e', moto: 'assets/closeups/gp/rally.png', face: 'assets/closeups/gp/brauda.png', consenso: '2026-10-28' },
|
||||||
// Il Betino dell'Arcu: verde e crema, lento ma non cade mai.
|
// 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' },
|
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0', moto: 'assets/closeups/gp/betino.png', sprite: 'arcu' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -223,6 +223,15 @@ export const story = {
|
|||||||
quit: [{ say: 'Lasci a metà? Ti segno la sconfitta a tavolino, eh.', who: 'ing_tozzi' }],
|
quit: [{ say: 'Lasci a metà? Ti segno la sconfitta a tavolino, eh.', who: 'ing_tozzi' }],
|
||||||
draw: [],
|
draw: [],
|
||||||
},
|
},
|
||||||
|
rinquarto: {
|
||||||
|
type: 'rinquarto',
|
||||||
|
picture: 'assets/closeups/biliardo-tavolo.png',
|
||||||
|
names: { player: 'Andrea', cpu: 'Bertovo' },
|
||||||
|
win: [{ say: 'Questo rinquarto era bono!', who: 'bertovo' }], // [DA ANDREA]
|
||||||
|
lose: [{ say: 'Le sponde ci sono per qualcosa!', who: 'bertovo' }], // [DA ANDREA]
|
||||||
|
quit: [{ say: 'Questo me lo tengo per la prossima.' }], // [DA ANDREA]
|
||||||
|
draw: [{ say: 'Si riprova?', who: 'bertovo' }], // [DA ANDREA]
|
||||||
|
},
|
||||||
fessura: {
|
fessura: {
|
||||||
type: 'torch',
|
type: 'torch',
|
||||||
picture: 'assets/closeups/palio-2007.jpg',
|
picture: 'assets/closeups/palio-2007.jpg',
|
||||||
@@ -285,6 +294,8 @@ export const story = {
|
|||||||
tosca: { name: 'La Tosca', color: '#ffb0d0' },
|
tosca: { name: 'La Tosca', color: '#ffb0d0' },
|
||||||
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
|
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
|
||||||
// L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt).
|
// L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt).
|
||||||
|
// Bertovo (Fabrizio Berti, consenso dato ad Andrea il 28 ottobre), il re dei rinquarti (sprite di Codex dalla sua foto).
|
||||||
|
bertovo: { name: 'Bertovo', color: '#9fd0ff', sprite: 'assets/characters/bertovo.json' },
|
||||||
ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a', sprite: 'assets/characters/ing-tozzi.json' },
|
ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a', sprite: 'assets/characters/ing-tozzi.json' },
|
||||||
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
|
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
|
||||||
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
||||||
@@ -1667,9 +1678,13 @@ export const story = {
|
|||||||
walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]],
|
walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]],
|
||||||
depth: [[190, 1.2], [360, 2.5]],
|
depth: [[190, 1.2], [360, 2.5]],
|
||||||
entry: [590, 202],
|
entry: [590, 202],
|
||||||
speakers: { ing_tozzi: [452, 150] },
|
speakers: { ing_tozzi: [452, 150], bertovo: [396, 132] },
|
||||||
// L'Ing. Tozzi aspetta accanto al primo tavolo, con la stecca: si parla con lui per giocare, come con Mariano.
|
// L'Ing. Tozzi aspetta accanto al primo tavolo, con la stecca: si parla con lui per giocare, come con Mariano.
|
||||||
people: [{ who: 'ing_tozzi', at: [452, 296], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 }],
|
people: [
|
||||||
|
{ who: 'ing_tozzi', at: [452, 296], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 },
|
||||||
|
// Bertovo al tavolo di mezzo, coi suoi rinquarti (Andrea, 28 ottobre).
|
||||||
|
{ who: 'bertovo', at: [396, 238], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 5.1 },
|
||||||
|
],
|
||||||
enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }],
|
enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
{
|
{
|
||||||
@@ -1688,6 +1703,13 @@ export const story = {
|
|||||||
talk: [{ dialogue: 'ing_tozzi' }],
|
talk: [{ dialogue: 'ing_tozzi' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'bertovo', name: 'Bertovo', box: [382, 162, 30, 76], at: [360, 244], face: 'right', default: 'talk',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Bertovo, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.' }],
|
||||||
|
talk: [{ dialogue: 'bertovo' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up',
|
id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -2673,7 +2695,8 @@ export const story = {
|
|||||||
name: 'Via Liguri Magelli',
|
name: 'Via Liguri Magelli',
|
||||||
map: [76, 97],
|
map: [76, 97],
|
||||||
background: 'assets/scenes/via-liguri-magelli.png',
|
background: 'assets/scenes/via-liguri-magelli.png',
|
||||||
walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [400, 250], [580, 318], [640, 330], [640, 360]],
|
// La strada gira intorno all'Audi nera parcheggiata davanti al Trappola.
|
||||||
|
walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [300, 242], [370, 246], [400, 252], [580, 318], [640, 330], [640, 360]],
|
||||||
depth: [[198, 0.3], [360, 1.1]],
|
depth: [[198, 0.3], [360, 1.1]],
|
||||||
entry: [320, 340],
|
entry: [320, 340],
|
||||||
speakers: { arcu: [374, 176] },
|
speakers: { arcu: [374, 176] },
|
||||||
@@ -2691,12 +2714,21 @@ export const story = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// «Vai dal Trappola, la tenda è il Trappola» (Andrea). [DA ANDREA: com'è dentro, e chi c'è]
|
// «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',
|
id: 'trappola', name: 'dal Trappola', box: [316, 150, 46, 46], at: [340, 252], face: 'right', default: 'walk',
|
||||||
actions: {
|
actions: {
|
||||||
look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }],
|
look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }],
|
||||||
walk: [{ say: 'Dal Trappola ci si passa dopo. Prima il palio.' }],
|
walk: [{ say: 'Dal Trappola ci si passa dopo. Prima il palio.' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// «Di chi è quell'Audi nera?» «Let it be» (che si legge «L'è di Bi»: è di Bini). Andrea, 28 ottobre.
|
||||||
|
id: 'audi', name: 'Audi nera', box: [292, 198, 68, 40], at: [330, 254], face: 'up', default: 'look',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Di chi è quell\'Audi nera?' }, { say: 'Let it be.', who: 'arcu', pose: 'explain' }],
|
||||||
|
talk: [{ say: 'Di chi è quell\'Audi nera?' }, { say: 'Let it be.', who: 'arcu', pose: 'explain' }],
|
||||||
|
use: [{ say: 'Non è mica mia. Mi garba, però, nera fiammante.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look',
|
id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -3184,6 +3216,36 @@ export const story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Bertovo al tavolo di mezzo: le biglie nascoste e «Oh tirami questo rinquarto!?» (Andrea, 28 ottobre).
|
||||||
|
// [DA ANDREA: le battute sono provvisorie, tranne la sua sfida]
|
||||||
|
bertovo: {
|
||||||
|
nodes: {
|
||||||
|
start: {
|
||||||
|
options: [
|
||||||
|
{ text: 'Bertovo! Che fai, giochi da solo?', then: [
|
||||||
|
{ say: 'Da solo? Io gioco contro le sponde. E vinco quasi sempre.', who: 'bertovo', pose: 'explain' },
|
||||||
|
] },
|
||||||
|
{ text: 'Come fai a prendere le biglie dove nessuno ci arriva?', then: [
|
||||||
|
{ say: 'Geometria. La biglia va dove le dici, basta chiederglielo per bene.', who: 'bertovo', pose: 'chalk' },
|
||||||
|
{ say: 'E con un po\' di gesso.', who: 'bertovo', pose: 'laugh' },
|
||||||
|
] },
|
||||||
|
{ text: 'Dai, fammi vedere un rinquarto.', next: 'sfida', then: [
|
||||||
|
{ say: 'Te lo faccio vedere? No: lo tiri te.', who: 'bertovo', pose: 'talk' },
|
||||||
|
{ say: 'Le biglie le metto io, nascoste per bene. Oh tirami questo rinquarto!?', who: 'bertovo', pose: 'explain' },
|
||||||
|
] },
|
||||||
|
{ text: 'Ci si vede, Bertovo.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sfida: {
|
||||||
|
options: [
|
||||||
|
{ text: 'Alla bona, una sponda e basta.', next: 'end', then: [{ say: 'Una sponda. Roba da principianti, ma va bene.', who: 'bertovo', pose: 'shrug' }, { closeup: 'rinquarto', level: 'bona' }] },
|
||||||
|
{ text: 'Seria.', next: 'end', then: [{ say: 'Due sponde. Così mi piace.', who: 'bertovo', pose: 'talk' }, { closeup: 'rinquarto', level: 'seria' }] },
|
||||||
|
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Tre sponde e niente aiuti. Qui nascono le geometrie celestiali.', who: 'bertovo', pose: 'cheer' }, { closeup: 'rinquarto', level: 'male' }] },
|
||||||
|
{ text: 'No, mi tremano le mani.', next: 'start', then: [{ say: 'Il tavolo è qui. Le sponde non scappano.', who: 'bertovo', pose: 'laugh' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
ing_tozzi: {
|
ing_tozzi: {
|
||||||
nodes: {
|
nodes: {
|
||||||
start: {
|
start: {
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
|
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
|
||||||
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
|
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
|
||||||
.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; }
|
||||||
|
/* Il riquadro dello swipe del GP di Fortezza; niente lente né selezione col dito fermo sul telefono. */
|
||||||
|
#gara-pad { touch-action: none; margin: 0 0 10px; padding: 18px 10px; min-height: 64px; display: flex; align-items: center; justify-content: center;
|
||||||
|
text-align: center; color: #c5c6b6; border: 2px dashed #897b57; border-radius: 10px; background: #1d2320; }
|
||||||
|
#gara-pad.active { border-style: solid; color: #f3dcac; }
|
||||||
|
#stage, #bocce-controls, #gara-pad { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
|
||||||
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
|
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
|
||||||
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
||||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help { 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; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { newGara, update, press, presenting, INTRO, release, cancel, shoot, key, steerButton, brakeButton, aimScreen, steering, status, button, standings, player, rider, limitAt, pointAt, TRACK, CURVES, PICKUPS, BRAKE, VERGE, LAPS, COUNTDOWN, RIDERS } from '../src/engine/gara.js';
|
import { SWIPE, setPower, newGara, update, press, presenting, INTRO, release, cancel, shoot, key, steerButton, brakeButton, aimScreen, steering, status, button, standings, player, rider, limitAt, pointAt, TRACK, CURVES, PICKUPS, BRAKE, VERGE, LAPS, COUNTDOWN, RIDERS } from '../src/engine/gara.js';
|
||||||
import { piloti } from '../src/story/piloti.js';
|
import { piloti } from '../src/story/piloti.js';
|
||||||
import { story } from '../src/story/story.js';
|
import { story } from '../src/story/story.js';
|
||||||
import { Game } from '../src/engine/game.js';
|
import { Game } from '../src/engine/game.js';
|
||||||
@@ -17,6 +17,8 @@ const run = (c, seconds, drive, dt = 1 / 60) => {
|
|||||||
return c.heard;
|
return c.heard;
|
||||||
};
|
};
|
||||||
const skip = c => { while (c.step === 'intro') press(c); return c; };
|
const skip = c => { while (c.step === 'intro') press(c); return c; };
|
||||||
|
// Lo sterzo col dito insegue quello voluto: per leggerlo si lascia assestare.
|
||||||
|
const settled = c => { for (let i = 0; i < 400; i++) steering(c); return steering(c); };
|
||||||
const race = c => { skip(c); assert.equal(c.step, 'start'); run(c, COUNTDOWN + 0.01); assert.equal(c.step, 'race'); return c; };
|
const race = c => { skip(c); assert.equal(c.step, 'start'); run(c, COUNTDOWN + 0.01); assert.equal(c.step, 'race'); return c; };
|
||||||
// Un dito al centro della scena indica la direzione del punto davanti al motorino.
|
// Un dito al centro della scena indica la direzione del punto davanti al motorino.
|
||||||
const swipeTo = (c, target) => {
|
const swipeTo = (c, target) => {
|
||||||
@@ -93,73 +95,73 @@ test('le fonti del gas sono indipendenti: lasciare un tasto non toglie quello de
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('lo sterzo: frecce, pulsanti e il dito che trascina; il mouse che passa senza premere non sterza', () => {
|
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 }));
|
const c = race(newGara(3, { riders: RIDERS })); setPower(c, 1);
|
||||||
assert.ok(key(c, 'ArrowLeft', true)); assert.equal(steering(c), -1);
|
assert.ok(key(c, 'ArrowLeft', true)); assert.equal(settled(c), -1);
|
||||||
key(c, 'ArrowLeft', false); assert.equal(steering(c), 0);
|
key(c, 'ArrowLeft', false); assert.equal(settled(c), 0);
|
||||||
steerButton(c, 1); assert.equal(steering(c), 1); steerButton(c, 0);
|
steerButton(c, 1); assert.equal(settled(c), 1); steerButton(c, 0);
|
||||||
// Gas da tastiera e sinistra, il mouse si muove a destra senza premere: lo sterzo resta a sinistra (P1).
|
// 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);
|
key(c, 'ArrowUp', true); key(c, 'ArrowLeft', true);
|
||||||
aimScreen(c, [300, 100], 5); aimScreen(c, [360, 100], 5);
|
aimScreen(c, [300, 100], 5); aimScreen(c, [360, 100], 5);
|
||||||
assert.equal(steering(c), -1);
|
assert.equal(settled(c), -1);
|
||||||
key(c, 'ArrowUp', false); key(c, 'ArrowLeft', false);
|
key(c, 'ArrowUp', false); key(c, 'ArrowLeft', false);
|
||||||
// Il dito indica una direzione; un altro puntatore non sposta niente.
|
// Il dito indica una direzione; un altro puntatore non sposta niente.
|
||||||
player(c).a = -0.5 / 2.6;
|
player(c).a = -0.5 / SWIPE.hard;
|
||||||
aimScreen(c, [300, 100], 9); press(c, 9);
|
aimScreen(c, [300, 100], 9); press(c, 9);
|
||||||
aimScreen(c, [314, 100], 9); assert.ok(Math.abs(steering(c) - 0.5) < 1e-10);
|
aimScreen(c, [314, 100], 9); assert.ok(Math.abs(settled(c) - 0.5) < 1e-6);
|
||||||
aimScreen(c, [250, 100], 4); assert.ok(Math.abs(steering(c) - 0.5) < 1e-10);
|
aimScreen(c, [250, 100], 4); assert.ok(Math.abs(settled(c) - 0.5) < 1e-6);
|
||||||
release(c, 9); assert.equal(steering(c), 0, 'lasciando il dito lo sterzo torna dritto');
|
release(c, 9); assert.equal(settled(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');
|
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');
|
assert.equal(key(c, 'x', true), false, 'gli altri tasti non sono suoi');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lo swipe segue lo schermo: scendendo, destra sterza negativo; avanti e piccoli movimenti vanno dritto', () => {
|
test('lo swipe segue lo schermo: scendendo, destra sterza negativo; avanti e piccoli movimenti vanno dritto', () => {
|
||||||
const c = race(newGara(3)), r = player(c);
|
const c = race(newGara(3)), r = player(c); setPower(c, 1);
|
||||||
r.a = Math.PI / 2;
|
r.a = Math.PI / 2;
|
||||||
aimScreen(c, [320, 180], 9); press(c, 9);
|
aimScreen(c, [320, 180], 9); press(c, 9);
|
||||||
aimScreen(c, [370, 180], 9);
|
aimScreen(c, [370, 180], 9);
|
||||||
assert.equal(steering(c), -1, 'scendendo, la destra dello schermo è a sinistra del motorino');
|
assert.equal(settled(c), -1, 'scendendo, la destra dello schermo è a sinistra del motorino');
|
||||||
r.a = 0;
|
r.a = 0;
|
||||||
assert.ok(Math.abs(steering(c)) < 1e-10, 'col dito fermo lo sterzo si annulla quando il motorino è allineato');
|
assert.ok(Math.abs(settled(c)) < 1e-6, 'col dito fermo lo sterzo si annulla quando il motorino è allineato');
|
||||||
r.a = Math.PI / 2;
|
r.a = Math.PI / 2;
|
||||||
aimScreen(c, [320, 230], 9); assert.ok(Math.abs(steering(c)) < 1e-10, 'swipe in avanti');
|
aimScreen(c, [320, 230], 9); assert.ok(Math.abs(settled(c)) < 1e-6, 'swipe in avanti');
|
||||||
aimScreen(c, [323, 184], 9); assert.equal(steering(c), 0, '5 px non fanno tremare lo sterzo');
|
aimScreen(c, [323, 184], 9); assert.equal(settled(c), 0, '5 px non fanno tremare lo sterzo');
|
||||||
aimScreen(c, [330, 230], 9);
|
aimScreen(c, [330, 230], 9);
|
||||||
assert.ok(Math.abs(steering(c) + Math.atan2(10, 50) * 2.6) < 1e-10, 'sterzo proporzionale all’angolo');
|
assert.ok(Math.abs(settled(c) + Math.atan2(10, 50) * SWIPE.hard) < 1e-6, 'sterzo proporzionale all’angolo');
|
||||||
r.a = -Math.PI + 0.1;
|
r.a = -Math.PI + 0.1;
|
||||||
aimScreen(c, [270, 180], 9);
|
aimScreen(c, [270, 180], 9);
|
||||||
assert.ok(Math.abs(steering(c) + 0.26) < 1e-10, 'si gira dalla parte più breve anche passando da π a -π');
|
assert.ok(Math.abs(settled(c) + 0.1 * SWIPE.hard) < 1e-6, 'si gira dalla parte più breve anche passando da π a -π');
|
||||||
release(c, 9);
|
release(c, 9);
|
||||||
assert.equal(c.holding, false); assert.equal(steering(c), 0);
|
assert.equal(c.holding, false); assert.equal(settled(c), 0);
|
||||||
assert.equal(c.anchor, null); assert.equal(c.finger, null);
|
assert.equal(c.anchor, null); assert.equal(c.finger, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('l’origine segue gli swipe lunghi e si può correggere; rilascio e annullamento puliscono il dito', () => {
|
test('l’origine segue gli swipe lunghi e si può correggere; rilascio e annullamento puliscono il dito', () => {
|
||||||
const c = race(newGara(3));
|
const c = race(newGara(3)); setPower(c, 1);
|
||||||
player(c).a = 0;
|
player(c).a = 0;
|
||||||
aimScreen(c, [100, 100], 9); press(c, 9);
|
aimScreen(c, [100, 100], 9); press(c, 9);
|
||||||
for (const x of [200, 300, 400]) {
|
for (const x of [200, 300, 400]) {
|
||||||
aimScreen(c, [x, 100], 9);
|
aimScreen(c, [x, 100], 9);
|
||||||
assert.deepEqual(c.anchor, [x - 60, 100]);
|
assert.deepEqual(c.anchor, [x - 60, 100]);
|
||||||
assert.ok(Math.abs(steering(c)) < 1e-10);
|
assert.ok(Math.abs(settled(c)) < 1e-6);
|
||||||
}
|
}
|
||||||
aimScreen(c, [400, 160], 9);
|
aimScreen(c, [400, 160], 9);
|
||||||
assert.ok(Math.abs(Math.hypot(c.finger[0] - c.anchor[0], c.finger[1] - c.anchor[1]) - 60) < 1e-10);
|
assert.ok(Math.abs(Math.hypot(c.finger[0] - c.anchor[0], c.finger[1] - c.anchor[1]) - 60) < 1e-6);
|
||||||
assert.equal(steering(c), 1, 'si corregge verso il basso senza arrivare al bordo');
|
assert.equal(settled(c), 1, 'si corregge verso il basso senza arrivare al bordo');
|
||||||
key(c, 'ArrowLeft', true); shoot(c); brakeButton(c, true);
|
key(c, 'ArrowLeft', true); shoot(c); brakeButton(c, true);
|
||||||
release(c, 9);
|
release(c, 9);
|
||||||
assert.equal(steering(c), -1, 'lasciare il dito conserva lo sterzo della tastiera');
|
assert.equal(settled(c), -1, 'lasciare il dito conserva lo sterzo della tastiera');
|
||||||
assert.ok(c.holding && c.braking, 'Gas e Frena del pannello restano attivi');
|
assert.ok(c.holding && c.braking, 'Gas e Frena del pannello restano attivi');
|
||||||
key(c, 'ArrowDown', true); brakeButton(c, false);
|
key(c, 'ArrowDown', true); brakeButton(c, false);
|
||||||
assert.ok(c.braking, 'il freno della tastiera resta attivo');
|
assert.ok(c.braking, 'il freno della tastiera resta attivo');
|
||||||
aimScreen(c, [200, 200], 10); press(c, 10);
|
aimScreen(c, [200, 200], 10); press(c, 10);
|
||||||
assert.equal(c.drag, 0, 'il nuovo tocco non eredita lo swipe');
|
assert.equal(c.drag, 0, 'il nuovo tocco non eredita lo swipe');
|
||||||
cancel(c);
|
cancel(c);
|
||||||
assert.equal(c.holding, false); assert.equal(c.braking, false); assert.equal(steering(c), 0);
|
assert.equal(c.holding, false); assert.equal(c.braking, false); assert.equal(settled(c), 0);
|
||||||
assert.equal(c.anchor, null); assert.equal(c.finger, null);
|
assert.equal(c.anchor, null); assert.equal(c.finger, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('alla bona si completano tre giri usando solo un dito, senza tasti o pulsanti', () => {
|
test('alla bona si completano tre giri usando solo un dito, senza tasti o pulsanti', () => {
|
||||||
const c = race(newGara(5, { level: 'bona', riders: piloti }));
|
const c = race(newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) }));
|
||||||
aimScreen(c, [320, 180], 'bot'); press(c, 'bot');
|
aimScreen(c, [320, 180], 'bot'); press(c, 'bot');
|
||||||
for (let t = 0; t < 180 && c.step === 'race'; t += 1 / 60) {
|
for (let t = 0; t < 180 && c.step === 'race'; t += 1 / 60) {
|
||||||
swipeTo(c, pointAt(player(c).s + 50));
|
swipeTo(c, pointAt(player(c).s + 50));
|
||||||
@@ -196,7 +198,7 @@ test('la presentazione: uno alla volta arrivano in griglia col motorino, poi il
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si vince', () => {
|
test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si vince', () => {
|
||||||
const wild = newGara(5, { level: 'bona', riders: piloti });
|
const wild = newGara(5, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) });
|
||||||
skip(wild);
|
skip(wild);
|
||||||
run(wild, COUNTDOWN + 60, () => true);
|
run(wild, COUNTDOWN + 60, () => true);
|
||||||
assert.ok(wild.heard.has('gara-fuori:andrea'));
|
assert.ok(wild.heard.has('gara-fuori:andrea'));
|
||||||
@@ -204,17 +206,26 @@ test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si v
|
|||||||
assert.equal(wild.step, 'race', 'dritto non si arriva');
|
assert.equal(wild.step, 'race', 'dritto non si arriva');
|
||||||
const heard = new Set();
|
const heard = new Set();
|
||||||
for (const seed of [1, 2, 3, 4]) {
|
for (const seed of [1, 2, 3, 4]) {
|
||||||
const c = newGara(seed, { level: 'bona', riders: piloti });
|
const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) });
|
||||||
skip(c);
|
skip(c); setPower(c, 1); // a sensibilità piena: il dito risponde subito
|
||||||
for (const k of run(c, COUNTDOWN + 120, careful)) heard.add(k);
|
for (const k of run(c, COUNTDOWN + 120, careful)) heard.add(k);
|
||||||
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player', `seme ${seed}`);
|
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player', `seme ${seed}`);
|
||||||
assert.equal(c.result.order[0], 'andrea'); assert.match(status(c), /Primo al GP di Fortezza!/);
|
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.equal(Object.keys(c.result.times).length, piloti.filter(p => p.consenso !== null).length);
|
||||||
}
|
}
|
||||||
assert.ok(['gara-giro:andrea', 'gara-arrivo:andrea', 'gara-turbo:andrea', 'gara-miscela:andrea'].every(k => heard.has(k)), [...heard].join());
|
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');
|
assert.ok(CURVES.length >= 2 && TRACK.length > 600, 'una pista con delle curve vere');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('alla sensibilità di partenza (bassa) lo stesso pilota non finisce mai nella paglia', () => {
|
||||||
|
for (const seed of [1, 2, 3]) {
|
||||||
|
const c = newGara(seed, { level: 'bona', riders: piloti.filter(p => p.consenso !== null) });
|
||||||
|
skip(c); assert.equal(c.sens, SWIPE.sens);
|
||||||
|
run(c, COUNTDOWN + 120, careful);
|
||||||
|
assert.equal(c.step, 'result'); assert.equal(c.result.offs, 0, `seme ${seed}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('le buche: piano si sobbalza, forte si casca; la lattina di miscela dà lo sprint', () => {
|
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');
|
const hole = PICKUPS.find(p => p.kind === 'buca'), can = PICKUPS.find(p => p.kind === 'miscela');
|
||||||
for (const [speed, fell] of [[20, false], [80, true]]) {
|
for (const [speed, fell] of [[20, false], [80, true]]) {
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as r from '../src/engine/rinquarto.js';
|
||||||
|
import { R, PIN, LEN, WID, predict, FIXED_STEP, VIEW, simulate, reach, speedFor } from '../src/engine/biliardo.js';
|
||||||
|
import { pinFall } from '../src/engine/biliardo-render.js';
|
||||||
|
import { drawRinquarto } from '../src/engine/rinquarto-render.js';
|
||||||
|
import { Game, MINIGAMES } from '../src/engine/game.js';
|
||||||
|
import { story } from '../src/story/story.js';
|
||||||
|
import { settle } from './helpers.mjs';
|
||||||
|
|
||||||
|
function roll(c, frames = [1 / 60]) {
|
||||||
|
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) r.update(c, frames[i % frames.length]);
|
||||||
|
assert.ok(['settle', 'result'].includes(c.step), 'il tiro si deve fermare');
|
||||||
|
}
|
||||||
|
function next(c) {
|
||||||
|
for (let i = 0; i < 20 && c.step === 'settle'; i++) r.update(c, 0.25);
|
||||||
|
assert.ok(['aim', 'result'].includes(c.step));
|
||||||
|
}
|
||||||
|
function play(c, shot = c.solution) {
|
||||||
|
Object.assign(c, shot);
|
||||||
|
assert.ok(r.shoot(c));
|
||||||
|
assert.equal(r.shoot(c), false, 'un solo tiro per posizione');
|
||||||
|
roll(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stesso seme e livello riproducono tutte le sei posizioni e le soluzioni', () => {
|
||||||
|
for (const level of Object.keys(r.LEVELS)) {
|
||||||
|
const a = r.newRinquarto(123, { level }), b = r.newRinquarto(123, { level });
|
||||||
|
for (let i = 1; i <= a.skill.positions; i++) {
|
||||||
|
assert.equal(a.position, i);
|
||||||
|
assert.deepEqual(a, b);
|
||||||
|
assert.match(r.status(a), /Bertovo: «Oh tirami questo rinquarto!\?»/);
|
||||||
|
play(a); play(b); next(a); next(b);
|
||||||
|
}
|
||||||
|
assert.deepEqual(a, b);
|
||||||
|
}
|
||||||
|
assert.notDeepEqual(r.newRinquarto(12).balls, r.newRinquarto(13).balls);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ogni posizione è coperta geometricamente e il solver trova un tiro che fa punto davvero', () => {
|
||||||
|
for (const level of Object.keys(r.LEVELS)) for (const seed of [0, 1, 42, 0xffffffff]) {
|
||||||
|
const c = r.newRinquarto(seed, { level });
|
||||||
|
for (let i = 0; i < c.skill.positions; i++) {
|
||||||
|
assert.ok(r.directCovered(c));
|
||||||
|
// Verifica indipendente: il disturbo sul segmento sta fra i due centri.
|
||||||
|
const a = c.balls.player, b = c.balls.cpu, d = c.balls.blocker;
|
||||||
|
const distance = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
const area = Math.abs((b.x - a.x) * (d.y - a.y) - (b.y - a.y) * (d.x - a.x));
|
||||||
|
assert.ok(area / distance < 2 * R);
|
||||||
|
assert.ok(Math.hypot(d.x - a.x, d.y - a.y) + Math.hypot(b.x - d.x, b.y - d.y) <= distance + 1e-6);
|
||||||
|
for (const ball of Object.values(c.balls)) {
|
||||||
|
assert.ok(ball.x >= R && ball.x <= LEN - R && ball.y >= R && ball.y <= WID - R);
|
||||||
|
for (const other of Object.values(c.balls)) if (other !== ball)
|
||||||
|
assert.ok(Math.hypot(ball.x - other.x, ball.y - other.y) > 2 * R);
|
||||||
|
}
|
||||||
|
const before = structuredClone(c), solution = r.solve(c);
|
||||||
|
assert.ok(solution);
|
||||||
|
assert.deepEqual(c, before, 'la ricerca non cambia lo stato né il seme');
|
||||||
|
const expected = r.tryShot(c, solution.angle, solution.power);
|
||||||
|
assert.ok(expected.point && expected.cushions >= c.skill.cushions);
|
||||||
|
assert.equal(r.tryShot(c, c.angle, 1).point, false, 'il diretto è coperto');
|
||||||
|
const points = c.points;
|
||||||
|
play(c, solution);
|
||||||
|
assert.deepEqual(c.attempt, expected);
|
||||||
|
assert.equal(c.points, points + 1);
|
||||||
|
next(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const clear = level => {
|
||||||
|
const c = r.newRinquarto(42, { level });
|
||||||
|
c.balls = {
|
||||||
|
player: { role: 'player', x: 80, y: 50, vx: 0, vy: 0 },
|
||||||
|
cpu: { role: 'cpu', x: 80, y: 100, vx: 0, vy: 0 },
|
||||||
|
};
|
||||||
|
c.pins = [];
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('un diretto libero non vale; una sponda vale solo alla bona, senza recuperare dopo il primo contatto', () => {
|
||||||
|
assert.deepEqual(Object.values(r.LEVELS).map(k => k.cushions), [1, 2, 3]);
|
||||||
|
for (const level of Object.keys(r.LEVELS)) {
|
||||||
|
const c = clear(level);
|
||||||
|
assert.equal(r.directCovered(c), false);
|
||||||
|
const direct = r.tryShot(c, Math.PI / 2, 1);
|
||||||
|
assert.equal(direct.cushions, 0);
|
||||||
|
assert.equal(direct.point, false);
|
||||||
|
assert.equal(direct.reason, 'poche sponde');
|
||||||
|
const bank = r.tryShot(c, -Math.PI / 2, 0.7);
|
||||||
|
assert.equal(bank.cushions, 1);
|
||||||
|
assert.equal(bank.point, level === 'bona');
|
||||||
|
play(c, { angle: -Math.PI / 2, power: 0.7 });
|
||||||
|
assert.equal(c.points, Number(level === 'bona'));
|
||||||
|
assert.deepEqual(c.attempt, bank);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('biglie e birilli di disturbo prima del bersaglio annullano il tiro', () => {
|
||||||
|
for (const kind of ['ball', 'pin']) {
|
||||||
|
const c = clear('bona');
|
||||||
|
if (kind === 'ball') c.balls.extra = { role: 'extra', x: 80, y: 25, vx: 0, vy: 0 };
|
||||||
|
else c.pins.push({ x: 80, y: 25, down: false });
|
||||||
|
const result = r.tryShot(c, -Math.PI / 2, 1);
|
||||||
|
assert.equal(result.point, false);
|
||||||
|
assert.equal(result.reason, 'disturbo toccato');
|
||||||
|
play(c, { angle: -Math.PI / 2, power: 1 });
|
||||||
|
assert.equal(c.points, 0);
|
||||||
|
assert.equal(c.attempt.reason, result.reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un disturbo dopo il bersaglio non annulla un punto già fatto', () => {
|
||||||
|
const c = clear('bona');
|
||||||
|
// Dopo la sponda alta la bianca colpisce la gialla, che abbatte il birillo.
|
||||||
|
c.pins = [{ x: 80, y: 120, down: false }];
|
||||||
|
const events = [];
|
||||||
|
simulate(c, 'player', -Math.PI / 2, 1, { onContact: e => events.push({ kind: e.kind, role: e.a.role }) });
|
||||||
|
assert.ok(events.findIndex(e => e.kind === 'pin') > events.findIndex(e => e.kind === 'ball'));
|
||||||
|
play(c, { angle: -Math.PI / 2, power: 1 });
|
||||||
|
assert.ok(c.pins[0].down, 'il contatto successivo avviene davvero');
|
||||||
|
assert.equal(c.points, 1);
|
||||||
|
assert.equal(c.attempt.reason, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('durante l’esito continua la caduta dei birilli, senza assegnare altri punti', () => {
|
||||||
|
const c = clear('bona');
|
||||||
|
play(c, { angle: -Math.PI / 2, power: 0.7 });
|
||||||
|
const p = { down: true, visualDownAt: c.visual.time };
|
||||||
|
c.pins.push(p);
|
||||||
|
const history = structuredClone(c.history), points = c.points;
|
||||||
|
r.update(c, 0.12);
|
||||||
|
assert.ok(Math.abs(pinFall(p, c.visual.time) - 0.5) < 1e-10);
|
||||||
|
r.update(c, 0.12);
|
||||||
|
assert.ok(Math.abs(pinFall(p, c.visual.time) - 1) < 1e-10);
|
||||||
|
assert.equal(c.step, 'settle');
|
||||||
|
assert.equal(c.points, points);
|
||||||
|
assert.deepEqual(c.history, history);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un birillo colpito appena prima dell’arresto finisce di cadere anche nel riepilogo finale', () => {
|
||||||
|
const c = clear('bona');
|
||||||
|
c.position = c.skill.positions;
|
||||||
|
// Urto reale negli ultimi centimetri di un tiro debole: il riepilogo arriva durante la caduta.
|
||||||
|
c.pins = [{ x: 80 + reach(speedFor(0)) + R + PIN - 0.01, y: 50, down: false }];
|
||||||
|
play(c, { angle: 0, power: 0 });
|
||||||
|
assert.equal(c.step, 'result');
|
||||||
|
const p = c.pins[0], before = structuredClone(c);
|
||||||
|
assert.ok(p.down && pinFall(p, c.visual.time) < 1);
|
||||||
|
r.update(c, 0.25);
|
||||||
|
assert.equal(pinFall(p, c.visual.time), 1);
|
||||||
|
before.visual.time = c.visual.time;
|
||||||
|
assert.deepEqual(c, before, 'avanza solo l’animazione, senza nuovi tiri o punti');
|
||||||
|
for (const dt of [0, -1, NaN, Infinity]) r.update(c, dt);
|
||||||
|
assert.deepEqual(c, before, 'tempi non validi non cambiano il riepilogo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gloria alla soglia di LEVELS, sconfitta un punto sotto: la sfida dura sempre tutte le posizioni', () => {
|
||||||
|
for (const level of Object.keys(r.LEVELS)) for (const win of [true, false]) {
|
||||||
|
const c = r.newRinquarto(17, { level }), target = c.skill.glory - Number(!win);
|
||||||
|
for (let i = 0; i < c.skill.positions; i++) {
|
||||||
|
play(c, i < target ? c.solution : { angle: c.angle, power: 1 });
|
||||||
|
if (i < c.skill.positions - 1) assert.equal(c.result, undefined);
|
||||||
|
next(c);
|
||||||
|
}
|
||||||
|
assert.equal(c.history.length, c.skill.positions);
|
||||||
|
assert.equal(c.points, target);
|
||||||
|
assert.equal(c.result.winner, win ? 'player' : 'cpu');
|
||||||
|
assert.match(r.status(c), win ? /Gloria!/ : /Sfida persa/);
|
||||||
|
const final = structuredClone(c);
|
||||||
|
r.update(c, 10);
|
||||||
|
final.visual.time = c.visual.time;
|
||||||
|
assert.deepEqual(c, final, 'il riepilogo rimane visibile');
|
||||||
|
assert.equal(r.press(c), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guida lunga fino alla seconda sponda, corta fino a 65 cm, assente a fassi male', () => {
|
||||||
|
const c = clear('bona');
|
||||||
|
c.balls.cpu.x = 200; c.angle = -Math.PI / 2; c.power = 1;
|
||||||
|
assert.deepEqual(r.guide(c), predict(c).points.slice(0, 3));
|
||||||
|
assert.equal(r.guide(c).length, 3);
|
||||||
|
c.skill = r.LEVELS.seria;
|
||||||
|
const short = r.guide(c);
|
||||||
|
assert.ok(short.length <= 2);
|
||||||
|
assert.ok(Math.hypot(short[1][0] - short[0][0], short[1][1] - short[0][1]) <= 65);
|
||||||
|
c.skill = r.LEVELS.male;
|
||||||
|
assert.deepEqual(r.guide(c), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la guida si ferma al primo disturbo o all’arresto della biglia', () => {
|
||||||
|
const c = clear('bona');
|
||||||
|
c.angle = -Math.PI / 2; c.power = 1;
|
||||||
|
c.balls.extra = { role: 'extra', x: 80, y: 25, vx: 0, vy: 0 };
|
||||||
|
let points = r.guide(c);
|
||||||
|
assert.equal(points.length, 2);
|
||||||
|
assert.ok(Math.abs(points[1][1] - (25 + 2 * R)) < 1e-7);
|
||||||
|
delete c.balls.extra;
|
||||||
|
c.angle = 0; c.power = 0;
|
||||||
|
points = r.guide(c);
|
||||||
|
assert.equal(points.length, 2);
|
||||||
|
assert.ok(points[1][0] > 80 && points[1][0] < 100, 'la guida non arriva alla sponda se manca forza');
|
||||||
|
assert.equal(points[1][1], 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comandi, suoni e fisica restano coerenti a frequenze diverse', () => {
|
||||||
|
const states = [];
|
||||||
|
for (const frames of [[1 / 30], [1 / 144], [0.008, 0.042, 0.09]]) {
|
||||||
|
const c = r.newRinquarto(42);
|
||||||
|
assert.ok(r.canAdjust(c));
|
||||||
|
r.press(c); r.update(c, 0.05); r.release(c);
|
||||||
|
assert.equal(c.step, 'aim'); assert.equal(c.power, 0.75);
|
||||||
|
r.press(c); r.update(c, 0.2);
|
||||||
|
assert.equal(r.aimScreen(c, [VIEW.x + 100, VIEW.y + 100]), false);
|
||||||
|
assert.equal(r.canAdjust(c), false);
|
||||||
|
r.cancel(c);
|
||||||
|
Object.assign(c, c.solution); r.shoot(c); roll(c, frames);
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'biliardo-cue'));
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'biliardo-rail'));
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'biliardo-hit'));
|
||||||
|
states.push({ balls: c.balls, pins: c.pins.map(({ visualDownAt, ...p }) => p), points: c.points, attempt: c.attempt });
|
||||||
|
}
|
||||||
|
for (const state of states.slice(1)) assert.deepEqual(state, states[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dialogo di Bertovo: si chiacchiera, lui lancia la sfida, poi tre livelli e No, primo piano e battute di uscita; lui sta al tavolo di mezzo del Circolo', () => {
|
||||||
|
assert.ok(MINIGAMES.rinquarto);
|
||||||
|
assert.equal(story.characters.bertovo.sprite, 'assets/characters/bertovo.json');
|
||||||
|
// Solo al Circolo 4 Maggio, e parlandoci si apre il suo dialogo.
|
||||||
|
const where = Object.entries(story.scenes).filter(([, sc]) => JSON.stringify(sc).includes('bertovo')).map(([id]) => id);
|
||||||
|
assert.deepEqual(where, ['circolo_4_maggio']);
|
||||||
|
const circolo = new Game(story); circolo.go('circolo_4_maggio'); settle(circolo);
|
||||||
|
assert.ok(circolo.people().some(p => p.who === 'bertovo'));
|
||||||
|
circolo.act('talk', 'bertovo'); settle(circolo);
|
||||||
|
assert.ok(circolo.dialogue, 'parlandoci si sceglie il livello');
|
||||||
|
for (const [index, level] of ['bona', 'seria', 'male', null].entries()) {
|
||||||
|
const g = new Game(story); settle(g);
|
||||||
|
g.run({ dialogue: 'bertovo' });
|
||||||
|
// Prima si chiacchiera; la sfida la lancia lui, poi si sceglie il livello.
|
||||||
|
assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito');
|
||||||
|
g.choose(g.options().findIndex(o => o.text === 'Dai, fammi vedere un rinquarto.'));
|
||||||
|
assert.ok(settle(g).some(s => s.who === 'bertovo' && s.text.includes('Oh tirami questo rinquarto!?')));
|
||||||
|
assert.equal(g.options().length, 4);
|
||||||
|
g.choose(index); settle(g);
|
||||||
|
if (!level) { assert.equal(g.closeup, null); assert.ok(g.dialogue, 'col No si torna a parlare'); continue; }
|
||||||
|
assert.equal(g.closeup.id, 'rinquarto');
|
||||||
|
assert.equal(g.closeup.level, level);
|
||||||
|
assert.ok(g.closeupPress());
|
||||||
|
g.update(0.2); assert.ok(g.closeupRelease());
|
||||||
|
assert.equal(g.closeup.step, 'rolling');
|
||||||
|
g.closeupClose();
|
||||||
|
assert.ok(settle(g).some(s => s.text === story.closeups.rinquarto.quit[0].say));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il riepilogo resta aperto e uscendo parte la battuta di vittoria o sconfitta', () => {
|
||||||
|
for (const win of [true, false]) {
|
||||||
|
const g = new Game(story); settle(g);
|
||||||
|
g.run({ closeup: 'rinquarto', level: 'bona' });
|
||||||
|
const c = g.closeup;
|
||||||
|
for (let i = 0; i < c.skill.positions; i++) {
|
||||||
|
play(c, win ? c.solution : { angle: c.angle, power: 1 });
|
||||||
|
next(c);
|
||||||
|
}
|
||||||
|
g.update(0.1);
|
||||||
|
assert.equal(g.closeup, c);
|
||||||
|
assert.equal(c.result.winner, win ? 'player' : 'cpu');
|
||||||
|
g.closeupClose();
|
||||||
|
assert.equal(g.closeup, null);
|
||||||
|
const line = story.closeups.rinquarto[win ? 'win' : 'lose'][0];
|
||||||
|
assert.ok(settle(g).some(s => s.text === line.say && s.who === 'bertovo'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disegno senza sprite, con disturbi e riepilogo, senza modificare lo stato', () => {
|
||||||
|
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
|
||||||
|
const previous = globalThis.document;
|
||||||
|
globalThis.document = { createElement: () => ({ getContext: () => ctx }) };
|
||||||
|
try {
|
||||||
|
for (const level of Object.keys(r.LEVELS)) {
|
||||||
|
const c = r.newRinquarto(42, { level }), bg = {};
|
||||||
|
const draw = () => {
|
||||||
|
const before = structuredClone(c);
|
||||||
|
drawRinquarto(ctx, c, story.closeups.rinquarto, bg);
|
||||||
|
assert.deepEqual(c, before);
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
r.shoot(c); r.update(c, FIXED_STEP); draw(); roll(c); draw();
|
||||||
|
c.step = 'result'; c.result = { winner: 'cpu' }; draw();
|
||||||
|
assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete globalThis.document; else globalThis.document = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,5 +8,8 @@
|
|||||||
"S'ha andà a fallo": "saandà a fallo",
|
"S'ha andà a fallo": "saandà a fallo",
|
||||||
"Ghiacciomobile": "Ghiacciomòbile",
|
"Ghiacciomobile": "Ghiacciomòbile",
|
||||||
"Not today": "Nòt tudèi",
|
"Not today": "Nòt tudèi",
|
||||||
"Commodore": "Commòdòr"
|
"Commodore": "Commòdòr",
|
||||||
|
"Let it be": "Leddibì",
|
||||||
|
"tirami": "tìrami",
|
||||||
|
"Bertovo": "Bertòvo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
"macchina": {
|
"macchina": {
|
||||||
"speaker": "Andrew Chipper"
|
"speaker": "Andrew Chipper"
|
||||||
},
|
},
|
||||||
|
"bertovo": {
|
||||||
|
"speaker": "Viktor Eka"
|
||||||
|
},
|
||||||
"ing_tozzi": {
|
"ing_tozzi": {
|
||||||
"speaker": "Badr Odhiambo"
|
"speaker": "Badr Odhiambo"
|
||||||
},
|
},
|
||||||
|
|||||||