Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
524 lines
36 KiB
JavaScript
524 lines
36 KiB
JavaScript
// 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, 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 (e poi si può continuare per il record). Unità: i pixel del primo piano. La fisica va a passo
|
||
// fisso (come il biliardo), con sottopassi quando pallina e flipper vanno forte.
|
||
// Rivista con la revisione di Codex del 31 ottobre: punti solo a un urto nuovo (non al contatto che dura), due dita
|
||
// sulla scena, annullare non lancia, niente cancelletto-ripiano, salvataggio col tempo della simulazione, corsie che
|
||
// contano quando si attraversano, traguardo acquisito appena raggiunto. Valvola di lancio a senso unico.
|
||
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, 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] };
|
||
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
|
||
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000, super: 250000 };
|
||
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
|
||
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
|
||
export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30, lockWindow: 20 };
|
||
// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta.
|
||
const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5;
|
||
// I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la
|
||
// gravità. La spinta dei funghi è uguale per tutti.
|
||
export const LEVELS = {
|
||
bona: { g: 680, save: 8, kick: 340, goal: 60000 },
|
||
seria: { g: 740, save: 4, kick: 340, goal: 120000 },
|
||
male: { g: 800, save: 1, kick: 340, goal: 200000 },
|
||
};
|
||
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, touching: false, ...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, col fondo. Un lancio che non è ancora entrato in campo si può ritentare;
|
||
// dopo l'ingresso la valvola inclinata impedisce il ritorno, senza creare un ripiano orizzontale.
|
||
walls.push(seg([432, 70], [432, 336]), seg([432, 326], [448, 326], { e: 0.2 }));
|
||
// Valvola inclinata: lascia uscire il lancio e devia a sinistra ogni rientro dal campo.
|
||
walls.push(seg([432, 70], [448, 60], { gate: true, e: 0.7 }));
|
||
// 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).
|
||
// Le uscite lasciano solo 14/12 pixel; lo spazio recuperato allarga le corsie di ritorno.
|
||
walls.push(seg([214, 240], [214, 290]), seg([214, 290], [292, 314], { e: 0.3 }));
|
||
walls.push(seg([420, 240], [420, 290]), seg([420, 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, touching: false });
|
||
const bumpers = [[281, 120], [360, 120], [320, 153]].map(([x, y]) => ({ x, y, r: 12, hit: 0, touching: false }));
|
||
// Il Regolo, in alto a destra sotto l'arco, dove la pallina scende dal giro.
|
||
const regolo = { x: 404, y: 62, r: 7, hit: 0, touching: false };
|
||
// Le corsie: ognuna fra due guide (o la sponda), con la bandiera dipinta in `x`; si accende attraversando in
|
||
// discesa la riga LANE_LINE.
|
||
const lanes = [[200, 277, 257], [277, 319, 297], [319, 361, 340], [361, 432, 381]].map(([x0, x1, x], i) => ({ x0, x1, x, y: LANE_LINE, rione: RIONI[i], lit: false }));
|
||
// Le due rampe: l'imbocco (un segmento che si attraversa in salita), il percorso sopra la tavola, l'uscita in
|
||
// fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
|
||
// in cima, come due ponti.
|
||
const ramps = [
|
||
// Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13.
|
||
{ id: 'sinistra', mouth: [[255, 226], [291, 226]], path: [[273, 226], [273, 196], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
|
||
{ id: 'destra', mouth: [[349, 226], [385, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
|
||
{ id: 'super', mouth: [[303, 232], [337, 232]], path: [[320, 232], [320, 188], [333, 145], [333, 96], [320, 72], [307, 96], [307, 145], [287, 168], [246, 180], [229, 198], [229, 220]], lit: 0, touching: false },
|
||
];
|
||
// Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
|
||
for (const r of ramps) {
|
||
r.len = 0; r.cum = [0];
|
||
for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); }
|
||
r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)];
|
||
}
|
||
// Ogni pallina ricorda i propri contatti: in multiball un urto non cancella quello dell'altra.
|
||
[...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
|
||
return { walls, targets, bumpers, regolo, lanes, ramps };
|
||
}
|
||
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
|
||
export function alongRamp(r, s) {
|
||
s = clamp(s, 0, r.len);
|
||
let i = 1; while (i < r.cum.length - 1 && r.cum[i] < s) i++;
|
||
const [ax, ay] = r.path[i - 1], [bx, by] = r.path[i], L = r.cum[i] - r.cum[i - 1] || 1, k = (s - r.cum[i - 1]) / L;
|
||
return { x: ax + (bx - ax) * k, y: ay + (by - ay) * k, dx: (bx - ax) / L, dy: (by - ay) / L };
|
||
}
|
||
|
||
export function newFlipper(seed = Date.now() >>> 0, def = {}) {
|
||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||
const c = { step: 'play', t: 0, sim: 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: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
|
||
ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1,
|
||
rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null,
|
||
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
|
||
newBall(c);
|
||
return c;
|
||
}
|
||
function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, inPlay: false, releaseAt: 0, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
|
||
function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; }
|
||
export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean);
|
||
export const multiball = c => c.extraBalls.length > 0;
|
||
export const rampOpen = (c, r) => r.id !== 'super' || c.special === 'super';
|
||
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
|
||
export const canThrow = c => waiting(c) && !c.holding;
|
||
export const canPress = canThrow;
|
||
// Due permessi diversi: il cursore della molla solo prima del lancio, i flipper per tutta la partita.
|
||
export const canAdjust = canThrow;
|
||
export const canFlip = c => c.step === 'play';
|
||
export const aimFree = () => true;
|
||
export const powerAt = time => clamp(time / CHARGE, 0, 1);
|
||
// Quanto resta del salvataggio della pallina, in secondi (0: non protetta).
|
||
export const saveLeft = c => c.ball && !c.ball.waiting ? Math.max(0, c.saveUntil - c.sim) : 0;
|
||
// Le scritte del tabellone: una alla volta, due secondi l'una; il traguardo passa davanti a tutte.
|
||
function say(c, line, front = false) {
|
||
if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; }
|
||
else c.lines.push(line);
|
||
}
|
||
// Gli effetti sulla tavola (Andrea: «più spettacolare»): anelli, scintille, coriandoli, luci che corrono, punti che
|
||
// volano, il lampo colorato e la scossa. Qui si segnano col loro tempo; li disegna flipper-render.js, e durano FX_LIFE.
|
||
export const FX_LIFE = 3;
|
||
function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.time, ...extra }); }
|
||
// I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si
|
||
// continua per il record.
|
||
function add(c, points, at) {
|
||
c.score += points;
|
||
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
|
||
if (!c.result && c.score >= c.skill.goal) {
|
||
c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true);
|
||
// La vittoria viene acquisita una volta sola: la pallina premio va nella riserva, senza fermare il tiro.
|
||
c.balls++; c.lines.unshift('PALLINA +1'); fx(c, 'pop', 324, 235, { text: '+1 PALLINA' });
|
||
c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0);
|
||
}
|
||
if (c.result) c.result.score = c.score;
|
||
}
|
||
|
||
// Il lancio: la molla spinge la pallina su per la corsia. Anche la molla più fiacca arriva all'arco (la velocità
|
||
// minima dipende dalla gravità del livello); da lì in su, la forza decide quanto gira.
|
||
export function launchSpeed(c, power) {
|
||
const min = Math.sqrt(2 * c.skill.g * 340), max = Math.max(min + 150, 900);
|
||
return min + (max - min) * clamp(power, 0, 1);
|
||
}
|
||
export function launch(c, power) {
|
||
if (!waiting(c)) return false;
|
||
// La molla vera non è mai identica due volte: un pelo di caso, così la partita non si ripete uguale.
|
||
c.ball.waiting = false; c.ball.vy = -launchSpeed(c, power) * (0.985 + random(c) * 0.03); c.ball.vx = 0;
|
||
// Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa.
|
||
c.saveUntil = c.saved ? 0 : c.sim + c.skill.save; c.holding = false; c.holdTime = 0; c.pointerId = null;
|
||
c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * clamp(power, 0, 1) });
|
||
return true;
|
||
}
|
||
// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno.
|
||
// Un «clic» (il pulsante attivato da tastiera) lo tiene su per un attimo, abbastanza perché la fisica lo veda.
|
||
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 (who === 'click' && down) f.click = CLICK_HOLD;
|
||
if (!was && f.held.length && c.step === 'play') c.events.push({ kind: 'flipper-flip', power: 0.6 });
|
||
return true;
|
||
}
|
||
export 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] };
|
||
}
|
||
|
||
// 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 = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } }
|
||
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.fx = c.fx.filter(f => c.visual.time - f.t < FX_LIFE);
|
||
c.accumulator += dt;
|
||
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
|
||
// La scia della pallina: le ultime posizioni, per la cometa.
|
||
for (const b of activeBalls(c)) {
|
||
if (!b.waiting && b.releaseAt <= c.sim) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0;
|
||
}
|
||
c.trail = c.ball?.trail ?? [];
|
||
}
|
||
function moveFlippers(c, h) {
|
||
for (const side of ['left', 'right']) {
|
||
const f = c.flippers[side];
|
||
if (f.click > 0 && (f.click -= h) <= 0) f.held = f.held.filter(k => k !== 'click');
|
||
const target = f.held.length ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest);
|
||
const previous = f.angle;
|
||
f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h);
|
||
f.omega = (f.angle - previous) / h;
|
||
}
|
||
}
|
||
function step(c) {
|
||
c.sim += STEP;
|
||
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP);
|
||
if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null;
|
||
if (c.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 };
|
||
if (c.step !== 'play') { moveFlippers(c, STEP); return; }
|
||
for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; }
|
||
// Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo.
|
||
const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp && b.releaseAt <= c.sim);
|
||
const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }]));
|
||
for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); }
|
||
const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy)));
|
||
const n = clamp(Math.ceil((speed + FLIP.speed * FLIP.len) * STEP / R), 1, 6), h = STEP / n;
|
||
// Le palette avanzano una sola volta per sottopasso, indipendentemente dal numero di palline.
|
||
for (let i = 0; i < n; i++) {
|
||
moveFlippers(c, h);
|
||
for (const ball of ground) {
|
||
ball.x += ball.vx * h; ball.y += ball.vy * h;
|
||
if (ball.x < LANE.x0 - R) ball.inPlay = true;
|
||
for (const w of c.walls) collideSeg(c, ball, w);
|
||
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
|
||
for (const b of c.bumpers) collideBumper(c, ball, b);
|
||
collideRegolo(c, ball);
|
||
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
|
||
limit(ball);
|
||
}
|
||
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
|
||
}
|
||
for (const ball of balls) {
|
||
if (ball.waiting || ball.releaseAt > c.sim) continue;
|
||
if (ball.ramp) { rideRamp(c, ball); continue; }
|
||
crossLanes(c, from.get(ball), ball);
|
||
enterRamps(c, from.get(ball), ball);
|
||
// L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
|
||
if (!ball.anchor || Math.hypot(ball.x - ball.anchor.x, ball.y - ball.anchor.y) > 3) { ball.anchor = { x: ball.x, y: ball.y }; ball.still = 0; }
|
||
else if ((ball.still += STEP) > NUDGE_AFTER) { ball.still = 0; ball.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
|
||
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) {
|
||
if ((ball.rest += STEP) > 0.3) {
|
||
// In multiball una ricaduta nella molla si rilancia da sola: i comandi restano alle palette.
|
||
if (multiball(c)) { ball.vx = 0; ball.vy = -launchSpeed(c, 0.7); ball.rest = 0; }
|
||
else newBall(c);
|
||
}
|
||
} else ball.rest = 0;
|
||
if (ball.y > FIELD.y1 + R * 2) drain(c, ball);
|
||
}
|
||
}
|
||
function collideBalls(a, b) {
|
||
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||
if (d >= R * 2) return;
|
||
const nx = d > 1e-6 ? dx / d : 1, ny = d > 1e-6 ? dy / d : 0, push = (R * 2 - d) / 2;
|
||
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
|
||
const speed = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||
if (speed > 0) { const impulse = speed * 0.95; a.vx -= nx * impulse; a.vy -= ny * impulse; b.vx += nx * impulse; b.vy += ny * impulse; }
|
||
limit(a); limit(b);
|
||
}
|
||
function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } }
|
||
const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R);
|
||
// Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata).
|
||
function crossLanes(c, from, ball) {
|
||
if (!(from.y < LANE_LINE && ball.y >= LANE_LINE)) return;
|
||
const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
|
||
const l = c.lanes.find(l => x >= l.x0 && x < l.x1);
|
||
if (!l || l.lit) return;
|
||
l.lit = true; add(c, SCORE.lane, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
|
||
if (c.lanes.every(k => k.lit)) {
|
||
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
|
||
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
|
||
}
|
||
}
|
||
// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il
|
||
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
|
||
// indietro e ricasca dall'imbocco.
|
||
function enterRamps(c, from, ball) {
|
||
for (const r of c.ramps) {
|
||
if (!rampOpen(c, r)) continue;
|
||
const [[x0, y], [x1]] = r.mouth;
|
||
if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue;
|
||
const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
|
||
if (x < x0 + R || x > x1 - R) continue;
|
||
// Conta solo la velocità lungo il ponte (sfiorarlo di traverso non fa salire).
|
||
const p = alongRamp(r, 0), forward = ball.vx * p.dx + ball.vy * p.dy;
|
||
if (forward < RAMP.speed) continue;
|
||
ball.ramp = { id: r.id, s: 0, v: forward * 0.95, off: x - p.x };
|
||
c.events.push({ kind: 'flipper-rampa', power: 0.7 });
|
||
return;
|
||
}
|
||
}
|
||
// Sul ponte: si sale fino alla cima perdendo velocità, si scende riprendendola (la stessa energia, meno un po'
|
||
// d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1).
|
||
export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); }
|
||
function rideRamp(c, ball) {
|
||
const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top;
|
||
on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP;
|
||
on.s += on.v * STEP;
|
||
const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend);
|
||
ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s);
|
||
if (on.s >= r.len) {
|
||
// In fondo al ponte: giù dall'altra parte, con la velocità che ha. Gli ostacoli lì sotto, se per caso la
|
||
// pallina esce sopra uno di loro, non contano come urto nuovo.
|
||
const v = Math.max(on.v, 150);
|
||
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
|
||
ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
|
||
if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]);
|
||
r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id });
|
||
if (rampFeature(c, r, ball)) return;
|
||
if (c.lastRamp && c.lastRamp.id !== r.id) {
|
||
add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-ponti', power: 1 }); c.lastRamp = null;
|
||
fx(c, 'burst', 324, 60, { big: true }); fx(c, 'wash', 0, 0, { color: '120,200,255' }); fx(c, 'shake', 0, 0); fx(c, 'chase', 0, 0, { ramp: c.ramps.find(k => k.id !== r.id).id });
|
||
}
|
||
else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; }
|
||
} else if (on.s <= 0) {
|
||
// Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte.
|
||
ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1;
|
||
} else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; }
|
||
}
|
||
// Tre passaggi completi sulla stessa rampa entro venti secondi. La finestra parte dal primo.
|
||
function announce(c, line) {
|
||
// Gli eventi di fase sostituiscono i vecchi annunci dei ponti; il premio del traguardo mantiene la precedenza.
|
||
const prize = c.lines.includes('PALLINA +1'); c.lines = [];
|
||
if (c.line === 'TRAGUARDO!') { if (prize) c.lines.push('PALLINA +1'); c.lines.push(line); }
|
||
else { c.line = null; say(c, line, true); if (prize) c.lines.push('PALLINA +1'); }
|
||
}
|
||
function celebrate(c, title, points, color) {
|
||
if (points) add(c, points, [320, 184]);
|
||
announce(c, title);
|
||
fx(c, 'celebrate', 320, 170, { text: title, points, color });
|
||
fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0);
|
||
}
|
||
function rampFeature(c, r, ball) {
|
||
if (r.id === 'super') {
|
||
// La rampa centrale esiste solo con entrambi i lock: premio e tre palline sul campo.
|
||
if (c.locks.length !== 2) return true;
|
||
for (const [i, lock] of c.locks.entries()) {
|
||
const released = lock.ball;
|
||
// Le uscite verticali centrano le corsie interne: nessuna spinta verso le buche laterali.
|
||
released.vx = 0; released.vy = 170; released.waiting = false;
|
||
released.trail = []; released.still = 0; released.anchor = null; released.rest = 0;
|
||
released.releaseAt = c.sim + i * 0.45; released.releasedFrom = lock.rampId;
|
||
c.extraBalls.push(released);
|
||
}
|
||
// Uscita laterale sopra la paletta sinistra, con velocità moderata; niente tiro dritto nella buca.
|
||
ball.vx = 0; ball.vy = 160;
|
||
c.locks = []; c.special = null; c.specialPaid = false; c.superOpenedAt = null; c.lastRamp = null;
|
||
c.rampRun = { id: null, count: 0, until: 0 }; c.saveUntil = 0; c.saved = true; c.balls++;
|
||
celebrate(c, 'SUPER SPECIAL', SCORE.super * c.jackpotX, '#e1a0ff');
|
||
say(c, 'PALLINA +1'); say(c, 'MULTIBALL!');
|
||
c.events.push({ kind: 'flipper-super', power: 1 });
|
||
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
|
||
return true;
|
||
}
|
||
if (multiball(c)) {
|
||
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
|
||
c.events.push({ kind: 'flipper-jackpot', power: 1 }); return true;
|
||
}
|
||
if (c.locks.some(lock => lock.rampId === r.id)) { c.rampRun = { id: null, count: 0, until: 0 }; say(c, 'LOCK OCCUPATO'); return true; }
|
||
if (c.special === r.id && !c.specialPaid) {
|
||
c.specialPaid = true;
|
||
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
|
||
c.events.push({ kind: 'flipper-jackpot', power: 1 });
|
||
}
|
||
const continuing = c.rampRun.id === r.id && c.sim <= c.rampRun.until;
|
||
c.rampRun = { id: r.id, count: continuing ? c.rampRun.count + 1 : 1, until: continuing ? c.rampRun.until : c.sim + RAMP.lockWindow };
|
||
if (c.rampRun.count < 3) return c.locks.length > 0;
|
||
c.locks.push({ rampId: r.id, ball }); ball.vx = 0; ball.vy = 0; ball.trail = [];
|
||
c.special = c.locks.length === 2 ? 'super' : c.ramps.find(other => other.id !== 'super' && other.id !== r.id).id;
|
||
c.rampRun = { id: null, count: 0, until: 0 }; c.lastRamp = null;
|
||
c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null;
|
||
newBall(c);
|
||
celebrate(c, c.locks.length === 2 ? 'SUPER APERTA' : 'BALL LOCKED', 0, c.locks.length === 2 ? '#e1a0ff' : '#72edff');
|
||
c.events.push({ kind: 'flipper-lock', power: 1 });
|
||
if (c.special === 'super') { c.superOpenedAt = c.visual.time; c.events.push({ kind: 'flipper-super-open', power: 1 }); }
|
||
return true;
|
||
}
|
||
function drain(c, ball) {
|
||
c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' });
|
||
if (multiball(c)) {
|
||
if (c.ball === ball) c.ball = c.extraBalls.shift();
|
||
else c.extraBalls = c.extraBalls.filter(b => b !== ball);
|
||
c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0, until: 0 };
|
||
say(c, multiball(c) ? 'DUE IN GIOCO' : 'UNA IN GIOCO'); return;
|
||
}
|
||
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
|
||
c.rampRun = { id: null, count: 0, until: 0 };
|
||
if (c.locks.length) {
|
||
// Si recupera l'ultimo lock. L'eventuale primo resta acquisito e non si scala la riserva.
|
||
c.locks.pop(); c.superOpenedAt = null;
|
||
c.special = c.locks.length ? c.ramps.find(r => r.id !== 'super' && r.id !== c.locks[0].rampId).id : null;
|
||
if (!c.locks.length) c.specialPaid = false;
|
||
c.lastRamp = null; c.saved = false; c.saveUntil = 0;
|
||
announce(c, 'LOCK LIBERO'); newBall(c); return;
|
||
}
|
||
c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1;
|
||
if (c.balls > 0) { say(c, `PALLINE: ${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 };
|
||
}
|
||
function contact(ball, object, hit) {
|
||
const index = ball.contacts.indexOf(object.contactId), fresh = index < 0;
|
||
if (hit && fresh) ball.contacts.push(object.contactId);
|
||
else if (!hit && !fresh) ball.contacts.splice(index, 1);
|
||
object.touching = hit;
|
||
return fresh;
|
||
}
|
||
// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la
|
||
// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi.
|
||
function hitSeg(ball, w, e) {
|
||
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) { contact(ball, w, false); return null; }
|
||
if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; }
|
||
ball.x = px + nx * R; ball.y = py + ny * R;
|
||
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, w, true);
|
||
if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
|
||
return { vn: -vn, nx, ny, fresh };
|
||
}
|
||
function collideSeg(c, ball, w) {
|
||
if (w.gate) {
|
||
if (!ball.inPlay) return;
|
||
const hit = hitSeg(ball, w, w.e);
|
||
if (hit && hit.vn > 0) ball.vx = Math.min(-100, ball.vx);
|
||
return;
|
||
}
|
||
if (!w.sling) { hitSeg(ball, w, w.e); return; }
|
||
// La sponda elastica: spinge via lungo la normale, tenendo la componente di strisciamento, e un po' a caso come
|
||
// la gomma vera; solo a un urto nuovo, e solo se picchia.
|
||
const hit = hitSeg(ball, w, null);
|
||
if (!hit || hit.vn <= 0) return;
|
||
if (hit.fresh && hit.vn > IMPACT) {
|
||
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30;
|
||
const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda
|
||
ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6;
|
||
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); fx(c, 'spark', ball.x, ball.y);
|
||
} else { ball.vx += 1.3 * hit.vn * hit.nx; ball.vy += 1.3 * hit.vn * hit.ny; }
|
||
}
|
||
function collideTarget(c, ball, t) {
|
||
const hit = hitSeg(ball, t, 0.5);
|
||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||
t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2);
|
||
const bank = c.targets.filter(k => k.bank === t.bank);
|
||
if (bank.every(k => k.down)) { c.jackpotX = Math.min(3, c.jackpotX + 1); fx(c, 'pop', 320, 205, { text: `JACKPOT X${c.jackpotX}` }); add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'IL TREBBIO!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
|
||
}
|
||
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
|
||
function hitCircle(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) { contact(ball, o, false); return null; }
|
||
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, fresh = contact(ball, o, true);
|
||
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
|
||
return { vn: -vn, nx, ny, fresh };
|
||
}
|
||
function collideBumper(c, ball, b) {
|
||
const hit = hitCircle(ball, b, 0.2);
|
||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2;
|
||
ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick;
|
||
b.hit = 0.15; add(c, SCORE.bumper, [b.x, b.y - 16]); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); fx(c, 'ring', b.x, b.y); fx(c, 'spark', ball.x, ball.y);
|
||
}
|
||
function collideRegolo(c, ball) {
|
||
const hit = hitCircle(ball, c.regolo, 0.7);
|
||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||
c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y);
|
||
}
|
||
// 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 (anche due dita).
|
||
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, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', 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;
|
||
}
|
||
// Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore).
|
||
export function cancel(c, pointerId) {
|
||
const all = pointerId === undefined;
|
||
if (all || c.pointerId === pointerId) { c.holding = false; c.holdTime = 0; c.pointerId = null; }
|
||
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
|
||
return true;
|
||
}
|
||
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), e le frecce.
|
||
export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true);
|
||
export function stopTurn(c, who = 'button') {
|
||
if (who === 'click') return false; // il colpo breve finisce da sé
|
||
const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false);
|
||
return l || r;
|
||
}
|
||
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 (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
|
||
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 goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
|
||
const feature = c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.';
|
||
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
|
||
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}`;
|
||
}
|