import * as THREE from 'three'; import * as CANNON from 'cannon-es'; // import CannonDebugger from 'cannon-es-debugger'; // Keep commented unless debugging physics visually // --- 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; const PLAYER_SPEED = 6; // Slightly faster const PLAYER_RADIUS = 0.5; const PLAYER_HEIGHT = 1.8; // Visual height const PLAYER_PHYSICS_HEIGHT = PLAYER_HEIGHT; // Height of physics body (using sphere, so this isn't directly used like capsule) const PLAYER_JUMP_FORCE = 8; // Adjust jump strength const PROJECTILE_SPEED = 15; const PROJECTILE_RADIUS = 0.2; const PICKUP_RADIUS = 1.5; // How close player needs to be to pickup items // --- Three.js Setup --- let scene, camera, renderer; let playerMesh; const meshesToSync = []; const projectiles = []; let axesHelper; // --- Physics Setup --- let world; let playerBody; const physicsBodies = []; // Keep track of ALL bodies we add let cannonDebugger = null; // --- Game State --- let gameState = {}; const keysPressed = {}; let gameLoopActive = false; let animationFrameId = null; // To potentially cancel the loop // --- Game Data (Simplified for Debugging, ensure 0,0 exists!) --- 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" }, // Note item name change "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"}, // Add more simple locations for testing movement "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 }, // ... }; // --- Materials and Geometries (Defined once) --- 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 }), // Use Lambert for walls too (needs light) // Basic materials for debug: 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), // Primitives for assembly 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), }; // --- 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 }, // Logical position, might not be needed now monsters: [], // { id, type, hp, body, mesh } items: [], // { id, name, body, mesh } - body might be null if not physics based }; keysPressed = {}; meshesToSync.length = 0; projectiles.length = 0; physicsBodies.length = 0; // Clear previous scene children if restarting if (scene) { while (scene.children.length > 0) { scene.remove(scene.children[0]); } } // Clear physics world bodies if restarting if (world) { while (world.bodies.length > 0) { world.removeBody(world.bodies[0]); } } initThreeJS(); initPhysics(); initPlayer(); // Player depends on physics world existing generateMap(); // Map depends on physics world existing setupInputListeners(); updateUI(); addLog("Welcome! Move: QWEASDZXC, Jump: Shift/X, Attack: Space, Pickup: F", "info"); console.log("--- Initialization Complete ---"); if (animationFrameId) cancelAnimationFrame(animationFrameId); // Stop previous loop if restarting gameLoopActive = true; animate(); } function initThreeJS() { console.log("Initializing Three.js..."); scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); // Match body background slightly better const aspect = sceneContainer.clientWidth / sceneContainer.clientHeight; camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000); camera.position.set(0, CAMERA_Y_OFFSET, 5); // Start overhead, slightly back camera.lookAt(0, 0, 0); // Look at origin initially renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows sceneContainer.appendChild(renderer.domElement); // Lighting scene.add(new THREE.AmbientLight(0xffffff, 0.6)); // More ambient light const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(20, 30, 15); // Adjust light direction dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; // Increase shadow map resolution dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 1; dirLight.shadow.camera.far = 60; // Adjust shadow camera range 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); // scene.add(new THREE.CameraHelper(dirLight.shadow.camera)); // Shadow debug // Axes Helper axesHelper = new THREE.AxesHelper(ROOM_SIZE * 0.5); axesHelper.position.set(0, 0.01, 0); // Place it clearly 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) }); // Slightly stronger gravity world.broadphase = new CANNON.SAPBroadphase(world); world.allowSleep = true; // Define materials 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"); // For trigger bodies if used // Ground plane 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); // Contact Materials (basic examples) 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 }); // Low friction vs walls 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."); // Optional: Physics Debugger Init // try { // cannonDebugger = new CannonDebugger(scene, world, { color: 0x00ff00, scale: 1.0 }); // console.log("Cannon-es Debugger Initialized."); // } catch (e) { console.error("Failed to initialize CannonDebugger.", e); } } // --- Primitive Assembly Functions (Keep as before) --- function createPlayerMesh() { /* ... same ... */ const group = new THREE.Group(); // Use DEBUG material temporarily const bodyMat = materials.debug_player; //new THREE.MeshLambertMaterial({ color: 0x0077ff }); const bodyGeom = new THREE.CylinderGeometry(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT - (PLAYER_RADIUS * 2), 8); // Lower poly const body = new THREE.Mesh(bodyGeom, bodyMat); body.position.y = PLAYER_RADIUS; body.castShadow = true; const headGeom = new THREE.SphereGeometry(PLAYER_RADIUS, 8, 8); // Lower poly 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); // Lower poly const noseMat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); const nose = new THREE.Mesh(noseGeom, noseMat); // Position nose correctly relative to player center (Y=PLAYER_HEIGHT/2) nose.position.set(0, (PLAYER_HEIGHT / 2) * 0.9, -PLAYER_RADIUS); // Position Z forward, adjust Y nose.rotation.x = Math.PI / 2 + Math.PI; // Point along -Z group.add(nose); group.position.y = PLAYER_HEIGHT / 2; // Set pivot to bottom center return group; } function createSimpleMonsterMesh(modelType = 'capsule_green') { /* ... Mostly same, maybe use debug material ... */ const group = new THREE.Group(); let color = 0xff00ff; // Magenta debug default let mat = materials.debug_monster; // Use debug material if (modelType === 'capsule_green') { color = 0x00ff00; // mat = new THREE.MeshLambertMaterial({ color: color }); // Keep debug for now const bodyGeom = new THREE.CylinderGeometry(0.4, 0.4, 1.0, 8); // Lower poly 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; // Center pivot return group; } function createSimpleItemMesh(modelType = 'sphere_red') { /* ... Mostly same, maybe use debug material ... */ let geom, mat; let color = 0x00ff00; // Green debug default mat = materials.debug_item; // Use debug material 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); } // mat = new THREE.MeshStandardMaterial({ color: color, metalness: 0.3, roughness: 0.6 }); // Keep debug const mesh = new THREE.Mesh(geom, mat); mesh.position.y = PLAYER_RADIUS; // Place roughly at pickup height mesh.castShadow = true; return mesh; } function createProjectileMesh() { /* ... same ... */ const geom = new THREE.SphereGeometry(PROJECTILE_RADIUS, 6, 6); // Lower poly const mat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); const mesh = new THREE.Mesh(geom, mat); return mesh; } // --- Player Setup --- function initPlayer() { console.log("Initializing Player..."); // Visual Mesh playerMesh = createPlayerMesh(); // Start position will be set by physics body sync scene.add(playerMesh); console.log("Player mesh added to scene."); // Physics Body - Start at center of 0,0 cell, slightly above ground const startX = 0 * ROOM_SIZE; const startZ = 0 * ROOM_SIZE; const startY = PLAYER_HEIGHT; // Start higher to ensure it settles onto ground 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") // Get or create material }); playerBody.allowSleep = false; playerBody.addEventListener("collide", handlePlayerCollision); world.addBody(playerBody); physicsBodies.push(playerBody); // Track body console.log(`Player physics body added at ${startX}, ${startY}, ${startZ}`); // Add to sync list meshesToSync.push({ mesh: playerMesh, body: playerBody }); console.log("Player added to sync list."); } // --- Map Generation --- function generateMap() { console.log("Generating Map..."); const wallPhysicsMaterial = world.materials.find(m => m.name === "wall") || new CANNON.Material("wall"); // Get or create material const groundPhysicsMaterial = world.materials.find(m => m.name === "ground"); // Should exist from initPhysics // Simplified: Only render a few cells around 0,0 for testing const renderRadius = 1; // Render 0,0 and its immediate neighbours for (let x = -renderRadius; x <= renderRadius; x++) { for (let z = -renderRadius; z <= renderRadius; z++) { const coordString = `${x},${z}`; const data = gameData[coordString]; if (data) { // Only generate if data exists for this coord console.log(`Generating cell: ${coordString}`); const type = data.type || 'default'; // Create Floor Mesh 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); // Create Walls (Visual + Physics) 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; // DEBUG Visual Wall const wallMesh = new THREE.Mesh(geom, materials.debug_wall); // Use debug material 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: wallPhysicsMaterial }); world.addBody(wallBody); physicsBodies.push(wallBody); } }); // Spawn Items & Monsters based on features features.forEach(feature => { if (feature.startsWith('item_')) { const itemName = feature.substring(5).replace(/_/g, ' '); // Handle underscores 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}`); } } // Handle other visual features if needed }); } 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; // Item height // Use DEBUG material const mesh = new THREE.Mesh(geometries.sphere, materials.debug_item); // Simplified mesh mesh.scale.set(0.6, 0.6, 0.6); // Make items smaller mesh.position.set(x, y, z); mesh.userData = { type: 'item', name: itemName }; mesh.castShadow = true; scene.add(mesh); // Store item in game state WITHOUT physics body initially for F pickup gameState.items.push({ id: THREE.MathUtils.generateUUID(), // Simple unique ID name: itemName, mesh: mesh, position: mesh.position // Store position for proximity check }); 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; // Start higher // Use DEBUG material const mesh = createSimpleMonsterMesh(monsterData.model); // Assuming this uses debug mat now mesh.position.set(x, y, z); // Set initial mesh pos mesh.userData = { type: 'monster', monsterType: monsterType }; scene.add(mesh); // Physics Body const shape = new CANNON.Sphere(PLAYER_RADIUS * 0.8); // Simple sphere collider 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}`); } // --- Input Handling (QWEASDZXC + Jump + F + Space) --- function setupInputListeners() { console.log("Setting up input listeners."); window.addEventListener('keydown', (event) => { keysPressed[event.key.toLowerCase()] = true; keysPressed[event.code] = true; // Keep Space, Shift etc. // Prevent default browser scroll on space/arrows 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); // Adjust speed for diagonal let moveX = 0; let moveZ = 0; // Forward/Backward (W/S) if (keysPressed['w']) moveZ -= 1; if (keysPressed['s']) moveZ += 1; // Strafe (A/D) if (keysPressed['a']) moveX -= 1; if (keysPressed['d']) moveX += 1; // Diagonal Forward (Q/E) if (keysPressed['q']) { moveZ -= 1; moveX -= 1; } if (keysPressed['e']) { moveZ -= 1; moveX += 1; } // Diagonal Backward (Z/C) if (keysPressed['z']) { moveZ += 1; moveX -= 1; } if (keysPressed['c']) { moveZ += 1; moveX += 1; } // --- Calculate final velocity vector --- const velocity = new CANNON.Vec3(0, playerBody.velocity.y, 0); // Preserve Y velocity for jump/gravity // Normalize if moving if (moveX !== 0 || moveZ !== 0) { const moveVec = new THREE.Vector2(moveX, moveZ); moveVec.normalize(); // Ensure consistent speed regardless of direction count velocity.x = moveVec.x * moveSpeed; velocity.z = moveVec.y * moveSpeed; // Make player mesh face movement direction // NOTE: This rotation assumes +Z is backward, -Z is forward const angle = Math.atan2(velocity.x, velocity.z); // atan2(x, z) gives angle from positive Z axis const targetQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle); // Smooth rotation (Slerp) playerMesh.quaternion.slerp(targetQuaternion, 0.15); // Adjust 0.15 for faster/slower turning } else { // Apply damping implicitly via Cannon setting, or force stop: velocity.x = 0; velocity.z = 0; } playerBody.velocity.x = velocity.x; playerBody.velocity.z = velocity.z; // Y velocity is handled by gravity and jump impulse // --- Jump (Shift or X) --- // !! Basic jump - no ground check yet !! if (keysPressed['shift'] || keysPressed['x']) { // Simple check: only jump if Y velocity is near zero (crude ground check) 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; // Consume jump input keysPressed['x'] = false; } // --- Pickup (F) --- if (keysPressed['f']) { pickupNearbyItem(); keysPressed['f'] = false; // Consume pickup input } // --- Fire (Space) --- if (keysPressed['space']) { fireProjectile(); keysPressed['space'] = false; // Consume fire input } } // --- Interaction Logic --- 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; // Using mesh position as items have no physics body now 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(); // Remove item visually scene.remove(item.mesh); // Remove from game state list gameState.items.splice(i, 1); pickedUp = true; break; // Pick up only one item per key press } } } if (!pickedUp) { addLog("Nothing nearby to pick up.", "info"); } } // --- Combat (Keep Projectile Logic) --- function fireProjectile() { /* ... same as before ... */ 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); // Base direction -Z is forward 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; // Fire from mid-height approx 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 }; } // --- Collision Handling --- function handlePlayerCollision(event) { /* ... same as before (but item part is unused now) ... */ const otherBody = event.body; if (!otherBody || !otherBody.userData) return; // Body might be removed already // Item pickup handled by 'F' key now. // Player <-> Monster Collision if (otherBody.userData.type === 'monster') { // Cooldown for taking damage? Needs state tracking. // Simple damage for now: 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!"); } } // Could add other collision handlers (player hitting wall hard, etc.) } function handleProjectileCollision(event) { /* ... same as before ... */ 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; // Update hp in body too addLog(`Hit ${otherBody.userData.monsterType} for ${damage} damage! (HP: ${monster.hp})`, "combat"); if (monster.hp <= 0) { addLog(`Defeated ${otherBody.userData.monsterType}!`, "info"); // Schedule removal after physics step if needed, direct removal can be tricky 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); } } } // Remove projectile immediately after any valid collision (except maybe player?) if (otherBody !== playerBody) { // Schedule removal might be safer if errors occur here removeProjectile(projectileData); } } function removeProjectile(projectileData) { /* ... same as before ... */ if (!projectileData) return; const index = projectiles.indexOf(projectileData); if (index === -1) return; // Already removed // Check if objects still exist before removing if(projectileData.mesh.parent) scene.remove(projectileData.mesh); // Check if body is still in world before removing 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); } // --- Monster AI --- function updateMonsters(deltaTime) { /* ... Mostly same as before ... */ const agroRangeSq = (ROOM_SIZE * 1.5) ** 2; if(!playerBody) return; // Need player position gameState.monsters.forEach(monster => { if (!monster || !monster.body) return; // Skip if monster removed 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; // Apply force instead of setting velocity for more 'physical' movement // monster.body.applyForce(direction.scale(speed * monster.body.mass * 0.5), monsterPos); // Adjust force multiplier // OR setting velocity is simpler for basic chase: monster.body.velocity.x = direction.x * speed; monster.body.velocity.z = direction.z * speed; // Rotation 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); // Smooth turn } else { monster.body.velocity.x = 0; monster.body.velocity.z = 0;} // Stop if close } else { // Stop if far monster.body.velocity.x = 0; monster.body.velocity.z = 0; } }); } // --- UI Update --- function updateUI() { /* ... same as before ... */ updateStatsDisplay(); updateInventoryDisplay(); } function updateStatsDisplay() { /* ... same as before ... */ let statsHTML = ''; statsHTML += `HP: ${gameState.stats.hp}/${gameState.stats.maxHp}`; statsHTML += `Str: ${gameState.stats.strength}`; statsHTML += `Wis: ${gameState.stats.wisdom}`; statsHTML += `Cor: ${gameState.stats.courage}`; statsElement.innerHTML = statsHTML; } function updateInventoryDisplay() { /* ... same as before ... */ let inventoryHTML = ''; if (gameState.inventory.length === 0) { inventoryHTML += 'Empty'; } else { gameState.inventory.forEach(item => { const itemInfo = itemsData[item] || { type: 'unknown', description: '???' }; const itemClass = `item-${itemInfo.type || 'unknown'}`; inventoryHTML += `${item}`; }); } inventoryElement.innerHTML = inventoryHTML; } function addLog(message, type = "info") { /* ... same as before ... */ 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}`; // Add timestamp logElement.appendChild(p); logElement.scrollTop = logElement.scrollHeight; } // --- Game Over --- function gameOver(reason) { addLog(`GAME OVER: ${reason}`, "error"); console.log("Game Over:", reason); gameLoopActive = false; // Stop the game loop // Optional: Show a restart button or overlay 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 = () => { // Clean up restart button if exists const oldButton = document.getElementById('restart-button'); if (oldButton) oldButton.remove(); init(); // Re-initialize the game }; restartButton.id = 'restart-button'; // Assign ID for potential removal sceneContainer.appendChild(restartButton); // Add button over the scene } // --- Main Game Loop --- let lastTime = 0; function animate(time) { if (!gameLoopActive) return; // Stop loop if game over or paused animationFrameId = requestAnimationFrame(animate); // Store ID to potentially cancel const currentTime = performance.now(); // Use performance.now for higher precision const deltaTime = (currentTime - lastTime) * 0.001; // Seconds lastTime = currentTime; // Avoid spiral of death on tab-out or large lag spikes const dtClamped = Math.min(deltaTime, 1 / 30); // Clamp to max 30 FPS step if (!world || !playerBody) return; // Exit if physics/player not ready // 1. Handle Input handleInput(dtClamped); // 2. Update AI updateMonsters(dtClamped); // 3. Step Physics World try { world.step(dtClamped); // Use clamped delta time } catch (e) { console.error("Physics step error:", e); // Potentially pause or handle error state } // 4. Update Projectiles for (let i = projectiles.length - 1; i >= 0; i--) { const p = projectiles[i]; if (p) { // Check if projectile still exists p.lifetime -= dtClamped; if (p.lifetime <= 0) { removeProjectile(p); } } } // 5. Sync Visuals with Physics meshesToSync.forEach(item => { // Ensure body and mesh still exist before syncing if (item && item.body && item.mesh && item.mesh.parent) { item.mesh.position.copy(item.body.position); item.mesh.quaternion.copy(item.body.quaternion); } else { // Clean up entries for objects that might have been removed improperly console.warn("Attempted to sync missing body/mesh pair."); // Maybe remove this entry from meshesToSync here? Needs careful handling. } }); // 6. Update Camera if (playerBody) { const targetCameraPos = new THREE.Vector3( playerBody.position.x, CAMERA_Y_OFFSET, playerBody.position.z + CAMERA_Y_OFFSET * 0.5 // Adjust Z offset based on height for better angle ); camera.position.lerp(targetCameraPos, 0.08); // Smoother follow const lookAtPos = new THREE.Vector3(playerBody.position.x, 0, playerBody.position.z); camera.lookAt(lookAtPos); } // 7. Physics Debugger Update (Optional) // if (cannonDebugger) { // cannonDebugger.update(); // } // 8. Render Scene renderer.render(scene, camera); } // --- Start Game --- init();