dex/src/lib/pinball.js
chris 0c16c1c4a7 Pinball: rework the table, fix mobile input, polish the render
Playtest feedback:
- Ball wedged in the bottom corners → the lower table is now deliberately
  open: below the side walls it's all drain except the two flippers and
  slingshots, so there are no enclosed basins. A displacement-based
  ball-search still nudges (then, after 3 strikes, drains) anything that
  does get pinned.
- "Needs more things to bounce off" → 5 bumpers (was 3) spread across the
  mid-field + 3 small posts.
- "Gutter rails too large" → the long inlane rails are gone; short corner
  guides only.
- "Can the ball go back to the launcher?" → yes: a ball that drains down
  the right side returns to the shooter lane, no life lost. Left/centre
  drains still cost a ball.
- Poké Ball now spins with its motion.
- Flippers stuck on mobile → per-pointer tracking (Map of pointerId→side),
  drop on pointerup/cancel/leave/lostpointercapture, clear on blur/hide.
  Two thumbs work; a missed pointerup can't leave a flipper held.
- Text selection on touch → user-select:none on the game, preventDefault
  on pointerdown.
- "Looks rudimentary" → framed panel, playfield grid + accent glow,
  concentric bumpers with a lit halo, slingshot triangles, flippers with
  a highlight core and pivot caps, a drawn plunger, a shaded Poké Ball.

Also: canvas backing store is sized from the container × capped DPR (crisp
on any display) and `.pin__over` no longer sits blurred over the table
when hidden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 14:37:38 -04:00

