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
This commit is contained in:
2026-10-28 23:36:05 +01:00
co-authored by Claude Opus 5.5
parent 6585c9ffcb
commit bf22dc2511
54 changed files with 954 additions and 117 deletions
+70
View File
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+96
View File
@@ -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
+17 -1
View File
@@ -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
+21
View File
@@ -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.
+5 -5
View File
@@ -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);
+6 -6
View File
@@ -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);
+2
View File
@@ -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 },
+17 -8
View File
@@ -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).
+6
View File
@@ -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'));
+49
View File
@@ -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();
}
+138
View File
@@ -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.`}`;
}
+105 -60
View File
@@ -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);
} }
} }
+9 -3
View File
@@ -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' },
]; ];
+66 -4
View File
@@ -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: {
+5
View File
@@ -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; }
+40 -29
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { 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]]) {
+295
View File
@@ -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;
}
});
+4 -1
View File
@@ -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"
} }
+3
View File
@@ -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"
}, },