Files
2026-09-12 01:33:07 -04:00

766 lines
27 KiB
JavaScript

// Game rules and the fixed-timestep simulation. No DOM access here, so the whole
// game can be driven headlessly from Node for tests.
import {
Ball,
Circle,
integrate,
collideSegment,
collideCircle,
collideFlipper,
collideBalls,
advanceOnTrack,
contact,
kick,
} from './physics.js';
import {
createTable,
MATERIALS,
BALL_RADIUS,
DRAIN_Y,
SHOOTER_X,
PLUNGER_REST_Y,
PLUNGER_TRAVEL,
LANE_LEFT,
LANE_TOP,
LEFT,
WIDTH,
CX,
} from './table.js';
export const PHYSICS_DT = 1 / 1000;
export const BALLS_PER_GAME = 3;
export const MULTIBALL_COUNT = 3;
// Effective gravity along the playfield. A real 6.5 degree table gives 9.81 * sin(6.5) = 1.11 m/s^2;
// this is raised for a snappier, arcade feel (tuned by play-testing).
const GRAVITY = 1500;
const DAMPING_PER_SECOND = 0.12; // gentle rolling resistance
const MAX_SPEED = 6500;
export const PULL_TIME = 1.0; // seconds to pull the plunger all the way back
// The first half of the pull is a fine-control "soft plunge" whose speeds (found by simulating launches)
// drop the ball into the top lanes for the skill shot; the second half sends it round the orbit.
const SOFT_PLUNGE = [1778, 1865];
const FULL_PLUNGE = 3200;
export function launchSpeed(pull) {
if (pull <= 0.5) return SOFT_PLUNGE[0] + (SOFT_PLUNGE[1] - SOFT_PLUNGE[0]) * (pull / 0.5);
return SOFT_PLUNGE[1] + (FULL_PLUNGE - SOFT_PLUNGE[1]) * ((pull - 0.5) / 0.5);
}
const BUMPER_KICK = 1700;
const SLING_KICK = 1500;
const SLING_THRESHOLD = 150;
const TARGET_THRESHOLD = 80;
const BONUS_COUNT_TIME = 2.2;
const SKILL_SHOT_WINDOW = 8;
const SUPER_BUMPER_TIME = 20;
const MAX_MULTIPLIER = 5;
// Left-outlane kickback: fires the ball back up the outlane when lit.
export const KICKBACK = { x: LEFT + 16, y: 905, maxX: LEFT + 32, speed: 2700 };
// A ramp is ridden like a bead on a wire: full vertical gravity (not the tilted-table component) fights
// the climb, scaled down from the real 9.81 m/s^2 for an arcade feel that still rewards a hard shot.
const RAMP_CLIMB_G = 6200;
const RAMP_FRICTION = 260;
const JACKPOT_BASE = 25000;
const JACKPOT_STEP = 10000;
const SUPER_JACKPOT = 100000;
const LOCK_VALUE = 15000;
const SCORES = {
bumper: 100,
superBumper: 1000,
sling: 50,
kicker: 25,
topLane: 500,
lanesComplete: 5000,
lanesAtMax: 25000,
inlane: 1000,
outlane: 2500,
drop: 1000,
dropsComplete: 10000,
standup: 750,
standupsComplete: 15000,
skillShot: 25000,
kickback: 500,
ramp: 750,
scoop: 500,
};
export class Game {
constructor({ random = Math.random, highScore = 0 } = {}) {
this.random = random;
this.table = createTable();
this.highScore = highScore;
this.events = [];
this.time = 0;
this.state = 'attract'; // attract | play | multiball | bonus | gameover
this.dampingFactor = Math.exp(-DAMPING_PER_SECOND * PHYSICS_DT);
this.plunger = { pull: 0, pos: 0, pulling: false, firing: false };
this.launchLatch = false; // ignore the Space press that started the game until it is released
this.bumperBodies = this.table.bumpers.map((b) => new Circle(b.x, b.y, b.radius, { material: MATERIALS.rubber }));
this.stats = { rescues: 0, drains: 0, launches: 0, kickbacks: 0, locks: 0, multiballs: 0 };
this.kickbackFlash = 0;
this.lockFlash = 0;
this.balls = [];
this.resetScores();
this.serveBall();
this.message = { text: 'PRESS SPACE', sub: 'TO START', time: 0 };
}
/** The primary ball: the shooter-lane ball before multiball, or the first active ball during it. */
get ball() {
return this.balls[0];
}
get ballVisible() {
return this.balls.length > 0;
}
resetScores() {
this.score = 0;
this.ballNumber = 1;
this.multiplier = 1;
this.bonus = 0;
this.bonusAward = 0;
this.topLit = [false, false, false];
this.superBumpers = 0;
this.kickbackLit = true;
this.newHighScore = false;
this.locked = 0;
this.jackpot = { left: false, right: false, super: false, value: JACKPOT_BASE };
for (const t of this.table.dropTargets) this.raiseTarget(t);
for (const t of this.table.standups) t.lit = false;
this.dropResetTimer = 0;
}
emit(type, data = {}) {
this.events.push({ type, ...data });
}
say(text, sub = '', time = 2) {
this.message = { text, sub, time };
}
addScore(points, x, y) {
this.score += points;
if (x !== undefined) this.emit('score', { points, x, y });
}
spawnBall(x, y) {
const ball = new Ball(BALL_RADIUS);
ball.place(x, y);
return ball;
}
// ---- Input -------------------------------------------------------------------------------
/** action: 'left' | 'right' | 'launch'. Keys mapped to the same action are reference-counted by the caller. */
setInput(action, pressed) {
if (action === 'left' || action === 'right') {
const flipper = this.table.flippers[action === 'left' ? 0 : 1];
if (flipper.pressed === pressed) return;
flipper.pressed = pressed;
this.emit('flipper', { side: action, up: pressed });
if (pressed && (this.state === 'play' || this.state === 'multiball')) this.laneChange(action === 'left' ? -1 : 1);
return;
}
if (action !== 'launch') return;
if (pressed) {
if (this.state === 'attract' || this.state === 'gameover') {
this.startGame();
this.launchLatch = true;
} else if (!this.launchLatch && !this.plunger.pulling) {
this.plunger.pulling = true;
this.plunger.firing = false;
this.emit('plungerPull');
}
} else if (this.launchLatch) {
this.launchLatch = false;
} else if (this.plunger.pulling) {
this.firePlunger();
}
}
laneChange(dir) {
const lit = this.topLit;
this.topLit = dir < 0 ? [lit[1], lit[2], lit[0]] : [lit[2], lit[0], lit[1]];
// While the skill shot is live the flippers also steer the blinking lane.
if (this.skillShotLive) this.skillShotLane = (this.skillShotLane + (dir < 0 ? 2 : 1)) % 3;
}
// ---- Game flow ---------------------------------------------------------------------------
startGame() {
this.resetScores();
this.state = 'play';
this.serveBall();
this.emit('start');
}
/** Put a fresh ball on the plunger for a new turn (resets per-ball state: multiplier, bonus, skill shot). */
serveBall() {
this.balls = [this.spawnBall(SHOOTER_X, PLUNGER_REST_Y - BALL_RADIUS - 0.5)];
this.inPlay = false; // becomes true once the ball clears the shooter lane
this.multiplier = 1;
this.bonus = 0;
this.superBumpers = 0;
this.skillShotLane = Math.floor(this.random() * 3);
this.skillShotTime = 0;
this.skillShotLive = true;
this.stuckTime = 0;
if (this.state === 'play') {
this.say(`BALL ${this.ballNumber}`, 'HOLD SPACE TO PLUNGE', 3);
this.emit('newBall');
}
}
/** Put another ball on the plunger without resetting the turn (locking a ball doesn't cost you a turn). */
serveExtraBall() {
this.balls.push(this.spawnBall(SHOOTER_X, PLUNGER_REST_Y - BALL_RADIUS - 0.5));
}
firePlunger() {
const p = this.plunger;
p.pulling = false;
p.firing = true;
const ball = this.balls.find((b) => !b.track && b.x > LANE_LEFT);
const tipY = PLUNGER_REST_Y + p.pos;
const resting = ball && Math.abs(ball.y + ball.radius - tipY) < 4 && Math.abs(ball.vy) < 200;
const power = p.pull;
if (resting && (this.state === 'play' || this.state === 'multiball')) {
ball.vy = -launchSpeed(power);
ball.vx = 0;
this.stats.launches++;
}
this.emit('launch', { power, withBall: Boolean(resting) });
p.pull = 0;
}
/** Remove a ball from play without it counting as a drain (used for locks). */
removeBall(ball) {
const i = this.balls.indexOf(ball);
if (i >= 0) this.balls.splice(i, 1);
}
drain() {
this.stats.drains++;
this.state = 'bonus';
this.bonusTimer = BONUS_COUNT_TIME;
this.bonusAward = this.bonus * this.multiplier;
for (const f of this.table.flippers) f.pressed = false;
this.emit('drain');
this.say('BALL LOST', `BONUS ${fmt(this.bonus)} x ${this.multiplier}`, BONUS_COUNT_TIME);
}
endOfBonus() {
this.score += this.bonusAward;
this.bonusAward = 0;
if (this.ballNumber >= BALLS_PER_GAME) {
this.state = 'gameover';
if (this.score > this.highScore) {
this.highScore = this.score;
this.newHighScore = true;
}
this.say('GAME OVER', this.newHighScore ? 'NEW HIGH SCORE!' : 'PRESS SPACE TO PLAY AGAIN', Infinity);
this.emit('gameover', { score: this.score, newHighScore: this.newHighScore });
return;
}
this.ballNumber++;
this.state = 'play';
this.serveBall();
}
// ---- Simulation --------------------------------------------------------------------------
step(dt = PHYSICS_DT) {
this.time += dt;
this.updatePlunger(dt);
for (const f of this.table.flippers) f.update(dt);
this.updateTimers(dt);
if (this.state === 'bonus') {
this.bonusTimer -= dt;
if (this.bonusTimer <= 0) this.endOfBonus();
return;
}
if (this.balls.length === 0) return;
// Iterate over a snapshot: balls can be removed (drain, lock) mid-loop.
for (const ball of [...this.balls]) {
if (!this.balls.includes(ball)) continue; // removed earlier this step (e.g. by a ball-ball collision path)
if (ball.track) {
this.advanceRamp(ball, dt);
continue;
}
integrate(ball, dt, GRAVITY, this.dampingFactor, MAX_SPEED);
this.collideBall(ball);
this.checkRampEntrance(ball);
if (ball.track) continue; // just captured onto a ramp — advanceRamp takes over next step
this.checkLockScoop(ball);
// The lock scoop can remove this ball (and serve a fresh one) — stop touching it if so, or the
// stale reference below would clobber state the fresh ball's serveBall() just reset (e.g. inPlay).
if (!this.balls.includes(ball)) continue;
this.checkRollovers(ball);
if (this.kickbackLit && ball.x < KICKBACK.maxX && ball.y > KICKBACK.y && ball.vy > 0) {
this.fireKickback(ball);
}
if (!this.inPlay && ball.y < LANE_TOP - ball.radius) this.inPlay = true;
// The shooter lane runs below the drain line (the plunger pulls the ball down), so exclude it.
if (ball.y > DRAIN_Y && ball.x < LANE_LEFT) {
this.loseBall(ball);
continue;
}
// Failsafe: if a ball ever escapes the table, remove it (rescued rather than lost).
if (ball.x < -40 || ball.x > WIDTH + 40 || ball.y < -80 || !Number.isFinite(ball.x + ball.y)) {
this.stats.rescues++;
this.removeBall(ball);
if (this.balls.length === 0 && (this.state === 'play' || this.state === 'multiball')) this.serveBall();
continue;
}
this.checkStuck(ball, dt);
}
// Ball-to-ball collisions (multiball): only between balls on the open playfield.
const loose = this.balls.filter((b) => !b.track);
for (let i = 0; i < loose.length; i++) {
for (let j = i + 1; j < loose.length; j++) collideBalls(loose[i], loose[j], 0.6);
}
}
/** A ball drains: in single-ball play that ends the turn; in multiball it just leaves the mix. */
loseBall(ball) {
this.removeBall(ball);
if (this.state === 'multiball' && this.balls.length > 0) {
this.emit('ballLost');
return; // multiball continues with the remaining ball(s)
}
if (this.state === 'multiball') this.state = 'play'; // last ball of a multiball: fall through to a normal drain
this.drain();
}
updatePlunger(dt) {
const p = this.plunger;
if (p.pulling) {
p.pull = Math.min(1, p.pull + dt / PULL_TIME);
p.pos = PLUNGER_TRAVEL * p.pull;
} else if (p.firing) {
p.pos -= 2600 * dt;
if (p.pos <= 0) {
p.pos = 0;
p.firing = false;
}
}
const seg = this.table.plunger;
const y = PLUNGER_REST_Y + p.pos;
seg.ay = seg.by = y;
seg.minY = seg.maxY = y;
}
updateTimers(dt) {
const t = this.table;
for (const b of t.bumpers) {
b.flash = Math.max(0, b.flash - dt * 4);
b.cooldown = Math.max(0, b.cooldown - dt);
}
t.lock.cooldown = Math.max(0, t.lock.cooldown - dt);
for (const s of t.slings) s.flash = Math.max(0, s.flash - dt * 6);
for (const r of t.rollovers) r.flash = Math.max(0, r.flash - dt * 2);
for (const d of t.dropTargets) {
d.flash = Math.max(0, d.flash - dt * 3);
d.drop = d.down ? Math.min(1, d.drop + dt * 12) : Math.max(0, d.drop - dt * 8);
}
for (const s of t.standups) s.flash = Math.max(0, s.flash - dt * 3);
this.kickbackFlash = Math.max(0, this.kickbackFlash - dt * 3);
this.lockFlash = Math.max(0, this.lockFlash - dt * 3);
if (this.superBumpers > 0) this.superBumpers = Math.max(0, this.superBumpers - dt);
if (this.message.time !== Infinity) this.message.time = Math.max(0, this.message.time - dt);
if (this.inPlay && this.skillShotLive) {
this.skillShotTime += dt;
if (this.skillShotTime > SKILL_SHOT_WINDOW) this.skillShotLive = false;
}
if (this.dropResetTimer > 0) {
this.dropResetTimer -= dt;
if (this.dropResetTimer <= 0) this.tryResetDropTargets();
}
}
collideBall(ball) {
const t = this.table;
for (const w of t.walls) {
if (!w.enabled || !collideSegment(ball, w)) continue;
if (w.tag === 'kicker' && contact.approach > 400) {
this.addScore(SCORES.kicker);
this.emit('kicker', { speed: contact.approach });
} else if (w.tag === 'gate') {
t.gate.swing = 1;
}
}
for (const p of t.posts) {
if (collideCircle(ball, p) && contact.approach > 300) this.emit('rubber', { speed: contact.approach });
}
for (let i = 0; i < t.bumpers.length; i++) {
if (collideCircle(ball, this.bumperBodies[i])) this.hitBumper(t.bumpers[i], ball);
}
for (const s of t.slings) {
for (const seg of s.segments) {
if (!collideSegment(ball, seg)) continue;
const onFace = seg === s.face && contact.nx * s.nx + contact.ny * s.ny > 0.8;
if (onFace && contact.approach > SLING_THRESHOLD) this.hitSling(s, ball);
else if (contact.approach > 300) this.emit('rubber', { speed: contact.approach });
}
}
for (const d of t.dropTargets) {
if (!d.down && collideSegment(ball, d.segment) && contact.approach > TARGET_THRESHOLD) this.hitDropTarget(d);
}
for (const s of t.standups) {
if (collideSegment(ball, s.segment) && contact.approach > TARGET_THRESHOLD) this.hitStandup(s);
}
for (const f of t.flippers) collideFlipper(ball, f);
if (collideSegment(ball, t.plunger) && contact.approach > 300) this.emit('rubber', { speed: contact.approach });
}
noteSwitch() {
// Any scoring switch other than the top lanes ends the skill-shot window.
if (this.inPlay) this.skillShotLive = false;
}
hitBumper(b, ball) {
kick(ball, contact.nx, contact.ny, BUMPER_KICK);
if (b.cooldown > 0) return;
b.cooldown = 0.08;
b.flash = 1;
this.noteSwitch();
const points = this.superBumpers > 0 ? SCORES.superBumper : SCORES.bumper;
this.addScore(points, b.x, b.y - b.radius);
this.emit('bumper', { index: b.index, x: b.x, y: b.y });
}
hitSling(s, ball) {
kick(ball, s.nx, s.ny, SLING_KICK);
s.flash = 1;
this.noteSwitch();
this.addScore(SCORES.sling, (s.top[0] + s.tip[0]) / 2, (s.top[1] + s.tip[1]) / 2);
this.emit('sling', { side: s.side });
}
fireKickback(ball) {
ball.vx = 0;
ball.vy = -KICKBACK.speed;
this.kickbackLit = false;
this.kickbackFlash = 1;
this.stats.kickbacks++;
this.addScore(SCORES.kickback, KICKBACK.x + 20, KICKBACK.y - 40);
this.say('KICKBACK!', 'RELIGHT AT THE DROP TARGETS', 2);
this.emit('kickback');
}
// ---- Ramps ---------------------------------------------------------------------------------
checkRampEntrance(ball) {
if (this.state !== 'play' && this.state !== 'multiball') return;
for (const ramp of this.table.ramps) {
const e = ramp.entrance;
const dx = ball.x - e.x;
const dy = ball.y - e.y;
if (dx * dx + dy * dy > e.radius * e.radius) continue;
const along = ball.vx * e.dirx + ball.vy * e.diry; // component of velocity along the entrance heading
if (along < e.minSpeed) continue;
const start = ramp.track.sample(0);
ball.track = ramp.track;
ball.s = 0;
ball.v = along;
ball.x = e.x;
ball.y = e.y;
ball.z = ball.prevZ = start.z;
ball.vx = 0;
ball.vy = 0;
this.emit('rampEnter', { side: ramp.track.side });
return;
}
}
advanceRamp(ball, dt) {
const result = advanceOnTrack(ball, dt, GRAVITY, RAMP_CLIMB_G, RAMP_FRICTION);
if (result === 'end') this.exitRamp(ball);
else if (result === 'start') this.rejectRamp(ball);
}
exitRamp(ball) {
const track = ball.track;
const p = track.sample(track.length);
const k = Math.hypot(p.tx, p.ty) || 1;
const speed = Math.max(300, ball.v * 0.92); // a little energy lost to the wireform
ball.track = null;
ball.x = p.x;
ball.y = p.y;
ball.z = ball.prevZ = 0;
ball.vx = (p.tx / k) * speed;
ball.vy = (p.ty / k) * speed;
this.noteSwitch();
const side = track.side;
if (this.state === 'multiball' && this.jackpot[side]) {
this.jackpot[side] = false;
this.addScore(this.jackpot.value, p.x, p.y - 30);
this.say(`${side.toUpperCase()} JACKPOT!`, fmt(this.jackpot.value), 2);
this.emit('jackpot', { side });
this.jackpot.value += JACKPOT_STEP;
if (!this.jackpot.left && !this.jackpot.right) {
this.jackpot.super = true;
this.say('SUPER JACKPOT LIT', 'SHOOT THE LOCK', 2.5);
this.emit('complete', { what: 'jackpots' });
}
} else {
this.bonus += 500;
this.addScore(SCORES.ramp, p.x, p.y - 20);
this.emit('ramp', { side });
}
}
/** Too weak to crest the ramp: it rolls back out of the entrance mouth. */
rejectRamp(ball) {
const track = ball.track;
const p = track.sample(0);
const k = Math.hypot(p.tx, p.ty) || 1;
const speed = Math.max(200, -ball.v * 0.7);
ball.track = null;
ball.x = p.x;
ball.y = p.y;
ball.z = ball.prevZ = 0;
ball.vx = -(p.tx / k) * speed;
ball.vy = -(p.ty / k) * speed;
}
// ---- Lock scoop ------------------------------------------------------------------------------
checkLockScoop(ball) {
const lock = this.table.lock;
if (lock.cooldown > 0) return;
const dx = ball.x - lock.x;
const dy = ball.y - lock.y;
if (dx * dx + dy * dy > lock.radius * lock.radius) return;
if (ball.speed < lock.captureSpeed) return;
lock.cooldown = 0.5; // give the eject a real chance to clear the area before this can fire again
this.noteSwitch();
this.lockFlash = 1;
if (this.state === 'multiball' && this.jackpot.super) {
this.jackpot.super = false;
this.addScore(SUPER_JACKPOT, lock.x, lock.y - 30);
this.say('SUPER JACKPOT!', fmt(SUPER_JACKPOT), 2.5);
this.emit('superJackpot');
this.jackpot.left = true;
this.jackpot.right = true;
this.jackpot.value = JACKPOT_BASE;
this.removeBall(ball);
this.serveExtraBall();
return;
}
if (this.state === 'play' && this.locked < 2) {
this.locked++;
this.stats.locks++;
this.addScore(LOCK_VALUE, lock.x, lock.y - 30);
this.say(`BALL LOCKED ${this.locked}/3`, this.locked === 2 ? 'SHOOT SCOOP TO START MULTIBALL' : 'SHOOT SCOOP TO LOCK', 2);
this.emit('lock', { count: this.locked });
this.removeBall(ball);
this.serveBall();
return;
}
if (this.state === 'play' && this.locked >= 2) {
this.startMultiball(ball);
return;
}
// Multiball with no super lit yet, or any other case: a simple scoop bonus, kicked back out.
// A randomised sideways component (never straight down) keeps this from settling into a perfectly
// vertical bounce with whatever sits just below the scoop — a real risk given how symmetric this
// cluster is otherwise.
this.addScore(SCORES.scoop, lock.x, lock.y - 30);
this.emit('scoop');
ball.x = lock.x;
ball.y = lock.y + lock.radius + ball.radius;
ball.vx = (this.random() < 0.5 ? -1 : 1) * (250 + this.random() * 400);
ball.vy = 1400;
}
startMultiball(triggerBall) {
this.locked = 0;
this.state = 'multiball';
this.stats.multiballs++;
triggerBall.x = this.table.lock.x;
triggerBall.y = this.table.lock.y + this.table.lock.radius + triggerBall.radius;
triggerBall.vx = 0;
triggerBall.vy = 1100;
// The other locked balls drop in from the open middle playfield — the scoop itself is wedged
// tightly between the pop bumpers, with no room to spawn a second ball beside it without overlap.
const drops = [CX - 70, CX + 70];
for (let k = 0; this.balls.length < MULTIBALL_COUNT; k++) {
const b = this.spawnBall(drops[k % drops.length], 520);
b.vx = 0;
b.vy = 650;
this.balls.push(b);
}
this.jackpot.left = true;
this.jackpot.right = true;
this.jackpot.super = false;
this.jackpot.value = JACKPOT_BASE;
this.say('MULTIBALL!', 'JACKPOTS LIT ON BOTH RAMPS', 2.5);
this.emit('multiball');
}
hitDropTarget(d) {
d.down = true;
d.segment.enabled = false;
d.flash = 1;
this.noteSwitch();
this.bonus += 1000;
this.addScore(SCORES.drop, d.x, d.y);
this.emit('drop', { index: d.index });
if (this.table.dropTargets.every((t) => t.down)) {
this.addScore(SCORES.dropsComplete, d.x + 60, d.y - 30);
this.superBumpers = SUPER_BUMPER_TIME;
this.kickbackLit = true;
this.say('SUPER BUMPERS', 'KICKBACK IS LIT', 2.5);
this.emit('complete', { what: 'drops' });
this.dropResetTimer = 1.2;
}
}
raiseTarget(d) {
d.down = false;
d.segment.enabled = true;
}
tryResetDropTargets() {
const balls = this.balls;
const clear = this.table.dropTargets.every((d) =>
balls.every((ball) => Math.hypot(ball.x - d.x, ball.y - d.y) > ball.radius + d.halfWidth + 6),
);
if (!clear) {
this.dropResetTimer = 0.25;
return;
}
for (const d of this.table.dropTargets) this.raiseTarget(d);
this.emit('dropReset');
}
hitStandup(s) {
if (s.flash > 0.6) return; // debounce
s.flash = 1;
this.noteSwitch();
this.bonus += 1000;
this.addScore(SCORES.standup, s.x, s.y);
this.emit('standup', { index: s.index });
s.lit = true;
if (this.table.standups.every((t) => t.lit)) {
this.addScore(SCORES.standupsComplete, s.x - 60, s.y - 30);
this.bonus += 5000;
this.say('TARGETS COMPLETE', `${fmt(SCORES.standupsComplete)}`, 2.5);
this.emit('complete', { what: 'standups' });
for (const t of this.table.standups) t.lit = false;
}
}
checkRollovers(ball) {
for (const r of this.table.rollovers) {
const inside = Math.hypot(ball.x - r.x, ball.y - r.y) < r.radius;
// Lane rollovers only count when the ball rolls down through them (not on a kickback's way back up).
if (inside && !r.inside && (r.kind === 'top' || ball.vy > 0)) this.hitRollover(r);
r.inside = inside;
}
}
hitRollover(r) {
r.flash = 1;
if (r.kind === 'top') {
if (this.skillShotLive && this.inPlay) {
this.skillShotLive = false;
if (r.index === this.skillShotLane) {
this.addScore(SCORES.skillShot, r.x, r.y + 30);
this.say('SKILL SHOT!', fmt(SCORES.skillShot), 2.5);
this.emit('complete', { what: 'skill' });
}
}
this.topLit[r.index] = true;
this.bonus += 1000;
this.addScore(SCORES.topLane, r.x, r.y);
this.emit('rollover', { kind: 'top' });
if (this.topLit.every(Boolean)) {
this.topLit = [false, false, false];
if (this.multiplier < MAX_MULTIPLIER) {
this.multiplier++;
this.addScore(SCORES.lanesComplete);
this.say(`BONUS ${this.multiplier}X`, 'LANES COMPLETE', 2.5);
} else {
this.addScore(SCORES.lanesAtMax);
this.say('LANES COMPLETE', fmt(SCORES.lanesAtMax), 2.5);
}
this.emit('complete', { what: 'lanes' });
}
return;
}
this.noteSwitch();
if (r.kind === 'inlane') {
this.bonus += 500;
this.addScore(SCORES.inlane, r.x, r.y);
} else {
this.addScore(SCORES.outlane, r.x, r.y);
}
this.emit('rollover', { kind: r.kind });
}
/**
* Catches a ball with nowhere to go. This has to cover three shapes of "stuck", from tightest to
* loosest: resting in place (low speed); bouncing energetically forever in a small pocket that
* happens to be exactly symmetric (a bumper cluster hit dead-centre) — high speed throughout, zero
* net progress; and cycling around a *larger* loop through several colliders (bumper -> bumper ->
* bumper -> repeat) that never resolves. That third shape defeats a simple "distance from an anchor
* point" check: the ball legitimately exceeds any reasonable radius partway around each lap, which
* keeps re-arming the check right as it happens, even though it is not actually going anywhere new.
* So instead this tracks the bounding box the ball has visited over a rolling multi-second window —
* a real loop still can't escape a modest box no matter how far it travels lap after lap — and forces
* a hard rescue once that window elapses without the box actually growing.
*/
checkStuck(ball, dt) {
const inLane = ball.x > LANE_LEFT;
const heldOnFlipper = this.table.flippers.some(
(f) => f.pressed && Math.hypot(ball.x - f.x, ball.y - f.y) < f.length + f.baseRadius + ball.radius + 2,
);
if ((this.state !== 'play' && this.state !== 'multiball') || inLane || heldOnFlipper) {
ball.stuckBox = null;
return;
}
if (!ball.stuckBox) ball.stuckBox = { minX: ball.x, maxX: ball.x, minY: ball.y, maxY: ball.y, t: 0 };
const box = ball.stuckBox;
box.minX = Math.min(box.minX, ball.x);
box.maxX = Math.max(box.maxX, ball.x);
box.minY = Math.min(box.minY, ball.y);
box.maxY = Math.max(box.maxY, ball.y);
box.t += dt;
if (box.maxX - box.minX > 220 || box.maxY - box.minY > 220) {
ball.stuckBox = { minX: ball.x, maxX: ball.x, minY: ball.y, maxY: ball.y, t: 0 }; // real progress: restart
return;
}
if (box.t > 4) {
ball.stuckBox = null;
// A hard, guaranteed-nonzero sideways kick plus enough downward speed to actually clear whatever
// cluster of colliders it's cycling through, rather than weakly falling straight back in.
ball.vx = (this.random() < 0.5 ? -1 : 1) * (500 + this.random() * 500);
ball.vy = 2200;
this.emit('nudge');
}
}
/** Drain queued events (the renderer and audio react to them once per frame). */
takeEvents() {
const e = this.events;
this.events = [];
return e;
}
}
export function fmt(n) {
return Math.round(n).toLocaleString('en-US');
}