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
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "pinball",
"runtimeExecutable": "node",
"runtimeArgs": ["server.js"],
"port": 3000
}
]
}
+154
View File
@@ -0,0 +1,154 @@
# Neon Pinball
A browser pinball game served by a tiny, zero-dependency Node.js server. The table is
modelled at real-world scale (millimetres), the physics runs at a fixed 1000 Hz with
interpolated 60/120 Hz rendering, and every sound is synthesised with the Web Audio API.
Play as long as you can keep the ball alive — the score keeps building until you lose
your third ball.
## Run it
Requires Node.js 20 or newer. There is nothing to install.
```bash
npm start
```
Then open <http://localhost:3000>. Use `PORT=8080 npm start` to pick another port.
> Opening `public/index.html` directly from disk will not work: browsers block ES module
> scripts on `file://` URLs, so the game has to be served over HTTP.
## Controls
| Key | Action |
| --- | --- |
| `A` or `←` | Left flipper |
| `D` or `→` | Right flipper |
| `Space` | Start a game; hold to pull the plunger back, release to launch |
| `P` / `Esc` | Pause |
| `M` | Sound on/off |
## Rules
- **3 balls per game.** When the third ball drains, it's game over. Your best score is saved in the browser.
- **Plunger:** a short hold (below the white tick on the power meter) is a soft plunge that drops the
ball into the top lanes; a long hold sends it all the way round the orbit.
- **Skill shot (25,000):** one top lane blinks at launch. Soft-plunge the ball into the lanes and steer
the blinking lane under it with the flippers.
- **Top lanes:** light all three to raise the end-of-ball bonus multiplier (2X–5X). The flipper buttons shift the lit lanes left and right.
- **Drop targets (left):** knock all three down for 10,000, 20 seconds of Super Bumpers (1,000 per hit), and to relight the kickback.
- **Stand-up targets (right):** light all three for 15,000.
- **Kickback:** lit at the start of the game; when a ball goes down the left outlane it's fired back into play.
- **Ramps:** a hard flipper shot up either outer lane climbs a ramp and loops back over the pop bumpers
to feed the same flipper again — too weak a shot and it rolls back out of the entrance mouth.
- **Lock & multiball:** shoot the center scoop (threaded between the two upper pop bumpers) to lock a
ball — it doesn't cost you a turn, a fresh ball is served immediately. Lock two, and the next scoop
shot starts 3-ball multiball with a jackpot lit on both ramps.
- **Jackpots:** during multiball, clearing a lit ramp scores its jackpot (climbing in value each time)
and unlights it; collecting both lights the Super Jackpot at the scoop.
- **Bonus:** targets and lanes build a bonus that is multiplied and paid when each ball drains.
## Tests
```bash
npm test
```
The physics and rules have no DOM dependencies, so the test suite (Node's built-in
`node:test`) drives the real game headlessly. It covers the flipper collision shape against
a brute-force reference, cradling and flipping, plunger power, drains and game over, the
scoring features, ramp riding (a track sampled against a brute-force distance check, a weak
shot rolling back vs. a hard shot cresting it), locking, multiball, jackpots and the super
jackpot, and a three-minute random-play run — now driving up to three simultaneous balls —
that checks no ball ever tunnels through a wall or escapes the table.
## Project layout
```
server.js static file server (node:http, correct MIME types, no path traversal)
public/index.html page and backbox HUD
public/style.css
public/js/physics.js ball, walls, posts, tapered flippers, collision response
public/js/table.js table geometry at real-world scale
public/js/game.js rules, scoring, fixed-timestep simulation
public/js/render.js canvas renderer (static layer cached, dynamic parts per frame)
public/js/audio.js synthesised sound effects
public/js/main.js game loop, keyboard input, HUD
test/ node:test suites
```
## How it works, and where the numbers come from
- **Scale.** The playfield is a standard 20.25" × 42" (514 × 1067 mm) with a 1-1/16" (27 mm) ball.
The flippers are 3" bats (3.25" with rubber) whose pivots are 7" apart, resting 31° below
horizontal and swinging to 20° above (Visual Pinball's defaults of 121° and 70°,
measured clockwise from 12 o'clock — about the 52° swing of a real flipper).
- **Gravity.** A real table is tilted 6.5°, giving 9.81 × sin 6.5° ≈ 1.11 m/s² along the playfield.
The game uses 1.5 m/s² for a snappier, arcade feel (tuned by play-testing).
- **Collisions** follow the "Ten Minute Physics" pinball approach: push the ball out along the
contact normal, then correct its normal velocity. On top of that there is speed-dependent
restitution (flipper elasticity 0.8, from Visual Pinball's defaults) and moving-surface
contacts for the flippers. The flipper is an exact tapered capsule (Inigo Quilez's uneven-capsule
distance function), so what you see is exactly what the ball hits. Multiball uses the same
tutorial's equal-mass ball-vs-ball collision for balls bumping into each other.
- **Ramps and the wireform return.** A ramp is a Catmull-Rom spline through hand-placed 3D control
points (x, y, and height above the playfield); the ball rides it like a bead on a wire, gaining or
losing speed to gravity along the climb (scaled down from the real 9.81 m/s² for an arcade feel) plus
rolling friction. Too weak a shot loses all its speed partway up and rolls back out of the entrance,
exactly as on a real ramp. The entrance itself sits well past the flipper, not right at its tip: an
early version put the mouth exactly where a hard flip's tip ends up, which turned out to catch nearly
*every* flip regardless of aim — there's no aiming to a mouth that every shot already passes through.
Simulating a cradled-ball flip across a wide sweep of hold times and searching the resulting
trajectories for a spot only a specific, contiguous band of hold times actually reaches (not a guess)
found a real one: holding the flip for roughly a beat past the instinctive snap sends the ball on a
different, later-diverging arc that the mouth sits on. Each ramp climbs from there over the pop bumpers
and loops back down to the natural post-flip point above the same flipper — no aim needed for the
return, only the entrance demands it.
- **Lock, multiball and jackpots** follow the standard pattern on games like Medieval Madness and Attack
from Mars: a captive-ball scoop below the pop bumpers builds a lock without costing a turn, then kicks
off real 3-ball multiball; ramp shots score an escalating jackpot while it's lit, and clearing both
lights a Super Jackpot back at the scoop. The scoop's plain "bonus, kicked back out" case ejects the
ball with a randomised sideways component and a short cooldown — an earlier straight-down, dead-centre
eject could fall onto a pop bumper and bounce straight back up into the scoop over and over, a
perfectly symmetric loop that trapped a ball indefinitely.
- **The stuck-ball rescue** doesn't watch instantaneous speed — a ball can be stuck while moving fast the
whole time, cycling energetically around a loop through several colliders (bumper → bumper → bumper →
repeat) that never actually goes anywhere. It watches the bounding box the ball has visited over a
rolling multi-second window instead: a real loop can't escape a modest box no matter how many laps it
runs, so once that window elapses without the box growing, it forces a hard rescue kick. This is what
caught a real game-breaking bug (found through extended simulation, not by inspection): a ball could
settle into a stable circuit around the pop bumpers and rack up score indefinitely without ever
draining.
- **The 3D tilt** is a real CSS 3D transform (`perspective` + `rotateX`) on the rendered table, not a
change to the game itself: physics and input stay in the flat, straight-down coordinate system the ball
actually moves in, and the browser tilts that finished picture in 3D space for display, anchored at the
bottom (flipper) edge so the far end recedes correctly.
- **Ramp and wireform rendering** is the classic 2D "cylinder" trick also used for the lane guides
(`metalRail`): several strokes of shrinking width and lightening colour nested on the same centreline
read as a round chrome tube, with a coloured LED-style accent stripe down one side that brightens when
that ramp's jackpot is lit.
- **Layout.** The overall arrangement — a decorative structure at the top of the arch, two big tube
ramps crossing over a pop-bumper triangle, bold arrow-shaped target banks flanking the bumpers, a
captive lock/spinner feature just below them, and lightning-bolt slingshots by the flippers — is
patterned after a user-supplied reference photo of a real cabinet, with none of its branding, colours,
or characters carried over.
- **Game loop.** A fixed-timestep accumulator with render interpolation ("Fix Your Timestep!"),
with the frame time clamped to 0.25 s to avoid a spiral of death after a stall.
### Sources
- Playfield size and slope: [Dimensions.com – Pinball Machines](https://www.dimensions.com/element/pinball-machines), [VPForums – Playfield sizes](https://www.vpforums.org/index.php?showtopic=2762)
- Ball size: [Marco Specialties – 1-1/16" ball](https://www.marcospecialties.com/pinball-parts/PB116), [Pinball Life – standard pinball](https://www.pinballlife.com/1-116-pinball-standard-size.html)
- Flipper spacing and length: [Pinside – distance between flippers](https://pinside.com/pinball/forum/topic/what-is-the-regular-distance-between-flippers)
- Flipper swing arc: [VPForums – flipper angles](https://www.vpforums.org/index.php?showtopic=39652)
- Visual Pinball flipper defaults and angle convention: [vpinball `flipper.cpp`](https://github.com/freezy/vpinball/blob/master/flipper.cpp), [VP10 physics notes](https://github.com/c-f-h/vpinball/wiki/VP10-Physics)
- Collision approach (including ball-vs-ball): [Ten Minute Physics – pinball](https://github.com/matthias-research/pages/blob/master/tenMinutePhysics/04-pinball.html)
- Tapered capsule SDF: [Inigo Quilez – 2D distance functions](https://iquilezles.org/articles/distfunctions2d/)
- Playfield layout, flow, orbits and ramps feeding a flipper: [Mission Pinball Framework – Layout considerations](https://docs.missionpinball.org/en/latest/physical_building/layout_considerations.html)
- Ramps, wireforms, scoops/VUKs and locks: [Pinball Makers wiki – Design](https://pinballmakers.com/wiki/index.php?title=Design), [Wikipedia – Pinball (playfield components, ball save, tilt)](https://en.wikipedia.org/wiki/Pinball)
- Real lock/multiball/jackpot examples referenced for the design: [Wikipedia – Twilight Zone (pinball)](https://en.wikipedia.org/wiki/Twilight_Zone_(pinball))
- Game loop: [Gaffer on Games – Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/)
- Browser APIs (MDN): [KeyboardEvent.code values](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values), [devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio), [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame), [JavaScript modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), [Web Audio autoplay](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices), [User activation](https://developer.mozilla.org/en-US/docs/Web/Security/User_activation)
- CSS 3D transforms (MDN): [perspective()](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/perspective), [rotateX()](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/rotateX)
+15
View File
@@ -0,0 +1,15 @@
{
"name": "pinball",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pinball",
"version": "1.0.0",
"engines": {
"node": ">=20"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "pinball",
"version": "1.0.0",
"description": "A browser pinball game with a zero-dependency Node.js server",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"engines": {
"node": ">=20"
}
}
+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;
}
}
+75
View File
@@ -0,0 +1,75 @@
// Zero-dependency static file server for the pinball game.
// Usage: `npm start` (or `PORT=8080 npm start`), then open the printed URL.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '127.0.0.1';
const PUBLIC_DIR = fileURLToPath(new URL('./public', import.meta.url));
// Module scripts must be served with a JavaScript MIME type or the browser refuses to run them.
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.json': 'application/json; charset=utf-8',
};
function sendText(res, status, message) {
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(message);
}
const server = http.createServer(async (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD');
sendText(res, 405, 'Method not allowed');
return;
}
let pathname;
try {
pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
sendText(res, 400, 'Bad request');
return;
}
if (pathname.endsWith('/')) pathname += 'index.html';
// Resolve inside PUBLIC_DIR and refuse anything that escapes it.
const filePath = path.join(PUBLIC_DIR, pathname);
if (!filePath.startsWith(PUBLIC_DIR + path.sep)) {
sendText(res, 403, 'Forbidden');
return;
}
try {
const body = await readFile(filePath);
const type = MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' });
res.end(req.method === 'HEAD' ? undefined : body);
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'EISDIR') sendText(res, 404, 'Not found');
else sendText(res, 500, 'Server error');
}
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is already in use. Try: PORT=${PORT + 1} npm start`);
} else {
console.error(err);
}
process.exit(1);
});
server.listen(PORT, HOST, () => {
console.log(`Pinball is running at http://localhost:${PORT}`);
console.log('Press Ctrl+C to stop.');
});
+358
View File
@@ -0,0 +1,358 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Game, PHYSICS_DT, PULL_TIME } from '../public/js/game.js';
import { segmentsCross } from '../public/js/physics.js';
import { SHOOTER_X, LANE_LEFT } from '../public/js/table.js';
function seeded(seed) {
let s = seed >>> 0;
return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
}
function run(game, seconds, onStep) {
const steps = Math.round(seconds / PHYSICS_DT);
for (let i = 0; i < steps; i++) {
game.step();
if (onStep && onStep() === false) return;
}
}
function newGame(seed = 1) {
const game = new Game({ random: seeded(seed) });
game.setInput('launch', true); // starts the game
game.setInput('launch', false);
return game;
}
function plunge(game, seconds) {
game.setInput('launch', true);
run(game, seconds);
game.setInput('launch', false);
}
test('space starts a game with the first ball in the shooter lane', () => {
const game = new Game({ random: seeded(1) });
assert.equal(game.state, 'attract');
game.setInput('launch', true);
game.setInput('launch', false);
assert.equal(game.state, 'play');
assert.equal(game.ballNumber, 1);
assert.ok(game.ball.x > LANE_LEFT);
});
test('pulling the plunger back never drains the ball', () => {
const game = newGame();
game.setInput('launch', true);
run(game, 1.5);
assert.equal(game.state, 'play');
assert.equal(game.stats.drains, 0);
});
test('holding space longer launches the ball harder', () => {
const speeds = [0, 0.3, 0.6, 0.9].map((hold) => {
const game = newGame();
game.setInput('launch', true);
run(game, hold);
const pull = game.plunger.pull;
game.setInput('launch', false);
assert.ok(Math.abs(pull - Math.min(1, hold / PULL_TIME)) < 0.01, `pull after ${hold}s was ${pull}`);
return -game.ball.vy;
});
for (let i = 1; i < speeds.length; i++) assert.ok(speeds[i] > speeds[i - 1], speeds.join(', '));
});
test('every plunger strength gets the ball out of the shooter lane', () => {
for (const hold of [0, 0.2, 0.45, 0.9]) {
const game = newGame();
plunge(game, hold);
run(game, 1.5, () => !game.inPlay);
assert.equal(game.inPlay, true, `hold ${hold}s`);
}
});
test('a ball dropped on a raised flipper cradles, and flipping sends it up the table', () => {
const game = newGame();
game.setInput('left', true);
run(game, 0.1);
game.ball.place(190, 820);
game.inPlay = true;
run(game, 3);
assert.ok(game.ball.speed < 20, 'ball should come to rest');
assert.equal(game.state, 'play');
game.setInput('left', false);
run(game, 0.25);
game.setInput('left', true);
let highest = Infinity;
run(game, 1.2, () => {
highest = Math.min(highest, game.ball.y);
});
assert.ok(highest < 300, `ball only reached y=${highest}`);
});
test('three drains end the game and the bonus is paid per ball', () => {
const game = newGame();
for (let ballNumber = 1; ballNumber <= 3; ballNumber++) {
assert.equal(game.ballNumber, ballNumber);
game.bonus = 2000;
game.multiplier = 3;
const before = game.score;
game.ball.place(238, 900); // straight down the middle
game.inPlay = true;
run(game, 4, () => game.state === 'play' || game.state === 'bonus');
assert.equal(game.score - before, 6000);
}
assert.equal(game.state, 'gameover');
assert.equal(game.highScore, 18000);
game.setInput('launch', true);
assert.equal(game.state, 'play');
assert.equal(game.score, 0);
});
test('completing the top lanes raises the bonus multiplier', () => {
const game = newGame();
game.inPlay = true;
game.skillShotLive = false;
for (const r of game.table.rollovers.filter((r) => r.kind === 'top')) game.hitRollover(r);
assert.equal(game.multiplier, 2);
assert.deepEqual(game.topLit, [false, false, false]);
});
test('the flipper buttons shift the lit top lanes', () => {
const game = newGame();
game.topLit = [true, false, false];
game.setInput('right', true);
assert.deepEqual(game.topLit, [false, true, false]);
game.setInput('right', true); // holding the button does not shift again
assert.deepEqual(game.topLit, [false, true, false]);
game.setInput('left', true);
assert.deepEqual(game.topLit, [true, false, false]);
game.setInput('left', false);
game.setInput('left', true);
assert.deepEqual(game.topLit, [false, false, true]);
});
test('the flippers steer the blinking skill-shot lane while it is live', () => {
const game = newGame();
game.skillShotLane = 1;
game.setInput('left', true);
assert.equal(game.skillShotLane, 0);
game.setInput('left', false);
game.setInput('right', true);
assert.equal(game.skillShotLane, 1);
});
test('a soft plunge usually drops into the top lanes and a full plunge orbits', () => {
const firstSwitch = (hold) => {
const game = newGame();
plunge(game, hold);
game.takeEvents();
let first = null;
run(game, 8, () => {
for (const e of game.takeEvents()) {
if (first) break;
if (e.type === 'rollover') first = e.kind;
else if (['bumper', 'kicker', 'sling', 'drop', 'standup', 'drain'].includes(e.type)) first = e.type;
}
return first === null;
});
return first;
};
let lanes = 0;
let drains = 0;
let count = 0;
for (let ms = 0; ms <= 500; ms += 20, count++) {
const first = firstSwitch(ms / 1000);
if (first === 'top') lanes++;
if (first === 'drain') drains++;
}
assert.ok(lanes / count >= 0.7, `only ${lanes}/${count} soft plunges reached a top lane`);
assert.equal(drains, 0);
// A full plunge sends the ball all the way around the orbit and down the outer lane, where it meets
// the drop-target bank (flanking the pop bumpers) before reaching the kicker bulge further down.
assert.equal(firstSwitch(0.9), 'drop');
});
test('knocking down every drop target lights super bumpers and the bank resets', () => {
const game = newGame();
game.ball.place(300, 300);
for (const d of game.table.dropTargets) game.hitDropTarget(d);
assert.ok(game.superBumpers > 0);
run(game, 2);
assert.ok(game.table.dropTargets.every((d) => !d.down));
});
function shootScoop(game) {
game.table.lock.cooldown = 0; // the scoop briefly refuses back-to-back hits; tests fire it on demand
const ball = game.ball;
ball.x = game.table.lock.x;
ball.y = game.table.lock.y;
ball.vx = 0;
ball.vy = 1000; // comfortably above the scoop's capture speed
run(game, 0.05);
}
test('locking a ball does not count as a drain or advance the ball number', () => {
const game = newGame();
const ballNumberBefore = game.ballNumber;
shootScoop(game);
assert.equal(game.locked, 1);
assert.equal(game.ballNumber, ballNumberBefore);
assert.equal(game.stats.drains, 0);
assert.equal(game.state, 'play');
assert.equal(game.balls.length, 1); // the locked ball was removed and a fresh one served
assert.ok(game.ball.x > LANE_LEFT, 'the fresh ball should be back on the plunger');
});
test('locking two balls then shooting the scoop a third time starts 3-ball multiball', () => {
const game = newGame();
shootScoop(game);
shootScoop(game);
assert.equal(game.locked, 2);
shootScoop(game);
assert.equal(game.state, 'multiball');
assert.equal(game.locked, 0);
assert.equal(game.balls.length, 3);
assert.equal(game.jackpot.left, true);
assert.equal(game.jackpot.right, true);
assert.equal(game.jackpot.super, false);
assert.equal(game.stats.multiballs, 1);
for (const ball of game.balls) {
assert.ok(ball.x >= 0 && ball.x <= 514, `spawned ball should be on the table: x=${ball.x}`);
}
});
test('a ramp shot during multiball collects its jackpot, and collecting both lights the super jackpot', () => {
const game = newGame();
game.state = 'multiball';
const ball = game.spawnBall(300, 500);
game.balls = [ball];
game.jackpot.left = true;
game.jackpot.right = true;
game.jackpot.value = 25000;
const before = game.score;
const leftTrack = game.table.ramps.find((r) => r.track.side === 'left').track;
ball.track = leftTrack;
ball.s = leftTrack.length - 1;
ball.v = 500;
run(game, 0.02);
assert.equal(game.jackpot.left, false);
assert.equal(game.score - before, 25000);
assert.equal(game.jackpot.super, false); // only one side collected so far
const rightTrack = game.table.ramps.find((r) => r.track.side === 'right').track;
ball.track = rightTrack;
ball.s = rightTrack.length - 1;
ball.v = 500;
run(game, 0.02);
assert.equal(game.jackpot.right, false);
assert.equal(game.jackpot.super, true);
});
test('collecting the super jackpot serves a fresh ball and relights both jackpots', () => {
const game = newGame();
game.state = 'multiball';
const ball = game.spawnBall(game.table.lock.x, game.table.lock.y);
game.balls = [ball];
game.jackpot.super = true;
game.jackpot.left = false;
game.jackpot.right = false;
ball.vx = 0;
ball.vy = 1000;
const before = game.score;
run(game, 0.05);
assert.equal(game.jackpot.super, false);
assert.equal(game.jackpot.left, true);
assert.equal(game.jackpot.right, true);
assert.equal(game.score - before, 100000);
assert.equal(game.balls.length, 1); // the old ball was removed and a fresh one served
assert.notEqual(game.balls[0], ball);
});
test('multiball continues until the very last ball drains, then ends as a normal ball loss', () => {
const game = newGame();
game.state = 'multiball';
const a = game.spawnBall(200, 500);
const b = game.spawnBall(300, 500);
const c = game.spawnBall(250, 900);
game.balls = [a, b, c];
game.bonus = 1000;
game.multiplier = 2;
a.x = 100;
a.y = 1050;
a.vx = 0;
a.vy = 10;
run(game, 0.01);
assert.equal(game.state, 'multiball');
assert.equal(game.balls.length, 2);
b.x = 100;
b.y = 1050;
b.vx = 0;
b.vy = 10;
run(game, 0.01);
assert.equal(game.state, 'multiball'); // one ball is still in play
assert.equal(game.balls.length, 1);
assert.equal(game.balls[0], c);
c.x = 100;
c.y = 1050;
c.vx = 0;
c.vy = 10;
run(game, 0.01);
assert.equal(game.state, 'bonus'); // the last ball of the turn: a normal drain
assert.equal(game.balls.length, 0);
assert.equal(game.bonusAward, 1000 * 2);
});
test('long random play never tunnels through walls or escapes the table', () => {
const game = newGame(7);
const rand = seeded(99);
let holdLeft = 0;
let holdRight = 0;
let tunnels = 0;
for (let i = 0; i < 180 / PHYSICS_DT; i++) {
if (game.state === 'gameover') game.setInput('launch', true), game.setInput('launch', false);
if (
(game.state === 'play' || game.state === 'multiball') &&
!game.inPlay &&
game.ball &&
game.ball.x === SHOOTER_X &&
Math.abs(game.ball.vy) < 1 &&
!game.plunger.pulling
) {
plunge(game, rand() * 0.9);
}
for (const ball of game.balls) {
for (const [index, f] of game.table.flippers.entries()) {
if (Math.hypot(ball.x - f.x, ball.y - f.y) < 85 && ball.vy > -50 && rand() < 0.02) {
if (index === 0) holdLeft = 0.18;
else holdRight = 0.18;
}
}
}
holdLeft -= PHYSICS_DT;
holdRight -= PHYSICS_DT;
game.setInput('left', holdLeft > 0);
game.setInput('right', holdRight > 0);
// Snapshot each ball's track state before the step: a ball riding a ramp legitimately passes
// over other playfield elements (that's the point of being elevated), so it's exempt below.
const before = game.balls.map((b) => ({ ball: b, hadTrack: Boolean(b.track) }));
game.step();
for (const { ball: b, hadTrack } of before) {
if (hadTrack || b.track) continue;
for (const w of game.table.walls) {
if (!w.enabled || !segmentsCross(b.prevX, b.prevY, b.x, b.y, w.ax, w.ay, w.bx, w.by)) continue;
const fromBelowGate = w.tag === 'gate' && (b.prevX - w.ax) * w.nx + (b.prevY - w.ay) * w.ny < 0;
if (!fromBelowGate) tunnels++;
}
}
game.takeEvents();
}
assert.equal(tunnels, 0);
assert.equal(game.stats.rescues, 0);
assert.ok(game.stats.drains > 0);
// Multiball itself is covered deterministically above; a random bot's luck at reaching it within any
// one seeded run is not something this stress test should assert on.
});
+185
View File
@@ -0,0 +1,185 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
Ball,
Segment,
Flipper,
Track,
collideSegment,
collideFlipper,
collideBalls,
advanceOnTrack,
resolveContact,
makeMaterial,
} from '../public/js/physics.js';
const flipperOptions = {
x: 0,
y: 0,
length: 64,
baseRadius: 11.5,
tipRadius: 7,
restAngle: 0.5,
upAngle: -0.35,
upSpeed: 40,
downSpeed: 16,
material: makeMaterial(0.8, 0.43),
};
// The tapered flipper is the convex hull of two discs, which equals the union of discs
// whose centre and radius are interpolated between the two ends. Brute-force that.
function bruteForceDistance(f, px, py) {
const tipX = f.x + Math.cos(f.angle) * f.length;
const tipY = f.y + Math.sin(f.angle) * f.length;
let best = Infinity;
for (let i = 0; i <= 4000; i++) {
const t = i / 4000;
const cx = f.x + (tipX - f.x) * t;
const cy = f.y + (tipY - f.y) * t;
const r = f.baseRadius + (f.tipRadius - f.baseRadius) * t;
best = Math.min(best, Math.hypot(px - cx, py - cy) - r);
}
return best;
}
test('flipper signed distance matches a brute-force distance to the tapered shape', () => {
const f = new Flipper(flipperOptions);
let seed = 1;
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
for (let i = 0; i < 300; i++) {
const px = -40 + rand() * 140;
const py = -60 + rand() * 120;
const { dist } = f.distance(px, py);
if (dist < 0.5) continue; // brute force only valid outside the shape
assert.ok(Math.abs(dist - bruteForceDistance(f, px, py)) < 0.05, `point ${px},${py}`);
}
});
test('flipper normal is the gradient of the distance field', () => {
const f = new Flipper(flipperOptions);
const h = 1e-4;
for (const [px, py] of [[30, -20], [30, 25], [-20, 0], [75, 30], [70, 50], [5, -16]]) {
const { dist, nx, ny } = f.distance(px, py);
const gx = (f.distance(px + h, py).dist - dist) / h;
const gy = (f.distance(px, py + h).dist - dist) / h;
assert.ok(Math.abs(gx - nx) < 1e-3 && Math.abs(gy - ny) < 1e-3, `normal at ${px},${py}`);
}
});
test('a ball bounces off a wall with restitution and is pushed out of it', () => {
const ball = new Ball(10);
ball.place(0, -9); // 1 mm inside a floor at y = 0
ball.vy = 2000;
const floor = new Segment(-100, 0, 100, 0, { material: makeMaterial(0.5) });
assert.equal(collideSegment(ball, floor), true);
assert.equal(ball.y, -10);
assert.ok(Math.abs(ball.vy + 1000) < 1e-9);
});
test('one-way segments block from one side only', () => {
const gate = new Segment(0, 0, 100, 0, { oneWay: true, material: makeMaterial(0.5) });
// Left-hand normal walking a->b in screen space points up (-y): blocks balls above the line.
const above = new Ball(10);
above.place(50, -8);
above.vy = 500;
assert.equal(collideSegment(above, gate), true);
const below = new Ball(10);
below.place(50, 8);
below.vy = -500;
assert.equal(collideSegment(below, gate), false);
});
test('a moving surface launches a resting ball', () => {
const ball = new Ball(10);
const e = 0.5;
resolveContact(ball, 0, -1, 0, 0, -1000, makeMaterial(e));
assert.ok(Math.abs(ball.vy + 1500) < 1e-9); // (1 + e) * surface speed
});
test('equal-mass balls exchange velocity along the line of centres and are pushed apart', () => {
const a = new Ball(13.5);
const b = new Ball(13.5);
a.place(0, 0);
b.place(20, 0); // 7 mm overlap (2 * 13.5 = 27 mm needed)
a.vx = 1000;
const approach = collideBalls(a, b, 1); // perfectly elastic
assert.ok(approach > 0);
assert.ok(Math.abs(a.vx) < 1e-6, `a should stop dead: ${a.vx}`); // a transfers all its velocity to b
assert.ok(Math.abs(b.vx - 1000) < 1e-6, `b should take a's velocity: ${b.vx}`);
assert.ok(Math.abs(b.x - a.x - 27) < 1e-6, 'balls should be pushed apart to just touch');
});
test('collideBalls does nothing when the balls are already separating', () => {
const a = new Ball(13.5);
const b = new Ball(13.5);
a.place(0, 0);
b.place(20, 0);
a.vx = -500; // moving away from b
const approach = collideBalls(a, b, 1);
assert.equal(approach, 0);
assert.equal(a.vx, -500);
});
test('a straight, flat track carries a ball from start to end at constant speed', () => {
const track = new Track([
[0, 0, 0],
[1000, 0, 0],
]);
const ball = new Ball(13.5);
ball.track = track;
ball.s = 0;
ball.v = 500;
let steps = 0;
let result = null;
while (!result && steps < 10000) {
result = advanceOnTrack(ball, 1 / 1000, 0, 0, 0); // no gravity component, no friction: speed is constant
steps++;
}
assert.equal(result, 'end');
assert.ok(Math.abs(ball.v - 500) < 1, `speed should be unchanged on a flat, frictionless track: ${ball.v}`);
assert.ok(Math.abs(ball.x - 1000) < 1);
});
test('a weak shot cannot crest a steep climb and rolls back out of the entrance', () => {
const track = new Track([
[0, 0, 0],
[200, 0, 100], // a short, steep 100 mm climb
]);
const ball = new Ball(13.5);
ball.track = track;
ball.s = 0;
ball.v = 400; // too slow to climb 100 mm against strong "gravity"
let result = null;
for (let i = 0; i < 20000 && !result; i++) result = advanceOnTrack(ball, 1 / 1000, 1500, 9000, 100);
assert.equal(result, 'start');
assert.ok(ball.v < 0, `should be moving back down the entrance: ${ball.v}`);
});
test('a hard shot crests the same climb and exits with reduced speed', () => {
const track = new Track([
[0, 0, 0],
[200, 0, 100],
]);
const ball = new Ball(13.5);
ball.track = track;
ball.s = 0;
ball.v = 2600;
let result = null;
for (let i = 0; i < 20000 && !result; i++) result = advanceOnTrack(ball, 1 / 1000, 1500, 9000, 100);
assert.equal(result, 'end');
assert.ok(ball.v > 0 && ball.v < 2600, `should have lost speed to the climb: ${ball.v}`);
});
test('a raised flipper throws a resting ball', () => {
const f = new Flipper(flipperOptions);
const ball = new Ball(13.5);
// Sit the ball on top of the flipper, halfway along.
const along = 40;
const c = Math.cos(f.angle);
const s = Math.sin(f.angle);
ball.place(c * along + s * 23, s * along - c * 23);
f.pressed = true;
f.update(0.001);
assert.equal(collideFlipper(ball, f), true);
assert.ok(ball.vy < -1000, `expected a strong upward throw, got vy=${ball.vy}`);
});