Pinball: rebuild the table — flat stadium look, connected shooter lane

Reworked the table geometry and render toward a flat "Pokémon stadium"
look and fixed the launcher, which never delivered the ball into play.

Visual:
- Flat, bright arena style — solid fills, crisp dark outlines, no glow.
  A rounded battle-arena floor with a Poké Ball painted on it, a crowd
  strip across the top. Dropped the dark playfield and neon strokes.
- The whole table is a rounded rectangle now (was a stitched-looking
  polygon); play boundary drawn as one smooth curve.
- Flippers are tapered bats; bumpers are dimensional pop bumpers with a
  skirt (Poké Balls up top, Great Balls below); slingshots are solid
  rubber wedges; a coil-spring plunger.

Launcher:
- The shooter lane is a real channel inside the right edge with a curled
  hood at the top. Because that corner reliably ping-ponged a launched
  ball straight back down the tube, step() now flies the ball out on a
  scripted arc ("hood delivery") — collisions off — until it's clearly
  among the bumpers. Every pull now launches; charge sets how far left
  it carries.
- Geometry is symmetric about the playfield centre; outlane/inlane guides
  run past the flipper pivots so a ball can't wedge in the nook beside
  them. Ball-search tightened (1.1s window, 4 strikes).

Other:
- A catch now freezes the table while the celebration plays, then
  resumes (frame() early-returns during catchT).
- Canvas height is capped to the viewport so the table never needs
  scrolling on mobile; tightened the surrounding layout.

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 18:25:13 -04:00
parent 8a784ec641
commit d9ec697af8
2 changed files with 428 additions and 220 deletions

View File

