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 { A10Flight, FLIGHT_ALT, METERS } from './a10.js';
import { Airstrike, type Launch } from './airstrike.js';
import { MAP_H, MAP_W } from './city.js';
import { angleDiff, type Vec } from './geometry.js';
import type { Body } from './physics.js';
/**
* An A-10 flown by the computer, raiding the city while the player drives: it hunts
* round wherever the player is (so there's something to see), going for the city's
* infrastructure first, then buildings, and strafing the traffic in between. It flies
* the same flight model and weapons as the player's jet: it lines up bomb runs on its
* own pipper, swings wide when a target's inside its turn, ripples Mk 82s, drops the
* JDAM on the big ones, walks the GAU-8 across the cars, and comes round again. The
* city's air defences shoot at it; enough hits and it goes down, and after a while
* another one comes in.
*/
export interface RaiderTarget {
x: number;
y: number;
/** How it means to hit it. */
how: 'bomb' | 'jdam' | 'gun' | 'maverick';
body?: Body;
}
export interface RaiderWorld {
/** Where the player is: the raid stays round here. */
focus(): Vec;
/** Something worth hitting near a point, or null. */
pickTarget(near: Vec): RaiderTarget | null;
/** Vehicles on the road near a point (for gun runs). */
carsNear(x: number, y: number, r: number): Body[];
/** The jet's been hit hard enough: it's going down. */
shotDown(x: number, y: number): void;
toast(text: string): void;
}
export class Raider {
readonly flight: A10Flight;
readonly air = new Airstrike();
x: number;
y: number;
damage = 0;
/** Going down: 0 flying .. 1 in the ground. */
fall = -1;
private target: RaiderTarget | null = null;
private retarget = 0;
/** Extending away from a target that was inside its turn. */
private extend = 0;
private burst = 0;
private rearm = 0;
firing = false;
private time = 0;
constructor(private readonly world: RaiderWorld) {
// Comes in from the map edge nearest the action.
const f = world.focus();
const edge = [
{ x: f.x, y: 150 },
{ x: f.x, y: MAP_H - 150 },
{ x: 150, y: f.y },
{ x: MAP_W - 150, y: f.y },
].sort((a, b) => Math.hypot(a.x - f.x, a.y - f.y) - Math.hypot(b.x - f.x, b.y - f.y))[0];
this.x = edge.x;
this.y = edge.y;
this.flight = new A10Flight(Math.atan2(f.y - edge.y, f.x - edge.x));
this.flight.lever = 0.45;
this.flight.spool = 0.45;
}
get alive(): boolean {
return this.fall < 0;
}
get altitude(): number {
return FLIGHT_ALT * (this.fall < 0 ? 1 : 1 - this.fall);
}
get vx(): number {
return (Math.cos(this.flight.heading) * this.flight.v) / METERS;
}
get vy(): number {
return (Math.sin(this.flight.heading) * this.flight.v) / METERS;
}
launch(): Launch {
return { x: this.x, y: this.y, z: this.altitude, vx: this.vx, vy: this.vy, heading: this.flight.heading };
}
hit(damage: number): void {
if (!this.alive) return;
this.damage = Math.min(100, this.damage + damage);
if (this.damage >= 100) {
this.fall = 0;
this.world.toast('The A-10 is hit and going down!');
}
}
update(dt: number): void {
this.time += dt;
this.air.update(dt);
this.runPending(dt);
const f = this.flight;
f.stores = this.air.storesKg;
this.firing = false;
if (!this.alive) {
// Rolled over and falling out of the sky.
this.fall = Math.min(1, this.fall + dt / 2.6);
f.bank = Math.min(1.4, f.bank + dt * 1.4);
f.step(dt, { roll: 1, throttleUp: false, throttleDown: true });
this.move(dt);
if (this.fall >= 1) {
this.world.shotDown(this.x, this.y);
this.fall = 2;
}
return;
}
// Out of everything: head away, re-arm, come back.
if (this.air.winchester || this.rearm > 0) {
if (this.rearm <= 0) this.rearm = 9;
this.rearm -= dt;
if (this.rearm <= 0) this.air.rearm();
}
this.retarget -= dt;
const focus = this.world.focus();
if (!this.target || this.retarget <= 0) {
this.target = this.rearm > 0 ? null : this.world.pickTarget(focus);
this.retarget = 14;
if (this.target) this.air.mode = this.target.how === 'jdam' ? 'gbu31' : this.target.how === 'bomb' ? (Math.random() < 0.5 ? 'mk82ld' : 'mk82hd') : this.air.mode;
}
const t = this.target;
const goal = t ? (t.body ? { x: t.body.x, y: t.body.y } : t) : { x: focus.x + Math.cos(this.time * 0.2) * 900, y: focus.y + Math.sin(this.time * 0.2) * 900 };
// Steer: aim the bomb pipper (or the nose) at the target, or extend away to come round.
const from = this.launch();
let aimAt: Vec = goal;
if (t && (t.how === 'bomb' || t.how === 'jdam')) {
const ccip = this.air.ccipBomb(from);
// Lead the turn so the pipper, not the nose, comes onto it.
aimAt = { x: goal.x - (ccip.x - this.x), y: goal.y - (ccip.y - this.y) };
}
const want = Math.atan2(aimAt.y - this.y, aimAt.x - this.x);
const off = angleDiff(want, f.heading);
const dist = Math.hypot(goal.x - this.x, goal.y - this.y);
if (this.extend > 0) this.extend -= dt;
else if (dist < 650 && Math.abs(off) > 1.1) this.extend = 2.2;
const roll = this.extend > 0 ? 0 : Math.max(-1, Math.min(1, off * 2.2));
// Slow down for the attack, open up when far off or leaving.
const throttle = dist > 1800 ? 0.85 : 0.4;
f.step(dt, { roll, throttleUp: f.lever < throttle, throttleDown: f.lever > throttle + 0.05 });
this.move(dt);
// Back over the map if it's wandered off the edge.
if (this.x < 150 || this.y < 150 || this.x > MAP_W - 150 || this.y > MAP_H - 150) {
f.heading += Math.max(-0.9 * dt, Math.min(0.9 * dt, angleDiff(Math.atan2(MAP_H / 2 - this.y, MAP_W / 2 - this.x), f.heading)));
}
if (!t || this.rearm > 0) {
this.air.cease();
return;
}
// Release: the pipper's on the target.
if (t.how === 'bomb' || t.how === 'jdam') {
const ccip = this.air.ccipBomb(this.launch());
if (Math.hypot(ccip.x - goal.x, ccip.y - goal.y) < (t.how === 'jdam' ? 70 : 40) && this.air.stock() > 0) {
const drops = t.how === 'jdam' ? 1 : 2 + Math.floor(Math.random() * 3);
for (let i = 0; i < drops; i++) setTimeoutLike(this, i * 0.13, () => this.air.drop(this.launch(), t.how === 'jdam' ? ccip : null));
this.target = null;
}
} else if (t.how === 'maverick') {
if (Math.abs(off) < 0.2 && dist < 2200 && this.air.mavericks > 0 && t.body) {
this.air.launchMaverick(this.launch(), [t.body]);
this.target = null;
}
}
// Strafe anything that drifts under the gun cross; on a gun target, hold it there.
const cross = this.air.ccipGun(this.launch());
const cars = this.world.carsNear(cross.x, cross.y, 30);
if (cars.length || (t.how === 'gun' && Math.hypot(cross.x - goal.x, cross.y - goal.y) < 40)) this.burst = Math.max(this.burst, 0.7);
if (this.burst > 0 && this.air.rounds > 0) {
this.burst -= dt;
this.firing = this.air.fire(dt, this.launch()) > 0;
if (t.how === 'gun' && this.burst <= 0) this.target = null;
} else this.air.cease();
}
private move(dt: number): void {
const gs = this.flight.v / METERS;
this.x += Math.cos(this.flight.heading) * gs * dt;
this.y += Math.sin(this.flight.heading) * gs * dt;
}
/** Bombs rippled off a few hundredths of a second apart. */
pending: { at: number; run: () => void }[] = [];
private runPending(dt: number): void {
for (const p of this.pending) p.at -= dt;
const due = this.pending.filter((p) => p.at <= 0);
this.pending = this.pending.filter((p) => p.at > 0);
for (const p of due) p.run();
}
}
function setTimeoutLike(r: Raider, delay: number, run: () => void): void {
if (delay <= 0) run();
else r.pending.push({ at: delay, run });
}
