Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
250 lines
18 KiB
JavaScript
250 lines
18 KiB
JavaScript
// Il flipper del River nel browser vero: dal bar, la molla col mouse e col dito, i flipper con due dita sulla scena,
|
|
// coi pulsanti e con le frecce, la pausa che lascia tutto, il risultato. npm run browser:flipper — schermate in
|
|
// /tmp/regolo-flipper.
|
|
import { chromium } from 'playwright';
|
|
import { mkdirSync } from 'node:fs';
|
|
import assert from 'node:assert/strict';
|
|
import { staticServer } from './helpers.mjs';
|
|
import { ARGS } from './chromium.mjs';
|
|
|
|
const out = process.env.OUT ?? '/tmp/regolo-flipper';
|
|
mkdirSync(out, { recursive: true });
|
|
const server = await staticServer();
|
|
let browser;
|
|
const errors = [];
|
|
try {
|
|
browser = await chromium.launch({ args: ARGS });
|
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
|
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('via_provinciale'); settle(game);
|
|
game.act('use', 'river'); settle(game);
|
|
game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game);
|
|
});
|
|
await page.waitForSelector('#bocce-controls:not([hidden])', { state: 'attached' });
|
|
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png'));
|
|
// Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola.
|
|
const readyAt = await page.evaluate(() => game.closeup.visual.time);
|
|
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
|
|
assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia');
|
|
assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
|
|
if (!mobile) {
|
|
// «Solo tavola»: la pulsantiera sparisce e la tavola si allarga; ripremuto, torna tutto com'era.
|
|
const before = (await page.locator('#scene').boundingBox()).width;
|
|
await page.click('#table-only');
|
|
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display === 'none');
|
|
await page.waitForFunction(w => document.getElementById('scene').getBoundingClientRect().width > w + 50, before);
|
|
assert.equal(await page.locator('#table-only').textContent(), 'Pulsantiera');
|
|
await page.click('#table-only');
|
|
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display !== 'none');
|
|
assert.ok(await page.locator('#flipper-help').isVisible());
|
|
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
|
|
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
|
|
} else {
|
|
// Sul telefono in orizzontale (Andrea): niente pulsantiera, la scena larga sotto i due pollici.
|
|
assert.ok(!(await page.locator('#side').isVisible()), 'sul telefono la pulsantiera del flipper sparisce');
|
|
const w = (await page.locator('#scene').boundingBox()).width;
|
|
assert.ok(w > viewport.width * 0.6, `sul telefono la scena del flipper è larga ${w} px`);
|
|
}
|
|
await page.screenshot({ path: `${out}/${name}-tavola.png` });
|
|
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
|
|
if (!mobile) await page.screenshot({ path: `${out}/desktop-dettaglio.png`, clip: { x: P(200, 0)[0], y: box.y, width: 248 / 640 * box.width, height: box.height } });
|
|
const cdp = mobile ? await page.context().newCDPSession(page) : null;
|
|
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) });
|
|
// La molla: tenendo premuto sulla scena, col mouse e col dito.
|
|
if (mobile) {
|
|
await touch('touchStart', [P(320, 320)]); // al centro: la molla
|
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
|
await touch('touchEnd', []);
|
|
} else {
|
|
// Da tastiera con la freccia giù (il mouse tiene la molla come il dito, provato sul telefono).
|
|
await page.keyboard.down('ArrowDown');
|
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
|
await page.keyboard.up('ArrowDown');
|
|
}
|
|
await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 });
|
|
// Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su.
|
|
if (mobile) {
|
|
await touch('touchStart', [P(250, 200)]);
|
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
|
|
await touch('touchStart', [P(250, 200), P(400, 200)]);
|
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0 && game.closeup.flippers.left.held.length > 0, null, { timeout: 3000 });
|
|
await page.screenshot({ path: `${out}/${name}-flipper.png` });
|
|
await touch('touchEnd', [P(400, 200)]);
|
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 });
|
|
await touch('touchEnd', []);
|
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
|
|
} else {
|
|
await page.mouse.move(...P(400, 200)); await page.mouse.down();
|
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0);
|
|
await page.mouse.down({ button: 'right' });
|
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 1, null, { timeout: 3000 });
|
|
await page.screenshot({ path: `${out}/${name}-flipper.png` });
|
|
await page.mouse.up(); await page.mouse.up({ button: 'right' });
|
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
|
|
await page.keyboard.down('ArrowLeft');
|
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
|
|
// La pausa lascia tutto: tornando, il flipper è giù anche se il tasto era premuto.
|
|
await page.click('#pause-button');
|
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
|
|
await page.keyboard.up('ArrowLeft');
|
|
await page.click('#resume');
|
|
await page.waitForFunction(() => document.querySelector('#pause').hidden);
|
|
// I due Shift: il sinistro alza la paletta sinistra, il destro la destra.
|
|
await page.keyboard.down('ShiftRight');
|
|
await page.waitForFunction(() => game.closeup.flippers.right.held.includes('key-ShiftRight') && !game.closeup.flippers.left.held.length);
|
|
await page.keyboard.down('ShiftLeft');
|
|
await page.waitForFunction(() => game.closeup.flippers.left.held.includes('key-ShiftLeft'));
|
|
await page.keyboard.up('ShiftRight'); await page.keyboard.up('ShiftLeft');
|
|
await page.waitForFunction(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length);
|
|
}
|
|
// Il traguardo aggiunge una pallina e lascia continuare la partita.
|
|
const reward = await page.evaluate(async () => {
|
|
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
|
m.cancel(c); if (m.waiting(c)) m.launch(c, 0.8);
|
|
c.balls = 1; c.score = c.skill.goal - m.SCORE.bumper; c.saveUntil = 0;
|
|
const b = c.bumpers[0]; b.touching = false;
|
|
Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200, waiting: false, ramp: null });
|
|
m.update(c, 0.05);
|
|
return { balls: c.balls, step: c.step, winner: c.result?.winner };
|
|
});
|
|
assert.deepEqual(reward, { balls: 2, step: 'play', winner: 'player' });
|
|
await page.waitForFunction(() => document.querySelector('#bocce-status').textContent.includes('pallina premio vinta'));
|
|
await page.screenshot({ path: `${out}/${name}-premio.png` });
|
|
// Una sequenza reale di rampe, con il motore: primo lock, secondo lock, rampa centrale e tre palline.
|
|
await page.evaluate(async () => {
|
|
const m = await import('/src/engine/flipper.js');
|
|
Object.assign(game.closeup, m.newFlipper(73, { level: 'bona' }));
|
|
window.pinballShot = id => {
|
|
const c = game.closeup, r = c.ramps.find(r => r.id === id);
|
|
if (m.waiting(c)) m.launch(c, 0.8);
|
|
Object.assign(c.ball, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null });
|
|
let entered = false;
|
|
for (let i = 0; i < 1000; i++) { m.update(c, m.STEP); if (c.ball?.ramp) entered = true; else if (entered) return; }
|
|
throw new Error(`rampa ${id} non completata`);
|
|
};
|
|
for (let i = 0; i < 3; i++) window.pinballShot('sinistra');
|
|
});
|
|
assert.equal(await page.evaluate(() => game.closeup.locks.length), 1);
|
|
await page.screenshot({ path: `${out}/${name}-lock.png` });
|
|
await page.evaluate(async () => {
|
|
for (let i = 0; i < 3; i++) window.pinballShot('destra');
|
|
const m = await import('/src/engine/flipper.js');
|
|
for (let i = 0; i < 4; i++) m.update(game.closeup, 0.2);
|
|
});
|
|
assert.equal(await page.evaluate(() => game.closeup.special), 'super');
|
|
await page.screenshot({ path: `${out}/${name}-super.png` });
|
|
const multi = await page.evaluate(async () => {
|
|
window.pinballShot('super');
|
|
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
|
window.pinballResume = game.update.bind(game); game.update = () => {};
|
|
window.pinballClassic = structuredClone(c); window.pinballClassic.transform = null;
|
|
(await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() });
|
|
m.update(c, 0.25); m.update(c, 0.2);
|
|
window.pinballExplosion = structuredClone(c);
|
|
return { balls: m.activeBalls(c).length, locks: c.locks.length, reserve: c.balls };
|
|
});
|
|
assert.deepEqual(multi, { balls: 3, locks: 0, reserve: 5 });
|
|
await page.screenshot({ path: `${out}/${name}-esplosione.png` });
|
|
await page.evaluate(async () => {
|
|
const m = await import('/src/engine/flipper.js');
|
|
for (let i = 0; i < 6; i++) m.update(game.closeup, 0.25);
|
|
});
|
|
await page.screenshot({ path: `${out}/${name}-trasformazione.png` });
|
|
await page.evaluate(async () => {
|
|
const m = await import('/src/engine/flipper.js');
|
|
for (let i = 0; i < 6; i++) m.update(game.closeup, 0.25);
|
|
window.pinballFuture = structuredClone(game.closeup);
|
|
});
|
|
assert.equal(await page.evaluate(() => game.closeup.table), 'future');
|
|
await page.screenshot({ path: `${out}/${name}-futuro.png` });
|
|
await page.screenshot({ path: `${out}/${name}-multiball.png` });
|
|
// Anche nel nuovo campo: due dita indipendenti, i due Shift e la pausa vera dell'applicazione.
|
|
if (mobile) {
|
|
await touch('touchStart', [P(250, 200), P(400, 200)]);
|
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length && game.closeup.flippers.right.held.length);
|
|
await touch('touchEnd', []);
|
|
} else {
|
|
await page.keyboard.down('ShiftLeft'); await page.keyboard.down('ShiftRight');
|
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length && game.closeup.flippers.right.held.length);
|
|
}
|
|
await page.evaluate(() => { game.update = window.pinballResume; });
|
|
await page.click('#pause-button');
|
|
const paused = await page.evaluate(() => ({ sim: game.closeup.sim, hits: game.closeup.future.hits, score: game.closeup.score }));
|
|
await page.waitForTimeout(250);
|
|
assert.deepEqual(await page.evaluate(() => ({ sim: game.closeup.sim, hits: game.closeup.future.hits, score: game.closeup.score })), paused);
|
|
assert.ok(await page.evaluate(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length));
|
|
if (!mobile) { await page.keyboard.up('ShiftLeft'); await page.keyboard.up('ShiftRight'); }
|
|
await page.click('#resume');
|
|
await page.evaluate(() => { game.update = () => {}; Object.assign(game.closeup, structuredClone(window.pinballFuture)); });
|
|
// Stessa lettura sincrona dei pixel per classico, detonazione/trasformazione e futuro. Si svuota prima
|
|
// l'arretrato precedente; in gioco si riportano in alto solo le palline prossime alla buca per misurare
|
|
// tutti i 150 fotogrammi con tre palline, senza finire accidentalmente sul campo classico.
|
|
for (const mode of ['classico', 'esplosione', 'futuro']) {
|
|
const backlog = await page.evaluate(async mode => {
|
|
const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup;
|
|
Object.assign(c, structuredClone(mode === 'classico' ? window.pinballClassic : mode === 'esplosione' ? window.pinballExplosion : window.pinballFuture));
|
|
const ctx = document.querySelector('#scene').getContext('2d');
|
|
r.render(ctx, game, { time: performance.now() }); ctx.getImageData(0, 0, 4, 4);
|
|
for (let i = 0; i < 150; i++) {
|
|
if (mode !== 'esplosione') for (const [k, b] of m.activeBalls(c).entries()) {
|
|
b.releaseAt = 0;
|
|
if (b.y > 295) Object.assign(b, { x: 260 + k * 55, y: 200, vx: 20, vy: -150, ramp: null });
|
|
}
|
|
m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() });
|
|
}
|
|
const t = performance.now(); ctx.getImageData(0, 0, 4, 4); const ms = Math.round(performance.now() - t);
|
|
if (mode === 'futuro' && c.table !== 'future') throw new Error('misura uscita dal futuro');
|
|
return ms;
|
|
}, mode);
|
|
console.log(`${name}: arretrato grafico ${mode} dopo 150 fotogrammi ${backlog} ms`);
|
|
assert.ok(backlog < 1500, `${mode}: arretrato grafico ${backlog} ms`);
|
|
}
|
|
// Percorsi veri fino al finale: la schermata mostra sigilli caricati e il bersaglio mobile da colpire.
|
|
await page.evaluate(async () => {
|
|
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
|
Object.assign(c, structuredClone(window.pinballFuture));
|
|
for (const b of c.extraBalls) b.releaseAt = c.sim + 1000;
|
|
for (const id of ['orbita', 'ponte', 'spira']) for (let i = 0; i < 2; i++) window.pinballShot(id);
|
|
});
|
|
assert.deepEqual(await page.evaluate(() => game.closeup.future.hits), { orbita: 2, ponte: 2, spira: 2 });
|
|
await page.screenshot({ path: `${out}/${name}-sigilli.png` });
|
|
await page.evaluate(async () => {
|
|
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
|
Object.assign(c.ball, { x: 320 + Math.sin((c.sim + m.STEP) * 1.6) * 24, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -250, ramp: null, contacts: [] });
|
|
m.update(c, m.STEP);
|
|
});
|
|
assert.equal(await page.evaluate(() => game.closeup.table), 'classic');
|
|
await page.evaluate(async () => {
|
|
(await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() });
|
|
const m = await import('/src/engine/flipper.js'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25);
|
|
});
|
|
await page.screenshot({ path: `${out}/${name}-ritorno.png` });
|
|
await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url('));
|
|
await page.evaluate(() => { game.update = window.pinballResume; for (const key of ['pinballResume', 'pinballShot', 'pinballClassic', 'pinballExplosion', 'pinballFuture']) delete window[key]; });
|
|
// Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale.
|
|
const finalScore = await page.evaluate(async () => {
|
|
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
|
for (let i = 0; i < 12 && c.step === 'play'; i++) {
|
|
if (m.waiting(c)) m.launch(c, 0.8);
|
|
c.saveUntil = 0;
|
|
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 }); m.update(c, 0.1);
|
|
}
|
|
return c.score;
|
|
});
|
|
await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita');
|
|
assert.ok((await page.locator('#bocce-status').textContent()).includes(`${finalScore} punti`));
|
|
await page.screenshot({ path: `${out}/${name}-fine.png` });
|
|
if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato
|
|
await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup);
|
|
await page.waitForFunction(() => document.querySelector('#scene').width === 640 && !document.querySelector('#scene').style.backgroundImage);
|
|
await page.close();
|
|
}
|
|
assert.deepEqual(errors, []);
|
|
console.log(`ok: schermate in ${out}`);
|
|
} finally { await browser?.close(); server.kill(); }
|