@ -34,59 +34,113 @@ const PHYS = {
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.
/** Points along an arc a0→a1 (radians, y-down), inclusive of both ends. */
function arcPts(cx, cy, r, a0, a1, n) {
const p = [];
for (let i = 0; i <= n; i++) {
const a = a0 + ((a1 - a0) * i) / n;
p.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
}
return p;
}
/** Consecutive points → wall segments sharing `extra` (role, e, k…). */
function polySegs(pts, extra) {
const s = [];
for (let i = 0; i < pts.length - 1; i++) s.push({ a: pts[i], b: pts[i + 1], ...extra });
return s;
}
const PI = Math.PI;
const D = PI / 180;
// The playfield is a rounded box. The usable area runs x≈16..264; the
// shooter lane is the strip to its right (x≈264..304). Everything below
// is built symmetrically about the centre line so nothing looks stitched.
const CX = 140; // centre of the usable playfield
// Outer table outline — a rounded rectangle. Fills the stage as the arena
// floor and is the ball's wall everywhere except the drain gap and the
// lane mouth (handled by their own segments).
const OUTLINE = [
...arcPts(58, 62, 44, 180 * D, 270 * D, 8), // TL → (58,18)
...arcPts(262, 62, 44, 270 * D, 360 * D, 8), // TR → (306,62)
...arcPts(262, 498, 44, 0, 90 * D, 8), // BR → (262,542)
...arcPts(58, 498, 44, 90 * D, 180 * D, 8), // BL → (14,498)
];
// Collision boundary: the outline's left + top + right, then straight
// down the shooter-lane outer wall. Open at the bottom (the drain).
const BOUND = [
[112, 508], // runs past the left flipper pivot so there's no nook beside it
[80, 496],
[54, 478],
[32, 452],
[18, 418],
[16, 388],
[16, 106],
...arcPts(60, 106, 44, 180 * D, 270 * D, 8), // TL corner → (60,62)
[252, 62],
...arcPts(252, 106, 44, 270 * D, 360 * D, 8), // TR corner → (296,106)
[304, 512], // shooter-lane outer wall
];
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.
outline: OUTLINE,
bound: BOUND,
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, 548] }, // right wall / shooter-lane outer (continues down as the return chute)
{ a: [288, 474], b: [288, 120] }, // shooter-lane divider (tall — the ball can't fall back in after launch)
{ a: [288, 474], b: [306, 474] }, // shooter-lane floor
{ a: [14, 60], b: [14, 448] }, // left wall
...polySegs(BOUND, { role: 'wall' }),
// left outlane is CLOSED: a solid deflector from the wall to the flipper
// pivot, so a ball down the left is always fed onto the left flipper.
{ a: [14, 448], b: [50, 470] },
{ a: [50, 470], b: [86, 504] },
// shooter lane — a channel on the right. Its inner wall runs straight
// up to hold the resting ball, then curls left into a hood that steers
// a launched ball down into the playfield (with step()'s scripted
// "hood delivery" doing the heavy lifting).
{ a: [304, 512], b: [268, 512], role: 'lane' }, // lane floor
...polySegs([[268, 512], [268, 150], ...arcPts(238, 150, 30, 0, -120 * D, 6), [196, 92]], {
role: 'lane',
}),
// right side stays open below the divider so a ball can trickle to the
// return; a short guide nudges it that way.
{ a: [288, 474], b: [262, 494] },
// right inlane guide — mirrors the left outlane, runs past the right
// flipper pivot so there's no nook beside it
...polySegs(
[
[250, 400],
[244, 436],
[226, 466],
[200, 486],
[168, 508],
],
{ role: 'guide' },
),
// slingshots (triangles above each flipper's outer half)
{ a: [96, 434], b: [110, 476], e: PHYS.slingE, k: PHYS.slingKick }, // L kicking face
{ a: [96, 434], b: [76, 476] },
{ a: [76, 476], b: [110, 476] },
{ a: [230, 434], b: [214, 476], e: PHYS.slingE, k: PHYS.slingKick }, // R kicking face
{ a: [230, 434], b: [250, 476] },
{ a: [214, 476], b: [250, 476] },
// slingshots — wedges above each flipper's outer half (symmetric),
// pulled up and inboard so they don't pocket against the outlane
{ a: [104, 424], b: [118, 462], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' },
{ a: [104, 424], b: [96, 462], role: 'sling' },
{ a: [96, 462], b: [118, 462], role: 'sling' },
{ a: [176, 424], b: [190, 462], role: 'sling' },
{ a: [176, 424], b: [162, 462], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' },
{ a: [162, 462], b: [190, 462], role: 'sling' },
],
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 },
{ x: 94, y: 152, r: 15, kind: 'poke' },
{ x: 140, y: 110, r: 16, kind: 'poke' },
{ x: 186, y: 152, r: 15, kind: 'poke' },
{ x: 116, y: 220, r: 12, kind: 'great' },
{ x: 164, y: 220, r: 12, kind: 'great' },
],
// 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 },
{ x: 44, y: 300, r: 7 },
{ x: 236, y: 300, r: 7 },
{ x: 140, y: 296, 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 },
L: { pivot: [96, 500], rest: 0.32 }, // radians, y-down; tip points down-right
R: { pivot: [184, 500], rest: Math.PI - 0.32 },
},
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
laneX: 286, // ball rest x in the shooter lane
laneRestY: 500,
drainY: 546,
returnMinX: 999, // no return chute — every side feeds a flipper
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
@ -224,31 +278,45 @@ export function createPinball(canvas, cbs = {}) {
b.x = TABLE.laneX;
b.vx = 0;
}
// Gate kick: partway up the lane, fling the ball left into the
// playfield (a straight-up launch has nowhere else to go). Fired well
// below the divider top so even a soft launch clears it; the kick
// scales with the charge so a full plunge rips across the table.
if (b.launching && b.y < 300) {
b.vx = -(170 + 220 * state.launchC);
// Hood delivery: the shooter lane and the playfield's top-right corner
// share a cramped space where wall bounces reliably ping a launched
// ball straight back down the tube. So once the ball nears the top of
// the lane we fly it into the playfield on a scripted arc — collisions
// off — until it's clearly among the bumpers. The charge sets how hard
// it cuts across.
if (b.launching && b.y < 104) {
b.vx = -(300 + 250 * state.launchC);
b.vy = 40;
b.launching = false;
}
// End the scripted glide once the kick has fired (b.launching cleared)
// and the ball is well left of the shooter lane — or, as a safety, has
// dropped past the bumpers.
if (b.hooding && !b.launching && (b.x < 212 || b.y > 236)) b.hooding = 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);
if (!b.hooding)
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);
}
}
if (b.hooding) {
// still gliding out of the hood — skip the rest of the collisions
b.vx *= PHYS.drag;
b.vy *= PHYS.drag;
return;
}
for (const p of TABLE.posts) {
const dx = b.x - p.x;
@ -325,27 +393,27 @@ export function createPinball(canvas, cbs = {}) {
}
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
// Ball-search: if the ball hasn't travelled 22 units in 1.1s (and isn't
// waiting in the shooter lane) it's wedged — kick it toward centre, like
// a real table. Three strikes and it drains.
// a real table. Four 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) {
if (b.trackT > 1.1) {
const moved = Math.hypot(b.x - b.trackX, b.y - b.trackY);
if (!b.inLane && moved < 24) {
if ((b.searches || 0) >= 2) {
if (!b.inLane && !b.hooding && moved < 22) {
if ((b.searches || 0) >= 3) {
loseBall();
return;
}
b.searches = (b.searches || 0) + 1;
const kx = W / 2 - b.x;
const ky = 210 - b.y;
const kx = CX - b.x;
const ky = 200 - b.y;
const kl = Math.hypot(kx, ky) || 1;
b.vx = (kx / kl) * 360;
b.vy = (ky / kl) * 360;
b.vx = (kx / kl) * 430;
b.vy = (ky / kl) * 430;
} else {
b.searches = 0;
}
@ -377,8 +445,15 @@ export function createPinball(canvas, cbs = {}) {
// --- render ---------------------------------------------------------
function resize() {
const host = canvas.parentElement;
const cw = Math.max(200, Math.min(host ? host.clientWidth : 320, 340));
const ch = cw * (H / W);
let cw = Math.max(200, Math.min(host ? host.clientWidth : 320, 340));
let ch = cw * (H / W);
// Keep the whole table on screen without scrolling: cap the height to
// what's left of the viewport after the nav, HUD, and controls.
const availH = (window.innerHeight || 800) - 250;
if (ch > availH) {
ch = Math.max(340, availH);
cw = ch * (W / H);
}
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = `${cw}px`;
canvas.style.height = `${ch}px`;
@ -388,13 +463,6 @@ export function createPinball(canvas, cbs = {}) {
render();
}
function line(ax, ay, bx, by) {
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
/** A Poké Ball centred at (cx,cy), radius r, rotated by ang. */
function pokeball(cx, cy, r, ang, topColor) {
ctx.save();
@ -431,165 +499,293 @@ export function createPinball(canvas, cbs = {}) {
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})`;
// Flat, bright "Pokémon stadium" palette — solid fills, crisp dark
// outlines, no glow. A battle-arena floor with a Poké Ball painted on
// it, ringed by stadium seating.
const C = {
arena: '#d8b878', // arena floor (sandy battle ground)
arenaAlt: '#cdaa66', // inner ring of the floor
apron: '#2b3040', // outside the arena bowl
seatA: '#454c63', // stadium seating bands
seatB: '#3a4056',
court: 'rgba(255,255,255,0.34)', // Poké Ball court art
courtRed: 'rgba(196,32,32,0.16)',
barrier: '#eef0f2', // stadium barrier fill
edge: '#1c2230', // universal dark outline
rubber: '#e5473a', // slingshots + rubber deflectors
rubberTop: '#ff6a5c',
flip: '#f7c948', // flippers (Pikachu yellow)
flipTop: '#ffe08a',
post: '#3d6bd6', // mid-field pegs (blue)
postTop: '#6f97ee',
metal: '#b8bdc7', // plunger spring
};
/** Stroke the current path: dark outline, then a coloured core on top. */
function stroke2(core, coreW, edgeW = coreW + 3) {
ctx.lineWidth = edgeW;
ctx.strokeStyle = C.edge;
ctx.stroke();
ctx.lineWidth = coreW;
ctx.strokeStyle = core;
ctx.stroke();
}
/** Filled circle with a dark outline and a lighter top cap (a "puck"). */
function puck(x, y, r, fill, top) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
if (top) {
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.clip();
ctx.beginPath();
ctx.arc(x, y - r * 0.32, r * 0.82, 0, Math.PI * 2);
ctx.fillStyle = top;
ctx.fill();
ctx.restore();
}
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = C.edge;
ctx.stroke();
}
const tri = (pts) => {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
ctx.lineTo(pts[1][0], pts[1][1]);
ctx.lineTo(pts[2][0], pts[2][1]);
ctx.closePath();
};
/** Trace a polyline as a smooth curve (quadratics through midpoints). */
function tracePath(pts, close) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i][0] + pts[i + 1][0]) / 2;
const my = (pts[i][1] + pts[i + 1][1]) / 2;
ctx.quadraticCurveTo(pts[i][0], pts[i][1], mx, my);
}
const last = pts[pts.length - 1];
ctx.lineTo(last[0], last[1]);
if (close) ctx.closePath();
}
/** A tapered flipper bat: wide at the pivot, rounded at the tip. */
function bat(ax, ay, bx, by, wr, wt, fill) {
const ang = Math.atan2(by - ay, bx - ax);
const px = -Math.sin(ang);
const py = Math.cos(ang);
ctx.beginPath();
ctx.moveTo(ax + px * wr, ay + py * wr);
ctx.lineTo(bx + px * wt, by + py * wt);
ctx.lineTo(bx - px * wt, by - py * wt);
ctx.lineTo(ax - px * wr, ay - py * wr);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.beginPath();
ctx.arc(ax, ay, wr, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(bx, by, wt, 0, Math.PI * 2);
ctx.fill();
}
/** A pop bumper sitting on the table — skirt, cap, gloss. */
function bumper(x, y, r, kind, lit) {
ctx.beginPath();
ctx.arc(x, y, r + 5, 0, Math.PI * 2);
ctx.fillStyle = C.edge;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, r + 5, 0, Math.PI * 2);
ctx.lineWidth = 2.5;
ctx.strokeStyle = lit ? '#ffd23f' : 'rgba(255,255,255,0.45)';
ctx.stroke();
pokeball(x, y, r, 0, lit ? '#ffd23f' : kind === 'great' ? '#3d7fd6' : accent);
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.clip();
ctx.beginPath();
ctx.arc(x - r * 0.3, y - r * 0.42, r * 0.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fill();
ctx.restore();
}
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);
// big Poké Ball watermark on the playfield
ctx.save();
ctx.globalAlpha = 0.05;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(W / 2, 262, 118, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(W / 2 - 118, 262);
ctx.lineTo(W / 2 + 118, 262);
ctx.stroke();
ctx.beginPath();
ctx.arc(W / 2, 262, 34, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
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([
[96, 434],
[110, 476],
[76, 476],
]);
ctx.fill();
tri([
[230, 434],
[214, 476],
[250, 476],
]);
ctx.fill();
// outside the bowl
ctx.fillStyle = C.apron;
ctx.fillRect(0, 0, W, H);
// 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]);
// stadium seating — a crowd strip across the top
for (let row = 0; row < 3; row++) {
const yy = 14 + row * 15;
ctx.fillStyle = row % 2 ? C.seatA : C.seatB;
ctx.fillRect(0, yy - 6, W, 13);
ctx.fillStyle = 'rgba(0,0,0,0.22)';
for (let xx = 5; xx < W; xx += 9) ctx.fillRect(xx, yy - 5, 3, 11);
}
// posts
for (const p of TABLE.posts) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = '#5a5f70';
// arena floor — the rounded table outline, filling the stage
tracePath(TABLE.outline, true);
ctx.fillStyle = C.arena;
ctx.fill();
stroke2(C.edge, 2, 2);
// Poké Ball painted on the floor, centred on the usable playfield
ctx.save();
tracePath(TABLE.outline, true);
ctx.clip();
ctx.beginPath();
ctx.arc(CX, 262, 128, Math.PI, 0);
ctx.fillStyle = C.courtRed;
ctx.fill();
ctx.strokeStyle = C.court;
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(CX, 262, 128, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(CX - 128, 262);
ctx.lineTo(CX + 128, 262);
ctx.stroke();
ctx.beginPath();
ctx.arc(CX, 262, 34, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
// slingshot wedges — solid rubber, lighter top face
for (const pts of [
[
[104, 424],
[118, 462],
[96, 462],
],
[
[176, 424],
[162, 462],
[190, 462],
],
]) {
tri(pts);
ctx.fillStyle = C.rubber;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = accent;
ctx.strokeStyle = C.edge;
ctx.stroke();
tri([pts[0], pts[1], [(pts[1][0] + pts[2][0]) / 2, (pts[1][1] + pts[2][1]) / 2]]);
ctx.fillStyle = C.rubberTop;
ctx.fill();
}
// bumpers — Poké Balls; a lit one "opens" (white) with a halo
// lane + guide walls — straight white barriers
for (const w of TABLE.walls) {
if (w.role !== 'lane' && w.role !== 'guide') continue;
ctx.beginPath();
ctx.moveTo(w.a[0], w.a[1]);
ctx.lineTo(w.b[0], w.b[1]);
stroke2(C.barrier, 3, 6);
}
// the play boundary — one smooth white barrier
tracePath(TABLE.bound, false);
stroke2(C.barrier, 5, 9);
// mid-field pegs
for (const p of TABLE.posts) puck(p.x, p.y, p.r, C.post, C.postTop);
// bumpers
for (let i = 0; i < TABLE.bumpers.length; i++) {
const bp = TABLE.bumpers[i];
const lit = state.lit[i];
if (lit) {
ctx.save();
ctx.shadowColor = accent;
ctx.shadowBlur = 24;
}
pokeball(bp.x, bp.y, bp.r, 0, lit ? '#ffffff' : accent);
if (lit) ctx.restore();
bumper(bp.x, bp.y, bp.r, bp.kind, state.lit[i]);
}
// flippers — tapered yellow bats
for (const which of ['L', 'R']) {
const s = flipperSeg(which);
bat(s.ax, s.ay, s.bx, s.by, 9.5, 4.5, C.edge);
bat(s.ax, s.ay, s.bx, s.by, 7.5, 3, C.flip);
bat(s.ax, s.ay, s.ax + (s.bx - s.ax) * 0.6, s.ay + (s.by - s.ay) * 0.6, 3.5, 2, C.flipTop);
}
// plunger — a coil spring below the shooter lane, compresses as it charges
{
const lx = TABLE.laneX;
const restY = 516;
const botY = 542;
const topY = restY + state.charge * (botY - restY - 6);
ctx.beginPath();
ctx.arc(bp.x, bp.y, bp.r + 2, 0, Math.PI * 2);
ctx.moveTo(lx, botY);
const coils = 4;
for (let k = 0; k <= coils; k++) {
const y = botY - (k / coils) * (botY - topY);
ctx.lineTo(k % 2 ? lx + 9 : lx - 9, y);
}
ctx.lineTo(lx, topY);
ctx.strokeStyle = C.edge;
ctx.lineWidth = 4;
ctx.stroke();
ctx.strokeStyle = C.metal;
ctx.lineWidth = 2;
ctx.strokeStyle = lit ? '#fff' : accentA(0.5);
ctx.stroke();
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(lx - 10, topY - 8, 20, 9, 3);
else ctx.rect(lx - 10, topY - 8, 20, 9);
ctx.fillStyle = C.metal;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = C.edge;
ctx.stroke();
}
// 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 — sits below the shooter-lane floor and is pulled DOWN to charge
{
const top = 480 + state.charge * 22; // more charge → further down
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
ctx.lineWidth = 4;
line(297, 474, 297, top); // shaft
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(288, top, 18, 12, 4);
else ctx.rect(288, top, 18, 12);
ctx.fill();
}
// ball — a spinning Poké Ball with a drop shadow
// ball — a spinning Poké Ball
const b = state.ball;
if (b) {
const r = PHYS.ballR;
ctx.beginPath();
ctx.arc(b.x, b.y + 2.5, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.arc(b.x + 1.5, b.y + 2, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.fill();
pokeball(b.x, b.y, r, b.spin, accent);
}
// catch — the table is frozen; show a scoreboard card over a dim wash
if (state.catchT > 0) {
const name = (state.catchMon?.name || 'a Pokémon').toUpperCase();
ctx.globalAlpha = Math.min(1, state.catchT / 1.4);
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, H / 2 - 60, W, 88);
ctx.fillStyle = '#fff';
ctx.font = 'bold 20px system-ui, sans-serif';
ctx.fillStyle = 'rgba(10,12,18,0.55)';
ctx.fillRect(0, 0, W, H);
const cy = 292;
const cw = 244;
const ch = 92;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(W / 2 - cw / 2, cy - ch / 2, cw, ch, 14);
else ctx.rect(W / 2 - cw / 2, cy - ch / 2, cw, ch);
ctx.fillStyle = C.edge;
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = '#ffd23f';
ctx.stroke();
ctx.textAlign = 'center';
ctx.fillText('Gotcha!', W / 2, H / 2 - 30);
ctx.font = 'bold 17px system-ui, sans-serif';
ctx.fillStyle = accent;
ctx.fillText(`${name} was caught!`, W / 2, H / 2 - 6);
ctx.globalAlpha = 1;
ctx.fillStyle = '#fff';
ctx.font = 'bold 21px system-ui, sans-serif';
ctx.fillText('Gotcha!', W / 2, cy - 6);
ctx.fillStyle = '#ffd23f';
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText(`${name} was caught!`, W / 2, cy + 18);
ctx.textAlign = 'left';
}
}
@ -598,7 +794,13 @@ export function createPinball(canvas, cbs = {}) {
// 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);
// A catch freezes the table — the ball hangs where it was while the
// celebration plays out, then the game resumes.
if (state.catchT > 0) {
state.catchT = Math.max(0, state.catchT - fdt);
state.charge = state.chargeStart ? Math.min(1, (now - state.chargeStart) / PULL_MS) : 0;
return;
}
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;
@ -651,13 +853,14 @@ export function createPinball(canvas, cbs = {}) {
} else if (!down && state.plunge && state.chargeStart && b?.inLane) {
const held = (performance.now() - state.chargeStart) / 1000;
const c = Math.min(1, held / (PULL_MS / 1000)); // 0 = tap, 1 = full hold
// Straight up the lane; step() kicks it left once it clears the gate.
// Both the launch speed and that kick scale with the charge, so a
// full plunge rips the ball across the top and a tap just lobs it in.
b.vy = -(760 + 320 * c);
// Straight up the lane fast enough to reach the hood; step() then
// flies it into the playfield (see "Hood delivery"). Every pull
// launches; a hard pull carries it to the far bumpers.
b.vy = -(940 + 180 * c);
b.vx = 0;
b.inLane = false; // commit — don't let a graze in the lane cancel the launch
b.launching = true;
b.hooding = true;
state.launchC = c;
state.chargeStart = 0;
state.charge = 0;

View File

@ -3888,12 +3888,18 @@
user-select: none;
-webkit-touch-callout: none;
}
.pin .view__header {
margin: 0 0 6px;
}
.pin .view__header h1 {
font-size: 1.4rem;
}
.pin__hud {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 16px;
margin: 2px 0 12px;
gap: 6px 16px;
margin: 2px 0 8px;
font-size: 0.82rem;
color: var(--text-dim);
}
@ -3912,20 +3918,19 @@
}
.pin__stage {
position: relative;
padding: 8px;
border-radius: calc(var(--radius-lg) + 8px);
background: linear-gradient(160deg, #2a2c36, #131419);
padding: 6px;
border-radius: calc(var(--radius-lg) + 6px);
background: #1c2230;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 10px 30px -12px rgba(0, 0, 0, 0.6);
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 10px 30px -14px rgba(0, 0, 0, 0.7);
}
.pin__canvas {
display: block;
margin: 0 auto; /* the engine sets style.width/height from the container */
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
touch-action: none;
background: #101116;
background: #2c6a34;
-webkit-user-select: none;
user-select: none;
}
@ -3952,7 +3957,7 @@
.pin__controls {
display: flex;
justify-content: center;
margin-top: 12px;
margin-top: 8px;
}
.pin__launch {
position: relative;
@ -3971,8 +3976,8 @@
position: relative;
}
.pin__hint {
margin-top: 10px;
font-size: 0.78rem;
margin-top: 7px;
font-size: 0.72rem;
color: var(--text-dim);
text-align: center;
}
@ -4025,8 +4030,8 @@
/* "caught this run" strip */
.pin__caught {
min-height: 38px;
margin-top: 10px;
min-height: 34px;
margin-top: 7px;
}
.pin__caught-row {
display: flex;