638 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* A tiny single-table pinball engine. Canvas 2D, fixed-timestep physics
* with sub-stepping so the ball doesn't tunnel thin walls.
*
* createPinball(canvas, { onScore, onBalls, onGameOver, onCatch, pickName })
* → { start, stop, newGame, setFlipper('L'|'R', down), setPlunge(down) }
*
* All geometry is in logical units (a W×H table); the renderer scales to
* the canvas backing store. Tune feel via TABLE / PHYS below.
*/
const W = 320;
const H = 560;
const PHYS = {
gravity: 1050, // units/s²
drag: 0.9998, // per sub-step (×substeps×fps) — keep close to 1 or the ball dies fast
maxSpeed: 1000, // 5 substeps keep even this from tunnelling
substeps: 5,
ballR: 7,
wallE: 0.34, // wall restitution
flipE: 0.28,
flipThick: 6,
flipLen: 56,
flipSwing: 1.02, // radians
flipSpeed: 34, // ease rate toward target angle
bumperE: 0.55,
bumperKick: 116,
postE: 0.75,
postKick: 70,
slingE: 1.1,
slingKick: 90,
spin: 0.07, // Poké Ball rotation per unit travelled
};
// Named table geometry — segments are { a:[x,y], b:[x,y], e? }.
// Concave corners are chamfered so a slow ball can't wedge in a vertex.
const TABLE = {
// Deliberately open at the bottom: below the side walls it's all drain
// except the two flippers and slingshots. No enclosed basins to wedge in.
walls: [
{ a: [14, 60], b: [42, 20] }, // top-left corner
{ a: [42, 20], b: [250, 20] }, // top
{ a: [250, 20], b: [300, 56] }, // top-right corner
{ a: [300, 56], b: [306, 96] },
{ a: [306, 96], b: [306, 474] }, // right wall / shooter-lane outer
{ a: [288, 474], b: [288, 236] }, // shooter-lane divider
{ a: [288, 474], b: [306, 474] }, // shooter-lane floor
{ a: [14, 60], b: [14, 452] }, // left wall
{ a: [14, 452], b: [40, 478] }, // left lower guide (rounds toward the flipper)
{ a: [288, 474], b: [264, 492] }, // right lower guide
// left slingshot — triangle above the flipper's outer half
{ a: [88, 442], b: [104, 486], e: PHYS.slingE, k: PHYS.slingKick }, // kicking face
{ a: [88, 442], b: [58, 486] }, // outer face
{ a: [58, 486], b: [104, 486] }, // bottom
// right slingshot
{ a: [232, 442], b: [216, 486], e: PHYS.slingE, k: PHYS.slingKick },
{ a: [232, 442], b: [262, 486] },
{ a: [216, 486], b: [262, 486] },
],
bumpers: [
{ x: 80, y: 132, r: 15 },
{ x: 160, y: 100, r: 15 },
{ x: 240, y: 132, r: 15 },
{ x: 120, y: 214, r: 13 },
{ x: 200, y: 214, r: 13 },
],
// small round posts — extra things to carom off in the mid-field
posts: [
{ x: 52, y: 300, r: 7 },
{ x: 268, y: 300, r: 7 },
{ x: 160, y: 300, r: 7 },
],
flippers: {
L: { pivot: [86, 504], rest: 0.3 }, // radians, y-down; tip points down-right
R: { pivot: [234, 504], rest: Math.PI - 0.3 },
},
laneX: 297, // ball rest x in the shooter lane
laneRestY: 466,
drainY: 552,
returnMinX: 246, // a ball past drainY here came down the right side → back to the plunger, no life lost
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
/** Closest point on segment a→b to p, plus the parametric t. */
function closestOnSeg(px, py, ax, ay, bx, by) {
const abx = bx - ax;
const aby = by - ay;
const denom = abx * abx + aby * aby || 1;
const t = clamp(((px - ax) * abx + (py - ay) * aby) / denom, 0, 1);
return [ax + t * abx, ay + t * aby, t];
}
export const _test = { closestOnSeg, clamp, W, H };
export function createPinball(canvas, cbs = {}) {
const ctx = canvas.getContext('2d');
const accent =
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#b3161a';
const state = {
running: false,
raf: 0,
last: 0,
score: 0,
balls: 3,
over: false,
ball: null, // { x, y, vx, vy, inLane }
flip: { L: false, R: false },
ang: { L: TABLE.flippers.L.rest, R: TABLE.flippers.R.rest },
angPrev: { L: TABLE.flippers.L.rest, R: TABLE.flippers.R.rest },
omega: { L: 0, R: 0 },
plunge: false,
chargeStart: 0, // performance.now() when the plunger was pressed
charge: 0, // 0..1, for the plunger visual only
lit: TABLE.bumpers.map(() => false),
catchT: 0,
catchName: '',
scale: 1,
};
function spawnBall(toLane = true) {
state.ball = {
x: TABLE.laneX,
y: TABLE.laneRestY - 4,
vx: 0,
vy: 0,
spin: 0,
inLane: toLane,
};
state.charge = 0;
state.chargeStart = 0;
}
const PULL_MS = 900; // hold this long for a full-strength launch
function newGame() {
state.score = 0;
state.balls = 3;
state.over = false;
state.lit = TABLE.bumpers.map(() => false);
spawnBall();
cbs.onScore?.(0);
cbs.onBalls?.(3);
}
function addScore(n) {
state.score += n;
cbs.onScore?.(state.score);
}
function flipperSeg(which) {
const f = TABLE.flippers[which];
const ang = state.ang[which];
return {
ax: f.pivot[0],
ay: f.pivot[1],
bx: f.pivot[0] + Math.cos(ang) * PHYS.flipLen,
by: f.pivot[1] + Math.sin(ang) * PHYS.flipLen,
};
}
function collideSeg(b, ax, ay, bx, by, e, half, svx = 0, svy = 0) {
const [cx, cy] = closestOnSeg(b.x, b.y, ax, ay, bx, by);
let dx = b.x - cx;
let dy = b.y - cy;
let d = Math.hypot(dx, dy);
const minD = PHYS.ballR + half;
if (d >= minD) return false;
if (d < 0.0001) {
dx = 0;
dy = -1;
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const rvx = b.vx - svx;
const rvy = b.vy - svy;
const vn = rvx * nx + rvy * ny;
if (vn < 0) {
const j = -(1 + e) * vn;
b.vx += j * nx;
b.vy += j * ny;
}
b.x += nx * (minD - d);
b.y += ny * (minD - d);
return true;
}
function step(dt) {
const b = state.ball;
if (!b || state.over) return;
// Flippers ease toward their target angle; ω drives the ball on contact.
for (const which of ['L', 'R']) {
const f = TABLE.flippers[which];
const dir = which === 'L' ? -1 : 1;
const target = state.flip[which] ? f.rest + dir * PHYS.flipSwing : f.rest;
state.angPrev[which] = state.ang[which];
state.ang[which] += (target - state.ang[which]) * clamp(PHYS.flipSpeed * dt, 0, 1);
state.omega[which] = (state.ang[which] - state.angPrev[which]) / dt;
}
// Plunger — the ball tracks the lane centre until it's launched.
if (b.inLane && b.x > 286) {
b.vx *= 0.6;
b.x += (TABLE.laneX - b.x) * 0.35;
}
// Gate kick: once the launched ball clears the divider, fling it left
// into the playfield (a straight-up launch has nowhere else to go).
if (b.launching && b.y < 236) {
b.vx = -(150 + 90 * state.launchC);
b.launching = false;
}
b.vy += PHYS.gravity * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
for (const w of TABLE.walls) {
if (collideSeg(b, w.a[0], w.a[1], w.b[0], w.b[1], w.e ?? PHYS.wallE, 0) && w.k) {
// slingshot face: shove the ball away from the segment + score
const mx = (w.a[0] + w.b[0]) / 2;
const my = (w.a[1] + w.b[1]) / 2;
const nx = b.x - mx;
const ny = b.y - my;
const nl = Math.hypot(nx, ny) || 1;
b.vx += (nx / nl) * w.k;
b.vy += (ny / nl) * w.k;
addScore(50);
}
}
for (const p of TABLE.posts) {
const dx = b.x - p.x;
const dy = b.y - p.y;
const d = Math.hypot(dx, dy) || 1;
if (d < PHYS.ballR + p.r) {
const nx = dx / d;
const ny = dy / d;
const vn = b.vx * nx + b.vy * ny;
if (vn < 0) {
const j = -(1 + PHYS.postE) * vn;
b.vx += j * nx;
b.vy += j * ny;
}
b.vx += nx * PHYS.postKick;
b.vy += ny * PHYS.postKick;
b.x = p.x + nx * (PHYS.ballR + p.r + 0.5);
b.y = p.y + ny * (PHYS.ballR + p.r + 0.5);
addScore(25);
}
}
for (let i = 0; i < TABLE.bumpers.length; i++) {
const bp = TABLE.bumpers[i];
let dx = b.x - bp.x;
let dy = b.y - bp.y;
const d = Math.hypot(dx, dy);
if (d < PHYS.ballR + bp.r) {
if (d < 0.001) {
dx = 0;
dy = -1;
}
const nx = dx / (d || 1);
const ny = dy / (d || 1);
const vn = b.vx * nx + b.vy * ny;
if (vn < 0) {
const j = -(1 + PHYS.bumperE) * vn;
b.vx += j * nx;
b.vy += j * ny;
}
b.vx += nx * PHYS.bumperKick;
b.vy += ny * PHYS.bumperKick;
b.x = bp.x + nx * (PHYS.ballR + bp.r + 0.5);
b.y = bp.y + ny * (PHYS.ballR + bp.r + 0.5);
addScore(100);
if (!state.lit[i]) {
state.lit[i] = true;
if (state.lit.every(Boolean)) {
state.lit = TABLE.bumpers.map(() => false);
addScore(5000);
state.catchName = (cbs.pickName?.() || 'Something').toUpperCase();
state.catchT = 1.6;
cbs.onCatch?.(state.catchName);
}
}
}
}
for (const which of ['L', 'R']) {
const s = flipperSeg(which);
const [cx, cy] = closestOnSeg(b.x, b.y, s.ax, s.ay, s.bx, s.by);
const svx = -state.omega[which] * (cy - s.ay);
const svy = state.omega[which] * (cx - s.ax);
collideSeg(b, s.ax, s.ay, s.bx, s.by, PHYS.flipE, PHYS.flipThick, svx, svy);
}
b.vx *= PHYS.drag;
b.vy *= PHYS.drag;
const sp = Math.hypot(b.vx, b.vy);
if (sp > PHYS.maxSpeed) {
b.vx *= PHYS.maxSpeed / sp;
b.vy *= PHYS.maxSpeed / sp;
}
b.spin += b.vx * dt * PHYS.spin; // Poké Ball rolls with its horizontal motion
// Ball-search: if the ball hasn't travelled 24 units in 1.4s (and isn't
// waiting in the shooter lane) it's wedged — kick it toward centre, like
// a real table. Three strikes and it drains.
b.trackT = (b.trackT || 0) + dt;
if (b.trackX == null) {
b.trackX = b.x;
b.trackY = b.y;
}
if (b.trackT > 1.4) {
const moved = Math.hypot(b.x - b.trackX, b.y - b.trackY);
if (!b.inLane && moved < 24) {
if ((b.searches || 0) >= 2) {
loseBall();
return;
}
b.searches = (b.searches || 0) + 1;
const kx = W / 2 - b.x;
const ky = 210 - b.y;
const kl = Math.hypot(kx, ky) || 1;
b.vx = (kx / kl) * 360;
b.vy = (ky / kl) * 360;
} else {
b.searches = 0;
}
b.trackT = 0;
b.trackX = b.x;
b.trackY = b.y;
}
if (b.y > TABLE.drainY) {
if (b.x >= TABLE.returnMinX)
spawnBall(true); // right outlane — back to the plunger, no life lost
else loseBall();
}
}
function loseBall() {
state.balls -= 1;
cbs.onBalls?.(state.balls);
if (state.balls <= 0) {
state.over = true;
state.ball = null;
cbs.onGameOver?.(state.score);
} else {
spawnBall();
}
}
// --- render ---------------------------------------------------------
function resize() {
const host = canvas.parentElement;
const cw = Math.max(200, Math.min(host ? host.clientWidth : 320, 340));
const ch = cw * (H / W);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = `${cw}px`;
canvas.style.height = `${ch}px`;
canvas.width = Math.round(cw * dpr);
canvas.height = Math.round(ch * dpr);
state.scale = canvas.width / W;
render();
}
function line(ax, ay, bx, by) {
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
const accentRGB = (() => {
const h = /^#([0-9a-f]{6})$/i.test(accent) ? accent : '#b3161a';
return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
})();
/** Blend the accent toward `to` ([r,g,b]) by `t`. */
const mix = (_ignored, to, t) => {
const c = (a, b) => Math.round(a + (b - a) * t);
return `rgb(${c(accentRGB[0], to[0])},${c(accentRGB[1], to[1])},${c(accentRGB[2], to[2])})`;
};
const accentA = (a) => `rgba(${accentRGB[0]},${accentRGB[1]},${accentRGB[2]},${a})`;
function render() {
ctx.setTransform(state.scale, 0, 0, state.scale, 0, 0);
// playfield
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#2b2d38');
g.addColorStop(0.55, '#1a1b22');
g.addColorStop(1, '#0d0e12');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const glow = ctx.createRadialGradient(W / 2, 150, 10, W / 2, 150, 240);
glow.addColorStop(0, accentA(0.18));
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
// faint grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1;
for (let x = 40; x < W; x += 40) line(x, 0, x, H);
for (let y = 40; y < H; y += 40) line(0, y, W, y);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// slingshot triangles (filled) — draw before the wall outline
const tri = (pts) => {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (const p of pts.slice(1)) ctx.lineTo(p[0], p[1]);
ctx.closePath();
};
ctx.fillStyle = accentA(0.22);
tri([
[88, 442],
[104, 486],
[58, 486],
]);
ctx.fill();
tri([
[232, 442],
[216, 486],
[262, 486],
]);
ctx.fill();
// wall outline
ctx.strokeStyle = 'rgba(255,255,255,0.7)';
ctx.lineWidth = 3;
for (const w of TABLE.walls) {
ctx.strokeStyle = w.k ? accent : 'rgba(255,255,255,0.7)';
ctx.lineWidth = w.k ? 4 : 3;
line(w.a[0], w.a[1], w.b[0], w.b[1]);
}
// posts
for (const p of TABLE.posts) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = '#5a5f70';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = accent;
ctx.stroke();
}
// bumpers — concentric, with a lit glow
for (let i = 0; i < TABLE.bumpers.length; i++) {
const bp = TABLE.bumpers[i];
const lit = state.lit[i];
if (lit) {
ctx.shadowColor = accent;
ctx.shadowBlur = 22;
}
ctx.beginPath();
ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2);
ctx.fillStyle = lit ? accent : '#3a3e4b';
ctx.fill();
ctx.shadowBlur = 0;
ctx.lineWidth = 3;
ctx.strokeStyle = lit ? '#fff' : accent;
ctx.stroke();
ctx.beginPath();
ctx.arc(bp.x, bp.y, bp.r * 0.5, 0, Math.PI * 2);
ctx.fillStyle = lit ? '#fff' : mix(accent, [255, 255, 255], 0.15);
ctx.globalAlpha = lit ? 0.9 : 0.5;
ctx.fill();
ctx.globalAlpha = 1;
}
// flippers
for (const which of ['L', 'R']) {
const s = flipperSeg(which);
ctx.strokeStyle = accent;
ctx.lineWidth = PHYS.flipThick * 2 + 2;
line(s.ax, s.ay, s.bx, s.by);
ctx.strokeStyle = mix(accent, [255, 255, 255], 0.35);
ctx.lineWidth = PHYS.flipThick;
line(s.ax, s.ay, s.bx, s.by);
ctx.beginPath();
ctx.arc(s.ax, s.ay, PHYS.flipThick + 1, 0, Math.PI * 2);
ctx.fillStyle = '#e8e8ec';
ctx.fill();
}
// plunger
const py = 470 - state.charge * 18;
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 3;
line(297, py + 22, 297, 486);
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.beginPath();
ctx.roundRect?.(289, py, 16, 22, 4);
ctx.fill();
if (!ctx.roundRect) ctx.fillRect(289, py, 16, 22);
// ball — a spinning Poké Ball
const b = state.ball;
if (b) {
const r = PHYS.ballR;
ctx.save();
ctx.translate(b.x, b.y + 1);
ctx.beginPath();
ctx.arc(0, 1.5, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fill();
ctx.restore();
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.spin);
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#f4f4f6';
ctx.fillRect(-r, -r, r * 2, r * 2);
ctx.fillStyle = accent;
ctx.fillRect(-r, -r, r * 2, r);
ctx.fillStyle = '#1b1c22';
ctx.fillRect(-r, -1.6, r * 2, 3.2);
ctx.beginPath();
ctx.arc(0, 0, 2.6, 0, Math.PI * 2);
ctx.fillStyle = '#1b1c22';
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, 1.5, 0, Math.PI * 2);
ctx.fillStyle = '#f4f4f6';
ctx.fill();
ctx.restore();
ctx.beginPath();
ctx.arc(b.x, b.y, r, 0, Math.PI * 2);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(0,0,0,0.4)';
ctx.stroke();
}
if (state.catchT > 0) {
ctx.globalAlpha = Math.min(1, state.catchT);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, H / 2 - 66, W, 92);
ctx.fillStyle = '#fff';
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 30);
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillStyle = accent;
ctx.fillText('+5000', W / 2, H / 2 - 6);
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
}
}
// One simulation frame (no drawing) — split out so a headless harness can
// drive the physics deterministically without requestAnimationFrame.
function frame(fdt, now) {
fdt = Math.min(fdt || 0, 1 / 30);
if (state.catchT > 0) state.catchT = Math.max(0, state.catchT - fdt);
const sdt = fdt / PHYS.substeps;
for (let i = 0; i < PHYS.substeps; i++) step(sdt);
state.charge = state.chargeStart ? Math.min(1, (now - state.chargeStart) / PULL_MS) : 0;
}
function loop(now) {
if (!state.running) return;
frame((now - state.last) / 1000, now);
state.last = now;
render();
state.raf = requestAnimationFrame(loop);
}
let ro = null;
function start() {
if (state.running) return;
state.running = true;
state.last = performance.now();
newGame();
resize();
if (typeof ResizeObserver === 'function' && canvas.parentElement) {
ro = new ResizeObserver(resize);
ro.observe(canvas.parentElement);
}
// Layout may not be settled on the first frame.
requestAnimationFrame(resize);
state.raf = requestAnimationFrame(loop);
}
function stop() {
state.running = false;
cancelAnimationFrame(state.raf);
ro?.disconnect();
ro = null;
}
return {
start,
stop,
newGame,
setFlipper: (which, down) => {
state.flip[which] = !!down;
},
setPlunge: (down) => {
down = !!down;
const b = state.ball;
if (down && !state.plunge && b?.inLane) {
state.chargeStart = performance.now();
} else if (!down && state.plunge && b?.inLane && state.chargeStart) {
const held = (performance.now() - state.chargeStart) / 1000;
const c = Math.min(1, Math.max(0.15, held / (PULL_MS / 1000)));
// Straight up the lane; step() kicks it left once it clears the divider.
b.vy = -(920 + 120 * c);
b.vx = 0;
b.inLane = false; // commit — don't let a graze in the lane cancel the launch
b.launching = true;
state.launchC = c;
state.chargeStart = 0;
}
state.plunge = down;
},
isOver: () => state.over,
/** Read-only snapshot for tests/debugging. */
peek: () => ({
score: state.score,
balls: state.balls,
over: state.over,
ball: state.ball && { ...state.ball },
running: state.running,
}),
/** Advance one frame without drawing — headless harness only. */
_frame: (fdt, now) => frame(fdt, now),
};
}