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/effects.ts
import * as THREE from 'three';
import { partsFor } from './parts.js';
import type { SimEvent } from './sim.js';

// Visual-only effects, simulated in world space (x, height, s) and drawn relative to the
// player like everything else (z = -(s - originS)):
// - panels knocked off cars fly, tumble, and shatter into shards when they land
// - explosions: fireball, flash, a burst of shards, the remaining panels blown off
// - flames and smoke rising from burnt-out wrecks
// - tyre marks wherever a car slides sideways

export interface EffectCar {
  id: number;
  x: number;
  s: number;
  yaw: number;
  vx: number;
  vs: number;
  len: number;
  wid: number;
  color: number;
  exploded: boolean;
  wrecked: boolean;
}

const GRAVITY = 9.8;
const GLASS = 0x9fc6d8;

interface Piece {
  mesh: THREE.Mesh;
  x: number; y: number; s: number;
  vx: number; vy: number; vs: number;
  spin: THREE.Vector3;
  color: number;
  size: number;
  age: number;
}

/** A pool of small instanced particles (shards, flames, smoke). */
class Particles {
  readonly mesh: THREE.InstancedMesh;
  private n: number;
  private next = 0;
  private x: Float32Array; private y: Float32Array; private s: Float32Array;
  private vx: Float32Array; private vy: Float32Array; private vs: Float32Array;
  private size: Float32Array; private life: Float32Array; private maxLife: Float32Array;
  private rx: Float32Array; private ry: Float32Array; private spin: Float32Array;

  constructor(geometry: THREE.BufferGeometry, material: THREE.Material, n: number, private kind: 'shard' | 'flame' | 'smoke') {
    this.n = n;
    this.mesh = new THREE.InstancedMesh(geometry, material, n);
    this.mesh.frustumCulled = false;
    this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    const f = () => new Float32Array(n);
    this.x = f(); this.y = f(); this.s = f(); this.vx = f(); this.vy = f(); this.vs = f();
    this.size = f(); this.life = f(); this.maxLife = f(); this.rx = f(); this.ry = f(); this.spin = f();
    const zero = new THREE.Matrix4().makeScale(0, 0, 0);
    for (let i = 0; i < n; i++) {
      this.mesh.setMatrixAt(i, zero);
      this.mesh.setColorAt(i, new THREE.Color(0xffffff));
    }
  }

  add(x: number, y: number, s: number, vx: number, vy: number, vs: number, size: number, life: number, color: number): void {
    const i = this.next;
    this.next = (this.next + 1) % this.n;
    this.x[i] = x; this.y[i] = y; this.s[i] = s;
    this.vx[i] = vx; this.vy[i] = vy; this.vs[i] = vs;
    this.size[i] = size; this.life[i] = life; this.maxLife[i] = life;
    this.rx[i] = Math.random() * 6; this.ry[i] = Math.random() * 6; this.spin[i] = (Math.random() - 0.5) * 20;
    this.mesh.setColorAt(i, new THREE.Color(color));
    this.mesh.instanceColor!.needsUpdate = true;
  }

  update(dt: number, originS: number): void {
    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const e = new THREE.Euler();
    const p = new THREE.Vector3();
    const sc = new THREE.Vector3();
    for (let i = 0; i < this.n; i++) {
      if (this.life[i] <= 0) continue;
      this.life[i] -= dt;
      const t = 1 - this.life[i] / this.maxLife[i]; // 0 → 1 over its life
      if (this.kind === 'shard') {
        this.vy[i] -= GRAVITY * dt;
        this.x[i] += this.vx[i] * dt; this.y[i] += this.vy[i] * dt; this.s[i] += this.vs[i] * dt;
        if (this.y[i] < this.size[i] / 2) {
          // Bounce, then skitter to a stop on the asphalt.
          this.y[i] = this.size[i] / 2;
          this.vy[i] = Math.abs(this.vy[i]) > 1 ? -this.vy[i] * 0.3 : 0;
          const k = Math.exp(-dt * 4);
          this.vx[i] *= k; this.vs[i] *= k; this.spin[i] *= k;
        }
        this.rx[i] += this.spin[i] * dt;
        const fade = this.life[i] < 1 ? this.life[i] : 1;
        sc.set(this.size[i] * fade, this.size[i] * 0.35 * fade, this.size[i] * 0.8 * fade);
      } else if (this.kind === 'flame') {
        this.x[i] += this.vx[i] * dt; this.y[i] += this.vy[i] * dt; this.s[i] += this.vs[i] * dt;
        const k = this.size[i] * (1 - t * 0.8);
        sc.set(k, k * 1.6, k);
        this.mesh.setColorAt(i, new THREE.Color().setHSL(0.13 - t * 0.12, 1, 0.6 - t * 0.25));
        this.mesh.instanceColor!.needsUpdate = true;
      } else {
        this.x[i] += this.vx[i] * dt; this.y[i] += this.vy[i] * dt; this.s[i] += this.vs[i] * dt;
        const k = this.size[i] * (0.6 + t * 2.2) * (t > 0.85 ? (1 - t) / 0.15 : 1);
        sc.set(k, k, k);
      }
      if (this.life[i] <= 0) sc.set(0, 0, 0);
      q.setFromEuler(e.set(this.rx[i], this.ry[i], 0));
      m.compose(p.set(this.x[i], this.y[i], -(this.s[i] - originS)), q, sc);
      this.mesh.setMatrixAt(i, m);
    }
    this.mesh.instanceMatrix.needsUpdate = true;
  }
}

