|
import * as THREE from 'three'; |
|
import * as CANNON from 'cannon-es'; |
|
|
|
|
|
|
|
const sceneContainer = document.getElementById('scene-container'); |
|
const statsElement = document.getElementById('stats-display'); |
|
const inventoryElement = document.getElementById('inventory-display'); |
|
const logElement = document.getElementById('log-display'); |
|
|
|
|
|
const ROOM_SIZE = 10; |
|
const WALL_HEIGHT = 4; |
|
const WALL_THICKNESS = 0.5; |
|
const CAMERA_Y_OFFSET = 20; |
|
const PLAYER_SPEED = 6; |
|
const PLAYER_RADIUS = 0.5; |
|
const PLAYER_HEIGHT = 1.8; |
|
const PLAYER_PHYSICS_HEIGHT = PLAYER_HEIGHT; |
|
const PLAYER_JUMP_FORCE = 8; |
|
const PROJECTILE_SPEED = 15; |
|
const PROJECTILE_RADIUS = 0.2; |
|
const PICKUP_RADIUS = 1.5; |
|
|
|
|
|
let scene, camera, renderer; |
|
let playerMesh; |
|
const meshesToSync = []; |
|
const projectiles = []; |
|
let axesHelper; |
|
|
|
|
|
let world; |
|
let playerBody; |
|
const physicsBodies = []; |
|
let cannonDebugger = null; |
|
|
|
|
|
let gameState = {}; |
|
const keysPressed = {}; |
|
let gameLoopActive = false; |
|
let animationFrameId = null; |
|
|
|
|
|
const gameData = { |
|
"0,0": { type: 'city', features: ['door_north', 'item_Key'], name: "City Square"}, |
|
"0,1": { type: 'forest', features: ['path_north', 'door_south', 'item_Healing_Potion'], name: "Forest Entrance" }, |
|
"0,2": { type: 'forest', features: ['path_south', 'monster_goblin'], name: "Deep Forest"}, |
|
"1,1": { type: 'forest', features: ['river', 'path_west'], name: "River Bend" }, |
|
"-1,1": { type: 'ruins', features: ['path_east'], name: "Old Ruins"}, |
|
|
|
"1,0": { type: 'plains', features: ['door_west'], name: "East Plains"}, |
|
"-1,0": { type: 'plains', features: ['door_east'], name: "West Plains"}, |
|
|
|
}; |
|
|
|
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 }, |
|
|
|
}; |
|
|
|
|
|
const materials = { |
|
floor_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_floor: new THREE.MeshLambertMaterial({ color: 0xaaaaaa }), |
|
wall: new THREE.MeshLambertMaterial({ color: 0x888888 }), |
|
|
|
debug_player: new THREE.MeshBasicMaterial({ color: 0x0077ff, wireframe: true }), |
|
debug_wall: new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true }), |
|
debug_item: new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }), |
|
debug_monster: new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true }), |
|
}; |
|
const geometries = { |
|
floor: new THREE.PlaneGeometry(ROOM_SIZE, ROOM_SIZE), |
|
wallNS: new THREE.BoxGeometry(ROOM_SIZE, WALL_HEIGHT, WALL_THICKNESS), |
|
wallEW: new THREE.BoxGeometry(WALL_THICKNESS, WALL_HEIGHT, ROOM_SIZE), |
|
|
|
box: new THREE.BoxGeometry(1, 1, 1), |
|
sphere: new THREE.SphereGeometry(0.5, 16, 16), |
|
cylinder: new THREE.CylinderGeometry(0.5, 0.5, 1, 16), |
|
cone: new THREE.ConeGeometry(0.5, 1, 16), |
|
plane: new THREE.PlaneGeometry(1, 1), |
|
}; |
|
|
|
|
|
|
|
|
|
function init() { |
|
console.log("--- Initializing Game ---"); |
|
|
|
gameState = { |
|
inventory: [], |
|
stats: { hp: 30, maxHp: 30, strength: 7, wisdom: 5, courage: 6 }, |
|
position: { x: 0, z: 0 }, |
|
monsters: [], |
|
items: [], |
|
}; |
|
keysPressed = {}; |
|
meshesToSync.length = 0; |
|
projectiles.length = 0; |
|
physicsBodies.length = 0; |
|
|
|
if (scene) { |
|
while (scene.children.length > 0) { |
|
scene.remove(scene.children[0]); |
|
} |
|
} |
|
|
|
if (world) { |
|
while (world.bodies.length > 0) { |
|
world.removeBody(world.bodies[0]); |
|
} |
|
} |
|
|
|
|
|
initThreeJS(); |
|
initPhysics(); |
|
initPlayer(); |
|
generateMap(); |
|
setupInputListeners(); |
|
updateUI(); |
|
addLog("Welcome! Move: QWEASDZXC, Jump: Shift/X, Attack: Space, Pickup: F", "info"); |
|
|
|
console.log("--- Initialization Complete ---"); |
|
if (animationFrameId) cancelAnimationFrame(animationFrameId); |
|
gameLoopActive = true; |
|
animate(); |
|
} |
|
|
|
function initThreeJS() { |
|
console.log("Initializing Three.js..."); |
|
scene = new THREE.Scene(); |
|
scene.background = new THREE.Color(0x1a1a1a); |
|
|
|
const aspect = sceneContainer.clientWidth / sceneContainer.clientHeight; |
|
camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000); |
|
camera.position.set(0, CAMERA_Y_OFFSET, 5); |
|
camera.lookAt(0, 0, 0); |
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true }); |
|
renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); |
|
renderer.shadowMap.enabled = true; |
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
sceneContainer.appendChild(renderer.domElement); |
|
|
|
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6)); |
|
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); |
|
dirLight.position.set(20, 30, 15); |
|
dirLight.castShadow = true; |
|
dirLight.shadow.mapSize.width = 2048; |
|
dirLight.shadow.mapSize.height = 2048; |
|
dirLight.shadow.camera.near = 1; |
|
dirLight.shadow.camera.far = 60; |
|
const shadowCamSize = 25; |
|
dirLight.shadow.camera.left = -shadowCamSize; |
|
dirLight.shadow.camera.right = shadowCamSize; |
|
dirLight.shadow.camera.top = shadowCamSize; |
|
dirLight.shadow.camera.bottom = -shadowCamSize; |
|
scene.add(dirLight); |
|
|
|
|
|
|
|
axesHelper = new THREE.AxesHelper(ROOM_SIZE * 0.5); |
|
axesHelper.position.set(0, 0.01, 0); |
|
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, -15, 0) }); |
|
world.broadphase = new CANNON.SAPBroadphase(world); |
|
world.allowSleep = true; |
|
|
|
|
|
const groundMaterial = new CANNON.Material("ground"); |
|
const playerMaterial = new CANNON.Material("player"); |
|
const wallMaterial = new CANNON.Material("wall"); |
|
const monsterMaterial = new CANNON.Material("monster"); |
|
const itemMaterial = new CANNON.Material("item"); |
|
|
|
|
|
const groundShape = new CANNON.Plane(); |
|
const groundBody = new CANNON.Body({ mass: 0, material: groundMaterial }); |
|
groundBody.addShape(groundShape); |
|
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); |
|
world.addBody(groundBody); |
|
physicsBodies.push(groundBody); |
|
|
|
|
|
const playerGround = new CANNON.ContactMaterial(playerMaterial, groundMaterial, { friction: 0.3, restitution: 0.1 }); |
|
const playerWall = new CANNON.ContactMaterial(playerMaterial, wallMaterial, { friction: 0.0, restitution: 0.1 }); |
|
const monsterGround = new CANNON.ContactMaterial(monsterMaterial, groundMaterial, { friction: 0.4, restitution: 0.1 }); |
|
const monsterWall = new CANNON.ContactMaterial(monsterMaterial, wallMaterial, { friction: 0.1, restitution: 0.2 }); |
|
|
|
world.addContactMaterial(playerGround); |
|
world.addContactMaterial(playerWall); |
|
world.addContactMaterial(monsterGround); |
|
world.addContactMaterial(monsterWall); |
|
|
|
console.log("Physics World Initialized."); |
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
function createPlayerMesh() { |
|
const group = new THREE.Group(); |
|
|
|
const bodyMat = materials.debug_player; |
|
const bodyGeom = new THREE.CylinderGeometry(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT - (PLAYER_RADIUS * 2), 8); |
|
const body = new THREE.Mesh(bodyGeom, bodyMat); |
|
body.position.y = PLAYER_RADIUS; body.castShadow = true; |
|
const headGeom = new THREE.SphereGeometry(PLAYER_RADIUS, 8, 8); |
|
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, 4); |
|
const noseMat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); |
|
const nose = new THREE.Mesh(noseGeom, noseMat); |
|
|
|
nose.position.set(0, (PLAYER_HEIGHT / 2) * 0.9, -PLAYER_RADIUS); |
|
nose.rotation.x = Math.PI / 2 + Math.PI; |
|
group.add(nose); |
|
group.position.y = PLAYER_HEIGHT / 2; |
|
return group; |
|
} |
|
function createSimpleMonsterMesh(modelType = 'capsule_green') { |
|
const group = new THREE.Group(); |
|
let color = 0xff00ff; |
|
let mat = materials.debug_monster; |
|
if (modelType === 'capsule_green') { |
|
color = 0x00ff00; |
|
|
|
const bodyGeom = new THREE.CylinderGeometry(0.4, 0.4, 1.0, 8); |
|
const headGeom = new THREE.SphereGeometry(0.4, 8, 8); |
|
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 { let geom = new THREE.BoxGeometry(0.8, 1.2, 0.8); const mesh = new THREE.Mesh(geom, mat); mesh.position.y = 0.6; group.add(mesh); } |
|
group.traverse(child => { if (child.isMesh) child.castShadow = true; }); |
|
group.position.y = 1.2 / 2; |
|
return group; |
|
} |
|
function createSimpleItemMesh(modelType = 'sphere_red') { |
|
let geom, mat; let color = 0x00ff00; |
|
mat = materials.debug_item; |
|
if(modelType === 'sphere_red') { color = 0xff0000; geom = new THREE.SphereGeometry(0.3, 8, 8); } |
|
else if (modelType === 'box_gold') { color = 0xffd700; geom = new THREE.BoxGeometry(0.4, 0.4, 0.4); } |
|
else { geom = new THREE.SphereGeometry(0.3, 8, 8); } |
|
|
|
const mesh = new THREE.Mesh(geom, mat); |
|
mesh.position.y = PLAYER_RADIUS; |
|
mesh.castShadow = true; |
|
return mesh; |
|
} |
|
function createProjectileMesh() { |
|
const geom = new THREE.SphereGeometry(PROJECTILE_RADIUS, 6, 6); |
|
const mat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); |
|
const mesh = new THREE.Mesh(geom, mat); |
|
return mesh; |
|
} |
|
|
|
function initPlayer() { |
|
console.log("Initializing Player..."); |
|
|
|
playerMesh = createPlayerMesh(); |
|
|
|
scene.add(playerMesh); |
|
console.log("Player mesh added to scene."); |
|
|
|
|
|
const startX = 0 * ROOM_SIZE; |
|
const startZ = 0 * ROOM_SIZE; |
|
const startY = PLAYER_HEIGHT; |
|
const playerShape = new CANNON.Sphere(PLAYER_RADIUS); |
|
playerBody = new CANNON.Body({ |
|
mass: 70, |
|
shape: playerShape, |
|
position: new CANNON.Vec3(startX, startY, startZ), |
|
linearDamping: 0.95, |
|
angularDamping: 1.0, |
|
material: world.materials.find(m => m.name === "player") || new CANNON.Material("player") |
|
}); |
|
playerBody.allowSleep = false; |
|
playerBody.addEventListener("collide", handlePlayerCollision); |
|
world.addBody(playerBody); |
|
physicsBodies.push(playerBody); |
|
console.log(`Player physics body added at ${startX}, ${startY}, ${startZ}`); |
|
|
|
|
|
meshesToSync.push({ mesh: playerMesh, body: playerBody }); |
|
console.log("Player added to sync list."); |
|
} |
|
|
|
|
|
function generateMap() { |
|
console.log("Generating Map..."); |
|
const wallPhysicsMaterial = world.materials.find(m => m.name === "wall") || new CANNON.Material("wall"); |
|
const groundPhysicsMaterial = world.materials.find(m => m.name === "ground"); |
|
|
|
|
|
const renderRadius = 1; |
|
for (let x = -renderRadius; x <= renderRadius; x++) { |
|
for (let z = -renderRadius; z <= renderRadius; z++) { |
|
const coordString = `${x},${z}`; |
|
const data = gameData[coordString]; |
|
|
|
if (data) { |
|
console.log(`Generating cell: ${coordString}`); |
|
const type = data.type || 'default'; |
|
|
|
|
|
const floorMat = floorMaterials[type] || floorMaterials.default_floor; |
|
const floorMesh = new THREE.Mesh(geometries.floor, floorMat); |
|
floorMesh.rotation.x = -Math.PI / 2; |
|
floorMesh.position.set(x * ROOM_SIZE, 0, z * ROOM_SIZE); |
|
floorMesh.receiveShadow = true; |
|
scene.add(floorMesh); |
|
|
|
|
|
const features = data.features || []; |
|
const wallDefs = [ |
|
['north', geometries.wallNS, 0, -0.5], |
|
['south', geometries.wallNS, 0, 0.5], |
|
['east', geometries.wallEW, 0.5, 0], |
|
['west', geometries.wallEW, -0.5, 0], |
|
]; |
|
|
|
wallDefs.forEach(([dir, geom, xOff, zOff]) => { |
|
const doorFeature = `door_${dir}`; |
|
const pathFeature = `path_${dir}`; |
|
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; |
|
|
|
|
|
const wallMesh = new THREE.Mesh(geom, materials.debug_wall); |
|
wallMesh.position.set(wallX, wallY, wallZ); |
|
wallMesh.castShadow = true; |
|
wallMesh.receiveShadow = true; |
|
scene.add(wallMesh); |
|
|
|
|
|
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: wallPhysicsMaterial }); |
|
world.addBody(wallBody); |
|
physicsBodies.push(wallBody); |
|
} |
|
}); |
|
|
|
|
|
features.forEach(feature => { |
|
if (feature.startsWith('item_')) { |
|
const itemName = feature.substring(5).replace(/_/g, ' '); |
|
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); |
|
if (monstersData[monsterType]) { |
|
spawnMonster(monsterType, x, z); |
|
} else { console.warn(`Monster feature found but no data for: ${monsterType}`); } |
|
} |
|
|
|
}); |
|
|
|
} else { |
|
console.log(`No data for cell: ${coordString}, skipping.`); |
|
} |
|
} |
|
} |
|
console.log("Map Generation Complete."); |
|
} |
|
|
|
function spawnItem(itemName, gridX, gridZ) { |
|
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 = new THREE.Mesh(geometries.sphere, materials.debug_item); |
|
mesh.scale.set(0.6, 0.6, 0.6); |
|
mesh.position.set(x, y, z); |
|
mesh.userData = { type: 'item', name: itemName }; |
|
mesh.castShadow = true; |
|
scene.add(mesh); |
|
|
|
|
|
gameState.items.push({ |
|
id: THREE.MathUtils.generateUUID(), |
|
name: itemName, |
|
mesh: mesh, |
|
position: mesh.position |
|
}); |
|
console.log(`Spawned item ${itemName} at ${gridX},${gridZ} (visual only)`); |
|
} |
|
|
|
function spawnMonster(monsterType, gridX, gridZ) { |
|
const monsterData = monstersData[monsterType]; |
|
if (!monsterData) 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_HEIGHT; |
|
|
|
|
|
const mesh = createSimpleMonsterMesh(monsterData.model); |
|
mesh.position.set(x, y, z); |
|
mesh.userData = { type: 'monster', monsterType: monsterType }; |
|
scene.add(mesh); |
|
|
|
|
|
const shape = new CANNON.Sphere(PLAYER_RADIUS * 0.8); |
|
const body = new CANNON.Body({ |
|
mass: 10, |
|
shape: shape, |
|
position: new CANNON.Vec3(x, y, z), |
|
linearDamping: 0.8, |
|
angularDamping: 0.9, |
|
material: world.materials.find(m => m.name === "monster") || new CANNON.Material("monster") |
|
}); |
|
body.allowSleep = true; |
|
body.userData = { type: 'monster', monsterType: monsterType, mesh: mesh, hp: monsterData.hp }; |
|
world.addBody(body); |
|
|
|
gameState.monsters.push({ id: body.id, type: monsterType, hp: monsterData.hp, body: body, mesh: mesh }); |
|
meshesToSync.push({ mesh: mesh, body: body }); |
|
physicsBodies.push(body); |
|
console.log(`Spawned monster ${monsterType} at ${gridX},${gridZ}`); |
|
} |
|
|
|
|
|
|
|
function setupInputListeners() { |
|
console.log("Setting up input listeners."); |
|
window.addEventListener('keydown', (event) => { |
|
keysPressed[event.key.toLowerCase()] = true; |
|
keysPressed[event.code] = true; |
|
|
|
if (['space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(event.code.toLowerCase())) { |
|
event.preventDefault(); |
|
} |
|
}); |
|
window.addEventListener('keyup', (event) => { |
|
keysPressed[event.key.toLowerCase()] = false; |
|
keysPressed[event.code] = false; |
|
}); |
|
} |
|
|
|
function handleInput(deltaTime) { |
|
if (!playerBody) return; |
|
|
|
const moveSpeed = PLAYER_SPEED; |
|
const diagonalSpeed = moveSpeed / Math.sqrt(2); |
|
|
|
let moveX = 0; |
|
let moveZ = 0; |
|
|
|
|
|
if (keysPressed['w']) moveZ -= 1; |
|
if (keysPressed['s']) moveZ += 1; |
|
|
|
|
|
if (keysPressed['a']) moveX -= 1; |
|
if (keysPressed['d']) moveX += 1; |
|
|
|
|
|
if (keysPressed['q']) { moveZ -= 1; moveX -= 1; } |
|
if (keysPressed['e']) { moveZ -= 1; moveX += 1; } |
|
|
|
|
|
if (keysPressed['z']) { moveZ += 1; moveX -= 1; } |
|
if (keysPressed['c']) { moveZ += 1; moveX += 1; } |
|
|
|
|
|
const velocity = new CANNON.Vec3(0, playerBody.velocity.y, 0); |
|
|
|
|
|
if (moveX !== 0 || moveZ !== 0) { |
|
const moveVec = new THREE.Vector2(moveX, moveZ); |
|
moveVec.normalize(); |
|
|
|
velocity.x = moveVec.x * moveSpeed; |
|
velocity.z = moveVec.y * moveSpeed; |
|
|
|
|
|
|
|
const angle = Math.atan2(velocity.x, velocity.z); |
|
const targetQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle); |
|
|
|
playerMesh.quaternion.slerp(targetQuaternion, 0.15); |
|
|
|
} else { |
|
|
|
velocity.x = 0; |
|
velocity.z = 0; |
|
} |
|
|
|
playerBody.velocity.x = velocity.x; |
|
playerBody.velocity.z = velocity.z; |
|
|
|
|
|
|
|
|
|
|
|
if (keysPressed['shift'] || keysPressed['x']) { |
|
|
|
if (Math.abs(playerBody.velocity.y) < 0.1) { |
|
console.log("Attempting Jump!"); |
|
playerBody.applyImpulse(new CANNON.Vec3(0, PLAYER_JUMP_FORCE, 0), playerBody.position); |
|
} |
|
keysPressed['shift'] = false; |
|
keysPressed['x'] = false; |
|
} |
|
|
|
|
|
if (keysPressed['f']) { |
|
pickupNearbyItem(); |
|
keysPressed['f'] = false; |
|
} |
|
|
|
|
|
if (keysPressed['space']) { |
|
fireProjectile(); |
|
keysPressed['space'] = false; |
|
} |
|
} |
|
|
|
|
|
function pickupNearbyItem() { |
|
if (!playerBody) return; |
|
const playerPos = playerBody.position; |
|
let pickedUp = false; |
|
|
|
for (let i = gameState.items.length - 1; i >= 0; i--) { |
|
const item = gameState.items[i]; |
|
const itemPos = item.position; |
|
const distance = playerPos.distanceTo(itemPos); |
|
|
|
if (distance < PICKUP_RADIUS) { |
|
if (!gameState.inventory.includes(item.name)) { |
|
gameState.inventory.push(item.name); |
|
addLog(`Picked up ${item.name}!`, "pickup"); |
|
updateInventoryDisplay(); |
|
|
|
|
|
scene.remove(item.mesh); |
|
|
|
gameState.items.splice(i, 1); |
|
pickedUp = true; |
|
break; |
|
} |
|
} |
|
} |
|
if (!pickedUp) { |
|
addLog("Nothing nearby to pick up.", "info"); |
|
} |
|
} |
|
|
|
|
|
|
|
function fireProjectile() { |
|
if (!playerBody || !playerMesh) return; |
|
addLog("Pew!", "combat"); |
|
const projectileMesh = createProjectileMesh(); |
|
const projectileShape = new CANNON.Sphere(PROJECTILE_RADIUS); |
|
const projectileBody = new CANNON.Body({ mass: 0.1, shape: projectileShape, linearDamping: 0.01, angularDamping: 0.01 }); |
|
projectileBody.addEventListener("collide", handleProjectileCollision); |
|
const offsetDistance = PLAYER_RADIUS + PROJECTILE_RADIUS + 0.1; |
|
const direction = new THREE.Vector3(0, 0, -1); |
|
direction.applyQuaternion(playerMesh.quaternion); |
|
const startPos = new CANNON.Vec3().copy(playerBody.position).vadd( |
|
new CANNON.Vec3(direction.x, 0, direction.z).scale(offsetDistance) |
|
); |
|
startPos.y = playerBody.position.y + PLAYER_HEIGHT * 0.3; |
|
projectileBody.position.copy(startPos); |
|
projectileMesh.position.copy(startPos); |
|
projectileBody.velocity = new CANNON.Vec3(direction.x, 0, direction.z).scale(PROJECTILE_SPEED); |
|
scene.add(projectileMesh); |
|
world.addBody(projectileBody); |
|
const projectileData = { mesh: projectileMesh, body: projectileBody, lifetime: 3.0 }; |
|
meshesToSync.push(projectileData); |
|
projectiles.push(projectileData); |
|
physicsBodies.push(projectileBody); |
|
projectileBody.userData = { type: 'projectile', mesh: projectileMesh, data: projectileData }; |
|
projectileMesh.userData = { type: 'projectile', body: projectileBody, data: projectileData }; |
|
} |
|
|
|
|
|
function handlePlayerCollision(event) { |
|
const otherBody = event.body; |
|
if (!otherBody || !otherBody.userData) return; |
|
|
|
|
|
|
|
|
|
if (otherBody.userData.type === 'monster') { |
|
|
|
|
|
gameState.stats.hp -= 1; |
|
addLog(`Hit by ${otherBody.userData.monsterType || 'monster'}! HP: ${gameState.stats.hp}`, "combat"); |
|
updateStatsDisplay(); |
|
if (gameState.stats.hp <= 0) { gameOver("Defeated by a monster!"); } |
|
} |
|
|
|
} |
|
|
|
function handleProjectileCollision(event) { |
|
const projectileBody = event.target; |
|
const otherBody = event.body; |
|
if (!projectileBody || !projectileBody.userData || !otherBody || !otherBody.userData) return; |
|
|
|
const projectileData = projectileBody.userData.data; |
|
|
|
if (otherBody.userData.type === 'monster') { |
|
const monsterId = otherBody.id; |
|
const monsterIndex = gameState.monsters.findIndex(m => m.id === monsterId); |
|
if (monsterIndex > -1) { |
|
const monster = gameState.monsters[monsterIndex]; |
|
const damage = gameState.stats.strength; |
|
monster.hp -= damage; |
|
otherBody.userData.hp = monster.hp; |
|
addLog(`Hit ${otherBody.userData.monsterType} for ${damage} damage! (HP: ${monster.hp})`, "combat"); |
|
if (monster.hp <= 0) { |
|
addLog(`Defeated ${otherBody.userData.monsterType}!`, "info"); |
|
|
|
scene.remove(monster.mesh); |
|
world.removeBody(monster.body); |
|
meshesToSync = meshesToSync.filter(sync => sync.body.id !== monster.body.id); |
|
physicsBodies = physicsBodies.filter(body => body.id !== monster.body.id); |
|
gameState.monsters.splice(monsterIndex, 1); |
|
} |
|
} |
|
} |
|
|
|
|
|
if (otherBody !== playerBody) { |
|
|
|
removeProjectile(projectileData); |
|
} |
|
} |
|
|
|
function removeProjectile(projectileData) { |
|
if (!projectileData) return; |
|
const index = projectiles.indexOf(projectileData); |
|
if (index === -1) return; |
|
|
|
|
|
if(projectileData.mesh.parent) scene.remove(projectileData.mesh); |
|
|
|
if (world.bodies.includes(projectileData.body)) world.removeBody(projectileData.body); |
|
|
|
meshesToSync = meshesToSync.filter(sync => sync.body && sync.body.id !== projectileData.body.id); |
|
physicsBodies = physicsBodies.filter(body => body && body.id !== projectileData.body.id); |
|
projectiles.splice(index, 1); |
|
} |
|
|
|
|
|
function updateMonsters(deltaTime) { |
|
const agroRangeSq = (ROOM_SIZE * 1.5) ** 2; |
|
if(!playerBody) return; |
|
|
|
gameState.monsters.forEach(monster => { |
|
if (!monster || !monster.body) return; |
|
const monsterPos = monster.body.position; |
|
const playerPos = playerBody.position; |
|
const distanceSq = playerPos.distanceSquared(monsterPos); |
|
|
|
if (distanceSq < agroRangeSq) { |
|
const direction = playerPos.vsub(monsterPos); |
|
direction.y = 0; |
|
if (direction.lengthSquared() > 0.1) { |
|
direction.normalize(); |
|
const monsterData = monstersData[monster.type]; |
|
const speed = monsterData ? monsterData.speed : 1; |
|
|
|
|
|
|
|
monster.body.velocity.x = direction.x * speed; |
|
monster.body.velocity.z = direction.z * speed; |
|
|
|
|
|
const angle = Math.atan2(direction.x, direction.z); |
|
const targetQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle); |
|
monster.mesh.quaternion.slerp(targetQuaternion, 0.1); |
|
} else { monster.body.velocity.x = 0; monster.body.velocity.z = 0;} |
|
} else { |
|
monster.body.velocity.x = 0; |
|
monster.body.velocity.z = 0; |
|
} |
|
}); |
|
} |
|
|
|
|
|
|
|
function updateUI() { |
|
updateStatsDisplay(); |
|
updateInventoryDisplay(); |
|
} |
|
function updateStatsDisplay() { |
|
let statsHTML = ''; |
|
statsHTML += `<span>HP: ${gameState.stats.hp}/${gameState.stats.maxHp}</span>`; |
|
statsHTML += `<span>Str: ${gameState.stats.strength}</span>`; |
|
statsHTML += `<span>Wis: ${gameState.stats.wisdom}</span>`; |
|
statsHTML += `<span>Cor: ${gameState.stats.courage}</span>`; |
|
statsElement.innerHTML = statsHTML; |
|
} |
|
function updateInventoryDisplay() { |
|
let inventoryHTML = ''; |
|
if (gameState.inventory.length === 0) { inventoryHTML += '<em>Empty</em>'; } |
|
else { gameState.inventory.forEach(item => { const itemInfo = itemsData[item] || { type: 'unknown', description: '???' }; const itemClass = `item-${itemInfo.type || 'unknown'}`; inventoryHTML += `<span class="${itemClass}" title="${itemInfo.description}">${item}</span>`; }); } |
|
inventoryElement.innerHTML = inventoryHTML; |
|
} |
|
function addLog(message, type = "info") { |
|
const p = document.createElement('p'); |
|
p.classList.add(type); |
|
p.textContent = `[${new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}] ${message}`; |
|
logElement.appendChild(p); |
|
logElement.scrollTop = logElement.scrollHeight; |
|
} |
|
|
|
|
|
|
|
function gameOver(reason) { |
|
addLog(`GAME OVER: ${reason}`, "error"); |
|
console.log("Game Over:", reason); |
|
gameLoopActive = false; |
|
|
|
const restartButton = document.createElement('button'); |
|
restartButton.textContent = "RESTART GAME"; |
|
restartButton.style.position = 'absolute'; |
|
restartButton.style.top = '50%'; |
|
restartButton.style.left = '50%'; |
|
restartButton.style.transform = 'translate(-50%, -50%)'; |
|
restartButton.style.padding = '20px 40px'; |
|
restartButton.style.fontSize = '2em'; |
|
restartButton.style.cursor = 'pointer'; |
|
restartButton.style.zIndex = '1000'; |
|
restartButton.onclick = () => { |
|
|
|
const oldButton = document.getElementById('restart-button'); |
|
if (oldButton) oldButton.remove(); |
|
init(); |
|
}; |
|
restartButton.id = 'restart-button'; |
|
sceneContainer.appendChild(restartButton); |
|
} |
|
|
|
|
|
|
|
let lastTime = 0; |
|
function animate(time) { |
|
if (!gameLoopActive) return; |
|
|
|
animationFrameId = requestAnimationFrame(animate); |
|
|
|
const currentTime = performance.now(); |
|
const deltaTime = (currentTime - lastTime) * 0.001; |
|
lastTime = currentTime; |
|
|
|
|
|
const dtClamped = Math.min(deltaTime, 1 / 30); |
|
|
|
if (!world || !playerBody) return; |
|
|
|
|
|
handleInput(dtClamped); |
|
|
|
|
|
updateMonsters(dtClamped); |
|
|
|
|
|
try { |
|
world.step(dtClamped); |
|
} catch (e) { |
|
console.error("Physics step error:", e); |
|
|
|
} |
|
|
|
|
|
|
|
for (let i = projectiles.length - 1; i >= 0; i--) { |
|
const p = projectiles[i]; |
|
if (p) { |
|
p.lifetime -= dtClamped; |
|
if (p.lifetime <= 0) { |
|
removeProjectile(p); |
|
} |
|
} |
|
} |
|
|
|
|
|
meshesToSync.forEach(item => { |
|
|
|
if (item && item.body && item.mesh && item.mesh.parent) { |
|
item.mesh.position.copy(item.body.position); |
|
item.mesh.quaternion.copy(item.body.quaternion); |
|
} else { |
|
|
|
console.warn("Attempted to sync missing body/mesh pair."); |
|
|
|
} |
|
}); |
|
|
|
|
|
if (playerBody) { |
|
const targetCameraPos = new THREE.Vector3( |
|
playerBody.position.x, |
|
CAMERA_Y_OFFSET, |
|
playerBody.position.z + CAMERA_Y_OFFSET * 0.5 |
|
); |
|
camera.position.lerp(targetCameraPos, 0.08); |
|
|
|
const lookAtPos = new THREE.Vector3(playerBody.position.x, 0, playerBody.position.z); |
|
camera.lookAt(lookAtPos); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderer.render(scene, camera); |
|
|
|
} |
|
|
|
|
|
init(); |