v0.4.9 — X,Y coordinates, no-switching can still clear the mainline, Q3 corrected, a clearance bug, three sounds, and the box art

A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the
first real audio beyond a placeholder.

- Coordinate labels read X,Y everywhere shown to a player, not the internal Y,X storage order.
  Display-only.
- "No switching" now means may not add or drop cars, not "never touch it" — these trains can
  still be moved onto Secondary Track to clear the mainline.
- Q3 corrected: Expedite governs WHERE a train may be left standing, not WHEN it leaves. The
  forced same-Stage departure is gone; a new fault costs 1 Revenue if an expedited train is left
  off the station when a Mainline Phase begins. Resolves "3/4 Express prints a rule it can never
  use" as a side effect.
- evaluateClearance now checks every occupant on a Mainline card before offering a judgment
  call, instead of returning on whichever it found first — found while explaining a playtest
  report, fixed with a regression test.
- Three new synthesised sounds: arrive, depart, crash.
- The splash page shows the box art.

Full detail, measurements and reasoning in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvU99NEakShRMg3nN3fHAZ
This commit is contained in:
Jesse
2026-08-19 23:51:35 -04:00
co-authored by Claude Sonnet 5
parent 37b1e5b969
commit 859817d173
25 changed files with 1048 additions and 739 deletions
+81 -17
View File
@@ -64,6 +64,38 @@ export function playCue(cue: string): void {
osc.stop(at + dur + 0.02);
};
/**
* A burst of filtered noise — the breath of steam behind a whistle, one chuff of a moving
* locomotive, or the boom of an impact. Shared by `stage` (one long burst) and the chuffs in
* `arrive`/`depart` (several short ones), and the wide-open burst under `crash`.
*/
const noiseBurst = (
at: number,
dur: number,
gain: number,
freq: number,
q: number,
type: BiquadFilterType = 'bandpass',
): void => {
const frames = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, frames, c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
const filt = c.createBiquadFilter();
filt.type = type;
filt.frequency.value = freq;
filt.Q.value = q;
const amp = c.createGain();
amp.gain.setValueAtTime(0, at);
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.05, dur / 4));
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(filt).connect(amp).connect(c.destination);
src.start(at);
src.stop(at + dur + 0.02);
};
const t = c.currentTime + 0.02;
if (cue === 'stage') {
@@ -73,23 +105,55 @@ export function playCue(cue: string): void {
for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) {
tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985);
}
const frames = Math.floor(c.sampleRate * d);
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
const band = c.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.8;
const amp = c.createGain();
amp.gain.setValueAtTime(0, t);
amp.gain.linearRampToValueAtTime(0.05, t + 0.05);
amp.gain.exponentialRampToValueAtTime(0.0001, t + d);
src.connect(band).connect(amp).connect(c.destination);
src.start(t);
src.stop(t + d);
noiseBurst(t, d, 0.05, 1400, 0.8);
return;
}
if (cue === 'arrive') {
/**
* A LOCOMOTIVE SLOWING TO A STOP: chuffs (one per wheel revolution) spaced FARTHER apart as
* road speed drops, then a soft two-partial bell tap as it settles — a lighter cousin of the
* `day` bell, not a repeat of it.
*/
let at = t;
const gaps = [0.13, 0.17, 0.22, 0.28];
for (let i = 0; i < gaps.length; i++) {
noiseBurst(at, 0.07, 0.11 - i * 0.015, 190, 1.1, 'bandpass');
at += gaps[i]!;
}
for (const fg of [[1046, 0.07], [1568, 0.04]]) {
tone(at + 0.12, fg[0]!, 0.3, fg[1]!, 'sine');
}
return;
}
if (cue === 'depart') {
/**
* THE INVERSE OF `arrive`: chuffs starting slow and pulling closer together as the train
* gathers speed, then one falling, fading horn note — a single call, not `completed`'s pair,
* since this train is only leaving the Office, not clearing the whole Division.
*/
let at = t;
const gaps = [0.22, 0.15, 0.1, 0.07, 0.05];
for (let i = 0; i < gaps.length; i++) {
noiseBurst(at, 0.06, 0.06 + i * 0.012, 210, 1.2, 'bandpass');
at += gaps[i]!;
}
tone(at, 349, 0.45, 0.1, 'sawtooth', 300);
return;
}
if (cue === 'crash') {
/**
* IMPACT, NOT MUSIC: two tones bent sharply downward at a DISSONANT (non-harmonic) interval,
* so the ear reads a collision rather than a chord — `couple`'s clank is consonant on purpose,
* and this is its opposite. A wide-open noise boom and a percussive sub-bass thump give it
* weight under the screech.
*/
tone(t, 311, 0.35, 0.16, 'square', 70);
tone(t, 233, 0.4, 0.13, 'sawtooth', 52);
noiseBurst(t, 0.4, 0.16, 900, 0.4, 'lowpass');
tone(t, 45, 0.3, 0.18, 'sine');
return;
}