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