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 { Controls } from '../input.js';
import { City, DISTRICT_RECTS, MAP_H, MAP_W, type Structure, type Material, SURF_DIRT, SURF_GRASS, SURF_ROAD, SURF_SIDEWALK, SURF_WATER, type District, type Spot } from './city.js';
import { Effects, type Ground } from './effects.js';
import { angleDiff, clamp, poseAt, type Vec } from './geometry.js';
import { MapRenderer, renderMinimap } from './paint.js';
import { Wildlife } from './animals.js';
import { Dispatcher, type Repairable } from './dispatch.js';
import { Helicopters } from './helicopters.js';
import { Mob, type Building } from './mob.js';
import { Missiles } from './missiles.js';
import type { BlastKind } from './audio.js';
import { BOMB_NAMES, Airstrike, type BombKind, type Launch, type Lift } from './airstrike.js';
import { GUN_RECOIL, METERS } from './a10.js';
import { drawCityStatus, drawFlightHud, type CityStatus, type FlightHud } from './hud.js';
import { Civil } from './civil.js';
import { Raider, type RaiderTarget } from './raider.js';
import { Services, type ServiceKind, type ServiceVehicle } from './services.js';
import { Street } from './street.js';
import { Workers } from './workers.js';
import { Pedestrians, type Ped } from './peds.js';
import { boxContact, resolveContact, type Body } from './physics.js';
import { Traffic, inView, type Car, type CrashKind, type View } from './traffic.js';
import { Truck, type SurfaceFeel, type VehicleKind } from './truck.js';
import { drawA10, drawA10Shadow } from './a10-model.js';
import { COLORS, drawCar, drawDozer, drawTruck, makeLook, type CarDamage, type CarLook } from './vehicles.js';
export type Phase = 'title' | 'playing' | 'paused' | 'over';
export type JobKind = 'pickup' | 'deliver';
export type Tone = 'good' | 'info' | 'bad';
interface Job {
kind: JobKind;
spot: Spot;
progress: number;
damageAtStart: number;
distance: number;
}
interface Floater {
x: number;
y: number;
text: string;
color: string;
life: number;
scale?: number;
}
export interface GameOptions {
carCount: number;
rudeness: number;
shake: boolean;
pedCount: number;
animalCount: number;
}
export interface GameEvents {
toast(text: string, tone: Tone): void;
over(): void;
/** Impact intensity scales with closing speed: ~0.15 scuff, 1 wreck, 2+ disaster. */
crash?(intensity: number, kind: CrashKind, pan: number): void;
/** An AI driver honks or shouts near the truck. */
honk?(rude: boolean, pan: number): void;
pickup?(): void;
delivered?(points: number): void;
totaled?(): void;
shift?(gear: number): void;
grind?(): void;
pedHit?(pan: number): void;
/** A wrecked car's alarm near the player (called about twice a second). */
alarm?(pan: number): void;
/** A fuel tank or the truck exploding. */
explosion?(size: number, pan: number): void;
/** A tumbling car's roof or side slamming the road. */
rollHit?(energy: number, pan: number): void;
/** The rig's wheels lift in a corner, and when it goes over. */
tipping?(): void;
/** Signature sound for the kind of vehicle that was hit. */
carFlavor?(model: string, intensity: number, pan: number): void;
animalHit?(kind: string, mass: number, pan: number): void;
stallSmash?(pan: number): void;
/** Music thumping from a party near the player; volume 0..1. */
partyBeat?(pan: number, volume: number): void;
/** A siren from an emergency vehicle near the player; volume 0..1. */
siren?(kind: ServiceKind, pan: number, volume: number): void;
/** The tow crew at work (winch and wrench). */
towWork?(pan: number): void;
/** A vehicle smashing into a building (masonry, not the vehicle). */
structureHit?(impact: number, pan: number): void;
hose?(pan: number): void;
/** A missile's warhead going off. */
missileHit?(pan: number): void;
/** A bomb's blast: the biggest boom there is. */
bigBlast?(pan: number, size: number): void;
/** An explosion or collapse, with how far it is from the listener (map units). */
blastSound?(kind: BlastKind, pan: number, distance: number): void;
/** How loud the fires around the listener roar, 0..1. */
fireLevel?(level: number): void;
/** A big fire crackling and roaring nearby; volume 0..1. */
fire?(volume: number): void;
/** A bomb leaving the rack (a falling whistle). */
bombDrop?(pan: number): void;
/** A missile leaving the launcher. */
missile?(pan: number): void;
/** A helicopter's rotor thumping overhead; volume 0..1. */
rotor?(pan: number, volume: number): void;
/** A crew at work with tools (wrenches, compactor, jackhammer). */
tools?(pan: number): void;
/** A lamp post, hydrant or trash can knocked over. */
knock?(kind: string, pan: number): void;
/** One beat of rave music from a rave near the player. */
raveBeat?(pan: number, volume: number): void;
/** The crash drama tier (1 CRUNCH … 4 APOCALYPSE). */
dramaTier?(tier: number, pan: number): void;
rollover?(): void;
/** The ballute on a high-drag bomb pops open. */
ballute?(): void;
/** A click in the cockpit: weapon selected, or an empty rack. */
click?(): void;
/** Ordnance loaded back aboard. */
rearmed?(): void;
/** The city's air-raid sirens wind up. */
airRaid?(): void;
/** A surface-to-air missile leaves its rail; a missile is tracking the jet (called each frame). */
samLaunch?(pan: number): void;
missileWarning?(on: boolean): void;
/** Anti-aircraft guns hammering away, and their shells hitting the jet. */
aaGuns?(pan: number, distance: number): void;
jetHit?(): void;
/** Flares popping. */
flares?(): void;
/** The jet's been shot down and hits the ground. */
jetDown?(): void;
/** The AI A-10 roaring past overhead (volume 0..1), and its gun heard from the ground. */
flyby?(pan: number, volume: number): void;
brrt?(pan: number, distance: number): void;
}
const NO_INPUT: Controls = { up: false, down: false, left: false, right: false, gun: false, throttleUp: false, throttleDown: false, bomb: false };
const LOAD_RADIUS = 24;
const LOAD_SPEED = 12;
const LOAD_SECONDS = 1.2;
/** Map units to metres for the HUD (the rig is about 18 m long). */
export const METERS_PER_UNIT = 0.33;
const VIEW_SPAN = 300;
/**
* The A-10's camera hangs above the jet looking straight down: the jet, nearer the lens,
* looks GROUND_SPREAD times bigger than the ground. JET_SPAN is how many map units span
* the short side of the screen at the jet's own height.
*/
const JET_SPAN = 440;
const GROUND_SPREAD = 2.2;
const PICKUP = '#4ade80';
const DROP = '#ff8a3d';
const DANGER = '#ff4d5e';
const INSURANCE = 50;
export const DISTRICT_NAMES: Record<District, string> = {
downtown: 'DOWNTOWN',
uptown: 'UPTOWN',
hood: 'THE HOOD',
port: 'THE PORT',
highway: 'THE INTERSTATE',
country: 'THE COUNTRY',
village: 'MILLBROOK',
industrial: 'INDUSTRIAL PARK',
docks: 'THE DOCKS',
factory: 'THE STEELWORKS',
rail: 'THE RAIL YARD',
suburb: 'MAPLE GROVE',
commercial: 'THE STRIP',
military: 'FORT DUNMORE',
rural: 'THE BACKCOUNTRY',
town: 'HARLAN',
};
/**
* How each kind of ground drives. Asphalt grips; a sidewalk is nearly as good but the
* curbs jolt; packed dirt lots are loose, draggy and rutted; grass is slippery, soft and
* lumpy; and the shallows at the shore bog you down with almost no grip.
*/
const FEEL: Record<number, SurfaceFeel> = {
[SURF_ROAD]: { grip: 1, drag: 0, bump: 0 },
[SURF_SIDEWALK]: { grip: 0.92, drag: 0.006, bump: 0.15 },
[SURF_DIRT]: { grip: 0.62, drag: 0.035, bump: 0.6 },
[SURF_GRASS]: { grip: 0.5, drag: 0.055, bump: 0.8 },
[SURF_WATER]: { grip: 0.3, drag: 0.3, bump: 0.4 },
};
const pick = <T>(items: readonly T[]): T => items[Math.floor(Math.random() * items.length)];
const range = (a: number, b: number) => a + Math.random() * (b - a);
export type TowChoice = 'repair' | 'reposition' | 'both';
const MOB = '#b06cff';
const MAX_FAVORS = 3;
const DISPATCH_TOASTS: Partial<Record<ServiceKind | 'medevac' | 'chopper', string>> = {
fire: 'Fire crews responding',
ambulance: 'Ambulance on the way',
police: 'Police responding',
tow: 'Tow truck on the way to clear the wreck',
medevac: 'Medevac helicopter inbound',
chopper: 'Police helicopter overhead',
};
/** Market stalls and party gear: things you can plough through. */
interface Prop {
kind: 'stall' | 'speaker' | 'table' | 'keg' | 'stage';
body: Body;
colors: [string, string];
produce: string[];
broken: boolean;
venue: number;
}
/** Vehicles with a flat, tall front that shove people down instead of scooping them up. */
const FLAT_FRONT = new Set(['bus', 'van', 'pickup', 'icecream']);
const DAY_SECONDS = 240;
function inHours(hour: number, from: number, to: number): boolean {
const h = to > 24 && hour < from ? hour + 24 : hour;
return h >= from && h < to;
}
const DRAMA: [number, string, string][] = [
[30, 'CRUNCH!', '#ffd84a'],
[62, 'WRECKED!', '#ff8a3d'],
[100, 'CARNAGE!!', '#ff4d5e'],
[145, 'APOCALYPSE!!!', '#ff2bd6'],
];
export class Game {
phase: Phase = 'title';
score = 0;
deliveries = 0;
overReason = '';
readonly city = new City();
readonly effects = new Effects();
readonly traffic: Traffic;
readonly peds: Pedestrians;
truck: Truck;
job: Job;
private readonly mapRenderer: MapRenderer;
private time = 0;
private shake = 0;
private slowmo = 0;
private dying = -1;
private exhaust = 0;
private alarmTick = 0;
private lastWallHit = 0;
private lastHint = -10;
private rolledTime = 0;
private flash = 0;
private lastDrama = -10;
private beat = 0;
/** Time of day in hours (0-24). A full day takes DAY_SECONDS of real time. */
hour = 7.5;
private lightCanvas: HTMLCanvasElement | null = null;
private pendingBlasts: { t: number; x: number; y: number; size: number }[] = [];
private props: Prop[] = [];
readonly wildlife: Wildlife;
readonly services: Services;
readonly workers: Workers;
readonly street: Street;
readonly helis: Helicopters;
readonly dispatch: Dispatcher;
readonly mob: Mob;
readonly missiles: Missiles;
readonly air: Airstrike;
/** The city fighting back: utilities, people, emergency services, air defence. */
readonly civil: Civil;
/** For the rig, sports car and dozer: an A-10 flown by the computer, raiding the city. */
raidEnabled = false;
raider: Raider | null = null;
private raidRespawn = 0;
private flybyAt = -10;
private brrtAt = -10;
/** A-10 sorties: things destroyed. */
destroyed = 0;
/**
* Subtle streaks behind the A-10: thin vapour trails off the wingtips that show up
* as it pulls hard (and faintly at speed), and a heat shimmer behind each engine.
*/
private drawTrails(ctx: CanvasRenderingContext2D): void {
const f = this.truck.flight;
if (!f) return;
const speedFrac = clamp(f.v / 200, 0, 1);
ctx.lineCap = 'round';
this.trails.forEach((tr, i) => {
const tip = i < 2;
const keep = tip ? 1.6 : 0.7;
for (let k = 1; k < tr.length; k++) {
const a = tr[k - 1];
const b = tr[k];
const pa = this.lift(a.x, a.y, a.z);
const pb = this.lift(b.x, b.y, b.z);
if (!pa || !pb) continue;
const age = (this.time - b.t) / keep;
// Wingtip vortices condense as the wing pulls g; the engines leave a heat shimmer.
const strength = tip ? (0.06 + b.bank * 0.9 + speedFrac * 0.05) * (1 - age) : (0.12 + speedFrac * 0.06) * (1 - age);
if (strength <= 0.01) continue;
ctx.strokeStyle = tip ? `rgba(255, 255, 255, ${Math.min(0.8, strength)})` : `rgba(215, 210, 205, ${strength})`;
ctx.lineWidth = (tip ? 1 + age * 1.6 : 3 + age * 4) * pb.s;
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.stroke();
}
});
ctx.lineCap = 'butt';
}
/** What's at a point: a building's construction if one's there, otherwise the ground's surface. */
private groundAt(x: number, y: number): Ground {
const st = this.city.structuresNear(x, y, 2).find((s) => !s.tree);
if (st) return st.mat === 'wood' ? 'wood' : 'stone';
switch (this.city.surfaceAt(x, y)) {
case SURF_WATER:
return 'water';
case SURF_GRASS:
return this.city.onDeck(x, y) ? 'tarmac' : 'grass';
case SURF_DIRT:
return 'earth';
case SURF_SIDEWALK:
return 'concrete';
default:
return 'tarmac';
}
}
/** Play an explosion or collapse where it happened (anything within earshot). */
private boom(kind: BlastKind, x: number, y: number): void {
// From the air the sound has the height to climb, too.
const d = Math.hypot(x - this.truck.x, y - this.truck.y, this.truck.altitude);
if (d < 1600) this.events.blastSound?.(kind, this.panOf(x), d);
}
/** How much of the city lies in ruins: the share of its buildings destroyed (0..100). */
ruins(): number {
const all = this.city.buildings;
let down = 0;
for (const b of all) if (b.destroyed) down++;
return all.length ? (down / all.length) * 100 : 0;
}
/** The A-10's gun, for its continuous sound: firing, and the current rounds per second. */
gunState(): { firing: boolean; rate: number } {
return { firing: this.firing && this.phase === 'playing', rate: 65 * (0.55 + 0.45 * this.air.spin) };
}
private firing = false;
private lastBrrt = -10;
/** The A-10 gun's heat (0..1) and whether it has overheated and is cooling down. */
/** Re-arming countdown (s) after the jet calls for more ordnance. */
private rearming = 0;
/** Camera height above the ground (map units); Infinity for anything on the ground. */
private camH = Infinity;
private camCenter = { x: 0, y: 0 };
private bombHeld = 0;
private mobProgress = 0;
private inTurf = false;
private lastDisrespect = -10;
private readonly disrespected = new WeakSet<object>();
/** Buildings brought down, until the repair crews rebuild them. */
private collapsed: Structure[] = [];
/** Buildings and trees on fire. */
private burning: Structure[] = [];
private fireTick = 0;
private crackle = 0;
private sirenTimer = 0;
private workTimer = 0;
private lastDispatchToast = -20;
private grindTimer = 0;
private hitStop = 0;
/** The A-10's wingtip vortices and engine shimmer: recent positions of each. */
private trails: { x: number; y: number; z: number; t: number; bank: number }[][] = [[], [], [], []];
private towChoice: TowChoice = 'both';
private zoom = 3;
private dpr = 1;
private devW = 300;
private devH = 300;
private insetTop = 0;
private insetBottom = 0;
private floaters: Floater[] = [];
private readonly wheelPrev = new Map<object, Vec[]>();
private readonly cam = { x: 0, y: 0 };
private minimap: HTMLCanvasElement | null = null;
readonly view: View = { x: 0, y: 0, w: 300, h: 300 };
constructor(
public options: GameOptions,
private readonly events: GameEvents,
) {
this.mapRenderer = new MapRenderer(this.city);
this.effects.solid = (x, y) => this.city.resolveCircle(x, y, 1.2, 0);
this.effects.groundAt = (x, y) => this.groundAt(x, y);
// Only dry grass and timber burn (spilt fuel burns briefly wherever it lands).
this.traffic = new Traffic(this.city, this.effects);
this.peds = new Pedestrians(this.city, this.effects);
this.wildlife = new Wildlife(this.city, this.effects);
this.wildlife.onHit = (a, speed, mass) => {
if (this.isNear(a.x, a.y)) this.events.animalHit?.(a.kind, mass, this.panOf(a.x));
const truck = this.truck;
if (Math.hypot(a.x - truck.x, a.y - truck.y) < 45 && Math.abs(truck.speed) > 10 && this.phase === 'playing') {
const hurt = Math.max(0, speed - 15) * mass * 0.05;
truck.damage = Math.min(100, truck.damage + hurt);
if (mass > 1) truck.dent(a.x, a.y, Math.min(3, mass * 0.6));
this.drama(a.x, a.y, speed * Math.min(1, mass / 2.5));
this.floaters.push({ x: a.x, y: a.y, text: a.kind.toUpperCase() + '!', color: DANGER, life: 1.2, scale: 1 });
}
};
this.services = new Services(this.city, this.effects);
this.services.onTruckHit = (v, closing, px, py) => this.hitUnit(v, closing, px, py);
this.services.onWrecked = (v) => {
this.unitWrecked(v);
this.tally(v.x, v.y, 60);
};
this.street = new Street(this.city, this.effects);
this.street.onKnock = (f, speed) => {
if (this.isNear(f.x, f.y)) this.events.knock?.(f.kind, this.panOf(f.x));
this.peds.hearHorn(f.x, f.y);
if (this.phase === 'playing' && speed > 20 && Math.hypot(f.x - this.truck.x, f.y - this.truck.y) < 40) {
const text = f.kind === 'lamp' ? 'TIMBER!' : f.kind === 'hydrant' ? 'GEYSER!' : 'TRASHED!';
this.floaters.push({ x: f.x, y: f.y, text, color: DANGER, life: 1.1, scale: 0.9 });
}
};
this.helis = new Helicopters(this.city);
this.helis.onRotor = (x, y) => {
const d = Math.hypot(x - this.truck.x, y - this.truck.y);
this.events.rotor?.(this.panOf(x), Math.max(0, 1 - d / 700));
};
this.missiles = new Missiles(this.city, this.effects);
this.air = new Airstrike();
this.air.onBomb = (x, y, kind) => this.bombBlast(x, y, kind);
this.air.onRound = (x, y, hei, vx, vy) => this.gunRound(x, y, hei, vx, vy);
this.air.onMaverick = (x, y) => this.maverickBlast(x, y);
this.air.onBallute = () => this.events.ballute?.();
this.traffic.onWrecked = (car) => this.tally(car.x, car.y, 25);
this.missiles.onExplode = (x, y, layer) => this.missileBlast(x, y, layer);
this.mob = new Mob(this.city, this.effects, this.traffic, this.services);
this.mob.onMayhem = (x, y, closing) => {
this.drama(x, y, closing);
if (this.isNear(x, y)) this.events.explosion?.(1.6, this.panOf(x));
};
this.mob.onBlast = (x, y, size, delay) => this.pendingBlasts.push({ t: this.time + delay, x, y, size });
this.mob.onDemolish = (st) => {
// The family's bomb: it comes down, and what's inside goes up.
this.collapse(st, 0, 0);
this.burnFromBlast(st);
};
this.traffic.closures = this.services.closures;
const game = this;
this.dispatch = new Dispatcher({
traffic: this.traffic,
peds: this.peds,
effects: this.effects,
wildlife: this.wildlife,
street: this.street,
services: this.services,
helis: this.helis,
get truck() {
return game.truck;
},
brokenProps: () => [...this.brokenProps(), ...this.rubble()],
hour: () => this.hour,
inTurf: (x, y) => this.mob.inTurf(x, y),
burning: () => this.burningSpots(),
douse: (x, y, r) => this.douse(x, y, r),
pressure: (x, y) => this.civil.pressure(x, y),
coordination: () => this.civil.coordination,
rescueSites: () => this.civil.rescueSites(),
dig: (site) => this.civil.dig(site as Parameters<Civil['dig']>[0]),
sound: (kind, x, y) => {
if (!this.isNear(x, y)) return;
if (kind === 'hose') this.events.hose?.(this.panOf(x));
else if (kind === 'tow') this.events.towWork?.(this.panOf(x));
else this.events.tools?.(this.panOf(x));
},
});
this.civil = new Civil(this.city, this.effects, this.services, this.peds, {
hour: () => this.hour,
burning: () => this.burning,
douse: (x, y, r) => this.douse(x, y, r),
toast: (text, tone) => {
if (this.phase === 'playing') this.events.toast(text, tone);
},
jet: () => {
const t = this.truck;
if (this.phase !== 'playing') return null;
// The raider, when the player's on the ground.
if (!t.plane) return this.raider?.alive ? { x: this.raider.x, y: this.raider.y, vx: this.raider.vx, vy: this.raider.vy } : null;
if (this.dying >= 0 || this.jetFalling !== false) return null;
return { x: t.x, y: t.y, vx: t.cab.vx, vy: t.cab.vy };
},
hitJet: (damage) => (this.truck.plane ? this.hitJet(damage) : this.raider?.hit(damage)),
explode: (x, y, size, air) => {
if (air) {
this.effects.flak(x, y);
this.effects.detonation(x, y, size * 0.6, 0, 'metal');
if (this.isNear(x, y)) this.boom('small', x, y);
} else this.pendingBlasts.push({ t: this.time, x, y, size });
},
sound: (kind, x, y) => {
const d = Math.hypot(x - this.truck.x, y - this.truck.y);
if (kind === 'siren') this.events.airRaid?.();
else if (kind === 'launch') this.events.samLaunch?.(this.panOf(x));
else if (kind === 'guns' && d < 1600) this.events.aaGuns?.(this.panOf(x), d);
else if (kind === 'heli' && this.isNear(x, y)) this.events.hose?.(this.panOf(x));
},
score: (points, x, y, label) => {
if (!this.truck.plane || this.phase !== 'playing') return;
this.score += points;
if (this.isNear(x, y)) this.floaters.push({ x, y: y - 10, text: `${label} +${points}`, color: '#ffd84a', life: 1.6, scale: 1.1 });
},
});
// Without power the junctions' lights go dark, and drivers have to feel their way through.
this.traffic.powered = (x, y) => this.civil.hasPower(x, y);
this.dispatch.onDispatch = (kind, x, y) => {
const text = DISPATCH_TOASTS[kind];
if (!text || this.phase !== 'playing' || Math.hypot(x - this.truck.x, y - this.truck.y) > 450 || this.time - this.lastDispatchToast < 9) return;
this.lastDispatchToast = this.time;
this.events.toast(text, 'info');
};
this.dispatch.onStage = (inc) => {
const text = inc.stage === 'tow' ? 'Scene secure: tow trucks called' : inc.stage === 'clean' ? 'Wrecks cleared: road crew sweeping up' : inc.stage === 'done' ? 'Road cleaned up and reopened' : '';
if (!text || this.phase !== 'playing' || Math.hypot(inc.x - this.truck.x, inc.y - this.truck.y) > 450 || this.time - this.lastDispatchToast < 5) return;
this.lastDispatchToast = this.time;
this.events.toast(text, inc.stage === 'done' ? 'good' : 'info');
};
this.traffic.onBoard = (_car, who) => this.peds.board(who as Ped);
this.traffic.onDropOff = (_car, x, y) => this.peds.dropOff(x, y);
this.workers = new Workers(this.city, this.peds, this.effects);
this.workers.onCrash = (x, y, closing) => {
if (this.isNear(x, y)) this.events.crash?.(closing / 100, 'car', this.panOf(x));
this.drama(x, y, closing);
if (this.phase === 'playing') this.floaters.push({ x, y, text: 'MY TRACTOR!', color: DANGER, life: 1.4, scale: 1 });
};
this.traffic.onEject = (car, vx, vy) => this.peds.launch(car.x, car.y, vx, vy, car.look.accent, car.look.color);
this.traffic.onCrash = (x, y, intensity, kind, car) => {
if (car.mob && kind === 'car' && intensity > 0.2 && !this.disrespected.has(car)) {
this.disrespected.add(car);
this.disrespect('You wrecked a family car!');
}
// A sliding wreck can bring a flimsy building down too.
const struck = kind === 'wall' ? this.city.lastStructure : null;
if (struck) this.hitStructure(struck, Math.max(0, intensity * 100 - (struck.mat === 'wood' ? 25 : 70)) * (car.body.mass / 3), struck.x - car.x, struck.y - car.y);
if (kind === 'wall' && intensity > 0.3) {
const material = this.city.lastMaterial;
if (material === 'concrete') {
const a = Math.atan2(car.y - y, car.x - x);
this.effects.structureHit(x, y, intensity * 100, Math.cos(a), Math.sin(a), material);
if (this.isNear(x, y)) this.events.structureHit?.(intensity * 100, this.panOf(x));
}
}
if (this.isNear(x, y)) {
this.events.crash?.(intensity, kind, this.panOf(x));
if (intensity > 0.12) this.events.carFlavor?.(car.look.model, intensity, this.panOf(x));
this.bump(Math.min(10, intensity * 6) * (kind === 'pileup' ? 0.5 : 1));
}
};
this.traffic.onExplode = (x, y, size) => this.blast(x, y, size);
this.traffic.onRollHit = (x, y, energy) => {
if (this.isNear(x, y)) {
this.events.rollHit?.(energy, this.panOf(x));
this.bump(Math.min(6, energy / 20));
}
};
this.traffic.onSay = (car, kind) => {
if (kind !== 'wreck' && this.phase === 'playing' && this.isNear(car.x, car.y)) this.events.honk?.(car.rude, this.panOf(car.x));
};
this.peds.onHit = (ped) => {
this.events.pedHit?.(this.panOf(ped.x));
const byPlayer = !this.truck.plane && Math.hypot(ped.x - this.truck.x, ped.y - this.truck.y) < 45;
if (this.phase !== 'playing' || !byPlayer) return;
if (this.mob.isGoon(ped.lines)) this.disrespect('You ran down one of the family!');
this.score = Math.max(0, this.score - INSURANCE);
this.floaters.push({ x: ped.x, y: ped.y, text: `-$${INSURANCE} INSURANCE`, color: DANGER, life: 1.6 });
this.events.toast(`Pedestrian hit! Insurance claim -$${INSURANCE}`, 'bad');
};
this.truck = this.newTruck();
this.job = this.makeJob('pickup');
this.snapCamera();
this.populate();
}
private populate(): void {
this.traffic.setCount(this.options.carCount, this.truck, this.view, this.options.rudeness);
this.peds.setCount(this.options.pedCount, this.truck, this.view);
this.peds.setupVenues(this.city.venues);
this.workers.setup();
this.mob.setupGoons(this.peds);
this.wildlife.setCount(this.options.animalCount, this.truck, this.view);
this.wildlife.setupHerds();
this.buildProps();
}
private buildProps(): void {
this.props = [];
const body = (x: number, y: number, angle: number, halfL: number, halfW: number): Body => ({ x, y, vx: 0, vy: 0, angle, av: 0, mass: 1e6, inertia: 1e9, halfL, halfW });
const awnings: [string, string][] = [
['#e74c3c', '#fdfdfd'],
['#2e86c1', '#fdfdfd'],
['#27ae60', '#f7dc6f'],
['#f39c12', '#fdfdfd'],
['#8e44ad', '#f5b7b1'],
];
this.city.venues.forEach((v, i) => {
const c = Math.cos(v.angle);
const s = Math.sin(v.angle);
const at = (u: number, w: number) => ({ x: v.x + c * u - s * w, y: v.y + s * u + c * w });
if (v.kind === 'market') {
for (const row of [-0.42, 0.42]) {
for (const col of [-0.5, 0, 0.5]) {
const p = at(col * v.r * 1.3, row * v.r);
this.props.push({ kind: 'stall', body: body(p.x, p.y, v.angle, 7, 4.5), colors: pick(awnings), produce: [pick(['#e74c3c', '#f39c12', '#27ae60', '#f4d03f', '#8e44ad']), pick(['#e74c3c', '#27ae60', '#d35400'])], broken: false, venue: i });
}
}
} else {
const layout: readonly (readonly [number, number, Prop['kind']])[] =
v.kind === 'rave'
? [
[0, -0.7, 'stage'],
[-0.62, -0.62, 'speaker'],
[0.62, -0.62, 'speaker'],
[-0.75, 0.3, 'speaker'],
[0.75, 0.3, 'speaker'],
]
: [
[-0.55, -0.5, 'speaker'],
[0.55, -0.5, 'speaker'],
[0, -0.55, 'table'],
[0.5, 0.45, 'keg'],
];
for (const [u, w, kind] of layout) {
const p = at(u * v.r, w * v.r);
const size = kind === 'stage' ? [14, 5] : kind === 'speaker' ? [3, 3] : kind === 'table' ? [7, 3] : [2, 2];
this.props.push({ kind, body: body(p.x, p.y, v.angle, size[0], size[1]), colors: ['#1b1b1f', '#3a3a40'], produce: [], broken: false, venue: i });
}
}
});
}
private advanceClock(realDt: number): void {
this.hour = (this.hour + (realDt * 24) / DAY_SECONDS) % 24;
this.peds.hour = this.hour;
this.city.venues.forEach((_, i) => this.peds.setVenueOpen(i, this.venueOpen(i)));
}
clockText(): string {
const h = Math.floor(this.hour);
const m = Math.floor((this.hour - h) * 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
/** Markets run by day, parties in the evening, raves through the night. */
venueOpen(i: number): boolean {
const v = this.city.venues[i];
if (v.kind === 'market') return inHours(this.hour, 7, 18);
if (v.kind === 'party') return inHours(this.hour, 18, 27);
return inHours(this.hour, 22, 29);
}
/** Traffic follows the clock: rush hours are packed, the small hours are empty. */
private trafficLevel(): number {
const h = this.hour;
const peak = (c: number, w: number) => Math.exp(-(((h - c) / w) ** 2));
return Math.min(1.35, 0.3 + 0.55 * this.daylight() + 0.5 * peak(8, 1.3) + 0.5 * peak(17.5, 1.6));
}
private pedLevel(): number {
// Evacuated districts empty out.
const evac = this.civil.evac.get(this.city.districtAt(this.cam.x, this.cam.y)) ?? 0;
return (0.35 + 0.75 * this.daylight()) * (1 - evac * 0.85);
}
/** 1 at midday, 0 in the dead of night. */
daylight(): number {
const h = this.hour;
if (h >= 7 && h < 17.5) return 1;
if (h >= 17.5 && h < 20.5) return 1 - (h - 17.5) / 3;
if (h >= 5 && h < 7) return (h - 5) / 2;
return 0;
}
private readonly sprites = new Map<string, HTMLCanvasElement>();
/** A soft round light, cached per colour, so lights cost one drawImage each. */
private lightSprite(color: string): HTMLCanvasElement {
let c = this.sprites.get(color);
if (!c) {
c = document.createElement('canvas');
c.width = c.height = 64;
const g2 = c.getContext('2d')!;
const g = g2.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0, color);
g.addColorStop(1, 'rgba(0, 0, 0, 0)');
g2.fillStyle = g;
g2.fillRect(0, 0, 64, 64);
this.sprites.set(color, c);
}
return c;
}
/** Darken the world at night and punch holes in the dark for every light. */
private drawNight(ctx: CanvasRenderingContext2D, vx: number, vy: number, zoom: number): void {
const day = this.daylight();
if (day >= 0.999) return;
const dark = (1 - day) * 0.78;
// The light map is soft, so a third of the screen resolution is plenty.
const w = Math.max(1, Math.round(this.devW / 3));
const h = Math.max(1, Math.round(this.devH / 3));
if (!this.lightCanvas || this.lightCanvas.width !== w || this.lightCanvas.height !== h) {
this.lightCanvas = document.createElement('canvas');
this.lightCanvas.width = w;
this.lightCanvas.height = h;
}
const lc = this.lightCanvas.getContext('2d')!;
const k = (zoom * w) / this.devW;
const sx = (x: number) => (x - vx) * k;
const sy = (y: number) => (y - vy) * k;
const visible = (x: number, y: number, m = 60) => inView(x, y, this.view, m);
// Dusk and dawn are warm; night is deep blue.
const warm = Math.max(0, 1 - Math.abs(day - 0.45) * 3);
lc.globalCompositeOperation = 'source-over';
lc.clearRect(0, 0, w, h);
lc.fillStyle = `rgba(${Math.round(12 + warm * 70)}, ${Math.round(16 + warm * 20)}, ${Math.round(42 - warm * 10)}, ${dark})`;
lc.fillRect(0, 0, w, h);
lc.globalCompositeOperation = 'destination-out';
const sprite = this.lightSprite('#000000');
const light = (x: number, y: number, r: number, strength: number) => {
const rr = r * k;
lc.globalAlpha = Math.min(1, strength);
lc.drawImage(sprite, sx(x) - rr, sy(y) - rr, rr * 2, rr * 2);
};
const beams = (x: number, y: number, a: number, reach: number, strength: number, steps = 3) => {
for (const [d, r] of (steps === 3
? [
[0.25, 0.22],
[0.55, 0.32],
[0.85, 0.4],
]
: [
[0.35, 0.3],
[0.75, 0.42],
]) as [number, number][]) light(x + Math.cos(a) * reach * d, y + Math.sin(a) * reach * d, reach * r, strength * (1.1 - d * 0.6));
};
// Street lamps and lit windows, only where the power's still on: a blackout is a black hole.
const lamps = this.street.lit(this.view).filter((p) => this.civil.hasPower(p.x, p.y));
for (const p of lamps) light(p.x, p.y, 40, 0.8);
let windows = 0;
for (const b of this.city.buildings) {
if (b.destroyed || windows > 260 || (b.id * 7) % 3 === 0 || !visible(b.x, b.y, 20) || !this.civil.hasPower(b.x, b.y)) continue;
windows++;
light(b.x, b.y, Math.min(b.w, b.d) * 0.45, 0.35);
}
for (const g of this.civil.lights(this.lift)) light(g.x, g.y, g.r, 0.9);
const t = this.truck;
if (!t.plane) beams(t.x + Math.cos(t.angle) * t.cab.halfL, t.y + Math.sin(t.angle) * t.cab.halfL, t.angle, t.sports ? 110 : 120, 0.95);
for (const p of this.air.lights(this.lift)) light(p.x, p.y, 10, 0.6);
if (this.raider) for (const p of this.raider.air.lights(this.lift)) light(p.x, p.y, 10, 0.6);
light(t.x, t.y, 22, 0.5);
for (const car of this.traffic.cars) {
if (!visible(car.x, car.y)) continue;
beams(car.x + Math.cos(car.angle) * car.body.halfL, car.y + Math.sin(car.angle) * car.body.halfL, car.angle, 60, 0.8, 2);
}
for (const v of this.services.vehicles) {
if (!visible(v.x, v.y)) continue;
beams(v.x + Math.cos(v.body.angle) * v.body.halfL, v.y + Math.sin(v.body.angle) * v.body.halfL, v.body.angle, 70, 0.85, 2);
}
for (const b of this.helis.beams(this.view)) light(b.x, b.y, b.r, 0.95);
for (const g of this.effects.glows(this.view)) light(g.x, g.y, g.r, 0.9);
for (const st of this.burning) if (visible(st.x, st.y, 80)) light(st.x, st.y, Math.max(st.w, st.d) * 1.3, 0.95);
this.city.venues.forEach((v, i) => {
if (v.kind !== 'market' && this.venueOpen(i) && visible(v.x, v.y, v.r + 60)) light(v.x, v.y, v.r * 1.6, 0.75);
});
lc.globalAlpha = 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this.lightCanvas, 0, 0, this.devW, this.devH);
// Coloured glows on top.
const night = 1 - day;
ctx.setTransform(zoom, 0, 0, zoom, -vx * zoom, -vy * zoom);
ctx.globalCompositeOperation = 'lighter';
const glow = (x: number, y: number, r: number, color: string) => {
ctx.drawImage(this.lightSprite(color), x - r, y - r, r * 2, r * 2);
};
ctx.globalAlpha = night;
for (const p of lamps) if (visible(p.x, p.y, 20)) glow(p.x, p.y, 10, 'rgba(255, 210, 140, 0.35)');
for (const car of this.traffic.cars) {
if (!visible(car.x, car.y, 20)) continue;
const c = Math.cos(car.angle);
const s = Math.sin(car.angle);
glow(car.x - c * car.body.halfL, car.y - s * car.body.halfL, car.braking ? 6 : 3.5, 'rgba(255, 30, 30, 0.6)');
glow(car.x + c * car.body.halfL, car.y + s * car.body.halfL, 5, 'rgba(255, 240, 200, 0.5)');
}
glow(t.x + Math.cos(t.angle) * (t.cab.halfL + 1), t.y + Math.sin(t.angle) * (t.cab.halfL + 1), 7, 'rgba(255, 245, 210, 0.6)');
const tc = t.trailerCenter();
const tail = t.solo ? t.cab.halfL : 19;
glow(tc.x - Math.cos(t.trailerAngle) * tail, tc.y - Math.sin(t.trailerAngle) * tail, t.braking ? 8 : 4, 'rgba(255, 30, 30, 0.6)');
for (const g of this.effects.glows(this.view)) glow(g.x, g.y, g.r * 0.6, g.color);
for (const p of this.peds.lights(this.view)) glow(p.x, p.y, 3, p.color);
for (const p of this.services.lights(this.view)) glow(p.x, p.y, 16, p.color);
for (const p of this.helis.lights(this.view)) glow(p.x, p.y, 5, p.color);
for (const m of this.missiles.lights(this.view)) glow(m.x, m.y, 7, 'rgba(255, 190, 70, 0.8)');
for (const p of this.air.lights(this.lift)) glow(p.x, p.y, 4, 'rgba(255, 200, 80, 0.7)');
for (const st of this.burning) if (visible(st.x, st.y, 40)) glow(st.x, st.y, Math.max(st.w, st.d) * 0.8, 'rgba(255, 120, 30, 0.45)');
const jet = t.plane && this.dying < 0 ? this.lift(t.x, t.y, t.altitude) : null;
if (jet) {
// Wingtip nav lights and a strobe, up at the jet's height.
const c = Math.cos(t.angle);
const s = Math.sin(t.angle);
const span = (8.7 / METERS) * Math.cos(t.bank * 0.9) * jet.s;
glow(jet.x + s * span, jet.y - c * span, 4 * jet.s, 'rgba(255, 50, 50, 0.8)');
glow(jet.x - s * span, jet.y + c * span, 4 * jet.s, 'rgba(60, 255, 110, 0.8)');
if (Math.floor(this.time * 1.5) % 2 === 0 && this.time % (2 / 1.5) < 0.1) glow(jet.x, jet.y, 14 * jet.s, 'rgba(255, 255, 255, 0.9)');
}
for (const p of this.signalGlows()) glow(p.x, p.y, 4, p.color);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
/** Big crashes get bigger: a headline, slow motion, a flash and, at the top, chain explosions. */
private drama(x: number, y: number, closing: number, chain = true): void {
let tier = 0;
DRAMA.forEach(([min], i) => {
if (closing >= min) tier = i + 1;
});
if (!tier || this.time - this.lastDrama < 0.4) return;
this.lastDrama = this.time;
const [, text, color] = DRAMA[tier - 1];
this.floaters.push({ x, y: y - 6, text, color, life: 1.2 + tier * 0.3, scale: 1 + tier * 0.4 });
if (this.options.shake) {
// From the air the bombs keep coming: shorter slow motion so the sortie keeps flowing.
this.slowmo = Math.max(this.slowmo, [0, 0, 0.25, 0.6, 1.4][tier] * (this.truck.plane ? 0.3 : 1));
this.flash = Math.max(this.flash, [0, 0, 0.12, 0.3, 0.65][tier] * (this.truck.plane ? 0.3 : 1));
this.zoom *= 1 + tier * 0.05;
this.bump(4 + tier * 5);
}
this.events.dramaTier?.(tier, this.panOf(x));
if (tier >= 3) {
this.dispatch.major(x, y);
// Shockwave: everything nearby gets thrown.
const radius = 40 + tier * 18;
// (A bomb brings its own fireball: no extra one on top.)
if (chain) {
if (tier === 3) this.effects.explosion(x, y, 0.5);
else this.effects.fireball(x, y, 1.1);
}
for (const car of this.traffic.all()) {
const dx = car.x - x;
const dy = car.y - y;
const d = Math.hypot(dx, dy);
if (d > radius || d < 0.1 || car.layer !== this.truck.layer) continue;
const force = (1 - d / radius) * tier * 35;
if (car.mode === 'drive') this.traffic.syncBody(car);
car.body.vx += (dx / d) * force;
car.body.vy += (dy / d) * force;
car.rollVel += (Math.random() - 0.5) * tier * 3;
this.traffic.applyHit(car, force, car.x - (dx / d) * 4, car.y - (dy / d) * 4, -dx / d, -dy / d, { material: 'car', color: '#ff9a3a' }, 'pileup');
}
this.peds.blast(x, y, radius);
this.blastStructures(x, y, radius * 0.8, tier * 40);
this.wildlife.blast(x, y, radius);
}
if (tier >= 4 && chain) {
const count = 3 + Math.floor(Math.random() * 3);
for (let i = 0; i < count; i++) this.pendingBlasts.push({ t: this.time + range(0.15, 1.2), x: x + range(-35, 35), y: y + range(-35, 35), size: range(0.7, 1.3) });
}
}
/** Anything moving on the ground smashes stalls and party gear and knocks over street furniture. */
private updateProps(): void {
const truck = this.truck;
const movers: Body[] = truck.layer === 0 ? [truck.cab, truck.trailer] : [];
for (const w of this.traffic.wrecks) if (w.layer === 0 && w.dmg.z < 6 && Math.hypot(w.body.vx, w.body.vy) > 9) movers.push(w.body);
for (const b of movers) this.street.strike(b, 0);
for (const prop of this.props) {
if (prop.broken) continue;
for (const b of movers) {
if (Math.abs(prop.body.x - b.x) > 30 || Math.abs(prop.body.y - b.y) > 30) continue;
const c = boxContact(b, prop.body);
if (!c) continue;
const speed = Math.hypot(b.vx, b.vy);
if (speed < 8) continue;
prop.broken = true;
b.vx *= 0.93;
b.vy *= 0.93;
const k = Math.min(1.4, speed / 90);
this.effects.crash(prop.body.x, prop.body.y, k, prop.kind === 'stall' ? [prop.colors[0], prop.colors[1], '#8a6a44'] : ['#1b1b1f', '#55524a'], { x: b.vx / speed, y: b.vy / speed }, { material: 'wood', glass: prop.kind === 'keg' });
if (prop.kind === 'stall') {
// Produce everywhere.
for (let i = 0; i < 3; i++) this.effects.crash(prop.body.x, prop.body.y, k * 0.6, prop.produce, { x: b.vx / speed, y: b.vy / speed }, { material: 'ground', glass: false, flavor: 'compact' });
}
if (this.isNear(prop.body.x, prop.body.y)) this.events.stallSmash?.(this.panOf(prop.body.x));
this.peds.hearHorn(prop.body.x, prop.body.y);
if (this.phase === 'playing' && this.isNear(prop.body.x, prop.body.y)) this.floaters.push({ x: prop.body.x, y: prop.body.y, text: prop.kind === 'stall' ? 'MY STALL!' : 'THE PARTY!', color: DANGER, life: 1.3, scale: 1 });
break;
}
}
}
setViewport(devW: number, devH: number, dpr: number, insetTop: number, insetBottom: number): void {
this.devW = devW;
this.devH = devH;
this.dpr = dpr;
this.insetTop = insetTop;
this.insetBottom = insetBottom;
this.updateView();
}
/** What the player drives this shift. */
vehicle: VehicleKind = 'rig';
private readonly sportsLook: CarLook = makeLook('sports', '#e8262f', '#1b1b1f', false);
private readonly sportsDmg: CarDamage = { crumple: [0, 0, 0, 0], wheels: [true, true, true, true], flipped: false, z: 0, roll: 0, hazard: false, burnt: 0, glass: false };
start(vehicle: VehicleKind = this.vehicle): void {
this.vehicle = vehicle;
this.truck = this.newTruck();
this.score = 0;
this.deliveries = 0;
this.overReason = '';
this.dying = -1;
this.slowmo = 0;
this.rolledTime = 0;
this.floaters = [];
this.effects.clear();
this.wheelPrev.clear();
this.snapCamera();
this.traffic.clear();
this.peds.clear();
this.wildlife.clear();
this.services.clear();
this.helis.clear();
this.dispatch.clear();
this.mob.clear();
this.missiles.clear();
this.air.clear();
this.civil.reset();
this.jetFalling = false;
this.raider = null;
this.raidRespawn = vehicle !== 'a10' && this.raidEnabled ? 4 : 0;
this.rearming = 0;
this.bombHeld = 0;
for (const tr of this.trails) tr.length = 0;
for (const st of this.burning) st.burning = 0;
this.burning = [];
this.destroyed = 0;
this.mobProgress = 0;
for (const st of this.collapsed) this.city.rebuild(st);
this.collapsed = [];
this.street.reset();
this.pendingBlasts = [];
this.flash = 0;
this.populate();
this.job = this.makeJob('pickup');
this.phase = 'playing';
}
shiftUp(): void {
// In the A-10, M is the throttle: a tap nudges the lever, holding pushes it.
if (this.truck.flight) this.truck.flight.nudge(0.04);
else this.manualShift(() => this.truck.shiftUp());
}
shiftDown(): void {
if (this.truck.flight) this.truck.flight.nudge(-0.04);
else this.manualShift(() => this.truck.shiftDown());
}
private manualShift(shift: () => void): void {
if (this.phase !== 'playing' || this.dying >= 0) return;
if (this.truck.automatic) {
if (this.time - this.lastHint > 4) {
this.lastHint = this.time;
this.events.toast(this.truck.dozer ? 'The dozer has no gears to shift: W forward, S back' : 'The sports car is automatic: just drive', 'info');
}
return;
}
shift();
}
horn(): void {
if (this.phase !== 'playing') return;
this.traffic.hearHorn(this.truck.x, this.truck.y);
this.peds.hearHorn(this.truck.x, this.truck.y);
}
targetDistance(): number {
return Math.round(Math.hypot(this.job.spot.x - this.truck.x, this.job.spot.y - this.truck.y) * METERS_PER_UNIT);
}
/** Speed for the HUD in km/h. */
speedKmh(): number {
return Math.round(Math.abs(this.truck.speed) * METERS_PER_UNIT * 3.6);
}
rpmFraction(): number {
return clamp(this.truck.rpm / this.truck.redline, 0, 1.05);
}
update(realDt: number, input: Controls): void {
if (this.phase === 'paused') return;
// Hit-stop: hold the frame for an instant on the biggest impacts.
if (this.hitStop > 0) {
this.hitStop -= realDt;
this.shake = Math.max(0, this.shake - realDt * 10);
return;
}
this.slowmo = Math.max(0, this.slowmo - realDt);
const dt = realDt * (this.slowmo > 0 ? 0.3 : 1);
this.time += dt;
const active = this.phase === 'playing' && this.dying < 0;
const truck = this.truck;
if (active && !truck.dozer) this.smashAhead(dt);
const waiting = !!this.services.active('mytow');
// Up on the interstate deck it's all asphalt, whatever lies underneath.
const feel = (x: number, y: number) => (truck.layer === 1 ? FEEL[SURF_ROAD] : (FEEL[this.city.surfaceAt(x, y)] ?? FEEL[SURF_ROAD]));
const hits = truck.update(dt, active && !waiting ? input : NO_INPUT, feel, this.city);
if (waiting) {
// Waiting for the tow: parking brake on.
for (const b of [truck.cab, truck.trailer]) {
b.vx *= Math.exp(-4 * dt);
b.vy *= Math.exp(-4 * dt);
b.av *= Math.exp(-4 * dt);
}
}
this.groundSpray(dt);
if (active) this.dozerGrind(dt, input);
this.updateLayer();
for (const e of truck.events.splice(0)) {
if (e.type === 'shift') this.events.shift?.(e.gear);
else if (e.type === 'grind') {
this.events.grind?.();
this.events.toast('Grinding gears! Slow down first', 'bad');
} else if (e.type === 'tipping') {
this.events.tipping?.();
this.events.toast('Wheels lifting! Ease off!', 'bad');
} else if (e.type === 'rollover') {
this.rollOver();
}
}
if (active && !truck.automatic && input.up && truck.gear >= 4 && Math.abs(truck.speed) < 4 && this.time - this.lastHint > 6) {
this.lastHint = this.time;
this.events.toast(`Bogging down in ${truck.gearName} — shift down with N`, 'info');
}
const wall = hits.reduce<(typeof hits)[number] | null>((m, h) => (!m || h.impact > m.impact ? h : m), null);
if (wall && wall.impact > 14 && this.phase === 'playing') {
this.wallCrash(wall.impact, wall.x, wall.y, wall.material);
if (wall.structure) this.hitStructure(wall.structure, this.rigPunch(wall.impact, wall.structure), wall.x - this.truck.x, wall.y - this.truck.y);
}
if (truck.rolled) {
const v = Math.hypot(truck.cab.vx, truck.cab.vy);
if (v > 15 && Math.random() < 0.8) {
this.effects.scrape(truck.x, truck.y, truck.cab.vx, truck.cab.vy);
this.effects.scrape(truck.trailer.x, truck.trailer.y, truck.trailer.vx, truck.trailer.vy);
}
if (v < 3 && this.dying < 0 && this.phase === 'playing') {
this.rolledTime += realDt;
if (this.rolledTime > 1.5 && this.rolledTime - realDt <= 1.5) this.events.toast('On your side! Press R to call a tow truck', 'bad');
}
}
this.updateCamera(realDt);
this.traffic.obstacles = [...this.peds.roadObstacles(), ...this.wildlife.roadObstacles()];
this.advanceClock(realDt);
this.traffic.setCount(Math.round(this.options.carCount * this.trafficLevel()), truck, this.view, this.options.rudeness);
this.traffic.update(dt, truck, this.view, this.options.rudeness);
this.collideTruckWithCars(active);
this.peds.setCount(Math.round(this.options.pedCount * this.pedLevel()), truck, this.view);
this.peds.update(dt, truck, this.view);
if (truck.layer === 0) {
this.peds.strike(truck.cab, 0, 'flat');
this.peds.strike(truck.trailer, 0, 'flat');
}
for (const w of this.traffic.wrecks) {
if (Math.hypot(w.body.vx, w.body.vy) > 15) {
this.peds.strike(w.body, w.layer, FLAT_FRONT.has(w.look.model) ? 'flat' : 'low');
this.wildlife.strike(w.body, w.layer);
}
}
this.wildlife.update(dt, truck, this.view);
if (truck.layer === 0) {
this.wildlife.strike(truck.cab, 0);
this.wildlife.strike(truck.trailer, 0);
}
for (const car of this.traffic.cars) {
if (car.layer !== 0 || car.speed < 15) continue;
this.wildlife.strike(car.body, 0, true);
this.peds.strike(car.body, 0, FLAT_FRONT.has(car.look.model) ? 'flat' : 'low', true);
}
this.traffic.blockers = this.services.vehicles.map((v) => ({ body: v.body, layer: v.layer, parked: v.state === 'work' }));
this.traffic.hailers = this.peds.hailing();
this.services.update(dt, { traffic: this.traffic, truck, obstacles: this.traffic.obstacles, view: this.view });
for (const v of this.services.vehicles) {
const speed = Math.abs(v.speed);
if (speed < 5) continue;
this.peds.strike(v.body, v.layer, FLAT_FRONT.has(v.look.model) || v.body.mass > 3 ? 'flat' : 'low', true);
if (speed > 15) this.wildlife.strike(v.body, v.layer, true);
}
this.updateProps();
this.helis.update(dt, this.view, truck);
this.dispatch.update(dt);
this.mob.update(dt, this.view);
this.missiles.update(dt, this.missileTargets(), this.view);
this.updateFires(dt);
const turf = this.mob.inTurf(truck.x, truck.y);
if (turf !== this.inTurf && this.phase === 'playing' && !truck.plane) this.events.toast(turf ? 'Family turf: the cops don\u2019t come here' : 'Leaving family turf', turf ? 'bad' : 'info');
this.inTurf = turf;
this.street.update(dt, 1 / (DAY_SECONDS * 3), this.view);
this.workers.update(dt, truck, this.view, this.hour);
this.sirenTimer -= realDt;
if (this.sirenTimer <= 0) {
this.sirenTimer = 0.9;
// At most two sirens, nearest first, fading with distance.
const loud = this.services.vehicles
.filter((v) => v.siren)
.map((v) => ({ v, d: Math.hypot(v.x - truck.x, v.y - truck.y) }))
.filter((e) => e.d < 650)
.sort((a, b) => a.d - b.d)
.slice(0, 2);
for (const { v, d } of loud) this.events.siren?.(v.kind, this.panOf(v.x), 1 - d / 650);
}
for (const b of this.pendingBlasts) {
if (b.t > this.time) continue;
this.effects.fireball(b.x, b.y, b.size);
if (b.size >= 2) {
// A tank farm, a bunker or a tanker going up: the full layered blast, and a column of smoke.
this.effects.bombBurst(b.x, b.y, 40 * b.size, b.size, 'metal', false);
this.effects.smokeColumn(b.x, b.y, 16 + b.size * 4);
this.effects.blastMark(b.x, b.y, 22 * b.size);
}
this.blastStructures(b.x, b.y, 45 * b.size, 110 * b.size);
this.civil.onBlast(b.x, b.y, 45 * b.size, 110 * b.size, false);
this.effects.crash(b.x, b.y, 1.4, ['#ff9a3a', '#1b1b1f', '#c9ccd1'], { x: 0, y: 0 }, { panels: 1, material: 'ground', glass: false });
this.boom(b.size >= 2 ? 'huge' : b.size > 1.1 ? 'medium' : 'small', b.x, b.y);
this.peds.blast(b.x, b.y, 40);
this.wildlife.blast(b.x, b.y, 40);
this.bump(10);
}
this.pendingBlasts = this.pendingBlasts.filter((b) => b.t > this.time);
this.flash = Math.max(0, this.flash - realDt * 1.6);
// Music from any party within earshot.
this.beat -= realDt;
if (this.beat <= 0) {
this.beat = 0.47;
// Mix the two most audible venues instead of stacking every nearby kick.
const audible = this.city.venues.flatMap((v, i) => {
if (v.kind === 'market' || !this.venueOpen(i)) return [];
const d = Math.hypot(v.x - truck.x, v.y - truck.y);
const reach = v.kind === 'rave' ? 480 : 320;
return d < reach ? [{ v, volume: 1 - d / reach }] : [];
}).sort((a, b) => b.volume - a.volume).slice(0, 2);
for (const { v, volume } of audible) {
if (v.kind === 'rave') this.events.raveBeat?.(this.panOf(v.x), volume);
else this.events.partyBeat?.(this.panOf(v.x), volume);
}
}
if (truck.layer < 2) this.effects.kick(truck.x, truck.y, 9, truck.cab.vx, truck.cab.vy);
this.flight(dt, input);
this.updateRaid(dt);
this.civil.update(dt);
this.events.missileWarning?.(this.civil.missileWarning && this.phase === 'playing');
this.layTracks();
this.updateEffects(dt, realDt, active);
if (active) {
// The A-10 flies sorties, not deliveries: it scores what it destroys.
if (!truck.plane) this.updateJob(dt);
if (truck.damage >= 100 && !truck.plane) {
truck.damage = 100;
this.dying = 2.4;
this.effects.fireball(truck.x, truck.y, 1.8);
const tc = truck.trailerCenter();
this.effects.crash(tc.x, tc.y, 2, [COLORS.trailer, COLORS.cab], { x: 0, y: 0 }, { panels: 3, wheels: 2 });
this.bump(20);
if (this.options.shake) this.slowmo = 1.2;
this.events.totaled?.();
}
}
if (this.dying >= 0) {
this.dying -= realDt;
if (Math.random() < dt * 30) this.effects.puff(truck.x + (Math.random() - 0.5) * 12, truck.y + (Math.random() - 0.5) * 12, true, Math.random() < 0.5, 2.2);
if (this.dying < 0) this.end(this.truck.plane ? 'SHOT DOWN' : this.truck.sports ? 'CAR TOTALED' : this.truck.dozer ? 'DOZER WRECKED' : 'TRUCK TOTALED');
}
}
/**
* The dozer's blade grinds into whatever building it's pushing against: timber gives
* way in a moment, stone in a few seconds of shoving. Backing into one works too,
* just slower.
*/
private dozerGrind(dt: number, input: Controls): void {
const t = this.truck;
if (!t.dozer || t.layer !== 0) return;
const dir = t.speed < -0.5 || (t.gear === 0 && input.down) ? -1 : 1;
const c = Math.cos(t.angle) * dir;
const s = Math.sin(t.angle) * dir;
const px = t.x + c * (t.cab.halfL + 3);
const py = t.y + s * (t.cab.halfL + 3);
const push = this.city.resolveCircle(px, py, 6, 0);
const st = this.city.lastStructure;
if (!st || (!push.x && !push.y) || !(input.up || input.down || Math.abs(t.speed) > 2)) return;
const wood = st.mat === 'wood';
this.hitStructure(st, (wood ? 420 : 170) * (dir > 0 ? 1 : 0.4) * dt, c, s);
this.grindTimer -= dt;
if (this.grindTimer <= 0) {
this.grindTimer = 0.12;
this.effects.crash(px, py, 0.25, wood ? ['#8a6a44', '#a0703c', '#5d4037'] : ['#9e978a', '#6f6a62', '#b8b2a6'], { x: -c, y: -s }, { material: wood ? 'wood' : 'concrete', glass: Math.random() < 0.2 });
this.effects.dust(px, py, 1.4);
this.bump(1.5);
if (Math.random() < 0.35 && this.isNear(px, py)) this.events.structureHit?.(45, this.panOf(px));
}
if (st.destroyed && this.phase === 'playing') this.floaters.push({ x: st.x, y: st.y, text: 'BULLDOZED!', color: DANGER, life: 1.5, scale: 1.2 });
}
/** Dust off dirt and grass, spray from the shallows, kicked up by the driven wheels. */
private groundSpray(dt: number): void {
const t = this.truck;
const v = Math.abs(t.speed);
const g = t.ground;
if (g.grip >= 0.9 || v < 15 || t.layer !== 0) return;
const bx = t.x - Math.cos(t.angle) * t.cab.halfL * 0.7;
const by = t.y - Math.sin(t.angle) * t.cab.halfL * 0.7;
if (g.grip < 0.4) {
if (Math.random() < dt * 25) this.effects.water(bx, by, bx - t.cab.vx * 0.15 + (Math.random() - 0.5) * 10, by - t.cab.vy * 0.15 + (Math.random() - 0.5) * 10);
} else if (Math.random() < dt * (6 + v / 10)) {
this.effects.dust(bx + (Math.random() - 0.5) * 6, by + (Math.random() - 0.5) * 6, 0.7 + v / 120);
}
}
/** The rig is on the elevated deck after climbing a ramp, and on the ground after leaving it. */
private updateLayer(): void {
const t = this.truck;
const onDeck = this.city.onDeck(t.x, t.y);
const ramp = this.city.onRampMiddle(t.x, t.y);
if (t.layer === 1 && !onDeck && !ramp) t.layer = 0;
else if (t.layer === 0 && ramp) t.layer = 1;
}
/** Over she goes: the loaded trailer drags the cab onto its side. */
private rollOver(): void {
const truck = this.truck;
truck.damage = Math.min(99, truck.damage + 45);
this.rolledTime = 0;
for (const b of [truck.cab, truck.trailer]) {
this.effects.crash(b.x, b.y, 1.3, [COLORS.cab, COLORS.trailer, '#55524a'], { x: b.vx / 150, y: b.vy / 150 }, { panels: 1, glass: true, material: 'ground' });
}
this.bump(16);
if (this.options.shake) this.slowmo = Math.max(this.slowmo, 1);
this.events.rollover?.();
this.events.crash?.(1.6, 'wall', this.panOf(truck.x));
this.events.toast('ROLLOVER!', 'bad');
}
/** A fuel-tank explosion shoves and damages everything nearby. */
private blast(x: number, y: number, size: number, sound = true): void {
const truck = this.truck;
if (sound) this.boom(size > 1.1 ? 'medium' : 'small', x, y);
if (this.isNear(x, y)) {
this.bump(10 * size);
if (this.options.shake && Math.hypot(x - truck.x, y - truck.y) < 160) this.slowmo = Math.max(this.slowmo, 0.6);
}
for (const b of truck.plane ? [] : [truck.cab, truck.trailer]) {
const dx = b.x - x;
const dy = b.y - y;
const d = Math.hypot(dx, dy);
if (d > 60 || d < 0.1) continue;
const push = (1 - d / 60) * 40 * size;
b.vx += (dx / d) * push;
b.vy += (dy / d) * push;
if (this.phase === 'playing') {
truck.damage = Math.min(100, truck.damage + (1 - d / 60) * 18 * size * (truck.dozer ? 0.3 : 1));
truck.dent(b.x - (dx / d) * 6, b.y - (dy / d) * 6, 2.5);
}
}
this.peds.blast(x, y, 55 * size);
this.blastStructures(x, y, 40 * size, 80 * size);
}
/** Everything a missile can hit. */
private missileTargets(): { body: Body; layer: number }[] {
return [...this.traffic.all().map((c) => ({ body: c.body, layer: c.layer })), ...this.services.vehicles.map((v) => ({ body: v.body, layer: v.layer }))];
}
/** The sports car fires a missile from its nose. */
fireMissile(): void {
const t = this.truck;
if (this.phase !== 'playing' || this.dying >= 0 || !(t.sports || t.plane)) return;
if (t.plane) {
// From the A-10: an AGM-65 Maverick off the outboard pylons.
if (this.rearming > 0 || this.jetFalling !== false) return;
const bodies = this.missileTargets().filter((m) => m.layer === 0).map((m) => m.body);
if (this.air.launchMaverick(this.launch(), bodies)) this.events.missile?.(this.panOf(t.x));
else if (this.air.mavericks <= 0) {
this.events.click?.();
this.events.toast('No Mavericks left (R to re-arm)', 'bad');
}
return;
}
const nose = t.cab.halfL + 3;
const side = 0;
const px = t.x + Math.cos(t.angle) * nose - Math.sin(t.angle) * side;
const py = t.y + Math.sin(t.angle) * nose + Math.cos(t.angle) * side;
const targets = this.missileTargets().filter((m) => !t.plane || m.layer === 0);
if (!this.missiles.fire(px, py, t.angle, t.speed, t.plane ? 0 : t.layer, targets, t.plane)) return;
this.events.missile?.(this.panOf(t.x));
}
/** A missile goes off: fireball, a shockwave that wrecks and throws cars, and a hole in whatever it hit. */
private missileBlast(x: number, y: number, layer: number): void {
this.civil.onBlast(x, y, 42, 380, false);
// What did it hit? A vehicle, a building, or the ground itself.
const hitCar = this.traffic.all().some((c) => c.layer === layer && Math.hypot(c.x - x, c.y - y) < c.body.halfL + 3);
this.effects.detonation(x, y, 1.25, 1.2, hitCar ? 'metal' : this.groundAt(x, y));
this.effects.blastMark(x, y, 30);
this.blast(x, y, 0.9, false);
this.boom('medium', x, y);
const radius = 42;
for (const car of this.traffic.all()) {
if (car.layer !== layer) continue;
const dx = car.x - x;
const dy = car.y - y;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const nx = d > 0.1 ? dx / d : Math.cos(car.angle);
const ny = d > 0.1 ? dy / d : Math.sin(car.angle);
const push = (1 - d / radius) * 150 + 30;
if (car.mode === 'drive') this.traffic.syncBody(car);
car.body.vx += nx * push;
car.body.vy += ny * push;
car.vz = Math.max(car.vz, (1 - d / radius) * 60);
car.rollVel += (Math.random() - 0.5) * 8;
this.traffic.applyHit(car, push, car.x - nx * car.body.halfW, car.y - ny * car.body.halfW, -nx, -ny, { material: 'car', color: '#ff9a3a' }, 'pileup');
if (d < radius * 0.5) car.fire = Math.max(car.fire, 12);
}
this.services.blast(x, y, radius, layer);
this.wildlife.blast(x, y, radius);
this.street.blast(x, y, radius * 0.6);
this.breakProps(x, y, radius * 0.6);
this.blastStructures(x, y, radius * 0.7, 380);
this.drama(x, y, 80);
}
/**
* Set a building or tree alight. Timber goes up readily and burns hot; stone needs a
* real blaze. Returns true if it caught.
*/
private ignite(st: Structure, chance = 1, force = false): boolean {
// Timber and trees burn; brick, stone and concrete only catch rarely (what's inside them).
const odds = force ? chance : chance * (st.tree ? 1 : st.mat === 'wood' ? 0.85 : 0.12);
if ((st.burning ?? 0) > 0 || Math.random() > odds) return false;
if (this.burning.length > 450) return false;
st.burning = st.tree ? 14 + Math.random() * 10 : st.mat === 'wood' ? 35 + Math.random() * 25 : 50 + Math.random() * 40;
this.burning.push(st);
return true;
}
private firestormAt = -60;
/**
* How fire moves through a city (Hamada's model and the ember studies): flames and
* radiant heat reach the buildings next door, more readily downwind; embers carried on
* the wind start spot fires well ahead of the front (most building ignitions); and where
* enough fires merge they start to feed each other, a firestorm, and everything spreads
* faster and further.
*/
private spread(st: Structure, r: number): void {
const wx = 0.83;
const wy = -0.55;
const nearby = this.burning.filter((o) => Math.abs(o.x - st.x) < 220 && Math.abs(o.y - st.y) < 220).length;
const storm = nearby >= 10;
if (storm && this.time - this.firestormAt > 40 && this.isNear(st.x, st.y)) {
this.firestormAt = this.time;
this.events.toast('FIRESTORM: the fires have merged and are feeding each other', 'bad');
}
const boost = (storm ? 2 : 1) * (st.destroyed ? 0.6 : 1);
const reach = (st.tree ? 18 : st.mat === 'wood' ? 40 : 30) * (storm ? 1.5 : 1);
for (const n of this.city.structuresNear(st.x, st.y, r + reach, true)) {
if (n === st || (n.burning ?? 0) > 0 || (n.destroyed && !n.tree)) continue;
const dx = n.x - st.x;
const dy = n.y - st.y;
const d = Math.hypot(dx, dy) || 1;
const gap = Math.max(0, d - r - Math.max(n.w, n.d) / 2);
if (gap > reach) continue;
const downwind = (dx * wx + dy * wy) / d;
this.ignite(n, (st.tree ? 0.16 : 0.14) * (1 - gap / reach) * (1 + 0.9 * downwind) * boost);
}
// Embers on the wind: spot fires out ahead of the front.
if (Math.random() < (st.mat === 'wood' || st.tree ? 0.18 : 0.1) * boost) {
const d = range(40, 170) * (storm ? 1.8 : 1);
const ex = st.x + wx * d + range(-40, 40);
const ey = st.y + wy * d + range(-40, 40);
for (const n of this.city.structuresNear(ex, ey, 14)) this.ignite(n, 0.45);
}
// In a firestorm the flames whirl up in columns.
if (storm && Math.random() < 0.025 && inView(st.x, st.y, this.view, 60)) this.effects.gasFire(st.x + range(-r, r) * 0.5, st.y + range(-r, r) * 0.5, range(3, 7));
}
/** Burning spots for the fire brigade. */
burningSpots(): Vec[] {
return this.burning.map((st) => ({ x: st.x, y: st.y }));
}
/** The fire crews hose a spot: buildings and trees there stop burning. */
douse(x: number, y: number, r: number): void {
for (const st of this.burning) if (Math.hypot(st.x - x, st.y - y) < r + Math.max(st.w, st.d) / 2) st.burning = 0;
}
/**
* Fire: burning buildings and trees eat away at themselves until they come down (and
* keep burning as rubble), set their neighbours alight (timber and trees easily, stone
* rarely), ignite wrecks beside them and send people running. Ground fires and burning
* wrecks start new ones.
*/
private updateFires(dt: number): void {
this.fireTick -= dt;
const tick = this.fireTick <= 0;
if (tick) this.fireTick = 0.5;
let nearest = Infinity;
for (const st of this.burning) {
st.burning = (st.burning ?? 0) - dt;
const r = Math.max(st.w, st.d) / 2;
if (!st.destroyed) {
// Fire eats it from within, and anything that burns long enough comes down.
st.burnTime = (st.burnTime ?? 0) + dt;
const rate = st.tree ? 0.13 : st.mat === 'wood' ? 0.085 : 0.04;
const limit = st.tree ? 8 : st.mat === 'wood' ? 12 : 25;
this.hitStructure(st, st.burnTime > limit ? st.hp + 1 : st.maxHp * rate * dt, 0, 0);
}
if (inView(st.x, st.y, this.view, r + 20)) this.effects.flames(st.x, st.y, st.w, st.d, st.angle, dt, st.destroyed ? 0.5 : 1);
nearest = Math.min(nearest, Math.hypot(st.x - this.truck.x, st.y - this.truck.y));
if (!tick) continue;
if (Math.random() < 0.3) this.peds.hearHorn(st.x, st.y);
this.spread(st, r);
}
if (tick) {
// Burning wrecks set what's beside them alight too.
for (const w of this.traffic.wrecks) {
if (w.fire <= 0 || w.layer !== 0) continue;
for (const st of this.city.structuresNear(w.x, w.y, 10)) this.ignite(st, 0.06);
}
}
for (const st of this.burning) if ((st.burning ?? 0) <= 0) {
st.burning = 0;
// The ruins smoulder long after the flames are out.
if (st.destroyed && inView(st.x, st.y, this.view, 60)) this.effects.smokeColumn(st.x, st.y, 18);
}
this.burning = this.burning.filter((st) => (st.burning ?? 0) > 0);
// The roar and crackle of a big fire nearby.
this.crackle -= dt;
if (this.crackle <= 0 && nearest < 400) {
this.crackle = 0.25;
this.events.fire?.(Math.max(0, 1 - nearest / 400));
}
// The steady roar of every fire within earshot, scaled by how much is burning.
let roar = 0;
for (const st of this.burning) {
const d = Math.hypot(st.x - this.truck.x, st.y - this.truck.y);
if (d < 600) roar += (1 - d / 600) * (st.tree ? 0.3 : 1);
}
this.events.fireLevel?.(Math.min(1, roar / 5));
}
/** Destruction points, for A-10 sorties. */
private tally(x: number, y: number, points: number): void {
if (!this.truck.plane || this.phase !== 'playing') return;
this.score += points;
this.destroyed++;
// Only the big kills get a number in the sky; the rest just add up.
if (points >= 60 && this.isNear(x, y) && Math.random() < 0.5) this.floaters.push({ x, y: y - 4, text: `+${points}`, color: '#ffd84a', life: 0.9, scale: 0.8 });
}
/**
* The pall: with enough of the city burning, smoke blots out the sun, a brown-grey
* murk over everything, thickening as the fires grow.
*/
private drawPall(ctx: CanvasRenderingContext2D): void {
const n = this.burning.length;
if (n < 6) return;
const k = Math.min(1, (n - 6) / 140);
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = `rgba(52, 40, 32, ${0.06 + k * 0.3})`;
ctx.fillRect(0, 0, this.devW, this.devH);
// Ash drifting across, flecks of grey and the odd glowing ember.
const flecks = Math.round(20 + k * 120);
for (let i = 0; i < flecks; i++) {
const seed = i * 97.31;
const x = ((Math.sin(seed) * 0.5 + 0.5) * this.devW + this.time * (30 + (i % 7) * 9) * this.dpr) % this.devW;
const y = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * this.devH - this.time * (14 + (i % 5) * 6) * this.dpr + this.devH * 4) % this.devH;
ctx.fillStyle = i % 9 === 0 ? `rgba(255, 140, 50, ${0.5 * k + 0.2})` : `rgba(190, 185, 178, ${0.25 + k * 0.35})`;
ctx.fillRect(x, y, 1.6 * this.dpr, 1.6 * this.dpr);
}
ctx.restore();
}
/** The city's vital signs for the panel. */
cityStatus(): CityStatus {
const c = this.civil;
const hospitals = this.city.stations.filter((s) => s.kind === 'hospital');
return {
alert: c.alertName,
level: c.alert,
power: c.powerShare(),
water: c.waterShare(),
comms: c.count('comms'),
hospitals: [hospitals.filter((s) => !s.structure?.destroyed).length, hospitals.length],
engines: [this.services.available('fire'), this.services.total('fire')],
fires: this.burning.length,
evacuated: c.evacuated(),
dead: c.dead,
trapped: c.trapped(),
rescued: c.rescued,
};
}
/**
* The air raid, when the player's driving: an A-10 arrives a few seconds in, goes
* after the city round the player, and if the air defences bring it down another
* comes in a while later.
*/
private updateRaid(dt: number): void {
if (this.truck.plane || this.phase !== 'playing') return;
if (!this.raider) {
if (!this.raidEnabled || this.raidRespawn <= 0) return;
this.raidRespawn -= dt;
if (this.raidRespawn <= 0) this.spawnRaider();
return;
}
const r = this.raider;
const wasFiring = r.firing;
r.update(dt);
if (r.fall >= 2) {
this.raider = null;
this.raidRespawn = this.raidEnabled ? 40 : 0;
return;
}
// Heard from the ground: the roar as it passes, and the gun's tearing BRRRT.
const d = Math.hypot(r.x - this.truck.x, r.y - this.truck.y);
if (d < 520 && this.time - this.flybyAt > 2.5) {
this.flybyAt = this.time;
this.events.flyby?.(this.panOf(r.x), 1 - d / 520);
}
if (r.firing && !wasFiring && d < 1800 && this.time - this.brrtAt > 0.8) {
this.brrtAt = this.time;
this.events.brrt?.(this.panOf(r.x), d);
}
if (r.damage >= 50 && Math.random() < dt * 20) r.air.smoke(r.x, r.y, r.altitude, r.damage >= 80);
}
private spawnRaider(): void {
const r = new Raider({
focus: () => ({ x: this.truck.x, y: this.truck.y }),
pickTarget: (near) => this.raidTarget(near),
carsNear: (x, y, rad) => this.traffic.all().filter((c) => c.layer === 0 && Math.abs(c.x - x) < rad && Math.abs(c.y - y) < rad).map((c) => c.body),
shotDown: (x, y) => {
const ground = this.groundAt(x, y);
this.effects.detonation(x, y, 3, 1.6, ground);
this.effects.bombBurst(x, y, 120, 2.6, ground, false);
this.effects.bombCrater(x, y, 26, ground);
this.effects.smokeColumn(x, y, 30);
this.blastStructures(x, y, 120, 1600);
this.civil.onBlast(x, y, 120, 1600, false);
this.peds.blast(x, y, 120);
this.boom('huge', x, y);
this.events.toast('The A-10 is down! Another will be along', 'info');
},
toast: (text) => this.events.toast(text, 'info'),
});
r.air.onBomb = (x, y, kind) => this.bombBlast(x, y, kind);
r.air.onRound = (x, y, hei, vx, vy) => this.gunRound(x, y, hei, vx, vy);
r.air.onMaverick = (x, y) => this.maverickBlast(x, y);
this.raider = r;
this.events.toast('AIR RAID! An A-10 is attacking the city', 'bad');
this.events.airRaid?.();
}
/** What the raider goes for: the city's infrastructure first, then buildings, then the traffic. */
private raidTarget(near: Vec): RaiderTarget | null {
const roll = Math.random();
const within = (x: number, y: number, r: number) => Math.hypot(x - near.x, y - near.y) < r;
if (roll < 0.3) {
const infra = this.city.infra.filter((i) => !i.st.destroyed && within(i.st.x, i.st.y, 2600));
if (infra.length) {
const i = pick(infra);
return { x: i.st.x, y: i.st.y, how: i.kind === 'power' || i.kind === 'waterworks' || i.kind === 'shelter' ? 'jdam' : 'bomb' };
}
}
if (roll < 0.75) {
const blds = this.city.buildings.filter((b) => !b.destroyed && within(b.x, b.y, 1300));
if (blds.length) {
const b = pick(blds);
return { x: b.x, y: b.y, how: 'bomb' };
}
}
if (roll < 0.85) {
const units = this.services.vehicles.filter((v) => v.layer === 0 && within(v.x, v.y, 1500));
if (units.length) {
const v = pick(units);
return { x: v.x, y: v.y, how: 'maverick', body: v.body };
}
}
const cars = this.traffic.all().filter((c) => c.layer === 0 && within(c.x, c.y, 1200));
if (cars.length) {
const c = pick(cars);
return { x: c.x, y: c.y, how: 'gun', body: c.body };
}
return null;
}
/** The jet is going down (shot down): seconds into the fall, or false. */
private jetFalling: number | false = false;
/** Anti-aircraft fire finds the jet. */
private hitJet(damage: number): void {
const t = this.truck;
if (!t.plane || this.jetFalling !== false) return;
t.damage = Math.min(100, t.damage + damage);
this.events.jetHit?.();
if (this.options.shake) this.shake = Math.max(this.shake, damage > 20 ? 9 : 2.5);
if (damage > 20) {
this.flash = Math.max(this.flash, 0.25);
this.events.toast(`HIT! Airframe ${Math.round(t.damage)}% damaged`, 'bad');
}
if (t.damage >= 100) {
this.jetFalling = 0;
this.events.toast('She’s going down!', 'bad');
}
}
/** F in the A-10: flares against the missiles. */
popFlares(): void {
if (this.phase !== 'playing' || !this.truck.plane) return;
if (this.civil.popFlares()) this.events.flares?.();
else if (this.civil.flareCount <= 0 && this.time - this.lastHint > 2) {
this.lastHint = this.time;
this.events.toast('Out of flares', 'bad');
}
}
/** The jet's position and velocity, where its weapons leave from. */
private launch(): Launch {
const t = this.truck;
return { x: t.x, y: t.y, z: t.altitude, vx: t.cab.vx, vy: t.cab.vy, heading: t.angle };
}
/**
* The A-10 in flight: wingtip and engine trails, the edge-of-map autopilot, the gun
* (with its recoil), rippled bombs and re-arming.
*/
private flight(dt: number, input: Controls): void {
const t = this.truck;
const f = t.flight;
this.air.update(dt);
this.firing = false;
if (!t.plane || !f || this.phase !== 'playing') {
this.air.cease();
for (const tr of this.trails) tr.length = 0;
return;
}
f.stores = this.air.storesKg;
// Hit: smoke streams from the damaged jet, then fire.
if (t.damage >= 45 && Math.random() < dt * (t.damage >= 80 ? 30 : 12)) this.air.smoke(t.x - Math.cos(t.angle) * 30, t.y - Math.sin(t.angle) * 30, t.altitude, t.damage >= 80);
if (this.jetFalling !== false) {
// Going down: rolled over, trailing fire, falling out of the sky.
this.jetFalling += dt;
f.lever = 0;
f.bank = Math.min(1.4, f.bank + dt * 1.5);
f.fall = Math.min(1, this.jetFalling / 2.4);
t.syncFlight();
if (f.fall >= 1 && this.dying < 0) {
const ground = this.groundAt(t.x, t.y);
this.effects.detonation(t.x, t.y, 3, 1.6, ground);
this.effects.bombBurst(t.x, t.y, 120, 2.6, ground, false);
this.effects.bombCrater(t.x, t.y, 26, ground);
this.effects.smokeColumn(t.x, t.y, 30);
this.blastStructures(t.x, t.y, 120, 1600);
this.peds.blast(t.x, t.y, 120);
this.boom('huge', t.x, t.y);
this.events.jetDown?.();
this.flash = Math.max(this.flash, 0.5);
this.dying = 3.4;
}
this.air.cease();
return;
}
// Record where the wingtips and engines have been (matching how the plane is drawn).
const c = Math.cos(t.angle);
const sn = Math.sin(t.angle);
const span = (8.6 / METERS) * Math.cos(t.bank * 0.7);
const z = t.altitude;
const at = (lx: number, ly: number) => ({ x: t.x + c * lx - sn * ly, y: t.y + sn * lx + c * ly, z });
const pull = clamp((f.g - 1.3) / 2.5, 0, 1);
[at(-1, -span), at(-1, span), at(-14, -4.2), at(-14, 4.2)].forEach((p, i) => {
const tr = this.trails[i];
tr.push({ ...p, t: this.time, bank: pull });
const keep = i < 2 ? 1.6 : 0.7;
while (tr.length && this.time - tr[0].t > keep) tr.shift();
});
// Over the edge of the map: the autopilot banks it back toward the city.
const margin = 200;
if (t.x < margin || t.y < margin || t.x > MAP_W - margin || t.y > MAP_H - margin) {
const home = Math.atan2(MAP_H / 2 - t.y, MAP_W / 2 - t.x);
f.heading += Math.max(-0.9 * dt, Math.min(0.9 * dt, angleDiff(home, f.heading)));
t.syncFlight();
}
// The GAU-8: hold G. 45 kN of recoil, more than one engine's thrust.
if (input.gun && this.rearming <= 0) {
if (this.air.rounds > 0) {
const fired = this.air.fire(dt, this.launch());
if (fired > 0) {
this.firing = true;
f.recoil = GUN_RECOIL * (0.55 + 0.45 * this.air.spin);
if (this.options.shake) this.shake = Math.max(this.shake, 0.8);
if (this.time - this.lastBrrt > 2.5) {
this.lastBrrt = this.time;
const p = this.lift(t.x + c * 90, t.y + sn * 90, z);
if (p) this.floaters.push({ x: p.x, y: p.y, text: 'BRRRRRT!', color: '#ffd84a', life: 1, scale: 1.2 });
}
}
} else if (this.time - this.lastHint > 2) {
this.lastHint = this.time;
this.events.click?.();
this.events.toast('Gun empty: Winchester on 30 mm (R to re-arm)', 'bad');
}
} else this.air.cease();
// Hold B to ripple: a bomb every 0.12 s for as long as it's held.
if (input.bomb) {
this.bombHeld += dt;
if (this.bombHeld > 0.3) this.dropBomb(true);
} else this.bombHeld = 0;
if (this.rearming > 0) {
this.rearming -= dt;
if (this.rearming <= 0) {
this.air.rearm();
this.civil.flareCount = 30;
// The ground crew patches the worst of the holes while they're at it.
t.damage = Math.max(0, t.damage - 40);
this.events.rearmed?.();
this.events.toast('Re-armed and patched: 1,150 rounds, 6 × Mk 82, GBU-31, 2 × Maverick, 30 flares', 'good');
}
}
}
/** R in the A-10: call up more ordnance. */
rearm(): void {
const t = this.truck;
if (this.phase !== 'playing' || !t.plane || this.rearming > 0) return;
this.rearming = 5;
this.events.toast('Ordnance on the way: weapons cold for 5 seconds', 'info');
}
/** V: step to the next bomb mode. */
cycleWeapon(): void {
if (this.phase !== 'playing' || !this.truck.plane) return;
const kind = this.air.cycleMode();
this.events.click?.();
this.events.toast(`${BOMB_NAMES[kind]} · ${this.air.stock(kind)} left`, 'info');
}
/** The HUD's view of the jet and its weapons (null on the ground). */
flightHud(): FlightHud | null {
const t = this.truck;
const f = t.flight;
if (!f || this.phase === 'title') return null;
const from = this.launch();
const bomb = this.air.stock() > 0 ? this.air.ccipBomb(from) : null;
const gun = this.air.ccipGun(from);
const screen = (x: number, y: number, z = 0) => {
const p = this.lift(x, y, z);
return p ? { x: (p.x - this.view.x) * this.zoom, y: (p.y - this.view.y) * this.zoom } : null;
};
return {
speedKmh: f.v * 3.6,
heading: f.heading,
g: f.g,
lever: f.lever,
spool: f.spool,
jet: screen(t.x, t.y, t.altitude),
bomb: bomb ? { at: screen(bomb.x, bomb.y), t: bomb.t } : null,
gun: screen(gun.x, gun.y),
mode: this.air.mode,
rounds: this.air.rounds,
mk82: this.air.mk82,
gbu31: this.air.gbu31,
mavericks: this.air.mavericks,
rearming: this.rearming,
firing: this.firing,
missile: this.civil.missileWarning,
flares: this.civil.flareCount,
time: this.time,
};
}
/** Release a bomb (B, or held B to ripple). */
dropBomb(ripple = false): void {
const t = this.truck;
if (this.phase !== 'playing' || !t.plane || this.dying >= 0 || this.rearming > 0 || this.jetFalling !== false) return;
if (this.air.stock() <= 0) {
if (!ripple) {
this.events.click?.();
this.events.toast(this.air.winchester ? 'Winchester: nothing left on the racks (R to re-arm)' : `No ${BOMB_NAMES[this.air.mode]} left: V to switch`, 'bad');
}
return;
}
const from = this.launch();
const aim = this.air.mode === 'gbu31' ? this.air.ccipBomb(from, 'gbu31') : null;
if (!this.air.drop(from, aim)) return;
this.events.bombDrop?.(this.panOf(t.x));
this.truck.flight!.stores = this.air.storesKg;
}
/**
* A bomb goes off. The tritonal (TNT with 20 % aluminium) flashes white-hot, the steel
* case swells and shatters into a spray of fragments that pepper everything far beyond
* the fireball, the shock wave flattens what's close, a cone of earth is thrown up and
* rains back in a ring, the dust column slumps and rolls out low along the ground, and
* on a humid day a vapour ring flashes out with the shock. A Mk 82 carries 87 kg of
* explosive; the GBU-31's Mk 84 carries 429 kg, and everything scales with it.
*/
private bombBlast(x: number, y: number, kind: BombKind): void {
const big = kind === 'gbu31';
// Blast radius scales with the cube root of the charge: about 1.7 × for the Mk 84.
const R = big ? 165 : 98;
this.civil.onBlast(x, y, R, big ? 1400 : 700, this.city.surfaceAt(x, y) === SURF_ROAD);
const size = big ? 3.8 : 2.8;
const ground = this.groundAt(x, y);
const wet = ground === 'water';
this.effects.detonation(x, y, size, 1.6, ground);
this.effects.bombBurst(x, y, R, size, ground, this.humid());
this.effects.smokeColumn(x, y, big ? 34 : 22);
if (!wet) {
this.effects.blastMark(x, y, R * 0.75);
this.effects.bombCrater(x, y, big ? 40 : 24, ground);
}
this.boom('huge', x, y);
this.events.bigBlast?.(this.panOf(x), big ? 2.2 : 1.4);
const t = this.truck;
const slant = Math.hypot(x - t.x, y - t.y, t.altitude) * METERS;
if (this.isNear(x, y)) {
// From the cockpit it's a flash below, not a wall of light.
this.flash = Math.max(this.flash, (big ? 0.7 : 0.5) * (t.plane ? 0.45 : 1));
// Felt through the airframe: the closer, the harder the jolt.
this.bump(clamp((big ? 34 : 24) * (1 - slant / 1600), 4, big ? 34 : 24));
this.hitStop = Math.max(this.hitStop, (big ? 0.12 : 0.08) * (t.plane ? 0.4 : 1));
}
// On the ground under someone else's bombs: the blast throws the player's ride too.
if (!t.plane && t.layer === 0) {
const d = Math.hypot(t.x - x, t.y - y);
if (d < R * 1.3) {
const k = 1 - d / (R * 1.3);
const nx = d > 0.1 ? (t.x - x) / d : 1;
const ny = d > 0.1 ? (t.y - y) / d : 0;
for (const b of [t.cab, t.trailer]) {
b.vx += nx * k * 220;
b.vy += ny * k * 220;
}
t.damage = Math.min(100, t.damage + k * (big ? 70 : 45) * this.armor());
this.events.crash?.(1 + k * 1.5, 'wall', this.panOf(x));
}
}
for (const car of this.traffic.all()) {
if (car.layer !== 0) continue;
const dx = car.x - x;
const dy = car.y - y;
const d = Math.hypot(dx, dy);
if (d > R * 2.6) continue;
const nx = d > 0.1 ? dx / d : 1;
const ny = d > 0.1 ? dy / d : 0;
if (car.mode === 'drive') this.traffic.syncBody(car);
if (d > R) {
// Out past the fireball the fragments still arrive: glass gone, panels holed.
if (Math.random() < 1 - (d - R) / (R * 1.6)) {
this.traffic.applyHit(car, 18 + Math.random() * 30, car.x - nx * car.body.halfW, car.y - ny * car.body.halfW, -nx, -ny, { material: 'car', color: '#ffb35a' }, 'pileup');
if (Math.random() < 0.12) car.fire = Math.max(car.fire, 10);
}
continue;
}
const push = (1 - d / R) * (big ? 340 : 260) + 40;
car.body.vx += nx * push;
car.body.vy += ny * push;
car.vz = Math.max(car.vz, (1 - d / R) * (big ? 260 : 210) + 20);
car.dmg.z = Math.max(car.dmg.z, 0.5);
car.rollVel += (Math.random() - 0.5) * 14;
this.traffic.applyHit(car, push * 1.2, car.x - nx * car.body.halfW, car.y - ny * car.body.halfW, -nx, -ny, { material: 'car', color: '#ff9a3a' }, 'pileup');
// Caught in the blast, cars burn rather than going off one after another.
car.fuse = 0;
car.exploded = true;
if (d < R * 0.7) car.fire = Math.max(car.fire, 18);
}
this.services.blast(x, y, R, 0);
this.peds.blast(x, y, R);
// Fragments reach people well beyond the blast itself.
this.peds.blast(x, y, R * 2.2);
this.wildlife.blast(x, y, R * 1.6);
this.street.blast(x, y, R * 0.8);
this.breakProps(x, y, R * 0.9);
this.blastStructures(x, y, R * 1.05, big ? 2600 : 1700);
// Nothing near ground zero is left standing.
for (const st of this.city.structuresNear(x, y, R * 0.45)) this.hitStructure(st, st.hp + 1, st.x - x, st.y - y, true);
this.drama(x, y, big ? 200 : 160, false);
}
/** On a damp morning or at night the shock wave can condense a vapour ring. */
private humid(): boolean {
const dampHours = this.hour < 9 || this.hour > 20;
return Math.random() < (dampHours ? 0.6 : 0.2);
}
/** An AGM-65 Maverick's warhead: the missile blast, a size up. */
private maverickBlast(x: number, y: number): void {
this.missileBlast(x, y, 0);
this.effects.detonation(x, y, 1.1, 1, this.groundAt(x, y));
this.drama(x, y, 120, false);
}
/**
* One 30 mm round. Five in six are armour-piercing incendiary: a small hole in, a
* spark and a flash of burning uranium, and real harm only if it finds the engine or
* the fuel tank (or the soft side of something). Every sixth is high-explosive
* incendiary: a little blast that sets fuel and timber alight. All of them gouge the
* ground, so a strafing run leaves its scar.
*/
private gunRound(x: number, y: number, hei: boolean, vx: number, vy: number): void {
const ground = this.city.surfaceAt(x, y);
const earth = ground === SURF_GRASS || ground === SURF_DIRT;
const near = this.isNear(x, y);
const what = this.groundAt(x, y);
const angle = Math.atan2(vy, vx);
if (what === 'water') {
// Rounds into water: spouts of spray instead of gouges.
if (near) this.effects.water(x, y, x + (Math.random() - 0.5) * 8, y - 6);
} else if (what === 'wood' || what === 'stone') {
// Into a building: splinters or chips of masonry.
if (near) this.effects.crash(x, y, hei ? 0.25 : 0.12, what === 'wood' ? ['#8a6a44', '#a0703c'] : ['#9e978a', '#6f6a62'], { x: 0, y: 0 }, { material: what === 'wood' ? 'wood' : 'concrete', glass: Math.random() < 0.2 });
} else this.effects.gouge(x, y, angle, earth, near);
if (near) {
if (hei) {
this.effects.puff(x, y, false, true, 1.1);
if (!earth) this.effects.scrape(x, y, (Math.random() - 0.5) * 80, (Math.random() - 0.5) * 80);
} else {
// The uranium penetrator flashes as it strikes; sparks skip off anything hard.
if (!earth || Math.random() < 0.3) this.effects.scrape(x, y, Math.cos(angle) * 120 + (Math.random() - 0.5) * 60, Math.sin(angle) * 120 + (Math.random() - 0.5) * 60);
if (earth && Math.random() < 0.5) this.effects.dustSpurt(x, y, angle);
}
}
for (const car of this.traffic.all()) {
if (car.layer !== 0 || Math.abs(car.x - x) > 9 || Math.abs(car.y - y) > 9) continue;
const lx = (x - car.x) * Math.cos(car.angle) + (y - car.y) * Math.sin(car.angle);
const ly = -(x - car.x) * Math.sin(car.angle) + (y - car.y) * Math.cos(car.angle);
if (Math.abs(lx) > car.body.halfL + 1.5 || Math.abs(ly) > car.body.halfW + 1.5) continue;
if (car.mode === 'drive') this.traffic.syncBody(car);
// Engine up front, fuel tank at the back: hits there are what kill a car.
const vital = Math.abs(lx) > car.body.halfL * 0.45;
const push = hei ? 55 : vital ? 38 : 16;
this.traffic.applyHit(car, push, x, y, Math.cos(angle) * 0.6, Math.sin(angle) * 0.6, { material: 'car', color: hei ? '#ffd84a' : '#fff1c4' }, 'pileup');
if (hei && Math.random() < 0.3) car.fire = Math.max(car.fire, 12);
else if (!hei && vital && Math.random() < 0.12) car.fire = Math.max(car.fire, 10);
if (vital && Math.random() < (hei ? 0.1 : 0.04)) car.fuse = Math.max(car.fuse, 0.3);
}
const r = hei ? 7 : 3;
if (hei) this.civil.onBlast(x, y, 10, 14, false);
// The raider's rounds find the player's ride too.
const t = this.truck;
if (!t.plane && t.layer === 0 && Math.hypot(t.x - x, t.y - y) < t.cab.halfL + 3) {
t.damage = Math.min(100, t.damage + (hei ? 4 : 2) * this.armor());
t.dent(x, y, hei ? 1.4 : 0.7);
if (near) this.effects.scrape(x, y, (Math.random() - 0.5) * 120, (Math.random() - 0.5) * 120);
}
this.services.blast(x, y, r + 1, 0);
this.peds.blast(x, y, r);
this.wildlife.blast(x, y, r);
this.street.blast(x, y, hei ? 5 : 2.5);
this.breakProps(x, y, hei ? 6 : 3);
// Armour-piercing rounds punch holes; the explosive ones chew. A building takes a long burst.
for (const st of this.city.structuresNear(x, y, hei ? 4 : 2, true)) {
// Only the explosive rounds can set what they bring down alight.
if (!st.destroyed) this.hitStructure(st, st.tree ? (hei ? 12 : 5) : st.station ? (hei ? 8 : 3) : hei ? 14 : 5, st.x - x, st.y - y, hei);
}
}
/** Market stalls and party gear caught in a blast. */
private breakProps(x: number, y: number, r: number): void {
for (const prop of this.props) {
if (prop.broken || Math.hypot(prop.body.x - x, prop.body.y - y) > r) continue;
prop.broken = true;
const dir = { x: prop.body.x - x, y: prop.body.y - y };
const l = Math.hypot(dir.x, dir.y) || 1;
this.effects.crash(prop.body.x, prop.body.y, 0.8, prop.kind === 'stall' ? [prop.colors[0], prop.colors[1], '#8a6a44'] : ['#1b1b1f', '#55524a'], { x: dir.x / l, y: dir.y / l }, { material: 'wood', glass: false });
}
}
/** How much a knock hurts: the sports car is fragile, the dozer is armour plate. */
private armor(): number {
return this.truck.sports ? 1.5 : this.truck.dozer ? 0.1 : 1;
}
/** How hard the rig hits a building: speed beyond what the wall can shrug off, times the rig's weight. */
private rigPunch(impact: number, st: Structure): number {
// A sports car weighs a fraction of the rig: it takes real speed to go through a wall.
const weight = this.truck.sports ? 0.45 : this.truck.dozer ? 4 : this.truck.loaded ? 1.6 : 1.2;
return Math.max(0, impact - (st.mat === 'wood' ? 15 : 45)) * weight;
}
/**
* Look a moment ahead of the moving rig: if it's about to hit a building hard enough
* to bring it down, bring it down first, so the rig ploughs through instead of bouncing.
*/
private smashAhead(dt: number): void {
const t = this.truck;
if (t.layer !== 0) return;
for (const b of [t.cab, t.trailer]) {
const v = Math.hypot(b.vx, b.vy);
if (v < 25) continue;
const ux = b.vx / v;
const uy = b.vy / v;
const reach = b.halfL * 0.8 + v * dt * 2;
const px = b.x + ux * reach;
const py = b.y + uy * reach;
const push = this.city.resolveCircle(px, py, b.halfW, 0);
const st = this.city.lastStructure;
if (!st || (!push.x && !push.y)) continue;
const l = Math.hypot(push.x, push.y);
const closing = -(b.vx * push.x + b.vy * push.y) / l;
if (closing < 20 || st.hp > this.rigPunch(closing, st)) continue;
this.hitStructure(st, this.rigPunch(closing, st), ux, uy);
// Through the wall: it costs speed (more for stone) and hurts.
const keep = st.mat === 'wood' ? 0.82 : 0.55;
for (const body of [t.cab, t.trailer]) {
body.vx *= keep;
body.vy *= keep;
}
t.damage = Math.min(100, t.damage + (st.mat === 'wood' ? 3 : 12));
t.dent(px, py, st.mat === 'wood' ? 1.2 : 2.5);
if (this.phase === 'playing') this.floaters.push({ x: st.x, y: st.y, text: st.mat === 'wood' ? 'SMASHED THROUGH!' : 'DEMOLISHED!', color: DANGER, life: 1.5, scale: 1.2 });
return;
}
}
/** A building takes a hit; enough and it comes down. (dx, dy): direction of the blow. */
/**
* A building or tree takes a hit; enough and it comes down. `blast`: an explosion did
* it, and an explosion that brings down something that burns sets it alight.
*/
private hitStructure(st: Structure, amount: number, dx: number, dy: number, blast = false): void {
if (!this.city.damageStructure(st, amount)) return;
const l = Math.hypot(dx, dy) || 1;
this.collapse(st, dx / l, dy / l);
if (blast) this.burnFromBlast(st);
}
/** Blown down: trees and timber always go up; brick and stone burn on what was inside. */
private burnFromBlast(st: Structure): void {
if (st.tree || st.mat === 'wood') this.ignite(st, 1, true);
else if (st.building || st.station) this.ignite(st, 0.7, true);
}
/** Down it comes: debris everywhere, dust, maybe fire, and a crater-free hole in the skyline. */
private collapse(st: Structure, dx: number, dy: number): void {
st.destroyed = true;
this.civil.onCollapse(st);
if (st.fuel) {
// Fuel, ammunition, aircraft: it goes up a moment later, and can set off its neighbours.
this.pendingBlasts.push({ t: this.time + 0.05 + Math.random() * 0.3, x: st.x, y: st.y, size: Math.min(3.6, 1.1 + st.fuel) });
this.ignite(st, 1, true);
}
// Things fall away from whatever knocked them down.
st.fallAngle = dx || dy ? Math.atan2(dy, dx) : Math.random() * Math.PI * 2;
st.fellAt = this.time;
if (st.tree) {
// A tree goes over in a burst of leaves and splinters.
this.effects.crash(st.x, st.y, 0.45, ['#2f6a35', '#6db454', '#4a3222', '#1f4d2c'], { x: dx, y: dy }, { material: 'wood', glass: false });
if (!this.collapsed.includes(st)) this.collapsed.push(st);
this.tally(st.x, st.y, 5);
return;
}
this.effects.collapse(st.x, st.y, st.w, st.d, st.angle, st.mat, dx * 1.2, dy * 1.2);
if (st.mat === 'wood' && Math.random() < 0.35) this.effects.structureHit(st.x, st.y, 130, dx, dy, 'wood');
this.boom(st.mat === 'wood' ? 'collapse-wood' : 'collapse-stone', st.x, st.y);
if (this.isNear(st.x, st.y)) {
this.bump(st.mat === 'wood' ? 8 : 14);
if (st.building && Math.hypot(st.x - this.truck.x, st.y - this.truck.y) < 250) this.hitStop = Math.max(this.hitStop, 0.04);
}
// Falling masonry and timber batter whatever's right next door.
for (const n of this.city.structuresNear(st.x, st.y, Math.max(st.w, st.d) / 2 + 6)) {
if (n === st || n.tree) continue;
this.hitStructure(n, n.maxHp * (st.mat === 'stone' ? 0.35 : 0.2), n.x - st.x, n.y - st.y);
}
this.peds.blast(st.x, st.y, Math.max(st.w, st.d) * 0.6);
this.peds.hearHorn(st.x, st.y);
if (!this.collapsed.includes(st)) this.collapsed.push(st);
this.tally(st.x, st.y, st.tree ? 5 : st.building ? 120 : 20);
}
/** Explosions knock buildings about too: within r, up to `power` damage. */
private blastStructures(x: number, y: number, r: number, power: number): void {
for (const st of this.city.structuresNear(x, y, r)) {
const d = Math.max(0, Math.hypot(st.x - x, st.y - y) - Math.max(st.w, st.d) / 2);
const amount = power * (1 - d / r) * (st.mat === 'wood' ? 1.4 : 0.8);
this.hitStructure(st, amount, st.x - x, st.y - y, true);
}
}
/** Collapsed buildings for the repair crews: rebuilt once nothing's standing in the way. */
private rubble(): Repairable[] {
// Felled trees stay down; the crews rebuild the rest.
return this.collapsed.filter((st) => !st.tree).map((st) => ({
x: st.x,
y: st.y,
fix: () => {
const r = Math.max(st.w, st.d) / 2 + 6;
const blocked = Math.hypot(this.truck.x - st.x, this.truck.y - st.y) < r || this.traffic.all().some((c) => Math.hypot(c.x - st.x, c.y - st.y) < r);
if (blocked) return;
this.city.rebuild(st);
this.collapsed.splice(this.collapsed.indexOf(st), 1);
for (let i = 0; i < 6; i++) this.effects.dust(st.x + (Math.random() - 0.5) * st.w, st.y + (Math.random() - 0.5) * st.d, 2);
},
}));
}
/** Rubble where buildings stood: splintered timber or broken stone. */
private drawRubble(ctx: CanvasRenderingContext2D): void {
for (const st of this.collapsed) {
if (!inView(st.x, st.y, this.view, Math.max(st.w, st.d))) continue;
if (st.tree) {
// A felled, scorched tree: blackened ground, a stump and the trunk lying across it.
const r = st.w / 2;
ctx.fillStyle = 'rgba(38, 32, 26, 0.85)';
ctx.beginPath();
ctx.arc(st.x, st.y, r, 0, Math.PI * 2);
ctx.fill();
const a = st.fallAngle ?? st.id * 2.399;
ctx.strokeStyle = '#4a3222';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(st.x, st.y);
ctx.lineTo(st.x + Math.cos(a) * r * 1.3, st.y + Math.sin(a) * r * 1.3);
ctx.stroke();
ctx.fillStyle = '#2b1d14';
ctx.beginPath();
ctx.arc(st.x, st.y, 1.6, 0, Math.PI * 2);
ctx.fill();
continue;
}
const wood = st.mat === 'wood';
ctx.save();
ctx.translate(st.x, st.y);
ctx.rotate(st.angle);
ctx.fillStyle = wood ? '#3a2c22' : '#4a4642';
ctx.fillRect(-st.w / 2, -st.d / 2, st.w, st.d);
const n = Math.min(40, 8 + (st.w * st.d) / 60);
for (let i = 0; i < n; i++) {
const u = Math.sin(i * 12.9898 + st.id) * 0.46 * st.w;
const v = Math.sin(i * 78.233 + st.id * 3.1) * 0.46 * st.d;
ctx.fillStyle = wood ? ['#8a6a44', '#5d4037', '#a0703c'][i % 3] : ['#9e978a', '#6f6a62', '#b8b2a6'][i % 3];
ctx.save();
ctx.translate(u, v);
ctx.rotate(i * 0.7);
ctx.fillRect(-2, -0.6, wood ? 5 : 3, wood ? 1.2 : 2.4);
ctx.restore();
}
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
ctx.lineWidth = 0.8;
ctx.strokeRect(-st.w / 2, -st.d / 2, st.w, st.d);
// The collapse itself: the roof slumps and caves in, cracking apart, over a second.
const fall = st.fellAt === undefined ? 1 : (this.time - st.fellAt) / 1.1;
if (fall < 1) {
const k = 1 - fall;
const sink = 1 - fall * 0.25;
ctx.globalAlpha = k;
ctx.fillStyle = wood ? '#7a5a3a' : '#8a857c';
ctx.fillRect((-st.w / 2) * sink, (-st.d / 2) * sink, st.w * sink, st.d * sink);
ctx.strokeStyle = 'rgba(20, 16, 14, 0.8)';
ctx.lineWidth = 0.9;
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = st.id + i * 1.7;
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * st.w * 0.5 * sink * (0.4 + fall), Math.sin(a) * st.d * 0.5 * sink * (0.4 + fall));
}
ctx.stroke();
ctx.fillStyle = `rgba(20, 16, 14, ${0.5 * fall})`;
ctx.beginPath();
ctx.ellipse(0, 0, st.w * 0.3 * fall, st.d * 0.3 * fall, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.restore();
}
}
private wallCrash(impact: number, x: number, y: number, material: Material): void {
const truck = this.truck;
const k = impact / 100;
const hard = material === 'concrete' ? 1.15 : material === 'barrier' ? 0.75 : 1;
truck.damage = Math.min(100, truck.damage + Math.max(0, impact - 16) * 0.16 * hard * this.armor());
truck.dent(x, y, k * 2.2 * hard);
const dir = { x: x - truck.x, y: y - truck.y };
const l = Math.hypot(dir.x, dir.y) || 1;
this.effects.crash(x, y, k * hard, [COLORS.cab, COLORS.trailer, '#8d9096', '#55524a'], { x: -dir.x / l, y: -dir.y / l }, { panels: impact > 90 ? 1 : 0, material, glass: impact > 60 });
this.drama(x, y, impact * hard);
if (material === 'concrete' || material === 'wood') {
this.effects.structureHit(x, y, impact * hard, -dir.x / l, -dir.y / l, material);
if (impact > 30 && this.isNear(x, y)) this.events.structureHit?.(impact * hard, this.panOf(x));
}
this.bump(2 + k * 8);
if (impact > 120 && this.options.shake) this.slowmo = Math.max(this.slowmo, 0.5);
if (this.time - this.lastWallHit > 0.15) this.events.crash?.(k, 'wall', this.panOf(x));
this.lastWallHit = this.time;
}
private collideTruckWithCars(active: boolean): void {
const truck = this.truck;
for (const car of this.traffic.all()) {
if (car.layer !== truck.layer || car.dmg.z > 8) continue;
if (Math.abs(car.x - truck.x) > 70 || Math.abs(car.y - truck.y) > 70) continue;
for (const body of [truck.cab, truck.trailer]) {
if (car.mode === 'drive') this.traffic.syncBody(car);
const c = boxContact(body, car.body);
if (!c) continue;
if (car.mode === 'drive' && Math.hypot(body.vx, body.vy) < 8) {
// A driver nosing into the parked rig: a bump, they wait.
this.traffic.bump(car, c.depth);
continue;
}
const hit = resolveContact(body, car.body, c, 0.12, 0.45);
if (hit.closing < 6) continue;
const k = this.traffic.applyHit(car, hit.closing, c.px, c.py, -c.nx, -c.ny, { material: 'truck', color: COLORS.cab }, 'car');
if (k <= 0.12 || !active) continue;
truck.damage = Math.min(100, truck.damage + Math.max(0, hit.closing - 14) * 0.06 * (car.body.mass / 1.5) * (truck.sports ? 2.2 : this.armor()));
truck.dent(c.px, c.py, k * 1.6);
this.drama(c.px, c.py, hit.closing * Math.min(1.5, 0.6 + car.body.mass / 3));
}
}
}
private isNear(x: number, y: number): boolean {
return inView(x, y, this.view, 80);
}
private panOf(x: number): number {
return Math.max(-1, Math.min(1, ((x - this.view.x) / Math.max(1, this.view.w)) * 2 - 1));
}
/** Every shift starts out in the country or the industrial park. */
private newTruck(): Truck {
const starts = this.city.lanes.filter(
(l) => l.kind === 'street' && (l.district === 'country' || l.district === 'industrial') && l.layer0 === 0 && l.len > 80,
);
const lane = starts.length ? pick(starts) : null;
if (lane) {
const p = poseAt(lane, lane.len * 0.5);
return new Truck(p.x, p.y, p.angle, this.vehicle);
}
const spot = pick(this.city.pickupSpots);
return new Truck(spot.x, spot.y, spot.angle, this.vehicle);
}
towBusy(): boolean {
return !!this.services.active('mytow');
}
/** What each tow option costs right now: it's your own truck, so mates' rates. */
towQuote(): Record<TowChoice, number> {
const repair = Math.round(this.truck.damage * 1.5) + 20;
return { repair, reposition: 25, both: repair + 20 };
}
requestTow(choice: TowChoice): void {
if (this.phase !== 'playing' || this.towBusy()) return;
if (this.truck.plane) {
this.events.toast('No tow trucks up here, Warthog', 'info');
return;
}
const cost = this.towQuote()[choice];
this.towChoice = choice;
// Your own tow truck: it's never far away and nothing gets in its way.
const ok = this.services.dispatchExpress(this.truck.x, this.truck.y, {
points: () => [{ x: this.truck.x, y: this.truck.y }],
work: 1.2,
onWorking: (crew, point) => {
if (Math.random() < 0.3) this.effects.scrape(point.x, point.y, crew.x - point.x, crew.y - point.y);
this.workTimer -= 1 / 60;
if (this.workTimer <= 0) {
this.workTimer = 0.8;
this.events.towWork?.(this.panOf(point.x));
}
},
onPointDone: () => this.applyTow(this.towChoice),
});
if (!ok) {
this.events.toast('Your tow truck can\u2019t find a road to you here', 'bad');
return;
}
this.score = Math.max(0, this.score - cost);
this.events.toast(`Your tow truck is on its way (-$${cost})`, 'info');
}
private applyTow(choice: TowChoice): void {
const t = this.truck;
if (choice !== 'reposition') {
t.damage = 0;
t.cabDents.length = 0;
t.trailerDents.length = 0;
}
if (choice !== 'repair') {
const p = this.services.roadPointNear(t.x, t.y);
if (p) {
for (const b of [t.cab, t.trailer]) {
b.vx = b.vy = 0;
b.av = 0;
b.angle = p.angle;
}
t.cab.x = p.x;
t.cab.y = p.y;
t.trailer.x = p.x - Math.cos(p.angle) * 24;
t.trailer.y = p.y - Math.sin(p.angle) * 24;
t.rolled = false;
t.roll = 0;
t.rollVel = 0;
t.layer = p.layer;
this.rolledTime = 0;
}
}
const text = choice === 'repair' ? 'GOOD AS NEW!' : choice === 'reposition' ? 'BACK ON THE ROAD!' : 'FIXED & ON THE ROAD!';
this.floaters.push({ x: t.x, y: t.y, text, color: PICKUP, life: 1.8, scale: 1.2 });
}
/** The rig rammed a city service unit. */
private hitUnit(v: ServiceVehicle, closing: number, px: number, py: number): void {
if (this.phase !== 'playing' || this.dying >= 0) return;
const truck = this.truck;
truck.damage = Math.min(100, truck.damage + Math.max(0, closing - 14) * 0.06 * (v.body.mass / 1.5));
truck.dent(px, py, Math.min(3, closing / 60));
this.drama(px, py, closing * Math.min(1.5, 0.6 + v.body.mass / 3));
if (this.isNear(px, py)) {
this.events.crash?.(closing / 100, 'car', this.panOf(px));
if (closing > 20) this.events.carFlavor?.(v.look.model, closing / 100, this.panOf(px));
}
}
/** A unit written off: its shell joins the wrecks (and gets towed like any other). */
private unitWrecked(v: ServiceVehicle): void {
const car = this.traffic.adoptWreck(v.look, v.body, v.layer, v.dmg);
car.engine = 60;
car.smokeKind = 'black';
car.smokeLevel = 0.7;
car.alarm = 0;
if (v.towing) {
// Whatever it was towing drops off the hook.
const back = v.body.halfL + v.towing.look.length / 2;
const towed = this.traffic.adoptWreck(v.towing.look, { ...v.body, x: v.x - Math.cos(v.body.angle) * back, y: v.y - Math.sin(v.body.angle) * back }, v.layer, v.towing.dmg);
towed.wreckTime = 999;
}
if (this.phase === 'playing' && Math.hypot(v.x - this.truck.x, v.y - this.truck.y) < 300) {
this.floaters.push({ x: v.x, y: v.y, text: 'UNIT DOWN!', color: DANGER, life: 1.5, scale: 1.1 });
}
}
/** Smashed stalls and party gear, for the repair crews. */
private brokenProps(): Repairable[] {
return this.props
.filter((p) => p.broken)
.map((p) => ({
x: p.body.x,
y: p.body.y,
fix: () => {
p.broken = false;
},
}));
}
private end(reason: string): void {
this.phase = 'over';
this.overReason = reason;
this.dying = -1;
this.events.over();
}
private bump(amount: number): void {
// Up in the A-10 the blasts are far below: a jolt, not an earthquake.
if (this.options.shake) this.shake = Math.max(this.shake, amount * (this.truck.plane ? 0.18 : 1));
}
private makeJob(kind: JobKind): Job {
const from = this.truck;
let pool: Spot[];
let minD: number;
if (kind === 'pickup') {
pool = this.city.pickupSpots;
minD = 260;
} else {
// Anywhere people want things, old city or new: the docks, the Strip, the suburb, the town.
const weights: [District, number][] = [
['uptown', 18],
['hood', 16],
['port', 12],
['village', 10],
['downtown', 12],
['docks', 8],
['commercial', 8],
['suburb', 8],
['factory', 4],
['town', 4],
];
let roll = Math.random() * weights.reduce((t, [, w]) => t + w, 0);
let district: District = 'downtown';
for (const [d, w] of weights) {
roll -= w;
if (roll <= 0) {
district = d;
break;
}
}
pool = this.city.dropSpots.filter((s) => s.district === district);
if (!pool.length) pool = this.city.dropSpots;
minD = 600;
}
let spot = pick(pool);
for (let attempt = 0; attempt < 60; attempt++) {
const candidate = pick(pool);
const d = Math.hypot(candidate.x - from.x, candidate.y - from.y);
// Pickups close by; deliveries a decent haul, but not across the whole map.
if (d >= minD && (kind === 'deliver' ? d < 9000 : d < 1000)) {
spot = candidate;
break;
}
}
return { kind, spot, progress: 0, damageAtStart: this.truck.damage, distance: Math.hypot(spot.x - from.x, spot.y - from.y) };
}
private updateJob(dt: number): void {
const job = this.job;
// Loaded: the family will take the cargo off your hands instead, for a favor.
if (job.kind === 'deliver') {
const hq = this.mob.hq;
const dm = Math.hypot(this.truck.x - hq.x, this.truck.y - hq.y);
if (dm < LOAD_RADIUS && Math.abs(this.truck.speed) < LOAD_SPEED && this.truck.layer === 0) {
this.mobProgress += dt / LOAD_SECONDS;
if (this.mobProgress >= 1) {
this.mobProgress = 0;
this.mobDelivery();
return;
}
} else {
this.mobProgress = Math.max(0, this.mobProgress - dt * 2);
}
}
const d = Math.hypot(this.truck.x - job.spot.x, this.truck.y - job.spot.y);
if (d < LOAD_RADIUS && Math.abs(this.truck.speed) < LOAD_SPEED && this.truck.layer === 0) {
job.progress += dt / LOAD_SECONDS;
if (job.progress >= 1) this.completeJob();
} else {
job.progress = Math.max(0, job.progress - dt * 2);
}
}
private completeJob(): void {
const spot = this.job.spot;
if (this.job.kind === 'pickup') {
this.floaters.push({ x: spot.x, y: spot.y, text: 'LOADED!', color: PICKUP, life: 1.4 });
this.truck.setLoaded(true);
this.job = this.makeJob('deliver');
this.events.toast(this.truck.dozer ? `Load in the blade — push it to ${DISTRICT_NAMES[this.job.spot.district]}` : this.truck.sports ? `Parcel aboard — deliver to ${DISTRICT_NAMES[this.job.spot.district]}` : `Loaded (heavier now!) — deliver to ${DISTRICT_NAMES[this.job.spot.district]}`, 'info');
this.events.pickup?.();
return;
}
const clean = this.truck.damage - this.job.damageAtStart < 3;
const points = 150 + Math.round(this.job.distance / 6) + (clean ? 100 : 0);
this.score += points;
this.deliveries += 1;
this.truck.damage = Math.max(0, this.truck.damage - 10);
this.truck.setLoaded(false);
this.floaters.push({ x: spot.x, y: spot.y, text: `+${points}`, color: DROP, life: 1.6 });
this.events.toast(clean ? `Clean delivery! +${points}` : `Delivered! +${points}`, 'good');
this.events.delivered?.(points);
this.job = this.makeJob('pickup');
}
/** Supplies delivered to the family: they owe you one. */
private mobDelivery(): void {
const hq = this.mob.hq;
this.truck.setLoaded(false);
this.score += 80;
const capped = this.mob.favors >= MAX_FAVORS;
this.mob.favors = Math.min(MAX_FAVORS, this.mob.favors + 1);
this.floaters.push({ x: hq.x, y: hq.y, text: capped ? '+$80' : '+1 FAVOR', color: MOB, life: 1.8, scale: 1.2 });
this.events.toast(capped ? 'The family thanks you (favors maxed) +$80' : `The family owes you one. Favors: ${this.mob.favors} — press F to call a hit`, 'good');
this.events.delivered?.(80);
this.job = this.makeJob('pickup');
}
private disrespect(why: string): void {
if (this.time - this.lastDisrespect < 4) return;
this.lastDisrespect = this.time;
const lost = this.mob.favors > 0;
this.mob.favors = Math.max(0, this.mob.favors - 1);
this.events.toast(`${why} The family is not happy${lost ? ' (-1 favor)' : ''}`, 'bad');
}
favors(): number {
return this.mob.favors;
}
/** Who the family could take care of right now: the car ahead, and the nearest building. */
hitTargets(): { car: Car | null; building: Building | null } {
const t = this.truck;
const fx = Math.cos(t.angle);
const fy = Math.sin(t.angle);
let car: Car | null = null;
let best = Infinity;
for (const c of this.traffic.cars) {
// Only cars you can see: you'll want to watch.
if (c.mode !== 'drive' || c.marked || c.layer !== 0 || !inView(c.x, c.y, this.view, -15)) continue;
const dx = c.x - t.x;
const dy = c.y - t.y;
const d = Math.hypot(dx, dy);
const along = dx * fx + dy * fy;
if (d > 260 || along < -40) continue;
const score = d - along * 0.6;
if (score < best) {
best = score;
car = c;
}
}
let building: Building | null = null;
best = Infinity;
for (const b of this.city.buildings) {
const d = Math.hypot(b.x - t.x, b.y - t.y);
if (d < 260 && d < best && !b.destroyed && inView(b.x, b.y, this.view, -10)) {
best = d;
building = b;
}
}
return { car, building };
}
/** Spend a favor: the family takes care of a car or a building. */
orderHit(kind: 'car' | 'building'): void {
if (this.phase !== 'playing' || this.mob.favors <= 0) return;
const { car, building } = this.hitTargets();
if (kind === 'car' && car) {
this.mob.favors--;
this.mob.hitCar(car, this.view);
this.events.toast('Consider it done. Watch that car.', 'info');
} else if (kind === 'building' && building) {
this.mob.favors--;
this.mob.hitBuilding(building);
this.events.toast('The boys are on their way. Stand well back.', 'info');
}
}
/** Skid marks and tyre smoke from sliding wheels. */
private layTracks(): void {
const track = (key: object, wheels: Vec[], width: number) => {
const prev = this.wheelPrev.get(key);
if (!wheels.length) {
if (prev) this.wheelPrev.delete(key);
return;
}
if (prev && prev.length === wheels.length) {
for (let i = 0; i < wheels.length; i++) {
const a = prev[i];
const b = wheels[i];
const d = Math.hypot(b.x - a.x, b.y - a.y);
if (d > 0.4 && d < 20) this.effects.skid(a.x, a.y, b.x, b.y, width);
}
}
this.wheelPrev.set(key, wheels);
};
const t = this.truck;
const tw = t.slidingWheels();
track(t, Math.abs(t.speed) > 6 || t.slip[1] ? tw : [], 1.9);
if (tw.length && Math.random() < 0.6) {
const w = tw[Math.floor(Math.random() * tw.length)];
this.effects.tyreSmoke(w.x, w.y, 1 + Math.min(1, Math.abs(t.speed) / 80));
}
for (const car of this.traffic.all()) {
if (!inView(car.x, car.y, this.view, 60)) {
this.wheelPrev.delete(car);
continue;
}
const wheels = car.skidding && car.dmg.z <= 0 ? this.carWheels(car) : [];
track(car, wheels, 1.3);
if (wheels.length && Math.random() < 0.3) this.effects.tyreSmoke(car.x, car.y, 0.7);
}
}
private carWheels(car: Car): Vec[] {
const c = Math.cos(car.angle);
const s = Math.sin(car.angle);
const out: Vec[] = [];
car.dmg.wheels.forEach((on, i) => {
if (!on || (car.mode === 'drive' && i < 2)) return;
const ax = (i < 2 ? 1 : -1) * car.body.halfL * 0.6;
const ay = (i % 2 === 0 ? -1 : 1) * 3.4;
out.push({ x: car.x + c * ax - s * ay, y: car.y + s * ax + c * ay });
});
return out;
}
private updateEffects(dt: number, realDt: number, active: boolean): void {
const truck = this.truck;
this.exhaust -= dt;
if (active && truck.throttle && this.exhaust <= 0 && !truck.plane) {
this.exhaust = 0.09 - this.rpmFraction() * 0.05;
const c = Math.cos(truck.angle);
const s = Math.sin(truck.angle);
const side = Math.random() < 0.5 ? -5.4 : 5.4;
this.effects.puff(truck.x - c * 6 - s * side, truck.y - s * 6 + c * side, true, false, 0.6 + this.rpmFraction() * 0.4);
}
if (truck.damage > 50 && Math.random() < dt * (truck.damage / 18)) {
this.effects.puff(truck.x + Math.cos(truck.angle) * 6, truck.y + Math.sin(truck.angle) * 6, true, truck.damage > 85, 1.2);
}
// Car alarms near the player.
this.alarmTick -= realDt;
if (this.alarmTick <= 0) {
this.alarmTick = 0.5;
const alarm = this.traffic.wrecks.filter((w) => w.alarm > 0 && this.isNear(w.x, w.y)).sort((a, b) => Math.hypot(a.x - truck.x, a.y - truck.y) - Math.hypot(b.x - truck.x, b.y - truck.y))[0];
if (alarm) this.events.alarm?.(this.panOf(alarm.x));
}
this.effects.update(dt);
for (const fl of this.floaters) {
fl.life -= realDt;
fl.y -= 12 * realDt;
}
this.floaters = this.floaters.filter((fl) => fl.life > 0);
this.shake = Math.max(0, this.shake - realDt * 30);
}
private snapCamera(): void {
this.cam.x = this.truck.x;
this.cam.y = this.truck.y;
this.zoom = Math.min(this.devW, this.devH) / VIEW_SPAN;
this.camH = Infinity;
if (this.truck.plane) {
const lead = this.jetLead();
this.cam.x += lead.x;
this.cam.y += lead.y;
this.camLead = Math.hypot(lead.x, lead.y);
this.zoom = this.jetZoom() / GROUND_SPREAD;
this.camH = this.cameraHeight();
}
this.updateView();
}
/** The camera height that makes the jet look exactly GROUND_SPREAD times ground size. */
private cameraHeight(): number {
const alt = Math.max(1, this.truck.altitude);
const f = GROUND_SPREAD;
return (alt * f) / (f - 1);
}
/** Map units per screen at the jet's height: its size on screen never changes. */
private jetZoom(): number {
return Math.min(this.devW, this.devH) / JET_SPAN;
}
private camLead = 0;
/** The camera sits ahead of the jet, so there's more to see where it's going. */
private jetLead(): Vec {
const t = this.truck;
const want = JET_SPAN * 0.26;
return { x: Math.cos(t.angle) * want, y: Math.sin(t.angle) * want };
}
/**
* Where something at height z (map units) is drawn, as a point on the ground plane
* seen through the camera, and how much bigger it looks than ground-level things.
*/
readonly lift: Lift = (x, y, z) => {
if (z <= 0) return { x, y, s: 1 };
// On the ground the camera's higher above it all: a passing jet is only a bit bigger.
const h = isFinite(this.camH) ? this.camH : 900;
const above = h - z;
if (above < 4) return null;
const s = h / above;
return { x: this.camCenter.x + (x - this.camCenter.x) * s, y: this.camCenter.y + (y - this.camCenter.y) * s, s };
};
private updateCamera(dt: number): void {
const truck = this.truck;
if (truck.plane) {
// Hang above the jet, pulling back a little as it goes faster.
const spread = GROUND_SPREAD * (1 + 0.3 * clamp((truck.flight!.v - 100) / 100, 0, 1));
const target = this.jetZoom() / spread;
this.zoom += (target - this.zoom) * Math.min(1, dt * 10);
// The lift projection must agree with the zoom actually used, so the jet keeps its size.
const f = this.jetZoom() / this.zoom;
const alt = Math.max(1, truck.altitude);
this.camH = f > 1.0001 ? (alt * f) / (f - 1) : alt + 1e5;
const want = this.jetLead();
this.camLead += (Math.hypot(want.x, want.y) - this.camLead) * Math.min(1, dt * 2.5);
const lead = { x: Math.cos(truck.angle) * this.camLead, y: Math.sin(truck.angle) * this.camLead };
const k = Math.min(1, dt * 6);
this.cam.x += (truck.x + lead.x - this.cam.x) * k;
this.cam.y += (truck.y + lead.y - this.cam.y) * k;
// It flies fast: never let the camera fall far behind.
this.cam.x = truck.x + lead.x + clamp(this.cam.x - truck.x - lead.x, -40, 40);
this.cam.y = truck.y + lead.y + clamp(this.cam.y - truck.y - lead.y, -40, 40);
this.updateView();
return;
}
this.camH = Infinity;
const speedFrac = clamp(Math.abs(truck.speed) / truck.maxSpeed, 0, 1);
// From the cockpit you see much more of the city.
const target = Math.min(this.devW, this.devH) / VIEW_SPAN / (1 + 0.75 * speedFrac) / (truck.plane ? 1.7 : 1);
this.zoom += (target - this.zoom) * Math.min(1, dt * 1.4);
const tx = truck.x + truck.cab.vx * 0.6;
const ty = truck.y + truck.cab.vy * 0.6;
const k = Math.min(1, dt * 3);
this.cam.x += (tx - this.cam.x) * k;
this.cam.y += (ty - this.cam.y) * k;
this.updateView();
}
private updateView(): void {
this.view.w = this.devW / this.zoom;
this.view.h = this.devH / this.zoom;
if (isFinite(this.camH)) {
// In the air the camera stays with the jet, even out over the edge of the map.
this.view.x = this.cam.x - this.view.w / 2;
this.view.y = this.cam.y - this.view.h / 2;
} else {
const axis = (center: number, size: number, max: number) => (size >= max ? (max - size) / 2 : clamp(center - size / 2, 0, max - size));
this.view.x = axis(this.cam.x, this.view.w, MAP_W);
this.view.y = axis(this.cam.y, this.view.h, MAP_H);
}
this.camCenter.x = this.view.x + this.view.w / 2;
this.camCenter.y = this.view.y + this.view.h / 2;
}
/**
* The world, ground up: map, ground effects, people and traffic, the freeway deck and
* what's on it, smoke and fire, and the A-10's ordnance in the air. Used for the main
* view and (pod = true) for the targeting pod's picture.
*/
private drawScene(ctx: CanvasRenderingContext2D, vx: number, vy: number, zoom: number, w: number, h: number, pod: boolean): void {
const world = () => ctx.setTransform(zoom, 0, 0, zoom, -vx * zoom, -vy * zoom);
const screen = () => ctx.setTransform(1, 0, 0, 1, 0, 0);
const truck = this.truck;
screen();
this.mapRenderer.draw(ctx, 'ground', vx, vy, zoom, w, h);
world();
this.effects.drawGround(ctx, this.view);
if (this.phase !== 'title' && !truck.plane) this.drawZone(ctx);
// From high up, people, animals and street furniture are smaller than a pixel: skip them.
const fine = zoom > 0.4;
if (zoom > 0.3) this.street.draw(ctx, this.view);
this.drawProps(ctx);
if (fine) this.workers.draw(ctx, this.view);
if (fine) this.wildlife.draw(ctx, this.view);
if (fine) this.peds.draw(ctx, this.view, this.time);
this.drawRubble(ctx);
this.drawCars(ctx, 0);
this.civil.drawGround(ctx, this.view, this.time);
this.drawSignals(ctx);
this.missiles.draw(ctx, this.view, this.time, 0);
this.mob.draw(ctx, this.view);
this.services.drawDepots(ctx, this.view);
this.helis.drawBases(ctx, this.view);
this.services.draw(ctx, this.view, this.time, 0);
if (truck.layer === 0) this.drawRig(ctx);
if (truck.plane && this.phase !== 'title') {
// The jet's shadow races over the ground beneath it.
const h = truck.altitude;
drawA10Shadow(ctx, truck.x + h * 0.35, truck.y + h * 0.5, truck.angle, truck.bank, 0.24);
}
const r = this.raider;
if (r && r.fall < 2) {
const h = r.altitude;
drawA10Shadow(ctx, r.x + h * 0.35, r.y + h * 0.5, r.flight.heading, r.flight.bank, 0.24);
}
screen();
const under = truck.layer === 0 && (this.city.onDeck(truck.x, truck.y) || this.city.onDeck(truck.trailer.x, truck.trailer.y));
this.mapRenderer.draw(ctx, 'deck', vx, vy, zoom, w, h, under ? 0.45 : 1);
world();
this.drawCars(ctx, 1);
this.missiles.draw(ctx, this.view, this.time, 1);
if (truck.layer === 1) this.drawRig(ctx);
this.services.draw(ctx, this.view, this.time, 1);
this.effects.drawAir(ctx, this.view);
this.workers.drawAir(ctx, this.view);
this.helis.draw(ctx, this.view, this.time);
this.air.draw(ctx, this.view, this.lift, 1 / zoom, this.time);
if (!pod) this.civil.drawAir(ctx, this.view, this.lift, 1 / zoom, this.time);
if (r && r.fall < 2 && !pod) {
r.air.draw(ctx, this.view, this.lift, 1 / zoom, this.time);
const at = this.lift(r.x, r.y, r.altitude);
if (at) drawA10(ctx, at.x, at.y, r.flight.heading, r.flight.bank, at.s, r.flight.spool, this.time, r.firing, r.air);
}
if (truck.plane && !pod) this.drawTrails(ctx);
}
render(ctx: CanvasRenderingContext2D): void {
const zoom = this.zoom;
const sx = this.shake ? (Math.random() - 0.5) * this.shake * this.dpr : 0;
const sy = this.shake ? (Math.random() - 0.5) * this.shake * this.dpr : 0;
const vx = this.view.x - sx / zoom;
const vy = this.view.y - sy / zoom;
const screen = () => ctx.setTransform(1, 0, 0, 1, 0, 0);
const truck = this.truck;
this.mapRenderer.beginFrame();
this.drawScene(ctx, vx, vy, zoom, this.devW, this.devH, false);
// The smoke over the city goes under the jet, which flies above it.
this.drawPall(ctx);
if (truck.plane && this.dying < 0) {
const at = this.lift(truck.x, truck.y, truck.altitude);
if (at) drawA10(ctx, at.x, at.y, truck.angle, truck.bank, at.s, truck.flight!.spool, this.time, this.firing, this.air);
}
this.drawNight(ctx, vx, vy, zoom);
screen();
const toScreen = (x: number, y: number): Vec => ({ x: (x - vx) * zoom, y: (y - vy) * zoom });
// From the air nobody's shouting loud enough to read: no speech bubbles.
const chatter = zoom > 0.6 && !truck.plane;
for (const car of chatter ? this.traffic.all() : []) {
if (car.bubbleTime <= 0 || !inView(car.x, car.y, this.view, 20)) continue;
const p = toScreen(car.x, car.y);
this.drawBubble(ctx, car.bubble, p.x, p.y - 7 * zoom, car.rude, Math.min(1, car.bubbleTime * 4));
}
for (const car of chatter ? this.traffic.cars : []) {
if (car.bubbleTime > 0 || car.alert <= 0 || !car.rude || !inView(car.x, car.y, this.view, 20)) continue;
const p = toScreen(car.x, car.y);
this.drawLabel(ctx, '!', p.x, p.y - 8 * zoom, DANGER, '#ffffff');
}
for (const ped of chatter ? this.peds.peds : []) {
if (ped.bubbleTime <= 0 || !inView(ped.x, ped.y, this.view, 20)) continue;
const p = toScreen(ped.x, ped.y);
this.drawBubble(ctx, ped.bubble, p.x, p.y - (5 + ped.z * 0.3) * zoom, false, Math.min(1, ped.bubbleTime * 4));
}
for (const fl of this.floaters) {
const p = toScreen(fl.x, fl.y);
this.drawLabel(ctx, fl.text, p.x, p.y - 14 * zoom, fl.color, COLORS.ink, fl.scale ?? 1.3);
}
if (this.phase === 'playing' && this.job.progress > 0) {
const p = toScreen(truck.x, truck.y);
const w = 60 * this.dpr;
const h = 7 * this.dpr;
const bx = p.x - w / 2;
const by = p.y - 16 * zoom - h;
ctx.fillStyle = COLORS.ink;
ctx.beginPath();
ctx.roundRect(bx - 2 * this.dpr, by - 2 * this.dpr, w + 4 * this.dpr, h + 4 * this.dpr, 4 * this.dpr);
ctx.fill();
ctx.fillStyle = this.job.kind === 'pickup' ? PICKUP : DROP;
ctx.beginPath();
ctx.roundRect(bx, by, w * Math.min(1, this.job.progress), h, 3 * this.dpr);
ctx.fill();
}
if (this.phase !== 'title' && !truck.plane) {
this.drawTargetArrow(ctx, toScreen);
if (this.job.kind === 'deliver') this.drawTargetArrow(ctx, toScreen, this.mob.hq, MOB, 0.75);
}
for (const b of this.mob.countdowns()) {
if (!inView(b.x, b.y, this.view, 20)) continue;
const p = toScreen(b.x, b.y);
this.drawLabel(ctx, `BOMB ${b.text}`, p.x, p.y - 10 * zoom, '#ff2d2d', COLORS.ink, 1.3);
}
for (const m of this.mob.marked()) {
if (!inView(m.x, m.y, this.view, 20)) continue;
const p = toScreen(m.x, m.y);
this.drawLabel(ctx, 'MARKED', p.x, p.y - 12 * zoom, '#ff2d2d', COLORS.ink, 0.9);
}
if (this.slowmo > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${Math.min(0.12, this.slowmo * 0.2)})`;
ctx.fillRect(0, 0, this.devW, this.devH);
}
if (this.flash > 0) {
ctx.fillStyle = `rgba(255, 244, 220, ${Math.min(0.7, this.flash)})`;
ctx.fillRect(0, 0, this.devW, this.devH);
}
// During a raid the driver sees the city's vital signs too.
if (!truck.plane && (this.raider || this.raidRespawn > 0) && this.raidEnabled && (this.phase === 'playing' || this.phase === 'paused')) drawCityStatus(ctx, this.cityStatus(), this.devW, this.devH, this.dpr, this.insetTop, this.insetBottom);
const hud = truck.plane && this.dying < 0 && (this.phase === 'playing' || this.phase === 'paused') ? this.flightHud() : null;
if (hud) {
drawFlightHud(ctx, hud, this.devW, this.devH, this.dpr, this.insetTop, this.insetBottom);
drawCityStatus(ctx, this.cityStatus(), this.devW, this.devH, this.dpr, this.insetTop, this.insetBottom);
}
}
private drawProps(ctx: CanvasRenderingContext2D): void {
this.city.venues.forEach((v, vi) => {
if (v.kind !== 'rave' || !this.venueOpen(vi) || !inView(v.x, v.y, this.view, v.r + 120)) return;
// Lasers fanning out from the stage, and a strobe.
const sx = v.x + Math.cos(v.angle) * 0 - Math.sin(v.angle) * -v.r * 0.7;
const sy = v.y + Math.sin(v.angle) * 0 + Math.cos(v.angle) * -v.r * 0.7;
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (let i = 0; i < 8; i++) {
const a = v.angle + Math.PI / 2 + Math.sin(this.time * (1.1 + i * 0.13) + i) * 0.9;
ctx.strokeStyle = ['rgba(60, 255, 120, 0.55)', 'rgba(255, 40, 200, 0.55)', 'rgba(60, 180, 255, 0.55)'][i % 3];
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + Math.cos(a) * v.r * 2.4, sy + Math.sin(a) * v.r * 2.4);
ctx.stroke();
}
if (Math.floor(this.time * 8) % 5 === 0) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.beginPath();
ctx.arc(v.x, v.y, v.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.lineCap = 'butt';
ctx.globalCompositeOperation = 'source-over';
});
for (const [vi, v] of this.city.venues.entries()) {
if (v.kind !== 'party' || !this.venueOpen(vi) || !inView(v.x, v.y, this.view, v.r + 20)) continue;
// Disco lights sweeping the lawn.
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < 4; i++) {
const a = this.time * (1.5 + i * 0.4) + i * 1.6;
const r = v.r * 0.55;
ctx.fillStyle = ['rgba(255, 60, 180, 0.22)', 'rgba(60, 200, 255, 0.22)', 'rgba(255, 220, 60, 0.22)', 'rgba(120, 255, 90, 0.22)'][i];
ctx.beginPath();
ctx.arc(v.x + Math.cos(a) * r, v.y + Math.sin(a * 1.3) * r, 12, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
// String lights around the yard.
for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2;
ctx.fillStyle = ['#ff4fa3', '#ffd400', '#3fd0ff', '#7dff6a'][(i + Math.floor(this.time * 4)) % 4];
ctx.beginPath();
ctx.arc(v.x + Math.cos(a) * v.r * 0.95, v.y + Math.sin(a) * v.r * 0.95, 1, 0, Math.PI * 2);
ctx.fill();
}
}
for (const prop of this.props) {
const b = prop.body;
if (!inView(b.x, b.y, this.view, 20)) continue;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.angle);
if (prop.broken) {
ctx.fillStyle = '#8a6a44';
for (let i = 0; i < 4; i++) ctx.fillRect(-b.halfL + i * 3.5, -1 + (i % 2) * 2, 3, 0.8);
ctx.fillStyle = prop.colors[0];
ctx.fillRect(-b.halfL * 0.6, -b.halfW * 0.4, b.halfL, b.halfW * 0.5);
} else if (prop.kind === 'stage') {
ctx.fillStyle = '#26262c';
ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
ctx.strokeStyle = '#9a9ca2';
ctx.lineWidth = 0.5;
ctx.strokeRect(-b.halfL + 0.5, -b.halfW + 0.5, b.halfL * 2 - 1, b.halfW * 2 - 1);
ctx.fillStyle = ['#ff2bd6', '#2bffb4', '#2b9bff'][Math.floor(this.time * 2) % 3];
ctx.fillRect(-2.5, -1, 5, 2);
} else if (prop.kind === 'stall' && !this.venueOpen(prop.venue)) {
// Closed for the night: covered with a tarp.
ctx.fillStyle = '#6b6f5a';
ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 0.4;
ctx.strokeRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
} else if (prop.kind === 'stall') {
ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
ctx.fillRect(-b.halfL + 1.2, -b.halfW + 1.6, b.halfL * 2, b.halfW * 2);
for (let i = 0; i < 6; i++) {
ctx.fillStyle = prop.colors[i % 2];
ctx.fillRect(-b.halfL + (i * b.halfL * 2) / 6, -b.halfW, (b.halfL * 2) / 6 + 0.1, b.halfW * 2);
}
ctx.fillStyle = '#8a6a44';
ctx.fillRect(-b.halfL, b.halfW, b.halfL * 2, 1.6);
for (let i = 0; i < 5; i++) {
ctx.fillStyle = prop.produce[i % prop.produce.length];
ctx.beginPath();
ctx.arc(-b.halfL + 1.6 + i * 2.6, b.halfW + 0.8, 0.8, 0, Math.PI * 2);
ctx.fill();
}
} else if (prop.kind === 'speaker') {
ctx.fillStyle = '#1b1b1f';
ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
const pulse = 1.2 + Math.abs(Math.sin(this.time * 13)) * 0.6;
ctx.fillStyle = '#4a4a52';
ctx.beginPath();
ctx.arc(0, 0, pulse, 0, Math.PI * 2);
ctx.fill();
} else if (prop.kind === 'table') {
ctx.fillStyle = '#5a5650';
ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
ctx.fillStyle = ['#ff4fa3', '#3fd0ff'][Math.floor(this.time * 3) % 2];
ctx.fillRect(-2, -1, 4, 2);
} else {
ctx.fillStyle = '#b7bcc2';
ctx.beginPath();
ctx.arc(0, 0, b.halfL, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
/** Traffic lights at the stop line of each approach. */
private drawSignals(ctx: CanvasRenderingContext2D): void {
for (const sig of this.city.signals) {
if (!inView(sig.x, sig.y, this.view, 40)) continue;
for (const a of sig.approaches) {
const light = this.traffic.light(sig, a.phase);
ctx.save();
ctx.translate(a.x, a.y);
ctx.rotate(a.angle);
ctx.fillStyle = '#1b1b1f';
ctx.beginPath();
ctx.roundRect(-1.5, -3.4, 3, 6.8, 0.8);
ctx.fill();
const dots: [number, string, boolean][] = [
[-2.1, '#ff3b3b', light === 'red'],
[0, '#ffb000', light === 'amber'],
[2.1, '#3dff7a', light === 'green'],
];
for (const [y, color, on] of dots) {
ctx.fillStyle = on ? color : 'rgba(255, 255, 255, 0.12)';
ctx.beginPath();
ctx.arc(0, y, 0.9, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
}
private signalGlows(): { x: number; y: number; color: string }[] {
const out: { x: number; y: number; color: string }[] = [];
for (const sig of this.city.signals) {
if (!inView(sig.x, sig.y, this.view, 30)) continue;
for (const a of sig.approaches) {
const light = this.traffic.light(sig, a.phase);
if (light === 'dark') continue;
out.push({ x: a.x, y: a.y, color: light === 'red' ? 'rgba(255, 50, 50, 0.6)' : light === 'amber' ? 'rgba(255, 176, 0, 0.6)' : 'rgba(60, 255, 120, 0.55)' });
}
}
return out;
}
private drawCars(ctx: CanvasRenderingContext2D, layer: number): void {
for (const car of this.traffic.wrecks) {
if (car.layer !== layer || !inView(car.x, car.y, this.view, 30)) continue;
drawCar(ctx, car.look, car.x, car.y, car.angle, true, false, car.dmg);
}
for (const car of this.traffic.cars) {
if (car.layer !== layer || !inView(car.x, car.y, this.view, 30)) continue;
drawCar(ctx, car.look, car.x, car.y, car.angle, false, car.braking, car.broken ? car.dmg : undefined);
}
}
private drawRig(ctx: CanvasRenderingContext2D): void {
const t = this.truck;
if (t.dozer) drawDozer(ctx, t.x, t.y, t.angle, t.odometer, t.cabDents, t.braking);
else if (t.sports) {
// Dents crumple the bodywork where they landed: nose, tail or a side.
const d = this.sportsDmg;
d.crumple = [0, 0, 0, 0];
for (const dent of t.cabDents) {
const zone = Math.abs(dent.x) / t.cab.halfL > Math.abs(dent.y) / t.cab.halfW ? (dent.x > 0 ? 0 : 1) : dent.y < 0 ? 2 : 3;
d.crumple[zone] = Math.min(zone < 2 ? 5 : 3, d.crumple[zone] + dent.r * 0.5);
}
d.glass = t.damage > 60;
d.burnt = t.damage >= 100 ? 0.7 : 0;
drawCar(ctx, this.sportsLook, t.x, t.y, t.angle, false, t.braking, d);
} else drawTruck(ctx, t.pose());
if (this.phase === 'over') {
const tc = this.truck.trailerCenter();
ctx.fillStyle = 'rgba(20, 16, 14, 0.6)';
for (const p of [this.truck, tc]) {
ctx.beginPath();
ctx.ellipse(p.x, p.y, 12, 8, this.truck.angle, 0, Math.PI * 2);
ctx.fill();
}
}
}
private drawZone(ctx: CanvasRenderingContext2D): void {
const { spot, kind } = this.job;
this.drawZoneAt(ctx, spot, kind === 'pickup' ? PICKUP : DROP, kind === 'pickup' ? 'LOAD HERE' : 'DROP HERE');
// Loaded: the family will take it instead.
if (kind === 'deliver') this.drawZoneAt(ctx, this.mob.hq, MOB, 'THE FAMILY');
}
private drawZoneAt(ctx: CanvasRenderingContext2D, spot: Spot, color: string, label: string): void {
ctx.save();
ctx.translate(spot.x, spot.y);
ctx.rotate(spot.angle);
ctx.globalAlpha = 0.2 + 0.14 * Math.sin(this.time * 6);
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(-26, -9, 52, 18, 3);
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]);
ctx.lineDashOffset = -this.time * 12;
ctx.stroke();
ctx.setLineDash([]);
ctx.lineDashOffset = 0;
ctx.lineWidth = 1.4;
ctx.lineCap = 'round';
for (let i = -1; i <= 1; i++) {
const x = i * 10 + ((this.time * 10) % 10) - 5;
ctx.beginPath();
ctx.moveTo(x - 2, -4);
ctx.lineTo(x + 2, 0);
ctx.lineTo(x - 2, 4);
ctx.stroke();
}
ctx.lineCap = 'butt';
ctx.restore();
const zoom = this.zoom;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const p = { x: (spot.x - this.view.x) * zoom, y: (spot.y - this.view.y) * zoom };
const bob = Math.abs(Math.sin(this.time * 4)) * 3 * this.dpr;
this.drawLabel(ctx, label, p.x, p.y - 14 * zoom - bob, color, COLORS.ink);
ctx.restore();
}
private font(size: number): string {
return `800 ${Math.round(size * this.dpr)}px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`;
}
private drawLabel(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, fg: string, bg: string, scale = 1): void {
ctx.font = this.font(11 * scale);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const w = ctx.measureText(text).width + 12 * this.dpr;
const h = 18 * this.dpr * scale;
ctx.fillStyle = bg;
ctx.beginPath();
ctx.roundRect(x - w / 2, y - h / 2, w, h, 5 * this.dpr);
ctx.fill();
ctx.fillStyle = fg;
ctx.fillText(text, x, y + this.dpr * 0.5);
}
private drawBubble(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, rude: boolean, alpha: number): void {
const d = this.dpr;
ctx.globalAlpha = alpha;
ctx.font = this.font(10.5);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const w = ctx.measureText(text).width + 14 * d;
const h = 20 * d;
const bx = x - w / 2;
const by = y - h - 6 * d;
ctx.fillStyle = rude ? '#fff4dc' : '#ffffff';
ctx.strokeStyle = COLORS.ink;
ctx.lineWidth = 1.6 * d;
ctx.beginPath();
ctx.roundRect(bx, by, w, h, 8 * d);
ctx.moveTo(x - 5 * d, by + h);
ctx.lineTo(x, by + h + 6 * d);
ctx.lineTo(x + 3 * d, by + h);
ctx.fill();
ctx.stroke();
ctx.fillRect(x - 4 * d, by + h - 2 * d, 6 * d, 3 * d);
ctx.fillStyle = rude ? '#c0182b' : COLORS.ink;
ctx.fillText(text, x, by + h / 2 + 0.5 * d);
ctx.globalAlpha = 1;
}
private drawTargetArrow(ctx: CanvasRenderingContext2D, toScreen: (x: number, y: number) => Vec, spot: Vec = this.job.spot, color = this.job.kind === 'pickup' ? PICKUP : DROP, size = 1): void {
const t = toScreen(spot.x, spot.y);
const d = this.dpr;
const margin = 22 * d;
const top = this.insetTop + margin;
const bottom = this.devH - this.insetBottom - margin;
if (t.x > margin && t.x < this.devW - margin && t.y > top && t.y < bottom) return;
const cx = this.devW / 2;
const cy = (top + bottom) / 2;
const dx = t.x - cx;
const dy = t.y - cy;
const k = Math.min((this.devW / 2 - margin) / Math.max(1, Math.abs(dx)), Math.max(4, (bottom - top) / 2) / Math.max(1, Math.abs(dy)));
const angle = Math.atan2(dy, dx);
const bob = Math.sin(this.time * 6) * 3 * d;
ctx.save();
ctx.translate(cx + dx * k - Math.cos(angle) * bob, cy + dy * k - Math.sin(angle) * bob);
ctx.rotate(angle);
ctx.scale(size, size);
ctx.beginPath();
ctx.moveTo(14 * d, 0);
ctx.lineTo(-8 * d, -11 * d);
ctx.lineTo(-3 * d, 0);
ctx.lineTo(-8 * d, 11 * d);
ctx.closePath();
ctx.fillStyle = color;
ctx.strokeStyle = COLORS.ink;
ctx.lineWidth = 2.5 * d;
ctx.lineJoin = 'round';
ctx.stroke();
ctx.fill();
ctx.restore();
}
/** Overview map with the truck, the view and the current target. */
drawMinimap(ctx: CanvasRenderingContext2D, w: number, h: number): void {
if (!this.minimap || this.minimap.width !== w || this.minimap.height !== h) this.minimap = renderMinimap(this.city, w, h);
const scale = Math.min(w / MAP_W, h / MAP_H);
ctx.drawImage(this.minimap, 0, 0);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 1;
ctx.strokeRect(this.view.x * scale, this.view.y * scale, this.view.w * scale, this.view.h * scale);
if (this.phase !== 'title' && !this.truck.plane) {
const pulse = 3 + Math.abs(Math.sin(this.time * 4)) * 3;
ctx.fillStyle = this.job.kind === 'pickup' ? PICKUP : DROP;
ctx.strokeStyle = COLORS.ink;
ctx.beginPath();
ctx.arc(this.job.spot.x * scale, this.job.spot.y * scale, pulse, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// The family's turf, their club and any hitmen out on a job.
const [, tx0, ty0, tx1, ty1] = DISTRICT_RECTS.find((r) => r[0] === 'hood')!;
ctx.strokeStyle = MOB;
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 2]);
ctx.strokeRect(tx0 * scale, ty0 * scale, (tx1 - tx0) * scale, (ty1 - ty0) * scale);
ctx.setLineDash([]);
ctx.fillStyle = MOB;
ctx.beginPath();
ctx.arc(this.mob.hq.x * scale, this.mob.hq.y * scale, this.job.kind === 'deliver' ? 4 : 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#111318';
for (const h of this.mob.hitmen()) ctx.fillRect(h.x * scale - 2, h.y * scale - 2, 4, 4);
// City units out on jobs.
for (const v of this.services.vehicles) {
ctx.fillStyle = v.spec.siren ? (v.kind === 'fire' ? '#ff4d5e' : v.kind === 'police' ? '#5b8cff' : '#ffffff') : '#ffb000';
ctx.fillRect(v.x * scale - 1.5, v.y * scale - 1.5, 3, 3);
}
for (const h of this.helis.helis) {
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(h.x * scale, h.y * scale, 2.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.save();
ctx.translate(this.truck.x * scale, this.truck.y * scale);
ctx.rotate(this.truck.angle);
ctx.fillStyle = COLORS.cab;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(6, 0);
ctx.lineTo(-4, -4);
ctx.lineTo(-4, 4);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
