TankWar3D / game.js
cutechicken's picture
Update game.js
af4bb6b verified
raw
history blame
86.6 kB
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';
});