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:
@@ -51,7 +51,12 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
const CW = { dp: 118, ml: 152, run: 78 };
|
||||
const CH = 58;
|
||||
const GAP = 6;
|
||||
const PAD = 26;
|
||||
/**
|
||||
* Room for the buffer stops AND their labels. "trains enter" hangs off the west end and "trains
|
||||
* leave" off the east, so a padding sized for the stop alone clipped both to their middle three
|
||||
* letters — the west end read "TER" and the east "tra".
|
||||
*/
|
||||
const PAD = 92;
|
||||
const SIDE_GAP = 34;
|
||||
|
||||
const esc = (t: string): string =>
|
||||
|
||||
@@ -536,3 +536,25 @@ export function trainPositions(s: GameState): { id: TrayId; label: string; where
|
||||
}
|
||||
|
||||
export { coordKey };
|
||||
|
||||
|
||||
/**
|
||||
* What a batch of events should SOUND like.
|
||||
*
|
||||
* Shared between the live game and the replay so the two cannot disagree about when a Stage ended.
|
||||
* The model names what happened; the page decides what it sounds like.
|
||||
*/
|
||||
export function cuesFor(events: readonly GameEvent[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const e of events) {
|
||||
if (e.type === 'trainMadeUp') out.push('train');
|
||||
if (e.type === 'stageBegan') {
|
||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
||||
// and the bell is the bigger event.
|
||||
if (e.day > 1 && e.stage === 1) out.push('day');
|
||||
else if (e.day > 1 || e.stage > 1) out.push('stage');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+65
-4
@@ -28,7 +28,7 @@ import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||
import { carLabel, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
|
||||
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
|
||||
@@ -37,6 +37,7 @@ import type { Frame } from './view.ts';
|
||||
import type { Decision } from './view.ts';
|
||||
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
|
||||
import { playCue } from '../web/sound.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame shape — only what the viewer draws
|
||||
@@ -104,7 +105,13 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
d !== null &&
|
||||
(d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
|
||||
visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged');
|
||||
frames.push(snapshot(s, lines, where ?? null, from, d, wasted));
|
||||
const frame = snapshot(s, lines, where ?? null, from, d, wasted);
|
||||
// Cues come from the RAW events, not the visible ones: what a frame sounds like is a different
|
||||
// question from what it prints, and `cuesFor` is shared with the live game so the replay cannot
|
||||
// sound a Stage the game did not.
|
||||
const cues = cuesFor(events);
|
||||
if (cues.length > 0) frame.cues = cues;
|
||||
frames.push(frame);
|
||||
};
|
||||
|
||||
frames.push(
|
||||
@@ -283,6 +290,14 @@ margin-top:5px;font-size:11px;line-height:1.35}
|
||||
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
|
||||
.box.empty{background:transparent;color:var(--dim)}
|
||||
.log{max-height:230px;overflow:auto}
|
||||
/* Auto-hide, the same as the playable page. Folded leaves a summary rather than nothing, because a
|
||||
panel that disappears entirely reads as broken instead of folded away. */
|
||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:#fff}
|
||||
#district.folded .grid{display:none}
|
||||
#district.folded #districtsummary{display:block;padding:2px 0}
|
||||
#districtsummary{display:none}
|
||||
.line{padding:2px 0;border-bottom:1px solid #23272e}
|
||||
.t-good{color:var(--good)}.t-bad{color:var(--bad)}.t-clock{color:var(--clock);font-weight:700}
|
||||
.t-quiet{color:var(--dim)}.t-plain{color:var(--fg)}
|
||||
@@ -341,7 +356,9 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
|
||||
<section><h2>Office Area</h2><div class="grid" id="grid"></div></section>
|
||||
<section id="district"><h2>Office Area <button id="districttoggle" class="ghost">auto-hide: on</button></h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<div class="grid" id="grid"></div></section>
|
||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||
<section><h2>Timetable — which train is due out at each Stage</h2><div id="tt"></div></section>
|
||||
</div>
|
||||
@@ -372,6 +389,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
|
||||
<footer>
|
||||
<button id="first">⏮ start</button>
|
||||
<button id="sound">🔇 muted</button>
|
||||
<button id="back">◀ step</button>
|
||||
<button id="play">▶ play</button>
|
||||
<button id="fwd">step ▶</button>
|
||||
@@ -396,6 +414,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
from the same state, which is the one failure a track diagram exists to prevent. */
|
||||
const divisionSvg = ${divisionSvg.toString()};
|
||||
const officeSvg = ${officeSvg.toString()};
|
||||
const playCue = ${playCue.toString()};
|
||||
|
||||
const PACKED = ${JSON.stringify(compress(rec.frames))};
|
||||
const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats;
|
||||
@@ -409,6 +428,11 @@ function carry(i, key) {
|
||||
const rehydrateCells = ${rehydrateCells.toString()};
|
||||
function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); }
|
||||
let i = 0, timer = null;
|
||||
/* Sound and auto-hide, the same two the playable page has — a replay is for watching a game back,
|
||||
so it should behave like one. Both default to the quiet, tidy setting. */
|
||||
let soundOn = false;
|
||||
let districtMode = 'auto';
|
||||
const FOCUS_PHASES = { localOps: 1, loadUnload: 1 };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||
|
||||
@@ -459,6 +483,20 @@ function render() {
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
|
||||
|
||||
/* Auto-hide: the district is worth its space during the phases that change it, and folded
|
||||
otherwise. A summary stays, because a panel that vanishes reads as broken. */
|
||||
const districtOpen = districtMode === 'auto' ? !!FOCUS_PHASES[f.phaseKey] : districtMode === 'open';
|
||||
const sec = $('district');
|
||||
if (districtOpen) sec.classList.remove('folded'); else sec.classList.add('folded');
|
||||
let carsStanding = 0;
|
||||
for (const c of CELLS) carsStanding += c.cars.length;
|
||||
$('districtsummary').textContent =
|
||||
CELLS.length + ' cards · ' + FACS.length + ' facilities · ' + carsStanding + ' cars standing';
|
||||
$('districttoggle').textContent =
|
||||
districtMode === 'auto'
|
||||
? (districtOpen ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
||||
: districtMode === 'open' ? 'always showing — click for auto-hide' : 'always hidden — click for auto-hide';
|
||||
if (f.where) {
|
||||
const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]');
|
||||
if (hit) hit.classList.add('bs-focus');
|
||||
@@ -521,9 +559,32 @@ function render() {
|
||||
$('log').scrollTop = $('log').scrollHeight;
|
||||
}
|
||||
|
||||
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
|
||||
/* Sounds fire only when moving FORWARD one frame at a time — stepping or playing. Scrubbing across
|
||||
a hundred frames would otherwise fire a hundred whistles at once, and stepping backwards would
|
||||
sound a Stage ending that is being un-done. */
|
||||
function go(n) {
|
||||
const from = i;
|
||||
i = Math.max(0, Math.min(FRAMES.length - 1, n));
|
||||
if (soundOn && i === from + 1) {
|
||||
const cues = FRAMES[i].cues || [];
|
||||
for (const c of cues) playCue(c);
|
||||
}
|
||||
render();
|
||||
}
|
||||
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); }
|
||||
|
||||
$('districttoggle').onclick = () => {
|
||||
const open = districtMode === 'auto' ? !!FOCUS_PHASES[FRAMES[i].phaseKey] : districtMode === 'open';
|
||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||
render();
|
||||
};
|
||||
$('sound').onclick = () => {
|
||||
soundOn = !soundOn;
|
||||
$('sound').textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
|
||||
/* Confirm audibly, and give the browser the user gesture it needs before audio may start. */
|
||||
if (soundOn) playCue('stage');
|
||||
};
|
||||
|
||||
$('ftot').textContent = FRAMES.length - 1;
|
||||
$('scrub').max = FRAMES.length - 1;
|
||||
$('scrub').oninput = (e) => go(+e.target.value);
|
||||
|
||||
+7
-2
@@ -155,6 +155,11 @@ export type Frame = {
|
||||
phase: string;
|
||||
/** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */
|
||||
phaseKey: string;
|
||||
/**
|
||||
* Sounds this frame earned — a Stage ending, a Day turning, a train being built. Filled by the
|
||||
* replay recorder, which sees the events; the live game keeps its own on the Game object.
|
||||
*/
|
||||
cues?: string[];
|
||||
actor: number | null;
|
||||
superintendent: number;
|
||||
revenue: number;
|
||||
@@ -376,9 +381,9 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
case 'draw.fromHomeOffice':
|
||||
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
|
||||
case 'draw.end':
|
||||
return 'End my turn';
|
||||
return 'End Local Operations';
|
||||
case 'switch.end':
|
||||
return 'End my switching turn';
|
||||
return 'End Local Operations';
|
||||
case 'loadUnload.end':
|
||||
return 'End my Cargo phase';
|
||||
case 'newTrain.passCar':
|
||||
|
||||
+56
-22
@@ -29,7 +29,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import { narrate } from '../sim/narrate.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
import {
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
trainName,
|
||||
variantLabel,
|
||||
} from '../sim/view.ts';
|
||||
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
|
||||
import type { TrackGeometry } from '../engine/content.ts';
|
||||
import { variantsFor } from '../engine/track.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
@@ -71,12 +72,12 @@ export type Game = {
|
||||
/** Narrated lines, newest last. */
|
||||
log: { text: string; tone: string }[];
|
||||
/**
|
||||
* Set when a card has been drawn this turn and not yet played or discarded.
|
||||
* True when the hand is OVER the limit, so the turn cannot end until it is played down.
|
||||
*
|
||||
* §6.2 makes you reduce your hand before the turn ends; this is that requirement made visible,
|
||||
* as a disabled button rather than a rejection after the click. UI state deliberately — putting
|
||||
* it in the engine would change what the bot may do, and with it every balance figure measured
|
||||
* against the current rules.
|
||||
* §6.2 is a hand limit — "reduce his hand to no more than three cards" — not a rule that a draw
|
||||
* must be spent. An earlier version set this on any draw, which forced a play even when two cards
|
||||
* had already been played and the hand held three or fewer. Derived from the hand each render, so
|
||||
* it cannot drift out of step with what is actually held.
|
||||
*/
|
||||
mustPlayCard: boolean;
|
||||
/**
|
||||
@@ -173,7 +174,14 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
used.add(k);
|
||||
return byKind.get(k) ?? [];
|
||||
});
|
||||
if (actions.length > 0) groups.push({ kind: prefix, title, actions });
|
||||
if (actions.length > 0) {
|
||||
// The New Train group names the TRAIN and what its card calls for. Without it, Extra X22
|
||||
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
|
||||
// single button to add a caboose and no reason why, which reads as a broken game rather than
|
||||
// as the card doing exactly what it prints.
|
||||
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
|
||||
groups.push({ kind: prefix, title: headed, actions });
|
||||
}
|
||||
}
|
||||
// Anything the table above does not name still has to be offered — silently dropping a legal
|
||||
// action would make the game unplayable in a way that is very hard to notice.
|
||||
@@ -283,6 +291,45 @@ function rotationNote(geometry: TrackGeometry | null, variant: number | undefine
|
||||
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* "Making up Extra X22 — Pee-Dee: 1 caboose (per-diem train, may only pick up MTs)".
|
||||
*
|
||||
* §8.2 lets a train depart with FEWER cars than its card lists but never with the wrong ones, so
|
||||
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
|
||||
* card the player can read.
|
||||
*/
|
||||
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
|
||||
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
|
||||
if (!first || !('trayId' in first)) return null;
|
||||
const tray = game.state.trays.get(first.trayId);
|
||||
if (!tray) return null;
|
||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!p) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (p.consist.freight > 0) {
|
||||
const types = p.consist.freightTypes?.join('/') ?? 'freight';
|
||||
parts.push(`${p.consist.freight} ${types}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||
}
|
||||
if (p.consist.coach > 0) parts.push(`${p.consist.coach} coach${p.consist.coach > 1 ? 'es' : ''}`);
|
||||
if (p.consist.caboose > 0) parts.push(`${p.consist.caboose} caboose`);
|
||||
const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all';
|
||||
const note = p.rules.note ? ` — ${p.rules.note}` : '';
|
||||
const name = `${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}”`;
|
||||
return `Making up ${name}: its card calls for ${calls}${note}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2 — "the player must reduce his hand to no more than three cards", four while a Red Flag is
|
||||
* held. The same test the engine applies to `draw.end`, asked here so the page can DISABLE the
|
||||
* button with a reason instead of hiding a move that has simply become illegal.
|
||||
*/
|
||||
export function overHandLimit(game: Game): boolean {
|
||||
const hand = game.state.decks.hands.get(0) ?? [];
|
||||
const limit = game.state.decks.redFlags.get(0) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
||||
return hand.length > limit;
|
||||
}
|
||||
|
||||
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
|
||||
export function submit(game: Game, intent: Intent): boolean {
|
||||
const actor = currentActor(game);
|
||||
@@ -294,14 +341,8 @@ export function submit(game: Game, intent: Intent): boolean {
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
if (intent.type === 'draw.fromHomeOffice' || intent.type === 'draw.fromDepartment') {
|
||||
game.mustPlayCard = true;
|
||||
} else if (intent.type === 'card.play' || intent.type === 'card.discard' || intent.type === 'localOps.choose') {
|
||||
// Choosing an option starts a fresh turn — and Freight Agent and Switching never draw at all,
|
||||
// so nothing can be left outstanding from them.
|
||||
game.mustPlayCard = false;
|
||||
}
|
||||
record(game, result.events, actor);
|
||||
game.mustPlayCard = overHandLimit(game);
|
||||
drain(game);
|
||||
return true;
|
||||
}
|
||||
@@ -345,15 +386,8 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||
|
||||
if (e.type === 'trainMadeUp') game.cues.push('train');
|
||||
if (e.type === 'stageBegan') {
|
||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||
// and has nothing behind it. A Day boundary gets the bell only: sounding both there would
|
||||
// collide, and the bell is the bigger event.
|
||||
if (e.day > 1 && e.stage === 1) game.cues.push('day');
|
||||
else if (e.day > 1 || e.stage > 1) game.cues.push('stage');
|
||||
}
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||
}
|
||||
|
||||
+24
-9
@@ -7,7 +7,7 @@
|
||||
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { play } from './sound.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import type { Game } from './game.ts';
|
||||
import {
|
||||
actionMenu,
|
||||
@@ -292,7 +292,7 @@ function render(): void {
|
||||
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
||||
// not then play a backlog of everything that happened while it was off.
|
||||
const cues = game.cues.splice(0, game.cues.length);
|
||||
if (soundOn) for (const c of cues) play(c);
|
||||
if (soundOn) for (const c of cues) playCue(c);
|
||||
|
||||
save();
|
||||
}
|
||||
@@ -309,10 +309,17 @@ function renderDistrict(f: ReturnType<typeof view>): void {
|
||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||
|
||||
// Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a
|
||||
// status line and was missed entirely; "always show" is an instruction.
|
||||
const btn = $('districttoggle');
|
||||
btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode;
|
||||
btn.textContent =
|
||||
districtMode === 'auto'
|
||||
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
||||
: districtMode === 'open'
|
||||
? 'always showing — click for auto-hide'
|
||||
: 'always hidden — click for auto-hide';
|
||||
btn.onclick = () => {
|
||||
// auto -> the opposite of what it is doing now -> back to auto.
|
||||
// auto -> pin it to the opposite of what auto is doing -> back to auto.
|
||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||
render();
|
||||
};
|
||||
@@ -382,10 +389,7 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
||||
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
|
||||
// the INTENT, not the label: matching button text would break the moment the wording
|
||||
// changed, and would have caught `switch.end` too.
|
||||
const blocked = menu.options[a.index]?.type === 'draw.end' && game.mustPlayCard;
|
||||
return blocked
|
||||
? `<button class="act blocked" disabled data-tip="You have drawn a card. Play it or discard it to a Department slot before ending your turn — §6.2 holds you to three cards.">${esc(a.label)} — play or discard first</button>`
|
||||
: actionButton(a.label, a.index);
|
||||
return actionButton(a.label, a.index);
|
||||
})
|
||||
.join('') +
|
||||
`</div>`,
|
||||
@@ -417,6 +421,17 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
if (
|
||||
game.state.clock.phase === 'localOps' &&
|
||||
game.state.turn.option === 'draw' &&
|
||||
!menu.options.some((i) => i.type === 'draw.end')
|
||||
) {
|
||||
const hand = (game.state.decks.hands.get(0) ?? []).length;
|
||||
html +=
|
||||
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
|
||||
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
for (const b of Array.from(el.querySelectorAll('button.act'))) {
|
||||
@@ -502,7 +517,7 @@ if (soundBtn) {
|
||||
paint();
|
||||
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
|
||||
// doubles as the user gesture the browser needs before any audio may start.
|
||||
if (soundOn) play('stage');
|
||||
if (soundOn) playCue('stage');
|
||||
};
|
||||
paint();
|
||||
}
|
||||
|
||||
+3
-3
@@ -102,8 +102,8 @@ button:hover{background:#39424e;border-color:#4d6fa8}
|
||||
button.act{display:inline-block}
|
||||
/* The district collapses when nothing in it is changing. A summary line stays, because a panel
|
||||
that vanishes entirely reads as broken rather than as folded away. */
|
||||
button.ghost{background:transparent;border:1px solid var(--line);color:var(--dim);font-size:10px;
|
||||
padding:1px 7px;margin-left:8px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
#district.folded #grid{display:none}
|
||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
@@ -198,7 +198,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<button id="districttoggle" class="ghost" title="Auto-focus opens the district during the phases that change it">auto</button>
|
||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<div id="grid"></div>
|
||||
|
||||
+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