import * as THREE from 'three'; // import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Keep commented unless needed // --- DOM Elements --- const sceneContainer = document.getElementById('scene-container'); const storyTitleElement = document.getElementById('story-title'); const storyContentElement = document.getElementById('story-content'); const choicesElement = document.getElementById('choices'); const statsElement = document.getElementById('stats-display'); const inventoryElement = document.getElementById('inventory-display'); // --- Config --- const ROOM_SIZE = 10; // Size of each map grid cell in 3D space const WALL_HEIGHT = 4; const WALL_THICKNESS = 0.5; const CAMERA_HEIGHT = 15; // How high the overhead camera is // --- Three.js Setup --- let scene, camera, renderer; let mapGroup; // A group to hold all map related objects // let controls; // Optional OrbitControls function initThreeJS() { scene = new THREE.Scene(); scene.background = new THREE.Color(0x111111); // Darker background for contrast scene.fog = new THREE.Fog(0x111111, CAMERA_HEIGHT * 1.5, CAMERA_HEIGHT * 3); // Add fog for depth camera = new THREE.PerspectiveCamera(60, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000); // Set initial overhead position (will be updated) camera.position.set(0, CAMERA_HEIGHT, 0); camera.lookAt(0, 0, 0); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); renderer.shadowMap.enabled = true; // Enable shadows if using lights that cast them sceneContainer.appendChild(renderer.domElement); // Lighting const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // Softer ambient scene.add(ambientLight); // Light source directly above the center (acts like a player flashlight) const playerLight = new THREE.PointLight(0xffffff, 1.5, ROOM_SIZE * 3); // Intensity, Distance playerLight.position.set(0, CAMERA_HEIGHT - 2, 0); // Position slightly below camera scene.add(playerLight); // We will move this light with the camera/player later // Map Group mapGroup = new THREE.Group(); scene.add(mapGroup); // Optional Controls (for debugging) // controls = new OrbitControls(camera, renderer.domElement); // controls.enableDamping = true; // controls.target.set(0, 0, 0); // Ensure controls start looking at origin window.addEventListener('resize', onWindowResize, false); animate(); } function onWindowResize() { if (!renderer || !camera) return; camera.aspect = sceneContainer.clientWidth / sceneContainer.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); } function animate() { requestAnimationFrame(animate); // if (controls) controls.update(); if (renderer && scene && camera) { renderer.render(scene, camera); } } // --- Primitive Creation Functions --- const materials = { floor_forest: new THREE.MeshLambertMaterial({ color: 0x228B22 }), // Green floor_cave: new THREE.MeshLambertMaterial({ color: 0x696969 }), // Grey floor_room: new THREE.MeshLambertMaterial({ color: 0xaaaaaa }), // Light Grey floor_city: new THREE.MeshLambertMaterial({ color: 0xdeb887 }), // Tan/Pavement wall: new THREE.MeshLambertMaterial({ color: 0x888888 }), door: new THREE.MeshLambertMaterial({ color: 0xcd853f }), // Peru (brownish) river: new THREE.MeshBasicMaterial({ color: 0x4682B4, transparent: true, opacity: 0.7 }), // Steel Blue unexplored: new THREE.MeshBasicMaterial({ color: 0x555555, wireframe: true }), visited_marker: new THREE.MeshBasicMaterial({ color: 0xaaaaaa, wireframe: true, transparent: true, opacity: 0.5 }), // Faint marker for visited current_marker: new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true }), // Yellow marker }; const geometries = { floor: new THREE.PlaneGeometry(ROOM_SIZE, ROOM_SIZE), wall: new THREE.BoxGeometry(ROOM_SIZE, WALL_HEIGHT, WALL_THICKNESS), wall_side: new THREE.BoxGeometry(WALL_THICKNESS, WALL_HEIGHT, ROOM_SIZE), marker: new THREE.BoxGeometry(ROOM_SIZE * 0.8, 0.1, ROOM_SIZE * 0.8), // Flat marker river: new THREE.PlaneGeometry(ROOM_SIZE * 0.3, ROOM_SIZE), // Thin river strip }; function createFloor(type = 'room', x = 0, z = 0) { const material = materials[`floor_${type}`] || materials.floor_room; const floor = new THREE.Mesh(geometries.floor, material); floor.rotation.x = -Math.PI / 2; // Rotate to be flat floor.position.set(x * ROOM_SIZE, 0, z * ROOM_SIZE); floor.receiveShadow = true; return floor; } function createWall(direction, x = 0, z = 0) { let wall; const base_x = x * ROOM_SIZE; const base_z = z * ROOM_SIZE; const half_room = ROOM_SIZE / 2; const wall_y = WALL_HEIGHT / 2; // Position center of wall at half height switch (direction) { case 'north': wall = new THREE.Mesh(geometries.wall, materials.wall); wall.position.set(base_x, wall_y, base_z - half_room); break; case 'south': wall = new THREE.Mesh(geometries.wall, materials.wall); wall.position.set(base_x, wall_y, base_z + half_room); break; case 'east': wall = new THREE.Mesh(geometries.wall_side, materials.wall); wall.position.set(base_x + half_room, wall_y, base_z); break; case 'west': wall = new THREE.Mesh(geometries.wall_side, materials.wall); wall.position.set(base_x - half_room, wall_y, base_z); break; default: return null; } wall.castShadow = true; wall.receiveShadow = true; return wall; } function createFeature(feature, x = 0, z = 0) { const base_x = x * ROOM_SIZE; const base_z = z * ROOM_SIZE; let mesh = null; // Example: Add a river feature if (feature === 'river') { mesh = new THREE.Mesh(geometries.river, materials.river); mesh.rotation.x = -Math.PI / 2; mesh.position.set(base_x, 0.05, base_z); // Slightly above floor } // Add more features: 'door_north', 'chest', 'tree' etc. // For doors, you might modify/omit wall segments instead of adding an object return mesh; } function createMarker(type, x = 0, z = 0) { let material; switch (type) { case 'current': material = materials.current_marker; break; case 'visited': material = materials.visited_marker; break; case 'unexplored': material = materials.unexplored; break; default: return null; } const marker = new THREE.Mesh(geometries.marker, material); marker.position.set(x * ROOM_SIZE, 0.1, z * ROOM_SIZE); // Slightly above floor return marker; } // --- Game Data (ADD MAP COORDINATES and TYPE/FEATURES) --- const gameData = { // Page ID: { title, content, options, illustration(optional), mapX, mapY, type, features? } "1": { title: "The Beginning", content: "

