awacke1's picture
Update index.html
60c08e9 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blocky Brawl: Dungeon Dimensions</title>
<style>
/* Basic styling for the body to remove default margins and overflows */
body {
margin: 0;
overflow: hidden; /* Hide scrollbars */
font-family: 'Inter', sans-serif; /* Use Inter font */
background-color: #1a1a1a; /* Dark background for the page */
}
/* Container for the game canvas and UI elements */
#game-container {
position: relative;
width: 100vw; /* Full viewport width */
height: 100vh; /* Full viewport height */
display: flex; /* Use flexbox for centering */
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically */
overflow: hidden; /* Ensure no overflow */
}
/* Main UI container at the top center */
#top-ui-container {
position: absolute;
top: 20px; /* Distance from the top */
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 15px; /* Space between UI sections */
z-index: 10;
pointer-events: none; /* Allow interaction with elements inside */
}
/* Container for scores and health/mana bars */
#player-stats-container {
display: flex;
justify-content: space-between;
width: 100%; /* Will be set dynamically by JS to fit content */
max-width: 800px; /* Max width for the stats section */
gap: 50px; /* Space between player stat blocks */
pointer-events: auto; /* Re-enable pointer events for UI elements */
}
/* Styling for the score displays */
.score-display {
color: white; /* White text color */
font-size: 2.2em; /* Larger font size */
font-weight: bold; /* Bold text */
text-shadow: 3px 3px 6px rgba(0,0,0,0.8); /* Stronger text shadow for readability */
padding: 10px 15px; /* Padding around text */
background-color: rgba(0, 0, 0, 0.4); /* Semi-transparent background */
border-radius: 12px; /* Rounded corners */
box-shadow: 0 4px 10px rgba(0,0,0,0.5); /* Subtle box shadow */
text-align: center;
min-width: 150px; /* Ensure minimum width */
}
/* Styling for the health and mana bars container */
.player-bars {
height: 70px; /* Height of the bar container */
width: 250px; /* Max width for bars */
border: 3px solid #ffffff; /* White border */
border-radius: 10px; /* Rounded corners */
box-shadow: 0 4px 10px rgba(0,0,0,0.5); /* Subtle box shadow */
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 5px;
box-sizing: border-box;
background-color: rgba(0, 0, 0, 0.4); /* Background for the bars container */
}
.bar-container {
width: 100%;
height: 25px; /* Height for individual bars */
background-color: #4a5568; /* Grey background for empty bar */
border-radius: 8px;
overflow: hidden;
}
.health-bar, .mana-bar {
height: 100%;
width: 100%; /* Initial width, will be updated by JS */
transition: width 0.3s ease-out, background-color 0.3s ease-out; /* Smooth transitions */
border-radius: 8px;
}
.health-bar { background-color: #28a745; } /* Green for health */
.mana-bar { background-color: #3b82f6; } /* Blue for mana */
/* Styling for the reset button */
#reset-button {
position: absolute;
bottom: 40px; /* Distance from the bottom */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Adjust for true centering */
padding: 18px 35px; /* Generous padding */
font-size: 1.8em; /* Larger font size */
background: linear-gradient(145deg, #ff6b6b, #ee4444); /* Gradient background */
color: white; /* White text */
border: none; /* No border */
border-radius: 15px; /* More rounded corners */
cursor: pointer; /* Pointer cursor on hover */
box-shadow: 0 8px 20px rgba(0,0,0,0.6); /* Stronger shadow */
transition: background 0.3s ease, transform 0.1s ease, box-shadow 0.3s ease; /* Smooth transitions */
z-index: 10; /* Ensure it's above the canvas */
font-weight: bold; /* Bold text */
letter-spacing: 1px; /* Slight letter spacing */
text-transform: uppercase; /* Uppercase text */
pointer-events: auto; /* Allow interaction */
}
#reset-button:hover {
background: linear-gradient(145deg, #ff4d4d, #cc3333); /* Darker gradient on hover */
transform: translateX(-50%) scale(1.05); /* Slightly enlarge on hover */
box-shadow: 0 10px 25px rgba(0,0,0,0.8); /* Deeper shadow on hover */
}
#reset-button:active {
transform: translateX(-50%) scale(0.98); /* Shrink slightly on click */
box-shadow: 0 4px 10px rgba(0,0,0,0.4); /* Recessed shadow on click */
}
/* Styling for the Three.js canvas */
canvas {
display: block; /* Remove extra space below canvas */
width: 100%; /* Make canvas fill its container */
height: 100%; /* Make canvas fill its container */
border-radius: 15px; /* Rounded corners for the canvas itself */
box-shadow: 0 0 25px rgba(0,0,0,0.7); /* Shadow around the canvas */
}
/* Controls display */
#controls {
background-color: rgba(0, 0, 0, 0.6);
color: white;
padding: 15px 25px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.7);
font-size: 1.1em;
text-align: center;
pointer-events: auto; /* Allow interaction */
display: flex;
gap: 40px; /* Space between player control sections */
}
#controls h3 {
margin-top: 0;
color: #ffd700; /* Gold color for headings */
font-size: 1.3em;
margin-bottom: 10px;
}
#controls ul {
list-style: none;
padding: 0;
margin: 0;
text-align: left;
}
#controls li {
margin-bottom: 5px;
}
#controls span {
font-weight: bold;
color: #aaffaa; /* Light green for keys */
display: inline-block;
width: 40px; /* Fixed width for key display */
}
</style>
</head>
<body>
<div id="game-container">
<div id="top-ui-container">
<div id="player-stats-container">
<div>
<div id="score-left" class="score-display">P1 Score: 0</div>
<div id="player-left-bars" class="player-bars">
<div class="bar-container"><div id="health-bar-left" class="health-bar"></div></div>
<div class="bar-container"><div id="mana-bar-left" class="mana-bar"></div></div>
</div>
</div>
<div>
<div id="score-right" class="score-display">P2 Score: 0</div>
<div id="player-right-bars" class="player-bars">
<div class="bar-container"><div id="health-bar-right" class="health-bar"></div></div>
<div class="bar-container"><div id="mana-bar-right" class="mana-bar"></div></div>
</div>
</div>
</div>
<div id="controls">
<div>
<h3>Player 1 Controls (Red Wizard)</h3>
<ul>
<li><span>WASD:</span> Move</li>
<li><span>E:</span> Cast Spell</li>
</ul>
</div>
<div>
<h3>Player 2 Controls (Blue Wizard)</h3>
<ul>
<li><span>IJKL:</span> Move</li>
<li><span>U:</span> Cast Spell</li>
</ul>
</div>
</div>
</div>
<button id="reset-button">Primitive Reset!</button>
</div>
<script type="module">
// Import Three.js library from a reliable CDN
import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
// Declare global variables for Three.js scene, camera, renderer, and game state
let scene, camera, renderer;
let player1, player2; // Our primitive-assembled characters (Wizards)
let monsters = []; // Array to hold monster objects
let activeProjectiles = []; // Array to hold active spell projectiles
// Game grid dimensions and unit size
const gridSize = 10; // Size of one grid square in Three.js units
const mapWidth = 20; // Number of grid squares wide
const mapHeight = 20; // Number of grid squares high
const dungeonMap = []; // 2D array to represent the dungeon layout (0: floor, 1: wall)
// Player statistics
const playerStats = {
left: {
health: 100,
maxHealth: 100,
mana: 50,
maxMana: 50,
score: 0,
position: new THREE.Vector3(-5 * gridSize, 0, -5 * gridSize) // Initial world position
},
right: {
health: 100,
maxHealth: 100,
mana: 50,
maxMana: 50,
score: 0,
position: new THREE.Vector3(5 * gridSize, 0, 5 * gridSize) // Initial world position
}
};
// Movement speed for players and monsters
const playerMoveSpeed = 0.15 * gridSize; // Adjust for desired player speed relative to grid
const monsterMoveSpeed = 0.05 * gridSize; // Adjust for desired monster speed relative to grid
// References to UI HTML elements
const uiElements = {
scoreLeft: document.getElementById('score-left'),
scoreRight: document.getElementById('score-right'),
healthBarLeft: document.getElementById('health-bar-left'),
manaBarLeft: document.getElementById('mana-bar-left'),
healthBarRight: document.getElementById('health-bar-right'),
manaBarRight: document.getElementById('mana-bar-right'),
resetButton: document.getElementById('reset-button')
};
// Object to keep track of pressed keys for smooth movement
const keys = {
// Player 1 movement
w: false, a: false, s: false, d: false,
// Player 1 action
p1_action: false,
// Player 2 movement
i: false, j: false, k: false, l: false,
// Player 2 action
p2_action: false
};
// Spell projectile parameters
const spellProjectileSpeed = 0.8 * gridSize;
const spellProjectileLife = 120; // frames (2 seconds at 60fps)
const spellProjectileRadius = 0.03 * gridSize; // Adjusted for grid size
const spellDamage = 20; // Damage dealt by a spell
// Player-to-player healing parameters
const playerHealingThreshold = gridSize * 2.5; // Players heal each other if within this distance
const healingAmountPerFrame = 0.1; // Small continuous heal
/**
* Handles window resize events to keep the camera and renderer aspect ratio correct.
*/
function onWindowResize() {
const aspectRatio = window.innerWidth / window.innerHeight;
const frustumSize = Math.max(mapWidth, mapHeight) * gridSize * 0.7;
camera.left = frustumSize * aspectRatio / - 2;
camera.right = frustumSize * aspectRatio / 2;
camera.top = frustumSize / 2;
camera.bottom = frustumSize / - 2;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
/**
* Handles keydown events, setting the corresponding key in the `keys` object to true.
* @param {KeyboardEvent} event - The keyboard event.
*/
function onKeyDown(event) {
switch (event.code) {
// Player 1 Movement
case 'KeyW': keys.w = true; break;
case 'KeyA': keys.a = true; break;
case 'KeyS': keys.s = true; break;
case 'KeyD': keys.d = true; break;
// Player 1 Action
case 'KeyE': keys.p1_action = true; break;
// Player 2 Movement
case 'KeyI': keys.i = true; break;
case 'KeyJ': keys.j = true; break;
case 'KeyK': keys.k = true; break;
case 'KeyL': keys.l = true; break;
// Player 2 Action
case 'KeyU': keys.p2_action = true; break;
}
}
/**
* Handles keyup events, setting the corresponding key in the `keys` object to false.
* @param {KeyboardEvent} event - The keyboard event.
*/
function onKeyUp(event) {
switch (event.code) {
case 'KeyW': keys.w = false; break;
case 'KeyA': keys.a = false; break;
case 'KeyS': keys.s = false; break;
case 'KeyD': keys.d = false; break;
case 'KeyE': keys.p1_action = false; break;
case 'KeyI': keys.i = false; break;
case 'KeyJ': keys.j = false; break;
case 'KeyK': keys.k = false; break;
case 'KeyL': keys.l = false; break;
case 'KeyU': keys.p2_action = false; break;
}
}
/**
* Generates a simple dungeon layout with floor and wall tiles.
* The dungeon is a large open room with a border of walls and some random inner walls.
*/
function generateDungeon() {
for (let y = 0; y < mapHeight; y++) {
dungeonMap[y] = []; // Initialize inner array for each row
for (let x = 0; x < mapWidth; x++) {
// Create a border of walls around the map
if (x === 0 || x === mapWidth - 1 || y === 0 || y === mapHeight - 1) {
dungeonMap[y][x] = 1; // Mark as wall
createWall(x, y); // Create the 3D wall object
} else if (Math.random() < 0.05) { // 5% chance of a random inner wall
dungeonMap[y][x] = 1; // Mark as wall
createWall(x, y); // Create the 3D wall object
} else {
dungeonMap[y][x] = 0; // Mark as floor
createFloor(x, y); // Create the 3D floor object
}
}
}
}
/**
* Creates a 3D floor tile at the given grid coordinates.
* Uses MeshPhongMaterial for better lighting.
* @param {number} x - The x-coordinate on the dungeon grid.
* @param {number} y - The y-coordinate on the dungeon grid.
*/
function createFloor(x, y) {
const geometry = new THREE.BoxGeometry(gridSize, 1, gridSize); // Flat box for floor
const material = new THREE.MeshPhongMaterial({
color: 0x3d4a5c, // Darker grey floor color
specular: 0x111111, // Slight specular highlight
shininess: 30 // Moderate shininess
});
const floor = new THREE.Mesh(geometry, material);
// Position the floor tile correctly in the 3D world
floor.position.set(
(x - mapWidth / 2 + 0.5) * gridSize, // Center X of the grid square
-0.5, // Half height of the floor to be below the ground plane (y=0)
(y - mapHeight / 2 + 0.5) * gridSize // Center Z of the grid square
);
scene.add(floor); // Add to the scene
}
/**
* Creates a 3D wall tile at the given grid coordinates.
* Uses MeshPhongMaterial for better lighting.
* @param {number} x - The x-coordinate on the dungeon grid.
* @param {number} y - The y-coordinate on the dungeon grid.
*/
function createWall(x, y) {
const geometry = new THREE.BoxGeometry(gridSize, gridSize * 2, gridSize); // Taller box for walls
const material = new THREE.MeshPhongMaterial({
color: 0x90a4ae, // Lighter grey wall color
specular: 0x333333, // More pronounced specular highlight
shininess: 60 // Higher shininess for walls
});
const wall = new THREE.Mesh(geometry, material);
// Position the wall tile correctly in the 3D world
wall.position.set(
(x - mapWidth / 2 + 0.5) * gridSize,
gridSize, // Position walls so they sit on the floor (half of wall height from y=0)
(y - mapHeight / 2 + 0.5) * gridSize
);
scene.add(wall); // Add to the scene
}
/**
* Creates a 3D wizard character assembled from primitive shapes, made less blocky.
* @param {number} color - The base color for the wizard's body.
* @returns {THREE.Group} A Three.js Group containing all wizard parts.
*/
function createWizard(color) {
const wizardGroup = new THREE.Group();
const scaleFactor = 0.8; // Overall scale for the wizard
const bodyHeight = 1.0 * scaleFactor;
const bodyRadiusTop = 0.3 * scaleFactor;
const bodyRadiusBottom = 0.4 * scaleFactor;
const headRadius = 0.25 * scaleFactor;
const hatHeight = 0.6 * scaleFactor;
const hatRadius = 0.35 * scaleFactor;
const armRadius = 0.1 * scaleFactor;
const armLength = 0.8 * scaleFactor;
const staffHandleLength = 1.2 * scaleFactor;
const staffOrbRadius = 0.15 * scaleFactor;
// Body (Capsule-like: Cylinder + two Hemispheres)
const bodyCylinder = new THREE.Mesh(
new THREE.CylinderGeometry(bodyRadiusTop, bodyRadiusBottom, bodyHeight, 32), // High segments for smooth body
new THREE.MeshPhongMaterial({ color: color, specular: 0x555555, shininess: 80 })
);
bodyCylinder.position.y = bodyHeight / 2;
wizardGroup.add(bodyCylinder);
const bodyTopSphere = new THREE.Mesh(
new THREE.SphereGeometry(bodyRadiusTop, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2), // Top hemisphere
new THREE.MeshPhongMaterial({ color: color, specular: 0x555555, shininess: 80 })
);
bodyTopSphere.position.y = bodyHeight;
wizardGroup.add(bodyTopSphere);
const bodyBottomSphere = new THREE.Mesh(
new THREE.SphereGeometry(bodyRadiusBottom, 32, 16, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2), // Bottom hemisphere
new THREE.MeshPhongMaterial({ color: color, specular: 0x555555, shininess: 80 })
);
bodyBottomSphere.position.y = 0;
wizardGroup.add(bodyBottomSphere);
// Head (Sphere)
const headGeometry = new THREE.SphereGeometry(headRadius, 64, 32); // Very high segments for smooth head
const headMaterial = new THREE.MeshPhongMaterial({ color: 0xffe0bd, specular: 0x222222, shininess: 50 }); // Skin tone
const head = new THREE.Mesh(headGeometry, headMaterial);
head.position.y = bodyHeight + headRadius; // On top of body
wizardGroup.add(head);
// Hat (Cone)
const hatGeometry = new THREE.ConeGeometry(hatRadius, hatHeight, 32); // High segments for smooth hat
const hatMaterial = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x111111, shininess: 70 }); // Dark hat
const hat = new THREE.Mesh(hatGeometry, hatMaterial);
hat.position.y = head.position.y + headRadius + hatHeight / 2; // On top of head
wizardGroup.add(hat);
// Arms (Cylinders)
const armGeometry = new THREE.CylinderGeometry(armRadius, armRadius, armLength, 16);
const armMaterial = new THREE.MeshPhongMaterial({ color: 0xaaaaaa, specular: 0x333333, shininess: 40 });
const leftArm = new THREE.Mesh(armGeometry, armMaterial);
leftArm.position.set(-bodyRadiusTop - armRadius, bodyHeight * 0.7, 0);
leftArm.rotation.z = Math.PI / 2; // Point outwards
wizardGroup.add(leftArm);
const rightArm = leftArm.clone();
rightArm.position.x = bodyRadiusTop + armRadius;
rightArm.rotation.z = -Math.PI / 2; // Point outwards
wizardGroup.add(rightArm);
// Staff (Cylinder and Sphere)
const staffHandleGeometry = new THREE.CylinderGeometry(0.005 * gridSize, 0.005 * gridSize, staffHandleLength, 16);
const staffHandleMaterial = new THREE.MeshPhongMaterial({ color: 0x8b4513, specular: 0x333333, shininess: 40 }); // Brown
const staffHandle = new THREE.Mesh(staffHandleGeometry, staffHandleMaterial);
staffHandle.position.set(0.5 * scaleFactor, bodyHeight * 0.5, 0.5 * scaleFactor); // Position relative to wizard
staffHandle.rotation.x = Math.PI / 4; // Angle the staff
wizardGroup.add(staffHandle);
const staffOrbGeometry = new THREE.SphereGeometry(staffOrbRadius, 32, 16); // High segments for smooth orb
const staffOrbMaterial = new THREE.MeshPhongMaterial({ color: 0x00ffff, emissive: 0x00ffff, emissiveIntensity: 0.5, specular: 0xffffff, shininess: 100 }); // Glowing Cyan orb
const staffOrb = new THREE.Mesh(staffOrbGeometry, staffOrbMaterial);
staffOrb.position.copy(staffHandle.position);
staffOrb.position.y += staffHandleLength / 2; // At the top of the staff
wizardGroup.add(staffOrb);
wizardGroup.scale.set(gridSize, gridSize, gridSize); // Scale the entire wizard to fit the grid unit
return wizardGroup; // Return the group containing all parts
}
/**
* Creates a 3D monster assembled from primitive shapes, made less blocky.
* @param {number} x - The x-coordinate on the dungeon grid.
* @param {number} y - The y-coordinate on the dungeon grid.
* @param {number} color - The color of the monster.
*/
function createMonster(x, y, color = 0x800080) { // Default purple monster
const monsterGroup = new THREE.Group();
const scaleFactor = 0.7; // Overall scale for the monster
// Main Body (Sphere)
const bodyGeometry = new THREE.SphereGeometry(0.5 * scaleFactor, 32, 16); // Smoother sphere
const bodyMaterial = new THREE.MeshPhongMaterial({ color: color, specular: 0x444444, shininess: 50 });
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
body.position.y = 0.5 * scaleFactor; // Center on the ground
monsterGroup.add(body);
// Eyes (small spheres)
const eyeGeometry = new THREE.SphereGeometry(0.1 * scaleFactor, 16, 8);
const eyeMaterial = new THREE.MeshPhongMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.8 });
const pupilMaterial = new THREE.MeshPhongMaterial({ color: 0x000000 });
const eye1 = new THREE.Mesh(eyeGeometry, eyeMaterial);
eye1.position.set(-0.2 * scaleFactor, 0.6 * scaleFactor, 0.4 * scaleFactor);
monsterGroup.add(eye1);
const pupil1 = new THREE.Mesh(eyeGeometry.clone(), pupilMaterial);
pupil1.scale.set(0.5, 0.5, 0.5);
pupil1.position.set(-0.2 * scaleFactor, 0.6 * scaleFactor, 0.45 * scaleFactor);
monsterGroup.add(pupil1);
const eye2 = eye1.clone();
eye2.position.x = 0.2 * scaleFactor;
monsterGroup.add(eye2);
const pupil2 = pupil1.clone();
pupil2.position.x = 0.2 * scaleFactor;
monsterGroup.add(pupil2);
// Spikes (Cones)
const spikeGeometry = new THREE.ConeGeometry(0.15 * scaleFactor, 0.4 * scaleFactor, 8);
const spikeMaterial = new THREE.MeshPhongMaterial({ color: 0x555555, specular: 0x222222, shininess: 30 });
const spike1 = new THREE.Mesh(spikeGeometry, spikeMaterial);
spike1.position.set(0, 0.9 * scaleFactor, 0);
monsterGroup.add(spike1);
const spike2 = spike1.clone();
spike2.rotation.y = Math.PI / 2;
spike2.position.set(0.5 * scaleFactor, 0.5 * scaleFactor, 0);
monsterGroup.add(spike2);
const spike3 = spike1.clone();
spike3.rotation.y = -Math.PI / 2;
spike3.position.set(-0.5 * scaleFactor, 0.5 * scaleFactor, 0);
monsterGroup.add(spike3);
monsterGroup.scale.set(gridSize, gridSize, gridSize); // Scale the entire monster to fit the grid unit
// Position the monster mesh in the 3D world
monsterGroup.position.set(
(x - mapWidth / 2 + 0.5) * gridSize,
0, // Base of monster at y=0
(y - mapHeight / 2 + 0.5) * gridSize
);
scene.add(monsterGroup); // Add to the scene
// Create a monster object to hold its mesh, stats
const monster = {
mesh: monsterGroup,
health: 50,
maxHealth: 50,
};
monsters.push(monster); // Add to the monsters array
}
/**
* Updates the 3D position of a player/monster mesh based on its game world coordinates.
* @param {THREE.Group} objectMesh - The Three.js Group representing the object.
* @param {number} worldX - The x-coordinate in the 3D world.
* @param {number} worldZ - The z-coordinate in the 3D world.
*/
function updateObjectPosition(objectMesh, worldX, worldZ) {
objectMesh.position.x = worldX;
objectMesh.position.z = worldZ;
}
/**
* Checks for collision with walls for a given position.
* Returns true if the position is inside a wall, false otherwise.
* @param {number} x - World X coordinate.
* @param {number} z - World Z coordinate.
* @returns {boolean}
*/
function isCollidingWithWall(x, z) {
// Convert world coordinates to grid coordinates
const gridX = Math.floor(x / gridSize + mapWidth / 2);
const gridY = Math.floor(z / gridSize + mapHeight / 2); // Z maps to Y in our 2D map
// Check boundaries
if (gridX < 0 || gridX >= mapWidth || gridY < 0 || gridY >= mapHeight) {
return true; // Out of bounds is a collision
}
// Check if it's a wall tile
return dungeonMap[gridY][gridX] === 1;
}
/**
* Updates player position with collision detection and sliding.
* @param {object} playerStat - The player's statistics object (playerStats.left or playerStats.right).
* @param {THREE.Group} playerMesh - The player's Three.js mesh.
* @param {number} moveX - Desired movement in X direction.
* @param {number} moveZ - Desired movement in Z direction.
*/
function updatePlayerMovement(playerStat, playerMesh, moveX, moveZ) {
let newX = playerStat.position.x + moveX;
let newZ = playerStat.position.z + moveZ; // Use .z for world Z
let collidedX = isCollidingWithWall(newX, playerStat.position.z);
let collidedZ = isCollidingWithWall(playerStat.position.x, newZ);
if (!collidedX) {
playerStat.position.x = newX;
}
if (!collidedZ) {
playerStat.position.z = newZ;
}
updateObjectPosition(playerMesh, playerStat.position.x, playerStat.position.z);
}
/**
* Creates a temporary visual spell effect at a given position.
* The effect expands and fades out over time.
* @param {THREE.Vector3} position - The world position where the spell effect should appear.
* @param {number} color - The color of the spell effect.
*/
function createSpellEffect(position, color) {
const geometry = new THREE.SphereGeometry(spellProjectileRadius * 2, 16, 16); // Sphere for the effect
const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.8 });
const spellEffect = new THREE.Mesh(geometry, material);
spellEffect.position.copy(position); // Copy player's position
spellEffect.position.y = gridSize * 0.5; // Slightly above ground
scene.add(spellEffect);
// Animation variables for scaling and fading
let scale = 0.1;
const fadeSpeed = 0.05;
/**
* Animates the spell effect (scaling up and fading out).
*/
function animateEffect() {
if (spellEffect.material.opacity > 0) {
spellEffect.material.opacity -= fadeSpeed; // Reduce opacity
scale += 0.05; // Increase scale
spellEffect.scale.set(scale, scale, scale); // Apply new scale
requestAnimationFrame(animateEffect); // Continue animation
} else {
scene.remove(spellEffect); // Remove from scene when fully faded
spellEffect.geometry.dispose(); // Clean up geometry
spellEffect.material.dispose(); // Clean up material
}
}
animateEffect(); // Start the effect animation
}
/**
* Class to represent a spell projectile.
*/
class Projectile extends THREE.Mesh {
constructor(originPlayer, targetPosition, damage) {
const geometry = new THREE.SphereGeometry(spellProjectileRadius, 16, 16);
const material = new THREE.MeshPhongMaterial({ color: 0xffd700, emissive: 0xffd700, emissiveIntensity: 0.5 });
super(geometry, material);
this.originPlayer = originPlayer;
this.damage = damage;
this.life = spellProjectileLife;
// Set initial position near the origin player, slightly above ground
this.position.copy(originPlayer.position);
this.position.y = gridSize * 0.5;
// Calculate velocity towards the target position
this.velocity = new THREE.Vector3();
this.velocity.subVectors(targetPosition, this.position).normalize().multiplyScalar(spellProjectileSpeed);
scene.add(this); // Add projectile to the scene
}
update() {
this.position.add(this.velocity);
this.life--;
}
}
/**
* Updates the health, mana, and score displayed in the UI.
*/
function updateUI() {
// Update Left Player UI
uiElements.scoreLeft.textContent = `P1 Score: ${playerStats.left.score}`;
uiElements.healthBarLeft.style.width = `${(playerStats.left.health / playerStats.left.maxHealth) * 100}%`;
uiElements.healthBarLeft.style.backgroundColor = playerStats.left.health > playerStats.left.maxHealth * 0.6 ? '#28a745' : (playerStats.left.health > playerStats.left.maxHealth * 0.3 ? '#ffc107' : '#dc3545');
uiElements.manaBarLeft.style.width = `${(playerStats.left.mana / playerStats.left.maxMana) * 100}%`;
// Update Right Player UI
uiElements.scoreRight.textContent = `P2 Score: ${playerStats.right.score}`;
uiElements.healthBarRight.style.width = `${(playerStats.right.health / playerStats.right.maxHealth) * 100}%`;
uiElements.healthBarRight.style.backgroundColor = playerStats.right.health > playerStats.right.maxHealth * 0.6 ? '#28a745' : (playerStats.right.health > playerStats.right.maxHealth * 0.3 ? '#ffc107' : '#dc3545');
uiElements.manaBarRight.style.width = `${(playerStats.right.mana / playerStats.right.maxMana) * 100}%`;
}
/**
* Resets the game state: restores player health/mana, resets scores,
* moves players to starting positions, and respawns monsters.
*/
function resetGame() {
// Reset Left Player stats and position
playerStats.left.health = playerStats.left.maxHealth;
playerStats.left.mana = playerStats.left.maxMana;
playerStats.left.score = 0;
playerStats.left.position.set(-5 * gridSize, 0, -5 * gridSize);
updateObjectPosition(player1, playerStats.left.position.x, playerStats.left.position.z);
// Reset Right Player stats and position
playerStats.right.health = playerStats.right.maxHealth;
playerStats.right.mana = playerStats.right.maxMana;
playerStats.right.score = 0;
playerStats.right.position.set(5 * gridSize, 0, 5 * gridSize);
updateObjectPosition(player2, playerStats.right.position.x, playerStats.right.position.z);
// Remove all existing monsters from the scene and array
monsters.forEach(monster => scene.remove(monster.mesh));
monsters = [];
spawnMonsters(3); // Spawn a new set of monsters
// Remove all active projectiles
while (activeProjectiles.length > 0) {
const projectile = activeProjectiles.pop();
scene.remove(projectile);
projectile.geometry.dispose();
projectile.material.dispose();
}
updateUI(); // Update UI to reflect reset stats
console.log("Game reset!");
}
/**
* Spawns a specified number of monsters at random valid locations in the dungeon.
* @param {number} count - The number of monsters to spawn.
*/
function spawnMonsters(count) {
for (let i = 0; i < count; i++) {
let spawned = false;
while (!spawned) {
// Generate random grid coordinates, avoiding the border walls
const randomGridX = Math.floor(Math.random() * (mapWidth - 2)) + 1;
const randomGridY = Math.floor(Math.random() * (mapHeight - 2)) + 1;
// Convert to world coordinates for spawning check
const worldX = (randomGridX - mapWidth / 2 + 0.5) * gridSize;
const worldZ = (randomGridY - mapHeight / 2 + 0.5) * gridSize;
// Ensure the spawn location is a floor tile and not too close to players
if (dungeonMap[randomGridY][randomGridX] === 0 &&
new THREE.Vector3(worldX, 0, worldZ).distanceTo(player1.position) > gridSize * 3 &&
new THREE.Vector3(worldX, 0, worldZ).distanceTo(player2.position) > gridSize * 3) {
createMonster(randomGridX, randomGridY); // Create the monster
spawned = true; // Monster successfully spawned
}
}
}
}
/**
* Initializes the Three.js scene, camera, renderer, and game elements.
*/
function init() {
// Scene Setup: The container for all 3D objects, lights, and cameras
scene = new THREE.Scene();
scene.background = new THREE.Color(0x333333); // Dark grey background for the 3D scene
// Camera Setup: Orthographic camera for a top-down semi-isometric view
const aspectRatio = window.innerWidth / window.innerHeight;
const frustumSize = Math.max(mapWidth, mapHeight) * gridSize * 0.7; // Adjusted frustum size for dungeon
camera = new THREE.OrthographicCamera(
frustumSize * aspectRatio / - 2,
frustumSize * aspectRatio / 2,
frustumSize / 2,
frustumSize / - 2,
1, 1000
);
// Position camera for a top-down semi-isometric view
camera.position.set(0, 40, 40); // Higher Y, and equal X/Z for a balanced angle
camera.lookAt(0, 0, 0); // Make the camera look directly at the origin
// Renderer Setup: Renders the 3D scene onto a 2D canvas
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('game-container').appendChild(renderer.domElement);
// Lighting: Essential for seeing 3D objects
const ambientLight = new THREE.AmbientLight(0x404040, 2); // Soft ambient light, affects all objects evenly
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1); // Directional light, like sunlight
directionalLight.position.set(0, 100, 0); // Positioned above the scene
scene.add(directionalLight);
// Generate the dungeon environment
generateDungeon();
// Player 1 Character: Wizard
player1 = createWizard(0xff4500); // Red-orange color
player1.position.copy(playerStats.left.position);
scene.add(player1);
// Player 2 Character: Wizard
player2 = createWizard(0x00aaff); // Blue color
player2.position.copy(playerStats.right.position);
scene.add(player2);
// UI Elements Initialization and Event Listeners
uiElements.resetButton.addEventListener('click', resetGame);
// Event Listeners for Keyboard Input
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('resize', onWindowResize);
// Spawn initial monsters
spawnMonsters(3);
// Update UI for the first time
updateUI();
// Start the animation loop
animate();
}
/**
* The main animation loop for Three.js.
* It continuously renders the scene and calls the game logic.
*/
function animate() {
requestAnimationFrame(animate);
// Player 1 Movement
let p1MoveX = 0, p1MoveZ = 0;
if (keys.w) p1MoveZ -= playerMoveSpeed; // Up
if (keys.s) p1MoveZ += playerMoveSpeed; // Down
if (keys.a) p1MoveX -= playerMoveSpeed; // Left
if (keys.d) p1MoveX += playerMoveSpeed; // Right
// Normalize diagonal movement speed
if (p1MoveX !== 0 && p1MoveZ !== 0) {
const length = Math.sqrt(p1MoveX * p1MoveX + p1MoveZ * p1MoveZ);
p1MoveX /= length;
p1MoveZ /= length;
p1MoveX *= playerMoveSpeed;
p1MoveZ *= playerMoveSpeed;
}
updatePlayerMovement(playerStats.left, player1, p1MoveX, p1MoveZ);
// Player 2 Movement
let p2MoveX = 0, p2MoveZ = 0;
if (keys.i) p2MoveZ -= playerMoveSpeed; // Up
if (keys.k) p2MoveZ += playerMoveSpeed; // Down
if (keys.j) p2MoveX -= playerMoveSpeed; // Left
if (keys.l) p2MoveX += playerMoveSpeed; // Right
// Normalize diagonal movement speed
if (p2MoveX !== 0 && p2MoveZ !== 0) {
const length = Math.sqrt(p2MoveX * p2MoveX + p2MoveZ * p2MoveZ);
p2MoveX /= length;
p2MoveZ /= length;
p2MoveX *= playerMoveSpeed;
p2MoveZ *= playerMoveSpeed;
}
updatePlayerMovement(playerStats.right, player2, p2MoveX, p2MoveZ);
// Auto-rotate players to face each other
player1.lookAt(player2.position.x, player1.position.y, player2.position.z);
player2.lookAt(player1.position.x, player2.position.y, player1.position.z);
// Player Actions
if (keys.p1_action) {
if (playerStats.left.mana >= 10) {
playerStats.left.mana -= 10;
console.log("Left player cast a spell!");
createSpellEffect(player1.position, 0xffa500); // Visual effect at cast
// Find closest monster for projectile target
let closestMonster = null;
let minDistance = Infinity;
monsters.forEach(monster => {
const dx = monster.mesh.position.x - player1.position.x;
const dz = monster.mesh.position.z - player1.position.z;
const distance = Math.sqrt(dx * dx + dz * dz);
if (distance < minDistance) {
minDistance = distance;
closestMonster = monster;
}
});
if (closestMonster) {
activeProjectiles.push(new Projectile(player1, closestMonster.mesh.position, spellDamage));
} else {
console.log("No monster to target for Left player's spell.");
}
} else {
console.log("Left player out of mana!");
}
keys.p1_action = false; // Prevent continuous casting on key hold
}
if (keys.p2_action) {
if (playerStats.right.mana >= 10) {
playerStats.right.mana -= 10;
console.log("Right player cast a spell!");
createSpellEffect(player2.position, 0x00ff00); // Visual effect at cast
// Find closest monster for projectile target
let closestMonster = null;
let minDistance = Infinity;
monsters.forEach(monster => {
const dx = monster.mesh.position.x - player2.position.x;
const dz = monster.mesh.position.z - player2.position.z;
const distance = Math.sqrt(dx * dx + dz * dz);
if (distance < minDistance) {
minDistance = distance;
closestMonster = monster;
}
});
if (closestMonster) {
activeProjectiles.push(new Projectile(player2, closestMonster.mesh.position, spellDamage));
} else {
console.log("No monster to target for Right player's spell.");
}
} else {
console.log("Right player out of mana!");
}
keys.p2_action = false; // Prevent continuous casting on key hold
}
// Update and check collisions for projectiles
for (let i = activeProjectiles.length - 1; i >= 0; i--) {
const projectile = activeProjectiles[i];
projectile.update();
let hit = false;
// Check collision with monsters
for (let j = monsters.length - 1; j >= 0; j--) {
const monster = monsters[j];
if (projectile.position.distanceTo(monster.mesh.position) < (spellProjectileRadius + monster.mesh.scale.x * 0.5 * gridSize)) {
monster.health -= projectile.damage;
createSpellEffect(monster.mesh.position, 0xff0000); // Red impact for damage
if (monster.health <= 0) {
console.log("Monster defeated!");
scene.remove(monster.mesh);
monsters.splice(j, 1);
// Award score to the player who cast the spell
if (projectile.originPlayer === player1) playerStats.left.score += 100;
else if (projectile.originPlayer === player2) playerStats.right.score += 100;
}
hit = true;
break; // Only hit one monster per projectile
}
}
if (hit || projectile.life <= 0) {
scene.remove(projectile);
projectile.geometry.dispose();
projectile.material.dispose();
activeProjectiles.splice(i, 1);
}
}
// Monster AI: Move towards and attack closest player
monsters.forEach(monster => {
const distToP1 = monster.mesh.position.distanceTo(player1.position);
const distToP2 = monster.mesh.position.distanceTo(player2.position);
let targetPlayerMesh = null;
let targetPlayerStat = null;
if (distToP1 <= distToP2) {
targetPlayerMesh = player1;
targetPlayerStat = playerStats.left;
} else {
targetPlayerMesh = player2;
targetPlayerStat = playerStats.right;
}
// Monster movement direction
let monsterMoveX = 0;
let monsterMoveZ = 0;
if (monster.mesh.position.x < targetPlayerMesh.position.x) {
monsterMoveX = monsterMoveSpeed;
} else if (monster.mesh.position.x > targetPlayerMesh.position.x) {
monsterMoveX = -monsterMoveSpeed;
}
if (monster.mesh.position.z < targetPlayerMesh.position.z) {
monsterMoveZ = monsterMoveSpeed;
} else if (monster.mesh.position.z > targetPlayerMesh.position.z) {
monsterMoveZ = -monsterMoveSpeed;
}
// Normalize diagonal movement for monsters
if (monsterMoveX !== 0 && monsterMoveZ !== 0) {
const length = Math.sqrt(monsterMoveX * monsterMoveX + monsterMoveZ * monsterMoveZ);
monsterMoveX /= length;
monsterMoveZ /= length;
}
// Apply monster movement with collision detection
let newMonsterX = monster.mesh.position.x + monsterMoveX;
let newMonsterZ = monster.mesh.position.z + monsterMoveZ;
let collidedMonsterX = isCollidingWithWall(newMonsterX, monster.mesh.position.z);
let collidedMonsterZ = isCollidingWithWall(monster.mesh.position.x, newMonsterZ);
if (!collidedMonsterX) {
monster.mesh.position.x = newMonsterX;
}
if (!collidedMonsterZ) {
monster.mesh.position.z = newMonsterZ;
}
// Monster attack logic
if (monster.mesh.position.distanceTo(targetPlayerMesh.position) < gridSize * 1.2) { // Attack if close to player
targetPlayerStat.health = Math.max(0, targetPlayerStat.health - 0.5); // Deal small continuous damage
console.log(`Monster attacked ${targetPlayerStat === playerStats.left ? 'Left' : 'Right'} Player!`);
}
});
// Player-to-player healing when close
const distanceBetweenPlayers = player1.position.distanceTo(player2.position);
if (distanceBetweenPlayers < playerHealingThreshold) {
// Player 1 heals Player 2
playerStats.right.health = Math.min(playerStats.right.maxHealth, playerStats.right.health + healingAmountPerFrame);
// Player 2 heals Player 1
playerStats.left.health = Math.min(playerStats.left.maxHealth, playerStats.left.health + healingAmountPerFrame);
}
// Check for game over condition
if (playerStats.left.health <= 0 && playerStats.right.health <= 0) {
console.log("Game Over! Both players incapacitated.");
// In a full game, you'd show a game over screen here.
}
updateUI(); // Update UI every frame
renderer.render(scene, camera);
}
// Initialize the game when the window loads
window.onload = init;
</script>
</body>
</html>