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 = '
로딩 실패. 페이지를 새로고침해주세요.
'; } } 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; // F키 누를 때 플레어 발사 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 체크 추가 this.fighter.switchWeapon(); console.log('R key pressed - Switching weapon to', this.fighter.currentWeapon); } break; } }); document.addEventListener('keyup', (event) => { if (this.isGameOver) return; // gameStarted 대신 this.isStarted 사용 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) => { // 여기도 gameStarted를 this.isStarted로 변경 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) => { // 여기도 gameStarted를 this.isStarted로 변경 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) { // DOM이 준비될 때까지 약간 대기 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!'); // 플레이어의 incoming missiles 목록에 추가 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 = `
${this.fighter.health}/${GAME_CONSTANTS.MAX_HEALTH}
`; } 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}
FLARES: ${this.fighter.flareCount}`; ammoElement.style.color = '#0f0'; } else { ammoElement.innerHTML = `AIM-9: ${this.fighter.aim9Missiles}
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}
FLARES: ${this.fighter.flareCount}
[${lockStatus}]`; } } } if (gameStatsElement) { gameStatsElement.innerHTML = `
Score: ${this.score}
Time: ${this.gameTime}s
Speed: ${speedKnots} KT
Alt: ${altitudeMeters}m (${altitudeFeet} FT)
Throttle: ${Math.round(this.fighter.throttle * 100)}%
G-Force: ${this.fighter.gForce.toFixed(1)}
Targets: ${this.enemies.length}
`; } this.updateWarnings(); this.updateHUD(); } } updateHUD() { // HUD 크로스헤어 업데이트 const hudElement = document.getElementById('hudCrosshair'); if (!hudElement) return; // 롤 각도에 따라 HUD 회전 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); // 음수를 곱해서 반대 방향으로, 10도당 20픽셀 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)); // 60 FPS 기준 // HUD 정보 업데이트 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; // 10km 이상은 표시하지 않음 // 적의 화면 좌표 계산 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) ); // 크로스헤어 반경 (75px) const crosshairRadius = 75; const isInCrosshair = distFromCenter < crosshairRadius; // 타겟 마커 생성 const marker = document.createElement('div'); marker.className = 'target-marker'; if (isInCrosshair) { marker.classList.add('in-crosshair'); // AIM-9 모드에서 락온 표시 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`; // AIM-9 모드에서 락온 정보 추가 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) { // 3D 좌표를 화면 좌표로 변환 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!
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) { // PULL UP 경고 - 강한 붉은 효과 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 { // LOW ALTITUDE 경고 - 약한 붉은 효과 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, '
'); document.body.appendChild(warningContainer); } } // 스톨 상태일 때만 "Press F to Escape" 경고 표시 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); } } // Over-G 시야 효과 - 수정된 부분 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); } // Over-G 지속 시간에 따라 점진적으로 어두워짐 // 1.5초부터 시작하여 서서히 진행 const adjustedTimer = Math.max(0, this.fighter.overGTimer - 1.5); // 1.5초 이후부터 카운트 const darknessFactor = Math.min(adjustedTimer / 2.0, 0.7); // 2초에 걸쳐 최대 70%까지만 어두워짐 // 시야 가장자리부터 어두워지는 효과 // 중앙은 상대적으로 늦게 어두워짐 const centerTransparency = Math.max(0.3, 1 - darknessFactor * 0.8); // 중앙은 최소 30% 투명도 유지 const midTransparency = Math.max(0.2, 1 - darknessFactor * 0.6); // 중간 투명도 const edgeOpacity = Math.min(0.8, darknessFactor * 0.8); // 가장자리 최대 80% 불투명 // 붉은 색조 제거, 검은색만 사용 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; `; // 심한 Over-G 상태에서 약간의 화면 흔들림 효과 if (darknessFactor > 0.5) { const shake = (1 - Math.random() * 2) * 1; blurEffect.style.transform = `translate(${shake}px, ${shake}px)`; } } else { // Over-G 상태가 아니면 효과 제거 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; // 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); // 직접 충돌 판정 (80m 이내) 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) {} // 1. 충돌 지점에 큰 폭발 효과 this.createExplosionEffect(collisionPoint); // 2. 양쪽 위치에도 폭발 효과 setTimeout(() => { this.createExplosionEffect(playerExplosionPos); this.createExplosionEffect(enemyExplosionPos); }, 50); // 3. 적기 제거 enemy.destroy(); this.enemies.splice(i, 1); this.score += 200; // 충돌 킬은 보너스 점수 // 4. 플레이어 파괴 this.fighter.health = 0; // 5. 게임 종료 setTimeout(() => { this.endGame(false, "COLLISION WITH ENEMY"); }, 100); // 폭발 효과가 보이도록 약간의 딜레이 return; // 충돌 처리 후 즉시 종료 } } // 플레이어 탄환 vs 적기 충돌 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); // 적기 제거 - 체력이 0 이하일 때만 제거 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); } // else 블록 제거 - 적이 파괴되지 않았으면 아무것도 하지 않음 break; // 하나의 탄환은 하나의 적만 맞출 수 있음 } } } // 적 탄환 vs 플레이어 충돌 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); } } } }); // incoming missiles 업데이트 (파괴된 미사일 제거) 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; // 1초 동안 지속 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); } } // 2. createExplosionEffect 수정 - emissive 속성 제거 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) { // 히트 마커 div 생성 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'; // 3D 위치를 화면 좌표로 변환 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); } // 0.5초 후 제거 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); } // F키 상태를 Fighter에 전달 this.fighter.escapeKeyPressed = this.keys.g; // 컨트롤 업데이트 - 반드시 물리 업데이트 전에 호출 this.fighter.updateControls(this.keys, deltaTime); // 락온 업데이트 (AIM-9 모드일 때만) 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; } // UI 업데이트 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 = `

${victory ? 'MISSION ACCOMPLISHED!' : 'SHOT DOWN!'}

${reason ? `
${reason}
` : ''}
Final Score: ${this.score}
Enemies Destroyed: ${GAME_CONSTANTS.ENEMY_COUNT - this.enemies.length}
Mission Time: ${GAME_CONSTANTS.MISSION_DURATION - this.gameTime}s
`; 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(); // window.gameInstance = 제거 });