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/road.ts
// The highway's shape: curvature (bends) and grade (hills) as pure functions of s, from a seed.
// The sim stays in road space (x across, s along) and only feels the forces of a bend or a
// slope; the renderer bends road space into the world with RoadFrames.
//
// The road is cut into SEG-metre segments. Each picks a curvature and a grade from a hash of
// its index, and the first BLEND metres ease (smoothstep) from the previous segment's values,
// so bends and slopes never kink. Heading and elevation are their integrals, in closed form.
// Segments in the mountains (zones.ts) wind with tighter bends.
import { zoneAt } from './zones.js';

export const SEG = 400;
const BLEND = 160;
export const MIN_RADIUS = 700;
const MAX_RADIUS = 2600;
export const MAX_GRADE = 0.05;
/** Through the mountains the road winds: tighter bends, one after another. */
export const MOUNTAIN_MIN_RADIUS = 380;
const MOUNTAIN_MAX_RADIUS = 650;
/** Segments around the start are straight and level, so a pursuit starts calmly. */
const CALM = 2;

let seed = 1;
const headPrefix = new Map<number, number>([[0, 0]]);
const elevPrefix = new Map<number, number>([[0, 0]]);
const kCache = new Map<number, number>();
const gCache = new Map<number, number>();

/** Pick a new road. Replays of a run need the same seed, so set it only when a run starts. */
export function setRoadSeed(n: number): void {
  seed = n >>> 0;
  for (const m of [headPrefix, elevPrefix]) {
    m.clear();
    m.set(0, 0);
  }
  kCache.clear();
  gCache.clear();
}

export function roadSeed(): number {
  return seed;
}

/** Deterministic 0..1 from an integer and a salt (for this seed). */
export function hash01(k: number, salt: number): number {
  let h = Math.imul(k | 0, 0x9e3779b1) ^ Math.imul((seed + salt * 0x632be5ab) | 0, 0x85ebca6b);
  h ^= h >>> 15;
  h = Math.imul(h, 0x2c1b3c6d);
  h ^= h >>> 12;
  h = Math.imul(h, 0x297a2d39);
  h ^= h >>> 15;
  return (h >>> 0) / 4294967296;
}

/** Curvature of segment k (1/m, positive bends right). Neighbouring bends alternate. */
function segCurve(k: number): number {
  let v = kCache.get(k);
  if (v === undefined) {
    if (Math.abs(k) < CALM) v = 0;
    else if (zoneAt(k * SEG + SEG / 2).area === 'mountains') {
      // Mountains: no straights, each bend tight and the opposite way to the last.
      const radius = MOUNTAIN_MIN_RADIUS + (MOUNTAIN_MAX_RADIUS - MOUNTAIN_MIN_RADIUS) * hash01(k, 2);
      v = (k & 1 ? -1 : 1) / radius;
    } else if (hash01(k, 1) < 0.4) v = 0;
    else {
      const radius = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * hash01(k, 2) ** 1.5;
      v = (k & 1 ? -1 : 1) / radius;
    }
    kCache.set(k, v);
  }
  return v;
}

/** Grade of segment k (rise per metre). Climbs and descents alternate, so it rolls. */
function segGrade(k: number): number {
  let v = gCache.get(k);
  if (v === undefined) {
    if (Math.abs(k) < CALM || hash01(k, 3) < 0.3) v = 0;
    else v = (k & 1 ? 1 : -1) * MAX_GRADE * (0.3 + 0.7 * hash01(k, 4));
    gCache.set(k, v);
  }
  return v;
}

const smooth = (u: number) => u * u * (3 - 2 * u);

function value(fn: (k: number) => number, s: number): number {
  const k = Math.floor(s / SEG);
  const t = s - k * SEG;
  const b = fn(k);
  if (t >= BLEND) return b;
  const a = fn(k - 1);
  return a + (b - a) * smooth(t / BLEND);
}

/** Integral of fn over the first t metres of segment k. */
function partial(fn: (k: number) => number, k: number, t: number): number {
  const a = fn(k - 1), b = fn(k);
  if (t < BLEND) {
    const u = t / BLEND;
    return a * t + (b - a) * BLEND * (u * u * u - (u * u * u * u) / 2);
  }
  return a * BLEND + (b - a) * BLEND * 0.5 + b * (t - BLEND);
}

