Il flipper del River: la tavola di Codex con San Piero dipinto (Fortezza, Pieve, torre, la Sieve fra i flipper, il Regolo con la corona, le bandiere dei rioni) e sopra le parti meccaniche del gioco: molla, arco, corsie dei rioni (tutte e quattro: PALIO), funghi, bersagli a caduta, Regolo, sponde elastiche, due flipper; fisica a passo fisso, tre palline, tre livelli (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 18:53:04 +01:00
co-authored by Claude Fable 5.1
parent 449c9511e1
commit 7076123fcf
28 changed files with 677 additions and 14 deletions
+151
View File
@@ -0,0 +1,151 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as m from '../src/engine/flipper.js';
import { drawFlipper } from '../src/engine/flipper-render.js';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); };
// Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento.
function autoplay(c, secs = 600) {
for (let i = 0; i < secs * 60 && c.step === 'play'; i++) {
if (m.waiting(c)) m.launch(c, 0.75);
const b = c.ball;
for (const side of ['left', 'right']) {
const [px, py] = m.PIVOT[side], dir = side === 'left' ? 1 : -1, rel = (b ? b.x - px : 0) * dir;
const near = b && !b.waiting && b.vy > 0 && b.y > py - 34 && b.y < py + 14 && rel > -6 && rel < 44;
if (near !== c.flippers[side].held.includes('ai')) m.hold(c, side, 'ai', near);
}
m.update(c, 1 / 60);
}
}
test('la tavola: tutto sta dentro il campo, le corsie sotto l\'arco, i funghi sui dischi del disegno', () => {
const c = m.newFlipper(1);
for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= m.FIELD.x0 && x <= m.FIELD.x1 && y >= m.FIELD.y0 && y <= m.FIELD.y1, `${w.a}-${w.b}`);
for (const b of c.bumpers) assert.ok(b.x > m.FIELD.x0 + 20 && b.x < m.LANE.x0 - 20);
assert.equal(c.lanes.length, 4); assert.deepEqual(c.lanes.map(l => l.rione), m.RIONI);
assert.equal(c.targets.length, 6); assert.equal(c.balls, m.BALLS);
assert.ok(m.waiting(c) && m.canThrow(c) && c.ball.x > m.LANE.x0, 'la pallina aspetta sulla molla');
});
test('il lancio: la molla manda la pallina su per la corsia, oltre il cancelletto, e non torna più nella corsia', () => {
const c = m.newFlipper(2, { level: 'bona' });
assert.ok(m.launch(c, 0.8)); assert.ok(!m.waiting(c));
let top = 999, left = false;
for (let i = 0; i < 180; i++) { m.update(c, 1 / 60); top = Math.min(top, c.ball?.y ?? 999); if (c.ball && c.ball.x < m.LANE.x0 - 30) left = true; }
assert.ok(top < 40, `è arrivata in cima (${top.toFixed(0)})`);
assert.ok(left, 'è uscita dalla corsia');
// Un lancio fiacco ricasca sulla molla e si rilancia.
const d = m.newFlipper(3, { level: 'bona' }); m.launch(d, 0); run(d, 3);
assert.ok(m.waiting(d) && d.balls === m.BALLS, 'tornata sulla molla, nessuna pallina persa');
});
test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una volta sola', () => {
const c = m.newFlipper(4, { level: 'seria' });
for (let n = 3; n >= 1; n--) {
assert.equal(c.balls, n);
m.launch(c, 0.9); c.saveUntil = 0; // niente salvataggio: si prova sotto
c.ball.x = 320; c.ball.y = 320; c.ball.vx = 0; c.ball.vy = 200; run(c, 1);
}
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.ok(!m.canThrow(c));
assert.match(m.status(c), /Ne servivano 12000/);
const d = m.newFlipper(5, { level: 'bona' });
m.launch(d, 0.9); d.ball.x = 320; d.ball.y = 320; d.ball.vx = 0; d.ball.vy = 200; run(d, 1);
assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla');
assert.match(d.line ?? '', /salvata/);
m.launch(d, 0.9); d.ball.x = 320; d.ball.y = 320; d.ball.vx = 0; d.ball.vy = 200; run(d, 1);
assert.equal(d.balls, 2, 'la seconda volta è persa davvero');
});
test('il flipper alzato rimanda su la pallina; abbassato la lascia scendere', () => {
const up = m.newFlipper(6, { level: 'seria' }); m.launch(up, 0.9);
Object.assign(up.ball, { x: 300, y: 300, vx: 0, vy: 150 });
m.hold(up, 'left', 'test', true);
let best = 0; for (let i = 0; i < 60; i++) { m.update(up, 1 / 60); best = Math.min(best, up.ball?.vy ?? 0); }
assert.ok(best < -250, `tirata su (vy ${best.toFixed(0)})`);
assert.ok(up.events.some(e => e.kind === 'flipper-flip'));
const down = m.newFlipper(6, { level: 'seria' }); m.launch(down, 0.9);
Object.assign(down.ball, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0;
run(down, 2);
assert.equal(down.balls, 2, 'senza flipper, giù nella Sieve');
});
test('funghi, bersagli, Regolo e corsie fanno punti; le quattro corsie insieme il Palio', () => {
const c = m.newFlipper(7, { level: 'bona' }); m.launch(c, 0.9);
const b = c.bumpers[0];
Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
assert.equal(c.score, m.SCORE.bumper); assert.ok(c.ball.vy > 0 && b.hit > 0, 'respinta');
assert.ok(c.events.some(e => e.kind === 'flipper-fungo'));
c.score = 0;
for (const t of c.targets.filter(k => k.bank === 'pieve')) { const before = c.score; Object.assign(c.ball, { x: t.a[0] + 10, y: (t.a[1] + t.b[1]) / 2, vx: -300, vy: 0 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(c.score > before, `caduto il ${t.i}`); }
assert.equal(c.score, 3 * m.SCORE.target + m.SCORE.bank);
assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'la fila si rialza');
assert.match(c.line, /Pieve/);
c.score = 0;
Object.assign(c.ball, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
assert.equal(c.score, m.SCORE.regolo);
c.score = 0;
for (const l of c.lanes) { Object.assign(c.ball, { x: l.x, y: l.y - 10, vx: 0, vy: 100 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(l.lit || c.lanes.every(k => !k.lit), l.rione); }
assert.equal(c.score, 4 * m.SCORE.lane + m.SCORE.palio);
assert.match(c.line, /PALIO/); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro');
});
test('comandi: tenere premuto carica la molla e lasciare lancia; in gioco la metà della scena è il flipper; tasti', () => {
const c = m.newFlipper(8, { level: 'bona' });
m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`);
assert.ok(!m.release(c, 2), 'un altro dito non lancia');
assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3));
m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4));
assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4));
assert.ok(m.cancel(c, 4)); assert.ok(!c.flippers.right.held.length);
// I pulsanti e le frecce.
assert.ok(m.turn(c, -1, 'button')); assert.ok(c.flippers.left.held.length); m.stopTurn(c, 'button'); assert.ok(!c.flippers.left.held.length);
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length);
assert.ok(!m.key(c, 'x', true));
// Spazio: carica e lancia; Invio: lancia con la forza del cursore.
const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d));
const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…');
});
test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => {
let won = 0;
for (let seed = 1; seed <= 8; seed++) {
const c = m.newFlipper(seed * 7919, { level: 'bona' });
autoplay(c);
assert.equal(c.step, 'result', `seme ${seed}: finita`);
if (c.result.winner === 'player') won++;
}
assert.ok(won >= 4, `vinte ${won} su 8`);
});
test('il disegno del flipper non cambia lo stato', () => {
const calls = [], gradient = { addColorStop() {} };
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
const c = m.newFlipper(11, { level: 'bona' }), bg = {};
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.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER'));
m.launch(c, 0.8); run(c, 1); draw();
c.step = 'result'; c.result = { winner: 'player', score: 7000, goal: 6000 }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!'));
});
test('al River: «Usa» o «Apri» il bar, il livello, la partita; vincendo il flag', () => {
const g = new Game(story); g.go('via_provinciale'); settle(g);
g.act('use', 'river'); settle(g);
assert.ok(g.options().some(o => o.text === 'Una partita seria.'));
g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g);
assert.equal(g.closeup?.id, 'flipper'); assert.equal(g.closeup.level, 'seria');
g.closeup.step = 'result'; g.closeup.result = { winner: 'player', score: 13000, goal: 12000 };
g.closeupClose(); settle(g);
assert.ok(g.state.flags.flipper_vinto);
g.act('open', 'river'); settle(g);
g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g);
assert.equal(g.closeup?.level, 'male');
g.closeupClose();
assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say);
});