Spaces:
Running
Running
Update index.html
Browse files- index.html +360 -437
index.html
CHANGED
@@ -3,84 +3,21 @@
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
-
<title>Procedural
|
7 |
<style>
|
8 |
-
body {
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
margin: 0;
|
13 |
-
padding: 0;
|
14 |
-
overflow: hidden;
|
15 |
-
display: flex;
|
16 |
-
flex-direction: column;
|
17 |
-
height: 100vh;
|
18 |
-
}
|
19 |
-
#game-container {
|
20 |
-
display: flex;
|
21 |
-
flex-grow: 1;
|
22 |
-
overflow: hidden;
|
23 |
-
}
|
24 |
-
#scene-container {
|
25 |
-
flex-grow: 3;
|
26 |
-
position: relative;
|
27 |
-
border-right: 2px solid #444;
|
28 |
-
min-width: 250px;
|
29 |
-
background-color: #000;
|
30 |
-
height: 100%;
|
31 |
-
box-sizing: border-box;
|
32 |
-
overflow: hidden;
|
33 |
-
}
|
34 |
-
#ui-container {
|
35 |
-
flex-grow: 2;
|
36 |
-
padding: 25px;
|
37 |
-
overflow-y: auto;
|
38 |
-
background-color: #2b2b2b;
|
39 |
-
min-width: 320px;
|
40 |
-
height: 100%;
|
41 |
-
box-sizing: border-box;
|
42 |
-
display: flex;
|
43 |
-
flex-direction: column;
|
44 |
-
}
|
45 |
#scene-container canvas { display: block; }
|
46 |
-
#story-title {
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
}
|
54 |
-
#story-content {
|
55 |
-
margin-bottom: 25px;
|
56 |
-
line-height: 1.7;
|
57 |
-
flex-grow: 1;
|
58 |
-
font-size: 1.1em;
|
59 |
-
}
|
60 |
-
#story-content p { margin-bottom: 1.1em; }
|
61 |
-
#story-content p:last-child { margin-bottom: 0; }
|
62 |
-
#stats-inventory-container {
|
63 |
-
margin-bottom: 25px;
|
64 |
-
padding: 15px;
|
65 |
-
border: 1px solid #444;
|
66 |
-
border-radius: 4px;
|
67 |
-
background-color: #333;
|
68 |
-
font-size: 0.95em;
|
69 |
-
}
|
70 |
-
#stats-display, #inventory-display {
|
71 |
-
margin-bottom: 10px;
|
72 |
-
line-height: 1.8;
|
73 |
-
}
|
74 |
-
#stats-display span, #inventory-display span {
|
75 |
-
display: inline-block;
|
76 |
-
background-color: #484848;
|
77 |
-
padding: 3px 9px;
|
78 |
-
border-radius: 15px;
|
79 |
-
margin: 0 8px 5px 0;
|
80 |
-
border: 1px solid #6a6a6a;
|
81 |
-
white-space: nowrap;
|
82 |
-
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3);
|
83 |
-
}
|
84 |
#stats-display strong, #inventory-display strong { color: #ccc; margin-right: 6px; }
|
85 |
#inventory-display em { color: #888; font-style: normal; }
|
86 |
.item-quest { background-color: #666030; border-color: #999048;}
|
@@ -88,50 +25,29 @@
|
|
88 |
.item-armor { background-color: #306630; border-color: #489948;}
|
89 |
.item-consumable { background-color: #664430; border-color: #996648;}
|
90 |
.item-unknown { background-color: #555; border-color: #777;}
|
91 |
-
|
92 |
-
#choices-container {
|
93 |
-
margin-top: auto;
|
94 |
-
padding-top: 20px;
|
95 |
-
border-top: 1px solid #555;
|
96 |
-
}
|
97 |
#choices-container h3 { margin-top: 0; margin-bottom: 12px; color: #ccc; font-size: 1.1em; }
|
98 |
#choices { display: flex; flex-direction: column; gap: 12px; }
|
99 |
-
.choice-button {
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
border-radius: 4px; cursor: pointer; text-align: left;
|
104 |
-
font-family: 'Courier New', monospace; font-size: 1.05em;
|
105 |
-
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.1s;
|
106 |
-
box-sizing: border-box;
|
107 |
-
}
|
108 |
-
.choice-button:hover:not(:disabled) {
|
109 |
-
background-color: #e0b050; color: #111; border-color: #c89040;
|
110 |
-
box-shadow: 0 0 5px rgba(255, 200, 100, 0.5);
|
111 |
-
}
|
112 |
-
.choice-button:disabled {
|
113 |
-
background-color: #404040; color: #777; cursor: not-allowed; border-color: #555;
|
114 |
-
opacity: 0.7;
|
115 |
-
}
|
116 |
-
.choice-button[title]:disabled::after {
|
117 |
-
content: ' (' attr(title) ')'; font-style: italic; color: #999; font-size: 0.9em; margin-left: 5px;
|
118 |
-
}
|
119 |
-
.message {
|
120 |
-
padding: 8px 12px; margin-bottom: 1em; border-left-width: 3px; border-left-style: solid;
|
121 |
-
font-size: 0.95em; background-color: rgba(255, 255, 255, 0.05);
|
122 |
-
}
|
123 |
.message-success { color: #8f8; border-left-color: #4a4; }
|
124 |
.message-failure { color: #f88; border-left-color: #a44; }
|
125 |
.message-info { color: #aaa; border-left-color: #666; }
|
126 |
.message-item { color: #8bf; border-left-color: #46a; }
|
|
|
|
|
127 |
</style>
|
128 |
</head>
|
129 |
<body>
|
130 |
<div id="game-container">
|
131 |
-
<div id="scene-container"
|
|
|
|
|
132 |
<div id="ui-container">
|
133 |
-
<h2 id="story-title">
|
134 |
-
<div id="story-content"><p>
|
135 |
<div id="stats-inventory-container">
|
136 |
<div id="stats-display"></div>
|
137 |
<div id="inventory-display"></div>
|
@@ -152,6 +68,7 @@
|
|
152 |
|
153 |
<script type="module">
|
154 |
import * as THREE from 'three';
|
|
|
155 |
|
156 |
const sceneContainer = document.getElementById('scene-container');
|
157 |
const storyTitleElement = document.getElementById('story-title');
|
@@ -159,10 +76,14 @@
|
|
159 |
const choicesElement = document.getElementById('choices');
|
160 |
const statsElement = document.getElementById('stats-display');
|
161 |
const inventoryElement = document.getElementById('inventory-display');
|
|
|
|
|
162 |
|
163 |
-
let scene, camera, renderer, clock;
|
164 |
-
let
|
165 |
-
let
|
|
|
|
|
166 |
|
167 |
const MAT = {
|
168 |
stone: new THREE.MeshStandardMaterial({ color: 0x777788, roughness: 0.85, metalness: 0.1 }),
|
@@ -176,18 +97,23 @@
|
|
176 |
error: new THREE.MeshStandardMaterial({ color: 0xff3300, roughness: 0.5, emissive: 0x551100 }),
|
177 |
gameOver: new THREE.MeshStandardMaterial({ color: 0xaa0000, roughness: 0.6, metalness: 0.2, emissive: 0x220000 }),
|
178 |
simple: new THREE.MeshStandardMaterial({ color: 0xaaaaaa, roughness: 0.8 }),
|
|
|
|
|
179 |
};
|
180 |
|
181 |
function initThreeJS() {
|
182 |
scene = new THREE.Scene();
|
183 |
scene.background = new THREE.Color(0x1a1a1a);
|
184 |
clock = new THREE.Clock();
|
|
|
|
|
|
|
|
|
185 |
|
186 |
const width = sceneContainer.clientWidth || 1;
|
187 |
const height = sceneContainer.clientHeight || 1;
|
188 |
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
|
189 |
-
camera.position.set(0,
|
190 |
-
camera.lookAt(0, 0.5, 0);
|
191 |
|
192 |
renderer = new THREE.WebGLRenderer({ antialias: true });
|
193 |
renderer.setSize(width, height);
|
@@ -197,7 +123,15 @@
|
|
197 |
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
198 |
sceneContainer.appendChild(renderer.domElement);
|
199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
window.addEventListener('resize', onWindowResize, false);
|
|
|
|
|
201 |
setTimeout(onWindowResize, 50);
|
202 |
animate();
|
203 |
}
|
@@ -211,92 +145,112 @@
|
|
211 |
renderer.setSize(width, height);
|
212 |
}
|
213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
214 |
function animate() {
|
215 |
requestAnimationFrame(animate);
|
216 |
const delta = clock.getDelta();
|
217 |
const time = clock.getElapsedTime();
|
218 |
-
|
|
|
|
|
|
|
219 |
if (renderer && scene && camera) renderer.render(scene, camera);
|
220 |
}
|
221 |
|
222 |
function createMesh(geometry, material, pos = {x:0,y:0,z:0}, rot = {x:0,y:0,z:0}, scale = {x:1,y:1,z:1}) {
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
|
231 |
function createGround(material = MAT.ground, size = 20) {
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
|
|
237 |
}
|
238 |
|
239 |
function setupLighting(type = 'default') {
|
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 |
const group = new THREE.Group();
|
285 |
-
group.add(createGround(MAT.dirt,
|
286 |
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
|
287 |
-
|
288 |
-
|
|
|
|
|
|
|
289 |
}
|
290 |
|
291 |
-
function
|
292 |
const group = new THREE.Group();
|
293 |
-
group.add(createGround(MAT.ground,
|
294 |
const trunkGeo = new THREE.CylinderGeometry(0.2, 0.3, 4, 8);
|
295 |
const leafGeo = new THREE.SphereGeometry(1.5, 8, 6);
|
296 |
-
for(let i=0; i<
|
297 |
-
const x = (Math.random() - 0.5) *
|
298 |
-
const z = (Math.random() - 0.5) *
|
299 |
-
if(Math.sqrt(x*x+z*z) <
|
300 |
const tree = new THREE.Group();
|
301 |
const trunk = createMesh(trunkGeo, MAT.wood, {y: 2});
|
302 |
const leaves = createMesh(leafGeo, MAT.leaf, {y: 4.5});
|
@@ -305,305 +259,169 @@
|
|
305 |
tree.rotation.y = Math.random() * Math.PI * 2;
|
306 |
group.add(tree);
|
307 |
}
|
308 |
-
|
|
|
|
|
|
|
|
|
|
|
309 |
}
|
310 |
|
311 |
-
|
312 |
const group = new THREE.Group();
|
313 |
-
group.add(createGround(MAT.stone.clone().set({color:
|
314 |
-
|
315 |
-
|
316 |
-
return { group, lighting: 'gameover' };
|
317 |
-
}
|
318 |
-
|
319 |
-
function createCaveScene() {
|
320 |
-
const group = new THREE.Group();
|
321 |
-
group.add(createGround(MAT.stone, 15));
|
322 |
-
const wallGeo = new THREE.SphereGeometry(10, 32, 16, 0, Math.PI*2, 0, Math.PI*0.7);
|
323 |
-
const walls = createMesh(wallGeo, MAT.stone, {y: 3});
|
324 |
walls.material.side = THREE.BackSide;
|
325 |
group.add(walls);
|
326 |
const coneGeo = new THREE.ConeGeometry(0.2, 1.0, 8);
|
327 |
-
for(let i=0; i<
|
328 |
-
const st = createMesh(coneGeo, MAT.stone, {x: (Math.random()-0.5)*
|
329 |
group.add(st);
|
330 |
}
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
let sceneData;
|
345 |
-
switch (illustrationKey) {
|
346 |
-
case 'forest': case 'overgrown-path': case 'goblin-ambush':
|
347 |
-
sceneData = createForestScene(); break;
|
348 |
-
case 'game-over':
|
349 |
-
sceneData = createGameOverScene(); break;
|
350 |
-
case 'cave':
|
351 |
-
sceneData = createCaveScene(); break;
|
352 |
-
default:
|
353 |
-
sceneData = createDefaultScene(); break;
|
354 |
-
}
|
355 |
-
|
356 |
-
currentSceneGroup = sceneData.group;
|
357 |
-
scene.add(currentSceneGroup);
|
358 |
-
setupLighting(sceneData.lighting);
|
359 |
-
}
|
360 |
-
|
361 |
-
const itemsData = {
|
362 |
-
"Rusty Sword": {type:"weapon", description:"Old but sharp."},
|
363 |
-
"Torch": {type:"consumable", description:"Provides light.", use: "light"},
|
364 |
-
"Key": {type:"quest", description:"A simple iron key."}
|
365 |
};
|
366 |
|
367 |
-
const
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
options: [
|
372 |
-
{ text: "Enter the Forest", next: 2 },
|
373 |
-
{ text: "Head towards the Hills", next: 3 }
|
374 |
-
],
|
375 |
-
illustration: "forest"
|
376 |
-
},
|
377 |
-
2: {
|
378 |
-
title: "Deep Forest",
|
379 |
-
content: "<p>The trees loom overhead, blocking most light. It's eerily quiet. You spot a faint glimmer ahead.</p>",
|
380 |
-
options: [
|
381 |
-
{ text: "Investigate the glimmer", next: 4 },
|
382 |
-
{ text: "Turn back to the path", next: 1 }
|
383 |
-
],
|
384 |
-
illustration: "forest"
|
385 |
-
},
|
386 |
-
3: {
|
387 |
-
title: "Rolling Hills",
|
388 |
-
content: "<p>The hills are gentle under an open sky. You see a small cave entrance in the side of one hill.</p>",
|
389 |
-
options: [
|
390 |
-
{ text: "Explore the cave", next: 5 },
|
391 |
-
{ text: "Continue over the hills", next: 99 },
|
392 |
-
{ text: "Go back to the path", next: 1 }
|
393 |
-
],
|
394 |
-
illustration: "default"
|
395 |
-
},
|
396 |
-
4: {
|
397 |
-
title: "Forest Glimmer",
|
398 |
-
content: "<p>The glimmer comes from a Rusty Sword half-buried in the leaves.</p>",
|
399 |
-
options: [ { text: "Take the sword", next: 1, reward: {addItem: "Rusty Sword"} } ],
|
400 |
-
illustration: "forest"
|
401 |
-
},
|
402 |
-
5: {
|
403 |
-
title: "Small Cave",
|
404 |
-
content: "<p>The cave is dark and damp. You can barely see. Something skitters in the darkness.</p>",
|
405 |
-
options: [
|
406 |
-
{ text: "Light Torch (if you have one)", requireItem: "Torch", consumeItem: true, next: 6},
|
407 |
-
{ text: "Try to fight in the dark (Dexterity Check DC 13)", check: {stat: "dexterity", dc: 13, onFailure: 7}, next: 8},
|
408 |
-
{ text: "Flee the cave", next: 3 }
|
409 |
-
],
|
410 |
-
illustration: "cave"
|
411 |
},
|
412 |
-
|
413 |
-
title: "
|
414 |
-
|
415 |
-
options: [ { text: "Fight!", next: 8 } ],
|
416 |
-
illustration: "cave"
|
417 |
},
|
418 |
-
|
419 |
-
title: "Cave
|
420 |
-
|
421 |
-
|
422 |
-
illustration: "cave"
|
423 |
-
},
|
424 |
-
8: {
|
425 |
-
title: "Cave - Victory",
|
426 |
-
content: "<p>You manage to defeat the creatures! You find an old Key.</p>",
|
427 |
-
options: [ { text: "Leave the cave", next: 3, reward: {addItem: "Key", xp: 50} } ],
|
428 |
-
illustration: "cave"
|
429 |
-
},
|
430 |
-
99: {
|
431 |
-
title: "The End?",
|
432 |
-
content: "<p>Your journey ends here... for now.</p>",
|
433 |
-
options: [ { text: "Restart", next: 1 } ],
|
434 |
-
illustration: "game-over",
|
435 |
-
gameOver: true
|
436 |
-
}
|
437 |
};
|
438 |
|
439 |
let gameState = {
|
440 |
-
|
441 |
character: {
|
442 |
-
name: "
|
443 |
-
stats: {
|
444 |
inventory: []
|
445 |
}
|
446 |
};
|
447 |
|
448 |
function startGame() {
|
449 |
const defaultChar = {
|
450 |
-
name: "
|
451 |
-
stats: {
|
452 |
inventory: []
|
453 |
};
|
454 |
gameState = {
|
455 |
-
|
456 |
character: JSON.parse(JSON.stringify(defaultChar))
|
457 |
};
|
458 |
console.log("Starting new game:", gameState);
|
459 |
-
|
460 |
}
|
461 |
|
462 |
-
function
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
if (
|
468 |
-
|
469 |
-
gameState.character.inventory = gameState.character.inventory.filter(i => i !== choiceData.requireItem);
|
470 |
-
consumedItemName = choiceData.requireItem;
|
471 |
-
messageLog += `<p class="message message-item"><em>Used: ${consumedItemName}</em></p>`;
|
472 |
-
} else {
|
473 |
-
console.error("Tried to consume unavailable item:", choiceData.requireItem);
|
474 |
-
return;
|
475 |
-
}
|
476 |
}
|
477 |
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
491 |
-
|
492 |
-
|
493 |
-
|
494 |
-
|
495 |
-
|
496 |
-
|
497 |
-
|
498 |
-
|
499 |
-
|
500 |
-
|
501 |
-
|
|
|
|
|
502 |
|
503 |
-
|
504 |
-
|
505 |
-
console.error(`Page data missing for ID: ${nextPageId}`);
|
506 |
-
renderPageInternal(99, gameData[99], messageLog + `<p class="message message-failure">Error: Page data missing!</p>`);
|
507 |
-
return;
|
508 |
-
}
|
509 |
|
510 |
-
|
511 |
-
|
512 |
-
|
513 |
-
messageLog += `<p class="message message-failure"><em>Lost ${targetPageData.hpLoss} HP.</em></p>`;
|
514 |
-
}
|
515 |
-
if (targetPageData.reward?.hpGain) {
|
516 |
-
hpChange = targetPageData.reward.hpGain;
|
517 |
-
messageLog += `<p class="message message-success"><em>Recovered ${targetPageData.reward.hpGain} HP.</em></p>`;
|
518 |
-
}
|
519 |
-
if(hpChange !== 0) {
|
520 |
-
gameState.character.stats.hp += hpChange;
|
521 |
-
gameState.character.stats.hp = Math.max(0, Math.min(gameState.character.stats.hp, gameState.character.stats.maxHp));
|
522 |
-
}
|
523 |
|
|
|
|
|
524 |
|
525 |
-
if (targetPageData.reward?.xp) {
|
526 |
-
gameState.character.stats.xp += targetPageData.reward.xp;
|
527 |
-
messageLog += `<p class="message message-info"><em>Gained ${targetPageData.reward.xp} XP.</em></p>`;
|
528 |
-
}
|
529 |
-
if (targetPageData.reward?.addItem) {
|
530 |
-
const item = targetPageData.reward.addItem;
|
531 |
-
if (itemsData[item] && !gameState.character.inventory.includes(item)) {
|
532 |
-
gameState.character.inventory.push(item);
|
533 |
-
messageLog += `<p class="message message-item"><em>Acquired: ${item}</em></p>`;
|
534 |
-
}
|
535 |
-
}
|
536 |
|
537 |
-
|
538 |
-
|
539 |
-
|
540 |
-
|
541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
542 |
return;
|
543 |
}
|
544 |
|
545 |
-
|
546 |
-
|
547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
548 |
|
549 |
-
|
550 |
-
|
551 |
-
|
552 |
-
|
553 |
-
message += `<p class="message message-failure">Render Error: Page ${pageId} not found!</p>`;
|
554 |
-
pageId = 99;
|
555 |
-
}
|
556 |
|
557 |
-
|
558 |
-
|
559 |
-
|
560 |
-
updateStatsDisplay();
|
561 |
-
updateInventoryDisplay();
|
562 |
-
choicesElement.innerHTML = '';
|
563 |
-
|
564 |
-
if (pageData.options && pageData.options.length > 0) {
|
565 |
-
pageData.options.forEach(option => {
|
566 |
-
const button = document.createElement('button');
|
567 |
-
button.classList.add('choice-button');
|
568 |
-
button.textContent = option.text;
|
569 |
-
let requirementMet = true;
|
570 |
-
let reqText = [];
|
571 |
-
|
572 |
-
if (option.requireItem && !gameState.character.inventory.includes(option.requireItem)) {
|
573 |
-
requirementMet = false;
|
574 |
-
reqText.push(`Requires: ${option.requireItem}`);
|
575 |
-
}
|
576 |
-
|
577 |
-
button.disabled = !requirementMet;
|
578 |
-
if (!requirementMet) {
|
579 |
-
button.title = reqText.join(', ');
|
580 |
-
} else {
|
581 |
-
const choiceData = {
|
582 |
-
nextPage: option.next,
|
583 |
-
check: option.check,
|
584 |
-
onFailure: option.onFailure,
|
585 |
-
reward: option.reward,
|
586 |
-
hpLoss: option.hpLoss,
|
587 |
-
requireItem: option.requireItem,
|
588 |
-
consumeItem: option.consumeItem,
|
589 |
-
};
|
590 |
-
button.onclick = () => handleChoiceClick(choiceData);
|
591 |
-
}
|
592 |
-
choicesElement.appendChild(button);
|
593 |
-
});
|
594 |
} else {
|
595 |
-
|
596 |
-
button.classList.add('choice-button');
|
597 |
-
button.textContent = "Restart";
|
598 |
-
button.onclick = () => handleChoiceClick({ nextPage: 1 });
|
599 |
-
choicesElement.appendChild(button);
|
600 |
}
|
601 |
-
updateScene(pageData.illustration);
|
602 |
-
}
|
603 |
-
|
604 |
-
function renderPage(pageId) {
|
605 |
-
renderPageInternal(pageId, gameData[pageId]);
|
606 |
}
|
|
|
607 |
|
608 |
function updateStatsDisplay() {
|
609 |
const { hp, maxHp, xp } = gameState.character.stats;
|
@@ -619,28 +437,133 @@
|
|
619 |
gameState.character.inventory.forEach(item => {
|
620 |
const itemDef = itemsData[item] || { type: 'unknown', description: '???' };
|
621 |
const itemClass = `item-${itemDef.type || 'unknown'}`;
|
622 |
-
|
|
|
623 |
});
|
624 |
}
|
625 |
inventoryElement.innerHTML = invHtml;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
626 |
}
|
627 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
628 |
|
629 |
document.addEventListener('DOMContentLoaded', () => {
|
630 |
-
console.log("DOM Ready - Initializing Adventure
|
631 |
try {
|
632 |
initThreeJS();
|
633 |
if (!scene || !camera || !renderer) throw new Error("Three.js failed to initialize.");
|
634 |
startGame();
|
635 |
-
console.log("Game started
|
636 |
} catch (error) {
|
637 |
console.error("Initialization failed:", error);
|
638 |
storyTitleElement.textContent = "Initialization Error";
|
639 |
storyContentElement.innerHTML = `<p style="color:red;">Failed to start game:</p><pre style="color:red; white-space: pre-wrap;">${error.stack || error}</pre>`;
|
640 |
-
choicesElement.innerHTML = '';
|
641 |
if(sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding: 20px;">3D Scene Failed</p>';
|
642 |
-
|
643 |
-
|
644 |
}
|
645 |
});
|
646 |
|
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Persistent Procedural World</title>
|
7 |
<style>
|
8 |
+
body { font-family: 'Courier New', monospace; background-color: #111; color: #eee; margin: 0; padding: 0; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
|
9 |
+
#game-container { display: flex; flex-grow: 1; overflow: hidden; }
|
10 |
+
#scene-container { flex-grow: 3; position: relative; border-right: 2px solid #444; min-width: 250px; background-color: #000; height: 100%; box-sizing: border-box; overflow: hidden; cursor: crosshair; }
|
11 |
+
#ui-container { flex-grow: 2; padding: 25px; overflow-y: auto; background-color: #2b2b2b; min-width: 320px; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
#scene-container canvas { display: block; }
|
13 |
+
#story-title { color: #f0c060; margin: 0 0 15px 0; padding-bottom: 10px; border-bottom: 1px solid #555; font-size: 1.6em; text-shadow: 1px 1px 1px #000; }
|
14 |
+
#story-content { margin-bottom: 25px; line-height: 1.7; flex-grow: 1; font-size: 1.1em; }
|
15 |
+
#stats-inventory-container { margin-bottom: 25px; padding: 15px; border: 1px solid #444; border-radius: 4px; background-color: #333; font-size: 0.95em; }
|
16 |
+
#stats-display, #inventory-display { margin-bottom: 10px; line-height: 1.8; }
|
17 |
+
#stats-display span, #inventory-display .item-tag { display: inline-block; background-color: #484848; padding: 3px 9px; border-radius: 15px; margin: 0 8px 5px 0; border: 1px solid #6a6a6a; white-space: nowrap; box-shadow: inset 0 1px 2px rgba(0,0,0,0.3); }
|
18 |
+
#inventory-display .item-tag { cursor: pointer; transition: background-color 0.2s; }
|
19 |
+
#inventory-display .item-tag:hover { background-color: #6a6a6a; }
|
20 |
+
#inventory-display .item-tag.placing { background-color: #a07030; border-color: #c89040; color: #fff; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
#stats-display strong, #inventory-display strong { color: #ccc; margin-right: 6px; }
|
22 |
#inventory-display em { color: #888; font-style: normal; }
|
23 |
.item-quest { background-color: #666030; border-color: #999048;}
|
|
|
25 |
.item-armor { background-color: #306630; border-color: #489948;}
|
26 |
.item-consumable { background-color: #664430; border-color: #996648;}
|
27 |
.item-unknown { background-color: #555; border-color: #777;}
|
28 |
+
#choices-container { margin-top: auto; padding-top: 20px; border-top: 1px solid #555; }
|
|
|
|
|
|
|
|
|
|
|
29 |
#choices-container h3 { margin-top: 0; margin-bottom: 12px; color: #ccc; font-size: 1.1em; }
|
30 |
#choices { display: flex; flex-direction: column; gap: 12px; }
|
31 |
+
.choice-button { display: block; width: 100%; padding: 12px 15px; margin-bottom: 0; background-color: #555; color: #eee; border: 1px solid #777; border-radius: 4px; cursor: pointer; text-align: left; font-family: 'Courier New', monospace; font-size: 1.05em; transition: background-color 0.2s, border-color 0.2s, box-shadow 0.1s; box-sizing: border-box; }
|
32 |
+
.choice-button:hover:not(:disabled) { background-color: #e0b050; color: #111; border-color: #c89040; box-shadow: 0 0 5px rgba(255, 200, 100, 0.5); }
|
33 |
+
.choice-button:disabled { background-color: #404040; color: #777; cursor: not-allowed; border-color: #555; opacity: 0.7; }
|
34 |
+
.message { padding: 8px 12px; margin-bottom: 1em; border-left-width: 3px; border-left-style: solid; font-size: 0.95em; background-color: rgba(255, 255, 255, 0.05); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
.message-success { color: #8f8; border-left-color: #4a4; }
|
36 |
.message-failure { color: #f88; border-left-color: #a44; }
|
37 |
.message-info { color: #aaa; border-left-color: #666; }
|
38 |
.message-item { color: #8bf; border-left-color: #46a; }
|
39 |
+
#action-info { position: absolute; bottom: 10px; left: 10px; background-color: rgba(0,0,0,0.7); color: #ffcc66; padding: 5px 10px; border-radius: 3px; font-size: 0.9em; display: none; }
|
40 |
+
|
41 |
</style>
|
42 |
</head>
|
43 |
<body>
|
44 |
<div id="game-container">
|
45 |
+
<div id="scene-container">
|
46 |
+
<div id="action-info">Mode: Explore</div>
|
47 |
+
</div>
|
48 |
<div id="ui-container">
|
49 |
+
<h2 id="story-title">World Initializing...</h2>
|
50 |
+
<div id="story-content"><p>Establishing reality...</p></div>
|
51 |
<div id="stats-inventory-container">
|
52 |
<div id="stats-display"></div>
|
53 |
<div id="inventory-display"></div>
|
|
|
68 |
|
69 |
<script type="module">
|
70 |
import * as THREE from 'three';
|
71 |
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Using controls for navigation now
|
72 |
|
73 |
const sceneContainer = document.getElementById('scene-container');
|
74 |
const storyTitleElement = document.getElementById('story-title');
|
|
|
76 |
const choicesElement = document.getElementById('choices');
|
77 |
const statsElement = document.getElementById('stats-display');
|
78 |
const inventoryElement = document.getElementById('inventory-display');
|
79 |
+
const actionInfoElement = document.getElementById('action-info');
|
80 |
+
|
81 |
|
82 |
+
let scene, camera, renderer, clock, controls, raycaster, mouse;
|
83 |
+
let worldGroup = null; // Parent for all world objects
|
84 |
+
let locationGroups = {}; // Store groups for each location/zone { locationId: group }
|
85 |
+
let currentMessage = ""; // To accumulate UI messages
|
86 |
+
let currentPlacingItem = null; // Item name being placed
|
87 |
|
88 |
const MAT = {
|
89 |
stone: new THREE.MeshStandardMaterial({ color: 0x777788, roughness: 0.85, metalness: 0.1 }),
|
|
|
97 |
error: new THREE.MeshStandardMaterial({ color: 0xff3300, roughness: 0.5, emissive: 0x551100 }),
|
98 |
gameOver: new THREE.MeshStandardMaterial({ color: 0xaa0000, roughness: 0.6, metalness: 0.2, emissive: 0x220000 }),
|
99 |
simple: new THREE.MeshStandardMaterial({ color: 0xaaaaaa, roughness: 0.8 }),
|
100 |
+
pickupHighlight: new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true, depthTest: false }),
|
101 |
+
placementPreview: new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.5, wireframe: true }),
|
102 |
};
|
103 |
|
104 |
function initThreeJS() {
|
105 |
scene = new THREE.Scene();
|
106 |
scene.background = new THREE.Color(0x1a1a1a);
|
107 |
clock = new THREE.Clock();
|
108 |
+
raycaster = new THREE.Raycaster();
|
109 |
+
mouse = new THREE.Vector2();
|
110 |
+
worldGroup = new THREE.Group();
|
111 |
+
scene.add(worldGroup);
|
112 |
|
113 |
const width = sceneContainer.clientWidth || 1;
|
114 |
const height = sceneContainer.clientHeight || 1;
|
115 |
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
|
116 |
+
camera.position.set(0, 5, 10);
|
|
|
117 |
|
118 |
renderer = new THREE.WebGLRenderer({ antialias: true });
|
119 |
renderer.setSize(width, height);
|
|
|
123 |
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
124 |
sceneContainer.appendChild(renderer.domElement);
|
125 |
|
126 |
+
controls = new OrbitControls(camera, renderer.domElement);
|
127 |
+
controls.enableDamping = true;
|
128 |
+
controls.dampingFactor = 0.1;
|
129 |
+
controls.target.set(0, 1, 0);
|
130 |
+
controls.maxPolarAngle = Math.PI / 2 - 0.05; // Prevent looking below ground
|
131 |
+
|
132 |
window.addEventListener('resize', onWindowResize, false);
|
133 |
+
renderer.domElement.addEventListener('mousemove', onMouseMove, false);
|
134 |
+
renderer.domElement.addEventListener('click', onMouseClick, false);
|
135 |
setTimeout(onWindowResize, 50);
|
136 |
animate();
|
137 |
}
|
|
|
145 |
renderer.setSize(width, height);
|
146 |
}
|
147 |
|
148 |
+
function onMouseMove( event ) {
|
149 |
+
mouse.x = ( event.clientX / renderer.domElement.clientWidth ) * 2 - 1;
|
150 |
+
mouse.y = - ( event.clientY / renderer.domElement.clientHeight ) * 2 + 1;
|
151 |
+
}
|
152 |
+
|
153 |
+
function onMouseClick( event ) {
|
154 |
+
if (currentPlacingItem) {
|
155 |
+
placeItem();
|
156 |
+
} else {
|
157 |
+
pickupItem();
|
158 |
+
}
|
159 |
+
}
|
160 |
+
|
161 |
function animate() {
|
162 |
requestAnimationFrame(animate);
|
163 |
const delta = clock.getDelta();
|
164 |
const time = clock.getElapsedTime();
|
165 |
+
|
166 |
+
controls.update();
|
167 |
+
worldGroup.traverse(obj => { if (obj.userData.update) obj.userData.update(time, delta); });
|
168 |
+
|
169 |
if (renderer && scene && camera) renderer.render(scene, camera);
|
170 |
}
|
171 |
|
172 |
function createMesh(geometry, material, pos = {x:0,y:0,z:0}, rot = {x:0,y:0,z:0}, scale = {x:1,y:1,z:1}) {
|
173 |
+
const mesh = new THREE.Mesh(geometry, material);
|
174 |
+
mesh.position.set(pos.x, pos.y, pos.z);
|
175 |
+
mesh.rotation.set(rot.x, rot.y, rot.z);
|
176 |
+
mesh.scale.set(scale.x, scale.y, scale.z);
|
177 |
+
mesh.castShadow = true; mesh.receiveShadow = true;
|
178 |
+
return mesh;
|
179 |
+
}
|
180 |
|
181 |
function createGround(material = MAT.ground, size = 20) {
|
182 |
+
const geo = new THREE.PlaneGeometry(size, size);
|
183 |
+
const ground = new THREE.Mesh(geo, material);
|
184 |
+
ground.rotation.x = -Math.PI / 2; ground.position.y = 0;
|
185 |
+
ground.receiveShadow = true; ground.castShadow = false;
|
186 |
+
ground.userData.isGround = true; // Mark for raycasting
|
187 |
+
return ground;
|
188 |
}
|
189 |
|
190 |
function setupLighting(type = 'default') {
|
191 |
+
currentLights.forEach(light => scene.remove(light));
|
192 |
+
currentLights = [];
|
193 |
+
|
194 |
+
let ambientIntensity = 0.4;
|
195 |
+
let dirIntensity = 0.9;
|
196 |
+
let dirColor = 0xffffff;
|
197 |
+
let dirPosition = new THREE.Vector3(10, 15, 8);
|
198 |
+
|
199 |
+
if (type === 'forest') {
|
200 |
+
ambientIntensity = 0.3; dirIntensity = 0.7; dirColor = 0xccffcc; dirPosition = new THREE.Vector3(5, 10, 5);
|
201 |
+
} else if (type === 'cave') {
|
202 |
+
ambientIntensity = 0.1; dirIntensity = 0;
|
203 |
+
const ptLight = new THREE.PointLight(0xffaa55, 1.5, 12, 1);
|
204 |
+
ptLight.position.set(0, 1.5, 1);
|
205 |
+
ptLight.castShadow = true;
|
206 |
+
ptLight.shadow.mapSize.set(512, 512);
|
207 |
+
scene.add(ptLight); currentLights.push(ptLight);
|
208 |
+
} else if (type === 'gameover') {
|
209 |
+
ambientIntensity = 0.1; dirIntensity = 0.4; dirColor = 0xff6666;
|
210 |
+
}
|
211 |
|
212 |
+
const ambientLight = new THREE.AmbientLight(0xffffff, ambientIntensity);
|
213 |
+
scene.add(ambientLight);
|
214 |
+
currentLights.push(ambientLight);
|
215 |
+
|
216 |
+
if (dirIntensity > 0) {
|
217 |
+
const directionalLight = new THREE.DirectionalLight(dirColor, dirIntensity);
|
218 |
+
directionalLight.position.copy(dirPosition);
|
219 |
+
directionalLight.castShadow = true;
|
220 |
+
directionalLight.shadow.mapSize.set(1024, 1024);
|
221 |
+
directionalLight.shadow.camera.near = 0.5;
|
222 |
+
directionalLight.shadow.camera.far = 50;
|
223 |
+
const shadowBounds = 20;
|
224 |
+
directionalLight.shadow.camera.left = -shadowBounds;
|
225 |
+
directionalLight.shadow.camera.right = shadowBounds;
|
226 |
+
directionalLight.shadow.camera.top = shadowBounds;
|
227 |
+
directionalLight.shadow.camera.bottom = -shadowBounds;
|
228 |
+
directionalLight.shadow.bias = -0.0005;
|
229 |
+
scene.add(directionalLight);
|
230 |
+
currentLights.push(directionalLight);
|
231 |
+
}
|
232 |
}
|
233 |
|
234 |
+
function createDefaultZone() {
|
235 |
const group = new THREE.Group();
|
236 |
+
group.add(createGround(MAT.dirt, 20));
|
237 |
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
|
238 |
+
const interactBox = createMesh(boxGeo, MAT.stone, {y: 0.5, x: 2, z: 2});
|
239 |
+
interactBox.userData = { isPickupable: true, itemName: "Mysterious Cube", description: "A plain stone cube."};
|
240 |
+
group.add(interactBox);
|
241 |
+
group.visible = false; // Start hidden
|
242 |
+
return { group, lighting: 'default', entryText: "You are in a default, featureless area.", cameraPos: {x:0, y:5, z:10}, targetPos: {x:0, y:1, z:0} };
|
243 |
}
|
244 |
|
245 |
+
function createForestZone() {
|
246 |
const group = new THREE.Group();
|
247 |
+
group.add(createGround(MAT.ground, 30));
|
248 |
const trunkGeo = new THREE.CylinderGeometry(0.2, 0.3, 4, 8);
|
249 |
const leafGeo = new THREE.SphereGeometry(1.5, 8, 6);
|
250 |
+
for(let i=0; i<20; i++) {
|
251 |
+
const x = (Math.random() - 0.5) * 28;
|
252 |
+
const z = (Math.random() - 0.5) * 28;
|
253 |
+
if(Math.sqrt(x*x+z*z) < 3) continue;
|
254 |
const tree = new THREE.Group();
|
255 |
const trunk = createMesh(trunkGeo, MAT.wood, {y: 2});
|
256 |
const leaves = createMesh(leafGeo, MAT.leaf, {y: 4.5});
|
|
|
259 |
tree.rotation.y = Math.random() * Math.PI * 2;
|
260 |
group.add(tree);
|
261 |
}
|
262 |
+
const swordGeo = new THREE.BoxGeometry(0.1, 1, 0.05);
|
263 |
+
const sword = createMesh(swordGeo, MAT.metal, {x: 3, y: 0.5, z: 4}, {z: Math.PI / 4});
|
264 |
+
sword.userData = { isPickupable: true, itemName: "Rusty Sword", description: "An old sword sticking out of the ground."};
|
265 |
+
group.add(sword);
|
266 |
+
group.visible = false;
|
267 |
+
return { group, lighting: 'forest', entryText: "You enter the dark forest. Sunlight struggles to get through.", cameraPos: {x:-5, y:6, z:12}, targetPos: {x:0, y:1, z:0} };
|
268 |
}
|
269 |
|
270 |
+
function createCaveZone() {
|
271 |
const group = new THREE.Group();
|
272 |
+
group.add(createGround(MAT.stone.clone().set({color: 0x555560}), 18));
|
273 |
+
const wallGeo = new THREE.SphereGeometry(12, 32, 16, 0, Math.PI*2, 0, Math.PI*0.7);
|
274 |
+
const walls = createMesh(wallGeo, MAT.stone, {y: 4});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
275 |
walls.material.side = THREE.BackSide;
|
276 |
group.add(walls);
|
277 |
const coneGeo = new THREE.ConeGeometry(0.2, 1.0, 8);
|
278 |
+
for(let i=0; i<15; i++){
|
279 |
+
const st = createMesh(coneGeo, MAT.stone, {x: (Math.random()-0.5)*16, y: 6 + Math.random()*2, z: (Math.random()-0.5)*16}, {x:Math.PI});
|
280 |
group.add(st);
|
281 |
}
|
282 |
+
const torchGeo = new THREE.CylinderGeometry(0.05, 0.05, 0.6, 6);
|
283 |
+
const torch = createMesh(torchGeo, MAT.wood, {x: -2, y: 0.3, z: 3}, {z: -Math.PI / 6});
|
284 |
+
torch.userData = { isPickupable: true, itemName: "Torch", description: "An unlit torch leans against the wall."};
|
285 |
+
group.add(torch);
|
286 |
+
group.visible = false;
|
287 |
+
return { group, lighting: 'cave', entryText: "It's dark and damp in here.", cameraPos: {x:0, y:4, z:8}, targetPos: {x:0, y:1, z:0} };
|
288 |
+
}
|
289 |
+
|
290 |
+
|
291 |
+
const locationData = {
|
292 |
+
'start': { creator: createDefaultZone },
|
293 |
+
'forest': { creator: createForestZone },
|
294 |
+
'cave': { creator: createCaveZone }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
295 |
};
|
296 |
|
297 |
+
const pageGraph = {
|
298 |
+
'start': {
|
299 |
+
title: "The Crossroads",
|
300 |
+
options: [ { text: "Enter Forest", transitionTo: 'forest' } ]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
301 |
},
|
302 |
+
'forest': {
|
303 |
+
title: "Dark Forest",
|
304 |
+
options: [ { text: "Return to Crossroads", transitionTo: 'start' }, { text: "Explore Deeper (TBC)", transitionTo: 'forest' }]
|
|
|
|
|
305 |
},
|
306 |
+
'cave': {
|
307 |
+
title: "Dim Cave",
|
308 |
+
options: [ { text: "Leave Cave", transitionTo: 'start' } ]
|
309 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
310 |
};
|
311 |
|
312 |
let gameState = {
|
313 |
+
currentLocationId: null,
|
314 |
character: {
|
315 |
+
name: "Player",
|
316 |
+
stats: { hp: 20, maxHp: 20, xp: 0 },
|
317 |
inventory: []
|
318 |
}
|
319 |
};
|
320 |
|
321 |
function startGame() {
|
322 |
const defaultChar = {
|
323 |
+
name: "Player",
|
324 |
+
stats: { hp: 20, maxHp: 20, xp: 0 },
|
325 |
inventory: []
|
326 |
};
|
327 |
gameState = {
|
328 |
+
currentLocationId: null,
|
329 |
character: JSON.parse(JSON.stringify(defaultChar))
|
330 |
};
|
331 |
console.log("Starting new game:", gameState);
|
332 |
+
transitionToLocation('start');
|
333 |
}
|
334 |
|
335 |
+
function transitionToLocation(newLocationId) {
|
336 |
+
console.log(`Transitioning from ${gameState.currentLocationId} to ${newLocationId}`);
|
337 |
+
currentMessage = "";
|
338 |
+
currentPlacingItem = null; // Cancel placement on transition
|
339 |
+
|
340 |
+
if (gameState.currentLocationId && locationGroups[gameState.currentLocationId]) {
|
341 |
+
locationGroups[gameState.currentLocationId].visible = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
342 |
}
|
343 |
|
344 |
+
let newGroup;
|
345 |
+
let locationInfo;
|
346 |
+
if (locationGroups[newLocationId]) {
|
347 |
+
newGroup = locationGroups[newLocationId];
|
348 |
+
newGroup.visible = true;
|
349 |
+
locationInfo = locationData[newLocationId].cachedInfo;
|
350 |
+
} else {
|
351 |
+
if (locationData[newLocationId] && locationData[newLocationId].creator) {
|
352 |
+
locationInfo = locationData[newLocationId].creator();
|
353 |
+
newGroup = locationInfo.group;
|
354 |
+
locationGroups[newLocationId] = newGroup;
|
355 |
+
locationData[newLocationId].cachedInfo = locationInfo;
|
356 |
+
worldGroup.add(newGroup);
|
357 |
+
newGroup.visible = true;
|
358 |
+
} else {
|
359 |
+
console.error(`Location data or creator missing for ID: ${newLocationId}`);
|
360 |
+
locationInfo = locationData['start'].creator();
|
361 |
+
newGroup = locationInfo.group;
|
362 |
+
locationGroups['start'] = newGroup;
|
363 |
+
locationData['start'].cachedInfo = locationInfo;
|
364 |
+
worldGroup.add(newGroup);
|
365 |
+
newGroup.visible = true;
|
366 |
+
newLocationId = 'start';
|
367 |
+
currentMessage += `<p class="message message-failure">Error: Couldn't load target location, returned to start.</p>`;
|
368 |
+
}
|
369 |
+
}
|
370 |
|
371 |
+
gameState.currentLocationId = newLocationId;
|
372 |
+
setupLighting(locationInfo.lighting || 'default');
|
|
|
|
|
|
|
|
|
373 |
|
374 |
+
if (locationInfo.cameraPos) camera.position.set(locationInfo.cameraPos.x, locationInfo.cameraPos.y, locationInfo.cameraPos.z);
|
375 |
+
if (locationInfo.targetPos) controls.target.set(locationInfo.targetPos.x, locationInfo.targetPos.y, locationInfo.targetPos.z);
|
376 |
+
controls.update();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
377 |
|
378 |
+
renderCurrentPageUI();
|
379 |
+
}
|
380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
381 |
|
382 |
+
function renderCurrentPageUI() {
|
383 |
+
const page = pageGraph[gameState.currentLocationId];
|
384 |
+
const locationInfo = locationData[gameState.currentLocationId]?.cachedInfo;
|
385 |
+
|
386 |
+
if (!page) {
|
387 |
+
console.error(`No page graph data for location ${gameState.currentLocationId}`);
|
388 |
+
storyTitleElement.textContent = "Lost";
|
389 |
+
storyContentElement.innerHTML = currentMessage + "<p>You seem to be in an unknown place.</p>";
|
390 |
+
choicesElement.innerHTML = `<button class="choice-button" onclick="handleTransition({transitionTo: 'start'})">Return to Start</button>`;
|
391 |
+
updateStatsDisplay();
|
392 |
+
updateInventoryDisplay();
|
393 |
return;
|
394 |
}
|
395 |
|
396 |
+
storyTitleElement.textContent = page.title;
|
397 |
+
storyContentElement.innerHTML = currentMessage + (locationInfo?.entryText ? `<p>${locationInfo.entryText}</p>` : '');
|
398 |
+
choicesElement.innerHTML = '';
|
399 |
+
|
400 |
+
if (page.options && page.options.length > 0) {
|
401 |
+
page.options.forEach(option => {
|
402 |
+
const button = document.createElement('button');
|
403 |
+
button.classList.add('choice-button');
|
404 |
+
button.textContent = option.text;
|
405 |
+
button.onclick = () => handleTransition(option);
|
406 |
+
choicesElement.appendChild(button);
|
407 |
+
});
|
408 |
+
} else {
|
409 |
+
choicesElement.innerHTML = '<p><i>No transitions available from here yet.</i></p>';
|
410 |
+
}
|
411 |
|
412 |
+
updateStatsDisplay();
|
413 |
+
updateInventoryDisplay();
|
414 |
+
updateActionInfo();
|
415 |
+
}
|
|
|
|
|
|
|
416 |
|
417 |
+
function handleTransition(option) {
|
418 |
+
if (option.transitionTo) {
|
419 |
+
transitionToLocation(option.transitionTo);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
420 |
} else {
|
421 |
+
console.warn("Choice option has no transitionTo property:", option);
|
|
|
|
|
|
|
|
|
422 |
}
|
|
|
|
|
|
|
|
|
|
|
423 |
}
|
424 |
+
window.handleTransition = handleTransition; // Make accessible from inline onclick
|
425 |
|
426 |
function updateStatsDisplay() {
|
427 |
const { hp, maxHp, xp } = gameState.character.stats;
|
|
|
437 |
gameState.character.inventory.forEach(item => {
|
438 |
const itemDef = itemsData[item] || { type: 'unknown', description: '???' };
|
439 |
const itemClass = `item-${itemDef.type || 'unknown'}`;
|
440 |
+
const placingClass = (item === currentPlacingItem) ? ' placing' : '';
|
441 |
+
invHtml += `<span class="item-tag ${itemClass}${placingClass}" title="${itemDef.description}" data-itemname="${item}">${item}</span>`;
|
442 |
});
|
443 |
}
|
444 |
inventoryElement.innerHTML = invHtml;
|
445 |
+
|
446 |
+
inventoryElement.querySelectorAll('.item-tag').forEach(tag => {
|
447 |
+
tag.onclick = () => { togglePlacementMode(tag.dataset.itemname); };
|
448 |
+
});
|
449 |
+
}
|
450 |
+
|
451 |
+
function updateActionInfo() {
|
452 |
+
if(currentPlacingItem) {
|
453 |
+
actionInfoElement.textContent = `Placing: ${currentPlacingItem} (Click ground)`;
|
454 |
+
actionInfoElement.style.display = 'block';
|
455 |
+
} else {
|
456 |
+
actionInfoElement.textContent = `Mode: Explore (Click items)`;
|
457 |
+
actionInfoElement.style.display = 'block'; // Keep it visible
|
458 |
+
}
|
459 |
+
}
|
460 |
+
|
461 |
+
|
462 |
+
function pickupItem() {
|
463 |
+
raycaster.setFromCamera(mouse, camera);
|
464 |
+
const currentGroup = locationGroups[gameState.currentLocationId];
|
465 |
+
if (!currentGroup) return;
|
466 |
+
|
467 |
+
const pickupables = [];
|
468 |
+
currentGroup.traverse(child => {
|
469 |
+
if (child.userData.isPickupable) {
|
470 |
+
pickupables.push(child);
|
471 |
+
}
|
472 |
+
});
|
473 |
+
|
474 |
+
const intersects = raycaster.intersectObjects(pickupables, false);
|
475 |
+
|
476 |
+
if (intersects.length > 0) {
|
477 |
+
const clickedObject = intersects[0].object;
|
478 |
+
const itemName = clickedObject.userData.itemName;
|
479 |
+
|
480 |
+
if (itemName && itemsData[itemName]) {
|
481 |
+
console.log(`Picked up: ${itemName}`);
|
482 |
+
currentMessage = `<p class="message message-item"><em>Picked up: ${itemName}</em></p>`;
|
483 |
+
|
484 |
+
if (!gameState.character.inventory.includes(itemName)) {
|
485 |
+
gameState.character.inventory.push(itemName);
|
486 |
+
} else {
|
487 |
+
currentMessage += `<p class="message message-info"><em>(You already had one)</em></p>`;
|
488 |
+
}
|
489 |
+
|
490 |
+
clickedObject.visible = false; // Hide it instead of removing, simpler state
|
491 |
+
clickedObject.userData.isPickupable = false; // Prevent re-picking
|
492 |
+
|
493 |
+
renderCurrentPageUI();
|
494 |
+
}
|
495 |
+
}
|
496 |
}
|
497 |
|
498 |
+
function togglePlacementMode(itemName) {
|
499 |
+
if (currentPlacingItem === itemName) {
|
500 |
+
currentPlacingItem = null; // Cancel placement
|
501 |
+
console.log("Placement cancelled.");
|
502 |
+
} else {
|
503 |
+
currentPlacingItem = itemName;
|
504 |
+
console.log(`Ready to place: ${itemName}`);
|
505 |
+
}
|
506 |
+
updateInventoryDisplay(); // Update highlighting
|
507 |
+
updateActionInfo();
|
508 |
+
}
|
509 |
+
|
510 |
+
function placeItem() {
|
511 |
+
if (!currentPlacingItem) return;
|
512 |
+
|
513 |
+
raycaster.setFromCamera(mouse, camera);
|
514 |
+
const currentGroup = locationGroups[gameState.currentLocationId];
|
515 |
+
if (!currentGroup) return;
|
516 |
+
|
517 |
+
const grounds = [];
|
518 |
+
currentGroup.traverse(child => { if(child.userData.isGround) grounds.push(child); });
|
519 |
+
|
520 |
+
const intersects = raycaster.intersectObjects(grounds);
|
521 |
+
|
522 |
+
if (intersects.length > 0) {
|
523 |
+
const point = intersects[0].point;
|
524 |
+
const itemName = currentPlacingItem;
|
525 |
+
const itemDef = itemsData[itemName];
|
526 |
+
|
527 |
+
console.log(`Placing ${itemName} at ${point.x.toFixed(1)}, ${point.z.toFixed(1)}`);
|
528 |
+
|
529 |
+
const itemGeo = new THREE.BoxGeometry(0.5, 0.5, 0.5); // Simple representation
|
530 |
+
const itemMat = MAT.simple.clone();
|
531 |
+
if(itemDef.type === 'weapon') itemMat.color.setHex(0xaa4444);
|
532 |
+
else if(itemDef.type === 'consumable') itemMat.color.setHex(0xaa7744);
|
533 |
+
else itemMat.color.setHex(0x8888aa);
|
534 |
+
|
535 |
+
const placedMesh = createMesh(itemGeo, itemMat, {x: point.x, y: 0.25, z: point.z});
|
536 |
+
placedMesh.userData = { isPlacedItem: true, itemName: itemName };
|
537 |
+
currentGroup.add(placedMesh);
|
538 |
+
|
539 |
+
gameState.character.inventory = gameState.character.inventory.filter(i => i !== itemName);
|
540 |
+
currentMessage = `<p class="message message-item"><em>Placed ${itemName}.</em></p>`;
|
541 |
+
currentPlacingItem = null; // Exit placement mode
|
542 |
+
renderCurrentPageUI();
|
543 |
+
|
544 |
+
} else {
|
545 |
+
console.log("Placement click missed ground.");
|
546 |
+
currentMessage = `<p class="message message-failure"><em>Cannot place item there.</em></p>`;
|
547 |
+
currentPlacingItem = null; // Exit placement mode on miss
|
548 |
+
renderCurrentPageUI();
|
549 |
+
}
|
550 |
+
}
|
551 |
+
|
552 |
|
553 |
document.addEventListener('DOMContentLoaded', () => {
|
554 |
+
console.log("DOM Ready - Initializing Persistent World Adventure.");
|
555 |
try {
|
556 |
initThreeJS();
|
557 |
if (!scene || !camera || !renderer) throw new Error("Three.js failed to initialize.");
|
558 |
startGame();
|
559 |
+
console.log("Game world initialized and started.");
|
560 |
} catch (error) {
|
561 |
console.error("Initialization failed:", error);
|
562 |
storyTitleElement.textContent = "Initialization Error";
|
563 |
storyContentElement.innerHTML = `<p style="color:red;">Failed to start game:</p><pre style="color:red; white-space: pre-wrap;">${error.stack || error}</pre>`;
|
|
|
564 |
if(sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding: 20px;">3D Scene Failed</p>';
|
565 |
+
document.getElementById('stats-inventory-container').style.display = 'none';
|
566 |
+
document.getElementById('choices-container').style.display = 'none';
|
567 |
}
|
568 |
});
|
569 |
|