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:
+18
-1
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user