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.
// 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;
}
}
