From b660bbeb9647f38975f0bdacfd10dc047eb94c4d Mon Sep 17 00:00:00 2001
From: Andrea Manzi
Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto - per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e - scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (30.000 punti). I tre funghi - sono i bastioni della Fortezza (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a - destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a - destra, è il Regolo (10.000). I due ponti: cerca le targhette RAMPA e le frecce illuminate, ambra a sinistra e - azzurre a destra, verso il centro sopra le sponde elastiche. Gli ingressi larghi accettano anche tiri un po' - decentrati: imboccali dal basso con un colpo di media forza, la pallina ci sale sopra e - scende dall'altra parte, nella corsia di ritorno (15.000); l'altro ponte entro dodici secondi: i PONTI, con un - bonus di 50.000 in più. Lo stesso ponte tre volte di fila: LOCK, la pallina resta trattenuta in cima e te ne arriva - una nuova sulla molla, mentre l'altro ponte si accende SPECIAL; fallo e incassi il JACKPOT (100.000), la pallina - trattenuta si libera e giochi in MULTIBALL con due palline. Il tasto destro del mouse è sempre il flipper di destra.
-I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto - destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra. - Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a - fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 60.000 punti, seria 120.000, - a fassi male 200.000. Al traguardo vinci una pallina extra, una sola volta per partita, e continui per il record.
+Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure usa + Spazio o il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla. + Le frecce sinistra e destra, 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.
+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 + 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. + 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 + quattro corsie dei rioni per il PALIO da 30.000. I respingenti valgono 1.000, i bersagli laterali 5.000 + l'uno e 25.000 la serie, il Regolo coronato 10.000.
+Parti con tre palline. Appena lanciata, la pallina è protetta una volta: 8 secondi alla bona, 4 seria, + 1 a fassi male. Il traguardo è 60.000, 120.000 o 200.000 punti: raggiungerlo assegna una pallina premio + una sola volta per partita. Poi puoi continuare per il record, i LOCK e la SUPER SPECIAL.
La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 8e73bdc..4120822 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -1,417 +1,399 @@ -// Il flipper del River in primo piano (flipper.js): la tavola dipinta da Codex con San Piero, e sopra le parti -// meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono), -// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, i due -// ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti -// come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che -// girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js). -import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball } from './flipper.js'; +// 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 { ambientHash as hash } from './ambient.js'; -import { text, panel } from './bocce-render.js'; -const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' }; -const rail = (ctx, [ax, ay], [bx, by], dark = '#2a2622', light = '#c9c3b6') => { - ctx.lineCap = 'round'; - ctx.strokeStyle = dark; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); - ctx.strokeStyle = light; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); -}; -function disc(ctx, x, y, r, fill, rim) { - ctx.fillStyle = rim; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r - Math.min(2, r * 0.45), 0, Math.PI * 2); ctx.fill(); +const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa'; +const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff' }; +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'); +function text(g, value, x, y, size = 7, color = WHITE, align = 'left', font = 'sans-serif', weight = 600) { + g.font = `${weight} ${size}px ${font}`; g.textAlign = align; g.textBaseline = 'middle'; g.fillStyle = color; g.fillText(value, x, y); } -function bumper(ctx, b) { - const r = b.r, lit = b.hit > 0; - disc(ctx, b.x + 1, b.y + 3, r + 1, '#172127', '#131a1e'); - const metal = ctx.createLinearGradient(b.x - r, b.y - r, b.x + r, b.y + r); - metal.addColorStop(0, '#fff3cc'); metal.addColorStop(0.35, '#bda16d'); - metal.addColorStop(0.55, '#fff1cc'); metal.addColorStop(1, '#665238'); - disc(ctx, b.x, b.y, r, metal, '#111d25'); - disc(ctx, b.x, b.y, r - 2.5, lit ? '#fff3bd' : '#e3b658', '#654223'); - const cap = ctx.createRadialGradient(b.x - 3, b.y - 4, 1, b.x, b.y, r - 4); - cap.addColorStop(0, lit ? '#fffef0' : '#e87c61'); cap.addColorStop(0.45, lit ? '#ffe39a' : '#ba4037'); cap.addColorStop(1, '#612b2b'); - disc(ctx, b.x, b.y, r - 4, cap, '#602c27'); - label(ctx, `${SCORE.bumper / 1000}K`, b.x, b.y + 0.5, 5, '#fff2d5'); - ctx.strokeStyle = '#ffe6ae'; ctx.lineWidth = 0.8; - ctx.beginPath(); ctx.arc(b.x, b.y, r - 1, Math.PI * 1.08, Math.PI * 1.65); ctx.stroke(); +function path(g, points, close = false) { + g.beginPath(); points.forEach((p, i) => i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1])); if (close) g.closePath(); } -// Incisioni con un carattere compatto, leggibile alla risoluzione della scena. -function label(ctx, line, x, y, size = 6, color = '#f8e6bc', align = 'center') { - ctx.font = `bold ${size}px monospace`; ctx.textAlign = align; ctx.textBaseline = 'middle'; - ctx.fillStyle = color; ctx.fillText(line, x, y); +function line(g, points, color, width = 1) { path(g, points); g.strokeStyle = color; g.lineWidth = width; g.stroke(); } +function poly(g, points, color, edge) { path(g, points, true); g.fillStyle = color; g.fill(); if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); } } +function disc(g, x, y, r, color, edge) { + g.beginPath(); g.arc(x, y, r, 0, TAU); g.fillStyle = color; g.fill(); + if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); } } -function polygon(ctx, points, fill, stroke) { - ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath(); - ctx.fillStyle = fill; ctx.fill(); - if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); } +function box(g, x, y, w, h, color, edge, radius = 5) { + g.beginPath(); g.roundRect(x, y, w, h, radius); g.fillStyle = color; g.fill(); + if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); } } -function screw(ctx, x, y) { - disc(ctx, x, y, 1.7, '#d1d9d5', '#25333a'); - ctx.strokeStyle = '#53636b'; ctx.lineWidth = 0.6; ctx.beginPath(); ctx.moveTo(x - 0.7, y + 0.7); ctx.lineTo(x + 0.7, y - 0.7); ctx.stroke(); +function gradient(g, x, y, w, h, stops) { + const v = g.createLinearGradient(x, y, x + w, y + h); stops.forEach(([at, color]) => v.addColorStop(at, color)); return v; } -// Il Regolo: un bersaglio tondo d'oro con la corona e il bordo scuro, che non si confonde col drago dipinto. -function regolo(ctx, o) { - const r = o.r + (o.hit > 0 ? 2 : 0); - ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.arc(o.x + 1, o.y + 2, r + 1, 0, Math.PI * 2); ctx.fill(); - disc(ctx, o.x, o.y, r + 1, o.hit > 0 ? '#fff6c8' : '#f0c040', '#3a2a08'); - const gem = ctx.createRadialGradient(o.x - 2, o.y - 2, 0, o.x, o.y, r - 1); - gem.addColorStop(0, '#bceca0'); gem.addColorStop(0.35, '#3f9c77'); gem.addColorStop(1, '#153f42'); - disc(ctx, o.x, o.y, r - 1.5, gem, '#65522c'); - polygon(ctx, [[o.x - 5, o.y - r - 1], [o.x - 6, o.y - r - 6], [o.x - 2, o.y - r - 4], [o.x, o.y - r - 8], [o.x + 2, o.y - r - 4], [o.x + 6, o.y - r - 6], [o.x + 5, o.y - r - 1]], '#edc877', '#65522c'); - label(ctx, `${SCORE.regolo / 1000}K`, o.x, o.y + 0.5, 5, '#fff4c8'); +function metal(g, x, y, w = 20, h = 20) { + return gradient(g, x, y, w, h, [[0, '#f1f8f6'], [0.2, '#8b9da6'], [0.46, '#263641'], [0.62, '#c4d5dc'], [1, '#354a58']]); } -// I ponti: canaline metalliche, ambra a sinistra e azzurre a destra, sollevate secondo la quota della pallina. -// Accesi, brillano; l'altro, in attesa della combo, invita. -// Si dipingono una volta per stato su una tela a parte e poi si copiano (i tratti larghi e trasparenti a ogni -// fotogramma lasciavano alla grafica un arretrato di secondi, vedi effetti-leggeri). -const RAMP_LOOK = { sinistra: ['#ffd57a', '#95713d'], destra: ['#9ce9f2', '#497d92'] }; -function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; } -function paintRamp(ctx, r, state) { - const [light, dark] = RAMP_LOOK[r.id], active = state !== 'base'; - // Il fondo è una canalina scura; i due parapetti sono fili cromati separati. - // Si segue esattamente il percorso della fisica, anche ai cambi di direzione. - const points = r.cum.map(s => rampPoint(r, s)); - const path = () => { ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); }; - const stroke = (width, color) => { ctx.lineWidth = width; ctx.strokeStyle = color; path(); ctx.stroke(); }; - ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - ctx.save(); ctx.translate(2, 4); stroke(17, 'rgba(3,12,19,0.48)'); ctx.restore(); - stroke(16, '#12212a'); stroke(14, active ? light : dark); - stroke(12, '#e0e4d6'); stroke(10, '#32454e'); stroke(7, '#192e39'); - stroke(2, r.id === 'sinistra' ? '#4e4940' : '#284957'); - // Traverse distanziate e piccoli inserti luminosi sui parapetti. - for (let s = 18; s < r.len - 8; s += 26) { - const p = rampPoint(r, s); - ctx.strokeStyle = '#62727a'; ctx.lineWidth = 0.7; - ctx.beginPath(); ctx.moveTo(p.x - p.dy * 4, p.y + p.dx * 4); ctx.lineTo(p.x + p.dy * 4, p.y - p.dx * 4); ctx.stroke(); - for (const side of [-1, 1]) { - ctx.fillStyle = active ? '#fff6d8' : light; - ctx.beginPath(); ctx.arc(p.x + p.dy * 6.5 * side, p.y - p.dx * 6.5 * side, 1, 0, Math.PI * 2); ctx.fill(); - } - } - // L'apertura coincide con il segmento d'ingresso: una pedana svasata e una soglia a terra. - const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2; - const deck = ctx.createLinearGradient(0, y - 30, 0, y); - deck.addColorStop(0, '#1b303b'); deck.addColorStop(0.75, '#455860'); deck.addColorStop(1, '#b8c3bd'); - polygon(ctx, [[cx - 5, y - 30], [cx + 5, y - 30], [x1, y], [x0, y]], deck, '#18232b'); - for (const side of [-1, 1]) { - rail(ctx, [cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y], '#18232b', light); - screw(ctx, cx + side * (x1 - x0) / 2, y); - } - ctx.fillStyle = '#edf0d9'; ctx.fillRect(x0 + 1, y - 1, x1 - x0 - 2, 1); - label(ctx, `${SCORE.ramp / 1000}K`, cx, y - 18, 5, light); -} -function rampEntrance(ctx, r, time, who) { - const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2; - const color = RAMP_LOOK[r.id][0], invite = who && who !== r.id; - // Un inserto scuro isola le frecce dai dettagli del ponte dipinto sotto. - polygon(ctx, [[x - 6, y + 1], [x + 6, y + 1], [x + 7, y + 22], [x - 7, y + 22]], '#10222e', color); - // Una freccia piena, leggibile anche sul telefono; pulsa più rapida per la combo. - const on = Math.sin(time * (invite ? 9 : 4)) > 0; - polygon(ctx, [[x, y + 4], [x + 5, y + 11], [x + 2, y + 11], [x + 2, y + 20], [x - 2, y + 20], [x - 2, y + 11], [x - 5, y + 11]], on ? '#fff6d6' : color); - ctx.fillStyle = '#112530'; ctx.fillRect(x - 14, y + 22, 28, 10); - ctx.strokeStyle = color; ctx.lineWidth = 0.7; ctx.strokeRect(x - 14, y + 22, 28, 10); - label(ctx, 'RAMPA', x, y + 27, 6, color); -} -const rampCache = new Map(); -function ramp(ctx, r, time, who) { - const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : (who === r.id + '!' || (who && who !== r.id)) && Math.sin(time * 6) > 0 ? 'invite' : 'base'; - if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; } - const key = `${r.id}:${state}`; - let layer = rampCache.get(key); - if (!layer) { - layer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); - paintRamp(layer.getContext('2d'), r, state); - rampCache.set(key, layer); - } - ctx.drawImage(layer, 0, 0); -} -let railLayer = null; -function rails(ctx, c) { - const paint = g => { - // Plastiche smaltate sopra gli elastici, con bordi e viti di fissaggio. - for (const side of [-1, 1]) { - const x = side < 0 ? 236 : 404, tip = x - side * 26; - polygon(g, [[x, 246], [x, 284], [tip, 290]], '#152f3c', '#c1a273'); - polygon(g, [[x - side * 4, 260], [x - side * 4, 280], [tip + side * 7, 284]], '#294c5b', '#597c83'); - screw(g, x, 250); screw(g, x, 281); screw(g, tip + side * 4, 286); - } - for (const w of c.walls) rail(g, w.a, w.b, w.sling ? '#582723' : '#18262d', w.sling ? '#f09a70' : '#d2d9cb'); - }; - if (typeof document === 'undefined') { paint(ctx); return; } - if (!railLayer) { railLayer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); paint(railLayer.getContext('2d')); } - ctx.drawImage(railLayer, 0, 0); -} -function flipper(ctx, c, side) { - const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side); - ctx.save(); ctx.translate(px, py); ctx.rotate(Math.atan2(ty - py, tx - px)); - const len = Math.hypot(tx - px, ty - py); - const shape = () => { - ctx.beginPath(); ctx.moveTo(0, -3); ctx.lineTo(len, -2.5); - ctx.quadraticCurveTo(len + 3, 0, len, 2.5); ctx.lineTo(0, 3); - ctx.arc(0, 0, 3, Math.PI / 2, Math.PI * 1.5); ctx.closePath(); - }; - ctx.save(); ctx.translate(1, 3); shape(); ctx.fillStyle = '#142029'; ctx.fill(); ctx.restore(); - shape(); ctx.fillStyle = f.held.length ? '#fff6da' : '#f0e4c6'; ctx.fill(); - ctx.strokeStyle = '#bb503c'; ctx.lineWidth = 1.5; ctx.stroke(); - ctx.strokeStyle = '#fffdf0'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(3, -1.7); ctx.lineTo(len - 2, -1.2); ctx.stroke(); - screw(ctx, 0, 0); ctx.restore(); -} -function plunger(ctx, c) { - const x = (LANE.x0 + LANE.x1) / 2, top = 326, pull = c.holding ? c.power * 14 : canThrow(c) ? c.power * 6 : 0; - ctx.fillStyle = '#4a4440'; ctx.fillRect(Math.round(x) - 3, top + pull, 6, 336 - top - pull + 10); - ctx.fillStyle = '#9a9088'; - for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1); - ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4); -} -// La serratura: la pallina trattenuta in fondo al ponte, dietro due sbarre, con la luce che pulsa. -function lock(ctx, b, time) { - ball(ctx, b); - ctx.strokeStyle = Math.sin(time * 6) > 0 ? '#ffd45a' : '#8a7a5a'; ctx.lineWidth = 1.5; - for (const dx of [-3, 3]) { ctx.beginPath(); ctx.moveTo(b.x + dx, b.y - 9); ctx.lineTo(b.x + dx, b.y + 7); ctx.stroke(); } - ctx.beginPath(); ctx.moveTo(b.x - 7, b.y - 9); ctx.lineTo(b.x + 7, b.y - 9); ctx.stroke(); - label(ctx, 'LOCK', b.x, b.y - 15, 5, '#ffe08c'); -} -function ball(ctx, b) { - if (!b) return; - // Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana. - const z = b.ramp?.z ?? 0, r = R + 1.5 * z, y = b.y - 2 - 4 * z, sh = 3 + 4 * z; - ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + sh * 0.7, b.y + sh, r, 0, Math.PI * 2); ctx.fill(); - const g = ctx.createRadialGradient(b.x - 1.5, y - 1.5, 0.5, b.x, y, r); - g.addColorStop(0, '#ffffff'); g.addColorStop(0.4, '#cfd3da'); g.addColorStop(1, '#5a6068'); - ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, y, r, 0, Math.PI * 2); ctx.fill(); -} - -// --- Il tabellone a punti luminosi: un carattere 5×7, cifre e lettere. -const GLYPHS = { - '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], - '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'], - '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '10000', '11110', '00001', '10001', '01110'], - '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], - '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'], - A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], - C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], D: ['11100', '10010', '10001', '10001', '10001', '10010', '11100'], - E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], - G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], - I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'], J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'], - K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], - M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'], - O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], - Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], - S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], - U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], - W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'], - Y: ['10001', '10001', '10001', '01010', '00100', '00100', '00100'], Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'], - '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], ':': ['00000', '00100', '00000', '00000', '00000', '00100', '00000'], - ',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'], - '.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], - '+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'], -}; -// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore. La griglia -// dei punti spenti è sempre uguale: si dipinge una volta per misura su una tela a parte e si copia (centinaia di -// rettangoli trasparenti a ogni fotogramma lasciavano arretrato alla grafica, vedi effetti-leggeri). -const gridCache = new Map(); -function offGrid(ctx, n, x0, y, pitch, off) { +function screw(g, x, y) { disc(g, x, y, 1.4, '#9caeb3', '#17262f'); line(g, [[x - 0.6, y + 0.6], [x + 0.6, y - 0.6]], '#273b47', 0.7); } +const glowCache = new Map(); +function glow(g, x, y, radius, color, alpha = 1) { if (typeof document === 'undefined') return; - const key = `${n}:${pitch}:${off}`, size = Math.max(1, pitch - 1); - let layer = gridCache.get(key); + let sprite = glowCache.get(color); + if (!sprite) { + sprite = Object.assign(document.createElement('canvas'), { width: 96, height: 96 }); + const s = sprite.getContext('2d'), v = s.createRadialGradient(48, 48, 0, 48, 48, 48); + v.addColorStop(0, color + 'b0'); v.addColorStop(0.22, color + '50'); v.addColorStop(0.6, color + '15'); v.addColorStop(1, color + '00'); + s.fillStyle = v; s.fillRect(0, 0, 96, 96); glowCache.set(color, sprite); + } + g.save(); g.globalAlpha *= alpha; g.globalCompositeOperation = 'screen'; g.drawImage(sprite, x - radius, y - radius, radius * 2, radius * 2); g.restore(); +} +const layers = new Map(); +function cached(g, key, paint, bounds = [0, 0, 640, 360]) { + if (typeof document === 'undefined') { paint(g); return; } + let layer = layers.get(key); if (!layer) { - layer = Object.assign(document.createElement('canvas'), { width: n * 6 * pitch, height: 7 * pitch }); - const g = layer.getContext('2d'); g.fillStyle = off; - for (let i = 0; i < n; i++) for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) g.fillRect((i * 6 + col) * pitch, row * pitch, size, size); - gridCache.set(key, layer); + layer = Object.assign(document.createElement('canvas'), { width: bounds[2] * 2, height: bounds[3] * 2 }); + const l = layer.getContext('2d'); l.scale(2, 2); l.translate(-bounds[0], -bounds[1]); paint(l); layers.set(key, layer); } - ctx.drawImage(layer, x0, Math.round(y)); + g.drawImage(layer, ...bounds); } -function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = 'left') { - const chars = [...line.toUpperCase()], w = chars.length * 6 * pitch - pitch; - const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x), size = Math.max(1, pitch - 1); - offGrid(ctx, chars.length, x0, y, pitch, off); - ctx.fillStyle = on; - chars.forEach((ch, i) => { - const g = GLYPHS[ch] ?? GLYPHS[' ']; - for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) if (g[row][col] === '1') ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size); - }); +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); +} +function outline(g, c) { + path(g, [[200, 336], ...c.walls.slice(0, 12).map(w => w.a), [448, 72], [448, 336]], true); +} +function cabinet(g, c, bg, glass) { + g.fillStyle = gradient(g, 0, 0, 640, 360, [[0, '#0c1924'], [0.5, '#02070d'], [1, '#101a24']]); g.fillRect(0, 0, 640, 360); + // Due colonne da backglass, incassate nel mobile. Il paese resta un'illustrazione nel riquadro di sinistra. + box(g, 12, 14, 174, 332, '#0c1721', '#243746', 9); + box(g, 467, 14, 161, 332, '#0c1721', '#243746', 9); + line(g, [[24, 20], [174, 20]], '#355263', 0.7); line(g, [[479, 20], [616, 20]], '#355263', 0.7); + for (const x of [19, 179, 474, 621]) for (const y of [21, 339]) screw(g, x, y); + if (glass) { + g.save(); g.beginPath(); g.roundRect(24, 237, 150, 88, 4); g.clip(); + g.drawImage(glass, 0, 170, 200, 130, 24, 237, 150, 98); + g.fillStyle = 'rgba(6,18,28,0.38)'; g.fillRect(24, 237, 150, 88); g.restore(); + } else if (bg) { + g.save(); g.globalAlpha = 0.25; g.drawImage(bg, 200, 30, 230, 280, 24, 237, 150, 88); g.restore(); + } + text(g, 'SAN PIERO A SIEVE', 99, 333, 6, MUTED, 'center'); + // Parete esterna, fianco e bisello: l'ombra scende sotto il piano, non sopra gli oggetti. + g.save(); g.translate(2, 7); outline(g, c); g.fillStyle = '#010306'; g.fill(); g.lineWidth = 18; g.strokeStyle = '#010306'; g.stroke(); g.restore(); + outline(g, c); g.lineWidth = 16; g.strokeStyle = '#182430'; g.stroke(); + g.lineWidth = 11; g.strokeStyle = metal(g, 194, 0, 250, 30); g.stroke(); + g.lineWidth = 6; g.strokeStyle = '#050c12'; g.stroke(); + g.fillStyle = gradient(g, 240, 5, 140, 320, [[0, '#10252d'], [0.5, '#102d31'], [1, '#071720']]); g.fill(); + g.save(); outline(g, c); g.clip(); + glow(g, 225, 138, 150, TINT.sinistra, 0.17); glow(g, 415, 202, 140, TINT.destra, 0.13); + // Incisione della Sieve nel piano in lacca; pochi segni, con spazio per leggere i tiri. + for (let i = 0; i < 7; i++) { + g.beginPath(); g.moveTo(305 + i * 3, 70); g.bezierCurveTo(230 + i * 3, 160, 414 + i * 2, 198, 302 + i * 4, 328); + g.strokeStyle = i === 3 ? '#2c6570' : '#173e47'; g.lineWidth = i === 3 ? 1.4 : 0.6; g.stroke(); + } + for (let i = 0; i < 40; i++) { g.fillStyle = '#28434b'; disc(g, 212 + hash(i * 7) * 211, 20 + hash(i * 13) * 304, 0.35, '#28434b'); } + g.strokeStyle = '#27464d'; g.lineWidth = 0.6; + for (const radius of [34, 39, 71]) { g.beginPath(); g.ellipse(320, 182, radius, radius * 0.7, 0, 0, TAU); g.stroke(); } + crown(g, 320, 182, 12, '#31585b'); + // I bastioni stilizzati al fondo del piano. + poly(g, [[278, 83], [278, 60], [286, 60], [286, 50], [296, 50], [296, 59], [307, 59], [307, 45], [318, 45], [318, 59], [329, 59], [329, 50], [340, 50], [340, 60], [352, 60], [352, 83]], '#1e3e43', '#365358'); + text(g, 'LA FORTEZZA', 315, 77, 5.5, '#5c7d7d', 'center'); + // La corsia della molla è una scanalatura separata, con il fondo metallico. + g.fillStyle = gradient(g, 432, 0, 16, 0, [[0, '#01070b'], [0.3, '#213442'], [0.65, '#0c1922'], [1, '#304754']]); g.fillRect(432, 72, 16, 264); + line(g, [[440, 86], [440, 306]], '#45606c', 0.5); + g.restore(); + // Plastiche sagomate delle sponde, con spessore e gomma viva sul bordo interno. + for (const side of [-1, 1]) { + const x = side < 0 ? 236 : 404, tip = x - side * 26, color = side < 0 ? TINT.sinistra : TINT.destra; + poly(g, [[x, 248], [x, 287], [tip, 294]], '#02070c'); + poly(g, [[x, 246], [x, 284], [tip, 290]], gradient(g, x, 246, -side * 25, 40, [[0, '#52717e'], [0.15, '#163140'], [1, '#091723']]), '#7d9ca5'); + poly(g, [[x - side * 4, 261], [x - side * 4, 281], [tip + side * 8, 284]], '#173c4b', color); + screw(g, x, 249); screw(g, x, 282); screw(g, tip + side * 4, 287); + } + g.lineCap = 'round'; g.lineJoin = 'round'; + for (const w of c.walls) { + if (w.gate) continue; + const pts = [w.a, w.b]; + g.save(); g.translate(1, 2); line(g, pts, '#02070c', 3.5); g.restore(); + line(g, pts, w.sling ? '#8c453b' : '#526c78', 2.3); + line(g, pts, w.sling ? '#ffab7c' : '#bdd2d5', 0.7); + } + box(g, 198, 336, 252, 17, '#080f16', '#35515c', 2); + text(g, 'R I V E R', 218, 345, 7, '#d6c49d'); + text(g, 'LOCK / JACKPOT / MULTIBALL', 431, 345, 5.5, MUTED, 'right'); + for (const side of [-1, 1]) for (let i = 0; i < 6; i++) { + const x = side < 0 ? 197 : 451, y = 106 + i * 36, color = side < 0 ? TINT.sinistra : TINT.destra; + glow(g, x, y, 14, color, 0.4); box(g, x - 0.7, y - 5, 1.4, 10, color, null, 0.7); + } +} +function rampPoint(r, s) { + const p = alongRamp(r, s), z = rampHeight(r, s); return { ...p, y: p.y - 1 - 8 * z }; +} +function paintTrack(g, r) { + const pts = r.cum.map(s => { const p = rampPoint(r, s); return [p.x, p.y]; }), color = TINT[r.id]; + g.lineCap = 'round'; g.lineJoin = 'round'; + // Piedini e ombra portata. La quota è la stessa usata dalla pallina. + for (let s = 45; s < r.len; s += 75) { + const p = rampPoint(r, s); + line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3); + screw(g, p.x + 7, p.y + 10); + } + const track = (color, width) => { + g.beginPath(); g.moveTo(...pts[0]); + for (let i = 1; i < pts.length - 1; i++) { + const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1]; + const d0 = Math.hypot(x - a[0], y - a[1]), d1 = Math.hypot(b[0] - x, b[1] - y), radius = Math.min(8, d0 / 3, d1 / 3); + g.lineTo(x - (x - a[0]) / d0 * radius, y - (y - a[1]) / d0 * radius); + g.quadraticCurveTo(x, y, x + (b[0] - x) / d1 * radius, y + (b[1] - y) / d1 * radius); + } + g.lineTo(...pts.at(-1)); g.strokeStyle = color; g.lineWidth = width; g.stroke(); + }; + g.save(); g.translate(3, 6); track('#02080cbb', 18); g.restore(); + track(color + '18', 20); track('#02080e', 16); track(color, 14); + track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10); + track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8); + track(color + '24', 4); + for (let s = 12; s < r.len - 4; s += 22) { + const p = rampPoint(r, s); + line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7); + for (const side of [-1, 1]) disc(g, p.x + p.dy * 6 * side, p.y - p.dx * 6 * side, 0.7, color); + } + const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2; + poly(g, [[cx - 6, y - 30], [cx + 6, y - 30], [x1, y + 2], [x0, y + 2]], '#030910'); + poly(g, [[cx - 5, y - 30], [cx + 5, y - 30], [x1 - 1, y], [x0 + 1, y]], gradient(g, 0, y - 30, 0, 30, [[0, '#172b38'], [0.8, '#3c5d6b'], [1, '#a6c4c8']]), '#7795a2'); + for (const side of [-1, 1]) { + 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'); +} +function rampLights(g, c, r, time) { + const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id); + const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super'; + const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0; + if (hot) { + for (let k = 0; k < 7; k++) { + const p = rampPoint(r, (time * 140 + k * r.len / 7) % r.len); + glow(g, p.x, p.y, 10, color, 0.7); disc(g, p.x, p.y, 1.1, WHITE); + } + } + const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2; + for (let i = 0; i < 3; i++) { + const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i); + if (lit) glow(g, x - 8 + i * 8, y + 6, 7, color, 0.7); + disc(g, x - 8 + i * 8, y + 6, 1.8, lit ? color : '#102a36', lit ? '#d6faff' : '#46616a'); + } + const pulse = 0.6 + 0.4 * Math.sin(time * (invited || combo ? 8 : 3)); + if (invited || combo) glow(g, x, y + 16, 23, color, pulse); + poly(g, [[x, y + 10], [x + 4, y + 16], [x + 1.5, y + 16], [x + 1.5, y + 22], [x - 1.5, y + 22], [x - 1.5, y + 16], [x - 4, y + 16]], invited ? WHITE : color); + box(g, x - 23, y + 24, 46, 10, '#08151e', color, 3); + text(g, locked ? 'LOCKED' : r.id === 'super' ? 'SUPER SPECIAL' : invited ? 'JACKPOT' : combo ? 'COMBO +50K' : r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO', x, y + 29, r.id === 'super' ? 5.1 : 5.8, invited ? WHITE : color, 'center'); +} +function bumper(g, b, time) { + const hot = b.hit > 0, color = hot ? '#ffce84' : '#76e4ec'; + glow(g, b.x, b.y, hot ? 29 : 18, color, hot ? 1 : 0.24); + g.beginPath(); g.ellipse(b.x + 1, b.y + 5, b.r + 2, b.r, 0, 0, TAU); g.fillStyle = '#01080d'; g.fill(); + disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b'); + disc(g, b.x, b.y, b.r - 2, '#172835', color); + const v = g.createRadialGradient(b.x - 3, b.y - 4, 0, b.x, b.y, b.r - 3); + v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e'); + disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7'); + crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9'); + for (const dx of [-8, 8]) screw(g, b.x + dx, b.y); + if (hot) { g.strokeStyle = color; g.lineWidth = 1; g.beginPath(); g.arc(b.x, b.y, b.r + 4 + Math.sin(time * 24) * 2, 0, TAU); g.stroke(); } +} +function target(g, c) { + for (const t of c.targets) { + const [x, y] = t.a, h = t.b[1] - y, color = t.bank === 'pieve' ? TINT.sinistra : TINT.destra; + box(g, x - 3, y - 1, 6, h + 2, '#030c12', '#45606d', 1); + if (t.down) { line(g, [[x - 2, y + h - 1], [x + 2, y + h - 1]], color); continue; } + g.fillStyle = metal(g, x - 2, y, 5, 6); g.fillRect(x - 2, y, 4, h); + g.fillStyle = color; g.fillRect(x - 1, y + 2, 2, h - 5); glow(g, x, y + h / 2, 8, color, 0.35); + } + const o = c.regolo; + glow(g, o.x, o.y, o.hit > 0 ? 30 : 18, '#99e5aa', o.hit > 0 ? 1 : 0.4); + 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'); +} +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); + glow(g, x, y, 13, side === 'left' ? TINT.sinistra : TINT.destra, f.held.length ? 0.7 : 0.3); + g.save(); g.translate(x, y); g.rotate(Math.atan2(ty - y, tx - x)); + const shape = () => { g.beginPath(); g.moveTo(0, -3); g.lineTo(len, -2.3); g.quadraticCurveTo(len + 3, 0, len, 2.3); g.lineTo(0, 3); g.arc(0, 0, 3, Math.PI / 2, Math.PI * 1.5); g.closePath(); }; + g.save(); g.translate(1, 4); shape(); g.fillStyle = '#00080c'; g.fill(); g.restore(); + shape(); g.fillStyle = '#e9eee4'; g.fill(); g.strokeStyle = '#d36754'; g.lineWidth = 1.4; g.stroke(); + line(g, [[3, -1.7], [len - 2, -1.3]], '#ffffff', 0.8); screw(g, 0, 0); g.restore(); +} +function ball(g, b, color = '#bcecff') { + const z = b.ramp?.z ?? 0, x = b.x, y = b.y - 1 - z * 8, r = R + z; + g.beginPath(); g.ellipse(x + 2 + z * 3, b.y + 3 + z * 2, r + 1, r * 0.55, 0, 0, TAU); g.fillStyle = '#00080dcc'; g.fill(); + const v = g.createRadialGradient(x - r * 0.35, y - r * 0.45, 0, x, y, r); + v.addColorStop(0, '#ffffff'); v.addColorStop(0.2, '#f6ffff'); v.addColorStop(0.42, color); v.addColorStop(0.65, '#667d8e'); v.addColorStop(0.8, '#132435'); v.addColorStop(1, '#b1ccdc'); + disc(g, x, y, r, v, '#bfdee8'); disc(g, x - r * 0.3, y - r * 0.4, 0.9, '#ffffff'); +} +function lock(g, held, time) { + const b = held.ball, color = TINT[held.rampId]; + glow(g, b.x, b.y, 22, color, 0.5 + 0.15 * Math.sin(time * 4)); + box(g, b.x - 9, b.y - 11, 18, 22, '#07151e', color, 5); ball(g, b, color); + for (const dx of [-6, 6]) line(g, [[b.x + dx, b.y - 8], [b.x + dx, b.y + 7]], metal(g, b.x - 6, b.y, 12, 10), 2); + text(g, 'LOCK', b.x, b.y - 16, 5.5, color, 'center'); +} +function plunger(g, c) { + const x = 440, pull = c.holding ? c.power * 12 : 0; + for (let y = 327 + pull; y < 347; y += 2.5) line(g, [[436, y], [444, y + 1]], '#adbdc4', 0.8); + box(g, 435, 324 + pull, 10, 4, metal(g, 435, 324, 10, 4), '#3c687b', 1); + for (let i = 0; i < 8; i++) { + g.fillStyle = canThrow(c) || c.holding ? (i / 8 < c.power ? TINT.destra : '#1e3844') : '#1e3844'; + g.fillRect(437, 290 - i * 6, 6, 2); + } + // La valvola disegnata coincide con il segmento inclinato della fisica. + const gate = c.walls.find(w => w.gate); + line(g, [gate.a, gate.b], '#061017', 3); line(g, [gate.a, gate.b], '#b0d1d5', 1.2); screw(g, ...gate.a); + if (waiting(c)) text(g, 'LANCIA', x, 302, 4.5, TINT.destra, 'center'); } -// Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola -// presentazione, fuori dalla partita (così disegnare non cambia niente). const shown = new WeakMap(); -// Il tabellone è il fondale luminoso di Codex (il «backglass»: il Trebbio, la Fortezza, il Regolo, i fuochi) sul -// fianco sinistro, 200×360: le cifre nella finestra nera, le lampadine sui bordi che girano, i cinque dischi in basso -// per le palline e i rioni, i ponti ai lati della finestra. Senza il fondale (tests), un pannello scuro. -const GLASS = { window: [20, 78, 160, 46], sockets: [[23, 329], [67, 329], [100, 329], [133, 329], [169, 329]], lampX: [8, 192], lampY0: 16, lampStep: 20 }; -function board(ctx, c, time, glass) { +export function shownScore(d, time) { const k = Math.min(1, (time - d.t0) / 0.5); return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; } +function board(g, c, time) { + crown(g, 99, 43, 10, '#e7c889'); + text(g, 'IL REGOLO', 99, 77, 24, '#eee8d7', 'center', 'Georgia, serif', 500); + text(g, 'R I V E R / P I N B A L L', 99, 96, 5.8, '#7f9d9e', 'center'); + box(g, 23, 111, 152, 80, '#030b13', '#2c4a58', 6); + text(g, 'PUNTEGGIO', 34, 123, 6, MUTED); let d = shown.get(c); - if (!d) { d = { from: c.score, to: c.score, t0: time, flash: 0, lineId: 0 }; shown.set(c, d); } - // Le cifre corrono: da dove erano a dove devono arrivare, in mezzo secondo, a scatti di dieci; a partita finita - // subito il totale. + if (!d) { d = { from: c.score, to: c.score, t0: time }; shown.set(c, d); } if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; } - const score = c.step === 'result' ? c.score : shownScore(d, time), hot = score !== c.score; - if (c.lineId !== d.lineId) { d.lineId = c.lineId; d.flash = time; } - const party = c.line && time - d.flash < 2, [wx, wy, ww, wh] = GLASS.window; - if (glass) ctx.drawImage(glass, 0, 0, 200, 360); - else { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 200, 360); ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(wx - 3, wy - 3, ww + 6, wh + 6); } - // Alla festa il fondale lampeggia (un lampo breve, solo sul fianco: una passata piccola). - if (party && Math.sin(time * 12) > 0.6) { ctx.globalAlpha = 0.25; ctx.fillStyle = '#fff6c8'; ctx.fillRect(0, 0, 200, 360); ctx.globalAlpha = 1; } - // Le lampadine sui bordi: tranquille di solito, impazzite per due secondi a ogni scritta. - const beat = time * (party ? 14 : 2); - let n = 0; - const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? (party ? '#ff5a3a' : '#c8842a') : '#5a3a1e'; ctx.fillRect(x - 2, y - 2, 4, 4); }; - for (let y = GLASS.lampY0; y < 300; y += GLASS.lampStep) lamp(GLASS.lampX[0], y); - for (let y = 300 - ((300 - GLASS.lampY0) % GLASS.lampStep); y >= GLASS.lampY0; y -= GLASS.lampStep) lamp(GLASS.lampX[1], y); - // La finestra: le cifre grosse color ambra col bagliore caldo dietro (sei cifre larghe, da un milione in su più - // fitte) e sotto la scritta: l'ultimo evento (lampeggia per due secondi), se no l'esito o il traguardo. - ctx.save(); ctx.beginPath(); ctx.rect(wx, wy, ww, wh); ctx.clip(); - ctx.fillStyle = '#000'; ctx.fillRect(wx, wy, ww, wh); - const digits = String(Math.round(score)).padStart(6, '0'), pitch = digits.length > 6 ? 3 : 4; - ctx.fillStyle = hot ? 'rgba(255,120,30,0.25)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(wx + 2, wy + 1, ww - 4, 29); - dots(ctx, digits, wx + ww / 2, wy + 1 + (4 - pitch) * 3, pitch, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center'); - const won = c.result?.winner === 'player'; - const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`); - const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3; - if (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); - if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh); - ctx.restore(); - // Il traguardo resta leggibile anche mentre il display celebra un colpo: una barra sottile sotto la finestra e, - // nella riga in basso, quanto manca (il pannello che c'era prima copriva il Trebbio dipinto). + const score = c.step === 'result' ? c.score : shownScore(d, time); + glow(g, 99, 146, 55, '#6dddf4', 0.2); + const digits = String(score).padStart(6, '0'); + text(g, digits, 99, 146, Math.min(28, 132 / (digits.length * 0.6)), '#c4f6ff', 'center', 'monospace', 500); + const message = c.step === 'result' ? (c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA') : c.line ?? (waiting(c) ? 'CARICA LA MOLLA' : 'PUNTA AL LOCK'); + text(g, message, 99, 176, 7, c.line ? '#ffdc93' : MUTED, 'center'); const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score); - ctx.fillStyle = '#1a1410'; ctx.fillRect(wx, wy + wh + 3, ww, 3); - ctx.fillStyle = remaining ? '#edc777' : '#a9e7bf'; ctx.fillRect(wx, wy + wh + 3, Math.round(ww * progress), 3); - // I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia). - c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a')); - // I cinque dischi in basso: le palline che restano, e i quattro rioni accesi. - const [[bx, by], ...rioni] = GLASS.sockets; - disc(ctx, bx, by, 11, '#2a2420', '#8a7a5a'); text(ctx, String(c.step === 'result' ? 0 : c.balls), bx, by + 1, 9, '#ffd45a', 'center'); - rioni.forEach(([x, y], i) => { const l = c.lanes[i]; disc(ctx, x, y, 11, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#8a7a5a'); }); - // Sotto, in piccolo: il salvataggio o la combo. - const save = saveLeft(c); - const note = multiball(c) ? 'MULTIBALL!' : c.locked ? 'LOCK: FAI IL PONTE SPECIAL' : c.rampRun?.count ? `PONTE ${c.rampRun.count}/3 PER IL LOCK` : save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO: +1 PALLINA'; - if (note) text(ctx, note, 100, 352, 6, save > 0 ? '#9fd27a' : '#ffe08c', 'center'); -} -// La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al -// Palio le quattro bandiere dei rioni che sfilano, ai ponti due archi di punti che si uniscono. -const CROWN = ['10001000100010001', '11011101110111011', '11111111111111111', '01111111111111110', '01111111111111110']; -function regia(ctx, line, age, wx, wy, ww, wh) { - const cx = wx + ww / 2; - if (line === 'TRAGUARDO!') { - const cols = Math.min(17, Math.floor(age * 12)); - for (let row = 0; row < 5; row++) for (let col = 0; col < cols; col++) if (CROWN[row][col] === '1') { ctx.fillStyle = row < 2 ? '#fff6c8' : '#ffd45a'; ctx.fillRect(cx - 34 + col * 4, wy + 2 + row * 3, 3, 2); } - } else if (line === 'PALIO!') { - ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0'].forEach((color, i) => { const x = wx + 2 + ((age * 140 + i * 40) % (ww - 4)); ctx.fillStyle = color; ctx.fillRect(Math.round(x), wy + 1, 8, 3); ctx.fillRect(Math.round(x), wy + wh - 4, 8, 3); }); - } else if (line === 'I PONTI!') { - const k = Math.min(1, age / 0.8); - for (let i = 0; i <= 16 * k; i++) { const a = Math.PI + i / 16 * Math.PI; ctx.fillStyle = i % 2 ? '#ffd45a' : '#fff6c8'; ctx.fillRect(Math.round(cx - 60 + Math.cos(a) * 30 + 30), Math.round(wy + wh - 3 + Math.sin(a) * 6), 3, 2); ctx.fillRect(Math.round(cx + 60 - Math.cos(a) * 30 - 30), Math.round(wy + wh - 3 + Math.sin(a) * 6), 3, 2); } + box(g, 28, 195, 142, 2, '#203641', null, 1); + if (progress) box(g, 28, 195, 142 * progress, 2, '#e7c889', null, 1); + text(g, remaining ? `MANCANO ${number(remaining)}` : 'TRAGUARDO / PALLINA +1', 99, 206, 6, '#c7c8ac', 'center'); + text(g, 'PALLINE', 28, 225, 6, MUTED); + for (let i = 0; i < Math.min(8, c.balls); i++) disc(g, 88 + i * 9, 225, 2.4, i === 0 ? '#d4f6fc' : '#4f7786', '#8aacb5'); + if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right'); + if (c.step === 'result') { + box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5); + text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center'); + text(g, `${number(c.result.score)} PUNTI`, 99, 287, 11, WHITE, 'center', 'monospace'); } + const save = saveLeft(c); + if (save > 0) { box(g, 248, 325, 144, 8, '#0b272f', '#4d9b9d', 3); text(g, `BALL SAVE ${Math.ceil(save)}s`, 320, 329, 5, '#a4f7d1', 'center'); } } -// I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo. -export function shownScore(d, time) { - const k = Math.min(1, (time - d.t0) / 0.5); - return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; +function mission(g, c, time) { + const multi = multiball(c); + text(g, 'LA CACCIA AL', 480, 35, 6, MUTED); text(g, 'JACKPOT', 480, 51, 17, '#f0d59d', 'left', 'Georgia, serif'); + text(g, 'DUE LOCK. UNA SUPER SPECIAL.', 480, 69, 5.5, '#8aabb8'); + for (let i = 0; i < 2; i++) { + const id = i ? 'destra' : 'sinistra', color = TINT[id], y = 83 + i * 56, held = c.locks.some(l => l.rampId === id); + const count = held || multi ? 3 : c.rampRun.id === id ? c.rampRun.count : 0; + box(g, 479, y, 137, 49, '#102431', c.special === id || multi ? color : '#28424e', 5); + text(g, `0${i + 1}`, 489, y + 12, 6, color, 'left', 'monospace'); + text(g, i ? 'TREBBIO' : 'FORTEZZA', 507, y + 12, 8, '#d4e6eb'); + for (let k = 0; k < 3; k++) { box(g, 489 + k * 28, y + 25, 22, 4, k < count ? color : '#213e4c', null, 1); } + const note = multi ? 'JACKPOT ATTIVO' : held ? 'PALLINA TRATTENUTA' : count ? `${count}/3 · ${Math.max(0, Math.ceil(c.rampRun.until - c.sim))}s PER IL LOCK` : '3 PASSAGGI IN 20 SECONDI'; + text(g, note, 489, y + 39, 5.5, held ? color : MUTED); + if (held) { disc(g, 604, y + 12, 3, color); glow(g, 604, y + 12, 10, color, 0.6); } + } + const open = c.special === 'super'; + box(g, 479, 195, 137, 64, open || multi ? '#241d3c' : '#121f2c', open ? TINT.super : '#364052', 5); + if (open) glow(g, 547, 222, 52, TINT.super, 0.25 + 0.15 * Math.sin(time * 5)); + text(g, '03 SUPER SPECIAL', 489, 208, 7.5, open ? '#f1d8ff' : '#a5a5b7'); + text(g, multi ? `${activeBalls(c).length} PALLINE IN GIOCO` : open ? 'RAMPA CENTRALE APERTA' : 'COMPLETA ENTRAMBI I LOCK', 489, 224, 5.5, open || multi ? '#ead6fc' : MUTED); + text(g, '+1 PALLINA + MULTIBALL', 489, 241, 6, '#d3bfdc'); + text(g, `JACKPOT ×${c.jackpotX}`, 480, 278, 8, '#f2d5a0'); + text(g, number((open ? SCORE.super : SCORE.jackpot) * c.jackpotX), 615, 278, 10, '#ffe4b2', 'right', 'monospace'); + text(g, 'I BERSAGLI COMPLETI ALZANO IL ×', 480, 292, 5, MUTED); + line(g, [[480, 303], [615, 303]], '#2c424d', 0.6); + text(g, multi ? 'LE RAMPE PAGANO JACKPOT' : c.special === 'super' ? 'MIRA ALLA FRECCIA VIOLA' : c.special ? 'L’ALTRO LOCK ACCENDE LA SUPER' : c.lastRamp ? `COMBO +50.000 / ${Math.max(0, Math.ceil(RAMP.combo - (c.sim - c.lastRamp.at)))}s` : 'ALTERNARE LE RAMPE: +50.000', 548, 316, 5.5, '#aec5c9', 'center'); + text(g, multi ? 'MULTIBALL' : c.special === 'super' ? 'SUPER SPECIAL' : 'LOCK • SPECIAL • JACKPOT', 548, 333, 6, multi || open ? TINT.super : '#6e8f9b', 'center'); } -// --- Gli effetti (c.fx): piccoli e tanti, niente filtri. Ognuno vive dal suo `t` per un tempo suo. -const CONFETTI = ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0', '#ffd45a']; -function drawFx(ctx, c, time) { +function effects(g, c, time) { + const latest = c.fx.findLast(f => f.kind === 'celebrate'); for (const f of c.fx) { const age = time - f.t; - if (f.kind === 'ring' && age < 0.4) { - ctx.globalAlpha = 1 - age / 0.4; ctx.strokeStyle = '#fff4c8'; ctx.lineWidth = 2; - ctx.beginPath(); ctx.arc(f.x, f.y, 10 + age * 110, 0, Math.PI * 2); ctx.stroke(); - } else if (f.kind === 'spark' && age < 0.6) { - ctx.globalAlpha = 1 - age / 0.6; - for (let i = 0; i < 10; i++) { - const a = hash(i * 7 + f.t * 100) * Math.PI * 2, v = 80 + hash(i * 13 + 1) * 140; - ctx.fillStyle = i % 2 ? '#fff4c8' : '#ffb13a'; - ctx.fillRect(Math.round(f.x + Math.cos(a) * v * age), Math.round(f.y + Math.sin(a) * v * age + 220 * age * age), 2, 2); + if (age < 0) continue; + g.save(); + 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; + line(g, [[f.x + Math.cos(a) * length, f.y + Math.sin(a) * length], [f.x + Math.cos(a) * (length + 3), f.y + Math.sin(a) * (length + 3)]], i % 2 ? '#f9edbc' : '#8de8fa', 1); } - } else if (f.kind === 'burst' && age < 1.6) { - const n = f.big ? 48 : 24; - ctx.globalAlpha = Math.min(1, (1.6 - age) / 0.5); - for (let i = 0; i < n; i++) { - const a = hash(i * 7 + 3) * Math.PI * 2, v = (f.big ? 120 : 80) + hash(i * 13 + 5) * 160, spin = hash(i * 3 + 9) * 10; - const x = f.x + Math.cos(a) * v * age * 0.8, y = f.y + Math.sin(a) * v * age * 0.6 + 160 * age * age; - if (x < FIELD.x0 || x > FIELD.x1 || y > FIELD.y1) continue; - ctx.fillStyle = CONFETTI[i % CONFETTI.length]; - const w = 3 + 2 * Math.abs(Math.sin(time * spin + i)), h = 2; - ctx.fillRect(Math.round(x - w / 2), Math.round(y), Math.round(w), h); + } else if (f.kind === 'ring' && age < 0.5) { + g.globalAlpha = 1 - age * 2; g.strokeStyle = '#b2f6ff'; g.lineWidth = 1.2; + g.beginPath(); g.ellipse(f.x, f.y, 12 + age * 42, 9 + age * 30, 0, 0, TAU); g.stroke(); + } else if (f.kind === 'pop' && age < 1.1) { + g.globalAlpha = Math.min(1, (1.1 - age) * 3); + text(g, f.text, f.x, f.y - age * 22, 7, '#fff1c7', 'center', 'monospace'); + } else if (f.kind === 'burst' && age < 1.5) { + g.globalAlpha = Math.min(1, (1.5 - age) * 2); + for (let i = 0; i < 28; i++) { + const a = hash(i * 9 + f.t) * TAU, speed = 25 + hash(i * 13) * 75; + const x = f.x + Math.cos(a) * speed * age, y = f.y + Math.sin(a) * speed * age + 20 * age * age; + if (x < 201 || x > 430 || y > 330) continue; + disc(g, x, y, 0.7 + hash(i), i % 2 ? '#a8e8ff' : '#ffe3a9'); } - } else if (f.kind === 'chase' && age < 1.2) { - const r = c.ramps.find(k => k.id === f.ramp); - if (!r) continue; - ctx.globalAlpha = Math.min(1, (1.2 - age) / 0.3); - for (let k = 0; k < 8; k++) { - const s = (age * 520 + k * 28) % (r.len + 1), p = rampPoint(r, s); - ctx.fillStyle = k % 2 ? '#ffd45a' : '#fff6c8'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2); ctx.fill(); + } else if (f.kind === 'celebrate' && f === latest && age < 2.6) { + const color = f.color ?? TINT.super; + const fade = Math.min(1, age * 5, (2.6 - age) * 3); + g.globalAlpha = fade; + // Fasci stretti, limitati al campo: la pallina resta leggibile. + g.save(); outline(g, c); g.clip(); + for (let i = 0; i < 5; i++) { + const a = -Math.PI / 2 + (i - 2) * 0.3 + Math.sin(time * 1.5) * 0.1; + 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'); } - } else if (f.kind === 'pop' && age < 1) { - ctx.globalAlpha = 1 - age; - text(ctx, f.text, f.x, f.y - age * 28, age < 0.15 ? 8 : 6, '#fff4c8', 'center'); - } else if (f.kind === 'wash' && age < 0.3) { - // Un lampo colorato sulla tavola sola, per un terzo di secondo: una passata piccola, non a tutto schermo. - ctx.globalAlpha = 0.35 * (1 - age / 0.3); ctx.fillStyle = `rgb(${f.color})`; - ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); + 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'); } + g.restore(); } - ctx.globalAlpha = 1; } -// La scossa del mobile ai colpi grossi: tutta la scena trema per un terzo di secondo. -function shake(c, time) { - let k = 0; - for (const f of c.fx) if (f.kind === 'shake') k = Math.max(k, 1 - (time - f.t) / 0.35); - return k > 0 ? [Math.round(Math.sin(time * 90) * 3 * k), Math.round(Math.cos(time * 70) * 2 * k)] : [0, 0]; -} -// La cometa: le ultime posizioni della pallina, sempre più tenui. -function trail(ctx, b) { - const n = b.trail.length; - b.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); }); - ctx.globalAlpha = 1; -} -function result(ctx, c) { - panel(ctx, 14, 236, 172, 56); - text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 100, 252, 8, '#efd49e', 'center'); - text(ctx, `${c.result.score} su ${c.result.goal}`, 100, 272, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center'); -} - -export function drawFlipper(ctx, c, def, bg, glass) { - const time = c.visual.time, [sx, sy] = shake(c, time); - ctx.save(); - if (sx || sy) { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 640, 360); ctx.translate(sx, sy); } - if (bg) ctx.drawImage(bg, 0, 0, 640, 360); - else { ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#e9d9b4'; ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); } - // La stampa resta visibile, ma le parti giocabili emergono dal paesaggio. - ctx.fillStyle = 'rgba(8,25,34,0.22)'; ctx.fillRect(201, 73, 230, 261); - // Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere. - for (const l of c.lanes) { - disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#273a3e', l.lit ? '#fff4c8' : '#b5a278'); - disc(ctx, l.x, l.y, 3.5, l.lit ? '#fff2c6' : COLORS[l.rione], '#182c32'); +export function drawFlipper(g, c, def, bg, glass) { + const time = c.visual.time; + g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'round'; g.lineJoin = 'round'; + const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0); + 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, `cabinet:${!!bg}:${!!glass}`, layer => cabinet(layer, c, bg, glass)); + for (const [i, l] of c.lanes.entries()) { + if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55); + disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b'); + disc(g, l.x, l.y, 2.3, l.lit ? RIONI[i] : '#203a44'); } - // Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. Non cambiano mai: dipinte - // una volta su una tela a parte. - rails(ctx, c); - // I bersagli a caduta: in piedi colorati col bordo scuro, caduti un segno a terra. - for (const t of c.targets) { - const [x, y0] = t.a, y1 = t.b[1]; - if (t.down) { ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(x - 2, y0, 4, y1 - y0); continue; } - ctx.fillStyle = '#2a2622'; ctx.fillRect(x - 3, y0 - 1, 6, y1 - y0 + 2); - ctx.fillStyle = t.bank === 'pieve' ? '#f2eee2' : '#e8b640'; ctx.fillRect(x - 2, y0, 4, y1 - y0); - ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(x - 2, y1 - 3, 4, 3); - ctx.fillStyle = '#fff8d7'; ctx.fillRect(x - 2, y0, 4, 1); - ctx.fillStyle = '#3a5864'; ctx.fillRect(x - 1, y0 + 3, 2, 4); + target(g, c); c.bumpers.forEach(b => bumper(g, b, time)); + if (c.special !== 'super') { + box(g, 305, 221, 30, 15, metal(g, 305, 221, 30, 15), '#354a5a', 3); + 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'); } - for (const b of c.bumpers) bumper(ctx, b); - regolo(ctx, c.regolo); - for (const r of c.ramps) rampEntrance(ctx, r, time, c.lastRamp?.id); - for (const side of ['left', 'right']) flipper(ctx, c, side); - plunger(ctx, c); - // Le palline (con la cometa) sotto i ponti, se sono sulla tavola; sopra, se sono su un ponte; la pallina - // trattenuta (LOCK) nella serratura in fondo al suo ponte; poi gli effetti. - const balls = activeBalls(c), special = c.special ? c.special + '!' : c.lastRamp?.id; - for (const b of balls) if (!b.waiting && !b.ramp) { trail(ctx, b); ball(ctx, b); } - for (const b of balls) if (b.waiting) ball(ctx, b); - for (const r of c.ramps) ramp(ctx, r, time, special); - if (c.locked) lock(ctx, c.locked.ball, time); - for (const b of balls) if (b.ramp) { trail(ctx, b); ball(ctx, b); } - drawFx(ctx, c, time); - if (c.special) { const r = c.ramps.find(k => k.id === c.special); if (Math.sin(time * 8) > 0) label(ctx, 'SPECIAL', (r.mouth[0][0] + r.mouth[1][0]) / 2, r.mouth[0][1] - 38, 6, '#fff6d6'); } - // Da fermo, l'invito: la molla da caricare. - if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center'); - board(ctx, c, time, glass); - if (c.step === 'result') result(ctx, c); - ctx.restore(); + const balls = activeBalls(c); + for (const [i, b] of balls.entries()) { + if (b.waiting) continue; + const color = [TINT.sinistra, TINT.destra, TINT.super][i]; + b.trail.forEach(([x, y], k) => { g.save(); g.globalAlpha = k / b.trail.length * 0.35; disc(g, x, y - 1, 1 + k / b.trail.length * 2, color); g.restore(); }); + } + // Ogni rampa copre gli oggetti sotto; la sua pallina si disegna sul proprio piano. + for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b); + for (const r of c.ramps) { + if (!rampOpen(c, r)) continue; + g.save(); + if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); } + cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [213, 50, 138, 202] : [213, 10, 215, 228]); rampLights(g, c, r, time); + for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]); + g.restore(); + } + 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); + // 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); + g.restore(); } diff --git a/src/engine/flipper.js b/src/engine/flipper.js index 478f1c9..7f2a7fc 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -7,15 +7,15 @@ // fisso (come il biliardo), con sottopassi quando pallina e flipper vanno forte. // Rivista con la revisione di Codex del 31 ottobre: punti solo a un urto nuovo (non al contatto che dura), due dita // sulla scena, annullare non lancia, niente cancelletto-ripiano, salvataggio col tempo della simulazione, corsie che -// contano quando si attraversano, traguardo acquisito appena raggiunto. +// contano quando si attraversano, traguardo acquisito appena raggiunto. Valvola di lancio a senso unico. export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5; export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 }; export const PIVOT = { left: [280, 314], right: [360, 314] }; // I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»). -export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000 }; +export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000, super: 250000 }; // Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si // sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI. -export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30 }; +export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30, lockWindow: 20 }; // Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta. const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5; // I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la @@ -37,9 +37,11 @@ export function layout() { const walls = []; for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 })); walls.push(seg([200, 72], [200, 336]), seg([448, 72], [448, 336])); - // La corsia della molla, col fondo. Niente cancelletto: una pallina che ricasca nella corsia si rilancia e basta - // (il cancelletto a senso unico faceva da ripiano ai lanci a metà). + // La corsia della molla, col fondo. Un lancio che non è ancora entrato in campo si può ritentare; + // dopo l'ingresso la valvola inclinata impedisce il ritorno, senza creare un ripiano orizzontale. walls.push(seg([432, 70], [432, 336]), seg([432, 326], [448, 326], { e: 0.2 })); + // Valvola inclinata: lascia uscire il lancio e devia a sinistra ogni rientro dal campo. + walls.push(seg([432, 70], [448, 60], { gate: true, e: 0.7 })); // Le corsie dei rioni, in cima, fra le bandiere: tre guide. for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 })); // I divisori delle uscite laterali e le guide che portano ai flipper (la guida passa sopra il perno e continua un @@ -67,6 +69,7 @@ export function layout() { // Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13. { id: 'sinistra', mouth: [[255, 226], [291, 226]], path: [[273, 226], [273, 196], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false }, { id: 'destra', mouth: [[349, 226], [385, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false }, + { id: 'super', mouth: [[303, 232], [337, 232]], path: [[320, 232], [320, 188], [333, 145], [333, 96], [320, 72], [307, 96], [307, 145], [287, 168], [246, 180], [229, 198], [229, 220]], lit: 0, touching: false }, ]; // Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende. for (const r of ramps) { @@ -91,15 +94,17 @@ 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: [], locked: null, special: null, rampRun: { id: null, count: 0 }, + ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1, + rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null, saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() }; newBall(c); return c; } -function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; } +function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, inPlay: false, releaseAt: 0, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; } function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; } export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean); export const multiball = c => c.extraBalls.length > 0; +export const rampOpen = (c, r) => r.id !== 'super' || c.special === 'super'; export const waiting = c => c.step === 'play' && !!c.ball?.waiting; export const canThrow = c => waiting(c) && !c.holding; export const canPress = canThrow; @@ -117,7 +122,7 @@ function say(c, line, front = false) { } // Gli effetti sulla tavola (Andrea: «più spettacolare»): anelli, scintille, coriandoli, luci che corrono, punti che // volano, il lampo colorato e la scossa. Qui si segnano col loro tempo; li disegna flipper-render.js, e durano FX_LIFE. -export const FX_LIFE = 2; +export const FX_LIFE = 3; function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.time, ...extra }); } // I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si // continua per il record. @@ -175,7 +180,7 @@ export function update(c, dt) { while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); } // La scia della pallina: le ultime posizioni, per la cometa. for (const b of activeBalls(c)) { - if (!b.waiting) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0; + if (!b.waiting && b.releaseAt <= c.sim) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0; } c.trail = c.ball?.trail ?? []; } @@ -193,10 +198,11 @@ function step(c) { c.sim += STEP; for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP); if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null; + if (c.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 }; if (c.step !== 'play') { moveFlippers(c, STEP); return; } for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; } // Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo. - const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp); + const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp && b.releaseAt <= c.sim); const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }])); for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); } const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy))); @@ -206,6 +212,7 @@ function step(c) { moveFlippers(c, h); for (const ball of ground) { ball.x += ball.vx * h; ball.y += ball.vy * h; + if (ball.x < LANE.x0 - R) ball.inPlay = true; for (const w of c.walls) collideSeg(c, ball, w); for (const t of c.targets) if (!t.down) collideTarget(c, ball, t); for (const b of c.bumpers) collideBumper(c, ball, b); @@ -216,7 +223,7 @@ function step(c) { for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]); } for (const ball of balls) { - if (ball.waiting) continue; + if (ball.waiting || ball.releaseAt > c.sim) continue; if (ball.ramp) { rideRamp(c, ball); continue; } crossLanes(c, from.get(ball), ball); enterRamps(c, from.get(ball), ball); @@ -261,6 +268,7 @@ function crossLanes(c, from, ball) { // indietro e ricasca dall'imbocco. function enterRamps(c, from, ball) { for (const r of c.ramps) { + if (!rampOpen(c, r)) continue; const [[x0, y], [x1]] = r.mouth; if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue; const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k; @@ -288,7 +296,8 @@ 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); - 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 (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]); + r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id }); if (rampFeature(c, r, ball)) return; if (c.lastRamp && c.lastRamp.id !== r.id) { add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-ponti', power: 1 }); c.lastRamp = null; @@ -300,29 +309,62 @@ function rideRamp(c, ball) { ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1; } else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; } } -// La sequenza considera i passaggi completi: bersagli e rimbalzi non la interrompono, l'altra rampa sì. +// Tre passaggi completi sulla stessa rampa entro venti secondi. La finestra parte dal primo. +function announce(c, line) { + // Gli eventi di fase sostituiscono i vecchi annunci dei ponti; il premio del traguardo mantiene la precedenza. + const prize = c.lines.includes('PALLINA +1'); c.lines = []; + if (c.line === 'TRAGUARDO!') { if (prize) c.lines.push('PALLINA +1'); c.lines.push(line); } + else { c.line = null; say(c, line, true); if (prize) c.lines.push('PALLINA +1'); } +} +function celebrate(c, title, points, color) { + if (points) add(c, points, [320, 184]); + announce(c, title); + fx(c, 'celebrate', 320, 170, { text: title, points, color }); + fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0); +} function rampFeature(c, r, ball) { - if (c.locked) { - if (c.special !== r.id) { say(c, 'TENTA SPECIAL'); return true; } - const released = c.locked.ball; - released.vx = 0; released.vy = 170; released.waiting = false; - released.trail = []; released.still = 0; released.anchor = null; released.rest = 0; - c.extraBalls.push(released); c.locked = null; c.special = null; c.lastRamp = null; - c.rampRun = { id: null, count: 0 }; c.saveUntil = 0; c.saved = true; - add(c, SCORE.jackpot, [324, 190]); say(c, 'JACKPOT!', c.line !== 'TRAGUARDO!'); say(c, 'MULTIBALL!'); - c.events.push({ kind: 'flipper-jackpot', power: 1 }); fx(c, 'burst', 324, 130, { big: true }); fx(c, 'shake', 0, 0); + if (r.id === 'super') { + // La rampa centrale esiste solo con entrambi i lock: premio e tre palline sul campo. + if (c.locks.length !== 2) return true; + for (const [i, lock] of c.locks.entries()) { + const released = lock.ball; + // Le uscite verticali centrano le corsie interne: nessuna spinta verso le buche laterali. + released.vx = 0; released.vy = 170; released.waiting = false; + released.trail = []; released.still = 0; released.anchor = null; released.rest = 0; + released.releaseAt = c.sim + i * 0.45; released.releasedFrom = lock.rampId; + c.extraBalls.push(released); + } + // Uscita laterale sopra la paletta sinistra, con velocità moderata; niente tiro dritto nella buca. + ball.vx = 0; ball.vy = 160; + c.locks = []; c.special = null; c.specialPaid = false; c.superOpenedAt = null; c.lastRamp = null; + c.rampRun = { id: null, count: 0, until: 0 }; c.saveUntil = 0; c.saved = true; c.balls++; + celebrate(c, 'SUPER SPECIAL', SCORE.super * c.jackpotX, '#e1a0ff'); + say(c, 'PALLINA +1'); say(c, 'MULTIBALL!'); + c.events.push({ kind: 'flipper-super', power: 1 }); for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); } return true; } - if (multiball(c)) return false; - c.rampRun = { id: r.id, count: c.rampRun.id === r.id ? c.rampRun.count + 1 : 1 }; - if (c.rampRun.count < 3) return false; - // La pallina resta visibile nella serratura all'uscita della rampa. Il nuovo lancio è gratuito. - c.locked = { rampId: r.id, ball }; ball.vx = 0; ball.vy = 0; ball.trail = []; - c.special = c.ramps.find(other => other.id !== r.id).id; c.lastRamp = null; + if (multiball(c)) { + celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75'); + c.events.push({ kind: 'flipper-jackpot', power: 1 }); return true; + } + if (c.locks.some(lock => lock.rampId === r.id)) { c.rampRun = { id: null, count: 0, until: 0 }; say(c, 'LOCK OCCUPATO'); return true; } + if (c.special === r.id && !c.specialPaid) { + c.specialPaid = true; + celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75'); + c.events.push({ kind: 'flipper-jackpot', power: 1 }); + } + const continuing = c.rampRun.id === r.id && c.sim <= c.rampRun.until; + c.rampRun = { id: r.id, count: continuing ? c.rampRun.count + 1 : 1, until: continuing ? c.rampRun.until : c.sim + RAMP.lockWindow }; + if (c.rampRun.count < 3) return c.locks.length > 0; + c.locks.push({ rampId: r.id, ball }); ball.vx = 0; ball.vy = 0; ball.trail = []; + c.special = c.locks.length === 2 ? 'super' : c.ramps.find(other => other.id !== 'super' && other.id !== r.id).id; + c.rampRun = { id: null, count: 0, until: 0 }; c.lastRamp = null; c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null; - newBall(c); say(c, 'LOCK!', c.line !== 'TRAGUARDO!'); say(c, 'SPECIAL!'); - c.events.push({ kind: 'flipper-lock', power: 1 }); fx(c, 'ring', ball.x, ball.y); + newBall(c); + celebrate(c, c.locks.length === 2 ? 'SUPER APERTA' : 'BALL LOCKED', 0, c.locks.length === 2 ? '#e1a0ff' : '#72edff'); + c.events.push({ kind: 'flipper-lock', power: 1 }); + if (c.special === 'super') { c.superOpenedAt = c.visual.time; c.events.push({ kind: 'flipper-super-open', power: 1 }); } return true; } function drain(c, ball) { @@ -330,17 +372,20 @@ function drain(c, ball) { if (multiball(c)) { if (c.ball === ball) c.ball = c.extraBalls.shift(); else c.extraBalls = c.extraBalls.filter(b => b !== ball); - c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0 }; - say(c, 'UNA IN GIOCO'); return; + c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0, until: 0 }; + say(c, multiball(c) ? 'DUE IN GIOCO' : 'UNA IN GIOCO'); return; } if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; } - c.rampRun = { id: null, count: 0 }; - if (c.locked) { - // Perduta la sostitutiva: si recupera quella trattenuta, senza scalare una vita o lasciare lock sospesi. - c.locked = null; c.special = null; c.lastRamp = null; c.saved = false; c.saveUntil = 0; - say(c, 'LOCK LIBERO', true); newBall(c); return; + c.rampRun = { id: null, count: 0, until: 0 }; + if (c.locks.length) { + // Si recupera l'ultimo lock. L'eventuale primo resta acquisito e non si scala la riserva. + c.locks.pop(); c.superOpenedAt = null; + c.special = c.locks.length ? c.ramps.find(r => r.id !== 'super' && r.id !== c.locks[0].rampId).id : null; + if (!c.locks.length) c.specialPaid = false; + c.lastRamp = null; c.saved = false; c.saveUntil = 0; + announce(c, 'LOCK LIBERO'); newBall(c); return; } - c.balls--; c.saved = false; c.lastRamp = null; + c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1; if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; } c.ball = null; c.step = 'result'; c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal }; @@ -366,6 +411,12 @@ function hitSeg(ball, w, e) { return { vn: -vn, nx, ny, fresh }; } function collideSeg(c, ball, w) { + if (w.gate) { + if (!ball.inPlay) return; + const hit = hitSeg(ball, w, w.e); + if (hit && hit.vn > 0) ball.vx = Math.min(-100, ball.vx); + return; + } if (!w.sling) { hitSeg(ball, w, w.e); return; } // La sponda elastica: spinge via lungo la normale, tenendo la componente di strisciamento, e un po' a caso come // la gomma vera; solo a un urto nuovo, e solo se picchia. @@ -383,7 +434,7 @@ function collideTarget(c, ball, t) { if (!hit || !hit.fresh || hit.vn < IMPACT) return; t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2); const bank = c.targets.filter(k => k.bank === t.bank); - if (bank.every(k => k.down)) { add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); 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.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) { @@ -465,7 +516,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.locked ? `LOCK: pallina trattenuta. Rampa ${c.special === 'sinistra' ? 'sinistra' : 'destra'} SPECIAL: jackpot ${SCORE.jackpot} e multiball.` : multiball(c) ? 'MULTIBALL: due palline in gioco!' : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3 per il LOCK.` : ''; + const feature = c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.'; const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`; if (c.line) return `${c.line} ${tally}`; return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`; diff --git a/src/engine/render.js b/src/engine/render.js index e39d916..c1544ae 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -166,6 +166,12 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) { } export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) { + // Il flipper copre tutto il canvas: evitare di dipingere e illuminare anche la strada nascosta sotto. + if (game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'flipper') { + drawCloseup(ctx, game, time); + if (debug) drawDebug(ctx, game, debug); + return; + } const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop); if (!bg && backdrop) loadBackground(backdrop); ctx.imageSmoothingEnabled = false; diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 055f68b..944eb73 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -173,6 +173,8 @@ export class Soundscape { else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); } else if (kind === 'flipper-lock') { this.tone(330, 330, 0.25, 0.06, 0, pan); this.tone(220, 220, 0.4, 0.06, 0.2, pan); this.noiseSound(0.08, 1200, 0.05, { pan }); } else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, pan); } + else if (kind === 'flipper-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); } 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 356d999..1dee3ef 100644 --- a/src/main.js +++ b/src/main.js @@ -1189,6 +1189,14 @@ function frame(now) { const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`; if (game && (!still || still !== drawnStill)) { if (running) game.update(dt); + // Il tavolo del flipper ha superfici e luci continue; gli altri ambienti restano in pixel art. + const pinballHD = game.closeup && story.closeups[game.closeup.id].type === 'flipper'; + const sceneScale = pinballHD ? 2 : 1; + if (canvas.width !== WIDTH * sceneScale) { + canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale; + canvas.classList.toggle('pinball-hd', !!pinballHD); + } + ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0); render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() }); // Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto. if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; } diff --git a/style.css b/style.css index 2421c21..e53f395 100644 --- a/style.css +++ b/style.css @@ -24,6 +24,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h #stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; } #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } +#scene.pinball-hd { image-rendering: auto; } #scene.torch { touch-action: none; } #scene.bocce { touch-action: none; } #scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index 760c68a..b6a0a43 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -29,6 +29,7 @@ try { // Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola. const readyAt = await page.evaluate(() => game.closeup.visual.time); await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt); + assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia'); assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia'); assert.ok(await page.locator('#flipper-help').isVisible()); assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper'); @@ -96,13 +97,51 @@ try { assert.deepEqual(reward, { balls: 2, step: 'play', winner: 'player' }); await page.waitForFunction(() => document.querySelector('#bocce-status').textContent.includes('pallina premio vinta')); await page.screenshot({ path: `${out}/${name}-premio.png` }); + // Una sequenza reale di rampe, con il motore: primo lock, secondo lock, rampa centrale e tre palline. + await page.evaluate(async () => { + const m = await import('/src/engine/flipper.js'); + Object.assign(game.closeup, m.newFlipper(73, { level: 'bona' })); + window.pinballShot = id => { + const c = game.closeup, r = c.ramps.find(r => r.id === id); + if (m.waiting(c)) m.launch(c, 0.8); + Object.assign(c.ball, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); + let entered = false; + for (let i = 0; i < 1000; i++) { m.update(c, m.STEP); if (c.ball?.ramp) entered = true; else if (entered) return; } + throw new Error(`rampa ${id} non completata`); + }; + for (let i = 0; i < 3; i++) window.pinballShot('sinistra'); + }); + assert.equal(await page.evaluate(() => game.closeup.locks.length), 1); + await page.screenshot({ path: `${out}/${name}-lock.png` }); + await page.evaluate(async () => { + for (let i = 0; i < 3; i++) window.pinballShot('destra'); + const m = await import('/src/engine/flipper.js'); + for (let i = 0; i < 4; i++) m.update(game.closeup, 0.2); + }); + assert.equal(await page.evaluate(() => game.closeup.special), 'super'); + 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 }; + }); + 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); + await page.screenshot({ path: `${out}/${name}-multiball.png` }); + const frameMs = await page.evaluate(() => new Promise(resolve => { + const stamps = []; const tick = t => { stamps.push(t); if (stamps.length === 20) resolve((t - stamps[0]) / 19); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); + })); + console.log(`${name}: intervallo frame medio ${frameMs.toFixed(1)} ms`); + await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; }); // Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale. const finalScore = await page.evaluate(async () => { const m = await import('/src/engine/flipper.js'), c = game.closeup; - for (let i = 0; i < 4 && c.step === 'play'; i++) { + for (let i = 0; i < 12 && c.step === 'play'; i++) { if (m.waiting(c)) m.launch(c, 0.8); c.saveUntil = 0; - Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); m.update(c, 0.1); + for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 }); m.update(c, 0.1); } return c.score; }); @@ -111,6 +150,7 @@ try { await page.screenshot({ path: `${out}/${name}-fine.png` }); if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup); + await page.waitForFunction(() => document.querySelector('#scene').width === 640); await page.close(); } assert.deepEqual(errors, []); diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index cca54e7..f89dccb 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -277,41 +277,131 @@ test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena'); }); -test('il LOCK: lo stesso ponte tre volte di fila trattiene la pallina e ne dà una nuova; l\'altro ponte SPECIAL vale il jackpot e il multiball', () => { - const c = m.newFlipper(21, { level: 'bona' }); m.launch(c, 0.9); c.score = 0; - const left = c.ramps[0], right = c.ramps[1]; - // Un tiro nel ponte: si aspetta che la pallina esca in fondo, prima che vada altrove (così il conto non si azzera). - const shoot = r => { const [[x0], [x1]] = r.mouth; put(c, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(c, 1 / 60); if (c.ball?.ramp) was = true; else if (was) break; } }; - shoot(left); assert.equal(c.rampRun.count, 1); assert.ok(!c.locked); - shoot(left); assert.equal(c.rampRun.count, 2); - shoot(left); - assert.ok(c.locked && c.locked.rampId === 'sinistra', 'la pallina è trattenuta'); - assert.equal(c.special, 'destra', 'l\'altro ponte è SPECIAL'); - assert.ok(m.waiting(c) && c.ball !== c.locked.ball, 'una pallina nuova sulla molla'); - assert.ok([c.line, ...c.lines].some(l => /LOCK/.test(l ?? '')) && [c.line, ...c.lines].some(l => /SPECIAL/.test(l ?? ''))); - assert.ok(c.events.some(e => e.kind === 'flipper-lock')); - assert.equal(c.balls, 3, 'il lock non costa una pallina'); - // Il ponte sbagliato non libera niente. - m.launch(c, 0.9); c.events.length = 0; shoot(left); - assert.ok(c.locked, 'ancora trattenuta'); - // Il ponte SPECIAL: jackpot, la pallina liberata, due in gioco. - const before = c.score; shoot(right); - assert.ok(c.score - before >= m.SCORE.jackpot + m.SCORE.ramp, `jackpot (${c.score - before})`); - assert.ok(!c.locked && !c.special, 'liberata'); - assert.ok(m.multiball(c) && m.activeBalls(c).length === 2, 'multiball'); - assert.ok(c.events.some(e => e.kind === 'flipper-jackpot')); - // In multiball perdere una pallina non costa: resta l'altra; persa anche quella, si scala. - const riserva = c.balls; // (il jackpot ha passato il traguardo: una pallina premio in più) - Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5); - assert.ok(!m.multiball(c) && c.balls === riserva && m.activeBalls(c).length === 1, 'una in gioco, nessuna persa'); - c.saveUntil = 0; Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5); - assert.equal(c.balls, riserva - 1, 'ora sì'); - // Persa la pallina nuova prima dello SPECIAL: quella trattenuta torna, senza scalare. - const d = m.newFlipper(22, { level: 'bona' }); m.launch(d, 0.9); - const go = r => { const [[x0], [x1]] = r.mouth; put(d, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(d, 1 / 60); if (d.ball?.ramp) was = true; else if (was) break; } }; - go(d.ramps[0]); go(d.ramps[0]); go(d.ramps[0]); assert.ok(d.locked); - m.launch(d, 0.9); d.saveUntil = 0; Object.assign(d.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(d, 0.5); - assert.ok(!d.locked && d.balls === 3 && m.waiting(d), 'lock libero, pallina di nuovo sulla molla'); +function rampShot(c, id) { + if (m.waiting(c)) m.launch(c, 0.8); + const r = c.ramps.find(r => r.id === id); + return ride(c, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null, waiting: false }); +} +function doubleLock(c) { for (const id of ['sinistra', 'destra']) for (let n = 0; n < 3; n++) rampShot(c, id); } + +test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball reale a tre, in ogni ordine', () => { + for (const level of Object.keys(m.LEVELS)) for (const ids of [['sinistra', 'destra'], ['destra', 'sinistra']]) { + const c = m.newFlipper(21, { level }); m.launch(c, 0.9); + const superRamp = c.ramps.find(r => r.id === 'super'); + assert.ok(!m.rampOpen(c, superRamp)); + rampShot(c, ids[0]); assert.equal(c.rampRun.count, 1); + rampShot(c, ids[0]); assert.equal(c.rampRun.count, 2); + rampShot(c, ids[0]); + assert.equal(c.locks.length, 1); assert.equal(c.locks[0].rampId, ids[0]); + assert.equal(c.special, ids[1]); assert.ok(m.waiting(c)); assert.equal(c.balls, 3); + const lockedBall = c.locks[0].ball; + rampShot(c, ids[0]); assert.equal(c.locks.length, 1, 'una rampa occupata non produce altri lock'); + const score = c.score; rampShot(c, ids[1]); + assert.equal(c.score - score, m.SCORE.ramp + m.SCORE.jackpot, 'SPECIAL paga una volta e avvia il secondo lock'); + assert.equal(c.locks[0].ball, lockedBall); assert.equal(c.rampRun.count, 1); + const afterSpecial = c.score; rampShot(c, ids[1]); + assert.equal(c.score - afterSpecial, m.SCORE.ramp, 'niente jackpot ripetuti durante la carica'); + rampShot(c, ids[1]); + assert.equal(c.locks.length, 2); assert.equal(c.special, 'super'); assert.ok(m.rampOpen(c, superRamp)); + assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0); + const before = c.score, reserve = c.balls, hadGoal = !!c.result; + rampShot(c, 'super'); + assert.equal(c.score - before, m.SCORE.super); + assert.equal(c.balls, reserve + 1 + (hadGoal ? 0 : 1), 'premio SUPER, più il traguardo se appena superato'); + assert.equal(c.locks.length, 0); assert.equal(c.special, null); + 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'); + // 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)); + } +}); + +test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie e la SUPER chiusa non cattura', () => { + const c = m.newFlipper(22); m.launch(c, 0.8); + put(c, { x: 320, y: 233, vx: 0, vy: -400 }); run(c, 0.02); + assert.equal(c.ball.ramp, null, 'la rampa centrale chiusa non esiste nella fisica'); + rampShot(c, 'sinistra'); rampShot(c, 'sinistra'); + c.ball.waiting = true; run(c, m.RAMP.lockWindow + 0.1); + assert.equal(c.rampRun.count, 0); rampShot(c, 'sinistra'); assert.equal(c.rampRun.count, 1); + rampShot(c, 'destra'); assert.equal(c.rampRun.id, 'destra'); assert.equal(c.rampRun.count, 1); + c.saveUntil = 0; put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP); + assert.equal(c.rampRun.count, 0); +}); + +test('la SUPER accompagna alla paletta sinistra e il multiball libera le palline sfalsate', () => { + 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; + 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; } + } + assert.ok(recovered, `${level}: ripresa dalla paletta, senza cadere direttamente nella buca centrale`); + } +}); + +test('perdere una sostitutiva restituisce un lock alla volta senza consumare vite; la SUPER si richiude', () => { + const c = m.newFlipper(23, { level: 'male' }); doubleLock(c); + const lives = c.balls; + for (const remaining of [1, 0]) { + m.launch(c, 0.8); c.saveUntil = 0; + put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP); + assert.equal(c.locks.length, remaining); assert.equal(c.balls, lives); assert.ok(m.waiting(c)); + assert.notEqual(c.special, 'super'); assert.equal(c.superOpenedAt, null); + } + assert.equal(c.special, null); assert.equal(c.specialPaid, false); +}); + +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 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); + const [a, b, spare] = m.activeBalls(c), bumper = c.bumpers[0]; + spare.waiting = true; + Object.assign(a, { x: bumper.x, y: bumper.y + bumper.r + 4, vx: 0, vy: -200, ramp: null, contacts: [] }); + Object.assign(b, { x: bumper.x, y: bumper.y - bumper.r - 4, vx: 0, vy: 200, ramp: null, contacts: [] }); + const score = c.score; m.update(c, m.STEP); + assert.equal(c.score - score, m.SCORE.bumper * 2, 'due urti sullo stesso fungo contano separatamente'); + Object.assign(a, { x: 310, y: 275, vx: 100, vy: 0 }); + Object.assign(b, { x: 318, y: 275, vx: -100, vy: 0 }); + m.update(c, m.STEP); assert.ok(a.vx < 0 && b.vx > 0, 'le palline si respingono'); + const lives = c.balls; + Object.assign(a, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP); + assert.equal(c.balls, lives); assert.equal(m.activeBalls(c).length, 2); +}); + +test('i bersagli alzano il jackpot fino a x3 e la valvola impedisce il rientro sulla molla', () => { + const c = m.newFlipper(25); m.launch(c, 0.8); + for (const bank of ['pieve', 'torre']) for (let i = 0; i < 3; i++) { + const t = c.targets.find(t => t.bank === bank && t.i === i), 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, ramp: null }); run(c, 0.02); + } + assert.equal(c.jackpotX, 3); doubleLock(c); + const before = c.score; rampShot(c, 'super'); assert.equal(c.score - before, m.SCORE.super * 3); + for (const [x, y] of [[434, 52], [438, 52], [441, 56]]) { + const d = m.newFlipper(26); m.launch(d, 0.8); + put(d, { x, y, vx: 0, vy: 240, inPlay: true }); + let enteredShooter = false; + for (let i = 0; i < 240; i++) { m.update(d, m.STEP); if (d.ball.x > m.LANE.x0 + m.R && d.ball.y > 76) enteredShooter = true; } + assert.ok(!enteredShooter, `valvola chiusa per il ritorno a x=${x}`); + } }); test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { @@ -332,15 +422,15 @@ test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giu const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); }; draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); assert.ok(calls.filter(([op]) => op === 'arc').every(([, , , r]) => r > 0), 'anche viti e inserti piccoli hanno raggi validi per Canvas'); - for (const r of c.ramps) { + for (const r of c.ramps.filter(r => r.id !== 'super')) { const x = (r.mouth[0][0] + r.mouth[1][0]) / 2; - assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === 'RAMPA' && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso'); + assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === (r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO') && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso'); } assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'MANCANO 60.000'), 'quanto manca al traguardo, nella riga in basso'); assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste'); - const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass && w === 200 && h === 360), 'il fondale sul fianco'); + const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass ), 'il fondale sul fianco'); m.launch(c, 0.8); run(c, 1); calls.length = 0; draw(); - assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi'); + assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'PUNTEGGIO'), 'il nuovo display del punteggio'); c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw(); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!')); // Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti).