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/zones.ts
// The areas the highway runs through, as a pure function of s and the road seed (like the
// bends), so the sim, the signs and the renderer agree and a replay sees the same land.
//
// Towns sit about every TOWN_EVERY metres; about one in four is a city, which runs longer.
// Each stretch between two towns is split into two different areas: open countryside,
// forest, mountains or road works. Road works close one or two of the left lanes behind a
// taper of barrels, and through the mountains the highway narrows to its three right lanes
// (the on-ramps join on the right, so a closure never meets one).
import { hash01, roadSeed } from './road.js';

export type Area = 'countryside' | 'forest' | 'mountains' | 'construction' | 'town' | 'city';
export const AREAS: Area[] = ['countryside', 'forest', 'mountains', 'construction', 'town', 'city'];

export interface Zone {
  area: Area;
  start: number;
  end: number;
  town?: number; // town index, for town and city zones
}

export const TOWN_EVERY = 3800;
const FIRST_TOWN = 1500;
const GAP_AREAS: [Area, number][] = [['countryside', 3], ['forest', 3], ['mountains', 2.5], ['construction', 1.5]];
/** Metres over which one area's look fades into the next. */
export const BLEND = 150;

const LANE_W = 3.7, LANES = 6;
const EDGE = (LANES * LANE_W) / 2;
const ROAD_HALF = EDGE + 1.3;

/** Centre of town j. */
export function townCentre(j: number): number {
  return FIRST_TOWN + j * TOWN_EVERY + (hash01(j, 40) - 0.5) * 600;
}

/** About one town in four is a city (never the first). */
export function isCity(j: number): boolean {
  return j > 0 && hash01(j, 41) < 0.25;
}

/** From the welcome sign to the "now leaving" sign. */
export function townSpan(j: number): { start: number; end: number } {
  const c = townCentre(j);
  return isCity(j) ? { start: c - 1000, end: c + 1400 } : { start: c - 600, end: c + 900 };
}

function pickGap(j: number, salt: number, not?: Area): Area {
  const options = GAP_AREAS.filter(([a]) => a !== not);
  let r = hash01(j, salt) * options.reduce((n, [, w]) => n + w, 0);
  for (const [a, w] of options) if ((r -= w) < 0) return a;
  return options[0][0];
}

/** The two areas between town j and town j + 1, and where one gives way to the other. */
function gapZones(j: number): [Zone, Zone] {
  const from = townSpan(j).end;
  const to = townSpan(j + 1).start;
  const a = pickGap(j, 43);
  const b = pickGap(j, 44, a);
  // Mountains and road works take the bigger share of the gap, so they last a bit longer.
  const share = (area: Area) => (area === 'mountains' || area === 'construction' ? 1.6 : 1);
  const split = from + (to - from) * (share(a) / (share(a) + share(b)) + 0.1 * (hash01(j, 42) - 0.5));
  return [{ area: a, start: from, end: split }, { area: b, start: split, end: to }];
}

// Zones are hundreds of metres long and asked about constantly (every car, every step), so
// the last few found are kept and checked first.
const recent: Zone[] = [];
let recentSeed = NaN;

/** The zone containing s. */
export function zoneAt(s: number): Zone {
  if (recentSeed !== roadSeed()) {
    recent.length = 0;
    recentSeed = roadSeed();
  }
  for (const z of recent) if (s >= z.start && s < z.end) return z;
  const z = findZone(s);
  recent.push(z);
  if (recent.length > 24) recent.shift();
  return z;
}

function findZone(s: number): Zone {
  let j = Math.max(-1, Math.floor((s - FIRST_TOWN) / TOWN_EVERY));
  // Step to the town whose span or following gap holds s.
  while (j >= 0 && s < townSpan(j).start) j--;
  while (s >= townSpan(j + 1).start) j++;
  if (j >= 0 && s < townSpan(j).end) return { area: isCity(j) ? 'city' : 'town', ...townSpan(j), town: j };
  // Before the first town: open country to start the drive.
  if (j < 0) return { area: 'countryside', start: -1e9, end: townSpan(0).start };
  const [a, b] = gapZones(j);
  return s < a.end ? a : b;
}

/** How much each area shapes the look at s: 1 for the zone's own, blended near its edges. */
export function areaWeights(s: number): Record<Area, number> {
  const w = { countryside: 0, forest: 0, mountains: 0, construction: 0, town: 0, city: 0 };
  const z = zoneAt(s);
  const toStart = s - z.start, toEnd = z.end - s;
  if (toStart < BLEND / 2) {
    const k = 0.5 + toStart / BLEND;
    w[z.area] += k;
    w[zoneAt(z.start - 1).area] += 1 - k;
  } else if (toEnd < BLEND / 2) {
    const k = 0.5 + toEnd / BLEND;
    w[z.area] += k;
    w[zoneAt(z.end + 1).area] += 1 - k;
  } else {
    w[z.area] = 1;
  }
  return w;
}

/** Traffic in each area, relative to the chosen density. */
const TRAFFIC: Record<Area, number> = { city: 1.15, town: 1, countryside: 1, construction: 0.9, forest: 0.7, mountains: 0.35 };

export function trafficAt(s: number): number {
  return TRAFFIC[zoneAt(s).area];
}

// ---- Road works

export interface Closure {
  kind: 'works' | 'narrows'; // road works behind barrels, or the mountain road narrowing to three lanes
  lanes: number; // left lanes closed (1 or 2 at road works, 3 in the mountains)
  taperIn: number; // barrels start angling across here...
  closed: number; // ...and the lanes are fully shut from here
  open: number; // the barrier angles back out from here...
  end: number; // ...and the road is clear again here
}

/**
 * The lane closure in a zone: road works (short ones only line the shoulder with barrels),
 * or the mountains, where the highway narrows to its three right lanes.
 */
export function closureOf(z: Zone): Closure | null {
  if (z.area === 'mountains' && z.end - z.start >= 500) {
    return { kind: 'narrows', lanes: 3, taperIn: z.start, closed: z.start + 220, open: z.end - 220, end: z.end };
  }
  if (z.area !== 'construction' || z.end - z.start < 700) return null;
  return {
    kind: 'works',
    lanes: hash01(Math.floor(z.start), 45) < 0.5 ? 1 : 2,
    taperIn: z.start + 200, closed: z.start + 380, open: z.end - 250, end: z.end - 120,
  };
}

/** Where the barrier line sits when `lanes` left lanes are closed. */
export const barrierX = (lanes: number): number => -EDGE + lanes * LANE_W - 0.3;

/** The left edge of the drivable road: the guardrail, or the road-works barrier line. */
export function leftWall(s: number): number {
  const c = closureOf(zoneAt(s));
  if (!c || s < c.taperIn || s > c.end) return -ROAD_HALF;
  const x = barrierX(c.lanes);
  if (s < c.closed) return -ROAD_HALF + (x + ROAD_HALF) * ((s - c.taperIn) / (c.closed - c.taperIn));
  if (s > c.open) return x + (-ROAD_HALF - x) * ((s - c.open) / (c.end - c.open));
  return x;
}

/** Left lanes closed at s (from the start of the taper to the end of the closure). */
export function closedLanes(s: number): number {
  const c = closureOf(zoneAt(s));
  return c && s >= c.taperIn && s <= c.end ? c.lanes : 0;
}

/** Left lanes closed anywhere from s to `ahead` metres on. */
export function closedAhead(s: number, ahead: number): number {
  let n = 0;
  for (let d = 0; d <= ahead; d += 50) n = Math.max(n, closedLanes(s + d));
  return n;
}