TankWar3D / game.js
cutechicken's picture
Update game.js
accbf92 verified
raw
history blame
25.2 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 = 2000;
const TANK_HEIGHT = 0.5;
const ENEMY_GROUND_HEIGHT = 0;
const ENEMY_SCALE = 10;
const MAX_HEALTH = 1000;
const ENEMY_MOVE_SPEED = 0.1;
const ENEMY_COUNT_MAX = 5;
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 = 10;
this.lastShootTime = 0;
this.shootInterval = 1000;
this.bullets = [];
}
async initialize(scene, loader) {
try {
const bodyResult = await loader.loadAsync('/models/abramsBody.glb');
this.body = bodyResult.scene;
this.body.position.copy(this.position);
const turretResult = await loader.loadAsync('/models/abramsTurret.glb');
this.turret = turretResult.scene;
this.turretGroup.position.y = 0.2;
this.turretGroup.add(this.turret);
this.body.add(this.turretGroup);
this.body.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
this.turret.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
scene.add(this.body);
this.isLoaded = true;
} catch (error) {
console.error('Error loading tank models:', error);
this.isLoaded = false;
}
}
shoot(scene) {
const currentTime = Date.now();
if (currentTime - this.lastShootTime < this.shootInterval || this.ammo <= 0) return null;
// μ΄μ•Œ 생성
const bulletGeometry = new THREE.SphereGeometry(0.2);
const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);
// μ΄μ•Œ μ‹œμž‘ μœ„μΉ˜ (포탑 끝)
const bulletOffset = new THREE.Vector3(0, 0.5, 2);
bulletOffset.applyQuaternion(this.turret.quaternion);
bullet.position.copy(this.body.position).add(bulletOffset);
// μ΄μ•Œ 속도 (카메라/포탑 λ°©ν–₯)
const direction = new THREE.Vector3(0, 0, 1);
direction.applyQuaternion(this.turret.quaternion);
bullet.velocity = direction.multiplyScalar(2);
scene.add(bullet);
this.bullets.push(bullet);
this.ammo--;
this.lastShootTime = currentTime;
// UI μ—…λ°μ΄νŠΈ
document.getElementById('ammo').textContent = `Ammo: ${this.ammo}/10`;
return bullet;
}
update(mouseX, mouseY) {
if (!this.body || !this.turretGroup) return;
const targetAngle = Math.atan2(mouseX, mouseY);
const currentRotation = this.turretGroup.rotation.y;
const rotationDiff = targetAngle - currentRotation;
let normalizedDiff = rotationDiff;
while (normalizedDiff > Math.PI) normalizedDiff -= Math.PI * 2;
while (normalizedDiff < -Math.PI) normalizedDiff += Math.PI * 2;
this.turretGroup.rotation.y += normalizedDiff * 0.1;
// μ΄μ•Œ μ—…λ°μ΄νŠΈ
for (let i = this.bullets.length - 1; i >= 0; i--) {
const bullet = this.bullets[i];
bullet.position.add(bullet.velocity);
// μ΄μ•Œμ΄ 맡 λ°–μœΌλ‘œ λ‚˜κ°€λ©΄ 제거
if (Math.abs(bullet.position.x) > MAP_SIZE/2 ||
Math.abs(bullet.position.z) > MAP_SIZE/2) {
scene.remove(bullet);
this.bullets.splice(i, 1);
}
}
}
move(direction) {
if (!this.body) return;
const moveVector = new THREE.Vector3();
moveVector.x = direction.x * this.moveSpeed;
moveVector.z = direction.z * this.moveSpeed;
moveVector.applyEuler(this.body.rotation);
this.body.position.add(moveVector);
}
rotate(angle) {
if (!this.body) return;
this.body.rotation.y += angle * this.turnSpeed;
}
getPosition() {
return this.body ? this.body.position : new THREE.Vector3();
}
takeDamage(damage) {
this.health -= damage;
return this.health <= 0;
}
}
// Enemy 클래슀 μˆ˜μ •
class Enemy {
constructor(scene, position, type = 'tank') {
this.scene = scene;
this.position = position;
this.mesh = null;
this.type = type; // 'tank' λ˜λŠ” 'heavy'
this.health = type === 'tank' ? 100 : 200; // heavyλŠ” 체λ ₯이 더 λ†’μŒ
this.lastAttackTime = 0;
this.bullets = [];
this.isLoaded = false;
this.moveSpeed = type === 'tank' ? ENEMY_MOVE_SPEED : ENEMY_MOVE_SPEED * 0.7; // heavyλŠ” 더 느림
}
async initialize(loader) {
try {
// νƒ€μž…μ— 따라 λ‹€λ₯Έ λͺ¨λΈ λ‘œλ“œ
const modelPath = this.type === 'tank' ? '/models/enemy1.glb' : '/models/enemy4.glb';
const result = await loader.loadAsync(modelPath);
this.mesh = result.scene;
this.mesh.position.copy(this.position);
this.mesh.scale.set(ENEMY_SCALE, ENEMY_SCALE, ENEMY_SCALE);
this.mesh.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
this.scene.add(this.mesh);
this.isLoaded = true;
} catch (error) {
console.error('Error loading enemy model:', error);
this.isLoaded = false;
}
}
update(playerPosition) {
if (!this.mesh || !this.isLoaded) return;
// ν”Œλ ˆμ΄μ–΄ λ°©ν–₯으둜 νšŒμ „
const direction = new THREE.Vector3()
.subVectors(playerPosition, this.mesh.position)
.normalize();
this.mesh.lookAt(playerPosition);
// ν”Œλ ˆμ΄μ–΄ λ°©ν–₯으둜 이동 (νƒ€μž…μ— 따라 λ‹€λ₯Έ 속도)
this.mesh.position.add(direction.multiplyScalar(this.moveSpeed));
// μ΄μ•Œ μ—…λ°μ΄νŠΈ
for (let i = this.bullets.length - 1; i >= 0; i--) {
const bullet = this.bullets[i];
bullet.position.add(bullet.velocity);
// μ΄μ•Œμ΄ 맡 λ°–μœΌλ‘œ λ‚˜κ°€λ©΄ 제거
if (Math.abs(bullet.position.x) > MAP_SIZE ||
Math.abs(bullet.position.z) > MAP_SIZE) {
this.scene.remove(bullet);
this.bullets.splice(i, 1);
}
}
}
shoot(playerPosition) {
const currentTime = Date.now();
const attackInterval = this.type === 'tank' ?
ENEMY_CONFIG.ATTACK_INTERVAL :
ENEMY_CONFIG.ATTACK_INTERVAL * 1.5; // heavyλŠ” λ°œμ‚¬ 간격이 더 κΉ€
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() {
// κΈ°λ³Έ Three.js μ„€μ •
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
document.getElementById('gameContainer').appendChild(this.renderer.domElement);
// κ²Œμž„ μš”μ†Œ μ΄ˆκΈ°ν™”
this.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.mouse = { x: 0, y: 0 };
this.keys = {
forward: false,
backward: false,
left: false,
right: false
};
// 이벀트 λ¦¬μŠ€λ„ˆ μ„€μ •
this.setupEventListeners();
this.initialize();
}
async initialize() {
try {
// μ‘°λͺ… μ„€μ •
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 50, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
this.scene.add(directionalLight);
// μ§€ν˜• 생성
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE),
new THREE.MeshStandardMaterial({
color: 0x333333,
roughness: 0.9,
metalness: 0.1
})
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this.scene.add(ground);
// 건물 생성
await this.createBuildings();
// 탱크 μ΄ˆκΈ°ν™”
await this.tank.initialize(this.scene, this.loader);
if (!this.tank.isLoaded) {
throw new Error('Tank loading failed');
}
// FPS 카메라 μ„€μ •
this.camera.position.set(0, 2, 0);
this.controls = new PointerLockControls(this.camera, document.body);
// λ‘œλ”© μ™„λ£Œ
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();
}
}
setupEventListeners() {
// ν‚€λ³΄λ“œ 이벀트
document.addEventListener('keydown', (event) => {
if (this.isLoading) 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;
case 'Escape':
if (this.controls.isLocked) {
this.controls.unlock();
}
break;
}
});
document.addEventListener('keyup', (event) => {
if (this.isLoading) 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('click', () => {
if (document.pointerLockElement !== document.body) {
this.controls.lock();
} else {
const bullet = this.tank.shoot(this.scene);
if (bullet) {
// μ΄μ•Œ λ°œμ‚¬ νš¨κ³ΌμŒμ΄λ‚˜ μ‹œκ°νš¨κ³Ό μΆ”κ°€ κ°€λŠ₯
}
}
});
// μ°½ 크기 λ³€κ²½ 이벀트
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.controls.isLocked) return;
const direction = new THREE.Vector3();
const cameraDirection = new THREE.Vector3();
this.camera.getWorldDirection(cameraDirection);
if (this.keys.forward) direction.z += 1;
if (this.keys.backward) direction.z -= 1;
if (this.keys.left) direction.x -= 1;
if (this.keys.right) direction.x += 1;
if (direction.length() > 0) {
direction.normalize();
// 카메라 λ°©ν–₯을 κΈ°μ€€μœΌλ‘œ 이동
direction.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.camera.rotation.y);
this.tank.move(direction);
// 탱크 본체λ₯Ό 카메라 λ°©ν–₯으둜 νšŒμ „
if (Math.abs(direction.z) > 0 || Math.abs(direction.x) > 0) {
this.tank.body.rotation.y = Math.atan2(direction.x, direction.z);
}
}
// 카메라와 탱크 동기화
const tankPos = this.tank.getPosition();
this.camera.position.x = tankPos.x;
this.camera.position.z = tankPos.z;
this.camera.position.y = tankPos.y + 2;
// 포탑 νšŒμ „μ„ 카메라와 동기화
if (this.tank.turret) {
this.tank.turret.rotation.y = this.camera.rotation.y;
}
}
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);
}
}
}
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>
`;
}
}
spawnEnemies() {
const spawnEnemy = () => {
if (this.enemies.length < ENEMY_COUNT_MAX && !this.isGameOver) {
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);
}
}
setTimeout(spawnEnemy, 3000);
};
spawnEnemy();
}
getValidEnemySpawnPosition() {
const margin = 20;
let position;
let attempts = 0;
const maxAttempts = 50;
do {
position = new THREE.Vector3(
(Math.random() - 0.5) * (MAP_SIZE - margin * 2),
ENEMY_GROUND_HEIGHT,
(Math.random() - 0.5) * (MAP_SIZE - margin * 2)
);
const distanceToPlayer = position.distanceTo(this.tank.getPosition());
if (distanceToPlayer < 100) continue;
let collisionFound = false;
for (const building of this.buildings) {
const buildingBox = new THREE.Box3().setFromObject(building);
if (buildingBox.containsPoint(position)) {
collisionFound = true;
break;
}
}
if (!collisionFound) return position;
attempts++;
} while (attempts < maxAttempts);
return null;
}
startGameTimer() {
const timer = setInterval(() => {
if (this.isLoading) return;
this.gameTime--;
if (this.gameTime <= 0 || this.isGameOver) {
clearInterval(timer);
this.endGame();
}
}, 1000);
}
updateParticles() {
for (let i = this.particles.length - 1; i >= 0; i--) {
const particle = this.particles[i];
if (!particle.update()) {
particle.destroy(this.scene);
this.particles.splice(i, 1);
}
}
}
createExplosion(position) {
for (let i = 0; i < PARTICLE_COUNT; i++) {
this.particles.push(new Particle(this.scene, position));
}
}
checkCollisions() {
if (this.isLoading || !this.tank.isLoaded) return;
const tankPosition = this.tank.getPosition();
this.enemies.forEach(enemy => {
if (!enemy.mesh || !enemy.isLoaded) return;
enemy.bullets.forEach(bullet => {
const distance = bullet.position.distanceTo(tankPosition);
if (distance < 1) {
if (this.tank.takeDamage(10)) {
this.endGame();
}
this.scene.remove(bullet);
enemy.bullets = enemy.bullets.filter(b => b !== bullet);
this.createExplosion(bullet.position);
document.getElementById('health').style.width =
`${(this.tank.health / MAX_HEALTH) * 100}%`;
}
});
});
const tankBoundingBox = new THREE.Box3().setFromObject(this.tank.body);
for (const building of this.buildings) {
const buildingBox = new THREE.Box3().setFromObject(building);
if (tankBoundingBox.intersectsBox(buildingBox)) {
this.tank.body.position.copy(this.previousTankPosition);
break;
}
}
this.previousTankPosition = this.tank.body.position.clone();
}
endGame() {
this.isGameOver = true;
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 = 'white';
gameOverDiv.style.fontSize = '48px';
gameOverDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
gameOverDiv.style.padding = '20px';
gameOverDiv.style.borderRadius = '10px';
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: #4CAF50; border: none;
color: white; border-radius: 5px;">
Play Again
</button>
`;
document.body.appendChild(gameOverDiv);
}
animate() {
if (this.isGameOver) return;
requestAnimationFrame(() => this.animate());
const currentTime = performance.now();
const deltaTime = (currentTime - this.lastTime) / 1000;
this.lastTime = currentTime;
if (this.isLoading) {
this.renderer.render(this.scene, this.camera);
return;
}
this.handleMovement();
this.tank.update(this.mouse.x, this.mouse.y);
const tankPosition = this.tank.getPosition();
this.enemies.forEach(enemy => {
enemy.update(tankPosition);
if (enemy.isLoaded && enemy.mesh.position.distanceTo(tankPosition) < ENEMY_CONFIG.ATTACK_RANGE) {
enemy.shoot(tankPosition);
}
});
this.updateParticles();
this.checkCollisions();
this.updateUI();
this.renderer.render(this.scene, this.camera);
}
updateUI() {
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}`;
}
}
}
// HTML의 startGame ν•¨μˆ˜μ™€ μ—°κ²°
window.startGame = function() {
document.getElementById('startScreen').style.display = 'none';
document.body.requestPointerLock();
};
// κ²Œμž„ μΈμŠ€ν„΄μŠ€ 생성
const game = new Game();