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 | |
function createDefaultAssembly() { | |
const group = new THREE.Group(); | |
const sphereGeo = new THREE.SphereGeometry(0.5, 16, 16); | |
group.add(createMesh(sphereGeo, stoneMaterial, { x: 0, y: 0.5, z: 0 })); | |
group.add(createGroundPlane()); | |
return group; | |
} | |
function createCityGatesAssembly() { | |
const group = new THREE.Group(); | |
const gh=4, gw=1.5, gd=0.8, ah=1, aw=3; | |
const tlGeo = new THREE.BoxGeometry(gw, gh, gd); | |
group.add(createMesh(tlGeo, stoneMaterial, { x:-(aw/2+gw/2), y:gh/2, z:0 })); | |
const trGeo = new THREE.BoxGeometry(gw, gh, gd); | |
group.add(createMesh(trGeo, stoneMaterial, { x:(aw/2+gw/2), y:gh/2, z:0 })); | |
const aGeo = new THREE.BoxGeometry(aw, ah, gd); | |
group.add(createMesh(aGeo, stoneMaterial, { x:0, y:gh-ah/2, z:0 })); | |
const cs=0.4; | |
const cg = new THREE.BoxGeometry(cs, cs, gd*1.1); | |
for(let i=-1; i<=1; i+=2){ | |
group.add(createMesh(cg.clone(), stoneMaterial, { x:-(aw/2+gw/2)+i*cs*0.7, y:gh+cs/2, z:0 })); | |
group.add(createMesh(cg.clone(), stoneMaterial, { x:(aw/2+gw/2)+i*cs*0.7, y:gh+cs/2, z:0 })); | |
} | |
group.add(createMesh(cg.clone(), stoneMaterial, { x:0, y:gh+ah-cs/2, z:0 })); | |
group.add(createGroundPlane(stoneMaterial)); | |
return group; | |
} | |
function createWeaponsmithAssembly() { | |
const group = new THREE.Group(); | |
const bw=3, bh=2.5, bd=3.5; | |
const bGeo = new THREE.BoxGeometry(bw, bh, bd); | |
group.add(createMesh(bGeo, darkWoodMaterial, { x:0, y:bh/2, z:0 })); | |
const ch=3.5; | |
const cGeo = new THREE.CylinderGeometry(0.3, 0.4, ch, 8); | |
group.add(createMesh(cGeo, stoneMaterial, { x:bw*0.3, y:ch/2, z:-bd*0.3 })); | |
group.add(createGroundPlane()); | |
return group; | |
} | |
function createTempleAssembly() { | |
const group = new THREE.Group(); | |
const bs=5, bsh=0.5, ch=3, cr=0.25, rh=0.5; | |
const bGeo = new THREE.BoxGeometry(bs, bsh, bs); | |
group.add(createMesh(bGeo, templeMaterial, { x:0, y:bsh/2, z:0 })); | |
const cGeo = new THREE.CylinderGeometry(cr, cr, ch, 12); | |
const cPos = [{x:-bs/3, z:-bs/3}, {x:bs/3, z:-bs/3}, {x:-bs/3, z:bs/3}, {x:bs/3, z:bs/3}]; | |
cPos.forEach(p=>group.add(createMesh(cGeo.clone(), templeMaterial, { x:p.x, y:bsh+ch/2, z:p.z }))); | |
const rGeo = new THREE.BoxGeometry(bs*0.9, rh, bs*0.9); | |
group.add(createMesh(rGeo, templeMaterial, { x:0, y:bsh+ch+rh/2, z:0 })); | |
group.add(createGroundPlane()); | |
return group; | |
} | |
function createResistanceMeetingAssembly() { | |
const group = new THREE.Group(); | |
const tw=2, th=0.8, td=1, tt=0.1; | |
const ttg = new THREE.BoxGeometry(tw, tt, td); | |
group.add(createMesh(ttg, woodMaterial, { x:0, y:th-tt/2, z:0 })); | |
const lh=th-tt, ls=0.1; | |
const lg=new THREE.BoxGeometry(ls, lh, ls); | |
const lofW=tw/2-ls*1.5; | |
const lofD=td/2-ls*1.5; | |
group.add(createMesh(lg, woodMaterial, { x:-lofW, y:lh/2, z:-lofD })); | |
group.add(createMesh(lg.clone(), woodMaterial, { x:lofW, y:lh/2, z:-lofD })); | |
group.add(createMesh(lg.clone(), woodMaterial, { x:-lofW, y:lh/2, z:lofD })); | |
group.add(createMesh(lg.clone(), woodMaterial, { x:lofW, y:lh/2, z:lofD })); | |
const ss=0.4; | |
const sg=new THREE.BoxGeometry(ss, ss*0.8, ss); | |
group.add(createMesh(sg, darkWoodMaterial, { x:-tw*0.6, y:ss*0.4, z:0 })); | |
group.add(createMesh(sg.clone(), darkWoodMaterial, { x:tw*0.6, y:ss*0.4, z:0 })); | |
group.add(createGroundPlane(stoneMaterial)); | |
return group; | |
} | |
function createForestAssembly(tc=10, a=10) { | |
const group = new THREE.Group(); | |
const cT=(x,z)=>{ | |
const tg=new THREE.Group(); | |
const th=Math.random()*1.5+2; | |
const tr=Math.random()*0.1+0.1; | |
const tGeo = new THREE.CylinderGeometry(tr*0.7, tr, th, 8); | |
tg.add(createMesh(tGeo, woodMaterial, {x:0, y:th/2, z:0})); | |
const fr=th*0.4+0.2; | |
const fGeo=new THREE.SphereGeometry(fr, 8, 6); | |
tg.add(createMesh(fGeo, leafMaterial, {x:0, y:th*0.9, z:0})); | |
tg.position.set(x,0,z); | |
return tg; | |
}; | |
for(let i=0; i<tc; i++){ | |
const x=(Math.random()-0.5)*a; | |
const z=(Math.random()-0.5)*a; | |
if(Math.sqrt(x*x+z*z)>1.0) group.add(cT(x,z)); | |
} | |
group.add(createGroundPlane(groundMaterial, a*1.1)); | |
return group; | |
} | |
function createRoadAmbushAssembly() { | |
const group = new THREE.Group(); | |
const a=12; | |
const fg = createForestAssembly(8, a); | |
group.add(fg); | |
const rw=3, rl=a*1.2; | |
const rGeo=new THREE.PlaneGeometry(rw, rl); | |
const rMat=new THREE.MeshStandardMaterial({color:0x966F33, roughness:0.9}); | |
const r=createMesh(rGeo, rMat, {x:0, y:0.01, z:0}, {x:-Math.PI/2}); | |
r.receiveShadow=true; | |
group.add(r); | |
const rkGeo=new THREE.SphereGeometry(0.5, 5, 4); | |
const rkMat=new THREE.MeshStandardMaterial({color:0x666666, roughness:0.8}); | |
group.add(createMesh(rkGeo, rkMat, {x:rw*0.7, y:0.25, z:1}, {y:Math.random()*Math.PI})); | |
group.add(createMesh(rkGeo.clone().scale(0.8,0.8,0.8), rkMat, {x:-rw*0.8, y:0.2, z:-2}, {y:Math.random()*Math.PI})); | |
return group; | |
} | |
function createForestEdgeAssembly() { | |
const group = new THREE.Group(); | |
const a=15; | |
const fg = createForestAssembly(15, a); | |
const ttr=[]; | |
fg.children.forEach(c => { | |
if(c.type === 'Group' && c.position.x > 0) ttr.push(c); | |
}); | |
ttr.forEach(t => fg.remove(t)); | |
group.add(fg); | |
return group; | |
} | |
function createPrisonerCellAssembly() { | |
const group = new THREE.Group(); | |
const cs=3, wh=2.5, wt=0.2, br=0.05, bsp=0.25; | |
const cfMat=stoneMaterial.clone(); | |
cfMat.color.setHex(0x555555); | |
group.add(createGroundPlane(cfMat, cs)); | |
const wbGeo=new THREE.BoxGeometry(cs, wh, wt); | |
group.add(createMesh(wbGeo, stoneMaterial, {x:0, y:wh/2, z:-cs/2})); | |
const wsGeo=new THREE.BoxGeometry(wt, wh, cs); | |
group.add(createMesh(wsGeo, stoneMaterial, {x:-cs/2, y:wh/2, z:0})); | |
group.add(createMesh(wsGeo.clone(), stoneMaterial, {x:cs/2, y:wh/2, z:0})); | |
const bGeo=new THREE.CylinderGeometry(br, br, wh, 8); | |
const nb=Math.floor(cs/bsp); | |
for(let i=0; i<nb; i++){ | |
const xp=-cs/2+(i+0.5)*bsp; | |
group.add(createMesh(bGeo.clone(), metalMaterial, {x:xp, y:wh/2, z:cs/2})); | |
} | |
return group; | |
} | |
function createGameOverAssembly() { | |
const group = new THREE.Group(); | |
const boxGeo = new THREE.BoxGeometry(2, 2, 2); | |
group.add(createMesh(boxGeo, gameOverMaterial, { x: 0, y: 1, z: 0 })); | |
group.add(createGroundPlane(stoneMaterial.clone().set({color: 0x333333}))); | |
return group; | |
} | |
function createErrorAssembly() { | |
const group = new THREE.Group(); | |
const coneGeo = new THREE.ConeGeometry(0.8, 1.5, 8); | |
group.add(createMesh(coneGeo, errorMaterial, { x: 0, y: 0.75, z: 0 })); | |
group.add(createGroundPlane()); | |
return group; | |
} | |
function createCrossroadsAssembly() { | |
const group = new THREE.Group(); | |
group.add(createGroundPlane(dirtMaterial, 30)); | |
const poleGeo = new THREE.CylinderGeometry(0.1, 0.1, 3, 8); | |
group.add(createMesh(poleGeo, woodMaterial, { y: 1.5 })); | |
const signGeo = new THREE.BoxGeometry(1.5, 0.3, 0.05); | |
group.add(createMesh(signGeo, woodMaterial, { y: 2.5, z: 0.2 }, { y: Math.PI / 4 })); | |
group.add(createMesh(signGeo, woodMaterial, { y: 2.5, z: -0.2 }, { y: -Math.PI / 4 })); | |
const grassGeo = new THREE.ConeGeometry(0.2, 0.5, 6); | |
for (let i = 0; i < 20; i++) { | |
const x = (Math.random() - 0.5) * 15; | |
const z = (Math.random() - 0.5) * 15; | |
if (Math.abs(x) > 2 || Math.abs(z) > 2) { | |
group.add(createMesh(grassGeo, grassMaterial, { x, y: 0.25, z }, { y: Math.random() * Math.PI })); | |
} | |
} | |
const rockGeo = new THREE.SphereGeometry(0.3, 6, 6); | |
for (let i = 0; i < 10; i++) { | |
group.add(createMesh(rockGeo, stoneMaterial, { x: (Math.random() - 0.5) * 10, y: 0.15, z: (Math.random() - 0.5) * 10 }, { y: Math.random() * Math.PI })); | |
} | |
return group; | |
} | |
function createRollingHillsAssembly() { | |
const group = new THREE.Group(); | |
const hillGeo = new THREE.PlaneGeometry(50, 50, 10, 10); | |
const hillMat = grassMaterial.clone(); | |
const hill = new THREE.Mesh(hillGeo, hillMat); | |
hill.rotation.x = -Math.PI / 2; | |
hill.receiveShadow = true; | |
for (let i = 0; i < hillGeo.attributes.position.count; i++) { | |
const x = hillGeo.attributes.position.getX(i); | |
const z = hillGeo.attributes.position.getZ(i); | |
hillGeo.attributes.position.setY(i, Math.sin(x * 0.2 + z * 0.2) * 2); | |
} | |
hillGeo.computeVertexNormals(); | |
group.add(hill); | |
const shepherdGeo = new THREE.CylinderGeometry(0.1, 0.1, 1, 8); | |
group.add(createMesh(shepherdGeo, darkWoodMaterial, { x: 10, y: 2, z: -15 })); | |
const grassGeo = new THREE.ConeGeometry(0.3, 0.7, 6); | |
for (let i = 0; i < 50; i++) { | |
group.add(createMesh(grassGeo, grassMaterial, { x: (Math.random() - 0.5) * 40, y: 0.35, z: (Math.random() - 0.5) * 40 }, { y: Math.random() * Math.PI })); | |
} | |
return group; | |
} | |
function createCoastalCliffsAssembly() { | |
const group = new THREE.Group(); | |
const cliffGeo = new THREE.BoxGeometry(10, 5, 10); | |
group.add(createMesh(cliffGeo, stoneMaterial, { y: 2.5 })); | |
const pathGeo = new THREE.PlaneGeometry(1, 10); | |
const path = createMesh(pathGeo, dirtMaterial, { x: -2, y: 2, z: 0 }, { x: -Math.PI / 2, z: -Math.PI / 4 }); | |
group.add(path); | |
const oceanGeo = new THREE.PlaneGeometry(100, 100); | |
const ocean = createMesh(oceanGeo, oceanMaterial, { y: -2 }, { x: -Math.PI / 2 }); | |
ocean.receiveShadow = false; | |
ocean.userData.update = (time) => { | |
ocean.position.y = -2 + Math.sin(time * 0.5) * 0.1; | |
}; | |
group.add(ocean); | |
return group; | |
} | |
function createForestEntranceAssembly() { | |
const group = createForestAssembly(20, 12); | |
const rootGeo = new THREE.TorusGeometry(0.5, 0.1, 8, 16); | |
for (let i = 0; i < 10; i++) { | |
group.add(createMesh(rootGeo, woodMaterial, { x: (Math.random() - 0.5) * 8, y: 0.1, z: (Math.random() - 0.5) * 8 }, { x: Math.PI / 2 })); | |
} | |
return group; | |
} | |
function createOvergrownPathAssembly() { | |
const group = new THREE.Group(); | |
group.add(createGroundPlane(dirtMaterial, 15)); | |
const forest = createForestAssembly(15, 10); | |
group.add(forest); | |
const fungiGeo = new THREE.SphereGeometry(0.1, 8, 8); | |
for (let i = 0; i < 30; i++) { | |
group.add(createMesh(fungiGeo, glowMaterial, { x: (Math.random() - 0.5) * 8, y: 0.1, z: (Math.random() - 0.5) * 8 })); | |
} | |
const vineGeo = new THREE.CylinderGeometry(0.05, 0.05, 2, 8); | |
for (let i = 0; i < 10; i++) { | |
group.add(createMesh(vineGeo, leafMaterial, { x: (Math.random() - 0.5) * 6, y: 2, z: (Math.random() - 0.5) * 6 }, { z: Math.random() * Math.PI })); | |
} | |
return group; | |
} | |
function createClearingStatueAssembly() { | |
const group = new THREE.Group(); | |
group.add(createGroundPlane(grassMaterial, 10)); | |
const statueGeo = new THREE.BoxGeometry(0.8, 2, 0.8); | |
group.add(createMesh(statueGeo, stoneMaterial, { y: 1 })); | |
const mossGeo = new THREE.SphereGeometry(0.2, 8, 8); | |
for (let i = 0; i < 10; i++) { | |
group.add(createMesh(mossGeo, grassMaterial, { x: (Math.random() - 0.5) * 0.8, y: 0.5 + Math.random(), z: (Math.random() - 0.5) * 0.8 })); | |
} | |
const leafGeo = new THREE.PlaneGeometry(0.1, 0.1); | |
for (let i = 0; i < 20; i++) { | |
group.add(createMesh(leafGeo, leafMaterial, { x: (Math.random() - 0.5) * 5, y: 0.05, z: (Math.random() - 0.5) * 5 }, { y: Math.random() * Math.PI })); | |
} | |
return group; | |
} | |
function createGoblinAmbushAssembly() { | |
const group = createOvergrownPathAssembly(); | |
const bodyGeo = new THREE.CylinderGeometry(0.3, 0.3, 1, 8); | |
const headGeo = new THREE.SphereGeometry(0.2, 8, 8); | |
const goblinMat = new THREE.MeshStandardMaterial({ color: 0x556B2F }); | |
for (let i = -1; i <= 1; i += 2) { | |
const goblin = new THREE.Group(); | |
goblin.add(createMesh(bodyGeo, goblinMat, { y: 0.5 })); | |
goblin.add(createMesh(headGeo, goblinMat, { y: 1.2 })); | |
const spearGeo = new THREE.CylinderGeometry(0.05, 0.05, 2, 8); | |
goblin.add(createMesh(spearGeo, woodMaterial, { x: 0.3, y: 1, z: 0 }, { z: Math.PI / 4 })); | |
goblin.position.set(i * 2, 0, 2); | |
group.add(goblin); | |
} | |
return group; | |
} | |
function createHiddenCoveAssembly() { | |
const group = new THREE.Group(); | |
group.add(createGroundPlane(sandMaterial, 15)); | |
const caveGeo = new THREE.BoxGeometry(3, 2, 3); | |
group.add(createMesh(caveGeo, stoneMaterial, { z: -5, y: 1 })); | |
const rockGeo = new THREE.SphereGeometry(0.5, 6, 6); | |
for (let i = 0; i < 15; i++) { | |
group.add(createMesh(rockGeo, stoneMaterial, { x: (Math.random() - 0.5) * 10, y: 0.25, z: (Math.random() - 0.5) * 10 })); | |
} | |
const seaweedGeo = new THREE.ConeGeometry(0.2, 1, 6); | |
for (let i = 0; i < 10; i++) { | |
group.add(createMesh(seaweedGeo, leafMaterial, { x: (Math.random() - 0.5) * 8, y: 0.5, z: (Math.random() - 0.5) * 8 })); | |
} | |
return group; | |
} | |
function createDarkCaveAssembly() { | |
const group = new THREE.Group(); | |
group.add(createGroundPlane(wetStoneMaterial, 10)); | |
const wallGeo = new THREE.CylinderGeometry(3, 3, 5, 12, 1, true); | |
const wall = createMesh(wallGeo, stoneMaterial, { y: 2.5 }, { x: Math.PI / 2 }); | |
wall.scale.set(1, 1, -1); | |
group.add(wall); | |
const dripGeo = new THREE.SphereGeometry(0.05, 8, 8); | |
for (let i = 0; i < 5; i++) { | |
const drip = createMesh(dripGeo, oceanMaterial, { x: (Math.random() - 0.5) * 2, y: 4, z: (Math.random() - 0.5) * 2 }); | |
drip.userData.update = (time) => { | |
drip.position.y -= 0.1; | |
if (drip.position.y < 0) drip.position.y = 4; | |
}; | |
group.add(drip); | |
} | |
return group; | |
} | |
// Game Data | |
const itemsData = { | |
"Flaming Sword":{type:"weapon", description:"A fiery blade"}, | |
"Whispering Bow":{type:"weapon", description:"A silent bow"}, | |
"Guardian Shield":{type:"armor", description:"A protective shield"}, | |
"Healing Light Spell":{type:"spell", description:"Mends minor wounds"}, | |
"Shield of Faith Spell":{type:"spell", description:"Temporary shield"}, | |
"Binding Runes Scroll":{type:"spell", description:"Binds an enemy"}, | |
"Secret Tunnel Map":{type:"quest", description:"Shows a hidden path"}, | |
"Poison Daggers":{type:"weapon", description:"Daggers with poison"}, | |
"Master Key":{type:"quest", description:"Unlocks many doors"}, | |
"Crude Dagger":{type:"weapon", description:"A roughly made dagger."}, | |
"Scout's Pouch":{type:"quest", description:"Contains odds and ends."} | |
}; | |
const gameData = { | |
"1": { title: "The Crossroads", content: `<p>Dust swirls... Which path calls to you?</p>`, options: [ { text: "Enter the Shadowwood Forest (North)", next: 5 }, { text: "Head towards the Rolling Hills (East)", next: 2 }, { text: "Investigate the Coastal Cliffs (West)", next: 3 } ], illustration: "crossroads-signpost-sunny" }, | |
"2": { title: "Rolling Hills", content: `<p>Verdant hills stretch before you... It feels peaceful...</p>`, options: [ { text: "Follow the narrow path", next: 4 }, { text: "Try to hail the distant shepherd (Charisma Check?)", next: 99 } ], illustration: "rolling-green-hills-shepherd-distance" }, | |
"3": { title: "Coastal Cliffs Edge", content: `<p>You stand atop windswept cliffs... A precarious-looking path descends...</p>`, options: [ { text: "Attempt the precarious descent (Dexterity Check)", check: { stat: 'dexterity', dc: 12, onFailure: 31 }, next: 30 }, { text: "Scan the cliff face for easier routes (Wisdom Check)", check: { stat: 'wisdom', dc: 11, onFailure: 32 }, next: 33 } ], illustration: "windy-sea-cliffs-crashing-waves-path-down" }, | |
"4": { title: "Hill Path Overlook", content: `<p>The path crests a hill... you see a small, overgrown shrine...</p>`, options: [ { text: "Investigate the overgrown shrine", next: 40 }, { text: "Continue towards the badlands", next: 41 } ], illustration: "hilltop-view-overgrown-shrine-wildflowers" }, | |
"5": { title: "Shadowwood Entrance", content: `<p>Sunlight struggles to pierce the dense canopy... How do you proceed?</p>`, options: [ { text: "Follow the main, albeit overgrown, path", next: 6 }, { text: "Try to navigate through the lighter undergrowth", next: 7 }, { text: "Look for animal trails or signs of passage (Wisdom Check)", check: { stat: 'wisdom', dc: 10, onFailure: 6 }, next: 8 } ], illustration: "dark-forest-entrance-gnarled-roots-filtered-light" }, | |
"6": { title: "Overgrown Forest Path", content: `<p>The path is barely visible... You hear a twig snap nearby!</p>`, options: [ { text: "Ready your weapon and investigate", next: 10 }, { text: "Attempt to hide quietly (Dexterity Check)", check: { stat: 'dexterity', dc: 11, onFailure: 10 }, next: 11 }, { text: "Call out cautiously", next: 10 } ], illustration: "overgrown-forest-path-glowing-fungi-vines" }, | |
"7": { title: "Tangled Undergrowth", content: `<p>Pushing through ferns... You stumble upon a small clearing containing a moss-covered, weathered stone statue...</p>`, options: [ { text: "Examine the statue closely (Intelligence Check)", check: { stat: 'intelligence', dc: 13, onFailure: 71 }, next: 70 }, { text: "Ignore the statue and press on", next: 72 }, { text: "Leave a small offering (if possible)", next: 73 } ], illustration: "forest-clearing-mossy-statue-weathered-stone" }, | |
"8": { title: "Hidden Game Trail", content: `<p>Your sharp eyes spot a faint trail... It leads towards a ravine spanned by a rickety rope bridge.</p><p>(+20 XP)</p>`, options: [ { text: "Risk crossing the rope bridge (Dexterity Check)", check: { stat: 'dexterity', dc: 10, onFailure: 81 }, next: 80 }, { text: "Search for another way across the ravine", next: 82 } ], illustration: "narrow-game-trail-forest-rope-bridge-ravine", reward: { xp: 20 } }, | |
"10": { title: "Goblin Ambush!", content: `<p>Two scraggly goblins leap out, brandishing crude spears!</p>`, options: [ { text: "Fight the goblins!", next: 12 }, { text: "Attempt to dodge past them (Dexterity Check)", check: { stat: 'dexterity', dc: 13, onFailure: 10 }, next: 13 } ], illustration: "two-goblins-ambush-forest-path-spears" }, | |
"11": { title: "Hidden Evasion", content: `<p>You melt into the shadows as the goblins blunder past.</p><p>(+30 XP)</p>`, options: [ { text: "Continue cautiously", next: 14 } ], illustration: "forest-shadows-hiding-goblins-walking-past", reward: { xp: 30 } }, | |
"12": { title: "Ambush Victory!", content: `<p>You defeat the goblins! Found a Crude Dagger.</p><p>(+50 XP)</p>`, options: [ { text: "Press onward", next: 14 } ], illustration: "defeated-goblins-forest-path-loot", reward: { xp: 50, addItem: "Crude Dagger" } }, | |
"13": { title: "Daring Escape", content: `<p>With surprising agility, you tumble past the goblins!</p><p>(+25 XP)</p>`, options: [ { text: "Keep running!", next: 14 } ], illustration: "blurred-motion-running-past-goblins-forest", reward: { xp: 25 } }, | |
"14": { title: "Forest Stream Crossing", content: `<p>The path leads to a clear, shallow stream...</p>`, options: [ { text: "Wade across the stream", next: 16 }, { text: "Look for a drier crossing point (fallen log?)", next: 15 } ], illustration: "forest-stream-crossing-dappled-sunlight-stones" }, | |
"15": { title: "Log Bridge", content: `<p>Further upstream, a large, mossy log spans the stream.</p>`, options: [ { text: "Cross carefully on the log (Dexterity Check)", check: { stat: 'dexterity', dc: 9, onFailure: 151 }, next: 16 }, { text: "Go back and wade instead", next: 14 } ], illustration: "mossy-log-bridge-over-forest-stream" }, | |
"151": { title: "Splash!", content: `<p>You slip on the mossy log and tumble into the cold stream! You're soaked but unharmed.</p>`, options: [ { text: "Shake yourself off and continue", next: 16 } ], illustration: "character-splashing-into-stream-from-log" }, | |
"16": { title: "Edge of the Woods", content: `<p>You emerge from the Shadowwood... Before you lie rocky foothills...</p>`, options: [ { text: "Begin the ascent into the foothills", next: 17 }, { text: "Scan the fortress from afar (Wisdom Check)", check: { stat: 'wisdom', dc: 14, onFailure: 17 }, next: 18 } ], illustration: "forest-edge-view-rocky-foothills-distant-mountain-fortress" }, | |
"17": { title: "Rocky Foothills Path", content: `<p>The climb is arduous... The fortress looms larger now.</p>`, options: [ { text: "Continue the direct ascent", next: 19 }, { text: "Look for signs of a hidden trail (Wisdom Check)", check: { stat: 'wisdom', dc: 15, onFailure: 19 }, next: 20 } ], illustration: "climbing-rocky-foothills-path-fortress-closer" }, | |
"18": { title: "Distant Observation", content: `<p>You notice what might be a less-guarded approach along the western ridge...</p><p>(+30 XP)</p>`, options: [ { text: "Take the main path into the foothills", next: 17 }, { text: "Attempt the western ridge approach", next: 21 } ], illustration: "zoomed-view-mountain-fortress-western-ridge", reward: { xp: 30 } }, | |
"19": { title: "Blocked Pass", content: `<p>The main path is blocked by a recent rockslide!</p>`, options: [ { text: "Try to climb over (Strength Check)", check: { stat: 'strength', dc: 14, onFailure: 191 }, next: 190 }, { text: "Search for another way around", next: 192 } ], illustration: "rockslide-blocking-mountain-path-boulders" }, | |
"20": { title: "Goat Trail", content: `<p>You discover a narrow trail barely wide enough for a mountain goat...</p><p>(+40 XP)</p>`, options: [ { text: "Follow the precarious goat trail", next: 22 } ], illustration: "narrow-goat-trail-mountainside-fortress-view", reward: { xp: 40 } }, | |
"30": { title: "Hidden Cove", content: `<p>Your careful descent brings you to a secluded cove. A dark cave entrance is visible...</p><p>(+25 XP)</p>`, options: [ { text: "Explore the dark cave", next: 35 } ], illustration: "hidden-cove-beach-dark-cave-entrance", reward: { xp: 25 } }, | |
"31": { title: "Tumbled Down", content: `<p>You lose your footing... landing hard on the sandy cove floor. You lose 5 HP. A dark cave entrance beckons.</p>`, options: [ { text: "Gingerly explore the dark cave", next: 35 } ], illustration: "character-fallen-at-bottom-of-cliff-path-cove", hpLoss: 5 }, | |
"32": { title: "No Easier Path", content: `<p>You scan the cliffs intently but find no obviously easier routes.</p>`, options: [ { text: "Attempt the precarious descent (Dexterity Check)", check: { stat: 'dexterity', dc: 12, onFailure: 31 }, next: 30 } ], illustration: "scanning-sea-cliffs-no-other-paths-visible" }, | |
"33": { title: "Smuggler's Steps?", content: `<p>Your keen eyes spot a series of barely visible handholds and steps carved into the rock...</p><p>(+15 XP)</p>`, options: [ { text: "Use the hidden steps (Easier Dex Check)", check: { stat: 'dexterity', dc: 8, onFailure: 31 }, next: 30 } ], illustration: "close-up-handholds-carved-in-cliff-face", reward: { xp: 15 } }, | |
"35": { title: "Dark Cave", content: `<p>The cave smells of salt and decay. Water drips somewhere within.</p>`, options: [{ text: "Press deeper into the darkness", next: 99 } ], illustration: "dark-cave-entrance-dripping-water" }, | |
"40": { title: "Overgrown Shrine", content: `<p>Wildflowers grow thick around a small stone shrine. It feels ancient and neglected.</p>`, options: [{ text: "Examine the carvings (Intelligence Check)", check:{stat:'intelligence', dc:11, onFailure: 401}, next: 400 } ], illustration: "overgrown-stone-shrine-wildflowers-close" }, | |
"41": { title: "Rocky Badlands", content: `<p>The green hills give way to cracked earth and jagged rock formations under a harsh sun.</p>`, options: [{ text: "Scout ahead", next: 99 } ], illustration: "rocky-badlands-cracked-earth-harsh-sun" }, | |
"70": { title: "Statue's Secret", content:"<p>Running your fingers over the mossy stone... found a Scout's Pouch!</p><p>(+40 XP)</p>", options: [{text:"Take the pouch and press on", next: 72}], illustration:"forest-clearing-mossy-statue-hidden-compartment", reward:{addItem: "Scout's Pouch", xp: 40}}, // Added reward object | |
"71": { title: "Just an Old Statue", content:"<p>Despite a careful examination, the statue appears to be just that β an old, weathered stone figure.</p>", options: [{text:"Ignore the statue and press on", next: 72}], illustration:"forest-clearing-mossy-statue-weathered-stone"}, | |
"72": { title: "Back to the Thicket", content:"<p>Leaving the clearing, you push back into the undergrowth, eventually relocating the main path.</p>", options: [{text:"Continue along the main path", next: 6}], illustration:"pushing-through-forest-undergrowth"}, | |
"73": { title: "A Small Offering", content:"<p>You place a small offering at the statue's base. You feel a subtle sense of approval.</p>", options: [{text:"Try to find the main path again", next: 72}], illustration:"forest-clearing-mossy-statue-offering"}, | |
"80": { title: "Across the Ravine", content:"<p>You make your way carefully across the swaying rope bridge.</p><p>(+25 XP)</p>", options: [{text:"Continue following the game trail", next: 14}], illustration:"character-crossing-rope-bridge-safely", reward:{xp:25}}, | |
"81": { title: "Bridge Collapse!", content:"<p>A rope snaps! The bridge lurches, sending you plunging into the ravine! You lose 10 HP.</p>", options: [{text:"Climb out and find another way", next: 82}], illustration:"rope-bridge-snapping-character-falling", hpLoss: 10}, | |
"82": { title: "Ravine Detour", content:"<p>You find a place where a fallen log provides a safer way across.</p>", options: [{text:"Cross the log bridge and continue", next: 14}], illustration:"fallen-log-crossing-ravine"}, | |
"190": { title: "Over the Rocks", content:"<p>With considerable effort, you manage to climb over the rockslide.</p><p>(+35 XP)</p>", options: [{text:"Continue up the path", next: 22}], illustration:"character-climbing-over-boulders", reward: {xp:35} }, | |
"191": { title: "Climb Fails", content:"<p>The boulders are too unstable or sheer. You cannot climb them safely.</p>", options: [{text:"Search for another way around", next: 192}], illustration:"character-slipping-on-rockslide-boulders"}, | |
"192": { title: "Detour Found", content:"<p>After some searching, you find a rough path leading around the rockslide, eventually rejoining the main trail.</p>", options: [{text:"Continue up the path", next: 22}], illustration:"rough-detour-path-around-rockslide"}, | |
"21": { title: "Western Ridge", content:"<p>The ridge path is narrow and exposed, with strong winds threatening to push you off.</p>", options: [{text:"Proceed carefully (Dexterity Check)", check:{stat:'dexterity', dc: 14, onFailure: 211}, next: 22 } ], illustration:"narrow-windy-mountain-ridge-path" }, | |
"22": { title: "Fortress Approach", content:"<p>You've navigated the treacherous paths and now stand near the outer walls of the dark fortress. Guards patrol the battlements.</p>", options: [{text:"Look for an unguarded entrance", next: 99}], illustration:"approaching-dark-fortress-walls-guards"}, // Simplified options for base code | |
"211": {title:"Lost Balance", content:"<p>A strong gust of wind catches you off guard, sending you tumbling down a steep slope! You lose 10 HP.</p>", options:[{text:"Climb back up and find another way", next: 17}], illustration:"character-falling-off-windy-ridge", hpLoss: 10}, | |
"400": { title: "Shrine Insights", content:"<p>The carvings depict cycles of growth. You feel a sense of calm. (+1 HP)</p>", options: [{text:"Continue towards the badlands", next: 41}], illustration: "overgrown-stone-shrine-wildflowers-close", reward: {hpGain: 1}}, | |
"401": { title: "Mysterious Carvings", content:"<p>The carvings are too worn to decipher.</p>", options: [{text:"Continue towards the badlands", next: 41}], illustration: "overgrown-stone-shrine-wildflowers-close"}, | |
"402": { title: "Moment of Peace", content:"<p>You spend a quiet moment in reflection. The tranquility settles your nerves.</p>", options: [{text:"Continue towards the badlands", next: 41}], illustration: "overgrown-stone-shrine-wildflowers-close"}, | |
"99": { title: "Game Over / To Be Continued...", content: "<p>Your adventure ends here (for now).</p>", options: [{ text: "Restart", next: 1 }], illustration: "game-over-generic", gameOver: true } | |
}; | |
// 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() { | |
// This function RESETS the game state fully | |
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: [] | |
}; | |
// Use deep copy for nested objects | |
gameState = { currentPageId: 1, character: JSON.parse(JSON.stringify(defaultChar)) }; | |
console.log("Starting new game state:", gameState); | |
renderPage(gameState.currentPageId); | |
} | |
function handleChoiceClick(choiceData) { | |
console.log("Choice clicked:", choiceData); | |
// --- Handle Restart --- | |
// If the 'next' property is explicitly 1 (used by the restart button on page 99) | |
if (choiceData.nextPage === 1 && gameState.currentPageId === 99) { | |
console.log("Restarting game..."); | |
startGame(); // Call the function that fully resets the game | |
return; // Stop processing this click further | |
} | |
// --- Standard Choice Processing --- | |
const optionNextPageId = parseInt(choiceData.nextPage); | |
const itemToAdd = choiceData.addItem; | |
let nextPageId = optionNextPageId; | |
let rollResultMessage = ""; | |
const check = choiceData.check; | |
// --- Basic Validation --- | |
// Check if nextPage is NaN AND there's no skill check (meaning invalid choice data) | |
if (isNaN(nextPageId) && !check) { | |
console.error("Invalid choice data:", choiceData); | |
// Optionally go to an error page or just log and return | |
// Going to page 99 for safety | |
renderPageInternal(99, gameData[99], `<p style="color:red;">Error: Invalid choice data!</p>`); | |
return; | |
} | |
// --- Skill Check Logic --- | |
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; // Success path | |
rollResultMessage = `<p class="roll-success"><em>${check.stat.charAt(0).toUpperCase() + check.stat.slice(1)} Check Success! (${totalResult} vs DC ${dc})</em></p>`; | |
} else { | |
nextPageId = parseInt(check.onFailure); // Failure path | |
rollResultMessage = `<p class="roll-failure"><em>${check.stat.charAt(0).toUpperCase() + check.stat.slice(1)} Check Failed! (${totalResult} vs DC ${dc})</em></p>`; | |
if (isNaN(nextPageId)) { // Handle invalid failure ID | |
console.error("Invalid onFailure ID:", check.onFailure); | |
nextPageId = 99; | |
rollResultMessage += `<p style="color:red;">Error: Invalid failure path ID!</p>`; | |
} | |
} | |
} | |
// --- Add Item from Choice Property (less common now) --- | |
if (itemToAdd && !gameState.character.inventory.includes(itemToAdd)) { | |
// Check if the item exists in itemsData before adding | |
if (itemsData[itemToAdd]) { | |
gameState.character.inventory.push(itemToAdd); | |
console.log("Added item via choice property:", itemToAdd); | |
} else { | |
console.warn(`Attempted to add unknown item from choice property: ${itemToAdd}`); | |
rollResultMessage += `<p style="color:orange;">Warning: Tried to add unknown item '${itemToAdd}'.</p>`; | |
} | |
} | |
// --- Get Target Page Data --- | |
const targetPageData = gameData[nextPageId]; | |
if (!targetPageData) { | |
console.error(`Data for page ${nextPageId} not found!`); | |
renderPageInternal(99, gameData[99], `<p style="color:red;">Error: Page data for ${nextPageId} missing!</p>`); | |
return; | |
} | |
// --- Apply Page Consequences/Rewards --- | |
let hpLostThisTurn = 0; | |
if (targetPageData.hpLoss) { | |
hpLostThisTurn = targetPageData.hpLoss; | |
gameState.character.stats.hp -= hpLostThisTurn; | |
console.log(`Lost ${hpLostThisTurn} HP.`); | |
} | |
if (targetPageData.reward && targetPageData.reward.hpGain) { // Check for hpGain | |
const hpGained = targetPageData.reward.hpGain; | |
gameState.character.stats.hp += hpGained; | |
console.log(`Gained ${hpGained} HP.`); | |
// Clamping to maxHP happens later | |
} | |
// Check for death AFTER applying HP changes for the current turn | |
if (gameState.character.stats.hp <= 0) { | |
gameState.character.stats.hp = 0; // Don't go below 0 | |
console.log("Player died!"); | |
nextPageId = 99; // Force game over page | |
rollResultMessage += `<p style="color:red;"><em>You have succumbed to your injuries!${hpLostThisTurn > 0 ? ` (-${hpLostThisTurn} HP)` : ''}</em></p>`; | |
renderPageInternal(nextPageId, gameData[nextPageId], rollResultMessage); // Render immediately | |
return; // Stop further processing | |
} | |
// Apply other rewards if player is still alive | |
if (targetPageData.reward) { | |
if (targetPageData.reward.xp) { | |
gameState.character.xp += targetPageData.reward.xp; | |
console.log(`Gained ${targetPageData.reward.xp} XP! Total: ${gameState.character.xp}`); | |
// TODO: Implement level up check here | |
} | |
if (targetPageData.reward.statIncrease) { | |
const stat = targetPageData.reward.statIncrease.stat; | |
const amount = targetPageData.reward.statIncrease.amount; | |
if (gameState.character.stats.hasOwnProperty(stat)) { | |
gameState.character.stats[stat] += amount; | |
console.log(`Stat ${stat} increased by ${amount}.`); | |
if (stat === 'constitution') recalculateMaxHp(); // Recalculate if Con changed | |
} | |
} | |
if (targetPageData.reward.addItem && !gameState.character.inventory.includes(targetPageData.reward.addItem)) { | |
const itemName = targetPageData.reward.addItem; | |
if (itemsData[itemName]) { // Check item exists | |
gameState.character.inventory.push(itemName); | |
console.log(`Found item via reward: ${itemName}`); | |
rollResultMessage += `<p><em>Item acquired: ${itemName}</em></p>`; | |
} else { | |
console.warn(`Attempted to add unknown item from reward: ${itemName}`); | |
rollResultMessage += `<p style="color:orange;">Warning: Tried to add unknown reward item '${itemName}'.</p>`; | |
} | |
} | |
} | |
// --- Update Game State & Render --- | |
gameState.currentPageId = nextPageId; // Set the new current page ID | |
recalculateMaxHp(); // Recalculate max HP based on current stats/level | |
gameState.character.stats.hp = Math.min(gameState.character.stats.hp, gameState.character.stats.maxHp); // Clamp HP | |
console.log("Transitioning to page:", nextPageId); | |
renderPageInternal(nextPageId, gameData[nextPageId], rollResultMessage); // Render the next page | |
} | |
function recalculateMaxHp() { | |
const baseHp = 10; // Assuming level 1 | |
const conModifier = Math.floor((gameState.character.stats.constitution - 10) / 2); | |
gameState.character.stats.maxHp = Math.max(1, baseHp + (conModifier * gameState.character.level)); | |
console.log(`Max HP recalculated: ${gameState.character.stats.maxHp}`); | |
} | |
function renderPageInternal(pageId, pageData, message = "") { | |
if (!pageData) { | |
console.error(`Render Error: No data for page ${pageId}! Falling back to page 99.`); | |
pageData = gameData[99]; // Fallback to game over page | |
message += `<p style="color:red;">Render Error: Data for page ${pageId} not found!</p>`; | |
pageId = 99; // Ensure we use 99 if data was missing | |
} | |
console.log(`Rendering page ${pageId}`); | |
storyTitleElement.textContent = pageData.title || "Untitled Page"; | |
storyContentElement.innerHTML = message + (pageData.content || "<p>...</p>"); | |
updateStatsDisplay(); | |
updateInventoryDisplay(); | |
choicesElement.innerHTML = ''; | |
if (pageData.options && pageData.options.length > 0) { | |
pageData.options.forEach(option => { | |
const button = document.createElement('button'); | |
button.classList.add('choice-button'); | |
button.textContent = option.text; | |
let requirementMet = true; | |
let requirementText = []; // Store unmet requirements text | |
// Check requirements (e.g., item needed) | |
if (option.requireItem && !gameState.character.inventory.includes(option.requireItem)) { | |
requirementMet = false; | |
requirementText.push(`Requires: ${option.requireItem}`); | |
} | |
// TODO: Add other requirement checks (stats, flags) here | |
button.disabled = !requirementMet; | |
if (!requirementMet) { | |
button.title = requirementText.join(', '); // Show requirements on hover | |
} else { | |
// Pass page ID directly in choiceData for handleChoiceClick | |
const choiceData = { nextPage: option.next, addItem: option.addItem, check: option.check }; | |
button.onclick = () => handleChoiceClick(choiceData); | |
} | |
choicesElement.appendChild(button); | |
}); | |
} else { // No options defined - End of branch or Game Over | |
const button = document.createElement('button'); | |
button.classList.add('choice-button'); | |
button.textContent = pageData.gameOver ? "Restart Adventure" : "The End (Restart?)"; | |
// Restart button always goes to page 1, triggering the startGame logic | |
button.onclick = () => handleChoiceClick({ nextPage: 1 }); // Use nextPage: 1 for restart | |
choicesElement.appendChild(button); | |
if (!pageData.gameOver) { | |
choicesElement.insertAdjacentHTML('afterbegin', '<p><i>The path ends here.</i></p>'); | |
} | |
} | |
updateScene(pageData.illustration || 'default'); | |
} | |
function renderPage(pageId) { | |
renderPageInternal(pageId, gameData[pageId]); | |
} | |
function updateStatsDisplay() { | |
const char=gameState.character; | |
statsElement.innerHTML = `<strong>Stats:</strong> <span>Lvl: ${char.level}</span> <span>XP: ${char.xp}/${char.xpToNextLevel}</span> <span>HP: ${char.stats.hp}/${char.stats.maxHp}</span> <span>Str: ${char.stats.strength}</span> <span>Int: ${char.stats.intelligence}</span> <span>Wis: ${char.stats.wisdom}</span> <span>Dex: ${char.stats.dexterity}</span> <span>Con: ${char.stats.constitution}</span> <span>Cha: ${char.stats.charisma}</span>`; | |
} | |
function updateInventoryDisplay() { | |
let h='<strong>Inventory:</strong> '; | |
if(gameState.character.inventory.length === 0){ | |
h+='<em>Empty</em>'; | |
} else { | |
gameState.character.inventory.forEach(i=>{ | |
const d=itemsData[i]||{type:'unknown',description:'???'}; | |
const c=`item-${d.type||'unknown'}`; | |
h+=`<span class="${c}" title="${d.description}">${i}</span>`; | |
}); | |
} | |
inventoryElement.innerHTML = h; | |
} | |
function updateScene(illustrationKey) { | |
if (!scene) return; // Guard clause | |
if (currentAssemblyGroup) { | |
scene.remove(currentAssemblyGroup); | |
// Basic disposal | |
currentAssemblyGroup.traverse(child => { | |
if (child.isMesh) { | |
child.geometry.dispose(); | |
// Avoid disposing shared materials | |
} | |
}); | |
currentAssemblyGroup = null; | |
} | |
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 'city-gates': assemblyFunction = createCityGatesAssembly; break; | |
case 'weaponsmith': assemblyFunction = createWeaponsmithAssembly; break; | |
case 'temple': assemblyFunction = createTempleAssembly; break; | |
case 'resistance-meeting': assemblyFunction = createResistanceMeetingAssembly; break; | |
case 'shadowwood-forest': assemblyFunction = createForestAssembly; break; | |
case 'road-ambush': assemblyFunction = createRoadAmbushAssembly; break; | |
case 'forest-edge': assemblyFunction = createForestEdgeAssembly; break; | |
case 'prisoner-cell': assemblyFunction = createPrisonerCellAssembly; break; | |
case 'game-over': case 'game-over-generic': assemblyFunction = createGameOverAssembly; break; | |
case 'error': assemblyFunction = createErrorAssembly; break; | |
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; | |
case 'rolling-green-hills-shepherd-distance': scene.fog = new THREE.Fog(0xA8E4A0, 15, 50); camera.position.set(0, 5, 15); camera.lookAt(0, 2, -5); assemblyFunction = createRollingHillsAssembly; break; | |
case 'windy-sea-cliffs-crashing-waves-path-down': scene.fog = new THREE.Fog(0x6699CC, 10, 40); scene.background = new THREE.Color(0x6699CC); camera.position.set(5, 5, 10); camera.lookAt(-2, 0, -5); assemblyFunction = createCoastalCliffsAssembly; break; | |
case 'dark-forest-entrance-gnarled-roots-filtered-light': scene.fog = new THREE.Fog(0x2E2E2E, 5, 20); camera.position.set(0, 2, 8); camera.lookAt(0, 1, 0); assemblyFunction = createForestEntranceAssembly; break; | |
case 'overgrown-forest-path-glowing-fungi-vines': scene.fog = new THREE.Fog(0x1A2F2A, 3, 15); camera.position.set(0, 1.5, 6); camera.lookAt(0, 0.5, 0); assemblyFunction = createOvergrownPathAssembly; break; | |
case 'forest-clearing-mossy-statue-weathered-stone': case 'forest-clearing-mossy-statue-hidden-compartment': case 'forest-clearing-mossy-statue-offering': scene.fog = new THREE.Fog(0x2E4F3A, 5, 25); camera.position.set(0, 2, 5); camera.lookAt(0, 1, 0); assemblyFunction = createClearingStatueAssembly; break; | |
case 'two-goblins-ambush-forest-path-spears': case 'forest-shadows-hiding-goblins-walking-past': case 'defeated-goblins-forest-path-loot': case 'blurred-motion-running-past-goblins-forest': scene.fog = new THREE.Fog(0x2E2E2E, 5, 20); camera.position.set(0, 2, 7); camera.lookAt(0, 1, 0); assemblyFunction = createGoblinAmbushAssembly; break; | |
case 'hidden-cove-beach-dark-cave-entrance': case 'character-fallen-at-bottom-of-cliff-path-cove': scene.fog = new THREE.Fog(0x336699, 5, 30); scene.background = new THREE.Color(0x336699); camera.position.set(0, 2, 8); camera.lookAt(0, 1, -2); assemblyFunction = createHiddenCoveAssembly; break; | |
case 'dark-cave-entrance-dripping-water': scene.fog = new THREE.Fog(0x1A1A1A, 2, 10); scene.background = new THREE.Color(0x1A1A1A); camera.position.set(0, 1.5, 4); camera.lookAt(0, 1, 0); assemblyFunction = createDarkCaveAssembly; break; | |
case 'hilltop-view-overgrown-shrine-wildflowers': scene.fog = new THREE.Fog(0xA8E4A0, 15, 50); camera.position.set(0, 5, 15); camera.lookAt(0, 2, -5); assemblyFunction = createRollingHillsAssembly; break; // Added missing case from data | |
case 'character-splashing-into-stream-from-log': scene.fog = new THREE.Fog(0x668866, 8, 25); camera.position.set(0, 2, 6); camera.lookAt(0, 0.5, 0); assemblyFunction = createForestAssembly; break; // Added missing case | |
case 'forest-stream-crossing-dappled-sunlight-stones': scene.fog = new THREE.Fog(0x668866, 8, 25); camera.position.set(0, 2, 6); camera.lookAt(0, 0.5, 0); assemblyFunction = createForestAssembly; break;// Added missing case | |
case 'mossy-log-bridge-over-forest-stream': scene.fog = new THREE.Fog(0x668866, 8, 25); camera.position.set(1, 2, 5); camera.lookAt(-1, 0.5, 0); assemblyFunction = createForestAssembly; break;// Added missing case | |
case 'forest-edge-view-rocky-foothills-distant-mountain-fortress': scene.fog = new THREE.Fog(0xAAAAAA, 10, 40); camera.position.set(0, 3, 10); camera.lookAt(0, 1, -5); assemblyFunction = createForestEdgeAssembly; break;// Added missing case | |
case 'climbing-rocky-foothills-path-fortress-closer': scene.fog = new THREE.Fog(0x778899, 8, 35); camera.position.set(0, 4, 9); camera.lookAt(0, 2, 0); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'zoomed-view-mountain-fortress-western-ridge': scene.fog = new THREE.Fog(0x778899, 8, 35); camera.position.set(5, 6, 12); camera.lookAt(-2, 3, -5); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'rockslide-blocking-mountain-path-boulders': scene.fog = new THREE.Fog(0x778899, 8, 35); camera.position.set(0, 4, 9); camera.lookAt(0, 2, 0); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'narrow-goat-trail-mountainside-fortress-view': scene.fog = new THREE.Fog(0x778899, 5, 30); camera.position.set(1, 3, 6); camera.lookAt(0, 2, -2); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'character-climbing-over-boulders': scene.fog = new THREE.Fog(0x778899, 8, 35); camera.position.set(0, 4, 9); camera.lookAt(0, 2, 0); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'character-slipping-on-rockslide-boulders': scene.fog = new THREE.Fog(0x778899, 8, 35); camera.position.set(0, 4, 9); camera.lookAt(0, 2, 0); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'rough-detour-path-around-rockslide': scene.fog = new THREE.Fog(0x778899, 8, 35); camera.position.set(0, 4, 9); camera.lookAt(0, 2, 0); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'narrow-windy-mountain-ridge-path': scene.fog = new THREE.Fog(0x8899AA, 6, 25); camera.position.set(2, 5, 7); camera.lookAt(0, 3, -3); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'approaching-dark-fortress-walls-guards': scene.fog = new THREE.Fog(0x444455, 5, 20); camera.position.set(0, 3, 8); camera.lookAt(0, 2, 0); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'character-falling-off-windy-ridge': scene.fog = new THREE.Fog(0x8899AA, 6, 25); camera.position.set(2, 5, 7); camera.lookAt(0, 3, -3); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'scanning-sea-cliffs-no-other-paths-visible': scene.fog = new THREE.Fog(0x6699CC, 10, 40); scene.background = new THREE.Color(0x6699CC); camera.position.set(5, 5, 10); camera.lookAt(-2, 0, -5); assemblyFunction = createCoastalCliffsAssembly; break;// Added missing case | |
case 'close-up-handholds-carved-in-cliff-face': scene.fog = new THREE.Fog(0x6699CC, 10, 40); scene.background = new THREE.Color(0x6699CC); camera.position.set(5, 5, 10); camera.lookAt(-2, 0, -5); assemblyFunction = createCoastalCliffsAssembly; break;// Added missing case | |
case 'overgrown-stone-shrine-wildflowers-close': scene.fog = new THREE.Fog(0xA8E4A0, 15, 50); camera.position.set(1, 2, 4); camera.lookAt(0, 0.5, 0); assemblyFunction = createRollingHillsAssembly; break;// Added missing case | |
case 'rocky-badlands-cracked-earth-harsh-sun': scene.fog = new THREE.Fog(0xD2B48C, 15, 40); scene.background = new THREE.Color(0xCD853F); camera.position.set(0, 3, 12); camera.lookAt(0, 1, 0); assemblyFunction = createDefaultAssembly; break;// Added missing case | |
case 'pushing-through-forest-undergrowth': scene.fog = new THREE.Fog(0x2E2E2E, 5, 20); camera.position.set(0, 1.5, 5); camera.lookAt(0, 1, 0); assemblyFunction = createForestAssembly; break;// Added missing case | |
case 'narrow-game-trail-forest-rope-bridge-ravine': scene.fog = new THREE.Fog(0x2E2E2E, 5, 20); camera.position.set(2, 3, 6); camera.lookAt(0, -1, -2); assemblyFunction = createForestAssembly; break;// Added missing case | |
case 'character-crossing-rope-bridge-safely': scene.fog = new THREE.Fog(0x2E2E2E, 5, 20); camera.position.set(2, 3, 6); camera.lookAt(0, -1, -2); assemblyFunction = createForestAssembly; break;// Added missing case | |
case 'rope-bridge-snapping-character-falling': scene.fog = new THREE.Fog(0x2E2E2E, 5, 20); camera.position.set(2, 3, 6); camera.lookAt(0, -1, -2); assemblyFunction = createForestAssembly; break;// Added missing case | |
case 'fallen-log-crossing-ravine': scene.fog = new THREE.Fog(0x2E2E2E, 5, 20); camera.position.set(2, 3, 6); camera.lookAt(0, -1, -2); assemblyFunction = createForestAssembly; break;// Added missing case | |
default: | |
console.warn(`Unknown illustration key: "${illustrationKey}". Using default.`); | |
assemblyFunction = createDefaultAssembly; break; | |
} | |
try { | |
currentAssemblyGroup = assemblyFunction(); | |
if (currentAssemblyGroup && currentAssemblyGroup.isGroup) { // Check it's a group | |
scene.add(currentAssemblyGroup); | |
adjustLighting(illustrationKey); | |
} else { | |
throw new Error("Assembly function failed to return a valid THREE.Group"); | |
} | |
} catch (error) { | |
console.error(`Error creating assembly for ${illustrationKey}:`, error); | |
if (currentAssemblyGroup) { scene.remove(currentAssemblyGroup); } // Try cleanup | |
currentAssemblyGroup = createErrorAssembly(); | |
scene.add(currentAssemblyGroup); | |
adjustLighting('error'); | |
} | |
} | |
function adjustLighting(illustrationKey) { // Unchanged | |
if (!scene) return; | |
scene.children.forEach(child => { if (child.isLight && child !== scene.children.find(c => c.isAmbientLight)) { scene.remove(child); } }); | |
const ambient = scene.children.find(c => c.isAmbientLight); | |
if (!ambient) { console.warn("Ambient light missing!"); return; } // Added guard | |
let directionalLight; | |
switch (illustrationKey) { | |
case 'crossroads-signpost-sunny': ambient.intensity = 0.8; directionalLight = new THREE.DirectionalLight(0xFFF8E1, 1.5); directionalLight.position.set(10, 15, 10); break; | |
case 'dark-forest-entrance-gnarled-roots-filtered-light': case 'overgrown-forest-path-glowing-fungi-vines': ambient.intensity = 0.3; directionalLight = new THREE.DirectionalLight(0xA8E4A0, 0.6); directionalLight.position.set(5, 10, 5); break; | |
case 'dark-cave-entrance-dripping-water': ambient.intensity = 0.1; directionalLight = new THREE.DirectionalLight(0x666666, 0.2); directionalLight.position.set(2, 5, 2); break; | |
default: ambient.intensity = 0.5; directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); directionalLight.position.set(8, 15, 10); | |
} | |
if (!directionalLight) { // Ensure light is created | |
console.warn("Directional light not set for key:", illustrationKey, ". Using default."); | |
ambient.intensity = 0.5; directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); directionalLight.position.set(8, 15, 10); | |
} | |
directionalLight.castShadow = true; | |
directionalLight.shadow.mapSize.set(1024, 1024); | |
directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 50; | |
directionalLight.shadow.camera.left = -15; directionalLight.shadow.camera.right = 15; | |
directionalLight.shadow.camera.top = 15; directionalLight.shadow.camera.bottom = -15; | |
scene.add(directionalLight); | |
} | |
// Initialization | |
document.addEventListener('DOMContentLoaded', () => { | |
console.log("DOM Ready."); | |
try { | |
initThreeJS(); // Initialize Three.js first | |
if (!scene || !camera || !renderer) { // Check if init failed | |
throw new Error("Three.js initialization failed."); | |
} | |
startGame(); // Start game logic | |
console.log("Game started."); | |
} catch (error) { | |
console.error("Initialization failed:", error); | |
storyTitleElement.textContent = "Error During Initialization"; | |
storyContentElement.innerHTML = `<p>Could not start the adventure. Please check the developer console (F12) for more details.</p><pre>${error.stack || error}</pre>`; | |
choicesElement.innerHTML = ''; // Clear choices on error | |
if(sceneContainer) sceneContainer.innerHTML = ''; // Clear scene on error | |
} | |
}); | |
</script> | |
</body> | |
</html> |