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 { FLIGHT_ALT } from './a10.js';
import type { Lift } from './airstrike.js';
import { LOOK, type City, type District, type Station, type Structure } from './city.js';
import type { Effects } from './effects.js';
import type { InfraSpec } from './expansion.js';
import type { Vec } from './geometry.js';
import type { Pedestrians } from './peds.js';
import type { ServiceKind, Services } from './services.js';
import { inView, type View } from './traffic.js';
import { drawHelicopter } from './vehicles.js';
/**
* The city fighting to stay alive. It runs like a real emergency organisation:
*
* - Utilities: power stations feed a substation in every district; when generating
* capacity falls short it sheds load, keeping hospitals and City Hall lit first.
* Waterworks need power to pump, and the water towers drain without them, so hydrant
* pressure falls and fires burn longer (as after the Napa and Loma Prieta quakes).
* The masts and City Hall's emergency operations centre set how quickly the city
* can coordinate anything at all.
* - People: every district has its population; collapsing buildings kill and trap the
* people inside, search-and-rescue teams dig the trapped out for the ambulances, and
* districts under attack are evacuated to the shelters. Life safety first.
* - Escalation: as the destruction mounts the alert goes up, the air-raid sirens wail,
* a state of emergency brings in mutual aid from the county, water-bombing
* helicopters fly from the reservoir and the bay, and the National Guard rolls out
* mobile air defences.
* - Air defence: anti-aircraft guns and surface-to-air missiles round the base and the
* vital sites, weapons free once the city knows it's under attack. Flares decoy the
* missiles; the guns just have to be survived.
*/
export interface CivilWorld {
hour(): number;
/** Structures on fire. */
burning(): Structure[];
douse(x: number, y: number, r: number): void;
toast(text: string, tone: 'good' | 'info' | 'bad'): void;
/** The A-10, if one's up there (position in map units, velocity in units/s). */
jet(): { x: number; y: number; vx: number; vy: number } | null;
/** Hits on the jet (damage points). */
hitJet(damage: number): void;
/** Explosions in the air or on the ground that the game should play out. */
explode(x: number, y: number, size: number, air: boolean): void;
sound(kind: 'siren' | 'launch' | 'flak' | 'guns' | 'heli', x: number, y: number): void;
/** Score the player for something that matters. */
score(points: number, x: number, y: number, label: string): void;
}
interface InfraState extends InfraSpec {
district: District;
/** Water towers: how full (0..1). */
level: number;
online: boolean;
}
interface Rescue {
x: number;
y: number;
trapped: number;
born: number;
st: Structure;
}
interface AASite {
x: number;
y: number;
kind: 'gun' | 'sam';
hp: number;
cooldown: number;
burst: number;
angle: number;
mobile: boolean;
}
interface AARound {
x0: number;
y0: number;
x1: number;
y1: number;
t: number;
dur: number;
}
interface SAM {
x: number;
y: number;
z: number;
angle: number;
speed: number;
age: number;
/** Chasing a flare instead of the jet. */
flare: Flare | null;
trail: number;
}
interface Flare {
x: number;
y: number;
vx: number;
vy: number;
life: number;
}
interface Bomber {
x: number;
y: number;
z: number;
vx: number;
vy: number;
angle: number;
rotor: number;
water: number;
state: 'base' | 'fill' | 'fight' | 'home';
timer: number;
target: Vec | null;
base: Vec;
}
/** Everyone who lives (or works) in each district. */
const POPULATION: Record<District, number> = {
downtown: 42000,
uptown: 12000,
hood: 26000,
port: 3000,
village: 5000,
industrial: 4000,
country: 2500,
docks: 4500,
factory: 6000,
rail: 1500,
suburb: 38000,
commercial: 9000,
military: 7000,
rural: 3500,
town: 6500,
highway: 0,
};
/** People per 100 square units of floor, by the look of the district. */
const DENSITY: Partial<Record<District, number>> = { downtown: 0.9, hood: 0.8, uptown: 0.25, village: 0.4, country: 0.15, port: 0.25, industrial: 0.25, military: 0.5 };
const ALERTS = ['NORMAL', 'ELEVATED', 'EMERGENCY', 'DISASTER', 'CATASTROPHE'];
const WATER_SOURCES: Vec[] = [
{ x: 6300, y: 2600 },
{ x: 720, y: 860 },
{ x: 3300, y: 5300 },
{ x: 12000, y: 21300 },
];
const range = (a: number, b: number) => a + Math.random() * (b - a);
export class Civil {
readonly infra: InfraState[];
private readonly powered = new Map<District, boolean>();
private readonly districts: District[] = Object.keys(POPULATION).filter((d) => d !== 'highway') as District[];
readonly evac = new Map<District, number>();
private readonly threat = new Map<District, number>();
private readonly orderedEvac = new Set<District>();
dead = 0;
rescued = 0;
injured = 0;
sheltered = 0;
alert = 0;
private destroyedBuildings = 0;
private infraLost = 0;
coordination = 1;
capacity = 1;
private readonly rescues: Rescue[] = [];
private readonly sites: AASite[] = [];
private readonly rounds: AARound[] = [];
private readonly sams: SAM[] = [];
private readonly flares: Flare[] = [];
flareCount = 30;
private flareCooldown = 0;
private readonly bombers: Bomber[] = [];
private readonly breaks: { x: number; y: number; until: number }[] = [];
private time = 0;
private tick = 0;
/** A missile is tracking the jet (for the HUD and the warning tone). */
missileWarning = false;
private aidWaves = 0;
private lastAid = -999;
private attacked = false;
private readonly lostNames = new Set<string>();
constructor(
private readonly city: City,
private readonly effects: Effects,
private readonly services: Services,
private readonly peds: Pedestrians,
private readonly world: CivilWorld,
) {
this.infra = city.infra.map((spec) => ({ ...spec, district: city.districtAt(spec.st.x, spec.st.y), level: 1, online: true }));
this.reset();
}
reset(): void {
for (const d of this.districts) {
this.powered.set(d, true);
this.evac.set(d, 0);
this.threat.set(d, 0);
}
for (const i of this.infra) {
i.level = 1;
i.online = true;
}
this.orderedEvac.clear();
this.lostNames.clear();
this.dead = this.rescued = this.injured = this.sheltered = 0;
this.alert = 0;
this.destroyedBuildings = this.infraLost = 0;
this.coordination = 1;
this.capacity = 1;
this.rescues.length = 0;
this.rounds.length = 0;
this.sams.length = 0;
this.flares.length = 0;
this.flareCount = 30;
this.bombers.length = 0;
this.breaks.length = 0;
this.aidWaves = 0;
this.attacked = false;
this.missileWarning = false;
this.sites.length = 0;
// Fixed air defences: round the air base, and on the city's vital sites.
const fixed: [number, number, 'gun' | 'sam'][] = [
[10400, 13250, 'gun'],
[14800, 13250, 'gun'],
[12600, 15900, 'gun'],
[11000, 15300, 'sam'],
[14200, 15300, 'sam'],
[14800, 1200, 'gun'],
[2600, 2280, 'gun'],
[9000, 2600, 'sam'],
[3600, 900, 'gun'],
[7500, 12000, 'sam'],
[1800, 7600, 'gun'],
];
for (const [x, y, kind] of fixed) this.sites.push({ x, y, kind, hp: kind === 'sam' ? 160 : 120, cooldown: range(0, 3), burst: 0, angle: 0, mobile: false });
}
// --- Queries for the rest of the game.
/** Is there power at a spot? */
hasPower(x: number, y: number): boolean {
return this.powered.get(this.city.districtAt(x, y)) ?? true;
}
/** Hydrant pressure at a spot (0 dry .. 1 full). */
pressure(x: number, y: number): number {
let best = -1;
for (const t of this.infra) {
if (t.kind !== 'watertower') continue;
const d = Math.hypot(t.st.x - x, t.st.y - y);
if (d < 3500) best = Math.max(best, t.online ? t.level : 0);
}
let p = best < 0 ? (this.supply() > 0 ? 0.7 : 0.25) : best;
// A burst main nearby: the pressure goes out of the hole in the road.
for (const b of this.breaks) if (b.until > this.time && Math.hypot(b.x - x, b.y - y) < 320) p *= 0.3;
return Math.max(0.05, p);
}
/** Waterworks pumping (online and powered). */
private supply(): number {
return this.infra.filter((w) => w.kind === 'waterworks' && w.online && this.hasPower(w.st.x, w.st.y)).length;
}
get alertName(): string {
return ALERTS[this.alert];
}
/** A share of the city's population living with power. */
powerShare(): number {
let on = 0;
let all = 0;
for (const d of this.districts) {
all += POPULATION[d];
if (this.powered.get(d)) on += POPULATION[d];
}
return all ? on / all : 1;
}
waterShare(): number {
const towers = this.infra.filter((t) => t.kind === 'watertower');
if (!towers.length) return this.supply() > 0 ? 1 : 0.3;
return towers.reduce((s, t) => s + (t.online ? t.level : 0), 0) / towers.length;
}
count(kind: InfraSpec['kind']): [number, number] {
const all = this.infra.filter((i) => i.kind === kind);
return [all.filter((i) => i.online).length, all.length];
}
evacuated(): number {
let n = 0;
for (const d of this.districts) n += POPULATION[d] * (this.evac.get(d) ?? 0);
return Math.round(n);
}
trapped(): number {
return this.rescues.reduce((s, r) => s + r.trapped, 0);
}
/** Collapse sites with people still under the rubble, for the rescue teams. */
rescueSites(): Rescue[] {
return this.rescues.filter((r) => r.trapped > 0);
}
/** A rescue team pulls people out at a site: they come out hurt, for the ambulances. */
dig(site: Rescue): void {
const n = Math.min(site.trapped, 1 + Math.floor(Math.random() * 3));
site.trapped -= n;
this.rescued += n;
this.injured += n;
for (let i = 0; i < Math.min(2, n); i++) this.peds.launch(site.x + range(-8, 8), site.y + range(-8, 8), range(-10, 10), range(-10, 10), '#8d877b', '#4a3222');
}
// --- Things happening to the city.
/** Something came down. Who was inside, and was it something the city needs? */
onCollapse(st: Structure): void {
const d = st.district;
const info = this.infra.find((i) => i.st === st);
if (info) {
info.online = false;
this.infraLost++;
if (!this.lostNames.has(info.name)) {
this.lostNames.add(info.name);
const effect =
info.kind === 'power'
? `generation down ${Math.round((info.capacity ?? 0) * 100)}%`
: info.kind === 'substation'
? 'district power cut'
: info.kind === 'waterworks'
? 'water pumping lost'
: info.kind === 'watertower'
? 'water pressure falling'
: info.kind === 'comms'
? 'radio and phones failing'
: info.kind === 'eoc'
? 'the emergency command is gone'
: 'mass casualties';
this.world.toast(`${info.name} destroyed: ${effect}`, 'bad');
this.world.score(info.kind === 'shelter' ? 300 : 500, st.x, st.y, info.kind === 'power' ? 'POWER STATION!' : 'INFRASTRUCTURE!');
}
if (info.kind === 'substation' || info.kind === 'power') this.effects.arcs(st.x, st.y, info.kind === 'power' ? 30 : 18);
if (info.kind === 'shelter') {
const lost = Math.round(this.sheltered * range(0.05, 0.12));
this.dead += lost;
this.sheltered -= lost;
}
}
this.attacked = true;
this.bump(d, 1.2);
if (!st.building && !st.station) return;
this.destroyedBuildings++;
// The people inside: fewer the more of the district has been evacuated.
const look = LOOK[d];
const residential = look === 'uptown' || look === 'hood' || look === 'village' || look === 'country';
const h = this.world.hour();
const night = h < 7 || h > 19;
const occupancy = residential ? (night ? 1 : 0.45) : night ? 0.25 : 1;
const people = ((st.w * st.d) / 100) * (DENSITY[look] ?? 0.3) * occupancy * (1 - (this.evac.get(d) ?? 0));
const dead = Math.round(people * range(0.12, 0.3));
const trapped = Math.round(people * range(0.15, 0.35));
this.dead += dead;
if (trapped > 0) this.rescues.push({ x: st.x, y: st.y, trapped, born: this.time, st });
// Gas mains rupture in the ruins: a roaring jet of flame that burns for a while.
if (Math.random() < (look === 'downtown' || look === 'hood' ? 0.3 : 0.15)) this.effects.gasFire(st.x + range(-st.w, st.w) * 0.3, st.y + range(-st.d, st.d) * 0.3, range(20, 45));
}
/** A blast somewhere: the district's alarm goes up; air defences in it take the damage. */
onBlast(x: number, y: number, r: number, power: number, onRoad: boolean): void {
this.attacked = true;
this.bump(this.city.districtAt(x, y), 0.6 + power * 0.002);
for (const s of this.sites) {
if (s.hp <= 0) continue;
const d = Math.hypot(s.x - x, s.y - y);
if (d > r + 14) continue;
s.hp -= power * (1 - d / (r + 14));
if (s.hp <= 0) {
this.world.explode(s.x, s.y, s.kind === 'sam' ? 2.4 : 1.6, false);
this.world.score(250, s.x, s.y, 'AIR DEFENCE DOWN!');
}
}
// A bomb in the road bursts the water main under it.
if (onRoad && power > 300 && Math.random() < 0.6) {
this.breaks.push({ x, y, until: this.time + 120 });
this.effects.geyser(x + range(-6, 6), y + range(-6, 6), 60);
}
}
private bump(d: District, amount: number): void {
this.threat.set(d, (this.threat.get(d) ?? 0) + amount);
}
/** The pilot pops flares: every missile tracking the jet may go for one instead. */
popFlares(): boolean {
const jet = this.world.jet();
if (!jet || this.flareCount <= 0 || this.flareCooldown > 0) return false;
this.flareCount -= 2;
this.flareCooldown = 0.8;
for (const side of [-1, 1]) {
const a = Math.atan2(jet.vy, jet.vx) + Math.PI + side * 0.6;
this.flares.push({ x: jet.x, y: jet.y, vx: jet.vx * 0.4 + Math.cos(a) * 160, vy: jet.vy * 0.4 + Math.sin(a) * 160, life: 3.2 });
}
for (const m of this.sams) if (!m.flare && Math.random() < 0.8) m.flare = this.flares[this.flares.length - 1 - Math.floor(Math.random() * 2)];
return true;
}
// --- The city's own turn.
update(dt: number): void {
this.time += dt;
this.flareCooldown -= dt;
this.tick -= dt;
if (this.tick <= 0) {
this.tick = 1;
this.think();
}
this.defend(dt);
this.fly(dt);
}
/** Once a second: utilities, evacuation, rescues, escalation. */
private think(): void {
for (const i of this.infra) i.online = !i.st.destroyed;
// Coordination: the masts and City Hall.
const [masts, mastsAll] = this.count('comms');
const eoc = this.infra.find((i) => i.kind === 'eoc');
this.coordination = Math.max(0.3, 0.35 + 0.35 * (mastsAll ? masts / mastsAll : 1) + (eoc && !eoc.online ? 0 : 0.3));
// Power: share out what's still generating, essentials first.
this.capacity = this.infra.filter((i) => i.kind === 'power' && i.online).reduce((s, i) => s + (i.capacity ?? 0), 0);
const hasStation = (d: District, kind: Station['kind']) => this.city.stations.some((s) => s.kind === kind && s.district === d && !s.structure?.destroyed);
const priority = (d: District) => (hasStation(d, 'hospital') ? 3 : 0) + (eoc && eoc.district === d ? 2 : 0) + (this.infra.some((w) => w.kind === 'waterworks' && w.district === d) ? 2 : 0) + POPULATION[d] / 50000;
const total = this.districts.reduce((s, d) => s + POPULATION[d], 0);
let left = this.capacity * total * 1.15;
const before = new Map(this.powered);
for (const d of [...this.districts].sort((a, b) => priority(b) - priority(a))) {
const subs = this.infra.filter((i) => i.kind === 'substation' && i.district === d);
const sub = !subs.length || subs.some((s) => s.online);
const need = POPULATION[d];
const on = sub && left >= need * 0.6;
if (on) left -= need;
this.powered.set(d, on);
}
const dark = this.districts.filter((d) => before.get(d) && !this.powered.get(d));
if (dark.length) this.world.toast(`Blackout: ${dark.length > 2 ? `${dark.length} districts` : dark.join(' and ')}`, 'bad');
// Water: towers fill while the works pump, and drain (faster when fighting fires) when they don't.
const pumping = this.supply() > 0;
const fires = this.world.burning();
for (const t of this.infra) {
if (t.kind !== 'watertower' || !t.online) continue;
const nearFires = fires.filter((f) => Math.hypot(f.x - t.st.x, f.y - t.st.y) < 3500).length;
t.level = Math.max(0, Math.min(1, t.level + (pumping ? 0.05 : 0) - 0.006 - nearFires * 0.0015));
}
// Threat cools off; districts that stay under attack are evacuated to the shelters.
for (const d of this.districts) {
const threat = (this.threat.get(d) ?? 0) * 0.97;
this.threat.set(d, threat);
const e = this.evac.get(d) ?? 0;
if (threat > 3 && e < 0.92 && this.alert >= 1) {
if (!this.orderedEvac.has(d)) {
this.orderedEvac.add(d);
this.world.toast(`Evacuation ordered: ${d.toUpperCase()}. Buses to the shelters`, 'info');
}
const step = Math.min(0.92 - e, 0.012 * this.coordination);
this.evac.set(d, e + step);
this.sheltered += POPULATION[d] * step;
}
}
// The trapped who aren't reached in time don't make it.
for (let i = this.rescues.length - 1; i >= 0; i--) {
const r = this.rescues[i];
if (this.time - r.born > 240 || r.trapped <= 0) {
this.dead += r.trapped;
this.rescues.splice(i, 1);
}
}
if (this.rescues.length > 40) this.rescues.splice(0, this.rescues.length - 40);
// Escalation.
const level = !this.attacked ? 0 : this.destroyedBuildings + this.infraLost * 6 + fires.length * 0.5 > 160 ? 4 : this.destroyedBuildings + this.infraLost * 6 > 70 ? 3 : this.destroyedBuildings + this.infraLost * 6 > 18 ? 2 : 1;
if (level > this.alert) {
this.alert = level;
const jet = this.world.jet();
this.world.sound('siren', jet?.x ?? 0, jet?.y ?? 0);
this.world.toast(
[
'',
'AIR RAID: sirens sounding, air defences weapons free',
'STATE OF EMERGENCY: evacuations, mutual aid requested, water bombers launching',
'DISASTER: National Guard deploying mobile air defence',
'CATASTROPHE: federal emergency, every unit in the state on its way',
][level],
'bad',
);
if (level >= 3 && jet) this.deployMobile(jet, level === 3 ? 2 : 3);
}
// Mutual aid: when the city's own units are run ragged, the county and state send more.
if (this.alert >= 2 && this.time - this.lastAid > 50 && this.aidWaves < 2 + this.alert) {
const stretched = this.services.available('fire') + this.services.available('ambulance') < 4;
if (stretched || this.aidWaves === 0) {
this.lastAid = this.time;
this.aidWaves++;
this.reinforce('fire', 2);
this.reinforce('ambulance', 2);
this.reinforce('rescue', 1);
this.reinforce('police', 1);
this.addBombers(this.aidWaves === 1 ? 2 : 1);
this.world.toast(`Mutual aid wave ${this.aidWaves}: engines, ambulances and a rescue team from the county${this.aidWaves === 1 ? ', and water bombers' : ''}`, 'info');
}
}
}
/** More units at the city's surviving stations (or a staging area, if they're all gone). */
private reinforce(kind: ServiceKind, n: number): void {
const depots = this.services.depots.filter((d) => d.kind === kind && !d.station.structure?.destroyed);
if (depots.length) {
for (let i = 0; i < n; i++) depots[i % depots.length].units++;
return;
}
const staging: Station = { kind: kind === 'ambulance' ? 'hospital' : kind === 'police' ? 'police' : 'fire', x: 5900, y: 7700, angle: 0, w: 40, d: 40, units: n, district: 'rail' };
this.services.addDepot(staging, kind, n);
}
/** National Guard air-defence vehicles, set up round where the jet's been working. */
private deployMobile(jet: Vec, n: number): void {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const d = range(900, 1500);
const x = Math.max(200, Math.min(16200, jet.x + Math.cos(a) * d));
const y = Math.max(200, Math.min(29000, jet.y + Math.sin(a) * d));
this.sites.push({ x, y, kind: i % 2 ? 'sam' : 'gun', hp: 90, cooldown: 3, burst: 0, angle: 0, mobile: true });
}
}
private addBombers(n: number): void {
for (let i = 0; i < n; i++) {
const base = { x: 12600 + (this.bombers.length % 4) * 120, y: 13780 };
this.bombers.push({ x: base.x, y: base.y, z: 0, vx: 0, vy: 0, angle: -Math.PI / 2, rotor: 0, water: 0, state: 'base', timer: 0, target: null, base });
}
}
// --- Air defence.
private defend(dt: number): void {
const jet = this.world.jet();
this.missileWarning = false;
for (const s of this.sites) {
if (s.hp <= 0 || !jet || this.alert < 1) continue;
const dx = jet.x - s.x;
const dy = jet.y - s.y;
const d = Math.hypot(dx, dy);
s.angle = Math.atan2(dy, dx);
s.cooldown -= dt;
if (s.kind === 'gun') {
if (d > 1250) continue;
if (s.burst > 0) {
s.burst -= dt;
// A stream of tracer, led on the jet, walking about it.
if (Math.random() < dt * 14) {
const flight = Math.hypot(d, FLIGHT_ALT) / 1700;
const spread = 26 + d * 0.05;
const ax = jet.x + jet.vx * flight + range(-spread, spread);
const ay = jet.y + jet.vy * flight + range(-spread, spread);
this.rounds.push({ x0: s.x, y0: s.y, x1: ax, y1: ay, t: 0, dur: flight });
if (Math.random() < 0.08) this.world.sound('guns', s.x, s.y);
}
} else if (s.cooldown <= 0) {
s.burst = range(1, 1.8);
s.cooldown = range(2.2, 3.4);
}
} else if (d < 1900 && s.cooldown <= 0 && this.sams.length < 3) {
s.cooldown = range(10, 15);
this.sams.push({ x: s.x, y: s.y, z: 4, angle: s.angle, speed: 200, age: 0, flare: null, trail: 0 });
this.world.sound('launch', s.x, s.y);
this.world.toast('MISSILE LAUNCH! Flares: F', 'bad');
}
}
// Flak and tracer: a hit if it bursts close to where the jet actually is.
for (const r of this.rounds) {
r.t += dt;
if (r.t < r.dur || r.t - dt >= r.dur) continue;
if (jet && Math.hypot(r.x1 - jet.x, r.y1 - jet.y) < 13) this.world.hitJet(range(1, 2));
if (Math.random() < 0.18) this.effects.flak(r.x1, r.y1);
}
for (let i = this.rounds.length - 1; i >= 0; i--) if (this.rounds[i].t > this.rounds[i].dur + 0.05) this.rounds.splice(i, 1);
// Missiles: boost, climb to the jet's height, and pull lead on it (or a flare).
for (const m of this.sams) {
m.age += dt;
m.speed = Math.min(780, m.speed + 900 * dt);
m.z = Math.min(FLIGHT_ALT, m.z + dt * 140);
const target = m.flare ?? jet;
if (target) {
const tx = target.x + ('vx' in target ? target.vx * 0.15 : 0);
const ty = target.y + ('vy' in target ? target.vy * 0.15 : 0);
const want = Math.atan2(ty - m.y, tx - m.x);
let da = want - m.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
m.angle += Math.max(-2.6 * dt, Math.min(2.6 * dt, da));
}
m.x += Math.cos(m.angle) * m.speed * dt;
m.y += Math.sin(m.angle) * m.speed * dt;
m.trail -= dt;
if (m.trail <= 0) {
m.trail = 0.02;
this.effects.missileTrail(m.x, m.y, m.z);
}
if (!m.flare && jet) this.missileWarning = true;
if (m.flare && m.flare.life > 0 && Math.hypot(m.flare.x - m.x, m.flare.y - m.y) < 18) m.age = 99;
if (!m.flare && jet && m.z >= FLIGHT_ALT * 0.8 && Math.hypot(jet.x - m.x, jet.y - m.y) < 22) {
this.world.hitJet(range(24, 34));
m.age = 99;
}
}
for (let i = this.sams.length - 1; i >= 0; i--) {
const m = this.sams[i];
if (m.age > 7) {
this.world.explode(m.x, m.y, 1, true);
this.sams.splice(i, 1);
}
}
for (const f of this.flares) {
f.life -= dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
f.vx *= Math.exp(-dt * 1.2);
f.vy *= Math.exp(-dt * 1.2);
}
for (let i = this.flares.length - 1; i >= 0; i--) if (this.flares[i].life <= 0) this.flares.splice(i, 1);
}
// --- Water bombers.
private fly(dt: number): void {
const fires = this.world.burning();
for (const b of this.bombers) {
b.rotor += dt * 30;
const claimed = this.bombers.filter((o) => o !== b && o.target).map((o) => o.target!);
if (b.state === 'base' || b.state === 'home') {
// The biggest blaze no one else is on.
if (this.alert >= 2 && fires.length) {
let best: Vec | null = null;
let score = 0;
for (const f of fires) {
if (claimed.some((c) => Math.hypot(c.x - f.x, c.y - f.y) < 300)) continue;
const n = fires.filter((g) => Math.hypot(g.x - f.x, g.y - f.y) < 150).length;
if (n > score) {
score = n;
best = { x: f.x, y: f.y };
}
}
if (best) {
b.target = best;
b.state = b.water > 0 ? 'fight' : 'fill';
}
}
if (b.state === 'home') {
if (this.flyTo(b, b.base, dt) < 10) {
b.state = 'base';
b.target = null;
}
} else b.z = Math.max(0, b.z - dt * 12);
continue;
}
b.z = Math.min(46, b.z + dt * 16);
if (b.state === 'fill') {
const src = WATER_SOURCES.reduce((p, q) => (Math.hypot(q.x - b.x, q.y - b.y) < Math.hypot(p.x - b.x, p.y - b.y) ? q : p));
if (this.flyTo(b, src, dt) < 20) {
b.timer += dt;
b.z = Math.max(14, b.z - dt * 30);
if (Math.random() < dt * 10) this.effects.water(b.x, b.y, b.x + range(-8, 8), b.y + range(-8, 8));
if (b.timer > 2.5) {
b.water = 1;
b.timer = 0;
b.state = 'fight';
}
}
} else if (b.state === 'fight' && b.target) {
if (this.flyTo(b, b.target, dt) < 25) {
// Release over the fire: a falling sheet of water that knocks the flames down.
for (let k = 0; k < 30; k++) this.effects.water(b.x, b.y, b.x + range(-50, 50), b.y + range(-50, 50));
this.world.douse(b.target.x, b.target.y, 75);
this.effects.extinguish(b.target.x, b.target.y, 75);
b.water = 0;
b.target = null;
b.state = fires.length > 1 ? 'fill' : 'home';
this.world.sound('heli', b.x, b.y);
}
}
}
}
private flyTo(b: Bomber, p: Vec, dt: number): number {
const dx = p.x - b.x;
const dy = p.y - b.y;
const d = Math.hypot(dx, dy);
const want = Math.min(160, d * 0.8);
const k = Math.min(1, dt * 1.2);
b.vx += ((d > 0.1 ? (dx / d) * want : 0) - b.vx) * k;
b.vy += ((d > 0.1 ? (dy / d) * want : 0) - b.vy) * k;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (Math.hypot(b.vx, b.vy) > 8) b.angle = Math.atan2(b.vy, b.vx);
return d;
}
// --- Drawing.
/** Air-defence sites and Guard vehicles on the ground. */
drawGround(ctx: CanvasRenderingContext2D, view: View, time: number): void {
for (const s of this.sites) {
if (!inView(s.x, s.y, view, 40)) continue;
const dead = s.hp <= 0;
ctx.save();
ctx.translate(s.x, s.y);
// Sandbag ring (or the Guard truck).
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.arc(3, 4, 15, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = dead ? '#3a3530' : s.mobile ? '#5a5f45' : '#b6a77d';
ctx.beginPath();
ctx.arc(0, 0, 15, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = dead ? '#2b2b2e' : '#4b5320';
ctx.beginPath();
ctx.arc(0, 0, 9, 0, Math.PI * 2);
ctx.fill();
if (!dead) {
ctx.rotate(s.angle);
ctx.fillStyle = '#2b2b2e';
if (s.kind === 'gun') {
ctx.fillRect(4, -2.2, 16, 1.6);
ctx.fillRect(4, 0.6, 16, 1.6);
if (s.burst > 0 && Math.sin(time * 60) > 0) {
ctx.fillStyle = '#ffd56a';
ctx.beginPath();
ctx.arc(21, 0, 3, 0, Math.PI * 2);
ctx.fill();
}
} else {
ctx.fillStyle = '#d5d8dc';
for (const oy of [-4, 0, 4]) ctx.fillRect(2, oy - 1.2, 14, 2.4);
}
}
ctx.restore();
}
}
/** Tracer, flak, missiles, flares and the water bombers, up in the air. */
drawAir(ctx: CanvasRenderingContext2D, view: View, lift: Lift, pixel: number, time: number): void {
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (const r of this.rounds) {
const k1 = Math.min(1, r.t / r.dur);
const k0 = Math.max(0, k1 - 0.25);
const a = lift(r.x0 + (r.x1 - r.x0) * k0, r.y0 + (r.y1 - r.y0) * k0, FLIGHT_ALT * k0);
const b = lift(r.x0 + (r.x1 - r.x0) * k1, r.y0 + (r.y1 - r.y0) * k1, FLIGHT_ALT * k1);
if (!a || !b || !inView(b.x, b.y, view, 60)) continue;
ctx.strokeStyle = 'rgba(255, 120, 60, 0.85)';
ctx.lineWidth = 1.6 * pixel;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
for (const f of this.flares) {
const at = lift(f.x, f.y, FLIGHT_ALT);
if (!at) continue;
const g = ctx.createRadialGradient(at.x, at.y, 0, at.x, at.y, 9 * at.s);
g.addColorStop(0, `rgba(255, 255, 230, ${Math.min(1, f.life)})`);
g.addColorStop(1, 'rgba(255, 200, 80, 0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(at.x, at.y, 9 * at.s, 0, Math.PI * 2);
ctx.fill();
}
ctx.lineCap = 'butt';
ctx.globalCompositeOperation = 'source-over';
for (const m of this.sams) {
const at = lift(m.x, m.y, m.z);
if (!at || !inView(at.x, at.y, view, 60)) continue;
ctx.save();
ctx.translate(at.x, at.y);
ctx.rotate(m.angle);
ctx.scale(at.s, at.s);
ctx.fillStyle = 'rgba(255, 236, 170, 0.95)';
ctx.beginPath();
ctx.moveTo(-5, -0.8);
ctx.lineTo(-10 - Math.sin(time * 70) * 2, 0);
ctx.lineTo(-5, 0.8);
ctx.fill();
ctx.fillStyle = '#ecf0f1';
ctx.fillRect(-5, -0.7, 10, 1.4);
ctx.fillStyle = '#c0392b';
ctx.fillRect(4, -0.7, 1.5, 1.4);
ctx.restore();
}
for (const b of this.bombers) {
if (!inView(b.x, b.y, view, 80)) continue;
// The bucket swinging under it, full or empty.
if (b.z > 4) {
ctx.strokeStyle = 'rgba(30, 30, 30, 0.6)';
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x - Math.cos(b.angle) * 10, b.y - Math.sin(b.angle) * 10 + 6);
ctx.stroke();
ctx.fillStyle = b.water > 0 ? '#3f86ad' : '#d35400';
ctx.beginPath();
ctx.arc(b.x - Math.cos(b.angle) * 10, b.y - Math.sin(b.angle) * 10 + 6, 2.6, 0, Math.PI * 2);
ctx.fill();
}
drawHelicopter(ctx, b.x, b.y, b.angle, b.z, b.rotor, '#c0392b', '#f2f2f2', false);
}
}
/** Glows for the night: tracer, rocket motors, flares. */
lights(lift: Lift): { x: number; y: number; r: number }[] {
const out: { x: number; y: number; r: number }[] = [];
for (const f of this.flares) {
const at = lift(f.x, f.y, FLIGHT_ALT);
if (at) out.push({ x: at.x, y: at.y, r: 40 });
}
for (const m of this.sams) {
const at = lift(m.x, m.y, m.z);
if (at) out.push({ x: at.x, y: at.y, r: 14 });
}
return out;
}
}
