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