awacke1 commited on
Commit
1aff277
·
verified ·
1 Parent(s): 1538158

Create index.html

Browse files
Files changed (1) hide show
  1. index.html +990 -0
index.html ADDED
@@ -0,0 +1,990 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
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!</title>
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;} /* Orchid/Violet */
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;} /* Grid for nav */
35
+ #action-choices { display: flex; flex-direction: column; gap: 12px; margin-top: 15px; border-top: 1px dashed #607080; padding-top: 15px;} /* Separate area for actions */
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;} /* Action buttons span full width */
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; }
41
+ .message { padding: 8px 12px; margin: 10px 0; border-left-width: 4px; border-left-style: solid; font-size: 1em; background-color: rgba(0, 0, 0, 0.1); border-radius: 3px; }
42
+ .message-info { color: #ccc; border-left-color: #666; }
43
+ .message-item { color: #9cf; border-left-color: #48a; }
44
+ .message-xp { color: #af8; border-left-color: #6a4; }
45
+ .message-warning { color: #f99; border-left-color: #c66; }
46
+ .message-success { color: #8f8; border-left-color: #4a4; }
47
+ .message-failure { color: #f88; border-left-color: #a44; }
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>
56
+ <body>
57
+ <div id="game-container">
58
+ <div id="scene-container">
59
+ <div id="action-info">Initializing...</div>
60
+ </div>
61
+ <div id="ui-container">
62
+ <h2 id="story-title">Initializing...</h2>
63
+ <div id="story-content"><p>Loading world...</p></div>
64
+ <div id="stats-inventory-container">
65
+ <div id="stats-display">Loading Stats...</div>
66
+ <div id="inventory-display">Inventory: ...</div>
67
+ </div>
68
+ <div id="choices-container">
69
+ <h3>Navigation</h3>
70
+ <div id="choices">Loading...</div>
71
+ <div id="action-choices"></div>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <script type="importmap">
77
+ { "imports": {
78
+ "three": "https://unpkg.com/[email protected]/build/three.module.js",
79
+ "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
80
+ }}
81
+ </script>
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
+
91
+ // --- DOM Elements ---
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'); // Nav buttons grid
96
+ const actionChoicesElement = document.getElementById('action-choices'); // Action buttons list
97
+ const statsElement = document.getElementById('stats-display');
98
+ const inventoryElement = document.getElementById('inventory-display');
99
+ const actionInfoElement = document.getElementById('action-info');
100
+
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 = {
112
+ stone_grey: new THREE.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 0.8 }),
113
+ stone_brown: new THREE.MeshStandardMaterial({ color: 0x9d8468, roughness: 0.85 }),
114
+ wood_light: new THREE.MeshStandardMaterial({ color: 0xcdaa7d, roughness: 0.7 }),
115
+ wood_dark: new THREE.MeshStandardMaterial({ color: 0x6f4e2d, roughness: 0.75 }),
116
+ leaf_green: new THREE.MeshStandardMaterial({ color: 0x4caf50, roughness: 0.6, side: THREE.DoubleSide }),
117
+ leaf_autumn: new THREE.MeshStandardMaterial({ color: 0xffa040, roughness: 0.65, side: THREE.DoubleSide }),
118
+ ground_dirt: new THREE.MeshStandardMaterial({ color: 0x8b5e3c, roughness: 0.9 }),
119
+ ground_grass: new THREE.MeshStandardMaterial({ color: 0x558b2f, roughness: 0.85 }),
120
+ metal_shiny: new THREE.MeshStandardMaterial({ color: 0xc0c0c0, roughness: 0.2, metalness: 0.8 }),
121
+ metal_rusty: new THREE.MeshStandardMaterial({ color: 0xb7410e, roughness: 0.9, metalness: 0.2 }),
122
+ crystal_blue: new THREE.MeshStandardMaterial({ color: 0x87cefa, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.8, emissive:0x205080, emissiveIntensity: 0.4 }),
123
+ crystal_red: new THREE.MeshStandardMaterial({ color: 0xff7f7f, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.8, emissive:0x802020, emissiveIntensity: 0.4 }),
124
+ bright_yellow: new THREE.MeshStandardMaterial({ color: 0xffeb3b, roughness: 0.6 }),
125
+ deep_purple: new THREE.MeshStandardMaterial({ color: 0x9c27b0, roughness: 0.7 }),
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 }), // DarkSeaGreen
130
+ text_material: new THREE.MeshBasicMaterial({ color: 0xffddaa }),
131
+ };
132
+
133
+ // --- Game State ---
134
+ let gameState = {};
135
+
136
+ // --- Item Data ---
137
+ const itemsData = {
138
+ "Sturdy Stick": {type:"tool", description:"Surprisingly pointy."},
139
+ "Wobbly Key": {type:"key", description:"Might fit a wobbly lock?"},
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
+ // Add more enemies later
149
+ };
150
+
151
+ // --- Procedural Assembly Shapes ---
152
+ const SHAPE_GENERATORS = {
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),
156
+ 'boxy_chunk': (size) => new THREE.BoxGeometry(size, size * (0.8 + Math.random() * 0.4), size * (0.8 + Math.random() * 0.4)),
157
+ 'spiky_ball': (size) => new THREE.IcosahedronGeometry(size * 0.7, 0),
158
+ 'flat_plate': (size) => new THREE.BoxGeometry(size * 1.5, size * 0.1, size * 1.5),
159
+ 'tall_cylinder': (size) => new THREE.CylinderGeometry(size * 0.3, size * 0.3, size * 2.0, 8),
160
+ 'knotty_thing': (size) => new THREE.TorusKnotGeometry(size * 0.4, size * 0.1, 50, 8),
161
+ 'gem_shape': (size) => new THREE.OctahedronGeometry(size * 0.6, 0),
162
+ 'basic_tetra': (size) => new THREE.TetrahedronGeometry(size * 0.7, 0),
163
+ 'squashed_ball': (size) => new THREE.SphereGeometry(size * 0.6, 12, 8).scale(1, 0.6, 1),
164
+ 'holed_box': (size) => {
165
+ const shape = new THREE.Shape();
166
+ shape.moveTo(-size/2, -size/2); shape.lineTo(-size/2, size/2); shape.lineTo(size/2, size/2); shape.lineTo(size/2, -size/2); shape.closePath();
167
+ const hole = new THREE.Path();
168
+ hole.absellipse(0, 0, size*0.2, size*0.2, 0, Math.PI*2, false);
169
+ shape.holes.push(hole);
170
+ return new THREE.ExtrudeGeometry(shape, {depth: size*0.3, bevelEnabled: false});
171
+ }
172
+ };
173
+ const shapeKeys = Object.keys(SHAPE_GENERATORS);
174
+
175
+ // --- Game Data (Page-Based, Expanded Story) ---
176
+ const gameData = {
177
+ 1: {
178
+ title: "Snoring Meadows",
179
+ content: "<p>You wake up in a field of gently snoring grass. Yup, snoring. To the North are some Wiggly Woods. East leads to the Clanky Caves entrance.</p>",
180
+ options: [
181
+ { text: "Enter Wiggly Woods (North)", next: 2 },
182
+ { text: "Approach Clanky Caves (East)", next: 5 },
183
+ { text: "Poke the snoring grass (Wisdom Check DC 10)", check: { stat: 'wisdom', dc: 10, next: 10, onFailure: 11 } }
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 really do wiggle! It's making you a bit dizzy. A grumpy-looking goblin blocks the path deeper into the woods (South?).</p>",
190
+ options: [
191
+ { text: "Talk to the Goblin", next: 3 },
192
+ { text: "Try to sneak past (Dexterity Check DC 12)", check: { stat: 'dexterity', dc: 12, next: 4, onFailure: 3 } },
193
+ { text: "Go Back (South)", next: 1 }
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! You wanna pass?' grunts the goblin, pointing a wobbly spear. 'Dis my wood! Whatcha gonna do?'</p>",
200
+ options: [
201
+ { text: "Fight the Grumpy Goblin!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 },
202
+ { text: "Offer him a Shiny Rock (Requires Shiny Rock)", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 10} },
203
+ { text: "Try to reason with him (Charisma Check DC 14)", check: { stat: 'charisma', dc: 14, next: 4, onFailure: 12 } },
204
+ { text: "Run Away!", next: 2 }
205
+ ],
206
+ assemblyParams: { // Similar to woods, maybe add goblin representation
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: "Past the Goblin!",
216
+ content: "<p>You somehow got past the goblin! The path continues North to a weirdly Square Clearing. Or you can go back South.</p>",
217
+ options: [
218
+ { text: "Go to Square Clearing (North)", next: 7 },
219
+ { text: "Go Back (South)", next: 2 }
220
+ ],
221
+ assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder'], accents: ['leaf_green'], count: 15, scaleRange: [1.0, 2.5], colorTheme: [MAT.wood_light, MAT.leaf_green], arrangement: 'scatter' }
222
+ },
223
+ 5: {
224
+ title: "Clanky Caves Entrance",
225
+ content: "<p>The entrance to the cave clanks and whirs softly. It smells like old metal and damp socks. A sign reads 'Beware of... Clanks?'</p>",
226
+ options: [ { text: "Enter the Caves", next: 8 }, { text: "Go Back West", next: 1 } ],
227
+ assemblyParams: { baseShape: 'stone_grey', baseSize: 20, mainShapes: ['spiky_ball', 'spinny_torus'], accents: ['metal_rusty', 'basic_tetra'], count: 25, scaleRange: [0.5, 1.8], colorTheme: [MAT.stone_grey, MAT.metal_rusty], arrangement: 'cluster', clusterRadius: 8 }
228
+ },
229
+ 7: { // Added North from page 4
230
+ title: "The Square Clearing",
231
+ content: "<p>This clearing is unnervingly square. Even the rocks look cubic! In the center is a perfectly square chest with a wobbly-looking lock.</p>",
232
+ options: [
233
+ { text: "Try the Wobbly Key (Requires Wobbly Key)", requires: "Wobbly Key", consume: true, next: 13 },
234
+ { text: "Whack it with a Stick (Requires Sturdy Stick)", requires: "Sturdy Stick", next: 14 },
235
+ { text: "Go Back South", next: 4 }
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! The cave is full of noisy, spinning metal things. Passages lead West and East.</p>",
242
+ options: [ { text: "Go West (Deeper?)", next: 9 }, { text: "Go East (Exit?)", next: 5 } ],
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>You found a pile of shiny sprockets! And maybe some suspicious mushrooms growing nearby.</p>",
248
+ options: [ { text: "Take a Sprocket!", reward: { addItem: "Shiny Sprocket", xp: 10 }, next: 8 }, { text: "Eat a Mushroom?", next: 15 }, { text: "Go Back East", next: 8 } ],
249
+ 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 }
250
+ },
251
+ 10: { // Success poking grass
252
+ title: "Grass Tickled!",
253
+ content: "<p>You gently poke the snoring grass. It giggles and coughs up a Wobbly Key! Weird.</p>",
254
+ options: [ { text: "Grab the Key!", reward: { addItem: "Wobbly Key", xp: 5 }, next: 1 } ],
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 Bites Back!",
259
+ content: "<p>Ouch! You poked too hard. The grass blade snapped back like a tiny whip! (-1 HP)</p>",
260
+ options: [ { text: "Okay, fine, leave it alone.", next: 1 } ],
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 Unimpressed",
266
+ content: "<p>'Nice try, smooth-talker!' the goblin sneers, waving his spear more menacingly. 'Now what?'</p>",
267
+ options: [
268
+ { text: "Fight him!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 },
269
+ { text: "Offer a Shiny Rock (If you have one)", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 10} },
270
+ { text: "Run Away!", next: 2 }
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>Click! The wobbly key fits the wobbly lock! Inside you find... another Shiny Rock?</p>",
277
+ options: [ { text: "Take the rock!", reward: { addItem: "Shiny Rock", xp: 15 }, next: 7 } ],
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
+ 14: { // Whack chest
281
+ title: "Thwack!",
282
+ content: "<p>You whack the square chest with your Sturdy Stick. It makes a dull *thud*. Nothing happens, but the stick feels slightly less sturdy.</p>",
283
+ options: [ { text: "Try the Wobbly Key (Requires Wobbly Key)", requires: "Wobbly Key", consume: true, next: 13 }, { text: "Give up and go back", next: 7 } ],
284
+ // Maybe add consequence: Sturdy Stick breaks? Requires more state tracking.
285
+ 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 }
286
+ },
287
+ 15: { // Eat Mushroom
288
+ title: "Gulp!",
289
+ content: "<p>You bravely (or foolishly?) eat the Suspicious Mushroom. Your vision goes purple for a second, then... you feel... bouncy! (+5 Max HP! HP Restored!)</p>",
290
+ options: [ { text: "Weird! Go back.", next: 8 } ],
291
+ reward: { statGain: { maxHp: 5 }, hpGain: 5, xp: 5 }, // Example stat gain
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: "Oof!",
296
+ content: "<p>You were defeated! You wake up back where you started, feeling dizzy.</p>",
297
+ options: [ { text: "Okay...", next: 1 } ],
298
+ assemblyParams: { baseShape: 'ground_grass', count: 5, mainShapes:['round_blob'], colorTheme:[MAT.grass]} // Simple scene
299
+ },
300
+ 99: { // Generic End/TBC
301
+ title: "Adventure Paused!",
302
+ content: "<p>That's as far as this path goes for now. What an adventure!</p>",
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
+ }
306
+ };
307
+
308
+ // --- Core Functions ---
309
+
310
+ function initThreeJS() {
311
+ console.log("initThreeJS started.");
312
+ scene = new THREE.Scene();
313
+ scene.background = new THREE.Color(0x2a3a4a);
314
+ clock = new THREE.Clock();
315
+ raycaster = new THREE.Raycaster();
316
+ mouse = new THREE.Vector2();
317
+
318
+ const width = sceneContainer.clientWidth || 1;
319
+ const height = sceneContainer.clientHeight || 1;
320
+ camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
321
+ camera.position.set(0, 8, 15);
322
+ camera.lookAt(0, 1, 0);
323
+
324
+ renderer = new THREE.WebGLRenderer({ antialias: true });
325
+ renderer.setSize(width, height);
326
+ renderer.shadowMap.enabled = true;
327
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
328
+ sceneContainer.appendChild(renderer.domElement);
329
+
330
+ // OrbitControls removed for focus on page-based nav
331
+ // controls = new OrbitControls(camera, renderer.domElement);
332
+
333
+ window.addEventListener('resize', onWindowResize, false);
334
+ renderer.domElement.addEventListener('click', onMouseClick, false); // For pickup
335
+ setTimeout(onWindowResize, 100);
336
+ animate();
337
+ console.log("initThreeJS finished.");
338
+ }
339
+
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; // Ensure font is null if failed
350
+ startGame(); // Start anyway
351
+ });
352
+ }
353
+
354
+
355
+ function onWindowResize() {
356
+ if (!renderer || !camera || !sceneContainer) return;
357
+ const width = sceneContainer.clientWidth || 1;
358
+ const height = sceneContainer.clientHeight || 1;
359
+ if (width > 0 && height > 0) {
360
+ camera.aspect = width / height;
361
+ camera.updateProjectionMatrix();
362
+ renderer.setSize(width, height);
363
+ }
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 pickup is enabled via click now
372
+ }
373
+
374
+
375
+ function animate() {
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 => {
384
+ if (obj.userData.update) obj.userData.update(time, delta);
385
+ });
386
+ }
387
+
388
+ if (renderer && scene && camera) renderer.render(scene, camera);
389
+ }
390
+
391
+ 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}) {
392
+ const mesh = new THREE.Mesh(geometry, material);
393
+ mesh.position.set(pos.x, pos.y, pos.z);
394
+ mesh.rotation.set(rot.x, rot.y, rot.z);
395
+ mesh.scale.set(scale.x, scale.y, scale.z);
396
+ mesh.castShadow = true; mesh.receiveShadow = true;
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); // Brighter ambient for kid-friendly
405
+ scene.add(ambientLight);
406
+ currentLights.push(ambientLight);
407
+
408
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); // Brighter directional
409
+ directionalLight.position.set(8, 12, 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.0005;
417
+ scene.add(directionalLight);
418
+ currentLights.push(directionalLight);
419
+ }
420
+
421
+ function createProceduralAssembly(params) {
422
+ console.log("Creating procedural assembly with params:", params);
423
+ const group = new THREE.Group();
424
+ const {
425
+ baseShape = 'ground_grass', baseSize = 20,
426
+ mainShapes = ['boxy_chunk'], accents = ['pointy_cone'],
427
+ count = 10, scaleRange = [0.5, 1.5],
428
+ colorTheme = [MAT.stone_grey, MAT.wood_light, MAT.leaf_green],
429
+ arrangement = 'scatter',
430
+ stackHeight = 5,
431
+ clusterRadius = 5,
432
+ patchPos = {x:0, y:0, z:0}, patchRadius = 3
433
+ } = params;
434
+
435
+ let baseMesh;
436
+ if (baseShape.startsWith('ground_')) {
437
+ const groundMat = MAT[baseShape] || MAT.ground_grass;
438
+ const groundGeo = new THREE.PlaneGeometry(baseSize, baseSize);
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; // Mark for placement/raycasting
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; // Allow placing on non-plane bases
450
+ group.add(baseMesh);
451
+ }
452
+
453
+ const allShapes = [...mainShapes, ...accents];
454
+ let lastY = 0.1; // Start stacking slightly above base
455
+ let stackCount = 0;
456
+
457
+ for (let i = 0; i < count; i++) {
458
+ if(allShapes.length === 0) break;
459
+ const shapeKey = allShapes[Math.floor(Math.random() * allShapes.length)];
460
+ const geoFunc = SHAPE_GENERATORS[shapeKey];
461
+ if (!geoFunc) { console.warn(`Shape generator missing: ${shapeKey}`); continue; }
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
+ geometry.computeBoundingBox(); // Needed for height calculation
469
+ let height = 0;
470
+ if (geometry.boundingBox) {
471
+ height = (geometry.boundingBox.max.y - geometry.boundingBox.min.y) * mesh.scale.y;
472
+ } else {
473
+ console.warn("Geometry missing boundingBox", geometry);
474
+ height = scaleFactor; // Estimate height if bbox failed
475
+ }
476
+ height = Math.max(0.1, height); // Ensure minimum height
477
+
478
+
479
+ let position = { x:0, y:0, z:0 };
480
+ let isValidPosition = false; // Flag to check if position calculation succeeded
481
+
482
+ switch(arrangement) {
483
+ case 'stack':
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
+ position.y = lastY + height / 2;
496
+ lastY += height * 0.95;
497
+ stackCount++;
498
+ isValidPosition = true;
499
+ }
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;
507
+ isValidPosition = true;
508
+ break;
509
+ case 'patch':
510
+ const pAngle = Math.random() * Math.PI * 2;
511
+ const pRadius = Math.random() * patchRadius;
512
+ position.x = patchPos.x + Math.cos(pAngle) * pRadius;
513
+ position.z = patchPos.z + Math.sin(pAngle) * pRadius;
514
+ position.y = (patchPos.y || 0) + height / 2;
515
+ isValidPosition = true;
516
+ break;
517
+ case 'scatter':
518
+ default:
519
+ position.x = (Math.random() - 0.5) * baseSize * 0.8;
520
+ position.z = (Math.random() - 0.5) * baseSize * 0.8;
521
+ position.y = height / 2;
522
+ isValidPosition = true;
523
+ break;
524
+ }
525
+
526
+ if (!isValidPosition) continue; // Skip if arrangement logic failed (e.g., stack full)
527
+
528
+ mesh.position.set(position.x, position.y, position.z);
529
+ mesh.rotation.set(
530
+ Math.random() * (arrangement.includes('stack') ? 0.2 : Math.PI),
531
+ Math.random() * Math.PI * 2,
532
+ Math.random() * (arrangement.includes('stack') ? 0.2 : Math.PI)
533
+ );
534
+
535
+ if (Math.random() < 0.3) {
536
+ mesh.userData.rotSpeed = (Math.random() - 0.5) * 0.4;
537
+ mesh.userData.bobSpeed = 1 + Math.random();
538
+ mesh.userData.bobAmount = 0.05 + Math.random() * 0.05;
539
+ mesh.userData.startY = mesh.position.y;
540
+ mesh.userData.update = (time, delta) => {
541
+ mesh.rotation.y += mesh.userData.rotSpeed * delta;
542
+ mesh.position.y = mesh.userData.startY + Math.sin(time * mesh.userData.bobSpeed) * mesh.userData.bobAmount;
543
+ };
544
+ }
545
+ group.add(mesh);
546
+ }
547
+ console.log(`Assembly created with ${group.children.length -1} procedural objects.`); // -1 for base
548
+ return group;
549
+ }
550
+
551
+ function updateScene(assemblyParams) {
552
+ console.log("updateScene called");
553
+ if (currentSceneGroup) {
554
+ currentSceneGroup.traverse(child => {
555
+ if (child.isMesh) {
556
+ if(child.geometry) child.geometry.dispose();
557
+ }
558
+ });
559
+ scene.remove(currentSceneGroup);
560
+ currentSceneGroup = null;
561
+ }
562
+ setupLighting('default'); // Use default lighting for simplicity now
563
+
564
+ if (!assemblyParams) {
565
+ console.warn("No assemblyParams provided, creating default scene.");
566
+ assemblyParams = { baseShape: 'ground_dirt', count: 5, mainShapes: ['boxy_chunk'] };
567
+ }
568
+
569
+ try {
570
+ currentSceneGroup = createProceduralAssembly(assemblyParams);
571
+ scene.add(currentSceneGroup);
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 = {
582
+ name: "Player",
583
+ stats: { hp: 25, maxHp: 25, xp: 0, strength: 12, dexterity: 11, constitution: 13, intelligence: 9, wisdom: 10, charisma: 8 },
584
+ inventory: ["Sturdy Stick"]
585
+ };
586
+ gameState = {
587
+ currentPageId: 1,
588
+ character: JSON.parse(JSON.stringify(defaultChar)),
589
+ combat: null // Ensure combat is null at start
590
+ };
591
+ renderPage(gameState.currentPageId);
592
+ console.log("startGame finished.");
593
+ }
594
+
595
+ function handleChoiceClick(choiceData) {
596
+ console.log("Choice clicked:", choiceData);
597
+ currentMessage = "";
598
+
599
+ let nextPageId = parseInt(choiceData.next);
600
+ const currentPageData = gameData[gameState.currentPageId];
601
+ const option = currentPageData?.options?.find(opt => opt.next === nextPageId || (opt.action === choiceData.action && opt.enemy === choiceData.enemy)); // Find the specific option
602
+
603
+ // 1. Check Requirements
604
+ if (option && option.requires) {
605
+ if (!gameState.character.inventory.includes(option.requires)) {
606
+ currentMessage = `<p class="message message-warning">You need a ${option.requires} for that!</p>`;
607
+ renderPage(gameState.currentPageId); // Re-render current page with message
608
+ return;
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
+ // 2. Handle Actions (Combat, Checks) before transitioning
617
+ if (option && option.action === 'triggerCombat' && option.enemy) {
618
+ startCombat(option.enemy, option.nextOnWin, option.nextOnLoss);
619
+ return; // Combat handles the next transition
620
+ }
621
+ if (option && option.check) {
622
+ performSkillCheck(option.check, option.next, option.onFailure);
623
+ return; // Skill check handles the next transition
624
+ }
625
+
626
+ // 3. Apply Rewards (if navigating directly)
627
+ if (option && option.reward) {
628
+ applyReward(option.reward);
629
+ }
630
+
631
+ // 4. Transition to Next Page (if not handled by action/check)
632
+ const targetPageData = gameData[nextPageId];
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
+ gameState.currentPageId = nextPageId;
640
+ renderPage(nextPageId);
641
+ }
642
+
643
+ function performSkillCheck(checkData, successPageId, failurePageId) {
644
+ const {stat, dc} = checkData;
645
+ const baseStat = gameState.character.stats[stat] || 10;
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); // Show the roll
654
+
655
+ if (success) {
656
+ currentMessage += `<p class="message message-success"><em>Success!</em></p>`;
657
+ gameState.currentPageId = successPageId;
658
+ } else {
659
+ currentMessage += `<p class="message message-failure"><em>Failed!</em></p>`;
660
+ gameState.currentPageId = failurePageId;
661
+ }
662
+ // Add short delay before rendering next page to see dice roll
663
+ setTimeout(() => renderPage(gameState.currentPageId), 1500);
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 found a ${rewardData.addItem}!</p>`;
671
+ } else {
672
+ currentMessage += `<p class="message message-info">You found another ${rewardData.addItem}, but can't carry more.</p>`;
673
+ }
674
+ }
675
+ if(rewardData.xp) {
676
+ gameState.character.stats.xp += rewardData.xp;
677
+ currentMessage += `<p class="message message-xp">You gained ${rewardData.xp} XP!</p>`;
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">You feel better! (+${rewardData.hpGain} HP)</p>`;
682
+ }
683
+ if(rewardData.statGain) { // Example: { statGain: { maxHp: 5 } }
684
+ for (const stat in rewardData.statGain) {
685
+ if (gameState.character.stats.hasOwnProperty(stat)) {
686
+ gameState.character.stats[stat] += rewardData.statGain[stat];
687
+ currentMessage += `<p class="message message-success">Your ${stat} increased by ${rewardData.statGain[stat]}!</p>`;
688
+ // If maxHp increases, maybe heal too?
689
+ if (stat === 'maxHp' && rewardData.statGain[stat] > 0) {
690
+ gameState.character.stats.hp += rewardData.statGain[stat];
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 as before */ return; }
703
+
704
+ storyTitleElement.textContent = pageData.title || "An Unnamed Place";
705
+ storyContentElement.innerHTML = currentMessage + (pageData.content || "<p>...</p>");
706
+ choicesElement.innerHTML = '';
707
+ actionChoicesElement.innerHTML = ''; // Clear action choices too
708
+
709
+ // Setup Navigation Buttons (Always show, disable if unavailable)
710
+ const neighbors = getZoneNeighbors(gameState.currentZoneId || getZoneId(1,1)); // Use placeholder if needed
711
+ const directions = {'north': 'North', 'south': 'South', 'east': 'East', 'west': 'West'};
712
+ for(const dir in directions) {
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 ${directions[dir]}`;
717
+ // For now, navigation uses pageData.options, not grid neighbors
718
+ button.disabled = true; // Disable all grid nav by default
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
+ // Disable if required item is missing
731
+ let requirementMet = true;
732
+ if (option.requires && !gameState.character.inventory.includes(option.requires)) {
733
+ requirementMet = false;
734
+ button.title = `Requires: ${option.requires}`;
735
+ }
736
+ button.disabled = !requirementMet;
737
+
738
+ button.onclick = () => handleChoiceClick(option); // Pass full option data
739
+ actionChoicesElement.appendChild(button);
740
+ });
741
+ } else {
742
+ actionChoicesElement.innerHTML = '<p><i>Nothing else to do here right now.</i></p>';
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
+ if (!gameState.character || !statsElement) return;
755
+ const stats = gameState.character.stats;
756
+ const hpColor = stats.hp / stats.maxHp < 0.3 ? '#f88' : (stats.hp / stats.maxHp < 0.6 ? '#fd5' : '#8f8');
757
+ const statBonus = (statVal) => {
758
+ const mod = Math.floor((statVal - 10) / 2);
759
+ return `${statVal} (${mod >= 0 ? '+' : ''}${mod})`;
760
+ };
761
+
762
+ statsElement.innerHTML = `<strong>Stats:</strong>
763
+ <span style="color:${hpColor}">HP: ${stats.hp}/${stats.maxHp}</span> <span>XP: ${stats.xp}</span><br>
764
+ <span>Str: ${statBonus(stats.strength)}</span> <span>Dex: ${statBonus(stats.dexterity)}</span> <span>Con: ${statBonus(stats.constitution)}</span>
765
+ <span>Int: ${statBonus(stats.intelligence)}</span> <span>Wis: ${statBonus(stats.wisdom)}</span> <span>Cha: ${statBonus(stats.charisma)}</span>`;
766
+ }
767
+
768
+ function updateInventoryDisplay() {
769
+ if (!gameState.character || !inventoryElement) return;
770
+ let invHtml = '<strong>Inventory:</strong> ';
771
+ if (gameState.character.inventory.length === 0) {
772
+ invHtml += '<em>Empty</em>';
773
+ } else {
774
+ gameState.character.inventory.forEach(item => {
775
+ const itemDef = itemsData[item] || { type: 'unknown', description: '???' };
776
+ const itemClass = `item-${itemDef.type || 'unknown'}`;
777
+ invHtml += `<span class="item-tag ${itemClass}" title="${itemDef.description}">${item}</span>`;
778
+ });
779
+ }
780
+ inventoryElement.innerHTML = invHtml;
781
+ }
782
+
783
+ function updateActionInfo() {
784
+ if (!actionInfoElement || !gameState ) return;
785
+ const mode = gameState.combat?.active ? "Combat!" : "Exploring";
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, // Simplified bonus
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} blocks your path!</p>`;
802
+ renderCurrentPageUI(); // Update UI to show combat mode
803
+ }
804
+
805
+ function handleCombatAction(action) {
806
+ if (!gameState.combat?.active) return;
807
+ currentMessage = ""; // Clear previous messages
808
+
809
+ if (action === 'attack') {
810
+ // Player Attack
811
+ const playerRoll = Math.floor(Math.random() * 20) + 1;
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; // Unarmed = 1d2 ?
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 the ${gameState.combat.enemyName} for ${damageRoll} damage! (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
824
+ } else {
825
+ currentMessage += `<p class="message message-failure">You missed the ${gameState.combat.enemyName}. (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
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 (Simplified: drop one random item from list)
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; // End combat
840
+ gameState.currentPageId = winPage; // Go to win page
841
+ renderPage(gameState.currentPageId);
842
+ return;
843
+ }
844
+
845
+ // Enemy Attack (if enemy still alive)
846
+ const enemyRoll = Math.floor(Math.random() * 20) + 1;
847
+ const enemyTotalAttack = enemyRoll + gameState.combat.enemyAttackBonus;
848
+ const playerAC = 10 + Math.floor((gameState.character.stats.dexterity - 10) / 2);
849
+ const enemyHit = enemyRoll === 20 || (enemyRoll !== 1 && enemyTotalAttack >= playerAC);
850
+
851
+ // Add slight delay for enemy roll display maybe?
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);
856
+ gameState.character.stats.hp -= damageRoll;
857
+ currentMessage += `<p class="message message-failure">The ${gameState.combat.enemyName} hits you for ${damageRoll} damage! (Rolled ${enemyTotalAttack} vs AC ${playerAC})</p>`;
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; // End combat
862
+ gameState.currentPageId = lossPage; // Go to loss page
863
+ setTimeout(() => renderPage(gameState.currentPageId), 1500); // Delay transition after showing roll
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), 1500); // Re-render UI after delay
870
+ }
871
+ }
872
+ window.handleCombatAction = handleCombatAction; // Make global
873
+
874
+ function displayDiceRoll(result, success) {
875
+ if (!threeFont) { console.warn("Font not loaded for dice roll."); return; }
876
+ activeTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); activeTimeouts = [];
877
+ scene.children.filter(c => c.userData?.isDiceRoll).forEach(c => scene.remove(c)); // Remove old rolls from main scene
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.transparent = true; // Needed for opacity fade
885
+
886
+ const textMesh = new THREE.Mesh(textGeo, textMat);
887
+ textMesh.userData.isDiceRoll = true;
888
+
889
+ const distance = 6;
890
+ const textPos = camera.position.clone().add(camera.getWorldDirection(new THREE.Vector3()).multiplyScalar(distance));
891
+ textMesh.position.copy(textPos);
892
+ textMesh.position.y += 1.0; // Adjust vertical position
893
+ textMesh.position.x -= textWidth / 2 * textMesh.scale.x; // Center horizontally
894
+ textMesh.quaternion.copy(camera.quaternion);
895
+
896
+ scene.add(textMesh);
897
+
898
+ const duration = 2.5; // seconds
899
+ const startTime = clock.getElapsedTime();
900
+ textMesh.userData.startTime = startTime;
901
+ textMesh.userData.update = (time) => {
902
+ const elapsed = time - textMesh.userData.startTime;
903
+ if (elapsed >= duration) {
904
+ if (textMesh.parent) textMesh.parent.remove(textMesh);
905
+ delete textMesh.userData.update;
906
+ } else {
907
+ textMesh.position.y += 0.01;
908
+ textMesh.material.opacity = 1.0 - (elapsed / duration);
909
+ }
910
+ };
911
+ }
912
+
913
+ function dropItemInScene(itemName, positionOffset = new THREE.Vector3(0, 0, 0)) {
914
+ const currentGroup = currentSceneGroup; // Items are dropped relative to the current scene group origin
915
+ if (!currentGroup || !itemsData[itemName]) return;
916
+
917
+ const itemDef = itemsData[itemName];
918
+ const itemGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4); // Simple representation
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);
922
+ else if(itemDef.type === 'quest') itemMat.color.setHex(0xcccc66);
923
+ else itemMat.color.setHex(0xaaaaee);
924
+
925
+ const dropPos = new THREE.Vector3(positionOffset.x, 0.2, positionOffset.z);
926
+ const droppedMesh = createMesh(itemGeo, itemMat, dropPos);
927
+ droppedMesh.userData = { isPickupable: true, itemName: itemName, description: `Dropped ${itemName}` };
928
+ currentGroup.add(droppedMesh); // Add item to the current scene group
929
+ console.log(`Dropped ${itemName} in scene`);
930
+ }
931
+
932
+ function pickupItem() { // Re-enabled pickup
933
+ if (gameState.combat?.active) return; // No pickup during combat
934
+
935
+ raycaster.setFromCamera(mouse, camera);
936
+ const currentGroup = currentSceneGroup;
937
+ if (!currentGroup) return;
938
+
939
+ const pickupables = [];
940
+ currentGroup.traverseVisible(child => { // Check only visible objects in current group
941
+ if (child.userData.isPickupable) pickupables.push(child);
942
+ });
943
+
944
+ const intersects = raycaster.intersectObjects(pickupables, false);
945
+
946
+ if (intersects.length > 0) {
947
+ const clickedObject = intersects[0].object;
948
+ const itemName = clickedObject.userData.itemName;
949
+
950
+ if (itemName && itemsData[itemName]) {
951
+ console.log(`Picked up: ${itemName}`);
952
+ currentMessage = `<p class="message message-item"><em>Picked up: ${itemName}</em></p>`;
953
+
954
+ if (!gameState.character.inventory.includes(itemName)) {
955
+ gameState.character.inventory.push(itemName);
956
+ } else {
957
+ currentMessage += `<p class="message message-info"><em>(Cannot carry more ${itemName})</em></p>`;
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(); // Update inventory and messages
966
+ }
967
+ }
968
+ }
969
+
970
+
971
+ document.addEventListener('DOMContentLoaded', () => {
972
+ console.log("DOM Ready - Initializing Wacky D&D Shapes Adventure!");
973
+ try {
974
+ initThreeJS();
975
+ if (!scene || !camera || !renderer) throw new Error("Three.js failed to initialize.");
976
+ loadFontAndStart(); // Load font, then start game
977
+ console.log("Initialization sequence started.");
978
+ } catch (error) {
979
+ console.error("Initialization failed:", error);
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').style.display = 'none';
984
+ document.getElementById('choices-container').style.display = 'none';
985
+ }
986
+ });
987
+
988
+ </script>
989
+ </body>
990
+ </html>