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, // 300kt 이하에서 스톨 위험 GRAVITY: 9.8, MOUSE_SENSITIVITY: 0.001, MAX_G_FORCE: 12.0, ENEMY_COUNT: 4, MISSILE_COUNT: 6, AMMO_COUNT: 940, // 940발로 변경 BULLET_DAMAGE: 50, // 발당 50 데미지 MAX_HEALTH: 1000, // 체력 1000 AIM9_COUNT: 8, // AIM-9 미사일 8발 AIM9_DAMAGE: 1000, // AIM-9 미사일 피해 1000! AIM9_SPEED: 514.4, // 1000kt를 m/s로 변환 AIM9_LOCK_RANGE: 6000, // 락온 거리 6000m AIM9_LOCK_REQUIRED: 3, // 3번 락온 필요 ENEMY_AIM9_COUNT: 4, // 적 AIM-9 미사일 수 ENEMY_LOCK_RANGE: 5000, // 적 락온 거리 ENEMY_LOCK_ANGLE: Math.PI / 12 // 적 락온 각도 (15도) }; // 전투기 클래스 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); // 초기 속도 350kt this.acceleration = new THREE.Vector3(0, 0, 0); this.rotation = new THREE.Euler(0, 0, 0, 'YXZ'); // 항공기 표준 오일러 순서 // 비행 제어 this.throttle = 0.7; // 초기 스로틀 70% this.speed = 350; // 초기 속도 350kt this.altitude = 2000; this.gForce = 1.0; this.health = GAME_CONSTANTS.MAX_HEALTH; // 체력 1000 // 조종 입력 시스템 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 = []; // 기관총 소리 배열 (최대 5개) // 플레어 시스템 this.flareCount = 3; // 플레어 3회 사용 가능 this.deployedFlares = []; // 배치된 플레어 배열 this.lastFlareTime = 0; // 마지막 플레어 사용 시간 // AIM-9 미사일 시스템 this.aim9Missiles = GAME_CONSTANTS.AIM9_COUNT; this.firedMissiles = []; // 발사된 미사일 배열 this.currentWeapon = 'MG'; // 'MG' 또는 'AIM9' this.lockTarget = null; // 현재 락온 중인 타겟 this.lockProgress = 0; // 락온 진행도 (0~3) this.lastLockTime = 0; // 마지막 락온 시도 시간 this.lockAudios = { locking: null, locked: null }; this.initializeLockAudios(); // 스톨 탈출을 위한 F키 상태 this.escapeKeyPressed = false; this.stallEscapeProgress = 0; // 스톨 탈출 진행도 (0~2초) // 카메라 설정 this.cameraDistance = 250; this.cameraHeight = 30; this.cameraLag = 0.06; // 경고 시스템 this.altitudeWarning = false; this.stallWarning = false; this.warningBlinkTimer = 0; this.warningBlinkState = false; // Over-G 시스템 this.overG = false; this.maxGForce = 9.0; this.overGTimer = 0; // Over-G 지속 시간 // 경고음 시스템 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 = []; // 락온 중인 적들 } // 헤딩을 0~360도로 정규화하는 헬퍼 함수 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; // 경고음에만 ended 이벤트 리스너 추가 (엔진 소리 제외) 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) { // missileIncoming 재생 if (this.warningAudios.missileIncoming && this.warningAudios.missileIncoming.paused) { this.warningAudios.missileIncoming.play().catch(e => {}); } } else { // 미사일이 없으면 missileIncoming 중지 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) { // missileLock 재생 if (this.warningAudios.missileLock && this.warningAudios.missileLock.paused) { this.warningAudios.missileLock.play().catch(e => {}); } } else { // 락온이 없으면 missileLock 중지 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'; // 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) { // AIM-9 모드가 아니면 락온 하지 않음 if (this.currentWeapon !== 'AIM9') { this.lockTarget = null; this.lockProgress = 0; return; } const now = Date.now(); // 1초에 한 번씩 락온 처리 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))); // 크로스헤어 범위 내 (약 10도) 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) { // Over-G 상태에서 스톨이 해제되지 않았으면 피치 조작 불가 if (this.overG && this.overGTimer > 1.0 && this.stallWarning) { // 요(Yaw)만 제한적으로 허용 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; // 60도 const maxRollAngle = Math.PI * 0.5; // 90도 this.targetPitch = Math.max(-maxPitchAngle, Math.min(maxPitchAngle, this.targetPitch)); this.targetRoll = Math.max(-maxRollAngle, Math.min(maxRollAngle, this.targetRoll)); } updateControls(keys, deltaTime) { // W/S: 스로틀만 제어 (가속/감속) 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); } // A/D: 보조 요 제어 (러더) - 반응성 개선 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); // Yaw 보간 (최단 경로) let yawDiff = this.normalizeAngle(this.targetYaw - this.rotation.y); this.rotation.y += yawDiff * yawRotationSpeed; // rotation.y를 -π ~ π 범위로 유지 this.rotation.y = this.normalizeAngle(this.rotation.y); this.targetYaw = this.normalizeAngle(this.targetYaw); // 롤 자동 복귀 시스템 if (Math.abs(yawDiff) < 0.05) { // 요 회전이 거의 없을 때 롤을 0으로 복귀 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; } // 현실적인 속도 계산 - 1750kt까지 증가 const minSpeed = 0; // 최소 속도 0kt (m/s) const maxSpeed = 900.6; // 최대 속도 1750kt를 m/s로 변환 (1750 * 0.5144444) 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); // 90도 기준 if (pitchDegrees > 30) { // 30도 이상일 때 급격한 감속 targetSpeed *= Math.max(0, 1 - climbFactor * 1.5); // 최대 150% 감속 (0kt까지) } else { targetSpeed *= (1 - climbFactor * 0.3); // 정상적인 감속 } } else if (pitchAngle > 0.1) { // 기수가 아래로 (하강) - 스톨 상태에서도 적용 const diveFactor = pitchAngle / (Math.PI / 3); targetSpeed *= (1 + diveFactor * 0.4); // 하강 시 가속 증가 (0.2 -> 0.4) } // G-Force 계산 개선 const turnRate = Math.abs(bankTurnRate) * 100; const pitchRate = Math.abs(this.rotation.x - this.targetPitch) * 10; // 고도에 따른 G-Force 증가 배율 계산 const altitudeInKm = this.position.y / 1000; // 미터를 킬로미터로 변환 const altitudeMultiplier = 1 + (altitudeInKm * 0.2); // 1km당 20% 증가 // 스로틀에 따른 G-Force 증가 배율 계산 // THR 50% 이하: 0배, THR 75%: 0.5배, THR 100%: 1.0배 let throttleGMultiplier = 0; if (this.throttle > 0.5) { // 0.5 ~ 1.0 범위를 0 ~ 1.0으로 매핑 throttleGMultiplier = (this.throttle - 0.5) * 2.0; } // 비정상적인 자세에 의한 G-Force 추가 let abnormalG = 0; // 뒤집힌 상태 (롤이 90도 이상) 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); // 스로틀 배율 추가 } // 롤이 ±30도 이상일 때 추가 G-Force const rollDegrees = Math.abs(this.rotation.z) * (180 / Math.PI); if (rollDegrees > 30) { // 30도 초과분당 0.1G 추가 const extremeRollG = (rollDegrees - 30) * 0.1; abnormalG += extremeRollG * altitudeMultiplier * (1 + throttleGMultiplier); } // 피치 각도가 ±40도 이상일 때 추가 G-Force if (pitchDegrees >= 40) { // 40도 이상일 때 급격한 G-Force 증가 const extremePitchG = (pitchDegrees - 40) * 0.15; // 40도 초과분당 0.15G 추가 abnormalG += extremePitchG * altitudeMultiplier * (1 + throttleGMultiplier); } // 기수가 계속 위를 향하고 있는 경우 (피치가 -30도 이하) if (pitchAngle < -Math.PI / 6) { const baseG = 2.0 + Math.abs(pitchAngle + Math.PI / 6) * 3; abnormalG += baseG * altitudeMultiplier * (1 + throttleGMultiplier); // 스로틀 배율 추가 } // 기수가 과도하게 아래를 향하고 있는 경우 (피치가 60도 이상) if (pitchAngle > Math.PI / 3) { const baseG = 2.0 + Math.abs(pitchAngle - Math.PI / 3) * 3; abnormalG += baseG * altitudeMultiplier * (1 + throttleGMultiplier); // 스로틀 배율 추가 } // 급격한 기동에 의한 G-Force const maneuverG = (turnRate + pitchRate + (Math.abs(this.rotation.z) * 3)) * (1 + throttleGMultiplier * 0.5); // 총 G-Force 계산 this.gForce = 1.0 + maneuverG + abnormalG; // G-Force 회복 조건 수정 // 1. Over-G 상태가 아닌 경우에만 회복 // 2. 피치가 ±10도 이내일 때만 회복 // 3. 스톨 상태가 아닐 때만 회복 const isPitchNeutral = Math.abs(pitchDegrees) <= 10; if (!this.overG && isPitchNeutral && !isInverted && !this.stallWarning) { // 스로틀이 높을수록 G-Force가 천천히 감소 const recoveryRate = 2.0 - throttleGMultiplier * 1.5; // THR 100%일 때 0.5, THR 50%일 때 2.0 this.gForce = THREE.MathUtils.lerp(this.gForce, 1.0 + maneuverG, deltaTime * recoveryRate); } else if (this.overG) { // Over-G 상태에서는 피치가 0도 근처(±10도)가 되고 스톨이 회복될 때까지 회복하지 않음 if (!isPitchNeutral || this.stallWarning) { // 피치가 중립이 아니거나 스톨 상태면 G-Force 유지 또는 증가만 가능 this.gForce = Math.max(this.gForce, 1.0 + maneuverG + abnormalG); } else { // 피치가 중립이고 스톨이 아닐 때만 매우 천천히 회복 const overGRecoveryRate = 0.3 - throttleGMultiplier * 0.2; // Over-G 상태에서는 더 느린 회복 this.gForce = THREE.MathUtils.lerp(this.gForce, 1.0 + maneuverG, deltaTime * overGRecoveryRate); } } // 스톨 상태에서는 Over-G가 감소하지 않도록 추가 처리 if (this.stallWarning && this.overG) { // 스톨 중에는 G-Force를 9.0 이상으로 유지 this.gForce = Math.max(this.gForce, 9.0); } this.overG = this.gForce > this.maxGForce; // Over-G 타이머 업데이트 if (this.overG) { this.overGTimer += deltaTime; // 1.5초 이상 Over-G 상태일 경우 시야 흐림 시작 if (this.overGTimer > 1.5) { // 속도 급격히 감소 (2.5초부터) if (this.overGTimer > 2.5) { targetSpeed *= Math.max(0.3, 1 - (this.overGTimer - 2.5) * 0.3); } // 시야 흐림 효과는 UI에서 처리 (1.5초부터) } } else { this.overGTimer = 0; // Over-G 상태가 아니면 타이머 리셋 } // 스톨 경고: 300kt 이하에서 스톨 위험 const speedKnots = this.speed * 1.94384; // m/s to knots const wasStalling = this.stallWarning; // 스톨 진입 조건 if (!this.stallWarning && speedKnots < GAME_CONSTANTS.STALL_SPEED) { this.stallWarning = true; this.stallEscapeProgress = 0; // 스톨 진입 시 진행도 초기화 } // 스톨 탈출 조건 if (this.stallWarning) { if (this.escapeKeyPressed) { // F키를 누르고 있으면 진행도 증가 this.stallEscapeProgress += deltaTime; // 2초 이상 누르면 스톨에서 탈출 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 { // F키를 놓으면 진행도 감소 this.stallEscapeProgress = Math.max(0, this.stallEscapeProgress - deltaTime * 2); } } // 속도 변화 적용 if (this.stallWarning) { // 스톨 상태에서의 속도 변화 if (pitchAngle > 0.1) { // 기수가 아래를 향할 때 // 다이빙으로 인한 속도 증가 - 1750kt 최대 속도에 맞춰 조정 const diveSpeedGain = Math.min(pitchAngle * 500, 400); // 최대 400m/s 증가 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); // 기수가 더 극단적으로 아래로 (72도까지) // 조종 불능 상태 - 더 심한 흔들림 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; // 3배 중력 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); // 쿼터니언 합성 (올바른 항공기 순서: Roll -> Pitch -> Yaw) // 항공기는 먼저 롤, 그 다음 피치, 마지막으로 요 순서로 회전 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; // y 속도는 위에서 중력으로 처리됨 } // 정상 비행 시 중력 효과 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; // 500m/s 이상에서 최대 양력 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(); // Y축 회전에 3π/2 추가 (모델 오프셋) 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; // 0.3~0.7 } // 플레어 업데이트 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') { // 기존 MG 발사 로직 // 탄약이 없으면 발사하지 않음 if (this.ammo <= 0) return; this.ammo--; // 직선 모양의 탄환 (100% 더 크게) const bulletGeometry = new THREE.CylinderGeometry(1.0, 1.0, 16, 8); // 반지름 0.75→1.0, 길이 12→16 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); // 실린더가 Z축 방향을 향하도록 추가 회전 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; // 1000에서 1500으로 증가 (50% 빠르게) const direction = new THREE.Vector3(0, 0, 1); direction.applyQuaternion(quaternion); bullet.velocity = direction.multiplyScalar(bulletSpeed); scene.add(bullet); this.bullets.push(bullet); // m134.ogg 소리 재생 - 중첩 제한 해제, 랜덤 피치 try { const audio = new Audio('sounds/m134.ogg'); audio.volume = 0.15; // 0.3에서 0.15로 감소 (50% 줄임) // -2 ~ +2 사이의 랜덤 피치 (playbackRate로 시뮬레이션) const randomPitch = 0.8 + Math.random() * 0.4; // 0.8 ~ 1.2 범위 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') { // AIM-9 미사일 발사 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; // 2초 쿨다운 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); // 양쪽으로 2개씩, 총 4개 플레어 발사 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; } // 6000m 이상 날아가거나 높이 제한 초과 시 제거 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(); } // HUD 표시용 헤딩 getter (0~360도) 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); // 200m/s 속도 // 좌우로 분산 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; // 5초 지속 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 = []; } } // AIM-9 미사일 클래스 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; // 2000kt를 m/s로 변환 this.mesh = null; this.isLoaded = false; this.lifeTime = 30; // 30초로 증가 (더 오래 추적) this.turnRate = 6.0; // 회전율 2배 증가 (더 빠른 추적) 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() { // missileswing.ogg는 update 메서드에서 한 번만 재생 // 여기서는 초기화하지 않음 } createMissile() { // 즉시 폴백 모델 생성 (비동기 로딩 대신) this.createFallbackMissile(); this.mesh.position.copy(this.position); this.scene.add(this.mesh); // 나중에 GLB 모델 로드 시도 (선택사항) 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); // 50% 더 크게 (1.5 -> 2.25) this.isLoaded = true; } update(deltaTime, playerPosition) { if (!this.mesh || !this.target || !this.target.position) { this.destroy(); return 'expired'; } // *** 강화된 플레어 탐지 로직 *** // 플레어 탐지 및 목표 변경 - 100% 성공률 if (window.gameInstance && !this.isTargetingFlare) { const nearestFlare = window.gameInstance.findNearestFlare(this.position); if (nearestFlare) { const flareDistance = this.position.distanceTo(nearestFlare.position); // 탐지 거리를 1000m로 확대하고 열 신호 조건 제거 if (flareDistance < 1000) { // 플레어로 목표 변경 - 100% 성공 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'; } // missileswing.ogg를 한 번만 재생 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) { // 초기 5초간 가속 this.speed = Math.min(this.speed + deltaTime * 300, 2057.6); // 최대 4000kt } else { // 이후에도 높은 속도 유지 this.speed = Math.min(this.speed + deltaTime * 100, 2057.6); } // 타겟과의 거리에 따라 속도 추가 증가 if (distance < 1000) { // 근거리에서 더욱 빠르게 this.speed = Math.min(this.speed * 1.2, 2572); // 최대 5000kt } 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 // 3초 동안 지속 }); // 연기 파티클 제한 (성능을 위해) 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; } } } // 적기용 AIM-9 미사일 클래스 (여기에 추가!) 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; // 2000kt를 m/s로 변환 this.mesh = null; this.isLoaded = false; this.lifeTime = 20; // 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; // 600kt로 시작 (500-750kt 범위) this.velocity = new THREE.Vector3(0, 0, 0); this.health = GAME_CONSTANTS.MAX_HEALTH; this.minSpeed = 257; // 500kt in m/s this.maxSpeed = 386; // 750kt in m/s // AI 상태 this.aiState = 'patrol'; // patrol, combat, evade, retreat 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(); // AIM-9 미사일 시스템 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; // 플레어 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); // AI 행동 실행 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); // 속도 유지 (500-750kt) - 항상 최소 속도 이상 유지 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); // 직진 상태 확인 (피치와 롤이 거의 0에 가까운지) const isPitchLevel = Math.abs(this.rotation.x) < 0.1; // 약 5.7도 이내 const isRollLevel = Math.abs(this.rotation.z) < 0.1; // 약 5.7도 이내 const isStraightFlying = isPitchLevel && isRollLevel; // 직진 비행 중이고 정확한 조준 시에만 발사 가능 this.canShoot = isStraightFlying && aimAccuracy < 0.15; // 발사 조건 충족 시 발사 if (this.canShoot) { this.fireWeapon(); } else { // 발사할 수 없으면 연발 카운터 리셋 this.burstCounter = 0; } // 거리에 따라 기동 결정 if (distance < 150) { // 150m 이내면 회피 기동 시작 this.selectEvadeTarget(); // 일시적 회피 모드 활성화 this.temporaryEvadeMode = true; this.evadeTimer = 1.0; } else if (distance < 500) { // 500m 이내면 측면 회피 this.selectEvadeTarget(); } else if (distance > 2000) { // 멀면 접근 this.targetPosition = playerPosition.clone(); } } // 새로운 메서드: 충돌 예측 checkCollisionPrediction(deltaTime) { if (!this.nearbyEnemies) return; // 2초 후 예상 위치 계산 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); // 150m 이내로 접근 예상 시 사전 회피 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; // Yaw 회전 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; } // Pitch 회전 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; // 30도 상승 } // 회피 중에는 최대 속도 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; // Yaw 회전 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; } // Pitch 회전 (제한적) 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; } // Pitch 제한 (±40도) 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); // 100m 미만: 긴급 회피 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++; } // 100-300m: 예방적 회피 else if (distance < 300) { // 반대 방향으로 회피 const avoidDir = this.position.clone().sub(enemy.position).normalize(); const strength = (300 - distance) / 200; // 100-300m 범위에서 강도 계산 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; // 2초 동안 회피 우선 } } } updatePhysics(deltaTime) { if (!this.mesh) return; // 속도 벡터 계산 (항상 전진) const forward = new THREE.Vector3(0, 0, 1); forward.applyEuler(this.rotation); // 속도 유지 (500-750kt, m/s로 변환) - 강제로 최소 속도 유지 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(); // 0.1초에 1발씩, 10발 연발 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++; // 10발 발사 완료 시 잠시 휴식 if (this.burstCounter >= 10) { this.burstCounter = 0; this.lastShootTime = now + 1000; // 1초 대기 } } } } 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); // X=0, Y=0으로 중앙 고정 muzzleOffset.applyEuler(this.rotation); bullet.position.copy(this.position).add(muzzleOffset); // 탄환을 발사 방향으로 회전 bullet.rotation.copy(this.rotation); bullet.rotateX(Math.PI / 2); // 실린더가 Z축 방향을 향하도록 // 탄환 초기 위치 저장 bullet.startPosition = bullet.position.clone(); // 탄환 속도 (플레이어와 동일하게 1500) - 정확히 전방으로 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; } // 거리 제한 (플레이어와 동일하게 6000m) 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) { // 1초마다 락온 진행 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) { // 미사일 쿨다운 체크 (3초) 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); // 4개 플레어 발사 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 = '