SOURCE / PINNED RELEASE
Made of little things.
Big Rig Rush
- Release
- ec9b6549f752…
- Author-recorded commit
- 74b729258b1a…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40
Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import type { Vec } from './geometry.js';
import type { View } from './traffic.js';
interface Spark {
x: number;
y: number;
vx: number;
vy: number;
life: number;
max: number;
}
interface Puff {
x: number;
y: number;
vx: number;
vy: number;
r: number;
grow: number;
life: number;
max: number;
kind: 'smoke' | 'fire' | 'tyre' | 'dust' | 'steam' | 'water' | 'blast' | 'flash';
tone: number;
/** Seconds before it appears (smoke rolling in after a fireball). */
delay?: number;
/** Dust colour (grey masonry, brown timber or earth); default is a dusty tan. */
rgb?: [number, number, number];
}
type PartKind = 'panel' | 'door' | 'bumper' | 'wheel' | 'hubcap' | 'glass' | 'chunk';
interface Part {
kind: PartKind;
x: number;
y: number;
z: number;
vx: number;
vy: number;
vz: number;
a: number;
va: number;
w: number;
h: number;
color: string;
born: number;
shape: 'rect' | 'round' | 'tri';
burning: number;
}
interface Ring {
x: number;
y: number;
r: number;
speed: number;
life: number;
max: number;
width: number;
/** A condensation ring: white vapour flashing out with the shock. */
vapor?: boolean;
}
interface Skid {
x1: number;
y1: number;
x2: number;
y2: number;
w: number;
born: number;
blood?: boolean;
}
interface Decal {
x: number;
y: number;
size: number;
hole: boolean;
rays: { a: number; l: number }[];
angle: number;
}
/** Road damage: a blast hole or a scorched patch, until the road crew fixes it. */
interface Crater {
x: number;
y: number;
r: number;
hole: boolean;
seed: number;
/** A bomb crater: a deep pit, a raised rim and ejecta streaks thrown out around it. */
big?: boolean;
/** Blown into soil (a brown rim and spoil) rather than tarmac or concrete. */
earth?: boolean;
}
/** A gouge torn out of the ground by a cannon round. */
interface Pit {
x: number;
y: number;
r: number;
angle: number;
/** Torn earth (grass, dirt) rather than a pock-mark in tarmac. */
earth: boolean;
born: number;
}
/** What an explosion hits: the ground's surface, a building's construction, or a vehicle. */
export type Ground = 'earth' | 'grass' | 'tarmac' | 'concrete' | 'water' | 'wood' | 'stone' | 'metal';
/** The wind: smoke drifts downwind (px/s). */
const WIND = { x: 9, y: -6 };
/** Fresh asphalt where the road crew filled a hole. */
interface Patch {
x: number;
y: number;
r: number;
angle: number;
born: number;
}
interface GroundFire {
x: number;
y: number;
r: number;
life: number;
max: number;
}
/** A light in the night: position, radius and warm/colour tint. */
export interface Glow {
x: number;
y: number;
r: number;
color: string;
}
export type HitMaterial = 'car' | 'truck' | 'concrete' | 'barrier' | 'shore' | 'ground' | 'wood';
export interface CrashOptions {
/** Number of wheels torn off and thrown. */
wheels?: number;
/** Extra body panels (doors, hood) thrown. */
panels?: number;
/** What the vehicle hit: changes the debris, dust and sparks. */
material?: HitMaterial;
/** Whether a windscreen shattered. */
glass?: boolean;
/** Engine damage: radiator steam, oily black smoke, or nothing. */
smoke?: 'none' | 'steam' | 'black';
/** The kind of vehicle, for its signature debris. */
flavor?: string;
}
interface Splat {
x: number;
y: number;
blobs: { dx: number; dy: number; r: number }[];
born: number;
}
const MAX_SKIDS = 9000;
const SKID_FADE = 240;
const MAX_PARTS = 2200;
const MAX_PUFFS = 2800;
const MAX_FIRES = 260;
const range = (a: number, b: number) => a + Math.random() * (b - a);
/** Sparks, smoke, fire, flying parts, shockwaves and skid marks. Debris stays where it lands. */
export class Effects {
private time = 0;
/** What's at a point (set by the game), and whether there's anything there that burns. */
groundAt: ((x: number, y: number) => Ground) | null = null;
private sparks: Spark[] = [];
private puffs: Puff[] = [];
private parts: Part[] = [];
private rings: Ring[] = [];
private skids: Skid[] = [];
private splats: Splat[] = [];
private fires: GroundFire[] = [];
private decals: Decal[] = [];
private drops: Spark[] = [];
private craters: Crater[] = [];
/** Smoke columns rising from big blasts, for a while afterwards. */
private columns: { x: number; y: number; life: number; timer: number }[] = [];
/** Things that keep going after the blast: gas mains on fire, burst water mains, arcing transformers. */
private emitters: { kind: 'gas' | 'geyser' | 'arc'; x: number; y: number; life: number; timer: number; angle: number }[] = [];
/** Gouges from cannon fire. */
private pits: Pit[] = [];
/** Scorched ground around big blasts. */
private burns: { x: number; y: number; r: number; born: number }[] = [];
private patches: Patch[] = [];
/** Optional wall test so debris bounces off buildings. */
solid: ((x: number, y: number) => Vec) | null = null;
clear(): void {
this.sparks = [];
this.puffs = [];
this.parts = [];
this.rings = [];
this.skids = [];
this.splats = [];
this.drops = [];
this.fires = [];
this.decals = [];
this.craters = [];
this.patches = [];
this.columns = [];
this.emitters = [];
this.burns = [];
this.pits = [];
}
/** A ruptured gas main in the ruins: a roaring jet of flame that burns for a while. */
gasFire(x: number, y: number, seconds: number): void {
this.emitters.push({ kind: 'gas', x, y, life: seconds, timer: 0, angle: Math.random() * Math.PI * 2 });
if (this.emitters.length > 60) this.emitters.shift();
}
/** A burst water main: a fountain of water out of the crater, running down the street. */
geyser(x: number, y: number, seconds: number): void {
this.emitters.push({ kind: 'geyser', x, y, life: seconds, timer: 0, angle: 0 });
if (this.emitters.length > 60) this.emitters.shift();
}
/** A wrecked transformer or power station arcing: blue-white flashes and showers of sparks. */
arcs(x: number, y: number, seconds: number): void {
this.emitters.push({ kind: 'arc', x, y, life: seconds, timer: 0, angle: 0 });
if (this.emitters.length > 60) this.emitters.shift();
}
/** An anti-aircraft shell bursting: a black puff with a flash at its heart. */
flak(x: number, y: number): void {
this.add({ x, y, vx: 0, vy: 0, r: 3, grow: 30, life: 0.12, max: 0.12, kind: 'flash', tone: 0 });
for (let i = 0; i < 4; i++) this.add({ x: x + range(-4, 4), y: y + range(-4, 4), vx: range(-8, 8), vy: range(-8, 8), r: range(4, 7), grow: range(6, 10), life: range(1.5, 2.6), max: 2.6, kind: 'smoke', tone: range(18, 30) });
}
/** A surface-to-air missile's smoke trail. */
missileTrail(x: number, y: number, z: number): void {
const max = range(1.4, 2.4);
this.add({ x, y, vx: range(-3, 3), vy: range(-3, 3), r: 1.5 + z * 0.01, grow: 6, life: max, max, kind: 'smoke', tone: range(200, 230) });
}
/** A towering column of black smoke that keeps billowing from a blast site. */
smokeColumn(x: number, y: number, seconds: number): void {
this.columns.push({ x, y, life: seconds, timer: 0 });
if (this.columns.length > 60) this.columns.shift();
}
private scorch(x: number, y: number, r: number, hole: boolean, earth = false): void {
if (this.craters.some((c) => Math.hypot(c.x - x, c.y - y) < Math.max(c.r, r) * 0.7)) return;
this.craters.push({ x, y, r, hole, seed: Math.random() * 10, earth });
if (this.craters.length > 140) this.craters.shift();
}
private readonly sprites = new Map<number, HTMLCanvasElement>();
/**
* A soft round particle in one colour, cached (colours are rounded so there are only
* a few hundred). Glowing ones have a hot centre; smoke is denser and fluffier.
*/
private soft(r: number, g: number, b: number, glow: boolean): HTMLCanvasElement {
const q = (v: number) => Math.max(0, Math.min(255, Math.round(v / 12) * 12));
const key = ((q(r) * 256 + q(g)) * 256 + q(b)) * 2 + (glow ? 1 : 0);
let c = this.sprites.get(key);
if (c) return c;
c = document.createElement('canvas');
c.width = c.height = 48;
const x = c.getContext('2d')!;
const grad = x.createRadialGradient(24, 24, 0, 24, 24, 24);
const col = `${q(r)}, ${q(g)}, ${q(b)}`;
if (glow) {
grad.addColorStop(0, `rgba(255, 255, 240, 1)`);
grad.addColorStop(0.25, `rgba(${col}, 0.95)`);
grad.addColorStop(0.6, `rgba(${col}, 0.45)`);
} else {
grad.addColorStop(0, `rgba(${col}, 1)`);
grad.addColorStop(0.5, `rgba(${col}, 0.85)`);
grad.addColorStop(0.8, `rgba(${col}, 0.35)`);
}
grad.addColorStop(1, `rgba(${col}, 0)`);
x.fillStyle = grad;
x.fillRect(0, 0, 48, 48);
this.sprites.set(key, c);
return c;
}
/** A dust cloud of a given colour (masonry grey, timber brown, earth). */
dustCloud(x: number, y: number, size: number, rgb: [number, number, number], vx = 0, vy = 0, delay = 0): void {
const max = range(2, 4.5);
this.add({ x, y, vx: vx + range(-12, 12), vy: vy + range(-12, 12), r: range(3, 5) * size, grow: range(5, 9) * size, life: max, max, kind: 'dust', tone: 0, rgb, delay });
}
/**
* A 30 mm round hitting the ground: it gouges a pit (torn earth, or a pock-mark in
* tarmac), throws up clods and a spurt of dirt or grit.
*/
gouge(x: number, y: number, angle: number, earth: boolean, near: boolean): void {
this.pits.push({ x, y, r: range(1.8, 3), angle, earth, born: this.time });
if (this.pits.length > 1800) this.pits.shift();
if (!near) return;
const rgb: [number, number, number] = earth ? [120, 92, 60] : [140, 138, 132];
this.add({ x, y, vx: range(-20, 20), vy: range(-20, 20), r: range(1.2, 2), grow: range(6, 10), life: range(0.6, 1.1), max: 1.1, kind: 'dust', tone: 0, rgb });
if (Math.random() < 0.5) this.part('chunk', x, y, 60, { x: 0, y: 0 }, earth ? '#5d4630' : '#55524a', 60, range(0.6, 1.2), range(0.5, 1), 'rect');
}
/** A bomb crater: deep, with a raised rim and ejecta streaks; the road crews fill it in later. */
bombCrater(x: number, y: number, r: number, ground: Ground = 'tarmac'): void {
if (ground === 'water') return;
const earth = ground === 'earth' || ground === 'grass';
this.craters = this.craters.filter((c) => Math.hypot(c.x - x, c.y - y) > r * 0.8);
this.craters.push({ x, y, r, hole: true, seed: Math.random() * 10, big: true, earth });
if (this.craters.length > 140) this.craters.shift();
// Clods of earth and broken tarmac thrown out in a ring.
for (let i = 0; i < 26; i++) {
const a = Math.random() * Math.PI * 2;
this.part('chunk', x + Math.cos(a) * r * 0.5, y + Math.sin(a) * r * 0.5, range(60, 140), { x: Math.cos(a), y: Math.sin(a) }, earth ? (i % 3 ? '#4a3a2a' : '#5d4630') : i % 3 ? '#2b2b2e' : '#55524a', range(40, 90), range(1.2, 3), range(1, 2.4), Math.random() < 0.4 ? 'tri' : 'rect');
}
}
/** A small scorched pock-mark in the road (cannon rounds). */
scorchMark(x: number, y: number, r: number): void {
this.scorch(x, y, r, false);
}
/** Cracks and holes in buildings, for the repair crews. */
structureDamage(): Vec[] {
return this.decals.map((d) => ({ x: d.x, y: d.y }));
}
/** Builders patch up walls near a point. Returns how many were fixed. */
repairStructure(x: number, y: number, r: number): number {
const before = this.decals.length;
this.decals = this.decals.filter((d) => Math.hypot(d.x - x, d.y - y) > r);
const fixed = before - this.decals.length;
if (fixed) for (let i = 0; i < 5; i++) this.dust(x + range(-3, 3), y + range(-3, 3), 0.8);
return fixed;
}
/** Blast holes and scorch marks in the road, for the road crews. */
roadDamage(): Vec[] {
return this.craters.map((c) => ({ x: c.x, y: c.y }));
}
/** Fill and resurface the road near a point. */
patchRoad(x: number, y: number, r: number): void {
for (const c of this.craters) {
if (Math.hypot(c.x - x, c.y - y) > r) continue;
this.patches.push({ x: c.x, y: c.y, r: c.r * 1.15, angle: Math.random() * Math.PI, born: this.time });
}
this.craters = this.craters.filter((c) => Math.hypot(c.x - x, c.y - y) > r);
if (this.patches.length > 160) this.patches.shift();
for (let i = 0; i < 4; i++) this.steam(x + range(-3, 3), y + range(-3, 3), 0.7);
}
/** Physical ground fire footprint for civilian road avoidance. */
roadFires(): { x: number; y: number; r: number }[] {
return this.fires.map((f) => ({ x: f.x, y: f.y, r: f.r + 4 }));
}
/** Every fire burning on the ground or in a building. */
allFires(): Vec[] {
return this.fires.map((f) => ({ x: f.x, y: f.y }));
}
/** Spots littered with debris, glass or blood, for the street sweepers. */
messSpots(): { x: number; y: number; n: number }[] {
const cells = new Map<number, { x: number; y: number; n: number }>();
const add = (x: number, y: number, w: number) => {
const key = Math.floor(y / 40) * 1000 + Math.floor(x / 40);
const c = cells.get(key);
if (c) {
c.x += x * w;
c.y += y * w;
c.n += w;
} else cells.set(key, { x: x * w, y: y * w, n: w });
};
for (const p of this.parts) if (p.z <= 0.5) add(p.x, p.y, 1);
for (const sp of this.splats) add(sp.x, sp.y, 1);
for (const k of this.skids) if (k.blood) add(k.x1, k.y1, 0.3);
const out: { x: number; y: number; n: number }[] = [];
for (const c of cells.values()) if (c.n >= 4) out.push({ x: c.x / c.n, y: c.y / c.n, n: c.n });
return out;
}
/**
* A vehicle smashes into a building or other structure. (nx, ny) points out of
* the wall toward the vehicle. Cracks, then holes with rubble, then fire.
*/
structureHit(x: number, y: number, impact: number, nx: number, ny: number, material: HitMaterial): void {
const k = impact / 100;
const out = { x: nx, y: ny };
const masonry = material === 'concrete';
if (masonry) {
for (let i = 0; i < 4 + k * 16; i++) this.part('chunk', x, y, 40 + k * 90, out, Math.random() < 0.5 ? '#9e978a' : '#6f6a62', 50 + k * 60, range(0.8, 2.6), range(0.8, 2), Math.random() < 0.3 ? 'tri' : 'rect');
for (let i = 0; i < 3 + k * 10; i++) this.part('glass', x, y, 40 + k * 70, out, 'rgba(205, 232, 255, 0.9)', 50 + k * 40, range(0.6, 1.4), range(0.5, 1));
for (let i = 0; i < 3 + k * 9; i++) this.dust(x + range(-4, 4), y + range(-4, 4), 1.2 + k);
}
if (impact < 30) return;
const hole = impact > 75;
this.decals.push({
x: x - nx * 2,
y: y - ny * 2,
size: (hole ? 5 : 3) + k * (hole ? 8 : 5),
hole,
angle: Math.random() * Math.PI,
rays: Array.from({ length: 5 + Math.floor(Math.random() * 4) }, () => ({ a: Math.random() * Math.PI * 2, l: range(0.6, 1.3) })),
});
if (this.decals.length > 220) this.decals.shift();
if (impact > 115 && material === 'wood') {
// Timber catches fire.
const life = range(25, 45);
this.fires.push({ x: x - nx * 5, y: y - ny * 5, r: 6 + k * 4, life, max: life });
for (let i = 0; i < 10; i++) this.puff(x, y, false, true, range(1.5, 2.5));
}
}
/**
* A building comes down: timber splinters into planks and shingles, stone into
* blocks and dust. (dx, dy) is the push of whatever smashed it (zero for a bomb).
*/
collapse(x: number, y: number, w: number, d: number, angle: number, mat: 'wood' | 'stone', dx: number, dy: number): void {
const wood = mat === 'wood';
const colors = wood ? ['#8a6a44', '#a0703c', '#5d4037', '#c9a27a', '#6b3f2a'] : ['#9e978a', '#6f6a62', '#b8b2a6', '#55524a', '#8d9096'];
const n = Math.min(70, 20 + (w * d) / 25);
const c = Math.cos(angle);
const s = Math.sin(angle);
for (let i = 0; i < n; i++) {
const u = (Math.random() - 0.5) * w;
const v = (Math.random() - 0.5) * d;
const px = x + c * u - s * v;
const py = y + s * u + c * v;
const dir = { x: dx + (Math.random() - 0.5) * 0.8, y: dy + (Math.random() - 0.5) * 0.8 };
if (wood) this.part('panel', px, py, range(40, 120), dir, colors[i % colors.length], range(40, 110), range(2, 5.5), range(0.5, 1), 'rect', Math.random() < 0.08 ? range(3, 8) : 0);
else this.part('chunk', px, py, range(30, 100), dir, colors[i % colors.length], range(40, 120), range(1.2, 3.4), range(1, 2.6), Math.random() < 0.4 ? 'tri' : 'rect');
if (i % 3 === 0) this.dust(px, py, range(1.5, 3));
}
// Big slabs of wall and roof sliding out: they stay where they land as rubble.
for (let i = 0; i < Math.min(10, 3 + (w * d) / 200); i++) {
const u = (Math.random() - 0.5) * w;
const v = (Math.random() - 0.5) * d;
this.part(wood ? 'door' : 'panel', x + c * u - s * v, y + s * u + c * v, range(15, 45), { x: dx + (Math.random() - 0.5), y: dy + (Math.random() - 0.5) }, wood ? colors[i % 2] : colors[i % 3], range(20, 50), range(5, 10), range(2.5, 4.5), 'rect');
}
// The dust cloud billowing out of the collapse.
for (let i = 0; i < Math.min(44, 14 + (w * d) / 60); i++) {
const a = Math.random() * Math.PI * 2;
const max = range(4, 8);
this.add({ x: x + range(-w, w) * 0.35, y: y + range(-d, d) * 0.35, vx: Math.cos(a) * range(25, 70) + dx * 15, vy: Math.sin(a) * range(25, 70) + dy * 15, r: range(6, 10), grow: range(8, 14), life: max * 1.4, max: max * 1.4, kind: 'dust', tone: 0, rgb: wood ? [128, 104, 78] : [150, 146, 138], delay: range(0, 0.4) });
}
for (let i = 0; i < 12; i++) this.part('glass', x + range(-w, w) * 0.4, y + range(-d, d) * 0.4, range(40, 90), { x: dx, y: dy }, 'rgba(205, 232, 255, 0.9)', 60, range(0.6, 1.4), range(0.5, 1));
for (let i = 0; i < 18; i++) this.puff(x + range(-w, w) * 0.5, y + range(-d, d) * 0.5, true, false, range(2.5, 4.5));
// The dust settles slowly: a haze hangs over the ruins and drifts off downwind.
this.haze(x, y, Math.max(w, d) * 0.7, wood ? [140, 116, 88] : [160, 156, 148], Math.min(14, 5 + (w * d) / 300));
if (this.parts.length > MAX_PARTS) this.parts.splice(0, this.parts.length - MAX_PARTS);
}
/**
* A slow, low haze of dust: big faint clouds that hang over a blast or a collapse long
* after it and drift away on the wind.
*/
haze(x: number, y: number, r: number, rgb: [number, number, number], n: number): void {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const d = Math.sqrt(Math.random()) * r;
const max = range(10, 18);
this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: WIND.x * range(0.6, 1.4), vy: WIND.y * range(0.6, 1.4), r: range(10, 16), grow: range(2, 4), life: max, max, kind: 'dust', tone: 0, rgb, delay: range(0.6, 2.5) });
}
}
/** A jet of water from a fire hose. */
water(x1: number, y1: number, x2: number, y2: number): void {
if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
const dx = x2 - x1;
const dy = y2 - y1;
const d = Math.hypot(dx, dy) || 1;
const speed = d * range(1.6, 2.2);
const max = 0.45;
this.puffs.push({ x: x1, y: y1, vx: (dx / d) * speed + range(-6, 6), vy: (dy / d) * speed + range(-6, 6), r: 0.6, grow: 6, life: max, max, kind: 'water', tone: 0 });
}
/** Put out every fire within r. Returns how many were burning. */
extinguish(x: number, y: number, r: number): number {
const before = this.fires.length;
for (const f of this.fires) if (Math.hypot(f.x - x, f.y - y) <= r && f.r > 6) this.scorch(f.x, f.y, f.r, false);
this.fires = this.fires.filter((f) => Math.hypot(f.x - x, f.y - y) > r);
for (let i = 0; i < 8; i++) this.steam(x + range(-r, r) * 0.5, y + range(-r, r) * 0.5, 1.5);
return before - this.fires.length;
}
/** Roadside crews sweep up debris, glass and blood around a scene. */
cleanup(x: number, y: number, r: number): void {
const far = (px: number, py: number) => Math.hypot(px - x, py - y) > r;
this.parts = this.parts.filter((p) => far(p.x, p.y));
this.splats = this.splats.filter((p) => far(p.x, p.y));
this.skids = this.skids.filter((k) => !k.blood || far(k.x1, k.y1));
}
/** Burning spots near a point (for the fire brigade). */
firesNear(x: number, y: number, r: number): Vec[] {
return this.fires.filter((f) => Math.hypot(f.x - x, f.y - y) < r).map((f) => ({ x: f.x, y: f.y }));
}
/** A fuel fireball: a huge blast that leaves the ground burning for a while. */
/** A fuel-tank fireball (a vehicle going up). */
fireball(x: number, y: number, size = 1, ground: Ground = 'metal'): void {
this.detonation(x, y, size, 1, ground);
}
private add(p: Puff): void {
if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
this.puffs.push(p);
}
/**
* A fireball whose character depends on what it hits:
* earth / grass an eruption of soil, clods and torn turf; a brown-rimmed crater
* tarmac broken asphalt, grit and sparks; a dark pit
* concrete pale dust and slabs
* water a geyser of spray and steam; no crater, nothing left burning
* wood splinters and a blaze
* stone masonry chunks and a cloud of grey dust
* metal a vehicle: torn metal, a shower of sparks and a fuel fireball
* Fire is only left behind where something can burn (dry grass, timber, spilt fuel).
*/
detonation(x: number, y: number, size = 1, burn = 1, ground?: Ground): void {
const s = size;
const kind = ground ?? this.groundAt?.(x, y) ?? 'tarmac';
const wet = kind === 'water';
const earthy = kind === 'earth' || kind === 'grass';
const fuel = kind === 'metal';
if (!wet) this.scorch(x, y, 4 + s * 6, true, earthy);
this.rings.push({ x, y, r: 6, speed: 200 * Math.sqrt(s), life: 0.4, max: 0.4, width: 3 * s });
this.add({ x, y, vx: 0, vy: 0, r: 16 * s, grow: 60 * s, life: 0.16, max: 0.16, kind: 'flash', tone: 0 });
// The fireball: smaller over water and soft earth (it soaks up the blast), bigger with fuel or timber.
const flame = wet ? 0.4 : earthy ? 0.75 : fuel || kind === 'wood' ? 1.3 : 1;
for (let i = 0; i < 40 * s * flame; i++) {
const a = Math.random() * Math.PI * 2;
const sp = range(10, 80) * s;
const max = range(1, 2.2) * Math.min(1.5, 0.7 + s * 0.4);
this.add({ x: x + Math.cos(a) * range(0, 6) * s, y: y + Math.sin(a) * range(0, 6) * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 6, r: range(4, 8) * s, grow: range(5, 11) * s, life: max, max, kind: 'blast', tone: 0 });
}
this.ejecta(x, y, s, kind);
// Big ones kick a ring of dust (or spray) racing outward along the ground, and boil up a mushroom of smoke.
if (s >= 1.4) {
const ring: [number, number, number] = earthy ? [122, 98, 66] : kind === 'concrete' || kind === 'stone' ? [160, 156, 148] : [132, 124, 110];
for (let i = 0; i < 46; i++) {
const a = (i / 46) * Math.PI * 2 + range(-0.05, 0.05);
const sp = range(150, 240) * Math.sqrt(s) * 1.4;
const max = range(1.4, 2.5);
this.add({ x: x + Math.cos(a) * 8 * s, y: y + Math.sin(a) * 8 * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, r: range(3, 5) * s, grow: 9 * s, life: max, max, kind: wet ? 'water' : 'dust', tone: 0, rgb: ring });
}
if (!wet) {
for (let i = 0; i < 18; i++) {
const max = range(4, 8);
this.add({ x: x + range(-8, 8) * s, y: y + range(-8, 8) * s, vx: range(-6, 6) + 4, vy: range(-6, 6) - 10, r: range(6, 10) * s, grow: range(4, 7) * s, life: max, max, kind: 'smoke', tone: fuel ? range(12, 24) : range(18, 34), delay: range(0.5, 1.4) });
}
}
}
// Smoke rolls in after it: black and oily from fuel, browner from earth, steam from water.
for (let i = 0; i < 38 * s * (earthy ? 0.7 : 1); i++) {
const a = Math.random() * Math.PI * 2;
const d = range(0, 26) * s;
const max = range(5, 11);
const tone = fuel ? range(14, 30) : earthy ? range(55, 80) : range(26, 55);
this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: Math.cos(a) * range(5, 25) + 6, vy: Math.sin(a) * range(5, 25) - 8, r: range(4, 8) * s, grow: range(3, 7) * s, life: max, max, kind: wet ? 'steam' : 'smoke', tone, delay: range(0.25, 0.9) });
}
// Sparks: a shower off metal and tarmac, few from soft earth or water.
const sparks = (fuel ? 150 : kind === 'tarmac' || kind === 'stone' || kind === 'concrete' ? 90 : wet ? 10 : 40) * s;
for (let i = 0; i < sparks; i++) {
const a = Math.random() * Math.PI * 2;
const sp = range(80, 360) * Math.sqrt(s);
this.sparks.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: range(0.3, 1.1), max: 1.1 });
}
if (this.sparks.length > 1400) this.sparks.splice(0, this.sparks.length - 1400);
if (this.parts.length > MAX_PARTS) this.parts.splice(0, this.parts.length - MAX_PARTS);
// Fire left behind only by something that burns: a vehicle's spilt fuel. Everything
// else an explosion hits is left cratered, scorched and smoking, not burning (the
// game sets alight what the blast destroys: trees, buildings).
if (fuel) {
for (let i = 0; i < 3 + Math.floor(s * 3); i++) {
const life = range(4, 9) * burn;
this.fires.push({ x: x + range(-14, 14) * s, y: y + range(-14, 14) * s, r: range(5, 10) * Math.min(2, s), life, max: life });
}
}
if (this.fires.length > MAX_FIRES) this.fires.splice(0, this.fires.length - MAX_FIRES);
}
/**
* The extra layers of a bomb going off, on top of its detonation(): the white-hot core
* of burning aluminium, the case's fragments peppering the ground far beyond the
* fireball, a cone of earth thrown up that rains back down in a ring, the dust column
* slumping into a low skirt that rolls outward, and (in damp air) a vapour ring
* flashing out with the shock. R is the blast radius in map units.
*/
bombBurst(x: number, y: number, R: number, s: number, kind: Ground, humid: boolean): void {
const wet = kind === 'water';
const earthy = kind === 'earth' || kind === 'grass';
const soil: [number, number, number] = earthy ? [112, 88, 60] : kind === 'concrete' || kind === 'stone' ? [164, 160, 152] : [128, 122, 112];
// White-hot core, then the flash spreading.
this.add({ x, y, vx: 0, vy: 0, r: R * 0.12, grow: R * 1.8, life: 0.2, max: 0.2, kind: 'flash', tone: 0 });
this.add({ x, y, vx: 0, vy: 0, r: R * 0.08, grow: R * 0.9, life: 0.38, max: 0.38, kind: 'flash', tone: 0 });
// Fragments: streaks racing out, and little spurts where they strike.
for (let i = 0; i < 160; i++) {
const a = Math.random() * Math.PI * 2;
const sp = R * range(7, 13);
this.sparks.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: range(0.08, 0.28), max: 0.28 });
}
for (let i = 0; i < 130; i++) {
const a = Math.random() * Math.PI * 2;
const d = R * (0.55 + Math.pow(Math.random(), 0.7) * 2.1);
const fx = x + Math.cos(a) * d;
const fy = y + Math.sin(a) * d;
const max = range(0.5, 1.1);
this.add({ x: fx, y: fy, vx: Math.cos(a) * 8, vy: Math.sin(a) * 8, r: range(0.8, 1.6), grow: range(3, 6), life: max, max, kind: wet ? 'water' : 'dust', tone: 0, rgb: soil, delay: d / (R * 12) });
}
if (this.sparks.length > 1600) this.sparks.splice(0, this.sparks.length - 1600);
if (wet) {
// A white column of water instead of an earth cone.
for (let i = 0; i < 40 * s; i++) {
const a = Math.random() * Math.PI * 2;
const d = range(0, R * 0.5);
const max = range(2, 4);
this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: Math.cos(a) * range(10, 40), vy: Math.sin(a) * range(10, 40), r: range(5, 9) * s, grow: range(8, 14) * s, life: max, max, kind: 'water', tone: 0, delay: range(0, 0.4) });
}
} else {
// The ejecta curtain: clods flung up and out at a steep angle...
const clods = earthy ? ['#4a3824', '#5d4630', '#3a2c1c', '#6b543a'] : ['#2b2b2e', '#55524a', '#8a857b'];
for (let i = 0; i < 46 * s; i++) this.part('chunk', x, y, R * 1.4, { x: 0, y: 0 }, clods[i % clods.length], range(140, 260), range(1, 2.6), range(1, 2.2), Math.random() < 0.4 ? 'tri' : 'rect');
// ...raining back down in a ring around the crater.
for (let i = 0; i < 40; i++) {
const a = (i / 40) * Math.PI * 2 + range(-0.06, 0.06);
const d = R * range(0.85, 1.55);
const max = range(2.4, 4);
this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: Math.cos(a) * range(10, 25), vy: Math.sin(a) * range(10, 25), r: range(3, 5) * s, grow: range(5, 9) * s, life: max, max, kind: 'dust', tone: 0, rgb: soil, delay: range(0.6, 1.3) });
}
// The column slumps: a low, heavy skirt of dust rolls out long after the bang.
for (let i = 0; i < 34; i++) {
const a = Math.random() * Math.PI * 2;
const max = range(6, 10);
const sp = range(25, 55);
this.add({ x: x + Math.cos(a) * R * 0.25, y: y + Math.sin(a) * R * 0.25, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, r: range(7, 11) * s, grow: range(7, 11) * s, life: max, max, kind: 'dust', tone: 0, rgb: soil, delay: range(1.6, 3) });
}
}
if (humid) this.rings.push({ x, y, r: R * 0.25, speed: R * 7, life: 0.3, max: 0.3, width: R * 0.3, vapor: true });
// Long after the bang a haze of dust and smoke hangs over the whole blast area.
this.haze(x, y, R * 1.3, soil, Math.round(10 + s * 5));
for (let i = 0; i < 16 * s; i++) {
const a = Math.random() * Math.PI * 2;
const d = Math.sqrt(Math.random()) * R * 0.6;
const max = range(9, 16);
this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: WIND.x * range(0.8, 1.6), vy: WIND.y * range(0.8, 1.6), r: range(8, 14), grow: range(4, 7), life: max, max, kind: 'smoke', tone: range(30, 60), delay: range(0.8, 2.5) });
}
}
/** A puff of dirt kicked forward where a round strikes soft ground. */
dustSpurt(x: number, y: number, angle: number): void {
const max = range(0.6, 1.1);
const sp = range(20, 45);
this.add({ x, y, vx: Math.cos(angle) * sp, vy: Math.sin(angle) * sp, r: range(1.2, 2), grow: range(4, 7), life: max, max, kind: 'dust', tone: 0, rgb: [112, 88, 60] });
}
/** What a blast throws out, by what it hit. */
private ejecta(x: number, y: number, s: number, kind: Ground): void {
const up = (n: number, rgb: [number, number, number], speed: number, grow: number, life: number) => {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const sp = range(0.3, 1) * speed * Math.sqrt(s);
const max = range(0.7, 1.3) * life;
this.add({ x: x + range(-4, 4) * s, y: y + range(-4, 4) * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - range(10, 40) * s, r: range(3, 6) * s, grow: grow * s, life: max, max, kind: 'dust', tone: 0, rgb });
}
};
const chunks = (n: number, colors: string[], speed: number, kindOf: PartKind = 'chunk', w: [number, number] = [1.2, 3.6]) => {
for (let i = 0; i < n; i++) this.part(kindOf, x, y, speed * Math.sqrt(s), { x: 0, y: 0 }, colors[i % colors.length], speed, range(w[0], w[1]), range(1, 2.6), Math.random() < 0.35 ? 'tri' : 'rect', kindOf === 'chunk' && Math.random() < 0.15 ? range(2, 5) : 0);
};
switch (kind) {
case 'earth':
case 'grass':
// A column of soil erupting, clods raining back down, torn turf on grass.
up(36 * s, [104, 80, 54], 120, 10, 2.4);
up(14 * s, [140, 116, 84], 60, 14, 3.4);
chunks(26 * s, ['#4a3824', '#5d4630', '#3a2c1c'], 190);
if (kind === 'grass') chunks(10 * s, ['#4d7a35', '#3f6a2c'], 150, 'chunk', [1, 2.4]);
break;
case 'tarmac':
up(14 * s, [118, 116, 112], 90, 9, 2);
chunks(18 * s, ['#2b2b2e', '#3a3a3e', '#55524a'], 180);
break;
case 'concrete':
case 'stone':
up(22 * s, [168, 164, 156], 100, 11, 2.8);
chunks(20 * s, ['#9e978a', '#6f6a62', '#b8b2a6'], 170, 'chunk', [1.5, 4]);
break;
case 'wood':
up(10 * s, [120, 98, 72], 80, 9, 2);
chunks(22 * s, ['#8a6a44', '#a0703c', '#5d4037'], 180, 'panel', [2, 5]);
break;
case 'metal':
chunks(16 * s, ['#8d9096', '#c9ccd1', '#1b1b1f'], 200, 'panel', [1.5, 4]);
break;
case 'water':
// A geyser of white water, a ring of spray and a cloud of steam.
for (let i = 0; i < 50 * s; i++) {
const a = Math.random() * Math.PI * 2;
const sp = range(10, 110) * Math.sqrt(s);
const max = range(0.8, 1.8);
this.add({ x: x + range(-3, 3) * s, y: y + range(-3, 3) * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - range(10, 50), r: range(3, 6) * s, grow: range(6, 12) * s, life: max, max, kind: 'water', tone: 0 });
}
for (let i = 0; i < 14 * s; i++) this.steam(x + range(-10, 10) * s, y + range(-10, 10) * s, 1.5 + s);
break;
}
}
/**
* Grass and brush fires creep outward: each healthy fire may light the ground beside
* it (wherever `burnable` says there's something to burn), so a blaze in a field or a
* row of yards keeps growing until it hits a road, water, or the fire crews.
*/
spreadGroundFires(dt: number, burnable: (x: number, y: number) => boolean): void {
if (this.fires.length >= MAX_FIRES) return;
const fresh: GroundFire[] = [];
for (const f of this.fires) {
if (f.life < f.max * 0.35 || Math.random() > dt * 0.5) continue;
const a = Math.random() * Math.PI * 2;
const d = f.r + range(5, 10);
const x = f.x + Math.cos(a) * d;
const y = f.y + Math.sin(a) * d;
if (!burnable(x, y) || this.fires.some((o) => Math.abs(o.x - x) < 6 && Math.abs(o.y - y) < 6)) continue;
const life = range(10, 20);
fresh.push({ x, y, r: range(5, 8), life, max: life });
if (this.fires.length + fresh.length >= MAX_FIRES) break;
}
this.fires.push(...fresh);
}
/** A wide charred blast mark around a big explosion, fading over several minutes. */
blastMark(x: number, y: number, r: number): void {
this.burns.push({ x, y, r, born: this.time });
if (this.burns.length > 40) this.burns.shift();
}
/** Flames licking out of a burning building or tree (call every frame it burns, while in view). */
flames(x: number, y: number, w: number, d: number, angle: number, dt: number, intensity: number): void {
const c = Math.cos(angle);
const sn = Math.sin(angle);
const rate = Math.min(28, 4 + (w * d) / 60) * intensity;
for (let n = Math.floor(rate * dt + Math.random()); n > 0; n--) {
const u = range(-0.45, 0.45) * w;
const v = range(-0.45, 0.45) * d;
const px = x + c * u - sn * v;
const py = y + sn * u + c * v;
const max = range(0.5, 1.1);
this.add({ x: px, y: py, vx: range(-6, 6) + 6, vy: range(-6, 6) - 10, r: range(2, 4) * Math.min(1.6, 0.6 + w / 30), grow: 5, life: max, max, kind: 'blast', tone: 0 });
// Thick smoke rolling off the blaze and away downwind.
if (Math.random() < 0.75) this.puff(px, py, true, false, range(2.4, 4.8));
// Embers drifting up out of the blaze.
if (Math.random() < 0.35) this.sparks.push({ x: px, y: py, vx: range(-12, 12) + 8, vy: range(-30, -8), life: range(0.8, 1.8), max: 1.8 });
}
}
/** A body sliding along the road leaves a red smear. */
smear(x1: number, y1: number, x2: number, y2: number): void {
this.skids.push({ x1, y1, x2, y2, w: 1.9, born: this.time, blood: true });
if (this.skids.length > MAX_SKIDS) this.skids.splice(0, this.skids.length - MAX_SKIDS);
}
/** Every light source the effects own: fires, fireballs and flashes. */
glows(view: View): Glow[] {
const out: Glow[] = [];
const vis = (x: number, y: number) => x > view.x - 60 && x < view.x + view.w + 60 && y > view.y - 60 && y < view.y + view.h + 60;
for (const f of this.fires) if (vis(f.x, f.y)) out.push({ x: f.x, y: f.y, r: f.r * 4 * (0.8 + Math.random() * 0.3), color: 'rgba(255, 140, 40, 0.5)' });
for (const r of this.rings) if (vis(r.x, r.y)) out.push({ x: r.x, y: r.y, r: r.r * 2.5, color: 'rgba(255, 220, 160, 0.6)' });
let n = 0;
for (const p of this.puffs) {
if (p.kind !== 'fire' || n > 40 || !vis(p.x, p.y)) continue;
n++;
out.push({ x: p.x, y: p.y, r: p.r * 4, color: 'rgba(255, 120, 30, 0.35)' });
}
for (const e of this.emitters) if (e.kind !== 'geyser' && vis(e.x, e.y)) out.push({ x: e.x, y: e.y, r: e.kind === 'gas' ? 70 : 40, color: e.kind === 'gas' ? 'rgba(255, 140, 40, 0.6)' : 'rgba(170, 210, 255, 0.6)' });
return out;
}
private part(kind: PartKind, x: number, y: number, speed: number, dir: Vec, color: string, lift: number, w: number, h: number, shape: Part['shape'] = 'rect', burning = 0): void {
const a = Math.random() * Math.PI * 2;
this.parts.push({
shape,
burning,
kind,
x,
y,
z: 0.5,
vx: Math.cos(a) * speed * range(0.3, 1) + dir.x * speed * 0.8,
vy: Math.sin(a) * speed * range(0.3, 1) + dir.y * speed * 0.8,
vz: lift * range(0.6, 1.2),
a: Math.random() * Math.PI,
va: range(-20, 20),
w,
h,
color,
born: this.time,
});
}
/**
* A collision. `intensity` comes from closing speed: ~0.2 is a scuff, 0.5 a solid
* hit, 1 a wreck, 2 a high-speed disaster. Everything scales with it.
*/
crash(x: number, y: number, intensity: number, colors: string[], dir: Vec = { x: 0, y: 0 }, opts: CrashOptions = {}): void {
const k = Math.min(3, Math.max(0.1, intensity));
const mat = opts.material ?? 'car';
// Every crash rolls its own dice so no two look alike, and the drama climbs
// steeply with speed.
const drama = 1 + Math.pow(Math.max(0, k - 0.5), 1.5) * 1.6;
const vary = () => range(0.55, 1.5) * drama;
const burnShare = Math.max(0, (k - 1.1) * 0.45);
const speed = (40 + k * 110) * vary();
const sparkBias = mat === 'barrier' ? 2.2 : mat === 'concrete' ? 0.7 : mat === 'truck' ? 1.4 : 1;
const sparkCount = (6 + k * 40) * sparkBias * vary();
for (let i = 0; i < sparkCount; i++) {
const a = Math.random() * Math.PI * 2;
const s = range(60, 220) * (0.5 + k * 0.6);
// Barrier scrapes fan sparks along the rail instead of in a ring.
const along = mat === 'barrier' ? 1.6 : 0.8;
this.sparks.push({ x, y, vx: Math.cos(a) * s * (mat === 'barrier' ? 0.4 : 1) + dir.x * 80 * k * along, vy: Math.sin(a) * s * (mat === 'barrier' ? 0.4 : 1) + dir.y * 80 * k * along, life: range(0.15, 0.6), max: 0.6 });
}
if (k > 0.3) this.rings.push({ x, y, r: 3, speed: 90 * vary(), life: 0.25, max: 0.25, width: 2.4 });
if (k > 1.1) this.rings.push({ x, y, r: 8, speed: 260 * Math.min(1.6, k), life: 0.5, max: 0.5, width: 4 });
if (opts.glass ?? k > 0.3) {
for (let i = 0; i < (3 + k * 14) * vary(); i++) this.part('glass', x, y, speed * 1.1, dir, 'rgba(205, 232, 255, 0.9)', 60 + k * 50, range(0.6, 1.5), range(0.5, 1.1));
}
for (let i = 0; i < (1 + k * 6) * vary(); i++) this.part('chunk', x, y, speed, dir, colors[Math.floor(Math.random() * colors.length)], 50 + k * 60, range(1, 2.6), range(0.8, 1.8), 'rect', Math.random() < burnShare ? range(1.5, 4) : 0);
if (k > 0.4 && Math.random() < 0.3 + k * 0.4) this.part('bumper', x, y, speed * 0.8, dir, Math.random() < 0.5 ? '#1c1c20' : colors[0], 60 + k * 70, range(6, 9), 1.4);
const panels = (k > 0.6 ? Math.floor(k * range(0.6, 2.4)) : 0) + (opts.panels ?? 0);
for (let i = 0; i < panels; i++) {
this.part(Math.random() < 0.5 ? 'door' : 'panel', x, y, speed * 0.9, dir, colors[i % colors.length], 80 + k * 80, range(4, 7), range(3, 5), 'rect', Math.random() < burnShare ? range(2, 5) : 0);
}
for (let i = 0; i < (opts.wheels ?? 0); i++) this.part('wheel', x, y, speed * 1.2, dir, '#141416', 90 + k * 90, 3.6, 1.8);
if (k > 0.7 && Math.random() < 0.5) this.part('hubcap', x, y, speed * 1.3, dir, '#cfd3d8', 70 + k * 60, 2.4, 2.4);
// Concrete throws grit and a dust cloud.
if (mat === 'concrete' || mat === 'shore') {
for (let i = 0; i < 3 + k * 8; i++) this.part('chunk', x, y, speed * 0.6, { x: -dir.x, y: -dir.y }, Math.random() < 0.5 ? '#9e978a' : '#7d776d', 40 + k * 40, range(0.8, 2), range(0.8, 1.6));
for (let i = 0; i < 2 + k * 6; i++) this.dust(x + range(-4, 4), y + range(-4, 4), 1 + k);
}
if (mat === 'wood') {
for (let i = 0; i < 4 + k * 10; i++) this.part('chunk', x, y, speed * 0.7, { x: -dir.x, y: -dir.y }, Math.random() < 0.5 ? '#7a5532' : '#a07a4c', 40 + k * 50, range(2, 4), range(0.4, 0.8));
for (let i = 0; i < 6 + k * 14; i++) this.part('chunk', x + range(-8, 8), y + range(-8, 8), range(10, 40), { x: 0, y: 0 }, Math.random() < 0.5 ? '#4f8a3e' : '#6db454', range(30, 80), range(1, 1.8), range(0.8, 1.4), 'round');
}
this.flavorDebris(x, y, k * drama, speed, dir, opts.flavor ?? '', colors[0]);
if (this.parts.length > MAX_PARTS) this.parts.splice(0, this.parts.length - MAX_PARTS);
// Smoke only when the engine took it.
if (opts.smoke === 'steam') for (let i = 0; i < 3 + k * 5; i++) this.steam(x, y, 1 + k * 0.5);
if (opts.smoke === 'black') for (let i = 0; i < 4 + k * 10; i++) this.puff(x + range(-4, 4) * k, y + range(-4, 4) * k, true, false, range(1.4, 2.6) * (0.7 + k * 0.4));
if (k > 1.15 && Math.random() < 0.5 + (k - 1.15)) for (let i = 0; i < k * 9; i++) this.puff(x + range(-6, 6), y + range(-6, 6), false, true, range(1.3, 2.4));
}
/** Signature debris for each kind of vehicle. */
private flavorDebris(x: number, y: number, k: number, speed: number, dir: Vec, flavor: string, color: string): void {
const n = (base: number) => Math.round(base * Math.min(3, 0.4 + k) * range(0.6, 1.3));
const spray = (count: number, shape: Part['shape'], colors: string[], w: [number, number], h: [number, number], lift = 70) => {
for (let i = 0; i < count; i++) this.part('chunk', x, y, speed * range(0.6, 1.2), dir, colors[i % colors.length], lift + k * 60, range(w[0], w[1]), range(h[0], h[1]), shape);
};
switch (flavor) {
case 'icecream':
spray(n(12), 'round', ['#ffd1e6', '#fff3c4', '#c8f0d8', '#d7b48a', '#8a5a3c'], [1.6, 2.6], [1.6, 2.6], 90);
spray(n(4), 'tri', ['#d9a75b'], [2, 3], [1.2, 1.6]);
break;
case 'police':
spray(n(5), 'round', ['#ff2d2d', '#1e4bff'], [1, 1.6], [1, 1.6], 100);
spray(n(2), 'rect', ['#f4f4f4', '#15161c'], [4, 6], [2.5, 4]);
break;
case 'bus':
spray(n(20), 'rect', ['rgba(205, 232, 255, 0.9)'], [0.8, 1.6], [0.6, 1.1], 90);
spray(n(6), 'rect', ['#3b4a6b', '#2c3650'], [2.4, 3.4], [2, 2.6]);
break;
case 'pickup':
spray(n(6), 'rect', Math.random() < 0.5 ? ['#e2c25a', '#d4b04a'] : ['#9a6b3c', '#8a5b2c'], [2.4, 4], [2, 3.2], 80);
break;
case 'van':
spray(n(9), 'rect', ['#c8a26a', '#b88f55', '#d8b47a'], [2, 3.6], [2, 3.2], 80);
break;
case 'limo':
spray(n(6), 'rect', ['#2f6b3a', '#1f4d2a'], [0.6, 0.9], [2, 2.6], 90);
spray(n(24), 'rect', ['#ff4fa3', '#ffd400', '#3fd0ff', '#7dff6a', '#ffffff'], [0.5, 0.9], [0.5, 0.9], 120);
break;
case 'beater':
spray(n(10), 'rect', ['#8a4a2a', '#6b3a22', '#a2673a'], [0.8, 1.8], [0.6, 1.2], 60);
break;
case 'sports':
spray(n(8), 'tri', ['#1b1b1f', '#33333a'], [1.6, 3], [1, 2], 90);
break;
case 'taxi':
spray(1, 'rect', ['#fff7c2'], [3, 3.4], [2.6, 3]);
break;
case 'compact':
spray(n(8), 'round', [color, '#2b2b2f'], [0.8, 1.6], [0.8, 1.6], 70);
break;
case 'motorcycle':
spray(n(4), 'rect', ['#9a9ca2', '#1b1b1f'], [1.6, 3], [0.6, 1]);
break;
}
}
/** Radiator steam: bright white, quick to fade. */
steam(x: number, y: number, size = 1): void {
if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
const max = range(0.5, 1.1);
this.puffs.push({ x: x + range(-2, 2), y: y + range(-2, 2), vx: range(-12, 12), vy: range(-14, 4), r: 1.4 * size, grow: 9, life: max, max, kind: 'steam', tone: 245 });
}
/** Brown-grey dust kicked off concrete or dirt. */
dust(x: number, y: number, size = 1): void {
if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
const max = range(0.8, 1.6);
this.puffs.push({ x, y, vx: range(-15, 15), vy: range(-15, 15), r: 2 * size, grow: 8 * size, life: max, max, kind: 'dust', tone: 160 });
}
/** A red splat on the road where a pedestrian was hit, with flying droplets. */
splat(x: number, y: number, size: number, vx = 0, vy = 0): void {
const blobs = Array.from({ length: 5 + Math.floor(size * 6) }, () => {
const a = Math.random() * Math.PI * 2;
const d = Math.pow(Math.random(), 0.7) * size * 4.5;
return { dx: Math.cos(a) * d + vx * 0.02 * Math.random(), dy: Math.sin(a) * d + vy * 0.02 * Math.random(), r: range(0.6, 1.6) * (1 + size * 0.4) };
});
this.splats.push({ x, y, blobs, born: this.time });
if (this.splats.length > 160) this.splats.shift();
for (let i = 0; i < 10 + size * 14; i++) {
const a = Math.random() * Math.PI * 2;
const s = range(20, 90) * size;
this.drops.push({ x, y, vx: Math.cos(a) * s + vx * 0.4, vy: Math.sin(a) * s + vy * 0.4, life: range(0.2, 0.5), max: 0.5 });
}
}
/** A fireball: the truck or a car going up. */
explosion(x: number, y: number, size = 1): void {
this.scorch(x, y, 4 + size * 6, true);
this.rings.push({ x, y, r: 6, speed: 340 * size, life: 0.6, max: 0.6, width: 6 });
this.rings.push({ x, y, r: 4, speed: 140 * size, life: 0.4, max: 0.4, width: 3 });
for (let i = 0; i < 40 * size; i++) this.puff(x + range(-10, 10) * size, y + range(-10, 10) * size, false, true, range(2, 4) * size);
for (let i = 0; i < 30 * size; i++) this.puff(x + range(-14, 14) * size, y + range(-14, 14) * size, true, false, range(2.5, 4.5) * size);
for (let i = 0; i < 70 * size; i++) {
const a = Math.random() * Math.PI * 2;
const s = range(80, 320);
this.sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: range(0.3, 0.9), max: 0.9 });
}
for (let i = 0; i < 14 * size; i++) this.part('chunk', x, y, 160, { x: 0, y: 0 }, i % 2 ? '#2a2826' : '#5a5650', 160, range(1.5, 3.5), range(1, 2.5));
}
puff(x: number, y: number, dark = false, fire = false, size = 1): void {
if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
// Dark smoke hangs around; grey puffs (exhaust, steam) clear quickly.
const max = fire ? range(0.35, 0.7) : dark ? range(2.6, 5.5) * Math.min(1.6, 0.7 + size * 0.2) : range(1.2, 2.6);
this.puffs.push({
x,
y,
vx: range(-10, 10) + 7,
vy: range(-10, 10) - 6,
r: (fire ? 2 : 2.4) * size,
grow: fire ? 5 : range(5, 10) * Math.min(2, size),
life: max,
max,
kind: fire ? 'fire' : 'smoke',
tone: dark ? range(30, 60) : range(110, 150),
});
}
/** White tyre smoke from a sliding or spinning wheel. */
tyreSmoke(x: number, y: number, amount = 1): void {
if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
const max = range(0.6, 1.2);
this.puffs.push({ x, y, vx: range(-6, 6), vy: range(-6, 6), r: 1.6 * amount, grow: 7 * amount, life: max, max, kind: 'tyre', tone: 225 });
}
/** A shower of sparks from metal dragging on asphalt. */
scrape(x: number, y: number, vx: number, vy: number): void {
for (let i = 0; i < 2; i++) {
this.sparks.push({ x, y, vx: -vx * range(0.2, 0.6) + range(-30, 30), vy: -vy * range(0.2, 0.6) + range(-30, 30), life: range(0.1, 0.3), max: 0.3 });
}
}
skid(x1: number, y1: number, x2: number, y2: number, w: number): void {
this.skids.push({ x1, y1, x2, y2, w, born: this.time });
if (this.skids.length > MAX_SKIDS) this.skids.splice(0, this.skids.length - MAX_SKIDS);
}
/** Vehicles shove debris they drive over. */
kick(x: number, y: number, r: number, vx: number, vy: number): void {
for (const p of this.parts) {
if (p.z > 2) continue;
const dx = p.x - x;
const dy = p.y - y;
if (dx * dx + dy * dy > r * r) continue;
const d = Math.hypot(dx, dy) || 1;
p.vx += vx * 0.6 + (dx / d) * 20;
p.vy += vy * 0.6 + (dy / d) * 20;
if (Math.hypot(vx, vy) > 60) {
p.vz = range(20, 60);
p.va = range(-15, 15);
}
}
}
update(dt: number): void {
this.time += dt;
for (const c of this.columns) {
c.life -= dt;
c.timer -= dt;
if (c.timer > 0) continue;
c.timer = 0.06;
const k = Math.min(1, c.life / 8);
this.puff(c.x + range(-7, 7), c.y + range(-7, 7), true, false, range(3, 5.5) * (0.55 + k * 0.6));
if (Math.random() < 0.3 * k) this.puff(c.x + range(-4, 4), c.y + range(-4, 4), false, true, range(1.5, 2.5));
}
this.columns = this.columns.filter((c) => c.life > 0);
for (const e of this.emitters) {
e.life -= dt;
e.timer -= dt;
if (e.timer > 0) continue;
if (e.kind === 'gas') {
// A jet of burning gas, licking downwind, with a pall of smoke over it.
e.timer = 0.04;
e.angle += range(-0.15, 0.15);
const sp = range(40, 90);
const max = range(0.4, 0.8);
this.add({ x: e.x, y: e.y, vx: Math.cos(e.angle) * sp + WIND.x, vy: Math.sin(e.angle) * sp + WIND.y, r: range(3, 5), grow: 9, life: max, max, kind: 'blast', tone: 0 });
if (Math.random() < 0.3) this.puff(e.x + Math.cos(e.angle) * 12, e.y + Math.sin(e.angle) * 12, true, false, range(2, 4));
} else if (e.kind === 'geyser') {
e.timer = 0.05;
for (let k = 0; k < 2; k++) {
const a = Math.random() * Math.PI * 2;
const max = range(0.6, 1.2);
this.add({ x: e.x, y: e.y, vx: Math.cos(a) * range(8, 30), vy: Math.sin(a) * range(8, 30) - 10, r: range(2, 3.5), grow: range(4, 7), life: max, max, kind: 'water', tone: 0 });
}
} else {
// Arcing: irregular blue-white flashes and spark showers.
e.timer = range(0.08, 0.5);
this.add({ x: e.x + range(-10, 10), y: e.y + range(-10, 10), vx: 0, vy: 0, r: range(3, 6), grow: 40, life: 0.08, max: 0.08, kind: 'flash', tone: 0 });
for (let k = 0; k < 14; k++) {
const a = Math.random() * Math.PI * 2;
const sp = range(60, 200);
this.sparks.push({ x: e.x, y: e.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: range(0.2, 0.6), max: 0.6 });
}
}
}
this.emitters = this.emitters.filter((e) => e.life > 0);
for (const s of this.sparks) {
s.life -= dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.vx *= 0.9;
s.vy *= 0.9;
}
this.sparks = this.sparks.filter((s) => s.life > 0);
if (this.sparks.length > 900) this.sparks.splice(0, this.sparks.length - 900);
for (const d of this.drops) {
d.life -= dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
d.vx *= 0.9;
d.vy *= 0.9;
if (d.life <= 0 && Math.random() < 0.35) {
this.splats.push({ x: d.x, y: d.y, blobs: [{ dx: 0, dy: 0, r: range(0.4, 0.9) }], born: this.time });
}
}
this.drops = this.drops.filter((d) => d.life > 0);
for (const f of this.fires) {
f.life -= dt;
if (Math.random() < dt * 14) this.puff(f.x + range(-f.r, f.r) * 0.6, f.y + range(-f.r, f.r) * 0.6, false, true, range(1, 2) * (f.life / f.max + 0.4));
if (Math.random() < dt * 5) this.puff(f.x, f.y, true, false, 1.5);
}
for (const f of this.fires) if (f.life <= 0 && f.r > 6) this.scorch(f.x, f.y, f.r, false);
this.fires = this.fires.filter((f) => f.life > 0);
if (this.splats.length > 400) this.splats.splice(0, this.splats.length - 400);
for (const p of this.puffs) {
if (p.delay && p.delay > 0) {
p.delay -= dt;
continue;
}
p.life -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.r += p.grow * dt;
if (p.kind === 'smoke' || p.kind === 'steam') {
// Smoke climbs, spreads and is carried off downwind.
const k = Math.min(1, dt * 0.7);
p.vx += (WIND.x - p.vx) * k;
p.vy += (WIND.y - p.vy) * k;
p.grow *= Math.exp(-0.15 * dt);
} else if (p.kind === 'dust') {
// Dust is heavy: it billows out fast, then hangs low and settles.
const drag = Math.exp(-2.6 * dt);
p.vx = p.vx * drag + WIND.x * 0.15 * dt;
p.vy = p.vy * drag + WIND.y * 0.15 * dt;
p.grow *= Math.exp(-0.9 * dt);
} else {
p.vx *= 0.98;
p.vy *= 0.98;
}
}
this.puffs = this.puffs.filter((p) => p.life > 0);
for (const r of this.rings) {
r.life -= dt;
r.r += r.speed * dt;
}
this.rings = this.rings.filter((r) => r.life > 0);
for (const p of this.parts) {
const airborne = p.z > 0 || p.vz > 0;
if (airborne) {
p.vz -= 340 * dt;
p.z += p.vz * dt;
if (p.z <= 0) {
p.z = 0;
p.vz = Math.abs(p.vz) > 40 ? -p.vz * 0.35 : 0;
p.vx *= 0.7;
p.vy *= 0.7;
p.va *= p.kind === 'wheel' ? 0.9 : 0.5;
}
}
// Wheels roll on; everything else scrapes to a stop.
const friction = airborne ? 0.996 : Math.exp((p.kind === 'wheel' ? -0.6 : p.kind === 'hubcap' ? -1.2 : -4) * dt);
p.vx *= friction;
p.vy *= friction;
if (p.burning > 0) {
p.burning -= dt;
if (Math.random() < dt * 30) this.puff(p.x, p.y, false, true, 0.7);
if (Math.random() < dt * 12) this.puff(p.x, p.y, true, false, 0.8);
}
if (p.kind === 'wheel' && !airborne && Math.hypot(p.vx, p.vy) > 5) p.a = Math.atan2(p.vy, p.vx);
else p.a += p.va * dt;
if (!airborne) p.va *= Math.exp(-3 * dt);
const nx = p.x + p.vx * dt;
const ny = p.y + p.vy * dt;
const push = this.solid && p.z < 4 && Math.hypot(p.vx, p.vy) > 1 ? this.solid(nx, ny) : null;
if (push && (push.x || push.y)) {
const l = Math.hypot(push.x, push.y);
const into = (p.vx * push.x + p.vy * push.y) / l;
p.vx -= (push.x / l) * into * 1.5;
p.vy -= (push.y / l) * into * 1.5;
p.x = nx + push.x;
p.y = ny + push.y;
} else {
p.x = nx;
p.y = ny;
}
}
if (this.skids.length && this.time - this.skids[0].born > SKID_FADE) {
this.skids = this.skids.filter((s) => this.time - s.born < SKID_FADE);
}
}
/** Skid marks and debris lying on the road (under vehicles). */
drawGround(ctx: CanvasRenderingContext2D, view: View): void {
const buckets: Skid[][] = [[], [], [], []];
const blood: Skid[] = [];
for (const s of this.skids) {
if (s.x1 < view.x - 10 || s.x1 > view.x + view.w + 10 || s.y1 < view.y - 10 || s.y1 > view.y + view.h + 10) continue;
if (s.blood) {
blood.push(s);
continue;
}
buckets[Math.min(3, Math.floor(((this.time - s.born) / SKID_FADE) * 4))].push(s);
}
ctx.lineCap = 'round';
buckets.forEach((list, i) => {
if (!list.length) return;
ctx.strokeStyle = `rgba(18, 17, 20, ${0.55 - i * 0.12})`;
for (const w of [1.3, 1.9]) {
ctx.lineWidth = w;
ctx.beginPath();
for (const s of list) {
if (s.w !== w) continue;
ctx.moveTo(s.x1, s.y1);
ctx.lineTo(s.x2, s.y2);
}
ctx.stroke();
}
});
if (blood.length) {
ctx.strokeStyle = 'rgba(150, 12, 18, 0.75)';
ctx.lineWidth = 2.2;
ctx.beginPath();
for (const s of blood) {
ctx.moveTo(s.x1, s.y1);
ctx.lineTo(s.x2, s.y2);
}
ctx.stroke();
}
ctx.lineCap = 'butt';
const near = (x: number, y: number, m: number) => x > view.x - m && x < view.x + view.w + m && y > view.y - m && y < view.y + view.h + m;
for (const b of this.burns) {
if (!near(b.x, b.y, b.r)) continue;
const fade = Math.max(0, 1 - (this.time - b.born) / 420);
if (fade <= 0) continue;
ctx.globalAlpha = 0.75 * fade;
ctx.drawImage(this.soft(22, 18, 16, false), b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);
ctx.globalAlpha = 1;
}
for (const p of this.patches) {
if (!near(p.x, p.y, 30)) continue;
const age = Math.min(1, (this.time - p.born) / 400);
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.fillStyle = `rgba(28, 28, 32, ${0.75 * (1 - age * 0.8)})`;
ctx.fillRect(-p.r, -p.r * 0.8, p.r * 2, p.r * 1.6);
ctx.strokeStyle = `rgba(150, 150, 150, ${0.35 * (1 - age)})`;
ctx.lineWidth = 0.4;
ctx.strokeRect(-p.r, -p.r * 0.8, p.r * 2, p.r * 1.6);
ctx.restore();
}
// Cannon gouges: a dark pit with a lighter lip of torn earth or broken tarmac.
for (const p of this.pits) {
if (!near(p.x, p.y, 5)) continue;
const fade = Math.max(0, 1 - (this.time - p.born) / 360);
if (fade <= 0) continue;
ctx.globalAlpha = fade;
ctx.fillStyle = p.earth ? '#6b5236' : '#7c7a74';
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.r * 2, p.r * 1.25, p.angle, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = p.earth ? '#2e2216' : '#1d1c1c';
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.r, p.r * 0.7, p.angle, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
for (const c of this.craters) {
if (!c.big || !near(c.x, c.y, c.r * 2.4)) continue;
// Ejecta: dark streaks of soil and debris flung out radially.
ctx.strokeStyle = c.earth ? 'rgba(62, 44, 26, 0.6)' : 'rgba(30, 30, 32, 0.5)';
ctx.lineCap = 'round';
for (let i = 0; i < 22; i++) {
const a = c.seed * 3 + i * 0.29 + Math.sin(i * 7.1 + c.seed) * 0.15;
const len = c.r * (1.4 + ((Math.sin(i * 12.9898 + c.seed) + 1) / 2) * 1);
ctx.lineWidth = 1.2 + ((i * 7) % 5) * 0.5;
ctx.beginPath();
ctx.moveTo(c.x + Math.cos(a) * c.r * 0.9, c.y + Math.sin(a) * c.r * 0.9);
ctx.lineTo(c.x + Math.cos(a) * len, c.y + Math.sin(a) * len);
ctx.stroke();
}
ctx.lineCap = 'butt';
// The raised rim, then the pit falling away into darkness.
ctx.fillStyle = c.earth ? '#6a5236' : '#4a4846';
ctx.beginPath();
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const rr = c.r * (1.05 + 0.12 * Math.sin(c.seed + i * 2.1));
if (i === 0) ctx.moveTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
else ctx.lineTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
}
ctx.closePath();
ctx.fill();
ctx.drawImage(this.soft(14, 11, 10, false), c.x - c.r * 1.05, c.y - c.r * 1.05, c.r * 2.1, c.r * 2.1);
ctx.fillStyle = 'rgba(10, 8, 8, 0.85)';
ctx.beginPath();
ctx.arc(c.x + c.r * 0.08, c.y + c.r * 0.1, c.r * 0.45, 0, Math.PI * 2);
ctx.fill();
}
for (const c of this.craters) {
if (c.big || !near(c.x, c.y, 30)) continue;
ctx.fillStyle = c.earth ? 'rgba(58, 40, 22, 0.6)' : c.hole ? 'rgba(18, 15, 14, 0.55)' : 'rgba(22, 18, 16, 0.42)';
ctx.beginPath();
for (let i = 0; i < 9; i++) {
const a = (i / 9) * Math.PI * 2;
const rr = c.r * (0.8 + 0.35 * Math.sin(c.seed + i * 2.3));
if (i === 0) ctx.moveTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
else ctx.lineTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
}
ctx.closePath();
ctx.fill();
if (!c.hole) continue;
ctx.fillStyle = '#1a1716';
ctx.beginPath();
ctx.arc(c.x, c.y, c.r * 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(15, 12, 10, 0.7)';
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = c.seed + i * 1.05;
ctx.moveTo(c.x + Math.cos(a) * c.r * 0.5, c.y + Math.sin(a) * c.r * 0.5);
ctx.lineTo(c.x + Math.cos(a + 0.2) * c.r * 1.4, c.y + Math.sin(a + 0.2) * c.r * 1.4);
}
ctx.stroke();
}
for (const f of this.fires) {
ctx.fillStyle = 'rgba(20, 16, 14, 0.5)';
ctx.beginPath();
ctx.arc(f.x, f.y, f.r * 1.2, 0, Math.PI * 2);
ctx.fill();
}
for (const d of this.decals) {
if (d.x < view.x - 30 || d.x > view.x + view.w + 30 || d.y < view.y - 30 || d.y > view.y + view.h + 30) continue;
ctx.strokeStyle = 'rgba(25, 22, 20, 0.75)';
ctx.lineWidth = 0.6;
ctx.beginPath();
for (const r of d.rays) {
const mx = d.x + Math.cos(r.a) * d.size * r.l * 0.6;
const my = d.y + Math.sin(r.a) * d.size * r.l * 0.6;
ctx.moveTo(d.x, d.y);
ctx.lineTo(mx + Math.sin(r.a) * 1.2, my - Math.cos(r.a) * 1.2);
ctx.lineTo(d.x + Math.cos(r.a) * d.size * r.l * (d.hole ? 1.6 : 1.1), d.y + Math.sin(r.a) * d.size * r.l * (d.hole ? 1.6 : 1.1));
}
ctx.stroke();
if (d.hole) {
ctx.fillStyle = '#1c1a19';
ctx.beginPath();
d.rays.forEach((r, i) => {
const rr = d.size * (0.55 + r.l * 0.3);
const px = d.x + Math.cos(d.angle + (i / d.rays.length) * Math.PI * 2) * rr;
const py = d.y + Math.sin(d.angle + (i / d.rays.length) * Math.PI * 2) * rr;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#8a4a2a';
ctx.lineWidth = 0.35;
ctx.beginPath();
for (let i = 0; i < 4; i++) {
const a = d.angle + i * 0.8;
ctx.moveTo(d.x - Math.cos(a) * d.size * 0.6, d.y - Math.sin(a) * d.size * 0.6);
ctx.lineTo(d.x + Math.cos(a) * d.size * 0.6, d.y + Math.sin(a) * d.size * 0.6);
}
ctx.stroke();
}
}
for (const sp of this.splats) {
if (sp.x < view.x - 20 || sp.x > view.x + view.w + 20 || sp.y < view.y - 20 || sp.y > view.y + view.h + 20) continue;
const age = Math.min(1, (this.time - sp.born) / 20);
ctx.fillStyle = `rgb(${Math.round(170 - age * 70)}, ${Math.round(14 - age * 6)}, ${Math.round(20 - age * 8)})`;
ctx.beginPath();
for (const b of sp.blobs) {
ctx.moveTo(sp.x + b.dx + b.r, sp.y + b.dy);
ctx.arc(sp.x + b.dx, sp.y + b.dy, b.r, 0, Math.PI * 2);
}
ctx.fill();
}
for (const p of this.parts) {
if (p.z > 0.5 || p.x < view.x - 10 || p.x > view.x + view.w + 10 || p.y < view.y - 10 || p.y > view.y + view.h + 10) continue;
this.drawPart(ctx, p, 1);
}
}
/** Airborne parts, smoke, fire, sparks and shockwaves (above vehicles). */
drawAir(ctx: CanvasRenderingContext2D, view: View): void {
const visible = (x: number, y: number) => x > view.x - 40 && x < view.x + view.w + 40 && y > view.y - 40 && y < view.y + view.h + 40;
for (const p of this.parts) {
if (p.z <= 0.5 || !visible(p.x, p.y)) continue;
ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
ctx.beginPath();
ctx.ellipse(p.x + p.z * 0.3, p.y + p.z * 0.45, p.w / 2, p.h / 2 + 0.3, p.a, 0, Math.PI * 2);
ctx.fill();
this.drawPart(ctx, p, 1 + p.z / 50);
}
// Puffs are soft, glowing balls (cached radial sprites) rather than flat discs.
// Flame and flashes add light; smoke, dust and steam are drawn over the top.
for (const p of this.puffs) {
if ((p.delay && p.delay > 0) || !visible(p.x, p.y)) continue;
const t = p.life / p.max;
let r = 0;
let g = 0;
let b = 0;
let a = 0;
let glow = false;
if (p.kind === 'flash') {
[r, g, b, a, glow] = [255, 250, 230, t, true];
} else if (p.kind === 'blast') {
// A ball of flame cooling as it ages: white-hot, yellow, orange, deep red, then soot.
const age = 1 - t;
if (age < 0.1) [r, g, b, a, glow] = [255, 240, 200, 0.85, true];
else if (age < 0.38) [r, g, b, a, glow] = [255, Math.round(200 - (age - 0.1) * 420), Math.round(70 - (age - 0.1) * 200), 0.75, true];
else if (age < 0.6) [r, g, b, a] = [Math.round(220 - (age - 0.38) * 450), Math.round(80 - (age - 0.38) * 220), 22, 0.75];
else [r, g, b, a] = [34, 28, 26, (0.85 * (1 - age)) / 0.4];
} else if (p.kind === 'fire') {
[r, g, b, a, glow] = [255, Math.round(110 + 110 * t), 40, 0.75 * t, true];
} else if (p.kind === 'tyre') {
[r, g, b, a] = [235, 235, 238, 0.4 * t];
} else if (p.kind === 'steam') {
[r, g, b, a] = [250, 252, 255, 0.55 * t];
} else if (p.kind === 'water') {
[r, g, b, a] = [175, 215, 255, 0.75 * t];
} else if (p.kind === 'dust') {
const c = p.rgb ?? [176, 164, 144];
[r, g, b, a] = [c[0], c[1], c[2], 0.6 * Math.min(1, t * 1.4)];
} else {
[r, g, b, a] = [p.tone, p.tone, p.tone + 6, 0.7 * Math.min(1, t * 1.6)];
}
if (a <= 0.01) continue;
ctx.globalCompositeOperation = glow ? 'lighter' : 'source-over';
ctx.globalAlpha = Math.min(1, a);
const size = p.r * 2.6;
ctx.drawImage(this.soft(r, g, b, glow), p.x - size / 2, p.y - size / 2, size, size);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#b5121b';
for (const d of this.drops) {
ctx.beginPath();
ctx.arc(d.x, d.y, 0.7, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (const s of this.sparks) {
const t = s.life / s.max;
ctx.strokeStyle = `rgba(255, ${Math.round(190 + 65 * t)}, ${Math.round(80 + 150 * t)}, ${Math.min(1, t * 1.6)})`;
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(s.x - s.vx * 0.035, s.y - s.vy * 0.035);
ctx.stroke();
}
for (const r of this.rings) {
const t = r.life / r.max;
if (r.vapor) {
ctx.strokeStyle = `rgba(250, 252, 255, ${0.45 * t})`;
ctx.lineWidth = r.width * (0.4 + t);
ctx.beginPath();
ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = `rgba(255, 255, 255, ${0.16 * t})`;
ctx.fill();
continue;
}
ctx.strokeStyle = `rgba(255, 245, 210, ${0.5 * t})`;
ctx.lineWidth = r.width * t + 0.4;
ctx.beginPath();
ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = `rgba(255, 240, 200, ${0.07 * t})`;
ctx.fill();
}
ctx.lineCap = 'butt';
ctx.globalCompositeOperation = 'source-over';
}
private drawPart(ctx: CanvasRenderingContext2D, p: Part, scale: number): void {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.a);
ctx.scale(scale, scale);
const hw = p.w / 2;
const hh = p.h / 2;
switch (p.kind) {
case 'wheel':
ctx.fillStyle = '#141416';
ctx.beginPath();
ctx.roundRect(-hw, -hh, p.w, p.h, 0.7);
ctx.fill();
ctx.fillStyle = '#9a9ca2';
ctx.fillRect(-0.5, -hh - 0.2, 1, p.h + 0.4);
break;
case 'hubcap':
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(0, 0, hw, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#7a7f86';
ctx.beginPath();
ctx.arc(0, 0, hw * 0.4, 0, Math.PI * 2);
ctx.fill();
break;
case 'door':
ctx.fillStyle = p.color;
ctx.fillRect(-hw, -hh, p.w, p.h);
ctx.fillStyle = '#1d2a3c';
ctx.fillRect(-hw + 0.8, -hh + 0.6, p.w * 0.6, p.h * 0.35);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)';
ctx.lineWidth = 0.3;
ctx.strokeRect(-hw, -hh, p.w, p.h);
break;
case 'panel':
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.moveTo(-hw, -hh);
ctx.lineTo(hw * 0.6, -hh * 1.1);
ctx.lineTo(hw, hh * 0.2);
ctx.lineTo(hw * 0.3, hh);
ctx.lineTo(-hw * 0.8, hh * 0.7);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = 0.3;
ctx.stroke();
break;
default:
ctx.fillStyle = p.burning > 0 ? '#ff9a3a' : p.color;
if (p.shape === 'round') {
ctx.beginPath();
ctx.ellipse(0, 0, hw, hh, 0, 0, Math.PI * 2);
ctx.fill();
} else if (p.shape === 'tri') {
ctx.beginPath();
ctx.moveTo(hw, 0);
ctx.lineTo(-hw, -hh);
ctx.lineTo(-hw, hh);
ctx.closePath();
ctx.fill();
} else {
ctx.fillRect(-hw, -hh, p.w, p.h);
}
}
ctx.restore();
}
}