/** Integral of fn from 0 to the start of segment k, cached. */
function prefix(fn: (k: number) => number, cache: Map<number, number>, k: number): number {
  const hit = cache.get(k);
  if (hit !== undefined) return hit;
  // Walk from the nearest cached segment toward k.
  let j = k;
  const step = k > 0 ? -1 : 1;
  while (!cache.has(j)) j += step;
  let v = cache.get(j)!;
  while (j !== k) {
    if (k > j) {
      v += partial(fn, j, SEG);
      j++;
    } else {
      j--;
      v -= partial(fn, j, SEG);
    }
    cache.set(j, v);
  }
  return v;
}

/** Road curvature at s (1/m; positive bends right, toward +x). */
export function curvature(s: number): number {
  return value(segCurve, s);
}

/** Road grade at s (rise per metre along the road). */
export function grade(s: number): number {
  return value(segGrade, s);
}

/** Absolute road heading at s (radians, clockwise seen from above; 0 at s = 0). */
export function heading(s: number): number {
  const k = Math.floor(s / SEG);
  return prefix(segCurve, headPrefix, k) + partial(segCurve, k, s - k * SEG);
}

/** Road height at s (metres; 0 at s = 0). */
export function elevation(s: number): number {
  const k = Math.floor(s / SEG);
  return prefix(segGrade, elevPrefix, k) + partial(segGrade, k, s - k * SEG);
}

/**
 * The road's centreline sampled every `step` metres around `origin`, in a world frame where
 * the road at `origin` sits at (0, 0, 0) heading along -z. Each sample is
 * [cx, cz, heading, height], all relative to the origin.
 */
export class RoadFrames {
  origin = 0;
  from = 0;
  readonly step: number;
  readonly count: number;
  readonly data: Float64Array;
  private originHeading = 0;

  constructor(private behind: number, ahead: number, step: number) {
    this.step = step;
    this.count = Math.round((behind + ahead) / step) + 1;
    this.data = new Float64Array(this.count * 4);
  }

  /** Absolute heading of the road at the origin, for the sun and the radar. */
  get baseHeading(): number {
    return this.originHeading;
  }

  update(origin: number): void {
    this.origin = origin;
    this.from = origin - this.behind;
    const { step, data, count } = this;
    const h0 = heading(origin), y0 = elevation(origin);
    this.originHeading = h0;
    const o = Math.round(this.behind / step);
    const set = (i: number, x: number, z: number) => {
      const s = this.from + i * step;
      data[i * 4] = x;
      data[i * 4 + 1] = z;
      data[i * 4 + 2] = heading(s) - h0;
      data[i * 4 + 3] = elevation(s) - y0;
    };
    // Integrate outward from the origin, using the exact heading at each midpoint.
    set(o, 0, 0);
    let x = 0, z = 0;
    for (let i = o + 1; i < count; i++) {
      const h = heading(this.from + (i - 0.5) * step) - h0;
      x += Math.sin(h) * step;
      z -= Math.cos(h) * step;
      set(i, x, z);
    }
    x = 0;
    z = 0;
    for (let i = o - 1; i >= 0; i--) {
      const h = heading(this.from + (i + 0.5) * step) - h0;
      x -= Math.sin(h) * step;
      z += Math.cos(h) * step;
      set(i, x, z);
    }
  }

  /** Map road space (x across, y up, s along) to the world. Matches the bend shader. */
  bend(x: number, y: number, s: number, out: { x: number; y: number; z: number }): { x: number; y: number; z: number } {
    const u = (s - this.from) / this.step;
    const last = this.count - 1;
    const uc = Math.min(Math.max(u, 0), last - 1e-6);
    const i = Math.floor(uc), f = uc - i;
    const d = this.data;
    const lerp = (c: number) => d[i * 4 + c] + (d[i * 4 + 4 + c] - d[i * 4 + c]) * f;
    const h = lerp(2);
    const ext = (u - uc) * this.step; // straight on past the sampled stretch
    out.x = lerp(0) + Math.sin(h) * ext + Math.cos(h) * x;
    out.y = lerp(3) + y;
    out.z = lerp(1) - Math.cos(h) * ext + Math.sin(h) * x;
    return out;
  }

  /** Road heading at s relative to the origin. */
  headingAt(s: number): number {
    const u = Math.min(Math.max((s - this.from) / this.step, 0), this.count - 1 - 1e-6);
    const i = Math.floor(u), f = u - i;
    return this.data[i * 4 + 2] + (this.data[i * 4 + 6] - this.data[i * 4 + 2]) * f;
  }
}