Flight Simulator
Grok 4.5 · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 56,308 bytes · SHA-256 b046caf8f0d6
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Apex Aero — Flight Simulator</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
/* ── css: base ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #02060e;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: #e8f1ff;
user-select: none;
}
#app { width: 100%; height: 100%; position: relative; }
#c { display: block; width: 100%; height: 100%; background: #000; }
/* ── css: screens ── */
.screen {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at 50% 30%, rgba(20,50,90,0.85), rgba(2,6,14,0.95));
z-index: 20;
backdrop-filter: blur(4px);
}
.screen.hidden, .hidden { display: none !important; }
.panel {
width: min(920px, 94vw);
padding: 36px 40px 28px;
border: 1px solid rgba(120,180,255,0.25);
border-radius: 16px;
background: linear-gradient(160deg, rgba(12,24,48,0.92), rgba(6,12,28,0.96));
box-shadow: 0 24px 80px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.06);
}
.panel.compact { width: min(420px, 90vw); text-align: center; }
.panel h1 {
font-size: 42px; letter-spacing: 0.22em;
font-weight: 300; text-align: center;
background: linear-gradient(90deg, #7ec8ff, #ffffff, #9ad8ff);
-webkit-background-clip: text; background-clip: text; color: transparent;
margin-bottom: 4px;
}
.tagline {
text-align: center; color: #7a9bc0; letter-spacing: 0.18em;
font-size: 12px; text-transform: uppercase; margin-bottom: 28px;
}
.section { margin-bottom: 22px; }
.section h2 {
font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
color: #6a8fb8; margin-bottom: 10px; font-weight: 600;
}
.card-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.card {
cursor: pointer; padding: 14px 12px;
border-radius: 10px;
border: 1px solid rgba(100,150,220,0.2);
background: rgba(20,40,70,0.45);
transition: border-color 0.15s, transform 0.15s, background 0.15s;
text-align: left;
}
.card:hover { border-color: rgba(140,190,255,0.55); transform: translateY(-2px); }
.card.selected {
border-color: #5eb0ff;
background: linear-gradient(160deg, rgba(40,90,150,0.55), rgba(20,50,90,0.65));
box-shadow: 0 0 0 1px rgba(94,176,255,0.35), 0 8px 24px rgba(30,100,200,0.2);
}
.card .name { font-size: 15px; font-weight: 600; margin-bottom: 4px; color: #eaf3ff; }
.card .sub { font-size: 11px; color: #89a8c8; line-height: 1.4; }
.card .stats { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
.card .chip {
font-size: 10px; padding: 2px 7px; border-radius: 999px;
background: rgba(80,140,220,0.18); color: #a8c8e8; letter-spacing: 0.04em;
}
.cta {
display: block; width: 100%; margin-top: 10px;
padding: 14px 20px; border: none; border-radius: 10px;
background: linear-gradient(90deg, #1a6fd4, #2a9bff);
color: #fff; font-size: 14px; font-weight: 700; letter-spacing: 0.18em;
cursor: pointer; transition: filter 0.15s, opacity 0.15s;
}
.cta:hover:not(:disabled) { filter: brightness(1.12); }
.cta:disabled { opacity: 0.35; cursor: not-allowed; }
.cta.secondary {
background: transparent;
border: 1px solid rgba(120,170,230,0.35);
color: #a8c8e8; margin-top: 10px;
}
.help {
margin-top: 18px; text-align: center; color: #5e7a9a;
font-size: 11px; line-height: 1.7;
}
.help kbd {
display: inline-block; padding: 1px 6px; margin: 0 1px;
border: 1px solid rgba(100,140,190,0.35); border-radius: 4px;
background: rgba(30,50,80,0.5); color: #9ab8d8; font-family: inherit; font-size: 10px;
}
#end-msg { color: #9ab8d8; margin: 12px 0 20px; line-height: 1.5; }
/* ── css: hud ── */
#hud {
position: absolute; inset: 0; pointer-events: none; z-index: 10;
font-variant-numeric: tabular-nums;
}
.hud-top, .hud-bottom {
position: absolute; left: 0; right: 0;
display: flex; justify-content: space-between; padding: 18px 28px;
gap: 16px;
}
.hud-top { top: 0; }
.hud-bottom { bottom: 0; }
.hud-block {
min-width: 110px;
padding: 8px 12px;
background: rgba(4,12,24,0.45);
border: 1px solid rgba(100,170,255,0.18);
border-radius: 8px;
backdrop-filter: blur(6px);
}
.hud-block.center { text-align: center; }
.hud-block.right { text-align: right; }
.hud-block .label {
font-size: 10px; letter-spacing: 0.16em; color: #6a98c8; margin-bottom: 2px;
}
.hud-block .value {
font-size: 22px; font-weight: 300; color: #d8ecff; line-height: 1.1;
}
.hud-block .value span { font-size: 11px; color: #6a98c8; margin-left: 3px; }
.hud-mid {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%);
}
#hud-attitude {
width: 90px; height: 90px; position: relative;
border-radius: 50%; overflow: hidden;
border: 2px solid rgba(140,190,255,0.45);
box-shadow: 0 0 20px rgba(40,100,200,0.25), inset 0 0 12px rgba(0,0,0,0.4);
background: #000;
}
#horizon-ball {
position: absolute; left: -20px; top: -20px; width: 130px; height: 130px;
transition: none;
}
#horizon-sky {
height: 50%; background: linear-gradient(#1a4a8a, #3a7cc0);
}
#horizon-ground {
height: 50%; background: linear-gradient(#6b4a1a, #3a2a0a);
}
#horizon-line {
position: absolute; left: 0; right: 0; top: 50%; height: 2px;
background: #e8d080; transform: translateY(-50%);
}
.att-marker {
position: absolute; left: 50%; top: 50%; width: 28px; height: 2px;
background: #ff6060; transform: translate(-50%, -50%);
box-shadow: -18px 0 0 #ff6060, 18px 0 0 #ff6060;
z-index: 2;
}
.att-marker::before {
content: ""; position: absolute; left: 50%; top: 50%;
width: 6px; height: 6px; border-radius: 50%; background: #ff6060;
transform: translate(-50%, -50%);
}
#stall-warn {
position: absolute; top: 22%; left: 50%; transform: translateX(-50%);
color: #ff4444; font-size: 22px; font-weight: 800; letter-spacing: 0.35em;
text-shadow: 0 0 12px rgba(255,40,40,0.8);
animation: pulse 0.5s ease-in-out infinite alternate;
}
@keyframes pulse { from { opacity: 0.5; } to { opacity: 1; } }
#mission-banner {
position: absolute; top: 14%; left: 50%; transform: translateX(-50%);
color: #b8d8ff; font-size: 13px; letter-spacing: 0.12em;
text-align: center; max-width: 60%; text-shadow: 0 2px 8px #000;
}
#crosshair {
position: absolute; left: 50%; top: 46%;
transform: translate(-50%, -50%);
font-size: 28px; color: rgba(160,220,255,0.7);
text-shadow: 0 0 8px rgba(80,160,255,0.5);
font-weight: 200;
}
#score {
position: absolute; top: 18px; right: 28px;
font-size: 16px; letter-spacing: 0.15em; color: #a8d4ff;
padding: 6px 12px;
background: rgba(4,12,24,0.45);
border: 1px solid rgba(100,170,255,0.18);
border-radius: 8px;
}
.hud-warn .value { color: #ff6a6a !important; }
</style>
</head>
<body>
<div id="app">
<canvas id="c"></canvas>
<div id="overlay" class="screen">
<div class="panel">
<h1>APEX AERO</h1>
<p class="tagline">High-fidelity flight dynamics</p>
<div class="section">
<h2>Select Aircraft</h2>
<div id="aircraft-list" class="card-row"></div>
</div>
<div class="section">
<h2>Select Mission</h2>
<div id="mission-list" class="card-row"></div>
</div>
<button id="btn-start" class="cta" disabled="">START FLIGHT</button>
<div class="help">W/S Pitch · A/D Roll · Q/E Yaw · Shift/Ctrl Throttle · 1–5 Throttle presets · Space Fire · Esc Menu</div>
</div>
</div>
<div id="hud" class="hidden">
<div class="hud-top">
<div class="hud-block">
<div class="label">AIRSPEED</div>
<div id="hud-speed" class="value">0 <span>kts</span></div>
</div>
<div class="hud-block center">
<div class="label">ALTITUDE</div>
<div id="hud-alt" class="value">0 <span>ft</span></div>
</div>
<div class="hud-block right">
<div class="label">THROTTLE</div>
<div id="hud-throttle" class="value">0<span>%</span></div>
</div>
</div>
<div class="hud-mid">
<div id="hud-attitude">
<div id="horizon-ball">
<div id="horizon-sky"></div>
<div id="horizon-ground"></div>
<div id="horizon-line"></div>
</div>
<div class="att-marker"></div>
</div>
</div>
<div class="hud-bottom">
<div class="hud-block">
<div class="label">HEADING</div>
<div id="hud-hdg" class="value">000°</div>
</div>
<div class="hud-block">
<div class="label">AoA</div>
<div id="hud-aoa" class="value">0.0°</div>
</div>
<div class="hud-block">
<div class="label">G-FORCE</div>
<div id="hud-g" class="value">1.0</div>
</div>
<div class="hud-block">
<div class="label">V/S</div>
<div id="hud-vs" class="value">0 <span>fpm</span></div>
</div>
</div>
<div id="stall-warn" class="warn hidden">STALL</div>
<div id="mission-banner"></div>
<div id="crosshair" class="hidden">+</div>
<div id="score" class="hidden">SCORE 0</div>
</div>
<div id="pause" class="screen hidden">
<div class="panel compact">
<h1>PAUSED</h1>
<button id="btn-resume" class="cta">RESUME</button>
<button id="btn-menu" class="cta secondary">MAIN MENU</button>
</div>
</div>
<div id="end" class="screen hidden">
<div class="panel compact">
<h1 id="end-title">MISSION COMPLETE</h1>
<p id="end-msg"></p>
<button id="btn-again" class="cta">FLY AGAIN</button>
<button id="btn-end-menu" class="cta secondary">MAIN MENU</button>
</div>
</div>
</div>
<script>
'use strict';
// ── const: AIRCRAFT ──
const AIRCRAFT = {
falcon: {
id: 'falcon',
name: 'Falcon F-12',
sub: 'Lightweight air superiority fighter. High thrust-to-weight, sharp response.',
mass: 9800,
wingArea: 28,
wingspan: 10.5,
thrustMax: 155000,
maxSpeed: 720,
cruiseSpeed: 280,
Cl0: 0.28,
ClAlpha: 5.0,
Cd0: 0.022,
K: 0.065,
stallAoA: 16 * Math.PI / 180,
inert: { pitch: 18000, roll: 9000, yaw: 28000 },
ctrl: { pitch: 2.4, roll: 3.6, yaw: 1.5 },
damp: { pitch: 4.5, roll: 5.5, yaw: 3.2 },
color: 0x4a90c8,
accent: 0xc8e0f8,
type: 'fighter'
},
atlas: {
id: 'atlas',
name: 'Atlas C-40',
sub: 'Heavy transport. High lift, sluggish roll, long runway needs.',
mass: 52000,
wingArea: 145,
wingspan: 38,
thrustMax: 300000,
maxSpeed: 280,
cruiseSpeed: 180,
Cl0: 0.42,
ClAlpha: 5.4,
Cd0: 0.038,
K: 0.045,
stallAoA: 13 * Math.PI / 180,
inert: { pitch: 220000, roll: 160000, yaw: 340000 },
ctrl: { pitch: 1.0, roll: 0.9, yaw: 0.75 },
damp: { pitch: 8.0, roll: 10.0, yaw: 7.0 },
color: 0x6a7a88,
accent: 0xc8c4a8,
type: 'transport'
},
comet: {
id: 'comet',
name: 'Comet XT',
sub: 'Aerobatic racer. Extreme maneuverability, thin stall margin.',
mass: 720,
wingArea: 10.5,
wingspan: 7.2,
thrustMax: 11000,
maxSpeed: 310,
cruiseSpeed: 160,
Cl0: 0.22,
ClAlpha: 5.6,
Cd0: 0.028,
K: 0.08,
stallAoA: 14 * Math.PI / 180,
inert: { pitch: 900, roll: 450, yaw: 1200 },
ctrl: { pitch: 3.8, roll: 5.4, yaw: 2.5 },
damp: { pitch: 3.0, roll: 3.5, yaw: 2.8 },
color: 0xd43030,
accent: 0xffe060,
type: 'racer'
}
};
// ── const: MISSIONS ──
const MISSIONS = {
takeoff: {
id: 'takeoff',
name: 'Precision Flight',
sub: 'Takeoff, circuit, and landing. Manage AoA, speed and descent.',
chips: ['Runway', 'Circuits', 'Landing']
},
dogfight: {
id: 'dogfight',
name: 'Dogfight',
sub: 'High-G pursuit against hostiles. Lead the target and fire.',
chips: ['Combat', 'Inertia', 'High-G']
}
};
// ── const: RHO0 ──
const RHO0 = 1.225;
// ── const: G ──
const G = 9.81;
// ── const: FT ──
const FT = 3.28084;
// ── const: KTS ──
const KTS = 1.94384;
// ── function: createInput ──
function createInput() {
const keys = Object.create(null);
const state = {
pitch: 0, roll: 0, yaw: 0, throttle: 0.3, fire: false,
_throtTarget: 0.3
};
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
function update(dt) {
const p = (keys.KeyS || keys.ArrowDown ? 1 : 0) - (keys.KeyW || keys.ArrowUp ? 1 : 0);
const r = (keys.KeyD || keys.ArrowRight ? 1 : 0) - (keys.KeyA || keys.ArrowLeft ? 1 : 0);
const y = (keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0);
state.pitch += (p - state.pitch) * Math.min(1, dt * 8);
state.roll += (r - state.roll) * Math.min(1, dt * 8);
state.yaw += (y - state.yaw) * Math.min(1, dt * 8);
if (keys.ShiftLeft || keys.ShiftRight) state._throtTarget = Math.min(1, state._throtTarget + dt * 0.45);
if (keys.ControlLeft || keys.ControlRight) state._throtTarget = Math.max(0, state._throtTarget - dt * 0.45);
if (keys.Digit1) state._throtTarget = 0;
if (keys.Digit2) state._throtTarget = 0.25;
if (keys.Digit3) state._throtTarget = 0.5;
if (keys.Digit4) state._throtTarget = 0.75;
if (keys.Digit5) state._throtTarget = 1;
state.throttle += (state._throtTarget - state.throttle) * Math.min(1, dt * 2.2);
state.fire = !!(keys.Space);
return state;
}
function setThrottle(t) {
state._throtTarget = t;
state.throttle = t;
}
return { update, keys, state, setThrottle };
}
// ── function: airDensity ──
function airDensity(alt) {
// ISA troposphere approximation
const T = Math.max(180, 288.15 - 0.0065 * alt);
const p = 101325 * Math.pow(T / 288.15, 5.2561);
return p / (287.05 * T);
}
// ── function: createAircraftState ──
function createAircraftState(spec, pos, heading) {
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, heading, 0, 'YXZ'));
return {
spec,
position: pos.clone(),
velocity: new THREE.Vector3(),
angularVel: new THREE.Vector3(),
quat: q,
throttle: 0.3,
onGround: true,
stalled: false,
gForce: 1,
aoa: 0,
airspeed: 0,
altitude: pos.y,
heading: heading,
gearDown: true,
lastAccel: new THREE.Vector3(),
smokeTimer: 0,
alive: true
};
}
// ── function: integrateFlight ──
function integrateFlight(ac, input, det, dt, opts) {
if (!ac.alive) return;
const s = ac.spec;
const rho = airDensity(Math.max(0, ac.position.y));
const qAtt = ac.quat;
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(qAtt);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(qAtt);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(qAtt);
ac.throttle = input.throttle;
const thrAlt = Math.max(0.25, rho / RHO0);
const thrust = s.thrustMax * ac.throttle * thrAlt;
const vel = ac.velocity;
const speed = vel.length();
ac.airspeed = speed;
let aoa = 0;
let beta = 0;
if (speed > 1) {
const vBody = vel.clone().applyQuaternion(qAtt.clone().invert());
aoa = Math.atan2(-vBody.y, -vBody.z);
beta = Math.atan2(vBody.x, -vBody.z);
}
ac.aoa = aoa;
let Cl = s.Cl0 + s.ClAlpha * aoa;
const stalled = Math.abs(aoa) > s.stallAoA;
ac.stalled = stalled && speed > 15 && !ac.onGround;
if (stalled) {
const over = (Math.abs(aoa) - s.stallAoA) / (s.stallAoA + 0.01);
Cl *= Math.max(0.1, 1 - over * 1.6);
}
Cl = THREE.MathUtils.clamp(Cl, -1.6, 1.8);
const Cd = s.Cd0 + s.K * Cl * Cl + (stalled ? 0.4 * Math.min(1, Math.abs(aoa)) : 0)
+ (ac.gearDown ? 0.018 : 0);
const qDyn = 0.5 * rho * speed * speed;
const liftMag = qDyn * s.wingArea * Cl;
const dragMag = qDyn * s.wingArea * Cd;
const vDir = speed > 0.5 ? vel.clone().normalize() : forward.clone();
const liftDir = new THREE.Vector3().crossVectors(right, vDir);
if (liftDir.lengthSq() > 1e-6) liftDir.normalize();
else liftDir.copy(up);
if (liftDir.dot(up) < 0) liftDir.negate();
const force = new THREE.Vector3();
force.addScaledVector(forward, thrust);
if (speed > 0.5) {
force.addScaledVector(liftDir, liftMag);
force.addScaledVector(vDir, -dragMag);
}
force.y -= s.mass * G;
const groundY = det ? det(ac.position.x, ac.position.z) : 0;
let agl = ac.position.y - groundY;
ac.altitude = ac.position.y;
const canFly = liftMag >= s.mass * G * 0.98 && speed > 25;
const onGr = agl < 1.65 && vel.y <= 3.0 && !canFly;
ac.onGround = onGr;
if (onGr) {
ac.position.y = groundY + 1.5;
agl = 1.5;
if (vel.y < 0) vel.y = 0;
const mu = speed < 3 ? 0.05 : 0.018;
const nForce = Math.max(0, s.mass * G - Math.max(0, liftMag));
if (speed > 0.05) force.addScaledVector(vDir, -mu * nForce);
else if (ac.throttle < 0.08) vel.set(0, 0, 0);
force.x -= vel.x * s.mass * 0.5;
const eul = new THREE.Euler().setFromQuaternion(qAtt, 'YXZ');
eul.z *= Math.max(0, 1 - dt * 6);
const vRot = Math.sqrt((2 * s.mass * G) / (Math.max(rho, 0.5) * s.wingArea * (s.Cl0 + s.ClAlpha * 0.15)));
if (speed < vRot * 0.8) eul.x *= Math.max(0, 1 - dt * 2.5);
else eul.x = THREE.MathUtils.clamp(eul.x, -0.06, 0.3);
qAtt.setFromEuler(eul);
ac.angularVel.z *= 0.35;
}
if (agl < 1.5 && vel.y < -16) ac.alive = false;
if (!onGr && agl < 0) {
if (speed > 40 && Math.abs(vel.y) > 12) ac.alive = false;
ac.position.y = groundY + 1.5;
vel.y = Math.max(vel.y, -1) * 0.2;
}
const accel = force.clone().multiplyScalar(1 / s.mass);
ac.lastAccel.copy(accel);
vel.addScaledVector(accel, dt);
if (vel.length() > s.maxSpeed * 1.35) vel.setLength(s.maxSpeed * 1.35);
ac.position.addScaledVector(vel, dt);
const gy2 = det ? det(ac.position.x, ac.position.z) : 0;
if (ac.position.y < gy2 + 1.5) {
if (canFly) {
ac.position.y = gy2 + 1.55;
if (vel.y < 0.2) vel.y = 1.2;
ac.onGround = false;
} else {
ac.position.y = gy2 + 1.5;
if (vel.y < 0) vel.y = 0;
}
}
const i = ac.angularVel;
const qDynN = Math.min(1.25, qDyn / 7500 + 0.1);
const auth = Math.min(1, Math.max(0.12, qDynN));
const stallFactor = stalled ? 0.35 : (Math.abs(aoa) > s.stallAoA * 0.7 ? 0.6 : 1);
let cp = input.pitch * s.ctrl.pitch * auth * stallFactor;
let cr = input.roll * s.ctrl.roll * auth * (stalled ? 0.5 : 1);
let cy = input.yaw * s.ctrl.yaw * auth;
if (onGr) {
cr *= 0.05;
const vRot = Math.sqrt((2 * s.mass * G) / (Math.max(rho, 0.5) * s.wingArea * (s.Cl0 + s.ClAlpha * 0.15)));
cp *= speed > vRot * 0.75 ? 0.85 : 0.2;
if (speed > 1.5) {
const steer = input.yaw * dt * (0.9 + Math.min(1.5, speed * 0.04)) * s.ctrl.yaw;
const rot = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -steer);
qAtt.premultiply(rot);
const flatFwd = new THREE.Vector3(0, 0, -1).applyQuaternion(qAtt);
flatFwd.y = 0;
if (flatFwd.lengthSq() > 0.01) {
flatFwd.normalize();
const spd = Math.hypot(vel.x, vel.z);
vel.x = flatFwd.x * spd;
vel.z = flatFwd.z * spd;
}
}
cy *= 0.25;
}
const pitchStab = 2.8 + (stalled ? 1.5 : 0);
i.x += (cp - i.x * s.damp.pitch * 0.16 - aoa * pitchStab) * dt * 14;
i.y += (cy - i.y * s.damp.yaw * 0.13 - beta * 2.5) * dt * 11;
i.z += (cr - i.z * s.damp.roll * 0.15) * dt * 16;
i.x = THREE.MathUtils.clamp(i.x, -2.8, 2.8);
i.y = THREE.MathUtils.clamp(i.y, -1.8, 1.8);
i.z = THREE.MathUtils.clamp(i.z, -3.8, 3.8);
const hx = i.x * dt * 0.5;
const hy = i.y * dt * 0.5;
const hz = i.z * dt * 0.5;
const qw = qAtt.w, qx = qAtt.x, qy = qAtt.y, qz = qAtt.z;
qAtt.w = qw + (-qx * hx - qy * hy - qz * hz);
qAtt.x = qx + (qw * hx + qy * hz - qz * hy);
qAtt.y = qy + (qw * hy - qx * hz + qz * hx);
qAtt.z = qz + (qw * hz + qx * hy - qy * hx);
qAtt.normalize();
const e = new THREE.Euler().setFromQuaternion(qAtt, 'YXZ');
ac.heading = e.y;
ac.gForce = THREE.MathUtils.clamp(
1 + (liftMag / (s.mass * G) - 1) * 0.85 + accel.y / G * 0.15,
-2, 11
);
if (!ac.onGround && ac.altitude - gy2 > 40) ac.gearDown = false;
if (ac.altitude - gy2 < 50 && opts && opts.autoGear) ac.gearDown = true;
}
// ── function: hash2 ──
function hash2(x, z) {
let n = Math.sin(x * 127.1 + z * 311.7) * 43758.5453;
return n - Math.floor(n);
}
// ── function: smoothNoise ──
function smoothNoise(x, z) {
const xi = Math.floor(x), zi = Math.floor(z);
const xf = x - xi, zf = z - zi;
const u = xf * xf * (3 - 2 * xf);
const v = zf * zf * (3 - 2 * zf);
const a = hash2(xi, zi);
const b = hash2(xi + 1, zi);
const c = hash2(xi, zi + 1);
const d = hash2(xi + 1, zi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
// ── function: fbm ──
function fbm(x, z) {
let a = 0, f = 1, amp = 1, sum = 0;
for (let i = 0; i < 5; i++) {
sum += smoothNoise(x * f, z * f) * amp;
a += amp;
amp *= 0.5;
f *= 2.05;
}
return sum / a;
}
// ── function: terrainHeight ──
function terrainHeight(x, z) {
// Flat runway strip around origin + rolling hills/mountains beyond
const d = Math.hypot(x, z);
const runway = Math.max(0, 1 - Math.abs(x) / 70) * Math.max(0, 1 - Math.abs(z) / 1400);
const coast = 1 - THREE.MathUtils.smoothstep(900, 2800, d);
const hills = fbm(x * 0.0011, z * 0.0011);
const ridged = 1 - Math.abs(fbm(x * 0.00055 + 20, z * 0.00055) * 2 - 1);
let h = hills * 90 + ridged * ridged * 280;
h *= THREE.MathUtils.smoothstep(120, 600, Math.abs(x) + Math.abs(z) * 0.3);
// ocean drop
if (d > 2200) h -= (d - 2200) * 0.08;
h = Math.max(h, -40);
// flatten for airfield
h = h * (1 - runway * 0.98);
return h;
}
// ── function: createTerrain ──
function createTerrain(scene) {
const size = 6000;
const seg = 200;
const geo = new THREE.PlaneGeometry(size, size, seg, seg);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const colors = new Float32Array(pos.count * 3);
const c = new THREE.Color();
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
const h = terrainHeight(x, z);
pos.setY(i, h);
if (h < 0.5) c.setRGB(0.18, 0.35, 0.48);
else if (h < 35) c.setRGB(0.30 + h * 0.002, 0.48, 0.22);
else if (h < 120) c.setRGB(0.40, 0.42, 0.24);
else if (h < 200) c.setRGB(0.50, 0.42, 0.34);
else c.setRGB(0.92, 0.94, 0.96);
if (Math.abs(x) < 90 && Math.abs(z) < 1250 && h < 4) {
c.setRGB(0.38, 0.40, 0.42);
}
colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.9,
metalness: 0.04
});
const mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = true;
scene.add(mesh);
const rw = new THREE.Group();
const asphalt = new THREE.Mesh(
new THREE.BoxGeometry(42, 0.5, 1900),
new THREE.MeshStandardMaterial({ color: 0x4a4e56, roughness: 0.92, metalness: 0.05 })
);
asphalt.position.y = 0.35;
asphalt.receiveShadow = true;
rw.add(asphalt);
const edgeMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
for (const sx of [-20, 20]) {
const edge = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.55, 1900), edgeMat);
edge.position.set(sx, 0.44, 0);
rw.add(edge);
}
const lineMat = new THREE.MeshBasicMaterial({ color: 0xffe060 });
for (let i = -16; i <= 16; i++) {
const dash = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.58, 24), lineMat);
dash.position.set(0, 0.45, i * 55);
rw.add(dash);
}
for (let s = -1; s <= 1; s += 2) {
for (let i = 0; i < 10; i++) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.58, 36), lineMat);
bar.position.set(-15 + i * 3.3, 0.45, s * 880);
rw.add(bar);
}
}
const lightGeo = new THREE.BoxGeometry(0.8, 0.7, 0.8);
const amber = new THREE.MeshStandardMaterial({ color: 0xffcc44, emissive: 0xffaa22, emissiveIntensity: 2 });
const blue = new THREE.MeshStandardMaterial({ color: 0x66aaff, emissive: 0x3388ff, emissiveIntensity: 1.5 });
for (let i = -18; i <= 18; i++) {
for (const sx of [-22, 22]) {
const l = new THREE.Mesh(lightGeo, i % 2 === 0 ? amber : blue);
l.position.set(sx, 0.6, i * 48);
rw.add(l);
}
}
scene.add(rw);
const bmat = new THREE.MeshStandardMaterial({ color: 0x9aa0aa, roughness: 0.7 });
const glass = new THREE.MeshStandardMaterial({ color: 0x4a98d0, metalness: 0.5, roughness: 0.25, emissive: 0x1a4060, emissiveIntensity: 0.4 });
const towerBase = new THREE.Mesh(new THREE.BoxGeometry(20, 12, 20), bmat);
towerBase.position.set(90, 6, -180);
const towerTop = new THREE.Mesh(new THREE.BoxGeometry(26, 7, 26), glass);
towerTop.position.set(90, 15.5, -180);
const hangar = new THREE.Mesh(new THREE.BoxGeometry(60, 16, 40), new THREE.MeshStandardMaterial({ color: 0x7a8088, roughness: 0.85 }));
hangar.position.set(120, 8, 200);
const hangar2 = hangar.clone();
hangar2.position.set(120, 8, 280);
scene.add(towerBase, towerTop, hangar, hangar2);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5a3a20 });
const leafMat = new THREE.MeshStandardMaterial({ color: 0x2f7a2c, roughness: 0.9 });
for (let i = 0; i < 80; i++) {
const tx = (Math.random() < 0.5 ? -1 : 1) * (70 + Math.random() * 220);
const tz = (Math.random() - 0.5) * 1600;
const th = terrainHeight(tx, tz);
if (th < 1 || th > 50) continue;
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.5, 4, 5), trunkMat);
trunk.position.set(tx, th + 2, tz);
const leaf = new THREE.Mesh(new THREE.ConeGeometry(3 + Math.random() * 2, 7, 6), leafMat);
leaf.position.set(tx, th + 7, tz);
scene.add(trunk, leaf);
}
return { heightAt: terrainHeight, mesh };
}
// ── function: makePlaneMesh ──
function makePlaneMesh(spec) {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: spec.color, metalness: 0.45, roughness: 0.38, emissive: spec.color, emissiveIntensity: 0.08
});
const accentMat = new THREE.MeshStandardMaterial({
color: spec.accent, metalness: 0.35, roughness: 0.42, emissive: spec.accent, emissiveIntensity: 0.12
});
const darkMat = new THREE.MeshStandardMaterial({ color: 0x111318, metalness: 0.6, roughness: 0.4 });
const glassMat = new THREE.MeshStandardMaterial({
color: 0xa8d8ff, metalness: 0.9, roughness: 0.08, transparent: true, opacity: 0.65, emissive: 0x224466, emissiveIntensity: 0.25
});
if (spec.type === 'fighter') {
const fus = new THREE.Mesh(new THREE.CapsuleGeometry(0.55, 4.2, 4, 10), bodyMat);
fus.rotation.x = Math.PI / 2;
fus.position.z = 0.2;
g.add(fus);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.6, 10), bodyMat);
nose.rotation.x = -Math.PI / 2;
nose.position.z = -2.6;
g.add(nose);
const wing = new THREE.Mesh(new THREE.BoxGeometry(7.2, 0.12, 2.0), bodyMat);
wing.position.set(0, -0.1, 0.3);
g.add(wing);
const wingTipL = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.08, 0.5), accentMat);
wingTipL.position.set(-3.6, -0.1, 0.3);
const wingTipR = wingTipL.clone(); wingTipR.position.x = 3.6;
g.add(wingTipL, wingTipR);
// leading edge accent stripe
const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.5, 3.5), accentMat);
stripe.position.set(0, 0.2, 0);
g.add(stripe);
const stab = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.08, 0.7), bodyMat);
stab.position.set(0, 0.1, 2.2);
g.add(stab);
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.4, 1.0), accentMat);
fin.position.set(0, 0.8, 2.0);
g.add(fin);
const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), glassMat);
canopy.position.set(0, 0.45, -0.6);
canopy.scale.set(1, 0.7, 1.4);
g.add(canopy);
const intakeL = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.4, 1.2), darkMat);
intakeL.position.set(-0.55, -0.35, 0.5);
const intakeR = intakeL.clone(); intakeR.position.x = 0.55;
g.add(intakeL, intakeR);
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.4, 0.4, 10), darkMat);
exhaust.rotation.x = Math.PI / 2;
exhaust.position.z = 2.6;
g.add(exhaust);
// afterburner glow
const glow = new THREE.Mesh(
new THREE.SphereGeometry(0.35, 8, 8),
new THREE.MeshBasicMaterial({ color: 0x66ccff })
);
glow.position.z = 2.9;
glow.name = 'exhaustGlow';
g.add(glow);
} else if (spec.type === 'transport') {
const fus = new THREE.Mesh(new THREE.CapsuleGeometry(1.1, 8.5, 6, 12), bodyMat);
fus.rotation.x = Math.PI / 2;
g.add(fus);
const wing = new THREE.Mesh(new THREE.BoxGeometry(16, 0.18, 2.4), bodyMat);
wing.position.set(0, 0.3, 0.5);
g.add(wing);
const engL = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 1.6, 10), darkMat);
engL.rotation.x = Math.PI / 2;
engL.position.set(-4.5, -0.3, 0.3);
const engR = engL.clone(); engR.position.x = 4.5;
g.add(engL, engR);
const stab = new THREE.Mesh(new THREE.BoxGeometry(5, 0.12, 1.2), bodyMat);
stab.position.set(0, 0.4, 4.5);
g.add(stab);
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.15, 2.4, 1.5), accentMat);
fin.position.set(0, 1.5, 4.3);
g.add(fin);
const cock = new THREE.Mesh(new THREE.SphereGeometry(0.7, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), glassMat);
cock.position.set(0, 0.8, -4.0);
cock.scale.set(1.2, 0.8, 1.5);
g.add(cock);
// livery stripe
const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.6, 8), accentMat);
stripe.position.set(0, 0.3, 0);
g.add(stripe);
} else {
const fus = new THREE.Mesh(new THREE.CapsuleGeometry(0.35, 2.6, 4, 8), bodyMat);
fus.rotation.x = Math.PI / 2;
g.add(fus);
const wing = new THREE.Mesh(new THREE.BoxGeometry(6.0, 0.1, 1.1), accentMat);
wing.position.set(0, 0, 0.1);
g.add(wing);
const wing2 = new THREE.Mesh(new THREE.BoxGeometry(4.5, 0.08, 0.8), bodyMat);
wing2.position.set(0, 0.7, 0.1);
g.add(wing2);
for (const sx of [-1.5, 1.5]) {
const st = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.7, 0.05), darkMat);
st.position.set(sx, 0.35, 0.1);
g.add(st);
}
const stab = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.06, 0.55), bodyMat);
stab.position.set(0, 0.15, 1.5);
g.add(stab);
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.85, 0.7), accentMat);
fin.position.set(0, 0.5, 1.45);
g.add(fin);
const propHub = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), darkMat);
propHub.position.z = -1.7;
g.add(propHub);
const prop = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.08, 0.12), darkMat);
prop.position.z = -1.8;
prop.name = 'prop';
g.add(prop);
const wheelL = new THREE.Mesh(new THREE.TorusGeometry(0.18, 0.06, 6, 12), darkMat);
wheelL.position.set(-0.5, -0.55, 0.2);
wheelL.rotation.y = Math.PI / 2;
const wheelR = wheelL.clone(); wheelR.position.x = 0.5;
g.add(wheelL, wheelR);
}
g.traverse((o) => {
if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
});
const scale = spec.type === 'transport' ? 2.4 : spec.type === 'fighter' ? 2.2 : 1.8;
g.scale.setScalar(scale);
return g;
}
// ── function: makeEnemyMesh ──
function makeEnemyMesh() {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: 0x304028, metalness: 0.5, roughness: 0.4 });
const accent = new THREE.MeshStandardMaterial({ color: 0xc04020, metalness: 0.4, roughness: 0.45 });
const fus = new THREE.Mesh(new THREE.CapsuleGeometry(0.5, 3.5, 4, 8), mat);
fus.rotation.x = Math.PI / 2;
g.add(fus);
const wing = new THREE.Mesh(new THREE.BoxGeometry(6.0, 0.1, 1.6), mat);
g.add(wing);
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.1, 0.8), accent);
fin.position.set(0, 0.7, 1.6);
g.add(fin);
g.scale.setScalar(1.6);
return g;
}
// ── function: makeBullet ──
function makeBullet(isPlayer) {
const m = new THREE.Mesh(
new THREE.SphereGeometry(0.35, 6, 6),
new THREE.MeshBasicMaterial({ color: isPlayer ? 0xffee66 : 0xff4422 })
);
return m;
}
// ── function: makeExplosion ──
function makeExplosion(scene, pos) {
const parts = [];
for (let i = 0; i < 18; i++) {
const m = new THREE.Mesh(
new THREE.SphereGeometry(0.4 + Math.random() * 0.6, 5, 5),
new THREE.MeshBasicMaterial({ color: Math.random() > 0.5 ? 0xff6622 : 0xffcc44 })
);
m.position.copy(pos);
const v = new THREE.Vector3(
(Math.random() - 0.5) * 40,
Math.random() * 30,
(Math.random() - 0.5) * 40
);
scene.add(m);
parts.push({ m, v, life: 0.8 + Math.random() * 0.5 });
}
return parts;
}
// ── function: createCameraRig ──
function createCameraRig(camera) {
const smoothedPos = new THREE.Vector3();
const smoothedLook = new THREE.Vector3();
let init = false;
function update(ac, dt) {
const back = new THREE.Vector3(0, 0, 1).applyQuaternion(ac.quat);
const bodyUp = new THREE.Vector3(0, 1, 0).applyQuaternion(ac.quat);
const up = new THREE.Vector3(0, 1, 0).lerp(bodyUp, 0.35).normalize();
// Closer cinematic chase
const desired = ac.position.clone()
.addScaledVector(back, 11)
.addScaledVector(up, 3.2);
const minY = terrainHeight(desired.x, desired.z) + 1.8;
if (desired.y < minY) desired.y = minY;
const look = ac.position.clone()
.add(new THREE.Vector3(0, 0, -1).applyQuaternion(ac.quat).multiplyScalar(12))
.addScaledVector(up, 1.0);
if (!init) {
smoothedPos.copy(desired);
smoothedLook.copy(look);
init = true;
}
const k = 1 - Math.exp(-dt * 6.5);
smoothedPos.lerp(desired, k);
smoothedLook.lerp(look, k);
camera.position.copy(smoothedPos);
camera.lookAt(smoothedLook);
}
function reset() { init = false; }
return { update, reset };
}
// ── function: updateHUD ──
function updateHUD(ac) {
const spd = document.getElementById('hud-speed');
const alt = document.getElementById('hud-alt');
const thr = document.getElementById('hud-throttle');
const hdg = document.getElementById('hud-hdg');
const aoa = document.getElementById('hud-aoa');
const g = document.getElementById('hud-g');
const vs = document.getElementById('hud-vs');
const kts = Math.round(ac.airspeed * KTS);
const feet = Math.round(Math.max(0, ac.altitude) * FT);
const thrP = Math.round(ac.throttle * 100);
let headingDeg = (-ac.heading * 180 / Math.PI) % 360;
if (headingDeg < 0) headingDeg += 360;
spd.innerHTML = kts + ' <span>kts</span>';
alt.innerHTML = feet + ' <span>ft</span>';
thr.innerHTML = thrP + '<span>%</span>';
hdg.textContent = String(Math.round(headingDeg)).padStart(3, '0') + '°';
aoa.textContent = (ac.aoa * 180 / Math.PI).toFixed(1) + '°';
g.textContent = ac.gForce.toFixed(1);
vs.innerHTML = Math.round(ac.velocity.y * FT * 60) + ' <span>fpm</span>';
const e = new THREE.Euler().setFromQuaternion(ac.quat, 'YXZ');
const pitchDeg = e.x * 180 / Math.PI;
const rollDeg = -e.z * 180 / Math.PI;
const ball = document.getElementById('horizon-ball');
ball.style.transform = 'translateY(' + (pitchDeg * 1.1) + 'px) rotate(' + rollDeg + 'deg)';
const stall = document.getElementById('stall-warn');
if (ac.stalled && !ac.onGround) stall.classList.remove('hidden');
else stall.classList.add('hidden');
const stallSpeedApprox = Math.sqrt((2 * ac.spec.mass * G) / (RHO0 * ac.spec.wingArea * (ac.spec.Cl0 + ac.spec.ClAlpha * ac.spec.stallAoA * 0.7)));
if (ac.airspeed < stallSpeedApprox * 1.15 && !ac.onGround && ac.airspeed > 5) spd.parentElement.classList.add('hud-warn');
else spd.parentElement.classList.remove('hud-warn');
}
// ── function: populateMenu ──
function populateMenu() {
const aList = document.getElementById('aircraft-list');
const mList = document.getElementById('mission-list');
aList.innerHTML = '';
mList.innerHTML = '';
Object.values(AIRCRAFT).forEach((a) => {
const d = document.createElement('div');
d.className = 'card';
d.dataset.id = a.id;
d.innerHTML = '<div class="name">' + a.name + '</div><div class="sub">' + a.sub + '</div>' +
'<div class="stats">' +
'<span class="chip">Mass ' + (a.mass / 1000).toFixed(1) + 't</span>' +
'<span class="chip">Thrust ' + Math.round(a.thrustMax / 1000) + 'kN</span>' +
'<span class="chip">Vmax ' + Math.round(a.maxSpeed * KTS) + 'kts</span>' +
'</div>';
aList.appendChild(d);
});
Object.values(MISSIONS).forEach((m) => {
const d = document.createElement('div');
d.className = 'card';
d.dataset.id = m.id;
d.innerHTML = '<div class="name">' + m.name + '</div><div class="sub">' + m.sub + '</div>' +
'<div class="stats">' + m.chips.map((c) => '<span class="chip">' + c + '</span>').join('') + '</div>';
mList.appendChild(d);
});
}
// ── function: createTakeoffMission ──
function createTakeoffMission(game) {
const state = {
phase: 'hold',
timer: 0,
message: 'Throttle up (Shift) and take off. Climb to 1,500 ft.',
touchdowns: 0
};
function reset() {
state.phase = 'hold';
state.timer = 0;
state.message = 'Throttle up (Shift) and take off. Climb to 1,500 ft.';
state.touchdowns = 0;
game.ac.position.set(0, terrainHeight(0, 820) + 1.5, 820);
game.ac.velocity.set(0, 0, 0);
game.ac.quat.setFromEuler(new THREE.Euler(0, 0, 0, 'YXZ'));
game.ac.angularVel.set(0, 0, 0);
game.ac.gearDown = true;
game.ac.alive = true;
game.input.setThrottle(0.35);
}
function update(dt) {
const ac = game.ac;
const altFt = ac.altitude * FT;
const spd = ac.airspeed * KTS;
const onRunway = Math.abs(ac.position.x) < 55 && Math.abs(ac.position.z) < 1200;
if (!ac.alive) {
game.endMission(false, 'Hard landing / crash. Manage descent rate and flare.');
return;
}
if (state.phase === 'hold' || state.phase === 'roll') {
if (spd > 20) state.phase = 'roll';
if (!ac.onGround && altFt > 50) {
state.phase = 'climb';
state.message = 'Positive rate. Climb to 1,500 ft, then fly a circuit.';
}
if (ac.onGround && ac.position.z < -900 && spd < 8) {
game.endMission(false, 'Runway overrun. Rotate earlier with more speed/power.');
}
} else if (state.phase === 'climb') {
if (altFt > 1500) {
state.phase = 'pattern';
state.message = 'Pattern altitude. Enter downwind and land on the runway.';
state.timer = 0;
}
} else if (state.phase === 'pattern') {
state.timer += dt;
if (ac.onGround && onRunway && spd < 90) {
state.phase = 'landed';
const rate = Math.abs(ac.velocity.y);
if (rate < 2.5) {
game.endMission(true, 'Greased it! Touchdown ' + spd.toFixed(0) + ' kts, sink ' + (rate * FT).toFixed(0) + ' ft/s.');
} else if (rate < 6) {
game.endMission(true, 'Acceptable landing. Sink rate ' + (rate * FT).toFixed(0) + ' ft/s.');
} else {
game.endMission(false, 'Heavy landing destroyed the gear.');
}
}
if (state.timer > 320) {
game.endMission(false, 'Time expired. Complete the circuit sooner.');
}
}
document.getElementById('mission-banner').textContent = state.message +
' | ' + Math.round(altFt) + ' ft · ' + Math.round(spd) + ' kts';
}
return { reset, update, id: 'takeoff' };
}
// ── function: createDogfightMission ──
function createDogfightMission(game) {
const enemies = [];
const bullets = [];
let score = 0;
let spawnTimer = 0;
let fireCooldown = 0;
let wave = 0;
function spawnEnemy() {
const ac = game.ac;
const ang = Math.random() * Math.PI * 2;
const dist = 400 + Math.random() * 280;
const pos = ac.position.clone().add(new THREE.Vector3(
Math.cos(ang) * dist,
40 + Math.random() * 100,
Math.sin(ang) * dist
));
if (pos.y < 120) pos.y = 120 + Math.random() * 80;
const mesh = makeEnemyMesh();
mesh.position.copy(pos);
game.scene.add(mesh);
enemies.push({
mesh, pos,
vel: new THREE.Vector3((Math.random() - 0.5) * 30, 0, (Math.random() - 0.5) * 30),
hp: 3,
turn: 0.4 + Math.random() * 0.3,
speed: 50 + Math.random() * 30,
fireCd: 2 + Math.random() * 2,
alive: true
});
}
function reset() {
enemies.forEach((e) => game.scene.remove(e.mesh));
enemies.length = 0;
bullets.forEach((b) => game.scene.remove(b.mesh));
bullets.length = 0;
score = 0;
spawnTimer = 0;
fireCooldown = 0;
wave = 0;
document.getElementById('score').textContent = 'SCORE 0';
document.getElementById('score').classList.remove('hidden');
document.getElementById('crosshair').classList.remove('hidden');
game.ac.position.set(0, 420, 0);
game.ac.velocity.set(0, 0, -90);
game.ac.quat.setFromEuler(new THREE.Euler(0, 0, 0, 'YXZ'));
game.ac.angularVel.set(0, 0, 0);
game.ac.gearDown = false;
game.ac.alive = true;
game.ac._hits = 0;
game.input.setThrottle(0.7);
for (let i = 0; i < 3; i++) spawnEnemy();
document.getElementById('mission-banner').textContent =
'Engage hostiles. High-G turns bleed energy — watch airspeed.';
}
function fireBullet(origin, dir, isPlayer) {
const mesh = makeBullet(isPlayer);
mesh.position.copy(origin);
game.scene.add(mesh);
bullets.push({
mesh,
vel: dir.clone().normalize().multiplyScalar(isPlayer ? 450 : 300),
life: 2.0,
isPlayer
});
}
function update(dt) {
const ac = game.ac;
if (!ac.alive) {
game.endMission(false, 'Aircraft destroyed. Final score: ' + score);
return;
}
// Keep dogfight above ground death spiral-ish
if (ac.position.y < 30 && ac.velocity.y < -5) {
// soft floor boost to keep combat plane in fight
ac.velocity.y += 25 * dt;
}
fireCooldown = Math.max(0, fireCooldown - dt);
if (game.input.state.fire && fireCooldown <= 0) {
fireCooldown = 0.1;
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(ac.quat);
const origin = ac.position.clone().add(fwd.clone().multiplyScalar(8));
fireBullet(origin, fwd, true);
}
for (const e of enemies) {
if (!e.alive) continue;
const toPlayer = ac.position.clone().sub(e.pos);
const dist = toPlayer.length();
const desired = toPlayer.normalize().multiplyScalar(e.speed);
// Lead intercept soft
desired.add(ac.velocity.clone().multiplyScalar(0.15));
e.vel.lerp(desired, dt * e.turn);
if (e.pos.y < 100) e.vel.y += 25 * dt;
if (e.pos.y > 650) e.vel.y -= 18 * dt;
e.pos.addScaledVector(e.vel, dt);
e.mesh.position.copy(e.pos);
e.mesh.lookAt(e.pos.clone().add(e.vel));
e.fireCd -= dt;
if (e.fireCd <= 0 && dist < 380) {
const dir = ac.position.clone().sub(e.pos).normalize();
const aim = e.vel.clone().normalize();
if (dir.dot(aim) > 0.94 && dist > 40) {
fireBullet(e.pos.clone(), dir, false);
e.fireCd = 1.4 + Math.random() * 1.5;
} else {
e.fireCd = 0.4;
}
}
}
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.life -= dt;
b.mesh.position.addScaledVector(b.vel, dt);
if (b.life <= 0) {
game.scene.remove(b.mesh);
bullets.splice(i, 1);
continue;
}
if (b.isPlayer) {
for (const e of enemies) {
if (!e.alive) continue;
if (b.mesh.position.distanceTo(e.pos) < 7) {
e.hp--;
game.scene.remove(b.mesh);
bullets.splice(i, 1);
if (e.hp <= 0) {
e.alive = false;
game.scene.remove(e.mesh);
game.explosions.push(...makeExplosion(game.scene, e.pos.clone()));
score += 100;
document.getElementById('score').textContent = 'SCORE ' + score;
}
break;
}
}
} else if (b.mesh.position.distanceTo(ac.position) < 5.5) {
game.scene.remove(b.mesh);
bullets.splice(i, 1);
ac.velocity.add(new THREE.Vector3(
(Math.random() - 0.5) * 8,
(Math.random() - 0.5) * 6,
(Math.random() - 0.5) * 8
));
ac._hits = (ac._hits || 0) + 1;
if (ac._hits >= 6) {
ac.alive = false;
game.explosions.push(...makeExplosion(game.scene, ac.position.clone()));
}
}
}
spawnTimer += dt;
const alive = enemies.filter((e) => e.alive).length;
if (alive === 0) {
wave++;
if (wave >= 4) {
game.endMission(true, 'Airspace clear! Score: ' + score);
return;
}
for (let i = 0; i < 2 + wave; i++) spawnEnemy();
document.getElementById('mission-banner').textContent =
'Wave ' + (wave + 1) + ' inbound. Score ' + score;
} else if (spawnTimer > 28 && alive < 4) {
spawnTimer = 0;
spawnEnemy();
}
document.getElementById('mission-banner').textContent =
'Hostiles ' + alive + ' · Wave ' + (wave + 1) + '/4 · Score ' + score +
' | G ' + ac.gForce.toFixed(1) + ' · ' + Math.round(ac.airspeed * KTS) + ' kts · Hits ' +
(ac._hits || 0) + '/6';
}
function cleanup() {
enemies.forEach((e) => game.scene.remove(e.mesh));
enemies.length = 0;
bullets.forEach((b) => game.scene.remove(b.mesh));
bullets.length = 0;
document.getElementById('score').classList.add('hidden');
document.getElementById('crosshair').classList.add('hidden');
}
return { reset, update, cleanup, id: 'dogfight' };
}
// ── function: createSky ──
function createSky(scene) {
scene.background = new THREE.Color(0x87b8e0);
scene.fog = new THREE.Fog(0xa8cce8, 600, 4200);
const hemi = new THREE.HemisphereLight(0xd0e8ff, 0x5a4a28, 0.85);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2d8, 1.35);
sun.position.set(400, 800, 200);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 2500;
sun.shadow.camera.left = -600;
sun.shadow.camera.right = 600;
sun.shadow.camera.top = 600;
sun.shadow.camera.bottom = -600;
scene.add(sun);
const amb = new THREE.AmbientLight(0x406080, 0.35);
scene.add(amb);
// Gradient sky dome
const skyGeo = new THREE.SphereGeometry(4500, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(0x1a4a8a) },
midColor: { value: new THREE.Color(0x6eb0e0) },
botColor: { value: new THREE.Color(0xc8dce8) }
},
vertexShader: [
'varying vec3 vWPos;',
'void main(){',
' vec4 wp = modelMatrix * vec4(position,1.0);',
' vWPos = wp.xyz;',
' gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);',
'}'
].join('\n'),
fragmentShader: [
'uniform vec3 topColor, midColor, botColor;',
'varying vec3 vWPos;',
'void main(){',
' float h = normalize(vWPos).y;',
' vec3 col = mix(botColor, midColor, smoothstep(-0.15, 0.25, h));',
' col = mix(col, topColor, smoothstep(0.2, 0.9, h));',
' gl_FragColor = vec4(col, 1.0);',
'}'
].join('\n')
});
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
// Sun disc
const sunMesh = new THREE.Mesh(
new THREE.SphereGeometry(50, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xfff2c8 })
);
sunMesh.position.set(1200, 900, 600);
scene.add(sunMesh);
// Clouds
const cloudMat = new THREE.MeshStandardMaterial({
color: 0xffffff, transparent: true, opacity: 0.5, roughness: 1, metalness: 0, side: THREE.DoubleSide
});
for (let i = 0; i < 50; i++) {
const c = new THREE.Mesh(new THREE.PlaneGeometry(100 + Math.random() * 180, 35 + Math.random() * 50), cloudMat);
c.position.set(
(Math.random() - 0.5) * 5000,
300 + Math.random() * 400,
(Math.random() - 0.5) * 5000
);
c.rotation.y = Math.random() * Math.PI;
c.rotation.x = -Math.PI / 2 + (Math.random() - 0.5) * 0.25;
scene.add(c);
}
return { sun, sky };
}
// ── function: createGame ──
function createGame() {
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
powerPreference: 'high-performance',
preserveDrawingBuffer: true
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
const scene = new THREE.Scene();
createSky(scene);
const terrain = createTerrain(scene);
const camera = new THREE.PerspectiveCamera(68, window.innerWidth / window.innerHeight, 0.5, 8000);
const camRig = createCameraRig(camera);
const input = createInput();
const game = {
renderer, scene, camera, terrain, camRig, input,
ac: null,
planeMesh: null,
mission: null,
mode: 'menu',
selectedAircraft: null,
selectedMission: null,
explosions: [],
clock: new THREE.Clock(),
endMission: null
};
game.endMission = function (success, msg) {
if (game.mode !== 'fly') return;
game.mode = 'end';
document.getElementById('hud').classList.add('hidden');
document.getElementById('end').classList.remove('hidden');
document.getElementById('end-title').textContent = success ? 'MISSION COMPLETE' : 'MISSION FAILED';
document.getElementById('end-msg').textContent = msg;
if (game.mission && game.mission.cleanup) game.mission.cleanup();
};
function attachPlane(spec) {
if (game.planeMesh) {
scene.remove(game.planeMesh);
game.planeMesh = null;
}
game.planeMesh = makePlaneMesh(spec);
scene.add(game.planeMesh);
game.ac = createAircraftState(spec, new THREE.Vector3(0, 2, 800), 0);
}
function startFlight() {
if (!game.selectedAircraft || !game.selectedMission) return;
attachPlane(AIRCRAFT[game.selectedAircraft]);
if (game.mission && game.mission.cleanup) game.mission.cleanup();
if (game.selectedMission === 'takeoff') game.mission = createTakeoffMission(game);
else game.mission = createDogfightMission(game);
game.mission.reset();
camRig.reset();
game.mode = 'fly';
game.ac._hits = 0;
document.getElementById('overlay').classList.add('hidden');
document.getElementById('pause').classList.add('hidden');
document.getElementById('end').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');
game.clock.start();
}
function toMenu() {
game.mode = 'menu';
if (game.mission && game.mission.cleanup) game.mission.cleanup();
game.mission = null;
document.getElementById('hud').classList.add('hidden');
document.getElementById('pause').classList.add('hidden');
document.getElementById('end').classList.add('hidden');
document.getElementById('overlay').classList.remove('hidden');
document.getElementById('score').classList.add('hidden');
document.getElementById('crosshair').classList.add('hidden');
}
populateMenu();
const btnStart = document.getElementById('btn-start');
function refreshStart() {
btnStart.disabled = !(game.selectedAircraft && game.selectedMission);
}
document.getElementById('aircraft-list').addEventListener('click', (e) => {
const card = e.target.closest('.card');
if (!card) return;
document.querySelectorAll('#aircraft-list .card').forEach((c) => c.classList.remove('selected'));
card.classList.add('selected');
game.selectedAircraft = card.dataset.id;
refreshStart();
});
document.getElementById('mission-list').addEventListener('click', (e) => {
const card = e.target.closest('.card');
if (!card) return;
document.querySelectorAll('#mission-list .card').forEach((c) => c.classList.remove('selected'));
card.classList.add('selected');
game.selectedMission = card.dataset.id;
refreshStart();
});
btnStart.addEventListener('click', startFlight);
document.getElementById('btn-resume').addEventListener('click', () => {
game.mode = 'fly';
document.getElementById('pause').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');
});
document.getElementById('btn-menu').addEventListener('click', toMenu);
document.getElementById('btn-again').addEventListener('click', startFlight);
document.getElementById('btn-end-menu').addEventListener('click', toMenu);
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape') {
if (game.mode === 'fly') {
game.mode = 'pause';
document.getElementById('pause').classList.remove('hidden');
} else if (game.mode === 'pause') {
game.mode = 'fly';
document.getElementById('pause').classList.add('hidden');
}
}
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
camera.position.set(50, 22, 140);
camera.lookAt(0, 2, 0);
function tick() {
requestAnimationFrame(tick);
const rawDt = Math.min(0.05, game.clock.getDelta());
if (game.mode === 'menu') {
const t = performance.now() * 0.00015;
camera.position.x = Math.sin(t) * 100 + 30;
camera.position.y = 22 + Math.sin(t * 0.7) * 3;
camera.position.z = Math.cos(t) * 100 + 90;
camera.lookAt(0, 3, 0);
renderer.render(scene, camera);
return;
}
if (game.mode === 'fly' && game.ac) {
const inp = input.update(rawDt);
integrateFlight(game.ac, inp, terrain.heightAt, rawDt, {
autoGear: game.selectedMission === 'takeoff'
});
game.planeMesh.position.copy(game.ac.position);
game.planeMesh.quaternion.copy(game.ac.quat);
const prop = game.planeMesh.getObjectByName('prop');
if (prop) prop.rotation.z += game.ac.throttle * 40 * rawDt + game.ac.airspeed * 0.05 * rawDt;
const glow = game.planeMesh.getObjectByName('exhaustGlow');
if (glow) {
const t = game.ac.throttle;
glow.scale.setScalar(0.4 + t * 1.4);
glow.material.color.setHSL(0.55 - t * 0.45, 1, 0.45 + t * 0.25);
glow.visible = t > 0.05;
}
camRig.update(game.ac, rawDt);
updateHUD(game.ac);
if (game.mission) game.mission.update(rawDt);
for (let i = game.explosions.length - 1; i >= 0; i--) {
const p = game.explosions[i];
p.life -= rawDt;
p.m.position.addScaledVector(p.v, rawDt);
p.v.y -= 15 * rawDt;
p.m.scale.multiplyScalar(0.96);
if (p.life <= 0) {
scene.remove(p.m);
game.explosions.splice(i, 1);
}
}
}
renderer.render(scene, camera);
}
const firstA = document.querySelector('#aircraft-list .card');
const firstM = document.querySelector('#mission-list .card');
if (firstA) { firstA.classList.add('selected'); game.selectedAircraft = firstA.dataset.id; }
if (firstM) { firstM.classList.add('selected'); game.selectedMission = firstM.dataset.id; }
refreshStart();
tick();
return game;
}
// ── main ──
window.game = createGame();
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.5","provider":"openrouter","persona":"composer","sessionId":"f574a2fc-5636-419b-af27-ca845d4a603c","tokensIn":2350045,"tokensOut":60094,"tokensTotal":2410139,"turns":52,"toolCalls":68,"failedToolCalls":1,"timestamp":"2026-07-17T17:26:57.324Z"} -->