diff --git a/docs/STATO.md b/docs/STATO.md index b54abf4..78e5e4f 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -18,6 +18,15 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, ## Dove siamo arrivati +- **Flipper, il risveglio del Regolo (2 novembre)**: skill shot da 25.000 sulla prima corsia lampeggiante del lancio, + spinner RIVER da 500 a giro, kickback monouso sul lato del banco completato. Bonus per vita: 1.000 a corsia, + 1.500 a bersaglio, 3.000 a rampa, moltiplicatore PALIO fino a ×5; lock e multiball lo conservano. N, freccia su + o SCUOTI sul bordo del canvas danno una spintarella: due avvertimenti per vita, terza scossa TILT (palette spente, + punti e salvataggi sospesi, bonus perso). Nel futuro i sigilli illuminano il Regolo: completati tutti, 30 secondi + per il colpo finale da 500.000; alla scadenza resta un giro per ciascun sigillo da rifare. Fuochi sui fianchi, + fanfare, spinner meccanico, knocker, ruggito e conto alla rovescia; annunci sul display laterale per lasciare + libere le palette. Regole nella guida del flipper, prove in `tests/flipper.test.mjs` e `tests/flipper.browser.mjs`. + - **La folla dell'Ingorgo che salta (31 ottobre)**: nelle cinque scene della festa la gente dipinta nello sfondo va su e giù a tempo (effetto `crowd` in render.js: la fascia della folla tagliata in strisce verticali, ognuna sobbalza sul battito con una fase sua; due fasce per scena, dietro piano e davanti forte, al `bpm` delle luci della scena). diff --git a/index.html b/index.html index c6e8b9d..02f7768 100644 --- a/index.html +++ b/index.html @@ -311,6 +311,13 @@ premuto Spazio o la freccia giù, o usa il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla. Le frecce sinistra e destra, i due Shift, i pulsanti Flipper o le due metà della scena comandano le palette; puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.

+

Tiro perfetto: al lancio centra la corsia che lampeggia SKILL per 25.000 punti. + Lo spinner RIVER paga 500 punti a ogni giro. Completa i tre bersagli di un lato per accendere il + Salvataggio della Sieve su quel lato: recupera una sola uscita laterale.

+

Spintarella: premi N o freccia su, oppure tocca SCUOTI sul bordo inferiore della tavola. + Due avvertimenti per pallina; alla terza scossa è TILT: palette spente, niente punti né salvataggi e bonus perso. + A fine pallina incassi 1.000 per corsia, 1.500 per bersaglio e 3.000 per rampa; ogni PALIO alza + il moltiplicatore bonus fino a ×5. Il bonus si conserva durante lock e multiball e si paga alla perdita di una vita.

Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis. Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo @@ -322,7 +329,8 @@

Nel futuro completa due volte ciascun percorso: il loop ORBITA DEL SERPENTE vale 35.000 a giro, la rampa sopraelevata PONTE DELLA FORTEZZA 50.000 e la SPIRA AL NEON 65.000. Contano solo i giri completi: un tiro troppo debole ricade senza premio. Ogni percorso carica un sigillo, senza limite di tempo. - Accesi tutti e tre, colpisci il Regolo coronato che si muove al centro: 500.000 punti e ritorno al tavolo + Accesi tutti e tre, il Regolo si risveglia: hai 30 secondi per colpire il bersaglio coronato che si muove al centro. + Se scade il tempo, basta un altro giro su ciascuna rampa per riprovare. Il colpo finale vale 500.000 punti e il ritorno al tavolo classico, con una pallina pronta sulla molla senza consumare la riserva. Torni al classico anche perdendo l'ultima pallina in gioco: in quel caso consumi una sola vita. I punti restano e puoi riaprire la SUPER. Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.

diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index bf0b882..c522a6e 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -1,6 +1,6 @@ // Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi. // Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia. -import { R, SCORE, RAMP, FUTURE, PLUNGER_ZONE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js'; +import { R, SCORE, RAMP, FUTURE, SHOW, SPINNER, NUDGE_ZONE, bonusValue, PLUNGER_ZONE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js'; import { ambientHash as hash } from './ambient.js'; import { SCENES, flipperScene, paintRegolo, paintZoneSymbol, paintFlipperScene, paintPlayfieldArt } from './flipper-art.js'; @@ -458,11 +458,25 @@ function mission(g, c, time) { } function effects(g, c, time) { const latest = c.fx.findLast(f => f.kind === 'celebrate'); + const firework = c.fx.findLast(f => f.kind === 'fireworks'); for (const f of c.fx) { const age = time - f.t; if (age < 0) continue; g.save(); - if (f.kind === 'spark' && age < 0.5) { + if (f.kind === 'fireworks' && f === firework && age < 2.8) { + // Quattro esplosioni sui fianchi: mai sopra la zona delle palette. + for (let k = 0; k < 4; k++) { + const a = age - k * 0.28; + if (a < 0 || a > 1.7) continue; + const x = k % 2 ? 455 : 185, y = 55 + Math.floor(k / 2) * 95; + g.globalAlpha = Math.min(1, (1.7 - a) * 2); + for (let j = 0; j < 18; j++) { + const angle = j / 18 * TAU + k, speed = 15 + hash(j + k * 31) * 27; + const dx = Math.cos(angle) * speed, dy = Math.sin(angle) * speed; + line(g, [[x + dx * a, y + dy * a + 14 * a * a], [x + dx * (a + 0.09), y + dy * (a + 0.09) + 14 * a * a]], j % 3 ? f.color : WHITE, 1.2); + } + } + } else if (f.kind === 'spark' && age < 0.5) { g.globalAlpha = 1 - age / 0.5; for (let i = 0; i < 8; i++) { const a = hash(i * 7 + f.t) * TAU, length = (18 + hash(i * 9) * 45) * age; @@ -493,10 +507,10 @@ function effects(g, c, time) { poly(g, [[320, 305], [320 + Math.cos(a - 0.025) * 330, 305 + Math.sin(a - 0.025) * 330], [320 + Math.cos(a + 0.025) * 330, 305 + Math.sin(a + 0.025) * 330]], color + '18'); } g.restore(); - glow(g, 320, 277, 85, color, 0.45); - box(g, 257, 265, 126, 31, '#07111deb', color, 6); - text(g, f.text, 320, 276, 11, WHITE, 'center', 'sans-serif', 800); - text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 320, 288, 6, color, 'center', 'monospace'); + glow(g, 99, 277, 60, color, 0.45); + box(g, 24, 261, 150, 37, '#07111df5', color, 6); + text(g, f.text, 99, 274, Math.min(10, 140 / (f.text.length * 0.65)), WHITE, 'center', 'sans-serif', 800); + text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 99, 288, 6, color, 'center', 'monospace'); } g.restore(); } @@ -547,10 +561,10 @@ function futureBoard(g, c) { text(g, `${n}/2`, 603, y + 27, 9, WHITE, 'right', 'monospace'); text(g, `OGNI GIRO +${number(r.points)}`, 488, y + 40, 5.5, MUTED); } - text(g, ready ? 'IL REGOLO È VULNERABILE' : 'POI COLPISCI IL REGOLO MOBILE', 548, 274, 6, ready ? TINT.orbita : '#d6c1ff', 'center'); + text(g, ready ? `RISVEGLIO: ${Math.min(SHOW.awake, Math.max(0, Math.ceil((c.future.awakeUntil ?? c.sim) - c.sim)))}s` : 'POI COLPISCI IL REGOLO MOBILE', 548, 274, 6, ready ? TINT.orbita : '#d6c1ff', 'center'); text(g, '+500.000', 548, 294, 22, WHITE, 'center', 'monospace'); - text(g, 'FINALE O ULTIMA PALLINA PERSA:', 548, 318, 5.5, MUTED, 'center'); - text(g, 'SI TORNA AL RIVER', 548, 332, 7, TINT.orbita, 'center'); + text(g, ready ? 'MIRA AL REGOLO CORONATO!' : 'OGNI SIGILLO RISVEGLIA IL REGOLO', 548, 318, 5.5, TINT.orbita, 'center'); + text(g, 'FINALE: RITORNO AL RIVER', 548, 332, 6, MUTED, 'center'); livingBackglass(g, c, c.visual.time); } function futureField(g, c, time) { @@ -559,6 +573,7 @@ function futureField(g, c, time) { target(g, c); const ready = Object.values(c.future.hits).every(n => n === 2); if (ready) glow(g, c.regolo.x, c.regolo.y, 30, TINT.orbita, 0.6); + showDevices(g, c, time); const balls = activeBalls(c); for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b); for (const r of c.ramps) { @@ -570,7 +585,8 @@ function futureField(g, c, time) { for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, color); } for (const side of ['left', 'right']) paddle(g, c, side); - plunger(g, c); effects(g, c, time); futureBoard(g, c); + plunger(g, c); futureBoard(g, c); showFeatures(g, c, time); effects(g, c, time); + for (const b of balls) if (!b.ramp && !b.waiting && b.y > 265 && b.releaseAt <= c.sim) ball(g, b); } function oldPlayfield(c, bg, glass) { return l => { @@ -638,6 +654,10 @@ export function drawFlipper(g, c, def, bg, glass) { } if (future) { if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint); + if (shake > 0) { + const dx = Math.sin(time * 75) * shake * 1.3, dy = Math.cos(time * 63) * shake; + g.translate(dx, dy); if (g.canvas?.style) g.canvas.style.backgroundPosition = `${dx}px ${dy}px`; + } futureField(g, c, time); plungerHint(g, c, time); g.restore(); return; } if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); } @@ -655,6 +675,7 @@ export function drawFlipper(g, c, def, bg, glass) { box(g, 307, 223, 26, 11, '#142431', null, 2); text(g, 'SUPER', 320, 228, 5, '#8b91ac', 'center'); for (let i = 0; i < 2; i++) disc(g, 315 + i * 10, 239, 1.6, c.locks.length > i ? TINT.super : '#263748'); } + showDevices(g, c, time); const balls = activeBalls(c); for (const [i, b] of balls.entries()) { if (b.waiting) continue; @@ -673,10 +694,11 @@ export function drawFlipper(g, c, def, bg, glass) { c.locks.forEach(held => lock(g, held, time)); for (const b of balls) if (b.releaseAt > c.sim) lock(g, { ball: b, rampId: b.releasedFrom }, time); for (const side of ['left', 'right']) paddle(g, c, side); - plunger(g, c); effects(g, c, time); + plunger(g, c); // Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa. for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b); - board(g, c, time); mission(g, c, time); plungerHint(g, c, time); + board(g, c, time); mission(g, c, time); showFeatures(g, c, time); effects(g, c, time); plungerHint(g, c, time); + for (const b of balls) if (!b.ramp && !b.waiting && b.y > 265 && b.releaseAt <= c.sim) ball(g, b); g.restore(); } // A pallina ferma, dove toccare per la molla (Andrea, 1 novembre: «sul telefono non è intuitivo premere al centro»): la @@ -692,3 +714,65 @@ function plungerHint(g, c, time) { text(g, 'e lascia: MOLLA', 320, 285, 7, WHITE, 'center', 'sans-serif', 700); g.restore(); } + +// Inserti e contatori dinamici, senza filtri o grandi superfici trasparenti. +function showDevices(g, c, time) { + if (c.table === 'classic') { + const { x, y, half } = SPINNER, height = 2 + Math.abs(Math.cos(c.spinner.angle)) * 9; + line(g, [[x - half - 3, y - 10], [x - half - 3, y + 10]], '#b6cad4', 2); + line(g, [[x + half + 3, y - 10], [x + half + 3, y + 10]], '#b6cad4', 2); + line(g, [[x - half - 3, y], [x + half + 3, y]], '#e8f6ff', 1); + box(g, x - half, y - height / 2, half * 2, height, c.spinner.speed > 2 ? '#ffc16c' : '#48717c', '#fff1c7', 1); + if (height > 6) text(g, 'RIVER', x, y, 5, INK, 'center'); + text(g, 'SPINNER 500 / GIRO', x, y + 15, 4, '#f2d5a0', 'center'); + if (!c.tilt.active && (c.skillShot.available || c.skillShot.active)) { + const lane = c.lanes[c.skillShot.lane], pulse = 0.6 + Math.sin(time * 6) * 0.3; + glow(g, lane.x, 91, 20, '#ffcf75', pulse); + poly(g, [[lane.x, 98], [lane.x - 4, 91], [lane.x + 4, 91]], '#fff0a8'); + text(g, 'SKILL', lane.x, 82, 5, '#ffdf83', 'center'); + } + } + for (const [side, x] of [['left', 207], ['right', 426]]) { + const lit = c.kickback[side] && !c.tilt.active; + box(g, x - 3, 271, 6, 13, lit ? '#74ffe0' : '#203947', lit ? '#d5fff2' : '#406071', 1); + if (lit) { glow(g, x, 277, 15, '#74ffe0', 0.65); poly(g, [[x, 258], [x - 3, 264], [x + 3, 264]], '#74ffe0'); } + } +} +function showFeatures(g, c, time) { + const off = c.tilt.active || waiting(c) || c.step !== 'play'; + const z = NUDGE_ZONE, color = off ? '#54717e' : c.tilt.warnings ? '#ffbe79' : '#9ae4e3'; + box(g, z.x, z.y + 1, z.w, z.h - 3, '#0a202c', color, 4); + text(g, c.tilt.active ? 'TILT' : `SCUOTI ${c.tilt.warnings}/2`, 320, z.y + 7, 6.5, color, 'center'); + text(g, 'TOCCA / N / ↑', 320, z.y + 14, 4.5, color, 'center'); + box(g, 24, 329, 150, 26, '#071018', null, 2); + text(g, `BONUS ×${c.bonus.x} ${number(bonusValue(c))}`, 99, 336, 6, '#f2d5a0', 'center'); + if (c.table === 'classic' && (c.skillShot.available || c.skillShot.active) && !c.tilt.active) { + text(g, `SKILL: ${c.lanes[c.skillShot.lane].rione.toUpperCase()}`, 99, 348, 5.5, '#ffdf83', 'center'); + } + const b = c.bonusPaid; + if (b && time - b.at < 3 && c.step !== 'result') { + box(g, 24, 244, 150, 65, '#07111df5', b.lost ? '#ff8079' : '#ffcf75', 4); + text(g, b.lost ? 'TILT · BONUS PERSO' : 'BONUS DI FINE PALLINA', 99, 255, 6.5, '#ffcf75', 'center'); + text(g, `CORSIE ${b.lanes} · BERSAGLI ${b.targets}`, 99, 270, 5.5, WHITE, 'center'); + text(g, `RAMPE ${b.ramps} · MOLTIPLICATORE ×${b.x}`, 99, 281, 5.5, WHITE, 'center'); + text(g, `+${number(Math.floor(b.total * Math.min(1, (time - b.at) / 1.2)))}`, 99, 297, 12, '#a4f7d1', 'center'); + } + if (c.table === 'future') { + const parts = Object.values(c.future.hits).filter(n => n === 2).length; + // Occhi, squame e coda prendono vita con i tre sigilli. + g.save(); g.beginPath(); g.rect(28, 251, 142, 57); g.clip(); + box(g, 28, 251, 142, 57, '#07121f', null, 2); + paintRegolo(g, 99, 286, 20, '#274751'); + for (let i = 0; i < parts; i++) { + g.save(); g.beginPath(); g.rect(28, 251 + i * 19, 142, 19); g.clip(); + paintRegolo(g, 99, 286, 20, ['#58f4e2', '#c1a0ff', '#ffcf75'][i]); g.restore(); + } + if (parts === 3) glow(g, 99, 280, 35, '#74ffe0', 0.6); + g.restore(); + } + if (c.tilt.active) { + box(g, 260, 116, 120, 35, '#1d0c18', '#ff8079', 5); + text(g, 'T I L T', 320, 130, 18, '#ff8079', 'center'); + text(g, 'PALETTE SPENTE · BONUS PERSO', 320, 144, 5, WHITE, 'center'); + } +} diff --git a/src/engine/flipper.js b/src/engine/flipper.js index 823733f..369d25d 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -25,6 +25,9 @@ export const LEVELS = { seria: { g: 740, save: 4, kick: 340, goal: 120000 }, male: { g: 800, save: 1, kick: 340, goal: 200000 }, }; +export const SHOW = { skill: 25000, spinner: 500, awake: 30, nudgeCooldown: 0.7 }; +export const SPINNER = { x: 320, y: 193, half: 15 }; +export const NUDGE_ZONE = { x: 270, y: 337, w: 100, h: 22 }; 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; } @@ -136,6 +139,10 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) { flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } }, table: 'classic', transform: null, future: null, ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1, rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null, + skillShot: { lane: seed % 3, available: true, active: false, until: 0 }, + spinner: { angle: 0, speed: 0 }, kickback: { left: false, right: false }, + bonus: { lanes: 0, targets: 0, ramps: 0, x: 1 }, bonusPaid: null, + tilt: { warnings: 0, active: false, last: -10 }, nudgeHeld: false, saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() }; newBall(c); return c; @@ -150,7 +157,7 @@ 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 canFlip = c => c.step === 'play' && !c.tilt.active; export const aimFree = () => true; export const powerAt = time => clamp(time / CHARGE, 0, 1); // Quanto resta del salvataggio della pallina, in secondi (0: non protetta). @@ -167,12 +174,15 @@ function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.tim // 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) { + if (c.tilt.active) return; 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-knocker', power: 1 }); + fireworks(c, '#ffcf75'); 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; @@ -185,7 +195,9 @@ export function launchSpeed(c, power) { return min + (max - min) * clamp(power, 0, 1); } export function launch(c, power) { - if (!waiting(c)) return false; + if (!waiting(c) || c.tilt.active) return false; + c.skillShot.active = c.table === 'classic' && c.skillShot.available; + c.skillShot.until = c.sim + 8; // 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. @@ -196,7 +208,7 @@ export function launch(c, power) { // 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 f = c.flippers[side]; if (!f || (down && !canFlip(c))) 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; @@ -208,6 +220,56 @@ export function flipperSeg(c, side) { return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] }; } +function fireworks(c, color) { + fx(c, 'fireworks', 0, 0, { color }); +} +export const bonusValue = c => (c.bonus.lanes * 1000 + c.bonus.targets * 1500 + c.bonus.ramps * 3000) * c.bonus.x; +function payBonus(c) { + const total = c.tilt.active ? 0 : bonusValue(c); + c.bonusPaid = { ...c.bonus, total, lost: c.tilt.active, at: c.visual.time }; + if (total) { add(c, total); announce(c, `BONUS +${total}`); c.events.push({ kind: 'flipper-bonus', power: 1 }); } + c.bonus = { lanes: 0, targets: 0, ramps: 0, x: 1 }; +} +function updateShow(c) { + if (c.skillShot.active && c.sim > c.skillShot.until) { c.skillShot.active = false; c.skillShot.available = false; } + const s = c.spinner; + if (c.table === 'classic' && !c.tilt.active && s.speed > 0) { + s.angle += s.speed * STEP; s.speed = Math.max(0, s.speed - 9 * STEP); + if (s.angle >= Math.PI * 2) { + s.angle %= Math.PI * 2; add(c, SHOW.spinner, [SPINNER.x, SPINNER.y - 10]); + c.events.push({ kind: 'flipper-spinner', power: 0.6 }); + } + } + if (!c.future?.awakeUntil || c.tilt.active) return; + const left = Math.min(SHOW.awake, Math.ceil(c.future.awakeUntil - c.sim)); + if (left <= 0) { + c.future.awakeUntil = null; + c.future.hits = { orbita: 1, ponte: 1, spira: 1 }; + announce(c, 'RICARICA I TRE SIGILLI'); + } else if (left <= 10 && left !== c.future.beat) { + c.future.beat = left; c.events.push({ kind: 'flipper-countdown', power: (11 - left) / 10 }); + } +} +function crossShow(c, from, ball) { + if (c.tilt.active || !from) return; + if (c.skillShot.active && ball.y > 185 && ball.inPlay) { c.skillShot.active = false; c.skillShot.available = false; } + if (c.table === 'classic' && ((from.y < SPINNER.y && ball.y >= SPINNER.y) || (from.y > SPINNER.y && ball.y <= SPINNER.y)) && Math.abs(ball.y - from.y) > 0.01 && Math.abs(ball.vy) > 30) { + const x = from.x + (ball.x - from.x) * (SPINNER.y - from.y) / (ball.y - from.y); + if (Math.abs(x - SPINNER.x) <= SPINNER.half) { + c.spinner.speed = Math.min(48, c.spinner.speed + Math.abs(ball.vy) * 0.055); + fx(c, 'spark', SPINNER.x, SPINNER.y); + } + } + if (from.y < 275 && ball.y >= 275 && ball.vy > 0) { + const side = ball.x < 214 ? 'left' : ball.x > 420 && ball.x < 432 ? 'right' : null; + if (side && c.kickback[side]) { + c.kickback[side] = false; ball.vy = -560; ball.vx = side === 'left' ? 35 : -35; + announce(c, 'SALVATA DALLA SIEVE!'); fx(c, 'ring', ball.x, ball.y); + c.events.push({ kind: 'flipper-kickback', power: 1 }); + } + } +} + // La fisica, a passi fissi. export function update(c, dt) { if (!Number.isFinite(dt) || dt <= 0) return; @@ -228,7 +290,7 @@ 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 target = f.held.length && !c.tilt.active ? (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; @@ -241,6 +303,7 @@ function step(c) { if (c.sim < c.transform.until) { moveFlippers(c, STEP); return; } c.transform = null; announce(c, 'CARICA I TRE SIGILLI'); } + if (c.step === 'play') updateShow(c); if (c.table === 'future') { c.regolo.x = 320 + Math.sin(c.sim * 1.6) * 24; } @@ -273,6 +336,7 @@ function step(c) { for (const ball of balls) { if (ball.waiting || ball.releaseAt > c.sim) continue; if (ball.ramp) { rideRamp(c, ball); continue; } + crossShow(c, from.get(ball), ball); 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. @@ -282,6 +346,7 @@ function step(c) { 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 if (c.tilt.active) { drain(c, ball); continue; } else newBall(c); } } else ball.rest = 0; @@ -302,12 +367,23 @@ function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED 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; + if (c.tilt.active || !(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; + if (!l) return; + if (c.skillShot.active) { + c.skillShot.active = false; c.skillShot.available = false; + if (c.lanes.indexOf(l) === c.skillShot.lane) { + celebrate(c, 'TIRO PERFETTO!', SHOW.skill, '#ffcf75'); + c.events.push({ kind: 'flipper-skill', power: 1 }); + } + } + if (l.lit) return; + c.bonus.lanes++; 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)) { + c.bonus.x = Math.min(5, c.bonus.x + 1); + fireworks(c, '#ffcf75'); 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); } @@ -316,6 +392,7 @@ function crossLanes(c, from, ball) { // 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) { + if (c.tilt.active) return; for (const r of c.ramps) { if (!rampOpen(c, r)) continue; const [[x0, y], [x1]] = r.mouth; @@ -345,9 +422,16 @@ function rideRamp(c, ball) { 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 (c.tilt.active) return; + c.bonus.ramps++; if (c.table === 'future') { c.future.hits[r.id] = Math.min(2, c.future.hits[r.id] + 1); r.lit = 3; add(c, r.points, [p.x, p.y]); announce(c, `${r.id.toUpperCase()} ${c.future.hits[r.id]}/2`); + if (!c.future.awakeUntil && Object.values(c.future.hits).every(n => n === 2)) { + c.future.awakeUntil = c.sim + SHOW.awake; c.future.beat = 31; + celebrate(c, 'REGOLO RISVEGLIATO!', 0, '#74ffe0'); + c.events.push({ kind: 'flipper-risveglio', power: 1 }); + } fx(c, 'ring', p.x, p.y); c.events.push({ kind: 'flipper-futuro-giro', power: 1 }); return; } if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]); @@ -374,6 +458,7 @@ 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 }); + fireworks(c, color); fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0); } function rampFeature(c, r, ball) { @@ -396,6 +481,7 @@ function rampFeature(c, r, ball) { c.transform = { at: c.sim, until: c.sim + FUTURE.duration }; c.events.push({ kind: 'flipper-esplosione', power: 1 }); say(c, 'PALLINA +1'); say(c, 'MULTIBALL!'); + c.events.push({ kind: 'flipper-knocker', power: 1 }); 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; @@ -441,7 +527,11 @@ function drain(c, ball) { c.lastRamp = null; c.saved = false; c.saveUntil = 0; announce(c, 'LOCK LIBERO'); newBall(c); return; } + payBonus(c); if (c.table === 'future') returnClassic(c); + c.tilt = { warnings: 0, active: false, last: -10 }; + c.kickback = { left: false, right: false }; c.spinner.speed = 0; + c.skillShot = { lane: (c.skillShot.lane + 1) % 3, available: true, active: false, until: 0 }; 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'; @@ -479,7 +569,7 @@ function collideSeg(c, ball, w) { // 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) { + if (hit.fresh && hit.vn > IMPACT && !c.tilt.active) { 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; @@ -488,10 +578,15 @@ function collideSeg(c, ball, w) { } function collideTarget(c, ball, t) { const hit = hitSeg(ball, t, 0.5); + if (c.tilt.active) return; if (!hit || !hit.fresh || hit.vn < IMPACT) return; + c.bonus.targets++; 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); } + if (bank.every(k => k.down)) { + c.kickback[t.bank === 'pieve' ? 'left' : 'right'] = true; + say(c, t.bank === 'pieve' ? 'SALVA SIEVE SX' : 'SALVA SIEVE DX'); + 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) { @@ -506,13 +601,14 @@ function hitCircle(ball, o, e) { function collideBumper(c, ball, b) { const hit = hitCircle(ball, b, 0.2); if (!hit || !hit.fresh || hit.vn < IMPACT) return; + if (c.tilt.active) 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; + if (!hit || !hit.fresh || hit.vn < IMPACT || c.tilt.active) return; if (c.table === 'future' && Object.values(c.future.hits).every(n => n === 2)) { c.future.won = true; 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); } @@ -541,6 +637,8 @@ export const PLUNGER_ZONE = 64; // mezza larghezza della fascia centrale, in pix export function press(c, pointerId = 'ptr') { if (c.step !== 'play') return false; const x = c.cursor?.[0] ?? 320; + const y = c.cursor?.[1] ?? 0; + if (pointerId !== 'key' && pointerId !== 'mouse-right' && x >= NUDGE_ZONE.x && x <= NUDGE_ZONE.x + NUDGE_ZONE.w && y >= NUDGE_ZONE.y && y <= NUDGE_ZONE.y + NUDGE_ZONE.h) return nudge(c); if (waiting(c) && pointerId !== 'mouse-right' && (pointerId === 'key' || Math.abs(x - 320) < PLUNGER_ZONE)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; } @@ -555,6 +653,7 @@ export function release(c, pointerId = 'ptr') { // 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.nudgeHeld = false; 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; @@ -566,11 +665,33 @@ export function stopTurn(c, who = 'button') { const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false); return l || r; } -export const nudge = () => false; +export function nudge(c, dir = 0) { + if (c.step !== 'play' || waiting(c) || c.transform || c.tilt.active || c.sim - c.tilt.last < SHOW.nudgeCooldown) return false; + c.tilt.last = c.sim; c.tilt.warnings++; + fx(c, 'shake', 0, 0); + if (c.tilt.warnings >= 3) { + c.tilt.active = true; c.saveUntil = 0; c.skillShot.active = false; + c.locks = []; c.special = null; c.superOpenedAt = null; c.spinner.speed = 0; + cancel(c); announce(c, 'TILT! BONUS PERSO'); + c.events.push({ kind: 'flipper-tilt', power: 1 }); + } else { + for (const b of activeBalls(c)) if (!b.waiting && !b.ramp && b.releaseAt <= c.sim) { + b.vx += clamp(dir, -1, 1) * 85; b.vy -= 105; limit(b); + } + announce(c, `ATTENTO! ${c.tilt.warnings}/2`); + c.events.push({ kind: 'flipper-nudge', power: 0.7 }); + } + return true; +} 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 (['ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight'].includes(k)) { hold(c, k.endsWith('Left') ? 'left' : 'right', 'key-' + k, down); return true; } + if (k === 'ArrowUp' || k.toLowerCase() === 'n') { + if (down && !c.nudgeHeld) { nudge(c); c.nudgeHeld = true; } + if (!down) c.nudgeHeld = false; + return true; + } // La molla: Spazio, o la freccia giù (si tira indietro come la molla vera). if (k === ' ' || k === 'ArrowDown') { 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; } @@ -595,8 +716,9 @@ export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia 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.transform ? 'SUPER SPECIAL! Il piano si apre: arriva il Regolo del futuro.' : c.table === 'future' ? `FUTURO: Orbita ${c.future.hits.orbita}/2 · Ponte ${c.future.hits.ponte}/2 · Spira ${c.future.hits.spira}/2. Poi Regolo mobile: +500000 e ritorno. Ultima pallina persa: ritorno, −1 vita.` : 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 : ''}`; + const feature = c.transform ? 'SUPER SPECIAL! Il piano si apre: arriva il Regolo del futuro.' : c.table === 'future' ? `FUTURO: Orbita ${c.future.hits.orbita}/2 · Ponte ${c.future.hits.ponte}/2 · Spira ${c.future.hits.spira}/2. Poi 30 secondi per il Regolo mobile: +500000 e ritorno. Ultima pallina persa: ritorno, −1 vita.` : 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 extras = c.tilt.active ? ' TILT: palette spente, bonus perso.' : ` Bonus ×${c.bonus.x}. Spintarella: N, ↑ o SCUOTI; terza scossa: tilt.`; + const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}${extras}`; 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 98c57bd..5891c64 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -162,6 +162,15 @@ export class Soundscape { else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); } else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan }); // Il flipper: la molla, il colpo del flipper, il fungo, la sponda elastica, il bersaglio, la corsia, il Palio, il buco. + else if (kind === 'flipper-spinner') { this.noiseSound(0.018, 3200, 0.025, { pan }); this.tone(1900, 950, 0.025, 0.035, 0, pan); } + else if (kind === 'flipper-skill') { for (const [i, f] of [659, 880, 1109, 1760].entries()) this.tone(f, f, 0.2, 0.06, i * 0.09, pan); } + else if (kind === 'flipper-knocker') { this.noiseSound(0.045, 650, 0.12, { pan }); this.tone(95, 42, 0.08, 0.11, 0, pan); } + else if (kind === 'flipper-nudge') { this.noiseSound(0.07, 400, 0.06, { pan }); this.tone(180, 120, 0.12, 0.06, 0, pan); } + else if (kind === 'flipper-tilt') { this.tone(165, 55, 0.8, 0.08, 0, pan); this.tone(173, 58, 0.8, 0.05, 0, pan); } + else if (kind === 'flipper-kickback') { this.noiseSound(0.08, 1400, 0.09, { pan }); this.tone(220, 1320, 0.3, 0.07, 0, pan); } + else if (kind === 'flipper-bonus') { for (let i = 0; i < 8; i++) this.tone(660 + i * 110, 660 + i * 110, 0.075, 0.04, i * 0.12, pan); } + else if (kind === 'flipper-risveglio') { this.noiseSound(1, 220, 0.09, { pan }); this.tone(55, 170, 1.4, 0.09, 0, pan); for (const [i, f] of [220, 330, 440, 660].entries()) this.tone(f, f, 0.5, 0.045, 0.3 + i * 0.15, pan); } + else if (kind === 'flipper-countdown') { this.tone(440 + strength * 660, 440 + strength * 660, 0.12, 0.055, 0, pan); this.tone(110, 55, 0.15, 0.055, 0.15, pan); } else if (kind === 'flipper-lancio') { this.noiseSound(0.12, 1800, 0.05 * strength, { pan }); this.tone(180, 600, 0.12, 0.05, 0, pan); } else if (kind === 'flipper-flip') { this.tone(240, 120, 0.05, 0.07, 0, pan); this.noiseSound(0.03, 2200, 0.04, { pan }); } else if (kind === 'flipper-fungo') { this.tone(900, 500, 0.06, 0.06 * strength + 0.02, 0, pan); this.tone(1800, 900, 0.04, 0.03, 0, pan); } diff --git a/src/main.js b/src/main.js index ed1953c..d7d1816 100644 --- a/src/main.js +++ b/src/main.js @@ -701,7 +701,7 @@ const MINI = { condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' }, campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' }, bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' }, - flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia; o tieni premuto Spazio o la freccia giù). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper, le frecce sinistra e destra o i due Shift. La SUPER SPECIAL fa esplodere la festa e apre Il Regolo del futuro: due giri completi su Orbita (35.000), Ponte (50.000) e Spira (65.000), poi colpisci il Regolo mobile per 500.000 e il ritorno al classico senza perdere vite. Perdere l’ultima pallina del multiball riporta al classico consumando una vita. Durante la trasformazione le palline sono al sicuro; comandi invariati.' }, + flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia; o tieni premuto Spazio o la freccia giù). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper, le frecce sinistra e destra o i due Shift. La SUPER SPECIAL fa esplodere la festa e apre Il Regolo del futuro: due giri completi su Orbita (35.000), Ponte (50.000) e Spira (65.000), poi hai 30 secondi per colpire il Regolo mobile per 500.000 e il ritorno al classico senza perdere vite. Perdere l’ultima pallina del multiball riporta al classico consumando una vita. Durante la trasformazione le palline sono al sicuro. Skill shot sulla corsia lampeggiante: 25.000. Spinner: 500 a giro. Completa un banco per il salvataggio laterale. Bonus a fine pallina fino a ×5. N, freccia su o SCUOTI: spintarella; terza scossa nella stessa vita: TILT, palette spente e bonus perso.' }, micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' }, basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' }, // La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto). diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index c462d5e..05d9bbb 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -132,6 +132,36 @@ try { await page.keyboard.up('ShiftRight'); await page.keyboard.up('ShiftLeft'); await page.waitForFunction(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length); } + // Nuove regole sul canvas reale: skill shot, fuochi e SCUOTI anche senza pulsantiera. + await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'); + window.showState = structuredClone(game.closeup); window.showUpdate = game.update; game.update = () => {}; + const c = game.closeup; Object.assign(c, m.newFlipper(1, { level: c.level })); m.launch(c, 0.3); + Object.assign(c.ball, { x: c.lanes[c.skillShot.lane].x, y: 103, vx: 0, vy: 300 }); m.update(c, m.STEP); + if (!c.events.some(e => e.kind === 'flipper-skill')) throw new Error('skill shot non premiato'); + c.ball.waiting = true; c.kickback.left = true; c.spinner.speed = 30; + for (let i = 0; i < 3; i++) m.update(c, 0.25); + Object.assign(c.ball, { waiting: false, x: 320, y: 250, vx: 0, vy: 0 }); + // Anche a simulazione ferma, lascia finire il conteggio animato del display. + (await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() }); + c.visual.time += 0.6; + }); + await page.screenshot({ path: `${out}/${name}-fuochi.png` }); + for (let i = 1; i <= 3; i++) { + await page.evaluate(() => { game.closeup.sim += 1; game.closeup.ball.releaseAt = 0; }); + if (mobile) { + const b = await page.locator('#scene').boundingBox(); + await touch('touchStart', [[b.x + b.width / 2, b.y + b.height * 347 / 360]]); await touch('touchEnd', []); + } else await page.keyboard.press('n'); + assert.equal(await page.evaluate(() => game.closeup.tilt.warnings), i); + } + assert.equal(await page.evaluate(() => game.closeup.tilt.active), true); + await page.keyboard.down('ArrowLeft'); + assert.equal(await page.evaluate(() => game.closeup.flippers.left.held.length), 0); + await page.keyboard.up('ArrowLeft'); + await page.screenshot({ path: `${out}/${name}-tilt.png` }); + await page.evaluate(() => { Object.assign(game.closeup, window.showState); game.update = window.showUpdate; delete window.showState; delete window.showUpdate; }); + assert.deepEqual(errors, [], 'nessun errore durante skill shot e tilt'); // 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; diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index e7c2312..511fcd2 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -593,3 +593,110 @@ test('per provare, openSuper fa davvero i due LOCK e apre la SUPER SPECIAL (main assert.ok(m.openSuper(c)); assert.equal(c.special, 'super'); assert.equal(c.locks.length, 2); assert.equal(c.table, 'classic'); }); + +test('skill shot: il primo passaggio paga una volta, anche su corsia già accesa; il ball save non lo rinnova', () => { + const c = m.newFlipper(1); m.launch(c, 0.2); + const lane = c.lanes[c.skillShot.lane]; lane.lit = true; + put(c, { x: lane.x, y: 103, vx: 0, vy: 300 }); + const score = c.score; m.update(c, m.STEP); + assert.equal(c.score - score, m.SHOW.skill); + assert.equal(c.skillShot.available, false); + c.saveUntil = c.sim + 2; + put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP); + m.launch(c, 0.2); assert.equal(c.skillShot.active, false); + put(c, { x: lane.x, y: 103, vx: 0, vy: 300 }); + const again = c.score; m.update(c, m.STEP); assert.equal(c.score, again); +}); + +test('le corsie scelte per lo skill shot si raggiungono con la molla a ogni difficoltà', () => { + for (const level of Object.keys(m.LEVELS)) { + const reached = new Set(); + for (let power = 0; power <= 100; power++) { + const c = m.newFlipper(42, { level }); m.launch(c, power / 100); + for (let i = 0; i < 900 && c.skillShot.available; i++) m.update(c, m.STEP); + c.lanes.forEach((l, i) => { if (l.lit) reached.add(i); }); + } + for (let seed = 0; seed < 6; seed++) assert.ok(reached.has(m.newFlipper(seed).skillShot.lane), `${level}: corsia raggiungibile`); + } +}); + +test('spinner: un attraversamento reale lo accelera, ogni giro paga e poi si ferma', () => { + const c = m.newFlipper(3); m.launch(c, 0.5); c.skillShot.active = false; + put(c, { x: m.SPINNER.x, y: m.SPINNER.y + 1, vx: 0, vy: -400 }); m.update(c, m.STEP); + assert.ok(c.spinner.speed > 15); + c.ball.waiting = true; const before = c.score; run(c, 4); + assert.ok(c.score > before); assert.equal((c.score - before) % m.SHOW.spinner, 0); + assert.equal(c.spinner.speed, 0); + const stopped = c.score; run(c, 1); assert.equal(c.score, stopped); +}); + +test('i banchi accendono i kickback, ogni respingente salva una sola uscita laterale', () => { + for (const [bank, side, x] of [['pieve', 'left', 207], ['torre', 'right', 426]]) { + const c = m.newFlipper(25); m.launch(c, 0.8); + for (const t of c.targets.filter(t => t.bank === bank)) { + const dir = bank === 'pieve' ? -1 : 1; + put(c, { x: t.a[0] - dir * 5, y: (t.a[1] + t.b[1]) / 2, vx: dir * 200, vy: 0, contacts: [] }); run(c, 0.02); + } + assert.equal(c.kickback[side], true); + const lives = c.balls; + put(c, { x, y: 274, vx: 0, vy: 400 }); m.update(c, m.STEP); + assert.ok(c.ball.vy < -500); assert.equal(c.kickback[side], false); assert.equal(c.balls, lives); + put(c, { x, y: 274, vx: 0, vy: 400 }); m.update(c, m.STEP); assert.ok(c.ball.vy > 0); + } +}); + +test('scosse: due avvertimenti, autorepeat ignorato, tilt disattiva palette punti e bonus fino alla prossima vita', () => { + const c = m.newFlipper(17); assert.equal(m.nudge(c), false); m.launch(c, 0.7); + put(c, { x: 320, y: 260, vx: 0, vy: 0 }); + m.key(c, 'n', true); m.key(c, 'n', true); assert.equal(c.tilt.warnings, 1); assert.ok(c.ball.vy < 0); + for (let i = 0; i < 2; i++) { + m.key(c, 'n', false); c.sim += 1; m.key(c, 'n', true); + } + assert.equal(c.tilt.active, true); assert.equal(m.hold(c, 'left', 'test', true), false); + c.bonus.lanes = 3; c.bonus.x = 2; c.kickback.left = true; + const score = c.score, lives = c.balls; + const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP); + assert.equal(c.score, score); + put(c, { x: 207, y: 274, vx: 0, vy: 400 }); m.update(c, m.STEP); assert.ok(c.ball.vy > 0); + put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP); + assert.equal(c.score, score); assert.equal(c.bonusPaid.lost, true); assert.equal(c.bonusPaid.total, 0); + assert.equal(c.balls, lives - 1); assert.equal(c.tilt.active, false); assert.equal(c.tilt.warnings, 0); + assert.ok(m.launch(c, 0.7)); assert.ok(m.hold(c, 'left', 'test', true)); +}); + +test('il bonus si paga una sola volta alla perdita della vita, senza consumarlo sui ball save', () => { + const c = m.newFlipper(1); m.launch(c, 0.5); + c.bonus = { lanes: 2, targets: 3, ramps: 1, x: 2 }; + const expected = 19000, score = c.score; + assert.equal(m.bonusValue(c), expected); + c.saveUntil = c.sim + 2; put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP); + assert.equal(c.score, score); assert.equal(m.bonusValue(c), expected); + m.launch(c, 0.5); put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP); + assert.equal(c.score - score, expected); assert.equal(c.bonusPaid.total, expected); assert.equal(m.bonusValue(c), 0); +}); + +test('il risveglio dura trenta secondi di simulazione e alla scadenza lascia un giro per sigillo da rifare', () => { + const c = inFuture(); + for (const id of ['orbita', 'ponte', 'spira']) { rampShot(c, id); rampShot(c, id); } + assert.ok(c.future.awakeUntil > c.sim); + assert.ok(c.events.some(e => e.kind === 'flipper-risveglio')); + for (const b of m.activeBalls(c)) b.releaseAt = c.sim + 100; + const score = c.score, lives = c.balls; + run(c, 30.1); + assert.equal(c.future.awakeUntil, null); + assert.deepEqual(c.future.hits, { orbita: 1, ponte: 1, spira: 1 }); + assert.equal(c.balls, lives); assert.equal(c.score, score); + c.ball.releaseAt = 0; + for (const id of ['orbita', 'ponte', 'spira']) rampShot(c, id); + assert.ok(c.future.awakeUntil > c.sim); +}); + +test('SCUOTI non intercetta la molla da tastiera o il tasto destro del mouse', () => { + const c = m.newFlipper(10); m.aimScreen(c, [320, 347]); + m.key(c, ' ', true); assert.ok(c.holding); run(c, 0.2); m.key(c, ' ', false); + assert.ok(!m.waiting(c)); + m.press(c, 'mouse-right'); assert.ok(c.flippers.right.held.includes('mouse-right')); + assert.equal(c.tilt.warnings, 0); m.release(c, 'mouse-right'); + m.press(c, 'touch'); assert.equal(c.tilt.warnings, 1); + assert.equal(c.flippers.left.held.length + c.flippers.right.held.length, 0); +});