Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
419 lines
28 KiB
JavaScript
419 lines
28 KiB
JavaScript
// 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();
|
||
}
|