Spaces:
Running
Running
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 = 50; | |
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/explosion.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; | |
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.alternativePath = null; | |
this.pathFindingTimeout = 0; | |
this.lastPathUpdateTime = 0; | |
this.pathUpdateInterval = 1000; // 1μ΄λ§λ€ κ²½λ‘ μ λ°μ΄νΈ | |
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.02); // κΈ°μ‘΄ 0.05μμ 0.02λ‘ μμ | |
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); | |
} | |
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; | |
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.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 = 50; // λ°μ λμ κ±΄λ¬Όλ‘ λ³κ²½ | |
const buildingModels = [ | |
'models/house1.glb', | |
'models/house2.glb', | |
'models/house3.glb', | |
'models/house4.glb' | |
]; | |
// μΆ©λ λ°μ€ μκ°νμ© μ¬μ§ (λλ²κΉ μ©) | |
const collisionBoxMaterial = new THREE.MeshBasicMaterial({ | |
color: 0xff0000, | |
wireframe: true, | |
visible: false // νμμ trueλ‘ λ³κ²½νμ¬ μΆ©λ λ°μ€ νμΈ | |
}); | |
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; // 70% νλ₯ λ‘ κ°μ₯μ리μ μμ± | |
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 collisionGeometry = new THREE.BoxGeometry(boxSize.x, boxSize.y, boxSize.z); | |
const collisionMesh = new THREE.Mesh(collisionGeometry, collisionBoxMaterial); | |
collisionMesh.position.copy(building.position); | |
collisionMesh.position.y += boxSize.y / 2; // μΆ©λ λ°μ€λ₯Ό 건물 μ€μ¬μ λ§μΆ€ | |
collisionMesh.rotation.copy(building.rotation); | |
// μΆ©λ λ°μ΄ν° μ€μ | |
building.userData.isCollidable = true; | |
building.userData.type = 'building'; | |
building.userData.collisionMesh = collisionMesh; | |
// λ€λ₯Έ 건물λ€κ³Όμ 거리 μ²΄ν¬ | |
let tooClose = false; | |
for (const obstacle of this.obstacles) { | |
const distance = building.position.distanceTo(obstacle.position); | |
if (distance < 20) { // μ΅μ 거리 μ€μ | |
tooClose = true; | |
break; | |
} | |
} | |
if (!tooClose) { | |
this.obstacles.push(building); | |
this.scene.add(building); | |
this.scene.add(collisionMesh); | |
} | |
} 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 = ` | |
<div class="loading-text" style="color: red;"> | |
Loading failed. Please refresh the page. | |
</div> | |
`; | |
} | |
} | |
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]; | |
// μ€λ ₯ μ μ© | |
particle.velocity.y += particle.gravity; | |
particle.mesh.position.add(particle.velocity); | |
// ν¬λͺ λ κ°μ | |
particle.mesh.material.opacity -= particle.fadeRate; | |
particle.life--; | |
// νν°ν΄ μ κ±° | |
if (particle.life <= 0 || particle.mesh.material.opacity <= 0) { | |
this.scene.remove(particle.mesh); | |
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 hitSounds = [ | |
'sounds/hit1.ogg', 'sounds/hit2.ogg', 'sounds/hit3.ogg', | |
'sounds/hit4.ogg', 'sounds/hit5.ogg', 'sounds/hit6.ogg', 'sounds/hit7.ogg' | |
]; | |
// νΌκ²© μ¬μ΄λ λ°°μ΄ μ μ | |
const beatSounds = ['sounds/beat1.ogg', 'sounds/beat2.ogg', 'sounds/beat3.ogg']; | |
const tankPosition = this.tank.getPosition(); | |
const tankBoundingBox = new THREE.Box3().setFromObject(this.tank.body); | |
// ν±ν¬μ μ₯μ λ¬Ό μΆ©λ μ²΄ν¬ (κ°μ ) | |
this.obstacles.forEach(obstacle => { | |
if (obstacle.userData.isCollidable) { // μΆ©λ κ°λ₯ν κ°μ²΄λ§ κ²μ¬ | |
const obstacleBoundingBox = new THREE.Box3().setFromObject(obstacle); | |
if (tankBoundingBox.intersectsBox(obstacleBoundingBox)) { | |
this.tank.body.position.copy(this.previousTankPosition); | |
} | |
} | |
}); | |
// μ ν±ν¬μ μ₯μ λ¬Ό μΆ©λ μ²΄ν¬ (μΆκ°) | |
this.enemies.forEach(enemy => { | |
if (!enemy.mesh || !enemy.isLoaded) return; | |
const enemyBoundingBox = new THREE.Box3().setFromObject(enemy.mesh); | |
const enemyPreviousPosition = enemy.mesh.position.clone(); | |
this.obstacles.forEach(obstacle => { | |
const obstacleBoundingBox = new THREE.Box3().setFromObject(obstacle); | |
if (enemyBoundingBox.intersectsBox(obstacleBoundingBox)) { | |
enemy.mesh.position.copy(enemyPreviousPosition); | |
} | |
}); | |
}); | |
// μ μ΄μκ³Ό νλ μ΄μ΄ ν±ν¬ μΆ©λ μ²΄ν¬ | |
this.enemies.forEach(enemy => { | |
if (!enemy.mesh || !enemy.isLoaded) return; | |
enemy.bullets.forEach((bullet, bulletIndex) => { | |
// νλ μ΄μ΄ ν±ν¬μ λ°μ΄λ© λ°μ€ μμ± | |
const tankBox = new THREE.Box3().setFromObject(this.tank.body); | |
// λ°μ΄λ© λ°μ€ ν¬κΈ° μ‘°μ (1.5λ°°λ‘ μμ ) | |
tankBox.min.x -= 0.75; | |
tankBox.max.x += 0.75; | |
// μ΄μμ λ°μ΄λ© λ°μ€ μμ± | |
const bulletBox = new THREE.Box3().setFromObject(bullet); | |
if (bulletBox.intersectsBox(tankBox)) { | |
const randomBeatSound = beatSounds[Math.floor(Math.random() * beatSounds.length)]; | |
const beatAudio = new Audio(randomBeatSound); | |
beatAudio.play(); | |
if (this.tank.takeDamage(250)) { | |
this.endGame(); | |
} | |
this.tank.createExplosionEffect(this.scene, bullet.position); | |
this.scene.remove(bullet); | |
enemy.bullets.splice(bulletIndex, 1); | |
document.getElementById('health').style.width = | |
`${(this.tank.health / MAX_HEALTH) * 100}%`; | |
} | |
}); | |
}); | |
// νλ μ΄μ΄ ν¬ν | |
// ν¬νκ³Ό μ₯μ λ¬Ό μΆ©λ μ²΄ν¬ | |
for (let i = this.tank.bullets.length - 1; i >= 0; i--) { | |
const bullet = this.tank.bullets[i]; | |
const bulletBox = new THREE.Box3().setFromObject(bullet); | |
for (const obstacle of this.obstacles) { | |
if (obstacle.userData.isCollidable) { // μΆ©λ κ°λ₯ν κ°μ²΄λ§ κ²μ¬ | |
const obstacleBox = new THREE.Box3().setFromObject(obstacle); | |
if (bulletBox.intersectsBox(obstacleBox)) { | |
// νλ° μ΄ννΈ μμ± | |
this.tank.createExplosionEffect(this.scene, bullet.position); | |
// ν¬ν μ κ±° | |
this.scene.remove(bullet); | |
this.tank.bullets.splice(i, 1); | |
break; | |
} | |
} | |
} | |
} | |
// μ ν±ν¬μ μ₯μ λ¬Ό μΆ©λ μ²΄ν¬ | |
this.enemies.forEach(enemy => { | |
if (!enemy.mesh || !enemy.isLoaded) return; | |
enemy.bullets.forEach((bullet, bulletIndex) => { | |
const distance = bullet.position.distanceTo(tankPosition); | |
if (distance < 1) { | |
// νΌκ²© μ¬μ΄λ μ¬μ | |
const randomBeatSound = beatSounds[Math.floor(Math.random() * beatSounds.length)]; | |
const beatAudio = new Audio(randomBeatSound); | |
beatAudio.play(); | |
if (this.tank.takeDamage(250)) { | |
this.endGame(); | |
} | |
// κΈ°μ‘΄μ createExplosion λμ createExplosionEffect μ¬μ© | |
this.tank.createExplosionEffect(this.scene, bullet.position); | |
this.scene.remove(bullet); | |
enemy.bullets.splice(bulletIndex, 1); | |
document.getElementById('health').style.width = | |
`${(this.tank.health / MAX_HEALTH) * 100}%`; | |
} | |
}); | |
}); | |
// νλ μ΄μ΄ μ΄μκ³Ό μ μΆ©λ μ²΄ν¬ | |
for (let i = this.tank.bullets.length - 1; i >= 0; i--) { | |
const bullet = this.tank.bullets[i]; | |
for (let j = this.enemies.length - 1; j >= 0; j--) { | |
const enemy = this.enemies[j]; | |
if (!enemy.mesh || !enemy.isLoaded) continue; | |
// μ μ μ°¨μ λ°μ΄λ© λ°μ€ μμ± | |
const enemyBox = new THREE.Box3().setFromObject(enemy.mesh); | |
// λ°μ΄λ© λ°μ€ ν¬κΈ° μ‘°μ (νμ 2λ°°λ‘) | |
enemyBox.min.x -= 0.75; // 2μμ 1.5λ‘ μμ | |
enemyBox.max.x += 0.75; | |
// μ΄μμ λ°μ΄λ© λ°μ€ μμ± | |
const bulletBox = new THREE.Box3().setFromObject(bullet); | |
// λ°μ€ μΆ©λ κ²μ¬ | |
if (bulletBox.intersectsBox(enemyBox)) { | |
const randomHitSound = hitSounds[Math.floor(Math.random() * hitSounds.length)]; | |
const hitAudio = new Audio(randomHitSound); | |
hitAudio.play(); | |
if (enemy.takeDamage(50)) { | |
enemy.destroy(); | |
this.enemies.splice(j, 1); | |
this.score += 100; | |
document.getElementById('score').textContent = `Score: ${this.score}`; | |
} | |
this.tank.createExplosionEffect(this.scene, bullet.position); | |
this.scene.remove(bullet); | |
this.tank.bullets.splice(i, 1); | |
break; | |
} | |
} | |
} | |
// νλ μ΄μ΄ ν±ν¬μ μ μ μ°¨ μΆ©λ μ²΄ν¬ | |
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; | |
// BGM μ μ§ | |
if (this.bgm) { | |
this.bgm.pause(); | |
this.bgm = null; | |
this.bgmPlaying = false; | |
} | |
// μ¬λ§ μ¬μ΄λ μ¬μ | |
const deathSounds = ['sounds/death1.ogg', 'sounds/death2.ogg']; | |
const randomDeathSound = deathSounds[Math.floor(Math.random() * deathSounds.length)]; | |
const deathAudio = new Audio(randomDeathSound); | |
deathAudio.play(); | |
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<br> | |
Score: ${this.score}<br> | |
Time Survived: ${GAME_DURATION - this.gameTime}s<br> | |
<button onclick="location.reload()" | |
style="font-size: 24px; padding: 10px; margin-top: 20px; | |
cursor: pointer; background: #0f0; border: none; | |
color: black; border-radius: 5px;"> | |
Play Again | |
</button> | |
`; | |
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}`; | |
} | |
} | |
} | |
// ν¬λ‘μ€ν€μ΄ μ λ°μ΄νΈ λ©μλ μΆκ° | |
updateCrosshair() { | |
// νλ©΄ μ€μμμ μ½κ°μ μ¬μ λ₯Ό λκ³ λ μ΄μΊμ€ν | |
const raycasterDirection = new THREE.Vector2(); | |
this.raycaster.setFromCamera(raycasterDirection, this.camera); | |
// μ μ μ°¨μ λ°μ΄λ© λ°μ€λ ν¬ν¨νμ¬ κ²μ¬ | |
const detectEnemy = this.enemies.some(enemy => { | |
if (!enemy.mesh || !enemy.isLoaded) return false; | |
// μ μ μ°¨μ λ°μ΄λ© λ°μ€ μμ± | |
const boundingBox = new THREE.Box3().setFromObject(enemy.mesh); | |
const intersects = this.raycaster.ray.intersectsBox(boundingBox); | |
// λ°μ΄λ© λ°μ€μμ κ΅μ°¨ μ¬λΆλ‘ νλ¨ | |
return intersects; | |
}); | |
if (detectEnemy) { | |
this.crosshair.classList.add('target-detected'); | |
} else { | |
this.crosshair.classList.remove('target-detected'); | |
} | |
} | |
updateEnemyLabels() { | |
const labelsContainer = document.getElementById('enemyLabels'); | |
// κΈ°μ‘΄ λΌλ²¨ λͺ¨λ μ κ±° | |
labelsContainer.innerHTML = ''; | |
this.enemies.forEach((enemy, index) => { | |
if (!enemy.mesh || !enemy.isLoaded) return; | |
// μ μμΉλ₯Ό νλ©΄ μ’νλ‘ λ³ν | |
const enemyPosition = enemy.mesh.position.clone(); | |
enemyPosition.y += 5; // μ 머리 μμ νμνκΈ° μν΄ λμ΄ μ‘°μ | |
const screenPosition = enemyPosition.project(this.camera); | |
// νλ©΄μ 보μ΄λμ§ νμΈ | |
if (screenPosition.z > 1) return; // μΉ΄λ©λΌ λ€μ μλ κ²½μ° | |
// μ κ³Ό νλ μ΄μ΄ μ¬μ΄μ 거리 κ³μ° | |
const distance = enemy.mesh.position.distanceTo(this.tank.getPosition()); | |
// λ μ΄λ λ²μ λ΄μ μλ κ²½μ°μλ§ νμ | |
if (distance <= this.radarRange) { | |
const x = (screenPosition.x + 1) / 2 * window.innerWidth; | |
const y = (-screenPosition.y + 1) / 2 * window.innerHeight; | |
// λΌλ²¨ μμ± | |
const label = document.createElement('div'); | |
label.className = 'enemy-label'; | |
label.textContent = 'T-90'; | |
label.style.left = `${x}px`; | |
label.style.top = `${y}px`; | |
// 거리μ λ°λ₯Έ ν¬λͺ λ μ‘°μ | |
const opacity = Math.max(0.2, 1 - (distance / this.radarRange)); | |
label.style.opacity = opacity; | |
labelsContainer.appendChild(label); | |
} | |
}); | |
} | |
animate() { | |
if (this.isGameOver) { | |
if (this.animationFrameId) { | |
cancelAnimationFrame(this.animationFrameId); | |
} | |
return; | |
} | |
this.animationFrameId = requestAnimationFrame(() => this.animate()); | |
// κ²μμ΄ μμλμ§ μμμΌλ©΄ λ λλ§λ§ μν | |
if (!this.isStarted) { | |
this.renderer.render(this.scene, this.camera); | |
return; | |
} | |
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.updateEnemyLabels(); // μ λΌλ²¨ μ λ°μ΄νΈ μΆκ° | |
this.updateParticles(); | |
this.checkCollisions(); | |
this.updateUI(); | |
this.updateRadar(); // λ μ΄λ μ λ°μ΄νΈ μΆκ° | |
// ν¬λ‘μ€ν€μ΄ μ λ°μ΄νΈ μΆκ° | |
this.updateCrosshair(); | |
} | |
this.renderer.render(this.scene, this.camera); | |
} | |
} | |
// Start game | |
window.startGame = function() { | |
document.getElementById('startScreen').style.display = 'none'; | |
document.body.requestPointerLock(); | |
if (!window.gameInstance) { | |
window.gameInstance = new Game(); | |
} | |
// κ²μ μμ μ€μ | |
window.gameInstance.isStarted = true; | |
window.gameInstance.initialize(); | |
}; | |
// Initialize game | |
document.addEventListener('DOMContentLoaded', () => { | |
// κ²μ μΈμ€ν΄μ€λ§ μμ±νκ³ μ΄κΈ°νλ νμ§ μμ | |
window.gameInstance = new Game(); | |
// κΈ°λ³Έμ μΈ μ¬ μ€μ λ§ μν | |
window.gameInstance.setupScene(); | |
window.gameInstance.animate(); // λ λλ§ μμ | |
// μμ νλ©΄ νμ | |
document.getElementById('startScreen').style.display = 'block'; | |
}); |