Biliardo più bello (Codex): tavolo in pixel art visto dall'alto con cornice a fasce e diamanti, sala in penombra e luce delle lampade sul panno, segnapunti a cifre rosse, steccata e birilli che cadono animati, biglie in pixel, l'Ing. Tozzi a bordo tavolo che dà il gesso, esulta o allarga le braccia. Più: scritte a 8 punti (a 6 la B sembrava una D) e la stecca che torna a seguire il mouse finché un clic non fissa la mira (Andrea: «non riesco più a girare la stecca»)

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:36:23 +01:00
co-authored by Claude Opus 5.5
parent 9eabd5c6e1
commit bd66ea45ad
9 changed files with 432 additions and 84 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+34
View File
@@ -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.
+187 -76
View File
@@ -1,56 +1,112 @@
import { LEN, WID, R, VIEW, canThrow, predict, status } from './biliardo.js'; import { LEN, WID, R, VIEW, canThrow, predict, status } from './biliardo.js';
import { text, panel } from './bocce-render.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 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; 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. // Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
const rr = R * VIEW.k; const rr = R * VIEW.k;
function sphere(ctx, role, x, y, r) { // La grafica resta indietro di 180 ms: la stecca arriva alla biglia prima del moto
const [dark, mid, light] = BALL[role]; // visibile, mentre la simulazione e i comandi continuano senza alcun ritardo.
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(); export const SHOT_DELAY = 0.18;
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05); export function visualFrame(c) {
g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark); const v = c.visual;
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); if (!v?.shot || !['rolling', 'settle'].includes(c.step)) return { balls: c.balls, pins: c.pins, time: v?.time ?? 0 };
ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1); const time = Math.max(v.shot.at, v.time - SHOT_DELAY), frames = v.frames;
} let before = frames[0], after = before;
function pin(ctx, p) { for (const frame of frames) {
const x = sx(p.x), y = sy(p.y), h = 7, w = 3; if (frame.at <= time) before = frame;
const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4']; after = frame;
if (p.down) { if (frame.at >= time) break;
// 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;
} }
ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2); const mix = after.at > before.at ? (time - before.at) / (after.at - before.at) : 0;
ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1); const balls = Object.fromEntries(before.balls.map((b, i) => [b.role, {
ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3); ...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) { // Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k; // esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
// Cornice di legno con i diamanti, sponda, panno. export function tablePatches() {
ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32); const xs = [0, 108, 190, 1484, 1564, 1672], ys = [0, 134, 214, 722, 813, 941];
ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26); const xd = [0, VIEW.x - 16, VIEW.x, sx(LEN), sx(LEN) + 16, 640];
ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3); const yd = [0, VIEW.y - 16, VIEW.y, sy(WID), sy(WID) + 16, 360];
ctx.fillStyle = '#e7d7a8'; return ys.slice(1).flatMap((_, j) => xs.slice(1).map((_, i) => [
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); } xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j],
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); } xd[i], yd[j], xd[i + 1] - xd[i], yd[j + 1] - yd[j],
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'; // Ombra rivolta verso l'esterno rispetto alla lampada centrale.
ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1); 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. // Sagoma a scalini, tre tinte e riflesso quadrato; r resta quello fisico.
ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h); 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) { 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'; 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.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])); 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); 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(); 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) { function cue(ctx, x, y, angle, back, alpha = 1) {
ctx.save(); ctx.save(); ctx.globalAlpha = alpha;
// La sala, in penombra, e il lume sopra il tavolo. ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420); ctx.fillStyle = '#c79a59'; ctx.fillRect(-back - 148, -1, 145, 2);
lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)'); ctx.fillStyle = '#81502a'; ctx.fillRect(-back - 148, -1, 62, 3);
ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#f2d59a'; ctx.fillRect(-back - 75, -1, 70, 1);
table(ctx); ctx.fillStyle = '#b8d3cf'; ctx.fillRect(-back - 5, -1, 3, 3);
for (const p of c.pins) pin(ctx, p); ctx.fillStyle = '#568f9c'; ctx.fillRect(-back - 2, -1, 2, 3);
if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c); ctx.restore();
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); export function tozziPose(c) {
for (const [i, who] of ['player', 'cpu'].entries()) { const last = c.last;
const x = 30 + i * 420, y = 24; if (last && last.age < 2.4) {
sphere(ctx, who, x + 4, y, 4); if (last.shooter === 'cpu' && (last.foul || !last.points)) return 'shrug';
text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]); if (last.to === 'cpu' && last.points > 0) return 'cheer';
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');
} }
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 hint = canThrow(c) ? predict(c).hit : null;
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).' const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).' : hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).'
: 'Clic fissa la mira. Azzurro: avversaria; giallo: la tua dopo l’urto.'; : c.aimLocked ? 'Mira fissata: un altro clic la sposta.' : 'La stecca segue il mouse: clic per fissare la mira.';
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'); ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15);
if (canThrow(c)) { // Il carattere pixel è disegnato per gli 8 punti: a 6 la B si confonde con la D. Le righe lunghe restano a 6.
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 320, 25, 7, '#f4e3c2', 'center'); const say = c.step === 'aim' ? c.current === 'player' ? instruction : `${def.names.cpu} studia il colpo…` : status(c, def.names);
ctx.fillStyle = '#3e3e36'; ctx.fillRect(245, 36, 150, 5); text(ctx, say, 320, 60, say.length * 8 <= 600 ? 8 : 6, hint === 'pin' || hint === 'pallino' ? '#ffae8b' : '#d7ccb4', 'center');
ctx.fillStyle = '#b9d392'; ctx.fillRect(245, 36, Math.round(c.power * 150), 5);
}
if (c.step === 'result') { if (c.step === 'result') {
panel(ctx, 170, 140, 300, 80); panel(ctx, 170, 140, 300, 80);
text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center'); 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, 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(); ctx.restore();
} }
+11
View File
@@ -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 }; 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.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(); 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); c.angle = angleTo(c.balls.player, c.balls.cpu);
return c; return c;
} }
@@ -158,6 +160,8 @@ function step(w, dt) {
function strike(c, who, angle, power) { function strike(c, who, angle, power) {
const b = c.balls[who], v = speedFor(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; b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
c.shot = { shooter: who, first: null, pallino: false, pins: [] }; 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; 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. // I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta.
function reset(c) { 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 (const p of c.pins) { p.down = false; delete p.by; delete p.dir; }
for (let pass = 0; pass < 4; pass++) { for (let pass = 0; pass < 4; pass++) {
for (const b of list(c)) for (const p of c.pins) { 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) { export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return; if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; dt = Math.min(dt, 0.25); c.t += dt;
c.visual.time += dt;
if (c.last) c.last.age += dt; if (c.last) c.last.age += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.step === 'rolling') { 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_THINK) cpuThink(c);
if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power); 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. // --- Comandi del giocatore: gli stessi delle bocce.
+5 -1
View File
@@ -676,7 +676,11 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time); drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return; 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.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`; ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.fillRect(0, 0, WIDTH, HEIGHT);
+11 -6
View File
@@ -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. // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { if (game.closeup) {
const aimed = game.closeupAim(scenePoint(e)); 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 }; press = { closeup: true };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress(); if (!game.skip()) game.closeupPress();
@@ -432,10 +435,11 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
// Al biliardo il clic fissa la mira: attraversare il tavolo per raggiungere «Tira» // Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo,
// o sfiorare il mouse dopo una regolazione fine non deve cambiare il colpo. // 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'; 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; }); 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-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); }); $('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]]) 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()); addEventListener('blur', () => game?.closeupCancel());
// --- Verbi. // --- Verbi.
@@ -543,7 +547,7 @@ window.addEventListener('keydown', e => {
const c = game.closeup, m = mini(); const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault(); 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)); else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
return; return;
} }
@@ -638,6 +642,7 @@ function updateUi() {
$('bocce-help').hidden = def.type !== 'bocce'; $('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo'; $('biliardo-help').hidden = def.type !== 'biliardo';
for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power }); 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. // 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; $('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
+1
View File
@@ -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] // 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: { biliardo: {
type: 'biliardo', type: 'biliardo',
picture: 'assets/closeups/biliardo-tavolo.png',
names: { player: 'Andrea', cpu: 'L\'Ing. Tozzi' }, names: { player: 'Andrea', cpu: 'L\'Ing. Tozzi' },
win: [ win: [
{ say: 'Venti. Hai vinto te, stasera il caffè lo pago io.', who: 'ing_tozzi' }, { say: 'Venti. Hai vinto te, stasera il caffè lo pago io.', who: 'ing_tozzi' },
+118
View File
@@ -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;
}
});
+65 -1
View File
@@ -1,7 +1,7 @@
// Regressioni della mira con veri eventi browser, su desktop e touch. // Regressioni della mira con veri eventi browser, su desktop e touch.
// npm run browser:biliardo — schermate in /tmp/regolo-biliardo. // npm run browser:biliardo — schermate in /tmp/regolo-biliardo.
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
@@ -10,6 +10,59 @@ mkdirSync(out, { recursive: true });
const server = await staticServer(); const server = await staticServer();
let browser; let browser;
const errors = []; 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 { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [ 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); game.choose(game.options().findIndex(o => o.text.includes('una partita ai birilli'))); settle(game);
}); });
await page.waitForSelector('#bocce-controls:not([hidden])'); 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(); const canvas = await page.locator('#scene').boundingBox();
for (const id of ['#sound', '#corner']) { for (const id of ['#sound', '#corner']) {
const box = await page.locator(id).boundingBox(); 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 }; return { x: r.x + x / 640 * r.width, y: r.y + y / 360 * r.height, r };
}; };
const target = await position(); 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); if (mobile) await page.touchscreen.tap(target.x, target.y);
else await page.mouse.click(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()).step, 'aim', 'un tocco sceglie la mira senza tirare');
@@ -72,6 +135,7 @@ try {
await page.locator('#bocce-power').fill('60'); await page.locator('#bocce-power').fill('60');
const prepared = await state(); const prepared = await state();
await page.screenshot({ path: `${out}/${name}-mira.png` }); await page.screenshot({ path: `${out}/${name}-mira.png` });
await checkArtwork(page, name);
const expected = await page.evaluate(async () => { const expected = await page.evaluate(async () => {
const { simulate } = await import('/src/engine/biliardo.js'); const { simulate } = await import('/src/engine/biliardo.js');
const c = game.closeup; const c = game.closeup;