diff --git a/assets/closeups/biliardo-tavolo.png b/assets/closeups/biliardo-tavolo.png new file mode 100644 index 0000000..148005f Binary files /dev/null and b/assets/closeups/biliardo-tavolo.png differ diff --git a/docs/BILIARDO_GRAFICA.md b/docs/BILIARDO_GRAFICA.md new file mode 100644 index 0000000..b2fa090 --- /dev/null +++ b/docs/BILIARDO_GRAFICA.md @@ -0,0 +1,34 @@ +# Primo piano del biliardo + +Il tavolo in `assets/closeups/biliardo-tavolo.png` è generato con lo strumento immagini integrato +di Codex, senza API. Prompt completo in `prompts/closeups/biliardo-tavolo.integrated.txt` +(cartella esclusa da git). Specifica: vista ortogonale dall'alto, pixel art da avventura anni '90, +mogano scuro a fasce chiare e scure, diamanti d'ottone, panno verde, sala in penombra; +senza buche, biglie, birilli, persone, stecche o testi. Riferimento di stile: `circolo-4-maggio.png`. +L'Ingegnere usa il foglio esistente `ing-tozzi.json`, coerente con la fotografia di riferimento. + +Il master misura 1672×941. `tablePatches()` in `biliardo-render.js` mappa il panno sorgente +(190, 214)–(1484, 722) su `(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k)`; i ritagli +esterni mantengono una cornice larga 16 pixel. La geometria della simulazione resta invariata. +Il caricamento passa da `story.closeups.biliardo.picture`, con la stessa cache delle bocce. + +La luce calda è disegnata sul panno con una piccola tela memorizzata, ingrandita senza smoothing. +Biglie e birilli usano blocchi di pixel; le ombre delle biglie si allontanano dalla lampada centrale. +I segnapunti sono cifre rosse a sette segmenti. Le quattro pose di Tozzi rimangono nel margine sinistro. + +`biliardo.js` aggiunge soltanto stato visivo: orologio, posizione iniziale della steccata, +breve cronologia delle posizioni e istante della caduta dei birilli. La presentazione del moto +è ritardata di 180 ms, per mostrare arretramento e scatto della stecca; la fisica parte subito. +La caduta dei birilli dura 240 ms sul medesimo orologio visivo. Previsione e CPU non scrivono +questa cronologia. Un confronto con la versione precedente ha verificato 6626 aggiornamenti +identici, escludendo i soli campi visivi. + +Controlli: `tests/biliardo-render.test.mjs` verifica allineamento, presentazione, cadute, +pose e immutabilità del disegno. `npm run browser:biliardo` conserva i controlli dei comandi +su desktop, touch e telefono verticale e aggiunge controlli dei pixel e catture delle animazioni +in `/tmp/regolo-biliardo`. + +Verifica di questa modifica: test JavaScript e Python superati; anteprima locale del renderer +ispezionata. Restano da completare `make -s verify` e `npm run browser:biliardo` in un ambiente +che permetta server locale e Chromium: nella sandbox della sessione entrambi sono bloccati +da `Operation not permitted`. Le vecchie schermate browser non validano questa modifica. diff --git a/src/engine/biliardo-render.js b/src/engine/biliardo-render.js index 48145f8..3107c2b 100644 --- a/src/engine/biliardo-render.js +++ b/src/engine/biliardo-render.js @@ -1,56 +1,112 @@ 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; // 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]; - ctx.fillStyle = 'rgba(0,20,8,0.45)'; ctx.beginPath(); ctx.ellipse(x + r * 0.35, y + r * 0.45, r * 1.05, r * 0.8, 0, 0, Math.PI * 2); ctx.fill(); - const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05); - g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark); - ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1); -} -function pin(ctx, p) { - const x = sx(p.x), y = sy(p.y), h = 7, w = 3; - const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4']; - if (p.down) { - // Birillo a terra, dalla parte in cui è caduto. - ctx.strokeStyle = top; ctx.lineWidth = 2; - ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(p.dir) * h, y + Math.sin(p.dir) * h); ctx.stroke(); - ctx.fillStyle = base; ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2); - return; +// La grafica resta indietro di 180 ms: la stecca arriva alla biglia prima del moto +// visibile, mentre la simulazione e i comandi continuano senza alcun ritardo. +export const SHOT_DELAY = 0.18; +export function visualFrame(c) { + const v = c.visual; + if (!v?.shot || !['rolling', 'settle'].includes(c.step)) return { balls: c.balls, pins: c.pins, time: v?.time ?? 0 }; + const time = Math.max(v.shot.at, v.time - SHOT_DELAY), frames = v.frames; + let before = frames[0], after = before; + for (const frame of frames) { + if (frame.at <= time) before = frame; + after = frame; + if (frame.at >= time) break; } - ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2); - ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1); - ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3); + const mix = after.at > before.at ? (time - before.at) / (after.at - before.at) : 0; + const balls = Object.fromEntries(before.balls.map((b, i) => [b.role, { + ...b, x: b.x + (after.balls[i].x - b.x) * mix, y: b.y + (after.balls[i].y - b.y) * mix, + }])); + return { balls, pins: before.pins, time }; } -function table(ctx) { - const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k; - // Cornice di legno con i diamanti, sponda, panno. - ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32); - ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26); - ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3); - ctx.fillStyle = '#e7d7a8'; - for (let i = 1; i < 8; i++) { const dx = x0 + w * i / 8; ctx.fillRect(Math.round(dx) - 1, y0 - 9, 2, 2); ctx.fillRect(Math.round(dx) - 1, y0 + h + 7, 2, 2); } - for (let i = 1; i < 4; i++) { const dy = y0 + h * i / 4; ctx.fillRect(x0 - 9, Math.round(dy) - 1, 2, 2); ctx.fillRect(x0 + w + 7, Math.round(dy) - 1, 2, 2); } - ctx.fillStyle = '#1d5a33'; ctx.fillRect(x0 - 5, y0 - 5, w + 10, h + 10); - ctx.fillStyle = '#2a7a44'; ctx.fillRect(x0, y0, w, h); - for (let i = 0; i < 420; i++) { - ctx.fillStyle = i % 2 ? '#2f8349' : '#26713f'; - ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1); +// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato +// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco. +export function tablePatches() { + const xs = [0, 108, 190, 1484, 1564, 1672], ys = [0, 134, 214, 722, 813, 941]; + const xd = [0, VIEW.x - 16, VIEW.x, sx(LEN), sx(LEN) + 16, 640]; + const yd = [0, VIEW.y - 16, VIEW.y, sy(WID), sy(WID) + 16, 360]; + return ys.slice(1).flatMap((_, j) => xs.slice(1).map((_, i) => [ + xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j], + xd[i], yd[j], xd[i + 1] - xd[i], yd[j + 1] - yd[j], + ])); +} + +// Ombra rivolta verso l'esterno rispetto alla lampada centrale. +function sphere(ctx, role, x, y, r, shadow = true) { + const [dark, mid, light] = BALL[role]; + const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6; + const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6; + if (shadow) { + const dx = (x - sx(LEN / 2)) / (LEN * VIEW.k / 2) * 3; + const dy = (y - sy(WID / 2)) / (WID * VIEW.k / 2) * 3 + 1; + ctx.fillStyle = '#103b2bcc'; + ctx.fillRect(Math.round(x - r + dx), Math.round(y - r / 2 + dy), Math.round(r * 2), Math.round(r)); } - // La linea di metà tavolo e il punto del castello. - ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h); + // Sagoma a scalini, tre tinte e riflesso quadrato; r resta quello fisico. + const left = Math.floor(x - r), top = Math.floor(y - r); + for (let py = top; py < y + r; py++) for (let px = left; px < x + r; px++) { + const dx = px + 0.5 - x, dy = py + 0.5 - y, d = Math.hypot(dx, dy); + if (d > r) continue; + ctx.fillStyle = d > r - 1 ? '#302a1e' : dx * lx + dy * ly < -r * 0.2 ? dark : mid; + ctx.fillRect(px, py, 1, 1); + } + ctx.fillStyle = light; ctx.fillRect(Math.round(x + lx * r * 0.4) - 1, Math.round(y + ly * r * 0.4) - 1, 2, 2); +} + +export function pinFall(p, time) { + if (!p.down) return 0; + if (p.visualDownAt === undefined) return 1; + return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24)); +} +function pin(ctx, p, time) { + const x = sx(p.x), y = sy(p.y), fall = pinFall(p, time); + const tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2); + const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt; + const ty = y + Math.sin(p.dir ?? 0) * 7 * tilt - 7 * lift; + const [base, top] = p.red ? ['#8d1f1a', '#ff7060'] : ['#b3a57f', '#fff8e4']; + ctx.fillStyle = '#123c2bb0'; ctx.fillRect(Math.round(x - 1), Math.round(y + 1), 5 + Math.round(tilt * 3), 2); + // Piccoli blocchi seguono la rotazione senza linee levigate. + for (let i = 0; i <= 7; i++) { + ctx.fillStyle = i > 4 ? top : base; + ctx.fillRect(Math.round(x + (tx - x) * i / 7) - 1, Math.round(y + (ty - y) * i / 7) - 1, i > 4 ? 3 : 2, 2); + } +} + +let lighting; +function table(ctx, bg) { + if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch); + else { + ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360); + ctx.fillStyle = '#51321c'; ctx.fillRect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32); + ctx.fillStyle = '#14674d'; ctx.fillRect(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k); + } + // La sala scompare in penombra, ma si riconoscono pavimento e boiserie. + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, 640, 360); + ctx.rect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32); + ctx.fillStyle = '#100f10bb'; ctx.fill('evenodd'); ctx.restore(); + if (!lighting) { + lighting = document.createElement('canvas'); lighting.width = 125; lighting.height = 63; + const light = lighting.getContext('2d'); + for (let y = 0; y < 63; y++) for (let x = 0; x < 125; x++) { + const d = Math.hypot((x - 62) / 69, (y - 31) / 38); + const warm = Math.max(0, 1 - d) * 0.22; + const shade = Math.max(0, d - 0.35) * 0.28; + light.fillStyle = `rgba(255,215,128,${Math.round(warm * 32) / 32})`; light.fillRect(x, y, 1, 1); + light.fillStyle = `rgba(4,25,20,${Math.round(shade * 32) / 32})`; light.fillRect(x, y, 1, 1); + } + } + ctx.drawImage(lighting, VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k); } function aimGuide(ctx, c) { - const p = predict(c), me = c.balls[c.current]; + const p = predict(c); 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])); @@ -74,54 +130,109 @@ function aimGuide(ctx, c) { 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; - const bx = sx(me.x) - Math.cos(a) * back, by = sy(me.y) - Math.sin(a) * back; - ctx.lineCap = 'round'; - ctx.strokeStyle = '#2b1a10'; ctx.lineWidth = 5; - ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke(); - ctx.strokeStyle = '#c99a5b'; ctx.lineWidth = 3; - ctx.beginPath(); ctx.moveTo(bx - Math.cos(a) * 6, by - Math.sin(a) * 6); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke(); - ctx.strokeStyle = '#4fb0ff'; ctx.lineWidth = 3; - ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * 5, by - Math.sin(a) * 5); ctx.stroke(); - ctx.lineCap = 'butt'; } -export function drawBiliardo(ctx, c, def) { - ctx.save(); - // La sala, in penombra, e il lume sopra il tavolo. - ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); - const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420); - lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)'); - ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360); - table(ctx); - for (const p of c.pins) pin(ctx, p); - if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c); - for (const role of ['pallino', 'cpu', 'player']) { const b = c.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); } - // 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 * 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'); +function cue(ctx, x, y, angle, back, alpha = 1) { + ctx.save(); ctx.globalAlpha = alpha; + ctx.translate(sx(x), sy(y)); ctx.rotate(angle); + ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5); + ctx.fillStyle = '#c79a59'; ctx.fillRect(-back - 148, -1, 145, 2); + ctx.fillStyle = '#81502a'; ctx.fillRect(-back - 148, -1, 62, 3); + ctx.fillStyle = '#f2d59a'; ctx.fillRect(-back - 75, -1, 70, 1); + ctx.fillStyle = '#b8d3cf'; ctx.fillRect(-back - 5, -1, 3, 3); + ctx.fillStyle = '#568f9c'; ctx.fillRect(-back - 2, -1, 2, 3); + ctx.restore(); +} + +export function tozziPose(c) { + const last = c.last; + if (last && last.age < 2.4) { + if (last.shooter === 'cpu' && (last.foul || !last.points)) return 'shrug'; + if (last.to === 'cpu' && last.points > 0) return 'cheer'; } + return c.step === 'aim' && c.current === 'cpu' ? 'chalk' : 'stand'; +} +export const TOZZI = { x: 26, y: 258, scale: 0.46 }; +function tozzi(ctx, c, sheet) { + if (!sheet) return; + const pose = tozziPose(c), frame = sheet.atlas.frames[pose]; + const { x, y, scale } = TOZZI; + const bob = pose === 'chalk' ? Math.floor(Math.sin(c.visual.time * 9) * 0.8) : 0; + ctx.fillStyle = '#080a09aa'; ctx.fillRect(10, y - 1, 33, 4); + ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h, + Math.round(x - frame.ax * scale), Math.round(y - frame.ay * scale) + bob, + Math.round(frame.w * scale), Math.round(frame.h * scale)); +} + +const SEGMENTS = [0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f]; +function digits(ctx, value, x, y) { + const segments = [[2, 0, 7, 2], [9, 2, 2, 6], [9, 10, 2, 6], [2, 16, 7, 2], + [0, 10, 2, 6], [0, 2, 2, 6], [2, 8, 7, 2]]; + for (const [i, digit] of String(value).padStart(2, '0').split('').entries()) { + segments.forEach(([dx, dy, w, h], bit) => { + ctx.fillStyle = SEGMENTS[Number(digit)] & (1 << bit) ? '#ff5a3d' : '#381b17'; + ctx.fillRect(x + i * 15 + dx, y + dy, w, h); + }); + } +} +function scoreboard(ctx, c, def) { + for (const [i, who] of ['player', 'cpu'].entries()) { + const x = 70 + i * 352, active = c.step === 'aim' && c.current === who; + ctx.fillStyle = '#0e0e0c'; ctx.fillRect(x - 2, 7, 152, 43); + ctx.fillStyle = '#705039'; ctx.fillRect(x, 7, 148, 1); + ctx.fillStyle = '#36281d'; ctx.fillRect(x, 49, 148, 2); + ctx.fillStyle = '#b09359'; ctx.fillRect(x + 3, 10, 1, 1); ctx.fillRect(x + 144, 46, 1, 1); + text(ctx, def.names[who], x + 10, 20, 8, '#ddcfad'); + sphere(ctx, who, x + 13, 36, 3, false); + text(ctx, active ? 'AL TIRO' : 'PUNTI', x + 24, 36, 8, active ? '#eac27d' : '#8e8064'); + digits(ctx, c.points[who], x + 105, 22); + } + text(ctx, 'CINQUE BIRILLI', 320, 15, 8, '#cab58c', 'center'); + if (canThrow(c)) { + text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 320, 29, 8, '#eee0ba', 'center'); + ctx.fillStyle = '#151c16'; ctx.fillRect(245, 39, 150, 5); + for (let i = 0; i < 30; i++) { + ctx.fillStyle = i / 30 < c.power ? i > 23 ? '#c19554' : '#9fbc82' : '#303b2d'; + ctx.fillRect(246 + i * 5, 40, 3, 3); + } + } else text(ctx, `A ${c.target} PUNTI`, 320, 33, 8, '#b0a080', 'center'); +} + +export function drawBiliardo(ctx, c, def, bg, sheet) { + ctx.save(); ctx.imageSmoothingEnabled = false; + table(ctx, bg); + tozzi(ctx, c, sheet); + const view = visualFrame(c); + ctx.save(); ctx.beginPath(); ctx.rect(VIEW.x - 15, VIEW.y - 15, LEN * VIEW.k + 30, WID * VIEW.k + 30); ctx.clip(); + if (c.step === 'aim' && (c.current === 'player' || c.plan)) { + aimGuide(ctx, c); + const me = c.balls[c.current]; + cue(ctx, me.x, me.y, c.angle, rr + 6 + c.power * 26); + } else if (c.visual.shot) { + const shot = c.visual.shot, age = c.visual.time - shot.at; + if (age < SHOT_DELAY + 0.12) { + const pull = 6 + shot.power * 26; + const back = age < 0.12 ? pull + age / 0.12 * 10 : Math.max(0, (pull + 10) * (1 - (age - 0.12) / 0.06)); + cue(ctx, shot.x, shot.y, shot.angle, rr + back, age < SHOT_DELAY ? 1 : 1 - (age - SHOT_DELAY) / 0.12); + } + } + for (const p of view.pins) pin(ctx, p, view.time); + for (const role of ['pallino', 'cpu', 'player']) { const b = view.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); } + 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).' : 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)) { - 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); - } + : 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); + // Il carattere pixel è disegnato per gli 8 punti: a 6 la B si confonde con la D. Le righe lunghe restano a 6. + const say = c.step === 'aim' ? c.current === 'player' ? instruction : `${def.names.cpu} studia il colpo…` : status(c, def.names); + text(ctx, say, 320, 60, say.length * 8 <= 600 ? 8 : 6, hint === 'pin' || hint === 'pallino' ? '#ffae8b' : '#d7ccb4', 'center'); if (c.step === 'result') { panel(ctx, 170, 140, 300, 80); text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center'); text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center'); - text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 6, '#d7ccb4', 'center'); + text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 8, '#d7ccb4', 'center'); } ctx.restore(); } diff --git a/src/engine/biliardo.js b/src/engine/biliardo.js index d7b0bf5..2b3e073 100644 --- a/src/engine/biliardo.js +++ b/src/engine/biliardo.js @@ -41,6 +41,8 @@ export function newBiliardo(seed = Date.now() >>> 0, def = {}) { holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null }; c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) }; c.pins = castle(); + // Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU. + c.visual = { time: 0, shot: null, frames: [] }; c.angle = angleTo(c.balls.player, c.balls.cpu); return c; } @@ -158,6 +160,8 @@ function step(w, dt) { function strike(c, who, angle, power) { const b = c.balls[who], v = speedFor(power); + c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y }; + c.visual.frames = [{ at: c.visual.time, balls: list(c).map(b => ({ ...b })), pins: c.pins.map(p => ({ ...p })) }]; b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v; c.shot = { shooter: who, first: null, pallino: false, pins: [] }; c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null; @@ -176,6 +180,7 @@ export function simulate(c, who, angle, power) { // I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta. function reset(c) { + for (const p of c.pins) delete p.visualDownAt; for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; } for (let pass = 0; pass < 4; pass++) { for (const b of list(c)) for (const p of c.pins) { @@ -224,6 +229,7 @@ function cpuThink(c, budget = 12) { export function update(c, dt) { if (!Number.isFinite(dt) || dt <= 0) return; 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.step === 'rolling') { @@ -236,6 +242,11 @@ export function update(c, dt) { if (!c.plan && c.t >= CPU_THINK) cpuThink(c); if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power); } + if (c.step === 'rolling' || c.step === 'settle') { + for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time; + c.visual.frames.push({ at: c.visual.time, balls: list(c).map(b => ({ ...b })), pins: c.pins.map(p => ({ ...p })) }); + while (c.visual.frames.length > 2 && c.visual.frames[1].at < c.visual.time - 0.4) c.visual.frames.shift(); + } } // --- Comandi del giocatore: gli stessi delle bocce. diff --git a/src/engine/render.js b/src/engine/render.js index b72be1e..5011ee8 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -676,7 +676,11 @@ function drawCloseup(ctx, game, time) { drawBocce(ctx, c, def, backgrounds.get(def.picture), time); return; } - if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); return; } + if (def.type === 'biliardo') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi')); + return; + } ctx.save(); ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`; ctx.fillRect(0, 0, WIDTH, HEIGHT); diff --git a/src/main.js b/src/main.js index dab3ec0..5f9c248 100644 --- a/src/main.js +++ b/src/main.js @@ -400,7 +400,10 @@ canvas.addEventListener('pointerdown', e => { // 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; + if (story.closeups[game.closeup.id].type === 'biliardo') { + if (!aimed && !game.speech) return; + if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira + } press = { closeup: true }; // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). if (!game.skip()) game.closeupPress(); @@ -432,10 +435,11 @@ canvas.addEventListener('contextmenu', e => { canvas.addEventListener('pointermove', e => { if (!running) return; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; - // 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. + // Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo, + // attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera. const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo'; - if (!billiards && (e.pointerType === 'mouse' || press?.closeup)) game.closeupAim(scenePoint(e)); + if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); } + else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e)); }); canvas.addEventListener('pointerleave', () => { mouse = null; }); @@ -450,7 +454,7 @@ const mini = () => MINI[story.closeups[game.closeup.id].type]; $('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower(c, Number(e.target.value) / 100); }); $('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot(c); }); for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) - $(id).addEventListener('click', () => { const c = miniState(); if (c) mini().nudge(c, dir); }); + $(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; }); addEventListener('blur', () => game?.closeupCancel()); // --- Verbi. @@ -543,7 +547,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, e.shiftKey); + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1, e.shiftKey)) c.aimLocked = true; } else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01)); return; } @@ -638,6 +642,7 @@ function updateUi() { $('bocce-help').hidden = def.type !== 'bocce'; $('biliardo-help').hidden = def.type !== 'biliardo'; for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power }); + if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera } // Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare. $('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2; diff --git a/src/story/story.js b/src/story/story.js index 40806cb..79e92d7 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -127,6 +127,7 @@ export const story = { // Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute] biliardo: { type: 'biliardo', + picture: 'assets/closeups/biliardo-tavolo.png', names: { player: 'Andrea', cpu: 'L\'Ing. Tozzi' }, win: [ { say: 'Venti. Hai vinto te, stasera il caffè lo pago io.', who: 'ing_tozzi' }, diff --git a/tests/biliardo-render.test.mjs b/tests/biliardo-render.test.mjs new file mode 100644 index 0000000..cdc39f1 --- /dev/null +++ b/tests/biliardo-render.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { newBiliardo, update, shoot, predict, simulate, judge, LEN, WID, VIEW, FIXED_STEP } from '../src/engine/biliardo.js'; +import { drawBiliardo, visualFrame, pinFall, tozziPose, tablePatches, TOZZI, SHOT_DELAY } from '../src/engine/biliardo-render.js'; +import { story } from '../src/story/story.js'; + +test('il panno generato coincide con VIEW, senza buchi fra i ritagli', () => { + const png = readFileSync(new URL('../' + story.closeups.biliardo.picture, import.meta.url)); + const patches = tablePatches(); + assert.equal(png.readUInt32BE(16), 1672); + assert.equal(png.readUInt32BE(20), 941); + for (const [i, expected] of [VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k].entries()) + assert.ok(Math.abs(patches[12][i + 4] - expected) < 1e-10); + let area = 0; + for (const [i, p] of patches.entries()) { + const [x, y, w, h, dx, dy, dw, dh] = p; + assert.ok(w > 0 && h > 0 && dw > 0 && dh > 0); + assert.ok(x >= 0 && y >= 0 && x + w <= 1672 && y + h <= 941); + assert.ok(dx >= 0 && dy >= 0 && dx + dw <= 640 && dy + dh <= 360); + if (i % 5 < 4) assert.equal(dx + dw, patches[i + 1][4]); + if (i < 20) assert.equal(dy + dh, patches[i + 5][5]); + area += dw * dh; + } + assert.ok(Math.abs(area - 640 * 360) < 1e-8); +}); + +test('la steccata trattiene solo il disegno: velocità immediata e traiettoria invariata', () => { + const c = newBiliardo(42), start = { ...c.balls.player }; + const expected = simulate(c, 'player', c.angle, c.power); + shoot(c); + assert.ok(Math.hypot(c.balls.player.vx, c.balls.player.vy) > 0); + update(c, SHOT_DELAY / 2); + assert.notEqual(c.balls.player.x, start.x, 'la fisica è già partita'); + assert.equal(visualFrame(c).balls.player.x, start.x, 'il disegno aspetta la punta della stecca'); + update(c, SHOT_DELAY); + const before = structuredClone(c); + const shown = visualFrame(c); + assert.ok(shown.balls.player.x > start.x); + assert.ok(shown.balls.player.x < c.balls.player.x); + assert.deepEqual(c, before, 'leggere il fotogramma non scrive nello stato'); + for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP); + assert.deepEqual(judge(c.shot), expected); + assert.ok(c.visual.frames.length < 110, 'la cronologia non cresce per tutta la partita'); + while (c.step === 'settle') update(c, FIXED_STEP); + assert.equal(visualFrame(c).balls, c.balls, 'al nuovo turno torna la posizione corrente'); +}); + +test('caduta progressiva dei birilli, senza alterare guida, punteggio o rialzata', () => { + const c = newBiliardo(9); + Object.assign(c.balls.player, { x: LEN / 2 - 60, y: WID / 2 }); + Object.assign(c.balls.cpu, { x: LEN / 2 - 30, y: WID / 2 }); + Object.assign(c.balls.pallino, { x: 20, y: 20 }); + c.angle = 0; c.power = 0.5; + const expected = simulate(c, 'player', c.angle, c.power), before = structuredClone(c); + predict(c); + assert.deepEqual(c, before, 'la previsione non fa partire animazioni'); + shoot(c); + for (let i = 0; i < 1000 && !c.pins.some(p => p.down); i++) update(c, FIXED_STEP); + const p = c.pins.find(p => p.down); + assert.ok(p && Number.isFinite(p.visualDownAt)); + assert.equal(pinFall(p, p.visualDownAt), 0); + assert.ok(pinFall(p, p.visualDownAt + 0.12) > 0 && pinFall(p, p.visualDownAt + 0.12) < 1); + assert.equal(pinFall(p, p.visualDownAt + 0.3), 1); + assert.equal(visualFrame(c).pins.some(p => p.down), false, 'anche la caduta segue il tempo delle biglie visibili'); + for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP); + assert.deepEqual(judge(c.shot), expected); + while (c.step === 'settle') update(c, FIXED_STEP); + assert.ok(c.pins.every(p => !p.down && p.visualDownAt === undefined)); +}); + +test('Tozzi pensa, esulta e sbaglia; tutte le pose restano fuori da tavolo e comandi', () => { + const c = newBiliardo(3); + assert.equal(tozziPose(c), 'stand'); + c.current = 'cpu'; assert.equal(tozziPose(c), 'chalk'); + for (const [last, pose] of [ + [{ shooter: 'cpu', to: 'cpu', points: 4, foul: null, age: 0 }, 'cheer'], + [{ shooter: 'cpu', to: 'player', points: 2, foul: 'miss', age: 0 }, 'shrug'], + [{ shooter: 'cpu', to: 'cpu', points: 0, foul: null, age: 0 }, 'shrug'], + [{ shooter: 'player', to: 'cpu', points: 2, foul: 'own', age: 0 }, 'cheer'], + ]) { c.last = last; assert.equal(tozziPose(c), pose); } + c.last.age = 3; assert.equal(tozziPose(c), 'chalk'); + const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url))); + for (const pose of ['stand', 'chalk', 'cheer', 'shrug']) { + const f = atlas.frames[pose], left = Math.round(TOZZI.x - f.ax * TOZZI.scale); + assert.ok(left >= 0 && left + Math.round(f.w * TOZZI.scale) < VIEW.x - 16, pose); + assert.ok(TOZZI.y - f.ay * TOZZI.scale > 72 && TOZZI.y < 360); + } +}); + +test('il disegno usa immagine e atlas, funziona durante il caricamento e non modifica la partita', () => { + const calls = [], ctx = new Proxy({}, { get: (o, key) => key in o ? o[key] : (...args) => calls.push([key, ...args]) }); + const previous = globalThis.document; + globalThis.document = { createElement: () => ({ getContext: () => ctx }) }; + const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url))); + const bg = {}, sheet = { atlas, img: {} }, c = newBiliardo(42); + try { + const render = (background, sprite) => { + const before = structuredClone(c); + drawBiliardo(ctx, c, story.closeups.biliardo, background, sprite); + assert.deepEqual(c, before); + }; + render(); // Il file può non essere ancora arrivato dalla rete. + calls.length = 0; + render(bg, sheet); + assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === bg)); + assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === sheet.img)); + assert.ok(!calls.some(([op]) => op === 'createRadialGradient'), 'biglie senza gradienti lisci'); + shoot(c); update(c, 0.1); render(bg, sheet); + update(c, 0.07); render(bg, sheet); + while (c.step === 'rolling') update(c, 1 / 60); + render(bg, sheet); + c.step = 'result'; c.result = { winner: 'cpu' }; render(bg, sheet); + } finally { + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + } +}); diff --git a/tests/biliardo.browser.mjs b/tests/biliardo.browser.mjs index 71ecd11..d503e34 100644 --- a/tests/biliardo.browser.mjs +++ b/tests/biliardo.browser.mjs @@ -1,7 +1,7 @@ // 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 { mkdirSync, writeFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { staticServer } from './helpers.mjs'; @@ -10,6 +10,59 @@ mkdirSync(out, { recursive: true }); const server = await staticServer(); let browser; const errors = []; + +// Fotogrammi ripetibili del renderer, oltre alle schermate dell'interfaccia reale. +async function checkArtwork(page, name) { + const captures = await page.evaluate(async () => { + const { story } = await import('/src/story/story.js'); + const { newBiliardo, shoot, update, LEN, WID, VIEW } = await import('/src/engine/biliardo.js'); + const { drawBiliardo } = await import('/src/engine/biliardo-render.js'); + const def = story.closeups.biliardo; + const load = async src => { const img = new Image(); img.src = src; await img.decode(); return img; }; + const bg = await load('/' + def.picture); + const atlas = await (await fetch('/assets/characters/ing-tozzi.json')).json(); + const sheet = { atlas, img: await load('/assets/characters/' + atlas.image) }; + const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 360; + const ctx = canvas.getContext('2d'), shots = [], used = []; + const drawImage = ctx.drawImage.bind(ctx); + ctx.drawImage = (image, ...args) => { used.push(image); drawImage(image, ...args); }; + const c = newBiliardo(42); + const draw = label => { + const before = JSON.stringify(c); + drawBiliardo(ctx, c, def, bg, sheet); + if (before !== JSON.stringify(c)) throw new Error('Il renderer modifica la partita'); + shots.push({ label, png: canvas.toDataURL('image/png').split(',')[1] }); + }; + c.step = 'settle'; draw('riposo'); + if (!used.includes(bg) || !used.includes(sheet.img)) throw new Error('Tavolo o Ingegnere non disegnati'); + const pixel = (x, y) => [...ctx.getImageData(x, y, 1, 1).data].slice(0, 3); + const bright = p => p.reduce((s, v) => s + v, 0); + if (bright(pixel(320, 180)) <= bright(pixel(85, 180))) throw new Error('Manca la luce centrale'); + if (pixel(177, 22).join() !== '255,90,61') throw new Error('Cifra rossa a sette segmenti assente'); + if (pixel(177, 30).join() !== '56,27,23') throw new Error('Lo zero accende il segmento centrale'); + const b = c.balls.player, bx = VIEW.x + b.x * VIEW.k, by = VIEW.y + b.y * VIEW.k; + const colors = new Set(); + for (let y = Math.ceil(by - 3); y <= by + 3; y++) for (let x = Math.ceil(bx - 3); x <= bx + 3; x++) colors.add(pixel(x, y).join()); + if (colors.size > 4 || !colors.has('255,253,244')) throw new Error('Biglia senza palette ridotta o riflesso quadrato'); + c.step = 'aim'; c.current = 'cpu'; draw('tozzi-chalk'); + c.last = { shooter: 'cpu', to: 'cpu', points: 4, foul: null, age: 0 }; c.points.cpu = 4; draw('tozzi-cheer'); + c.last = { shooter: 'cpu', to: 'player', points: 2, foul: 'miss', age: 0 }; draw('tozzi-shrug'); + c.last = null; c.current = 'player'; c.points.cpu = 0; + shoot(c); update(c, 0.1); draw('stecca-arretrata'); update(c, 0.07); draw('stecca-avanti'); update(c, 0.08); draw('biglie-partite'); + const pins = newBiliardo(9); + Object.assign(pins.balls.player, { x: LEN / 2 - 60, y: WID / 2 }); + Object.assign(pins.balls.cpu, { x: LEN / 2 - 30, y: WID / 2 }); + Object.assign(pins.balls.pallino, { x: 20, y: 20 }); + pins.angle = 0; pins.power = 0.5; shoot(pins); + for (let n = 0; n < 1000 && !pins.pins.some(p => p.down); n++) update(pins, 1 / 240); + update(pins, 0.25); update(pins, 0.04); + drawBiliardo(ctx, pins, def, bg, sheet); + shots.push({ label: 'birilli-caduta', png: canvas.toDataURL('image/png').split(',')[1] }); + return shots; + }); + for (const { label, png } of captures) writeFileSync(`${out}/${name}-${label}.png`, Buffer.from(png, 'base64')); +} + try { browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); for (const [name, mobile, viewport] of [ @@ -32,6 +85,10 @@ try { game.choose(game.options().findIndex(o => o.text.includes('una partita ai birilli'))); settle(game); }); await page.waitForSelector('#bocce-controls:not([hidden])'); + await page.waitForFunction(async () => { + const { hasBackground } = await import('/src/engine/render.js'); + return hasBackground('assets/closeups/biliardo-tavolo.png'); + }); const canvas = await page.locator('#scene').boundingBox(); for (const id of ['#sound', '#corner']) { const box = await page.locator(id).boundingBox(); @@ -55,6 +112,12 @@ try { return { x: r.x + x / 640 * r.width, y: r.y + y / 360 * r.height, r }; }; const target = await position(); + if (!mobile) { + // Prima del clic la stecca segue il mouse (Andrea, 25 ottobre: «non riesco più a girare la stecca»). + const before = (await state()).angle; + await page.mouse.move(target.r.x + target.r.width * 0.5, target.r.y + target.r.height * 0.85, { steps: 4 }); + assert.notEqual((await state()).angle, before, 'senza clic la stecca gira col mouse'); + } 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'); @@ -72,6 +135,7 @@ try { await page.locator('#bocce-power').fill('60'); const prepared = await state(); await page.screenshot({ path: `${out}/${name}-mira.png` }); + await checkArtwork(page, name); const expected = await page.evaluate(async () => { const { simulate } = await import('/src/engine/biliardo.js'); const c = game.closeup;