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
+27 -14
View File
@@ -23,6 +23,7 @@ import type {
RollingStock,
TrackCard,
TrayId,
TrackArc,
TurnoutOrientation,
} from './state.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
@@ -77,7 +78,12 @@ type PortPair = readonly [Port, Port];
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather
* than a separate port.
*/
function connectionsFor(card: TrackCard): readonly PortPair[] {
/**
* Exported so the board can DRAW what the engine believes. Deriving the rails from anything else
* would let a picture disagree with the rules about whether two cards join — which is the one thing
* a track diagram exists to settle.
*/
export function connectionsFor(card: TrackCard): readonly PortPair[] {
switch (card.geometry.kind) {
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
case 'modifier':
@@ -108,18 +114,22 @@ function connectionsFor(card: TrackCard): readonly PortPair[] {
[o.stem, o.diverge],
];
}
// A CURVE joins the through track to one diverging side — handedness is printed on the
// card, not chosen (the design supplies both hands). A SHARP curve is geometrically the
// same but costs two Moves to cross.
// Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides
// which end of the through track the leg leaves from, not whether it goes up or down.
/**
* A CURVE IS AN ARC between two adjacent edges — one connection, no through track. The
* printed cards show exactly this (docs/tracks.png, rows 3-4): a single sweep from edge to
* edge with nothing running past it.
*
* It was previously modelled as a through track PLUS a diverging leg, which is a turnout —
* and made curves topologically identical duplicates of them. Worse, every leg diverged
* south, so no piece anywhere reached NORTH except an n-s straight. A district could
* therefore only ever be a vertical column: no siding, no parallel track, no run-around.
*
* A SHARP curve is the same shape and costs two Moves to cross.
*/
case 'curved':
case 'sharpCurved': {
const stem = card.geometry.hand === 'left' ? 'w' : 'e';
return [
['e', 'w'],
[stem, 's'],
];
const arc = card.geometry.arc ?? (card.geometry.hand === 'left' ? 'sw' : 'se');
return [[arc[0] as Port, arc[1] as Port]];
}
}
}
@@ -144,7 +154,10 @@ export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', di
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
* rotation is free.
*/
const ARCS: readonly TrackArc[] = ['ne', 'nw', 'se', 'sw'];
export type TrackVariant = {
arc?: TrackArc;
axis?: 'ew' | 'ns';
turnout?: TurnoutOrientation;
bypass?: Port;
@@ -167,9 +180,9 @@ export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
case 'curved':
case 'sharpCurved':
// Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left
// to choose — one variant.
return [{ axis: 'ew' }];
// All four rotations. A two-port arc has no handedness that survives turning — its mirror IS
// one of its rotations — so the printed hand governs supply, not what can be built.
return ARCS.map((arc) => ({ arc }));
}
}