import test from 'node:test'; import assert from 'node:assert/strict'; import { Ball, Segment, Flipper, Track, collideSegment, collideFlipper, collideBalls, advanceOnTrack, resolveContact, makeMaterial, } from '../public/js/physics.js'; const flipperOptions = { x: 0, y: 0, length: 64, baseRadius: 11.5, tipRadius: 7, restAngle: 0.5, upAngle: -0.35, upSpeed: 40, downSpeed: 16, material: makeMaterial(0.8, 0.43), }; // The tapered flipper is the convex hull of two discs, which equals the union of discs // whose centre and radius are interpolated between the two ends. Brute-force that. function bruteForceDistance(f, px, py) { const tipX = f.x + Math.cos(f.angle) * f.length; const tipY = f.y + Math.sin(f.angle) * f.length; let best = Infinity; for (let i = 0; i <= 4000; i++) { const t = i / 4000; const cx = f.x + (tipX - f.x) * t; const cy = f.y + (tipY - f.y) * t; const r = f.baseRadius + (f.tipRadius - f.baseRadius) * t; best = Math.min(best, Math.hypot(px - cx, py - cy) - r); } return best; } test('flipper signed distance matches a brute-force distance to the tapered shape', () => { const f = new Flipper(flipperOptions); let seed = 1; const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); for (let i = 0; i < 300; i++) { const px = -40 + rand() * 140; const py = -60 + rand() * 120; const { dist } = f.distance(px, py); if (dist < 0.5) continue; // brute force only valid outside the shape assert.ok(Math.abs(dist - bruteForceDistance(f, px, py)) < 0.05, `point ${px},${py}`); } }); test('flipper normal is the gradient of the distance field', () => { const f = new Flipper(flipperOptions); const h = 1e-4; for (const [px, py] of [[30, -20], [30, 25], [-20, 0], [75, 30], [70, 50], [5, -16]]) { const { dist, nx, ny } = f.distance(px, py); const gx = (f.distance(px + h, py).dist - dist) / h; const gy = (f.distance(px, py + h).dist - dist) / h; assert.ok(Math.abs(gx - nx) < 1e-3 && Math.abs(gy - ny) < 1e-3, `normal at ${px},${py}`); } }); test('a ball bounces off a wall with restitution and is pushed out of it', () => { const ball = new Ball(10); ball.place(0, -9); // 1 mm inside a floor at y = 0 ball.vy = 2000; const floor = new Segment(-100, 0, 100, 0, { material: makeMaterial(0.5) }); assert.equal(collideSegment(ball, floor), true); assert.equal(ball.y, -10); assert.ok(Math.abs(ball.vy + 1000) < 1e-9); }); test('one-way segments block from one side only', () => { const gate = new Segment(0, 0, 100, 0, { oneWay: true, material: makeMaterial(0.5) }); // Left-hand normal walking a->b in screen space points up (-y): blocks balls above the line. const above = new Ball(10); above.place(50, -8); above.vy = 500; assert.equal(collideSegment(above, gate), true); const below = new Ball(10); below.place(50, 8); below.vy = -500; assert.equal(collideSegment(below, gate), false); }); test('a moving surface launches a resting ball', () => { const ball = new Ball(10); const e = 0.5; resolveContact(ball, 0, -1, 0, 0, -1000, makeMaterial(e)); assert.ok(Math.abs(ball.vy + 1500) < 1e-9); // (1 + e) * surface speed }); test('equal-mass balls exchange velocity along the line of centres and are pushed apart', () => { const a = new Ball(13.5); const b = new Ball(13.5); a.place(0, 0); b.place(20, 0); // 7 mm overlap (2 * 13.5 = 27 mm needed) a.vx = 1000; const approach = collideBalls(a, b, 1); // perfectly elastic assert.ok(approach > 0); assert.ok(Math.abs(a.vx) < 1e-6, `a should stop dead: ${a.vx}`); // a transfers all its velocity to b assert.ok(Math.abs(b.vx - 1000) < 1e-6, `b should take a's velocity: ${b.vx}`); assert.ok(Math.abs(b.x - a.x - 27) < 1e-6, 'balls should be pushed apart to just touch'); }); test('collideBalls does nothing when the balls are already separating', () => { const a = new Ball(13.5); const b = new Ball(13.5); a.place(0, 0); b.place(20, 0); a.vx = -500; // moving away from b const approach = collideBalls(a, b, 1); assert.equal(approach, 0); assert.equal(a.vx, -500); }); test('a straight, flat track carries a ball from start to end at constant speed', () => { const track = new Track([ [0, 0, 0], [1000, 0, 0], ]); const ball = new Ball(13.5); ball.track = track; ball.s = 0; ball.v = 500; let steps = 0; let result = null; while (!result && steps < 10000) { result = advanceOnTrack(ball, 1 / 1000, 0, 0, 0); // no gravity component, no friction: speed is constant steps++; } assert.equal(result, 'end'); assert.ok(Math.abs(ball.v - 500) < 1, `speed should be unchanged on a flat, frictionless track: ${ball.v}`); assert.ok(Math.abs(ball.x - 1000) < 1); }); test('a weak shot cannot crest a steep climb and rolls back out of the entrance', () => { const track = new Track([ [0, 0, 0], [200, 0, 100], // a short, steep 100 mm climb ]); const ball = new Ball(13.5); ball.track = track; ball.s = 0; ball.v = 400; // too slow to climb 100 mm against strong "gravity" let result = null; for (let i = 0; i < 20000 && !result; i++) result = advanceOnTrack(ball, 1 / 1000, 1500, 9000, 100); assert.equal(result, 'start'); assert.ok(ball.v < 0, `should be moving back down the entrance: ${ball.v}`); }); test('a hard shot crests the same climb and exits with reduced speed', () => { const track = new Track([ [0, 0, 0], [200, 0, 100], ]); const ball = new Ball(13.5); ball.track = track; ball.s = 0; ball.v = 2600; let result = null; for (let i = 0; i < 20000 && !result; i++) result = advanceOnTrack(ball, 1 / 1000, 1500, 9000, 100); assert.equal(result, 'end'); assert.ok(ball.v > 0 && ball.v < 2600, `should have lost speed to the climb: ${ball.v}`); }); test('a raised flipper throws a resting ball', () => { const f = new Flipper(flipperOptions); const ball = new Ball(13.5); // Sit the ball on top of the flipper, halfway along. const along = 40; const c = Math.cos(f.angle); const s = Math.sin(f.angle); ball.place(c * along + s * 23, s * along - c * 23); f.pressed = true; f.update(0.001); assert.equal(collideFlipper(ball, f), true); assert.ok(ball.vy < -1000, `expected a strong upward throw, got vy=${ball.vy}`); });