Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass

This commit is contained in:
Jesse
2026-08-04 11:26:18 -04:00
parent ec792dfc15
commit c3c5c59a45
16 changed files with 1249 additions and 129 deletions
+18 -1
View File
@@ -79,6 +79,14 @@ export type Game = {
* against the current rules.
*/
mustPlayCard: boolean;
/**
* Sounds the last batch of events earned, for the page to play and clear.
*
* The model names WHAT happened — a Stage ended, a Day turned, a train was built — and the view
* decides what that sounds like. Detection lives here because this is where events are seen; it
* would be guesswork from a rendered frame.
*/
cues: string[];
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -114,7 +122,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false };
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [] };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -336,6 +344,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const mine = who !== null && 'player' in e;
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');
}
}
// 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);
+82 -14
View File
@@ -5,8 +5,9 @@
* is answered by the engine or by the shared view helpers.
*/
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { play } from './sound.ts';
import type { Game } from './game.ts';
import {
actionMenu,
@@ -26,6 +27,28 @@ let game: Game;
let selected: string | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
/**
* AUTO-FOCUS on the district.
*
* It is only worth the vertical space during the phases that change it — Local Operations, where
* cards and track are placed and the crew switches, and Cargo, where loads move. During New Train,
* Mainline and Supervisor Shift nothing in the district moves and the Division map is what matters.
*
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
* they change it again. Display only — in a multiplayer game two players may reasonably want it
* set differently, so this must never become part of game state.
*/
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
/**
* Sound, OFF until asked for.
*
* Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio
* rather than the finished thing — and a playtester who did not ask for noise should not get any.
* One click in the title bar turns it on, and that click is also the gesture browsers require
* before any audio may start.
*/
let soundOn = false;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
@@ -149,7 +172,16 @@ function render(): void {
// -- board. Both renderers are shared with the replay so the two can never draw different
// pictures of the same position.
const grid = $('grid');
grid.innerHTML = officeSvg(f.cells, f.runningRow);
// The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the
// same pass so they share its origin and its canvas: a target outside the canvas is a legal move
// the player cannot click.
const ghostCoords = [...spotsAt.keys()]
.filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k))
.map((k) => {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
@@ -160,18 +192,11 @@ function render(): void {
(g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
// A legal EMPTY square has no card to outline, so draw a target for it.
const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k));
if (ghosts.length > 0) {
const coords = ghosts.map(([k]) => {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
grid.innerHTML = grid.innerHTML.replace('</svg>', ghostSvg(coords, f.cells, f.runningRow) + '</svg>');
for (const [key, list] of ghosts) {
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
for (const [key, list] of spotsAt) {
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
function pick(key: string, list: { label: string; index: number }[]): void {
@@ -261,10 +286,38 @@ function render(): void {
.join('');
log.scrollTop = log.scrollHeight;
renderDistrict(f);
renderActions(menu);
// 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);
save();
}
function renderDistrict(f: ReturnType<typeof view>): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district');
if (open) sec.classList.remove('folded');
else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
const crew = f.cells.filter((c) => c.tray).length;
$('districtsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : '');
const btn = $('districttoggle');
btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode;
btn.onclick = () => {
// auto -> the opposite of what it is doing now -> back to auto.
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
render();
};
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
@@ -439,4 +492,19 @@ installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
const soundBtn = document.getElementById('sound');
if (soundBtn) {
const paint = (): void => {
soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
};
soundBtn.onclick = () => {
soundOn = !soundOn;
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');
};
paint();
}
start();
+17 -1
View File
@@ -100,6 +100,14 @@ button:hover{background:#39424e;border-color:#4d6fa8}
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
#actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px}
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:hover{border-color:#4d6fa8;color:var(--fg)}
#district.folded #grid{display:none}
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
#districtsummary{display:none}
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
so a disabled button must not wear it. */
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
@@ -169,6 +177,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
@@ -186,7 +195,14 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
<section><h2>Your Office Area <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></h2><div id="grid"></div></section>
<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>
</h2>
<div id="districtsummary" class="dim"></div>
<div id="grid"></div>
</section>
<section><h2>History</h2><div id="log"></div></section>
</div>
+136
View File
@@ -0,0 +1,136 @@
/**
* 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. 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.
*
* 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.
*
* "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.
*/
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');
}
}
}
/** A train is added to the board. */
export function allAboard(): void {
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;
}
} catch {
// fall through to the call below
}
// 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();
}