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
218 lines
8.5 KiB
TypeScript
218 lines
8.5 KiB
TypeScript
/**
|
|
* The railroad, heard.
|
|
*
|
|
* SYNTHESISED, NOT SAMPLED, and that is a constraint rather than a preference. The site is a static
|
|
* folder that fetches nothing — there is a test asserting no page reaches an external host — so an
|
|
* audio file would have to be committed to the repo, and a plausible-sounding whistle is not
|
|
* something to invent. See TODO for sourcing real clips; these stand in until then, which is why
|
|
* sound defaults to off.
|
|
*
|
|
* SELF-CONTAINED ON PURPOSE, like the two board renderers. The playable page imports this; the
|
|
* replay is a single HTML file with an inline script and cannot import anything, so it embeds the
|
|
* function via `Function.toString()`. One implementation either way — a second copy would drift,
|
|
* and a replay that sounds different from the game is worse than a replay with no sound.
|
|
*
|
|
* The AudioContext is parked on `window` rather than in a module variable for the same reason: the
|
|
* embedded copy has no module scope to keep it in, and browsers permit only a handful per page.
|
|
*/
|
|
export function playCue(cue: string): void {
|
|
const w = window as unknown as { __smAudio?: AudioContext; AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
|
|
|
|
// "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built
|
|
// into the browser, needs no asset and works offline — but it is whatever system voice the player
|
|
// has, so a real clip is the first thing to replace here.
|
|
if (cue === 'train') {
|
|
try {
|
|
if (window.speechSynthesis && typeof SpeechSynthesisUtterance === 'function') {
|
|
const u = new SpeechSynthesisUtterance('All aboard!');
|
|
u.rate = 0.95;
|
|
u.pitch = 0.85;
|
|
u.volume = 0.9;
|
|
window.speechSynthesis.speak(u);
|
|
return;
|
|
}
|
|
} catch {
|
|
// no voice available — fall through to the two-note call below
|
|
}
|
|
}
|
|
|
|
let c: AudioContext;
|
|
try {
|
|
const Ctor = w.AudioContext ?? w.webkitAudioContext;
|
|
if (!Ctor) return;
|
|
// Browsers refuse to start audio until the player has interacted with the page, so the context
|
|
// is made on first use — by which time they have clicked something — and resumed if suspended.
|
|
w.__smAudio ??= new Ctor();
|
|
c = w.__smAudio;
|
|
if (c.state === 'suspended') void c.resume();
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
/** A tone with an envelope, so nothing starts or stops with a click. */
|
|
const tone = (at: number, freq: number, dur: number, gain: number, type: OscillatorType, bendTo?: number): void => {
|
|
const osc = c.createOscillator();
|
|
const amp = c.createGain();
|
|
osc.type = type;
|
|
osc.frequency.setValueAtTime(freq, at);
|
|
if (bendTo !== undefined) osc.frequency.linearRampToValueAtTime(bendTo, at + dur);
|
|
amp.gain.setValueAtTime(0, at);
|
|
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.04, dur / 4));
|
|
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
|
osc.connect(amp).connect(c.destination);
|
|
osc.start(at);
|
|
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') {
|
|
// A real steam whistle is a CHORD of several chambers slightly out of tune with each other,
|
|
// plus the breath of the steam. The beating between the partials IS the sound.
|
|
const d = 1.8;
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
|
|
if (cue === 'couple') {
|
|
/**
|
|
* KNUCKLE COUPLERS MEETING: a low metallic clank and the slack running out behind it.
|
|
*
|
|
* Coupling a cut was reported as invisible — the car simply left the board and only history said
|
|
* where it went. It is the single most consequential thing a switching move does, so it gets the
|
|
* one sound a player will learn to listen for.
|
|
*/
|
|
for (const fg of [[86, 0.22], [131, 0.13], [197, 0.07], [61, 0.18]]) {
|
|
tone(t, fg[0]!, 0.26, fg[1]!, 'square', fg[0]! * 0.82);
|
|
}
|
|
tone(t + 0.1, 74, 0.34, 0.1, 'triangle', 58);
|
|
return;
|
|
}
|
|
|
|
if (cue === 'drop') {
|
|
// Setting out is the same act in reverse and quieter: one clank, no slack behind it.
|
|
for (const fg of [[104, 0.14], [156, 0.08]]) {
|
|
tone(t, fg[0]!, 0.18, fg[1]!, 'square', fg[0]! * 0.85);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (cue === 'schedule') {
|
|
// A die landing on the despatcher's desk, then the slot taking it: a short clatter and a chime.
|
|
for (const at of [t, t + 0.05, t + 0.09]) tone(at, 200 + Math.random() * 90, 0.05, 0.07, 'square');
|
|
tone(t + 0.16, 880, 0.3, 0.09, 'sine');
|
|
tone(t + 0.16, 1320, 0.28, 0.05, 'sine');
|
|
return;
|
|
}
|
|
|
|
if (cue === 'completed') {
|
|
// A train clearing the Division and whistling off: two long horn notes falling away, with the
|
|
// second quieter and lower so it reads as distance rather than repetition. Longer than any other
|
|
// cue on purpose — it is the only one that pays every player, and it should land as an event.
|
|
tone(t, 392, 0.55, 0.13, 'sawtooth', 370);
|
|
tone(t, 262, 0.55, 0.09, 'triangle', 247);
|
|
tone(t + 0.42, 330, 0.75, 0.08, 'sawtooth', 300);
|
|
tone(t + 0.42, 220, 0.75, 0.055, 'triangle', 200);
|
|
return;
|
|
}
|
|
|
|
if (cue === 'day') {
|
|
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
|
|
for (const at of [t, t + 0.22]) {
|
|
for (const fg of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]]) {
|
|
tone(at, fg[0]!, 0.4, fg[1]!, 'square');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (cue === 'train') {
|
|
// Reached only when the platform has no voice: a two-note conductor's call rather than silence.
|
|
tone(t, 523, 0.22, 0.14, 'triangle');
|
|
tone(t + 0.2, 784, 0.34, 0.14, 'triangle');
|
|
}
|
|
}
|