diff --git a/src/lib/pinball.js b/src/lib/pinball.js new file mode 100644 index 0000000..911eb12 --- /dev/null +++ b/src/lib/pinball.js @@ -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), + }; +} diff --git a/src/router.js b/src/router.js index cf295fa..433ea30 100644 --- a/src/router.js +++ b/src/router.js @@ -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) { diff --git a/src/store/pinball.js b/src/store/pinball.js new file mode 100644 index 0000000..5619f0e --- /dev/null +++ b/src/store/pinball.js @@ -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 })); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index f3fabfe..5455634 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; +} diff --git a/src/views/PinballView.js b/src/views/PinballView.js new file mode 100644 index 0000000..fb530af --- /dev/null +++ b/src/views/PinballView.js @@ -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 table’s 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; +} diff --git a/src/views/WhosThatView.js b/src/views/WhosThatView.js index 6e8f8b2..49dd31d 100644 --- a/src/views/WhosThatView.js +++ b/src/views/WhosThatView.js @@ -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' }, diff --git a/test/pinball.test.js b/test/pinball.test.js new file mode 100644 index 0000000..fb74ef1 --- /dev/null +++ b/test/pinball.test.js @@ -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]); + }); +});