...

", illustration: "city-gates", mapX: 0, mapY: 0, type: 'city', features: ['door_north'], // Example feature options: [ { text: "Visit the local weaponsmith", next: 2 }, { text: "Seek wisdom at the temple", next: 3 }, { text: "Meet the resistance leader", next: 4 } ] }, "2": { // Weaponsmith - let's place it near the start title: "The Weaponsmith", content: "

...

", illustration: "weaponsmith", mapX: -1, mapY: 0, type: 'room', features: ['door_east'], options: [ { text: "Take Flaming Sword", next: 5, addItem: "Flaming Sword" }, // ... other weapon choices ... { text: "Return to city square", next: 1 } ] }, "3": { // Temple title: "The Ancient Temple", content: "

...

", illustration: "temple", mapX: 1, mapY: 0, type: 'room', features: ['door_west'], options: [ { text: "Learn Healing Light", next: 5, addItem: "Healing Light Spell" }, // ... other spell choices ... { text: "Return to city square", next: 1 } ] }, "4": { // Resistance Tavern title: "The Resistance Leader", content: "

...

", illustration: "resistance-meeting", mapX: 0, mapY: -1, type: 'room', features: ['door_north'], options: [ { text: "Take Secret Tunnel Map", next: 5, addItem: "Secret Tunnel Map" }, // ... other item choices ... { text: "Return to city square", next: 1 } ] }, "5": { // Start of Forest Path (North of City) title: "The Journey Begins", content: "

