// 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); } } }