initial Game
This commit is contained in:
@@ -0,0 +1,765 @@
|
||||
// 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');
|
||||
}
|
||||
Reference in New Issue
Block a user