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:
+3
-2
@@ -344,7 +344,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
const tray = game.state.trays.get(trayId);
|
||||
const where =
|
||||
tray?.position.at === 'grid'
|
||||
? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})`
|
||||
? `, standing at (${tray.position.coord.col}, ${tray.position.coord.row})`
|
||||
: '';
|
||||
groups.push({
|
||||
kind: prefix,
|
||||
@@ -670,7 +670,8 @@ function subjectOf(
|
||||
node?: number;
|
||||
links: string[];
|
||||
} | null {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
const at = (c: { row: number; col: number }): string => `(${c.col}, ${c.row})`;
|
||||
/**
|
||||
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
|
||||
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
|
||||
|
||||
+43
-29
@@ -11,6 +11,11 @@ body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
|
||||
main{width:100%;max-width:760px}
|
||||
.hero{display:flex;gap:24px;align-items:flex-start}
|
||||
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line)}
|
||||
.hero-image img{display:block;width:100%;height:auto}
|
||||
.hero-content{flex:1;min-width:0}
|
||||
@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}}
|
||||
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
||||
.tag{color:var(--dim);margin:0;font-size:14px}
|
||||
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
|
||||
@@ -32,39 +37,48 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||
<div class="hero">
|
||||
<div class="hero-image">
|
||||
<img src="./images/station-master-splash.jpg"
|
||||
alt="Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office."
|
||||
width="560" height="700">
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||
<g id="ties"></g>
|
||||
</svg>
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||
<g id="ties"></g>
|
||||
</svg>
|
||||
|
||||
<p class="blurb">
|
||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
<p class="blurb">
|
||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
|
||||
<div class="doors">
|
||||
<a class="door" href="./play.html">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
without clearing your cache, your game is preserved and you can continue automatically.
|
||||
During the game you can also explicitly save your progress for later replay.</p>
|
||||
<span class="go">Start a game →</span>
|
||||
</a>
|
||||
<div class="doors">
|
||||
<a class="door" href="./play.html">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
without clearing your cache, your game is preserved and you can continue automatically.
|
||||
During the game you can also explicitly save your progress for later replay.</p>
|
||||
<span class="go">Start a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./replays.html">
|
||||
<h2>Replays</h2>
|
||||
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
|
||||
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
|
||||
<span class="go">Browse replays →</span>
|
||||
</a>
|
||||
<a class="door" href="./replays.html">
|
||||
<h2>Replays</h2>
|
||||
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
|
||||
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
|
||||
<span class="go">Browse replays →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rule"></div>
|
||||
|
||||
+2
-2
@@ -819,8 +819,8 @@ function renderActions(
|
||||
.map(
|
||||
(m) =>
|
||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
|
||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
|
||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.col}, ${m.from.row}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||
`${esc(m.label)} <span class="dim">(${m.from.col}, ${m.from.row})</span></button>`,
|
||||
)
|
||||
.join('') +
|
||||
`</div>`;
|
||||
|
||||
+81
-17
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user