initial Game
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Synthesised sound effects using the Web Audio API (no audio files needed).
|
||||
// Browsers keep an AudioContext suspended until a user gesture, so `unlock()`
|
||||
// is called from the first keydown.
|
||||
|
||||
const MASTER_VOLUME = 0.35;
|
||||
|
||||
export class Sound {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
this.noiseBuffer = null;
|
||||
this.muted = false;
|
||||
this.lastPlayed = new Map();
|
||||
}
|
||||
|
||||
unlock() {
|
||||
if (typeof AudioContext === 'undefined') return;
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext();
|
||||
this.master = new GainNode(this.ctx, { gain: this.muted ? 0 : MASTER_VOLUME });
|
||||
this.master.connect(this.ctx.destination);
|
||||
// One second of white noise, shared by every noise-based effect.
|
||||
const length = this.ctx.sampleRate;
|
||||
this.noiseBuffer = new AudioBuffer({ length, sampleRate: this.ctx.sampleRate });
|
||||
const data = this.noiseBuffer.getChannelData(0);
|
||||
for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
if (this.ctx.state === 'suspended') this.ctx.resume();
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.muted = !this.muted;
|
||||
if (this.master) this.master.gain.value = this.muted ? 0 : MASTER_VOLUME;
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return this.ctx && this.ctx.state === 'running' && !this.muted;
|
||||
}
|
||||
|
||||
/** Avoid stacking dozens of identical sounds in the same instant. */
|
||||
throttle(name, seconds) {
|
||||
const now = this.ctx.currentTime;
|
||||
const last = this.lastPlayed.get(name) ?? -Infinity;
|
||||
if (now - last < seconds) return false;
|
||||
this.lastPlayed.set(name, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
tone({ freq, to = null, type = 'sine', dur = 0.1, gain = 0.4, delay = 0 }) {
|
||||
const ctx = this.ctx;
|
||||
const t = ctx.currentTime + delay;
|
||||
const osc = new OscillatorNode(ctx, { type, frequency: freq });
|
||||
if (to) {
|
||||
osc.frequency.setValueAtTime(freq, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(to, t + dur);
|
||||
}
|
||||
const env = new GainNode(ctx, { gain: 0 });
|
||||
env.gain.setValueAtTime(0.0001, t);
|
||||
env.gain.exponentialRampToValueAtTime(gain, t + 0.004);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
osc.connect(env).connect(this.master);
|
||||
osc.start(t);
|
||||
osc.stop(t + dur + 0.02);
|
||||
}
|
||||
|
||||
noise({ freq = 1000, to = null, q = 1, type = 'bandpass', dur = 0.05, gain = 0.4, delay = 0 }) {
|
||||
const ctx = this.ctx;
|
||||
const t = ctx.currentTime + delay;
|
||||
const src = new AudioBufferSourceNode(ctx, { buffer: this.noiseBuffer });
|
||||
const filter = new BiquadFilterNode(ctx, { type, frequency: freq });
|
||||
filter.Q.value = q;
|
||||
if (to) {
|
||||
filter.frequency.setValueAtTime(freq, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(to, t + dur);
|
||||
}
|
||||
const env = new GainNode(ctx, { gain: 0 });
|
||||
env.gain.setValueAtTime(0.0001, t);
|
||||
env.gain.exponentialRampToValueAtTime(gain, t + 0.003);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
src.connect(filter).connect(env).connect(this.master);
|
||||
src.start(t, Math.random() * 0.5);
|
||||
src.stop(t + dur + 0.02);
|
||||
}
|
||||
|
||||
arpeggio(notes, { type = 'square', step = 0.07, dur = 0.09, gain = 0.18 } = {}) {
|
||||
notes.forEach((freq, i) => this.tone({ freq, type, dur, gain, delay: i * step }));
|
||||
}
|
||||
|
||||
/** React to a game event (see Game.emit). */
|
||||
play(event) {
|
||||
if (!this.ready) return;
|
||||
switch (event.type) {
|
||||
case 'flipper':
|
||||
if (event.up) {
|
||||
this.noise({ freq: 2200, type: 'highpass', dur: 0.03, gain: 0.3 });
|
||||
this.tone({ freq: 120, to: 60, type: 'square', dur: 0.05, gain: 0.18 });
|
||||
} else {
|
||||
this.noise({ freq: 1500, type: 'highpass', dur: 0.02, gain: 0.1 });
|
||||
}
|
||||
break;
|
||||
case 'bumper':
|
||||
this.tone({ freq: 170, to: 70, type: 'triangle', dur: 0.14, gain: 0.55 });
|
||||
this.noise({ freq: 2600, dur: 0.04, gain: 0.35, q: 2 });
|
||||
break;
|
||||
case 'sling':
|
||||
this.noise({ freq: 1300, dur: 0.05, gain: 0.45, q: 1.5 });
|
||||
this.tone({ freq: 95, to: 55, type: 'square', dur: 0.06, gain: 0.2 });
|
||||
break;
|
||||
case 'kicker':
|
||||
case 'rubber':
|
||||
if (this.throttle('rubber', 0.04)) {
|
||||
const g = Math.min(0.35, (event.speed ?? 400) / 5000);
|
||||
this.noise({ freq: 900, dur: 0.03, gain: g, q: 3 });
|
||||
}
|
||||
break;
|
||||
case 'drop':
|
||||
this.tone({ freq: 320, to: 110, type: 'square', dur: 0.08, gain: 0.22 });
|
||||
this.noise({ freq: 500, type: 'lowpass', dur: 0.06, gain: 0.3 });
|
||||
break;
|
||||
case 'dropReset':
|
||||
this.noise({ freq: 700, type: 'lowpass', dur: 0.08, gain: 0.3 });
|
||||
break;
|
||||
case 'standup':
|
||||
this.tone({ freq: 540, to: 270, type: 'triangle', dur: 0.09, gain: 0.35 });
|
||||
break;
|
||||
case 'rollover':
|
||||
if (event.kind === 'outlane') this.tone({ freq: 330, to: 150, type: 'sawtooth', dur: 0.25, gain: 0.18 });
|
||||
else if (event.kind === 'inlane') this.tone({ freq: 660, to: 990, dur: 0.08, gain: 0.3 });
|
||||
else {
|
||||
this.tone({ freq: 880, dur: 0.06, gain: 0.3 });
|
||||
this.tone({ freq: 1320, dur: 0.07, gain: 0.3, delay: 0.06 });
|
||||
}
|
||||
break;
|
||||
case 'complete':
|
||||
this.arpeggio(event.what === 'skill' ? [523, 659, 784, 1047, 1319, 1568] : [523, 659, 784, 1047]);
|
||||
break;
|
||||
case 'launch':
|
||||
this.noise({ freq: 300, to: 3000, type: 'lowpass', dur: 0.25, gain: 0.15 + 0.3 * event.power });
|
||||
break;
|
||||
case 'plungerPull':
|
||||
this.tone({ freq: 90, type: 'sawtooth', dur: 0.05, gain: 0.08 });
|
||||
break;
|
||||
case 'drain':
|
||||
this.tone({ freq: 440, to: 90, type: 'sawtooth', dur: 0.7, gain: 0.22 });
|
||||
break;
|
||||
case 'newBall':
|
||||
this.arpeggio([660, 880], { step: 0.1, type: 'triangle', gain: 0.25 });
|
||||
break;
|
||||
case 'start':
|
||||
this.arpeggio([392, 523, 659, 784], { step: 0.08, gain: 0.2 });
|
||||
break;
|
||||
case 'gameover':
|
||||
this.arpeggio([523, 392, 330, 262], { step: 0.22, dur: 0.22, type: 'triangle', gain: 0.3 });
|
||||
break;
|
||||
case 'kickback':
|
||||
this.tone({ freq: 150, to: 55, type: 'square', dur: 0.12, gain: 0.35 });
|
||||
this.noise({ freq: 450, type: 'lowpass', dur: 0.1, gain: 0.45 });
|
||||
break;
|
||||
case 'nudge':
|
||||
this.noise({ freq: 220, type: 'lowpass', dur: 0.12, gain: 0.4 });
|
||||
break;
|
||||
case 'rampEnter':
|
||||
this.noise({ freq: 500, to: 2200, type: 'bandpass', dur: 0.35, gain: 0.25, q: 0.7 });
|
||||
break;
|
||||
case 'ramp':
|
||||
this.tone({ freq: 700, to: 900, type: 'triangle', dur: 0.1, gain: 0.25 });
|
||||
break;
|
||||
case 'lock':
|
||||
this.arpeggio([440, 660], { step: 0.09, type: 'square', gain: 0.28 });
|
||||
this.noise({ freq: 300, type: 'lowpass', dur: 0.15, gain: 0.3 });
|
||||
break;
|
||||
case 'multiball':
|
||||
this.arpeggio([392, 494, 587, 784, 987], { step: 0.06, type: 'square', gain: 0.32 });
|
||||
this.noise({ freq: 1200, dur: 0.3, gain: 0.4, q: 0.6 });
|
||||
break;
|
||||
case 'jackpot':
|
||||
this.arpeggio([784, 988, 1175], { step: 0.05, type: 'square', gain: 0.35 });
|
||||
break;
|
||||
case 'superJackpot':
|
||||
this.arpeggio([523, 659, 784, 1047, 1319, 1568, 2093], { step: 0.06, type: 'square', gain: 0.4 });
|
||||
this.noise({ freq: 1800, dur: 0.4, gain: 0.35, q: 0.5 });
|
||||
break;
|
||||
case 'scoop':
|
||||
this.tone({ freq: 500, to: 300, type: 'sine', dur: 0.12, gain: 0.2 });
|
||||
break;
|
||||
case 'ballLost':
|
||||
this.tone({ freq: 300, to: 120, type: 'sawtooth', dur: 0.3, gain: 0.15 });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Entry point: game loop, keyboard input and the backbox HUD.
|
||||
|
||||
import { Game, PHYSICS_DT, BALLS_PER_GAME, fmt } from './game.js';
|
||||
import { WIDTH, HEIGHT } from './table.js';
|
||||
import { Renderer } from './render.js';
|
||||
import { Sound } from './audio.js';
|
||||
|
||||
const HIGH_SCORE_KEY = 'neon-pinball-high-score';
|
||||
|
||||
function loadHighScore() {
|
||||
try {
|
||||
return Number(localStorage.getItem(HIGH_SCORE_KEY)) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHighScore(value) {
|
||||
try {
|
||||
localStorage.setItem(HIGH_SCORE_KEY, String(value));
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode, blocked site data); the game still works.
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('table');
|
||||
const game = new Game({ highScore: loadHighScore() });
|
||||
const renderer = new Renderer(canvas, game);
|
||||
const sound = new Sound();
|
||||
let paused = false;
|
||||
|
||||
// ---- Layout --------------------------------------------------------------------------------
|
||||
|
||||
const backbox = document.querySelector('.backbox');
|
||||
|
||||
const playfield = document.querySelector('.playfield');
|
||||
|
||||
// The table is tilted back in 3D (see .playfield-tilt in style.css) for a sense of depth. That
|
||||
// foreshortens its ON-SCREEN height (rotateX + perspective compress the far edge) without changing its
|
||||
// width (the near, bottom edge stays anchored at full width) — so .playfield is sized to the box the
|
||||
// tilted result should actually occupy, while the canvas inside it is rendered taller by this measured
|
||||
// factor so the tilt's own compression brings it back down to fill that box.
|
||||
const TILT_HEIGHT_COMPENSATION = 1.14;
|
||||
|
||||
function layout() {
|
||||
const narrow = window.innerWidth <= 760;
|
||||
const pad = 24;
|
||||
const maxHeight = window.innerHeight - pad;
|
||||
const maxWidth = narrow ? window.innerWidth - pad : window.innerWidth - pad * 2 - backbox.offsetWidth;
|
||||
const visualHeight = Math.max(320, Math.min(maxHeight, (maxWidth * HEIGHT) / WIDTH));
|
||||
const visualWidth = (visualHeight * WIDTH) / HEIGHT;
|
||||
playfield.style.width = `${visualWidth}px`;
|
||||
playfield.style.height = `${visualHeight}px`;
|
||||
renderer.resize(Math.floor(visualHeight * TILT_HEIGHT_COMPENSATION), window.devicePixelRatio || 1);
|
||||
}
|
||||
window.addEventListener('resize', layout);
|
||||
layout();
|
||||
|
||||
// ---- Input ---------------------------------------------------------------------------------
|
||||
|
||||
const KEY_ACTIONS = {
|
||||
KeyA: 'left',
|
||||
ArrowLeft: 'left',
|
||||
KeyD: 'right',
|
||||
ArrowRight: 'right',
|
||||
Space: 'launch',
|
||||
};
|
||||
// Several keys drive the same action, so track which ones are down.
|
||||
const held = { left: new Set(), right: new Set(), launch: new Set() };
|
||||
|
||||
function releaseAll() {
|
||||
for (const [action, keys] of Object.entries(held)) {
|
||||
if (keys.size) {
|
||||
keys.clear();
|
||||
game.setInput(action, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPaused(value) {
|
||||
if (paused === value || (value && game.state !== 'play' && game.state !== 'multiball')) return;
|
||||
paused = value;
|
||||
releaseAll();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
sound.unlock(); // browsers only allow audio to start after a user gesture
|
||||
const action = KEY_ACTIONS[e.code];
|
||||
if (action) {
|
||||
e.preventDefault(); // stop Space/arrow keys from scrolling the page
|
||||
if (e.repeat || paused) return;
|
||||
const keys = held[action];
|
||||
const wasHeld = keys.size > 0;
|
||||
keys.add(e.code);
|
||||
if (!wasHeld) game.setInput(action, true);
|
||||
return;
|
||||
}
|
||||
if (e.code === 'KeyP' || e.code === 'Escape') {
|
||||
setPaused(!paused);
|
||||
} else if (e.code === 'KeyM') {
|
||||
const muted = sound.toggleMute();
|
||||
game.say(muted ? 'SOUND OFF' : 'SOUND ON', '', 1.5);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
const action = KEY_ACTIONS[e.code];
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
const keys = held[action];
|
||||
if (!keys.delete(e.code)) return;
|
||||
if (keys.size === 0) game.setInput(action, false);
|
||||
});
|
||||
|
||||
// Don't leave flippers stuck up when the window loses focus.
|
||||
window.addEventListener('blur', releaseAll);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) setPaused(true);
|
||||
});
|
||||
|
||||
// ---- HUD -----------------------------------------------------------------------------------
|
||||
|
||||
const hud = {
|
||||
score: document.getElementById('score'),
|
||||
message: document.getElementById('message'),
|
||||
sub: document.getElementById('sub'),
|
||||
ball: document.getElementById('ball'),
|
||||
bonus: document.getElementById('bonus'),
|
||||
multiplier: document.getElementById('multiplier'),
|
||||
high: document.getElementById('high'),
|
||||
};
|
||||
const shown = {};
|
||||
|
||||
function setText(key, value) {
|
||||
if (shown[key] !== value) {
|
||||
shown[key] = value;
|
||||
hud[key].textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function idleMessage() {
|
||||
if (paused) return ['PAUSED', 'PRESS P TO RESUME'];
|
||||
switch (game.state) {
|
||||
case 'attract':
|
||||
return ['PRESS SPACE', 'TO START'];
|
||||
case 'gameover':
|
||||
return ['GAME OVER', 'PRESS SPACE TO PLAY AGAIN'];
|
||||
case 'bonus':
|
||||
return ['BALL LOST', ''];
|
||||
case 'multiball': {
|
||||
if (game.jackpot.super) return ['SUPER JACKPOT LIT', 'SHOOT THE LOCK'];
|
||||
const lit = (game.jackpot.left ? 1 : 0) + (game.jackpot.right ? 1 : 0);
|
||||
return ['MULTIBALL', lit > 0 ? 'JACKPOTS LIT ON THE RAMPS' : `${game.balls.length} BALLS IN PLAY`];
|
||||
}
|
||||
default:
|
||||
if (!game.inPlay) return ['LAUNCH!', game.skillShotLive ? 'AIM FOR THE BLINKING LANE' : 'HOLD SPACE TO PLUNGE'];
|
||||
if (game.superBumpers > 0) return ['SUPER BUMPERS', `${Math.ceil(game.superBumpers)} SECONDS`];
|
||||
if (game.locked > 0) return [`BALL ${game.ballNumber}`, `${game.locked}/2 LOCKED`];
|
||||
return [`BALL ${game.ballNumber}`, ''];
|
||||
}
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
setText('score', fmt(game.score));
|
||||
const m = game.message;
|
||||
const [text, sub] = !paused && m.time > 0 ? [m.text, m.sub] : idleMessage();
|
||||
setText('message', text);
|
||||
setText('sub', sub);
|
||||
setText('ball', game.state === 'attract' ? `- / ${BALLS_PER_GAME}` : `${game.ballNumber} / ${BALLS_PER_GAME}`);
|
||||
setText('bonus', fmt(game.bonus));
|
||||
setText('multiplier', `${game.multiplier}X`);
|
||||
setText('high', fmt(game.highScore));
|
||||
}
|
||||
|
||||
// ---- Main loop: fixed physics timestep, interpolated rendering ------------------------------
|
||||
|
||||
let lastTime = null;
|
||||
let accumulator = 0;
|
||||
|
||||
function frame(now) {
|
||||
if (lastTime === null) lastTime = now;
|
||||
let frameTime = (now - lastTime) / 1000;
|
||||
lastTime = now;
|
||||
if (frameTime > 0.25) frameTime = 0.25; // avoid a "spiral of death" after a stall
|
||||
|
||||
if (!paused) {
|
||||
accumulator += frameTime;
|
||||
while (accumulator >= PHYSICS_DT) {
|
||||
game.step(PHYSICS_DT);
|
||||
accumulator -= PHYSICS_DT;
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of game.takeEvents()) {
|
||||
sound.play(event);
|
||||
renderer.onEvent(event);
|
||||
if (event.type === 'gameover' && event.newHighScore) saveHighScore(game.highScore);
|
||||
}
|
||||
|
||||
renderer.paused = paused;
|
||||
renderer.render(paused ? 1 : accumulator / PHYSICS_DT, paused ? 0 : frameTime);
|
||||
updateHud();
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// Handy for debugging from the browser console.
|
||||
window.pinball = { game, renderer, sound };
|
||||
@@ -0,0 +1,465 @@
|
||||
// Minimal 2D physics for a steel ball rolling on an inclined playfield.
|
||||
//
|
||||
// Units are millimetres and seconds. +x points right and +y points DOWN the
|
||||
// table (towards the player), which matches canvas coordinates.
|
||||
//
|
||||
// Collision response follows Matthias Müller's "Ten Minute Physics" pinball
|
||||
// tutorial: push the ball out of the obstacle along the contact normal, then
|
||||
// correct the ball's normal velocity. This version adds speed-dependent
|
||||
// restitution, moving surfaces (flippers) and a tapered flipper shape.
|
||||
|
||||
/** Approach speeds below this (mm/s) are treated as inelastic so the ball can roll and rest without jitter. */
|
||||
const RESTING_SPEED = 30;
|
||||
|
||||
/**
|
||||
* restitution: bounciness at low impact speed (0..1).
|
||||
* falloff: how quickly restitution drops with impact speed: e = restitution / (1 + falloff * speed_in_m_per_s).
|
||||
* friction: Coulomb-style friction coefficient applied to the sliding velocity on impact.
|
||||
*/
|
||||
export function makeMaterial(restitution, falloff = 0, friction = 0) {
|
||||
return { restitution, falloff, friction };
|
||||
}
|
||||
|
||||
/** Result of the most recent successful collision test (reused to avoid allocations in the hot loop). */
|
||||
export const contact = { approach: 0, nx: 0, ny: 0 };
|
||||
|
||||
export class Ball {
|
||||
constructor(radius) {
|
||||
this.radius = radius;
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.prevX = 0;
|
||||
this.prevY = 0;
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
// Riding a ramp or wireform: the track, distance along it, speed along it and height above the playfield.
|
||||
this.track = null;
|
||||
this.s = 0;
|
||||
this.v = 0;
|
||||
this.z = 0;
|
||||
this.prevZ = 0;
|
||||
}
|
||||
|
||||
/** Teleport the ball (no interpolation smear) and stop it. */
|
||||
place(x, y) {
|
||||
this.x = this.prevX = x;
|
||||
this.y = this.prevY = y;
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
this.track = null;
|
||||
this.z = this.prevZ = 0;
|
||||
}
|
||||
|
||||
get speed() {
|
||||
return Math.hypot(this.vx, this.vy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the ball by one fixed step using semi-implicit Euler.
|
||||
* `dampingFactor` is the per-step velocity multiplier (rolling resistance).
|
||||
*/
|
||||
export function integrate(ball, dt, gravity, dampingFactor, maxSpeed) {
|
||||
ball.prevX = ball.x;
|
||||
ball.prevY = ball.y;
|
||||
ball.vy += gravity * dt;
|
||||
ball.vx *= dampingFactor;
|
||||
ball.vy *= dampingFactor;
|
||||
const speed = Math.hypot(ball.vx, ball.vy);
|
||||
if (speed > maxSpeed) {
|
||||
const s = maxSpeed / speed;
|
||||
ball.vx *= s;
|
||||
ball.vy *= s;
|
||||
}
|
||||
ball.x += ball.vx * dt;
|
||||
ball.y += ball.vy * dt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a contact. (nx, ny) is the unit normal pointing from the surface to the ball,
|
||||
* `depth` the penetration, and (svx, svy) the velocity of the surface at the contact point.
|
||||
* Returns the approach speed if an impulse was applied, otherwise 0.
|
||||
*/
|
||||
export function resolveContact(ball, nx, ny, depth, svx, svy, material) {
|
||||
ball.x += nx * depth;
|
||||
ball.y += ny * depth;
|
||||
|
||||
const rvx = ball.vx - svx;
|
||||
const rvy = ball.vy - svy;
|
||||
const vn = rvx * nx + rvy * ny;
|
||||
if (vn >= 0) return 0; // already separating
|
||||
|
||||
const approach = -vn;
|
||||
const e = approach < RESTING_SPEED ? 0 : material.restitution / (1 + (material.falloff * approach) / 1000);
|
||||
const jn = (1 + e) * approach;
|
||||
let dvx = jn * nx;
|
||||
let dvy = jn * ny;
|
||||
|
||||
if (material.friction > 0 && approach >= RESTING_SPEED) {
|
||||
const tx = rvx - vn * nx;
|
||||
const ty = rvy - vn * ny;
|
||||
const ts = Math.hypot(tx, ty);
|
||||
if (ts > 1e-6) {
|
||||
const jt = Math.min(material.friction * jn, ts); // never reverse the sliding direction
|
||||
dvx -= (tx / ts) * jt;
|
||||
dvy -= (ty / ts) * jt;
|
||||
}
|
||||
}
|
||||
|
||||
ball.vx += dvx;
|
||||
ball.vy += dvy;
|
||||
return approach;
|
||||
}
|
||||
|
||||
/** Make sure the ball leaves along (nx, ny) at no less than `speed` (pop bumpers, slingshots). */
|
||||
export function kick(ball, nx, ny, speed) {
|
||||
const vn = ball.vx * nx + ball.vy * ny;
|
||||
if (vn < speed) {
|
||||
ball.vx += (speed - vn) * nx;
|
||||
ball.vy += (speed - vn) * ny;
|
||||
}
|
||||
}
|
||||
|
||||
/** A straight wall with rounded ends (a capsule of the given radius around the segment a→b). */
|
||||
export class Segment {
|
||||
constructor(ax, ay, bx, by, { radius = 0, material, oneWay = false, tag = null } = {}) {
|
||||
this.ax = ax;
|
||||
this.ay = ay;
|
||||
this.bx = bx;
|
||||
this.by = by;
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
this.len = Math.hypot(dx, dy);
|
||||
this.ux = dx / this.len;
|
||||
this.uy = dy / this.len;
|
||||
// Left-hand normal when walking from a to b in screen space. One-way segments only block this side.
|
||||
this.nx = this.uy;
|
||||
this.ny = -this.ux;
|
||||
this.radius = radius;
|
||||
this.material = material;
|
||||
this.oneWay = oneWay;
|
||||
this.tag = tag;
|
||||
this.enabled = true;
|
||||
this.minX = Math.min(ax, bx) - radius;
|
||||
this.maxX = Math.max(ax, bx) + radius;
|
||||
this.minY = Math.min(ay, by) - radius;
|
||||
this.maxY = Math.max(ay, by) + radius;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true (and fills `contact`) if the ball touched the segment. */
|
||||
export function collideSegment(ball, s) {
|
||||
const r = ball.radius;
|
||||
if (ball.x < s.minX - r || ball.x > s.maxX + r || ball.y < s.minY - r || ball.y > s.maxY + r) return false;
|
||||
|
||||
const px = ball.x - s.ax;
|
||||
const py = ball.y - s.ay;
|
||||
let t = px * s.ux + py * s.uy;
|
||||
if (t < 0) t = 0;
|
||||
else if (t > s.len) t = s.len;
|
||||
const dx = ball.x - (s.ax + s.ux * t);
|
||||
const dy = ball.y - (s.ay + s.uy * t);
|
||||
const minDist = r + s.radius;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 >= minDist * minDist) return false;
|
||||
if (s.oneWay && px * s.nx + py * s.ny < 0) return false;
|
||||
|
||||
const d = Math.sqrt(d2);
|
||||
let nx = s.nx;
|
||||
let ny = s.ny;
|
||||
if (d > 1e-9) {
|
||||
nx = dx / d;
|
||||
ny = dy / d;
|
||||
}
|
||||
contact.nx = nx;
|
||||
contact.ny = ny;
|
||||
contact.approach = resolveContact(ball, nx, ny, minDist - d, 0, 0, s.material);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A round post, pop bumper body, etc. */
|
||||
export class Circle {
|
||||
constructor(x, y, radius, { material, tag = null } = {}) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.radius = radius;
|
||||
this.material = material;
|
||||
this.tag = tag;
|
||||
}
|
||||
}
|
||||
|
||||
export function collideCircle(ball, c) {
|
||||
const dx = ball.x - c.x;
|
||||
const dy = ball.y - c.y;
|
||||
const minDist = ball.radius + c.radius;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 >= minDist * minDist) return false;
|
||||
const d = Math.sqrt(d2);
|
||||
const nx = d > 1e-9 ? dx / d : 0;
|
||||
const ny = d > 1e-9 ? dy / d : -1;
|
||||
contact.nx = nx;
|
||||
contact.ny = ny;
|
||||
contact.approach = resolveContact(ball, nx, ny, minDist - d, 0, 0, c.material);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A flipper: a tapered capsule (large circle at the pivot, small circle at the tip) that
|
||||
* rotates between a rest angle and an "up" angle. Angles are radians in screen space
|
||||
* (0 = pointing right, positive = clockwise on screen because +y is down).
|
||||
*/
|
||||
export class Flipper {
|
||||
constructor({ x, y, length, baseRadius, tipRadius, restAngle, upAngle, upSpeed, downSpeed, material }) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.length = length;
|
||||
this.baseRadius = baseRadius;
|
||||
this.tipRadius = tipRadius;
|
||||
this.restAngle = restAngle;
|
||||
this.upAngle = upAngle;
|
||||
this.upSpeed = upSpeed;
|
||||
this.downSpeed = downSpeed;
|
||||
this.material = material;
|
||||
this.angle = restAngle;
|
||||
this.prevAngle = restAngle;
|
||||
this.omega = 0; // angular velocity (rad/s) during the last step
|
||||
this.pressed = false;
|
||||
// Constants for Inigo Quilez's exact 2D uneven-capsule signed distance function.
|
||||
this.b = (baseRadius - tipRadius) / length;
|
||||
this.a = Math.sqrt(1 - this.b * this.b);
|
||||
this._d = { dist: 0, nx: 0, ny: 0 };
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
this.prevAngle = this.angle;
|
||||
const target = this.pressed ? this.upAngle : this.restAngle;
|
||||
const step = (this.pressed ? this.upSpeed : this.downSpeed) * dt;
|
||||
const diff = target - this.angle;
|
||||
this.angle = Math.abs(diff) <= step ? target : this.angle + Math.sign(diff) * step;
|
||||
this.omega = (this.angle - this.prevAngle) / dt;
|
||||
}
|
||||
|
||||
/** 0 at rest, 1 fully up. */
|
||||
get lift() {
|
||||
return (this.angle - this.restAngle) / (this.upAngle - this.restAngle);
|
||||
}
|
||||
|
||||
tipPosition(angle = this.angle) {
|
||||
return { x: this.x + Math.cos(angle) * this.length, y: this.y + Math.sin(angle) * this.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed distance from (px, py) to the flipper surface and the outward surface normal.
|
||||
* Port of sdUnevenCapsule (iquilezles.org/articles/distfunctions2d): capsule along the local
|
||||
* +y axis from (0,0) with radius r1 to (0,h) with radius r2.
|
||||
*/
|
||||
distance(px, py) {
|
||||
const c = Math.cos(this.angle);
|
||||
const s = Math.sin(this.angle);
|
||||
const rx = px - this.x;
|
||||
const ry = py - this.y;
|
||||
const ly = rx * c + ry * s; // along the flipper, pivot → tip
|
||||
const across = -rx * s + ry * c; // perpendicular to the flipper
|
||||
const lx = Math.abs(across);
|
||||
const side = across < 0 ? -1 : 1;
|
||||
const { a, b, length: h } = this;
|
||||
const k = -b * lx + a * ly;
|
||||
let dist;
|
||||
let nlx;
|
||||
let nly;
|
||||
if (k < 0) {
|
||||
const L = Math.hypot(lx, ly);
|
||||
dist = L - this.baseRadius;
|
||||
nlx = L > 1e-9 ? lx / L : 1;
|
||||
nly = L > 1e-9 ? ly / L : 0;
|
||||
} else if (k > a * h) {
|
||||
const qy = ly - h;
|
||||
const L = Math.hypot(lx, qy);
|
||||
dist = L - this.tipRadius;
|
||||
nlx = L > 1e-9 ? lx / L : 1;
|
||||
nly = L > 1e-9 ? qy / L : 0;
|
||||
} else {
|
||||
dist = a * lx + b * ly - this.baseRadius;
|
||||
nlx = a;
|
||||
nly = b;
|
||||
}
|
||||
nlx *= side;
|
||||
const out = this._d;
|
||||
out.dist = dist;
|
||||
out.nx = nly * c - nlx * s;
|
||||
out.ny = nly * s + nlx * c;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export function collideFlipper(ball, f) {
|
||||
const dx = ball.x - f.x;
|
||||
const dy = ball.y - f.y;
|
||||
const reach = f.length + f.baseRadius + ball.radius;
|
||||
if (dx * dx + dy * dy > reach * reach) return false;
|
||||
|
||||
const { dist, nx, ny } = f.distance(ball.x, ball.y);
|
||||
const pen = ball.radius - dist;
|
||||
if (pen <= 0) return false;
|
||||
|
||||
// Velocity of the flipper surface at the contact point: omega x r.
|
||||
const qx = ball.x - nx * dist;
|
||||
const qy = ball.y - ny * dist;
|
||||
const svx = -f.omega * (qy - f.y);
|
||||
const svy = f.omega * (qx - f.x);
|
||||
|
||||
contact.nx = nx;
|
||||
contact.ny = ny;
|
||||
contact.approach = resolveContact(ball, nx, ny, pen, svx, svy, f.material);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ball-to-ball collision between two equal-mass balls (multiball). This is the Ten Minute Physics
|
||||
* handleBallBallCollision formula with m1 = m2, skipping the impulse when the balls already separate.
|
||||
*/
|
||||
export function collideBalls(a, b, restitution) {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const minDist = a.radius + b.radius;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 === 0 || d2 >= minDist * minDist) return 0;
|
||||
const d = Math.sqrt(d2);
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
const corr = (minDist - d) / 2;
|
||||
a.x -= nx * corr;
|
||||
a.y -= ny * corr;
|
||||
b.x += nx * corr;
|
||||
b.y += ny * corr;
|
||||
const v1 = a.vx * nx + a.vy * ny;
|
||||
const v2 = b.vx * nx + b.vy * ny;
|
||||
if (v1 - v2 <= 0) return 0;
|
||||
const newV1 = (v1 + v2 - (v1 - v2) * restitution) / 2;
|
||||
const newV2 = (v1 + v2 - (v2 - v1) * restitution) / 2;
|
||||
a.vx += nx * (newV1 - v1);
|
||||
a.vy += ny * (newV1 - v1);
|
||||
b.vx += nx * (newV2 - v2);
|
||||
b.vy += ny * (newV2 - v2);
|
||||
return v1 - v2;
|
||||
}
|
||||
|
||||
/** Uniform Catmull-Rom spline through [x, y, z] control points, sampled `steps` times per span. */
|
||||
function catmullRom(points, steps) {
|
||||
const out = [];
|
||||
const p = (i) => points[Math.max(0, Math.min(points.length - 1, i))];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = p(i - 1);
|
||||
const p1 = p(i);
|
||||
const p2 = p(i + 1);
|
||||
const p3 = p(i + 2);
|
||||
for (let k = 0; k < steps; k++) {
|
||||
const t = k / steps;
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
out.push(
|
||||
[0, 1, 2].map(
|
||||
(c) =>
|
||||
0.5 *
|
||||
(2 * p1[c] +
|
||||
(-p0[c] + p2[c]) * t +
|
||||
(2 * p0[c] - 5 * p1[c] + 4 * p2[c] - p3[c]) * t2 +
|
||||
(-p0[c] + 3 * p1[c] - 3 * p2[c] + p3[c]) * t3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push([...points[points.length - 1]]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ramp or wireform the ball rides above the playfield. The ball is treated as a bead on a wire:
|
||||
* its state is a distance along a smooth path and a speed along it. The path climbs and falls through
|
||||
* an elevation profile (z, mm above the playfield); the climb plus the table's own slope decide whether
|
||||
* a shot makes it or rolls back out of the entrance, as a weak shot does on a real ramp.
|
||||
*/
|
||||
export class Track {
|
||||
constructor(controlPoints, { name, kind = 'wire', width = 40, steps = 10 } = {}) {
|
||||
this.name = name;
|
||||
this.kind = kind; // 'ramp' (plastic) or 'wire' (habitrail)
|
||||
this.width = width;
|
||||
this.points = catmullRom(controlPoints, steps);
|
||||
this.cum = [0];
|
||||
for (let i = 1; i < this.points.length; i++) {
|
||||
const [ax, ay] = this.points[i - 1];
|
||||
const [bx, by] = this.points[i];
|
||||
this.cum.push(this.cum[i - 1] + Math.hypot(bx - ax, by - ay));
|
||||
}
|
||||
this.length = this.cum[this.cum.length - 1];
|
||||
this._s = { x: 0, y: 0, z: 0, tx: 0, ty: 0, slope: 0 };
|
||||
}
|
||||
|
||||
/** Position, height, unit in-plane tangent and climb (dz per mm of plan distance) at distance s. */
|
||||
sample(s) {
|
||||
const cum = this.cum;
|
||||
s = Math.max(0, Math.min(this.length, s));
|
||||
let lo = 0;
|
||||
let hi = cum.length - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (cum[mid] <= s) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const [ax, ay, az] = this.points[lo];
|
||||
const [bx, by, bz] = this.points[hi];
|
||||
const len = cum[hi] - cum[lo] || 1;
|
||||
const t = (s - cum[lo]) / len;
|
||||
const out = this._s;
|
||||
out.x = ax + (bx - ax) * t;
|
||||
out.y = ay + (by - ay) * t;
|
||||
out.z = az + (bz - az) * t;
|
||||
out.tx = (bx - ax) / len;
|
||||
out.ty = (by - ay) / len;
|
||||
out.slope = (bz - az) / len;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a ball riding a track by one step. `gTable` is gravity along the playfield (towards the
|
||||
* player), `gUp` gravity perpendicular to it (what a ramp climbs against), `friction` a rolling
|
||||
* deceleration. Returns 'end' or 'start' when the ball leaves the track, otherwise null.
|
||||
*/
|
||||
export function advanceOnTrack(ball, dt, gTable, gUp, friction) {
|
||||
const track = ball.track;
|
||||
let p = track.sample(ball.s);
|
||||
const k = Math.sqrt(1 + p.slope * p.slope);
|
||||
// Gravity along the 3D path direction (tx, ty, slope) / k.
|
||||
let a = (gTable * p.ty - gUp * p.slope) / k;
|
||||
if (ball.v > 0) a -= friction;
|
||||
else if (ball.v < 0) a += friction;
|
||||
ball.prevX = ball.x;
|
||||
ball.prevY = ball.y;
|
||||
ball.prevZ = ball.z;
|
||||
ball.v += a * dt;
|
||||
ball.s += (ball.v * dt) / k;
|
||||
if (ball.s >= track.length) {
|
||||
ball.s = track.length;
|
||||
p = track.sample(ball.s);
|
||||
return 'end';
|
||||
}
|
||||
if (ball.s <= 0) {
|
||||
ball.s = 0;
|
||||
return 'start';
|
||||
}
|
||||
p = track.sample(ball.s);
|
||||
ball.x = p.x;
|
||||
ball.y = p.y;
|
||||
ball.z = p.z;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does the segment p→q cross the segment a→b? (Used by tests to detect tunnelling.) */
|
||||
export function segmentsCross(px, py, qx, qy, ax, ay, bx, by) {
|
||||
const d1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||
const d2 = (bx - ax) * (qy - ay) - (by - ay) * (qx - ax);
|
||||
const d3 = (qx - px) * (ay - py) - (qy - py) * (ax - px);
|
||||
const d4 = (qx - px) * (by - py) - (qy - py) * (bx - px);
|
||||
return d1 * d2 < 0 && d3 * d4 < 0;
|
||||
}
|
||||
+1157
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
// Table layout at real-world scale, in millimetres (+y points down the table).
|
||||
//
|
||||
// Reference dimensions (see README for sources):
|
||||
// playfield 20.25" x 42" -> 514 x 1067 mm
|
||||
// ball 1-1/16" -> 27 mm diameter
|
||||
// flippers: pivots 7" apart (centre to centre), 3.25" overall with rubber,
|
||||
// rest 31 deg below horizontal, 20 deg above when raised (Visual Pinball
|
||||
// defaults of 121 / 70 deg measured clockwise from 12 o'clock).
|
||||
|
||||
import { Segment, Circle, Flipper, Track, makeMaterial } from './physics.js';
|
||||
|
||||
export const WIDTH = 514;
|
||||
export const HEIGHT = 1067;
|
||||
export const BALL_RADIUS = 13.5;
|
||||
/** Once the ball's centre passes this line it has gone under the apron: drained. */
|
||||
export const DRAIN_Y = 1000;
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
export const MATERIALS = {
|
||||
wall: makeMaterial(0.45, 0.25, 0.04),
|
||||
rubber: makeMaterial(0.8, 0.3, 0.08),
|
||||
flipper: makeMaterial(0.8, 0.43, 0.05),
|
||||
target: makeMaterial(0.35, 0.2, 0.04),
|
||||
plunger: makeMaterial(0.1, 0, 0),
|
||||
};
|
||||
|
||||
// Side walls and shooter lane.
|
||||
export const LEFT = 12;
|
||||
export const RIGHT = 464; // playfield face of the shooter-lane divider
|
||||
export const CX = (LEFT + RIGHT) / 2; // flipper centreline
|
||||
export const LANE_LEFT = 470;
|
||||
export const LANE_RIGHT = 502;
|
||||
export const SHOOTER_X = (LANE_LEFT + LANE_RIGHT) / 2;
|
||||
export const PLUNGER_REST_Y = 1000; // top of the plunger tip at rest
|
||||
export const PLUNGER_TRAVEL = 60; // how far the plunger can be pulled back
|
||||
export const LANE_TOP = 292; // top of the shooter-lane divider
|
||||
export const ARCH = { x: 257, y: 257, r: 245 };
|
||||
|
||||
// Flippers.
|
||||
export const FLIPPER_Y = 930;
|
||||
export const FLIPPER_SPACING = 177.8; // 7" between pivots
|
||||
export const FLIPPER_GEOMETRY = {
|
||||
length: 64, // pivot-centre to tip-centre: 64 + 11.5 + 7 = 82.5 mm = 3.25" overall
|
||||
baseRadius: 11.5, // VPX default 21.5 VP units ~ 11.6 mm
|
||||
tipRadius: 7, // VPX default 13 VP units ~ 7.0 mm
|
||||
restAngle: 31 * DEG,
|
||||
upAngle: -20 * DEG,
|
||||
};
|
||||
|
||||
const GUIDE_RADIUS = 3; // half-thickness of the metal lane guides
|
||||
const SLING_RADIUS = 5; // half-thickness of the slingshot rubber
|
||||
const LANE_CLEAR = 32; // clear lane width for the 27 mm ball
|
||||
|
||||
function mirrorX(x) {
|
||||
return 2 * CX - x;
|
||||
}
|
||||
|
||||
function pushPolyline(walls, points, options) {
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const [ax, ay] = points[i];
|
||||
const [bx, by] = points[i + 1];
|
||||
walls.push(new Segment(ax, ay, bx, by, options));
|
||||
}
|
||||
}
|
||||
|
||||
function arcPoints(cx, cy, r, fromDeg, toDeg, steps) {
|
||||
const pts = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = (fromDeg + ((toDeg - fromDeg) * i) / steps) * DEG;
|
||||
pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Lower playfield for one side: flipper, inlane/outlane guide and slingshot. side = -1 (left) or +1 (right). */
|
||||
function buildLowerSide(side) {
|
||||
const mx = side < 0 ? (x) => x : mirrorX; // everything is designed on the left and mirrored
|
||||
const pivotX = CX - FLIPPER_SPACING / 2;
|
||||
const g = FLIPPER_GEOMETRY;
|
||||
const d = { x: Math.cos(g.restAngle), y: Math.sin(g.restAngle) }; // along the resting flipper
|
||||
const n = { x: d.y, y: -d.x }; // upper normal of the resting flipper
|
||||
|
||||
// Inlane guide: meets the top of the flipper's base circle so the ball rolls straight on to the flipper.
|
||||
const endX = pivotX + n.x * (g.baseRadius - GUIDE_RADIUS);
|
||||
const endY = FLIPPER_Y + n.y * (g.baseRadius - GUIDE_RADIUS);
|
||||
const dividerX = LEFT + LANE_CLEAR - 1 + GUIDE_RADIUS; // outlane | inlane divider
|
||||
const t = (endX - dividerX) / d.x;
|
||||
const bendY = endY - d.y * t;
|
||||
const dividerTop = 700;
|
||||
const guide = [
|
||||
[dividerX, dividerTop],
|
||||
[dividerX, bendY],
|
||||
[endX, endY],
|
||||
];
|
||||
|
||||
// Slingshot: left edge parallel to the divider, bottom edge parallel to the inlane guide.
|
||||
const offset = GUIDE_RADIUS + LANE_CLEAR + SLING_RADIUS;
|
||||
const slingX = dividerX + offset;
|
||||
const lineX = dividerX + n.x * offset;
|
||||
const lineY = bendY + n.y * offset;
|
||||
const blY = lineY + ((slingX - lineX) * d.y) / d.x;
|
||||
const brX = 152;
|
||||
const brY = lineY + ((brX - lineX) * d.y) / d.x;
|
||||
const top = [slingX, 760];
|
||||
const bl = [slingX, blY];
|
||||
const br = [brX, brY];
|
||||
|
||||
const flipper = new Flipper({
|
||||
x: mx(pivotX),
|
||||
y: FLIPPER_Y,
|
||||
length: g.length,
|
||||
baseRadius: g.baseRadius,
|
||||
tipRadius: g.tipRadius,
|
||||
restAngle: side < 0 ? g.restAngle : Math.PI - g.restAngle,
|
||||
upAngle: side < 0 ? g.upAngle : Math.PI - g.upAngle,
|
||||
upSpeed: 38,
|
||||
downSpeed: 16,
|
||||
material: MATERIALS.flipper,
|
||||
});
|
||||
flipper.side = side < 0 ? 'left' : 'right';
|
||||
|
||||
const m = (p) => [mx(p[0]), p[1]];
|
||||
const sling = {
|
||||
side: flipper.side,
|
||||
// Keep vertices in a consistent winding (top, bottom corner, flipper-side corner).
|
||||
top: m(top),
|
||||
bottom: m(bl),
|
||||
tip: m(br),
|
||||
flash: 0,
|
||||
};
|
||||
|
||||
const inlaneX = (dividerX + GUIDE_RADIUS + slingX - SLING_RADIUS) / 2;
|
||||
const outlaneX = (LEFT + dividerX - GUIDE_RADIUS) / 2;
|
||||
|
||||
return {
|
||||
flipper,
|
||||
guide: guide.map(m),
|
||||
dividerPost: { x: mx(dividerX), y: dividerTop },
|
||||
sling,
|
||||
inlane: { x: mx(inlaneX), y: 800 },
|
||||
outlane: { x: mx(outlaneX), y: 790 },
|
||||
};
|
||||
}
|
||||
|
||||
// Ramp/wireform crossover: the ball is flipped hard up the outer corridor (between the side wall and
|
||||
// the slingshot), climbs a plastic ramp over the pop bumpers, and a wire habitrail carries it back down
|
||||
// to the OPPOSITE inlane — the classic crossover layout used on real tables so a good ramp shot feeds the
|
||||
// other flipper for a continuous rhythm (Mission Pinball Framework's playfield-layout notes: ball guides
|
||||
// into an orbit/ramp should return the ball toward a flipper).
|
||||
const RAMP_HEIGHT = 46; // mm above the playfield at the crest — a plastic ramp's typical rise
|
||||
export const RAMP_ENTRY_SPEED = 1250; // mm/s of upward speed needed to climb in, below that it rolls back out
|
||||
|
||||
/**
|
||||
* One ramp for side = -1 (left) or +1 (right); left is fed by the left flipper, right by the right.
|
||||
*
|
||||
* The entrance position below was not guessed: a hard flip off a cradled ball was simulated across a
|
||||
* wide sweep of hold times and the full trajectory logged, then searched for a spot that only a specific,
|
||||
* contiguous band of hold times actually passes through fast and upward. Putting the mouth right at the
|
||||
* flipper's own tip (the obvious first guess) caught nearly every flip regardless of timing, because
|
||||
* that's the one point every hard shot passes through on the way out — there's no aiming to it. A point
|
||||
* well downrange, past where a "snap" flip has already peeled off in a different direction, is only
|
||||
* reached by holding the flipper for roughly a beat before releasing — a real, learnable technique,
|
||||
* not a hidden auto-capture.
|
||||
*/
|
||||
function buildRamp(side) {
|
||||
const mx = side < 0 ? (x) => x : mirrorX;
|
||||
const g = FLIPPER_GEOMETRY;
|
||||
const pivotX = CX - FLIPPER_SPACING / 2;
|
||||
const upAngle = g.upAngle;
|
||||
const tipUpX = pivotX + Math.cos(upAngle) * g.length;
|
||||
const tipUpY = FLIPPER_Y + Math.sin(upAngle) * g.length;
|
||||
const clear = g.tipRadius + 15;
|
||||
const returnX = tipUpX + Math.cos(upAngle) * clear;
|
||||
const returnY = tipUpY + Math.sin(upAngle) * clear;
|
||||
|
||||
// Control points designed on the left and mirrored; (x, y, z) in mm, z = height above the playfield.
|
||||
// Entrance first — well past the flipper, where the timing-dependent shot lands — then up and over
|
||||
// the pop bumpers, then back down to the natural post-flip point above the SAME flipper (the return,
|
||||
// which needs no special aim: any ball rolling off the ramp lands right where the flipper already is).
|
||||
const raw = [
|
||||
[350, 742, 12], // entrance: reached only by holding the flip for roughly a beat, not a snap flip
|
||||
[415, 540, 30],
|
||||
[400, 380, RAMP_HEIGHT], // crest, arcing up over the pop bumpers, clear of the kicker bulge
|
||||
[330, 260, RAMP_HEIGHT],
|
||||
[190, 195, RAMP_HEIGHT - 2], // wireform: the highest, flattest part of the loop
|
||||
[80, 250, 36],
|
||||
[40, 400, 18],
|
||||
[32, 580, 5],
|
||||
[42, 740, 0],
|
||||
[120, 850, 0],
|
||||
[returnX, returnY, 0], // return, feeding back to the same flipper right where it naturally throws the ball
|
||||
];
|
||||
const points = raw.map(([x, y, z]) => [mx(x), y, Math.max(0, z)]);
|
||||
const track = new Track(points, { name: `${side < 0 ? 'left' : 'right'}Ramp`, kind: 'ramp', width: 40, steps: 14 });
|
||||
track.side = side < 0 ? 'left' : 'right';
|
||||
track.color = side < 0 ? '#ff3fa4' : '#34e7ff';
|
||||
const p0 = track.sample(0);
|
||||
|
||||
return {
|
||||
track,
|
||||
entrance: { x: p0.x, y: p0.y, dirx: 0, diry: -1, radius: 22, minSpeed: RAMP_ENTRY_SPEED },
|
||||
};
|
||||
}
|
||||
|
||||
/** Center lock/spinner scoop, sitting just below the pop bumper triangle where a straight shot up the
|
||||
* middle reaches it directly — it holds balls to build a multiball, then jackpots run on the ramps
|
||||
* once multiball starts. */
|
||||
function buildLockScoop() {
|
||||
return { x: CX + 12, y: 375, radius: 19, captureSpeed: 500, cooldown: 0 };
|
||||
}
|
||||
|
||||
/** Build every static and interactive element of the table. */
|
||||
export function createTable() {
|
||||
const walls = [];
|
||||
const posts = [];
|
||||
|
||||
// Outer boundary: left wall with a rubber "kicker" bulge, the top arch, and the shooter lane's outer wall.
|
||||
const bottom = HEIGHT + 60;
|
||||
const leftWall = [
|
||||
[LEFT, bottom],
|
||||
[LEFT, 650],
|
||||
[70, 480],
|
||||
[LEFT, 380],
|
||||
[LEFT, ARCH.y],
|
||||
];
|
||||
const arch = arcPoints(ARCH.x, ARCH.y, ARCH.r, 180, 360, 72);
|
||||
const outline = [...leftWall, ...arch.slice(1), [LANE_RIGHT, bottom]];
|
||||
pushPolyline(walls, leftWall.slice(0, 2), { material: MATERIALS.wall });
|
||||
pushPolyline(walls, leftWall.slice(1, 3), { material: MATERIALS.wall });
|
||||
pushPolyline(walls, leftWall.slice(2, 4), { material: MATERIALS.rubber, tag: 'kicker' });
|
||||
pushPolyline(walls, leftWall.slice(3), { material: MATERIALS.wall });
|
||||
pushPolyline(walls, [leftWall[4], ...arch.slice(1), [LANE_RIGHT, bottom]], { material: MATERIALS.wall });
|
||||
|
||||
// Shooter-lane divider (with a mirrored kicker bulge on its playfield side).
|
||||
const divider = [
|
||||
[RIGHT, LANE_TOP],
|
||||
[RIGHT, 380],
|
||||
[mirrorX(70), 480],
|
||||
[RIGHT, 650],
|
||||
[RIGHT, bottom],
|
||||
[LANE_LEFT, bottom],
|
||||
[LANE_LEFT, LANE_TOP],
|
||||
];
|
||||
pushPolyline(walls, divider.slice(0, 2), { material: MATERIALS.wall });
|
||||
pushPolyline(walls, divider.slice(1, 3), { material: MATERIALS.rubber, tag: 'kicker' });
|
||||
pushPolyline(walls, divider.slice(2, 5), { material: MATERIALS.wall });
|
||||
pushPolyline(walls, divider.slice(5), { material: MATERIALS.wall });
|
||||
posts.push(new Circle((RIGHT + LANE_LEFT) / 2, LANE_TOP, (LANE_LEFT - RIGHT) / 2, { material: MATERIALS.wall }));
|
||||
posts.push(new Circle(70, 480, 6, { material: MATERIALS.rubber, tag: 'kickerPost' }));
|
||||
posts.push(new Circle(mirrorX(70), 480, 6, { material: MATERIALS.rubber, tag: 'kickerPost' }));
|
||||
|
||||
// One-way gate at the top of the shooter lane: the ball can leave the lane but not fall back in.
|
||||
const gate = new Segment(RIGHT + 3, LANE_TOP, LANE_RIGHT, ARCH.y, {
|
||||
radius: 1.5,
|
||||
material: MATERIALS.wall,
|
||||
oneWay: true,
|
||||
tag: 'gate',
|
||||
});
|
||||
gate.swing = 0; // visual only
|
||||
walls.push(gate);
|
||||
|
||||
// Top rollover lanes.
|
||||
const laneGuideXs = [178, 226, 274, 322];
|
||||
const laneTop = 85;
|
||||
const laneBottom = 140;
|
||||
for (const x of laneGuideXs) {
|
||||
walls.push(new Segment(x, laneTop, x, laneBottom, { radius: GUIDE_RADIUS, material: MATERIALS.wall }));
|
||||
posts.push(new Circle(x, laneTop, 5, { material: MATERIALS.rubber }));
|
||||
}
|
||||
const rollovers = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = (laneGuideXs[i] + laneGuideXs[i + 1]) / 2;
|
||||
rollovers.push({ kind: 'top', index: i, x, y: 118, radius: 14, inside: false, flash: 0 });
|
||||
}
|
||||
|
||||
// Pop bumpers.
|
||||
const bumpers = [
|
||||
{ x: 195, y: 215 },
|
||||
{ x: 305, y: 215 },
|
||||
{ x: 250, y: 300 },
|
||||
].map((b, i) => ({ ...b, index: i, radius: 24, flash: 0, cooldown: 0 }));
|
||||
|
||||
// Lower playfield, both sides.
|
||||
const lowers = [buildLowerSide(-1), buildLowerSide(1)];
|
||||
const flippers = lowers.map((l) => l.flipper);
|
||||
const guides = lowers.map((l) => l.guide);
|
||||
const slings = lowers.map((l) => l.sling);
|
||||
for (const l of lowers) {
|
||||
pushPolyline(walls, l.guide, { radius: GUIDE_RADIUS, material: MATERIALS.wall });
|
||||
posts.push(new Circle(l.dividerPost.x, l.dividerPost.y, 6, { material: MATERIALS.rubber }));
|
||||
rollovers.push({ kind: 'inlane', side: l.flipper.side, x: l.inlane.x, y: l.inlane.y, radius: 14, inside: false, flash: 0 });
|
||||
rollovers.push({ kind: 'outlane', side: l.flipper.side, x: l.outlane.x, y: l.outlane.y, radius: 14, inside: false, flash: 0 });
|
||||
}
|
||||
for (const s of slings) {
|
||||
const opts = { radius: SLING_RADIUS, material: MATERIALS.rubber };
|
||||
s.segments = [
|
||||
new Segment(...s.top, ...s.bottom, opts),
|
||||
new Segment(...s.bottom, ...s.tip, opts),
|
||||
new Segment(...s.tip, ...s.top, { ...opts, tag: 'sling' }),
|
||||
];
|
||||
s.face = s.segments[2];
|
||||
// Outward normal of the kicking face (points towards the middle of the table).
|
||||
const fx = s.tip[0] - s.top[0];
|
||||
const fy = s.tip[1] - s.top[1];
|
||||
const len = Math.hypot(fx, fy);
|
||||
const nx = -fy / len;
|
||||
const ny = fx / len;
|
||||
const inward = (CX - s.top[0]) * nx > 0 ? 1 : -1;
|
||||
s.nx = nx * inward;
|
||||
s.ny = ny * inward;
|
||||
}
|
||||
|
||||
// Flanking the pop bumpers on each side: a bank of three drop targets on the left, three stand-up
|
||||
// targets on the right, both well clear of the ramps that arc above them.
|
||||
const dropTargets = bankOnFace([25, 200], [75, 340], 'drop');
|
||||
const standups = bankOnFace([mirrorX(25), 200], [mirrorX(75), 340], 'standup');
|
||||
|
||||
// Plunger tip (moves with the plunger).
|
||||
const plunger = new Segment(LANE_LEFT, PLUNGER_REST_Y, LANE_RIGHT, PLUNGER_REST_Y, { material: MATERIALS.plunger, tag: 'plunger' });
|
||||
|
||||
// Ramps (with wireform returns) and the center lock scoop.
|
||||
const ramps = [buildRamp(-1), buildRamp(1)];
|
||||
const lock = buildLockScoop();
|
||||
|
||||
return {
|
||||
walls,
|
||||
posts,
|
||||
gate,
|
||||
bumpers,
|
||||
slings,
|
||||
flippers,
|
||||
guides,
|
||||
rollovers,
|
||||
dropTargets,
|
||||
standups,
|
||||
plunger,
|
||||
ramps,
|
||||
lock,
|
||||
outline,
|
||||
divider,
|
||||
laneGuideXs,
|
||||
laneTop,
|
||||
laneBottom,
|
||||
};
|
||||
}
|
||||
|
||||
/** Three targets evenly spaced along the wall face a→b, standing slightly proud of it. */
|
||||
function bankOnFace(a, b, kind) {
|
||||
const fx = b[0] - a[0];
|
||||
const fy = b[1] - a[1];
|
||||
const len = Math.hypot(fx, fy);
|
||||
const ux = fx / len;
|
||||
const uy = fy / len;
|
||||
// Normal pointing into the playfield (towards the flipper centreline).
|
||||
let nx = -uy;
|
||||
let ny = ux;
|
||||
if ((CX - a[0]) * nx < 0) {
|
||||
nx = -nx;
|
||||
ny = -ny;
|
||||
}
|
||||
const targets = [];
|
||||
const midX = (a[0] + b[0]) / 2;
|
||||
const midY = (a[1] + b[1]) / 2;
|
||||
const halfWidth = 14;
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const cx = midX + ux * i * 38 + nx * 6;
|
||||
const cy = midY + uy * i * 38 + ny * 6;
|
||||
const seg = new Segment(cx - ux * halfWidth, cy - uy * halfWidth, cx + ux * halfWidth, cy + uy * halfWidth, {
|
||||
radius: 3,
|
||||
material: MATERIALS.target,
|
||||
tag: kind,
|
||||
});
|
||||
targets.push({ kind, index: i + 1, x: cx, y: cy, ux, uy, nx, ny, halfWidth, segment: seg, down: false, lit: false, drop: 0, flash: 0 });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
Reference in New Issue
Block a user