initial Game

This commit is contained in:
christopher
2026-09-12 01:33:07 -04:00
commit a740d2f141
15 changed files with 4275 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
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}`);
});