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
+231
View File
@@ -0,0 +1,231 @@
// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»). Visto dall'alto, la tavola
// in verticale al centro del primo piano 640×360; la pallina parte dalla corsia di destra con la molla, fa il giro
// dell'arco in cima, passa in una delle quattro corsie dei rioni, rimbalza sui tre funghi (i bastioni della Fortezza),
// abbatte i bersagli della Pieve a sinistra e della torre a destra, tocca il Regolo in mezzo, e giù dalle sponde
// elastiche ai due flipper, con la Sieve dipinta che scorre nel buco in mezzo. Tre palline; si vince arrivando al
// traguardo di punti del livello. Unità: i pixel del primo piano. La fisica va a passo fisso (come il biliardo).
export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5;
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, LAUNCH = [420, 900], CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
export const PIVOT = { left: [280, 314], right: [360, 314] };
export const SCORE = { bumper: 100, sling: 50, lane: 200, palio: 3000, target: 500, bank: 2000, regolo: 1000 };
// I livelli: la gravità, quanti secondi dura il salvataggio della pallina appena lanciata, la spinta dei funghi, il traguardo.
export const LEVELS = {
bona: { g: 620, save: 8, kick: 300, goal: 6000 },
seria: { g: 780, save: 3, kick: 340, goal: 12000 },
male: { g: 880, save: 0, kick: 380, goal: 25000 },
};
export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio'];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
const seg = (a, b, extra = {}) => ({ a, b, e: 0.55, ...extra });
// La tavola: sponde, corsie, sponde elastiche, funghi, bersagli. Le coordinate seguono il disegno di Codex
// (assets/closeups/river-flipper.png): i tre dischi dei funghi, le quattro bandiere dei rioni, la corsia della molla.
export function layout() {
const arc = [[200, 72], [203, 50], [212, 32], [228, 18], [252, 10], [288, 6], [324, 5], [360, 6], [396, 10], [420, 18], [436, 32], [445, 50], [448, 72]];
const walls = [];
for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 }));
walls.push(seg([200, 72], [200, 336]), seg([448, 72], [448, 336]));
// La corsia della molla, e in cima il cancelletto che non fa tornare indietro la pallina.
walls.push(seg([432, 70], [432, 336]), seg([432, 70], [448, 70], { gate: true }), seg([432, 326], [448, 326], { e: 0.2 }));
// Le corsie dei rioni, in cima, fra le bandiere: tre guide.
for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 }));
// I divisori delle uscite laterali e le guide che portano ai flipper.
// (La guida passa sopra il perno e continua un po' lungo il flipper: finendo sul perno la pallina ci restava incastrata.)
walls.push(seg([222, 240], [222, 290]), seg([222, 290], [292, 314], { e: 0.3 }));
walls.push(seg([418, 240], [418, 290]), seg([418, 290], [348, 314], { e: 0.3 }));
// Le sponde elastiche sopra i flipper: il lato lungo spinge.
walls.push(seg([236, 246], [236, 284]), seg([236, 284], [262, 290]), seg([236, 246], [262, 290], { sling: true }));
walls.push(seg([404, 246], [404, 284]), seg([404, 284], [378, 290]), seg([404, 246], [378, 290], { sling: true }));
// I bersagli a caduta: la Pieve a sinistra, la torre a destra, con la sponda dietro.
walls.push(seg([210, 146], [210, 198]), seg([429, 146], [429, 198]));
const targets = [];
for (const [bank, x] of [['pieve', 216], ['torre', 423]]) for (let i = 0; i < 3; i++) targets.push({ bank, i, a: [x, 150 + i * 16], b: [x, 162 + i * 16], down: false });
const bumpers = [[281, 120], [360, 120], [320, 153]].map(([x, y]) => ({ x, y, r: 12, hit: 0 }));
// Il Regolo, in alto a destra sotto l'arco, dove la pallina scende dal giro.
const regolo = { x: 404, y: 62, r: 6, hit: 0 };
const lanes = [257, 297, 340, 381].map((x, i) => ({ x, y: 88, rione: RIONI[i], lit: false }));
return { walls, targets, bumpers, regolo, lanes };
}
export function newFlipper(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'play', t: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7,
holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false,
flippers: { left: { angle: FLIP.rest, omega: 0, held: [] }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [] } },
ball: null, saveUntil: 0, still: 0, visual: { time: 0 }, ...layout() };
newBall(c);
return c;
}
function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true }; c.still = 0; c.anchor = null; }
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
export const canThrow = c => waiting(c) && !c.holding;
export const canPress = canThrow;
export const canAdjust = c => c.step !== 'result';
export const aimFree = () => true;
export const powerAt = time => clamp(time / CHARGE, 0, 1);
function say(c, line) { c.line = line; c.lineT = 2; }
function add(c, points) { c.score += points; }
// Il lancio: la molla spinge la pallina su per la corsia.
export function launch(c, power) {
if (!waiting(c)) return false;
c.ball.waiting = false; c.ball.vy = -(LAUNCH[0] + (LAUNCH[1] - LAUNCH[0]) * clamp(power, 0, 1)); c.ball.vx = 0;
// Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa.
c.saveUntil = c.saved ? 0 : c.t + c.skill.save; c.holding = false; c.holdTime = 0;
c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * power });
return true;
}
// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno.
export function hold(c, side, who, down) {
const f = c.flippers[side]; if (!f) return false;
const was = f.held.length > 0;
f.held = f.held.filter(h => h !== who); if (down) f.held.push(who);
if (!was && f.held.length && c.step === 'play') c.events.push({ kind: 'flipper-flip', power: 0.6 });
return true;
}
// La fisica, a passi fissi.
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
if (c.lineT > 0 && (c.lineT -= dt) <= 0) c.line = null;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt);
c.accumulator += dt;
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
}
function flipperSeg(c, side) {
const f = c.flippers[side], [px, py] = PIVOT[side];
return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] };
}
function step(c) {
// I flipper: su se tenuti, giù se no; velocità angolare costante.
for (const side of ['left', 'right']) {
const f = c.flippers[side];
const target = f.held.length ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest);
const d = target - f.angle, move = FLIP.speed * STEP;
f.omega = Math.abs(d) <= move ? 0 : Math.sign(d) * FLIP.speed;
f.angle = Math.abs(d) <= move ? target : f.angle + Math.sign(d) * move;
}
const ball = c.ball;
if (c.step !== 'play' || !ball || ball.waiting) return;
ball.vy += c.skill.g * STEP;
const v = Math.hypot(ball.vx, ball.vy);
if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; }
ball.x += ball.vx * STEP; ball.y += ball.vy * STEP;
for (const w of c.walls) collideSeg(c, ball, w);
for (const t of c.targets) if (!t.down && collideSeg(c, ball, { a: t.a, b: t.b, e: 0.5 })) dropTarget(c, t);
for (const b of c.bumpers) if (collideCircle(ball, b, 0)) { kick(c, ball, b, c.skill.kick); b.hit = 0.15; add(c, SCORE.bumper); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); }
if (collideCircle(ball, c.regolo, 0.7)) { c.regolo.hit = 0.3; add(c, SCORE.regolo); say(c, 'Il Regolo!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); }
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
// Le corsie si accendono entrando (non restandoci sopra: dopo il Palio ripartono spente).
for (const l of c.lanes) {
const inside = Math.hypot(ball.x - l.x, ball.y - l.y) < 7;
if (inside && !l.inside && !l.lit) {
l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 });
if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO! Tutti e quattro i rioni!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; }
}
l.inside = inside;
}
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa.
if (!c.anchor || Math.hypot(ball.x - c.anchor.x, ball.y - c.anchor.y) > 3) { c.anchor = { x: ball.x, y: ball.y }; c.still = 0; }
else if ((c.still += STEP) > 2.5) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; c.still = 0; c.anchor = null; }
// Un lancio fiacco ricasca sulla molla: si rilancia.
if (ball.x > LANE.x0 && ball.y > 315 && Math.abs(ball.vy) < 8 && !ball.waiting) newBall(c);
if (ball.y > FIELD.y1 + R * 2) drain(c);
}
function dropTarget(c, t) {
t.down = true; add(c, SCORE.target); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 });
const bank = c.targets.filter(k => k.bank === t.bank);
if (bank.every(k => k.down)) { add(c, SCORE.bank); say(c, t.bank === 'pieve' ? 'La Pieve!' : 'La torre!'); for (const k of bank) k.down = false; }
}
function drain(c) {
c.events.push({ kind: 'flipper-buco', power: 0.8 });
if (c.t < c.saveUntil) { say(c, 'Pallina salvata!'); c.saved = true; newBall(c); return; }
c.balls--; c.saved = false;
if (c.balls > 0) { say(c, `Pallina persa. Ne restano ${c.balls}.`); newBall(c); return; }
c.ball = null; c.step = 'result';
c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal };
}
// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna true se ha toccato.
function collideSeg(c, ball, w) {
const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1;
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny);
if (d >= R) return false;
if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; }
// Il cancelletto ferma solo chi scende dall'alto.
if (w.gate && !(ny < 0 && ball.vy > 0)) return false;
ball.x = px + nx * R; ball.y = py + ny * R;
const vn = ball.vx * nx + ball.vy * ny;
if (vn < 0) {
// La sponda elastica spinge via, un po' a caso come la gomma vera (così la pallina non fa il ping-pong in eterno).
if (w.sling) { const a = Math.atan2(ny, nx) + (random(c) - 0.5) * 0.5, s = 230 + random(c) * 40; ball.vx = Math.cos(a) * s + (ball.vx - vn * nx) * 0.3; ball.vy = Math.sin(a) * s + (ball.vy - vn * ny) * 0.3; add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); }
else { ball.vx -= (1 + w.e) * vn * nx; ball.vy -= (1 + w.e) * vn * ny; }
}
return true;
}
function collideCircle(ball, o, e) {
const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r;
if (d >= min) return false;
const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1;
ball.x = o.x + nx * min; ball.y = o.y + ny * min;
const vn = ball.vx * nx + ball.vy * ny;
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
return true;
}
function kick(c, ball, o, speed) {
const a = Math.atan2(ball.y - o.y, ball.x - o.x) + (random(c) - 0.5) * 0.3;
ball.vx = Math.cos(a) * speed; ball.vy = Math.sin(a) * speed;
}
// Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato.
function collideFlipper(c, ball, side) {
const f = c.flippers[side], { a, b } = flipperSeg(c, side), [ax, ay] = a, dx = b[0] - ax, dy = b[1] - ay, len2 = dx * dx + dy * dy;
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny), min = R + FLIP.r;
if (d >= min) return false;
if (d < 1e-6) { nx = 0; ny = -1; } else { nx /= d; ny /= d; }
ball.x = px + nx * min; ball.y = py + ny * min;
// La velocità della superficie: ω × r, col raggio dal perno al punto toccato.
const rx = px - ax, ry = py - ay, sx = -f.omega * ry, sy = f.omega * rx;
const vn = (ball.vx - sx) * nx + (ball.vy - sy) * ny;
if (vn < 0) { ball.vx -= (1 + 0.45) * vn * nx; ball.vy -= (1 + 0.45) * vn * ny; }
return true;
}
// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco
// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto.
export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; }
export function press(c, pointerId = 'ptr') {
if (c.step !== 'play') return false;
if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; }
return hold(c, (c.cursor?.[0] ?? 320) < 320 ? 'left' : 'right', pointerId, true);
}
export function release(c, pointerId = 'ptr') {
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
let any = false;
for (const side of ['left', 'right']) if (c.flippers[side].held.includes(pointerId)) any = hold(c, side, pointerId, false) || any;
return any;
}
export const cancel = (c, pointerId) => pointerId === undefined ? release(c, c.pointerId ?? 'ptr') : release(c, pointerId);
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti), e le frecce.
export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true);
export const stopTurn = (c, who = 'button') => hold(c, 'left', who, false) | hold(c, 'right', who, false);
export const nudge = () => false;
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
export function key(c, k, down) {
if (k === 'ArrowLeft' || k === 'ArrowRight') { hold(c, k === 'ArrowLeft' ? 'left' : 'right', 'key-' + k, down); return true; }
if (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
if (k === 'Enter') { if (down) shoot(c); return true; }
return false;
}
// Il pulsante Lancia: con la forza del cursore.
export const shoot = c => canThrow(c) && launch(c, c.power);
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
export function status(c) {
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
const tally = `Punti ${c.score} (traguardo ${c.skill.goal}), palline ${c.balls}.`;
if (c.line) return `${c.line} ${tally}`;
return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`;
}