Flight Simulator
Grok 4.5 · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
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 82,053 bytes · SHA-256 bfdc114b8d0e
<!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>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&family=Rajdhani:wght@400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #02060c;
font-family: 'Rajdhani', system-ui, sans-serif;
color: #e8f4ff;
user-select: none;
}
#canvas-wrap {
position: fixed; inset: 0;
z-index: 0;
}
canvas { display: block; width: 100%; height: 100%; }
/* ── Menus ── */
.screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
z-index: 20;
background:
radial-gradient(ellipse at 50% 0%, rgba(30,80,160,0.35), transparent 55%),
radial-gradient(ellipse at 80% 100%, rgba(180,60,20,0.2), transparent 50%),
linear-gradient(180deg, #040a14 0%, #0a1628 50%, #06101c 100%);
transition: opacity 0.5s, visibility 0.5s;
}
.screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.logo {
font-family: 'Orbitron', sans-serif;
font-size: clamp(2.4rem, 6vw, 4.2rem);
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
background: linear-gradient(180deg, #ffffff 0%, #7ec8ff 45%, #2a7fd4 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 40px rgba(80,160,255,0.35);
margin-bottom: 0.2rem;
}
.tagline {
font-size: 1rem;
letter-spacing: 0.45em;
text-transform: uppercase;
color: #6a9fc4;
margin-bottom: 2.5rem;
}
.panel {
background: rgba(8, 18, 36, 0.82);
border: 1px solid rgba(100, 170, 255, 0.22);
border-radius: 12px;
padding: 1.6rem 1.8rem;
backdrop-filter: blur(14px);
box-shadow: 0 20px 60px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.05);
max-width: 920px;
width: min(94vw, 920px);
}
.panel h2 {
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
letter-spacing: 0.25em;
color: #7ec8ff;
margin-bottom: 1rem;
text-transform: uppercase;
}
.fleet {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.9rem;
margin-bottom: 1.4rem;
}
@media (max-width: 720px) {
.fleet { grid-template-columns: 1fr; }
}
.aircraft-card {
position: relative;
padding: 1.1rem 1rem 1rem;
border-radius: 10px;
border: 1px solid rgba(100,160,255,0.18);
background: linear-gradient(160deg, rgba(20,40,70,0.7), rgba(10,20,40,0.85));
cursor: pointer;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.aircraft-card:hover {
border-color: rgba(120,190,255,0.55);
transform: translateY(-2px);
}
.aircraft-card.selected {
border-color: #4db8ff;
box-shadow: 0 0 0 1px rgba(77,184,255,0.4), 0 10px 30px rgba(40,120,220,0.25);
background: linear-gradient(160deg, rgba(25,60,110,0.85), rgba(12,28,55,0.9));
}
.aircraft-card .name {
font-family: 'Orbitron', sans-serif;
font-size: 0.95rem;
letter-spacing: 0.08em;
margin-bottom: 0.2rem;
}
.aircraft-card .role {
font-size: 0.78rem;
color: #7aa0c0;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.stat-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.78rem; margin: 0.28rem 0; color: #a8c8e0;
}
.bar {
width: 88px; height: 5px; border-radius: 3px;
background: rgba(255,255,255,0.08); overflow: hidden;
}
.bar > i {
display: block; height: 100%; border-radius: 3px;
background: linear-gradient(90deg, #2a8fd4, #6ed0ff);
}
.modes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
margin-bottom: 1.4rem;
}
@media (max-width: 560px) { .modes { grid-template-columns: 1fr; } }
.mode-card {
padding: 1.15rem 1.1rem;
border-radius: 10px;
border: 1px solid rgba(100,160,255,0.18);
background: linear-gradient(160deg, rgba(18,36,62,0.75), rgba(8,16,30,0.85));
cursor: pointer;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.mode-card:hover { border-color: rgba(120,190,255,0.5); transform: translateY(-2px); }
.mode-card.selected {
border-color: #ff9a4d;
box-shadow: 0 0 0 1px rgba(255,154,77,0.35), 0 10px 28px rgba(180,80,20,0.2);
}
.mode-card .m-title {
font-family: 'Orbitron', sans-serif;
font-size: 0.92rem;
letter-spacing: 0.1em;
margin-bottom: 0.35rem;
}
.mode-card .m-desc {
font-size: 0.82rem;
color: #8aacc8;
line-height: 1.35;
}
.btn-row {
display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap;
}
.btn {
font-family: 'Orbitron', sans-serif;
font-size: 0.82rem;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 0.85rem 1.8rem;
border-radius: 8px;
border: 1px solid rgba(100,180,255,0.45);
background: linear-gradient(180deg, #1a5a9a, #0d3a6a);
color: #eaf6ff;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, filter 0.15s;
}
.btn:hover {
transform: translateY(-1px);
filter: brightness(1.12);
box-shadow: 0 8px 24px rgba(40,120,220,0.35);
}
.btn.primary {
background: linear-gradient(180deg, #2b8fd8, #1560a8);
border-color: rgba(140,210,255,0.65);
}
.btn.ghost {
background: transparent;
border-color: rgba(120,160,200,0.35);
}
.controls-hint {
margin-top: 1.2rem;
font-size: 0.78rem;
color: #5a809a;
text-align: center;
line-height: 1.55;
}
.controls-hint kbd {
display: inline-block;
padding: 0.1rem 0.4rem;
margin: 0 0.1rem;
border-radius: 3px;
border: 1px solid rgba(100,140,180,0.35);
background: rgba(20,40,70,0.6);
font-family: 'Rajdhani', sans-serif;
font-size: 0.75rem;
color: #9ec4e0;
}
/* ── HUD ── */
#hud {
position: fixed; inset: 0;
z-index: 10;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s;
}
#hud.active { opacity: 1; }
.hud-corner {
position: absolute;
font-family: 'Orbitron', sans-serif;
font-size: 0.68rem;
letter-spacing: 0.12em;
color: rgba(150, 210, 255, 0.75);
text-transform: uppercase;
}
.hud-corner.tl { top: 16px; left: 18px; }
.hud-corner.tr { top: 16px; right: 18px; text-align: right; }
.hud-corner.bl { bottom: 16px; left: 18px; }
.hud-corner.br { bottom: 16px; right: 18px; text-align: right; }
.hud-value {
font-size: 1.55rem;
font-weight: 600;
color: #dff2ff;
letter-spacing: 0.04em;
line-height: 1.15;
text-shadow: 0 0 12px rgba(80,160,255,0.45);
}
.hud-unit {
font-size: 0.65rem;
color: #6a9ab8;
letter-spacing: 0.15em;
}
.hud-label {
font-size: 0.62rem;
color: #5a88a8;
margin-bottom: 0.15rem;
}
#horizon-wrap {
position: absolute;
left: 50%; top: 50%;
width: 220px; height: 220px;
transform: translate(-50%, -50%);
pointer-events: none;
}
#horizon {
width: 100%; height: 100%;
}
#speed-tape, #alt-tape {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 64px;
height: 220px;
border: 1px solid rgba(100,180,255,0.2);
background: rgba(4, 12, 24, 0.45);
backdrop-filter: blur(4px);
border-radius: 4px;
overflow: hidden;
}
#speed-tape { left: calc(50% - 200px); }
#alt-tape { right: calc(50% - 200px); }
.tape-center {
position: absolute;
left: 0; right: 0; top: 50%;
height: 28px;
margin-top: -14px;
border-top: 1px solid rgba(120,200,255,0.5);
border-bottom: 1px solid rgba(120,200,255,0.5);
background: rgba(20,80,140,0.35);
display: flex; align-items: center; justify-content: center;
font-family: 'Orbitron', sans-serif;
font-size: 0.95rem;
color: #fff;
z-index: 2;
}
.tape-marks {
position: absolute; left: 0; right: 0; top: 0;
will-change: transform;
}
.tape-mark {
height: 28px;
display: flex; align-items: center; justify-content: flex-end;
padding-right: 8px;
font-size: 0.72rem;
color: rgba(160,210,255,0.7);
font-family: 'Orbitron', sans-serif;
border-bottom: 1px solid rgba(80,140,200,0.12);
}
#alt-tape .tape-mark { justify-content: flex-start; padding-left: 8px; }
#throttle-bar {
position: absolute;
left: 24px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 180px;
border: 1px solid rgba(100,180,255,0.25);
border-radius: 3px;
background: rgba(4,12,24,0.5);
overflow: hidden;
}
#throttle-fill {
position: absolute;
left: 0; right: 0; bottom: 0;
background: linear-gradient(0deg, #1a6ab0, #4ec8ff);
transition: height 0.08s linear;
}
#throttle-label {
position: absolute;
left: 24px;
top: calc(50% + 100px);
font-family: 'Orbitron', sans-serif;
font-size: 0.6rem;
color: #6a9ab8;
letter-spacing: 0.1em;
}
#aoa-bar {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 180px;
border: 1px solid rgba(100,180,255,0.25);
border-radius: 3px;
background: rgba(4,12,24,0.5);
overflow: hidden;
}
#aoa-fill {
position: absolute;
left: 0; right: 0;
height: 4px;
background: #7ee0ff;
box-shadow: 0 0 8px rgba(100,220,255,0.6);
}
#aoa-stall {
position: absolute;
left: 0; right: 0;
height: 2px;
background: #ff4466;
opacity: 0.7;
}
#aoa-label {
position: absolute;
right: 24px;
top: calc(50% + 100px);
font-family: 'Orbitron', sans-serif;
font-size: 0.6rem;
color: #6a9ab8;
letter-spacing: 0.1em;
text-align: right;
}
#warn {
position: absolute;
top: 22%;
left: 50%;
transform: translateX(-50%);
font-family: 'Orbitron', sans-serif;
font-size: 0.95rem;
letter-spacing: 0.28em;
color: #ff4060;
text-shadow: 0 0 16px rgba(255,40,60,0.6);
opacity: 0;
transition: opacity 0.15s;
text-align: center;
}
#warn.show { opacity: 1; animation: pulse 0.6s ease-in-out infinite alternate; }
@keyframes pulse { from { opacity: 0.55; } to { opacity: 1; } }
#mission-banner {
position: absolute;
top: 12%;
left: 50%;
transform: translateX(-50%);
text-align: center;
pointer-events: none;
}
#mission-banner .mb-title {
font-family: 'Orbitron', sans-serif;
font-size: 0.78rem;
letter-spacing: 0.3em;
color: #7ec8ff;
margin-bottom: 0.3rem;
}
#mission-banner .mb-obj {
font-size: 0.95rem;
color: #c8e4ff;
letter-spacing: 0.04em;
}
#g-meter {
position: absolute;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
font-family: 'Orbitron', sans-serif;
font-size: 0.75rem;
letter-spacing: 0.15em;
color: #8ab8d8;
}
#g-meter span { color: #dff2ff; font-size: 1.05rem; }
#crosshair {
position: absolute;
left: 50%; top: 50%;
width: 28px; height: 28px;
margin: -14px 0 0 -14px;
border: 1px solid rgba(150,210,255,0.35);
border-radius: 50%;
}
#crosshair::before, #crosshair::after {
content: '';
position: absolute;
background: rgba(150,210,255,0.45);
}
#crosshair::before {
left: 50%; top: 3px; bottom: 3px; width: 1px; margin-left: -0.5px;
}
#crosshair::after {
top: 50%; left: 3px; right: 3px; height: 1px; margin-top: -0.5px;
}
#minimap {
position: absolute;
bottom: 18px;
right: 18px;
width: 140px;
height: 140px;
border-radius: 8px;
border: 1px solid rgba(100,180,255,0.3);
background: rgba(4,14,28,0.65);
overflow: hidden;
}
#minimap canvas { width: 100%; height: 100%; display: block; }
#pause-overlay {
position: fixed; inset: 0;
z-index: 30;
display: none;
align-items: center;
justify-content: center;
background: rgba(2,8,16,0.72);
backdrop-filter: blur(6px);
}
#pause-overlay.show { display: flex; }
#pause-overlay .panel { text-align: center; min-width: 320px; }
#pause-overlay h2 { margin-bottom: 1.2rem; }
#flash {
position: fixed; inset: 0;
z-index: 25;
pointer-events: none;
background: #fff;
opacity: 0;
transition: opacity 0.08s;
}
#loading {
position: fixed; inset: 0; z-index: 50;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #030810;
transition: opacity 0.6s;
}
#loading.hide { opacity: 0; pointer-events: none; }
.load-bar {
width: 220px; height: 3px;
background: rgba(255,255,255,0.08);
border-radius: 2px;
overflow: hidden;
margin-top: 1.2rem;
}
.load-bar > i {
display: block; height: 100%; width: 0%;
background: linear-gradient(90deg, #2a8fd4, #7ed0ff);
transition: width 0.3s;
}
</style>
</head>
<body>
<div id="loading">
<div class="logo" style="font-size:2rem">APEX AERO</div>
<div class="tagline" style="margin-bottom:0">INITIALIZING SYSTEMS</div>
<div class="load-bar"><i id="load-fill"></i></div>
</div>
<div id="canvas-wrap"></div>
<div id="flash"></div>
<!-- Main Menu -->
<div id="menu" class="screen">
<div class="logo">Apex Aero</div>
<div class="tagline">Precision Flight Dynamics</div>
<div class="panel">
<h2>Select Aircraft</h2>
<div class="fleet" id="fleet"></div>
<h2>Mission Profile</h2>
<div class="modes">
<div class="mode-card selected" data-mode="takeoff" id="mode-takeoff">
<div class="m-title">✦ Takeoff & Landing</div>
<div class="m-desc">Precision runway ops. Manage lift, airspeed, and stall margins. Land within the zone.</div>
</div>
<div class="mode-card" data-mode="dogfight" id="mode-dogfight">
<div class="m-title">⚡ Dogfight Arena</div>
<div class="m-desc">High-G combat. Track and destroy the unmanned target drone before it escapes.</div>
</div>
</div>
<div class="btn-row">
<button class="btn primary" id="btn-start">Engage</button>
</div>
<div class="controls-hint">
<kbd>W</kbd><kbd>S</kbd> pitch · <kbd>A</kbd><kbd>D</kbd> roll · <kbd>Q</kbd><kbd>E</kbd> yaw<br>
<kbd>Shift</kbd>/<kbd>Ctrl</kbd> or <kbd>↑</kbd>/<kbd>↓</kbd> throttle · <kbd>Space</kbd> fire · <kbd>C</kbd> camera · <kbd>P</kbd>/<kbd>Esc</kbd> pause · pointer lock mouse look
</div>
</div>
</div>
<!-- Pause -->
<div id="pause-overlay">
<div class="panel">
<h2>Systems Hold</h2>
<div class="btn-row" style="flex-direction:column;align-items:stretch">
<button class="btn primary" id="btn-resume">Resume</button>
<button class="btn ghost" id="btn-restart">Restart Mission</button>
<button class="btn ghost" id="btn-menu">Main Menu</button>
</div>
</div>
</div>
<!-- HUD -->
<div id="hud">
<div class="hud-corner tl">
<div class="hud-label">Aircraft</div>
<div class="hud-value" id="h-name" style="font-size:1rem">—</div>
<div class="hud-unit" id="h-mode">—</div>
</div>
<div class="hud-corner tr">
<div class="hud-label">Heading</div>
<div class="hud-value" id="h-hdg">000</div>
<div class="hud-unit">DEG</div>
</div>
<div class="hud-corner bl">
<div class="hud-label">Vertical Speed</div>
<div class="hud-value" id="h-vs" style="font-size:1.15rem">0</div>
<div class="hud-unit">FT/MIN</div>
</div>
<div class="hud-corner br">
<div class="hud-label">Throttle · RPM</div>
<div class="hud-value" id="h-thr" style="font-size:1.15rem">0%</div>
<div class="hud-unit" id="h-rpm">0 RPM</div>
</div>
<div id="speed-tape">
<div class="tape-marks" id="spd-marks"></div>
<div class="tape-center" id="spd-readout">0</div>
</div>
<div id="alt-tape">
<div class="tape-marks" id="alt-marks"></div>
<div class="tape-center" id="alt-readout">0</div>
</div>
<div id="horizon-wrap">
<svg id="horizon" viewBox="0 0 220 220">
<defs>
<clipPath id="hclip"><circle cx="110" cy="110" r="78"/></clipPath>
</defs>
<g id="h-ball" clip-path="url(#hclip)">
<rect id="h-sky" x="-50" y="-100" width="320" height="210" fill="rgba(40,100,180,0.35)"/>
<rect id="h-gnd" x="-50" y="110" width="320" height="210" fill="rgba(80,60,30,0.4)"/>
<line x1="-50" y1="110" x2="270" y2="110" stroke="rgba(220,200,140,0.7)" stroke-width="2"/>
<g id="h-pitch"></g>
</g>
<circle cx="110" cy="110" r="78" fill="none" stroke="rgba(120,190,255,0.35)" stroke-width="1.5"/>
<polygon points="110,108 105,118 115,118" fill="#7ee0ff"/>
<line x1="70" y1="110" x2="95" y2="110" stroke="#7ee0ff" stroke-width="2"/>
<line x1="125" y1="110" x2="150" y2="110" stroke="#7ee0ff" stroke-width="2"/>
</svg>
</div>
<div id="throttle-bar"><div id="throttle-fill" style="height:0%"></div></div>
<div id="throttle-label">THR</div>
<div id="aoa-bar">
<div id="aoa-stall" style="bottom:72%"></div>
<div id="aoa-fill" style="bottom:40%"></div>
</div>
<div id="aoa-label">AoA</div>
<div id="crosshair"></div>
<div id="warn"></div>
<div id="mission-banner">
<div class="mb-title" id="mb-title">MISSION</div>
<div class="mb-obj" id="mb-obj">—</div>
</div>
<div id="g-meter">LOAD <span id="h-g">1.0</span> G</div>
<div id="minimap"><canvas id="mm-canvas" width="280" height="280"></canvas></div>
</div>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script>
// ═══════════════════════════════════════════════════════════
// Constants & Aircraft Catalog
// ═══════════════════════════════════════════════════════════
const FT = 3.28084;
const M_PER_FT = 1 / FT;
const SEA_LEVEL_DENSITY = 1.225;
const GRAVITY = 9.81;
const WORLD_SIZE = 12000;
const TERRAIN_RES = 192;
const FLEET = [
{
id: 'falcon',
name: 'XF-14 FALCON',
role: 'Air Superiority',
mass: 9200,
wingArea: 52.5,
wingspan: 14.2,
cl0: 0.18,
clAlpha: 5.2,
clMax: 1.45,
cd0: 0.022,
kInduced: 0.045,
maxThrust: 145000,
throttleLag: 0.55,
pitchAuth: 1.35,
rollAuth: 2.1,
yawAuth: 0.85,
pitchDamp: 2.8,
rollDamp: 3.2,
yawDamp: 2.4,
maxSpeed: 720,
stallAoA: 0.28,
color: 0x3a7ab8,
accent: 0xc0d8f0,
style: 'fighter'
},
{
id: 'condor',
name: 'C-80 CONDOR',
role: 'Heavy Transport',
mass: 42000,
wingArea: 168,
wingspan: 42,
cl0: 0.32,
clAlpha: 4.6,
clMax: 1.65,
cd0: 0.038,
kInduced: 0.052,
maxThrust: 280000,
throttleLag: 1.4,
pitchAuth: 0.55,
rollAuth: 0.7,
yawAuth: 0.45,
pitchDamp: 3.6,
rollDamp: 4.0,
yawDamp: 3.2,
maxSpeed: 280,
stallAoA: 0.32,
color: 0x6a7a8a,
accent: 0xd4c48a,
style: 'transport'
},
{
id: 'viper',
name: 'AV-7 VIPER',
role: 'Agility Interceptor',
mass: 6100,
wingArea: 38,
wingspan: 9.8,
cl0: 0.12,
clAlpha: 5.8,
clMax: 1.55,
cd0: 0.019,
kInduced: 0.048,
maxThrust: 118000,
throttleLag: 0.32,
pitchAuth: 1.85,
rollAuth: 2.8,
yawAuth: 1.15,
pitchDamp: 2.2,
rollDamp: 2.6,
yawDamp: 2.0,
maxSpeed: 780,
stallAoA: 0.30,
color: 0xb83828,
accent: 0x202028,
style: 'interceptor'
}
];
// ═══════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════
const state = {
phase: 'menu', // menu | flying | paused | ended
mode: 'takeoff',
aircraftId: 'falcon',
pointerLocked: false,
keys: Object.create(null),
mouseDX: 0,
mouseDY: 0,
camMode: 0, // 0 chase, 1 cockpit, 2 free
mission: {
status: 'idle',
message: '',
landed: false,
airborne: false,
takeoffDist: 0,
shots: 0,
hits: 0,
time: 0
}
};
// ═══════════════════════════════════════════════════════════
// Audio Engine (Web Audio API)
// ═══════════════════════════════════════════════════════════
class AudioFX {
constructor() {
this.ctx = null;
this.master = null;
this.engine = null;
this.wind = null;
this.started = false;
}
async init() {
if (this.started) return;
const AC = window.AudioContext || window.webkitAudioContext;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.35;
this.master.connect(this.ctx.destination);
// Engine: layered oscillators through filter
this.engineGain = this.ctx.createGain();
this.engineGain.gain.value = 0;
this.engineFilter = this.ctx.createBiquadFilter();
this.engineFilter.type = 'lowpass';
this.engineFilter.frequency.value = 400;
this.engineFilter.Q.value = 0.7;
this.engineGain.connect(this.engineFilter);
this.engineFilter.connect(this.master);
this.osc1 = this.ctx.createOscillator();
this.osc1.type = 'sawtooth';
this.osc1.frequency.value = 55;
this.osc1G = this.ctx.createGain();
this.osc1G.gain.value = 0.35;
this.osc1.connect(this.osc1G);
this.osc1G.connect(this.engineGain);
this.osc2 = this.ctx.createOscillator();
this.osc2.type = 'square';
this.osc2.frequency.value = 110;
this.osc2G = this.ctx.createGain();
this.osc2G.gain.value = 0.12;
this.osc2.connect(this.osc2G);
this.osc2G.connect(this.engineGain);
// Noise for turbofan hiss / wind
const bufLen = this.ctx.sampleRate * 2;
const noiseBuf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate);
const data = noiseBuf.getChannelData(0);
for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1;
this.noiseSrc = this.ctx.createBufferSource();
this.noiseSrc.buffer = noiseBuf;
this.noiseSrc.loop = true;
this.noiseFilter = this.ctx.createBiquadFilter();
this.noiseFilter.type = 'bandpass';
this.noiseFilter.frequency.value = 800;
this.noiseFilter.Q.value = 0.5;
this.noiseGain = this.ctx.createGain();
this.noiseGain.gain.value = 0;
this.noiseSrc.connect(this.noiseFilter);
this.noiseFilter.connect(this.noiseGain);
this.noiseGain.connect(this.master);
// Wind ambience
this.windSrc = this.ctx.createBufferSource();
this.windSrc.buffer = noiseBuf;
this.windSrc.loop = true;
this.windFilter = this.ctx.createBiquadFilter();
this.windFilter.type = 'highpass';
this.windFilter.frequency.value = 400;
this.windGain = this.ctx.createGain();
this.windGain.gain.value = 0.02;
this.windSrc.connect(this.windFilter);
this.windFilter.connect(this.windGain);
this.windGain.connect(this.master);
this.osc1.start();
this.osc2.start();
this.noiseSrc.start();
this.windSrc.start();
this.started = true;
}
resume() {
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
}
update(rpmNorm, thr, speed, aoa, altitude) {
if (!this.started) return;
const t = this.ctx.currentTime;
const engineVol = 0.08 + thr * 0.55 + rpmNorm * 0.2;
const densScale = Math.exp(-altitude / 10000);
this.engineGain.gain.setTargetAtTime(engineVol * densScale, t, 0.08);
const baseF = 40 + rpmNorm * 90 + thr * 40;
this.osc1.frequency.setTargetAtTime(baseF, t, 0.05);
this.osc2.frequency.setTargetAtTime(baseF * 2.02, t, 0.05);
this.engineFilter.frequency.setTargetAtTime(280 + thr * 1400 + rpmNorm * 600, t, 0.08);
const rush = Math.min(1, speed / 180);
const aoaBoost = Math.min(1, Math.abs(aoa) * 3);
this.noiseGain.gain.setTargetAtTime((0.02 + rush * 0.22 + aoaBoost * 0.08) * densScale, t, 0.1);
this.noiseFilter.frequency.setTargetAtTime(500 + rush * 2500 + aoaBoost * 800, t, 0.1);
this.windGain.gain.setTargetAtTime(0.015 + rush * 0.08 + altitude / 40000, t, 0.15);
}
boom() {
if (!this.started) return;
const t = this.ctx.currentTime;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(180, t);
o.frequency.exponentialRampToValueAtTime(40, t + 0.35);
g.gain.setValueAtTime(0.5, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
o.connect(g); g.connect(this.master);
o.start(t); o.stop(t + 0.45);
}
gun() {
if (!this.started) return;
const t = this.ctx.currentTime;
const bufLen = Math.floor(this.ctx.sampleRate * 0.06);
const buf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufLen; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufLen * 0.25));
const src = this.ctx.createBufferSource();
src.buffer = buf;
const f = this.ctx.createBiquadFilter();
f.type = 'highpass'; f.frequency.value = 900;
const g = this.ctx.createGain();
g.gain.value = 0.35;
src.connect(f); f.connect(g); g.connect(this.master);
src.start();
}
stallWarn(on) {
if (!this.started) return;
if (on && !this._stallOsc) {
const t = this.ctx.currentTime;
this._stallOsc = this.ctx.createOscillator();
this._stallG = this.ctx.createGain();
this._stallOsc.type = 'square';
this._stallOsc.frequency.value = 880;
this._stallG.gain.value = 0.04;
this._stallOsc.connect(this._stallG);
this._stallG.connect(this.master);
this._stallOsc.start();
} else if (!on && this._stallOsc) {
try { this._stallOsc.stop(); } catch (_) {}
this._stallOsc = null;
}
}
}
const audio = new AudioFX();
// ═══════════════════════════════════════════════════════════
// Terrain
// ═══════════════════════════════════════════════════════════
function hash2(x, z) {
let n = Math.sin(x * 127.1 + z * 311.7) * 43758.5453;
return n - Math.floor(n);
}
function smoothNoise(x, z) {
const ix = Math.floor(x), iz = Math.floor(z);
const fx = x - ix, fz = z - iz;
const ux = fx * fx * (3 - 2 * fx);
const uz = fz * fz * (3 - 2 * fz);
const a = hash2(ix, iz);
const b = hash2(ix + 1, iz);
const c = hash2(ix, iz + 1);
const d = hash2(ix + 1, iz + 1);
return a + (b - a) * ux + (c - a) * uz + (a - b - c + d) * ux * uz;
}
function fbm(x, z) {
let v = 0, a = 1, f = 1, s = 0;
for (let i = 0; i < 6; i++) {
v += a * smoothNoise(x * f, z * f);
s += a;
a *= 0.5;
f *= 2.05;
}
return v / s;
}
function terrainHeight(x, z) {
// Strong flat corridor for the active runway (x≈0, z from -500..2200)
const rx = Math.abs(x) / 90;
const rz = (z < -500) ? (-500 - z) / 400
: (z > 2200) ? (z - 2200) / 400
: 0;
const runwayInflu = Math.exp(-(rx * rx * 2.2 + rz * rz * 2.2));
const continent = fbm(x * 0.00018, z * 0.00018);
const ridges = Math.abs(fbm(x * 0.00055 + 40, z * 0.00055 - 12) - 0.5) * 2;
const hills = fbm(x * 0.0012, z * 0.0012);
let h = continent * 420 + ridges * 380 + hills * 90 - 80;
// Soft coast drop-off
const edge = Math.max(Math.abs(x), Math.abs(z)) / (WORLD_SIZE * 0.48);
if (edge > 0.75) h -= (edge - 0.75) * 900;
// Flatten runway to a constant pad for reliable takeoff / landing
h = h * (1 - runwayInflu) + 2 * runwayInflu;
return Math.max(h, -40);
}
function buildTerrain(scene) {
const geo = new THREE.PlaneGeometry(WORLD_SIZE, WORLD_SIZE, TERRAIN_RES, TERRAIN_RES);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const colors = new Float32Array(pos.count * 3);
const c = new THREE.Color();
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
const y = terrainHeight(x, z);
pos.setY(i, y);
if (y < 2) {
c.setHSL(0.55, 0.35, 0.18 + Math.max(0, y + 40) / 200);
} else if (y < 40) {
// grass / runway green-brown
const t = (y - 2) / 38;
c.setRGB(0.18 + t * 0.08, 0.28 + t * 0.12, 0.12 + t * 0.04);
// runway strip tint
if (Math.abs(x) < 45 && z > -400 && z < 2200) {
c.setRGB(0.22, 0.22, 0.24);
}
} else if (y < 220) {
const t = (y - 40) / 180;
c.setRGB(0.25 + t * 0.15, 0.32 - t * 0.1, 0.14);
} else if (y < 420) {
const t = (y - 220) / 200;
c.setRGB(0.38 + t * 0.2, 0.32 + t * 0.15, 0.26 + t * 0.15);
} else {
c.setRGB(0.82, 0.85, 0.9);
}
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.92,
metalness: 0.05,
flatShading: false
});
const mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = true;
mesh.castShadow = false;
scene.add(mesh);
// Water plane
const waterGeo = new THREE.PlaneGeometry(WORLD_SIZE * 1.2, WORLD_SIZE * 1.2);
waterGeo.rotateX(-Math.PI / 2);
const waterMat = new THREE.MeshStandardMaterial({
color: 0x0a2a48,
roughness: 0.15,
metalness: 0.65,
transparent: true,
opacity: 0.88
});
const water = new THREE.Mesh(waterGeo, waterMat);
water.position.y = 0.5;
scene.add(water);
// Runway markings
const rMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1e, roughness: 0.85 });
const runway = new THREE.Mesh(new THREE.BoxGeometry(70, 0.4, 2400), rMat);
runway.position.set(0, 2.3, 800);
runway.receiveShadow = true;
scene.add(runway);
const lineMat = new THREE.MeshBasicMaterial({ color: 0xf0e8c0 });
for (let i = 0; i < 28; i++) {
const line = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.45, 28), lineMat);
line.position.set(0, 2.55, -200 + i * 80);
scene.add(line);
}
// Threshold chevrons
const thrMat = new THREE.MeshBasicMaterial({ color: 0xe8e0a0 });
for (let s = -1; s <= 1; s += 2) {
for (let k = 0; k < 6; k++) {
const ch = new THREE.Mesh(new THREE.BoxGeometry(3, 0.42, 14), thrMat);
ch.position.set(s * (8 + k * 4), 2.55, -280);
scene.add(ch);
const ch2 = ch.clone();
ch2.position.z = 1900;
scene.add(ch2);
}
}
// Landing zone marker rings (mission)
state._runwayStart = new THREE.Vector3(0, 3, -300);
state._runwayEnd = new THREE.Vector3(0, 3, 1900);
return mesh;
}
// Sparse trees / rocks as instanced meshes
function decorateTerrain(scene) {
const treeGeo = new THREE.ConeGeometry(4, 16, 5);
const treeMat = new THREE.MeshStandardMaterial({ color: 0x1a4a28, roughness: 0.9 });
const trees = new THREE.InstancedMesh(treeGeo, treeMat, 400);
const dummy = new THREE.Object3D();
let n = 0;
for (let i = 0; i < 1200 && n < 400; i++) {
const x = (Math.random() - 0.5) * WORLD_SIZE * 0.85;
const z = (Math.random() - 0.5) * WORLD_SIZE * 0.85;
if (Math.abs(x) < 120 && z > -500 && z < 2300) continue;
const y = terrainHeight(x, z);
if (y < 8 || y > 280) continue;
dummy.position.set(x, y + 6, z);
const s = 0.6 + Math.random() * 1.4;
dummy.scale.set(s, s * (0.8 + Math.random() * 0.6), s);
dummy.rotation.y = Math.random() * Math.PI;
dummy.updateMatrix();
trees.setMatrixAt(n++, dummy.matrix);
}
trees.instanceMatrix.needsUpdate = true;
trees.castShadow = true;
scene.add(trees);
}
// ═══════════════════════════════════════════════════════════
// Procedural Aircraft Models
// ═══════════════════════════════════════════════════════════
function makeAircraftMesh(spec) {
const root = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: spec.color, metalness: 0.55, roughness: 0.35
});
const accentMat = new THREE.MeshStandardMaterial({
color: spec.accent, metalness: 0.4, roughness: 0.45
});
const canopyMat = new THREE.MeshStandardMaterial({
color: 0x88ccee, metalness: 0.9, roughness: 0.1, transparent: true, opacity: 0.55
});
const darkMat = new THREE.MeshStandardMaterial({ color: 0x111118, metalness: 0.6, roughness: 0.4 });
if (spec.style === 'fighter') {
// Fuselage
const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(0.7, 7.5, 4, 12), bodyMat);
fuse.rotation.z = Math.PI / 2;
fuse.scale.set(1, 1, 0.85);
fuse.position.set(0, 0, 0);
root.add(fuse);
// Nose
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.55, 2.2, 10), bodyMat);
nose.rotation.x = -Math.PI / 2;
nose.position.set(0, 0, 4.6);
root.add(nose);
// Canopy
const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.7, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), canopyMat);
canopy.scale.set(0.9, 0.7, 1.4);
canopy.position.set(0, 0.55, 1.5);
root.add(canopy);
// Wings
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(5.5, -1.2);
wingShape.lineTo(5.5, -1.8);
wingShape.lineTo(0, -2.6);
wingShape.lineTo(0, 0);
const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth: 0.12, bevelEnabled: false });
const wingL = new THREE.Mesh(wingGeo, bodyMat);
wingL.rotation.set(-0.05, 0, 0.08);
wingL.position.set(0.4, -0.1, 0.5);
root.add(wingL);
const wingR = wingL.clone();
wingR.scale.x = -1;
wingR.position.x = -0.4;
root.add(wingR);
// Twin vertical tails
for (const sx of [-1, 1]) {
const vt = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.6, 1.4), accentMat);
vt.position.set(sx * 1.1, 0.9, -3.2);
vt.rotation.z = sx * 0.25;
root.add(vt);
}
// Engines
for (const sx of [-1, 1]) {
const eng = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.5, 2.5, 10), darkMat);
eng.rotation.x = Math.PI / 2;
eng.position.set(sx * 0.7, -0.15, -3.5);
root.add(eng);
const glow = new THREE.Mesh(
new THREE.CircleGeometry(0.4, 12),
new THREE.MeshBasicMaterial({ color: 0x44aaff, transparent: true, opacity: 0.7 })
);
glow.position.set(sx * 0.7, -0.15, -4.8);
root.add(glow);
}
// Stabilizers
const hstab = new THREE.Mesh(new THREE.BoxGeometry(4.2, 0.08, 1.1), bodyMat);
hstab.position.set(0, 0.1, -3.6);
root.add(hstab);
} else if (spec.style === 'transport') {
const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(1.5, 14, 4, 14), bodyMat);
fuse.rotation.z = Math.PI / 2;
root.add(fuse);
const nose = new THREE.Mesh(new THREE.SphereGeometry(1.4, 12, 10), bodyMat);
nose.scale.set(1, 1, 1.4);
nose.position.set(0, 0, 7.5);
root.add(nose);
const cockpit = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.2, 2.5), canopyMat);
cockpit.position.set(0, 1.0, 6.2);
root.add(cockpit);
// High wings
const wing = new THREE.Mesh(new THREE.BoxGeometry(28, 0.22, 4.2), bodyMat);
wing.position.set(0, 1.4, 0.5);
wing.rotation.z = 0.03;
root.add(wing);
// Engines under wing
for (const sx of [-8, -3.5, 3.5, 8]) {
const eng = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.6, 2.8, 10), darkMat);
eng.rotation.x = Math.PI / 2;
eng.position.set(sx, 0.5, 1.2);
root.add(eng);
const glow = new THREE.Mesh(
new THREE.CircleGeometry(0.48, 12),
new THREE.MeshBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.55 })
);
glow.position.set(sx, 0.5, -0.2);
root.add(glow);
}
// Tail
const hstab = new THREE.Mesh(new THREE.BoxGeometry(10, 0.15, 2.5), bodyMat);
hstab.position.set(0, 0.5, -7.5);
root.add(hstab);
const vstab = new THREE.Mesh(new THREE.BoxGeometry(0.2, 4.5, 3), accentMat);
vstab.position.set(0, 2.6, -7.2);
root.add(vstab);
} else {
// Interceptor — delta / canard
const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(0.55, 6.5, 4, 12), bodyMat);
fuse.rotation.z = Math.PI / 2;
root.add(fuse);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.45, 2.5, 8), bodyMat);
nose.rotation.x = -Math.PI / 2;
nose.position.set(0, 0, 4.2);
root.add(nose);
const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.55, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), canopyMat);
canopy.scale.set(0.8, 0.55, 1.3);
canopy.position.set(0, 0.42, 1.2);
root.add(canopy);
// Delta wing
const dShape = new THREE.Shape();
dShape.moveTo(0, 1.5);
dShape.lineTo(5.2, -3.8);
dShape.lineTo(0.3, -3.8);
dShape.lineTo(0, 1.5);
const dGeo = new THREE.ExtrudeGeometry(dShape, { depth: 0.1, bevelEnabled: false });
const dL = new THREE.Mesh(dGeo, bodyMat);
dL.position.set(0.2, -0.05, 0.8);
root.add(dL);
const dR = dL.clone();
dR.scale.x = -1;
dR.position.x = -0.2;
root.add(dR);
// Canards
const canard = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.06, 0.7), accentMat);
canard.position.set(0, 0.1, 2.8);
root.add(canard);
// Single vertical tail
const vt = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.9, 1.6), accentMat);
vt.position.set(0, 1.0, -2.8);
root.add(vt);
// Engine
const eng = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.55, 2.2, 10), darkMat);
eng.rotation.x = Math.PI / 2;
eng.position.set(0, -0.1, -3.2);
root.add(eng);
const glow = new THREE.Mesh(
new THREE.CircleGeometry(0.45, 12),
new THREE.MeshBasicMaterial({ color: 0xff6633, transparent: true, opacity: 0.75 })
);
glow.position.set(0, -0.1, -4.35);
root.add(glow);
}
root.traverse(o => {
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
// Scale to feel right in world units
const scale = spec.style === 'transport' ? 1.1 : 1.0;
root.scale.setScalar(scale);
return root;
}
// ═══════════════════════════════════════════════════════════
// Flight Physics (Newtonian aerodynamics)
// ═══════════════════════════════════════════════════════════
class Aircraft {
constructor(spec, mesh) {
this.spec = spec;
this.mesh = mesh;
this.position = new THREE.Vector3();
this.velocity = new THREE.Vector3();
this.orientation = new THREE.Quaternion();
this.angularVel = new THREE.Vector3(); // local body rates (pitch, yaw, roll) rad/s loosely
this.throttle = 0;
this.throttleActual = 0;
this.rpm = 0;
// Control inputs [-1,1]
this.input = { pitch: 0, roll: 0, yaw: 0 };
this.onGround = true;
this.gearDown = true;
this.aoa = 0;
this.sideslip = 0;
this.gLoad = 1;
this.airspeed = 0;
this.altitude = 0;
this.heading = 0;
this.pitchAngle = 0;
this.bankAngle = 0;
this.stalling = false;
this.crashed = false;
this.alive = true;
this._fwd = new THREE.Vector3();
this._up = new THREE.Vector3();
this._right = new THREE.Vector3();
this._liftDir = new THREE.Vector3();
this._force = new THREE.Vector3();
this._tmp = new THREE.Vector3();
this._q = new THREE.Quaternion();
this._euler = new THREE.Euler();
}
reset(pos, heading = 0, grounded = true) {
this.position.copy(pos);
this.velocity.set(0, 0, 0);
this.angularVel.set(0, 0, 0);
this.orientation.setFromEuler(new THREE.Euler(0, heading, 0, 'YXZ'));
this.throttle = grounded ? 0 : 0.7;
this.throttleActual = this.throttle;
this.rpm = this.throttle;
this.onGround = grounded;
this.crashed = false;
this.alive = true;
this.gLoad = 1;
this.aoa = 0;
this.sideslip = 0;
this.pitchAngle = 0;
this.bankAngle = 0;
this.heading = heading;
this.airspeed = 0;
this.altitude = pos.y;
this.stalling = false;
this.syncMesh();
}
syncMesh() {
this.mesh.position.copy(this.position);
this.mesh.quaternion.copy(this.orientation);
}
setControls(pitch, roll, yaw, thrTarget) {
this.input.pitch = THREE.MathUtils.clamp(pitch, -1, 1);
this.input.roll = THREE.MathUtils.clamp(roll, -1, 1);
this.input.yaw = THREE.MathUtils.clamp(yaw, -1, 1);
this.throttle = THREE.MathUtils.clamp(thrTarget, 0, 1);
}
densityAt(alt) {
// ISA approx
return SEA_LEVEL_DENSITY * Math.exp(-alt / 8500);
}
update(dt) {
if (!this.alive) return;
const s = this.spec;
dt = Math.min(dt, 0.05);
// Engine response lag
const lag = s.throttleLag;
const thrRate = (this.throttle - this.throttleActual) / Math.max(lag, 0.05);
this.throttleActual += thrRate * dt;
this.throttleActual = THREE.MathUtils.clamp(this.throttleActual, 0, 1);
this.rpm += (this.throttleActual - this.rpm) * Math.min(1, dt * 2.5);
// Body axes
this._fwd.set(0, 0, 1).applyQuaternion(this.orientation);
this._up.set(0, 1, 0).applyQuaternion(this.orientation);
this._right.set(1, 0, 0).applyQuaternion(this.orientation);
const speed = this.velocity.length();
this.airspeed = speed;
this.altitude = this.position.y;
// Angle of attack & sideslip relative to airflow
if (speed > 0.5) {
const vNorm = this._tmp.copy(this.velocity).normalize();
// aoa: angle between velocity and its projection on body XZ (pitch plane)
const vFwd = vNorm.dot(this._fwd);
const vUp = vNorm.dot(this._up);
const vRight = vNorm.dot(this._right);
this.aoa = Math.atan2(-vUp, Math.max(0.001, vFwd));
this.sideslip = Math.atan2(vRight, Math.max(0.001, vFwd));
} else {
this.aoa = 0;
this.sideslip = 0;
}
const rho = this.densityAt(Math.max(0, this.altitude));
const qDyn = 0.5 * rho * speed * speed;
// Lift coefficient with stall model
let cl = s.cl0 + s.clAlpha * this.aoa;
const stall = s.stallAoA;
this.stalling = Math.abs(this.aoa) > stall * 0.85;
if (Math.abs(this.aoa) > stall) {
// Post-stall drop
const excess = (Math.abs(this.aoa) - stall) / stall;
cl = Math.sign(cl || 1) * s.clMax * Math.max(0.15, 1 - excess * 1.4);
// Add buffeting torque
this.angularVel.x += (Math.random() - 0.5) * excess * 0.8 * dt;
this.angularVel.z += (Math.random() - 0.5) * excess * 1.2 * dt;
} else {
cl = THREE.MathUtils.clamp(cl, -s.clMax, s.clMax);
}
// Drag: parasitic + induced
const cd = s.cd0 + s.kInduced * cl * cl + Math.abs(this.sideslip) * 0.15;
// Force accumulation in world frame
this._force.set(0, 0, 0);
// Thrust along body forward
const thrust = s.maxThrust * this.throttleActual * (rho / SEA_LEVEL_DENSITY);
this._force.addScaledVector(this._fwd, thrust);
// Lift perpendicular to velocity, in the plane of body up/fwd (approx)
if (speed > 1) {
// Lift direction: perpendicular to velocity toward body up
this._liftDir.copy(this._up).addScaledVector(this._fwd, 0); // base
// More correct: lift = normalize(vel × right) direction with sign of aoa
this._liftDir.crossVectors(this.velocity, this._right).normalize();
// Ensure lift points mostly "up" relative to body
if (this._liftDir.dot(this._up) < 0) this._liftDir.negate();
const liftMag = qDyn * s.wingArea * cl;
this._force.addScaledVector(this._liftDir, liftMag);
// Drag opposite velocity
const dragMag = qDyn * s.wingArea * cd;
this._force.addScaledVector(this.velocity, -dragMag / speed);
// Side force from sideslip
const cy = -1.8 * this.sideslip;
this._force.addScaledVector(this._right, qDyn * s.wingArea * cy * 0.35);
}
// Gravity
this._force.y -= s.mass * GRAVITY;
// Ground interaction
const groundY = terrainHeight(this.position.x, this.position.z) + 2.2;
if (this.position.y <= groundY + 0.05 && this.velocity.y <= 0.5) {
this.onGround = true;
this.position.y = groundY;
// Impact check — only on hard landings / cartwheels, not continuous taxi
const impact = -this.velocity.y;
const bank = Math.abs(this.bankAngle);
const wasAirborne = !this.onGround || impact > 2;
if (wasAirborne && (impact > 14 || (bank > 0.75 && speed > 20) || (Math.abs(this.pitchAngle) > 0.7 && impact > 4))) {
this.crash();
return;
}
// Kill vertical / settle
if (this.velocity.y < 0) this.velocity.y = 0;
this.velocity.y = 0;
// Body forward projected on ground plane
const forwardGround = this._tmp.set(this._fwd.x, 0, this._fwd.z);
if (forwardGround.lengthSq() > 0.001) forwardGround.normalize();
else forwardGround.set(0, 0, 1);
// Thrust along ground track
this.velocity.addScaledVector(forwardGround, (thrust / s.mass) * dt);
// Lateral tire scrub — keep mostly along heading
let speedH = Math.hypot(this.velocity.x, this.velocity.z);
if (speedH > 0.15) {
const along = this.velocity.x * forwardGround.x + this.velocity.z * forwardGround.z;
const latX = this.velocity.x - forwardGround.x * along;
const latZ = this.velocity.z - forwardGround.z * along;
// Kill sideslip strongly, mild rolling resistance along track
const rollMu = 0.02 + (1 - this.throttleActual) * 0.12; // brake when throttling down
const latDamp = Math.exp(-8 * dt);
this.velocity.x = forwardGround.x * along * Math.exp(-rollMu * dt) + latX * latDamp;
this.velocity.z = forwardGround.z * along * Math.exp(-rollMu * dt) + latZ * latDamp;
// Steer with yaw input
const steer = this.input.yaw * 0.85 * Math.min(1, speedH / 25);
this._q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), -steer * dt);
this.orientation.premultiply(this._q);
// Redirect velocity with heading slightly
const f2 = new THREE.Vector3(0, 0, 1).applyQuaternion(this.orientation);
f2.y = 0;
if (f2.lengthSq() > 0.001) {
f2.normalize();
const spd = Math.hypot(this.velocity.x, this.velocity.z);
const blend = 1 - Math.exp(-2.5 * dt);
const tx = f2.x * spd;
const tz = f2.z * spd;
this.velocity.x += (tx - this.velocity.x) * blend;
this.velocity.z += (tz - this.velocity.z) * blend;
}
}
// Integrate ground roll
this.position.x += this.velocity.x * dt;
this.position.z += this.velocity.z * dt;
this.position.y = terrainHeight(this.position.x, this.position.z) + 2.2;
speedH = Math.hypot(this.velocity.x, this.velocity.z);
this.airspeed = speedH;
// Attitude: allow limited nose-up with elevator for rotation
this._euler.setFromQuaternion(this.orientation, 'YXZ');
this._euler.z *= Math.exp(-5 * dt);
// euler.x > 0 = nose down; nose-up target is negative
const targetPitch = THREE.MathUtils.clamp(-this.input.pitch * 0.18, -0.22, 0.06);
this._euler.x += (targetPitch - this._euler.x) * Math.min(1, 3 * dt);
this.orientation.setFromEuler(this._euler);
// Liftoff when lift exceeds weight (rotation helps)
const rotateAoA = Math.max(0.04, -this._euler.x + 0.02);
const liftOffCl = s.cl0 + s.clAlpha * rotateAoA;
const liftoffLift = 0.5 * rho * speedH * speedH * s.wingArea * liftOffCl;
if (liftoffLift > s.mass * GRAVITY * 0.98 && speedH > 28) {
this.onGround = false;
this.velocity.y = Math.max(2.0, (liftoffLift / s.mass - GRAVITY) * 0.3);
}
} else {
this.onGround = false;
// Integrate forces
this.velocity.addScaledVector(this._force, dt / s.mass);
this.position.addScaledVector(this.velocity, dt);
// Ground collision from below mid-air
if (this.position.y < groundY) {
this.position.y = groundY;
if (-this.velocity.y > 10 || Math.abs(this.bankAngle) > 0.7) {
this.crash();
return;
}
this.velocity.y = Math.max(0, this.velocity.y);
this.onGround = true;
}
}
// ── Rotational dynamics (simplified moments) ──
if (!this.onGround || this.airspeed > 40) {
const dyn = Math.min(1, (0.5 * rho * Math.max(speed, 5) ** 2) / 8000);
const qCtrl = Math.max(0.15, dyn);
// Body axes: +X right, +Y up, +Z forward → +rx pitches nose DOWN (right-hand).
// Invert pitch so +input (W) is nose-up.
this.angularVel.x += (-this.input.pitch * s.pitchAuth * qCtrl - this.angularVel.x * s.pitchDamp * 0.35) * dt;
this.angularVel.z += (-this.input.roll * s.rollAuth * qCtrl - this.angularVel.z * s.rollDamp * 0.35) * dt;
this.angularVel.y += (-this.input.yaw * s.yawAuth * qCtrl - this.angularVel.y * s.yawDamp * 0.4) * dt;
// Stability: weathercock / pitch stability lean toward zero aoa/sideslip
this.angularVel.x += (-this.aoa * 1.4) * dt * qCtrl;
this.angularVel.y += (-this.sideslip * 1.1) * dt * qCtrl;
// Couple: roll from yaw, etc. mild
this.angularVel.z += this.angularVel.y * 0.15 * dt;
// Limit rates
this.angularVel.x = THREE.MathUtils.clamp(this.angularVel.x, -2.2, 2.2);
this.angularVel.y = THREE.MathUtils.clamp(this.angularVel.y, -1.4, 1.4);
this.angularVel.z = THREE.MathUtils.clamp(this.angularVel.z, -3.5, 3.5);
// Apply angular velocity in body frame
const wx = this.angularVel.x * dt;
const wy = this.angularVel.y * dt;
const wz = this.angularVel.z * dt;
const dq = new THREE.Quaternion(
wx * 0.5, wy * 0.5, wz * 0.5, 1
).normalize();
this.orientation.multiply(dq).normalize();
} else if (this.onGround) {
// Nose wheel / elevators slight on ground for attitude
this._euler.setFromQuaternion(this.orientation, 'YXZ');
this._euler.x += this.input.pitch * 0.25 * dt;
this._euler.x = THREE.MathUtils.clamp(this._euler.x, -0.2, 0.18);
this.orientation.setFromEuler(this._euler);
}
// Derived instruments
this._euler.setFromQuaternion(this.orientation, 'YXZ');
this.pitchAngle = this._euler.x;
this.heading = this._euler.y;
this.bankAngle = this._euler.z;
// G-load estimate: acceleration along body up / g
if (speed > 5 && !this.onGround) {
const liftApprox = (qDyn * s.wingArea * cl) / (s.mass * GRAVITY);
this.gLoad = THREE.MathUtils.clamp(liftApprox * Math.cos(this.bankAngle * 0.2) + 0.15, -2, 12);
} else {
this.gLoad = 1;
}
this.syncMesh();
}
crash() {
this.crashed = true;
this.alive = false;
this.velocity.set(0, 0, 0);
audio.boom();
document.getElementById('flash').style.opacity = '0.7';
setTimeout(() => { document.getElementById('flash').style.opacity = '0'; }, 120);
}
}
// ═══════════════════════════════════════════════════════════
// Target drone for dogfight
// ═══════════════════════════════════════════════════════════
class Drone {
constructor(scene) {
this.mesh = makeAircraftMesh({
style: 'interceptor',
color: 0x445566,
accent: 0xffaa22
});
this.mesh.scale.setScalar(0.85);
scene.add(this.mesh);
this.position = new THREE.Vector3();
this.velocity = new THREE.Vector3();
this.orientation = new THREE.Quaternion();
this.hp = 100;
this.alive = true;
this.t = 0;
this._f = new THREE.Vector3();
this._u = new THREE.Vector3();
}
spawn(near) {
this.position.set(
near.x + 400 + Math.random() * 200,
Math.max(near.y + 80, 180),
near.z + 600
);
this.velocity.set(0, 0, 90);
this.orientation.identity();
this.hp = 100;
this.alive = true;
this.t = 0;
this.mesh.visible = true;
this.sync();
}
sync() {
this.mesh.position.copy(this.position);
this.mesh.quaternion.copy(this.orientation);
}
update(dt, playerPos) {
if (!this.alive) return;
this.t += dt;
// Orbit / weave AI
const toPlayer = this._f.copy(playerPos).sub(this.position);
const dist = toPlayer.length();
toPlayer.normalize();
// Desired direction: bank around player
const side = this._u.set(-toPlayer.z, 0, toPlayer.x);
const desired = toPlayer.multiplyScalar(0.35).add(side.multiplyScalar(0.75));
desired.y += Math.sin(this.t * 0.7) * 0.25 + (220 - this.position.y) * 0.002;
desired.normalize();
// Steer velocity
this.velocity.lerp(desired.multiplyScalar(95 + Math.sin(this.t * 0.3) * 20), 1 - Math.exp(-1.2 * dt));
this.position.addScaledVector(this.velocity, dt);
// Face velocity
if (this.velocity.lengthSq() > 1) {
const look = new THREE.Matrix4().lookAt(
this.position,
this._f.copy(this.position).add(this.velocity),
new THREE.Vector3(0, 1, 0)
);
this.orientation.setFromRotationMatrix(look);
}
this.sync();
// Terrain avoid
const gh = terrainHeight(this.position.x, this.position.z) + 40;
if (this.position.y < gh) {
this.position.y = gh;
this.velocity.y = Math.abs(this.velocity.y) * 0.5 + 10;
}
}
hit(dmg = 34) {
this.hp -= dmg;
if (this.hp <= 0) {
this.alive = false;
this.mesh.visible = false;
audio.boom();
return true;
}
return false;
}
}
// ═══════════════════════════════════════════════════════════
// Projectiles
// ═══════════════════════════════════════════════════════════
class Guns {
constructor(scene) {
this.scene = scene;
this.bolts = [];
this._geo = new THREE.SphereGeometry(0.45, 6, 6);
this._mat = new THREE.MeshBasicMaterial({ color: 0xffee88 });
this.cooldown = 0;
}
fire(origin, dir, speed = 420) {
if (this.cooldown > 0) return false;
const mesh = new THREE.Mesh(this._geo, this._mat);
mesh.position.copy(origin);
this.scene.add(mesh);
this.bolts.push({
mesh,
vel: dir.clone().normalize().multiplyScalar(speed),
life: 2.2
});
this.cooldown = 0.1;
audio.gun();
return true;
}
update(dt, drone) {
this.cooldown = Math.max(0, this.cooldown - dt);
for (let i = this.bolts.length - 1; i >= 0; i--) {
const b = this.bolts[i];
b.life -= dt;
b.mesh.position.addScaledVector(b.vel, dt);
if (drone && drone.alive && b.mesh.position.distanceTo(drone.position) < 12) {
const killed = drone.hit(28);
state.mission.hits++;
this.scene.remove(b.mesh);
this.bolts.splice(i, 1);
if (killed) {
state.mission.status = 'success';
state.mission.message = 'TARGET DESTROYED';
showWarn('TARGET DESTROYED', 3);
}
continue;
}
if (b.life <= 0) {
this.scene.remove(b.mesh);
this.bolts.splice(i, 1);
}
}
}
clear() {
for (const b of this.bolts) this.scene.remove(b.mesh);
this.bolts.length = 0;
}
}
// ═══════════════════════════════════════════════════════════
// Scene bootstrap
// ═══════════════════════════════════════════════════════════
if (typeof THREE === 'undefined') {
document.getElementById('loading').innerHTML =
'<div class="logo" style="font-size:1.6rem">APEX AERO</div>' +
'<div class="tagline">Unable to load Three.js from CDN. Serve this file over HTTP and ensure network access.</div>';
throw new Error('THREE.js not available');
}
const wrap = document.getElementById('canvas-wrap');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
wrap.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87b8e0);
scene.fog = new THREE.FogExp2(0x87b8e0, 0.000085);
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 25000);
// Sky dome
{
const skyGeo = new THREE.SphereGeometry(14000, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(0x1a4a8a) },
midColor: { value: new THREE.Color(0x6aacdc) },
botColor: { value: new THREE.Color(0xc8dce8) },
offset: { value: 0 },
exponent: { value: 0.55 }
},
vertexShader: `
varying vec3 vW;
void main() {
vec4 w = modelMatrix * vec4(position, 1.0);
vW = w.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 midColor;
uniform vec3 botColor;
uniform float offset;
uniform float exponent;
varying vec3 vW;
void main() {
float h = normalize(vW + offset).y;
vec3 col = mix(botColor, midColor, max(pow(max(h, 0.0), exponent), 0.0));
col = mix(col, topColor, max(pow(max(h, 0.0), exponent * 2.2), 0.0));
gl_FragColor = vec4(col, 1.0);
}
`
});
// Fix typo in uniform
skyMat.uniforms.midColor.value = new THREE.Color(0x6aacdc);
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
state._sky = sky;
}
// Lights
const hemi = new THREE.HemisphereLight(0xb8d8ff, 0x3a2a18, 0.55);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2d8, 1.35);
sun.position.set(400, 800, 200);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 3000;
sun.shadow.camera.left = -600;
sun.shadow.camera.right = 600;
sun.shadow.camera.top = 600;
sun.shadow.camera.bottom = -600;
scene.add(sun);
scene.add(new THREE.AmbientLight(0x406080, 0.25));
// Direct renderer (no external postprocessing modules)
// Build world
document.getElementById('load-fill').style.width = '20%';
buildTerrain(scene);
document.getElementById('load-fill').style.width = '55%';
decorateTerrain(scene);
document.getElementById('load-fill').style.width = '70%';
// Mission ring for landing
const landingRing = new THREE.Mesh(
new THREE.RingGeometry(28, 36, 48),
new THREE.MeshBasicMaterial({ color: 0x44ffaa, side: THREE.DoubleSide, transparent: true, opacity: 0.55 })
);
landingRing.rotation.x = -Math.PI / 2;
landingRing.position.set(0, 2.8, 1600);
landingRing.visible = false;
scene.add(landingRing);
// Cloud puffs (simple billboards)
{
const cloudGeo = new THREE.SphereGeometry(1, 8, 6);
const cloudMat = new THREE.MeshStandardMaterial({
color: 0xffffff, transparent: true, opacity: 0.45, roughness: 1, metalness: 0
});
for (let i = 0; i < 60; i++) {
const c = new THREE.Mesh(cloudGeo, cloudMat);
const x = (Math.random() - 0.5) * WORLD_SIZE * 0.9;
const z = (Math.random() - 0.5) * WORLD_SIZE * 0.9;
const y = 280 + Math.random() * 420;
c.position.set(x, y, z);
c.scale.set(40 + Math.random() * 80, 18 + Math.random() * 30, 40 + Math.random() * 80);
scene.add(c);
}
}
// Aircraft instances (only one active at a time)
const craftMeshes = {};
const craftPhysics = {};
for (const spec of FLEET) {
const mesh = makeAircraftMesh(spec);
mesh.visible = false;
scene.add(mesh);
craftMeshes[spec.id] = mesh;
craftPhysics[spec.id] = new Aircraft(spec, mesh);
}
let plane = craftPhysics.falcon;
let drone = new Drone(scene);
drone.mesh.visible = false;
const guns = new Guns(scene);
document.getElementById('load-fill').style.width = '100%';
// ═══════════════════════════════════════════════════════════
// UI setup
// ═══════════════════════════════════════════════════════════
function buildFleetUI() {
const el = document.getElementById('fleet');
el.innerHTML = '';
for (const a of FLEET) {
const card = document.createElement('div');
card.className = 'aircraft-card' + (a.id === state.aircraftId ? ' selected' : '');
card.dataset.id = a.id;
const thrN = a.maxThrust / a.mass;
const bars = (v) => `<i style="width:${v}%"></i>`;
card.innerHTML = `
<div class="name">${a.name}</div>
<div class="role">${a.role}</div>
<div class="stat-row"><span>Thrust/Wt</span><div class="bar">${bars(THREE.MathUtils.clamp(thrN / 25 * 100, 10, 100))}</div></div>
<div class="stat-row"><span>Agility</span><div class="bar">${bars(THREE.MathUtils.clamp(a.rollAuth / 2.8 * 100, 10, 100))}</div></div>
<div class="stat-row"><span>Top Speed</span><div class="bar">${bars(THREE.MathUtils.clamp(a.maxSpeed / 780 * 100, 10, 100))}</div></div>
<div class="stat-row"><span>Mass</span><div class="bar">${bars(THREE.MathUtils.clamp(a.mass / 42000 * 100, 10, 100))}</div></div>
`;
card.addEventListener('click', () => {
state.aircraftId = a.id;
document.querySelectorAll('.aircraft-card').forEach(c => c.classList.toggle('selected', c.dataset.id === a.id));
});
el.appendChild(card);
}
}
buildFleetUI();
document.querySelectorAll('.mode-card').forEach(card => {
card.addEventListener('click', () => {
state.mode = card.dataset.mode;
document.querySelectorAll('.mode-card').forEach(c => c.classList.toggle('selected', c === card));
});
});
// Pitch ladder ticks
{
const g = document.getElementById('h-pitch');
for (let p = -30; p <= 30; p += 10) {
if (p === 0) continue;
const y = 110 - p * 1.6;
const len = Math.abs(p) % 20 === 0 ? 28 : 18;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', 110 - len);
line.setAttribute('x2', 110 + len);
line.setAttribute('y1', y);
line.setAttribute('y2', y);
line.setAttribute('stroke', 'rgba(180,220,255,0.45)');
line.setAttribute('stroke-width', '1');
g.appendChild(line);
const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
txt.setAttribute('x', 110 - len - 4);
txt.setAttribute('y', y + 3);
txt.setAttribute('fill', 'rgba(180,220,255,0.5)');
txt.setAttribute('font-size', '8');
txt.setAttribute('text-anchor', 'end');
txt.setAttribute('font-family', 'Orbitron, sans-serif');
txt.textContent = String(p);
g.appendChild(txt);
}
}
// Tape marks
function buildTape(el, step, count, unit = 1) {
el.innerHTML = '';
for (let i = 0; i < count; i++) {
const d = document.createElement('div');
d.className = 'tape-mark';
d.dataset.v = String(i * step * unit);
d.textContent = String(i * step);
el.appendChild(d);
}
}
buildTape(document.getElementById('spd-marks'), 20, 40);
buildTape(document.getElementById('alt-marks'), 100, 80);
// ═══════════════════════════════════════════════════════════
// Input
// ═══════════════════════════════════════════════════════════
window.addEventListener('keydown', e => {
state.keys[e.code] = true;
if (['Space', 'ArrowUp', 'ArrowDown'].includes(e.code)) e.preventDefault();
if (e.code === 'KeyP' || e.code === 'Escape') {
if (state.phase === 'flying') pauseGame();
else if (state.phase === 'paused') resumeGame();
}
if (e.code === 'KeyC' && state.phase === 'flying') {
state.camMode = (state.camMode + 1) % 3;
}
});
window.addEventListener('keyup', e => { state.keys[e.code] = false; });
renderer.domElement.addEventListener('click', () => {
if (state.phase === 'flying') renderer.domElement.requestPointerLock();
});
document.addEventListener('pointerlockchange', () => {
state.pointerLocked = document.pointerLockElement === renderer.domElement;
});
document.addEventListener('mousemove', e => {
if (!state.pointerLocked) return;
state.mouseDX += e.movementX;
state.mouseDY += e.movementY;
});
// ═══════════════════════════════════════════════════════════
// Game flow
// ═══════════════════════════════════════════════════════════
function showWarn(msg, secs = 1.5) {
const w = document.getElementById('warn');
w.textContent = msg;
w.classList.add('show');
clearTimeout(showWarn._t);
showWarn._t = setTimeout(() => w.classList.remove('show'), secs * 1000);
}
function selectCraft(id) {
for (const k of Object.keys(craftMeshes)) craftMeshes[k].visible = false;
plane = craftPhysics[id];
craftMeshes[id].visible = true;
document.getElementById('h-name').textContent = plane.spec.name;
}
function startMission() {
audio.init().then(() => audio.resume());
state.phase = 'flying';
state.camMode = 0;
selectCraft(state.aircraftId);
guns.clear();
drone.mesh.visible = false;
drone.alive = false;
state.mission = {
status: 'active',
message: '',
landed: false,
airborne: false,
takeoffDist: 0,
shots: 0,
hits: 0,
time: 0
};
landingRing.visible = state.mode === 'takeoff';
if (state.mode === 'takeoff') {
const spawnZ = -350;
const spawnY = terrainHeight(0, spawnZ) + 2.2;
plane.reset(new THREE.Vector3(0, spawnY, spawnZ), 0, true);
document.getElementById('h-mode').textContent = 'TAKEOFF / LANDING';
document.getElementById('mb-title').textContent = 'PRECISION OPS';
document.getElementById('mb-obj').textContent = 'Throttle up · Rotate · Climb out · Return & land in the zone';
showWarn('CLEARED FOR TAKEOFF', 2.5);
} else {
plane.reset(new THREE.Vector3(0, 320, 0), 0, false);
plane.velocity.set(0, 0, 120);
plane.airspeed = 120;
plane.throttle = 0.75;
plane.throttleActual = 0.75;
thrTarget = 0.75;
drone.spawn(plane.position);
document.getElementById('h-mode').textContent = 'DOGFIGHT';
document.getElementById('mb-title').textContent = 'INTERCEPT';
document.getElementById('mb-obj').textContent = 'Engage target drone — SPACE to fire';
showWarn('WEAPONS FREE', 2);
}
thrTarget = plane.throttle;
document.getElementById('menu').classList.add('hidden');
document.getElementById('hud').classList.add('active');
document.getElementById('pause-overlay').classList.remove('show');
renderer.domElement.requestPointerLock();
}
function pauseGame() {
state.phase = 'paused';
document.getElementById('pause-overlay').classList.add('show');
document.exitPointerLock?.();
}
function resumeGame() {
state.phase = 'flying';
document.getElementById('pause-overlay').classList.remove('show');
renderer.domElement.requestPointerLock();
audio.resume();
}
function toMenu() {
state.phase = 'menu';
document.getElementById('pause-overlay').classList.remove('show');
document.getElementById('hud').classList.remove('active');
document.getElementById('menu').classList.remove('hidden');
document.exitPointerLock?.();
for (const k of Object.keys(craftMeshes)) craftMeshes[k].visible = false;
drone.mesh.visible = false;
guns.clear();
}
document.getElementById('btn-start').addEventListener('click', startMission);
document.getElementById('btn-resume').addEventListener('click', resumeGame);
document.getElementById('btn-restart').addEventListener('click', startMission);
document.getElementById('btn-menu').addEventListener('click', toMenu);
// ═══════════════════════════════════════════════════════════
// Camera
// ═══════════════════════════════════════════════════════════
const camOffset = new THREE.Vector3();
const camLook = new THREE.Vector3();
const camSmoothPos = new THREE.Vector3(0, 30, -40);
const camSmoothLook = new THREE.Vector3();
let freeYaw = 0, freePitch = 0.2;
function updateCamera(dt) {
const p = plane.position;
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(plane.orientation);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(plane.orientation);
if (state.camMode === 0) {
// Chase
camOffset.copy(fwd).multiplyScalar(-22 - plane.airspeed * 0.04).addScaledVector(up, 6);
camSmoothPos.lerp(p.clone().add(camOffset), 1 - Math.exp(-3.5 * dt));
camLook.copy(p).addScaledVector(fwd, 25).addScaledVector(up, 1);
camSmoothLook.lerp(camLook, 1 - Math.exp(-5 * dt));
camera.position.copy(camSmoothPos);
camera.up.lerp(up, 0.15).normalize();
camera.lookAt(camSmoothLook);
} else if (state.camMode === 1) {
// Cockpit
camera.position.copy(p).addScaledVector(up, 1.2).addScaledVector(fwd, 1.5);
camera.up.copy(up);
camera.lookAt(p.clone().addScaledVector(fwd, 50).addScaledVector(up, 1.0));
plane.mesh.visible = false;
} else {
// Orbit free via mouse residual
freeYaw -= state.mouseDX * 0.004;
freePitch = THREE.MathUtils.clamp(freePitch + state.mouseDY * 0.003, -1.2, 1.2);
const r = 35;
camera.position.set(
p.x + Math.sin(freeYaw) * Math.cos(freePitch) * r,
p.y + Math.sin(freePitch) * r + 8,
p.z + Math.cos(freeYaw) * Math.cos(freePitch) * r
);
camera.up.set(0, 1, 0);
camera.lookAt(p);
}
if (state.camMode !== 1) plane.mesh.visible = true;
// Keep sky with cam
if (state._sky) state._sky.position.copy(camera.position);
sun.position.set(camera.position.x + 400, 800, camera.position.z + 200);
sun.target.position.copy(camera.position);
sun.target.updateMatrixWorld();
// Fog / sky tint by altitude
const alt = plane.altitude;
const fogCol = new THREE.Color().setHSL(0.58, 0.35, THREE.MathUtils.clamp(0.55 + alt / 5000, 0.55, 0.85));
scene.fog.color.lerp(fogCol, 0.05);
scene.background.copy(scene.fog.color);
}
// ═══════════════════════════════════════════════════════════
// HUD update
// ═══════════════════════════════════════════════════════════
function pad3(n) {
n = ((Math.round(n) % 360) + 360) % 360;
return String(n).padStart(3, '0');
}
function updateHUD() {
const kts = plane.airspeed * 1.94384;
const altFt = plane.altitude * FT;
const vs = plane.velocity.y * FT * 60;
document.getElementById('spd-readout').textContent = String(Math.max(0, Math.round(kts)));
document.getElementById('alt-readout').textContent = String(Math.max(0, Math.round(altFt)));
document.getElementById('h-hdg').textContent = pad3(THREE.MathUtils.radToDeg(plane.heading));
document.getElementById('h-vs').textContent = (vs >= 0 ? '+' : '') + Math.round(vs);
document.getElementById('h-thr').textContent = Math.round(plane.throttleActual * 100) + '%';
document.getElementById('h-rpm').textContent = Math.round(plane.rpm * 100) + '% N1';
document.getElementById('h-g').textContent = plane.gLoad.toFixed(1);
document.getElementById('throttle-fill').style.height = (plane.throttleActual * 100) + '%';
// AoA bar: map -0.2..0.4 rad to 0..100%
const aoaPct = THREE.MathUtils.clamp((plane.aoa + 0.15) / 0.5, 0, 1) * 100;
document.getElementById('aoa-fill').style.bottom = aoaPct + '%';
document.getElementById('aoa-stall').style.bottom = THREE.MathUtils.clamp((plane.spec.stallAoA + 0.15) / 0.5, 0, 1) * 100 + '%';
// Tapes
const spdOff = -(kts / 20) * 28 + 110 - 14;
document.getElementById('spd-marks').style.transform = `translateY(${spdOff}px)`;
const altOff = -(altFt / 100) * 28 + 110 - 14;
document.getElementById('alt-marks').style.transform = `translateY(${altOff}px)`;
// Artificial horizon
const bankDeg = THREE.MathUtils.radToDeg(plane.bankAngle);
// pitchAngle < 0 ⇒ nose up (Three body axes); move horizon down
const pitchPx = -THREE.MathUtils.radToDeg(plane.pitchAngle) * 1.6;
const ball = document.getElementById('h-ball');
ball.setAttribute('transform', `rotate(${-bankDeg} 110 110) translate(0 ${pitchPx})`);
// Warnings
if (!plane.alive) {
document.getElementById('warn').textContent = 'AIRCRAFT LOST';
document.getElementById('warn').classList.add('show');
audio.stallWarn(false);
} else if (plane.stalling && !plane.onGround && plane.airspeed > 20) {
document.getElementById('warn').textContent = 'STALL STALL';
document.getElementById('warn').classList.add('show');
audio.stallWarn(true);
} else if (plane.gLoad > 7) {
document.getElementById('warn').textContent = 'OVER-G';
document.getElementById('warn').classList.add('show');
audio.stallWarn(false);
} else if (state.mission.status !== 'success' && state.mission.status !== 'fail') {
document.getElementById('warn').classList.remove('show');
audio.stallWarn(false);
}
// Minimap
const mm = document.getElementById('mm-canvas');
const ctx = mm.getContext('2d');
const W = mm.width, H = mm.height;
ctx.fillStyle = 'rgba(8,20,40,0.9)';
ctx.fillRect(0, 0, W, H);
const scale = 0.04;
ctx.save();
ctx.translate(W / 2, H / 2);
// runway
ctx.fillStyle = 'rgba(200,200,220,0.35)';
ctx.fillRect(-70 * scale * 5, -(-300) * scale - 2400 * scale * 0.5, 70 * scale * 10, 2400 * scale);
// player
ctx.rotate(-plane.heading);
ctx.fillStyle = '#6ed0ff';
ctx.beginPath();
ctx.moveTo(0, -8);
ctx.lineTo(6, 8);
ctx.lineTo(-6, 8);
ctx.closePath();
ctx.fill();
// drone
if (drone.alive) {
const dx = (drone.position.x - plane.position.x) * scale;
const dz = (drone.position.z - plane.position.z) * scale;
// unrotate for world relative… we rotated by heading so:
ctx.fillStyle = '#ff6644';
ctx.beginPath();
ctx.arc(dx, -dz, 5, 0, Math.PI * 2);
ctx.fill();
}
// landing ring
if (state.mode === 'takeoff') {
const lx = (0 - plane.position.x) * scale;
const lz = (1600 - plane.position.z) * scale;
ctx.strokeStyle = 'rgba(80,255,180,0.7)';
ctx.beginPath();
ctx.arc(lx, -lz, 10, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
ctx.strokeStyle = 'rgba(100,180,255,0.4)';
ctx.strokeRect(1, 1, W - 2, H - 2);
// Mission objectives progress
if (state.mode === 'takeoff' && state.mission.status === 'active') {
if (!plane.onGround && plane.altitude > 30) state.mission.airborne = true;
if (state.mission.airborne && plane.onGround && plane.airspeed < 8) {
const dz = Math.hypot(plane.position.x - 0, plane.position.z - 1600);
if (dz < 50) {
state.mission.status = 'success';
state.mission.message = 'LANDING COMPLETE';
showWarn('LANDING NAILED', 4);
document.getElementById('mb-obj').textContent = 'Mission complete — precision landing successful';
} else if (dz < 200) {
document.getElementById('mb-obj').textContent = `On ground — taxi into the zone (${Math.round(dz)} m)`;
} else {
document.getElementById('mb-obj').textContent = 'Landed off-zone — taxi or reposition for another approach';
}
} else if (state.mission.airborne) {
document.getElementById('mb-obj').textContent = `Airborne · Fly pattern · Land in glowing zone · Alt ${Math.round(altFt)} ft`;
} else {
document.getElementById('mb-obj').textContent = `On runway · Throttle: ${Math.round(plane.throttle * 100)}% · IAS ${Math.round(kts)} kts · Rotate ~120 kts`;
}
} else if (state.mode === 'dogfight' && state.mission.status === 'active') {
const d = plane.position.distanceTo(drone.position);
document.getElementById('mb-obj').textContent =
`Target ${Math.round(d)} m · HP ${Math.max(0, Math.round(drone.hp))} · Hits ${state.mission.hits}`;
}
}
// ═══════════════════════════════════════════════════════════
// Controls reading
// ═══════════════════════════════════════════════════════════
let thrTarget = 0;
function readControls(dt) {
const k = state.keys;
let pitch = 0, roll = 0, yaw = 0;
if (k['KeyW'] || k['KeyS']) {
pitch = (k['KeyW'] ? 1 : 0) + (k['KeyS'] ? -1 : 0);
}
if (k['KeyA'] || k['KeyD']) {
roll = (k['KeyA'] ? -1 : 0) + (k['KeyD'] ? 1 : 0);
}
if (k['KeyQ'] || k['KeyE']) {
yaw = (k['KeyQ'] ? -1 : 0) + (k['KeyE'] ? 1 : 0);
}
// Mouse as alternate stick when pointer locked (subtle)
if (state.pointerLocked && state.camMode !== 2) {
pitch += THREE.MathUtils.clamp(-state.mouseDY * 0.012, -1, 1);
roll += THREE.MathUtils.clamp(state.mouseDX * 0.012, -1, 1);
}
state.mouseDX = 0;
state.mouseDY = 0;
// Throttle
if (k['ShiftLeft'] || k['ShiftRight'] || k['ArrowUp']) thrTarget += 0.55 * dt;
if (k['ControlLeft'] || k['ControlRight'] || k['ArrowDown']) thrTarget -= 0.55 * dt;
// number keys 1-0 rough set
for (let i = 1; i <= 9; i++) {
if (k['Digit' + i]) thrTarget = i / 10;
}
if (k['Digit0']) thrTarget = 1;
thrTarget = THREE.MathUtils.clamp(thrTarget, 0, 1);
plane.setControls(pitch, roll, yaw, thrTarget);
// Fire
if (k['Space'] && state.mode === 'dogfight' && plane.alive) {
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(plane.orientation);
const origin = plane.position.clone().addScaledVector(fwd, 8);
origin.y += 0.5;
if (guns.fire(origin, fwd, 500 + plane.airspeed)) {
state.mission.shots++;
}
}
}
// ═══════════════════════════════════════════════════════════
// Main loop
// ═══════════════════════════════════════════════════════════
const clock = new THREE.Clock();
function tick() {
requestAnimationFrame(tick);
const dt = Math.min(clock.getDelta(), 0.05);
if (state.phase === 'flying') {
readControls(dt);
plane.update(dt);
if (state.mode === 'dogfight') {
drone.update(dt, plane.position);
guns.update(dt, drone);
} else {
guns.update(dt, null);
}
state.mission.time += dt;
if (!plane.alive && state.mission.status === 'active') {
state.mission.status = 'fail';
document.getElementById('mb-obj').textContent = 'Mission failed — press P for options';
}
updateCamera(dt);
updateHUD();
audio.update(
plane.rpm,
plane.throttleActual,
plane.airspeed,
plane.aoa,
plane.altitude
);
// Ambient light shift with altitude
const dens = plane.densityAt(plane.altitude);
sun.intensity = 0.7 + dens * 0.7;
} else if (state.phase === 'menu') {
// Orbit preview camera over runway
const t = performance.now() * 0.00015;
camera.position.set(Math.sin(t) * 180, 60 + Math.sin(t * 0.7) * 15, Math.cos(t) * 180 + 600);
camera.up.set(0, 1, 0);
camera.lookAt(0, 5, 800);
if (state._sky) state._sky.position.copy(camera.position);
}
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Boot
setTimeout(() => {
document.getElementById('loading').classList.add('hide');
tick();
}, 400);
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.5","provider":"openrouter","persona":"full","sessionId":"00745108-e2e3-4da2-815b-bc1d3ac6ae4f","tokensIn":2583583,"tokensOut":40485,"tokensTotal":2624068,"turns":37,"toolCalls":37,"failedToolCalls":0,"timestamp":"2026-07-17T17:59:05.416Z"} -->