Fixed division end of track labels and other minor bugs. Trains now show their information. Play cards now required to get to 3 or less, no other restrictions. auto hide now more prominent. it and sounds now are in replay.
This commit is contained in:
+93
-118
@@ -4,133 +4,108 @@
|
||||
* 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. The Web Audio API can build these from oscillators at no download cost, and
|
||||
* what it produces is honestly synthetic rather than a fake recording.
|
||||
* something to invent. See TODO for sourcing real clips; these stand in until then, which is why
|
||||
* sound defaults to off.
|
||||
*
|
||||
* A steam whistle is the one that repays the effort: a real one is a CHORD of several chambers
|
||||
* slightly out of tune with each other, plus the breath of the steam itself. Three detuned partials
|
||||
* and a band of noise is most of the way there.
|
||||
* 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.
|
||||
*
|
||||
* "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built
|
||||
* into the browser, needs no asset and works offline, so it says the words — and where the platform
|
||||
* has no voice installed, a two-note call takes its place rather than nothing happening.
|
||||
* 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 };
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
/**
|
||||
* Browsers refuse to start audio until the player has interacted with the page, so the context is
|
||||
* created on first use — by which time they have clicked something — and resumed if it was
|
||||
* suspended in the meantime.
|
||||
*/
|
||||
function audio(): AudioContext | null {
|
||||
try {
|
||||
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
ctx ??= new Ctor();
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
return ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A tone with an attack/decay envelope, so nothing starts or stops with a click. */
|
||||
function tone(
|
||||
c: AudioContext,
|
||||
at: number,
|
||||
freq: number,
|
||||
dur: number,
|
||||
gain: number,
|
||||
type: OscillatorType = 'sine',
|
||||
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);
|
||||
}
|
||||
|
||||
/** The escaping steam under a whistle — filtered noise, without which it is just a chord. */
|
||||
function breath(c: AudioContext, at: number, dur: number, gain: number): void {
|
||||
const frames = Math.floor(c.sampleRate * dur);
|
||||
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, at);
|
||||
amp.gain.linearRampToValueAtTime(gain, at + 0.05);
|
||||
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
src.connect(band).connect(amp).connect(c.destination);
|
||||
src.start(at);
|
||||
src.stop(at + dur);
|
||||
}
|
||||
|
||||
/** End of a Stage. A three-chime whistle, one long call. */
|
||||
export function whistle(): void {
|
||||
const c = audio();
|
||||
if (!c) return;
|
||||
const t = c.currentTime + 0.02;
|
||||
const d = 0.9;
|
||||
// A minor-third-ish cluster, deliberately not in tune with itself — that beating IS the sound.
|
||||
for (const [f, g] of [[392, 0.16], [466, 0.13], [587, 0.10], [784, 0.05]] as const) {
|
||||
tone(c, t, f, d, g, 'sawtooth', f * 0.985);
|
||||
}
|
||||
breath(c, t, d, 0.05);
|
||||
}
|
||||
|
||||
/** End of a Day. The grade-crossing bell: two strikes, metallic and inharmonic. */
|
||||
export function clang(): void {
|
||||
const c = audio();
|
||||
if (!c) return;
|
||||
const t = c.currentTime + 0.02;
|
||||
for (const at of [t, t + 0.22]) {
|
||||
// Inharmonic partials are what separate a bell from a beep.
|
||||
for (const [f, g] of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]] as const) {
|
||||
tone(c, at, f, 0.4, g, 'square');
|
||||
// "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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A train is added to the board. */
|
||||
export function allAboard(): void {
|
||||
let c: AudioContext;
|
||||
try {
|
||||
const s = window.speechSynthesis;
|
||||
if (s && typeof SpeechSynthesisUtterance === 'function') {
|
||||
const u = new SpeechSynthesisUtterance('All aboard!');
|
||||
u.rate = 0.95;
|
||||
u.pitch = 0.85;
|
||||
u.volume = 0.9;
|
||||
s.speak(u);
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
// fall through to the call below
|
||||
return;
|
||||
}
|
||||
// No voice on this platform: a two-note conductor's call rather than silence.
|
||||
const c = audio();
|
||||
if (!c) return;
|
||||
const t = c.currentTime + 0.02;
|
||||
tone(c, t, 523, 0.22, 0.14, 'triangle');
|
||||
tone(c, t + 0.2, 784, 0.34, 0.14, 'triangle');
|
||||
}
|
||||
|
||||
/** Cue names the model emits; the view decides what they sound like. */
|
||||
export function play(cue: string): void {
|
||||
if (cue === 'stage') whistle();
|
||||
else if (cue === 'day') clang();
|
||||
else if (cue === 'train') allAboard();
|
||||
/** 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);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user