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:
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
@@ -2439,6 +2439,10 @@
|
||||
"file": "andrea-728f589a77.mp3",
|
||||
"sec": 1.68
|
||||
},
|
||||
"andrea|Si fa una sfida a rinquarti?": {
|
||||
"file": "andrea-fe39c8f30c.mp3",
|
||||
"sec": 1.49
|
||||
},
|
||||
"andrea|Ci si vede, Bertovo.": {
|
||||
"file": "andrea-a3364fd72b.mp3",
|
||||
"sec": 1.78
|
||||
@@ -2455,13 +2459,13 @@
|
||||
"file": "bertovo-6c52f3fa0d.mp3",
|
||||
"sec": 1.52
|
||||
},
|
||||
"bertovo|Te lo faccio vedere? No: lo tiri te.": {
|
||||
"file": "bertovo-fde93190e9.mp3",
|
||||
"sec": 3.12
|
||||
"bertovo|Guarda bene: prima lo tiro io, poi lo rifai te. Tre posizioni, una sponda.": {
|
||||
"file": "bertovo-ad20035f2e.mp3",
|
||||
"sec": 6.6
|
||||
},
|
||||
"bertovo|Le biglie le metto io, nascoste per bene. Oh tirami questo rinquarto!?": {
|
||||
"file": "bertovo-a6a09b5b87.mp3",
|
||||
"sec": 5.83
|
||||
"bertovo|Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?": {
|
||||
"file": "bertovo-80c18678cd.mp3",
|
||||
"sec": 8.82
|
||||
},
|
||||
"bertovo|Occhio alle sponde, fuori.": {
|
||||
"file": "bertovo-4d4d630038.mp3",
|
||||
@@ -2755,21 +2759,21 @@
|
||||
"file": "ing_tozzi-282062c119.mp3",
|
||||
"sec": 3.64
|
||||
},
|
||||
"bertovo|Questo rinquarto era bono!": {
|
||||
"file": "bertovo-d71d01230d.mp3",
|
||||
"sec": 1.26
|
||||
"bertovo|M'hai battuto ai rinquarti. Però un lo dire a nessuno.": {
|
||||
"file": "bertovo-9aa247cffe.mp3",
|
||||
"sec": 4.2
|
||||
},
|
||||
"bertovo|Le sponde ci sono per qualcosa!": {
|
||||
"file": "bertovo-a63d2da89d.mp3",
|
||||
"sec": 1.44
|
||||
"bertovo|Le sponde ci sono per qualcosa! Basta chiederglielo per bene.": {
|
||||
"file": "bertovo-5d93de3623.mp3",
|
||||
"sec": 4.13
|
||||
},
|
||||
"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
|
||||
"bertovo|Visto? Geometrie celestiali. Si riprova quando vuoi.": {
|
||||
"file": "bertovo-ca941bee91.mp3",
|
||||
"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.": {
|
||||
"file": "andrea-6484641c58.mp3",
|
||||
|
||||
@@ -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
|
||||
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]**.
|
||||
|
||||
## 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
@@ -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.
|
||||
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).
|
||||
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
|
||||
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
|
||||
|
||||
+12
-13
@@ -250,19 +250,18 @@
|
||||
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p>
|
||||
</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>
|
||||
<p>I rinquarti di Bertovo: sei posizioni. Su ognuna tiri prima tu, poi il tavolo torna com'era e tira Bertovo; un
|
||||
punto per ogni rinquarto riuscito, vince chi ne fa di più (si può anche pareggiare). La tua biglia è la bianca, il
|
||||
bersaglio la gialla con l'anello. Le biglie viola con la croce e i birilli sono disturbi: toccarne uno prima del
|
||||
bersaglio annulla il tiro. La diretta è sempre coperta, ma c'è sempre un rinquarto possibile.</p>
|
||||
<p>Punto se la bianca tocca la gialla dopo almeno le sponde richieste: conta il primo contatto. Alla bona una
|
||||
sponda e la guida fino al secondo rimbalzo, Seria due sponde e la guida corta, A fassi male tre sponde e niente
|
||||
guida. Le posizioni si fanno più strette man mano, e Bertovo è più preciso a ogni livello. La forza è sempre piena:
|
||||
è un rompicapo di angoli. Nell'allenamento («Fammi vedere un rinquarto») tira prima lui, poi lo rifai tu.</p>
|
||||
<p>Muovi il mouse o tocca il tavolo per mirare: un tocco fissa solo la mira. Per tirare premi Tira, oppure tieni
|
||||
premuto sul tavolo (o Spazio) finché la stecca si carica e lascia. Frecce ← → o i pulsanti Mira (tenuti
|
||||
premuti) girano la stecca, sempre più svelti; con Maiusc a passi fini. Dopo il tiro la traiettoria resta sul tavolo
|
||||
con le sponde numerate e il punto del contatto: tocca o premi Prossima per andare avanti. Esc esce.</p>
|
||||
</details>
|
||||
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
||||
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
||||
|
||||
@@ -206,7 +206,7 @@ export function drawBiliardo(ctx, c, def, bg, sheet) {
|
||||
ctx.restore();
|
||||
scoreboard(ctx, c, def);
|
||||
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).'
|
||||
: 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);
|
||||
|
||||
+42
-10
@@ -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;
|
||||
// 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.
|
||||
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 CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
|
||||
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) });
|
||||
}
|
||||
|
||||
function strike(c, who, angle, power) {
|
||||
export function strike(c, who, angle, power) {
|
||||
c.turn = null;
|
||||
const b = c.balls[who], v = speedFor(power);
|
||||
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 };
|
||||
@@ -242,7 +243,15 @@ export function update(c, dt, onContact) {
|
||||
dt = Math.min(dt, 0.25); c.t += dt;
|
||||
c.visual.time += 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) {
|
||||
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
|
||||
c.windup -= dt;
|
||||
@@ -288,18 +297,40 @@ export function setPower(c, value) {
|
||||
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 press(c) {
|
||||
if (!canThrow(c) || c.holding) return false;
|
||||
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
|
||||
// Una pressione continua ruota senza dipendere dalla ripetizione della tastiera.
|
||||
export function turn(c, dir, owner, fine = false) {
|
||||
if (!canThrow(c) || c.holding || c.turn) return false;
|
||||
c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.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;
|
||||
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.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto
|
||||
c.holding = false; return strike(c, 'player', c.angle, c.power);
|
||||
if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto
|
||||
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.
|
||||
@@ -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 === 'rolling') return 'Biglie in movimento…';
|
||||
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);
|
||||
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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).
|
||||
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); }
|
||||
closeupRelease() { return !!this.minigame()?.release?.(this.closeup); }
|
||||
closeupCancel() { return !!this.minigame()?.cancel?.(this.closeup); }
|
||||
closeupPress(pointerId) { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup, pointerId); }
|
||||
closeupRelease(pointerId) { return !!this.minigame()?.release?.(this.closeup, pointerId); }
|
||||
closeupCancel(pointerId) { return !!this.minigame()?.cancel?.(this.closeup, pointerId); }
|
||||
closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
|
||||
|
||||
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
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 { guide, canThrow, shooter } from './rinquarto.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
|
||||
const NAME = { andrea: 'Andrea', bertovo: 'Bertovo' };
|
||||
// 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) {
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
table(ctx, bg);
|
||||
@@ -17,33 +36,42 @@ export function drawRinquarto(ctx, c, def, bg) {
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
// 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)) {
|
||||
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();
|
||||
const k = R * VIEW.k;
|
||||
ctx.strokeStyle = '#1b0d29'; ctx.lineWidth = 2;
|
||||
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();
|
||||
// In alto: chi tira, il punteggio, le sponde; l'esito sta qui, così non copre il tavolo.
|
||||
ctx.fillStyle = '#17160ff0'; ctx.fillRect(54, 4, 532, 64);
|
||||
text(ctx, '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');
|
||||
text(ctx, c.skill.training ? 'I RINQUARTI DI BERTOVO · ALLENAMENTO' : 'I RINQUARTI DI BERTOVO', 320, 13, 9, '#efd49e', 'center');
|
||||
const score = c.skill.training ? 'senza punti' : `Andrea ${c.score.andrea} Bertovo ${c.score.bertovo}`;
|
||||
text(ctx, `Posizione ${c.position}/${c.skill.positions} ${score} Sponde ${c.attempt.cushions}/${c.skill.cushions}`, 320, 29, 9, '#fff0c8', 'center');
|
||||
const who = NAME[shooter(c)];
|
||||
const line = c.step === 'review' ? c.line : c.step === 'think' ? 'Tira Bertovo…' : c.step === 'rolling' ? `Tira ${who}…` : `Tocca ad ${who}: «Oh tirami questo rinquarto!?»`;
|
||||
text(ctx, line, 320, 45, 8, c.step === 'review' ? (c.attempt.point ? '#b9e5b3' : '#ffbf9f') : '#9fd0ff', 'center');
|
||||
if (c.step === 'review') text(ctx, c.last ? 'Tocca per il risultato' : 'Tocca o premi Prossima', 320, 59, 7, '#d9ccba', 'center');
|
||||
if (c.step === 'result') {
|
||||
const r = c.result, title = r.training ? 'ALLENAMENTO FINITO' : r.winner === 'player' ? 'HAI BATTUTO BERTOVO!' : r.winner === 'cpu' ? 'HA VINTO BERTOVO' : 'PARI';
|
||||
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();
|
||||
}
|
||||
|
||||
+179
-78
@@ -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';
|
||||
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 = {
|
||||
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 },
|
||||
allenamento: { positions: 3, cushions: 1, width: [5, 4], bertovo: 1, guideBounces: 2, guideLength: Infinity, training: true },
|
||||
bona: { positions: 6, cushions: 1, width: [5, 2.5], bertovo: 0.45, guideBounces: 2, guideLength: Infinity },
|
||||
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;
|
||||
export const aimFree = c => !billiard.canThrow(c);
|
||||
export const button = c => c.step === 'result' ? 'Finita' : c.step === 'aim' ? 'Tira' : 'In movimento…';
|
||||
// BUDGET: prove a vuoto per fotogramma mentre si prepara la posizione dopo (una costa circa 0,2 ms su un PC).
|
||||
const SCAN = 720, STEP_ANGLE = Math.PI * 2 / SCAN, BUDGET = 15, THINK = 1.1;
|
||||
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
||||
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||
const 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 }) {
|
||||
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;
|
||||
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.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,
|
||||
});
|
||||
// Il tiro provato a vuoto, su una disposizione qualunque (quella in gioco se non se ne passa un'altra).
|
||||
export function tryShot(c, angle, power = 1, layout = null) {
|
||||
const w = layout ? { ...c, balls: layout.balls, pins: layout.pins } : c, result = attempt();
|
||||
simulate(w, '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;
|
||||
});
|
||||
// Coperta: lungo la linea dei centri il primo urto della bianca non è il bersaglio (si confrontano le distanze del
|
||||
// primo impatto, non solo la vicinanza alla linea: revisione di Codex, rilievo 8).
|
||||
export function directCovered(c, layout = c) {
|
||||
const a = layout.balls.player, b = layout.balls.cpu, dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||||
if (!d) return false;
|
||||
const ux = dx / d, uy = dy / d;
|
||||
const impact = (p, radius) => {
|
||||
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 && 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) {
|
||||
// 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 };
|
||||
// Una disposizione a caso: bianca e gialla lontane, un disturbo in mezzo, un altro e due birilli sparsi.
|
||||
function candidate(c) {
|
||||
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) return null;
|
||||
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;
|
||||
}
|
||||
const copy = layout => ({ balls: Object.fromEntries(Object.entries(layout.balls).map(([k, b]) => [k, { ...b }])), pins: layout.pins.map(p => ({ ...p })) });
|
||||
|
||||
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;
|
||||
// Prepara il tavolo per chi tira adesso, sempre dalla disposizione di partenza della posizione.
|
||||
function setTable(c) {
|
||||
const fresh = copy(c.start);
|
||||
c.balls = fresh.balls; c.pins = fresh.pins;
|
||||
c.step = shooter(c) === 'andrea' ? 'aim' : 'think';
|
||||
c.t = c.accumulator = 0; c.power = 1; c.holding = false; c.charging = false; c.aimLocked = false; c.plan = null;
|
||||
c.shot = c.pending = c.visual.shot = null; c.windup = 0; c.trail = [];
|
||||
c.angle = Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x);
|
||||
c.attempt = attempt();
|
||||
c.line = '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 = {}) {
|
||||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||
const c = { ...billiard.newBiliardo(seed), level, skill: LEVELS[level], position: 0, points: 0, history: [] };
|
||||
position(c);
|
||||
const c = { ...billiard.newBiliardo(seed), level, skill: LEVELS[level], position: 0, score: { andrea: 0, bertovo: 0 }, history: [],
|
||||
order: LEVELS[level].training ? ['bertovo', 'andrea'] : ['andrea', 'bertovo'], ready: [], gen: null, fixedPower: 1, trail: [], last: false };
|
||||
nextPosition(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) {
|
||||
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);
|
||||
// Intanto si prepara la posizione dopo, un po' per volta.
|
||||
if (c.position + c.ready.length < c.skill.positions) { const p = generate(c, BUDGET); if (p) c.ready.push(p); }
|
||||
// L'ultimo birillo può essere ancora in caduta: l'animazione va avanti anche fermi sull'esito.
|
||||
if (c.step === 'result' || c.step === 'review') { c.visual.time += dt; return; }
|
||||
if (c.step === 'think') {
|
||||
c.t += dt; c.visual.time += dt;
|
||||
if (c.t >= THINK) { const plan = bertovoPlan(c); billiard.strike(c, 'player', plan.angle, plan.power); }
|
||||
return;
|
||||
}
|
||||
const before = c.step;
|
||||
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 };
|
||||
if (c.step === 'rolling' && !c.windup) {
|
||||
const b = c.balls.player, last = c.trail.at(-1);
|
||||
if (!last || Math.hypot(last[0] - b.x, last[1] - b.y) > 1.5) c.trail.push([b.x, b.y]);
|
||||
}
|
||||
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.
|
||||
@@ -130,9 +224,16 @@ export function guide(c) {
|
||||
return points;
|
||||
}
|
||||
|
||||
const need = c => c.skill.cushions === 1 ? 'Serve almeno 1 sponda.' : `Servono almeno ${c.skill.cushions} sponde.`;
|
||||
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}.`;
|
||||
const score = c.skill.training ? 'Allenamento, senza punti.' : `Andrea ${c.score.andrea}, Bertovo ${c.score.bertovo}.`;
|
||||
const prefix = `Posizione ${c.position} di ${c.skill.positions}. ${score}`;
|
||||
if (c.step === 'result') {
|
||||
if (c.result.training) return 'Allenamento finito. Quando vuoi, la sfida vera.';
|
||||
return `${prefix} ${c.result.winner === 'player' ? 'Hai battuto Bertovo!' : c.result.winner === 'cpu' ? 'Ha vinto Bertovo.' : 'Pari.'}`;
|
||||
}
|
||||
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}.`;
|
||||
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
@@ -507,15 +507,16 @@ canvas.addEventListener('pointerdown', e => {
|
||||
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
|
||||
return;
|
||||
}
|
||||
if (press?.closeup) return;
|
||||
const aimed = game.closeupAim(scenePoint(e));
|
||||
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.closeup.aimLocked = true; // il clic fissa la mira
|
||||
}
|
||||
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).
|
||||
if (!game.skip()) game.closeupPress();
|
||||
if (!game.skip()) game.closeupPress(e.pointerId);
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
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; }
|
||||
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);
|
||||
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
|
||||
if (!press.long) {
|
||||
@@ -554,8 +560,13 @@ canvas.addEventListener('pointerup', e => {
|
||||
}
|
||||
press = null;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
|
||||
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
|
||||
canvas.addEventListener('pointercancel', e => {
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
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.' },
|
||||
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
||||
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
|
||||
rinquarto: { ...rinquarto, back: 'Torna da Bertovo', label: 'I rinquarti di Bertovo: clic o tocco fissa la mira. Frecce 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.' },
|
||||
// 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.' },
|
||||
@@ -645,7 +656,16 @@ for (let i = 0; i < 3; 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]]) {
|
||||
$(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).
|
||||
$(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); });
|
||||
@@ -821,8 +841,8 @@ window.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { game.closeupClose(); return; }
|
||||
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
|
||||
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
|
||||
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true)) { e.preventDefault(); return; }
|
||||
if (onControl) return;
|
||||
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true, e.shiftKey)) { e.preventDefault(); return; }
|
||||
if (onControl && [' ', 'Enter'].includes(e.key)) return;
|
||||
const c = game.closeup, m = mini();
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
@@ -868,7 +888,7 @@ window.addEventListener('keydown', e => {
|
||||
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
|
||||
window.addEventListener('keyup', e => {
|
||||
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
|
||||
if (miniState() && mini().key) { mini().key(game.closeup, e.key, false); return; }
|
||||
if (miniState() && mini().key?.(game.closeup, e.key, false)) return;
|
||||
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-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);
|
||||
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).
|
||||
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
|
||||
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 ';
|
||||
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');
|
||||
|
||||
+12
-7
@@ -227,10 +227,11 @@ export const story = {
|
||||
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]
|
||||
// Andrea contro Bertovo, sulle stesse posizioni; nell'allenamento il risultato è sempre «pari». [DA ANDREA: le battute]
|
||||
win: [{ say: 'M\'hai battuto ai rinquarti. Però un lo dire a nessuno.', who: 'bertovo', pose: 'shrug' }, { set: 'rinquarti_vinti' }],
|
||||
lose: [{ say: 'Le sponde ci sono per qualcosa! Basta chiederglielo per bene.', who: 'bertovo', pose: 'laugh' }],
|
||||
quit: [{ say: 'Questo me lo tengo per la prossima.' }],
|
||||
draw: [{ say: 'Visto? Geometrie celestiali. Si riprova quando vuoi.', who: 'bertovo', pose: 'explain' }],
|
||||
},
|
||||
fessura: {
|
||||
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: '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' },
|
||||
// L'allenamento: lui tira per primo e fa vedere, poi lo rifai te, senza punti.
|
||||
{ text: 'Dai, fammi vedere un rinquarto.', next: 'end', then: [
|
||||
{ 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' }] },
|
||||
],
|
||||
|
||||
@@ -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; }
|
||||
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
|
||||
.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 summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary { cursor: pointer; padding: 6px 0; }
|
||||
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p { max-width: 76em; line-height: 1.7; }
|
||||
#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, #rinquarto-help summary { cursor: pointer; padding: 6px 0; }
|
||||
#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) {
|
||||
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
||||
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
||||
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
||||
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
||||
#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-volume { width: 130px; accent-color: var(--accent); }
|
||||
|
||||
+143
-262
@@ -1,294 +1,175 @@
|
||||
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 { FIXED_STEP, LONG_PRESS } from '../src/engine/biliardo.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');
|
||||
// Tira (Andrea col tiro dato, o Bertovo da sé) e aspetta l'esito.
|
||||
function roll(c) {
|
||||
for (let i = 0; i < 20000 && c.step !== 'review' && c.step !== 'result'; i++) r.update(c, 1 / 60);
|
||||
assert.ok(['review', 'result'].includes(c.step), 'il tiro si deve fermare');
|
||||
}
|
||||
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');
|
||||
function andrea(c, angle = c.solution.angle) { c.angle = angle; assert.ok(r.shoot(c)); roll(c); return c.history.at(-1); }
|
||||
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]));
|
||||
|
||||
test('stesso seme e livello: stesse posizioni e soluzioni', () => {
|
||||
const a = r.newRinquarto(11, { level: 'seria' }), b = r.newRinquarto(11, { level: 'seria' });
|
||||
assert.ok(samePlace(a.start, b.start)); assert.deepEqual(a.solution, b.solution);
|
||||
assert.notDeepEqual(r.newRinquarto(12, { level: 'seria' }).solution, a.solution);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
assert.equal(c.history.at(-1).who, 'bertovo'); assert.equal(c.score.bertovo, Number(c.history.at(-1).point));
|
||||
r.advance(c);
|
||||
assert.equal(c.position, 2); assert.equal(r.shooter(c), 'andrea'); assert.equal(c.step, 'aim');
|
||||
});
|
||||
|
||||
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 });
|
||||
test('alla fine vince chi ha più punti; può finire pari', () => {
|
||||
const c = r.newRinquarto(8, { level: 'bona' });
|
||||
for (let p = 0; p < c.skill.positions; p++) { andrea(c, c.solution.angle + Math.PI); r.advance(c); roll(c); r.advance(c); }
|
||||
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);
|
||||
assert.equal(c.score.andrea, 0);
|
||||
assert.equal(c.result.winner, c.score.bertovo > 0 ? 'cpu' : 'draw');
|
||||
assert.match(r.status(c), c.score.bertovo > 0 ? /Ha vinto Bertovo/ : /Pari/);
|
||||
for (const [a, b, w] of [[3, 2, 'player'], [2, 3, 'cpu'], [2, 2, 'draw']]) {
|
||||
const d = { ...c, step: 'review', last: true, round: 1, position: c.skill.positions, score: { andrea: a, bertovo: b } };
|
||||
r.advance(d); assert.equal(d.result.winner, w);
|
||||
}
|
||||
});
|
||||
|
||||
test('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 });
|
||||
test('l\'allenamento: tira prima Bertovo, sempre giusto, poi Andrea; niente punti, finisce pari', () => {
|
||||
const c = r.newRinquarto(2, { level: 'allenamento' });
|
||||
assert.equal(r.shooter(c), 'bertovo'); assert.equal(c.step, 'think');
|
||||
for (let p = 0; p < c.skill.positions; p++) {
|
||||
roll(c); assert.ok(c.history.at(-1).point, 'Bertovo fa vedere il rinquarto');
|
||||
r.advance(c); assert.equal(r.shooter(c), 'andrea');
|
||||
andrea(c); r.advance(c);
|
||||
}
|
||||
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.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));
|
||||
}
|
||||
const g = new Game(story); g.go('circolo_4_maggio'); settle(g);
|
||||
assert.ok(g.people().some(p => p.who === 'bertovo'));
|
||||
g.act('talk', 'bertovo'); settle(g);
|
||||
assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito');
|
||||
g.choose(g.options().findIndex(o => o.text === 'Si fa una sfida a rinquarti?'));
|
||||
assert.ok(settle(g).some(s => s.who === 'bertovo' && s.text.includes('Oh tirami questo rinquarto!?')));
|
||||
assert.equal(g.options().length, 4);
|
||||
g.choose(g.options().findIndex(o => o.text.startsWith('No'))); settle(g);
|
||||
assert.equal(g.closeup, null); assert.ok(g.dialogue, 'col No si torna a parlare');
|
||||
g.choose(g.options().findIndex(o => o.text === 'Si fa una sfida a rinquarti?')); settle(g);
|
||||
g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
|
||||
assert.equal(g.closeup?.id, 'rinquarto'); assert.equal(g.closeup.level, 'seria');
|
||||
g.closeup.step = 'result'; g.closeup.result = { winner: 'player', points: { andrea: 4, bertovo: 2 } };
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.rinquarti_vinti);
|
||||
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', () => {
|
||||
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', () => {
|
||||
test('il disegno: traiettoria con le sponde numerate, riepilogo, e lo stato non cambia', () => {
|
||||
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));
|
||||
}
|
||||
const c = r.newRinquarto(8, { level: 'bona' }), bg = {};
|
||||
const draw = () => { const before = structuredClone(c); drawRinquarto(ctx, c, story.closeups.rinquarto, bg); assert.deepEqual(c, before); };
|
||||
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
|
||||
andrea(c); calls.length = 0; draw();
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === '1'), 'la prima sponda è numerata');
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && /punto!/.test(t)));
|
||||
c.step = 'result'; c.result = { winner: 'cpu', points: { andrea: 1, bertovo: 3 } }; calls.length = 0; draw();
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'HA VINTO BERTOVO'));
|
||||
} finally {
|
||||
if (previous === undefined) delete globalThis.document; else globalThis.document = previous;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user