TankWar3D / game.js
cutechicken's picture
Update game.js
b02c095 verified
raw
history blame
68.5 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 = 1000;
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 = 30;
const ENEMY_CONFIG = {
ATTACK_RANGE: 100,
ATTACK_INTERVAL: 2000,
BULLET_SPEED: 2
};
// TankPlayer 클래슀
class TankPlayer {
constructor() {
this.body = null;
this.turret = null;
this.position = new THREE.Vector3(0, 0, 0);
this.rotation = new THREE.Euler(0, 0, 0);
this.turretRotation = 0;
this.moveSpeed = 0.5;
this.turnSpeed = 0.03;
this.turretGroup = new THREE.Group();
this.health = MAX_HEALTH;
this.isLoaded = false;
this.ammo = 1; // λ³€κ²½: 10 -> 1
this.maxAmmo = 1; // μΆ”κ°€: μ΅œλŒ€ 포탄 수
this.isReloading = false; // μΆ”κ°€: 재μž₯μ „ μƒνƒœ
this.reloadTime = 3000; // μΆ”κ°€: 3초 재μž₯μ „ μ‹œκ°„
this.lastShootTime = 0;
this.bullets = [];
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.moveSpeed = type === 'tank' ? ENEMY_MOVE_SPEED : ENEMY_MOVE_SPEED * 0.7;
}
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;
}
}
update(playerPosition) {
if (!this.mesh || !this.isLoaded) return;
const direction = new THREE.Vector3()
.subVectors(playerPosition, this.mesh.position)
.normalize();
const distanceToPlayer = this.mesh.position.distanceTo(playerPosition);
const minDistance = 50;
// 이전 μœ„μΉ˜ μ €μž₯
const previousPosition = this.mesh.position.clone();
if (distanceToPlayer > minDistance) {
const moveVector = direction.multiplyScalar(this.moveSpeed);
const newPosition = this.mesh.position.clone().add(moveVector);
// μ§€ν˜• 높이 κ°€μ Έμ˜€κΈ°
const heightAtNewPos = window.gameInstance.getHeightAtPosition(
newPosition.x,
newPosition.z
);
newPosition.y = heightAtNewPos + TANK_HEIGHT;
// μž„μ‹œλ‘œ μœ„μΉ˜ μ΄λ™ν•˜μ—¬ 좩돌 체크
const originalPosition = this.mesh.position.clone();
this.mesh.position.copy(newPosition);
// μž₯μ• λ¬Όκ³Ό 좩돌 체크
const enemyBox = new THREE.Box3().setFromObject(this.mesh);
let hasCollision = false;
// λͺ¨λ“  μž₯애물에 λŒ€ν•΄ 좩돌 검사
for (const obstacle of window.gameInstance.obstacles) {
const obstacleBox = new THREE.Box3().setFromObject(obstacle);
if (enemyBox.intersectsBox(obstacleBox)) {
hasCollision = true;
break;
}
}
// λ‹€λ₯Έ 적 νƒ±ν¬μ™€μ˜ 좩돌 검사
if (!hasCollision) {
for (const otherEnemy of window.gameInstance.enemies) {
if (otherEnemy !== this && otherEnemy.mesh) {
const otherEnemyBox = new THREE.Box3().setFromObject(otherEnemy.mesh);
if (enemyBox.intersectsBox(otherEnemyBox)) {
hasCollision = true;
break;
}
}
}
}
// 맡 경계 체크
const mapBoundary = MAP_SIZE / 2;
if (Math.abs(newPosition.x) > mapBoundary ||
Math.abs(newPosition.z) > mapBoundary) {
hasCollision = true;
}
// 좩돌이 있으면 이전 μœ„μΉ˜λ‘œ 볡귀, μ—†μœΌλ©΄ μƒˆ μœ„μΉ˜ μœ μ§€
if (hasCollision) {
this.mesh.position.copy(previousPosition);
// 좩돌 μ‹œ 우회 경둜 μ‹œλ„
const alternateDirections = [
new THREE.Vector3(-direction.z, 0, direction.x), // μ™Όμͺ½μœΌλ‘œ 90도
new THREE.Vector3(direction.z, 0, -direction.x), // 였λ₯Έμͺ½μœΌλ‘œ 90도
new THREE.Vector3(-direction.x, 0, -direction.z) // 180도 νšŒμ „
];
for (const altDirection of alternateDirections) {
const altMoveVector = altDirection.multiplyScalar(this.moveSpeed);
const altNewPosition = previousPosition.clone().add(altMoveVector);
this.mesh.position.copy(altNewPosition);
const altEnemyBox = new THREE.Box3().setFromObject(this.mesh);
let altHasCollision = false;
// μƒˆλ‘œμš΄ λ°©ν–₯에 λŒ€ν•œ 좩돌 검사
for (const obstacle of window.gameInstance.obstacles) {
const obstacleBox = new THREE.Box3().setFromObject(obstacle);
if (altEnemyBox.intersectsBox(obstacleBox)) {
altHasCollision = true;
break;
}
}
if (!altHasCollision) {
// 우회 κ²½λ‘œκ°€ κ°€λŠ₯ν•˜λ©΄ κ·Έ λ°©ν–₯으둜 이동
break;
} else {
// μš°νšŒλ„ λΆˆκ°€λŠ₯ν•˜λ©΄ 이전 μœ„μΉ˜λ‘œ 볡귀
this.mesh.position.copy(previousPosition);
}
}
}
// μ§€ν˜•μ— λ”°λ₯Έ 기울기 μ‘°μ •
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);
}
// ν”Œλ ˆμ΄μ–΄λ₯Ό ν–₯ν•΄ 포탑 νšŒμ „
this.mesh.lookAt(playerPosition);
// μ΄μ•Œ μ—…λ°μ΄νŠΈ
if (this.bullets) {
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;
}
}
}
}
}
shoot(playerPosition) {
const currentTime = Date.now();
const attackInterval = this.type === 'tank' ?
ENEMY_CONFIG.ATTACK_INTERVAL :
ENEMY_CONFIG.ATTACK_INTERVAL * 1.5;
if (currentTime - this.lastAttackTime < attackInterval) return;
const bulletGeometry = new THREE.SphereGeometry(this.type === 'tank' ? 0.2 : 0.3);
const bulletMaterial = new THREE.MeshBasicMaterial({
color: this.type === 'tank' ? 0xff0000 : 0xff6600
});
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);
bullet.position.copy(this.mesh.position);
const direction = new THREE.Vector3()
.subVectors(playerPosition, this.mesh.position)
.normalize();
const bulletSpeed = this.type === 'tank' ?
ENEMY_CONFIG.BULLET_SPEED :
ENEMY_CONFIG.BULLET_SPEED * 0.8;
bullet.velocity = direction.multiplyScalar(bulletSpeed);
this.scene.add(bullet);
this.bullets.push(bullet);
this.lastAttackTime = currentTime;
}
takeDamage(damage) {
this.health -= damage;
return this.health <= 0;
}
destroy() {
if (this.mesh) {
this.scene.remove(this.mesh);
this.bullets.forEach(bullet => this.scene.remove(bullet));
this.bullets = [];
this.isLoaded = false;
}
}
}
// Particle 클래슀
class Particle {
constructor(scene, position) {
const geometry = new THREE.SphereGeometry(0.1);
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.position.copy(position);
this.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 0.3,
Math.random() * 0.2,
(Math.random() - 0.5) * 0.3
);
this.gravity = -0.01;
this.lifetime = 60;
this.age = 0;
scene.add(this.mesh);
}
update() {
this.velocity.y += this.gravity;
this.mesh.position.add(this.velocity);
this.age++;
return this.age < this.lifetime;
}
destroy(scene) {
scene.remove(this.mesh);
}
}
// Game 클래슀
class Game {
constructor() {
// κ²Œμž„ μ‹œμž‘ μ—¬λΆ€λ₯Ό μΆ”μ ν•˜λŠ” ν”Œλž˜κ·Έ μΆ”κ°€
this.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 μΆ”κ°€
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 = []; // 배열이 μ—†μœΌλ©΄ μ΄ˆκΈ°ν™”
}
// λ°”μœ„ 생성 개수λ₯Ό 70개둜 μ€„μž„
const ROCK_COUNT = 70;
// λ°”μœ„ μ§€μ˜€λ©”νŠΈλ¦¬ μ •μ˜
const rockGeometries = [
new THREE.DodecahedronGeometry(3),
new THREE.DodecahedronGeometry(2),
new THREE.DodecahedronGeometry(4)
];
const rockMaterial = new THREE.MeshStandardMaterial({
color: 0x8B4513,
roughness: 0.9,
metalness: 0.1
});
// 좩돌 λ°•μŠ€ μ‹œκ°ν™”μš© 재질 (λ””λ²„κΉ…μš©)
const collisionBoxMaterial = new THREE.MeshBasicMaterial({
color: 0xff0000,
wireframe: true,
visible: false // ν•„μš”μ‹œ true둜 λ³€κ²½ν•˜μ—¬ 좩돌 λ°•μŠ€ 확인
});
for (let i = 0; i < ROCK_COUNT; i++) {
const rockGeometry = rockGeometries[Math.floor(Math.random() * rockGeometries.length)];
const rock = new THREE.Mesh(rockGeometry, rockMaterial);
// λ°”μœ„ μœ„μΉ˜ μ„€μ • - 맡 κ°€μž₯μžλ¦¬μ— 더 많이 배치
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;
}
rock.position.set(x, Math.random() * 2, z);
rock.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
Math.random() * Math.PI
);
// λ°”μœ„ 크기λ₯Ό μ’€ 더 λ‹€μ–‘ν•˜κ²Œ μ„€μ •
const scale = 1 + Math.random() * 1.5; // 1.0 ~ 2.5 μ‚¬μ΄μ˜ 크기
rock.scale.set(scale, scale, scale);
// 좩돌 λ°•μŠ€ 생성 및 μ„€μ •
const boundingBox = new THREE.Box3().setFromObject(rock);
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(rock.position);
collisionMesh.rotation.copy(rock.rotation);
// λͺ¨λ“  λ°”μœ„λ₯Ό 좩돌 κ°€λŠ₯ν•˜κ²Œ μ„€μ •
rock.userData.isCollidable = true;
rock.userData.type = 'rock';
rock.userData.collisionMesh = collisionMesh;
rock.castShadow = true;
rock.receiveShadow = true;
// λ‹€λ₯Έ λ°”μœ„λ“€κ³Όμ˜ 거리 체크
let tooClose = false;
for (const obstacle of this.obstacles) {
const distance = rock.position.distanceTo(obstacle.position);
if (distance < 15) { // μ΅œμ†Œ 거리 μ„€μ •
tooClose = true;
break;
}
}
if (!tooClose) {
this.obstacles.push(rock);
this.scene.add(rock);
this.scene.add(collisionMesh);
}
}
// 선인μž₯ μΆ”κ°€
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);
// 선인μž₯은 obstacles 배열에 μΆ”κ°€ν•˜μ§€ μ•ŠμŒ
}
}
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 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();
}
this.scene.remove(bullet);
enemy.bullets.splice(bulletIndex, 1); // filter λŒ€μ‹  splice μ‚¬μš©
this.createExplosion(bullet.position);
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 distance = bullet.position.distanceTo(enemy.mesh.position);
if (distance < 2) {
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}`;
}
}
}
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.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';
});