Spaces:
Running
Running
import * as THREE from 'three'; | |
import * as CANNON from 'cannon-es'; | |
// Uncomment for physics debugging: | |
// import CannonDebugger from 'cannon-es-debugger'; | |
// --- 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 = 20; // Increased camera height for better overview | |
const PLAYER_SPEED = 5; | |
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; | |
const meshesToSync = []; | |
const projectiles = []; | |
let axesHelper; // For debugging orientation | |
// --- Physics Setup --- | |
let world; | |
let playerBody; | |
const physicsBodies = []; | |
let cannonDebugger = null; // Optional physics debugger instance | |
// --- Game State --- | |
let gameState = {}; // Will be initialized in init() | |
const keysPressed = {}; | |
let gameLoopActive = false; // Control the game loop | |
// --- Game Data (Ensure starting point 0,0 exists!) --- | |
const gameData = { | |
"0,0": { type: 'city', features: ['door_north', 'item_Key'], name: "City Square"}, // Added item | |
"0,1": { type: 'forest', features: ['path_north', 'door_south', 'monster_goblin'], name: "Forest Path" }, // Added monster | |
"0,2": { type: 'forest', features: ['path_south'], name: "Deep Forest"}, | |
"1,1": { type: 'forest', features: ['river', 'path_west'], name: "River Bend" }, | |
"-1,1": { type: 'ruins', features: ['path_east', 'item_Healing Potion'], name: "Old Ruins"}, // Added item | |
// ... Add many more locations ... | |
}; | |
const itemsData = { | |
"Healing Potion": { type: "consumable", description: "Restores 10 HP.", hpRestore: 10, model: 'sphere_red' }, | |
"Key": { type: "quest", description: "A rusty key.", model: 'box_gold'}, | |
// ... | |
}; | |
const monstersData = { | |
"goblin": { hp: 15, attack: 4, defense: 1, speed: 2, model: 'capsule_green', xp: 5 }, | |
// ... | |
}; | |
// --- Initialization --- | |
function init() { | |
console.log("Initializing Game..."); | |
// Reset state cleanly | |
gameState = { | |
inventory: [], | |
stats: { hp: 30, maxHp: 30, strength: 7, wisdom: 5, courage: 6 }, | |
position: { x: 0, z: 0 }, | |
monsters: [], | |
items: [], | |
}; | |
// Clear existing sync/physics lists if restarting | |
meshesToSync.length = 0; | |
projectiles.length = 0; | |
physicsBodies.length = 0; | |
initThreeJS(); | |
initPhysics(); | |
initPlayer(); // Must be after physics | |
generateMap(); // Generate map based on gameData | |
setupInputListeners(); | |
updateUI(); // Initial UI update | |
addLog("Welcome! Move with WASD/Arrows. Space to Attack.", "info"); | |
console.log("Initialization Complete."); | |
gameLoopActive = true; // Start the loop AFTER setup | |
animate(); // Start the game loop | |
} | |
function initThreeJS() { | |
console.log("Initializing Three.js..."); | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x111111); | |
camera = new THREE.PerspectiveCamera(60, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000); | |
camera.position.set(0, CAMERA_Y_OFFSET, 5); // Start above origin | |
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.5); | |
scene.add(ambientLight); | |
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); // Brighter directional | |
dirLight.position.set(15, 25, 10); // Higher angle | |
dirLight.castShadow = true; | |
dirLight.shadow.mapSize.width = 1024; | |
dirLight.shadow.mapSize.height = 1024; | |
// Optional: Improve shadow quality/range | |
dirLight.shadow.camera.near = 0.5; | |
dirLight.shadow.camera.far = 50; | |
dirLight.shadow.camera.left = -ROOM_SIZE * 2; | |
dirLight.shadow.camera.right = ROOM_SIZE * 2; | |
dirLight.shadow.camera.top = ROOM_SIZE * 2; | |
dirLight.shadow.camera.bottom = -ROOM_SIZE * 2; | |
scene.add(dirLight); | |
// scene.add( new THREE.CameraHelper( dirLight.shadow.camera ) ); // Debug shadow camera | |
// Axes Helper | |
axesHelper = new THREE.AxesHelper(ROOM_SIZE / 2); // Size relative to room | |
axesHelper.position.set(0, 0.1, 0); // Slightly above ground | |
scene.add(axesHelper); | |
console.log("Three.js Initialized."); | |
window.addEventListener('resize', onWindowResize, false); | |
} | |
function initPhysics() { | |
console.log("Initializing Cannon-es..."); | |
world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.82, 0) }); | |
world.broadphase = new CANNON.SAPBroadphase(world); // Generally better than Naive | |
world.allowSleep = true; // Allow bodies to sleep to save performance | |
// Ground plane | |
const groundShape = new CANNON.Plane(); | |
const groundBody = new CANNON.Body({ mass: 0, material: new CANNON.Material("ground") }); | |
groundBody.addShape(groundShape); | |
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); | |
world.addBody(groundBody); | |
console.log("Physics World Initialized."); | |
// Optional: Physics Debugger | |
// try { | |
// cannonDebugger = new CannonDebugger(scene, world, { | |
// color: 0x00ff00, // Optional: specify color | |
// scale: 1.0, // Optional: scale | |
// }); | |
// console.log("Cannon-es Debugger Initialized."); | |
// } catch (e) { | |
// console.error("Failed to initialize CannonDebugger. Did you include it?", e); | |
// } | |
} | |
// --- Primitive Creation Functions (Keep as before) --- | |
function createPlayerMesh() { /* ... same as before ... */ | |
const group = new THREE.Group(); | |
const bodyMat = new THREE.MeshLambertMaterial({ color: 0x0077ff }); | |
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; | |
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; | |
head.castShadow = true; | |
group.add(body); group.add(head); | |
const noseGeom = new THREE.ConeGeometry(PLAYER_RADIUS * 0.3, PLAYER_RADIUS * 0.5, 8); | |
const noseMat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); | |
const nose = new THREE.Mesh(noseGeom, noseMat); | |
nose.position.set(0, PLAYER_HEIGHT * 0.7, -PLAYER_RADIUS * 0.7); // Point along negative Z initially | |
nose.rotation.x = Math.PI / 2 + Math.PI; // Adjust rotation to point forward (-Z) | |
group.add(nose); | |
return group; | |
} | |
function createSimpleMonsterMesh(modelType = 'capsule_green') { /* ... same as before ... */ | |
const group = new THREE.Group(); | |
let color = 0xff0000; | |
let geom; let mat; | |
if (modelType === 'capsule_green') { | |
color = 0x00ff00; | |
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 { 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') { /* ... same as before ... */ | |
let geom, mat; let color = 0xffffff; | |
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 { 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; mesh.castShadow = true; | |
return mesh; | |
} | |
function createProjectileMesh() { /* ... same as before ... */ | |
const geom = new THREE.SphereGeometry(PROJECTILE_RADIUS, 8, 8); | |
const mat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); | |
const mesh = new THREE.Mesh(geom, mat); | |
return mesh; | |
} | |
// Shared Geometries / Materials for walls/floors | |
const floorGeometry = new THREE.PlaneGeometry(ROOM_SIZE, ROOM_SIZE); | |
const wallN SGeometry = new THREE.BoxGeometry(ROOM_SIZE, WALL_HEIGHT, WALL_THICKNESS); | |
const wallEWGeometry = new THREE.BoxGeometry(WALL_THICKNESS, WALL_HEIGHT, ROOM_SIZE); | |
const floorMaterials = { | |
city: new THREE.MeshLambertMaterial({ color: 0xdeb887 }), | |
forest: new THREE.MeshLambertMaterial({ color: 0x228B22 }), | |
cave: new THREE.MeshLambertMaterial({ color: 0x696969 }), | |
ruins: new THREE.MeshLambertMaterial({ color: 0x778899 }), | |
plains: new THREE.MeshLambertMaterial({ color: 0x90EE90 }), | |
default: new THREE.MeshLambertMaterial({ color: 0xaaaaaa }) | |
}; | |
const wallMaterial = new THREE.MeshLambertMaterial({ color: 0x888888 }); | |
// --- Player Setup --- | |
function initPlayer() { | |
console.log("Initializing Player..."); | |
// Visual Mesh | |
playerMesh = createPlayerMesh(); | |
// Start slightly above ground to avoid initial collision issues | |
playerMesh.position.set(0, PLAYER_HEIGHT, 0); // Adjust initial Y based on model pivot | |
scene.add(playerMesh); | |
console.log("Player mesh added to scene."); | |
// Physics Body | |
const playerShape = new CANNON.Sphere(PLAYER_RADIUS); | |
playerBody = new CANNON.Body({ | |
mass: 70, // Player mass in kg (approx) | |
shape: playerShape, | |
position: new CANNON.Vec3(0, PLAYER_HEIGHT, 0), // Match mesh Y | |
linearDamping: 0.95, // High damping for tight control | |
angularDamping: 1.0, // Prevent any spinning | |
material: new CANNON.Material("player") // Assign physics material | |
}); | |
// playerBody.fixedRotation = true; // Might not be needed with angular damping 1.0 | |
playerBody.allowSleep = false; // Player should always be active | |
playerBody.addEventListener("collide", handlePlayerCollision); | |
world.addBody(playerBody); | |
console.log("Player physics body added."); | |
// Add to sync list | |
meshesToSync.push({ mesh: playerMesh, body: playerBody }); | |
} | |
// --- Map Generation --- | |
function generateMap() { | |
console.log("Generating Map..."); | |
const staticMaterial = new CANNON.Material("static"); // Physics material for walls/floor | |
// Add physics ground plane (redundant with initPhysics but ensures material) | |
const groundShape = new CANNON.Plane(); | |
const groundBody = new CANNON.Body({ mass: 0, material: staticMaterial }); | |
groundBody.addShape(groundShape); | |
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); | |
world.addBody(groundBody); | |
physicsBodies.push(groundBody); | |
// Add visual grid helper (optional) | |
// const gridHelper = new THREE.GridHelper(ROOM_SIZE * 5, 5, 0x444444, 0x444444); // Size, Divisions | |
// scene.add(gridHelper); | |
for (const coordString in gameData) { | |
const [xStr, yStr] = coordString.split(','); | |
const x = parseInt(xStr); | |
const z = parseInt(yStr); // Map Y is World Z | |
const data = gameData[coordString]; | |
const type = data.type || 'default'; | |
// Create Floor Mesh | |
const floorMat = floorMaterials[type] || floorMaterials.default; | |
const floorMesh = new THREE.Mesh(floorGeometry, floorMat); | |
floorMesh.rotation.x = -Math.PI / 2; | |
floorMesh.position.set(x * ROOM_SIZE, 0, z * ROOM_SIZE); | |
floorMesh.receiveShadow = true; | |
scene.add(floorMesh); | |
// console.log(`Added floor at <span class="math-inline">\{x\},</span>{z}`); | |
// Create Wall Meshes and Physics Bodies | |
const features = data.features || []; | |
const wallDefs = [ // [direction, geometry, xOffset, zOffset] | |
['north', wallNSGeometry, 0, -0.5], | |
['south', wallNSGeometry, 0, 0.5], | |
['east', wallEWGeometry, 0.5, 0], | |
['west', wallEWGeometry, -0.5, 0], | |
]; | |
wallDefs.forEach(([dir, geom, xOff, zOff]) => { | |
const doorFeature = `door_${dir}`; | |
const pathFeature = `path_${dir}`; // Consider paths as openings | |
if (!features.includes(doorFeature) && !features.includes(pathFeature)) { | |
const wallX = x * ROOM_SIZE + xOff * ROOM_SIZE; | |
const wallZ = z * ROOM_SIZE + zOff * ROOM_SIZE; | |
const wallY = WALL_HEIGHT / 2; | |
// Visual Wall | |
const wallMesh = new THREE.Mesh(geom, wallMaterial); | |
wallMesh.position.set(wallX, wallY, wallZ); | |
wallMesh.castShadow = true; | |
wallMesh.receiveShadow = true; | |
scene.add(wallMesh); | |
// Physics Wall | |
const wallShape = new CANNON.Box(new CANNON.Vec3(geom.parameters.width / 2, geom.parameters.height / 2, geom.parameters.depth / 2)); | |
const wallBody = new CANNON.Body({ mass: 0, shape: wallShape, position: new CANNON.Vec3(wallX, wallY, wallZ), material: staticMaterial }); | |
world.addBody(wallBody); | |
physicsBodies.push(wallBody); | |
} | |
}); | |
// Spawn Items based on features like 'item_Key' | |
features.forEach(feature => { | |
if (feature.startsWith('item_')) { | |
const itemName = feature.substring(5).replace('_', ' '); // e.g., item_Healing_Potion -> Healing Potion | |
if(itemsData[itemName]) { | |
spawnItem(itemName, x, z); | |
} else { | |
console.warn(`Item feature found but no data for: ${itemName}`); | |
} | |
} else if (feature.startsWith('monster_')) { | |
const monsterType = feature.substring(8); // e.g., monster_goblin -> goblin | |
if(monstersData[monsterType]) { | |
spawnMonster(monsterType, x, z); | |
} else { | |
console.warn(`Monster feature found but no data for: ${monsterType}`); | |
} | |
} | |
// Handle other visual features (rivers etc.) - currently no physics | |
else if (feature === 'river') { | |
const riverMesh = createFeature(feature, x, z); | |
if (riverMesh) scene.add(riverMesh); | |
} | |
}); | |
} | |
console.log("Map Generation Complete."); | |
} | |
function spawnItem(itemName, gridX, gridZ) { /* ... Mostly same as before ... */ | |
const itemData = itemsData[itemName]; | |
if (!itemData) return; | |
const x = gridX * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.4); | |
const z = gridZ * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.4); | |
const y = PLAYER_RADIUS; | |
const mesh = createSimpleItemMesh(itemData.model); | |
mesh.position.set(x, y, z); | |
mesh.userData = { type: 'item', name: itemName }; | |
scene.add(mesh); | |
const shape = new CANNON.Sphere(PLAYER_RADIUS); // Make pickup radius same as player | |
const body = new CANNON.Body({ mass: 0, isTrigger: true, shape: shape, position: new CANNON.Vec3(x, y, z)}); | |
body.userData = { type: 'item', name: itemName, mesh: mesh }; // Link back | |
world.addBody(body); | |
gameState.items.push({ id: body.id, name: itemName, body: body, mesh: mesh }); | |
physicsBodies.push(body); | |
console.log(`Spawned item ${ |