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.
// Records the chase as snapshots so a successful PIT can be replayed from outside the car.
import type { CarPose, ReplayCamera, ViewState } from './render.js';
import type { Sim, SimEvent } from './sim.js';
const KEEP_SECONDS = 14;
const RECORD_EVERY = 1 / 40;
const LEAD_IN = 3.5; // seconds of chase shown before the hit
const SLOWMO = { before: 0.4, after: 1.6, rate: 0.35 };
interface Frame {
t: number;
cars: CarPose[];
}
interface Recorded {
t: number;
event: SimEvent;
}
export class Recorder {
private frames: Frame[] = [];
private events: Recorded[] = [];
private last = -Infinity;
record(sim: Sim): void {
for (const { other: _live, ...event } of sim.events) {
// Copied without the live car reference.
this.events.push({ t: sim.time, event });
}
if (sim.time - this.last < RECORD_EVERY) return;
this.last = sim.time;
this.frames.push({
t: sim.time,
cars: sim.cars.map(({ id, kind, x, s, yaw, vx, vs, color, len, wid, wrecked, lost, exploded, type, cargoLost, trailerYaw, signal, braking }) =>
({ id, kind, x, s, yaw, vx, vs, color, len, wid, wrecked, lost, exploded, type, cargoLost, trailerYaw, signal, braking })),
});
const cutoff = sim.time - KEEP_SECONDS;
while (this.frames.length > 2 && this.frames[0].t < cutoff) this.frames.shift();
while (this.events.length && this.events[0].t < cutoff) this.events.shift();
}
/** A looping replay from shortly before `contact` to the end of the recording. */
replay(contact: number): Replay {
const start = Math.max(this.frames[0].t, contact - LEAD_IN);
return new Replay(this.frames, this.events, start, this.frames[this.frames.length - 1].t, contact);
}
}
export class Replay {
private cursor: number;
private restarted = true;
private frames: Frame[];
// Every car seen during the replay, with the first and last frame it appears in, so cars the
// sim spawned or removed mid-replay (it culls far from the player, not the replay camera)
// stay on screen for the whole loop instead of popping in and out.
private spans = new Map<number, { first: CarPose; last: CarPose; firstT: number; lastT: number }>();
constructor(
frames: Frame[],
private events: Recorded[],
private start: number,
private end: number,
private contact: number,
) {
this.cursor = start;
// Only the frames this replay covers (plus one either side for interpolation).
const from = Math.max(0, frames.findIndex((f) => f.t >= start) - 1);
this.frames = frames.slice(from);
for (const frame of this.frames) {
for (const car of frame.cars) {
const span = this.spans.get(car.id);
if (!span) this.spans.set(car.id, { first: car, last: car, firstT: frame.t, lastT: frame.t });
else {
span.last = car;
span.lastT = frame.t;
}
}
}
}
/** Move the playhead; returns the events that happened in the stretch just played. */
advance(dt: number): SimEvent[] {
const near = this.cursor > this.contact - SLOWMO.before && this.cursor < this.contact + SLOWMO.after;
const prev = this.cursor;
this.cursor += dt * (near ? SLOWMO.rate : 1);
this.restarted = false;
if (this.cursor > this.end) {
this.cursor = this.start;
this.restarted = true;
return [];
}
return this.events.filter((e) => e.t > prev && e.t <= this.cursor).map((e) => e.event);
}
get slowMotion(): boolean {
return this.cursor > this.contact - SLOWMO.before && this.cursor < this.contact + SLOWMO.after;
}
camera(): ReplayCamera {
// Start behind and to the right, swing round to the side as the suspect spins.
const progress = (this.cursor - this.start) / Math.max(0.1, this.end - this.start);
return { angle: 0.45 + progress * 1.25, snap: this.restarted, rate: this.slowMotion ? SLOWMO.rate : 1 };
}
/** Interpolated scene at the playhead. */
state(): ViewState {
const frames = this.frames;
let lo = 0, hi = frames.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (frames[mid].t <= this.cursor) lo = mid;
else hi = mid;
}
const a = frames[lo], b = frames[hi];
const k = b.t > a.t ? Math.min(1, Math.max(0, (this.cursor - a.t) / (b.t - a.t))) : 0;
const next = new Map(b.cars.map((c) => [c.id, c]));
const here = new Set(a.cars.map((c) => c.id));
// Cars outside their recorded span carry on (or arrive) at their recorded velocity;
// wrecks stay put.
const coast = (c: CarPose, dt: number): CarPose =>
c.wrecked ? c : { ...c, x: c.x + c.vx * dt, s: c.s + c.vs * dt };
const missing: CarPose[] = [];
for (const [id, span] of this.spans) {
if (here.has(id)) continue;
missing.push(span.lastT < this.cursor ? coast(span.last, this.cursor - span.lastT) : coast(span.first, this.cursor - span.firstT));
}
const present = a.cars.map((c) => {
const d = next.get(c.id);
if (!d) return coast(c, this.cursor - a.t);
const dyaw = Math.atan2(Math.sin(d.yaw - c.yaw), Math.cos(d.yaw - c.yaw));
return {
...c,
x: c.x + (d.x - c.x) * k,
s: c.s + (d.s - c.s) * k,
yaw: c.yaw + dyaw * k,
trailerYaw: c.trailerYaw === undefined || d.trailerYaw === undefined ? c.trailerYaw
: c.trailerYaw + Math.atan2(Math.sin(d.trailerYaw - c.trailerYaw), Math.cos(d.trailerYaw - c.trailerYaw)) * k,
vx: c.vx + (d.vx - c.vx) * k,
vs: c.vs + (d.vs - c.vs) * k,
wrecked: k < 0.5 ? c.wrecked : d.wrecked,
lost: k < 0.5 ? c.lost : d.lost,
exploded: k < 0.5 ? c.exploded : d.exploded,
cargoLost: k < 0.5 ? c.cargoLost : d.cargoLost,
};
});
const cars = [...present, ...missing];
return { cars, player: cars.find((c) => c.kind === 'player')!, input: { steer: 0 } };
}
}
