Files
2026-09-12 01:33:07 -04:00

1158 lines
39 KiB
JavaScript

// Canvas renderer. World units are millimetres; one transform maps the table onto the canvas.
// Static artwork is drawn once per resize into an offscreen canvas; moving parts are drawn every frame.
import { WIDTH, HEIGHT, LEFT, RIGHT, LANE_LEFT, LANE_RIGHT, SHOOTER_X, PLUNGER_REST_Y, CX, LANE_TOP } from './table.js';
import { fmt, KICKBACK } from './game.js';
const APRON_Y = 986;
const COLORS = {
lane: '#ffd23f',
multiplier: '#ff8a1f',
drop: '#ff4d6d',
standup: '#34e7ff',
bumper: ['#ff3fa4', '#34e7ff', '#b16cff'],
sling: '#ff3fa4',
rubber: '#f1ece2',
lock: '#ffd23f',
jackpot: '#ffe14d',
};
function seeded(seed) {
let s = seed;
return () => ((s = (s * 16807) % 2147483647) / 2147483647);
}
export class Renderer {
constructor(canvas, game) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.game = game;
this.staticLayer = document.createElement('canvas');
this.apronLayer = document.createElement('canvas');
this.glowCache = new Map();
this.particles = [];
this.popups = [];
this.trails = new WeakMap(); // per-ball motion trail (multiball can have several balls at once)
this.shake = 0;
this.time = 0;
}
resize(cssHeight, dpr) {
const cssWidth = (cssHeight * WIDTH) / HEIGHT;
this.canvas.style.width = `${cssWidth}px`;
this.canvas.style.height = `${cssHeight}px`;
const w = Math.floor(cssWidth * dpr);
const h = Math.floor(cssHeight * dpr);
for (const c of [this.canvas, this.staticLayer, this.apronLayer]) {
c.width = w;
c.height = h;
}
this.sx = w / WIDTH;
this.sy = h / HEIGHT;
this.drawStatic(this.staticLayer.getContext('2d'));
this.drawApron(this.apronLayer.getContext('2d'));
}
worldTransform(ctx, ox = 0, oy = 0) {
ctx.setTransform(this.sx, 0, 0, this.sy, ox * this.sx, oy * this.sy);
}
// ---- Helpers ------------------------------------------------------------------------------
glowSprite(color) {
let sprite = this.glowCache.get(color);
if (sprite) return sprite;
sprite = document.createElement('canvas');
sprite.width = sprite.height = 64;
const g = sprite.getContext('2d');
const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, color);
grad.addColorStop(0.35, color + '88');
grad.addColorStop(1, color + '00');
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
this.glowCache.set(color, sprite);
return sprite;
}
glow(ctx, x, y, radius, color, alpha = 1) {
if (alpha <= 0.01) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = Math.min(1, alpha);
ctx.drawImage(this.glowSprite(color), x - radius, y - radius, radius * 2, radius * 2);
ctx.restore();
}
polygon(ctx, points) {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
ctx.closePath();
}
/** A round insert lamp. */
lamp(ctx, x, y, r, color, on, label = '') {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = on ? color : color + '2e';
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = on ? '#ffffffcc' : color + '66';
ctx.stroke();
if (on) this.glow(ctx, x, y, r * 3.2, color, 0.8);
if (label) {
ctx.fillStyle = on ? '#1a0b14' : color + '99';
ctx.font = `800 ${r * 0.95}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, x, y + 0.5);
}
}
/** A triangular arrow insert pointing along angle `a`. */
arrow(ctx, x, y, size, a, color, on) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.beginPath();
ctx.moveTo(size, 0);
ctx.lineTo(-size * 0.7, size * 0.75);
ctx.lineTo(-size * 0.35, 0);
ctx.lineTo(-size * 0.7, -size * 0.75);
ctx.closePath();
ctx.fillStyle = on ? color : color + '2e';
ctx.fill();
ctx.strokeStyle = on ? '#ffffffaa' : color + '55';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
if (on) this.glow(ctx, x, y, size * 3, color, 0.7);
}
metalRail(ctx, points, width) {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.strokeStyle = '#2b2f3a';
ctx.lineWidth = width + 2;
ctx.stroke();
ctx.strokeStyle = '#aeb6c6';
ctx.lineWidth = width;
ctx.stroke();
ctx.strokeStyle = '#eef2fa';
ctx.lineWidth = width * 0.3;
ctx.stroke();
}
/** Screen Y for a point at height z above the playfield: nudged toward the viewer, matching drawBall. */
liftY(y, z) {
return y + z * 0.4;
}
rubberPost(ctx, x, y, r) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = COLORS.rubber;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, r * 0.45, 0, Math.PI * 2);
ctx.fillStyle = '#9aa1ad';
ctx.fill();
}
// ---- Static artwork -----------------------------------------------------------------------
drawStatic(ctx) {
const t = this.game.table;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
this.worldTransform(ctx);
// Cabinet surround.
const wood = ctx.createLinearGradient(0, 0, WIDTH, 0);
wood.addColorStop(0, '#231a3d');
wood.addColorStop(0.5, '#171129');
wood.addColorStop(1, '#231a3d');
ctx.fillStyle = wood;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Playfield.
this.polygon(ctx, t.outline);
const pf = ctx.createLinearGradient(0, 0, 0, HEIGHT);
pf.addColorStop(0, '#1c0f52');
pf.addColorStop(0.45, '#2a0f5c');
pf.addColorStop(1, '#081b45');
ctx.fillStyle = pf;
ctx.fill();
ctx.save();
this.polygon(ctx, t.outline);
ctx.clip();
this.drawPlayfieldArt(ctx);
ctx.restore();
// Outline rim.
ctx.lineJoin = 'round';
this.polygon(ctx, t.outline);
ctx.strokeStyle = '#8f98ad';
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = '#ff3fa455';
ctx.lineWidth = 1;
ctx.stroke();
// Shooter-lane divider (same finish as the cabinet so both side bulges match).
this.polygon(ctx, t.divider);
ctx.fillStyle = '#1f1736';
ctx.fill();
ctx.strokeStyle = '#8f98ad';
ctx.lineWidth = 2;
ctx.stroke();
// Kicker rubbers on the side bulges.
for (const [a, b] of [
[[LEFT, 380], [70, 480]],
[[RIGHT, 380], [2 * CX - 70, 480]],
]) {
ctx.beginPath();
ctx.moveTo(...a);
ctx.lineTo(...b);
ctx.strokeStyle = COLORS.rubber;
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.stroke();
}
// Shooter lane floor markings.
ctx.fillStyle = '#ffffff14';
for (let y = 420; y < 960; y += 60) {
ctx.beginPath();
ctx.moveTo(SHOOTER_X, y - 10);
ctx.lineTo(SHOOTER_X + 8, y + 4);
ctx.lineTo(SHOOTER_X - 8, y + 4);
ctx.closePath();
ctx.fill();
}
// Top lane guides, inlane guides and posts.
for (const x of t.laneGuideXs) this.metalRail(ctx, [[x, t.laneTop], [x, t.laneBottom]], 6);
for (const g of t.guides) this.metalRail(ctx, g, 6);
for (const p of t.posts) {
if (p.radius >= 5) this.rubberPost(ctx, p.x, p.y, p.radius);
}
}
drawPlayfieldArt(ctx) {
const rand = seeded(7);
// Nebula glows.
for (const [x, y, r, c] of [
[120, 260, 260, '#ff3fa4'],
[400, 520, 280, '#34e7ff'],
[220, 760, 240, '#b16cff'],
]) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, c + '30');
g.addColorStop(1, c + '00');
ctx.fillStyle = g;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
// Perspective grid on the lower half.
ctx.strokeStyle = '#34e7ff14';
ctx.lineWidth = 1;
for (let i = -8; i <= 8; i++) {
ctx.beginPath();
ctx.moveTo(CX + i * 12, 520);
ctx.lineTo(CX + i * 70, 1000);
ctx.stroke();
}
for (let y = 540, step = 14; y < 1000; y += step, step *= 1.18) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(WIDTH, y);
ctx.stroke();
}
// Stars.
for (let i = 0; i < 140; i++) {
const x = rand() * WIDTH;
const y = rand() * 620;
const r = rand() * 1.3 + 0.3;
ctx.fillStyle = `rgba(255,255,255,${0.25 + rand() * 0.5})`;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
this.drawTopStructure(ctx);
// Starburst behind the pop bumpers.
ctx.save();
ctx.translate(250, 245);
for (let i = 0; i < 24; i++) {
ctx.rotate((Math.PI * 2) / 24);
ctx.fillStyle = i % 2 ? '#ff3fa414' : '#34e7ff10';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(160, -12);
ctx.lineTo(160, 12);
ctx.closePath();
ctx.fill();
}
ctx.restore();
// Centre logo.
ctx.save();
ctx.translate(CX, 690);
ctx.rotate(-0.12);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'italic 900 62px system-ui, sans-serif';
ctx.lineWidth = 2;
ctx.strokeStyle = '#ff3fa466';
ctx.strokeText('NEON', 0, 0);
ctx.fillStyle = '#ff3fa41c';
ctx.fillText('NEON', 0, 0);
ctx.font = 'italic 800 20px system-ui, sans-serif';
ctx.fillStyle = '#34e7ff55';
ctx.fillText('P I N B A L L', 0, 44);
ctx.restore();
// Lane labels.
ctx.font = '700 9px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#ffffff55';
ctx.fillText('BONUS X', 250, 184);
ctx.save();
ctx.translate(SHOOTER_X, 700);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = '#ffd23f55';
ctx.font = '800 11px system-ui, sans-serif';
ctx.fillText('SKILL SHOT', 0, 4);
ctx.restore();
}
/**
* A small silhouetted skyline at the very top of the arch — three towers, lit windows, a spire.
* This is part of the cached static layer (baked in once per resize), so the windows get a fixed
* lit/unlit state from the seeded RNG rather than a live blink, which would never actually animate.
*/
drawTopStructure(ctx) {
const rand = seeded(41);
const tower = (cx, roofY, bodyTop, bodyBottom, halfWidth, roofColor, bodyColor) => {
ctx.beginPath();
ctx.moveTo(cx, roofY);
ctx.lineTo(cx + halfWidth * 1.3, bodyTop);
ctx.lineTo(cx - halfWidth * 1.3, bodyTop);
ctx.closePath();
ctx.fillStyle = roofColor;
ctx.fill();
ctx.fillStyle = bodyColor;
ctx.fillRect(cx - halfWidth, bodyTop, halfWidth * 2, bodyBottom - bodyTop);
ctx.strokeStyle = '#000000aa';
ctx.lineWidth = 1.5;
ctx.strokeRect(cx - halfWidth, bodyTop, halfWidth * 2, bodyBottom - bodyTop);
// a window, lit or not (fixed per tower — this art is drawn once and cached)
const winOn = rand() > 0.4;
ctx.fillStyle = winOn ? '#ffe37a' : '#5a4a2a';
ctx.fillRect(cx - 3, bodyTop + (bodyBottom - bodyTop) * 0.45, 6, 8);
if (winOn) this.glow(ctx, cx, bodyTop + (bodyBottom - bodyTop) * 0.45 + 4, 14, '#ffe37a', 0.6);
};
// connecting parapet wall
ctx.fillStyle = '#241a3c';
ctx.fillRect(CX - 95, 88, 190, 12);
for (let x = CX - 92; x < CX + 92; x += 16) ctx.fillRect(x, 82, 9, 8);
ctx.strokeStyle = '#000000aa';
ctx.lineWidth = 1.5;
ctx.strokeRect(CX - 95, 88, 190, 12);
// flanking towers
tower(CX - 65, 50, 66, 96, 13, '#ff3fa4', '#2a1c46');
tower(CX + 65, 50, 66, 96, 13, '#34e7ff', '#2a1c46');
// central, taller tower with a spire
tower(CX, 34, 56, 92, 16, '#ffd23f', '#332253');
ctx.beginPath();
ctx.moveTo(CX, 12);
ctx.lineTo(CX + 2.5, 34);
ctx.lineTo(CX - 2.5, 34);
ctx.closePath();
ctx.fillStyle = '#ffd23f';
ctx.fill();
this.glow(ctx, CX, 20, 26, '#ffd23f', 0.5);
// rim glow along the whole silhouette
this.glow(ctx, CX, 60, 130, '#b16cff', 0.28);
}
drawApron(ctx) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
this.worldTransform(ctx);
const pts = [
[LEFT, APRON_Y + 14],
[CX - 60, APRON_Y],
[CX + 60, APRON_Y],
[RIGHT, APRON_Y + 14],
[RIGHT, HEIGHT],
[LEFT, HEIGHT],
];
this.polygon(ctx, pts);
const g = ctx.createLinearGradient(0, APRON_Y, 0, HEIGHT);
g.addColorStop(0, '#3a1250');
g.addColorStop(1, '#16081f');
ctx.fillStyle = g;
ctx.fill();
ctx.strokeStyle = '#ff3fa4';
ctx.lineWidth = 2;
ctx.stroke();
// Instruction cards.
for (const [x, lines] of [
[LEFT + 14, ['3 BALLS PER GAME', 'SKILL SHOT 25,000']],
[CX + 44, ['LANES ADVANCE BONUS', 'DROPS LIGHT KICKBACK']],
]) {
ctx.fillStyle = '#f4eedd';
ctx.fillRect(x, APRON_Y + 30, 170, 36);
ctx.fillStyle = '#2a1a3a';
ctx.font = '700 9.5px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(lines[0], x + 8, APRON_Y + 41);
ctx.fillText(lines[1], x + 8, APRON_Y + 56);
}
}
// ---- Effects ------------------------------------------------------------------------------
onEvent(e) {
const g = this.game;
switch (e.type) {
case 'score':
this.popups.push({ x: e.x, y: e.y, text: `+${fmt(e.points)}`, life: 0.9 });
break;
case 'bumper':
this.sparks(e.x, e.y, COLORS.bumper[e.index], 14);
this.shake = Math.max(this.shake, 1.2);
break;
case 'sling': {
const s = g.table.slings[e.side === 'left' ? 0 : 1];
this.sparks((s.top[0] + s.tip[0]) / 2, (s.top[1] + s.tip[1]) / 2, COLORS.sling, 10);
this.shake = Math.max(this.shake, 0.8);
break;
}
case 'drop':
case 'standup':
if (g.ball) this.sparks(g.ball.x, g.ball.y, e.type === 'drop' ? COLORS.drop : COLORS.standup, 8);
break;
case 'complete':
if (g.ball) this.sparks(g.ball.x, g.ball.y, '#ffd23f', 30);
break;
case 'ramp':
case 'rampEnter':
this.sparks(g.table.ramps.find((r) => r.track.side === e.side)?.entrance.x ?? CX, 700, e.side === 'left' ? '#ff3fa4' : '#34e7ff', 6);
break;
case 'jackpot':
this.sparks(g.table.lock.x, g.table.lock.y, COLORS.jackpot, 22);
this.shake = Math.max(this.shake, 1);
break;
case 'superJackpot':
this.sparks(g.table.lock.x, g.table.lock.y, '#ff3fa4', 45);
this.shake = 2.2;
break;
case 'lock':
case 'multiball':
this.sparks(g.table.lock.x, g.table.lock.y, COLORS.lock, e.count ? 12 : 26);
this.shake = Math.max(this.shake, e.count ? 0.6 : 1.5);
break;
case 'drain':
this.shake = 2;
break;
default:
break;
}
}
sparks(x, y, color, n) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = 60 + Math.random() * 220;
this.particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.5 + Math.random() * 0.4, color });
}
}
// ---- Frame --------------------------------------------------------------------------------
render(alpha, frameDt) {
const ctx = this.ctx;
const g = this.game;
const t = g.table;
this.time += frameDt;
this.shake = Math.max(0, this.shake - frameDt * 10);
const ox = this.shake ? (Math.random() - 0.5) * this.shake : 0;
const oy = this.shake ? (Math.random() - 0.5) * this.shake : 0;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#07060d';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx.setTransform(1, 0, 0, 1, ox * this.sx, oy * this.sy);
ctx.drawImage(this.staticLayer, 0, 0);
this.worldTransform(ctx, ox, oy);
this.drawLamps(ctx);
this.drawTargets(ctx);
this.drawSlings(ctx);
this.drawGate(ctx);
this.drawPlunger(ctx);
this.drawLockHole(ctx);
for (const f of t.flippers) this.drawFlipper(ctx, f, f.prevAngle + (f.angle - f.prevAngle) * alpha);
this.drawRamps(ctx); // clear plastic/wire, arcing over everything beneath it
// The pop bumpers and the lock's readouts sit right where the two loops cross overhead, so they're
// drawn after the ramps too — gameplay clarity on the things you actually shoot at wins over strict
// draw-order-by-height here, same trade-off real ramps make with clear plastic over what's below.
this.drawBumpers(ctx);
this.drawLockLamps(ctx);
this.drawBalls(ctx, alpha); // the ball is always drawn last so it reads clearly, even riding a ramp
ctx.setTransform(1, 0, 0, 1, ox * this.sx, oy * this.sy);
ctx.drawImage(this.apronLayer, 0, 0);
this.worldTransform(ctx, ox, oy);
this.drawApronLamps(ctx);
this.drawParticles(ctx, frameDt);
this.drawOverlay(ctx);
}
blink(rate = 4) {
return (this.time * rate) % 1 < 0.5;
}
drawLamps(ctx) {
const g = this.game;
const t = g.table;
// Top lanes (the skill-shot lane blinks until the ball is in play).
t.rollovers.forEach((r) => {
if (r.kind === 'top') {
const skill = g.state === 'play' && g.skillShotLive && r.index === g.skillShotLane;
const on = g.topLit[r.index] || (skill && this.blink(5));
this.lamp(ctx, r.x, 158, 8, COLORS.lane, on);
ctx.strokeStyle = '#d8dde8aa';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(r.x, r.y - 10);
ctx.lineTo(r.x, r.y + 10);
ctx.stroke();
this.glow(ctx, r.x, r.y, 30, COLORS.lane, r.flash);
} else {
const color = r.kind === 'inlane' ? '#6dff9b' : '#ff4d6d';
this.lamp(ctx, r.x, r.y + 30, 6, color, r.flash > 0.05);
ctx.strokeStyle = '#d8dde888';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(r.x, r.y - 8);
ctx.lineTo(r.x, r.y + 8);
ctx.stroke();
}
});
// Bonus multiplier inserts.
[2, 3, 4, 5].forEach((m, i) => {
const x = CX - 54 + i * 36;
const y = 600 + Math.abs(i - 1.5) * 8;
this.lamp(ctx, x, y, 12, COLORS.multiplier, g.multiplier >= m, `${m}X`);
});
// Super bumpers.
const superOn = g.superBumpers > 0 && (g.superBumpers > 4 || this.blink(6));
this.lamp(ctx, 250, 420, 10, '#ff3fa4', superOn, 'S');
// Jackpot lamps at each ramp's exit (its own flipper's lane) — the two entrances sit close
// together near the middle, but the exits are well apart, one per side, so the labels never collide.
for (const ramp of t.ramps) {
const lit = g.state === 'multiball' && g.jackpot[ramp.track.side];
const exit = ramp.track.sample(ramp.track.length);
const sign = ramp.track.side === 'left' ? -1 : 1;
const x = exit.x + sign * 26;
this.arrow(ctx, x, exit.y - 6, 8, -Math.PI / 2, ramp.track.color, lit);
if (lit) {
ctx.fillStyle = ramp.track.color + 'dd';
ctx.font = '800 7px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = '#07061099';
ctx.lineWidth = 2.5;
ctx.strokeText('JACKPOT', x, exit.y - 18);
ctx.fillText('JACKPOT', x, exit.y - 18);
}
}
// Kickback in the left outlane: arrow lamp, label and the kicker arm.
this.arrow(ctx, KICKBACK.x, 850, 8, -Math.PI / 2, '#6dff9b', g.kickbackLit);
ctx.save();
ctx.translate(KICKBACK.x, 900);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = g.kickbackLit ? '#6dff9bdd' : '#6dff9b44';
ctx.font = '800 8px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('KICKBACK', 0, 0);
ctx.restore();
ctx.fillStyle = '#c9ced8';
ctx.beginPath();
ctx.roundRect(KICKBACK.x - 9, 958 - g.kickbackFlash * 22, 18, 6, 2);
ctx.fill();
this.glow(ctx, KICKBACK.x, 930, 40, '#6dff9b', g.kickbackFlash);
}
/** A bold arrow-shaped target panel, pointing along its normal into the playfield. */
drawArrowTarget(ctx, d, litColor, dimColor, lit, amount) {
if (amount <= 0.02) return;
// slot it sits in
ctx.save();
ctx.translate(d.x, d.y);
ctx.rotate(Math.atan2(d.uy, d.ux));
ctx.fillStyle = '#05030a';
ctx.fillRect(-d.halfWidth - 2, -5, d.halfWidth * 2 + 4, 10);
ctx.restore();
const size = d.halfWidth * 1.9;
const angle = Math.atan2(d.ny, d.nx);
ctx.save();
ctx.translate(d.x, d.y);
ctx.rotate(angle);
ctx.scale(1, amount);
ctx.beginPath();
ctx.moveTo(size, 0);
ctx.lineTo(-size * 0.65, size * 0.8);
ctx.lineTo(-size * 0.3, 0);
ctx.lineTo(-size * 0.65, -size * 0.8);
ctx.closePath();
const grad = ctx.createLinearGradient(-size * 0.6, 0, size, 0);
grad.addColorStop(0, dimColor);
grad.addColorStop(1, litColor);
ctx.fillStyle = grad;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = lit ? '#fffdf0' : '#241a04';
ctx.stroke();
ctx.restore();
this.glow(ctx, d.x, d.y, size * (lit ? 2.6 : 1.6), litColor, lit ? 0.85 : d.flash * 0.9);
}
drawTargets(ctx) {
const t = this.game.table;
for (const d of t.dropTargets) this.drawArrowTarget(ctx, d, '#ffb020', '#7a3d00', d.flash > 0.05, 1 - d.drop);
for (const s of t.standups) this.drawArrowTarget(ctx, s, s.lit ? '#fff4b8' : '#ffcf3f', s.lit ? '#ffdb70' : '#7a5a00', s.lit, 1);
}
/** A jagged lightning-bolt decal running from (x1,y1) to (x2,y2), the slingshot face's theme. */
drawLightningBolt(ctx, x1, y1, x2, y2, width, color, glowAmt) {
const dx = x2 - x1;
const dy = y2 - y1;
const nx = -dy;
const ny = dx;
const offsets = [0, 0.4, -0.3, 0.45, -0.2, 0];
const pts = offsets.map((o, i) => {
const t = i / (offsets.length - 1);
return [x1 + dx * t + nx * o, y1 + dy * t + ny * o];
});
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.strokeStyle = '#000000aa';
ctx.lineWidth = width * 0.6;
ctx.stroke();
ctx.strokeStyle = color;
ctx.lineWidth = width * 0.4;
ctx.stroke();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = width * 0.15;
ctx.stroke();
if (glowAmt > 0) this.glow(ctx, (x1 + x2) / 2, (y1 + y2) / 2, width * 4, color, glowAmt);
}
drawSlings(ctx) {
for (const s of this.game.table.slings) {
const [tx, ty] = s.top;
const [bx, by] = s.bottom;
const [px, py] = s.tip;
// plastic
this.polygon(ctx, [s.top, s.bottom, s.tip]);
const grad = ctx.createLinearGradient(bx, ty, px, py);
grad.addColorStop(0, '#241832');
grad.addColorStop(1, '#3a2450');
ctx.fillStyle = grad;
ctx.fill();
// rubber ring; the kicking face bulges briefly when it fires
const bulge = s.flash * 7;
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(bx, by);
ctx.lineTo(px, py);
ctx.quadraticCurveTo((tx + px) / 2 + s.nx * bulge, (ty + py) / 2 + s.ny * bulge, tx, ty);
ctx.closePath();
ctx.lineJoin = 'round';
ctx.strokeStyle = COLORS.rubber;
ctx.lineWidth = 9;
ctx.stroke();
ctx.strokeStyle = '#00000033';
ctx.lineWidth = 1;
ctx.stroke();
// lightning-bolt decal on the plastic face
this.drawLightningBolt(ctx, tx, ty, px, py, 13, '#ffe14d', s.flash);
for (const [x, y] of [s.top, s.bottom, s.tip]) this.rubberPost(ctx, x, y, 5);
this.glow(ctx, (tx + px) / 2, (ty + py) / 2, 50, COLORS.sling, s.flash);
}
}
drawBumpers(ctx) {
const g = this.game;
for (const b of g.table.bumpers) {
const color = COLORS.bumper[b.index];
const lit = b.flash > 0 || g.superBumpers > 0;
// shadow and skirt
ctx.beginPath();
ctx.arc(b.x + 3, b.y + 4, b.radius + 3, 0, Math.PI * 2);
ctx.fillStyle = '#00000066';
ctx.fill();
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius + 2, 0, Math.PI * 2);
ctx.fillStyle = '#d8dde8';
ctx.fill();
// body
const body = ctx.createRadialGradient(b.x - 6, b.y - 8, 2, b.x, b.y, b.radius);
body.addColorStop(0, '#ffffff');
body.addColorStop(0.35, color);
body.addColorStop(1, '#1a0a26');
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius - 1 + b.flash * 1.5, 0, Math.PI * 2);
ctx.fillStyle = body;
ctx.fill();
// cap
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius * 0.62, 0, Math.PI * 2);
ctx.fillStyle = lit ? '#fff7fb' : '#e9e3f5';
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = color;
ctx.font = '900 9px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(g.superBumpers > 0 ? '1000' : '100', b.x, b.y + 0.5);
this.glow(ctx, b.x, b.y, b.radius * 3, color, b.flash * 1.2 + (g.superBumpers > 0 ? 0.25 : 0));
}
}
drawGate(ctx) {
const gate = this.game.table.gate;
gate.swing = Math.max(0, gate.swing - 0.08);
ctx.strokeStyle = '#d8dde8';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(gate.ax, gate.ay);
ctx.lineTo(gate.bx - gate.swing * 6, gate.by + gate.swing * 10);
ctx.stroke();
}
drawPlunger(ctx) {
const y = PLUNGER_REST_Y + this.game.plunger.pos;
const x0 = LANE_LEFT + 2;
const w = LANE_RIGHT - LANE_LEFT - 4;
// spring
ctx.strokeStyle = '#9aa1ad';
ctx.lineWidth = 1.5;
ctx.beginPath();
const springTop = y + 12;
const springBottom = HEIGHT - 4;
const coils = 9;
for (let i = 0; i <= coils * 2; i++) {
const sy = springTop + ((springBottom - springTop) * i) / (coils * 2);
const sx = SHOOTER_X + (i % 2 ? 9 : -9);
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
}
ctx.stroke();
// rod
ctx.fillStyle = '#c9ced8';
ctx.fillRect(SHOOTER_X - 3, y, 6, HEIGHT - y);
// tip
const tip = ctx.createLinearGradient(x0, 0, x0 + w, 0);
tip.addColorStop(0, '#7a0d2a');
tip.addColorStop(0.5, '#ff3f6b');
tip.addColorStop(1, '#7a0d2a');
ctx.fillStyle = tip;
ctx.beginPath();
ctx.roundRect(x0, y, w, 12, 3);
ctx.fill();
// power meter while pulling
const p = this.game.plunger;
if (p.pulling) {
const h = 120 * p.pull;
ctx.fillStyle = '#ffffff22';
ctx.fillRect(LANE_RIGHT + 3, PLUNGER_REST_Y - 120, 6, 120);
ctx.fillStyle = p.pull > 0.5 ? '#ff3f6b' : '#ffd23f';
ctx.fillRect(LANE_RIGHT + 3, PLUNGER_REST_Y - h, 6, h);
// Soft plunge (skill shot) below the tick, full plunge (orbit) above it.
ctx.fillStyle = '#ffffff';
ctx.fillRect(LANE_RIGHT + 1, PLUNGER_REST_Y - 61, 10, 2);
}
}
// ---- Ramps, wireforms and the lock scoop ---------------------------------------------------
/** Offset polylines (left/right rail) for a path of [x, y] screen points, `half` mm either side. */
strokePath(ctx, pts) {
ctx.beginPath();
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.stroke();
}
/**
* A glossy chrome tube along `pts`, the ramp/wireform look: several strokes of shrinking width and
* lightening colour, nested inside one another, is the standard 2D trick for a round cross-section —
* the same idea as `metalRail` but wider, with a coloured LED accent down one side that brightens when
* the jackpot behind it is lit.
*/
drawTube(ctx, pts, width, color, lit) {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const path = () => this.strokePath(ctx, pts);
// soft coloured glow behind the tube, stronger when lit
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = color + (lit ? '55' : '18');
ctx.lineWidth = width + (lit ? 16 : 8);
path();
ctx.restore();
// dark drop shadow / outline
ctx.strokeStyle = 'rgba(3,2,8,0.75)';
ctx.lineWidth = width + 5;
path();
// chrome body: dark -> mid -> bright highlight, nested strokes read as a cylinder
ctx.strokeStyle = '#2e323e';
ctx.lineWidth = width;
path();
ctx.strokeStyle = '#aeb6c6';
ctx.lineWidth = width * 0.66;
path();
ctx.strokeStyle = '#f3f6fb';
ctx.lineWidth = width * 0.26;
path();
// coloured LED accent, offset to one edge of the tube like a light strip
const [ox, oy] = this.offsetPath(pts, width * 0.32);
ctx.strokeStyle = lit ? '#ffffff' : color;
ctx.lineWidth = width * 0.14;
ctx.beginPath();
ox.forEach((x, i) => (i ? ctx.lineTo(x, oy[i]) : ctx.moveTo(x, oy[i])));
ctx.stroke();
if (lit) this.glow(ctx, pts[Math.floor(pts.length / 2)][0], pts[Math.floor(pts.length / 2)][1], width * 2.2, color, 0.5);
}
/** Perpendicular-offset version of a polyline, for the accent stripe running along one edge of a tube. */
offsetPath(pts, dist) {
const ox = [];
const oy = [];
for (let i = 0; i < pts.length; i++) {
const [x, y] = pts[i];
const [px, py] = pts[Math.max(0, i - 1)];
const [nx, ny] = pts[Math.min(pts.length - 1, i + 1)];
let dx = nx - px;
let dy = ny - py;
const len = Math.hypot(dx, dy) || 1;
dx /= len;
dy /= len;
ox.push(x - dy * dist);
oy.push(y + dx * dist);
}
return [ox, oy];
}
/** A few metal cross-braces along the wireform, like the real thing. */
drawWireBraces(ctx, pts, width) {
const [ox, oy] = this.offsetPath(pts, width * 0.55);
ctx.strokeStyle = '#9aa1ad99';
ctx.lineWidth = 2;
for (let i = 2; i < pts.length - 2; i += 3) {
ctx.beginPath();
ctx.moveTo(pts[i][0] - (ox[i] - pts[i][0]), pts[i][1] - (oy[i] - pts[i][1]));
ctx.lineTo(ox[i], oy[i]);
ctx.stroke();
}
}
drawRamps(ctx) {
const g = this.game;
for (const ramp of g.table.ramps) {
const track = ramp.track;
const lit = g.state === 'multiball' && g.jackpot[track.side];
// Shadow the whole track casts on the playfield below it.
ctx.beginPath();
track.points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y + 9) : ctx.moveTo(x, y + 9)));
ctx.strokeStyle = 'rgba(4,2,10,0.4)';
ctx.lineWidth = track.width * 0.8;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// The first ~42% is the solid climb (a real plastic ramp); the rest is the wire return.
const splitS = track.length * 0.42;
const plastic = [];
const wire = [];
track.points.forEach(([x, y, z], i) => {
const p = [x, this.liftY(y, z)];
(track.cum[i] <= splitS ? plastic : wire).push(p);
});
if (plastic.length > 1) this.drawTube(ctx, plastic, 30, track.color, lit);
if (wire.length > 1) {
if (plastic.length) wire.unshift(plastic[plastic.length - 1]);
this.drawTube(ctx, wire, 20, track.color, lit);
this.drawWireBraces(ctx, wire, 20);
}
}
}
/** The scoop hole itself — drawn early, since the ramps' clear plastic legitimately arcs over it. */
drawLockHole(ctx) {
const lock = this.game.table.lock;
ctx.beginPath();
ctx.arc(lock.x, lock.y, lock.radius + 4, 0, Math.PI * 2);
ctx.fillStyle = '#8a91a0';
ctx.fill();
ctx.beginPath();
ctx.arc(lock.x, lock.y, lock.radius, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(lock.x, lock.y, 0, lock.x, lock.y, lock.radius);
grad.addColorStop(0, '#020103');
grad.addColorStop(1, '#1c1530');
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = '#4b4160';
ctx.lineWidth = 1.5;
ctx.stroke();
}
/** Lock/jackpot readouts — drawn after the ramps so they stay legible where the loops cross above it. */
drawLockLamps(ctx) {
const g = this.game;
const lock = g.table.lock;
this.glow(ctx, lock.x, lock.y, 60, COLORS.lock, g.lockFlash);
for (let i = 0; i < 2; i++) {
const a = Math.PI * 0.5 + (i - 0.5) * 1.0;
const lx = lock.x + Math.cos(a) * (lock.radius + 11);
const ly = lock.y + Math.sin(a) * (lock.radius + 11);
const filled = i < g.locked;
ctx.beginPath();
ctx.arc(lx, ly, 5, 0, Math.PI * 2);
const bg = ctx.createRadialGradient(lx - 1.5, ly - 1.5, 0.5, lx, ly, 5);
bg.addColorStop(0, filled ? '#ffffff' : '#4a4460');
bg.addColorStop(1, filled ? '#9aa4b8' : '#2a2540');
ctx.fillStyle = bg;
ctx.fill();
ctx.strokeStyle = filled ? '#ffffff' : '#5a5270';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.fillStyle = '#ffd23fdd';
ctx.font = '800 8px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = '#07061099';
ctx.lineWidth = 2.5;
ctx.strokeText('LOCK', lock.x, lock.y + lock.radius + 24);
ctx.fillText('LOCK', lock.x, lock.y + lock.radius + 24);
const superLit = g.jackpot.super && this.blink(4);
this.arrow(ctx, lock.x, lock.y - lock.radius - 15, 8, Math.PI / 2, '#ff3fa4', superLit);
if (g.jackpot.super) {
ctx.fillStyle = this.blink(4) ? '#ff3fa4dd' : '#ff3fa455';
ctx.font = '800 7px system-ui, sans-serif';
ctx.strokeText('SUPER', lock.x, lock.y - lock.radius - 26);
ctx.fillText('SUPER', lock.x, lock.y - lock.radius - 26);
}
}
drawBalls(ctx, alpha) {
for (const ball of this.game.balls) this.drawBall(ctx, ball, alpha);
}
drawBall(ctx, b, alpha) {
const gx = b.prevX + (b.x - b.prevX) * alpha;
const gy = b.prevY + (b.y - b.prevY) * alpha;
const z = b.prevZ + (b.z - b.prevZ) * alpha;
const r = b.radius;
// Riding a ramp: nudge the sprite toward the viewer and enlarge it a touch to read as "lifted off
// the playfield", while the shadow stays pinned to the true (unlifted) position below it.
const x = gx;
const y = this.liftY(gy, z);
const scale = 1 + z * 0.0035;
// motion trail (tracked per ball so multiball doesn't cross-contaminate trails)
let trail = this.trails.get(b);
if (!trail) this.trails.set(b, (trail = []));
trail.push({ x, y });
if (trail.length > 7) trail.shift();
const speed = b.speed;
if (speed > 900 && !b.track) {
const k = Math.min(1, (speed - 900) / 2500);
trail.forEach((p, i) => {
ctx.beginPath();
ctx.arc(p.x, p.y, r * (0.5 + (0.5 * i) / trail.length), 0, Math.PI * 2);
ctx.fillStyle = `rgba(190,220,255,${(0.12 * k * i) / trail.length})`;
ctx.fill();
});
}
// shadow on the playfield beneath (offset further and softer the higher the ball rides)
ctx.beginPath();
ctx.ellipse(gx + 4 + z * 0.15, gy + 5, r * (1.02 + z * 0.01), r * (0.9 + z * 0.01), 0, 0, Math.PI * 2);
ctx.fillStyle = `rgba(0,0,0,${Math.max(0.2, 0.44 - z * 0.005)})`;
ctx.fill();
// chrome
const rr = r * scale;
const grad = ctx.createRadialGradient(x - rr * 0.35, y - rr * 0.4, rr * 0.08, x, y, rr);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(0.25, '#dfe6f2');
grad.addColorStop(0.65, '#7c8597');
grad.addColorStop(1, '#262b36');
ctx.beginPath();
ctx.arc(x, y, rr, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
// coloured environment reflections
ctx.globalCompositeOperation = 'lighter';
ctx.beginPath();
ctx.arc(x + rr * 0.3, y + rr * 0.35, rr * 0.45, 0, Math.PI * 2);
ctx.fillStyle = '#ff3fa422';
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
drawFlipper(ctx, f, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const tx = f.x + c * f.length;
const ty = f.y + s * f.length;
// Tangent directions of the tapered sides (same maths as the collision shape).
const phi = Math.atan2(f.a, f.b);
const a1 = angle + phi;
const a2 = angle - phi;
const path = () => {
ctx.beginPath();
ctx.moveTo(f.x + Math.cos(a1) * f.baseRadius, f.y + Math.sin(a1) * f.baseRadius);
ctx.lineTo(tx + Math.cos(a1) * f.tipRadius, ty + Math.sin(a1) * f.tipRadius);
ctx.arc(tx, ty, f.tipRadius, a1, a2, true);
ctx.lineTo(f.x + Math.cos(a2) * f.baseRadius, f.y + Math.sin(a2) * f.baseRadius);
ctx.arc(f.x, f.y, f.baseRadius, a2, a1, true);
ctx.closePath();
};
// shadow
ctx.save();
ctx.translate(3, 5);
path();
ctx.fillStyle = '#00000066';
ctx.fill();
ctx.restore();
// body + rubber
path();
const grad = ctx.createLinearGradient(f.x, f.y - 12, f.x, f.y + 12);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(1, '#cfd3dc');
ctx.fillStyle = grad;
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = '#e8254f';
ctx.stroke();
// pivot
ctx.beginPath();
ctx.arc(f.x, f.y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#8a91a0';
ctx.fill();
ctx.strokeStyle = '#4b5160';
ctx.lineWidth = 1;
ctx.stroke();
}
drawApronLamps(ctx) {
const g = this.game;
// Ball-in-play indicator on the apron.
for (let i = 1; i <= 3; i++) {
const on = g.state !== 'attract' && g.state !== 'gameover' && i === g.ballNumber;
this.lamp(ctx, CX - 24 + (i - 1) * 24, APRON_Y + 40, 8, '#ffd23f', on, `${i}`);
}
ctx.fillStyle = '#ffd23f99';
ctx.font = '800 8px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('BALL IN PLAY', CX, APRON_Y + 62);
}
drawParticles(ctx, dt) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
this.particles = this.particles.filter((p) => (p.life -= dt) > 0);
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= 0.92;
p.vy *= 0.92;
ctx.globalAlpha = Math.min(1, p.life * 2);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 1.8, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
this.popups = this.popups.filter((p) => (p.life -= dt) > 0);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '800 13px system-ui, sans-serif';
for (const p of this.popups) {
const k = p.life / 0.9;
ctx.globalAlpha = Math.min(1, k * 1.8);
ctx.fillStyle = '#fff6c8';
ctx.strokeStyle = '#2a0a2a';
ctx.lineWidth = 3;
const y = p.y - (1 - k) * 30;
ctx.strokeText(p.text, p.x, y);
ctx.fillText(p.text, p.x, y);
}
ctx.globalAlpha = 1;
}
drawOverlay(ctx) {
const g = this.game;
let title = null;
let sub = null;
if (this.paused) {
title = 'PAUSED';
sub = 'PRESS P TO RESUME';
} else if (g.state === 'attract') {
title = 'NEON PINBALL';
sub = this.blink(1.2) ? 'PRESS SPACE TO START' : '';
} else if (g.state === 'gameover') {
title = 'GAME OVER';
sub = this.blink(1.2) ? 'PRESS SPACE TO PLAY AGAIN' : '';
} else if (g.state === 'play' && !g.inPlay && g.ball && g.ball.x > LANE_LEFT && g.ball.y > LANE_TOP && !g.plunger.pulling) {
ctx.save();
ctx.translate(SHOOTER_X, 870);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = this.blink(2) ? '#ffd23f' : '#ffd23f55';
ctx.font = '800 10px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('HOLD SPACE', 0, 0);
ctx.restore();
}
if (!title) return;
ctx.fillStyle = '#07061099';
ctx.fillRect(LEFT, 400, RIGHT - LEFT, 150);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'italic 900 44px system-ui, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.shadowColor = '#ff3fa4';
ctx.shadowBlur = 18;
ctx.fillText(title, CX, 455);
ctx.shadowBlur = 0;
if (g.state === 'gameover') {
ctx.font = '800 20px system-ui, sans-serif';
ctx.fillStyle = '#34e7ff';
ctx.fillText(`SCORE ${fmt(g.score)}`, CX, 495);
}
if (sub) {
ctx.font = '800 14px system-ui, sans-serif';
ctx.fillStyle = '#ffd23f';
ctx.fillText(sub, CX, 528);
}
}
}