Spaces:
Running
Running
Update index.html
Browse files- index.html +348 -324
index.html
CHANGED
|
@@ -3,38 +3,33 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>Wacky D&D Shapes Adventure
|
| 7 |
<style>
|
| 8 |
-
/* --- Base Styles --- */
|
| 9 |
body { font-family: 'Verdana', sans-serif; background-color: #2a3a4a; color: #f0f0f0; margin: 0; padding: 0; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
|
| 10 |
#game-container { display: flex; flex-grow: 1; overflow: hidden; }
|
| 11 |
#scene-container { flex-grow: 3; position: relative; border-right: 3px solid #506070; min-width: 250px; background-color: #101820; height: 100%; box-sizing: border-box; overflow: hidden; cursor: default; }
|
| 12 |
#ui-container { flex-grow: 2; padding: 25px; overflow-y: auto; background-color: #3a4a5a; min-width: 320px; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; }
|
| 13 |
#scene-container canvas { display: block; }
|
| 14 |
-
|
| 15 |
-
/* --- UI Elements --- */
|
| 16 |
#story-title { color: #ffd700; margin: 0 0 15px 0; padding-bottom: 10px; border-bottom: 2px solid #506070; font-size: 1.8em; font-weight: bold; text-shadow: 1px 1px 2px #111; }
|
| 17 |
#story-content { margin-bottom: 25px; line-height: 1.7; flex-grow: 1; font-size: 1.1em; color: #e8e8e8;}
|
| 18 |
#stats-inventory-container { margin-bottom: 25px; padding: 15px; border: 1px solid #506070; border-radius: 5px; background-color: #4a5a6a; font-size: 0.95em; }
|
| 19 |
#stats-display, #inventory-display { margin-bottom: 10px; line-height: 1.8; }
|
| 20 |
-
#stats-display span { display: inline-block; background-color: #5a6a7a; padding: 4px 10px; border-radius: 15px; margin: 0 8px 5px 0; border: 1px solid #7a8a9a; white-space: nowrap; box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); }
|
| 21 |
-
#inventory-display .item-tag { display: inline-block; background-color: #5a6a7a; padding: 4px 10px; border-radius: 15px; margin: 0 8px 5px 0; border: 1px solid #7a8a9a; white-space: nowrap; box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); cursor: default; }
|
| 22 |
#stats-display strong, #inventory-display strong { color: #e0e0e0; margin-right: 6px; font-weight:bold; }
|
| 23 |
#inventory-display em { color: #aabbcc; font-style: normal; }
|
| 24 |
.item-tool { background-color: #a0522d; border-color: #cd853f; color: #fff;}
|
| 25 |
.item-key { background-color: #ffd700; border-color: #f0e68c; color: #333;}
|
| 26 |
.item-treasure { background-color: #20b2aa; border-color: #48d1cc; color: #fff;}
|
| 27 |
.item-food { background-color: #ff6347; border-color: #fa8072; color: #fff;}
|
| 28 |
-
.item-quest { background-color: #da70d6; border-color: #ee82ee; color: #fff;}
|
| 29 |
.item-unknown { background-color: #778899; border-color: #b0c4de;}
|
| 30 |
-
|
| 31 |
-
/* --- Choices & Messages --- */
|
| 32 |
#choices-container { margin-top: auto; padding-top: 20px; border-top: 2px solid #506070; }
|
| 33 |
#choices-container h3 { margin-top: 0; margin-bottom: 12px; color: #e0e0e0; font-size: 1.2em; font-weight: bold;}
|
| 34 |
-
#choices { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 15px;}
|
| 35 |
-
#action-choices { display: flex; flex-direction: column; gap: 12px; margin-top: 15px; border-top: 1px dashed #607080; padding-top: 15px;}
|
| 36 |
.choice-button { display: block; width: 100%; padding: 13px 16px; margin-bottom: 0; background-color: #607080; color: #fff; border: 1px solid #8090a0; border-radius: 5px; cursor: pointer; text-align: center; font-family: 'Verdana', sans-serif; font-size: 1.0em; font-weight: bold; transition: background-color 0.2s, transform 0.1s; box-sizing: border-box; letter-spacing: 0.5px; }
|
| 37 |
-
.choice-button.action { text-align: left; grid-column: span 2;}
|
| 38 |
.choice-button:hover:not(:disabled) { background-color: #ffc107; color: #222; border-color: #ffca2c; transform: translateY(-1px); }
|
| 39 |
.choice-button:disabled { background-color: #4e5a66; color: #8a9aab; cursor: not-allowed; border-color: #607080; opacity: 0.6; transform: none; box-shadow: none;}
|
| 40 |
.choice-button[title]:disabled::after { content: ' (' attr(title) ')'; font-style: italic; font-size: 0.9em; margin-left: 5px; }
|
|
@@ -48,8 +43,6 @@
|
|
| 48 |
.message-combat { color: #f98; border-left-color: #c64; font-weight: bold;}
|
| 49 |
.combat-button { background-color: #a33; border-color: #c66; color: #fff; font-weight: bold; text-align: center;}
|
| 50 |
.combat-button:hover:not(:disabled) { background-color: #d44; border-color: #f88;}
|
| 51 |
-
|
| 52 |
-
/* --- Action Info --- */
|
| 53 |
#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: block; z-index: 10;}
|
| 54 |
</style>
|
| 55 |
</head>
|
|
@@ -60,7 +53,7 @@
|
|
| 60 |
</div>
|
| 61 |
<div id="ui-container">
|
| 62 |
<h2 id="story-title">Initializing...</h2>
|
| 63 |
-
<div id="story-content"><p>Loading
|
| 64 |
<div id="stats-inventory-container">
|
| 65 |
<div id="stats-display">Loading Stats...</div>
|
| 66 |
<div id="inventory-display">Inventory: ...</div>
|
|
@@ -82,9 +75,9 @@
|
|
| 82 |
|
| 83 |
<script type="module">
|
| 84 |
import * as THREE from 'three';
|
|
|
|
| 85 |
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
| 86 |
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
|
| 87 |
-
// Orbit Controls removed again for simplicity focus
|
| 88 |
|
| 89 |
console.log("Script module execution started.");
|
| 90 |
|
|
@@ -92,8 +85,8 @@
|
|
| 92 |
const sceneContainer = document.getElementById('scene-container');
|
| 93 |
const storyTitleElement = document.getElementById('story-title');
|
| 94 |
const storyContentElement = document.getElementById('story-content');
|
| 95 |
-
const choicesElement = document.getElementById('choices');
|
| 96 |
-
const actionChoicesElement = document.getElementById('action-choices');
|
| 97 |
const statsElement = document.getElementById('stats-display');
|
| 98 |
const inventoryElement = document.getElementById('inventory-display');
|
| 99 |
const actionInfoElement = document.getElementById('action-info');
|
|
@@ -101,11 +94,12 @@
|
|
| 101 |
console.log("DOM elements obtained.");
|
| 102 |
|
| 103 |
// --- Core Three.js Variables ---
|
| 104 |
-
let scene, camera, renderer, clock;
|
| 105 |
let currentSceneGroup = null;
|
| 106 |
let currentLights = [];
|
| 107 |
let threeFont = null;
|
| 108 |
let currentMessage = "";
|
|
|
|
| 109 |
|
| 110 |
// --- Materials ---
|
| 111 |
const MAT = {
|
|
@@ -126,8 +120,8 @@
|
|
| 126 |
sky_blue: new THREE.MeshStandardMaterial({ color: 0x03a9f4, roughness: 0.5 }),
|
| 127 |
town_wood: new THREE.MeshStandardMaterial({ color: 0xae8a63, roughness: 0.7 }),
|
| 128 |
town_roof: new THREE.MeshStandardMaterial({ color: 0x8b4513, roughness: 0.8 }),
|
| 129 |
-
goblin_skin: new THREE.MeshStandardMaterial({ color: 0x8FBC8F, roughness: 0.8 }),
|
| 130 |
-
text_material: new THREE.MeshBasicMaterial({ color: 0xffddaa }),
|
| 131 |
};
|
| 132 |
|
| 133 |
// --- Game State ---
|
|
@@ -140,16 +134,18 @@
|
|
| 140 |
"Goblin's Favorite Sock": {type:"quest", description:"Smells... unique."},
|
| 141 |
"Shiny Rock": {type:"treasure", description:"Distractingly shiny."},
|
| 142 |
"Suspicious Mushroom": {type:"food", description:"Maybe... just maybe..."},
|
|
|
|
| 143 |
};
|
| 144 |
|
| 145 |
// --- Enemy Data ---
|
| 146 |
const enemyData = {
|
| 147 |
'goblin': { name: "Grumpy Goblin", hp: 12, defense: 12, attackBonus: 1, damageDice: 4, xp: 25, drops: ["Goblin's Favorite Sock", "Shiny Rock"] },
|
| 148 |
-
|
|
|
|
| 149 |
};
|
| 150 |
|
| 151 |
-
// --- Procedural
|
| 152 |
-
|
| 153 |
'pointy_cone': (size) => new THREE.ConeGeometry(size * 0.5, size * 1.2, 6 + Math.floor(Math.random() * 5)),
|
| 154 |
'round_blob': (size) => new THREE.SphereGeometry(size * 0.6, 12, 8),
|
| 155 |
'spinny_torus': (size) => new THREE.TorusGeometry(size * 0.5, size * 0.15, 8, 16),
|
|
@@ -172,134 +168,139 @@
|
|
| 172 |
};
|
| 173 |
const shapeKeys = Object.keys(SHAPE_GENERATORS);
|
| 174 |
|
| 175 |
-
// --- Game Data (
|
| 176 |
-
|
| 177 |
-
1: {
|
| 178 |
title: "Snoring Meadows",
|
| 179 |
-
content: "<p>You wake
|
| 180 |
options: [
|
| 181 |
-
{ text: "
|
| 182 |
-
{ text: "
|
| 183 |
-
{ text: "Poke the snoring grass (
|
| 184 |
],
|
| 185 |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' }
|
| 186 |
},
|
| 187 |
-
2: {
|
| 188 |
title: "Wiggly Woods",
|
| 189 |
-
content: "<p>These trees
|
| 190 |
options: [
|
| 191 |
-
{ text: "
|
| 192 |
-
{ text: "Try to
|
| 193 |
-
{ text: "
|
| 194 |
],
|
| 195 |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple], arrangement: 'cluster', clusterRadius: 10 }
|
| 196 |
},
|
| 197 |
-
3: {
|
| 198 |
-
title: "Grumpy Goblin",
|
| 199 |
-
content: "<p>'Oi!
|
| 200 |
options: [
|
| 201 |
-
{ text: "
|
| 202 |
-
{ text: "Offer
|
| 203 |
-
{ text: "
|
| 204 |
-
{ text: "
|
| 205 |
],
|
| 206 |
-
assemblyParams: {
|
| 207 |
-
baseShape: 'ground_dirt', baseSize: 25,
|
| 208 |
-
mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn', 'basic_tetra'],
|
| 209 |
-
count: 20, scaleRange: [1.0, 3.0],
|
| 210 |
-
colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple, MAT.goblin_skin],
|
| 211 |
-
arrangement: 'cluster', clusterRadius: 10
|
| 212 |
-
}
|
| 213 |
},
|
| 214 |
-
4: {
|
| 215 |
-
title: "
|
| 216 |
-
content: "<p>You
|
| 217 |
options: [
|
| 218 |
-
{ text: "
|
| 219 |
-
{ text: "Go Back (South)", next: 2 }
|
| 220 |
],
|
| 221 |
-
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder'], accents: ['leaf_green'], count:
|
| 222 |
},
|
| 223 |
-
5: {
|
| 224 |
title: "Clanky Caves Entrance",
|
| 225 |
-
content: "<p>
|
| 226 |
-
options: [ { text: "Enter
|
| 227 |
-
assemblyParams: { baseShape: 'stone_grey', baseSize: 20, mainShapes: ['spiky_ball', 'spinny_torus'], accents: ['metal_rusty'
|
| 228 |
},
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
title: "The Square Clearing",
|
| 231 |
-
content: "<p>
|
| 232 |
options: [
|
| 233 |
-
{ text: "Try
|
| 234 |
-
{ text: "
|
| 235 |
-
{ text: "
|
| 236 |
],
|
| 237 |
-
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: [], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass], arrangement: 'patch', patchRadius: 8 }
|
| 238 |
},
|
| 239 |
8: { // Inside Clanky Caves
|
| 240 |
title: "Clanky Caves - Junction",
|
| 241 |
-
content: "<p>Clank! Whirr! Sproing!
|
| 242 |
-
options: [ { text: "Go West (
|
| 243 |
-
assemblyParams: { baseShape: 'stone_grey', baseSize: 25, mainShapes: ['spinny_torus', 'holed_box'], accents: ['metal_shiny', 'basic_tetra'], count: 30, scaleRange: [0.6, 2.0], colorTheme: [MAT.stone_grey, MAT.metal_shiny, MAT.metal_rusty, MAT.bright_yellow], arrangement: 'cluster', clusterRadius: 10 }
|
| 244 |
},
|
| 245 |
-
9: { // Deeper Cave
|
| 246 |
title: "Sprocket Stash",
|
| 247 |
-
content: "<p>
|
| 248 |
-
options: [ { text: "
|
| 249 |
-
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape'], accents: ['spinny_torus', 'metal_shiny'], count:
|
| 250 |
},
|
| 251 |
10: { // Success poking grass
|
| 252 |
title: "Grass Tickled!",
|
| 253 |
-
content: "<p>
|
| 254 |
-
options: [ { text: "
|
| 255 |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' }
|
| 256 |
},
|
| 257 |
11: { // Fail poking grass
|
| 258 |
-
title: "Grass
|
| 259 |
-
content: "<p>
|
| 260 |
-
options: [ { text: "Okay,
|
| 261 |
-
hpLoss: 1,
|
| 262 |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' }
|
| 263 |
},
|
| 264 |
12: { // Fail charisma check on goblin
|
| 265 |
-
title: "Goblin
|
| 266 |
-
content: "<p>'
|
| 267 |
options: [
|
| 268 |
-
{ text: "
|
| 269 |
-
{ text: "Offer
|
| 270 |
-
{ text: "
|
| 271 |
],
|
| 272 |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn', 'basic_tetra'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple, MAT.goblin_skin], arrangement: 'cluster', clusterRadius: 10 }
|
| 273 |
},
|
| 274 |
13: { // Open chest with key
|
| 275 |
-
title: "Chest Opens!",
|
| 276 |
-
content: "<p>
|
| 277 |
-
options: [ { text: "Take the
|
| 278 |
-
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: [], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass], arrangement: 'patch', patchRadius: 8 }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
},
|
| 280 |
-
|
| 281 |
-
title: "
|
| 282 |
-
content: "<p>You
|
| 283 |
-
options: [ { text: "
|
| 284 |
-
|
| 285 |
-
assemblyParams: { baseShape: '
|
| 286 |
},
|
| 287 |
-
|
| 288 |
-
title: "
|
| 289 |
-
content: "<p>You
|
| 290 |
-
options: [ { text: "
|
| 291 |
-
|
| 292 |
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape'], accents: ['spinny_torus', 'metal_shiny'], count: 20, scaleRange: [0.4, 1.0], colorTheme: [MAT.metal_shiny, MAT.bright_yellow, MAT.deep_purple], arrangement: 'patch', patchRadius: 4 }
|
| 293 |
},
|
| 294 |
98: { // Lose combat
|
| 295 |
-
title: "
|
| 296 |
-
content: "<p>
|
| 297 |
-
options: [ { text: "
|
| 298 |
-
assemblyParams: { baseShape: 'ground_grass', count: 5, mainShapes:['round_blob'], colorTheme:[MAT.grass]}
|
| 299 |
},
|
| 300 |
99: { // Generic End/TBC
|
| 301 |
-
title: "
|
| 302 |
-
content: "<p>That's
|
| 303 |
options: [ { text: "Start Over?", next: 1 } ],
|
| 304 |
assemblyParams: { baseShape: 'flat_plate', baseSize: 10, mainShapes: ['basic_tetra'], count: 5, scaleRange: [1, 2], colorTheme: [MAT.sky_blue, MAT.bright_yellow], arrangement: 'center_stack', stackHeight: 3 }
|
| 305 |
}
|
|
@@ -327,11 +328,11 @@
|
|
| 327 |
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
| 328 |
sceneContainer.appendChild(renderer.domElement);
|
| 329 |
|
| 330 |
-
//
|
| 331 |
// controls = new OrbitControls(camera, renderer.domElement);
|
| 332 |
|
| 333 |
window.addEventListener('resize', onWindowResize, false);
|
| 334 |
-
renderer.domElement.addEventListener('click', onMouseClick, false);
|
| 335 |
setTimeout(onWindowResize, 100);
|
| 336 |
animate();
|
| 337 |
console.log("initThreeJS finished.");
|
|
@@ -340,14 +341,15 @@
|
|
| 340 |
function loadFontAndStart() {
|
| 341 |
console.log("Loading font...");
|
| 342 |
const loader = new FontLoader();
|
|
|
|
| 343 |
loader.load('https://unpkg.com/[email protected]/examples/fonts/helvetiker_regular.typeface.json', function (font) {
|
| 344 |
threeFont = font;
|
| 345 |
console.log("Font loaded.");
|
| 346 |
startGame();
|
| 347 |
}, undefined, function (error) {
|
| 348 |
console.error('Font loading failed:', error);
|
| 349 |
-
threeFont = null;
|
| 350 |
-
startGame();
|
| 351 |
});
|
| 352 |
}
|
| 353 |
|
|
@@ -364,11 +366,10 @@
|
|
| 364 |
}
|
| 365 |
|
| 366 |
function onMouseClick( event ) {
|
| 367 |
-
// Calculate mouse position in normalized device coordinates (-1 to +1)
|
| 368 |
const rect = renderer.domElement.getBoundingClientRect();
|
| 369 |
mouse.x = ( (event.clientX - rect.left) / rect.width ) * 2 - 1;
|
| 370 |
mouse.y = - ( (event.clientY - rect.top) / rect.height ) * 2 + 1;
|
| 371 |
-
pickupItem(); // Only
|
| 372 |
}
|
| 373 |
|
| 374 |
|
|
@@ -376,8 +377,7 @@
|
|
| 376 |
requestAnimationFrame(animate);
|
| 377 |
const delta = clock.getDelta();
|
| 378 |
const time = clock.getElapsedTime();
|
| 379 |
-
|
| 380 |
-
// controls?.update(); // OrbitControls removed
|
| 381 |
|
| 382 |
if (currentSceneGroup) {
|
| 383 |
currentSceneGroup.traverse(obj => {
|
|
@@ -397,23 +397,23 @@
|
|
| 397 |
return mesh;
|
| 398 |
}
|
| 399 |
|
| 400 |
-
function setupLighting(type = 'default') {
|
| 401 |
currentLights.forEach(light => { if (light.parent) light.parent.remove(light); if(scene.children.includes(light)) scene.remove(light); });
|
| 402 |
currentLights = [];
|
| 403 |
|
| 404 |
-
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
| 405 |
scene.add(ambientLight);
|
| 406 |
currentLights.push(ambientLight);
|
| 407 |
|
| 408 |
-
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
| 409 |
-
directionalLight.position.set(8,
|
| 410 |
directionalLight.castShadow = true;
|
| 411 |
directionalLight.shadow.mapSize.set(1024, 1024);
|
| 412 |
-
|
| 413 |
const sb = 25;
|
| 414 |
directionalLight.shadow.camera.left = -sb; directionalLight.shadow.camera.right = sb;
|
| 415 |
directionalLight.shadow.camera.top = sb; directionalLight.shadow.camera.bottom = -sb;
|
| 416 |
-
directionalLight.shadow.bias = -0.
|
| 417 |
scene.add(directionalLight);
|
| 418 |
currentLights.push(directionalLight);
|
| 419 |
}
|
|
@@ -439,19 +439,19 @@
|
|
| 439 |
baseMesh = new THREE.Mesh(groundGeo, groundMat);
|
| 440 |
baseMesh.rotation.x = -Math.PI / 2; baseMesh.position.y = 0;
|
| 441 |
baseMesh.receiveShadow = true; baseMesh.castShadow = false;
|
| 442 |
-
baseMesh.userData.isGround = true;
|
| 443 |
group.add(baseMesh);
|
| 444 |
} else {
|
| 445 |
const baseGeoFunc = SHAPE_GENERATORS[baseShape] || SHAPE_GENERATORS['flat_plate'];
|
| 446 |
const baseGeo = baseGeoFunc(baseSize);
|
| 447 |
baseMesh = createMesh(baseGeo, colorTheme[0] || MAT.stone_grey, {y:0.1});
|
| 448 |
baseMesh.receiveShadow = true; baseMesh.castShadow = false;
|
| 449 |
-
baseMesh.userData.isGround = true;
|
| 450 |
group.add(baseMesh);
|
| 451 |
}
|
| 452 |
|
| 453 |
const allShapes = [...mainShapes, ...accents];
|
| 454 |
-
let lastY = 0.1;
|
| 455 |
let stackCount = 0;
|
| 456 |
|
| 457 |
for (let i = 0; i < count; i++) {
|
|
@@ -462,68 +462,61 @@
|
|
| 462 |
|
| 463 |
const scaleFactor = scaleRange[0] + Math.random() * (scaleRange[1] - scaleRange[0]);
|
| 464 |
const geometry = geoFunc(scaleFactor);
|
|
|
|
|
|
|
| 465 |
const material = colorTheme[Math.floor(Math.random() * colorTheme.length)];
|
| 466 |
const mesh = createMesh(geometry, material);
|
| 467 |
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
| 469 |
let height = 0;
|
| 470 |
if (geometry.boundingBox) {
|
| 471 |
height = (geometry.boundingBox.max.y - geometry.boundingBox.min.y) * mesh.scale.y;
|
| 472 |
} else {
|
| 473 |
-
|
| 474 |
-
height = scaleFactor; // Estimate height if bbox failed
|
| 475 |
}
|
| 476 |
-
height = Math.max(0.1, height);
|
| 477 |
|
| 478 |
|
| 479 |
let position = { x:0, y:0, z:0 };
|
| 480 |
-
let isValidPosition = false;
|
| 481 |
|
| 482 |
switch(arrangement) {
|
| 483 |
-
|
| 484 |
-
if (stackCount < stackHeight) {
|
| 485 |
-
position.y = lastY + height / 2;
|
| 486 |
-
position.x = (Math.random() - 0.5) * 0.5 * scaleFactor;
|
| 487 |
-
position.z = (Math.random() - 0.5) * 0.5 * scaleFactor;
|
| 488 |
-
lastY += height * 0.9;
|
| 489 |
-
stackCount++;
|
| 490 |
-
isValidPosition = true;
|
| 491 |
-
}
|
| 492 |
-
break;
|
| 493 |
-
case 'center_stack':
|
| 494 |
if (stackCount < stackHeight) {
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
break;
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
position.
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
break;
|
| 524 |
}
|
| 525 |
|
| 526 |
-
if (!isValidPosition) continue;
|
| 527 |
|
| 528 |
mesh.position.set(position.x, position.y, position.z);
|
| 529 |
mesh.rotation.set(
|
|
@@ -544,22 +537,26 @@
|
|
| 544 |
}
|
| 545 |
group.add(mesh);
|
| 546 |
}
|
| 547 |
-
console.log(`Assembly created with ${group.children.length -1} procedural objects.`);
|
| 548 |
return group;
|
| 549 |
}
|
| 550 |
|
| 551 |
function updateScene(assemblyParams) {
|
| 552 |
console.log("updateScene called");
|
|
|
|
|
|
|
|
|
|
| 553 |
if (currentSceneGroup) {
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
| 560 |
currentSceneGroup = null;
|
| 561 |
}
|
| 562 |
-
setupLighting('default');
|
| 563 |
|
| 564 |
if (!assemblyParams) {
|
| 565 |
console.warn("No assemblyParams provided, creating default scene.");
|
|
@@ -572,10 +569,10 @@
|
|
| 572 |
console.log("New scene group added.");
|
| 573 |
} catch (error) {
|
| 574 |
console.error("Error creating procedural assembly:", error);
|
| 575 |
-
// Add error indicator?
|
| 576 |
}
|
| 577 |
}
|
| 578 |
|
|
|
|
| 579 |
function startGame() {
|
| 580 |
console.log("startGame called.");
|
| 581 |
const defaultChar = {
|
|
@@ -586,59 +583,64 @@
|
|
| 586 |
gameState = {
|
| 587 |
currentPageId: 1,
|
| 588 |
character: JSON.parse(JSON.stringify(defaultChar)),
|
| 589 |
-
combat: null
|
| 590 |
};
|
| 591 |
renderPage(gameState.currentPageId);
|
| 592 |
console.log("startGame finished.");
|
| 593 |
}
|
| 594 |
|
| 595 |
-
function handleChoiceClick(
|
| 596 |
-
console.log("Choice clicked:",
|
| 597 |
currentMessage = "";
|
| 598 |
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
|
|
|
|
|
|
|
|
|
| 602 |
|
| 603 |
-
//
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
}
|
| 610 |
-
if (option.consume) {
|
| 611 |
-
gameState.character.inventory = gameState.character.inventory.filter(i => i !== option.requires);
|
| 612 |
-
currentMessage += `<p class="message message-item">Used your ${option.requires}.</p>`;
|
| 613 |
-
}
|
| 614 |
}
|
| 615 |
|
| 616 |
-
//
|
| 617 |
-
if (option
|
| 618 |
startCombat(option.enemy, option.nextOnWin, option.nextOnLoss);
|
| 619 |
-
|
| 620 |
-
}
|
| 621 |
-
if (option && option.check) {
|
| 622 |
performSkillCheck(option.check, option.next, option.onFailure);
|
| 623 |
-
|
| 624 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
if (!targetPageData) {
|
| 634 |
-
console.error(`Invalid next page ID: ${nextPageId}`);
|
| 635 |
-
currentMessage += `<p class="message message-warning">That path is mysteriously blocked!</p>`;
|
| 636 |
-
nextPageId = gameState.currentPageId;
|
| 637 |
-
}
|
| 638 |
|
| 639 |
-
|
| 640 |
-
|
|
|
|
|
|
|
|
|
|
| 641 |
}
|
|
|
|
| 642 |
|
| 643 |
function performSkillCheck(checkData, successPageId, failurePageId) {
|
| 644 |
const {stat, dc} = checkData;
|
|
@@ -646,11 +648,11 @@
|
|
| 646 |
const modifier = Math.floor((baseStat - 10) / 2);
|
| 647 |
const roll = Math.floor(Math.random() * 20) + 1;
|
| 648 |
const total = roll + modifier;
|
| 649 |
-
const success = total >= dc;
|
| 650 |
|
| 651 |
console.log(`Skill Check ${stat}: Roll ${roll} + Mod ${modifier} = ${total} vs DC ${dc}`);
|
| 652 |
currentMessage += `<p class="message message-info"><em>Rolling ${stat}... (Rolled ${total} vs DC ${dc})</em></p>`;
|
| 653 |
-
displayDiceRoll(roll, success);
|
| 654 |
|
| 655 |
if (success) {
|
| 656 |
currentMessage += `<p class="message message-success"><em>Success!</em></p>`;
|
|
@@ -659,111 +661,131 @@
|
|
| 659 |
currentMessage += `<p class="message message-failure"><em>Failed!</em></p>`;
|
| 660 |
gameState.currentPageId = failurePageId;
|
| 661 |
}
|
| 662 |
-
|
| 663 |
-
|
|
|
|
| 664 |
}
|
| 665 |
|
| 666 |
function applyReward(rewardData) {
|
|
|
|
|
|
|
| 667 |
if(rewardData.addItem && itemsData[rewardData.addItem]) {
|
| 668 |
if (!gameState.character.inventory.includes(rewardData.addItem)) {
|
| 669 |
gameState.character.inventory.push(rewardData.addItem);
|
| 670 |
-
currentMessage += `<p class="message message-item">You
|
| 671 |
} else {
|
| 672 |
-
currentMessage += `<p class="message message-info">You found another ${rewardData.addItem}
|
| 673 |
}
|
| 674 |
}
|
| 675 |
if(rewardData.xp) {
|
| 676 |
gameState.character.stats.xp += rewardData.xp;
|
| 677 |
-
currentMessage += `<p class="message message-xp">
|
| 678 |
}
|
| 679 |
if(rewardData.hpGain) {
|
| 680 |
gameState.character.stats.hp = Math.min(gameState.character.stats.maxHp, gameState.character.stats.hp + rewardData.hpGain);
|
| 681 |
-
currentMessage += `<p class="message message-success">
|
| 682 |
}
|
| 683 |
-
if(rewardData.statGain) {
|
| 684 |
for (const stat in rewardData.statGain) {
|
| 685 |
if (gameState.character.stats.hasOwnProperty(stat)) {
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
if (stat === 'maxHp' &&
|
| 690 |
-
gameState.character.stats.hp +=
|
| 691 |
}
|
|
|
|
| 692 |
}
|
| 693 |
}
|
| 694 |
}
|
| 695 |
}
|
| 696 |
|
| 697 |
-
|
| 698 |
function renderPage(pageId) {
|
| 699 |
console.log(`Rendering page ${pageId}`);
|
| 700 |
const pageData = gameData[pageId];
|
| 701 |
|
| 702 |
-
if (!pageData) { /* Error handling
|
| 703 |
|
| 704 |
storyTitleElement.textContent = pageData.title || "An Unnamed Place";
|
| 705 |
storyContentElement.innerHTML = currentMessage + (pageData.content || "<p>...</p>");
|
| 706 |
choicesElement.innerHTML = '';
|
| 707 |
-
actionChoicesElement.innerHTML = '';
|
| 708 |
|
| 709 |
-
//
|
| 710 |
-
const neighbors =
|
| 711 |
-
const
|
| 712 |
-
|
| 713 |
-
const neighborId = neighbors[dir]; // Note: This uses grid logic, pageData uses .next
|
| 714 |
const button = document.createElement('button');
|
| 715 |
button.classList.add('choice-button');
|
| 716 |
-
button.textContent = `Go ${
|
| 717 |
-
//
|
| 718 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
choicesElement.appendChild(button);
|
| 720 |
-
}
|
| 721 |
-
|
| 722 |
|
| 723 |
-
// Add options from pageData to Action Choices area
|
| 724 |
-
if (pageData.options && pageData.options.length > 0) {
|
| 725 |
-
pageData.options.forEach(option => {
|
| 726 |
-
const button = document.createElement('button');
|
| 727 |
-
button.classList.add('choice-button', 'action'); // Mark as action
|
| 728 |
-
button.textContent = option.text;
|
| 729 |
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
|
|
|
|
|
|
| 743 |
}
|
| 744 |
|
|
|
|
| 745 |
updateStatsDisplay();
|
| 746 |
updateInventoryDisplay();
|
| 747 |
updateActionInfo();
|
| 748 |
updateScene(pageData.assemblyParams);
|
| 749 |
}
|
| 750 |
-
window.handleChoiceClick = handleChoiceClick; // Make global
|
| 751 |
-
|
| 752 |
|
| 753 |
function updateStatsDisplay() {
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
}
|
| 767 |
|
| 768 |
function updateInventoryDisplay() {
|
| 769 |
if (!gameState.character || !inventoryElement) return;
|
|
@@ -786,25 +808,25 @@
|
|
| 786 |
actionInfoElement.textContent = `Location: ${gameData[gameState.currentPageId]?.title || 'Unknown'} | ${mode}`;
|
| 787 |
}
|
| 788 |
|
| 789 |
-
// --- Combat ---
|
| 790 |
function startCombat(enemyTypeId, nextOnWin, nextOnLoss) {
|
| 791 |
const enemyBase = enemyData[enemyTypeId];
|
| 792 |
if (!enemyBase) { console.error("Unknown enemy type:", enemyTypeId); return; }
|
| 793 |
gameState.combat = {
|
| 794 |
active: true, enemyId: enemyTypeId, enemyName: enemyBase.name,
|
| 795 |
enemyHp: enemyBase.hp, enemyMaxHp: enemyBase.hp,
|
| 796 |
-
enemyDefense: enemyBase.defense, enemyAttackBonus: 2,
|
| 797 |
enemyDamageDice: enemyBase.damageDice || 4, enemyXp: enemyBase.xp,
|
| 798 |
enemyDrops: enemyBase.drops || [],
|
| 799 |
nextOnWin: nextOnWin, nextOnLoss: nextOnLoss
|
| 800 |
};
|
| 801 |
-
currentMessage = `<p class="message message-combat">A ${enemyBase.name}
|
| 802 |
-
|
| 803 |
}
|
| 804 |
|
| 805 |
function handleCombatAction(action) {
|
| 806 |
if (!gameState.combat?.active) return;
|
| 807 |
-
currentMessage = "";
|
| 808 |
|
| 809 |
if (action === 'attack') {
|
| 810 |
// Player Attack
|
|
@@ -812,33 +834,32 @@
|
|
| 812 |
const playerAtkBonus = Math.floor((gameState.character.stats.strength - 10) / 2);
|
| 813 |
const playerTotalAttack = playerRoll + playerAtkBonus;
|
| 814 |
const playerHit = playerRoll === 20 || (playerRoll !== 1 && playerTotalAttack >= gameState.combat.enemyDefense);
|
| 815 |
-
|
| 816 |
-
displayDiceRoll(playerRoll, playerHit); // Show roll
|
| 817 |
|
| 818 |
if (playerHit) {
|
| 819 |
const weapon = gameState.character.inventory.find(i => itemsData[i]?.type === 'weapon');
|
| 820 |
-
const baseDamage = itemsData[weapon]?.baseDamage || 2; //
|
| 821 |
-
const damageRoll = Math.max(1, Math.floor(Math.random() * baseDamage) + 1 + playerAtkBonus);
|
| 822 |
gameState.combat.enemyHp -= damageRoll;
|
| 823 |
-
currentMessage += `<p class="message message-success">You hit
|
| 824 |
} else {
|
| 825 |
-
currentMessage += `<p class="message message-failure">You missed
|
| 826 |
}
|
| 827 |
|
| 828 |
// Check Enemy Defeat
|
| 829 |
if (gameState.combat.enemyHp <= 0) {
|
| 830 |
currentMessage += `<p class="message message-success"><b>You defeated the ${gameState.combat.enemyName}!</b></p>`;
|
| 831 |
-
applyReward({ xp: gameState.combat.enemyXp });
|
| 832 |
-
// Handle Drops
|
| 833 |
if (gameState.combat.enemyDrops.length > 0) {
|
| 834 |
const droppedItemName = gameState.combat.enemyDrops[Math.floor(Math.random() * gameState.combat.enemyDrops.length)];
|
| 835 |
dropItemInScene(droppedItemName, new THREE.Vector3(Math.random()*2-1, 0.2, Math.random()*2-1));
|
| 836 |
currentMessage += `<p class="message message-item"><em>The ${gameState.combat.enemyName} dropped a ${droppedItemName}! Click it to pick up.</em></p>`;
|
| 837 |
}
|
| 838 |
const winPage = gameState.combat.nextOnWin;
|
| 839 |
-
gameState.combat = null;
|
| 840 |
-
gameState.currentPageId = winPage;
|
| 841 |
-
renderPage(gameState.currentPageId);
|
| 842 |
return;
|
| 843 |
}
|
| 844 |
|
|
@@ -848,8 +869,7 @@
|
|
| 848 |
const playerAC = 10 + Math.floor((gameState.character.stats.dexterity - 10) / 2);
|
| 849 |
const enemyHit = enemyRoll === 20 || (enemyRoll !== 1 && enemyTotalAttack >= playerAC);
|
| 850 |
|
| 851 |
-
|
| 852 |
-
setTimeout(() => displayDiceRoll(enemyRoll, enemyHit), 500); // Display enemy roll slightly later
|
| 853 |
|
| 854 |
if (enemyHit) {
|
| 855 |
const damageRoll = Math.max(1, Math.floor(Math.random() * gameState.combat.enemyDamageDice) + 1);
|
|
@@ -858,44 +878,44 @@
|
|
| 858 |
if (gameState.character.stats.hp <= 0) {
|
| 859 |
currentMessage += `<p class="message message-failure"><b>You have been defeated!</b></p>`;
|
| 860 |
const lossPage = gameState.combat.nextOnLoss;
|
| 861 |
-
gameState.combat = null;
|
| 862 |
-
gameState.currentPageId = lossPage;
|
| 863 |
-
|
| 864 |
return;
|
| 865 |
}
|
| 866 |
} else {
|
| 867 |
currentMessage += `<p class="message message-info">The ${gameState.combat.enemyName} misses you. (Rolled ${enemyTotalAttack} vs AC ${playerAC})</p>`;
|
| 868 |
}
|
| 869 |
-
setTimeout(() => renderPage(gameState.currentPageId),
|
| 870 |
}
|
| 871 |
}
|
| 872 |
-
window.handleCombatAction = handleCombatAction;
|
| 873 |
|
| 874 |
-
|
| 875 |
-
if (!threeFont) {
|
| 876 |
activeTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); activeTimeouts = [];
|
| 877 |
-
scene.children.filter(c => c.userData?.isDiceRoll).forEach(c => scene.remove(c));
|
| 878 |
|
| 879 |
const textGeo = new TextGeometry(result.toString(), { font: threeFont, size: 1.0, height: 0.1, curveSegments: 4 });
|
| 880 |
-
textGeo.computeBoundingBox();
|
| 881 |
-
const textWidth = textGeo.boundingBox.max.x - textGeo.boundingBox.min.x;
|
| 882 |
const textMat = MAT.text_material.clone();
|
| 883 |
textMat.color.setHex(success ? 0x88ff88 : 0xff8888);
|
| 884 |
-
textMat.
|
| 885 |
|
| 886 |
const textMesh = new THREE.Mesh(textGeo, textMat);
|
| 887 |
textMesh.userData.isDiceRoll = true;
|
| 888 |
|
| 889 |
-
const distance =
|
| 890 |
-
const
|
|
|
|
| 891 |
textMesh.position.copy(textPos);
|
| 892 |
-
textMesh.position.y += 1.
|
| 893 |
-
textMesh.
|
| 894 |
-
textMesh.quaternion.copy(camera.quaternion);
|
| 895 |
|
| 896 |
scene.add(textMesh);
|
| 897 |
|
| 898 |
-
const duration = 2.5;
|
|
|
|
| 899 |
const startTime = clock.getElapsedTime();
|
| 900 |
textMesh.userData.startTime = startTime;
|
| 901 |
textMesh.userData.update = (time) => {
|
|
@@ -903,19 +923,23 @@
|
|
| 903 |
if (elapsed >= duration) {
|
| 904 |
if (textMesh.parent) textMesh.parent.remove(textMesh);
|
| 905 |
delete textMesh.userData.update;
|
|
|
|
| 906 |
} else {
|
| 907 |
-
textMesh.position.y += 0.
|
| 908 |
-
|
|
|
|
|
|
|
| 909 |
}
|
| 910 |
};
|
| 911 |
-
|
|
|
|
| 912 |
|
| 913 |
function dropItemInScene(itemName, positionOffset = new THREE.Vector3(0, 0, 0)) {
|
| 914 |
-
const currentGroup = currentSceneGroup; //
|
| 915 |
if (!currentGroup || !itemsData[itemName]) return;
|
| 916 |
|
| 917 |
const itemDef = itemsData[itemName];
|
| 918 |
-
const itemGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4);
|
| 919 |
const itemMat = MAT.simple.clone();
|
| 920 |
if(itemDef.type === 'weapon') itemMat.color.setHex(0xcc6666);
|
| 921 |
else if(itemDef.type === 'consumable') itemMat.color.setHex(0xcc9966);
|
|
@@ -929,15 +953,15 @@
|
|
| 929 |
console.log(`Dropped ${itemName} in scene`);
|
| 930 |
}
|
| 931 |
|
| 932 |
-
function pickupItem() {
|
| 933 |
-
if (gameState.combat?.active) return;
|
| 934 |
|
| 935 |
raycaster.setFromCamera(mouse, camera);
|
| 936 |
const currentGroup = currentSceneGroup;
|
| 937 |
if (!currentGroup) return;
|
| 938 |
|
| 939 |
const pickupables = [];
|
| 940 |
-
currentGroup.traverseVisible(child => { //
|
| 941 |
if (child.userData.isPickupable) pickupables.push(child);
|
| 942 |
});
|
| 943 |
|
|
@@ -954,20 +978,18 @@
|
|
| 954 |
if (!gameState.character.inventory.includes(itemName)) {
|
| 955 |
gameState.character.inventory.push(itemName);
|
| 956 |
} else {
|
| 957 |
-
currentMessage += `<p class="message message-info"><em>(
|
| 958 |
}
|
| 959 |
|
| 960 |
-
// Remove object fully after pickup
|
| 961 |
if(clickedObject.parent) clickedObject.parent.remove(clickedObject);
|
| 962 |
if(clickedObject.geometry) clickedObject.geometry.dispose();
|
| 963 |
-
// We assume material is shared from MAT, so don't dispose
|
| 964 |
|
| 965 |
-
renderCurrentPageUI();
|
| 966 |
}
|
| 967 |
}
|
| 968 |
}
|
| 969 |
|
| 970 |
-
|
| 971 |
document.addEventListener('DOMContentLoaded', () => {
|
| 972 |
console.log("DOM Ready - Initializing Wacky D&D Shapes Adventure!");
|
| 973 |
try {
|
|
@@ -980,8 +1002,10 @@
|
|
| 980 |
storyTitleElement.textContent = "Initialization Error";
|
| 981 |
storyContentElement.innerHTML = `<p style="color:red;">Failed to start game:</p><pre style="color:red; white-space: pre-wrap;">${error.stack || error}</pre><p style="color:yellow;">Check console (F12) for details.</p>`;
|
| 982 |
if(sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding: 20px;">3D Scene Failed</p>';
|
| 983 |
-
document.getElementById('stats-inventory-container')
|
| 984 |
-
document.getElementById('choices-container')
|
|
|
|
|
|
|
| 985 |
}
|
| 986 |
});
|
| 987 |
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Wacky D&D Shapes Adventure! (Var Fix)</title>
|
| 7 |
<style>
|
| 8 |
+
/* --- Base Styles (Same as before) --- */
|
| 9 |
body { font-family: 'Verdana', sans-serif; background-color: #2a3a4a; color: #f0f0f0; margin: 0; padding: 0; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
|
| 10 |
#game-container { display: flex; flex-grow: 1; overflow: hidden; }
|
| 11 |
#scene-container { flex-grow: 3; position: relative; border-right: 3px solid #506070; min-width: 250px; background-color: #101820; height: 100%; box-sizing: border-box; overflow: hidden; cursor: default; }
|
| 12 |
#ui-container { flex-grow: 2; padding: 25px; overflow-y: auto; background-color: #3a4a5a; min-width: 320px; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; }
|
| 13 |
#scene-container canvas { display: block; }
|
|
|
|
|
|
|
| 14 |
#story-title { color: #ffd700; margin: 0 0 15px 0; padding-bottom: 10px; border-bottom: 2px solid #506070; font-size: 1.8em; font-weight: bold; text-shadow: 1px 1px 2px #111; }
|
| 15 |
#story-content { margin-bottom: 25px; line-height: 1.7; flex-grow: 1; font-size: 1.1em; color: #e8e8e8;}
|
| 16 |
#stats-inventory-container { margin-bottom: 25px; padding: 15px; border: 1px solid #506070; border-radius: 5px; background-color: #4a5a6a; font-size: 0.95em; }
|
| 17 |
#stats-display, #inventory-display { margin-bottom: 10px; line-height: 1.8; }
|
| 18 |
+
#stats-display span, #inventory-display .item-tag { display: inline-block; background-color: #5a6a7a; padding: 4px 10px; border-radius: 15px; margin: 0 8px 5px 0; border: 1px solid #7a8a9a; white-space: nowrap; box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); }
|
|
|
|
| 19 |
#stats-display strong, #inventory-display strong { color: #e0e0e0; margin-right: 6px; font-weight:bold; }
|
| 20 |
#inventory-display em { color: #aabbcc; font-style: normal; }
|
| 21 |
.item-tool { background-color: #a0522d; border-color: #cd853f; color: #fff;}
|
| 22 |
.item-key { background-color: #ffd700; border-color: #f0e68c; color: #333;}
|
| 23 |
.item-treasure { background-color: #20b2aa; border-color: #48d1cc; color: #fff;}
|
| 24 |
.item-food { background-color: #ff6347; border-color: #fa8072; color: #fff;}
|
| 25 |
+
.item-quest { background-color: #da70d6; border-color: #ee82ee; color: #fff;}
|
| 26 |
.item-unknown { background-color: #778899; border-color: #b0c4de;}
|
|
|
|
|
|
|
| 27 |
#choices-container { margin-top: auto; padding-top: 20px; border-top: 2px solid #506070; }
|
| 28 |
#choices-container h3 { margin-top: 0; margin-bottom: 12px; color: #e0e0e0; font-size: 1.2em; font-weight: bold;}
|
| 29 |
+
#choices { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 15px;}
|
| 30 |
+
#action-choices { display: flex; flex-direction: column; gap: 12px; margin-top: 15px; border-top: 1px dashed #607080; padding-top: 15px;}
|
| 31 |
.choice-button { display: block; width: 100%; padding: 13px 16px; margin-bottom: 0; background-color: #607080; color: #fff; border: 1px solid #8090a0; border-radius: 5px; cursor: pointer; text-align: center; font-family: 'Verdana', sans-serif; font-size: 1.0em; font-weight: bold; transition: background-color 0.2s, transform 0.1s; box-sizing: border-box; letter-spacing: 0.5px; }
|
| 32 |
+
.choice-button.action { text-align: left; grid-column: span 2;}
|
| 33 |
.choice-button:hover:not(:disabled) { background-color: #ffc107; color: #222; border-color: #ffca2c; transform: translateY(-1px); }
|
| 34 |
.choice-button:disabled { background-color: #4e5a66; color: #8a9aab; cursor: not-allowed; border-color: #607080; opacity: 0.6; transform: none; box-shadow: none;}
|
| 35 |
.choice-button[title]:disabled::after { content: ' (' attr(title) ')'; font-style: italic; font-size: 0.9em; margin-left: 5px; }
|
|
|
|
| 43 |
.message-combat { color: #f98; border-left-color: #c64; font-weight: bold;}
|
| 44 |
.combat-button { background-color: #a33; border-color: #c66; color: #fff; font-weight: bold; text-align: center;}
|
| 45 |
.combat-button:hover:not(:disabled) { background-color: #d44; border-color: #f88;}
|
|
|
|
|
|
|
| 46 |
#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: block; z-index: 10;}
|
| 47 |
</style>
|
| 48 |
</head>
|
|
|
|
| 53 |
</div>
|
| 54 |
<div id="ui-container">
|
| 55 |
<h2 id="story-title">Initializing...</h2>
|
| 56 |
+
<div id="story-content"><p>Loading assets...</p></div>
|
| 57 |
<div id="stats-inventory-container">
|
| 58 |
<div id="stats-display">Loading Stats...</div>
|
| 59 |
<div id="inventory-display">Inventory: ...</div>
|
|
|
|
| 75 |
|
| 76 |
<script type="module">
|
| 77 |
import * as THREE from 'three';
|
| 78 |
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Re-added controls
|
| 79 |
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
| 80 |
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
|
|
|
|
| 81 |
|
| 82 |
console.log("Script module execution started.");
|
| 83 |
|
|
|
|
| 85 |
const sceneContainer = document.getElementById('scene-container');
|
| 86 |
const storyTitleElement = document.getElementById('story-title');
|
| 87 |
const storyContentElement = document.getElementById('story-content');
|
| 88 |
+
const choicesElement = document.getElementById('choices');
|
| 89 |
+
const actionChoicesElement = document.getElementById('action-choices');
|
| 90 |
const statsElement = document.getElementById('stats-display');
|
| 91 |
const inventoryElement = document.getElementById('inventory-display');
|
| 92 |
const actionInfoElement = document.getElementById('action-info');
|
|
|
|
| 94 |
console.log("DOM elements obtained.");
|
| 95 |
|
| 96 |
// --- Core Three.js Variables ---
|
| 97 |
+
let scene, camera, renderer, clock, controls, raycaster, mouse; // Added controls, raycaster, mouse back
|
| 98 |
let currentSceneGroup = null;
|
| 99 |
let currentLights = [];
|
| 100 |
let threeFont = null;
|
| 101 |
let currentMessage = "";
|
| 102 |
+
let activeTimeouts = []; // Clear timeouts on scene change
|
| 103 |
|
| 104 |
// --- Materials ---
|
| 105 |
const MAT = {
|
|
|
|
| 120 |
sky_blue: new THREE.MeshStandardMaterial({ color: 0x03a9f4, roughness: 0.5 }),
|
| 121 |
town_wood: new THREE.MeshStandardMaterial({ color: 0xae8a63, roughness: 0.7 }),
|
| 122 |
town_roof: new THREE.MeshStandardMaterial({ color: 0x8b4513, roughness: 0.8 }),
|
| 123 |
+
goblin_skin: new THREE.MeshStandardMaterial({ color: 0x8FBC8F, roughness: 0.8 }),
|
| 124 |
+
text_material: new THREE.MeshBasicMaterial({ color: 0xffddaa, transparent: true }), // Make text transparent for fade
|
| 125 |
};
|
| 126 |
|
| 127 |
// --- Game State ---
|
|
|
|
| 134 |
"Goblin's Favorite Sock": {type:"quest", description:"Smells... unique."},
|
| 135 |
"Shiny Rock": {type:"treasure", description:"Distractingly shiny."},
|
| 136 |
"Suspicious Mushroom": {type:"food", description:"Maybe... just maybe..."},
|
| 137 |
+
"Cave Crystal": {type:"unknown", description:"A faintly glowing crystal shard."}
|
| 138 |
};
|
| 139 |
|
| 140 |
// --- Enemy Data ---
|
| 141 |
const enemyData = {
|
| 142 |
'goblin': { name: "Grumpy Goblin", hp: 12, defense: 12, attackBonus: 1, damageDice: 4, xp: 25, drops: ["Goblin's Favorite Sock", "Shiny Rock"] },
|
| 143 |
+
'skeleton': { name: "Clattering Skeleton", hp: 10, defense: 13, attackBonus: 2, damageDice: 4, xp: 20, drops: ["Ancient Coin"] }, // Added skeleton
|
| 144 |
+
'spider': { name: "Hairy Spider", hp: 15, defense: 11, attackBonus: 2, damageDice: 3, xp: 25, drops: ["Cave Crystal"] } // Adjusted spider
|
| 145 |
};
|
| 146 |
|
| 147 |
+
// --- Procedural Shapes ---
|
| 148 |
+
const SHAPE_GENERATORS = {
|
| 149 |
'pointy_cone': (size) => new THREE.ConeGeometry(size * 0.5, size * 1.2, 6 + Math.floor(Math.random() * 5)),
|
| 150 |
'round_blob': (size) => new THREE.SphereGeometry(size * 0.6, 12, 8),
|
| 151 |
'spinny_torus': (size) => new THREE.TorusGeometry(size * 0.5, size * 0.15, 8, 16),
|
|
|
|
| 168 |
};
|
| 169 |
const shapeKeys = Object.keys(SHAPE_GENERATORS);
|
| 170 |
|
| 171 |
+
// --- Game Data (More Pages) ---
|
| 172 |
+
const gameData = {
|
| 173 |
+
1: { // Snoring Meadows (Start)
|
| 174 |
title: "Snoring Meadows",
|
| 175 |
+
content: "<p>You wake with a start! Not from a nightmare, but because the grass around you is... snoring softly? Weird. A path leads North into the Wiggly Woods and East towards some noisy-looking Clanky Caves.</p>",
|
| 176 |
options: [
|
| 177 |
+
{ text: "Venture into Wiggly Woods", next: 2 },
|
| 178 |
+
{ text: "Investigate Clanky Caves", next: 5 },
|
| 179 |
+
{ text: "Poke the snoring grass? (WIS Check DC 10)", check: { stat: 'wisdom', dc: 10, next: 10, onFailure: 11 } }
|
| 180 |
],
|
| 181 |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' }
|
| 182 |
},
|
| 183 |
+
2: { // Wiggly Woods Entrance
|
| 184 |
title: "Wiggly Woods",
|
| 185 |
+
content: "<p>Whoa! These trees are doing the wiggle! It's quite groovy, but makes walking tricky. A particularly grumpy-looking Goblin blocks the path deeper (North).</p>",
|
| 186 |
options: [
|
| 187 |
+
{ text: "Politely ask the Goblin to move", next: 3 },
|
| 188 |
+
{ text: "Try to wiggle past (DEX Check DC 12)", check: { stat: 'dexterity', dc: 12, next: 4, onFailure: 3 } }, // Fail leads to talk
|
| 189 |
+
{ text: "Wiggle back to the Meadows (South)", next: 1 }
|
| 190 |
],
|
| 191 |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple], arrangement: 'cluster', clusterRadius: 10 }
|
| 192 |
},
|
| 193 |
+
3: { // Confront Goblin
|
| 194 |
+
title: "Grumpy Goblin Guard",
|
| 195 |
+
content: "<p>'Oi! Wiggler!' the Goblin shouts, adjusting his single, smelly sock. 'Dis MY patch o' wiggles! Whatcha want?' He doesn't look like he enjoys conversation. Or baths.</p>",
|
| 196 |
options: [
|
| 197 |
+
{ text: "'Just passing through!' (CHA Check DC 14)", check: { stat: 'charisma', dc: 14, next: 4, onFailure: 12 } },
|
| 198 |
+
{ text: "Offer 'Shiny Rock' as tribute", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 15} },
|
| 199 |
+
{ text: "Prepare for a Wobbly Tussle!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 },
|
| 200 |
+
{ text: "'Nevermind!' (Retreat)", next: 2 }
|
| 201 |
],
|
| 202 |
+
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn', 'basic_tetra'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple, MAT.goblin_skin], arrangement: 'cluster', clusterRadius: 10 }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
},
|
| 204 |
+
4: { // Past Goblin
|
| 205 |
+
title: "Further Into the Woods",
|
| 206 |
+
content: "<p>You made it past the grumpy guardian! The woods get thicker here, but you see a strange, perfectly Square Clearing to the North.</p>",
|
| 207 |
options: [
|
| 208 |
+
{ text: "Investigate the Square Clearing (North)", next: 7 },
|
| 209 |
+
{ text: "Go Back Past Goblin Spot (South)", next: 2 } // Assume goblin is gone/pacified
|
| 210 |
],
|
| 211 |
+
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'round_blob'], accents: ['leaf_green'], count: 25, scaleRange: [1.0, 2.8], colorTheme: [MAT.wood_light, MAT.leaf_green, MAT.stone_brown], arrangement: 'scatter' }
|
| 212 |
},
|
| 213 |
+
5: { // Cave Entrance
|
| 214 |
title: "Clanky Caves Entrance",
|
| 215 |
+
content: "<p>CLANK! WHOOSH! A rusty metal sign hangs crookedly: 'Clanky Caves - Enter At Yer Own Risk & Volume Level'. It smells faintly of oil and... ozone?</p>",
|
| 216 |
+
options: [ { text: "Bravely Enter!", next: 8 }, { text: "Nope Out (West)", next: 1 } ],
|
| 217 |
+
assemblyParams: { baseShape: 'stone_grey', baseSize: 20, mainShapes: ['spiky_ball', 'spinny_torus', 'holed_box'], accents: ['metal_rusty'], count: 25, scaleRange: [0.5, 1.8], colorTheme: [MAT.stone_grey, MAT.metal_rusty, MAT.metal_shiny], arrangement: 'cluster', clusterRadius: 8 }
|
| 218 |
},
|
| 219 |
+
6: { // Placeholder - Reached from old page 3, needs rewrite
|
| 220 |
+
title: "Gem Patch Revisited",
|
| 221 |
+
content: "<p>You return to the shiny spot. The remaining gems twinkle invitingly.</p>",
|
| 222 |
+
options: [ { text: "Go Back to Meadow Path", next: 1 } ], // Simple exit
|
| 223 |
+
assemblyParams: { baseShape: 'ground_grass', baseSize: 15, mainShapes: ['gem_shape', 'basic_tetra'], accents: ['pointy_cone'], count: 30, scaleRange: [0.2, 0.7], colorTheme: [MAT.crystal_blue, MAT.crystal_red, MAT.stone_grey], arrangement: 'patch', patchPos: {x:0, y:0.1, z:0}, patchRadius: 5 }
|
| 224 |
+
},
|
| 225 |
+
7: { // Square Clearing
|
| 226 |
title: "The Square Clearing",
|
| 227 |
+
content: "<p>Wow, they weren't kidding. Everything is suspiciously square - the rocks, the flowers... In the center sits a locked chest, also square, with a distinctly wobbly keyhole.</p>",
|
| 228 |
options: [
|
| 229 |
+
{ text: "Try Wobbly Key", requires: "Wobbly Key", consume: true, next: 13 },
|
| 230 |
+
{ text: "Try Sturdy Stick (Strength Check DC 13 to pry)", requires: "Sturdy Stick", check: { stat: 'strength', dc: 13, next: 14, onFailure: 14 } }, // Fail still goes to 14 but maybe adds message
|
| 231 |
+
{ text: "Leave the Creepy Clearing (South)", next: 4 }
|
| 232 |
],
|
| 233 |
+
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: ['basic_tetra'], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass, MAT.stone_grey], arrangement: 'patch', patchRadius: 8 }
|
| 234 |
},
|
| 235 |
8: { // Inside Clanky Caves
|
| 236 |
title: "Clanky Caves - Junction",
|
| 237 |
+
content: "<p>Clank! Whirr! Sproing! It's a cacophony! Gears spin wildly, pistons pump uselessly. Passages lead West (Clankier?) and East (Quieter?).</p>",
|
| 238 |
+
options: [ { text: "Go West (More Clanks!)", next: 9 }, { text: "Go East (Less Clanky?)", next: 5 } ],
|
| 239 |
+
assemblyParams: { baseShape: 'stone_grey', baseSize: 25, mainShapes: ['spinny_torus', 'holed_box', 'tall_cylinder'], accents: ['metal_shiny', 'basic_tetra'], count: 30, scaleRange: [0.6, 2.0], colorTheme: [MAT.stone_grey, MAT.metal_shiny, MAT.metal_rusty, MAT.bright_yellow], arrangement: 'cluster', clusterRadius: 10 }
|
| 240 |
},
|
| 241 |
+
9: { // Deeper Cave / Sprocket Stash
|
| 242 |
title: "Sprocket Stash",
|
| 243 |
+
content: "<p>Jackpot! A huge pile of Shiny Sprockets! They spin hypnotically. Also, some weird purple mushrooms pulse nearby.</p>",
|
| 244 |
+
options: [ { text: "Grab a Sprocket!", reward: { addItem: "Shiny Sprocket", xp: 10 }, next: 9 }, { text: "Lick a Mushroom? (CON Check DC 11)", check: {stat:'constitution', dc: 11, next: 15, onFailure: 16 } }, { text: "Go Back East", next: 8 } ],
|
| 245 |
+
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape', 'pointy_cone'], accents: ['spinny_torus', 'metal_shiny'], count: 25, scaleRange: [0.4, 1.0], colorTheme: [MAT.metal_shiny, MAT.bright_yellow, MAT.deep_purple], arrangement: 'patch', patchRadius: 4 }
|
| 246 |
},
|
| 247 |
10: { // Success poking grass
|
| 248 |
title: "Grass Tickled!",
|
| 249 |
+
content: "<p>Hehe! The blade of grass giggles (yes, really!) and spits out a very Wobbly Key!</p>",
|
| 250 |
+
options: [ { text: "Nab the Key!", reward: { addItem: "Wobbly Key", xp: 5 }, next: 1 } ],
|
| 251 |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' }
|
| 252 |
},
|
| 253 |
11: { // Fail poking grass
|
| 254 |
+
title: "Grass Annoyed!",
|
| 255 |
+
content: "<p>The snoring grass grumbles and rolls over, hiding whatever might have been there. Maybe try again later?</p>",
|
| 256 |
+
options: [ { text: "Okay, okay, sorry!", next: 1 } ],
|
|
|
|
| 257 |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' }
|
| 258 |
},
|
| 259 |
12: { // Fail charisma check on goblin
|
| 260 |
+
title: "Goblin Scowls",
|
| 261 |
+
content: "<p>'Bah! Words are cheap!' The goblin tightens his grip on the spear. 'Try again, maybe with shiny stuff?'</p>",
|
| 262 |
options: [
|
| 263 |
+
{ text: "Attack!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 },
|
| 264 |
+
{ text: "Offer 'Shiny Rock'", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 10} },
|
| 265 |
+
{ text: "Flee!", next: 2 }
|
| 266 |
],
|
| 267 |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn', 'basic_tetra'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple, MAT.goblin_skin], arrangement: 'cluster', clusterRadius: 10 }
|
| 268 |
},
|
| 269 |
13: { // Open chest with key
|
| 270 |
+
title: "Wobbly Chest Opens!",
|
| 271 |
+
content: "<p>Wiggle, jiggle... click! The key works! Inside is... a single, slightly Suspicious Mushroom. Huh.</p>",
|
| 272 |
+
options: [ { text: "Take the Mushroom", reward: { addItem: "Suspicious Mushroom", xp: 15 }, next: 7 } ],
|
| 273 |
+
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: [], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass, MAT.stone_grey], arrangement: 'patch', patchRadius: 8 }
|
| 274 |
+
},
|
| 275 |
+
14: { // Whack chest / Fail Str check
|
| 276 |
+
title: "Thwack! Ow.",
|
| 277 |
+
content: "<p>You whack the chest. It remains stubbornly square and closed. Your stick might have a splinter. The lock still wobbles mockingly.</p>",
|
| 278 |
+
options: [ { text: "Try Wobbly Key", requires: "Wobbly Key", consume: true, next: 13 }, { text: "Leave it", next: 7 } ],
|
| 279 |
+
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: [], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass, MAT.stone_grey], arrangement: 'patch', patchRadius: 8 }
|
| 280 |
},
|
| 281 |
+
15: { // Success licking Mushroom
|
| 282 |
+
title: "Tingly!",
|
| 283 |
+
content: "<p>You lick the mushroom. It tastes like purple and static! You feel... stronger! (+1 Strength!)</p>",
|
| 284 |
+
options: [ { text: "Whoa! Go Back.", next: 8 } ],
|
| 285 |
+
reward: { statGain: { strength: 1 }, xp: 5 }, // Example stat gain
|
| 286 |
+
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape'], accents: ['spinny_torus', 'metal_shiny'], count: 20, scaleRange: [0.4, 1.0], colorTheme: [MAT.metal_shiny, MAT.bright_yellow, MAT.deep_purple], arrangement: 'patch', patchRadius: 4 }
|
| 287 |
},
|
| 288 |
+
16: { // Fail licking Mushroom
|
| 289 |
+
title: "Blech!",
|
| 290 |
+
content: "<p>You lick the mushroom. It tastes like old socks and regret. Your tongue feels fuzzy. (-1 Charisma temporarily? TBC)</p>",
|
| 291 |
+
options: [ { text: "Ugh! Go Back.", next: 8 } ],
|
| 292 |
+
// Add temporary effect later if needed
|
| 293 |
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape'], accents: ['spinny_torus', 'metal_shiny'], count: 20, scaleRange: [0.4, 1.0], colorTheme: [MAT.metal_shiny, MAT.bright_yellow, MAT.deep_purple], arrangement: 'patch', patchRadius: 4 }
|
| 294 |
},
|
| 295 |
98: { // Lose combat
|
| 296 |
+
title: "Bonked!",
|
| 297 |
+
content: "<p>Ouch! That hurt. You wake up back in the Snoring Meadows, feeling rather silly.</p>",
|
| 298 |
+
options: [ { text: "Try again?", next: 1 } ],
|
| 299 |
+
assemblyParams: { baseShape: 'ground_grass', count: 5, mainShapes:['round_blob'], colorTheme:[MAT.grass]}
|
| 300 |
},
|
| 301 |
99: { // Generic End/TBC
|
| 302 |
+
title: "To Be Continued... Maybe!",
|
| 303 |
+
content: "<p>That's the end of this particular wacky path. Was there a point? Who knows! Adventure!</p>",
|
| 304 |
options: [ { text: "Start Over?", next: 1 } ],
|
| 305 |
assemblyParams: { baseShape: 'flat_plate', baseSize: 10, mainShapes: ['basic_tetra'], count: 5, scaleRange: [1, 2], colorTheme: [MAT.sky_blue, MAT.bright_yellow], arrangement: 'center_stack', stackHeight: 3 }
|
| 306 |
}
|
|
|
|
| 328 |
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
| 329 |
sceneContainer.appendChild(renderer.domElement);
|
| 330 |
|
| 331 |
+
// Controls removed for page-based navigation focus
|
| 332 |
// controls = new OrbitControls(camera, renderer.domElement);
|
| 333 |
|
| 334 |
window.addEventListener('resize', onWindowResize, false);
|
| 335 |
+
renderer.domElement.addEventListener('click', onMouseClick, false);
|
| 336 |
setTimeout(onWindowResize, 100);
|
| 337 |
animate();
|
| 338 |
console.log("initThreeJS finished.");
|
|
|
|
| 341 |
function loadFontAndStart() {
|
| 342 |
console.log("Loading font...");
|
| 343 |
const loader = new FontLoader();
|
| 344 |
+
// Ensure the path to the font is correct or use a reliable CDN
|
| 345 |
loader.load('https://unpkg.com/[email protected]/examples/fonts/helvetiker_regular.typeface.json', function (font) {
|
| 346 |
threeFont = font;
|
| 347 |
console.log("Font loaded.");
|
| 348 |
startGame();
|
| 349 |
}, undefined, function (error) {
|
| 350 |
console.error('Font loading failed:', error);
|
| 351 |
+
threeFont = null;
|
| 352 |
+
startGame();
|
| 353 |
});
|
| 354 |
}
|
| 355 |
|
|
|
|
| 366 |
}
|
| 367 |
|
| 368 |
function onMouseClick( event ) {
|
|
|
|
| 369 |
const rect = renderer.domElement.getBoundingClientRect();
|
| 370 |
mouse.x = ( (event.clientX - rect.left) / rect.width ) * 2 - 1;
|
| 371 |
mouse.y = - ( (event.clientY - rect.top) / rect.height ) * 2 + 1;
|
| 372 |
+
pickupItem(); // Only use click for pickup now
|
| 373 |
}
|
| 374 |
|
| 375 |
|
|
|
|
| 377 |
requestAnimationFrame(animate);
|
| 378 |
const delta = clock.getDelta();
|
| 379 |
const time = clock.getElapsedTime();
|
| 380 |
+
// controls?.update(); // Controls removed
|
|
|
|
| 381 |
|
| 382 |
if (currentSceneGroup) {
|
| 383 |
currentSceneGroup.traverse(obj => {
|
|
|
|
| 397 |
return mesh;
|
| 398 |
}
|
| 399 |
|
| 400 |
+
function setupLighting(type = 'default') { // Simplified lighting
|
| 401 |
currentLights.forEach(light => { if (light.parent) light.parent.remove(light); if(scene.children.includes(light)) scene.remove(light); });
|
| 402 |
currentLights = [];
|
| 403 |
|
| 404 |
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
| 405 |
scene.add(ambientLight);
|
| 406 |
currentLights.push(ambientLight);
|
| 407 |
|
| 408 |
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
| 409 |
+
directionalLight.position.set(8, 15, 10);
|
| 410 |
directionalLight.castShadow = true;
|
| 411 |
directionalLight.shadow.mapSize.set(1024, 1024);
|
| 412 |
+
directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 50;
|
| 413 |
const sb = 25;
|
| 414 |
directionalLight.shadow.camera.left = -sb; directionalLight.shadow.camera.right = sb;
|
| 415 |
directionalLight.shadow.camera.top = sb; directionalLight.shadow.camera.bottom = -sb;
|
| 416 |
+
directionalLight.shadow.bias = -0.001; // Adjusted bias slightly
|
| 417 |
scene.add(directionalLight);
|
| 418 |
currentLights.push(directionalLight);
|
| 419 |
}
|
|
|
|
| 439 |
baseMesh = new THREE.Mesh(groundGeo, groundMat);
|
| 440 |
baseMesh.rotation.x = -Math.PI / 2; baseMesh.position.y = 0;
|
| 441 |
baseMesh.receiveShadow = true; baseMesh.castShadow = false;
|
| 442 |
+
baseMesh.userData.isGround = true;
|
| 443 |
group.add(baseMesh);
|
| 444 |
} else {
|
| 445 |
const baseGeoFunc = SHAPE_GENERATORS[baseShape] || SHAPE_GENERATORS['flat_plate'];
|
| 446 |
const baseGeo = baseGeoFunc(baseSize);
|
| 447 |
baseMesh = createMesh(baseGeo, colorTheme[0] || MAT.stone_grey, {y:0.1});
|
| 448 |
baseMesh.receiveShadow = true; baseMesh.castShadow = false;
|
| 449 |
+
baseMesh.userData.isGround = true;
|
| 450 |
group.add(baseMesh);
|
| 451 |
}
|
| 452 |
|
| 453 |
const allShapes = [...mainShapes, ...accents];
|
| 454 |
+
let lastY = 0.1;
|
| 455 |
let stackCount = 0;
|
| 456 |
|
| 457 |
for (let i = 0; i < count; i++) {
|
|
|
|
| 462 |
|
| 463 |
const scaleFactor = scaleRange[0] + Math.random() * (scaleRange[1] - scaleRange[0]);
|
| 464 |
const geometry = geoFunc(scaleFactor);
|
| 465 |
+
if (!geometry) { console.warn(`Geometry creation failed for ${shapeKey}`); continue; } // Check geometry creation
|
| 466 |
+
|
| 467 |
const material = colorTheme[Math.floor(Math.random() * colorTheme.length)];
|
| 468 |
const mesh = createMesh(geometry, material);
|
| 469 |
|
| 470 |
+
try {
|
| 471 |
+
geometry.computeBoundingBox();
|
| 472 |
+
} catch(e) { console.error("Error computing bounding box", e, geometry); continue; }
|
| 473 |
+
|
| 474 |
let height = 0;
|
| 475 |
if (geometry.boundingBox) {
|
| 476 |
height = (geometry.boundingBox.max.y - geometry.boundingBox.min.y) * mesh.scale.y;
|
| 477 |
} else {
|
| 478 |
+
height = scaleFactor;
|
|
|
|
| 479 |
}
|
| 480 |
+
height = Math.max(0.1, height);
|
| 481 |
|
| 482 |
|
| 483 |
let position = { x:0, y:0, z:0 };
|
| 484 |
+
let isValidPosition = false;
|
| 485 |
|
| 486 |
switch(arrangement) {
|
| 487 |
+
case 'stack':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
if (stackCount < stackHeight) {
|
| 489 |
+
position.y = lastY + height / 2;
|
| 490 |
+
position.x = (Math.random() - 0.5) * 0.5 * scaleFactor;
|
| 491 |
+
position.z = (Math.random() - 0.5) * 0.5 * scaleFactor;
|
| 492 |
+
lastY += height * 0.9;
|
| 493 |
+
stackCount++; isValidPosition = true;
|
| 494 |
+
} break;
|
| 495 |
+
case 'center_stack':
|
| 496 |
+
if (stackCount < stackHeight) {
|
| 497 |
+
position.y = lastY + height / 2;
|
| 498 |
+
lastY += height * 0.95;
|
| 499 |
+
stackCount++; isValidPosition = true;
|
| 500 |
+
} break;
|
| 501 |
+
case 'cluster':
|
| 502 |
+
const angle = Math.random() * Math.PI * 2;
|
| 503 |
+
const radius = Math.random() * clusterRadius;
|
| 504 |
+
position.x = Math.cos(angle) * radius;
|
| 505 |
+
position.z = Math.sin(angle) * radius;
|
| 506 |
+
position.y = height / 2; isValidPosition = true; break;
|
| 507 |
+
case 'patch':
|
| 508 |
+
const pAngle = Math.random() * Math.PI * 2;
|
| 509 |
+
const pRadius = Math.random() * patchRadius;
|
| 510 |
+
position.x = patchPos.x + Math.cos(pAngle) * pRadius;
|
| 511 |
+
position.z = patchPos.z + Math.sin(pAngle) * pRadius;
|
| 512 |
+
position.y = (patchPos.y || 0) + height / 2; isValidPosition = true; break;
|
| 513 |
+
case 'scatter': default:
|
| 514 |
+
position.x = (Math.random() - 0.5) * baseSize * 0.8;
|
| 515 |
+
position.z = (Math.random() - 0.5) * baseSize * 0.8;
|
| 516 |
+
position.y = height / 2; isValidPosition = true; break;
|
|
|
|
| 517 |
}
|
| 518 |
|
| 519 |
+
if (!isValidPosition) continue;
|
| 520 |
|
| 521 |
mesh.position.set(position.x, position.y, position.z);
|
| 522 |
mesh.rotation.set(
|
|
|
|
| 537 |
}
|
| 538 |
group.add(mesh);
|
| 539 |
}
|
| 540 |
+
console.log(`Assembly created with ${group.children.length -1} procedural objects.`);
|
| 541 |
return group;
|
| 542 |
}
|
| 543 |
|
| 544 |
function updateScene(assemblyParams) {
|
| 545 |
console.log("updateScene called");
|
| 546 |
+
activeTimeouts.forEach(id => clearTimeout(id)); // Clear pending timeouts
|
| 547 |
+
activeTimeouts = [];
|
| 548 |
+
|
| 549 |
if (currentSceneGroup) {
|
| 550 |
+
scene.remove(currentSceneGroup); // Remove group cleanly
|
| 551 |
+
// Dispose geometries in the removed group
|
| 552 |
+
currentSceneGroup.traverse(child => {
|
| 553 |
+
if (child.isMesh && child.geometry) {
|
| 554 |
+
child.geometry.dispose();
|
| 555 |
+
}
|
| 556 |
+
});
|
| 557 |
currentSceneGroup = null;
|
| 558 |
}
|
| 559 |
+
setupLighting('default');
|
| 560 |
|
| 561 |
if (!assemblyParams) {
|
| 562 |
console.warn("No assemblyParams provided, creating default scene.");
|
|
|
|
| 569 |
console.log("New scene group added.");
|
| 570 |
} catch (error) {
|
| 571 |
console.error("Error creating procedural assembly:", error);
|
|
|
|
| 572 |
}
|
| 573 |
}
|
| 574 |
|
| 575 |
+
|
| 576 |
function startGame() {
|
| 577 |
console.log("startGame called.");
|
| 578 |
const defaultChar = {
|
|
|
|
| 583 |
gameState = {
|
| 584 |
currentPageId: 1,
|
| 585 |
character: JSON.parse(JSON.stringify(defaultChar)),
|
| 586 |
+
combat: null
|
| 587 |
};
|
| 588 |
renderPage(gameState.currentPageId);
|
| 589 |
console.log("startGame finished.");
|
| 590 |
}
|
| 591 |
|
| 592 |
+
function handleChoiceClick(option) { // Renamed parameter for clarity
|
| 593 |
+
console.log("Choice clicked:", option);
|
| 594 |
currentMessage = "";
|
| 595 |
|
| 596 |
+
// Check requirements first
|
| 597 |
+
if (option.requires && !gameState.character.inventory.includes(option.requires)) {
|
| 598 |
+
currentMessage = `<p class="message message-warning">You need a ${option.requires} for that!</p>`;
|
| 599 |
+
renderPage(gameState.currentPageId); // Re-render current page with message
|
| 600 |
+
return;
|
| 601 |
+
}
|
| 602 |
|
| 603 |
+
// Consume item if required and successful (or if action doesn't fail)
|
| 604 |
+
let consumedItem = false;
|
| 605 |
+
if (option.requires && option.consume) {
|
| 606 |
+
gameState.character.inventory = gameState.character.inventory.filter(i => i !== option.requires);
|
| 607 |
+
currentMessage += `<p class="message message-item">Used your ${option.requires}.</p>`;
|
| 608 |
+
consumedItem = true; // Mark as consumed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
}
|
| 610 |
|
| 611 |
+
// Handle different action types
|
| 612 |
+
if (option.action === 'triggerCombat' && option.enemy) {
|
| 613 |
startCombat(option.enemy, option.nextOnWin, option.nextOnLoss);
|
| 614 |
+
// Combat flow handles the next steps and UI render
|
| 615 |
+
} else if (option.check) {
|
|
|
|
| 616 |
performSkillCheck(option.check, option.next, option.onFailure);
|
| 617 |
+
// Skill check flow handles the next steps and UI render
|
| 618 |
+
} else if (option.next) {
|
| 619 |
+
// Simple navigation or action leading to next page
|
| 620 |
+
let nextPageId = parseInt(option.next);
|
| 621 |
+
const targetPageData = gameData[nextPageId];
|
| 622 |
+
|
| 623 |
+
if (!targetPageData) {
|
| 624 |
+
console.error(`Invalid next page ID: ${nextPageId}`);
|
| 625 |
+
currentMessage += `<p class="message message-warning">That path is mysteriously blocked!</p>`;
|
| 626 |
+
nextPageId = gameState.currentPageId; // Stay on current page
|
| 627 |
+
}
|
| 628 |
|
| 629 |
+
// Apply rewards associated *with this specific choice*
|
| 630 |
+
if (option.reward) {
|
| 631 |
+
applyReward(option.reward);
|
| 632 |
+
}
|
| 633 |
|
| 634 |
+
gameState.currentPageId = nextPageId;
|
| 635 |
+
renderPage(nextPageId); // Render the next page
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
|
| 637 |
+
} else {
|
| 638 |
+
console.warn("Choice has no action or next page:", option);
|
| 639 |
+
currentMessage += `<p class="message message-info">Nothing seems to happen...</p>`;
|
| 640 |
+
renderPage(gameState.currentPageId); // Re-render current page
|
| 641 |
+
}
|
| 642 |
}
|
| 643 |
+
window.handleChoiceClick = handleChoiceClick; // Expose for inline handlers
|
| 644 |
|
| 645 |
function performSkillCheck(checkData, successPageId, failurePageId) {
|
| 646 |
const {stat, dc} = checkData;
|
|
|
|
| 648 |
const modifier = Math.floor((baseStat - 10) / 2);
|
| 649 |
const roll = Math.floor(Math.random() * 20) + 1;
|
| 650 |
const total = roll + modifier;
|
| 651 |
+
const success = roll === 20 || (roll !== 1 && total >= dc); // Nat 20 success, Nat 1 fail
|
| 652 |
|
| 653 |
console.log(`Skill Check ${stat}: Roll ${roll} + Mod ${modifier} = ${total} vs DC ${dc}`);
|
| 654 |
currentMessage += `<p class="message message-info"><em>Rolling ${stat}... (Rolled ${total} vs DC ${dc})</em></p>`;
|
| 655 |
+
displayDiceRoll(roll, success);
|
| 656 |
|
| 657 |
if (success) {
|
| 658 |
currentMessage += `<p class="message message-success"><em>Success!</em></p>`;
|
|
|
|
| 661 |
currentMessage += `<p class="message message-failure"><em>Failed!</em></p>`;
|
| 662 |
gameState.currentPageId = failurePageId;
|
| 663 |
}
|
| 664 |
+
|
| 665 |
+
// Add delay before showing result page
|
| 666 |
+
activeTimeouts.push(setTimeout(() => renderPage(gameState.currentPageId), 2600)); // Longer delay
|
| 667 |
}
|
| 668 |
|
| 669 |
function applyReward(rewardData) {
|
| 670 |
+
// Refactored reward application
|
| 671 |
+
if(!rewardData) return;
|
| 672 |
if(rewardData.addItem && itemsData[rewardData.addItem]) {
|
| 673 |
if (!gameState.character.inventory.includes(rewardData.addItem)) {
|
| 674 |
gameState.character.inventory.push(rewardData.addItem);
|
| 675 |
+
currentMessage += `<p class="message message-item">You got a ${rewardData.addItem}!</p>`;
|
| 676 |
} else {
|
| 677 |
+
currentMessage += `<p class="message message-info">You found another ${rewardData.addItem}.</p>`;
|
| 678 |
}
|
| 679 |
}
|
| 680 |
if(rewardData.xp) {
|
| 681 |
gameState.character.stats.xp += rewardData.xp;
|
| 682 |
+
currentMessage += `<p class="message message-xp">Gained ${rewardData.xp} XP!</p>`;
|
| 683 |
}
|
| 684 |
if(rewardData.hpGain) {
|
| 685 |
gameState.character.stats.hp = Math.min(gameState.character.stats.maxHp, gameState.character.stats.hp + rewardData.hpGain);
|
| 686 |
+
currentMessage += `<p class="message message-success">Recovered ${rewardData.hpGain} HP.</p>`;
|
| 687 |
}
|
| 688 |
+
if(rewardData.statGain) {
|
| 689 |
for (const stat in rewardData.statGain) {
|
| 690 |
if (gameState.character.stats.hasOwnProperty(stat)) {
|
| 691 |
+
const increase = rewardData.statGain[stat];
|
| 692 |
+
gameState.character.stats[stat] += increase;
|
| 693 |
+
currentMessage += `<p class="message message-success">${stat.charAt(0).toUpperCase() + stat.slice(1)} increased by ${increase}!</p>`;
|
| 694 |
+
if (stat === 'maxHp' && increase > 0) { // Also heal when maxHP increases
|
| 695 |
+
gameState.character.stats.hp += increase;
|
| 696 |
}
|
| 697 |
+
// Potentially recalculate maxHP if constitution changes (add recalculateMaxHp function if needed)
|
| 698 |
}
|
| 699 |
}
|
| 700 |
}
|
| 701 |
}
|
| 702 |
|
|
|
|
| 703 |
function renderPage(pageId) {
|
| 704 |
console.log(`Rendering page ${pageId}`);
|
| 705 |
const pageData = gameData[pageId];
|
| 706 |
|
| 707 |
+
if (!pageData) { /* Error handling */ console.error(`No page data for ID: ${pageId}`); return; }
|
| 708 |
|
| 709 |
storyTitleElement.textContent = pageData.title || "An Unnamed Place";
|
| 710 |
storyContentElement.innerHTML = currentMessage + (pageData.content || "<p>...</p>");
|
| 711 |
choicesElement.innerHTML = '';
|
| 712 |
+
actionChoicesElement.innerHTML = '';
|
| 713 |
|
| 714 |
+
// Navigation Buttons - Always show 4, disable if no exit
|
| 715 |
+
const neighbors = {}; // For page-based, neighbours aren't fixed grid, maybe infer from options? Or ignore for now.
|
| 716 |
+
const navOptions = pageData.options.filter(opt => opt.text.toLowerCase().includes("go ")); // Basic filter for nav text
|
| 717 |
+
['North', 'South', 'East', 'West'].forEach(dir => {
|
|
|
|
| 718 |
const button = document.createElement('button');
|
| 719 |
button.classList.add('choice-button');
|
| 720 |
+
button.textContent = `Go ${dir}`;
|
| 721 |
+
// Find if an option corresponds to this direction (simplistic check)
|
| 722 |
+
const matchingOption = pageData.options.find(opt => opt.text.toLowerCase().includes(`(${dir.toLowerCase()})`) || opt.text.toLowerCase().includes(`go ${dir.toLowerCase()}`));
|
| 723 |
+
if (matchingOption) {
|
| 724 |
+
button.onclick = () => handleChoiceClick(matchingOption);
|
| 725 |
+
// Check requirements for this specific nav option
|
| 726 |
+
if (matchingOption.requires && !gameState.character.inventory.includes(matchingOption.requires)) {
|
| 727 |
+
button.disabled = true;
|
| 728 |
+
button.title = `Requires: ${matchingOption.requires}`;
|
| 729 |
+
}
|
| 730 |
+
} else {
|
| 731 |
+
button.disabled = true; // Disable if no matching option found
|
| 732 |
+
}
|
| 733 |
choicesElement.appendChild(button);
|
| 734 |
+
});
|
|
|
|
| 735 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
|
| 737 |
+
// Action Buttons (Non-navigational)
|
| 738 |
+
if (pageData.options) {
|
| 739 |
+
pageData.options.filter(opt => !opt.text.toLowerCase().includes("go ")).forEach(option => { // Filter out nav buttons
|
| 740 |
+
const button = document.createElement('button');
|
| 741 |
+
button.classList.add('choice-button', 'action');
|
| 742 |
+
if (option.action === 'triggerCombat') button.classList.add('combat-button'); // Style combat triggers
|
| 743 |
+
button.textContent = option.text;
|
| 744 |
+
|
| 745 |
+
let requirementMet = true;
|
| 746 |
+
if (option.requires && !gameState.character.inventory.includes(option.requires)) {
|
| 747 |
+
requirementMet = false;
|
| 748 |
+
button.title = `Requires: ${option.requires}`;
|
| 749 |
+
}
|
| 750 |
+
button.disabled = !requirementMet;
|
| 751 |
+
|
| 752 |
+
button.onclick = () => handleChoiceClick(option);
|
| 753 |
+
actionChoicesElement.appendChild(button);
|
| 754 |
+
});
|
| 755 |
+
}
|
| 756 |
+
if (actionChoicesElement.innerHTML === '') {
|
| 757 |
+
actionChoicesElement.innerHTML = '<p><i>Nothing else seems possible here.</i></p>';
|
| 758 |
+
}
|
| 759 |
|
| 760 |
+
// Combat UI
|
| 761 |
+
if (gameState.combat?.active) {
|
| 762 |
+
actionChoicesElement.innerHTML += `
|
| 763 |
+
<div id="combat-ui">
|
| 764 |
+
<p class="message message-combat">Fighting ${gameState.combat.enemyName}! (Enemy HP: ${gameState.combat.enemyHp})</p>
|
| 765 |
+
<button class="choice-button combat-button action" onclick="handleCombatAction('attack')">Attack!</button>
|
| 766 |
+
</div>`;
|
| 767 |
}
|
| 768 |
|
| 769 |
+
|
| 770 |
updateStatsDisplay();
|
| 771 |
updateInventoryDisplay();
|
| 772 |
updateActionInfo();
|
| 773 |
updateScene(pageData.assemblyParams);
|
| 774 |
}
|
|
|
|
|
|
|
| 775 |
|
| 776 |
function updateStatsDisplay() {
|
| 777 |
+
if (!gameState.character || !statsElement) return;
|
| 778 |
+
const stats = gameState.character.stats;
|
| 779 |
+
const hpColor = stats.hp / stats.maxHp < 0.3 ? '#f88' : (stats.hp / stats.maxHp < 0.6 ? '#fd5' : '#8f8');
|
| 780 |
+
const statBonus = (statVal) => {
|
| 781 |
+
const mod = Math.floor((statVal - 10) / 2);
|
| 782 |
+
return `${statVal} (${mod >= 0 ? '+' : ''}${mod})`;
|
| 783 |
+
};
|
| 784 |
+
statsElement.innerHTML = `<strong>Stats:</strong>
|
| 785 |
+
<span style="color:${hpColor}">HP: ${stats.hp}/${stats.maxHp}</span> <span>XP: ${stats.xp}</span><br>
|
| 786 |
+
<span>Str: ${statBonus(stats.strength)}</span> <span>Dex: ${statBonus(stats.dexterity)}</span> <span>Con: ${statBonus(stats.constitution)}</span>
|
| 787 |
+
<span>Int: ${statBonus(stats.intelligence)}</span> <span>Wis: ${statBonus(stats.wisdom)}</span> <span>Cha: ${statBonus(stats.charisma)}</span>`;
|
| 788 |
+
}
|
|
|
|
| 789 |
|
| 790 |
function updateInventoryDisplay() {
|
| 791 |
if (!gameState.character || !inventoryElement) return;
|
|
|
|
| 808 |
actionInfoElement.textContent = `Location: ${gameData[gameState.currentPageId]?.title || 'Unknown'} | ${mode}`;
|
| 809 |
}
|
| 810 |
|
| 811 |
+
// --- Combat & Item Functions ---
|
| 812 |
function startCombat(enemyTypeId, nextOnWin, nextOnLoss) {
|
| 813 |
const enemyBase = enemyData[enemyTypeId];
|
| 814 |
if (!enemyBase) { console.error("Unknown enemy type:", enemyTypeId); return; }
|
| 815 |
gameState.combat = {
|
| 816 |
active: true, enemyId: enemyTypeId, enemyName: enemyBase.name,
|
| 817 |
enemyHp: enemyBase.hp, enemyMaxHp: enemyBase.hp,
|
| 818 |
+
enemyDefense: enemyBase.defense, enemyAttackBonus: enemyBase.attackBonus || 2,
|
| 819 |
enemyDamageDice: enemyBase.damageDice || 4, enemyXp: enemyBase.xp,
|
| 820 |
enemyDrops: enemyBase.drops || [],
|
| 821 |
nextOnWin: nextOnWin, nextOnLoss: nextOnLoss
|
| 822 |
};
|
| 823 |
+
currentMessage = `<p class="message message-combat">Watch out! A ${enemyBase.name} attacks!</p>`;
|
| 824 |
+
renderPage(gameState.currentPageId); // Re-render to show combat UI
|
| 825 |
}
|
| 826 |
|
| 827 |
function handleCombatAction(action) {
|
| 828 |
if (!gameState.combat?.active) return;
|
| 829 |
+
currentMessage = "";
|
| 830 |
|
| 831 |
if (action === 'attack') {
|
| 832 |
// Player Attack
|
|
|
|
| 834 |
const playerAtkBonus = Math.floor((gameState.character.stats.strength - 10) / 2);
|
| 835 |
const playerTotalAttack = playerRoll + playerAtkBonus;
|
| 836 |
const playerHit = playerRoll === 20 || (playerRoll !== 1 && playerTotalAttack >= gameState.combat.enemyDefense);
|
| 837 |
+
displayDiceRoll(playerRoll, playerHit);
|
|
|
|
| 838 |
|
| 839 |
if (playerHit) {
|
| 840 |
const weapon = gameState.character.inventory.find(i => itemsData[i]?.type === 'weapon');
|
| 841 |
+
const baseDamage = itemsData[weapon]?.baseDamage || 2; // Use weapon or 1d2 for unarmed
|
| 842 |
+
const damageRoll = Math.max(1, Math.floor(Math.random() * baseDamage) + 1 + playerAtkBonus); // Add STR mod to damage
|
| 843 |
gameState.combat.enemyHp -= damageRoll;
|
| 844 |
+
currentMessage += `<p class="message message-success">You hit for ${damageRoll} damage! (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
|
| 845 |
} else {
|
| 846 |
+
currentMessage += `<p class="message message-failure">You missed. (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
|
| 847 |
}
|
| 848 |
|
| 849 |
// Check Enemy Defeat
|
| 850 |
if (gameState.combat.enemyHp <= 0) {
|
| 851 |
currentMessage += `<p class="message message-success"><b>You defeated the ${gameState.combat.enemyName}!</b></p>`;
|
| 852 |
+
applyReward({ xp: gameState.combat.enemyXp }); // Apply XP reward
|
| 853 |
+
// Handle Drops
|
| 854 |
if (gameState.combat.enemyDrops.length > 0) {
|
| 855 |
const droppedItemName = gameState.combat.enemyDrops[Math.floor(Math.random() * gameState.combat.enemyDrops.length)];
|
| 856 |
dropItemInScene(droppedItemName, new THREE.Vector3(Math.random()*2-1, 0.2, Math.random()*2-1));
|
| 857 |
currentMessage += `<p class="message message-item"><em>The ${gameState.combat.enemyName} dropped a ${droppedItemName}! Click it to pick up.</em></p>`;
|
| 858 |
}
|
| 859 |
const winPage = gameState.combat.nextOnWin;
|
| 860 |
+
gameState.combat = null;
|
| 861 |
+
gameState.currentPageId = winPage;
|
| 862 |
+
setTimeout(() => renderPage(gameState.currentPageId), 500); // Short delay after win message
|
| 863 |
return;
|
| 864 |
}
|
| 865 |
|
|
|
|
| 869 |
const playerAC = 10 + Math.floor((gameState.character.stats.dexterity - 10) / 2);
|
| 870 |
const enemyHit = enemyRoll === 20 || (enemyRoll !== 1 && enemyTotalAttack >= playerAC);
|
| 871 |
|
| 872 |
+
activeTimeouts.push( setTimeout(() => displayDiceRoll(enemyRoll, enemyHit), 600) ); // Display enemy roll slightly later
|
|
|
|
| 873 |
|
| 874 |
if (enemyHit) {
|
| 875 |
const damageRoll = Math.max(1, Math.floor(Math.random() * gameState.combat.enemyDamageDice) + 1);
|
|
|
|
| 878 |
if (gameState.character.stats.hp <= 0) {
|
| 879 |
currentMessage += `<p class="message message-failure"><b>You have been defeated!</b></p>`;
|
| 880 |
const lossPage = gameState.combat.nextOnLoss;
|
| 881 |
+
gameState.combat = null;
|
| 882 |
+
gameState.currentPageId = lossPage;
|
| 883 |
+
activeTimeouts.push( setTimeout(() => renderPage(gameState.currentPageId), 2600) ); // Delay transition
|
| 884 |
return;
|
| 885 |
}
|
| 886 |
} else {
|
| 887 |
currentMessage += `<p class="message message-info">The ${gameState.combat.enemyName} misses you. (Rolled ${enemyTotalAttack} vs AC ${playerAC})</p>`;
|
| 888 |
}
|
| 889 |
+
activeTimeouts.push( setTimeout(() => renderPage(gameState.currentPageId), 2600) ); // Re-render UI after delay
|
| 890 |
}
|
| 891 |
}
|
| 892 |
+
window.handleCombatAction = handleCombatAction;
|
| 893 |
|
| 894 |
+
function displayDiceRoll(result, success) {
|
| 895 |
+
if (!threeFont) { return; }
|
| 896 |
activeTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); activeTimeouts = [];
|
| 897 |
+
scene.children.filter(c => c.userData?.isDiceRoll).forEach(c => scene.remove(c));
|
| 898 |
|
| 899 |
const textGeo = new TextGeometry(result.toString(), { font: threeFont, size: 1.0, height: 0.1, curveSegments: 4 });
|
| 900 |
+
textGeo.computeBoundingBox(); textGeo.center(); // Center geometry
|
|
|
|
| 901 |
const textMat = MAT.text_material.clone();
|
| 902 |
textMat.color.setHex(success ? 0x88ff88 : 0xff8888);
|
| 903 |
+
textMat.opacity = 1.0; // Start fully opaque
|
| 904 |
|
| 905 |
const textMesh = new THREE.Mesh(textGeo, textMat);
|
| 906 |
textMesh.userData.isDiceRoll = true;
|
| 907 |
|
| 908 |
+
const distance = 5; // Distance in front of camera
|
| 909 |
+
const cameraDirection = camera.getWorldDirection(new THREE.Vector3());
|
| 910 |
+
const textPos = camera.position.clone().add(cameraDirection.multiplyScalar(distance));
|
| 911 |
textMesh.position.copy(textPos);
|
| 912 |
+
textMesh.position.y += 1.2; // Position slightly higher
|
| 913 |
+
textMesh.quaternion.copy(camera.quaternion); // Face camera
|
|
|
|
| 914 |
|
| 915 |
scene.add(textMesh);
|
| 916 |
|
| 917 |
+
const duration = 2.5;
|
| 918 |
+
const fadeStart = 1.5; // Start fading after 1.5s
|
| 919 |
const startTime = clock.getElapsedTime();
|
| 920 |
textMesh.userData.startTime = startTime;
|
| 921 |
textMesh.userData.update = (time) => {
|
|
|
|
| 923 |
if (elapsed >= duration) {
|
| 924 |
if (textMesh.parent) textMesh.parent.remove(textMesh);
|
| 925 |
delete textMesh.userData.update;
|
| 926 |
+
// Remove from timeout tracking - not strictly needed as it stops updating
|
| 927 |
} else {
|
| 928 |
+
textMesh.position.y += 0.015; // Float up
|
| 929 |
+
if (elapsed > fadeStart) {
|
| 930 |
+
textMesh.material.opacity = 1.0 - ((elapsed - fadeStart) / (duration - fadeStart));
|
| 931 |
+
}
|
| 932 |
}
|
| 933 |
};
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
|
| 937 |
function dropItemInScene(itemName, positionOffset = new THREE.Vector3(0, 0, 0)) {
|
| 938 |
+
const currentGroup = currentSceneGroup; // Drop relative to current scene group
|
| 939 |
if (!currentGroup || !itemsData[itemName]) return;
|
| 940 |
|
| 941 |
const itemDef = itemsData[itemName];
|
| 942 |
+
const itemGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4);
|
| 943 |
const itemMat = MAT.simple.clone();
|
| 944 |
if(itemDef.type === 'weapon') itemMat.color.setHex(0xcc6666);
|
| 945 |
else if(itemDef.type === 'consumable') itemMat.color.setHex(0xcc9966);
|
|
|
|
| 953 |
console.log(`Dropped ${itemName} in scene`);
|
| 954 |
}
|
| 955 |
|
| 956 |
+
function pickupItem() {
|
| 957 |
+
if (gameState.combat?.active) return;
|
| 958 |
|
| 959 |
raycaster.setFromCamera(mouse, camera);
|
| 960 |
const currentGroup = currentSceneGroup;
|
| 961 |
if (!currentGroup) return;
|
| 962 |
|
| 963 |
const pickupables = [];
|
| 964 |
+
currentGroup.traverseVisible(child => { // Only check visible objects
|
| 965 |
if (child.userData.isPickupable) pickupables.push(child);
|
| 966 |
});
|
| 967 |
|
|
|
|
| 978 |
if (!gameState.character.inventory.includes(itemName)) {
|
| 979 |
gameState.character.inventory.push(itemName);
|
| 980 |
} else {
|
| 981 |
+
currentMessage += `<p class="message message-info"><em>(You already have a ${itemName})</em></p>`;
|
| 982 |
}
|
| 983 |
|
|
|
|
| 984 |
if(clickedObject.parent) clickedObject.parent.remove(clickedObject);
|
| 985 |
if(clickedObject.geometry) clickedObject.geometry.dispose();
|
|
|
|
| 986 |
|
| 987 |
+
renderCurrentPageUI();
|
| 988 |
}
|
| 989 |
}
|
| 990 |
}
|
| 991 |
|
| 992 |
+
// --- Initialization ---
|
| 993 |
document.addEventListener('DOMContentLoaded', () => {
|
| 994 |
console.log("DOM Ready - Initializing Wacky D&D Shapes Adventure!");
|
| 995 |
try {
|
|
|
|
| 1002 |
storyTitleElement.textContent = "Initialization Error";
|
| 1003 |
storyContentElement.innerHTML = `<p style="color:red;">Failed to start game:</p><pre style="color:red; white-space: pre-wrap;">${error.stack || error}</pre><p style="color:yellow;">Check console (F12) for details.</p>`;
|
| 1004 |
if(sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding: 20px;">3D Scene Failed</p>';
|
| 1005 |
+
const statsInvContainer = document.getElementById('stats-inventory-container');
|
| 1006 |
+
const choicesCont = document.getElementById('choices-container');
|
| 1007 |
+
if (statsInvContainer) statsInvContainer.style.display = 'none';
|
| 1008 |
+
if (choicesCont) choicesCont.style.display = 'none';
|
| 1009 |
}
|
| 1010 |
});
|
| 1011 |
|