1d353bd
edaf0c0
7c0cab7
7f369a4
edaf0c0
7c0cab7
1d353bd
7c0cab7
6c1f7e8
7c0cab7
0a69594
7c0cab7
0a69594
6c1f7e8
edaf0c0
0a69594
6c1f7e8
0a69594
7c0cab7
1d353bd
7c0cab7
0a69594
7c0cab7
0a69594
6c1f7e8
0a69594
edaf0c0
7c0cab7
0a69594
7c0cab7
0a69594
edaf0c0
0a69594
edaf0c0
7c0cab7
0a69594
edaf0c0
0a69594
edaf0c0
0a69594
edaf0c0
7f369a4
0a69594
7c0cab7
6c1f7e8
7c0cab7
7f369a4
7c0cab7
0a69594
7f369a4
7c0cab7
0a69594
7f369a4
6c1f7e8
0a69594
6c1f7e8
0a69594
edaf0c0
6c1f7e8
edaf0c0
7f369a4
edaf0c0
6c1f7e8
0a69594
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
6c1f7e8
edaf0c0
6c1f7e8
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
6c1f7e8
0a69594
6c1f7e8
edaf0c0
6c1f7e8
0a69594
7f369a4
edaf0c0
0a69594
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
6c1f7e8
7f369a4
edaf0c0
6c1f7e8
edaf0c0
6c1f7e8
7f369a4
6c1f7e8
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
6c1f7e8
edaf0c0
6c1f7e8
edaf0c0
6c1f7e8
edaf0c0
6c1f7e8
edaf0c0
6c1f7e8
7f369a4
edaf0c0
6c1f7e8
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
7f369a4
edaf0c0
6c1f7e8
7f369a4
6c1f7e8
edaf0c0
6c1f7e8
edaf0c0
6c1f7e8
7f369a4
6c1f7e8
0a69594
6c1f7e8
0a69594
6c1f7e8
7f369a4
6c1f7e8
edaf0c0
0a69594
6c1f7e8
0a69594
edaf0c0
6c1f7e8
0a69594
edaf0c0
6c1f7e8
0a69594
edaf0c0
0a69594
edaf0c0
0a69594
7f369a4
0a69594
edaf0c0
0a69594
edaf0c0
0a69594
7c0cab7
6c1f7e8
7c0cab7
1d353bd
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
<!DOCTYPE html>
<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 (Stable Base)</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}
.xp-gain{color:#7af;font-style:italic;font-size:0.9em;display:block;margin-top:0.5em;}
</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;
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 });
// Added materials for forest variation from previous step, keep them
const pineLeafMaterial = new THREE.MeshStandardMaterial({ color: 0x1A5A2A, roughness: 0.7, metalness: 0 });
const gnarledWoodMaterial = new THREE.MeshStandardMaterial({ color: 0x6B4F3A, roughness: 0.85, metalness: 0 });
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);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(8, 15, 10); directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 1024; directionalLight.shadow.mapSize.height = 1024;
directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 50;
const shadowCamSize = 15;
directionalLight.shadow.camera.left = -shadowCamSize; directionalLight.shadow.camera.right = shadowCamSize;
directionalLight.shadow.camera.top = shadowCamSize; directionalLight.shadow.camera.bottom = -shadowCamSize;
scene.add(directionalLight);
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);
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 (Using simpler, non-parameterized versions first) ---
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(params = {}) { // Kept params for potential future use, but using defaults now
const group = new THREE.Group(); const tc=10, a=10; // Use fixed values
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(); 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(); 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; }
// --- 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."}, "Healing Poultice":{type:"spell", description:"Soothes wounds, heals 5 HP."} };
const gameData = { // Using the expanded game data with ~20 pages and checks
"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...</p>`, options: [ { text: "Follow the narrow path", next: 4 }, { text: "Head back to the crossroads", next: 1 } ], illustration: "rolling-green-hills-shepherd-distance" },
"3": { title: "Coastal Cliffs Edge", content: `<p>You stand atop windswept cliffs...</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 }, { text: "Return to the crossroads", next: 1 } ], 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 }, { text: "Go back", next: 2 } ], 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 class="xp-gain">(+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: 101 }, 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 class="xp-gain">(+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 class="xp-gain">(+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 class="xp-gain">(+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 class="xp-gain">(+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 class="xp-gain">(+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 class="xp-gain">(+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... 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 }, { text: "Return to crossroads", next: 1} ], 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 class="xp-gain">(+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...</p>`, options: [{ text: "Press deeper into the darkness (End)", next: 99 } ], illustration: "dark-cave-entrance-dripping-water" },
"40": { title: "Overgrown Shrine", content: `<p>Wildflowers grow thick around a small stone shrine...</p>`, options: [{ text: "Examine the carvings (Intelligence Check)", check:{stat:'intelligence', dc:11, onFailure: 401}, next: 400 }, { text: "Leave it be", next: 4 } ], illustration: "overgrown-stone-shrine-wildflowers-close" },
"41": { title: "Rocky Badlands", content: `<p>The green hills give way to cracked earth...</p>`, options: [{ text: "Scout ahead (End)", next: 99 } ], illustration: "rocky-badlands-cracked-earth-harsh-sun" },
"70": { title: "Statue Examined", content: `<p>The statue depicts a forgotten nature deity. You notice a small compartment at its base.</p><p class="xp-gain">(+10 XP)</p>`, options: [{text:"Try to open compartment (Strength Check?)", check:{stat:'strength', dc: 10, onFailure: 71}, next: 700}], illustration:"close-up-mossy-statue-compartment", reward:{xp:10}},
"71": { title: "Statue Unyielding", content: `<p>You examine the statue but learn little. It remains an imposing enigma.</p>`, options: [{text:"Press onward", next: 72}], illustration:"forest-clearing-mossy-statue-weathered-stone-shrug"},
"72": { title: "Deeper Woods", content: `<p>Leaving the statue behind, you push further into the increasingly dense woods.</p>`, options: [{text:"Continue", next: 14}], illustration:"dense-forest-undergrowth-shadows" },
"73": { title: "Offering Made", content: `<p>You leave a small token. For a moment, you think you feel a sense of ancient approval.</p>`, options: [{text:"Press onward", next: 72}], illustration:"offering-at-base-of-mossy-statue"},
"80": { title: "Bridge Crossed", content: `<p>You make it across the swaying bridge, your heart pounding.</p><p class="xp-gain">(+15 XP)</p>`, options: [{text:"Continue on the trail", next: 16}], illustration:"view-from-end-of-rope-bridge-forest", reward:{xp:15}},
"81": { title: "Bridge Collapse!", content: `<p>A frayed rope snaps! You plummet into the shallow ravine below, losing 8 HP!</p>`, options: [{text:"Climb out and find another way", next: 82}], illustration:"character-falling-from-broken-rope-bridge", hpLoss:8},
"82": { title: "Ravine Detour", content: `<p>You find a place where the ravine narrows and manage to climb down and back up the other side.</p><p class="xp-gain">(+5 XP)</p>`, options: [{text:"Continue on the trail", next: 16}], illustration:"climbing-out-of-shallow-ravine-forest", reward:{xp:5}},
"101": { title:"Failed Dodge", content:"<p>You try to dodge, but a goblin spear trips you! You take 3 damage.</p>", options:[{text:"Get up and Fight!", next: 12}], illustration:"character-tripped-by-goblin-spear", hpLoss: 3},
"190": { title: "Over the Rocks", content:"<p>With considerable effort, you clamber over the rockslide.</p><p class="xp-gain">(+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. 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.</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...</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...</p>`, options: [{text:"Look for an unguarded entrance (End of Demo)", next: 99}], illustration:"approaching-dark-fortress-walls-guards"},
"211": {title:"Lost Balance", content:"<p>A strong gust sends you tumbling down a steep slope! (-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 Secrets", content:"<p>The carvings depict ancient rituals. You find a loose stone revealing a Healing Poultice!</p><p class="xp-gain">(+20 XP)</p>", options:[{text:"Take the poultice and leave", next:4, addItem:"Healing Poultice"}], illustration:"close-up-shrine-carvings-hidden-compartment", reward:{xp:20}},
"401": {title:"Mysterious Carvings", content:"<p>The carvings are worn and indecipherable.</p>", options:[{text:"Leave the shrine", next:4}], illustration:"worn-stone-carvings-shrine"},
"700": {title:"Statue's Gift", content:"<p>The compartment clicks open, revealing a smooth, grey stone that feels strangely warm.</p><p class="xp-gain">(+30 XP)</p>", options:[{text:"Take the stone and press on", next: 72, addItem:"Warm Stone"}], illustration:"hand-holding-warm-grey-stone-statue-base", reward:{xp:30}},
"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, availableStatPoints: 0, // Stat points for leveling
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, availableStatPoints: 0, stats: { strength: 8, intelligence: 10, wisdom: 10, dexterity: 10, constitution: 10, charisma: 8, hp: 12, maxHp: 12 }, inventory: [] };
gameState = { currentPageId: 1, character: { ...defaultChar } };
recalculateMaxHp();
gameState.character.stats.hp = gameState.character.stats.maxHp;
renderPage(gameState.currentPageId);
}
function levelUpCharacter() {
const char = gameState.character;
if (char.xp < char.xpToNextLevel) return false;
char.level++;
char.xp -= char.xpToNextLevel;
char.xpToNextLevel = Math.floor(char.xpToNextLevel * 1.5 + 50);
char.availableStatPoints += 1; // Gain 1 point
recalculateMaxHp();
char.stats.hp = char.stats.maxHp; // Full heal
console.log(`LEVEL UP! Reached Level ${char.level}.`);
// Later, add UI to spend points. For now, they just accumulate.
updateStatsDisplay(); // Update UI to show new level/XP/HP/Points
return true;
}
function recalculateMaxHp() {
const char = gameState.character;
const conModifier = Math.floor((char.stats.constitution - 10) / 2);
char.stats.maxHp = 8 + (char.level * Math.max(1, 2 + conModifier)); // Recalculate Max HP
}
function handleChoiceClick(choiceData) {
const optionNextPageId = parseInt(choiceData.nextPage);
const itemToAdd = choiceData.addItem;
let nextPageId = optionNextPageId;
let rollResultMessage = "";
let gainedXpThisTurn = 0;
const check = choiceData.check;
const effect = choiceData.effect;
if (isNaN(optionNextPageId) && !check && !effect) { console.error("Invalid choice data:", choiceData); return; }
// --- Apply direct effects (like resting) ---
if (effect) {
if (effect.hpGain) {
const maxHeal = gameState.character.stats.maxHp - gameState.character.stats.hp;
const actualGain = Math.min(effect.hpGain, maxHeal);
if (actualGain > 0) {
gameState.character.stats.hp += actualGain;
rollResultMessage += `<p class="xp-gain">Rested and recovered ${actualGain} HP.</p>`;
console.log(`Recovered ${actualGain} HP.`);
} else {
rollResultMessage += `<p class="xp-gain">You rest, but gain no HP.</p>`;
}
}
// If effect determines next page, override default
if (effect.setNextPage !== undefined) nextPageId = effect.setNextPage;
// For simple rest, we still want to proceed, so no return here.
}
// --- Process Stat Check ---
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;
const successMargin = totalResult - dc;
console.log(`Check: ${check.stat} (DC ${dc}) | Roll: ${roll} + Mod: ${modifier} = ${totalResult}`);
if (totalResult >= dc) { // Success
nextPageId = optionNextPageId;
rollResultMessage += `<p class="roll-success"><em>Check Success! (${totalResult} vs DC ${dc})</em></p>`;
const marginBonus = Math.max(0, Math.floor(successMargin * 1.0)); // XP per point over DC
const oddsBonus = Math.max(0, Math.floor(dc * 0.5)); // XP for difficulty
const checkBonusXp = marginBonus + oddsBonus;
if (checkBonusXp > 0) { gainedXpThisTurn += checkBonusXp; rollResultMessage += `<p class="xp-gain">+${checkBonusXp} bonus XP!</p>`; console.log(`Check bonus XP: ${checkBonusXp}`); }
} else { // Failure
nextPageId = parseInt(check.onFailure);
rollResultMessage += `<p class="roll-failure"><em>Check Failed! (${totalResult} vs DC ${dc})</em></p>`;
if (isNaN(nextPageId)) { console.error("Invalid onFailure ID:", check.onFailure); nextPageId = 99; }
}
}
// --- Add immediate item from option ---
if (itemToAdd && !gameState.character.inventory.includes(itemToAdd)) {
gameState.character.inventory.push(itemToAdd); console.log("Added item:", itemToAdd);
}
// --- Move to Next Page & Process Landing ---
gameState.currentPageId = nextPageId;
const nextPageData = gameData[nextPageId];
if (nextPageData) {
if (nextPageData.hpLoss) {
gameState.character.stats.hp -= nextPageData.hpLoss;
console.log(`Lost ${nextPageData.hpLoss} HP.`);
// HP check is done after potential level up healing
}
if (nextPageData.reward) {
if (nextPageData.reward.xp) { gainedXpThisTurn += nextPageData.reward.xp; console.log(`Base reward: +${nextPageData.reward.xp} XP.`); }
if (nextPageData.reward.statIncrease) { const stat = nextPageData.reward.statIncrease.stat; const amount = nextPageData.reward.statIncrease.amount; if (gameState.character.stats.hasOwnProperty(stat)) { gameState.character.stats[stat] += amount; console.log(`Stat ${stat} increased by ${amount}.`); recalculateMaxHp(); } } // Recalc HP if CON changes
if(nextPageData.reward.addItem && !gameState.character.inventory.includes(nextPageData.reward.addItem)){ gameState.character.inventory.push(nextPageData.reward.addItem); console.log(`Found item: ${nextPageData.reward.addItem}`); }
}
if (gainedXpThisTurn > 0) {
gameState.character.xp += gainedXpThisTurn;
console.log(`Total XP Gained: ${gainedXpThisTurn}. Current XP: ${gameState.character.xp}`);
if (!rollResultMessage.includes("bonus XP")) { // Avoid double message if check bonus already added
rollResultMessage += `<p class="xp-gain">You gained ${gainedXpThisTurn} XP.</p>`;
}
}
let leveledUp = false;
while (gameState.character.xp >= gameState.character.xpToNextLevel) {
if (levelUpCharacter()) { leveledUp = true; } else { break; }
}
if (leveledUp) { rollResultMessage += `<p class="level-up">LEVEL UP! You reached Level ${gameState.character.level}!</p>`; }
recalculateMaxHp(); // Ensure maxHP is current
gameState.character.stats.hp = Math.min(gameState.character.stats.hp, gameState.character.stats.maxHp); // Clamp HP
gameState.character.stats.hp = Math.max(0, gameState.character.stats.hp);
// Check for death *after* potential healing from level up
if (gameState.character.stats.hp <= 0) {
console.log("Player died!"); nextPageId = 99; // Force redirect
renderPageInternal(99, gameData[99], rollResultMessage + "<p class='roll-failure'><em>Your wounds overwhelm you...</em></p>"); return;
}
} else { // Invalid next page ID
console.error(`Data for page ${nextPageId} not found!`);
renderPageInternal(99, gameData[99], "<p><em>Error: Next page data missing!</em></p>"); return;
}
renderPageInternal(nextPageId, gameData[nextPageId] || gameData["99"], rollResultMessage);
}
function renderPageInternal(pageId, pageData, message = "") {
if (!pageData) { console.error(`Render Error: No data for page ${pageId}`); return; }
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;
if (option.requireItem && !gameState.character.inventory.includes(option.requireItem)) { requirementMet = false; button.title = `Requires: ${option.requireItem}`; button.disabled = true; }
if (requirementMet) { const choiceData = { nextPage: option.next, addItem: option.addItem, check: option.check, effect: option.effect }; button.onclick = () => handleChoiceClick(choiceData); } else { 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 End"; button.onclick = () => handleChoiceClick({ nextPage: pageData.gameOver ? 1 : 99 }); choicesElement.appendChild(button); if (!pageData.gameOver) choicesElement.insertAdjacentHTML('afterbegin', '<p><i>The path ends here.</i></p>'); }
updateScene(pageData.illustration || 'default', pageData.sceneParams);
}
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 title="Strength">Str: ${char.stats.strength}</span> <span title="Intelligence">Int: ${char.stats.intelligence}</span> <span title="Wisdom">Wis: ${char.stats.wisdom}</span> <span title="Dexterity">Dex: ${char.stats.dexterity}</span> <span title="Constitution">Con: ${char.stats.constitution}</span> <span title="Charisma">Cha: ${char.stats.charisma}</span>`; } // Added titles
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, sceneParams = {}) {
if (currentAssemblyGroup) { scene.remove(currentAssemblyGroup); }
currentAssemblyGroup = null; let assemblyFunction;
// Simple routing for now, using default for most new keys
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': case 'dark-forest-entrance-gnarled-roots-filtered-light': case 'overgrown-forest-path-glowing-fungi-vines': case 'forest-clearing-mossy-statue-weathered-stone': case 'narrow-game-trail-forest-rope-bridge-ravine': case 'forest-stream-crossing-dappled-sunlight-stones': case 'mossy-log-bridge-over-forest-stream': case 'dense-forest-undergrowth-shadows': assemblyFunction = createForestAssembly; break; // Group forest keys
case 'road-ambush': case 'two-goblins-ambush-forest-path-spears': assemblyFunction = createRoadAmbushAssembly; break; // Group ambush keys
case 'forest-edge': case 'forest-edge-view-rocky-foothills-distant-mountain-fortress': assemblyFunction = createForestEdgeAssembly; break; // Group edge keys
case 'prisoner-cell': assemblyFunction = createPrisonerCellAssembly; break;
case 'game-over': case 'game-over-generic': assemblyFunction = createGameOverAssembly; break;
case 'error': assemblyFunction = createErrorAssembly; break;
// Add more specific assignments if needed
default: assemblyFunction = createDefaultAssembly; break;
}
try { currentAssemblyGroup = assemblyFunction(sceneParams); scene.add(currentAssemblyGroup); } catch (error) { console.error(`Error creating assembly for ${illustrationKey}:`, error); currentAssemblyGroup = createErrorAssembly(); scene.add(currentAssemblyGroup); }
}
document.addEventListener('DOMContentLoaded', () => {
try { initThreeJS(); startGame(); } catch (error) { console.error("Init failed:", error); storyTitleElement.textContent = "Error"; storyContentElement.innerHTML = `<p>Init Error. Check console.</p><pre>${error}</pre>`; }
});
</script>
</body>
</html> |