Spaces:
Sleeping
Sleeping
| 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(); | |