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