|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<title>Neon Arena</title> |
|
<link rel="icon" href="data:image/x-icon;base64,"> |
|
<style> |
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
body { overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui, sans-serif; } |
|
canvas { display: block; } |
|
#hud { |
|
position: fixed; top: 0; left: 0; width: 100%; padding: 20px 30px; |
|
display: flex; justify-content: space-between; align-items: center; |
|
pointer-events: none; z-index: 10; |
|
font-size: 22px; color: #fff; text-shadow: 0 0 10px rgba(0,200,255,0.8); |
|
} |
|
#score-display { font-weight: 700; letter-spacing: 2px; } |
|
#lives-display { font-weight: 700; letter-spacing: 2px; } |
|
#lives-display .heart { color: #ff3366; margin: 0 2px; text-shadow: 0 0 12px #ff3366; } |
|
#game-over { |
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%; |
|
display: none; flex-direction: column; align-items: center; justify-content: center; |
|
background: rgba(0,0,0,0.85); z-index: 20; |
|
} |
|
#game-over h1 { |
|
font-size: 64px; color: #ff3366; margin-bottom: 10px; |
|
text-shadow: 0 0 30px #ff3366, 0 0 60px rgba(255,51,102,0.5); |
|
letter-spacing: 6px; |
|
} |
|
#game-over .final-score { |
|
font-size: 28px; color: #fff; margin-bottom: 40px; |
|
text-shadow: 0 0 10px rgba(255,255,255,0.5); |
|
} |
|
#game-over .restart-hint { |
|
font-size: 18px; color: #88aacc; animation: pulse 2s infinite; |
|
} |
|
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } } |
|
#damage-flash { |
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%; |
|
pointer-events: none; z-index: 15; |
|
background: radial-gradient(ellipse at center, transparent 40%, rgba(255,0,0,0.4) 100%); |
|
opacity: 0; transition: opacity 0.1s; |
|
} |
|
#combo { |
|
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); |
|
font-size: 48px; font-weight: 900; color: #ffcc00; |
|
text-shadow: 0 0 20px #ffcc00; pointer-events: none; z-index: 12; |
|
opacity: 0; transition: opacity 0.3s; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<div id="hud"> |
|
<div id="score-display">SCORE: 0</div> |
|
<div id="lives-display"></div> |
|
</div> |
|
<div id="game-over"> |
|
<h1>GAME OVER</h1> |
|
<div class="final-score" id="final-score-display">Final Score: 0</div> |
|
<div class="restart-hint">Press any key to restart</div> |
|
</div> |
|
<div id="damage-flash"></div> |
|
<div id="combo"></div> |
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> |
|
<script> |
|
(function() { |
|
'use strict'; |
|
|
|
// === CONSTANTS === |
|
const ARENA_SIZE = 40; |
|
const HALF_ARENA = ARENA_SIZE / 2; |
|
const PLAYER_SPEED = 30; |
|
const PLAYER_ACCEL = 55; |
|
const PLAYER_FRICTION = 6; |
|
const PLAYER_RADIUS = 0.8; |
|
const ORB_RADIUS = 0.5; |
|
const ORB_SPEED = 3; |
|
const ORB_SPAWN_RATE = 2000; |
|
const ENEMY_BASE_SPEED = 6; |
|
const ENEMY_SPAWN_INTERVAL_BASE = 2000; |
|
const ENEMY_SPAWN_INTERVAL_MIN = 400; |
|
const ENEMY_RADIUS = 0.7; |
|
const LIVES_START = 3; |
|
const CAMERA_HEIGHT = 8; |
|
const CAMERA_DISTANCE = 10; |
|
const CAMERA_LERP = 0.06; |
|
|
|
// === STATE === |
|
let score = 0; |
|
let lives = LIVES_START; |
|
let gameRunning = true; |
|
let gameTime = 0; |
|
let difficulty = 1; |
|
let lastEnemySpawn = 0; |
|
let lastOrbSpawn = 0; |
|
let comboCount = 0; |
|
let lastCollectTime = 0; |
|
let playerInvincible = 0; |
|
let screenShake = 0; |
|
|
|
const keys = {}; |
|
const playerVelocity = new THREE.Vector3(); |
|
const cameraTarget = new THREE.Vector3(); |
|
const cameraPosition = new THREE.Vector3(); |
|
|
|
// === SCENE SETUP === |
|
const scene = new THREE.Scene(); |
|
scene.background = new THREE.Color(0x050510); |
|
scene.fog = new THREE.FogExp2(0x050510, 0.018); |
|
|
|
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200); |
|
camera.position.set(0, CAMERA_HEIGHT, CAMERA_DISTANCE); |
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true }); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
|
renderer.shadowMap.enabled = true; |
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
renderer.toneMapping = THREE.ACESFilmicToneMapping; |
|
renderer.toneMappingExposure = 1.2; |
|
document.body.appendChild(renderer.domElement); |
|
|
|
// FPS meter |
|
const fpsEl = document.createElement('div'); |
|
fpsEl.style.cssText = 'position:fixed;bottom:10px;left:10px;color:#0f0;font:12px monospace;z-index:100;pointer-events:none;text-shadow:0 0 4px #0f0;'; |
|
document.body.appendChild(fpsEl); |
|
|
|
// === LIGHTING === |
|
const ambientLight = new THREE.AmbientLight(0x222244, 0.4); |
|
scene.add(ambientLight); |
|
|
|
const dirLight = new THREE.DirectionalLight(0x88aaff, 0.6); |
|
dirLight.position.set(15, 20, 10); |
|
dirLight.castShadow = true; |
|
dirLight.shadow.mapSize.set(2048, 2048); |
|
dirLight.shadow.camera.near = 1; |
|
dirLight.shadow.camera.far = 60; |
|
dirLight.shadow.camera.left = -25; |
|
dirLight.shadow.camera.right = 25; |
|
dirLight.shadow.camera.top = 25; |
|
dirLight.shadow.camera.bottom = -25; |
|
scene.add(dirLight); |
|
|
|
const pointLight1 = new THREE.PointLight(0x00ccff, 1.5, 30); |
|
pointLight1.position.set(0, 5, 0); |
|
scene.add(pointLight1); |
|
|
|
const pointLight2 = new THREE.PointLight(0xff3366, 0.8, 20); |
|
pointLight2.position.set(-15, 4, -15); |
|
scene.add(pointLight2); |
|
|
|
const pointLight3 = new THREE.PointLight(0xffcc00, 0.8, 20); |
|
pointLight3.position.set(15, 4, 15); |
|
scene.add(pointLight3); |
|
|
|
// === ARENA FLOOR === |
|
const floorGeo = new THREE.PlaneGeometry(ARENA_SIZE, ARENA_SIZE, 20, 20); |
|
const floorMat = new THREE.MeshStandardMaterial({ |
|
color: 0x111122, |
|
roughness: 0.8, |
|
metalness: 0.2, |
|
}); |
|
const floor = new THREE.Mesh(floorGeo, floorMat); |
|
floor.rotation.x = -Math.PI / 2; |
|
floor.receiveShadow = true; |
|
scene.add(floor); |
|
|
|
// Grid lines on floor |
|
const gridHelper = new THREE.GridHelper(ARENA_SIZE, 20, 0x1a1a3a, 0x111133); |
|
gridHelper.position.y = 0.01; |
|
scene.add(gridHelper); |
|
|
|
// === ARENA WALLS (glowing borders) === |
|
const wallMat = new THREE.MeshStandardMaterial({ |
|
color: 0x00aaff, |
|
emissive: 0x004488, |
|
emissiveIntensity: 0.5, |
|
transparent: true, |
|
opacity: 0.3, |
|
}); |
|
const wallHeight = 2; |
|
const wallPositions = [ |
|
{ pos: [0, wallHeight/2, -HALF_ARENA], rot: [0, 0, 0], scale: [ARENA_SIZE, wallHeight, 0.3] }, |
|
{ pos: [0, wallHeight/2, HALF_ARENA], rot: [0, 0, 0], scale: [ARENA_SIZE, wallHeight, 0.3] }, |
|
{ pos: [-HALF_ARENA, wallHeight/2, 0], rot: [0, 0, 0], scale: [0.3, wallHeight, ARENA_SIZE] }, |
|
{ pos: [HALF_ARENA, wallHeight/2, 0], rot: [0, 0, 0], scale: [0.3, wallHeight, ARENA_SIZE] }, |
|
]; |
|
wallPositions.forEach(w => { |
|
const geo = new THREE.BoxGeometry(1, 1, 1); |
|
const mesh = new THREE.Mesh(geo, wallMat); |
|
mesh.position.set(...w.pos); |
|
mesh.scale.set(...w.scale); |
|
mesh.castShadow = true; |
|
scene.add(mesh); |
|
}); |
|
|
|
// === PLAYER === |
|
const playerGroup = new THREE.Group(); |
|
const playerBodyGeo = new THREE.SphereGeometry(PLAYER_RADIUS, 16, 16); |
|
const playerBodyMat = new THREE.MeshStandardMaterial({ |
|
color: 0x00ddff, |
|
emissive: 0x0066aa, |
|
emissiveIntensity: 0.8, |
|
roughness: 0.2, |
|
metalness: 0.8, |
|
}); |
|
const playerBody = new THREE.Mesh(playerBodyGeo, playerBodyMat); |
|
playerBody.castShadow = true; |
|
playerGroup.add(playerBody); |
|
|
|
// Player glow ring |
|
const ringGeo = new THREE.TorusGeometry(PLAYER_RADIUS + 0.15, 0.05, 8, 32); |
|
const ringMat = new THREE.MeshStandardMaterial({ |
|
color: 0x00ffff, |
|
emissive: 0x00ffff, |
|
emissiveIntensity: 1.5, |
|
}); |
|
const playerRing = new THREE.Mesh(ringGeo, ringMat); |
|
playerRing.rotation.x = Math.PI / 2; |
|
playerGroup.add(playerRing); |
|
|
|
// Player point light |
|
const playerLight = new THREE.PointLight(0x00ccff, 2, 8); |
|
playerLight.position.y = 0.5; |
|
playerGroup.add(playerLight); |
|
|
|
scene.add(playerGroup); |
|
|
|
// === ORBS === |
|
const orbs = []; |
|
const orbColors = [0xffcc00, 0xff6600, 0xff0066, 0x66ff00, 0x00ffcc]; |
|
|
|
function spawnOrb() { |
|
const color = orbColors[Math.floor(Math.random() * orbColors.length)]; |
|
const geo = new THREE.SphereGeometry(ORB_RADIUS, 12, 12); |
|
const mat = new THREE.MeshStandardMaterial({ |
|
color: color, |
|
emissive: color, |
|
emissiveIntensity: 1.2, |
|
transparent: true, |
|
opacity: 0.9, |
|
}); |
|
const orb = new THREE.Mesh(geo, mat); |
|
|
|
// Random position within arena bounds |
|
const margin = 3; |
|
orb.position.x = (Math.random() - 0.5) * (ARENA_SIZE - margin * 2); |
|
orb.position.z = (Math.random() - 0.5) * (ARENA_SIZE - margin * 2); |
|
orb.position.y = 1.2 + Math.random() * 0.5; |
|
|
|
// Glow light for orb |
|
const orbLight = new THREE.PointLight(color, 0.8, 5); |
|
orb.add(orbLight); |
|
|
|
orb.userData = { |
|
baseY: orb.position.y, |
|
phase: Math.random() * Math.PI * 2, |
|
collected: false, |
|
}; |
|
|
|
scene.add(orb); |
|
orbs.push(orb); |
|
} |
|
|
|
for (let i = 0; i < 8; i++) spawnOrb(); |
|
|
|
// === ENEMIES === |
|
const enemies = []; |
|
const enemyColors = [0xff0044, 0xff3300, 0xcc0066]; |
|
|
|
function spawnEnemy() { |
|
const color = enemyColors[Math.floor(Math.random() * enemyColors.length)]; |
|
const geo = new THREE.OctahedronGeometry(ENEMY_RADIUS, 0); |
|
const mat = new THREE.MeshStandardMaterial({ |
|
color: color, |
|
emissive: color, |
|
emissiveIntensity: 0.8, |
|
roughness: 0.3, |
|
metalness: 0.6, |
|
}); |
|
const enemy = new THREE.Mesh(geo, mat); |
|
|
|
// Spawn at random edge position |
|
const side = Math.floor(Math.random() * 4); |
|
const margin = 2; |
|
switch (side) { |
|
case 0: enemy.position.set((Math.random()-0.5)*ARENA_SIZE, 1.2, -HALF_ARENA - margin); break; |
|
case 1: enemy.position.set((Math.random()-0.5)*ARENA_SIZE, 1.2, HALF_ARENA + margin); break; |
|
case 2: enemy.position.set(-HALF_ARENA - margin, 1.2, (Math.random()-0.5)*ARENA_SIZE); break; |
|
case 3: enemy.position.set(HALF_ARENA + margin, 1.2, (Math.random()-0.5)*ARENA_SIZE); break; |
|
} |
|
|
|
// Enemy glow |
|
const enemyLight = new THREE.PointLight(color, 0.5, 4); |
|
enemy.add(enemyLight); |
|
|
|
enemy.userData = { |
|
speed: ENEMY_BASE_SPEED + Math.random() * 2, |
|
rotationSpeed: new THREE.Vector3( |
|
(Math.random()-0.5)*3, (Math.random()-0.5)*3, (Math.random()-0.5)*3 |
|
), |
|
phase: Math.random() * Math.PI * 2, |
|
}; |
|
|
|
scene.add(enemy); |
|
enemies.push(enemy); |
|
} |
|
|
|
// === PARTICLES === |
|
const particles = []; |
|
const particlePool = []; |
|
|
|
function spawnParticles(position, color, count, spread, speed) { |
|
for (let i = 0; i < count; i++) { |
|
let p = particlePool.length > 0 ? particlePool.pop() : null; |
|
if (!p) { |
|
p = { |
|
mesh: new THREE.Mesh( |
|
new THREE.SphereGeometry(0.08 + Math.random() * 0.12, 6, 6), |
|
new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1 }) |
|
), |
|
velocity: new THREE.Vector3(), |
|
life: 0, |
|
maxLife: 0.5 + Math.random() * 0.5, |
|
}; |
|
scene.add(p.mesh); |
|
} |
|
|
|
p.mesh.position.copy(position); |
|
p.mesh.material.opacity = 1; |
|
p.mesh.scale.setScalar(0.5 + Math.random()); |
|
|
|
const angle = Math.random() * Math.PI * 2; |
|
const vertAngle = (Math.random() - 0.5) * Math.PI; |
|
p.velocity.set( |
|
Math.cos(angle) * Math.cos(vertAngle), |
|
Math.sin(vertAngle) * 0.5 + 0.5, |
|
Math.sin(angle) * Math.cos(vertAngle) |
|
).multiplyScalar(speed * (0.5 + Math.random())); |
|
|
|
p.life = p.maxLife; |
|
particles.push(p); |
|
} |
|
} |
|
|
|
function updateParticles(dt) { |
|
for (let i = particles.length - 1; i >= 0; i--) { |
|
const p = particles[i]; |
|
p.life -= dt; |
|
if (p.life <= 0) { |
|
scene.remove(p.mesh); |
|
p.mesh.material.dispose(); |
|
particlePool.push(p); |
|
particles.splice(i, 1); |
|
continue; |
|
} |
|
p.velocity.y -= 9.8 * dt; |
|
p.mesh.position.add(p.velocity.clone().multiplyScalar(dt)); |
|
p.mesh.material.opacity = Math.max(0, p.life / p.maxLife); |
|
p.mesh.scale.setScalar((p.life / p.maxLife) * (0.5 + Math.random() * 0.3)); |
|
} |
|
} |
|
|
|
// === HUD === |
|
function updateHUD() { |
|
document.getElementById('score-display').textContent = `SCORE: ${score}`; |
|
let heartsHTML = ''; |
|
for (let i = 0; i < LIVES_START; i++) { |
|
heartsHTML += i < lives ? '<span class="heart">♥</span>' : '<span style="color:#333;margin:0 2px;">♥</span>'; |
|
} |
|
document.getElementById('lives-display').innerHTML = heartsHTML; |
|
} |
|
|
|
// === GAME OVER === |
|
function showGameOver() { |
|
gameRunning = false; |
|
document.getElementById('final-score-display').textContent = `Final Score: ${score}`; |
|
document.getElementById('game-over').style.display = 'flex'; |
|
} |
|
|
|
function restartGame() { |
|
score = 0; |
|
lives = LIVES_START; |
|
gameTime = 0; |
|
difficulty = 1; |
|
comboCount = 0; |
|
playerInvincible = 0; |
|
screenShake = 0; |
|
playerVelocity.set(0, 0, 0); |
|
playerGroup.position.set(0, 0.8, 0); |
|
|
|
// Clear enemies |
|
enemies.forEach(e => { scene.remove(e); }); |
|
enemies.length = 0; |
|
|
|
// Clear orbs |
|
orbs.forEach(o => { scene.remove(o); }); |
|
orbs.length = 0; |
|
for (let i = 0; i < 8; i++) spawnOrb(); |
|
|
|
lastEnemySpawn = performance.now(); |
|
lastOrbSpawn = performance.now(); |
|
|
|
document.getElementById('game-over').style.display = 'none'; |
|
gameRunning = true; |
|
updateHUD(); |
|
} |
|
|
|
// === INPUT === |
|
document.addEventListener('keydown', e => { |
|
keys[e.code] = true; |
|
if (!gameRunning && document.getElementById('game-over').style.display === 'flex') { |
|
restartGame(); |
|
} |
|
}); |
|
document.addEventListener('keyup', e => { keys[e.code] = false; }); |
|
|
|
window.addEventListener('resize', () => { |
|
camera.aspect = window.innerWidth / window.innerHeight; |
|
camera.updateProjectionMatrix(); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
}); |
|
|
|
// === COLLISION === |
|
function checkCollision(a, b, radiusA, radiusB) { |
|
const dx = a.position.x - b.position.x; |
|
const dz = a.position.z - b.position.z; |
|
const dist = Math.sqrt(dx * dx + dz * dz); |
|
return dist < radiusA + radiusB; |
|
} |
|
|
|
// === MAIN LOOP === |
|
let prevTime = performance.now(); |
|
let fpsFrames = 0; |
|
let fpsTime = 0; |
|
let currentFps = 0; |
|
|
|
function update() { |
|
const now = performance.now(); |
|
const dt = Math.min((now - prevTime) / 1000, 0.05); |
|
prevTime = now; |
|
|
|
// FPS calculation |
|
fpsFrames++; |
|
fpsTime += dt; |
|
if (fpsTime >= 0.5) { |
|
currentFps = Math.round(fpsFrames / fpsTime); |
|
fpsFrames = 0; |
|
fpsTime = 0; |
|
fpsEl.textContent = `${currentFps} FPS`; |
|
} |
|
|
|
if (!gameRunning) return; |
|
|
|
gameTime += dt; |
|
difficulty = 1 + gameTime / 30; // ramps over 30 seconds |
|
|
|
// === PLAYER MOVEMENT === |
|
let moveX = 0, moveZ = 0; |
|
if (keys['KeyW'] || keys['ArrowUp']) moveZ -= 1; |
|
if (keys['KeyS'] || keys['ArrowDown']) moveZ += 1; |
|
if (keys['KeyA'] || keys['ArrowLeft']) moveX -= 1; |
|
if (keys['KeyD'] || keys['ArrowRight']) moveX += 1; |
|
|
|
// Normalize diagonal |
|
if (moveX !== 0 && moveZ !== 0) { |
|
const inv = 1 / Math.sqrt(2); |
|
moveX *= inv; |
|
moveZ *= inv; |
|
} |
|
|
|
// Apply acceleration |
|
playerVelocity.x += moveX * PLAYER_ACCEL * dt; |
|
playerVelocity.z += moveZ * PLAYER_ACCEL * dt; |
|
|
|
// Apply friction |
|
playerVelocity.x -= playerVelocity.x * PLAYER_FRICTION * dt; |
|
playerVelocity.z -= playerVelocity.z * PLAYER_FRICTION * dt; |
|
|
|
// Clamp speed |
|
const speed = Math.sqrt(playerVelocity.x ** 2 + playerVelocity.z ** 2); |
|
if (speed > PLAYER_SPEED) { |
|
playerVelocity.x *= PLAYER_SPEED / speed; |
|
playerVelocity.z *= PLAYER_SPEED / speed; |
|
} |
|
|
|
// Move player |
|
playerGroup.position.x += playerVelocity.x * dt; |
|
playerGroup.position.z += playerVelocity.z * dt; |
|
|
|
// Arena bounds |
|
playerGroup.position.x = Math.max(-HALF_ARENA + PLAYER_RADIUS, Math.min(HALF_ARENA - PLAYER_RADIUS, playerGroup.position.x)); |
|
playerGroup.position.z = Math.max(-HALF_ARENA + PLAYER_RADIUS, Math.min(HALF_ARENA - PLAYER_RADIUS, playerGroup.position.z)); |
|
|
|
// Rotate player based on movement |
|
if (speed > 0.5) { |
|
const targetRot = Math.atan2(playerVelocity.x, playerVelocity.z); |
|
playerGroup.rotation.y += (targetRot - playerGroup.rotation.y) * 10 * dt; |
|
} |
|
|
|
// Player bob animation |
|
playerBody.position.y = Math.sin(now * 0.003) * 0.1; |
|
playerRing.rotation.z = now * 0.002; |
|
|
|
// Invincibility flash - smooth toggle |
|
if (playerInvincible > 0) { |
|
playerInvincible -= dt; |
|
const flashPhase = Math.floor((2 - playerInvincible) * 8) % 2; |
|
playerBody.visible = flashPhase === 0; |
|
} else { |
|
playerBody.visible = true; |
|
} |
|
|
|
// === CAMERA === |
|
const camOffset = new THREE.Vector3(0, CAMERA_HEIGHT, CAMERA_DISTANCE); |
|
// Add slight offset based on player velocity for dynamic feel |
|
camOffset.x += playerVelocity.x * 0.3; |
|
camOffset.z += playerVelocity.z * 0.2; |
|
|
|
const desiredCamPos = playerGroup.position.clone().add(camOffset); |
|
cameraPosition.lerp(desiredCamPos, CAMERA_LERP); |
|
camera.position.copy(cameraPosition); |
|
camera.lookAt(playerGroup.position); |
|
|
|
// === ORBS === |
|
for (let i = orbs.length - 1; i >= 0; i--) { |
|
const orb = orbs[i]; |
|
orb.userData.phase += dt * ORB_SPEED; |
|
orb.position.y = orb.userData.baseY + Math.sin(orb.userData.phase) * 0.3; |
|
orb.rotation.y += dt * 2; |
|
|
|
if (checkCollision(playerGroup, orb, PLAYER_RADIUS, ORB_RADIUS)) { |
|
// Collect! |
|
const now2 = performance.now(); |
|
if (now2 - lastCollectTime < 2000) { |
|
comboCount++; |
|
} else { |
|
comboCount = 1; |
|
} |
|
lastCollectTime = now2; |
|
|
|
const points = 10 * (1 + Math.floor(comboCount / 3)); |
|
score += points; |
|
|
|
// Particles |
|
spawnParticles(orb.position, orb.material.color.getHex(), 12, 3, 4); |
|
|
|
scene.remove(orb); |
|
orbs.splice(i, 1); |
|
|
|
// Spawn new orb |
|
spawnOrb(); |
|
updateHUD(); |
|
|
|
// Combo display |
|
if (comboCount >= 3) { |
|
const comboEl = document.getElementById('combo'); |
|
comboEl.textContent = `${comboCount}x COMBO!`; |
|
comboEl.style.opacity = '1'; |
|
setTimeout(() => { comboEl.style.opacity = '0'; }, 800); |
|
} |
|
} |
|
} |
|
|
|
// === ENEMIES === |
|
const spawnInterval = Math.max(ENEMY_SPAWN_INTERVAL_MIN, ENEMY_SPAWN_INTERVAL_BASE / difficulty); |
|
if (now - lastEnemySpawn > spawnInterval) { |
|
spawnEnemy(); |
|
lastEnemySpawn = now; |
|
} |
|
|
|
for (let i = enemies.length - 1; i >= 0; i--) { |
|
const enemy = enemies[i]; |
|
|
|
// Move toward player |
|
const dir = new THREE.Vector3().subVectors(playerGroup.position, enemy.position); |
|
dir.y = 0; |
|
dir.normalize(); |
|
|
|
const enemySpeed = (ENEMY_BASE_SPEED + (difficulty - 1) * 2) * (0.8 + Math.random() * 0.4); |
|
enemy.position.x += dir.x * enemySpeed * dt; |
|
enemy.position.z += dir.z * enemySpeed * dt; |
|
enemy.position.y = 1.2 + Math.sin(now * 0.004 + enemy.userData.phase) * 0.2; |
|
|
|
// Rotate |
|
enemy.rotation.x += enemy.userData.rotationSpeed.x * dt; |
|
enemy.rotation.y += enemy.userData.rotationSpeed.y * dt; |
|
enemy.rotation.z += enemy.userData.rotationSpeed.z * dt; |
|
|
|
// Collision with player |
|
if (playerInvincible <= 0 && checkCollision(playerGroup, enemy, PLAYER_RADIUS, ENEMY_RADIUS)) { |
|
lives--; |
|
playerInvincible = 2; |
|
screenShake = 0.5; |
|
|
|
// Damage flash |
|
const flash = document.getElementById('damage-flash'); |
|
flash.style.opacity = '1'; |
|
setTimeout(() => { flash.style.opacity = '0'; }, 300); |
|
|
|
// Particles |
|
spawnParticles(playerGroup.position, 0xff0000, 15, 4, 5); |
|
|
|
// Remove enemy |
|
spawnParticles(enemy.position, enemy.material.color.getHex(), 8, 2, 3); |
|
scene.remove(enemy); |
|
enemies.splice(i, 1); |
|
|
|
updateHUD(); |
|
|
|
if (lives <= 0) { |
|
showGameOver(); |
|
return; |
|
} |
|
} |
|
|
|
// Remove if too far from arena |
|
if (Math.abs(enemy.position.x) > HALF_ARENA + 10 || Math.abs(enemy.position.z) > HALF_ARENA + 10) { |
|
scene.remove(enemy); |
|
enemies.splice(i, 1); |
|
} |
|
} |
|
|
|
// === PARTICLES === |
|
updateParticles(dt); |
|
|
|
// === SCREEN SHAKE === |
|
if (screenShake > 0) { |
|
screenShake -= dt * 2; |
|
camera.position.x += (Math.random() - 0.5) * screenShake * 0.3; |
|
camera.position.y += (Math.random() - 0.5) * screenShake * 0.2; |
|
} |
|
|
|
// === ANIMATE LIGHTS === |
|
pointLight1.intensity = 1.5 + Math.sin(now * 0.002) * 0.3; |
|
pointLight2.position.x = -15 + Math.sin(now * 0.001) * 3; |
|
pointLight3.position.x = 15 + Math.cos(now * 0.001) * 3; |
|
} |
|
|
|
function animate() { |
|
requestAnimationFrame(animate); |
|
update(); |
|
renderer.render(scene, camera); |
|
} |
|
|
|
updateHUD(); |
|
animate(); |
|
|
|
})(); |
|
</script> |
|
</body> |
|
</html> |