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:
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user