Flight Simulator
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · de5cb62838
This prompt is designed to be given to an AI model or a development team to establish a clear, high-fidelity goal for building the simulator. It emphasizes technical constraints and the critical importance of realistic physics. *** # 🚀 Three.js Flight Simulator Prompt: Apex Aero ## 🎯 Project Goal Design and develop a high-fidelity, physically accurate 3D flight simulator implemented entirely in HTML using **Three.js** (or a compatible framework like Babylon.js) and pure JavaScript/TypeScript. The core experience must prioritize realistic aerodynamics, inertial forces, and challenging gameplay across diverse scenarios. ## 🛠️ Technical Stack & Core Requirements 1. **Engine:** Three.js (mandatory for all rendering and scene management). 2. **Language:** JavaScript/TypeScript. 3. **Physics Engine:** Implement a custom, highly detailed Newtonian physics model that simulates flight dynamics realistically (not just simple linear movement). 4. **Input:** Mouse/Keyboard controls for pitch, roll, yaw, throttle, and thrust management. ## ✨ Feature Breakdown ### 1. Aircraft Selection & Differentiation (The Fleet) Implement a minimum of **three distinct aircraft models**. These planes must possess mathematically differentiated attributes that directly affect flight physics: * **Attribute Examples:** Max Speed, Thrust/Engine Power, Lift Coefficient, Drag Profile, Turning Radius, Weight, and Maneuverability Response. * **Visuals:** Each plane must have a unique, high-quality 3D model. ### 2. Terrain Generation (The World) * **Procedural Generation:** The environment must be procedurally generated to create vast, varied, and visually stunning landscapes (mountains, valleys, coastlines). * **Detail:** Implement complex geometry with accurate heightmaps and texture mapping to ensure realistic visual scale and topographical awareness for the pilot. * **Collision Detection:** Terrain interaction must accurately reflect ground friction and altitude constraints. ### 3. Gameplay Scenarios (The Missions) The game must include two primary, distinct gameplay modes: #### A. Takeoff/Landing Scenario (Precision Flight) * Focus on managing altitude, airspeed, and engine power precisely. * Requires accurate simulation of lift-off physics, climb rates, descent control, stall limits, and runway requirements based on the chosen aircraft's weight and performance characteristics. #### B. Dogfight Scenario (High-Speed Maneuvering) * Focus on high-G maneuvering, managing speed, angle of attack, and inertia in a dynamic combat environment. * Requires accurate simulation of slipstream effects, banking dynamics, and the physical limits of the aircraft during aggressive turns and evasive maneuvers. ## 🔬 Physics & Fidelity (The Critical Focus) **This is the single most important requirement.** The game must feel *physically accurate* and challenging. 1. **Aerodynamics:** Implement realistic drag calculation (profile drag vs. induced drag), lift generation based on angle of attack, and realistic air density effects based on altitude. 2. **Inertia & Momentum:** Aircraft movement must account for inertia. Sudden changes in direction or thrust must result in realistic lag, pitch/roll response time, and momentum carry-over. 3. **Engine Response:** Throttle input must accurately translate to force, RPM changes, engine heat/stress (optional visual feedback), and realistic acceleration rates based on the plane's power-to-weight ratio. 4. **Atmospheric Effects:** Altitude must affect air density and performance; this effect must be consistent across all scenarios. ## 🎧 Audio Immersion Sound design is mandatory for full immersion. The audio experience must be dynamic and directly linked to the physics calculations. * **Engine Sound:** Engine noise, pitch, and volume must change realistically based on throttle input, RPM, acceleration, and altitude (accounting for air density). * **Aerodynamic Sounds:** Implement realistic sound effects for airflow over the wings and control surfaces (e.g., whistling or roaring based on speed/angle of attack). * **Environmental Audio:** Include dynamic ambient sounds that reflect the environment (e.g., wind noise increasing at higher altitudes, distant rumble of mountains). ## 🎨 Aesthetic & User Experience * **Visual Style:** Cinematic, high-definition realism. Use modern lighting techniques (PBR materials) to enhance the visual beauty of the procedural terrain. * **UI/HUD:** The Heads-Up Display (HUD) must clearly display critical flight data (Altitude, Airspeed, Pitch, Roll, Engine Status) in a clean, intuitive format. ## ✅ Deliverables Summary The result should be an interactive application demonstrating: 1. A fully functional Three.js scene with procedurally generated terrain. 2. Three selectable aircraft with distinct physics profiles. 3. Functional controls for realistic flight dynamics. 4. Working Takeoff/Landing and Dogfight scenarios, demonstrating the accurate interaction of the defined physics models.
Inspect original source 83,417 bytes · SHA-256 225663d5c8f3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Apex Aero — Flight Simulator</title>
<style>
:root { --hud: #9dffb0; --hud-dim: rgba(157,255,176,0.45); --warn: #ff5a4a; --panel: rgba(8,14,20,0.72); }
* { box-sizing: border-box; }
html, body { margin:0; height:100%; overflow:hidden; background:#000; font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color:#e8f1f7; user-select:none; }
canvas#gl { position:fixed; inset:0; width:100%; height:100%; display:block; }
/* ---------- MENU ---------- */
#menu { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background: radial-gradient(ellipse at center, rgba(10,20,35,0.55), rgba(0,0,0,0.85)); z-index:20; overflow:auto; }
#menu.hidden, #pause.hidden, #result.hidden { display:none; }
.panel { background: var(--panel); border:1px solid rgba(255,255,255,0.12); border-radius:14px; padding:28px 34px; max-width:980px; width:92%; backdrop-filter: blur(8px); box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
h1 { margin:0 0 4px; font-size:34px; letter-spacing:4px; font-weight:300; }
h1 span { color:#ffb13b; font-weight:700; }
.sub { color:#9fb3c4; margin:0 0 22px; font-size:14px; }
h2 { font-size:12px; letter-spacing:3px; text-transform:uppercase; color:#8fa7ba; margin:20px 0 10px; font-weight:600; }
.cards { display:grid; grid-template-columns: repeat(3,1fr); gap:12px; }
.card { background: rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); border-radius:10px; padding:14px; cursor:pointer; transition: all .15s; }
.card:hover { background: rgba(255,255,255,0.08); }
.card.sel { border-color:#ffb13b; box-shadow: 0 0 0 1px #ffb13b inset; background: rgba(255,177,59,0.08); }
.card h3 { margin:0 0 4px; font-size:16px; }
.card .role { font-size:11px; text-transform:uppercase; letter-spacing:2px; color:#ffb13b; }
.card p { font-size:12.5px; color:#b6c6d3; margin:8px 0; line-height:1.4; }
.stats { display:grid; grid-template-columns:1fr 1fr; gap:3px 10px; font-size:11.5px; color:#9fb3c4; }
.stats b { color:#e8f1f7; font-weight:500; }
.bars { margin-top:8px; }
.bar-row { display:flex; align-items:center; gap:6px; font-size:10px; color:#8fa7ba; }
.bar-row .lbl { width:58px; }
.bar-row .track { flex:1; height:5px; background:rgba(255,255,255,0.1); border-radius:3px; overflow:hidden; }
.bar-row .fill { height:100%; background:linear-gradient(90deg,#ffb13b,#ff6b3b); }
.missions { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.mission { border:1px solid rgba(255,255,255,0.1); border-radius:10px; padding:14px; cursor:pointer; background: rgba(255,255,255,0.03); }
.mission.sel { border-color:#5ec8ff; background: rgba(94,200,255,0.08); }
.mission h3 { margin:0 0 6px; font-size:15px; color:#5ec8ff; }
.mission p { margin:0; font-size:12.5px; color:#b6c6d3; line-height:1.45; }
.row { display:flex; gap:12px; align-items:center; justify-content:space-between; margin-top:24px; flex-wrap:wrap; }
button { font: inherit; font-size:14px; padding:11px 22px; border-radius:8px; border:0; cursor:pointer; color:#1b1202; background:#ffb13b; font-weight:600; letter-spacing:.5px; }
button:hover { filter:brightness(1.08); }
button.ghost { background: transparent; color:#e8f1f7; border:1px solid rgba(255,255,255,0.25); font-weight:500; }
.controls { font-size:12px; color:#9fb3c4; line-height:1.7; columns:2; }
.controls kbd { display:inline-block; min-width:22px; padding:1px 6px; border:1px solid rgba(255,255,255,0.3); border-bottom-width:2px; border-radius:4px; font-family: monospace; color:#e8f1f7; font-size:11px; text-align:center; }
/* ---------- HUD ---------- */
#hud { position:fixed; inset:0; pointer-events:none; z-index:10; font-family: "Consolas", "Menlo", "Courier New", monospace; color:var(--hud); text-shadow: 0 0 4px rgba(0,0,0,0.9); }
#hud.hidden { display:none; }
.tape { position:absolute; top:50%; transform:translateY(-50%); width:150px; height:260px; border:1px solid var(--hud-dim); background: rgba(0,10,5,0.35); overflow:hidden; }
#spdTape { left:24px; }
#altTape { right:24px; }
.tape .lbl { position:absolute; top:4px; left:0; right:0; text-align:center; font-size:11px; letter-spacing:2px; color:var(--hud-dim); }
.tape .val { position:absolute; top:50%; left:0; right:0; transform:translateY(-50%); text-align:center; font-size:26px; font-weight:700; background: rgba(0,0,0,0.45); padding:4px 0; border-top:1px solid var(--hud-dim); border-bottom:1px solid var(--hud-dim); }
.tape .sub2 { position:absolute; bottom:6px; left:0; right:0; text-align:center; font-size:12px; color:var(--hud); }
.tape .ticks { position:absolute; top:0; bottom:0; width:100%; font-size:11px; color:var(--hud-dim); }
.tape .ticks div { position:absolute; left:0; right:0; text-align:center; }
#spdTape .ticks { right:0; }
#altTape .ticks { right:0; }
.gauge { position:absolute; left:24px; bottom:24px; width:200px; font-size:12px; }
.gauge .r { display:flex; justify-content:space-between; margin-bottom:3px; }
.gbar { height:8px; border:1px solid var(--hud-dim); position:relative; margin-bottom:10px; }
.gbar .f { position:absolute; left:0; top:0; bottom:0; background:var(--hud); width:0%; }
#rightInfo { position:absolute; right:24px; bottom:24px; width:200px; font-size:12px; text-align:right; }
#rightInfo .r { margin-bottom:4px; }
#horizonWrap { position:absolute; left:50%; top:50%; width:260px; height:260px; transform:translate(-50%,-50%); }
#horizonBox { position:absolute; inset:0; overflow:hidden; border-radius:50%; }
#horizonInner { position:absolute; left:-50%; top:-50%; width:200%; height:200%; transform-origin:50% 50%; }
#sky { position:absolute; left:0; right:0; top:0; height:50%; background: linear-gradient(rgba(60,120,200,0.0), rgba(60,120,200,0.0)); }
#pitchLadder { position:absolute; left:0; right:0; top:50%; height:0; }
#pitchLadder .rung { position:absolute; left:50%; transform:translateX(-50%); width:120px; text-align:center; font-size:11px; color:var(--hud); }
#pitchLadder .rung .ln { height:1px; background:var(--hud); margin:0 auto; width:70px; }
#bankArc { position:absolute; left:50%; top:-6px; width:160px; height:80px; transform:translateX(-50%); }
#aimCross { position:absolute; left:50%; top:50%; width:40px; height:40px; transform:translate(-50%,-50%); }
#aimCross::before, #aimCross::after { content:""; position:absolute; background:var(--hud); }
#aimCross::before { left:50%; top:0; width:1px; height:14px; transform:translateX(-50%); box-shadow:0 26px 0 var(--hud); }
#aimCross::after { top:50%; left:0; height:1px; width:14px; transform:translateY(-50%); box-shadow: 26px 0 0 var(--hud); }
#flightPath { position:absolute; width:18px; height:18px; border:1px solid var(--hud); border-radius:50%; transform:translate(-50%,-50%); }
#flightPath::before, #flightPath::after { content:""; position:absolute; background:var(--hud); }
#flightPath::before { left:-10px; top:8px; width:8px; height:1px; }
#flightPath::after { right:-10px; top:8px; width:8px; height:1px; }
#hdgStrip { position:absolute; top:14px; left:50%; transform:translateX(-50%); width:420px; height:36px; border:1px solid var(--hud-dim); background: rgba(0,10,5,0.35); overflow:hidden; }
#hdgStrip .ticks { position:absolute; top:0; bottom:0; left:0; right:0; }
#hdgStrip .ticks span { position:absolute; top:6px; font-size:11px; transform:translateX(-50%); color:var(--hud); }
#hdgStrip .ticks i { position:absolute; bottom:0; width:1px; height:6px; background:var(--hud-dim); }
#hdgStrip .cur { position:absolute; left:50%; top:0; bottom:0; width:1px; background:#ffe06b; }
#hdgVal { position:absolute; top:52px; left:50%; transform:translateX(-50%); font-size:18px; font-weight:700; }
#vsVal { position:absolute; }
#warn { position:absolute; top:28%; left:50%; transform:translateX(-50%); font-size:30px; font-weight:800; color:var(--warn); letter-spacing:4px; display:none; animation: blink .4s steps(2,start) infinite; }
#msg { position:absolute; bottom:80px; left:50%; transform:translateX(-50%); font-size:16px; text-align:center; color:#fff; min-width:400px; letter-spacing:1px; }
#obj { position:absolute; top:70px; left:24px; font-size:13px; line-height:1.6; max-width:420px; }
#obj .t { color:#ffe06b; letter-spacing:2px; font-size:11px; }
#score { position:absolute; top:70px; right:24px; text-align:right; font-size:13px; line-height:1.6; }
#throttleBox { position:absolute; left:50%; bottom:20px; transform:translateX(-50%); width:320px; font-size:11px; }
#throttleBox .track { height:6px; border:1px solid var(--hud-dim); position:relative; }
#throttleBox .fill { position:absolute; left:0; top:0; bottom:0; background:var(--hud); width:0; }
#throttleBox .lbl { display:flex; justify-content:space-between; margin-bottom:3px; }
#blackout { position:fixed; inset:0; pointer-events:none; z-index:9; background: radial-gradient(ellipse at center, rgba(0,0,0,0) 30%, rgba(0,0,0,1) 85%); opacity:0; transition: opacity .08s linear; }
#redout { position:fixed; inset:0; pointer-events:none; z-index:8; background: radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(255,40,40,0.7) 100%); opacity:0; }
#damage { position:fixed; inset:0; pointer-events:none; z-index:8; background: rgba(255,0,0,0.0); transition: background .25s; }
.tgt { position:absolute; width:46px; height:46px; border:1px solid #ff5a4a; transform:translate(-50%,-50%); display:none; }
.tgt .tl { position:absolute; left:-2px; top:-18px; font-size:10px; white-space:nowrap; color:#ff8a7a; }
@keyframes blink { 50% { opacity:0; } }
/* ---------- PAUSE ---------- */
#pause { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.5); z-index:25; }
#result { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.55); z-index:26; }
#result .panel { text-align:center; }
#result h1 { font-size:28px; }
#result .big { font-size:54px; font-weight:700; color:#ffb13b; margin:10px 0; }
#result .lines { text-align:left; display:inline-block; font-size:14px; color:#b6c6d3; line-height:1.8; }
#startHint { position:fixed; left:50%; bottom:14px; transform:translateX(-50%); z-index:5; font-size:11px; color:#8fa7ba; }
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div id="menu">
<div class="panel">
<h1>APEX <span>AERO</span></h1>
<p class="sub">Newtonian flight model · altitude-dependent air density · lift, induced drag and stall physics</p>
<h2>Select Aircraft</h2>
<div class="cards" id="planeCards"></div>
<h2>Select Mission</h2>
<div class="missions" id="missionCards">
<div class="mission sel" data-mission="takeoff">
<h3>Precision Flight — Takeoff & Landing</h3>
<p>Start on the runway. Manage throttle, rotation speed and climb to 1,000 m. Circle back and touch down on the centerline with a gentle sink rate, within the touchdown zone, before the aircraft stops.</p>
</div>
<div class="mission" data-mission="dogfight">
<h3>Dogfight — High-G Maneuvering</h3>
<p>Hostile fighters are vectoring in at 1–2 km. Use energy, turn rate and gun solutions to shoot them down. Exceed the airframe's G limit and the aircraft breaks apart. Blackout and red-out effects follow hard turns.</p>
</div>
</div>
<div class="row">
<div class="controls">
<div><kbd>W</kbd><kbd>S</kbd> pitch (nose down / up)</div>
<div><kbd>A</kbd><kbd>D</kbd> roll left / right</div>
<div><kbd>Q</kbd><kbd>E</kbd> rudder (yaw)</div>
<div><kbd>Shift</kbd> / <kbd>Ctrl</kbd> throttle up / down</div>
<div><kbd>X</kbd> idle · <kbd>Z</kbd> full throttle</div>
<div><kbd>F</kbd> flaps · <kbd>G</kbd> gear · <kbd>B</kbd> brakes</div>
<div><kbd>Space</kbd> gun (dogfight) · <kbd>C</kbd> camera</div>
<div><kbd>M</kbd> mouse stick · <kbd>P</kbd> / <kbd>Esc</kbd> pause · <kbd>R</kbd> restart</div>
</div>
<button id="startBtn">START FLIGHT</button>
</div>
</div>
</div>
<div id="hud" class="hidden">
<div id="hdgStrip"><div class="ticks" id="hdgTicks"></div><div class="cur"></div></div>
<div id="hdgVal">000°</div>
<div class="tape" id="spdTape"><div class="lbl">AIRSPEED KT</div><div class="ticks" id="spdTicks"></div><div class="val" id="spdVal">0</div><div class="sub2" id="machVal">M 0.00</div></div>
<div class="tape" id="altTape"><div class="lbl">ALTITUDE FT</div><div class="ticks" id="altTicks"></div><div class="val" id="altVal">0</div><div class="sub2" id="vsVal">VS 0</div></div>
<div id="horizonWrap">
<div id="horizonBox"><div id="horizonInner"><div id="sky"></div><div id="pitchLadder"></div></div></div>
<div id="flightPath" style="left:50%; top:50%;"></div>
<div id="aimCross" style="left:50%; top:50%; opacity:0.6;"></div>
</div>
<div class="gauge" id="gaugeL">
<div class="r"><span>ENGINE RPM</span><span id="rpmVal">0%</span></div>
<div class="gbar"><div class="f" id="rpmBar"></div></div>
<div class="r"><span>EGT</span><span id="tempVal">0%</span></div>
<div class="gbar"><div class="f" id="tempBar"></div></div>
<div class="r"><span>AoA</span><span id="aoaVal">0.0°</span></div>
<div class="gbar"><div class="f" id="aoaBar"></div></div>
<div class="r"><span>FLAPS / GEAR</span><span id="cfgVal">UP / DN</span></div>
<div class="r"><span>DAMAGE</span><span id="dmgVal">0%</span></div>
</div>
<div id="rightInfo">
<div class="r">LOAD <b id="gVal">1.0 G</b></div>
<div class="r">BANK <span id="bankVal">0°</span></div>
<div class="r">PITCH <span id="pitchVal">0°</span></div>
<div class="r">SLIP <span id="slipVal">0</span></div>
<div class="r">DENSITY <span id="densVal">100%</span></div>
<div class="r">AIR TEMP <span id="tmpVal">15°C</span></div>
</div>
<div id="obj"></div>
<div id="score"></div>
<div id="warn">STALL</div>
<div id="msg"></div>
<div id="throttleBox">
<div class="lbl"><span>THROTTLE</span><span id="thrVal">0%</span></div>
<div class="track"><div class="fill" id="thrBar"></div></div>
</div>
<div class="tgt" id="tgt"><div class="tl" id="tgtLbl"></div></div>
</div>
<div id="blackout"></div>
<div id="redout"></div>
<div id="damage"></div>
<div id="pause" class="hidden">
<div class="panel" style="text-align:center; width:340px;">
<h1 style="font-size:24px;">PAUSED</h1>
<div class="row" style="flex-direction:column;">
<button id="resumeBtn" style="width:100%;">RESUME</button>
<button id="restartBtn" class="ghost" style="width:100%;">RESTART MISSION</button>
<button id="menuBtn" class="ghost" style="width:100%;">AIRCRAFT / MISSION MENU</button>
</div>
</div>
</div>
<div id="result" class="hidden">
<div class="panel" id="resultPanel"></div>
</div>
<div id="startHint">Click START FLIGHT · Mouse stick: press M after takeoff · Audio starts with the first click</div>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
// ============================================================
// CONSTANTS & UTILITIES
// ============================================================
const G = 9.80665;
const RHO0 = 1.225;
const T0 = 288.15;
const KT = 1.94384; // m/s -> knots
const FT = 3.28084; // m -> ft
const DEG = Math.PI / 180;
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };
const rand = (a, b) => a + Math.random() * (b - a);
// Runway geometry (world): centerline along -Z, threshold at z=+250, far end z=-1850
const RWY = { x0: -30, x1: 30, zNear: 300, zFar: -1900, elev: 2.0 };
const WORLD_RADIUS = 7600;
// ============================================================
// FLEET — mathematically differentiated aircraft
// ============================================================
// mass kg, S wing area m², AR aspect ratio, CL0 zero-alpha lift, CLa lift slope /rad,
// aStall stall AoA rad, CD0 parasite drag, e Oswald efficiency, Tmax max thrust N,
// pitchPow/pitchStab/pitchDamp: pitch authority, static stability, damping
// rollPow/rollDamp, yawPow/yawStab/yawDamp, maxRate [pitch,roll,yaw] rad/s,
// spool: engine spool time constant s, maxG: structural limit, gearH: gear clearance m
const FLEET = {
skylark: {
name: 'Skylark T-40', role: 'Trainer', key: 'skylark',
desc: 'Forgiving high-wing trainer. Low stall speed and generous control authority, but limited power.',
mass: 1100, S: 16, AR: 7, CL0: 0.30, CLa: 5.0, aStall: 0.28, CD0: 0.045, e: 0.80,
Tmax: 4200, gearCD: 0.012, flapCL: 0.35, flapCD: 0.03,
pitchPow: 2.2, pitchStab: 2.6, pitchDamp: 1.4, rollPow: 3.0, rollDamp: 2.2,
yawPow: 1.1, yawStab: 1.4, yawDamp: 1.2, maxRate: [1.2, 2.2, 1.0],
spool: 1.5, maxG: 5.0, gearH: 1.2, color: 0xf2c230, accent: 0x222831, span: 11,
stats: [['Max speed', '~185 kt'], ['Stall', '~50 kt'], ['Thrust/Weight', '0.39'], ['Empty mass', '1.1 t']],
bars: { Speed: 0.35, Climb: 0.45, Agility: 0.6, Stall: 0.95 }
},
falcon: {
name: 'Falcon F-9', role: 'Interceptor', key: 'falcon',
desc: 'Swept-delta fighter. Excellent turn rate and high-G tolerance; needs energy management.',
mass: 8000, S: 30, AR: 3.5, CL0: 0.20, CLa: 4.4, aStall: 0.30, CD0: 0.024, e: 0.75,
Tmax: 52000, gearCD: 0.015, flapCL: 0.45, flapCD: 0.035,
pitchPow: 3.6, pitchStab: 3.2, pitchDamp: 2.0, rollPow: 5.2, rollDamp: 3.6,
yawPow: 1.8, yawStab: 2.4, yawDamp: 1.8, maxRate: [1.8, 3.4, 1.6],
spool: 1.0, maxG: 9.0, gearH: 1.4, color: 0x8a949e, accent: 0x3d4650, span: 14,
stats: [['Max speed', '~610 kt'], ['Stall', '~70 kt'], ['Thrust/Weight', '0.66'], ['Empty mass', '8.0 t']],
bars: { Speed: 0.8, Climb: 0.7, Agility: 0.9, Stall: 0.5 }
},
raptor: {
name: 'Raptor X-H', role: 'Heavy Jet', key: 'raptor',
desc: 'Heavy strike jet. Enormous thrust and a dependable wing, but sluggish in roll and heavy in the turn.',
mass: 20000, S: 60, AR: 4.0, CL0: 0.25, CLa: 4.8, aStall: 0.26, CD0: 0.028, e: 0.80,
Tmax: 190000, gearCD: 0.02, flapCL: 0.55, flapCD: 0.05,
pitchPow: 2.4, pitchStab: 2.8, pitchDamp: 1.8, rollPow: 2.4, rollDamp: 2.4,
yawPow: 1.4, yawStab: 2.0, yawDamp: 1.6, maxRate: [0.9, 1.6, 0.9],
spool: 2.4, maxG: 6.5, gearH: 1.6, color: 0x2e3b2c, accent: 0x6b5c3e, span: 22,
stats: [['Max speed', '~800 kt'], ['Stall', '~115 kt'], ['Thrust/Weight', '0.97'], ['Empty mass', '20 t']],
bars: { Speed: 0.95, Climb: 0.6, Agility: 0.35, Stall: 0.8 }
}
};
// Enemy model: falcon with red livery
const ENEMY_KEY = 'falcon';
// ============================================================
// NOISE & TERRAIN
// ============================================================
function hash2(ix, iz) {
let n = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263)) | 0;
n = Math.imul(n ^ (n >>> 13), 1274126177);
return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
}
function vnoise(x, z) {
const xi = Math.floor(x), zi = Math.floor(z);
const fx = x - xi, fz = z - zi;
const u = fx * fx * (3 - 2 * fx), v = fz * fz * (3 - 2 * fz);
const a = hash2(xi, zi), b = hash2(xi + 1, zi), c = hash2(xi, zi + 1), d = hash2(xi + 1, zi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm(x, z, oct) {
let s = 0, a = 0.5, f = 1, norm = 0;
for (let i = 0; i < oct; i++) { s += a * vnoise(x * f, z * f); norm += a; a *= 0.5; f *= 2.03; }
return s / norm;
}
// Procedural heightfield (meters). Runway flattened, mountains suppressed near field.
const TERRAIN_HALF = 8000;
function terrainH(x, z) {
const r = Math.hypot(x, z);
const c = fbm(x * 0.00022 + 11, z * 0.00022 + 7, 4);
let base = (c - 0.44) * 900;
base = lerp(35, base, smooth(1200, 2800, r));
const mtnMask = smooth(2600, 4300, r) * smooth(0.42, 0.62, fbm(x * 0.00035 + 3, z * 0.00035 + 5, 3));
const rn = fbm(x * 0.00085, z * 0.00085, 5);
const ridge = 1 - Math.abs(rn * 2 - 1);
const mtn = mtnMask * ridge * ridge * 1500;
const detail = (fbm(x * 0.006, z * 0.006, 3) - 0.5) * 35;
let h = base + mtn + detail;
// Runway flattening (x in [-30,30], z in [-1950, 330])
const dx = Math.max(0, Math.abs(x) - 45);
const dz = Math.max(0, z - 380, -2000 - z);
const d = Math.hypot(dx, dz);
const w = 1 - smooth(10, 120, d);
h = lerp(h, RWY.elev, w);
return h;
}
function buildTerrain() {
const size = TERRAIN_HALF * 2, seg = 320;
const geo = new THREE.PlaneGeometry(size, size, seg, seg);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setY(i, terrainH(pos.getX(i), pos.getZ(i)));
}
geo.computeVertexNormals();
const nrm = geo.attributes.normal;
const col = new Float32Array(pos.count * 3);
const cSand = [0.80, 0.74, 0.52], cGrass = [0.33, 0.52, 0.22], cForest = [0.17, 0.34, 0.14];
const cRock = [0.45, 0.41, 0.36], cSnow = [0.95, 0.96, 0.98], cDirt = [0.42, 0.36, 0.26];
const mix = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
for (let i = 0; i < pos.count; i++) {
const y = pos.getY(i), slope = 1 - nrm.getY(i);
let c = y < 4 ? cSand : mix(cSand, cGrass, smooth(4, 14, y));
c = mix(c, cForest, smooth(60, 260, y) * 0.6 * (1 - smooth(0.25, 0.5, slope)));
c = mix(c, cRock, smooth(0.22, 0.45, slope));
c = mix(c, cDirt, smooth(420, 620, y) * 0.4 * (1 - smooth(0.2, 0.4, slope)));
c = mix(c, cRock, smooth(700, 900, y) * 0.7);
c = mix(c, cSnow, smooth(1100, 1400, y) * (0.5 + 0.5 * (1 - slope)));
col[i * 3] = c[0]; col[i * 3 + 1] = c[1]; col[i * 3 + 2] = c[2];
}
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92, metalness: 0.0, flatShading: false });
const mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = false;
scene.add(mesh);
// Sea
const seaGeo = new THREE.PlaneGeometry(size * 1.2, size * 1.2);
seaGeo.rotateX(-Math.PI / 2);
const sea = new THREE.Mesh(seaGeo, new THREE.MeshStandardMaterial({ color: 0x1d5f80, roughness: 0.12, metalness: 0.1, transparent: true, opacity: 0.92 }));
sea.position.y = -0.5;
scene.add(sea);
return mesh;
}
// Runway with markings
function buildRunway() {
const cv = document.createElement('canvas');
cv.width = 128; cv.height = 1024;
const g = cv.getContext('2d');
g.fillStyle = '#3a3d40'; g.fillRect(0, 0, 128, 1024);
g.fillStyle = '#e9e9e9';
// Centerline dashes (texture runs from far end (top) to threshold (bottom))
for (let y = 0; y < 1024; y += 64) g.fillRect(62, y + 8, 4, 36);
// Threshold stripes near threshold (bottom)
for (let x = 10; x < 128; x += 20) g.fillRect(x, 930, 10, 80);
// Edge lines
g.fillRect(4, 0, 3, 1024); g.fillRect(121, 0, 3, 1024);
// Touchdown zone aiming bars
for (let i = 0; i < 3; i++) { g.fillRect(24, 740 - i * 40, 22, 14); g.fillRect(82, 740 - i * 40, 22, 14); }
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
const len = RWY.zNear - RWY.zFar; // 2250
const geo = new THREE.PlaneGeometry(60, len);
geo.rotateX(-Math.PI / 2);
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ map: tex, roughness: 0.75, metalness: 0 }));
mesh.position.set(0, RWY.elev + 0.12, (RWY.zNear + RWY.zFar) / 2);
scene.add(mesh);
}
// ============================================================
// RENDERER / SCENE / SKY / CLOUDS
// ============================================================
const canvas = document.getElementById('gl');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
const HORIZON = new THREE.Color(0xbfd9ec);
scene.fog = new THREE.Fog(HORIZON, 3500, 15000);
const camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 0.5, 30000);
// Sky dome gradient
{
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: { top: { value: new THREE.Color(0x2c6fbf) }, hor: { value: HORIZON }, sunDir: { value: new THREE.Vector3(0.4, 0.5, -0.5).normalize() } },
vertexShader: `varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `uniform vec3 top; uniform vec3 hor; uniform vec3 sunDir; varying vec3 vDir;
void main(){ float h = clamp(vDir.y, 0.0, 1.0);
vec3 c = mix(hor, top, pow(h, 0.55));
float s = max(dot(normalize(vDir), normalize(sunDir)), 0.0);
c += vec3(1.0,0.85,0.6) * pow(s, 220.0) * 1.2 + vec3(1.0,0.8,0.5)*pow(s,8.0)*0.10;
gl_FragColor = vec4(c,1.0); }`
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(20000, 32, 16), skyMat));
}
// Lights (PBR-friendly)
const hemi = new THREE.HemisphereLight(0xcfe8ff, 0x3a4a2a, 1.1);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff1dc, 2.6);
sun.position.set(4000, 6000, -3000);
scene.add(sun);
// Soft radial texture for cloud puffs
function makePuffTexture() {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d');
const grd = g.createRadialGradient(64, 64, 4, 64, 64, 64);
grd.addColorStop(0, 'rgba(255,255,255,0.95)');
grd.addColorStop(0.5, 'rgba(255,255,255,0.45)');
grd.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grd; g.fillRect(0, 0, 128, 128);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
const clouds = [];
function buildClouds() {
const tex = makePuffTexture();
for (let i = 0; i < 110; i++) {
const grp = new THREE.Group();
const n = 4 + Math.floor(Math.random() * 4);
const scale = rand(260, 620);
for (let k = 0; k < n; k++) {
const m = new THREE.SpriteMaterial({ map: tex, color: 0xffffff, transparent: true, opacity: rand(0.55, 0.9), depthWrite: false, fog: true });
const s = new THREE.Sprite(m);
s.position.set(rand(-1, 1) * scale, rand(-0.2, 0.25) * scale, rand(-1, 1) * scale * 0.6);
s.scale.setScalar(scale * rand(0.7, 1.1));
grp.add(s);
}
const a = Math.random() * Math.PI * 2, d = rand(1200, 7000);
grp.position.set(Math.cos(a) * d, rand(650, 1500), Math.sin(a) * d);
scene.add(grp);
clouds.push(grp);
}
}
// Background build
const terrainMesh = buildTerrain();
buildRunway();
buildClouds();
// Wake of the terrain; smoke/dust particles & tracers
const fxGroup = new THREE.Group();
scene.add(fxGroup);
// ============================================================
// AIRCRAFT MODELS (procedural Three.js meshes, nose = +X, up = +Y, right = +Z)
// ============================================================
function fuselageGeo(L, R) {
const pts = [];
const N = 30;
for (let i = 0; i <= N; i++) {
const t = i / N;
const r = R * Math.pow(Math.sin(Math.PI * Math.pow(t, 0.8)), 0.55);
pts.push(new THREE.Vector2(Math.max(r, 0.001), -L / 2 + L * t));
}
const g = new THREE.LatheGeometry(pts, 28);
g.rotateZ(-Math.PI / 2); // lathe axis -> body X
return g;
}
function wingGeo(span, rc, tc, tipLeadX, thick) {
const s = new THREE.Shape();
s.moveTo(rc * 0.5, 0);
s.lineTo(tipLeadX, span);
s.lineTo(tipLeadX - tc, span);
s.lineTo(-rc * 0.5, 0);
s.closePath();
const g = new THREE.ExtrudeGeometry(s, { depth: thick, bevelEnabled: false });
g.rotateX(Math.PI / 2);
g.translate(0, thick / 2, 0);
return g;
}
function finGeo(height, rc, tc, tipLeadX, thick) {
const s = new THREE.Shape();
s.moveTo(rc * 0.5, 0);
s.lineTo(tipLeadX, height);
s.lineTo(tipLeadX - tc, height);
s.lineTo(-rc * 0.5, 0);
s.closePath();
const g = new THREE.ExtrudeGeometry(s, { depth: thick, bevelEnabled: false });
g.translate(0, 0, -thick / 2);
return g;
}
function mirrorPair(parent, geo, mat, x, y, z = 0, sym = true) {
const a = new THREE.Mesh(geo, mat);
a.position.set(x, y, z);
a.castShadow = false;
parent.add(a);
if (sym) {
const b = new THREE.Mesh(geo, mat);
b.position.set(x, y, -z);
b.scale.z = -1;
parent.add(b);
}
return a;
}
function gearWheel(parent, x, y, z, leg = true) {
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
const legMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a6, metalness: 0.6, roughness: 0.4 });
const w = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.32, 0.22, 16), wheelMat);
w.rotation.z = Math.PI / 2;
w.position.set(x, y + 0.32, z);
parent.add(w);
if (leg) {
const l = new THREE.Mesh(new THREE.BoxGeometry(0.1, Math.abs(y) - 0.3, 0.1), legMat);
l.position.set(x, (y + 0.3 + 0) / 2 + 0.25, z);
parent.add(l);
}
}
function makeMats(color, accent) {
return {
body: new THREE.MeshStandardMaterial({ color, roughness: 0.42, metalness: 0.25 }),
accent: new THREE.MeshStandardMaterial({ color: accent, roughness: 0.5, metalness: 0.2, side: THREE.DoubleSide }),
glass: new THREE.MeshPhysicalMaterial({ color: 0x0b1a2a, roughness: 0.05, metalness: 0.0, clearcoat: 1, transmission: 0.0, side: THREE.DoubleSide }),
dark: new THREE.MeshStandardMaterial({ color: 0x1c1f24, roughness: 0.6, metalness: 0.3 }),
metal: new THREE.MeshStandardMaterial({ color: 0xb8bec6, roughness: 0.25, metalness: 0.9 })
};
}
function buildSkylark(group, spec) {
const m = makeMats(spec.color, spec.accent);
const fus = new THREE.Mesh(fuselageGeo(8.4, 0.78), m.body);
group.add(fus);
// Stripe
const stripe = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.12, 1.56), m.accent);
stripe.position.set(0.8, 0.1, 0);
group.add(stripe);
// Canopy (bubble)
const can = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), m.glass);
can.scale.set(1.7, 0.7, 0.92);
can.position.set(1.0, 0.7, 0);
group.add(can);
// High straight wing
mirrorPair(group, wingGeo(5.6, 1.9, 1.3, 0.0, 0.14), m.body, 0.6, 0.72);
// Struts
mirrorPair(group, new THREE.BoxGeometry(0.08, 0.7, 0.08), m.metal, 0.4, -0.05, 1.8);
// Horizontal tail
mirrorPair(group, wingGeo(2.5, 1.1, 0.7, -0.3, 0.08), m.body, -3.6, 0.15);
// Vertical fin
group.add(new THREE.Mesh(finGeo(1.7, 1.6, 0.8, -0.2, 0.1), m.body).translateX(-3.7).translateY(0.6));
// Fin stripe
const fs = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.13), m.accent);
fs.position.set(-4.0, 1.1, 0);
group.add(fs);
// Propeller
const prop = new THREE.Group();
prop.position.set(4.2, 0, 0);
const spin = new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.9, 20), m.metal);
spin.rotation.z = -Math.PI / 2; spin.position.x = 0.4;
prop.add(spin);
const blade1 = new THREE.Mesh(new THREE.BoxGeometry(0.06, 2.6, 0.18), m.dark);
const blade2 = blade1.clone();
blade2.rotation.x = Math.PI;
blade1.position.x = 0.2; blade2.position.x = 0.2;
prop.add(blade1, blade2);
group.add(prop);
// Gear (tricycle, fixed)
gearWheel(group, -0.6, -spec.gearH, 1.1);
gearWheel(group, -0.6, -spec.gearH, -1.1);
gearWheel(group, 3.3, -spec.gearH, 0, true);
return { prop, propSpeed: 1 };
}
function buildFalcon(group, spec) {
const m = makeMats(spec.color, spec.accent);
group.add(new THREE.Mesh(fuselageGeo(16.0, 0.95), m.body));
// Cockpit canopy
const can = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), m.glass);
can.scale.set(2.6, 0.75, 0.82);
can.position.set(3.4, 0.7, 0);
group.add(can);
// Intake under nose
const intake = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.9, 1.5), m.dark);
intake.position.set(1.8, -0.4, 0);
group.add(intake);
// Swept delta wing
mirrorPair(group, wingGeo(5.5, 9.0, 1.4, -2.2, 0.16), m.body, 1.3, -0.15);
// Winglet accent
mirrorPair(group, wingGeo(1.0, 1.4, 0.8, -0.2, 0.06), m.accent, -2.5, 1.2, 4.9);
// Twin canted fins
const finL = new THREE.Mesh(finGeo(3.2, 4.2, 2.2, -1.3, 0.2), m.body);
finL.position.set(-5.4, 0.7, 1.1); finL.rotation.x = -0.22; group.add(finL);
const finR = finL.clone(); finR.position.z = -1.1; finR.rotation.x = 0.22; group.add(finR);
// Tail stab
mirrorPair(group, wingGeo(2.6, 2.8, 1.1, -1.3, 0.1), m.body, -6.6, 0.2);
// Engine exhaust
const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.85, 0.6, 20, 1, true), m.dark);
nozzle.rotation.z = Math.PI / 2; nozzle.position.set(-8.4, 0, 0);
group.add(nozzle);
// Gear
gearWheel(group, -1.0, -spec.gearH, 1.6);
gearWheel(group, -1.0, -spec.gearH, -1.6);
gearWheel(group, 4.8, -spec.gearH, 0);
return { prop: null };
}
function buildRaptor(group, spec) {
const m = makeMats(spec.color, spec.accent);
group.add(new THREE.Mesh(fuselageGeo(22.0, 1.25), m.body));
// Glass nose & canopy
const can = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), m.glass);
can.scale.set(2.8, 0.9, 1.1);
can.position.set(5.5, 0.9, 0);
group.add(can);
// Big wing
mirrorPair(group, wingGeo(10.5, 5.2, 2.6, -0.3, 0.2), m.body, 0.5, -0.45);
// Twin engine nacelles on wing
for (const z of [-3.2, 3.2]) {
const nac = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.7, 6.0, 20, 1, true), m.dark);
nac.rotation.z = Math.PI / 2;
nac.position.set(-1.6, -1.1, z);
group.add(nac);
const inner = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.62, 0.2, 18), m.accent);
inner.rotation.z = Math.PI / 2; inner.position.set(-4.6, -1.1, z);
group.add(inner);
}
// Twin tail fins
for (const z of [-1.8, 1.8]) {
const f = new THREE.Mesh(finGeo(5.0, 6.0, 3.0, -2.4, 0.22), m.body);
f.position.set(-7.2, 1.2, z);
if (z < 0) f.rotation.x = -0.1; else f.rotation.x = 0.1;
group.add(f);
}
// Horizontal tail
mirrorPair(group, wingGeo(5.6, 3.2, 1.6, -0.6, 0.12), m.body, -8.2, 0.1);
// Gear
gearWheel(group, -1.2, -spec.gearH, 2.4);
gearWheel(group, -1.2, -spec.gearH, -2.4);
gearWheel(group, 6.2, -spec.gearH, 0);
return { prop: null };
}
function buildAircraft(key) {
const spec = FLEET[key];
const group = new THREE.Group();
let extra;
if (key === 'skylark') extra = buildSkylark(group, spec);
else if (key === 'falcon') extra = buildFalcon(group, spec);
else extra = buildRaptor(group, spec);
group.traverse(o => { if (o.isMesh) { o.castShadow = false; o.receiveShadow = false; } });
return { group, prop: extra.prop || null, spec };
}
// ============================================================
// FLIGHT MODEL — rigid-body 6DOF, body-frame rates, quaternion attitude
// Body axes: X forward (nose), Y up, Z right (starboard).
// Angular rate vector w = (p roll about X [+ = right wing down],
// r yaw about Y [+ = nose left],
// q pitch about Z [+ = nose up]).
// ============================================================
const VMAX = { skylark: 95, falcon: 330, raptor: 410 }; // m/s, speed where thrust ~ drag
const FUEL0 = { skylark: 300, falcon: 3000, raptor: 8000 }; // kg
const SFC = { skylark: 8e-5, falcon: 3.5e-5, raptor: 3.2e-5 }; // kg per N·s
const LEN = { skylark: 8.4, falcon: 16, raptor: 22 };
// Tricycle gear: nose ahead of CG, mmains aft of CG (CG must sit between them for ground stability)
const GEAR = {
skylark: { nx: 3.3, mx: -0.6, mz: 1.1 },
falcon: { nx: 4.8, mx: -1.0, mz: 1.6 },
raptor: { nx: 6.2, mx: -1.2, mz: 2.4 }
};
const IDLE = 0.18;
const PHYS_DT = 1 / 120;
const GRAV = new THREE.Vector3(0, -G, 0);
function groundH(x, z) { return Math.max(terrainH(x, z), 0); }
function newAirframe(key) {
const spec = FLEET[key];
return {
key, spec, L: LEN[key], c: spec.S / spec.span,
fuel: FUEL0[key], p: new THREE.Vector3(), v: new THREE.Vector3(),
q: new THREE.Quaternion(), w: new THREE.Vector3(),
thr: 0, rpm: 0, dE: 0, dA: 0, dR: 0, flap: 0, gearDown: true, brake: false,
damage: 0, onGround: false, alpha: 0, beta: 0, V: 0, rho: RHO0, alt: 0,
n: 1, thrust: 0, stall: false, impact: 0, crashed: false, crashReason: '',
touchdowns: 0, lastVS: 0, lastLat: 0, lastDist: 0, wasAir: false, buffet: 0,
lift: 0, drag: 0, mass: spec.mass + FUEL0[key], gLoad: 1
};
}
const _va = new THREE.Vector3(), _vb = new THREE.Vector3(), _f = new THREE.Vector3();
const _u = new THREE.Vector3(), _r = new THREE.Vector3(), _vh = new THREE.Vector3();
const _Ld = new THREE.Vector3(), _Fa = new THREE.Vector3(), _F = new THREE.Vector3();
const _M = new THREE.Vector3(), _Mg = new THREE.Vector3(), _Fg = new THREE.Vector3();
const _qInv = new THREE.Quaternion(), _dq = new THREE.Quaternion(), _ax = new THREE.Vector3();
const _rp = new THREE.Vector3(), _P = new THREE.Vector3(), _vP = new THREE.Vector3();
const _wW = new THREE.Vector3(), _tmp = new THREE.Vector3(), _Fw = new THREE.Vector3();
// ctl = { pitch, roll, yaw, thr, brake } (pitch +1 = nose up, roll +1 = right, yaw +1 = nose left, thr 0..1)
function stepAirframe(a, dt, ctl, wind) {
const s = a.spec;
const fuelOk = a.fuel > 0 && a.damage < 100 && !a.crashed;
const m = s.mass + a.fuel;
a.mass = m;
const b = s.span, c = a.c, L = a.L;
const Ix = m * (0.30 * b) * (0.30 * b);
const Iy = m * (0.27 * L) * (0.27 * L);
const Iz = m * (0.26 * L) * (0.26 * L);
// ---- control surface actuators (first-order lag, heavier = slower)
const tau = 0.06 + 0.00002 * m;
const ka = 1 - Math.exp(-dt / tau);
a.dE += (clamp(ctl.pitch, -1, 1) - a.dE) * ka;
a.dA += (clamp(ctl.roll, -1, 1) - a.dA) * ka;
a.dR += (clamp(ctl.yaw, -1, 1) - a.dR) * ka;
// ---- engine spool: rpm lags throttle
const rpmTgt = fuelOk ? (ctl.thr > 0.01 ? IDLE + (1 - IDLE) * ctl.thr : IDLE) : 0;
a.rpm += (rpmTgt - a.rpm) * (1 - Math.exp(-dt / (s.spool * 0.5)));
a.thr = ctl.thr;
// ---- atmosphere (ISA troposphere): density falls with altitude
const alt = Math.max(0, a.p.y);
const Tair = T0 - 0.0065 * alt;
const rho = RHO0 * Math.pow(Math.max(Tair, 200) / T0, 4.256);
a.rho = rho; a.alt = alt;
// ---- air-relative velocity in body axes
_va.copy(a.v).sub(wind);
_qInv.copy(a.q).invert();
_vb.copy(_va).applyQuaternion(_qInv);
const V = Math.max(_va.length(), 0.5);
const alpha = Math.atan2(-_vb.y, _vb.x);
const beta = Math.asin(clamp(_vb.z / V, -1, 1));
a.V = V; a.alpha = alpha; a.beta = beta;
const qdyn = 0.5 * rho * V * V;
const dyn = qdyn * s.S;
a.qdyn = qdyn;
_f.set(1, 0, 0).applyQuaternion(a.q);
_u.set(0, 1, 0).applyQuaternion(a.q);
_r.set(0, 0, 1).applyQuaternion(a.q);
_vh.copy(_va).divideScalar(V);
// ---- lift / drag coefficients with smooth Abramov stall blend
const aS = s.aStall, Mk = 22;
const e1 = Math.exp(-Mk * (alpha - aS)), e2 = Math.exp(Mk * (alpha + aS));
const sig = (1 + e1 + e2) / ((1 + e1) * (1 + e2));
const sa = Math.sin(alpha), ca = Math.cos(alpha);
const flapF = a.flap * 0.5;
const clLin = s.CL0 + s.CLa * alpha + s.flapCL * flapF;
const CL = (1 - sig) * clLin + sig * 2 * sa * ca;
const cdi = CL * CL / (Math.PI * s.AR * s.e);
const CD = (1 - sig) * (s.CD0 + cdi) + sig * (s.CD0 + 2 * sa * sa)
+ s.flapCD * flapF + (a.gearDown ? s.gearCD : 0)
+ (a.brake && a.onGround ? 0.03 : 0);
a.lift = dyn * CL; a.drag = dyn * CD;
// Lift is perpendicular to air velocity, in the plane containing body up
const ud = _u.dot(_vh);
_Ld.copy(_u).addScaledVector(_vh, -ud);
const Lm = _Ld.length();
if (Lm > 1e-6) _Ld.divideScalar(Lm); else _Ld.set(0, 1, 0);
_Fa.set(0, 0, 0);
_Fa.addScaledVector(_Ld, dyn * CL);
_Fa.addScaledVector(_vh, -dyn * CD);
// Side force opposing sideslip
_Fa.addScaledVector(_r, -dyn * (0.4 + 0.1 * s.yawStab) * beta);
// Thrust: propeller/jet lapse with density and airspeed
const sf = Math.max(0.05, 1 - 0.3 * Math.pow(V / VMAX[a.key], 2));
const powF = 1 - 0.5 * smooth(60, 100, a.damage);
const Tn = fuelOk ? s.Tmax * a.rpm * Math.pow(rho / RHO0, 0.75) * sf * powF : 0;
a.thrust = Tn;
a.fuel = Math.max(0, a.fuel - SFC[a.key] * Tn * dt);
_Fa.addScaledVector(_f, Tn);
// Load factor (non-gravitational specific force along body up)
const nNow = _Fa.dot(_u) / (m * G);
a.n = nNow; a.gLoad = nNow;
// ---- aerodynamic moments (body axes), scaled by dynamic pressure
const Vs = Math.max(V, 25);
const p = a.w.x, rr = a.w.y, qq = a.w.z;
const Mx = dyn * b * (s.rollPow * 0.08 * a.dA - 0.06 * beta) - dyn * b * b * (s.rollDamp * 0.5) / (2 * Vs) * p;
const Mz = dyn * c * (s.pitchPow * 0.10 * a.dE - s.pitchStab * 0.06 * alpha) - dyn * c * c * (s.pitchDamp * 12) / (2 * Vs) * qq;
const My = dyn * b * (s.yawPow * 0.06 * a.dR - s.yawStab * 0.04 * beta) - dyn * b * b * (s.yawDamp * 0.3) / (2 * Vs) * rr;
_M.set(Mx, My, Mz);
// ---- ground contact: three spring-damper gear points
const gr = GEAR[a.key];
const gearH = a.gearDown ? s.gearH : 0.45;
const m3 = m / 3;
const wn = 14, zeta = 0.7;
const K = m3 * wn * wn, Cd = 2 * zeta * Math.sqrt(K * m3);
_Fg.set(0, 0, 0); _Mg.set(0, 0, 0);
_wW.copy(a.w).applyQuaternion(a.q);
let nContact = 0, impact = 0;
const pts = [[gr.nx, -gearH, 0], [gr.mx, -gearH, -gr.mz], [gr.mx, -gearH, gr.mz]];
for (let i = 0; i < 3; i++) {
_rp.set(pts[i][0], pts[i][1], pts[i][2]).applyQuaternion(a.q);
_P.copy(a.p).add(_rp);
const pen = groundH(_P.x, _P.z) - _P.y;
if (pen > 0) {
_vP.copy(_wW).cross(_rp).add(a.v);
const vn = _vP.y;
let N = K * pen - Cd * vn;
if (N < 0) N = 0;
nContact++;
if (-vn > impact) impact = -vn;
let fx = 0, fz = 0;
const hs = Math.hypot(_vP.x, _vP.z);
if (hs > 1e-3 && N > 0) {
const mu = a.brake ? 0.55 : 0.04;
const Ff = Math.min(mu * N, m3 * hs / dt * 0.5);
fx = -_vP.x / hs * Ff; fz = -_vP.z / hs * Ff;
}
_Fw.set(fx, N, fz);
_Fg.add(_Fw);
_tmp.copy(_rp).cross(_Fw);
_Mg.add(_tmp);
}
}
// Nosewheel steering + ground yaw damping
const onG = nContact > 0;
if (onG) {
_Mg.applyQuaternion(_qInv);
_M.add(_Mg);
_M.y += a.dR * Iy * 1.5 * clamp(V / 12, 0, 1) - rr * Iy * 2.0;
}
// ---- Touchdown / impact bookkeeping
if (onG && !a.onGround && a.wasAir !== false) {
a.touchdowns++;
a.lastVS = impact;
a.lastLat = a.p.x;
a.lastDist = a.p.z;
if (impact > 2.5) a.damage += (impact - 2.5) * 4;
if (impact > 7.0) { a.crashed = true; a.crashReason = 'Hard landing - airframe destroyed'; }
}
if (onG) a.impact = impact;
a.wasAir = !onG;
a.onGround = onG;
// Flipped on ground -> crash
if (onG && _u.y < 0.15) { a.crashed = true; a.crashReason = 'Rolled over on ground'; }
// ---- angular dynamics: w_dot = I^-1 (M - w x Iw)
const Ixw = Ix * p, Iyw = Iy * rr, Izw = Iz * qq;
const wdx = (_M.x - (rr * Izw - qq * Iyw)) / Ix;
const wdy = (_M.y - (qq * Ixw - p * Izw)) / Iy;
const wdz = (_M.z - (p * Iyw - rr * Ixw)) / Iz;
a.w.x = clamp(a.w.x + wdx * dt, -8, 8);
a.w.y = clamp(a.w.y + wdy * dt, -8, 8);
a.w.z = clamp(a.w.z + wdz * dt, -8, 8);
// Orientation update: body-frame rotation increment
const wm = a.w.length();
if (wm > 1e-9) {
_ax.copy(a.w).divideScalar(wm);
_dq.setFromAxisAngle(_ax, wm * dt);
a.q.multiply(_dq).normalize();
}
// Ground pitch limiter: gear geometry is simplified, so keep the nose
// within a plausible attitude range while in contact with the runway.
if (onG) {
const fz = _f.set(1, 0, 0).applyQuaternion(a.q);
const pitchNow = Math.asin(clamp(fz.y, -1, 1));
const lim = [-5 * DEG, 12 * DEG];
const tgtP = clamp(pitchNow, lim[0], lim[1]);
if (tgtP !== pitchNow) {
_dq.setFromAxisAngle(_ax.set(0, 0, 1), tgtP - pitchNow);
a.q.multiply(_dq).normalize();
if (a.w.z * (tgtP - pitchNow) < 0) a.w.z = 0;
}
}
// ---- linear dynamics
_F.copy(_Fa).add(_Fg).addScaledVector(GRAV, m);
a.v.addScaledVector(_F, dt / m);
a.p.addScaledVector(a.v, dt);
// Sanity: never sink through the terrain
const gy = groundH(a.p.x, a.p.z);
if (a.p.y < gy - 6) { a.p.y = gy - 6; }
// ---- structural load damage
const an = Math.abs(nNow);
if (an > s.maxG * 1.5) a.damage += 30 * dt;
else if (an > s.maxG) a.damage += (an - s.maxG) * 2 * dt;
if (a.damage >= 100 && !a.crashed) { a.crashed = true; a.crashReason = 'Airframe failure under load'; }
// Stall detection and buffet intensity
a.stall = !onG && alpha > aS * 0.92;
a.buffet = smooth(aS * 0.8, aS * 1.05, Math.abs(alpha)) * (onG ? 0 : 1);
// Attitude helpers for HUD
const f = _f, u = _u, r = _r;
a.pitch = Math.asin(clamp(f.y, -1, 1));
a.bank = Math.atan2(-r.y, u.y);
a.hdg = ((Math.atan2(f.x, -f.z) / DEG) + 360) % 360;
a.climb = a.v.y;
}
function airframeFromSpawn(a, x, y, z, hdgDeg, speed, climbRate = 0) {
a.p.set(x, y, z);
a.touchdowns = 0;
const hdg = hdgDeg * DEG;
// heading: forward = (sin h, 0, -cos h) -> compass 0 = north = -Z
a.q.setFromAxisAngle(_ax.set(0, 1, 0), Math.PI / 2 - hdg);
a.v.set(Math.sin(hdg) * speed, climbRate, -Math.cos(hdg) * speed);
a.w.set(0, 0, 0);
a.rpm = 0; a.dE = a.dA = a.dR = 0;
}
// ============================================================
// AUDIO — Web Audio synthesis driven by physics state
// ============================================================
const AudioSys = {
ctx: null, ready: false,
init() {
if (this.ready) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = this.ctx = new AC();
this.master = ctx.createGain(); this.master.gain.value = 0.7;
const comp = ctx.createDynamicsCompressor(); comp.threshold.value = -18;
this.master.connect(comp); comp.connect(ctx.destination);
// Shared noise buffer (2s)
const nb = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
const d = nb.getChannelData(0);
let last = 0;
for (let i = 0; i < d.length; i++) { const w = Math.random() * 2 - 1; last = (last + 0.02 * w) / 1.02; d[i] = last * 3.5; }
this.noise = nb;
const mkNoise = (filterType, freq, q) => {
const src = ctx.createBufferSource(); src.buffer = nb; src.loop = true;
const f = ctx.createBiquadFilter(); f.type = filterType; f.frequency.value = freq; f.Q.value = q;
src.connect(f);
return { src, f };
};
// ENGINE: sawtooth-ish harmonic stack through lowpass, with noise
this.eng = ctx.createGain(); this.eng.gain.value = 0;
this.engF = ctx.createBiquadFilter(); this.engF.type = 'lowpass'; this.engF.frequency.value = 800; this.engF.Q.value = 2;
this.engOsc1 = ctx.createOscillator(); this.engOsc1.type = 'sawtooth';
this.engOsc2 = ctx.createOscillator(); this.engOsc2.type = 'square';
this.engOsc2.detune.value = 6;
const g2 = ctx.createGain(); g2.gain.value = 0.35;
this.engOsc2.connect(g2); g2.connect(this.engF);
this.engOsc1.connect(this.engF);
this.engF.connect(this.eng);
this.engNoiseN = mkNoise('bandpass', 400, 0.7);
this.engNoiseG = ctx.createGain(); this.engNoiseG.gain.value = 0.4;
this.engNoiseN.f.connect(this.engNoiseG); this.engNoiseG.connect(this.eng);
this.engNoiseN.src.start();
this.eng.connect(this.master);
this.engOsc1.start(); this.engOsc2.start();
// WIND: bandpass noise whose gain & cutoff rise with airspeed and altitude
this.wind = mkNoise('bandpass', 500, 0.6);
this.windG = ctx.createGain(); this.windG.gain.value = 0;
this.wind.f.connect(this.windG); this.windG.connect(this.master);
this.wind.src.start();
// AERO WHISTLE: high-pass noise, scaled by airspeed & AoA
this.aero = mkNoise('highpass', 1200, 1.0);
this.aeroG = ctx.createGain(); this.aeroG.gain.value = 0;
this.aero.f.connect(this.aeroG); this.aeroG.connect(this.master);
this.aero.src.start();
// BUFFET: low-frequency wobble when stalling
this.buf = mkNoise('lowpass', 45, 0.5);
this.bufG = ctx.createGain(); this.bufG.gain.value = 0;
this.buf.f.connect(this.bufG); this.bufG.connect(this.master);
this.buf.src.start();
// SURFACE / GEAR RUMBLE on ground (rolling)
this.roll = mkNoise('bandpass', 180, 0.8);
this.rollG = ctx.createGain(); this.rollG.gain.value = 0;
this.roll.f.connect(this.rollG); this.rollG.connect(this.master);
this.roll.src.start();
// Distant mountain rumble: very low drone, subtle
this.rumble = mkNoise('lowpass', 90, 0.3);
this.rumbleG = ctx.createGain(); this.rumbleG.gain.value = 0.0;
this.rumble.f.connect(this.rumbleG); this.rumbleG.connect(this.master);
this.rumble.src.start();
// Gunfire: noise burst tied to trigger
this.gunG = ctx.createGain(); this.gunG.gain.value = 0;
this.gunF = ctx.createBiquadFilter(); this.gunF.type = 'lowpass'; this.gunF.frequency.value = 2400;
this.gunF.connect(this.gunG); this.gunG.connect(this.master);
this.gunSrc = ctx.createBufferSource(); this.gunSrc.buffer = nb; this.gunSrc.loop = true;
this.gunSrc.connect(this.gunF); this.gunSrc.start();
// Warning tone (stall / overspeed / gear)
this.warnOsc = ctx.createOscillator(); this.warnOsc.type = 'square'; this.warnOsc.frequency.value = 880;
this.warnG = ctx.createGain(); this.warnG.gain.value = 0;
this.warnOsc.connect(this.warnG); this.warnG.connect(this.master); this.warnOsc.start();
this.ready = true;
},
resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
set(p, t, k = 0.05) {
if (!this.ready) return;
p.linearRampToValueAtTime(t, this.ctx.currentTime + k);
},
// One-shot thump (explosion / impact)
boom(amp = 0.8, dur = 1.6) {
if (!this.ready) return;
const ctx = this.ctx, t = ctx.currentTime;
const src = ctx.createBufferSource(); src.buffer = this.noise;
const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(900, t); f.frequency.exponentialRampToValueAtTime(40, t + dur);
const g = ctx.createGain(); g.gain.setValueAtTime(amp, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t, Math.random()); src.stop(t + dur);
},
// Hit ping / chirp
ping(freq = 1400) {
if (!this.ready) return;
const ctx = this.ctx, t = ctx.currentTime;
const o = ctx.createOscillator(); o.type = 'sine';
o.frequency.setValueAtTime(freq, t); o.frequency.exponentialRampToValueAtTime(freq * 0.5, t + 0.25);
const g = ctx.createGain(); g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 0.35);
},
// Gear / flap clunk
clunk() {
if (!this.ready) return;
const ctx = this.ctx, t = ctx.currentTime;
const src = ctx.createBufferSource(); src.buffer = this.noise;
const f = ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 300; f.Q.value = 2;
const g = ctx.createGain(); g.gain.setValueAtTime(0.5, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
src.connect(f); f.connect(g); g.connect(this.master); src.start(t, Math.random() * 1.5); src.stop(t + 0.3);
},
// Per-frame mix update driven by physics + environment
update(a, env) {
if (!this.ready) return;
const s = a.spec;
const rpm = a.rpm;
const rho = a.rho / RHO0;
const V = a.V;
const vsp = clamp(V / VMAX[a.key], 0, 1.2);
// Engine pitch rises with rpm; density lowers pitch slightly and reduces volume at altitude
const base = (a.key === 'skylark' ? 70 : a.key === 'falcon' ? 95 : 55) + 130 * rpm;
const pitch = base * (0.85 + 0.15 * rho) * (1 + 0.04 * clamp(a.thrust / s.Tmax, 0, 1));
this.set(this.engOsc1.frequency, pitch, 0.08);
this.set(this.engOsc2.frequency, pitch * 0.5, 0.08);
this.set(this.engF.frequency, 300 + 1800 * rpm * (0.6 + 0.4 * rho), 0.1);
const engVol = (a.rpm < 0.02 ? 0 : (0.05 + 0.25 * rpm) * (0.45 + 0.55 * rho)) * (a.damage > 90 ? 0.4 : 1);
this.set(this.eng.gain, engVol, 0.08);
this.set(this.engNoiseG.gain, 0.15 + 0.35 * rpm * rho, 0.1);
this.set(this.engNoiseN.f.frequency, 250 + 500 * rpm, 0.1);
// Wind: grows with airspeed, rises further with altitude (thinner air = more buffet-like wash)
const windVol = clamp(0.05 + 0.35 * vsp * vsp + 0.07 * clamp(a.alt / 3000, 0, 1), 0, 0.5);
this.set(this.windG.gain, windVol * (env.cockpit ? 0.45 : 1), 0.15);
this.set(this.wind.f.frequency, 350 + 1500 * vsp, 0.15);
// Aero whistle: airflow over wing / canopy, stronger near stall and high speed
const aeroAmt = clamp(vsp * vsp * 0.5 + Math.abs(a.alpha) * 0.6, 0, 1);
const aeroVol = a.onGround ? 0 : aeroAmt * 0.22 * rho;
this.set(this.aeroG.gain, aeroVol, 0.1);
this.set(this.aero.f.frequency, 1000 + 2800 * vsp, 0.15);
// Buffet when near stall
this.set(this.bufG.gain, a.buffet * 0.5 * (0.5 + 0.5 * Math.sin(performance.now() * 0.06)), 0.05);
// Ground roll rumble
const roll = a.onGround ? clamp(V / 40, 0, 1) * 0.35 : 0;
this.set(this.rollG.gain, roll, 0.1);
this.set(this.roll.f.frequency, 120 + 200 * clamp(V / 70, 0, 1), 0.1);
// Distant mountains rumble + wind pitch by altitude
this.set(this.rumbleG.gain, 0.03 + 0.04 * clamp(env.nearMountain || 0, 0, 1), 0.5);
// Warning tone
const warnOn = (a.stall ? 1 : 0) + (a.damage > 60 ? 0.5 : 0);
this.set(this.warnG.gain, warnOn > 0 && (Math.floor(performance.now() / 180) % 2 === 0) ? 0.06 : 0, 0.02);
}
};
// ============================================================
// INPUT
// ============================================================
const keys = {};
const mouse = { x: 0, y: 0, active: false, dx: 0, dy: 0, fire: false };
let mouseStick = false;
addEventListener('keydown', e => {
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Tab'].includes(e.code)) e.preventDefault();
keys[e.code] = true;
if (e.code === 'KeyM' && game.state === 'flying') mouseStick = !mouseStick;
if ((e.code === 'KeyP' || e.code === 'Escape') && game.state === 'flying') togglePause();
else if (e.code === 'KeyP' && game.state === 'paused') togglePause();
if (e.code === 'KeyR' && game.state !== 'menu') startMission(game.key, game.mission);
if (e.code === 'KeyF' && game.state === 'flying') { const a = game.ac; a.flap = (a.flap + 1) % 3; AudioSys.clunk(); }
if (e.code === 'KeyG' && game.state === 'flying') { const a = game.ac; if (a.spec.key !== 'x') { a.gearDown = !a.gearDown; AudioSys.clunk(); } }
if (e.code === 'KeyC' && game.state === 'flying') game.camMode = (game.camMode + 1) % 3;
if (e.code === 'KeyX' && game.state === 'flying') game.ac.thr = 0;
if (e.code === 'KeyZ' && game.state === 'flying') game.ac.thr = 1;
if (e.code === 'Escape' && game.state === 'menu') {}
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('mousemove', e => {
mouse.x = e.clientX; mouse.y = e.clientY;
if (mouseStick) { mouse.dx += e.movementX || 0; mouse.dy += e.movementY || 0; }
});
addEventListener('mousedown', e => { if (e.button === 0 && game.state === 'flying') mouse.fire = true; });
addEventListener('mouseup', e => { if (e.button === 0) mouse.fire = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; mouse.fire = false; });
// ============================================================
// GAME STATE, CONTROLS, ENEMIES, WEAPONS, MISSIONS
// ============================================================
const game = {
state: 'menu', key: 'skylark', mission: 'takeoff', ac: null, mesh: null, prop: null,
camMode: 0, time: 0, phase: 0, enemies: [], bullets: [], expl: [], ammo: 0, kills: 0,
gunCD: 0, msgT: 0, msgText: '', endCountdown: 0, ended: false, endSuccess: false,
lastTD: 0, camInit: false, wind: new THREE.Vector3(), hitFlash: 0, landNote: '', landPts: 0,
startTime: 0, missionTime: 0, menuT: 0
};
const camPos = new THREE.Vector3(), camLook = new THREE.Vector3();
const CAM_COCKPIT_Q = new THREE.Quaternion().setFromRotationMatrix(
new THREE.Matrix4().makeBasis(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0), new THREE.Vector3(-1, 0, 0)));
const _bod = new THREE.Vector3(), _dir = new THREE.Vector3(), _qi = new THREE.Quaternion();
const _ff = new THREE.Vector3(), _tt = new THREE.Vector3();
const TGT_RANGE_MAX = 2500;
function setMsg(text, dur = 4) { game.msgText = text; game.msgT = dur; }
// ---- control mapping: keyboard digital inputs passed through actuator lag in physics
let stickX = 0, stickY = 0;
function readControls(dt, a) {
let pitch = (keys.KeyS ? 1 : 0) - (keys.KeyW ? 1 : 0);
let roll = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
let yaw = (keys.KeyQ ? 1 : 0) - (keys.KeyE ? 1 : 0);
if (mouseStick) {
const cx = innerWidth / 2, cy = innerHeight / 2;
const tx = clamp((mouse.x - cx) / (innerWidth * 0.3), -1, 1);
const ty = clamp((cy - mouse.y) / (innerHeight * 0.3), -1, 1);
roll = clamp(roll + tx, -1, 1); pitch = clamp(pitch + ty, -1, 1);
}
if (keys.ShiftLeft || keys.ShiftRight) a.thr = Math.min(1, a.thr + dt * 0.6);
if (keys.ControlLeft || keys.ControlRight) a.thr = Math.max(0, a.thr - dt * 0.6);
a.brake = !!keys.KeyB;
return { pitch, roll, yaw, thr: a.thr, brake: a.brake };
}
// ---- enemy AI: pure-pursuit using the same physics model
function enemyAI(e, target, dt) {
const a = e.a;
_dir.copy(target.p).sub(a.p);
const dist = _dir.length();
_dir.divideScalar(Math.max(dist, 1));
_qi.copy(a.q).invert();
_bod.copy(_dir).applyQuaternion(_qi);
const pitchErr = Math.atan2(_bod.y, _bod.x);
const yawErr = Math.atan2(-_bod.z, _bod.x);
const bankT = clamp(-yawErr * 1.5, -1.25, 1.25);
const roll = clamp(3.0 * (bankT - a.bank) - 0.6 * a.w.x, -1, 1);
let pitch = clamp(3.6 * pitchErr - 1.0 * a.w.z, -1, 1);
const agl = a.p.y - groundH(a.p.x, a.p.z);
if (agl < 260) pitch = Math.max(pitch, clamp((260 - agl) / 120, 0, 1));
const yaw = clamp(yawErr * 0.4, -0.4, 0.4);
const thr = a.V < 115 ? 1 : (dist > 900 ? 1 : 0.75);
a.thr = thr;
e.bodyDir = _bod.x;
e.dist = dist;
return { pitch, roll, yaw, thr, brake: false };
}
function spawnExplosion(pos, maxS, life = 1.6) {
const m = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12),
new THREE.MeshBasicMaterial({ color: 0xffa040, transparent: true, opacity: 1, depthWrite: false }));
m.position.copy(pos);
scene.add(m);
game.expl.push({ mesh: m, t: 0, life, maxS });
}
function killEnemy(e) {
if (e.dead) return;
e.dead = true;
e.mesh.visible = false;
game.kills++;
spawnExplosion(e.a.p, 30, 2.0);
AudioSys.boom(0.7, 1.6);
const left = game.enemies.filter(x => !x.dead).length;
setMsg(left ? `SPLASH ONE — ${left} HOSTILE${left > 1 ? 'S' : ''} REMAINING` : 'ALL HOSTILES DOWN', 3);
}
function fireBullet(owner, origin, vel, life = 2.4) {
const mesh = new THREE.Mesh(BULLET_GEO, owner === 'player' ? BULLET_MAT_P : BULLET_MAT_E);
mesh.position.copy(origin);
scene.add(mesh);
game.bullets.push({ p: origin.clone(), v: vel.clone(), life, owner, mesh });
}
const BULLET_GEO = new THREE.BoxGeometry(0.25, 0.25, 4.0);
const BULLET_MAT_P = new THREE.MeshBasicMaterial({ color: 0xffe27a });
const BULLET_MAT_E = new THREE.MeshBasicMaterial({ color: 0xff5a4a });
function stepBullets(dt) {
const a = game.ac;
for (let i = game.bullets.length - 1; i >= 0; i--) {
const b = game.bullets[i];
b.p.addScaledVector(b.v, dt);
b.life -= dt;
b.mesh.position.copy(b.p);
b.mesh.lookAt(_tt.copy(b.p).add(b.v));
let remove = b.life <= 0 || b.p.y < groundH(b.p.x, b.p.z);
if (!remove && b.owner === 'player') {
for (const e of game.enemies) {
if (e.dead) continue;
if (b.p.distanceTo(e.a.p) < 9) {
e.hp -= 3.4; e.flash = 0.15; remove = true;
AudioSys.ping(1800);
if (e.hp <= 0) killEnemy(e);
break;
}
}
} else if (!remove && b.owner !== 'player' && !a.crashed) {
if (b.p.distanceTo(a.p) < 8) {
a.damage += 0.7; game.hitFlash = 0.35; remove = true;
AudioSys.ping(420);
}
}
if (remove) { scene.remove(b.mesh); game.bullets.splice(i, 1); }
}
}
function spawnPlayerFire(a, dt) {
game.gunCD -= dt;
const fire = keys.Space || mouse.fire;
if (!fire || game.mission !== 'dogfight' || game.ammo <= 0 || a.crashed) return;
if (game.gunCD > 0) return;
game.gunCD = 1 / 14;
game.ammo--;
_ff.set(1, 0, 0).applyQuaternion(a.q);
const origin = a.p.clone().addScaledVector(_ff, 8);
const vel = a.v.clone().addScaledVector(_ff, 900);
fireBullet('player', origin, vel);
AudioSys.gunShot && AudioSys.gunShot();
}
function enemyFire(e, dt) {
const a = game.ac;
e.fireCD = (e.fireCD || 0) - dt;
if (e.dead || a.crashed || e.fireCD > 0) return;
if (e.dist > 900 || e.bodyDir < Math.cos(7 * DEG)) return;
e.fireCD = 0.16;
_ff.set(1, 0, 0).applyQuaternion(e.a.q);
const origin = e.a.p.clone().addScaledVector(_ff, 8);
const vel = e.a.v.clone().addScaledVector(_ff, 900);
fireBullet('enemy', origin, vel);
}
// ---- Mission setup
function clearWorld() {
if (game.mesh) scene.remove(game.mesh);
for (const e of game.enemies) scene.remove(e.mesh);
for (const b of game.bullets) scene.remove(b.mesh);
for (const x of game.expl) scene.remove(x.mesh);
game.enemies = []; game.bullets = []; game.expl = [];
}
function makeEnemy(x, y, z, hdgSpeedTarget) {
const a = newAirframe('falcon');
const dx = -x, dz = 1100 - z;
const hdg = Math.atan2(dx, -dz) / DEG;
airframeFromSpawn(a, x, y, z, (hdg + 360) % 360, 185);
a.rpm = 0.85; a.thr = 0.9; a.gearDown = false; a.flap = 0;
a.onGround = false; a.wasAir = true; a.damage = 0;
const built = buildAircraft('falcon');
built.group.traverse(o => {
if (o.isMesh && o.material && o.material.color && o.material.color.getHex() === FLEET.falcon.color) {
o.material = o.material.clone(); o.material.color.set(0x9a2420);
}
});
scene.add(built.group);
return { a, mesh: built.group, hp: 100, dead: false, fireCD: 0, flash: 0, dist: 9999, bodyDir: 0 };
}
function startMission(key, mission) {
AudioSys.init(); AudioSys.resume();
clearWorld();
game.key = key; game.mission = mission;
const built = buildAircraft(key);
game.mesh = built.group; game.prop = built.prop;
scene.add(game.mesh);
const a = newAirframe(key);
game.ac = a;
game.phase = 0; game.kills = 0; game.time = 0; game.ended = false; game.endCountdown = 0;
game.lastTD = 0; game.camInit = false; game.hitFlash = 0; game.gunCD = 0; game.landNote = '';
game.missionTime = 0;
game.ammo = mission === 'dogfight' ? 800 : 0;
if (mission === 'takeoff') {
airframeFromSpawn(a, 0, RWY.elev + a.spec.gearH, 260, 0, 0);
a.gearDown = true; a.flap = 1; a.onGround = true; a.wasAir = false;
a.thr = 0;
setMsg('CLEARED FOR TAKEOFF — SHIFT TO ADVANCE THROTTLE', 5);
} else {
airframeFromSpawn(a, 0, 650, 1100, 0, 190);
a.rpm = 0.85; a.thr = 0.85; a.gearDown = false; a.flap = 0;
a.onGround = false; a.wasAir = true;
const spawns = [[-900, 760, -700], [950, 820, -900], [-1500, 900, -200], [1350, 780, -350]];
for (const s of spawns) game.enemies.push(makeEnemy(s[0], s[1], s[2]));
setMsg('HOSTILES INBOUND — ENGAGE AT WILL', 5);
}
game.state = 'flying';
document.getElementById('menu').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');
document.getElementById('pause').classList.add('hidden');
document.getElementById('result').classList.add('hidden');
}
function togglePause() {
if (game.state === 'flying') {
game.state = 'paused';
document.getElementById('pause').classList.remove('hidden');
} else if (game.state === 'paused') {
game.state = 'flying';
document.getElementById('pause').classList.add('hidden');
}
}
function toMenu() {
game.state = 'menu';
document.getElementById('pause').classList.add('hidden');
document.getElementById('result').classList.add('hidden');
document.getElementById('hud').classList.add('hidden');
document.getElementById('menu').classList.remove('hidden');
}
function endMission(success, reason) {
if (game.ended) return;
game.ended = true;
game.endSuccess = success;
game.endReason = reason;
game.endCountdown = success ? 2.0 : 2.8;
if (!success) {
spawnExplosion(game.ac.p, 40, 2.4);
AudioSys.boom(1.0, 2.2);
game.mesh.visible = false;
}
}
function evaluateLanding(a) {
const z = a.p.z, x = a.p.x, vs = a.lastVS, hdg = a.hdg;
let pts = 100; const notes = [];
// Touchdown zone: first 300 m past the threshold (z 300 -> 0)
if (z > 320) { pts -= 30; notes.push('SHORT OF RUNWAY'); }
else if (z < -300) { pts -= Math.min(30, (-300 - z) / 40); notes.push('LONG LANDING'); }
else notes.push('ON TOUCHDOWN ZONE');
if (Math.abs(x) > 14) { pts -= Math.min(30, Math.abs(x) - 14); notes.push('OFF CENTERLINE'); }
const hd = Math.min(Math.abs(hdg), 360 - Math.abs(hdg));
if (hd > 25) { pts -= 15; notes.push('WRONG HEADING'); }
if (vs > 1.5) pts -= Math.min(40, (vs - 1.5) * 20);
if (vs > 3.2) notes.push('HARD TOUCHDOWN');
pts = Math.max(0, Math.round(pts));
game.landPts = pts;
game.landNote = `TOUCHDOWN ${vs.toFixed(1)} m/s · ${notes.join(' · ')}`;
setMsg(game.landNote, 6);
return pts;
}
function missionStep(dt) {
const a = game.ac;
if (game.ended) {
game.endCountdown -= dt;
if (game.endCountdown <= 0) showResult();
return;
}
if (a.crashed) { endMission(false, a.crashReason || 'Aircraft lost'); return; }
game.missionTime += dt;
if (Math.hypot(a.p.x, a.p.z) > 7300) {
setMsg('LEAVING OPERATIONAL AREA — TURN BACK NOW', 1);
a.damage += 1.5 * dt;
}
if (game.mission === 'takeoff') {
const agl = a.p.y - groundH(a.p.x, a.p.z);
if (game.phase === 0 && !a.onGround && agl > 300) {
game.phase = 1;
setMsg('CLIMB COMPLETE — TURN BACK, LAND ON HEADING 000 FROM THE SOUTH THRESHOLD', 6);
}
if (a.touchdowns > game.lastTD) {
game.lastTD = a.touchdowns;
if (game.phase === 1) {
game.landPts = evaluateLanding(a);
game.phase = 2;
}
}
if (game.phase === 2 && a.onGround && a.V < 3) {
endMission(true, 'Landing complete');
}
} else {
const alive = game.enemies.filter(e => !e.dead).length;
if (alive === 0) endMission(true, 'All hostiles destroyed');
}
}
function physicsStep(dt) {
game.time += dt;
game.wind.set(1.6 * Math.sin(game.time * 0.21) + 0.8, 0, 1.8 * Math.cos(game.time * 0.13));
if (game.state !== 'flying') return;
const a = game.ac;
if (!a.crashed) {
const ctl = readControls(dt, a);
stepAirframe(a, dt, ctl, game.wind);
spawnPlayerFire(a, dt);
}
if (game.mission === 'dogfight') {
for (const e of game.enemies) {
if (e.dead) continue;
if (e.a.crashed) { killEnemy(e); continue; }
const ctl = enemyAI(e, a, dt);
stepAirframe(e.a, dt, ctl, game.wind);
if (e.a.crashed || e.a.onGround) { if (!e.dead) spawnExplosion(e.a.p, 25); killEnemy(e); continue; }
enemyFire(e, dt);
}
}
stepBullets(dt);
missionStep(dt);
game.msgT -= dt;
}
// ============================================================
// MENU BUILD
// ============================================================
let selPlane = 'falcon', selMission = 'takeoff';
function buildMenu() {
const wrap = document.getElementById('planeCards');
wrap.innerHTML = '';
for (const key of Object.keys(FLEET)) {
const s = FLEET[key];
const c = document.createElement('div');
c.className = 'card' + (key === selPlane ? ' sel' : '');
c.dataset.key = key;
const bars = Object.entries(s.bars).map(([k, v]) =>
`<div class="bar-row"><span class="lbl">${k}</span><div class="track"><div class="fill" style="width:${v * 100}%"></div></div></div>`).join('');
c.innerHTML = `<div class="role">${s.role}</div><h3>${s.name}</h3><p>${s.desc}</p>
<div class="stats">${s.stats.map(([a, b]) => `<span>${a}</span><b>${b}</b>`).join('')}</div>
<div class="bars">${bars}</div>`;
c.addEventListener('click', () => {
selPlane = key;
wrap.querySelectorAll('.card').forEach(x => x.classList.toggle('sel', x.dataset.key === key));
});
wrap.appendChild(c);
}
document.querySelectorAll('.mission').forEach(m => {
m.addEventListener('click', () => {
selMission = m.dataset.mission;
document.querySelectorAll('.mission').forEach(x => x.classList.toggle('sel', x === m));
});
});
document.getElementById('startBtn').addEventListener('click', () => { startMission(selPlane, selMission); });
document.getElementById('resumeBtn').addEventListener('click', togglePause);
document.getElementById('restartBtn').addEventListener('click', () => startMission(game.key, game.mission));
document.getElementById('menuBtn').addEventListener('click', toMenu);
}
function showResult() {
game.state = 'done';
document.getElementById('hud').classList.add('hidden');
const p = document.getElementById('resultPanel');
const t = game.missionTime;
const mins = Math.floor(t / 60), secs = Math.floor(t % 60).toString().padStart(2, '0');
let title, big, lines;
if (game.mission === 'takeoff') {
if (game.endSuccess) {
const g = game.landPts >= 90 ? 'PERFECT' : game.landPts >= 70 ? 'GOOD' : game.landPts >= 45 ? 'FIRM' : 'ROUGH';
title = 'MISSION COMPLETE'; big = `${game.landPts}/100 · ${g}`;
} else { title = 'MISSION FAILED'; big = 'AIRCRAFT LOST'; }
lines = `<div>Result: ${game.endSuccess ? 'Landed and stopped on the runway' : game.endReason}</div>
<div>Flight time: ${mins}:${secs}</div><div>Landing: ${game.landNote || 'n/a'}</div>`;
} else {
if (game.endSuccess) { title = 'VICTORY'; big = 'ALL HOSTILES DOWN'; }
else { title = 'MISSION FAILED'; big = 'AIRCRAFT LOST'; }
lines = `<div>Result: ${game.endSuccess ? 'Air superiority achieved' : game.endReason}</div>
<div>Kills: ${game.kills} / ${game.enemies.length}</div>
<div>Engagement time: ${mins}:${secs}</div><div>Rounds remaining: ${game.ammo}</div>`;
}
p.innerHTML = `<h1>${title}</h1><div class="big">${big}</div><div class="lines">${lines}</div>
<div class="row" style="justify-content:center;gap:14px;margin-top:24px;">
<button id="againBtn">FLY AGAIN</button><button class="ghost" id="backBtn">AIRCRAFT / MISSION MENU</button></div>`;
document.getElementById('result').classList.remove('hidden');
document.getElementById('againBtn').onclick = () => startMission(game.key, game.mission);
document.getElementById('backBtn').onclick = toMenu;
}
// Gun sound (noise burst through the gun filter)
AudioSys.gunShot = function () {
if (!this.ready) return;
const ctx = this.ctx, t = ctx.currentTime;
const src = ctx.createBufferSource(); src.buffer = this.noise;
const f = ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 1100; f.Q.value = 0.8;
const g = ctx.createGain(); g.gain.setValueAtTime(0.18, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
src.connect(f); f.connect(g); g.connect(this.master); src.start(t, Math.random()); src.stop(t + 0.06);
};
// ============================================================
// CAMERA
// ============================================================
function updateCamera(dt, a) {
const mode = game.camMode;
_ff.set(1, 0, 0).applyQuaternion(a.q);
_u.set(0, 1, 0).applyQuaternion(a.q);
if (mode === 1) {
// Cockpit: at the canopy, looking along the nose
_tmp.copy(a.p).addScaledVector(_ff, 1.6).addScaledVector(_u, 1.1);
camPos.copy(_tmp);
camLook.copy(a.p).addScaledVector(_ff, 300).addScaledVector(_u, 1.1);
camera.up.copy(_u);
camera.position.copy(camPos);
camera.lookAt(camLook);
game.mesh.visible = false;
return;
}
game.mesh.visible = !a.crashed;
if (mode === 0) {
const dist = 26 + 6 * clamp(a.V / 200, 0, 1), height = 7.5;
_tmp.copy(a.p).addScaledVector(_ff, -dist).addScaledVector(_u, height);
const k = game.camInit ? 1 - Math.exp(-dt * 6) : 1;
camPos.lerp(_tmp, k);
camLook.copy(a.p).addScaledVector(_ff, 22);
game.camInit = true;
} else {
// External orbit camera - slow orbit about the aircraft
const t = game.time * 0.08;
_tmp.set(Math.cos(t) * 55, 18, Math.sin(t) * 55).add(a.p);
camPos.copy(_tmp);
camLook.copy(a.p);
game.camInit = true;
}
camera.up.set(0, 1, 0);
camera.position.copy(camPos);
camera.lookAt(camLook);
}
// ============================================================
// HUD
// ============================================================
const $ = id => document.getElementById(id);
const tapePool = { spd: [], alt: [] };
function renderTape(id, pool, value, pxPerUnit, step, bigStep, fmt) {
const box = $(id);
const h = box.clientHeight || 260;
const span = Math.ceil((h / 2) / pxPerUnit / step) * step + step;
const base = Math.round(value / step) * step;
let n = 0;
for (let v = base - span; v <= base + span; v += step) {
if (v < 0) continue;
let el = pool[n];
if (!el) { el = document.createElement('div'); pool.push(el); box.querySelector('.ticks').appendChild(el); }
el.style.display = 'block';
el.style.top = (h / 2 - (v - value) * pxPerUnit) + 'px';
el.textContent = (v % bigStep === 0) ? fmt(v) : '';
el.style.transform = 'translateY(-50%)';
n++;
}
for (let i = n; i < pool.length; i++) pool[i].style.display = 'none';
}
// Tapes: pixels per unit
const SPD_PX = 1.6, ALT_PX = 0.22;
function updateHUD(a, dt) {
const kts = a.V * KT;
$('spdVal').textContent = Math.round(kts);
$('machVal').textContent = 'M ' + (a.V / 340.3).toFixed(2);
const altFt = Math.max(0, a.alt * FT);
$('altVal').textContent = Math.round(altFt);
$('vsVal').textContent = 'VS ' + Math.round(a.climb * FT * 60 / 100) * 100;
renderTape('spdTape', tapePool.spd, kts, SPD_PX, 10, 20, v => v);
renderTape('altTape', tapePool.alt, altFt, ALT_PX, 20, 100, v => v);
// Heading strip
const hd = a.hdg || 0;
let ht = '';
for (let d = -60; d <= 60; d += 5) {
const h = ((Math.round(hd / 5) * 5 + d) % 360 + 360) % 360;
const x = 210 + (d + (hd - Math.round(hd / 5) * 5)) * 6;
const lbl = { 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[h] || (h % 30 === 0 ? String(h / 10).padStart(2, '0') : '');
ht += `<i style="left:${x}px"></i>`;
if (lbl) ht += `<span style="left:${x}px">${lbl}</span>`;
}
$('hdgTicks').innerHTML = ht;
$('hdgVal').textContent = String(Math.round(hd) % 360).padStart(3, '0') + '°';
// Horizon: pitch ladder & bank
const pitchDeg = a.pitch / DEG, bankDeg = a.bank / DEG;
const pxPerDeg = 6;
const hi = $('horizonInner');
hi.style.transform = `rotate(${-bankDeg}deg)`;
const off = pitchDeg * pxPerDeg;
const top = 50 + (off / 260) * 100;
hi.style.background = `linear-gradient(to bottom, rgba(70,130,200,0.28) 0, rgba(70,130,200,0.28) ${top - 0.01}%, rgba(90,60,30,0.32) ${top}%, rgba(90,60,30,0.32) 100%)`;
let lad = '';
for (let d = -30; d <= 30; d += 5) {
if (d === 0) continue;
const y = -(d - pitchDeg) * pxPerDeg;
const w = d % 10 === 0 ? 70 : 36;
lad += `<div style="position:absolute;left:50%;top:${y}px;width:${w}px;margin-left:${-w / 2}px;height:1px;background:#9dffb0"></div>`;
if (d % 10 === 0) lad += `<div style="position:absolute;left:50%;top:${y - 7}px;transform:translateX(-50%);font-size:10px;color:#9dffb0">${Math.abs(d)}</div>`;
}
$('pitchLadder').innerHTML = lad;
// Horizon line at 0 pitch
$('pitchLadder').style.top = (130) + 'px';
$('pitchLadder').style.height = '0px';
$('pitchLadder').style.width = '100%';
$('pitchLadder').style.transform = 'translateY(0)';
$('pitchVal').textContent = pitchDeg.toFixed(1) + '°';
$('bankVal').textContent = bankDeg.toFixed(0) + '°';
$('gVal').textContent = a.n.toFixed(2) + ' G';
const slip = a.beta / DEG;
$('slipVal').textContent = Math.abs(slip) < 0.5 ? 'CTR' : (slip > 0 ? 'L ' : 'R ') + Math.abs(slip).toFixed(1);
$('densVal').textContent = Math.round(a.rho / RHO0 * 100) + '%';
const tC = (T0 - 0.0065 * a.alt) - 273.15;
$('tmpVal').textContent = Math.round(tC) + '°C';
// Engine & gauges
$('rpmVal').textContent = Math.round(a.rpm * 100) + '%';
$('rpmBar').style.width = (a.rpm * 100) + '%';
const egt = clamp(a.rpm * 0.8 + a.thr * 0.1 + (a.damage / 100) * 0.2, 0, 1);
$('tempVal').textContent = Math.round(egt * 100) + '%';
$('tempBar').style.width = (egt * 100) + '%';
$('tempBar').style.background = egt > 0.9 ? '#ff5a4a' : '';
const aoaDeg = a.alpha / DEG;
$('aoaVal').textContent = aoaDeg.toFixed(1) + '°';
$('aoaBar').style.width = clamp((aoaDeg + 5) / 30, 0, 1) * 100 + '%';
$('aoaBar').style.background = a.stall ? '#ff5a4a' : '';
$('cfgVal').textContent = `FLP ${['UP', '½', 'FULL'][a.flap]} / ${a.gearDown ? 'DN' : 'UP'}`;
$('dmgVal').textContent = Math.round(a.damage) + '%';
$('thrVal').textContent = Math.round(a.thr * 100) + '%';
$('thrBar').style.width = (a.thr * 100) + '%';
// Warnings
const agl = a.p.y - groundH(a.p.x, a.p.z);
let warn = '';
if (a.stall) warn = 'STALL';
else if (agl < 90 && a.climb < -4 && !a.onGround) warn = 'PULL UP';
else if (a.V * KT > 600 && game.mission === 'takeoff') warn = 'OVERSPEED';
else if (a.damage > 70) warn = 'STRUCTURAL DAMAGE';
const wEl = $('warn');
wEl.style.display = warn ? 'block' : 'none';
wEl.textContent = warn;
// Objectives panel
const obj = $('obj'), sc = $('score');
if (game.mission === 'takeoff') {
const ph = game.phase === 0 ? 'CLIMB TO 1,000 FT AGL' : game.phase === 1 ? 'TURN BACK · LAND HDG 000 · TOUCHDOWN ZONE NEAR THRESHOLD' : 'ROLL OUT AND STOP';
obj.innerHTML = `<div class="t">OBJECTIVE</div>${ph}`;
sc.innerHTML = `<div class="t" style="color:#ffe06b;letter-spacing:2px;font-size:11px">FUEL</div>${Math.round(a.fuel / FUEL0[a.key] * 100)}%`;
} else {
const alive = game.enemies.filter(e => !e.dead).length;
obj.innerHTML = `<div class="t">OBJECTIVE</div>DESTROY ${alive} HOSTILE${alive === 1 ? '' : 'S'}`;
sc.innerHTML = `<div class="t" style="color:#ffe06b;letter-spacing:2px;font-size:11px">KILLS</div>${game.kills}<br><span style="color:#ffe06b;font-size:11px;letter-spacing:2px">AMMO</span> ${game.ammo}`;
}
$('msg').textContent = game.msgT > 0 ? game.msgText : '';
// Gear / flap markers + vertical speed tape is in altTape
// Blackout / redout by sustained load
const gN = a.n;
const bo = gN > 4.5 ? clamp((gN - 4.5) / 3.5, 0, 1) : 0;
const ro = gN < -1.5 ? clamp((-gN - 1.5) / 2, 0, 1) : 0;
$('blackout').style.opacity = bo;
$('redout').style.opacity = ro;
$('damage').style.background = `rgba(255,0,0,${game.hitFlash > 0 ? 0.25 : 0})`;
game.hitFlash = Math.max(0, game.hitFlash - dt);
// Enemy target markers (dogfight)
const tgt = $('tgt');
tgt.style.display = 'none';
if (game.mission === 'dogfight') {
let best = null, bestD = 1e9;
for (const e of game.enemies) {
if (e.dead) continue;
_ff.copy(e.a.p).project(camera);
if (_ff.z > 1 || _ff.z < -1) continue;
const d = e.a.p.distanceTo(a.p);
if (d < bestD) { bestD = d; best = { e, pr: _ff.clone() }; }
}
if (best && bestD < TGT_RANGE_MAX) {
const sx = (best.pr.x * 0.5 + 0.5) * innerWidth;
const sy = (-best.pr.y * 0.5 + 0.5) * innerHeight;
tgt.style.display = 'block';
tgt.style.left = sx + 'px'; tgt.style.top = sy + 'px';
$('tgtLbl').textContent = `HOSTILE ${Math.round(bestD)} m · ${Math.round(best.e.hp)}%`;
}
}
}
// ============================================================
// RENDER / FRAME LOOP
// ============================================================
function syncMeshes(dt) {
const a = game.ac;
if (game.state === 'flying' || game.state === 'paused' || game.state === 'done') {
game.mesh.position.copy(a.p);
game.mesh.quaternion.copy(a.q);
if (game.prop) game.prop.rotation.x += dt * (60 * a.rpm + 4);
}
for (const e of game.enemies) {
if (e.dead) continue;
e.mesh.position.copy(e.a.p);
e.mesh.quaternion.copy(e.a.q);
if (e.flash > 0) e.flash -= dt;
}
// Expl
for (let i = game.expl.length - 1; i >= 0; i--) {
const x = game.expl[i];
x.t += dt;
const k = x.t / x.life;
x.mesh.scale.setScalar(Math.max(0.1, x.maxS * Math.sqrt(k)));
x.mesh.material.opacity = Math.max(0, 1 - k);
if (k >= 1) { scene.remove(x.mesh); game.expl.splice(i, 1); }
}
// Clouds drift with wind
for (const c of clouds) {
c.position.x += game.wind.x * dt * 0.5;
c.position.z += game.wind.z * dt * 0.5;
if (c.position.x > 8000) c.position.x -= 16000;
if (c.position.x < -8000) c.position.x += 16000;
if (c.position.z > 8000) c.position.z -= 16000;
if (c.position.z < -8000) c.position.z += 16000;
}
}
let lastT = performance.now(), acc = 0, frameN = 0;
function frame(now) {
requestAnimationFrame(frame);
let dt = Math.min(0.1, (now - lastT) / 1000);
lastT = now;
frameN++;
if (game.state === 'flying') {
acc += dt;
let steps = 0;
while (acc >= PHYS_DT && steps < 40) { physicsStep(PHYS_DT); acc -= PHYS_DT; steps++; }
if (steps >= 40) acc = 0;
} else if (game.state === 'menu') {
game.menuT += dt;
// Idle orbit camera for menu
const t = game.menuT * 0.05;
camera.position.set(Math.cos(t) * 900 + 0, 260, Math.sin(t) * 900 - 1400);
camera.lookAt(0, 90, -1300);
}
if (game.state === 'flying' || game.state === 'paused' || game.state === 'done') {
const a = game.ac;
syncMeshes(dt);
if (game.state !== 'paused') updateCamera(dt, a);
if (game.state === 'flying') {
if (game.ended && game.endSuccess === false) { /* nothing */ }
updateHUD(a, dt);
AudioSys.update(a, { cockpit: game.camMode === 1, nearMountain: smooth(400, 1400, a.alt) * 0.8 });
}
} else {
syncMeshes(dt);
}
// Keep the sky following the camera
renderer.render(scene, camera);
}
function onResize() {
renderer.setSize(innerWidth, innerHeight, false);
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
}
addEventListener('resize', onResize);
onResize();
buildMenu();
window.__dbg = { game, keys };
// Initial menu camera setup
camera.position.set(0, 260, -200);
// Start loop
requestAnimationFrame(frame);
// Tie up loose state
game.ac = newAirframe(selPlane);
game.key = selPlane; game.mission = selMission;
game.mesh = buildAircraft(selPlane).group;
scene.add(game.mesh);
game.mesh.visible = false;
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"94a550d3-24f9-4fe7-a5c9-42202430eca1","tokensIn":2128792,"tokensOut":230332,"tokensTotal":2359124,"cost":0.20988695499999996,"turns":32,"toolCalls":40,"failedToolCalls":0,"timestamp":"2026-10-08T03:29:17.511Z"} -->