From 6b090b9a0a98c7fce647300d2ed5d8da07c01c5d Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 23:42:09 -0400 Subject: [PATCH] Pinball: hand-tuned table via a new drag-to-edit dev tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/lib/pinball.js | 224 ++++++----- src/router.js | 2 + src/styles/layout.css | 49 +++ src/views/PinballEditorView.js | 686 +++++++++++++++++++++++++++++++++ src/views/PinballView.js | 3 + test/pinball.test.js | 97 ++++- 6 files changed, 967 insertions(+), 94 deletions(-) create mode 100644 src/views/PinballEditorView.js diff --git a/src/lib/pinball.js b/src/lib/pinball.js index b885294..00bef6f 100644 --- a/src/lib/pinball.js +++ b/src/lib/pinball.js @@ -14,23 +14,23 @@ const W = 320; const H = 560; const PHYS = { - gravity: 1050, // units/s² - drag: 0.9998, // per sub-step (×substeps×fps) — keep close to 1 or the ball dies fast - maxSpeed: 1120, // 5 substeps keep even this from tunnelling + gravity: 1220, // units/s² — heavier than a floaty ball, eased back a touch from 1320 + drag: 0.9997, // per sub-step (×substeps×fps) — keep close to 1 or the ball dies fast + maxSpeed: 1100, // 5 substeps keep even this from tunnelling substeps: 5, ballR: 7, wallE: 0.34, // wall restitution flipE: 0.28, flipThick: 6, - flipLen: 56, + flipLen: 50, flipSwing: 1.02, // radians flipSpeed: 34, // ease rate toward target angle bumperE: 0.55, bumperKick: 116, postE: 0.75, postKick: 70, - slingE: 1.1, - slingKick: 90, + slingE: 1.3, + slingKick: 230, // a real kick away from the rubber, not a nudge 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 // 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 +const CX = 160; // centre of the whole table, tunnel included (canvas centre) // 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 @@ -64,24 +64,59 @@ const CX = 140; // centre of the usable playfield 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) + ...arcPts(262, 508, 44, 0, 90 * D, 8), // BR → (262,552) + ...arcPts(58, 508, 44, 90 * D, 180 * D, 8), // BL → (14,508) ]; -// Collision boundary: the outline's left + top + right, then straight -// down the shooter-lane outer wall. Open at the bottom (the drain). +// This table's layout was hand-tuned in the #/pinball-editor dev tool +// (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 = [ - [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], + [70, 550], + [22, 520], + [16, 446], [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 + [17, 97], + [19, 89], + [23, 82], + [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 = { @@ -89,58 +124,58 @@ const TABLE = { bound: BOUND, walls: [ ...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 - // 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 inlane guide — mirrors the left outlane, runs past the right - // flipper pivot so there's no nook beside it + // shooter lane — a narrow, separate channel to the right of the + // playfield. Its inner wall runs up to hold the resting ball, then + // curls left into a hood that steers a launched ball into the + // playfield ("hood delivery" in step() does the heavy lifting). + { a: [306, 505], b: [304, 139], role: 'lane' }, // outer wall + { a: [306, 504], b: [288, 504], role: 'lane' }, // floor ...polySegs( [ - [250, 400], - [244, 436], - [226, 466], - [200, 486], - [168, 508], + [288, 506], + [288, 151], + [287, 145], + [285, 139], + [281, 134], + [276, 130], + [271, 127], ], - { role: 'guide' }, + { role: 'lane' }, ), - // 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' }, + // slingshots — tight above each flipper's outer half + { a: [71, 422], b: [139, 457], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' }, + { a: [71, 422], b: [96, 456], role: 'sling' }, + { a: [96, 456], b: [139, 457], role: 'sling' }, + { a: [249, 422], b: [181, 457], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' }, + { a: [249, 422], b: [224, 456], role: 'sling' }, + { a: [224, 456], b: [181, 457], role: 'sling' }, ], bumpers: [ - { 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' }, + { x: 114, y: 154, r: 15, kind: 'poke' }, + { x: 160, y: 112, r: 16, kind: 'poke' }, + { x: 206, y: 154, r: 15, kind: 'poke' }, + { x: 136, y: 222, r: 12, kind: 'great' }, + { x: 184, y: 222, r: 12, kind: 'great' }, ], posts: [ - { x: 44, y: 300, r: 7 }, - { x: 236, y: 300, r: 7 }, - { x: 140, y: 296, r: 7 }, + { x: 64, y: 303, r: 7 }, + { x: 256, y: 303, r: 7 }, + { x: 160, y: 299, r: 8 }, ], flippers: { - L: { pivot: [96, 500], rest: 0.32 }, // radians, y-down; tip points down-right - R: { pivot: [184, 500], rest: Math.PI - 0.32 }, + // Same size on both sides, mirrored pivots — centred on the whole + // 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 - laneRestY: 500, + laneX: 295, // ball rest x in the shooter lane + laneRestY: 488, 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)); @@ -154,7 +189,7 @@ function closestOnSeg(px, py, ax, ay, bx, by) { 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 = {}) { const ctx = canvas.getContext('2d'); @@ -215,11 +250,12 @@ export function createPinball(canvas, cbs = {}) { function flipperSeg(which) { const f = TABLE.flippers[which]; const ang = state.ang[which]; + const len = f.len ?? PHYS.flipLen; return { ax: f.pivot[0], ay: f.pivot[1], - bx: f.pivot[0] + Math.cos(ang) * PHYS.flipLen, - by: f.pivot[1] + Math.sin(ang) * PHYS.flipLen, + bx: f.pivot[0] + Math.cos(ang) * len, + 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. - if (b.inLane && b.x > 286) { + if (b.inLane && b.x > 291) { b.vx *= 0.6; 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. 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. - 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.x = TABLE.laneX; b.vx = 0; @@ -282,9 +318,9 @@ export function createPinball(canvas, cbs = {}) { // 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) { + // off (bumpers/posts/slings stay solid) — until it's clearly among the + // bumpers. The charge sets how hard it cuts across. + if (b.launching && b.y < 145) { b.vx = -(300 + 250 * state.launchC); b.vy = 40; b.launching = false; @@ -292,7 +328,7 @@ export function createPinball(canvas, cbs = {}) { // 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; + if (b.hooding && !b.launching && (b.x < 220 || b.y > 260)) b.hooding = false; b.vy += PHYS.gravity * dt; b.x += b.vx * dt; @@ -671,14 +707,14 @@ export function createPinball(canvas, cbs = {}) { // slingshot wedges — solid rubber, lighter top face for (const pts of [ [ - [104, 424], - [118, 462], - [96, 462], + [71, 422], + [139, 457], + [96, 456], ], [ - [176, 424], - [162, 462], - [190, 462], + [249, 422], + [181, 457], + [224, 456], ], ]) { 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); } - // 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 restY = 516; - const botY = 542; - const topY = restY + state.charge * (botY - restY - 6); + const restY = 503; // touching the ball, uncharged + const pullY = 513; // fully pulled back + const anchorY = 522; // fixed base + const knobY = restY + state.charge * (pullY - restY); ctx.beginPath(); - 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.moveTo(lx, anchorY); + ctx.lineTo(lx, knobY); + stroke2(C.metal, 3, 6); + ctx.strokeStyle = 'rgba(28,34,48,0.45)'; + 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(); - if (ctx.roundRect) ctx.roundRect(lx - 10, topY - 8, 20, 9, 3); - else ctx.rect(lx - 10, topY - 8, 20, 9); + if (ctx.roundRect) ctx.roundRect(lx - 9, knobY - 5, 18, 10, 4); + else ctx.rect(lx - 9, knobY - 5, 18, 10); ctx.fillStyle = C.metal; ctx.fill(); ctx.lineWidth = 2; @@ -856,7 +894,7 @@ export function createPinball(canvas, cbs = {}) { // 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.vy = -(1040 + 200 * c); b.vx = 0; b.inLane = false; // commit — don't let a graze in the lane cancel the launch b.launching = true; diff --git a/src/router.js b/src/router.js index 433ea30..5169c00 100644 --- a/src/router.js +++ b/src/router.js @@ -15,6 +15,7 @@ import { BreedingView } from './views/BreedingView.js'; import { ShinyView } from './views/ShinyView.js'; import { WhosThatView } from './views/WhosThatView.js'; import { PinballView } from './views/PinballView.js'; +import { PinballEditorView } from './views/PinballEditorView.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { prefersReducedMotion } from './store/settings.js'; @@ -43,6 +44,7 @@ const routes = [ { pattern: /^#\/settings$/, view: () => SettingsView() }, { pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps { pattern: /^#\/pinball$/, view: () => PinballView() }, // hidden — linked from #/whos-that + { pattern: /^#\/pinball-editor$/, view: () => PinballEditorView() }, // TEMPORARY dev tool — see PinballEditorView.js ]; function resolve(hash) { diff --git a/src/styles/layout.css b/src/styles/layout.css index d5c5898..5bbf436 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -4055,3 +4055,52 @@ font-size: 0.78rem; 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; +} diff --git a/src/views/PinballEditorView.js b/src/views/PinballEditorView.js new file mode 100644 index 0000000..be426d0 --- /dev/null +++ b/src/views/PinballEditorView.js @@ -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 doesn’t 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; +} diff --git a/src/views/PinballView.js b/src/views/PinballView.js index 33f562f..ae6a669 100644 --- a/src/views/PinballView.js +++ b/src/views/PinballView.js @@ -279,6 +279,9 @@ export async function PinballView() { el('a', { class: 'link', href: '#/' }, '‹ Dex'), el('span', {}, ' · '), 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( diff --git a/test/pinball.test.js b/test/pinball.test.js index fb74ef1..b9f4cb3 100644 --- a/test/pinball.test.js +++ b/test/pinball.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { _test } from '../src/lib/pinball.js'; -const { closestOnSeg, clamp } = _test; +const { closestOnSeg, clamp, TABLE, PHYS } = _test; describe('clamp', () => { it('bounds a value', () => { @@ -27,3 +27,98 @@ describe('closestOnSeg', () => { 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([]); + }); +});