You leave Silverhold...

", illustration: "shadowwood-forest", mapX: 0, mapY: 1, type: 'forest', features: ['path_north', 'path_east', 'path_west', 'door_south'], options: [ { text: "Take the main road (North)", next: 6 }, { text: "Follow the river path (East)", next: 7 }, { text: "Brave the ruins shortcut (West)", next: 8 }, { text: "Return to the City Gate", next: 1} // Link back ] }, "6": { // Forest Path North title: "Ambush!", content: "

Scouts jump out!

", illustration: "road-ambush", mapX: 0, mapY: 2, type: 'forest', features: ['path_north', 'path_south'], // Challenge will determine next step (9 or 10) challenge: { title: "Escape Ambush", stat: "courage", difficulty: 5, success: 9, failure: 10 }, options: [] // Options determined by challenge }, "7": { // River Path East title: "Misty River", content: "

A spirit appears...

", illustration: "river-spirit", mapX: 1, mapY: 1, type: 'forest', features: ['river', 'path_west'], challenge: { title: "River Riddle", stat: "wisdom", difficulty: 6, success: 11, failure: 12 }, options: [] }, "8": { // Ruins Path West title: "Forgotten Ruins", content: "

Whispers echo...

", illustration: "ancient-ruins", mapX: -1, mapY: 1, type: 'ruins', features: ['path_east'], challenge: { title: "Navigate Ruins", stat: "wisdom", difficulty: 5, success: 13, failure: 14 }, options: [] }, // --- Corresponding Challenge Outcomes --- "9": { // Success Ambush (Continue North) title: "Breaking Through", content: "

You fought them off!

", illustration: "forest-edge", mapX: 0, mapY: 3, type: 'forest', features: ['path_north', 'path_south'], // Edge of forest options: [ {text: "Cross the plains", next: 15} ] // To page 15 (fortress approach) }, "10": { // Failure Ambush (Captured) -> Leads to different location potentially? Let's put it nearby for now. title: "Captured!", content: "

They drag you to an outpost.

", illustration: "prisoner-cell", mapX: 1, mapY: 2, type: 'cave', // Represent outpost as cave/cell options: [ { text: "Wait...", next: 20 } ] // Link to page 20 logic }, "11": { // Success Riddle title: "Spirit's Blessing", content: "

The spirit blesses you.

", illustration: "spirit-blessing", mapX: 1, mapY: 1, type: 'forest', features: ['river', 'path_west'], // Stay in same spot, get item/stat addItem: "Water Spirit's Blessing", statIncrease: { stat: "wisdom", amount: 2 }, options: [ { text: "Continue journey", next: 15 } ] // Option to move on }, "12": { // Failure Riddle title: "Spirit's Wrath", content: "

Pulled underwater!

", illustration: "river-danger", mapX: 1, mapY: 1, type: 'forest', features: ['river', 'path_west'], // Stay in same spot, lose HP hpLoss: 8, options: [ { text: "Struggle onwards", next: 15 } ] }, "13": { // Success Ruins title: "Ancient Allies", content: "

The spirits offer help.

", illustration: "ancient-spirits", mapX: -1, mapY: 1, type: 'ruins', features: ['path_east'], addItem: "Ancient Amulet", statIncrease: { stat: "wisdom", amount: 1 }, options: [ { text: "Accept amulet and continue", next: 15 } ] }, "14": { // Failure Ruins title: "Lost in Time", content: "

Exhausted from the maze.

", illustration: "lost-ruins", mapX: -1, mapY: 1, type: 'ruins', features: ['path_east'], hpLoss: 10, options: [ { text: "Push onwards, weakened", next: 15 } ] }, "15": { // Fortress Approach (example) title: "The Looming Fortress", content: "

The dark fortress rises...

", illustration: "evil-fortress", mapX: 0, mapY: 4, type: 'plains', // Barren plains options: [ /* ... options to infiltrate ... */ {text: "Survey the area", next: 16} ] }, // ... Add ALL other pages with mapX, mapY, type, features ... "99": { title: "Game Over", content: "

