TwoPlayerCombat / index.html
awacke1's picture
Update index.html
fb169a2 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js Isometric 3D Combat Game</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
margin: 0;
overflow: hidden;
font-family: 'Inter', sans-serif;
background-color: #1a202c; /* Tailwind gray-900 */
color: #e2e8f0; /* Tailwind slate-200 */
display: flex;
flex-direction: column; /* Allow UI stacking */
align-items: center;
justify-content: center; /* Center game area */
height: 100vh;
position: relative; /* For absolute positioning of UI elements */
}
#game-canvas-wrapper {
/* Wrapper for the canvas, helps in centering or specific sizing */
/* width: 80vw; */ /* Example: Use viewport units for responsiveness */
/* height: 60vh; */
/* max-width: 1000px; */ /* Max size */
/* aspect-ratio: 16 / 9; */ /* Maintain aspect ratio */
border: 2px solid #4a5568; /* Tailwind gray-600 */
border-radius: 0.5rem; /* Tailwind rounded-lg */
position: relative; /* For game over message */
}
canvas {
display: block; /* Remove extra space below canvas */
width: 100%; /* Canvas fills its wrapper */
height: 100%;
}
.score-board {
position: absolute;
top: 20px;
padding: 10px 15px;
font-size: 1.2rem; /* md:text-lg */
font-weight: bold;
color: #1a202c; /* Tailwind gray-900 for text on colored bg */
border-radius: 0.375rem; /* rounded-md */
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
z-index: 10;
}
#player1-ui {
left: 20px;
background-color: #38b2ac; /* Teal */
}
#player2-ui {
right: 20px;
background-color: #ed8936; /* Orange */
}
.shield-timer {
font-size: 0.9rem;
margin-top: 5px;
font-weight: normal;
}
.controls-and-reset {
position: absolute;
bottom: 10px; /* Position at the bottom */
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 700px; /* Adjust width as needed */
z-index: 10;
}
.instructions {
background-color: rgba(45, 55, 72, 0.9); /* Tailwind gray-700 with more opacity */
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
margin-bottom: 10px;
}
.instructions h1 { font-size: 1.2rem; margin-bottom: 0.3rem; }
.instructions p { font-size: 0.85rem; margin-bottom: 0.2rem; }
kbd {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: #1f2937;
background-color: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
margin: 0 0.1rem;
}
#reset-button {
padding: 0.7rem 1.5rem;
font-size: 1rem;
font-weight: bold;
color: white;
background-color: #c53030; /* Tailwind red-700 */
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background-color 0.2s;
}
#reset-button:hover {
background-color: #9b2c2c; /* Tailwind red-800 */
}
#game-over-message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.9);
color: white;
padding: 25px 35px;
border-radius: 10px;
font-size: 2rem;
text-align: center;
z-index: 20;
display: none;
border: 3px solid #e53e3e; /* red-600 */
}
</style>
</head>
<body>
<div id="player1-ui" class="score-board">
<div>P1 Score: <span id="player1-score">0</span></div>
<div>P1 Health: <span id="player1-health">3</span></div>
<div class="shield-timer">Shield: <span id="player1-shield-status">OFF</span></div>
</div>
<div id="player2-ui" class="score-board">
<div>P2 Score: <span id="player2-score">0</span></div>
<div>P2 Health: <span id="player2-health">3</span></div>
<div class="shield-timer">Shield: <span id="player2-shield-status">OFF</span></div>
</div>
<div id="game-canvas-wrapper">
<div id="game-over-message">Game Over!</div>
</div>
<div class="controls-and-reset">
<div class="instructions">
<h1>Isometric Combat!</h1>
<p>P1 (Teal): <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> Move | <kbd>L SHIFT</kbd> Shoot | <kbd>TAB</kbd> Shield</p>
<p>P2 (Orange): <kbd>I</kbd><kbd>J</kbd><kbd>K</kbd><kbd>L</kbd> Move | <kbd>R SHIFT</kbd> Shoot | <kbd>\</kbd> Shield</p>
</div>
<button id="reset-button">Reset Game</button>
</div>
<script>
// --- Game Constants ---
const PLAYER_SPEED = 0.15; // Adjusted for 3D
const PLAYER_RADIUS = 0.5; // For collision, visual size might differ
const PROJECTILE_SIZE = 0.15;
const PROJECTILE_SPEED = 0.4;
const PLAYER_MAX_HEALTH = 3;
const INVADER_RADIUS = 0.6;
const PARATROOPER_RADIUS = 0.4;
const INVADER_FIRE_COOLDOWN = 1800;
const PARATROOPER_FIRE_COOLDOWN = 2200;
const PLAYER_FIRE_COOLDOWN = 300;
const SHIELD_DURATION = 10000; // 10 seconds
const SHIELD_COOLDOWN = 20000; // 20 seconds after shield ends
const GAME_PLANE_WIDTH = 20;
const GAME_PLANE_HEIGHT = 12; // This is depth (Z-axis)
const DIVIDING_LINE_POS_X = 0;
const PARATROOPER_SPAWN_Y = 10;
const PARATROOPER_DROP_SPEED = 0.05;
const PARATROOPER_SPAWN_INTERVAL = 5000; // ms
// --- Global Variables ---
let scene, camera, renderer;
let player1, player2;
let projectiles = [];
let invaders = [];
let paratroopers = [];
let keysPressed = {};
let gameOver = false;
let lastParatrooperSpawnTime = 0;
let ambientLight, directionalLight;
let groundPlane, dividingLineMesh;
// DOM Elements
let player1ScoreEl, player1HealthEl, player1ShieldStatusEl;
let player2ScoreEl, player2HealthEl, player2ShieldStatusEl;
let resetButtonEl, gameOverMessageEl, gameCanvasWrapperEl;
// --- Initialization ---
function init() {
gameCanvasWrapperEl = document.getElementById('game-canvas-wrapper');
player1ScoreEl = document.getElementById('player1-score');
player1HealthEl = document.getElementById('player1-health');
player1ShieldStatusEl = document.getElementById('player1-shield-status');
player2ScoreEl = document.getElementById('player2-score');
player2HealthEl = document.getElementById('player2-health');
player2ShieldStatusEl = document.getElementById('player2-shield-status');
resetButtonEl = document.getElementById('reset-button');
gameOverMessageEl = document.getElementById('game-over-message');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a202c);
setupCamera();
setupLights();
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(gameCanvasWrapperEl.clientWidth || 800, (gameCanvasWrapperEl.clientWidth || 800) * (9/16) ); // Initial size
renderer.shadowMap.enabled = true; // Enable shadows
gameCanvasWrapperEl.appendChild(renderer.domElement);
createGround();
createDividingLine();
resetButtonEl.addEventListener('click', resetGame);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
window.addEventListener('resize', onWindowResize, false);
resetGame();
animate();
}
function setupCamera() {
const aspect = (gameCanvasWrapperEl.clientWidth || 800) / ((gameCanvasWrapperEl.clientWidth || 800) * (9/16));
camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000);
// Isometric-like position
camera.position.set(GAME_PLANE_WIDTH * 0.7, GAME_PLANE_WIDTH * 0.8, GAME_PLANE_HEIGHT * 0.7); // Adjust for good view
camera.lookAt(0, 0, 0); // Look at the center of the scene
}
function setupLights() {
ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 15, 10);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
// Define shadow camera frustum to cover play area
directionalLight.shadow.camera.left = -GAME_PLANE_WIDTH;
directionalLight.shadow.camera.right = GAME_PLANE_WIDTH;
directionalLight.shadow.camera.top = GAME_PLANE_HEIGHT;
directionalLight.shadow.camera.bottom = -GAME_PLANE_HEIGHT;
scene.add(directionalLight);
}
function createGround() {
const groundGeometry = new THREE.PlaneGeometry(GAME_PLANE_WIDTH, GAME_PLANE_HEIGHT);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x4a5568, side: THREE.DoubleSide }); // Tailwind gray-600
groundPlane = new THREE.Mesh(groundGeometry, groundMaterial);
groundPlane.rotation.x = -Math.PI / 2; // Rotate to be flat
groundPlane.receiveShadow = true;
scene.add(groundPlane);
}
function createDividingLine() {
const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
const points = [];
points.push(new THREE.Vector3(DIVIDING_LINE_POS_X, 0.01, -GAME_PLANE_HEIGHT / 2));
points.push(new THREE.Vector3(DIVIDING_LINE_POS_X, 0.01, GAME_PLANE_HEIGHT / 2));
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
dividingLineMesh = new THREE.Line(lineGeometry, lineMaterial);
scene.add(dividingLineMesh);
}
function resetGame() {
gameOver = false;
gameOverMessageEl.style.display = 'none';
keysPressed = {};
projectiles.forEach(p => scene.remove(p)); projectiles = [];
invaders.forEach(i => scene.remove(i.meshGroup)); invaders = []; // Remove group
paratroopers.forEach(pt => scene.remove(pt.meshGroup)); paratroopers = []; // Remove group
if (player1) scene.remove(player1.meshGroup);
if (player2) scene.remove(player2.meshGroup);
createPlayers();
createInitialInvaders();
lastParatrooperSpawnTime = Date.now();
updateUI();
}
// --- Create 3D Assembled Game Elements ---
function createPlayerModel(color) {
const group = new THREE.Group();
// Body (capsule-like: cylinder + two half-spheres)
const bodyRadius = PLAYER_RADIUS * 0.6;
const bodyHeight = PLAYER_RADIUS * 1.2;
const bodyCylinderGeom = new THREE.CylinderGeometry(bodyRadius, bodyRadius, bodyHeight, 16);
const bodyMaterial = new THREE.MeshStandardMaterial({ color: color });
const bodyCylinder = new THREE.Mesh(bodyCylinderGeom, bodyMaterial);
bodyCylinder.castShadow = true;
group.add(bodyCylinder);
const sphereGeom = new THREE.SphereGeometry(bodyRadius, 16, 8);
const topSphere = new THREE.Mesh(sphereGeom, bodyMaterial);
topSphere.position.y = bodyHeight / 2;
topSphere.castShadow = true;
group.add(topSphere);
const bottomSphere = new THREE.Mesh(sphereGeom, bodyMaterial);
bottomSphere.position.y = -bodyHeight / 2;
bottomSphere.castShadow = true;
group.add(bottomSphere);
// "Gun" barrel
const barrelLength = PLAYER_RADIUS * 0.8;
const barrelRadius = PLAYER_RADIUS * 0.15;
const barrelGeom = new THREE.CylinderGeometry(barrelRadius, barrelRadius, barrelLength, 8);
const barrelMaterial = new THREE.MeshStandardMaterial({ color: 0x666666 });
const barrel = new THREE.Mesh(barrelGeom, barrelMaterial);
barrel.rotation.z = Math.PI / 2; // Point forward along X
barrel.position.x = bodyRadius + barrelLength / 2 - 0.1; // Position in front of body
barrel.position.y = 0; // Centered vertically on body
barrel.castShadow = true;
group.add(barrel);
group.position.y = PLAYER_RADIUS * 0.6 + bodyHeight/2; // Sit on ground plane
return group;
}
function createInvaderModel(color) {
const group = new THREE.Group();
const mainBodySize = INVADER_RADIUS * 0.8;
// Main body (Box)
const bodyGeom = new THREE.BoxGeometry(mainBodySize, mainBodySize, mainBodySize);
const bodyMaterial = new THREE.MeshStandardMaterial({ color: color });
const body = new THREE.Mesh(bodyGeom, bodyMaterial);
body.castShadow = true;
group.add(body);
// "Eyes" or "Sensors" (small spheres)
const eyeRadius = mainBodySize * 0.15;
const eyeGeom = new THREE.SphereGeometry(eyeRadius, 8, 8);
const eyeMaterial = new THREE.MeshStandardMaterial({ color: 0xffff00 });
const eye1 = new THREE.Mesh(eyeGeom, eyeMaterial);
eye1.position.set(mainBodySize * 0.25, mainBodySize * 0.2, mainBodySize * 0.51);
group.add(eye1);
const eye2 = new THREE.Mesh(eyeGeom, eyeMaterial);
eye2.position.set(-mainBodySize * 0.25, mainBodySize * 0.2, mainBodySize * 0.51);
group.add(eye2);
group.position.y = mainBodySize / 2; // Sit on ground plane
return group;
}
function createParatrooperModel(color) {
const group = new THREE.Group();
const bodyRadius = PARATROOPER_RADIUS * 0.7;
const bodyHeight = PARATROOPER_RADIUS * 1.5;
// Body (Cylinder)
const bodyGeom = new THREE.CylinderGeometry(bodyRadius*0.7, bodyRadius, bodyHeight, 12);
const bodyMaterial = new THREE.MeshStandardMaterial({ color: color });
const body = new THREE.Mesh(bodyGeom, bodyMaterial);
body.castShadow = true;
group.add(body);
// "Canopy" (half-sphere)
const canopyRadius = PARATROOPER_RADIUS * 1.5;
const canopyGeom = new THREE.SphereGeometry(canopyRadius, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2);
const canopyMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true, opacity: 0.7 });
const canopy = new THREE.Mesh(canopyGeom, canopyMaterial);
canopy.position.y = bodyHeight / 2 + canopyRadius * 0.5;
canopy.castShadow = true; // May not look great with transparency
group.add(canopy);
// No specific ground adjustment here as it drops
return group;
}
function createPlayers() {
player1 = { meshGroup: createPlayerModel(0x38b2ac), // Teal
health: PLAYER_MAX_HEALTH, score: 0, lastShotTime: 0,
shieldActive: false, shieldEndTime: 0, shieldCooldownEndTime: 0,
id: 'player1', radius: PLAYER_RADIUS
};
player1.meshGroup.position.set(-GAME_PLANE_WIDTH / 4, player1.meshGroup.position.y, 0);
scene.add(player1.meshGroup);
player2 = { meshGroup: createPlayerModel(0xed8936), // Orange
health: PLAYER_MAX_HEALTH, score: 0, lastShotTime: 0,
shieldActive: false, shieldEndTime: 0, shieldCooldownEndTime: 0,
id: 'player2', radius: PLAYER_RADIUS
};
player2.meshGroup.position.set(GAME_PLANE_WIDTH / 4, player2.meshGroup.position.y, 0);
// Rotate P2 to face P1
player2.meshGroup.rotation.y = Math.PI;
scene.add(player2.meshGroup);
}
function createInitialInvaders() {
const invaderPositions = [
new THREE.Vector3(0, 0, GAME_PLANE_HEIGHT / 4),
new THREE.Vector3(0, 0, -GAME_PLANE_HEIGHT / 4),
];
invaderPositions.forEach((pos, index) => {
const invaderMeshGroup = createInvaderModel(0x9f7aea); // Purple
invaderMeshGroup.position.set(pos.x, invaderMeshGroup.position.y, pos.z);
const invader = {
meshGroup: invaderMeshGroup, health: 1, id: `invader${index}`,
lastShotTime: 0, radius: INVADER_RADIUS, originalZ: pos.z, oscillationTime: Math.random() * Math.PI * 2
};
scene.add(invader.meshGroup);
invaders.push(invader);
});
}
function spawnParatrooper() {
const spawnX = (Math.random() - 0.5) * (GAME_PLANE_WIDTH * 0.8); // Random X within most of the width
const spawnZ = (Math.random() - 0.5) * (GAME_PLANE_HEIGHT * 0.8); // Random Z within most of the depth
const paratrooperMeshGroup = createParatrooperModel(0xdd6b20); // Darker Orange
paratrooperMeshGroup.position.set(spawnX, PARATROOPER_SPAWN_Y, spawnZ);
const paratrooper = {
meshGroup: paratrooperMeshGroup, health: 1, id: `paratrooper${paratroopers.length}`,
lastShotTime: 0, radius: PARATROOPER_RADIUS, targetY: paratrooperMeshGroup.position.y / 2 + PARATROOPER_RADIUS // Land on its feet
};
scene.add(paratrooper.meshGroup);
paratroopers.push(paratrooper);
lastParatrooperSpawnTime = Date.now();
}
function createProjectile(shooter) {
if (!shooter || shooter.health <= 0) return;
const now = Date.now();
const fireCooldown = (shooter.id.includes('invader') ? INVADER_FIRE_COOLDOWN :
(shooter.id.includes('paratrooper') ? PARATROOPER_FIRE_COOLDOWN : PLAYER_FIRE_COOLDOWN));
if (now - shooter.lastShotTime < fireCooldown) return;
shooter.lastShotTime = now;
const projectileGeom = new THREE.SphereGeometry(PROJECTILE_SIZE, 8, 8);
let projectileMaterial, projectileColor;
let velocity = new THREE.Vector3();
const startPos = shooter.meshGroup.position.clone();
startPos.y += PLAYER_RADIUS * 0.5; // Fire from mid-body height
// Determine direction based on shooter's orientation
const direction = new THREE.Vector3();
shooter.meshGroup.getWorldDirection(direction); // Gets the local -Z direction
if (shooter.id === 'player1') {
projectileColor = 0x81e6d9; // Lighter Teal
velocity.copy(direction).multiplyScalar(-PROJECTILE_SPEED); // Player 1 model faces -Z by default
} else if (shooter.id === 'player2') {
projectileColor = 0xfbd38d; // Lighter Orange
velocity.copy(direction).multiplyScalar(-PROJECTILE_SPEED); // Player 2 model is rotated PI, so its -Z is forward
} else if (shooter.id.includes('invader') || shooter.id.includes('paratrooper')) {
projectileColor = shooter.id.includes('invader') ? 0xc4b5fd : 0xffa07a; // Light purple or light salmon
const targetPlayer = (player1.health > 0 && player2.health > 0) ? (Math.random() < 0.5 ? player1 : player2) : (player1.health > 0 ? player1 : (player2.health > 0 ? player2 : null));
if (targetPlayer) {
velocity.subVectors(targetPlayer.meshGroup.position, shooter.meshGroup.position).normalize().multiplyScalar(PROJECTILE_SPEED * 0.8);
} else { return; } // No valid target
} else { return; }
projectileMaterial = new THREE.MeshStandardMaterial({ color: projectileColor, emissive: projectileColor, emissiveIntensity: 0.5 });
const projectile = new THREE.Mesh(projectileGeom, projectileMaterial);
projectile.castShadow = true;
// Adjust start position slightly in front of shooter based on their facing direction
const offset = direction.clone().multiplyScalar(-shooter.radius * 1.2); // Negative because getWorldDirection gives -Z
startPos.add(offset);
projectile.position.copy(startPos);
projectile.userData = { ownerId: shooter.id, velocity: velocity, creationTime: Date.now() };
scene.add(projectile);
projectiles.push(projectile);
}
// --- Event Handlers ---
function onKeyDown(event) {
if (gameOver && event.key !== "Escape") return; // Allow Esc for potential menu later
keysPressed[event.key.toLowerCase()] = true;
const key = event.key.toLowerCase();
// Player 1 Controls
if (player1.health > 0) {
if (key === 'shift' && event.location === KeyboardEvent.DOM_KEY_LOCATION_LEFT) {
createProjectile(player1); event.preventDefault();
}
if (key === 'tab') {
activateShield(player1); event.preventDefault();
}
}
// Player 2 Controls
if (player2.health > 0) {
if (key === 'shift' && event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT) {
createProjectile(player2); event.preventDefault();
}
if (key === '\\') {
activateShield(player2); event.preventDefault();
}
}
}
function onKeyUp(event) {
keysPressed[event.key.toLowerCase()] = false;
}
function onWindowResize() {
const w = gameCanvasWrapperEl.clientWidth || 800;
const h = (gameCanvasWrapperEl.clientWidth || 800) * (9/16);
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
// --- Game Logic ---
function activateShield(player) {
const now = Date.now();
if (!player.shieldActive && now > player.shieldCooldownEndTime) {
player.shieldActive = true;
player.shieldEndTime = now + SHIELD_DURATION;
player.shieldCooldownEndTime = player.shieldEndTime + SHIELD_COOLDOWN; // Cooldown starts after shield ends
// Visual feedback for shield
if (!player.shieldMesh) {
const shieldGeom = new THREE.SphereGeometry(player.radius * 1.5, 16, 16);
const shieldMat = new THREE.MeshStandardMaterial({ color: 0x00ffff, transparent: true, opacity: 0.3 });
player.shieldMesh = new THREE.Mesh(shieldGeom, shieldMat);
player.meshGroup.add(player.shieldMesh); // Add to player's group
}
player.shieldMesh.visible = true;
updateUI();
}
}
function updateShields() {
const now = Date.now();
[player1, player2].forEach(player => {
if (player.shieldActive && now > player.shieldEndTime) {
player.shieldActive = false;
if (player.shieldMesh) player.shieldMesh.visible = false;
updateUI();
}
});
}
function handlePlayerMovement(player, up, down, left, right) {
if (!player || player.health <= 0) return;
const moveDirection = new THREE.Vector3(0, 0, 0);
if (keysPressed[left]) moveDirection.x -= 1;
if (keysPressed[right]) moveDirection.x += 1;
if (keysPressed[up]) moveDirection.z -= 1; // Forward in local Z
if (keysPressed[down]) moveDirection.z += 1; // Backward in local Z
if (moveDirection.lengthSq() > 0) {
moveDirection.normalize().multiplyScalar(PLAYER_SPEED);
// Apply rotation for turning, then move
if (keysPressed[left]) player.meshGroup.rotation.y += 0.05;
if (keysPressed[right]) player.meshGroup.rotation.y -= 0.05;
// Transform movement to world space based on player's orientation
const worldMove = moveDirection.clone().applyQuaternion(player.meshGroup.quaternion);
player.meshGroup.position.add(worldMove);
}
// Boundary and dividing line checks
const halfWidth = GAME_PLANE_WIDTH / 2 - player.radius;
const halfDepth = GAME_PLANE_HEIGHT / 2 - player.radius;
player.meshGroup.position.z = Math.max(-halfDepth, Math.min(halfDepth, player.meshGroup.position.z));
if (player.id === 'player1') { // Left player
player.meshGroup.position.x = Math.max(-halfWidth, Math.min(DIVIDING_LINE_POS_X - player.radius, player.meshGroup.position.x));
} else { // player2, Right player
player.meshGroup.position.x = Math.max(DIVIDING_LINE_POS_X + player.radius, Math.min(halfWidth, player.meshGroup.position.x));
}
// Collision with other player (simple sphere check)
const otherPlayer = player.id === 'player1' ? player2 : player1;
if (otherPlayer.health > 0) {
const distSq = player.meshGroup.position.distanceToSquared(otherPlayer.meshGroup.position);
if (distSq < (player.radius + otherPlayer.radius) ** 2) {
// Basic push-apart (can be jittery, more complex physics needed for smooth)
const delta = player.meshGroup.position.clone().sub(otherPlayer.meshGroup.position).normalize();
const overlap = (player.radius + otherPlayer.radius) - Math.sqrt(distSq);
player.meshGroup.position.add(delta.multiplyScalar(overlap / 2));
// otherPlayer.meshGroup.position.sub(delta.multiplyScalar(overlap / 2)); // Not strictly needed if only one moves
}
}
}
function updateInvaderBehavior() {
invaders.forEach(invader => {
if (invader.health <= 0) return;
// Simple oscillation on Z for invaders
invader.oscillationTime += 0.02;
invader.meshGroup.position.z = invader.originalZ + Math.sin(invader.oscillationTime) * (GAME_PLANE_HEIGHT * 0.1);
// Aim and fire
if (Date.now() - invader.lastShotTime > INVADER_FIRE_COOLDOWN) {
if (Math.random() < 0.5) createProjectile(invader);
}
});
}
function updateParatroopers() {
for (let i = paratroopers.length - 1; i >= 0; i--) {
const pt = paratroopers[i];
if (pt.health <= 0) continue;
// Drop until they reach their target Y (ground level)
if (pt.meshGroup.position.y > pt.targetY) {
pt.meshGroup.position.y -= PARATROOPER_DROP_SPEED;
} else {
pt.meshGroup.position.y = pt.targetY; // Landed
// Basic movement on ground (e.g., towards center or a player)
// For now, they just stay put and fire
}
// Fire
if (Date.now() - pt.lastShotTime > PARATROOPER_FIRE_COOLDOWN) {
if (Math.random() < 0.4) createProjectile(pt);
}
}
// Spawn new paratroopers
if (Date.now() - lastParatrooperSpawnTime > PARATROOPER_SPAWN_INTERVAL && paratroopers.length < 5) {
spawnParatrooper();
}
}
function updateProjectiles() {
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.position.add(p.userData.velocity);
if (Date.now() - p.userData.creationTime > 5000 || // Lifespan
Math.abs(p.position.x) > GAME_PLANE_WIDTH / 2 + 2 ||
Math.abs(p.position.z) > GAME_PLANE_HEIGHT / 2 + 2 ||
p.position.y < -1 || p.position.y > PARATROOPER_SPAWN_Y + 2) {
scene.remove(p);
projectiles.splice(i, 1);
continue;
}
checkProjectileHit(p, i);
}
}
function checkProjectileHit(projectile, projectileIndex) {
const pPos = projectile.position;
const ownerId = projectile.userData.ownerId;
// Check players
[player1, player2].forEach(player => {
if (player.health <= 0 || player.id === ownerId || player.shieldActive) return;
const distSq = pPos.distanceToSquared(player.meshGroup.position);
if (distSq < (player.radius + PROJECTILE_SIZE) ** 2) {
player.health--;
scene.remove(projectile); projectiles.splice(projectileIndex, 1);
if (!ownerId.includes('invader') && !ownerId.includes('paratrooper')) { // Player hit player
const shooter = ownerId === 'player1' ? player1 : player2;
shooter.score++;
}
// Hit flash (can be improved)
const originalColor = player.id === 'player1' ? 0x38b2ac : 0xed8936;
player.meshGroup.children[0].material.color.setHex(0xff0000);
setTimeout(() => { if(player.meshGroup.children[0]) player.meshGroup.children[0].material.color.setHex(originalColor); }, 100);
updateUI(); checkWinCondition(); return;
}
});
if (projectiles.indexOf(projectile) === -1) return; // Hit a player
// Check invaders
for (let j = invaders.length - 1; j >= 0; j--) {
const inv = invaders[j];
if (inv.health <= 0 || ownerId.includes('invader')) continue;
const distSq = pPos.distanceToSquared(inv.meshGroup.position);
if (distSq < (inv.radius + PROJECTILE_SIZE) ** 2) {
inv.health--;
scene.remove(projectile); projectiles.splice(projectileIndex, 1);
if (ownerId === 'player1') player1.score++; else if (ownerId === 'player2') player2.score++;
if (inv.health <= 0) { scene.remove(inv.meshGroup); invaders.splice(j, 1); }
else { // Hit flash
inv.meshGroup.children[0].material.color.setHex(0xff0000);
setTimeout(() => { if(inv.meshGroup.children[0]) inv.meshGroup.children[0].material.color.setHex(0x9f7aea); }, 100);
}
updateUI(); return;
}
}
if (projectiles.indexOf(projectile) === -1) return;
// Check paratroopers
for (let k = paratroopers.length - 1; k >= 0; k--) {
const pt = paratroopers[k];
if (pt.health <= 0 || ownerId.includes('paratrooper')) continue;
const distSq = pPos.distanceToSquared(pt.meshGroup.position);
if (distSq < (pt.radius + PROJECTILE_SIZE) ** 2) {
pt.health--;
scene.remove(projectile); projectiles.splice(projectileIndex, 1);
if (ownerId === 'player1') player1.score++; else if (ownerId === 'player2') player2.score++;
if (pt.health <= 0) { scene.remove(pt.meshGroup); paratroopers.splice(k, 1); }
else { // Hit flash
pt.meshGroup.children[0].material.color.setHex(0xff0000);
setTimeout(() => { if(pt.meshGroup.children[0]) pt.meshGroup.children[0].material.color.setHex(0xdd6b20); }, 100);
}
updateUI(); return;
}
}
}
function updateUI() {
player1ScoreEl.textContent = player1.score;
player1HealthEl.textContent = Math.max(0, player1.health);
player2ScoreEl.textContent = player2.score;
player2HealthEl.textContent = Math.max(0, player2.health);
const now = Date.now();
player1ShieldStatusEl.textContent = player1.shieldActive ? `ON (${Math.ceil((player1.shieldEndTime - now)/1000)}s)` : (now < player1.shieldCooldownEndTime ? `CD (${Math.ceil((player1.shieldCooldownEndTime - now)/1000)}s)`: 'OFF');
player2ShieldStatusEl.textContent = player2.shieldActive ? `ON (${Math.ceil((player2.shieldEndTime - now)/1000)}s)` : (now < player2.shieldCooldownEndTime ? `CD (${Math.ceil((player2.shieldCooldownEndTime - now)/1000)}s)`: 'OFF');
}
function checkWinCondition() {
if (gameOver) return;
let winner = null;
if (player1.health <= 0 && player2.health <=0) winner = "It's a Draw!";
else if (player1.health <= 0) winner = "Player 2 Wins!";
else if (player2.health <= 0) winner = "Player 1 Wins!";
if (winner) {
gameOver = true;
gameOverMessageEl.textContent = winner;
gameOverMessageEl.style.display = 'block';
}
}
// --- Animation Loop ---
function animate() {
requestAnimationFrame(animate);
if (!gameOver) {
handlePlayerMovement(player1, 'w', 's', 'a', 'd');
handlePlayerMovement(player2, 'i', 'k', 'j', 'l');
updateInvaderBehavior();
updateParatroopers();
updateShields();
}
updateProjectiles();
updateUI(); // Continuously update UI for timers
renderer.render(scene, camera);
}
// --- Start the game ---
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
</body>
</html>