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: 300 }; // 전투기 클래스 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); // 비행 제어 this.throttle = 0.6; // 초기 스로틀 60% this.speed = 350; // 초기 속도 350kt this.altitude = 2000; this.gForce = 1.0; this.health = 100; // 조종 입력 시스템 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; // 스톨 탈출을 위한 F키 상태 this.escapeKeyPressed = false; // 카메라 설정 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 // 엔진 소리 추가 }; this.initializeWarningAudios(); } 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; // 엔진 소리는 계속 반복 // 경고음에만 ended 이벤트 리스너 추가 (엔진 소리 제외) Object.keys(this.warningAudios).forEach(key => { if (key !== 'normal' && this.warningAudios[key]) { this.warningAudios[key].addEventListener('ended', () => { this.updateWarningAudios(); }); } }); } catch (e) { console.log('Warning 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.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 !== 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; } }); } 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 전투기 모델 생성 완료'); } updateMouseInput(deltaX, deltaY) { const sensitivity = GAME_CONSTANTS.MOUSE_SENSITIVITY * 1.0; // 마우스 Y축: 피치(기수 상하) this.targetPitch -= deltaY * sensitivity; // 마우스 X축: 요(Yaw) - 몸체를 좌우로 회전 this.targetYaw += deltaX * sensitivity * 0.8; // 요 회전 감도 조정 // 요 회전에 따른 자동 롤 (뱅크 턴) // 좌우로 회전할 때 자연스럽게 날개가 기울어짐 const yawRate = deltaX * sensitivity * 0.8; this.targetRoll = -yawRate * 15; // 요 회전량에 비례하여 롤 발생 // 각도 제한 const maxPitchAngle = Math.PI / 3; // 60도 const maxRollAngle = Math.PI * 0.5; // 90도로 제한 (자동 롤) this.targetPitch = Math.max(-maxPitchAngle, Math.min(maxPitchAngle, this.targetPitch)); // 자동 롤은 제한된 범위 내에서만 작동 if (Math.abs(this.targetRoll) < maxRollAngle) { // 롤 각도 제한 적용 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 * 0.4; // 감소된 러더 효과 } if (keys.d) { this.targetYaw += deltaTime * 0.4; // 감소된 러더 효과 } } updatePhysics(deltaTime) { if (!this.mesh) return; // 부드러운 회전 보간 const rotationSpeed = deltaTime * 2.0; this.rotation.x = THREE.MathUtils.lerp(this.rotation.x, this.targetPitch, rotationSpeed); this.rotation.y = THREE.MathUtils.lerp(this.rotation.y, this.targetYaw, rotationSpeed); // 롤 자동 복귀 시스템 if (Math.abs(this.targetYaw - this.rotation.y) < 0.05) { // 요 회전이 거의 없을 때 롤을 0으로 복귀 this.targetRoll *= 0.95; } this.rotation.z = THREE.MathUtils.lerp(this.rotation.z, this.targetRoll, rotationSpeed * 1.5); // 롤은 더 빠르게 반응 // 워썬더 스타일: 요 회전이 주도적, 롤은 보조적 // 롤에 따른 추가 요 회전은 제거하거나 최소화 let bankTurnRate = 0; if (Math.abs(this.rotation.z) > 0.3) { // 롤이 충분히 클 때만 const bankAngle = this.rotation.z; bankTurnRate = Math.sin(bankAngle) * deltaTime * 0.1; // 매우 작은 선회율 this.targetYaw += bankTurnRate; } // 현실적인 속도 계산 const minSpeed = 0; // 최소 속도 0kt const maxSpeed = 600; // 최대 속도 600kt 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 추가 let abnormalG = 0; // 뒤집힌 상태 (롤이 90도 이상) const isInverted = Math.abs(this.rotation.z) > Math.PI / 2; if (isInverted) { abnormalG += (3.0 + Math.abs(Math.abs(this.rotation.z) - Math.PI / 2) * 2) * altitudeMultiplier; // 고도 배율 적용 } // 기수가 계속 위를 향하고 있는 경우 (피치가 -30도 이하) if (pitchAngle < -Math.PI / 6) { abnormalG += (2.0 + Math.abs(pitchAngle + Math.PI / 6) * 3) * altitudeMultiplier; // 고도 배율 적용 } // 기수가 과도하게 아래를 향하고 있는 경우 (피치가 60도 이상) if (pitchAngle > Math.PI / 3) { abnormalG += (2.0 + Math.abs(pitchAngle - Math.PI / 3) * 3) * altitudeMultiplier; // 고도 배율 적용 } // 급격한 기동에 의한 G-Force const maneuverG = turnRate + pitchRate + (Math.abs(this.rotation.z) * 3); // 총 G-Force 계산 this.gForce = 1.0 + maneuverG + abnormalG; // 정상 자세로 돌아오면 G-Force 자연스럽게 감소 if (!isInverted && pitchAngle > -Math.PI / 6 && pitchAngle < Math.PI / 6) { // 정상 자세일 때 G-Force를 부드럽게 감소 this.gForce = THREE.MathUtils.lerp(this.gForce, 1.0 + maneuverG, deltaTime * 2.0); } this.overG = this.gForce > this.maxGForce; // Over-G 타이머 업데이트 if (this.overG) { this.overGTimer += deltaTime; // 3초 이상 Over-G 상태일 경우 if (this.overGTimer > 3.0) { // 속도 급격히 감소 (0kt까지) targetSpeed *= Math.max(0, 1 - (this.overGTimer - 3.0) * 0.5); // 시야 흐림 효과는 UI에서 처리 } } 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; } // 스톨 탈출 조건: F키를 누르고 있고, 기수가 아래를 향하고 있고, 속도가 충분할 때 if (this.stallWarning && this.escapeKeyPressed && pitchAngle > 0.2 && speedKnots > GAME_CONSTANTS.STALL_SPEED + 50) { // F키를 누르고 있을 때만 스톨 탈출 this.stallWarning = false; } // 속도 변화 적용 if (this.stallWarning) { // 스톨 상태에서의 속도 변화 if (pitchAngle > 0.1) { // 기수가 아래를 향할 때 // 다이빙으로 인한 속도 증가 const diveSpeedGain = Math.min(pitchAngle * 300, 200); // 최대 200m/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 / 3, this.targetPitch + deltaTime * 2.0); // 기수가 빠르게 아래로 // 조종 불능 상태 this.rotation.x += (Math.random() - 0.5) * deltaTime * 0.5; this.rotation.z += (Math.random() - 0.5) * deltaTime * 0.5; // 중력에 의한 가속 const gravityAcceleration = GAME_CONSTANTS.GRAVITY * deltaTime * 3.0; // 3배 중력 this.velocity.y -= gravityAcceleration; } // 속도 벡터 계산 const noseDirection = new THREE.Vector3(0, 0, 1); noseDirection.applyEuler(this.rotation); 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 = (this.speed / maxSpeed) * 0.8; const lift = gravityEffect * liftFactor; this.velocity.y += lift; } // 위치 업데이트 this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); // 지면 충돌 if (this.position.y <= GAME_CONSTANTS.MIN_ALTITUDE) { this.position.y = GAME_CONSTANTS.MIN_ALTITUDE; this.health = 0; 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); 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; // 경고 깜빡임 타이머 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 } } shoot(scene) { const currentTime = Date.now(); if (currentTime - this.lastShootTime < 100 || this.ammo <= 0) return; this.lastShootTime = currentTime; this.ammo--; const bulletGeometry = new THREE.SphereGeometry(0.2); const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, emissive: 0xffff00, emissiveIntensity: 0.7 }); const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); const muzzleOffset = new THREE.Vector3(0, 0, 8); muzzleOffset.applyEuler(this.rotation); bullet.position.copy(this.position).add(muzzleOffset); const bulletSpeed = 1000; const direction = new THREE.Vector3(0, 0, 1); direction.applyEuler(this.rotation); bullet.velocity = direction.multiplyScalar(bulletSpeed).add(this.velocity); scene.add(bullet); this.bullets.push(bullet); try { const audio = new Audio('sounds/gunfire.ogg'); if (audio) { audio.volume = 0.3; audio.play().catch(e => console.log('Gunfire sound failed to play')); } } catch (e) {} } updateBullets(scene, 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.distanceTo(this.position) > 8000 || bullet.position.y < 0 || bullet.position.y > GAME_CONSTANTS.MAX_ALTITUDE + 500) { scene.remove(bullet); this.bullets.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); backward.applyEuler(this.rotation); up.applyEuler(this.rotation); const cameraPosition = this.position.clone() .add(backward.multiplyScalar(this.cameraDistance)) .add(up.multiplyScalar(this.cameraHeight)); return cameraPosition; } getCameraTarget() { return this.position.clone(); } } // 적 전투기 클래스 class EnemyFighter { constructor(scene, position) { this.mesh = null; this.isLoaded = false; this.scene = scene; this.position = position.clone(); this.velocity = new THREE.Vector3(0, 0, 120); this.rotation = new THREE.Euler(0, 0, 0); this.health = 100; this.speed = 120; this.bullets = []; this.lastShootTime = 0; this.aiState = 'patrol'; this.targetPosition = position.clone(); this.patrolCenter = position.clone(); this.patrolRadius = 2000; this.lastStateChange = 0; } 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 = 3 * Math.PI / 2; 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; console.log('Fallback 적기 모델 생성 완료'); } update(playerPosition, deltaTime) { if (!this.mesh || !this.isLoaded) return; const currentTime = Date.now(); const distanceToPlayer = this.position.distanceTo(playerPosition); if (distanceToPlayer < 5000) { const direction = new THREE.Vector3() .subVectors(playerPosition, this.position) .normalize(); this.velocity = direction.multiplyScalar(this.speed); this.rotation.y = Math.atan2(direction.x, direction.z); if (distanceToPlayer < 2000 && currentTime - this.lastShootTime > 1500) { this.shoot(); } } else { if (this.position.distanceTo(this.targetPosition) < 300) { const angle = Math.random() * Math.PI * 2; this.targetPosition = this.patrolCenter.clone().add( new THREE.Vector3( Math.cos(angle) * this.patrolRadius, (Math.random() - 0.5) * 1000, Math.sin(angle) * this.patrolRadius ) ); } const direction = new THREE.Vector3() .subVectors(this.targetPosition, this.position) .normalize(); this.velocity = direction.multiplyScalar(this.speed * 0.7); this.rotation.y = Math.atan2(direction.x, direction.z); } this.position.add(this.velocity.clone().multiplyScalar(deltaTime)); if (this.position.y < GAME_CONSTANTS.MIN_ALTITUDE) { this.position.y = GAME_CONSTANTS.MIN_ALTITUDE; } if (this.position.y > GAME_CONSTANTS.MAX_ALTITUDE) { this.position.y = GAME_CONSTANTS.MAX_ALTITUDE; } 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); 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; this.updateBullets(deltaTime); } shoot() { this.lastShootTime = Date.now(); const bulletGeometry = new THREE.SphereGeometry(0.15); const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000, emissive: 0xff0000, emissiveIntensity: 0.5 }); const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); const muzzleOffset = new THREE.Vector3(0, 0, 6); muzzleOffset.applyEuler(this.rotation); bullet.position.copy(this.position).add(muzzleOffset); const direction = new THREE.Vector3(0, 0, 1); direction.applyEuler(this.rotation); bullet.velocity = direction.multiplyScalar(800); this.scene.add(bullet); this.bullets.push(bullet); } 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.distanceTo(this.position) > 5000 || bullet.position.y < 0) { this.scene.remove(bullet); this.bullets.splice(i, 1); } } } takeDamage(damage) { this.health -= damage; return this.health <= 0; } destroy() { if (this.mesh) { this.scene.remove(this.mesh); this.bullets.forEach(bullet => this.scene.remove(bullet)); this.bullets = []; 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.bgm = null; this.bgmPlaying = false; this.keys = { w: false, a: false, s: false, d: false, f: false }; this.isStarted = false; 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); 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(8000, 10000, 8000); directionalLight.castShadow = true; directionalLight.shadow.mapSize.width = 2048; directionalLight.shadow.mapSize.height = 2048; directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 20000; directionalLight.shadow.camera.left = -10000; directionalLight.shadow.camera.right = 10000; directionalLight.shadow.camera.top = 10000; directionalLight.shadow.camera.bottom = -10000; this.scene.add(directionalLight); const groundGeometry = new THREE.PlaneGeometry(GAME_CONSTANTS.MAP_SIZE, GAME_CONSTANTS.MAP_SIZE); const groundMaterial = new THREE.MeshLambertMaterial({ color: 0x8FBC8F, transparent: true, opacity: 0.8 }); const ground = new THREE.Mesh(groundGeometry, groundMaterial); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; this.scene.add(ground); this.addClouds(); } addClouds() { const cloudGeometry = new THREE.SphereGeometry(100, 8, 6); const cloudMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.5 }); for (let i = 0; i < 100; i++) { const cloud = new THREE.Mesh(cloudGeometry, cloudMaterial); cloud.position.set( (Math.random() - 0.5) * GAME_CONSTANTS.MAP_SIZE, Math.random() * 4000 + 1000, (Math.random() - 0.5) * GAME_CONSTANTS.MAP_SIZE ); cloud.scale.set( Math.random() * 3 + 1, Math.random() * 2 + 0.5, Math.random() * 3 + 1 ); this.scene.add(cloud); } } setupEventListeners() { document.addEventListener('keydown', (event) => { if (this.isGameOver || !gameStarted) return; switch(event.code) { case 'KeyW': this.keys.w = true; break; case 'KeyA': this.keys.a = true; break; case 'KeyS': this.keys.s = true; break; case 'KeyD': this.keys.d = true; break; case 'KeyF': this.keys.f = true; break; } }); document.addEventListener('keyup', (event) => { if (this.isGameOver || !gameStarted) 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; } }); document.addEventListener('mousemove', (event) => { if (!document.pointerLockElement || this.isGameOver || !gameStarted) return; const deltaX = event.movementX || 0; const deltaY = event.movementY || 0; this.fighter.updateMouseInput(deltaX, deltaY); }); 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(); console.log('게임 시작!'); } 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) { this.endGame(true); } } }, 1000); } 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'); if (scoreElement) scoreElement.textContent = `Score: ${this.score}`; if (timeElement) timeElement.textContent = `Time: ${this.gameTime}s`; if (healthElement) healthElement.style.width = `${this.fighter.health}%`; if (ammoElement) ammoElement.textContent = `AMMO: ${this.fighter.ammo}`; 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.rotation.y * (180 / Math.PI)) + 360) % 360); // 선회율 계산 (도/초) 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'); // 2000m 이내면 락온 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`; 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()); 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 W 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 F 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; } } `; document.head.appendChild(style); } } // Over-G 3초 이상일 때 시야 흐림 효과 if (this.fighter.overG && this.fighter.overGTimer > 3.0) { let blurEffect = document.getElementById('overGBlurEffect'); if (!blurEffect) { blurEffect = document.createElement('div'); blurEffect.id = 'overGBlurEffect'; document.body.appendChild(blurEffect); } // Over-G 지속 시간에 따라 흐림 정도 증가 const blurIntensity = Math.min((this.fighter.overGTimer - 3.0) * 0.3, 0.8); blurEffect.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(ellipse at center, transparent 20%, rgba(0, 0, 0, ${blurIntensity}) 50%, rgba(0, 0, 0, ${blurIntensity + 0.2}) 100%); pointer-events: none; z-index: 1400; `; } else { // Over-G 상태가 아니면 효과 제거 const blurEffect = document.getElementById('overGBlurEffect'); if (blurEffect) { blurEffect.remove(); } } } 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); } }); } checkCollisions() { 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 < 25) { this.scene.remove(bullet); this.fighter.bullets.splice(i, 1); if (enemy.takeDamage(30)) { enemy.destroy(); this.enemies.splice(j, 1); this.score += 100; } break; } } } this.enemies.forEach(enemy => { enemy.bullets.forEach((bullet, index) => { const distance = bullet.position.distanceTo(this.fighter.position); if (distance < 30) { this.scene.remove(bullet); enemy.bullets.splice(index, 1); if (this.fighter.takeDamage(20)) { this.endGame(false); } } }); }); } 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) { // F키 상태를 Fighter에 전달 this.fighter.escapeKeyPressed = this.keys.f; this.fighter.updateControls(this.keys, deltaTime); this.fighter.updatePhysics(deltaTime); this.fighter.updateBullets(this.scene, deltaTime); if (this.isStarted) { this.enemies.forEach(enemy => { enemy.update(this.fighter.position, deltaTime); }); this.checkCollisions(); if (this.fighter.health <= 0 && this.fighter.position.y <= 0) { this.endGame(false, "GROUND COLLISION"); return; } this.updateUI(); this.updateRadar(); if (this.enemies.length === 0) { this.endGame(true); } } const targetCameraPos = this.fighter.getCameraPosition(); const targetCameraTarget = this.fighter.getCameraTarget(); this.camera.position.lerp(targetCameraPos, this.fighter.cameraLag); this.camera.lookAt(targetCameraTarget); } else { if (this.fighter.isLoaded) { const initialCameraPos = this.fighter.getCameraPosition(); const initialTarget = this.fighter.getCameraTarget(); this.camera.position.copy(initialCameraPos); this.camera.lookAt(initialTarget); } } 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'); existingWarnings.forEach(w => w.remove()); const blurEffect = document.getElementById('overGBlurEffect'); if (blurEffect) { blurEffect.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(); } else if (window.gameInstance.fighter.isLoaded) { window.gameInstance.fighter.shoot(window.gameInstance.scene); } } }, true); window.addEventListener('gameReady', () => { gameCanStart = true; }); document.addEventListener('DOMContentLoaded', () => { console.log('전투기 시뮬레이터 초기화 중...'); window.gameInstance = new Game(); });