// 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(); }