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:
parent
8a784ec641
commit
d9ec697af8
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user