Your adventure ends here.

", mapX: 0, mapY: 0, type: 'gameover', // Special type options: [{ text: "Restart", next: 1 }], illustration: "game-over", gameOver: true } }; const itemsData = { // (Keep this data as before) "Flaming Sword": { type: "weapon", description: "A fiery blade. +3 Attack.", attackBonus: 3 }, "Whispering Bow": { type: "weapon", description: "A silent bow. +2 Attack.", attackBonus: 2 }, // ... other items ... "Secret Tunnel Map": { type: "quest", description: "Shows a hidden path" }, "Master Key": { type: "quest", description: "Unlocks many doors" }, "Ancient Amulet": { type: "armor", description: "Wards off some dark magic. +1 Defense", defenseBonus: 1}, // Made armor type }; // --- Game State --- let gameState = {}; // Initialize in startGame let visitedCoords = new Set(); // Track visited map coordinates as "x,y" strings // --- Game Logic Functions --- function startGame() { gameState = { // Reset state currentPageId: 1, inventory: [], stats: { courage: 7, wisdom: 5, strength: 6, hp: 30, maxHp: 30 } }; visitedCoords = new Set(); // Reset visited locations const startPage = gameData[gameState.currentPageId]; if (startPage && startPage.mapX !== undefined && startPage.mapY !== undefined) { visitedCoords.add(`${startPage.mapX},${startPage.mapY}`); // Mark start as visited } renderPage(gameState.currentPageId); } function renderPage(pageId) { const page = gameData[pageId]; if (!page) { console.error(`Error: Page data not found for ID: ${pageId}`); // Handle error state more gracefully if needed storyTitleElement.textContent = "Error"; storyContentElement.innerHTML = "

Adventure lost in the void!

"; choicesElement.innerHTML = ''; // Clear choices addChoiceButton("Restart", 1); // Add only restart updateScene(null); // Clear the scene or show error state return; } // Update UI Text storyTitleElement.textContent = page.title || "Untitled Location"; storyContentElement.innerHTML = page.content || "

...

"; updateStatsDisplay(); updateInventoryDisplay(); // Update Choices based on requirements choicesElement.innerHTML = ''; // Clear old choices let hasAvailableOptions = false; if (page.options && !page.challenge && !page.gameOver) { // Don't show choices if there's a challenge or game over page.options.forEach(option => { // Check requirements before adding the button const requirementFailed = !checkChoiceRequirements(option, gameState.inventory); const button = addChoiceButton(option.text, option.next, option.addItem, requirementFailed); if (!requirementFailed) { hasAvailableOptions = true; } }); } // Handle Game Over / No Options / Challenge Page if (page.gameOver) { addChoiceButton("Restart Adventure", 1); hasAvailableOptions = true; // Allow restart } else if (page.challenge) { choicesElement.innerHTML = `

A challenge blocks your path...

