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:
parent
55d26f50ed
commit
9fa46493a8
501
src/lib/pinball.js
Normal file
501
src/lib/pinball.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import { ProgressView } from './views/ProgressView.js';
|
|||||||
import { BreedingView } from './views/BreedingView.js';
|
import { BreedingView } from './views/BreedingView.js';
|
||||||
import { ShinyView } from './views/ShinyView.js';
|
import { ShinyView } from './views/ShinyView.js';
|
||||||
import { WhosThatView } from './views/WhosThatView.js';
|
import { WhosThatView } from './views/WhosThatView.js';
|
||||||
|
import { PinballView } from './views/PinballView.js';
|
||||||
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||||
import { prefersReducedMotion } from './store/settings.js';
|
import { prefersReducedMotion } from './store/settings.js';
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ const routes = [
|
|||||||
{ pattern: /^#\/search$/, view: () => SearchView() },
|
{ pattern: /^#\/search$/, view: () => SearchView() },
|
||||||
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
||||||
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
|
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
|
||||||
|
{ pattern: /^#\/pinball$/, view: () => PinballView() }, // hidden — linked from #/whos-that
|
||||||
];
|
];
|
||||||
|
|
||||||
function resolve(hash) {
|
function resolve(hash) {
|
||||||
|
|||||||
8
src/store/pinball.js
Normal file
8
src/store/pinball.js
Normal 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 }));
|
||||||
|
}
|
||||||
@ -3879,3 +3879,77 @@
|
|||||||
.wtp__feedback.is-bad {
|
.wtp__feedback.is-bad {
|
||||||
color: var(--danger);
|
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
129
src/views/PinballView.js
Normal 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 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;
|
||||||
|
}
|
||||||
@ -220,7 +220,13 @@ export async function WhosThatView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view.append(
|
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(
|
el(
|
||||||
'header',
|
'header',
|
||||||
{ class: 'view__header' },
|
{ class: 'view__header' },
|
||||||
|
|||||||
29
test/pinball.test.js
Normal file
29
test/pinball.test.js
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user