Flight Simulator
thinkingcap qwen3.6 27b · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 78,493 bytes · SHA-256 279f0981cf68
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Apex Aero - Flight Simulator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; color: #fff; }
#menu { position: fixed; inset: 0; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 40%, #0d1b2a 100%); }
#menu h1 { font-size: 4em; letter-spacing: 0.3em; text-shadow: 0 0 30px rgba(0,150,255,0.5), 0 0 60px rgba(0,150,255,0.2); margin-bottom: -10px; }
.subtitle { font-size: 1.2em; color: #88aacc; letter-spacing: 0.5em; margin-bottom: 40px; }
#aircraft-select { display: flex; gap: 20px; margin-bottom: 30px; }
.aircraft-card { width: 200px; padding: 20px; border: 2px solid #334; border-radius: 12px; cursor: pointer; transition: all 0.3s; background: rgba(255,255,255,0.03); text-align: center; }
.aircraft-card:hover { border-color: #4af; transform: translateY(-5px); box-shadow: 0 10px 30px rgba(0,100,255,0.2); }
.aircraft-card.selected { border-color: #4af; background: rgba(0,100,255,0.1); box-shadow: 0 0 20px rgba(0,100,255,0.3); }
.aircraft-card h3 { color: #4af; margin-bottom: 8px; font-size: 1.1em; }
.aircraft-card p { font-size: 0.75em; color: #99aabb; line-height: 1.6; }
.stat-bar { height: 4px; background: #223; border-radius: 2px; margin-top: 4px; overflow: hidden; }
.stat-fill { height: 100%; background: linear-gradient(90deg, #4af, #4f8); border-radius: 2px; transition: width 0.5s; }
#scenario-select { display: flex; gap: 20px; margin-bottom: 30px; }
.scenario-card { padding: 15px 30px; border: 2px solid #334; border-radius: 8px; cursor: pointer; transition: all 0.3s; background: rgba(255,255,255,0.03); }
.scenario-card:hover { border-color: #fa4; }
.scenario-card.selected { border-color: #fa4; background: rgba(255,170,0,0.1); }
#start-btn { padding: 15px 60px; font-size: 1.3em; letter-spacing: 0.2em; border: 2px solid #4af; background: transparent; color: #fff; cursor: pointer; transition: all 0.3s; border-radius: 8px; }
#start-btn:not(:disabled) { cursor: pointer; }
#start-btn:hover:not(:disabled) { background: rgba(0,100,255,0.2); box-shadow: 0 0 30px rgba(0,100,255,0.3); }
#start-btn:disabled { opacity: 0.3; cursor: not-allowed; }
#controls-info { margin-top: 30px; padding: 15px 30px; background: rgba(255,255,255,0.05); border-radius: 8px; font-size: 0.8em; color: #7799aa; text-align: center; line-height: 1.8; }
#hud { position: fixed; inset: 0; z-index: 50; pointer-events: none; }
#crosshair { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); color: rgba(255,255,255,0.6); font-size: 24px; z-index: 51; pointer-events: none; }
#message-overlay { position: fixed; top: 20%; left: 50%; transform: translateX(-50%); color: #fff; font-size: 1.5em; text-shadow: 0 0 10px rgba(0,0,0,0.8); z-index: 60; pointer-events: none; opacity: 0; transition: opacity 0.5s; }
</style>
</head>
<body>
<div id="menu">
<h1>APEX AERO</h1>
<p class="subtitle">Flight Simulator</p>
<div id="aircraft-select"></div>
<div id="scenario-select"></div>
<button id="start-btn" disabled="">LAUNCH SIMULATION</button>
<div id="controls-info">
<h3>Controls</h3>
<p><b>W/S</b> Pitch | <b>A/D</b> Roll | <b>Q/E</b> Yaw | <b>Shift/Ctrl</b> Throttle Up/Down</p>
<p><b>Space</b> Brakes | <b>R</b> Reset Position | <b>TAB</b> Toggle HUD</p>
</div>
</div>
<div id="hud" style="display:none;">
<canvas id="hud-canvas"></canvas>
</div>
<div id="crosshair">+</div>
<div id="message-overlay"></div>
<script>
'use strict';
// ── const: AIRCRAFT_DATA ──
const AIRCRAFT_DATA = {
falcon: {
name: "Falcon X1",
description: "Light fighter - agile and fast",
color: 0x4488ff,
accentColor: 0xffaa00,
mass: 5200,
wingArea: 32,
maxThrust: 18000,
liftCoeffMax: 1.8,
dragProfile: 0.025,
dragInduced: 0.04,
rollRate: 2.5,
pitchRate: 1.8,
yawRate: 1.2,
stallSpeed: 65,
maxG: 9,
engineResponse: 0.03,
stats: { speed: 75, agility: 90, power: 65 }
},
thunder: {
name: "Thunder MK-III",
description: "Heavy interceptor - powerful and stable",
color: 0xff4444,
accentColor: 0x88ff88,
mass: 12500,
wingArea: 55,
maxThrust: 45000,
liftCoeffMax: 1.6,
dragProfile: 0.03,
dragInduced: 0.035,
rollRate: 1.8,
pitchRate: 1.4,
yawRate: 0.9,
stallSpeed: 75,
maxG: 7,
engineResponse: 0.02,
stats: { speed: 60, agility: 55, power: 95 }
},
phantom: {
name: "Phantom Stealth",
description: "Stealth bomber - balanced and efficient",
color: 0x333344,
accentColor: 0xaa44ff,
mass: 8800,
wingArea: 45,
maxThrust: 28000,
liftCoeffMax: 1.7,
dragProfile: 0.02,
dragInduced: 0.038,
rollRate: 2.2,
pitchRate: 1.6,
yawRate: 1.0,
stallSpeed: 70,
maxG: 8,
engineResponse: 0.025,
stats: { speed: 70, agility: 75, power: 75 }
}
};
// ── const: SCENARIOS ──
const SCENARIOS = {
takeoff: { name: "Takeoff & Landing", description: "Precision flight on the runway" },
dogfight: { name: "Dogfight", description: "High-speed aerial combat" }
};
// ── const: RHO0 ──
// Sea level air density (kg/m^3)
const RHO0 = 1.225;
// ── const: SCALE_HEIGHT ──
// Scale height for atmosphere model
const SCALE_HEIGHT = 8500;
// ── class: FlightPhysics ──
class FlightPhysics {
constructor(acData) {
// Aircraft properties
this.mass = acData.mass;
this.wingArea = acData.wingArea;
this.maxThrust = acData.maxThrust;
this.liftCoeffMax = acData.liftCoeffMax;
this.dragProfile = acData.dragProfile;
this.dragInduced = acData.dragInduced;
this.rollRate = acData.rollRate;
this.pitchRate = acData.pitchRate;
this.yawRate = acData.yawRate;
this.stallSpeed = acData.stallSpeed;
this.maxG = acData.maxG;
this.engineResponseTime = acData.engineResponse;
// State
this.position = new THREE.Vector3(0, 50, 0);
this.velocity = new THREE.Vector3(0, 0, 0);
this.quaternion = new THREE.Quaternion();
this.angularVelocity = new THREE.Vector3(0, 0, 0);
// Control inputs (normalized -1 to 1)
this.pitchInput = 0;
this.rollInput = 0;
this.yawInput = 0;
this.throttleTarget = 0;
this.brakes = false;
// Engine state
this.currentThrust = 0;
this.rpm = 0;
// Derived values for HUD
this.airspeed = 0;
this.altitude = 0;
this.verticalSpeed = 0;
this.gLoad = 1;
this.angleOfAttack = 0;
this.airDensity = RHO0;
this.isStalled = false;
}
reset(x, y, z, heading) {
this.position.set(x, y, z);
this.velocity.set(0, 0, 0);
this.quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), -heading);
this.angularVelocity.set(0, 0, 0);
this.currentThrust = 0;
this.rpm = 0;
this.throttleTarget = 0;
}
getAirDensity(altitude) {
return RHO0 * Math.exp(-Math.max(0, altitude) / SCALE_HEIGHT);
}
update(dt) {
dt = Math.min(dt, 0.05); // cap delta to prevent physics explosions
// Engine response (smooth throttle changes)
const thrustDiff = this.throttleTarget - (this.currentThrust / this.maxThrust);
const engineRate = this.engineResponseTime * 60;
if (Math.abs(thrustDiff) > 0.001) {
const step = Math.sign(thrustDiff) * Math.min(Math.abs(thrustDiff), engineRate * dt);
this.currentThrust = (this.currentThrust / this.maxThrust + step) * this.maxThrust;
}
// RPM proportional to thrust percentage
this.rpm = THREE.MathUtils.lerp(this.rpm, this.throttleTarget, 0.1);
// Air density at current altitude
this.airDensity = this.getAirDensity(this.position.y);
// Transform velocity to aircraft local frame
const bodyVel = new THREE.Vector3();
this.velocity.clone().applyQuaternion(this.quaternion.clone().invert());
// Airspeed (forward component magnitude)
this.airspeed = Math.sqrt(
bodyVel.x * bodyVel.x + bodyVel.y * bodyVel.y + bodyVel.z * bodyVel.z
);
// Angle of attack approximation
if (this.airspeed > 1) {
this.angleOfAttack = Math.atan2(bodyVel.y, Math.max(bodyVel.x, 0.1));
} else {
this.angleOfAttack = 0;
}
// Stall detection
this.isStalled = this.airspeed < this.stallSpeed && this.position.y > 5;
// --- Force calculations ---
const rho = this.airDensity;
const S = this.wingArea;
const V = this.airspeed;
const m = this.mass;
const g = 9.81;
// Lift coefficient based on angle of attack (simplified linear region)
let Cl = Math.max(-0.5, Math.min(this.liftCoeffMax, this.angleOfAttack * 4));
if (this.isStalled) {
Cl *= 0.3; // drastic lift reduction in stall
}
// Lift force: L = 0.5 * rho * V^2 * S * Cl
const liftForce = 0.5 * rho * V * V * S * Cl;
// Drag: profile drag + induced drag
const CdProfile = this.dragProfile;
const CdInduced = this.dragInduced * Cl * Cl;
const totalDrag = 0.5 * rho * V * V * S * (CdProfile + CdInduced);
// Ground friction when on ground
let groundFriction = 0;
if (this.position.y < 3 && !this.brakes) {
groundFriction = totalDrag * 2;
} else if (this.brakes) {
groundFriction = m * g * 0.6; // strong braking force
}
// Build force vectors in aircraft local frame then transform to world
const forcesLocal = new THREE.Vector3();
// Thrust along +X axis (forward)
forcesLocal.x += this.currentThrust;
// Drag along -X axis (backward)
forcesLocal.x -= totalDrag;
// Ground friction along -X
forcesLocal.x -= groundFriction;
// Lift along +Y axis (up in aircraft frame)
forcesLocal.y += liftForce;
// Weight along -Y world axis
const weight = m * g;
// Transform forces to world space
const thrustWorld = new THREE.Vector3();
forcesLocal.applyQuaternion(this.quaternion);
// Total force in world space (weight is already in world space)
const totalForce = new THREE.Vector3(
thrustWorld.x,
thrustWorld.y - weight + thrustWorld.z, // Y is up in our convention...
thrustWorld.z
);
// Actually let me redo this more carefully. In Three.js:
// Aircraft forward = local +X (after rotation)
// Aircraft up = local +Y (after rotation)
// Aircraft right = local +Z (after rotation)
// World up = +Y
const forwardDir = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
const upDir = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion);
// Thrust in forward direction
const thrustVec = forwardDir.clone().multiplyScalar(this.currentThrust);
// Drag opposite to velocity
let dragVec = new THREE.Vector3();
if (V > 0.1) {
dragVec = this.velocity.clone().normalize().multiplyScalar(-totalDrag);
}
// Lift in aircraft up direction
const liftVec = upDir.clone().multiplyScalar(liftForce);
// Weight downward
const weightVec = new THREE.Vector3(0, -weight, 0);
// Ground friction opposite to velocity
let frictionVec = new THREE.Vector3();
if (groundFriction > 0 && V > 0.1) {
frictionVec = this.velocity.clone().normalize().multiplyScalar(-groundFriction);
}
// Net force
const netForce = new THREE.Vector3()
.add(thrustVec)
.add(dragVec)
.add(liftVec)
.add(weightVec)
.add(frictionVec);
// Acceleration = F/m
const acceleration = netForce.divideScalar(m);
// Update velocity
this.velocity.add(acceleration.multiplyScalar(dt));
// Ground collision
if (this.position.y < 2) {
this.position.y = 2;
if (this.velocity.y < -5) {
// Hard landing damage indicator
this.gLoad = Math.abs(this.velocity.y) / g * 3;
} else {
this.velocity.y = Math.max(0, this.velocity.y);
}
}
// Update position
this.position.add(this.velocity.clone().multiplyScalar(dt));
// --- Angular dynamics (attitude control) ---
const rollTarget = -this.rollInput * this.rollRate;
const pitchTarget = -this.pitchInput * this.pitchRate;
const yawTarget = this.yawInput * this.yawRate;
// Smooth angular velocity changes
const angDamp = 0.92;
const angResponse = Math.min(1, dt * 5);
this.angularVelocity.x = THREE.MathUtils.lerp(this.angularVelocity.x, pitchTarget, angResponse);
this.angularVelocity.y = THREE.MathUtils.lerp(this.angularVelocity.y, yawTarget, angResponse);
this.angularVelocity.z = THREE.MathUtils.lerp(this.angularVelocity.z, rollTarget, angResponse);
// Apply angular damping
this.angularVelocity.multiplyScalar(angDamp);
// Update orientation from angular velocity
const deltaQuat = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
this.angularVelocity.x * dt,
this.angularVelocity.y * dt,
this.angularVelocity.z * dt,
'XYZ'
)
);
this.quaternion.multiply(deltaQuat).normalize();
// Auto-level when no input (gentle)
if (Math.abs(this.rollInput) < 0.1 && Math.abs(this.pitchInput) < 0.1) {
const levelQuat = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0), -this.angularVelocity.x * dt * 0.5
);
this.quaternion.multiply(levelQuat).normalize();
}
// G-load calculation (normal acceleration)
const normalAccel = Math.sqrt(acceleration.y * acceleration.y +
acceleration.x * acceleration.x +
acceleration.z * acceleration.z);
this.gLoad = 1 + normalAccel / g;
if (this.isStalled) this.gLoad *= 0.5;
// Vertical speed in m/s
this.verticalSpeed = this.velocity.y;
// Altitude
this.altitude = Math.max(0, this.position.y);
}
}
// ── class: TerrainNoise ──
// Simplex-like noise implementation for terrain generation
class TerrainNoise {
constructor(seed) {
this.seed = seed || Math.random() * 65536;
this.perm = new Uint8Array(512);
this.grad3 = [
[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],
[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],
[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]
];
this._initPerm();
}
_initPerm() {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
// Fisher-Yates shuffle with seed
let s = this.seed;
for (let i = 255; i > 0; i--) {
s = (s * 16807 + 0) % 2147483647;
const j = s % (i + 1);
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
}
_dot(g, x, y) { return g[0]*x + g[1]*y; }
noise(xin, yin) {
const F2 = 0.5 * (Math.sqrt(3) - 1);
const G2 = (3 - Math.sqrt(3)) / 6;
const s = (xin + yin) * F2;
const i = Math.floor(xin + s);
const j = Math.floor(yin + s);
const t = (i + j) * G2;
const X0 = i - t, Y0 = j - t;
const x0 = xin - X0, y0 = yin - Y0;
let i1, j1;
if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; }
const x1 = x0 - i1 + G2, y1 = y0 - j1 + G2;
const x2 = x0 - 1 + 2*G2, y2 = y0 - 1 + 2*G2;
const ii = i & 255, jj = j & 255;
const gi0 = this.perm[ii + this.perm[jj]] % 12;
const gi1 = this.perm[ii + i1 + this.perm[jj + j1]] % 12;
const gi2 = this.perm[ii + 1 + this.perm[jj + 1]] % 12;
let n0, n1, n2;
let t0 = 0.5 - x0*x0 - y0*y0;
if (t0 < 0) n0 = 0;
else { t0 *= t0; n0 = t0 * t0 * this._dot(this.grad3[gi0], x0, y0); }
let t1 = 0.5 - x1*x1 - y1*y1;
if (t1 < 0) n1 = 0;
else { t1 *= t1; n1 = t1 * t1 * this._dot(this.grad3[gi1], x1, y1); }
let t2 = 0.5 - x2*x2 - y2*y2;
if (t2 < 0) n2 = 0;
else { t2 *= t2; n2 = t2 * t2 * this._dot(this.grad3[gi2], x2, y2); }
return 70 * (n0 + n1 + n2);
}
fbm(x, y, octaves) {
let val = 0, amp = 1, freq = 1, max = 0;
for (let i = 0; i < octaves; i++) {
val += this.noise(x * freq, y * freq) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return val / max;
}
getHeight(worldX, worldZ) {
const scale = 0.0015;
let h = this.fbm(worldX * scale, worldZ * scale, 6);
// Add ridges
const ridge = 1 - Math.abs(this.fbm(worldX * scale * 2 + 5, worldZ * scale * 2 + 5, 4));
h += ridge * ridge * 0.4;
// Flatten near origin for runway area
const distFromOrigin = Math.sqrt(worldX*worldX + worldZ*worldZ);
if (distFromOrigin < 300) {
const flattenFactor = distFromOrigin / 300;
h *= flattenFactor * flattenFactor;
}
return Math.max(0, h * 250);
}
}
// ── class: TerrainRenderer ──
class TerrainRenderer {
constructor(scene, noise) {
this.scene = scene;
this.noise = noise;
this.chunks = new Map();
this.chunkSize = 500;
this.chunkRes = 80;
this.renderDistance = 6; // chunks in each direction
this.lastPlayerChunk = null;
this.meshes = [];
}
_chunkKey(cx, cz) { return `${cx},${cz}`; }
_createChunkMesh(cx, cz) {
const size = this.chunkSize;
const res = this.chunkRes;
const geometry = new THREE.PlaneGeometry(size, size, res, res);
geometry.rotateX(-Math.PI / 2);
const positions = geometry.attributes.position.array;
const colors = new Float32Array(positions.length);
const halfSize = size / 2;
const worldOX = cx * size;
const worldOZ = cz * size;
for (let i = 0; i < positions.length; i += 3) {
const wx = positions[i] + worldOX;
const wz = positions[i + 2] + worldOZ;
const h = this.noise.getHeight(wx, wz);
positions[i + 1] = h;
// Color based on height with more variation
const t = Math.min(h / 200, 1);
const noiseVal = (Math.sin(wx * 0.05) * Math.cos(wz * 0.07)) * 0.05;
if (h < 3) {
// Water/beach - sandy color
colors[i] = 0.72 + noiseVal;
colors[i+1] = 0.68 + noiseVal;
colors[i+2] = 0.45 + noiseVal;
} else if (h < 25) {
// Low grass - vibrant green with variation
const gVar = Math.sin(wx * 0.1 + wz * 0.08) * 0.05;
colors[i] = 0.12 + noiseVal;
colors[i+1] = 0.35 + gVar;
colors[i+2] = 0.08 + noiseVal;
} else if (h < 70) {
// Forest - darker green/brown mix
const fVar = Math.sin(wx * 0.06 - wz * 0.1) * 0.04;
colors[i] = 0.08 + noiseVal;
colors[i+1] = 0.22 + fVar;
colors[i+2] = 0.05 + noiseVal;
} else if (h < 130) {
// Rock - gray with variation
const r = 0.32 + Math.sin(wx * 0.08 + wz * 0.06) * 0.06;
colors[i] = r;
colors[i+1] = r + 0.02;
colors[i+2] = r + 0.04;
} else if (h < 175) {
// Snow line transition
const s = Math.min((h - 130) / 45, 1);
colors[i] = 0.32 + s * 0.6 + noiseVal;
colors[i+1] = 0.32 + s * 0.6 + noiseVal;
colors[i+2] = 0.38 + s * 0.57 + noiseVal;
} else {
// Snow peak - bright white
colors[i] = 0.92 + noiseVal;
colors[i+1] = 0.94 + noiseVal;
colors[i+2] = 0.96 + noiseVal;
}
}
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.85,
metalness: 0.1,
flatShading: false
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(worldOX, 0, worldOZ);
return mesh;
}
update(playerX, playerZ) {
const pcx = Math.round(playerX / this.chunkSize);
const pcz = Math.round(playerZ / this.chunkSize);
if (pcx === this.lastPlayerChunk?.cx && pcz === this.lastPlayerChunk?.cz) return;
this.lastPlayerChunk = { cx: pcx, cz: pcz };
// Determine needed chunks
const needed = new Set();
for (let dx = -this.renderDistance; dx <= this.renderDistance; dx++) {
for (let dz = -this.renderDistance; dz <= this.renderDistance; dz++) {
if (dx*dx + dz*dz > this.renderDistance * this.renderDistance + 1) continue;
needed.add(this._chunkKey(pcx + dx, pcz + dz));
}
}
// Remove far chunks
for (const [key, mesh] of this.chunks) {
if (!needed.has(key)) {
this.scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
this.chunks.delete(key);
}
}
// Add new chunks
for (const key of needed) {
if (!this.chunks.has(key)) {
const [cx, cz] = key.split(',').map(Number);
const mesh = this._createChunkMesh(cx, cz);
this.scene.add(mesh);
this.chunks.set(key, mesh);
}
}
}
getHeightAt(x, z) {
return this.noise.getHeight(x, z);
}
dispose() {
for (const [, mesh] of this.chunks) {
this.scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
}
this.chunks.clear();
}
}
// ── class: AircraftModel ──
class AircraftModel {
static createFalcon(color, accent) {
const group = new THREE.Group();
// Fuselage - sleek pointed body
const fuselageGeo = new THREE.CylinderGeometry(0.4, 0.6, 8, 8);
const fuselageMat = new THREE.MeshStandardMaterial({ color, roughness: 0.3, metalness: 0.7 });
const fuselage = new THREE.Mesh(fuselageGeo, fuselageMat);
fuselage.rotation.z = Math.PI / 2;
group.add(fuselage);
// Nose cone
const noseGeo = new THREE.ConeGeometry(0.4, 2, 8);
const nose = new THREE.Mesh(noseGeo, fuselageMat);
nose.rotation.z = -Math.PI / 2;
nose.position.x = 5;
group.add(nose);
// Cockpit canopy
const cockpitGeo = new THREE.SphereGeometry(0.45, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2);
const cockpitMat = new THREE.MeshStandardMaterial({ color: 0x88ccff, roughness: 0.1, metalness: 0.9, transparent: true, opacity: 0.7 });
const cockpit = new THREE.Mesh(cockpitGeo, cockpitMat);
cockpit.position.set(2, 0.35, 0);
group.add(cockpit);
// Main wings - swept back
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(-1.5, 6);
wingShape.lineTo(-2, 6);
wingShape.lineTo(-0.5, 0);
const wingExtrudeSettings = { depth: 0.08, bevelEnabled: true, bevelThickness: 0.02, bevelSize: 0.02 };
const wingGeo = new THREE.ExtrudeGeometry(wingShape, wingExtrudeSettings);
const wingMat = new THREE.MeshStandardMaterial({ color, roughness: 0.4, metalness: 0.6 });
const leftWing = new THREE.Mesh(wingGeo, wingMat);
leftWing.position.set(1, -0.3, 0);
group.add(leftWing);
const rightWing = new THREE.Mesh(wingGeo, wingMat);
rightWing.position.set(1, -0.3, 0);
rightWing.scale.z = -1;
group.add(rightWing);
// Tail wings (horizontal stabilizers)
const tailShape = new THREE.Shape();
tailShape.moveTo(0, 0);
tailShape.lineTo(-0.8, 2.5);
tailShape.lineTo(-1.2, 2.5);
tailShape.lineTo(-0.3, 0);
const tailGeo = new THREE.ExtrudeGeometry(tailShape, { depth: 0.06, bevelEnabled: false });
const leftTail = new THREE.Mesh(tailGeo, wingMat);
leftTail.position.set(-3.5, -0.2, 0);
group.add(leftTail);
const rightTail = new THREE.Mesh(tailGeo, wingMat);
rightTail.position.set(-3.5, -0.2, 0);
rightTail.scale.z = -1;
group.add(rightTail);
// Vertical stabilizer (tail fin)
const vStabShape = new THREE.Shape();
vStabShape.moveTo(0, 0);
vStabShape.lineTo(-1.5, 2.5);
vStabShape.lineTo(-2, 2);
vStabShape.lineTo(-0.5, 0);
const vStabGeo = new THREE.ExtrudeGeometry(vStabShape, { depth: 0.06, bevelEnabled: false });
const vStab = new THREE.Mesh(vStabGeo, wingMat);
vStab.position.set(-3.5, 0.2, -0.03);
group.add(vStab);
// Engine exhaust glow
const exhaustGeo = new THREE.CylinderGeometry(0.15, 0.25, 0.5, 8);
const exhaustMat = new THREE.MeshStandardMaterial({ color: accent, emissive: accent, emissiveIntensity: 0.5 });
const exhaust = new THREE.Mesh(exhaustGeo, exhaustMat);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.x = -4.2;
group.add(exhaust);
// Engine glow light
const engineLight = new THREE.PointLight(accent, 0.5, 10);
engineLight.position.set(-4.5, 0, 0);
group.add(engineLight);
group.userData.engineLight = engineLight;
return group;
}
static createThunder(color, accent) {
const group = new THREE.Group();
// Larger fuselage
const fuselageGeo = new THREE.CylinderGeometry(0.7, 1.0, 12, 8);
const fuselageMat = new THREE.MeshStandardMaterial({ color, roughness: 0.35, metalness: 0.6 });
const fuselage = new THREE.Mesh(fuselageGeo, fuselageMat);
fuselage.rotation.z = Math.PI / 2;
group.add(fuselage);
// Nose
const noseGeo = new THREE.ConeGeometry(0.7, 3, 8);
const nose = new THREE.Mesh(noseGeo, fuselageMat);
nose.rotation.z = -Math.PI / 2;
nose.position.x = 7.5;
group.add(nose);
// Cockpit
const cockpitGeo = new THREE.SphereGeometry(0.6, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2);
const cockpitMat = new THREE.MeshStandardMaterial({ color: 0x44aa44, roughness: 0.1, metalness: 0.9, transparent: true, opacity: 0.7 });
const cockpit = new THREE.Mesh(cockpitGeo, cockpitMat);
cockpit.position.set(3.5, 0.5, 0);
group.add(cockpit);
// Broad wings
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(-1, 8);
wingShape.lineTo(-2.5, 8);
wingShape.lineTo(-0.8, 0);
const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth: 0.12, bevelEnabled: true, bevelThickness: 0.03, bevelSize: 0.03 });
const wingMat = new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.5 });
const leftWing = new THREE.Mesh(wingGeo, wingMat);
leftWing.position.set(1, -0.5, 0);
group.add(leftWing);
const rightWing = new THREE.Mesh(wingGeo, wingMat);
rightWing.position.set(1, -0.5, 0);
rightWing.scale.z = -1;
group.add(rightWing);
// Twin vertical stabilizers
for (let side of [-1, 1]) {
const vStabShape = new THREE.Shape();
vStabShape.moveTo(0, 0);
vStabShape.lineTo(-2, 3.5);
vStabShape.lineTo(-2.8, 3);
vStabShape.lineTo(-0.8, 0);
const vStabGeo = new THREE.ExtrudeGeometry(vStabShape, { depth: 0.08, bevelEnabled: false });
const vStab = new THREE.Mesh(vStabGeo, wingMat);
vStab.position.set(-5, 0.3, side * 1.2 - side * 0.04);
group.add(vStab);
}
// Twin engines
for (let side of [-1, 1]) {
const engGeo = new THREE.CylinderGeometry(0.25, 0.35, 1, 8);
const engMat = new THREE.MeshStandardMaterial({ color: accent, emissive: accent, emissiveIntensity: 0.4 });
const eng = new THREE.Mesh(engGeo, engMat);
eng.rotation.z = Math.PI / 2;
eng.position.set(-6, -0.3, side * 1.5);
group.add(eng);
const elight = new THREE.PointLight(accent, 0.4, 8);
elight.position.set(-6.5, -0.3, side * 1.5);
group.add(elight);
}
group.userData.engineLight = group.children[group.children.length - 1];
return group;
}
static createPhantom(color, accent) {
const group = new THREE.Group();
// Angular stealth fuselage
const bodyGeo = new THREE.BoxGeometry(9, 0.8, 2);
const bodyMat = new THREE.MeshStandardMaterial({ color, roughness: 0.5, metalness: 0.4 });
const body = new THREE.Mesh(bodyGeo, bodyMat);
group.add(body);
// Pointed nose
const noseGeo = new THREE.ConeGeometry(0.6, 3, 4);
const nose = new THREE.Mesh(noseGeo, bodyMat);
nose.rotation.z = -Math.PI / 2;
nose.position.x = 6;
group.add(nose);
// Cockpit slit
const cockpitGeo = new THREE.BoxGeometry(1.5, 0.3, 0.8);
const cockpitMat = new THREE.MeshStandardMaterial({ color: 0x6644aa, roughness: 0.1, metalness: 0.9, transparent: true, opacity: 0.7 });
const cockpit = new THREE.Mesh(cockpitGeo, cockpitMat);
cockpit.position.set(3, 0.5, 0);
group.add(cockpit);
// Delta wings (large triangular)
const wingShape = new THREE.Shape();
wingShape.moveTo(2, 0);
wingShape.lineTo(-4, 7);
wingShape.lineTo(-3, 7.5);
wingShape.lineTo(1, 0);
const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth: 0.1, bevelEnabled: true, bevelThickness: 0.02, bevelSize: 0.02 });
const wingMat = new THREE.MeshStandardMaterial({ color, roughness: 0.5, metalness: 0.4 });
const leftWing = new THREE.Mesh(wingGeo, wingMat);
leftWing.position.set(0, -0.4, 0);
group.add(leftWing);
const rightWing = new THREE.Mesh(wingGeo, wingMat);
rightWing.position.set(0, -0.4, 0);
rightWing.scale.z = -1;
group.add(rightWing);
// Canted vertical stabilizers
for (let side of [-1, 1]) {
const vStabShape = new THREE.Shape();
vStabShape.moveTo(0, 0);
vStabShape.lineTo(-2.5, 3);
vStabShape.lineTo(-3, 2.8);
vStabShape.lineTo(-0.5, 0);
const vStabGeo = new THREE.ExtrudeGeometry(vStabShape, { depth: 0.06, bevelEnabled: false });
const vStab = new THREE.Mesh(vStabGeo, wingMat);
vStab.position.set(-3, 0.4, side * 2 - side * 0.03);
group.add(vStab);
}
// Engine exhaust (centered)
const exhaustGeo = new THREE.CylinderGeometry(0.2, 0.35, 0.6, 8);
const exhaustMat = new THREE.MeshStandardMaterial({ color: accent, emissive: accent, emissiveIntensity: 0.5 });
const exhaust = new THREE.Mesh(exhaustGeo, exhaustMat);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.x = -4.8;
group.add(exhaust);
const engineLight = new THREE.PointLight(accent, 0.6, 10);
engineLight.position.set(-5, 0, 0);
group.add(engineLight);
group.userData.engineLight = engineLight;
return group;
}
static create(type, color, accent) {
switch (type) {
case 'falcon': return this.createFalcon(color, accent);
case 'thunder': return this.createThunder(color, accent);
case 'phantom': return this.createPhantom(color, accent);
default: return this.createFalcon(color, accent);
}
}
}
// ── class: FlightAudio ──
class FlightAudio {
constructor() {
this.ctx = null;
this.engineGain = null;
this.windGain = null;
this.stallOsc = null;
this.initialized = false;
}
init() {
if (this.initialized) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
// Engine sound - layered oscillators for realistic jet engine
const masterGain = this.ctx.createGain();
masterGain.gain.value = 0.15;
masterGain.connect(this.ctx.destination);
// Low rumble (engine core)
this.engineOsc1 = this.ctx.createOscillator();
this.engineOsc1.type = 'sawtooth';
this.engineOsc1.frequency.value = 60;
const engineFilter1 = this.ctx.createBiquadFilter();
engineFilter1.type = 'lowpass';
engineFilter1.frequency.value = 200;
engineFilter1.Q.value = 2;
this.engineGain = this.ctx.createGain();
this.engineGain.gain.value = 0.3;
this.engineOsc1.connect(engineFilter1);
engineFilter1.connect(this.engineGain);
this.engineGain.connect(masterGain);
this.engineOsc1.start();
// Mid frequency (turbine whine)
this.engineOsc2 = this.ctx.createOscillator();
this.engineOsc2.type = 'sawtooth';
this.engineOsc2.frequency.value = 180;
const engineFilter2 = this.ctx.createBiquadFilter();
engineFilter2.type = 'bandpass';
engineFilter2.frequency.value = 400;
engineFilter2.Q.value = 3;
this.engineGain2 = this.ctx.createGain();
this.engineGain2.gain.value = 0.15;
this.engineOsc2.connect(engineFilter2);
engineFilter2.connect(this.engineGain2);
this.engineGain2.connect(masterGain);
this.engineOsc2.start();
// High frequency (compressor whine)
this.engineOsc3 = this.ctx.createOscillator();
this.engineOsc3.type = 'sine';
this.engineOsc3.frequency.value = 800;
this.engineGain3 = this.ctx.createGain();
this.engineGain3.gain.value = 0.05;
this.engineOsc3.connect(this.engineGain3);
this.engineGain3.connect(masterGain);
this.engineOsc3.start();
// Wind noise - filtered white noise
const bufferSize = 2 * this.ctx.sampleRate;
const windBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const windData = windBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
windData[i] = Math.random() * 2 - 1;
}
this.windSource = this.ctx.createBufferSource();
this.windSource.buffer = windBuffer;
this.windSource.loop = true;
const windFilter = this.ctx.createBiquadFilter();
windFilter.type = 'bandpass';
windFilter.frequency.value = 500;
windFilter.Q.value = 0.5;
this.windGain = this.ctx.createGain();
this.windGain.gain.value = 0;
this.windSource.connect(windFilter);
windFilter.connect(this.windGain);
this.windGain.connect(masterGain);
this.windSource.start();
// Stall warning oscillator
this.stallOsc = this.ctx.createOscillator();
this.stallOsc.type = 'square';
this.stallOsc.frequency.value = 800;
const stallGain = this.ctx.createGain();
stallGain.gain.value = 0;
this.stallGainNode = stallGain;
this.stallOsc.connect(stallGain);
stallGain.connect(masterGain);
this.stallOsc.start();
// Ambient drone (low frequency rumble)
const ambientOsc = this.ctx.createOscillator();
ambientOsc.type = 'sine';
ambientOsc.frequency.value = 30;
const ambientGain = this.ctx.createGain();
ambientGain.gain.value = 0.02;
ambientOsc.connect(ambientGain);
ambientGain.connect(masterGain);
ambientOsc.start();
this.initialized = true;
} catch (e) {
console.warn('Audio init failed:', e);
}
}
update(throttle, airspeed, altitude, isStalled, dt) {
if (!this.initialized || !this.ctx) return;
const t = this.ctx.currentTime;
// Engine pitch and volume based on throttle
const baseFreq1 = 40 + throttle * 80;
const baseFreq2 = 120 + throttle * 300;
const baseFreq3 = 500 + throttle * 600;
this.engineOsc1.frequency.setTargetAtTime(baseFreq1, t, 0.05);
this.engineOsc2.frequency.setTargetAtTime(baseFreq2, t, 0.05);
this.engineOsc3.frequency.setTargetAtTime(baseFreq3, t, 0.05);
// Engine volume based on throttle and altitude (less dense air = quieter)
const altFactor = Math.exp(-altitude / 10000);
const engineVol = 0.05 + throttle * 0.4 * altFactor;
this.engineGain.gain.setTargetAtTime(engineVol, t, 0.1);
this.engineGain2.gain.setTargetAtTime(engineVol * 0.6, t, 0.1);
this.engineGain3.gain.setTargetAtTime(engineVol * 0.3, t, 0.1);
// Wind noise based on airspeed
const windVol = Math.min(airspeed / 400, 1) * 0.2;
this.windGain.gain.setTargetAtTime(windVol, t, 0.1);
// Stall warning
if (isStalled && airspeed > 5) {
const stallPulse = Math.sin(t * 8) > 0 ? 0.15 : 0;
this.stallGainNode.gain.setTargetAtTime(stallPulse, t, 0.02);
} else {
this.stallGainNode.gain.setTargetAtTime(0, t, 0.1);
}
}
stop() {
if (this.ctx) {
try { this.ctx.close(); } catch(e) {}
this.initialized = false;
}
}
}
// ── class: HUD ──
class HUD {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.visible = true;
this.resize();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
draw(physics, throttle, scenario, enemies) {
if (!this.visible) return;
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
const cx = w / 2;
const cy = h / 2;
ctx.clearRect(0, 0, w, h);
ctx.save();
// Color scheme
const hudColor = '#4affaa';
const warnColor = '#ff6644';
const dimColor = 'rgba(74,255,170,0.3)';
ctx.strokeStyle = hudColor;
ctx.fillStyle = hudColor;
ctx.lineWidth = 1.5;
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
// --- Pitch ladder (centered) ---
const pitchScale = 3; // pixels per degree
const rollAngle = this._getRoll(physics.quaternion);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-rollAngle);
// Pitch lines
for (let deg = -45; deg <= 45; deg += 10) {
if (deg === 0) continue;
const y = -deg * pitchScale;
const lineW = Math.abs(deg) % 20 === 0 ? 60 : 30;
ctx.strokeStyle = dimColor;
ctx.beginPath();
ctx.moveTo(-lineW, y);
ctx.lineTo(lineW, y);
ctx.stroke();
if (Math.abs(deg) % 20 === 0 && Math.abs(deg) > 0) {
ctx.fillStyle = dimColor;
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(Math.abs(deg).toString(), lineW + 15, y + 4);
}
}
// Horizon line
const pitchAngle = this._getPitch(physics.quaternion) * (180 / Math.PI);
const horizonY = -pitchAngle * pitchScale;
ctx.strokeStyle = hudColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-150, horizonY);
ctx.lineTo(150, horizonY);
ctx.stroke();
// Aircraft reference symbol (fixed)
ctx.strokeStyle = hudColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-30, 0);
ctx.lineTo(-12, 0);
ctx.moveTo(12, 0);
ctx.lineTo(30, 0);
ctx.stroke();
// Center dot
ctx.fillStyle = hudColor;
ctx.beginPath();
ctx.arc(0, 0, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// --- Left side: Airspeed tape ---
const speed = physics.airspeed * 1.944; // m/s to knots
this._drawTape(80, cy - 150, 300, speed, 'kts', hudColor);
// --- Right side: Altitude tape ---
const alt = Math.round(physics.altitude * 3.281); // meters to feet
this._drawTape(w - 140, cy - 150, 300, alt, 'ft', hudColor);
// --- Bottom: G-meter ---
const gVal = Math.max(-1, Math.min(12, physics.gLoad));
ctx.fillStyle = dimColor;
ctx.fillRect(w/2 - 80, h - 60, 160, 8);
const gColor = Math.abs(gVal) > 7 ? warnColor : hudColor;
ctx.fillStyle = gColor;
const gWidth = ((gVal + 1) / 13) * 156;
ctx.fillRect(w/2 - 80, h - 60, Math.max(0, gWidth), 8);
ctx.fillStyle = hudColor;
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'center';
ctx.fillText(`G: ${gVal.toFixed(1)}`, w/2, h - 45);
// --- Throttle bar (bottom left) ---
const throttlePct = Math.round(throttle * 100);
ctx.fillStyle = dimColor;
ctx.fillRect(30, h - 80, 12, 60);
ctx.fillStyle = hudColor;
ctx.fillRect(30, h - 20 - throttlePct * 0.6, 12, throttlePct * 0.6);
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText(`${throttlePct}%`, 36, h - 85);
// --- Stall warning ---
if (physics.isStalled) {
ctx.fillStyle = warnColor;
ctx.font = 'bold 24px monospace';
ctx.textAlign = 'center';
const flash = Math.sin(Date.now() * 0.01) > 0;
if (flash) ctx.fillText('⚠ STALL ⚠', cx, cy - 80);
}
// --- Ground proximity warning ---
if (physics.altitude < 50 && physics.verticalSpeed > 2) {
ctx.fillStyle = warnColor;
ctx.font = 'bold 18px monospace';
ctx.textAlign = 'center';
const flash = Math.sin(Date.now() * 0.015) > 0;
if (flash) ctx.fillText('PULL UP', cx, cy + 60);
}
// --- Heading indicator (top center) ---
const heading = this._getHeading(physics.quaternion);
ctx.fillStyle = hudColor;
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'center';
ctx.fillText(`HDG ${Math.round((heading + 360) % 360)}°`, cx, 30);
// --- Air density indicator ---
const densityRatio = (physics.airDensity / RHO0 * 100).toFixed(0);
ctx.font = '11px monospace';
ctx.fillStyle = dimColor;
ctx.textAlign = 'left';
ctx.fillText(`σ: ${densityRatio}%`, 30, h - 95);
// --- Vertical speed indicator (right side) ---
const vsi = Math.round(physics.verticalSpeed * 196.85); // m/s to ft/min
ctx.fillStyle = hudColor;
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'left';
ctx.fillText(`V/S: ${vsi > 0 ? '+' : ''}${vsi}`, w - 140, cy + 170);
// --- Player health (dogfight) ---
if (window._gameHealth !== undefined) {
const hp = Math.max(0, window._gameHealth);
ctx.fillStyle = dimColor;
ctx.fillRect(w/2 - 80, h - 35, 160, 8);
ctx.fillStyle = hp > 50 ? hudColor : warnColor;
ctx.fillRect(w/2 - 80, h - 35, (hp / 100) * 160, 8);
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText(`HP: ${Math.round(hp)}%`, w/2, h - 23);
}
// --- Mission score ---
if (window._gameScore !== undefined) {
ctx.fillStyle = hudColor;
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'right';
ctx.fillText(`SCORE: ${Math.round(window._gameScore)}`, w - 30, 30);
}
// --- Radar (dogfight mode only) ---
if (scenario === 'dogfight' && enemies) {
this._drawRadar(w - 100, h - 120, 70, physics.position, enemies);
}
ctx.restore();
}
_drawTape(x, tapeY, height, value, unit, color) {
const ctx = this.ctx;
// Tape background
ctx.fillStyle = 'rgba(0,20,10,0.6)';
ctx.fillRect(x - 5, tapeY, 70, height);
ctx.strokeStyle = 'rgba(74,255,170,0.3)';
ctx.strokeRect(x - 5, tapeY, 70, height);
const tickSpacing = 20;
const range = Math.ceil(height / (tickSpacing * 2));
for (let i = -range; i <= range; i++) {
const val = value + i * (unit === 'kts' ? 10 : 500);
const tickY = tapeY + height/2 - i * tickSpacing;
if (tickY < tapeY || tickY > tapeY + height) continue;
ctx.strokeStyle = 'rgba(74,255,170,0.3)';
ctx.beginPath();
ctx.moveTo(x + 60, tickY);
ctx.lineTo(x + 68, tickY);
ctx.stroke();
if (i % 2 === 0) {
ctx.fillStyle = 'rgba(74,255,170,0.6)';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.fillText(Math.round(val).toString(), x + 3, tickY + 4);
}
}
// Current value highlight bracket
const midY = tapeY + height / 2;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x - 10, midY - 8);
ctx.lineTo(x - 3, midY);
ctx.lineTo(x - 10, midY + 8);
ctx.closePath();
ctx.fill();
// Current value text
ctx.fillStyle = color;
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'right';
ctx.fillText(`${Math.round(value)} ${unit}`, x - 8, midY + 5);
}
_drawRadar(rx, ry, radius, playerPos, enemies) {
const ctx = this.ctx;
// Radar background circle
ctx.strokeStyle = 'rgba(74,255,170,0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(rx, ry, radius, 0, Math.PI * 2);
ctx.stroke();
// Range rings
for (let r = 1; r <= 3; r++) {
ctx.strokeStyle = 'rgba(74,255,170,0.15)';
ctx.beginPath();
ctx.arc(rx, ry, radius * r / 3, 0, Math.PI * 2);
ctx.stroke();
}
// Cross lines (N/S/E/W)
ctx.strokeStyle = 'rgba(74,255,170,0.2)';
ctx.beginPath();
ctx.moveTo(rx - radius, ry);
ctx.lineTo(rx + radius, ry);
ctx.moveTo(rx, ry - radius);
ctx.lineTo(rx, ry + radius);
ctx.stroke();
// Player blip (center)
ctx.fillStyle = '#4affaa';
ctx.beginPath();
ctx.arc(rx, ry, 3, 0, Math.PI * 2);
ctx.fill();
// Enemy blips
const range = 1500; // meters per radar radius
for (const enemy of enemies) {
if (!enemy.alive || !enemy.mesh) continue;
const dx = enemy.mesh.position.x - playerPos.x;
const dz = enemy.mesh.position.z - playerPos.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist > range) continue;
// Rotate by player heading to make radar relative
const forwardX = Math.sin(this._getHeadingRaw(enemy.mesh.quaternion || new THREE.Quaternion()));
const px = (dx / range) * radius;
const pz = (dz / range) * radius;
ctx.fillStyle = '#ff4444';
ctx.beginPath();
ctx.arc(rx + px, ry - pz, 3, 0, Math.PI * 2);
ctx.fill();
// Distance label
ctx.fillStyle = 'rgba(255,68,68,0.7)';
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.fillText(Math.round(dist), rx + px + 5, ry - pz + 3);
}
// N label
ctx.fillStyle = 'rgba(74,255,170,0.6)';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.fillText('N', rx, ry - radius + 10);
// Range label
ctx.fillStyle = 'rgba(74,255,170,0.3)';
ctx.font = '8px monospace';
ctx.fillText(`${Math.round(range)}m`, rx, ry + radius - 5);
}
_getHeadingRaw(q) {
return Math.atan2(2 * (q.w * q.y + q.x * q.z), 1 - 2 * (q.y * q.y + q.z * q.z));
}
_getPitch(q) {
return Math.asin(-2 * (q.x * q.z - q.w * q.y));
}
_getRoll(q) {
return Math.atan2(2 * (q.w * q.z + q.x * q.y), 1 - 2 * (q.y * q.y + q.z * q.z));
}
_getHeading(q) {
const yaw = this._getHeadingRaw(q);
return -(yaw * 180 / Math.PI);
}
toggle() {
this.visible = !this.visible;
if (!this.visible) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}
}
// ── class: EnemyAircraft ──
class EnemyAircraft {
constructor(scene, position) {
this.scene = scene;
this.mesh = AircraftModel.create('falcon', 0xff2222, 0xff8844);
this.mesh.position.copy(position);
this.scene.add(this.mesh);
// Simple physics for enemy
this.velocity = new THREE.Vector3(50, 0, -30);
this.quaternion = new THREE.Quaternion();
this.targetPosition = null;
this.state = 'patrol'; // patrol, chase, evade
this.health = 100;
this.alive = true;
// Patrol waypoints
this.patrolAngle = Math.random() * Math.PI * 2;
this.orbitRadius = 800 + Math.random() * 400;
this.orbitAltitude = 300 + Math.random() * 500;
// Attack cooldown
this.attackCooldown = 0;
// Reaction delay
this.reactionTimer = 0;
}
update(dt, playerPos, playerVel) {
if (!this.alive) return;
const distToPlayer = this.mesh.position.distanceTo(playerPos);
// State machine
if (distToPlayer < 600 && this.health > 0) {
this.state = 'chase';
} else if (this.health <= 25) {
this.state = 'evade';
} else {
this.state = 'patrol';
}
let targetDir;
switch (this.state) {
case 'patrol':
this.patrolAngle += dt * 0.15;
const tx = Math.cos(this.patrolAngle) * this.orbitRadius;
const tz = Math.sin(this.patrolAngle) * this.orbitRadius;
targetDir = new THREE.Vector3(tx - this.mesh.position.x,
this.orbitAltitude - this.mesh.position.y,
tz - this.mesh.position.z).normalize();
break;
case 'chase':
// Lead the player slightly
const leadPos = playerPos.clone().add(playerVel.clone().multiplyScalar(2));
targetDir = leadPos.sub(this.mesh.position).normalize();
// Attack when close enough and aligned
this.attackCooldown -= dt;
if (distToPlayer < 300 && this.attackCooldown <= 0) {
const toPlayer = playerPos.clone().sub(this.mesh.position).normalize();
const forward = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
if (forward.dot(toPlayer) > 0.85) {
this.attackCooldown = 2;
// Damage player (callback handled by game)
}
}
break;
case 'evade':
const awayFromPlayer = this.mesh.position.clone().sub(playerPos).normalize();
targetDir = awayFromPlayer;
break;
}
// Smooth turning toward target direction
const currentForward = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
const crossProduct = new THREE.Vector3().crossVectors(currentForward, targetDir);
const turnRate = Math.min(crossProduct.length(), dt * 2.5);
if (turnRate > 0.01) {
const axis = crossProduct.normalize();
const deltaQuat = new THREE.Quaternion().setFromAxisAngle(axis, turnRate);
this.quaternion.multiply(deltaQuat).normalize();
}
// Speed management
const speed = this.velocity.length();
const targetSpeed = 120 + Math.random() * 30;
const accel = (targetSpeed - speed) * dt * 0.5;
const forward = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
this.velocity.add(forward.multiplyScalar(accel));
// Altitude control
if (this.mesh.position.y < 100) {
this.velocity.y += dt * 20;
} else if (this.mesh.position.y > 800) {
this.velocity.y -= dt * 15;
}
// Move
this.mesh.position.add(this.velocity.clone().multiplyScalar(dt));
// Update mesh orientation
this.mesh.quaternion.copy(this.quaternion);
// Engine glow based on speed
if (this.mesh.userData.engineLight) {
const intensity = Math.min(speed / 200, 1) * 0.8;
this.mesh.userData.engineLight.intensity = intensity;
}
}
takeDamage(amount) {
this.health -= amount;
if (this.health <= 0 && this.alive) {
this.alive = false;
// Explosion effect handled by game
}
}
destroy() {
this.scene.remove(this.mesh);
this.mesh.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
}
}
// ── class: ExplosionEffect ──
class ExplosionEffect {
constructor(scene, position) {
this.particles = [];
this.alive = true;
// Create fireball particles
for (let i = 0; i < 30; i++) {
const size = 1 + Math.random() * 4;
const geo = new THREE.SphereGeometry(size, 6, 6);
const mat = new THREE.MeshBasicMaterial({
color: new THREE.Color().setHSL(0.05 + Math.random() * 0.1, 1, 0.5),
transparent: true, opacity: 1
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(position);
const vel = new THREE.Vector3(
(Math.random() - 0.5) * 40,
Math.random() * 30 + 10,
(Math.random() - 0.5) * 40
);
scene.add(mesh);
this.particles.push({ mesh, vel, life: 1 });
}
// Flash light
const flash = new THREE.PointLight(0xff6622, 5, 100);
flash.position.copy(position);
scene.add(flash);
this.flash = flash;
this.flashLife = 1;
}
update(dt) {
if (!this.alive) return;
// Update particles
for (const p of this.particles) {
p.life -= dt * 0.8;
p.vel.y -= dt * 9.81;
p.mesh.position.add(p.vel.clone().multiplyScalar(dt));
p.mesh.material.opacity = Math.max(0, p.life);
p.mesh.scale.multiplyScalar(1 + dt * 2);
if (p.life <= 0) {
p.mesh.visible = false;
}
}
// Flash decay
this.flashLife -= dt * 3;
this.flash.intensity = Math.max(0, this.flashLife * 5);
if (this.particles.every(p => p.life <= 0)) {
this.alive = false;
for (const p of this.particles) {
p.mesh.geometry.dispose();
p.mesh.material.dispose();
p.mesh.parent.remove(p.mesh);
}
this.flash.parent.remove(this.flash);
}
}
}
// ── class: Runway ──
class Runway {
constructor(scene) {
this.scene = scene;
this.group = new THREE.Group();
// Main runway surface
const rwGeo = new THREE.PlaneGeometry(40, 2000);
rwGeo.rotateX(-Math.PI / 2);
const rwMat = new THREE.MeshStandardMaterial({
color: 0x333333, roughness: 0.9, metalness: 0.1
});
this.runway = new THREE.Mesh(rwGeo, rwMat);
this.runway.position.y = 0.5;
this.group.add(this.runway);
// Runway markings - center line dashes
for (let z = -950; z < 950; z += 40) {
const dashGeo = new THREE.PlaneGeometry(1, 20);
dashGeo.rotateX(-Math.PI / 2);
const dashMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
const dash = new THREE.Mesh(dashGeo, dashMat);
dash.position.set(0, 0.6, z);
this.group.add(dash);
}
// Edge lines
for (let side of [-18, 18]) {
const edgeGeo = new THREE.PlaneGeometry(1, 2000);
edgeGeo.rotateX(-Math.PI / 2);
const edgeMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
const edge = new THREE.Mesh(edgeGeo, edgeMat);
edge.position.set(side, 0.6, 0);
this.group.add(edge);
}
// Threshold markings
for (let z of [-980, 980]) {
for (let x = -12; x <= 12; x += 4) {
const tGeo = new THREE.PlaneGeometry(2, 30);
tGeo.rotateX(-Math.PI / 2);
const tMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
const tMesh = new THREE.Mesh(tGeo, tMat);
tMesh.position.set(x, 0.6, z);
this.group.add(tMesh);
}
}
// Runway lights (emissive)
for (let side of [-20, 20]) {
for (let z = -980; z <= 980; z += 30) {
const lightGeo = new THREE.SphereGeometry(0.3, 6, 6);
const lightMat = new THREE.MeshBasicMaterial({ color: 0x44ff44 });
const lightMesh = new THREE.Mesh(lightGeo, lightMat);
lightMesh.position.set(side, 1, z);
this.group.add(lightMesh);
}
}
// PAPI lights (approach slope indicators)
for (let i = 0; i < 4; i++) {
const pGeo = new THREE.SphereGeometry(0.5, 6, 6);
const pMat = new THREE.MeshBasicMaterial({ color: i < 2 ? 0xff0000 : 0xffffff });
const pMesh = new THREE.Mesh(pGeo, pMat);
pMesh.position.set(-25, 3, -980);
this.group.add(pMesh);
}
// Landing zone indicator (for scoring)
const lzGeo = new THREE.PlaneGeometry(30, 100);
lzGeo.rotateX(-Math.PI / 2);
const lzMat = new THREE.MeshBasicMaterial({
color: 0x44ffaa, transparent: true, opacity: 0.15
});
this.landingZone = new THREE.Mesh(lzGeo, lzMat);
this.landingZone.position.set(0, 0.7, -200);
this.group.add(this.landingZone);
// Touchdown zone markings
for (let x of [-8, 0, 8]) {
const tzGeo = new THREE.PlaneGeometry(4, 60);
tzGeo.rotateX(-Math.PI / 2);
const tzMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
const tzMesh = new THREE.Mesh(tzGeo, tzMat);
tzMesh.position.set(x, 0.65, -200);
this.group.add(tzMesh);
}
scene.add(this.group);
}
isInLandingZone(pos) {
return Math.abs(pos.x) < 18 && pos.z > -250 && pos.z < -150;
}
destroy() {
this.scene.remove(this.group);
this.group.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
}
}
// ── class: Game ──
class Game {
constructor() {
this.state = 'menu'; // menu, playing, paused, crashed
this.selectedAircraft = null;
this.selectedScenario = null;
// Three.js core
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 1, 20000);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
// Game objects
this.physics = null;
this.aircraftMesh = null;
this.terrainNoise = null;
this.terrainRenderer = null;
this.audio = null;
this.hud = null;
this.runway = null;
this.enemies = [];
this.explosions = [];
this.contrails = null;
// Input state
this.keys = {};
// Camera
this.cameraMode = 'chase'; // chase, cockpit, free
this.cameraOffset = new THREE.Vector3(-25, 8, 0);
this.cameraSmoothness = 0.06;
// Mission state
this.missionScore = 0;
this.playerHealth = 100;
this.enemiesDestroyed = 0;
this.landingGrade = null;
this.messageTimer = 0;
// Time tracking
this.lastTime = 0;
this.elapsed = 0;
// Expose for HUD
window._gameHealth = 100;
window._gameScore = 0;
this._initRenderer();
this._setupScene();
this._setupInput();
}
_initRenderer() {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.0;
document.getElementById('hud').appendChild(this.renderer.domElement);
}
_setupScene() {
// Sky gradient using a large sphere with improved shader
const skyGeo = new THREE.SphereGeometry(9000, 32, 32);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x0a1a4a) },
midColor: { value: new THREE.Color(0x3366aa) },
bottomColor: { value: new THREE.Color(0xddeeff) },
offset: { value: 20 },
exponent: { value: 0.5 }
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPos.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 midColor;
uniform vec3 bottomColor;
uniform float offset;
uniform float exponent;
varying vec3 vWorldPosition;
void main() {
float h = normalize(vWorldPosition + offset).y;
vec3 color;
if (h > 0.0) {
color = mix(midColor, topColor, pow(h, exponent));
} else {
color = mix(bottomColor, midColor, pow(-h * 2.0, exponent));
}
gl_FragColor = vec4(color, 1.0);
}
`,
side: THREE.BackSide
});
this.scene.add(new THREE.Mesh(skyGeo, skyMat));
// Fog for depth - color matches horizon
this.scene.fog = new THREE.FogExp2(0xaabbcc, 0.00012);
// Lighting
const ambientLight = new THREE.AmbientLight(0x5577bb, 0.5);
this.scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffeedd, 1.3);
sunLight.position.set(500, 800, 300);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 10;
sunLight.shadow.camera.far = 3000;
sunLight.shadow.camera.left = -500;
sunLight.shadow.camera.right = 500;
sunLight.shadow.camera.top = 500;
sunLight.shadow.camera.bottom = -500;
this.scene.add(sunLight);
const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x445522, 0.35);
this.scene.add(hemiLight);
// Clouds layer
this._createClouds();
}
_createClouds() {
const cloudGroup = new THREE.Group();
for (let i = 0; i < 60; i++) {
const size = 50 + Math.random() * 150;
const geo = new THREE.SphereGeometry(size, 8, 6);
const mat = new THREE.MeshStandardMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.4 + Math.random() * 0.3,
roughness: 1,
metalness: 0
});
const cloud = new THREE.Mesh(geo, mat);
cloud.position.set(
(Math.random() - 0.5) * 8000,
400 + Math.random() * 600,
(Math.random() - 0.5) * 8000
);
cloud.scale.y = 0.3;
cloudGroup.add(cloud);
}
this.scene.add(cloudGroup);
this.clouds = cloudGroup;
}
_setupInput() {
window.addEventListener('keydown', e => {
this.keys[e.code] = true;
if (e.code === 'Tab') {
e.preventDefault();
if (this.hud) this.hud.toggle();
}
if (e.code === 'KeyR' && this.state === 'playing') {
this._resetAircraft();
}
});
window.addEventListener('keyup', e => {
this.keys[e.code] = false;
});
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
if (this.hud) this.hud.resize();
});
}
_cleanup() {
// Clean up previous game state before starting new one
if (this.aircraftMesh) {
this.scene.remove(this.aircraftMesh);
this.aircraftMesh.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
this.aircraftMesh = null;
}
if (this.terrainRenderer) {
this.terrainRenderer.dispose();
this.terrainRenderer = null;
}
if (this.runway) {
this.runway.destroy();
this.runway = null;
}
for (const e of this.enemies) e.destroy();
this.enemies = [];
this.explosions.forEach(e => { try { e.alive = false; } catch(ex){} });
this.explosions = [];
if (this.contrails) {
this.contrails.dispose();
this.contrails = null;
}
if (this.audio) {
this.audio.stop();
this.audio = null;
}
}
start(aircraftType, scenario) {
// Allow re-launching - always clean up first
this._cleanup();
this.selectedAircraft = aircraftType;
this.selectedScenario = scenario;
const acData = AIRCRAFT_DATA[aircraftType];
// Initialize physics
this.physics = new FlightPhysics(acData);
// Create terrain
this.terrainNoise = new TerrainNoise(Math.random() * 10000);
this.terrainRenderer = new TerrainRenderer(this.scene, this.terrainNoise);
// Create aircraft mesh
const acModel = AircraftModel.create(aircraftType, acData.color, acData.accentColor);
this.aircraftMesh = acModel;
this.scene.add(acModel);
// Contrails
this.contrails = new ContrailSystem(this.scene);
// Initialize audio
this.audio = new FlightAudio();
this.audio.init();
// Initialize HUD
const hudCanvas = document.getElementById('hud-canvas');
this.hud = new HUD(hudCanvas);
if (scenario === 'takeoff') {
this._setupTakeoffScenario();
} else {
this._setupDogfightScenario();
}
// Switch UI
document.getElementById('menu').style.display = 'none';
document.getElementById('hud').style.display = 'block';
document.getElementById('crosshair').style.display = 'block';
this.state = 'playing';
this.lastTime = performance.now();
window._gameHealth = 100;
window._gameScore = 0;
this._showMessage(scenario === 'takeoff' ? 'Takeoff & Landing - Clear for takeoff!' : 'Dogfight - Hostiles detected!');
}
_setupTakeoffScenario() {
// Place aircraft on runway
this.physics.reset(0, 2, 500, Math.PI); // facing negative Z (down runway)
// Create runway
this.runway = new Runway(this.scene);
this.missionScore = 0;
this.playerHealth = 100;
}
_setupDogfightScenario() {
// Place aircraft in the air
this.physics.reset(0, 400, 0, 0);
this.physics.velocity.set(80, 0, 50);
// Spawn enemies
for (let i = 0; i < 3; i++) {
const angle = (i / 3) * Math.PI * 2 + Math.random();
const dist = 600 + Math.random() * 400;
const pos = new THREE.Vector3(
Math.cos(angle) * dist,
300 + Math.random() * 300,
Math.sin(angle) * dist
);
this.enemies.push(new EnemyAircraft(this.scene, pos));
}
this.missionScore = 0;
this.playerHealth = 100;
this.enemiesDestroyed = 0;
}
_resetAircraft() {
if (this.selectedScenario === 'takeoff') {
this.physics.reset(0, 2, 500, Math.PI);
} else {
this.physics.reset(0, 400, 0, 0);
this.physics.velocity.set(80, 0, 50);
}
this.playerHealth = 100;
window._gameHealth = 100;
this._showMessage('Position reset');
}
_showMessage(text) {
const overlay = document.getElementById('message-overlay');
overlay.textContent = text;
overlay.style.opacity = '1';
this.messageTimer = 3;
}
update() {
if (this.state !== 'playing') return;
const now = performance.now();
let dt = (now - this.lastTime) / 1000;
dt = Math.min(dt, 0.05);
this.lastTime = now;
this.elapsed += dt;
// --- Input processing ---
if (this.keys['KeyW']) this.physics.pitchInput = Math.max(-1, this.physics.pitchInput - dt * 3);
else if (this.keys['KeyS']) this.physics.pitchInput = Math.min(1, this.physics.pitchInput + dt * 3);
else this.physics.pitchInput *= Math.pow(0.05, dt);
if (this.keys['KeyA']) this.physics.rollInput = Math.max(-1, this.physics.rollInput - dt * 3);
else if (this.keys['KeyD']) this.physics.rollInput = Math.min(1, this.physics.rollInput + dt * 3);
else this.physics.rollInput *= Math.pow(0.05, dt);
if (this.keys['KeyQ']) this.physics.yawInput = Math.max(-1, this.physics.yawInput - dt * 2);
else if (this.keys['KeyE']) this.physics.yawInput = Math.min(1, this.physics.yawInput + dt * 2);
else this.physics.yawInput *= Math.pow(0.05, dt);
if (this.keys['ShiftLeft'] || this.keys['ShiftRight']) {
this.physics.throttleTarget = Math.min(1, this.physics.throttleTarget + dt * 0.8);
}
if (this.keys['ControlLeft'] || this.keys['ControlRight']) {
this.physics.throttleTarget = Math.max(0, this.physics.throttleTarget - dt * 0.8);
}
this.physics.brakes = !!this.keys['Space'];
// --- Physics update ---
this.physics.update(dt);
// --- Update aircraft mesh ---
if (this.aircraftMesh) {
this.aircraftMesh.position.copy(this.physics.position);
this.aircraftMesh.quaternion.copy(this.physics.quaternion);
// Engine glow based on throttle
if (this.aircraftMesh.userData.engineLight) {
const intensity = this.physics.throttleTarget * 1.5;
this.aircraftMesh.userData.engineLight.intensity = intensity;
}
}
// --- Contrails update ---
if (this.contrails && this.aircraftMesh) {
this.contrails.addPoint(this.physics.position);
this.contrails.update(dt, this.physics.altitude);
}
// --- Terrain update ---
if (this.terrainRenderer) {
this.terrainRenderer.update(this.physics.position.x, this.physics.position.z);
}
// --- Ground collision check ---
const terrainHeight = this.terrainNoise ? this.terrainNoise.getHeight(
this.physics.position.x, this.physics.position.z
) : 0;
if (this.physics.position.y < terrainHeight + 2 && this.state === 'playing') {
// Crash into terrain
if (this.physics.velocity.length() > 15) {
this._crash('Terrain collision!');
} else {
this.physics.position.y = terrainHeight + 2;
this.physics.velocity.y = Math.max(0, this.physics.velocity.y);
}
}
// --- Camera update ---
this._updateCamera(dt);
// --- Mission-specific updates ---
if (this.selectedScenario === 'dogfight') {
this._updateDogfight(dt);
} else {
this._updateTakeoffMission(dt);
}
// --- Audio update ---
if (this.audio) {
this.audio.update(
this.physics.throttleTarget,
this.physics.airspeed,
this.physics.altitude,
this.physics.isStalled,
dt
);
}
// --- Message timer ---
if (this.messageTimer > 0) {
this.messageTimer -= dt;
if (this.messageTimer <= 0) {
document.getElementById('message-overlay').style.opacity = '0';
}
}
// --- HUD update ---
if (this.hud) {
window._gameHealth = this.playerHealth;
window._gameScore = this.missionScore;
const aliveEnemies = this.selectedScenario === 'dogfight' ? this.enemies : null;
this.hud.draw(this.physics, this.physics.throttleTarget, this.selectedScenario, aliveEnemies);
}
// --- Explosion effects ---
for (let i = this.explosions.length - 1; i >= 0; i--) {
this.explosions[i].update(dt);
if (!this.explosions[i].alive) {
this.explosions.splice(i, 1);
}
}
// --- Clouds slow drift ---
if (this.clouds) {
this.clouds.position.x += dt * 2;
}
}
_updateCamera(dt) {
const pos = this.physics.position;
const quat = this.physics.quaternion;
// Calculate chase camera position behind aircraft
const backDir = new THREE.Vector3(-1, 0, 0).applyQuaternion(quat);
const upDir = new THREE.Vector3(0, 1, 0).applyQuaternion(quat);
const desiredPos = pos.clone()
.add(backDir.multiplyScalar(this.cameraOffset.x))
.add(upDir.multiplyScalar(this.cameraOffset.y));
// Smooth camera follow
this.camera.position.lerp(desiredPos, Math.min(1, this.cameraSmoothness + dt * 2));
// Look at aircraft (slightly ahead)
const lookTarget = pos.clone().add(
new THREE.Vector3(1, 0, 0).applyQuaternion(quat).multiplyScalar(20)
);
this.camera.lookAt(lookTarget);
}
_updateDogfight(dt) {
// Update enemies
for (const enemy of this.enemies) {
if (!enemy.alive) continue;
enemy.update(dt, this.physics.position, this.physics.velocity);
// Check if player is shooting (F key)
const dist = this.physics.position.distanceTo(enemy.mesh.position);
if (dist < 200 && this.keys['KeyF']) {
const toEnemy = enemy.mesh.position.clone().sub(this.physics.position).normalize();
const forward = new THREE.Vector3(1, 0, 0).applyQuaternion(this.physics.quaternion);
if (forward.dot(toEnemy) > 0.85) {
this.missionScore += 1;
// Check hit
enemy.takeDamage(25 + Math.random() * 15);
if (!enemy.alive) {
this.enemiesDestroyed++;
this.explosions.push(new ExplosionEffect(this.scene, enemy.mesh.position.clone()));
enemy.destroy();
this.missionScore += 50;
this._showMessage(`Enemy destroyed! Score: ${this.missionScore}`);
} else {
// Hit spark effect
this.explosions.push(new ExplosionEffect(this.scene,
enemy.mesh.position.clone().add(new THREE.Vector3(0, 1, 0))));
}
}
}
// Enemy damage to player when very close
if (dist < 50) {
this.playerHealth -= dt * 20;
if (this.playerHealth <= 0) {
this._crash('Shot down!');
}
}
}
// Check mission complete
if (this.enemies.every(e => !e.alive)) {
this.missionScore += Math.round(this.playerHealth);
this._showMessage(`Mission Complete! Final Score: ${this.missionScore}`);
}
}
_updateTakeoffMission(dt) {
// Landing evaluation
if (this.runway && this.physics.position.y < 5 &&
Math.abs(this.physics.velocity.y) < 10 &&
this.runway.isInLandingZone(this.physics.position)) {
const speed = this.physics.airspeed;
const vsi = Math.abs(this.physics.verticalSpeed);
if (speed < 60 && vsi < 5) {
let grade = 'A';
let score = 100;
if (vsi > 3 || speed > 50) { grade = 'B'; score = 80; }
if (vsi > 6 || speed > 70) { grade = 'C'; score = 60; }
if (vsi > 10 || speed > 90) { grade = 'D'; score = 40; }
this.landingGrade = grade;
this.missionScore = score;
this._showMessage(`Landing Grade: ${grade} - Score: ${score}`);
}
}
// Takeoff bonus
if (this.physics.altitude > 100 && this.missionScore === 0) {
this.missionScore = 25;
this._showMessage('Takeoff successful!');
}
}
_crash(reason) {
this.state = 'crashed';
// Create explosion at aircraft position
if (this.aircraftMesh) {
this.explosions.push(new ExplosionEffect(this.scene, this.physics.position.clone()));
this.aircraftMesh.visible = false;
}
this._showMessage(`CRASHED: ${reason}`);
// Allow restart after delay
setTimeout(() => {
if (this.state === 'crashed') {
this._returnToMenu();
}
}, 5000);
}
_returnToMenu() {
this.state = 'menu';
// Cleanup
if (this.aircraftMesh) this.scene.remove(this.aircraftMesh);
if (this.terrainRenderer) this.terrainRenderer.dispose();
if (this.runway) this.runway.destroy();
for (const e of this.enemies) e.destroy();
this.enemies = [];
this.explosions.forEach(e => { try { e.alive = false; } catch(ex){} });
this.explosions = [];
if (this.contrails) this.contrails.dispose();
if (this.audio) this.audio.stop();
document.getElementById('menu').style.display = 'flex';
document.getElementById('hud').style.display = 'none';
document.getElementById('crosshair').style.display = 'none';
}
render() {
this.renderer.render(this.scene, this.camera);
}
}
// ── class: ContrailSystem ──
class ContrailSystem {
constructor(scene) {
this.scene = scene;
this.trails = []; // Array of trail points per engine
this.maxTrailLength = 200;
this.alive = true;
// Create a line geometry that we'll update each frame
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(this.maxTrailLength * 3);
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.4
});
this.line = new THREE.Line(geo, mat);
this.line.frustumCulled = false; // Always render even if far away
scene.add(this.line);
this.trailPoints = [];
}
addPoint(position) {
this.trailPoints.push({
pos: position.clone(),
age: 0,
opacity: 1
});
if (this.trailPoints.length > this.maxTrailLength) {
this.trailPoints.shift();
}
}
update(dt, altitude) {
// Contrails only form at higher altitudes where air is cold enough
const contrailAltitude = 200; // meters
if (altitude > contrailAltitude) {
this.trailPoints.forEach(p => p.age += dt);
// Fade old points
for (let i = this.trailPoints.length - 1; i >= 0; i--) {
const p = this.trailPoints[i];
p.opacity = Math.max(0, 1 - p.age / 8);
if (p.opacity <= 0) {
this.trailPoints.splice(i, 1);
}
}
} else {
// Fade out when below contrail altitude
for (let i = this.trailPoints.length - 1; i >= 0; i--) {
const p = this.trailPoints[i];
p.opacity -= dt * 2;
if (p.opacity <= 0) {
this.trailPoints.splice(i, 1);
}
}
}
// Update line geometry
const positions = this.line.geometry.attributes.position.array;
for (let i = 0; i < this.maxTrailLength; i++) {
if (i < this.trailPoints.length) {
positions[i * 3] = this.trailPoints[i].pos.x;
positions[i * 3 + 1] = this.trailPoints[i].pos.y;
positions[i * 3 + 2] = this.trailPoints[i].pos.z;
} else {
positions[i * 3] = 0;
positions[i * 3 + 1] = -9999; // Hide unused vertices
positions[i * 3 + 2] = 0;
}
}
this.line.geometry.attributes.position.needsUpdate = true;
this.line.material.opacity = 0.4;
}
dispose() {
this.scene.remove(this.line);
this.line.geometry.dispose();
this.line.material.dispose();
}
}
// ── main ──
(function() {
const game = new Game();
// Expose for testing/debugging
window.gameRef = game;
// Build aircraft selection cards
const acSelect = document.getElementById('aircraft-select');
Object.entries(AIRCRAFT_DATA).forEach(([key, data]) => {
const card = document.createElement('div');
card.className = 'aircraft-card';
card.dataset.type = key;
card.innerHTML = `
<h3>${data.name}</h3>
<p>${data.description}</p>
<p style="margin-top:8px;">Speed</p><div class="stat-bar"><div class="stat-fill" style="width:${data.stats.speed}%"></div></div>
<p>Agility</p><div class="stat-bar"><div class="stat-fill" style="width:${data.stats.agility}%"></div></div>
<p>Power</p><div class="stat-bar"><div class="stat-fill" style="width:${data.stats.power}%"></div></div>
`;
card.addEventListener('click', () => {
document.querySelectorAll('.aircraft-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
checkReady();
});
acSelect.appendChild(card);
});
// Build scenario selection cards
const scSelect = document.getElementById('scenario-select');
Object.entries(SCENARIOS).forEach(([key, data]) => {
const card = document.createElement('div');
card.className = 'scenario-card';
card.dataset.scenario = key;
card.innerHTML = `<strong>${data.name}</strong><br><small style="color:#99aabb">${data.description}</small>`;
card.addEventListener('click', () => {
document.querySelectorAll('.scenario-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
checkReady();
});
scSelect.appendChild(card);
});
let selectedAC = null, selectedSC = null;
function checkReady() {
const acCard = document.querySelector('.aircraft-card.selected');
const scCard = document.querySelector('.scenario-card.selected');
selectedAC = acCard ? acCard.dataset.type : null;
selectedSC = scCard ? scCard.dataset.scenario : null;
document.getElementById('start-btn').disabled = !(selectedAC && selectedSC);
}
// Start button
document.getElementById('start-btn').addEventListener('click', () => {
if (selectedAC && selectedSC) {
game.start(selectedAC, selectedSC);
}
});
// Game loop
function loop() {
requestAnimationFrame(loop);
game.update();
game.render();
}
loop();
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"composer","sessionId":"300489a3-a92c-4ced-a4c6-5fc4cd1df0a1","tokensIn":2546106,"tokensOut":61671,"tokensTotal":2607777,"turns":55,"toolCalls":56,"failedToolCalls":0,"timestamp":"2026-07-18T15:24:09.282Z"} -->