initial Game
This commit is contained in:
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user