import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; // 게임 상수 const GAME_DURATION = 180; const MAP_SIZE = 500; const TANK_HEIGHT = 0.5; const ENEMY_GROUND_HEIGHT = 0; const ENEMY_SCALE = 1; const MAX_HEALTH = 1000; const ENEMY_MOVE_SPEED = 0.1; const ENEMY_COUNT_MAX = 3; const PARTICLE_COUNT = 15; const BUILDING_COUNT = 25; const ENEMY_CONFIG = { ATTACK_RANGE: 100, ATTACK_INTERVAL: 2000, BULLET_SPEED: 2 }; // TankPlayer 클래스 class TankPlayer { constructor() { this.body = null; this.turret = null; this.position = new THREE.Vector3(0, 0, 0); this.rotation = new THREE.Euler(0, 0, 0); this.turretRotation = 0; this.moveSpeed = 0.5; this.turnSpeed = 0.03; this.turretGroup = new THREE.Group(); this.health = MAX_HEALTH; this.isLoaded = false; this.ammo = 1; // 변경: 10 -> 1 this.maxAmmo = 1; // 추가: 최대 포탄 수 this.isReloading = false; // 추가: 재장전 상태 this.reloadTime = 3000; // 추가: 3초 재장전 시간 this.lastShootTime = 0; this.bullets = []; this.obstacles = []; this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); this.renderer = new THREE.WebGLRenderer({ antialias: true }); } // 별도의 메서드로 분리 createExplosionEffect(scene, position) { // 폭발 중심 플래시 const flashGeometry = new THREE.SphereGeometry(3); const flashMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent: true, opacity: 1 }); const flash = new THREE.Mesh(flashGeometry, flashMaterial); flash.position.copy(position); scene.add(flash); // 폭발 파티클 for (let i = 0; i < 30; i++) { const size = Math.random() * 0.5 + 0.3; const geometry = new THREE.SphereGeometry(size); // 다양한 색상의 파티클 const colors = [0xff4500, 0xff8c00, 0xff0000, 0xffd700]; const material = new THREE.MeshBasicMaterial({ color: colors[Math.floor(Math.random() * colors.length)], transparent: true, opacity: 1 }); const particle = new THREE.Mesh(geometry, material); particle.position.copy(position); // 파티클 속도와 방향 설정 const speed = Math.random() * 0.5 + 0.3; const angle = Math.random() * Math.PI * 2; const elevation = Math.random() * Math.PI - Math.PI / 2; particle.velocity = new THREE.Vector3( Math.cos(angle) * Math.cos(elevation) * speed, Math.sin(elevation) * speed, Math.sin(angle) * Math.cos(elevation) * speed ); particle.gravity = -0.015; particle.life = Math.random() * 30 + 30; particle.fadeRate = 0.97; scene.add(particle); window.gameInstance.particles.push({ mesh: particle, velocity: particle.velocity, gravity: particle.gravity, life: particle.life, fadeRate: particle.fadeRate }); } // 폭발 링 이펙트 const ringGeometry = new THREE.RingGeometry(0.1, 2, 32); const ringMaterial = new THREE.MeshBasicMaterial({ color: 0xff8c00, transparent: true, opacity: 1, side: THREE.DoubleSide }); const ring = new THREE.Mesh(ringGeometry, ringMaterial); ring.position.copy(position); ring.lookAt(new THREE.Vector3(0, 1, 0)); scene.add(ring); // 링 확장 애니메이션 const expandRing = () => { ring.scale.x += 0.2; ring.scale.y += 0.2; ring.material.opacity *= 0.95; if (ring.material.opacity > 0.01) { requestAnimationFrame(expandRing); } else { scene.remove(ring); } }; expandRing(); // 폭발음 효과 const explosionSound = new Audio('sounds/bang.ogg'); explosionSound.volume = 0.4; explosionSound.play(); // 카메라 흔들림 효과 if (window.gameInstance && window.gameInstance.camera) { const camera = window.gameInstance.camera; const originalPosition = camera.position.clone(); let shakeTime = 0; const shakeIntensity = 0.3; const shakeDuration = 500; const shakeCamera = () => { if (shakeTime < shakeDuration) { camera.position.x = originalPosition.x + (Math.random() - 0.5) * shakeIntensity; camera.position.y = originalPosition.y + (Math.random() - 0.5) * shakeIntensity; camera.position.z = originalPosition.z + (Math.random() - 0.5) * shakeIntensity; shakeTime += 16; requestAnimationFrame(shakeCamera); } else { camera.position.copy(originalPosition); } }; shakeCamera(); } // 중심 플래시 제거 setTimeout(() => { scene.remove(flash); }, 100); } async initialize(scene, loader) { try { const bodyResult = await loader.loadAsync('/models/abramsBody.glb'); this.body = bodyResult.scene; const turretResult = await loader.loadAsync('/models/abramsTurret.glb'); this.turret = turretResult.scene; this.turretGroup.position.y = 0.2; this.turretGroup.add(this.turret); this.body.add(this.turretGroup); // 그림자 설정은 그대로 유지 this.body.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; child.material.shadowSide = THREE.BackSide; child.material.needsUpdate = true; } }); this.turret.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; child.material.shadowSide = THREE.BackSide; child.material.needsUpdate = true; } }); // 그림자 평면 const shadowPlaneGeometry = new THREE.PlaneGeometry(8, 8); const shadowPlaneMaterial = new THREE.ShadowMaterial({ opacity: 0.3 }); this.shadowPlane = new THREE.Mesh(shadowPlaneGeometry, shadowPlaneMaterial); this.shadowPlane.receiveShadow = true; this.shadowPlane.rotation.x = -Math.PI / 2; this.shadowPlane.position.y = 0.1; this.body.add(this.shadowPlane); // 간단한 스폰 위치 설정 const spawnPosition = new THREE.Vector3( (Math.random() - 0.5) * (MAP_SIZE * 0.8), // MAP_SIZE의 80%만 사용 TANK_HEIGHT, // 고정된 높이 사용 (Math.random() - 0.5) * (MAP_SIZE * 0.8) ); this.body.position.copy(spawnPosition); // 폭발 이펙트 메서드 추가 this.createExplosionEffect = (scene, position) => { // 폭발 파티클 for (let i = 0; i < 15; i++) { const size = Math.random() * 0.2 + 0.1; const geometry = new THREE.SphereGeometry(size); const material = new THREE.MeshBasicMaterial({ color: Math.random() < 0.5 ? 0xff4500 : 0xff8c00 }); const particle = new THREE.Mesh(geometry, material); particle.position.copy(position); const speed = Math.random() * 0.3 + 0.2; const angle = Math.random() * Math.PI * 2; const elevation = Math.random() * Math.PI - Math.PI / 2; particle.velocity = new THREE.Vector3( Math.cos(angle) * Math.cos(elevation) * speed, Math.sin(elevation) * speed, Math.sin(angle) * Math.cos(elevation) * speed ); particle.gravity = -0.01; particle.life = Math.random() * 20 + 20; particle.fadeRate = 1 / particle.life; scene.add(particle); window.gameInstance.particles.push({ mesh: particle, velocity: particle.velocity, gravity: particle.gravity, life: particle.life, fadeRate: particle.fadeRate }); } // 충돌 사운드 재생 const explosionSound = new Audio('sounds/explosion.ogg'); explosionSound.volume = 0.3; explosionSound.play(); }; scene.add(this.body); this.isLoaded = true; this.updateAmmoDisplay(); } catch (error) { console.error('Error loading tank models:', error); this.isLoaded = false; } } shoot(scene) { // 재장전 중이거나 탄약이 없으면 발사하지 않음 if (this.isReloading || this.ammo <= 0) { return null; } // 발사 딜레이 체크 (연속 발사 방지) const currentTime = Date.now(); if (currentTime - this.lastShootTime < 100) { // 100ms의 발사 딜레이 return null; } this.lastShootTime = currentTime; // 발사음 효과 (한 번만 재생) const sounds = ['sounds/mbtfire1.ogg', 'sounds/mbtfire2.ogg', 'sounds/mbtfire3.ogg', 'sounds/mbtfire4.ogg']; const randomSound = sounds[Math.floor(Math.random() * sounds.length)]; const audio = new Audio(randomSound); audio.volume = 0.5; // 이전 오디오 인스턴스가 있다면 중지 if (this.lastAudio) { this.lastAudio.pause(); this.lastAudio.currentTime = 0; } this.lastAudio = audio; audio.play(); // 발사 이펙트 this.createMuzzleFlash(scene); // 포탄 생성 const bullet = this.createBullet(scene); if (bullet) { this.ammo--; this.updateAmmoDisplay(); // 탄약을 모두 소진했을 때만 재장전 시작 if (this.ammo <= 0) { this.startReload(); } } return bullet; } startReload() { if (this.isReloading) return; // 이미 재장전 중이면 무시 this.isReloading = true; const reloadingText = document.getElementById('reloadingText'); reloadingText.style.display = 'block'; setTimeout(() => { this.ammo = this.maxAmmo; this.isReloading = false; reloadingText.style.display = 'none'; this.updateAmmoDisplay(); }, this.reloadTime); } createMuzzleFlash(scene) { if (!this.turret) return; const flashGroup = new THREE.Group(); // 화염 크기 증가 const flameGeometry = new THREE.SphereGeometry(1.0, 8, 8); const flameMaterial = new THREE.MeshBasicMaterial({ color: 0xffa500, transparent: true, opacity: 0.8 }); const flame = new THREE.Mesh(flameGeometry, flameMaterial); flame.scale.set(2, 2, 3); flashGroup.add(flame); // 연기 효과 크기 증가 const smokeGeometry = new THREE.SphereGeometry(0.8, 8, 8); const smokeMaterial = new THREE.MeshBasicMaterial({ color: 0x555555, transparent: true, opacity: 0.5 }); for (let i = 0; i < 5; i++) { // 연기 파티클 수 증가 const smoke = new THREE.Mesh(smokeGeometry, smokeMaterial); smoke.position.set( Math.random() * 1 - 0.5, Math.random() * 1 - 0.5, -1 - Math.random() ); smoke.scale.set(1.5, 1.5, 1.5); flashGroup.add(smoke); } // 포구 위치 계산 const muzzleOffset = new THREE.Vector3(0, 0.5, 4); const muzzlePosition = new THREE.Vector3(); const turretWorldQuaternion = new THREE.Quaternion(); this.turret.getWorldPosition(muzzlePosition); this.turret.getWorldQuaternion(turretWorldQuaternion); muzzleOffset.applyQuaternion(turretWorldQuaternion); muzzlePosition.add(muzzleOffset); flashGroup.position.copy(muzzlePosition); flashGroup.quaternion.copy(turretWorldQuaternion); scene.add(flashGroup); // 이펙트 지속 시간 증가 setTimeout(() => { scene.remove(flashGroup); }, 500); } createBullet(scene) { if (!this.turret) return null; // 터렛이 없으면 null 반환 // 포탄 크기 증가 const bulletGeometry = new THREE.CylinderGeometry(0.2, 0.2, 2, 8); const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xffd700 }); const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); // 포탑의 월드 위치와 방향 가져오기 const muzzleOffset = new THREE.Vector3(0, 0.5, 4); // 포구 위치 조정 (앞쪽으로 더 이동) const muzzlePosition = new THREE.Vector3(); const turretWorldQuaternion = new THREE.Quaternion(); // 포탑의 월드 변환 행렬 가져오기 this.turret.getWorldPosition(muzzlePosition); this.turret.getWorldQuaternion(turretWorldQuaternion); // 포구 오프셋 적용 muzzleOffset.applyQuaternion(turretWorldQuaternion); muzzlePosition.add(muzzleOffset); // 포탄 위치와 회전 설정 bullet.position.copy(muzzlePosition); bullet.quaternion.copy(turretWorldQuaternion); // 발사 방향 설정 const direction = new THREE.Vector3(0, 0, 1); direction.applyQuaternion(turretWorldQuaternion); bullet.velocity = direction.multiplyScalar(5); scene.add(bullet); this.bullets.push(bullet); return bullet; } update(mouseX, mouseY, scene) { if (!this.body || !this.turretGroup) return; const absoluteTurretRotation = mouseX; this.turretGroup.rotation.y = absoluteTurretRotation - this.body.rotation.y; this.turretRotation = absoluteTurretRotation; // 총알 업데이트 및 충돌 체크 for (let i = this.bullets.length - 1; i >= 0; i--) { const bullet = this.bullets[i]; const oldPosition = bullet.position.clone(); bullet.position.add(bullet.velocity); // 지형 높이 체크 const terrainHeight = window.gameInstance.getHeightAtPosition( bullet.position.x, bullet.position.z ); if (bullet.position.y < terrainHeight || Math.abs(bullet.position.x) > MAP_SIZE / 2 || Math.abs(bullet.position.z) > MAP_SIZE / 2) { // 폭발 이펙트 생성 this.createExplosionEffect(scene, bullet.position); scene.remove(bullet); this.bullets.splice(i, 1); } } } move(direction) { if (!this.body) return; const moveVector = new THREE.Vector3(); moveVector.x = direction.x * this.moveSpeed; moveVector.z = direction.z * this.moveSpeed; // 새로운 위치 계산 const newPosition = this.body.position.clone().add(moveVector); // 새로운 위치의 지형 높이 가져오기 const heightAtNewPos = window.gameInstance.getHeightAtPosition(newPosition.x, newPosition.z); // 탱크 높이만큼 더해서 지형 위에 위치시킴 newPosition.y = heightAtNewPos + TANK_HEIGHT; // 경사가 너무 가파른지 체크 const currentHeight = this.body.position.y; const heightDifference = Math.abs(newPosition.y - currentHeight); const maxClimbAngle = 0.5; // 최대 등반 각도 if (heightDifference / this.moveSpeed < maxClimbAngle) { this.body.position.copy(newPosition); // 탱크의 방향 벡터 계산 const forwardVector = new THREE.Vector3(0, 0, 1).applyQuaternion(this.body.quaternion); const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(this.body.quaternion); // 탱크 주변의 높이 계산 const frontHeight = window.gameInstance.getHeightAtPosition( newPosition.x + forwardVector.x, newPosition.z + forwardVector.z ); const backHeight = window.gameInstance.getHeightAtPosition( newPosition.x - forwardVector.x, newPosition.z - forwardVector.z ); const rightHeight = window.gameInstance.getHeightAtPosition( newPosition.x + rightVector.x, newPosition.z + rightVector.z ); const leftHeight = window.gameInstance.getHeightAtPosition( newPosition.x - rightVector.x, newPosition.z - rightVector.z ); // 탱크의 기울기 계산 및 적용 const pitch = Math.atan2(frontHeight - backHeight, 2); const roll = Math.atan2(rightHeight - leftHeight, 2); // 기존 y축 회전은 유지하면서 새로운 기울기 적용 const currentYRotation = this.body.rotation.y; this.body.rotation.set(pitch, currentYRotation, roll); } } rotate(angle) { if (!this.body) return; // y축 회전 적용 this.body.rotation.y += angle * this.turnSpeed; // 현재 위치에서의 지형에 맞춰 탱크 기울기 조정 const position = this.body.position; const forwardVector = new THREE.Vector3(0, 0, 1).applyQuaternion(this.body.quaternion); const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(this.body.quaternion); // 탱크 주변의 높이 계산 const frontHeight = window.gameInstance.getHeightAtPosition( position.x + forwardVector.x, position.z + forwardVector.z ); const backHeight = window.gameInstance.getHeightAtPosition( position.x - forwardVector.x, position.z - forwardVector.z ); const rightHeight = window.gameInstance.getHeightAtPosition( position.x + rightVector.x, position.z + rightVector.z ); const leftHeight = window.gameInstance.getHeightAtPosition( position.x - rightVector.x, position.z - rightVector.z ); // 탱크의 기울기 계산 및 적용 const pitch = Math.atan2(frontHeight - backHeight, 2); const roll = Math.atan2(rightHeight - leftHeight, 2); // 현재 y축 회전은 유지하면서 새로운 기울기 적용 const currentYRotation = this.body.rotation.y; this.body.rotation.set(pitch, currentYRotation, roll); } getPosition() { return this.body ? this.body.position : new THREE.Vector3(); } takeDamage(damage) { this.health -= damage; // 사망 여부 체크 if (this.health <= 0) { const deathSound = new Audio('sounds/bang.ogg'); deathSound.play(); return true; // 사망 } return false; // 생존 } startReload() { this.isReloading = true; const reloadingText = document.getElementById('reloadingText'); reloadingText.style.display = 'block'; setTimeout(() => { this.ammo = this.maxAmmo; this.isReloading = false; reloadingText.style.display = 'none'; this.updateAmmoDisplay(); }, this.reloadTime); } updateAmmoDisplay() { document.getElementById('ammoDisplay').textContent = `APFSDS: ${this.ammo}/${this.maxAmmo}`; } } // Enemy 클래스 class Enemy { constructor(scene, position, type = 'tank') { // 기본 속성 this.scene = scene; this.position = position; this.mesh = null; this.type = type; this.health = type === 'tank' ? 100 : 200; this.lastAttackTime = 0; this.bullets = []; this.isLoaded = false; this.alternativePath = null; this.pathFindingTimeout = 0; this.lastPathUpdateTime = 0; this.pathUpdateInterval = 3000; // 3초마다 경로 업데이트 this.moveSpeed = type === 'tank' ? ENEMY_MOVE_SPEED : ENEMY_MOVE_SPEED * 0.7; // AI 상태 관리 this.aiState = { mode: 'pursue', lastStateChange: 0, stateChangeCooldown: 3000, lastVisibilityCheck: 0, visibilityCheckInterval: 500, canSeePlayer: false, lastKnownPlayerPosition: null, searchStartTime: null, targetRotation: 0, currentRotation: 0, isAiming: false, aimingTime: 0, requiredAimTime: 1000 // 조준에 필요한 시간 }; // 경로 탐색 및 회피 시스템 this.pathfinding = { currentPath: [], pathUpdateInterval: 1000, lastPathUpdate: 0, isAvoidingObstacle: false, avoidanceDirection: null, obstacleCheckDistance: 10, avoidanceTime: 0, maxAvoidanceTime: 3000, // 최대 회피 시간 sensorAngles: [-45, 0, 45], // 전방 감지 각도 sensorDistance: 15 // 감지 거리 }; // 전투 시스템 this.combat = { minEngagementRange: 30, maxEngagementRange: 150, optimalRange: 80, aimThreshold: 0.1, // 조준 정확도 임계값 lastShotAccuracy: 0, consecutiveHits: 0, maxConsecutiveHits: 3 }; } // 장애물 감지 시스템 detectObstacles() { const obstacles = []; const position = this.mesh.position.clone(); position.y += 1; // 센서 높이 조정 this.pathfinding.sensorAngles.forEach(angle => { const direction = new THREE.Vector3(0, 0, 1) .applyQuaternion(this.mesh.quaternion) .applyAxisAngle(new THREE.Vector3(0, 1, 0), angle * Math.PI / 180); const raycaster = new THREE.Raycaster(position, direction, 0, this.pathfinding.sensorDistance); const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); if (intersects.length > 0) { obstacles.push({ angle: angle, distance: intersects[0].distance, point: intersects[0].point }); } }); return obstacles; } // 회피 방향 계산 calculateAvoidanceDirection(obstacles) { if (obstacles.length === 0) return null; // 모든 장애물의 방향을 고려하여 최적의 회피 방향 계산 const avoidanceVector = new THREE.Vector3(); obstacles.forEach(obstacle => { const avoidDir = new THREE.Vector3() .subVectors(this.mesh.position, obstacle.point) .normalize() .multiplyScalar(1 / obstacle.distance); // 거리에 반비례하는 가중치 avoidanceVector.add(avoidDir); }); return avoidanceVector.normalize(); } // 조준 시스템 updateAiming(playerPosition) { const targetDirection = new THREE.Vector3() .subVectors(playerPosition, this.mesh.position) .normalize(); // 목표 회전각 계산 this.aiState.targetRotation = Math.atan2(targetDirection.x, targetDirection.z); // 현재 회전각 부드럽게 조정 - 선회 속도를 느리게 하기 위해 조정 const rotationDiff = this.aiState.targetRotation - this.aiState.currentRotation; let rotationStep = Math.sign(rotationDiff) * Math.min(Math.abs(rotationDiff), 0.05); // 기존 0.02에서 0.05로 수정 this.aiState.currentRotation += rotationStep; // 메시 회전 적용 this.mesh.rotation.y = this.aiState.currentRotation; // 조준 정확도 계산 const aimAccuracy = 1 - Math.abs(rotationDiff) / Math.PI; return aimAccuracy > this.combat.aimThreshold; } // 전투 거리 관리 maintainCombatDistance(playerPosition) { const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); let moveDirection = new THREE.Vector3(); if (distanceToPlayer < this.combat.minEngagementRange) { // 너무 가까우면 후진 moveDirection.subVectors(this.mesh.position, playerPosition).normalize(); } else if (distanceToPlayer > this.combat.maxEngagementRange) { // 너무 멀면 전진 moveDirection.subVectors(playerPosition, this.mesh.position).normalize(); } else if (Math.abs(distanceToPlayer - this.combat.optimalRange) > 10) { // 최적 거리로 조정 const targetDistance = this.combat.optimalRange; moveDirection.subVectors(playerPosition, this.mesh.position).normalize(); if (distanceToPlayer > targetDistance) { moveDirection.multiplyScalar(1); } else { moveDirection.multiplyScalar(-1); } } return moveDirection; } // 발사 조건 확인 canShoot(playerPosition) { const distance = this.mesh.position.distanceTo(playerPosition); const hasLineOfSight = this.checkLineOfSight(playerPosition); const isAimed = this.updateAiming(playerPosition); return distance <= this.combat.maxEngagementRange && distance >= this.combat.minEngagementRange && hasLineOfSight && isAimed; } // 메인 업데이트 함수 update(playerPosition) { if (!this.mesh || !this.isLoaded) return; // AI 상태 업데이트 this.updateAIState(playerPosition); // 장애물 감지 및 시야 체크 const obstacles = this.detectObstacles(); const currentTime = Date.now(); const hasLineOfSight = this.checkLineOfSight(playerPosition); const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); // 경로 업데이트 주기 체크 if (currentTime - this.lastPathUpdateTime > this.pathUpdateInterval) { if (!hasLineOfSight) { this.alternativePath = this.findAlternativePath(playerPosition); } this.lastPathUpdateTime = currentTime; } // 장애물 회피 로직 if (obstacles.length > 0 && !this.pathfinding.isAvoidingObstacle) { this.pathfinding.isAvoidingObstacle = true; this.pathfinding.avoidanceDirection = this.calculateAvoidanceDirection(obstacles); this.pathfinding.avoidanceTime = 0; } // 이동 로직 실행 if (this.pathfinding.isAvoidingObstacle) { // 회피 동작 this.pathfinding.avoidanceTime += 16; if (this.pathfinding.avoidanceTime >= this.pathfinding.maxAvoidanceTime) { this.pathfinding.isAvoidingObstacle = false; } else { const avoidMove = this.pathfinding.avoidanceDirection.multiplyScalar(this.moveSpeed); this.mesh.position.add(avoidMove); } } else if (!hasLineOfSight) { // 시야가 없을 때의 이동 if (this.alternativePath) { const pathDirection = new THREE.Vector3() .subVectors(this.alternativePath, this.mesh.position) .normalize(); this.mesh.position.add(pathDirection.multiplyScalar(this.moveSpeed)); const targetRotation = Math.atan2(pathDirection.x, pathDirection.z); this.mesh.rotation.y = this.smoothRotation(this.mesh.rotation.y, targetRotation, 0.1); } } else { // 시야가 있을 때의 이동 this.alternativePath = null; // AI 상태에 따른 이동 switch (this.aiState.mode) { case 'pursue': if (distanceToPlayer > ENEMY_CONFIG.ATTACK_RANGE * 0.7) { const moveDirection = new THREE.Vector3() .subVectors(playerPosition, this.mesh.position) .normalize(); this.mesh.position.add(moveDirection.multiplyScalar(this.moveSpeed)); } break; case 'flank': const flankPosition = this.calculateFlankPosition(playerPosition); this.findPathToTarget(flankPosition); this.moveAlongPath(); break; case 'retreat': if (distanceToPlayer < ENEMY_CONFIG.ATTACK_RANGE * 0.3) { const retreatDirection = new THREE.Vector3() .subVectors(this.mesh.position, playerPosition) .normalize(); this.mesh.position.add(retreatDirection.multiplyScalar(this.moveSpeed)); } break; } // 플레이어 방향으로 회전 const directionToPlayer = new THREE.Vector3() .subVectors(playerPosition, this.mesh.position) .normalize(); const targetRotation = Math.atan2(directionToPlayer.x, directionToPlayer.z); this.mesh.rotation.y = this.smoothRotation(this.mesh.rotation.y, targetRotation, 0.1); } // 전투 거리 조정 const combatMove = this.maintainCombatDistance(playerPosition); if (combatMove.length() > 0) { this.mesh.position.add(combatMove.multiplyScalar(this.moveSpeed)); } // 공격 처리 if (hasLineOfSight && distanceToPlayer <= ENEMY_CONFIG.ATTACK_RANGE && this.canShoot(playerPosition)) { this.shoot(playerPosition); } // 총알 업데이트 this.updateBullets(); // 탱크 기울기 조정 this.adjustTankTilt(); } checkLineOfSight(targetPosition) { if (!this.mesh) return false; const startPos = this.mesh.position.clone(); startPos.y += 2; // 포탑 높이 const direction = new THREE.Vector3() .subVectors(targetPosition, startPos) .normalize(); const distance = startPos.distanceTo(targetPosition); const raycaster = new THREE.Raycaster(startPos, direction, 0, distance); const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); return intersects.length === 0; } findAlternativePath(playerPosition) { const currentPos = this.mesh.position.clone(); const directionToPlayer = new THREE.Vector3() .subVectors(playerPosition, currentPos) .normalize(); // 좌우 90도 방향 계산 const leftDirection = new THREE.Vector3() .copy(directionToPlayer) .applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2); const rightDirection = new THREE.Vector3() .copy(directionToPlayer) .applyAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 2); // 좌우 30미터 지점 확인 const checkDistance = 30; const leftPoint = currentPos.clone().add(leftDirection.multiplyScalar(checkDistance)); const rightPoint = currentPos.clone().add(rightDirection.multiplyScalar(checkDistance)); // 각 방향의 장애물 체크 const leftClear = this.checkPathClear(currentPos, leftPoint); const rightClear = this.checkPathClear(currentPos, rightPoint); if (leftClear && rightClear) { // 둘 다 가능하면 랜덤 선택 return Math.random() < 0.5 ? leftPoint : rightPoint; } else if (leftClear) { return leftPoint; } else if (rightClear) { return rightPoint; } return null; } checkPathClear(start, end) { const direction = new THREE.Vector3().subVectors(end, start).normalize(); const distance = start.distanceTo(end); const raycaster = new THREE.Raycaster(start, direction, 0, distance); const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); return intersects.length === 0; } async initialize(loader) { try { const modelPath = this.type === 'tank' ? '/models/t90.glb' : '/models/t90.glb'; const result = await loader.loadAsync(modelPath); this.mesh = result.scene; this.mesh.position.copy(this.position); this.mesh.scale.set(ENEMY_SCALE, ENEMY_SCALE, ENEMY_SCALE); this.mesh.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); this.scene.add(this.mesh); this.isLoaded = true; } catch (error) { console.error('Error loading enemy model:', error); this.isLoaded = false; } } // 시야 확인 메서드 (기존 코드 수정) checkLineOfSight(playerPosition) { if (!this.mesh) return false; const startPos = this.mesh.position.clone(); startPos.y += 2; // 포탑 높이 const direction = new THREE.Vector3() .subVectors(playerPosition, startPos) .normalize(); const distance = startPos.distanceTo(playerPosition); const raycaster = new THREE.Raycaster(startPos, direction, 0, distance); const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); // 장애물과의 충돌이 있는지 확인 return intersects.length === 0; } // 대체 경로 찾기 메서드 findAlternativePath(playerPosition) { const currentPos = this.mesh.position.clone(); const directionToPlayer = new THREE.Vector3() .subVectors(playerPosition, currentPos) .normalize(); // 좌우 90도 방향 계산 const leftDirection = new THREE.Vector3() .copy(directionToPlayer) .applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2); const rightDirection = new THREE.Vector3() .copy(directionToPlayer) .applyAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 2); // 좌우 30미터 지점 확인 const checkDistance = 30; const leftPoint = currentPos.clone().add(leftDirection.multiplyScalar(checkDistance)); const rightPoint = currentPos.clone().add(rightDirection.multiplyScalar(checkDistance)); // 각 방향의 장애물 체크 const leftClear = this.checkPathClear(currentPos, leftPoint); const rightClear = this.checkPathClear(currentPos, rightPoint); if (leftClear && rightClear) { // 둘 다 가능하면 랜덤 선택 return Math.random() < 0.5 ? leftPoint : rightPoint; } else if (leftClear) { return leftPoint; } else if (rightClear) { return rightPoint; } return null; } // 경로 유효성 확인 checkPathClear(start, end) { const direction = new THREE.Vector3().subVectors(end, start).normalize(); const distance = start.distanceTo(end); const raycaster = new THREE.Raycaster(start, direction, 0, distance); const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); return intersects.length === 0; } // 부드러운 회전 처리 smoothRotation(current, target, factor) { let delta = target - current; // 각도 차이를 -PI에서 PI 사이로 정규화 while (delta > Math.PI) delta -= Math.PI * 2; while (delta < -Math.PI) delta += Math.PI * 2; return current + delta * factor; } updateAIState(playerPosition) { const currentTime = Date.now(); const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); if (currentTime - this.aiState.lastVisibilityCheck > this.aiState.visibilityCheckInterval) { this.aiState.canSeePlayer = this.checkLineOfSight(playerPosition); this.aiState.lastVisibilityCheck = currentTime; if (this.aiState.canSeePlayer) { this.aiState.lastKnownPlayerPosition = playerPosition.clone(); this.aiState.searchStartTime = null; } } // 상태 변경 쿨다운을 2초로 설정 const stateChangeCooldown = 2000; if (currentTime - this.aiState.lastStateChange > this.aiState.stateChangeCooldown) { if (this.health < 30) { this.aiState.mode = 'retreat'; } else if (distanceToPlayer < 30 && this.aiState.canSeePlayer) { this.aiState.mode = 'flank'; } else { this.aiState.mode = 'pursue'; } this.aiState.lastStateChange = currentTime; } } findPathToTarget(targetPosition) { const currentTime = Date.now(); if (currentTime - this.pathfinding.lastPathUpdate < this.pathfinding.pathUpdateInterval) { return; } this.pathfinding.currentPath = this.generatePathPoints(this.mesh.position.clone(), targetPosition); this.pathfinding.lastPathUpdate = currentTime; } generatePathPoints(start, end) { const points = []; const direction = new THREE.Vector3().subVectors(end, start).normalize(); const distance = start.distanceTo(end); const steps = Math.ceil(distance / 10); for (let i = 0; i <= steps; i++) { const point = start.clone().add(direction.multiplyScalar(i * 10)); points.push(point); } return points; } moveAlongPath() { if (this.pathfinding.currentPath.length === 0) return; const targetPoint = this.pathfinding.currentPath[0]; const direction = new THREE.Vector3() .subVectors(targetPoint, this.mesh.position) .normalize(); const moveVector = direction.multiplyScalar(this.moveSpeed); this.mesh.position.add(moveVector); if (this.mesh.position.distanceTo(targetPoint) < 2) { this.pathfinding.currentPath.shift(); } } calculateFlankPosition(playerPosition) { const angle = Math.random() * Math.PI * 2; const radius = 40; return new THREE.Vector3( playerPosition.x + Math.cos(angle) * radius, playerPosition.y, playerPosition.z + Math.sin(angle) * radius ); } calculateRetreatPosition(playerPosition) { const direction = new THREE.Vector3() .subVectors(this.mesh.position, playerPosition) .normalize(); return this.mesh.position.clone().add(direction.multiplyScalar(50)); } adjustTankTilt() { const forwardVector = new THREE.Vector3(0, 0, 1).applyQuaternion(this.mesh.quaternion); const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(this.mesh.quaternion); const frontHeight = window.gameInstance.getHeightAtPosition( this.mesh.position.x + forwardVector.x, this.mesh.position.z + forwardVector.z ); const backHeight = window.gameInstance.getHeightAtPosition( this.mesh.position.x - forwardVector.x, this.mesh.position.z - forwardVector.z ); const rightHeight = window.gameInstance.getHeightAtPosition( this.mesh.position.x + rightVector.x, this.mesh.position.z + rightVector.z ); const leftHeight = window.gameInstance.getHeightAtPosition( this.mesh.position.x - rightVector.x, this.mesh.position.z - rightVector.z ); const pitch = Math.atan2(frontHeight - backHeight, 2); const roll = Math.atan2(rightHeight - leftHeight, 2); const currentRotation = this.mesh.rotation.y; this.mesh.rotation.set(pitch, currentRotation, roll); } updateBullets() { for (let i = this.bullets.length - 1; i >= 0; i--) { const bullet = this.bullets[i]; bullet.position.add(bullet.velocity); if (Math.abs(bullet.position.x) > MAP_SIZE / 2 || Math.abs(bullet.position.z) > MAP_SIZE / 2) { this.scene.remove(bullet); this.bullets.splice(i, 1); continue; } const bulletBox = new THREE.Box3().setFromObject(bullet); for (const obstacle of window.gameInstance.obstacles) { const obstacleBox = new THREE.Box3().setFromObject(obstacle); if (bulletBox.intersectsBox(obstacleBox)) { this.scene.remove(bullet); this.bullets.splice(i, 1); break; } } } } createMuzzleFlash() { if (!this.mesh) return; const flashGroup = new THREE.Group(); const flameGeometry = new THREE.SphereGeometry(1.0, 8, 8); const flameMaterial = new THREE.MeshBasicMaterial({ color: 0xffa500, transparent: true, opacity: 0.8 }); const flame = new THREE.Mesh(flameGeometry, flameMaterial); flame.scale.set(2, 2, 3); flashGroup.add(flame); const smokeGeometry = new THREE.SphereGeometry(0.8, 8, 8); const smokeMaterial = new THREE.MeshBasicMaterial({ color: 0x555555, transparent: true, opacity: 0.5 }); for (let i = 0; i < 5; i++) { const smoke = new THREE.Mesh(smokeGeometry, smokeMaterial); smoke.position.set( Math.random() * 1 - 0.5, Math.random() * 1 - 0.5, -1 - Math.random() ); smoke.scale.set(1.5, 1.5, 1.5); flashGroup.add(smoke); } const muzzleOffset = new THREE.Vector3(0, 0.5, 4); const muzzlePosition = new THREE.Vector3(); const meshWorldQuaternion = new THREE.Quaternion(); this.mesh.getWorldPosition(muzzlePosition); this.mesh.getWorldQuaternion(meshWorldQuaternion); muzzleOffset.applyQuaternion(meshWorldQuaternion); muzzlePosition.add(muzzleOffset); flashGroup.position.copy(muzzlePosition); flashGroup.quaternion.copy(meshWorldQuaternion); this.scene.add(flashGroup); setTimeout(() => { this.scene.remove(flashGroup); }, 500); } createMassiveExplosion() { // 중심 폭발 const explosionGroup = new THREE.Group(); // 주 폭발 플래시 (크기 감소) const flashGeometry = new THREE.SphereGeometry(4); // 8에서 4로 감소 const flashMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent: true, opacity: 1 }); const flash = new THREE.Mesh(flashGeometry, flashMaterial); flash.position.copy(this.mesh.position); this.scene.add(flash); // 다중 폭발 파티클 (수와 크기 감소) for (let i = 0; i < 50; i++) { // 100에서 50으로 감소 const size = Math.random() * 1 + 0.5; // 크기 범위 감소 const geometry = new THREE.SphereGeometry(size); // 다양한 폭발 색상 const colors = [0xff4500, 0xff8c00, 0xff0000, 0xffd700]; const material = new THREE.MeshBasicMaterial({ color: colors[Math.floor(Math.random() * colors.length)], transparent: true, opacity: 1 }); const particle = new THREE.Mesh(geometry, material); particle.position.copy(this.mesh.position); // 파티클 속도 감소 const speed = Math.random() * 1 + 0.5; // 속도 범위 감소 const angle = Math.random() * Math.PI * 2; const elevation = Math.random() * Math.PI - Math.PI / 2; particle.velocity = new THREE.Vector3( Math.cos(angle) * Math.cos(elevation) * speed, Math.sin(elevation) * speed, Math.sin(angle) * Math.cos(elevation) * speed ); particle.gravity = -0.05; // 중력 효과 감소 particle.life = Math.random() * 30 + 30; // 수명 감소 particle.fadeRate = 0.98; this.scene.add(particle); window.gameInstance.particles.push({ mesh: particle, velocity: particle.velocity, gravity: particle.gravity, life: particle.life, fadeRate: particle.fadeRate }); } // 폭발 링 이펙트 (크기와 확장 속도 감소) for (let i = 0; i < 2; i++) { // 3에서 2로 감소 const ringGeometry = new THREE.RingGeometry(0.1, 2, 32); const ringMaterial = new THREE.MeshBasicMaterial({ color: 0xff8c00, transparent: true, opacity: 1, side: THREE.DoubleSide }); const ring = new THREE.Mesh(ringGeometry, ringMaterial); ring.position.copy(this.mesh.position); ring.rotation.x = Math.random() * Math.PI; ring.rotation.y = Math.random() * Math.PI; this.scene.add(ring); // 링 확장 애니메이션 (확장 속도 감소) const expandRing = () => { ring.scale.x += 0.15; // 0.3에서 0.15로 감소 ring.scale.y += 0.15; ring.material.opacity *= 0.96; if (ring.material.opacity > 0.01) { requestAnimationFrame(expandRing); } else { this.scene.remove(ring); } }; expandRing(); } // 화염 기둥 효과 (크기와 수 감소) const fireColumn = new THREE.Group(); for (let i = 0; i < 10; i++) { // 20에서 10으로 감소 const fireGeometry = new THREE.ConeGeometry(1, 4, 8); // 크기 감소 const fireMaterial = new THREE.MeshBasicMaterial({ color: 0xff4500, transparent: true, opacity: 0.8 }); const fire = new THREE.Mesh(fireGeometry, fireMaterial); fire.position.y = i * 0.3; // 0.5에서 0.3으로 감소 fire.rotation.x = Math.random() * Math.PI; fire.rotation.z = Math.random() * Math.PI; fireColumn.add(fire); } fireColumn.position.copy(this.mesh.position); this.scene.add(fireColumn); // 화염 기둥 애니메이션 (확장 속도 감소) const animateFireColumn = () => { fireColumn.scale.y += 0.05; // 0.1에서 0.05로 감소 fireColumn.children.forEach(fire => { fire.material.opacity *= 0.95; }); if (fireColumn.children[0].material.opacity > 0.01) { requestAnimationFrame(animateFireColumn); } else { this.scene.remove(fireColumn); } }; animateFireColumn(); // 폭발 사운드 효과 const explosionSounds = [ new Audio('sounds/explosion.ogg'), new Audio('sounds/bang.ogg') ]; explosionSounds.forEach(sound => { sound.volume = 0.5; sound.play(); }); // 카메라 흔들림 효과 (강도 감소) if (window.gameInstance && window.gameInstance.camera) { const camera = window.gameInstance.camera; const originalPosition = camera.position.clone(); let shakeTime = 0; const shakeIntensity = 0.5; // 1.0에서 0.5로 감소 const shakeDuration = 500; // 1000에서 500으로 감소 const shakeCamera = () => { if (shakeTime < shakeDuration) { camera.position.x = originalPosition.x + (Math.random() - 0.5) * shakeIntensity; camera.position.y = originalPosition.y + (Math.random() - 0.5) * shakeIntensity; camera.position.z = originalPosition.z + (Math.random() - 0.5) * shakeIntensity; shakeTime += 16; requestAnimationFrame(shakeCamera); } else { camera.position.copy(originalPosition); } }; shakeCamera(); } // 중심 플래시 제거 setTimeout(() => { this.scene.remove(flash); }, 100); // 200에서 100으로 감소 } shoot(playerPosition) { const currentTime = Date.now(); const attackInterval = this.type === 'tank' ? ENEMY_CONFIG.ATTACK_INTERVAL : ENEMY_CONFIG.ATTACK_INTERVAL * 1.5; if (currentTime - this.lastAttackTime < attackInterval) return; // 플레이어와의 방향 차이 계산 const directionToPlayer = new THREE.Vector3() .subVectors(playerPosition, this.mesh.position) .normalize(); const forwardDirection = new THREE.Vector3(0, 0, 1) .applyQuaternion(this.mesh.quaternion) .normalize(); const dotProduct = forwardDirection.dot(directionToPlayer); const angleToPlayer = Math.acos(dotProduct); // 일정 각도 이하일 경우에만 공격 const attackAngleThreshold = Math.PI / 8; // 약 22.5도 if (angleToPlayer > attackAngleThreshold) return; this.createMuzzleFlash(); const enemyFireSound = new Audio('sounds/mbtfire5.ogg'); enemyFireSound.volume = 0.3; enemyFireSound.play(); const bulletGeometry = new THREE.CylinderGeometry(0.2, 0.2, 2, 8); 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.5, 4); const muzzlePosition = new THREE.Vector3(); this.mesh.getWorldPosition(muzzlePosition); muzzleOffset.applyQuaternion(this.mesh.quaternion); muzzlePosition.add(muzzleOffset); bullet.position.copy(muzzlePosition); bullet.quaternion.copy(this.mesh.quaternion); const direction = new THREE.Vector3() .subVectors(playerPosition, muzzlePosition) .normalize(); const bulletSpeed = this.type === 'tank' ? ENEMY_CONFIG.BULLET_SPEED : ENEMY_CONFIG.BULLET_SPEED * 0.8; bullet.velocity = direction.multiplyScalar(bulletSpeed); const trailGeometry = new THREE.CylinderGeometry(0.1, 0.1, 1, 8); const trailMaterial = new THREE.MeshBasicMaterial({ color: 0xff4444, transparent: true, opacity: 0.5 }); const trail = new THREE.Mesh(trailGeometry, trailMaterial); trail.position.z = -1; bullet.add(trail); this.scene.add(bullet); this.bullets.push(bullet); this.lastAttackTime = currentTime; } takeDamage(damage) { this.health -= damage; // 사망 여부 체크 if (this.health <= 0) { const deathSound = new Audio('sounds/bang.ogg'); deathSound.play(); return true; // 사망 } return false; // 생존 } destroy() { if (this.mesh) { this.scene.remove(this.mesh); this.bullets.forEach(bullet => this.scene.remove(bullet)); this.bullets = []; this.isLoaded = false; } } } // Particle 클래스 class Particle { constructor(scene, position) { const geometry = new THREE.SphereGeometry(0.1); const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }); this.mesh = new THREE.Mesh(geometry, material); this.mesh.position.copy(position); this.velocity = new THREE.Vector3( (Math.random() - 0.5) * 0.3, Math.random() * 0.2, (Math.random() - 0.5) * 0.3 ); this.gravity = -0.01; this.lifetime = 60; this.age = 0; scene.add(this.mesh); } update() { this.velocity.y += this.gravity; this.mesh.position.add(this.velocity); this.age++; return this.age < this.lifetime; } destroy(scene) { scene.remove(this.mesh); } } // Game 클래스 class Game { constructor() { // 게임 시작 여부를 추적하는 플래그 추가 this.isStarted = false; // 오디오 관련 속성 추가 this.bgmPlaying = false; // BGM 재생 상태 추적 this.bgm = null; // BGM 오디오 객체 저장 this.engineSound = null; this.engineStopSound = null; this.isEngineRunning = false; this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 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.outputColorSpace = THREE.SRGBColorSpace; this.enemyLabels = new Map(); // 적 라벨을 추적하기 위한 Map 추가 this.raycaster = new THREE.Raycaster(); this.crosshair = document.getElementById('crosshair'); document.getElementById('gameContainer').appendChild(this.renderer.domElement); // 레이더 관련 속성 추가 this.radarUpdateInterval = 100; // 100ms마다 레이더 업데이트 this.lastRadarUpdate = 0; this.radarRange = 200; // 레이더 감지 범위 this.tank = new TankPlayer(); this.enemies = []; this.particles = []; this.buildings = []; this.loader = new GLTFLoader(); this.controls = null; this.gameTime = GAME_DURATION; this.score = 0; this.isGameOver = false; this.isLoading = true; this.previousTankPosition = new THREE.Vector3(); this.lastTime = performance.now(); this.gameTimer = null; this.animationFrameId = null; this.lastAudio = null; // 마지막 발사음 추적을 위한 속성 추가 this.mouse = { x: 0, y: 0 }; this.keys = { forward: false, backward: false, left: false, right: false }; this.setupEventListeners(); this.initialize(); this.obstacles = []; // obstacles 배열 추가 } setupScene() { // 씬 초기화 this.scene.background = new THREE.Color(0x87CEEB); // 하늘색 배경 // 안개 설정 this.scene.fog = new THREE.FogExp2(0x87CEEB, 0.0008); // 렌더러 설정 this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.setPixelRatio(window.devicePixelRatio); // 카메라 초기 설정 this.camera.position.set(0, 15, -30); this.camera.lookAt(0, 0, 0); // 씬 경계 설정 const mapBoundary = MAP_SIZE / 2; this.sceneBounds = { minX: -mapBoundary, maxX: mapBoundary, minZ: -mapBoundary, maxZ: mapBoundary }; } async initialize() { try { // BGM이 아직 재생되지 않은 경우에만 재생 if (!this.bgmPlaying && !this.bgm) { this.bgm = new Audio('sounds/BGM.ogg'); this.bgm.volume = 0.5; this.bgm.loop = true; this.bgm.play(); this.bgmPlaying = true; } // 시작 사운드 재생 const startSounds = ['sounds/start1.ogg', 'sounds/start2.ogg', 'sounds/start3.ogg']; const randomStartSound = startSounds[Math.floor(Math.random() * startSounds.length)]; const startAudio = new Audio(randomStartSound); startAudio.volume = 0.5; startAudio.play(); // 렌더러 설정 this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.outputColorSpace = THREE.SRGBColorSpace; // 기본 씬 설정 this.setupScene(); // 안개 효과 this.scene.fog = new THREE.FogExp2(0x87CEEB, 0.0008); this.scene.background = new THREE.Color(0x87CEEB); // 조명 설정 // 주변광 const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); this.scene.add(ambientLight); // 메인 태양광 const mainLight = new THREE.DirectionalLight(0xffffff, 1.0); mainLight.position.set(MAP_SIZE/2, MAP_SIZE/2, MAP_SIZE/2); mainLight.castShadow = true; // 그림자 설정 mainLight.shadow.mapSize.width = 4096; mainLight.shadow.mapSize.height = 4096; mainLight.shadow.camera.near = 0.5; mainLight.shadow.camera.far = MAP_SIZE * 2; mainLight.shadow.camera.left = -MAP_SIZE; mainLight.shadow.camera.right = MAP_SIZE; mainLight.shadow.camera.top = MAP_SIZE; mainLight.shadow.camera.bottom = -MAP_SIZE; mainLight.shadow.bias = -0.001; mainLight.shadow.radius = 2; mainLight.shadow.normalBias = 0.02; this.scene.add(mainLight); // 보조 태양광 const secondaryLight = new THREE.DirectionalLight(0xffffff, 0.3); secondaryLight.position.set(-50, 50, -50); this.scene.add(secondaryLight); // 환경광 const hemisphereLight = new THREE.HemisphereLight( 0x87CEEB, 0xFFE87C, 0.3 ); this.scene.add(hemisphereLight); // 지형 생성 const groundGeometry = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE, 100, 100); const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xD2B48C, roughness: 0.8, metalness: 0.2, envMapIntensity: 1.0 }); const ground = new THREE.Mesh(groundGeometry, groundMaterial); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; // 지형 높이 설정 const vertices = ground.geometry.attributes.position.array; for (let i = 0; i < vertices.length; i += 3) { vertices[i + 2] = 0; // 모든 높이를 0으로 설정 } ground.geometry.attributes.position.needsUpdate = true; ground.geometry.computeVertexNormals(); this.ground = ground; this.scene.add(ground); // 격자 효과 추가 const gridHelper = new THREE.GridHelper(MAP_SIZE, 50, 0x000000, 0x000000); gridHelper.material.opacity = 0.1; gridHelper.material.transparent = true; gridHelper.position.y = 0.1; this.scene.add(gridHelper); // 사막 장식 추가 await this.addDesertDecorations(); // 탱크 초기화 await this.tank.initialize(this.scene, this.loader); if (!this.tank.isLoaded) { throw new Error('Tank loading failed'); } // 스폰 위치 검증 const spawnPos = this.findValidSpawnPosition(); const heightAtSpawn = this.getHeightAtPosition(spawnPos.x, spawnPos.z); const slopeCheckPoints = [ { x: spawnPos.x + 2, z: spawnPos.z }, { x: spawnPos.x - 2, z: spawnPos.z }, { x: spawnPos.x, z: spawnPos.z + 2 }, { x: spawnPos.x, z: spawnPos.z - 2 } ]; const slopes = slopeCheckPoints.map(point => { const pointHeight = this.getHeightAtPosition(point.x, point.z); return Math.abs(pointHeight - heightAtSpawn) / 2; }); const maxSlope = Math.max(...slopes); if (maxSlope > 0.3) { location.reload(); return; } // 카메라 초기 설정 const tankPosition = this.tank.getPosition(); this.camera.position.set( tankPosition.x, tankPosition.y + 15, tankPosition.z - 30 ); this.camera.lookAt(tankPosition); // 로딩 완료 처리 this.isLoading = false; document.getElementById('loading').style.display = 'none'; // 게임 시작 this.animate(); this.spawnEnemies(); this.startGameTimer(); } catch (error) { console.error('Game initialization error:', error); this.handleLoadingError(); } } // 레이더 업데이트 메서드 추가 updateRadar() { const currentTime = Date.now(); if (currentTime - this.lastRadarUpdate < this.radarUpdateInterval) return; const radar = document.getElementById('radar'); const radarRect = radar.getBoundingClientRect(); const radarCenter = { x: radarRect.width / 2, y: radarRect.height / 2 }; // 기존 적 도트 제거 const oldDots = radar.getElementsByClassName('enemy-dot'); while (oldDots[0]) { oldDots[0].remove(); } // 탱크 위치 가져오기 const tankPos = this.tank.getPosition(); // 모든 적에 대해 레이더에 표시 this.enemies.forEach(enemy => { if (!enemy.mesh || !enemy.isLoaded) return; const enemyPos = enemy.mesh.position; const distance = tankPos.distanceTo(enemyPos); // 레이더 범위 내에 있는 경우만 표시 if (distance <= this.radarRange) { // 탱크 기준 상대 각도 계산 const angle = Math.atan2( enemyPos.x - tankPos.x, enemyPos.z - tankPos.z ); // 상대 거리를 레이더 크기에 맞게 스케일링 const relativeDistance = distance / this.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); } }); this.lastRadarUpdate = currentTime; } async addDesertDecorations() { if (!this.obstacles) { this.obstacles = []; } const BUILDING_COUNT = 25; const buildingModels = [ 'models/house1.glb', 'models/house2.glb', 'models/house3.glb', 'models/house4.glb' ]; // 충돌 박스 시각화를 위한 재질 const collisionBoxMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 }); // 플레이어 충돌 반경 및 최소 거리 계산 const playerCollisionRadius = 2; // 플레이어 충돌 반경 (탱크의 크기 기준) const clearanceBuffer = 1; // 여유 공간 const minimumDistance = playerCollisionRadius * 2 + clearanceBuffer; // 건물 간 최소 거리 for (let i = 0; i < BUILDING_COUNT; i++) { try { const modelPath = buildingModels[Math.floor(Math.random() * buildingModels.length)]; const result = await this.loader.loadAsync(modelPath); const building = result.scene; let x, z; const edgeSpawn = Math.random() < 0.7; if (edgeSpawn) { if (Math.random() < 0.5) { x = (Math.random() < 0.5 ? -1 : 1) * (MAP_SIZE * 0.4 + Math.random() * MAP_SIZE * 0.1); z = (Math.random() - 0.5) * MAP_SIZE * 0.9; } else { x = (Math.random() - 0.5) * MAP_SIZE * 0.9; z = (Math.random() < 0.5 ? -1 : 1) * (MAP_SIZE * 0.4 + Math.random() * MAP_SIZE * 0.1); } } else { x = (Math.random() - 0.5) * MAP_SIZE * 0.6; z = (Math.random() - 0.5) * MAP_SIZE * 0.6; } building.position.set(x, 0, z); building.rotation.y = Math.random() * Math.PI * 2; building.scale.set(1, 1, 1); // 그림자 설정 building.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); // 충돌 박스 생성 및 시각화 const boundingBox = new THREE.Box3().setFromObject(building); const boxSize = boundingBox.getSize(new THREE.Vector3()); // 충돌 박스의 와이어프레임 생성 const boxGeometry = new THREE.BufferGeometry(); const positions = new Float32Array([ // 바닥 사각형 -boxSize.x / 2, 0.1, -boxSize.z / 2, -boxSize.x / 2, 0.1, boxSize.z / 2, boxSize.x / 2, 0.1, boxSize.z / 2, boxSize.x / 2, 0.1, -boxSize.z / 2, -boxSize.x / 2, 0.1, -boxSize.z / 2 ]); boxGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const collisionBoxLine = new THREE.Line(boxGeometry, collisionBoxMaterial); collisionBoxLine.position.copy(building.position); collisionBoxLine.rotation.y = building.rotation.y; // 충돌 데이터 설정 building.userData.isCollidable = true; building.userData.type = 'building'; building.userData.collisionBox = collisionBoxLine; let tooClose = false; for (const obstacle of this.obstacles) { const distance = building.position.distanceTo(obstacle.position); if (distance < minimumDistance) { // 최소 거리 조건 추가 tooClose = true; break; } } if (!tooClose) { this.obstacles.push(building); this.scene.add(building); this.scene.add(collisionBoxLine); // 충돌 박스 라인 추가 } } catch (error) { console.error('Error loading building model:', error); } } // 선인장 추가 (기존 코드 유지) const cactusGeometry = new THREE.CylinderGeometry(0.5, 0.7, 4, 8); const cactusMaterial = new THREE.MeshStandardMaterial({ color: 0x2F4F2F, roughness: 0.8 }); for (let i = 0; i < 50; i++) { const cactus = new THREE.Mesh(cactusGeometry, cactusMaterial); cactus.position.set( (Math.random() - 0.5) * MAP_SIZE * 0.8, 2, (Math.random() - 0.5) * MAP_SIZE * 0.8 ); cactus.castShadow = true; cactus.receiveShadow = true; cactus.userData.isCollidable = false; cactus.userData.type = 'cactus'; this.scene.add(cactus); } } getHeightAtPosition(x, z) { return 0; // 항상 높이 0 반환 } findValidSpawnPosition() { const margin = 50; let position; let attempts = 0; const maxAttempts = 50; const maxSlope = 0.3; // 최대 허용 경사 while (attempts < maxAttempts) { position = new THREE.Vector3( (Math.random() - 0.5) * (MAP_SIZE - margin * 2), 0, (Math.random() - 0.5) * (MAP_SIZE - margin * 2) ); // 현재 위치의 높이 가져오기 const height = this.getHeightAtPosition(position.x, position.z); position.y = height + TANK_HEIGHT; // 주변 지형의 경사 체크 const checkPoints = [ { x: position.x + 2, z: position.z }, { x: position.x - 2, z: position.z }, { x: position.x, z: position.z + 2 }, { x: position.x, z: position.z - 2 } ]; const slopes = checkPoints.map(point => { const pointHeight = this.getHeightAtPosition(point.x, point.z); return Math.abs(pointHeight - height) / 2; }); const maxCurrentSlope = Math.max(...slopes); if (maxCurrentSlope <= maxSlope) { return position; } attempts++; } // 실패 시 기본 위치 반환 return new THREE.Vector3(0, TANK_HEIGHT, 0); } setupEventListeners() { document.addEventListener('keydown', (event) => { if (this.isLoading || this.isGameOver) return; switch(event.code) { case 'KeyW': this.keys.forward = true; break; case 'KeyS': this.keys.backward = true; break; case 'KeyA': this.keys.left = true; break; case 'KeyD': this.keys.right = true; break; } }); document.addEventListener('keyup', (event) => { if (this.isLoading || this.isGameOver) return; switch(event.code) { case 'KeyW': this.keys.forward = false; break; case 'KeyS': this.keys.backward = false; break; case 'KeyA': this.keys.left = false; break; case 'KeyD': this.keys.right = false; break; } }); document.addEventListener('mousemove', (event) => { if (this.isLoading || this.isGameOver || !document.pointerLockElement) return; const movementX = event.movementX || event.mozMovementX || event.webkitMovementX || 0; this.mouse.x += movementX * 0.002; this.mouse.y = 0; while (this.mouse.x > Math.PI) this.mouse.x -= Math.PI * 2; while (this.mouse.x < -Math.PI) this.mouse.x += Math.PI * 2; }); document.addEventListener('click', () => { if (!document.pointerLockElement) { document.body.requestPointerLock(); } else if (!this.isGameOver && this.tank && this.tank.isLoaded) { const bullet = this.tank.shoot(this.scene); if (bullet) { // Shooting effects... } } }); document.addEventListener('pointerlockchange', () => { if (!document.pointerLockElement) { this.mouse.x = 0; this.mouse.y = 0; } }); window.addEventListener('resize', () => { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); }); } handleMovement() { if (!this.tank.isLoaded || this.isGameOver) return; const direction = new THREE.Vector3(); const isMoving = this.keys.forward || this.keys.backward; // 이동 시작할 때 사운드 처리 if (isMoving && !this.isEngineRunning) { this.isEngineRunning = true; // 이전 사운드 정지 if (this.engineStopSound) { this.engineStopSound.pause(); } if (this.engineSound) { this.engineSound.pause(); } // 엔진 정지 사운드 재생 this.engineStopSound = new Audio('sounds/engine.ogg'); this.engineStopSound.play(); // 엔진 정지 사운드 종료 후 엔진 사운드 시작 this.engineStopSound.onended = () => { this.engineSound = new Audio('sounds/engine.ogg'); this.engineSound.loop = true; this.engineSound.play(); }; } // 이동 멈출 때 사운드 처리 else if (!isMoving && this.isEngineRunning) { this.isEngineRunning = false; if (this.engineSound) { this.engineSound.pause(); this.engineSound = null; } if (this.engineStopSound) { this.engineStopSound.pause(); this.engineStopSound = null; } const stopSound = new Audio('sounds/enginestop.ogg'); stopSound.play(); } // 차체 이동과 회전은 유지 if (this.keys.forward) direction.z += 1; if (this.keys.backward) direction.z -= 1; if (this.keys.left) this.tank.rotate(-1); if (this.keys.right) this.tank.rotate(1); if (direction.length() > 0) { direction.normalize(); direction.applyEuler(this.tank.body.rotation); this.tank.move(direction); } // 탱크 위치 가져오기 const tankPos = this.tank.getPosition(); // 카메라는 마우스 X 회전에만 따라감 const cameraDistance = 15; const cameraHeight = 8; const cameraAngle = this.mouse.x + Math.PI; // 항상 포탑의 뒤쪽에 위치 const cameraX = tankPos.x + Math.sin(cameraAngle) * cameraDistance; const cameraZ = tankPos.z + Math.cos(cameraAngle) * cameraDistance; this.camera.position.set( cameraX, tankPos.y + cameraHeight, cameraZ ); // 카메라가 탱크를 바라보도록 설정 const lookAtPoint = new THREE.Vector3( tankPos.x, tankPos.y + 1, tankPos.z ); this.camera.lookAt(lookAtPoint); } createBuildings() { const buildingTypes = [ { width: 10, height: 30, depth: 10, color: 0x808080 }, { width: 15, height: 40, depth: 15, color: 0x606060 }, { width: 20, height: 50, depth: 20, color: 0x404040 } ]; for (let i = 0; i < BUILDING_COUNT; i++) { const type = buildingTypes[Math.floor(Math.random() * buildingTypes.length)]; const building = this.createBuilding(type); let position; let attempts = 0; do { position = new THREE.Vector3( (Math.random() - 0.5) * (MAP_SIZE - type.width), type.height / 2, (Math.random() - 0.5) * (MAP_SIZE - type.depth) ); attempts++; } while (this.checkBuildingCollision(position, type) && attempts < 50); if (attempts < 50) { building.position.copy(position); this.buildings.push(building); this.scene.add(building); } } return Promise.resolve(); } createBuilding(type) { const geometry = new THREE.BoxGeometry(type.width, type.height, type.depth); const material = new THREE.MeshPhongMaterial({ color: type.color, emissive: 0x222222, specular: 0x111111, shininess: 30 }); const building = new THREE.Mesh(geometry, material); building.castShadow = true; building.receiveShadow = true; return building; } checkBuildingCollision(position, type) { const margin = 5; const bbox = new THREE.Box3( new THREE.Vector3( position.x - (type.width / 2 + margin), 0, position.z - (type.depth / 2 + margin) ), new THREE.Vector3( position.x + (type.width / 2 + margin), type.height, position.z + (type.depth / 2 + margin) ) ); return this.buildings.some(building => { const buildingBox = new THREE.Box3().setFromObject(building); return bbox.intersectsBox(buildingBox); }); } handleLoadingError() { this.isLoading = false; const loadingElement = document.getElementById('loading'); if (loadingElement) { loadingElement.innerHTML = `