Flight Simulator
Qwen 3.6 27b MTP · 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 103,261 bytes · SHA-256 6aea09bc4e88
<!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://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; color: #fff; user-select: none; }
#app { width: 100vw; height: 100vh; position: relative; }
#canvas-container { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
/* Menu Screen */
#menu-screen {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 40%, #0d1b2a 100%);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100; overflow-y: auto; padding: 20px;
}
.title { font-size: 4em; font-weight: 900; letter-spacing: 8px; background: linear-gradient(90deg, #ff6b35, #f7c948, #ff6b35); -webkit-background-clip: text; -webkit-text-fill-color: transparent; text-shadow: none; margin-bottom: 5px; }
.subtitle { font-size: 1.2em; color: #8899aa; letter-spacing: 4px; margin-bottom: 40px; }
h2 { color: #f7c948; font-size: 1.3em; margin-bottom: 15px; text-transform: uppercase; letter-spacing: 2px; }
h3 { color: #aabbcc; font-size: 1em; margin-bottom: 10px; }
/* Aircraft Cards */
.aircraft-cards, .scenario-cards { display: flex; gap: 15px; flex-wrap: wrap; justify-content: center; margin-bottom: 25px; }
.ac-card, .sc-card {
width: 200px; padding: 15px; border-radius: 10px; cursor: pointer;
background: rgba(255,255,255,0.05); border: 2px solid rgba(255,255,255,0.1);
transition: all 0.3s; text-align: center;
}
.ac-card:hover, .sc-card:hover { background: rgba(255,255,255,0.1); border-color: #f7c948; transform: translateY(-3px); }
.ac-card.selected, .sc-card.selected { border-color: #ff6b35; background: rgba(255,107,53,0.15); box-shadow: 0 0 20px rgba(255,107,53,0.3); }
.ac-icon { font-size: 3em; margin-bottom: 8px; }
.ac-name { font-weight: bold; font-size: 1.1em; color: #fff; margin-bottom: 5px; }
.ac-stats { font-size: 0.75em; color: #8899aa; line-height: 1.6; text-align: left; }
.sc-name { font-weight: bold; font-size: 1em; color: #fff; margin-bottom: 5px; }
.sc-desc { font-size: 0.75em; color: #8899aa; line-height: 1.4; }
/* Start Button */
#start-btn {
padding: 15px 50px; font-size: 1.2em; font-weight: bold; letter-spacing: 3px;
background: linear-gradient(90deg, #ff6b35, #e85d26); color: #fff; border: none;
border-radius: 8px; cursor: pointer; transition: all 0.3s; margin-top: 15px;
}
#start-btn:hover:not(:disabled) { transform: scale(1.05); box-shadow: 0 0 30px rgba(255,107,53,0.5); }
#start-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* Controls Info */
#controls-info { margin-top: 25px; padding: 15px 25px; background: rgba(255,255,255,0.03); border-radius: 8px; max-width: 400px; }
.control-row { display: flex; justify-content: space-between; margin: 4px 0; font-size: 0.8em; color: #99aabb; }
kbd { background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 3px; font-family: monospace; color: #f7c948; }
/* HUD */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 50; }
.hud-box { background: rgba(0,20,40,0.6); border: 1px solid rgba(0,255,200,0.3); padding: 5px 10px; border-radius: 4px; display: flex; align-items: center; gap: 5px; }
.hud-label { font-size: 0.6em; color: #00ffc8; text-transform: uppercase; letter-spacing: 1px; }
.hud-unit { font-size: 0.6em; color: #00ffc8; }
#hud-top { position: absolute; top: 15px; left: 50%; transform: translateX(-50%); display: flex; gap: 20px; }
.hud-altitude span:nth-child(2), .hud-heading span:nth-child(2) { font-size: 1.4em; font-weight: bold; color: #fff; min-width: 60px; text-align: center; }
/* Speed & Alt Tapes */
.hud-tape { position: absolute; top: 50%; transform: translateY(-50%); width: 80px; height: 200px; overflow: hidden; background: rgba(0,20,40,0.3); border: 1px solid rgba(0,255,200,0.2); }
.hud-left { left: 15px; }
.hud-right { right: 15px; }
.tape-values { position: absolute; width: 100%; transition: transform 0.1s linear; }
.tape-val { height: 30px; display: flex; align-items: center; justify-content: center; font-size: 0.8em; color: #66aacc; border-bottom: 1px solid rgba(0,255,200,0.1); }
.tape-current { position: absolute; top: 50%; transform: translateY(-50%); width: 100%; text-align: center; font-size: 1.3em; font-weight: bold; color: #fff; z-index: 2; background: rgba(0,20,40,0.8); padding: 3px 0; }
.tape-label { position: absolute; bottom: 5px; width: 100%; text-align: center; font-size: 0.7em; color: #00ffc8; }
/* Center Horizon */
#hud-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 400px; height: 300px; }
#horizon-canvas { width: 100%; height: 100%; border-radius: 8px; opacity: 0.7; }
/* Bottom HUD */
#hud-bottom { position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%); display: flex; gap: 15px; align-items: center; }
.hud-throttle span:nth-child(2) { font-size: 1.2em; font-weight: bold; color: #fff; min-width: 40px; text-align: center; }
.throttle-bar { width: 80px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; margin-left: 5px; }
#throttle-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #00ff88, #f7c948, #ff6b35); transition: width 0.1s; border-radius: 3px; }
.hud-gforce span:nth-child(2) { font-size: 1.2em; font-weight: bold; color: #fff; min-width: 40px; text-align: center; }
#hud-warnings { min-width: 150px; text-align: center; }
#warning-text { font-size: 0.8em; color: #ff4444; font-weight: bold; animation: blink 0.5s infinite alternate; }
@keyframes blink { from { opacity: 1; } to { opacity: 0.3; } }
/* Crosshair */
#crosshair { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 40px; height: 40px; border: 2px solid rgba(255,0,0,0.7); border-radius: 50%; }
#crosshair::before { content: '+'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgba(255,0,0,0.8); font-size: 1.5em; }
/* Overlays */
#pause-overlay, #game-over, #crash-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 200;
}
#pause-overlay h2 { font-size: 3em; color: #f7c948; margin-bottom: 10px; }
#game-over h2, #crash-overlay h2 { font-size: 2.5em; margin-bottom: 15px; }
#go-title { color: #00ff88; }
#crash-overlay h2 { color: #ff4444; }
#game-over p, #crash-overlay p { color: #aabbcc; font-size: 1.1em; margin-bottom: 20px; max-width: 500px; text-align: center; }
button { padding: 12px 30px; font-size: 1em; font-weight: bold; border: none; border-radius: 6px; cursor: pointer; transition: all 0.2s; margin: 5px; }
#restart-btn, #restart-crash-btn { background: linear-gradient(90deg, #ff6b35, #e85d26); color: #fff; }
#menu-btn { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.3) !important; }
button:hover { transform: scale(1.05); }
/* Dogfight enemy indicators */
.enemy-indicator { position: absolute; width: 8px; height: 8px; background: #ff4444; border-radius: 50%; pointer-events: none; z-index: 60; box-shadow: 0 0 10px #ff4444; }
/* ── css: hud-extra ── */
.hud-health { display: flex; align-items: center; gap: 5px; }
.health-bar { width: 80px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; }
#health-fill { height: 100%; width: 100%; background: linear-gradient(90deg, #ff4444, #ffaa00, #00ff88); transition: width 0.2s; border-radius: 3px; }
</style>
</head>
<body>
<div id="app">
<!-- Main canvas container -->
<div id="canvas-container"></div>
<!-- Loading / Menu Screen -->
<div id="menu-screen">
<h1 class="title">APEX AERO</h1>
<p class="subtitle">Flight Simulator</p>
<div id="aircraft-select">
<h2>Select Aircraft</h2>
<div class="aircraft-cards" id="aircraft-cards"></div>
</div>
<div id="scenario-select">
<h2>Select Scenario</h2>
<div class="scenario-cards" id="scenario-cards"></div>
</div>
<button id="start-btn" disabled="">LAUNCH SIMULATION</button>
<div id="controls-info">
<h3>Controls</h3>
<div class="control-row"><span>Pitch Down/Up:</span><kbd>W / S</kbd></div>
<div class="control-row"><span>Roll Left/Right:</span><kbd>A / D</kbd></div>
<div class="control-row"><span>Yaw Left/Right:</span><kbd>Q / E</kbd></div>
<div class="control-row"><span>Throttle Up/Down:</span><kbd>Shift / Ctrl</kbd></div>
<div class="control-row"><span>Brakes:</span><kbd>B</kbd></div>
<div class="control-row"><span>Gear Toggle:</span><kbd>G</kbd></div>
<div class="control-row"><span>Flaps:</span><kbd>F</kbd></div>
<div class="control-row"><span>Fire (Dogfight):</span><kbd>Space / Left Click</kbd></div>
<div class="control-row"><span>Camera Toggle:</span><kbd>C</kbd></div>
</div>
</div>
<!-- HUD Overlay -->
<div id="hud" style="display:none;">
<!-- Top bar: altitude and heading -->
<div id="hud-top">
<div class="hud-box hud-altitude">
<span class="hud-label">ALT</span>
<span id="hud-alt-value">0</span><span class="hud-unit">FT</span>
</div>
<div class="hud-box hud-heading">
<span class="hud-label">HDG</span>
<span id="hud-hdg-value">360</span><span class="hud-unit">°</span>
</div>
</div>
<!-- Left side: airspeed tape -->
<div id="hud-speed-tape" class="hud-tape hud-left">
<div class="tape-values" id="speed-tape-values"></div>
<div class="tape-current" id="speed-current">0</div>
<span class="hud-unit tape-label">KTS</span>
</div>
<!-- Right side: altitude tape -->
<div id="hud-alt-tape" class="hud-tape hud-right">
<div class="tape-values" id="alt-tape-values"></div>
<div class="tape-current" id="alt-current">0</div>
<span class="hud-unit tape-label">FT</span>
</div>
<!-- Center: pitch ladder and horizon -->
<div id="hud-center">
<canvas id="horizon-canvas" width="400" height="300"></canvas>
<div id="hud-pitch-ladder"></div>
<div id="hud-aircraft-symbol"></div>
</div>
<!-- Bottom: throttle, G-force, warnings -->
<div id="hud-bottom">
<div class="hud-box hud-throttle">
<span class="hud-label">THR</span>
<span id="hud-thr-value">0</span><span class="hud-unit">%</span>
<div class="throttle-bar"><div id="throttle-fill"></div></div>
</div>
<div class="hud-box hud-gforce" id="hud-gforce">
<span class="hud-label">G</span>
<span id="hud-g-value">1.0</span>
</div>
<div class="hud-box hud-warnings" id="hud-warnings"></div>
<div class="hud-box hud-score" id="hud-score" style="display:none;">
<span class="hud-label">SCORE</span>
<span id="hud-score-value">0</span>
</div>
<div class="hud-box hud-health" id="hud-health" style="display:none;">
<span class="hud-label">HP</span>
<div class="health-bar"><div id="health-fill"></div></div>
<span id="hud-hp-value" style="font-size:0.9em;color:#fff;min-width:30px;text-align:center;">100</span>
</div></div>
<!-- Crosshair for dogfight -->
<div id="crosshair" style="display:none;"></div>
</div>
<!-- Pause overlay -->
<div id="pause-overlay" style="display:none;">
<h2>PAUSED</h2>
<p>Press ESC to resume</p>
</div>
<!-- Game Over / Mission Complete -->
<div id="game-over" style="display:none;">
<h2 id="go-title">MISSION COMPLETE</h2>
<p id="go-message"></p>
<button id="restart-btn">PLAY AGAIN</button>
<button id="menu-btn">MAIN MENU</button>
</div>
<!-- Crash overlay -->
<div id="crash-overlay" style="display:none;">
<h2>CRASHED!</h2>
<p id="crash-reason"></p>
<button id="restart-crash-btn">RETRY</button>
</div>
</div>
<script>
'use strict';
// ── const: DEG2RAD ──
const DEG2RAD = Math.PI / 180;
// ── const: RAD2DEG ──
const RAD2DEG = 180 / Math.PI;
// ── const: GRAVITY ──
const GRAVITY = 9.81;
// ── const: SEA_LEVEL_PRESSURE ──
// m/s²
const SEA_LEVEL_PRESSURE = 101325;
// ── const: SEA_LEVEL_TEMP ──
// Pa
const SEA_LEVEL_TEMP = 288.15;
// ── const: TEMP_LAPSE_RATE ──
// K
const TEMP_LAPSE_RATE = 0.0065;
// ── function: getAirDensity ──
// K/m
// Sea level air density (kg/m³)
function getAirDensity(altitudeMeters) {
const h = Math.max(0, altitudeMeters);
const temp = SEA_LEVEL_TEMP - TEMP_LAPSE_RATE * h;
const pressure = SEA_LEVEL_PRESSURE * Math.pow(temp / SEA_LEVEL_TEMP, 5.25588);
return (pressure / (287.05 * temp)); // ideal gas law
}
// ── const: AIRCRAFT_PROFILES ──
// Aircraft profiles with distinct physics characteristics
const AIRCRAFT_PROFILES = {
falcon: {
name: "Falcon X-1",
icon: "🛩️",
description: "Light fighter - agile and fast",
mass: 4500, // kg
wingArea: 28, // m²
maxThrust: 65000, // N (per engine, single)
thrustResponseTime: 1.5, // seconds to full throttle
clMax: 1.8, // max lift coefficient
cd0: 0.02, // zero-lift drag coefficient
kInduced: 0.04, // induced drag factor (1/(π*e*AR))
rollRate: 2.5, // rad/s max
pitchRate: 1.8, // rad/s max
yawRate: 0.6, // rad/s max
momentOfInertiaX: 8000, // kg·m² (roll)
momentOfInertiaY: 25000, // kg·m² (pitch)
momentOfInertiaZ: 30000, // kg·m² (yaw)
maxSpeed: 340, // m/s (~660 kts)
stallSpeed: 35, // m/s at sea level, 1G
color: 0x2244aa,
accentColor: 0xff6b35,
scale: 1.0
},
titan: {
name: "Titan B-2",
icon: "✈️",
description: "Heavy bomber - powerful but slow",
mass: 18000, // kg
wingArea: 75, // m²
maxThrust: 140000, // N (total)
thrustResponseTime: 3.0, // seconds to full throttle
clMax: 2.2, // higher for slower flight
cd0: 0.035, // more drag due to size
kInduced: 0.03,
rollRate: 1.2, // rad/s max - sluggish
pitchRate: 0.8, // rad/s max
yawRate: 0.3, // rad/s max
momentOfInertiaX: 65000,
momentOfInertiaY: 180000,
momentOfInertiaZ: 220000,
maxSpeed: 250, // m/s (~485 kts)
stallSpeed: 45, // m/s - heavier needs more speed
color: 0x3a5a3a,
accentColor: 0x66cc66,
scale: 1.5
},
viper: {
name: "Viper S-7",
icon: "🚀",
description: "Interceptor - extreme performance",
mass: 8200, // kg
wingArea: 36, // m²
maxThrust: 120000, // N (total)
thrustResponseTime: 1.0, // very responsive engine
clMax: 2.5, // delta wing high CL
cd0: 0.018, // clean aerodynamics
kInduced: 0.035,
rollRate: 3.5, // rad/s max - extremely agile
pitchRate: 2.2, // rad/s max
yawRate: 0.8, // rad/s max
momentOfInertiaX: 15000,
momentOfInertiaY: 45000,
momentOfInertiaZ: 55000,
maxSpeed: 380, // m/s (~740 kts) - fastest
stallSpeed: 40, // m/s
color: 0x662222,
accentColor: 0xffcc00,
scale: 1.15
}
};
// ── const: SCENARIOS ──
// Scenario definitions
const SCENARIOS = {
takeoffLanding: {
name: "Takeoff & Landing",
icon: "🛫",
description: "Master precision flight - take off and land safely on the runway"
},
dogfight: {
name: "Dogfight Arena",
icon: "⚔️",
description: "High-speed aerial combat against enemy aircraft"
}
};
// ── const: INPUT ──
// Input state
const INPUT = {
pitchDown: false, pitchUp: false,
rollLeft: false, rollRight: false,
yawLeft: false, yawRight: false,
throttleUp: false, throttleDown: false,
brakes: false, gearToggle: false, flaps: false, fire: false, cameraToggle: false,
mouseX: 0, mouseY: 0, mouseDown: false
};
// ── let: gameState ──
// Game state
let gameState = 'menu';
// ── let: selectedAircraft ──
// menu, playing, paused, gameover, crashed
let selectedAircraft = null;
// ── let: selectedScenario ──
let selectedScenario = null;
// ── class: FlightPhysics ──
class FlightPhysics {
constructor(profile) {
this.profile = profile;
// Position (meters) - world space
this.position = new THREE.Vector3(0, 5, 0);
// Velocity (m/s) - body frame initially aligned with world
this.velocity = new THREE.Vector3(0, 0, 0);
// Orientation as quaternion
this.quaternion = new THREE.Euler(0, 0, 0);
this.orientationQuat = new THREE.Quaternion();
// Angular velocity (rad/s) - body frame
this.angularVelocity = new THREE.Vector3(0, 0, 0);
// Control surfaces deflection (-1 to 1)
this.elevatorDeflection = 0;
this.aileronDeflection = 0;
this.rudderDeflection = 0;
// Engine state
this.throttleTarget = 0; // 0-1 target
this.throttleCurrent = 0; // 0-1 actual (with response lag)
this.currentThrust = 0; // N
// Gear and flaps
this.gearDown = true;
this.flapAngle = 0; // radians, 0 to ~0.35
this.brakeForce = 0; // N per wheel
// Derived state
this.airSpeed = 0; // m/s (true airspeed)
this.angleOfAttack = 0; // radians
this.sideslipAngle = 0; // radians
this.gLoad = 1.0;
this.liftForce = new THREE.Vector3();
this.dragForce = new THREE.Vector3();
this.totalForce = new THREE.Vector3();
// Warnings
this.stallWarning = false;
this.overGWarning = false;
this.speedWarning = false;
}
reset(x, y, z, heading) {
this.position.set(x, y, z);
this.velocity.set(0, 0, 0);
this.quaternion.set(0, -heading * DEG2RAD, 0); // pitch, yaw, roll
this.orientationQuat.setFromEuler(this.quaternion);
this.angularVelocity.set(0, 0, 0);
this.elevatorDeflection = 0;
this.aileronDeflection = 0;
this.rudderDeflection = 0;
this.throttleTarget = 0;
this.throttleCurrent = 0;
this.currentThrust = 0;
this.gearDown = true;
this.flapAngle = 0;
this.brakeForce = 0;
}
update(dt) {
const p = this.profile;
// Clamp dt to prevent physics explosion
dt = Math.min(dt, 0.05);
// Update throttle response (engine lag)
const thrustRate = 1.0 / p.thrustResponseTime;
if (this.throttleCurrent < this.throttleTarget) {
this.throttleCurrent = Math.min(this.throttleTarget, this.throttleCurrent + thrustRate * dt);
} else {
this.throttleCurrent = Math.max(this.throttleTarget, this.throttleCurrent - thrustRate * dt);
}
// Current thrust force (N)
this.currentThrust = p.maxThrust * this.throttleCurrent;
// Get world-space velocity magnitude
const velWorld = this.velocity.length();
this.airSpeed = Math.max(0, -this.velocity.dot(this.getForward()));
// Air density at current altitude
const rho = getAirDensity(Math.max(0, this.position.y));
const dynamicPressure = 0.5 * rho * this.airSpeed * this.airSpeed;
// Calculate angle of attack (simplified)
const forwardBody = new THREE.Vector3(1, 0, 0);
const velBody = this.velocity.clone().applyQuaternion(this.orientationQuat.clone().invert());
if (this.airSpeed > 1.0) {
this.angleOfAttack = Math.atan2(-velBody.y, Math.max(1, -velBody.x));
this.sideslipAngle = Math.asin(Math.max(-1, Math.min(1, velBody.z / Math.max(1, this.airSpeed))));
} else {
this.angleOfAttack = 0;
this.sideslipAngle = 0;
}
// Clamp angle of attack for realistic behavior
const aoaMax = 0.45; // ~26 degrees before stall
const aoaEffective = Math.max(-aoaMax, Math.min(aoaMax, this.angleOfAttack));
// Lift coefficient: CL = CL_max * (1 - |alpha|/alpha_stall) for linear region
// With flap bonus
const flapBonus = this.flapAngle / 0.35; // 0 to 1
let clAlpha = 6.28; // lift curve slope per radian (thin airfoil theory ~2π)
let clBase = clAlpha * aoaEffective;
// Add flap contribution
const clFlap = flapBonus * 1.5;
let clTotal = Math.min(p.clMax + clFlap, clBase + clFlap);
// Stall: if AoA exceeds critical, lift drops dramatically
if (Math.abs(this.angleOfAttack) > aoaMax * 0.8) {
const stallFactor = 1.0 - Math.pow((Math.abs(this.angleOfAttack) - aoaMax * 0.8) / (aoaMax * 0.2), 2);
clTotal *= Math.max(0, stallFactor);
}
this.stallWarning = (this.airSpeed > 5 && Math.abs(this.angleOfAttack) > aoaMax * 0.7);
// Lift force magnitude: L = CL * q * S
const liftMagnitude = clTotal * dynamicPressure * p.wingArea;
// Drag calculation
// Profile drag (parasite): CD0 * q * S
const profileDrag = p.cd0 * dynamicPressure * p.wingArea;
// Induced drag: K * CL² * q * S
const inducedDrag = p.kInduced * clTotal * clTotal * dynamicPressure * p.wingArea;
// Gear and flap drag penalty
const gearDrag = this.gearDown ? 0.015 * dynamicPressure * p.wingArea : 0;
const flapDrag = flapBonus * 0.02 * dynamicPressure * p.wingArea;
const totalDragMagnitude = profileDrag + inducedDrag + gearDrag + flapDrag;
// Speed warning (approaching max)
this.speedWarning = this.airSpeed > p.maxSpeed * 0.9;
// Build force vectors in body frame, then transform to world
// Thrust: along body X axis (forward)
const thrustBody = new THREE.Vector3(this.currentThrust, 0, 0);
const thrustWorld = thrustBody.applyQuaternion(this.orientationQuat);
// Drag: opposite to velocity direction
let dragWorld = new THREE.Vector3(0, 0, 0);
if (this.airSpeed > 0.1) {
const velDir = this.velocity.clone().normalize();
dragWorld.copy(velDir).multiplyScalar(totalDragMagnitude);
}
// Lift: perpendicular to velocity in the plane of symmetry
let liftWorld = new THREE.Vector3(0, 0, 0);
if (this.airSpeed > 0.1) {
const velDir = this.velocity.clone().normalize();
// Lift direction is perpendicular to velocity and right wing vector
const rightBody = new THREE.Vector3(0, 0, 1).applyQuaternion(this.orientationQuat);
const liftDir = new THREE.Vector3().crossVectors(rightBody, velDir).normalize();
liftWorld.copy(liftDir).multiplyScalar(-liftMagnitude); // negative because our convention
}
this.liftForce.copy(liftWorld);
this.dragForce.copy(dragWorld);
// Gravity: straight down in world space
const gravityForce = new THREE.Vector3(0, -p.mass * GRAVITY, 0);
// Brake force (only effective on ground)
let brakeWorld = new THREE.Vector3(0, 0, 0);
if (this.position.y < 2 && this.brakeForce > 0) {
const frictionCoeff = 0.8;
const normalForce = p.mass * GRAVITY - liftMagnitude; // reduced by lift
brakeWorld.copy(this.velocity).normalize().multiplyScalar(-Math.min(this.brakeForce, frictionCoeff * Math.max(0, normalForce)));
}
// Total force
this.totalForce.copy(thrustWorld)
.add(dragWorld)
.add(liftWorld)
.add(gravityForce)
.add(brakeWorld);
// Ground collision: prevent going below terrain
if (this.position.y < 0.5 && liftMagnitude < p.mass * GRAVITY) {
this.totalForce.y = Math.max(0, this.totalForce.y + p.mass * GRAVITY);
this.velocity.y = Math.max(0, this.velocity.y);
// Ground friction on lateral movement
if (this.position.y <= 0.5) {
const groundFriction = 0.9;
this.velocity.x *= (1 - groundFriction * dt);
this.velocity.z *= (1 - groundFriction * dt);
}
}
// Newton's second law: F = ma => a = F/m
const acceleration = this.totalForce.clone().divideScalar(p.mass);
// Integrate velocity and position
this.velocity.add(acceleration.multiplyScalar(dt));
this.position.add(this.velocity.clone().multiplyScalar(dt));
// Calculate G-load (normal force / weight)
const normalAccel = liftMagnitude / p.mass;
this.gLoad = 1.0 + normalAccel / GRAVITY;
if (this.position.y < 2) {
this.gLoad = Math.max(1, this.gLoad); // ground supports weight
}
this.overGWarning = Math.abs(this.gLoad) > 7;
// === ROTATIONAL DYNAMICS ===
// Calculate control surface moments
// Elevator moment (pitch): proportional to deflection and dynamic pressure
const elevatorMoment = -this.elevatorDeflection * dynamicPressure * p.wingArea * 3.0 / p.mass;
// Aileron moment (roll)
const aileronMoment = this.aileronDeflection * dynamicPressure * p.wingArea * 2.5 / p.mass;
// Rudder moment (yaw) + adverse yaw from ailerons
const rudderMoment = -this.rudderDeflection * dynamicPressure * p.wingArea * 1.5 / p.mass;
const adverseYaw = -this.aileronDeflection * dynamicPressure * p.wingArea * 0.3 / p.mass;
// Damping moments (proportional to angular velocity)
const rollDamping = -this.angularVelocity.x * dynamicPressure * p.wingArea * 0.5 / p.mass;
const pitchDamping = -this.angularVelocity.y * dynamicPressure * p.wingArea * 1.0 / p.mass;
const yawDamping = -this.angularVelocity.z * dynamicPressure * p.wingArea * 0.8 / p.mass;
// Total angular acceleration (with moment of inertia)
const rollAccel = (aileronMoment + rollDamping) / Math.max(1, p.momentOfInertiaX / 1000);
const pitchAccel = (elevatorMoment + pitchDamping) / Math.max(1, p.momentOfInertiaY / 1000);
const yawAccel = (rudderMoment + adverseYaw + yawDamping) / Math.max(1, p.momentOfInertiaZ / 1000);
// Integrate angular velocity with rate limits
this.angularVelocity.x += rollAccel * dt;
this.angularVelocity.y += pitchAccel * dt;
this.angularVelocity.z += yawAccel * dt;
// Clamp angular rates to aircraft limits
const speedFactor = Math.min(1, this.airSpeed / 30); // reduced control at low speed
this.angularVelocity.x = THREE.MathUtils.clamp(this.angularVelocity.x, -p.rollRate * speedFactor, p.rollRate * speedFactor);
this.angularVelocity.y = THREE.MathUtils.clamp(this.angularVelocity.y, -p.pitchRate * speedFactor, p.pitchRate * speedFactor);
this.angularVelocity.z = THREE.MathUtils.clamp(this.angularVelocity.z, -p.yawRate * speedFactor, p.yawRate * speedFactor);
// Integrate orientation using angular velocity
const angVelQuat = new THREE.Quaternion().setFromEuler(
new THREE.Euler(this.angularVelocity.x * dt, this.angularVelocity.y * dt, this.angularVelocity.z * dt)
);
this.orientationQuat.multiply(angVelQuat).normalize();
// Update Euler angles for reference
this.quaternion.setFromQuaternion(this.orientationQuat);
}
getForward() {
return new THREE.Vector3(1, 0, 0).applyQuaternion(this.orientationQuat);
}
getUp() {
return new THREE.Vector3(0, 1, 0).applyQuaternion(this.orientationQuat);
}
getRight() {
return new THREE.Vector3(0, 0, 1).applyQuaternion(this.orientationQuat);
}
}
// ── function: buildEnemyModel ──
function buildEnemyModel() {
const group = new THREE.Group();
const s = 0.9;
const bodyMat = new THREE.MeshPhongMaterial({ color: 0x882222, shininess: 60 });
const darkMat = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, shininess: 40 });
// Simple enemy fighter shape
const fuselageGeo = new THREE.CylinderGeometry(0.25 * s, 0.2 * s, 5 * s, 6);
const fuselage = new THREE.Mesh(fuselageGeo, bodyMat);
fuselage.rotation.z = Math.PI / 2;
group.add(fuselage);
// Nose
const noseGeo = new THREE.ConeGeometry(0.25 * s, 1.2 * s, 6);
const nose = new THREE.Mesh(noseGeo, bodyMat);
nose.rotation.z = -Math.PI / 2;
nose.position.x = 3.1 * s;
group.add(nose);
// Wings
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(2 * s, -1 * s);
wingShape.lineTo(2 * s, -2.2 * s);
wingShape.lineTo(-0.5 * s, -2 * s);
wingShape.lineTo(-0.5 * s, 0);
const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth: 0.05 * s });
const rWing = new THREE.Mesh(wingGeo, bodyMat);
rWing.rotation.x = -Math.PI / 2;
rWing.position.set(0.3 * s, -0.05 * s, 0.03 * s);
group.add(rWing);
const lWing = new THREE.Mesh(wingGeo, bodyMat);
lWing.rotation.x = Math.PI / 2;
lWing.position.set(0.3 * s, -0.05 * s, -0.03 * s);
group.add(lWing);
// Tail fin
const tailFinShape = new THREE.Shape();
tailFinShape.moveTo(0, 0);
tailFinShape.lineTo(-1 * s, -0.2 * s);
tailFinShape.lineTo(-2 * s, 0);
const tailGeo = new THREE.ExtrudeGeometry(tailFinShape, { depth: 0.03 * s });
const tailFin = new THREE.Mesh(tailGeo, bodyMat);
tailFin.position.set(-1.5 * s, 0.2 * s, -0.015 * s);
group.add(tailFin);
// Red accent on wings
[-2, 2].forEach(z => {
const tipGeo = new THREE.BoxGeometry(0.3 * s, 0.06 * s, 0.15 * s);
const tipMat = new THREE.MeshPhongMaterial({ color: 0xff0000 });
const tip = new THREE.Mesh(tipGeo, tipMat);
tip.position.set(2 * s, -0.05 * s, z * s);
group.add(tip);
});
// Exhaust glow
const exhaustGeo = new THREE.CylinderGeometry(0.12 * s, 0.18 * s, 0.3 * s, 6);
const exhaustMat = new THREE.MeshPhongMaterial({ color: 0xff4400, emissive: 0xff2200 });
const exhaust = new THREE.Mesh(exhaustGeo, exhaustMat);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.x = -2.65 * s;
group.add(exhaust);
return group;
}
// ── class: NoiseGenerator ──
// Simplex-like noise implementation for terrain generation
class NoiseGenerator {
constructor(seed) {
this.seed = seed || Math.random() * 65536;
this.perm = new Uint8Array(512);
this.grad3 = [
[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],
[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],
[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]
];
this.init();
}
init() {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
// Shuffle with seed
let s = this.seed;
for (let i = 255; i > 0; i--) {
s = (s * 16807 + 0) % 2147483647;
const j = s % (i + 1);
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) {
this.perm[i] = p[i & 255];
}
}
dot3(g, x, y) {
return g[0] * x + g[1] * y;
}
noise2D(x, y) {
const F2 = 0.5 * (Math.sqrt(3) - 1);
const G2 = (3 - Math.sqrt(3)) / 6;
const s = (x + y) * F2;
const i = Math.floor(x + s);
const j = Math.floor(y + s);
const t = (i + j) * G2;
const X0 = i - t;
const Y0 = j - t;
const x0 = x - X0;
const y0 = y - Y0;
let i1, j1;
if (x0 > y0) { i1 = 1; j1 = 0; }
else { i1 = 0; j1 = 1; }
const x1 = x0 - i1 + G2;
const y1 = y0 - j1 + G2;
const x2 = x0 - 1 + 2 * G2;
const y2 = y0 - 1 + 2 * G2;
const ii = i & 255;
const jj = j & 255;
const gi0 = this.perm[ii + this.perm[jj]] % 12;
const gi1 = this.perm[ii + i1 + this.perm[jj + j1]] % 12;
const gi2 = this.perm[ii + 1 + this.perm[jj + 1]] % 12;
let n0, n1, n2;
let t0 = 0.5 - x0 * x0 - y0 * y0;
if (t0 < 0) n0 = 0;
else { t0 *= t0; n0 = t0 * t0 * this.dot3(this.grad3[gi0], x0, y0); }
let t1 = 0.5 - x1 * x1 - y1 * y1;
if (t1 < 0) n1 = 0;
else { t1 *= t1; n1 = t1 * t1 * this.dot3(this.grad3[gi1], x1, y1); }
let t2 = 0.5 - x2 * x2 - y2 * y2;
if (t2 < 0) n2 = 0;
else { t2 *= t2; n2 = t2 * t2 * this.dot3(this.grad3[gi2], x2, y2); }
return 70 * (n0 + n1 + n2);
}
// Fractal Brownian Motion for realistic terrain
fbm(x, y, octaves) {
let value = 0;
let amplitude = 1;
let frequency = 1;
let maxValue = 0;
for (let i = 0; i < octaves; i++) {
value += amplitude * this.noise2D(x * frequency, y * frequency);
maxValue += amplitude;
amplitude *= 0.5;
frequency *= 2;
}
return value / maxValue;
}
// Get terrain height at world position (x, z)
getHeight(wx, wz) {
const scale = 0.003;
let h = this.fbm(wx * scale, wz * scale, 6);
// Normalize to 0-1 range then map to height
h = (h + 1) / 2;
// Apply power for mountain peaks
h = Math.pow(h, 1.5);
// Base terrain height
let height = h * 800; // max ~800m
// Add some detail noise
const detail = this.fbm(wx * scale * 4, wz * scale * 4, 3) * 50;
height += detail;
return Math.max(0, height);
}
// Get terrain color based on height and slope
getTerrainColor(height, slope) {
if (height < 10) return new THREE.Color(0x2a6e3f); // grass lowland
if (height < 50) return new THREE.Color(0x3d8b4f); // forest green
if (height < 150) return new THREE.Color(0x5a7a4a); // dark green hills
if (height < 300) return new THREE.Color(0x6b6b5e); // rocky brown
if (height < 500) return new THREE.Color(0x8a8a7a); // gray rock
if (height < 650) return new THREE.Color(0xb0b0a8); // light rock
return new THREE.Color(0xe8e8f0); // snow peaks
}
}
// ── function: generateTerrain ──
// Terrain mesh generator with LOD-like approach
function generateTerrain(noise, size, resolution) {
const halfSize = size / 2;
const segments = resolution;
const geometry = new THREE.PlaneGeometry(size, size, segments, segments);
geometry.rotateX(-Math.PI / 2); // Lay flat
const positions = geometry.attributes.position.array;
const colors = new Float32Array(positions.length);
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i];
const z = positions[i + 2];
// Get height from noise
const h = noise.getHeight(x, z);
positions[i + 1] = h;
// Calculate approximate slope for coloring
const dx = size / segments;
const dz = size / segments;
const hL = noise.getHeight(x - dx, z);
const hR = noise.getHeight(x + dx, z);
const hU = noise.getHeight(x, z - dz);
const hD = noise.getHeight(x, z + dz);
const slope = Math.sqrt(Math.pow((hR - hL) / (2 * dx), 2) + Math.pow((hD - hU) / (2 * dz), 2));
// Color based on height and slope
let color;
if (slope > 0.5) {
color = new THREE.Color(0x6b6b5e); // steep = rocky
} else {
color = noise.getTerrainColor(h, slope);
}
colors[i] = color.r;
colors[i + 1] = color.g;
colors[i + 2] = color.b;
}
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.computeVertexNormals();
const material = new THREE.MeshPhongMaterial({
vertexColors: true,
flatShading: false,
shininess: 10,
side: THREE.DoubleSide
});
return new THREE.Mesh(geometry, material);
}
// ── function: generateRunway ──
// Generate a runway for takeoff/landing scenario
function generateRunway(length) {
const group = new THREE.Group();
// Runway surface (dark asphalt)
const runwayGeo = new THREE.PlaneGeometry(30, length || 2000);
const runwayMat = new THREE.MeshPhongMaterial({
color: 0x333333, shininess: 5
});
const runway = new THREE.Mesh(runwayGeo, runwayMat);
runway.rotation.x = -Math.PI / 2;
group.add(runway);
// Center line markings
for (let z = -(length || 2000) / 2 + 50; z < (length || 2000) / 2 - 50; z += 40) {
const markGeo = new THREE.PlaneGeometry(1, 20);
const markMat = new THREE.MeshPhongMaterial({ color: 0xffffff });
const mark = new THREE.Mesh(markGeo, markMat);
mark.rotation.x = -Math.PI / 2;
mark.position.set(0, 0.05, z);
group.add(mark);
}
// Edge lights (yellow)
[-15, 15].forEach(x => {
for (let z = -(length || 2000) / 2; z < (length || 2000) / 2; z += 30) {
const lightGeo = new THREE.SphereGeometry(0.3, 6, 4);
const lightMat = new THREE.MeshPhongMaterial({
color: 0xffcc00, emissive: 0xffaa00, emissiveIntensity: 0.5
});
const light = new THREE.Mesh(lightGeo, lightMat);
light.position.set(x, 0.3, z);
group.add(light);
}
});
// Threshold markings (white stripes at start)
for (let i = 0; i < 6; i++) {
const stripeGeo = new THREE.PlaneGeometry(4, 2);
const stripeMat = new THREE.MeshPhongMaterial({ color: 0xffffff });
[-8 + i * 3.2, 8 - i * 3.2].forEach(x => {
const stripe = new THREE.Mesh(stripeGeo, stripeMat);
stripe.rotation.x = -Math.PI / 2;
stripe.position.set(x, 0.06, -(length || 2000) / 2 + 100);
group.add(stripe);
});
}
// Runway numbers (simplified as white rectangles)
const numGeo = new THREE.PlaneGeometry(8, 3);
const numMat = new THREE.MeshPhongMaterial({ color: 0xffffff });
[-150, 150].forEach(z => {
const num = new THREE.Mesh(numGeo, numMat);
num.rotation.x = -Math.PI / 2;
num.position.set(0, 0.06, z);
group.add(num);
});
return group;
}
// ── function: generateWater ──
// Generate water plane for ocean/coastline effect
function generateWater(size) {
const geo = new THREE.PlaneGeometry(size * 2, size * 2, 50, 50);
geo.rotateX(-Math.PI / 2);
const mat = new THREE.MeshPhongMaterial({
color: 0x1a4a7a,
transparent: true,
opacity: 0.7,
shininess: 100,
specular: 0x88bbff
});
const water = new THREE.Mesh(geo, mat);
water.position.y = -5; // Slightly below terrain base
return { mesh: water, material: mat };
}
// ── class: TakeoffLandingMission ──
// Takeoff & Landing Mission Manager
class TakeoffLandingMission {
constructor(scene, physics) {
this.scene = scene;
this.physics = physics;
this.phase = 'taxi'; // taxi, takeoff, climb, cruise, descent, approach, landing, complete
this.score = 0;
this.runwayLength = 2500;
this.targetAltitude = 300; // meters (~1000ft) for cruise
this.landingZoneStart = -this.runwayLength / 2 + 200;
// Landing metrics
this.touchdownSpeed = 0;
this.touchdownFlareRate = 0;
this.touchdownDistanceFromThreshold = 0;
// Runway object reference
this.runway = null;
}
init() {
// Place runway at origin, flat area
const noise = new NoiseGenerator(42);
this.noise = noise;
// Create runway
this.runway = generateRunway(this.runwayLength);
this.scene.add(this.runway);
// Position aircraft on runway for takeoff
this.physics.reset(0, 1.5, -this.runwayLength / 2 + 100, Math.PI); // facing down runway
// Set initial throttle to idle
this.physics.throttleTarget = 0.1;
// Add approach lights and markers
this.addApproachMarkers();
}
addApproachMarkers() {
// PAPI-like lights on the side of runway
const papiGroup = new THREE.Group();
for (let i = 0; i < 4; i++) {
const lightGeo = new THREE.SphereGeometry(0.5, 6, 4);
const color = i < 2 ? 0xff0000 : 0xffffff; // red and white PAPI
const lightMat = new THREE.MeshPhongMaterial({
color: color, emissive: color, emissiveIntensity: 0.8
});
const light = new THREE.Mesh(lightGeo, lightMat);
light.position.set(18, 2, -this.runwayLength / 2 + 300 + i * 5);
papiGroup.add(light);
}
this.scene.add(papiGroup);
}
update(dt) {
const p = this.physics;
const alt = Math.max(0, p.position.y);
const speedKts = p.airSpeed * 1.94384; // m/s to knots
// Phase detection
if (this.phase === 'taxi') {
if (speedKts > 20) this.phase = 'takeoff';
} else if (this.phase === 'takeoff') {
if (alt > 10) this.phase = 'climb';
} else if (this.phase === 'climb') {
if (alt >= this.targetAltitude * 0.9) {
this.phase = 'cruise';
this.score += 25; // Reached altitude
}
} else if (this.phase === 'cruise') {
// Check if player is descending toward landing zone
const zPos = p.position.z;
if (zPos > -this.runwayLength / 2 + 100 && alt < this.targetAltitude * 0.5) {
this.phase = 'descent';
}
} else if (this.phase === 'descent') {
if (alt < 30) this.phase = 'approach';
} else if (this.phase === 'approach') {
// Check for landing
if (alt <= 2 && speedKts > 5) {
this.touchdownSpeed = speedKts;
this.touchdownFlareRate = p.velocity.y;
this.touchdownDistanceFromThreshold = Math.abs(p.position.z - (-this.runwayLength / 2 + 300));
// Score landing
let landingScore = 100;
const stallSpd = p.profile.stallSpeed * 1.94384;
const idealTouchdown = stallSpd * 1.15;
// Speed score (ideal: ~15% above stall)
landingScore -= Math.abs(speedKts - idealTouchdown) * 2;
// Flare rate score (should be near zero or slightly negative)
landingScore -= Math.abs(p.velocity.y) * 10;
// Distance from threshold score
landingScore -= this.touchdownDistanceFromThreshold / 50;
this.score += Math.max(0, Math.round(landingScore));
if (speedKts < stallSpd * 2 && Math.abs(p.velocity.y) < 3) {
this.phase = 'complete';
return true; // Mission complete
} else {
this.phase = 'taxi'; // Try again
}
}
}
return false;
}
getInstructions() {
const instructions = [
`Phase: ${this.phase.toUpperCase()}`,
];
switch (this.phase) {
case 'taxi':
instructions.push("Increase throttle and accelerate down the runway");
break;
case 'takeoff':
instructions.push("Pull up gently with W key to rotate");
break;
case 'climb':
instructions.push(`Climb to ${Math.round(this.targetAltitude * 3.28)} ft`);
break;
case 'cruise':
instructions.push("Fly down the runway, then descend for landing");
break;
case 'descent':
instructions.push("Reduce throttle and begin descent");
break;
case 'approach':
instructions.push("Flare gently before touchdown - aim for ~15% above stall speed");
break;
case 'complete':
instructions.push(`Excellent landing! Score: ${this.score}`);
break;
}
return instructions.join('\n');
}
cleanup() {
if (this.runway) this.scene.remove(this.runway);
}
}
// ── class: DogfightMission ──
// Dogfight Mission Manager with enemy AI
class DogfightMission {
constructor(scene, physics) {
this.scene = scene;
this.physics = physics;
this.enemies = [];
this.bullets = [];
this.enemyBullets = [];
this.score = 0;
this.playerHealth = 100;
this.maxEnemies = 4;
this.fireCooldown = 0;
this.explosions = [];
// Arena bounds
this.arenaRadius = 5000;
this.minAltitude = 200;
this.maxAltitude = 3000;
}
init() {
const noise = new NoiseGenerator(123);
this.noise = noise;
// Position player in the air for dogfight
this.physics.reset(0, 500, 0, Math.PI / 4);
this.physics.velocity.set(80, 0, 80); // Start with some speed
this.physics.throttleTarget = 0.6;
this.physics.gearDown = false;
// Spawn initial enemies
for (let i = 0; i < this.maxEnemies; i++) {
this.spawnEnemy();
}
}
spawnEnemy() {
const angle = Math.random() * Math.PI * 2;
const dist = 1500 + Math.random() * 2000;
const alt = 300 + Math.random() * 1500;
const enemyModel = buildEnemyModel();
this.scene.add(enemyModel);
// Enemy physics (simplified)
const enemyProfile = { ...AIRCRAFT_PROFILES.falcon };
const enemyPhysics = new FlightPhysics(enemyProfile);
enemyPhysics.reset(
Math.cos(angle) * dist,
alt,
Math.sin(angle) * dist,
angle + Math.PI // Face toward center
);
enemyPhysics.velocity.set(
-Math.cos(angle) * 60,
0,
-Math.sin(angle) * 60
);
enemyPhysics.throttleTarget = 0.5;
this.enemies.push({
model: enemyModel,
physics: enemyPhysics,
health: 100,
state: 'patrol', // patrol, engage, evade, retreat
targetAngle: Math.random() * Math.PI * 2,
fireCooldown: 0,
alive: true
});
}
update(dt) {
const p = this.physics;
// Update player bullets
this.updateBullets(this.bullets, dt);
this.updateBullets(this.enemyBullets, dt);
// Update explosions
this.explosions = this.explosions.filter(exp => {
exp.life -= dt;
if (exp.mesh) {
const s = Math.max(0.1, exp.life / exp.maxLife);
exp.mesh.scale.setScalar(s * 5);
exp.mesh.material.opacity = s;
}
return exp.life > 0;
});
// Fire cooldown
if (this.fireCooldown > 0) this.fireCooldown -= dt;
// Player fire
if (INPUT.fire && this.fireCooldown <= 0) {
this.fireBullet(p);
this.fireCooldown = 0.15; // Rate of fire
}
// Update enemies
for (const enemy of this.enemies) {
if (!enemy.alive) continue;
this.updateEnemyAI(enemy, dt);
enemy.physics.update(dt);
// Sync model position/rotation
enemy.model.position.copy(enemy.physics.position);
enemy.model.quaternion.copy(enemy.physics.orientationQuat);
// Enemy fire at player
if (enemy.fireCooldown <= 0 && enemy.state === 'engage') {
const dist = p.position.distanceTo(enemy.physics.position);
if (dist < 1500) {
this.enemyFireBullet(enemy);
enemy.fireCooldown = 0.3 + Math.random() * 0.2;
}
}
enemy.fireCooldown -= dt;
// Keep enemies in bounds
const distFromCenter = Math.sqrt(
enemy.physics.position.x ** 2 +
enemy.physics.position.z ** 2
);
if (distFromCenter > this.arenaRadius) {
// Turn back toward center
const angleToCenter = Math.atan2(-enemy.physics.position.x, -enemy.physics.position.z);
enemy.targetAngle = angleToCenter;
}
// Keep altitude in bounds
if (enemy.physics.position.y < this.minAltitude) {
enemy.physics.elevatorDeflection = 0.5;
} else if (enemy.physics.position.y > this.maxAltitude) {
enemy.physics.elevatorDeflection = -0.3;
}
}
// Check bullet collisions with enemies
for (const bullet of this.bullets) {
for (const enemy of this.enemies) {
if (!enemy.alive) continue;
const dist = bullet.position.distanceTo(enemy.physics.position);
if (dist < 15) {
enemy.health -= 25;
bullet.hit = true;
// Create hit effect
this.createExplosion(bullet.position.clone(), 0.3, 0xff8844);
if (enemy.health <= 0) {
this.destroyEnemy(enemy);
}
}
}
}
// Check enemy bullet collisions with player
for (const bullet of this.enemyBullets) {
const dist = bullet.position.distanceTo(p.position);
if (dist < 10) {
this.playerHealth -= 15;
bullet.hit = true;
this.createExplosion(bullet.position.clone(), 0.2, 0xff4444);
if (this.playerHealth <= 0) {
return 'crashed'; // Player destroyed
}
}
}
// Clean up hit bullets
this.bullets = this.bullets.filter(b => !b.hit && b.alive);
this.enemyBullets = this.enemyBullets.filter(b => !b.hit && b.alive);
// Respawn enemies if all destroyed
const aliveEnemies = this.enemies.filter(e => e.alive).length;
if (aliveEnemies === 0) {
for (let i = 0; i < this.maxEnemies; i++) {
setTimeout(() => this.spawnEnemy(), i * 1000);
}
}
// Check win condition (score based)
if (this.score >= 500) {
return 'complete';
}
return null;
}
updateEnemyAI(enemy, dt) {
const ep = enemy.physics;
const pp = this.physics.position;
// Calculate distance and direction to player
const dx = pp.x - ep.position.x;
const dz = pp.z - ep.position.z;
const dy = pp.y - ep.position.y;
const distToPlayer = Math.sqrt(dx * dx + dz * dz);
// State machine
if (distToPlayer < 800) {
enemy.state = 'engage';
} else if (distToPlayer > 2500) {
enemy.state = 'patrol';
} else {
enemy.state = Math.random() < 0.7 ? 'engage' : 'patrol';
}
// Calculate desired heading toward player
const angleToPlayer = Math.atan2(dx, dz);
if (enemy.state === 'engage') {
// Try to get behind the player
enemy.targetAngle = angleToPlayer + 0.5; // Offset for tail chase
// Adjust altitude to match player
if (dy > 100) ep.elevatorDeflection = -0.3;
else if (dy < -100) ep.elevatorDeflection = 0.3;
else ep.elevatorDeflection *= 0.9; // Dampen
// Bank toward player
const bankAngle = Math.atan2(dx, dz) - angleToPlayer;
ep.aileronDeflection = THREE.MathUtils.clamp(bankAngle * 2, -1, 1);
} else {
// Patrol: fly in circles or random patterns
enemy.targetAngle += dt * 0.3;
ep.elevatorDeflection = Math.sin(Date.now() * 0.001) * 0.2;
ep.aileronDeflection = Math.cos(Date.now() * 0.0015) * 0.3;
}
// Smoothly turn toward target angle
const headingDiff = enemy.targetAngle - (Math.atan2(
new THREE.Vector3(1, 0, 0).applyQuaternion(ep.orientationQuat).x,
new THREE.Vector3(1, 0, 0).applyQuaternion(ep.orientationQuat).z
));
// Normalize angle difference
let normalizedDiff = headingDiff;
while (normalizedDiff > Math.PI) normalizedDiff -= Math.PI * 2;
while (normalizedDiff < -Math.PI) normalizedDiff += Math.PI * 2;
ep.rudderDeflection = THREE.MathUtils.clamp(normalizedDiff, -1, 1);
// Maintain throttle
if (enemy.state === 'engage') {
ep.throttleTarget = 0.7 + Math.random() * 0.3;
} else {
ep.throttleTarget = 0.4;
}
}
fireBullet(physics) {
const forward = physics.getForward();
const bulletGeo = new THREE.SphereGeometry(0.5, 4, 4);
const bulletMat = new THREE.MeshPhongMaterial({
color: 0xffff00, emissive: 0xffaa00, emissiveIntensity: 1
});
const bulletMesh = new THREE.Mesh(bulletGeo, bulletMat);
const startPos = physics.position.clone().add(forward.multiplyScalar(5));
bulletMesh.position.copy(startPos);
this.scene.add(bulletMesh);
this.bullets.push({
mesh: bulletMesh,
position: startPos.clone(),
velocity: forward.clone().multiplyScalar(400), // Bullet speed m/s
alive: true,
hit: false,
life: 3.0
});
}
enemyFireBullet(enemy) {
const ep = enemy.physics;
const forward = ep.getForward();
const bulletGeo = new THREE.SphereGeometry(0.4, 4, 4);
const bulletMat = new THREE.MeshPhongMaterial({
color: 0xff4444, emissive: 0xff2222, emissiveIntensity: 1
});
const bulletMesh = new THREE.Mesh(bulletGeo, bulletMat);
const startPos = ep.position.clone().add(forward.multiplyScalar(5));
bulletMesh.position.copy(startPos);
this.scene.add(bulletMesh);
// Add some inaccuracy
const dir = forward.clone();
dir.x += (Math.random() - 0.5) * 0.05;
dir.y += (Math.random() - 0.5) * 0.03;
dir.z += (Math.random() - 0.5) * 0.05;
dir.normalize();
this.enemyBullets.push({
mesh: bulletMesh,
position: startPos.clone(),
velocity: dir.multiplyScalar(350),
alive: true,
hit: false,
life: 2.5
});
}
updateBullets(bulletArray, dt) {
for (const bullet of bulletArray) {
if (!bullet.alive || bullet.hit) continue;
bullet.life -= dt;
if (bullet.life <= 0) {
bullet.alive = false;
this.scene.remove(bullet.mesh);
continue;
}
// Gravity effect on bullets (slight drop)
bullet.velocity.y -= GRAVITY * 0.1 * dt;
const displacement = bullet.velocity.clone().multiplyScalar(dt);
bullet.position.add(displacement);
bullet.mesh.position.copy(bullet.position);
// Remove if too far or below ground
if (bullet.position.y < -10 ||
Math.abs(bullet.position.x) > 10000 ||
Math.abs(bullet.position.z) > 10000) {
bullet.alive = false;
this.scene.remove(bullet.mesh);
}
}
}
createExplosion(position, maxLife, color) {
const geo = new THREE.SphereGeometry(1, 8, 6);
const mat = new THREE.MeshPhongMaterial({
color: color, emissive: color, emissiveIntensity: 2,
transparent: true, opacity: 1
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(position);
this.scene.add(mesh);
this.explosions.push({ mesh, life: maxLife, maxLife });
}
destroyEnemy(enemy) {
enemy.alive = false;
this.createExplosion(enemy.physics.position.clone(), 1.5, 0xff6622);
this.scene.remove(enemy.model);
// Remove associated bullets
this.enemyBullets = this.enemyBullets.filter(b => b.mesh !== null);
this.score += 100;
}
cleanup() {
for (const enemy of this.enemies) {
if (enemy.model.parent) this.scene.remove(enemy.model);
}
for (const bullet of [...this.bullets, ...this.enemyBullets]) {
if (bullet.mesh && bullet.mesh.parent) this.scene.remove(bullet.mesh);
}
for (const exp of this.explosions) {
if (exp.mesh && exp.mesh.parent) this.scene.remove(exp.mesh);
}
}
}
// ── let: horizonCtx ──
// HUD Canvas renderer for artificial horizon
let horizonCtx = null;
// ── function: initHUD ──
function initHUD() {
const canvas = document.getElementById('horizon-canvas');
if (canvas) {
horizonCtx = canvas.getContext('2d');
}
}
// ── function: drawArtificialHorizon ──
function drawArtificialHorizon(physics) {
if (!horizonCtx) return;
const ctx = horizonCtx;
const w = 400, h = 300;
ctx.clearRect(0, 0, w, h);
// Get pitch and roll from orientation
const euler = new THREE.Euler().setFromQuaternion(physics.orientationQuat);
const pitchDeg = euler.x * RAD2DEG;
const rollDeg = euler.z * RAD2DEG;
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate(-rollDeg * DEG2RAD); // Roll rotation
// Sky (top half) - blue gradient
const skyGrad = ctx.createLinearGradient(0, -h, 0, 0);
skyGrad.addColorStop(0, '#1a3a6e');
skyGrad.addColorStop(1, '#4a8abf');
ctx.fillStyle = skyGrad;
ctx.fillRect(-w, -h, w * 2, h);
// Ground (bottom half) - brown gradient
const groundGrad = ctx.createLinearGradient(0, 0, 0, h);
groundGrad.addColorStop(0, '#5a7a4a');
groundGrad.addColorStop(1, '#3a5a2a');
ctx.fillStyle = groundGrad;
ctx.fillRect(-w, 0, w * 2, h);
// Horizon line
const horizonOffset = pitchDeg * 5; // Scale: 5 pixels per degree
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-w, -horizonOffset);
ctx.lineTo(w, -horizonOffset);
ctx.stroke();
// Pitch ladder lines
ctx.font = '10px monospace';
ctx.textAlign = 'center';
for (let deg = -30; deg <= 30; deg += 5) {
if (deg === 0) continue;
const y = -horizonOffset - deg * 5;
if (y < -h || y > h) continue;
const lineWidth = Math.abs(deg) % 10 === 0 ? 60 : 30;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.abs(deg) % 10 === 0 ? 2 : 1;
// Dashed line for above horizon, solid for below
if (deg > 0) {
ctx.setLineDash([4, 4]);
} else {
ctx.setLineDash([]);
}
ctx.beginPath();
ctx.moveTo(-lineWidth, y);
ctx.lineTo(lineWidth, y);
ctx.stroke();
// Wing marks at ±5 degrees
if (Math.abs(deg) === 5) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(-lineWidth - 10, y - 2, 10, 4);
ctx.fillRect(lineWidth, y - 2, 10, 4);
}
// Degree labels
if (Math.abs(deg) % 10 === 0 && Math.abs(deg) > 0) {
ctx.fillStyle = '#ffffff';
ctx.fillText(Math.abs(deg).toString(), -lineWidth - 15, y + 4);
ctx.fillText(Math.abs(deg).toString(), lineWidth + 15, y + 4);
}
}
ctx.setLineDash([]);
ctx.restore();
// Aircraft symbol (fixed in center)
ctx.save();
ctx.translate(w / 2, h / 2);
// Left wing reference
ctx.strokeStyle = '#ffcc00';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-50, 10);
ctx.lineTo(-20, 10);
ctx.lineTo(-15, 18);
ctx.stroke();
// Right wing reference
ctx.beginPath();
ctx.moveTo(50, 10);
ctx.lineTo(20, 10);
ctx.lineTo(15, 18);
ctx.stroke();
// Center dot
ctx.fillStyle = '#ffcc00';
ctx.beginPath();
ctx.arc(0, 10, 4, 0, Math.PI * 2);
ctx.fill();
// Roll indicator arc at top
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, -h / 2 + 30, 40, Math.PI * 0.15, Math.PI * 0.85);
ctx.stroke();
// Roll pointer
const rollRad = -rollDeg * DEG2RAD;
const ptrX = Math.sin(rollRad) * 40;
const ptrY = -h / 2 + 30 + (1 - Math.cos(rollRad)) * 40;
ctx.fillStyle = '#ffcc00';
ctx.beginPath();
ctx.moveTo(ptrX, ptrY);
ctx.lineTo(ptrX - 5, ptrY - 8);
ctx.lineTo(ptrX + 5, ptrY - 8);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// ── function: updateTapes ──
// Update speed tape values dynamically
function updateTapes(physics) {
const speedKts = Math.round(physics.airSpeed * 1.94384);
const altFt = Math.max(0, Math.round(physics.position.y * 3.28084));
// Speed tape
const speedTape = document.getElementById('speed-tape-values');
let speedHTML = '';
for (let i = -5; i <= 5; i++) {
const val = Math.max(0, speedKts + i * 20);
const isCurrent = i === 0;
speedHTML += `<div class="tape-val" style="${isCurrent ? 'color:#fff;font-weight:bold;' : ''}">${val}</div>`;
}
speedTape.innerHTML = speedHTML;
// Altitude tape
const altTape = document.getElementById('alt-tape-values');
let altHTML = '';
for (let i = -5; i <= 5; i++) {
const val = Math.max(0, altFt + i * 200);
const isCurrent = i === 0;
altHTML += `<div class="tape-val" style="${isCurrent ? 'color:#fff;font-weight:bold;' : ''}">${val}</div>`;
}
altTape.innerHTML = altHTML;
}
// ── class: CameraManager ──
// Camera manager with multiple modes
class CameraManager {
constructor(camera) {
this.camera = camera;
this.mode = 0; // 0=chase, 1=cockpit, 2=top-down
this.smoothPos = new THREE.Vector3();
this.smoothTarget = new THREE.Vector3();
this.initialized = false;
}
update(physics) {
if (!this.initialized) {
this.smoothPos.copy(this.camera.position);
this.smoothTarget.copy(physics.position);
this.initialized = true;
}
switch (this.mode) {
case 0: this.updateChaseCamera(physics); break;
case 1: this.updateCockpitCamera(physics); break;
case 2: this.updateTopDownCamera(physics); break;
}
}
updateChaseCamera(physics) {
const forward = physics.getForward();
const up = physics.getUp();
// Desired camera position: behind and above aircraft
const desiredPos = physics.position.clone()
.add(forward.multiplyScalar(-25))
.add(up.multiplyScalar(8));
// Smooth interpolation
this.smoothPos.lerp(desiredPos, 0.08);
// Look slightly ahead of aircraft
const lookTarget = physics.position.clone()
.add(forward.multiplyScalar(30));
this.smoothTarget.lerp(lookTarget, 0.1);
this.camera.position.copy(this.smoothPos);
this.camera.lookAt(this.smoothTarget);
}
updateCockpitCamera(physics) {
const forward = physics.getForward();
const up = physics.getUp();
// Camera at cockpit position, looking forward
const camPos = physics.position.clone()
.add(forward.multiplyScalar(2))
.add(up.multiplyScalar(0.5));
this.smoothPos.lerp(camPos, 0.15);
const lookTarget = physics.position.clone()
.add(forward.multiplyScalar(100));
this.smoothTarget.lerp(lookTarget, 0.15);
this.camera.position.copy(this.smoothPos);
this.camera.lookAt(this.smoothTarget);
}
updateTopDownCamera(physics) {
const desiredPos = new THREE.Vector3(
physics.position.x,
physics.position.y + 200,
physics.position.z + 50
);
this.smoothPos.lerp(desiredPos, 0.05);
this.smoothTarget.lerp(physics.position, 0.1);
this.camera.position.copy(this.smoothPos);
this.camera.lookAt(this.smoothTarget);
}
toggleMode() {
this.mode = (this.mode + 1) % 3;
}
}
// ── class: ParticleSystem ──
// Particle system for engine exhaust and effects
class ParticleSystem {
constructor(scene, maxParticles) {
this.scene = scene;
this.maxParticles = maxParticles || 200;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(maxParticles * 3);
const colors = new Float32Array(maxParticles * 4); // RGBA
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
this.material = new THREE.PointsMaterial({
size: 2,
vertexColors: true,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
depthWrite: false
});
this.points = new THREE.Points(geometry, this.material);
scene.add(this.points);
this.particles = [];
}
emit(position, velocity, color, life) {
if (this.particles.length >= this.maxParticles) return;
this.particles.push({
position: position.clone(),
velocity: velocity.clone(),
color: new THREE.Color(color),
life: life || 1.0,
maxLife: life || 1.0
});
}
update(dt) {
const positions = this.points.geometry.attributes.position.array;
const colors = this.points.geometry.attributes.color.array;
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.life -= dt;
if (p.life <= 0) {
this.particles.splice(i, 1);
continue;
}
}
// Update buffer attributes
for (let i = 0; i < this.maxParticles; i++) {
const idx3 = i * 3;
const idx4 = i * 4;
if (i < this.particles.length) {
const p = this.particles[i];
// Update position
p.position.add(p.velocity.clone().multiplyScalar(dt));
p.velocity.multiplyScalar(0.98); // Drag
positions[idx3] = p.position.x;
positions[idx3 + 1] = p.position.y;
positions[idx3 + 2] = p.position.z;
const alpha = p.life / p.maxLife;
colors[idx4] = p.color.r;
colors[idx4 + 1] = p.color.g;
colors[idx4 + 2] = p.color.b;
colors[idx4 + 3] = alpha * 0.6;
} else {
positions[idx3] = 0;
positions[idx3 + 1] = -9999; // Hide below terrain
positions[idx3 + 2] = 0;
colors[idx4 + 3] = 0;
}
}
this.points.geometry.attributes.position.needsUpdate = true;
this.points.geometry.attributes.color.needsUpdate = true;
}
}
// ── function: createClouds ──
// Cloud system for atmosphere
function createClouds(scene, count) {
const clouds = [];
for (let i = 0; i < count; i++) {
const group = new THREE.Group();
// Each cloud is made of several overlapping spheres
const numPuffs = 3 + Math.floor(Math.random() * 4);
for (let j = 0; j < numPuffs; j++) {
const size = 20 + Math.random() * 40;
const geo = new THREE.SphereGeometry(size, 8, 6);
const mat = new THREE.MeshPhongMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7 + Math.random() * 0.2,
flatShading: true
});
const puff = new THREE.Mesh(geo, mat);
puff.position.set(
(Math.random() - 0.5) * size * 2,
(Math.random() - 0.5) * size * 0.3,
(Math.random() - 0.5) * size * 1.5
);
group.add(puff);
}
// Position cloud in the sky
group.position.set(
(Math.random() - 0.5) * 20000,
800 + Math.random() * 2000,
(Math.random() - 0.5) * 20000
);
scene.add(group);
clouds.push({ group, speed: 5 + Math.random() * 15 });
}
return clouds;
}
// ── function: updateClouds ──
function updateClouds(clouds, dt) {
for (const cloud of clouds) {
cloud.group.position.x += cloud.speed * dt;
// Wrap around
if (cloud.group.position.x > 10000) {
cloud.group.position.x = -10000;
}
}
}
// ── let: renderer ──
let renderer = null;
// ── let: scene ──
let scene = null;
// ── let: camera ──
let camera = null;
// ── let: clock ──
let clock = null;
// ── let: aircraftModel ──
let aircraftModel = null;
// ── let: physics ──
let physics = null;
// ── let: mission ──
let mission = null;
// ── let: cameraManager ──
let cameraManager = null;
// ── let: particles ──
let particles = null;
// ── let: clouds ──
let clouds = [];
// ── let: terrainChunks ──
let terrainChunks = [];
// ── let: waterMesh ──
let waterMesh = null;
// ── let: waterMaterial ──
let waterMaterial = null;
// ── let: keysDown ──
let keysDown = {};
// ── let: lastTime ──
let lastTime = 0;
// ── function: initScene ──
function initScene() {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.getElementById('canvas-container').appendChild(renderer.domElement);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.FogExp2(0x87CEEB, 0.00015);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 50000);
camera.position.set(0, 100, -200);
clock = new THREE.Clock();
setupLighting();
createSkyDome();
}
// ── function: setupLighting ──
function setupLighting() {
const ambient = new THREE.AmbientLight(0x405070, 0.6);
scene.add(ambient);
const hemi = new THREE.HemisphereLight(0x87CEEB, 0x3a5a2a, 0.4);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff5e6, 1.2);
sun.position.set(5000, 8000, 3000);
sun.castShadow = true;
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
sun.shadow.camera.near = 100;
sun.shadow.camera.far = 20000;
sun.shadow.camera.left = -5000;
sun.shadow.camera.right = 5000;
sun.shadow.camera.top = 5000;
sun.shadow.camera.bottom = -5000;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x8899bb, 0.3);
fill.position.set(-3000, 2000, -2000);
scene.add(fill);
}
// ── function: createSkyDome ──
function createSkyDome() {
const skyGeo = new THREE.SphereGeometry(25000, 16, 12);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x0044aa) },
bottomColor: { value: new THREE.Color(0xaaddff) },
offset: { value: 20 },
exponent: { value: 0.6 }
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform float offset;
uniform float exponent;
varying vec3 vWorldPosition;
void main() {
float h = normalize(vWorldPosition + offset).y;
gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0);
}
`,
side: THREE.BackSide
});
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
}
// ── function: buildTerrainChunks ──
function buildTerrainChunks(centerX, centerZ) {
for (let i = terrainChunks.length - 1; i >= 0; i--) {
const chunk = terrainChunks[i];
const dx = chunk.cx - centerX;
const dz = chunk.cz - centerZ;
if (Math.sqrt(dx * dx + dz * dz) > 8000) {
scene.remove(chunk.mesh);
terrainChunks.splice(i, 1);
}
}
const chunkSize = 4000;
const resolution = 64;
const noise = new NoiseGenerator(42);
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
const cx = centerX + x * chunkSize;
const cz = centerZ + z * chunkSize;
let found = false;
for (const chunk of terrainChunks) {
if (Math.abs(chunk.cx - cx) < 1 && Math.abs(chunk.cz - cz) < 1) {
found = true;
break;
}
}
if (!found) {
const terrain = generateTerrain(noise, chunkSize, resolution);
terrain.position.set(cx, 0, cz);
scene.add(terrain);
terrainChunks.push({ mesh: terrain, cx, cz });
}
}
}
}
// ── function: initMenu ──
function initMenu() {
const aircraftCards = document.getElementById('aircraft-cards');
aircraftCards.innerHTML = '';
for (const [key, profile] of Object.entries(AIRCRAFT_PROFILES)) {
const card = document.createElement('div');
card.className = 'ac-card';
card.dataset.aircraft = key;
card.innerHTML = `
<div class="ac-icon">${profile.icon}</div>
<div class="ac-name">${profile.name}</div>
<div class="ac-stats">
Speed: ${Math.round(profile.maxSpeed * 1.94384)} kts<br>
Thrust: ${(profile.maxThrust / 1000).toFixed(0)} kN<br>
Weight: ${(profile.mass / 1000).toFixed(1)} t<br>
Roll Rate: ${Math.round(profile.rollRate * RAD2DEG)}°/s<br>
${profile.description}
</div>
`;
card.addEventListener('click', () => {
document.querySelectorAll('.ac-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedAircraft = key;
checkStartReady();
});
aircraftCards.appendChild(card);
}
const scenarioCards = document.getElementById('scenario-cards');
scenarioCards.innerHTML = '';
for (const [key, sc] of Object.entries(SCENARIOS)) {
const card = document.createElement('div');
card.className = 'sc-card';
card.dataset.scenario = key;
card.innerHTML = `
<div class="ac-icon">${sc.icon}</div>
<div class="sc-name">${sc.name}</div>
<div class="sc-desc">${sc.description}</div>
`;
card.addEventListener('click', () => {
document.querySelectorAll('.sc-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedScenario = key;
checkStartReady();
});
scenarioCards.appendChild(card);
}
document.getElementById('start-btn').addEventListener('click', startGame);
document.getElementById('restart-btn').addEventListener('click', () => {
document.getElementById('game-over').style.display = 'none';
startGame();
});
document.getElementById('menu-btn').addEventListener('click', returnToMenu);
document.getElementById('restart-crash-btn').addEventListener('click', () => {
document.getElementById('crash-overlay').style.display = 'none';
startGame();
});
}
// ── function: checkStartReady ──
function checkStartReady() {
const btn = document.getElementById('start-btn');
btn.disabled = !(selectedAircraft && selectedScenario);
}
// ── function: startGame ──
function startGame() {
if (!selectedAircraft || !selectedScenario) return;
document.getElementById('menu-screen').style.display = 'none';
document.getElementById('hud').style.display = 'block';
document.getElementById('game-over').style.display = 'none';
document.getElementById('crash-overlay').style.display = 'none';
cleanupGameObjects();
const profile = AIRCRAFT_PROFILES[selectedAircraft];
physics = new FlightPhysics(profile);
aircraftModel = buildAircraftModel(profile);
scene.add(aircraftModel);
cameraManager = new CameraManager(camera);
particles = new ParticleSystem(scene, 300);
clouds = createClouds(scene, 25);
if (waterMesh) scene.remove(waterMesh);
const water = generateWater(10000);
waterMesh = water.mesh;
waterMaterial = water.material;
scene.add(waterMesh);
if (selectedScenario === 'takeoffLanding') {
mission = new TakeoffLandingMission(scene, physics);
mission.init();
document.getElementById('crosshair').style.display = 'none';
document.getElementById('hud-score').style.display = 'flex';
} else if (selectedScenario === 'dogfight') {
mission = new DogfightMission(scene, physics);
mission.init();
document.getElementById('crosshair').style.display = 'block';
document.getElementById('hud-score').style.display = 'flex';
}
buildTerrainChunks(0, 0);
gameState = 'playing';
lastTime = performance.now();
}
// ── function: cleanupGameObjects ──
function cleanupGameObjects() {
if (aircraftModel && aircraftModel.parent) scene.remove(aircraftModel);
if (mission) mission.cleanup();
if (particles && particles.points.parent) scene.remove(particles.points);
for (const cloud of (clouds || [])) {
if (cloud.group.parent) scene.remove(cloud.group);
}
for (const chunk of terrainChunks) {
if (chunk.mesh.parent) scene.remove(chunk.mesh);
}
terrainChunks = [];
}
// ── function: returnToMenu ──
function returnToMenu() {
gameState = 'menu';
cleanupGameObjects();
document.getElementById('menu-screen').style.display = 'flex';
document.getElementById('hud').style.display = 'none';
document.getElementById('game-over').style.display = 'none';
document.getElementById('crash-overlay').style.display = 'none';
}
// ── function: handleInput ──
function handleInput(dt) {
const p = physics;
if (!p || gameState !== 'playing') return;
const pitchInput = (INPUT.pitchDown ? 1 : 0) - (INPUT.pitchUp ? 1 : 0);
p.elevatorDeflection = THREE.MathUtils.lerp(p.elevatorDeflection, pitchInput * 0.8, dt * 5);
const rollInput = (INPUT.rollLeft ? 1 : 0) - (INPUT.rollRight ? 1 : 0);
p.aileronDeflection = THREE.MathUtils.lerp(p.aileronDeflection, rollInput * 0.8, dt * 5);
const yawInput = (INPUT.yawLeft ? 1 : 0) - (INPUT.yawRight ? 1 : 0);
p.rudderDeflection = THREE.MathUtils.lerp(p.rudderDeflection, yawInput * 0.8, dt * 5);
if (INPUT.throttleUp) {
p.throttleTarget = Math.min(1, p.throttleTarget + dt * 0.8);
}
if (INPUT.throttleDown) {
p.throttleTarget = Math.max(0, p.throttleTarget - dt * 0.8);
}
p.brakeForce = INPUT.brakes ? 50000 : 0;
if (INPUT.gearToggle) {
p.gearDown = !p.gearDown;
INPUT.gearToggle = false;
}
if (INPUT.flaps) {
p.flapAngle = p.flapAngle > 0.1 ? 0 : 0.35;
INPUT.flaps = false;
}
}
// ── function: emitExhaust ──
function emitExhaust() {
if (!physics || !particles) return;
const thrust = physics.currentThrust;
if (thrust > 1000) {
const backward = physics.getForward().multiplyScalar(-1);
const pos = physics.position.clone().add(backward.multiplyScalar(4));
const spread = thrust / physics.profile.maxThrust;
const vel = new THREE.Vector3(
-50 * spread + (Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 5,
(Math.random() - 0.5) * 10
);
const r = Math.min(1, spread);
const g = Math.max(0.3, 1 - spread * 0.7);
const b = Math.max(0.2, 1 - spread);
particles.emit(pos, vel, new THREE.Color(r, g, b), 0.5 + Math.random() * 0.5);
}
}
// ── function: checkCrashConditions ──
function checkCrashConditions() {
if (!physics) return null;
const p = physics;
if (p.position.y < 1 && p.airSpeed > 50) {
const euler = new THREE.Euler().setFromQuaternion(p.orientationQuat);
if (Math.abs(euler.x) > 0.4 || Math.abs(euler.z) > 0.3) {
return 'Crashed! Too steep angle on touchdown';
}
}
if (p.position.y < 1 && p.velocity.y < -15) {
return 'Crashed! Impact too hard';
}
if (Math.abs(p.gLoad) > 9) {
return 'Structural failure! Exceeded G limits';
}
return null;
}
// ── function: showGameOver ──
function showGameOver(completed) {
gameState = 'gameover';
const overlay = document.getElementById('game-over');
overlay.style.display = 'flex';
document.getElementById('go-title').textContent = completed ? 'MISSION COMPLETE!' : 'MISSION FAILED';
document.getElementById('go-title').style.color = completed ? '#00ff88' : '#ff4444';
let message = '';
if (selectedScenario === 'takeoffLanding') {
message = `Score: ${mission.score} points\n`;
message += `Touchdown Speed: ${Math.round(mission.touchdownSpeed)} kts\n`;
message += `Vertical Speed: ${mission.touchdownFlareRate.toFixed(1)} m/s`;
} else if (selectedScenario === 'dogfight') {
message = `Score: ${mission.score} points\n`;
message += `Enemies destroyed! Great flying, pilot.`;
}
document.getElementById('go-message').textContent = message;
}
// ── function: showCrash ──
function showCrash(reason) {
gameState = 'crashed';
const overlay = document.getElementById('crash-overlay');
overlay.style.display = 'flex';
document.getElementById('crash-reason').textContent = reason;
}
// ── function: onWindowResize ──
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// ── function: gameLoop ──
function gameLoop() {
requestAnimationFrame(gameLoop);
const now = performance.now();
let dt = (now - lastTime) / 1000;
lastTime = now;
dt = Math.min(dt, 0.05);
if (gameState === 'playing') {
handleInput(dt);
physics.update(dt);
aircraftModel.position.copy(physics.position);
aircraftModel.quaternion.copy(physics.orientationQuat);
cameraManager.update(physics);
emitExhaust();
particles.update(dt);
updateClouds(clouds, dt);
if (waterMesh) {
const positions = waterMesh.geometry.attributes.position.array;
for (let i = 1; i < positions.length - 2; i += 3) {
positions[i] = Math.sin(positions[i-1] * 0.01 + now * 0.001) * 2 +
Math.cos(positions[i+1] * 0.015 + now * 0.0015) * 1.5 - 5;
}
waterMesh.geometry.attributes.position.needsUpdate = true;
}
if (mission) {
const result = mission.update(dt);
if (result === 'complete') showGameOver(true);
else if (result === 'crashed') showCrash('Destroyed in combat!');
}
const crashReason = checkCrashConditions();
if (crashReason) showCrash(crashReason);
buildTerrainChunks(physics.position.x, physics.position.z);
updateHUD(physics, mission);
updateTapes(physics);
}
renderer.render(scene, camera);
}
// ── function: updateHUD ──
function updateHUD(physics, mission) {
// Speed tape current value
const speedKts = Math.round(physics.airSpeed * 1.94384);
const el = document.getElementById('speed-current');
if (el) el.textContent = speedKts;
// Altitude in feet
const altFt = Math.max(0, Math.round(physics.position.y * 3.28084));
const altEl = document.getElementById('alt-current');
if (altEl) altEl.textContent = altFt;
const hudAltEl = document.getElementById('hud-alt-value');
if (hudAltEl) hudAltEl.textContent = altFt;
// Heading
const forward = physics.getForward();
let heading = Math.atan2(forward.x, forward.z) * RAD2DEG;
if (heading < 0) heading += 360;
const hdgEl = document.getElementById('hud-hdg-value');
if (hdgEl) hdgEl.textContent = Math.round(heading);
// Throttle
const thrPct = Math.round(physics.throttleCurrent * 100);
const thrEl = document.getElementById('hud-thr-value');
if (thrEl) thrEl.textContent = thrPct;
const thrFill = document.getElementById('throttle-fill');
if (thrFill) thrFill.style.width = thrPct + '%';
// G-force
const gValEl = document.getElementById('hud-g-value');
if (gValEl) gValEl.textContent = physics.gLoad.toFixed(1);
// Warnings
const warningsEl = document.getElementById('hud-warnings');
let warningText = '';
if (physics.stallWarning) warningText += '⚠ STALL ';
if (physics.overGWarning) warningText += '⚠ HIGH G ';
if (physics.speedWarning) warningText += '⚠ OVERSPEED ';
if (physics.position.y < 5 && physics.airSpeed > 10) warningText += '🛬 TERRAIN ';
if (warningText) {
warningsEl.innerHTML = '<span id="warning-text">' + warningText + '</span>';
} else {
warningsEl.innerHTML = '';
}
// Artificial horizon on canvas
drawArtificialHorizon(physics);
// Mission-specific HUD elements
if (mission) {
const scoreEl = document.getElementById('hud-score');
const scoreVal = document.getElementById('hud-score-value');
if (selectedScenario === 'dogfight') {
scoreEl.style.display = 'flex';
if (scoreVal) scoreVal.textContent = mission.score;
// Health bar for dogfight
const healthBar = document.getElementById('hud-health');
const hpFill = document.getElementById('health-fill');
const hpValue = document.getElementById('hud-hp-value');
if (healthBar) healthBar.style.display = 'flex';
const hpPct = Math.max(0, mission.playerHealth);
if (hpFill) {
hpFill.style.width = hpPct + '%';
hpFill.style.background = hpPct > 50 ? '#00ff88' : hpPct > 25 ? '#ffaa00' : '#ff4444';
}
if (hpValue) {
hpValue.textContent = Math.round(hpPct);
hpValue.style.color = hpPct > 50 ? '#00ff88' : hpPct > 25 ? '#ffaa00' : '#ff4444';
}
// Hide G-force display in dogfight mode to save space
const gforceEl = document.getElementById('hud-gforce');
if (gforceEl) gforceEl.style.display = 'none';
} else {
scoreEl.style.display = 'flex';
if (scoreVal) scoreVal.textContent = mission.score;
// Show G-force for takeoff/landing
const healthBar = document.getElementById('hud-health');
if (healthBar) healthBar.style.display = 'none';
const gforceEl = document.getElementById('hud-gforce');
if (gforceEl) gforceEl.style.display = 'flex';
}
}
}
// ── function: buildAircraftModel ──
function buildAircraftModel(profile) {
const group = new THREE.Group();
const s = profile.scale;
const bodyMat = new THREE.MeshPhongMaterial({ color: profile.color, shininess: 80, specular: 0x444444 });
const accentMat = new THREE.MeshPhongMaterial({ color: profile.accentColor, shininess: 60 });
const darkMat = new THREE.MeshPhongMaterial({ color: 0x222222, shininess: 40 });
const glassMat = new THREE.MeshPhongMaterial({ color: 0x88ccff, transparent: true, opacity: 0.5, shininess: 100 });
if (profile.name === "Falcon X-1") {
// Fuselage
const fuselageGeo = new THREE.CylinderGeometry(0.3 * s, 0.25 * s, 6 * s, 8);
const fuselage = new THREE.Mesh(fuselageGeo, bodyMat);
fuselage.rotation.z = Math.PI / 2;
group.add(fuselage);
// Nose cone
const noseGeo = new THREE.ConeGeometry(0.3 * s, 1.5 * s, 8);
const nose = new THREE.Mesh(noseGeo, bodyMat);
nose.rotation.z = -Math.PI / 2;
nose.position.x = 3.75 * s;
group.add(nose);
// Cockpit canopy (half sphere)
const cockpitGeo = new THREE.SphereGeometry(0.35 * s, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2);
const cockpit = new THREE.Mesh(cockpitGeo, glassMat);
cockpit.position.set(1.0 * s, 0.25 * s, 0);
group.add(cockpit);
// Wings - swept back using extruded shapes
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(3 * s, -1.5 * s);
wingShape.lineTo(3 * s, -2.8 * s);
wingShape.lineTo(-1 * s, -2.5 * s);
wingShape.lineTo(-1 * s, 0);
const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth: 0.06 * s });
const rightWing = new THREE.Mesh(wingGeo, bodyMat);
rightWing.rotation.x = -Math.PI / 2;
rightWing.position.set(0.5 * s, -0.06 * s, 0.1 * s);
group.add(rightWing);
const leftWing = new THREE.Mesh(wingGeo, bodyMat);
leftWing.rotation.x = Math.PI / 2;
leftWing.position.set(0.5 * s, -0.06 * s, -0.1 * s);
group.add(leftWing);
// Wing tip accents
const tipGeo = new THREE.BoxGeometry(0.3 * s, 0.08 * s, 0.2 * s);
[-2.75, 2.75].forEach(z => {
const tip = new THREE.Mesh(tipGeo, accentMat);
tip.position.set(3 * s, -0.06 * s, z * s);
group.add(tip);
});
// Vertical stabilizer (tail fin)
const tailFinShape = new THREE.Shape();
tailFinShape.moveTo(0, 0);
tailFinShape.lineTo(-1.5 * s, -0.3 * s);
tailFinShape.lineTo(-2.5 * s, 0);
const tailFinGeo = new THREE.ExtrudeGeometry(tailFinShape, { depth: 0.04 * s });
const tailFin = new THREE.Mesh(tailFinGeo, bodyMat);
tailFin.position.set(-1.5 * s, 0.25 * s, -0.02 * s);
group.add(tailFin);
// Horizontal stabilizers
const hStabShape = new THREE.Shape();
hStabShape.moveTo(0, 0);
hStabShape.lineTo(-1.5 * s, -0.8 * s);
hStabShape.lineTo(-1.5 * s, -1.4 * s);
hStabShape.lineTo(-2.5 * s, -1.2 * s);
hStabShape.lineTo(-2.5 * s, 0);
const hStabGeo = new THREE.ExtrudeGeometry(hStabShape, { depth: 0.04 * s });
[-1, 1].forEach(side => {
const stab = new THREE.Mesh(hStabGeo, bodyMat);
stab.rotation.x = side * -Math.PI / 2;
stab.position.set(-1.5 * s, -0.04 * s, side * 0.1 * s);
group.add(stab);
});
// Engine exhaust
const exhaustGeo = new THREE.CylinderGeometry(0.15 * s, 0.2 * s, 0.3 * s, 8);
const exhaust = new THREE.Mesh(exhaustGeo, darkMat);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.x = -3.15 * s;
group.add(exhaust);
} else if (profile.name === "Titan B-2") {
// Main fuselage
const bodyGeo = new THREE.BoxGeometry(8 * s, 0.9 * s, 1.5 * s);
const body = new THREE.Mesh(bodyGeo, bodyMat);
group.add(body);
// Nose cone (4-sided)
const noseGeo2 = new THREE.ConeGeometry(0.6 * s, 2 * s, 4);
const nose2 = new THREE.Mesh(noseGeo2, bodyMat);
nose2.rotation.z = -Math.PI / 2;
nose2.position.x = 5 * s;
group.add(nose2);
// Cockpit bubble
const cabGeo = new THREE.SphereGeometry(0.4 * s, 8, 6);
const cabin = new THREE.Mesh(cabGeo, glassMat);
cabin.position.set(3.5 * s, 0.5 * s, 0);
group.add(cabin);
// Large straight wings
const wingGeo2 = new THREE.BoxGeometry(4 * s, 0.1 * s, 6 * s);
const wings = new THREE.Mesh(wingGeo2, bodyMat);
wings.position.set(-0.5 * s, -0.1 * s, 0);
group.add(wings);
// Wing tip accents
const wTipGeo = new THREE.BoxGeometry(0.3 * s, 0.15 * s, 0.4 * s);
[-3, 3].forEach(z => {
const wt = new THREE.Mesh(wTipGeo, accentMat);
wt.position.set(-0.5 * s, -0.1 * s, z * s);
group.add(wt);
});
// Twin tail fins
[-0.6, 0.6].forEach(z => {
const fin = new THREE.Mesh(new THREE.BoxGeometry(2 * s, 1.5 * s, 0.08 * s), bodyMat);
fin.position.set(-3.5 * s, 0.75 * s, z * s);
group.add(fin);
});
// Horizontal stabilizer (FIXED: wrapped in Mesh)
const hStab2 = new THREE.Mesh(new THREE.BoxGeometry(1.5 * s, 0.08 * s, 4 * s), bodyMat);
hStab2.position.set(-3.5 * s, -0.1 * s, 0);
group.add(hStab2);
// Engine nacelles (under wings)
[-2, 2].forEach(z => {
const nacelle = new THREE.Mesh(new THREE.CylinderGeometry(0.3 * s, 0.35 * s, 1.5 * s, 8), darkMat);
nacelle.rotation.z = Math.PI / 2;
nacelle.position.set(-0.5 * s, -0.6 * s, z * s);
group.add(nacelle);
});
} else if (profile.name === "Viper S-7") {
// Pointed nose
const noseGeo3 = new THREE.ConeGeometry(0.25 * s, 4 * s, 6);
const nose3 = new THREE.Mesh(noseGeo3, bodyMat);
nose3.rotation.z = -Math.PI / 2;
nose3.position.x = 3 * s;
group.add(nose3);
// Main fuselage (tapered cylinder)
const bodyGeo3 = new THREE.CylinderGeometry(0.15 * s, 0.35 * s, 4 * s, 6);
const body3 = new THREE.Mesh(bodyGeo3, bodyMat);
body3.rotation.z = Math.PI / 2;
body3.position.x = -0.5 * s;
group.add(body3);
// Cockpit canopy (FIXED: using cylinder + spheres instead of CapsuleGeometry)
const cabBody = new THREE.Mesh(new THREE.CylinderGeometry(0.18 * s, 0.18 * s, 0.8 * s, 6), glassMat);
cabBody.rotation.z = Math.PI / 2;
cabBody.position.set(1.5 * s, 0.3 * s, 0);
group.add(cabBody);
const cabFront = new THREE.Mesh(new THREE.SphereGeometry(0.18 * s, 6, 4), glassMat);
cabFront.position.set(1.9 * s, 0.3 * s, 0);
group.add(cabFront);
// Delta wings (large triangular)
const deltaShape = new THREE.Shape();
deltaShape.moveTo(0, 0);
deltaShape.lineTo(2 * s, -1.5 * s);
deltaShape.lineTo(-1 * s, -3.5 * s);
deltaShape.lineTo(-2 * s, -3.5 * s);
deltaShape.lineTo(-2 * s, 0);
const deltaGeo = new THREE.ExtrudeGeometry(deltaShape, { depth: 0.05 * s });
[-1, 1].forEach(side => {
const wing = new THREE.Mesh(deltaGeo, bodyMat);
wing.rotation.x = side * -Math.PI / 2;
wing.position.set(1 * s, -0.05 * s, side * 0.03 * s);
group.add(wing);
});
// Wing leading edge accents
const leGeo = new THREE.BoxGeometry(3 * s, 0.06 * s, 0.1 * s);
[-2.8, 2.8].forEach(z => {
const le = new THREE.Mesh(leGeo, accentMat);
le.position.set(0 * s, -0.05 * s, z * s);
group.add(le);
});
// Small vertical stabilizer at rear
const vStabShape = new THREE.Shape();
vStabShape.moveTo(0, 0);
vStabShape.lineTo(-1 * s, -0.2 * s);
vStabShape.lineTo(-1.8 * s, 0);
const vStabGeo = new THREE.ExtrudeGeometry(vStabShape, { depth: 0.03 * s });
const vStab = new THREE.Mesh(vStabGeo, bodyMat);
vStab.position.set(-1.5 * s, 0.2 * s, -0.015 * s);
group.add(vStab);
// Twin engine exhausts
[-0.15, 0.15].forEach(z => {
const ex = new THREE.Mesh(new THREE.CylinderGeometry(0.1 * s, 0.15 * s, 0.4 * s, 6), darkMat);
ex.rotation.z = Math.PI / 2;
ex.position.set(-2.7 * s, -0.1 * s, z * s);
group.add(ex);
});
}
// Landing gear (common to all)
const gearMat = new THREE.MeshPhongMaterial({ color: 0x444444 });
[-0.5, 0.5].forEach(z => {
const strut = new THREE.Mesh(new THREE.CylinderGeometry(0.03 * s, 0.03 * s, 0.6 * s, 4), gearMat);
strut.position.set(1 * s, -0.5 * s, z * s);
group.add(strut);
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.12 * s, 0.12 * s, 0.08 * s, 8), darkMat);
wheel.rotation.x = Math.PI / 2;
wheel.position.set(1 * s, -0.8 * s, z * s);
group.add(wheel);
});
// Nose gear
const noseStrut = new THREE.Mesh(new THREE.CylinderGeometry(0.025 * s, 0.025 * s, 0.4 * s, 4), gearMat);
noseStrut.position.set(3.5 * s, -0.4 * s, 0);
group.add(noseStrut);
// Center the model
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
group.position.sub(center);
return group;
}
// ── main ──
(function() {
// Initialize Three.js scene
initScene();
// Initialize HUD canvas
initHUD();
// Build menu UI
initMenu();
// Keyboard input handling
document.addEventListener('keydown', function(e) {
keysDown[e.code] = true;
if (gameState === 'playing') {
switch (e.code) {
case 'KeyW': INPUT.pitchUp = true; break;
case 'KeyS': INPUT.pitchDown = true; break;
case 'KeyA': INPUT.rollLeft = true; break;
case 'KeyD': INPUT.rollRight = true; break;
case 'KeyQ': INPUT.yawLeft = true; break;
case 'KeyE': INPUT.yawRight = true; break;
case 'ShiftLeft': case 'ShiftRight': INPUT.throttleUp = true; e.preventDefault(); break;
case 'ControlLeft': case 'ControlRight': INPUT.throttleDown = true; e.preventDefault(); break;
case 'KeyB': INPUT.brakes = true; break;
case 'KeyG': INPUT.gearToggle = true; break;
case 'KeyF': INPUT.flaps = true; break;
case 'Space': INPUT.fire = true; e.preventDefault(); break;
case 'KeyC': if (cameraManager) cameraManager.toggleMode(); break;
case 'Escape':
gameState = 'paused';
document.getElementById('pause-overlay').style.display = 'flex';
break;
}
} else if (gameState === 'paused') {
if (e.code === 'Escape') {
gameState = 'playing';
document.getElementById('pause-overlay').style.display = 'none';
lastTime = performance.now();
}
}
});
document.addEventListener('keyup', function(e) {
keysDown[e.code] = false;
switch (e.code) {
case 'KeyW': INPUT.pitchUp = false; break;
case 'KeyS': INPUT.pitchDown = false; break;
case 'KeyA': INPUT.rollLeft = false; break;
case 'KeyD': INPUT.rollRight = false; break;
case 'KeyQ': INPUT.yawLeft = false; break;
case 'KeyE': INPUT.yawRight = false; break;
case 'ShiftLeft': case 'ShiftRight': INPUT.throttleUp = false; break;
case 'ControlLeft': case 'ControlRight': INPUT.throttleDown = false; break;
case 'KeyB': INPUT.brakes = false; break;
case 'Space': INPUT.fire = false; break;
}
});
// Mouse input for firing in dogfight mode
document.addEventListener('mousedown', function(e) {
if (gameState === 'playing' && e.button === 0) {
INPUT.mouseDown = true;
INPUT.fire = true;
}
});
document.addEventListener('mouseup', function(e) {
if (e.button === 0) {
INPUT.mouseDown = false;
// Only clear fire if not holding spacebar
if (!keysDown['Space']) INPUT.fire = false;
}
});
// Window resize handling
window.addEventListener('resize', onWindowResize);
// Start the game loop
lastTime = performance.now();
requestAnimationFrame(gameLoop);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"2c837dde-2d50-424a-b2c1-729bebf061a2","tokensIn":2083481,"tokensOut":47477,"tokensTotal":2130958,"turns":49,"toolCalls":48,"failedToolCalls":1,"timestamp":"2026-07-17T17:04:24.160Z"} -->