// 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'; 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 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(); } 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 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 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; } 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']]); } 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; 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 layerOf(key, paint, bounds) { let layer = layers.get(key); if (!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); } return layer; } function cached(g, key, paint, bounds = [0, 0, 640, 360]) { if (typeof document === 'undefined') { paint(g); return; } g.drawImage(layerOf(key, paint, bounds), ...bounds); } // Il mobile, che non cambia mai, sta sullo sfondo CSS della tela in pagina invece di essere copiato a ogni fotogramma: // una copia a tutto schermo fra gli altri tratti costava da sola mezzo secondo ogni 150 fotogrammi (swiftshader). La // tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta. const sheets = new Map(); function cabinetBehind(g, key, paint) { const canvas = g.canvas; if (typeof document === 'undefined' || !(canvas instanceof HTMLCanvasElement) || !canvas.isConnected) return false; let url = sheets.get(key); if (!url) { url = `url(${layerOf(key, paint, [0, 0, 640, 360]).toDataURL()})`; sheets.set(key, url); } if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; } return true; } export function cabinetAway(canvas) { canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; } 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'); } const shown = new WeakMap(); 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 }; 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); 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); 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'); } } 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'); } 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 (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 === '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 === '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'); } 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(); } } export function drawFlipper(g, c, def, bg, glass) { const time = c.visual.time; // Niente estremità e giunti arrotondati nei tratti di ogni fotogramma: costano più di tutto il resto (vedi la // memoria «effetti leggeri»); il mobile e i binari, in cache, li tengono. g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'butt'; g.lineJoin = 'miter'; const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0); const key = `cabinet:${!!bg}:${!!glass}`, paint = layer => cabinet(layer, c, bg, glass); if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); } else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint); 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'); } 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'); } 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(); }); } 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(); }