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 HELICOPTER_HEIGHT = 30;
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 OBSTACLE_COUNT = 50;
const ENEMY_CONFIG = {
ATTACK_RANGE: 100,
ATTACK_INTERVAL: 2000,
BULLET_SPEED: 2
};
// 게임 변수
let scene, camera, renderer, controls;
let enemies = [];
let bullets = [];
let enemyBullets = [];
let playerHealth = MAX_HEALTH;
let ammo = 30;
let currentStage = 1;
let isGameOver = false;
let lastTime = performance.now();
let lastRender = 0;
// 오실레이터 기반 총소리 생성기
class GunSoundGenerator {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
createGunshot() {
const currentTime = this.audioContext.currentTime;
// 메인 오실레이터
const osc = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(200, currentTime);
osc.frequency.exponentialRampToValueAtTime(50, currentTime + 0.1);
gainNode.gain.setValueAtTime(0.5, currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, currentTime + 0.1);
osc.connect(gainNode);
gainNode.connect(this.audioContext.destination);
osc.start(currentTime);
osc.stop(currentTime + 0.1);
// 노이즈 추가
const bufferSize = this.audioContext.sampleRate * 0.1;
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = this.audioContext.createBufferSource();
const noiseGain = this.audioContext.createGain();
noise.buffer = buffer;
noiseGain.gain.setValueAtTime(0.2, currentTime);
noiseGain.gain.exponentialRampToValueAtTime(0.01, currentTime + 0.05);
noise.connect(noiseGain);
noiseGain.connect(this.audioContext.destination);
noise.start(currentTime);
}
resume() {
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
}
}
// 사운드 시스템 초기화
const gunSound = new GunSoundGenerator();
async function init() {
document.getElementById('loading').style.display = 'block';
try {
// Scene 초기화
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 0, 1000);
// Renderer 최적화
renderer = new THREE.WebGLRenderer({
antialias: false,
powerPreference: "high-performance"
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap;
document.body.appendChild(renderer.domElement);
// Camera 설정
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, HELICOPTER_HEIGHT, 0);
// 기본 조명
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(100, 100, 50);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);
// Controls 설정
controls = new PointerLockControls(camera, document.body);
// 이벤트 리스너
setupEventListeners();
// 모델 테스트 먼저 실행
await testModelLoading();
// 게임 요소 초기화
await Promise.all([
createTerrain(),
createEnemies()
]);
document.getElementById('loading').style.display = 'none';
console.log('Game initialized successfully');
} catch (error) {
console.error('Initialization error:', error);
document.getElementById('loading').innerHTML = `
Error loading models. Please check console and file paths.
`;
throw error;
}
}
function setupEventListeners() {
document.addEventListener('click', onClick);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
window.addEventListener('resize', onWindowResize);
}
async function testModelLoading() {
const loader = new GLTFLoader();
try {
const modelPath = 'models/enemy1.glb';
console.log('Testing model loading:', modelPath);
const gltf = await loader.loadAsync(modelPath);
console.log('Test model loaded successfully:', gltf);
} catch (error) {
console.error('Test model loading failed:', error);
throw error;
}
}
function createTerrain() {
return new Promise((resolve) => {
const geometry = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE, 100, 100);
const material = new THREE.MeshStandardMaterial({
color: 0xD2B48C,
roughness: 0.8,
metalness: 0.2
});
const vertices = geometry.attributes.position.array;
for (let i = 0; i < vertices.length; i += 3) {
vertices[i + 2] = Math.sin(vertices[i] * 0.01) * Math.cos(vertices[i + 1] * 0.01) * 20;
}
geometry.attributes.position.needsUpdate = true;
geometry.computeVertexNormals();
const terrain = new THREE.Mesh(geometry, material);
terrain.rotation.x = -Math.PI / 2;
terrain.receiveShadow = true;
scene.add(terrain);
addObstacles();
resolve();
});
}
function addObstacles() {
const rockGeometry = new THREE.DodecahedronGeometry(10);
const rockMaterial = new THREE.MeshStandardMaterial({
color: 0x8B4513,
roughness: 0.9
});
for (let i = 0; i < OBSTACLE_COUNT; i++) {
const rock = new THREE.Mesh(rockGeometry, rockMaterial);
rock.position.set(
(Math.random() - 0.5) * MAP_SIZE * 0.9,
Math.random() * 10,
(Math.random() - 0.5) * MAP_SIZE * 0.9
);
rock.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
Math.random() * Math.PI
);
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
}
}
async function createEnemies() {
console.log('Creating enemies...');
const loader = new GLTFLoader();
const enemyCount = Math.min(3 + currentStage, ENEMY_COUNT_MAX);
for (let i = 0; i < enemyCount; i++) {
const angle = (i / enemyCount) * Math.PI * 2;
const radius = 200;
const position = new THREE.Vector3(
Math.cos(angle) * radius,
ENEMY_GROUND_HEIGHT,
Math.sin(angle) * radius
);
// 임시 적 생성
const tempEnemy = createTemporaryEnemy(position);
scene.add(tempEnemy.model);
enemies.push(tempEnemy);
// GLB 모델 로드
try {
const modelIndex = i % 4 + 1;
const modelPath = `models/enemy${modelIndex}.glb`;
console.log(`Loading model: ${modelPath}`);
const gltf = await loader.loadAsync(modelPath);
const model = gltf.scene;
// 모델 설정
model.scale.set(ENEMY_SCALE, ENEMY_SCALE, ENEMY_SCALE);
model.position.copy(position);
// 모델 재질 및 그림자 설정
model.traverse((node) => {
if (node.isMesh) {
node.castShadow = true;
node.receiveShadow = true;
node.material.metalness = 0.2;
node.material.roughness = 0.8;
}
});
// 임시 모델 교체
scene.remove(tempEnemy.model);
scene.add(model);
enemies[enemies.indexOf(tempEnemy)].model = model;
console.log(`Successfully loaded enemy model ${modelIndex}`);
} catch (error) {
console.error(`Error loading enemy model:`, error);
}
}
}
function createTemporaryEnemy(position) {
const geometry = new THREE.BoxGeometry(5, 10, 5);
const material = new THREE.MeshPhongMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.8
});
const model = new THREE.Mesh(geometry, material);
model.position.copy(position);
model.castShadow = true;
model.receiveShadow = true;
return {
model: model,
health: 100,
speed: ENEMY_MOVE_SPEED,
lastAttackTime: 0
};
}
function createExplosion(position) {
const particles = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
const particle = new THREE.Mesh(
new THREE.SphereGeometry(0.3),
new THREE.MeshBasicMaterial({
color: 0xff4400,
transparent: true,
opacity: 1
})
);
particle.position.copy(position);
particle.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 2,
Math.random() * 2,
(Math.random() - 0.5) * 2
);
particles.push(particle);
scene.add(particle);
}
// 폭발 광원 효과
const explosionLight = new THREE.PointLight(0xff4400, 2, 20);
explosionLight.position.copy(position);
scene.add(explosionLight);
let opacity = 1;
const animate = () => {
opacity -= 0.05;
if (opacity <= 0) {
particles.forEach(p => scene.remove(p));
scene.remove(explosionLight);
return;
}
particles.forEach(particle => {
particle.position.add(particle.velocity);
particle.material.opacity = opacity;
});
requestAnimationFrame(animate);
};
animate();
}
function onClick() {
if (!controls.isLocked) {
controls.lock();
gunSound.resume();
} else if (ammo > 0) {
shoot();
}
}
function onKeyDown(event) {
switch(event.code) {
case 'KeyW': moveState.forward = true; break;
case 'KeyS': moveState.backward = true; break;
case 'KeyA': moveState.left = true; break;
case 'KeyD': moveState.right = true; break;
case 'KeyR': reload(); break;
}
}
function onKeyUp(event) {
switch(event.code) {
case 'KeyW': moveState.forward = false; break;
case 'KeyS': moveState.backward = false; break;
case 'KeyA': moveState.left = false; break;
case 'KeyD': moveState.right = false; break;
}
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// 이동 상태
const moveState = {
forward: false,
backward: false,
left: false,
right: false
};
function shoot() {
if (ammo <= 0) return;
ammo--;
updateAmmoDisplay();
const bullet = createBullet();
bullets.push(bullet);
gunSound.createGunshot();
// 총구 화염 효과
const muzzleFlash = new THREE.PointLight(0xffff00, 3, 10);
muzzleFlash.position.copy(camera.position);
scene.add(muzzleFlash);
setTimeout(() => scene.remove(muzzleFlash), 50);
}
function createBullet() {
const bullet = new THREE.Mesh(
new THREE.SphereGeometry(0.5),
new THREE.MeshBasicMaterial({
color: 0xffff00,
emissive: 0xffff00,
emissiveIntensity: 1
})
);
bullet.position.copy(camera.position);
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
bullet.velocity = direction.multiplyScalar(5);
scene.add(bullet);
return bullet;
}
function createEnemyBullet(enemy) {
const bullet = new THREE.Mesh(
new THREE.SphereGeometry(0.5),
new THREE.MeshBasicMaterial({
color: 0xff0000,
emissive: 0xff0000,
emissiveIntensity: 1
})
);
bullet.position.copy(enemy.model.position);
bullet.position.y += 5;
const direction = new THREE.Vector3();
direction.subVectors(camera.position, enemy.model.position).normalize();
bullet.velocity = direction.multiplyScalar(ENEMY_CONFIG.BULLET_SPEED);
scene.add(bullet);
return bullet;
}
function updateMovement() {
if (controls.isLocked) {
const speed = 2.0;
if (moveState.forward) controls.moveForward(speed);
if (moveState.backward) controls.moveForward(-speed);
if (moveState.left) controls.moveRight(-speed);
if (moveState.right) controls.moveRight(speed);
// 고도 제한
if (camera.position.y < HELICOPTER_HEIGHT) {
camera.position.y = HELICOPTER_HEIGHT;
} else if (camera.position.y > HELICOPTER_HEIGHT + 10) {
camera.position.y = HELICOPTER_HEIGHT + 10;
}
}
}
function updateBullets() {
for (let i = bullets.length - 1; i >= 0; i--) {
if (!bullets[i]) continue;
bullets[i].position.add(bullets[i].velocity);
// 적과의 충돌 검사
for (let j = enemies.length - 1; j >= 0; j--) {
const enemy = enemies[j];
if (!enemy || !enemy.model) continue;
if (bullets[i] && bullets[i].position.distanceTo(enemy.model.position) < 10) {
scene.remove(bullets[i]);
bullets.splice(i, 1);
enemy.health -= 25;
createExplosion(enemy.model.position.clone());
if (enemy.health <= 0) {
createExplosion(enemy.model.position.clone());
scene.remove(enemy.model);
enemies.splice(j, 1);
}
break;
}
}
// 범위 벗어난 총알 제거
if (bullets[i] && bullets[i].position.distanceTo(camera.position) > 1000) {
scene.remove(bullets[i]);
bullets.splice(i, 1);
}
}
}
function updateEnemyBullets() {
for (let i = enemyBullets.length - 1; i >= 0; i--) {
if (!enemyBullets[i]) continue;
enemyBullets[i].position.add(enemyBullets[i].velocity);
if (enemyBullets[i].position.distanceTo(camera.position) < 3) {
playerHealth -= 10;
updateHealthBar();
createExplosion(enemyBullets[i].position.clone());
scene.remove(enemyBullets[i]);
enemyBullets.splice(i, 1);
if (playerHealth <= 0) {
gameOver(false);
}
continue;
}
if (enemyBullets[i].position.distanceTo(camera.position) > 1000) {
scene.remove(enemyBullets[i]);
enemyBullets.splice(i, 1);
}
}
}
function updateEnemies() {
const currentTime = Date.now();
enemies.forEach(enemy => {
if (!enemy || !enemy.model) return;
// 적 이동 로직
const direction = new THREE.Vector3();
direction.subVectors(camera.position, enemy.model.position);
direction.y = 0;
direction.normalize();
const newPosition = enemy.model.position.clone()
.add(direction.multiplyScalar(enemy.speed));
newPosition.y = ENEMY_GROUND_HEIGHT;
enemy.model.position.copy(newPosition);
// 적이 플레이어를 바라보도록 설정
enemy.model.lookAt(new THREE.Vector3(
camera.position.x,
enemy.model.position.y,
camera.position.z
));
// 공격 로직
const distanceToPlayer = enemy.model.position.distanceTo(camera.position);
if (distanceToPlayer < ENEMY_CONFIG.ATTACK_RANGE &&
currentTime - enemy.lastAttackTime > ENEMY_CONFIG.ATTACK_INTERVAL) {
enemyBullets.push(createEnemyBullet(enemy));
enemy.lastAttackTime = currentTime;
// 공격 시 발광 효과
const attackFlash = new THREE.PointLight(0xff0000, 2, 20);
attackFlash.position.copy(enemy.model.position);
scene.add(attackFlash);
setTimeout(() => scene.remove(attackFlash), 100);
}
});
}
function reload() {
ammo = 30;
updateAmmoDisplay();
}
function updateAmmoDisplay() {
document.getElementById('ammo').textContent = `Ammo: ${ammo}/30`;
}
function updateHealthBar() {
const healthElement = document.getElementById('health');
const healthPercentage = (playerHealth / MAX_HEALTH) * 100;
healthElement.style.width = `${healthPercentage}%`;
}
function updateHelicopterHUD() {
document.querySelector('#altitude-indicator span').textContent =
Math.round(camera.position.y);
const speed = Math.round(
Math.sqrt(
moveState.forward * moveState.forward +
moveState.right * moveState.right
) * 100
);
document.querySelector('#speed-indicator span').textContent = speed;
const heading = Math.round(
(camera.rotation.y * (180 / Math.PI) + 360) % 360
);
document.querySelector('#compass span').textContent = heading;
updateRadar();
}
function updateRadar() {
const radarTargets = document.querySelector('.radar-targets');
radarTargets.innerHTML = '';
enemies.forEach(enemy => {
if (!enemy || !enemy.model) return;
const relativePos = enemy.model.position.clone().sub(camera.position);
const distance = relativePos.length();
if (distance < 500) {
const playerAngle = camera.rotation.y;
const enemyAngle = Math.atan2(relativePos.x, relativePos.z);
const relativeAngle = enemyAngle - playerAngle;
const normalizedDistance = distance / 500;
const dot = document.createElement('div');
dot.className = 'radar-dot';
dot.style.left = `${50 + Math.sin(relativeAngle) * normalizedDistance * 45}%`;
dot.style.top = `${50 + Math.cos(relativeAngle) * normalizedDistance * 45}%`;
radarTargets.appendChild(dot);
}
});
}
function checkGameStatus() {
if (enemies.length === 0 && currentStage < 5) {
currentStage++;
document.getElementById('stage').style.display = 'block';
document.getElementById('stage').textContent = `Stage ${currentStage}`;
setTimeout(() => {
document.getElementById('stage').style.display = 'none';
createEnemies();
}, 2000);
}
}
function cleanupResources() {
bullets.forEach(bullet => scene.remove(bullet));
bullets = [];
enemyBullets.forEach(bullet => scene.remove(bullet));
enemyBullets = [];
enemies.forEach(enemy => {
if (enemy && enemy.model) {
scene.remove(enemy.model);
}
});
enemies = [];
}
function gameOver(won) {
isGameOver = true;
controls.unlock();
cleanupResources();
setTimeout(() => {
alert(won ? 'Mission Complete!' : 'Game Over!');
location.reload();
}, 100);
}
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
// 프레임 제한 (60fps)
if (timestamp - lastRender < 16) {
return;
}
lastRender = timestamp;
if (controls.isLocked && !isGameOver) {
updateMovement();
updateBullets();
updateEnemies();
updateEnemyBullets();
updateHelicopterHUD();
checkGameStatus();
}
renderer.render(scene, camera);
}
// 성능 모니터링
let lastFpsUpdate = 0;
let frameCount = 0;
function updateFPS(timestamp) {
frameCount++;
if (timestamp - lastFpsUpdate >= 1000) {
const fps = Math.round(frameCount * 1000 / (timestamp - lastFpsUpdate));
console.log('FPS:', fps);
frameCount = 0;
lastFpsUpdate = timestamp;
}
requestAnimationFrame(updateFPS);
}
// 게임 시작
window.addEventListener('load', async () => {
try {
await init();
console.log('Game started');
console.log('Active enemies:', enemies.length);
gameLoop(performance.now());
updateFPS(performance.now());
} catch (error) {
console.error('Game initialization error:', error);
document.getElementById('loading').innerHTML = `
Error loading game. Please check console and file paths.
`;
}
});
// 디버깅을 위한 전역 접근
window.debugGame = {
scene,
camera,
enemies,
gunSound,
reloadEnemies: createEnemies
};