1158 lines
39 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|