awacke1's picture
Update game.js
fa40038 verified
raw
history blame
20.7 kB
import * as THREE from 'three';
import * as CANNON from 'cannon-es';
// import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Optional for debugging
// --- DOM Elements ---
const sceneContainer = document.getElementById('scene-container');
const statsElement = document.getElementById('stats-display');
const inventoryElement = document.getElementById('inventory-display');
const logElement = document.getElementById('log-display');
// --- Config ---
const ROOM_SIZE = 10;
const WALL_HEIGHT = 4;
const WALL_THICKNESS = 0.5;
const CAMERA_Y_OFFSET = 15; // Camera height
const PLAYER_SPEED = 5; // Movement speed units/sec
const PLAYER_RADIUS = 0.5;
const PLAYER_HEIGHT = 1.8;
const PROJECTILE_SPEED = 15;
const PROJECTILE_RADIUS = 0.2;
// --- Three.js Setup ---
let scene, camera, renderer;
let playerMesh; // Visual representation of the player
const meshesToSync = []; // Array to hold { mesh, body } pairs for sync
const projectiles = []; // Active projectiles { mesh, body, lifetime }
// --- Physics Setup ---
let world;
let playerBody; // Physics body for the player
const physicsBodies = []; // Keep track of bodies to remove later if needed
const cannonDebugRenderer = null; // Optional: Use cannon-es-debugger
// --- Game State ---
let gameState = {
inventory: [],
stats: { hp: 30, maxHp: 30, strength: 7, wisdom: 5, courage: 6 },
position: { x: 0, z: 0 }, // Player's logical grid position (optional)
monsters: [], // Store active monster data { id, hp, body, mesh, ... }
items: [], // Store active item data { id, name, body, mesh, ... }
};
const keysPressed = {}; // Track currently pressed keys
// --- Game Data (Keep relevant parts, add monster/item placements) ---
const gameData = {
// Structure: "x,y": { type, features, items?, monsters? }
"0,0": { type: 'city', features: ['door_north'] },
"0,1": { type: 'forest', features: ['path_north', 'door_south', 'item_potion'] }, // Add item marker
"0,2": { type: 'forest', features: ['path_south', 'monster_goblin'] }, // Add monster marker
// ... Add many more locations based on your design ...
};
const itemsData = {
"Healing Potion": { type: "consumable", description: "Restores 10 HP.", hpRestore: 10, model: 'sphere_red' },
"Key": { type: "quest", description: "Unlocks a door.", model: 'box_gold'},
// ...
};
const monstersData = {
"goblin": { hp: 15, attack: 4, defense: 1, speed: 2, model: 'capsule_green', xp: 5 },
// ...
};
// --- Initialization ---
function init() {
initThreeJS();
initPhysics();
initPlayer();
generateMap(); // Generate based on gameData
setupInputListeners();
animate(); // Start the game loop
updateUI(); // Initial UI update
addLog("Welcome! Move with WASD/Arrows. Space to Attack.", "info");
}
function initThreeJS() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111);
camera = new THREE.PerspectiveCamera(60, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000);
// Start camera slightly offset, will follow player
camera.position.set(0, CAMERA_Y_OFFSET, 5); // Look slightly forward initially
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
renderer.shadowMap.enabled = true;
sceneContainer.appendChild(renderer.domElement);
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(10, 20, 5);
dirLight.castShadow = true; // Enable shadows for this light
scene.add(dirLight);
// Configure shadow properties if needed
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
window.addEventListener('resize', onWindowResize, false);
}
function initPhysics() {
world = new CANNON.World({
gravity: new CANNON.Vec3(0, -9.82, 0) // Standard gravity
});
world.broadphase = new CANNON.NaiveBroadphase(); // Simple broadphase for now
// world.solver.iterations = 10; // Adjust solver iterations if needed
// Ground plane (physics only)
const groundShape = new CANNON.Plane();
const groundBody = new CANNON.Body({ mass: 0 }); // Mass 0 means static
groundBody.addShape(groundShape);
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); // Rotate plane to be horizontal
world.addBody(groundBody);
}
// --- Primitive Assembly Functions ---
function createPlayerMesh() {
const group = new THREE.Group();
// Simple capsule: cylinder + sphere cap
const bodyMat = new THREE.MeshLambertMaterial({ color: 0x0077ff }); // Blue player
const bodyGeom = new THREE.CylinderGeometry(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT - (PLAYER_RADIUS * 2), 16);
const body = new THREE.Mesh(bodyGeom, bodyMat);
body.position.y = PLAYER_RADIUS; // Position cylinder part correctly
body.castShadow = true;
const headGeom = new THREE.SphereGeometry(PLAYER_RADIUS, 16, 16);
const head = new THREE.Mesh(headGeom, bodyMat);
head.position.y = PLAYER_HEIGHT - PLAYER_RADIUS; // Position head on top
head.castShadow = true;
group.add(body);
group.add(head);
// Add a "front" indicator (e.g., small cone)
const noseGeom = new THREE.ConeGeometry(PLAYER_RADIUS * 0.3, PLAYER_RADIUS * 0.5, 8);
const noseMat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // Yellow nose
const nose = new THREE.Mesh(noseGeom, noseMat);
nose.position.set(0, PLAYER_HEIGHT * 0.7, PLAYER_RADIUS * 0.7); // Position in front, slightly down
nose.rotation.x = Math.PI / 2; // Point forward
group.add(nose);
return group;
}
function createSimpleMonsterMesh(modelType = 'capsule_green') {
const group = new THREE.Group();
let color = 0xff0000; // Default red
let geom;
let mat;
if (modelType === 'capsule_green') {
color = 0x00ff00; // Green
mat = new THREE.MeshLambertMaterial({ color: color });
const bodyGeom = new THREE.CylinderGeometry(0.4, 0.4, 1.0, 12);
const headGeom = new THREE.SphereGeometry(0.4, 12, 12);
const body = new THREE.Mesh(bodyGeom, mat);
const head = new THREE.Mesh(headGeom, mat);
body.position.y = 0.5;
head.position.y = 1.0 + 0.4;
group.add(body);
group.add(head);
} else { // Default box monster
geom = new THREE.BoxGeometry(0.8, 1.2, 0.8);
mat = new THREE.MeshLambertMaterial({ color: color });
const mesh = new THREE.Mesh(geom, mat);
mesh.position.y = 0.6;
group.add(mesh);
}
group.traverse(child => { if (child.isMesh) child.castShadow = true; });
return group;
}
function createSimpleItemMesh(modelType = 'sphere_red') {
let geom, mat;
let color = 0xffffff; // Default white
if(modelType === 'sphere_red') {
color = 0xff0000;
geom = new THREE.SphereGeometry(0.3, 16, 16);
} else if (modelType === 'box_gold') {
color = 0xffd700;
geom = new THREE.BoxGeometry(0.4, 0.4, 0.4);
} else { // Default sphere
geom = new THREE.SphereGeometry(0.3, 16, 16);
}
mat = new THREE.MeshStandardMaterial({ color: color, metalness: 0.3, roughness: 0.6 });
const mesh = new THREE.Mesh(geom, mat);
mesh.position.y = PLAYER_RADIUS; // Place at player radius height
mesh.castShadow = true;
return mesh;
}
function createProjectileMesh() {
const geom = new THREE.SphereGeometry(PROJECTILE_RADIUS, 8, 8);
const mat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // Bright yellow
const mesh = new THREE.Mesh(geom, mat);
return mesh;
}
// --- Player Setup ---
function initPlayer() {
// Visuals
playerMesh = createPlayerMesh();
playerMesh.position.y = PLAYER_HEIGHT / 2; // Adjust initial position based on model
scene.add(playerMesh);
// Physics
// Using a capsule shape approximation (sphere + cylinder + sphere) is complex in Cannon.
// Let's use a simpler Sphere or Box for now. Sphere is often better for rolling/movement.
const playerShape = new CANNON.Sphere(PLAYER_RADIUS);
playerBody = new CANNON.Body({
mass: 5, // Give player some mass
shape: playerShape,
position: new CANNON.Vec3(0, PLAYER_HEIGHT / 2, 0), // Start at origin, slightly above ground
linearDamping: 0.9, // Add damping to prevent sliding forever
angularDamping: 0.9, // Prevent spinning wildly
fixedRotation: true, // Prevent player body from tipping over (optional but good for top-down)
});
playerBody.addEventListener("collide", handlePlayerCollision); // Add collision listener
world.addBody(playerBody);
// Add to sync list
meshesToSync.push({ mesh: playerMesh, body: playerBody });
}
// --- Map Generation ---
function generateMap() {
const wallMaterial = new CANNON.Material("wallMaterial"); // For physics interactions
for (const coordString in gameData) {
const [xStr, yStr] = coordString.split(',');
const x = parseInt(xStr);
const y = parseInt(yStr); // Represents Z in 3D
const data = gameData[coordString];
// Create Floor Mesh (visual only, physics ground plane handles floor collision)
const floorMesh = createFloor(data.type, x, y);
scene.add(floorMesh); // Add floor directly to scene, not mapGroup if physics handles it
// Create Wall Meshes and Physics Bodies
const features = data.features || [];
const wallPositions = [
{ dir: 'north', xOffset: 0, zOffset: -0.5, geom: geometries.wall },
{ dir: 'south', xOffset: 0, zOffset: 0.5, geom: geometries.wall },
{ dir: 'east', xOffset: 0.5, zOffset: 0, geom: geometries.wall_side },
{ dir: 'west', xOffset: -0.5, zOffset: 0, geom: geometries.wall_side },
];
wallPositions.forEach(wp => {
// Check if a feature indicates an opening in this direction
const doorFeature = `door_${wp.dir}`;
const pathFeature = `path_${wp.dir}`; // Consider paths as openings too
if (!features.includes(doorFeature) && !features.includes(pathFeature)) {
// Add Visual Wall Mesh
const wallMesh = new THREE.Mesh(wp.geom, materials.wall);
wallMesh.position.set(
x * ROOM_SIZE + wp.xOffset * ROOM_SIZE,
WALL_HEIGHT / 2,
y * ROOM_SIZE + wp.zOffset * ROOM_SIZE
);
wallMesh.castShadow = true;
wallMesh.receiveShadow = true;
scene.add(wallMesh); // Add walls directly to scene
// Add Physics Wall Body
const wallShape = new CANNON.Box(new CANNON.Vec3(
wp.geom.parameters.width / 2,
wp.geom.parameters.height / 2,
wp.geom.parameters.depth / 2
));
const wallBody = new CANNON.Body({
mass: 0, // Static
shape: wallShape,
position: new CANNON.Vec3(wallMesh.position.x, wallMesh.position.y, wallMesh.position.z),
material: wallMaterial // Assign physics material
});
world.addBody(wallBody);
physicsBodies.push(wallBody); // Keep track if needed for removal
}
});
// Spawn Items
if (data.items) {
data.items.forEach(itemName => spawnItem(itemName, x, y));
}
// Spawn Monsters
if (data.monsters) {
data.monsters.forEach(monsterType => spawnMonster(monsterType, x, y));
}
// Add other features visually (rivers, etc. - physics interaction TBD)
features.forEach(feature => {
if (feature === 'river') {
const riverMesh = createFeature(feature, x, y);
if (riverMesh) scene.add(riverMesh);
}
// Handle other features
});
}
}
function spawnItem(itemName, gridX, gridY) {
const itemData = itemsData[itemName];
if (!itemData) {
console.warn(`Item data not found for ${itemName}`);
return;
}
const x = gridX * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.5); // Randomize position within cell
const z = gridY * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.5);
const y = PLAYER_RADIUS; // Place at reachable height
// Visual Mesh
const mesh = createSimpleItemMesh(itemData.model);
mesh.position.set(x, y, z);
mesh.userData = { type: 'item', name: itemName }; // Store game data on mesh
scene.add(mesh);
// Physics Body (Static Sensor)
const shape = new CANNON.Sphere(0.4); // Slightly larger than visual for easier pickup
const body = new CANNON.Body({
mass: 0,
isTrigger: true, // Sensor - detects collision but doesn't cause physical reaction
shape: shape,
position: new CANNON.Vec3(x, y, z)
});
body.userData = { type: 'item', name: itemName, mesh }; // Link body back to mesh
world.addBody(body);
gameState.items.push({ id: body.id, name: itemName, body: body, mesh: mesh });
physicsBodies.push(body);
}
function spawnMonster(monsterType, gridX, gridY) {
const monsterData = monstersData[monsterType];
if (!monsterData) {
console.warn(`Monster data not found for ${monsterType}`);
return;
}
const x = gridX * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.3); // Randomize position
const z = gridY * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.3);
const y = PLAYER_HEIGHT / 2; // Start at roughly player height
// Visual Mesh
const mesh = createSimpleMonsterMesh(monsterData.model);
mesh.position.set(x, y, z);
mesh.userData = { type: 'monster', monsterType: monsterType };
scene.add(mesh);
// Physics Body (Dynamic)
// Using a simple sphere collider for monsters for now
const shape = new CANNON.Sphere(PLAYER_RADIUS * 0.8); // Slightly smaller than player
const body = new CANNON.Body({
mass: 10, // Give mass
shape: shape,
position: new CANNON.Vec3(x, y, z),
linearDamping: 0.8,
angularDamping: 0.9,
fixedRotation: true, // Prevent tipping
});
body.userData = { type: 'monster', monsterType: monsterType, mesh: mesh, hp: monsterData.hp }; // Store HP on body userData
world.addBody(body);
gameState.monsters.push({ id: body.id, type: monsterType, hp: monsterData.hp, body: body, mesh: mesh });
meshesToSync.push({ mesh: mesh, body: body }); // Add monster to sync list
physicsBodies.push(body);
}
// --- Input Handling ---
function setupInputListeners() {
window.addEventListener('keydown', (event) => {
keysPressed[event.key.toLowerCase()] = true;
keysPressed[event.code] = true; // Also store by code (e.g., Space)
});
window.addEventListener('keyup', (event) => {
keysPressed[event.key.toLowerCase()] = false;
keysPressed[event.code] = false;
});
}
function handleInput(deltaTime) {
if (!playerBody) return;
const moveDirection = new CANNON.Vec3(0, 0, 0);
const moveSpeed = PLAYER_SPEED;
if (keysPressed['w'] || keysPressed['arrowup']) {
moveDirection.z = -1;
} else if (keysPressed['s'] || keysPressed['arrowdown']) {
moveDirection.z = 1;
}
if (keysPressed['a'] || keysPressed['arrowleft']) {
moveDirection.x = -1;
} else if (keysPressed['d'] || keysPressed['arrowright']) {
moveDirection.x = 1;
}
// Normalize diagonal movement and apply speed
if (moveDirection.lengthSquared() > 0) { // Only normalize if there's movement
moveDirection.normalize();
// Apply velocity directly - better for responsive character control than forces
playerBody.velocity.x = moveDirection.x * moveSpeed;
playerBody.velocity.z = moveDirection.z * moveSpeed;
// Make player mesh face movement direction (optional)
const angle = Math.atan2(moveDirection.x, moveDirection.z);
// Smooth rotation? Lerp quaternion later. For now, direct set.
playerMesh.quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle);
} else {
// If no movement keys pressed, gradually stop (handled by linearDamping)
// Or set velocity to zero for instant stop:
// playerBody.velocity.x = 0;
// playerBody.velocity.z = 0;
}
// Handle 'Fire' (Space bar)
if (keysPressed['space']) {
fireProjectile();
keysPressed['space'] = false; // Prevent holding space for continuous fire (debounce)
}
}
// --- Combat ---
function fireProjectile() {
if (!playerBody || !playerMesh) return;
addLog("Pew!", "combat"); // Simple log
// Create Mesh
const projectileMesh = createProjectileMesh();
// Create Physics Body
const projectileShape = new CANNON.Sphere(PROJECTILE_RADIUS);
const projectileBody = new CANNON.Body({
mass: 0.1, // Very light
shape: projectileShape,
linearDamping: 0.01, // Minimal damping
angularDamping: 0.01,
});
projectileBody.addEventListener("collide", handleProjectileCollision);
// Calculate initial position and velocity
// Start slightly in front of player, based on player's rotation
const offsetDistance = PLAYER_RADIUS + PROJECTILE_RADIUS + 0.1; // Start just outside player radius
const direction = new THREE.Vector3(0, 0, -1); // Base direction (forward Z)
direction.applyQuaternion(playerMesh.quaternion); // Rotate based on player mesh orientation
const startPos = new CANNON.Vec3().copy(playerBody.position).vadd(
new CANNON.Vec3(direction.x, 0, direction.z).scale(offsetDistance) // Offset horizontally
);
startPos.y = PLAYER_HEIGHT * 0.7; // Fire from "head" height approx
projectileBody.position.copy(startPos);
projectileMesh.position.copy(startPos); // Sync initial mesh position
// Set velocity in the direction the player is facing
projectileBody.velocity = new CANNON.Vec3(direction.x, 0, direction.z).scale(PROJECTILE_SPEED);
// Add to scene and world
scene.add(projectileMesh);
world.addBody(projectileBody);
// Add to sync list and active projectiles list
const projectileData = { mesh: projectileMesh, body: projectileBody, lifetime: 3.0 }; // 3 second lifetime
meshesToSync.push(projectileData);
projectiles.push(projectileData);
physicsBodies.push(projectileBody);
// Link body and mesh
projectileBody.userData = { type: 'projectile', mesh: projectileMesh, data: projectileData };
projectileMesh.userData = { type: 'projectile', body: projectileBody, data: projectileData };
}
// --- Collision Handling ---
function handlePlayerCollision(event) {
const otherBody = event.body; // The body the player collided with
if (!otherBody.userData) return;
// Player <-> Item Collision
if (otherBody.userData.type === 'item') {
const itemName = otherBody.userData.name;
const itemIndex = gameState.items.findIndex(item => item.id === otherBody.id);
if (itemIndex > -1 && !gameState.inventory.includes(itemName)) {
gameState.inventory.push(itemName);
addLog(`Picked up ${itemName}!`, "pickup");
updateInventoryDisplay(); // Update UI immediately
// Remove item from world
scene.remove(otherBody.userData.mesh);
world.removeBody(otherBody);
meshesToSync = meshesToSync.filter(sync => sync.body.id !== otherBody.id);
physicsBodies = physicsBodies.filter(body => body.id !== otherBody.id);
gameState.items.splice(itemIndex, 1);
}
}
// Player <-> Monster Collision (Simple damage placeholder)
else if (otherBody.userData.type === 'monster') {
// Example: Player takes damage on touch
// Implement cooldown later
gameState.stats.hp -= 1; // Monster touch damage
addLog(`Touched by ${otherBody.userData.monsterType}! HP: ${gameState.stats.hp}`, "combat");
updateStatsDisplay();
if (gameState.stats.hp <= 0) {
gameOver("Defeated by a monster!");
}
}
}
function handleProjectileCollision(event) {
const projectileBody = event.target; // The projectile body itself
const otherBody = event.body;