Playtest feedback: - Ball wedged in the bottom corners → the lower table is now deliberately open: below the side walls it's all drain except the two flippers and slingshots, so there are no enclosed basins. A displacement-based ball-search still nudges (then, after 3 strikes, drains) anything that does get pinned. - "Needs more things to bounce off" → 5 bumpers (was 3) spread across the mid-field + 3 small posts. - "Gutter rails too large" → the long inlane rails are gone; short corner guides only. - "Can the ball go back to the launcher?" → yes: a ball that drains down the right side returns to the shooter lane, no life lost. Left/centre drains still cost a ball. - Poké Ball now spins with its motion. - Flippers stuck on mobile → per-pointer tracking (Map of pointerId→side), drop on pointerup/cancel/leave/lostpointercapture, clear on blur/hide. Two thumbs work; a missed pointerup can't leave a flipper held. - Text selection on touch → user-select:none on the game, preventDefault on pointerdown. - "Looks rudimentary" → framed panel, playfield grid + accent glow, concentric bumpers with a lit halo, slingshot triangles, flippers with a highlight core and pivot caps, a drawn plunger, a shaded Poké Ball. Also: canvas backing store is sized from the container × capped DPR (crisp on any display) and `.pin__over` no longer sits blurred over the table when hidden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
638 lines
19 KiB
JavaScript
638 lines
19 KiB
JavaScript
/**
|
||
* 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),
|
||
};
|
||
}
|