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 { Body } from './physics.js';
import { inView, type View } from './traffic.js';
/**
* The A-10's weapons, flown as projectiles from the jet's working height down to the
* ground. Positions and velocities are in map units; time is compressed to the game's
* scale (a bomb takes about 1.3 s to fall), so everything lands where you can see it.
*
* - GAU-8/A Avenger: 1,150 rounds at 3,900 a minute after a half-second spin-up, the
* combat mix of five armour-piercing incendiary rounds to one high-explosive, a tight
* stream walking across the ground ahead of the nose.
* - Mk 82 AIR (500 lb): the BSU-49 ballute pops open in high-drag mode and the bomb
* drops steeply behind the jet, or stays closed in low-drag mode and flies on ahead.
* - GBU-31 JDAM (2,000 lb Mk 84): steered onto the point under the pipper at release.
* - AGM-65 Maverick: locks the vehicle nearest the jet's line of flight and dives onto it.
*/
export type BombKind = 'mk82hd' | 'mk82ld' | 'gbu31';
export const BOMB_NAMES: Record<BombKind, string> = {
mk82hd: 'MK-82 AIR · HIGH DRAG',
mk82ld: 'MK-82 AIR · LOW DRAG',
gbu31: 'GBU-31 JDAM',
};
/** Where a weapon leaves the jet: map position and velocity (units, units/s). */
export interface Launch {
x: number;
y: number;
z: number;
vx: number;
vy: number;
heading: number;
}
/** Projects a point at height z (units) to where it's drawn in ground coordinates, with its scale. */
export type Lift = (x: number, y: number, z: number) => { x: number; y: number; s: number } | null;
interface Bomb {
kind: BombKind;
x: number;
y: number;
z: number;
vx: number;
vy: number;
vz: number;
age: number;
/** The ballute's inflation, 0..1 (high drag only). */
chute: number;
target?: { x: number; y: number };
}
interface Round {
x0: number;
y0: number;
x1: number;
y1: number;
t: number;
dur: number;
hei: boolean;
}
interface Maverick {
x: number;
y: number;
z: number;
angle: number;
speed: number;
age: number;
target: Body | null;
aim: { x: number; y: number };
dist0: number;
}
interface AirPuff {
x: number;
y: number;
z: number;
vx: number;
vy: number;
r: number;
grow: number;
life: number;
max: number;
rgb: [number, number, number];
alpha: number;
}
/** Gravity at the game's scale (units/s²): a bomb falls the working height in about 1.3 s. */
const GRAVITY = 140;
const ROUNDS_PER_SECOND = 65;
const ROUND_SPEED = 3000;
/** Spread of the stream (radians, one sigma). */
const SIGMA = 0.012;
export const FULL_LOAD = { rounds: 1150, mk82: 6, gbu31: 1, mavericks: 2 };
const gauss = () => {
let u = 0;
let v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
/** How far ahead of the jet the gun's rounds strike the ground (units). */
export function gunReach(speed: number): number {
return 230 + speed * 0.12;
}
export class Airstrike {
rounds = FULL_LOAD.rounds;
mk82 = FULL_LOAD.mk82;
gbu31 = FULL_LOAD.gbu31;
mavericks = FULL_LOAD.mavericks;
mode: BombKind = 'mk82hd';
/** Barrel spin-up, 0..1: the first moments of a burst fire slower. */
spin = 0;
private bombs: Bomb[] = [];
private rnds: Round[] = [];
private mavs: Maverick[] = [];
private puffs: AirPuff[] = [];
private gunTimer = 0;
private bombCooldown = 0;
private mavCooldown = 0;
private mavSide = 1;
private heiCount = 0;
/** A bomb reaches the ground. */
onBomb: ((x: number, y: number, kind: BombKind) => void) | null = null;
/** A 30 mm round hits the ground (dx, dy: its direction of travel). */
onRound: ((x: number, y: number, hei: boolean, dx: number, dy: number) => void) | null = null;
/** A Maverick's warhead goes off. */
onMaverick: ((x: number, y: number) => void) | null = null;
/** The ballute pops open. */
onBallute: ((x: number) => void) | null = null;
clear(): void {
this.bombs = [];
this.rnds = [];
this.mavs = [];
this.puffs = [];
this.spin = 0;
this.gunTimer = 0;
this.rearm();
}
rearm(): void {
this.rounds = FULL_LOAD.rounds;
this.mk82 = FULL_LOAD.mk82;
this.gbu31 = FULL_LOAD.gbu31;
this.mavericks = FULL_LOAD.mavericks;
if (this.mode === 'gbu31' && !this.gbu31) this.mode = 'mk82hd';
}
/** Everything still hanging on the jet, plus the ammunition (kg). */
get storesKg(): number {
return this.rounds * 0.7 + this.mk82 * 241 + this.gbu31 * 934 + this.mavericks * 305 + 250;
}
get winchester(): boolean {
return !this.rounds && !this.mk82 && !this.gbu31 && !this.mavericks;
}
/** Bombs of the selected kind still on the racks. */
stock(kind: BombKind = this.mode): number {
return kind === 'gbu31' ? this.gbu31 : this.mk82;
}
/** Step through the bomb modes: Mk 82 high drag, low drag, the JDAM. */
cycleMode(): BombKind {
const order: BombKind[] = ['mk82hd', 'mk82ld', 'gbu31'];
for (let i = 1; i <= order.length; i++) {
const next = order[(order.indexOf(this.mode) + i) % order.length];
if (this.stock(next) > 0 || i === order.length) {
this.mode = next;
break;
}
}
return this.mode;
}
/** Hold the trigger: the stream walks across the ground ahead of the nose. Returns rounds fired. */
fire(dt: number, from: Launch): number {
if (this.rounds <= 0) {
this.cease();
return 0;
}
this.spin = Math.min(1, this.spin + dt / 0.5);
const rate = ROUNDS_PER_SECOND * (0.55 + 0.45 * this.spin);
this.gunTimer -= dt;
let fired = 0;
const c = Math.cos(from.heading);
const s = Math.sin(from.heading);
const speed = Math.hypot(from.vx, from.vy);
while (this.gunTimer <= 0 && this.rounds > 0) {
this.gunTimer += 1 / rate;
this.rounds--;
fired++;
const reach = gunReach(speed) * (1 + gauss() * SIGMA * 2);
const side = gauss() * SIGMA * reach;
const x0 = from.x + c * 22;
const y0 = from.y + s * 22;
const x1 = from.x + c * reach - s * side;
const y1 = from.y + s * reach + c * side;
this.heiCount = (this.heiCount + 1) % 6;
this.rnds.push({ x0, y0, x1, y1, t: 0, dur: Math.hypot(reach, FLIGHT_ALT) / ROUND_SPEED, hei: this.heiCount === 0 });
// Gun gas: the cloud that streams back from the nose.
if (Math.random() < 0.45) this.puff(x0, y0, FLIGHT_ALT, from.vx * 0.5, from.vy * 0.5, 3, 12, 1, [196, 192, 184], 0.45);
}
return fired;
}
/** Let go of the trigger: the barrels coast down. */
cease(): void {
this.gunTimer = 0;
this.spin = Math.max(0, this.spin - 0.08);
}
/** Release one bomb of the selected kind. Returns what was dropped, if anything. */
drop(from: Launch, target: { x: number; y: number } | null): BombKind | null {
if (this.bombCooldown > 0) return null;
const kind = this.mode;
if (this.stock(kind) <= 0) return null;
if (kind === 'gbu31') this.gbu31--;
else this.mk82--;
// Racks ripple about 0.12 s apart.
this.bombCooldown = 0.12;
const side = this.mk82 % 2 ? -1 : 1;
const scatter = kind === 'gbu31' ? 0 : 0.03;
const speed = Math.hypot(from.vx, from.vy);
this.bombs.push({
kind,
x: from.x - Math.sin(from.heading) * side * 6,
y: from.y + Math.cos(from.heading) * side * 6,
z: from.z - 3,
vx: from.vx + gauss() * speed * scatter,
vy: from.vy + gauss() * speed * scatter,
vz: -8,
age: 0,
chute: 0,
target: kind === 'gbu31' && target ? { ...target } : undefined,
});
if (this.mode !== 'gbu31' && this.mk82 <= 0 && this.gbu31 > 0) this.mode = 'gbu31';
if (this.mode === 'gbu31' && this.gbu31 <= 0 && this.mk82 > 0) this.mode = 'mk82hd';
return kind;
}
/** Fire a Maverick at the vehicle nearest the line of flight (within 15°), or straight ahead. */
launchMaverick(from: Launch, targets: Body[]): boolean {
if (this.mavericks <= 0 || this.mavCooldown > 0) return false;
let best: Body | null = null;
let bestCos = Math.cos(0.26);
const c = Math.cos(from.heading);
const s = Math.sin(from.heading);
for (const b of targets) {
const dx = b.x - from.x;
const dy = b.y - from.y;
const d = Math.hypot(dx, dy);
if (d < 120 || d > 2600) continue;
const k = (dx * c + dy * s) / d;
if (k > bestCos) {
bestCos = k;
best = b;
}
}
this.mavericks--;
this.mavCooldown = 0.6;
this.mavSide = -this.mavSide;
const aim = { x: from.x + c * 900, y: from.y + s * 900 };
const tx = best ? best.x : aim.x;
const ty = best ? best.y : aim.y;
this.mavs.push({
x: from.x - s * this.mavSide * 14,
y: from.y + c * this.mavSide * 14,
z: from.z - 4,
angle: from.heading,
speed: Math.hypot(from.vx, from.vy),
age: 0,
target: best,
aim,
dist0: Math.max(200, Math.hypot(tx - from.x, ty - from.y)),
});
return true;
}
/** Where a bomb released now would land, and how long it would fall. */
ccipBomb(from: Launch, kind: BombKind = this.mode): { x: number; y: number; t: number } {
let { x, y, z, vx, vy } = from;
let vz = -8;
let t = 0;
const dt = 0.02;
while (z > 0 && t < 5) {
const chute = kind === 'mk82hd' ? Math.min(1, t / 0.35) : 0;
if (chute) {
const k = Math.exp(-3.2 * chute * dt);
vx *= k;
vy *= k;
}
vz -= GRAVITY * dt;
if (chute) vz = Math.max(vz, -150);
x += vx * dt;
y += vy * dt;
z += vz * dt;
t += dt;
}
return { x, y, t };
}
/** Where the gun's stream is striking. */
ccipGun(from: Launch): { x: number; y: number } {
const reach = gunReach(Math.hypot(from.vx, from.vy));
return { x: from.x + Math.cos(from.heading) * reach, y: from.y + Math.sin(from.heading) * reach };
}
/** Smoke from a damaged jet: grey streaming back, or black and burning when it's bad. */
smoke(x: number, y: number, z: number, burning: boolean): void {
if (burning) this.puff(x, y, z, 0, 0, 3, 8, 0.35, [255, 150, 50], 0.85);
this.puff(x, y, z, 0, 0, 4, burning ? 14 : 9, burning ? 2.4 : 1.6, burning ? [40, 36, 34] : [120, 118, 114], burning ? 0.7 : 0.4);
}
private puff(x: number, y: number, z: number, vx: number, vy: number, r: number, grow: number, life: number, rgb: [number, number, number], alpha: number): void {
if (this.puffs.length > 600) this.puffs.shift();
this.puffs.push({ x, y, z, vx, vy, r, grow, life, max: life, rgb, alpha });
}
update(dt: number): void {
this.bombCooldown -= dt;
this.mavCooldown -= dt;
for (const b of this.bombs) {
b.age += dt;
if (b.kind === 'mk82hd') {
const before = b.chute;
b.chute = Math.min(1, b.age / 0.35);
if (before === 0 && b.chute > 0) this.onBallute?.(b.x);
const k = Math.exp(-3.2 * b.chute * dt);
b.vx *= k;
b.vy *= k;
}
b.vz -= GRAVITY * dt;
if (b.kind === 'mk82hd' && b.chute > 0) b.vz = Math.max(b.vz, -150);
// The JDAM's tail fins steer its fall onto the coordinates it was given.
if (b.target) {
const tgo = (b.vz + Math.sqrt(b.vz * b.vz + 2 * GRAVITY * b.z)) / GRAVITY;
if (tgo > 0.05) {
const lim = 600 * dt;
b.vx += Math.max(-lim, Math.min(lim, ((b.target.x - b.x) / tgo - b.vx) * 4 * dt));
b.vy += Math.max(-lim, Math.min(lim, ((b.target.y - b.y) / tgo - b.vy) * 4 * dt));
}
}
b.x += b.vx * dt;
b.y += b.vy * dt;
b.z += b.vz * dt;
}
for (const b of this.bombs) if (b.z <= 0) this.onBomb?.(b.x, b.y, b.kind);
this.bombs = this.bombs.filter((b) => b.z > 0);
for (const r of this.rnds) r.t += dt;
for (const r of this.rnds) {
if (r.t < r.dur) continue;
const l = Math.hypot(r.x1 - r.x0, r.y1 - r.y0) || 1;
this.onRound?.(r.x1, r.y1, r.hei, (r.x1 - r.x0) / l, (r.y1 - r.y0) / l);
}
this.rnds = this.rnds.filter((r) => r.t < r.dur);
// Mavericks: off the rail, the motor lights, and it flies down onto the target.
for (const m of this.mavs) {
m.age += dt;
const tx = m.target ? m.target.x : m.aim.x;
const ty = m.target ? m.target.y : m.aim.y;
if (m.age > 0.15) {
m.speed = Math.min(1000, m.speed + (m.age < 1 ? 420 : 80) * dt);
const want = Math.atan2(ty - m.y, tx - m.x);
let d = want - m.angle;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
m.angle += Math.max(-3 * dt, Math.min(3 * dt, d));
if (Math.random() < dt * 60) this.puff(m.x, m.y, m.z, 0, 0, 3.5, 14, 1.8 + Math.random(), [228, 226, 222], 0.5);
}
m.x += Math.cos(m.angle) * m.speed * dt;
m.y += Math.sin(m.angle) * m.speed * dt;
// Glide down so it arrives on the ground at the target.
const left = Math.hypot(tx - m.x, ty - m.y);
m.z = Math.min(m.z, Math.max(0, (FLIGHT_ALT - 4) * Math.min(1, left / m.dist0)));
if (left < 8) m.z = 0;
}
for (const m of this.mavs) if (m.z <= 0 || m.age > 6) this.onMaverick?.(m.x, m.y);
this.mavs = this.mavs.filter((m) => m.z > 0 && m.age <= 6);
for (const p of this.puffs) {
p.life -= dt;
p.r += p.grow * dt;
const drag = Math.exp(-dt * 1.6);
p.vx *= drag;
p.vy *= drag;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
this.puffs = this.puffs.filter((p) => p.life > 0);
}
/** Bombs on their way down, Mavericks and their smoke, the gun gas and the streaking rounds. */
draw(ctx: CanvasRenderingContext2D, view: View, lift: Lift, pixel: number, time: number): void {
for (const p of this.puffs) {
const at = lift(p.x, p.y, p.z);
if (!at || !inView(at.x, at.y, view, 60)) continue;
const k = p.life / p.max;
ctx.fillStyle = `rgba(${p.rgb[0]}, ${p.rgb[1]}, ${p.rgb[2]}, ${p.alpha * k * k})`;
ctx.beginPath();
ctx.arc(at.x, at.y, p.r * at.s, 0, Math.PI * 2);
ctx.fill();
}
for (const b of this.bombs) {
const at = lift(b.x, b.y, b.z);
if (!at || !inView(at.x, at.y, view, 60)) continue;
// Its shadow races to meet it.
ctx.fillStyle = `rgba(0, 0, 0, ${0.3 * (1 - b.z / FLIGHT_ALT) + 0.08})`;
ctx.beginPath();
ctx.ellipse(b.x + b.z * 0.35, b.y + b.z * 0.5, b.kind === 'gbu31' ? 6 : 3.8, 1.5, Math.atan2(b.vy, b.vx), 0, Math.PI * 2);
ctx.fill();
const heavy = b.kind === 'gbu31';
const len = heavy ? 6 : 3.6;
const wid = heavy ? 0.75 : 0.45;
ctx.save();
ctx.translate(at.x, at.y);
ctx.rotate(Math.atan2(b.vy, b.vx));
// Nose-down bombs look shorter from above.
const fall = Math.hypot(b.vx, b.vy) / (Math.hypot(b.vx, b.vy, b.vz) || 1);
ctx.scale(at.s * (0.4 + 0.6 * fall), at.s);
if (b.kind === 'mk82hd' && b.chute > 0) {
// The ballute: a fat khaki bag on a short riser behind the tail.
const r = wid * 2.4 * b.chute;
ctx.strokeStyle = 'rgba(40, 40, 40, 0.7)';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(-len, 0);
ctx.lineTo(-len - r * 1.6, 0);
ctx.stroke();
ctx.fillStyle = '#b9a76a';
ctx.beginPath();
ctx.ellipse(-len - r * 2.2, 0, r * 0.8, r, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = heavy ? '#5d6550' : '#4f5a45';
ctx.beginPath();
ctx.ellipse(0, 0, len, wid, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#2b2b2e';
ctx.fillRect(-len - wid * 0.4, -wid * 1.8, wid * 1.2, wid * 3.6);
ctx.fillStyle = '#ffd400';
ctx.fillRect(len * 0.45, -wid, wid * 0.5, wid * 2);
ctx.restore();
}
for (const m of this.mavs) {
const at = lift(m.x, m.y, m.z);
if (!at || !inView(at.x, at.y, view, 60)) continue;
const len = 3.8;
ctx.save();
ctx.translate(at.x, at.y);
ctx.rotate(m.angle);
ctx.scale(at.s, at.s);
if (m.age > 0.15) {
const flick = len * (0.9 + Math.sin(time * 70) * 0.25);
ctx.fillStyle = 'rgba(255, 236, 170, 0.95)';
ctx.beginPath();
ctx.moveTo(-len, -0.6);
ctx.lineTo(-len - flick, 0);
ctx.lineTo(-len, 0.6);
ctx.fill();
}
ctx.fillStyle = '#c9c7bd';
ctx.fillRect(-len, -0.5, len * 2, 1);
ctx.fillStyle = '#3a3a3e';
ctx.beginPath();
ctx.arc(len, 0, 0.5, -Math.PI / 2, Math.PI / 2);
ctx.fill();
ctx.fillStyle = '#8d9096';
ctx.fillRect(-len, -1.4, 1.1, 2.8);
ctx.fillRect(len * 0.1, -1.1, 0.8, 2.2);
ctx.restore();
}
// The rounds: hot streaks from the nose down to the ground.
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (const r of this.rnds) {
const k0 = Math.max(0, r.t / r.dur - 0.35);
const k1 = Math.min(1, r.t / r.dur);
const a = lift(r.x0 + (r.x1 - r.x0) * k0, r.y0 + (r.y1 - r.y0) * k0, FLIGHT_ALT * (1 - k0));
const b = lift(r.x0 + (r.x1 - r.x0) * k1, r.y0 + (r.y1 - r.y0) * k1, FLIGHT_ALT * (1 - k1));
if (!a || !b || !inView(b.x, b.y, view, 80)) continue;
ctx.strokeStyle = r.hei ? 'rgba(255, 214, 120, 0.9)' : 'rgba(255, 170, 70, 0.6)';
ctx.lineWidth = (r.hei ? 1.8 : 1.2) * pixel;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
ctx.lineCap = 'butt';
ctx.globalCompositeOperation = 'source-over';
}
/** Glows for the night overlay: hot rounds and rocket motors. */
lights(lift: Lift): { x: number; y: number }[] {
const out: { x: number; y: number }[] = [];
for (const r of this.rnds) {
if (!r.hei) continue;
const k = Math.min(1, r.t / r.dur);
const at = lift(r.x0 + (r.x1 - r.x0) * k, r.y0 + (r.y1 - r.y0) * k, FLIGHT_ALT * (1 - k));
if (at) out.push(at);
}
for (const m of this.mavs) {
const at = lift(m.x, m.y, m.z);
if (at && m.age > 0.15) out.push(at);
}
return out;
}
}
