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