Back to Pursuit PIT
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.

src/game/cities.ts
// 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 };
}