Draw the board as track, split the site into three pages, and fix curve geometry

Adds SVG board rendering shared by the game and the replay, a splash page with a
shareable replay directory, hover tooltips for reference detail, and makes curves
two-port arcs so sidings and run-arounds can finally be built.
This commit is contained in:
Jesse
2026-08-01 13:35:53 -04:00
parent f00255ce31
commit f52ff0e9ac
21 changed files with 5359 additions and 291 deletions
+48 -6
View File
@@ -10,7 +10,7 @@
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -26,6 +26,8 @@ execFileSync(
'tsc',
'--ignoreConfig',
'src/web/main.ts',
'src/web/splash.ts',
'src/web/replays.ts',
'--outDir', dist,
'--rootDir', 'src',
'--target', 'es2022',
@@ -107,10 +109,47 @@ function bustImports(dir: string): number {
const busted = bustImports(dist);
const page = readFileSync(join(root, 'src/web/index.html'), 'utf8')
.replaceAll('__BUILD__', stamp)
.replace(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
writeFileSync(join(dist, 'index.html'), page);
// Three pages now: the splash, the game, and the replay directory. Each is stamped and each has
// its entry script cache-busted, or a returning visitor gets new HTML against old modules.
for (const name of ['index.html', 'play.html', 'replays.html']) {
const page = readFileSync(join(root, 'src/web', name), 'utf8')
.replaceAll('__BUILD__', stamp)
.replace(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
writeFileSync(join(dist, name), page);
}
/**
* Publish whatever replays are in `public/replays/`, plus an index of them.
*
* Static hosting cannot list a directory, so the page needs a manifest. A replay is a save — seed
* plus moves — so these are a few hundred bytes each, not megabytes.
*/
const replaySrc = join(root, 'public/replays');
const replayOut = join(dist, 'replays');
mkdirSync(replayOut, { recursive: true });
const manifest: { file: string; title: string; note?: string; seed?: number }[] = [];
if (existsSync(replaySrc)) {
for (const f of readdirSync(replaySrc).filter((n) => n.endsWith('.json') && n !== 'manifest.json')) {
const raw = readFileSync(join(replaySrc, f), 'utf8');
try {
const save = JSON.parse(raw) as { seed?: number; title?: string; note?: string; history?: unknown[] };
if (typeof save.seed !== 'number' || !Array.isArray(save.history)) {
console.warn(` skipped ${f} — not a Station Master save`);
continue;
}
writeFileSync(join(replayOut, f), raw);
manifest.push({
file: f,
title: save.title ?? f.replace(/\.json$/, ''),
...(save.note ? { note: save.note } : {}),
seed: save.seed,
});
} catch {
console.warn(` skipped ${f} — could not be parsed`);
}
}
}
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
// A tiny note for whoever unzips this later and wonders what it needs.
writeFileSync(
@@ -129,4 +168,7 @@ writeFileSync(
].join('\n'),
);
console.log(`built -> ${dist}\n ${stamp}\n cache-busted ${busted} modules with ?v=${tag}`);
console.log(
`built -> ${dist}\n ${stamp}\n cache-busted ${busted} modules with ?v=${tag}` +
`\n ${manifest.length} replay(s) published`,
);