Biliardo più preciso (revisione di Codex): urti calcolati nel punto esatto di contatto, anche nei colpi di taglio e veloci; la guida simula il tiro vero e mostra le due biglie dopo l'urto e i falli; la mira resta ferma andando sui comandi; frecce e Maiusc per la mira fine; comandi che non coprono il tavolo; test nel browser con mouse, touch e telefono. Più un tetto agli urti per passo, così due biglie a contatto non possono bloccare il gioco

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
2026-10-25 23:12:46 +01:00
co-authored by Claude Opus 5.5
parent ddc877a2d1
commit 9eabd5c6e1
10 changed files with 434 additions and 71 deletions
+6
View File
@@ -47,6 +47,11 @@ Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
Al biliardo del Circolo 4 Maggio, clic o tocco fissa la mira; regola la forza e premi **Tira**.
Le frecce ← → affinano la direzione, con Maiusc per passi ancora più piccoli. La guida usa la stessa fisica
del tiro: mostra sponde, primo contatto e direzioni iniziali dopo l'urto (azzurro per l'avversaria, giallo
per la propria biglia). Tenendo premuto sul tavolo o Spazio si carica il colpo, con la mira bloccata.
## In Docker
```zsh
@@ -71,5 +76,6 @@ npm test # logica, storia coerente, soluzione percorsa dall'inizio alla
npm install && npx playwright install chromium-headless-shell
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
npm run browser:biliardo # mira e tiro con mouse/touch, desktop e telefono; schermate in /tmp/regolo-biliardo
```
+8 -3
View File
@@ -122,9 +122,14 @@
Vince chi fa più punti nella mano.</p>
</details>
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
<p>Tocca il tavolo per mirare: la linea mostra dove va la tua biglia e la freccia dove parte quella colpita.
Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare.
Frecce ← →: mira fine; ↑ ↓: forza. Esc esce dalla partita.</p>
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
la tua. Le frecce mostrano solo il primo tratto, fino al prossimo ostacolo o al massimo 50 cm.
Senza urti, il cerchietto indica dove ti fermi. La guida arancione segnala un primo contatto falloso.</p>
<p>Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare:
durante la carica la mira resta ferma. Frecce ← →: mira fine; Maiusc + ← →: regolazione ancora più precisa;
↑ ↓: forza. Esc esce dalla partita. I colpi sono centrali, senza effetti di rotazione.</p>
<p>Cinque birilli: devi colpire la biglia avversaria e mandarla sui birilli o sul pallino. Birillo bianco 2 punti,
rosso 4, rosso da solo 10; pallino toccato dalla biglia avversaria 3. Se manchi la biglia avversaria, tocchi prima
il pallino o abbatti birilli con la tua, i punti (almeno 2) vanno all'avversario. Un colpo a testa; vince chi arriva prima a 20.</p>
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000"
},
+29 -17
View File
@@ -1,11 +1,11 @@
import { LEN, WID, R, PIN, VIEW, canThrow, predict, lastLine, status } from './biliardo.js';
import { LEN, WID, R, VIEW, canThrow, predict, status } from './biliardo.js';
import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
// Le biglie si disegnano un po' più grandi del vero (la fisica resta quella), sennò sul telefono spariscono.
const rr = R * VIEW.k * 1.3;
// Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
const rr = R * VIEW.k;
function sphere(ctx, role, x, y, r) {
const [dark, mid, light] = BALL[role];
@@ -51,17 +51,28 @@ function table(ctx) {
function aimGuide(ctx, c) {
const p = predict(c), me = c.balls[c.current];
ctx.strokeStyle = 'rgba(255,248,220,0.75)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
const foul = p.hit === 'pin' || p.hit === 'pallino';
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,248,220,0.85)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1]));
for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y));
ctx.stroke(); ctx.setLineDash([]);
if (p.ghost) {
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
if (p.dir) {
const o = c.balls[p.hit], L = 22;
ctx.strokeStyle = p.hit === 'pallino' ? 'rgba(255,120,100,0.9)' : 'rgba(150,210,255,0.9)';
ctx.beginPath(); ctx.moveTo(sx(o.x), sy(o.y)); ctx.lineTo(sx(o.x) + p.dir[0] * L, sy(o.y) + p.dir[1] * L); ctx.stroke();
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
for (const track of p.outgoing) {
const [start, end] = track.points;
if (!end || Math.hypot(end[0] - start[0], end[1] - start[1]) < 1) continue;
const x = sx(end[0]), y = sy(end[1]), a = Math.atan2(end[1] - start[1], end[0] - start[0]);
ctx.strokeStyle = track.role === c.current ? '#f4df8d' : track.role === 'pallino' ? '#ffae8b' : '#96dfff';
ctx.setLineDash(track.role === c.current ? [2, 3] : []);
ctx.beginPath(); ctx.moveTo(sx(start[0]), sy(start[1])); ctx.lineTo(x, y); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(x - Math.cos(a - 0.5) * 5, y - Math.sin(a - 0.5) * 5);
ctx.lineTo(x, y); ctx.lineTo(x - Math.cos(a + 0.5) * 5, y - Math.sin(a + 0.5) * 5); ctx.stroke();
}
} else {
// Il cerchietto finale è l'arresto effettivo con la forza scelta.
const [x, y] = p.points.at(-1);
ctx.strokeStyle = '#fff8dc'; ctx.beginPath(); ctx.arc(sx(x), sy(y), 2, 0, Math.PI * 2); ctx.stroke();
}
// La stecca dietro la biglia, tirata indietro quanto la forza.
const a = c.angle, back = rr + 6 + c.power * 26, len = 150;
@@ -90,20 +101,21 @@ export function drawBiliardo(ctx, c, def) {
// Punteggio: i due nomi, i punti, chi deve tirare.
panel(ctx, 18, 8, 604, 62);
for (const [i, who] of ['player', 'cpu'].entries()) {
const x = 30 + i * 300, y = 24;
const x = 30 + i * 420, y = 24;
sphere(ctx, who, x + 4, y, 4);
text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]);
text(ctx, String(c.points[who]), x + 14, y + 16, 10, c.step === 'aim' && c.current === who ? '#fff2a3' : '#f4e3c2');
if (c.step === 'aim' && c.current === who) text(ctx, '< al tiro', x + 44, y + 16, 6, '#fff2a3');
}
const last = c.last && c.last.age < 6 ? lastLine(c, def.names) : '';
if (last) text(ctx, last, 610, 42, 6, c.last.foul ? '#f3b0a0' : '#c9eab3', 'right');
text(ctx, c.step === 'aim' ? c.current === 'player' ? 'Tocca il tavolo per mirare; tieni premuto e lascia per tirare.' : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, '#d7ccb4', 'center');
const hint = canThrow(c) ? predict(c).hit : null;
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).'
: 'Clic fissa la mira. Azzurro: avversaria; giallo: la tua dopo l’urto.';
text(ctx, c.step === 'aim' ? c.current === 'player' ? instruction : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, hint === 'pin' || hint === 'pallino' ? '#ffae8b' : '#d7ccb4', 'center');
if (canThrow(c)) {
panel(ctx, 18, 318, 176, 34);
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 329, 7);
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 341, 150, 5);
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 341, Math.round(c.power * 150), 5);
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 320, 25, 7, '#f4e3c2', 'center');
ctx.fillStyle = '#3e3e36'; ctx.fillRect(245, 36, 150, 5);
ctx.fillStyle = '#b9d392'; ctx.fillRect(245, 36, Math.round(c.power * 150), 5);
}
if (c.step === 'result') {
panel(ctx, 170, 140, 300, 80);
+121 -41
View File
@@ -8,9 +8,10 @@ export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
export const SWING = 1.4, FIXED_STEP = 1 / 240;
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' };
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
export const VIEW = { x: 57, y: 78, k: 1.85 };
export const VIEW = { x: 70, y: 88, k: 1.76 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
@@ -72,12 +73,12 @@ function cushions(b) {
// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte.
export function collide(a, b) {
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= 2 * R) return 0;
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = (2 * R - d) / 2;
if (d > 2 * R + CONTACT_EPS) return 0;
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = Math.max(0, (2 * R - d) / 2);
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (closing <= 0) return 0;
const j = closing * 0.975;
if (closing <= CONTACT_EPS) return 0;
const j = closing * (1 + BALL_RESTITUTION) / 2;
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
return closing;
}
@@ -87,28 +88,70 @@ function touch(shot, a, b) {
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
}
function step(w, dt) {
let hit = 0, rail = 0;
for (const b of w.balls) {
// Tempo al primo contatto: si risolve l'intersezione del moto relativo con il cerchio.
// Anche un colpo veloce o molto sottile viene risolto sul punto d'urto, non dopo la compenetrazione.
function contactTime(dx, dy, vx, vy, radius) {
const closing = dx * vx + dy * vy, speed2 = vx * vx + vy * vy;
if (closing >= -CONTACT_EPS * radius || speed2 === 0) return Infinity;
const gap = dx * dx + dy * dy - radius * radius;
if (gap <= CONTACT_EPS) return 0;
const discriminant = closing * closing - speed2 * gap;
if (discriminant <= 0) return Infinity;
return gap / (-closing + Math.sqrt(discriminant));
}
function slow(b, dt) {
const v = Math.hypot(b.vx, b.vy);
if (v < EPS) { b.vx = b.vy = 0; continue; }
const nv = Math.max(0, v - (FRICTION + DRAG * v) * dt), travel = (v + nv) / 2 * dt;
b.x += b.vx / v * travel; b.y += b.vy / v * travel;
b.vx *= nv / v; b.vy *= nv / v;
rail = Math.max(rail, cushions(b));
for (const p of w.pins) {
if (p.down || Math.hypot(b.x - p.x, b.y - p.y) >= R + PIN) continue;
// Il birillo cade dalla parte opposta a chi lo colpisce; la biglia perde un po' di slancio.
Object.assign(p, { down: true, by: b.role, dir: Math.atan2(p.y - b.y, p.x - b.x) });
b.vx *= 0.88; b.vy *= 0.88;
w.shot?.pins.push(p);
if (!v) return;
const nv = (v + FRICTION / DRAG) * Math.exp(-DRAG * dt) - FRICTION / DRAG;
const scale = nv < EPS ? 0 : nv / v;
b.vx *= scale; b.vy *= scale;
}
function advance(balls, dt) {
for (const b of balls) { b.x += b.vx * dt; b.y += b.vy * dt; }
}
function step(w, dt) {
// Mezzo passo d'attrito, moto con urti continui, mezzo passo d'attrito.
// Questa stessa integrazione serve al tiro, alla guida e alle prove dell'avversario.
for (const b of w.balls) slow(b, dt / 2);
let left = dt, hit = 0, rail = 0;
// Un tetto agli urti per passo: due biglie a contatto con un arrotondamento sfortunato non devono bloccare il gioco.
for (let events = 0; left > 1e-10 && events < 64; events++) {
let time = left, event = null;
const consider = (t, kind, a, b, axis, edge) => {
if (t >= -1e-10 && t <= time) { time = Math.max(0, t); event = { kind, a, b, axis, edge }; }
};
for (let i = 0; i < w.balls.length; i++) {
const a = w.balls[i];
if (a.vx) consider(((a.vx < 0 ? R : LEN - R) - a.x) / a.vx, 'rail', a, null, 'x', a.vx < 0 ? R : LEN - R);
if (a.vy) consider(((a.vy < 0 ? R : WID - R) - a.y) / a.vy, 'rail', a, null, 'y', a.vy < 0 ? R : WID - R);
for (let j = i + 1; j < w.balls.length; j++) {
const b = w.balls[j];
consider(contactTime(a.x - b.x, a.y - b.y, a.vx - b.vx, a.vy - b.vy, 2 * R), 'ball', a, b);
}
if (!a.vx && !a.vy) continue;
for (const p of w.pins) if (!p.down)
consider(contactTime(a.x - p.x, a.y - p.y, a.vx, a.vy, R + PIN), 'pin', a, p);
}
advance(w.balls, time); left -= time;
if (!event) break;
const { kind, a, b, axis, edge } = event;
if (kind === 'rail') {
const v = axis === 'x' ? 'vx' : 'vy';
a[axis] = edge;
rail = Math.max(rail, Math.abs(a[v])); a[v] *= -CUSHION;
} else if (kind === 'ball') {
const impact = collide(a, b);
if (impact > 0) { touch(w.shot, a, b); hit = Math.max(hit, impact); }
} else {
const v = Math.hypot(a.vx, a.vy);
Object.assign(b, { down: true, by: a.role, dir: Math.atan2(b.y - a.y, b.x - a.x) });
a.vx *= 0.88; a.vy *= 0.88;
w.shot?.pins.push(b);
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
}
w.onContact?.(event);
}
for (let i = 0; i < w.balls.length; i++) for (let j = i + 1; j < w.balls.length; j++) {
const s = collide(w.balls[i], w.balls[j]);
if (s > 0) { touch(w.shot, w.balls[i], w.balls[j]); hit = Math.max(hit, s); }
}
for (const b of w.balls) slow(b, dt / 2);
if (w.events && w.events.length < 16 && (hit > 12 || rail > 12))
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
}
@@ -197,13 +240,20 @@ export function update(c, dt) {
// --- Comandi del giocatore: gli stessi delle bocce.
export function aimAt(c, x, y) {
if (!canThrow(c) || !Number.isFinite(x) || !Number.isFinite(y)) return false;
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const b = c.balls.player;
if (Math.hypot(x - b.x, y - b.y) < R) return false;
c.angle = Math.atan2(y - b.y, x - b.x); return true;
}
export function aimScreen(c, [sx, sy]) { return aimAt(c, (sx - VIEW.x) / VIEW.k, (sy - VIEW.y) / VIEW.k); }
export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.angle += dir * 0.01; return true; }
export function aimScreen(c, [sx, sy]) {
const x = (sx - VIEW.x) / VIEW.k, y = (sy - VIEW.y) / VIEW.k;
if (x < 0 || x > LEN || y < 0 || y > WID) return false;
return aimAt(c, x, y);
}
export function nudge(c, dir, fine = false) {
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
c.angle += dir * (fine ? 0.0005 : 0.002); return true;
}
export function setPower(c, value) {
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
c.power = clamp(value, 0, 1); return true;
@@ -223,24 +273,54 @@ export function release(c) {
c.holding = false; return strike(c, 'player', c.angle, c.power);
}
// Dove andrebbe la biglia: la linea fino al primo urto (con le sponde, fino a dove arriva con quella forza),
// la biglia fantasma dove tocca, e verso dove partirebbe quella colpita.
// La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
// Dopo il primo contatto mostra il tratto iniziale delle due biglie, fino al prossimo
// ostacolo, all'arresto o a 50 cm. La cache evita di riprovare il tiro a ogni disegno.
const predictions = new WeakMap();
export function predict(c) {
const me = c.balls[c.current], others = list(c).filter(b => b !== me);
let x = me.x, y = me.y, dx = Math.cos(c.angle), dy = Math.sin(c.angle), left = reach(speedFor(c.power));
const points = [[x, y]], STEP = 0.5;
for (let n = 0; left > 0 && n < 6000; n++) {
x += dx * STEP; y += dy * STEP; left -= STEP;
if (x < R || x > LEN - R) { x = clamp(x, R, LEN - R); dx = -dx; left *= 0.6; points.push([x, y]); }
if (y < R || y > WID - R) { y = clamp(y, R, WID - R); dy = -dy; left *= 0.6; points.push([x, y]); }
for (const o of others) {
const d = Math.hypot(o.x - x, o.y - y);
if (d < 2 * R) { points.push([x, y]); return { points, ghost: { x, y }, hit: o.role, dir: [(o.x - x) / d, (o.y - y) / d] }; }
const key = [c.current, c.angle, c.power, ...list(c).flatMap(b => [b.x, b.y, b.vx, b.vy]),
...c.pins.flatMap(p => [p.x, p.y, p.down])].join('|');
const cached = predictions.get(c);
if (cached?.key === key) return cached.result;
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
const v = speedFor(c.power);
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
const tracks = [];
const point = b => [b.x, b.y];
const endTrack = track => {
track.points.push(point(track.ball)); track.done = true;
};
const w = { balls, pins: c.pins.map(p => ({ ...p })), onContact(event) {
const { kind, a, b } = event;
for (const track of tracks) if (!track.done && (track.ball === a || track.ball === b)) endTrack(track);
if (result.hit || (a !== me && b !== me)) return;
result.points.push(point(me));
if (kind === 'rail') return;
result.ghost = { x: me.x, y: me.y };
result.hit = kind === 'pin' ? 'pin' : (a === me ? b : a).role;
if (kind === 'pin') return;
const other = a === me ? b : a, speed = Math.hypot(other.vx, other.vy);
result.dir = speed ? [other.vx / speed, other.vy / speed] : null;
for (const ball of [me, other]) {
const points = [point(ball)];
tracks.push({ ball, points, done: false });
result.outgoing.push({ role: ball.role, points });
}
for (const p of c.pins) if (!p.down && Math.hypot(p.x - x, p.y - y) < R + PIN) { points.push([x, y]); return { points, ghost: { x, y }, hit: 'pin', dir: null }; }
} };
for (let i = 0; i < 15 / FIXED_STEP; i++) {
step(w, FIXED_STEP);
for (const track of tracks) {
if (track.done) continue;
const [x, y] = track.points[0], dx = track.ball.x - x, dy = track.ball.y - y, d = Math.hypot(dx, dy);
if (d >= 50) { track.points.push([x + dx * 50 / d, y + dy * 50 / d]); track.done = true; }
else if (!track.ball.vx && !track.ball.vy) endTrack(track);
}
points.push([x, y]);
return { points, ghost: null, hit: null, dir: null };
if (result.hit ? tracks.every(t => t.done) : !me.vx && !me.vy) break;
}
if (!result.hit) result.points.push(point(me));
predictions.set(c, { key, result });
return result;
}
const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' };
+10 -4
View File
@@ -399,8 +399,9 @@ canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) {
const aimed = game.closeupAim(scenePoint(e));
if (story.closeups[game.closeup.id].type === 'biliardo' && !aimed && !game.speech) return;
press = { closeup: true };
game.closeupAim(scenePoint(e));
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress();
canvas.setPointerCapture(e.pointerId);
@@ -431,7 +432,10 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => {
if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
// Al biliardo il clic fissa la mira: attraversare il tavolo per raggiungere «Tira»
// o sfiorare il mouse dopo una regolazione fine non deve cambiare il colpo.
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
if (!billiards && (e.pointerType === 'mouse' || press?.closeup)) game.closeupAim(scenePoint(e));
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
@@ -439,7 +443,7 @@ canvas.addEventListener('pointerleave', () => { mouse = null; });
// lancio con un solo clic, anche per chi non vuole usare la pressione temporizzata.
const MINI = {
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: tocca il tavolo per mirare. Regola la forza e premi Tira.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type];
@@ -539,7 +543,7 @@ window.addEventListener('keydown', e => {
const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1);
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1, e.shiftKey);
else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
return;
}
@@ -623,6 +627,7 @@ function updateUi() {
$('bocce-controls').hidden = !mg;
$('bocce-controls').inert = !running;
if (mg) {
$('bocce-controls').setAttribute('aria-label', def.type === 'biliardo' ? 'Comandi del biliardo' : 'Comandi delle bocce');
$('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
@@ -644,6 +649,7 @@ function updateUi() {
$('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
$('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo');
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null;
+9 -4
View File
@@ -34,16 +34,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p { max-width: 76em; line-height: 1.7; }
#bocce-help, #biliardo-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-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 { font-size: 9px; }
#bocce-help, #biliardo-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); }
@@ -56,6 +56,10 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
/* Il primo piano del libro si chiude da qui (o con Esc). */
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
/* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
#stage.biliardo #scene { height: auto; }
#stage.biliardo #closeup-close { position: static; display: block; margin: 6px 10px 6px auto; min-height: 32px; }
/* Audio: nota per il muto, cursore della musica e cursore delle voci («bla»), in alto a destra sopra la scena (anche sul titolo). */
#sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
@@ -265,6 +269,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
@media (orientation: landscape) and (max-height: 520px) {
#app { width: 100vw; flex-direction: row; }
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; }
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
+126
View File
@@ -0,0 +1,126 @@
// Regressioni della mira con veri eventi browser, su desktop e touch.
// npm run browser:biliardo — schermate in /tmp/regolo-biliardo.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-biliardo';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [
['desktop', false, { width: 1280, height: 860 }],
['touch', true, { width: 844, height: 390 }],
['portrait', true, { width: 390, height: 844 }],
]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash');
await page.click('#new-game');
await page.click('#rione [data-rione="campomigliaio"]');
await page.click('#skip-intro');
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
settle(game);
game.go('circolo_4_maggio'); settle(game);
game.act('talk', 'ing_tozzi'); settle(game);
game.choose(game.options().findIndex(o => o.text.includes('una partita ai birilli'))); settle(game);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
const canvas = await page.locator('#scene').boundingBox();
for (const id of ['#sound', '#corner']) {
const box = await page.locator(id).boundingBox();
assert.ok(box.y + box.height <= canvas.y, `${id} non deve coprire il punteggio`);
}
const exit = await page.locator('#closeup-close').boundingBox();
assert.ok(exit.y >= canvas.y + canvas.height, 'uscita fuori dal tavolo');
await page.waitForTimeout(1600); // completa la dissolvenza della sala prima delle schermate
const reset = () => page.evaluate(async () => {
const { newBiliardo } = await import('/src/engine/biliardo.js');
game.closeup = { ...newBiliardo(42), id: 'biliardo' };
});
const state = () => page.evaluate(() => ({ angle: game.closeup.angle, power: game.closeup.power, step: game.closeup.step }));
const position = async () => {
const r = await page.locator('#scene').boundingBox();
const [x, y] = await page.evaluate(async () => {
const { VIEW } = await import('/src/engine/biliardo.js');
const b = game.closeup.balls.cpu;
return [VIEW.x + b.x * VIEW.k, VIEW.y + (b.y + 3) * VIEW.k];
});
return { x: r.x + x / 640 * r.width, y: r.y + y / 360 * r.height, r };
};
const target = await position();
if (mobile) await page.touchscreen.tap(target.x, target.y);
else await page.mouse.click(target.x, target.y);
assert.equal((await state()).step, 'aim', 'un tocco sceglie la mira senza tirare');
assert.equal((await state()).power, 0.45, 'il tocco conserva la forza impostata');
// Il passaggio del mouse verso i comandi attraversa il panno.
const fixed = (await state()).angle;
await page.mouse.move(target.r.x + target.r.width * 0.55, target.r.y + target.r.height * 0.8, { steps: 8 });
assert.equal((await state()).angle, fixed, 'spostarsi verso Tira non deve cambiare la mira');
await page.keyboard.press('ArrowRight');
const nudged = (await state()).angle;
assert.ok(Math.abs(nudged - fixed - 0.002) < 1e-10);
await page.keyboard.press('Shift+ArrowLeft');
assert.ok(Math.abs((await state()).angle - nudged + 0.0005) < 1e-10);
await page.locator('#bocce-power').fill('60');
const prepared = await state();
await page.screenshot({ path: `${out}/${name}-mira.png` });
const expected = await page.evaluate(async () => {
const { simulate } = await import('/src/engine/biliardo.js');
const c = game.closeup;
return simulate(c, 'player', c.angle, c.power);
});
if (mobile) await page.tap('#bocce-throw'); else await page.click('#bocce-throw');
assert.equal((await state()).step, 'rolling');
assert.equal((await state()).angle, prepared.angle);
const score = await page.evaluate(async () => {
const { update, judge, FIXED_STEP } = await import('/src/engine/biliardo.js');
const c = game.closeup;
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
return judge(c.shot);
});
assert.deepEqual(score, expected, 'il tiro dai comandi segue la simulazione');
await reset();
const hold = await position();
const cdp = mobile ? await page.context().newCDPSession(page) : null;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.25);
const heldAngle = (await state()).angle;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: hold.x - 30, y: hold.y + 20 }] });
else await page.mouse.move(hold.x - 30, hold.y + 20, { steps: 4 });
assert.equal((await state()).angle, heldAngle, 'il movimento durante la carica non sposta la mira');
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
else await page.mouse.up();
assert.equal((await state()).step, 'rolling');
assert.equal((await state()).angle, heldAngle);
// Un gesto interrotto non deve lasciare un tiro in carica.
await reset();
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
await page.waitForFunction(() => game.closeup.holding);
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
else { await page.locator('#scene').dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up(); }
assert.equal((await state()).step, 'aim');
assert.equal((await state()).power, 0.45);
assert.equal(await page.evaluate(() => game.closeup.holding), false);
await page.keyboard.press('Escape');
assert.equal(await page.evaluate(() => game.closeup), null);
await page.close();
console.log(`${name}: mira, ritocchi, forza, tiro, carica e annullamento OK`);
}
assert.deepEqual(errors, [], 'nessun errore JavaScript nel browser');
console.log(`Schermate: ${out}`);
} finally {
await browser?.close();
server.kill();
}
+123 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBiliardo, update, press, release, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimScreen, canThrow, LEN, WID, R, VIEW, SWING, powerAt } from '../src/engine/biliardo.js';
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION } from '../src/engine/biliardo.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
@@ -86,6 +86,8 @@ test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita fin
assert.equal(c.step, 'rolling', 'l\'avversario ha tirato');
assert.ok(frames > 20, 'non tira subito: ci pensa');
for (let turn = 0; turn < 60 && c.step !== 'result'; turn++) {
// Una mano comprende anche il tempo in cui la CPU pensa prima di tirare.
for (let i = 0; i < 600 && c.step === 'aim' && c.current === 'cpu'; i++) update(c, 1 / 60);
still(c);
if (c.step === 'aim' && c.current === 'player') {
const me = c.balls.player, other = c.balls.cpu;
@@ -99,6 +101,126 @@ test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita fin
assert.equal(press(c), false, 'a partita finita non si tira più');
});
const emptyTable = () => {
const c = newBiliardo(123);
c.pins = [];
Object.assign(c.balls.player, { x: 30, y: 30 });
Object.assign(c.balls.cpu, { x: 250, y: 120 });
Object.assign(c.balls.pallino, { x: 20, y: 120 });
return c;
};
const roll = (c, dt = FIXED_STEP) => {
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, dt);
assert.equal(c.step, 'settle', 'tutte le biglie devono fermarsi');
};
const near = (actual, expected, tolerance = 1e-6) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≠ ${expected} (tolleranza ${tolerance})`);
test('la guida e il tiro finiscono nello stesso punto, anche dopo sponde oblique e a diverse forze', () => {
for (const [x, y, angle, power] of [[30, 30, 0, 0.2], [270, 30, 0.65, 0.85], [260, 100, 0.1, 1], [30, 10, -1.2, 0.6]]) {
const c = emptyTable();
Object.assign(c.balls.player, { x, y });
c.angle = angle; c.power = power;
const before = JSON.stringify(c), p = predict(c);
assert.equal(JSON.stringify(c), before, 'la previsione non altera il tavolo né il seme casuale');
assert.equal(p.hit, null);
const end = p.points.at(-1);
shoot(c); roll(c);
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power)), 0.01);
}
});
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
const c = emptyTable();
Object.assign(c.balls.player, { x: 270, y: 30 });
c.angle = Math.PI / 4; c.power = 0.3;
const p = predict(c), [start, rail, end] = p.points;
assert.equal(p.points.length, 3);
near(rail[0], LEN - R);
near((rail[1] - start[1]) / (rail[0] - start[0]), 1);
near((end[1] - rail[1]) / (end[0] - rail[0]), -1 / CUSHION);
});
test('i colpi sottili arrivano al punto di contatto corretto anche a piena forza', () => {
for (const offset of [0, R, 2 * R * 0.9, 2 * R - 0.0001, 2 * R + 0.0001]) {
const c = emptyTable();
Object.assign(c.balls.player, { x: 30, y: 50 + offset });
Object.assign(c.balls.cpu, { x: 100, y: 50 });
c.angle = 0; c.power = 1;
const p = predict(c);
shoot(c);
for (let i = 0; i < 1000 && c.balls.player.x < 110 && !c.shot.first; i++) update(c, FIXED_STEP);
if (offset > 2 * R) {
assert.equal(c.shot.first, null, 'un passaggio esterno non diventa un urto');
continue;
}
assert.equal(p.hit, 'cpu');
assert.equal(c.shot.first, 'cpu', 'non deve attraversare la biglia nei colpi radenti');
near(p.ghost.x, 100 - Math.sqrt((2 * R) ** 2 - offset ** 2));
near(p.ghost.y, 50 + offset);
const speed = Math.hypot(c.balls.cpu.vx, c.balls.cpu.vy);
near(c.balls.cpu.vx / speed, p.dir[0]); near(c.balls.cpu.vy / speed, p.dir[1]);
const target = p.outgoing.find(t => t.role === 'cpu').points;
near((target[1][0] - target[0][0]) / Math.hypot(target[1][0] - target[0][0], target[1][1] - target[0][1]), p.dir[0]);
}
});
test('gli urti conservano la quantità di moto e non creano energia; le biglie che si separano non rimbalzano di nuovo', () => {
const a = { x: 40, y: 50, vx: 300, vy: 80 }, b = { x: 40 + 2 * R, y: 50, vx: -20, vy: -15 };
const energy = a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2;
assert.ok(collide(a, b) > 0);
near(a.vx + b.vx, 280); near(a.vy + b.vy, 65);
assert.ok(a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2 < energy);
const after = JSON.stringify([a, b]);
assert.equal(collide(a, b), 0);
assert.equal(JSON.stringify([a, b]), after);
});
test('la mira è bloccata durante la carica, il tocco breve ripristina la forza e le frecce consentono ritocchi fini', () => {
const c = newBiliardo(1), angle = c.angle, power = c.power;
press(c);
assert.equal(aimAt(c, 100, 100), false);
assert.equal(nudge(c, 1), false);
update(c, 0.05); release(c);
assert.equal(c.step, 'aim'); assert.equal(c.angle, angle); assert.equal(c.power, power);
nudge(c, 1); near(c.angle - angle, 0.002);
nudge(c, -1, true); near(c.angle - angle, 0.0015);
const fixed = c.angle;
assert.equal(aimScreen(c, [320, 30]), false, 'il pannello superiore non è una superficie di mira');
assert.equal(c.angle, fixed);
press(c); update(c, 0.2); cancel(c);
assert.equal(c.power, power);
press(c); update(c, 0.2); release(c);
near(Math.atan2(c.balls.player.vy, c.balls.player.vx), fixed);
});
test('anteprima, CPU e tiro reale condividono ordine degli urti e punteggio', () => {
for (const [angle, power] of [[0, 1], [0.15, 0.5], [-0.6, 0.8], [2.4, 0.6]]) {
const c = newBiliardo(1);
Object.assign(c.balls.player, { x: 100, y: 71 });
Object.assign(c.balls.cpu, { x: 126, y: 71 });
c.angle = angle; c.power = power;
const p = predict(c), expected = simulate(c, 'player', angle, power);
shoot(c); roll(c);
assert.deepEqual(judge(c.shot), expected);
if (p.hit === 'cpu' || p.hit === 'pallino') assert.equal(c.shot.first, p.hit);
if (angle === 0) assert.ok(c.shot.pins.every(pin => pin.by === 'cpu'));
}
});
test('la fisica resta identica a 30, 60, 144 fps e con fotogrammi irregolari', () => {
const states = [];
for (const frames of [[1 / 30], [1 / 60], [1 / 144], [0.008, 0.042, 0.016, 0.09]]) {
const c = newBiliardo(1);
c.angle += 0.02; c.power = 0.9;
shoot(c);
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, frames[i % frames.length]);
assert.equal(c.step, 'settle');
states.push({ balls: c.balls, score: judge(c.shot) });
}
for (const s of states.slice(1)) assert.deepEqual(s, states[0]);
});
test('mirare: un tocco sullo schermo gira la stecca verso quel punto; la previsione trova la biglia davanti', () => {
const c = newBiliardo(1);
const me = c.balls.player;
+1 -1
View File
@@ -407,7 +407,7 @@ try {
await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click();
await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await b.locator('#scene').boundingBox();
const [tx, ty] = await b.evaluate(() => { const c = game.closeup, V = c.balls.cpu; return [57 + V.x * 1.85, 78 + V.y * 1.85]; });
const [tx, ty] = await b.evaluate(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; });
await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height);
await b.mouse.down();
await b.waitForTimeout(500);