awacke1 commited on
Commit
5a10444
·
verified ·
1 Parent(s): a9cd951

Create index.html

Browse files
Files changed (1) hide show
  1. index.html +913 -0
index.html ADDED
@@ -0,0 +1,913 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Procedural World - D&D Style</title>
7
+ <style>
8
+ /* --- Base Styles --- */
9
+ body { font-family: 'Courier New', monospace; background-color: #111; color: #eee; 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: 2px solid #444; min-width: 250px; background-color: #000; height: 100%; box-sizing: border-box; overflow: hidden; cursor: default; }
12
+ #ui-container { flex-grow: 2; padding: 25px; overflow-y: auto; background-color: #2b2b2b; min-width: 320px; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; }
13
+ #scene-container canvas { display: block; }
14
+
15
+ /* --- UI Elements --- */
16
+ #story-title { color: #f0c060; margin: 0 0 15px 0; padding-bottom: 10px; border-bottom: 1px solid #555; font-size: 1.6em; text-shadow: 1px 1px 1px #000; }
17
+ #story-content { margin-bottom: 25px; line-height: 1.7; flex-grow: 1; font-size: 1.1em; }
18
+ #stats-inventory-container { margin-bottom: 25px; padding: 15px; border: 1px solid #444; border-radius: 4px; background-color: #333; 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: #484848; padding: 3px 9px; border-radius: 15px; margin: 0 8px 5px 0; border: 1px solid #6a6a6a; white-space: nowrap; box-shadow: inset 0 1px 2px rgba(0,0,0,0.3); }
21
+ #inventory-display .item-tag { display: inline-block; background-color: #484848; padding: 3px 9px; border-radius: 15px; margin: 0 8px 5px 0; border: 1px solid #6a6a6a; white-space: nowrap; box-shadow: inset 0 1px 2px rgba(0,0,0,0.3); cursor: default; } /* No placement click */
22
+ #stats-display strong, #inventory-display strong { color: #ccc; margin-right: 6px; }
23
+ #inventory-display em { color: #888; font-style: normal; }
24
+ .item-weapon { background-color: #663030; border-color: #994848;}
25
+ .item-consumable { background-color: #664430; border-color: #996648;}
26
+ .item-unknown { background-color: #555; border-color: #777;}
27
+
28
+ /* --- Choices & Messages --- */
29
+ #choices-container { margin-top: auto; padding-top: 20px; border-top: 1px solid #555; }
30
+ #choices-container h3 { margin-top: 0; margin-bottom: 12px; color: #ccc; font-size: 1.1em; }
31
+ #choices { display: flex; flex-direction: column; gap: 12px; }
32
+ .choice-button { display: block; width: 100%; padding: 12px 15px; margin-bottom: 0; background-color: #555; color: #eee; border: 1px solid #777; border-radius: 4px; cursor: pointer; text-align: left; font-family: 'Courier New', monospace; font-size: 1.05em; transition: background-color 0.2s, border-color 0.2s, box-shadow 0.1s; box-sizing: border-box; }
33
+ .choice-button:hover:not(:disabled) { background-color: #e0b050; color: #111; border-color: #c89040; box-shadow: 0 0 5px rgba(255, 200, 100, 0.5); }
34
+ .choice-button:disabled { background-color: #404040; color: #777; cursor: not-allowed; border-color: #555; opacity: 0.7; }
35
+ .message { padding: 8px 12px; margin-bottom: 1em; border-left-width: 3px; border-left-style: solid; font-size: 0.95em; background-color: rgba(255, 255, 255, 0.05); border-color: #666; color: #aaa;}
36
+ .message-failure { color: #f88; border-left-color: #a44; }
37
+ .message-success { color: #8f8; border-left-color: #4a4; }
38
+ .message-info { color: #aaa; border-left-color: #666; }
39
+ .message-item { color: #8bf; border-left-color: #46a; }
40
+ .message-combat { color: #f98; border-left-color: #c64; font-weight: bold;}
41
+ .combat-button { background-color: #a33; border-color: #c66; color: #fff; font-weight: bold; text-align: center;}
42
+ .combat-button:hover:not(:disabled) { background-color: #d44; border-color: #f88;}
43
+
44
+ /* --- Action Info --- */
45
+ #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;}
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="game-container">
50
+ <div id="scene-container">
51
+ <div id="action-info">Initializing...</div>
52
+ </div>
53
+ <div id="ui-container">
54
+ <h2 id="story-title">Initializing...</h2>
55
+ <div id="story-content"><p>Loading world...</p></div>
56
+ <div id="stats-inventory-container">
57
+ <div id="stats-display">Loading Stats...</div>
58
+ <div id="inventory-display">Inventory: ...</div>
59
+ </div>
60
+ <div id="choices-container">
61
+ <h3>What will you do?</h3>
62
+ <div id="choices">Loading...</div>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ <script type="importmap">
68
+ { "imports": {
69
+ "three": "https://unpkg.com/[email protected]/build/three.module.js",
70
+ "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
71
+ }}
72
+ </script>
73
+
74
+ <script type="module">
75
+ import * as THREE from 'three';
76
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
77
+ import { FontLoader } from 'three/addons/loaders/FontLoader.js';
78
+ import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
79
+
80
+ console.log("Script module execution started.");
81
+
82
+ // --- DOM Elements ---
83
+ const sceneContainer = document.getElementById('scene-container');
84
+ const storyTitleElement = document.getElementById('story-title');
85
+ const storyContentElement = document.getElementById('story-content');
86
+ const choicesElement = document.getElementById('choices');
87
+ const statsElement = document.getElementById('stats-display');
88
+ const inventoryElement = document.getElementById('inventory-display');
89
+ const actionInfoElement = document.getElementById('action-info');
90
+
91
+ // --- Core Three.js Variables ---
92
+ let scene, camera, renderer, clock, controls, raycaster, mouse;
93
+ let worldGroup = null; // Parent for all zone content
94
+ let zoneGroups = {}; // Stores loaded zone data: { zoneId: { group, lighting, title, ... } }
95
+ let currentLights = [];
96
+ let threeFont = null; // For 3D text
97
+
98
+ // --- Game State ---
99
+ let gameState = {}; // Initialized in startGame
100
+ let currentMessage = ""; // Accumulates messages for UI update
101
+ let activeTimeouts = []; // Track animation timeouts
102
+
103
+ // --- Materials ---
104
+ const MAT = {
105
+ stone: new THREE.MeshStandardMaterial({ color: 0x777788, roughness: 0.85 }),
106
+ dark_stone: new THREE.MeshStandardMaterial({ color: 0x444455, roughness: 0.9 }),
107
+ wood: new THREE.MeshStandardMaterial({ color: 0x9F6633, roughness: 0.75 }),
108
+ dark_wood: new THREE.MeshStandardMaterial({ color: 0x6F4E2D, roughness: 0.8 }),
109
+ leaf: new THREE.MeshStandardMaterial({ color: 0x3E9B4E, roughness: 0.6, side: THREE.DoubleSide }),
110
+ ground: new THREE.MeshStandardMaterial({ color: 0x556B2F, roughness: 0.95 }),
111
+ dirt: new THREE.MeshStandardMaterial({ color: 0x8B5E3C, roughness: 0.9 }),
112
+ grass: new THREE.MeshStandardMaterial({ color: 0x4CB781, roughness: 0.85 }),
113
+ water: new THREE.MeshStandardMaterial({ color: 0x4682B4, roughness: 0.3, transparent: true, opacity: 0.85 }),
114
+ metal: new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.8, roughness: 0.4 }),
115
+ simple: new THREE.MeshStandardMaterial({ color: 0xaaaaaa, roughness: 0.8 }),
116
+ text: new THREE.MeshBasicMaterial({ color: 0xffddaa }),
117
+ // Zone specific themes
118
+ ruins_stone: new THREE.MeshStandardMaterial({ color: 0x888a8f, roughness: 0.9 }),
119
+ town_wood: new THREE.MeshStandardMaterial({ color: 0xae8a63, roughness: 0.7 }),
120
+ town_roof: new THREE.MeshStandardMaterial({ color: 0x8b4513, roughness: 0.8 }),
121
+ };
122
+
123
+ // --- Game Data ---
124
+ const itemsData = {
125
+ "Rusty Sword": {type:"weapon", description:"Old but sharp.", baseDamage: 3},
126
+ "Health Potion": {type:"consumable", description:"Restores 10 HP.", effect: { hpGain: 10 }},
127
+ "Goblin Ear": {type:"quest", description:"A gruesome trophy."},
128
+ "Cave Crystal": {type:"unknown", description:"A faintly glowing crystal shard."},
129
+ "Ancient Coin": {type:"unknown", description:"A worn coin from a forgotten era."}
130
+ };
131
+
132
+ const enemyData = {
133
+ 'goblin': { name: "Goblin", hp: 8, defense: 11, attackDamage: 2, xp: 15, drops: ["Goblin Ear", "Health Potion"] },
134
+ 'skeleton': { name: "Skeleton", hp: 10, defense: 12, attackDamage: 3, xp: 20, drops: ["Ancient Coin"] },
135
+ 'spider': { name: "Giant Spider", hp: 12, defense: 13, attackDamage: 3, xp: 25, drops: ["Cave Crystal"] }
136
+ };
137
+
138
+ const zoneCreators = {};
139
+ const MAP_ROWS = 3;
140
+ const MAP_COLS = 4;
141
+
142
+ // --- Core Functions ---
143
+
144
+ function initThreeJS() {
145
+ console.log("initThreeJS started.");
146
+ scene = new THREE.Scene();
147
+ scene.background = new THREE.Color(0x1a1a1a);
148
+ clock = new THREE.Clock();
149
+ raycaster = new THREE.Raycaster();
150
+ mouse = new THREE.Vector2();
151
+ worldGroup = new THREE.Group();
152
+ scene.add(worldGroup);
153
+
154
+ const width = sceneContainer.clientWidth || 300;
155
+ const height = sceneContainer.clientHeight || 200;
156
+ camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
157
+ camera.position.set(0, 8, 15); // Start slightly further back/higher
158
+
159
+ renderer = new THREE.WebGLRenderer({ antialias: true });
160
+ renderer.setSize(width, height);
161
+ renderer.shadowMap.enabled = true;
162
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
163
+ sceneContainer.appendChild(renderer.domElement);
164
+
165
+ controls = new OrbitControls(camera, renderer.domElement);
166
+ controls.enableDamping = true;
167
+ controls.dampingFactor = 0.1;
168
+ controls.target.set(0, 1, 0);
169
+ controls.maxPolarAngle = Math.PI / 2 - 0.05;
170
+ controls.minDistance = 3;
171
+ controls.maxDistance = 50;
172
+
173
+ window.addEventListener('resize', onWindowResize, false);
174
+ renderer.domElement.addEventListener('click', onMouseClick, false);
175
+ setTimeout(onWindowResize, 100);
176
+ animate();
177
+ console.log("initThreeJS finished successfully.");
178
+ }
179
+
180
+ function loadFontAndStart() {
181
+ console.log("Loading font...");
182
+ const loader = new FontLoader();
183
+ loader.load('https://unpkg.com/[email protected]/examples/fonts/helvetiker_regular.typeface.json', function (font) {
184
+ threeFont = font;
185
+ console.log("Font loaded.");
186
+ startGame();
187
+ }, undefined, function (error) {
188
+ console.error('Font loading failed:', error);
189
+ storyTitleElement.textContent = "Font Load Error";
190
+ storyContentElement.innerHTML = `<p class="message message-failure">Could not load required font. Combat text disabled.</p>`;
191
+ startGame(); // Start anyway, but combat text won't work
192
+ });
193
+ }
194
+
195
+ function onWindowResize() {
196
+ if (!renderer || !camera || !sceneContainer) return;
197
+ const width = sceneContainer.clientWidth || 300;
198
+ const height = sceneContainer.clientHeight || 200;
199
+ if (width > 0 && height > 0) {
200
+ camera.aspect = width / height;
201
+ camera.updateProjectionMatrix();
202
+ renderer.setSize(width, height);
203
+ }
204
+ }
205
+
206
+ function onMouseClick( event ) {
207
+ // Currently only used for picking up items
208
+ pickupItem();
209
+ }
210
+
211
+ function animate() {
212
+ requestAnimationFrame(animate);
213
+ const delta = clock.getDelta();
214
+ const time = clock.getElapsedTime();
215
+
216
+ controls.update();
217
+ // Animate objects within the *currently visible* zone group
218
+ if (gameState.currentZoneId && zoneGroups[gameState.currentZoneId]) {
219
+ zoneGroups[gameState.currentZoneId].group.traverse(obj => {
220
+ if (obj.userData.update) obj.userData.update(time, delta);
221
+ });
222
+ }
223
+
224
+ if (renderer && scene && camera) renderer.render(scene, camera);
225
+ }
226
+
227
+ // --- Geometry/Mesh Helpers ---
228
+ 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}) {
229
+ const mesh = new THREE.Mesh(geometry, material);
230
+ mesh.position.set(pos.x, pos.y, pos.z);
231
+ mesh.rotation.set(rot.x, rot.y, rot.z);
232
+ mesh.scale.set(scale.x, scale.y, scale.z);
233
+ mesh.castShadow = true; mesh.receiveShadow = true;
234
+ return mesh;
235
+ }
236
+
237
+ function createGround(material = MAT.ground, size = 20) {
238
+ const geo = new THREE.PlaneGeometry(size, size);
239
+ const ground = new THREE.Mesh(geo, material);
240
+ ground.rotation.x = -Math.PI / 2; ground.position.y = 0;
241
+ ground.receiveShadow = true; ground.castShadow = false;
242
+ ground.userData.isGround = true;
243
+ return ground;
244
+ }
245
+
246
+ // --- Procedural Structure Helpers ---
247
+ function createTowerSegment(radius = 1, height = 2, material = MAT.stone) {
248
+ const geo = new THREE.CylinderGeometry(radius, radius, height, 8);
249
+ return createMesh(geo, material, {y: height/2});
250
+ }
251
+
252
+ function createWallSection(width = 4, height = 2.5, depth = 0.5, material = MAT.stone) {
253
+ const geo = new THREE.BoxGeometry(width, height, depth);
254
+ return createMesh(geo, material, {y: height/2});
255
+ }
256
+
257
+ function createSimpleHouse(width=3, height=2, depth=4, woodMat=MAT.town_wood, roofMat=MAT.town_roof) {
258
+ const group = new THREE.Group();
259
+ const wallGeo = new THREE.BoxGeometry(width, height, depth);
260
+ const walls = createMesh(wallGeo, woodMat, {y: height/2});
261
+ group.add(walls);
262
+ const roofGeo = new THREE.ConeGeometry(Math.max(width, depth) * 0.7, height * 0.6, 4); // Pyramid roof
263
+ const roof = createMesh(roofGeo, roofMat, {y: height + height*0.3});
264
+ roof.rotation.y = Math.PI / 4; // Align edges
265
+ group.add(roof);
266
+ return group;
267
+ }
268
+
269
+ // --- Lighting ---
270
+ function setupLighting(type = 'default') {
271
+ console.log(`Setting up lighting for type: ${type}`);
272
+ currentLights.forEach(light => {
273
+ if (light && light.parent) light.parent.remove(light);
274
+ if (light && scene.children.includes(light)) scene.remove(light);
275
+ });
276
+ currentLights = [];
277
+
278
+ let ambientIntensity = 0.4;
279
+ let dirIntensity = 0.9;
280
+ let dirColor = 0xffffff;
281
+ let dirPosition = new THREE.Vector3(10, 15, 8);
282
+
283
+ if (type === 'forest') { ambientIntensity = 0.3; dirIntensity = 0.7; dirColor = 0xccffcc; dirPosition = new THREE.Vector3(5, 10, 5); }
284
+ if (type === 'cave') { ambientIntensity = 0.15; dirIntensity = 0; } // Rely on point light
285
+ if (type === 'ruins') { ambientIntensity = 0.35; dirIntensity = 0.6; dirColor = 0xaaaaff; dirPosition = new THREE.Vector3(-8, 12, -5); }
286
+ if (type === 'town') { ambientIntensity = 0.5; dirIntensity = 1.0; dirColor = 0xffeedd; dirPosition = new THREE.Vector3(12, 18, 10); }
287
+
288
+ const ambientLight = new THREE.AmbientLight(0xffffff, ambientIntensity);
289
+ scene.add(ambientLight);
290
+ currentLights.push(ambientLight);
291
+
292
+ if (dirIntensity > 0) {
293
+ const directionalLight = new THREE.DirectionalLight(dirColor, dirIntensity);
294
+ directionalLight.position.copy(dirPosition);
295
+ directionalLight.castShadow = true;
296
+ directionalLight.shadow.mapSize.set(1024, 1024);
297
+ directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 50;
298
+ const sb = 25; // Wider shadow area for larger zones
299
+ directionalLight.shadow.camera.left = -sb; directionalLight.shadow.camera.right = sb;
300
+ directionalLight.shadow.camera.top = sb; directionalLight.shadow.camera.bottom = -sb;
301
+ directionalLight.shadow.bias = -0.0005;
302
+ scene.add(directionalLight);
303
+ currentLights.push(directionalLight);
304
+ }
305
+ if (type === 'cave') {
306
+ const ptLight = new THREE.PointLight(0xffaa55, 1.5, 15, 1.5); // Increased range/decay
307
+ ptLight.position.set(0, 3, 0);
308
+ ptLight.castShadow = true;
309
+ ptLight.shadow.mapSize.set(512, 512);
310
+ currentLights.push(ptLight); // Will be added to group later
311
+ }
312
+ console.log(`Lighting setup complete. Lights tracked: ${currentLights.length}`);
313
+ }
314
+
315
+ // --- Zone Creation Functions (Enhanced) ---
316
+ function createFieldZone(zoneId) {
317
+ console.log(`Creating zone: ${zoneId} (Field)`);
318
+ const group = new THREE.Group();
319
+ group.add(createGround(MAT.grass, 40)); // Larger zone
320
+ const rockGeo = new THREE.IcosahedronGeometry(0.5 + Math.random()*0.8, 0);
321
+ for(let i=0; i<8; i++) {
322
+ const rock = createMesh(rockGeo, MAT.stone, {x: (Math.random()-0.5)*35, y:0.4, z: (Math.random()-0.5)*35});
323
+ rock.rotation.set(Math.random(), Math.random(), Math.random());
324
+ group.add(rock);
325
+ }
326
+ // Add a simple floating/bobbing animation to rocks
327
+ group.children.filter(c=>c.geometry.type === 'IcosahedronGeometry').forEach(rock => {
328
+ rock.userData.startY = rock.position.y;
329
+ rock.userData.update = (time) => {
330
+ rock.position.y = rock.userData.startY + Math.sin(time * 1.5 + rock.id * 0.5) * 0.1; // Bobbing
331
+ };
332
+ });
333
+ group.visible = false;
334
+ return { group, lighting: 'default', title: "Rolling Fields", entryText: `The wind whispers through the tall grass of zone ${zoneId}. Scattered boulders dot the landscape.`, options: [], zoneId: zoneId };
335
+ }
336
+
337
+ function createForestZone(zoneId) {
338
+ console.log(`Creating zone: ${zoneId} (Forest)`);
339
+ const group = new THREE.Group();
340
+ group.add(createGround(MAT.ground, 40));
341
+ const trunkGeo = new THREE.CylinderGeometry(0.2, 0.3, 4 + Math.random()*2, 8);
342
+ const leafGeo = new THREE.SphereGeometry(1.5 + Math.random(), 8, 6);
343
+ for(let i=0; i<35; i++) { // Denser forest
344
+ const x = (Math.random() - 0.5) * 38;
345
+ const z = (Math.random() - 0.5) * 38;
346
+ if(Math.sqrt(x*x+z*z) < 1) continue;
347
+ const tree = new THREE.Group();
348
+ const trunkHeight = 4 + Math.random()*2;
349
+ const trunk = createMesh(trunkGeo.clone().scale(1, trunkHeight/4, 1), MAT.wood, {y: trunkHeight/2}); // Scale trunk height
350
+ const leaves = createMesh(leafGeo.clone().scale(1+Math.random()*0.3, 1+Math.random()*0.3, 1+Math.random()*0.3), MAT.leaf, {y: trunkHeight + 0.5});
351
+ tree.add(trunk); tree.add(leaves);
352
+ tree.position.set(x, 0, z);
353
+ tree.rotation.y = Math.random() * Math.PI * 2;
354
+ // Add subtle sway animation
355
+ tree.userData.swaySpeed = 0.1 + Math.random() * 0.1;
356
+ tree.userData.swayAmount = 0.005 + Math.random() * 0.005;
357
+ tree.userData.update = (time) => {
358
+ tree.rotation.z = Math.sin(time * tree.userData.swaySpeed + tree.position.x) * tree.userData.swayAmount;
359
+ tree.rotation.x = Math.cos(time * tree.userData.swaySpeed * 0.7 + tree.position.z) * tree.userData.swayAmount;
360
+ };
361
+ group.add(tree);
362
+ }
363
+ group.visible = false;
364
+ return { group, lighting: 'forest', title: "Deep Forest", entryText: `Ancient trees form a dense canopy overhead in ${zoneId}. Strange sounds echo.`, options: [{text: "Search for Goblin Tracks", action: "triggerCombat", enemy: "goblin"}], zoneId: zoneId };
365
+ }
366
+
367
+ function createCaveZone(zoneId) {
368
+ console.log(`Creating zone: ${zoneId} (Cave)`);
369
+ const group = new THREE.Group();
370
+ group.add(createGround(MAT.stone.clone().set({color: 0x4a4a55}), 25)); // Darker floor
371
+ const wallGeo = new THREE.SphereGeometry(15, 32, 16, 0, Math.PI*2, 0, Math.PI*0.8); // Larger cave
372
+ const walls = createMesh(wallGeo, MAT.dark_stone, {y: 5});
373
+ walls.material.side = THREE.BackSide;
374
+ group.add(walls);
375
+ const coneGeo = new THREE.ConeGeometry(0.1 + Math.random()*0.2, 0.8 + Math.random()*1.5, 8); // Stalactites/mites
376
+ for(let i=0; i<25; i++){
377
+ const x = (Math.random()-0.5)*22;
378
+ const z = (Math.random()-0.5)*22;
379
+ const yUp = 7 + Math.random()*3;
380
+ const yDown = 0.5 + Math.random();
381
+ if (Math.random() > 0.5) group.add(createMesh(coneGeo.clone(), MAT.dark_stone, {x:x, y:yUp, z:z}, {x:Math.PI})); // Stalactite
382
+ if (Math.random() > 0.5) group.add(createMesh(coneGeo.clone(), MAT.dark_stone, {x:x, y:yDown, z:z})); // Stalagmite
383
+ }
384
+ // Add glowing crystals
385
+ const crystalGeo = new THREE.IcosahedronGeometry(0.3 + Math.random()*0.2, 0);
386
+ for(let i=0; i<5; i++) {
387
+ const crystal = createMesh(crystalGeo, MAT.simple.clone().set({color:0xaaaaff, emissive: 0x4444ff, emissiveIntensity: 0.8}),
388
+ {x: (Math.random()-0.5)*15, y:0.3 + Math.random()*2, z: (Math.random()-0.5)*15}
389
+ );
390
+ crystal.userData = { isPickupable: true, itemName: "Cave Crystal", description: "A faintly glowing crystal shard."};
391
+ // Add pulsing animation
392
+ crystal.userData.baseScale = 1.0;
393
+ crystal.userData.update = (time) => {
394
+ const scale = crystal.userData.baseScale + Math.sin(time * 2 + crystal.id) * 0.1;
395
+ crystal.scale.set(scale, scale, scale);
396
+ };
397
+ group.add(crystal);
398
+ }
399
+ group.visible = false;
400
+ return { group, lighting: 'cave', title: "Echoing Cave", entryText: `Water drips in the darkness of ${zoneId}. Strange crystals pulse with faint light.`, options: [{text: "Disturb Spider Nest", action: "triggerCombat", enemy: "spider"}], zoneId: zoneId };
401
+ }
402
+
403
+ function createRuinsZone(zoneId) {
404
+ console.log(`Creating zone: ${zoneId} (Ruins)`);
405
+ const group = new THREE.Group();
406
+ group.add(createGround(MAT.dirt, 35));
407
+ // Stacked walls/pillars
408
+ for(let i=0; i<12; i++) {
409
+ const stackHeight = Math.floor(1 + Math.random() * 3); // 1 to 3 segments high
410
+ const basePos = {x: (Math.random()-0.5)*30, y:0, z: (Math.random()-0.5)*30};
411
+ const rotY = Math.random() * Math.PI;
412
+ for (let j=0; j<stackHeight; j++) {
413
+ const width = 1 + Math.random();
414
+ const height = 0.8 + Math.random() * 0.4;
415
+ const depth = 1 + Math.random();
416
+ const segmentGeo = new THREE.BoxGeometry(width, height, depth);
417
+ const segment = createMesh(segmentGeo, MAT.ruins_stone,
418
+ {x: basePos.x, y: basePos.y + height/2, z: basePos.z},
419
+ {x:(Math.random()-0.5)*0.1, y: rotY + (Math.random()-0.5)*0.1, z:(Math.random()-0.5)*0.1} // Slight random tilt
420
+ );
421
+ group.add(segment);
422
+ basePos.y += height; // Move up for next segment
423
+ }
424
+ }
425
+ group.visible = false;
426
+ return { group, lighting: 'ruins', title: "Ancient Ruins", entryText: `The wind howls through the skeletal remains of ${zoneId}. Broken stones lie everywhere.`, options: [{text: "Disturb Grave", action: "triggerCombat", enemy: "skeleton"}], zoneId: zoneId };
427
+ }
428
+
429
+ function createTownZone(zoneId) { // New Zone Type
430
+ console.log(`Creating zone: ${zoneId} (Town)`);
431
+ const group = new THREE.Group();
432
+ group.add(createGround(MAT.dirt.clone().set({color: 0xaa8866}), 30)); // Packed earth/cobble look
433
+ for(let i=0; i<10; i++) {
434
+ const house = createSimpleHouse(
435
+ 2 + Math.random()*2, // width
436
+ 1.8 + Math.random()*0.5, // height
437
+ 3 + Math.random()*2, // depth
438
+ MAT.town_wood, MAT.town_roof
439
+ );
440
+ house.position.set((Math.random()-0.5)*25, 0, (Math.random()-0.5)*25);
441
+ house.rotation.y = Math.floor(Math.random()*4) * Math.PI / 2 + (Math.random()-0.5)*0.1; // Align roughly to grid + tilt
442
+ group.add(house);
443
+ }
444
+ // Add a central well (example of combining primitives)
445
+ const wellBaseGeo = new THREE.CylinderGeometry(0.8, 0.8, 0.5, 12);
446
+ const wellBase = createMesh(wellBaseGeo, MAT.stone, {y: 0.25});
447
+ group.add(wellBase);
448
+ const wellWallGeo = new THREE.CylinderGeometry(0.7, 0.6, 1.0, 12);
449
+ const wellWall = createMesh(wellWallGeo, MAT.stone, {y: 0.5 + 0.5});
450
+ group.add(wellWall);
451
+
452
+ group.visible = false;
453
+ return { group, lighting: 'town', title: "Small Settlement", entryText: `You arrive at the small settlement in ${zoneId}. Smoke curls from a few chimneys.`, options: [{text:"Talk to Villager (TBC)", action:"talk"}], zoneId: zoneId };
454
+ }
455
+
456
+ function getZoneId(row, col) { return `zone_${row}_${col}`; }
457
+
458
+ function populateZoneCreators() {
459
+ console.log("Populating zone creators...");
460
+ // zoneCreators = {}; // Removed const error
461
+ for (let r = 0; r < MAP_ROWS; r++) {
462
+ for (let c = 0; c < MAP_COLS; c++) {
463
+ const zoneId = getZoneId(r, c);
464
+ let creatorFunc;
465
+ // More varied pattern
466
+ if (r === 0 && c === 0) creatorFunc = createCaveZone;
467
+ else if (r === 0) creatorFunc = createForestZone;
468
+ else if (r === 1 && c === 1) creatorFunc = createTownZone; // Add town
469
+ else if (r === 1 && c === 3) creatorFunc = createRuinsZone;
470
+ else if (r === 2 && c === 0) creatorFunc = createRuinsZone;
471
+ else if (r === 2 && c === 3) creatorFunc = createCaveZone;
472
+ else creatorFunc = createFieldZone; // Default to field
473
+
474
+ zoneCreators[zoneId] = () => creatorFunc(zoneId);
475
+ }
476
+ }
477
+ console.log(`Zone creators populated with ${Object.keys(zoneCreators).length} entries.`);
478
+ }
479
+
480
+ function getZoneNeighbors(zoneId) {
481
+ const parts = zoneId.split('_');
482
+ if (parts.length !== 3) return {};
483
+ const r = parseInt(parts[1]);
484
+ const c = parseInt(parts[2]);
485
+ const neighbors = {};
486
+ if (r > 0 && zoneCreators[getZoneId(r - 1, c)]) neighbors.north = getZoneId(r - 1, c);
487
+ if (r < MAP_ROWS - 1 && zoneCreators[getZoneId(r + 1, c)]) neighbors.south = getZoneId(r + 1, c);
488
+ if (c > 0 && zoneCreators[getZoneId(r, c - 1)]) neighbors.west = getZoneId(r, c - 1);
489
+ if (c < MAP_COLS - 1 && zoneCreators[getZoneId(r, c + 1)]) neighbors.east = getZoneId(r, c + 1);
490
+ return neighbors;
491
+ }
492
+
493
+ function startGame() {
494
+ console.log("startGame called.");
495
+ const defaultChar = {
496
+ name: "Player",
497
+ stats: { hp: 20, maxHp: 20, xp: 0, strength: 10, dexterity: 10, constitution: 10, intelligence: 10, wisdom: 10, charisma: 10 }, // Added D&D stats
498
+ inventory: []
499
+ };
500
+ gameState = {
501
+ currentZoneId: null,
502
+ character: JSON.parse(JSON.stringify(defaultChar)),
503
+ combat: null // Reset combat state
504
+ };
505
+ populateZoneCreators();
506
+ zoneGroups = {};
507
+ while(worldGroup.children.length > 0){
508
+ worldGroup.remove(worldGroup.children[0]);
509
+ }
510
+ console.log("Starting new game state:", gameState);
511
+ transitionToZone(getZoneId(1, 1)); // Start in center
512
+ console.log("startGame finished.");
513
+ }
514
+
515
+ function transitionToZone(newZoneId) {
516
+ console.log(`Attempting transition to ${newZoneId}`);
517
+ currentMessage = "";
518
+ gameState.combat = null; // End combat on zone change
519
+
520
+ if (gameState.currentZoneId && zoneGroups[gameState.currentZoneId]) {
521
+ console.log(`Hiding old zone: ${gameState.currentZoneId}`);
522
+ zoneGroups[gameState.currentZoneId].group.visible = false;
523
+ } else {
524
+ console.log("No current zone to hide.");
525
+ }
526
+
527
+ let zoneInfo;
528
+ if (zoneGroups[newZoneId]) {
529
+ zoneInfo = zoneGroups[newZoneId];
530
+ zoneInfo.group.visible = true;
531
+ console.log(`Re-entering cached zone ${newZoneId}`);
532
+ } else {
533
+ const creator = zoneCreators[newZoneId];
534
+ if (creator) {
535
+ try {
536
+ zoneInfo = creator();
537
+ zoneGroups[newZoneId] = zoneInfo;
538
+ worldGroup.add(zoneInfo.group);
539
+ zoneInfo.group.visible = true;
540
+ console.log(`Created and entered new zone ${newZoneId}`);
541
+ } catch (creationError) {
542
+ console.error(`Error creating zone ${newZoneId}:`, creationError);
543
+ currentMessage = `<p class="message message-failure">Error creating zone ${newZoneId}.</p>`;
544
+ renderCurrentPageUI(); return;
545
+ }
546
+ } else {
547
+ console.error(`No creator found for zone ID: ${newZoneId}, critical error.`);
548
+ currentMessage = `<p class="message message-failure">Error: Zone creator missing for ${newZoneId}. Cannot proceed.</p>`;
549
+ storyTitleElement.textContent = "Fatal Error"; storyContentElement.innerHTML = currentMessage; choicesElement.innerHTML = ''; return;
550
+ }
551
+ }
552
+
553
+ gameState.currentZoneId = newZoneId;
554
+ setupLighting(zoneInfo.lighting || 'default');
555
+
556
+ // Add tracked point lights to the current zone group
557
+ const currentZoneGroup = zoneInfo.group;
558
+ currentLights.forEach(light => {
559
+ if (light && light.isPointLight && zoneInfo.lighting === 'cave') { // Only add point lights to caves
560
+ if(light.parent) light.parent.remove(light);
561
+ currentZoneGroup.add(light);
562
+ console.log("Added point light to cave group:", newZoneId);
563
+ } else if (light && !light.isPointLight && !light.parent) { // Ensure ambient/directional are in main scene
564
+ scene.add(light);
565
+ }
566
+ });
567
+
568
+ camera.position.set(0, 8, 15); // Reset camera view slightly higher
569
+ controls.target.set(0, 1, 0);
570
+ controls.update();
571
+
572
+ console.log(`Transition to ${newZoneId} complete. Rendering UI.`);
573
+ renderCurrentPageUI();
574
+ }
575
+
576
+
577
+ function renderCurrentPageUI() {
578
+ console.log(`renderCurrentPageUI for zone: ${gameState.currentZoneId}`);
579
+ const zoneInfo = zoneGroups[gameState.currentZoneId];
580
+ const zoneId = gameState.currentZoneId;
581
+
582
+ if (!storyTitleElement || !storyContentElement || !choicesElement || !statsElement || !inventoryElement || !actionInfoElement) {
583
+ console.error("Crucial UI element missing!"); return;
584
+ }
585
+ if (!zoneInfo || !zoneInfo.group) {
586
+ console.error(`No zone info/group for ${zoneId}`);
587
+ storyTitleElement.textContent = "Error"; storyContentElement.innerHTML = currentMessage + "<p>Cannot render zone data.</p>";
588
+ choicesElement.innerHTML = `<button class="choice-button" onclick="transitionToZoneWrapper('${getZoneId(1, 1)}')">Return to Start</button>`;
589
+ updateStatsDisplay(); updateInventoryDisplay(); updateActionInfo(); return;
590
+ }
591
+
592
+ storyTitleElement.textContent = zoneInfo.title || "Unknown Zone";
593
+ storyContentElement.innerHTML = currentMessage + (zoneInfo.entryText ? `<p>${zoneInfo.entryText}</p>` : '');
594
+ choicesElement.innerHTML = '';
595
+
596
+ // --- Navigation Buttons (Always Show, Disable if no neighbor) ---
597
+ const neighbors = getZoneNeighbors(zoneId);
598
+ const directions = {'north': 'North', 'south': 'South', 'east': 'East', 'west': 'West'};
599
+ console.log("Adding neighbor buttons:", neighbors);
600
+ for(const dir in directions) {
601
+ const neighborId = neighbors[dir];
602
+ const button = document.createElement('button');
603
+ button.classList.add('choice-button');
604
+ button.textContent = `Go ${directions[dir]}`;
605
+ if (neighborId) {
606
+ button.addEventListener('click', () => transitionToZone(neighborId));
607
+ } else {
608
+ button.disabled = true; // Disable if no neighbor in this direction
609
+ }
610
+ choicesElement.appendChild(button);
611
+ }
612
+
613
+ // --- Zone Specific Action Buttons ---
614
+ if (zoneInfo.options && zoneInfo.options.length > 0) {
615
+ console.log("Adding zone specific options");
616
+ zoneInfo.options.forEach(option => {
617
+ const button = document.createElement('button');
618
+ button.classList.add('choice-button');
619
+ if (option.action === 'triggerCombat') { // Style combat buttons differently
620
+ button.classList.add('combat-button');
621
+ }
622
+ button.textContent = option.text;
623
+ button.addEventListener('click', () => handleZoneAction(option));
624
+ choicesElement.appendChild(button);
625
+ });
626
+ }
627
+
628
+ // --- Combat UI ---
629
+ if (gameState.combat?.active) {
630
+ choicesElement.innerHTML += `
631
+ <div id="combat-ui">
632
+ <p class="message message-combat">Combat vs ${gameState.combat.enemyName}! (Enemy HP: ${gameState.combat.enemyHp})</p>
633
+ <button class="choice-button combat-button" onclick="handleCombatAction('attack')">Roll to Attack!</button>
634
+ </div>`;
635
+ }
636
+
637
+ if (choicesElement.innerHTML === '') { // Check if empty after adding everything
638
+ choicesElement.innerHTML = '<p><i>No exits or actions defined here yet.</i></p>';
639
+ }
640
+
641
+ console.log("Updating stats, inventory, action info...");
642
+ updateStatsDisplay();
643
+ updateInventoryDisplay();
644
+ updateActionInfo();
645
+ console.log("renderCurrentPageUI finished.");
646
+ }
647
+
648
+ // Wrapper for potential inline use later (though addEventListener is preferred)
649
+ function transitionToZoneWrapper(zoneId) { transitionToZone(zoneId); }
650
+ window.transitionToZoneWrapper = transitionToZoneWrapper;
651
+
652
+ function handleZoneAction(option) {
653
+ console.log("Handling zone action:", option);
654
+ if (option.action === 'triggerCombat' && option.enemy) {
655
+ startCombat(option.enemy);
656
+ } else {
657
+ currentMessage = `<p class="message message-info">Action '${option.action || option.text}' not fully implemented yet.</p>`;
658
+ renderCurrentPageUI(); // Re-render to show message
659
+ }
660
+ }
661
+ window.handleZoneAction = handleZoneAction; // Make accessible
662
+
663
+ function updateStatsDisplay() {
664
+ if (!gameState.character || !statsElement) return;
665
+ const { hp, maxHp, xp, strength, dexterity, constitution, intelligence, wisdom, charisma } = gameState.character.stats;
666
+ const hpColor = hp / maxHp < 0.3 ? '#f88' : (hp / maxHp < 0.6 ? '#fd5' : '#8f8');
667
+ // Calculate basic modifiers (D&D style)
668
+ const strMod = Math.floor((strength - 10) / 2);
669
+ const dexMod = Math.floor((dexterity - 10) / 2);
670
+ const conMod = Math.floor((constitution - 10) / 2);
671
+ const intMod = Math.floor((intelligence - 10) / 2);
672
+ const wisMod = Math.floor((wisdom - 10) / 2);
673
+ const chaMod = Math.floor((charisma - 10) / 2);
674
+
675
+ statsElement.innerHTML = `<strong>Stats:</strong>
676
+ <span style="color:${hpColor}">HP: ${hp}/${maxHp}</span> <span>XP: ${xp}</span><br>
677
+ <span>Str: ${strength} (${strMod>=0?'+':''}${strMod})</span>
678
+ <span>Dex: ${dexterity} (${dexMod>=0?'+':''}${dexMod})</span>
679
+ <span>Con: ${constitution} (${conMod>=0?'+':''}${conMod})</span>
680
+ <span>Int: ${intelligence} (${intMod>=0?'+':''}${intMod})</span>
681
+ <span>Wis: ${wisdom} (${wisMod>=0?'+':''}${wisMod})</span>
682
+ <span>Cha: ${charisma} (${chaMod>=0?'+':''}${chaMod})</span>`;
683
+ }
684
+
685
+ function updateInventoryDisplay() {
686
+ if (!gameState.character || !inventoryElement) return;
687
+ let invHtml = '<strong>Inventory:</strong> ';
688
+ if (gameState.character.inventory.length === 0) {
689
+ invHtml += '<em>Empty</em>';
690
+ } else {
691
+ gameState.character.inventory.forEach(item => {
692
+ const itemDef = itemsData[item] || { type: 'unknown', description: '???' };
693
+ const itemClass = `item-${itemDef.type || 'unknown'}`;
694
+ invHtml += `<span class="item-tag ${itemClass}" title="${itemDef.description}">${item}</span>`;
695
+ });
696
+ }
697
+ inventoryElement.innerHTML = invHtml;
698
+ // No placement listeners needed now
699
+ }
700
+
701
+ function updateActionInfo() {
702
+ if (!actionInfoElement || !gameState ) return;
703
+ const mode = gameState.combat?.active ? "Combat" : "Explore";
704
+ actionInfoElement.textContent = `Zone: ${gameState.currentZoneId || 'None'} | Mode: ${mode}`;
705
+ }
706
+
707
+ // --- Combat & Item Functions ---
708
+ function startCombat(enemyTypeId) {
709
+ const enemyBase = enemyData[enemyTypeId];
710
+ if (!enemyBase) {
711
+ console.error("Unknown enemy type:", enemyTypeId);
712
+ currentMessage = `<p class="message message-failure">Error: Unknown enemy encountered!</p>`;
713
+ renderCurrentPageUI();
714
+ return;
715
+ }
716
+ gameState.combat = {
717
+ active: true,
718
+ enemyId: enemyTypeId,
719
+ enemyName: enemyBase.name,
720
+ enemyHp: enemyBase.hp,
721
+ enemyMaxHp: enemyBase.hp, // Store max HP
722
+ enemyDefense: enemyBase.defense,
723
+ enemyDamage: enemyBase.attackDamage,
724
+ enemyXp: enemyBase.xp,
725
+ enemyDrops: enemyBase.drops || []
726
+ };
727
+ currentMessage = `<p class="message message-combat">A wild ${enemyBase.name} appears!</p>`;
728
+ renderCurrentPageUI();
729
+ }
730
+
731
+ function handleCombatAction(action) {
732
+ if (!gameState.combat?.active) return;
733
+ currentMessage = ""; // Clear previous combat messages
734
+
735
+ if (action === 'attack') {
736
+ // --- Player Turn ---
737
+ const roll = Math.floor(Math.random() * 20) + 1;
738
+ const attackBonus = Math.floor((gameState.character.stats.strength - 10) / 2); // Simple STR mod
739
+ const totalAttack = roll + attackBonus;
740
+ const hit = totalAttack >= gameState.combat.enemyDefense;
741
+
742
+ displayDiceRoll(roll, hit); // Show the dice roll visually
743
+
744
+ if (hit) {
745
+ const weapon = gameState.character.inventory.find(i => itemsData[i]?.type === 'weapon');
746
+ const baseDamage = itemsData[weapon]?.baseDamage || 1; // Use weapon or 1
747
+ const damageRoll = Math.max(1, Math.floor(Math.random() * baseDamage) + 1 + attackBonus); // Add STR mod to damage
748
+ gameState.combat.enemyHp -= damageRoll;
749
+ currentMessage += `<p class="message message-success">You hit the ${gameState.combat.enemyName} for ${damageRoll} damage! (Rolled ${totalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
750
+ } else {
751
+ currentMessage += `<p class="message message-failure">You missed the ${gameState.combat.enemyName}. (Rolled ${totalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
752
+ }
753
+
754
+ // --- Check Enemy Defeat ---
755
+ if (gameState.combat.enemyHp <= 0) {
756
+ currentMessage += `<p class="message message-success"><b>You defeated the ${gameState.combat.enemyName}!</b></p>`;
757
+ gameState.character.stats.xp += gameState.combat.enemyXp;
758
+ currentMessage += `<p class="message message-info"><em>Gained ${gameState.combat.enemyXp} XP.</em></p>`;
759
+ if (gameState.combat.enemyDrops.length > 0) {
760
+ const droppedItemName = gameState.combat.enemyDrops[Math.floor(Math.random() * gameState.combat.enemyDrops.length)];
761
+ dropItemInZone(droppedItemName, new THREE.Vector3(Math.random()*2-1, 0, Math.random()*2-1));
762
+ currentMessage += `<p class="message message-item"><em>The ${gameState.combat.enemyName} dropped a ${droppedItemName}!</em></p>`;
763
+ }
764
+ gameState.combat = null; // End combat
765
+ renderCurrentPageUI(); // Update UI completely
766
+ return; // Exit function after enemy defeat
767
+ }
768
+
769
+ // --- Enemy Turn (if player didn't defeat it) ---
770
+ const enemyAttackRoll = Math.floor(Math.random() * 20) + 1 + 3; // Enemy bonus = +3
771
+ const playerDefense = 10 + Math.floor((gameState.character.stats.dexterity - 10) / 2); // Simple AC
772
+ if (enemyAttackRoll >= playerDefense) {
773
+ const enemyDamageDealt = Math.max(1, Math.floor(Math.random() * gameState.combat.enemyDamage) + 1);
774
+ gameState.character.stats.hp -= enemyDamageDealt;
775
+ currentMessage += `<p class="message message-failure">The ${gameState.combat.enemyName} hits you for ${enemyDamageDealt} damage! (Rolled ${enemyAttackRoll} vs AC ${playerDefense})</p>`;
776
+ if (gameState.character.stats.hp <= 0) {
777
+ currentMessage += `<p class="message message-failure"><b>You have been defeated!</b></p>`;
778
+ gameState.combat = null; // End combat
779
+ // TODO: Implement proper game over / respawn
780
+ gameState.character.stats.hp = 1; // Temp: reset to 1 hp
781
+ transitionToZone(getZoneId(1,1)); // Go back to start
782
+ return; // Exit after player defeat
783
+ }
784
+ } else {
785
+ currentMessage += `<p class="message message-info">The ${gameState.combat.enemyName} misses you. (Rolled ${enemyAttackRoll} vs AC ${playerDefense})</p>`;
786
+ }
787
+ renderCurrentPageUI(); // Update UI after both turns
788
+ }
789
+ }
790
+ window.handleCombatAction = handleCombatAction; // Make accessible
791
+
792
+ function displayDiceRoll(result, success) {
793
+ if (!threeFont) return; // Can't display if font isn't loaded
794
+
795
+ // Clear previous dice rolls immediately
796
+ activeTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
797
+ activeTimeouts = [];
798
+ worldGroup.children.filter(c => c.userData.isDiceRoll).forEach(c => worldGroup.remove(c));
799
+
800
+ const textGeo = new TextGeometry(result.toString(), { font: threeFont, size: 0.8, height: 0.1, curveSegments: 4 });
801
+ textGeo.computeBoundingBox();
802
+ const textWidth = textGeo.boundingBox.max.x - textGeo.boundingBox.min.x;
803
+ const textMat = MAT.text.clone();
804
+ textMat.color.setHex(success ? 0x88ff88 : 0xff8888);
805
+
806
+ const textMesh = new THREE.Mesh(textGeo, textMat);
807
+ textMesh.userData.isDiceRoll = true; // Mark for cleanup
808
+
809
+ const distance = 4; // How far in front of camera
810
+ const textPos = camera.position.clone().add(camera.getWorldDirection(new THREE.Vector3()).multiplyScalar(distance));
811
+ textMesh.position.copy(textPos);
812
+ textMesh.position.y += 1.5; // Higher up
813
+ textMesh.position.x -= textWidth / 2;
814
+ textMesh.quaternion.copy(camera.quaternion);
815
+
816
+ worldGroup.add(textMesh); // Add to world group, not scene directly
817
+
818
+ // Animate and remove
819
+ const duration = 2.0; // seconds
820
+ const startTime = clock.getElapsedTime();
821
+ textMesh.userData.startTime = startTime;
822
+ textMesh.userData.update = (time) => {
823
+ const elapsed = time - textMesh.userData.startTime;
824
+ if (elapsed >= duration) {
825
+ if (textMesh.parent) textMesh.parent.remove(textMesh);
826
+ delete textMesh.userData.update;
827
+ } else {
828
+ textMesh.position.y += 0.01; // Float up slowly
829
+ textMesh.material.opacity = 1.0 - (elapsed / duration); // Fade out (requires transparent=true on material)
830
+ // textMesh.scale.setScalar(1 + elapsed * 0.5); // Optionally grow
831
+ }
832
+ };
833
+ }
834
+
835
+ function dropItemInZone(itemName, positionOffset = new THREE.Vector3(0, 0, 0)) {
836
+ const currentGroup = zoneGroups[gameState.currentZoneId]?.group;
837
+ if (!currentGroup || !itemsData[itemName]) return;
838
+
839
+ const itemDef = itemsData[itemName];
840
+ const itemGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4);
841
+ const itemMat = MAT.simple.clone();
842
+ if(itemDef.type === 'weapon') itemMat.color.setHex(0xcc6666);
843
+ else if(itemDef.type === 'consumable') itemMat.color.setHex(0xcc9966);
844
+ else if(itemDef.type === 'quest') itemMat.color.setHex(0xcccc66);
845
+ else itemMat.color.setHex(0xaaaaee);
846
+
847
+ const dropPos = new THREE.Vector3(positionOffset.x, 0.2, positionOffset.z);
848
+ const droppedMesh = createMesh(itemGeo, itemMat, dropPos);
849
+ droppedMesh.userData = { isPickupable: true, itemName: itemName, description: `Dropped ${itemName}` };
850
+ currentGroup.add(droppedMesh);
851
+ console.log(`Dropped ${itemName} in zone ${gameState.currentZoneId}`);
852
+ }
853
+
854
+ function pickupItem() {
855
+ if (gameState.combat?.active) return; // No pickup during combat
856
+
857
+ raycaster.setFromCamera(mouse, camera);
858
+ const currentGroup = zoneGroups[gameState.currentZoneId]?.group;
859
+ if (!currentGroup) return;
860
+
861
+ const pickupables = [];
862
+ currentGroup.traverseVisible(child => {
863
+ if (child.userData.isPickupable) pickupables.push(child);
864
+ });
865
+
866
+ const intersects = raycaster.intersectObjects(pickupables, false);
867
+
868
+ if (intersects.length > 0) {
869
+ const clickedObject = intersects[0].object;
870
+ const itemName = clickedObject.userData.itemName;
871
+
872
+ if (itemName && itemsData[itemName]) {
873
+ console.log(`Picked up: ${itemName}`);
874
+ currentMessage = `<p class="message message-item"><em>Picked up: ${itemName}</em></p>`;
875
+
876
+ if (!gameState.character.inventory.includes(itemName)) {
877
+ gameState.character.inventory.push(itemName);
878
+ } else {
879
+ currentMessage += `<p class="message message-info"><em>(Cannot carry more ${itemName})</em></p>`;
880
+ }
881
+
882
+ clickedObject.visible = false;
883
+ clickedObject.userData.isPickupable = false;
884
+ // Consider removing object fully: clickedObject.parent.remove(clickedObject);
885
+
886
+ renderCurrentPageUI();
887
+ }
888
+ }
889
+ }
890
+
891
+ // --- Initialization ---
892
+ document.addEventListener('DOMContentLoaded', () => {
893
+ console.log("DOM Ready - Initializing World Grid + Interaction.");
894
+ try {
895
+ initThreeJS();
896
+ if (!scene || !camera || !renderer) throw new Error("Three.js failed to initialize.");
897
+ loadFontAndStart(); // Load font, then start game
898
+ console.log("Initialization sequence started.");
899
+ } catch (error) {
900
+ console.error("Initialization failed:", error);
901
+ storyTitleElement.textContent = "Initialization Error";
902
+ 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>`;
903
+ if(sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding: 20px;">3D Scene Failed</p>';
904
+ const statsInvContainer = document.getElementById('stats-inventory-container');
905
+ const choicesCont = document.getElementById('choices-container');
906
+ if (statsInvContainer) statsInvContainer.style.display = 'none';
907
+ if (choicesCont) choicesCont.style.display = 'none';
908
+ }
909
+ });
910
+
911
+ </script>
912
+ </body>
913
+ </html>