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':
|
||||
|
||||
Reference in New Issue
Block a user