| import * as THREE from 'three'; |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
|
|
| |
| let gameCanStart = false; |
| let gameStarted = false; |
|
|
| |
| const GAME_CONSTANTS = { |
| MISSION_DURATION: 180, |
| MAP_SIZE: 15000, |
| MAX_ALTITUDE: 15000, |
| MIN_ALTITUDE: 0, |
| MAX_SPEED: 800, |
| STALL_SPEED: 300, |
| GRAVITY: 9.8, |
| MOUSE_SENSITIVITY: 0.001, |
| MAX_G_FORCE: 12.0, |
| ENEMY_COUNT: 4, |
| MISSILE_COUNT: 6, |
| AMMO_COUNT: 940, |
| BULLET_DAMAGE: 50, |
| MAX_HEALTH: 1000, |
| AIM9_COUNT: 8, |
| AIM9_DAMAGE: 1000, |
| AIM9_SPEED: 514.4, |
| AIM9_LOCK_RANGE: 6000, |
| AIM9_LOCK_REQUIRED: 3, |
| ENEMY_AIM9_COUNT: 4, |
| ENEMY_LOCK_RANGE: 5000, |
| ENEMY_LOCK_ANGLE: Math.PI / 12 |
| }; |
|
|
| |
| class Fighter { |
| constructor() { |
| this.mesh = null; |
| this.isLoaded = false; |
| |
| |
| this.position = new THREE.Vector3(0, 2000, 0); |
| this.velocity = new THREE.Vector3(0, 0, 350); |
| this.acceleration = new THREE.Vector3(0, 0, 0); |
| this.rotation = new THREE.Euler(0, 0, 0, 'YXZ'); |
| |
| |
| this.throttle = 0.7; |
| this.speed = 350; |
| this.altitude = 2000; |
| this.gForce = 1.0; |
| this.health = GAME_CONSTANTS.MAX_HEALTH; |
| |
| |
| this.pitchInput = 0; |
| this.rollInput = 0; |
| this.yawInput = 0; |
| |
| |
| this.mousePitch = 0; |
| this.mouseRoll = 0; |
| |
| |
| this.targetPitch = 0; |
| this.targetRoll = 0; |
| this.targetYaw = 0; |
| |
| |
| this.missiles = GAME_CONSTANTS.MISSILE_COUNT; |
| this.ammo = GAME_CONSTANTS.AMMO_COUNT; |
| this.bullets = []; |
| this.lastShootTime = 0; |
| this.isMouseDown = false; |
| this.gunfireAudios = []; |
|
|
| |
| this.flareCount = 3; |
| this.deployedFlares = []; |
| this.lastFlareTime = 0; |
| |
| |
| this.aim9Missiles = GAME_CONSTANTS.AIM9_COUNT; |
| this.firedMissiles = []; |
| this.currentWeapon = 'MG'; |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| this.lastLockTime = 0; |
| this.lockAudios = { |
| locking: null, |
| locked: null |
| }; |
| this.initializeLockAudios(); |
| |
| |
| this.escapeKeyPressed = false; |
| this.stallEscapeProgress = 0; |
| |
| |
| this.cameraDistance = 250; |
| this.cameraHeight = 30; |
| this.cameraLag = 0.06; |
| |
| |
| this.altitudeWarning = false; |
| this.stallWarning = false; |
| this.warningBlinkTimer = 0; |
| this.warningBlinkState = false; |
| |
| |
| this.overG = false; |
| this.maxGForce = 9.0; |
| this.overGTimer = 0; |
| |
| |
| this.warningAudios = { |
| altitude: null, |
| pullup: null, |
| overg: null, |
| stall: null, |
| normal: null, |
| missileLock: null, |
| missileIncoming: null |
| }; |
| this.initializeWarningAudios(); |
| |
| |
| this.incomingMissiles = []; |
| this.missileWarningActive = false; |
| this.beingLockedBy = []; |
| } |
| |
| |
| normalizeHeading(radians) { |
| let degrees = radians * (180 / Math.PI); |
| while (degrees < 0) degrees += 360; |
| while (degrees >= 360) degrees -= 360; |
| return degrees; |
| } |
| |
| |
| normalizeAngle(angle) { |
| while (angle > Math.PI) angle -= Math.PI * 2; |
| while (angle < -Math.PI) angle += Math.PI * 2; |
| return angle; |
| } |
| |
| initializeWarningAudios() { |
| try { |
| this.warningAudios.altitude = new Audio('sounds/altitude.ogg'); |
| this.warningAudios.altitude.volume = 0.75; |
| |
| this.warningAudios.pullup = new Audio('sounds/pullup.ogg'); |
| this.warningAudios.pullup.volume = 0.9; |
| |
| this.warningAudios.overg = new Audio('sounds/overg.ogg'); |
| this.warningAudios.overg.volume = 0.75; |
| |
| this.warningAudios.stall = new Audio('sounds/alert.ogg'); |
| this.warningAudios.stall.volume = 0.75; |
| |
| |
| this.warningAudios.normal = new Audio('sounds/normal.ogg'); |
| this.warningAudios.normal.volume = 0.5; |
| this.warningAudios.normal.loop = true; |
| |
| |
| this.warningAudios.missileLock = new Audio('sounds/missile2.ogg'); |
| this.warningAudios.missileLock.volume = 0.8; |
| this.warningAudios.missileLock.loop = true; |
| |
| this.warningAudios.missileIncoming = new Audio('sounds/missile3.ogg'); |
| this.warningAudios.missileIncoming.volume = 1.0; |
| this.warningAudios.missileIncoming.loop = true; |
| |
| |
| Object.keys(this.warningAudios).forEach(key => { |
| if (key !== 'normal' && key !== 'missileLock' && key !== 'missileIncoming' && this.warningAudios[key]) { |
| this.warningAudios[key].addEventListener('ended', () => { |
| this.updateWarningAudios(); |
| }); |
| } |
| }); |
| } catch (e) { |
| console.log('Warning audio initialization failed:', e); |
| } |
| } |
| |
| initializeLockAudios() { |
| try { |
| this.lockAudios.locking = new Audio('sounds/lockon.ogg'); |
| this.lockAudios.locking.volume = 0.5; |
| this.lockAudios.locking.loop = true; |
| |
| this.lockAudios.locked = new Audio('sounds/lockondone.ogg'); |
| this.lockAudios.locked.volume = 0.7; |
| } catch (e) { |
| console.log('Lock audio initialization failed:', e); |
| } |
| } |
| |
| startEngineSound() { |
| |
| if (this.warningAudios.normal) { |
| this.warningAudios.normal.play().catch(e => { |
| console.log('Engine sound failed to start:', e); |
| }); |
| } |
| } |
| |
| updateWarningAudios() { |
| let currentWarning = null; |
| |
| |
| if (this.incomingMissiles.length > 0) { |
| |
| if (this.warningAudios.missileIncoming && this.warningAudios.missileIncoming.paused) { |
| this.warningAudios.missileIncoming.play().catch(e => {}); |
| } |
| } else { |
| |
| if (this.warningAudios.missileIncoming && !this.warningAudios.missileIncoming.paused) { |
| this.warningAudios.missileIncoming.pause(); |
| this.warningAudios.missileIncoming.currentTime = 0; |
| } |
| } |
| |
| |
| if (this.beingLockedBy.length > 0 && this.incomingMissiles.length === 0) { |
| |
| if (this.warningAudios.missileLock && this.warningAudios.missileLock.paused) { |
| this.warningAudios.missileLock.play().catch(e => {}); |
| } |
| } else { |
| |
| if (this.warningAudios.missileLock && !this.warningAudios.missileLock.paused) { |
| this.warningAudios.missileLock.pause(); |
| this.warningAudios.missileLock.currentTime = 0; |
| } |
| } |
| |
| |
| if (this.altitude < 250) { |
| currentWarning = 'pullup'; |
| } |
| else if (this.altitude < 500) { |
| currentWarning = 'altitude'; |
| } |
| else if (this.overG) { |
| currentWarning = 'overg'; |
| } |
| else if (this.stallWarning) { |
| currentWarning = 'stall'; |
| } |
| |
| |
| Object.keys(this.warningAudios).forEach(key => { |
| if (key !== 'normal' && key !== 'missileLock' && key !== 'missileIncoming' && |
| key !== currentWarning && this.warningAudios[key] && !this.warningAudios[key].paused) { |
| this.warningAudios[key].pause(); |
| this.warningAudios[key].currentTime = 0; |
| } |
| }); |
| |
| if (currentWarning && this.warningAudios[currentWarning]) { |
| if (this.warningAudios[currentWarning].paused) { |
| this.warningAudios[currentWarning].play().catch(e => {}); |
| } |
| } |
| } |
| |
| stopAllWarningAudios() { |
| |
| Object.values(this.warningAudios).forEach(audio => { |
| if (audio && !audio.paused) { |
| audio.pause(); |
| audio.currentTime = 0; |
| } |
| }); |
| |
| |
| if (this.lockAudios.locking && !this.lockAudios.locking.paused) { |
| this.lockAudios.locking.pause(); |
| this.lockAudios.locking.currentTime = 0; |
| } |
| } |
|
|
| async initialize(scene, loader) { |
| try { |
| const result = await loader.loadAsync('models/f-15.glb'); |
| this.mesh = result.scene; |
| this.mesh.position.copy(this.position); |
| this.mesh.scale.set(2, 2, 2); |
| this.mesh.rotation.y = Math.PI / 4; |
| |
| this.mesh.traverse((child) => { |
| if (child.isMesh) { |
| child.castShadow = true; |
| child.receiveShadow = true; |
| } |
| }); |
| |
| scene.add(this.mesh); |
| this.isLoaded = true; |
| console.log('F-15 전투기 로딩 완료'); |
| } catch (error) { |
| console.error('F-15 모델 로딩 실패:', error); |
| this.createFallbackModel(scene); |
| } |
| } |
|
|
| createFallbackModel(scene) { |
| const group = new THREE.Group(); |
| |
| const fuselageGeometry = new THREE.CylinderGeometry(0.8, 1.5, 12, 8); |
| const fuselageMaterial = new THREE.MeshLambertMaterial({ color: 0x606060 }); |
| const fuselage = new THREE.Mesh(fuselageGeometry, fuselageMaterial); |
| fuselage.rotation.x = Math.PI / 2; |
| group.add(fuselage); |
| |
| const wingGeometry = new THREE.BoxGeometry(16, 0.3, 4); |
| const wingMaterial = new THREE.MeshLambertMaterial({ color: 0x404040 }); |
| const wings = new THREE.Mesh(wingGeometry, wingMaterial); |
| wings.position.z = -1; |
| group.add(wings); |
| |
| const tailGeometry = new THREE.BoxGeometry(0.3, 4, 3); |
| const tailMaterial = new THREE.MeshLambertMaterial({ color: 0x404040 }); |
| const tail = new THREE.Mesh(tailGeometry, tailMaterial); |
| tail.position.z = -5; |
| tail.position.y = 1.5; |
| group.add(tail); |
| |
| const horizontalTailGeometry = new THREE.BoxGeometry(6, 0.2, 2); |
| const horizontalTail = new THREE.Mesh(horizontalTailGeometry, tailMaterial); |
| horizontalTail.position.z = -5; |
| horizontalTail.position.y = 0.5; |
| group.add(horizontalTail); |
| |
| this.mesh = group; |
| this.mesh.position.copy(this.position); |
| this.mesh.scale.set(2, 2, 2); |
| scene.add(this.mesh); |
| this.isLoaded = true; |
| |
| console.log('Fallback 전투기 모델 생성 완료'); |
| } |
| |
| switchWeapon() { |
| if (this.currentWeapon === 'MG') { |
| this.currentWeapon = 'AIM9'; |
| |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| if (this.lockAudios.locking && !this.lockAudios.locking.paused) { |
| this.lockAudios.locking.pause(); |
| this.lockAudios.locking.currentTime = 0; |
| } |
| } else { |
| this.currentWeapon = 'MG'; |
| |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| if (this.lockAudios.locking && !this.lockAudios.locking.paused) { |
| this.lockAudios.locking.pause(); |
| this.lockAudios.locking.currentTime = 0; |
| } |
| } |
| } |
|
|
| updateLockOn(enemies, deltaTime) { |
| |
| if (this.currentWeapon !== 'AIM9') { |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| return; |
| } |
| |
| const now = Date.now(); |
| |
| |
| if (now - this.lastLockTime < 1000) { |
| return; |
| } |
| |
| |
| let closestEnemy = null; |
| let closestDistance = GAME_CONSTANTS.AIM9_LOCK_RANGE; |
| |
| enemies.forEach(enemy => { |
| if (!enemy.mesh || !enemy.isLoaded) return; |
| |
| const distance = this.position.distanceTo(enemy.position); |
| if (distance > GAME_CONSTANTS.AIM9_LOCK_RANGE) return; |
| |
| |
| const toEnemy = enemy.position.clone().sub(this.position).normalize(); |
| const forward = new THREE.Vector3(0, 0, 1); |
| |
| |
| const quaternion = new THREE.Quaternion(); |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| pitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| yawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); |
| rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| forward.applyQuaternion(quaternion); |
| |
| const dotProduct = forward.dot(toEnemy); |
| const angle = Math.acos(Math.max(-1, Math.min(1, dotProduct))); |
| |
| |
| if (angle < Math.PI / 18 && distance < closestDistance) { |
| closestEnemy = enemy; |
| closestDistance = distance; |
| } |
| }); |
| |
| |
| if (closestEnemy) { |
| if (this.lockTarget === closestEnemy) { |
| |
| this.lockProgress++; |
| this.lastLockTime = now; |
| |
| |
| if (this.lockProgress < GAME_CONSTANTS.AIM9_LOCK_REQUIRED) { |
| if (this.lockAudios.locking && this.lockAudios.locking.paused) { |
| this.lockAudios.locking.play().catch(e => {}); |
| } |
| } else { |
| |
| if (this.lockAudios.locking && !this.lockAudios.locking.paused) { |
| this.lockAudios.locking.pause(); |
| this.lockAudios.locking.currentTime = 0; |
| } |
| if (this.lockAudios.locked) { |
| this.lockAudios.locked.play().catch(e => {}); |
| } |
| } |
| } else { |
| |
| this.lockTarget = closestEnemy; |
| this.lockProgress = 1; |
| this.lastLockTime = now; |
| |
| |
| if (this.lockAudios.locking && this.lockAudios.locking.paused) { |
| this.lockAudios.locking.play().catch(e => {}); |
| } |
| } |
| } else { |
| |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| if (this.lockAudios.locking && !this.lockAudios.locking.paused) { |
| this.lockAudios.locking.pause(); |
| this.lockAudios.locking.currentTime = 0; |
| } |
| } |
| } |
|
|
| updateMouseInput(deltaX, deltaY) { |
| |
| if (this.overG && this.overGTimer > 1.0 && this.stallWarning) { |
| |
| const sensitivity = GAME_CONSTANTS.MOUSE_SENSITIVITY * 0.3; |
| this.targetYaw += deltaX * sensitivity * 0.3; |
| return; |
| } |
| |
| const sensitivity = GAME_CONSTANTS.MOUSE_SENSITIVITY * 1.0; |
| |
| |
| const pitchRate = -deltaY * sensitivity; |
| const yawRate = deltaX * sensitivity * 0.8; |
| |
| |
| this.targetPitch += pitchRate; |
| this.targetYaw += yawRate; |
| |
| |
| |
| const targetBankAngle = -yawRate * 15; |
| |
| |
| this.targetRoll = THREE.MathUtils.lerp(this.targetRoll, targetBankAngle, 0.1); |
| |
| |
| const maxPitchAngle = Math.PI / 3; |
| const maxRollAngle = Math.PI * 0.5; |
| |
| this.targetPitch = Math.max(-maxPitchAngle, Math.min(maxPitchAngle, this.targetPitch)); |
| this.targetRoll = Math.max(-maxRollAngle, Math.min(maxRollAngle, this.targetRoll)); |
| } |
|
|
| updateControls(keys, deltaTime) { |
| |
| if (keys.w) { |
| this.throttle = Math.min(1.0, this.throttle + deltaTime * 0.5); |
| } |
| if (keys.s) { |
| this.throttle = Math.max(0.1, this.throttle - deltaTime * 0.5); |
| } |
| |
| |
| if (keys.a) { |
| this.targetYaw -= deltaTime * 1.2; |
| } |
| if (keys.d) { |
| this.targetYaw += deltaTime * 1.2; |
| } |
| } |
|
|
| updatePhysics(deltaTime) { |
| if (!this.mesh) return; |
| |
| |
| const rotationSpeed = deltaTime * 2.0; |
| const yawRotationSpeed = deltaTime * 3.0; |
| |
| |
| this.rotation.x = THREE.MathUtils.lerp(this.rotation.x, this.targetPitch, rotationSpeed); |
| this.rotation.z = THREE.MathUtils.lerp(this.rotation.z, this.targetRoll, rotationSpeed * 1.5); |
| |
| |
| let yawDiff = this.normalizeAngle(this.targetYaw - this.rotation.y); |
| this.rotation.y += yawDiff * yawRotationSpeed; |
| |
| |
| this.rotation.y = this.normalizeAngle(this.rotation.y); |
| this.targetYaw = this.normalizeAngle(this.targetYaw); |
| |
| |
| if (Math.abs(yawDiff) < 0.05) { |
| |
| this.targetRoll *= 0.95; |
| } |
| |
| |
| let bankTurnRate = 0; |
| if (Math.abs(this.rotation.z) > 0.3) { |
| const bankAngle = this.rotation.z; |
| bankTurnRate = Math.sin(bankAngle) * deltaTime * 0.1; |
| this.targetYaw += bankTurnRate; |
| } |
| |
| |
| const minSpeed = 0; |
| const maxSpeed = 900.6; |
| let targetSpeed = minSpeed + (maxSpeed - minSpeed) * this.throttle; |
| |
| |
| const pitchAngle = this.rotation.x; |
| const pitchDegrees = Math.abs(pitchAngle) * (180 / Math.PI); |
| |
| |
| if (pitchAngle < -0.1 && !this.stallWarning) { |
| const climbFactor = Math.abs(pitchAngle) / (Math.PI / 2); |
| if (pitchDegrees > 30) { |
| targetSpeed *= Math.max(0, 1 - climbFactor * 1.5); |
| } else { |
| targetSpeed *= (1 - climbFactor * 0.3); |
| } |
| } else if (pitchAngle > 0.1) { |
| const diveFactor = pitchAngle / (Math.PI / 3); |
| targetSpeed *= (1 + diveFactor * 0.4); |
| } |
| |
| |
| const turnRate = Math.abs(bankTurnRate) * 100; |
| const pitchRate = Math.abs(this.rotation.x - this.targetPitch) * 10; |
| |
| |
| const altitudeInKm = this.position.y / 1000; |
| const altitudeMultiplier = 1 + (altitudeInKm * 0.2); |
| |
| |
| |
| let throttleGMultiplier = 0; |
| if (this.throttle > 0.5) { |
| |
| throttleGMultiplier = (this.throttle - 0.5) * 2.0; |
| } |
| |
| |
| let abnormalG = 0; |
| |
| |
| const isInverted = Math.abs(this.rotation.z) > Math.PI / 2; |
| if (isInverted) { |
| const baseG = 3.0 + Math.abs(Math.abs(this.rotation.z) - Math.PI / 2) * 2; |
| abnormalG += baseG * altitudeMultiplier * (1 + throttleGMultiplier); |
| } |
| |
| |
| const rollDegrees = Math.abs(this.rotation.z) * (180 / Math.PI); |
| if (rollDegrees > 30) { |
| |
| const extremeRollG = (rollDegrees - 30) * 0.1; |
| abnormalG += extremeRollG * altitudeMultiplier * (1 + throttleGMultiplier); |
| } |
| |
| |
| if (pitchDegrees >= 40) { |
| |
| const extremePitchG = (pitchDegrees - 40) * 0.15; |
| abnormalG += extremePitchG * altitudeMultiplier * (1 + throttleGMultiplier); |
| } |
| |
| |
| if (pitchAngle < -Math.PI / 6) { |
| const baseG = 2.0 + Math.abs(pitchAngle + Math.PI / 6) * 3; |
| abnormalG += baseG * altitudeMultiplier * (1 + throttleGMultiplier); |
| } |
| |
| |
| if (pitchAngle > Math.PI / 3) { |
| const baseG = 2.0 + Math.abs(pitchAngle - Math.PI / 3) * 3; |
| abnormalG += baseG * altitudeMultiplier * (1 + throttleGMultiplier); |
| } |
| |
| |
| const maneuverG = (turnRate + pitchRate + (Math.abs(this.rotation.z) * 3)) * (1 + throttleGMultiplier * 0.5); |
| |
| |
| this.gForce = 1.0 + maneuverG + abnormalG; |
| |
| |
| |
| |
| |
| const isPitchNeutral = Math.abs(pitchDegrees) <= 10; |
| |
| if (!this.overG && isPitchNeutral && !isInverted && !this.stallWarning) { |
| |
| const recoveryRate = 2.0 - throttleGMultiplier * 1.5; |
| this.gForce = THREE.MathUtils.lerp(this.gForce, 1.0 + maneuverG, deltaTime * recoveryRate); |
| } else if (this.overG) { |
| |
| if (!isPitchNeutral || this.stallWarning) { |
| |
| this.gForce = Math.max(this.gForce, 1.0 + maneuverG + abnormalG); |
| } else { |
| |
| const overGRecoveryRate = 0.3 - throttleGMultiplier * 0.2; |
| this.gForce = THREE.MathUtils.lerp(this.gForce, 1.0 + maneuverG, deltaTime * overGRecoveryRate); |
| } |
| } |
| |
| |
| if (this.stallWarning && this.overG) { |
| |
| this.gForce = Math.max(this.gForce, 9.0); |
| } |
| |
| this.overG = this.gForce > this.maxGForce; |
| |
| |
| if (this.overG) { |
| this.overGTimer += deltaTime; |
| |
| |
| if (this.overGTimer > 1.5) { |
| |
| if (this.overGTimer > 2.5) { |
| targetSpeed *= Math.max(0.3, 1 - (this.overGTimer - 2.5) * 0.3); |
| } |
| |
| } |
| } else { |
| this.overGTimer = 0; |
| } |
| |
| |
| const speedKnots = this.speed * 1.94384; |
| const wasStalling = this.stallWarning; |
| |
| |
| if (!this.stallWarning && speedKnots < GAME_CONSTANTS.STALL_SPEED) { |
| this.stallWarning = true; |
| this.stallEscapeProgress = 0; |
| } |
| |
| |
| if (this.stallWarning) { |
| if (this.escapeKeyPressed) { |
| |
| this.stallEscapeProgress += deltaTime; |
| |
| |
| if (this.stallEscapeProgress >= 2.0 && speedKnots >= 350) { |
| this.stallWarning = false; |
| this.stallEscapeProgress = 0; |
| |
| this.targetPitch = Math.max(-0.2, Math.min(0.2, this.targetPitch)); |
| } |
| } else { |
| |
| this.stallEscapeProgress = Math.max(0, this.stallEscapeProgress - deltaTime * 2); |
| } |
| } |
| |
| |
| if (this.stallWarning) { |
| |
| if (pitchAngle > 0.1) { |
| |
| const diveSpeedGain = Math.min(pitchAngle * 500, 400); |
| this.speed = Math.min(maxSpeed, this.speed + diveSpeedGain * deltaTime); |
| } else { |
| |
| this.speed = Math.max(0, this.speed - deltaTime * 100); |
| } |
| } else { |
| |
| this.speed = THREE.MathUtils.lerp(this.speed, targetSpeed, deltaTime * 0.5); |
| } |
| |
| |
| if (this.stallWarning) { |
| |
| this.targetPitch = Math.min(Math.PI / 2.5, this.targetPitch + deltaTime * 2.5); |
| |
| |
| this.rotation.x += (Math.random() - 0.5) * deltaTime * 0.8; |
| this.rotation.z += (Math.random() - 0.5) * deltaTime * 0.8; |
| |
| |
| const gravityAcceleration = GAME_CONSTANTS.GRAVITY * deltaTime * 3.0; |
| this.velocity.y -= gravityAcceleration; |
| } |
| |
| |
| const noseDirection = new THREE.Vector3(0, 0, 1); |
| |
| |
| const quaternion = new THREE.Quaternion(); |
| |
| |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| |
| const xAxis = new THREE.Vector3(1, 0, 0); |
| const yAxis = new THREE.Vector3(0, 1, 0); |
| const zAxis = new THREE.Vector3(0, 0, 1); |
| |
| |
| pitchQuat.setFromAxisAngle(xAxis, this.rotation.x); |
| yawQuat.setFromAxisAngle(yAxis, this.rotation.y); |
| rollQuat.setFromAxisAngle(zAxis, this.rotation.z); |
| |
| |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| |
| noseDirection.applyQuaternion(quaternion); |
| |
| if (!this.stallWarning) { |
| |
| this.velocity = noseDirection.multiplyScalar(this.speed); |
| } else { |
| |
| this.velocity.x = noseDirection.x * this.speed * 0.5; |
| this.velocity.z = noseDirection.z * this.speed * 0.5; |
| |
| } |
| |
| |
| if (!this.stallWarning) { |
| const gravityEffect = GAME_CONSTANTS.GRAVITY * deltaTime * 0.15; |
| this.velocity.y -= gravityEffect; |
| |
| |
| const liftFactor = Math.min((this.speed / 500), 1.0) * 0.8; |
| const lift = gravityEffect * liftFactor; |
| this.velocity.y += lift; |
| } |
| |
| |
| this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); |
| |
| |
| if (this.position.y <= GAME_CONSTANTS.MIN_ALTITUDE) { |
| this.position.y = GAME_CONSTANTS.MIN_ALTITUDE; |
| this.health = 0; |
| |
| |
| if (window.gameInstance) { |
| window.gameInstance.createExplosionEffect(this.position); |
| } |
| |
| return; |
| } |
| |
| |
| if (this.position.y > GAME_CONSTANTS.MAX_ALTITUDE) { |
| this.position.y = GAME_CONSTANTS.MAX_ALTITUDE; |
| this.altitudeWarning = true; |
| if (this.velocity.y > 0) this.velocity.y = 0; |
| } else { |
| this.altitudeWarning = false; |
| } |
| |
| |
| const mapLimit = GAME_CONSTANTS.MAP_SIZE / 2; |
| if (this.position.x > mapLimit) this.position.x = -mapLimit; |
| if (this.position.x < -mapLimit) this.position.x = mapLimit; |
| if (this.position.z > mapLimit) this.position.z = -mapLimit; |
| if (this.position.z < -mapLimit) this.position.z = mapLimit; |
| |
| |
| this.mesh.position.copy(this.position); |
| |
| |
| const meshQuaternion = new THREE.Quaternion(); |
| |
| |
| const meshPitchQuat = new THREE.Quaternion(); |
| const meshYawQuat = new THREE.Quaternion(); |
| const meshRollQuat = new THREE.Quaternion(); |
| |
| meshPitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| meshYawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y + 3 * Math.PI / 2); |
| meshRollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| |
| meshQuaternion.multiply(meshRollQuat); |
| meshQuaternion.multiply(meshPitchQuat); |
| meshQuaternion.multiply(meshYawQuat); |
| |
| this.mesh.quaternion.copy(meshQuaternion); |
| |
| |
| this.warningBlinkTimer += deltaTime; |
| if (this.warningBlinkTimer >= 1.0) { |
| this.warningBlinkTimer = 0; |
| this.warningBlinkState = !this.warningBlinkState; |
| } |
| |
| |
| this.altitude = this.position.y; |
| |
| |
| this.updateWarningAudios(); |
| |
| |
| if (this.warningAudios.normal && !this.warningAudios.normal.paused) { |
| this.warningAudios.normal.volume = 0.3 + this.throttle * 0.4; |
| } |
| |
| |
| for (let i = this.deployedFlares.length - 1; i >= 0; i--) { |
| const flare = this.deployedFlares[i]; |
| if (!flare.update(deltaTime)) { |
| this.deployedFlares.splice(i, 1); |
| } |
| } |
| } |
|
|
| shoot(scene) { |
| if (this.currentWeapon === 'MG') { |
| |
| |
| if (this.ammo <= 0) return; |
| |
| this.ammo--; |
| |
| |
| const bulletGeometry = new THREE.CylinderGeometry(1.0, 1.0, 16, 8); |
| const bulletMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xffff00, |
| }); |
| const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); |
| |
| |
| const muzzleOffset = new THREE.Vector3(0, 0, 10); |
| |
| |
| const quaternion = new THREE.Quaternion(); |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| pitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| yawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); |
| rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| muzzleOffset.applyQuaternion(quaternion); |
| bullet.position.copy(this.position).add(muzzleOffset); |
| |
| |
| bullet.quaternion.copy(quaternion); |
| |
| const cylinderRotation = new THREE.Quaternion(); |
| cylinderRotation.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2); |
| bullet.quaternion.multiply(cylinderRotation); |
| |
| |
| bullet.startPosition = bullet.position.clone(); |
| |
| const bulletSpeed = 1500; |
| const direction = new THREE.Vector3(0, 0, 1); |
| direction.applyQuaternion(quaternion); |
| bullet.velocity = direction.multiplyScalar(bulletSpeed); |
| |
| scene.add(bullet); |
| this.bullets.push(bullet); |
| |
| |
| try { |
| const audio = new Audio('sounds/m134.ogg'); |
| audio.volume = 0.15; |
| |
| |
| const randomPitch = 0.8 + Math.random() * 0.4; |
| audio.playbackRate = randomPitch; |
| |
| audio.play().catch(e => console.log('Gunfire sound failed to play')); |
| |
| |
| audio.addEventListener('ended', () => { |
| audio.remove(); |
| }); |
| } catch (e) { |
| console.log('Audio error:', e); |
| } |
| } else if (this.currentWeapon === 'AIM9') { |
| |
| if (this.aim9Missiles <= 0) return; |
| if (this.lockProgress < GAME_CONSTANTS.AIM9_LOCK_REQUIRED) return; |
| if (!this.lockTarget) return; |
| |
| this.aim9Missiles--; |
| |
| |
| const isLeftWing = Math.random() < 0.5; |
| const wingOffset = new THREE.Vector3(isLeftWing ? -8 : 8, -1, 2); |
| |
| |
| const quaternion = new THREE.Quaternion(); |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| pitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| yawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); |
| rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| wingOffset.applyQuaternion(quaternion); |
| const missileStartPos = this.position.clone().add(wingOffset); |
| |
| |
| const missile = new AIM9Missile(scene, missileStartPos, this.lockTarget, this.rotation.clone()); |
| this.firedMissiles.push(missile); |
| |
| |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| if (this.lockAudios.locking && !this.lockAudios.locking.paused) { |
| this.lockAudios.locking.pause(); |
| this.lockAudios.locking.currentTime = 0; |
| } |
| |
| |
| try { |
| const missileSound = new Audio('sounds/missile.ogg'); |
| missileSound.volume = 0.7; |
| missileSound.play().catch(e => {}); |
| } catch (e) { |
| console.log('Missile sound failed:', e); |
| } |
| } |
| } |
| deployFlares() { |
| if (this.flareCount <= 0) return; |
| |
| const now = Date.now(); |
| if (now - this.lastFlareTime < 2000) return; |
| |
| this.flareCount--; |
| this.lastFlareTime = now; |
| |
| |
| const tailOffset = new THREE.Vector3(0, 0, -15); |
| const quaternion = new THREE.Quaternion(); |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| pitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| yawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); |
| rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| tailOffset.applyQuaternion(quaternion); |
| const flareStartPos = this.position.clone().add(tailOffset); |
| |
| |
| const backwardDirection = new THREE.Vector3(0, 0, -1); |
| backwardDirection.applyQuaternion(quaternion); |
| |
| |
| for (let i = 0; i < 4; i++) { |
| const isLeftSide = i < 2; |
| const flare = new Flare(window.gameInstance.scene, flareStartPos, backwardDirection, isLeftSide); |
| this.deployedFlares.push(flare); |
| |
| |
| if (window.gameInstance) { |
| window.gameInstance.notifyMissilesOfFlare(flare); |
| } |
| } |
| |
| |
| |
| |
| try { |
| const flareSound = new Audio('sounds/flare.ogg'); |
| flareSound.volume = 0.6; |
| flareSound.play().catch(e => { |
| console.log('Flare sound not found, using alternative'); |
| |
| const altSound = new Audio('sounds/missile.ogg'); |
| altSound.volume = 0.3; |
| altSound.playbackRate = 2.0; |
| altSound.play().catch(e => {}); |
| }); |
| } catch (e) {} |
| |
| console.log(`Flares deployed! Remaining: ${this.flareCount}`); |
| } |
|
|
| updateBullets(scene, deltaTime, gameInstance) { |
| for (let i = this.bullets.length - 1; i >= 0; i--) { |
| const bullet = this.bullets[i]; |
| bullet.position.add(bullet.velocity.clone().multiplyScalar(deltaTime)); |
| |
| |
| const direction = bullet.velocity.clone().normalize(); |
| const angle = Math.atan2(direction.x, direction.z); |
| bullet.rotation.y = angle; |
| |
| |
| if (bullet.position.y <= 0) { |
| |
| gameInstance.createGroundImpactEffect(bullet.position); |
| scene.remove(bullet); |
| this.bullets.splice(i, 1); |
| continue; |
| } |
| |
| |
| if (bullet.position.distanceTo(bullet.startPosition) > 6000 || |
| bullet.position.y > GAME_CONSTANTS.MAX_ALTITUDE + 500) { |
| scene.remove(bullet); |
| this.bullets.splice(i, 1); |
| } |
| } |
| |
| |
| for (let i = this.firedMissiles.length - 1; i >= 0; i--) { |
| const missile = this.firedMissiles[i]; |
| const result = missile.update(deltaTime, this.position); |
| |
| if (result === 'hit' || result === 'expired') { |
| this.firedMissiles.splice(i, 1); |
| } |
| } |
| } |
|
|
| takeDamage(damage) { |
| this.health -= damage; |
| return this.health <= 0; |
| } |
|
|
| getCameraPosition() { |
| const backward = new THREE.Vector3(0, 0, -1); |
| const up = new THREE.Vector3(0, 1, 0); |
| |
| |
| const quaternion = new THREE.Quaternion(); |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| pitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| yawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); |
| rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| backward.applyQuaternion(quaternion); |
| up.applyQuaternion(quaternion); |
| |
| const cameraPosition = this.position.clone() |
| .add(backward.multiplyScalar(this.cameraDistance)) |
| .add(up.multiplyScalar(this.cameraHeight)); |
| |
| return cameraPosition; |
| } |
|
|
| getCameraTarget() { |
| return this.position.clone(); |
| } |
| |
| |
| getHeadingDegrees() { |
| return this.normalizeHeading(this.rotation.y); |
| } |
| } |
|
|
| |
| class Flare { |
| constructor(scene, position, direction, isLeftSide) { |
| this.scene = scene; |
| this.position = position.clone(); |
| this.velocity = direction.clone().multiplyScalar(200); |
| |
| |
| const sideOffset = isLeftSide ? -1 : 1; |
| const perpendicular = new THREE.Vector3(sideOffset * 50, -20, (Math.random() - 0.5) * 30); |
| this.velocity.add(perpendicular); |
| |
| this.lifeTime = 5.0; |
| this.mesh = null; |
| this.createFlare(); |
| |
| |
| this.heatSignature = 1.0; |
| } |
| |
| createFlare() { |
| |
| const group = new THREE.Group(); |
| |
| |
| const coreGeometry = new THREE.SphereGeometry(0.5, 6, 6); |
| const coreMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xffffff, |
| transparent: true, |
| opacity: 1.0 |
| }); |
| const core = new THREE.Mesh(coreGeometry, coreMaterial); |
| group.add(core); |
| |
| |
| const glowGeometry = new THREE.SphereGeometry(2, 8, 8); |
| const glowMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xff6600, |
| transparent: true, |
| opacity: 0.6 |
| }); |
| const glow = new THREE.Mesh(glowGeometry, glowMaterial); |
| group.add(glow); |
| |
| |
| const outerGlowGeometry = new THREE.SphereGeometry(4, 8, 8); |
| const outerGlowMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xff3300, |
| transparent: true, |
| opacity: 0.3 |
| }); |
| const outerGlow = new THREE.Mesh(outerGlowGeometry, outerGlowMaterial); |
| group.add(outerGlow); |
| |
| this.mesh = group; |
| this.mesh.position.copy(this.position); |
| this.scene.add(this.mesh); |
| |
| |
| this.smokeTrail = []; |
| this.smokeEmitTime = 0; |
| } |
| |
| update(deltaTime) { |
| this.lifeTime -= deltaTime; |
| if (this.lifeTime <= 0) { |
| this.destroy(); |
| return false; |
| } |
| |
| |
| this.heatSignature = Math.max(0, this.heatSignature - deltaTime * 0.15); |
| |
| |
| this.velocity.y -= 50 * deltaTime; |
| |
| |
| this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); |
| this.mesh.position.copy(this.position); |
| |
| |
| const fadeRatio = this.lifeTime / 5.0; |
| this.mesh.children[0].material.opacity = fadeRatio; |
| this.mesh.children[1].material.opacity = fadeRatio * 0.6; |
| this.mesh.children[2].material.opacity = fadeRatio * 0.3; |
| |
| |
| this.smokeEmitTime += deltaTime; |
| if (this.smokeEmitTime >= 0.05) { |
| this.smokeEmitTime = 0; |
| this.createSmoke(); |
| } |
| |
| |
| for (let i = this.smokeTrail.length - 1; i >= 0; i--) { |
| const smoke = this.smokeTrail[i]; |
| smoke.life -= deltaTime; |
| |
| if (smoke.life <= 0) { |
| this.scene.remove(smoke.mesh); |
| this.smokeTrail.splice(i, 1); |
| } else { |
| smoke.mesh.scale.multiplyScalar(1.05); |
| smoke.mesh.material.opacity = smoke.life / 2.0 * 0.5; |
| } |
| } |
| |
| |
| if (this.position.y <= 0) { |
| this.destroy(); |
| return false; |
| } |
| |
| return true; |
| } |
| |
| createSmoke() { |
| const smokeGeometry = new THREE.SphereGeometry(1 + Math.random() * 1, 6, 6); |
| const smokeMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xcccccc, |
| transparent: true, |
| opacity: 0.5 |
| }); |
| |
| const smoke = new THREE.Mesh(smokeGeometry, smokeMaterial); |
| smoke.position.copy(this.position); |
| smoke.position.add(new THREE.Vector3( |
| (Math.random() - 0.5) * 2, |
| (Math.random() - 0.5) * 2, |
| (Math.random() - 0.5) * 2 |
| )); |
| |
| this.scene.add(smoke); |
| this.smokeTrail.push({ |
| mesh: smoke, |
| life: 2.0 |
| }); |
| |
| |
| if (this.smokeTrail.length > 30) { |
| const oldSmoke = this.smokeTrail.shift(); |
| this.scene.remove(oldSmoke.mesh); |
| } |
| } |
| |
| destroy() { |
| if (this.mesh) { |
| this.scene.remove(this.mesh); |
| } |
| this.smokeTrail.forEach(smoke => { |
| this.scene.remove(smoke.mesh); |
| }); |
| this.smokeTrail = []; |
| } |
| } |
|
|
| |
| class AIM9Missile { |
| constructor(scene, position, target, rotation) { |
| this.scene = scene; |
| this.position = position.clone(); |
| this.target = target; |
| this.rotation = rotation.clone(); |
| this.speed = 1028.8; |
| this.mesh = null; |
| this.isLoaded = false; |
| this.lifeTime = 30; |
| this.turnRate = 6.0; |
| this.startPosition = position.clone(); |
| |
| |
| this.smokeTrail = []; |
| this.smokeEmitTime = 0; |
| |
| |
| const quaternion = new THREE.Quaternion(); |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| pitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| yawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); |
| rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| const initialDirection = new THREE.Vector3(0, 0, 1); |
| initialDirection.applyQuaternion(quaternion); |
| this.velocity = initialDirection.multiplyScalar(this.speed); |
| |
| |
| this.swingAudio = null; |
| this.swingAudioPlayed = false; |
| this.initializeAudio(); |
| |
| this.createMissile(); |
| } |
| |
| initializeAudio() { |
| |
| |
| } |
| |
| createMissile() { |
| |
| this.createFallbackMissile(); |
| |
| this.mesh.position.copy(this.position); |
| this.scene.add(this.mesh); |
| |
| |
| const loader = new GLTFLoader(); |
| loader.load('models/aim-9.glb', |
| (result) => { |
| |
| if (this.mesh && this.mesh.parent) { |
| this.scene.remove(this.mesh); |
| this.mesh = result.scene; |
| this.mesh.scale.set(0.75, 0.75, 0.75); |
| this.mesh.position.copy(this.position); |
| this.scene.add(this.mesh); |
| console.log('AIM-9 model loaded and replaced'); |
| } |
| }, |
| undefined, |
| (error) => { |
| console.log('AIM-9 model not found, keeping fallback'); |
| } |
| ); |
| } |
| |
| createFallbackMissile() { |
| |
| const group = new THREE.Group(); |
| |
| |
| const bodyGeometry = new THREE.CylinderGeometry(0.3, 0.4, 4, 8); |
| const bodyMaterial = new THREE.MeshLambertMaterial({ color: 0x808080 }); |
| const body = new THREE.Mesh(bodyGeometry, bodyMaterial); |
| body.rotation.x = Math.PI / 2; |
| group.add(body); |
| |
| |
| const noseGeometry = new THREE.ConeGeometry(0.3, 1, 8); |
| const noseMaterial = new THREE.MeshLambertMaterial({ color: 0x404040 }); |
| const nose = new THREE.Mesh(noseGeometry, noseMaterial); |
| nose.position.z = 2.5; |
| nose.rotation.x = -Math.PI / 2; |
| group.add(nose); |
| |
| |
| const finGeometry = new THREE.BoxGeometry(2, 0.1, 0.5); |
| const finMaterial = new THREE.MeshLambertMaterial({ color: 0x606060 }); |
| |
| for (let i = 0; i < 4; i++) { |
| const fin = new THREE.Mesh(finGeometry, finMaterial); |
| fin.position.z = -1.5; |
| fin.rotation.z = (Math.PI / 2) * i; |
| group.add(fin); |
| } |
| |
| |
| const flameGeometry = new THREE.ConeGeometry(0.4, 1.5, 8); |
| const flameMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xff4400, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| const flame = new THREE.Mesh(flameGeometry, flameMaterial); |
| flame.position.z = -2.5; |
| flame.rotation.x = Math.PI / 2; |
| group.add(flame); |
| |
| this.mesh = group; |
| this.mesh.scale.set(3.375, 3.375, 3.375); |
| this.isLoaded = true; |
| } |
| |
| update(deltaTime, playerPosition) { |
| if (!this.mesh || !this.target || !this.target.position) { |
| this.destroy(); |
| return 'expired'; |
| } |
| |
| |
| |
| if (window.gameInstance && !this.isTargetingFlare) { |
| const nearestFlare = window.gameInstance.findNearestFlare(this.position); |
| if (nearestFlare) { |
| const flareDistance = this.position.distanceTo(nearestFlare.position); |
| |
| if (flareDistance < 1000) { |
| |
| console.log('Missile redirected to flare! Distance:', flareDistance); |
| this.target = nearestFlare; |
| this.isTargetingFlare = true; |
| |
| |
| try { |
| const evadeSound = new Audio('sounds/missileswing3.ogg'); |
| evadeSound.volume = 0.6; |
| evadeSound.play().catch(e => {}); |
| } catch (e) {} |
| } |
| } |
| } |
| |
| |
| if (this.isTargetingFlare && (!this.target.mesh || this.target.lifeTime <= 0)) { |
| |
| this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); |
| this.mesh.position.copy(this.position); |
| |
| this.lifeTime -= deltaTime; |
| if (this.lifeTime <= 0 || this.position.y <= 0) { |
| this.destroy(); |
| return 'expired'; |
| } |
| |
| |
| this.smokeEmitTime += deltaTime; |
| if (this.smokeEmitTime >= 0.02) { |
| this.smokeEmitTime = 0; |
| this.createSmokeParticle(); |
| } |
| |
| |
| for (let i = this.smokeTrail.length - 1; i >= 0; i--) { |
| const smoke = this.smokeTrail[i]; |
| smoke.life -= deltaTime; |
| |
| if (smoke.life <= 0) { |
| this.scene.remove(smoke.mesh); |
| this.smokeTrail.splice(i, 1); |
| } else { |
| smoke.mesh.scale.multiplyScalar(1.02); |
| smoke.mesh.material.opacity = smoke.life / 3.0; |
| smoke.mesh.position.y += deltaTime * 2; |
| } |
| } |
| |
| return 'flying'; |
| } |
| |
| |
| this.lifeTime -= deltaTime; |
| if (this.lifeTime <= 0) { |
| this.destroy(); |
| return 'expired'; |
| } |
| |
| |
| if (!this.swingAudioPlayed && this.lifeTime < 29.5) { |
| try { |
| this.swingAudio = new Audio('sounds/missileswing.ogg'); |
| this.swingAudio.volume = 0.5; |
| this.swingAudio.play().catch(e => {}); |
| this.swingAudioPlayed = true; |
| } catch (e) { |
| console.log('Missile swing audio failed:', e); |
| } |
| } |
| |
| |
| const toTarget = this.target.position.clone().sub(this.position); |
| const distance = toTarget.length(); |
| |
| |
| if (distance < 50) { |
| |
| console.log('AIM-9 missile hit! Distance:', distance); |
| this.onHit(); |
| return 'hit'; |
| } |
| |
| |
| toTarget.normalize(); |
| |
| |
| const newDirection = toTarget.clone(); |
| |
| |
| if (this.lifeTime > 25) { |
| |
| this.speed = Math.min(this.speed + deltaTime * 300, 2057.6); |
| } else { |
| |
| this.speed = Math.min(this.speed + deltaTime * 100, 2057.6); |
| } |
| |
| |
| if (distance < 1000) { |
| |
| this.speed = Math.min(this.speed * 1.2, 2572); |
| } |
| |
| this.velocity = newDirection.multiplyScalar(this.speed); |
| |
| |
| this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); |
| this.mesh.position.copy(this.position); |
| |
| |
| this.mesh.lookAt(this.target.position); |
| |
| |
| this.smokeEmitTime += deltaTime; |
| if (this.smokeEmitTime >= 0.02) { |
| this.smokeEmitTime = 0; |
| this.createSmokeParticle(); |
| } |
| |
| |
| for (let i = this.smokeTrail.length - 1; i >= 0; i--) { |
| const smoke = this.smokeTrail[i]; |
| smoke.life -= deltaTime; |
| |
| if (smoke.life <= 0) { |
| this.scene.remove(smoke.mesh); |
| this.smokeTrail.splice(i, 1); |
| } else { |
| |
| smoke.mesh.scale.multiplyScalar(1.02); |
| smoke.mesh.material.opacity = smoke.life / 3.0; |
| |
| |
| smoke.mesh.position.y += deltaTime * 2; |
| } |
| } |
| |
| |
| if (this.position.y <= 0) { |
| this.destroy(); |
| return 'expired'; |
| } |
| |
| if (this.target.position.y <= 50 && distance < 200) { |
| |
| console.log('AIM-9 missile ground proximity hit!'); |
| this.onHit(); |
| return 'hit'; |
| } |
| |
| return 'flying'; |
| } |
| |
| createSmokeParticle() { |
| |
| const backward = this.velocity.clone().normalize().multiplyScalar(-3); |
| const smokePos = this.position.clone().add(backward); |
| |
| |
| const smokeGeometry = new THREE.SphereGeometry( |
| 1 + Math.random() * 3.5, |
| 6, |
| 6 |
| ); |
| const smokeMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xffffff, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| |
| const smoke = new THREE.Mesh(smokeGeometry, smokeMaterial); |
| smoke.position.copy(smokePos); |
| |
| |
| smoke.position.x += (Math.random() - 0.5) * 1; |
| smoke.position.y += (Math.random() - 0.5) * 1; |
| smoke.position.z += (Math.random() - 0.5) * 1; |
| |
| this.scene.add(smoke); |
| |
| this.smokeTrail.push({ |
| mesh: smoke, |
| life: 3.0 |
| }); |
| |
| |
| if (this.smokeTrail.length > 150) { |
| const oldSmoke = this.smokeTrail.shift(); |
| this.scene.remove(oldSmoke.mesh); |
| } |
| } |
| |
| onHit() { |
| |
| if (window.gameInstance) { |
| window.gameInstance.createExplosionEffect(this.position); |
| } |
| |
| |
| try { |
| const hitSound = new Audio('sounds/missilehit.ogg'); |
| hitSound.volume = 0.8; |
| hitSound.play().catch(e => {}); |
| } catch (e) { |
| console.log('Missile hit sound failed:', e); |
| } |
| |
| |
| if (this.target && this.target.takeDamage) { |
| console.log('AIM-9 hitting target, damage:', GAME_CONSTANTS.AIM9_DAMAGE); |
| const isDead = this.target.takeDamage(GAME_CONSTANTS.AIM9_DAMAGE); |
| console.log('Target destroyed:', isDead); |
| |
| |
| if (isDead && window.gameInstance) { |
| |
| const enemyIndex = window.gameInstance.enemies.indexOf(this.target); |
| if (enemyIndex !== -1) { |
| console.log('Removing destroyed enemy from game'); |
| this.target.destroy(); |
| window.gameInstance.enemies.splice(enemyIndex, 1); |
| window.gameInstance.score += 100; |
| |
| try { |
| const killVoice = new Audio('sounds/voice_kill_a.ogg'); |
| killVoice.volume = 0.8; |
| killVoice.play().catch(e => { |
| console.log('Kill voice failed to play:', e); |
| }); |
| } catch (e) { |
| console.log('Kill voice error:', e); |
| } |
| |
| |
| window.gameInstance.showSubtitle('Nice work, one enemy down. Keep going.', 4000); |
| } |
| } |
| } |
| |
| this.destroy(); |
| } |
| |
| destroy() { |
| if (this.mesh) { |
| this.scene.remove(this.mesh); |
| } |
| |
| |
| this.smokeTrail.forEach(smoke => { |
| if (smoke.mesh) { |
| this.scene.remove(smoke.mesh); |
| } |
| }); |
| this.smokeTrail = []; |
| |
| if (this.swingAudio) { |
| this.swingAudio.pause(); |
| this.swingAudio = null; |
| } |
| } |
| } |
|
|
| |
| class EnemyAIM9Missile { |
| constructor(scene, position, target, rotation) { |
| this.scene = scene; |
| this.position = position.clone(); |
| this.target = target; |
| this.rotation = rotation.clone(); |
| this.speed = 1028.8; |
| this.mesh = null; |
| this.isLoaded = false; |
| this.lifeTime = 20; |
| this.turnRate = 4.0; |
| this.startPosition = position.clone(); |
| |
| |
| this.smokeTrail = []; |
| this.smokeEmitTime = 0; |
| |
| |
| const quaternion = new THREE.Quaternion(); |
| const pitchQuat = new THREE.Quaternion(); |
| const yawQuat = new THREE.Quaternion(); |
| const rollQuat = new THREE.Quaternion(); |
| |
| pitchQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.rotation.x); |
| yawQuat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); |
| rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), this.rotation.z); |
| |
| quaternion.multiply(rollQuat); |
| quaternion.multiply(pitchQuat); |
| quaternion.multiply(yawQuat); |
| |
| const initialDirection = new THREE.Vector3(0, 0, 1); |
| initialDirection.applyQuaternion(quaternion); |
| this.velocity = initialDirection.multiplyScalar(this.speed); |
| |
| this.createMissile(); |
| } |
| |
| createMissile() { |
| |
| const group = new THREE.Group(); |
| |
| |
| const bodyGeometry = new THREE.CylinderGeometry(0.3, 0.4, 4, 8); |
| const bodyMaterial = new THREE.MeshLambertMaterial({ color: 0x808080 }); |
| const body = new THREE.Mesh(bodyGeometry, bodyMaterial); |
| body.rotation.x = Math.PI / 2; |
| group.add(body); |
| |
| |
| const noseGeometry = new THREE.ConeGeometry(0.3, 1, 8); |
| const noseMaterial = new THREE.MeshLambertMaterial({ color: 0x404040 }); |
| const nose = new THREE.Mesh(noseGeometry, noseMaterial); |
| nose.position.z = 2.5; |
| nose.rotation.x = -Math.PI / 2; |
| group.add(nose); |
| |
| |
| const finGeometry = new THREE.BoxGeometry(2, 0.1, 0.5); |
| const finMaterial = new THREE.MeshLambertMaterial({ color: 0x606060 }); |
| |
| for (let i = 0; i < 4; i++) { |
| const fin = new THREE.Mesh(finGeometry, finMaterial); |
| fin.position.z = -1.5; |
| fin.rotation.z = (Math.PI / 2) * i; |
| group.add(fin); |
| } |
| |
| |
| const flameGeometry = new THREE.ConeGeometry(0.4, 1.5, 8); |
| const flameMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xff4400, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| const flame = new THREE.Mesh(flameGeometry, flameMaterial); |
| flame.position.z = -2.5; |
| flame.rotation.x = Math.PI / 2; |
| group.add(flame); |
| |
| this.mesh = group; |
| this.mesh.scale.set(3.375, 3.375, 3.375); |
| this.mesh.position.copy(this.position); |
| this.scene.add(this.mesh); |
| this.isLoaded = true; |
| } |
| |
| update(deltaTime, enemyPosition) { |
| if (!this.mesh || !this.target || !this.target.position) { |
| this.destroy(); |
| return 'expired'; |
| } |
| |
| |
| if (window.gameInstance && !this.isTargetingFlare) { |
| const nearestFlare = window.gameInstance.findNearestFlare(this.position); |
| if (nearestFlare) { |
| const flareDistance = this.position.distanceTo(nearestFlare.position); |
| if (flareDistance < 1000) { |
| console.log('Enemy missile redirected to flare!'); |
| this.target = nearestFlare; |
| this.isTargetingFlare = true; |
| } |
| } |
| } |
| |
| this.lifeTime -= deltaTime; |
| if (this.lifeTime <= 0) { |
| this.destroy(); |
| return 'expired'; |
| } |
| |
| |
| const toTarget = this.target.position.clone().sub(this.position); |
| const distance = toTarget.length(); |
| |
| |
| if (distance < 50) { |
| |
| console.log('Enemy AIM-9 missile hit player!'); |
| this.onHit(); |
| return 'hit'; |
| } |
| |
| |
| toTarget.normalize(); |
| |
| |
| const currentDirection = this.velocity.clone().normalize(); |
| |
| |
| const turnSpeed = this.turnRate * deltaTime; |
| const newDirection = currentDirection.lerp(toTarget, turnSpeed); |
| newDirection.normalize(); |
| |
| |
| this.velocity = newDirection.multiplyScalar(this.speed); |
| |
| |
| this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); |
| this.mesh.position.copy(this.position); |
| |
| |
| this.mesh.lookAt(this.target.position); |
| |
| |
| this.smokeEmitTime += deltaTime; |
| if (this.smokeEmitTime >= 0.02) { |
| this.smokeEmitTime = 0; |
| this.createSmokeParticle(); |
| } |
| |
| |
| for (let i = this.smokeTrail.length - 1; i >= 0; i--) { |
| const smoke = this.smokeTrail[i]; |
| smoke.life -= deltaTime; |
| |
| if (smoke.life <= 0) { |
| this.scene.remove(smoke.mesh); |
| this.smokeTrail.splice(i, 1); |
| } else { |
| |
| smoke.mesh.scale.multiplyScalar(1.02); |
| smoke.mesh.material.opacity = smoke.life / 3.0; |
| |
| |
| smoke.mesh.position.y += deltaTime * 2; |
| } |
| } |
| |
| |
| if (this.position.y <= 0) { |
| this.destroy(); |
| return 'expired'; |
| } |
| |
| return 'flying'; |
| } |
| |
| createSmokeParticle() { |
| |
| const backward = this.velocity.clone().normalize().multiplyScalar(-3); |
| const smokePos = this.position.clone().add(backward); |
| |
| |
| const smokeGeometry = new THREE.SphereGeometry( |
| 1 + Math.random() * 3.5, |
| 6, |
| 6 |
| ); |
| const smokeMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xffffff, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| |
| const smoke = new THREE.Mesh(smokeGeometry, smokeMaterial); |
| smoke.position.copy(smokePos); |
| |
| |
| smoke.position.x += (Math.random() - 0.5) * 1; |
| smoke.position.y += (Math.random() - 0.5) * 1; |
| smoke.position.z += (Math.random() - 0.5) * 1; |
| |
| this.scene.add(smoke); |
| |
| this.smokeTrail.push({ |
| mesh: smoke, |
| life: 3.0 |
| }); |
| |
| |
| if (this.smokeTrail.length > 150) { |
| const oldSmoke = this.smokeTrail.shift(); |
| this.scene.remove(oldSmoke.mesh); |
| } |
| } |
| |
| onHit() { |
| |
| if (window.gameInstance) { |
| window.gameInstance.createExplosionEffect(this.position); |
| } |
| |
| |
| if (this.target && this.target.takeDamage) { |
| this.target.takeDamage(GAME_CONSTANTS.AIM9_DAMAGE); |
| } |
| |
| this.destroy(); |
| } |
| |
| destroy() { |
| if (this.mesh) { |
| this.scene.remove(this.mesh); |
| } |
| |
| |
| this.smokeTrail.forEach(smoke => { |
| if (smoke.mesh) { |
| this.scene.remove(smoke.mesh); |
| } |
| }); |
| this.smokeTrail = []; |
| } |
| } |
|
|
|
|
| |
| |
| class EnemyFighter { |
| constructor(scene, position) { |
| this.mesh = null; |
| this.isLoaded = false; |
| this.scene = scene; |
| this.position = position.clone(); |
| this.rotation = new THREE.Euler(0, Math.random() * Math.PI * 2, 0); |
| |
| |
| this.speed = 600; |
| this.velocity = new THREE.Vector3(0, 0, 0); |
| this.health = GAME_CONSTANTS.MAX_HEALTH; |
| this.minSpeed = 257; |
| this.maxSpeed = 386; |
| |
| |
| this.aiState = 'patrol'; |
| this.targetPosition = null; |
| this.playerFighter = null; |
| |
| |
| this.temporaryEvadeMode = false; |
| this.evadeTimer = 0; |
| |
| |
| this.predictedPosition = new THREE.Vector3(); |
| |
| |
| this.bullets = []; |
| this.burstCounter = 0; |
| this.lastShootTime = 0; |
| this.canShoot = false; |
| |
| |
| this.targetRotation = this.rotation.clone(); |
| this.turnSpeed = 1.5; |
| |
| |
| this.nearbyEnemies = []; |
| this.avoidanceVector = new THREE.Vector3(); |
| |
| |
| this.aim9Missiles = GAME_CONSTANTS.ENEMY_AIM9_COUNT; |
| this.firedMissiles = []; |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| this.lastLockTime = 0; |
| this.lastMissileFireTime = 0; |
| this.isLocking = false; |
| |
| |
| this.flareCount = 1; |
| this.deployedFlares = []; |
| this.lastFlareTime = 0; |
| this.hasUsedFlare = false; |
| |
| |
| this.selectNewPatrolTarget(); |
| } |
| |
| async initialize(loader) { |
| try { |
| const result = await loader.loadAsync('models/mig-29.glb'); |
| this.mesh = result.scene; |
| this.mesh.position.copy(this.position); |
| this.mesh.scale.set(1.5, 1.5, 1.5); |
| this.mesh.rotation.y = Math.PI / 4; |
| |
| this.mesh.traverse((child) => { |
| if (child.isMesh) { |
| child.castShadow = true; |
| child.receiveShadow = true; |
| } |
| }); |
| |
| this.scene.add(this.mesh); |
| this.isLoaded = true; |
| console.log('MiG-29 적기 로딩 완료'); |
| } catch (error) { |
| console.error('MiG-29 모델 로딩 실패:', error); |
| this.createFallbackModel(); |
| } |
| } |
| |
| createFallbackModel() { |
| const group = new THREE.Group(); |
| |
| const fuselageGeometry = new THREE.CylinderGeometry(0.6, 1.0, 8, 8); |
| const fuselageMaterial = new THREE.MeshLambertMaterial({ color: 0x800000 }); |
| const fuselage = new THREE.Mesh(fuselageGeometry, fuselageMaterial); |
| fuselage.rotation.x = -Math.PI / 2; |
| group.add(fuselage); |
| |
| const wingGeometry = new THREE.BoxGeometry(12, 0.3, 3); |
| const wingMaterial = new THREE.MeshLambertMaterial({ color: 0x600000 }); |
| const wings = new THREE.Mesh(wingGeometry, wingMaterial); |
| wings.position.z = -0.5; |
| group.add(wings); |
| |
| this.mesh = group; |
| this.mesh.position.copy(this.position); |
| this.mesh.scale.set(1.5, 1.5, 1.5); |
| this.scene.add(this.mesh); |
| this.isLoaded = true; |
| } |
| |
| update(playerPosition, deltaTime) { |
| if (!this.mesh || !this.isLoaded) return; |
| |
| |
| for (let i = this.deployedFlares.length - 1; i >= 0; i--) { |
| const flare = this.deployedFlares[i]; |
| if (!flare.update(deltaTime)) { |
| this.deployedFlares.splice(i, 1); |
| } |
| } |
| |
| |
| if (!this.hasUsedFlare && this.flareCount > 0 && window.gameInstance) { |
| const incomingMissiles = window.gameInstance.findIncomingMissilesForEnemy(this); |
| if (incomingMissiles.length > 0) { |
| const nearestMissile = incomingMissiles[0]; |
| const missileDistance = this.position.distanceTo(nearestMissile.position); |
| |
| if (missileDistance < 500) { |
| this.deployFlares(); |
| this.hasUsedFlare = true; |
| } |
| } |
| } |
| |
| |
| if (this.temporaryEvadeMode && this.evadeTimer > 0) { |
| this.evadeTimer -= deltaTime; |
| if (this.evadeTimer <= 0) { |
| this.temporaryEvadeMode = false; |
| } |
| } |
| |
| const distanceToPlayer = this.position.distanceTo(playerPosition); |
| |
| |
| if (this.temporaryEvadeMode) { |
| this.aiState = 'evade'; |
| } else if (distanceToPlayer <= 3000) { |
| this.aiState = 'combat'; |
| } else { |
| this.aiState = 'patrol'; |
| } |
| |
| |
| this.calculateAvoidance(); |
| this.checkCollisionPrediction(deltaTime); |
| |
| |
| switch (this.aiState) { |
| case 'patrol': |
| this.executePatrol(deltaTime); |
| break; |
| case 'combat': |
| this.executeCombat(playerPosition, deltaTime); |
| break; |
| case 'evade': |
| this.executeEmergencyEvade(deltaTime); |
| break; |
| } |
| |
| |
| this.updatePhysics(deltaTime); |
| |
| |
| this.updateBullets(deltaTime); |
| |
| |
| this.updateMissiles(deltaTime); |
| |
| |
| if (this.playerFighter) { |
| this.updateLockOn(deltaTime); |
| } |
| } |
| |
| executePatrol(deltaTime) { |
| |
| if (!this.targetPosition || this.position.distanceTo(this.targetPosition) < 500) { |
| this.selectNewPatrolTarget(); |
| } |
| |
| |
| this.smoothTurnToTarget(this.targetPosition, deltaTime); |
| |
| |
| if (this.speed < this.minSpeed) { |
| this.speed = this.minSpeed; |
| } |
| this.speed = THREE.MathUtils.clamp(this.speed, this.minSpeed, this.maxSpeed); |
| } |
| |
| executeCombat(playerPosition, deltaTime) { |
| const distance = this.position.distanceTo(playerPosition); |
| |
| |
| this.smoothTurnToTarget(playerPosition, deltaTime); |
| |
| |
| const aimAccuracy = this.calculateAimAccuracy(playerPosition); |
| |
| |
| const isPitchLevel = Math.abs(this.rotation.x) < 0.1; |
| const isRollLevel = Math.abs(this.rotation.z) < 0.1; |
| const isStraightFlying = isPitchLevel && isRollLevel; |
| |
| |
| this.canShoot = isStraightFlying && aimAccuracy < 0.15; |
| |
| |
| if (this.canShoot) { |
| this.fireWeapon(); |
| } else { |
| |
| this.burstCounter = 0; |
| } |
| |
| |
| if (distance < 150) { |
| |
| this.selectEvadeTarget(); |
| |
| this.temporaryEvadeMode = true; |
| this.evadeTimer = 1.0; |
| } else if (distance < 500) { |
| |
| this.selectEvadeTarget(); |
| } else if (distance > 2000) { |
| |
| this.targetPosition = playerPosition.clone(); |
| } |
| } |
| |
| |
| checkCollisionPrediction(deltaTime) { |
| if (!this.nearbyEnemies) return; |
| |
| |
| const predictTime = 2.0; |
| const forward = new THREE.Vector3(0, 0, 1).applyEuler(this.rotation); |
| this.predictedPosition.copy(this.position).add(forward.multiplyScalar(this.speed * predictTime)); |
| |
| this.nearbyEnemies.forEach(enemy => { |
| if (enemy === this || !enemy.position) return; |
| |
| |
| const enemyForward = new THREE.Vector3(0, 0, 1).applyEuler(enemy.rotation); |
| const enemyPredicted = enemy.position.clone().add(enemyForward.multiplyScalar(enemy.speed * predictTime)); |
| |
| |
| const predictedDistance = this.predictedPosition.distanceTo(enemyPredicted); |
| |
| |
| if (predictedDistance < 150) { |
| |
| const avoidDir = this.predictedPosition.clone().sub(enemyPredicted).normalize(); |
| |
| |
| if (this.position.y > enemy.position.y) { |
| avoidDir.y += 0.3; |
| } else { |
| avoidDir.y -= 0.3; |
| } |
| |
| this.avoidanceVector.add(avoidDir.multiplyScalar(1.5)); |
| } |
| }); |
| } |
| |
| |
| executeEmergencyEvade(deltaTime) { |
| |
| if (this.avoidanceVector.length() > 0) { |
| const evadeDirection = this.avoidanceVector.clone().normalize(); |
| |
| |
| const targetYaw = Math.atan2(evadeDirection.x, evadeDirection.z); |
| const targetPitch = Math.asin(-evadeDirection.y) * 0.5; |
| |
| |
| const emergencyTurnSpeed = this.turnSpeed * 2.0; |
| const maxTurnRate = emergencyTurnSpeed * deltaTime; |
| |
| |
| const yawDiff = this.normalizeAngle(targetYaw - this.rotation.y); |
| if (Math.abs(yawDiff) > maxTurnRate) { |
| this.rotation.y += Math.sign(yawDiff) * maxTurnRate; |
| } else { |
| this.rotation.y = targetYaw; |
| } |
| |
| |
| const pitchDiff = targetPitch - this.rotation.x; |
| if (Math.abs(pitchDiff) > maxTurnRate * 0.7) { |
| this.rotation.x += Math.sign(pitchDiff) * maxTurnRate * 0.7; |
| } else { |
| this.rotation.x = targetPitch; |
| } |
| |
| |
| this.rotation.z = Math.sign(yawDiff) * Math.min(Math.abs(yawDiff), Math.PI / 6); |
| } else { |
| |
| this.rotation.x = -Math.PI / 6; |
| } |
| |
| |
| this.speed = this.maxSpeed; |
| } |
| |
| smoothTurnToTarget(targetPos, deltaTime, isEmergency = false) { |
| |
| const direction = targetPos.clone().sub(this.position); |
| direction.y *= 0.5; |
| direction.normalize(); |
| |
| |
| if (this.avoidanceVector.length() > 0) { |
| const avoidanceStrength = this.temporaryEvadeMode ? 1.0 : 0.5; |
| direction.add(this.avoidanceVector.multiplyScalar(avoidanceStrength)); |
| direction.normalize(); |
| } |
| |
| |
| const targetYaw = Math.atan2(direction.x, direction.z); |
| const targetPitch = Math.asin(-direction.y); |
| |
| |
| const turnSpeed = isEmergency ? this.turnSpeed * 2.0 : (this.temporaryEvadeMode ? this.turnSpeed * 1.5 : this.turnSpeed); |
| const maxTurnRate = turnSpeed * deltaTime; |
| |
| |
| const yawDiff = this.normalizeAngle(targetYaw - this.rotation.y); |
| if (Math.abs(yawDiff) > maxTurnRate) { |
| this.rotation.y += Math.sign(yawDiff) * maxTurnRate; |
| } else { |
| this.rotation.y = targetYaw; |
| } |
| |
| |
| const maxPitchRate = maxTurnRate * 0.7; |
| if (Math.abs(targetPitch - this.rotation.x) > maxPitchRate) { |
| this.rotation.x += Math.sign(targetPitch - this.rotation.x) * maxPitchRate; |
| } else { |
| this.rotation.x = targetPitch; |
| } |
| |
| |
| const maxPitchAngle = Math.PI * 40 / 180; |
| this.rotation.x = THREE.MathUtils.clamp(this.rotation.x, -maxPitchAngle, maxPitchAngle); |
| |
| |
| if (!this.temporaryEvadeMode) { |
| this.rotation.z = -yawDiff * 0.5; |
| this.rotation.z = THREE.MathUtils.clamp(this.rotation.z, -Math.PI / 4, Math.PI / 4); |
| } |
| } |
| |
| calculateAvoidance() { |
| this.avoidanceVector.set(0, 0, 0); |
| |
| if (!this.nearbyEnemies) return; |
| |
| let avoidCount = 0; |
| let criticalAvoidance = false; |
| |
| this.nearbyEnemies.forEach(enemy => { |
| if (enemy === this || !enemy.position) return; |
| |
| const distance = this.position.distanceTo(enemy.position); |
| |
| |
| if (distance < 100 && distance > 0) { |
| criticalAvoidance = true; |
| |
| |
| const avoidDir = this.position.clone().sub(enemy.position).normalize(); |
| const strength = 2.0; |
| this.avoidanceVector.add(avoidDir.multiplyScalar(strength)); |
| |
| |
| if (this.position.y > enemy.position.y) { |
| this.avoidanceVector.y += 0.5; |
| } else { |
| this.avoidanceVector.y -= 0.5; |
| } |
| |
| avoidCount++; |
| } |
| |
| else if (distance < 300) { |
| |
| const avoidDir = this.position.clone().sub(enemy.position).normalize(); |
| const strength = (300 - distance) / 200; |
| this.avoidanceVector.add(avoidDir.multiplyScalar(strength)); |
| avoidCount++; |
| } |
| }); |
| |
| if (avoidCount > 0) { |
| this.avoidanceVector.divideScalar(avoidCount); |
| this.avoidanceVector.normalize(); |
| |
| |
| if (criticalAvoidance) { |
| this.avoidanceVector.multiplyScalar(2.0); |
| |
| this.temporaryEvadeMode = true; |
| this.evadeTimer = 2.0; |
| } |
| } |
| } |
| |
| updatePhysics(deltaTime) { |
| if (!this.mesh) return; |
| |
| |
| const forward = new THREE.Vector3(0, 0, 1); |
| forward.applyEuler(this.rotation); |
| |
| |
| if (this.speed < this.minSpeed) { |
| this.speed = this.minSpeed; |
| } |
| this.speed = THREE.MathUtils.clamp(this.speed, this.minSpeed, this.maxSpeed); |
| |
| |
| this.velocity = forward.multiplyScalar(this.speed); |
| |
| |
| this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); |
| |
| |
| if (this.position.y < 500) { |
| this.position.y = 500; |
| this.rotation.x = -0.2; |
| } else if (this.position.y > 8000) { |
| this.position.y = 8000; |
| this.rotation.x = 0.2; |
| } |
| |
| |
| const mapLimit = GAME_CONSTANTS.MAP_SIZE / 2; |
| const boundaryBuffer = mapLimit * 0.9; |
| |
| if (Math.abs(this.position.x) > boundaryBuffer || Math.abs(this.position.z) > boundaryBuffer) { |
| |
| const centerDirection = new THREE.Vector3(-this.position.x, 0, -this.position.z).normalize(); |
| const targetYaw = Math.atan2(centerDirection.x, centerDirection.z); |
| this.rotation.y = targetYaw; |
| this.selectNewPatrolTarget(); |
| } |
| |
| |
| this.position.x = THREE.MathUtils.clamp(this.position.x, -mapLimit, mapLimit); |
| this.position.z = THREE.MathUtils.clamp(this.position.z, -mapLimit, mapLimit); |
| |
| |
| this.mesh.position.copy(this.position); |
| this.mesh.rotation.x = this.rotation.x; |
| this.mesh.rotation.y = this.rotation.y + 3 * Math.PI / 2; |
| this.mesh.rotation.z = this.rotation.z; |
| } |
| |
| fireWeapon() { |
| const now = Date.now(); |
| |
| |
| if (now - this.lastShootTime >= 100) { |
| |
| const isPitchLevel = Math.abs(this.rotation.x) < 0.1; |
| const isRollLevel = Math.abs(this.rotation.z) < 0.1; |
| |
| if (isPitchLevel && isRollLevel) { |
| this.shoot(); |
| this.lastShootTime = now; |
| this.burstCounter++; |
| |
| |
| if (this.burstCounter >= 10) { |
| this.burstCounter = 0; |
| this.lastShootTime = now + 1000; |
| } |
| } |
| } |
| } |
| |
| shoot() { |
| |
| const bulletGeometry = new THREE.CylinderGeometry(1.0, 1.0, 16, 8); |
| const bulletMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xff0000, |
| }); |
| const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); |
| |
| |
| const muzzleOffset = new THREE.Vector3(0, 0, 10); |
| muzzleOffset.applyEuler(this.rotation); |
| bullet.position.copy(this.position).add(muzzleOffset); |
| |
| |
| bullet.rotation.copy(this.rotation); |
| bullet.rotateX(Math.PI / 2); |
| |
| |
| bullet.startPosition = bullet.position.clone(); |
| |
| |
| const direction = new THREE.Vector3(0, 0, 1); |
| direction.applyEuler(this.rotation); |
| bullet.velocity = direction.multiplyScalar(1500); |
| |
| this.scene.add(bullet); |
| this.bullets.push(bullet); |
| |
| |
| if (this.playerFighter) { |
| const distanceToPlayer = this.position.distanceTo(this.playerFighter.position); |
| if (distanceToPlayer < 3000) { |
| try { |
| const audio = new Audio('sounds/MGLAUNCH.ogg'); |
| const volumeMultiplier = 1 - (distanceToPlayer / 3000); |
| audio.volume = 0.5 * volumeMultiplier; |
| audio.play().catch(e => {}); |
| } catch (e) {} |
| } |
| } |
| } |
| |
| updateBullets(deltaTime) { |
| for (let i = this.bullets.length - 1; i >= 0; i--) { |
| const bullet = this.bullets[i]; |
| bullet.position.add(bullet.velocity.clone().multiplyScalar(deltaTime)); |
| |
| |
| if (bullet.position.y <= 0) { |
| if (window.gameInstance) { |
| window.gameInstance.createGroundImpactEffect(bullet.position); |
| } |
| this.scene.remove(bullet); |
| this.bullets.splice(i, 1); |
| continue; |
| } |
| |
| |
| if (bullet.position.distanceTo(bullet.startPosition) > 6000 || |
| bullet.position.y > GAME_CONSTANTS.MAX_ALTITUDE + 500) { |
| this.scene.remove(bullet); |
| this.bullets.splice(i, 1); |
| } |
| } |
| } |
| |
| updateMissiles(deltaTime) { |
| for (let i = this.firedMissiles.length - 1; i >= 0; i--) { |
| const missile = this.firedMissiles[i]; |
| const result = missile.update(deltaTime, this.position); |
| |
| if (result === 'hit' || result === 'expired') { |
| this.firedMissiles.splice(i, 1); |
| } |
| } |
| } |
| |
| updateLockOn(deltaTime) { |
| if (!this.playerFighter || this.aim9Missiles <= 0) { |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| this.isLocking = false; |
| return; |
| } |
| |
| const now = Date.now(); |
| const distance = this.position.distanceTo(this.playerFighter.position); |
| |
| |
| if (distance <= GAME_CONSTANTS.ENEMY_LOCK_RANGE && this.aiState === 'combat') { |
| |
| const toPlayer = this.playerFighter.position.clone().sub(this.position).normalize(); |
| const forward = new THREE.Vector3(0, 0, 1).applyEuler(this.rotation); |
| const dotProduct = forward.dot(toPlayer); |
| const angle = Math.acos(Math.max(-1, Math.min(1, dotProduct))); |
| |
| if (angle < GAME_CONSTANTS.ENEMY_LOCK_ANGLE) { |
| |
| if (now - this.lastLockTime >= 1000) { |
| if (this.lockTarget === this.playerFighter) { |
| this.lockProgress++; |
| this.lastLockTime = now; |
| |
| |
| if (this.lockProgress === 1 && !this.isLocking) { |
| this.isLocking = true; |
| if (window.gameInstance) { |
| window.gameInstance.onEnemyLockStart(this); |
| } |
| } |
| |
| |
| if (this.lockProgress >= GAME_CONSTANTS.AIM9_LOCK_REQUIRED) { |
| |
| if (now - this.lastMissileFireTime >= 3000) { |
| this.fireAIM9(); |
| this.lastMissileFireTime = now; |
| } |
| |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| this.isLocking = false; |
| } |
| } else { |
| |
| this.lockTarget = this.playerFighter; |
| this.lockProgress = 1; |
| this.lastLockTime = now; |
| this.isLocking = true; |
| if (window.gameInstance) { |
| window.gameInstance.onEnemyLockStart(this); |
| } |
| } |
| } |
| } else { |
| |
| if (this.lockTarget) { |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| if (this.isLocking) { |
| this.isLocking = false; |
| if (window.gameInstance) { |
| window.gameInstance.onEnemyLockLost(this); |
| } |
| } |
| } |
| } |
| } else { |
| |
| if (this.lockTarget) { |
| this.lockTarget = null; |
| this.lockProgress = 0; |
| if (this.isLocking) { |
| this.isLocking = false; |
| if (window.gameInstance) { |
| window.gameInstance.onEnemyLockLost(this); |
| } |
| } |
| } |
| } |
| } |
| |
| fireAIM9() { |
| if (this.aim9Missiles <= 0 || !this.lockTarget) return; |
| |
| this.aim9Missiles--; |
| |
| |
| const isLeftWing = Math.random() < 0.5; |
| const wingOffset = new THREE.Vector3(isLeftWing ? -6 : 6, -1, 2); |
| wingOffset.applyEuler(this.rotation); |
| const missileStartPos = this.position.clone().add(wingOffset); |
| |
| |
| const missile = new EnemyAIM9Missile(this.scene, missileStartPos, this.lockTarget, this.rotation.clone()); |
| this.firedMissiles.push(missile); |
| |
| |
| if (window.gameInstance) { |
| window.gameInstance.onEnemyMissileLaunch(this, missile); |
| } |
| |
| |
| if (this.playerFighter) { |
| const distanceToPlayer = this.position.distanceTo(this.playerFighter.position); |
| if (distanceToPlayer < 3000) { |
| try { |
| const missileSound = new Audio('sounds/missile.ogg'); |
| const volumeMultiplier = 1 - (distanceToPlayer / 3000); |
| missileSound.volume = 0.5 * volumeMultiplier; |
| missileSound.play().catch(e => {}); |
| } catch (e) {} |
| } |
| } |
| } |
| |
| |
| deployFlares() { |
| if (this.flareCount <= 0) return; |
| |
| this.flareCount--; |
| |
| |
| const tailOffset = new THREE.Vector3(0, 0, -10); |
| tailOffset.applyEuler(this.rotation); |
| const flareStartPos = this.position.clone().add(tailOffset); |
| |
| |
| const backwardDirection = new THREE.Vector3(0, 0, -1); |
| backwardDirection.applyEuler(this.rotation); |
| |
| |
| for (let i = 0; i < 4; i++) { |
| const isLeftSide = i < 2; |
| const flare = new Flare(this.scene, flareStartPos, backwardDirection, isLeftSide); |
| this.deployedFlares.push(flare); |
| |
| if (window.gameInstance) { |
| window.gameInstance.notifyMissilesOfFlare(flare); |
| } |
| } |
| |
| |
| |
| console.log('Enemy deployed flares!'); |
| |
| |
| if (window.gameInstance) { |
| try { |
| const flareVoice = new Audio('sounds/voice_flare_a.ogg'); |
| flareVoice.volume = 0.8; |
| flareVoice.play().catch(e => { |
| console.log('Flare voice failed to play:', e); |
| }); |
| } catch (e) { |
| console.log('Flare voice error:', e); |
| } |
| |
| |
| window.gameInstance.showSubtitle('The enemy evaded the missile using flares!', 4000); |
| } |
| } |
| |
| calculateAimAccuracy(target) { |
| const toTarget = target.clone().sub(this.position).normalize(); |
| const forward = new THREE.Vector3(0, 0, 1).applyEuler(this.rotation); |
| const dotProduct = forward.dot(toTarget); |
| return Math.acos(Math.max(-1, Math.min(1, dotProduct))); |
| } |
| |
| selectNewPatrolTarget() { |
| const mapLimit = GAME_CONSTANTS.MAP_SIZE / 2 * 0.7; |
| |
| |
| const minAltitude = 1000; |
| const maxAltitude = 6000; |
| |
| this.targetPosition = new THREE.Vector3( |
| (Math.random() - 0.5) * 2 * mapLimit, |
| minAltitude + Math.random() * (maxAltitude - minAltitude), |
| (Math.random() - 0.5) * 2 * mapLimit |
| ); |
| } |
| |
| selectEvadeTarget() { |
| |
| const evadeDistance = 1000 + Math.random() * 1000; |
| const evadeAngle = Math.random() * Math.PI * 2; |
| |
| this.targetPosition = new THREE.Vector3( |
| this.position.x + Math.cos(evadeAngle) * evadeDistance, |
| this.position.y + (Math.random() - 0.5) * 500, |
| this.position.z + Math.sin(evadeAngle) * evadeDistance |
| ); |
| |
| |
| const mapLimit = GAME_CONSTANTS.MAP_SIZE / 2 * 0.8; |
| this.targetPosition.x = THREE.MathUtils.clamp(this.targetPosition.x, -mapLimit, mapLimit); |
| this.targetPosition.z = THREE.MathUtils.clamp(this.targetPosition.z, -mapLimit, mapLimit); |
| this.targetPosition.y = THREE.MathUtils.clamp(this.targetPosition.y, 1000, 6000); |
| } |
| |
| normalizeAngle(angle) { |
| while (angle > Math.PI) angle -= Math.PI * 2; |
| while (angle < -Math.PI) angle += Math.PI * 2; |
| return angle; |
| } |
| |
| takeDamage(damage) { |
| console.log(`Enemy taking damage: ${damage}, Current health: ${this.health}`); |
| this.health -= damage; |
| console.log(`Enemy health after damage: ${this.health}`); |
| const isDead = this.health <= 0; |
| console.log(`Enemy is dead: ${isDead}`); |
| return isDead; |
| } |
| |
| destroy() { |
| if (this.mesh) { |
| this.scene.remove(this.mesh); |
| this.bullets.forEach(bullet => this.scene.remove(bullet)); |
| this.bullets = []; |
| this.firedMissiles.forEach(missile => missile.destroy()); |
| this.firedMissiles = []; |
| this.deployedFlares.forEach(flare => flare.destroy()); |
| this.deployedFlares = []; |
| this.isLoaded = false; |
| } |
| } |
| } |
|
|
| |
| class Game { |
| constructor() { |
| this.scene = new THREE.Scene(); |
| this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 50000); |
| this.renderer = new THREE.WebGLRenderer({ antialias: true }); |
| this.renderer.setSize(window.innerWidth, window.innerHeight); |
| this.renderer.shadowMap.enabled = true; |
| this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| this.renderer.setClearColor(0x87CEEB); |
| this.renderer.setPixelRatio(window.devicePixelRatio); |
| |
| document.getElementById('gameContainer').appendChild(this.renderer.domElement); |
| |
| this.loader = new GLTFLoader(); |
| this.fighter = new Fighter(); |
| this.enemies = []; |
| this.isLoaded = false; |
| this.isBGMReady = false; |
| this.isGameOver = false; |
| this.gameTime = GAME_CONSTANTS.MISSION_DURATION; |
| this.score = 0; |
| this.lastTime = performance.now(); |
| this.gameTimer = null; |
| this.animationFrameId = null; |
| this.lastShootTime = 0; |
| |
| this.bgm = null; |
| this.bgmPlaying = false; |
| |
| this.keys = { w: false, a: false, s: false, d: false, f: false, g: false, r: false }; |
| this.isStarted = false; |
| |
| |
| this.firstMissileVoicePlayed = false; |
| |
| window.gameInstance = this; |
| |
| this.setupScene(); |
| this.setupEventListeners(); |
| this.preloadGame(); |
| } |
|
|
| async preloadGame() { |
| try { |
| console.log('게임 리소스 사전 로딩 중...'); |
| |
| await this.fighter.initialize(this.scene, this.loader); |
| |
| if (!this.fighter.isLoaded) { |
| throw new Error('전투기 로딩 실패'); |
| } |
| |
| await this.preloadEnemies(); |
| |
| this.isLoaded = true; |
| console.log('게임 리소스 로딩 완료'); |
| |
| await this.preloadBGM(); |
| |
| this.showStartScreen(); |
| |
| this.animate(); |
| |
| } catch (error) { |
| console.error('게임 사전 로딩 실패:', error); |
| document.getElementById('loading').innerHTML = |
| '<div class="loading-text" style="color: red;">로딩 실패. 페이지를 새로고침해주세요.</div>'; |
| } |
| } |
|
|
| showStartScreen() { |
| if (this.isLoaded && this.isBGMReady) { |
| const loadingElement = document.getElementById('loading'); |
| if (loadingElement) { |
| loadingElement.style.display = 'none'; |
| } |
| |
| const startScreen = document.getElementById('startScreen'); |
| if (startScreen) { |
| startScreen.style.display = 'flex'; |
| } |
| |
| window.dispatchEvent(new Event('gameReady')); |
| |
| console.log('모든 리소스 준비 완료 - Start Game 버튼 표시'); |
| } |
| } |
|
|
| async preloadBGM() { |
| console.log('BGM 사전 로딩...'); |
| |
| return new Promise((resolve) => { |
| try { |
| this.bgm = new Audio('sounds/main.ogg'); |
| this.bgm.volume = 0.25; |
| this.bgm.loop = true; |
| |
| this.bgm.addEventListener('canplaythrough', () => { |
| console.log('BGM 재생 준비 완료'); |
| this.isBGMReady = true; |
| resolve(); |
| }); |
| |
| this.bgm.addEventListener('error', (e) => { |
| console.log('BGM 에러:', e); |
| this.isBGMReady = true; |
| resolve(); |
| }); |
| |
| this.bgm.load(); |
| |
| setTimeout(() => { |
| if (!this.isBGMReady) { |
| console.log('BGM 로딩 타임아웃 - 게임 진행'); |
| this.isBGMReady = true; |
| resolve(); |
| } |
| }, 3000); |
| |
| } catch (error) { |
| console.log('BGM 사전 로딩 실패:', error); |
| this.isBGMReady = true; |
| resolve(); |
| } |
| }); |
| } |
|
|
| async preloadEnemies() { |
| for (let i = 0; i < GAME_CONSTANTS.ENEMY_COUNT; i++) { |
| const angle = (i / GAME_CONSTANTS.ENEMY_COUNT) * Math.PI * 2; |
| const distance = 6000 + Math.random() * 3000; |
| |
| const position = new THREE.Vector3( |
| Math.cos(angle) * distance, |
| 2500 + Math.random() * 2000, |
| Math.sin(angle) * distance |
| ); |
| |
| const enemy = new EnemyFighter(this.scene, position); |
| enemy.playerFighter = this.fighter; |
| await enemy.initialize(this.loader); |
| this.enemies.push(enemy); |
| } |
| } |
|
|
| setupScene() { |
| |
| this.scene.background = new THREE.Color(0x87CEEB); |
| this.scene.fog = new THREE.Fog(0x87CEEB, 1000, 30000); |
| |
| |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); |
| this.scene.add(ambientLight); |
| |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); |
| directionalLight.position.set(5000, 8000, 5000); |
| directionalLight.castShadow = true; |
| directionalLight.shadow.mapSize.width = 2048; |
| directionalLight.shadow.mapSize.height = 2048; |
| directionalLight.shadow.camera.near = 0.5; |
| directionalLight.shadow.camera.far = 15000; |
| directionalLight.shadow.camera.left = -8000; |
| directionalLight.shadow.camera.right = 8000; |
| directionalLight.shadow.camera.top = 8000; |
| directionalLight.shadow.camera.bottom = -8000; |
| this.scene.add(directionalLight); |
| |
| |
| const groundGeometry = new THREE.PlaneGeometry(GAME_CONSTANTS.MAP_SIZE, GAME_CONSTANTS.MAP_SIZE); |
| const groundMaterial = new THREE.MeshLambertMaterial({ |
| color: 0x8FBC8F |
| }); |
| const ground = new THREE.Mesh(groundGeometry, groundMaterial); |
| ground.rotation.x = -Math.PI / 2; |
| ground.receiveShadow = true; |
| this.scene.add(ground); |
| |
| |
| this.addClouds(); |
| } |
|
|
| addClouds() { |
| |
| const cloudLayers = [ |
| { count: 50, minHeight: 800, maxHeight: 1500, opacity: 0.7, scale: 1.5 }, |
| { count: 80, minHeight: 2000, maxHeight: 4000, opacity: 0.5, scale: 2.0 }, |
| { count: 40, minHeight: 5000, maxHeight: 8000, opacity: 0.3, scale: 3.0 } |
| ]; |
| |
| cloudLayers.forEach(layer => { |
| for (let i = 0; i < layer.count; i++) { |
| |
| const cloudGroup = new THREE.Group(); |
| |
| |
| const sphereCount = 5 + Math.floor(Math.random() * 8); |
| |
| for (let j = 0; j < sphereCount; j++) { |
| const radius = 50 + Math.random() * 100; |
| const cloudGeometry = new THREE.SphereGeometry(radius, 8, 6); |
| const cloudMaterial = new THREE.MeshLambertMaterial({ |
| color: 0xffffff, |
| transparent: true, |
| opacity: layer.opacity * (0.7 + Math.random() * 0.3) |
| }); |
| |
| const cloudPart = new THREE.Mesh(cloudGeometry, cloudMaterial); |
| |
| |
| cloudPart.position.set( |
| (Math.random() - 0.5) * 200, |
| (Math.random() - 0.5) * 50, |
| (Math.random() - 0.5) * 200 |
| ); |
| |
| cloudGroup.add(cloudPart); |
| } |
| |
| |
| cloudGroup.position.set( |
| (Math.random() - 0.5) * GAME_CONSTANTS.MAP_SIZE, |
| layer.minHeight + Math.random() * (layer.maxHeight - layer.minHeight), |
| (Math.random() - 0.5) * GAME_CONSTANTS.MAP_SIZE |
| ); |
| |
| |
| const scale = layer.scale * (0.8 + Math.random() * 0.4); |
| cloudGroup.scale.set(scale, scale * 0.5, scale); |
| |
| |
| cloudGroup.userData = { |
| driftSpeed: (Math.random() - 0.5) * 0.1, |
| floatSpeed: Math.random() * 0.5 + 0.5, |
| initialY: cloudGroup.position.y, |
| time: Math.random() * Math.PI * 2 |
| }; |
| |
| this.scene.add(cloudGroup); |
| |
| |
| if (!this.clouds) this.clouds = []; |
| this.clouds.push(cloudGroup); |
| } |
| }); |
| |
| |
| for (let i = 0; i < 30; i++) { |
| const fogGeometry = new THREE.BoxGeometry(500, 100, 500); |
| const fogMaterial = new THREE.MeshLambertMaterial({ |
| color: 0xffffff, |
| transparent: true, |
| opacity: 0.2 |
| }); |
| |
| const fog = new THREE.Mesh(fogGeometry, fogMaterial); |
| fog.position.set( |
| (Math.random() - 0.5) * GAME_CONSTANTS.MAP_SIZE, |
| 50 + Math.random() * 200, |
| (Math.random() - 0.5) * GAME_CONSTANTS.MAP_SIZE |
| ); |
| |
| this.scene.add(fog); |
| } |
| } |
|
|
| setupEventListeners() { |
| document.addEventListener('keydown', (event) => { |
| if (this.isGameOver) return; |
| |
| if (!this.isStarted) return; |
| |
| switch(event.code) { |
| case 'KeyW': |
| this.keys.w = true; |
| console.log('W key pressed - Accelerating'); |
| break; |
| case 'KeyA': |
| this.keys.a = true; |
| console.log('A key pressed - Turning left'); |
| break; |
| case 'KeyS': |
| this.keys.s = true; |
| console.log('S key pressed - Decelerating'); |
| break; |
| case 'KeyD': |
| this.keys.d = true; |
| console.log('D key pressed - Turning right'); |
| break; |
| case 'KeyF': |
| this.keys.f = true; |
| |
| if (!event.repeat && this.fighter) { |
| this.fighter.deployFlares(); |
| } |
| break; |
| case 'KeyG': |
| this.keys.g = true; |
| console.log('G key pressed - Stall escape'); |
| break; |
| case 'KeyR': |
| if (!event.repeat && this.fighter) { |
| this.fighter.switchWeapon(); |
| console.log('R key pressed - Switching weapon to', this.fighter.currentWeapon); |
| } |
| break; |
| } |
| }); |
|
|
| document.addEventListener('keyup', (event) => { |
| if (this.isGameOver) return; |
| |
| |
| if (!this.isStarted) return; |
| |
| switch(event.code) { |
| case 'KeyW': this.keys.w = false; break; |
| case 'KeyA': this.keys.a = false; break; |
| case 'KeyS': this.keys.s = false; break; |
| case 'KeyD': this.keys.d = false; break; |
| case 'KeyF': this.keys.f = false; break; |
| case 'KeyG': this.keys.g = false; break; |
| case 'KeyR': this.keys.r = false; break; |
| } |
| }); |
|
|
| document.addEventListener('mousemove', (event) => { |
| |
| if (!document.pointerLockElement || this.isGameOver || !this.isStarted) return; |
| |
| const deltaX = event.movementX || 0; |
| const deltaY = event.movementY || 0; |
| |
| this.fighter.updateMouseInput(deltaX, deltaY); |
| }); |
|
|
| document.addEventListener('mousedown', (event) => { |
| |
| if (!document.pointerLockElement || this.isGameOver || !this.isStarted) return; |
| |
| if (event.button === 0) { |
| this.fighter.isMouseDown = true; |
| this.lastShootTime = 0; |
| } |
| }); |
|
|
| document.addEventListener('mouseup', (event) => { |
| if (event.button === 0) { |
| this.fighter.isMouseDown = false; |
| } |
| }); |
|
|
| window.addEventListener('resize', () => { |
| this.camera.aspect = window.innerWidth / window.innerHeight; |
| this.camera.updateProjectionMatrix(); |
| this.renderer.setSize(window.innerWidth, window.innerHeight); |
| }); |
| } |
|
|
| startGame() { |
| if (!this.isLoaded) { |
| console.log('게임이 아직 로딩 중입니다...'); |
| return; |
| } |
| |
| this.isStarted = true; |
| this.startGameTimer(); |
| |
| |
| this.fighter.startEngineSound(); |
| |
| |
| try { |
| const startVoice = new Audio('sounds/voice_start_a.ogg'); |
| startVoice.volume = 0.8; |
| startVoice.play().catch(e => { |
| console.log('Start voice failed to play:', e); |
| }); |
| } catch (e) { |
| console.log('Start voice error:', e); |
| } |
| |
| |
| this.showSubtitle('Listen up! Find and shut down all enemy squad that invaded their airspace.', 5000); |
| |
| console.log('게임 시작!'); |
| } |
|
|
| showSubtitle(text, duration) { |
| |
| setTimeout(() => { |
| |
| const existingSubtitle = document.getElementById('gameSubtitle'); |
| if (existingSubtitle) { |
| existingSubtitle.remove(); |
| } |
| |
| |
| const subtitle = document.createElement('div'); |
| subtitle.id = 'gameSubtitle'; |
| subtitle.style.cssText = ` |
| position: fixed; |
| bottom: 100px; |
| left: 50%; |
| transform: translateX(-50%); |
| color: #ffffff; |
| font-size: 24px; |
| font-weight: bold; |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.8); |
| background: rgba(0,0,0,0.7); |
| padding: 15px 30px; |
| border-radius: 5px; |
| z-index: 2000; |
| text-align: center; |
| max-width: 80%; |
| pointer-events: none; |
| `; |
| subtitle.textContent = text; |
| document.body.appendChild(subtitle); |
| |
| console.log('Subtitle displayed:', text); |
| |
| |
| setTimeout(() => { |
| if (subtitle && subtitle.parentNode) { |
| subtitle.style.transition = 'opacity 0.5s'; |
| subtitle.style.opacity = '0'; |
| setTimeout(() => { |
| if (subtitle && subtitle.parentNode) { |
| subtitle.remove(); |
| } |
| }, 500); |
| } |
| }, duration); |
| }, 100); |
| } |
|
|
| startBGM() { |
| if (this.bgmPlaying || !this.bgm) return; |
| |
| console.log('BGM 재생 시도...'); |
| |
| const playPromise = this.bgm.play(); |
| |
| if (playPromise !== undefined) { |
| playPromise.then(() => { |
| this.bgmPlaying = true; |
| console.log('BGM 재생 시작 성공!'); |
| }).catch(error => { |
| console.log('자동 재생이 차단됨:', error); |
| console.log('클릭 후 재생 시도 대기 중...'); |
| |
| const tryPlayOnInteraction = () => { |
| if (!this.bgmPlaying && this.bgm) { |
| console.log('사용자 상호작용으로 BGM 재생 시도...'); |
| this.bgm.play().then(() => { |
| this.bgmPlaying = true; |
| console.log('BGM 재생 시작 성공 (클릭 후)!'); |
| document.removeEventListener('click', tryPlayOnInteraction); |
| document.removeEventListener('keydown', tryPlayOnInteraction); |
| }).catch(e => console.log('BGM 재생 실패:', e)); |
| } |
| }; |
| |
| document.addEventListener('click', tryPlayOnInteraction); |
| document.addEventListener('keydown', tryPlayOnInteraction); |
| }); |
| } |
| } |
|
|
| startGameTimer() { |
| this.gameTimer = setInterval(() => { |
| if (!this.isGameOver) { |
| this.gameTime--; |
| |
| if (this.gameTime <= 0) { |
| |
| if (this.enemies.length > 0) { |
| this.endGame(false, "TIME OVER - MISSION FAILED"); |
| } else { |
| this.endGame(true); |
| } |
| } |
| } |
| }, 1000); |
| } |
|
|
| |
| onEnemyLockStart(enemy) { |
| console.log('Warning: Enemy locking on!'); |
| |
| |
| if (!this.fighter.beingLockedBy.includes(enemy)) { |
| this.fighter.beingLockedBy.push(enemy); |
| } |
| |
| |
| this.fighter.updateWarningAudios(); |
| } |
| |
| |
| onEnemyLockLost(enemy) { |
| console.log('Enemy lock lost'); |
| |
| |
| const index = this.fighter.beingLockedBy.indexOf(enemy); |
| if (index !== -1) { |
| this.fighter.beingLockedBy.splice(index, 1); |
| } |
| |
| |
| this.fighter.updateWarningAudios(); |
| } |
| |
| |
| onEnemyMissileLaunch(enemy, missile) { |
| console.log('Warning: Missile launched!'); |
| |
| |
| this.fighter.incomingMissiles.push(missile); |
| |
| |
| const index = this.fighter.beingLockedBy.indexOf(enemy); |
| if (index !== -1) { |
| this.fighter.beingLockedBy.splice(index, 1); |
| } |
| |
| |
| this.fighter.updateWarningAudios(); |
| |
| |
| if (!this.firstMissileVoicePlayed) { |
| this.firstMissileVoicePlayed = true; |
| |
| try { |
| const missileVoice = new Audio('sounds/voice_missile_a.ogg'); |
| missileVoice.volume = 0.8; |
| missileVoice.play().catch(e => { |
| console.log('Missile voice failed to play:', e); |
| }); |
| } catch (e) { |
| console.log('Missile voice error:', e); |
| } |
| |
| |
| this.showSubtitle('Watch out the enemy missile.', 4000); |
| } |
| } |
| notifyMissilesOfFlare(flare) { |
| |
| const allMissiles = []; |
| |
| |
| this.fighter.firedMissiles.forEach(missile => { |
| allMissiles.push(missile); |
| }); |
| |
| |
| this.enemies.forEach(enemy => { |
| enemy.firedMissiles.forEach(missile => { |
| allMissiles.push(missile); |
| }); |
| }); |
| |
| |
| allMissiles.forEach(missile => { |
| if (missile && missile.position) { |
| const distance = missile.position.distanceTo(flare.position); |
| if (distance < 500) { |
| |
| missile.detectedFlare = flare; |
| } |
| } |
| }); |
| } |
|
|
| findNearestFlare(position) { |
| let nearestFlare = null; |
| let nearestDistance = Infinity; |
| |
| |
| this.fighter.deployedFlares.forEach(flare => { |
| if (flare.heatSignature > 0.3) { |
| const distance = position.distanceTo(flare.position); |
| if (distance < nearestDistance) { |
| nearestDistance = distance; |
| nearestFlare = flare; |
| } |
| } |
| }); |
| |
| |
| this.enemies.forEach(enemy => { |
| enemy.deployedFlares.forEach(flare => { |
| if (flare.heatSignature > 0.3) { |
| const distance = position.distanceTo(flare.position); |
| if (distance < nearestDistance) { |
| nearestDistance = distance; |
| nearestFlare = flare; |
| } |
| } |
| }); |
| }); |
| |
| return nearestFlare; |
| } |
|
|
| findIncomingMissilesForEnemy(enemy) { |
| const incomingMissiles = []; |
| |
| |
| this.fighter.firedMissiles.forEach(missile => { |
| if (missile.target === enemy) { |
| incomingMissiles.push(missile); |
| } |
| }); |
| |
| |
| incomingMissiles.sort((a, b) => { |
| const distA = enemy.position.distanceTo(a.position); |
| const distB = enemy.position.distanceTo(b.position); |
| return distA - distB; |
| }); |
| |
| return incomingMissiles; |
| } |
| updateUI() { |
| if (this.fighter.isLoaded) { |
| const speedKnots = Math.round(this.fighter.speed * 1.94384); |
| const altitudeFeet = Math.round(this.fighter.altitude * 3.28084); |
| const altitudeMeters = Math.round(this.fighter.altitude); |
| |
| const scoreElement = document.getElementById('score'); |
| const timeElement = document.getElementById('time'); |
| const healthElement = document.getElementById('health'); |
| const ammoElement = document.getElementById('ammoDisplay'); |
| const gameStatsElement = document.getElementById('gameStats'); |
| |
| |
| const healthBar = document.getElementById('healthBar'); |
| if (healthBar) { |
| healthBar.innerHTML = ` |
| <div id="health" style="width: ${(this.fighter.health / GAME_CONSTANTS.MAX_HEALTH) * 100}%"></div> |
| <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-weight: bold;"> |
| ${this.fighter.health}/${GAME_CONSTANTS.MAX_HEALTH} |
| </div> |
| `; |
| } |
| |
| if (scoreElement) scoreElement.textContent = `Score: ${this.score}`; |
| if (timeElement) timeElement.textContent = `Time: ${this.gameTime}s`; |
| |
| |
| if (ammoElement) { |
| if (this.fighter.currentWeapon === 'MG') { |
| ammoElement.innerHTML = `20MM MG: ${this.fighter.ammo}<br>FLARES: ${this.fighter.flareCount}`; |
| ammoElement.style.color = '#0f0'; |
| } else { |
| ammoElement.innerHTML = `AIM-9: ${this.fighter.aim9Missiles}<br>FLARES: ${this.fighter.flareCount}`; |
| ammoElement.style.color = '#ff0000'; |
| |
| |
| if (this.fighter.lockTarget && this.fighter.lockProgress > 0) { |
| const lockStatus = this.fighter.lockProgress >= GAME_CONSTANTS.AIM9_LOCK_REQUIRED ? 'LOCKED' : `LOCKING ${this.fighter.lockProgress}/3`; |
| ammoElement.innerHTML = `AIM-9: ${this.fighter.aim9Missiles}<br>FLARES: ${this.fighter.flareCount}<br>[${lockStatus}]`; |
| } |
| } |
| } |
| |
| if (gameStatsElement) { |
| gameStatsElement.innerHTML = ` |
| <div>Score: ${this.score}</div> |
| <div>Time: ${this.gameTime}s</div> |
| <div>Speed: ${speedKnots} KT</div> |
| <div>Alt: ${altitudeMeters}m (${altitudeFeet} FT)</div> |
| <div>Throttle: ${Math.round(this.fighter.throttle * 100)}%</div> |
| <div>G-Force: ${this.fighter.gForce.toFixed(1)}</div> |
| <div>Targets: ${this.enemies.length}</div> |
| `; |
| } |
| |
| this.updateWarnings(); |
| this.updateHUD(); |
| } |
| } |
| |
| updateHUD() { |
| |
| const hudElement = document.getElementById('hudCrosshair'); |
| if (!hudElement) return; |
| |
| |
| const rollDegrees = this.fighter.rotation.z * (180 / Math.PI); |
| hudElement.style.transform = `translate(-50%, -50%) rotate(${-rollDegrees}deg)`; |
| |
| |
| const pitchLadder = document.getElementById('pitchLadder'); |
| if (pitchLadder) { |
| const pitchDegrees = this.fighter.rotation.x * (180 / Math.PI); |
| |
| const pitchOffset = -pitchDegrees * 2; |
| pitchLadder.style.transform = `translateY(${pitchOffset}px)`; |
| } |
| |
| |
| const speedKnots = Math.round(this.fighter.speed * 1.94384); |
| const altitudeMeters = Math.round(this.fighter.altitude); |
| const pitchDegrees = Math.round(this.fighter.rotation.x * (180 / Math.PI)); |
| const rollDegreesRounded = Math.round(rollDegrees); |
| const headingDegrees = Math.round(this.fighter.getHeadingDegrees()); |
| |
| |
| if (!this.lastHeading) this.lastHeading = headingDegrees; |
| let turnRate = (headingDegrees - this.lastHeading); |
| if (turnRate > 180) turnRate -= 360; |
| if (turnRate < -180) turnRate += 360; |
| this.lastHeading = headingDegrees; |
| const turnRateDegPerSec = Math.round(turnRate / (1/60)); |
| |
| |
| const hudSpeed = document.getElementById('hudSpeed'); |
| const hudAltitude = document.getElementById('hudAltitude'); |
| const hudHeading = document.getElementById('hudHeading'); |
| const hudPitch = document.getElementById('hudPitch'); |
| const hudRoll = document.getElementById('hudRoll'); |
| const hudTurnRate = document.getElementById('hudTurnRate'); |
| |
| if (hudSpeed) hudSpeed.textContent = `SPD: ${speedKnots} KT`; |
| if (hudAltitude) hudAltitude.textContent = `ALT: ${altitudeMeters} M`; |
| if (hudHeading) hudHeading.textContent = `HDG: ${String(headingDegrees).padStart(3, '0')}°`; |
| if (hudPitch) hudPitch.textContent = `PITCH: ${pitchDegrees}°`; |
| if (hudRoll) hudRoll.textContent = `ROLL: ${rollDegreesRounded}°`; |
| if (hudTurnRate) hudTurnRate.textContent = `TURN: ${Math.abs(turnRateDegPerSec) > 1 ? turnRateDegPerSec : 0}°/s`; |
| |
| |
| const targetMarkers = document.getElementById('targetMarkers'); |
| if (targetMarkers) { |
| targetMarkers.innerHTML = ''; |
| |
| |
| this.enemies.forEach(enemy => { |
| if (!enemy.mesh || !enemy.isLoaded) return; |
| |
| const distance = this.fighter.position.distanceTo(enemy.position); |
| if (distance > 10000) return; |
| |
| |
| const enemyScreenPos = this.getScreenPosition(enemy.position); |
| if (!enemyScreenPos) return; |
| |
| |
| const centerX = window.innerWidth / 2; |
| const centerY = window.innerHeight / 2; |
| const distFromCenter = Math.sqrt( |
| Math.pow(enemyScreenPos.x - centerX, 2) + |
| Math.pow(enemyScreenPos.y - centerY, 2) |
| ); |
| |
| |
| const crosshairRadius = 75; |
| const isInCrosshair = distFromCenter < crosshairRadius; |
| |
| |
| const marker = document.createElement('div'); |
| marker.className = 'target-marker'; |
| |
| if (isInCrosshair) { |
| marker.classList.add('in-crosshair'); |
| |
| |
| if (this.fighter.currentWeapon === 'AIM9' && distance < GAME_CONSTANTS.AIM9_LOCK_RANGE) { |
| if (this.fighter.lockTarget === enemy) { |
| if (this.fighter.lockProgress >= GAME_CONSTANTS.AIM9_LOCK_REQUIRED) { |
| marker.classList.add('locked'); |
| marker.style.border = '2px solid #ff0000'; |
| marker.style.boxShadow = '0 0 20px #ff0000'; |
| } else { |
| |
| marker.style.border = '2px solid #ffff00'; |
| marker.style.boxShadow = '0 0 10px #ffff00'; |
| marker.style.animation = `target-pulse ${1.0 / this.fighter.lockProgress}s infinite`; |
| } |
| } |
| } else if (distance < 2000) { |
| marker.classList.add('locked'); |
| } |
| |
| |
| const targetBox = document.createElement('div'); |
| targetBox.className = 'target-box'; |
| marker.appendChild(targetBox); |
| |
| |
| const targetInfo = document.createElement('div'); |
| targetInfo.className = 'target-info'; |
| targetInfo.textContent = `${Math.round(distance)}m`; |
| |
| |
| if (this.fighter.currentWeapon === 'AIM9' && this.fighter.lockTarget === enemy) { |
| if (this.fighter.lockProgress >= GAME_CONSTANTS.AIM9_LOCK_REQUIRED) { |
| targetInfo.textContent += ' LOCKED'; |
| targetInfo.style.color = '#ff0000'; |
| } else { |
| targetInfo.textContent += ` LOCK ${this.fighter.lockProgress}/3`; |
| targetInfo.style.color = '#ffff00'; |
| } |
| } |
| |
| marker.appendChild(targetInfo); |
| } |
| |
| marker.style.left = `${enemyScreenPos.x}px`; |
| marker.style.top = `${enemyScreenPos.y}px`; |
| |
| targetMarkers.appendChild(marker); |
| }); |
| } |
| } |
| |
| getScreenPosition(worldPosition) { |
| |
| const vector = worldPosition.clone(); |
| vector.project(this.camera); |
| |
| |
| if (vector.z > 1) return null; |
| |
| const x = (vector.x * 0.5 + 0.5) * window.innerWidth; |
| const y = (-vector.y * 0.5 + 0.5) * window.innerHeight; |
| |
| return { x, y }; |
| } |
|
|
| updateWarnings() { |
| |
| const existingWarnings = document.querySelectorAll('.warning-message'); |
| existingWarnings.forEach(w => w.remove()); |
| |
| |
| const existingStallWarnings = document.querySelectorAll('.stall-escape-warning'); |
| existingStallWarnings.forEach(w => w.remove()); |
| |
| |
| const existingMissileWarnings = document.querySelectorAll('.missile-warning'); |
| existingMissileWarnings.forEach(w => w.remove()); |
| |
| |
| let altitudeEdgeEffect = document.getElementById('altitudeEdgeEffect'); |
| |
| |
| if (this.fighter.incomingMissiles.length > 0) { |
| if (!altitudeEdgeEffect) { |
| altitudeEdgeEffect = document.createElement('div'); |
| altitudeEdgeEffect.id = 'altitudeEdgeEffect'; |
| document.body.appendChild(altitudeEdgeEffect); |
| } |
| |
| |
| const edgeIntensity = 0.8; |
| altitudeEdgeEffect.style.cssText = ` |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| pointer-events: none; |
| z-index: 1300; |
| background: radial-gradient(ellipse at center, |
| transparent 30%, |
| rgba(255, 0, 0, ${edgeIntensity * 0.4}) 50%, |
| rgba(255, 0, 0, ${edgeIntensity}) 100%); |
| box-shadow: inset 0 0 200px rgba(255, 0, 0, ${edgeIntensity}), |
| inset 0 0 150px rgba(255, 0, 0, ${edgeIntensity * 0.8}); |
| animation: missile-pulse 0.3s infinite; |
| `; |
| |
| |
| const missileWarning = document.createElement('div'); |
| missileWarning.className = 'missile-warning'; |
| missileWarning.style.cssText = ` |
| position: fixed; |
| top: 20%; |
| left: 50%; |
| transform: translateX(-50%); |
| color: #ff0000; |
| font-size: 36px; |
| font-weight: bold; |
| text-shadow: 0 0 20px rgba(255,0,0,1), 0 0 40px rgba(255,0,0,0.8); |
| z-index: 1600; |
| text-align: center; |
| animation: missile-blink 0.2s infinite; |
| `; |
| missileWarning.innerHTML = 'MISSILE INCOMING!<br>USE FLARE! PRESS F!'; |
| document.body.appendChild(missileWarning); |
| |
| } else if (this.fighter.altitude < 500) { |
| if (!altitudeEdgeEffect) { |
| altitudeEdgeEffect = document.createElement('div'); |
| altitudeEdgeEffect.id = 'altitudeEdgeEffect'; |
| document.body.appendChild(altitudeEdgeEffect); |
| } |
| |
| let edgeIntensity; |
| if (this.fighter.altitude < 250) { |
| |
| edgeIntensity = 0.6; |
| altitudeEdgeEffect.style.cssText = ` |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| pointer-events: none; |
| z-index: 1300; |
| background: radial-gradient(ellipse at center, |
| transparent 40%, |
| rgba(255, 0, 0, ${edgeIntensity * 0.3}) 60%, |
| rgba(255, 0, 0, ${edgeIntensity}) 100%); |
| box-shadow: inset 0 0 150px rgba(255, 0, 0, ${edgeIntensity}), |
| inset 0 0 100px rgba(255, 0, 0, ${edgeIntensity * 0.8}); |
| animation: pulse-red 0.5s infinite; |
| `; |
| } else { |
| |
| edgeIntensity = 0.3; |
| altitudeEdgeEffect.style.cssText = ` |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| pointer-events: none; |
| z-index: 1300; |
| background: radial-gradient(ellipse at center, |
| transparent 50%, |
| rgba(255, 0, 0, ${edgeIntensity * 0.2}) 70%, |
| rgba(255, 0, 0, ${edgeIntensity}) 100%); |
| box-shadow: inset 0 0 100px rgba(255, 0, 0, ${edgeIntensity}), |
| inset 0 0 50px rgba(255, 0, 0, ${edgeIntensity * 0.5}); |
| `; |
| } |
| } else { |
| |
| if (altitudeEdgeEffect) { |
| altitudeEdgeEffect.remove(); |
| } |
| } |
| |
| if (this.fighter.warningBlinkState) { |
| const warningContainer = document.createElement('div'); |
| warningContainer.className = 'warning-message'; |
| warningContainer.style.cssText = ` |
| position: fixed; |
| top: 30%; |
| left: 50%; |
| transform: translateX(-50%); |
| color: #ff0000; |
| font-size: 24px; |
| font-weight: bold; |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.8); |
| z-index: 1500; |
| text-align: center; |
| `; |
| |
| let warningText = ''; |
| |
| if (this.fighter.altitude < 250) { |
| warningText += 'PULL UP! PULL UP!\n'; |
| } else if (this.fighter.altitude < 500) { |
| warningText += 'LOW ALTITUDE WARNING\n'; |
| } |
| |
| if (this.fighter.altitudeWarning) { |
| warningText += 'ALTITUDE LIMIT\n'; |
| } |
| |
| if (this.fighter.stallWarning) { |
| warningText += 'STALL WARNING\n'; |
| } |
| |
| if (this.fighter.overG) { |
| warningText += 'OVER-G! OVER-G!\n'; |
| } |
| |
| if (warningText) { |
| warningContainer.innerHTML = warningText.replace(/\n/g, '<br>'); |
| document.body.appendChild(warningContainer); |
| } |
| } |
| |
| |
| if (this.fighter.stallWarning) { |
| const stallEscapeWarning = document.createElement('div'); |
| stallEscapeWarning.className = 'stall-escape-warning'; |
| stallEscapeWarning.style.cssText = ` |
| position: fixed; |
| bottom: 100px; |
| left: 50%; |
| transform: translateX(-50%); |
| background: rgba(255, 0, 0, 0.8); |
| color: #ffffff; |
| font-size: 28px; |
| font-weight: bold; |
| padding: 15px 30px; |
| border: 3px solid #ff0000; |
| border-radius: 10px; |
| z-index: 1600; |
| text-align: center; |
| animation: blink 0.5s infinite; |
| `; |
| stallEscapeWarning.innerHTML = 'PRESS G TO ESCAPE'; |
| document.body.appendChild(stallEscapeWarning); |
| |
| |
| if (!document.getElementById('blinkAnimation')) { |
| const style = document.createElement('style'); |
| style.id = 'blinkAnimation'; |
| style.innerHTML = ` |
| @keyframes blink { |
| 0%, 50% { opacity: 1; } |
| 51%, 100% { opacity: 0.3; } |
| } |
| @keyframes pulse-green { |
| 0%, 100% { |
| opacity: 1; |
| transform: translateX(-50%) scale(1); |
| } |
| 50% { |
| opacity: 0.8; |
| transform: translateX(-50%) scale(1.1); |
| } |
| } |
| @keyframes box-pulse { |
| 0%, 100% { |
| background: rgba(255, 0, 0, 0.9); |
| box-shadow: 0 0 20px rgba(255, 0, 0, 0.8), |
| 0 0 40px rgba(255, 0, 0, 0.4); |
| } |
| 50% { |
| background: rgba(255, 50, 50, 1); |
| box-shadow: 0 0 30px rgba(255, 100, 100, 1), |
| 0 0 60px rgba(255, 0, 0, 0.8); |
| } |
| } |
| @keyframes pulse-red { |
| 0%, 100% { |
| opacity: 1; |
| } |
| 50% { |
| opacity: 0.7; |
| } |
| } |
| @keyframes missile-pulse { |
| 0%, 100% { |
| opacity: 1; |
| } |
| 50% { |
| opacity: 0.5; |
| } |
| } |
| @keyframes missile-blink { |
| 0%, 50% { opacity: 1; } |
| 51%, 100% { opacity: 0.2; } |
| } |
| `; |
| document.head.appendChild(style); |
| } |
| } |
| |
| |
| if (this.fighter.overG && this.fighter.overGTimer > 1.5) { |
| let blurEffect = document.getElementById('overGBlurEffect'); |
| if (!blurEffect) { |
| blurEffect = document.createElement('div'); |
| blurEffect.id = 'overGBlurEffect'; |
| document.body.appendChild(blurEffect); |
| } |
| |
| |
| |
| const adjustedTimer = Math.max(0, this.fighter.overGTimer - 1.5); |
| const darknessFactor = Math.min(adjustedTimer / 2.0, 0.7); |
| |
| |
| |
| const centerTransparency = Math.max(0.3, 1 - darknessFactor * 0.8); |
| const midTransparency = Math.max(0.2, 1 - darknessFactor * 0.6); |
| const edgeOpacity = Math.min(0.8, darknessFactor * 0.8); |
| |
| |
| blurEffect.style.cssText = ` |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: radial-gradient(ellipse at center, |
| rgba(0, 0, 0, ${1 - centerTransparency}) 0%, |
| rgba(0, 0, 0, ${1 - midTransparency}) 50%, |
| rgba(0, 0, 0, ${edgeOpacity}) 80%, |
| rgba(0, 0, 0, ${Math.min(0.85, edgeOpacity + 0.05)}) 100%); |
| pointer-events: none; |
| z-index: 1400; |
| transition: background 0.3s ease-out; |
| `; |
| |
| |
| if (darknessFactor > 0.5) { |
| const shake = (1 - Math.random() * 2) * 1; |
| blurEffect.style.transform = `translate(${shake}px, ${shake}px)`; |
| } |
| |
| } else { |
| |
| const blurEffect = document.getElementById('overGBlurEffect'); |
| if (blurEffect) { |
| |
| blurEffect.style.transition = 'opacity 0.5s ease-out'; |
| blurEffect.style.opacity = '0'; |
| setTimeout(() => { |
| if (blurEffect.parentNode) { |
| blurEffect.remove(); |
| } |
| }, 500); |
| } |
| } |
| } |
|
|
| updateRadar() { |
| const radar = document.getElementById('radar'); |
| if (!radar) return; |
| |
| const oldDots = radar.getElementsByClassName('enemy-dot'); |
| while (oldDots[0]) { |
| oldDots[0].remove(); |
| } |
| |
| const radarCenter = { x: 100, y: 100 }; |
| const radarRange = 10000; |
| |
| this.enemies.forEach(enemy => { |
| if (!enemy.mesh || !enemy.isLoaded) return; |
| |
| const distance = this.fighter.position.distanceTo(enemy.position); |
| if (distance <= radarRange) { |
| const relativePos = enemy.position.clone().sub(this.fighter.position); |
| const angle = Math.atan2(relativePos.x, relativePos.z); |
| const relativeDistance = distance / radarRange; |
| |
| const dotX = radarCenter.x + Math.sin(angle) * (radarCenter.x * relativeDistance); |
| const dotY = radarCenter.y + Math.cos(angle) * (radarCenter.y * relativeDistance); |
| |
| const dot = document.createElement('div'); |
| dot.className = 'enemy-dot'; |
| dot.style.left = `${dotX}px`; |
| dot.style.top = `${dotY}px`; |
| radar.appendChild(dot); |
| } |
| }); |
| } |
|
|
| |
| createGroundImpactEffect(position) { |
| |
| const flashGeometry = new THREE.SphereGeometry(3, 8, 8); |
| const flashMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xffaa00, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| |
| const flash = new THREE.Mesh(flashGeometry, flashMaterial); |
| flash.position.copy(position); |
| flash.position.y = 0.5; |
| this.scene.add(flash); |
| |
| |
| const particleCount = 10; |
| const particles = []; |
| |
| for (let i = 0; i < particleCount; i++) { |
| |
| const particleGeometry = new THREE.SphereGeometry(0.3 + Math.random() * 0.3, 4, 4); |
| const particleMaterial = new THREE.MeshBasicMaterial({ |
| color: Math.random() > 0.5 ? 0xffaa00 : 0xff6600, |
| transparent: true, |
| opacity: 1.0 |
| }); |
| |
| const particle = new THREE.Mesh(particleGeometry, particleMaterial); |
| particle.position.copy(position); |
| particle.position.y = 0.2; |
| |
| |
| const angle = Math.random() * Math.PI * 2; |
| const speed = 10 + Math.random() * 30; |
| const upSpeed = 15 + Math.random() * 25; |
| |
| particle.velocity = new THREE.Vector3( |
| Math.cos(angle) * speed, |
| upSpeed, |
| Math.sin(angle) * speed |
| ); |
| |
| particle.life = 0.5; |
| |
| this.scene.add(particle); |
| particles.push(particle); |
| } |
| |
| |
| const dustCount = 3; |
| const dusts = []; |
| |
| for (let i = 0; i < dustCount; i++) { |
| const dustGeometry = new THREE.SphereGeometry(1 + Math.random() * 1, 6, 6); |
| const dustMaterial = new THREE.MeshBasicMaterial({ |
| color: 0x8b7355, |
| transparent: true, |
| opacity: 0.4 |
| }); |
| |
| const dust = new THREE.Mesh(dustGeometry, dustMaterial); |
| dust.position.copy(position); |
| dust.position.y = 0.5; |
| dust.position.x += (Math.random() - 0.5) * 2; |
| dust.position.z += (Math.random() - 0.5) * 2; |
| |
| dust.velocity = new THREE.Vector3( |
| (Math.random() - 0.5) * 5, |
| 3 + Math.random() * 5, |
| (Math.random() - 0.5) * 5 |
| ); |
| |
| dust.life = 0.8; |
| |
| this.scene.add(dust); |
| dusts.push(dust); |
| } |
| |
| |
| const impactGeometry = new THREE.RingGeometry(0.5, 2, 8); |
| const impactMaterial = new THREE.MeshBasicMaterial({ |
| color: 0x333333, |
| side: THREE.DoubleSide, |
| transparent: true, |
| opacity: 0.5 |
| }); |
| |
| const impact = new THREE.Mesh(impactGeometry, impactMaterial); |
| impact.position.copy(position); |
| impact.position.y = 0.05; |
| impact.rotation.x = -Math.PI / 2; |
| this.scene.add(impact); |
| |
| |
| const animateImpact = () => { |
| let allDead = true; |
| |
| |
| if (flash.material.opacity > 0) { |
| flash.material.opacity -= 0.08; |
| flash.scale.multiplyScalar(1.1); |
| allDead = false; |
| } else if (flash.parent) { |
| this.scene.remove(flash); |
| } |
| |
| |
| particles.forEach(particle => { |
| if (particle.life > 0) { |
| allDead = false; |
| particle.life -= 0.03; |
| |
| |
| particle.position.add(particle.velocity.clone().multiplyScalar(0.03)); |
| |
| |
| particle.velocity.y -= 2; |
| |
| |
| particle.material.opacity = particle.life * 2; |
| |
| |
| const scale = particle.life * 2; |
| particle.scale.set(scale, scale, scale); |
| } else if (particle.parent) { |
| this.scene.remove(particle); |
| } |
| }); |
| |
| |
| dusts.forEach(dust => { |
| if (dust.life > 0) { |
| allDead = false; |
| dust.life -= 0.02; |
| |
| |
| dust.position.add(dust.velocity.clone().multiplyScalar(0.02)); |
| |
| |
| dust.velocity.y *= 0.95; |
| |
| |
| dust.scale.multiplyScalar(1.03); |
| |
| |
| dust.material.opacity = dust.life * 0.5; |
| } else if (dust.parent) { |
| this.scene.remove(dust); |
| } |
| }); |
| |
| |
| if (impact.material.opacity > 0) { |
| impact.material.opacity -= 0.01; |
| allDead = false; |
| } else if (impact.parent) { |
| this.scene.remove(impact); |
| } |
| |
| if (!allDead) { |
| requestAnimationFrame(animateImpact); |
| } |
| }; |
| |
| animateImpact(); |
| |
| |
| try { |
| const impactSound = new Audio('sounds/hit.ogg'); |
| impactSound.volume = 0.2; |
| impactSound.play().catch(e => { |
| console.log('Impact sound not found or failed to play'); |
| }); |
| } catch (e) { |
| console.log('Impact sound error:', e); |
| } |
| } |
|
|
| checkCollisions() { |
| |
| for (let i = this.enemies.length - 1; i >= 0; i--) { |
| const enemy = this.enemies[i]; |
| if (!enemy.mesh || !enemy.isLoaded) continue; |
| |
| const distance = this.fighter.position.distanceTo(enemy.position); |
| |
| if (distance < 80) { |
| console.log('Direct collision detected! Distance:', distance); |
| |
| |
| const collisionPoint = this.fighter.position.clone().add(enemy.position).divideScalar(2); |
| const playerExplosionPos = this.fighter.position.clone(); |
| const enemyExplosionPos = enemy.position.clone(); |
| |
| |
| try { |
| const collisionSound = new Audio('sounds/bang.ogg'); |
| collisionSound.volume = 1.0; |
| collisionSound.play().catch(e => {}); |
| |
| |
| setTimeout(() => { |
| const secondExplosion = new Audio('sounds/bang.ogg'); |
| secondExplosion.volume = 0.8; |
| secondExplosion.play().catch(e => {}); |
| }, 100); |
| } catch (e) {} |
| |
| |
| this.createExplosionEffect(collisionPoint); |
| |
| |
| setTimeout(() => { |
| this.createExplosionEffect(playerExplosionPos); |
| this.createExplosionEffect(enemyExplosionPos); |
| }, 50); |
| |
| |
| enemy.destroy(); |
| this.enemies.splice(i, 1); |
| this.score += 200; |
| |
| |
| this.fighter.health = 0; |
| |
| |
| setTimeout(() => { |
| this.endGame(false, "COLLISION WITH ENEMY"); |
| }, 100); |
| |
| return; |
| } |
| } |
| |
| |
| for (let i = this.fighter.bullets.length - 1; i >= 0; i--) { |
| const bullet = this.fighter.bullets[i]; |
| |
| for (let j = this.enemies.length - 1; j >= 0; j--) { |
| const enemy = this.enemies[j]; |
| if (!enemy.mesh || !enemy.isLoaded) continue; |
| |
| const distance = bullet.position.distanceTo(enemy.position); |
| if (distance < 90) { |
| |
| const explosionPosition = enemy.position.clone(); |
| |
| |
| this.showHitMarker(explosionPosition); |
| |
| this.createHitEffect(explosionPosition); |
| |
| |
| this.scene.remove(bullet); |
| this.fighter.bullets.splice(i, 1); |
| |
| if (enemy.takeDamage(GAME_CONSTANTS.BULLET_DAMAGE)) { |
| |
| console.log('Enemy destroyed! Creating explosion at:', explosionPosition); |
| |
| |
| this.createExplosionEffect(explosionPosition); |
| |
| |
| enemy.destroy(); |
| this.enemies.splice(j, 1); |
| this.score += 100; |
| |
| |
| console.log('Enemies remaining:', this.enemies.length); |
| |
| |
| try { |
| const killVoice = new Audio('sounds/voice_kill_a.ogg'); |
| killVoice.volume = 0.8; |
| killVoice.play().catch(e => { |
| console.log('Kill voice failed to play:', e); |
| }); |
| } catch (e) { |
| console.log('Kill voice error:', e); |
| } |
| |
| |
| this.showSubtitle('Nice work, one enemy down. Keep going.', 4000); |
| } |
| |
| |
| break; |
| } |
| } |
| } |
| |
| |
| this.enemies.forEach(enemy => { |
| for (let index = enemy.bullets.length - 1; index >= 0; index--) { |
| const bullet = enemy.bullets[index]; |
| const distance = bullet.position.distanceTo(this.fighter.position); |
| if (distance < 100) { |
| |
| const playerPosition = this.fighter.position.clone(); |
| |
| |
| this.createHitEffect(playerPosition); |
| |
| |
| this.scene.remove(bullet); |
| enemy.bullets.splice(index, 1); |
| |
| if (this.fighter.takeDamage(GAME_CONSTANTS.BULLET_DAMAGE)) { |
| console.log('Player destroyed! Creating explosion'); |
| |
| this.createExplosionEffect(playerPosition); |
| |
| this.endGame(false); |
| } |
| } |
| } |
| }); |
| |
| |
| this.fighter.incomingMissiles = this.fighter.incomingMissiles.filter(missile => { |
| return missile && missile.mesh && missile.mesh.parent; |
| }); |
| } |
|
|
| createHitEffect(position) { |
| |
| const particleCount = 15; |
| const particles = []; |
| |
| for (let i = 0; i < particleCount; i++) { |
| const particleGeometry = new THREE.SphereGeometry(0.5, 4, 4); |
| const particleMaterial = new THREE.MeshBasicMaterial({ |
| color: Math.random() > 0.5 ? 0xffaa00 : 0xff6600, |
| transparent: true, |
| opacity: 1.0 |
| }); |
| |
| const particle = new THREE.Mesh(particleGeometry, particleMaterial); |
| particle.position.copy(position); |
| |
| |
| particle.velocity = new THREE.Vector3( |
| (Math.random() - 0.5) * 100, |
| (Math.random() - 0.5) * 100, |
| (Math.random() - 0.5) * 100 |
| ); |
| |
| particle.life = 1.0; |
| |
| this.scene.add(particle); |
| particles.push(particle); |
| } |
| |
| |
| const flashGeometry = new THREE.SphereGeometry(5, 8, 8); |
| const flashMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xffff00, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| |
| const flash = new THREE.Mesh(flashGeometry, flashMaterial); |
| flash.position.copy(position); |
| this.scene.add(flash); |
| |
| |
| const animateParticles = () => { |
| let allDead = true; |
| |
| particles.forEach(particle => { |
| if (particle.life > 0) { |
| allDead = false; |
| particle.life -= 0.02; |
| |
| |
| particle.position.add(particle.velocity.clone().multiplyScalar(0.02)); |
| |
| |
| particle.velocity.y -= 2; |
| |
| |
| particle.material.opacity = particle.life; |
| |
| |
| const scale = particle.life; |
| particle.scale.set(scale, scale, scale); |
| } else if (particle.parent) { |
| this.scene.remove(particle); |
| } |
| }); |
| |
| |
| if (flash.material.opacity > 0) { |
| flash.material.opacity -= 0.05; |
| flash.scale.multiplyScalar(1.1); |
| } else if (flash.parent) { |
| this.scene.remove(flash); |
| } |
| |
| if (!allDead || flash.material.opacity > 0) { |
| requestAnimationFrame(animateParticles); |
| } |
| }; |
| |
| animateParticles(); |
| |
| |
| try { |
| const hitSound = new Audio('sounds/hit.ogg'); |
| hitSound.volume = 0.3; |
| hitSound.play().catch(e => { |
| console.log('Hit sound not found or failed to play'); |
| }); |
| } catch (e) { |
| console.log('Hit sound error:', e); |
| } |
| } |
|
|
| |
| createExplosionEffect(position) { |
| |
| if (!position || position.x === undefined) { |
| console.error('Invalid position for explosion effect'); |
| return; |
| } |
| |
| console.log('Creating explosion effect at position:', position); |
| |
| |
| try { |
| const explosionSound = new Audio('sounds/bang.ogg'); |
| explosionSound.volume = 1.0; |
| |
| |
| const playPromise = explosionSound.play(); |
| if (playPromise !== undefined) { |
| playPromise.then(() => { |
| console.log('Explosion sound played successfully'); |
| }).catch(e => { |
| console.error('Explosion sound failed:', e); |
| }); |
| } |
| } catch (e) { |
| console.error('Explosion sound error:', e); |
| } |
| |
| |
| const explosionGeometry = new THREE.SphereGeometry(50, 16, 16); |
| const explosionMaterial = new THREE.MeshBasicMaterial({ |
| color: 0xffaa00, |
| transparent: true, |
| opacity: 1.0 |
| }); |
| |
| const explosion = new THREE.Mesh(explosionGeometry, explosionMaterial); |
| explosion.position.copy(position); |
| this.scene.add(explosion); |
| |
| |
| const debrisCount = 30; |
| const debris = []; |
| |
| for (let i = 0; i < debrisCount; i++) { |
| const debrisGeometry = new THREE.BoxGeometry( |
| 2 + Math.random() * 4, |
| 2 + Math.random() * 4, |
| 2 + Math.random() * 4 |
| ); |
| const debrisMaterial = new THREE.MeshBasicMaterial({ |
| color: Math.random() > 0.5 ? 0xff6600 : 0x333333, |
| transparent: true, |
| opacity: 1.0 |
| }); |
| |
| const debrisPiece = new THREE.Mesh(debrisGeometry, debrisMaterial); |
| debrisPiece.position.copy(position); |
| |
| |
| debrisPiece.velocity = new THREE.Vector3( |
| (Math.random() - 0.5) * 200, |
| (Math.random() - 0.5) * 200, |
| (Math.random() - 0.5) * 200 |
| ); |
| debrisPiece.rotationSpeed = new THREE.Vector3( |
| Math.random() * 10, |
| Math.random() * 10, |
| Math.random() * 10 |
| ); |
| debrisPiece.life = 2.0; |
| |
| this.scene.add(debrisPiece); |
| debris.push(debrisPiece); |
| } |
| |
| |
| const smokeCount = 10; |
| const smoke = []; |
| |
| for (let i = 0; i < smokeCount; i++) { |
| const smokeGeometry = new THREE.SphereGeometry(10 + Math.random() * 20, 8, 8); |
| const smokeMaterial = new THREE.MeshBasicMaterial({ |
| color: 0x222222, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| |
| const smokePuff = new THREE.Mesh(smokeGeometry, smokeMaterial); |
| smokePuff.position.copy(position); |
| smokePuff.position.add(new THREE.Vector3( |
| (Math.random() - 0.5) * 20, |
| (Math.random() - 0.5) * 20, |
| (Math.random() - 0.5) * 20 |
| )); |
| |
| smokePuff.velocity = new THREE.Vector3( |
| (Math.random() - 0.5) * 50, |
| Math.random() * 50 + 20, |
| (Math.random() - 0.5) * 50 |
| ); |
| smokePuff.life = 3.0; |
| |
| this.scene.add(smokePuff); |
| smoke.push(smokePuff); |
| } |
| |
| |
| const animateExplosion = () => { |
| let allDead = true; |
| |
| |
| if (explosion.material.opacity > 0) { |
| explosion.material.opacity -= 0.02; |
| explosion.scale.multiplyScalar(1.08); |
| allDead = false; |
| } else if (explosion.parent) { |
| this.scene.remove(explosion); |
| } |
| |
| |
| debris.forEach(piece => { |
| if (piece.life > 0) { |
| allDead = false; |
| piece.life -= 0.02; |
| |
| |
| piece.position.add(piece.velocity.clone().multiplyScalar(0.02)); |
| |
| |
| piece.velocity.y -= 3; |
| |
| |
| piece.rotation.x += piece.rotationSpeed.x * 0.02; |
| piece.rotation.y += piece.rotationSpeed.y * 0.02; |
| piece.rotation.z += piece.rotationSpeed.z * 0.02; |
| |
| |
| piece.material.opacity = piece.life / 2; |
| } else if (piece.parent) { |
| this.scene.remove(piece); |
| } |
| }); |
| |
| |
| smoke.forEach(puff => { |
| if (puff.life > 0) { |
| allDead = false; |
| puff.life -= 0.02; |
| |
| |
| puff.position.add(puff.velocity.clone().multiplyScalar(0.02)); |
| |
| |
| puff.velocity.y *= 0.98; |
| puff.velocity.x *= 0.98; |
| puff.velocity.z *= 0.98; |
| |
| |
| puff.scale.multiplyScalar(1.02); |
| |
| |
| puff.material.opacity = (puff.life / 3) * 0.8; |
| } else if (puff.parent) { |
| this.scene.remove(puff); |
| } |
| }); |
| |
| if (!allDead) { |
| requestAnimationFrame(animateExplosion); |
| } |
| }; |
| |
| animateExplosion(); |
| } |
|
|
| showHitMarker(position) { |
| |
| const hitMarker = document.createElement('div'); |
| hitMarker.style.cssText = ` |
| position: fixed; |
| color: #ff0000; |
| font-size: 24px; |
| font-weight: bold; |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.8); |
| z-index: 1500; |
| pointer-events: none; |
| animation: hitFade 0.5s ease-out forwards; |
| `; |
| hitMarker.textContent = 'HIT'; |
| |
| |
| const screenPos = this.getScreenPosition(position); |
| if (screenPos) { |
| hitMarker.style.left = `${screenPos.x}px`; |
| hitMarker.style.top = `${screenPos.y}px`; |
| hitMarker.style.transform = 'translate(-50%, -50%)'; |
| |
| document.body.appendChild(hitMarker); |
| |
| |
| if (!document.getElementById('hitAnimation')) { |
| const style = document.createElement('style'); |
| style.id = 'hitAnimation'; |
| style.innerHTML = ` |
| @keyframes hitFade { |
| 0% { |
| opacity: 1; |
| transform: translate(-50%, -50%) scale(1); |
| } |
| 100% { |
| opacity: 0; |
| transform: translate(-50%, -100%) scale(1.5); |
| } |
| } |
| `; |
| document.head.appendChild(style); |
| } |
| |
| |
| setTimeout(() => { |
| hitMarker.remove(); |
| }, 500); |
| } |
| } |
|
|
| animate() { |
| if (this.isGameOver) return; |
| |
| this.animationFrameId = requestAnimationFrame(() => this.animate()); |
| |
| const currentTime = performance.now(); |
| const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.1); |
| this.lastTime = currentTime; |
| |
| if (this.isLoaded && this.fighter.isLoaded && this.isStarted) { |
| |
| if (this.keys.w || this.keys.s || this.keys.a || this.keys.d) { |
| console.log('animate() - Keys state:', this.keys); |
| } |
| |
| |
| this.fighter.escapeKeyPressed = this.keys.g; |
| |
| |
| this.fighter.updateControls(this.keys, deltaTime); |
| |
| |
| this.fighter.updateLockOn(this.enemies, deltaTime); |
| |
| |
| this.fighter.updatePhysics(deltaTime); |
| |
| |
| this.fighter.updateBullets(this.scene, deltaTime, this); |
| |
| |
| if (this.fighter.isMouseDown) { |
| const currentShootTime = Date.now(); |
| if (!this.lastShootTime || currentShootTime - this.lastShootTime >= 100) { |
| this.fighter.shoot(this.scene); |
| this.lastShootTime = currentShootTime; |
| } |
| } |
| |
| |
| this.enemies.forEach(enemy => { |
| enemy.nearbyEnemies = this.enemies; |
| }); |
| |
| this.enemies.forEach(enemy => { |
| enemy.update(this.fighter.position, deltaTime); |
| }); |
| |
| |
| this.checkCollisions(); |
| |
| |
| if (this.fighter.health <= 0) { |
| if (this.fighter.position.y <= 0) { |
| this.endGame(false, "GROUND COLLISION"); |
| } else { |
| this.endGame(false); |
| } |
| return; |
| } |
| |
| |
| this.updateUI(); |
| this.updateRadar(); |
| |
| |
| if (this.enemies.length === 0) { |
| this.endGame(true); |
| } |
| } else if (this.isLoaded && this.fighter.isLoaded) { |
| |
| this.fighter.updatePhysics(deltaTime); |
| } |
| |
| |
| if (this.clouds) { |
| this.clouds.forEach(cloud => { |
| cloud.userData.time += deltaTime; |
| cloud.position.x += cloud.userData.driftSpeed; |
| cloud.position.y = cloud.userData.initialY + |
| Math.sin(cloud.userData.time * cloud.userData.floatSpeed) * 20; |
| |
| const mapLimit = GAME_CONSTANTS.MAP_SIZE / 2; |
| if (cloud.position.x > mapLimit) cloud.position.x = -mapLimit; |
| if (cloud.position.x < -mapLimit) cloud.position.x = mapLimit; |
| }); |
| } |
| |
| |
| if (this.fighter.isLoaded) { |
| const targetCameraPos = this.fighter.getCameraPosition(); |
| const targetCameraTarget = this.fighter.getCameraTarget(); |
| |
| this.camera.position.lerp(targetCameraPos, this.fighter.cameraLag); |
| this.camera.lookAt(targetCameraTarget); |
| } |
| |
| this.renderer.render(this.scene, this.camera); |
| } |
|
|
| endGame(victory = false, reason = "") { |
| this.isGameOver = true; |
| |
| if (this.fighter && this.fighter.stopAllWarningAudios) { |
| this.fighter.stopAllWarningAudios(); |
| } |
| |
| if (this.bgm) { |
| this.bgm.pause(); |
| this.bgm = null; |
| this.bgmPlaying = false; |
| } |
| |
| if (this.gameTimer) { |
| clearInterval(this.gameTimer); |
| } |
| |
| document.exitPointerLock(); |
| |
| |
| const existingWarnings = document.querySelectorAll('.warning-message, .stall-escape-warning, .missile-warning'); |
| existingWarnings.forEach(w => w.remove()); |
| |
| const blurEffect = document.getElementById('overGBlurEffect'); |
| if (blurEffect) { |
| blurEffect.remove(); |
| } |
| |
| const altitudeEffect = document.getElementById('altitudeEdgeEffect'); |
| if (altitudeEffect) { |
| altitudeEffect.remove(); |
| } |
| |
| const gameOverDiv = document.createElement('div'); |
| gameOverDiv.className = 'start-screen'; |
| gameOverDiv.style.display = 'flex'; |
| gameOverDiv.innerHTML = ` |
| <h1 style="color: ${victory ? '#0f0' : '#f00'}; font-size: 48px;"> |
| ${victory ? 'MISSION ACCOMPLISHED!' : 'SHOT DOWN!'} |
| </h1> |
| ${reason ? `<div style="color: #ff0000; font-size: 20px; margin: 10px 0;">${reason}</div>` : ''} |
| <div style="color: #0f0; font-size: 24px; margin: 20px 0;"> |
| Final Score: ${this.score}<br> |
| Enemies Destroyed: ${GAME_CONSTANTS.ENEMY_COUNT - this.enemies.length}<br> |
| Mission Time: ${GAME_CONSTANTS.MISSION_DURATION - this.gameTime}s |
| </div> |
| <button class="start-button" onclick="location.reload()"> |
| New Mission |
| </button> |
| `; |
| |
| document.body.appendChild(gameOverDiv); |
| } |
| } |
|
|
| |
| window.gameInstance = null; |
|
|
| window.startGame = function() { |
| if (!window.gameInstance || !window.gameInstance.isLoaded || !window.gameInstance.isBGMReady) { |
| console.log('게임이 아직 준비되지 않았습니다...'); |
| return; |
| } |
| |
| gameStarted = true; |
| document.getElementById('startScreen').style.display = 'none'; |
| |
| document.body.requestPointerLock(); |
| |
| window.gameInstance.startBGM(); |
| window.gameInstance.startGame(); |
| } |
|
|
| function showPointerLockNotification() { |
| const existingNotification = document.getElementById('pointerLockNotification'); |
| if (existingNotification) { |
| existingNotification.remove(); |
| } |
|
|
| const notification = document.createElement('div'); |
| notification.id = 'pointerLockNotification'; |
| notification.innerHTML = 'Click to resume control'; |
| notification.style.cssText = ` |
| position: fixed; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| background: rgba(0, 0, 0, 0.8); |
| color: #00ff00; |
| padding: 20px; |
| border: 2px solid #00ff00; |
| border-radius: 10px; |
| font-size: 18px; |
| z-index: 2001; |
| text-align: center; |
| pointer-events: none; |
| `; |
| |
| document.body.appendChild(notification); |
|
|
| const removeNotification = () => { |
| if (document.pointerLockElement) { |
| notification.remove(); |
| document.removeEventListener('pointerlockchange', removeNotification); |
| } |
| }; |
| document.addEventListener('pointerlockchange', removeNotification); |
| } |
|
|
| document.addEventListener('pointerlockchange', () => { |
| if (document.pointerLockElement === document.body) { |
| console.log('Pointer locked'); |
| } else { |
| console.log('Pointer unlocked'); |
| if (gameStarted && window.gameInstance && !window.gameInstance.isGameOver) { |
| console.log('게임 중 포인터 락 해제됨 - 클릭하여 다시 잠그세요'); |
| showPointerLockNotification(); |
| } |
| } |
| }); |
|
|
| document.addEventListener('click', (event) => { |
| if (!gameStarted && !event.target.classList.contains('start-button')) { |
| event.preventDefault(); |
| event.stopPropagation(); |
| return false; |
| } |
| |
| if (gameStarted && window.gameInstance && !window.gameInstance.isGameOver) { |
| if (!document.pointerLockElement) { |
| console.log('게임 중 클릭 - 포인터 락 재요청'); |
| document.body.requestPointerLock(); |
| } |
| |
| } |
| }, true); |
|
|
| window.addEventListener('gameReady', () => { |
| gameCanStart = true; |
| }); |
|
|
| document.addEventListener('DOMContentLoaded', () => { |
| console.log('전투기 시뮬레이터 초기화 중...'); |
| const game = new Game(); |
| }); |