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 { BombKind } from './airstrike.js';
/** Everything the A-10's head-up display shows, in screen pixels where it's a position. */
export interface FlightHud {
speedKmh: number;
heading: number;
g: number;
lever: number;
spool: number;
jet: { x: number; y: number } | null;
bomb: { at: { x: number; y: number } | null; t: number } | null;
gun: { x: number; y: number } | null;
mode: BombKind;
rounds: number;
mk82: number;
gbu31: number;
mavericks: number;
rearming: number;
firing: boolean;
/** A missile is tracking the jet; flares left. */
missile: boolean;
flares: number;
time: number;
}
/** The city's state, for the panel in the corner. */
export interface CityStatus {
alert: string;
level: number;
power: number;
water: number;
comms: [number, number];
hospitals: [number, number];
engines: [number, number];
fires: number;
evacuated: number;
dead: number;
trapped: number;
rescued: number;
}
const GREEN = '#8dff9e';
const AMBER = '#ffc44d';
const RED = '#ff4d5e';
const INK = 'rgba(4, 20, 8, 0.85)';
/**
* The A-10's head-up display, drawn over the world in screen space: airspeed and g, a
* heading tape, the throttle, the stores, the bomb pipper (where a bomb released now
* would land, with its fall line) and the gun cross (where the stream is striking).
* HUD green with a dark outline so it reads over any ground; on a small frame it keeps
* just the numbers.
*/
export function drawFlightHud(ctx: CanvasRenderingContext2D, hud: FlightHud, w: number, h: number, dpr: number, top: number, bottom: number): void {
const min = Math.min(w, h);
const fs = Math.round(Math.max(10 * dpr, Math.min(15 * dpr, min * 0.026)));
const compact = min / dpr < 330 || h - top - bottom < fs * 16;
const mono = `${fs}px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`;
const bold = `700 ${fs}px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.lineJoin = 'round';
ctx.textBaseline = 'middle';
const lw = Math.max(1, dpr * 1.2);
const text = (s: string, x: number, y: number, color = GREEN, align: CanvasTextAlign = 'left', font = mono) => {
ctx.font = font;
ctx.textAlign = align;
ctx.lineWidth = lw * 3;
ctx.strokeStyle = INK;
ctx.strokeText(s, x, y);
ctx.fillStyle = color;
ctx.fillText(s, x, y);
};
const stroke = (color: string, path: () => void, width = lw) => {
ctx.beginPath();
path();
ctx.lineWidth = width + lw * 2;
ctx.strokeStyle = INK;
ctx.stroke();
ctx.lineWidth = width;
ctx.strokeStyle = color;
ctx.stroke();
};
const onScreen = (p: { x: number; y: number }) => p.x > 0 && p.y > top && p.x < w && p.y < h - bottom;
// Bomb pipper and its fall line from the jet.
if (hud.bomb?.at && onScreen(hud.bomb.at)) {
const p = hud.bomb.at;
const r = fs * 1.1;
if (hud.jet) {
ctx.setLineDash([fs * 0.5, fs * 0.4]);
stroke('rgba(141, 255, 158, 0.75)', () => {
ctx.moveTo(hud.jet!.x, hud.jet!.y);
ctx.lineTo(p.x, p.y);
});
ctx.setLineDash([]);
}
stroke(GREEN, () => ctx.arc(p.x, p.y, r, 0, Math.PI * 2));
ctx.fillStyle = GREEN;
ctx.beginPath();
ctx.arc(p.x, p.y, lw * 1.6, 0, Math.PI * 2);
ctx.fill();
if (!compact) text(hud.mode === 'gbu31' ? 'GBU-31' : hud.mode === 'mk82hd' ? 'MK-82 HD' : 'MK-82 LD', p.x + r + fs * 0.4, p.y, GREEN);
}
// Gun cross where the stream strikes.
if (hud.gun && onScreen(hud.gun)) {
const p = hud.gun;
const r = fs * 0.9;
const color = hud.firing ? AMBER : GREEN;
stroke(color, () => {
ctx.moveTo(p.x - r, p.y);
ctx.lineTo(p.x - r * 0.35, p.y);
ctx.moveTo(p.x + r * 0.35, p.y);
ctx.lineTo(p.x + r, p.y);
ctx.moveTo(p.x, p.y - r);
ctx.lineTo(p.x, p.y - r * 0.35);
ctx.moveTo(p.x, p.y + r * 0.35);
ctx.lineTo(p.x, p.y + r);
});
}
const midY = top + (h - top - bottom) / 2;
if (!compact) {
// Airspeed box, g and Mach on the left.
const lx = Math.max(fs * 3.2, w * 0.07);
const bw = fs * 4.4;
ctx.fillStyle = INK;
ctx.fillRect(lx - bw / 2, midY - fs * 0.8, bw, fs * 1.6);
ctx.strokeStyle = GREEN;
ctx.lineWidth = lw;
ctx.strokeRect(lx - bw / 2, midY - fs * 0.8, bw, fs * 1.6);
text(String(Math.round(hud.speedKmh)), lx, midY, GREEN, 'center', bold);
text('KM/H', lx, midY - fs * 1.6, GREEN, 'center');
text(`G ${hud.g.toFixed(1)}`, lx, midY + fs * 1.7, hud.g > 3 ? AMBER : GREEN, 'center');
text(`M ${(hud.speedKmh / 1225).toFixed(2)}`, lx, midY + fs * 2.9, GREEN, 'center');
// Heading tape across the top.
const hy = top + fs * 1.6;
const hw = Math.min(w * 0.5, fs * 26);
const hdg = ((((hud.heading * 180) / Math.PI + 90) % 360) + 360) % 360;
ctx.save();
ctx.beginPath();
ctx.rect(w / 2 - hw / 2, hy - fs, hw, fs * 2.4);
ctx.clip();
const per = hw / 60;
for (let d = Math.floor((hdg - 32) / 5) * 5; d <= hdg + 32; d += 5) {
const x = w / 2 + (d - hdg) * per;
const n = ((d % 360) + 360) % 360;
const major = n % 10 === 0;
stroke(GREEN, () => {
ctx.moveTo(x, hy + fs * 0.9);
ctx.lineTo(x, hy + fs * (major ? 0.3 : 0.6));
});
if (n % 30 === 0) text(({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as Record<number, string>)[n] ?? String(n / 10).padStart(2, '0'), x, hy - fs * 0.2, GREEN, 'center');
}
ctx.restore();
stroke(GREEN, () => {
ctx.moveTo(w / 2, hy + fs * 1.4);
ctx.lineTo(w / 2 - fs * 0.4, hy + fs * 1.9);
ctx.lineTo(w / 2 + fs * 0.4, hy + fs * 1.9);
ctx.closePath();
});
} else {
text(`${Math.round(hud.speedKmh)} KM/H`, fs * 0.6, top + fs * 1.2, GREEN);
}
// Throttle: the lever (bar marker) and what the engines are giving (fill).
const by = h - bottom - fs * 1.2;
if (!compact) {
const tx = fs * 0.8;
const th = fs * 5;
const ty = by - th;
ctx.fillStyle = INK;
ctx.fillRect(tx, ty, fs * 0.9, th);
ctx.fillStyle = GREEN;
ctx.fillRect(tx, ty + th * (1 - hud.spool), fs * 0.9, th * hud.spool);
stroke(GREEN, () => ctx.rect(tx, ty, fs * 0.9, th));
stroke(AMBER, () => {
const ly = ty + th * (1 - hud.lever);
ctx.moveTo(tx - fs * 0.3, ly);
ctx.lineTo(tx + fs * 1.2, ly);
}, lw * 2);
const lev = hud.lever > 0.97 ? 'MAX' : hud.lever < 0.03 ? 'IDLE' : `${Math.round(hud.lever * 100)}%`;
text(`THR ${lev}`, tx + fs * 1.6, by - fs * 0.6, GREEN);
text('W/S · N/M', tx + fs * 1.6, by - fs * 1.8, 'rgba(141, 255, 158, 0.7)');
}
// Stores: the selected bomb mode marked.
const lines: [string, string][] = [
[`GUN ${String(hud.rounds).padStart(4, ' ')}`, hud.rounds ? (hud.firing ? AMBER : GREEN) : RED],
[`${hud.mode === 'gbu31' ? ' ' : '>'}MK-82 ×${hud.mk82} ${hud.mode === 'mk82ld' ? 'LD' : 'HD'}`, hud.mk82 ? GREEN : RED],
[`${hud.mode === 'gbu31' ? '>' : ' '}GBU-31 ×${hud.gbu31}`, hud.gbu31 ? GREEN : RED],
[` AGM-65 ×${hud.mavericks}`, hud.mavericks ? GREEN : RED],
];
lines.push([` FLARES ×${hud.flares} (F)`, hud.flares ? GREEN : RED]);
if (hud.rearming > 0) lines.push([`REARMING ${Math.ceil(hud.rearming)}s`, AMBER]);
if (compact) {
text(lines[0][0], fs * 0.6, by, lines[0][1]);
text(hud.mode === 'gbu31' ? `GBU ×${hud.gbu31}` : `MK82 ×${hud.mk82}`, w - fs * 0.6, by, GREEN, 'right');
} else {
lines.forEach(([s, color], i) => text(s, w - fs * 0.8, by - (lines.length - 1 - i) * fs * 1.25, color, 'right'));
}
// Missile warning: flashing, middle of the frame.
if (hud.missile && Math.floor(hud.time * 5) % 2 === 0) text('MISSILE · FLARES (F)', w / 2, top + (h - top - bottom) * 0.28, RED, 'center', `700 ${fs * 2}px ui-monospace, monospace`);
ctx.restore();
}
const fmt = (n: number) => (n >= 10000 ? `${Math.round(n / 1000)}K` : n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(Math.round(n)));
/**
* The city's vital signs in the top-left corner while you're flying: the alert level,
* power and water, comms, hospitals and fire engines still working, the fires burning,
* and the human toll.
*/
export function drawCityStatus(ctx: CanvasRenderingContext2D, c: CityStatus, w: number, h: number, dpr: number, top: number, bottom: number): void {
const min = Math.min(w, h);
if (min / dpr < 330 || h - top - bottom < 240 * dpr) return;
const fs = Math.round(Math.max(9 * dpr, Math.min(12 * dpr, min * 0.02)));
const rows: [string, string, string][] = [
['POWER', `${Math.round(c.power * 100)}%`, c.power < 0.5 ? RED : c.power < 0.9 ? AMBER : GREEN],
['WATER', `${Math.round(c.water * 100)}%`, c.water < 0.4 ? RED : c.water < 0.8 ? AMBER : GREEN],
['COMMS', `${c.comms[0]}/${c.comms[1]}`, c.comms[0] < c.comms[1] ? AMBER : GREEN],
['HOSPITALS', `${c.hospitals[0]}/${c.hospitals[1]}`, c.hospitals[0] < c.hospitals[1] ? AMBER : GREEN],
['ENGINES', `${c.engines[0]}/${c.engines[1]}`, c.engines[0] === 0 ? RED : GREEN],
['FIRES', String(c.fires), c.fires > 40 ? RED : c.fires > 10 ? AMBER : GREEN],
['EVACUATED', fmt(c.evacuated), GREEN],
['TRAPPED', fmt(c.trapped), c.trapped ? AMBER : GREEN],
['RESCUED', fmt(c.rescued), GREEN],
['KILLED', fmt(c.dead), c.dead ? RED : GREEN],
];
const x0 = Math.round(8 * dpr);
const y0 = Math.round(top + 8 * dpr);
const lh = fs * 1.35;
const bw = fs * 13;
const color = c.level >= 3 ? RED : c.level >= 1 ? AMBER : GREEN;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = 'rgba(8, 14, 10, 0.62)';
ctx.fillRect(x0, y0, bw, lh * (rows.length + 1.6));
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(1, dpr);
ctx.strokeRect(x0, y0, bw, lh * (rows.length + 1.6));
ctx.textBaseline = 'middle';
ctx.font = `700 ${fs}px ui-monospace, Menlo, Consolas, monospace`;
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.fillText(`CITY: ${c.alert}`, x0 + fs * 0.6, y0 + lh * 0.8);
ctx.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`;
rows.forEach(([k, v, col], i) => {
const y = y0 + lh * (i + 1.9);
ctx.fillStyle = 'rgba(200, 220, 205, 0.85)';
ctx.textAlign = 'left';
ctx.fillText(k, x0 + fs * 0.6, y);
ctx.fillStyle = col;
ctx.textAlign = 'right';
ctx.fillText(v, x0 + bw - fs * 0.6, y);
});
ctx.restore();
}
