Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Choose Your Own Procedural Adventure</title> | |
<style> | |
body { | |
font-family: 'Courier New', monospace; | |
background-color: #222; | |
color: #eee; | |
margin: 0; | |
padding: 0; | |
overflow: hidden; | |
display: flex; | |
flex-direction: column; | |
height: 100vh; | |
} | |
#game-container { | |
display: flex; | |
flex-grow: 1; | |
overflow: hidden; | |
} | |
#scene-container { | |
flex-grow: 3; | |
position: relative; | |
border-right: 2px solid #555; | |
min-width: 200px; | |
background-color: #1a1a1a; | |
height: 100%; | |
box-sizing: border-box; | |
} | |
#ui-container { | |
flex-grow: 2; | |
padding: 20px; | |
overflow-y: auto; | |
background-color: #333; | |
min-width: 280px; | |
height: 100%; | |
box-sizing: border-box; | |
display: flex; | |
flex-direction: column; | |
} | |
#scene-container canvas { display: block; } | |
#story-title { | |
color: #ffcc66; | |
margin-top: 0; | |
margin-bottom: 15px; | |
border-bottom: 1px solid #555; | |
padding-bottom: 10px; | |
font-size: 1.4em; | |
} | |
#story-content { | |
margin-bottom: 20px; | |
line-height: 1.6; | |
flex-grow: 1; | |
} | |
#story-content p { margin-bottom: 1em; } | |
#story-content p:last-child { margin-bottom: 0; } | |
#stats-inventory-container { | |
margin-bottom: 20px; | |
padding-bottom: 15px; | |
border-bottom: 1px solid #555; | |
font-size: 0.9em; | |
} | |
#stats-display, #inventory-display { | |
margin-bottom: 10px; | |
line-height: 1.8; | |
} | |
#stats-display span, #inventory-display span { | |
display: inline-block; | |
background-color: #444; | |
padding: 3px 8px; | |
border-radius: 15px; | |
margin-right: 8px; | |
margin-bottom: 5px; | |
border: 1px solid #666; | |
white-space: nowrap; | |
} | |
#stats-display strong, #inventory-display strong { color: #aaa; margin-right: 5px; } | |
#inventory-display em { color: #888; font-style: normal; } | |
#inventory-display .item-quest { background-color: #666030; border-color: #999048;} | |
#inventory-display .item-weapon { background-color: #663030; border-color: #994848;} | |
#inventory-display .item-armor { background-color: #306630; border-color: #489948;} | |
#inventory-display .item-spell { background-color: #303066; border-color: #484899;} | |
#inventory-display .item-unknown { background-color: #555; border-color: #777;} | |
#choices-container { | |
margin-top: auto; | |
padding-top: 15px; | |
border-top: 1px solid #555; | |
} | |
#choices-container h3 { margin-top: 0; margin-bottom: 10px; color: #aaa; } | |
#choices { display: flex; flex-direction: column; gap: 10px; } | |
.choice-button { | |
display: block; width: 100%; padding: 10px 12px; margin-bottom: 0; | |
background-color: #555; color: #eee; border: 1px solid #777; | |
border-radius: 5px; cursor: pointer; text-align: left; | |
font-family: 'Courier New', monospace; font-size: 1em; | |
transition: background-color 0.2s, border-color 0.2s; | |
box-sizing: border-box; | |
} | |
.choice-button:hover:not(:disabled) { background-color: #d4a017; color: #222; border-color: #b8860b; } | |
.choice-button:disabled { background-color: #444; color: #888; cursor: not-allowed; border-color: #666; opacity: 0.7; } | |
.roll-success { color: #7f7; border-left: 3px solid #4a4; padding-left: 8px; margin-bottom: 1em; font-size: 0.9em; } | |
.roll-failure { color: #f77; border-left: 3px solid #a44; padding-left: 8px; margin-bottom: 1em; font-size: 0.9em; } | |
</style> | |
</head> | |
<body> | |
<div id="game-container"> | |
<div id="scene-container"></div> | |
<div id="ui-container"> | |
<h2 id="story-title">Loading Adventure...</h2> | |
<div id="story-content"> | |
<p>Please wait while the adventure loads.</p> | |
</div> | |
<div id="stats-inventory-container"> | |
<div id="stats-display"></div> | |
<div id="inventory-display"></div> | |
</div> | |
<div id="choices-container"> | |
<h3>What will you do?</h3> | |
<div id="choices"></div> | |
</div> | |
</div> | |
</div> | |
<script type="importmap"> | |
{ | |
"imports": { | |
"three": "https://unpkg.com/[email protected]/build/three.module.js", | |
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/" | |
} | |
} | |
</script> | |
<script type="module"> | |
import * as THREE from 'three'; | |
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'); | |
let scene, camera, renderer; | |
let currentAssemblyGroup = null; | |
// Materials | |
const stoneMaterial = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8, metalness: 0.1 }); | |
const woodMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.7, metalness: 0 }); | |
const darkWoodMaterial = new THREE.MeshStandardMaterial({ color: 0x5C3D20, roughness: 0.7, metalness: 0 }); | |
const leafMaterial = new THREE.MeshStandardMaterial({ color: 0x2E8B57, roughness: 0.6, metalness: 0 }); | |
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x556B2F, roughness: 0.9, metalness: 0 }); | |
const metalMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.8, roughness: 0.3 }); | |
const templeMaterial = new THREE.MeshStandardMaterial({ color: 0xA99B78, roughness: 0.7, metalness: 0.1 }); | |
const errorMaterial = new THREE.MeshStandardMaterial({ color: 0xffa500, roughness: 0.5 }); | |
const gameOverMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.5 }); | |
const dirtMaterial = new THREE.MeshStandardMaterial({ color: 0x8B5E3C, roughness: 0.9 }); | |
const grassMaterial = new THREE.MeshStandardMaterial({ color: 0x3CB371, roughness: 0.8 }); | |
const oceanMaterial = new THREE.MeshStandardMaterial({ color: 0x1E90FF, roughness: 0.5, metalness: 0.2 }); | |
const sandMaterial = new THREE.MeshStandardMaterial({ color: 0xF4A460, roughness: 0.9 }); | |
const wetStoneMaterial = new THREE.MeshStandardMaterial({ color: 0x2F4F4F, roughness: 0.7 }); | |
const glowMaterial = new THREE.MeshStandardMaterial({ color: 0x00FFAA, emissive: 0x00FFAA, emissiveIntensity: 0.5 }); | |
function initThreeJS() { | |
if (!sceneContainer) { console.error("Scene container not found!"); return; } | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x222222); | |
const width = sceneContainer.clientWidth; | |
const height = sceneContainer.clientHeight; | |
camera = new THREE.PerspectiveCamera(75, (width / height) || 1, 0.1, 1000); | |
camera.position.set(0, 2.5, 7); | |
camera.lookAt(0, 0.5, 0); | |
renderer = new THREE.WebGLRenderer({ antialias: true }); | |
renderer.setSize(width || 400, height || 300); | |
renderer.shadowMap.enabled = true; | |
renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
sceneContainer.appendChild(renderer.domElement); | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
scene.add(ambientLight); | |
window.addEventListener('resize', onWindowResize, false); | |
setTimeout(onWindowResize, 100); | |
animate(); | |
} | |
function onWindowResize() { | |
if (!renderer || !camera || !sceneContainer) return; | |
const width = sceneContainer.clientWidth; | |
const height = sceneContainer.clientHeight; | |
if (width > 0 && height > 0) { | |
camera.aspect = width / height; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(width, height); | |
} | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
const time = performance.now() * 0.001; | |
scene.traverse(obj => { | |
if (obj.userData.update) obj.userData.update(time); | |
}); | |
if (renderer && scene && camera) { | |
renderer.render(scene, camera); | |
} | |
} | |
function createMesh(geometry, material, position = { x: 0, y: 0, z: 0 }, rotation = { x: 0, y: 0, z: 0 }, scale = { x: 1, y: 1, z: 1 }) { | |
const mesh = new THREE.Mesh(geometry, material); | |
mesh.position.set(position.x, position.y, position.z); | |
mesh.rotation.set(rotation.x, rotation.y, rotation.z); | |
mesh.scale.set(scale.x, scale.y, scale.z); | |
mesh.castShadow = true; mesh.receiveShadow = true; | |
return mesh; | |
} | |
function createGroundPlane(material = groundMaterial, size = 20) { | |
const groundGeo = new THREE.PlaneGeometry(size, size); | |
const ground = new THREE.Mesh(groundGeo, material); | |
ground.rotation.x = -Math.PI / 2; ground.position.y = -0.05; | |
ground.receiveShadow = true; ground.castShadow = false; | |
return ground; | |
} | |
// [Procedural generation functions here: createDefaultAssembly, createCityGatesAssembly, etc.] | |
// Game Data | |
const itemsData = { | |
"Flaming Sword":{type:"weapon", description:"A fiery blade"}, | |
// β¦ [other items] | |
}; | |
const gameData = { | |
"1": { title: "The Crossroads", content: `<p>β¦</p>`, options: [ { text: "Enter the Shadowwood Forest (North)", next: 5 }, /* β¦ */ ], illustration: "crossroads-signpost-sunny" }, | |
// β¦ [all other pages] | |
}; | |
// Game State | |
let gameState = { | |
currentPageId: 1, | |
character: { | |
name: "Hero", race: "Human", alignment: "Neutral Good", class: "Adventurer", | |
level: 1, xp: 0, xpToNextLevel: 100, | |
stats: { strength: 8, intelligence: 10, wisdom: 10, dexterity: 10, constitution: 10, charisma: 8, hp: 12, maxHp: 12 }, | |
inventory: [] | |
} | |
}; | |
// Game Logic Functions | |
function startGame() { | |
const defaultChar = { | |
name: "Hero", race: "Human", alignment: "Neutral Good", class: "Adventurer", | |
level: 1, xp: 0, xpToNextLevel: 100, | |
stats: { strength: 8, intelligence: 10, wisdom: 10, dexterity: 10, constitution: 10, charisma: 8, hp: 12, maxHp: 12 }, | |
inventory: [] | |
}; | |
gameState = { currentPageId: 1, character: JSON.parse(JSON.stringify(defaultChar)) }; | |
renderPage(gameState.currentPageId); | |
} | |
function handleChoiceClick(choiceData) { | |
// FIXED: use `choiceData.next` (which we actually pass) instead of undefined `choiceData.nextPage` | |
const optionNextPageId = Number(choiceData.next); | |
const itemToAdd = choiceData.addItem; | |
let nextPageId = optionNextPageId; | |
let rollResultMessage = ""; | |
const check = choiceData.check; | |
if (isNaN(optionNextPageId) && !check && choiceData.next !== 1) { | |
console.error("Invalid choice data:", choiceData); | |
renderPageInternal(99, gameData[99] || { title: "Error", content: "<p>Invalid Choice Data!</p>", illustration: "error", gameOver: true }, "<p><em>Error: Invalid choice data encountered!</em></p>"); | |
return; | |
} | |
if (choiceData.next === 1 && gameState.currentPageId === 99) { | |
startGame(); | |
return; | |
} | |
if (check) { | |
const statValue = gameState.character.stats[check.stat] || 10; | |
const modifier = Math.floor((statValue - 10) / 2); | |
const roll = Math.floor(Math.random() * 20) + 1; | |
const totalResult = roll + modifier; | |
const dc = check.dc; | |
console.log(`Check: ${check.stat} (DC ${dc}) | Roll: ${roll} + Mod: ${modifier} = ${totalResult}`); | |
if (totalResult >= dc) { | |
nextPageId = optionNextPageId; | |
rollResultMessage = `<p class="roll-success"><em>${check.stat.charAt(0).toUpperCase() + check.stat.slice(1)} Check Success! (Rolled ${roll} + ${modifier} = ${totalResult} vs DC ${dc})</em></p>`; | |
} else { | |
nextPageId = parseInt(check.onFailure, 10); | |
rollResultMessage = `<p class="roll-failure"><em>${check.stat.charAt(0).toUpperCase() + check.stat.slice(1)} Check Failed! (Rolled ${roll} + ${modifier} = ${totalResult} vs DC ${dc})</em></p>`; | |
if (isNaN(nextPageId)) { | |
console.error("Invalid onFailure ID:", check.onFailure); | |
nextPageId = 99; | |
rollResultMessage += "<p><em>Error: Invalid failure path!</em></p>"; | |
} | |
} | |
} | |
const targetPageData = gameData[nextPageId]; | |
if (targetPageData) { | |
if (targetPageData.hpLoss) { | |
gameState.character.stats.hp -= targetPageData.hpLoss; | |
if (gameState.character.stats.hp <= 0) { | |
gameState.character.stats.hp = 0; | |
nextPageId = 99; | |
rollResultMessage += "<p><em>You have succumbed to your injuries!</em></p>"; | |
} | |
} | |
if (targetPageData.reward) { | |
if (targetPageData.reward.xp) { | |
gameState.character.xp += targetPageData.reward.xp; | |
} | |
if (targetPageData.reward.statIncrease) { | |
const { stat, amount } = targetPageData.reward.statIncrease; | |
if (gameState.character.stats.hasOwnProperty(stat)) { | |
gameState.character.stats[stat] += amount; | |
if (stat === 'constitution') { | |
const conMod = Math.floor((gameState.character.stats.constitution - 10) / 2); | |
gameState.character.stats.maxHp = 10 + (conMod * gameState.character.level); | |
gameState.character.stats.hp = Math.min(gameState.character.stats.hp, gameState.character.stats.maxHp); | |
} | |
} | |
} | |
if (targetPageData.reward.addItem && !gameState.character.inventory.includes(targetPageData.reward.addItem)) { | |
gameState.character.inventory.push(targetPageData.reward.addItem); | |
} | |
} | |
if (itemToAdd && !gameState.character.inventory.includes(itemToAdd)) { | |
gameState.character.inventory.push(itemToAdd); | |
} | |
} else { | |
console.error(`Data for page ${nextPageId} not found!`); | |
renderPageInternal(99, gameData[99], "<p><em>Error: Next page data missing!</em></p>"); | |
return; | |
} | |
gameState.currentPageId = nextPageId; | |
const conModifier = Math.floor((gameState.character.stats.constitution - 10) / 2); | |
gameState.character.stats.maxHp = 10 + conModifier; | |
gameState.character.stats.hp = Math.min(gameState.character.stats.hp, gameState.character.stats.maxHp); | |
renderPageInternal(nextPageId, gameData[nextPageId], rollResultMessage); | |
} | |
function renderPageInternal(pageId, pageData, message = "") { | |
if (!pageData) { | |
pageData = gameData[99]; | |
message += "<p><em>Render Error: Page data missing!</em></p>"; | |
pageId = 99; | |
} | |
storyTitleElement.textContent = pageData.title; | |
storyContentElement.innerHTML = message + pageData.content; | |
updateStatsDisplay(); | |
updateInventoryDisplay(); | |
choicesElement.innerHTML = ''; | |
const options = pageData.options || []; | |
const isGameOverOrEnd = pageData.gameOver || options.length === 0; | |
if (!isGameOverOrEnd) { | |
options.forEach(option => { | |
const button = document.createElement('button'); | |
button.classList.add('choice-button'); | |
button.textContent = option.text; | |
let requirementMet = true; | |
let requirementText = ""; | |
if (option.requireItem && !gameState.character.inventory.includes(option.requireItem)) { | |
requirementMet = false; | |
requirementText = `Requires: ${option.requireItem}`; | |
} | |
button.disabled = !requirementMet; | |
if (requirementMet) { | |
const choiceData = { | |
next: option.next, | |
addItem: option.addItem, | |
check: option.check | |
}; | |
button.onclick = () => handleChoiceClick(choiceData); | |
} else { | |
button.title = requirementText; | |
button.classList.add('disabled'); | |
} | |
choicesElement.appendChild(button); | |
}); | |
} else { | |
const button = document.createElement('button'); | |
button.classList.add('choice-button'); | |
button.textContent = pageData.gameOver ? "Restart Adventure" : "The Path Ends Here (Restart?)"; | |
button.onclick = () => handleChoiceClick({ next: 1 }); | |
choicesElement.appendChild(button); | |
if (!pageData.gameOver) { | |
choicesElement.insertAdjacentHTML('afterbegin', '<p><i>There are no further paths from here.</i></p>'); | |
} | |
} | |
updateScene(pageData.illustration || 'default'); | |
} | |
function renderPage(pageId) { renderPageInternal(pageId, gameData[pageId]); } | |
function updateStatsDisplay() { | |
const c = gameState.character; | |
statsElement.innerHTML = | |
`<strong>Stats:</strong> | |
<span>Lvl: ${c.level}</span> | |
<span>XP: ${c.xp}/${c.xpToNextLevel}</span> | |
<span>HP: ${c.stats.hp}/${c.stats.maxHp}</span> | |
<span>Str: ${c.stats.strength}</span> | |
<span>Int: ${c.stats.intelligence}</span> | |
<span>Wis: ${c.stats.wisdom}</span> | |
<span>Dex: ${c.stats.dexterity}</span> | |
<span>Con: ${c.stats.constitution}</span> | |
<span>Cha: ${c.stats.charisma}</span>`; | |
} | |
function updateInventoryDisplay() { | |
let html = '<strong>Inventory:</strong> '; | |
if (gameState.character.inventory.length === 0) { | |
html += '<em>Empty</em>'; | |
} else { | |
gameState.character.inventory.forEach(itemName => { | |
const item = itemsData[itemName] || { type: 'unknown', description: 'An unknown item.' }; | |
const cls = `item-${item.type || 'unknown'}`; | |
html += `<span class="${cls}" title="${item.description}">${itemName}</span>`; | |
}); | |
} | |
inventoryElement.innerHTML = html; | |
} | |
function updateScene(illustrationKey) { | |
if (!scene) return; | |
if (currentAssemblyGroup) scene.remove(currentAssemblyGroup); | |
scene.fog = null; | |
scene.background = new THREE.Color(0x222222); | |
camera.position.set(0, 2.5, 7); | |
camera.lookAt(0, 0.5, 0); | |
let assemblyFunction; | |
switch (illustrationKey) { | |
case 'crossroads-signpost-sunny': | |
scene.fog = new THREE.Fog(0x87CEEB, 10, 30); | |
scene.background = new THREE.Color(0x87CEEB); | |
camera.position.set(0, 3, 10); camera.lookAt(0, 1, 0); | |
assemblyFunction = createCrossroadsAssembly; | |
break; | |
// β¦ [all other cases] | |
default: | |
assemblyFunction = createDefaultAssembly; | |
break; | |
} | |
try { | |
currentAssemblyGroup = assemblyFunction(); | |
scene.add(currentAssemblyGroup); | |
adjustLighting(illustrationKey); | |
} catch (e) { | |
console.error(`Error building scene "${illustrationKey}":`, e); | |
currentAssemblyGroup = createErrorAssembly(); | |
scene.add(currentAssemblyGroup); | |
adjustLighting('error'); | |
} | |
onWindowResize(); | |
} | |
function adjustLighting(illustrationKey) { | |
if (!scene) return; | |
const toRemove = scene.children.filter(c => c.isLight && !c.isAmbientLight); | |
toRemove.forEach(l => scene.remove(l)); | |
const ambient = scene.children.find(c => c.isAmbientLight); | |
if (!ambient) scene.add(new THREE.AmbientLight(0xffffff, 0.5)); | |
let intensity = 1.2, amb = 0.5, color = 0xffffff, pos = { x: 8, y: 15, z: 10 }; | |
switch (illustrationKey) { | |
case 'crossroads-signpost-sunny': | |
amb = 0.8; intensity = 1.5; color = 0xFFF8E1; pos = { x: 10, y: 15, z: 10 }; | |
break; | |
// β¦ [lighting cases] | |
case 'error': | |
amb = 0.4; intensity = 1.0; color = 0xFFCC00; pos = { x: 0, y: 5, z: 5 }; | |
break; | |
default: | |
break; | |
} | |
const ambLight = scene.children.find(c => c.isAmbientLight); | |
if (ambLight) ambLight.intensity = amb; | |
const dir = new THREE.DirectionalLight(color, intensity); | |
dir.position.set(pos.x, pos.y, pos.z); | |
dir.castShadow = true; | |
dir.shadow.mapSize.set(1024, 1024); | |
dir.shadow.camera.near = 0.5; | |
dir.shadow.camera.far = 50; | |
dir.shadow.camera.left = -15; | |
dir.shadow.camera.right = 15; | |
dir.shadow.camera.top = 15; | |
dir.shadow.camera.bottom = -15; | |
scene.add(dir); | |
} | |
document.addEventListener('DOMContentLoaded', () => { | |
try { | |
initThreeJS(); | |
if (scene && camera && renderer) { | |
startGame(); | |
} else { | |
throw new Error("Three.js failed to initialize."); | |
} | |
} catch (err) { | |
console.error("Initialization error:", err); | |
storyTitleElement.textContent = "Initialization Error"; | |
storyContentElement.innerHTML = `<p>Unable to start the game. Check console for details.</p><pre>${err.stack}</pre>`; | |
choicesElement.innerHTML = '<p>Cannot proceed.</p>'; | |
if (sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding:10px;">3D Scene Failed</p>'; | |
} | |
}); | |
</script> | |
</body> | |
</html> | |