diff --git a/docs/STATO.md b/docs/STATO.md index aaf495f..2981630 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -84,6 +84,16 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, imbocchi svasati allineati alla fisica, frecce animate e targhette RAMPA; funghi con ghiere metalliche, sponde smaltate, palette avorio con gomma rossa e Regolo verde con corona. Il traguardo e i punti mancanti restano visibili sotto il display, con una barra di avanzamento. Prove desktop e touch in `browser:flipper`. + Accessibilità dei tiri (Andrea): imbocchi delle rampe spostati verso il centro e allargati da 22 a 36 pixel + (spazio utile alla pallina da 13 a 27; ridotti dai primi 42 perché troppo larghi), pendenza e attrito ridotti per salire con colpi medi. Uscite laterali + ristrette da 22/14 a 14/12 pixel, con più spazio nelle corsie di ritorno alle palette. Test sui tiri obliqui, + decentrati e fuori bocca a tutti i livelli, e sulle discese laterali recuperate e ancora perdenti. + Al traguardo si vince una pallina extra, una sola volta per partita: il tiro prosegue e il premio si aggiunge + alla riserva, con avviso sul tabellone. Test a tutti i livelli anche raggiungendo il traguardo con l'ultima pallina. + Il LOCK (idea di Andrea, fatto da Codex e completato, 31 ottobre): lo stesso ponte tre volte di fila trattiene la + pallina nella serratura in fondo al ponte e ne dà una nuova sulla molla; l'altro ponte si accende SPECIAL; fatto, + JACKPOT (100.000), la pallina si libera e si gioca in MULTIBALL (due palline, ognuna coi suoi contatti e la sua scia; + persa una, resta l'altra senza scalare). Persa la pallina nuova prima dello SPECIAL, torna quella trattenuta. [DA ANDREA] un record per livello che resti salvato (suggerito da Codex). - **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio Mario, che mi ha fatto vivere tante avventure più belle della fantasia.» diff --git a/index.html b/index.html index d7b2aee..a4fcb72 100644 --- a/index.html +++ b/index.html @@ -310,14 +310,17 @@ sono i bastioni della Fortezza (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a destra, è il Regolo (10.000). I due ponti: cerca le targhette RAMPA e le frecce illuminate, ambra a sinistra e - azzurre a destra, appena sopra le sponde elastiche. Imboccali dal basso con un colpo deciso: la pallina ci sale sopra e + azzurre a destra, verso il centro sopra le sponde elastiche. Gli ingressi larghi accettano anche tiri un po' + decentrati: imboccali dal basso con un colpo di media forza, la pallina ci sale sopra e scende dall'altra parte, nella corsia di ritorno (15.000); l'altro ponte entro dodici secondi: i PONTI, con un - bonus di 50.000 in più. Il tasto destro del mouse è sempre il flipper di destra.
+ bonus di 50.000 in più. Lo stesso ponte tre volte di fila: LOCK, la pallina resta trattenuta in cima e te ne arriva + una nuova sulla molla, mentre l'altro ponte si accende SPECIAL; fallo e incassi il JACKPOT (100.000), la pallina + trattenuta si libera e giochi in MULTIBALL con due palline. Il tasto destro del mouse è sempre il flipper di destra.I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra. Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 60.000 punti, seria 120.000, - a fassi male 200.000; raggiunto il traguardo, si continua per il record.
+ a fassi male 200.000. Al traguardo vinci una pallina extra, una sola volta per partita, e continui per il record.La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 3366aa4..8e73bdc 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -4,7 +4,7 @@ // ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti // come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che // girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js). -import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js'; +import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball } from './flipper.js'; import { ambientHash as hash } from './ambient.js'; import { text, panel } from './bocce-render.js'; @@ -112,7 +112,7 @@ function rampEntrance(ctx, r, time, who) { } const rampCache = new Map(); function ramp(ctx, r, time, who) { - const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : who && who !== r.id && Math.sin(time * 6) > 0 ? 'invite' : 'base'; + const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : (who === r.id + '!' || (who && who !== r.id)) && Math.sin(time * 6) > 0 ? 'invite' : 'base'; if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; } const key = `${r.id}:${state}`; let layer = rampCache.get(key); @@ -161,6 +161,14 @@ function plunger(ctx, c) { for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1); ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4); } +// La serratura: la pallina trattenuta in fondo al ponte, dietro due sbarre, con la luce che pulsa. +function lock(ctx, b, time) { + ball(ctx, b); + ctx.strokeStyle = Math.sin(time * 6) > 0 ? '#ffd45a' : '#8a7a5a'; ctx.lineWidth = 1.5; + for (const dx of [-3, 3]) { ctx.beginPath(); ctx.moveTo(b.x + dx, b.y - 9); ctx.lineTo(b.x + dx, b.y + 7); ctx.stroke(); } + ctx.beginPath(); ctx.moveTo(b.x - 7, b.y - 9); ctx.lineTo(b.x + 7, b.y - 9); ctx.stroke(); + label(ctx, 'LOCK', b.x, b.y - 15, 5, '#ffe08c'); +} function ball(ctx, b) { if (!b) return; // Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana. @@ -261,13 +269,11 @@ function board(ctx, c, time, glass) { if (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh); ctx.restore(); - // Il traguardo resta leggibile anche mentre il display celebra un colpo. + // Il traguardo resta leggibile anche mentre il display celebra un colpo: una barra sottile sotto la finestra e, + // nella riga in basso, quanto manca (il pannello che c'era prima copriva il Trebbio dipinto). const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score); - panel(ctx, 20, 134, 160, 31); - label(ctx, `OBIETTIVO ${c.skill.goal.toLocaleString('it-IT')}`, 28, 142, 7, '#f1d7a1', 'left'); - label(ctx, remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO RAGGIUNTO', 28, 152, 6, remaining ? '#becbd0' : '#a9e7bf', 'left'); - ctx.fillStyle = '#30434a'; ctx.fillRect(28, 159, 144, 2); - ctx.fillStyle = '#edc777'; ctx.fillRect(28, 159, 144 * progress, 2); + ctx.fillStyle = '#1a1410'; ctx.fillRect(wx, wy + wh + 3, ww, 3); + ctx.fillStyle = remaining ? '#edc777' : '#a9e7bf'; ctx.fillRect(wx, wy + wh + 3, Math.round(ww * progress), 3); // I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia). c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a')); // I cinque dischi in basso: le palline che restano, e i quattro rioni accesi. @@ -276,7 +282,7 @@ function board(ctx, c, time, glass) { rioni.forEach(([x, y], i) => { const l = c.lanes[i]; disc(ctx, x, y, 11, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#8a7a5a'); }); // Sotto, in piccolo: il salvataggio o la combo. const save = saveLeft(c); - const note = save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : ''; + const note = multiball(c) ? 'MULTIBALL!' : c.locked ? 'LOCK: FAI IL PONTE SPECIAL' : c.rampRun?.count ? `PONTE ${c.rampRun.count}/3 PER IL LOCK` : save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO: +1 PALLINA'; if (note) text(ctx, note, 100, 352, 6, save > 0 ? '#9fd27a' : '#ffe08c', 'center'); } // La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al @@ -351,9 +357,9 @@ function shake(c, time) { return k > 0 ? [Math.round(Math.sin(time * 90) * 3 * k), Math.round(Math.cos(time * 70) * 2 * k)] : [0, 0]; } // La cometa: le ultime posizioni della pallina, sempre più tenui. -function trail(ctx, c) { - const n = c.trail.length; - c.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); }); +function trail(ctx, b) { + const n = b.trail.length; + b.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); }); ctx.globalAlpha = 1; } function result(ctx, c) { @@ -393,12 +399,16 @@ export function drawFlipper(ctx, c, def, bg, glass) { for (const r of c.ramps) rampEntrance(ctx, r, time, c.lastRamp?.id); for (const side of ['left', 'right']) flipper(ctx, c, side); plunger(ctx, c); - // La pallina (con la cometa) sotto i ponti, se è sulla tavola; sopra, se è su un ponte; poi gli effetti. - if (c.ball && !c.ball.waiting) trail(ctx, c); - if (!c.ball?.ramp) ball(ctx, c.ball); - for (const r of c.ramps) ramp(ctx, r, time, c.lastRamp?.id); - if (c.ball?.ramp) ball(ctx, c.ball); + // Le palline (con la cometa) sotto i ponti, se sono sulla tavola; sopra, se sono su un ponte; la pallina + // trattenuta (LOCK) nella serratura in fondo al suo ponte; poi gli effetti. + const balls = activeBalls(c), special = c.special ? c.special + '!' : c.lastRamp?.id; + for (const b of balls) if (!b.waiting && !b.ramp) { trail(ctx, b); ball(ctx, b); } + for (const b of balls) if (b.waiting) ball(ctx, b); + for (const r of c.ramps) ramp(ctx, r, time, special); + if (c.locked) lock(ctx, c.locked.ball, time); + for (const b of balls) if (b.ramp) { trail(ctx, b); ball(ctx, b); } drawFx(ctx, c, time); + if (c.special) { const r = c.ramps.find(k => k.id === c.special); if (Math.sin(time * 8) > 0) label(ctx, 'SPECIAL', (r.mouth[0][0] + r.mouth[1][0]) / 2, r.mouth[0][1] - 38, 6, '#fff6d6'); } // Da fermo, l'invito: la molla da caricare. if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center'); board(ctx, c, time, glass); diff --git a/src/engine/flipper.js b/src/engine/flipper.js index 7a7507f..478f1c9 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -12,7 +12,7 @@ export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 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 }; +export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000 }; // 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 }; @@ -64,9 +64,9 @@ export function layout() { // 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: 33 pixel utili alla pallina anziché 13. - { id: 'sinistra', mouth: [[252, 226], [294, 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: [[346, 226], [388, 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 }, + // 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 }, ]; // Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende. for (const r of ramps) { @@ -74,6 +74,8 @@ export function layout() { 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ì. @@ -89,11 +91,15 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) { 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, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() }; + ball: null, extraBalls: [], locked: null, special: null, rampRun: { id: null, count: 0 }, + saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], 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, ramp: null }; c.still = 0; c.anchor = null; c.rest = 0; } +function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, 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 waiting = c => c.step === 'play' && !!c.ball?.waiting; export const canThrow = c => waiting(c) && !c.holding; export const canPress = canThrow; @@ -120,6 +126,8 @@ function add(c, points, at) { 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; @@ -166,7 +174,10 @@ export function update(c, dt) { c.accumulator += dt; while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); } // La scia della pallina: le ultime posizioni, per la cometa. - if (c.ball && !c.ball.waiting) { c.trail.push([c.ball.x, c.ball.y]); if (c.trail.length > 10) c.trail.shift(); } else c.trail.length = 0; + for (const b of activeBalls(c)) { + if (!b.waiting) { 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']) { @@ -180,36 +191,56 @@ function moveFlippers(c, h) { } function step(c) { c.sim += STEP; - const ball = c.ball; 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.step !== 'play' || !ball || ball.waiting) { moveFlippers(c, STEP); return; } + 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]; } - if (ball.ramp) { moveFlippers(c, STEP); rideRamp(c, ball); return; } - ball.vy += c.skill.g * STEP; - limit(ball); - // Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo. - const tip = FLIP.speed * FLIP.len, n = clamp(Math.ceil((Math.hypot(ball.vx, ball.vy) + tip) * STEP / R), 1, 6), h = STEP / n; - const from = { x: ball.x, y: ball.y }; + // 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); + 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); - ball.x += ball.vx * h; ball.y += ball.vy * h; - 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 (const ball of ground) { + ball.x += ball.vx * h; ball.y += ball.vy * h; + 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]); } - crossLanes(c, from, ball); - enterRamps(c, from, ball); - // La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è - // trattenuta su un flipper alzato: quella è una mossa del giocatore. - 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) > NUDGE_AFTER) { c.still = 0; c.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } } - // Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia. - if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) { if ((c.rest += STEP) > 0.3) newBall(c); } else c.rest = 0; - if (ball.y > FIELD.y1 + R * 2) drain(c); + for (const ball of balls) { + if (ball.waiting) 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); @@ -256,8 +287,9 @@ function rideRamp(c, ball) { // 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; - for (const o of [...c.bumpers, c.regolo, ...c.targets, ...c.walls]) o.touching = true; + ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId); 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 }); @@ -268,25 +300,68 @@ function rideRamp(c, ball) { 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; } } -function drain(c) { +// La sequenza considera i passaggi completi: bersagli e rimbalzi non la interrompono, l'altra rampa sì. +function rampFeature(c, r, ball) { + if (c.locked) { + if (c.special !== r.id) { say(c, 'TENTA SPECIAL'); return true; } + const released = c.locked.ball; + released.vx = 0; released.vy = 170; released.waiting = false; + released.trail = []; released.still = 0; released.anchor = null; released.rest = 0; + c.extraBalls.push(released); c.locked = null; c.special = null; c.lastRamp = null; + c.rampRun = { id: null, count: 0 }; c.saveUntil = 0; c.saved = true; + add(c, SCORE.jackpot, [324, 190]); say(c, 'JACKPOT!', c.line !== 'TRAGUARDO!'); say(c, 'MULTIBALL!'); + c.events.push({ kind: 'flipper-jackpot', power: 1 }); fx(c, 'burst', 324, 130, { big: true }); fx(c, 'shake', 0, 0); + for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); } + return true; + } + if (multiball(c)) return false; + c.rampRun = { id: r.id, count: c.rampRun.id === r.id ? c.rampRun.count + 1 : 1 }; + if (c.rampRun.count < 3) return false; + // La pallina resta visibile nella serratura all'uscita della rampa. Il nuovo lancio è gratuito. + c.locked = { rampId: r.id, ball }; ball.vx = 0; ball.vy = 0; ball.trail = []; + c.special = c.ramps.find(other => other.id !== r.id).id; c.lastRamp = null; + c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null; + newBall(c); say(c, 'LOCK!', c.line !== 'TRAGUARDO!'); say(c, 'SPECIAL!'); + c.events.push({ kind: 'flipper-lock', power: 1 }); fx(c, 'ring', ball.x, ball.y); + 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 }; + say(c, 'UNA IN GIOCO'); return; + } if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; } + c.rampRun = { id: null, count: 0 }; + if (c.locked) { + // Perduta la sostitutiva: si recupera quella trattenuta, senza scalare una vita o lasciare lock sospesi. + c.locked = null; c.special = null; c.lastRamp = null; c.saved = false; c.saveUntil = 0; + say(c, 'LOCK LIBERO', true); newBall(c); return; + } c.balls--; c.saved = false; c.lastRamp = null; 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) { w.touching = false; return null; } + 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 = !w.touching; - w.touching = true; + 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 }; } @@ -313,11 +388,10 @@ function collideTarget(c, ball, t) { // 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) { o.touching = false; return null; } + 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 = !o.touching; - o.touching = true; + 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 }; } @@ -390,8 +464,9 @@ 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, ora il record' : `traguardo ${c.skill.goal}`; - const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.`; + const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`; + const feature = c.locked ? `LOCK: pallina trattenuta. Rampa ${c.special === 'sinistra' ? 'sinistra' : 'destra'} SPECIAL: jackpot ${SCORE.jackpot} e multiball.` : multiball(c) ? 'MULTIBALL: due palline in gioco!' : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3 per il 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}`; } diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 8f98e27..055f68b 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -171,6 +171,8 @@ export class Soundscape { else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan); else if (kind === 'flipper-ponti') for (const [i, f] of [392, 523, 659, 784, 1046, 1318].entries()) this.tone(f, f, 0.16, 0.06, i * 0.08, pan); else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); } + else if (kind === 'flipper-lock') { this.tone(330, 330, 0.25, 0.06, 0, pan); this.tone(220, 220, 0.4, 0.06, 0.2, pan); this.noiseSound(0.08, 1200, 0.05, { pan }); } + else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, pan); } else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); } else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); } else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } diff --git a/src/story/piloti.js b/src/story/piloti.js index 7625cb0..7c7d8f8 100644 --- a/src/story/piloti.js +++ b/src/story/piloti.js @@ -24,8 +24,8 @@ export const piloti = [ // Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea). { id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' }, // Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di - // Andrea); il ritratto di Codex dalla sua foto. [DA ANDREA: motore e testa] - { id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 3, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' }, + // Andrea); il ritratto di Codex dalla sua foto. In curva prudente (Andrea, 31 ottobre). [DA ANDREA: il motore] + { id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 5, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' }, // Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea). { id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' }, // La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa] diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index 55d5c08..760c68a 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -83,10 +83,31 @@ try { await page.click('#resume'); await page.waitForFunction(() => document.querySelector('#pause').hidden); } - // La fine: si fa finire la partita e si guarda il quadro. - await page.evaluate(() => { const c = game.closeup; c.score = 7350; c.step = 'result'; c.ball = null; c.result = { winner: 'player', score: 7350, goal: c.skill.goal }; }); + // Il traguardo aggiunge una pallina e lascia continuare la partita. + const reward = await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'), c = game.closeup; + m.cancel(c); if (m.waiting(c)) m.launch(c, 0.8); + c.balls = 1; c.score = c.skill.goal - m.SCORE.bumper; c.saveUntil = 0; + const b = c.bumpers[0]; b.touching = false; + Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200, waiting: false, ramp: null }); + m.update(c, 0.05); + return { balls: c.balls, step: c.step, winner: c.result?.winner }; + }); + assert.deepEqual(reward, { balls: 2, step: 'play', winner: 'player' }); + await page.waitForFunction(() => document.querySelector('#bocce-status').textContent.includes('pallina premio vinta')); + await page.screenshot({ path: `${out}/${name}-premio.png` }); + // Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale. + const finalScore = await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'), c = game.closeup; + for (let i = 0; i < 4 && c.step === 'play'; i++) { + if (m.waiting(c)) m.launch(c, 0.8); + c.saveUntil = 0; + Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); m.update(c, 0.1); + } + return c.score; + }); await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita'); - assert.match(await page.locator('#bocce-status').textContent(), /7350 punti/); + assert.ok((await page.locator('#bocce-status').textContent()).includes(`${finalScore} punti`)); await page.screenshot({ path: `${out}/${name}-fine.png` }); if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup); diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index 26db920..5c86e8a 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -170,6 +170,27 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`); }); +test('il traguardo assegna una sola pallina premio, utilizzabile anche se si era all\'ultima, a tutti i livelli', () => { + for (const level of Object.keys(m.LEVELS)) for (const missing of [m.SCORE.bumper, 500]) { + const c = m.newFlipper(85, { level }); m.launch(c, 0.8); + c.balls = 1; c.score = c.skill.goal - missing; + const currentBall = c.ball, b = c.bumpers[0]; + put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05); + assert.equal(c.balls, 2, `${level}: una pallina in più, raggiungendo o superando la soglia`); + assert.equal(c.ball, currentBall, 'il tiro in corso prosegue'); + assert.equal(c.step, 'play'); assert.equal(c.result.winner, 'player'); + assert.equal(c.line, 'TRAGUARDO!'); assert.equal(c.lines[0], 'PALLINA +1'); + assert.match(m.status(c), /pallina premio vinta/); + put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05); + assert.ok(c.score > c.skill.goal); assert.equal(c.balls, 2, 'altri punti non assegnano altri premi'); + c.saveUntil = 0; put(c, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); run(c, 0.1); + assert.equal(c.balls, 1); assert.ok(m.waiting(c), 'persa l\'ultima originale, si può lanciare la pallina premio'); + m.launch(c, 0.8); c.saveUntil = 0; + put(c, { x: 320, y: 342, vx: 0, vy: 300 }); run(c, 0.1); + assert.equal(c.balls, 0); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player'); + } +}); + test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => { const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0; const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 }); @@ -201,6 +222,40 @@ test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nel assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa'); }); +test('le rampe centrali si prendono con tiri obliqui dalle palette e anche fuori centro, a tutti i livelli', () => { + for (const level of Object.keys(m.LEVELS)) for (const [index, side] of [[0, -1], [1, 1]]) { + const c = m.newFlipper(81, { level }); m.launch(c, 0.8); + ride(c, { x: 320 + side * 20, y: 290, vx: side * 90, vy: -460 }); + assert.equal(c.score, m.SCORE.ramp, `${level}: tiro obliquo dalla zona delle palette, lato ${side}`); + assert.ok(c.ramps[index].lit > 0); + for (const offset of [-12, 12]) { + const d = m.newFlipper(82, { level }); m.launch(d, 0.8); + const r = d.ramps[index]; + ride(d, { x: r.path[0][0] + offset, y: r.mouth[0][1] + 1, vx: 0, vy: -330 }); + assert.equal(d.score, m.SCORE.ramp, `${level}: basta un colpo medio, decentrato di ${offset}`); + } + const e = m.newFlipper(83, { level }); m.launch(e, 0.8); + const r = e.ramps[index]; + put(e, { x: r.mouth[0][0] + m.R - 1, y: r.mouth[0][1] + 1, vx: 0, vy: -330 }); run(e, 0.05); + assert.equal(e.ball.ramp, null, 'fuori dalla bocca disegnata la pallina non viene catturata'); + } +}); + +test('le uscite laterali sono più strette: i tiri vicini rientrano verso le palette, quelli esterni si perdono ancora', () => { + for (const [x, safe] of [[218, true], [419, true], [207, false], [426, false]]) { + const c = m.newFlipper(84); m.launch(c, 0.8); c.saveUntil = 0; + put(c, { x, y: 250, vx: 0, vy: 100 }); + let lower = null; + for (let i = 0; i < 2 / m.STEP && c.balls === 3; i++) { + m.update(c, m.STEP); + if (!lower && c.ball?.y >= 300) lower = { x: c.ball.x }; + } + assert.ok(lower, `la pallina ${x} non resta incastrata nella corsia`); + if (safe) assert.ok(lower.x > 250 && lower.x < 390, `${x} torna verso le palette (${lower.x})`); + else { assert.equal(lower.x, x, 'la corsia esterna resta aperta'); assert.equal(c.balls, 2); } + } +}); + test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => { const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9); const kinds = () => c.fx.map(f => f.kind); @@ -222,6 +277,43 @@ test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena'); }); +test('il LOCK: lo stesso ponte tre volte di fila trattiene la pallina e ne dà una nuova; l\'altro ponte SPECIAL vale il jackpot e il multiball', () => { + const c = m.newFlipper(21, { level: 'bona' }); m.launch(c, 0.9); c.score = 0; + const left = c.ramps[0], right = c.ramps[1]; + // Un tiro nel ponte: si aspetta che la pallina esca in fondo, prima che vada altrove (così il conto non si azzera). + const shoot = r => { const [[x0], [x1]] = r.mouth; put(c, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(c, 1 / 60); if (c.ball?.ramp) was = true; else if (was) break; } }; + shoot(left); assert.equal(c.rampRun.count, 1); assert.ok(!c.locked); + shoot(left); assert.equal(c.rampRun.count, 2); + shoot(left); + assert.ok(c.locked && c.locked.rampId === 'sinistra', 'la pallina è trattenuta'); + assert.equal(c.special, 'destra', 'l\'altro ponte è SPECIAL'); + assert.ok(m.waiting(c) && c.ball !== c.locked.ball, 'una pallina nuova sulla molla'); + assert.ok([c.line, ...c.lines].some(l => /LOCK/.test(l ?? '')) && [c.line, ...c.lines].some(l => /SPECIAL/.test(l ?? ''))); + assert.ok(c.events.some(e => e.kind === 'flipper-lock')); + assert.equal(c.balls, 3, 'il lock non costa una pallina'); + // Il ponte sbagliato non libera niente. + m.launch(c, 0.9); c.events.length = 0; shoot(left); + assert.ok(c.locked, 'ancora trattenuta'); + // Il ponte SPECIAL: jackpot, la pallina liberata, due in gioco. + const before = c.score; shoot(right); + assert.ok(c.score - before >= m.SCORE.jackpot + m.SCORE.ramp, `jackpot (${c.score - before})`); + assert.ok(!c.locked && !c.special, 'liberata'); + assert.ok(m.multiball(c) && m.activeBalls(c).length === 2, 'multiball'); + assert.ok(c.events.some(e => e.kind === 'flipper-jackpot')); + // In multiball perdere una pallina non costa: resta l'altra; persa anche quella, si scala. + const riserva = c.balls; // (il jackpot ha passato il traguardo: una pallina premio in più) + Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5); + assert.ok(!m.multiball(c) && c.balls === riserva && m.activeBalls(c).length === 1, 'una in gioco, nessuna persa'); + c.saveUntil = 0; Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5); + assert.equal(c.balls, riserva - 1, 'ora sì'); + // Persa la pallina nuova prima dello SPECIAL: quella trattenuta torna, senza scalare. + const d = m.newFlipper(22, { level: 'bona' }); m.launch(d, 0.9); + const go = r => { const [[x0], [x1]] = r.mouth; put(d, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(d, 1 / 60); if (d.ball?.ramp) was = true; else if (was) break; } }; + go(d.ramps[0]); go(d.ramps[0]); go(d.ramps[0]); assert.ok(d.locked); + m.launch(d, 0.9); d.saveUntil = 0; Object.assign(d.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(d, 0.5); + assert.ok(!d.locked && d.balls === 3 && m.waiting(d), 'lock libero, pallina di nuovo sulla molla'); +}); + test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { let won = 0; for (let seed = 1; seed <= 8; seed++) {