From d9ec697af82728d7cf7818a74469ce470311079b Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 18:25:13 -0400 Subject: [PATCH] =?UTF-8?q?Pinball:=20rebuild=20the=20table=20=E2=80=94=20?= =?UTF-8?q?flat=20stadium=20look,=20connected=20shooter=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/lib/pinball.js | 615 ++++++++++++++++++++++++++++-------------- src/styles/layout.css | 33 ++- 2 files changed, 428 insertions(+), 220 deletions(-) diff --git a/src/lib/pinball.js b/src/lib/pinball.js index 76e056a..b885294 100644 --- a/src/lib/pinball.js +++ b/src/lib/pinball.js @@ -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; diff --git a/src/styles/layout.css b/src/styles/layout.css index 2e6af8f..d5c5898 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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;