diff --git a/docs/STATO.md b/docs/STATO.md index c9a5e1f..c7ca505 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -63,10 +63,21 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, medi, 15.000 punti. Tre passaggi sulla stessa rampa entro 20 secondi dal primo: LOCK e nuova pallina gratis. Un'altra rampa completata o il tempo esaurito interrompono la serie. Dopo il primo LOCK, primo passaggio nell'altra rampa SPECIAL: jackpot da 100.000; tre passaggi ravvicinati fanno il secondo LOCK. - - Due LOCK alzano la rampa centrale SUPER SPECIAL: 250.000 punti, una pallina extra in riserva e multiball a - tre. Uscita SUPER raccordata alla corsia sinistra sopra la paletta, a velocità ridotta; i due lock liberano - le palline dai lati, sfalsati di 0,45 secondi. In multiball le rampe pagano jackpot, e una vita si perde solo - all'ultima pallina. Prima della SUPER, perdere la sostitutiva recupera l'ultimo lock senza scalare vite. + - Due LOCK alzano la rampa centrale SUPER SPECIAL: 250.000 punti (× moltiplicatore), una pallina extra in + riserva e, dal 1 novembre, **Il Regolo del futuro**. Un botto sintetico grave, lampo, onde d'urto, schegge + colorate e scossa del mobile precedono l'apertura in due metà del piano classico. Trasformazione di 3,2 s: + le tre palline restano protette, poi vengono servite sfalsate di 0,55 s verso le palette. + Prima della SUPER, perdere la sostitutiva recupera l'ultimo lock senza scalare vite. + - **Secondo campo vero**, disegnato in codice: niente corsie dei rioni o banchi classici; altri respingenti, + raccordi, tre percorsi sopraelevati e Regolo mobile sopra le palette. ORBITA DEL SERPENTE (loop esterno, + 35.000 a giro), PONTE DELLA FORTEZZA (ponte incrociato, 50.000), SPIRA AL NEON (spirale più ripida, + 65.000). Imbocchi dal basso, salita con perdita d'energia, quota e uscita nelle corsie interne; un tiro + debole ricade senza punti. Tabellone digitale, circuiti e bastioni immaginari, luci ciano/viola/rosa, + pulsazione musicale originale sintetizzata con Web Audio. È fantasia del mobile, nessun fatto nuovo sul paese. + - **Tre sigilli**: completare due volte ciascun percorso, in qualsiasi ordine e senza scadenza, poi colpire + il Regolo mobile: +500.000 e ritorno al classico con pallina sulla molla, senza consumare vite. Anche + perdendo l'ultima pallina si torna al classico, consumando una sola vita; il punteggio rimane. La SUPER + si può riaprire. Durante il multiball perdere una delle altre palline non scala la riserva. - Completare una serie di bersagli alza il moltiplicatore jackpot fino a ×3, azzerato alla perdita di una vita. Restano PONTI (+50.000 alternando le rampe entro 12 secondi), PALIO (+30.000), respingenti, bersagli e Regolo. Traguardi 60.000/120.000/200.000: una pallina premio una sola volta, vittoria acquisita anche uscendo prima. @@ -77,10 +88,22 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, - Luci che scorrono sulle rampe attive, frecce e spie 1/3–2/3–LOCK, rampa SUPER che si alza, fasci luminosi, scintille, particelle e fanfare distinte per jackpot, apertura SUPER e multiball; la pallina resta leggibile. Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca». - - Test `flipper.test.mjs`: sequenze in entrambi gli ordini e a tutti i livelli, scadenze, premi, collisioni, - recupero della SUPER con la paletta, rilascio sfalsato e valvola. `npm run browser:flipper`: desktop e touch, - screenshot di tavolo, LOCK, SUPER, multiball e risultato in `/tmp/regolo-flipper`, e la misura dell'arretrato - grafico dopo 150 fotogrammi in multiball (deve restare sotto 1,5 s). + - Test `flipper.test.mjs`: sequenze a tutti i livelli e in entrambi gli ordini, trasformazione protetta, + rilascio sfalsato, fisica e punteggi dei nuovi percorsi, tiri reali dalla paletta, Regolo mobile, finale, + ritorno e nuova SUPER, ball save, stato serializzabile e disegno senza mutazioni. I salvataggi dell'avventura + continuano a usare il checkpoint prima del minigioco, come gli altri primi piani; la pausa mantiene il + campo in corso e rilascia i comandi senza avanzare il tempo. `npm test`: 326 test verdi. + - `node scripts/verify.mjs flipper`: desktop e touch, comandi e pausa nel futuro, percorso fino al finale; + schermate di esplosione, apertura, futuro, sigilli e ritorno in `/tmp/regolo-flipper`, guardate e corrette. + Arretrato grafico misurato separatamente su 150 fotogrammi classici, esplosione/apertura e futuro, con + lettura dei pixel prima e dopo. Fondali sullo sfondo CSS, binari e pannelli in cache, onde e schegge leggere; + niente filtri o tratti arrotondati nel disegno dinamico. Soglia per ogni misura: 1,5 s. + Verifica finale (1 novembre): `REGOLO_GPU=0`, SwiftShader, classico/esplosione/futuro **31/56/26 ms** + desktop e **27/22/22 ms** touch. Anche il comando senza variabili passa, selezionando SwiftShader. + `REGOLO_GPU=1`: **30/17/25 ms** desktop e **23/18/20 ms** touch, ma il renderer effettivo è + `ANGLE (Mesa, llvmpipe (LLVM 21.1.8 256 bits), OpenGL ES 3.2)`: ancora software. In questo ambiente + non sono esposti dispositivi GPU; la misura su scheda video fisica resta da fare su un host che la esponga. + Schermate del secondo backend in `/tmp/regolo-flipper/llvmpipe`. [DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello. - **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 8b40345..51b02b1 100644 --- a/index.html +++ b/index.html @@ -314,9 +314,15 @@ Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo passaggio paga un JACKPOT da 100.000, e tre passaggi ravvicinati fanno il secondo LOCK.