`; // Challenge resolved in handleChoiceClick *after* rendering this page } else if (!hasAvailableOptions && !page.gameOver && !page.challenge) { choicesElement.innerHTML = '

No further options available from here.

'; addChoiceButton("Restart Adventure", 1); } // Update 3D Scene based on current page's coordinates updateScene(pageId); } // Helper to add choice buttons function addChoiceButton(text, nextPage, addItem = null, disabled = false, requireItem = null, requireAnyItem = null) { const button = document.createElement('button'); button.classList.add('choice-button'); button.textContent = text; button.disabled = disabled; // Store data button.dataset.nextPage = nextPage; if (addItem) button.dataset.addItem = addItem; // Create tooltip for disabled buttons based on requirement if (disabled) { if (requireItem) button.title = `Requires: ${requireItem}`; else if (requireAnyItem) button.title = `Requires one of: ${requireAnyItem.join(', ')}`; else button.title = `Cannot choose this option now.`; } if (!disabled) { button.onclick = () => handleChoiceClick(button.dataset); } choicesElement.appendChild(button); return button; // Return button in case needed } // Check choice requirements (returns true if requirements MET) function checkChoiceRequirements(option, inventory) { const reqItem = option.requireItem; if (reqItem && !inventory.includes(reqItem)) { return false; // Specific item required but not present } const reqAnyItem = option.requireAnyItem; if (reqAnyItem && !reqAnyItem.some(item => inventory.includes(item))) { return false; // Required one item from list, but none present } return true; // All requirements met or no requirements } function handleChoiceClick(dataset) { const nextPageId = parseInt(dataset.nextPage); const itemToAdd = dataset.addItem; if (isNaN(nextPageId)) { console.error("Invalid nextPageId:", dataset.nextPage); return; } // --- Process Effects of Making the Choice --- if (itemToAdd && !gameState.inventory.includes(itemToAdd)) { gameState.inventory.push(itemToAdd); console.log("Added item:", itemToAdd); // Add feedback to player? Maybe via story text update? } // --- Move to Next Page --- const previousPageId = gameState.currentPageId; gameState.currentPageId = nextPageId; const nextPageData = gameData[nextPageId]; if (!nextPageData) { console.error(`Data for page ${nextPageId} not found!`); renderPage(99); // Go to game over page as fallback return; } // Add coordinates to visited set if (nextPageData.mapX !== undefined && nextPageData.mapY !== undefined) { visitedCoords.add(`${nextPageData.mapX},${nextPageData.mapY}`); } // --- Process Landing Effects & Challenges --- let currentPageIdAfterEffects = nextPageId; // Start with the target page ID // Apply HP loss defined on the *landing* page if (nextPageData.hpLoss) { gameState.stats.hp -= nextPageData.hpLoss; console.log(`Lost ${nextPageData.hpLoss} HP. Current HP: ${gameState.stats.hp}`); if (gameState.stats.hp <= 0) { console.log("Player died from HP loss!"); gameState.stats.hp = 0; renderPage(99); // Go to a specific game over page ID return; } } // Apply stat increase defined on the *landing* page if (nextPageData.statIncrease) { const stat = nextPageData.statIncrease.stat; const amount = nextPageData.statIncrease.amount; if (gameState.stats.hasOwnProperty(stat)) { gameState.stats[stat] += amount; console.log(`Stat ${stat} increased by ${amount}.`); } } // Handle Challenge on the landing page if (nextPageData.challenge) { const challenge = nextPageData.challenge; const reqStat = challenge.stat; const difficulty = challenge.difficulty; const successPage = challenge.success; const failurePage = challenge.failure; if (reqStat && difficulty !== undefined && successPage !== undefined && failurePage !== undefined) { const currentStatValue = gameState.stats[reqStat] || 0; const roll = Math.floor(Math.random() * 6) + 1; const total = roll + currentStatValue; const success = total >= difficulty; console.log(`Challenge: ${challenge.title || 'Test'}. Roll(${roll}) + ${reqStat}(${currentStatValue}) = ${total} vs DC ${difficulty}. Success: ${success}`); // Update page based on challenge result currentPageIdAfterEffects = success ? successPage : failurePage; // Apply stat change from challenge outcome (optional: only on success/failure?) // This example applies basic +/- based on outcome if(gameState.stats.hasOwnProperty(reqStat)) { gameState.stats[reqStat] += success ? 1 : -1; gameState.stats[reqStat] = Math.max(1, gameState.stats[reqStat]); // Don't go below 1 } // Add challenge result to story? (Needs modification of renderPage or state) // If challenge leads to non-existent page, handle error if (!gameData[currentPageIdAfterEffects]) { console.error(`Challenge outcome page ${currentPageIdAfterEffects} not found!`); renderPage(99); return; } // Update visited coords for the challenge outcome page const challengeOutcomePageData = gameData[currentPageIdAfterEffects]; if (challengeOutcomePageData.mapX !== undefined && challengeOutcomePageData.mapY !== undefined) { visitedCoords.add(`${challengeOutcomePageData.mapX},${challengeOutcomePageData.mapY}`); } } else { console.error("Malformed challenge data on page", nextPageId); } gameState.currentPageId = currentPageIdAfterEffects; // Update state with final page after challenge } // --- Render the final page after all effects/challenges --- renderPage(gameState.currentPageId); } function updateStatsDisplay() { let statsHTML = ''; // Start fresh 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() { let inventoryHTML = ''; // Start fresh 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; } // --- Map and Scene Update Logic --- function updateScene(currentPageId) { if (!mapGroup) return; // Clear previous map elements while (mapGroup.children.length > 0) { mapGroup.remove(mapGroup.children[0]); } const currentPageData = gameData[currentPageId]; if (!currentPageData || currentPageData.mapX === undefined || currentPageData.mapY === undefined) { console.warn("Cannot update scene, current page has no map coordinates:", currentPageId); // Maybe show a default "limbo" state? camera.position.set(0, CAMERA_HEIGHT, 0); camera.lookAt(0, 0, 0); if (scene.getObjectByName("playerLight")) scene.getObjectByName("playerLight").position.set(0, CAMERA_HEIGHT - 2, 0); return; } const currentX = currentPageData.mapX; const currentY = currentPageData.mapY; // Using Y for Z in 3D // Update camera and light position to center on current room const targetX = currentX * ROOM_SIZE; const targetZ = currentY * ROOM_SIZE; camera.position.set(targetX, CAMERA_HEIGHT, targetZ + 0.1); // Offset slightly to avoid z-fighting if looking straight down camera.lookAt(targetX, 0, targetZ); const playerLight = scene.getObjectByName("playerLight"); // Get light added in init if (playerLight) playerLight.position.set(targetX, CAMERA_HEIGHT - 2, targetZ); // Define view distance (how many neighbors to render) const viewDistance = 2; // Render current room and neighbors for (let dx = -viewDistance; dx <= viewDistance; dx++) { for (let dy = -viewDistance; dy <= viewDistance; dy++) { const checkX = currentX + dx; const checkY = currentY + dy; const coordString = `${checkX},${checkY}`; // Find page ID for this coordinate (requires efficient lookup - maybe build map index?) let pageIdAtCoord = null; for (const id in gameData) { if (gameData[id].mapX === checkX && gameData[id].mapY === checkY) { pageIdAtCoord = id; break; } } if (pageIdAtCoord) { const pageDataAtCoord = gameData[pageIdAtCoord]; const isVisited = visitedCoords.has(coordString); const isCurrent = (checkX === currentX && checkY === currentY); if (isCurrent || isVisited) { // Render visited/current room const floor = createFloor(pageDataAtCoord.type, checkX, checkY); mapGroup.add(floor); // Add walls (simplified: add all 4 unless a feature indicates door/opening) // More complex: check connections to neighbors const features = pageDataAtCoord.features || []; if (!features.includes('door_north')) mapGroup.add(createWall('north', checkX, checkY)); if (!features.includes('door_south')) mapGroup.add(createWall('south', checkX, checkY)); if (!features.includes('door_east')) mapGroup.add(createWall('east', checkX, checkY)); if (!features.includes('door_west')) mapGroup.add(createWall('west', checkX, checkY)); // Add other features features.forEach(feature => { const featureMesh = createFeature(feature, checkX, checkY); if (featureMesh) mapGroup.add(featureMesh); }); // Add marker if (isCurrent) { mapGroup.add(createMarker('current', checkX, checkY)); } else { mapGroup.add(createMarker('visited', checkX, checkY)); } } else { // Render unexplored marker for adjacent non-visited rooms if(Math.abs(dx) <= 1 && Math.abs(dy) <= 1 && !(dx === 0 && dy ===0)) { // Only immediate neighbors mapGroup.add(createMarker('unexplored', checkX, checkY)); } } } else { // Optional: Render something if coordinate is empty but adjacent (like a boundary wall) // Or just leave it empty (part of the fog) } } } // Update controls target if using OrbitControls // if (controls) controls.target.set(targetX, 0, targetZ); } // --- Initialization --- initThreeJS(); startGame(); // Start the game after setting up Three.js