Pinball: hand-tuned table via a new drag-to-edit dev tool

Added #/pinball-editor — a temporary drag-to-edit layout board (points,
bumpers/posts, flipper pivot+tip, mirror lock, add/delete nodes,
rubber-band multi-select, live JSON export/autosave). Used it to
redesign the table by hand instead of guessing coordinates, then
rebuilt TABLE in pinball.js from the exported layout:

- Real outlane tubes on both sides (inboard of the outer wall) instead
  of a sealed funnel — a ball can miss the flipper and drain, same as
  a real table, verified to have no wedge points.
- Flippers same length, mirrored pivots and rest angle; slingshots and
  outlane tubes are exact mirrors of each other.
- Everything (outlanes, slings, bumpers, posts, flippers) recentred on
  the whole table width — including the shooter lane — rather than
  just the playfield-minus-lane, so it isn't visually lopsided.
- Bumper/post sizes back to a consistent set after some experimentation
  drifted them apart (15/16/15/12/12 and 7/7/8).
- Physics: heavier gravity (was floaty), stronger slingshot kick (the
  rubber wasn't doing anything), eased back down slightly after the
  first pass felt too fast, flipper length is now per-flipper.
- The "gravity only goes down" trap-safety test (added last session)
  now runs against this hand-edited geometry too and passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 23:42:09 -04:00
parent 5861dd6b97
commit 6b090b9a0a
6 changed files with 967 additions and 94 deletions

View File

@ -14,23 +14,23 @@ const W = 320;
const H = 560; const H = 560;
const PHYS = { const PHYS = {
gravity: 1050, // units/s² gravity: 1220, // units/s² — heavier than a floaty ball, eased back a touch from 1320
drag: 0.9998, // per sub-step (×substeps×fps) — keep close to 1 or the ball dies fast drag: 0.9997, // per sub-step (×substeps×fps) — keep close to 1 or the ball dies fast
maxSpeed: 1120, // 5 substeps keep even this from tunnelling maxSpeed: 1100, // 5 substeps keep even this from tunnelling
substeps: 5, substeps: 5,
ballR: 7, ballR: 7,
wallE: 0.34, // wall restitution wallE: 0.34, // wall restitution
flipE: 0.28, flipE: 0.28,
flipThick: 6, flipThick: 6,
flipLen: 56, flipLen: 50,
flipSwing: 1.02, // radians flipSwing: 1.02, // radians
flipSpeed: 34, // ease rate toward target angle flipSpeed: 34, // ease rate toward target angle
bumperE: 0.55, bumperE: 0.55,
bumperKick: 116, bumperKick: 116,
postE: 0.75, postE: 0.75,
postKick: 70, postKick: 70,
slingE: 1.1, slingE: 1.3,
slingKick: 90, slingKick: 230, // a real kick away from the rubber, not a nudge
spin: 0.07, // Poké Ball rotation per unit travelled spin: 0.07, // Poké Ball rotation per unit travelled
}; };
@ -56,7 +56,7 @@ const D = PI / 180;
// The playfield is a rounded box. The usable area runs x≈16..264; the // 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 // shooter lane is the strip to its right (x≈264..304). Everything below
// is built symmetrically about the centre line so nothing looks stitched. // is built symmetrically about the centre line so nothing looks stitched.
const CX = 140; // centre of the usable playfield const CX = 160; // centre of the whole table, tunnel included (canvas centre)
// Outer table outline — a rounded rectangle. Fills the stage as the arena // 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 // floor and is the ball's wall everywhere except the drain gap and the
@ -64,24 +64,59 @@ const CX = 140; // centre of the usable playfield
const OUTLINE = [ const OUTLINE = [
...arcPts(58, 62, 44, 180 * D, 270 * D, 8), // TL → (58,18) ...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, 62, 44, 270 * D, 360 * D, 8), // TR → (306,62)
...arcPts(262, 498, 44, 0, 90 * D, 8), // BR → (262,542) ...arcPts(262, 508, 44, 0, 90 * D, 8), // BR → (262,552)
...arcPts(58, 498, 44, 90 * D, 180 * D, 8), // BL → (14,498) ...arcPts(58, 508, 44, 90 * D, 180 * D, 8), // BL → (14,508)
]; ];
// Collision boundary: the outline's left + top + right, then straight // This table's layout was hand-tuned in the #/pinball-editor dev tool
// down the shooter-lane outer wall. Open at the bottom (the drain). // (drag-to-edit, then exported as JSON) rather than computed — that's why
// the numbers below don't fall into neat patterns. See PinballEditorView.js.
//
// Collision boundary: left wall + rounded top + right wall, sweeping in
// at the bottom on both sides — that's the true outlane (a ball that
// stays outboard of the OUTLANE_* guides below rides this wall straight
// past the flipper to the drain).
const BOUND = [ const BOUND = [
[112, 508], // runs past the left flipper pivot so there's no nook beside it [70, 550],
[80, 496], [22, 520],
[54, 478], [16, 446],
[32, 452],
[18, 418],
[16, 388],
[16, 106], [16, 106],
...arcPts(60, 106, 44, 180 * D, 270 * D, 8), // TL corner → (60,62) [17, 97],
[252, 62], [19, 89],
...arcPts(252, 106, 44, 270 * D, 360 * D, 8), // TR corner → (296,106) [23, 82],
[304, 512], // shooter-lane outer wall [29, 75],
[36, 69],
[43, 65],
[51, 63],
[60, 62],
[261, 61],
[270, 62],
[278, 64],
[285, 68],
[292, 74],
[298, 81],
[303, 88],
[304, 96],
[305, 105],
[305, 451],
[307, 535],
[267, 552],
];
// Outlane tubes — a second wall inboard of each side. A ball that rides
// this one (instead of drifting further out to BOUND) is fed onto the
// flipper — it's effectively the inlane guide, ending right at the pivot.
const OUTLANE_L = [
[54, 405],
[54, 445],
[57, 500],
[102, 522],
];
const OUTLANE_R = [
[266, 405],
[266, 445],
[263, 500],
[218, 522],
]; ];
const TABLE = { const TABLE = {
@ -89,58 +124,58 @@ const TABLE = {
bound: BOUND, bound: BOUND,
walls: [ walls: [
...polySegs(BOUND, { role: 'wall' }), ...polySegs(BOUND, { role: 'wall' }),
...polySegs(OUTLANE_L, { role: 'guide' }),
...polySegs(OUTLANE_R, { role: 'guide' }),
// shooter lane — a channel on the right. Its inner wall runs straight // shooter lane — a narrow, separate channel to the right of the
// up to hold the resting ball, then curls left into a hood that steers // playfield. Its inner wall runs up to hold the resting ball, then
// a launched ball down into the playfield (with step()'s scripted // curls left into a hood that steers a launched ball into the
// "hood delivery" doing the heavy lifting). // playfield ("hood delivery" in step() does the heavy lifting).
{ a: [304, 512], b: [268, 512], role: 'lane' }, // lane floor { a: [306, 505], b: [304, 139], role: 'lane' }, // outer wall
...polySegs([[268, 512], [268, 150], ...arcPts(238, 150, 30, 0, -120 * D, 6), [196, 92]], { { a: [306, 504], b: [288, 504], role: 'lane' }, // floor
role: 'lane',
}),
// right inlane guide — mirrors the left outlane, runs past the right
// flipper pivot so there's no nook beside it
...polySegs( ...polySegs(
[ [
[250, 400], [288, 506],
[244, 436], [288, 151],
[226, 466], [287, 145],
[200, 486], [285, 139],
[168, 508], [281, 134],
[276, 130],
[271, 127],
], ],
{ role: 'guide' }, { role: 'lane' },
), ),
// slingshots — wedges above each flipper's outer half (symmetric), // slingshots — tight above each flipper's outer half
// pulled up and inboard so they don't pocket against the outlane { a: [71, 422], b: [139, 457], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' },
{ a: [104, 424], b: [118, 462], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' }, { a: [71, 422], b: [96, 456], role: 'sling' },
{ a: [104, 424], b: [96, 462], role: 'sling' }, { a: [96, 456], b: [139, 457], role: 'sling' },
{ a: [96, 462], b: [118, 462], role: 'sling' }, { a: [249, 422], b: [181, 457], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' },
{ a: [176, 424], b: [190, 462], role: 'sling' }, { a: [249, 422], b: [224, 456], role: 'sling' },
{ a: [176, 424], b: [162, 462], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' }, { a: [224, 456], b: [181, 457], role: 'sling' },
{ a: [162, 462], b: [190, 462], role: 'sling' },
], ],
bumpers: [ bumpers: [
{ x: 94, y: 152, r: 15, kind: 'poke' }, { x: 114, y: 154, r: 15, kind: 'poke' },
{ x: 140, y: 110, r: 16, kind: 'poke' }, { x: 160, y: 112, r: 16, kind: 'poke' },
{ x: 186, y: 152, r: 15, kind: 'poke' }, { x: 206, y: 154, r: 15, kind: 'poke' },
{ x: 116, y: 220, r: 12, kind: 'great' }, { x: 136, y: 222, r: 12, kind: 'great' },
{ x: 164, y: 220, r: 12, kind: 'great' }, { x: 184, y: 222, r: 12, kind: 'great' },
], ],
posts: [ posts: [
{ x: 44, y: 300, r: 7 }, { x: 64, y: 303, r: 7 },
{ x: 236, y: 300, r: 7 }, { x: 256, y: 303, r: 7 },
{ x: 140, y: 296, r: 7 }, { x: 160, y: 299, r: 8 },
], ],
flippers: { flippers: {
L: { pivot: [96, 500], rest: 0.32 }, // radians, y-down; tip points down-right // Same size on both sides, mirrored pivots — centred on the whole
R: { pivot: [184, 500], rest: Math.PI - 0.32 }, // table (playfield + shooter lane), not just the playfield alone.
L: { pivot: [96, 524], rest: 0.28, len: 56 }, // radians, y-down; tip points down-right
R: { pivot: [224, 524], rest: Math.PI - 0.28, len: 56 },
}, },
laneX: 286, // ball rest x in the shooter lane laneX: 295, // ball rest x in the shooter lane
laneRestY: 500, laneRestY: 488,
drainY: 546, drainY: 546,
returnMinX: 999, // no return chute — every side feeds a flipper returnMinX: 999, // no return chute — every side feeds a flipper or an outlane
}; };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
@ -154,7 +189,7 @@ function closestOnSeg(px, py, ax, ay, bx, by) {
return [ax + t * abx, ay + t * aby, t]; return [ax + t * abx, ay + t * aby, t];
} }
export const _test = { closestOnSeg, clamp, W, H }; export const _test = { closestOnSeg, clamp, W, H, TABLE, PHYS };
export function createPinball(canvas, cbs = {}) { export function createPinball(canvas, cbs = {}) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@ -215,11 +250,12 @@ export function createPinball(canvas, cbs = {}) {
function flipperSeg(which) { function flipperSeg(which) {
const f = TABLE.flippers[which]; const f = TABLE.flippers[which];
const ang = state.ang[which]; const ang = state.ang[which];
const len = f.len ?? PHYS.flipLen;
return { return {
ax: f.pivot[0], ax: f.pivot[0],
ay: f.pivot[1], ay: f.pivot[1],
bx: f.pivot[0] + Math.cos(ang) * PHYS.flipLen, bx: f.pivot[0] + Math.cos(ang) * len,
by: f.pivot[1] + Math.sin(ang) * PHYS.flipLen, by: f.pivot[1] + Math.sin(ang) * len,
}; };
} }
@ -265,7 +301,7 @@ export function createPinball(canvas, cbs = {}) {
} }
// Plunger — the ball tracks the lane centre until it's launched. // Plunger — the ball tracks the lane centre until it's launched.
if (b.inLane && b.x > 286) { if (b.inLane && b.x > 291) {
b.vx *= 0.6; b.vx *= 0.6;
b.x += (TABLE.laneX - b.x) * 0.35; b.x += (TABLE.laneX - b.x) * 0.35;
} }
@ -273,7 +309,7 @@ export function createPinball(canvas, cbs = {}) {
// back down the lane, forget the launch so the plunger can re-arm. // back down the lane, forget the launch so the plunger can re-arm.
if (b.launching && b.vy > 0 && b.y > 320) b.launching = false; if (b.launching && b.vy > 0 && b.y > 320) b.launching = false;
// Re-arm the plunger for any near-stationary ball back in the lane. // Re-arm the plunger for any near-stationary ball back in the lane.
if (!b.inLane && !b.launching && b.x > 290 && b.y > 442 && Math.hypot(b.vx, b.vy) < 140) { if (!b.inLane && !b.launching && b.x > 289 && b.y > 460 && Math.hypot(b.vx, b.vy) < 140) {
b.inLane = true; b.inLane = true;
b.x = TABLE.laneX; b.x = TABLE.laneX;
b.vx = 0; b.vx = 0;
@ -282,9 +318,9 @@ export function createPinball(canvas, cbs = {}) {
// share a cramped space where wall bounces reliably ping a launched // 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 // 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 // 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 // off (bumpers/posts/slings stay solid) — until it's clearly among the
// it cuts across. // bumpers. The charge sets how hard it cuts across.
if (b.launching && b.y < 104) { if (b.launching && b.y < 145) {
b.vx = -(300 + 250 * state.launchC); b.vx = -(300 + 250 * state.launchC);
b.vy = 40; b.vy = 40;
b.launching = false; b.launching = false;
@ -292,7 +328,7 @@ export function createPinball(canvas, cbs = {}) {
// End the scripted glide once the kick has fired (b.launching cleared) // 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 // and the ball is well left of the shooter lane — or, as a safety, has
// dropped past the bumpers. // dropped past the bumpers.
if (b.hooding && !b.launching && (b.x < 212 || b.y > 236)) b.hooding = false; if (b.hooding && !b.launching && (b.x < 220 || b.y > 260)) b.hooding = false;
b.vy += PHYS.gravity * dt; b.vy += PHYS.gravity * dt;
b.x += b.vx * dt; b.x += b.vx * dt;
@ -671,14 +707,14 @@ export function createPinball(canvas, cbs = {}) {
// slingshot wedges — solid rubber, lighter top face // slingshot wedges — solid rubber, lighter top face
for (const pts of [ for (const pts of [
[ [
[104, 424], [71, 422],
[118, 462], [139, 457],
[96, 462], [96, 456],
], ],
[ [
[176, 424], [249, 422],
[162, 462], [181, 457],
[190, 462], [224, 456],
], ],
]) { ]) {
tri(pts); tri(pts);
@ -722,29 +758,31 @@ export function createPinball(canvas, cbs = {}) {
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); 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 // plunger — a metal rod that pulls DOWN, away from the ball, as it
// charges; small ticks along the shaft read as a spring without a
// busy zigzag coil. Sits well below the resting ball, so it never
// visually overlaps it.
{ {
const lx = TABLE.laneX; const lx = TABLE.laneX;
const restY = 516; const restY = 503; // touching the ball, uncharged
const botY = 542; const pullY = 513; // fully pulled back
const topY = restY + state.charge * (botY - restY - 6); const anchorY = 522; // fixed base
const knobY = restY + state.charge * (pullY - restY);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(lx, botY); ctx.moveTo(lx, anchorY);
const coils = 4; ctx.lineTo(lx, knobY);
for (let k = 0; k <= coils; k++) { stroke2(C.metal, 3, 6);
const y = botY - (k / coils) * (botY - topY); ctx.strokeStyle = 'rgba(28,34,48,0.45)';
ctx.lineTo(k % 2 ? lx + 9 : lx - 9, y); ctx.lineWidth = 1.5;
for (let y = knobY + 4; y < anchorY - 1; y += 4) {
ctx.beginPath();
ctx.moveTo(lx - 4, y);
ctx.lineTo(lx + 4, y);
ctx.stroke();
} }
ctx.lineTo(lx, topY);
ctx.strokeStyle = C.edge;
ctx.lineWidth = 4;
ctx.stroke();
ctx.strokeStyle = C.metal;
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath(); ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(lx - 10, topY - 8, 20, 9, 3); if (ctx.roundRect) ctx.roundRect(lx - 9, knobY - 5, 18, 10, 4);
else ctx.rect(lx - 10, topY - 8, 20, 9); else ctx.rect(lx - 9, knobY - 5, 18, 10);
ctx.fillStyle = C.metal; ctx.fillStyle = C.metal;
ctx.fill(); ctx.fill();
ctx.lineWidth = 2; ctx.lineWidth = 2;
@ -856,7 +894,7 @@ export function createPinball(canvas, cbs = {}) {
// Straight up the lane fast enough to reach the hood; step() then // Straight up the lane fast enough to reach the hood; step() then
// flies it into the playfield (see "Hood delivery"). Every pull // flies it into the playfield (see "Hood delivery"). Every pull
// launches; a hard pull carries it to the far bumpers. // launches; a hard pull carries it to the far bumpers.
b.vy = -(940 + 180 * c); b.vy = -(1040 + 200 * c);
b.vx = 0; b.vx = 0;
b.inLane = false; // commit — don't let a graze in the lane cancel the launch b.inLane = false; // commit — don't let a graze in the lane cancel the launch
b.launching = true; b.launching = true;

View File

@ -15,6 +15,7 @@ import { BreedingView } from './views/BreedingView.js';
import { ShinyView } from './views/ShinyView.js'; import { ShinyView } from './views/ShinyView.js';
import { WhosThatView } from './views/WhosThatView.js'; import { WhosThatView } from './views/WhosThatView.js';
import { PinballView } from './views/PinballView.js'; import { PinballView } from './views/PinballView.js';
import { PinballEditorView } from './views/PinballEditorView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js'; import { prefersReducedMotion } from './store/settings.js';
@ -43,6 +44,7 @@ const routes = [
{ pattern: /^#\/settings$/, view: () => SettingsView() }, { pattern: /^#\/settings$/, view: () => SettingsView() },
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps { pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
{ pattern: /^#\/pinball$/, view: () => PinballView() }, // hidden — linked from #/whos-that { pattern: /^#\/pinball$/, view: () => PinballView() }, // hidden — linked from #/whos-that
{ pattern: /^#\/pinball-editor$/, view: () => PinballEditorView() }, // TEMPORARY dev tool — see PinballEditorView.js
]; ];
function resolve(hash) { function resolve(hash) {

View File

@ -4055,3 +4055,52 @@
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text-dim); color: var(--text-dim);
} }
/* ---- TEMPORARY dev tool: pinball board editor (#/pinball-editor) -- */
.pin-ed {
max-width: 56rem;
margin: 0 auto;
}
.pin-ed__hint {
color: var(--text-dim);
font-size: 0.85rem;
max-width: 42rem;
}
.pin-ed__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 10px 0;
}
.pin-ed__status {
font-size: 0.82rem;
color: var(--text-dim);
}
.pin-ed__selcount {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent);
}
.pin-ed__board {
overflow: auto;
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
max-width: 100%;
}
.pin-ed__json-h {
margin: 16px 0 6px;
font-size: 1rem;
}
.pin-ed__json {
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
padding: 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
resize: vertical;
}

View File

@ -0,0 +1,686 @@
import { el, onTeardown } from '../lib/dom.js';
/**
* TEMPORARY dev tool a drag-to-edit layout board for the pinball table
* (#/pinball). Lets a human push points/circles around visually instead of
* hand-computing coordinates, then exports the result as JSON so the real
* geometry in src/lib/pinball.js can be rebuilt from it. Not linked from
* anywhere but the hidden Pinball view's nav row; delete this file (and its
* route + nav link) once the table layout is settled.
*/
const W = 320;
const H = 560;
const SCALE = 1.6; // canvas is drawn bigger than the real game for precision
const STORE_KEY = 'pdx.pinballEditorLayout';
const CX = 140;
const mx = (x) => 2 * CX - x;
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).toFixed(1), +(cy + Math.sin(a) * r).toFixed(1)]);
}
return p;
}
const D = Math.PI / 180;
// The current shipped geometry (mirrors src/lib/pinball.js) — the editor's
// starting point every time it's reset.
function defaultLayout() {
return {
bound: [
[40, 520],
[20, 486],
[16, 446],
[16, 106],
...arcPts(60, 106, 44, 180 * D, 270 * D, 8),
[220, 62],
...arcPts(mx(60), 106, 44, 270 * D, 360 * D, 8),
[mx(16), 446],
[mx(20), 486],
[mx(40), 520],
],
outlaneL: [
[40, 404],
[38, 438],
[42, 472],
[62, 505],
],
outlaneR: [
[mx(40), 404],
[mx(38), 438],
[mx(42), 472],
[mx(62), 505],
],
laneOuter: [
[306, 495],
[306, 90],
],
laneFloor: [
[306, 495],
[284, 495],
],
laneInner: [[284, 495], [284, 150], ...arcPts(258, 150, 26, 0, -0.62 * Math.PI, 8), [210, 110]],
slingL: [
[104, 420],
[118, 458],
[90, 458],
], // apex, kick-face base, other base
slingR: [
[mx(104), 420],
[mx(118), 458],
[mx(90), 458],
],
bumpers: [
{ x: 94, y: 152, r: 15 },
{ x: 140, y: 110, r: 16 },
{ x: 186, y: 152, r: 15 },
{ x: 116, y: 220, r: 12 },
{ x: 164, y: 220, r: 12 },
],
posts: [
{ x: 44, y: 300, r: 7 },
{ x: 236, y: 300, r: 7 },
{ x: 140, y: 296, r: 7 },
],
flipperL: { pivot: [84, 500], tip: [84 + 50 * Math.cos(0.3), 500 + 50 * Math.sin(0.3)] },
flipperR: {
pivot: [196, 500],
tip: [196 + 50 * Math.cos(Math.PI - 0.3), 500 + 50 * Math.sin(Math.PI - 0.3)],
},
laneX: 295,
laneRestY: 488,
drainY: 546,
};
}
function loadLayout() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) return JSON.parse(raw);
} catch {
/* corrupt or unavailable — fall through to defaults */
}
return defaultLayout();
}
const GROUP_COLOR = {
bound: '#eef0f2',
outlaneL: '#54e0a6',
outlaneR: '#54e0a6',
laneOuter: '#7fb2ff',
laneFloor: '#7fb2ff',
laneInner: '#7fb2ff',
slingL: '#ff6a5c',
slingR: '#ff6a5c',
bumpers: '#ffd23f',
posts: '#c084fc',
flipperL: '#f7c948',
flipperR: '#f7c948',
};
const POLYLINES = [
'bound',
'outlaneL',
'outlaneR',
'laneOuter',
'laneFloor',
'laneInner',
'slingL',
'slingR',
];
const MIRROR_PAIRS = [
['outlaneL', 'outlaneR'],
['slingL', 'slingR'],
];
export async function PinballEditorView() {
const view = el('section', { class: 'view pin-ed' });
let layout = loadLayout();
let mirrorLock = true;
let drag = null; // { kind:'point'|'circle'|'move'|'multiMove'|'marquee'|'flipperPivot'|'flipperTip', ... }
let selected = new Set(); // handle keys, "group:index" — points + circles only
const keyOf = (hit) => `${hit.group}:${hit.index}`;
function handlePos(key) {
const [group, idxStr] = key.split(':');
const i = Number(idxStr);
return POLYLINES.includes(group) ? layout[group][i] : [layout[group][i].x, layout[group][i].y];
}
function setHandlePos(key, x, y) {
const [group, idxStr] = key.split(':');
const i = Number(idxStr);
if (POLYLINES.includes(group)) layout[group][i] = [x, y];
else {
layout[group][i].x = x;
layout[group][i].y = y;
}
}
const canvas = el('canvas', {
width: Math.round(W * SCALE),
height: Math.round(H * SCALE),
style: `width:${W * SCALE}px;height:${H * SCALE}px;touch-action:none;background:#0d0e12;border-radius:8px;cursor:crosshair;`,
});
const ctx = canvas.getContext('2d');
const jsonBox = el('textarea', {
class: 'pin-ed__json',
readOnly: true,
rows: 14,
spellcheck: false,
});
const status = el('span', { class: 'pin-ed__status' }, '');
const selCount = el('span', { class: 'pin-ed__selcount' }, '');
function updateSelStatus() {
selCount.textContent = selected.size
? `${selected.size} selected — drag to move together, Delete to remove`
: '';
}
function save() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(layout));
} catch {
/* storage unavailable — the export box still has the data */
}
jsonBox.value = JSON.stringify(layout, null, 2);
}
function toCanvas([x, y]) {
return [x * SCALE, y * SCALE];
}
function toLogical(cx, cy) {
return [cx / SCALE, cy / SCALE];
}
function drawPolyline(pts, color, close = false) {
ctx.beginPath();
pts.forEach(([x, y], i) => {
const [cx, cy] = toCanvas([x, y]);
i === 0 ? ctx.moveTo(cx, cy) : ctx.lineTo(cx, cy);
});
if (close) ctx.closePath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
function drawHandle([x, y], color, r = 5, isSelected = false) {
const [cx, cy] = toCanvas([x, y]);
if (isSelected) {
ctx.beginPath();
ctx.arc(cx, cy, r + 4, 0, Math.PI * 2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.setLineDash([3, 2]);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = isSelected ? '#fff' : '#000';
ctx.stroke();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// faint floor + grid for scale reference
ctx.fillStyle = '#d8b878';
ctx.fillRect(...toCanvas([12, 14]), ...[296 * SCALE, (H - 20) * SCALE]);
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
ctx.lineWidth = 1;
for (let gx = 0; gx <= W; gx += 20)
drawPolyline(
[
[gx, 0],
[gx, H],
],
'rgba(0,0,0,0.08)',
);
for (let gy = 0; gy <= H; gy += 20)
drawPolyline(
[
[0, gy],
[W, gy],
],
'rgba(0,0,0,0.08)',
);
for (const g of POLYLINES) {
drawPolyline(layout[g], GROUP_COLOR[g]);
layout[g].forEach((p, i) => drawHandle(p, GROUP_COLOR[g], 5, selected.has(`${g}:${i}`)));
}
for (const g of ['bumpers', 'posts']) {
layout[g].forEach((c, i) => {
const [cx, cy] = toCanvas([c.x, c.y]);
ctx.beginPath();
ctx.arc(cx, cy, c.r * SCALE, 0, Math.PI * 2);
ctx.strokeStyle = GROUP_COLOR[g];
ctx.lineWidth = 2;
ctx.stroke();
drawHandle([c.x, c.y], GROUP_COLOR[g], 4, selected.has(`${g}:${i}`));
});
}
for (const g of ['flipperL', 'flipperR']) {
const f = layout[g];
drawPolyline([f.pivot, f.tip], GROUP_COLOR[g]);
drawHandle(f.pivot, GROUP_COLOR[g], 6);
drawHandle(f.tip, '#ffe08a', 5);
}
// centre line, for eyeballing symmetry
drawPolyline(
[
[CX, 0],
[CX, H],
],
'rgba(255,255,255,0.25)',
);
if (drag?.kind === 'marquee') {
const [x0, y0] = toCanvas(drag.start);
const [x1, y1] = toCanvas(drag.cur);
ctx.fillStyle = 'rgba(127,178,255,0.15)';
ctx.fillRect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x1 - x0), Math.abs(y1 - y0));
ctx.strokeStyle = 'rgba(127,178,255,0.8)';
ctx.lineWidth = 1;
ctx.strokeRect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x1 - x0), Math.abs(y1 - y0));
}
}
function mirrorPartnerGroup(group) {
for (const [l, r] of MIRROR_PAIRS) {
if (group === l) return r;
if (group === r) return l;
}
return null;
}
function hitTest(lx, ly) {
const HIT = 10 / SCALE;
for (const g of POLYLINES) {
for (let i = 0; i < layout[g].length; i++) {
const [px, py] = layout[g][i];
if (Math.hypot(px - lx, py - ly) < HIT) return { kind: 'point', group: g, index: i };
}
}
for (const g of ['bumpers', 'posts']) {
for (let i = 0; i < layout[g].length; i++) {
const c = layout[g][i];
if (Math.hypot(c.x - lx, c.y - ly) < HIT) return { kind: 'circle', group: g, index: i };
}
}
for (const g of ['flipperL', 'flipperR']) {
const f = layout[g];
if (Math.hypot(f.pivot[0] - lx, f.pivot[1] - ly) < HIT)
return { kind: 'flipperPivot', group: g };
if (Math.hypot(f.tip[0] - lx, f.tip[1] - ly) < HIT) return { kind: 'flipperTip', group: g };
}
return null;
}
function pointerLogical(e) {
const rect = canvas.getBoundingClientRect();
return toLogical(
((e.clientX - rect.left) / rect.width) * canvas.width,
((e.clientY - rect.top) / rect.height) * canvas.height,
);
}
/** Closest point on segment a→b to p (logical units). */
function closestOnSeg(px, py, [ax, ay], [bx, by]) {
const abx = bx - ax;
const aby = by - ay;
const t = Math.max(
0,
Math.min(1, ((px - ax) * abx + (py - ay) * aby) / (abx * abx + aby * aby || 1)),
);
return [ax + t * abx, ay + t * aby];
}
/** Nearest point ON A LINE BODY (not a handle) across every polyline. */
function hitTestLine(lx, ly) {
const HIT = 7 / SCALE;
let best = null;
for (const g of POLYLINES) {
const pts = layout[g];
for (let i = 0; i < pts.length - 1; i++) {
const [cx, cy] = closestOnSeg(lx, ly, pts[i], pts[i + 1]);
const d = Math.hypot(cx - lx, cy - ly);
if (d < HIT && (!best || d < best.d)) best = { group: g, segIndex: i, d };
}
}
return best;
}
canvas.addEventListener('pointerdown', (e) => {
const [lx, ly] = pointerLogical(e);
const handle = hitTest(lx, ly);
if (handle && (handle.kind === 'point' || handle.kind === 'circle')) {
const key = keyOf(handle);
if (e.shiftKey) {
selected.has(key) ? selected.delete(key) : selected.add(key);
drag = null; // shift-click only adjusts the selection, no drag
render();
updateSelStatus();
return;
}
if (selected.has(key) && selected.size > 1) {
// dragging a handle that's part of a multi-selection moves all of it
drag = {
kind: 'multiMove',
start: [lx, ly],
keys: [...selected],
snapshot: [...selected].map((k) => handlePos(k)),
};
} else {
selected = new Set([key]);
drag = handle;
}
} else if (handle) {
drag = handle; // flipper pivot/tip — not selectable, just draggable
} else {
const line = hitTestLine(lx, ly);
if (line) {
// click-drag on a line's body (not a handle) moves the WHOLE shape
selected = new Set();
drag = {
kind: 'move',
group: line.group,
start: [lx, ly],
snapshot: layout[line.group].map((p) => [...p]),
};
} else {
// empty space — rubber-band select
if (!e.shiftKey) selected = new Set();
drag = { kind: 'marquee', start: [lx, ly], cur: [lx, ly], additive: e.shiftKey };
}
}
if (drag) canvas.setPointerCapture(e.pointerId);
render();
updateSelStatus();
});
canvas.addEventListener('pointermove', (e) => {
if (!drag) return;
let [lx, ly] = pointerLogical(e);
lx = Math.round(Math.max(0, Math.min(W, lx)));
ly = Math.round(Math.max(0, Math.min(H, ly)));
if (drag.kind === 'point') {
layout[drag.group][drag.index] = [lx, ly];
if (mirrorLock) {
const partner = mirrorPartnerGroup(drag.group);
if (partner && layout[partner][drag.index]) layout[partner][drag.index] = [mx(lx), ly];
}
} else if (drag.kind === 'move') {
const dx = lx - drag.start[0];
const dy = ly - drag.start[1];
layout[drag.group] = drag.snapshot.map(([x, y]) => [x + dx, y + dy]);
if (mirrorLock) {
const partner = mirrorPartnerGroup(drag.group);
if (partner) layout[partner] = drag.snapshot.map(([x, y]) => [mx(x + dx), y + dy]);
}
} else if (drag.kind === 'circle') {
layout[drag.group][drag.index].x = lx;
layout[drag.group][drag.index].y = ly;
} else if (drag.kind === 'flipperPivot') {
const f = layout[drag.group];
const dx = lx - f.pivot[0];
const dy = ly - f.pivot[1];
f.pivot = [lx, ly];
f.tip = [f.tip[0] + dx, f.tip[1] + dy];
if (mirrorLock) {
const other = drag.group === 'flipperL' ? layout.flipperR : layout.flipperL;
other.pivot = [mx(lx), ly];
}
} else if (drag.kind === 'flipperTip') {
layout[drag.group].tip = [lx, ly];
} else if (drag.kind === 'multiMove') {
const dx = lx - drag.start[0];
const dy = ly - drag.start[1];
drag.keys.forEach((k, i) => {
const [ox, oy] = drag.snapshot[i];
setHandlePos(k, ox + dx, oy + dy);
});
} else if (drag.kind === 'marquee') {
drag.cur = [lx, ly];
render();
return; // nothing to save — no data changed yet
}
render();
save();
});
const endDrag = () => {
if (drag?.kind === 'marquee') {
const [x0, y0] = drag.start;
const [x1, y1] = drag.cur;
const lo = [Math.min(x0, x1), Math.min(y0, y1)];
const hi = [Math.max(x0, x1), Math.max(y0, y1)];
const inBox = ([x, y]) => x >= lo[0] && x <= hi[0] && y >= lo[1] && y <= hi[1];
const next = drag.additive ? selected : new Set();
for (const g of POLYLINES) layout[g].forEach((p, i) => inBox(p) && next.add(`${g}:${i}`));
for (const g of ['bumpers', 'posts'])
layout[g].forEach((c, i) => inBox([c.x, c.y]) && next.add(`${g}:${i}`));
selected = next;
updateSelStatus();
}
drag = null;
render();
};
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
// double-click a handle to delete it; double-click a bare line to add a
// point there (mirrored to the paired group, while lock is on)
canvas.addEventListener('dblclick', (e) => {
e.preventDefault();
const [lx, ly] = pointerLogical(e);
const handle = hitTest(lx, ly);
if (handle) {
if (handle.kind === 'point') {
if (layout[handle.group].length <= 2) {
status.textContent = 'A line needs at least 2 points — add one before deleting.';
return;
}
layout[handle.group].splice(handle.index, 1);
const partner = mirrorLock && mirrorPartnerGroup(handle.group);
if (partner && layout[partner][handle.index]) layout[partner].splice(handle.index, 1);
status.textContent = `Deleted a point from ${handle.group}.`;
} else if (handle.kind === 'circle') {
layout[handle.group].splice(handle.index, 1);
status.textContent = `Deleted a ${handle.group.slice(0, -1)}.`;
} else {
status.textContent = "Flippers can't be deleted.";
return;
}
selected = new Set(); // indices shifted — any prior selection is now stale
render();
save();
updateSelStatus();
return;
}
const line = hitTestLine(lx, ly);
if (line) {
const pt = [Math.round(lx), Math.round(ly)];
layout[line.group].splice(line.segIndex + 1, 0, pt);
const partner = mirrorLock && mirrorPartnerGroup(line.group);
if (partner) layout[partner].splice(line.segIndex + 1, 0, [mx(pt[0]), pt[1]]);
status.textContent = `Added a point to ${line.group}.`;
selected = new Set();
render();
save();
updateSelStatus();
}
});
// scroll to resize a bumper/post
canvas.addEventListener(
'wheel',
(e) => {
const [lx, ly] = pointerLogical(e);
const hit = hitTest(lx, ly);
if (hit?.kind !== 'circle') return;
e.preventDefault();
const c = layout[hit.group][hit.index];
c.r = Math.max(4, Math.min(40, c.r - Math.sign(e.deltaY)));
render();
save();
},
{ passive: false },
);
function deleteSelected() {
if (!selected.size) return;
const byGroup = new Map();
for (const k of selected) {
const [g, iStr] = k.split(':');
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g).push(Number(iStr));
}
let blocked = false;
for (const [g, idxs] of byGroup) {
idxs.sort((a, b) => b - a); // delete highest index first so earlier ones stay valid
if (POLYLINES.includes(g) && layout[g].length - idxs.length < 2) {
blocked = true;
continue; // keep at least 2 points on a line
}
for (const i of idxs) layout[g].splice(i, 1);
}
selected = new Set();
status.textContent = blocked
? 'Deleted what I could — a line needs at least 2 points. (This does not auto-mirror; use "Mirror L → R now" after.)'
: 'Deleted the selection. (This does not auto-mirror; use "Mirror L → R now" after if needed.)';
render();
save();
updateSelStatus();
}
const onKeyDown = (e) => {
if ((e.key === 'Delete' || e.key === 'Backspace') && selected.size) {
e.preventDefault();
deleteSelected();
} else if (e.key === 'Escape' && selected.size) {
selected = new Set();
render();
updateSelStatus();
}
};
window.addEventListener('keydown', onKeyDown);
const addCircle = (group, defaults) => {
layout[group].push({ ...defaults });
render();
save();
status.textContent = `Added a ${group.slice(0, -1)} — drag it into place.`;
};
const addBumperBtn = el('button', {
class: 'button button--ghost',
type: 'button',
onclick: () => addCircle('bumpers', { x: CX, y: 260, r: 14 }),
});
addBumperBtn.textContent = '+ Bumper';
const addPostBtn = el('button', {
class: 'button button--ghost',
type: 'button',
onclick: () => addCircle('posts', { x: CX, y: 260, r: 7 }),
});
addPostBtn.textContent = '+ Post';
const mirrorBtn = el('button', {
class: 'button button--ghost',
type: 'button',
onclick: () => {
mirrorLock = !mirrorLock;
mirrorBtn.textContent = mirrorLock ? 'Mirror lock: on' : 'Mirror lock: off';
},
});
mirrorBtn.textContent = 'Mirror lock: on';
const mirrorNowBtn = el('button', {
class: 'button button--ghost',
type: 'button',
onclick: () => {
for (const [l, r] of MIRROR_PAIRS) {
layout[r] = layout[l].map(([x, y]) => [mx(x), y]);
}
layout.flipperR.pivot = [mx(layout.flipperL.pivot[0]), layout.flipperL.pivot[1]];
layout.flipperR.tip = [mx(layout.flipperL.tip[0]), layout.flipperL.tip[1]];
render();
save();
status.textContent = 'Mirrored L → R.';
},
});
mirrorNowBtn.textContent = 'Mirror L → R now';
const resetBtn = el('button', {
class: 'button button--ghost',
type: 'button',
onclick: () => {
if (!confirm('Reset the board to the current shipped layout? This discards your edits.'))
return;
layout = defaultLayout();
selected = new Set();
render();
save();
updateSelStatus();
status.textContent = 'Reset to defaults.';
},
});
resetBtn.textContent = 'Reset to defaults';
const copyBtn = el('button', {
class: 'button',
type: 'button',
onclick: async () => {
try {
await navigator.clipboard.writeText(jsonBox.value);
status.textContent = 'Copied JSON to clipboard.';
} catch {
jsonBox.select();
status.textContent = 'Clipboard blocked — text is selected, copy manually (Ctrl/Cmd+C).';
}
},
});
copyBtn.textContent = 'Copy JSON';
view.append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/pinball' }, ' Pinball')),
el('header', { class: 'view__header' }, el('h1', {}, 'Pinball board editor')),
el(
'p',
{ class: 'pin-ed__hint' },
'Drag a handle to move just that point. Drag a bare line to move the whole shape. ',
'Double-click a handle to delete it; double-click a bare line to add a point there. ',
'Drag on empty space to rubber-band select a group of handles (shift-drag or shift-click adds to the selection); ',
'drag any selected handle to move them all together, or press Delete to remove them (Esc clears the selection). ',
'Scroll over a bumper or post to resize it. ',
'Left-side groups (mint outlane, red sling) mirror to the right automatically while "Mirror lock" is on — ',
'note that multi-select move/delete does NOT auto-mirror; use "Mirror L → R now" after. ',
'This is a layout tool only — it doesnt affect the live game until someone copies the JSON back into the code.',
),
el(
'div',
{ class: 'pin-ed__toolbar' },
mirrorBtn,
mirrorNowBtn,
addBumperBtn,
addPostBtn,
resetBtn,
copyBtn,
status,
selCount,
),
el('div', { class: 'pin-ed__board' }, canvas),
el('h2', { class: 'pin-ed__json-h' }, 'Live JSON (autosaved to this browser)'),
jsonBox,
);
render();
save();
onTeardown(view, () => window.removeEventListener('keydown', onKeyDown));
return view;
}

View File

@ -279,6 +279,9 @@ export async function PinballView() {
el('a', { class: 'link', href: '#/' }, ' Dex'), el('a', { class: 'link', href: '#/' }, ' Dex'),
el('span', {}, ' · '), el('span', {}, ' · '),
el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"), el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"),
el('span', {}, ' · '),
// TEMPORARY dev link — remove with PinballEditorView.js once the layout is settled
el('a', { class: 'link', href: '#/pinball-editor' }, 'Edit board'),
), ),
el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')), el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')),
el( el(

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { _test } from '../src/lib/pinball.js'; import { _test } from '../src/lib/pinball.js';
const { closestOnSeg, clamp } = _test; const { closestOnSeg, clamp, TABLE, PHYS } = _test;
describe('clamp', () => { describe('clamp', () => {
it('bounds a value', () => { it('bounds a value', () => {
@ -27,3 +27,98 @@ describe('closestOnSeg', () => {
expect(closestOnSeg(3, 4, 1, 1, 1, 1).slice(0, 2)).toEqual([1, 1]); expect(closestOnSeg(3, 4, 1, 1, 1, 1).slice(0, 2)).toEqual([1, 1]);
}); });
}); });
// "Gravity only goes down" (pinballmakers.com/wiki/Design#Pinball_Basics):
// any two table features that don't touch by design must stay at least a
// ball's width apart everywhere, or a ball can wedge between them with no
// way through. This walks the real TABLE geometry (including hand-edited
// layouts exported from the #/pinball-editor dev tool) and fails loudly if
// it finds that class of bug.
describe('table geometry has no gravity traps', () => {
const BALL_D = PHYS.ballR * 2;
// The dangerous range is a gap the ball can partially enter but not pass
// through — roughly 3px (below that, the ball can't get its centre near
// the gap at all; it just bounces off whichever wall it reaches first)
// up to one ball-diameter. Gaps at/above BALL_D are honest passages —
// including a deliberately narrow one — not traps.
const DANGER_MIN = 3;
const EPS = 0.05;
function samplePts(a, b, step = 2) {
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
const n = Math.max(1, Math.round(len / step));
return Array.from({ length: n + 1 }, (_, i) => {
const t = i / n;
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
});
}
const eqPt = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]) < EPS;
// Union-find: two wall segments are the same physical feature (allowed
// to be close, even touching) if a chain of shared endpoints connects
// them — e.g. the tessellated boundary curve, or a slingshot's 3 edges.
const walls = TABLE.walls;
const parent = walls.map((_, i) => i);
const find = (x) => {
while (parent[x] !== x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
};
for (let i = 0; i < walls.length; i++) {
for (let j = i + 1; j < walls.length; j++) {
const [w1, w2] = [walls[i], walls[j]];
if (eqPt(w1.a, w2.a) || eqPt(w1.a, w2.b) || eqPt(w1.b, w2.a) || eqPt(w1.b, w2.b)) {
const [ra, rb] = [find(i), find(j)];
if (ra !== rb) parent[ra] = rb;
}
}
}
const sampled = walls.map((w) => samplePts(w.a, w.b));
it('keeps unrelated wall segments at least a ball-width apart', () => {
const offenders = [];
for (let i = 0; i < walls.length; i++) {
for (let j = i + 1; j < walls.length; j++) {
if (find(i) === find(j)) continue; // same feature — expected to touch
let minD = Infinity;
for (const p of sampled[i])
for (const q of sampled[j]) minD = Math.min(minD, Math.hypot(p[0] - q[0], p[1] - q[1]));
if (minD >= DANGER_MIN && minD < BALL_D)
offenders.push(
`#${i} (${walls[i].role}) <-> #${j} (${walls[j].role}): ${minD.toFixed(1)}px`,
);
}
}
expect(offenders).toEqual([]);
});
it('keeps bumpers/posts clear of every wall', () => {
const circles = [...TABLE.bumpers, ...TABLE.posts];
const offenders = [];
for (const c of circles) {
for (const w of walls) {
const [cx, cy] = closestOnSeg(c.x, c.y, w.a[0], w.a[1], w.b[0], w.b[1]);
const gap = Math.hypot(c.x - cx, c.y - cy) - c.r;
if (gap >= DANGER_MIN && gap < BALL_D)
offenders.push(`(${c.x},${c.y}) r${c.r} <-> ${w.role}: ${gap.toFixed(1)}px`);
}
}
expect(offenders).toEqual([]);
});
it('keeps bumpers/posts clear of each other', () => {
const circles = [...TABLE.bumpers, ...TABLE.posts];
const offenders = [];
for (let i = 0; i < circles.length; i++) {
for (let j = i + 1; j < circles.length; j++) {
const [a, b] = [circles[i], circles[j]];
const gap = Math.hypot(a.x - b.x, a.y - b.y) - a.r - b.r;
if (gap >= DANGER_MIN && gap < BALL_D)
offenders.push(`(${a.x},${a.y}) <-> (${b.x},${b.y}): ${gap.toFixed(1)}px`);
}
}
expect(offenders).toEqual([]);
});
});