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 = `${tickVal.toFixed(0)}`; 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();