Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
192 lines
12 KiB
JavaScript
192 lines
12 KiB
JavaScript
// 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, writeFileSync } from 'node:fs';
|
|
import assert from 'node:assert/strict';
|
|
import { staticServer } from './helpers.mjs';
|
|
|
|
const out = process.env.OUT ?? '/tmp/regolo-biliardo';
|
|
mkdirSync(out, { recursive: true });
|
|
const server = await staticServer();
|
|
let browser;
|
|
const errors = [];
|
|
|
|
// 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 [
|
|
['desktop', false, { width: 1280, height: 860 }],
|
|
['touch', true, { width: 844, height: 390 }],
|
|
['portrait', true, { width: 390, height: 844 }],
|
|
]) {
|
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
await page.goto(`http://127.0.0.1:${server.port}/`);
|
|
await page.click('#splash');
|
|
await page.click('#new-game');
|
|
await page.click('#rione [data-rione="campomigliaio"]');
|
|
await page.click('#skip-intro');
|
|
await page.evaluate(async () => {
|
|
const { settle } = await import('/tests/helpers.mjs');
|
|
settle(game);
|
|
game.go('circolo_4_maggio'); settle(game);
|
|
game.act('talk', 'ing_tozzi'); settle(game);
|
|
game.choose(game.options().findIndex(o => o.text.includes('una partita ai birilli'))); settle(game);
|
|
game.choose(game.options().findIndex(o => o.text === 'Seria.')); 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();
|
|
assert.ok(box.y + box.height <= canvas.y, `${id} non deve coprire il punteggio`);
|
|
}
|
|
const exit = await page.locator('#closeup-close').boundingBox();
|
|
assert.ok(exit.y >= canvas.y + canvas.height, 'uscita fuori dal tavolo');
|
|
await page.waitForTimeout(1600); // completa la dissolvenza della sala prima delle schermate
|
|
const reset = () => page.evaluate(async () => {
|
|
const { newBiliardo } = await import('/src/engine/biliardo.js');
|
|
game.closeup = { ...newBiliardo(42), id: 'biliardo' };
|
|
});
|
|
const state = () => page.evaluate(() => ({ angle: game.closeup.angle, power: game.closeup.power, step: game.closeup.step }));
|
|
const position = async () => {
|
|
const r = await page.locator('#scene').boundingBox();
|
|
const [x, y] = await page.evaluate(async () => {
|
|
const { VIEW } = await import('/src/engine/biliardo.js');
|
|
const b = game.closeup.balls.cpu;
|
|
return [VIEW.x + b.x * VIEW.k, VIEW.y + (b.y + 3) * VIEW.k];
|
|
});
|
|
return { x: r.x + x / 640 * r.width, y: r.y + y / 360 * r.height, r };
|
|
};
|
|
const target = await position();
|
|
if (!mobile) {
|
|
// 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');
|
|
assert.equal((await state()).power, 0.45, 'il tocco conserva la forza impostata');
|
|
|
|
// Il passaggio del mouse verso i comandi attraversa il panno.
|
|
const fixed = (await state()).angle;
|
|
await page.mouse.move(target.r.x + target.r.width * 0.55, target.r.y + target.r.height * 0.8, { steps: 8 });
|
|
assert.equal((await state()).angle, fixed, 'spostarsi verso Tira non deve cambiare la mira');
|
|
await page.keyboard.press('ArrowRight');
|
|
const nudged = (await state()).angle;
|
|
assert.ok(Math.abs(nudged - fixed - 0.002) < 1e-10);
|
|
await page.keyboard.press('Shift+ArrowLeft');
|
|
assert.ok(Math.abs((await state()).angle - nudged + 0.0005) < 1e-10);
|
|
await page.locator('#bocce-power').fill('60');
|
|
const prepared = await state();
|
|
await page.screenshot({ path: `${out}/${name}-mira.png` });
|
|
await checkArtwork(page, name);
|
|
const expected = await page.evaluate(async () => {
|
|
const { simulate } = await import('/src/engine/biliardo.js');
|
|
const c = game.closeup;
|
|
return simulate(c, 'player', c.angle, c.power);
|
|
});
|
|
if (mobile) await page.tap('#bocce-throw'); else await page.click('#bocce-throw');
|
|
assert.equal((await state()).step, 'rolling');
|
|
assert.equal((await state()).angle, prepared.angle);
|
|
const score = await page.evaluate(async () => {
|
|
const { update, judge, FIXED_STEP } = await import('/src/engine/biliardo.js');
|
|
const c = game.closeup;
|
|
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
|
|
return judge(c.shot);
|
|
});
|
|
assert.deepEqual(score, expected, 'il tiro dai comandi segue la simulazione');
|
|
|
|
await reset();
|
|
const hold = await position();
|
|
const cdp = mobile ? await page.context().newCDPSession(page) : null;
|
|
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
|
|
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
|
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.25);
|
|
const heldAngle = (await state()).angle;
|
|
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: hold.x - 30, y: hold.y + 20 }] });
|
|
else await page.mouse.move(hold.x - 30, hold.y + 20, { steps: 4 });
|
|
assert.equal((await state()).angle, heldAngle, 'il movimento durante la carica non sposta la mira');
|
|
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
else await page.mouse.up();
|
|
assert.equal((await state()).step, 'rolling');
|
|
assert.equal((await state()).angle, heldAngle);
|
|
|
|
// Un gesto interrotto non deve lasciare un tiro in carica.
|
|
await reset();
|
|
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
|
|
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
|
|
await page.waitForFunction(() => game.closeup.holding);
|
|
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
|
|
else { await page.locator('#scene').dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up(); }
|
|
assert.equal((await state()).step, 'aim');
|
|
assert.equal((await state()).power, 0.45);
|
|
assert.equal(await page.evaluate(() => game.closeup.holding), false);
|
|
await page.keyboard.press('Escape');
|
|
assert.equal(await page.evaluate(() => game.closeup), null);
|
|
await page.close();
|
|
console.log(`${name}: mira, ritocchi, forza, tiro, carica e annullamento OK`);
|
|
}
|
|
assert.deepEqual(errors, [], 'nessun errore JavaScript nel browser');
|
|
console.log(`Schermate: ${out}`);
|
|
} finally {
|
|
await browser?.close();
|
|
server.kill();
|
|
}
|