Hidden mini-game: single-table pinball (#/pinball)

A small canvas pinball engine (src/lib/pinball.js): fixed-timestep physics
with 5 substeps, circle-vs-segment collision, two rotating flippers whose
angular velocity drives the ball, 3 pop bumpers, 2 slingshots, a charged
plunger, 3 balls. Lighting all 3 bumpers "catches" a random Pokémon for a
bonus. Ball-search kicks a wedged ball toward centre; three strikes and it
drains. High score in pdx.pinball.

Hidden like "Who's that Pokémon?" — no nav entry; the two games
cross-link from each other's back-nav, or reach it at #/pinball.

Controls: ← / → (also A/L, Z) flippers, hold Space / the Launch button for
the plunger; on touch, tap the table's left or right half.

Rendering: the canvas is sized from its container with a DPR-capped
backing store (crisp on any display). `_frame()` is exposed so the physics
can be driven headlessly. 3 unit tests for the geometry helpers.

The physics are stable and the game loop is complete; feel (flipper
strength, ball speed, table balance) still wants hands-on tuning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 14:18:11 -04:00
parent 55d26f50ed
commit 9fa46493a8
7 changed files with 750 additions and 1 deletions

501
src/lib/pinball.js Normal file
View File

@ -0,0 +1,501 @@
/**
* 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 = 540;
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.32, // wall restitution
flipE: 0.28,
flipThick: 6,
flipLen: 56,
flipSwing: 1.02, // radians
flipSpeed: 34, // ease rate toward target angle
bumperE: 0.5,
bumperKick: 108,
slingE: 1.06,
slingKick: 78,
};
// Named table geometry — segments are { a:[x,y], b:[x,y], e? }.
const TABLE = {
// Concave corners are chamfered so a slow ball can't wedge in a vertex.
walls: [
{ a: [14, 58], b: [40, 20] }, // top-left corner
{ a: [40, 20], b: [252, 20] }, // top
{ a: [252, 20], b: [300, 56] }, // top-right corner
{ a: [300, 56], b: [306, 96] },
{ a: [306, 96], b: [306, 446] }, // right wall / shooter-lane outer
{ a: [288, 446], b: [288, 230] }, // shooter-lane divider
{ a: [288, 446], b: [306, 446] }, // shooter-lane floor
{ a: [14, 58], b: [14, 452] }, // left wall
{ a: [14, 452], b: [34, 476] }, // left chamfer
{ a: [34, 476], b: [86, 506], e: 0.12 }, // left inlane → left flipper
{ a: [288, 446], b: [268, 470] }, // right chamfer off the divider
{ a: [268, 470], b: [214, 504], e: 0.12 }, // right inlane → right flipper
],
// slingshots — bouncy, kink up off the inlane end (a convex corner)
slings: [
{ a: [86, 506], b: [114, 452] },
{ a: [214, 504], b: [188, 452] },
],
bumpers: [
{ x: 92, y: 150, r: 15 },
{ x: 168, y: 114, r: 15 },
{ x: 226, y: 150, r: 15 },
],
flippers: {
L: { pivot: [88, 500], rest: 0.36 }, // radians, y-down; tip points down-right
R: { pivot: [214, 500], rest: Math.PI - 0.36 },
},
laneX: 297, // ball rest x in the shooter lane
drainY: 536,
};
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: [false, false, false],
catchT: 0,
catchName: '',
scale: 1,
};
function spawnBall() {
state.ball = { x: TABLE.laneX, y: 436, vx: 0, vy: 0, inLane: true };
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 = [false, false, 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) {
collideSeg(b, w.a[0], w.a[1], w.b[0], w.b[1], w.e ?? PHYS.wallE, 0);
}
for (const s of TABLE.slings) {
if (collideSeg(b, s.a[0], s.a[1], s.b[0], s.b[1], PHYS.slingE, 2)) {
const mx = (s.a[0] + s.b[0]) / 2;
const my = (s.a[1] + s.b[1]) / 2;
const nx = b.x - mx;
const ny = b.y - my;
const nl = Math.hypot(nx, ny) || 1;
b.vx += (nx / nl) * PHYS.slingKick;
b.vy += (ny / nl) * PHYS.slingKick;
addScore(50);
}
}
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 = [false, false, 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;
}
// 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) 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();
}
function render() {
ctx.setTransform(state.scale, 0, 0, state.scale, 0, 0);
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#282a34');
g.addColorStop(1, '#101116');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const glow = ctx.createRadialGradient(W / 2, 150, 20, W / 2, 150, 220);
glow.addColorStop(0, `${accent}22`);
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 2.5;
for (const w of TABLE.walls) line(w.a[0], w.a[1], w.b[0], w.b[1]);
ctx.strokeStyle = accent;
ctx.lineWidth = 5;
for (const s of TABLE.slings) line(s.a[0], s.a[1], s.b[0], s.b[1]);
for (let i = 0; i < TABLE.bumpers.length; i++) {
const bp = TABLE.bumpers[i];
ctx.beginPath();
ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2);
if (state.lit[i]) {
ctx.fillStyle = accent;
ctx.shadowColor = accent;
ctx.shadowBlur = 18;
} else {
ctx.fillStyle = '#4a4e5c';
ctx.shadowBlur = 0;
}
ctx.fill();
ctx.shadowBlur = 0;
ctx.lineWidth = 3;
ctx.strokeStyle = state.lit[i] ? '#fff' : accent;
ctx.stroke();
}
ctx.strokeStyle = accent;
ctx.lineWidth = PHYS.flipThick * 2;
for (const which of ['L', 'R']) {
const s = flipperSeg(which);
line(s.ax, s.ay, s.bx, s.by);
}
// plunger
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillRect(289, 452 + state.charge * 14, 16, 20);
const b = state.ball;
if (b) {
ctx.save();
ctx.beginPath();
ctx.arc(b.x, b.y, PHYS.ballR, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#f2f2f5';
ctx.fillRect(b.x - 9, b.y - 9, 18, 18);
ctx.fillStyle = accent;
ctx.fillRect(b.x - 9, b.y - 9, 18, 9);
ctx.restore();
ctx.beginPath();
ctx.arc(b.x, b.y, PHYS.ballR, 0, Math.PI * 2);
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.stroke();
ctx.beginPath();
ctx.moveTo(b.x - PHYS.ballR, b.y);
ctx.lineTo(b.x + PHYS.ballR, b.y);
ctx.stroke();
ctx.beginPath();
ctx.arc(b.x, b.y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
}
if (state.catchT > 0) {
ctx.globalAlpha = Math.min(1, state.catchT);
ctx.fillStyle = '#fff';
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 40);
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.fillStyle = accent;
ctx.fillText('+5000', W / 2, H / 2 - 18);
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),
};
}

View File

@ -14,6 +14,7 @@ import { ProgressView } from './views/ProgressView.js';
import { BreedingView } from './views/BreedingView.js';
import { ShinyView } from './views/ShinyView.js';
import { WhosThatView } from './views/WhosThatView.js';
import { PinballView } from './views/PinballView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
@ -41,6 +42,7 @@ const routes = [
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
{ pattern: /^#\/pinball$/, view: () => PinballView() }, // hidden — linked from #/whos-that
];
function resolve(hash) {

8
src/store/pinball.js Normal file
View File

@ -0,0 +1,8 @@
import { createStore } from './createStore.js';
/** High score for the hidden pinball table (#/pinball). */
export const pinball = createStore('pdx.pinball', { best: 0 });
export function recordPinball(score) {
if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score }));
}

