diff --git a/assets/voices/andrea-fe39c8f30c.mp3 b/assets/voices/andrea-fe39c8f30c.mp3
new file mode 100644
index 0000000..6424fab
Binary files /dev/null and b/assets/voices/andrea-fe39c8f30c.mp3 differ
diff --git a/assets/voices/bertovo-03564e5a92.mp3 b/assets/voices/bertovo-03564e5a92.mp3
deleted file mode 100644
index ba88c2c..0000000
Binary files a/assets/voices/bertovo-03564e5a92.mp3 and /dev/null differ
diff --git a/assets/voices/bertovo-5d93de3623.mp3 b/assets/voices/bertovo-5d93de3623.mp3
new file mode 100644
index 0000000..b84b76e
Binary files /dev/null and b/assets/voices/bertovo-5d93de3623.mp3 differ
diff --git a/assets/voices/bertovo-80c18678cd.mp3 b/assets/voices/bertovo-80c18678cd.mp3
new file mode 100644
index 0000000..a20282c
Binary files /dev/null and b/assets/voices/bertovo-80c18678cd.mp3 differ
diff --git a/assets/voices/bertovo-9aa247cffe.mp3 b/assets/voices/bertovo-9aa247cffe.mp3
new file mode 100644
index 0000000..e8a5d98
Binary files /dev/null and b/assets/voices/bertovo-9aa247cffe.mp3 differ
diff --git a/assets/voices/bertovo-a63d2da89d.mp3 b/assets/voices/bertovo-a63d2da89d.mp3
deleted file mode 100644
index 82e1f69..0000000
Binary files a/assets/voices/bertovo-a63d2da89d.mp3 and /dev/null differ
diff --git a/assets/voices/bertovo-a6a09b5b87.mp3 b/assets/voices/bertovo-a6a09b5b87.mp3
deleted file mode 100644
index 0b6560c..0000000
Binary files a/assets/voices/bertovo-a6a09b5b87.mp3 and /dev/null differ
diff --git a/assets/voices/bertovo-ad20035f2e.mp3 b/assets/voices/bertovo-ad20035f2e.mp3
new file mode 100644
index 0000000..0da42c4
Binary files /dev/null and b/assets/voices/bertovo-ad20035f2e.mp3 differ
diff --git a/assets/voices/bertovo-ca941bee91.mp3 b/assets/voices/bertovo-ca941bee91.mp3
new file mode 100644
index 0000000..0f8012b
Binary files /dev/null and b/assets/voices/bertovo-ca941bee91.mp3 differ
diff --git a/assets/voices/bertovo-d71d01230d.mp3 b/assets/voices/bertovo-d71d01230d.mp3
deleted file mode 100644
index 6989e8d..0000000
Binary files a/assets/voices/bertovo-d71d01230d.mp3 and /dev/null differ
diff --git a/assets/voices/bertovo-fde93190e9.mp3 b/assets/voices/bertovo-fde93190e9.mp3
deleted file mode 100644
index e7894c5..0000000
Binary files a/assets/voices/bertovo-fde93190e9.mp3 and /dev/null differ
diff --git a/assets/voices/index.json b/assets/voices/index.json
index 84ef503..c95817f 100644
--- a/assets/voices/index.json
+++ b/assets/voices/index.json
@@ -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",
diff --git a/docs/IDEE.md b/docs/IDEE.md
index 6142f7a..56ea2bf 100644
--- a/docs/IDEE.md
+++ b/docs/IDEE.md
@@ -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).
diff --git a/docs/STATO.md b/docs/STATO.md
index e7d70de..0ab807a 100644
--- a/docs/STATO.md
+++ b/docs/STATO.md
@@ -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
diff --git a/index.html b/index.html
index a6337fe..66aa620 100644
--- a/index.html
+++ b/index.html
@@ -250,19 +250,18 @@
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.
Come si gioca
-
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.
-
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.
-
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.
+
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.
+
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.
+
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.
Come si gioca
diff --git a/src/engine/biliardo-render.js b/src/engine/biliardo-render.js
index f856981..6ac5b74 100644
--- a/src/engine/biliardo-render.js
+++ b/src/engine/biliardo-render.js
@@ -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);
diff --git a/src/engine/biliardo.js b/src/engine/biliardo.js
index 32af3e1..f793c10 100644
--- a/src/engine/biliardo.js
+++ b/src/engine/biliardo.js
@@ -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})`;
}
diff --git a/src/engine/game.js b/src/engine/game.js
index f7af17a..32eb60a 100644
--- a/src/engine/game.js
+++ b/src/engine/game.js
@@ -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:
diff --git a/src/engine/rinquarto-render.js b/src/engine/rinquarto-render.js
index 2dcd9a1..71ac363 100644
--- a/src/engine/rinquarto-render.js
+++ b/src/engine/rinquarto-render.js
@@ -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();
}
diff --git a/src/engine/rinquarto.js b/src/engine/rinquarto.js
index 329c2fe..7df6395 100644
--- a/src/engine/rinquarto.js
+++ b/src/engine/rinquarto.js
@@ -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)}`;
}
diff --git a/src/main.js b/src/main.js
index 896255d..57b2cb4 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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');
diff --git a/src/story/story.js b/src/story/story.js
index e5ae8af..5f7f3bd 100644
--- a/src/story/story.js
+++ b/src/story/story.js
@@ -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' }] },
],
diff --git a/style.css b/style.css
index 88b18cb..81c5e08 100644
--- a/style.css
+++ b/style.css
@@ -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); }
diff --git a/tests/rinquarto.test.mjs b/tests/rinquarto.test.mjs
index 0898aac..3d40e40 100644
--- a/tests/rinquarto.test.mjs
+++ b/tests/rinquarto.test.mjs
@@ -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;
}