/** Dark streaks laid where tyres slide; kept relative to an anchor so floats stay small. */
class TyreMarks {
  readonly group = new THREE.Group();
  private mesh: THREE.InstancedMesh;
  private n = 5000;
  private next = 0;
  private seg = new Float32Array(this.n * 4); // x0, s0, x1, s1
  private used = 0;
  private anchor = 0;
  private wheels = new Map<number, { pts: [number, number][]; sliding: boolean; seen: number }>();
  private frame = 0;

  constructor() {
    const geo = new THREE.PlaneGeometry(1, 1);
    geo.rotateX(-Math.PI / 2);
    const mat = new THREE.MeshBasicMaterial({ color: 0x141414, transparent: true, opacity: 0.6, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -3 });
    this.mesh = new THREE.InstancedMesh(geo, mat, this.n);
    this.mesh.frustumCulled = false;
    this.mesh.count = 0;
    this.group.add(this.mesh);
  }

  private place(i: number): void {
    const [x0, s0, x1, s1] = this.seg.subarray(i * 4, i * 4 + 4);
    const len = Math.hypot(x1 - x0, s1 - s0);
    const m = new THREE.Matrix4().compose(
      new THREE.Vector3((x0 + x1) / 2, 0.025, -((s0 + s1) / 2 - this.anchor)),
      new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -Math.atan2(x1 - x0, s1 - s0)),
      new THREE.Vector3(0.24, 1, len + 0.05),
    );
    this.mesh.setMatrixAt(i, m);
  }

  follow(originS: number): void {
    this.group.position.z = -(this.anchor - originS);
  }

  /** Wipe every mark (the replay starts its own set each loop). */
  clear(): void {
    this.used = 0;
    this.next = 0;
    this.mesh.count = 0;
    this.wheels.clear();
  }

  update(cars: readonly EffectCar[], originS: number): void {
    this.frame++;
    if (Math.abs(originS - this.anchor) > 4000) {
      this.anchor = Math.round(originS);
      for (let i = 0; i < this.used; i++) this.place(i);
      this.mesh.instanceMatrix.needsUpdate = true;
    }
    for (const car of cars) {
      const speed = Math.hypot(car.vx, car.vs);
      const slip = Math.abs(car.vx * Math.cos(car.yaw) - car.vs * Math.sin(car.yaw));
      const sliding = speed > 3 && (slip > 2.2 || (car.wrecked && speed > 4));
      const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw), rx = Math.cos(car.yaw), rs = -Math.sin(car.yaw);
      const pts: [number, number][] = [];
      for (const side of [-1, 1]) {
        for (const end of [-1, 1]) {
          const lat = side * (car.wid / 2 - 0.15), lon = end * car.len * 0.32;
          pts.push([car.x + rx * lat + fx * lon, car.s + rs * lat + fs * lon]);
        }
      }
      const w = this.wheels.get(car.id);
      if (w && w.sliding && sliding) {
        pts.forEach(([x, s], k) => {
          const [px, ps] = w.pts[k];
          if (Math.hypot(x - px, s - ps) < 0.3) return;
          const i = this.next;
          this.next = (this.next + 1) % this.n;
          this.used = Math.min(this.n, this.used + 1);
          this.seg.set([px, ps, x, s], i * 4);
          this.place(i);
          w.pts[k] = [x, s];
        });
        this.mesh.count = this.used;
        this.mesh.instanceMatrix.needsUpdate = true;
        w.seen = this.frame;
      } else {
        this.wheels.set(car.id, { pts, sliding, seen: this.frame });
      }
    }
    if (this.frame % 120 === 0) for (const [id, w] of this.wheels) if (w.seen < this.frame - 60) this.wheels.delete(id);
    this.group.position.z = -(this.anchor - originS);
  }
}

