Spaces:
Running
Running
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: "<p>...</p>", 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: "<p>...</p>", 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: "<p>...</p>", 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: "<p>...</p>", 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: "<p>You leave Silverhold...</p>", 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: "<p>Scouts jump out!</p>", 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: "<p>A spirit appears...</p>", 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: "<p>Whispers echo...</p>", 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: "<p>You fought them off!</p>", 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: "<p>They drag you to an outpost.</p>", 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: "<p>The spirit blesses you.</p>", 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: "<p>Pulled underwater!</p>", 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: "<p>The spirits offer help.</p>", 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: "<p>Exhausted from the maze.</p>", 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: "<p>The dark fortress rises...</p>", 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: "<p>Your adventure ends here.</p>", | |
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 = "<p>Adventure lost in the void!</p>"; | |
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 || "<p>...</p>"; | |
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 = `<p><i>A challenge blocks your path...</i></p>`; | |
// Challenge resolved in handleChoiceClick *after* rendering this page | |
} else if (!hasAvailableOptions && !page.gameOver && !page.challenge) { | |
choicesElement.innerHTML = '<p><i>No further options available from here.</i></p>'; | |
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 += `<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 = ''; // Start fresh | |
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; | |
} | |
// --- 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 |