From a740d2f141178c55bbc17039735f734aea9665a8 Mon Sep 17 00:00:00 2001 From: christopher Date: Sat, 12 Sep 2026 01:33:07 -0400 Subject: [PATCH] initial Game --- .claude/launch.json | 11 + README.md | 154 ++++++ package-lock.json | 15 + package.json | 14 + public/index.html | 63 +++ public/js/audio.js | 194 +++++++ public/js/game.js | 765 ++++++++++++++++++++++++++++ public/js/main.js | 209 ++++++++ public/js/physics.js | 465 +++++++++++++++++ public/js/render.js | 1157 ++++++++++++++++++++++++++++++++++++++++++ public/js/table.js | 377 ++++++++++++++ public/style.css | 233 +++++++++ server.js | 75 +++ test/game.test.js | 358 +++++++++++++ test/physics.test.js | 185 +++++++ 15 files changed, 4275 insertions(+) create mode 100644 .claude/launch.json create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/index.html create mode 100644 public/js/audio.js create mode 100644 public/js/game.js create mode 100644 public/js/main.js create mode 100644 public/js/physics.js create mode 100644 public/js/render.js create mode 100644 public/js/table.js create mode 100644 public/style.css create mode 100644 server.js create mode 100644 test/game.test.js create mode 100644 test/physics.test.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..393f4a9 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "pinball", + "runtimeExecutable": "node", + "runtimeArgs": ["server.js"], + "port": 3000 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..43b5647 --- /dev/null +++ b/README.md @@ -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 . 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) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bef905e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "pinball", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pinball", + "version": "1.0.0", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..058934f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e89bad6 --- /dev/null +++ b/public/index.html @@ -0,0 +1,63 @@ + + + + + + Neon Pinball + + + + +
+
+
+ +
+
+ + +
+ + + diff --git a/public/js/audio.js b/public/js/audio.js new file mode 100644 index 0000000..acb7dde --- /dev/null +++ b/public/js/audio.js @@ -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; + } + } +} diff --git a/public/js/game.js b/public/js/game.js new file mode 100644 index 0000000..88a6e3f --- /dev/null +++ b/public/js/game.js @@ -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'); +} diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..befb8cb --- /dev/null +++ b/public/js/main.js @@ -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 }; diff --git a/public/js/physics.js b/public/js/physics.js new file mode 100644 index 0000000..eb89ad0 --- /dev/null +++ b/public/js/physics.js @@ -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; +} diff --git a/public/js/render.js b/public/js/render.js new file mode 100644 index 0000000..2e982b0 --- /dev/null +++ b/public/js/render.js @@ -0,0 +1,1157 @@ +// Canvas renderer. World units are millimetres; one transform maps the table onto the canvas. +// Static artwork is drawn once per resize into an offscreen canvas; moving parts are drawn every frame. + +import { WIDTH, HEIGHT, LEFT, RIGHT, LANE_LEFT, LANE_RIGHT, SHOOTER_X, PLUNGER_REST_Y, CX, LANE_TOP } from './table.js'; +import { fmt, KICKBACK } from './game.js'; + +const APRON_Y = 986; +const COLORS = { + lane: '#ffd23f', + multiplier: '#ff8a1f', + drop: '#ff4d6d', + standup: '#34e7ff', + bumper: ['#ff3fa4', '#34e7ff', '#b16cff'], + sling: '#ff3fa4', + rubber: '#f1ece2', + lock: '#ffd23f', + jackpot: '#ffe14d', +}; + +function seeded(seed) { + let s = seed; + return () => ((s = (s * 16807) % 2147483647) / 2147483647); +} + +export class Renderer { + constructor(canvas, game) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.game = game; + this.staticLayer = document.createElement('canvas'); + this.apronLayer = document.createElement('canvas'); + this.glowCache = new Map(); + this.particles = []; + this.popups = []; + this.trails = new WeakMap(); // per-ball motion trail (multiball can have several balls at once) + this.shake = 0; + this.time = 0; + } + + resize(cssHeight, dpr) { + const cssWidth = (cssHeight * WIDTH) / HEIGHT; + this.canvas.style.width = `${cssWidth}px`; + this.canvas.style.height = `${cssHeight}px`; + const w = Math.floor(cssWidth * dpr); + const h = Math.floor(cssHeight * dpr); + for (const c of [this.canvas, this.staticLayer, this.apronLayer]) { + c.width = w; + c.height = h; + } + this.sx = w / WIDTH; + this.sy = h / HEIGHT; + this.drawStatic(this.staticLayer.getContext('2d')); + this.drawApron(this.apronLayer.getContext('2d')); + } + + worldTransform(ctx, ox = 0, oy = 0) { + ctx.setTransform(this.sx, 0, 0, this.sy, ox * this.sx, oy * this.sy); + } + + // ---- Helpers ------------------------------------------------------------------------------ + + glowSprite(color) { + let sprite = this.glowCache.get(color); + if (sprite) return sprite; + sprite = document.createElement('canvas'); + sprite.width = sprite.height = 64; + const g = sprite.getContext('2d'); + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, color); + grad.addColorStop(0.35, color + '88'); + grad.addColorStop(1, color + '00'); + g.fillStyle = grad; + g.fillRect(0, 0, 64, 64); + this.glowCache.set(color, sprite); + return sprite; + } + + glow(ctx, x, y, radius, color, alpha = 1) { + if (alpha <= 0.01) return; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = Math.min(1, alpha); + ctx.drawImage(this.glowSprite(color), x - radius, y - radius, radius * 2, radius * 2); + ctx.restore(); + } + + polygon(ctx, points) { + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]); + ctx.closePath(); + } + + /** A round insert lamp. */ + lamp(ctx, x, y, r, color, on, label = '') { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = on ? color : color + '2e'; + ctx.fill(); + ctx.lineWidth = 1.2; + ctx.strokeStyle = on ? '#ffffffcc' : color + '66'; + ctx.stroke(); + if (on) this.glow(ctx, x, y, r * 3.2, color, 0.8); + if (label) { + ctx.fillStyle = on ? '#1a0b14' : color + '99'; + ctx.font = `800 ${r * 0.95}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, x, y + 0.5); + } + } + + /** A triangular arrow insert pointing along angle `a`. */ + arrow(ctx, x, y, size, a, color, on) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(a); + ctx.beginPath(); + ctx.moveTo(size, 0); + ctx.lineTo(-size * 0.7, size * 0.75); + ctx.lineTo(-size * 0.35, 0); + ctx.lineTo(-size * 0.7, -size * 0.75); + ctx.closePath(); + ctx.fillStyle = on ? color : color + '2e'; + ctx.fill(); + ctx.strokeStyle = on ? '#ffffffaa' : color + '55'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); + if (on) this.glow(ctx, x, y, size * 3, color, 0.7); + } + + metalRail(ctx, points, width) { + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); + ctx.strokeStyle = '#2b2f3a'; + ctx.lineWidth = width + 2; + ctx.stroke(); + ctx.strokeStyle = '#aeb6c6'; + ctx.lineWidth = width; + ctx.stroke(); + ctx.strokeStyle = '#eef2fa'; + ctx.lineWidth = width * 0.3; + ctx.stroke(); + } + + /** Screen Y for a point at height z above the playfield: nudged toward the viewer, matching drawBall. */ + liftY(y, z) { + return y + z * 0.4; + } + + rubberPost(ctx, x, y, r) { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = COLORS.rubber; + ctx.fill(); + ctx.beginPath(); + ctx.arc(x, y, r * 0.45, 0, Math.PI * 2); + ctx.fillStyle = '#9aa1ad'; + ctx.fill(); + } + + // ---- Static artwork ----------------------------------------------------------------------- + + drawStatic(ctx) { + const t = this.game.table; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + this.worldTransform(ctx); + + // Cabinet surround. + const wood = ctx.createLinearGradient(0, 0, WIDTH, 0); + wood.addColorStop(0, '#231a3d'); + wood.addColorStop(0.5, '#171129'); + wood.addColorStop(1, '#231a3d'); + ctx.fillStyle = wood; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + // Playfield. + this.polygon(ctx, t.outline); + const pf = ctx.createLinearGradient(0, 0, 0, HEIGHT); + pf.addColorStop(0, '#1c0f52'); + pf.addColorStop(0.45, '#2a0f5c'); + pf.addColorStop(1, '#081b45'); + ctx.fillStyle = pf; + ctx.fill(); + + ctx.save(); + this.polygon(ctx, t.outline); + ctx.clip(); + this.drawPlayfieldArt(ctx); + ctx.restore(); + + // Outline rim. + ctx.lineJoin = 'round'; + this.polygon(ctx, t.outline); + ctx.strokeStyle = '#8f98ad'; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.strokeStyle = '#ff3fa455'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Shooter-lane divider (same finish as the cabinet so both side bulges match). + this.polygon(ctx, t.divider); + ctx.fillStyle = '#1f1736'; + ctx.fill(); + ctx.strokeStyle = '#8f98ad'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Kicker rubbers on the side bulges. + for (const [a, b] of [ + [[LEFT, 380], [70, 480]], + [[RIGHT, 380], [2 * CX - 70, 480]], + ]) { + ctx.beginPath(); + ctx.moveTo(...a); + ctx.lineTo(...b); + ctx.strokeStyle = COLORS.rubber; + ctx.lineWidth = 5; + ctx.lineCap = 'round'; + ctx.stroke(); + } + + // Shooter lane floor markings. + ctx.fillStyle = '#ffffff14'; + for (let y = 420; y < 960; y += 60) { + ctx.beginPath(); + ctx.moveTo(SHOOTER_X, y - 10); + ctx.lineTo(SHOOTER_X + 8, y + 4); + ctx.lineTo(SHOOTER_X - 8, y + 4); + ctx.closePath(); + ctx.fill(); + } + + // Top lane guides, inlane guides and posts. + for (const x of t.laneGuideXs) this.metalRail(ctx, [[x, t.laneTop], [x, t.laneBottom]], 6); + for (const g of t.guides) this.metalRail(ctx, g, 6); + for (const p of t.posts) { + if (p.radius >= 5) this.rubberPost(ctx, p.x, p.y, p.radius); + } + } + + drawPlayfieldArt(ctx) { + const rand = seeded(7); + // Nebula glows. + for (const [x, y, r, c] of [ + [120, 260, 260, '#ff3fa4'], + [400, 520, 280, '#34e7ff'], + [220, 760, 240, '#b16cff'], + ]) { + const g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, c + '30'); + g.addColorStop(1, c + '00'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + } + // Perspective grid on the lower half. + ctx.strokeStyle = '#34e7ff14'; + ctx.lineWidth = 1; + for (let i = -8; i <= 8; i++) { + ctx.beginPath(); + ctx.moveTo(CX + i * 12, 520); + ctx.lineTo(CX + i * 70, 1000); + ctx.stroke(); + } + for (let y = 540, step = 14; y < 1000; y += step, step *= 1.18) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(WIDTH, y); + ctx.stroke(); + } + // Stars. + for (let i = 0; i < 140; i++) { + const x = rand() * WIDTH; + const y = rand() * 620; + const r = rand() * 1.3 + 0.3; + ctx.fillStyle = `rgba(255,255,255,${0.25 + rand() * 0.5})`; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } + this.drawTopStructure(ctx); + // Starburst behind the pop bumpers. + ctx.save(); + ctx.translate(250, 245); + for (let i = 0; i < 24; i++) { + ctx.rotate((Math.PI * 2) / 24); + ctx.fillStyle = i % 2 ? '#ff3fa414' : '#34e7ff10'; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(160, -12); + ctx.lineTo(160, 12); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); + // Centre logo. + ctx.save(); + ctx.translate(CX, 690); + ctx.rotate(-0.12); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = 'italic 900 62px system-ui, sans-serif'; + ctx.lineWidth = 2; + ctx.strokeStyle = '#ff3fa466'; + ctx.strokeText('NEON', 0, 0); + ctx.fillStyle = '#ff3fa41c'; + ctx.fillText('NEON', 0, 0); + ctx.font = 'italic 800 20px system-ui, sans-serif'; + ctx.fillStyle = '#34e7ff55'; + ctx.fillText('P I N B A L L', 0, 44); + ctx.restore(); + // Lane labels. + ctx.font = '700 9px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#ffffff55'; + ctx.fillText('BONUS X', 250, 184); + ctx.save(); + ctx.translate(SHOOTER_X, 700); + ctx.rotate(-Math.PI / 2); + ctx.fillStyle = '#ffd23f55'; + ctx.font = '800 11px system-ui, sans-serif'; + ctx.fillText('SKILL SHOT', 0, 4); + ctx.restore(); + } + + /** + * A small silhouetted skyline at the very top of the arch — three towers, lit windows, a spire. + * This is part of the cached static layer (baked in once per resize), so the windows get a fixed + * lit/unlit state from the seeded RNG rather than a live blink, which would never actually animate. + */ + drawTopStructure(ctx) { + const rand = seeded(41); + const tower = (cx, roofY, bodyTop, bodyBottom, halfWidth, roofColor, bodyColor) => { + ctx.beginPath(); + ctx.moveTo(cx, roofY); + ctx.lineTo(cx + halfWidth * 1.3, bodyTop); + ctx.lineTo(cx - halfWidth * 1.3, bodyTop); + ctx.closePath(); + ctx.fillStyle = roofColor; + ctx.fill(); + ctx.fillStyle = bodyColor; + ctx.fillRect(cx - halfWidth, bodyTop, halfWidth * 2, bodyBottom - bodyTop); + ctx.strokeStyle = '#000000aa'; + ctx.lineWidth = 1.5; + ctx.strokeRect(cx - halfWidth, bodyTop, halfWidth * 2, bodyBottom - bodyTop); + // a window, lit or not (fixed per tower — this art is drawn once and cached) + const winOn = rand() > 0.4; + ctx.fillStyle = winOn ? '#ffe37a' : '#5a4a2a'; + ctx.fillRect(cx - 3, bodyTop + (bodyBottom - bodyTop) * 0.45, 6, 8); + if (winOn) this.glow(ctx, cx, bodyTop + (bodyBottom - bodyTop) * 0.45 + 4, 14, '#ffe37a', 0.6); + }; + // connecting parapet wall + ctx.fillStyle = '#241a3c'; + ctx.fillRect(CX - 95, 88, 190, 12); + for (let x = CX - 92; x < CX + 92; x += 16) ctx.fillRect(x, 82, 9, 8); + ctx.strokeStyle = '#000000aa'; + ctx.lineWidth = 1.5; + ctx.strokeRect(CX - 95, 88, 190, 12); + // flanking towers + tower(CX - 65, 50, 66, 96, 13, '#ff3fa4', '#2a1c46'); + tower(CX + 65, 50, 66, 96, 13, '#34e7ff', '#2a1c46'); + // central, taller tower with a spire + tower(CX, 34, 56, 92, 16, '#ffd23f', '#332253'); + ctx.beginPath(); + ctx.moveTo(CX, 12); + ctx.lineTo(CX + 2.5, 34); + ctx.lineTo(CX - 2.5, 34); + ctx.closePath(); + ctx.fillStyle = '#ffd23f'; + ctx.fill(); + this.glow(ctx, CX, 20, 26, '#ffd23f', 0.5); + // rim glow along the whole silhouette + this.glow(ctx, CX, 60, 130, '#b16cff', 0.28); + } + + drawApron(ctx) { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + this.worldTransform(ctx); + const pts = [ + [LEFT, APRON_Y + 14], + [CX - 60, APRON_Y], + [CX + 60, APRON_Y], + [RIGHT, APRON_Y + 14], + [RIGHT, HEIGHT], + [LEFT, HEIGHT], + ]; + this.polygon(ctx, pts); + const g = ctx.createLinearGradient(0, APRON_Y, 0, HEIGHT); + g.addColorStop(0, '#3a1250'); + g.addColorStop(1, '#16081f'); + ctx.fillStyle = g; + ctx.fill(); + ctx.strokeStyle = '#ff3fa4'; + ctx.lineWidth = 2; + ctx.stroke(); + // Instruction cards. + for (const [x, lines] of [ + [LEFT + 14, ['3 BALLS PER GAME', 'SKILL SHOT 25,000']], + [CX + 44, ['LANES ADVANCE BONUS', 'DROPS LIGHT KICKBACK']], + ]) { + ctx.fillStyle = '#f4eedd'; + ctx.fillRect(x, APRON_Y + 30, 170, 36); + ctx.fillStyle = '#2a1a3a'; + ctx.font = '700 9.5px system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(lines[0], x + 8, APRON_Y + 41); + ctx.fillText(lines[1], x + 8, APRON_Y + 56); + } + } + + // ---- Effects ------------------------------------------------------------------------------ + + onEvent(e) { + const g = this.game; + switch (e.type) { + case 'score': + this.popups.push({ x: e.x, y: e.y, text: `+${fmt(e.points)}`, life: 0.9 }); + break; + case 'bumper': + this.sparks(e.x, e.y, COLORS.bumper[e.index], 14); + this.shake = Math.max(this.shake, 1.2); + break; + case 'sling': { + const s = g.table.slings[e.side === 'left' ? 0 : 1]; + this.sparks((s.top[0] + s.tip[0]) / 2, (s.top[1] + s.tip[1]) / 2, COLORS.sling, 10); + this.shake = Math.max(this.shake, 0.8); + break; + } + case 'drop': + case 'standup': + if (g.ball) this.sparks(g.ball.x, g.ball.y, e.type === 'drop' ? COLORS.drop : COLORS.standup, 8); + break; + case 'complete': + if (g.ball) this.sparks(g.ball.x, g.ball.y, '#ffd23f', 30); + break; + case 'ramp': + case 'rampEnter': + this.sparks(g.table.ramps.find((r) => r.track.side === e.side)?.entrance.x ?? CX, 700, e.side === 'left' ? '#ff3fa4' : '#34e7ff', 6); + break; + case 'jackpot': + this.sparks(g.table.lock.x, g.table.lock.y, COLORS.jackpot, 22); + this.shake = Math.max(this.shake, 1); + break; + case 'superJackpot': + this.sparks(g.table.lock.x, g.table.lock.y, '#ff3fa4', 45); + this.shake = 2.2; + break; + case 'lock': + case 'multiball': + this.sparks(g.table.lock.x, g.table.lock.y, COLORS.lock, e.count ? 12 : 26); + this.shake = Math.max(this.shake, e.count ? 0.6 : 1.5); + break; + case 'drain': + this.shake = 2; + break; + default: + break; + } + } + + sparks(x, y, color, n) { + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const s = 60 + Math.random() * 220; + this.particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.5 + Math.random() * 0.4, color }); + } + } + + // ---- Frame -------------------------------------------------------------------------------- + + render(alpha, frameDt) { + const ctx = this.ctx; + const g = this.game; + const t = g.table; + this.time += frameDt; + this.shake = Math.max(0, this.shake - frameDt * 10); + const ox = this.shake ? (Math.random() - 0.5) * this.shake : 0; + const oy = this.shake ? (Math.random() - 0.5) * this.shake : 0; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = '#07060d'; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + ctx.setTransform(1, 0, 0, 1, ox * this.sx, oy * this.sy); + ctx.drawImage(this.staticLayer, 0, 0); + this.worldTransform(ctx, ox, oy); + + this.drawLamps(ctx); + this.drawTargets(ctx); + this.drawSlings(ctx); + this.drawGate(ctx); + this.drawPlunger(ctx); + this.drawLockHole(ctx); + for (const f of t.flippers) this.drawFlipper(ctx, f, f.prevAngle + (f.angle - f.prevAngle) * alpha); + this.drawRamps(ctx); // clear plastic/wire, arcing over everything beneath it + // The pop bumpers and the lock's readouts sit right where the two loops cross overhead, so they're + // drawn after the ramps too — gameplay clarity on the things you actually shoot at wins over strict + // draw-order-by-height here, same trade-off real ramps make with clear plastic over what's below. + this.drawBumpers(ctx); + this.drawLockLamps(ctx); + this.drawBalls(ctx, alpha); // the ball is always drawn last so it reads clearly, even riding a ramp + + ctx.setTransform(1, 0, 0, 1, ox * this.sx, oy * this.sy); + ctx.drawImage(this.apronLayer, 0, 0); + this.worldTransform(ctx, ox, oy); + this.drawApronLamps(ctx); + this.drawParticles(ctx, frameDt); + this.drawOverlay(ctx); + } + + blink(rate = 4) { + return (this.time * rate) % 1 < 0.5; + } + + drawLamps(ctx) { + const g = this.game; + const t = g.table; + // Top lanes (the skill-shot lane blinks until the ball is in play). + t.rollovers.forEach((r) => { + if (r.kind === 'top') { + const skill = g.state === 'play' && g.skillShotLive && r.index === g.skillShotLane; + const on = g.topLit[r.index] || (skill && this.blink(5)); + this.lamp(ctx, r.x, 158, 8, COLORS.lane, on); + ctx.strokeStyle = '#d8dde8aa'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(r.x, r.y - 10); + ctx.lineTo(r.x, r.y + 10); + ctx.stroke(); + this.glow(ctx, r.x, r.y, 30, COLORS.lane, r.flash); + } else { + const color = r.kind === 'inlane' ? '#6dff9b' : '#ff4d6d'; + this.lamp(ctx, r.x, r.y + 30, 6, color, r.flash > 0.05); + ctx.strokeStyle = '#d8dde888'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(r.x, r.y - 8); + ctx.lineTo(r.x, r.y + 8); + ctx.stroke(); + } + }); + // Bonus multiplier inserts. + [2, 3, 4, 5].forEach((m, i) => { + const x = CX - 54 + i * 36; + const y = 600 + Math.abs(i - 1.5) * 8; + this.lamp(ctx, x, y, 12, COLORS.multiplier, g.multiplier >= m, `${m}X`); + }); + // Super bumpers. + const superOn = g.superBumpers > 0 && (g.superBumpers > 4 || this.blink(6)); + this.lamp(ctx, 250, 420, 10, '#ff3fa4', superOn, 'S'); + // Jackpot lamps at each ramp's exit (its own flipper's lane) — the two entrances sit close + // together near the middle, but the exits are well apart, one per side, so the labels never collide. + for (const ramp of t.ramps) { + const lit = g.state === 'multiball' && g.jackpot[ramp.track.side]; + const exit = ramp.track.sample(ramp.track.length); + const sign = ramp.track.side === 'left' ? -1 : 1; + const x = exit.x + sign * 26; + this.arrow(ctx, x, exit.y - 6, 8, -Math.PI / 2, ramp.track.color, lit); + if (lit) { + ctx.fillStyle = ramp.track.color + 'dd'; + ctx.font = '800 7px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.strokeStyle = '#07061099'; + ctx.lineWidth = 2.5; + ctx.strokeText('JACKPOT', x, exit.y - 18); + ctx.fillText('JACKPOT', x, exit.y - 18); + } + } + // Kickback in the left outlane: arrow lamp, label and the kicker arm. + this.arrow(ctx, KICKBACK.x, 850, 8, -Math.PI / 2, '#6dff9b', g.kickbackLit); + ctx.save(); + ctx.translate(KICKBACK.x, 900); + ctx.rotate(-Math.PI / 2); + ctx.fillStyle = g.kickbackLit ? '#6dff9bdd' : '#6dff9b44'; + ctx.font = '800 8px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('KICKBACK', 0, 0); + ctx.restore(); + ctx.fillStyle = '#c9ced8'; + ctx.beginPath(); + ctx.roundRect(KICKBACK.x - 9, 958 - g.kickbackFlash * 22, 18, 6, 2); + ctx.fill(); + this.glow(ctx, KICKBACK.x, 930, 40, '#6dff9b', g.kickbackFlash); + } + + /** A bold arrow-shaped target panel, pointing along its normal into the playfield. */ + drawArrowTarget(ctx, d, litColor, dimColor, lit, amount) { + if (amount <= 0.02) return; + // slot it sits in + ctx.save(); + ctx.translate(d.x, d.y); + ctx.rotate(Math.atan2(d.uy, d.ux)); + ctx.fillStyle = '#05030a'; + ctx.fillRect(-d.halfWidth - 2, -5, d.halfWidth * 2 + 4, 10); + ctx.restore(); + + const size = d.halfWidth * 1.9; + const angle = Math.atan2(d.ny, d.nx); + ctx.save(); + ctx.translate(d.x, d.y); + ctx.rotate(angle); + ctx.scale(1, amount); + ctx.beginPath(); + ctx.moveTo(size, 0); + ctx.lineTo(-size * 0.65, size * 0.8); + ctx.lineTo(-size * 0.3, 0); + ctx.lineTo(-size * 0.65, -size * 0.8); + ctx.closePath(); + const grad = ctx.createLinearGradient(-size * 0.6, 0, size, 0); + grad.addColorStop(0, dimColor); + grad.addColorStop(1, litColor); + ctx.fillStyle = grad; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = lit ? '#fffdf0' : '#241a04'; + ctx.stroke(); + ctx.restore(); + this.glow(ctx, d.x, d.y, size * (lit ? 2.6 : 1.6), litColor, lit ? 0.85 : d.flash * 0.9); + } + + drawTargets(ctx) { + const t = this.game.table; + for (const d of t.dropTargets) this.drawArrowTarget(ctx, d, '#ffb020', '#7a3d00', d.flash > 0.05, 1 - d.drop); + for (const s of t.standups) this.drawArrowTarget(ctx, s, s.lit ? '#fff4b8' : '#ffcf3f', s.lit ? '#ffdb70' : '#7a5a00', s.lit, 1); + } + + /** A jagged lightning-bolt decal running from (x1,y1) to (x2,y2), the slingshot face's theme. */ + drawLightningBolt(ctx, x1, y1, x2, y2, width, color, glowAmt) { + const dx = x2 - x1; + const dy = y2 - y1; + const nx = -dy; + const ny = dx; + const offsets = [0, 0.4, -0.3, 0.45, -0.2, 0]; + const pts = offsets.map((o, i) => { + const t = i / (offsets.length - 1); + return [x1 + dx * t + nx * o, y1 + dy * t + ny * o]; + }); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.beginPath(); + pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); + ctx.strokeStyle = '#000000aa'; + ctx.lineWidth = width * 0.6; + ctx.stroke(); + ctx.strokeStyle = color; + ctx.lineWidth = width * 0.4; + ctx.stroke(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = width * 0.15; + ctx.stroke(); + if (glowAmt > 0) this.glow(ctx, (x1 + x2) / 2, (y1 + y2) / 2, width * 4, color, glowAmt); + } + + drawSlings(ctx) { + for (const s of this.game.table.slings) { + const [tx, ty] = s.top; + const [bx, by] = s.bottom; + const [px, py] = s.tip; + // plastic + this.polygon(ctx, [s.top, s.bottom, s.tip]); + const grad = ctx.createLinearGradient(bx, ty, px, py); + grad.addColorStop(0, '#241832'); + grad.addColorStop(1, '#3a2450'); + ctx.fillStyle = grad; + ctx.fill(); + // rubber ring; the kicking face bulges briefly when it fires + const bulge = s.flash * 7; + ctx.beginPath(); + ctx.moveTo(tx, ty); + ctx.lineTo(bx, by); + ctx.lineTo(px, py); + ctx.quadraticCurveTo((tx + px) / 2 + s.nx * bulge, (ty + py) / 2 + s.ny * bulge, tx, ty); + ctx.closePath(); + ctx.lineJoin = 'round'; + ctx.strokeStyle = COLORS.rubber; + ctx.lineWidth = 9; + ctx.stroke(); + ctx.strokeStyle = '#00000033'; + ctx.lineWidth = 1; + ctx.stroke(); + // lightning-bolt decal on the plastic face + this.drawLightningBolt(ctx, tx, ty, px, py, 13, '#ffe14d', s.flash); + for (const [x, y] of [s.top, s.bottom, s.tip]) this.rubberPost(ctx, x, y, 5); + this.glow(ctx, (tx + px) / 2, (ty + py) / 2, 50, COLORS.sling, s.flash); + } + } + + drawBumpers(ctx) { + const g = this.game; + for (const b of g.table.bumpers) { + const color = COLORS.bumper[b.index]; + const lit = b.flash > 0 || g.superBumpers > 0; + // shadow and skirt + ctx.beginPath(); + ctx.arc(b.x + 3, b.y + 4, b.radius + 3, 0, Math.PI * 2); + ctx.fillStyle = '#00000066'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(b.x, b.y, b.radius + 2, 0, Math.PI * 2); + ctx.fillStyle = '#d8dde8'; + ctx.fill(); + // body + const body = ctx.createRadialGradient(b.x - 6, b.y - 8, 2, b.x, b.y, b.radius); + body.addColorStop(0, '#ffffff'); + body.addColorStop(0.35, color); + body.addColorStop(1, '#1a0a26'); + ctx.beginPath(); + ctx.arc(b.x, b.y, b.radius - 1 + b.flash * 1.5, 0, Math.PI * 2); + ctx.fillStyle = body; + ctx.fill(); + // cap + ctx.beginPath(); + ctx.arc(b.x, b.y, b.radius * 0.62, 0, Math.PI * 2); + ctx.fillStyle = lit ? '#fff7fb' : '#e9e3f5'; + ctx.fill(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.fillStyle = color; + ctx.font = '900 9px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(g.superBumpers > 0 ? '1000' : '100', b.x, b.y + 0.5); + this.glow(ctx, b.x, b.y, b.radius * 3, color, b.flash * 1.2 + (g.superBumpers > 0 ? 0.25 : 0)); + } + } + + drawGate(ctx) { + const gate = this.game.table.gate; + gate.swing = Math.max(0, gate.swing - 0.08); + ctx.strokeStyle = '#d8dde8'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(gate.ax, gate.ay); + ctx.lineTo(gate.bx - gate.swing * 6, gate.by + gate.swing * 10); + ctx.stroke(); + } + + drawPlunger(ctx) { + const y = PLUNGER_REST_Y + this.game.plunger.pos; + const x0 = LANE_LEFT + 2; + const w = LANE_RIGHT - LANE_LEFT - 4; + // spring + ctx.strokeStyle = '#9aa1ad'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const springTop = y + 12; + const springBottom = HEIGHT - 4; + const coils = 9; + for (let i = 0; i <= coils * 2; i++) { + const sy = springTop + ((springBottom - springTop) * i) / (coils * 2); + const sx = SHOOTER_X + (i % 2 ? 9 : -9); + i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy); + } + ctx.stroke(); + // rod + ctx.fillStyle = '#c9ced8'; + ctx.fillRect(SHOOTER_X - 3, y, 6, HEIGHT - y); + // tip + const tip = ctx.createLinearGradient(x0, 0, x0 + w, 0); + tip.addColorStop(0, '#7a0d2a'); + tip.addColorStop(0.5, '#ff3f6b'); + tip.addColorStop(1, '#7a0d2a'); + ctx.fillStyle = tip; + ctx.beginPath(); + ctx.roundRect(x0, y, w, 12, 3); + ctx.fill(); + // power meter while pulling + const p = this.game.plunger; + if (p.pulling) { + const h = 120 * p.pull; + ctx.fillStyle = '#ffffff22'; + ctx.fillRect(LANE_RIGHT + 3, PLUNGER_REST_Y - 120, 6, 120); + ctx.fillStyle = p.pull > 0.5 ? '#ff3f6b' : '#ffd23f'; + ctx.fillRect(LANE_RIGHT + 3, PLUNGER_REST_Y - h, 6, h); + // Soft plunge (skill shot) below the tick, full plunge (orbit) above it. + ctx.fillStyle = '#ffffff'; + ctx.fillRect(LANE_RIGHT + 1, PLUNGER_REST_Y - 61, 10, 2); + } + } + + // ---- Ramps, wireforms and the lock scoop --------------------------------------------------- + + /** Offset polylines (left/right rail) for a path of [x, y] screen points, `half` mm either side. */ + strokePath(ctx, pts) { + ctx.beginPath(); + pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); + ctx.stroke(); + } + + /** + * A glossy chrome tube along `pts`, the ramp/wireform look: several strokes of shrinking width and + * lightening colour, nested inside one another, is the standard 2D trick for a round cross-section — + * the same idea as `metalRail` but wider, with a coloured LED accent down one side that brightens when + * the jackpot behind it is lit. + */ + drawTube(ctx, pts, width, color, lit) { + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + const path = () => this.strokePath(ctx, pts); + // soft coloured glow behind the tube, stronger when lit + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = color + (lit ? '55' : '18'); + ctx.lineWidth = width + (lit ? 16 : 8); + path(); + ctx.restore(); + // dark drop shadow / outline + ctx.strokeStyle = 'rgba(3,2,8,0.75)'; + ctx.lineWidth = width + 5; + path(); + // chrome body: dark -> mid -> bright highlight, nested strokes read as a cylinder + ctx.strokeStyle = '#2e323e'; + ctx.lineWidth = width; + path(); + ctx.strokeStyle = '#aeb6c6'; + ctx.lineWidth = width * 0.66; + path(); + ctx.strokeStyle = '#f3f6fb'; + ctx.lineWidth = width * 0.26; + path(); + // coloured LED accent, offset to one edge of the tube like a light strip + const [ox, oy] = this.offsetPath(pts, width * 0.32); + ctx.strokeStyle = lit ? '#ffffff' : color; + ctx.lineWidth = width * 0.14; + ctx.beginPath(); + ox.forEach((x, i) => (i ? ctx.lineTo(x, oy[i]) : ctx.moveTo(x, oy[i]))); + ctx.stroke(); + if (lit) this.glow(ctx, pts[Math.floor(pts.length / 2)][0], pts[Math.floor(pts.length / 2)][1], width * 2.2, color, 0.5); + } + + /** Perpendicular-offset version of a polyline, for the accent stripe running along one edge of a tube. */ + offsetPath(pts, dist) { + const ox = []; + const oy = []; + for (let i = 0; i < pts.length; i++) { + const [x, y] = pts[i]; + const [px, py] = pts[Math.max(0, i - 1)]; + const [nx, ny] = pts[Math.min(pts.length - 1, i + 1)]; + let dx = nx - px; + let dy = ny - py; + const len = Math.hypot(dx, dy) || 1; + dx /= len; + dy /= len; + ox.push(x - dy * dist); + oy.push(y + dx * dist); + } + return [ox, oy]; + } + + /** A few metal cross-braces along the wireform, like the real thing. */ + drawWireBraces(ctx, pts, width) { + const [ox, oy] = this.offsetPath(pts, width * 0.55); + ctx.strokeStyle = '#9aa1ad99'; + ctx.lineWidth = 2; + for (let i = 2; i < pts.length - 2; i += 3) { + ctx.beginPath(); + ctx.moveTo(pts[i][0] - (ox[i] - pts[i][0]), pts[i][1] - (oy[i] - pts[i][1])); + ctx.lineTo(ox[i], oy[i]); + ctx.stroke(); + } + } + + drawRamps(ctx) { + const g = this.game; + for (const ramp of g.table.ramps) { + const track = ramp.track; + const lit = g.state === 'multiball' && g.jackpot[track.side]; + // Shadow the whole track casts on the playfield below it. + ctx.beginPath(); + track.points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y + 9) : ctx.moveTo(x, y + 9))); + ctx.strokeStyle = 'rgba(4,2,10,0.4)'; + ctx.lineWidth = track.width * 0.8; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // The first ~42% is the solid climb (a real plastic ramp); the rest is the wire return. + const splitS = track.length * 0.42; + const plastic = []; + const wire = []; + track.points.forEach(([x, y, z], i) => { + const p = [x, this.liftY(y, z)]; + (track.cum[i] <= splitS ? plastic : wire).push(p); + }); + if (plastic.length > 1) this.drawTube(ctx, plastic, 30, track.color, lit); + if (wire.length > 1) { + if (plastic.length) wire.unshift(plastic[plastic.length - 1]); + this.drawTube(ctx, wire, 20, track.color, lit); + this.drawWireBraces(ctx, wire, 20); + } + } + } + + /** The scoop hole itself — drawn early, since the ramps' clear plastic legitimately arcs over it. */ + drawLockHole(ctx) { + const lock = this.game.table.lock; + ctx.beginPath(); + ctx.arc(lock.x, lock.y, lock.radius + 4, 0, Math.PI * 2); + ctx.fillStyle = '#8a91a0'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(lock.x, lock.y, lock.radius, 0, Math.PI * 2); + const grad = ctx.createRadialGradient(lock.x, lock.y, 0, lock.x, lock.y, lock.radius); + grad.addColorStop(0, '#020103'); + grad.addColorStop(1, '#1c1530'); + ctx.fillStyle = grad; + ctx.fill(); + ctx.strokeStyle = '#4b4160'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + /** Lock/jackpot readouts — drawn after the ramps so they stay legible where the loops cross above it. */ + drawLockLamps(ctx) { + const g = this.game; + const lock = g.table.lock; + this.glow(ctx, lock.x, lock.y, 60, COLORS.lock, g.lockFlash); + + for (let i = 0; i < 2; i++) { + const a = Math.PI * 0.5 + (i - 0.5) * 1.0; + const lx = lock.x + Math.cos(a) * (lock.radius + 11); + const ly = lock.y + Math.sin(a) * (lock.radius + 11); + const filled = i < g.locked; + ctx.beginPath(); + ctx.arc(lx, ly, 5, 0, Math.PI * 2); + const bg = ctx.createRadialGradient(lx - 1.5, ly - 1.5, 0.5, lx, ly, 5); + bg.addColorStop(0, filled ? '#ffffff' : '#4a4460'); + bg.addColorStop(1, filled ? '#9aa4b8' : '#2a2540'); + ctx.fillStyle = bg; + ctx.fill(); + ctx.strokeStyle = filled ? '#ffffff' : '#5a5270'; + ctx.lineWidth = 1; + ctx.stroke(); + } + + ctx.fillStyle = '#ffd23fdd'; + ctx.font = '800 8px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.strokeStyle = '#07061099'; + ctx.lineWidth = 2.5; + ctx.strokeText('LOCK', lock.x, lock.y + lock.radius + 24); + ctx.fillText('LOCK', lock.x, lock.y + lock.radius + 24); + + const superLit = g.jackpot.super && this.blink(4); + this.arrow(ctx, lock.x, lock.y - lock.radius - 15, 8, Math.PI / 2, '#ff3fa4', superLit); + if (g.jackpot.super) { + ctx.fillStyle = this.blink(4) ? '#ff3fa4dd' : '#ff3fa455'; + ctx.font = '800 7px system-ui, sans-serif'; + ctx.strokeText('SUPER', lock.x, lock.y - lock.radius - 26); + ctx.fillText('SUPER', lock.x, lock.y - lock.radius - 26); + } + } + + drawBalls(ctx, alpha) { + for (const ball of this.game.balls) this.drawBall(ctx, ball, alpha); + } + + drawBall(ctx, b, alpha) { + const gx = b.prevX + (b.x - b.prevX) * alpha; + const gy = b.prevY + (b.y - b.prevY) * alpha; + const z = b.prevZ + (b.z - b.prevZ) * alpha; + const r = b.radius; + // Riding a ramp: nudge the sprite toward the viewer and enlarge it a touch to read as "lifted off + // the playfield", while the shadow stays pinned to the true (unlifted) position below it. + const x = gx; + const y = this.liftY(gy, z); + const scale = 1 + z * 0.0035; + + // motion trail (tracked per ball so multiball doesn't cross-contaminate trails) + let trail = this.trails.get(b); + if (!trail) this.trails.set(b, (trail = [])); + trail.push({ x, y }); + if (trail.length > 7) trail.shift(); + const speed = b.speed; + if (speed > 900 && !b.track) { + const k = Math.min(1, (speed - 900) / 2500); + trail.forEach((p, i) => { + ctx.beginPath(); + ctx.arc(p.x, p.y, r * (0.5 + (0.5 * i) / trail.length), 0, Math.PI * 2); + ctx.fillStyle = `rgba(190,220,255,${(0.12 * k * i) / trail.length})`; + ctx.fill(); + }); + } + // shadow on the playfield beneath (offset further and softer the higher the ball rides) + ctx.beginPath(); + ctx.ellipse(gx + 4 + z * 0.15, gy + 5, r * (1.02 + z * 0.01), r * (0.9 + z * 0.01), 0, 0, Math.PI * 2); + ctx.fillStyle = `rgba(0,0,0,${Math.max(0.2, 0.44 - z * 0.005)})`; + ctx.fill(); + // chrome + const rr = r * scale; + const grad = ctx.createRadialGradient(x - rr * 0.35, y - rr * 0.4, rr * 0.08, x, y, rr); + grad.addColorStop(0, '#ffffff'); + grad.addColorStop(0.25, '#dfe6f2'); + grad.addColorStop(0.65, '#7c8597'); + grad.addColorStop(1, '#262b36'); + ctx.beginPath(); + ctx.arc(x, y, rr, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + // coloured environment reflections + ctx.globalCompositeOperation = 'lighter'; + ctx.beginPath(); + ctx.arc(x + rr * 0.3, y + rr * 0.35, rr * 0.45, 0, Math.PI * 2); + ctx.fillStyle = '#ff3fa422'; + ctx.fill(); + ctx.globalCompositeOperation = 'source-over'; + } + + drawFlipper(ctx, f, angle) { + const c = Math.cos(angle); + const s = Math.sin(angle); + const tx = f.x + c * f.length; + const ty = f.y + s * f.length; + // Tangent directions of the tapered sides (same maths as the collision shape). + const phi = Math.atan2(f.a, f.b); + const a1 = angle + phi; + const a2 = angle - phi; + const path = () => { + ctx.beginPath(); + ctx.moveTo(f.x + Math.cos(a1) * f.baseRadius, f.y + Math.sin(a1) * f.baseRadius); + ctx.lineTo(tx + Math.cos(a1) * f.tipRadius, ty + Math.sin(a1) * f.tipRadius); + ctx.arc(tx, ty, f.tipRadius, a1, a2, true); + ctx.lineTo(f.x + Math.cos(a2) * f.baseRadius, f.y + Math.sin(a2) * f.baseRadius); + ctx.arc(f.x, f.y, f.baseRadius, a2, a1, true); + ctx.closePath(); + }; + // shadow + ctx.save(); + ctx.translate(3, 5); + path(); + ctx.fillStyle = '#00000066'; + ctx.fill(); + ctx.restore(); + // body + rubber + path(); + const grad = ctx.createLinearGradient(f.x, f.y - 12, f.x, f.y + 12); + grad.addColorStop(0, '#ffffff'); + grad.addColorStop(1, '#cfd3dc'); + ctx.fillStyle = grad; + ctx.fill(); + ctx.lineWidth = 3; + ctx.strokeStyle = '#e8254f'; + ctx.stroke(); + // pivot + ctx.beginPath(); + ctx.arc(f.x, f.y, 4, 0, Math.PI * 2); + ctx.fillStyle = '#8a91a0'; + ctx.fill(); + ctx.strokeStyle = '#4b5160'; + ctx.lineWidth = 1; + ctx.stroke(); + } + + drawApronLamps(ctx) { + const g = this.game; + // Ball-in-play indicator on the apron. + for (let i = 1; i <= 3; i++) { + const on = g.state !== 'attract' && g.state !== 'gameover' && i === g.ballNumber; + this.lamp(ctx, CX - 24 + (i - 1) * 24, APRON_Y + 40, 8, '#ffd23f', on, `${i}`); + } + ctx.fillStyle = '#ffd23f99'; + ctx.font = '800 8px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('BALL IN PLAY', CX, APRON_Y + 62); + } + + drawParticles(ctx, dt) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + this.particles = this.particles.filter((p) => (p.life -= dt) > 0); + for (const p of this.particles) { + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vx *= 0.92; + p.vy *= 0.92; + ctx.globalAlpha = Math.min(1, p.life * 2); + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, 1.8, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + this.popups = this.popups.filter((p) => (p.life -= dt) > 0); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '800 13px system-ui, sans-serif'; + for (const p of this.popups) { + const k = p.life / 0.9; + ctx.globalAlpha = Math.min(1, k * 1.8); + ctx.fillStyle = '#fff6c8'; + ctx.strokeStyle = '#2a0a2a'; + ctx.lineWidth = 3; + const y = p.y - (1 - k) * 30; + ctx.strokeText(p.text, p.x, y); + ctx.fillText(p.text, p.x, y); + } + ctx.globalAlpha = 1; + } + + drawOverlay(ctx) { + const g = this.game; + let title = null; + let sub = null; + if (this.paused) { + title = 'PAUSED'; + sub = 'PRESS P TO RESUME'; + } else if (g.state === 'attract') { + title = 'NEON PINBALL'; + sub = this.blink(1.2) ? 'PRESS SPACE TO START' : ''; + } else if (g.state === 'gameover') { + title = 'GAME OVER'; + sub = this.blink(1.2) ? 'PRESS SPACE TO PLAY AGAIN' : ''; + } else if (g.state === 'play' && !g.inPlay && g.ball && g.ball.x > LANE_LEFT && g.ball.y > LANE_TOP && !g.plunger.pulling) { + ctx.save(); + ctx.translate(SHOOTER_X, 870); + ctx.rotate(-Math.PI / 2); + ctx.fillStyle = this.blink(2) ? '#ffd23f' : '#ffd23f55'; + ctx.font = '800 10px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('HOLD SPACE', 0, 0); + ctx.restore(); + } + if (!title) return; + ctx.fillStyle = '#07061099'; + ctx.fillRect(LEFT, 400, RIGHT - LEFT, 150); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = 'italic 900 44px system-ui, sans-serif'; + ctx.fillStyle = '#ffffff'; + ctx.shadowColor = '#ff3fa4'; + ctx.shadowBlur = 18; + ctx.fillText(title, CX, 455); + ctx.shadowBlur = 0; + if (g.state === 'gameover') { + ctx.font = '800 20px system-ui, sans-serif'; + ctx.fillStyle = '#34e7ff'; + ctx.fillText(`SCORE ${fmt(g.score)}`, CX, 495); + } + if (sub) { + ctx.font = '800 14px system-ui, sans-serif'; + ctx.fillStyle = '#ffd23f'; + ctx.fillText(sub, CX, 528); + } + } +} diff --git a/public/js/table.js b/public/js/table.js new file mode 100644 index 0000000..74f3e67 --- /dev/null +++ b/public/js/table.js @@ -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; +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..c3c4295 --- /dev/null +++ b/public/style.css @@ -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; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..ca556d3 --- /dev/null +++ b/server.js @@ -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.'); +}); diff --git a/test/game.test.js b/test/game.test.js new file mode 100644 index 0000000..17fc4d1 --- /dev/null +++ b/test/game.test.js @@ -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. +}); diff --git a/test/physics.test.js b/test/physics.test.js new file mode 100644 index 0000000..7ca1dda --- /dev/null +++ b/test/physics.test.js @@ -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}`); +});