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
+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;
}
});