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
+358
View File
@@ -0,0 +1,358 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Game, PHYSICS_DT, PULL_TIME } from '../public/js/game.js';
import { segmentsCross } from '../public/js/physics.js';
import { SHOOTER_X, LANE_LEFT } from '../public/js/table.js';
function seeded(seed) {
let s = seed >>> 0;
return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
}
function run(game, seconds, onStep) {
const steps = Math.round(seconds / PHYSICS_DT);
for (let i = 0; i < steps; i++) {
game.step();
if (onStep && onStep() === false) return;
}
}
function newGame(seed = 1) {
const game = new Game({ random: seeded(seed) });
game.setInput('launch', true); // starts the game
game.setInput('launch', false);
return game;
}
function plunge(game, seconds) {
game.setInput('launch', true);
run(game, seconds);
game.setInput('launch', false);
}
test('space starts a game with the first ball in the shooter lane', () => {
const game = new Game({ random: seeded(1) });
assert.equal(game.state, 'attract');
game.setInput('launch', true);
game.setInput('launch', false);
assert.equal(game.state, 'play');
assert.equal(game.ballNumber, 1);
assert.ok(game.ball.x > LANE_LEFT);
});
test('pulling the plunger back never drains the ball', () => {
const game = newGame();
game.setInput('launch', true);
run(game, 1.5);
assert.equal(game.state, 'play');
assert.equal(game.stats.drains, 0);
});
test('holding space longer launches the ball harder', () => {
const speeds = [0, 0.3, 0.6, 0.9].map((hold) => {
const game = newGame();
game.setInput('launch', true);
run(game, hold);
const pull = game.plunger.pull;
game.setInput('launch', false);
assert.ok(Math.abs(pull - Math.min(1, hold / PULL_TIME)) < 0.01, `pull after ${hold}s was ${pull}`);
return -game.ball.vy;
});
for (let i = 1; i < speeds.length; i++) assert.ok(speeds[i] > speeds[i - 1], speeds.join(', '));
});
test('every plunger strength gets the ball out of the shooter lane', () => {
for (const hold of [0, 0.2, 0.45, 0.9]) {
const game = newGame();
plunge(game, hold);
run(game, 1.5, () => !game.inPlay);
assert.equal(game.inPlay, true, `hold ${hold}s`);
}
});
test('a ball dropped on a raised flipper cradles, and flipping sends it up the table', () => {
const game = newGame();
game.setInput('left', true);
run(game, 0.1);
game.ball.place(190, 820);
game.inPlay = true;
run(game, 3);
assert.ok(game.ball.speed < 20, 'ball should come to rest');
assert.equal(game.state, 'play');
game.setInput('left', false);
run(game, 0.25);
game.setInput('left', true);
let highest = Infinity;
run(game, 1.2, () => {
highest = Math.min(highest, game.ball.y);
});
assert.ok(highest < 300, `ball only reached y=${highest}`);
});
test('three drains end the game and the bonus is paid per ball', () => {
const game = newGame();
for (let ballNumber = 1; ballNumber <= 3; ballNumber++) {
assert.equal(game.ballNumber, ballNumber);
game.bonus = 2000;
game.multiplier = 3;
const before = game.score;
game.ball.place(238, 900); // straight down the middle
game.inPlay = true;
run(game, 4, () => game.state === 'play' || game.state === 'bonus');
assert.equal(game.score - before, 6000);
}
assert.equal(game.state, 'gameover');
assert.equal(game.highScore, 18000);
game.setInput('launch', true);
assert.equal(game.state, 'play');
assert.equal(game.score, 0);
});
test('completing the top lanes raises the bonus multiplier', () => {
const game = newGame();
game.inPlay = true;
game.skillShotLive = false;
for (const r of game.table.rollovers.filter((r) => r.kind === 'top')) game.hitRollover(r);
assert.equal(game.multiplier, 2);
assert.deepEqual(game.topLit, [false, false, false]);
});
test('the flipper buttons shift the lit top lanes', () => {
const game = newGame();
game.topLit = [true, false, false];
game.setInput('right', true);
assert.deepEqual(game.topLit, [false, true, false]);
game.setInput('right', true); // holding the button does not shift again
assert.deepEqual(game.topLit, [false, true, false]);
game.setInput('left', true);
assert.deepEqual(game.topLit, [true, false, false]);
game.setInput('left', false);
game.setInput('left', true);
assert.deepEqual(game.topLit, [false, false, true]);
});
test('the flippers steer the blinking skill-shot lane while it is live', () => {
const game = newGame();
game.skillShotLane = 1;
game.setInput('left', true);
assert.equal(game.skillShotLane, 0);
game.setInput('left', false);
game.setInput('right', true);
assert.equal(game.skillShotLane, 1);
});
test('a soft plunge usually drops into the top lanes and a full plunge orbits', () => {
const firstSwitch = (hold) => {
const game = newGame();
plunge(game, hold);
game.takeEvents();
let first = null;
run(game, 8, () => {
for (const e of game.takeEvents()) {
if (first) break;
if (e.type === 'rollover') first = e.kind;
else if (['bumper', 'kicker', 'sling', 'drop', 'standup', 'drain'].includes(e.type)) first = e.type;
}
return first === null;
});
return first;
};
let lanes = 0;
let drains = 0;
let count = 0;
for (let ms = 0; ms <= 500; ms += 20, count++) {
const first = firstSwitch(ms / 1000);
if (first === 'top') lanes++;
if (first === 'drain') drains++;
}
assert.ok(lanes / count >= 0.7, `only ${lanes}/${count} soft plunges reached a top lane`);
assert.equal(drains, 0);
// A full plunge sends the ball all the way around the orbit and down the outer lane, where it meets
// the drop-target bank (flanking the pop bumpers) before reaching the kicker bulge further down.
assert.equal(firstSwitch(0.9), 'drop');
});
test('knocking down every drop target lights super bumpers and the bank resets', () => {
const game = newGame();
game.ball.place(300, 300);
for (const d of game.table.dropTargets) game.hitDropTarget(d);
assert.ok(game.superBumpers > 0);
run(game, 2);
assert.ok(game.table.dropTargets.every((d) => !d.down));
});
function shootScoop(game) {
game.table.lock.cooldown = 0; // the scoop briefly refuses back-to-back hits; tests fire it on demand
const ball = game.ball;
ball.x = game.table.lock.x;
ball.y = game.table.lock.y;
ball.vx = 0;
ball.vy = 1000; // comfortably above the scoop's capture speed
run(game, 0.05);
}
test('locking a ball does not count as a drain or advance the ball number', () => {
const game = newGame();
const ballNumberBefore = game.ballNumber;
shootScoop(game);
assert.equal(game.locked, 1);
assert.equal(game.ballNumber, ballNumberBefore);
assert.equal(game.stats.drains, 0);
assert.equal(game.state, 'play');
assert.equal(game.balls.length, 1); // the locked ball was removed and a fresh one served
assert.ok(game.ball.x > LANE_LEFT, 'the fresh ball should be back on the plunger');
});
test('locking two balls then shooting the scoop a third time starts 3-ball multiball', () => {
const game = newGame();
shootScoop(game);
shootScoop(game);
assert.equal(game.locked, 2);
shootScoop(game);
assert.equal(game.state, 'multiball');
assert.equal(game.locked, 0);
assert.equal(game.balls.length, 3);
assert.equal(game.jackpot.left, true);
assert.equal(game.jackpot.right, true);
assert.equal(game.jackpot.super, false);
assert.equal(game.stats.multiballs, 1);
for (const ball of game.balls) {
assert.ok(ball.x >= 0 && ball.x <= 514, `spawned ball should be on the table: x=${ball.x}`);
}
});
test('a ramp shot during multiball collects its jackpot, and collecting both lights the super jackpot', () => {
const game = newGame();
game.state = 'multiball';
const ball = game.spawnBall(300, 500);
game.balls = [ball];
game.jackpot.left = true;
game.jackpot.right = true;
game.jackpot.value = 25000;
const before = game.score;
const leftTrack = game.table.ramps.find((r) => r.track.side === 'left').track;
ball.track = leftTrack;
ball.s = leftTrack.length - 1;
ball.v = 500;
run(game, 0.02);
assert.equal(game.jackpot.left, false);
assert.equal(game.score - before, 25000);
assert.equal(game.jackpot.super, false); // only one side collected so far
const rightTrack = game.table.ramps.find((r) => r.track.side === 'right').track;
ball.track = rightTrack;
ball.s = rightTrack.length - 1;
ball.v = 500;
run(game, 0.02);
assert.equal(game.jackpot.right, false);
assert.equal(game.jackpot.super, true);
});
test('collecting the super jackpot serves a fresh ball and relights both jackpots', () => {
const game = newGame();
game.state = 'multiball';
const ball = game.spawnBall(game.table.lock.x, game.table.lock.y);
game.balls = [ball];
game.jackpot.super = true;
game.jackpot.left = false;
game.jackpot.right = false;
ball.vx = 0;
ball.vy = 1000;
const before = game.score;
run(game, 0.05);
assert.equal(game.jackpot.super, false);
assert.equal(game.jackpot.left, true);
assert.equal(game.jackpot.right, true);
assert.equal(game.score - before, 100000);
assert.equal(game.balls.length, 1); // the old ball was removed and a fresh one served
assert.notEqual(game.balls[0], ball);
});
test('multiball continues until the very last ball drains, then ends as a normal ball loss', () => {
const game = newGame();
game.state = 'multiball';
const a = game.spawnBall(200, 500);
const b = game.spawnBall(300, 500);
const c = game.spawnBall(250, 900);
game.balls = [a, b, c];
game.bonus = 1000;
game.multiplier = 2;
a.x = 100;
a.y = 1050;
a.vx = 0;
a.vy = 10;
run(game, 0.01);
assert.equal(game.state, 'multiball');
assert.equal(game.balls.length, 2);
b.x = 100;
b.y = 1050;
b.vx = 0;
b.vy = 10;
run(game, 0.01);
assert.equal(game.state, 'multiball'); // one ball is still in play
assert.equal(game.balls.length, 1);
assert.equal(game.balls[0], c);
c.x = 100;
c.y = 1050;
c.vx = 0;
c.vy = 10;
run(game, 0.01);
assert.equal(game.state, 'bonus'); // the last ball of the turn: a normal drain
assert.equal(game.balls.length, 0);
assert.equal(game.bonusAward, 1000 * 2);
});
test('long random play never tunnels through walls or escapes the table', () => {
const game = newGame(7);
const rand = seeded(99);
let holdLeft = 0;
let holdRight = 0;
let tunnels = 0;
for (let i = 0; i < 180 / PHYSICS_DT; i++) {
if (game.state === 'gameover') game.setInput('launch', true), game.setInput('launch', false);
if (
(game.state === 'play' || game.state === 'multiball') &&
!game.inPlay &&
game.ball &&
game.ball.x === SHOOTER_X &&
Math.abs(game.ball.vy) < 1 &&
!game.plunger.pulling
) {
plunge(game, rand() * 0.9);
}
for (const ball of game.balls) {
for (const [index, f] of game.table.flippers.entries()) {
if (Math.hypot(ball.x - f.x, ball.y - f.y) < 85 && ball.vy > -50 && rand() < 0.02) {
if (index === 0) holdLeft = 0.18;
else holdRight = 0.18;
}
}
}
holdLeft -= PHYSICS_DT;
holdRight -= PHYSICS_DT;
game.setInput('left', holdLeft > 0);
game.setInput('right', holdRight > 0);
// Snapshot each ball's track state before the step: a ball riding a ramp legitimately passes
// over other playfield elements (that's the point of being elevated), so it's exempt below.
const before = game.balls.map((b) => ({ ball: b, hadTrack: Boolean(b.track) }));
game.step();
for (const { ball: b, hadTrack } of before) {
if (hadTrack || b.track) continue;
for (const w of game.table.walls) {
if (!w.enabled || !segmentsCross(b.prevX, b.prevY, b.x, b.y, w.ax, w.ay, w.bx, w.by)) continue;
const fromBelowGate = w.tag === 'gate' && (b.prevX - w.ax) * w.nx + (b.prevY - w.ay) * w.ny < 0;
if (!fromBelowGate) tunnels++;
}
}
game.takeEvents();
}
assert.equal(tunnels, 0);
assert.equal(game.stats.rescues, 0);
assert.ok(game.stats.drains > 0);
// Multiball itself is covered deterministically above; a random bot's luck at reaching it within any
// one seeded run is not something this stress test should assert on.
});
+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}`);
});