Compare commits
2 Commits
9fa46493a8
...
f957a93c36
| Author | SHA1 | Date | |
|---|---|---|---|
| f957a93c36 | |||
| 0c16c1c4a7 |
@ -2,7 +2,8 @@
|
||||
* 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 })
|
||||
* createPinball(canvas, { onScore, onBalls, onGameOver, onCatch, onBump,
|
||||
* onDrain, pickMon })
|
||||
* → { start, stop, newGame, setFlipper('L'|'R', down), setPlunge(down) }
|
||||
*
|
||||
* All geometry is in logical units (a W×H table); the renderer scales to
|
||||
@ -10,7 +11,7 @@
|
||||
*/
|
||||
|
||||
const W = 320;
|
||||
const H = 540;
|
||||
const H = 560;
|
||||
|
||||
const PHYS = {
|
||||
gravity: 1050, // units/s²
|
||||
@ -18,51 +19,69 @@ const PHYS = {
|
||||
maxSpeed: 1000, // 5 substeps keep even this from tunnelling
|
||||
substeps: 5,
|
||||
ballR: 7,
|
||||
wallE: 0.32, // wall restitution
|
||||
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.5,
|
||||
bumperKick: 108,
|
||||
slingE: 1.06,
|
||||
slingKick: 78,
|
||||
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 = {
|
||||
// Concave corners are chamfered so a slow ball can't wedge in a vertex.
|
||||
// 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, 58], b: [40, 20] }, // top-left corner
|
||||
{ a: [40, 20], b: [252, 20] }, // top
|
||||
{ a: [252, 20], b: [300, 56] }, // top-right corner
|
||||
{ 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, 446] }, // right wall / shooter-lane outer
|
||||
{ a: [288, 446], b: [288, 230] }, // shooter-lane divider
|
||||
{ a: [288, 446], b: [306, 446] }, // shooter-lane floor
|
||||
{ a: [14, 58], b: [14, 452] }, // left wall
|
||||
{ a: [14, 452], b: [34, 476] }, // left chamfer
|
||||
{ a: [34, 476], b: [86, 506], e: 0.12 }, // left inlane → left flipper
|
||||
{ a: [288, 446], b: [268, 470] }, // right chamfer off the divider
|
||||
{ a: [268, 470], b: [214, 504], e: 0.12 }, // right inlane → right flipper
|
||||
],
|
||||
// slingshots — bouncy, kink up off the inlane end (a convex corner)
|
||||
slings: [
|
||||
{ a: [86, 506], b: [114, 452] },
|
||||
{ a: [214, 504], b: [188, 452] },
|
||||
{ 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: 92, y: 150, r: 15 },
|
||||
{ x: 168, y: 114, r: 15 },
|
||||
{ x: 226, y: 150, r: 15 },
|
||||
{ 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: [88, 500], rest: 0.36 }, // radians, y-down; tip points down-right
|
||||
R: { pivot: [214, 500], rest: Math.PI - 0.36 },
|
||||
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
|
||||
drainY: 536,
|
||||
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));
|
||||
@ -98,14 +117,21 @@ export function createPinball(canvas, cbs = {}) {
|
||||
plunge: false,
|
||||
chargeStart: 0, // performance.now() when the plunger was pressed
|
||||
charge: 0, // 0..1, for the plunger visual only
|
||||
lit: [false, false, false],
|
||||
lit: TABLE.bumpers.map(() => false),
|
||||
catchT: 0,
|
||||
catchName: '',
|
||||
catchMon: null,
|
||||
scale: 1,
|
||||
};
|
||||
|
||||
function spawnBall() {
|
||||
state.ball = { x: TABLE.laneX, y: 436, vx: 0, vy: 0, inLane: true };
|
||||
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;
|
||||
}
|
||||
@ -116,7 +142,7 @@ export function createPinball(canvas, cbs = {}) {
|
||||
state.score = 0;
|
||||
state.balls = 3;
|
||||
state.over = false;
|
||||
state.lit = [false, false, false];
|
||||
state.lit = TABLE.bumpers.map(() => false);
|
||||
spawnBall();
|
||||
cbs.onScore?.(0);
|
||||
cbs.onBalls?.(3);
|
||||
@ -196,20 +222,39 @@ export function createPinball(canvas, cbs = {}) {
|
||||
b.y += b.vy * dt;
|
||||
|
||||
for (const w of TABLE.walls) {
|
||||
collideSeg(b, w.a[0], w.a[1], w.b[0], w.b[1], w.e ?? PHYS.wallE, 0);
|
||||
}
|
||||
for (const s of TABLE.slings) {
|
||||
if (collideSeg(b, s.a[0], s.a[1], s.b[0], s.b[1], PHYS.slingE, 2)) {
|
||||
const mx = (s.a[0] + s.b[0]) / 2;
|
||||
const my = (s.a[1] + s.b[1]) / 2;
|
||||
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) * PHYS.slingKick;
|
||||
b.vy += (ny / nl) * PHYS.slingKick;
|
||||
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);
|
||||
cbs.onBump?.('post');
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
||||
const bp = TABLE.bumpers[i];
|
||||
let dx = b.x - bp.x;
|
||||
@ -233,14 +278,16 @@ export function createPinball(canvas, cbs = {}) {
|
||||
b.x = bp.x + nx * (PHYS.ballR + bp.r + 0.5);
|
||||
b.y = bp.y + ny * (PHYS.ballR + bp.r + 0.5);
|
||||
addScore(100);
|
||||
cbs.onBump?.('bumper');
|
||||
if (!state.lit[i]) {
|
||||
state.lit[i] = true;
|
||||
if (state.lit.every(Boolean)) {
|
||||
state.lit = [false, false, false];
|
||||
state.lit = TABLE.bumpers.map(() => false);
|
||||
addScore(5000);
|
||||
state.catchName = (cbs.pickName?.() || 'Something').toUpperCase();
|
||||
state.catchT = 1.6;
|
||||
cbs.onCatch?.(state.catchName);
|
||||
const mon = cbs.pickMon?.() || { id: 0, name: 'Something' };
|
||||
state.catchMon = mon;
|
||||
state.catchT = 2.2;
|
||||
cbs.onCatch?.(mon);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -260,6 +307,7 @@ export function createPinball(canvas, cbs = {}) {
|
||||
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
|
||||
@ -290,12 +338,17 @@ export function createPinball(canvas, cbs = {}) {
|
||||
b.trackY = b.y;
|
||||
}
|
||||
|
||||
if (b.y > TABLE.drainY) loseBall();
|
||||
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);
|
||||
cbs.onDrain?.();
|
||||
if (state.balls <= 0) {
|
||||
state.over = true;
|
||||
state.ball = null;
|
||||
@ -326,95 +379,200 @@ export function createPinball(canvas, cbs = {}) {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** A Poké Ball centred at (cx,cy), radius r, rotated by ang. */
|
||||
function pokeball(cx, cy, r, ang, topColor) {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(ang);
|
||||
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 = topColor;
|
||||
ctx.fillRect(-r, -r, r * 2, r);
|
||||
const band = Math.max(2.4, r * 0.3);
|
||||
ctx.fillStyle = '#17181d';
|
||||
ctx.fillRect(-r, -band / 2, r * 2, band);
|
||||
const br = Math.max(2, r * 0.36);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, br, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#17181d';
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, br * 0.55, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#f4f4f6';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.lineWidth = Math.max(1, r * 0.14);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.45)';
|
||||
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, '#282a34');
|
||||
g.addColorStop(1, '#101116');
|
||||
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, 20, W / 2, 150, 220);
|
||||
glow.addColorStop(0, `${accent}22`);
|
||||
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';
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.lineWidth = 2.5;
|
||||
for (const w of TABLE.walls) line(w.a[0], w.a[1], w.b[0], w.b[1]);
|
||||
// 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();
|
||||
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = 5;
|
||||
for (const s of TABLE.slings) line(s.a[0], s.a[1], s.b[0], s.b[1]);
|
||||
// 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 — Poké Balls; a lit one "opens" (white) with a halo
|
||||
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
||||
const bp = TABLE.bumpers[i];
|
||||
ctx.beginPath();
|
||||
ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2);
|
||||
if (state.lit[i]) {
|
||||
ctx.fillStyle = accent;
|
||||
const lit = state.lit[i];
|
||||
if (lit) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = accent;
|
||||
ctx.shadowBlur = 18;
|
||||
} else {
|
||||
ctx.fillStyle = '#4a4e5c';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowBlur = 24;
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = state.lit[i] ? '#fff' : accent;
|
||||
pokeball(bp.x, bp.y, bp.r, 0, lit ? '#ffffff' : accent);
|
||||
if (lit) ctx.restore();
|
||||
ctx.beginPath();
|
||||
ctx.arc(bp.x, bp.y, bp.r + 2, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = lit ? '#fff' : accentA(0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = PHYS.flipThick * 2;
|
||||
// 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
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.fillRect(289, 452 + state.charge * 14, 16, 20);
|
||||
// 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
|
||||
const b = state.ball;
|
||||
if (b) {
|
||||
ctx.save();
|
||||
const r = PHYS.ballR;
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, PHYS.ballR, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = '#f2f2f5';
|
||||
ctx.fillRect(b.x - 9, b.y - 9, 18, 18);
|
||||
ctx.fillStyle = accent;
|
||||
ctx.fillRect(b.x - 9, b.y - 9, 18, 9);
|
||||
ctx.restore();
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, PHYS.ballR, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(b.x - PHYS.ballR, b.y);
|
||||
ctx.lineTo(b.x + PHYS.ballR, b.y);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, 2.4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.arc(b.x, b.y + 2.5, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.28)';
|
||||
ctx.fill();
|
||||
pokeball(b.x, b.y, r, b.spin, accent);
|
||||
}
|
||||
|
||||
if (state.catchT > 0) {
|
||||
ctx.globalAlpha = Math.min(1, state.catchT);
|
||||
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 22px system-ui, sans-serif';
|
||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 40);
|
||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||||
ctx.fillText('Gotcha!', W / 2, H / 2 - 30);
|
||||
ctx.font = 'bold 17px system-ui, sans-serif';
|
||||
ctx.fillStyle = accent;
|
||||
ctx.fillText('+5000', W / 2, H / 2 - 18);
|
||||
ctx.fillText(`${name} was caught!`, W / 2, H / 2 - 6);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
@ -1,8 +1,20 @@
|
||||
import { createStore } from './createStore.js';
|
||||
|
||||
/** High score for the hidden pinball table (#/pinball). */
|
||||
export const pinball = createStore('pdx.pinball', { best: 0 });
|
||||
/** High score + the catch log for the hidden pinball table (#/pinball). */
|
||||
export const pinball = createStore('pdx.pinball', { best: 0, caught: [], sound: true });
|
||||
|
||||
export function recordPinball(score) {
|
||||
if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score }));
|
||||
}
|
||||
|
||||
/** Log a Pokémon "caught" on the table (most recent first, de-duped, capped). */
|
||||
export function recordCatch(mon) {
|
||||
pinball.set((s) => {
|
||||
const caught = [mon, ...s.caught.filter((m) => m.id !== mon.id)].slice(0, 24);
|
||||
return { ...s, caught };
|
||||
});
|
||||
}
|
||||
|
||||
export function togglePinballSound() {
|
||||
pinball.set((s) => ({ ...s, sound: !s.sound }));
|
||||
}
|
||||
|
||||
@ -3882,8 +3882,11 @@
|
||||
|
||||
/* ---- Hidden mini-game: Pinball (#/pinball) ----------------------- */
|
||||
.pin {
|
||||
max-width: 26rem;
|
||||
max-width: 24rem;
|
||||
margin: 0 auto;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
.pin__hud {
|
||||
display: flex;
|
||||
@ -3909,14 +3912,22 @@
|
||||
}
|
||||
.pin__stage {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
border-radius: calc(var(--radius-lg) + 8px);
|
||||
background: linear-gradient(160deg, #2a2c36, #131419);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
0 10px 30px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.pin__canvas {
|
||||
display: block;
|
||||
margin: 0 auto; /* the engine sets style.width/height from the container */
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
touch-action: none;
|
||||
background: #101116;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.pin__over[hidden] {
|
||||
display: none;
|
||||
@ -3953,3 +3964,77 @@
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
.pin__sound {
|
||||
margin-left: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* catch pop-up over the table */
|
||||
.pin__catch[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.pin__catch {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: radial-gradient(60% 50% at 50% 45%, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.55));
|
||||
animation: pinCatch 0.3s ease;
|
||||
}
|
||||
@keyframes pinCatch {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
.pin__catch img {
|
||||
filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
.pin__catch-title {
|
||||
margin: 6px 0 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pin__catch-name {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* "caught this run" strip */
|
||||
.pin__caught {
|
||||
min-height: 38px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.pin__caught-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pin__caught-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.pin__caught-row img {
|
||||
background: var(--surface-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pin__caught-empty {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
@ -1,39 +1,119 @@
|
||||
import { el, onTeardown } from '../lib/dom.js';
|
||||
import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { createPinball } from '../lib/pinball.js';
|
||||
import { pinball, recordPinball } from '../store/pinball.js';
|
||||
import { pinball, recordPinball, recordCatch, togglePinballSound } from '../store/pinball.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
|
||||
const pretty = (name) => name.replace(/-/g, ' ');
|
||||
|
||||
// --- tiny WebAudio blips (no assets) ---------------------------------
|
||||
let actx = null;
|
||||
function tone(freq, dur, type = 'square', vol = 0.05, slideTo) {
|
||||
if (!pinball.get().sound) return;
|
||||
try {
|
||||
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (actx.state === 'suspended') actx.resume();
|
||||
const t = actx.currentTime;
|
||||
const o = actx.createOscillator();
|
||||
const g = actx.createGain();
|
||||
o.type = type;
|
||||
o.frequency.setValueAtTime(freq, t);
|
||||
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t + dur);
|
||||
g.gain.setValueAtTime(vol, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
o.connect(g).connect(actx.destination);
|
||||
o.start(t);
|
||||
o.stop(t + dur + 0.02);
|
||||
} catch {
|
||||
/* audio not available — no-op */
|
||||
}
|
||||
}
|
||||
const sfx = {
|
||||
bump: () => tone(320 + Math.random() * 120, 0.06),
|
||||
post: () => tone(520, 0.04, 'triangle', 0.035),
|
||||
drain: () => tone(300, 0.35, 'sawtooth', 0.05, 90),
|
||||
launch: () => tone(180, 0.22, 'sawtooth', 0.05, 520),
|
||||
catch: () => {
|
||||
[523, 659, 784, 1047].forEach((f, i) =>
|
||||
setTimeout(() => tone(f, 0.12, 'square', 0.05), i * 90),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Hidden mini-game — a single pinball table. Reached from the "Who's that
|
||||
* Pokémon?" screen's nav row, or the bare #/pinball hash.
|
||||
* Hidden mini-game — a single Pokémon-flavoured pinball table. Reached from
|
||||
* the "Who's that Pokémon?" screen's nav row, or the bare #/pinball hash.
|
||||
*
|
||||
* ← / → (or A / L) work the flippers; hold Space (or the Launch button) to
|
||||
* pull the plunger. On touch, tap the left or right half of the table.
|
||||
* ← / → (or A / L / Z) work the flippers; hold Space or the Launch button
|
||||
* to pull the plunger. On touch, tap the table's left or right half.
|
||||
*/
|
||||
export async function PinballView() {
|
||||
const view = el('section', { class: 'view pin' });
|
||||
const snap = await loadSnapshot();
|
||||
|
||||
const scoreEl = el('span', { class: 'pin__score' }, '0');
|
||||
const bestEl = el('span', { class: 'pin__best' }, `Best ${pinball.get().best}`);
|
||||
const bestEl = el('span', { class: 'pin__best' }, `Best ${pinball.get().best.toLocaleString()}`);
|
||||
const ballsEl = el('span', { class: 'pin__balls' });
|
||||
const overlay = el('div', { class: 'pin__over', hidden: true });
|
||||
|
||||
const catchPop = el('div', { class: 'pin__catch', hidden: true });
|
||||
const caughtStrip = el('div', { class: 'pin__caught' });
|
||||
const canvas = el('canvas', { class: 'pin__canvas', 'aria-label': 'Pinball table' });
|
||||
|
||||
function renderBalls(n) {
|
||||
ballsEl.textContent = '●'.repeat(Math.max(0, n)) || '—';
|
||||
}
|
||||
function renderCaught() {
|
||||
const list = pinball.get().caught;
|
||||
caughtStrip.replaceChildren(
|
||||
list.length
|
||||
? el(
|
||||
'div',
|
||||
{ class: 'pin__caught-row' },
|
||||
el('span', { class: 'pin__caught-label' }, 'Caught'),
|
||||
...list.slice(0, 14).map((m) => Sprite(m.id, { size: 30, alt: m.name })),
|
||||
)
|
||||
: el('span', { class: 'pin__caught-empty' }, 'Light all 5 Poké Balls to catch one.'),
|
||||
);
|
||||
}
|
||||
|
||||
let catchTimer = null;
|
||||
const soundBtn = el('button', {
|
||||
class: 'pin__sound',
|
||||
type: 'button',
|
||||
title: 'Sound',
|
||||
'aria-label': 'Toggle sound',
|
||||
onclick: () => {
|
||||
togglePinballSound();
|
||||
soundBtn.textContent = pinball.get().sound ? '🔊' : '🔇';
|
||||
},
|
||||
});
|
||||
soundBtn.textContent = pinball.get().sound ? '🔊' : '🔇';
|
||||
|
||||
const game = createPinball(canvas, {
|
||||
onScore: (s) => (scoreEl.textContent = s.toLocaleString()),
|
||||
onBalls: renderBalls,
|
||||
pickName: () => pretty(snap.species[(Math.random() * snap.species.length) | 0].name),
|
||||
onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()),
|
||||
onDrain: () => sfx.drain(),
|
||||
pickMon: () => {
|
||||
const s = snap.species[(Math.random() * snap.species.length) | 0];
|
||||
return { id: s.id, name: pretty(s.name) };
|
||||
},
|
||||
onCatch: (mon) => {
|
||||
sfx.catch();
|
||||
recordCatch(mon);
|
||||
renderCaught();
|
||||
catchPop.replaceChildren(
|
||||
Sprite(mon.id, { style: 'official', size: 120, alt: mon.name }),
|
||||
el('p', { class: 'pin__catch-title' }, 'Gotcha!'),
|
||||
el('p', { class: 'pin__catch-name' }, `${mon.name} was caught!`),
|
||||
);
|
||||
catchPop.hidden = false;
|
||||
clearTimeout(catchTimer);
|
||||
catchTimer = setTimeout(() => (catchPop.hidden = true), 2200);
|
||||
},
|
||||
onGameOver: (score) => {
|
||||
recordPinball(score);
|
||||
bestEl.textContent = `Best ${pinball.get().best}`;
|
||||
bestEl.textContent = `Best ${pinball.get().best.toLocaleString()}`;
|
||||
overlay.replaceChildren(
|
||||
el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`),
|
||||
el(
|
||||
@ -54,21 +134,16 @@ export async function PinballView() {
|
||||
});
|
||||
|
||||
// --- input --------------------------------------------------------
|
||||
const KEYS = {
|
||||
ArrowLeft: 'L',
|
||||
ArrowRight: 'R',
|
||||
a: 'L',
|
||||
d: 'R',
|
||||
z: 'L',
|
||||
l: 'R',
|
||||
};
|
||||
const KEYS = { ArrowLeft: 'L', ArrowRight: 'R', a: 'L', d: 'R', z: 'L', l: 'R' };
|
||||
function onKey(down) {
|
||||
return (e) => {
|
||||
if (e.repeat) return;
|
||||
const f = KEYS[e.key];
|
||||
if (f) {
|
||||
game.setFlipper(f, down);
|
||||
e.preventDefault();
|
||||
} else if (e.key === ' ' || e.key === 'ArrowDown') {
|
||||
if (down && !e.repeat) sfx.launch();
|
||||
game.setPlunge(down);
|
||||
e.preventDefault();
|
||||
}
|
||||
@ -79,23 +154,56 @@ export async function PinballView() {
|
||||
window.addEventListener('keydown', kd);
|
||||
window.addEventListener('keyup', ku);
|
||||
|
||||
// Per-pointer flipper tracking: two thumbs work, and a missed pointerup
|
||||
// can never leave a flipper stuck on.
|
||||
const pointers = new Map(); // pointerId -> 'L' | 'R'
|
||||
function resync() {
|
||||
let L = false;
|
||||
let R = false;
|
||||
for (const side of pointers.values()) side === 'L' ? (L = true) : (R = true);
|
||||
game.setFlipper('L', L);
|
||||
game.setFlipper('R', R);
|
||||
}
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
canvas.setPointerCapture?.(e.pointerId);
|
||||
const left = e.offsetX < canvas.clientWidth / 2;
|
||||
game.setFlipper(left ? 'L' : 'R', true);
|
||||
canvas._side = left ? 'L' : 'R';
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointers.set(e.pointerId, e.clientX - rect.left < rect.width / 2 ? 'L' : 'R');
|
||||
resync();
|
||||
});
|
||||
const releaseFlippers = (e) => {
|
||||
if (canvas._side) game.setFlipper(canvas._side, false);
|
||||
canvas._side = null;
|
||||
const drop = (e) => {
|
||||
if (pointers.delete(e.pointerId)) resync();
|
||||
};
|
||||
canvas.addEventListener('pointerup', releaseFlippers);
|
||||
canvas.addEventListener('pointercancel', releaseFlippers);
|
||||
for (const ev of ['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture'])
|
||||
canvas.addEventListener(ev, drop);
|
||||
|
||||
const launchBtn = el('button', { class: 'button pin__launch', type: 'button' }, 'Launch (hold)');
|
||||
for (const ev of ['pointerdown']) launchBtn.addEventListener(ev, () => game.setPlunge(true));
|
||||
const launchBtn = el('button', {
|
||||
class: 'button pin__launch',
|
||||
type: 'button',
|
||||
onpointerdown: (e) => {
|
||||
e.preventDefault();
|
||||
sfx.launch();
|
||||
game.setPlunge(true);
|
||||
},
|
||||
});
|
||||
launchBtn.textContent = 'Launch (hold)';
|
||||
const releasePlunge = () => game.setPlunge(false);
|
||||
for (const ev of ['pointerup', 'pointerleave', 'pointercancel'])
|
||||
launchBtn.addEventListener(ev, () => game.setPlunge(false));
|
||||
launchBtn.addEventListener(ev, releasePlunge);
|
||||
|
||||
// Global safety net: any release / focus loss drops the plunger and flippers.
|
||||
const panic = () => {
|
||||
game.setPlunge(false);
|
||||
if (pointers.size) {
|
||||
pointers.clear();
|
||||
resync();
|
||||
}
|
||||
};
|
||||
const onVis = () => document.hidden && panic();
|
||||
window.addEventListener('pointerup', releasePlunge);
|
||||
window.addEventListener('pointercancel', panic);
|
||||
window.addEventListener('blur', panic);
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
view.append(
|
||||
el(
|
||||
@ -106,24 +214,33 @@ export async function PinballView() {
|
||||
el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"),
|
||||
),
|
||||
el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')),
|
||||
el('div', { class: 'pin__hud' }, el('span', {}, 'Score ', scoreEl), ballsEl, bestEl),
|
||||
el('div', { class: 'pin__stage' }, canvas, overlay),
|
||||
el('div', { class: 'pin__hud' }, el('span', {}, 'Score ', scoreEl), ballsEl, bestEl, soundBtn),
|
||||
el('div', { class: 'pin__stage' }, canvas, overlay, catchPop),
|
||||
caughtStrip,
|
||||
el('div', { class: 'pin__controls' }, launchBtn),
|
||||
el(
|
||||
'p',
|
||||
{ class: 'pin__hint' },
|
||||
'← / → flippers · hold Space to launch · tap the table’s left / right half on touch',
|
||||
'← / → flippers · hold Space to launch · on touch, tap the table’s left / right half',
|
||||
),
|
||||
);
|
||||
|
||||
renderBalls(3);
|
||||
// Give the canvas a layout box before the engine measures it.
|
||||
renderCaught();
|
||||
requestAnimationFrame(() => game.start());
|
||||
|
||||
const offStore = pinball.subscribe(() => renderCaught());
|
||||
|
||||
onTeardown(view, () => {
|
||||
game.stop();
|
||||
clearTimeout(catchTimer);
|
||||
offStore();
|
||||
window.removeEventListener('keydown', kd);
|
||||
window.removeEventListener('keyup', ku);
|
||||
window.removeEventListener('pointerup', releasePlunge);
|
||||
window.removeEventListener('pointercancel', panic);
|
||||
window.removeEventListener('blur', panic);
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user