// 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'; 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: ['--autoplay-policy=no-user-gesture-required', '--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 }]]) { 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])'); 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'); 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'); 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(440, 320)]); await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' }); await touch('touchEnd', []); } else { await page.mouse.move(...P(440, 320)); await page.mouse.down(); await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' }); await page.mouse.up(); } 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); // I pulsanti tenuti premuti. const b = await page.locator('#bocce-left').boundingBox(); await touch('touchStart', [[b.x + b.width / 2, b.y + b.height / 2]]); await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); await touch('touchEnd', []); await page.waitForFunction(() => game.closeup.flippers.left.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); } // 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'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25); // Ferma solo la simulazione per fotografare le tre palline, prima che una possa scendere. window.pinballResume = game.update.bind(game); game.update = dt => { game.closeup.visual.time += dt; }; return { balls: m.activeBalls(game.closeup).length, locks: game.closeup.locks.length, reserve: game.closeup.balls, time: game.closeup.visual.time }; }); assert.deepEqual({ balls: multi.balls, locks: multi.locks, reserve: multi.reserve }, { balls: 3, locks: 0, reserve: 5 }); await page.waitForFunction(t => game.closeup.visual.time > t + 0.7, multi.time); await page.screenshot({ path: `${out}/${name}-multiball.png` }); const frameMs = await page.evaluate(() => new Promise(resolve => { const stamps = []; const tick = t => { stamps.push(t); if (stamps.length === 20) resolve((t - stamps[0]) / 19); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); })); console.log(`${name}: intervallo frame medio ${frameMs.toFixed(1)} ms`); await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; }); // 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); await page.close(); } assert.deepEqual(errors, []); console.log(`ok: schermate in ${out}`); } finally { await browser?.close(); server.kill(); }