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
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neon Pinball</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎱</text></svg>">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="cabinet">
<div class="playfield">
<div class="playfield-tilt">
<canvas id="table" aria-label="Pinball table"></canvas>
</div>
</div>
<aside class="backbox">
<h1 class="marquee">NEON <span>PINBALL</span></h1>
<section class="dmd" aria-live="polite">
<div class="dmd-score" id="score">0</div>
<div class="dmd-message" id="message">PRESS SPACE</div>
<div class="dmd-sub" id="sub">TO START</div>
</section>
<dl class="stats">
<div><dt>Ball</dt><dd id="ball">1 / 3</dd></div>
<div><dt>Bonus</dt><dd id="bonus">0</dd></div>
<div><dt>Multiplier</dt><dd id="multiplier">1X</dd></div>
<div><dt>High score</dt><dd id="high">0</dd></div>
</dl>
<section class="panel">
<h2>Controls</h2>
<ul class="keys">
<li><span><kbd>A</kbd> <kbd>&larr;</kbd></span> Left flipper</li>
<li><span><kbd>D</kbd> <kbd>&rarr;</kbd></span> Right flipper</li>
<li><span><kbd>Space</kbd></span> Hold to pull the plunger, release to launch</li>
<li><span><kbd>P</kbd></span> Pause</li>
<li><span><kbd>M</kbd></span> Sound on/off</li>
</ul>
</section>
<section class="panel">
<h2>How to score</h2>
<ul class="rules">
<li><b>Skill shot:</b> give a soft plunge (short hold) so the ball drops into the top lanes, and steer the blinking lane under it with the flippers: 25,000. A long hold sends the ball round the orbit instead.</li>
<li><b>Ramps:</b> a hard flipper shot up either outer lane climbs the ramp and crosses the wireform to the far side — feed it too weak and it rolls back out.</li>
<li><b>Lock &amp; multiball:</b> shoot the center scoop to lock a ball (it doesn't cost you a turn); lock two and the next scoop shot starts 3-ball multiball with jackpots lit on both ramps.</li>
<li><b>Jackpots:</b> collect both ramp jackpots during multiball to light the Super Jackpot at the scoop.</li>
<li><b>Top lanes:</b> light all three to raise the bonus multiplier (up to 5X). Flippers shift the lit lanes.</li>
<li><b>Drop targets:</b> knock down all three for Super Bumpers (1,000 a hit) and to relight the kickback.</li>
<li><b>Kickback:</b> when lit, it fires a ball in the left outlane back into play.</li>
<li><b>Stand-up targets:</b> light all three for 15,000.</li>
<li>The bonus &times; multiplier is added when each ball drains. Three balls per game.</li>
</ul>
</section>
</aside>
</main>
<script type="module" src="js/main.js"></script>
</body>
</html>
+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;
}
+233
View File
@@ -0,0 +1,233 @@
:root {
--bg: #07060d;
--panel: #12101f;
--panel-edge: #2a2446;
--text: #d9d4f2;
--muted: #8d86b3;
--dmd: #ff9d2e;
--dmd-dim: rgba(255, 157, 46, 0.12);
--accent: #ff3fa4;
--accent-2: #34e7ff;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body {
background:
radial-gradient(ellipse at 30% 0%, #1d1440 0%, transparent 60%),
radial-gradient(ellipse at 90% 100%, #0f2a44 0%, transparent 55%),
var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
}
.cabinet {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
height: 100%;
padding: 12px;
}
/* A slight downward camera angle onto the table, like looking at a cabinet from in front of it: the
canvas still renders the flat, straight-down game exactly as the physics sees it — this tilts that
finished picture in 3D space, so no game or input coordinate changes with it.
.playfield is sized (by main.js) to the box the tilted table should visually occupy; .playfield-tilt
is positioned absolutely within it and holds a canvas rendered taller than that box on purpose — the
tilt's own perspective foreshortening compresses it back down to fit, bottom edge anchored in place. */
.playfield {
flex: none;
position: relative;
perspective: 1900px;
perspective-origin: 50% 15%;
}
.playfield-tilt {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%) rotateX(28deg);
transform-origin: 50% 100%;
line-height: 0;
border-radius: 14px;
box-shadow:
0 0 0 2px #2b2150,
0 0 50px rgba(120, 70, 255, 0.4),
0 45px 70px rgba(0, 0, 0, 0.7);
overflow: hidden;
}
canvas {
display: block;
}
.backbox {
flex: none;
width: 300px;
max-height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.marquee {
margin: 0;
font-size: 34px;
font-weight: 900;
letter-spacing: 0.08em;
line-height: 1;
color: #fff;
text-shadow: 0 0 6px var(--accent), 0 0 22px var(--accent);
}
.marquee span {
color: var(--accent-2);
text-shadow: 0 0 6px var(--accent-2), 0 0 22px var(--accent-2);
}
.dmd {
background-color: #120a02;
background-image: radial-gradient(circle, rgba(255, 157, 46, 0.09) 1px, transparent 1.3px);
background-size: 4px 4px;
border: 2px solid #3b2508;
border-radius: 8px;
padding: 12px 14px;
text-align: center;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: var(--dmd);
text-shadow: 0 0 4px var(--dmd), 0 0 12px rgba(255, 140, 20, 0.6);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.8);
}
.dmd-score {
font-size: 34px;
font-weight: 800;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.dmd-message {
font-size: 18px;
font-weight: 700;
min-height: 1.4em;
letter-spacing: 0.1em;
}
.dmd-sub {
font-size: 12px;
min-height: 1.4em;
letter-spacing: 0.12em;
opacity: 0.85;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0;
}
.stats div {
background: var(--panel);
border: 1px solid var(--panel-edge);
border-radius: 8px;
padding: 6px 10px;
}
.stats dt {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.stats dd {
margin: 0;
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.panel {
background: var(--panel);
border: 1px solid var(--panel-edge);
border-radius: 8px;
padding: 10px 12px;
}
.panel h2 {
margin: 0 0 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.panel ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 5px;
font-size: 13px;
}
.keys li {
display: flex;
gap: 8px;
align-items: baseline;
}
.keys li span {
flex: none;
min-width: 70px;
}
.rules li {
color: var(--text);
}
.rules b {
color: var(--accent-2);
font-weight: 600;
}
kbd {
display: inline-block;
min-width: 1.6em;
padding: 1px 5px;
border: 1px solid #4a4270;
border-bottom-width: 3px;
border-radius: 5px;
background: #1c1833;
font: 600 12px/1.3 ui-monospace, Menlo, Consolas, monospace;
text-align: center;
color: #fff;
}
@media (max-width: 760px) {
body {
overflow: auto;
}
.cabinet {
flex-direction: column;
height: auto;
}
.backbox {
width: min(100%, 420px);
max-height: none;
}
}