From 9eabd5c6e1c23c451778e6edbf98f6d576b53f9a Mon Sep 17 00:00:00 2001
From: Andrea Manzi
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.
+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.
+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.
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.
diff --git a/package.json b/package.json index 88c773f..0387a84 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/engine/biliardo-render.js b/src/engine/biliardo-render.js index a5180cb..48145f8 100644 --- a/src/engine/biliardo-render.js +++ b/src/engine/biliardo-render.js @@ -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); diff --git a/src/engine/biliardo.js b/src/engine/biliardo.js index 1d91030..d7b0bf5 100644 --- a/src/engine/biliardo.js +++ b/src/engine/biliardo.js @@ -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; } +// 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) 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) { - let hit = 0, rail = 0; - for (const b of w.balls) { - 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); + // 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); + } + if (result.hit ? tracks.every(t => t.done) : !me.vx && !me.vy) break; } - points.push([x, y]); - return { points, ghost: null, hit: null, dir: null }; + 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' }; diff --git a/src/main.js b/src/main.js index 8992253..dab3ec0 100644 --- a/src/main.js +++ b/src/main.js @@ -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; diff --git a/style.css b/style.css index 6a96512..0f353fc 100644 --- a/style.css +++ b/style.css @@ -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. */ diff --git a/tests/biliardo.browser.mjs b/tests/biliardo.browser.mjs new file mode 100644 index 0000000..71ecd11 --- /dev/null +++ b/tests/biliardo.browser.mjs @@ -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(); +} diff --git a/tests/biliardo.test.mjs b/tests/biliardo.test.mjs index c11061c..ee8a779 100644 --- a/tests/biliardo.test.mjs +++ b/tests/biliardo.test.mjs @@ -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; diff --git a/tests/browser.mjs b/tests/browser.mjs index 07e2dc2..0f54e87 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -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);