// 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}`; }