Biliardo più preciso (revisione di Codex): urti calcolati nel punto esatto di contatto, anche nei colpi di taglio e veloci; la guida simula il tiro vero e mostra le due biglie dopo l'urto e i falli; la mira resta ferma andando sui comandi; frecce e Maiusc per la mira fine; comandi che non coprono il tavolo; test nel browser con mouse, touch e telefono. Più un tetto agli urti per passo, così due biglie a contatto non possono bloccare il gioco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
// 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 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 = [];
|
||||
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);
|
||||
});
|
||||
await page.waitForSelector('#bocce-controls:not([hidden])');
|
||||
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) 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` });
|
||||
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();
|
||||
}
|
||||
+123
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newBiliardo, update, press, release, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimScreen, canThrow, LEN, WID, R, VIEW, SWING, powerAt } from '../src/engine/biliardo.js';
|
||||
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION } from '../src/engine/biliardo.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
@@ -86,6 +86,8 @@ test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita fin
|
||||
assert.equal(c.step, 'rolling', 'l\'avversario ha tirato');
|
||||
assert.ok(frames > 20, 'non tira subito: ci pensa');
|
||||
for (let turn = 0; turn < 60 && c.step !== 'result'; turn++) {
|
||||
// Una mano comprende anche il tempo in cui la CPU pensa prima di tirare.
|
||||
for (let i = 0; i < 600 && c.step === 'aim' && c.current === 'cpu'; i++) update(c, 1 / 60);
|
||||
still(c);
|
||||
if (c.step === 'aim' && c.current === 'player') {
|
||||
const me = c.balls.player, other = c.balls.cpu;
|
||||
@@ -99,6 +101,126 @@ test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita fin
|
||||
assert.equal(press(c), false, 'a partita finita non si tira più');
|
||||
});
|
||||
|
||||
const emptyTable = () => {
|
||||
const c = newBiliardo(123);
|
||||
c.pins = [];
|
||||
Object.assign(c.balls.player, { x: 30, y: 30 });
|
||||
Object.assign(c.balls.cpu, { x: 250, y: 120 });
|
||||
Object.assign(c.balls.pallino, { x: 20, y: 120 });
|
||||
return c;
|
||||
};
|
||||
const roll = (c, dt = FIXED_STEP) => {
|
||||
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, dt);
|
||||
assert.equal(c.step, 'settle', 'tutte le biglie devono fermarsi');
|
||||
};
|
||||
const near = (actual, expected, tolerance = 1e-6) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≠ ${expected} (tolleranza ${tolerance})`);
|
||||
|
||||
test('la guida e il tiro finiscono nello stesso punto, anche dopo sponde oblique e a diverse forze', () => {
|
||||
for (const [x, y, angle, power] of [[30, 30, 0, 0.2], [270, 30, 0.65, 0.85], [260, 100, 0.1, 1], [30, 10, -1.2, 0.6]]) {
|
||||
const c = emptyTable();
|
||||
Object.assign(c.balls.player, { x, y });
|
||||
c.angle = angle; c.power = power;
|
||||
const before = JSON.stringify(c), p = predict(c);
|
||||
assert.equal(JSON.stringify(c), before, 'la previsione non altera il tavolo né il seme casuale');
|
||||
assert.equal(p.hit, null);
|
||||
const end = p.points.at(-1);
|
||||
shoot(c); roll(c);
|
||||
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
|
||||
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power)), 0.01);
|
||||
}
|
||||
});
|
||||
|
||||
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
|
||||
const c = emptyTable();
|
||||
Object.assign(c.balls.player, { x: 270, y: 30 });
|
||||
c.angle = Math.PI / 4; c.power = 0.3;
|
||||
const p = predict(c), [start, rail, end] = p.points;
|
||||
assert.equal(p.points.length, 3);
|
||||
near(rail[0], LEN - R);
|
||||
near((rail[1] - start[1]) / (rail[0] - start[0]), 1);
|
||||
near((end[1] - rail[1]) / (end[0] - rail[0]), -1 / CUSHION);
|
||||
});
|
||||
|
||||
test('i colpi sottili arrivano al punto di contatto corretto anche a piena forza', () => {
|
||||
for (const offset of [0, R, 2 * R * 0.9, 2 * R - 0.0001, 2 * R + 0.0001]) {
|
||||
const c = emptyTable();
|
||||
Object.assign(c.balls.player, { x: 30, y: 50 + offset });
|
||||
Object.assign(c.balls.cpu, { x: 100, y: 50 });
|
||||
c.angle = 0; c.power = 1;
|
||||
const p = predict(c);
|
||||
shoot(c);
|
||||
for (let i = 0; i < 1000 && c.balls.player.x < 110 && !c.shot.first; i++) update(c, FIXED_STEP);
|
||||
if (offset > 2 * R) {
|
||||
assert.equal(c.shot.first, null, 'un passaggio esterno non diventa un urto');
|
||||
continue;
|
||||
}
|
||||
assert.equal(p.hit, 'cpu');
|
||||
assert.equal(c.shot.first, 'cpu', 'non deve attraversare la biglia nei colpi radenti');
|
||||
near(p.ghost.x, 100 - Math.sqrt((2 * R) ** 2 - offset ** 2));
|
||||
near(p.ghost.y, 50 + offset);
|
||||
const speed = Math.hypot(c.balls.cpu.vx, c.balls.cpu.vy);
|
||||
near(c.balls.cpu.vx / speed, p.dir[0]); near(c.balls.cpu.vy / speed, p.dir[1]);
|
||||
const target = p.outgoing.find(t => t.role === 'cpu').points;
|
||||
near((target[1][0] - target[0][0]) / Math.hypot(target[1][0] - target[0][0], target[1][1] - target[0][1]), p.dir[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test('gli urti conservano la quantità di moto e non creano energia; le biglie che si separano non rimbalzano di nuovo', () => {
|
||||
const a = { x: 40, y: 50, vx: 300, vy: 80 }, b = { x: 40 + 2 * R, y: 50, vx: -20, vy: -15 };
|
||||
const energy = a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2;
|
||||
assert.ok(collide(a, b) > 0);
|
||||
near(a.vx + b.vx, 280); near(a.vy + b.vy, 65);
|
||||
assert.ok(a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2 < energy);
|
||||
const after = JSON.stringify([a, b]);
|
||||
assert.equal(collide(a, b), 0);
|
||||
assert.equal(JSON.stringify([a, b]), after);
|
||||
});
|
||||
|
||||
test('la mira è bloccata durante la carica, il tocco breve ripristina la forza e le frecce consentono ritocchi fini', () => {
|
||||
const c = newBiliardo(1), angle = c.angle, power = c.power;
|
||||
press(c);
|
||||
assert.equal(aimAt(c, 100, 100), false);
|
||||
assert.equal(nudge(c, 1), false);
|
||||
update(c, 0.05); release(c);
|
||||
assert.equal(c.step, 'aim'); assert.equal(c.angle, angle); assert.equal(c.power, power);
|
||||
nudge(c, 1); near(c.angle - angle, 0.002);
|
||||
nudge(c, -1, true); near(c.angle - angle, 0.0015);
|
||||
const fixed = c.angle;
|
||||
assert.equal(aimScreen(c, [320, 30]), false, 'il pannello superiore non è una superficie di mira');
|
||||
assert.equal(c.angle, fixed);
|
||||
press(c); update(c, 0.2); cancel(c);
|
||||
assert.equal(c.power, power);
|
||||
press(c); update(c, 0.2); release(c);
|
||||
near(Math.atan2(c.balls.player.vy, c.balls.player.vx), fixed);
|
||||
});
|
||||
|
||||
test('anteprima, CPU e tiro reale condividono ordine degli urti e punteggio', () => {
|
||||
for (const [angle, power] of [[0, 1], [0.15, 0.5], [-0.6, 0.8], [2.4, 0.6]]) {
|
||||
const c = newBiliardo(1);
|
||||
Object.assign(c.balls.player, { x: 100, y: 71 });
|
||||
Object.assign(c.balls.cpu, { x: 126, y: 71 });
|
||||
c.angle = angle; c.power = power;
|
||||
const p = predict(c), expected = simulate(c, 'player', angle, power);
|
||||
shoot(c); roll(c);
|
||||
assert.deepEqual(judge(c.shot), expected);
|
||||
if (p.hit === 'cpu' || p.hit === 'pallino') assert.equal(c.shot.first, p.hit);
|
||||
if (angle === 0) assert.ok(c.shot.pins.every(pin => pin.by === 'cpu'));
|
||||
}
|
||||
});
|
||||
|
||||
test('la fisica resta identica a 30, 60, 144 fps e con fotogrammi irregolari', () => {
|
||||
const states = [];
|
||||
for (const frames of [[1 / 30], [1 / 60], [1 / 144], [0.008, 0.042, 0.016, 0.09]]) {
|
||||
const c = newBiliardo(1);
|
||||
c.angle += 0.02; c.power = 0.9;
|
||||
shoot(c);
|
||||
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, frames[i % frames.length]);
|
||||
assert.equal(c.step, 'settle');
|
||||
states.push({ balls: c.balls, score: judge(c.shot) });
|
||||
}
|
||||
for (const s of states.slice(1)) assert.deepEqual(s, states[0]);
|
||||
});
|
||||
|
||||
test('mirare: un tocco sullo schermo gira la stecca verso quel punto; la previsione trova la biglia davanti', () => {
|
||||
const c = newBiliardo(1);
|
||||
const me = c.balls.player;
|
||||
|
||||
+1
-1
@@ -407,7 +407,7 @@ try {
|
||||
await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click();
|
||||
await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
|
||||
const r = await b.locator('#scene').boundingBox();
|
||||
const [tx, ty] = await b.evaluate(() => { const c = game.closeup, V = c.balls.cpu; return [57 + V.x * 1.85, 78 + V.y * 1.85]; });
|
||||
const [tx, ty] = await b.evaluate(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; });
|
||||
await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height);
|
||||
await b.mouse.down();
|
||||
await b.waitForTimeout(500);
|
||||
|
||||
Reference in New Issue
Block a user