Con entrambi i LOCK si alza la rampa viola centrale, SUPER SPECIAL. Completala: 250.000 punti, - una pallina extra in riserva e MULTIBALL a tre palline! La SUPER restituisce la pallina nella corsia - laterale verso la paletta sinistra; quelle trattenute vengono liberate dai due lati, sfalsate. - In multiball le rampe pagano altri jackpot e perdi una vita solo quando perdi l'ultima pallina in gioco. + una pallina extra in riserva e una sorpresa esplosiva! Il piano si apre e rivela Il Regolo del futuro, + un secondo campo al neon. Durante la trasformazione le tre palline sono al sicuro; poi parte il MULTIBALL, + con uscite sfalsate verso le palette. I comandi restano gli stessi.

+

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 + 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.

Completa i tre bersagli di un lato per alzare il moltiplicatore dei jackpot, fino a ×3 (si azzera alla perdita di una vita). Alterna le due rampe entro 12 secondi per il bonus PONTI da 50.000; accendi le diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index c7c2545..7b358f6 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -1,10 +1,10 @@ // 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, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js'; +import { R, SCORE, RAMP, FUTURE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js'; import { ambientHash as hash } from './ambient.js'; const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa'; -const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff' }; +const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff', orbita: '#58f4e2', ponte: '#c1a0ff', spira: '#ff729e' }; const RIONI = ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff']; const TAU = Math.PI * 2, clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const number = n => Math.round(n).toLocaleString('it-IT'); @@ -68,7 +68,7 @@ function cabinetBehind(g, key, paint) { if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; } return true; } -export function cabinetAway(canvas) { canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; } +export function cabinetAway(canvas) { canvas.style.backgroundPosition = ''; canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; } function crown(g, x, y, size, color) { poly(g, [[x - size, y + size / 2], [x - size * 1.1, y - size / 2], [x - size / 2, y], [x, y - size], [x + size / 2, y], [x + size * 1.1, y - size / 2], [x + size, y + size / 2]], color); line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1); @@ -178,7 +178,7 @@ function paintTrack(g, r) { const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]]; line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y); } - text(g, r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center'); + text(g, r.points ? number(r.points) : r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center'); } function rampLights(g, c, r, time) { const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id); @@ -228,7 +228,7 @@ function target(g, c) { disc(g, o.x + 1, o.y + 3, o.r + 1, '#02090b'); disc(g, o.x, o.y, o.r + 1, metal(g, o.x - 6, o.y - 6), '#c5b682'); disc(g, o.x, o.y, o.r - 1, '#18483e', '#94d7a5'); crown(g, o.x, o.y, 3.4, '#f7dda0'); - text(g, 'REGOLO', o.x, o.y - 14, 5, '#acd6b7', 'center'); + text(g, c.future && Object.values(c.future.hits).every(n => n === 2) ? 'LIBERAMI!' : 'REGOLO', o.x, o.y + (c.table === 'future' ? 20 : -14), 5, '#acd6b7', 'center'); } function paddle(g, c, side) { const f = c.flippers[side], { a: [x, y], b: [tx, ty] } = flipperSeg(c, side), len = Math.hypot(tx - x, ty - y); @@ -371,13 +371,142 @@ function effects(g, c, time) { g.restore(); } } +// Il secondo campo ha il suo fondale: circuiti incisi, bastioni sospesi e il serpente come costellazione. +// Tutto questo, compresi i binari, si dipinge una volta sola; sul telefono restano solo luci e palline. +function futureCabinet(g, c, bg, glass) { + cabinet(g, c, bg, glass); + g.save(); outline(g, c); g.clip(); + g.fillStyle = gradient(g, 200, 0, 230, 340, [[0, '#080c29'], [0.5, '#14112d'], [1, '#061f2a']]); g.fillRect(200, 5, 232, 331); + for (let i = 0; i < 13; i++) { + const x = 205 + i * 18; + line(g, [[x, 320], [320 + (x - 320) * 0.35, 50]], '#18364a', 0.6); + line(g, [[201, 90 + i * 19], [432, 90 + i * 19]], '#18364a', 0.6); + } + glow(g, 320, 105, 115, '#975cff', 0.6); glow(g, 320, 281, 90, '#24dcda', 0.4); + poly(g, [[276, 99], [276, 73], [287, 73], [287, 62], [298, 62], [298, 76], [310, 76], [310, 53], [330, 53], [330, 76], [343, 76], [343, 62], [355, 62], [355, 75], [365, 75], [365, 99]], '#161e41', '#9c86ee'); + line(g, [[278, 103], [320, 118], [363, 103]], '#58f4e2', 1); + text(g, 'FORTEZZA / NUCLEO', 320, 91, 5, '#d1c7ff', 'center'); + const serpent = [[304, 277], [288, 265], [295, 252], [326, 252], [343, 270], [332, 284], [313, 284], [313, 271], [323, 267]]; + line(g, serpent, '#143d50', 8); line(g, serpent, '#398c9c', 1); crown(g, 323, 264, 5, '#58f4e2'); + for (const w of c.walls) if (!w.gate) { line(g, [w.a, w.b], '#2e526a', 3); line(g, [w.a, w.b], '#69b7c7', 0.7); } + g.restore(); + // Il mobile stesso cambia: pannelli a circuito, la finestra sul River resta il legame col 2007. + for (const x of [12, 467]) { line(g, [[x + 9, 100], [x + 9, 31], [x + 31, 31]], '#58f4e2', 1.2); } + box(g, 198, 336, 252, 17, '#080f20', '#396c84', 2); + text(g, 'R I V E R / IL REGOLO DEL FUTURO', 320, 345, 6, '#99ffee', 'center'); +} +function futureBoard(g, c) { + const ready = Object.values(c.future.hits).every(n => n === 2); + text(g, 'IL REGOLO', 99, 51, 21, WHITE, 'center', 'sans-serif', 800); + text(g, 'D E L F U T U R O', 99, 76, 9, TINT.orbita, 'center'); + text(g, 'IL RIVER HA UN SEGRETO.', 99, 94, 5.5, '#a69abf', 'center'); + box(g, 23, 111, 152, 82, '#040c1c', '#396c84'); + text(g, 'PUNTI / CONNESSIONE ATTIVA', 34, 124, 5.5, TINT.orbita); + text(g, number(c.score), 99, 148, 22, WHITE, 'center', 'monospace'); + text(g, c.transform ? 'RISVEGLIO DEL NUCLEO' : ready ? 'COLPISCI IL REGOLO!' : c.line ?? 'CARICA I TRE SIGILLI', 99, 178, 6.5, '#d6c1ff', 'center'); + text(g, `${activeBalls(c).length} IN CAMPO / ${c.balls} IN RISERVA`, 99, 210, 6, '#b7e1e7', 'center'); + text(g, 'STESSI TASTI. UN ALTRO MONDO.', 99, 225, 5.5, MUTED, 'center'); + text(g, 'I TRE SIGILLI', 480, 49, 16, WHITE); + text(g, 'DUE PERCORSI COMPLETI CIASCUNO', 480, 69, 5.5, TINT.orbita); + for (const [i, r] of c.ramps.entries()) { + const y = 85 + i * 57, color = TINT[r.id], n = c.future.hits[r.id]; + box(g, 479, y, 137, 49, '#101d34', n === 2 ? color : '#334a68'); + text(g, r.name, 487, y + 12, 6.5, color); + for (let k = 0; k < 2; k++) box(g, 488 + k * 34, y + 25, 27, 5, k < n ? color : '#29374a', null, 1); + 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, '+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'); +} +function futureField(g, c, time) { + for (const b of c.bumpers) bumper(g, b, 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); + 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) { + cached(g, `track:future:${r.id}`, layer => paintTrack(layer, r), [207, 5, 221, 250]); + const color = TINT[r.id], x = r.path[0][0], y = r.mouth[0][1]; + for (let k = 0; k < 4; k++) { const p = rampPoint(r, (time * 120 + k * r.len / 4) % r.len); disc(g, p.x, p.y, 1.3, color); } + poly(g, [[x, y + 7], [x - 4, y + 14], [x + 4, y + 14]], color); + text(g, r.id.toUpperCase(), x, y + 21, 5.5, color, 'center'); + 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); +} +function oldPlayfield(c, bg, glass) { + return l => { + const classic = { ...c, ...layout(), table: 'classic' }; + cabinet(l, classic, bg, glass); target(l, classic); + classic.bumpers.forEach(b => bumper(l, b, 0)); classic.ramps.forEach(r => paintTrack(l, r)); + for (const side of ['left', 'right']) paddle(l, classic, side); + }; +} +function transformation(g, c, bg, glass) { + const age = c.sim - c.transform.at; + // Le due metà del vecchio piano scorrono via. Sono ritagli piccoli, mai copie dello schermo intero. + if (age >= FUTURE.reveal && age < 2.7 && typeof document !== 'undefined') { + const old = layerOf(`split:${!!bg}:${!!glass}`, oldPlayfield(c, bg, glass), [200, 5, 248, 331]); + const k = clamp((age - FUTURE.reveal) / 1.6, 0, 1), shift = k * k * 150; + g.save(); g.beginPath(); g.rect(200, 5, 248, 331); g.clip(); + g.drawImage(old, 0, 0, 248, 662, 200 - shift, 5, 124, 331); + g.drawImage(old, 248, 0, 248, 662, 324 + shift, 5, 124, 331); + line(g, [[324 - shift, 5], [324 - shift, 336]], TINT.orbita, 2); + line(g, [[324 + shift, 5], [324 + shift, 336]], TINT.ponte, 2); g.restore(); + } + // Una detonazione sola, poi schegge e onde sottili: nessun filtro o lampeggio continuo. + const blast = Math.min(age, 1.7); + if (age < 1.7) { + glow(g, 320, 165, 60 + blast * 100, '#b9faff', Math.max(0, 1 - blast / 1.7)); + for (let i = 0; i < 3; i++) { + const radius = 10 + Math.max(0, blast - i * 0.13) * 210; + g.beginPath(); g.ellipse(320, 165, radius, radius * 0.7, 0, 0, TAU); g.strokeStyle = [WHITE, TINT.orbita, TINT.ponte][i]; g.lineWidth = 1.5; g.stroke(); + } + for (let i = 0; i < 20; i++) { + const a = i * TAU / 20 + 0.12, d = 25 + blast * 210; + line(g, [[320 + Math.cos(a) * d, 165 + Math.sin(a) * d * 0.7], [320 + Math.cos(a) * (d + 24), 165 + Math.sin(a) * (d + 24) * 0.7]], i % 2 ? TINT.orbita : '#ffe3a4', 1.2); + } + for (let i = 0; i < 64; i++) { + const a = hash(i * 11) * TAU, speed = 80 + hash(i * 17) * 180, d = speed * blast; + const x = 320 + Math.cos(a) * d, y = 165 + Math.sin(a) * d * 0.7 + blast * blast * 22; + const color = [TINT.orbita, TINT.spira, '#ffe3a4', WHITE][i % 4]; + poly(g, [[x, y - 3], [x + 2 + i % 3, y], [x, y + 4], [x - 2, y]], color); + } + if (age < 0.09) { g.fillStyle = '#e4ffff'; g.fillRect(201, 6, 230, 328); } + } + const title = age < FUTURE.reveal ? 'SUPER SPECIAL' : 'IL REGOLO DEL FUTURO'; + box(g, 215, 145, 210, 47, '#050e24', age < FUTURE.reveal ? TINT.spira : TINT.orbita, 3); + text(g, title, 320, 163, age < FUTURE.reveal ? 22 : 16, WHITE, 'center', 'sans-serif', 900); + text(g, age < FUTURE.reveal ? 'IL RIVER NON SE L’ASPETTAVA.' : 'IL PIANO SI APRE. IL SERPENTE SI SVEGLIA.', 320, 181, 6, TINT.orbita, 'center'); +} export function drawFlipper(g, c, def, bg, glass) { const time = c.visual.time; // Niente estremità e giunti arrotondati nei tratti di ogni fotogramma: costano più di tutto il resto (vedi la // memoria «effetti leggeri»); il mobile e i binari, in cache, li tengono. g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'butt'; g.lineJoin = 'miter'; const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0); - const key = `cabinet:${!!bg}:${!!glass}`, paint = layer => cabinet(layer, c, bg, glass); + if (g.canvas?.style) g.canvas.style.backgroundPosition = ''; + const future = c.table === 'future'; + const key = `cabinet:${future}:${!!bg}:${!!glass}`, paint = layer => (future ? futureCabinet : cabinet)(layer, c, bg, glass); + if (c.transform) { + if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint); + const age = c.sim - c.transform.at, force = Math.max(0, 1 - age / 1.1); + const dx = Math.sin(age * 79) * force * 4, dy = Math.cos(age * 67) * force * 3; + g.translate(dx, dy); + if (g.canvas?.style) g.canvas.style.backgroundPosition = `${dx}px ${dy}px`; + if (!future) cached(g, `split:${!!bg}:${!!glass}`, oldPlayfield(c, bg, glass), [200, 5, 248, 331]); + if (future) futureField(g, c, time); else { board(g, c, time); mission(g, c, time); } + transformation(g, c, bg, glass); g.restore(); return; + } + if (future) { + if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint); + futureField(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); } else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint); diff --git a/src/engine/flipper.js b/src/engine/flipper.js index d5e6a1a..7d7136a 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -85,6 +85,42 @@ export function layout() { [...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; }); return { walls, targets, bumpers, regolo, lanes, ramps }; } +// Sotto il legno c'è una macchina impossibile: fantasia del mobile, non storia del paese. +export const FUTURE = { reveal: 1.1, duration: 3.2, finale: 500000 }; +export function futureLayout() { + const base = layout(); + // Restano il mobile, la molla e le guide alle palette; spariscono corsie e banchi classici. + const walls = base.walls.filter((w, i) => i < 17 || (i >= 20 && i < 30)); + walls.push(seg([205, 90], [228, 125]), seg([428, 92], [409, 120])); + const bumpers = [[280, 157], [368, 169]].map(([x, y]) => ({ x, y, r: 11, hit: 0, touching: false })); + const regolo = { x: 320, y: 276, r: 9, hit: 0, touching: false }; + const ramps = [ + { id: 'orbita', name: 'ORBITA DEL SERPENTE', points: 35000, mouth: [[246, 235], [282, 235]], path: [[264, 235], [253, 197], [228, 158], [224, 89], [245, 42], [293, 25], [346, 29], [394, 50], [410, 89], [396, 116], [357, 112], [345, 85], [368, 64], [397, 85], [411, 135], [411, 220]] }, + { id: 'ponte', name: 'PONTE DELLA FORTEZZA', points: 50000, mouth: [[302, 238], [338, 238]], path: [[320, 238], [320, 201], [300, 167], [300, 108], [315, 66], [338, 53], [356, 73], [349, 105], [313, 128], [267, 133], [229, 174], [229, 220]] }, + { id: 'spira', slope: 300, name: 'SPIRA AL NEON', points: 65000, mouth: [[364, 235], [400, 235]], path: [[382, 235], [386, 203], [400, 165], [388, 132], [363, 128], [347, 151], [353, 178], [376, 187], [401, 180], [411, 204], [411, 245]] }, + ]; + for (const r of ramps) { + r.len = 0; r.cum = [0]; r.lit = 0; + for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); } + r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)]; + } + [...walls, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; }); + return { walls, targets: [], lanes: [], bumpers, regolo, ramps }; +} +function openFuture(c) { + Object.assign(c, futureLayout()); c.table = 'future'; c.resets = {}; c.fx = []; + c.future = { hits: { orbita: 0, ponte: 0, spira: 0 } }; + // La trasformazione parcheggia le tre palline al sicuro, poi le serve una alla volta. + activeBalls(c).forEach((b, i) => Object.assign(b, { x: i === 1 ? 411 : 229, y: 220 - i * 32, vx: 0, vy: 160, + ramp: null, contacts: [], trail: [], releaseAt: c.transform.until + i * 0.55, releasedFrom: i === 1 ? 'spira' : 'orbita' })); +} +function returnClassic(c, won = false) { + Object.assign(c, layout()); c.table = 'classic'; c.future = null; c.transform = null; + c.extraBalls = []; c.resets = {}; c.lastRamp = null; c.jackpotX = 1; c.saveUntil = 0; c.saved = false; + cancel(c); newBall(c); + if (won) { celebrate(c, 'REGOLO LIBERATO!', FUTURE.finale, '#74ffe0'); c.events.push({ kind: 'flipper-futuro-finale', power: 1 }); } + else announce(c, 'RITORNO AL RIVER'); +} // Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì. export function alongRamp(r, s) { s = clamp(s, 0, r.len); @@ -98,7 +134,7 @@ 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, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1, + 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, saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() }; newBall(c); @@ -200,6 +236,14 @@ function moveFlippers(c, h) { } function step(c) { c.sim += STEP; + if (c.transform) { + if (c.table !== 'future' && c.sim >= c.transform.at + FUTURE.reveal) openFuture(c); + if (c.sim < c.transform.until) { moveFlippers(c, STEP); return; } + c.transform = null; announce(c, 'CARICA I TRE SIGILLI'); + } + if (c.table === 'future') { + c.regolo.x = 320 + Math.sin(c.sim * 1.6) * 24; + } for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP); if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null; if (c.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 }; @@ -243,6 +287,7 @@ function step(c) { } else ball.rest = 0; if (ball.y > FIELD.y1 + R * 2) drain(c, ball); } + if (c.future?.won) returnClassic(c, true); } function collideBalls(a, b) { let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy); @@ -289,8 +334,8 @@ function enterRamps(c, from, ball) { // d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1). export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); } function rideRamp(c, ball) { - const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top; - on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP; + const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top, slope = r.slope ?? RAMP.slope; + on.v += (uphill ? -slope : slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP; on.s += on.v * STEP; const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend); ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s); @@ -300,6 +345,11 @@ 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.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`); + 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]); 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; @@ -343,6 +393,8 @@ function rampFeature(c, r, ball) { c.locks = []; c.special = null; c.specialPaid = false; c.superOpenedAt = null; c.lastRamp = null; c.rampRun = { id: null, count: 0, until: 0 }; c.saveUntil = 0; c.saved = true; c.balls++; celebrate(c, 'SUPER SPECIAL', SCORE.super * c.jackpotX, '#e1a0ff'); + 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-super', power: 1 }); for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); } @@ -389,6 +441,7 @@ function drain(c, ball) { c.lastRamp = null; c.saved = false; c.saveUntil = 0; announce(c, 'LOCK LIBERO'); newBall(c); return; } + if (c.table === 'future') returnClassic(c); 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'; @@ -460,6 +513,7 @@ function collideBumper(c, ball, b) { function collideRegolo(c, ball) { const hit = hitCircle(ball, c.regolo, 0.7); if (!hit || !hit.fresh || hit.vn < IMPACT) 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); } // Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato. @@ -521,7 +575,7 @@ 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.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 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 : ''}`; 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/music.js b/src/engine/music.js index 3bca7c7..5a1976a 100644 --- a/src/engine/music.js +++ b/src/engine/music.js @@ -13,12 +13,14 @@ import { versioned } from '../version.js'; // suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro: // è la musica della piazza accanto, che arriva da lontano. // `ok(cond)` dice se una condizione della storia vale adesso (game.js, check). -export function trackFor(story, { screen, scene, phase, closeup, at, ok = () => false }) { +// `table`: la tavola del primo piano, se ne ha più d'una (il flipper: 'classic' o 'future'), che può avere la sua musica. +export function trackFor(story, { screen, scene, phase, closeup, table, at, ok = () => false }) { const m = story.music ?? {}; if (screen === 'splash') return null; if (screen === 'title' || screen === 'intro') return m.title ?? null; if (screen === 'ending') return m.ending ?? m.game ?? null; - if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music; + const cu = closeup && story.closeups?.[closeup]; + if (cu?.music) return cu.tableMusic?.[table] ?? cu.music; const sc = story.scenes[scene]; const stages = (sc?.stages ?? []).filter(st => ok(st.when)); if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 944eb73..98c57bd 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -175,6 +175,10 @@ export class Soundscape { 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-super-open') { this.noiseSound(0.45, 1600, 0.04, { pan }); this.tone(180, 1200, 0.65, 0.06, 0, pan); for (const [i, f] of [659, 880, 1318].entries()) this.tone(f, f, 0.18, 0.05, 0.5 + i * 0.12, pan); } else if (kind === 'flipper-super') { for (const [i, f] of [392, 523, 659, 784, 1046, 1318, 1568, 2093].entries()) this.tone(f, f * 1.005, 0.24, 0.055, i * 0.08, pan); for (const [i, f] of [523, 784, 1046].entries()) this.tone(f, f, 0.9, 0.045, 0.65 + i * 0.04, pan); } + // Timbri originali della tavola nascosta: botto grave e scintille (la musica è flipper-futuro.mp3, di Andrea). + else if (kind === 'flipper-esplosione') { this.noiseSound(1.1, 700, 0.14, { pan }); this.tone(130, 32, 1.2, 0.13, 0, pan); this.tone(90, 1800, 1.6, 0.06, 0.7, pan); } + else if (kind === 'flipper-futuro-giro') { this.tone(390, 1560, 0.38, 0.065, 0, pan); this.tone(1174, 1174, 0.25, 0.04, 0.25, pan); } + else if (kind === 'flipper-futuro-finale') { this.noiseSound(0.7, 1600, 0.07, { pan }); for (const [i, f] of [196, 294, 494, 740, 988, 1480].entries()) this.tone(f, f, 0.5, 0.055, i * 0.12, 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/main.js b/src/main.js index d0b056b..283c0d6 100644 --- a/src/main.js +++ b/src/main.js @@ -661,7 +661,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.' }, + 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.' }, 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). @@ -1194,7 +1194,7 @@ function frame(now) { const searching = game?.closeup?.id === 'fessura'; music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1; music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id, - at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), dt); + table: game?.closeup?.table, at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), dt); // Un minuto senza toccare niente, a gioco fermo: pausa da sola. if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause(); ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 }); diff --git a/src/story/story.js b/src/story/story.js index 9f3eba8..40fdca9 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -363,7 +363,8 @@ export const story = { // Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»): la tavola di Codex, // fisica e parti meccaniche in flipper.js. [DA ANDREA: le battute sono provvisorie] flipper: { - type: 'flipper', music: 'assets/audio/flipper.mp3', // di Andrea (Suno, 1 novembre) + // Le musiche di Andrea (Suno, 1 novembre): la classica, e quella del Regolo del futuro dopo la SUPER SPECIAL. + type: 'flipper', music: 'assets/audio/flipper.mp3', tableMusic: { future: 'assets/audio/flipper-futuro.mp3' }, picture: 'assets/closeups/river-flipper.png', backglass: 'assets/closeups/river-flipper-tabellone.png', // il fondale luminoso del tabellone (Codex: Trebbio, Fortezza, Regolo, fuochi) names: { player: 'Andrea', cpu: 'il flipper' }, diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index efe7cf6..38a4a2b 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -131,28 +131,92 @@ try { await page.screenshot({ path: `${out}/${name}-super.png` }); const multi = await page.evaluate(async () => { window.pinballShot('super'); - const m = await import('/src/engine/flipper.js'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25); - // Ferma solo la simulazione per fotografare le tre palline, prima che una possa scendere. - window.pinballResume = game.update.bind(game); game.update = dt => { game.closeup.visual.time += dt; }; - return { balls: m.activeBalls(game.closeup).length, locks: game.closeup.locks.length, reserve: game.closeup.balls, time: game.closeup.visual.time }; + const m = await import('/src/engine/flipper.js'), c = game.closeup; + window.pinballResume = game.update.bind(game); game.update = () => {}; + window.pinballClassic = structuredClone(c); window.pinballClassic.transform = null; + (await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() }); + m.update(c, 0.25); m.update(c, 0.2); + window.pinballExplosion = structuredClone(c); + return { balls: m.activeBalls(c).length, locks: c.locks.length, reserve: c.balls }; }); - assert.deepEqual({ balls: multi.balls, locks: multi.locks, reserve: multi.reserve }, { balls: 3, locks: 0, reserve: 5 }); - await page.waitForFunction(t => game.closeup.visual.time > t + 0.7, multi.time); + assert.deepEqual(multi, { balls: 3, locks: 0, reserve: 5 }); + await page.screenshot({ path: `${out}/${name}-esplosione.png` }); + await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'); + for (let i = 0; i < 6; i++) m.update(game.closeup, 0.25); + }); + await page.screenshot({ path: `${out}/${name}-trasformazione.png` }); + await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'); + for (let i = 0; i < 6; i++) m.update(game.closeup, 0.25); + window.pinballFuture = structuredClone(game.closeup); + }); + assert.equal(await page.evaluate(() => game.closeup.table), 'future'); + await page.screenshot({ path: `${out}/${name}-futuro.png` }); await page.screenshot({ path: `${out}/${name}-multiball.png` }); - // L'arretrato grafico (vedi la memoria «effetti leggeri»): 150 fotogrammi disegnati di fila con tre palline e gli - // effetti vivi, poi una lettura dei pixel che aspetta la grafica. Il flipper di Codex ne lasciava 4,2 s; deve - // restare sotto il secondo e mezzo anche col rendering software di queste prove. - const backlog = await page.evaluate(async () => { - const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup; - const ctx = document.querySelector('#scene').getContext('2d'); - ctx.getImageData(0, 0, 4, 4); // si aspetta prima l'arretrato di tutta la prova fin qui - for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() }); } - const t = performance.now(); ctx.getImageData(0, 0, 4, 4); return Math.round(performance.now() - t); + // Anche nel nuovo campo: due dita indipendenti, i due Shift e la pausa vera dell'applicazione. + if (mobile) { + await touch('touchStart', [P(250, 200), P(400, 200)]); + await page.waitForFunction(() => game.closeup.flippers.left.held.length && game.closeup.flippers.right.held.length); + await touch('touchEnd', []); + } else { + await page.keyboard.down('ShiftLeft'); await page.keyboard.down('ShiftRight'); + await page.waitForFunction(() => game.closeup.flippers.left.held.length && game.closeup.flippers.right.held.length); + } + await page.evaluate(() => { game.update = window.pinballResume; }); + await page.click('#pause-button'); + const paused = await page.evaluate(() => ({ sim: game.closeup.sim, hits: game.closeup.future.hits, score: game.closeup.score })); + await page.waitForTimeout(250); + assert.deepEqual(await page.evaluate(() => ({ sim: game.closeup.sim, hits: game.closeup.future.hits, score: game.closeup.score })), paused); + assert.ok(await page.evaluate(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length)); + if (!mobile) { await page.keyboard.up('ShiftLeft'); await page.keyboard.up('ShiftRight'); } + await page.click('#resume'); + await page.evaluate(() => { game.update = () => {}; Object.assign(game.closeup, structuredClone(window.pinballFuture)); }); + // Stessa lettura sincrona dei pixel per classico, detonazione/trasformazione e futuro. Si svuota prima + // l'arretrato precedente; in gioco si riportano in alto solo le palline prossime alla buca per misurare + // tutti i 150 fotogrammi con tre palline, senza finire accidentalmente sul campo classico. + for (const mode of ['classico', 'esplosione', 'futuro']) { + const backlog = await page.evaluate(async mode => { + const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup; + Object.assign(c, structuredClone(mode === 'classico' ? window.pinballClassic : mode === 'esplosione' ? window.pinballExplosion : window.pinballFuture)); + const ctx = document.querySelector('#scene').getContext('2d'); + r.render(ctx, game, { time: performance.now() }); ctx.getImageData(0, 0, 4, 4); + for (let i = 0; i < 150; i++) { + if (mode !== 'esplosione') for (const [k, b] of m.activeBalls(c).entries()) { + b.releaseAt = 0; + if (b.y > 295) Object.assign(b, { x: 260 + k * 55, y: 200, vx: 20, vy: -150, ramp: null }); + } + m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() }); + } + const t = performance.now(); ctx.getImageData(0, 0, 4, 4); const ms = Math.round(performance.now() - t); + if (mode === 'futuro' && c.table !== 'future') throw new Error('misura uscita dal futuro'); + return ms; + }, mode); + console.log(`${name}: arretrato grafico ${mode} dopo 150 fotogrammi ${backlog} ms`); + assert.ok(backlog < 1500, `${mode}: arretrato grafico ${backlog} ms`); + } + // Percorsi veri fino al finale: la schermata mostra sigilli caricati e il bersaglio mobile da colpire. + await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'), c = game.closeup; + Object.assign(c, structuredClone(window.pinballFuture)); + for (const b of c.extraBalls) b.releaseAt = c.sim + 1000; + for (const id of ['orbita', 'ponte', 'spira']) for (let i = 0; i < 2; i++) window.pinballShot(id); }); - console.log(`${name}: arretrato grafico dopo 150 fotogrammi ${backlog} ms`); - assert.ok(backlog < 1500, `arretrato grafico ${backlog} ms`); - await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url('), null, { timeout: 2000 }); // il mobile sullo sfondo CSS - await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; }); + assert.deepEqual(await page.evaluate(() => game.closeup.future.hits), { orbita: 2, ponte: 2, spira: 2 }); + await page.screenshot({ path: `${out}/${name}-sigilli.png` }); + await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'), c = game.closeup; + Object.assign(c.ball, { x: 320 + Math.sin((c.sim + m.STEP) * 1.6) * 24, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -250, ramp: null, contacts: [] }); + m.update(c, m.STEP); + }); + assert.equal(await page.evaluate(() => game.closeup.table), 'classic'); + await page.evaluate(async () => { + (await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() }); + const m = await import('/src/engine/flipper.js'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25); + }); + await page.screenshot({ path: `${out}/${name}-ritorno.png` }); + await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url(')); + await page.evaluate(() => { game.update = window.pinballResume; for (const key of ['pinballResume', 'pinballShot', 'pinballClassic', 'pinballExplosion', 'pinballFuture']) delete window[key]; }); // 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; diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index b37ca5f..c325e3c 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -316,13 +316,16 @@ test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball real assert.equal(m.activeBalls(c).length, 3); assert.ok(m.multiball(c)); assert.equal(new Set(m.activeBalls(c)).size, 3, 'tre palline distinte'); assert.ok(c.events.some(e => e.kind === 'flipper-super')); - const winScore = c.score, lives = c.balls; - rampShot(c, ids[0]); - assert.ok(c.score - winScore >= m.SCORE.ramp + m.SCORE.jackpot, 'in multiball le rampe pagano jackpot'); + assert.ok(c.transform, 'la SUPER avvia la trasformazione'); + run(c, m.FUTURE.duration); + assert.equal(c.table, 'future'); + const lives = c.balls; + for (const b of m.activeBalls(c)) b.releaseAt = 0; // Metti tutte le palline sotto il campo nello stesso passo: la vita si scala una volta soltanto. for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, waiting: false, releaseAt: 0 }); c.saveUntil = 0; m.update(c, m.STEP); assert.equal(c.balls, lives - 1); assert.equal(c.extraBalls.length, 0); assert.ok(m.waiting(c)); + assert.equal(c.table, 'classic', 'persa l’ultima si torna al River'); } }); @@ -338,25 +341,28 @@ test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie assert.equal(c.rampRun.count, 0); }); -test('la SUPER accompagna alla paletta sinistra e il multiball libera le palline sfalsate', () => { +test('la trasformazione protegge le tre palline, poi le libera sfalsate verso le palette', () => { for (const level of Object.keys(m.LEVELS)) { const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super'); - const main = c.ball, delayed = c.extraBalls.find(b => b.releaseAt > c.sim); - assert.equal(main.x, 229); assert.equal(main.y, 220); assert.equal(main.vx, 0); assert.equal(main.vy, 160); - for (const b of c.extraBalls) { assert.ok([229, 411].includes(b.x)); assert.equal(b.vx, 0, 'i lock non sparano verso le buche laterali'); } - assert.ok(delayed, 'una serratura aspetta prima di liberare la terza pallina'); - const position = [delayed.x, delayed.y]; run(c, 0.2); - assert.deepEqual([delayed.x, delayed.y], position, 'durante il ritardo resta nel lock'); - run(c, 0.3); assert.notDeepEqual([delayed.x, delayed.y], position, 'poi viene liberata'); - // Isola la pallina della SUPER per verificare che un colpo normale possa recuperarla. - c.extraBalls = []; c.ball = main; + const before = m.activeBalls(c).map(b => [b.x, b.y]), score = c.score, lives = c.balls; + run(c, 0.8); assert.deepEqual(m.activeBalls(c).map(b => [b.x, b.y]), before); + assert.equal(c.score, score); assert.equal(c.balls, lives); + run(c, m.FUTURE.duration - 0.8); + assert.equal(c.table, 'future'); assert.equal(c.transform, null); + const main = c.ball, delayed = c.extraBalls.at(-1), position = [delayed.x, delayed.y]; + assert.ok(delayed.releaseAt > c.sim); + run(c, 0.2); assert.deepEqual([delayed.x, delayed.y], position); + // Isola la prima pallina per verificare il recupero dalla paletta, senza alterare la geometria. + const d = structuredClone(c); d.extraBalls = []; let recovered = false; for (let i = 0; i < 480; i++) { - if (main.x > 284 && main.y > 300) m.hold(c, 'left', 'test', true); - m.update(c, m.STEP); - if (main.vy < -250) { recovered = true; break; } + if (d.ball.x > 284 && d.ball.y > 300) m.hold(d, 'left', 'test', true); + m.update(d, m.STEP); + if (d.ball.vy < -250) { recovered = true; break; } } - assert.ok(recovered, `${level}: ripresa dalla paletta, senza cadere direttamente nella buca centrale`); + assert.ok(recovered, `${level}: prima pallina ripresa dalla paletta`); + run(c, 1); assert.notDeepEqual([delayed.x, delayed.y], position); + assert.ok(Number.isFinite(main.x)); } }); @@ -373,7 +379,8 @@ test('perdere una sostitutiva restituisce un lock alla volta senza consumare vit }); test('il multiball ha contatti indipendenti, urti fra palline e palette che avanzano una sola volta', () => { - const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super'); + const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super'); run(c, m.FUTURE.duration); + for (const b of m.activeBalls(c)) b.releaseAt = 0; const single = m.newFlipper(24); m.hold(single, 'left', 'test', true); m.hold(c, 'left', 'test', true); m.update(single, m.STEP); m.update(c, m.STEP); assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10); @@ -473,3 +480,106 @@ test('dietro la Pieve non c\'è più il canale che portava dritto all\'uscita si assert.ok(!inChannel, 'mai fra la parete e il dorso dei bersagli'); assert.ok(c.ball.x > 210 || c.ball.y < 146, `in campo (${Math.round(c.ball.x)}, ${Math.round(c.ball.y)})`); }); + +function inFuture(level = 'seria') { + const c = m.newFlipper(96, { level }); doubleLock(c); rampShot(c, 'super'); run(c, m.FUTURE.duration); + // Gli altri tiri restano parcheggiati mentre si misura un percorso alla volta. + for (const b of c.extraBalls) b.releaseAt = c.sim + 1000; + c.ball.releaseAt = 0; return c; +} + +test('il futuro è un altro campo: tre giri reali, quote, punti e sigilli solo a percorso completo', () => { + for (const level of Object.keys(m.LEVELS)) { + const c = inFuture(level), old = m.layout(); + assert.notDeepEqual(c.walls, old.walls); assert.notDeepEqual(c.bumpers, old.bumpers); + assert.equal(c.targets.length, 0); assert.equal(c.lanes.length, 0); + for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= 200 && x <= 448 && y >= 5 && y <= 336); + for (const r of c.ramps) { + const score = c.score; + for (let n = 1; n <= 3; n++) { + const result = rampShot(c, r.id); + assert.ok(result.frames > 50); assert.ok(result.top < r.mouth[0][1] - 40); + assert.equal(c.score - score, r.points * n); assert.equal(c.future.hits[r.id], Math.min(n, 2)); + assert.ok(result.exit.x === 229 || result.exit.x === 411, 'ritorno nelle corsie interne'); + } + // Un ingresso debole sulla salita ricade e non carica il sigillo. + const d = inFuture(level), rd = d.ramps.find(k => k.id === r.id), points = d.score; + const result = ride(d, { x: rd.path[0][0], y: rd.mouth[0][1] + 1, vx: 0, vy: -270, ramp: null }); + assert.equal(d.future.hits[r.id], 0); assert.equal(d.score, points); assert.ok(result.exit.y >= rd.mouth[0][1]); + } + const score = c.score, lives = c.balls; + const nextX = 320 + Math.sin((c.sim + m.STEP) * 1.6) * 24; + put(c, { x: nextX, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -250, ramp: null, contacts: [] }); + m.update(c, m.STEP); + assert.equal(c.table, 'classic'); assert.equal(c.score, score + m.FUTURE.finale); + assert.equal(c.balls, lives); assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0); + assert.deepEqual(c.ramps.map(r => r.id), ['sinistra', 'destra', 'super']); + doubleLock(c); rampShot(c, 'super'); assert.ok(c.transform, 'il viaggio è ripetibile'); + } +}); + +test('nel futuro il Regolo si muove, gli urti e la molla funzionano, niente finale prima dei sigilli', () => { + const c = inFuture(), x = c.regolo.x; + c.ball.waiting = true; run(c, 0.5); assert.notEqual(c.regolo.x, x); + assert.ok(m.key(c, ' ', true)); run(c, 0.4); m.key(c, ' ', false); assert.ok(!m.waiting(c)); + // Un respingente devia davvero la pallina. + const b = c.bumpers[0], score = c.score; + put(c, { x: b.x, y: b.y + b.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP); + assert.ok(c.ball.vy > 0); assert.equal(c.score, score + m.SCORE.bumper); + put(c, { x: c.regolo.x, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP); + assert.equal(c.table, 'future'); assert.equal(c.score, score + m.SCORE.bumper + m.SCORE.regolo); +}); + +test('futuro e trasformazione sono serializzabili; annullare e disegnare non fanno avanzare la partita', () => { + for (const transforming of [false, true]) { + const c = inFuture(); if (transforming) { doubleLockAfterReturn(c); } + m.hold(c, 'left', 'touch', true); m.cancel(c); + const saved = JSON.parse(JSON.stringify(c)); + const calls = [], gradient = { addColorStop() {} }; + const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } }); + for (let i = 0; i < 10; i++) drawFlipper(ctx, c, story.closeups.flipper, {}); + assert.deepEqual(JSON.parse(JSON.stringify(c)), saved, 'disegno e pausa non consumano tempo'); + assert.ok(!c.flippers.left.held.length); + run(c, 0.5); run(saved, 0.5); + assert.deepEqual(JSON.parse(JSON.stringify(c)), saved, 'ripresa identica dopo il passaggio JSON'); + } +}); +function doubleLockAfterReturn(c) { + for (const b of m.activeBalls(c)) Object.assign(b, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 }); + m.update(c, m.STEP); doubleLock(c); rampShot(c, 'super'); +} + +test('il ball save nel futuro salva una sola volta, conserva campo e sigilli, poi torna al classico', () => { + const c = inFuture(); c.extraBalls = []; c.future.hits.ponte = 1; + c.saveUntil = c.sim + 2; const lives = c.balls; + put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP); + assert.equal(c.table, 'future'); assert.equal(c.balls, lives); assert.ok(m.waiting(c)); assert.equal(c.future.hits.ponte, 1); + m.launch(c, 0.7); assert.equal(m.saveLeft(c), 0); + put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP); + assert.equal(c.table, 'classic'); assert.equal(c.balls, lives - 1); +}); + +test('i tre percorsi del futuro sono raggiungibili con il colpo fisico della paletta', () => { + for (const [id, x, y] of [['orbita', 288, 300], ['ponte', 290, 306], ['spira', 308, 320]]) { + const c = m.newFlipper(9); + Object.assign(c, m.futureLayout(), { table: 'future', future: { hits: { orbita: 0, ponte: 0, spira: 0 } } }); + m.launch(c, 0.8); c.saveUntil = 0; + put(c, { x, y, vx: 0, vy: 180, inPlay: true }); m.hold(c, 'left', 'test', true); + let entered = null; + for (let i = 0; i < 160; i++) { m.update(c, m.STEP); if (c.ball?.ramp) { entered = c.ball.ramp.id; break; } } + assert.equal(entered, id, 'il tiro parte dalla paletta, non dall’imbocco'); + } +}); + +test('salvare l’avventura durante il futuro conserva il checkpoint, come negli altri minigiochi', () => { + const g = new Game(story); g.go('via_provinciale'); settle(g); + g.act('use', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g); + const checkpoint = structuredClone(g.checkpoint); + Object.assign(g.closeup, inFuture('bona')); + const before = structuredClone(g.closeup), save = JSON.parse(JSON.stringify(g.savable())); + assert.deepEqual(save, checkpoint); assert.deepEqual(g.closeup, before, 'salvare non consuma sigilli o palline'); + const loaded = new Game(story, { state: save }); + assert.equal(loaded.state.scene, 'via_provinciale'); assert.equal(loaded.closeup, null); + g.closeupCancel(); assert.equal(g.closeup.table, 'future'); + g.closeupClose(); settle(g); assert.ok(g.state.flags.flipper_vinto, 'il traguardo resta acquisito uscendo dal futuro'); +}); diff --git a/tests/music.test.mjs b/tests/music.test.mjs index a51433a..1d6a8f4 100644 --- a/tests/music.test.mjs +++ b/tests/music.test.mjs @@ -24,10 +24,13 @@ test('il brano giusto per titolo, fine e scena', () => { assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'i', phase: sera }), 'g.mp3'); }); -test('il flipper del River ha la sua musica, e il file c\'è', () => { +test('il flipper del River ha la sua musica, e un\'altra la tavola del futuro', () => { const track = trackFor(story, { screen: 'game', scene: 'river', closeup: 'flipper' }); assert.equal(track, 'assets/audio/flipper.mp3'); - assert.ok(existsSync(track)); + assert.equal(trackFor(story, { screen: 'game', scene: 'river', closeup: 'flipper', table: 'classic' }), track); + const future = trackFor(story, { screen: 'game', scene: 'river', closeup: 'flipper', table: 'future' }); + assert.equal(future, 'assets/audio/flipper-futuro.mp3', 'il Regolo del futuro ha la sua musica'); + for (const f of [track, future]) assert.ok(existsSync(f), f); }); test('volume e muto', () => {