SOURCE / PINNED RELEASE
Made of little things.
Big Rig Rush
- Release
- ec9b6549f752…
- Author-recorded commit
- 74b729258b1a…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40
Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { DISTRICT_RECTS, type District, type Structure } from './city.js';
import { boxCorners, type Vec } from './geometry.js';
/**
* The country beyond the old city: ten times the map. Across the bay the docks (quays,
* gantry cranes, moored ships and acres of containers); north-east the steelworks and
* refinery; the rail yard running the width of the map; the suburb of Maple Grove and
* the Strip with its stadium; Fort Dunmore, an air base with a runway, hangars, parked
* jets, a tank park and ammunition bunkers; and to the south the backcountry: farms,
* forest, hills, the reservoir and its dam, a wind farm and the market town of Harlan.
* Route 9 runs on south, Route 101 and Route 50 tie it all together.
*
* Everything here that stands up can be knocked down. Tanks, tank cars, aircraft,
* ammunition and fuel are flagged as fuel: they go up when they come down.
*/
/** A road junction, as the city builds them. */
export interface BNode {
x: number;
y: number;
}
export type RoadKind = 'street' | 'avenue' | 'boulevard' | 'expressway' | 'industrial' | 'country' | 'drive';
/** What the expansion needs from the city while it's being built. */
export interface Builder {
add(layer: number, x0: number, y0: number, x1: number, y1: number, draw: (ctx: CanvasRenderingContext2D) => void): void;
node(x: number, y: number, district: District): BNode;
road(a: BNode, b: BNode, kind: RoadKind, district: District, ctrl?: Vec[]): void;
/** A road along a curve with junctions at the given distances; returns the junction at a distance. */
chain(curve: Vec[], positions: number[], kind: RoadKind, district: District, ends?: [number, BNode][]): (s: number) => BNode;
grid(xs: number[], ys: number[], district: District, kindAt: (vertical: boolean, index: number) => RoadKind, skip?: number): BNode[][];
/** A winding two-lane road from a to b through the control points. */
country(ctrl: Vec[], a: BNode, b: BNode, district: District): void;
special(district: District, corners: Vec[], draw: (ctx: CanvasRenderingContext2D) => void, opts?: { mat?: 'wood' | 'stone'; hp?: number; fuel?: number; building?: boolean; layer?: number }): Structure;
lake(cx: number, cy: number, rx: number, ry: number): void;
/** Keep an area clear of the buildings that line the roads. */
reserve(x0: number, y0: number, x1: number, y1: number): void;
/** Nothing built, no road and no water within r of a point. */
isFree(x: number, y: number, r: number): boolean;
claimBox(cx: number, cy: number, angle: number, hw: number, hd: number): boolean;
forest(x0: number, y0: number, x1: number, y1: number, density: number): void;
ground(x0: number, y0: number, x1: number, y1: number, surface: 'road' | 'sidewalk' | 'dirt'): void;
stacks: { x: number; y: number; steam: boolean }[];
curvePath(ctrl: Vec[], step: number): Vec[];
pathLength(pts: Vec[]): number;
sAtY(pts: Vec[], y: number): number;
sAtX(pts: Vec[], x: number): number;
layers: { ground: number; walk: number; object: number; canopy: number };
/** The city's critical infrastructure, as it's built. */
infra: InfraSpec[];
/** Existing ordinary buildings (for picking City Hall and the shelters). */
buildings: Structure[];
}
/** Critical infrastructure: what keeps the city alive, and what the A-10 is there to break. */
export type InfraKind = 'power' | 'substation' | 'waterworks' | 'watertower' | 'comms' | 'eoc' | 'shelter';
export interface InfraSpec {
kind: InfraKind;
name: string;
st: Structure;
/** Share of the city's generating capacity (power stations). */
capacity?: number;
}
const rand = Math.random;
const range = (a: number, b: number) => a + Math.random() * (b - a);
const pick = <T>(items: readonly T[]): T => items[Math.floor(Math.random() * items.length)];
function shadeHex(hex: string, amount: number): string {
const n = parseInt(hex.slice(1), 16);
const f = (v: number) => Math.max(0, Math.min(255, Math.round(amount > 0 ? v + (255 - v) * amount : v * (1 + amount))));
return `rgb(${f((n >> 16) & 255)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`;
}
function fill(ctx: CanvasRenderingContext2D, pts: Vec[], color: string): void {
ctx.beginPath();
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
/** A hard drop shadow down-right, as every building in the game casts. */
function dropShadow(ctx: CanvasRenderingContext2D, pts: Vec[], height: number): void {
const dx = Math.min(24, height * 0.8);
const dy = dx * 1.15;
ctx.save();
ctx.translate(dx, dy);
fill(ctx, pts, 'rgba(0, 0, 0, 0.3)');
ctx.restore();
}
/** Draw in a box's own frame (centre, angle). */
function local(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, draw: () => void): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
draw();
ctx.restore();
}
const box = (x: number, y: number, w: number, h: number, angle = 0) => boxCorners(x, y, angle, w / 2, h / 2);
const circlePts = (x: number, y: number, r: number, n = 16): Vec[] => Array.from({ length: n }, (_, i) => ({ x: x + Math.cos((i / n) * Math.PI * 2) * r, y: y + Math.sin((i / n) * Math.PI * 2) * r }));
// --- Layout (map units). The old city fills x < 5200, y < 9200.
const DOCK_X = [7950, 8400, 8850, 9300, 9750, 10200, 10650];
const DOCK_Y = [450, 1050, 1650, 2250, 2850, 3450, 4050, 4650];
const FAC_X = [11550, 12150, 12750, 13350, 13950, 14550, 15150, 15750];
const FAC_Y = DOCK_Y;
const R101_X = 11200;
const R101S_X = 8800;
const R50_Y = 7700;
const DEPOT_Y = 5480;
const YARD_Y = 7330;
const TRACK_Y0 = 5800;
const TRACKS = 14;
const TRACK_GAP = 60;
const COM_X = [9300, 10050, 10800, 11550, 12300, 13050, 13800, 14550, 15300, 16000];
const COM_Y = [8050, 8900, 9750];
const SUB_X = [5700, 6100, 6500, 6900, 7300, 7700, 8100, 8500];
const SUB_Y = [8100, 8550, 9000, 9450, 9900, 10350, 10800, 11250, 11700, 12150, 12600, 13050, 13500, 13950, 14400];
const MIL_X = [9500, 10300, 11100, 11900, 12700, 13500, 14300, 15100, 15900];
const MIL_Y = [10500, 11300, 12100, 12900];
const BASE = { x0: 9180, y0: 10180, x1: 16120, y1: 16280 };
const RUNWAY = { x0: 10000, x1: 15500, y: 14650, w: 150 };
const TOWN_X = [6350, 6520, 6690, 6860, 7030, 7200, 7370, 7540];
const TOWN_Y = [20850, 21020, 21190, 21360, 21530, 21700, 21870];
const RESERVOIR = { x: 12000, y: 21300, rx: 1500, ry: 800 };
/** Junctions the old city's network hands over: Route 9's south end, the port and the industrial park. */
export interface Handover {
r9End: BNode;
port: BNode;
industrial: BNode;
}
/** Ground painting that has to go down before the roads: the rail yard, the airfield, hills, the quay. */
export function expansionScenery(b: Builder): void {
const L = b.layers;
// Rolling hills in the backcountry: darker swells with soft contour bands.
for (let i = 0; i < 26; i++) {
const x = range(400, 16000);
const y = range(15500, 28800);
if (Math.hypot(x - RESERVOIR.x, y - RESERVOIR.y) < 2200) continue;
const r = range(500, 1100);
b.add(L.ground, x - r, y - r, x + r, y + r, (ctx) => {
for (let k = 0; k < 5; k++) {
const rr = r * (1 - k * 0.18);
const g = ctx.createRadialGradient(x, y, rr * 0.2, x, y, rr);
g.addColorStop(0, 'rgba(40, 80, 34, 0.12)');
g.addColorStop(1, 'rgba(40, 80, 34, 0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(x, y, rr, rr * 0.8, i, 0, Math.PI * 2);
ctx.fill();
}
});
}
// The quay: a concrete apron along the docks' waterfront.
b.add(L.ground, 7680, 200, 7940, 4720, (ctx) => {
ctx.fillStyle = '#a3a097';
ctx.fillRect(7690, 250, 220, 4450);
ctx.fillStyle = '#e0c040';
ctx.fillRect(7700, 250, 3, 4450);
ctx.fillStyle = '#3a3a3c';
for (let y = 280; y < 4680; y += 70) ctx.fillRect(7706, y, 5, 5);
});
b.ground(7690, 250, 7910, 4700, 'sidewalk');
// The rail yard: ballast, sleepers and steel, in chunks so each painted tile only draws its own piece.
for (let x = 5650; x < 16150; x += 512) {
const x1 = Math.min(16150, x + 512);
b.add(L.ground, x, TRACK_Y0 - 40, x1, TRACK_Y0 + TRACKS * TRACK_GAP, (ctx) => {
ctx.fillStyle = '#8c8270';
ctx.fillRect(x, TRACK_Y0 - 30, x1 - x, TRACKS * TRACK_GAP + 10);
for (let t = 0; t < TRACKS; t++) {
const y = TRACK_Y0 + t * TRACK_GAP;
ctx.fillStyle = '#7a705f';
ctx.fillRect(x, y - 11, x1 - x, 22);
ctx.fillStyle = '#4a3a2c';
for (let sx = x; sx < x1; sx += 6) ctx.fillRect(sx, y - 7, 2.4, 14);
ctx.fillStyle = '#b7b9bd';
ctx.fillRect(x, y - 4.6, x1 - x, 1.4);
ctx.fillRect(x, y + 3.2, x1 - x, 1.4);
}
});
}
b.ground(5650, TRACK_Y0 - 30, 16150, TRACK_Y0 + TRACKS * TRACK_GAP - 20, 'dirt');
b.reserve(5600, TRACK_Y0 - 120, 16200, TRACK_Y0 + TRACKS * TRACK_GAP + 40);
// The airfield: runway, taxiway, apron.
const rw = RUNWAY;
b.add(L.ground, rw.x0 - 60, rw.y - 500, rw.x1 + 60, rw.y + 120, (ctx) => {
ctx.fillStyle = '#9a9890';
ctx.fillRect(rw.x0, 13350, rw.x1 - rw.x0, 620);
ctx.fillStyle = '#3e3f3d';
ctx.fillRect(rw.x0 - 40, rw.y - rw.w / 2, rw.x1 - rw.x0 + 80, rw.w);
ctx.fillRect(rw.x0, 14180, rw.x1 - rw.x0, 54);
for (let x = rw.x0 + 300; x < rw.x1; x += 900) ctx.fillRect(x - 27, 13950, 54, 260);
// Runway markings: edges, the dashed centre line, thresholds and numbers.
ctx.fillStyle = '#f2f2ee';
ctx.fillRect(rw.x0 - 40, rw.y - rw.w / 2 + 4, rw.x1 - rw.x0 + 80, 3);
ctx.fillRect(rw.x0 - 40, rw.y + rw.w / 2 - 7, rw.x1 - rw.x0 + 80, 3);
for (let x = rw.x0 + 260; x < rw.x1 - 260; x += 120) ctx.fillRect(x, rw.y - 2, 70, 4);
for (const [x, dir] of [
[rw.x0, 1],
[rw.x1, -1],
] as const) {
for (let k = 0; k < 8; k++) ctx.fillRect(x + dir * 20 - (dir < 0 ? 90 : 0), rw.y - 64 + k * 17, 90, 9);
ctx.save();
ctx.translate(x + dir * 180, rw.y);
ctx.rotate(dir > 0 ? Math.PI / 2 : -Math.PI / 2);
ctx.font = '800 60px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(dir > 0 ? '09' : '27', 0, 0);
ctx.restore();
}
// Taxiway centre line and the apron's parking marks.
ctx.fillStyle = '#e8c33a';
ctx.fillRect(rw.x0, 14205, rw.x1 - rw.x0, 3);
for (let x = rw.x0 + 300; x < rw.x1; x += 900) ctx.fillRect(x - 1.5, 13950, 3, 260);
for (let x = rw.x0 + 150; x < rw.x1 - 100; x += 300) {
ctx.fillRect(x, 13620, 3, 300);
ctx.fillRect(x - 40, 13900, 80, 3);
}
});
b.ground(rw.x0 - 40, rw.y - rw.w / 2, rw.x1 + 40, rw.y + rw.w / 2, 'road');
b.ground(rw.x0, 13350, rw.x1, 14240, 'sidewalk');
b.reserve(BASE.x0 + 60, 13150, BASE.x1 - 20, BASE.y1 - 40);
// Reserve the plant sites in the steelworks, the stadium block and the motor pool.
for (const [i, j] of PLANT_BLOCKS) b.reserve(FAC_X[i] + 70, FAC_Y[j] + 70, FAC_X[i + 1] - 70, FAC_Y[j + 1] - 70);
b.reserve(COM_X[4] + 80, COM_Y[1] + 70, COM_X[5] - 80, COM_Y[2] - 70);
}
/** Factory blocks given over to the big plants (column, row of the block's top-left junction). */
const PLANT_BLOCKS: [number, number][] = [
[5, 0],
[6, 0],
[2, 3],
[3, 3],
[5, 4],
[1, 6],
[4, 6],
];
/** The roads: two new highways, the districts' grids and the country roads. */
export function expansionNetwork(b: Builder, h: Handover): void {
// Route 9 runs on south through the backcountry.
const r9 = b.curvePath(
[
{ x: h.r9End.x, y: h.r9End.y },
{ x: 1560, y: 11000 },
{ x: 1480, y: 13500 },
{ x: 1600, y: 16000 },
{ x: 1500, y: 19000 },
{ x: 1650, y: 22500 },
{ x: 1520, y: 26000 },
{ x: 1600, y: 29000 },
],
8,
);
const r9Ys = [11000, 14000, 17500, 21000, 24500, 28000];
const r9Len = b.pathLength(r9);
const r9Node = b.chain(r9, [0, ...r9Ys.map((y) => b.sAtY(r9, y)), r9Len], 'expressway', 'highway', [[0, h.r9End]]);
const r9At = (y: number) => r9Node(b.sAtY(r9, y));
// The docks: a coarse grid of wide streets behind the quay.
const docks = b.grid(DOCK_X, DOCK_Y, 'docks', () => 'industrial', 0.08);
// Bay Road: round the south end of the bay from the port to the docks.
b.country(
[
{ x: 5100, y: 4350 },
{ x: 5800, y: 5080 },
{ x: 6700, y: 5190 },
{ x: 7500, y: 5110 },
],
h.port,
docks[0][DOCK_Y.length - 1],
'docks',
);
// The steelworks: big blocks for big plants.
const fac = b.grid(FAC_X, FAC_Y, 'factory', () => 'industrial', 0.06);
// Route 50: east-west from the industrial park across the whole map.
const r50Xs = [6100, 7300, R101S_X, 10050, R101_X, 12300, 14550, 15800];
const r50 = b.curvePath([{ x: h.industrial.x, y: h.industrial.y }, { x: 16050, y: R50_Y }], 10);
const r50Len = b.pathLength(r50);
const r50Node = b.chain(r50, [0, ...r50Xs.map((x) => b.sAtX(r50, x)), r50Len], 'expressway', 'highway', [[0, h.industrial]]);
const r50At = (x: number) => r50Node(b.sAtX(r50, x));
// Route 101: north-south between the docks and the steelworks, across the rail yard.
const r101Ys = [...DOCK_Y, DEPOT_Y, YARD_Y];
const r101 = b.curvePath([{ x: R101_X, y: 150 }, { x: R101_X, y: R50_Y }], 10);
const r101Len = b.pathLength(r101);
const r101Node = b.chain(r101, [0, ...r101Ys.map((y) => y - 150), r101Len], 'expressway', 'highway', [[r101Len, r50At(R101_X)]]);
for (const [j, y] of DOCK_Y.entries()) {
b.road(docks[DOCK_X.length - 1][j], r101Node(y - 150), 'industrial', 'docks');
b.road(r101Node(y - 150), fac[0][j], 'industrial', 'factory');
}
// Depot Road and Yard Road run along either side of the rail yard.
const depotW = b.node(5800, DEPOT_Y, 'rail');
const depotE = b.node(16050, DEPOT_Y, 'rail');
b.road(depotW, r101Node(DEPOT_Y - 150), 'industrial', 'rail');
b.road(r101Node(DEPOT_Y - 150), depotE, 'industrial', 'rail');
const yardW = b.node(5800, YARD_Y, 'rail');
const yardE = b.node(16050, YARD_Y, 'rail');
b.road(yardW, r101Node(YARD_Y - 150), 'industrial', 'rail');
b.road(r101Node(YARD_Y - 150), yardE, 'industrial', 'rail');
// Route 101 south: on down past the suburb and the base to the bottom of the map.
const r101sYs = [9000, 9750, 10350, 11300, 11700, 13050, 14400, 17500, 21360, 24500, 28000];
const r101s = b.curvePath([{ x: R101S_X, y: R50_Y }, { x: R101S_X + 60, y: 16000 }, { x: R101S_X - 40, y: 22000 }, { x: R101S_X + 30, y: 29000 }], 10);
const r101sNode = b.chain(r101s, [0, ...r101sYs.map((y) => b.sAtY(r101s, y)), b.pathLength(r101s)], 'expressway', 'highway', [[0, r50At(R101S_X)]]);
const r101sAt = (y: number) => r101sNode(b.sAtY(r101s, y));
// Maple Grove: a leafy suburb of streets and houses.
const sub = b.grid(SUB_X, SUB_Y, 'suburb', (vertical, i) => ((vertical && i === 3) || (!vertical && i === 6) ? 'avenue' : 'street'), 0.12);
b.road(sub[1][0], r50At(6100), 'street', 'suburb');
b.road(sub[4][0], r50At(7300), 'street', 'suburb');
for (const j of [2, 5, 9, 11, 14]) b.road(sub[SUB_X.length - 1][j], r101sAt(SUB_Y[j]), 'street', 'suburb');
// The Strip: wide avenues of big stores, and the stadium.
const com = b.grid(COM_X, COM_Y, 'commercial', (vertical) => (vertical ? 'street' : 'avenue'));
b.road(com[1][0], r50At(10050), 'avenue', 'commercial');
b.road(com[4][0], r50At(12300), 'avenue', 'commercial');
b.road(com[7][0], r50At(14550), 'avenue', 'commercial');
b.road(r101sAt(9750), com[0][2], 'avenue', 'commercial');
// Fort Dunmore: the base's own grid, through the gate off Route 101.
const mil = b.grid(MIL_X, MIL_Y, 'military', (vertical) => (vertical ? 'street' : 'avenue'));
b.road(r101sAt(11300), mil[0][1], 'avenue', 'military');
// Harlan: a market town in the backcountry.
const town = b.grid(TOWN_X, TOWN_Y, 'town', (vertical, i) => ((vertical && i === 4) || (!vertical && i === 3) ? 'avenue' : 'street'), 0.1);
b.road(town[TOWN_X.length - 1][3], r101sAt(21360), 'street', 'town');
// Country roads across the backcountry.
b.country([{ x: 2600, y: 11300 }, { x: 3800, y: 10900 }, { x: 4900, y: 11500 }], r9At(11000), sub[0][7], 'rural');
b.country([{ x: 3200, y: 17200 }, { x: 5200, y: 18100 }, { x: 7000, y: 17600 }], r9At(17500), r101sAt(17500), 'rural');
b.country([{ x: 10200, y: 17700 }, { x: 12000, y: 18600 }, { x: 14000, y: 18300 }], r101sAt(17500), b.node(15800, 18900, 'rural'), 'rural');
b.country([{ x: 2800, y: 21500 }, { x: 4400, y: 20800 }, { x: 5600, y: 21300 }], r9At(21000), town[0][3], 'rural');
b.country([{ x: 10500, y: 23900 }, { x: 12500, y: 25200 }, { x: 14200, y: 24600 }], r101sAt(24500), b.node(15800, 25200, 'rural'), 'rural');
b.country([{ x: 3200, y: 25300 }, { x: 5200, y: 24700 }, { x: 7000, y: 25100 }], r9At(24500), r101sAt(24500), 'rural');
b.country([{ x: 4000, y: 28300 }, { x: 6500, y: 27800 }], r9At(28000), r101sAt(28000), 'rural');
b.country([{ x: 11000, y: 28300 }, { x: 13500, y: 27600 }], r101sAt(28000), b.node(15800, 28200, 'rural'), 'rural');
b.country([{ x: 800, y: 14600 }, { x: 400, y: 16200 }], r9At(14000), b.node(600, 17500, 'rural'), 'rural');
b.country([{ x: 9800, y: 15000 }], r101sAt(14400), b.node(10600, 16900, 'rural'), 'rural');
}
/** The landmarks, once the roads and the buildings along them are in. */
export function expansionFeatures(b: Builder): void {
docksFeatures(b);
factoryFeatures(b);
railFeatures(b);
baseFeatures(b);
stripFeatures(b);
backcountryFeatures(b);
infrastructure(b);
}
// --- The docks.
function docksFeatures(b: Builder): void {
const L = b.layers;
// Gantry cranes along the quay, their booms out over the water.
for (let y = 520; y < 4500; y += 520) {
const cy = y + range(-60, 60);
const color = pick(['#c0392b', '#e67e22', '#2471a3']);
b.special(
'docks',
box(7800, cy, 70, 50),
(ctx) => {
dropShadow(ctx, box(7760, cy, 200, 46), 40);
local(ctx, 7800, cy, 0, () => {
// Legs and portal beams, then the boom reaching out across the berth.
ctx.fillStyle = color;
for (const [lx, ly] of [
[-33, -24],
[27, -24],
[-33, 20],
[27, 20],
])
ctx.fillRect(lx, ly, 6, 4);
ctx.fillRect(-35, -22, 70, 6);
ctx.fillRect(-35, 16, 70, 6);
ctx.fillRect(-160, -6, 210, 12);
ctx.fillStyle = shadeHex(color, -0.3);
ctx.fillRect(-160, -2, 210, 4);
ctx.fillStyle = '#2b2b2e';
ctx.fillRect(-20, -12, 22, 24);
ctx.fillStyle = '#ffd400';
ctx.fillRect(-120, -4, 10, 8);
});
},
{ mat: 'stone', hp: 700, building: false, layer: L.canopy },
);
}
// Ships at the berths: container ships stacked high, and a tanker.
const berths = [
{ y0: 400, len: 880, tanker: false },
{ y0: 1450, len: 760, tanker: true },
{ y0: 2400, len: 900, tanker: false },
{ y0: 3500, len: 700, tanker: false },
];
for (const s of berths) {
const cx = 7560;
const cy = s.y0 + s.len / 2;
const w = s.tanker ? 120 : 130;
const hull = s.tanker ? '#2c3e50' : pick(['#7b241c', '#1f618d', '#145a32']);
const rows = Array.from({ length: Math.floor((s.len - 220) / 44) }, () => Array.from({ length: 6 }, () => pick(['#c0392b', '#2471a3', '#d4ac0d', '#239b56', '#7d3c98', '#ca6f1e', '#5d6d7e', '#ecf0f1'])));
const outline: Vec[] = [
{ x: cx - w / 2, y: s.y0 + 60 },
{ x: cx, y: s.y0 },
{ x: cx + w / 2, y: s.y0 + 60 },
{ x: cx + w / 2, y: s.y0 + s.len - 20 },
{ x: cx + w / 2 - 12, y: s.y0 + s.len },
{ x: cx - w / 2 + 12, y: s.y0 + s.len },
{ x: cx - w / 2, y: s.y0 + s.len - 20 },
];
b.special(
'docks',
box(cx, cy, w, s.len),
(ctx) => {
dropShadow(ctx, outline, 14);
fill(ctx, outline, hull);
fill(
ctx,
outline.map((p) => ({ x: cx + (p.x - cx) * 0.86, y: cy + (p.y - cy) * 0.97 })),
s.tanker ? '#8a3324' : '#5d6d7e',
);
// The bridge and funnel aft.
ctx.fillStyle = '#ecf0f1';
ctx.fillRect(cx - w / 2 + 14, s.y0 + s.len - 110, w - 28, 50);
ctx.fillStyle = '#1d2a3c';
ctx.fillRect(cx - w / 2 + 18, s.y0 + s.len - 104, w - 36, 6);
ctx.fillStyle = s.tanker ? '#c0392b' : '#f39c12';
ctx.fillRect(cx - 12, s.y0 + s.len - 52, 24, 22);
if (s.tanker) {
// Deck pipework and tank hatches.
ctx.strokeStyle = '#d5d8dc';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(cx, s.y0 + 70);
ctx.lineTo(cx, s.y0 + s.len - 120);
ctx.stroke();
ctx.fillStyle = '#d5d8dc';
for (let y = s.y0 + 100; y < s.y0 + s.len - 130; y += 60) ctx.fillRect(cx - 34, y, 68, 10);
} else {
rows.forEach((row, k) => {
row.forEach((c, i) => {
ctx.fillStyle = c;
ctx.fillRect(cx - w / 2 + 14 + i * ((w - 28) / 6), s.y0 + 90 + k * 44, (w - 28) / 6 - 2, 40);
});
});
}
},
{ mat: 'stone', hp: s.tanker ? 1600 : 2200, fuel: s.tanker ? 3 : 0.6, building: false },
);
}
// Container stacks wherever there's room in the yards.
const colors = ['#c0392b', '#2471a3', '#d4ac0d', '#239b56', '#7d3c98', '#ca6f1e', '#5d6d7e', '#a04000', '#1abc9c'];
for (let i = 0; i < 2600; i++) {
const x = range(7990, 10900);
const y = range(320, 4820);
const vertical = rand() < 0.5;
const n = 2 + Math.floor(rand() * 4);
const w = vertical ? 30 : n * 42;
const h = vertical ? n * 42 : 30;
if (!b.claimBox(x, y, 0, w / 2 + 4, h / 2 + 4)) continue;
const stack = Array.from({ length: n * 2 }, () => pick(colors));
b.special(
'docks',
box(x, y, w, h),
(ctx) => {
dropShadow(ctx, box(x, y, w, h), 10);
stack.forEach((c, k) => {
const a = Math.floor(k / 2);
const sIdx = k % 2;
const cx = vertical ? x - 7.5 + sIdx * 15 : x - w / 2 + 21 + a * 42;
const cy = vertical ? y - h / 2 + 21 + a * 42 : y - 7.5 + sIdx * 15;
ctx.fillStyle = c;
if (vertical) ctx.fillRect(cx - 7, cy - 20, 14, 40);
else ctx.fillRect(cx - 20, cy - 7, 40, 14);
ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
if (vertical) for (let r = -18; r < 20; r += 4) ctx.fillRect(cx - 7, cy + r, 14, 0.8);
else for (let r = -18; r < 20; r += 4) ctx.fillRect(cx + r, cy - 7, 0.8, 14);
});
},
{ mat: 'stone', hp: 160, building: false },
);
}
}
// --- The steelworks and refinery.
function factoryFeatures(b: Builder): void {
const blockOf = (i: number, j: number) => ({ x0: FAC_X[i] + 80, y0: FAC_Y[j] + 80, x1: FAC_X[i + 1] - 80, y1: FAC_Y[j + 1] - 80 });
const [power1, power2, ref1, ref2, mill, gas, ref3] = PLANT_BLOCKS.map(([i, j]) => blockOf(i, j));
// The power station: two hyperbolic cooling towers trailing steam, and the turbine hall.
const hall = box((power1.x1 + power2.x0) / 2, (power1.y0 + power1.y1) / 2, 160, 300);
b.infra.push({
kind: 'power',
name: 'Steelworks Power Station',
capacity: 0.5,
st: b.special(
'factory',
hall,
(ctx) => {
dropShadow(ctx, hall, 30);
fill(ctx, hall, '#8d8f94');
ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
const [c0, , c2] = hall;
for (let y = Math.min(c0.y, c2.y) + 10; y < Math.max(c0.y, c2.y) - 6; y += 18) ctx.fillRect(Math.min(c0.x, c2.x) + 8, y, 144, 8);
ctx.fillStyle = '#ffd400';
ctx.fillRect(Math.min(c0.x, c2.x) + 4, Math.min(c0.y, c2.y) + 4, 10, 10);
},
{ mat: 'stone', hp: 2600, building: true },
),
});
for (const blk of [power1, power2]) {
const cx = (blk.x0 + blk.x1) / 2;
const cy = (blk.y0 + blk.y1) / 2;
const r = 150;
b.stacks.push({ x: cx, y: cy, steam: true });
b.special(
'factory',
box(cx, cy, r * 1.6, r * 1.6),
(ctx) => {
dropShadow(ctx, circlePts(cx, cy, r), 60);
const g = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.3, r * 0.2, cx, cy, r);
g.addColorStop(0, '#d6d3cb');
g.addColorStop(1, '#8f8b82');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#4f4c47';
ctx.beginPath();
ctx.arc(cx, cy, r * 0.62, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(240, 240, 240, 0.5)';
ctx.beginPath();
ctx.arc(cx, cy, r * 0.5, 0, Math.PI * 2);
ctx.fill();
},
{ mat: 'stone', hp: 3200, building: true },
);
}
// Refinery tank farms: rows of big round tanks in their bund walls, and a gas holder.
for (const blk of [ref1, ref2, ref3, gas]) {
const big = blk === gas;
const step = big ? 0 : 110;
const tanks: { x: number; y: number; r: number; color: string }[] = [];
if (big) tanks.push({ x: (blk.x0 + blk.x1) / 2, y: (blk.y0 + blk.y1) / 2, r: 140, color: '#7b8a8b' });
else for (let y = blk.y0 + 60; y < blk.y1 - 40; y += step) for (let x = blk.x0 + 60; x < blk.x1 - 40; x += step) if (rand() < 0.85) tanks.push({ x, y, r: range(34, 46), color: pick(['#ecf0f1', '#d5d8dc', '#b3b6b7', '#e5e1d3']) });
b.add(b.layers.ground, blk.x0, blk.y0, blk.x1, blk.y1, (ctx) => {
ctx.fillStyle = '#9a958a';
ctx.fillRect(blk.x0, blk.y0, blk.x1 - blk.x0, blk.y1 - blk.y0);
ctx.strokeStyle = '#7d786d';
ctx.lineWidth = 3;
ctx.strokeRect(blk.x0 + 6, blk.y0 + 6, blk.x1 - blk.x0 - 12, blk.y1 - blk.y0 - 12);
// Pipe racks between the tanks.
ctx.strokeStyle = '#6f6a62';
ctx.lineWidth = 2;
ctx.beginPath();
for (let y = blk.y0 + 115; y < blk.y1 - 40; y += 110) {
ctx.moveTo(blk.x0 + 20, y);
ctx.lineTo(blk.x1 - 20, y);
}
ctx.stroke();
});
for (const t of tanks) {
b.special(
'factory',
box(t.x, t.y, t.r * 1.6, t.r * 1.6),
(ctx) => {
dropShadow(ctx, circlePts(t.x, t.y, t.r), big ? 40 : 18);
const g = ctx.createRadialGradient(t.x - t.r * 0.35, t.y - t.r * 0.35, t.r * 0.1, t.x, t.y, t.r);
g.addColorStop(0, shadeHex(t.color, 0.3));
g.addColorStop(1, shadeHex(t.color, -0.25));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(t.x, t.y, t.r * 0.7, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#c0392b';
ctx.fillRect(t.x - 2, t.y - t.r + 3, 4, 6);
},
{ mat: 'stone', hp: big ? 1800 : 520, fuel: big ? 3.2 : 1.6, building: false },
);
}
}
// The steel mill: a blast furnace with its stoves and stack, and the long rolling mill.
const mx = (mill.x0 + mill.x1) / 2;
const my = (mill.y0 + mill.y1) / 2;
b.special(
'factory',
box(mx, my + 90, mill.x1 - mill.x0 - 40, 120),
(ctx) => {
const pts = box(mx, my + 90, mill.x1 - mill.x0 - 40, 120);
dropShadow(ctx, pts, 26);
fill(ctx, pts, '#7a8088');
ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
for (let x = mill.x0 + 30; x < mill.x1 - 20; x += 16) ctx.fillRect(x, my + 32, 6, 116);
},
{ mat: 'stone', hp: 2400, building: true },
);
for (const [k, sx] of [-120, -50, 20, 90].entries()) {
const x = mx + sx;
const y = my - 90;
const r = k === 0 ? 44 : 30;
if (k === 0) b.stacks.push({ x, y, steam: false });
b.special(
'factory',
box(x, y, r * 1.6, r * 1.6),
(ctx) => {
dropShadow(ctx, circlePts(x, y, r), 50);
ctx.fillStyle = k === 0 ? '#5d4a3a' : '#8d7f6e';
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = k === 0 ? '#ff8c2a' : '#3a3530';
ctx.beginPath();
ctx.arc(x, y, r * 0.35, 0, Math.PI * 2);
ctx.fill();
},
{ mat: 'stone', hp: k === 0 ? 1500 : 700, fuel: k === 0 ? 1.2 : 0, building: k === 0 },
);
}
}
// --- The rail yard.
function railFeatures(b: Builder): void {
// Trains standing on most of the tracks: boxcars, hoppers, flats with containers, tank cars.
const kinds = ['box', 'box', 'hopper', 'flat', 'tank', 'tank', 'box'] as const;
for (let t = 0; t < TRACKS; t++) {
if (rand() < 0.3) continue;
const y = TRACK_Y0 + t * TRACK_GAP;
let x = range(5800, 9000);
const end = Math.min(16000, x + range(1800, 5200));
let first = true;
while (x < end) {
const kind = first ? 'loco' : pick(kinds);
const len = kind === 'loco' ? 62 : 52;
const cx = x + len / 2;
const color = kind === 'loco' ? pick(['#f2b705', '#1f618d', '#c0392b']) : kind === 'tank' ? pick(['#1b1b1f', '#ecf0f1']) : kind === 'hopper' ? '#6e5a44' : kind === 'flat' ? '#5d6d7e' : pick(['#8a3324', '#6e2c00', '#7b4b2a', '#2e4053']);
const cargo = pick(['#c0392b', '#2471a3', '#d4ac0d', '#239b56']);
// Crossing Route 101: leave the level crossing clear.
if (Math.abs(cx - R101_X) > 80) {
b.special(
'rail',
box(cx, y, len - 4, 16),
(ctx) => {
dropShadow(ctx, box(cx, y, len - 4, 16), 8);
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(cx - len / 2 + 2, y - 8, len - 4, 16, kind === 'tank' ? 8 : 2);
ctx.fill();
if (kind === 'loco') {
ctx.fillStyle = '#1d2a3c';
ctx.fillRect(cx + len / 2 - 14, y - 6, 8, 12);
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
for (let gx = cx - len / 2 + 8; gx < cx + 10; gx += 6) ctx.fillRect(gx, y - 5, 3, 10);
} else if (kind === 'flat') {
ctx.fillStyle = cargo;
ctx.fillRect(cx - len / 2 + 5, y - 6.5, len - 10, 13);
} else if (kind === 'hopper') {
ctx.fillStyle = '#2b2118';
for (let k = 0; k < 3; k++) ctx.fillRect(cx - len / 2 + 6 + k * 15, y - 5, 12, 10);
} else if (kind === 'tank') {
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.fillRect(cx - len / 2 + 6, y - 6, len - 12, 3);
ctx.fillStyle = '#c0392b';
ctx.fillRect(cx - 3, y - 7, 6, 3);
} else {
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
for (let gx = cx - len / 2 + 6; gx < cx + len / 2 - 4; gx += 5) ctx.fillRect(gx, y - 8, 1, 16);
}
},
{ mat: 'stone', hp: kind === 'loco' ? 600 : 260, fuel: kind === 'tank' ? 1.3 : kind === 'loco' ? 0.6 : 0, building: false },
);
}
x += len;
first = false;
}
}
// The engine shed and the station at the west end of the yard.
const shed = box(6150, YARD_Y - 230, 520, 150);
b.special(
'rail',
shed,
(ctx) => {
dropShadow(ctx, shed, 22);
fill(ctx, shed, '#8a3324');
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
for (let x = 5900; x < 6410; x += 30) ctx.fillRect(x, YARD_Y - 305, 12, 150);
ctx.fillStyle = '#d5d8dc';
ctx.fillRect(5890, YARD_Y - 232, 520, 4);
},
{ mat: 'stone', hp: 1400, building: true },
);
}
// --- Fort Dunmore.
function baseFeatures(b: Builder): void {
const L = b.layers;
// The perimeter fence, open at the main gate.
const gateY = MIL_Y[1];
b.add(L.object, BASE.x0 - 4, BASE.y0 - 4, BASE.x1 + 4, BASE.y1 + 4, (ctx) => {
ctx.strokeStyle = 'rgba(150, 156, 160, 0.9)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 3]);
ctx.beginPath();
ctx.moveTo(BASE.x0, gateY - 60);
ctx.lineTo(BASE.x0, BASE.y0);
ctx.lineTo(BASE.x1, BASE.y0);
ctx.lineTo(BASE.x1, BASE.y1);
ctx.lineTo(BASE.x0, BASE.y1);
ctx.lineTo(BASE.x0, gateY + 60);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#c0392b';
ctx.fillRect(BASE.x0 - 12, gateY - 64, 24, 8);
ctx.fillRect(BASE.x0 - 12, gateY + 56, 24, 8);
});
// Hangars along the apron, arched roofs ribbed in the sun.
const hangarY = 13450;
for (let x = RUNWAY.x0 + 200; x < RUNWAY.x1 - 200; x += 560) {
const pts = box(x, hangarY, 300, 190);
b.special(
'military',
pts,
(ctx) => {
dropShadow(ctx, pts, 30);
const g = ctx.createLinearGradient(x - 150, 0, x + 150, 0);
g.addColorStop(0, '#5f6a52');
g.addColorStop(0.5, '#8d977c');
g.addColorStop(1, '#4f5a44');
ctx.fillStyle = g;
ctx.fillRect(x - 150, hangarY - 95, 300, 190);
ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
for (let ry = hangarY - 90; ry < hangarY + 95; ry += 14) ctx.fillRect(x - 150, ry, 300, 2);
ctx.fillStyle = '#2b2b2e';
ctx.fillRect(x - 130, hangarY + 92, 260, 4);
},
{ mat: 'stone', hp: 1600, building: true },
);
}
// Jets parked on the apron: fuel and ordnance aboard.
for (let x = RUNWAY.x0 + 150; x < RUNWAY.x1 - 100; x += 300) {
if (rand() < 0.2) continue;
const y = 13770;
const ang = -Math.PI / 2;
const fighter = rand() < 0.6;
b.special(
'military',
box(x, y, 50, 40),
(ctx) => {
local(ctx, x + 4, y + 5, ang, () => drawJet(ctx, fighter, 'rgba(0, 0, 0, 0.28)'));
local(ctx, x, y, ang, () => drawJet(ctx, fighter));
},
{ mat: 'stone', hp: 140, fuel: 0.9, building: false },
);
}
// The control tower and the radar dome by the apron.
const tower = box(12600, 13230, 70, 70);
b.special(
'military',
tower,
(ctx) => {
dropShadow(ctx, tower, 70);
fill(ctx, tower, '#d5d0c4');
ctx.fillStyle = '#1d2a3c';
ctx.fillRect(12570, 13200, 60, 60);
ctx.fillStyle = 'rgba(170, 205, 255, 0.5)';
ctx.fillRect(12575, 13205, 25, 8);
ctx.fillStyle = '#c0392b';
ctx.fillRect(12598, 13228, 4, 4);
},
{ mat: 'stone', hp: 900, building: true },
);
b.special(
'military',
box(13000, 13230, 70, 70),
(ctx) => {
dropShadow(ctx, circlePts(13000, 13230, 44), 40);
const g = ctx.createRadialGradient(12985, 13215, 6, 13000, 13230, 44);
g.addColorStop(0, '#ffffff');
g.addColorStop(1, '#b8bcc0');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(13000, 13230, 44, 0, Math.PI * 2);
ctx.fill();
},
{ mat: 'stone', hp: 500, building: false },
);
// The motor pool: rows of tanks south of the runway.
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 12; col++) {
if (rand() < 0.15) continue;
const x = 10200 + col * 70;
const y = 15050 + row * 60;
b.special(
'military',
box(x, y, 30, 20),
(ctx) => {
local(ctx, x + 2.5, y + 3, -Math.PI / 2, () => drawTank(ctx, 'rgba(0, 0, 0, 0.3)'));
local(ctx, x, y, -Math.PI / 2, () => drawTank(ctx));
},
{ mat: 'stone', hp: 320, fuel: 0.7, building: false },
);
}
}
// Ammunition bunkers: earth-covered igloos with concrete faces.
for (let k = 0; k < 9; k++) {
const x = 11600 + k * 330;
const y = 15500;
const pts = box(x, y, 120, 80);
b.special(
'military',
pts,
(ctx) => {
dropShadow(ctx, pts, 10);
const g = ctx.createRadialGradient(x, y + 10, 10, x, y, 70);
g.addColorStop(0, '#7fae5a');
g.addColorStop(1, '#4f7a3a');
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(x, y, 60, 40, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#b8b2a6';
ctx.fillRect(x - 26, y - 44, 52, 10);
ctx.fillStyle = '#5d6d7e';
ctx.fillRect(x - 12, y - 44, 24, 8);
},
{ mat: 'stone', hp: 700, fuel: 2.8, building: false },
);
}
// The fuel depot.
for (let k = 0; k < 4; k++) {
const x = 15200 + (k % 2) * 140;
const y = 15350 + Math.floor(k / 2) * 140;
b.special(
'military',
box(x, y, 90, 90),
(ctx) => {
dropShadow(ctx, circlePts(x, y, 55), 20);
const g = ctx.createRadialGradient(x - 18, y - 18, 6, x, y, 55);
g.addColorStop(0, '#9aa08a');
g.addColorStop(1, '#5a6048');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, 55, 0, Math.PI * 2);
ctx.fill();
},
{ mat: 'stone', hp: 700, fuel: 2.4, building: false },
);
}
}
/** A parked jet seen from above, nose along +x: a fighter, or an A-10. */
function drawJet(ctx: CanvasRenderingContext2D, fighter: boolean, shadow?: string): void {
const grey = shadow ?? (fighter ? '#9aa2a8' : '#7d878e');
const dark = shadow ?? '#59626a';
ctx.fillStyle = grey;
if (fighter) {
ctx.beginPath();
ctx.moveTo(24, 0);
ctx.lineTo(4, -4);
ctx.lineTo(-6, -18);
ctx.lineTo(-10, -18);
ctx.lineTo(-8, -4);
ctx.lineTo(-18, -9);
ctx.lineTo(-21, -9);
ctx.lineTo(-20, 0);
ctx.lineTo(-21, 9);
ctx.lineTo(-18, 9);
ctx.lineTo(-8, 4);
ctx.lineTo(-10, 18);
ctx.lineTo(-6, 18);
ctx.lineTo(4, 4);
ctx.closePath();
ctx.fill();
} else {
ctx.fillRect(-20, -2.5, 44, 5);
ctx.fillRect(-2, -26, 8, 52);
ctx.fillRect(-20, -9, 6, 18);
ctx.fillStyle = dark;
ctx.fillRect(-14, -6, 9, 4);
ctx.fillRect(-14, 2, 9, 4);
}
if (!shadow) {
ctx.fillStyle = '#1d2a3c';
ctx.beginPath();
ctx.ellipse(12, 0, 4, 1.8, 0, 0, Math.PI * 2);
ctx.fill();
}
}
/** A tank seen from above, gun along +x. */
function drawTank(ctx: CanvasRenderingContext2D, shadow?: string): void {
ctx.fillStyle = shadow ?? '#4b5320';
ctx.fillRect(-14, -9, 28, 18);
if (shadow) return;
ctx.fillStyle = '#2b2b2e';
ctx.fillRect(-14, -9, 28, 3.5);
ctx.fillRect(-14, 5.5, 28, 3.5);
ctx.fillStyle = '#5c6630';
ctx.beginPath();
ctx.arc(-1, 0, 6.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3a4019';
ctx.fillRect(4, -1, 18, 2);
}
// --- The Strip.
function stripFeatures(b: Builder): void {
const x = (COM_X[4] + COM_X[5]) / 2;
const y = (COM_Y[1] + COM_Y[2]) / 2;
const rx = 280;
const ry = 330;
const stadium = b.special(
'commercial',
box(x, y, rx * 1.7, ry * 1.7),
(ctx) => {
ctx.save();
ctx.translate(16, 18);
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Tiers of seats in team colours, the roof ring, the pitch and its lines.
const rings = ['#d5d8dc', '#2471a3', '#c0392b', '#2471a3', '#e5e1d3'];
rings.forEach((c, k) => {
ctx.fillStyle = c;
ctx.beginPath();
ctx.ellipse(x, y, rx - k * 26, ry - k * 26, 0, 0, Math.PI * 2);
ctx.fill();
});
ctx.fillStyle = '#4e9a3c';
ctx.fillRect(x - rx + 150, y - ry + 150, (rx - 150) * 2, (ry - 150) * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2;
ctx.strokeRect(x - rx + 160, y - ry + 160, (rx - 160) * 2, (ry - 160) * 2);
ctx.beginPath();
ctx.moveTo(x - rx + 160, y);
ctx.lineTo(x + rx - 160, y);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 30, 0, Math.PI * 2);
ctx.stroke();
},
{ mat: 'stone', hp: 5000, building: true },
);
b.infra.push({ kind: 'shelter', name: 'Stadium Shelter', st: stadium });
}
// --- The backcountry.
function backcountryFeatures(b: Builder): void {
// The reservoir behind its dam.
const R = RESERVOIR;
b.lake(R.x, R.y, R.rx, R.ry);
const dam = box(R.x, R.y + R.ry + 40, 900, 60);
const damSt = b.special(
'rural',
dam,
(ctx) => {
dropShadow(ctx, dam, 30);
fill(ctx, dam, '#b8b2a6');
ctx.fillStyle = '#8d877b';
ctx.fillRect(R.x - 450, R.y + R.ry + 50, 900, 20);
ctx.fillStyle = '#5dade2';
ctx.fillRect(R.x - 40, R.y + R.ry + 10, 80, 60);
},
{ mat: 'stone', hp: 6000, building: true },
);
b.infra.push({ kind: 'power', name: 'Harlan Dam Hydro', capacity: 0.25, st: damSt });
// The wind farm on the south-east ridges.
let placed = 0;
for (let i = 0; i < 400 && placed < 18; i++) {
const x = range(12800, 16100);
const y = range(25800, 28900);
if (!b.isFree(x, y, 30)) continue;
b.claimBox(x, y, 0, 8, 8);
const a = rand() * Math.PI * 2;
b.special(
'rural',
box(x, y, 12, 12),
(ctx) => {
// A long shadow across the fields, then the nacelle and three blades.
ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + 70, y + 80);
ctx.stroke();
ctx.fillStyle = '#f4f6f7';
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ecf0f1';
ctx.lineWidth = 3;
ctx.beginPath();
for (let k = 0; k < 3; k++) {
const ba = a + (k * Math.PI * 2) / 3;
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(ba) * 48, y + Math.sin(ba) * 48);
}
ctx.stroke();
},
{ mat: 'stone', hp: 400, building: false, layer: b.layers.canopy },
);
placed++;
}
// Woods all over the backcountry, patchy and thinner than the old forests.
const woods: [number, number, number, number, number][] = [
[200, 9500, 1300, 13800, 0.5],
[2000, 12000, 4800, 15600, 0.42],
[200, 18500, 1200, 23500, 0.5],
[2200, 22200, 5600, 24000, 0.4],
[9300, 19000, 10800, 23500, 0.45],
[14200, 19500, 16200, 23500, 0.5],
[6000, 25500, 9000, 27300, 0.4],
[9600, 26000, 12500, 27200, 0.38],
[3000, 26500, 5500, 27500, 0.42],
[5600, 15000, 8500, 16800, 0.4],
[5400, 9300, 5500, 14600, 0.3],
[11600, 16700, 16100, 17900, 0.4],
[200, 26000, 2500, 29000, 0.45],
];
for (const [x0, y0, x1, y1, d] of woods) b.forest(x0, y0, x1, y1, d);
}
// --- Critical infrastructure.
/** Somewhere free in a rectangle for a box of half-size hw × hd, or null. */
function freeSpot(b: Builder, rect: [number, number, number, number], hw: number, hd: number, tries = 700): Vec | null {
const [x0, y0, x1, y1] = rect;
for (let i = 0; i < tries; i++) {
const x = range(x0 + hw, x1 - hw);
const y = range(y0 + hd, y1 - hd);
if (b.claimBox(x, y, 0, hw + 6, hd + 6)) return { x, y };
}
return null;
}
const rectOf = (d: District): [number, number, number, number] | null => {
const r = DISTRICT_RECTS.find((e) => e[0] === d);
return r ? [r[1], r[2], r[3], r[4]] : null;
};
/** Areas to look in for each district's substation and water tower (the old country and the backcountry have no rectangle). */
// (A function, not a constant: the district table isn't ready while this module loads.)
type Rect = [number, number, number, number];
const serviceAreas = (): [District, string, Rect, ...Rect[]][] => [
['downtown', 'Downtown', rectOf('downtown')!, [1640, 1850, 1885, 3300], [1880, 3110, 3250, 3360], [1700, 1700, 3300, 1950]],
['uptown', 'Uptown', rectOf('uptown')!],
['hood', 'The Hood', rectOf('hood')!, [90, 2450, 300, 3950], [280, 3870, 1420, 4120], [1380, 2500, 1480, 3900]],
['port', 'Port', rectOf('port')!],
['village', 'Millbrook', [1500, 4800, 2700, 5900]],
['industrial', 'Industrial Park', rectOf('industrial')!],
['country', 'County', [300, 4400, 3000, 6600]],
['docks', 'Docks', rectOf('docks')!],
['factory', 'Steelworks', rectOf('factory')!],
['rail', 'Rail Yard', [5600, 6950, 16000, 7250]],
['suburb', 'Maple Grove', rectOf('suburb')!],
['commercial', 'The Strip', rectOf('commercial')!],
['military', 'Fort Dunmore', [9300, 10300, 16000, 13000]],
['town', 'Harlan', [6000, 20500, 8200, 22300]],
['rural', 'Backcountry', [2500, 16500, 8500, 20000]],
];
function infrastructure(b: Builder): void {
// Bayside Power Station: the old city's oil-fired plant, two tall stacks and its tanks.
const plant = freeSpot(b, [3000, 6900, 5150, 8900], 130, 90) ?? freeSpot(b, [300, 4400, 5100, 6800], 130, 90);
if (plant) {
const { x, y } = plant;
const pts = box(x, y, 260, 180);
b.stacks.push({ x: x - 70, y: y - 50, steam: false }, { x: x - 20, y: y - 50, steam: false });
b.infra.push({
kind: 'power',
name: 'Bayside Power Station',
capacity: 0.25,
st: b.special(
'industrial',
pts,
(ctx) => {
dropShadow(ctx, pts, 40);
fill(ctx, box(x, y, 260, 180), '#9a958a');
fill(ctx, box(x - 30, y + 10, 180, 110), '#7a8088');
ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
for (let k = -110; k < 60; k += 16) ctx.fillRect(x + k, y - 44, 8, 108);
for (const sx of [-70, -20]) {
ctx.fillStyle = '#b8b2a6';
ctx.beginPath();
ctx.arc(x + sx, y - 50, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#2b2b2e';
ctx.beginPath();
ctx.arc(x + sx, y - 50, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#c0392b';
ctx.fillRect(x + sx - 14, y - 52, 28, 4);
}
for (const tx of [80, 110]) {
ctx.fillStyle = '#d5d8dc';
ctx.beginPath();
ctx.arc(x + tx, y + 50, 20, 0, Math.PI * 2);
ctx.fill();
}
},
{ mat: 'stone', hp: 2400, building: true, fuel: 1.2 },
),
});
}
// A substation in every district: transformers in a fenced gravel yard.
const firstFree = (rects: Rect[], hw: number, hd: number) => {
for (const r of rects) {
const at = freeSpot(b, r, hw, hd);
if (at) return at;
}
return null;
};
for (const [d, name, ...rects] of serviceAreas()) {
const at = firstFree(rects, 40, 32);
if (!at) continue;
const { x, y } = at;
const pts = box(x, y, 80, 64);
b.infra.push({
kind: 'substation',
name: `${name} Substation`,
st: b.special(
d,
pts,
(ctx) => {
fill(ctx, pts, '#a8a49a');
ctx.strokeStyle = '#6f6a62';
ctx.lineWidth = 1;
ctx.setLineDash([3, 2]);
ctx.strokeRect(x - 40, y - 32, 80, 64);
ctx.setLineDash([]);
for (let k = 0; k < 3; k++) {
const tx = x - 24 + k * 24;
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(tx - 6, y - 8, 15, 16);
ctx.fillStyle = '#5d6d7e';
ctx.fillRect(tx - 8, y - 10, 14, 16);
ctx.fillStyle = '#d5d8dc';
ctx.fillRect(tx - 6, y - 14, 2, 4);
ctx.fillRect(tx - 1, y - 14, 2, 4);
ctx.fillRect(tx + 4, y - 14, 2, 4);
}
ctx.strokeStyle = '#3a3a3e';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(x - 36, y - 22);
ctx.lineTo(x + 36, y - 22);
ctx.moveTo(x - 36, y + 20);
ctx.lineTo(x + 36, y + 20);
ctx.stroke();
ctx.fillStyle = '#ffd400';
ctx.fillRect(x + 28, y + 22, 8, 6);
},
{ mat: 'stone', hp: 420, building: false, fuel: 0.6 },
),
});
}
// Water towers: a tank on legs, the district's name round it.
for (const [d, name, ...rects] of serviceAreas()) {
if (d === 'rail' || d === 'port') continue;
const at = firstFree(rects, 22, 22);
if (!at) continue;
const { x, y } = at;
b.infra.push({
kind: 'watertower',
name: `${name} Water Tower`,
st: b.special(
d,
box(x, y, 36, 36),
(ctx) => {
// A tall tower casts a long shadow.
ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
ctx.beginPath();
ctx.ellipse(x + 40, y + 46, 22, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 0, 0, 0.28)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(x + 4, y + 5);
ctx.lineTo(x + 34, y + 40);
ctx.stroke();
ctx.strokeStyle = '#5d6d7e';
ctx.lineWidth = 2;
ctx.beginPath();
for (const [lx, ly] of [
[-14, -14],
[14, -14],
[-14, 14],
[14, 14],
]) {
ctx.moveTo(x + lx, y + ly);
ctx.lineTo(x + lx * 0.4, y + ly * 0.4);
}
ctx.stroke();
const g = ctx.createRadialGradient(x - 6, y - 6, 2, x, y, 20);
g.addColorStop(0, '#e8f4fb');
g.addColorStop(1, '#8fb6cc');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#5d6d7e';
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
},
{ mat: 'stone', hp: 360, building: false },
),
});
}
// Waterworks: the lakeside plant for the old city and the reservoir plant below the dam.
const works: [string, [number, number, number, number]][] = [
['Lakeside Waterworks', [200, 300, 1500, 1700]],
['Reservoir Waterworks', [10800, 22300, 13200, 23600]],
];
for (const [name, rect] of works) {
const at = freeSpot(b, rect, 110, 80);
if (!at) continue;
const { x, y } = at;
const pts = box(x, y, 220, 160);
b.infra.push({
kind: 'waterworks',
name,
st: b.special(
name.startsWith('Lake') ? 'country' : 'rural',
pts,
(ctx) => {
dropShadow(ctx, pts, 10);
fill(ctx, pts, '#b8b2a6');
// Settling tanks and filter beds.
for (const [cx, cy] of [
[-60, -30],
[0, -30],
[60, -30],
]) {
ctx.fillStyle = '#8d877b';
ctx.beginPath();
ctx.arc(x + cx, y + cy, 26, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#4f8fb3';
ctx.beginPath();
ctx.arc(x + cx, y + cy, 22, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#3f86ad';
ctx.fillRect(x - 96, y + 14, 120, 50);
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
for (let k = 0; k < 4; k++) ctx.fillRect(x - 96, y + 22 + k * 12, 120, 2);
ctx.fillStyle = '#7a8088';
ctx.fillRect(x + 36, y + 10, 60, 56);
},
{ mat: 'stone', hp: 1600, building: true },
),
});
}
// Radio and phone masts: the city's eyes and ears.
const masts: [District, string, [number, number, number, number]][] = [
['country', 'Ridge Mast', [1700, 300, 2500, 1300]],
['commercial', 'Strip Tower', rectOf('commercial')!],
['rural', 'Harlan Mast', [9000, 18000, 11000, 20000]],
];
for (const [d, name, rect] of masts) {
const at = freeSpot(b, rect, 16, 16);
if (!at) continue;
const { x, y } = at;
b.infra.push({
kind: 'comms',
name,
st: b.special(
d,
box(x, y, 26, 26),
(ctx) => {
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + 120, y + 138);
ctx.stroke();
ctx.strokeStyle = '#c0392b';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(x - 12, y + 10);
ctx.lineTo(x, y - 12);
ctx.lineTo(x + 12, y + 10);
ctx.closePath();
ctx.moveTo(x - 6, y - 1);
ctx.lineTo(x + 6, y - 1);
ctx.stroke();
ctx.fillStyle = '#ecf0f1';
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ff3b3b';
ctx.fillRect(x - 1, y - 14, 2, 2);
},
{ mat: 'stone', hp: 300, building: false, layer: b.layers.canopy },
),
});
}
// City Hall, where the emergency operations centre runs the response; and shelters in the towns.
const nearest = (x: number, y: number, district: District) =>
b.buildings.filter((st) => st.district === district && !st.station).sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y))[0];
const hall = nearest(2600, 2520, 'downtown');
if (hall) b.infra.push({ kind: 'eoc', name: 'City Hall EOC', st: hall });
for (const [x, y, d, name] of [
[2050, 5340, 'village', 'Millbrook School Shelter'],
[6900, 21360, 'town', 'Harlan Hall Shelter'],
[7100, 11000, 'suburb', 'Maple Grove High Shelter'],
[800, 3200, 'hood', 'Hood Community Shelter'],
] as [number, number, District, string][]) {
const st = nearest(x, y, d);
if (st) b.infra.push({ kind: 'shelter', name, st });
}
}
