flight-simulator / src /main.js
underrate's picture
Upload 4 files
88edf0d verified
Raw
History Blame Contribute Delete
50.5 kB
import * as THREE from 'three';
import { Aircraft } from './Aircraft.js';
class FlightSimulator {
constructor() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 3200);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.clock = new THREE.Clock();
this.aircraft = new Aircraft();
this.clouds = null;
this.sunLight = null;
this.sunMesh = null;
this.cloudWrapX = 1200;
this.windVector = new THREE.Vector3(1, 0, 0.18).normalize();
this.cameraLookTarget = new THREE.Vector3();
this.cameraFollowSharpness = 4.6;
this.cameraLookSharpness = 6.4;
this.fpsEstimate = 60;
this.activeKeys = new Set();
this.isPaused = false;
this.hasLoaded = false;
this.miniMapCtx = null;
this.touchActive = {};
this.lowAltitudeThreshold = 50;
this.keyElements = {
w: document.getElementById('key-w'),
s: document.getElementById('key-s'),
a: document.getElementById('key-a'),
d: document.getElementById('key-d'),
q: document.getElementById('key-q'),
e: document.getElementById('key-e'),
shift: document.getElementById('key-shift'),
arrowup: document.getElementById('key-arrowup'),
arrowdown: document.getElementById('key-arrowdown')
};
this.hud = {
speed: document.getElementById('hud-speed'),
altitude: document.getElementById('hud-altitude'),
heading: document.getElementById('hud-heading'),
pitch: document.getElementById('hud-pitch'),
roll: document.getElementById('hud-roll'),
throttle: document.getElementById('hud-throttle'),
fps: document.getElementById('hud-fps'),
horizon: document.getElementById('horizon-ladder'),
throttleBar: document.getElementById('hud-throttle-bar'),
engineState: document.getElementById('hud-engine-state'),
flightStatus: document.getElementById('flight-status'),
compassTape: document.getElementById('compass-tape'),
pitchMarkers: document.getElementById('pitch-markers'),
altitudeWarning: document.getElementById('altitude-warning'),
headingReadout: document.getElementById('heading-readout'),
speedReadout: document.getElementById('speed-readout'),
altReadout: document.getElementById('alt-readout'),
speedScale: document.getElementById('speed-scale'),
altScale: document.getElementById('alt-scale')
};
this.hasHud = !!(this.hud.speed && this.hud.altitude && this.hud.heading && this.hud.horizon);
this.init();
}
init() {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.renderer.setClearColor(0x1a0f2e, 1);
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.15;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('game-container').appendChild(this.renderer.domElement);
this.camera.position.set(0, 10, 20);
this.camera.lookAt(0, 0, 0);
// Support dynamic lighting
this.hemisphereLight = new THREE.HemisphereLight(0x5a4a8a, 0x0f1b29, 0.65);
this.scene.add(this.hemisphereLight);
const directionalLight = new THREE.DirectionalLight(0xff7733, 2.2);
directionalLight.position.set(600, 150, -800);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 10;
directionalLight.shadow.camera.far = 3000;
directionalLight.shadow.camera.left = -1000;
directionalLight.shadow.camera.right = 1000;
directionalLight.shadow.camera.top = 1000;
directionalLight.shadow.camera.bottom = -1000;
directionalLight.shadow.bias = -0.001;
this.scene.add(directionalLight);
const fillLight = new THREE.DirectionalLight(0x443377, 1.2);
fillLight.position.set(-400, 300, 400);
this.scene.add(fillLight);
this.sunLight = directionalLight;
this.createSky();
this.scene.fog = new THREE.FogExp2(0x25143a, 0.0006); // Rich atmospheric dark purple/magenta fog
this.createTerrain();
this.createDistantMountains();
this.createForest();
this.createLake();
this.createSun(directionalLight.position);
this.createClouds();
this.scene.add(this.aircraft.getMesh());
this.cameraLookTarget.copy(this.aircraft.position);
this.setupInputIndicators();
this.setupCompass();
this.setupUIButtons();
this.setupMiniMap();
this.setupTouchControls();
this.setupBankMarks();
this.updateHud(1 / 60);
// Hide loading screen after initialization
setTimeout(() => {
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay) {
loadingOverlay.classList.add('hidden');
this.hasLoaded = true;
}
}, 800);
window.addEventListener('resize', this.onWindowResize.bind(this));
this.animate();
}
setupInputIndicators() {
window.addEventListener('keydown', (event) => this.handleKeyHighlight(event, true));
window.addEventListener('keyup', (event) => this.handleKeyHighlight(event, false));
window.addEventListener('blur', () => {
this.activeKeys.clear();
this.renderInputHighlights();
});
}
handleKeyHighlight(event, isPressed) {
let key = event.key;
// Map special keys to our lookup names
if (key === 'Shift') key = 'shift';
else if (key === 'ArrowUp') key = 'arrowup';
else if (key === 'ArrowDown') key = 'arrowdown';
else key = key.toLowerCase();
if (!Object.prototype.hasOwnProperty.call(this.keyElements, key)) return;
if (isPressed) {
this.activeKeys.add(key);
} else {
this.activeKeys.delete(key);
}
this.renderInputHighlights();
}
renderInputHighlights() {
Object.entries(this.keyElements).forEach(([key, element]) => {
if (!element) return;
element.classList.toggle('active', this.activeKeys.has(key));
});
}
setupCompass() {
if (!this.hud.compassTape) return;
const directions = ['N', '30', '60', 'E', '120', '150', 'S', '210', '240', 'W', '300', '330'];
const tape = this.hud.compassTape;
// Triple the tape for seamless wrapping at all headings
for (let rep = 0; rep < 3; rep++) {
directions.forEach((dir) => {
const mark = document.createElement('span');
mark.className = 'compass-marking';
if (dir === 'N' || dir === 'E' || dir === 'S' || dir === 'W') {
mark.classList.add('major');
if (dir === 'N') mark.classList.add('north');
}
mark.textContent = dir;
tape.appendChild(mark);
});
}
}
updateCompass(heading) {
if (!this.hud.compassTape) return;
// 12 marks per revolution × 28px each = 336px per 360°
const pxPerDeg = (12 * 28) / 360;
const containerWidth = this.hud.compassTape.parentElement?.clientWidth || 300;
const offset = -(heading * pxPerDeg) - (12 * 28) + containerWidth / 2;
this.hud.compassTape.style.transform = `translateX(${offset}px)`;
}
setupUIButtons() {
// Fullscreen toggle
const fullscreenBtn = document.getElementById('btn-fullscreen');
if (fullscreenBtn) {
fullscreenBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(console.error);
} else {
document.exitFullscreen();
}
});
}
// Pause menu
const menuBtn = document.getElementById('btn-menu');
const pauseOverlay = document.getElementById('pause-overlay');
const resumeBtn = document.getElementById('btn-resume');
const restartBtn = document.getElementById('btn-restart');
if (menuBtn && pauseOverlay) {
menuBtn.addEventListener('click', () => this.togglePause());
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.togglePause();
});
}
if (resumeBtn) {
resumeBtn.addEventListener('click', () => this.togglePause());
}
if (restartBtn) {
restartBtn.addEventListener('click', () => {
this.aircraft.position.set(0, 200, 0);
this.aircraft.velocity.set(0, 0, -80);
this.aircraft.rotation.set(0, 0, 0);
this.aircraft.cruiseThrust = 0.65;
this.aircraft.boostThrust = 0;
this.togglePause();
});
}
}
setupBankMarks() {
const container = document.getElementById('bank-marks');
if (!container) return;
const angles = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60];
angles.forEach(angle => {
const mark = document.createElement('div');
mark.className = 'bank-mark' + (angle % 30 === 0 ? ' major' : '');
mark.style.transform = `rotate(${angle}deg)`;
container.appendChild(mark);
});
}
updateTapeScale(container, value, step, count) {
if (!container) return;
container.innerHTML = '';
const half = Math.floor(count / 2);
const baseVal = Math.round(value / step) * step;
for (let i = -half; i <= half; i++) {
const tickVal = baseVal + i * step;
if (tickVal < 0) continue;
const pct = 50 - (i / half) * 50;
const tick = document.createElement('div');
tick.className = 'tape-tick';
tick.style.top = `${pct}%`;
tick.innerHTML = `<span class="tick-label">${tickVal.toFixed(0)}</span><span class="tick-line"></span>`;
container.appendChild(tick);
}
}
togglePause() {
this.isPaused = !this.isPaused;
const pauseOverlay = document.getElementById('pause-overlay');
if (pauseOverlay) {
pauseOverlay.classList.toggle('visible', this.isPaused);
}
if (!this.isPaused) {
this.clock.start();
}
}
setupMiniMap() {
const canvas = document.getElementById('mini-map-canvas');
if (!canvas) return;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
this.miniMapCtx = canvas.getContext('2d');
}
updateMiniMap() {
if (!this.miniMapCtx) return;
const ctx = this.miniMapCtx;
const w = ctx.canvas.width;
const h = ctx.canvas.height;
const scale = 0.08;
// Clear
ctx.fillStyle = 'rgba(15, 45, 63, 0.8)';
ctx.fillRect(0, 0, w, h);
// Draw terrain as grid
ctx.strokeStyle = 'rgba(46, 229, 182, 0.2)';
ctx.lineWidth = 0.5;
const gridSize = 20;
for (let x = 0; x < w; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = 0; y < h; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// Draw aircraft (center)
const cx = w / 2;
const cy = h / 2;
// Aircraft direction indicator
const heading = this.aircraft.rotation.y;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-heading);
ctx.fillStyle = '#2ee5b6';
ctx.beginPath();
ctx.moveTo(0, -8);
ctx.lineTo(-5, 6);
ctx.lineTo(5, 6);
ctx.closePath();
ctx.fill();
ctx.restore();
// Draw compass indicator
ctx.fillStyle = '#e9f6ff';
ctx.font = '8px Chakra Petch';
ctx.textAlign = 'center';
const headingDeg = Math.round(THREE.MathUtils.euclideanModulo(THREE.MathUtils.radToDeg(heading), 360));
ctx.fillText(`${headingDeg}°`, cx, h - 4);
}
setupTouchControls() {
const touchButtons = {
'touch-pitch-up': { key: 'w', action: 'pitch' },
'touch-pitch-down': { key: 's', action: 'pitch' },
'touch-roll-left': { key: 'a', action: 'roll' },
'touch-roll-right': { key: 'd', action: 'roll' },
'touch-yaw-left': { key: 'q', action: 'yaw' },
'touch-yaw-right': { key: 'e', action: 'yaw' },
'touch-throttle': { key: 'shift', action: 'throttle' }
};
Object.entries(touchButtons).forEach(([id, config]) => {
const btn = document.getElementById(id);
if (!btn) return;
const handleStart = (e) => {
e.preventDefault();
btn.classList.add('active');
this.touchActive[config.key] = true;
this.activeKeys.add(config.key);
this.renderInputHighlights();
};
const handleEnd = (e) => {
e.preventDefault();
btn.classList.remove('active');
this.touchActive[config.key] = false;
this.activeKeys.delete(config.key);
this.renderInputHighlights();
};
btn.addEventListener('touchstart', handleStart, { passive: false });
btn.addEventListener('touchend', handleEnd, { passive: false });
btn.addEventListener('mousedown', handleStart);
btn.addEventListener('mouseup', handleEnd);
btn.addEventListener('mouseleave', handleEnd);
});
}
createTerrain() {
const size = 3200;
const segments = 300;
const geometry = new THREE.PlaneGeometry(size, size, segments, segments);
const position = geometry.attributes.position;
const colors = [];
// Richer, more varied biome colors
const deepWaterColor = new THREE.Color(0x0a1a24);
const shallowWaterColor = new THREE.Color(0x1a3a3a);
const sandColor = new THREE.Color(0x8a7a5a);
const lowlandColor = new THREE.Color(0x1a3520);
const grassColor = new THREE.Color(0x2d5a2d);
const forestColor = new THREE.Color(0x1f4228);
const highlandColor = new THREE.Color(0x4a5a40);
const rockyColor = new THREE.Color(0x5a5850);
const alpineColor = new THREE.Color(0x7a7870);
const snowColor = new THREE.Color(0xd8dee8);
const color = new THREE.Color();
const blend = new THREE.Color();
for (let i = 0; i < position.count; i++) {
const x = position.getX(i);
const y = position.getY(i);
const distanceToRunway = Math.sqrt(x * x + y * y);
const runwayProximity = 1 - THREE.MathUtils.smoothstep(distanceToRunway, 30, 210);
let height = this.getTerrainHeight(x, y);
height = THREE.MathUtils.lerp(height, 1.4, runwayProximity * 0.76);
position.setZ(i, height);
// Calculate slope for rock faces
const sampleStep = 6;
const slopeX = this.getTerrainHeight(x + sampleStep, y) - this.getTerrainHeight(x - sampleStep, y);
const slopeY = this.getTerrainHeight(x, y + sampleStep) - this.getTerrainHeight(x, y - sampleStep);
const slope = Math.abs(slopeX) + Math.abs(slopeY);
const slopeFactor = THREE.MathUtils.clamp(slope / 35, 0, 1);
// Add subtle color variation
const noiseVar = Math.sin(x * 0.018 + y * 0.012) * 0.5 + Math.cos(y * 0.024 + x * 0.016) * 0.5;
const variation = noiseVar * 0.025;
// Height-based biomes with smooth transitions
if (height < -8) {
color.copy(deepWaterColor);
} else if (height < -2) {
blend.lerpColors(deepWaterColor, shallowWaterColor, THREE.MathUtils.clamp((height + 8) / 6, 0, 1));
color.copy(blend);
} else if (height < 2) {
blend.lerpColors(shallowWaterColor, sandColor, THREE.MathUtils.clamp((height + 2) / 4, 0, 1));
color.copy(blend);
} else if (height < 8) {
blend.lerpColors(sandColor, grassColor, THREE.MathUtils.clamp((height - 2) / 6, 0, 1));
color.copy(blend);
} else if (height < 20) {
blend.lerpColors(grassColor, forestColor, THREE.MathUtils.clamp((height - 8) / 12, 0, 1));
color.copy(blend);
} else if (height < 40) {
blend.lerpColors(forestColor, highlandColor, THREE.MathUtils.clamp((height - 20) / 20, 0, 1));
color.copy(blend);
} else if (height < 70) {
blend.lerpColors(highlandColor, rockyColor, THREE.MathUtils.clamp((height - 40) / 30, 0, 1));
color.copy(blend);
} else if (height < 100) {
blend.lerpColors(rockyColor, alpineColor, THREE.MathUtils.clamp((height - 70) / 30, 0, 1));
color.copy(blend);
} else {
blend.lerpColors(alpineColor, snowColor, THREE.MathUtils.clamp((height - 100) / 40, 0, 1));
color.copy(blend);
}
// Blend in rock color on steep slopes
color.lerp(rockyColor, slopeFactor * 0.6);
color.offsetHSL(0, 0, variation);
colors.push(color.r, color.g, color.b);
}
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.85,
metalness: 0.05,
flatShading: true,
side: THREE.DoubleSide
});
const terrain = new THREE.Mesh(geometry, material);
terrain.rotation.x = -Math.PI / 2;
terrain.position.y = 0;
terrain.receiveShadow = true;
terrain.castShadow = true;
this.scene.add(terrain);
const runwayGeometry = new THREE.PlaneGeometry(240, 22);
const runwayMaterial = new THREE.MeshStandardMaterial({
color: 0x1f2226,
roughness: 0.7,
metalness: 0.2,
side: THREE.DoubleSide
});
const runway = new THREE.Mesh(runwayGeometry, runwayMaterial);
runway.rotation.x = -Math.PI / 2;
runway.position.set(0, 0.07, 0);
runway.receiveShadow = true;
this.scene.add(runway);
const shoulderGeometry = new THREE.PlaneGeometry(240, 28);
const shoulderMaterial = new THREE.MeshStandardMaterial({
color: 0x3d4647,
roughness: 0.9,
metalness: 0.05,
side: THREE.DoubleSide
});
const shoulder = new THREE.Mesh(shoulderGeometry, shoulderMaterial);
shoulder.rotation.x = -Math.PI / 2;
shoulder.position.set(0, 0.04, 0);
shoulder.receiveShadow = true;
this.scene.add(shoulder);
const stripeGeometry = new THREE.PlaneGeometry(170, 1.3);
const stripeMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.2,
metalness: 0.1,
emissive: 0x222222,
side: THREE.DoubleSide
});
const stripe = new THREE.Mesh(stripeGeometry, stripeMaterial);
stripe.rotation.x = -Math.PI / 2;
stripe.position.set(0, 0.1, 0);
stripe.receiveShadow = true;
this.scene.add(stripe);
for (let i = -95; i <= 95; i += 20) {
if (Math.abs(i) < 20) continue;
const marker = new THREE.Mesh(new THREE.PlaneGeometry(10, 1.15), stripeMaterial);
marker.rotation.x = -Math.PI / 2;
marker.position.set(i, 0.1, 0);
marker.receiveShadow = true;
this.scene.add(marker);
}
}
getTerrainHeight(x, y) {
// Large continental features - mountain ranges
const continental =
Math.sin(x * 0.0008) * 35 +
Math.cos(y * 0.00065) * 28 +
Math.sin((x * 0.0005 + y * 0.0004)) * 22;
// Medium ridgelines - create spine-like formations
const ridges =
Math.sin((x + y) * 0.0022) * 18 +
Math.cos((x - y) * 0.0018) * 14 +
Math.sin(x * 0.0035) * Math.cos(y * 0.0028) * 12;
// Smaller hills and valleys
const hills =
Math.sin(x * 0.008) * Math.cos(y * 0.006) * 8 +
Math.cos(y * 0.009) * Math.sin(x * 0.007) * 6;
// Fine detail and texture
const details =
Math.sin(x * 0.025) * Math.cos(y * 0.022) * 3 +
Math.sin((x + y) * 0.038) * 1.8 +
Math.cos(x * 0.042) * Math.sin(y * 0.035) * 1.2;
// Create valley near runway (smoothed area)
const distToRunway = Math.sqrt(x * x + y * y);
const valleyFactor = Math.max(0, 1 - distToRunway / 400);
const valleySmoothing = valleyFactor * valleyFactor * 30;
return continental + ridges + hills + details - valleySmoothing;
}
createSky() {
this.skyColorDusk = [new THREE.Color('#040714'), new THREE.Color('#10183b'), new THREE.Color('#40204d'), new THREE.Color('#bd3f3b'), new THREE.Color('#f7732a'), new THREE.Color('#ffc266')];
this.skyColorDay = [new THREE.Color('#1a5b9c'), new THREE.Color('#2b7cc2'), new THREE.Color('#4d9de0'), new THREE.Color('#8abbf0'), new THREE.Color('#b3d4f5'), new THREE.Color('#e6f2ff')];
this.skyColorNight = [new THREE.Color('#000000'), new THREE.Color('#030408'), new THREE.Color('#06070c'), new THREE.Color('#0a0c12'), new THREE.Color('#10121a'), new THREE.Color('#161824')];
this.skyCanvas = document.createElement('canvas');
this.skyCanvas.width = 2;
this.skyCanvas.height = 512;
this.skyCtx = this.skyCanvas.getContext('2d', { alpha: false });
this.skyTexture = new THREE.CanvasTexture(this.skyCanvas);
this.skyTexture.colorSpace = THREE.SRGBColorSpace;
this.scene.background = this.skyTexture;
// Stars as a separate particle system
const starGeo = new THREE.BufferGeometry();
const starPos = [];
for (let i = 0; i < 600; i++) {
const v = new THREE.Vector3(
(Math.random() - 0.5) * 2,
Math.random() * 0.8 + 0.2, // Only upper hemisphere
(Math.random() - 0.5) * 2
).normalize().multiplyScalar(1500);
starPos.push(v.x, v.y, v.z);
}
starGeo.setAttribute('position', new THREE.Float32BufferAttribute(starPos, 3));
const starMat = new THREE.PointsMaterial({ color: 0xffffff, size: 4, transparent: true, opacity: 1, sizeAttenuation: true, depthWrite: false });
this.stars = new THREE.Points(starGeo, starMat);
this.scene.add(this.stars);
}
createSun(position) {
const sunGeometry = new THREE.SphereGeometry(65, 32, 32);
const sunMaterial = new THREE.MeshBasicMaterial({ color: 0xffedd6 });
const sun = new THREE.Mesh(sunGeometry, sunMaterial);
sun.position.copy(position).setLength(1200);
this.sunMesh = sun;
this.scene.add(sun);
// Main glow
const haloGeometry = new THREE.SphereGeometry(180, 32, 32);
const haloMaterial = new THREE.MeshBasicMaterial({
color: 0xff3b00,
transparent: true,
opacity: 0.4,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const halo = new THREE.Mesh(haloGeometry, haloMaterial);
halo.position.copy(sun.position);
this.sunHalo = halo;
this.scene.add(halo);
// Secondary wide aura
const auraGeometry = new THREE.SphereGeometry(450, 32, 32);
const auraMaterial = new THREE.MeshBasicMaterial({
color: 0x990033,
transparent: true,
opacity: 0.15,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const aura = new THREE.Mesh(auraGeometry, auraMaterial);
aura.position.copy(sun.position);
this.sunAura = aura;
this.scene.add(aura);
}
updateTimeOfDay(time) {
// 0=midnight, 0.25=sunrise, 0.5=noon, 0.75=sunset
const cycleDuration = 420; // 7 minutes per cycle
// Start at mid-day (0.45)
const timeOffset = cycleDuration * 0.45;
const t = ((time + timeOffset) % cycleDuration) / cycleDuration;
let dayFactor = 0, duskFactor = 0, nightFactor = 0;
if (t < 0.25) {
let f = t / 0.25;
nightFactor = 1 - f; duskFactor = f;
} else if (t < 0.5) {
let f = (t - 0.25) / 0.25;
duskFactor = 1 - f; dayFactor = f;
} else if (t < 0.75) {
let f = (t - 0.5) / 0.25;
dayFactor = 1 - f; duskFactor = f;
} else {
let f = (t - 0.75) / 0.25;
duskFactor = 1 - f; nightFactor = f;
}
// Blend Sky Gradient
const grad = this.skyCtx.createLinearGradient(0, 0, 0, 512);
const stops = [0, 0.3, 0.5, 0.65, 0.8, 1];
for (let i = 0; i < 6; i++) {
const tempColor = new THREE.Color()
.copy(this.skyColorDay[i]).multiplyScalar(dayFactor)
.add(new THREE.Color().copy(this.skyColorDusk[i]).multiplyScalar(duskFactor))
.add(new THREE.Color().copy(this.skyColorNight[i]).multiplyScalar(nightFactor));
grad.addColorStop(stops[i], '#' + tempColor.getHexString());
}
this.skyCtx.fillStyle = grad;
this.skyCtx.fillRect(0, 0, 2, 512);
this.skyTexture.needsUpdate = true;
// Star fade
if (this.stars) {
this.stars.material.opacity = Math.max(0, nightFactor + duskFactor * 0.2);
}
// Blend Fog
const fogDay = new THREE.Color(0x8cb6db);
const fogDusk = new THREE.Color(0x25143a);
const fogNight = new THREE.Color(0x0a0c12);
this.scene.fog.color.copy(fogDay).multiplyScalar(dayFactor)
.add(new THREE.Color().copy(fogDusk).multiplyScalar(duskFactor))
.add(new THREE.Color().copy(fogNight).multiplyScalar(nightFactor));
// Blend HemisphereLight
if (this.hemisphereLight) {
this.hemisphereLight.color.copy(new THREE.Color(0xffffff).multiplyScalar(dayFactor)
.add(new THREE.Color(0x5a4a8a).multiplyScalar(duskFactor))
.add(new THREE.Color(0x111122).multiplyScalar(nightFactor)));
this.hemisphereLight.groundColor.copy(new THREE.Color(0x445544).multiplyScalar(dayFactor)
.add(new THREE.Color(0x0f1b29).multiplyScalar(duskFactor))
.add(new THREE.Color(0x05050a).multiplyScalar(nightFactor)));
}
// Orbit Sun Light & Positions
// Orbit around X-axis so it matches the terrain/runway orientation nicely
const angle = (t - 0.25) * Math.PI * 2;
if (this.sunLight && this.sunMesh) {
this.sunLight.position.set(0, Math.sin(angle) * 1200, Math.cos(angle) * 1200);
const el = Math.max(0, Math.sin(angle));
this.sunLight.intensity = el * 2.2;
const sunColorDay = new THREE.Color(0xffffe0);
const sunColorDusk = new THREE.Color(0xff5500);
this.sunLight.color.copy(sunColorDusk).lerp(sunColorDay, el);
// Update sun meshes and fade them out at night
this.sunMesh.position.copy(this.sunLight.position);
if (this.sunHalo) {
this.sunHalo.position.copy(this.sunLight.position);
this.sunHalo.material.opacity = el * 0.4;
}
if (this.sunAura) {
this.sunAura.position.copy(this.sunLight.position);
this.sunAura.material.opacity = el * 0.15;
}
}
}
createClouds() {
const cloudGroup = new THREE.Group();
const cloudMaterial = new THREE.MeshStandardMaterial({
color: 0xffe2d1,
roughness: 1.0,
metalness: 0,
emissive: 0x4a1835,
emissiveIntensity: 0.35,
flatShading: true
});
for (let i = 0; i < 26; i++) {
const cloud = new THREE.Group();
const puffCount = 4 + Math.floor(Math.random() * 4);
for (let j = 0; j < puffCount; j++) {
const puffGeometry = new THREE.SphereGeometry(7 + Math.random() * 10, 14, 12);
const puff = new THREE.Mesh(puffGeometry, cloudMaterial);
puff.position.set((Math.random() - 0.5) * 24, Math.random() * 7, (Math.random() - 0.5) * 18);
puff.scale.y = this.randomRange(0.58, 1.0);
puff.castShadow = true;
puff.receiveShadow = true;
cloud.add(puff);
}
cloud.position.set(
this.randomRange(-this.cloudWrapX, this.cloudWrapX),
this.randomRange(130, 260),
this.randomRange(-1200, 1200)
);
cloud.scale.setScalar(this.randomRange(0.7, 1.35));
cloud.userData.speed = this.randomRange(3.2, 7.6);
cloud.userData.phase = this.randomRange(0, Math.PI * 2);
cloud.userData.baseY = cloud.position.y;
cloudGroup.add(cloud);
}
this.clouds = cloudGroup;
this.scene.add(cloudGroup);
}
createDistantMountains() {
const mountainGroup = new THREE.Group();
// Varied rock materials for different mountain types
const darkRockMaterial = new THREE.MeshStandardMaterial({
color: 0x3a3540,
roughness: 0.92,
metalness: 0.03,
flatShading: true
});
const mediumRockMaterial = new THREE.MeshStandardMaterial({
color: 0x5a5560,
roughness: 0.88,
metalness: 0.04,
flatShading: true
});
const lightRockMaterial = new THREE.MeshStandardMaterial({
color: 0x7a7580,
roughness: 0.85,
metalness: 0.05,
flatShading: true
});
// Snow materials for different conditions
const freshSnowMaterial = new THREE.MeshStandardMaterial({
color: 0xf0f5fa,
roughness: 0.7,
metalness: 0.05,
flatShading: true
});
const oldSnowMaterial = new THREE.MeshStandardMaterial({
color: 0xd0d8e0,
roughness: 0.8,
metalness: 0.03,
flatShading: true
});
const foothillMaterial = new THREE.MeshStandardMaterial({
color: 0x3d4f3b,
roughness: 0.95,
metalness: 0.02,
flatShading: true
});
const screeMaterial = new THREE.MeshStandardMaterial({
color: 0x6a6860,
roughness: 0.98,
metalness: 0.01,
flatShading: true
});
// Helper to create jagged mountain geometry
const perturbGeometry = (geometry, intensity, preserveBase = true) => {
const posAttribute = geometry.attributes.position;
const vertex = new THREE.Vector3();
for (let i = 0; i < posAttribute.count; i++) {
vertex.fromBufferAttribute(posAttribute, i);
if (!preserveBase || vertex.y > -0.3 * geometry.parameters.height) {
vertex.x += (Math.random() - 0.5) * intensity;
vertex.z += (Math.random() - 0.5) * intensity;
vertex.y += (Math.random() - 0.5) * intensity * 0.4;
}
posAttribute.setXYZ(i, vertex.x, vertex.y, vertex.z);
}
geometry.computeVertexNormals();
return geometry;
};
// Major mountain peaks - dramatic and varied
for (let i = 0; i < 55; i++) {
const angle = this.randomRange(0, Math.PI * 2);
const radius = this.randomRange(1000, 1800);
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const ground = this.getTerrainHeight(x, z);
// Varied peak heights - some very tall, some medium
const peakHeight = this.randomRange(180, 520);
const baseRadius = peakHeight * this.randomRange(0.5, 0.95);
// Choose random rock material
const rockMats = [darkRockMaterial, mediumRockMaterial, lightRockMaterial];
const rockMat = rockMats[Math.floor(Math.random() * rockMats.length)];
// Create varied mountain shapes
let geom;
const shapeType = Math.random();
if (shapeType < 0.3) {
// Sharp spire
geom = new THREE.CylinderGeometry(0, baseRadius * 0.7, peakHeight, 6, 4);
geom = perturbGeometry(geom, baseRadius * 0.3);
} else if (shapeType < 0.6) {
// Classic cone
geom = new THREE.CylinderGeometry(0, baseRadius, peakHeight, 8, 5);
geom = perturbGeometry(geom, baseRadius * 0.22);
} else {
// Broad dome with jagged top
geom = new THREE.CylinderGeometry(baseRadius * 0.15, baseRadius * 1.2, peakHeight, 10, 6);
geom = perturbGeometry(geom, baseRadius * 0.35);
}
const mountain = new THREE.Mesh(geom, rockMat);
mountain.position.set(x, ground + peakHeight * 0.5, z);
mountain.rotation.y = this.randomRange(0, Math.PI * 2);
mountain.castShadow = true;
mountain.receiveShadow = true;
mountainGroup.add(mountain);
// Add snow cap for tall peaks
if (peakHeight > 300) {
const snowHeight = peakHeight * this.randomRange(0.25, 0.4);
const snowBase = baseRadius * this.randomRange(0.25, 0.4);
const snowMat = Math.random() > 0.5 ? freshSnowMaterial : oldSnowMaterial;
let capGeom;
if (Math.random() > 0.5) {
capGeom = new THREE.CylinderGeometry(0, snowBase, snowHeight, 7, 3);
} else {
capGeom = new THREE.SphereGeometry(snowBase, 8, 6, 0, Math.PI * 2, 0, Math.PI * 0.6);
}
capGeom = perturbGeometry(capGeom, snowBase * 0.15, false);
const cap = new THREE.Mesh(capGeom, snowMat);
cap.position.set(0, peakHeight * 0.5 - snowHeight * 0.4, 0);
cap.rotation.y = this.randomRange(0, Math.PI * 2);
mountain.add(cap);
}
// Add rocky outcrops on some mountains
if (Math.random() > 0.6) {
const outcropCount = Math.floor(this.randomRange(1, 4));
for (let j = 0; j < outcropCount; j++) {
const outcropGeom = new THREE.ConeGeometry(
baseRadius * this.randomRange(0.1, 0.25),
peakHeight * this.randomRange(0.15, 0.35),
5
);
perturbGeometry(outcropGeom, baseRadius * 0.1, false);
const outcrop = new THREE.Mesh(outcropGeom, darkRockMaterial);
outcrop.position.set(
this.randomRange(-baseRadius * 0.5, baseRadius * 0.5),
peakHeight * this.randomRange(0.1, 0.4),
this.randomRange(-baseRadius * 0.5, baseRadius * 0.5)
);
mountain.add(outcrop);
}
}
}
// Rocky foothills - varied shapes
for (let i = 0; i < 100; i++) {
const angle = this.randomRange(0, Math.PI * 2);
const radius = this.randomRange(600, 1400);
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const ground = this.getTerrainHeight(x, z);
const hillHeight = this.randomRange(50, 160);
const hillRadius = hillHeight * this.randomRange(1.0, 2.2);
let geom;
const hillType = Math.random();
if (hillType < 0.4) {
geom = new THREE.CylinderGeometry(0, hillRadius, hillHeight, 7, 3);
} else if (hillType < 0.7) {
geom = new THREE.DodecahedronGeometry(hillRadius * 0.6, 1);
geom.scale(1, hillHeight / (hillRadius * 0.6), 1);
} else {
geom = new THREE.ConeGeometry(hillRadius * 0.8, hillHeight, 6);
}
geom = perturbGeometry(geom, hillRadius * 0.12);
const hill = new THREE.Mesh(geom, Math.random() > 0.5 ? foothillMaterial : screeMaterial);
hill.position.set(x, ground + hillHeight * 0.4, z);
hill.rotation.y = this.randomRange(0, Math.PI * 2);
hill.castShadow = true;
hill.receiveShadow = true;
mountainGroup.add(hill);
}
// Small rocky outcrops scattered around
for (let i = 0; i < 80; i++) {
const angle = this.randomRange(0, Math.PI * 2);
const radius = this.randomRange(400, 1200);
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const ground = this.getTerrainHeight(x, z);
if (ground < 5) continue; // Skip water areas
const rockSize = this.randomRange(8, 25);
const geom = new THREE.DodecahedronGeometry(rockSize, 0);
perturbGeometry(geom, rockSize * 0.2, false);
const rock = new THREE.Mesh(geom, darkRockMaterial);
rock.position.set(x, ground + rockSize * 0.3, z);
rock.rotation.set(
this.randomRange(-0.3, 0.3),
this.randomRange(0, Math.PI * 2),
this.randomRange(-0.3, 0.3)
);
rock.scale.y = this.randomRange(0.5, 1.2);
rock.castShadow = true;
rock.receiveShadow = true;
mountainGroup.add(rock);
}
this.scene.add(mountainGroup);
}
createForest() {
const maxTrees = 500;
const trunkGeometry = new THREE.CylinderGeometry(0.12, 0.26, 2.2, 6);
const canopyGeometry = new THREE.ConeGeometry(1.15, 4.2, 8);
const broadCanopyGeometry = new THREE.SphereGeometry(2.2, 8, 6);
const trunkMaterial = new THREE.MeshStandardMaterial({
color: 0x3d2719,
roughness: 0.98,
metalness: 0,
flatShading: true
});
// Varied canopy colors
const canopyMaterials = [
new THREE.MeshStandardMaterial({ color: 0x1a4520, roughness: 0.93, metalness: 0.01, flatShading: true }),
new THREE.MeshStandardMaterial({ color: 0x2d5530, roughness: 0.93, metalness: 0.01, flatShading: true }),
new THREE.MeshStandardMaterial({ color: 0x1f3825, roughness: 0.93, metalness: 0.01, flatShading: true }),
new THREE.MeshStandardMaterial({ color: 0x3a6035, roughness: 0.93, metalness: 0.01, flatShading: true }),
];
const trunks = new THREE.InstancedMesh(trunkGeometry, trunkMaterial, maxTrees);
const canopies = new THREE.InstancedMesh(canopyGeometry, canopyMaterials[0], maxTrees);
const broadCanopies = new THREE.InstancedMesh(broadCanopyGeometry, canopyMaterials[1], maxTrees);
trunks.castShadow = true;
trunks.receiveShadow = true;
canopies.castShadow = true;
canopies.receiveShadow = true;
broadCanopies.castShadow = true;
broadCanopies.receiveShadow = true;
const dummy = new THREE.Object3D();
let placedConifers = 0;
let placedBroad = 0;
let attempts = 0;
while (placedConifers + placedBroad < maxTrees && attempts < maxTrees * 15) {
attempts += 1;
const x = this.randomRange(-1300, 1300);
const z = this.randomRange(-1300, 1300);
if (Math.sqrt(x * x + z * z) < 250) continue;
const height = this.getTerrainHeight(x, z);
if (height < 2 || height > 60) continue;
// Conifers prefer higher elevations, broadleaf prefer lower
const isConifer = height > 25 || Math.random() > 0.4;
const scale = this.randomRange(0.6, 1.8) * (isConifer ? 1.1 : 0.9);
if (isConifer) {
// Conifer tree
dummy.position.set(x, height + (2.2 * scale) / 2, z);
dummy.rotation.set(0, this.randomRange(0, Math.PI * 2), 0);
dummy.scale.set(scale, scale, scale);
dummy.updateMatrix();
trunks.setMatrixAt(placedConifers, dummy.matrix);
dummy.position.set(x, height + 2.6 * scale, z);
dummy.scale.set(scale * 1.1, scale * 1.2, scale * 1.1);
dummy.updateMatrix();
canopies.setMatrixAt(placedConifers, dummy.matrix);
placedConifers++;
} else {
// Broadleaf tree
dummy.position.set(x, height + (2.2 * scale) / 2, z);
dummy.rotation.set(0, this.randomRange(0, Math.PI * 2), 0);
dummy.scale.set(scale * 0.8, scale, scale * 0.8);
dummy.updateMatrix();
trunks.setMatrixAt(maxTrees - 1 - placedBroad, dummy.matrix);
dummy.position.set(x, height + 2.8 * scale, z);
dummy.scale.set(scale * 0.9, scale * 0.7, scale * 0.9);
dummy.updateMatrix();
broadCanopies.setMatrixAt(placedBroad, dummy.matrix);
placedBroad++;
}
}
trunks.count = placedConifers + placedBroad;
canopies.count = placedConifers;
broadCanopies.count = placedBroad;
trunks.instanceMatrix.needsUpdate = true;
canopies.instanceMatrix.needsUpdate = true;
broadCanopies.instanceMatrix.needsUpdate = true;
this.scene.add(trunks);
this.scene.add(canopies);
this.scene.add(broadCanopies);
}
createLake() {
// Find the lowest spot for the lake
let lowestSpot = { x: 400, z: -450, h: this.getTerrainHeight(400, -450) };
for (let i = 0; i < 300; i++) {
const x = this.randomRange(-1000, 1000);
const z = this.randomRange(-1000, 1000);
if (Math.sqrt(x * x + z * z) < 280) continue;
const h = this.getTerrainHeight(x, z);
if (h < lowestSpot.h) {
lowestSpot = { x, z, h };
}
}
const lakeRadius = 180;
const waterLevel = lowestSpot.h + 1.2;
// Main lake water with reflective material
const lakeMaterial = new THREE.MeshStandardMaterial({
color: 0x4a8090,
emissive: 0x1a3040,
emissiveIntensity: 0.15,
roughness: 0.08,
metalness: 0.9,
transparent: true,
opacity: 0.88,
flatShading: true
});
const lake = new THREE.Mesh(
new THREE.CircleGeometry(lakeRadius, 64),
lakeMaterial
);
lake.rotation.x = -Math.PI / 2;
lake.position.set(lowestSpot.x, waterLevel, lowestSpot.z);
lake.receiveShadow = true;
this.scene.add(lake);
// Lake shore with beach
const shore = new THREE.Mesh(
new THREE.RingGeometry(lakeRadius, lakeRadius + 18, 64),
new THREE.MeshStandardMaterial({
color: 0x6a7a5a,
roughness: 0.92,
metalness: 0,
flatShading: true
})
);
shore.rotation.x = -Math.PI / 2;
shore.position.set(lowestSpot.x, waterLevel - 0.02, lowestSpot.z);
shore.receiveShadow = true;
this.scene.add(shore);
// Create rivers flowing into the lake from higher elevations
const riverCount = 3;
const riverMaterial = new THREE.MeshStandardMaterial({
color: 0x3a7080,
roughness: 0.15,
metalness: 0.7,
transparent: true,
opacity: 0.85,
flatShading: true
});
for (let r = 0; r < riverCount; r++) {
// Start river from a random high point
const startAngle = (r / riverCount) * Math.PI * 2 + this.randomRange(-0.5, 0.5);
const startRadius = this.randomRange(600, 900);
let currentX = Math.cos(startAngle) * startRadius;
let currentZ = Math.sin(startAngle) * startRadius;
// Flow towards lake
const riverPoints = [];
const maxPoints = 60;
for (let i = 0; i < maxPoints; i++) {
const height = this.getTerrainHeight(currentX, currentZ);
riverPoints.push(new THREE.Vector3(currentX, height + 0.3, currentZ));
// Move towards lake with some wandering
const toLakeX = lowestSpot.x - currentX;
const toLakeZ = lowestSpot.z - currentZ;
const distToLake = Math.sqrt(toLakeX * toLakeX + toLakeZ * toLakeZ);
if (distToLake < lakeRadius + 20) break;
// Flow downhill with some meandering
const wander = this.randomRange(-0.4, 0.4);
currentX += (toLakeX / distToLake) * 25 + wander * 15;
currentZ += (toLakeZ / distToLake) * 25 + wander * 15;
}
// Create river as a tube
if (riverPoints.length >= 2) {
const curve = new THREE.CatmullRomCurve3(riverPoints);
const riverWidth = this.randomRange(8, 14);
const tubeGeom = new THREE.TubeGeometry(curve, riverPoints.length * 2, riverWidth, 6, false);
const river = new THREE.Mesh(tubeGeom, riverMaterial);
river.receiveShadow = true;
this.scene.add(river);
}
}
// Store lake info for other systems
this.lakePosition = lowestSpot;
this.lakeRadius = lakeRadius;
this.lakeWaterLevel = waterLevel;
}
randomRange(min, max) {
return min + Math.random() * (max - min);
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
updateHud(deltaTime) {
if (!this.hasHud) return;
const speedMps = this.aircraft.velocity.length();
const speedKnots = speedMps * 1.94384;
const altitudeMeters = Math.max(this.aircraft.position.y, 0);
const heading = THREE.MathUtils.euclideanModulo(THREE.MathUtils.radToDeg(this.aircraft.rotation.y), 360);
const pitchDeg = THREE.MathUtils.radToDeg(this.aircraft.rotation.x);
const rollDeg = THREE.MathUtils.radToDeg(this.aircraft.rotation.z);
const throttle = THREE.MathUtils.clamp(this.aircraft.cruiseThrust + this.aircraft.boostThrust, 0, 1);
if (deltaTime > 0) {
const instantFps = 1 / deltaTime;
this.fpsEstimate = THREE.MathUtils.lerp(this.fpsEstimate, instantFps, 0.08);
}
// Clean up -0 display
const cleanNum = (v) => Object.is(v, -0) ? 0 : v;
this.hud.speed.textContent = `${speedKnots.toFixed(0)} kn`;
this.hud.altitude.textContent = `${altitudeMeters.toFixed(0)} m`;
this.hud.heading.textContent = `${heading.toFixed(0).padStart(3, '0')}°`;
this.hud.pitch.textContent = `${cleanNum(Math.round(pitchDeg))}°`;
this.hud.roll.textContent = `${cleanNum(Math.round(rollDeg))}°`;
this.hud.throttle.textContent = `${(throttle * 100).toFixed(0)}%`;
this.hud.fps.textContent = `${this.fpsEstimate.toFixed(0)} FPS`;
// Update heading readout
if (this.hud.headingReadout) {
this.hud.headingReadout.textContent = `${heading.toFixed(0).padStart(3, '0')}°`;
}
// Update speed/alt tape readouts
if (this.hud.speedReadout) this.hud.speedReadout.textContent = speedKnots.toFixed(0);
if (this.hud.altReadout) this.hud.altReadout.textContent = altitudeMeters.toFixed(0);
// Update speed/alt tape scales
this.updateTapeScale(this.hud.speedScale, speedKnots, 20, 5);
this.updateTapeScale(this.hud.altScale, altitudeMeters, 50, 5);
// Throttle bar with color states
const throttlePercent = (throttle * 100).toFixed(0);
this.hud.throttleBar.style.width = `${throttlePercent}%`;
this.hud.throttleBar.classList.remove('high', 'max');
if (throttle >= 0.9) {
this.hud.throttleBar.classList.add('max');
} else if (throttle >= 0.7) {
this.hud.throttleBar.classList.add('high');
}
// Engine state with color
if (throttle >= 0.95) {
this.hud.engineState.textContent = 'Afterburn';
} else if (throttle >= 0.7) {
this.hud.engineState.textContent = 'High';
} else if (throttle >= 0.45) {
this.hud.engineState.textContent = 'Nominal';
} else {
this.hud.engineState.textContent = 'Low';
}
// Altitude warning
const terrainHeight = this.getTerrainHeight(this.aircraft.position.x, this.aircraft.position.z);
const altitudeAGL = altitudeMeters - terrainHeight;
if (altitudeAGL < this.lowAltitudeThreshold && altitudeAGL > 0) {
this.hud.altitudeWarning.classList.add('visible');
this.hud.altitude.classList.add('danger');
this.hud.flightStatus.classList.add('danger');
this.hud.flightStatus.classList.remove('warning');
} else if (altitudeAGL < this.lowAltitudeThreshold * 2) {
this.hud.altitudeWarning.classList.remove('visible');
this.hud.altitude.classList.remove('danger');
this.hud.flightStatus.classList.add('warning');
this.hud.flightStatus.classList.remove('danger');
} else {
this.hud.altitudeWarning.classList.remove('visible');
this.hud.altitude.classList.remove('danger', 'warning');
this.hud.flightStatus.classList.remove('warning', 'danger');
}
// Horizon and pitch markers
const pitchOffset = THREE.MathUtils.clamp(-pitchDeg * 1.2, -42, 42);
this.hud.horizon.style.transform =
`translate(-50%, calc(-50% + ${pitchOffset.toFixed(2)}px)) rotate(${(-rollDeg).toFixed(2)}deg)`;
// Update pitch markers rotation
if (this.hud.pitchMarkers) {
this.hud.pitchMarkers.style.transform = `translate(-50%, -50%) rotate(${(-rollDeg).toFixed(2)}deg)`;
}
// Update compass
this.updateCompass(heading);
// Update mini-map
this.updateMiniMap();
}
animate() {
requestAnimationFrame(this.animate.bind(this));
// Skip update when paused
if (this.isPaused) {
this.renderer.render(this.scene, this.camera);
return;
}
const deltaTime = this.clock.getDelta();
this.aircraft.update(deltaTime);
const aircraftPosition = this.aircraft.position;
const speed = this.aircraft.velocity.length();
const speedRatio = THREE.MathUtils.clamp(speed / this.aircraft.maxSpeed, 0, 1);
const chaseDistance = THREE.MathUtils.lerp(22, 34, speedRatio);
const chaseHeight = THREE.MathUtils.lerp(6, 9, speedRatio);
const cameraFrame = new THREE.Euler(
this.aircraft.rotation.x * 0.22,
this.aircraft.rotation.y,
this.aircraft.rotation.z * 0.18
);
const desiredCameraPosition = new THREE.Vector3(0, chaseHeight, chaseDistance).applyEuler(cameraFrame);
desiredCameraPosition.add(aircraftPosition);
const followBlend = 1 - Math.exp(-deltaTime * this.cameraFollowSharpness);
this.camera.position.lerp(desiredCameraPosition, followBlend);
const cameraGround = this.getTerrainHeight(this.camera.position.x, this.camera.position.z);
this.camera.position.y = Math.max(this.camera.position.y, cameraGround + 4.2);
const forward = new THREE.Vector3(0, 0, -1).applyEuler(this.aircraft.rotation).normalize();
const desiredLookTarget = aircraftPosition
.clone()
.add(forward.multiplyScalar(THREE.MathUtils.lerp(32, 74, speedRatio)))
.add(new THREE.Vector3(0, 2.2, 0));
const lookBlend = 1 - Math.exp(-deltaTime * this.cameraLookSharpness);
this.cameraLookTarget.lerp(desiredLookTarget, lookBlend);
this.camera.lookAt(this.cameraLookTarget);
const targetFov = THREE.MathUtils.lerp(73, 82, speedRatio);
if (Math.abs(targetFov - this.camera.fov) > 0.01) {
this.camera.fov = THREE.MathUtils.lerp(this.camera.fov, targetFov, followBlend * 0.9);
this.camera.updateProjectionMatrix();
}
if (this.clouds) {
const time = this.clock.elapsedTime;
this.clouds.children.forEach((cloud) => {
const speed = cloud.userData.speed ?? 4;
cloud.position.x += this.windVector.x * speed * deltaTime;
cloud.position.z += this.windVector.z * speed * deltaTime;
cloud.position.y = (cloud.userData.baseY ?? cloud.position.y) + Math.sin(time * 0.12 + cloud.userData.phase) * 1.9;
if (cloud.position.x > this.cloudWrapX) cloud.position.x = -this.cloudWrapX;
if (cloud.position.x < -this.cloudWrapX) cloud.position.x = this.cloudWrapX;
if (cloud.position.z > 1200) cloud.position.z = -1200;
if (cloud.position.z < -1200) cloud.position.z = 1200;
});
}
this.updateTimeOfDay(this.clock.elapsedTime);
this.updateHud(deltaTime);
this.renderer.render(this.scene, this.camera);
}
}
const simulator = new FlightSimulator();