initial Game

This commit is contained in:
christopher
2026-09-12 01:33:07 -04:00
commit a740d2f141
15 changed files with 4275 additions and 0 deletions
+194
View File
@@ -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;
}
}
}
+765
View File
@@ -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');
}
+209
View File
@@ -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 };
+465
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+377
View File
@@ -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;
}