SOURCE / PINNED RELEASE
Made of little things.
Poly Dogfight
- Release
- b8511ee374e3…
- Author-recorded commit
- 5579434b9d8b…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b
Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
beginnerControls, planetBeginnerControls, createCrashHistory, recordBeginnerCrash, dismissBeginnerOffer,
type CrashHistory,
} from './sim/beginner.ts';
import { castRay, findAim, gunMuzzle, type AimSolution } from './sim/aim.ts';
import { STORAGE_DOMAIN } from '@napplet/sdk';
import {
AmbientLight,
Color,
DirectionalLight,
Fog,
HemisphereLight,
PerspectiveCamera,
Scene,
Vector3,
WebGLRenderer,
} from 'three';
import { createAudio } from './audio.ts';
import { CAMERA_MODES, createFlightCamera, type CameraMode } from './camera.ts';
import { runtimeHasDomain } from './domain-availability.js';
import { createInput, mergeControls } from './input.ts';
import { onHostSettings } from './napplet-settings.ts';
import { defaultSettings, loadSettings, saveSettings, type Settings } from './settings.ts';
import { GUNS, PLANET_RADIUS, SIM_HZ } from './sim/constants.ts';
import { LIVERIES, MAX_PLAYERS, planRoster, screenIndicator, splitLayout, steerToward, type ViewRect } from './players.ts';
import {
collidePlanes,
createFlightState,
neutralInput,
spawnAt,
stepFlight,
type ControlInput,
type FlightState,
type World,
} from './sim/flight.ts';
import { mulberry32 } from './sim/noise.ts';
import { createGuns, stepGuns } from './sim/guns.ts';
import { createRockets, rocketsChasing, seekerTarget, stepRockets } from './sim/rockets.ts';
import { generateObstacles } from './sim/obstacles.ts';
import { RIFT, surfaceHeight, terrainHeight } from './sim/terrain.ts';
import { createEffects } from './world/effects.ts';
import { createObstacleMeshes } from './world/obstacle-meshes.ts';
import { PALETTE } from './world/palette.ts';
import { createHorizonCuller } from './world/chunks.ts';
import { createPlanet } from './world/planet.ts';
import { createPlaneModel, type PlaneModel } from './world/plane-model.ts';
import { createSky } from './world/sky.ts';
import { createPerfOverlay } from './perf.ts';
import { createRocketMeshes } from './world/rockets.ts';
import { createTracers } from './world/tracers.ts';
import './styles.css';
// Poly Dogfight keeps its own art direction: the host theme is not applied.
// See docs/napplet-visual-design.md; the scene and HUD palettes are app-owned.
function $<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
const ui = {
app: $<HTMLElement>('#app'),
canvas: $<HTMLCanvasElement>('#scene'),
views: $<HTMLElement>('#views'),
spare: $<HTMLElement>('#spareView'),
viewTemplate: $<HTMLTemplateElement>('#viewTemplate'),
pilotsNote: $<HTMLElement>('#pilotsNote'),
menu: $<HTMLElement>('#menu'),
flightModes: $<HTMLElement>('#flightModes'),
menuTitle: $<HTMLElement>('#menuTitle'),
flyButton: $<HTMLButtonElement>('#flyButton'),
startHint: $<HTMLElement>('#startHint'),
invert: $<HTMLInputElement>('#invertToggle'),
mute: $<HTMLInputElement>('#muteToggle'),
saveNote: $<HTMLElement>('#saveNote'),
pauseButton: $<HTMLButtonElement>('#pauseButton'),
cameraButton: $<HTMLButtonElement>('#cameraButton'),
touch: $<HTMLElement>('#touchControls'),
throttleKnob: $<HTMLElement>('#throttleKnob'),
error: $<HTMLElement>('#errorNotice'),
};
// ---------------------------------------------------------------- Renderer
let renderer: WebGLRenderer;
try {
renderer = new WebGLRenderer({
canvas: ui.canvas,
antialias: window.devicePixelRatio < 2,
powerPreference: 'high-performance',
});
} catch (error) {
ui.error.hidden = false;
ui.menu.hidden = true;
throw error;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
const perf = createPerfOverlay(renderer, ui.app);
const scene = new Scene();
scene.background = new Color(PALETTE.skyHorizon);
scene.fog = new Fog(PALETTE.fog, 380, 1900);
const sun = new DirectionalLight(PALETTE.sunLight, 2.1);
const hemi = new HemisphereLight(PALETTE.hemiSky, PALETTE.hemiGround, 1.1);
scene.add(sun, sun.target, hemi, new AmbientLight(0xffffff, 0.25));
const sky = createSky();
scene.add(sky.mesh);
const horizon = createHorizonCuller();
const field = generateObstacles();
const obstacleMeshes = createObstacleMeshes(field, horizon);
const planet = createPlanet(horizon, obstacleMeshes.solidChunks);
scene.add(planet.land, planet.sea);
scene.add(obstacleMeshes.group);
const effects = createEffects();
scene.add(effects.group);
const guns = createGuns();
const tracers = createTracers();
scene.add(tracers.mesh);
const rockets = createRockets();
const rocketMeshes = createRocketMeshes();
scene.add(rocketMeshes.group);
// ---------------------------------------------------------------- Simulation
const world: World = {
surfaceHeight,
terrainHeight,
hitsObstacle: (p, r) => field.hits(p, r),
// The mesh samples the terrain every ~9 m; leave room for peaks between samples.
maxSurfaceHeight: Math.max(0, planet.maxHeight) + 12,
};
const random = mulberry32(Date.now() >>> 0);
type ViewUi = {
root: HTMLElement;
pilot: HTMLElement;
pilotName: HTMLElement;
speed: HTMLElement;
alt: HTMLElement;
throttle: HTMLElement;
boost: HTMLElement;
stall: HTMLElement;
crash: HTMLElement;
crashTitle: HTMLElement;
respawn: HTMLElement;
beginner: HTMLElement;
offer: HTMLElement;
indicators: HTMLElement;
reticle: HTMLElement;
aimPoint: HTMLElement;
flightPath: HTMLElement;
hitMarker: HTMLElement;
rocketMeter: HTMLElement;
rocketPips: HTMLElement[];
incoming: HTMLElement;
lockBox: HTMLElement;
/** Edge and on-screen markers: other pilots by seat, incoming rockets from ROCKET_KEY. */
markers: Map<number, PilotIndicator>;
};
/**
* How far ahead the outside-view sight and flight-path marker reach. Short
* enough that the chase camera keeps both on screen through hard turns.
*/
const SIGHT_RANGE = 150;
/** Marker keys for incoming rockets, after the four pilot seats. */
const ROCKET_KEY = 100;
type PilotIndicator = { root: HTMLElement; label: HTMLElement; distance: HTMLElement; text: string };
/** One plane, its controller, camera and split-screen view. */
type Pilot = {
aim: AimSolution | null;
aimUpdated: number;
/** Ray-cast reach of the gun line and the flight path, refreshed with the aim. */
sightDistance: number;
pathDistance: number;
hitUntil: number;
armoredHit: boolean;
/** The plane this pilot's rocket seeker sees now, for the lock box. */
lock: FlightState | null;
lockUpdated: number;
beginner: boolean;
pendingBeginner: boolean;
history: CrashHistory;
/** Livery and label index, kept for as long as this pilot stays in. */
seat: number;
pad: number | null;
state: FlightState;
model: PlaneModel;
view: ReturnType<typeof createFlightCamera>;
/** Pilot 1's camera mode lives in the saved settings. */
cameraMode: CameraMode;
crashPoint: Vector3 | null;
/** Boost stays off after resuming until the button is released. */
boostGate: boolean;
rect: ViewRect;
ui: ViewUi;
};
const pilots: Pilot[] = [];
function createViewUi(seat: number): ViewUi {
const fragment = ui.viewTemplate.content.cloneNode(true) as DocumentFragment;
const root = fragment.querySelector<HTMLElement>('.view')!;
const ref = (name: string) => root.querySelector<HTMLElement>(`[data-ref="${name}"]`)!;
root.style.setProperty('--pilot', LIVERIES[seat]!.css);
root.setAttribute('aria-label', `Pilot ${seat + 1}`);
ui.views.append(root);
const view: ViewUi = {
root,
pilot: ref('pilot'),
pilotName: ref('pilotName'),
speed: ref('speed'),
alt: ref('alt'),
throttle: ref('throttle'),
boost: ref('boost'),
stall: ref('stall'),
crash: ref('crash'),
crashTitle: ref('crashTitle'),
respawn: ref('respawn'),
beginner: ref('beginner'),
offer: ref('offer'),
indicators: ref('indicators'),
reticle: ref('reticle'),
aimPoint: ref('aimPoint'),
flightPath: ref('flightPath'),
hitMarker: ref('hitMarker'),
rocketMeter: ref('rocketMeter'),
rocketPips: Array.from(root.querySelectorAll<HTMLElement>('[data-ref="rocketPip"]')),
incoming: ref('incoming'),
lockBox: ref('lockBox'),
markers: new Map(),
};
view.pilotName.textContent = `P${seat + 1}`;
return view;
}
function createPilot(seat: number, pad: number | null): Pilot {
const livery = LIVERIES[seat]!;
const model = createPlaneModel(livery);
scene.add(model.group, model.marker);
const viewUi = createViewUi(seat);
viewUi.root.querySelector('[data-ref="accept"]')!.addEventListener('click', () => answerOffer(pilots.find((p) => p.ui === viewUi)!, true));
viewUi.root.querySelector('[data-ref="decline"]')!.addEventListener('click', () => answerOffer(pilots.find((p) => p.ui === viewUi)!, false));
return {
aim: null,
aimUpdated: -Infinity,
sightDistance: SIGHT_RANGE,
pathDistance: SIGHT_RANGE,
hitUntil: 0,
armoredHit: false,
lock: null,
lockUpdated: -Infinity,
beginner: pilots[0]?.beginner ?? false,
pendingBeginner: false,
history: createCrashHistory(),
seat,
pad,
state: createFlightState(),
model,
view: createFlightCamera(1),
cameraMode: 'chase',
crashPoint: null,
boostGate: true,
rect: { x: 0, y: 0, w: 1, h: 1 },
ui: viewUi,
};
}
function removePilot(pilot: Pilot): void {
scene.remove(pilot.model.group, pilot.model.marker);
pilot.ui.root.remove();
}
const forwardOf = (state: FlightState) => new Vector3(0, 0, -1).applyQuaternion(state.orientation);
/** True when a fresh spawn has room: no obstacle nearby or ahead, no other plane close. */
function spawnIsClear(pilot: Pilot): boolean {
const { position } = pilot.state;
const ahead = position.clone().addScaledVector(forwardOf(pilot.state), 40);
if (field.hits(position, 12) || field.hits(ahead, 12)) return false;
return pilots.every((other) => other === pilot || !other.state.alive || other.state.position.distanceTo(position) > 25);
}
/** Opening shot: above the Great Rift canyon, heading along it. */
function spawnAtRift(pilot: Pilot): void {
const along = RIFT.arcStart + 0.02;
const e1 = new Vector3(...RIFT.e1);
const e2 = new Vector3(...RIFT.e2);
const dir = e1.clone().multiplyScalar(Math.cos(along)).addScaledVector(e2, Math.sin(along));
const heading = e1.clone().multiplyScalar(-Math.sin(along)).addScaledVector(e2, Math.cos(along));
spawnAt(pilot.state, world, dir, heading);
}
function spawnRandom(pilot: Pilot): void {
for (let attempt = 0; attempt < 40; attempt++) {
const u = random() * 2 - 1;
const theta = random() * Math.PI * 2;
const r = Math.sqrt(1 - u * u);
const dir = new Vector3(r * Math.cos(theta), u, r * Math.sin(theta));
const heading = new Vector3(random() - 0.5, random() - 0.5, random() - 0.5).normalize();
if (Math.abs(heading.dot(dir)) > 0.95) continue;
spawnAt(pilot.state, world, dir, heading);
// Keep a clear bubble ahead of the spawn point and away from other pilots.
if (spawnIsClear(pilot) && pilots.every((o) => o === pilot || !o.state.alive || o.state.position.distanceTo(pilot.state.position) > 80))
return;
}
}
/** New pilots join in formation off pilot 1's wing so everyone starts together. */
const FORMATION = [0, 32, -32, 64];
function spawnBeside(pilot: Pilot, leader: Pilot): void {
if (!leader.state.alive) return spawnRandom(pilot);
const up = leader.state.position.clone().normalize();
const forward = forwardOf(leader.state).addScaledVector(up, 0);
forward.addScaledVector(up, -forward.dot(up)).normalize();
const right = new Vector3().crossVectors(forward, up).normalize();
const offset = FORMATION[pilot.seat] ?? 40;
const point = leader.state.position
.clone()
.addScaledVector(right, offset)
.addScaledVector(forward, -Math.abs(offset) * 0.4);
spawnAt(pilot.state, world, point.normalize(), forward);
if (!spawnIsClear(pilot)) spawnRandom(pilot);
}
pilots.push(createPilot(0, null));
spawnAtRift(pilots[0]!);
// ---------------------------------------------------------------- Settings and input
let settings: Settings = defaultSettings();
let preferencesChanged = false;
const audio = createAudio();
const input = createInput(
{
stick: $('#stick'),
stickKnob: $('#stickKnob'),
throttle: $('#throttleSlider'),
boost: $('#boostButton'),
yawLeft: $('#yawLeft'),
yawRight: $('#yawRight'),
fire: $('#fireButton'),
rocket: $('#rocketButton'),
},
);
function modeControls(): void {
const existing = Array.from(ui.flightModes.querySelectorAll<HTMLInputElement>('input'));
if (existing.length === pilots.length && existing.every((el, i) => el.dataset.seat === String(pilots[i]!.seat))) {
existing.forEach((el, i) => { el.checked = pilots[i]!.beginner; });
const beginner = pilots[0]!.beginner;
$('#flightHint').textContent = beginner
? 'Beginner: up / W climbs, down / S descends; left / right turns. Full-speed steering and boost stay available. Throttle is automatic; release the stick to level out.'
: 'Stick back / S pulls the nose up. Stalled? Push the nose down and add power.';
ui.touch.classList.toggle('beginner', beginner);
return;
}
ui.flightModes.replaceChildren();
for (const pilot of pilots) {
const label = document.createElement('label');
label.className = 'toggle';
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.dataset.seat = String(pilot.seat);
toggle.checked = pilot.beginner;
toggle.addEventListener('change', () => setBeginner(pilot, toggle.checked));
label.append(toggle, ` Beginner controls${pilots.length > 1 ? ` — P${pilot.seat + 1}` : ''}`);
ui.flightModes.append(label);
}
}
function setBeginner(pilot: Pilot, enabled: boolean): void {
preferencesChanged = true;
pilot.beginner = enabled;
pilot.pendingBeginner = false;
dismissBeginnerOffer(pilot.history, pilot.state.time);
if (pilot === pilots[0]) {
settings.beginner = enabled;
void persist();
}
modeControls();
}
function answerOffer(pilot: Pilot, accepted: boolean): void {
if (!pilot) return;
dismissBeginnerOffer(pilot.history, pilot.state.time);
if (accepted) {
if (pilot.state.alive) setBeginner(pilot, true);
else pilot.pendingBeginner = true;
pilot.boostGate = true;
}
}
function applySettings(): void {
ui.invert.checked = settings.invertPitch;
ui.mute.checked = settings.muted;
audio.setMuted(settings.muted);
modeControls();
}
async function persist(): Promise<void> {
const saved = await saveSettings(settings);
ui.saveNote.hidden = saved;
if (!saved) {
ui.saveNote.textContent = runtimeHasDomain(STORAGE_DOMAIN)
? 'Preferences could not be saved this time.'
: 'Preferences last for this session only.';
}
}
void loadSettings().then((loaded) => {
if (preferencesChanged) return;
settings = loaded;
pilots[0]!.beginner = loaded.beginner;
applySettings();
});
onHostSettings((values) => {
if (typeof values.invertPitch === 'boolean') settings.invertPitch = values.invertPitch;
if (typeof values.muted === 'boolean') settings.muted = values.muted;
applySettings();
});
applySettings();
ui.invert.addEventListener('change', () => {
preferencesChanged = true;
settings.invertPitch = ui.invert.checked;
void persist();
});
ui.mute.addEventListener('change', () => {
preferencesChanged = true;
settings.muted = ui.mute.checked;
audio.setMuted(settings.muted);
void persist();
});
// The menu's controls list follows whichever device was used last.
type ControlsDevice = 'keyboard' | 'gamepad';
let controlsDevice: ControlsDevice = 'keyboard';
let knownPads = 0;
function showControlsFor(device: ControlsDevice): void {
if (device === controlsDevice) return;
controlsDevice = device;
const panel = $<HTMLElement>('#controlsPanel');
panel.dataset.device = device;
for (const list of panel.querySelectorAll<HTMLElement>('[data-for]')) list.hidden = list.dataset.for !== device;
$('#controlsDevice').textContent = device === 'gamepad' ? 'Gamepad' : 'Keyboard';
}
window.addEventListener('keydown', () => showControlsFor('keyboard'), { capture: true });
const coarse = window.matchMedia('(pointer: coarse)');
let touchSeen = coarse.matches;
const updateTouch = () => ui.touch.classList.toggle('on', touchSeen && !paused);
window.addEventListener(
'pointerdown',
(event) => {
if (event.pointerType === 'touch' && !touchSeen) {
touchSeen = true;
updateTouch();
}
},
{ capture: true },
);
// ---------------------------------------------------------------- Game flow
let paused = true;
let started = false;
function setPaused(value: boolean): void {
paused = value;
ui.menu.hidden = !paused;
if (paused) {
input.clear();
ui.menuTitle.textContent = started ? 'Paused' : 'Ready for take-off';
ui.flyButton.textContent = started ? 'Resume' : 'Fly';
ui.flyButton.focus({ preventScroll: true });
audio.suspend();
} else {
started = true;
for (const pilot of pilots) pilot.boostGate = true;
audio.start();
ui.canvas.focus?.();
}
updateTouch();
}
ui.flyButton.addEventListener('click', () => setPaused(false));
ui.pauseButton.addEventListener('click', () => setPaused(!paused));
ui.cameraButton.addEventListener('click', () => cycleCamera(pilots[0]!));
const cameraModeOf = (pilot: Pilot): CameraMode => (pilot.seat === 0 ? settings.camera : pilot.cameraMode);
function cycleCamera(pilot: Pilot): void {
const next = CAMERA_MODES[(CAMERA_MODES.indexOf(cameraModeOf(pilot)) + 1) % CAMERA_MODES.length]!;
pilot.view.snap();
if (pilot.seat === 0) {
settings.camera = next;
void persist();
} else {
pilot.cameraMode = next;
}
}
const CRASH_TEXT = {
ground: 'Crashed!',
water: 'Splashdown!',
obstacle: 'Ouch — hit something!',
midair: 'Mid-air collision!',
shotdown: 'Shot down!',
} as const;
// ---------------------------------------------------------------- Split screen
let frameWidth = 1;
let frameHeight = 1;
let spareRect: ViewRect | null = null;
const overview = new PerspectiveCamera(40, 1, 50, 6000);
function placeView(element: HTMLElement, rect: ViewRect): void {
element.style.left = `${rect.x * 100}%`;
element.style.top = `${rect.y * 100}%`;
element.style.width = `${rect.w * 100}%`;
element.style.height = `${rect.h * 100}%`;
}
function layout(): void {
const { views, spare } = splitLayout(pilots.length, frameWidth, frameHeight);
pilots.forEach((pilot, i) => {
pilot.rect = views[i]!;
placeView(pilot.ui.root, pilot.rect);
pilot.ui.pilot.hidden = pilots.length < 2;
const camera = pilot.view.camera;
camera.aspect = (pilot.rect.w * frameWidth) / Math.max(1, pilot.rect.h * frameHeight);
camera.updateProjectionMatrix();
});
ui.views.classList.toggle('split', pilots.length > 1);
ui.app.classList.toggle('split', pilots.length > 1);
spareRect = spare;
ui.spare.hidden = !spare;
if (spare) {
placeView(ui.spare, spare);
overview.aspect = (spare.w * frameWidth) / Math.max(1, spare.h * frameHeight);
overview.updateProjectionMatrix();
}
ui.pilotsNote.textContent =
pilots.length >= MAX_PLAYERS
? `${pilots.length} pilots in split screen. All seats are full.`
: pilots.length > 1
? `${pilots.length} pilots in split screen. Another controller can join (up to ${MAX_PLAYERS}).`
: `Up to ${MAX_PLAYERS} pilots: each extra controller joins in split screen.`;
}
/** Add pilots for newly detected controllers and drop those whose controller left. */
function syncRoster(activePads: number[]): void {
const plan = planRoster(
pilots.map((pilot) => pilot.pad),
activePads,
);
const unchanged = plan.length === pilots.length && plan.every((slot, i) => slot.from === i && slot.pad === pilots[i]!.pad);
if (unchanged) return;
const kept = new Set(plan.map((slot) => slot.from));
pilots.forEach((pilot, i) => {
if (!kept.has(i)) removePilot(pilot);
});
const previous = pilots.slice();
pilots.length = 0;
for (const slot of plan) {
if (slot.from !== null) {
const pilot = previous[slot.from]!;
pilot.pad = slot.pad;
pilots.push(pilot);
continue;
}
const used = new Set(pilots.map((p) => p.seat).concat(previous.filter((_, i) => kept.has(i)).map((p) => p.seat)));
const seat = [0, 1, 2, 3].find((s) => !used.has(s)) ?? pilots.length;
const pilot = createPilot(seat, slot.pad);
pilots.push(pilot);
spawnBeside(pilot, pilots[0]!);
}
layout();
modeControls();
}
// ---------------------------------------------------------------- Frame loop
const sunAxis = new Vector3(0.6, 0.35, 0.72).normalize();
const sunDir = new Vector3();
const up = new Vector3();
const STEP = 1 / SIM_HZ;
let accumulator = 0;
let last = performance.now();
let lastHud = 0;
function resize(): void {
frameWidth = Math.max(1, ui.app.clientWidth);
frameHeight = Math.max(1, ui.app.clientHeight);
renderer.setSize(frameWidth, frameHeight, false);
layout();
}
new ResizeObserver(resize).observe(ui.app);
resize();
function updateHud(now: number): void {
if (now - lastHud < 66) return;
lastHud = now;
for (const { state, ui: view } of pilots) {
view.speed.textContent = String(Math.round(state.airspeed * 3.6));
view.alt.textContent = String(Math.max(0, Math.round(state.groundClearance)));
view.throttle.style.transform = `scaleX(${state.throttle.toFixed(3)})`;
view.boost.style.transform = `scaleX(${state.boost.toFixed(3)})`;
view.boost.classList.toggle('locked', state.boostLockout > 0);
view.rocketPips.forEach((pip, i) => {
const fill = i < state.rockets ? 1 : i === state.rockets ? state.rocketCharge : 0;
pip.style.transform = `scaleX(${fill.toFixed(3)})`;
pip.classList.toggle('loading', i >= state.rockets);
});
view.rocketPips[0]?.parentElement?.classList.toggle('cooling', state.rocketCooldown > 0);
view.rocketMeter.setAttribute('aria-label', `Rockets ready: ${state.rockets}`);
view.incoming.hidden = !state.alive || !rocketsChasing(rockets, state).length;
view.stall.hidden = !(state.alive && state.stalled && state.stallAmount > 0.15);
const pilot = pilots.find((p) => p.ui === view)!;
view.beginner.hidden = !pilot.beginner;
view.offer.hidden = !pilot.history.offered;
view.crash.hidden = state.alive || pilot.history.offered;
if (!state.alive) {
view.crashTitle.textContent = CRASH_TEXT[state.crashCause ?? 'ground'];
view.respawn.textContent = String(Math.max(1, Math.ceil(state.crashTimer)));
}
}
// Touch controls and the throttle slider belong to pilot 1.
const lead = pilots[0]!.state;
ui.throttleKnob.style.bottom = `calc(${(lead.throttle * 100).toFixed(1)}% - ${(lead.throttle * 22).toFixed(1)}px)`;
}
/** Point the sun and sky at a viewer so it's always a bright afternoon where they fly. */
function lightFor(viewer: Vector3, focus: Vector3): void {
up.copy(focus).normalize();
sunDir.copy(sunAxis).addScaledVector(up, -sunAxis.dot(up));
if (sunDir.lengthSq() < 1e-4) sunDir.set(1, 0, 0).addScaledVector(up, -up.x);
sunDir.normalize().multiplyScalar(0.55).addScaledVector(up, 0.8).normalize();
sun.position.copy(viewer).addScaledVector(sunDir, 100);
sun.target.position.copy(viewer);
hemi.position.copy(up);
sky.update(viewer, viewer.clone().normalize(), sunDir);
}
/** Show other pilots' beacons in this view, sized to stay readable at range. */
function placeMarkers(viewer: Vector3, self: Pilot | null, scale: number): void {
for (const pilot of pilots) {
const marker = pilot.model.marker;
const { state } = pilot;
marker.visible = pilots.length > 1 && pilot !== self && state.alive;
if (!marker.visible) continue;
marker.position.copy(state.position).addScaledVector(up.copy(state.position).normalize(), 4.5);
const distance = viewer.distanceTo(marker.position);
marker.visible = distance > 30;
marker.scale.setScalar(Math.min(14, Math.max(0.6, distance * scale)));
marker.scale.y *= 1.6;
}
}
const indicatorPoint = new Vector3();
function indicatorFor(view: ViewUi, key: number): PilotIndicator {
let marker = view.markers.get(key);
if (marker) return marker;
const rocket = key >= ROCKET_KEY;
const root = document.createElement('div');
root.className = rocket ? 'indicator rocket' : 'indicator';
root.style.setProperty('--pilot', rocket ? 'var(--danger)' : LIVERIES[key]!.css);
const arrow = document.createElement('span');
arrow.className = 'indicator-arrow';
const label = document.createElement('span');
label.className = 'indicator-label';
label.textContent = rocket ? 'ROCKET' : `P${key + 1}`;
const distance = document.createElement('small');
label.append(' ', distance);
root.append(arrow, label);
view.indicators.append(root);
marker = { root, label, distance, text: '' };
view.markers.set(key, marker);
return marker;
}
const formatDistance = (metres: number) =>
metres < 1000 ? `${Math.round(metres / 10) * 10} m` : `${(metres / 1000).toFixed(1)} km`;
/**
* Point each pilot toward the others: a tag under planes in view, an arrow
* pinned to the screen edge for those out of view. Arrows follow the curve of
* the planet so a pilot on the far side reads as "over the horizon that way".
*/
function placeIndicators(viewer: Pilot): void {
const camera = viewer.view.camera;
const width = viewer.rect.w * frameWidth;
const height = viewer.rect.h * frameHeight;
const tanY = Math.tan((camera.fov * Math.PI) / 360);
const margin = Math.min(16, width * 0.08, height * 0.08);
const shown = new Set<number>();
const labels: { x: number; y: number }[] = [];
// Rockets locked onto this pilot come first so their warnings keep their place.
const targets: { key: number; position: Vector3 }[] = [];
if (viewer.state.alive && !paused)
rocketsChasing(rockets, viewer.state).forEach((rocket, i) => targets.push({ key: ROCKET_KEY + i, position: rocket.position }));
for (const other of pilots)
if (other !== viewer && other.state.alive) targets.push({ key: other.seat, position: other.state.position });
for (const { key, position } of targets) {
const { aim, distance } = steerToward(camera.position, position);
// Close enough to see clearly: no tag cluttering the view (rockets always warn).
if (distance < 35 && key < ROCKET_KEY) continue;
indicatorPoint.set(aim.x, aim.y, aim.z).applyMatrix4(camera.matrixWorldInverse);
const spot = screenIndicator(indicatorPoint, tanY * camera.aspect, tanY, width, height, margin);
const marker = indicatorFor(viewer.ui, key);
shown.add(key);
// The label sits inward from the arrow (or under the plane). When it would
// cover another label, slide the whole marker along its edge (or just the
// tag, in view) so each arrow keeps its own label.
const lx = spot.onScreen ? 0 : -Math.cos(spot.angle) * 46;
let ly = spot.onScreen ? 26 : -Math.sin(spot.angle) * 24;
const alongTop = !spot.onScreen && (spot.y <= margin + 0.5 || spot.y >= height - margin - 0.5);
const covered = () => labels.some((l) => Math.abs(l.x - spot.x - lx) < 78 && Math.abs(l.y - spot.y - ly) < 24);
for (let tries = 0; tries < 3 && covered(); tries++) {
if (spot.onScreen) ly += spot.y < height / 2 ? 24 : -24;
else if (alongTop) spot.x += spot.x < width / 2 ? 80 : -80;
else spot.y += spot.y < height / 2 ? 24 : -24;
}
labels.push({ x: spot.x + lx, y: spot.y + ly });
marker.root.hidden = false;
marker.root.classList.toggle('onscreen', spot.onScreen);
marker.root.style.transform = `translate(${spot.x.toFixed(1)}px, ${spot.y.toFixed(1)}px)`;
marker.root.style.setProperty('--angle', `${spot.angle.toFixed(3)}rad`);
marker.root.style.setProperty('--lx', `${lx.toFixed(1)}px`);
marker.root.style.setProperty('--ly', `${ly.toFixed(1)}px`);
const text = formatDistance(distance);
if (text !== marker.text) marker.distance.textContent = marker.text = text;
}
for (const [key, marker] of viewer.ui.markers) if (!shown.has(key)) marker.root.hidden = true;
}
const sightOrigin = new Vector3();
const sightDirection = new Vector3();
const pathDirection = new Vector3();
const sightScreen = new Vector3();
/** The gun line (rounds inherit the plane's velocity) and the flight path, from the plane. */
function sightRays(state: FlightState): void {
sightOrigin.copy(gunMuzzle(state, 0));
sightDirection.copy(forwardOf(state)).multiplyScalar(GUNS.muzzleSpeed).add(state.velocity).normalize();
if (state.velocity.lengthSq() > 1) pathDirection.copy(state.velocity).normalize();
else pathDirection.copy(forwardOf(state));
}
/** Put `element` where `point` lands in the camera, clamped inside the view; false if behind. */
function placeOnScreen(element: HTMLElement, point: Vector3, camera: PerspectiveCamera): boolean {
sightScreen.copy(point).project(camera);
if (sightScreen.z > 1) return false;
const x = Math.max(-0.92, Math.min(0.92, sightScreen.x));
const y = Math.max(-0.92, Math.min(0.92, sightScreen.y));
element.style.left = `${(x + 1) * 50}%`;
element.style.top = `${(1 - y) * 50}%`;
return true;
}
/** Same prediction as the next shot, projected independently into each camera. */
function placeAim(pilot: Pilot, now: number): void {
const { reticle, aimPoint, flightPath, hitMarker } = pilot.ui;
reticle.hidden = paused || !pilot.state.alive;
const outside = cameraModeOf(pilot) !== 'cockpit';
if (reticle.hidden) { pilot.aim = null; pilot.aimUpdated = -Infinity; }
else if (now - pilot.aimUpdated >= 50) {
pilot.aim = findAim(pilot.state, pilots.map(p => p.state), world);
if (outside) {
sightRays(pilot.state);
pilot.sightDistance = castRay(sightOrigin, sightDirection, SIGHT_RANGE, world);
pilot.pathDistance = castRay(pilot.state.position, pathDirection, SIGHT_RANGE, world);
}
pilot.aimUpdated = now;
}
// Outside the cockpit the screen centre isn't where the guns point: cast the
// gun line and the flight path ahead and mark where each meets the world.
flightPath.hidden = true;
if (reticle.hidden || !outside) {
reticle.style.left = reticle.style.top = '';
} else {
sightRays(pilot.state);
const camera = pilot.view.camera;
const sight = sightScreen.copy(sightOrigin).addScaledVector(sightDirection, pilot.sightDistance);
if (!placeOnScreen(reticle, sight, camera)) reticle.style.left = reticle.style.top = '';
const path = sightScreen.copy(pilot.state.position).addScaledVector(pathDirection, pilot.pathDistance);
flightPath.hidden = !placeOnScreen(flightPath, path, camera);
flightPath.classList.toggle('impact', pilot.pathDistance < SIGHT_RANGE);
}
if (pilot.aim && !pilots.some(p => p.state === pilot.aim!.target && p.state.alive)) pilot.aim = null;
const aim = pilot.aim;
reticle.classList.toggle('assisted', !!aim);
reticle.setAttribute('aria-label', aim ? 'Gun sight: assisted shot available' : 'Gun sight');
hitMarker.hidden = reticle.hidden || now > pilot.hitUntil;
hitMarker.classList.toggle('armored', pilot.armoredHit);
aimPoint.hidden = true;
if (aim) {
const point = aim.point.clone().project(pilot.view.camera);
if (point.z >= -1 && point.z <= 1 && Math.abs(point.x) <= 1 && Math.abs(point.y) <= 1) {
aimPoint.hidden = false;
aimPoint.style.left = `${(point.x + 1) * 50}%`;
aimPoint.style.top = `${(1 - point.y) * 50}%`;
}
}
}
const seekerNose = new Vector3();
/** The plane a rocket launched now would lock onto, boxed in this pilot's view. */
function placeLock(pilot: Pilot, now: number): void {
const { state } = pilot;
const { lockBox } = pilot.ui;
if (paused || !state.alive) {
pilot.lock = null;
pilot.lockUpdated = -Infinity;
} else if (now - pilot.lockUpdated >= 50) {
const forward = forwardOf(state);
seekerNose.copy(state.position).addScaledVector(forward, 3);
pilot.lock = seekerTarget(seekerNose, forward, pilots.map((p) => p.state), state, world);
pilot.lockUpdated = now;
}
const lock = pilot.lock;
lockBox.hidden = true;
if (!lock || !lock.alive) return;
const point = lock.position.clone().project(pilot.view.camera);
if (point.z < -1 || point.z > 1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1) return;
lockBox.hidden = false;
lockBox.classList.toggle('waiting', state.rockets === 0 || state.rocketCooldown > 0);
lockBox.style.left = `${(point.x + 1) * 50}%`;
lockBox.style.top = `${(1 - point.y) * 50}%`;
}
function setViewport(rect: ViewRect): void {
const x = Math.round(rect.x * frameWidth);
const w = Math.round((rect.x + rect.w) * frameWidth) - x;
const top = Math.round(rect.y * frameHeight);
const h = Math.round((rect.y + rect.h) * frameHeight) - top;
const y = frameHeight - top - h;
renderer.setViewport(x, y, w, h);
renderer.setScissor(x, y, w, h);
}
const centroid = new Vector3();
function renderOverview(rect: ViewRect, time: number): void {
centroid.set(0, 0, 0);
for (const pilot of pilots) centroid.add(pilot.state.position);
if (centroid.lengthSq() < 1) centroid.set(0, 1, 0);
centroid.normalize();
// Hang high above the pilots and drift slowly around them.
const side = new Vector3(0, 1, 0).cross(centroid);
if (side.lengthSq() < 1e-4) side.set(1, 0, 0);
side.normalize();
const view = centroid.clone().applyAxisAngle(side, 0.35).applyAxisAngle(centroid, time * 0.05);
overview.position.copy(view).multiplyScalar(PLANET_RADIUS * 3.2);
overview.up.copy(side);
overview.lookAt(0, 0, 0);
lightFor(overview.position, centroid);
horizon.cullFor(overview.position);
placeMarkers(overview.position, null, 0.012);
for (const pilot of pilots) pilot.model.group.visible = false;
const fog = scene.fog as Fog;
const [near, far] = [fog.near, fog.far];
fog.near = 4000;
fog.far = 8000;
setViewport(rect);
renderer.render(scene, overview);
fog.near = near;
fog.far = far;
}
/** Body-frame centres of the damageable parts, for smoke trails. */
const SMOKE_POINTS = {
left: new Vector3(-2.1, 0.4, -0.5),
right: new Vector3(2.1, 0.4, -0.5),
tail: new Vector3(0, 0.5, 2.4),
} as const;
const smokeAt = new Vector3();
const smokeDrift = new Vector3();
/** Damaged parts trail smoke: light when hurt, black once destroyed. */
function emitDamageSmoke(pilot: Pilot, dt: number): void {
const { state } = pilot;
if (!state.alive) return;
for (const part of ['left', 'right', 'tail'] as const) {
const amount = state.damage[part];
if (amount < 0.3 || random() > amount * 16 * dt) continue;
smokeAt.copy(SMOKE_POINTS[part]).applyQuaternion(state.orientation).add(state.position);
smokeDrift.copy(state.velocity).multiplyScalar(0.15).addScaledVector(smokeAt.clone().normalize(), 1.5);
const darkness = amount >= 1 ? 2 : amount > 0.65 ? 1 : 0;
effects.puff(smokeAt, smokeDrift, darkness, amount >= 1 ? 1.7 : 1);
}
}
function audioMix(): void {
const alive = pilots.filter((pilot) => pilot.state.alive);
const n = Math.max(1, alive.length);
audio.update({
throttle: alive.reduce((sum, p) => sum + p.state.throttle, 0) / n,
airspeed: alive.reduce((sum, p) => sum + p.state.airspeed, 0) / n,
boosting: alive.some((p) => p.state.boosting),
stall: Math.max(0, ...alive.map((p) => p.state.stallAmount)),
alive: alive.length > 0,
time: pilots[0]!.state.time,
});
}
function frame(now: number): void {
const dt = Math.min(0.1, (now - last) / 1000);
last = now;
perf.begin();
const frameInput = input.poll(dt);
syncRoster(frameInput.activePads);
const padCount = frameInput.activePads.length;
if (frameInput.padAny || padCount > knownPads) showControlsFor('gamepad');
else if (padCount === 0) showControlsFor('keyboard');
knownPads = padCount;
for (const pilot of pilots) {
const pad = frameInput.pads.find((p) => p.index === pilot.pad);
if (pilot.history.offered) {
if (pad?.accept) answerOffer(pilot, true);
else if (pad?.decline) answerOffer(pilot, false);
} else if (paused && pad?.mode) setBeginner(pilot, !pilot.beginner);
}
if (frameInput.pause) setPaused(!paused);
else if (paused && frameInput.pads.some((pad) => pad.accept)) setPaused(false);
if (frameInput.mute) {
settings.muted = !settings.muted;
applySettings();
void persist();
}
// Each pilot's controls: pilot 1 also listens to keyboard and touch.
const controls: ControlInput[] = pilots.map((pilot, i) => {
const pad = frameInput.pads.find((p) => p.index === pilot.pad);
if (pad?.camera || (i === 0 && frameInput.localCamera)) cycleCamera(pilot);
let control = pad?.control ?? neutralInput();
if (i === 0) control = mergeControls(frameInput.local, control);
if (!pilot.state.alive) control = neutralInput();
if (pilot.boostGate) pilot.boostGate = control.boost;
if (pilot.boostGate) control.boost = false;
return control;
});
perf.simBegin();
if (!paused) {
accumulator += dt;
// Steps run in a burst each frame; spread their sounds back out over the
// next frame so gunfire keeps an even cadence.
let stepIndex = 0;
while (accumulator >= STEP) {
const wasAlive = pilots.map((pilot) => pilot.state.alive);
pilots.forEach((pilot, i) => {
const raw = controls[i]!;
const control = pilot.beginner
? (cameraModeOf(pilot) === 'cockpit' ? beginnerControls : planetBeginnerControls)(pilot.state, raw)
: { ...raw, pitch: settings.invertPitch ? -raw.pitch : raw.pitch };
stepFlight(pilot.state, control, world, STEP);
});
const states = pilots.map((pilot) => pilot.state);
const gunfire = stepGuns(
guns,
states,
controls.map((control) => control.fire),
world,
STEP,
);
for (const shooter of gunfire.shots) audio.shot(stepIndex * STEP, states[shooter]!.gunSide * 0.2);
for (const confirmation of gunfire.confirmations) {
const shooter = pilots.find(p => p.state === confirmation.owner);
if (shooter && (!confirmation.armored || shooter.hitUntil <= performance.now() || shooter.armoredHit)) {
shooter.hitUntil = performance.now() + 180;
shooter.armoredHit = confirmation.armored;
}
}
for (const hit of gunfire.hits) {
const target = pilots[hit.target]!;
target.model.addHole(hit.part, hit.local);
effects.spark(hit.world);
audio.hit(hit.destroyed);
if (hit.destroyed) {
const livery = LIVERIES[target.seat]!;
effects.shards(hit.world, target.state.velocity, [livery.wing, livery.body, livery.trim], 12);
}
}
for (const point of gunfire.impacts) effects.spark(point);
const salvo = stepRockets(
rockets,
states,
controls.map((control) => control.rocket),
world,
STEP,
);
for (const launch of salvo.launches) audio.rocketLaunch(stepIndex * STEP, launch.side * 0.3);
for (const hit of salvo.hits) {
const target = pilots[hit.target]!;
target.model.addHole(hit.part, hit.local);
effects.blast(hit.world, hit.world.clone().normalize());
const livery = LIVERIES[target.seat]!;
effects.shards(hit.world, target.state.velocity, [livery.wing, livery.body, livery.trim], hit.destroyed ? 16 : 6);
audio.rocketBoom(stepIndex * STEP, true);
const shooter = pilots.find((p) => p.state === hit.owner);
if (shooter && shooter !== target) {
shooter.hitUntil = performance.now() + 300;
shooter.armoredHit = false;
}
}
for (const point of salvo.explosions) {
effects.blast(point, point.clone().normalize());
audio.rocketBoom(stepIndex * STEP);
}
collidePlanes(states);
pilots.forEach((pilot, i) => {
const { state } = pilot;
// Absolute throttle (touch slider) applies once.
controls[i]!.throttleSet = null;
if (wasAlive[i] && !state.alive) {
if (!pilot.beginner) recordBeginnerCrash(pilot.history, state.crashCause, state.time);
pilot.crashPoint = state.position.clone();
const livery = LIVERIES[pilot.seat]!;
effects.explode(state.position, state.velocity, state.position.clone().normalize(), [
livery.body,
livery.wing,
livery.trim,
]);
audio.crash();
}
if (!state.alive && state.crashTimer <= 0) {
spawnRandom(pilot);
if (pilot.pendingBeginner) setBeginner(pilot, true);
pilot.crashPoint = null;
pilot.view.snap();
}
});
accumulator -= STEP;
stepIndex++;
}
}
perf.simEnd();
const simDt = paused ? 0 : dt;
const shadows = pilots.map(({ state }) => {
const n = state.position.clone().normalize();
return {
up: n,
groundPoint: n.clone().multiplyScalar(PLANET_RADIUS + surfaceHeight(n.x, n.y, n.z)),
clearance: state.groundClearance,
alive: state.alive,
};
});
effects.update(simDt, shadows);
tracers.update(guns.bullets);
rocketMeshes.update(rockets.rockets, simDt, (position, drift) => effects.puff(position, drift, 0, 1.3));
if (!paused) for (const pilot of pilots) emitDamageSmoke(pilot, dt);
obstacleMeshes.update(simDt);
for (const pilot of pilots) {
pilot.model.group.position.copy(pilot.state.position);
pilot.model.group.quaternion.copy(pilot.state.orientation);
pilot.model.update(simDt, pilot.state.throttle, pilot.state.boosting);
pilot.model.setDamage(pilot.state.damage);
pilot.model.setRockets(pilot.state.rockets, pilot.state.rocketSide);
pilot.view.update(pilot.state, cameraModeOf(pilot), dt, pilot.crashPoint);
}
renderer.setScissorTest(pilots.length > 1);
for (const pilot of pilots) {
const camera = pilot.view.camera;
for (const other of pilots) {
other.model.group.visible = other.state.alive;
other.model.setCockpitView(other === pilot && cameraModeOf(pilot) === 'cockpit');
}
lightFor(camera.position, pilot.state.alive ? pilot.state.position : (pilot.crashPoint ?? pilot.state.position));
horizon.cullFor(camera.position);
placeMarkers(camera.position, pilot, 0.008);
setViewport(pilot.rect);
renderer.render(scene, camera);
placeIndicators(pilot);
placeAim(pilot, now);
placeLock(pilot, now);
}
if (spareRect) renderOverview(spareRect, now / 1000);
renderer.setScissorTest(false);
audioMix();
updateHud(now);
perf.end();
requestAnimationFrame(frame);
}
// "Press any key" on the title card; buttons and toggles keep their own keys.
window.addEventListener('keydown', (event) => {
if (!paused || started || event.ctrlKey || event.metaKey || event.altKey) return;
if (['Escape', 'KeyP', 'Tab', 'Enter', 'Space'].includes(event.code)) return;
const target = event.target as HTMLElement | null;
if (target?.closest('input')) return;
setPaused(false);
});
window.addEventListener('blur', () => {
if (started && !paused) setPaused(true);
});
window.addEventListener('pagehide', () => input.dispose());
setPaused(true);
// Keep the title-screen camera on the plane while the menu is up.
pilots[0]!.view.update(pilots[0]!.state, settings.camera, 0, null);
updateHud(performance.now() + 1000);
requestAnimationFrame(frame);
