Spaces:
Sleeping
Sleeping
| import * as THREE from 'three'; | |
| export class Aircraft { | |
| constructor() { | |
| // Aircraft properties | |
| this.position = new THREE.Vector3(0, 200, 0); | |
| this.rotation = new THREE.Euler(0, 0, 0); | |
| this.velocity = new THREE.Vector3(0, 0, -80); | |
| this.maxSpeed = 180; | |
| this.stallSpeed = 35; | |
| this.modelYawOffset = Math.PI; | |
| // Flight characteristics | |
| this.cruiseThrust = 0.65; | |
| this.boostThrust = 0; | |
| this.maxThrust = 10; | |
| this.liftCoefficient = 0.0018; | |
| this.dragCoefficient = 0.001; | |
| this.pitchRate = 1.4; | |
| this.rollRate = 1.5; | |
| this.yawRate = 0.6; | |
| this.turnAssist = 0.8; | |
| // Control inputs | |
| this.pitch = 0; | |
| this.yaw = 0; | |
| this.roll = 0; | |
| this.effectTime = 0; | |
| this.engineGlowMaterials = []; | |
| this.exhaustTrails = []; | |
| this.navLights = []; | |
| // Create aircraft mesh | |
| this.createAircraftMesh(); | |
| // Setup controls | |
| this.setupControls(); | |
| } | |
| createAircraftMesh() { | |
| // Su-30 Fighter Jet Materials - Russian Air Force blue-gray camouflage | |
| const mainBodyMat = new THREE.MeshStandardMaterial({ | |
| color: 0x4a5868, // Blue-gray base (Russian camouflage) | |
| roughness: 0.5, | |
| metalness: 0.45, | |
| flatShading: true | |
| }); | |
| const darkPanelMat = new THREE.MeshStandardMaterial({ | |
| color: 0x2a3540, // Darker blue-gray panels | |
| roughness: 0.55, | |
| metalness: 0.5, | |
| flatShading: true | |
| }); | |
| const lightPanelMat = new THREE.MeshStandardMaterial({ | |
| color: 0x6a7585, // Lighter blue-gray panels | |
| roughness: 0.45, | |
| metalness: 0.4, | |
| flatShading: true | |
| }); | |
| const noseConeMat = new THREE.MeshStandardMaterial({ | |
| color: 0x1a1a1a, // Dark radar nose cone | |
| roughness: 0.3, | |
| metalness: 0.2, | |
| flatShading: true | |
| }); | |
| const canopyGlassMat = new THREE.MeshStandardMaterial({ | |
| color: 0x0a1520, | |
| emissive: 0x1a3040, | |
| emissiveIntensity: 0.12, | |
| roughness: 0.05, | |
| metalness: 0.95, | |
| transparent: true, | |
| opacity: 0.75 | |
| }); | |
| const canopyFrameMat = new THREE.MeshStandardMaterial({ | |
| color: 0x1a1a1a, | |
| roughness: 0.4, | |
| metalness: 0.6, | |
| flatShading: true | |
| }); | |
| const exhaustNozzleMat = new THREE.MeshStandardMaterial({ | |
| color: 0x2a2825, | |
| roughness: 0.7, | |
| metalness: 0.3, | |
| flatShading: true | |
| }); | |
| // Camouflage secondary color for upper surfaces | |
| const camoDarkMat = new THREE.MeshStandardMaterial({ | |
| color: 0x354555, // Darker camo patches | |
| roughness: 0.5, | |
| metalness: 0.45, | |
| flatShading: true | |
| }); | |
| this.mesh = new THREE.Group(); | |
| // Helper function to create symmetrical parts | |
| const addPair = (factory) => { | |
| this.mesh.add(factory(1)); | |
| this.mesh.add(factory(-1)); | |
| }; | |
| // ==================== FUSELAGE ==================== | |
| // Nose cone (radome) - pointed conical shape | |
| const noseCone = new THREE.Mesh(new THREE.ConeGeometry(0.45, 2.2, 8), noseConeMat); | |
| noseCone.rotation.x = Math.PI / 2; | |
| noseCone.position.set(0, 0.05, 5.1); | |
| this.mesh.add(noseCone); | |
| // Forward fuselage - contains tandem cockpit | |
| const forwardFuselage = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.55, 0.72, 3.8, 8), | |
| mainBodyMat | |
| ); | |
| forwardFuselage.rotation.x = Math.PI / 2; | |
| forwardFuselage.position.set(0, 0.08, 3.0); | |
| forwardFuselage.scale.set(1.0, 0.75, 1.0); | |
| this.mesh.add(forwardFuselage); | |
| // Center fuselage - wider, contains intakes | |
| const centerFuselage = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.85, 0.78, 3.0, 8), | |
| mainBodyMat | |
| ); | |
| centerFuselage.rotation.x = Math.PI / 2; | |
| centerFuselage.position.set(0, 0.0, 0.3); | |
| centerFuselage.scale.set(1.0, 0.7, 1.0); | |
| this.mesh.add(centerFuselage); | |
| // Aft fuselage - narrowing toward engines | |
| const aftFuselage = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.78, 0.55, 3.0, 8), | |
| mainBodyMat | |
| ); | |
| aftFuselage.rotation.x = Math.PI / 2; | |
| aftFuselage.position.set(0, 0.0, -2.0); | |
| aftFuselage.scale.set(1.0, 0.65, 1.0); | |
| this.mesh.add(aftFuselage); | |
| // Engine nacelle area (between engines) | |
| const tailBoom = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.6, 0.45, 2.5), | |
| darkPanelMat | |
| ); | |
| tailBoom.position.set(0, 0.1, -3.8); | |
| this.mesh.add(tailBoom); | |
| // Belly spine | |
| const bellySpine = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.5, 0.25, 6.0), | |
| darkPanelMat | |
| ); | |
| bellySpine.position.set(0, -0.38, 0.3); | |
| this.mesh.add(bellySpine); | |
| // ==================== CANOPY (Tandem Two-Seat) ==================== | |
| // Front canopy (pilot) | |
| const frontCanopy = new THREE.Mesh( | |
| new THREE.SphereGeometry(0.48, 12, 10), | |
| canopyGlassMat | |
| ); | |
| frontCanopy.scale.set(1.0, 0.55, 1.2); | |
| frontCanopy.position.set(0, 0.42, 3.4); | |
| this.mesh.add(frontCanopy); | |
| // Rear canopy (WSO - Weapons Systems Officer) | |
| const rearCanopy = new THREE.Mesh( | |
| new THREE.SphereGeometry(0.48, 12, 10), | |
| canopyGlassMat | |
| ); | |
| rearCanopy.scale.set(1.0, 0.5, 1.1); | |
| rearCanopy.position.set(0, 0.42, 2.3); | |
| this.mesh.add(rearCanopy); | |
| // Canopy frames | |
| const frontFrame = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.06, 0.06, 0.9), | |
| canopyFrameMat | |
| ); | |
| frontFrame.position.set(0, 0.52, 3.4); | |
| this.mesh.add(frontFrame); | |
| const rearFrame = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.06, 0.06, 0.85), | |
| canopyFrameMat | |
| ); | |
| rearFrame.position.set(0, 0.50, 2.3); | |
| this.mesh.add(rearFrame); | |
| // Divider frame between front and rear canopies | |
| const dividerFrame = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.7, 0.04, 0.06), | |
| canopyFrameMat | |
| ); | |
| dividerFrame.position.set(0, 0.46, 2.85); | |
| this.mesh.add(dividerFrame); | |
| // Dorsal spine hump behind cockpit (houses avionics for two-seat config) | |
| const dorsalHump = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.55, 0.18, 1.2), | |
| mainBodyMat | |
| ); | |
| dorsalHump.position.set(0, 0.32, 1.5); | |
| this.mesh.add(dorsalHump); | |
| // Dorsal airbrake (behind the hump) | |
| const dorsalAirbrake = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.45, 0.4, 0.08), | |
| darkPanelMat | |
| ); | |
| dorsalAirbrake.position.set(0, 0.45, 0.8); | |
| dorsalAirbrake.rotation.x = -0.15; | |
| this.mesh.add(dorsalAirbrake); | |
| // Canopy rear bulkhead | |
| const canopyRearFrame = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.65, 0.05, 0.06), | |
| canopyFrameMat | |
| ); | |
| canopyRearFrame.position.set(0, 0.44, 1.8); | |
| this.mesh.add(canopyRearFrame); | |
| // ==================== IRST (Infra-Red Search and Track) ==================== | |
| // Distinctive ball in front of the windshield - Su-30 feature | |
| const irstBall = new THREE.Mesh( | |
| new THREE.SphereGeometry(0.12, 12, 12), | |
| new THREE.MeshStandardMaterial({ | |
| color: 0x1a1a1a, | |
| roughness: 0.2, | |
| metalness: 0.1 | |
| }) | |
| ); | |
| irstBall.position.set(0, 0.35, 4.2); | |
| this.mesh.add(irstBall); | |
| // IRST housing | |
| const irstHousing = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.08, 0.12, 0.15, 8), | |
| mainBodyMat | |
| ); | |
| irstHousing.rotation.x = Math.PI / 2; | |
| irstHousing.position.set(0, 0.28, 4.15); | |
| this.mesh.add(irstHousing); | |
| // ==================== LERX (Leading Edge Root Extensions) ==================== | |
| addPair((dir) => { | |
| const lerx = new THREE.Mesh( | |
| new THREE.BoxGeometry(2.2, 0.08, 1.8), | |
| mainBodyMat | |
| ); | |
| lerx.position.set(dir * 1.1, 0.0, 1.8); | |
| lerx.rotation.y = dir * 0.35; | |
| return lerx; | |
| }); | |
| // ==================== MAIN DELTA WINGS ==================== | |
| const createWing = (dir) => { | |
| const shape = new THREE.Shape(); | |
| // Delta wing planform - typical Su-27/J-16 shape | |
| if (dir === 1) { | |
| shape.moveTo(0.6, 2.2); // root leading edge | |
| shape.lineTo(6.5, -0.5); // wing tip leading edge | |
| shape.lineTo(6.5, -1.3); // wing tip trailing edge | |
| shape.lineTo(1.2, -3.2); // wing root trailing edge (flaperon) | |
| shape.lineTo(0.6, 2.2); // back to start | |
| } else { | |
| shape.moveTo(-0.6, 2.2); | |
| shape.lineTo(-1.2, -3.2); | |
| shape.lineTo(-6.5, -1.3); | |
| shape.lineTo(-6.5, -0.5); | |
| shape.lineTo(-0.6, 2.2); | |
| } | |
| const geo = new THREE.ExtrudeGeometry(shape, { | |
| depth: 0.12, | |
| bevelEnabled: true, | |
| bevelThickness: 0.02, | |
| bevelSize: 0.02, | |
| bevelSegments: 1 | |
| }); | |
| const mesh = new THREE.Mesh(geo, mainBodyMat); | |
| mesh.rotation.x = Math.PI / 2; | |
| mesh.position.set(0, 0.02, 0.3); | |
| return mesh; | |
| }; | |
| this.mesh.add(createWing(1)); | |
| this.mesh.add(createWing(-1)); | |
| // Upper wing camouflage patches | |
| addPair((dir) => { | |
| const camoPatch = new THREE.Mesh( | |
| new THREE.BoxGeometry(2.5, 0.02, 1.5), | |
| camoDarkMat | |
| ); | |
| camoPatch.position.set(dir * 3.0, 0.14, -0.8); | |
| camoPatch.rotation.y = dir * 0.15; | |
| return camoPatch; | |
| }); | |
| addPair((dir) => { | |
| const camoPatch2 = new THREE.Mesh( | |
| new THREE.BoxGeometry(1.8, 0.02, 1.2), | |
| camoDarkMat | |
| ); | |
| camoPatch2.position.set(dir * 4.5, 0.14, -0.3); | |
| camoPatch2.rotation.y = dir * 0.1; | |
| return camoPatch2; | |
| }); | |
| // Wing fuel tanks / pods | |
| addPair((dir) => { | |
| const tank = new THREE.Mesh( | |
| new THREE.CapsuleGeometry(0.18, 2.0, 6, 8), | |
| lightPanelMat | |
| ); | |
| tank.rotation.x = Math.PI / 2; | |
| tank.rotation.z = dir * 0.1; | |
| tank.position.set(dir * 4.5, -0.15, -0.8); | |
| return tank; | |
| }); | |
| // ==================== CANARDS (Front control surfaces) ==================== | |
| addPair((dir) => { | |
| const canardShape = new THREE.Shape(); | |
| if (dir === 1) { | |
| canardShape.moveTo(0, 0.6); | |
| canardShape.lineTo(1.6, 0.1); | |
| canardShape.lineTo(1.5, -0.3); | |
| canardShape.lineTo(0, -0.5); | |
| } else { | |
| canardShape.moveTo(0, 0.6); | |
| canardShape.lineTo(0, -0.5); | |
| canardShape.lineTo(-1.5, -0.3); | |
| canardShape.lineTo(-1.6, 0.1); | |
| } | |
| const canardGeo = new THREE.ExtrudeGeometry(canardShape, { | |
| depth: 0.06, | |
| bevelEnabled: true, | |
| bevelThickness: 0.01, | |
| bevelSize: 0.01, | |
| bevelSegments: 1 | |
| }); | |
| const canard = new THREE.Mesh(canardGeo, mainBodyMat); | |
| canard.rotation.x = Math.PI / 2; | |
| canard.position.set(0, 0.15, 4.2); | |
| return canard; | |
| }); | |
| // ==================== AIR INTAKES ==================== | |
| addPair((dir) => { | |
| // Main intake structure | |
| const intake = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.65, 0.55, 2.8), | |
| darkPanelMat | |
| ); | |
| intake.position.set(dir * 1.05, -0.15, 1.2); | |
| this.mesh.add(intake); | |
| // Intake lip | |
| const intakeLip = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.72, 0.62, 0.15), | |
| lightPanelMat | |
| ); | |
| intakeLip.position.set(dir * 1.05, -0.15, 2.5); | |
| this.mesh.add(intakeLip); | |
| // Intake splitter plate | |
| const splitter = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.08, 0.48, 2.0), | |
| mainBodyMat | |
| ); | |
| splitter.position.set(dir * 0.72, -0.12, 1.8); | |
| return splitter; | |
| }); | |
| // ==================== TWIN VERTICAL STABILIZERS ==================== | |
| addPair((dir) => { | |
| // Vertical stabilizer (tail fin) | |
| const tailShape = new THREE.Shape(); | |
| tailShape.moveTo(0, 0); // leading edge bottom | |
| tailShape.lineTo(-0.8, 1.9); // leading edge top (swept) | |
| tailShape.lineTo(-1.6, 1.85); // trailing edge top | |
| tailShape.lineTo(-1.2, 0); // trailing edge bottom | |
| const tailGeo = new THREE.ExtrudeGeometry(tailShape, { | |
| depth: 0.08, | |
| bevelEnabled: true, | |
| bevelThickness: 0.015, | |
| bevelSize: 0.015, | |
| bevelSegments: 1 | |
| }); | |
| const tail = new THREE.Mesh(tailGeo, mainBodyMat); | |
| tail.rotation.y = Math.PI / 2; | |
| tail.rotation.x = dir * 0.12; // Slight outward cant | |
| tail.position.set(dir * 0.95, 0.2, -4.2); | |
| this.mesh.add(tail); | |
| // Rudder | |
| const rudder = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.05, 1.4, 0.4), | |
| darkPanelMat | |
| ); | |
| rudder.position.set(dir * 1.45, 1.0, -4.8); | |
| rudder.rotation.x = dir * 0.12; | |
| return rudder; | |
| }); | |
| // ==================== HORIZONTAL STABILIZERS ==================== | |
| addPair((dir) => { | |
| const stabShape = new THREE.Shape(); | |
| if (dir === 1) { | |
| stabShape.moveTo(0, 0.5); | |
| stabShape.lineTo(2.2, 0.15); | |
| stabShape.lineTo(2.0, -0.25); | |
| stabShape.lineTo(0, -0.6); | |
| } else { | |
| stabShape.moveTo(0, 0.5); | |
| stabShape.lineTo(0, -0.6); | |
| stabShape.lineTo(-2.0, -0.25); | |
| stabShape.lineTo(-2.2, 0.15); | |
| } | |
| const stabGeo = new THREE.ExtrudeGeometry(stabShape, { | |
| depth: 0.06, | |
| bevelEnabled: true, | |
| bevelThickness: 0.01, | |
| bevelSize: 0.01, | |
| bevelSegments: 1 | |
| }); | |
| const stabilizer = new THREE.Mesh(stabGeo, mainBodyMat); | |
| stabilizer.rotation.x = Math.PI / 2; | |
| stabilizer.position.set(0, 0.15, -4.5); | |
| return stabilizer; | |
| }); | |
| // ==================== TWIN ENGINES ==================== | |
| addPair((dir) => { | |
| // Engine nacelle | |
| const nacelle = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.38, 0.42, 2.2, 10), | |
| mainBodyMat | |
| ); | |
| nacelle.rotation.x = Math.PI / 2; | |
| nacelle.position.set(dir * 0.65, -0.08, -4.0); | |
| this.mesh.add(nacelle); | |
| // Exhaust nozzle | |
| const nozzle = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.32, 0.28, 0.5, 12), | |
| exhaustNozzleMat | |
| ); | |
| nozzle.rotation.x = Math.PI / 2; | |
| nozzle.position.set(dir * 0.65, -0.08, -5.15); | |
| this.mesh.add(nozzle); | |
| // Engine glow | |
| const glowMat = new THREE.MeshStandardMaterial({ | |
| color: 0xff6633, | |
| emissive: 0xff4411, | |
| emissiveIntensity: 1.0, | |
| transparent: true, | |
| opacity: 0.85, | |
| toneMapped: false | |
| }); | |
| this.engineGlowMaterials.push(glowMat); | |
| const glow = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.24, 0.26, 0.15, 12), | |
| glowMat | |
| ); | |
| glow.rotation.x = Math.PI / 2; | |
| glow.position.set(dir * 0.65, -0.08, -5.38); | |
| this.mesh.add(glow); | |
| // Afterburner flame | |
| const flameMat = new THREE.MeshBasicMaterial({ | |
| color: 0xff8844, | |
| transparent: true, | |
| opacity: 0.45, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false | |
| }); | |
| const flame = new THREE.Mesh( | |
| new THREE.ConeGeometry(0.22, 2.5, 10, 1, true), | |
| flameMat | |
| ); | |
| flame.rotation.x = Math.PI / 2; | |
| flame.position.set(dir * 0.65, -0.08, -6.6); | |
| this.exhaustTrails.push(flame); | |
| this.mesh.add(flame); | |
| return flame; // Return to satisfy addPair expectation | |
| }); | |
| // ==================== DETAILS & ANTENNAS ==================== | |
| // IFR (In-flight refueling) probe | |
| const ifrProbe = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.02, 0.025, 0.8, 6), | |
| darkPanelMat | |
| ); | |
| ifrProbe.rotation.x = Math.PI / 2; | |
| ifrProbe.position.set(0, 0.35, 4.8); | |
| this.mesh.add(ifrProbe); | |
| // Dorsal antenna | |
| const dorsalAntenna = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.08, 0.15, 0.8), | |
| darkPanelMat | |
| ); | |
| dorsalAntenna.position.set(0, 0.52, -0.5); | |
| this.mesh.add(dorsalAntenna); | |
| // ==================== NAVIGATION LIGHTS ==================== | |
| const redLightMat = new THREE.MeshStandardMaterial({ | |
| color: 0xff2222, | |
| emissive: 0xff0000, | |
| emissiveIntensity: 1.5, | |
| roughness: 0.2 | |
| }); | |
| const greenLightMat = new THREE.MeshStandardMaterial({ | |
| color: 0x22ff44, | |
| emissive: 0x00ff22, | |
| emissiveIntensity: 1.5, | |
| roughness: 0.2 | |
| }); | |
| const whiteLightMat = new THREE.MeshStandardMaterial({ | |
| color: 0xffffff, | |
| emissive: 0xffffff, | |
| emissiveIntensity: 0.8, | |
| roughness: 0.2 | |
| }); | |
| // Port (red) and Starboard (green) lights on wing tips | |
| const leftWingLight = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 8), redLightMat); | |
| leftWingLight.position.set(-6.4, 0.08, -0.9); | |
| this.mesh.add(leftWingLight); | |
| const rightWingLight = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 8), greenLightMat); | |
| rightWingLight.position.set(6.4, 0.08, -0.9); | |
| this.mesh.add(rightWingLight); | |
| // Tail lights (white) | |
| addPair((dir) => { | |
| const tailLight = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 8), whiteLightMat); | |
| tailLight.position.set(dir * 1.0, 1.9, -4.8); | |
| return tailLight; | |
| }); | |
| // Belly strobe | |
| const bellyStrobe = new THREE.Mesh(new THREE.SphereGeometry(0.04, 8, 8), whiteLightMat); | |
| bellyStrobe.position.set(0, -0.42, -1.0); | |
| this.mesh.add(bellyStrobe); | |
| this.navLights.push({ material: redLightMat, mode: 'steady', base: 1.2 }); | |
| this.navLights.push({ material: greenLightMat, mode: 'steady', base: 1.2 }); | |
| this.navLights.push({ material: whiteLightMat, mode: 'strobe', base: 0.3 }); | |
| // ==================== SHADOWS ==================== | |
| this.mesh.traverse((child) => { | |
| if (child.isMesh && (!child.material || !child.material.transparent)) { | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| } | |
| }); | |
| this.mesh.position.copy(this.position); | |
| } | |
| setupControls() { | |
| document.addEventListener('keydown', (event) => this.handleKeyDown(event)); | |
| document.addEventListener('keyup', (event) => this.handleKeyUp(event)); | |
| } | |
| handleKeyDown(event) { | |
| if (event.key === 'Shift') { | |
| this.boostThrust = 0.5; | |
| return; | |
| } | |
| if (event.key === 'ArrowUp') { | |
| this.throttleUp = true; | |
| return; | |
| } | |
| if (event.key === 'ArrowDown') { | |
| this.throttleDown = true; | |
| return; | |
| } | |
| switch (event.key.toLowerCase()) { | |
| case 'w': this.pitch = -1; break; // Pitch up (nose rises) | |
| case 's': this.pitch = 1; break; // Pitch down (nose drops) | |
| case 'a': this.roll = -1; break; // Roll left (left wing down) | |
| case 'd': this.roll = 1; break; // Roll right (right wing down) | |
| case 'q': this.yaw = 1; break; // Yaw left (turn left) | |
| case 'e': this.yaw = -1; break; // Yaw right (turn right) | |
| } | |
| } | |
| handleKeyUp(event) { | |
| if (event.key === 'Shift') { | |
| this.boostThrust = 0; | |
| return; | |
| } | |
| if (event.key === 'ArrowUp') { | |
| this.throttleUp = false; | |
| return; | |
| } | |
| if (event.key === 'ArrowDown') { | |
| this.throttleDown = false; | |
| return; | |
| } | |
| switch (event.key.toLowerCase()) { | |
| case 'w': case 's': this.pitch = 0; break; | |
| case 'a': case 'd': this.roll = 0; break; | |
| case 'q': case 'e': this.yaw = 0; break; | |
| } | |
| } | |
| update(deltaTime) { | |
| // Gradual throttle control | |
| if (this.throttleUp) { | |
| this.cruiseThrust = THREE.MathUtils.clamp(this.cruiseThrust + 0.4 * deltaTime, 0, 1); | |
| } | |
| if (this.throttleDown) { | |
| this.cruiseThrust = THREE.MathUtils.clamp(this.cruiseThrust - 0.4 * deltaTime, 0, 1); | |
| } | |
| const speed = Math.max(this.velocity.length(), 0.01); | |
| const speedRatio = THREE.MathUtils.clamp(speed / 80, 0.35, 1.6); | |
| // ── Apply rotation rates using YXZ Euler order ── | |
| this.rotation.order = 'YXZ'; | |
| this.rotation.x += this.pitch * this.pitchRate * deltaTime; | |
| this.rotation.z += this.roll * this.rollRate * deltaTime; | |
| this.rotation.y += this.yaw * this.yawRate * deltaTime; | |
| // Banked turns: roll naturally induces yaw at speed | |
| this.rotation.y -= this.rotation.z * this.turnAssist * speedRatio * deltaTime; | |
| // Clamp pitch to ±60° (well away from ±90° gimbal lock zone) | |
| this.rotation.x = THREE.MathUtils.clamp(this.rotation.x, -1.05, 1.05); | |
| this.rotation.z = THREE.MathUtils.clamp(this.rotation.z, -1.2, 1.2); | |
| // Auto-leveling: gently stabilise when no input | |
| if (this.pitch === 0) { | |
| this.rotation.x = THREE.MathUtils.lerp(this.rotation.x, 0, deltaTime * 0.4); | |
| } | |
| if (this.roll === 0) { | |
| this.rotation.z = THREE.MathUtils.lerp(this.rotation.z, 0, deltaTime * 1.0); | |
| } | |
| // Calculate body axes | |
| const forward = new THREE.Vector3(0, 0, -1).applyEuler(this.rotation).normalize(); | |
| const right = new THREE.Vector3(1, 0, 0).applyEuler(this.rotation).normalize(); | |
| // Aerodynamic forces | |
| const throttle = THREE.MathUtils.clamp(this.cruiseThrust + this.boostThrust, 0, 1); | |
| const thrust = forward.clone().multiplyScalar(throttle * this.maxThrust); | |
| const velocityDir = speed > 0.1 ? this.velocity.clone().normalize() : forward.clone(); | |
| let liftDir = new THREE.Vector3().crossVectors(right, velocityDir); | |
| if (liftDir.lengthSq() < 0.0001) { | |
| liftDir = new THREE.Vector3(0, 1, 0).applyEuler(this.rotation); | |
| } else { | |
| liftDir.normalize(); | |
| } | |
| const speedSq = speed * speed; | |
| let liftMag = this.liftCoefficient * speedSq; | |
| const stallFactor = THREE.MathUtils.clamp(speed / this.stallSpeed, 0, 1); | |
| liftMag *= stallFactor; | |
| const lift = liftDir.multiplyScalar(liftMag); | |
| const drag = velocityDir.multiplyScalar(-this.dragCoefficient * speedSq); | |
| const gravity = new THREE.Vector3(0, -9.81, 0); | |
| const acceleration = new THREE.Vector3() | |
| .add(thrust) | |
| .add(lift) | |
| .add(drag) | |
| .add(gravity); | |
| this.velocity.addScaledVector(acceleration, deltaTime); | |
| // Align velocity toward aircraft heading (makes pitch/roll actually steer) | |
| const alignTarget = forward.clone().multiplyScalar(this.velocity.length()); | |
| this.velocity.lerp(alignTarget, deltaTime * 2.5); | |
| // Update position | |
| this.position.addScaledVector(this.velocity, deltaTime); | |
| // Cap speed and keep above ground | |
| if (this.velocity.length() > this.maxSpeed) { | |
| this.velocity.setLength(this.maxSpeed); | |
| } | |
| if (this.position.y < 2) { | |
| this.position.y = 2; | |
| if (this.velocity.y < 0) this.velocity.y = 0; | |
| } | |
| this.updateVisualEffects(deltaTime, throttle, speed); | |
| // Update mesh | |
| this.mesh.position.copy(this.position); | |
| this.mesh.rotation.set( | |
| this.rotation.x, | |
| this.rotation.y + this.modelYawOffset, | |
| this.rotation.z | |
| ); | |
| } | |
| updateVisualEffects(deltaTime, throttle, speed) { | |
| this.effectTime += deltaTime; | |
| const speedRatio = THREE.MathUtils.clamp(speed / this.maxSpeed, 0, 1); | |
| const boostFactor = THREE.MathUtils.clamp((throttle - this.cruiseThrust) / (1 - this.cruiseThrust), 0, 1); | |
| this.engineGlowMaterials.forEach((material, index) => { | |
| const flicker = 0.88 + Math.sin(this.effectTime * 34 + index * 1.7) * 0.12; | |
| material.emissiveIntensity = (0.42 + throttle * 1.45 + boostFactor * 1.55) * flicker; | |
| material.opacity = THREE.MathUtils.clamp(0.22 + throttle * 0.5 + boostFactor * 0.3, 0.2, 0.96); | |
| }); | |
| this.exhaustTrails.forEach((trail, index) => { | |
| const pulse = 0.9 + Math.sin(this.effectTime * 26 + index * 2.1) * 0.1; | |
| const width = THREE.MathUtils.lerp(0.75, 1.35, throttle) * pulse; | |
| const length = THREE.MathUtils.lerp(0.9, 3.8, throttle + boostFactor * 0.3); | |
| trail.scale.set(width, width, length); | |
| trail.material.opacity = THREE.MathUtils.clamp((throttle - 0.22) * 0.58 + speedRatio * 0.26, 0, 0.72); | |
| }); | |
| this.navLights.forEach((light) => { | |
| if (light.mode === 'strobe') { | |
| light.material.emissiveIntensity = Math.sin(this.effectTime * 16.5) > 0.89 ? 2.6 : 0.15; | |
| } else { | |
| light.material.emissiveIntensity = light.base; | |
| } | |
| }); | |
| } | |
| getMesh() { | |
| return this.mesh; | |
| } | |
| } | |