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 { City } from './city.js';
import { angleDiff, type Vec } from './geometry.js';
import type { ServiceTask } from './services.js';
import { inView, type View } from './traffic.js';
import { drawHelicopter, drawPed, type PedLook } from './vehicles.js';
export type HeliKind = 'medevac' | 'police';
interface Base {
kind: HeliKind;
x: number;
y: number;
busy: boolean;
/** Gone with its building. */
lost: () => boolean;
}
interface Medic {
x: number;
y: number;
angle: number;
stride: number;
queue: Vec[];
timer: number;
}
const CRUISE = 46;
const MEDIC: PedLook = { shirt: '#ff3b3b', pants: '#1f3b6e', skin: '#e0ac69', hair: '#2b1d14', hat: '#f4f4f4' };
export class Helicopter {
x: number;
y: number;
z = 0;
vx = 0;
vy = 0;
angle = 0;
rotor = 0;
state: 'up' | 'fly' | 'down' | 'work' | 'circle' | 'home' | 'land' = 'up';
timer = 0;
dest: Vec;
medics: Medic[] = [];
constructor(
readonly kind: HeliKind,
readonly base: Base,
readonly target: Vec,
readonly task: ServiceTask | null,
) {
this.x = base.x;
this.y = base.y;
this.dest = { ...target };
}
get busy(): boolean {
return this.state !== 'home' && this.state !== 'land';
}
}
/** Medevac helicopters from hospital helipads, and a police helicopter that circles big incidents. */
export class Helicopters {
readonly helis: Helicopter[] = [];
private readonly bases: Base[] = [];
private time = 0;
/** Rotor thump near the player. */
onRotor: ((x: number, y: number) => void) | null = null;
private thump = 0;
constructor(private readonly city: City) {
let police = 0;
for (const st of city.stations) {
const lost = () => !!st.structure?.destroyed;
if (st.kind === 'hospital') this.bases.push({ kind: 'medevac', x: st.x, y: st.y, busy: false, lost });
// Two police helicopters, on the first two police station roofs.
if (st.kind === 'police' && police++ < 2) this.bases.push({ kind: 'police', x: st.x, y: st.y, busy: false, lost });
}
}
clear(): void {
this.helis.length = 0;
for (const b of this.bases) b.busy = false;
}
covering(kind: HeliKind, x: number, y: number, r: number): boolean {
return this.helis.some((h) => h.kind === kind && h.busy && Math.hypot(h.target.x - x, h.target.y - y) < r);
}
available(kind: HeliKind): number {
return this.bases.filter((b) => b.kind === kind && !b.busy && !b.lost()).length;
}
/** Fly the nearest free helicopter of a kind out to a spot. */
dispatch(kind: HeliKind, x: number, y: number, task: ServiceTask | null): boolean {
const base = this.bases.filter((b) => b.kind === kind && !b.busy && !b.lost()).sort((a, b) => Math.hypot(a.x - x, a.y - y) - Math.hypot(b.x - x, b.y - y))[0];
if (!base) return false;
const h = new Helicopter(kind, base, { x, y }, task);
h.angle = Math.atan2(y - base.y, x - base.x);
if (kind === 'medevac') {
const spot = this.landingSpot(x, y);
if (!spot) return false;
h.dest = spot;
}
base.busy = true;
this.helis.push(h);
return true;
}
/** Somewhere clear of buildings and trees near the scene to set down. */
private landingSpot(x: number, y: number): Vec | null {
for (let r = 18; r <= 90; r += 12) {
for (let k = 0; k < 10; k++) {
const a = (k / 10) * Math.PI * 2 + r;
const px = x + Math.cos(a) * r;
const py = y + Math.sin(a) * r;
const push = this.city.resolveCircle(px, py, 11, 0);
if (!push.x && !push.y && !this.city.onDeck(px, py)) return { x: px, y: py };
}
}
return null;
}
update(dt: number, view: View, listener: Vec): void {
this.time += dt;
for (const h of this.helis) {
h.rotor += dt * (h.z > 0 || h.state === 'up' ? 28 : 6);
switch (h.state) {
case 'up':
h.z = Math.min(CRUISE, h.z + dt * 16);
// Climbing out: to the scene, or home after a job.
if (h.z >= CRUISE * 0.6) h.state = h.timer < 0 ? 'home' : 'fly';
break;
case 'fly':
if (this.flyTo(h, h.dest, dt) < 6) {
if (h.kind === 'police') {
h.state = 'circle';
h.timer = 50;
} else h.state = 'down';
}
break;
case 'down':
this.hover(h, dt);
h.z = Math.max(0, h.z - dt * 14);
if (h.z <= 0) {
h.state = 'work';
h.timer = 0;
const pts = h.task?.points() ?? [];
h.medics = [0, 1].map((i) => ({ x: h.x + (i ? 6 : -6), y: h.y + 4, angle: 0, stride: 0, queue: pts.filter((_, k) => k % 2 === i), timer: 0 }));
}
break;
case 'work':
this.hover(h, dt);
h.timer += dt;
if (this.medicsWork(h, dt) || h.timer > 70) {
h.task?.onDone?.();
h.medics = [];
h.state = 'up';
h.dest = { x: h.base.x, y: h.base.y };
h.timer = -1;
}
break;
case 'circle': {
h.timer -= dt;
const a = this.time * 0.45 + h.base.x;
this.flyTo(h, { x: h.target.x + Math.cos(a) * 55, y: h.target.y + Math.sin(a) * 55 }, dt, 60);
h.z += (CRUISE + 8 - h.z) * Math.min(1, dt);
if (h.timer <= 0) {
h.task?.onDone?.();
h.state = 'home';
}
break;
}
case 'home':
h.z += (CRUISE - h.z) * Math.min(1, dt);
if (this.flyTo(h, { x: h.base.x, y: h.base.y }, dt) < 4) h.state = 'land';
break;
case 'land':
this.hover(h, dt);
h.z = Math.max(0, h.z - dt * 14);
break;
}
}
for (let i = this.helis.length - 1; i >= 0; i--) {
const h = this.helis[i];
if (h.state === 'land' && h.z <= 0) {
h.base.busy = false;
this.helis.splice(i, 1);
}
}
this.thump -= dt;
if (this.thump <= 0) {
this.thump = 0.16;
const near = this.helis.filter((h) => h.z > 0 || h.state === 'up').sort((a, b) => Math.hypot(a.x - listener.x, a.y - listener.y) - Math.hypot(b.x - listener.x, b.y - listener.y))[0];
if (near && inView(near.x, near.y, view, 400)) this.onRotor?.(near.x, near.y);
}
}
/** Accelerate toward a point and turn to face the direction of travel. Returns the distance left. */
private flyTo(h: Helicopter, p: Vec, dt: number, top = 150): number {
const dx = p.x - h.x;
const dy = p.y - h.y;
const d = Math.hypot(dx, dy);
const want = Math.min(top, d * 0.9);
const tvx = d > 0.1 ? (dx / d) * want : 0;
const tvy = d > 0.1 ? (dy / d) * want : 0;
const k = Math.min(1, dt * 1.2);
h.vx += (tvx - h.vx) * k;
h.vy += (tvy - h.vy) * k;
h.x += h.vx * dt;
h.y += h.vy * dt;
if (Math.hypot(h.vx, h.vy) > 8) h.angle += angleDiff(Math.atan2(h.vy, h.vx), h.angle) * Math.min(1, dt * 2);
return d;
}
private hover(h: Helicopter, dt: number): void {
h.vx *= Math.exp(-3 * dt);
h.vy *= Math.exp(-3 * dt);
h.x += h.vx * dt;
h.y += h.vy * dt;
}
/** Flight medics run to each patient and treat them. Returns true once both are back aboard. */
private medicsWork(h: Helicopter, dt: number): boolean {
let done = true;
for (const m of h.medics) {
const goal = m.queue[0] ?? { x: h.x, y: h.y + 4 };
const dx = goal.x - m.x;
const dy = goal.y - m.y;
const d = Math.hypot(dx, dy);
if (d > 3) {
const step = Math.min(d - 2.9, 26 * dt);
m.x += (dx / d) * step;
m.y += (dy / d) * step;
m.angle = Math.atan2(dy, dx);
m.stride += step;
done = false;
continue;
}
if (!m.queue.length) continue;
done = false;
m.timer += dt;
if (m.timer >= (h.task?.work ?? 2)) {
m.timer = 0;
h.task?.onPointDone?.(goal, null as never);
m.queue.shift();
}
}
return done;
}
/** Helicopters waiting on their pads. */
drawBases(ctx: CanvasRenderingContext2D, view: View): void {
for (const b of this.bases) {
if (b.busy || b.lost() || !inView(b.x, b.y, view, 30)) continue;
drawHelicopter(ctx, b.x, b.y, -Math.PI / 2, 0, 0.4, b.kind === 'medevac' ? '#f4f1ea' : '#1f3b6e', b.kind === 'medevac' ? '#d93a3a' : '#f4f1ea', false);
}
}
draw(ctx: CanvasRenderingContext2D, view: View, time: number): void {
for (const h of this.helis) {
if (!inView(h.x, h.y, view, 60)) continue;
for (const m of h.medics) drawPed(ctx, MEDIC, m.x, m.y, m.angle, m.stride, 'walk', 0, 0, time);
const flash = Math.floor(time * 3) % 2 === 0;
drawHelicopter(ctx, h.x, h.y, h.angle, h.z, h.rotor, h.kind === 'medevac' ? '#f4f1ea' : '#1f3b6e', h.kind === 'medevac' ? '#d93a3a' : '#f4f1ea', flash);
}
}
/** Searchlights (police) and landing lights for the night overlay. */
beams(view: View): { x: number; y: number; r: number }[] {
const out: { x: number; y: number; r: number }[] = [];
for (const h of this.helis) {
if (h.kind === 'police' && h.state === 'circle') {
const a = this.time * 0.8;
const p = { x: h.target.x + Math.cos(a) * 18, y: h.target.y + Math.sin(a * 1.3) * 18 };
if (inView(p.x, p.y, view, 40)) out.push({ x: p.x, y: p.y, r: 30 });
} else if (h.z < 30 && h.z > 0 && inView(h.x, h.y, view, 40)) out.push({ x: h.x, y: h.y, r: 24 });
}
return out;
}
/** Blinking nav lights. */
lights(view: View): { x: number; y: number; color: string }[] {
if (Math.floor(this.time * 2) % 2) return [];
return this.helis.filter((h) => inView(h.x, h.y, view, 40)).map((h) => ({ x: h.x, y: h.y, color: 'rgba(255, 40, 40, 0.8)' }));
}
}