export class Effects {
  private pieces: Piece[] = [];
  private shards: Particles;
  private flames: Particles;
  private smoke: Particles;
  private marks = new TyreMarks();
  // The replay lays its own marks from the replayed tyres, starting clean every loop, so
  // streaks appear as the cars slide instead of being already on the road.
  private replayMarks = new TyreMarks();
  private replayPlayerS: number | null = null;
  private fireballs: { mesh: THREE.Mesh; x: number; s: number; age: number; size: number }[] = [];
  readonly flash = new THREE.PointLight(0xffa040, 0, 60, 1.6);
  private flashS = 0;
  private flashX = 0;
  private boxGeo = new THREE.BoxGeometry(1, 1, 1);
  private ballGeo = new THREE.IcosahedronGeometry(1, 1);
  private pieceMats = new Map<number, THREE.MeshLambertMaterial>();
  private burnTimer = 0;

  constructor(private scene: THREE.Scene) {
    this.shards = new Particles(this.boxGeo, new THREE.MeshLambertMaterial({ color: 0xffffff }), 2500, 'shard');
    this.flames = new Particles(new THREE.OctahedronGeometry(0.5), new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false }), 600, 'flame');
    this.smoke = new Particles(new THREE.IcosahedronGeometry(0.5, 0), new THREE.MeshLambertMaterial({ color: 0x2e2c2a, transparent: true, opacity: 0.55, depthWrite: false }), 400, 'smoke');
    scene.add(this.shards.mesh, this.flames.mesh, this.smoke.mesh, this.marks.group, this.replayMarks.group, this.flash);
  }

  private pieceMat(color: number): THREE.MeshLambertMaterial {
    let m = this.pieceMats.get(color);
    if (!m) {
      m = new THREE.MeshLambertMaterial({ color, flatShading: true });
      this.pieceMats.set(color, m);
    }
    return m;
  }

  /** Fling panel `index` of `car` off with an extra kick (m/s) away from the car. */
  private fling(car: EffectCar, index: number, kick: number): void {
    const part = partsFor(car.len, car.wid)[index];
    const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw), rx = Math.cos(car.yaw), rs = -Math.sin(car.yaw);
    const x = car.x + rx * part.lat + fx * part.lon;
    const s = car.s + rs * part.lat + fs * part.lon;
    const out = Math.hypot(part.lat, part.lon) || 1;
    const ox = (rx * part.lat + fx * part.lon) / out, os = (rs * part.lat + fs * part.lon) / out;
    const mesh = new THREE.Mesh(this.boxGeo, this.pieceMat(car.color));
    mesh.scale.set(part.w, part.h, part.l);
    this.scene.add(mesh);
    this.pieces.push({
      mesh, x, y: part.y, s,
      vx: car.vx * 0.7 + ox * kick + (Math.random() - 0.5) * 2,
      vy: 2 + Math.random() * 3 + kick * 0.3,
      vs: car.vs * 0.7 + os * kick + (Math.random() - 0.5) * 2,
      spin: new THREE.Vector3((Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14),
      color: car.color,
      size: Math.max(part.w, part.l),
      age: 0,
    });
  }

  private shatter(p: Piece): void {
    const count = 8 + Math.floor(p.size * 6);
    for (let i = 0; i < count; i++) {
      const glass = Math.random() < 0.2;
      this.shards.add(
        p.x + (Math.random() - 0.5) * p.size, Math.max(0.1, p.y), p.s + (Math.random() - 0.5) * p.size,
        p.vx * 0.5 + (Math.random() - 0.5) * 5, 1 + Math.random() * 3.5, p.vs * 0.5 + (Math.random() - 0.5) * 5,
        0.08 + Math.random() * 0.2, 5 + Math.random() * 4, glass ? GLASS : p.color,
      );
    }
  }

  onEvent(e: SimEvent, cars: ReadonlyMap<number, EffectCar>): void {
    const car = e.car !== undefined ? cars.get(e.car) : undefined;
    if (!car) return;
    if (e.type === 'bird') {
      // Burst of blood and a few feathers, carried along with the car that hit it.
      const y = e.y ?? 1;
      for (let i = 0; i < 34; i++) {
        const a = Math.random() * Math.PI * 2, v = 1 + Math.random() * 5;
        this.shards.add(e.x, y, e.s, car.vx * 0.85 + Math.cos(a) * v, 0.5 + Math.random() * 4, car.vs * 0.85 + Math.sin(a) * v,
          0.04 + Math.random() * 0.1, 1.5 + Math.random() * 2.5, Math.random() < 0.7 ? 0x8c0b0b : 0x5e0606);
      }
      for (let i = 0; i < 9; i++) {
        const a = Math.random() * Math.PI * 2, v = 1 + Math.random() * 3;
        this.shards.add(e.x, y + 0.2, e.s, car.vx * 0.6 + Math.cos(a) * v, 1 + Math.random() * 3, car.vs * 0.6 + Math.sin(a) * v,
          0.1 + Math.random() * 0.12, 2 + Math.random() * 2, Math.random() < 0.6 ? 0x2b2b30 : 0x6d6a66);
      }
    } else if (e.type === 'damage') {
      this.fling(car, e.part!, 1.5 + Math.min(6, e.strength * 0.4));
    } else if (e.type === 'explode') {
      for (let i = 0; i < 10; i++) if (e.part! & (1 << i)) this.fling(car, i, 8 + Math.random() * 6);
      // A gas tanker (anything rig-sized) goes up far bigger.
      const big = car.len > 10;
      for (let i = 0; i < (big ? 150 : 60); i++) {
        const a = Math.random() * Math.PI * 2, v = (4 + Math.random() * 12) * (big ? 1.6 : 1);
        this.shards.add(car.x, 0.8, car.s, car.vx * 0.5 + Math.cos(a) * v, 3 + Math.random() * 8, car.vs * 0.5 + Math.sin(a) * v,
          0.1 + Math.random() * 0.25, 6 + Math.random() * 4, Math.random() < 0.3 ? GLASS : Math.random() < 0.5 ? 0x222222 : car.color);
      }
      const mesh = new THREE.Mesh(this.ballGeo, new THREE.MeshBasicMaterial({ color: 0xffd060, transparent: true, fog: false }));
      this.scene.add(mesh);
      this.fireballs.push({ mesh, x: car.x, s: car.s, age: 0, size: big ? 2.6 : 1 });
      this.flash.intensity = big ? 1400 : 400;
      this.flashX = car.x;
      this.flashS = car.s;
    }
  }

  update(dt: number, originS: number, cars: readonly EffectCar[], live: boolean): void {
    const z = (s: number) => -(s - originS);
    for (const p of this.pieces) {
      p.age += dt;
      p.vy -= GRAVITY * dt;
      p.x += p.vx * dt; p.y += p.vy * dt; p.s += p.vs * dt;
      p.mesh.rotation.x += p.spin.x * dt;
      p.mesh.rotation.y += p.spin.y * dt;
      p.mesh.rotation.z += p.spin.z * dt;
      p.mesh.position.set(p.x, p.y, z(p.s));
    }
    // Panels shatter when they hit the road.
    this.pieces = this.pieces.filter((p) => {
      if ((p.y > 0.15 || p.vy > 0) && p.age < 3) return true;
      this.shatter(p);
      this.scene.remove(p.mesh);
      return false;
    });

    for (const f of this.fireballs) {
      f.age += dt;
      const t = f.age / 1.1;
      f.mesh.scale.setScalar((0.8 + Math.min(1, f.age / 0.35) * 4.2) * f.size);
      f.mesh.position.set(f.x, 1.2 + f.age * 2, z(f.s));
      const m = f.mesh.material as THREE.MeshBasicMaterial;
      m.opacity = Math.max(0, 0.9 * (1 - t));
      m.color.setHSL(0.09 - t * 0.08, 1, 0.55 - t * 0.4);
    }
    this.fireballs = this.fireballs.filter((f) => {
      if (f.age < 1.1) return true;
      this.scene.remove(f.mesh);
      (f.mesh.material as THREE.Material).dispose();
      return false;
    });
    this.flash.intensity *= Math.exp(-dt * 6);
    this.flash.position.set(this.flashX, 3, z(this.flashS));

    // Burning wrecks.
    this.burnTimer += dt;
    while (this.burnTimer > 1 / 30) {
      this.burnTimer -= 1 / 30;
      for (const car of cars) {
        if (!car.exploded || Math.abs(car.s - originS) > 500) continue;
        const ox = (Math.random() - 0.5) * car.wid, os = (Math.random() - 0.5) * car.len * 0.7;
        this.flames.add(car.x + ox, 0.7, car.s + os, (Math.random() - 0.5) * 0.6, 2 + Math.random() * 2, (Math.random() - 0.5) * 0.6,
          0.5 + Math.random() * 0.6, 0.5 + Math.random() * 0.4, 0xffaa30);
        if (Math.random() < 0.3) {
          this.smoke.add(car.x + ox, 2, car.s + os, (Math.random() - 0.5) * 0.8, 1.8 + Math.random(), -1 + (Math.random() - 0.5),
            1 + Math.random(), 3.5 + Math.random() * 1.5, 0x2e2c2a);
        }
      }
    }
    this.shards.update(dt, originS);
    this.flames.update(dt, originS);
    this.smoke.update(dt, originS);
    this.marks.group.visible = live;
    this.replayMarks.group.visible = !live;
    if (live) {
      this.marks.update(cars, originS);
      this.replayPlayerS = null;
    } else {
      // Entering the replay, or the loop jumping back to its start (the player car moves
      // backwards), begins a fresh set of marks.
      if (this.replayPlayerS === null || originS < this.replayPlayerS - 5) this.replayMarks.clear();
      this.replayPlayerS = originS;
      this.replayMarks.update(cars, originS);
      this.marks.follow(originS);
    }
  }
}