Flight Sim
Grok 4.5 · legacy
◈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 49,646 bytes · SHA-256 5986008fa313
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Horizon Flight Simulator</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&family=Share+Tech+Mono&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
font-family: 'Share Tech Mono', monospace;
color: #e8f4ff;
user-select: none;
-webkit-user-select: none;
}
#canvas-container {
position: fixed;
inset: 0;
z-index: 0;
}
canvas { display: block; width: 100%; height: 100%; }
/* ── Title / Start screen ── */
#overlay {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse at 50% 30%, rgba(20,60,120,0.55) 0%, transparent 55%),
linear-gradient(180deg, #020810 0%, #0a1a30 50%, #041018 100%);
transition: opacity 0.8s ease, visibility 0.8s;
}
#overlay.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.logo {
font-family: 'Orbitron', sans-serif;
font-weight: 700;
font-size: clamp(2rem, 6vw, 3.6rem);
letter-spacing: 0.18em;
text-transform: uppercase;
background: linear-gradient(180deg, #ffffff 0%, #7ec8ff 45%, #3a8fd4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: none;
filter: drop-shadow(0 0 28px rgba(100,180,255,0.45));
margin-bottom: 0.4rem;
}
.tagline {
font-size: 0.95rem;
letter-spacing: 0.35em;
color: #6a9skonstante;
text-transform: uppercase;
margin-bottom: 2.5rem;
opacity: 0.85;
}
.panel {
background: rgba(8, 20, 40, 0.72);
border: 1px solid rgba(100, 170, 255, 0.25);
border-radius: 12px;
padding: 1.6rem 2rem;
max-width: 420px;
width: 90%;
backdrop-filter: blur(12px);
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 20px 50px rgba(0,0,0,0.5),
0 0 40px rgba(50,120,220,0.12);
}
.panel h2 {
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
letter-spacing: 0.2em;
color: #8ec8ff;
margin-bottom: 1rem;
text-align: center;
}
.controls-grid {
display: grid;
grid-template-columns: 110px 1fr;
gap: 0.55rem 1rem;
font-size: 0.82rem;
line-height: 1.4;
}
.key {
color: #fff;
background: rgba(60,120,200,0.25);
border: 1px solid rgba(120,180,255,0.35);
border-radius: 4px;
padding: 0.15rem 0.45rem;
font-size: 0.75rem;
white-space: nowrap;
text-align: center;
}
.desc { color: #9bb8d4; }
#start-btn {
margin-top: 1.8rem;
font-family: 'Orbitron', sans-serif;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #041018;
background: linear-gradient(180deg, #a8d8ff 0%, #4a9fe0 50%, #2a78c0 100%);
border: none;
border-radius: 6px;
padding: 0.9rem 2.8rem;
cursor: pointer;
box-shadow:
0 0 0 1px rgba(255,255,255,0.3) inset,
0 8px 24px rgba(60,140,255,0.4),
0 0 40px rgba(80,160,255,0.25);
transition: transform 0.15s, box-shadow 0.15s;
}
#start-btn:hover {
transform: translateY(-2px);
box-shadow:
0 0 0 1px rgba(255,255,255,0.4) inset,
0 12px 32px rgba(60,140,255,0.55),
0 0 50px rgba(80,160,255,0.4);
}
#start-btn:active { transform: translateY(0); }
.footer-note {
margin-top: 1.5rem;
font-size: 0.72rem;
color: #4a6a88;
letter-spacing: 0.08em;
}
/* ── HUD ── */
#hud {
position: fixed;
inset: 0;
z-index: 10;
pointer-events: none;
opacity: 0;
transition: opacity 0.6s;
}
#hud.active { opacity: 1; }
.hud-corner {
position: absolute;
width: 48px;
height: 48px;
border-color: rgba(120,200,255,0.45);
border-style: solid;
border-width: 0;
}
.hud-corner.tl { top: 18px; left: 18px; border-top-width: 2px; border-left-width: 2px; }
.hud-corner.tr { top: 18px; right: 18px; border-top-width: 2px; border-right-width: 2px; }
.hud-corner.bl { bottom: 18px; left: 18px; border-bottom-width: 2px; border-left-width: 2px; }
.hud-corner.br { bottom: 18px; right: 18px; border-bottom-width: 2px; border-right-width: 2px; }
#speed-tape, #alt-tape {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 72px;
height: 220px;
background: linear-gradient(90deg, rgba(0,15,30,0.55), rgba(0,20,40,0.35));
border: 1px solid rgba(100,180,255,0.28);
border-radius: 4px;
overflow: hidden;
backdrop-filter: blur(4px);
}
#speed-tape { left: 36px; }
#alt-tape { right: 36px; }
.tape-label {
position: absolute;
top: 6px;
left: 0;
right: 0;
text-align: center;
font-family: 'Orbitron', sans-serif;
font-size: 0.58rem;
letter-spacing: 0.15em;
color: rgba(140,200,255,0.7);
}
.tape-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: 'Orbitron', sans-serif;
font-size: 1.15rem;
font-weight: 600;
color: #fff;
text-shadow: 0 0 12px rgba(100,180,255,0.8);
background: rgba(0,30,60,0.7);
border: 1px solid rgba(120,200,255,0.5);
border-radius: 3px;
padding: 0.2rem 0.45rem;
min-width: 58px;
text-align: center;
z-index: 2;
}
.tape-unit {
display: block;
font-size: 0.5rem;
letter-spacing: 0.1em;
color: rgba(140,200,255,0.65);
font-weight: 400;
margin-top: 1px;
}
/* Artificial horizon indicators */
#crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120px;
height: 120px;
}
#crosshair svg { width: 100%; height: 100%; overflow: visible; }
#pitch-ladder {
position: absolute;
top: 50%;
left: 50%;
width: 180px;
height: 280px;
transform: translate(-50%, -50%);
overflow: hidden;
opacity: 0.7;
}
/* Top bar */
#top-bar {
position: absolute;
top: 22px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 2rem;
align-items: center;
background: rgba(0,15,35,0.55);
border: 1px solid rgba(100,180,255,0.25);
border-radius: 6px;
padding: 0.45rem 1.4rem;
backdrop-filter: blur(6px);
}
.instr {
text-align: center;
min-width: 70px;
}
.instr .lab {
font-family: 'Orbitron', sans-serif;
font-size: 0.52rem;
letter-spacing: 0.18em;
color: rgba(140,200,255,0.65);
}
.instr .val {
font-family: 'Orbitron', sans-serif;
font-size: 1.05rem;
font-weight: 600;
color: #e8f4ff;
text-shadow: 0 0 8px rgba(100,180,255,0.5);
}
.instr .unit {
font-size: 0.55rem;
color: rgba(140,200,255,0.5);
margin-left: 2px;
}
/* Bottom status */
#status-bar {
position: absolute;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1.5rem;
align-items: center;
background: rgba(0,15,35,0.55);
border: 1px solid rgba(100,180,255,0.22);
border-radius: 6px;
padding: 0.4rem 1.2rem;
backdrop-filter: blur(6px);
font-size: 0.75rem;
}
.status-item { color: #8ab4d4; }
.status-item span { color: #c8e4ff; }
.throttle-bar {
width: 90px;
height: 8px;
background: rgba(0,40,80,0.6);
border-radius: 3px;
overflow: hidden;
border: 1px solid rgba(100,180,255,0.25);
}
.throttle-fill {
height: 100%;
width: 40%;
background: linear-gradient(90deg, #2a8fd4, #7ec8ff);
border-radius: 2px;
transition: width 0.08s linear;
box-shadow: 0 0 8px rgba(100,180,255,0.5);
}
/* Compass */
#compass {
position: absolute;
bottom: 78px;
left: 50%;
transform: translateX(-50%);
width: 200px;
height: 28px;
background: rgba(0,15,35,0.55);
border: 1px solid rgba(100,180,255,0.25);
border-radius: 4px;
overflow: hidden;
backdrop-filter: blur(4px);
}
#compass-strip {
position: absolute;
top: 0;
left: 50%;
height: 100%;
display: flex;
align-items: center;
will-change: transform;
}
.comp-mark {
flex-shrink: 0;
width: 40px;
text-align: center;
font-size: 0.65rem;
color: rgba(160,210,255,0.7);
}
.comp-mark.cardinal {
color: #fff;
font-family: 'Orbitron', sans-serif;
font-weight: 600;
font-size: 0.7rem;
}
.comp-needle {
position: absolute;
top: 0;
left: 50%;
width: 2px;
height: 100%;
background: #ff6b4a;
transform: translateX(-50%);
box-shadow: 0 0 6px #ff6b4a;
z-index: 2;
}
/* Stall / warning */
#warn {
position: absolute;
top: 30%;
left: 50%;
transform: translateX(-50%);
font-family: 'Orbitron', sans-serif;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.3em;
color: #ff4455;
text-shadow: 0 0 16px rgba(255,40,60,0.8);
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
#warn.show { opacity: 1; animation: pulse 0.5s ease infinite alternate; }
@keyframes pulse { from { opacity: 0.6; } to { opacity: 1; } }
/* Mini map */
#minimap {
position: absolute;
bottom: 28px;
right: 28px;
width: 120px;
height: 120px;
border-radius: 50%;
border: 2px solid rgba(100,180,255,0.4);
background: rgba(5,25,50,0.65);
overflow: hidden;
backdrop-filter: blur(4px);
box-shadow: 0 0 20px rgba(0,0,0,0.4), inset 0 0 30px rgba(20,60,120,0.3);
}
#minimap canvas {
width: 100%;
height: 100%;
}
.mm-label {
position: absolute;
top: 6px;
left: 0; right: 0;
text-align: center;
font-family: 'Orbitron', sans-serif;
font-size: 0.48rem;
letter-spacing: 0.15em;
color: rgba(140,200,255,0.55);
}
/* Pause */
#pause-menu {
position: fixed;
inset: 0;
z-index: 30;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(2,8,18,0.72);
backdrop-filter: blur(8px);
}
#pause-menu.show { display: flex; }
#pause-menu h1 {
font-family: 'Orbitron', sans-serif;
font-size: 2rem;
letter-spacing: 0.3em;
color: #a0d0ff;
margin-bottom: 1.5rem;
}
#pause-menu p {
color: #6a90b0;
font-size: 0.85rem;
letter-spacing: 0.1em;
}
/* FPS counter */
#fps {
position: absolute;
top: 22px;
left: 80px;
font-size: 0.65rem;
color: rgba(140,200,255,0.4);
font-family: 'Orbitron', sans-serif;
}
/* Help toast */
#help {
position: absolute;
bottom: 110px;
left: 28px;
font-size: 0.7rem;
color: rgba(140,180,220,0.5);
line-height: 1.6;
max-width: 200px;
}
</style>
</head>
<body>
<div id="canvas-container"></div>
<!-- Start overlay -->
<div id="overlay">
<div class="logo">HORIZON</div>
<div class="tagline">Flight Simulator</div>
<div class="panel">
<h2>Flight Controls</h2>
<div class="controls-grid">
<span class="key">W / S</span><span class="desc">Pitch down / up</span>
<span class="key">A / D</span><span class="desc">Roll left / right</span>
<span class="key">Q / E</span><span class="desc">Yaw left / right</span>
<span class="key">↑ / ↓</span><span class="desc">Throttle up / down</span>
<span class="key">Shift</span><span class="desc">Boost thrust</span>
<span class="key">Space</span><span class="desc">Airbrake</span>
<span class="key">C</span><span class="desc">Cycle camera</span>
<span class="key">R</span><span class="desc">Reset aircraft</span>
<span class="key">Esc</span><span class="desc">Pause</span>
<span class="key">Mouse</span><span class="desc">Look (cockpit cam)</span>
</div>
</div>
<button id="start-btn">Take Flight</button>
<div class="footer-note">CLICK TO ENABLE POINTER LOCK · BEST IN FULLSCREEN</div>
</div>
<!-- HUD -->
<div id="hud">
<div class="hud-corner tl"></div>
<div class="hud-corner tr"></div>
<div class="hud-corner bl"></div>
<div class="hud-corner br"></div>
<div id="speed-tape">
<div class="tape-label">SPD</div>
<div class="tape-value" id="spd-val">0<span class="tape-unit">KTS</span></div>
</div>
<div id="alt-tape">
<div class="tape-label">ALT</div>
<div class="tape-value" id="alt-val">0<span class="tape-unit">FT</span></div>
</div>
<div id="crosshair">
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="20" y1="60" x2="48" y2="60" stroke="rgba(160,220,255,0.75)" stroke-width="1.5"/>
<line x1="72" y1="60" x2="100" y2="60" stroke="rgba(160,220,255,0.75)" stroke-width="1.5"/>
<line x1="60" y1="20" x2="60" y2="42" stroke="rgba(160,220,255,0.55)" stroke-width="1.2"/>
<circle cx="60" cy="60" r="5" stroke="rgba(160,220,255,0.8)" stroke-width="1.5" fill="none"/>
<line x1="60" y1="60" x2="60" y2="60" stroke="rgba(255,100,80,0.9)" stroke-width="2"/>
<!-- wing bars -->
<path d="M28 68 L48 60 L28 52" stroke="rgba(160,220,255,0.6)" stroke-width="1.2" fill="none"/>
<path d="M92 68 L72 60 L92 52" stroke="rgba(160,220,255,0.6)" stroke-width="1.2" fill="none"/>
</svg>
</div>
<div id="top-bar">
<div class="instr">
<div class="lab">HEADING</div>
<div class="val"><span id="hdg-val">360</span><span class="unit">°</span></div>
</div>
<div class="instr">
<div class="lab">VS</div>
<div class="val"><span id="vs-val">0</span><span class="unit">FPM</span></div>
</div>
<div class="instr">
<div class="lab">G</div>
<div class="val"><span id="g-val">1.0</span></div>
</div>
<div class="instr">
<div class="lab">PITCH</div>
<div class="val"><span id="pitch-val">0</span><span class="unit">°</span></div>
</div>
<div class="instr">
<div class="lab">ROLL</div>
<div class="val"><span id="roll-val">0</span><span class="unit">°</span></div>
</div>
</div>
<div id="compass">
<div class="comp-needle"></div>
<div id="compass-strip"></div>
</div>
<div id="status-bar">
<div class="status-item">THR <span id="thr-pct">40</span>%</div>
<div class="throttle-bar"><div class="throttle-fill" id="thr-fill"></div></div>
<div class="status-item">CAM <span id="cam-name">CHASE</span></div>
<div class="status-item">GEAR <span id="gear-ind" style="color:#5f5">UP</span></div>
</div>
<div id="minimap">
<div class="mm-label">MAP</div>
<canvas id="mm-canvas" width="120" height="120"></canvas>
</div>
<div id="warn">STALL</div>
<div id="fps">-- FPS</div>
<div id="help">
W/S pitch · A/D roll<br>
Q/E yaw · ↑↓ throttle<br>
C camera · R reset · Esc pause
</div>
</div>
<!-- Pause -->
<div id="pause-menu">
<h1>PAUSED</h1>
<p>PRESS ESC TO RESUME</p>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { Sky } from 'three/addons/objects/Sky.js';
// ═══════════════════════════════════════════════════════════
// HORIZON FLIGHT SIMULATOR
// ═══════════════════════════════════════════════════════════
const GRAVITY = 9.81;
const FT_PER_M = 3.28084;
const KTS_PER_MS = 1.94384;
const WORLD_SIZE = 16000;
const SEA_LEVEL = 0;
// ── State ────────────────────────────────────────────────
const keys = {};
let running = false;
let paused = false;
let pointerLocked = false;
// ── Renderer ─────────────────────────────────────────────
const container = document.getElementById('canvas-container');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.55;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
// ── Scene ────────────────────────────────────────────────
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x87b5d4, 0.00012);
// ── Cameras ──────────────────────────────────────────────
const chaseCam = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.5, 50000);
const cockpitCam = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 50000);
const freeCam = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.5, 50000);
const cams = [
{ cam: chaseCam, name: 'CHASE' },
{ cam: cockpitCam, name: 'COCKPIT' },
{ cam: freeCam, name: 'ORBIT' }
];
let camIdx = 0;
let activeCam = chaseCam;
// Orbit mouse look
let orbitYaw = 0;
let orbitPitch = 0.25;
let mouseDX = 0;
let mouseDY = 0;
// ── Sky ──────────────────────────────────────────────────
const sky = new Sky();
sky.scale.setScalar(45000);
scene.add(sky);
const sun = new THREE.Vector3();
const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 4;
skyUniforms['rayleigh'].value = 2.2;
skyUniforms['mieCoefficient'].value = 0.004;
skyUniforms['mieDirectionalG'].value = 0.72;
const sunPhi = THREE.MathUtils.degToRad(88.5);
const sunTheta = THREE.MathUtils.degToRad(160);
sun.setFromSphericalCoords(1, sunPhi, sunTheta);
skyUniforms['sunPosition'].value.copy(sun);
// ── Lights ───────────────────────────────────────────────
const hemi = new THREE.HemisphereLight(0xb1d0ff, 0x3a5040, 0.55);
scene.add(hemi);
const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.8);
sunLight.position.copy(sun).multiplyScalar(5000);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 100;
sunLight.shadow.camera.far = 12000;
sunLight.shadow.camera.left = -3000;
sunLight.shadow.camera.right = 3000;
sunLight.shadow.camera.top = 3000;
sunLight.shadow.camera.bottom = -3000;
sunLight.shadow.bias = -0.0002;
scene.add(sunLight);
const amb = new THREE.AmbientLight(0x406080, 0.25);
scene.add(amb);
// ── Terrain ──────────────────────────────────────────────
function createTerrain() {
const segs = 256;
const size = WORLD_SIZE;
const geo = new THREE.PlaneGeometry(size, size, segs, segs);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const colors = [];
const color = new THREE.Color();
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
// Multi-octave noise
let h = 0;
h += Math.sin(x * 0.0008) * Math.cos(z * 0.0007) * 180;
h += Math.sin(x * 0.0021 + 1.3) * Math.cos(z * 0.0019) * 90;
h += Math.sin(x * 0.005 + 2.1) * Math.cos(z * 0.0045 + 0.7) * 35;
h += Math.sin(x * 0.012) * Math.sin(z * 0.011) * 12;
// Ridges / mountains off to the sides
const dist = Math.sqrt(x * x + z * z);
const ridge = Math.max(0, dist - 2500) * 0.04;
h += ridge * (0.6 + 0.4 * Math.sin(x * 0.001) * Math.cos(z * 0.0012));
// Central valley / runway area flatter
const flat = Math.exp(-dist * dist / (1800 * 1800));
h *= (1 - flat * 0.92);
h = Math.max(h, -2);
pos.setY(i, h);
// Vertex colors by height
if (h < 5) {
color.setHSL(0.28, 0.35, 0.28 + Math.random() * 0.04); // grass
} else if (h < 80) {
color.setHSL(0.25, 0.4, 0.22 + h / 400);
} else if (h < 200) {
color.setHSL(0.1, 0.25, 0.3 + h / 800); // rocky
} else {
color.setHSL(0.08, 0.1, 0.45 + h / 1200); // snow peaks
}
// Water tint for near-zero
if (h < 1) color.setHSL(0.55, 0.35, 0.32);
colors.push(color.r, color.g, color.b);
}
geo.setAttribute('color', new THREE.Float32BufferAttribute(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 = true;
return mesh;
}
const terrain = createTerrain();
scene.add(terrain);
// Terrain height sampling (approximate via noisy function matching generator)
function terrainHeight(x, z) {
let h = 0;
h += Math.sin(x * 0.0008) * Math.cos(z * 0.0007) * 180;
h += Math.sin(x * 0.0021 + 1.3) * Math.cos(z * 0.0019) * 90;
h += Math.sin(x * 0.005 + 2.1) * Math.cos(z * 0.0045 + 0.7) * 35;
h += Math.sin(x * 0.012) * Math.sin(z * 0.011) * 12;
const dist = Math.sqrt(x * x + z * z);
const ridge = Math.max(0, dist - 2500) * 0.04;
h += ridge * (0.6 + 0.4 * Math.sin(x * 0.001) * Math.cos(z * 0.0012));
const flat = Math.exp(-dist * dist / (1800 * 1800));
h *= (1 - flat * 0.92);
return Math.max(h, -2);
}
// ── Water ────────────────────────────────────────────────
const waterGeo = new THREE.PlaneGeometry(WORLD_SIZE * 1.2, WORLD_SIZE * 1.2, 1, 1);
waterGeo.rotateX(-Math.PI / 2);
const waterMat = new THREE.MeshStandardMaterial({
color: 0x1a4a6e,
roughness: 0.15,
metalness: 0.6,
transparent: true,
opacity: 0.75
});
const water = new THREE.Mesh(waterGeo, waterMat);
water.position.y = -1.5;
water.receiveShadow = true;
scene.add(water);
// ── Runway ───────────────────────────────────────────────
function createRunway() {
const g = new THREE.Group();
// Main strip
const strip = new THREE.Mesh(
new THREE.BoxGeometry(40, 0.4, 1400),
new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.85 })
);
strip.position.y = 0.5;
strip.receiveShadow = true;
g.add(strip);
// Centerline dashes
for (let i = -650; i <= 650; i += 40) {
const dash = new THREE.Mesh(
new THREE.BoxGeometry(1.2, 0.42, 18),
new THREE.MeshStandardMaterial({ color: 0xf0f0e0, roughness: 0.9 })
);
dash.position.set(0, 0.55, i);
g.add(dash);
}
// Edge lights
const lightMat = new THREE.MeshStandardMaterial({
color: 0xffffff, emissive: 0xffaa40, emissiveIntensity: 1.2, roughness: 0.4
});
for (let i = -680; i <= 680; i += 30) {
for (const side of [-18, 18]) {
const lg = new THREE.Mesh(new THREE.SphereGeometry(0.6, 6, 6), lightMat);
lg.position.set(side, 0.9, i);
g.add(lg);
}
}
// Threshold marks
for (const z of [-680, 680]) {
for (let x = -14; x <= 14; x += 4) {
const m = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 0.42, 30),
new THREE.MeshStandardMaterial({ color: 0xf0f0e0 })
);
m.position.set(x, 0.55, z);
g.add(m);
}
}
// Apron
const apron = new THREE.Mesh(
new THREE.BoxGeometry(120, 0.3, 100),
new THREE.MeshStandardMaterial({ color: 0x333338, roughness: 0.9 })
);
apron.position.set(80, 0.4, 0);
g.add(apron);
// Tower
const tower = new THREE.Mesh(
new THREE.BoxGeometry(10, 40, 10),
new THREE.MeshStandardMaterial({ color: 0xd0d0c8, roughness: 0.7 })
);
tower.position.set(100, 20, 40);
tower.castShadow = true;
g.add(tower);
const towerTop = new THREE.Mesh(
new THREE.CylinderGeometry(8, 7, 8, 8),
new THREE.MeshStandardMaterial({ color: 0x2266aa, roughness: 0.4, metalness: 0.3, transparent: true, opacity: 0.85 })
);
towerTop.position.set(100, 44, 40);
g.add(towerTop);
// Hangar
const hangar = new THREE.Mesh(
new THREE.BoxGeometry(50, 16, 40),
new THREE.MeshStandardMaterial({ color: 0x6a7080, roughness: 0.75 })
);
hangar.position.set(90, 8, -60);
hangar.castShadow = true;
g.add(hangar);
return g;
}
scene.add(createRunway());
// ── Clouds ───────────────────────────────────────────────
function createClouds() {
const group = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 1,
transparent: true,
opacity: 0.78,
depthWrite: false
});
for (let i = 0; i < 60; i++) {
const cg = new THREE.Group();
const n = 3 + Math.floor(Math.random() * 5);
for (let j = 0; j < n; j++) {
const s = 40 + Math.random() * 90;
const puff = new THREE.Mesh(new THREE.SphereGeometry(s, 7, 7), mat);
puff.position.set(
(Math.random() - 0.5) * 120,
(Math.random() - 0.5) * 30,
(Math.random() - 0.5) * 80
);
puff.scale.y = 0.45 + Math.random() * 0.3;
cg.add(puff);
}
cg.position.set(
(Math.random() - 0.5) * WORLD_SIZE * 0.85,
350 + Math.random() * 600,
(Math.random() - 0.5) * WORLD_SIZE * 0.85
);
group.add(cg);
}
return group;
}
const clouds = createClouds();
scene.add(clouds);
// ── Distant mountains (impostors) ────────────────────────
function createDistantPeaks() {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color: 0x4a5a6a,
roughness: 1,
flatShading: true
});
for (let i = 0; i < 24; i++) {
const ang = (i / 24) * Math.PI * 2;
const dist = 7000 + Math.random() * 2000;
const h = 400 + Math.random() * 800;
const r = 300 + Math.random() * 500;
const peak = new THREE.Mesh(new THREE.ConeGeometry(r, h, 5), mat);
peak.position.set(Math.cos(ang) * dist, h * 0.3, Math.sin(ang) * dist);
g.add(peak);
}
return g;
}
scene.add(createDistantPeaks());
// ── Trees (instanced) ────────────────────────────────────
function createTrees() {
const trunkGeo = new THREE.CylinderGeometry(0.4, 0.7, 6, 5);
const leafGeo = new THREE.ConeGeometry(3.5, 10, 6);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a3020, roughness: 1 });
const leafMat = new THREE.MeshStandardMaterial({ color: 0x2a6030, roughness: 0.9 });
const count = 400;
const trunks = new THREE.InstancedMesh(trunkGeo, trunkMat, count);
const leaves = new THREE.InstancedMesh(leafGeo, leafMat, count);
const dummy = new THREE.Object3D();
let placed = 0;
let attempts = 0;
while (placed < count && attempts < count * 20) {
attempts++;
const x = (Math.random() - 0.5) * 5000;
const z = (Math.random() - 0.5) * 5000;
// Keep clear of runway
if (Math.abs(x) < 80 && Math.abs(z) < 800) continue;
const y = terrainHeight(x, z);
if (y < 2 || y > 120) continue;
const scale = 0.7 + Math.random() * 1.2;
dummy.position.set(x, y + 3 * scale, z);
dummy.scale.setScalar(scale);
dummy.rotation.y = Math.random() * Math.PI;
dummy.updateMatrix();
trunks.setMatrixAt(placed, dummy.matrix);
dummy.position.set(x, y + 9 * scale, z);
dummy.updateMatrix();
leaves.setMatrixAt(placed, dummy.matrix);
placed++;
}
trunks.castShadow = true;
leaves.castShadow = true;
scene.add(trunks);
scene.add(leaves);
}
createTrees();
// ── Aircraft ─────────────────────────────────────────────
function createAircraft() {
const ac = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: 0xe8ecf0, roughness: 0.35, metalness: 0.55
});
const accentMat = new THREE.MeshStandardMaterial({
color: 0x1a6aaf, roughness: 0.4, metalness: 0.5
});
const darkMat = new THREE.MeshStandardMaterial({
color: 0x1a1a22, roughness: 0.6, metalness: 0.4
});
const glassMat = new THREE.MeshStandardMaterial({
color: 0x88ccee, roughness: 0.05, metalness: 0.9, transparent: true, opacity: 0.55
});
const glowMat = new THREE.MeshStandardMaterial({
color: 0xff4400, emissive: 0xff2200, emissiveIntensity: 1.5, roughness: 0.3
});
// Fuselage
const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(1.1, 7.5, 6, 12), bodyMat);
fuse.rotation.z = Math.PI / 2;
fuse.castShadow = true;
ac.add(fuse);
// Nose cone
const nose = new THREE.Mesh(new THREE.ConeGeometry(1.05, 2.8, 10), bodyMat);
nose.rotation.z = -Math.PI / 2;
nose.position.z = -5.5;
nose.castShadow = true;
ac.add(nose);
// Cockpit canopy
const canopy = new THREE.Mesh(new THREE.SphereGeometry(1.15, 10, 8, 0, Math.PI * 2, 0, Math.PI * 0.5), glassMat);
canopy.position.set(0, 0.7, -1.5);
canopy.scale.set(0.9, 0.85, 1.6);
ac.add(canopy);
// Main wings
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(7, -0.8);
wingShape.lineTo(7.2, -1.5);
wingShape.lineTo(0, -1.8);
wingShape.lineTo(0, 0);
const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth: 0.18, bevelEnabled: false });
const wingL = new THREE.Mesh(wingGeo, accentMat);
wingL.rotation.x = Math.PI / 2;
wingL.rotation.z = Math.PI / 2;
wingL.position.set(0, -0.15, 0.3);
wingL.castShadow = true;
ac.add(wingL);
const wingR = wingL.clone();
wingR.scale.z = -1;
ac.add(wingR);
// Wingtips
for (const s of [-1, 1]) {
const tip = new THREE.Mesh(
new THREE.BoxGeometry(0.12, 1.2, 0.6),
accentMat
);
tip.position.set(s * 7.1, 0.4, 1.0);
ac.add(tip);
}
// Horizontal stabilizer
const hstab = new THREE.Mesh(new THREE.BoxGeometry(4.5, 0.12, 1.3), bodyMat);
hstab.position.set(0, 0.3, 4.2);
hstab.castShadow = true;
ac.add(hstab);
// Vertical stabilizer
const vstab = new THREE.Mesh(new THREE.BoxGeometry(0.12, 2.4, 1.6), accentMat);
vstab.position.set(0, 1.4, 4.0);
vstab.castShadow = true;
ac.add(vstab);
// Engine / intake
const intake = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.85, 2.5, 10), darkMat);
intake.rotation.x = Math.PI / 2;
intake.position.set(0, -0.5, 2.5);
ac.add(intake);
// Exhaust glow
const exhaust = new THREE.Mesh(new THREE.SphereGeometry(0.55, 8, 8), glowMat);
exhaust.position.set(0, -0.5, 4.0);
exhaust.scale.z = 1.6;
ac.add(exhaust);
ac.userData.exhaust = exhaust;
// Afterburner light
const abLight = new THREE.PointLight(0xff5500, 0, 25);
abLight.position.set(0, -0.5, 5);
ac.add(abLight);
ac.userData.abLight = abLight;
// Landing gear (visual, retracted look as small stubs)
for (const [x, z] of [[0, -2.5], [-1.5, 1], [1.5, 1]]) {
const gear = new THREE.Mesh(
new THREE.CylinderGeometry(0.12, 0.12, 0.8, 6),
darkMat
);
gear.position.set(x, -1.3, z);
ac.add(gear);
const wheel = new THREE.Mesh(
new THREE.TorusGeometry(0.35, 0.12, 6, 10),
darkMat
);
wheel.rotation.y = Math.PI / 2;
wheel.position.set(x, -1.75, z);
ac.add(wheel);
}
// Stripes
const stripe = new THREE.Mesh(
new THREE.BoxGeometry(0.05, 0.08, 6),
new THREE.MeshStandardMaterial({ color: 0xffcc00, roughness: 0.5, metalness: 0.3 })
);
stripe.position.set(1.05, 0.2, -0.5);
ac.add(stripe);
const stripe2 = stripe.clone();
stripe2.position.x = -1.05;
ac.add(stripe2);
// Scale up for visibility
ac.scale.setScalar(1.0);
ac.rotation.order = 'YXZ';
return ac;
}
const aircraft = createAircraft();
scene.add(aircraft);
// ── Flight model ─────────────────────────────────────────
const plane = {
// Position / velocity in world space
pos: new THREE.Vector3(0, 80, 200),
vel: new THREE.Vector3(0, 0, -60), // start with forward speed
// Orientation: heading (yaw), pitch, roll in radians
yaw: Math.PI, // facing -Z initially? wait: vel is -Z so heading = PI (facing -Z is south if +Z is north)
pitch: 0,
roll: 0,
// Control state
throttle: 0.55,
// Derived
airspeed: 60,
aoa: 0,
gForce: 1,
onGround: false,
stalling: false,
crashed: false
};
// Forward, up, right axes
const forward = new THREE.Vector3();
const up = new THREE.Vector3();
const right = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _e = new THREE.Euler();
const _v = new THREE.Vector3();
function updateAxes() {
_e.set(plane.pitch, plane.yaw, plane.roll, 'YXZ');
_q.setFromEuler(_e);
forward.set(0, 0, -1).applyQuaternion(_q);
up.set(0, 1, 0).applyQuaternion(_q);
right.set(1, 0, 0).applyQuaternion(_q);
}
function resetAircraft() {
plane.pos.set(0, 80, 200);
plane.vel.set(0, 0, -70);
plane.yaw = 0;
plane.pitch = 0.05;
plane.roll = 0;
plane.throttle = 0.55;
plane.airspeed = 70;
plane.aoa = 0;
plane.gForce = 1;
plane.onGround = false;
plane.stalling = false;
plane.crashed = false;
updateAxes();
aircraft.position.copy(plane.pos);
aircraft.quaternion.copy(_q);
}
resetAircraft();
// ── Physics step ─────────────────────────────────────────
function physics(dt) {
if (plane.crashed) return;
// ── Controls ──
const pitchRate = 1.1;
const rollRate = 1.8;
const yawRate = 0.7;
let pitchIn = 0, rollIn = 0, yawIn = 0;
if (keys['w'] || keys['ArrowUp'] && keys['ShiftLeft']) { /* handled below */ }
// Pitch: W = nose down (negative pitch rate for flight-sim convention in our euler)
// In aviation: pull back (S) = pitch up
if (keys['w']) pitchIn -= 1;
if (keys['s']) pitchIn += 1;
if (keys['a']) rollIn -= 1;
if (keys['d']) rollIn += 1;
if (keys['q']) yawIn += 1;
if (keys['e']) yawIn -= 1;
// Throttle
if (keys['ArrowUp']) plane.throttle = Math.min(1, plane.throttle + 0.4 * dt);
if (keys['ArrowDown']) plane.throttle = Math.max(0, plane.throttle - 0.4 * dt);
// Alternate throttle keys
if (keys['='] || keys['+']) plane.throttle = Math.min(1, plane.throttle + 0.4 * dt);
if (keys['-'] || keys['_']) plane.throttle = Math.max(0, plane.throttle - 0.4 * dt);
// Mouse pitch/roll assist when locked (cockpit feel)
if (pointerLocked && camIdx === 1) {
pitchIn += mouseDY * 0.4;
rollIn += mouseDX * 0.5;
}
mouseDX *= 0.85;
mouseDY *= 0.85;
// Recenter tendency for roll when no input (stability)
if (Math.abs(rollIn) < 0.01) {
plane.roll *= (1 - 0.4 * dt);
}
plane.pitch += pitchIn * pitchRate * dt;
plane.roll += rollIn * rollRate * dt;
plane.yaw += yawIn * yawRate * dt;
// Clamp pitch
plane.pitch = THREE.MathUtils.clamp(plane.pitch, -1.3, 1.3);
// Wrap roll
if (plane.roll > Math.PI) plane.roll -= Math.PI * 2;
if (plane.roll < -Math.PI) plane.roll += Math.PI * 2;
updateAxes();
// ── Forces ──
plane.airspeed = plane.vel.length();
const speed = Math.max(plane.airspeed, 0.1);
// Angle of attack: angle between velocity and aircraft nose
const velDir = _v.copy(plane.vel).normalize();
const aoa = Math.asin(THREE.MathUtils.clamp(velDir.dot(up), -1, 1));
plane.aoa = aoa;
// Thrust
const maxThrust = 55;
let thrustMag = plane.throttle * maxThrust;
if (keys['ShiftLeft'] || keys['ShiftRight']) thrustMag *= 1.55;
// Airbrake
if (keys[' ']) thrustMag *= 0.2;
const thrust = forward.clone().multiplyScalar(thrustMag);
// Lift — peaks around ~10° AoA, drops after stall ~18°
const aoaDeg = THREE.MathUtils.radToDeg(aoa);
let liftCoeff = 0;
const stallAngle = 16;
if (Math.abs(aoaDeg) < stallAngle) {
liftCoeff = 0.12 * aoaDeg + 0.25; // base + linear lift
} else {
// Post-stall: drop
liftCoeff = (0.12 * stallAngle + 0.25) * Math.max(0, 1 - (Math.abs(aoaDeg) - stallAngle) / 20);
liftCoeff *= Math.sign(aoaDeg || 1);
}
plane.stalling = Math.abs(aoaDeg) > stallAngle && speed > 15;
const rho = Math.exp(-plane.pos.y / 9000) * 1.2; // air density falloff
const wingArea = 18;
const liftMag = 0.5 * rho * speed * speed * wingArea * Math.abs(liftCoeff) * 0.018;
const liftDir = up.clone();
if (liftCoeff < 0) liftDir.negate();
// Also reduce lift efficiency when inverted / high bank
const lift = liftDir.multiplyScalar(liftMag);
// Drag
const dragCoeff = 0.02 + Math.abs(liftCoeff) * 0.015 + (keys[' '] ? 0.08 : 0);
const dragMag = 0.5 * rho * speed * speed * dragCoeff * 2.5;
const drag = velDir.clone().multiplyScalar(-dragMag);
// Gravity
const grav = new THREE.Vector3(0, -GRAVITY, 0);
// Net acceleration
const mass = 12; // simplified mass units
const accel = new THREE.Vector3()
.add(thrust)
.add(lift)
.add(drag)
.divideScalar(mass)
.add(grav);
// G-force estimate (along body up)
const bodyAccel = accel.clone().sub(grav);
plane.gForce = 1 + bodyAccel.dot(up) / GRAVITY;
plane.gForce = THREE.MathUtils.clamp(plane.gForce, -3, 9);
// Integrate velocity & position
plane.vel.addScaledVector(accel, dt);
// Ground interaction
const groundY = terrainHeight(plane.pos.x, plane.pos.z) + 2.2;
const nextY = plane.pos.y + plane.vel.y * dt;
if (nextY < groundY) {
plane.pos.y = groundY;
// Hard impact?
if (plane.vel.y < -18 || Math.abs(plane.roll) > 0.9 || Math.abs(plane.pitch) > 0.7) {
// Crash
plane.crashed = true;
plane.vel.set(0, 0, 0);
} else {
// Soft landing / taxi
plane.vel.y = Math.max(0, plane.vel.y);
// Friction on ground
plane.vel.x *= (1 - 1.5 * dt);
plane.vel.z *= (1 - 1.5 * dt);
plane.onGround = true;
// Level gently
plane.pitch *= (1 - 3 * dt);
plane.roll *= (1 - 3 * dt);
// If throttle high enough, takeoff
if (plane.airspeed > 45 && plane.throttle > 0.6) {
plane.vel.y += 5 * dt;
}
}
} else {
plane.pos.y = nextY;
plane.onGround = false;
}
plane.pos.x += plane.vel.x * dt;
plane.pos.z += plane.vel.z * dt;
// World bounds soft clamp
const bound = WORLD_SIZE * 0.48;
plane.pos.x = THREE.MathUtils.clamp(plane.pos.x, -bound, bound);
plane.pos.z = THREE.MathUtils.clamp(plane.pos.z, -bound, bound);
// Ceiling
if (plane.pos.y > 3500) {
plane.pos.y = 3500;
plane.vel.y = Math.min(0, plane.vel.y);
}
// Apply to mesh
aircraft.position.copy(plane.pos);
_e.set(plane.pitch, plane.yaw, plane.roll, 'YXZ');
aircraft.quaternion.setFromEuler(_e);
// Exhaust visual
const thrFlash = plane.throttle * (keys['ShiftLeft'] || keys['ShiftRight'] ? 2.2 : 1);
aircraft.userData.exhaust.material.emissiveIntensity = 0.6 + thrFlash * 1.8;
aircraft.userData.exhaust.scale.set(0.7 + thrFlash * 0.3, 0.7 + thrFlash * 0.3, 1 + thrFlash * 1.5);
aircraft.userData.abLight.intensity = thrFlash * 4;
}
// ── Camera ───────────────────────────────────────────────
const chaseOffset = new THREE.Vector3(0, 4, 14);
const _camPos = new THREE.Vector3();
const _lookAt = new THREE.Vector3();
function updateCamera(dt) {
updateAxes();
if (camIdx === 0) {
// Chase — smooth follow behind aircraft
const desired = chaseOffset.clone().applyQuaternion(aircraft.quaternion).add(plane.pos);
// Lift chase cam roughly with pitch so we stay above
desired.y = Math.max(desired.y, plane.pos.y + 2);
chaseCam.position.lerp(desired, 1 - Math.pow(0.001, dt));
_lookAt.copy(plane.pos).addScaledVector(forward, 20).addScaledVector(up, 1);
chaseCam.lookAt(_lookAt);
// Keep upright-ish
chaseCam.up.lerp(new THREE.Vector3(0, 1, 0), 0.05);
} else if (camIdx === 1) {
// Cockpit
const cp = new THREE.Vector3(0, 0.9, -1.2).applyQuaternion(aircraft.quaternion).add(plane.pos);
cockpitCam.position.copy(cp);
// Free look offset
const lookQ = aircraft.quaternion.clone();
const lookE = new THREE.Euler().setFromQuaternion(lookQ, 'YXZ');
lookE.y -= orbitYaw * 0.5;
lookE.x -= orbitPitch * 0.4;
lookQ.setFromEuler(lookE);
const lookDir = new THREE.Vector3(0, 0, -1).applyQuaternion(lookQ);
cockpitCam.lookAt(cp.clone().add(lookDir));
cockpitCam.up.copy(up);
} else {
// Orbit
const dist = 22;
const ox = Math.sin(orbitYaw) * Math.cos(orbitPitch) * dist;
const oy = Math.sin(orbitPitch) * dist + 3;
const oz = Math.cos(orbitYaw) * Math.cos(orbitPitch) * dist;
freeCam.position.copy(plane.pos).add(new THREE.Vector3(ox, oy, oz));
freeCam.lookAt(plane.pos);
freeCam.up.set(0, 1, 0);
}
activeCam = cams[camIdx].cam;
}
// ── HUD ──────────────────────────────────────────────────
const spdEl = document.getElementById('spd-val');
const altEl = document.getElementById('alt-val');
const hdgEl = document.getElementById('hdg-val');
const vsEl = document.getElementById('vs-val');
const gEl = document.getElementById('g-val');
const pitchEl = document.getElementById('pitch-val');
const rollEl = document.getElementById('roll-val');
const thrPct = document.getElementById('thr-pct');
const thrFill = document.getElementById('thr-fill');
const camName = document.getElementById('cam-name');
const warnEl = document.getElementById('warn');
const fpsEl = document.getElementById('fps');
const compassStrip = document.getElementById('compass-strip');
const mmCanvas = document.getElementById('mm-canvas');
const mmCtx = mmCanvas.getContext('2d');
// Build compass strip (repeat headings so we can scroll)
(function buildCompass() {
const dirs = ['N', '3', '6', 'E', '12', '15', 'S', '21', '24', 'W', '30', '33'];
// 3 full revolutions for seamless scroll
let html = '';
for (let r = 0; r < 5; r++) {
for (let i = 0; i < 36; i++) {
const deg = i * 10;
const label = deg % 90 === 0
? ['N', 'E', 'S', 'W'][deg / 90]
: String(deg / 10);
const cls = deg % 90 === 0 ? 'comp-mark cardinal' : 'comp-mark';
html += `<div class="${cls}">${label}</div>`;
}
}
compassStrip.innerHTML = html;
})();
function updateHUD() {
const kts = Math.round(plane.airspeed * KTS_PER_MS);
const altFt = Math.round(Math.max(0, plane.pos.y) * FT_PER_M);
let hdg = Math.round(((THREE.MathUtils.radToDeg(-plane.yaw) % 360) + 360) % 360);
if (hdg === 0) hdg = 360;
const vs = Math.round(plane.vel.y * FT_PER_M * 60);
const pitchDeg = Math.round(THREE.MathUtils.radToDeg(plane.pitch));
const rollDeg = Math.round(THREE.MathUtils.radToDeg(plane.roll));
spdEl.innerHTML = `${kts}<span class="tape-unit">KTS</span>`;
altEl.innerHTML = `${altFt}<span class="tape-unit">FT</span>`;
hdgEl.textContent = String(hdg).padStart(3, '0');
vsEl.textContent = (vs >= 0 ? '+' : '') + vs;
gEl.textContent = plane.gForce.toFixed(1);
pitchEl.textContent = pitchDeg;
rollEl.textContent = rollDeg;
thrPct.textContent = Math.round(plane.throttle * 100);
thrFill.style.width = (plane.throttle * 100) + '%';
camName.textContent = cams[camIdx].name;
// Compass scroll: each mark is 40px, 10° per mark
// Center on current heading
const offset = hdg / 10 * 40;
// Strip starts with multiple revolutions; center of rev 2
const base = 36 * 40 * 2;
compassStrip.style.transform = `translate(calc(-50% + ${base - offset}px), 0)`;
// Warnings
if (plane.crashed) {
warnEl.textContent = 'CRASHED — PRESS R';
warnEl.classList.add('show');
} else if (plane.stalling) {
warnEl.textContent = 'STALL';
warnEl.classList.add('show');
} else if (plane.airspeed < 30 && !plane.onGround) {
warnEl.textContent = 'LOW SPEED';
warnEl.classList.add('show');
} else if (plane.pos.y - terrainHeight(plane.pos.x, plane.pos.z) < 25 && plane.vel.y < -8) {
warnEl.textContent = 'TERRAIN';
warnEl.classList.add('show');
} else {
warnEl.classList.remove('show');
}
// Minimap
drawMinimap(hdg);
}
function drawMinimap(hdg) {
const ctx = mmCtx;
const W = 120, H = 120, cx = 60, cy = 60;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = 'rgba(10,40,70,0.9)';
ctx.beginPath();
ctx.arc(cx, cy, 58, 0, Math.PI * 2);
ctx.fill();
// Scale: minimap shows ~4000m radius
const scale = 58 / 4000;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-THREE.MathUtils.degToRad(hdg) + Math.PI); // north up relative to aircraft heading
// Runway
ctx.save();
ctx.translate(-plane.pos.x * scale, -plane.pos.z * scale);
ctx.fillStyle = 'rgba(180,180,190,0.7)';
ctx.fillRect(-2, -35, 4, 70);
// Tower marker
ctx.fillStyle = 'rgba(100,180,255,0.8)';
ctx.beginPath();
ctx.arc(5, 2, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Grid rings
ctx.strokeStyle = 'rgba(100,170,255,0.15)';
ctx.lineWidth = 1;
for (const r of [20, 40, 55]) {
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
// Aircraft chevron (always center pointing up)
ctx.fillStyle = '#ff6644';
ctx.beginPath();
ctx.moveTo(cx, cy - 7);
ctx.lineTo(cx - 5, cy + 6);
ctx.lineTo(cx, cy + 2);
ctx.lineTo(cx + 5, cy + 6);
ctx.closePath();
ctx.fill();
// Border
ctx.strokeStyle = 'rgba(120,190,255,0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, 58, 0, Math.PI * 2);
ctx.stroke();
}
// ── Input ────────────────────────────────────────────────
window.addEventListener('keydown', (e) => {
keys[e.key] = true;
keys[e.code] = true;
if (e.key === 'Escape' && running) {
paused = !paused;
document.getElementById('pause-menu').classList.toggle('show', paused);
if (paused && document.pointerLockElement) document.exitPointerLock();
}
if ((e.key === 'c' || e.key === 'C') && running && !paused) {
camIdx = (camIdx + 1) % cams.length;
orbitYaw = 0;
orbitPitch = camIdx === 2 ? 0.3 : 0;
}
if ((e.key === 'r' || e.key === 'R') && running) {
resetAircraft();
}
// Prevent arrow keys scrolling
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
keys[e.key] = false;
keys[e.code] = false;
});
document.addEventListener('mousemove', (e) => {
if (!pointerLocked) return;
mouseDX += e.movementX * 0.002;
mouseDY += e.movementY * 0.002;
orbitYaw += e.movementX * 0.005;
orbitPitch = THREE.MathUtils.clamp(orbitPitch + e.movementY * 0.004, -1.2, 1.2);
});
renderer.domElement.addEventListener('click', () => {
if (running && !paused) renderer.domElement.requestPointerLock();
});
document.addEventListener('pointerlockchange', () => {
pointerLocked = document.pointerLockElement === renderer.domElement;
});
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
for (const c of cams) {
c.cam.aspect = w / h;
c.cam.updateProjectionMatrix();
}
renderer.setSize(w, h);
});
// ── Start ────────────────────────────────────────────────
document.getElementById('start-btn').addEventListener('click', () => {
document.getElementById('overlay').classList.add('hidden');
document.getElementById('hud').classList.add('active');
running = true;
paused = false;
resetAircraft();
renderer.domElement.requestPointerLock();
});
// ── Main loop ────────────────────────────────────────────
let last = performance.now();
let frames = 0;
let fpsT = 0;
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
frames++;
fpsT += dt;
if (fpsT >= 0.5) {
fpsEl.textContent = Math.round(frames / fpsT) + ' FPS';
frames = 0;
fpsT = 0;
}
// Gentle cloud drift
clouds.rotation.y += dt * 0.001;
if (running && !paused) {
physics(dt);
updateCamera(dt);
updateHUD();
} else if (!running) {
// Idle showcase orbit on title screen
const t = now * 0.00015;
chaseCam.position.set(Math.sin(t) * 18, 6 + Math.sin(t * 0.7) * 2, Math.cos(t) * 18 + 200);
chaseCam.position.y += 70;
chaseCam.lookAt(plane.pos);
aircraft.position.copy(plane.pos);
aircraft.rotation.y = t * 0.5;
}
// Shadow camera follows plane
sunLight.position.copy(plane.pos).add(sun.clone().multiplyScalar(2000));
sunLight.target.position.copy(plane.pos);
sunLight.target.updateMatrixWorld();
renderer.render(scene, running ? activeCam : chaseCam);
}
requestAnimationFrame(frame);
</script>
</body>
</html>