TankWar3D / game.js
cutechicken's picture
Update game.js
2ce947b verified
raw
history blame
45.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 = 1000;
const TANK_HEIGHT = 0.5;
const ENEMY_GROUND_HEIGHT = 0;
const ENEMY_SCALE = 10;
const MAX_HEALTH = 1000;
const ENEMY_MOVE_SPEED = 0.1;
const ENEMY_COUNT_MAX = 3;
const PARTICLE_COUNT = 15;
const BUILDING_COUNT = 30;
const ENEMY_CONFIG = {
ATTACK_RANGE: 100,
ATTACK_INTERVAL: 2000,
BULLET_SPEED: 2
};
// TankPlayer ํด๋ž˜์Šค
class TankPlayer {
constructor() {
this.body = null;
this.turret = null;
this.position = new THREE.Vector3(0, 0, 0);
this.rotation = new THREE.Euler(0, 0, 0);
this.turretRotation = 0;
this.moveSpeed = 0.5;
this.turnSpeed = 0.03;
this.turretGroup = new THREE.Group();
this.health = MAX_HEALTH;
this.isLoaded = false;
this.ammo = 1; // ๋ณ€๊ฒฝ: 10 -> 1
this.maxAmmo = 1; // ์ถ”๊ฐ€: ์ตœ๋Œ€ ํฌํƒ„ ์ˆ˜
this.isReloading = false; // ์ถ”๊ฐ€: ์žฌ์žฅ์ „ ์ƒํƒœ
this.reloadTime = 3000; // ์ถ”๊ฐ€: 3์ดˆ ์žฌ์žฅ์ „ ์‹œ๊ฐ„
this.lastShootTime = 0;
this.bullets = [];
}
async initialize(scene, loader) {
try {
const bodyResult = await loader.loadAsync('/models/abramsBody.glb');
this.body = bodyResult.scene;
const turretResult = await loader.loadAsync('/models/abramsTurret.glb');
this.turret = turretResult.scene;
this.turretGroup.position.y = 0.2;
this.turretGroup.add(this.turret);
this.body.add(this.turretGroup);
this.body.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
this.turret.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
// ์—ฌ๊ธฐ์„œ ์œ ํšจํ•œ ์Šคํฐ ์œ„์น˜๋ฅผ ์ฐพ์•„ ์ ์šฉ
if (window.gameInstance) {
const spawnPos = window.gameInstance.findValidSpawnPosition();
this.body.position.copy(spawnPos);
} else {
this.body.position.copy(this.position);
}
// ํญ๋ฐœ ์ดํŽ™ํŠธ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
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 sounds = ['sounds/mbtfire1.ogg', 'sounds/mbtfire2.ogg', 'sounds/mbtfire3.ogg', 'sounds/mbtfire4.ogg'];
const randomSound = sounds[Math.floor(Math.random() * sounds.length)];
const audio = new Audio(randomSound);
audio.volume = 0.5;
audio.play();
const bullet = this.createBullet(scene);
if (bullet) {
this.ammo--;
this.updateAmmoDisplay();
this.startReload();
}
return bullet;
}
createBullet(scene) {
const bulletGeometry = new THREE.SphereGeometry(0.2);
const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);
const bulletOffset = new THREE.Vector3(0, 0.5, 2);
bulletOffset.applyQuaternion(this.turretGroup.quaternion);
bulletOffset.applyQuaternion(this.body.quaternion);
bullet.position.copy(this.body.position).add(bulletOffset);
const direction = new THREE.Vector3(0, 0, 1);
direction.applyQuaternion(this.turretGroup.quaternion);
direction.applyQuaternion(this.body.quaternion);
bullet.velocity = direction.multiplyScalar(2);
scene.add(bullet);
this.bullets.push(bullet);
return bullet;
}
update(mouseX, mouseY, scene) {
if (!this.body || !this.turretGroup) return;
const absoluteTurretRotation = mouseX;
this.turretGroup.rotation.y = absoluteTurretRotation - this.body.rotation.y;
this.turretRotation = absoluteTurretRotation;
// ์ด์•Œ ์—…๋ฐ์ดํŠธ ๋ฐ ์ถฉ๋Œ ์ฒดํฌ
for (let i = this.bullets.length - 1; i >= 0; i--) {
const bullet = this.bullets[i];
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/enemy1.glb' : '/models/enemy4.glb';
const result = await loader.loadAsync(modelPath);
this.mesh = result.scene;
this.mesh.position.copy(this.position);
this.mesh.scale.set(ENEMY_SCALE, ENEMY_SCALE, ENEMY_SCALE);
this.mesh.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
this.scene.add(this.mesh);
this.isLoaded = true;
} catch (error) {
console.error('Error loading enemy model:', error);
this.isLoaded = false;
}
}
update(playerPosition) {
if (!this.mesh || !this.isLoaded) return;
const direction = new THREE.Vector3()
.subVectors(playerPosition, this.mesh.position)
.normalize();
const distanceToPlayer = this.mesh.position.distanceTo(playerPosition);
const minDistance = 50;
this.mesh.lookAt(playerPosition);
if (distanceToPlayer > minDistance) {
const moveVector = direction.multiplyScalar(this.moveSpeed);
const newPosition = this.mesh.position.clone().add(moveVector);
// ์ƒˆ๋กœ์šด ์œ„์น˜์˜ ์ง€ํ˜• ๋†’์ด ๊ฐ€์ ธ์˜ค๊ธฐ
const heightAtNewPos = window.gameInstance.getHeightAtPosition(newPosition.x, newPosition.z);
newPosition.y = heightAtNewPos + TANK_HEIGHT;
// ๊ฒฝ์‚ฌ ์ฒดํฌ
const heightDifference = Math.abs(newPosition.y - this.mesh.position.y);
const maxClimbAngle = 0.5;
if (heightDifference / this.moveSpeed < maxClimbAngle) {
this.mesh.position.copy(newPosition);
// ์  ํƒฑํฌ ๊ธฐ์šธ๊ธฐ ์กฐ์ •
const forwardVector = new THREE.Vector3(0, 0, 1).applyQuaternion(this.mesh.quaternion);
const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(this.mesh.quaternion);
const frontHeight = window.gameInstance.getHeightAtPosition(
newPosition.x + forwardVector.x,
newPosition.z + forwardVector.z
);
const backHeight = window.gameInstance.getHeightAtPosition(
newPosition.x - forwardVector.x,
newPosition.z - forwardVector.z
);
const rightHeight = window.gameInstance.getHeightAtPosition(
newPosition.x + rightVector.x,
newPosition.z + rightVector.z
);
const leftHeight = window.gameInstance.getHeightAtPosition(
newPosition.x - rightVector.x,
newPosition.z - rightVector.z
);
const pitch = Math.atan2(frontHeight - backHeight, 2);
const roll = Math.atan2(rightHeight - leftHeight, 2);
this.mesh.rotation.x = pitch;
this.mesh.rotation.z = roll;
}
}
// ์ด์•Œ ์—…๋ฐ์ดํŠธ ๋ถ€๋ถ„
for (let i = this.bullets.length - 1; i >= 0; i--) {
const bullet = this.bullets[i];
bullet.position.add(bullet.velocity);
if (Math.abs(bullet.position.x) > MAP_SIZE ||
Math.abs(bullet.position.z) > MAP_SIZE) {
this.scene.remove(bullet);
this.bullets.splice(i, 1);
}
}
}
shoot(playerPosition) {
const currentTime = Date.now();
const attackInterval = this.type === 'tank' ?
ENEMY_CONFIG.ATTACK_INTERVAL :
ENEMY_CONFIG.ATTACK_INTERVAL * 1.5;
if (currentTime - this.lastAttackTime < attackInterval) return;
const bulletGeometry = new THREE.SphereGeometry(this.type === 'tank' ? 0.2 : 0.3);
const bulletMaterial = new THREE.MeshBasicMaterial({
color: this.type === 'tank' ? 0xff0000 : 0xff6600
});
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);
bullet.position.copy(this.mesh.position);
const direction = new THREE.Vector3()
.subVectors(playerPosition, this.mesh.position)
.normalize();
const bulletSpeed = this.type === 'tank' ?
ENEMY_CONFIG.BULLET_SPEED :
ENEMY_CONFIG.BULLET_SPEED * 0.8;
bullet.velocity = direction.multiplyScalar(bulletSpeed);
this.scene.add(bullet);
this.bullets.push(bullet);
this.lastAttackTime = currentTime;
}
takeDamage(damage) {
this.health -= damage;
return this.health <= 0;
}
destroy() {
if (this.mesh) {
this.scene.remove(this.mesh);
this.bullets.forEach(bullet => this.scene.remove(bullet));
this.bullets = [];
this.isLoaded = false;
}
}
}
// Particle ํด๋ž˜์Šค
class Particle {
constructor(scene, position) {
const geometry = new THREE.SphereGeometry(0.1);
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.position.copy(position);
this.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 0.3,
Math.random() * 0.2,
(Math.random() - 0.5) * 0.3
);
this.gravity = -0.01;
this.lifetime = 60;
this.age = 0;
scene.add(this.mesh);
}
update() {
this.velocity.y += this.gravity;
this.mesh.position.add(this.velocity);
this.age++;
return this.age < this.lifetime;
}
destroy(scene) {
scene.remove(this.mesh);
}
}
// Game ํด๋ž˜์Šค
class Game {
constructor() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
document.getElementById('gameContainer').appendChild(this.renderer.domElement);
// ๋ ˆ์ด๋” ๊ด€๋ จ ์†์„ฑ ์ถ”๊ฐ€
this.radarUpdateInterval = 100; // 100ms๋งˆ๋‹ค ๋ ˆ์ด๋” ์—…๋ฐ์ดํŠธ
this.lastRadarUpdate = 0;
this.radarRange = 200; // ๋ ˆ์ด๋” ๊ฐ์ง€ ๋ฒ”์œ„
this.tank = new TankPlayer();
this.enemies = [];
this.particles = [];
this.buildings = [];
this.loader = new GLTFLoader();
this.controls = null;
this.gameTime = GAME_DURATION;
this.score = 0;
this.isGameOver = false;
this.isLoading = true;
this.previousTankPosition = new THREE.Vector3();
this.lastTime = performance.now();
this.gameTimer = null;
this.animationFrameId = null;
this.mouse = { x: 0, y: 0 };
this.keys = {
forward: false,
backward: false,
left: false,
right: false
};
this.setupEventListeners();
this.initialize();
}
async initialize() {
try {
// ์•ˆ๊ฐœ ํšจ๊ณผ ์ œ๊ฑฐ
this.scene.fog = null;
this.scene.background = new THREE.Color(0x87CEEB);
// ์ฃผ๋ณ€๊ด‘ ์„ค์ • - ๋” ๋ฐ๊ฒŒ
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
// ํƒœ์–‘๊ด‘ ์„ค์ •
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(100, 100, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
this.scene.add(directionalLight);
// ์ง€ํ˜• ์ƒ์„ฑ ์ˆ˜์ •
const groundGeometry = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE, 100, 100);
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0xD2B48C,
roughness: 0.8,
metalness: 0.2,
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
// ์ง€ํ˜• ๋†’์ด ์„ค์ • (๊ธฐ์กด ์ง€ํ˜• ์ˆ˜์ • ์ฝ”๋“œ ํฌํ•จ)
const vertices = ground.geometry.attributes.position.array;
const heightScale = 15; // ๋†’์ด ์Šค์ผ€์ผ
const baseFrequency = 0.008; // ๊ธฐ๋ณธ ์ฃผํŒŒ์ˆ˜
for (let i = 0; i < vertices.length; i += 3) {
const x = vertices[i];
const z = vertices[i + 1];
const height = Math.sin(x * baseFrequency) * Math.cos(z * baseFrequency) * heightScale;
vertices[i + 2] = height;
}
ground.geometry.attributes.position.needsUpdate = true;
ground.geometry.computeVertexNormals();
// EdgesGeometry๋กœ ์„  ์ถ”๊ฐ€
const edgesGeometry = new THREE.EdgesGeometry(ground.geometry);
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
const contourLines = new THREE.LineSegments(edgesGeometry, lineMaterial);
// ์ง€ํ˜• ๋ฐ ์„  ์ถ”๊ฐ€
this.scene.add(ground);
this.scene.add(contourLines);
// ๋” ๋‹ค์–‘ํ•œ ์ง€ํ˜• ์ƒ์„ฑ
const vertices = ground.geometry.attributes.position.array;
const heightScale = 15; // ๋†’์ด ์Šค์ผ€์ผ
const baseFrequency = 0.008; // ๊ธฐ๋ณธ ์ฃผํŒŒ์ˆ˜
for (let i = 0; i < vertices.length; i += 3) {
const x = vertices[i];
const y = vertices[i + 1];
// ์—ฌ๋Ÿฌ ์ฃผํŒŒ์ˆ˜๋ฅผ ์กฐํ•ฉํ•œ ์ง€ํ˜• ์ƒ์„ฑ
let height = 0;
// ํฐ ์–ธ๋• (๊ธฐ๋ณธ ์ง€ํ˜•)
height += Math.sin(x * baseFrequency) * Math.cos(y * baseFrequency) * heightScale;
// ์ค‘๊ฐ„ ํฌ๊ธฐ ์ง€ํ˜•
height += Math.sin(x * baseFrequency * 2) * Math.cos(y * baseFrequency * 2) * (heightScale * 0.5);
// ์ž‘์€ ๊ตด๊ณก
height += Math.sin(x * baseFrequency * 4) * Math.cos(y * baseFrequency * 4) * (heightScale * 0.25);
// ๋žœ๋ค ๋…ธ์ด์ฆˆ ์ถ”๊ฐ€ (์•„์ฃผ ์ž‘์€ ๊ตด๊ณก)
const noise = (Math.random() - 0.5) * heightScale * 0.1;
height += noise;
// ๋งต ๊ฐ€์žฅ์ž๋ฆฌ๋กœ ๊ฐˆ์ˆ˜๋ก ๋†’์ด๋ฅผ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ๊ฐ์†Œ
const distanceFromCenter = Math.sqrt(x * x + y * y) / (MAP_SIZE * 0.5);
const edgeFactor = Math.pow(Math.max(0, 1 - distanceFromCenter), 2); // ๋ถ€๋“œ๋Ÿฌ์šด ๊ฐ์‡ 
// ์ตœ์ข… ๋†’์ด ์„ค์ •
vertices[i + 2] = height * edgeFactor;
}
ground.geometry.attributes.position.needsUpdate = true;
ground.geometry.computeVertexNormals();
this.ground = ground;
this.scene.add(ground);
// ์‚ฌ๋ง‰ ์žฅ์‹ ์ถ”๊ฐ€
await this.addDesertDecorations();
// ํƒฑํฌ ์ดˆ๊ธฐํ™”
await this.tank.initialize(this.scene, this.loader);
if (!this.tank.isLoaded) {
throw new Error('Tank loading failed');
}
// ์นด๋ฉ”๋ผ ์ดˆ๊ธฐ ์œ„์น˜ ์„ค์ •
const tankPosition = this.tank.getPosition();
this.camera.position.set(
tankPosition.x,
tankPosition.y + 15,
tankPosition.z - 30
);
this.camera.lookAt(tankPosition);
// ๋กœ๋”ฉ ์™„๋ฃŒ
this.isLoading = false;
document.getElementById('loading').style.display = 'none';
// ๊ฒŒ์ž„ ์‹œ์ž‘
this.animate();
this.spawnEnemies();
this.startGameTimer();
} catch (error) {
console.error('Game initialization error:', error);
this.handleLoadingError();
}
}
// ๋ ˆ์ด๋” ์—…๋ฐ์ดํŠธ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
updateRadar() {
const currentTime = Date.now();
if (currentTime - this.lastRadarUpdate < this.radarUpdateInterval) return;
const radar = document.getElementById('radar');
const radarRect = radar.getBoundingClientRect();
const radarCenter = {
x: radarRect.width / 2,
y: radarRect.height / 2
};
// ๊ธฐ์กด ์  ๋„ํŠธ ์ œ๊ฑฐ
const oldDots = radar.getElementsByClassName('enemy-dot');
while (oldDots[0]) {
oldDots[0].remove();
}
// ํƒฑํฌ ์œ„์น˜ ๊ฐ€์ ธ์˜ค๊ธฐ
const tankPos = this.tank.getPosition();
// ๋ชจ๋“  ์ ์— ๋Œ€ํ•ด ๋ ˆ์ด๋”์— ํ‘œ์‹œ
this.enemies.forEach(enemy => {
if (!enemy.mesh || !enemy.isLoaded) return;
const enemyPos = enemy.mesh.position;
const distance = tankPos.distanceTo(enemyPos);
// ๋ ˆ์ด๋” ๋ฒ”์œ„ ๋‚ด์— ์žˆ๋Š” ๊ฒฝ์šฐ๋งŒ ํ‘œ์‹œ
if (distance <= this.radarRange) {
// ํƒฑํฌ ๊ธฐ์ค€ ์ƒ๋Œ€ ๊ฐ๋„ ๊ณ„์‚ฐ
const angle = Math.atan2(
enemyPos.x - tankPos.x,
enemyPos.z - tankPos.z
);
// ์ƒ๋Œ€ ๊ฑฐ๋ฆฌ๋ฅผ ๋ ˆ์ด๋” ํฌ๊ธฐ์— ๋งž๊ฒŒ ์Šค์ผ€์ผ๋ง
const relativeDistance = distance / this.radarRange;
const dotX = radarCenter.x + Math.sin(angle) * (radarCenter.x * relativeDistance);
const dotY = radarCenter.y + Math.cos(angle) * (radarCenter.y * relativeDistance);
// ์  ๋„ํŠธ ์ƒ์„ฑ ๋ฐ ์ถ”๊ฐ€
const dot = document.createElement('div');
dot.className = 'enemy-dot';
dot.style.left = `${dotX}px`;
dot.style.top = `${dotY}px`;
radar.appendChild(dot);
}
});
this.lastRadarUpdate = currentTime;
}
async addDesertDecorations() {
// ๋ฐ”์œ„ ์ƒ์„ฑ
const rockGeometries = [
new THREE.DodecahedronGeometry(3),
new THREE.DodecahedronGeometry(2),
new THREE.DodecahedronGeometry(4)
];
const rockMaterial = new THREE.MeshStandardMaterial({
color: 0x8B4513,
roughness: 0.9,
metalness: 0.1
});
for (let i = 0; i < 100; i++) {
const rockGeometry = rockGeometries[Math.floor(Math.random() * rockGeometries.length)];
const rock = new THREE.Mesh(rockGeometry, rockMaterial);
rock.position.set(
(Math.random() - 0.5) * MAP_SIZE * 0.9,
Math.random() * 2,
(Math.random() - 0.5) * MAP_SIZE * 0.9
);
rock.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
Math.random() * Math.PI
);
rock.scale.set(
1 + Math.random() * 0.5,
1 + Math.random() * 0.5,
1 + Math.random() * 0.5
);
rock.castShadow = true;
rock.receiveShadow = true;
this.scene.add(rock);
}
// ์„ ์ธ์žฅ ์ถ”๊ฐ€ (๊ฐ„๋‹จํ•œ geometry๋กœ ํ‘œํ˜„)
const cactusGeometry = new THREE.CylinderGeometry(0.5, 0.7, 4, 8);
const cactusMaterial = new THREE.MeshStandardMaterial({
color: 0x2F4F2F,
roughness: 0.8
});
for (let i = 0; i < 50; i++) {
const cactus = new THREE.Mesh(cactusGeometry, cactusMaterial);
cactus.position.set(
(Math.random() - 0.5) * MAP_SIZE * 0.8,
2,
(Math.random() - 0.5) * MAP_SIZE * 0.8
);
cactus.castShadow = true;
cactus.receiveShadow = true;
this.scene.add(cactus);
}
}
getHeightAtPosition(x, z) {
if (!this.ground) return 0;
// ์ง€ํ˜•์˜ ์ •์  ๋ฐ์ดํ„ฐ
const vertices = this.ground.geometry.attributes.position.array;
const segmentsX = Math.sqrt(vertices.length / 3) - 1;
const segmentsZ = segmentsX;
// ๋งต ์ขŒํ‘œ๋ฅผ ์ง€ํ˜• ๊ฒฉ์ž ์ขŒํ‘œ๋กœ ๋ณ€ํ™˜
const gridX = ((x + MAP_SIZE / 2) / MAP_SIZE) * segmentsX;
const gridZ = ((z + MAP_SIZE / 2) / MAP_SIZE) * segmentsZ;
// ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๊ฒฉ์ž์  ์ฐพ๊ธฐ
const x1 = Math.floor(gridX);
const z1 = Math.floor(gridZ);
const x2 = Math.min(x1 + 1, segmentsX);
const z2 = Math.min(z1 + 1, segmentsZ);
// ๊ฒฉ์ž์ ๋“ค์˜ ๋†’์ด ๊ฐ€์ ธ์˜ค๊ธฐ
const getHeight = (x, z) => {
if (x < 0 || x > segmentsX || z < 0 || z > segmentsZ) return 0;
const index = (z * (segmentsX + 1) + x) * 3 + 2;
return vertices[index];
};
const h11 = getHeight(x1, z1);
const h21 = getHeight(x2, z1);
const h12 = getHeight(x1, z2);
const h22 = getHeight(x2, z2);
// ๋ณด๊ฐ„์œผ๋กœ ์ •ํ™•ํ•œ ๋†’์ด ๊ณ„์‚ฐ
const fx = gridX - x1;
const fz = gridZ - z1;
const h1 = h11 * (1 - fx) + h21 * fx;
const h2 = h12 * (1 - fx) + h22 * fx;
return h1 * (1 - fz) + h2 * fz;
}
findValidSpawnPosition() {
const margin = 50;
let position;
let attempts = 0;
const maxAttempts = 50;
const maxSlope = 0.3; // ์ตœ๋Œ€ ํ—ˆ์šฉ ๊ฒฝ์‚ฌ
while (attempts < maxAttempts) {
position = new THREE.Vector3(
(Math.random() - 0.5) * (MAP_SIZE - margin * 2),
0,
(Math.random() - 0.5) * (MAP_SIZE - margin * 2)
);
// ํ˜„์žฌ ์œ„์น˜์˜ ๋†’์ด ๊ฐ€์ ธ์˜ค๊ธฐ
const height = this.getHeightAtPosition(position.x, position.z);
position.y = height + TANK_HEIGHT;
// ์ฃผ๋ณ€ ์ง€ํ˜•์˜ ๊ฒฝ์‚ฌ ์ฒดํฌ
const checkPoints = [
{ x: position.x + 2, z: position.z },
{ x: position.x - 2, z: position.z },
{ x: position.x, z: position.z + 2 },
{ x: position.x, z: position.z - 2 }
];
const slopes = checkPoints.map(point => {
const pointHeight = this.getHeightAtPosition(point.x, point.z);
return Math.abs(pointHeight - height) / 2;
});
const maxCurrentSlope = Math.max(...slopes);
if (maxCurrentSlope <= maxSlope) {
return position;
}
attempts++;
}
// ์‹คํŒจ ์‹œ ๊ธฐ๋ณธ ์œ„์น˜ ๋ฐ˜ํ™˜
return new THREE.Vector3(0, TANK_HEIGHT, 0);
}
setupEventListeners() {
document.addEventListener('keydown', (event) => {
if (this.isLoading || this.isGameOver) return;
switch(event.code) {
case 'KeyW': this.keys.forward = true; break;
case 'KeyS': this.keys.backward = true; break;
case 'KeyA': this.keys.left = true; break;
case 'KeyD': this.keys.right = true; break;
}
});
document.addEventListener('keyup', (event) => {
if (this.isLoading || this.isGameOver) return;
switch(event.code) {
case 'KeyW': this.keys.forward = false; break;
case 'KeyS': this.keys.backward = false; break;
case 'KeyA': this.keys.left = false; break;
case 'KeyD': this.keys.right = false; break;
}
});
document.addEventListener('mousemove', (event) => {
if (this.isLoading || this.isGameOver || !document.pointerLockElement) return;
const movementX = event.movementX || event.mozMovementX || event.webkitMovementX || 0;
this.mouse.x += movementX * 0.002;
this.mouse.y = 0;
while (this.mouse.x > Math.PI) this.mouse.x -= Math.PI * 2;
while (this.mouse.x < -Math.PI) this.mouse.x += Math.PI * 2;
});
document.addEventListener('click', () => {
if (!document.pointerLockElement) {
document.body.requestPointerLock();
} else if (!this.isGameOver) {
const bullet = this.tank.shoot(this.scene);
if (bullet) {
// Shooting effects could be added here
}
}
});
document.addEventListener('pointerlockchange', () => {
if (!document.pointerLockElement) {
this.mouse.x = 0;
this.mouse.y = 0;
}
});
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
}
handleMovement() {
if (!this.tank.isLoaded || this.isGameOver) return;
const direction = new THREE.Vector3();
// ์ฐจ์ฒด ์ด๋™๊ณผ ํšŒ์ „์€ ์œ ์ง€
if (this.keys.forward) direction.z += 1;
if (this.keys.backward) direction.z -= 1;
if (this.keys.left) this.tank.rotate(-1);
if (this.keys.right) this.tank.rotate(1);
if (direction.length() > 0) {
direction.normalize();
direction.applyEuler(this.tank.body.rotation);
this.tank.move(direction);
}
// ํƒฑํฌ ์œ„์น˜ ๊ฐ€์ ธ์˜ค๊ธฐ
const tankPos = this.tank.getPosition();
// ์นด๋ฉ”๋ผ๋Š” ๋งˆ์šฐ์Šค X ํšŒ์ „์—๋งŒ ๋”ฐ๋ผ๊ฐ
const cameraDistance = 30;
const cameraHeight = 15;
const cameraAngle = this.mouse.x + Math.PI; // ํ•ญ์ƒ ํฌํƒ‘์˜ ๋’ค์ชฝ์— ์œ„์น˜
const cameraX = tankPos.x + Math.sin(cameraAngle) * cameraDistance;
const cameraZ = tankPos.z + Math.cos(cameraAngle) * cameraDistance;
this.camera.position.set(
cameraX,
tankPos.y + cameraHeight,
cameraZ
);
// ์นด๋ฉ”๋ผ๊ฐ€ ํƒฑํฌ๋ฅผ ๋ฐ”๋ผ๋ณด๋„๋ก ์„ค์ •
const lookAtPoint = new THREE.Vector3(
tankPos.x,
tankPos.y + 2,
tankPos.z
);
this.camera.lookAt(lookAtPoint);
}
createBuildings() {
const buildingTypes = [
{ width: 10, height: 30, depth: 10, color: 0x808080 },
{ width: 15, height: 40, depth: 15, color: 0x606060 },
{ width: 20, height: 50, depth: 20, color: 0x404040 }
];
for (let i = 0; i < BUILDING_COUNT; i++) {
const type = buildingTypes[Math.floor(Math.random() * buildingTypes.length)];
const building = this.createBuilding(type);
let position;
let attempts = 0;
do {
position = new THREE.Vector3(
(Math.random() - 0.5) * (MAP_SIZE - type.width),
type.height / 2,
(Math.random() - 0.5) * (MAP_SIZE - type.depth)
);
attempts++;
} while (this.checkBuildingCollision(position, type) && attempts < 50);
if (attempts < 50) {
building.position.copy(position);
this.buildings.push(building);
this.scene.add(building);
}
}
return Promise.resolve();
}
createBuilding(type) {
const geometry = new THREE.BoxGeometry(type.width, type.height, type.depth);
const material = new THREE.MeshPhongMaterial({
color: type.color,
emissive: 0x222222,
specular: 0x111111,
shininess: 30
});
const building = new THREE.Mesh(geometry, material);
building.castShadow = true;
building.receiveShadow = true;
return building;
}
checkBuildingCollision(position, type) {
const margin = 5;
const bbox = new THREE.Box3(
new THREE.Vector3(
position.x - (type.width / 2 + margin),
0,
position.z - (type.depth / 2 + margin)
),
new THREE.Vector3(
position.x + (type.width / 2 + margin),
type.height,
position.z + (type.depth / 2 + margin)
)
);
return this.buildings.some(building => {
const buildingBox = new THREE.Box3().setFromObject(building);
return bbox.intersectsBox(buildingBox);
});
}
handleLoadingError() {
this.isLoading = false;
const loadingElement = document.getElementById('loading');
if (loadingElement) {
loadingElement.innerHTML = `
<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 tankPosition = this.tank.getPosition();
// ์  ์ด์•Œ๊ณผ ํ”Œ๋ ˆ์ด์–ด ํƒฑํฌ ์ถฉ๋Œ ์ฒดํฌ
this.enemies.forEach(enemy => {
if (!enemy.mesh || !enemy.isLoaded) return;
enemy.bullets.forEach(bullet => {
const distance = bullet.position.distanceTo(tankPosition);
if (distance < 1) {
if (this.tank.takeDamage(250)) { // ๋ฐ๋ฏธ์ง€๋ฅผ 250์œผ๋กœ ์ˆ˜์ •
this.endGame();
}
this.scene.remove(bullet);
enemy.bullets = enemy.bullets.filter(b => b !== bullet);
this.createExplosion(bullet.position);
document.getElementById('health').style.width =
`${(this.tank.health / MAX_HEALTH) * 100}%`;
}
});
});
// ํ”Œ๋ ˆ์ด์–ด ์ด์•Œ๊ณผ ์  ์ถฉ๋Œ ์ฒดํฌ
this.tank.bullets.forEach((bullet, bulletIndex) => {
this.enemies.forEach((enemy, enemyIndex) => {
if (!enemy.mesh || !enemy.isLoaded) return;
const distance = bullet.position.distanceTo(enemy.mesh.position);
if (distance < 2) {
if (enemy.takeDamage(50)) {
enemy.destroy();
this.enemies.splice(enemyIndex, 1);
this.score += 100;
document.getElementById('score').textContent = `Score: ${this.score}`;
}
this.scene.remove(bullet);
this.tank.bullets.splice(bulletIndex, 1);
this.createExplosion(bullet.position);
}
});
});
// ํ”Œ๋ ˆ์ด์–ด ํƒฑํฌ์™€ ์  ์ „์ฐจ ์ถฉ๋Œ ์ฒดํฌ
const tankBoundingBox = new THREE.Box3().setFromObject(this.tank.body);
this.enemies.forEach(enemy => {
if (!enemy.mesh || !enemy.isLoaded) return;
const enemyBoundingBox = new THREE.Box3().setFromObject(enemy.mesh);
if (tankBoundingBox.intersectsBox(enemyBoundingBox)) {
this.tank.body.position.copy(this.previousTankPosition);
}
});
// ์ด์ „ ์œ„์น˜ ์ €์žฅ
this.previousTankPosition.copy(this.tank.body.position);
}
endGame() {
if (this.isGameOver) return;
this.isGameOver = true;
if (this.gameTimer) {
clearInterval(this.gameTimer);
}
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
document.exitPointerLock();
const gameOverDiv = document.createElement('div');
gameOverDiv.style.position = 'absolute';
gameOverDiv.style.top = '50%';
gameOverDiv.style.left = '50%';
gameOverDiv.style.transform = 'translate(-50%, -50%)';
gameOverDiv.style.color = '#0f0';
gameOverDiv.style.fontSize = '48px';
gameOverDiv.style.backgroundColor = 'rgba(0, 20, 0, 0.7)';
gameOverDiv.style.padding = '20px';
gameOverDiv.style.borderRadius = '10px';
gameOverDiv.style.textAlign = 'center';
gameOverDiv.innerHTML = `
Game Over<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}`;
}
}
}
animate() {
if (this.isGameOver) {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
return;
}
this.animationFrameId = requestAnimationFrame(() => this.animate());
const currentTime = performance.now();
const deltaTime = (currentTime - this.lastTime) / 1000;
this.lastTime = currentTime;
if (!this.isLoading) {
this.handleMovement();
this.tank.update(this.mouse.x, this.mouse.y, this.scene);
const tankPosition = this.tank.getPosition();
this.enemies.forEach(enemy => {
enemy.update(tankPosition);
if (enemy.isLoaded && enemy.mesh.position.distanceTo(tankPosition) < ENEMY_CONFIG.ATTACK_RANGE) {
enemy.shoot(tankPosition);
}
});
this.updateParticles();
this.checkCollisions();
this.updateUI();
this.updateRadar(); // ๋ ˆ์ด๋” ์—…๋ฐ์ดํŠธ ์ถ”๊ฐ€
}
this.renderer.render(this.scene, this.camera);
}
}
// Start game
window.startGame = function() {
document.getElementById('startScreen').style.display = 'none';
document.body.requestPointerLock();
// Create new game instance or restart existing one
if (!window.gameInstance) {
window.gameInstance = new Game();
}
};
// Initialize game
document.addEventListener('DOMContentLoaded', () => {
const game = new Game();
});