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:
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+11
-6
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user