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 {
angleDiff,
boxCorners,
clamp,
convexHull,
cubicPoints,
makePath,
offsetPoints,
pointAt,
slicePath,
smoothCurve,
tracePath,
type Path,
type Vec,
} from './geometry.js';
import { drawCar, makeLook, shade, type CarModel } from './vehicles.js';
import { expansionFeatures, expansionNetwork, expansionScenery, type Builder, type InfraSpec } from './expansion.js';
export type District =
| 'downtown'
| 'uptown'
| 'hood'
| 'port'
| 'highway'
| 'country'
| 'village'
| 'industrial'
| 'docks'
| 'factory'
| 'rail'
| 'suburb'
| 'commercial'
| 'military'
| 'rural'
| 'town';
export type LaneKind = 'street' | 'connector' | 'highway' | 'ramp';
export interface Lane extends Path {
id: number;
kind: LaneKind;
speed: number;
next: number[];
left: number;
right: number;
turn: boolean;
district: District;
/** Layer (0 ground, 1 elevated) before and after `breakS`. */
layer0: number;
layer1: number;
breakS: number;
}
export interface Item {
x0: number;
y0: number;
x1: number;
y1: number;
draw(ctx: CanvasRenderingContext2D): void;
}
export interface Spot {
x: number;
y: number;
angle: number;
district: District;
}
export interface WalkEnd {
walk: number;
atStart: boolean;
x: number;
y: number;
links: { walk: number; atStart: boolean }[];
cross: { walk: number; atStart: boolean } | null;
}
export interface Walk extends Path {
id: number;
district: District;
ends: [WalkEnd, WalkEnd];
}
/** The whole map: the original city in the north-west corner, and the country around it. */
export const MAP_W = 16400;
export const MAP_H = 29200;
/** The original city's corner of the map. */
export const CORE_W = 5200;
export const CORE_H = 9200;
export const GRASS = '#5f9447';
export const L_GROUND = 0;
export const L_WALK = 1;
export const L_ROAD = 2;
export const L_MARK = 3;
export const L_OBJECT = 4;
export const L_CANOPY = 5;
export const L_DECK = 6;
export const L_DECKTOP = 7;
export const SURF_GRASS = 0;
export const SURF_ROAD = 1;
export const SURF_SIDEWALK = 2;
export const SURF_DIRT = 3;
export const SURF_WATER = 4;
export function laneLayer(lane: Lane, s: number): number {
return s < lane.breakS ? lane.layer0 : lane.layer1;
}
const CELL = 8;
const GW = Math.ceil(MAP_W / CELL);
const GH = Math.ceil(MAP_H / CELL);
const HASH = 128;
const OCC_ROAD = 1;
const OCC_BUILT = 2;
const OCC_DECK = 4;
const OCC_WATER = 8;
const WATER = '#3f86ad';
const SAND = '#d9c99a';
const rand = Math.random;
const range = (a: number, b: number) => a + Math.random() * (b - a);
const randInt = (n: number) => Math.floor(Math.random() * n);
const pick = <T>(items: readonly T[]): T => items[randInt(items.length)];
interface Spec {
lanes: number;
laneW: number;
median: number;
medianType: 'none' | 'grass';
sidewalk: number;
speed: number;
crosswalk: boolean;
center: 'yellow' | 'none';
oneWay: boolean;
}
const SPECS = {
street: { lanes: 1, laneW: 14, median: 0, medianType: 'none', sidewalk: 9, speed: 55, crosswalk: true, center: 'yellow', oneWay: false },
avenue: { lanes: 2, laneW: 15, median: 0, medianType: 'none', sidewalk: 11, speed: 62, crosswalk: true, center: 'yellow', oneWay: false },
boulevard: { lanes: 1, laneW: 15, median: 16, medianType: 'grass', sidewalk: 10, speed: 62, crosswalk: true, center: 'none', oneWay: false },
expressway: { lanes: 2, laneW: 16, median: 18, medianType: 'grass', sidewalk: 0, speed: 112, crosswalk: false, center: 'none', oneWay: false },
industrial: { lanes: 1, laneW: 18, median: 0, medianType: 'none', sidewalk: 6, speed: 58, crosswalk: false, center: 'yellow', oneWay: false },
country: { lanes: 1, laneW: 14, median: 0, medianType: 'none', sidewalk: 0, speed: 72, crosswalk: false, center: 'yellow', oneWay: false },
drive: { lanes: 1, laneW: 13, median: 0, medianType: 'none', sidewalk: 7, speed: 45, crosswalk: false, center: 'none', oneWay: false },
ramp: { lanes: 1, laneW: 16, median: 0, medianType: 'none', sidewalk: 0, speed: 88, crosswalk: false, center: 'none', oneWay: true },
spur: { lanes: 2, laneW: 16, median: 0, medianType: 'none', sidewalk: 0, speed: 150, crosswalk: false, center: 'none', oneWay: true },
} satisfies Record<string, Spec>;
type SpecName = keyof typeof SPECS;
interface Style {
asphalt: string;
sidewalk: string;
curb: string;
markAlpha: number;
worn: boolean;
}
const STYLES: Record<District, Style> = {
downtown: { asphalt: '#3a3b45', sidewalk: '#a7a195', curb: '#d3ccbc', markAlpha: 1, worn: false },
uptown: { asphalt: '#45474f', sidewalk: '#d9d2c2', curb: '#f1ebdc', markAlpha: 1, worn: false },
hood: { asphalt: '#3d3a37', sidewalk: '#8e877a', curb: '#a39b8b', markAlpha: 0.5, worn: true },
port: { asphalt: '#403f3d', sidewalk: '#94918a', curb: '#aaa69c', markAlpha: 0.8, worn: true },
highway: { asphalt: '#37383f', sidewalk: '#9a9488', curb: '#c8c2b4', markAlpha: 1, worn: false },
country: { asphalt: '#3f3e3a', sidewalk: '#8c8466', curb: '#a39b80', markAlpha: 0.85, worn: true },
village: { asphalt: '#45454a', sidewalk: '#cfc6b2', curb: '#ece4d0', markAlpha: 1, worn: false },
industrial: { asphalt: '#3c3c3e', sidewalk: '#8f8c84', curb: '#a7a398', markAlpha: 0.75, worn: true },
docks: { asphalt: '#3e3d3b', sidewalk: '#96938b', curb: '#aba79d', markAlpha: 0.8, worn: true },
factory: { asphalt: '#3a3a3c', sidewalk: '#8b8880', curb: '#a39f94', markAlpha: 0.7, worn: true },
rail: { asphalt: '#3d3c3a', sidewalk: '#8f8a7e', curb: '#a6a092', markAlpha: 0.7, worn: true },
suburb: { asphalt: '#47484f', sidewalk: '#d6cfbf', curb: '#ede7d8', markAlpha: 1, worn: false },
commercial: { asphalt: '#3b3c45', sidewalk: '#b3ad9f', curb: '#d6cfbf', markAlpha: 1, worn: false },
military: { asphalt: '#3f413a', sidewalk: '#8e8d80', curb: '#a6a492', markAlpha: 1, worn: false },
rural: { asphalt: '#403f3a', sidewalk: '#8c8466', curb: '#a39b80', markAlpha: 0.75, worn: true },
town: { asphalt: '#45454a', sidewalk: '#cfc6b2', curb: '#ece4d0', markAlpha: 1, worn: false },
};
/** Which of the original districts' buildings each district borrows (military has its own). */
export const LOOK: Record<District, District> = {
downtown: 'downtown',
uptown: 'uptown',
hood: 'hood',
port: 'port',
highway: 'highway',
country: 'country',
village: 'village',
industrial: 'industrial',
docks: 'port',
factory: 'industrial',
rail: 'industrial',
suburb: 'uptown',
commercial: 'downtown',
military: 'military',
rural: 'country',
town: 'village',
};
interface RNode {
id: number;
x: number;
y: number;
district: District;
arms: Arm[];
radius: number;
joint: boolean;
}
interface Arm {
road: Road;
angle: number;
in: number[];
out: number[];
}
interface Road {
id: number;
a: RNode | null;
b: RNode | null;
ctrl: Vec[];
spec: Spec;
name: SpecName;
district: District;
halfW: number;
pts: Vec[];
fwd: number[];
back: number[];
layer0: number;
layer1: number;
breakFrac: number;
}
export type Material = 'concrete' | 'barrier' | 'shore' | 'wood';
export type StationKind = 'hospital' | 'police' | 'fire' | 'tow' | 'works';
/** A hospital, police station, fire station, tow depot or public works yard, with a fixed fleet. */
export interface Station {
kind: StationKind;
x: number;
y: number;
angle: number;
w: number;
d: number;
units: number;
district: District;
/** Its building: if that's destroyed, the station's fleet is out of action until it's rebuilt. */
structure?: Structure;
}
export interface Venue {
kind: 'market' | 'party' | 'rave';
x: number;
y: number;
r: number;
angle: number;
district: District;
}
interface Collider {
ax: number;
ay: number;
bx: number;
by: number;
r: number;
layer: number;
mat: Material;
/** The structure this wall belongs to, or -1. */
owner: number;
}
/** A building (or other solid block) that can be smashed: wood gives way easily, stone takes a beating. */
export interface Structure {
id: number;
x: number;
y: number;
angle: number;
w: number;
d: number;
district: District;
mat: 'wood' | 'stone';
hp: number;
maxHp: number;
destroyed: boolean;
/** An ordinary building (not a fence post, tank or pile). */
building: boolean;
/** A tree (w is its canopy diameter): blasts and gunfire fell it. */
tree?: boolean;
/** Seconds of fire left (0 or undefined: not burning). */
burning?: number;
/** A station's building (hospital, police, fire, tow, works). */
station?: boolean;
/** Game time it came down (for the collapse animation), and which way it fell. */
fellAt?: number;
fallAngle?: number;
/** How long it's been burning (a building that burns long enough comes down). */
burnTime?: number;
/** Full of fuel or explosives (tanks, tank cars, aircraft, ammo bunkers): it goes up when it comes down. */
fuel?: number;
}
/** Traffic lights at a junction: each approach lane belongs to one of two phases. */
export interface Signal {
x: number;
y: number;
/** Seconds into the cycle at time zero, so junctions don't all change together. */
offset: number;
approaches: { lane: number; phase: number; x: number; y: number; angle: number }[];
}
/** Districts built of timber (houses, barns, shops); everywhere else is brick, stone and concrete. */
const WOODEN = new Set<District>(['hood', 'village', 'country', 'suburb', 'rural', 'town']);
function strokeLine(ctx: CanvasRenderingContext2D, pts: Vec[], color: string, width: number, dash: number[] = [], closed = false): void {
if (pts.length < 2) return;
tracePath(ctx, pts, closed);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dash);
ctx.stroke();
ctx.setLineDash([]);
}
function bboxOf(pts: Vec[], pad: number): [number, number, number, number] {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (const p of pts) {
x0 = Math.min(x0, p.x);
y0 = Math.min(y0, p.y);
x1 = Math.max(x1, p.x);
y1 = Math.max(y1, p.y);
}
return [x0 - pad, y0 - pad, x1 + pad, y1 + pad];
}
function fillPoly(ctx: CanvasRenderingContext2D, pts: Vec[], color: string): void {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
ctx.closePath();
ctx.fill();
}
/** Arc-length position where a polyline crosses the horizontal line y. */
function sAtY(pts: Vec[], y: number): number {
const path = makePath(pts);
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
if ((a.y - y) * (b.y - y) <= 0 && a.y !== b.y) return path.cum[i - 1] + ((y - a.y) / (b.y - a.y)) * (path.cum[i] - path.cum[i - 1]);
}
return y < pts[0].y ? 0 : path.len;
}
function sAtX(pts: Vec[], x: number): number {
let s = 0;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
const seg = Math.hypot(b.x - a.x, b.y - a.y);
if ((a.x - x) * (b.x - x) <= 0 && a.x !== b.x) return s + seg * ((x - a.x) / (b.x - a.x));
s += seg;
}
return s;
}
function nearestIndex(pts: Vec[], p: Vec): number {
let best = 0;
let bestD = Infinity;
for (let i = 0; i < pts.length; i++) {
const d = (pts[i].x - p.x) ** 2 + (pts[i].y - p.y) ** 2;
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
function tangentAt(pts: Vec[], i: number): Vec {
const a = pts[Math.max(0, i - 1)];
const b = pts[Math.min(pts.length - 1, i + 1)];
const l = Math.hypot(b.x - a.x, b.y - a.y) || 1;
return { x: (b.x - a.x) / l, y: (b.y - a.y) / l };
}
/** Arc position on `curve` closest to any point of `other`. */
function crossingS(curve: Vec[], other: Vec[]): number {
let best = 0;
let bestD = Infinity;
const path = makePath(curve);
for (let i = 0; i < curve.length; i++) {
for (let k = 0; k < other.length; k += 3) {
const d = (curve[i].x - other[k].x) ** 2 + (curve[i].y - other[k].y) ** 2;
if (d < bestD) {
bestD = d;
best = path.cum[i];
}
}
}
return best;
}
const COUNTRY_RECTS: [number, number, number, number][] = [
[0, 0, 1460, 1720],
[1660, 0, 2560, 1450],
[1600, 3560, 4900, 4400],
[0, 1950, 290, 4400],
[0, 3880, 1600, 4400],
[0, 4300, 5200, 6800],
[0, 6800, 3000, 9200],
[3000, 8900, 5200, 9200],
];
export const DISTRICT_RECTS: [District, number, number, number, number][] = [
['downtown', 1880, 1940, 3230, 3110],
['uptown', 2560, 330, 4600, 1460],
['hood', 280, 2540, 1380, 3870],
['port', 3560, 2990, 4880, 4120],
['village', 1650, 5050, 2470, 5630],
['industrial', 3000, 6880, 5150, 8900],
['docks', 7760, 250, 11000, 4900],
['factory', 11350, 250, 16150, 5150],
['rail', 5500, 5250, 16150, 7450],
['commercial', 9000, 7850, 16150, 9900],
['suburb', 5500, 7850, 8650, 14600],
['military', 9150, 10150, 16150, 16300],
['town', 6250, 20750, 7750, 21950],
];
export class City {
readonly lanes: Lane[] = [];
readonly walks: Walk[] = [];
readonly layers: Item[][] = [[], [], [], [], [], [], [], []];
readonly pickupSpots: Spot[] = [];
readonly dropSpots: Spot[] = [];
readonly venues: Venue[] = [];
readonly stations: Station[] = [];
/** Every ordinary building's footprint (for mob hits and repair crews). */
/** Everything solid that can be smashed, and the subset that are ordinary buildings. */
readonly structures: Structure[] = [];
readonly buildings: Structure[] = [];
/** Traffic lights, and which signal (and phase) controls each lane's stop line. */
readonly signals: Signal[] = [];
readonly laneSignal = new Map<number, { signal: Signal; phase: number }>();
/** Where tow trucks take the wrecks. */
junkyard: Vec | null = null;
/** The structure hit by the deepest contact in the last resolveCircle call. */
lastStructure: Structure | null = null;
/** While a building is being laid out: its district (null for stations, which can't be destroyed). */
private structDistrict: District | null = null;
/** Which stations each district still needs. */
private readonly stationPlan: Partial<Record<District, StationKind[]>> = {
downtown: ['hospital', 'police', 'fire', 'tow', 'works'],
hood: ['police', 'fire', 'works', 'hospital', 'tow'],
uptown: ['fire', 'police', 'hospital', 'works', 'tow'],
village: ['hospital', 'police', 'fire', 'works', 'tow'],
industrial: ['fire', 'tow', 'police', 'works'],
port: ['tow', 'works', 'fire'],
country: ['tow', 'fire'],
docks: ['fire', 'tow', 'works', 'police'],
factory: ['fire', 'works', 'tow', 'hospital'],
rail: ['tow', 'works'],
suburb: ['fire', 'police', 'hospital', 'works', 'tow'],
commercial: ['police', 'hospital', 'fire', 'tow', 'works'],
military: ['fire', 'hospital', 'police', 'works'],
town: ['hospital', 'police', 'fire', 'works', 'tow'],
rural: ['tow', 'fire', 'works', 'tow', 'fire'],
};
/** Street lamps, lit at night. */
readonly lights: Vec[] = [];
/** Crop fields and orchards, where farmers work. */
readonly fields: { x: number; y: number; w: number; h: number; kind: 'crop' | 'orchard' }[] = [];
/** Construction sites: centre, angle and size. */
readonly sites: { x: number; y: number; angle: number; w: number; d: number }[] = [];
/** Factory yards with forklift routes and workers. */
readonly yards: { x: number; y: number; angle: number; w: number; d: number }[] = [];
/** Smokestacks and cooling towers that puff smoke or steam. */
readonly stacks: { x: number; y: number; steam: boolean }[] = [];
/** Fenced fields where cows graze. */
readonly pastures: { x: number; y: number; w: number; h: number }[] = [];
readonly countryCurves: Vec[][] = [];
readonly surface = new Uint8Array(GW * GH);
private readonly occ = new Uint8Array(GW * GH);
private readonly deckMask = new Uint8Array(GW * GH);
private readonly rampMask = new Uint8Array(GW * GH);
private readonly spawnLanes: number[] = [];
private readonly spawnCum: number[] = [];
private readonly nodes: RNode[] = [];
private readonly roads: Road[] = [];
private readonly colliders: Collider[] = [];
private readonly hash = new Map<number, number[]>();
private readonly mark: number[] = [];
private stamp = 0;
/** Material of the deepest contact found by the last resolveCircle call. */
lastMaterial: Material = 'concrete';
private pendingOff: { from: number; road: Road }[] = [];
private pendingOn: { to: number; road: Road }[] = [];
private freewayCenter: Vec[] = [];
constructor() {
this.buildScenery();
this.buildNetwork();
this.finalizeRoads();
this.paintRoads();
this.placePiers();
this.buildConnectors();
this.placeSignals();
this.paintMarkings();
this.buildWalks();
this.placeLots();
expansionFeatures(this.builder());
this.placeVenues();
this.plantFields();
this.plantForests();
this.sprinkleTrees();
for (const lane of this.lanes) {
if (lane.kind === 'connector') continue;
this.spawnLanes.push(lane.id);
this.spawnCum.push((this.spawnCum[this.spawnCum.length - 1] ?? 0) + lane.len);
}
}
/** The city's critical infrastructure: power, water, comms, City Hall, shelters. */
readonly infra: InfraSpec[] = [];
/** Extra woods for plantForests (the backcountry's). */
private readonly extraForests: [number, number, number, number, number][] = [];
/** The city's building tools, handed to the expansion (see expansion.ts). */
private builder(): Builder {
return {
add: (layer, x0, y0, x1, y1, draw) => this.add(layer, x0, y0, x1, y1, draw),
node: (x, y, d) => this.node(x, y, d),
road: (a, b, kind, d, ctrl) => this.road(a as RNode, b as RNode, kind, d, ctrl),
chain: (curve, positions, kind, d, ends = []) => this.chain(curve, positions, kind, d, ends as [number, RNode][]),
grid: (xs, ys, d, kindAt, skip) => this.grid(xs, ys, d, kindAt, skip),
country: (ctrl, a, b, d) => {
const curve = smoothCurve([{ x: a.x, y: a.y }, ...ctrl, { x: b.x, y: b.y }], 8);
this.countryCurves.push(curve);
const len = makePath(curve).len;
this.chain(curve, [0, len], 'country', d, [
[0, a as RNode],
[len, b as RNode],
]);
},
special: (d, corners, draw, opts) => this.special(d, corners, draw, opts),
lake: (cx, cy, rx, ry) => this.lake(cx, cy, rx, ry),
reserve: (x0, y0, x1, y1) => this.stampRect(this.occ, x0, y0, x1 - x0, y1 - y0, OCC_BUILT, true),
isFree: (x, y, r) => {
for (let dy = -r; dy <= r; dy += CELL) {
for (let dx = -r; dx <= r; dx += CELL) {
const j = this.cellIndex(x + dx, y + dy);
if (j < 0 || this.occ[j] !== 0 || this.surface[j] === SURF_WATER || this.surface[j] === SURF_ROAD) return false;
}
}
return true;
},
claimBox: (cx, cy, angle, hw, hd) => {
if (!this.lotFree(boxCorners(cx, cy, angle, hw, hd), cx, cy, angle, hw, hd)) return false;
this.claim(cx, cy, angle, hw, hd);
return true;
},
forest: (x0, y0, x1, y1, density) => this.extraForests.push([x0, y0, x1, y1, density]),
ground: (x0, y0, x1, y1, surface) => this.stampRect(this.surface, x0, y0, x1 - x0, y1 - y0, surface === 'road' ? SURF_ROAD : surface === 'dirt' ? SURF_DIRT : SURF_SIDEWALK),
stacks: this.stacks,
curvePath: (ctrl, step) => smoothCurve(ctrl, step),
pathLength: (pts) => makePath(pts).len,
sAtY: (pts, y) => sAtY(pts, y),
sAtX: (pts, x) => sAtX(pts, x),
layers: { ground: L_GROUND, walk: L_WALK, object: L_OBJECT, canopy: L_CANOPY },
infra: this.infra,
buildings: this.buildings,
};
}
// --- Queries.
districtAt(x: number, y: number): District {
for (const [d, x0, y0, x1, y1] of DISTRICT_RECTS) if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return d;
for (const [x0, y0, x1, y1] of COUNTRY_RECTS) if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return 'country';
// Beyond the old city: open country, farms and forest.
if (x > CORE_W || y > CORE_H) return 'rural';
return 'highway';
}
private cellIndex(x: number, y: number): number {
const cx = Math.floor(x / CELL);
const cy = Math.floor(y / CELL);
if (cx < 0 || cy < 0 || cx >= GW || cy >= GH) return -1;
return cy * GW + cx;
}
surfaceAt(x: number, y: number): number {
const i = this.cellIndex(x, y);
return i < 0 ? SURF_GRASS : this.surface[i];
}
onDeck(x: number, y: number): boolean {
const i = this.cellIndex(x, y);
return i >= 0 && this.deckMask[i] === 1;
}
onRampMiddle(x: number, y: number): boolean {
const i = this.cellIndex(x, y);
return i >= 0 && this.rampMask[i] === 1;
}
randomSpawn(): { lane: number; s: number } {
const total = this.spawnCum[this.spawnCum.length - 1];
const target = rand() * total;
let lo = 0;
let hi = this.spawnCum.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.spawnCum[mid] < target) lo = mid + 1;
else hi = mid;
}
const lane = this.lanes[this.spawnLanes[lo]];
return { lane: lane.id, s: rand() * lane.len };
}
/** Push needed to move a circle out of everything solid on `layer`. */
resolveCircle(x: number, y: number, r: number, layer: number): Vec {
let px = 0;
let py = 0;
let deepest = 0;
this.lastStructure = null;
this.stamp++;
for (let cy = Math.floor((y - r) / HASH); cy <= Math.floor((y + r) / HASH); cy++) {
for (let cx = Math.floor((x - r) / HASH); cx <= Math.floor((x + r) / HASH); cx++) {
const cell = this.hash.get((cy + 10) * 1000 + cx + 10);
if (!cell) continue;
for (const id of cell) {
if (this.mark[id] === this.stamp) continue;
this.mark[id] = this.stamp;
const s = this.colliders[id];
if (s.layer !== layer) continue;
if (s.owner >= 0 && this.structures[s.owner].destroyed) continue;
const qx = x + px;
const qy = y + py;
const vx = s.bx - s.ax;
const vy = s.by - s.ay;
const t = clamp(((qx - s.ax) * vx + (qy - s.ay) * vy) / (vx * vx + vy * vy || 1), 0, 1);
const dx = qx - (s.ax + vx * t);
const dy = qy - (s.ay + vy * t);
const reach = r + s.r;
const d = Math.hypot(dx, dy);
if (d >= reach) continue;
if (d < 1e-6) {
const l = Math.hypot(vx, vy) || 1;
px += (-vy / l) * reach;
py += (vx / l) * reach;
continue;
}
px += (dx / d) * (reach - d);
py += (dy / d) * (reach - d);
if (reach - d > deepest) {
deepest = reach - d;
this.lastMaterial = s.mat;
this.lastStructure = s.owner >= 0 ? this.structures[s.owner] : null;
}
}
}
}
return { x: px, y: py };
}
// --- Low-level builders.
private add(layer: number, x0: number, y0: number, x1: number, y1: number, draw: (ctx: CanvasRenderingContext2D) => void): void {
this.layers[layer].push({ x0, y0, x1, y1, draw });
}
private addLane(pts: Vec[], kind: LaneKind, speed: number, district: District, turn = false, layers: [number, number, number] = [0, 0, 0]): number {
const id = this.lanes.length;
const path = makePath(pts);
this.lanes.push({ ...path, id, kind, speed, next: [], left: -1, right: -1, turn, district, layer0: layers[0], layer1: layers[1], breakS: layers[2] * path.len });
return id;
}
private solidSeg(a: Vec, b: Vec, r: number, layer: number, mat?: Material): void {
const id = this.colliders.length;
this.colliders.push({ ax: a.x, ay: a.y, bx: b.x, by: b.y, r, layer, mat: mat ?? (r >= 1.5 ? 'barrier' : 'concrete'), owner: this.solidOwner });
for (let cy = Math.floor((Math.min(a.y, b.y) - r) / HASH); cy <= Math.floor((Math.max(a.y, b.y) + r) / HASH); cy++) {
for (let cx = Math.floor((Math.min(a.x, b.x) - r) / HASH); cx <= Math.floor((Math.max(a.x, b.x) + r) / HASH); cx++) {
const key = (cy + 10) * 1000 + cx + 10;
let cell = this.hash.get(key);
if (!cell) this.hash.set(key, (cell = []));
cell.push(id);
}
}
}
private solidLine(pts: Vec[], r: number, layer: number, closed = false, mat?: Material): void {
const n = closed ? pts.length : pts.length - 1;
for (let i = 0; i < n; i++) this.solidSeg(pts[i], pts[(i + 1) % pts.length], r, layer, mat);
}
private solidOwner = -1;
private solidPoly(corners: Vec[], layer = 0): void {
const district = this.structDistrict;
if (!district || layer !== 0) {
this.solidLine(corners, 0.5, layer, true);
return;
}
// Part of a building: register it as a structure that can be smashed.
const [c0, c1, c2] = corners;
const w = Math.hypot(c1.x - c0.x, c1.y - c0.y);
const d = Math.hypot(c2.x - c1.x, c2.y - c1.y);
const mat = WOODEN.has(district) ? 'wood' : 'stone';
const area = w * d;
const hp = mat === 'wood' ? 40 + area * 0.04 : 180 + area * 0.12;
const st: Structure = {
id: this.structures.length,
x: corners.reduce((a, p) => a + p.x, 0) / corners.length,
y: corners.reduce((a, p) => a + p.y, 0) / corners.length,
angle: Math.atan2(c1.y - c0.y, c1.x - c0.x),
w,
d,
district,
mat,
hp,
maxHp: hp,
destroyed: false,
building: area > 250,
};
this.structures.push(st);
if (st.building) this.buildings.push(st);
this.solidOwner = st.id;
this.solidLine(corners, 0.5, layer, true, mat === 'wood' ? 'wood' : 'concrete');
this.solidOwner = -1;
}
/** Knock a structure about. Returns true if that brought it down. */
damageStructure(st: Structure, amount: number): boolean {
if (st.destroyed || amount <= 0) return false;
st.hp -= amount;
if (st.hp > 0) return false;
st.destroyed = true;
return true;
}
/** Put a collapsed structure back up (the repair crews). */
rebuild(st: Structure): void {
st.destroyed = false;
st.hp = st.maxHp;
}
/** Structures with any part within r of a point. */
private structureGrid: Map<number, Structure[]> | null = null;
/** Structures with any part within r of a point (set `all` to include ones already down). */
structuresNear(x: number, y: number, r: number, all = false): Structure[] {
if (!this.structureGrid) {
this.structureGrid = new Map();
for (const st of this.structures) {
const key = Math.floor(st.y / 64) * 1000 + Math.floor(st.x / 64);
const cell = this.structureGrid.get(key);
if (cell) cell.push(st);
else this.structureGrid.set(key, [st]);
}
}
// Buildings are up to ~90 across, so look a cell further than the radius.
const out: Structure[] = [];
const reach = r + 50;
for (let cy = Math.floor((y - reach) / 64); cy <= Math.floor((y + reach) / 64); cy++) {
for (let cx = Math.floor((x - reach) / 64); cx <= Math.floor((x + reach) / 64); cx++) {
const cell = this.structureGrid.get(cy * 1000 + cx);
if (!cell) continue;
for (const st of cell) if ((all || !st.destroyed) && Math.hypot(st.x - x, st.y - y) < r + Math.max(st.w, st.d) / 2) out.push(st);
}
}
return out;
}
private stampCircle(grid: Uint8Array, x: number, y: number, r: number, value: number, or = false): void {
for (let cy = Math.floor((y - r) / CELL); cy <= Math.floor((y + r) / CELL); cy++) {
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) {
if (cx < 0 || cy < 0 || cx >= GW || cy >= GH) continue;
const dx = cx * CELL + CELL / 2 - x;
const dy = cy * CELL + CELL / 2 - y;
if (dx * dx + dy * dy > r * r) continue;
const i = cy * GW + cx;
grid[i] = or ? grid[i] | value : value;
}
}
}
private stampPath(grid: Uint8Array, pts: Vec[], r: number, value: number, or = false): void {
const path = makePath(pts);
for (let s = 0; s <= path.len; s += CELL * 0.75) {
const p = pointAt(path, s);
this.stampCircle(grid, p.x, p.y, r, value, or);
}
}
private stampRect(grid: Uint8Array, x: number, y: number, w: number, h: number, value: number, or = false): void {
for (let cy = Math.floor(y / CELL); cy <= Math.floor((y + h) / CELL); cy++) {
for (let cx = Math.floor(x / CELL); cx <= Math.floor((x + w) / CELL); cx++) {
if (cx < 0 || cy < 0 || cx >= GW || cy >= GH) continue;
const i = cy * GW + cx;
grid[i] = or ? grid[i] | value : value;
}
}
}
private tree(x: number, y: number, r: number, tone: 'park' | 'fancy' | 'scrappy' | 'pine' = 'park'): void {
this.structures.push({ id: this.structures.length, x, y, angle: 0, w: r * 2, d: r * 2, district: 'country', mat: 'wood', hp: 10, maxHp: 10, destroyed: false, building: false, tree: true });
const [dark, light] =
tone === 'fancy' ? ['#2a6538', '#7cc35f'] : tone === 'scrappy' ? ['#4d6b33', '#93ab5b'] : tone === 'pine' ? ['#1f4d2c', '#3f7f45'] : ['#2f6a35', '#6db454'];
const blobs = Array.from({ length: 3 }, () => ({ a: rand() * Math.PI * 2, d: r * range(0.25, 0.5), s: r * range(0.35, 0.5) }));
this.add(L_CANOPY, x - r, y - r, x + r * 1.5, y + r * 1.6, (ctx) => {
ctx.fillStyle = 'rgba(0, 0, 0, 0.26)';
ctx.beginPath();
ctx.ellipse(x + r * 0.35, y + r * 0.5, r, r * 0.95, 0, 0, Math.PI * 2);
ctx.fill();
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
g.addColorStop(0, light);
g.addColorStop(1, dark);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
for (const b of blobs) {
ctx.beginPath();
ctx.arc(x + Math.cos(b.a) * b.d - r * 0.15, y + Math.sin(b.a) * b.d - r * 0.15, b.s, 0, Math.PI * 2);
ctx.fill();
}
});
}
// --- Scenery: grass texture, district grounds, the bay, parks, farms.
private buildScenery(): void {
for (let gy = 0; gy < MAP_H; gy += 256) {
for (let gx = 0; gx < MAP_W; gx += 256) {
const tufts = Array.from({ length: 46 }, () => ({ x: gx + rand() * 256, y: gy + rand() * 256, light: rand() < 0.5 }));
this.add(L_GROUND, gx, gy, gx + 258, gy + 258, (ctx) => {
ctx.lineWidth = 0.8;
ctx.lineCap = 'round';
for (const t of tufts) {
ctx.strokeStyle = t.light ? 'rgba(160, 210, 110, 0.35)' : 'rgba(30, 70, 30, 0.3)';
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.lineTo(t.x - 1, t.y - 2.5);
ctx.moveTo(t.x + 1, t.y);
ctx.lineTo(t.x + 1.8, t.y - 2.2);
ctx.stroke();
}
ctx.lineCap = 'butt';
});
}
}
const pads: [District, string][] = [
['downtown', '#8f8a80'],
['hood', '#86795c'],
['port', '#8a8984'],
];
for (const [d, color] of pads) {
const [, x0, y0, x1, y1] = DISTRICT_RECTS.find((r) => r[0] === d)!;
const patches = d === 'hood' ? Array.from({ length: 140 }, () => ({ x: range(x0, x1), y: range(y0, y1), r: range(6, 22) })) : [];
this.add(L_GROUND, x0, y0, x1, y1, (ctx) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(x0, y0, x1 - x0, y1 - y0, 30);
ctx.fill();
ctx.fillStyle = 'rgba(95, 140, 70, 0.55)';
for (const p of patches) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
});
this.stampRect(this.surface, x0, y0, x1 - x0, y1 - y0, d === 'hood' ? SURF_DIRT : SURF_SIDEWALK);
}
// The bay: the old city's waterfront on its west shore, round the south end, and the
// docks' quays along its east shore.
const coast = smoothCurve(
[
{ x: 4930, y: -50 },
{ x: 4940, y: 900 },
{ x: 4900, y: 1900 },
{ x: 4910, y: 2900 },
{ x: 4920, y: 4150 },
{ x: 5000, y: 4400 },
{ x: 5180, y: 4560 },
{ x: 5600, y: 4700 },
{ x: 6300, y: 4860 },
{ x: 7000, y: 4920 },
{ x: 7480, y: 4800 },
{ x: 7690, y: 4460 },
{ x: 7700, y: 3500 },
{ x: 7700, y: 2400 },
{ x: 7700, y: 1300 },
{ x: 7700, y: 300 },
{ x: 7700, y: -50 },
],
12,
);
const waterPoly = coast;
this.add(L_GROUND, 4800, -100, 7800, 5000, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, coast, SAND, 40);
fillPoly(ctx, waterPoly, WATER);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
ctx.lineWidth = 1.2;
for (let i = 0; i < coast.length; i += 9) {
const p = coast[i];
ctx.beginPath();
ctx.moveTo(p.x + 30, p.y + 14);
ctx.quadraticCurveTo(p.x + 38, p.y + 10, p.x + 46, p.y + 14);
ctx.stroke();
}
});
for (const p of coast) {
this.stampCircle(this.occ, p.x, p.y, 24, OCC_WATER, true);
}
// The country beyond the old city.
expansionScenery(this.builder());
// Rasterise the water polygon at grid resolution.
const mask = document.createElement('canvas');
mask.width = GW;
mask.height = GH;
const mctx = mask.getContext('2d', { willReadFrequently: true })!;
mctx.scale(1 / CELL, 1 / CELL);
fillPoly(mctx, waterPoly, '#000');
const data = mctx.getImageData(0, 0, GW, GH).data;
for (let i = 0; i < GW * GH; i++) {
if (data[i * 4 + 3] > 128) {
this.surface[i] = SURF_WATER;
this.occ[i] |= OCC_WATER;
}
}
for (const layer of [0, 1]) {
this.solidLine(offsetPoints(coast, -6), 4, layer, false, 'shore');
this.solidLine([{ x: 0, y: 0 }, { x: MAP_W, y: 0 }, { x: MAP_W, y: MAP_H }, { x: 0, y: MAP_H }], 6, layer, true);
}
// Lakeside park in the north-west, Mirror Lake in the south and the quarry.
this.lake(720, 860, 380, 250);
this.lake(3300, 5300, 250, 160);
this.quarry(3000, 6420, 190);
for (let i = 0; i < 170; i++) {
const a = rand() * Math.PI * 2;
const d = range(1.15, 2.1);
this.tree(720 + Math.cos(a) * 380 * d, 860 + Math.sin(a) * 250 * d, range(7, 13), rand() < 0.4 ? 'pine' : 'park');
}
// Farm fields to the south.
for (let i = 0; i < 6; i++) {
const fx = 1700 + i * 300;
const fy = 3720;
const color = pick(['#b9a35a', '#8fae4f', '#c9b36a', '#7d9f45']);
this.add(L_GROUND, fx, fy, fx + 280, fy + 360, (ctx) => {
ctx.fillStyle = color;
ctx.fillRect(fx, fy, 280, 360);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.12)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let r = 6; r < 280; r += 9) {
ctx.moveTo(fx + r, fy + 4);
ctx.lineTo(fx + r, fy + 356);
}
ctx.stroke();
});
this.stampRect(this.occ, fx, fy, 280, 360, OCC_BUILT, true);
}
}
private quarry(cx: number, cy: number, r: number): void {
const rings = 5;
this.add(L_GROUND, cx - r - 30, cy - r - 30, cx + r + 30, cy + r + 30, (ctx) => {
ctx.fillStyle = '#a89a80';
ctx.beginPath();
ctx.ellipse(cx, cy, r + 26, r * 0.8 + 26, 0.2, 0, Math.PI * 2);
ctx.fill();
for (let i = 0; i < rings; i++) {
const k = 1 - i / rings;
ctx.fillStyle = ['#9a8f7c', '#8a806e', '#7b7262', '#6c6456', '#5d574b'][i];
ctx.beginPath();
ctx.ellipse(cx + i * 6, cy + i * 4, r * k, r * 0.8 * k, 0.2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
ctx.lineWidth = 1.2;
ctx.stroke();
}
ctx.fillStyle = '#4f6e7a';
ctx.beginPath();
ctx.ellipse(cx + 30, cy + 22, r * 0.18, r * 0.12, 0.2, 0, Math.PI * 2);
ctx.fill();
});
this.stampCircle(this.surface, cx, cy, r, SURF_DIRT);
}
private lake(cx: number, cy: number, rx: number, ry: number): void {
this.add(L_GROUND, cx - rx - 40, cy - ry - 40, cx + rx + 40, cy + ry + 40, (ctx) => {
ctx.strokeStyle = '#cdb98c';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.ellipse(cx, cy, rx + 30, ry + 30, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = SAND;
ctx.beginPath();
ctx.ellipse(cx, cy, rx + 8, ry + 8, 0, 0, Math.PI * 2);
ctx.fill();
const g = ctx.createRadialGradient(cx, cy, 30, cx, cy, rx);
g.addColorStop(0, '#2f6f95');
g.addColorStop(1, WATER);
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
});
for (let a = 0; a < Math.PI * 2; a += 0.05) {
this.stampCircle(this.surface, cx + Math.cos(a) * rx * 0.5, cy + Math.sin(a) * ry * 0.5, Math.min(rx, ry) * 0.55, SURF_WATER);
}
this.stampRect(this.occ, cx - rx - 10, cy - ry - 10, (rx + 10) * 2, (ry + 10) * 2, OCC_WATER, true);
const ring: Vec[] = [];
for (let a = 0; a < Math.PI * 2; a += 0.08) ring.push({ x: cx + Math.cos(a) * (rx - 4), y: cy + Math.sin(a) * (ry - 4) });
this.solidLine(ring, 2, 0, true, 'shore');
}
/** Dense woods with solid trunks, grouped into one painted item per map chunk. */
private plantForests(): void {
const regions: [number, number, number, number, number][] = [
[20, 20, 1450, 1700, 0.85],
[1680, 20, 2540, 1420, 0.55],
[20, 1960, 280, 4300, 0.7],
[3600, 3600, 4800, 3980, 0.25],
[20, 4300, 1450, 6780, 0.8],
[4250, 5900, 5180, 6780, 0.75],
[1700, 5800, 2600, 6780, 0.5],
[20, 7900, 1300, 9180, 0.7],
[1700, 8600, 3000, 9180, 0.6],
[3800, 4500, 5180, 4900, 0.45],
...this.extraForests,
];
const cells = new Map<number, { x: number; y: number; r: number; tone: number }[]>();
for (const [x0, y0, x1, y1, density] of regions) {
for (let y = y0; y < y1; y += 17) {
for (let x = x0; x < x1; x += 17) {
const px = x + range(-6, 6);
const py = y + range(-6, 6);
// Patchy woods: a cheap noise field thins the trees into clearings.
const n = Math.sin(px * 0.011) * Math.cos(py * 0.013) + Math.sin((px + py) * 0.005);
if (rand() > density * (0.55 + n * 0.45)) continue;
let clear = true;
for (let dy = -2; dy <= 2 && clear; dy++) {
for (let dx = -2; dx <= 2 && clear; dx++) {
const j = this.cellIndex(px + dx * CELL, py + dy * CELL);
if (j < 0 || this.occ[j] !== 0 || this.surface[j] === SURF_WATER || this.surface[j] === SURF_ROAD) clear = false;
}
}
if (!clear) continue;
const r = range(6, 11);
// Each trunk is a (very weak) structure, so it can be blown down.
this.structures.push({ id: this.structures.length, x: px, y: py, angle: 0, w: r * 2, d: r * 2, district: 'country', mat: 'wood', hp: 12, maxHp: 12, destroyed: false, building: false, tree: true });
this.solidOwner = this.structures.length - 1;
this.solidSeg({ x: px, y: py }, { x: px + 0.01, y: py }, 2.4, 0, 'wood');
this.solidOwner = -1;
const key = Math.floor(py / 256) * 1000 + Math.floor(px / 256);
const list = cells.get(key) ?? [];
list.push({ x: px, y: py, r, tone: rand() });
cells.set(key, list);
const i = this.cellIndex(px, py);
if (i >= 0) this.occ[i] |= OCC_BUILT;
}
}
}
for (const trees of cells.values()) {
const [x0, y0, x1, y1] = bboxOf(trees, 14);
this.add(L_CANOPY, x0, y0, x1 + 8, y1 + 10, (ctx) => {
ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
for (const t of trees) {
ctx.beginPath();
ctx.ellipse(t.x + t.r * 0.4, t.y + t.r * 0.5, t.r, t.r * 0.9, 0, 0, Math.PI * 2);
ctx.fill();
}
for (const t of trees) {
const pine = t.tone < 0.45;
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, pine ? '#3f7f45' : t.tone < 0.8 ? '#6db454' : '#8fbf4a');
g.addColorStop(1, pine ? '#1b4527' : '#2a5f30');
ctx.fillStyle = g;
ctx.beginPath();
if (pine) {
for (let k = 0; k < 8; k++) {
const a = (k / 8) * Math.PI * 2;
const rr = k % 2 ? t.r * 0.7 : t.r;
ctx.lineTo(t.x + Math.cos(a) * rr, t.y + Math.sin(a) * rr);
}
ctx.closePath();
} else {
ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
}
ctx.fill();
}
});
}
}
/** Patchwork farmland: crops, orchards, pastures and hay. */
private plantFields(): void {
// The old city's farms, then the backcountry's bigger, emptier ones.
const areas: { x0: number; y0: number; x1: number; y1: number; sx: number; sy: number; district: District; skip: number }[] = [
{ x0: 320, y0: 3950, x1: 4850, y1: 9100, sx: 240, sy: 190, district: 'country', skip: 0.25 },
{ x0: 200, y0: 9300, x1: 16200, y1: 29000, sx: 300, sy: 240, district: 'rural', skip: 0.35 },
];
for (const area of areas)
for (let y = area.y0; y < area.y1; y += area.sy) {
for (let x = area.x0; x < area.x1; x += area.sx) {
const w = range(area.sx * 0.7, area.sx * 0.94);
const h = range(area.sy * 0.68, area.sy * 0.92);
const fx = x + range(0, area.sx - w);
const fy = y + range(0, area.sy - h);
const cx = fx + w / 2;
const cy = fy + h / 2;
if (this.districtAt(cx, cy) !== area.district || rand() < area.skip) continue;
if (area.district === 'rural') {
// Leave the backcountry's woods to the trees, and gather the farms into clusters.
if (this.extraForests.some(([x0, y0, x1, y1]) => cx > x0 && cx < x1 && cy > y0 && cy < y1)) continue;
const n = Math.sin(cx * 0.0011 + 1.3) * Math.cos(cy * 0.0009) + Math.sin((cx - cy) * 0.0006);
if (n < -0.25 || rand() > 0.55 + n * 0.4) continue;
}
if (!this.lotFree(boxCorners(cx, cy, 0, w / 2, h / 2), cx, cy, 0, w / 2, h / 2)) continue;
this.claim(cx, cy, 0, w / 2, h / 2);
const roll = rand();
if (roll < 0.22) {
this.pastures.push({ x: fx, y: fy, w, h });
this.add(L_GROUND, fx - 2, fy - 2, fx + w + 2, fy + h + 2, (ctx) => {
ctx.fillStyle = '#79b257';
ctx.fillRect(fx, fy, w, h);
ctx.strokeStyle = '#8a6a44';
ctx.lineWidth = 1;
ctx.strokeRect(fx, fy, w, h);
ctx.fillStyle = '#8a6a44';
for (let i = 0; i < w; i += 12) {
ctx.fillRect(fx + i - 0.6, fy - 0.6, 1.2, 1.2);
ctx.fillRect(fx + i - 0.6, fy + h - 0.6, 1.2, 1.2);
}
});
// Fences keep the cows in (and the truck out, mostly).
this.solidLine([{ x: fx, y: fy }, { x: fx + w, y: fy }, { x: fx + w, y: fy + h }, { x: fx, y: fy + h }], 0.6, 0, true, 'wood');
} else if (roll < 0.42) {
this.fields.push({ x: fx, y: fy, w, h, kind: 'orchard' });
const trees: Vec[] = [];
for (let ty = fy + 10; ty < fy + h - 6; ty += 16) for (let tx = fx + 10; tx < fx + w - 6; tx += 16) trees.push({ x: tx + range(-1, 1), y: ty + range(-1, 1) });
const fruit = pick(['#e74c3c', '#f39c12', '#f4d03f']);
this.add(L_CANOPY, fx - 4, fy - 4, fx + w + 8, fy + h + 8, (ctx) => {
ctx.fillStyle = '#6a9a48';
ctx.fillRect(fx, fy, w, h);
for (const t of trees) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
ctx.beginPath();
ctx.arc(t.x + 2, t.y + 2.5, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3f7f3a';
ctx.beginPath();
ctx.arc(t.x, t.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = fruit;
ctx.fillRect(t.x - 2, t.y - 1, 1, 1);
ctx.fillRect(t.x + 1.5, t.y + 1, 1, 1);
}
});
} else {
this.fields.push({ x: fx, y: fy, w, h, kind: 'crop' });
const crop = pick(['#b9a35a', '#8fae4f', '#c9b36a', '#7d9f45', '#9b7b52', '#d9c65a']);
const sunflower = rand() < 0.15;
const bales = rand() < 0.3 ? Array.from({ length: 6 }, () => ({ x: fx + range(10, w - 10), y: fy + range(10, h - 10) })) : [];
const vertical = rand() < 0.5;
this.add(L_GROUND, fx, fy, fx + w, fy + h, (ctx) => {
ctx.fillStyle = sunflower ? '#5d8a3a' : crop;
ctx.fillRect(fx, fy, w, h);
ctx.strokeStyle = sunflower ? '#f4d03f' : 'rgba(0, 0, 0, 0.12)';
ctx.lineWidth = sunflower ? 1.6 : 2;
ctx.setLineDash(sunflower ? [1.6, 2.4] : []);
ctx.beginPath();
if (vertical) {
for (let r = 6; r < w; r += 9) {
ctx.moveTo(fx + r, fy + 4);
ctx.lineTo(fx + r, fy + h - 4);
}
} else {
for (let r = 6; r < h; r += 9) {
ctx.moveTo(fx + 4, fy + r);
ctx.lineTo(fx + w - 4, fy + r);
}
}
ctx.stroke();
ctx.setLineDash([]);
for (const b of bales) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
ctx.beginPath();
ctx.arc(b.x + 1.5, b.y + 2, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e2c25a';
ctx.beginPath();
ctx.arc(b.x, b.y, 3, 0, Math.PI * 2);
ctx.fill();
}
});
for (const b of bales) this.solidSeg(b, { x: b.x + 0.01, y: b.y }, 3, 0, 'wood');
}
}
}
}
/** Farmer's markets and wild parties: open spots the game fills with stalls and crowds. */
private placeVenues(): void {
const tryPlace = (kind: Venue['kind'], district: District, x0: number, y0: number, x1: number, y1: number, r: number): boolean => {
for (let i = 0; i < 1500; i++) {
const x = range(x0, x1);
const y = range(y0, y1);
const corners = boxCorners(x, y, 0, r, r);
if (!this.lotFree(corners, x, y, 0, r, r)) continue;
this.claim(x, y, 0, r, r);
const venue = { kind, x, y, r, angle: rand() * Math.PI, district };
this.venues.push(venue);
this.add(L_OBJECT, x - r - 4, y - r - 4, x + r + 4, y + r + 4, (ctx) => {
ctx.fillStyle = kind === 'market' ? '#a89470' : kind === 'rave' ? '#3a3631' : '#6aa84f';
ctx.beginPath();
ctx.roundRect(x - r, y - r, r * 2, r * 2, 10);
ctx.fill();
if (kind === 'party') {
ctx.fillStyle = '#3fb4dc';
ctx.beginPath();
ctx.roundRect(x - r * 0.5, y + r * 0.2, r, r * 0.55, 4);
ctx.fill();
}
});
return true;
}
return false;
};
// Markets on the farm road and downtown; parties in uptown and the hood.
const farmCurve = this.countryCurves[2];
if (farmCurve) {
const p = farmCurve[Math.floor(farmCurve.length * 0.3)];
tryPlace('market', 'country', p.x - 140, p.y - 140, p.x + 140, p.y + 140, 42);
}
tryPlace('market', 'downtown', 1900, 1950, 3200, 3100, 30) || tryPlace('market', 'downtown', 1700, 1900, 3300, 3200, 24);
tryPlace('party', 'uptown', 2600, 400, 4500, 1400, 36);
tryPlace('party', 'hood', 300, 2560, 1360, 3860, 26) || tryPlace('party', 'hood', 290, 2550, 1380, 3880, 18);
tryPlace('party', 'village', 1600, 5000, 2500, 5700, 20) || tryPlace('party', 'village', 1550, 4950, 2550, 5750, 15);
tryPlace('rave', 'port', 3600, 3000, 4850, 4100, 42) || tryPlace('rave', 'port', 3600, 3000, 4850, 4100, 30);
tryPlace('rave', 'country', 250, 1150, 1300, 1650, 48);
tryPlace('market', 'village', 1600, 5000, 2500, 5700, 20) || tryPlace('market', 'village', 1550, 4950, 2550, 5750, 16);
tryPlace('party', 'suburb', 5600, 8000, 8600, 14500, 30);
tryPlace('party', 'suburb', 5600, 8000, 8600, 14500, 24);
tryPlace('market', 'town', 6250, 20750, 7750, 21950, 22);
tryPlace('rave', 'rail', 5700, 6900, 11000, 7250, 40);
tryPlace('rave', 'docks', 8000, 400, 10900, 4800, 36);
}
private sprinkleTrees(): void {
let placed = 0;
for (let i = 0; i < 30000 && placed < 3200; i++) {
const x = range(20, MAP_W - 20);
const y = range(20, MAP_H - 20);
let clear = true;
for (let dy = -2; dy <= 2 && clear; dy++) {
for (let dx = -2; dx <= 2 && clear; dx++) {
const j = this.cellIndex(x + dx * CELL, y + dy * CELL);
if (j < 0 || this.occ[j] !== 0 || this.surface[j] === SURF_WATER || this.surface[j] === SURF_ROAD) clear = false;
}
}
if (!clear) continue;
const district = this.districtAt(x, y);
this.tree(x, y, range(6, 12), district === 'uptown' ? 'fancy' : district === 'hood' ? 'scrappy' : rand() < 0.3 ? 'pine' : 'park');
placed++;
}
}
// --- Road network topology.
private node(x: number, y: number, district: District): RNode {
const n: RNode = { id: this.nodes.length, x, y, district, arms: [], radius: 0, joint: false };
this.nodes.push(n);
return n;
}
private road(a: RNode | null, b: RNode | null, name: SpecName, district: District, ctrl?: Vec[], layers: [number, number, number] = [0, 0, 0]): Road {
const spec = SPECS[name];
const halfW = spec.median / 2 + spec.lanes * spec.laneW * (spec.oneWay ? 0.5 : 1) + (name === 'expressway' ? 4 : 0);
const r: Road = {
id: this.roads.length,
a,
b,
ctrl: ctrl ?? [
{ x: a!.x, y: a!.y },
{ x: b!.x, y: b!.y },
],
spec,
name,
district,
halfW,
pts: [],
fwd: [],
back: [],
layer0: layers[0],
layer1: layers[1],
breakFrac: layers[2],
};
this.roads.push(r);
return r;
}
/** Nodes along a curve at the given arc positions, joined by roads. Returns a lookup by position. */
private chain(curve: Vec[], positions: number[], name: SpecName, district: District, existing: [number, RNode][] = []): (s: number) => RNode {
const path = makePath(curve);
const sorted = [...new Set(positions.map((s) => clamp(s, 0, path.len)))].sort((a, b) => a - b);
const nodes = sorted.map((s) => {
const known = existing.find(([es]) => Math.abs(es - s) < 1);
if (known) return known[1];
const p = pointAt(path, s);
return this.node(p.x, p.y, district);
});
for (let i = 0; i < nodes.length - 1; i++) {
const piece = slicePath(curve, sorted[i], sorted[i + 1]);
piece[0] = { x: nodes[i].x, y: nodes[i].y };
piece[piece.length - 1] = { x: nodes[i + 1].x, y: nodes[i + 1].y };
const mid = this.districtAt((nodes[i].x + nodes[i + 1].x) / 2, (nodes[i].y + nodes[i + 1].y) / 2);
this.road(nodes[i], nodes[i + 1], name, mid === 'highway' ? district : mid, piece);
}
return (s: number) => {
let best = 0;
for (let i = 1; i < sorted.length; i++) if (Math.abs(sorted[i] - s) < Math.abs(sorted[best] - s)) best = i;
return nodes[best];
};
}
private grid(xs: number[], ys: number[], district: District, kindAt: (vertical: boolean, index: number) => SpecName, skip = 0): RNode[][] {
const nodes = xs.map((x) => ys.map((y) => this.node(x, y, district)));
const degree = new Map<RNode, number>();
const links: [RNode, RNode, SpecName][] = [];
for (let j = 0; j < ys.length; j++) for (let i = 0; i < xs.length - 1; i++) links.push([nodes[i][j], nodes[i + 1][j], kindAt(false, j)]);
for (let i = 0; i < xs.length; i++) for (let j = 0; j < ys.length - 1; j++) links.push([nodes[i][j], nodes[i][j + 1], kindAt(true, i)]);
for (const [a, b] of links) {
degree.set(a, (degree.get(a) ?? 0) + 1);
degree.set(b, (degree.get(b) ?? 0) + 1);
}
for (const [a, b, kind] of links) {
const interior = Math.min(a.x, b.x) > xs[0] && Math.max(a.x, b.x) < xs[xs.length - 1] && Math.min(a.y, b.y) > ys[0] && Math.max(a.y, b.y) < ys[ys.length - 1];
if (skip && interior && rand() < skip && degree.get(a)! > 3 && degree.get(b)! > 3) {
degree.set(a, degree.get(a)! - 1);
degree.set(b, degree.get(b)! - 1);
continue;
}
this.road(a, b, kind, district);
}
return nodes;
}
private buildNetwork(): void {
// Interstate 90 runs west to east across the whole map.
const i90 = smoothCurve(
[
{ x: 30, y: 1900 },
{ x: 600, y: 1880 },
{ x: 1300, y: 1740 },
{ x: 2000, y: 1620 },
{ x: 2700, y: 1600 },
{ x: 3400, y: 1640 },
{ x: 4100, y: 1560 },
{ x: 4600, y: 1490 },
{ x: 4870, y: 1470 },
],
6,
);
this.freewayCenter = i90;
// Route 9: a divided expressway running north-south at ground level.
const r9 = smoothCurve(
[
{ x: 1500, y: 10 },
{ x: 1520, y: 600 },
{ x: 1600, y: 1300 },
{ x: 1620, y: 1760 },
{ x: 1600, y: 2300 },
{ x: 1560, y: 3000 },
{ x: 1520, y: 3700 },
{ x: 1500, y: 4260 },
{ x: 1470, y: 4800 },
{ x: 1560, y: 5300 },
{ x: 1520, y: 6000 },
{ x: 1450, y: 6790 },
{ x: 1520, y: 7600 },
{ x: 1460, y: 8400 },
{ x: 1500, y: 9190 },
],
8,
);
const r9Len = makePath(r9).len;
const r9Cross = crossingS(r9, i90);
const r9Ys = [350, 900, 2130, 2390, 2650, 2920, 2720, 3080, 3440, 3800, 4700, 5220, 5460, 6200, 7000, 7700, 8400];
const r9Node = this.chain(r9, [0, r9Cross - 175, r9Cross + 175, ...r9Ys.map((y) => sAtY(r9, y)), r9Len], 'expressway', 'highway');
const r9At = (y: number) => r9Node(sAtY(r9, y));
// Downtown: a dense grid with three avenues each way.
const dx = [1950, 2080, 2210, 2350, 2480, 2600, 2740, 2880, 3010, 3150];
const dy = [2000, 2130, 2260, 2390, 2520, 2650, 2790, 2920, 3050];
const down = this.grid(dx, dy, 'downtown', (_vertical, i) => ([2, 5, 8].includes(i) ? 'avenue' : 'street'));
for (const j of [1, 3, 5, 7]) this.road(r9At(dy[j]), down[0][j], j === 5 ? 'avenue' : 'street', 'downtown');
const southAve = smoothCurve([{ x: dx[2], y: dy[8] }, { x: 2160, y: 3260 }, { x: 1950, y: 3420 }, r9At(3440)], 8);
this.road(down[2][8], r9At(3440), 'avenue', 'downtown', southAve);
// Grand Avenue runs north from downtown, under I-90, into uptown.
const grand = smoothCurve([{ x: dx[5], y: dy[0] }, { x: 2605, y: 1600 }, { x: 2640, y: 1250 }], 8);
const gLen = makePath(grand).len;
const gCross = crossingS(grand, i90);
const uptownGate = this.node(2640, 1250, 'uptown');
const grandNode = this.chain(grand, [0, gCross - 175, gCross + 175, gLen], 'avenue', 'highway', [
[0, down[5][0]],
[gLen, uptownGate],
]);
// Uptown: a curving boulevard with cul-de-sac drives off it.
const boulevard = smoothCurve(
[
{ x: 2640, y: 1250 },
{ x: 2760, y: 980 },
{ x: 3050, y: 760 },
{ x: 3450, y: 640 },
{ x: 3850, y: 700 },
{ x: 4150, y: 900 },
{ x: 4200, y: 1100 },
{ x: 4070, y: 1250 },
],
8,
);
const bPath = makePath(boulevard);
const branchS: number[] = [];
for (let s = 230; s < bPath.len - 200; s += range(200, 260)) branchS.push(s);
const harborTop = this.node(4070, 1250, 'uptown');
const bNode = this.chain(boulevard, [0, ...branchS, bPath.len], 'boulevard', 'uptown', [
[0, uptownGate],
[bPath.len, harborTop],
]);
let side = 1;
for (const s of branchS) {
const n = bNode(s);
const i = nearestIndex(boulevard, n);
const t = tangentAt(boulevard, i);
const nrm = { x: -t.y * side, y: t.x * side };
side = -side;
const len = range(200, 320);
const bend = range(-0.5, 0.5);
const end = { x: n.x + nrm.x * len + t.x * len * bend * 0.6, y: n.y + nrm.y * len + t.y * len * bend * 0.6 };
if (end.y < 140 || end.y > 1330 || end.x < 2560 || end.x > 4600) continue;
if (Math.min(...i90.filter((_, k) => k % 6 === 0).map((p) => Math.hypot(p.x - end.x, p.y - end.y))) < 260) continue;
const curve = smoothCurve([n, { x: n.x + nrm.x * len * 0.45 + t.x * len * bend * 0.2, y: n.y + nrm.y * len * 0.45 + t.y * len * bend * 0.2 }, end], 8);
this.road(n, this.node(end.x, end.y, 'uptown'), 'drive', 'uptown', curve);
}
// The port: wide industrial streets on a coarse grid.
const px = [3700, 4000, 4300, 4600];
const py = [3050, 3350, 3650, 3950];
const port = this.grid(px, py, 'port', () => 'industrial');
// Harbor Road: from the port north, under I-90, to uptown.
const harbor = smoothCurve([{ x: 4000, y: 3050 }, { x: 4010, y: 2300 }, { x: 4060, y: 1560 }, { x: 4070, y: 1250 }], 8);
const hLen = makePath(harbor).len;
const hCross = crossingS(harbor, i90);
const hRow = sAtY(harbor, dy[5]);
const harborNode = this.chain(harbor, [0, hRow, hCross - 175, hCross + 175, hLen], 'avenue', 'highway', [
[0, port[1][0]],
[hLen, harborTop],
]);
this.road(down[9][5], harborNode(hRow), 'avenue', 'downtown');
this.road(down[9][8], port[0][0], 'avenue', 'downtown');
// The hood: a tight, irregular grid of narrow streets.
const hx = [350, 470, 590, 710, 830, 950, 1070, 1190, 1310];
const hy = [2600, 2720, 2840, 2960, 3080, 3200, 3320, 3440, 3560, 3680, 3800];
const hood = this.grid(hx, hy, 'hood', (vertical, i) => ((vertical && i === 4) || (!vertical && i === 5) ? 'street' : 'drive'), 0.14);
for (const j of [1, 4, 7, 10]) this.road(hood[8][j], r9At(hy[j]), 'street', 'hood');
// Country roads: winding two-lane blacktop through the forest and the farms.
const country = (ctrl: Vec[], a: RNode, b: RNode, stops: number[] = []) => {
const curve = smoothCurve([{ x: a.x, y: a.y }, ...ctrl, { x: b.x, y: b.y }], 8);
this.countryCurves.push(curve);
const len = makePath(curve).len;
return this.chain(curve, [0, ...stops.map((f) => f * len), len], 'country', 'country', [
[0, a],
[len, b],
]);
};
// Old Mill Road: from Route 9 west through the woods, round the lake and down to the hood.
const mill = country(
[
{ x: 1250, y: 330 },
{ x: 980, y: 260 },
{ x: 640, y: 420 },
{ x: 300, y: 360 },
{ x: 170, y: 760 },
{ x: 260, y: 1180 },
{ x: 170, y: 1600 },
{ x: 330, y: 2100 },
{ x: 250, y: 2400 },
],
r9At(350),
hood[0][0],
[0.35],
);
// Ridge Road: from Route 9 up over the hills to the uptown gate.
country(
[
{ x: 1850, y: 830 },
{ x: 2080, y: 1060 },
{ x: 2350, y: 1120 },
],
r9At(900),
uptownGate,
);
// Farm Road: across the fields from Route 9 to the port.
const farm = country(
[
{ x: 1800, y: 3660 },
{ x: 2400, y: 3700 },
{ x: 3000, y: 3620 },
{ x: 3450, y: 3820 },
],
r9At(3800),
port[0][3],
[0.45],
);
// A lane down to the lake, and a farm lane south off Farm Road.
const millMid = mill(makePath(this.countryCurves[0]).len * 0.35);
this.road(millMid, this.node(720, 560, 'country'), 'country', 'country', smoothCurve([millMid, { x: 760, y: 500 }, { x: 720, y: 560 }], 8));
const farmMid = farm(makePath(this.countryCurves[2]).len * 0.45);
const orchardJct = this.node(farmMid.x + 40, 4700, 'country');
this.road(farmMid, orchardJct, 'country', 'country', smoothCurve([farmMid, { x: farmMid.x + 30, y: 3950 }, { x: farmMid.x + 50, y: 4350 }, orchardJct], 8));
// Millbrook: a little village of cottages and shops on Route 9.
const vx = [1700, 1820, 1940, 2060, 2180, 2300, 2420];
const vy = [5100, 5220, 5340, 5460, 5580];
const village = this.grid(vx, vy, 'village', (vertical, i) => ((vertical && i === 3) || (!vertical && i === 2) ? 'avenue' : 'street'), 0.1);
for (const j of [1, 3]) this.road(r9At(vy[j]), village[0][j], 'street', 'village');
// Orchard Road: from Route 9 through the orchards, round Mirror Lake to Millbrook.
country([{ x: 2000, y: 4640 }], r9At(4700), orchardJct);
country(
[
{ x: 3300, y: 4800 },
{ x: 4000, y: 4700 },
{ x: 4600, y: 4950 },
{ x: 4650, y: 5500 },
{ x: 4300, y: 5900 },
{ x: 3600, y: 6050 },
{ x: 2900, y: 5700 },
],
orchardJct,
village[6][2],
);
// Quarry Road and Pine Hollow Road.
country([{ x: 2200, y: 5900 }, { x: 2600, y: 6200 }], village[3][4], this.node(2860, 6330, 'country'));
country(
[
{ x: 380, y: 4300 },
{ x: 250, y: 4900 },
{ x: 420, y: 5500 },
{ x: 800, y: 6100 },
{ x: 1200, y: 6250 },
],
hood[0][10],
r9At(6200),
);
// The industrial park: factories, tank farms, rail and scrap on a big grid.
const ix = [3200, 3550, 3900, 4250, 4600, 4950];
const iy = [7000, 7350, 7700, 8050, 8400, 8750];
const ind = this.grid(ix, iy, 'industrial', () => 'industrial', 0.12);
// Route 12 links Route 9 to the park; Factory Road comes down from Millbrook.
this.road(r9At(7700), ind[0][2], 'expressway', 'highway');
country([{ x: 2700, y: 5900 }, { x: 2600, y: 6600 }, { x: 3100, y: 6880 }], village[6][4], ind[1][0]);
// County Road 7: a long loop through the south-western farms and woods.
country(
[
{ x: 1000, y: 8550 },
{ x: 520, y: 8350 },
{ x: 300, y: 7750 },
{ x: 560, y: 7150 },
],
r9At(8400),
r9At(7000),
);
// The country beyond: Route 9 runs on south, and the new districts hang off it.
expansionNetwork(this.builder(), { r9End: r9Node(r9Len), port: port[3][3], industrial: ind[5][2] });
this.buildFreeway(
i90,
[
{ north: r9Node(r9Cross - 175), south: r9Node(r9Cross + 175) },
{ north: grandNode(gCross + 175), south: grandNode(gCross - 175) },
{ north: harborNode(hCross + 175), south: harborNode(hCross - 175) },
],
port[0][0],
);
}
// --- The interstate: elevated, three lanes each way, diamond interchanges and a spur.
private buildFreeway(center: Vec[], diamonds: { north: RNode; south: RNode }[], spurEnd: RNode): void {
const n = center.length;
const offs = [16, 32, 48];
const westCenter = [...center].reverse();
const eb = offs.map((o) => offsetPoints(center, o));
const wb = offs.map((o) => offsetPoints(westCenter, o));
const D = 50;
type Cut = { idx: number; off?: Road; on?: Road };
const ebCuts: Cut[] = [];
const wbCuts: Cut[] = [];
const ramps: { pts: Vec[]; off: boolean }[] = [];
const gaps: [boolean[], boolean[]] = [new Array<boolean>(n).fill(false), new Array<boolean>(n).fill(false)];
const rampRoad = (pts: Vec[], node: RNode, off: boolean): Road => {
ramps.push({ pts, off });
return this.road(off ? null : node, off ? node : null, 'ramp', 'highway', pts, off ? [1, 0, 0.5] : [0, 1, 0.5]);
};
const curveTo = (p0: Vec, t0: Vec, p3: Vec, t3: Vec) => {
const k = Math.hypot(p3.x - p0.x, p3.y - p0.y) * 0.45;
return cubicPoints(p0, { x: p0.x + t0.x * k, y: p0.y + t0.y * k }, { x: p3.x - t3.x * k, y: p3.y - t3.y * k }, p3, 28);
};
const mark = (gap: boolean[], from: number, to: number) => {
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) gap[clamp(i, 0, n - 1)] = true;
};
for (const dm of diamonds) {
const cross = nearestIndex(center, { x: (dm.north.x + dm.south.x) / 2, y: (dm.north.y + dm.south.y) / 2 });
const tan = tangentAt(center, cross);
const back = { x: -tan.x, y: -tan.y };
const ebOff = cross - D;
const ebOn = cross + D;
ebCuts.push(
{ idx: ebOff, off: rampRoad(curveTo(eb[2][ebOff], tangentAt(eb[2], ebOff), dm.south, tan), dm.south, true) },
{ idx: ebOn, on: rampRoad(curveTo(dm.south, tan, eb[2][ebOn], tangentAt(eb[2], ebOn)), dm.south, false) },
);
const wCross = n - 1 - cross;
const wbOff = wCross - D;
const wbOn = wCross + D;
wbCuts.push(
{ idx: wbOff, off: rampRoad(curveTo(wb[2][wbOff], tangentAt(wb[2], wbOff), dm.north, back), dm.north, true) },
{ idx: wbOn, on: rampRoad(curveTo(dm.north, back, wb[2][wbOn], tangentAt(wb[2], wbOn)), dm.north, false) },
);
mark(gaps[0], ebOff - 3, ebOff + D - 8);
mark(gaps[0], ebOn + 3, ebOn - D + 8);
mark(gaps[1], n - 1 - (wbOff - 3), n - 1 - (wbOff + D - 8));
mark(gaps[1], n - 1 - (wbOn + 3), n - 1 - (wbOn - D + 8));
}
// I-190 spur: leaves eastbound I-90 and runs south-east to the port.
const spurDiv = nearestIndex(center, { x: 3020, y: 1620 });
const spurMerge = nearestIndex(center, { x: 3480, y: 1640 });
const spurBase = smoothCurve(
[
{ x: 3260, y: 1800 },
{ x: 3360, y: 2050 },
{ x: 3440, y: 2400 },
{ x: 3560, y: 2760 },
{ x: spurEnd.x, y: spurEnd.y - 60 },
{ x: spurEnd.x, y: spurEnd.y },
],
8,
);
const sbCenter = offsetPoints(spurBase, 18);
const nbCenter = offsetPoints([...spurBase].reverse(), 18);
const divTan = tangentAt(eb[2], spurDiv);
const divStart = { x: eb[2][spurDiv].x - divTan.y * 8, y: eb[2][spurDiv].y + divTan.x * 8 };
const sbPts = [...curveTo(divStart, divTan, sbCenter[0], tangentAt(sbCenter, 0)), ...sbCenter.slice(1)];
const mTan = tangentAt(eb[2], spurMerge);
const mergeEnd = { x: eb[2][spurMerge].x - mTan.y * 8, y: eb[2][spurMerge].y + mTan.x * 8 };
const nbPts = [...nbCenter, ...curveTo(nbCenter[nbCenter.length - 1], tangentAt(nbCenter, nbCenter.length - 1), mergeEnd, mTan).slice(1)];
const sb = this.road(null, spurEnd, 'spur', 'highway', sbPts, [1, 0, 0.86]);
const nb = this.road(spurEnd, null, 'spur', 'highway', nbPts, [0, 1, 0.14]);
ebCuts.push({ idx: spurDiv, off: sb }, { idx: spurMerge, on: nb });
mark(gaps[0], spurDiv - 3, spurDiv + 40);
mark(gaps[0], spurMerge + 3, spurMerge - 40);
// Lanes, split at every ramp so parallel lanes share segments.
const carriage = (lanePts: Vec[][], cuts: Cut[]) => {
const idxs = [...new Set([0, ...cuts.map((c) => c.idx), n - 1])].sort((a, b) => a - b);
const segs: number[][] = [];
for (let k = 0; k < idxs.length - 1; k++) {
const ids = lanePts.map((pts) => this.addLane(pts.slice(idxs[k], idxs[k + 1] + 1), 'highway', 175, 'highway', false, [1, 1, 0]));
ids.forEach((id, i) => {
if (i > 0) this.lanes[id].left = ids[i - 1];
if (i < ids.length - 1) this.lanes[id].right = ids[i + 1];
});
segs.push(ids);
}
for (let k = 0; k < segs.length - 1; k++) {
segs[k].forEach((id, i) => this.lanes[id].next.push(segs[k + 1][i]));
for (const c of cuts) {
if (c.idx !== idxs[k + 1]) continue;
if (c.off) this.pendingOff.push({ from: segs[k][2], road: c.off });
if (c.on) this.pendingOn.push({ to: segs[k + 1][2], road: c.on });
}
}
};
carriage(eb, ebCuts);
carriage(wb, wbCuts);
// Collision: barriers on both edges and the median, with gaps at ramps.
const edgeRuns = (pts: Vec[], gap: boolean[], reverse: boolean) => {
const runs: Vec[][] = [];
let run: Vec[] = [];
for (let i = 0; i < n; i++) {
if (!gap[reverse ? n - 1 - i : i]) run.push(pts[i]);
else if (run.length) {
runs.push(run);
run = [];
}
}
if (run.length) runs.push(run);
return runs.filter((r) => r.length > 1);
};
const barrierRuns = [...edgeRuns(offsetPoints(center, 69), gaps[0], false), ...edgeRuns(offsetPoints(westCenter, 69), gaps[1], true), center];
for (const run of barrierRuns) this.solidLine(run, 2, 1);
for (const end of [0, n - 1]) {
const t = tangentAt(center, end);
this.solidSeg({ x: center[end].x + t.y * 70, y: center[end].y - t.x * 70 }, { x: center[end].x - t.y * 70, y: center[end].y + t.x * 70 }, 3, 1);
}
this.stampPath(this.deckMask, center, 70, 1);
this.stampPath(this.occ, center, 92, OCC_DECK, true);
const [x0, y0, x1, y1] = bboxOf(center, 90);
this.add(L_MARK, x0, y0, x1 + 20, y1 + 30, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, center.map((p) => ({ x: p.x + 12, y: p.y + 18 })), 'rgba(0, 0, 0, 0.3)', 140);
});
this.add(L_DECK, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, center, '#8d8a82', 144);
strokeLine(ctx, center, '#36373e', 136);
strokeLine(ctx, offsetPoints(center, 6), '#e8b83a', 1);
strokeLine(ctx, offsetPoints(center, -6), '#e8b83a', 1);
for (const c of [center, westCenter]) {
strokeLine(ctx, offsetPoints(c, 24), '#e6e2d6', 1, [9, 11]);
strokeLine(ctx, offsetPoints(c, 40), '#e6e2d6', 1, [9, 11]);
strokeLine(ctx, offsetPoints(c, 57), '#e6e2d6', 1);
}
});
this.add(L_DECKTOP, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
for (const run of barrierRuns) {
strokeLine(ctx, run, '#7d7a72', 4.2);
strokeLine(ctx, run, '#d4d0c6', 2.8);
strokeLine(ctx, run, 'rgba(255, 255, 255, 0.6)', 0.6);
}
ctx.fillStyle = '#5c5f66';
for (let i = 20; i < n; i += 40) {
ctx.beginPath();
ctx.arc(center[i].x, center[i].y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
});
// Exit signs on gantries just before each off-ramp.
for (const c of [...ebCuts, ...wbCuts]) {
if (!c.off || c.off.name !== 'ramp') continue;
const west = wbCuts.includes(c);
const idx = west ? n - 1 - (c.idx - 16) : c.idx - 16;
if (idx < 0 || idx >= n) continue;
const t = tangentAt(center, idx);
const p = center[idx];
const nx = -t.y * (west ? -1 : 1);
const ny = t.x * (west ? -1 : 1);
const a = { x: p.x, y: p.y };
const b = { x: p.x + nx * 70, y: p.y + ny * 70 };
const m = { x: p.x + nx * 50, y: p.y + ny * 50 };
const ang = Math.atan2(ny, nx);
this.add(L_DECKTOP, Math.min(a.x, b.x) - 20, Math.min(a.y, b.y) - 20, Math.max(a.x, b.x) + 20, Math.max(a.y, b.y) + 20, (ctx) => {
strokeLine(ctx, [{ x: a.x + 3, y: a.y + 4 }, { x: b.x + 3, y: b.y + 4 }], 'rgba(0, 0, 0, 0.25)', 5);
strokeLine(ctx, [a, b], '#8d9096', 2);
ctx.save();
ctx.translate(m.x, m.y);
ctx.rotate(ang);
ctx.fillStyle = '#1f6a46';
ctx.strokeStyle = '#f4ecd8';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.roundRect(-11, -5, 22, 10, 1.5);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#f4ecd8';
ctx.font = '700 4px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('EXIT', 0, 0.3);
ctx.restore();
});
}
for (const r of ramps) this.paintRamp(r.pts, r.off);
this.paintSpur(sbPts, 0.86, true);
this.paintSpur(nbPts, 0.14, false);
}
private placePiers(): void {
const center = this.freewayCenter;
for (let i = 10; i < center.length; i += 22) {
const p = center[i];
const idx = this.cellIndex(p.x, p.y);
if (idx < 0 || this.surface[idx] === SURF_ROAD || this.surface[idx] === SURF_WATER || this.surface[idx] === SURF_SIDEWALK) continue;
const t = tangentAt(center, i);
const corners = boxCorners(p.x, p.y, Math.atan2(t.y, t.x) + Math.PI / 2, 40, 4);
this.add(L_OBJECT, p.x - 44, p.y - 44, p.x + 44, p.y + 44, (ctx) => fillPoly(ctx, corners, '#9e9a90'));
this.solidPoly(corners, 0);
}
}
private paintRamp(pts: Vec[], off: boolean): void {
const len = makePath(pts).len;
const high = off ? slicePath(pts, 0, len * 0.56) : slicePath(pts, len * 0.44, len);
const low = off ? slicePath(pts, len * 0.48, len) : slicePath(pts, 0, len * 0.52);
this.stampPath(this.deckMask, high, 11, 1);
this.stampPath(this.rampMask, slicePath(pts, len * 0.42, len * 0.58), 12, 1);
this.stampPath(this.surface, low, 11, SURF_ROAD);
this.stampPath(this.occ, pts, 20, OCC_ROAD, true);
const [x0, y0, x1, y1] = bboxOf(pts, 30);
this.add(L_ROAD, x0, y0, x1, y1, (ctx) => {
ctx.lineCap = 'round';
strokeLine(ctx, low, '#37383f', 22);
ctx.lineCap = 'butt';
strokeLine(ctx, offsetPoints(low, 9.5), '#e6e2d6', 1);
strokeLine(ctx, offsetPoints(low, -9.5), '#e6e2d6', 1);
});
this.add(L_MARK, x0, y0, x1 + 20, y1 + 30, (ctx) => {
strokeLine(ctx, high.map((p) => ({ x: p.x + 8, y: p.y + 12 })), 'rgba(0, 0, 0, 0.25)', 24);
});
const outer = offsetPoints(high, 12);
const innerFull = offsetPoints(high, -12);
const inner = off ? innerFull.slice(Math.floor(innerFull.length * 0.55)) : innerFull.slice(0, Math.ceil(innerFull.length * 0.45));
const outerRun = off ? outer.slice(Math.floor(outer.length * 0.12)) : outer.slice(0, Math.ceil(outer.length * 0.88));
this.solidLine(outerRun, 1.5, 1);
if (inner.length > 1) this.solidLine(inner, 1.5, 1);
this.add(L_DECK, x0, y0, x1, y1, (ctx) => {
ctx.lineCap = 'round';
strokeLine(ctx, high, '#8d8a82', 26);
strokeLine(ctx, high, '#36373e', 21);
ctx.lineCap = 'butt';
});
this.add(L_DECKTOP, x0, y0, x1, y1, (ctx) => {
for (const b of [outerRun, inner]) {
if (b.length < 2) continue;
strokeLine(ctx, b, '#7d7a72', 3.4);
strokeLine(ctx, b, '#d4d0c6', 2.2);
}
});
}
private paintSpur(pts: Vec[], breakFrac: number, highFirst: boolean): void {
const len = makePath(pts).len;
const high = highFirst ? slicePath(pts, 0, len * (breakFrac + 0.04)) : slicePath(pts, len * (breakFrac - 0.04), len);
const low = highFirst ? slicePath(pts, len * (breakFrac - 0.02), len) : slicePath(pts, 0, len * (breakFrac + 0.02));
this.stampPath(this.deckMask, high, 18, 1);
this.stampPath(this.rampMask, slicePath(pts, len * (breakFrac - 0.03), len * (breakFrac + 0.03)), 18, 1);
this.stampPath(this.surface, low, 18, SURF_ROAD);
this.stampPath(this.occ, pts, 34, OCC_ROAD | OCC_DECK, true);
const [x0, y0, x1, y1] = bboxOf(pts, 40);
this.add(L_ROAD, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, low, '#37383f', 36);
strokeLine(ctx, low, '#e6e2d6', 1, [9, 11]);
});
this.add(L_MARK, x0, y0, x1 + 20, y1 + 30, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, high.map((p) => ({ x: p.x + 10, y: p.y + 15 })), 'rgba(0, 0, 0, 0.28)', 40);
});
const outer = offsetPoints(high, 19);
const trim = Math.floor(outer.length * 0.12);
const outerRun = highFirst ? outer.slice(trim) : outer.slice(0, outer.length - trim);
this.solidLine(outerRun, 1.5, 1);
this.add(L_DECK, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, high, '#8d8a82', 40);
strokeLine(ctx, high, '#36373e', 34);
strokeLine(ctx, high, '#e6e2d6', 1, [9, 11]);
});
this.add(L_DECKTOP, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, outerRun, '#7d7a72', 3.4);
strokeLine(ctx, outerRun, '#d4d0c6', 2.2);
});
}
// --- Turning topology into lanes.
private finalizeRoads(): void {
const armsAt = new Map<RNode, { road: Road; atStart: boolean }[]>();
for (const r of this.roads) {
if (r.a) armsAt.set(r.a, [...(armsAt.get(r.a) ?? []), { road: r, atStart: true }]);
if (r.b) armsAt.set(r.b, [...(armsAt.get(r.b) ?? []), { road: r, atStart: false }]);
}
for (const node of this.nodes) {
const arms = armsAt.get(node) ?? [];
const angles = arms.map(({ road, atStart }) => {
const pts = road.ctrl;
const p = atStart ? pts[0] : pts[pts.length - 1];
const q = atStart ? pts[Math.min(2, pts.length - 1)] : pts[Math.max(0, pts.length - 3)];
return Math.atan2(q.y - p.y, q.x - p.x);
});
if (arms.length === 2 && Math.abs(angleDiff(angles[0], angles[1])) > 2.7 && arms[0].road.name === arms[1].road.name) {
node.joint = true;
node.radius = 0;
} else if (arms.length === 1) {
node.radius = 26;
} else {
node.radius = Math.max(0, ...arms.map((a) => a.road.halfW)) + 4;
}
}
for (const r of this.roads) {
const len = makePath(r.ctrl).len;
const s0 = r.a ? r.a.radius : 0;
const s1 = len - (r.b ? r.b.radius : 0);
r.pts = slicePath(r.ctrl, s0, Math.max(s0 + 1, s1));
const spec = r.spec;
const offsets = spec.oneWay
? Array.from({ length: spec.lanes }, (_, i) => (i - (spec.lanes - 1) / 2) * spec.laneW)
: Array.from({ length: spec.lanes }, (_, i) => spec.median / 2 + spec.laneW * (i + 0.5));
const kind: LaneKind = r.name === 'ramp' ? 'ramp' : r.name === 'spur' ? 'highway' : 'street';
r.fwd = offsets.map((o) => this.addLane(offsetPoints(r.pts, o), kind, spec.speed, r.district, false, [r.layer0, r.layer1, r.breakFrac]));
if (!spec.oneWay) r.back = offsets.map((o) => this.addLane(offsetPoints([...r.pts].reverse(), o), kind, spec.speed, r.district));
for (const group of [r.fwd, r.back]) {
group.forEach((id, i) => {
if (i > 0) this.lanes[id].left = group[i - 1];
if (i < group.length - 1) this.lanes[id].right = group[i + 1];
});
}
const p = r.pts;
const m = p.length - 1;
if (r.a) r.a.arms.push({ road: r, angle: Math.atan2(p[1].y - p[0].y, p[1].x - p[0].x), out: r.fwd, in: r.back });
if (r.b) r.b.arms.push({ road: r, angle: Math.atan2(p[m - 1].y - p[m].y, p[m - 1].x - p[m].x), out: r.back, in: r.fwd });
// Delivery spots on the curb lane of ordinary streets.
if (r.district !== 'highway' && !spec.oneWay && r.name !== 'expressway' && makePath(p).len > 70) {
for (const group of [r.fwd, r.back]) {
const lane = this.lanes[group[group.length - 1]];
const a = pointAt(lane, lane.len / 2);
const b = pointAt(lane, lane.len / 2 + 2);
const spot = { x: a.x, y: a.y, angle: Math.atan2(b.y - a.y, b.x - a.x), district: r.district };
this.dropSpots.push(spot);
if (['downtown', 'industrial', 'port', 'country', 'docks', 'factory', 'rail', 'rural', 'commercial'].includes(r.district)) this.pickupSpots.push(spot);
}
}
}
for (const { from, road } of this.pendingOff) this.lanes[from].next.push(...road.fwd);
for (const { to, road } of this.pendingOn) for (const id of road.fwd) this.lanes[id].next = [to];
}
/** Traffic lights at every town junction where three or more streets meet. */
private placeSignals(): void {
for (const node of this.nodes) {
if (node.joint || node.arms.length < 3 || node.district === 'country' || node.district === 'rural' || node.district === 'highway') continue;
const ins = node.arms.flatMap((arm) => arm.in.map((id) => ({ arm, lane: this.lanes[id] })));
if (!ins.length || ins.some(({ lane }) => lane.kind !== 'street' || lane.layer1 !== 0)) continue;
const ref = node.arms[0].angle;
const signal: Signal = { x: node.x, y: node.y, offset: rand() * 30, approaches: [] };
for (const { arm, lane } of ins) {
const end = lane.pts[lane.pts.length - 1];
const prev = lane.pts[Math.max(0, lane.pts.length - 2)];
const a = Math.atan2(end.y - prev.y, end.x - prev.x);
const phase = Math.abs(Math.cos(arm.angle - ref)) > 0.7 ? 0 : 1;
// The signal head stands at the curb on the driver's right, just short of the junction.
signal.approaches.push({ lane: lane.id, phase, x: end.x - Math.sin(a) * 6 - Math.cos(a) * 2, y: end.y + Math.cos(a) * 6 - Math.sin(a) * 2, angle: a });
this.laneSignal.set(lane.id, { signal, phase });
}
this.signals.push(signal);
}
}
private buildConnectors(): void {
for (const node of this.nodes) {
if (node.joint) {
const [a, b] = node.arms;
a.in.forEach((id, i) => this.lanes[id].next.push(b.out[Math.min(i, b.out.length - 1)]));
b.in.forEach((id, i) => this.lanes[id].next.push(a.out[Math.min(i, a.out.length - 1)]));
continue;
}
for (const armA of node.arms) {
if (!armA.in.length) continue;
const hin = armA.angle + Math.PI;
for (const armB of node.arms) {
if (!armB.out.length) continue;
const d = angleDiff(armB.angle, hin);
const uTurn = armB === armA || Math.abs(d) > 2.6;
if (uTurn && node.arms.length > 1) continue;
const kind = uTurn ? 'uturn' : Math.abs(d) < 0.6 ? 'straight' : d > 0 ? 'right' : 'left';
let pairs: [number, number][];
if (kind === 'straight') {
pairs = armA.in.length === 1 ? armB.out.map((_, o) => [0, o] as [number, number]) : armA.in.map((_, i) => [i, Math.min(i, armB.out.length - 1)] as [number, number]);
} else if (kind === 'right') {
pairs = [[armA.in.length - 1, armB.out.length - 1]];
} else {
pairs = [[0, 0]];
}
for (const [i, o] of pairs) this.connect(this.lanes[armA.in[i]], this.lanes[armB.out[o]], kind, node);
}
armA.in.forEach((id, i) => {
const lane = this.lanes[id];
if (lane.next.length) return;
for (const armB of node.arms) {
if (armB === armA || !armB.out.length) continue;
this.connect(lane, this.lanes[armB.out[Math.min(i, armB.out.length - 1)]], 'left', node);
}
});
}
}
}
private connect(inLane: Lane, outLane: Lane, kind: 'straight' | 'right' | 'left' | 'uturn', node: RNode): void {
const p0 = inLane.pts[inLane.pts.length - 1];
const p2 = outLane.pts[0];
const a = inLane.pts[Math.max(0, inLane.pts.length - 2)];
const b = outLane.pts[Math.min(1, outLane.pts.length - 1)];
const h0 = Math.atan2(p0.y - a.y, p0.x - a.x);
const h1 = Math.atan2(b.y - p2.y, b.x - p2.x);
const dist = Math.hypot(p2.x - p0.x, p2.y - p0.y);
const k = kind === 'uturn' ? 34 : dist * 0.45;
const pts =
kind === 'straight' && dist < 1
? [p0, p2]
: cubicPoints(p0, { x: p0.x + Math.cos(h0) * k, y: p0.y + Math.sin(h0) * k }, { x: p2.x - Math.cos(h1) * k, y: p2.y - Math.sin(h1) * k }, p2, 12);
const speed = kind === 'straight' ? inLane.speed : kind === 'uturn' ? 22 : 34;
const id = this.addLane(pts, 'connector', speed, node.district, kind !== 'straight');
this.lanes[id].next = [outLane.id];
inLane.next.push(id);
}
// --- Painting ground roads.
private paintRoads(): void {
const ground = this.roads.filter((r) => r.name !== 'ramp' && r.name !== 'spur');
for (const r of ground) {
const st = STYLES[r.district];
const w = r.halfW;
if (r.spec.sidewalk) this.stampPath(this.surface, r.ctrl, w + r.spec.sidewalk, SURF_SIDEWALK);
this.stampPath(this.occ, r.ctrl, w + r.spec.sidewalk + 2, OCC_ROAD, true);
const [x0, y0, x1, y1] = bboxOf(r.ctrl, w + r.spec.sidewalk + 4);
this.add(L_WALK, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
if (r.spec.sidewalk) {
strokeLine(ctx, r.ctrl, st.sidewalk, (w + r.spec.sidewalk) * 2);
strokeLine(ctx, r.ctrl, st.curb, w * 2 + 2.4);
} else {
strokeLine(ctx, r.ctrl, r.name === 'country' ? '#a8996f' : '#7d8a5a', w * 2 + 6);
}
});
this.add(L_ROAD, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
strokeLine(ctx, r.ctrl, st.asphalt, w * 2);
});
}
for (const r of ground) this.stampPath(this.surface, r.ctrl, r.halfW, SURF_ROAD);
// Street lamps along town roads and Route 9.
for (const r of ground) {
if (r.district === 'country' || r.district === 'rural' || (r.district === 'highway' && r.name !== 'expressway')) continue;
const path = makePath(r.ctrl);
const off = r.halfW + Math.max(2, r.spec.sidewalk * 0.6);
const poles: Vec[] = [];
for (let s = 30; s < path.len - 20; s += 75) {
const p = pointAt(path, s);
const q = pointAt(path, s + 1);
const l = Math.hypot(q.x - p.x, q.y - p.y) || 1;
const side = (Math.floor(s / 75) % 2) * 2 - 1;
const pole = { x: p.x - ((q.y - p.y) / l) * off * side, y: p.y + ((q.x - p.x) / l) * off * side };
poles.push(pole);
this.lights.push(pole);
}
if (!poles.length) continue;
const [x0, y0, x1, y1] = bboxOf(poles, 3);
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
ctx.fillStyle = '#5c5f66';
for (const p of poles) {
ctx.beginPath();
ctx.arc(p.x, p.y, 1.3, 0, Math.PI * 2);
ctx.fill();
}
});
}
for (const node of this.nodes) {
const roads = ground.filter((r) => r.a === node || r.b === node);
if (!roads.length || node.joint) continue;
const st = STYLES[roads[0].district];
const walk = Math.max(...roads.map((r) => r.spec.sidewalk));
const cul = node.arms.length === 1 || roads.length === 1;
const rr = cul ? 26 : Math.max(...roads.map((r) => r.halfW));
const x = node.x;
const y = node.y;
this.stampCircle(this.surface, x, y, rr + walk, SURF_SIDEWALK);
this.stampCircle(this.surface, x, y, rr, SURF_ROAD);
this.stampCircle(this.occ, x, y, rr + walk + 2, OCC_ROAD, true);
if (walk) {
this.add(L_WALK, x - rr - walk, y - rr - walk, x + rr + walk, y + rr + walk, (ctx) => {
ctx.fillStyle = st.sidewalk;
ctx.beginPath();
ctx.arc(x, y, rr + walk, 0, Math.PI * 2);
ctx.fill();
});
}
this.add(L_ROAD, x - rr, y - rr, x + rr, y + rr, (ctx) => {
ctx.fillStyle = st.asphalt;
ctx.beginPath();
ctx.arc(x, y, rr, 0, Math.PI * 2);
ctx.fill();
});
if (cul && roads.length === 1 && roads[0].name === 'drive') this.tree(x, y, 7, 'fancy');
}
}
private paintMarkings(): void {
for (const r of this.roads) {
if (r.name === 'ramp' || r.name === 'spur') continue;
const st = STYLES[r.district];
const spec = r.spec;
const pts = r.pts;
const path = makePath(pts);
const len = path.len;
if (len < 4) continue;
const w = r.halfW;
const [x0, y0, x1, y1] = bboxOf(pts, w + 4);
const potholes = st.worn
? Array.from({ length: Math.floor(len / 90) + randInt(2) }, () => {
const p = pointAt(path, range(10, Math.max(11, len - 10)));
return { x: p.x + range(-w * 0.7, w * 0.7), y: p.y + range(-w * 0.7, w * 0.7), r: range(1.6, 3.6), a: rand() * 3 };
})
: [];
if (spec.medianType === 'grass' && r.name === 'boulevard') {
for (let s = 20; s < len - 20; s += 34) {
const t = pointAt(path, s);
this.tree(t.x, t.y, 6.5, 'fancy');
}
}
const ends = [
{ node: r.a, at: 0 },
{ node: r.b, at: len },
].filter((e) => e.node && !e.node.joint && e.node.arms.length > 1);
this.add(L_MARK, x0, y0, x1, y1, (ctx) => {
ctx.lineJoin = 'round';
if (spec.median) {
strokeLine(ctx, pts, st.curb, spec.median + 1.6);
strokeLine(ctx, pts, '#5f9447', spec.median - 1);
}
ctx.globalAlpha = st.markAlpha;
if (spec.center === 'yellow') {
strokeLine(ctx, offsetPoints(pts, -1.3), '#e8b83a', 0.9);
strokeLine(ctx, offsetPoints(pts, 1.3), '#e8b83a', 0.9);
}
for (let i = 1; i < spec.lanes; i++) {
const o = spec.median / 2 + spec.laneW * i;
strokeLine(ctx, offsetPoints(pts, o), '#e6e2d6', 0.9, [6, 7]);
strokeLine(ctx, offsetPoints(pts, -o), '#e6e2d6', 0.9, [6, 7]);
}
if (r.name === 'expressway') {
strokeLine(ctx, offsetPoints(pts, w - 2), '#e6e2d6', 1);
strokeLine(ctx, offsetPoints(pts, -w + 2), '#e6e2d6', 1);
}
for (const e of ends) {
const p = pointAt(path, e.at === 0 ? 1 : len - 1);
const q = pointAt(path, e.at === 0 ? 4 : len - 4);
const ang = Math.atan2(q.y - p.y, q.x - p.x);
const nx = -Math.sin(ang);
const ny = Math.cos(ang);
const fx = Math.cos(ang);
const fy = Math.sin(ang);
if (spec.crosswalk) {
ctx.fillStyle = '#e6e2d6';
for (let o = -w + 2; o < w - 2; o += 4.6) {
ctx.beginPath();
ctx.moveTo(p.x + nx * o, p.y + ny * o);
ctx.lineTo(p.x + nx * (o + 2.4), p.y + ny * (o + 2.4));
ctx.lineTo(p.x + nx * (o + 2.4) + fx * 7, p.y + ny * (o + 2.4) + fy * 7);
ctx.lineTo(p.x + nx * o + fx * 7, p.y + ny * o + fy * 7);
ctx.fill();
}
}
const sb = spec.crosswalk ? 10 : 2;
const side = e.at === 0 ? -1 : 1;
strokeLine(
ctx,
[
{ x: p.x + fx * sb + nx * side * (spec.median / 2 + 0.5), y: p.y + fy * sb + ny * side * (spec.median / 2 + 0.5) },
{ x: p.x + fx * sb + nx * side * (w - 1), y: p.y + fy * sb + ny * side * (w - 1) },
],
'#e6e2d6',
1.4,
);
}
ctx.globalAlpha = 1;
for (const h of potholes) {
ctx.fillStyle = '#26252a';
ctx.beginPath();
ctx.ellipse(h.x, h.y, h.r, h.r * 0.7, h.a, 0, Math.PI * 2);
ctx.fill();
}
});
}
}
// --- Sidewalk network for pedestrians.
private buildWalks(): void {
const endsAt = new Map<RNode, WalkEnd[]>();
for (const r of this.roads) {
if (!r.spec.sidewalk || r.name === 'ramp' || r.name === 'spur') continue;
const off = r.halfW + r.spec.sidewalk / 2;
const ids: number[] = [];
for (const side of [1, -1]) {
const pts = offsetPoints(r.pts, off * side);
if (pts.length < 2 || makePath(pts).len < 6) continue;
const id = this.walks.length;
const mk = (atStart: boolean): WalkEnd => {
const p = atStart ? pts[0] : pts[pts.length - 1];
return { walk: id, atStart, x: p.x, y: p.y, links: [], cross: null };
};
const walk: Walk = { ...makePath(pts), id, district: r.district, ends: [mk(true), mk(false)] };
this.walks.push(walk);
ids.push(id);
if (r.a) endsAt.set(r.a, [...(endsAt.get(r.a) ?? []), walk.ends[0]]);
if (r.b) endsAt.set(r.b, [...(endsAt.get(r.b) ?? []), walk.ends[1]]);
}
if (ids.length === 2) {
for (const k of [0, 1] as const) {
this.walks[ids[0]].ends[k].cross = { walk: ids[1], atStart: k === 0 };
this.walks[ids[1]].ends[k].cross = { walk: ids[0], atStart: k === 0 };
}
}
}
for (const [node, ends] of endsAt) {
for (const e of ends) {
const reach = node.radius * 1.9 + 24;
const near = ends.filter((o) => o !== e && o.walk !== e.cross?.walk && Math.hypot(o.x - e.x, o.y - e.y) < reach);
near.sort((a, b) => Math.hypot(a.x - e.x, a.y - e.y) - Math.hypot(b.x - e.x, b.y - e.y));
e.links = near.slice(0, 2).map((o) => ({ walk: o.walk, atStart: o.atStart }));
}
}
}
// --- Buildings along every street.
private lotFree(corners: Vec[], cx: number, cy: number, angle: number, hw: number, hd: number): boolean {
if (!corners.every((p) => p.x > 10 && p.y > 10 && p.x < MAP_W - 10 && p.y < MAP_H - 10)) return false;
const c = Math.cos(angle);
const s = Math.sin(angle);
for (let u = -hw; u <= hw + 0.01; u += Math.max(4, hw / 3)) {
for (let v = -hd; v <= hd + 0.01; v += Math.max(4, hd / 3)) {
const i = this.cellIndex(cx + c * u - s * v, cy + s * u + c * v);
if (i < 0 || this.occ[i] !== 0) return false;
}
}
return true;
}
private claim(cx: number, cy: number, angle: number, hw: number, hd: number): void {
const c = Math.cos(angle);
const s = Math.sin(angle);
for (let u = -hw - 2; u <= hw + 2; u += 3) {
for (let v = -hd - 2; v <= hd + 2; v += 3) {
const i = this.cellIndex(cx + c * u - s * v, cy + s * u + c * v);
if (i >= 0) this.occ[i] |= OCC_BUILT;
}
}
}
private placeLots(): void {
const config: Record<District, { f: [number, number]; d: [number, number]; setback: number } | null> = {
downtown: { f: [34, 74], d: [38, 66], setback: 1 },
uptown: { f: [74, 112], d: [64, 92], setback: 14 },
hood: { f: [26, 38], d: [28, 40], setback: 4 },
port: { f: [100, 180], d: [76, 130], setback: 10 },
highway: null,
country: { f: [80, 130], d: [70, 110], setback: 22 },
village: { f: [30, 48], d: [30, 44], setback: 5 },
industrial: { f: [110, 200], d: [90, 150], setback: 12 },
docks: { f: [120, 200], d: [80, 140], setback: 10 },
factory: { f: [140, 240], d: [110, 180], setback: 14 },
rail: { f: [110, 180], d: [70, 110], setback: 10 },
suburb: { f: [52, 74], d: [48, 66], setback: 10 },
commercial: { f: [90, 180], d: [70, 140], setback: 30 },
military: { f: [70, 140], d: [44, 90], setback: 16 },
rural: { f: [80, 130], d: [70, 110], setback: 22 },
town: { f: [30, 48], d: [30, 44], setback: 5 },
};
for (const r of this.roads) {
const cfg = config[r.district];
if (!cfg || r.name === 'ramp' || r.name === 'spur' || r.name === 'expressway') continue;
const path = makePath(r.pts);
for (const side of [1, -1]) {
let s = 4;
while (s < path.len - 8) {
const f = range(cfg.f[0], cfg.f[1]);
const d = range(cfg.d[0], cfg.d[1]);
const mid = s + f / 2;
if (mid > path.len - 4) break;
const p = pointAt(path, mid);
const q1 = pointAt(path, Math.min(path.len, mid + 2));
const q0 = pointAt(path, Math.max(0, mid - 2));
let ang = Math.atan2(q1.y - q0.y, q1.x - q0.x);
if (side < 0) ang += Math.PI;
const nx = -Math.sin(ang);
const ny = Math.cos(ang);
const off = r.halfW + r.spec.sidewalk + cfg.setback + d / 2 + 1;
const cx = p.x + nx * off;
const cy = p.y + ny * off;
const corners = boxCorners(cx, cy, ang, f / 2, d / 2);
// Farmsteads are few and far between; out in the far country fewer still.
if ((r.district === 'country' && rand() < 0.7) || (r.district === 'rural' && rand() < 0.9)) {
s += r.district === 'rural' ? 160 : 60;
continue;
}
if (this.lotFree(corners, cx, cy, ang, f / 2, d / 2)) {
this.claim(cx, cy, ang, f / 2, d / 2);
this.building(r.district, cx, cy, ang, f, d, corners);
s += f + (r.district === 'downtown' ? 3 : r.district === 'hood' ? 2 : r.district === 'commercial' ? 30 : 8);
} else {
s += 8;
}
}
}
}
}
private building(district: District, cx: number, cy: number, angle: number, w: number, d: number, corners: Vec[]): void {
this.structDistrict = district;
this.lookDistrict = district;
this.buildingInner(LOOK[district], cx, cy, angle, w, d, corners);
this.structDistrict = null;
}
/** The real district of the building being laid out (it may borrow another's look). */
private lookDistrict: District = 'downtown';
/**
* A one-off destructible structure (a crane, a ship, a hangar, a rail car): registered
* like a building in `district`, drawn by `draw` at the object layer.
*/
special(district: District, corners: Vec[], draw: (ctx: CanvasRenderingContext2D) => void, opts: { mat?: 'wood' | 'stone'; hp?: number; fuel?: number; building?: boolean; layer?: number } = {}): Structure {
this.structDistrict = district;
this.solidPoly(corners);
this.structDistrict = null;
const st = this.structures[this.structures.length - 1];
if (opts.mat) st.mat = opts.mat;
if (opts.hp) st.hp = st.maxHp = opts.hp;
if (opts.fuel) st.fuel = opts.fuel;
if (opts.building !== undefined) {
st.building = opts.building;
const i = this.buildings.indexOf(st);
if (!opts.building && i >= 0) this.buildings.splice(i, 1);
else if (opts.building && i < 0) this.buildings.push(st);
}
const [x0, y0, x1, y1] = bboxOf(corners, 30);
this.add(opts.layer ?? L_OBJECT, x0, y0, x1, y1, draw);
return st;
}
private buildingInner(district: District, cx: number, cy: number, angle: number, w: number, d: number, corners: Vec[]): void {
const [x0, y0, x1, y1] = bboxOf(corners, 24);
const inLocal = (ctx: CanvasRenderingContext2D, fn: () => void) => {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
fn();
ctx.restore();
};
const shadow = (ctx: CanvasRenderingContext2D, height: number, pts = corners) => {
const dx = Math.min(20, height * 0.8);
const dy = dx * 1.15;
fillPoly(ctx, convexHull([...pts, ...pts.map((p) => ({ x: p.x + dx, y: p.y + dy }))]), 'rgba(0, 0, 0, 0.3)');
};
const sub = (r: { x: number; y: number; w: number; h: number }) =>
boxCorners(
cx + Math.cos(angle) * (r.x + r.w / 2) - Math.sin(angle) * (r.y + r.h / 2),
cy + Math.sin(angle) * (r.x + r.w / 2) + Math.cos(angle) * (r.y + r.h / 2),
angle,
r.w / 2,
r.h / 2,
);
const hw = w / 2;
const hd = d / 2;
// Stations are planned by the real district (which may borrow another's look).
const plan = this.stationPlan[this.lookDistrict];
const big = district === 'hood' || district === 'village' ? w >= 34 && d >= 30 : w >= 44 && d >= 36;
if (plan?.length && big && rand() < 0.4) {
const kind = plan.shift()!;
this.stations.push({ kind, x: cx, y: cy, angle, w, d, units: 2, district: this.lookDistrict });
// Stations can be destroyed too (their fleets are out until the building's rebuilt).
this.solidPoly(corners);
const stStruct = this.structures[this.structures.length - 1];
stStruct.station = true;
stStruct.mat = 'stone';
stStruct.hp = stStruct.maxHp = stStruct.maxHp * 1.5;
this.stations[this.stations.length - 1].structure = stStruct;
this.structDistrict = null;
const roofColor = { hospital: '#f2f2ee', police: '#2c4a7a', fire: '#a5332a', tow: '#6f6a62', works: '#c27a1e' }[kind];
const label = { hospital: 'HOSPITAL', police: 'POLICE', fire: 'FIRE DEPT', tow: 'ROADSIDE', works: 'PUBLIC WORKS' }[kind];
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, kind === 'hospital' ? 30 : 12);
inLocal(ctx, () => {
ctx.fillStyle = roofColor;
ctx.fillRect(-hw, -hd, w, d);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)';
ctx.lineWidth = 0.8;
ctx.strokeRect(-hw + 2, -hd + 2, w - 4, d - 4);
const r = Math.min(hw, hd) * 0.45;
if (kind === 'hospital') {
// Rooftop helipad for the medevac helicopter, and a red cross.
ctx.fillStyle = '#4a4d52';
ctx.beginPath();
ctx.arc(0, 0, 11, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#f4f1ea';
ctx.lineWidth = 0.8;
ctx.stroke();
ctx.fillStyle = '#f4f1ea';
ctx.fillRect(-3.5, -4, 1.4, 8);
ctx.fillRect(2.1, -4, 1.4, 8);
ctx.fillRect(-3.5, -0.7, 7, 1.4);
ctx.fillStyle = '#d93a3a';
const cx2 = hw - 9;
const cy2 = -hd + 9;
ctx.fillRect(cx2 - 1.5, cy2 - 5, 3, 10);
ctx.fillRect(cx2 - 5, cy2 - 1.5, 10, 3);
} else if (kind === 'works') {
// Salt dome, pipe stacks and a hazard-striped apron.
ctx.fillStyle = '#d9d2c2';
ctx.beginPath();
ctx.arc(-hw * 0.4, -hd * 0.2, r * 0.9, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#5c5f66';
for (let i = 0; i < 4; i++) ctx.fillRect(hw * 0.15, -hd * 0.5 + i * 3, hw * 0.6, 2);
ctx.fillStyle = '#ffcc00';
for (let x = -hw + 2; x < hw - 2; x += 4) ctx.fillRect(x, hd - 3, 2, 2);
} else if (kind === 'police') {
ctx.fillStyle = '#f4f1ea';
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const rr = i % 2 ? r * 0.45 : r;
const a = (i / 10) * Math.PI * 2 - Math.PI / 2;
ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
}
ctx.closePath();
ctx.fill();
} else if (kind === 'fire') {
ctx.fillStyle = '#ffcc33';
ctx.beginPath();
ctx.arc(0, 0, r * 0.7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#a5332a';
ctx.fillRect(-r * 0.15, -r * 0.6, r * 0.3, r * 1.2);
ctx.fillRect(-r * 0.6, -r * 0.15, r * 1.2, r * 0.3);
}
// Garage doors on the street side, and a name sign.
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
for (let x = -hw + 4; x < hw - 8; x += 10) ctx.fillRect(x, -hd, 8, 2);
ctx.fillStyle = kind === 'tow' ? '#ffcc00' : '#ffffff';
ctx.font = `800 ${Math.min(6, w / 9)}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, 0, hd - 5);
});
});
return;
}
if ((district === 'downtown' || district === 'village' || district === 'industrial') && rand() < (district === 'industrial' ? 0.12 : 0.08)) {
// A construction site: dirt, a foundation, rebar and stacked beams.
this.sites.push({ x: cx, y: cy, angle, w, d });
const beams = Array.from({ length: 3 }, () => ({ x: range(-hw + 6, hw - 18), y: range(hd * 0.3, hd - 8) }));
const fw = w * 0.6;
const fd = d * 0.5;
this.solidPoly(sub({ x: -fw / 2, y: -hd + 6, w: fw, h: 2 }));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#9b8463';
ctx.fillRect(-hw, -hd, w, d);
ctx.fillStyle = '#b8b2a6';
ctx.fillRect(-fw / 2, -hd + 6, fw, fd);
ctx.strokeStyle = '#7d4a2a';
ctx.lineWidth = 0.4;
ctx.beginPath();
for (let x = -fw / 2 + 3; x < fw / 2; x += 4) {
ctx.moveTo(x, -hd + 7);
ctx.lineTo(x, -hd + 5 + fd);
}
for (let y = -hd + 9; y < -hd + 6 + fd; y += 4) {
ctx.moveTo(-fw / 2 + 1, y);
ctx.lineTo(fw / 2 - 1, y);
}
ctx.stroke();
for (const b of beams) {
ctx.fillStyle = '#c0392b';
ctx.fillRect(b.x, b.y, 14, 1.4);
ctx.fillRect(b.x, b.y + 2, 14, 1.4);
}
ctx.fillStyle = '#2e86c1';
ctx.fillRect(hw - 8, hd - 10, 4, 4);
ctx.strokeStyle = '#ff8a00';
ctx.lineWidth = 0.7;
ctx.setLineDash([2, 1.4]);
ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
ctx.setLineDash([]);
}),
);
return;
}
if (district === 'industrial') {
const roll = rand();
const steel = pick(['#8d9096', '#7a8088', '#9a9578', '#6f7a83']);
if (roll < 0.35) {
// Factory with a sawtooth roof and smokestacks.
this.solidPoly(corners);
const stackPts = Array.from({ length: 1 + randInt(2) }, (_, i) => ({ x: -hw + 10 + i * 14, y: hd - 10 }));
for (const sp of stackPts) {
const c = sub({ x: sp.x - 4, y: sp.y - 4, w: 8, h: 8 });
this.stacks.push({ x: (c[0].x + c[2].x) / 2, y: (c[0].y + c[2].y) / 2, steam: false });
}
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, 18);
inLocal(ctx, () => {
ctx.fillStyle = steel;
ctx.fillRect(-hw, -hd, w, d);
for (let x = -hw; x < hw; x += 10) {
ctx.fillStyle = shade(steel, 0.18);
ctx.fillRect(x, -hd, 6, d);
ctx.fillStyle = 'rgba(140, 190, 230, 0.45)';
ctx.fillRect(x + 6, -hd, 1.6, d);
}
for (const sp of stackPts) {
ctx.fillStyle = '#4a4a50';
ctx.beginPath();
ctx.arc(sp.x, sp.y, 4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#c0392b';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#1b1b1f';
ctx.beginPath();
ctx.arc(sp.x, sp.y, 2.4, 0, Math.PI * 2);
ctx.fill();
}
});
});
return;
}
if (roll < 0.5) {
// Tank farm.
const tanks = Array.from({ length: 2 + randInt(3) }, (_, i) => ({ x: -hw + 18 + i * 30, y: range(-hd * 0.3, hd * 0.3), r: range(10, 14) })).filter((t) => t.x + t.r < hw);
for (const t of tanks) this.solidPoly(sub({ x: t.x - t.r * 0.8, y: t.y - t.r * 0.8, w: t.r * 1.6, h: t.r * 1.6 }));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#8a8984';
ctx.fillRect(-hw, -hd, w, d);
for (const t of tanks) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.arc(t.x + 5, t.y + 6, t.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e8e6e0';
ctx.beginPath();
ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#b5b2a8';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.arc(t.x, t.y, t.r * 0.65, 0, Math.PI * 2);
ctx.stroke();
}
}),
);
return;
}
if (roll < 0.62) {
// Scrapyard: piles of crushed cars (and where the tow trucks bring the wrecks).
this.junkyard ??= { x: cx, y: cy };
const piles = Array.from({ length: 5 }, () => ({ x: range(-hw + 12, hw - 12), y: range(-hd + 12, hd - 12), r: range(8, 13), colors: Array.from({ length: 5 }, () => pick(['#8a4a3a', '#5e7b8a', '#7a6a55', '#3d7dd8', '#b33a3a', '#6b6f3a'])) }));
for (const pl of piles) this.solidPoly(sub({ x: pl.x - pl.r * 0.7, y: pl.y - pl.r * 0.7, w: pl.r * 1.4, h: pl.r * 1.4 }));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#7d6e58';
ctx.fillRect(-hw, -hd, w, d);
for (const pl of piles) {
pl.colors.forEach((c, i) => {
ctx.save();
ctx.translate(pl.x + Math.cos(i * 1.3) * pl.r * 0.4, pl.y + Math.sin(i * 1.9) * pl.r * 0.4);
ctx.rotate(i * 0.7);
ctx.fillStyle = c;
ctx.fillRect(-5, -3, 10, 6);
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(-5, 1, 10, 2);
ctx.restore();
});
}
ctx.strokeStyle = '#9aa0a6';
ctx.lineWidth = 0.6;
ctx.setLineDash([1.6, 1]);
ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
ctx.setLineDash([]);
}),
);
return;
}
if (roll < 0.72) {
// Rail yard with boxcars.
const tracks = [-hd * 0.45, 0, hd * 0.45];
const cars = tracks.flatMap((ty) => Array.from({ length: Math.floor((w - 10) / 26) }, (_, i) => ({ x: -hw + 6 + i * 26, y: ty, show: rand() < 0.7, color: pick(['#8a3b2e', '#2471a3', '#5d6d7e', '#ca6f1e', '#3f5e3a']) })));
for (const c of cars) if (c.show) this.solidPoly(sub({ x: c.x, y: c.y - 4.5, w: 22, h: 9 }));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#7b7466';
ctx.fillRect(-hw, -hd, w, d);
for (const ty of tracks) {
ctx.fillStyle = '#5a4632';
for (let x = -hw; x < hw; x += 3) ctx.fillRect(x, ty - 4, 1.2, 8);
ctx.fillStyle = '#b5b8bd';
ctx.fillRect(-hw, ty - 2.6, w, 0.6);
ctx.fillRect(-hw, ty + 2, w, 0.6);
}
for (const c of cars) {
if (!c.show) continue;
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(c.x + 1.5, c.y - 3, 22, 9);
ctx.fillStyle = c.color;
ctx.fillRect(c.x, c.y - 4.5, 22, 9);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 0.5;
ctx.strokeRect(c.x, c.y - 4.5, 22, 9);
}
}),
);
return;
}
if (roll < 0.8 && w > 120 && d > 110) {
// Power plant cooling tower.
const r = Math.min(hw, hd) * 0.7;
this.stacks.push({ x: cx, y: cy, steam: true });
this.solidPoly(boxCorners(cx, cy, angle, r * 0.75, r * 0.75));
this.add(L_OBJECT, x0 - 20, y0 - 20, x1 + 20, y1 + 20, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#8a8984';
ctx.fillRect(-hw, -hd, w, d);
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.arc(14, 16, r, 0, Math.PI * 2);
ctx.fill();
const g = ctx.createRadialGradient(-r * 0.3, -r * 0.3, r * 0.2, 0, 0, r);
g.addColorStop(0, '#e2dfd8');
g.addColorStop(1, '#a9a59c');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#5f5d58';
ctx.beginPath();
ctx.arc(0, 0, r * 0.62, 0, Math.PI * 2);
ctx.fill();
}),
);
return;
}
// Open yard with pallets: forklifts and workers move about here.
this.yards.push({ x: cx, y: cy, angle, w, d });
const pallets = Array.from({ length: 10 }, () => ({ x: range(-hw + 6, hw - 10), y: range(-hd + 6, hd - 10), c: pick(['#c8a26a', '#8a6a44', '#2471a3', '#d4ac0d']) }));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#7a7872';
ctx.fillRect(-hw, -hd, w, d);
ctx.strokeStyle = 'rgba(255, 210, 0, 0.7)';
ctx.lineWidth = 0.6;
ctx.strokeRect(-hw + 4, -hd + 4, w - 8, d - 8);
for (const pl of pallets) {
ctx.fillStyle = pl.c;
ctx.fillRect(pl.x, pl.y, 5, 5);
}
}),
);
return;
}
if (district === 'downtown') {
const roll = rand();
if (roll < 0.1) {
const per = Math.max(1, Math.floor((w - 4) / 12));
const cars = Array.from({ length: per * 2 }, (_, k) => ({
k,
show: rand() < 0.65,
look: makeLook(pick<CarModel>(['sedan', 'suv', 'taxi']), pick(['#3d7dd8', '#e6e6e6', '#2e2f38', '#b33a3a', '#f2c230']), '#888', false),
}));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#4a4b55';
ctx.fillRect(-hw, -hd, w, d);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.lineWidth = 0.6;
ctx.beginPath();
for (let k = 0; k <= per; k++) {
ctx.moveTo(-hw + 2 + k * 12, -hd + 2);
ctx.lineTo(-hw + 2 + k * 12, -hd + 20);
ctx.moveTo(-hw + 2 + k * 12, hd - 2);
ctx.lineTo(-hw + 2 + k * 12, hd - 20);
}
ctx.stroke();
for (const c of cars) {
if (!c.show) continue;
const top = c.k < per;
drawCar(ctx, c.look, -hw + 8 + (c.k % per) * 12, top ? -hd + 11 : hd - 11, top ? Math.PI / 2 : -Math.PI / 2);
}
}),
);
return;
}
if (roll < 0.16) {
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#cfc6b4';
ctx.fillRect(-hw, -hd, w, d);
ctx.fillStyle = '#9e9686';
ctx.beginPath();
ctx.arc(0, 0, Math.min(hw, hd) * 0.45, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = WATER;
ctx.beginPath();
ctx.arc(0, 0, Math.min(hw, hd) * 0.36, 0, Math.PI * 2);
ctx.fill();
}),
);
this.solidPoly(boxCorners(cx, cy, angle, Math.min(hw, hd) * 0.45, Math.min(hw, hd) * 0.45));
this.tree(cx + Math.cos(angle) * hw * 0.7, cy + Math.sin(angle) * hw * 0.7, 7);
return;
}
const height = range(20, 80);
const color = pick(['#5d6b7a', '#6f8796', '#4f5d6e', '#8a96a3', '#7b7f8c', '#3f5568', '#9b8f80', '#6a6e78', '#a08f7a']);
const hvac = Array.from({ length: 1 + randInt(3) }, () => ({ x: range(-hw + 5, hw - 12), y: range(-hd + 5, hd - 11), w: range(6, 9), h: range(5, 7) }));
const helipad = w > 46 && d > 46 && rand() < 0.35;
const garden = rand() < 0.18;
const glass = rand() < 0.3;
this.solidPoly(corners);
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, height);
inLocal(ctx, () => {
const g = ctx.createLinearGradient(-hw, -hd, hw, hd);
g.addColorStop(0, shade(color, 0.18));
g.addColorStop(1, shade(color, -0.18));
ctx.fillStyle = g;
ctx.fillRect(-hw, -hd, w, d);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
ctx.lineWidth = 0.8;
ctx.strokeRect(-hw + 2.5, -hd + 2.5, w - 5, d - 5);
if (glass) {
ctx.fillStyle = 'rgba(140, 190, 230, 0.5)';
for (let sx = -hw + 6; sx < hw - 8; sx += 6) ctx.fillRect(sx, -2, 4, 4);
}
if (garden) {
ctx.fillStyle = '#5d9b4a';
ctx.beginPath();
ctx.roundRect(-hw + 5, -hd + 5, Math.min(22, w - 10), Math.min(14, d - 10), 3);
ctx.fill();
}
if (helipad) {
ctx.fillStyle = '#3a3d44';
ctx.beginPath();
ctx.arc(0, 0, 12, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ffcc33';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = '700 11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('H', 0, 0.5);
} else {
for (const h of hvac) {
ctx.fillStyle = '#d3d7da';
ctx.fillRect(h.x, h.y, h.w, h.h);
ctx.fillStyle = '#6a7076';
ctx.beginPath();
ctx.arc(h.x + h.w / 2, h.y + h.h / 2, Math.min(h.w, h.h) * 0.3, 0, Math.PI * 2);
ctx.fill();
}
}
});
});
return;
}
if (district === 'uptown') {
const mw = w * range(0.5, 0.62);
const md = d * range(0.42, 0.55);
const main = { x: -hw + w * 0.12, y: -hd + d * 0.12, w: mw, h: md };
const wing = { x: main.x + mw * 0.5, y: main.y + md - 2, w: mw * 0.5, h: md * 0.5 };
const poolW = Math.min(30, hw - (main.x + mw) - 6);
const pool = poolW > 14 ? { x: main.x + mw + 6, y: main.y + 6, w: poolW, h: Math.min(18, md - 4) } : null;
const roof = pick(['#a4553a', '#7b4a3a', '#5c6670', '#8e6a4e', '#b07a50']);
const car = rand() < 0.6 ? makeLook(pick<CarModel>(['sports', 'suv']), pick(['#111318', '#f2f2f2', '#c0c4ca', '#7a1f2b', '#d9b25a']), '#888', false) : null;
const mainC = sub(main);
const wingC = sub(wing);
this.solidPoly(mainC);
this.solidPoly(wingC);
if (pool) this.solidPoly(sub(pool));
this.add(L_OBJECT, x0 - 14, y0 - 14, x1 + 14, y1 + 14, (ctx) => {
inLocal(ctx, () => {
ctx.fillStyle = '#6aa84f';
ctx.fillRect(-hw, -hd - 14, w, d + 14);
ctx.strokeStyle = '#2f5e2c';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(-hw + 2, -hd + 2, w - 4, d - 4, 4);
ctx.stroke();
ctx.fillStyle = '#cfcac0';
ctx.fillRect(main.x + mw * 0.2, -hd - 14, 9, main.y + hd + 14);
if (car) drawCar(ctx, car, main.x + mw * 0.2 + 4.5, -hd - 4, -Math.PI / 2);
if (pool) {
ctx.fillStyle = '#f2efe6';
ctx.beginPath();
ctx.roundRect(pool.x - 2.5, pool.y - 2.5, pool.w + 5, pool.h + 5, 4);
ctx.fill();
ctx.fillStyle = '#3fb4dc';
ctx.beginPath();
ctx.roundRect(pool.x, pool.y, pool.w, pool.h, 3);
ctx.fill();
}
});
shadow(ctx, 9, mainC);
shadow(ctx, 7, wingC);
inLocal(ctx, () => {
this.hipRoof(ctx, wing, shade(roof, -0.06));
this.hipRoof(ctx, main, roof);
});
});
const tp = sub({ x: hw - 18, y: hd - 18, w: 4, h: 4 })[0];
this.tree(tp.x, tp.y, range(8, 11), 'fancy');
return;
}
if (district === 'hood') {
const roof = pick(['#6d5a4e', '#7d6b5a', '#5a5f66', '#8a6f52', '#6e4c40', '#59636b']);
if (rand() < 0.65) {
const house = { x: -hw + 3, y: -hd + 3, w: w - 6 - range(0, 6), h: d - 6 - range(0, 6) };
const hc = sub(house);
this.solidPoly(hc);
const patches = Array.from({ length: 1 + randInt(3) }, () => ({ x: house.x + range(2, house.w - 9), y: house.y + range(2, house.h - 8), w: range(4, 8), h: range(3, 6), light: rand() < 0.5 }));
const tarp = rand() < 0.3;
const yard = pick(['#8a7556', '#7d7a4a']);
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
inLocal(ctx, () => {
ctx.fillStyle = yard;
ctx.fillRect(-hw, -hd, w, d);
});
shadow(ctx, 5, hc);
inLocal(ctx, () => {
const horizontal = house.w >= house.h;
ctx.fillStyle = shade(roof, 0.15);
if (horizontal) ctx.fillRect(house.x, house.y, house.w, house.h / 2);
else ctx.fillRect(house.x, house.y, house.w / 2, house.h);
ctx.fillStyle = shade(roof, -0.18);
if (horizontal) ctx.fillRect(house.x, house.y + house.h / 2, house.w, house.h / 2);
else ctx.fillRect(house.x + house.w / 2, house.y, house.w / 2, house.h);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = 0.6;
ctx.strokeRect(house.x, house.y, house.w, house.h);
for (const p of patches) {
ctx.fillStyle = shade(roof, p.light ? 0.3 : -0.35);
ctx.fillRect(p.x, p.y, p.w, p.h);
}
if (tarp) {
ctx.fillStyle = '#2f6fc0';
ctx.fillRect(house.x + house.w * 0.55, house.y + 2, house.w * 0.3, house.h * 0.4);
}
ctx.strokeStyle = 'rgba(190, 196, 200, 0.75)';
ctx.lineWidth = 0.5;
ctx.setLineDash([1.6, 1]);
ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
ctx.setLineDash([]);
});
});
return;
}
const junk = Array.from({ length: 8 }, () => ({ x: range(-hw + 4, hw - 4), y: range(-hd + 4, hd - 4), t: randInt(4), r: range(1.5, 3) }));
const wreck = rand() < 0.5 ? makeLook('beater', pick(['#7a6a55', '#8a4a3a', '#5e7b8a']), '#9a9a9a', false) : null;
if (wreck) this.solidPoly(boxCorners(cx, cy, angle + 0.3, 8, 5));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
inLocal(ctx, () => {
ctx.fillStyle = '#86704f';
ctx.fillRect(-hw, -hd, w, d);
for (const j of junk) {
if (j.t === 0) {
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(j.x, j.y, j.r, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.fillStyle = j.t === 1 ? '#25272b' : j.t === 2 ? '#d8d0b8' : 'rgba(70, 110, 40, 0.8)';
ctx.beginPath();
ctx.arc(j.x, j.y, j.r, 0, Math.PI * 2);
ctx.fill();
}
}
if (wreck) drawCar(ctx, wreck, 0, 0, 0.3, true);
ctx.strokeStyle = 'rgba(190, 196, 200, 0.75)';
ctx.lineWidth = 0.5;
ctx.setLineDash([1.6, 1]);
ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
ctx.setLineDash([]);
}),
);
return;
}
if (district === 'village') {
const shop = rand() < 0.3;
const roof = pick(['#c0392b', '#2e86c1', '#d4ac0d', '#7d3c98', '#5d6d7e', '#a0522d']);
const house = { x: -hw + 4, y: -hd + (shop ? 1 : 6), w: w - 8, h: d - (shop ? 4 : 12) };
const hc = sub(house);
this.solidPoly(hc);
const flowers = Array.from({ length: 8 }, () => ({ x: range(-hw + 2, hw - 2), y: range(hd - 5, hd - 1), c: pick(['#ff6b8b', '#ffd34d', '#ffffff', '#b37cff']) }));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
inLocal(ctx, () => {
ctx.fillStyle = shop ? '#cfc6b2' : '#7fb35e';
ctx.fillRect(-hw, -hd, w, d);
for (const f of flowers) {
ctx.fillStyle = f.c;
ctx.beginPath();
ctx.arc(f.x, f.y, 0.9, 0, Math.PI * 2);
ctx.fill();
}
if (!shop) {
ctx.strokeStyle = '#f4f1ea';
ctx.lineWidth = 0.6;
ctx.setLineDash([1.2, 0.8]);
ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
ctx.setLineDash([]);
}
});
shadow(ctx, shop ? 7 : 6, hc);
inLocal(ctx, () => {
if (shop) {
ctx.fillStyle = '#d8d2c6';
ctx.fillRect(house.x, house.y, house.w, house.h);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
ctx.strokeRect(house.x + 1.5, house.y + 1.5, house.w - 3, house.h - 3);
for (let i = 0; i < 6; i++) {
ctx.fillStyle = i % 2 ? '#ffffff' : roof;
ctx.fillRect(house.x + (i * house.w) / 6, house.y - 2.5, house.w / 6 + 0.1, 2.5);
}
} else {
this.hipRoof(ctx, house, roof);
}
});
});
return;
}
if (district === 'country') {
// A farmstead: house, red barn and a silo.
const house = { x: -hw + 6, y: -hd + 6, w: w * 0.3, h: d * 0.32 };
const barn = { x: hw - w * 0.42 - 4, y: -hd + 8, w: w * 0.42, h: d * 0.48 };
const silo = { x: hw - 10, y: hd - 12, r: Math.min(9, d * 0.1 + 4) };
const hc = sub(house);
const bc = sub(barn);
const sc = sub({ x: silo.x - silo.r, y: silo.y - silo.r, w: silo.r * 2, h: silo.r * 2 });
this.solidPoly(hc);
this.solidPoly(bc);
this.solidPoly(sc);
const roof = pick(['#6d5a4e', '#5a5f66', '#8a6f52']);
this.add(L_OBJECT, x0 - 10, y0 - 10, x1 + 10, y1 + 10, (ctx) => {
inLocal(ctx, () => {
ctx.fillStyle = '#7aa85a';
ctx.fillRect(-hw, -hd, w, d);
ctx.fillStyle = '#b59a6a';
ctx.fillRect(house.x + house.w / 2 - 3, -hd - 22, 6, house.y + hd + 22);
ctx.strokeStyle = '#d8d0b8';
ctx.lineWidth = 0.8;
ctx.strokeRect(-hw + 1, -hd + 1, w - 2, d - 2);
});
shadow(ctx, 6, hc);
shadow(ctx, 12, bc);
shadow(ctx, 18, sc);
inLocal(ctx, () => {
this.hipRoof(ctx, house, roof);
const horizontal = barn.w >= barn.h;
ctx.fillStyle = '#b8352a';
ctx.fillRect(barn.x, barn.y, barn.w, barn.h);
ctx.fillStyle = '#8f2920';
if (horizontal) ctx.fillRect(barn.x, barn.y + barn.h / 2, barn.w, barn.h / 2);
else ctx.fillRect(barn.x + barn.w / 2, barn.y, barn.w / 2, barn.h);
ctx.strokeStyle = '#f1e9da';
ctx.lineWidth = 0.9;
ctx.strokeRect(barn.x + 0.5, barn.y + 0.5, barn.w - 1, barn.h - 1);
ctx.fillStyle = '#9aa7ad';
ctx.beginPath();
ctx.arc(silo.x, silo.y, silo.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#c6d0d4';
ctx.beginPath();
ctx.arc(silo.x - silo.r * 0.25, silo.y - silo.r * 0.25, silo.r * 0.55, 0, Math.PI * 2);
ctx.fill();
});
});
return;
}
if (district === 'military') {
const roll = rand();
this.solidPoly(corners);
if (roll < 0.55) {
// Barracks: long and low, a ridged olive roof with air-conditioning units along it.
const units = Array.from({ length: Math.max(2, Math.floor(w / 26)) }, (_, i) => -hw + 12 + i * 26);
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, 10);
inLocal(ctx, () => {
ctx.fillStyle = '#7a8466';
ctx.fillRect(-hw, -hd, w, d / 2);
ctx.fillStyle = '#5f6a4e';
ctx.fillRect(-hw, 0, w, d / 2);
ctx.fillStyle = '#4b5320';
ctx.fillRect(-hw, -1, w, 2);
ctx.fillStyle = '#d5d8dc';
for (const ux of units) ctx.fillRect(ux, -hd + 4, 8, 6);
});
});
} else if (roll < 0.8) {
// Headquarters: pale stone, a flagpole on the lawn out front.
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, 16);
inLocal(ctx, () => {
ctx.fillStyle = '#d8d1bf';
ctx.fillRect(-hw, -hd, w, d);
ctx.fillStyle = '#bdb5a0';
ctx.fillRect(-hw + 4, -hd + 4, w - 8, d - 8);
ctx.fillStyle = '#2c3e50';
ctx.fillRect(-2, hd - 2, 4, 14);
ctx.fillStyle = '#c0392b';
ctx.fillRect(2, hd + 2, 10, 6);
ctx.fillStyle = '#2471a3';
ctx.fillRect(2, hd + 2, 4, 3);
});
});
} else {
// Vehicle shed: open bays with olive trucks inside.
const bays = Math.max(2, Math.floor(w / 24));
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, 8);
inLocal(ctx, () => {
ctx.fillStyle = '#6b6f62';
ctx.fillRect(-hw, -hd, w, d);
for (let i = 0; i < bays; i++) {
const bx = -hw + 4 + i * ((w - 8) / bays);
ctx.fillStyle = '#2b2b2e';
ctx.fillRect(bx, -hd + 4, (w - 8) / bays - 4, d - 8);
ctx.fillStyle = '#4b5320';
ctx.fillRect(bx + 3, -hd + 8, (w - 8) / bays - 10, d * 0.45);
}
});
});
}
return;
}
if (district === 'port') {
if (rand() < 0.55) {
const color = pick(['#8d9096', '#a2675a', '#6f8a9a', '#9a9578']);
this.solidPoly(corners);
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, 16);
inLocal(ctx, () => {
ctx.fillStyle = shade(color, 0.12);
ctx.fillRect(-hw, -hd, w, d / 2);
ctx.fillStyle = shade(color, -0.12);
ctx.fillRect(-hw, 0, w, d / 2);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.12)';
ctx.lineWidth = 0.6;
ctx.beginPath();
for (let x = -hw + 4; x < hw; x += 4) {
ctx.moveTo(x, -hd);
ctx.lineTo(x, hd);
}
ctx.stroke();
ctx.fillStyle = '#2a2b30';
for (let x = -hw + 8; x < hw - 10; x += 18) ctx.fillRect(x, -hd - 2, 10, 2);
});
});
return;
}
const rows = Math.max(1, Math.floor(d / 14));
const cols = Math.max(1, Math.floor(w / 26));
const boxes = Array.from({ length: rows * cols }, () => ({ show: rand() < 0.8, color: pick(['#c0392b', '#2471a3', '#d4ac0d', '#239b56', '#7d3c98', '#ca6f1e', '#5d6d7e']) }));
this.solidPoly(corners);
this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
shadow(ctx, 7);
inLocal(ctx, () => {
ctx.fillStyle = '#76746e';
ctx.fillRect(-hw, -hd, w, d);
boxes.forEach((b, k) => {
if (!b.show) return;
const bx = -hw + 2 + (k % cols) * 26;
const by = -hd + 2 + Math.floor(k / cols) * 14;
ctx.fillStyle = b.color;
ctx.fillRect(bx, by, 24, 12);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)';
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let x = bx + 3; x < bx + 24; x += 3) {
ctx.moveTo(x, by);
ctx.lineTo(x, by + 12);
}
ctx.stroke();
});
});
});
}
}
private hipRoof(ctx: CanvasRenderingContext2D, r: { x: number; y: number; w: number; h: number }, color: string): void {
const inset = Math.min(r.w, r.h) / 2;
const horizontal = r.w >= r.h;
const a = horizontal ? { x: r.x + inset, y: r.y + r.h / 2 } : { x: r.x + r.w / 2, y: r.y + inset };
const b = horizontal ? { x: r.x + r.w - inset, y: r.y + r.h / 2 } : { x: r.x + r.w / 2, y: r.y + r.h - inset };
const tl = { x: r.x, y: r.y };
const tr = { x: r.x + r.w, y: r.y };
const br = { x: r.x + r.w, y: r.y + r.h };
const bl = { x: r.x, y: r.y + r.h };
if (horizontal) {
fillPoly(ctx, [tl, tr, b, a], shade(color, 0.22));
fillPoly(ctx, [tr, br, b], shade(color, -0.05));
fillPoly(ctx, [br, bl, a, b], shade(color, -0.25));
fillPoly(ctx, [bl, tl, a], shade(color, 0.08));
} else {
fillPoly(ctx, [tl, tr, a], shade(color, 0.22));
fillPoly(ctx, [tr, br, b, a], shade(color, -0.05));
fillPoly(ctx, [br, bl, b], shade(color, -0.25));
fillPoly(ctx, [bl, tl, a, b], shade(color, 0.08));
}
}
}
