// 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'); }