View File

@ -3879,3 +3879,77 @@
.wtp__feedback.is-bad {
color: var(--danger);
}
/* ---- Hidden mini-game: Pinball (#/pinball) ----------------------- */
.pin {
max-width: 26rem;
margin: 0 auto;
}
.pin__hud {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 16px;
margin: 2px 0 12px;
font-size: 0.82rem;
color: var(--text-dim);
}
.pin__score {
font-size: 1.1rem;
font-weight: 800;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.pin__balls {
letter-spacing: 2px;
color: var(--accent);
}
.pin__best {
margin-left: auto;
}
.pin__stage {
position: relative;
}
.pin__canvas {
display: block;
margin: 0 auto; /* the engine sets style.width/height from the container */
border-radius: var(--radius-lg);
border: 1px solid var(--border);
touch-action: none;
background: #101116;
}
.pin__over[hidden] {
display: none;
}
.pin__over {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: var(--radius-lg);
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(2px);
}
.pin__over-score {
margin: 0;
font-size: 1.3rem;
font-weight: 800;
}
.pin__controls {
display: flex;
justify-content: center;
margin-top: 12px;
}
.pin__launch {
min-width: 12rem;
touch-action: none;
}
.pin__hint {
margin-top: 10px;
font-size: 0.78rem;
color: var(--text-dim);
text-align: center;
}

129
src/views/PinballView.js Normal file
View File

@ -0,0 +1,129 @@
import { el, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { createPinball } from '../lib/pinball.js';
import { pinball, recordPinball } from '../store/pinball.js';
const pretty = (name) => name.replace(/-/g, ' ');
/**
* Hidden mini-game a single pinball table. Reached from the "Who's that
* Pokémon?" screen's nav row, or the bare #/pinball hash.
*
* / (or A / L) work the flippers; hold Space (or the Launch button) to
* pull the plunger. On touch, tap the left or right half of the table.
*/
export async function PinballView() {
const view = el('section', { class: 'view pin' });
const snap = await loadSnapshot();
const scoreEl = el('span', { class: 'pin__score' }, '0');
const bestEl = el('span', { class: 'pin__best' }, `Best ${pinball.get().best}`);
const ballsEl = el('span', { class: 'pin__balls' });
const overlay = el('div', { class: 'pin__over', hidden: true });
const canvas = el('canvas', { class: 'pin__canvas', 'aria-label': 'Pinball table' });
function renderBalls(n) {
ballsEl.textContent = '●'.repeat(Math.max(0, n)) || '—';
}
const game = createPinball(canvas, {
onScore: (s) => (scoreEl.textContent = s.toLocaleString()),
onBalls: renderBalls,
pickName: () => pretty(snap.species[(Math.random() * snap.species.length) | 0].name),
onGameOver: (score) => {
recordPinball(score);
bestEl.textContent = `Best ${pinball.get().best}`;
overlay.replaceChildren(
el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`),
el(
'button',
{
class: 'button',
type: 'button',
onclick: () => {
overlay.hidden = true;
game.newGame();
},
},
'Play again',
),
);
overlay.hidden = false;
},
});
// --- input --------------------------------------------------------
const KEYS = {
ArrowLeft: 'L',
ArrowRight: 'R',
a: 'L',
d: 'R',
z: 'L',
l: 'R',
};
function onKey(down) {
return (e) => {
const f = KEYS[e.key];
if (f) {
game.setFlipper(f, down);
e.preventDefault();
} else if (e.key === ' ' || e.key === 'ArrowDown') {
game.setPlunge(down);
e.preventDefault();
}
};
}
const kd = onKey(true);
const ku = onKey(false);
window.addEventListener('keydown', kd);
window.addEventListener('keyup', ku);
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture?.(e.pointerId);
const left = e.offsetX < canvas.clientWidth / 2;
game.setFlipper(left ? 'L' : 'R', true);
canvas._side = left ? 'L' : 'R';
});
const releaseFlippers = (e) => {
if (canvas._side) game.setFlipper(canvas._side, false);
canvas._side = null;
};
canvas.addEventListener('pointerup', releaseFlippers);
canvas.addEventListener('pointercancel', releaseFlippers);
const launchBtn = el('button', { class: 'button pin__launch', type: 'button' }, 'Launch (hold)');
for (const ev of ['pointerdown']) launchBtn.addEventListener(ev, () => game.setPlunge(true));
for (const ev of ['pointerup', 'pointerleave', 'pointercancel'])
launchBtn.addEventListener(ev, () => game.setPlunge(false));
view.append(
el(
'nav',
{ class: 'lookup__nav' },
el('a', { class: 'link', href: '#/' }, ' Dex'),
el('span', {}, ' · '),
el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"),
),
el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')),
el('div', { class: 'pin__hud' }, el('span', {}, 'Score ', scoreEl), ballsEl, bestEl),
el('div', { class: 'pin__stage' }, canvas, overlay),
el('div', { class: 'pin__controls' }, launchBtn),
el(
'p',
{ class: 'pin__hint' },
'← / → flippers · hold Space to launch · tap the tables left / right half on touch',
),
);
renderBalls(3);
// Give the canvas a layout box before the engine measures it.
requestAnimationFrame(() => game.start());
onTeardown(view, () => {
game.stop();
window.removeEventListener('keydown', kd);
window.removeEventListener('keyup', ku);
});
return view;
}

View File

@ -220,7 +220,13 @@ export async function WhosThatView() {
}
view.append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/' }, ' Dex')),
el(
'nav',
{ class: 'lookup__nav' },
el('a', { class: 'link', href: '#/' }, ' Dex'),
el('span', {}, ' · '),
el('a', { class: 'link', href: '#/pinball' }, 'Pinball'),
),
el(
'header',
{ class: 'view__header' },

29
test/pinball.test.js Normal file
View File

@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { _test } from '../src/lib/pinball.js';
const { closestOnSeg, clamp } = _test;
describe('clamp', () => {
it('bounds a value', () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(-3, 0, 10)).toBe(0);
expect(clamp(99, 0, 10)).toBe(10);
});
});
describe('closestOnSeg', () => {
it('projects onto the segment interior', () => {
const [x, y, t] = closestOnSeg(5, 5, 0, 0, 10, 0);
expect([x, y]).toEqual([5, 0]);
expect(t).toBeCloseTo(0.5);
});
it('clamps to the endpoints', () => {
expect(closestOnSeg(-4, 2, 0, 0, 10, 0).slice(0, 2)).toEqual([0, 0]);
expect(closestOnSeg(20, -1, 0, 0, 10, 0).slice(0, 2)).toEqual([10, 0]);
});
it('handles a degenerate (zero-length) segment', () => {
expect(closestOnSeg(3, 4, 1, 1, 1, 1).slice(0, 2)).toEqual([1, 1]);
});
});