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 { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { sfx } from './game/audio.js';
import { DISTRICT_NAMES, Game, type Tone } from './game/game.js';
import type { VehicleKind } from './game/truck.js';
import { Input, type InputAction } from './input.js';
import { onSettingsChange, settings, type GameSettings } from './napplet-settings.js';
import './styles.css';
// App-owned palette: the city, vehicles and highway-sign UI keep their colours in
// every host, so FOLLOW_HOST_THEME stays off and no theme handler is installed.
// See docs/napplet-visual-design.md and docs/brief.md.
const BEST_KEY = 'best-score';
const CAR_COUNTS = { light: 30, normal: 50, rush: 72 } as const;
const PED_COUNTS = { light: 30, normal: 45, rush: 60 } as const;
const ANIMAL_COUNTS = { light: 14, normal: 22, rush: 30 } as const;
function requireElement<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
const el = {
app: requireElement<HTMLElement>('#app'),
canvas: requireElement<HTMLCanvasElement>('#view'),
score: requireElement<HTMLElement>('#score'),
jobGauge: requireElement<HTMLElement>('#jobGauge'),
jobLabel: requireElement<HTMLElement>('#jobLabel'),
jobDistrict: requireElement<HTMLElement>('#jobDistrict'),
distance: requireElement<HTMLElement>('#distance'),
gear: requireElement<HTMLElement>('#gear'),
driveGauge: requireElement<HTMLElement>('.gauge-drive'),
tachMeter: requireElement<HTMLElement>('#tachMeter'),
tachBar: requireElement<HTMLElement>('#tachBar'),
speed: requireElement<HTMLElement>('#speed'),
clock: requireElement<HTMLElement>('#clock'),
clockIcon: requireElement<HTMLElement>('#clockIcon'),
clockGauge: requireElement<HTMLElement>('.gauge-clock'),
choice: requireElement<HTMLElement>('#choice'),
choiceTab: requireElement<HTMLElement>('#choiceTab'),
choiceTitle: requireElement<HTMLElement>('#choiceTitle'),
choiceBody: requireElement<HTMLElement>('#choiceBody'),
choiceButtons: requireElement<HTMLElement>('#choiceButtons'),
minimap: requireElement<HTMLCanvasElement>('#minimap'),
damageMeter: requireElement<HTMLElement>('#damageMeter'),
damageBar: requireElement<HTMLElement>('#damageBar'),
damageKey: requireElement<HTMLElement>('#damageKey'),
pauseButton: requireElement<HTMLButtonElement>('#pauseButton'),
favorButton: requireElement<HTMLButtonElement>('#favorButton'),
favors: requireElement<HTMLElement>('#favors'),
toast: requireElement<HTMLOutputElement>('#toast'),
overlay: requireElement<HTMLElement>('#overlay'),
overlayTab: requireElement<HTMLElement>('#overlayTab'),
overlayTitle: requireElement<HTMLElement>('#overlayTitle'),
overlayBody: requireElement<HTMLElement>('#overlayBody'),
overlayButton: requireElement<HTMLButtonElement>('#overlayButton'),
overlayHint: requireElement<HTMLElement>('#overlayHint'),
vehiclePick: requireElement<HTMLElement>('#vehiclePick'),
raidToggle: requireElement<HTMLButtonElement>('#raidToggle'),
};
const ctx = el.canvas.getContext('2d', { alpha: false })!;
const miniCtx = el.minimap.getContext('2d', { alpha: false })!;
const coarsePointer = window.matchMedia('(pointer: coarse)');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let touchSeen = false;
let bestScore = 0;
let newBest = false;
function gameOptions(value: GameSettings) {
return {
carCount: CAR_COUNTS[value.traffic],
pedCount: PED_COUNTS[value.traffic],
animalCount: ANIMAL_COUNTS[value.traffic],
rudeness: value.rudeness,
shake: value.screenShake && !reducedMotion.matches,
};
}
const game = new Game(gameOptions(settings), {
toast: showToast,
over: () => {
void recordScore();
showOverlay();
},
crash: (i, kind, pan) => {
// Lift mid-speed hits so speed bites harder; extreme crashes get extra layers.
sfx.impact(Math.min(1.75, Math.pow(i / 1.7, 0.8) * 1.75), kind, pan);
if (i > 1.1 && kind !== 'pileup') sfx.extremeHit(i, pan);
},
explosion: (size, pan) => sfx.explosion(size, pan),
rollHit: (energy, pan) => sfx.rollThud(energy, pan),
tipping: () => sfx.tipping(),
carFlavor: (model, i, pan) => sfx.carFlavor(model, i, pan),
animalHit: (_kind, mass, pan) => sfx.animalHit(mass, pan),
stallSmash: (pan) => sfx.stallSmash(pan),
partyBeat: (pan, volume) => sfx.partyBeat(pan, volume),
raveBeat: (pan, volume) => sfx.raveBeat(pan, volume),
siren: (kind, pan, volume) => sfx.siren(kind, pan, volume),
towWork: (pan) => sfx.towWinch(pan),
structureHit: (impact, pan) => sfx.structureHit(impact, pan),
hose: (pan) => sfx.hoseSpray(pan),
missile: (pan) => sfx.missile(pan),
bigBlast: (pan, size) => sfx.bigBlast(pan, size),
blastSound: (kind, pan, distance) => sfx.blast(kind, pan, distance),
fireLevel: (level) => sfx.fireLevel(level),
ballute: () => sfx.ballute(),
click: () => sfx.click(),
rearmed: () => sfx.rearmed(),
airRaid: () => sfx.airRaid(),
samLaunch: (pan) => sfx.samLaunch(pan),
missileWarning: (on) => sfx.missileLock(on),
aaGuns: (pan, distance) => sfx.aaGuns(pan, distance),
jetHit: () => sfx.jetHit(),
flares: () => sfx.flares(),
jetDown: () => sfx.bigBlast(0, 2.4),
flyby: (pan, volume) => sfx.flyby(pan, volume),
brrt: (pan, distance) => sfx.brrt(pan, distance),
missileHit: (pan) => sfx.missileHit(pan),
fire: (volume) => sfx.fireCrackle(volume),
bombDrop: (pan) => sfx.bombDrop(pan),
rotor: (pan, volume) => sfx.rotor(pan, volume),
tools: (pan) => sfx.tools(pan),
knock: (kind, pan) => sfx.knock(kind, pan),
dramaTier: (tier, pan) => sfx.dramaTier(tier, pan),
honk: (rude, pan) => sfx.honk(rude, pan),
pickup: () => sfx.loaded(),
delivered: () => sfx.delivered(),
totaled: () => sfx.totaled(),
shift: () => sfx.shift(),
grind: () => sfx.grind(),
pedHit: (pan) => sfx.pedHit(pan),
alarm: (pan) => sfx.alarmBeep(pan),
});
// --- Sound: Web Audio may only start from a user gesture, so any key or tap unlocks it.
const unlockAudio = () => sfx.unlock();
window.addEventListener('keydown', unlockAudio, { capture: true });
window.addEventListener('pointerdown', unlockAudio, { capture: true });
sfx.setVolume(settings.soundVolume / 100);
// --- Best score: saved through the optional storage domain, else session only.
async function withTimeout<T>(promise: Promise<T>, timeoutMs = 3000): Promise<T> {
let timer = 0;
const timeout = new Promise<T>((_, reject) => {
timer = window.setTimeout(() => reject(new Error('timed out')), timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
window.clearTimeout(timer);
}
}
async function loadBest(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
const saved = Number(await withTimeout(storage.getItem(BEST_KEY)));
if (Number.isFinite(saved) && saved > bestScore) {
bestScore = saved;
if (game.phase === 'title') showOverlay();
}
} catch {
// Keep the session-only best score.
}
}
async function recordScore(): Promise<void> {
newBest = game.score > bestScore;
if (!newBest) return;
bestScore = game.score;
showOverlay();
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
await withTimeout(storage.setItem(BEST_KEY, String(bestScore)));
} catch {
showToast('Best score kept for this session only', 'info');
}
}
// --- Overlay (title, pause, game over) styled as a highway sign.
function controlsHint(): string {
if (vehicle === 'a10') {
return showTouch()
? 'A-10: bank with ◀ ▶, throttle THR+ / THR−, GUN, BOMB, FIRE a Maverick, FLR flares, WPN picks the bomb, ARM re-arms'
: 'A-10: A/D bank · W/S or N/M throttle · G gun · B bomb (hold to ripple) · V bomb mode · Space Maverick · F flares · R re-arm · K mute · P pause';
}
return showTouch()
? 'Hold GAS / BRK (hold BRK when stopped to reverse), steer with ◀ ▶, shift with ▼ ▲'
: '1-4 pick ride · W gas · A/D steer · N/M shift (rig) · Space missiles · H horn · R tow · F mob hit · K mute · P pause';
}
/** The A-10 relabels the pedals: gas and brake become its throttle. */
function labelPads(kind: VehicleKind): void {
const plane = kind === 'a10';
for (const b of document.querySelectorAll<HTMLElement>('[data-label-a10]')) {
b.dataset.labelGround ??= b.textContent ?? '';
b.textContent = plane ? b.dataset.labelA10! : b.dataset.labelGround;
}
}
// --- Ride picker: the rig or the sports car.
let vehicle: VehicleKind = 'rig';
const picks = [...el.vehiclePick.querySelectorAll<HTMLButtonElement>('.pick')];
function pickVehicle(kind: VehicleKind): void {
vehicle = kind;
for (const b of picks) b.setAttribute('aria-checked', String(b.dataset.vehicle === kind));
sfx.setVoice(kind === 'sports' ? 'sports' : kind === 'a10' ? 'jet' : 'diesel');
labelPads(kind);
// The air raid is for the drivers: in the A-10 you are the raid.
el.raidToggle.hidden = kind === 'a10' || game.phase === 'paused';
if (game.phase !== 'playing') el.overlayHint.textContent = `${bestScore > 0 ? `Best ${bestScore} · ` : ''}${controlsHint()}`;
}
/** Switch the computer-flown A-10 raid on or off (for the rig, sports car and dozer). */
function toggleRaid(): void {
game.raidEnabled = !game.raidEnabled;
el.raidToggle.setAttribute('aria-pressed', String(game.raidEnabled));
el.raidToggle.querySelector('b')!.textContent = game.raidEnabled ? 'ON' : 'OFF';
}
el.raidToggle.addEventListener('click', toggleRaid);
for (const b of picks) b.addEventListener('click', () => pickVehicle(b.dataset.vehicle as VehicleKind));
el.vehiclePick.addEventListener('keydown', (event) => {
if (event.code !== 'ArrowLeft' && event.code !== 'ArrowRight') return;
event.preventDefault();
event.stopPropagation();
const order: VehicleKind[] = ['rig', 'sports', 'dozer', 'a10'];
pickVehicle(order[(order.indexOf(vehicle) + (event.code === 'ArrowRight' ? 1 : order.length - 1)) % order.length]);
picks.find((b) => b.dataset.vehicle === vehicle)?.focus();
});
window.addEventListener(
'keydown',
(event) => {
if (game.phase === 'playing' || game.phase === 'paused' || dialog) return;
const ride: VehicleKind | undefined = { Digit1: 'rig', Digit2: 'sports', Digit3: 'dozer', Digit4: 'a10' }[event.code] as VehicleKind | undefined;
if (ride) {
event.preventDefault();
pickVehicle(ride);
} else if (event.code === 'Digit5' && vehicle !== 'a10') {
event.preventDefault();
toggleRaid();
}
},
{ capture: true },
);
function showOverlay(): void {
const phase = game.phase;
el.overlay.hidden = phase === 'playing';
el.app.dataset.phase = phase;
if (phase === 'playing') return;
el.vehiclePick.hidden = phase === 'paused';
el.raidToggle.hidden = phase === 'paused' || vehicle === 'a10';
if (phase === 'title') {
el.overlayTab.textContent = 'EXIT 1';
el.overlayTitle.textContent = 'BIG RIG RUSH';
el.overlayBody.textContent =
'Pick your ride: the manual 8-speed rig, a light automatic sports car, a bulldozer that pushes through anything, or an A-10 Warthog with missiles, bombs and a 30 mm cannon. Load up downtown and haul it across the city and the countryside, day and night. The city runs itself: police close the road while fire, ambulance, tow, sweeper, garbage and repair crews clean up every mess. You are the mess. Deliver to the family in the hood (purple) and they owe you a favor: F calls a hit.';
el.overlayButton.textContent = 'START SHIFT';
} else if (phase === 'paused') {
el.overlayTab.textContent = 'REST AREA';
el.overlayTitle.textContent = 'PAUSED';
el.overlayBody.textContent = `Score ${game.score} · ${game.deliveries} delivered`;
el.overlayButton.textContent = 'BACK ON THE ROAD';
} else {
el.overlayTab.textContent = 'END OF SHIFT';
el.overlayTitle.textContent = game.overReason;
el.overlayBody.textContent = `${game.deliveries} delivered · score ${game.score}${newBest ? ' · NEW BEST!' : ''}`;
el.overlayButton.textContent = 'DRIVE AGAIN';
}
const best = bestScore > 0 ? `Best ${bestScore} · ` : '';
el.overlayHint.textContent = `${best}${controlsHint()}`;
el.overlayButton.focus({ preventScroll: true });
}
function startOrResume(): void {
sfx.unlock();
if (game.phase === 'paused') {
game.phase = 'playing';
} else {
newBest = false;
game.start(vehicle);
el.app.dataset.vehicle = vehicle;
// The A-10's extra touch pads change how much of the frame is free for the HUD.
resize();
sfx.airHorn();
}
input.reset();
showOverlay();
el.app.focus({ preventScroll: true });
}
function pause(): void {
if (game.phase !== 'playing') return;
game.phase = 'paused';
input.reset();
showOverlay();
}
// --- Choice dialog (tow service). The game holds still while one is open.
interface Option {
label: string;
detail: string;
run: () => void;
}
let dialog: Option[] | null = null;
function openDialog(tab: string, title: string, body: string, options: Option[]): void {
dialog = options;
input.reset();
el.choiceTab.textContent = tab;
el.choiceTitle.textContent = title;
el.choiceBody.textContent = body;
el.choiceButtons.replaceChildren(
...options.map((o, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'plate';
b.textContent = `${i + 1} ${o.label}`;
const d = document.createElement('span');
d.textContent = o.detail;
b.append(d);
b.addEventListener('click', () => choose(i));
return b;
}),
);
el.choice.hidden = false;
(el.choiceButtons.firstElementChild as HTMLButtonElement | null)?.focus({ preventScroll: true });
}
function closeDialog(): void {
dialog = null;
el.choice.hidden = true;
el.app.focus({ preventScroll: true });
}
function choose(i: number): void {
const option = dialog?.[i];
closeDialog();
option?.run();
}
window.addEventListener(
'keydown',
(event) => {
if (!dialog) return;
const n = Number(event.key);
if (n >= 1 && n <= dialog.length) {
event.preventDefault();
event.stopImmediatePropagation();
choose(n - 1);
} else if (event.code === 'Escape' || event.code === 'KeyR' || event.code === 'KeyF') {
event.preventDefault();
event.stopImmediatePropagation();
closeDialog();
}
},
{ capture: true },
);
function openTow(): void {
if (game.phase !== 'playing') return;
if (game.truck.plane) {
game.rearm();
return;
}
if (game.towBusy()) {
showToast('The tow truck is already on its way', 'info');
return;
}
const q = game.towQuote();
openDialog('YOUR TOW TRUCK', 'NEED A TOW?', 'Your own tow truck is never far away. Pick a service:', [
{ label: 'REPAIR THE RIG', detail: `$${q.repair}`, run: () => game.requestTow('repair') },
{ label: 'BACK ON THE ROAD', detail: `$${q.reposition}`, run: () => game.requestTow('reposition') },
{ label: 'BOTH', detail: `$${q.both}`, run: () => game.requestTow('both') },
]);
}
function openHit(): void {
if (game.phase !== 'playing') return;
if (game.favors() <= 0) {
showToast('The family owes you nothing. Deliver cargo to their club (purple) for a favor', 'info');
return;
}
const { car, building } = game.hitTargets();
const options: Option[] = [];
if (car) options.push({ label: 'HIT THE CAR AHEAD', detail: 'black sedan', run: () => game.orderHit('car') });
if (building) options.push({ label: 'BLOW UP THE BUILDING', detail: 'nearest one', run: () => game.orderHit('building') });
if (!options.length) {
showToast('Nothing nearby for the family to take care of', 'info');
return;
}
openDialog('THE FAMILY', 'CALL IN A FAVOR?', `You have ${game.favors()} favor${game.favors() > 1 ? 's' : ''}. One hit costs one.`, options);
}
el.favorButton.addEventListener('click', openHit);
function handleAction(action: InputAction): void {
if (action === 'bomb') {
game.dropBomb();
} else if (action === 'fire') {
// Space fires missiles while driving the sports car, and starts or resumes otherwise.
if (game.phase === 'playing') game.fireMissile();
else startOrResume();
} else if (action === 'weapon') {
game.cycleWeapon();
} else if (action === 'hit') {
// In the A-10, F pops flares.
if (game.truck.plane) game.popFlares();
else openHit();
} else if (action === 'tow') {
openTow();
} else if (action === 'horn') {
if (game.phase === 'playing') {
sfx.airHorn();
game.horn();
}
} else if (action === 'shiftUp') {
game.shiftUp();
} else if (action === 'shiftDown') {
game.shiftDown();
} else if (action === 'mute') {
showToast(sfx.toggleMute() ? 'Sound off (K)' : 'Sound on (K)', 'info');
} else if (action === 'pause') {
if (game.phase === 'playing') pause();
else if (game.phase === 'paused') startOrResume();
} else if (game.phase !== 'playing') {
startOrResume();
}
}
// --- Toasts.
let toastTimer = 0;
function showToast(text: string, tone: Tone): void {
el.toast.textContent = text;
el.toast.dataset.tone = tone;
el.toast.classList.add('show');
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => el.toast.classList.remove('show'), 1800);
}
// --- Input and touch controls.
const input = new Input(handleAction, () => {
if (!touchSeen) {
touchSeen = true;
updateTouchControls();
}
});
input.attachButtons(document.querySelectorAll<HTMLElement>('.pad[data-control]'));
input.attachActionButtons(document.querySelectorAll<HTMLElement>('[data-action]'));
function showTouch(): boolean {
if (settings.touchControls === 'always') return true;
if (settings.touchControls === 'never') return false;
return coarsePointer.matches || touchSeen;
}
function updateTouchControls(): void {
el.app.classList.toggle('show-touch', showTouch());
resize();
if (game.phase !== 'playing') el.overlayHint.textContent = `${bestScore > 0 ? `Best ${bestScore} · ` : ''}${controlsHint()}`;
}
el.overlayButton.addEventListener('click', startOrResume);
el.pauseButton.addEventListener('click', () => {
if (game.phase === 'playing') pause();
else if (game.phase === 'paused') startOrResume();
});
el.app.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'touch' && !touchSeen) {
touchSeen = true;
updateTouchControls();
}
});
window.addEventListener('blur', () => {
input.reset();
pause();
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
input.reset();
pause();
sfx.suspend();
}
});
coarsePointer.addEventListener('change', updateTouchControls);
reducedMotion.addEventListener('change', () => (game.options = gameOptions(settings)));
onSettingsChange((value) => {
game.options = gameOptions(value);
input.reset();
sfx.setVolume(value.soundVolume / 100);
updateTouchControls();
});
// --- Sizing: the canvas matches device pixels so lines and text stay smooth.
const MAX_DPR = 2;
let minimapVisible = false;
function resize(): void {
const rect = el.app.getBoundingClientRect();
const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (el.canvas.width !== w || el.canvas.height !== h) {
el.canvas.width = w;
el.canvas.height = h;
}
// Keep the off-screen target arrow clear of the HUD and touch pedals.
const hud = requireElement<HTMLElement>('.hud').getBoundingClientRect();
const pad = requireElement<HTMLElement>('.touch-pad').getBoundingClientRect();
game.setViewport(w, h, dpr, (hud.bottom - rect.top) * dpr, pad.height ? (rect.bottom - pad.top) * dpr : 0);
const mini = el.minimap.getBoundingClientRect();
minimapVisible = mini.width > 0;
if (minimapVisible) {
const mw = Math.round(mini.width * dpr);
const mh = Math.round(mini.height * dpr);
if (el.minimap.width !== mw || el.minimap.height !== mh) {
el.minimap.width = mw;
el.minimap.height = mh;
}
}
}
new ResizeObserver(resize).observe(el.app);
resize();
// --- HUD.
const hudCache = { score: '', label: '', distance: '', damage: -1, kind: '', gear: '', rpm: -1, speed: '', clock: '', night: '', favors: -1, heatMode: false };
function updateHud(): void {
const favors = game.phase === 'title' ? 0 : game.favors();
if (favors !== hudCache.favors) {
hudCache.favors = favors;
el.favorButton.hidden = favors <= 0;
el.favors.textContent = String(favors);
}
const clock = game.clockText();
if (clock !== hudCache.clock) el.clock.textContent = hudCache.clock = clock;
const night = game.daylight() < 0.4 ? 'true' : 'false';
if (night !== hudCache.night) {
hudCache.night = night;
el.clockGauge.dataset.night = night;
el.clockIcon.textContent = night === 'true' ? '☾' : '☀';
}
const score = String(game.score);
if (score !== hudCache.score) el.score.textContent = hudCache.score = score;
// The A-10 flies sorties: the job gauge counts what's been destroyed.
const plane = game.truck.plane && game.phase !== 'title';
const kind = plane ? 'deliver' : game.job.kind;
const district = plane ? 'SORTIE' : DISTRICT_NAMES[game.job.spot.district];
const label = `${kind}:${district}:${plane}`;
if (label !== hudCache.label) {
hudCache.label = label;
el.jobGauge.dataset.kind = kind;
el.jobLabel.textContent = plane ? 'CITY IN RUINS' : kind === 'pickup' ? 'PICK UP' : 'DELIVER';
el.jobDistrict.textContent = plane ? '' : ` · ${district}`;
}
const distance = plane ? `${game.ruins().toFixed(game.ruins() < 10 ? 1 : 0)}%` : `${game.targetDistance()} m`;
if (distance !== hudCache.distance) el.distance.textContent = hudCache.distance = distance;
const gear = game.truck.gearName;
if (gear !== hudCache.gear) {
hudCache.gear = gear;
el.gear.textContent = gear;
el.driveGauge.dataset.gear = gear;
}
const rpm = Math.round(game.rpmFraction() * 50);
if (rpm !== hudCache.rpm) {
hudCache.rpm = rpm;
el.tachBar.style.width = `${Math.min(100, rpm * 2)}%`;
el.tachMeter.setAttribute('aria-valuenow', String(Math.round(game.truck.rpm)));
el.tachMeter.dataset.zone = rpm >= 48 ? 'red' : rpm >= 40 ? 'high' : 'low';
}
const speed = String(game.speedKmh());
if (speed !== hudCache.speed) el.speed.textContent = hudCache.speed = speed;
const damage = Math.round(game.truck.damage);
if (damage !== hudCache.damage || plane !== hudCache.heatMode) {
hudCache.damage = damage;
hudCache.heatMode = plane;
el.damageKey.textContent = 'DMG';
el.damageBar.style.width = `${damage}%`;
el.damageMeter.setAttribute('aria-label', plane ? 'Airframe damage' : 'Truck damage');
el.damageMeter.setAttribute('aria-valuenow', String(damage));
el.damageMeter.dataset.level = plane && damage >= 100 ? 'locked' : damage >= 70 ? 'high' : damage >= 40 ? 'mid' : 'low';
}
}
// --- Main loop.
let last = performance.now();
function frame(now: number): void {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (!dialog) game.update(dt, input.controls);
const truck = game.truck;
if (truck.plane) sfx.setFlight(truck.bank, Math.abs(truck.speed) / truck.maxSpeed);
const gun = game.gunState();
sfx.gunUpdate(gun.firing && !dialog && game.phase === 'playing', gun.rate);
if (dialog || game.phase !== 'playing') sfx.fireLevel(0);
sfx.driveUpdate(truck.speed / truck.maxSpeed, truck.throttle, truck.braking, game.phase === 'playing' && truck.damage < 100, game.rpmFraction());
game.render(ctx);
if (minimapVisible) {
miniCtx.setTransform(1, 0, 0, 1, 0, 0);
game.drawMinimap(miniCtx, el.minimap.width, el.minimap.height);
}
updateHud();
requestAnimationFrame(frame);
}
updateTouchControls();
showOverlay();
void loadBest();
requestAnimationFrame(frame);
