I rinquarti di Bertovo: gara a punti (Bertovo tira dopo Andrea, preciso secondo il livello), allenamento, posizioni misurate, traiettoria con le sponde e Prossima, comandi sicuri col dito (revisione di Codex); la Brauda in gara; voce di Bertovo (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:55:47 +01:00
co-authored by Claude Opus 5.5
parent bf22dc2511
commit 086e4682d4
24 changed files with 506 additions and 422 deletions
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.
+19 -15
View File
@@ -2439,6 +2439,10 @@
"file": "andrea-728f589a77.mp3", "file": "andrea-728f589a77.mp3",
"sec": 1.68 "sec": 1.68
}, },
"andrea|Si fa una sfida a rinquarti?": {
"file": "andrea-fe39c8f30c.mp3",
"sec": 1.49
},
"andrea|Ci si vede, Bertovo.": { "andrea|Ci si vede, Bertovo.": {
"file": "andrea-a3364fd72b.mp3", "file": "andrea-a3364fd72b.mp3",
"sec": 1.78 "sec": 1.78
@@ -2455,13 +2459,13 @@
"file": "bertovo-6c52f3fa0d.mp3", "file": "bertovo-6c52f3fa0d.mp3",
"sec": 1.52 "sec": 1.52
}, },
"bertovo|Te lo faccio vedere? No: lo tiri te.": { "bertovo|Guarda bene: prima lo tiro io, poi lo rifai te. Tre posizioni, una sponda.": {
"file": "bertovo-fde93190e9.mp3", "file": "bertovo-ad20035f2e.mp3",
"sec": 3.12 "sec": 6.6
}, },
"bertovo|Le biglie le metto io, nascoste per bene. Oh tirami questo rinquarto!?": { "bertovo|Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?": {
"file": "bertovo-a6a09b5b87.mp3", "file": "bertovo-80c18678cd.mp3",
"sec": 5.83 "sec": 8.82
}, },
"bertovo|Occhio alle sponde, fuori.": { "bertovo|Occhio alle sponde, fuori.": {
"file": "bertovo-4d4d630038.mp3", "file": "bertovo-4d4d630038.mp3",
@@ -2755,21 +2759,21 @@
"file": "ing_tozzi-282062c119.mp3", "file": "ing_tozzi-282062c119.mp3",
"sec": 3.64 "sec": 3.64
}, },
"bertovo|Questo rinquarto era bono!": { "bertovo|M'hai battuto ai rinquarti. Però un lo dire a nessuno.": {
"file": "bertovo-d71d01230d.mp3", "file": "bertovo-9aa247cffe.mp3",
"sec": 1.26 "sec": 4.2
}, },
"bertovo|Le sponde ci sono per qualcosa!": { "bertovo|Le sponde ci sono per qualcosa! Basta chiederglielo per bene.": {
"file": "bertovo-a63d2da89d.mp3", "file": "bertovo-5d93de3623.mp3",
"sec": 1.44 "sec": 4.13
}, },
"andrea|Questo me lo tengo per la prossima.": { "andrea|Questo me lo tengo per la prossima.": {
"file": "andrea-c64bb3be5a.mp3", "file": "andrea-c64bb3be5a.mp3",
"sec": 1.82 "sec": 1.82
}, },
"bertovo|Si riprova?": { "bertovo|Visto? Geometrie celestiali. Si riprova quando vuoi.": {
"file": "bertovo-03564e5a92.mp3", "file": "bertovo-ca941bee91.mp3",
"sec": 0.63 "sec": 5.41
}, },
"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",
+8
View File
@@ -158,3 +158,11 @@ equitazione, minigolf. Risulta invece il circuito cittadino della «I Coppa di S
11 giugno 1967: 5.600 m in senso orario attorno al poggio della Fortezza, partenza davanti alla scuola in Via 11 giugno 1967: 5.600 m in senso orario attorno al poggio della Fortezza, partenza davanti alla scuola in Via
Provinciale, con la strada di Massorondinaio asfaltata apposta come bretella (fonte: CAMET, «storia circuito San Provinciale, con la strada di Massorondinaio asfaltata apposta come bretella (fonte: CAMET, «storia circuito San
Piero»). Il disegno della pista del campeggio va preso dai ricordi di Andrea **[DA ANDREA]**. Piero»). Il disegno della pista del campeggio va preso dai ricordi di Andrea **[DA ANDREA]**.
## La pista col gesso delle Micro Machines (Andrea, 28 ottobre)
Sul parcheggio sotto casa, a Cardetole (le righe gialle), si disegnava col gesso la pista delle macchinine; si davano
spinte a turno e la macchinina doveva restare in pista: tutta fuori, si torna da dove si era partiti. Si gioca contro
Tozzino. Le macchinine: Micro Machines, le Formula 1 degli anni 80/90 con gli alettoni e i loro colori (senza marchi
né nomi veri). Proposta: vista dall'alto sull'asfalto, mira e spinta come al biliardo (riusarne la fisica), a turno,
vince chi taglia per primo il traguardo. Il quadro della pista e le macchinine da Codex (crediti finiti il 28: domani).
+5 -1
View File
@@ -283,7 +283,11 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
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 - 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). foto (`prompts/personaggi/bertovo-sheet.txt`): parlandoci, i suoi rinquarti (`src/engine/rinquarto.js`). Dopo la
revisione di Codex: gara a punti (sulla stessa posizione tira prima Andrea, poi il tavolo torna com'era e tira
Bertovo, preciso secondo il livello), allenamento («Fammi vedere un rinquarto»: tira prima lui), posizioni misurate
(finestra di angoli buoni sempre più stretta), forza sempre piena, traiettoria con le sponde numerate e Prossima,
tocco che mira senza tirare, un dito solo per volta, frecce che girano finché tenute.
- 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
+12 -13
View File
@@ -250,19 +250,18 @@
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> <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. <p>I rinquarti di Bertovo: sei posizioni. Su ognuna tiri prima tu, poi il tavolo torna com'era e tira Bertovo; un
Le biglie viola con la croce e i birilli sono disturbi: toccarne uno prima del bersaglio annulla il tiro. punto per ogni rinquarto riuscito, vince chi ne fa di più (si può anche pareggiare). La tua biglia è la bianca, il
La linea diretta è sempre coperta, ma esiste sempre almeno un tiro valido di sponda.</p> bersaglio la gialla con l'anello. Le biglie viola con la croce e i birilli sono disturbi: toccarne uno prima del
<p>Un punto se la bianca tocca il bersaglio dopo almeno il numero di sponde richiesto. bersaglio annulla il tiro. La diretta è sempre coperta, ma c'è sempre un rinquarto possibile.</p>
Conta il primo contatto: arrivare al bersaglio troppo presto non vale, anche se lo ritocchi dopo. <p>Punto se la bianca tocca la gialla dopo almeno le sponde richieste: conta il primo contatto. Alla bona una
Alla bona: una sponda, guida fino al secondo rimbalzo, gloria con 3 punti su 6. sponda e la guida fino al secondo rimbalzo, Seria due sponde e la guida corta, A fassi male tre sponde e niente
Seria: due sponde, guida corta (65 cm al massimo, fino alla prima sponda), gloria con 3 punti su 6. guida. Le posizioni si fanno più strette man mano, e Bertovo è più preciso a ogni livello. La forza è sempre piena:
A fassi male: tre sponde, nessuna guida, gloria con 2 punti su 6. è un rompicapo di angoli. Nell'allenamento («Fammi vedere un rinquarto») tira prima lui, poi lo rifai tu.</p>
La guida si ferma sempre al primo ostacolo o quando la biglia si arresta.</p> <p>Muovi il mouse o tocca il tavolo per mirare: un tocco fissa solo la mira. Per tirare premi Tira, oppure tieni
<p>Muovi il mouse per mirare; clic o tocco fissa la mira. Frecce ← → per affinarla, Maiusc per passi più piccoli; premuto sul tavolo (o Spazio) finché la stecca si carica e lascia. Frecce ← → o i pulsanti Mira (tenuti
↑ ↓ o cursore per la forza, poi Tira. Oppure tieni premuto sul tavolo, Spazio o Invio e rilascia per tirare. premuti) girano la stecca, sempre più svelti; con Maiusc a passi fini. Dopo il tiro la traiettoria resta sul tavolo
Durante la carica la mira resta ferma. Dopo il tiro leggi Punto! o Niente; la posizione seguente arriva da sé. con le sponde numerate e il punto del contatto: tocca o premi Prossima per andare avanti. Esc esce.</p>
Esc esce; alla fine il riepilogo resta finché torni da Bertovo.</p>
</details> </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>
+1 -1
View File
@@ -206,7 +206,7 @@ export function drawBiliardo(ctx, c, def, bg, sheet) {
ctx.restore(); ctx.restore();
scoreboard(ctx, c, def); scoreboard(ctx, c, def);
const hint = canThrow(c) ? predict(c).hit : null; const hint = canThrow(c) ? predict(c).hit : null;
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).' const instruction = c.holding ? c.charging ? 'Carica iniziata: rilascia per tirare.' : 'Tieni 0,6 s per caricare; un tocco fissa la mira.' : hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).' : hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).'
: c.aimLocked ? 'Mira fissata: un altro clic la sposta.' : 'La stecca segue il mouse: clic per fissare la mira.'; : c.aimLocked ? 'Mira fissata: un altro clic la sposta.' : 'La stecca segue il mouse: clic per fissare la mira.';
ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15); ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15);
+42 -10
View File
@@ -9,7 +9,7 @@ export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHIO
export const SWING = 1.4, FIXED_STEP = 1 / 240; export const SWING = 1.4, FIXED_STEP = 1 / 240;
// La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo). // La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo).
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini. // Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
export const WINDUP = 0.18; export const WINDUP = 0.18, LONG_PRESS = 0.6;
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5; const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95; const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
export const OTHER = { player: 'cpu', cpu: 'player' }; export const OTHER = { player: 'cpu', cpu: 'player' };
@@ -169,7 +169,8 @@ function step(w, dt) {
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) }); w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
} }
function strike(c, who, angle, power) { export function strike(c, who, angle, power) {
c.turn = null;
const b = c.balls[who], v = speedFor(power); const b = c.balls[who], v = speedFor(power);
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y }; c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power }; c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
@@ -242,7 +243,15 @@ export function update(c, dt, onContact) {
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;
if (c.last) c.last.age += dt; if (c.last) c.last.age += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } if (c.turn && canThrow(c) && !c.holding) {
c.turn.time += dt;
c.angle += c.turn.dir * dt * (c.turn.fine ? 0.03 : 0.6 + 1.8 * Math.min(1, c.turn.time / 1.2));
}
if (c.holding) {
c.holdTime += dt;
c.charging = c.holdTime >= c.holdDelay;
if (c.charging) c.power = c.fixedPower ?? (c.holdDelay ? Math.min(1, c.previousPower + powerAt(c.holdTime - c.holdDelay)) : powerAt(c.holdTime));
}
if (c.step === 'rolling' && c.windup > 0) { if (c.step === 'rolling' && c.windup > 0) {
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto. // La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
c.windup -= dt; c.windup -= dt;
@@ -288,18 +297,40 @@ export function setPower(c, value) {
c.power = clamp(value, 0, 1); return true; c.power = clamp(value, 0, 1); return true;
} }
export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); } export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
export function press(c) { // Una pressione continua ruota senza dipendere dalla ripetizione della tastiera.
if (!canThrow(c) || c.holding) return false; export function turn(c, dir, owner, fine = false) {
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true; if (!canThrow(c) || c.holding || c.turn) return false;
c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.0005 : 0.03);
c.aimLocked = true; return true;
} }
export function cancel(c) { export function stopTurn(c, owner) {
if (c.turn?.owner !== owner) return false;
c.turn = null; return true;
}
export function key(c, key, down, fine = false) {
if (!['ArrowLeft', 'ArrowRight'].includes(key)) return false;
if (down) { if (c.turn?.owner === key) c.turn.fine = fine; else turn(c, key === 'ArrowLeft' ? -1 : 1, key, fine); }
else stopTurn(c, key);
return true;
}
export function press(c, pointerId) {
if (!canThrow(c) || c.holding) return false;
c.turn = null; c.pointerId = pointerId; c.holdDelay = pointerId === undefined ? 0 : LONG_PRESS;
c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0;
if (!c.holdDelay && c.fixedPower === undefined) c.power = 0;
return true;
}
export function cancel(c, pointerId) {
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
c.turn = null; c.charging = false;
if (!c.holding) return false; if (!c.holding) return false;
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true; c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
} }
export function release(c) { export function release(c, pointerId) {
if (c.pointerId !== pointerId) return false;
if (!c.holding) return false; if (!c.holding) return false;
if (c.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto
c.holding = false; return strike(c, 'player', c.angle, c.power); c.holding = c.charging = false; return strike(c, 'player', c.angle, c.power);
} }
// La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito. // La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
@@ -363,6 +394,7 @@ export function status(c, names) {
if (c.step === 'result') return `Partita a ${names[c.result.winner]}: ${c.points.player} a ${c.points.cpu}.`; if (c.step === 'result') return `Partita a ${names[c.result.winner]}: ${c.points.player} a ${c.points.cpu}.`;
if (c.step === 'rolling') return 'Biglie in movimento…'; if (c.step === 'rolling') return 'Biglie in movimento…';
if (c.step === 'settle') return 'Si contano i birilli…'; if (c.step === 'settle') return 'Si contano i birilli…';
if (c.holding) return c.charging ? 'Carica iniziata: rilascia per tirare.' : 'Mira fissata. Tieni 0,6 s per iniziare la carica.';
const last = lastLine(c, names); const last = lastLine(c, names);
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`; return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
} }
+3 -3
View File
@@ -313,9 +313,9 @@ export class Game {
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio). // Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; } minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
closeupPress() { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup); } closeupPress(pointerId) { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup, pointerId); }
closeupRelease() { return !!this.minigame()?.release?.(this.closeup); } closeupRelease(pointerId) { return !!this.minigame()?.release?.(this.closeup, pointerId); }
closeupCancel() { return !!this.minigame()?.cancel?.(this.closeup); } closeupCancel(pointerId) { return !!this.minigame()?.cancel?.(this.closeup, pointerId); }
closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); } closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio: // Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
+45 -17
View File
@@ -1,9 +1,28 @@
import { LEN, WID, R, VIEW, WINDUP } from './biliardo.js'; import { LEN, WID, R, VIEW, WINDUP } from './biliardo.js';
import { table, sphere, pin, cue } from './biliardo-render.js'; import { table, sphere, pin, cue } from './biliardo-render.js';
import { guide, canThrow } from './rinquarto.js'; import { guide, canThrow, shooter } from './rinquarto.js';
import { text, panel } from './bocce-render.js'; import { text, panel } from './bocce-render.js';
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k; const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
const NAME = { andrea: 'Andrea', bertovo: 'Bertovo' };
// La traiettoria del tiro: resta sul tavolo finché non si va avanti, con le sponde numerate e il contatto che decide.
function trail(ctx, c) {
if (c.trail.length < 2) return;
const good = c.attempt.point, done = c.step === 'review';
ctx.strokeStyle = done ? (good ? '#b9e5b3' : '#ffbf9f') : '#fff8dccc'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(sx(c.trail[0][0]), sy(c.trail[0][1]));
for (const [x, y] of c.trail.slice(1)) ctx.lineTo(sx(x), sy(y));
ctx.stroke();
c.attempt.marks.forEach((m, i) => {
ctx.fillStyle = '#17160f'; ctx.beginPath(); ctx.arc(sx(m.x), sy(m.y), 5, 0, Math.PI * 2); ctx.fill();
text(ctx, String(i + 1), sx(m.x), sy(m.y) + 0.5, 7, '#ffe28a', 'center');
});
if (done && c.attempt.hit) {
const { x, y } = c.attempt.hit;
ctx.strokeStyle = good ? '#b9e5b3' : '#ff6a4a'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(sx(x), sy(y), R * VIEW.k + 4, 0, Math.PI * 2); ctx.stroke();
}
}
export function drawRinquarto(ctx, c, def, bg) { export function drawRinquarto(ctx, c, def, bg) {
ctx.save(); ctx.imageSmoothingEnabled = false; ctx.save(); ctx.imageSmoothingEnabled = false;
table(ctx, bg); table(ctx, bg);
@@ -17,33 +36,42 @@ export function drawRinquarto(ctx, c, def, bg) {
ctx.stroke(); ctx.setLineDash([]); ctx.stroke(); ctx.setLineDash([]);
} }
const b = c.balls.player; const b = c.balls.player;
cue(ctx, b.x, b.y, c.angle, R * VIEW.k + 6 + c.power * 26); cue(ctx, b.x, b.y, c.angle, R * VIEW.k + 6 + (c.charging ? 26 : 10));
} else if (c.visual.shot && c.windup > 0) { } else if (c.visual.shot && c.windup > 0) {
const s = c.visual.shot; const s = c.visual.shot;
cue(ctx, s.x, s.y, s.angle, R * VIEW.k + (6 + s.power * 26) * c.windup / WINDUP); cue(ctx, s.x, s.y, s.angle, R * VIEW.k + 32 * c.windup / WINDUP);
} }
trail(ctx, c);
for (const p of c.pins) pin(ctx, p, c.visual.time); for (const p of c.pins) pin(ctx, p, c.visual.time);
// Il bersaglio ha un anello che pulsa; i disturbi una croce grossa, che si vede anche sul telefono.
const tgt = c.balls.cpu, pulse = 1 + Math.sin(c.visual.time * 4) * 0.5;
ctx.strokeStyle = '#ffe28a'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(sx(tgt.x), sy(tgt.y), R * VIEW.k + 3 + pulse, 0, Math.PI * 2); ctx.stroke();
for (const b of Object.values(c.balls)) { for (const b of Object.values(c.balls)) {
sphere(ctx, b.role, sx(b.x), sy(b.y), R * VIEW.k); 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)) { if (!['player', 'cpu'].includes(b.role)) {
ctx.strokeStyle = '#2b163e'; ctx.lineWidth = 1; const k = R * VIEW.k;
ctx.beginPath(); ctx.moveTo(sx(b.x) - 2, sy(b.y) - 2); ctx.lineTo(sx(b.x) + 2, sy(b.y) + 2); ctx.strokeStyle = '#1b0d29'; ctx.lineWidth = 2;
ctx.moveTo(sx(b.x) + 2, sy(b.y) - 2); ctx.lineTo(sx(b.x) - 2, sy(b.y) + 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sx(b.x) - k, sy(b.y) - k); ctx.lineTo(sx(b.x) + k, sy(b.y) + k);
ctx.moveTo(sx(b.x) + k, sy(b.y) - k); ctx.lineTo(sx(b.x) - k, sy(b.y) + k); ctx.stroke();
} }
} }
ctx.restore(); ctx.restore();
// In alto: chi tira, il punteggio, le sponde; l'esito sta qui, così non copre il tavolo.
ctx.fillStyle = '#17160ff0'; ctx.fillRect(54, 4, 532, 64); ctx.fillStyle = '#17160ff0'; ctx.fillRect(54, 4, 532, 64);
text(ctx, 'I RINQUARTI DI BERTOVO', 320, 13, 9, '#efd49e', 'center'); text(ctx, c.skill.training ? 'I RINQUARTI DI BERTOVO · ALLENAMENTO' : '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'); const score = c.skill.training ? 'senza punti' : `Andrea ${c.score.andrea} Bertovo ${c.score.bertovo}`;
text(ctx, canThrow(c) ? `Bianca: ${def.names.player} · Gialla: bersaglio · Viola e birilli: disturbi` : c.line, 320, 43, 7, '#d9ccba', 'center'); text(ctx, `Posizione ${c.position}/${c.skill.positions} ${score} Sponde ${c.attempt.cushions}/${c.skill.cushions}`, 320, 29, 9, '#fff0c8', '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'); const who = NAME[shooter(c)];
if (c.step === 'settle' || c.step === 'result') { const line = c.step === 'review' ? c.line : c.step === 'think' ? 'Tira Bertovo…' : c.step === 'rolling' ? `Tira ${who}…` : `Tocca ad ${who}: «Oh tirami questo rinquarto!?»`;
const final = c.step === 'result'; text(ctx, line, 320, 45, 8, c.step === 'review' ? (c.attempt.point ? '#b9e5b3' : '#ffbf9f') : '#9fd0ff', 'center');
panel(ctx, 155, 145, 330, final ? 96 : 58); if (c.step === 'review') text(ctx, c.last ? 'Tocca per il risultato' : 'Tocca o premi Prossima', 320, 59, 7, '#d9ccba', 'center');
text(ctx, c.attempt.point ? 'Punto!' : 'Niente', 320, 164, 12, c.attempt.point ? '#b9e5b3' : '#ffbf9f', 'center'); if (c.step === 'result') {
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'); const r = c.result, title = r.training ? 'ALLENAMENTO FINITO' : r.winner === 'player' ? 'HAI BATTUTO BERTOVO!' : r.winner === 'cpu' ? 'HA VINTO BERTOVO' : 'PARI';
if (final) text(ctx, `Obiettivo: ${c.skill.glory}. Torna da Bertovo per uscire.`, 320, 221, 7, '#d9ccba', 'center'); panel(ctx, 155, 145, 330, 80);
text(ctx, title, 320, 166, 12, r.winner === 'player' ? '#b9e5b3' : '#efd49e', 'center');
if (!r.training) text(ctx, `Andrea ${r.points.andrea} · Bertovo ${r.points.bertovo}, su ${c.skill.positions} posizioni`, 320, 190, 8, '#efd49e', 'center');
text(ctx, 'Torna da Bertovo per uscire.', 320, 210, 8, '#d9ccba', 'center');
} }
ctx.restore(); ctx.restore();
} }
+179 -78
View File
@@ -1,120 +1,214 @@
// I rinquarti di Bertovo: posizioni coperte, un solo tentativo per posizione. // I rinquarti di Bertovo (Andrea, 28 ottobre; revisione di Codex): Bertovo mette le biglie in modo che la diretta sia
// coperta, e si deve arrivare al bersaglio (la gialla) dopo almeno tante sponde senza toccare prima i disturbi.
// Sulla stessa posizione tira prima Andrea, poi il tavolo torna com'era e tira Bertovo: un punto per ogni rinquarto
// riuscito, sei posizioni, vince chi ne fa di più. Nell'allenamento tira prima Bertovo, per far vedere, e senza punti.
//
// La forza è sempre piena: conta solo il primo contatto, quindi tirare piano non serve mai (revisione di Codex,
// rilievo 5). Il gioco è un rompicapo di angoli.
import * as billiard from './biliardo.js'; import * as billiard from './biliardo.js';
const { LEN, WID, R, PIN, simulate, predict } = billiard; const { LEN, WID, R, PIN, simulate, predict } = billiard;
export { aimAt, aimScreen, nudge, setPower, press, release, cancel, shoot, canThrow } from './biliardo.js'; export { aimAt, aimScreen, nudge, release, cancel, turn, stopTurn, key } from './biliardo.js';
// `cushions`: sponde da fare prima del bersaglio. `width`: la finestra di angoli buoni (in gradi, a forza piena) che
// una posizione deve avere, dalla prima all'ultima: le posizioni si fanno più strette man mano. `bertovo`: quanto è
// preciso lui (la probabilità di stare dentro la finestra), dal più distratto al più spietato.
export const LEVELS = { export const LEVELS = {
bona: { positions: 6, cushions: 1, glory: 3, guideBounces: 2, guideLength: Infinity }, allenamento: { positions: 3, cushions: 1, width: [5, 4], bertovo: 1, guideBounces: 2, guideLength: Infinity, training: true },
seria: { positions: 6, cushions: 2, glory: 3, guideBounces: 1, guideLength: 65 }, bona: { positions: 6, cushions: 1, width: [5, 2.5], bertovo: 0.45, guideBounces: 2, guideLength: Infinity },
male: { positions: 6, cushions: 3, glory: 2, guideBounces: 0, guideLength: 0 }, seria: { positions: 6, cushions: 2, width: [3, 1.5], bertovo: 0.65, guideBounces: 1, guideLength: 65 },
male: { positions: 6, cushions: 3, width: [2, 1], bertovo: 0.88, guideBounces: 0, guideLength: 0 },
}; };
export const canAdjust = c => billiard.canThrow(c) && !c.holding; // BUDGET: prove a vuoto per fotogramma mentre si prepara la posizione dopo (una costa circa 0,2 ms su un PC).
export const aimFree = c => !billiard.canThrow(c); const SCAN = 720, STEP_ANGLE = Math.PI * 2 / SCAN, BUDGET = 15, THINK = 1.1;
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 }); 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; } 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 }); const gauss = c => Math.sqrt(-2 * Math.log(1 - random(c))) * Math.cos(2 * Math.PI * random(c));
// z tale che una normale stia entro ±z con probabilità p: quanto deve essere stretto l'errore di Bertovo.
const zFor = p => p >= 1 ? Infinity : Math.sqrt(2) * erfinv(p);
function erfinv(x) {
const a = 0.147, l = Math.log(1 - x * x), t = 2 / (Math.PI * a) + l / 2;
return Math.sign(x) * Math.sqrt(Math.sqrt(t * t - l / a) - t);
}
const attempt = () => ({ cushions: 0, decided: false, point: false, reason: null, marks: [], hit: null });
// Conta solo le sponde della propria biglia prima del primo ostacolo. export const shooter = c => c.order[c.round ?? 0];
// Si può mirare e tirare solo quando tocca ad Andrea (la biglia, per il biliardo, è sempre quella «player»).
export const canThrow = c => c.step === 'aim' && shooter(c) === 'andrea';
export const canAdjust = c => canThrow(c) && !c.holding;
// Il pulsante del pannello: Tira quando tocca ad Andrea, Prossima mentre si guarda l'esito.
export const canPress = c => canThrow(c) || c.step === 'review';
export const aimFree = c => !canThrow(c);
export const setPower = () => false; // la forza è sempre piena
export const button = c => c.step === 'review' ? (c.last ? 'Finita' : 'Prossima') : c.step === 'result' ? 'Finita'
: c.step === 'think' ? 'Tira Bertovo…' : c.step === 'aim' ? 'Tira' : 'In movimento…';
// Conta solo le sponde della bianca prima del primo ostacolo; il primo contatto decide.
function contact(result, required, { kind, a, b }) { function contact(result, required, { kind, a, b }) {
if (result.decided || (a.role !== 'player' && b?.role !== 'player')) return; if (result.decided || (a.role !== 'player' && b?.role !== 'player')) return;
if (kind === 'rail') { result.cushions++; return; } if (kind === 'rail') { result.cushions++; result.marks.push({ x: a.x, y: a.y }); return; }
result.decided = true; result.decided = true;
const other = a.role === 'player' ? b : a; const white = a.role === 'player' ? a : b, other = white === a ? b : a;
result.hit = { x: white.x, y: white.y };
result.point = kind === 'ball' && other.role === 'cpu' && result.cushions >= required; result.point = kind === 'ball' && other.role === 'cpu' && result.cushions >= required;
result.reason = result.point ? null : kind === 'ball' && other.role === 'cpu' ? 'poche sponde' : 'disturbo toccato'; result.reason = result.point ? null : kind === 'ball' && other.role === 'cpu' ? 'poche sponde' : 'disturbo toccato';
} }
export function tryShot(c, angle, power) { // Il tiro provato a vuoto, su una disposizione qualunque (quella in gioco se non se ne passa un'altra).
const result = attempt(); export function tryShot(c, angle, power = 1, layout = null) {
simulate(c, 'player', angle, power, { const w = layout ? { ...c, balls: layout.balls, pins: layout.pins } : c, result = attempt();
onContact: event => contact(result, c.skill.cushions, event), simulate(w, 'player', angle, power, { onContact: event => contact(result, c.skill.cushions, event), stop: () => result.decided });
stop: () => result.decided,
});
if (!result.decided) result.reason = 'bersaglio mancato'; if (!result.decided) result.reason = 'bersaglio mancato';
return result; return result;
} }
// Distanza dal segmento dei centri, con i raggi sommati (biglia in movimento inclusa). // Coperta: lungo la linea dei centri il primo urto della bianca non è il bersaglio (si confrontano le distanze del
export function directCovered(c) { // primo impatto, non solo la vicinanza alla linea: revisione di Codex, rilievo 8).
const a = c.balls.player, b = c.balls.cpu, dx = b.x - a.x, dy = b.y - a.y, d2 = dx * dx + dy * dy; export function directCovered(c, layout = c) {
if (!d2) return false; const a = layout.balls.player, b = layout.balls.cpu, dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
const obstacles = [...Object.values(c.balls).filter(b => !['player', 'cpu'].includes(b.role)).map(b => ({ ...b, radius: R })), if (!d) return false;
...c.pins.filter(p => !p.down).map(p => ({ ...p, radius: PIN }))]; const ux = dx / d, uy = dy / d;
return obstacles.some(p => { const impact = (p, radius) => {
const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / d2; const t = (p.x - a.x) * ux + (p.y - a.y) * uy, off = Math.hypot(p.x - a.x - t * ux, p.y - a.y - t * uy), reach = R + radius;
return t > 0 && t < 1 && Math.hypot(p.x - a.x - t * dx, p.y - a.y - t * dy) < R + p.radius; return t > 0 && off < reach ? t - Math.sqrt(reach * reach - off * off) : Infinity;
}); };
const target = impact(b, R);
const obstacles = [...Object.values(layout.balls).filter(x => !['player', 'cpu'].includes(x.role)).map(x => impact(x, R)),
...layout.pins.filter(p => !p.down).map(p => impact(p, PIN))];
return obstacles.some(t => t < target);
} }
export function solve(c) { // Una disposizione a caso: bianca e gialla lontane, un disturbo in mezzo, un altro e due birilli sparsi.
// Angoli intercalati: si esplora subito tutto il tavolo, poi si affina a mezzo grado. function candidate(c) {
for (let offset = 0; offset < 8; offset++) for (let i = offset; i < 720; i += 8) { const place = role => ball(role, 20 + random(c) * (LEN - 40), 18 + random(c) * (WID - 36));
const angle = i * Math.PI / 360; const player = place('player'), cpu = place('cpu');
for (const power of [1, 0.82, 0.65]) { if (Math.hypot(cpu.x - player.x, cpu.y - player.y) < 70) return null;
if (tryShot(c, angle, power).point) return { angle, power }; 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);
const layout = { balls: { player, cpu, blocker, extra: place('extra') },
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(layout.balls).map(b => ({ ...b, radius: R })), ...layout.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))) return null;
return directCovered(c, layout) ? layout : null;
}
// Le finestre di angoli buoni (a forza piena), dalle prove fatte: gruppi di angoli vicini che fanno punto.
function windows(hits) {
const out = [];
for (let i = 0; i < SCAN; i++) {
if (!hits[i] || hits[(i - 1 + SCAN) % SCAN]) continue;
let n = 0; const cushions = [];
while (hits[(i + n) % SCAN] && n < SCAN) { cushions.push(hits[(i + n) % SCAN]); n++; }
const middle = cushions[Math.floor(n / 2)];
out.push({ angle: (i + (n - 1) / 2) * STEP_ANGLE, width: n * 360 / SCAN, cushions: middle });
} }
return out;
}
// La finestra che deve avere la posizione numero k (da 0): dalla più larga alla più stretta.
const widthFor = (skill, k) => skill.width[0] + (skill.width[1] - skill.width[0]) * (skill.positions > 1 ? k / (skill.positions - 1) : 0);
// Il generatore lavora a pezzi (BUDGET prove alla volta), così il gioco non si ferma mentre prepara la posizione dopo.
function generate(c, budget) {
let g = c.gen;
while (budget > 0) {
if (!g) {
const layout = candidate(c);
if (!layout) { budget--; continue; }
g = c.gen = { layout, i: 0, hits: new Array(SCAN).fill(0) };
}
for (; g.i < SCAN && budget > 0; g.i++, budget--) {
const r = tryShot(c, g.i * STEP_ANGLE, 1, g.layout);
if (r.point) g.hits[g.i] = r.cushions;
}
if (g.i < SCAN) return null;
c.gen = null;
const k = c.ready.length + c.position, need = widthFor(c.skill, k);
// Si preferisce una finestra col numero giusto di sponde; alla bona deve esserci per forza.
const all = windows(g.hits), exact = all.filter(w => w.cushions === c.skill.cushions);
const pool = exact.length || c.skill.cushions === 1 ? exact : all;
const best = pool.sort((a, b) => b.width - a.width)[0];
if (best && best.width >= need) return { layout: g.layout, solution: { angle: best.angle, power: 1, width: best.width, cushions: best.cushions } };
g = null;
} }
return null; return null;
} }
const copy = layout => ({ balls: Object.fromEntries(Object.entries(layout.balls).map(([k, b]) => [k, { ...b }])), pins: layout.pins.map(p => ({ ...p })) });
function position(c) { // Prepara il tavolo per chi tira adesso, sempre dalla disposizione di partenza della posizione.
for (;;) { function setTable(c) {
const place = role => ball(role, 20 + random(c) * (LEN - 40), 18 + random(c) * (WID - 36)); const fresh = copy(c.start);
const player = place('player'), cpu = place('cpu'); c.balls = fresh.balls; c.pins = fresh.pins;
if (Math.hypot(cpu.x - player.x, cpu.y - player.y) < 70) continue; c.step = shooter(c) === 'andrea' ? 'aim' : 'think';
const t = 0.3 + random(c) * 0.4; c.t = c.accumulator = 0; c.power = 1; c.holding = false; c.charging = false; c.aimLocked = false; c.plan = null;
const blocker = ball('blocker', player.x + (cpu.x - player.x) * t, player.y + (cpu.y - player.y) * t); c.shot = c.pending = c.visual.shot = null; c.windup = 0; c.trail = [];
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.angle = Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x);
c.attempt = attempt(); c.attempt = attempt();
c.line = 'Bertovo: «Oh tirami questo rinquarto!?»'; c.line = shooter(c) === 'andrea' ? 'Bertovo: «Oh tirami questo rinquarto!?»' : 'Tira Bertovo…';
}
function nextPosition(c) {
while (!c.ready.length) { const p = generate(c, 4000); if (p) c.ready.push(p); }
const p = c.ready.shift();
c.position++; c.start = copy(p.layout); c.solution = p.solution; c.round = 0;
setTable(c);
} }
export function newRinquarto(seed = Date.now() >>> 0, def = {}) { export function newRinquarto(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria'; const level = LEVELS[def.level] ? def.level : 'seria';
const c = { ...billiard.newBiliardo(seed), level, skill: LEVELS[level], position: 0, points: 0, history: [] }; const c = { ...billiard.newBiliardo(seed), level, skill: LEVELS[level], position: 0, score: { andrea: 0, bertovo: 0 }, history: [],
position(c); order: LEVELS[level].training ? ['bertovo', 'andrea'] : ['andrea', 'bertovo'], ready: [], gen: null, fixedPower: 1, trail: [], last: false };
nextPosition(c);
return c; return c;
} }
// Bertovo mira al centro della finestra buona, con un errore che dipende dal livello: dentro la finestra con la
// probabilità `bertovo`. Il tiro poi si fa davvero: se sbaglia, sbaglia sul panno.
export function bertovoPlan(c) {
const half = c.solution.width / 2 * Math.PI / 180, z = zFor(c.skill.bertovo);
return { angle: c.solution.angle + (Number.isFinite(z) ? gauss(c) * half / z : 0), power: 1 };
}
// Dopo l'esito, un tocco (o Prossima) va avanti: tocca all'altro sulla stessa posizione, o alla posizione dopo.
export function advance(c) {
if (c.step !== 'review') return false;
if (c.round < c.order.length - 1) { c.round++; setTable(c); return true; }
if (c.position >= c.skill.positions) { finish(c); return true; }
nextPosition(c);
return true;
}
function finish(c) {
const { andrea, bertovo } = c.score;
c.step = 'result';
c.result = { winner: c.skill.training ? 'draw' : andrea > bertovo ? 'player' : andrea < bertovo ? 'cpu' : 'draw', points: { ...c.score }, training: !!c.skill.training };
}
// Mentre si guarda l'esito, Spazio, Invio, un tocco sul tavolo o il pulsante vanno avanti.
export function press(c, pointerId) { return c.step === 'review' ? advance(c) : billiard.press(c, pointerId); }
export function shoot(c) { return c.step === 'review' ? advance(c) : canThrow(c) && !c.holding && billiard.strike(c, 'player', c.angle, 1); }
export function update(c, dt) { export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return; if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); dt = Math.min(dt, 0.25);
if (c.step === 'result') { // Intanto si prepara la posizione dopo, un po' per volta.
// L’ultimo birillo può essere ancora in caduta: il riepilogo ferma la partita, non l’animazione. if (c.position + c.ready.length < c.skill.positions) { const p = generate(c, BUDGET); if (p) c.ready.push(p); }
c.visual.time += dt; // L'ultimo birillo può essere ancora in caduta: l'animazione va avanti anche fermi sull'esito.
return; if (c.step === 'result' || c.step === 'review') { c.visual.time += dt; return; }
} if (c.step === 'think') {
if (c.step === 'settle') { c.t += dt; c.visual.time += dt;
c.t += dt; if (c.t >= THINK) { const plan = bertovoPlan(c); billiard.strike(c, 'player', plan.angle, plan.power); }
// 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; return;
} }
const before = c.step;
billiard.update(c, dt, event => contact(c.attempt, c.skill.cushions, event)); billiard.update(c, dt, event => contact(c.attempt, c.skill.cushions, event));
if (c.step !== 'settle') return; if (c.step === 'rolling' && !c.windup) {
if (!c.attempt.decided) c.attempt.reason = 'bersaglio mancato'; const b = c.balls.player, last = c.trail.at(-1);
c.points += Number(c.attempt.point); if (!last || Math.hypot(last[0] - b.x, last[1] - b.y) > 1.5) c.trail.push([b.x, b.y]);
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 };
} }
if (before !== 'rolling' || c.step !== 'settle') return;
// Il tiro è finito: l'esito resta sul tavolo, con la traiettoria, finché non si va avanti.
if (!c.attempt.decided) c.attempt.reason = 'bersaglio mancato';
const who = shooter(c);
if (!c.skill.training) c.score[who] += Number(c.attempt.point);
c.history.push({ position: c.position, who, ...c.attempt });
c.line = `${who === 'andrea' ? 'Andrea' : 'Bertovo'}: ${c.attempt.point ? 'punto!' : `niente, ${c.attempt.reason}.`}`;
c.step = 'review';
c.last = c.round === c.order.length - 1 && c.position >= c.skill.positions;
} }
// La guida si ferma al contatto, al limite di lunghezza o alla sponda consentita. // La guida si ferma al contatto, al limite di lunghezza o alla sponda consentita.
@@ -130,9 +224,16 @@ export function guide(c) {
return points; return points;
} }
const need = c => c.skill.cushions === 1 ? 'Serve almeno 1 sponda.' : `Servono almeno ${c.skill.cushions} sponde.`;
export function status(c) { export function status(c) {
const prefix = `Posizione ${c.position} di ${c.skill.positions}. Punti: ${c.points}.`; const score = c.skill.training ? 'Allenamento, senza punti.' : `Andrea ${c.score.andrea}, Bertovo ${c.score.bertovo}.`;
if (c.step === 'result') return `${prefix} ${c.line} ${c.result.winner === 'player' ? 'Gloria!' : 'Sfida persa.'} Obiettivo: ${c.skill.glory}.`; const prefix = `Posizione ${c.position} di ${c.skill.positions}. ${score}`;
if (c.step === 'result') {
if (c.result.training) return 'Allenamento finito. Quando vuoi, la sfida vera.';
return `${prefix} ${c.result.winner === 'player' ? 'Hai battuto Bertovo!' : c.result.winner === 'cpu' ? 'Ha vinto Bertovo.' : 'Pari.'}`;
}
if (c.step === 'review') return `${prefix} ${c.line} ${c.last ? 'Tocca per il risultato.' : 'Tocca o premi Prossima.'}`;
if (c.step === 'think') return `${prefix} Tira Bertovo…`;
if (c.step === 'rolling') return `${prefix} Sponde: ${c.attempt.cushions}/${c.skill.cushions}.`; 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.`}`; return `${prefix} ${c.line} ${need(c)}`;
} }
+33 -11
View File
@@ -507,15 +507,16 @@ canvas.addEventListener('pointerdown', e => {
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); } if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
return; return;
} }
if (press?.closeup) return;
const aimed = game.closeupAim(scenePoint(e)); const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'rinquarto', '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
} }
if (['bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true; if (['bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true;
press = { closeup: true }; press = { closeup: true, pointerId: e.pointerId, state: game.closeup };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress(); if (!game.skip()) game.closeupPress(e.pointerId);
canvas.setPointerCapture(e.pointerId); canvas.setPointerCapture(e.pointerId);
return; return;
} }
@@ -543,7 +544,12 @@ canvas.addEventListener('pointerup', e => {
if (e.pointerId === press.gara) { if (game?.closeup) gara.release(game.closeup, e.pointerId); press = null; } if (e.pointerId === press.gara) { if (game?.closeup) gara.release(game.closeup, e.pointerId); press = null; }
return; return;
} }
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; } if (press.closeup) {
if (e.pointerId !== press.pointerId) return;
const state = press.state; press = null;
if (running && game.closeup === state && !game.closeupRelease(e.pointerId) && !game.skip()) game.closeupTap();
return;
}
clearTimeout(press.timer); clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità. // Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) { if (!press.long) {
@@ -554,8 +560,13 @@ canvas.addEventListener('pointerup', e => {
} }
press = null; press = null;
}); });
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); canvas.addEventListener('pointercancel', e => {
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } }); if (press?.closeup && e.pointerId !== press.pointerId) return;
if (press) clearTimeout(press.timer);
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
press = null;
});
canvas.addEventListener('lostpointercapture', e => { if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -590,7 +601,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.' }, rinquarto: { ...rinquarto, back: 'Torna da Bertovo', label: 'I rinquarti di Bertovo: clic o tocco fissa la mira, frecce o pulsanti Mira per girare la stecca (Maiusc per i passi fini), Tira per tirare. Dopo l’esito, Prossima.' },
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.' },
@@ -645,7 +656,16 @@ for (let i = 0; i < 3; i++) {
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); }); button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
} }
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) { for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; }); $(id).addEventListener('click', e => { const c = miniState(); if (!c) return;
if (mini().turn) { if (!e.detail) { mini().turn(c, dir, 'click'); mini().stopTurn(c, 'click'); } }
else if (mini().nudge(c, dir)) c.aimLocked = true;
});
$(id).addEventListener('pointerdown', e => { const c = miniState();
if (c && mini().turn?.(c, dir, e.pointerId)) $(id).setPointerCapture(e.pointerId);
});
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $(id).addEventListener(ev, e => {
const c = miniState(); if (c) mini().stopTurn?.(c, e.pointerId);
});
// Al GP di Fortezza si sterza finché il pulsante resta premuto (anche col dito, insieme a Gas). // Al GP di Fortezza si sterza finché il pulsante resta premuto (anche col dito, insieme a Gas).
$(id).addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().steerButton?.(c, dir); }); $(id).addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().steerButton?.(c, dir); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $(id).addEventListener(ev, () => { const c = miniState(); if (c) mini().steerButton?.(c, 0); }); for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $(id).addEventListener(ev, () => { const c = miniState(); if (c) mini().steerButton?.(c, 0); });
@@ -821,8 +841,8 @@ window.addEventListener('keydown', e => {
if (e.key === 'Escape') { game.closeupClose(); return; } if (e.key === 'Escape') { game.closeupClose(); return; }
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls'); const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono). // Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true)) { e.preventDefault(); return; } if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true, e.shiftKey)) { e.preventDefault(); return; }
if (onControl) return; if (onControl && [' ', 'Enter'].includes(e.key)) return;
const c = game.closeup, m = mini(); const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault(); e.preventDefault();
@@ -868,7 +888,7 @@ window.addEventListener('keydown', e => {
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro. // Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
window.addEventListener('keyup', e => { window.addEventListener('keyup', e => {
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri. // Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
if (miniState() && mini().key) { mini().key(game.closeup, e.key, false); return; } if (miniState() && mini().key?.(game.closeup, e.key, false)) return;
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
}); });
@@ -985,10 +1005,12 @@ function updateUi() {
setValue($('bocce-power'), Math.round(c.power * 100)); setValue($('bocce-power'), Math.round(c.power * 100));
setValue($('bocce-power-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']) setProp($(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);
setProp($('bocce-throw'), 'disabled', !mg.canThrow(c) || (c.holding && !mg.toggle) || !!game.speech); setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? 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).
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata). // Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
const powerLabel = $('bocce-power').closest('label'); const powerLabel = $('bocce-power').closest('label');
// Ai rinquarti la forza è sempre piena: il cursore non serve.
setProp(powerLabel, 'hidden', def.type === 'rinquarto');
const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza '; const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza ';
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName; if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio'); setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
+12 -7
View File
@@ -227,10 +227,11 @@ export const story = {
type: 'rinquarto', type: 'rinquarto',
picture: 'assets/closeups/biliardo-tavolo.png', picture: 'assets/closeups/biliardo-tavolo.png',
names: { player: 'Andrea', cpu: 'Bertovo' }, names: { player: 'Andrea', cpu: 'Bertovo' },
win: [{ say: 'Questo rinquarto era bono!', who: 'bertovo' }], // [DA ANDREA] // Andrea contro Bertovo, sulle stesse posizioni; nell'allenamento il risultato è sempre «pari». [DA ANDREA: le battute]
lose: [{ say: 'Le sponde ci sono per qualcosa!', who: 'bertovo' }], // [DA ANDREA] win: [{ say: 'M\'hai battuto ai rinquarti. Però un lo dire a nessuno.', who: 'bertovo', pose: 'shrug' }, { set: 'rinquarti_vinti' }],
quit: [{ say: 'Questo me lo tengo per la prossima.' }], // [DA ANDREA] lose: [{ say: 'Le sponde ci sono per qualcosa! Basta chiederglielo per bene.', who: 'bertovo', pose: 'laugh' }],
draw: [{ say: 'Si riprova?', who: 'bertovo' }], // [DA ANDREA] quit: [{ say: 'Questo me lo tengo per la prossima.' }],
draw: [{ say: 'Visto? Geometrie celestiali. Si riprova quando vuoi.', who: 'bertovo', pose: 'explain' }],
}, },
fessura: { fessura: {
type: 'torch', type: 'torch',
@@ -3229,9 +3230,13 @@ export const story = {
{ say: 'Geometria. La biglia va dove le dici, basta chiederglielo per bene.', who: 'bertovo', pose: 'chalk' }, { 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' }, { say: 'E con un po\' di gesso.', who: 'bertovo', pose: 'laugh' },
] }, ] },
{ text: 'Dai, fammi vedere un rinquarto.', next: 'sfida', then: [ // L'allenamento: lui tira per primo e fa vedere, poi lo rifai te, senza punti.
{ say: 'Te lo faccio vedere? No: lo tiri te.', who: 'bertovo', pose: 'talk' }, { text: 'Dai, fammi vedere un rinquarto.', next: 'end', then: [
{ say: 'Le biglie le metto io, nascoste per bene. Oh tirami questo rinquarto!?', who: 'bertovo', pose: 'explain' }, { say: 'Guarda bene: prima lo tiro io, poi lo rifai te. Tre posizioni, una sponda.', who: 'bertovo', pose: 'chalk' },
{ closeup: 'rinquarto', level: 'allenamento' },
] },
{ text: 'Si fa una sfida a rinquarti?', next: 'sfida', then: [
{ say: 'Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?', who: 'bertovo', pose: 'explain' },
] }, ] },
{ text: 'Ci si vede, Bertovo.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] }, { text: 'Ci si vede, Bertovo.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] },
], ],
+4 -4
View File
@@ -58,16 +58,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#stage, #bocce-controls, #gara-pad { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } #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, #rinquarto-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary { cursor: pointer; padding: 6px 0; } #bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p { max-width: 76em; line-height: 1.7; } #bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p { max-width: 76em; line-height: 1.7; }
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#bocce-throw { flex-basis: 100%; } #bocce-throw { flex-basis: 100%; }
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help { font-size: 9px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help { font-size: 9px; }
} }
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); } .effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
#effects-volume { width: 130px; accent-color: var(--accent); } #effects-volume { width: 130px; accent-color: var(--accent); }
+140 -259
View File
@@ -1,294 +1,175 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as r from '../src/engine/rinquarto.js'; 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 { FIXED_STEP, LONG_PRESS } from '../src/engine/biliardo.js';
import { pinFall } from '../src/engine/biliardo-render.js';
import { drawRinquarto } from '../src/engine/rinquarto-render.js'; import { drawRinquarto } from '../src/engine/rinquarto-render.js';
import { Game, MINIGAMES } from '../src/engine/game.js'; import { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js'; import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
function roll(c, frames = [1 / 60]) { // Tira (Andrea col tiro dato, o Bertovo da sé) e aspetta l'esito.
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) r.update(c, frames[i % frames.length]); function roll(c) {
assert.ok(['settle', 'result'].includes(c.step), 'il tiro si deve fermare'); for (let i = 0; i < 20000 && c.step !== 'review' && c.step !== 'result'; i++) r.update(c, 1 / 60);
assert.ok(['review', 'result'].includes(c.step), 'il tiro si deve fermare');
} }
function next(c) { function andrea(c, angle = c.solution.angle) { c.angle = angle; assert.ok(r.shoot(c)); roll(c); return c.history.at(-1); }
for (let i = 0; i < 20 && c.step === 'settle'; i++) r.update(c, 0.25); const samePlace = (a, b) => JSON.stringify(Object.values(a.balls).map(x => [x.role, x.x, x.y])) === JSON.stringify(Object.values(b.balls).map(x => [x.role, x.x, x.y]));
assert.ok(['aim', 'result'].includes(c.step));
} test('stesso seme e livello: stesse posizioni e soluzioni', () => {
function play(c, shot = c.solution) { const a = r.newRinquarto(11, { level: 'seria' }), b = r.newRinquarto(11, { level: 'seria' });
Object.assign(c, shot); assert.ok(samePlace(a.start, b.start)); assert.deepEqual(a.solution, b.solution);
assert.ok(r.shoot(c)); assert.notDeepEqual(r.newRinquarto(12, { level: 'seria' }).solution, a.solution);
assert.equal(r.shoot(c), false, 'un solo tiro per posizione'); });
test('ogni posizione: diretta coperta, una finestra buona larga quanto chiede il livello, e la soluzione fa punto davvero', () => {
for (const level of ['bona', 'seria', 'male']) {
const c = r.newRinquarto(3, { level }), widths = [];
for (let p = 1; p <= 3; p++) {
assert.equal(c.position, p);
assert.ok(r.directCovered(c), `${level} ${p}: diretta coperta`);
const shot = r.tryShot(c, c.solution.angle);
assert.ok(shot.point, `${level} ${p}: la soluzione fa punto`);
assert.ok(shot.cushions >= c.skill.cushions);
if (level === 'bona') assert.equal(c.solution.cushions, 1, 'alla bona c\'è sempre un rinquarto a una sponda');
widths.push(c.solution.width);
andrea(c); r.advance(c); roll(c); r.advance(c);
}
assert.ok(widths.every(w => w >= c.skill.width[1]), `${level}: finestre ${widths}`);
}
});
test('la diretta coperta guarda il primo impatto, non solo la vicinanza alla linea (rilievo 8 di Codex)', () => {
const at = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
const free = { balls: { player: at('player', 50, 50), cpu: at('cpu', 100, 50), blocker: at('blocker', 99, 56.05) }, pins: [] };
assert.equal(r.directCovered(null, free), false, 'il bersaglio si tocca prima');
const covered = { balls: { player: at('player', 50, 50), cpu: at('cpu', 100, 50), blocker: at('blocker', 75, 52) }, pins: [] };
assert.equal(r.directCovered(null, covered), true);
});
test('la diretta tocca il disturbo; meno sponde del richiesto non valgono', () => {
const c = r.newRinquarto(5, { level: 'seria' });
const direct = r.tryShot(c, Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x));
assert.equal(direct.point, false); assert.equal(direct.reason, 'disturbo toccato');
const bona = r.newRinquarto(5, { level: 'bona' }), one = r.tryShot(bona, bona.solution.angle);
assert.ok(one.point);
assert.equal(r.tryShot({ ...bona, skill: r.LEVELS.male }, bona.solution.angle).point, one.cushions >= 3);
});
test('a turno: prima Andrea, poi il tavolo torna com\'era e tira Bertovo; l\'esito resta finché non si va avanti', () => {
const c = r.newRinquarto(8, { level: 'bona' });
assert.equal(r.shooter(c), 'andrea'); assert.ok(r.canThrow(c)); assert.equal(r.button(c), 'Tira');
const mine = andrea(c);
assert.equal(c.step, 'review'); assert.equal(mine.who, 'andrea'); assert.ok(mine.point);
assert.equal(c.score.andrea, 1); assert.ok(c.trail.length > 5, 'la traiettoria resta');
assert.equal(mine.marks.length, mine.cushions, 'le sponde hanno il loro punto'); assert.ok(mine.hit);
for (let i = 0; i < 300; i++) r.update(c, 1 / 60);
assert.equal(c.step, 'review', 'non va avanti da sé'); assert.equal(r.button(c), 'Prossima');
assert.ok(r.press(c), 'un tocco va avanti');
assert.equal(r.shooter(c), 'bertovo'); assert.equal(c.step, 'think');
assert.ok(samePlace(c, c.start), 'il tavolo è tornato com\'era');
assert.equal(r.canThrow(c), false, 'mentre tira lui i comandi sono fermi'); assert.equal(r.shoot(c), false);
assert.match(r.status(c), /Tira Bertovo/);
roll(c); roll(c);
} assert.equal(c.history.at(-1).who, 'bertovo'); assert.equal(c.score.bertovo, Number(c.history.at(-1).point));
r.advance(c);
test('stesso seme e livello riproducono tutte le sei posizioni e le soluzioni', () => { assert.equal(c.position, 2); assert.equal(r.shooter(c), 'andrea'); assert.equal(c.step, 'aim');
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', () => { test('alla fine vince chi ha più punti; può finire pari', () => {
for (const level of Object.keys(r.LEVELS)) for (const seed of [0, 1, 42, 0xffffffff]) { const c = r.newRinquarto(8, { level: 'bona' });
const c = r.newRinquarto(seed, { level }); for (let p = 0; p < c.skill.positions; p++) { andrea(c, c.solution.angle + Math.PI); r.advance(c); roll(c); r.advance(c); }
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'); assert.equal(c.step, 'result');
const p = c.pins[0], before = structuredClone(c); assert.equal(c.score.andrea, 0);
assert.ok(p.down && pinFall(p, c.visual.time) < 1); assert.equal(c.result.winner, c.score.bertovo > 0 ? 'cpu' : 'draw');
r.update(c, 0.25); assert.match(r.status(c), c.score.bertovo > 0 ? /Ha vinto Bertovo/ : /Pari/);
assert.equal(pinFall(p, c.visual.time), 1); for (const [a, b, w] of [[3, 2, 'player'], [2, 3, 'cpu'], [2, 2, 'draw']]) {
before.visual.time = c.visual.time; const d = { ...c, step: 'review', last: true, round: 1, position: c.skill.positions, score: { andrea: a, bertovo: b } };
assert.deepEqual(c, before, 'avanza solo l’animazione, senza nuovi tiri o punti'); r.advance(d); assert.equal(d.result.winner, w);
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', () => { test('l\'allenamento: tira prima Bertovo, sempre giusto, poi Andrea; niente punti, finisce pari', () => {
const c = clear('bona'); const c = r.newRinquarto(2, { level: 'allenamento' });
c.balls.cpu.x = 200; c.angle = -Math.PI / 2; c.power = 1; assert.equal(r.shooter(c), 'bertovo'); assert.equal(c.step, 'think');
assert.deepEqual(r.guide(c), predict(c).points.slice(0, 3)); for (let p = 0; p < c.skill.positions; p++) {
assert.equal(r.guide(c).length, 3); roll(c); assert.ok(c.history.at(-1).point, 'Bertovo fa vedere il rinquarto');
c.skill = r.LEVELS.seria; r.advance(c); assert.equal(r.shooter(c), 'andrea');
const short = r.guide(c); andrea(c); r.advance(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]); assert.deepEqual(c.score, { andrea: 0, bertovo: 0 });
assert.equal(c.result.winner, 'draw'); assert.ok(c.result.training);
}); });
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', () => { test('Bertovo è più preciso salendo di livello', () => {
const spread = level => {
const c = r.newRinquarto(4, { level: 'bona' }); c.skill = r.LEVELS[level];
const errors = Array.from({ length: 400 }, () => r.bertovoPlan(c).angle - c.solution.angle);
return Math.sqrt(errors.reduce((a, e) => a + e * e, 0) / errors.length);
};
const [bona, seria, male] = ['bona', 'seria', 'male'].map(spread);
assert.ok(bona > seria && seria > male, `${bona} ${seria} ${male}`);
assert.equal(spread('allenamento'), 0);
});
test('i comandi: un tocco mira e basta, tenendo premuto si tira; il secondo dito non tira; le frecce girano finché tenute', () => {
const c = r.newRinquarto(6, { level: 'bona' });
r.aimAt(c, c.balls.cpu.x, c.balls.cpu.y + 20);
assert.ok(r.press(c, 1)); r.update(c, 0.15); r.release(c, 1);
assert.equal(c.step, 'aim', 'un tocco normale non brucia la posizione');
assert.ok(r.press(c, 1)); r.update(c, 0.1);
assert.equal(r.release(c, 2), false, 'il secondo dito non lascia il tiro del primo');
assert.equal(c.step, 'aim');
for (let t = 0; t < LONG_PRESS; t += 0.1) r.update(c, 0.1);
assert.ok(c.charging, 'tenendo premuto la stecca si carica');
r.release(c, 1); assert.equal(c.step, 'rolling'); assert.equal(c.power, 1, 'forza sempre piena');
const d = r.newRinquarto(6, { level: 'bona' }), a0 = d.angle;
assert.ok(r.key(d, 'ArrowRight', true)); r.update(d, 0.5);
const a1 = d.angle; assert.ok(a1 > a0 + 0.2, 'tenendo la freccia gira, e accelera');
r.key(d, 'ArrowRight', false); r.update(d, 0.5); assert.equal(d.angle, a1, 'lasciandola si ferma');
r.key(d, 'ArrowLeft', true, true); r.update(d, 0.5); r.key(d, 'ArrowLeft', false);
assert.ok(a1 - d.angle < 0.05, 'con Maiusc i passi sono fini');
assert.equal(r.setPower(d, 0.2), false); assert.equal(d.power, 1);
});
test('dialogo di Bertovo: si chiacchiera, la sfida o l\'allenamento; lui sta al tavolo di mezzo del Circolo', () => {
assert.ok(MINIGAMES.rinquarto); assert.ok(MINIGAMES.rinquarto);
assert.equal(story.characters.bertovo.sprite, 'assets/characters/bertovo.json'); 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); const where = Object.entries(story.scenes).filter(([, sc]) => JSON.stringify(sc).includes('bertovo')).map(([id]) => id);
assert.deepEqual(where, ['circolo_4_maggio']); assert.deepEqual(where, ['circolo_4_maggio']);
const circolo = new Game(story); circolo.go('circolo_4_maggio'); settle(circolo); const g = new Game(story); g.go('circolo_4_maggio'); settle(g);
assert.ok(circolo.people().some(p => p.who === 'bertovo')); assert.ok(g.people().some(p => p.who === 'bertovo'));
circolo.act('talk', 'bertovo'); settle(circolo); g.act('talk', 'bertovo'); settle(g);
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'); 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.')); g.choose(g.options().findIndex(o => o.text === 'Si fa una sfida a rinquarti?'));
assert.ok(settle(g).some(s => s.who === 'bertovo' && s.text.includes('Oh tirami questo rinquarto!?'))); assert.ok(settle(g).some(s => s.who === 'bertovo' && s.text.includes('Oh tirami questo rinquarto!?')));
assert.equal(g.options().length, 4); assert.equal(g.options().length, 4);
g.choose(index); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('No'))); settle(g);
if (!level) { assert.equal(g.closeup, null); assert.ok(g.dialogue, 'col No si torna a parlare'); continue; } assert.equal(g.closeup, null); assert.ok(g.dialogue, 'col No si torna a parlare');
assert.equal(g.closeup.id, 'rinquarto'); g.choose(g.options().findIndex(o => o.text === 'Si fa una sfida a rinquarti?')); settle(g);
assert.equal(g.closeup.level, level); g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
assert.ok(g.closeupPress()); assert.equal(g.closeup?.id, 'rinquarto'); assert.equal(g.closeup.level, 'seria');
g.update(0.2); assert.ok(g.closeupRelease()); g.closeup.step = 'result'; g.closeup.result = { winner: 'player', points: { andrea: 4, bertovo: 2 } };
assert.equal(g.closeup.step, 'rolling'); g.closeupClose(); settle(g);
g.closeupClose(); assert.ok(g.state.flags.rinquarti_vinti);
assert.ok(settle(g).some(s => s.text === story.closeups.rinquarto.quit[0].say)); const h = new Game(story); h.go('circolo_4_maggio'); settle(h);
} h.act('talk', 'bertovo'); settle(h);
h.choose(h.options().findIndex(o => o.text === 'Dai, fammi vedere un rinquarto.')); settle(h);
assert.equal(h.closeup?.level, 'allenamento');
}); });
test('il riepilogo resta aperto e uscendo parte la battuta di vittoria o sconfitta', () => { test('il disegno: traiettoria con le sponde numerate, riepilogo, e lo stato non cambia', () => {
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 calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
const previous = globalThis.document; const previous = globalThis.document;
globalThis.document = { createElement: () => ({ getContext: () => ctx }) }; globalThis.document = { createElement: () => ({ getContext: () => ctx }) };
try { try {
for (const level of Object.keys(r.LEVELS)) { const c = r.newRinquarto(8, { level: 'bona' }), bg = {};
const c = r.newRinquarto(42, { level }), bg = {}; const draw = () => { const before = structuredClone(c); drawRinquarto(ctx, c, story.closeups.rinquarto, bg); assert.deepEqual(c, before); };
const draw = () => { draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
const before = structuredClone(c); andrea(c); calls.length = 0; draw();
drawRinquarto(ctx, c, story.closeups.rinquarto, bg); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === '1'), 'la prima sponda è numerata');
assert.deepEqual(c, before); assert.ok(calls.some(([op, t]) => op === 'fillText' && /punto!/.test(t)));
}; c.step = 'result'; c.result = { winner: 'cpu', points: { andrea: 1, bertovo: 3 } }; calls.length = 0; draw();
draw(); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO BERTOVO'));
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 { } finally {
if (previous === undefined) delete globalThis.document; else globalThis.document = previous; if (previous === undefined) delete globalThis.document; else globalThis.document = previous;
} }