SOURCE / PINNED RELEASE
Made of little things.
Pursuit PIT
- Release
- 565f4981bdc3…
- Author-recorded commit
- 197afdbed44b…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7
Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// City layouts: each city on the road is one of seven styles, laid out on a street grid either
// side of the interstate. Every block is filled — buildings, a park or a parking garage — with
// the tallest towers downtown (the middle of the city) and low buildings toward the edges.
// Some cross streets carry over the highway on bridges. Pure data, from the road seed; the
// renderer (areas.ts) draws it.
import { MERGE_LEN, MERGE_TAPER, MERGE_X, RAMP_CURVE, RAMP_EVERY, ROAD_HALF, rampAt, rampX } from './sim.js';
import { hash01, roadSeed } from './road.js';
import type { Zone } from './zones.js';
export interface CityStyle {
name: string;
core: number; // typical height downtown, m
edge: number; // typical height at the outskirts
curve: number; // how sharply heights fall off from the centre
block: number; // block length along the highway, m
blockX: number; // block depth across
street: number; // street width
lot: number; // typical lot frontage
park: number; // share of blocks that are parks
garage: number; // share of lots that are parking garages
industrial: boolean; // works and warehouses with chimneys among the low buildings
bridgeEvery: number; // every nth cross street bridges the highway
towers: number[]; // colours of the tall buildings
low: number[]; // colours of the low ones (muted, so nothing shouts)
}
export const CITY_STYLES: CityStyle[] = [
{
name: 'Metropolis', core: 280, edge: 14, curve: 1.6, block: 110, blockX: 90, street: 16, lot: 40,
park: 0.05, garage: 0.08, industrial: false, bridgeEvery: 2,
towers: [0x7f8fa3, 0x9aa7b5, 0x5f6f82, 0xaab4bd, 0x6d7a8a],
low: [0xb8b0a2, 0xa3a9ad, 0xc2b8a6, 0x9c9488, 0xb0a99a],
},
{
name: 'Old brick town', core: 75, edge: 9, curve: 1.2, block: 80, blockX: 70, street: 12, lot: 22,
park: 0.12, garage: 0.05, industrial: false, bridgeEvery: 3,
towers: [0x8f5a46, 0x9c6b52, 0x7d5446, 0xa47a62],
low: [0x9a6a55, 0xa98c74, 0x8c7466, 0xb39a82, 0x8f6f60],
},
{
name: 'Desert city', core: 130, edge: 8, curve: 1.8, block: 120, blockX: 100, street: 18, lot: 35,
park: 0.03, garage: 0.1, industrial: false, bridgeEvery: 2,
towers: [0xc9b28f, 0xb89d7a, 0xd4c3a3, 0xa58f72],
low: [0xd1b48e, 0xc4a07a, 0xdcc7a5, 0xb98f6c, 0xcbb595],
},
{
name: 'Port city', core: 190, edge: 12, curve: 1.5, block: 130, blockX: 110, street: 16, lot: 45,
park: 0.07, garage: 0.12, industrial: true, bridgeEvery: 2,
towers: [0x6f8790, 0x8a9ba3, 0x5d7079, 0x9fb0b6],
low: [0x8a9496, 0x9aa39c, 0x7e8a8c, 0xa8a8a0, 0x8c8a82],
},
{
name: 'Garden city', core: 95, edge: 10, curve: 1.3, block: 100, blockX: 85, street: 14, lot: 30,
park: 0.24, garage: 0.04, industrial: false, bridgeEvery: 3,
towers: [0xe2ddd0, 0xd4cfc2, 0xc8cec8, 0xbfc7c0],
low: [0xe0d8c4, 0xcfd6c8, 0xd8cdb8, 0xc6ccc0, 0xe4dccb],
},
{
name: 'Mill city', core: 65, edge: 12, curve: 1, block: 140, blockX: 120, street: 16, lot: 50,
park: 0.04, garage: 0.12, industrial: true, bridgeEvery: 2,
towers: [0x857a6e, 0x9a8f80, 0x6f6a64],
low: [0x8b6f5c, 0x7f7a72, 0x957f6a, 0x6e6a62, 0x8a8278],
},
{
name: 'Capital', core: 230, edge: 14, curve: 2.2, block: 105, blockX: 95, street: 18, lot: 36,
park: 0.14, garage: 0.08, industrial: false, bridgeEvery: 2,
towers: [0xd9d2c0, 0x8fa0b2, 0xc9c0ad, 0x7e90a4, 0xb5b9be],
low: [0xd6cdb9, 0xc4bca9, 0xb9b3a6, 0xcfc4ae, 0xa9a59c],
},
];
/** Which of the seven styles city (town index) j is. */
export function cityStyle(j: number): CityStyle {
return CITY_STYLES[Math.floor(hash01(j, 47) * CITY_STYLES.length)];
}
export type LotKind = 'building' | 'factory' | 'garage' | 'park';
export interface Lot {
kind: LotKind;
x: number; // centre, across (negative: left of the highway)
s: number; // centre, along
w: number; // size across
d: number; // size along
h: number; // height (parks: 0)
color: number;
extra?: 'antenna' | 'roofbox' | 'chimney';
}
/** A strip of street asphalt. */
export interface Street {
x0: number;
x1: number;
s0: number;
s1: number;
}
export interface CityLayout {
style: CityStyle;
lots: Lot[];
streets: Street[];
bridges: number[]; // s of each cross street that bridges the highway
width: number; // street width
}
/** Grid edges: the frontage street runs here, just outside the highway's verge. */
const FRONTAGE = ROAD_HALF + 22;
const REACH = 640; // how far the city spreads either side
const RAMP_LEN = RAMP_CURVE + 260;
const OVERPASS = 920; // signs.ts BRIDGE_OFFSET: the regular overpasses, every RAMP_EVERY
/** A city bridge's deck reaches this far each side of the centre line, then ramps down over BRIDGE_RAMP. */
export const BRIDGE_SPAN = ROAD_HALF + 26;
export const BRIDGE_RAMP = 70;
/** The slip road (and the merge lane) cross the right side of the grid here: keep it clear. */
function onSlipRoad(x0: number, x1: number, s0: number, s1: number): boolean {
if (x1 < 0) return false;
for (let s = s0; s <= s1; s += 10) {
const d = s - rampAt(s);
if (d <= -RAMP_LEN - 30 || d >= MERGE_LEN + MERGE_TAPER + 20) continue;
const rx = d < 0 ? rampX(-d) : MERGE_X;
if (rx + 25 > x0) return true;
}
return false;
}
const cache = new Map<string, CityLayout>();
export function cityLayout(zone: Zone): CityLayout {
const key = `${roadSeed()}:${zone.start}`;
const hit = cache.get(key);
if (hit) return hit;
const layout = build(zone);
cache.set(key, layout);
if (cache.size > 6) cache.delete(cache.keys().next().value!);
return layout;
}
function build(zone: Zone): CityLayout {
const j = zone.town ?? 0;
const st = cityStyle(j);
const lots: Lot[] = [];
const streets: Street[] = [];
const bridges: number[] = [];
const centre = (zone.start + zone.end) / 2, half = (zone.end - zone.start) / 2;
const salt = Math.floor(zone.start);
let n = 0;
const rnd = () => hash01(salt * 131 + n++, 48);
const pick = <T>(list: T[]) => list[Math.floor(rnd() * list.length)];
// Cross streets (across the highway) and the long streets beside it.
const cross: number[] = [];
for (let s = zone.start + st.block * 0.6; s < zone.end - st.street; s += st.block + st.street) cross.push(s);
const along: number[] = [];
for (let x = FRONTAGE; x < REACH; x += st.blockX + st.street) along.push(x);
const farX = along[along.length - 1] + st.blockX;
for (const side of [-1, 1]) {
for (const x of along) {
// In pieces, leaving gaps where a slip road runs through.
const x0 = side * x - st.street / 2, x1 = side * x + st.street / 2;
let from: number | null = null;
for (let s = zone.start; s <= zone.end; s += 20) {
const clear = s < zone.end && !onSlipRoad(Math.min(x0, x1), Math.max(x0, x1), s, s + 20);
if (clear && from === null) from = s;
if (!clear && from !== null) {
streets.push({ x0, x1, s0: from, s1: s });
from = null;
}
}
}
for (const s of cross) {
const [a, b] = side < 0 ? [-farX, -FRONTAGE] : [FRONTAGE, farX];
if (!onSlipRoad(a, b, s - st.street / 2, s + st.street / 2)) streets.push({ x0: a, x1: b, s0: s - st.street / 2, s1: s + st.street / 2 });
}
}
// Bridges: every so often a cross street carries over the highway (clear of the slip roads
// and of the regular overpasses).
cross.forEach((s, i) => {
if (i % st.bridgeEvery) return;
const overpass = Math.round((s - OVERPASS) / RAMP_EVERY) * RAMP_EVERY + OVERPASS;
if (Math.abs(s - overpass) < 60) return;
// A slip road may pass under the deck or well out past the ramp's foot, not where it lands.
for (const ds of [-20, 0, 20]) {
const d = s + ds - rampAt(s + ds);
if (d <= -RAMP_LEN - 30 || d >= MERGE_LEN + MERGE_TAPER + 20) continue;
const rx = d < 0 ? rampX(-d) : MERGE_X;
if (rx + 6 > BRIDGE_SPAN && rx - 6 < BRIDGE_SPAN + BRIDGE_RAMP) return;
}
bridges.push(s);
});
// Blocks: between the streets, filled lot by lot.
const sEdges = [zone.start, ...cross, zone.end];
for (const side of [-1, 1]) {
for (let xi = 0; xi < along.length; xi++) {
const xa = along[xi] + st.street / 2;
const xb = (xi + 1 < along.length ? along[xi + 1] : farX + st.street / 2) - st.street / 2;
for (let si = 0; si + 1 < sEdges.length; si++) {
const sa = sEdges[si] + (si === 0 ? 0 : st.street / 2);
const sb = sEdges[si + 1] - (si + 1 === sEdges.length - 1 ? 0 : st.street / 2);
if (sb - sa < 12) continue;
const [bx0, bx1] = side < 0 ? [-xb, -xa] : [xa, xb];
if (onSlipRoad(bx0, bx1, sa, sb)) continue; // the interchange: left as open ground
const bs = (sa + sb) / 2, bx = (xa + xb) / 2;
// 1 downtown (middle of the city, near the highway), 0 out at the edges.
const along01 = Math.abs(bs - centre) / half, across01 = (bx - FRONTAGE) / (REACH - FRONTAGE);
const core = Math.max(0, 1 - Math.max(along01, across01 * 0.85));
if (rnd() < st.park * (1.4 - core * 0.6)) {
lots.push({ kind: 'park', x: side * bx, s: bs, w: xb - xa, d: sb - sa, h: 0, color: pick([0x5f8a3e, 0x56803a, 0x678f45]) });
continue;
}
// Lots: frontage along the block, one or two deep.
const nS = Math.max(1, Math.round((sb - sa) / (st.lot * (1 + core * 0.6))));
const nX = xb - xa > 70 ? 2 : 1;
for (let a = 0; a < nS; a++) {
for (let c = 0; c < nX; c++) {
const ls0 = sa + ((sb - sa) * a) / nS, ls1 = sa + ((sb - sa) * (a + 1)) / nS;
const lx0 = xa + ((xb - xa) * c) / nX, lx1 = xa + ((xb - xa) * (c + 1)) / nX;
const ls = (ls0 + ls1) / 2, lx = side * (lx0 + lx1) / 2;
const lw = lx1 - lx0, ld = ls1 - ls0;
if (rnd() < st.garage) {
const levels = 3 + Math.floor(rnd() * 4);
lots.push({ kind: 'garage', x: lx, s: ls, w: lw - 3, d: ld - 3, h: levels * 3.2, color: pick([0x9b9890, 0xa29e94, 0x8f8c86]) });
continue;
}
// Height: downtown towers, low buildings at the edges, with the odd spike.
let h = (st.edge + (st.core - st.edge) * core ** st.curve) * (0.45 + 0.8 * rnd());
if (core > 0.65 && rnd() < 0.12) h *= 1.5;
h = Math.max(6, h);
const tall = h > 35;
if (!tall && st.industrial && rnd() < 0.45) {
lots.push({ kind: 'factory', x: lx, s: ls, w: lw - 4, d: ld - 4, h: 8 + rnd() * 8, color: pick(st.low), extra: rnd() < 0.5 ? 'chimney' : undefined });
continue;
}
// Towers stand a little slimmer on their lots; low buildings fill them.
const slim = tall ? 0.6 + rnd() * 0.3 : 0.88;
lots.push({
kind: 'building', x: lx, s: ls, w: lw * slim - 2, d: ld * slim - 2, h,
color: tall ? pick(st.towers) : pick(st.low),
extra: h > 150 ? 'antenna' : rnd() < 0.35 ? 'roofbox' : undefined,
});
}
}
}
}
}
return { style: st, lots, streets, bridges, width: st.street };
}
