Il flipper rifatto da Codex: tavola ad alta risoluzione con lacca, cromature e luci animate; due LOCK (tre passaggi sulla stessa rampa in venti secondi, Fortezza e Trebbio), la SUPER SPECIAL al centro che si apre con entrambi i lock e dà pallina premio e multiball a tre; jackpot fino a x3 coi bersagli, nuove fanfare; la valvola contro il rientro nella molla; display del punteggio e pannello della caccia al jackpot ai lati; test unitari e nel browser (lock, super, multiball) (Andrea)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
2026-10-31 23:47:51 +01:00
co-authored by Claude Fable 5.1
parent bbce40b014
commit b660bbeb96
10 changed files with 694 additions and 529 deletions
+42 -2
View File
@@ -29,6 +29,7 @@ try {
// 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');
@@ -96,13 +97,51 @@ try {
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 < 4 && c.step === 'play'; i++) {
for (let i = 0; i < 12 && c.step === 'play'; i++) {
if (m.waiting(c)) m.launch(c, 0.8);
c.saveUntil = 0;
Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); m.update(c, 0.1);
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;
});
@@ -111,6 +150,7 @@ try {
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, []);
+129 -39
View File
@@ -277,41 +277,131 @@ test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
});
test('il LOCK: lo stesso ponte tre volte di fila trattiene la pallina e ne dà una nuova; l\'altro ponte SPECIAL vale il jackpot e il multiball', () => {
const c = m.newFlipper(21, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
const left = c.ramps[0], right = c.ramps[1];
// Un tiro nel ponte: si aspetta che la pallina esca in fondo, prima che vada altrove (così il conto non si azzera).
const shoot = r => { const [[x0], [x1]] = r.mouth; put(c, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(c, 1 / 60); if (c.ball?.ramp) was = true; else if (was) break; } };
shoot(left); assert.equal(c.rampRun.count, 1); assert.ok(!c.locked);
shoot(left); assert.equal(c.rampRun.count, 2);
shoot(left);
assert.ok(c.locked && c.locked.rampId === 'sinistra', 'la pallina è trattenuta');
assert.equal(c.special, 'destra', 'l\'altro ponte è SPECIAL');
assert.ok(m.waiting(c) && c.ball !== c.locked.ball, 'una pallina nuova sulla molla');
assert.ok([c.line, ...c.lines].some(l => /LOCK/.test(l ?? '')) && [c.line, ...c.lines].some(l => /SPECIAL/.test(l ?? '')));
assert.ok(c.events.some(e => e.kind === 'flipper-lock'));
assert.equal(c.balls, 3, 'il lock non costa una pallina');
// Il ponte sbagliato non libera niente.
m.launch(c, 0.9); c.events.length = 0; shoot(left);
assert.ok(c.locked, 'ancora trattenuta');
// Il ponte SPECIAL: jackpot, la pallina liberata, due in gioco.
const before = c.score; shoot(right);
assert.ok(c.score - before >= m.SCORE.jackpot + m.SCORE.ramp, `jackpot (${c.score - before})`);
assert.ok(!c.locked && !c.special, 'liberata');
assert.ok(m.multiball(c) && m.activeBalls(c).length === 2, 'multiball');
assert.ok(c.events.some(e => e.kind === 'flipper-jackpot'));
// In multiball perdere una pallina non costa: resta l'altra; persa anche quella, si scala.
const riserva = c.balls; // (il jackpot ha passato il traguardo: una pallina premio in più)
Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5);
assert.ok(!m.multiball(c) && c.balls === riserva && m.activeBalls(c).length === 1, 'una in gioco, nessuna persa');
c.saveUntil = 0; Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5);
assert.equal(c.balls, riserva - 1, 'ora sì');
// Persa la pallina nuova prima dello SPECIAL: quella trattenuta torna, senza scalare.
const d = m.newFlipper(22, { level: 'bona' }); m.launch(d, 0.9);
const go = r => { const [[x0], [x1]] = r.mouth; put(d, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(d, 1 / 60); if (d.ball?.ramp) was = true; else if (was) break; } };
go(d.ramps[0]); go(d.ramps[0]); go(d.ramps[0]); assert.ok(d.locked);
m.launch(d, 0.9); d.saveUntil = 0; Object.assign(d.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(d, 0.5);
assert.ok(!d.locked && d.balls === 3 && m.waiting(d), 'lock libero, pallina di nuovo sulla molla');
function rampShot(c, id) {
if (m.waiting(c)) m.launch(c, 0.8);
const r = c.ramps.find(r => r.id === id);
return ride(c, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null, waiting: false });
}
function doubleLock(c) { for (const id of ['sinistra', 'destra']) for (let n = 0; n < 3; n++) rampShot(c, id); }
test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball reale a tre, in ogni ordine', () => {
for (const level of Object.keys(m.LEVELS)) for (const ids of [['sinistra', 'destra'], ['destra', 'sinistra']]) {
const c = m.newFlipper(21, { level }); m.launch(c, 0.9);
const superRamp = c.ramps.find(r => r.id === 'super');
assert.ok(!m.rampOpen(c, superRamp));
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 1);
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 2);
rampShot(c, ids[0]);
assert.equal(c.locks.length, 1); assert.equal(c.locks[0].rampId, ids[0]);
assert.equal(c.special, ids[1]); assert.ok(m.waiting(c)); assert.equal(c.balls, 3);
const lockedBall = c.locks[0].ball;
rampShot(c, ids[0]); assert.equal(c.locks.length, 1, 'una rampa occupata non produce altri lock');
const score = c.score; rampShot(c, ids[1]);
assert.equal(c.score - score, m.SCORE.ramp + m.SCORE.jackpot, 'SPECIAL paga una volta e avvia il secondo lock');
assert.equal(c.locks[0].ball, lockedBall); assert.equal(c.rampRun.count, 1);
const afterSpecial = c.score; rampShot(c, ids[1]);
assert.equal(c.score - afterSpecial, m.SCORE.ramp, 'niente jackpot ripetuti durante la carica');
rampShot(c, ids[1]);
assert.equal(c.locks.length, 2); assert.equal(c.special, 'super'); assert.ok(m.rampOpen(c, superRamp));
assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0);
const before = c.score, reserve = c.balls, hadGoal = !!c.result;
rampShot(c, 'super');
assert.equal(c.score - before, m.SCORE.super);
assert.equal(c.balls, reserve + 1 + (hadGoal ? 0 : 1), 'premio SUPER, più il traguardo se appena superato');
assert.equal(c.locks.length, 0); assert.equal(c.special, null);
assert.equal(m.activeBalls(c).length, 3); assert.ok(m.multiball(c));
assert.equal(new Set(m.activeBalls(c)).size, 3, 'tre palline distinte');
assert.ok(c.events.some(e => e.kind === 'flipper-super'));
const winScore = c.score, lives = c.balls;
rampShot(c, ids[0]);
assert.ok(c.score - winScore >= m.SCORE.ramp + m.SCORE.jackpot, 'in multiball le rampe pagano jackpot');
// Metti tutte le palline sotto il campo nello stesso passo: la vita si scala una volta soltanto.
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, waiting: false, releaseAt: 0 });
c.saveUntil = 0; m.update(c, m.STEP);
assert.equal(c.balls, lives - 1); assert.equal(c.extraBalls.length, 0); assert.ok(m.waiting(c));
}
});
test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie e la SUPER chiusa non cattura', () => {
const c = m.newFlipper(22); m.launch(c, 0.8);
put(c, { x: 320, y: 233, vx: 0, vy: -400 }); run(c, 0.02);
assert.equal(c.ball.ramp, null, 'la rampa centrale chiusa non esiste nella fisica');
rampShot(c, 'sinistra'); rampShot(c, 'sinistra');
c.ball.waiting = true; run(c, m.RAMP.lockWindow + 0.1);
assert.equal(c.rampRun.count, 0); rampShot(c, 'sinistra'); assert.equal(c.rampRun.count, 1);
rampShot(c, 'destra'); assert.equal(c.rampRun.id, 'destra'); assert.equal(c.rampRun.count, 1);
c.saveUntil = 0; put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.rampRun.count, 0);
});
test('la SUPER accompagna alla paletta sinistra e il multiball libera le palline sfalsate', () => {
for (const level of Object.keys(m.LEVELS)) {
const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super');
const main = c.ball, delayed = c.extraBalls.find(b => b.releaseAt > c.sim);
assert.equal(main.x, 229); assert.equal(main.y, 220); assert.equal(main.vx, 0); assert.equal(main.vy, 160);
for (const b of c.extraBalls) { assert.ok([229, 411].includes(b.x)); assert.equal(b.vx, 0, 'i lock non sparano verso le buche laterali'); }
assert.ok(delayed, 'una serratura aspetta prima di liberare la terza pallina');
const position = [delayed.x, delayed.y]; run(c, 0.2);
assert.deepEqual([delayed.x, delayed.y], position, 'durante il ritardo resta nel lock');
run(c, 0.3); assert.notDeepEqual([delayed.x, delayed.y], position, 'poi viene liberata');
// Isola la pallina della SUPER per verificare che un colpo normale possa recuperarla.
c.extraBalls = []; c.ball = main;
let recovered = false;
for (let i = 0; i < 480; i++) {
if (main.x > 284 && main.y > 300) m.hold(c, 'left', 'test', true);
m.update(c, m.STEP);
if (main.vy < -250) { recovered = true; break; }
}
assert.ok(recovered, `${level}: ripresa dalla paletta, senza cadere direttamente nella buca centrale`);
}
});
test('perdere una sostitutiva restituisce un lock alla volta senza consumare vite; la SUPER si richiude', () => {
const c = m.newFlipper(23, { level: 'male' }); doubleLock(c);
const lives = c.balls;
for (const remaining of [1, 0]) {
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.locks.length, remaining); assert.equal(c.balls, lives); assert.ok(m.waiting(c));
assert.notEqual(c.special, 'super'); assert.equal(c.superOpenedAt, null);
}
assert.equal(c.special, null); assert.equal(c.specialPaid, false);
});
test('il multiball ha contatti indipendenti, urti fra palline e palette che avanzano una sola volta', () => {
const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super');
const single = m.newFlipper(24); m.hold(single, 'left', 'test', true); m.hold(c, 'left', 'test', true);
m.update(single, m.STEP); m.update(c, m.STEP);
assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10);
const [a, b, spare] = m.activeBalls(c), bumper = c.bumpers[0];
spare.waiting = true;
Object.assign(a, { x: bumper.x, y: bumper.y + bumper.r + 4, vx: 0, vy: -200, ramp: null, contacts: [] });
Object.assign(b, { x: bumper.x, y: bumper.y - bumper.r - 4, vx: 0, vy: 200, ramp: null, contacts: [] });
const score = c.score; m.update(c, m.STEP);
assert.equal(c.score - score, m.SCORE.bumper * 2, 'due urti sullo stesso fungo contano separatamente');
Object.assign(a, { x: 310, y: 275, vx: 100, vy: 0 });
Object.assign(b, { x: 318, y: 275, vx: -100, vy: 0 });
m.update(c, m.STEP); assert.ok(a.vx < 0 && b.vx > 0, 'le palline si respingono');
const lives = c.balls;
Object.assign(a, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
assert.equal(c.balls, lives); assert.equal(m.activeBalls(c).length, 2);
});
test('i bersagli alzano il jackpot fino a x3 e la valvola impedisce il rientro sulla molla', () => {
const c = m.newFlipper(25); m.launch(c, 0.8);
for (const bank of ['pieve', 'torre']) for (let i = 0; i < 3; i++) {
const t = c.targets.find(t => t.bank === bank && t.i === i), dir = bank === 'pieve' ? -1 : 1;
put(c, { x: t.a[0] - dir * 5, y: (t.a[1] + t.b[1]) / 2, vx: dir * 200, vy: 0, ramp: null }); run(c, 0.02);
}
assert.equal(c.jackpotX, 3); doubleLock(c);
const before = c.score; rampShot(c, 'super'); assert.equal(c.score - before, m.SCORE.super * 3);
for (const [x, y] of [[434, 52], [438, 52], [441, 56]]) {
const d = m.newFlipper(26); m.launch(d, 0.8);
put(d, { x, y, vx: 0, vy: 240, inPlay: true });
let enteredShooter = false;
for (let i = 0; i < 240; i++) { m.update(d, m.STEP); if (d.ball.x > m.LANE.x0 + m.R && d.ball.y > 76) enteredShooter = true; }
assert.ok(!enteredShooter, `valvola chiusa per il ritorno a x=${x}`);
}
});
test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => {
@@ -332,15 +422,15 @@ test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giu
const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); };
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.filter(([op]) => op === 'arc').every(([, , , r]) => r > 0), 'anche viti e inserti piccoli hanno raggi validi per Canvas');
for (const r of c.ramps) {
for (const r of c.ramps.filter(r => r.id !== 'super')) {
const x = (r.mouth[0][0] + r.mouth[1][0]) / 2;
assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === 'RAMPA' && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === (r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO') && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
}
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'MANCANO 60.000'), 'quanto manca al traguardo, nella riga in basso');
assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste');
const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass && w === 200 && h === 360), 'il fondale sul fianco');
const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass ), 'il fondale sul fianco');
m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();
assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi');
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'PUNTEGGIO'), 'il nuovo display del punteggio');
c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!'));
// Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti).