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:
parent
5861dd6b97
commit
6b090b9a0a
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
686
src/views/PinballEditorView.js
Normal file
686
src/views/PinballEditorView.js
Normal 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 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;
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user