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
125 lines
4.6 KiB
JavaScript
125 lines
4.6 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { _test } from '../src/lib/pinball.js';
|
|
|
|
const { closestOnSeg, clamp, TABLE, PHYS } = _test;
|
|
|
|
describe('clamp', () => {
|
|
it('bounds a value', () => {
|
|
expect(clamp(5, 0, 10)).toBe(5);
|
|
expect(clamp(-3, 0, 10)).toBe(0);
|
|
expect(clamp(99, 0, 10)).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('closestOnSeg', () => {
|
|
it('projects onto the segment interior', () => {
|
|
const [x, y, t] = closestOnSeg(5, 5, 0, 0, 10, 0);
|
|
expect([x, y]).toEqual([5, 0]);
|
|
expect(t).toBeCloseTo(0.5);
|
|
});
|
|
|
|
it('clamps to the endpoints', () => {
|
|
expect(closestOnSeg(-4, 2, 0, 0, 10, 0).slice(0, 2)).toEqual([0, 0]);
|
|
expect(closestOnSeg(20, -1, 0, 0, 10, 0).slice(0, 2)).toEqual([10, 0]);
|
|
});
|
|
|
|
it('handles a degenerate (zero-length) segment', () => {
|
|
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([]);
|
|
});
|
|
});
|