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 = 1000; const TANK_HEIGHT = 0.5; const ENEMY_GROUND_HEIGHT = 0; const ENEMY_SCALE = 10; const MAX_HEALTH = 1000; const ENEMY_MOVE_SPEED = 0.1; const ENEMY_COUNT_MAX = 3; const PARTICLE_COUNT = 15; const BUILDING_COUNT = 30; 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 = []; } 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; } }); this.turret.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); // 여기서 유효한 스폰 위치를 찾아 적용 if (window.gameInstance) { const spawnPos = window.gameInstance.findValidSpawnPosition(); this.body.position.copy(spawnPos); } else { this.body.position.copy(this.position); } 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 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; audio.play(); const bullet = this.createBullet(scene); if (bullet) { this.ammo--; this.updateAmmoDisplay(); this.startReload(); } return bullet; } createBullet(scene) { const bulletGeometry = new THREE.SphereGeometry(0.2); const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); const bulletOffset = new THREE.Vector3(0, 0.5, 2); bulletOffset.applyQuaternion(this.turretGroup.quaternion); bulletOffset.applyQuaternion(this.body.quaternion); bullet.position.copy(this.body.position).add(bulletOffset); const direction = new THREE.Vector3(0, 0, 1); direction.applyQuaternion(this.turretGroup.quaternion); direction.applyQuaternion(this.body.quaternion); bullet.velocity = direction.multiplyScalar(2); 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]; bullet.position.add(bullet.velocity); if ( Math.abs(bullet.position.x) > MAP_SIZE / 2 || Math.abs(bullet.position.z) > MAP_SIZE / 2 ) { 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; return this.health <= 0; } 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.moveSpeed = type === 'tank' ? ENEMY_MOVE_SPEED : ENEMY_MOVE_SPEED * 0.7; } async initialize(loader) { try { const modelPath = this.type === 'tank' ? '/models/enemy1.glb' : '/models/enemy4.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; } } update(playerPosition) { if (!this.mesh || !this.isLoaded) return; const direction = new THREE.Vector3() .subVectors(playerPosition, this.mesh.position) .normalize(); const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); const minDistance = 50; this.mesh.lookAt(playerPosition); if (distanceToPlayer > minDistance) { const moveVector = direction.multiplyScalar(this.moveSpeed); const newPosition = this.mesh.position.clone().add(moveVector); // 새로운 위치의 지형 높이 가져오기 const heightAtNewPos = window.gameInstance.getHeightAtPosition(newPosition.x, newPosition.z); newPosition.y = heightAtNewPos + TANK_HEIGHT; // 경사 체크 const heightDifference = Math.abs(newPosition.y - this.mesh.position.y); const maxClimbAngle = 0.5; if (heightDifference / this.moveSpeed < maxClimbAngle) { this.mesh.position.copy(newPosition); // 적 탱크 기울기 조정 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( 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); this.mesh.rotation.x = pitch; this.mesh.rotation.z = roll; } } // 총알 업데이트 부분 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 || Math.abs(bullet.position.z) > MAP_SIZE) { this.scene.remove(bullet); this.bullets.splice(i, 1); } } } 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 bulletGeometry = new THREE.SphereGeometry(this.type === 'tank' ? 0.2 : 0.3); const bulletMaterial = new THREE.MeshBasicMaterial({ color: this.type === 'tank' ? 0xff0000 : 0xff6600 }); const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); bullet.position.copy(this.mesh.position); const direction = new THREE.Vector3() .subVectors(playerPosition, this.mesh.position) .normalize(); const bulletSpeed = this.type === 'tank' ? ENEMY_CONFIG.BULLET_SPEED : ENEMY_CONFIG.BULLET_SPEED * 0.8; bullet.velocity = direction.multiplyScalar(bulletSpeed); this.scene.add(bullet); this.bullets.push(bullet); this.lastAttackTime = currentTime; } 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; } } } // 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.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; 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.mouse = { x: 0, y: 0 }; this.keys = { forward: false, backward: false, left: false, right: false }; this.setupEventListeners(); this.initialize(); } async initialize() { try { // 안개 효과 제거 this.scene.fog = null; this.scene.background = new THREE.Color(0x87CEEB); // 주변광 설정 - 더 밝게 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambientLight); // 태양광 설정 const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(100, 100, 50); directionalLight.castShadow = true; directionalLight.shadow.mapSize.width = 1024; directionalLight.shadow.mapSize.height = 1024; this.scene.add(directionalLight); // 지형 생성 수정 const groundGeometry = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE, 100, 100); const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xD2B48C, roughness: 0.8, metalness: 0.2 }); const ground = new THREE.Mesh(groundGeometry, groundMaterial); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; // 더 완만한 지형 생성 const vertices = ground.geometry.attributes.position.array; const heightScale = 10; // 높이 스케일 감소 const frequency = 0.005; // 주파수 감소로 더 완만한 경사 생성 for (let i = 0; i < vertices.length; i += 3) { const x = vertices[i]; const y = vertices[i + 1]; // Perlin 노이즈와 유사한 효과를 내는 수정된 수식 vertices[i + 2] = (Math.sin(x * frequency) * Math.cos(y * frequency) * heightScale) + (Math.sin(x * frequency * 2) * Math.cos(y * frequency * 2) * heightScale * 0.5); // 맵 가장자리로 갈수록 높이를 점진적으로 줄임 const distanceFromCenter = Math.sqrt(x * x + y * y) / (MAP_SIZE * 0.5); const edgeFactor = Math.max(0, 1 - distanceFromCenter); vertices[i + 2] *= edgeFactor; } ground.geometry.attributes.position.needsUpdate = true; ground.geometry.computeVertexNormals(); this.ground = ground; // 지형 참조 저장 this.scene.add(ground); // 사막 장식 추가 await this.addDesertDecorations(); // 탱크 초기화 await this.tank.initialize(this.scene, this.loader); if (!this.tank.isLoaded) { throw new Error('Tank loading failed'); } // 카메라 초기 위치 설정 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() { // 바위 생성 const rockGeometries = [ new THREE.DodecahedronGeometry(3), new THREE.DodecahedronGeometry(2), new THREE.DodecahedronGeometry(4) ]; const rockMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.9, metalness: 0.1 }); for (let i = 0; i < 100; i++) { const rockGeometry = rockGeometries[Math.floor(Math.random() * rockGeometries.length)]; const rock = new THREE.Mesh(rockGeometry, rockMaterial); rock.position.set( (Math.random() - 0.5) * MAP_SIZE * 0.9, Math.random() * 2, (Math.random() - 0.5) * MAP_SIZE * 0.9 ); rock.rotation.set( Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI ); rock.scale.set( 1 + Math.random() * 0.5, 1 + Math.random() * 0.5, 1 + Math.random() * 0.5 ); rock.castShadow = true; rock.receiveShadow = true; this.scene.add(rock); } // 선인장 추가 (간단한 geometry로 표현) 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; this.scene.add(cactus); } } getHeightAtPosition(x, z) { if (!this.ground) return 0; // 지형의 정점 데이터 const vertices = this.ground.geometry.attributes.position.array; const segmentsX = Math.sqrt(vertices.length / 3) - 1; const segmentsZ = segmentsX; // 맵 좌표를 지형 격자 좌표로 변환 const gridX = ((x + MAP_SIZE / 2) / MAP_SIZE) * segmentsX; const gridZ = ((z + MAP_SIZE / 2) / MAP_SIZE) * segmentsZ; // 가장 가까운 격자점 찾기 const x1 = Math.floor(gridX); const z1 = Math.floor(gridZ); const x2 = Math.min(x1 + 1, segmentsX); const z2 = Math.min(z1 + 1, segmentsZ); // 격자점들의 높이 가져오기 const getHeight = (x, z) => { if (x < 0 || x > segmentsX || z < 0 || z > segmentsZ) return 0; const index = (z * (segmentsX + 1) + x) * 3 + 2; return vertices[index]; }; const h11 = getHeight(x1, z1); const h21 = getHeight(x2, z1); const h12 = getHeight(x1, z2); const h22 = getHeight(x2, z2); // 보간으로 정확한 높이 계산 const fx = gridX - x1; const fz = gridZ - z1; const h1 = h11 * (1 - fx) + h21 * fx; const h2 = h12 * (1 - fx) + h22 * fx; return h1 * (1 - fz) + h2 * fz; } 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) { const bullet = this.tank.shoot(this.scene); if (bullet) { // Shooting effects could be added here } } }); 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(); // 차체 이동과 회전은 유지 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 = 30; const cameraHeight = 15; 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 + 2, 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 = `
Loading failed. Please refresh the page.
`; } } startGameTimer() { if (this.gameTimer) { clearInterval(this.gameTimer); } this.gameTimer = setInterval(() => { if (this.isLoading || this.isGameOver) { clearInterval(this.gameTimer); return; } this.gameTime--; document.getElementById('time').textContent = `Time: ${this.gameTime}s`; if (this.gameTime <= 0) { clearInterval(this.gameTimer); this.endGame(); } }, 1000); } spawnEnemies() { const spawnEnemy = () => { if (this.enemies.length < 3 && !this.isGameOver) { // 최대 3대로 제한 const position = this.getValidEnemySpawnPosition(); if (position) { const type = Math.random() < 0.7 ? 'tank' : 'heavy'; const enemy = new Enemy(this.scene, position, type); enemy.initialize(this.loader); this.enemies.push(enemy); } } if (!this.isGameOver) { setTimeout(spawnEnemy, 10000); // 10초마다 스폰 } }; spawnEnemy(); } getValidEnemySpawnPosition() { const margin = 50; let position; let attempts = 0; const maxAttempts = 50; const maxSlope = 0.3; do { 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); // 플레이어와의 거리 체크 const distanceToPlayer = position.distanceTo(this.tank.getPosition()); if (distanceToPlayer > 100 && maxCurrentSlope <= maxSlope) { return position; } attempts++; } while (attempts < maxAttempts); return null; } updateParticles() { for (let i = this.particles.length - 1; i >= 0; i--) { const particle = this.particles[i]; if (!particle.update()) { particle.destroy(this.scene); this.particles.splice(i, 1); } } } createExplosion(position) { for (let i = 0; i < PARTICLE_COUNT; i++) { this.particles.push(new Particle(this.scene, position)); } } checkCollisions() { if (this.isLoading || !this.tank.isLoaded) return; const tankPosition = this.tank.getPosition(); // 적 총알과 플레이어 탱크 충돌 체크 this.enemies.forEach(enemy => { if (!enemy.mesh || !enemy.isLoaded) return; enemy.bullets.forEach(bullet => { const distance = bullet.position.distanceTo(tankPosition); if (distance < 1) { if (this.tank.takeDamage(250)) { // 데미지를 250으로 수정 this.endGame(); } this.scene.remove(bullet); enemy.bullets = enemy.bullets.filter(b => b !== bullet); this.createExplosion(bullet.position); document.getElementById('health').style.width = `${(this.tank.health / MAX_HEALTH) * 100}%`; } }); }); // 플레이어 총알과 적 충돌 체크 this.tank.bullets.forEach((bullet, bulletIndex) => { this.enemies.forEach((enemy, enemyIndex) => { if (!enemy.mesh || !enemy.isLoaded) return; const distance = bullet.position.distanceTo(enemy.mesh.position); if (distance < 2) { if (enemy.takeDamage(50)) { enemy.destroy(); this.enemies.splice(enemyIndex, 1); this.score += 100; document.getElementById('score').textContent = `Score: ${this.score}`; } this.scene.remove(bullet); this.tank.bullets.splice(bulletIndex, 1); this.createExplosion(bullet.position); } }); }); // 플레이어 탱크와 적 전차 충돌 체크 const tankBoundingBox = new THREE.Box3().setFromObject(this.tank.body); this.enemies.forEach(enemy => { if (!enemy.mesh || !enemy.isLoaded) return; const enemyBoundingBox = new THREE.Box3().setFromObject(enemy.mesh); if (tankBoundingBox.intersectsBox(enemyBoundingBox)) { this.tank.body.position.copy(this.previousTankPosition); } }); // 이전 위치 저장 this.previousTankPosition.copy(this.tank.body.position); } endGame() { if (this.isGameOver) return; this.isGameOver = true; if (this.gameTimer) { clearInterval(this.gameTimer); } if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); } document.exitPointerLock(); const gameOverDiv = document.createElement('div'); gameOverDiv.style.position = 'absolute'; gameOverDiv.style.top = '50%'; gameOverDiv.style.left = '50%'; gameOverDiv.style.transform = 'translate(-50%, -50%)'; gameOverDiv.style.color = '#0f0'; gameOverDiv.style.fontSize = '48px'; gameOverDiv.style.backgroundColor = 'rgba(0, 20, 0, 0.7)'; gameOverDiv.style.padding = '20px'; gameOverDiv.style.borderRadius = '10px'; gameOverDiv.style.textAlign = 'center'; gameOverDiv.innerHTML = ` Game Over
Score: ${this.score}
Time Survived: ${GAME_DURATION - this.gameTime}s
`; document.body.appendChild(gameOverDiv); } updateUI() { if (!this.isGameOver) { const healthBar = document.getElementById('health'); if (healthBar) { healthBar.style.width = `${(this.tank.health / MAX_HEALTH) * 100}%`; } const timeElement = document.getElementById('time'); if (timeElement) { timeElement.textContent = `Time: ${this.gameTime}s`; } const scoreElement = document.getElementById('score'); if (scoreElement) { scoreElement.textContent = `Score: ${this.score}`; } } } animate() { if (this.isGameOver) { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); } return; } this.animationFrameId = requestAnimationFrame(() => this.animate()); const currentTime = performance.now(); const deltaTime = (currentTime - this.lastTime) / 1000; this.lastTime = currentTime; if (!this.isLoading) { this.handleMovement(); this.tank.update(this.mouse.x, this.mouse.y, this.scene); const tankPosition = this.tank.getPosition(); this.enemies.forEach(enemy => { enemy.update(tankPosition); if (enemy.isLoaded && enemy.mesh.position.distanceTo(tankPosition) < ENEMY_CONFIG.ATTACK_RANGE) { enemy.shoot(tankPosition); } }); this.updateParticles(); this.checkCollisions(); this.updateUI(); this.updateRadar(); // 레이더 업데이트 추가 } this.renderer.render(this.scene, this.camera); } } // Start game window.startGame = function() { document.getElementById('startScreen').style.display = 'none'; document.body.requestPointerLock(); // Create new game instance or restart existing one if (!window.gameInstance) { window.gameInstance = new Game(); } }; // Initialize game document.addEventListener('DOMContentLoaded', () => { const game = new Game(); });