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
+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();