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;