Spaces:
Running
Running
| <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> | |