awacke1 commited on
Commit
e34e138
Β·
verified Β·
1 Parent(s): 6e26371

Update game.js

Browse files
Files changed (1) hide show
  1. game.js +357 -757
game.js CHANGED
@@ -1,825 +1,425 @@
1
- import * as THREE from 'three';
2
- // Optional: Add OrbitControls for debugging/viewing scene
3
- // import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4
-
5
- // --- DOM Elements ---
6
- const sceneContainer = document.getElementById('scene-container');
7
- const storyTitleElement = document.getElementById('story-title');
8
- const storyContentElement = document.getElementById('story-content');
9
- const choicesElement = document.getElementById('choices');
10
- const statsElement = document.getElementById('stats-display');
11
- const inventoryElement = document.getElementById('inventory-display');
12
-
13
- // --- Three.js Setup ---
14
- let scene, camera, renderer; // Basic scene objects
15
- let currentAssemblyGroup = null; // Group to hold the current scene's objects
16
- // let controls; // Optional OrbitControls
17
-
18
- // --- Shared Materials (Define common materials here for reuse) ---
19
- const stoneMaterial = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8, metalness: 0.1 });
20
- const woodMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.7, metalness: 0 });
21
- const darkWoodMaterial = new THREE.MeshStandardMaterial({ color: 0x5C3D20, roughness: 0.7, metalness: 0 });
22
- const leafMaterial = new THREE.MeshStandardMaterial({ color: 0x2E8B57, roughness: 0.6, metalness: 0 }); // SeaGreen
23
- const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x556B2F, roughness: 0.9, metalness: 0 }); // DarkOliveGreen
24
- const metalMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.8, roughness: 0.3 });
25
- const fabricMaterial = new THREE.MeshStandardMaterial({ color: 0x696969, roughness: 0.9, metalness: 0 }); // DimGray
26
- const waterMaterial = new THREE.MeshStandardMaterial({ color: 0x60A3D9, roughness: 0.2, metalness: 0.1, transparent: true, opacity: 0.7 });
27
- const templeMaterial = new THREE.MeshStandardMaterial({ color: 0xA99B78, roughness: 0.7, metalness: 0.1 }); // Light stone/sandstone
28
- const fireMaterial = new THREE.MeshStandardMaterial({ color: 0xFF4500, emissive: 0xff6600, roughness: 0.5, metalness: 0 }); // OrangeRed emissive
29
- const errorMaterial = new THREE.MeshStandardMaterial({ color: 0xffa500, roughness: 0.5 }); // Orange
30
- const gameOverMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.5 }); // Red
31
-
32
-
33
- function initThreeJS() {
34
- // Scene
35
- scene = new THREE.Scene();
36
- scene.background = new THREE.Color(0x222222); // Match body background
37
- // scene.fog = new THREE.Fog(0x222222, 8, 20); // Optional: Add fog for depth
38
-
39
- // Camera
40
- camera = new THREE.PerspectiveCamera(75, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000);
41
- camera.position.set(0, 2.5, 7); // Adjusted camera position for better view
42
- camera.lookAt(0, 0, 0);
43
-
44
- // Renderer
45
- renderer = new THREE.WebGLRenderer({ antialias: true });
46
- renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
47
- renderer.shadowMap.enabled = true; // Enable shadows
48
- renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows
49
- sceneContainer.appendChild(renderer.domElement);
50
-
51
- // Basic Lighting
52
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // Soft white light
53
- scene.add(ambientLight);
54
- const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
55
- directionalLight.position.set(8, 15, 10);
56
- directionalLight.castShadow = true; // Enable shadow casting
57
- // Configure shadow properties (optional, adjust for quality/performance)
58
- directionalLight.shadow.mapSize.width = 1024;
59
- directionalLight.shadow.mapSize.height = 1024;
60
- directionalLight.shadow.camera.near = 0.5;
61
- directionalLight.shadow.camera.far = 50;
62
- directionalLight.shadow.camera.left = -15;
63
- directionalLight.shadow.camera.right = 15;
64
- directionalLight.shadow.camera.top = 15;
65
- directionalLight.shadow.camera.bottom = -15;
66
- scene.add(directionalLight);
67
- // const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 5); // Helper to visualize light
68
- // scene.add(lightHelper);
69
- // const shadowCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera); // Helper for shadow camera
70
- // scene.add(shadowCameraHelper);
71
-
72
-
73
- // REMOVED: Basic Object (Placeholder) - Will be added dynamically
74
-
75
- // Optional Controls
76
- // controls = new OrbitControls(camera, renderer.domElement);
77
- // controls.enableDamping = true;
78
- // controls.target.set(0, 1, 0); // Adjust target if needed
79
-
80
- // Handle Resize
81
- window.addEventListener('resize', onWindowResize, false);
82
-
83
- // Start Animation Loop
84
- animate();
85
- }
86
-
87
- // --- Helper function to create meshes ---
88
- function createMesh(geometry, material, position = { x: 0, y: 0, z: 0 }, rotation = { x: 0, y: 0, z: 0 }, scale = { x: 1, y: 1, z: 1 }) {
89
- const mesh = new THREE.Mesh(geometry, material);
90
- mesh.position.set(position.x, position.y, position.z);
91
- mesh.rotation.set(rotation.x, rotation.y, rotation.z);
92
- mesh.scale.set(scale.x, scale.y, scale.z);
93
- mesh.castShadow = true;
94
- mesh.receiveShadow = true;
95
- return mesh;
96
- }
97
-
98
- // --- Procedural Generation Functions ---
99
-
100
- function createGroundPlane(material = groundMaterial, size = 20) {
101
- const groundGeo = new THREE.PlaneGeometry(size, size);
102
- const ground = new THREE.Mesh(groundGeo, material);
103
- ground.rotation.x = -Math.PI / 2; // Rotate flat
104
- ground.position.y = -0.05; // Slightly below origin
105
- ground.receiveShadow = true;
106
- return ground;
107
- }
108
 
109
- function createDefaultAssembly() {
110
- const group = new THREE.Group();
111
- const sphereGeo = new THREE.SphereGeometry(0.5, 16, 16);
112
- group.add(createMesh(sphereGeo, stoneMaterial, { x: 0, y: 0.5, z: 0 }));
113
- group.add(createGroundPlane());
114
- return group;
115
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
- function createCityGatesAssembly() {
118
- const group = new THREE.Group();
119
- const gateWallHeight = 4;
120
- const gateWallWidth = 1.5;
121
- const gateWallDepth = 0.8;
122
- const archHeight = 1;
123
- const archWidth = 3;
124
-
125
- // Left Tower
126
- const towerLeftGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth);
127
- group.add(createMesh(towerLeftGeo, stoneMaterial, { x: -(archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 }));
128
-
129
- // Right Tower
130
- const towerRightGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth);
131
- group.add(createMesh(towerRightGeo, stoneMaterial, { x: (archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 }));
132
-
133
- // Arch Top
134
- const archGeo = new THREE.BoxGeometry(archWidth, archHeight, gateWallDepth);
135
- group.add(createMesh(archGeo, stoneMaterial, { x: 0, y: gateWallHeight - archHeight / 2, z: 0 }));
136
-
137
- // Optional: Add crenellations (battlements)
138
- const crenellationSize = 0.4;
139
- for (let i = -2; i <= 2; i += 1) {
140
- const crenGeo = new THREE.BoxGeometry(crenellationSize, crenellationSize, gateWallDepth * 1.1);
141
- group.add(createMesh(crenGeo, stoneMaterial, { x: -(archWidth / 2 + gateWallWidth / 2) + i * crenellationSize * 1.5, y: gateWallHeight + crenellationSize / 2, z: 0 }));
142
- group.add(createMesh(crenGeo, stoneMaterial, { x: (archWidth / 2 + gateWallWidth / 2) + i * crenellationSize * 1.5, y: gateWallHeight + crenellationSize / 2, z: 0 }));
143
  }
144
 
145
- group.add(createGroundPlane(stoneMaterial)); // Stone ground
146
- return group;
 
 
 
147
  }
148
 
149
- function createWeaponsmithAssembly() {
150
- const group = new THREE.Group();
151
- const buildingWidth = 3;
152
- const buildingHeight = 2.5;
153
- const buildingDepth = 3.5;
154
- const roofHeight = 1;
155
-
156
- // Main Building
157
- const buildingGeo = new THREE.BoxGeometry(buildingWidth, buildingHeight, buildingDepth);
158
- group.add(createMesh(buildingGeo, darkWoodMaterial, { x: 0, y: buildingHeight / 2, z: 0 }));
159
-
160
- // Roof (simple triangular prism shape made of two planes)
161
- const roofGeo = new THREE.PlaneGeometry(buildingWidth * 1.1, Math.sqrt(Math.pow(buildingDepth / 2, 2) + Math.pow(roofHeight, 2)));
162
- const roofLeft = createMesh(roofGeo, woodMaterial, { x: 0, y: buildingHeight + roofHeight / 2, z: -buildingDepth / 4 }, { x: 0, y: 0, z: Math.atan2(roofHeight, buildingDepth / 2) });
163
- const roofRight = createMesh(roofGeo, woodMaterial, { x: 0, y: buildingHeight + roofHeight / 2, z: buildingDepth / 4 }, { x: 0, y: Math.PI, z: -Math.atan2(roofHeight, buildingDepth / 2) });
164
- group.add(roofLeft);
165
- group.add(roofRight);
166
- // Add gable ends (triangles)
167
- const gableShape = new THREE.Shape();
168
- gableShape.moveTo(-buildingWidth/2, buildingHeight);
169
- gableShape.lineTo(buildingWidth/2, buildingHeight);
170
- gableShape.lineTo(0, buildingHeight + roofHeight);
171
- gableShape.closePath();
172
- const gableGeo = new THREE.ShapeGeometry(gableShape);
173
- group.add(createMesh(gableGeo, woodMaterial, {x: 0, y: 0, z: buildingDepth/2}, {x: 0, y: 0, z: 0}));
174
- group.add(createMesh(gableGeo, woodMaterial, {x: 0, y: 0, z: -buildingDepth/2}, {x: 0, y: Math.PI, z: 0}));
175
-
176
-
177
- // Forge/Chimney (simple representation)
178
- const forgeHeight = 3;
179
- const forgeGeo = new THREE.CylinderGeometry(0.3, 0.4, forgeHeight, 8);
180
- group.add(createMesh(forgeGeo, stoneMaterial, { x: buildingWidth * 0.3, y: forgeHeight / 2, z: -buildingDepth * 0.3 }));
181
-
182
- // Anvil (simple block)
183
- const anvilGeo = new THREE.BoxGeometry(0.4, 0.5, 0.7);
184
- group.add(createMesh(anvilGeo, metalMaterial, { x: -buildingWidth * 0.2, y: 0.25, z: buildingDepth * 0.2 }));
185
-
186
-
187
- group.add(createGroundPlane());
188
- return group;
189
  }
190
 
191
- function createTempleAssembly() {
192
- const group = new THREE.Group();
193
- const baseSize = 5;
194
- const baseHeight = 0.5;
195
- const columnHeight = 3;
196
- const columnRadius = 0.25;
197
- const roofHeight = 1;
198
-
199
- // Base Platform
200
- const baseGeo = new THREE.BoxGeometry(baseSize, baseHeight, baseSize);
201
- group.add(createMesh(baseGeo, templeMaterial, { x: 0, y: baseHeight / 2, z: 0 }));
202
-
203
- // Columns (example: 4 columns)
204
- const colPositions = [
205
- { x: -baseSize / 3, z: -baseSize / 3 },
206
- { x: baseSize / 3, z: -baseSize / 3 },
207
- { x: -baseSize / 3, z: baseSize / 3 },
208
- { x: baseSize / 3, z: baseSize / 3 },
209
- ];
210
- const colGeo = new THREE.CylinderGeometry(columnRadius, columnRadius, columnHeight, 12);
211
- colPositions.forEach(pos => {
212
- group.add(createMesh(colGeo, templeMaterial, { x: pos.x, y: baseHeight + columnHeight / 2, z: pos.z }));
213
  });
214
 
215
- // Simple Roof Slab
216
- const roofGeo = new THREE.BoxGeometry(baseSize * 0.8, roofHeight / 2, baseSize * 0.8);
217
- group.add(createMesh(roofGeo, templeMaterial, { x: 0, y: baseHeight + columnHeight + roofHeight / 4, z: 0 }));
218
-
219
- // Optional: Pyramid roof top
220
- const pyramidGeo = new THREE.ConeGeometry(baseSize * 0.5, roofHeight * 1.5, 4); // 4 sides for pyramid
221
- group.add(createMesh(pyramidGeo, templeMaterial, { x: 0, y: baseHeight + columnHeight + roofHeight *0.75, z: 0 }, { x: 0, y: Math.PI / 4, z: 0 })); // Rotate for alignment
222
-
223
-
224
- group.add(createGroundPlane());
225
- return group;
226
- }
227
-
228
- function createResistanceMeetingAssembly() {
229
- const group = new THREE.Group();
230
- const tableWidth = 2;
231
- const tableHeight = 0.8;
232
- const tableDepth = 1;
233
- const tableThickness = 0.1;
234
-
235
- // Table Top
236
- const tableTopGeo = new THREE.BoxGeometry(tableWidth, tableThickness, tableDepth);
237
- group.add(createMesh(tableTopGeo, woodMaterial, { x: 0, y: tableHeight - tableThickness / 2, z: 0 }));
238
-
239
- // Table Legs
240
- const legHeight = tableHeight - tableThickness;
241
- const legSize = 0.1;
242
- const legGeo = new THREE.BoxGeometry(legSize, legHeight, legSize);
243
- const legOffsetW = tableWidth / 2 - legSize * 1.5;
244
- const legOffsetD = tableDepth / 2 - legSize * 1.5;
245
- group.add(createMesh(legGeo, woodMaterial, { x: -legOffsetW, y: legHeight / 2, z: -legOffsetD }));
246
- group.add(createMesh(legGeo, woodMaterial, { x: legOffsetW, y: legHeight / 2, z: -legOffsetD }));
247
- group.add(createMesh(legGeo, woodMaterial, { x: -legOffsetW, y: legHeight / 2, z: legOffsetD }));
248
- group.add(createMesh(legGeo, woodMaterial, { x: legOffsetW, y: legHeight / 2, z: legOffsetD }));
249
-
250
- // Simple Stools/Boxes for people to sit on
251
- const stoolSize = 0.4;
252
- const stoolGeo = new THREE.BoxGeometry(stoolSize, stoolSize * 0.8, stoolSize);
253
- group.add(createMesh(stoolGeo, darkWoodMaterial, { x: -tableWidth * 0.6, y: stoolSize * 0.4, z: 0 }));
254
- group.add(createMesh(stoolGeo, darkWoodMaterial, { x: tableWidth * 0.6, y: stoolSize * 0.4, z: 0 }));
255
- group.add(createMesh(stoolGeo, darkWoodMaterial, { x: 0, y: stoolSize * 0.4, z: -tableDepth * 0.7 }));
256
-
257
- // Dim room feeling - maybe add simple walls
258
- const wallHeight = 3;
259
- const wallThickness = 0.2;
260
- const roomSize = 5;
261
- const wallBackGeo = new THREE.BoxGeometry(roomSize, wallHeight, wallThickness);
262
- group.add(createMesh(wallBackGeo, stoneMaterial, { x: 0, y: wallHeight / 2, z: -roomSize / 2 }, {}));
263
- const wallLeftGeo = new THREE.BoxGeometry(wallThickness, wallHeight, roomSize);
264
- group.add(createMesh(wallLeftGeo, stoneMaterial, { x: -roomSize / 2, y: wallHeight / 2, z: 0 }, {}));
265
-
266
-
267
- group.add(createGroundPlane(stoneMaterial)); // Stone floor
268
- return group;
269
  }
270
 
271
- function createForestAssembly(treeCount = 15, area = 10) {
272
- const group = new THREE.Group();
273
-
274
- // Tree generation function
275
- const createTree = (x, z) => {
276
- const treeGroup = new THREE.Group();
277
- const trunkHeight = Math.random() * 2 + 2; // Random height between 2 and 4
278
- const trunkRadius = Math.random() * 0.15 + 0.1; // Random radius
279
- const trunkGeo = new THREE.CylinderGeometry(trunkRadius * 0.7, trunkRadius, trunkHeight, 8);
280
- treeGroup.add(createMesh(trunkGeo, woodMaterial, { x: 0, y: trunkHeight / 2, z: 0 }));
281
-
282
- // Foliage (simple sphere or cone)
283
- const foliageType = Math.random();
284
- const foliageHeight = trunkHeight * (Math.random() * 0.5 + 0.8); // Relative to trunk
285
- if (foliageType < 0.6) { // Sphere foliage
286
- const foliageRadius = trunkHeight * 0.4;
287
- const foliageGeo = new THREE.SphereGeometry(foliageRadius, 8, 6);
288
- treeGroup.add(createMesh(foliageGeo, leafMaterial, { x: 0, y: trunkHeight * 0.9 + foliageRadius * 0.5, z: 0 }));
289
- } else { // Cone foliage
290
- const foliageRadius = trunkHeight * 0.5;
291
- const coneGeo = new THREE.ConeGeometry(foliageRadius, foliageHeight, 8);
292
- treeGroup.add(createMesh(coneGeo, leafMaterial, { x: 0, y: trunkHeight * 0.9 + foliageHeight * 0.5, z: 0 }));
293
- }
294
- treeGroup.position.set(x, 0, z); // Set position for the whole tree
295
- // Slight random rotation for variation
296
- treeGroup.rotation.y = Math.random() * Math.PI * 2;
297
- return treeGroup;
298
- };
299
-
300
- // Scatter trees
301
- for (let i = 0; i < treeCount; i++) {
302
- const x = (Math.random() - 0.5) * area;
303
- const z = (Math.random() - 0.5) * area;
304
- // Basic check to avoid trees too close to the center (optional)
305
- if (Math.sqrt(x*x + z*z) > 1.5) {
306
- group.add(createTree(x, z));
307
- }
308
  }
309
-
310
- group.add(createGroundPlane()); // Forest floor
311
- return group;
312
  }
313
 
314
- function createRoadAmbushAssembly() {
315
- const group = new THREE.Group();
316
- const area = 12;
317
-
318
- // Add some forest elements
319
- const forestGroup = createForestAssembly(10, area);
320
- group.add(forestGroup); // Reuse forest generation
321
-
322
- // Add a simple road (a flat, wider plane)
323
- const roadWidth = 3;
324
- const roadLength = area * 1.5;
325
- const roadGeo = new THREE.PlaneGeometry(roadWidth, roadLength);
326
- const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x966F33, roughness: 0.9 }); // Muddy brown
327
- const road = createMesh(roadGeo, roadMaterial, {x: 0, y: 0.01, z: 0}, {x: -Math.PI / 2}); // Slightly above ground
328
- road.receiveShadow = true; // Ensure road receives shadows too
329
- group.add(road);
330
-
331
- // Add some rocks/bushes for cover (simple spheres/low boxes)
332
- const rockGeo = new THREE.SphereGeometry(0.5, 5, 4);
333
- const rockMaterial = new THREE.MeshStandardMaterial({ color: 0x666666, roughness: 0.8 });
334
- group.add(createMesh(rockGeo, rockMaterial, {x: roadWidth * 0.7, y: 0.25, z: 1}, {y: Math.random() * Math.PI}));
335
- group.add(createMesh(rockGeo, rockMaterial, {x: -roadWidth * 0.8, y: 0.3, z: -2}, {y: Math.random() * Math.PI, x: Math.random()*0.2}));
336
- group.add(createMesh(new THREE.SphereGeometry(0.7, 5, 4), rockMaterial, {x: roadWidth * 0.9, y: 0.35, z: -3}, {y: Math.random() * Math.PI}));
337
-
338
- // Suggestion: You could add simple cylinder/box figures near cover later for the ambushers
339
-
340
- // Ground plane is added by createForestAssembly
341
 
342
- return group;
343
- }
 
344
 
345
- function createForestEdgeAssembly() {
346
- const group = new THREE.Group();
347
- const area = 15;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
 
349
- // Dense forest on one side
350
- const forestGroup = new THREE.Group();
351
- for (let i = 0; i < 20; i++) { // More trees, denser area
352
- const x = (Math.random() - 0.9) * area / 2; // Skew to one side (negative X)
353
- const z = (Math.random() - 0.5) * area;
354
- forestGroup.add(createForestAssembly(1, 0).children[0].position.set(x,0,z)); // Add single tree procedurally
355
  }
356
- group.add(forestGroup);
357
-
358
-
359
- // Open plains on the other side (just ground)
360
- group.add(createGroundPlane(groundMaterial, area * 1.2)); // Larger ground plane
361
-
362
- return group;
363
  }
364
 
365
- function createPrisonerCellAssembly() {
366
- const group = new THREE.Group();
367
- const cellSize = 3;
368
- const wallHeight = 2.5;
369
- const wallThickness = 0.2;
370
- const barRadius = 0.04;
371
- const barSpacing = 0.2;
372
-
373
- // Floor
374
- group.add(createGroundPlane(stoneMaterial, cellSize));
375
-
376
- // Back Wall
377
- const wallBackGeo = new THREE.BoxGeometry(cellSize, wallHeight, wallThickness);
378
- group.add(createMesh(wallBackGeo, stoneMaterial, { x: 0, y: wallHeight / 2, z: -cellSize / 2 }));
379
-
380
- // Left Wall
381
- const wallSideGeo = new THREE.BoxGeometry(wallThickness, wallHeight, cellSize);
382
- group.add(createMesh(wallSideGeo, stoneMaterial, { x: -cellSize / 2, y: wallHeight / 2, z: 0 }));
383
-
384
- // Right Wall (Partial or Full)
385
- group.add(createMesh(wallSideGeo, stoneMaterial, { x: cellSize / 2, y: wallHeight / 2, z: 0 }));
386
-
387
- // Ceiling (optional)
388
- // const ceilingGeo = new THREE.BoxGeometry(cellSize, wallThickness, cellSize);
389
- // group.add(createMesh(ceilingGeo, stoneMaterial, { x: 0, y: wallHeight, z: 0 }));
390
 
391
-
392
- // Bars for the front
393
- const barGeo = new THREE.CylinderGeometry(barRadius, barRadius, wallHeight, 8);
394
- const numBars = Math.floor(cellSize / barSpacing);
395
- for (let i = 0; i <= numBars; i++) {
396
- const xPos = -cellSize / 2 + i * barSpacing;
397
- group.add(createMesh(barGeo, metalMaterial, { x: xPos, y: wallHeight / 2, z: cellSize / 2 }));
 
 
 
 
 
 
398
  }
399
- // Horizontal bars (top/bottom)
400
- const horizBarGeo = new THREE.BoxGeometry(cellSize, barRadius * 2, barRadius * 2);
401
- group.add(createMesh(horizBarGeo, metalMaterial, {x: 0, y: wallHeight - barRadius, z: cellSize/2}));
402
- group.add(createMesh(horizBarGeo, metalMaterial, {x: 0, y: barRadius, z: cellSize/2}));
403
-
404
-
405
- return group;
406
- }
407
-
408
- function createGameOverAssembly() {
409
- const group = new THREE.Group();
410
- const boxGeo = new THREE.BoxGeometry(2, 2, 2);
411
- group.add(createMesh(boxGeo, gameOverMaterial, { x: 0, y: 1, z: 0 }));
412
- group.add(createGroundPlane(stoneMaterial.clone().set({color: 0x333333}))); // Darker ground
413
- return group;
414
- }
415
-
416
- function createErrorAssembly() {
417
- const group = new THREE.Group();
418
- const coneGeo = new THREE.ConeGeometry( 0.8, 1.5, 8 );
419
- group.add(createMesh(coneGeo, errorMaterial, { x: 0, y: 0.75, z: 0 }));
420
- group.add(createGroundPlane());
421
- return group;
422
- }
423
-
424
-
425
- function onWindowResize() {
426
- if (!renderer || !camera) return;
427
- camera.aspect = sceneContainer.clientWidth / sceneContainer.clientHeight;
428
- camera.updateProjectionMatrix();
429
- renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
430
  }
431
 
432
- function animate() {
433
- requestAnimationFrame(animate);
434
-
435
- // Optional: Add subtle animation to the entire assembly
436
- if (currentAssemblyGroup) {
437
- // Example: Very slow rotation
438
- // currentAssemblyGroup.rotation.y += 0.0005;
439
- }
440
-
441
- // if (controls) controls.update(); // If using OrbitControls
442
-
443
- if (renderer && scene && camera) {
444
- renderer.render(scene, camera);
 
 
 
 
 
 
 
 
 
 
 
445
  }
 
446
  }
447
 
448
- // --- Game Data (Ported from Python, simplified for now) ---
449
- const gameData = {
450
- "1": {
451
- title: "The Beginning",
452
- content: `<p>The Evil Power Master has been terrorizing the land... You stand at the entrance to Silverhold, ready to begin your quest.</p><p>How will you prepare?</p>`,
453
- options: [
454
- { text: "Visit the local weaponsmith", next: 2, /* addItem: "..." */ },
455
- { text: "Seek wisdom at the temple", next: 3, /* addItem: "..." */ },
456
- { text: "Meet the resistance leader", next: 4, /* addItem: "..." */ }
457
- ],
458
- illustration: "city-gates" // Key for Three.js scene
459
- },
460
- "2": {
461
- title: "The Weaponsmith",
462
- content: `<p>Gorn the weaponsmith welcomes you. "You'll need more than common steel," he says, offering weapons.</p>`,
463
- options: [
464
- { text: "Take the Flaming Sword", next: 5, addItem: "Flaming Sword" },
465
- { text: "Choose the Whispering Bow", next: 5, addItem: "Whispering Bow" },
466
- { text: "Select the Guardian Shield", next: 5, addItem: "Guardian Shield" }
467
- ],
468
- illustration: "weaponsmith"
469
- },
470
- "3": {
471
- title: "The Ancient Temple",
472
- content: `<p>High Priestess Alara greets you. "Prepare your mind and spirit." She offers to teach you a secret art.</p>`,
473
- options: [
474
- { text: "Learn Healing Light", next: 5, addItem: "Healing Light Spell" },
475
- { text: "Master Shield of Faith", next: 5, addItem: "Shield of Faith Spell" },
476
- { text: "Study Binding Runes", next: 5, addItem: "Binding Runes Scroll" }
477
- ],
478
- illustration: "temple"
479
- },
480
- "4": {
481
- title: "The Resistance Leader",
482
- content: `<p>Lyra, the resistance leader, shows you a map. "His fortress has three possible entry points." She offers an item.</p>`,
483
- options: [
484
- { text: "Take the Secret Tunnel Map", next: 5, addItem: "Secret Tunnel Map" },
485
- { text: "Accept Poison Daggers", next: 5, addItem: "Poison Daggers" },
486
- { text: "Choose the Master Key", next: 5, addItem: "Master Key" }
487
- ],
488
- illustration: "resistance-meeting"
489
- },
490
- "5": {
491
- title: "The Journey Begins",
492
- content: `<p>You leave Silverhold and enter the corrupted Shadowwood Forest. Strange sounds echo. Which path will you take?</p>`,
493
- options: [
494
- { text: "Take the main road", next: 6 }, // Leads to page 6 (Ambush)
495
- { text: "Follow the river path", next: 7 }, // Leads to page 7 (River Spirit) - NEEDS 3D Scene
496
- { text: "Brave the ruins shortcut", next: 8 } // Leads to page 8 (Ruins) - NEEDS 3D Scene
497
- ],
498
- illustration: "shadowwood-forest" // Key for Three.js scene
499
- // Add more pages here...
500
- },
501
- // Add placeholder pages 6, 7, 8 etc. to continue the story
502
- "6": {
503
- title: "Ambush!",
504
- content: "<p>Scouts jump out from behind rocks and trees! 'Surrender!'</p>",
505
- options: [{ text: "Fight!", next: 9 }, { text: "Try to flee!", next: 10 }], // Example links
506
- illustration: "road-ambush"
507
- },
508
- "7": { // Placeholder - NEEDS 3D Scene function
509
- title: "River Path",
510
- content: "<p>You follow the winding river. The water seems unnaturally dark.</p>",
511
- options: [{ text: "Continue along the river", next: 11 }, { text: "Investigate strange glow", next: 12 }],
512
- illustration: "river-spirit" // Needs createRiverSpiritAssembly()
513
- },
514
- "8": { // Placeholder - NEEDS 3D Scene function
515
- title: "Ancient Ruins",
516
- content: "<p>Crumbling stones and overgrown vines mark ancient ruins. It feels watched.</p>",
517
- options: [{ text: "Search the main structure", next: 13 }, { text: "Look for hidden passages", next: 14 }],
518
- illustration: "ancient-ruins" // Needs createRuinsAssembly()
519
- },
520
- "9": { // Example continuation
521
- title: "Victory!",
522
- content: "<p>You defeat the scouts and retrieve some basic supplies. The forest edge is near.</p>",
523
- options: [{ text: "Proceed to the fortress plains", next: 15 }],
524
- illustration: "forest-edge"
525
- },
526
- "10": { // Example continuation
527
- title: "Captured!",
528
- content: "<p>Your attempt to flee fails! You are knocked out and awaken in a dark, damp cell.</p>",
529
- options: [{ text: "Wait and observe", next: 20 }], // Go to prison observation page
530
- illustration: "prisoner-cell"
531
- },
532
- // ... Add many more pages based on your Python data ...
533
- "15": { // Placeholder for plains
534
- title: "Fortress Plains",
535
- content: "<p>You emerge from the forest onto windswept plains. The dark fortress looms ahead.</p>",
536
- options: [{ text: "Approach the main gate", next: 30 }, { text: "Scout the perimeter", next: 31 }],
537
- illustration: "fortress-plains" // Needs createFortressPlainsAssembly()
538
- },
539
- "20": { // Placeholder for cell observation
540
- title: "Inside the Cell",
541
- content: "<p>The cell is small and cold. You hear guards patrolling outside.</p>",
542
- options: [{ text: "Look for weaknesses in the bars", next: 21 }, { text: "Try to talk to a guard", next: 22 }],
543
- illustration: "prisoner-cell" // Reuse cell
544
- },
545
- // Game Over placeholder
546
- "99": {
547
- title: "Game Over",
548
- content: "<p>Your adventure ends here.</p>",
549
- options: [{ text: "Restart", next: 1 }], // Link back to start
550
- illustration: "game-over",
551
- gameOver: true
552
- }
553
- };
554
-
555
- const itemsData = { // Simplified item data
556
- "Flaming Sword": { type: "weapon", description: "A fiery blade" },
557
- "Whispering Bow": { type: "weapon", description: "A silent bow" },
558
- "Guardian Shield": { type: "armor", description: "A protective shield" },
559
- "Healing Light Spell": { type: "spell", description: "Mends minor wounds" },
560
- "Shield of Faith Spell": { type: "spell", description: "Temporary shield" },
561
- "Binding Runes Scroll": { type: "spell", description: "Binds an enemy" },
562
- "Secret Tunnel Map": { type: "quest", description: "Shows a hidden path" },
563
- "Poison Daggers": { type: "weapon", description: "Daggers with poison" },
564
- "Master Key": { type: "quest", description: "Unlocks many doors" },
565
- // Add other items...
566
- };
567
-
568
- // --- Game State ---
569
- let gameState = {
570
- currentPageId: 1,
571
- inventory: [],
572
- stats: {
573
- courage: 7,
574
- wisdom: 5,
575
- strength: 6,
576
- hp: 30,
577
- maxHp: 30
578
  }
579
- };
580
-
581
- // --- Game Logic Functions ---
582
-
583
- function startGame() {
584
- gameState = { // Reset state
585
- currentPageId: 1,
586
- inventory: [],
587
- stats: { courage: 7, wisdom: 5, strength: 6, hp: 30, maxHp: 30 }
588
- };
589
- renderPage(gameState.currentPageId);
590
  }
591
 
592
- function renderPage(pageId) {
593
- const page = gameData[pageId];
594
- if (!page) {
595
- console.error(`Error: Page data not found for ID: ${pageId}`);
596
- storyTitleElement.textContent = "Error";
597
- storyContentElement.innerHTML = "<p>Could not load page data. Adventure halted.</p>";
598
- choicesElement.innerHTML = '<button class="choice-button" onclick="handleChoiceClick({ nextPage: 1 })">Restart</button>'; // Provide restart option
599
- updateScene('error'); // Show error scene
600
- return;
601
- }
602
-
603
- // Update UI
604
- storyTitleElement.textContent = page.title || "Untitled Page";
605
- storyContentElement.innerHTML = page.content || "<p>...</p>";
606
- updateStatsDisplay();
607
- updateInventoryDisplay();
608
-
609
- // Update Choices
610
- choicesElement.innerHTML = ''; // Clear old choices
611
- if (page.options && page.options.length > 0) {
612
- page.options.forEach(option => {
613
- const button = document.createElement('button');
614
- button.classList.add('choice-button');
615
- button.textContent = option.text;
616
-
617
- // Check requirements (basic check for now)
618
- let requirementMet = true;
619
- if (option.requireItem && !gameState.inventory.includes(option.requireItem)) {
620
- requirementMet = false;
621
- button.title = `Requires: ${option.requireItem}`; // Tooltip
622
- button.disabled = true;
623
- }
624
- // Add requireAnyItem check here later if needed
625
 
626
- if (requirementMet) {
627
- // Store data needed for handling the choice using dataset
628
- const choiceData = { nextPage: option.next }; // Always include next page
629
- if (option.addItem) {
630
- choiceData.addItem = option.addItem;
631
- }
632
- // Add other potential effects as data attributes if needed (e.g., data-stat-change="strength:1")
633
 
634
- // Use an event listener instead of inline onclick for better practice
635
- button.addEventListener('click', () => handleChoiceClick(choiceData));
636
 
637
- } else {
638
- button.classList.add('disabled'); // Style disabled buttons
639
- }
640
 
641
- choicesElement.appendChild(button);
642
- });
643
- } else if (page.gameOver) {
644
- const button = document.createElement('button');
645
- button.classList.add('choice-button');
646
- button.textContent = "Restart Adventure";
647
- button.addEventListener('click', () => handleChoiceClick({ nextPage: 1 })); // Restart goes to page 1
648
- choicesElement.appendChild(button);
649
- } else {
650
- // Handle dead ends where no options are defined and it's not game over
651
- choicesElement.innerHTML = '<p><i>There are no further paths from here.</i></p>';
652
- const button = document.createElement('button');
653
- button.classList.add('choice-button');
654
- button.textContent = "Restart Adventure";
655
- button.addEventListener('click', () => handleChoiceClick({ nextPage: 1 })); // Restart goes to page 1
656
- choicesElement.appendChild(button);
657
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
 
659
-
660
- // Update 3D Scene
661
- updateScene(page.illustration || 'default');
662
  }
663
 
664
-
665
- // Modified handleChoiceClick to accept an object
666
  function handleChoiceClick(choiceData) {
667
- const nextPageId = parseInt(choiceData.nextPage); // Ensure it's a number
668
  const itemToAdd = choiceData.addItem;
669
- // Add other potential effects from choiceData here (e.g., stat changes tied to the *choice itself*)
670
 
671
  if (isNaN(nextPageId)) {
672
- console.error("Invalid nextPageId:", choiceData.nextPage);
673
- return;
674
  }
675
 
676
- // --- Process Effects of Making the Choice ---
677
- // Add item if specified and not already present
678
- if (itemToAdd && !gameState.inventory.includes(itemToAdd)) {
679
- gameState.inventory.push(itemToAdd);
680
  console.log("Added item:", itemToAdd);
 
 
681
  }
682
- // Add stat changes/hp loss *linked to the choice itself* here if needed
683
 
684
- // --- Move to Next Page and Process Landing Effects ---
685
  gameState.currentPageId = nextPageId;
686
-
687
  const nextPageData = gameData[nextPageId];
 
688
  if (nextPageData) {
689
  // Apply HP loss defined on the *landing* page
690
  if (nextPageData.hpLoss) {
691
- gameState.stats.hp -= nextPageData.hpLoss;
692
  console.log(`Lost ${nextPageData.hpLoss} HP.`);
693
- if (gameState.stats.hp <= 0) {
 
694
  console.log("Player died from HP loss!");
695
- gameState.stats.hp = 0;
696
- renderPage(99); // Go to a specific game over page ID
697
- return; // Stop further processing
698
  }
699
  }
700
- // Apply stat increase defined on the *landing* page
701
- if (nextPageData.statIncrease) {
702
- const stat = nextPageData.statIncrease.stat;
703
- const amount = nextPageData.statIncrease.amount;
704
- if (gameState.stats.hasOwnProperty(stat)) {
705
- gameState.stats[stat] += amount;
706
- console.log(`Stat ${stat} increased by ${amount}.`);
707
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  }
709
- // Check if landing page is game over
710
- if (nextPageData.gameOver) {
711
- console.log("Reached Game Over page.");
712
- renderPage(nextPageId);
713
- return;
714
- }
715
 
716
  } else {
717
  console.error(`Data for page ${nextPageId} not found!`);
718
- // Optionally go to an error page or restart
719
- renderPage(99); // Go to game over page as fallback
720
  return;
721
  }
722
 
723
-
724
- // Render the new page
 
725
  renderPage(nextPageId);
726
  }
727
 
728
-
729
- function updateStatsDisplay() {
730
- let statsHTML = '<strong>Stats:</strong> ';
731
- statsHTML += `<span>HP: ${gameState.stats.hp}/${gameState.stats.maxHp}</span>`;
732
- statsHTML += `<span>Str: ${gameState.stats.strength}</span>`;
733
- statsHTML += `<span>Wis: ${gameState.stats.wisdom}</span>`;
734
- statsHTML += `<span>Cor: ${gameState.stats.courage}</span>`;
735
- statsElement.innerHTML = statsHTML;
736
- }
737
-
738
- function updateInventoryDisplay() {
739
- let inventoryHTML = '<strong>Inventory:</strong> ';
740
- if (gameState.inventory.length === 0) {
741
- inventoryHTML += '<em>Empty</em>';
742
- } else {
743
- gameState.inventory.forEach(item => {
744
- const itemInfo = itemsData[item] || { type: 'unknown', description: '???' };
745
- // Add class based on item type for styling
746
- const itemClass = `item-${itemInfo.type || 'unknown'}`;
747
- inventoryHTML += `<span class="${itemClass}" title="${itemInfo.description}">${item}</span>`;
748
- });
749
- }
750
- inventoryElement.innerHTML = inventoryHTML;
751
- }
752
-
753
-
754
- function updateScene(illustrationKey) {
755
- console.log("Updating scene for:", illustrationKey);
756
-
757
- // 1. Remove the old assembly if it exists
758
- if (currentAssemblyGroup) {
759
- scene.remove(currentAssemblyGroup);
760
- // Optional: Dispose of geometries and materials if scenes get complex
761
- // currentAssemblyGroup.traverse(child => {
762
- // if (child.isMesh) {
763
- // if(child.geometry) child.geometry.dispose();
764
- // // Dispose materials carefully if they are shared!
765
- // // If not shared: if(child.material) child.material.dispose();
766
- // }
767
- // });
768
- }
769
- currentAssemblyGroup = null; // Reset the reference
770
-
771
- // 2. Select the generation function based on the key
772
- let assemblyFunction;
773
- switch (illustrationKey) {
774
- case 'city-gates': assemblyFunction = createCityGatesAssembly; break;
775
- case 'weaponsmith': assemblyFunction = createWeaponsmithAssembly; break;
776
- case 'temple': assemblyFunction = createTempleAssembly; break;
777
- case 'resistance-meeting': assemblyFunction = createResistanceMeetingAssembly; break;
778
- case 'shadowwood-forest': assemblyFunction = createForestAssembly; break;
779
- case 'road-ambush': assemblyFunction = createRoadAmbushAssembly; break;
780
- case 'forest-edge': assemblyFunction = createForestEdgeAssembly; break;
781
- case 'prisoner-cell': assemblyFunction = createPrisonerCellAssembly; break;
782
- case 'game-over': assemblyFunction = createGameOverAssembly; break;
783
- case 'error': assemblyFunction = createErrorAssembly; break;
784
-
785
- // --- Add cases for new/missing scenes ---
786
- // case 'river-spirit': assemblyFunction = createRiverSpiritAssembly; break; // TODO
787
- // case 'ancient-ruins': assemblyFunction = createRuinsAssembly; break; // TODO
788
- // case 'fortress-plains': assemblyFunction = createFortressPlainsAssembly; break; // TODO
789
-
790
- default:
791
- console.warn(`No specific assembly function found for key: ${illustrationKey}. Using default.`);
792
- assemblyFunction = createDefaultAssembly;
793
- break;
794
- }
795
-
796
- // 3. Create the new assembly
797
- try {
798
- currentAssemblyGroup = assemblyFunction();
799
- } catch (error) {
800
- console.error(`Error creating assembly for key ${illustrationKey}:`, error);
801
- currentAssemblyGroup = createErrorAssembly(); // Show error scene on generation failure
802
- }
803
-
804
-
805
- // 4. Add the new assembly to the scene
806
- if (currentAssemblyGroup) {
807
- scene.add(currentAssemblyGroup);
808
- // Optional: Slightly randomize overall rotation/position for non-fixed scenes like forests
809
- if (['shadowwood-forest', 'road-ambush', 'forest-edge'].includes(illustrationKey)) {
810
- currentAssemblyGroup.rotation.y = Math.random() * 0.1 - 0.05; // Small random Y rotation
811
- }
812
- } else {
813
- console.error(`Assembly function for ${illustrationKey} did not return a group.`);
814
- currentAssemblyGroup = createErrorAssembly(); // Fallback
815
- scene.add(currentAssemblyGroup);
816
- }
817
- }
818
-
819
-
820
- // --- Initialization ---
821
- initThreeJS();
822
- startGame(); // Start the game after setting up Three.js
823
-
824
- // Removed global handleChoiceClick - now using event listeners in renderPage
825
- // window.handleChoiceClick = handleChoiceClick;
 
1
+ // --- Game State (Modify Existing) ---
2
+ let gameState = {
3
+ currentPageId: 1,
4
+ // πŸ‘‡ Encapsulate character data
5
+ character: {
6
+ name: "Hero",
7
+ race: "Human",
8
+ alignment: "Neutral Good",
9
+ class: "Fighter",
10
+ level: 1,
11
+ xp: 0,
12
+ xpToNextLevel: 100, // Experience needed for level 2
13
+ statPointsPerLevel: 1, // How many points earned on level up (optional)
14
+ availableStatPoints: 0, // Points available to spend
15
+ stats: {
16
+ strength: 7,
17
+ intelligence: 5,
18
+ wisdom: 5, // Corrected spelling from before
19
+ dexterity: 6,
20
+ constitution: 6, // Added constitution
21
+ charisma: 5, // Added charisma
22
+ hp: 30,
23
+ maxHp: 30
24
+ },
25
+ inventory: [] // Will mirror items collected in game
26
+ }
27
+ // Note: We removed the top-level 'stats' and 'inventory' as they are now inside character
28
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
+ // --- DOM Element Getters (Add New) ---
31
+ const charNameInput = document.getElementById('char-name');
32
+ const charRaceSpan = document.getElementById('char-race');
33
+ const charAlignmentSpan = document.getElementById('char-alignment');
34
+ const charClassSpan = document.getElementById('char-class');
35
+ const charLevelSpan = document.getElementById('char-level');
36
+ const charXPSpan = document.getElementById('char-xp');
37
+ const charXPNextSpan = document.getElementById('char-xp-next');
38
+ const charHPSpan = document.getElementById('char-hp');
39
+ const charMaxHPSpan = document.getElementById('char-max-hp');
40
+ const charInventoryList = document.getElementById('char-inventory-list');
41
+ const statSpans = { // Map stat names to their display elements
42
+ strength: document.getElementById('stat-strength'),
43
+ intelligence: document.getElementById('stat-intelligence'),
44
+ wisdom: document.getElementById('stat-wisdom'),
45
+ dexterity: document.getElementById('stat-dexterity'),
46
+ constitution: document.getElementById('stat-constitution'),
47
+ charisma: document.getElementById('stat-charisma'),
48
+ };
49
+ const statIncreaseButtons = document.querySelectorAll('.stat-increase');
50
+ const levelUpButton = document.getElementById('levelup-btn');
51
+ const saveCharButton = document.getElementById('save-char-btn');
52
+ const exportCharButton = document.getElementById('export-char-btn');
53
+ const statIncreaseCostSpan = document.getElementById('stat-increase-cost');
54
+
55
+ // --- NEW Character Sheet Functions ---
56
+
57
+ /**
58
+ * Renders the entire character sheet based on gameState.character
59
+ */
60
+ function renderCharacterSheet() {
61
+ const char = gameState.character;
62
+
63
+ charNameInput.value = char.name;
64
+ charRaceSpan.textContent = char.race;
65
+ charAlignmentSpan.textContent = char.alignment;
66
+ charClassSpan.textContent = char.class;
67
+ charLevelSpan.textContent = char.level;
68
+ charXPSpan.textContent = char.xp;
69
+ charXPNextSpan.textContent = char.xpToNextLevel;
70
+
71
+ // Update HP (ensure it doesn't exceed maxHP)
72
+ char.stats.hp = Math.min(char.stats.hp, char.stats.maxHp);
73
+ charHPSpan.textContent = char.stats.hp;
74
+ charMaxHPSpan.textContent = char.stats.maxHp;
75
+
76
+ // Update core stats display
77
+ for (const stat in statSpans) {
78
+ if (statSpans.hasOwnProperty(stat) && char.stats.hasOwnProperty(stat)) {
79
+ statSpans[stat].textContent = char.stats[stat];
80
+ }
81
+ }
82
 
83
+ // Update inventory list (up to 15 slots)
84
+ charInventoryList.innerHTML = ''; // Clear previous list
85
+ const maxSlots = 15;
86
+ for (let i = 0; i < maxSlots; i++) {
87
+ const li = document.createElement('li');
88
+ if (i < char.inventory.length) {
89
+ const item = char.inventory[i];
90
+ const itemInfo = itemsData[item] || { type: 'unknown', description: '???' };
91
+ const itemSpan = document.createElement('span');
92
+ itemSpan.classList.add(`item-${itemInfo.type || 'unknown'}`);
93
+ itemSpan.title = itemInfo.description;
94
+ itemSpan.textContent = item;
95
+ li.appendChild(itemSpan);
96
+ } else {
97
+ // Add placeholder for empty slot
98
+ const emptySlotSpan = document.createElement('span');
99
+ emptySlotSpan.classList.add('item-slot');
100
+ li.appendChild(emptySlotSpan);
101
+ }
102
+ charInventoryList.appendChild(li);
 
 
 
 
 
 
103
  }
104
 
105
+ // Update level up / stat increase buttons state
106
+ updateLevelUpAvailability();
107
+
108
+ // Display cost to increase stat (example: level * 10)
109
+ statIncreaseCostSpan.textContent = calculateStatIncreaseCost();
110
  }
111
 
112
+ /**
113
+ * Calculates the XP cost to increase a stat (example logic)
114
+ */
115
+ function calculateStatIncreaseCost() {
116
+ // Cost could depend on current stat value or level
117
+ return (gameState.character.level * 10) + 5; // Example: 15 XP at level 1, 25 at level 2 etc.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  }
119
 
120
+ /**
121
+ * Enables/disables level up and stat increase buttons based on XP/Points
122
+ */
123
+ function updateLevelUpAvailability() {
124
+ const char = gameState.character;
125
+ const canLevelUp = char.xp >= char.xpToNextLevel;
126
+ levelUpButton.disabled = !canLevelUp;
127
+
128
+ const canIncreaseStat = char.availableStatPoints > 0 || (char.xp >= calculateStatIncreaseCost()); // Can spend points OR XP
129
+ statIncreaseButtons.forEach(button => {
130
+ // Enable if points available OR if enough XP (and not leveling up)
131
+ button.disabled = !(char.availableStatPoints > 0 || (char.xp >= calculateStatIncreaseCost()));
132
+ // Optionally disable if level up is pending to force level up first?
133
+ // button.disabled = button.disabled || canLevelUp;
 
 
 
 
 
 
 
 
134
  });
135
 
136
+ // Enable spending stat points ONLY if available > 0
137
+ if (char.availableStatPoints > 0) {
138
+ statIncreaseCostSpan.parentElement.innerHTML = `<small>Available points: ${char.availableStatPoints} / Cost per point: 1</small>`;
139
+ statIncreaseButtons.forEach(button => button.disabled = false);
140
+ } else {
141
+ statIncreaseCostSpan.parentElement.innerHTML = `<small>Cost to increase stat: <span id="stat-increase-cost">${calculateStatIncreaseCost()}</span> XP</small>`;
142
+ // Disable based on XP check done above
143
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  }
145
 
146
+ /**
147
+ * Handles leveling up the character
148
+ */
149
+ function handleLevelUp() {
150
+ const char = gameState.character;
151
+ if (char.xp >= char.xpToNextLevel) {
152
+ char.level++;
153
+ char.xp -= char.xpToNextLevel; // Subtract cost
154
+ char.xpToNextLevel = Math.floor(char.xpToNextLevel * 1.6); // Increase next level cost (adjust multiplier)
155
+ char.availableStatPoints += char.statPointsPerLevel; // Grant stat points
156
+
157
+ // Increase max HP based on Constitution (example: + half CON modifier)
158
+ const conModifier = Math.floor((char.stats.constitution - 10) / 2);
159
+ const hpGain = Math.max(1, Math.floor(Math.random() * 6) + 1 + conModifier); // Roll d6 + CON mod (like D&D)
160
+ char.stats.maxHp += hpGain;
161
+ char.stats.hp = char.stats.maxHp; // Full heal on level up
162
+
163
+ console.log(`πŸŽ‰ Leveled Up to ${char.level}! Gained ${char.statPointsPerLevel} stat point(s) and ${hpGain} HP.`);
164
+ renderCharacterSheet(); // Update display
165
+ } else {
166
+ console.warn("Not enough XP to level up yet.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  }
 
 
 
168
  }
169
 
170
+ /**
171
+ * Handles increasing a specific stat
172
+ */
173
+ function handleStatIncrease(statName) {
174
+ const char = gameState.character;
175
+ const cost = calculateStatIncreaseCost();
176
+
177
+ // Priority 1: Spend available stat points
178
+ if (char.availableStatPoints > 0) {
179
+ char.stats[statName]++;
180
+ char.availableStatPoints--;
181
+ console.log(`πŸ“ˆ Increased ${statName} using a point. ${char.availableStatPoints} points remaining.`);
182
+
183
+ // Update derived stats if needed (e.g., CON affects maxHP)
184
+ if (statName === 'constitution') {
185
+ const oldModifier = Math.floor((char.stats.constitution - 1 - 10) / 2);
186
+ const newModifier = Math.floor((char.stats.constitution - 10) / 2);
187
+ const hpBonusPerLevel = Math.max(0, newModifier - oldModifier) * char.level; // Gain HP retroactively? Or just going forward? Simpler: just add difference.
188
+ if(hpBonusPerLevel > 0) {
189
+ console.log(`Increased max HP by ${hpBonusPerLevel} due to CON increase.`);
190
+ char.stats.maxHp += hpBonusPerLevel;
191
+ char.stats.hp += hpBonusPerLevel; // Also increase current HP
192
+ }
193
+ }
 
 
 
194
 
195
+ renderCharacterSheet();
196
+ return; // Exit after spending a point
197
+ }
198
 
199
+ // Priority 2: Spend XP if no points are available
200
+ if (char.xp >= cost) {
201
+ char.stats[statName]++;
202
+ char.xp -= cost;
203
+ console.log(`πŸ’ͺ Increased ${statName} for ${cost} XP.`);
204
+
205
+ // Update derived stats (same as above)
206
+ if (statName === 'constitution') {
207
+ const oldModifier = Math.floor((char.stats.constitution - 1 - 10) / 2);
208
+ const newModifier = Math.floor((char.stats.constitution - 10) / 2);
209
+ const hpBonusPerLevel = Math.max(0, newModifier - oldModifier) * char.level;
210
+ if(hpBonusPerLevel > 0) {
211
+ console.log(`Increased max HP by ${hpBonusPerLevel} due to CON increase.`);
212
+ char.stats.maxHp += hpBonusPerLevel;
213
+ char.stats.hp += hpBonusPerLevel;
214
+ }
215
+ }
216
 
217
+ renderCharacterSheet();
218
+ } else {
219
+ console.warn(`Not enough XP (${char.xp}/${cost}) or stat points to increase ${statName}.`);
 
 
 
220
  }
 
 
 
 
 
 
 
221
  }
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
+ /**
225
+ * Saves character data to localStorage
226
+ */
227
+ function saveCharacter() {
228
+ try {
229
+ localStorage.setItem('textAdventureCharacter', JSON.stringify(gameState.character));
230
+ console.log('πŸ’Ύ Character saved locally.');
231
+ // Optional: Add brief visual confirmation
232
+ saveCharButton.textContent = 'πŸ’Ύ Saved!';
233
+ setTimeout(() => { saveCharButton.innerHTML = 'πŸ’Ύ<span class="btn-label">Save</span>'; }, 1500);
234
+ } catch (e) {
235
+ console.error('Error saving character to localStorage:', e);
236
+ alert('Failed to save character. Local storage might be full or disabled.');
237
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  }
239
 
240
+ /**
241
+ * Loads character data from localStorage
242
+ */
243
+ function loadCharacter() {
244
+ try {
245
+ const savedData = localStorage.getItem('textAdventureCharacter');
246
+ if (savedData) {
247
+ const loadedChar = JSON.parse(savedData);
248
+ // Basic validation / merging with default structure
249
+ gameState.character = {
250
+ ...gameState.character, // Start with defaults
251
+ ...loadedChar, // Override with loaded data
252
+ stats: { // Ensure stats object exists and merge
253
+ ...gameState.character.stats,
254
+ ...(loadedChar.stats || {})
255
+ },
256
+ inventory: loadedChar.inventory || [] // Ensure inventory array exists
257
+ };
258
+ console.log('πŸ’Ύ Character loaded from local storage.');
259
+ return true; // Indicate success
260
+ }
261
+ } catch (e) {
262
+ console.error('Error loading character from localStorage:', e);
263
+ // Don't overwrite gameState if loading fails
264
  }
265
+ return false; // Indicate nothing loaded or error
266
  }
267
 
268
+ /**
269
+ * Exports character data as a JSON file download
270
+ */
271
+ function exportCharacter() {
272
+ try {
273
+ const charJson = JSON.stringify(gameState.character, null, 2); // Pretty print JSON
274
+ const blob = new Blob([charJson], { type: 'application/json' });
275
+ const url = URL.createObjectURL(blob);
276
+ const a = document.createElement('a');
277
+ a.href = url;
278
+ // Sanitize name for filename
279
+ const filename = `${gameState.character.name.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'character'}_save.json`;
280
+ a.download = filename;
281
+ document.body.appendChild(a); // Required for Firefox
282
+ a.click();
283
+ document.body.removeChild(a);
284
+ URL.revokeObjectURL(url); // Clean up
285
+ console.log(`πŸ“€ Character exported as ${filename}`);
286
+ } catch (e) {
287
+ console.error('Error exporting character:', e);
288
+ alert('Failed to export character data.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
 
 
 
 
 
 
 
 
 
 
 
290
  }
291
 
292
+ // --- Event Listeners (Add New) ---
293
+ charNameInput.addEventListener('change', () => {
294
+ gameState.character.name = charNameInput.value.trim() || "Hero";
295
+ // No need to re-render just for name change unless displaying it elsewhere
296
+ console.log(`πŸ‘€ Name changed to: ${gameState.character.name}`);
297
+ // Maybe save automatically on name change?
298
+ // saveCharacter();
299
+ });
300
+
301
+ levelUpButton.addEventListener('click', handleLevelUp);
302
+
303
+ statIncreaseButtons.forEach(button => {
304
+ button.addEventListener('click', () => {
305
+ const statToIncrease = button.dataset.stat;
306
+ if (statToIncrease) {
307
+ handleStatIncrease(statToIncrease);
308
+ }
309
+ });
310
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
+ saveCharButton.addEventListener('click', saveCharacter);
313
+ exportCharButton.addEventListener('click', exportCharacter);
 
 
 
 
 
314
 
 
 
315
 
316
+ // --- Modify Existing Functions ---
 
 
317
 
318
+ function startGame() {
319
+ // Try loading character first
320
+ if (!loadCharacter()) {
321
+ // If no save found, initialize with defaults (already done by gameState definition)
322
+ console.log("No saved character found, starting new.");
 
 
 
 
 
 
 
 
 
 
 
323
  }
324
+ // Ensure compatibility if loaded save is old/missing fields
325
+ gameState.character = {
326
+ ...{ // Define ALL default fields here
327
+ name: "Hero", race: "Human", alignment: "Neutral Good", class: "Fighter",
328
+ level: 1, xp: 0, xpToNextLevel: 100, statPointsPerLevel: 1, availableStatPoints: 0,
329
+ stats: { strength: 7, intelligence: 5, wisdom: 5, dexterity: 6, constitution: 6, charisma: 5, hp: 30, maxHp: 30 },
330
+ inventory: []
331
+ },
332
+ ...gameState.character // Loaded data overrides defaults
333
+ };
334
+ // Ensure stats object has all keys after loading potentially partial data
335
+ gameState.character.stats = {
336
+ strength: 7, intelligence: 5, wisdom: 5, dexterity: 6, constitution: 6, charisma: 5, hp: 30, maxHp: 30, // Defaults first
337
+ ...(gameState.character.stats || {}) // Loaded stats override defaults
338
+ }
339
 
340
+ gameState.currentPageId = 1; // Always start at page 1
341
+ renderCharacterSheet(); // Initial render of the sheet
342
+ renderPage(gameState.currentPageId); // Render the story page
343
  }
344
 
 
 
345
  function handleChoiceClick(choiceData) {
346
+ const nextPageId = parseInt(choiceData.nextPage);
347
  const itemToAdd = choiceData.addItem;
 
348
 
349
  if (isNaN(nextPageId)) {
350
+ console.error("Invalid nextPageId:", choiceData.nextPage); return;
 
351
  }
352
 
353
+ // Process Choice Effects
354
+ if (itemToAdd && !gameState.character.inventory.includes(itemToAdd)) {
355
+ gameState.character.inventory.push(itemToAdd); // Add to character inventory
 
356
  console.log("Added item:", itemToAdd);
357
+ // Limit inventory size?
358
+ // if (gameState.character.inventory.length > 15) { /* Handle overflow */ }
359
  }
 
360
 
361
+ // Process Landing Page Effects
362
  gameState.currentPageId = nextPageId;
 
363
  const nextPageData = gameData[nextPageId];
364
+
365
  if (nextPageData) {
366
  // Apply HP loss defined on the *landing* page
367
  if (nextPageData.hpLoss) {
368
+ gameState.character.stats.hp -= nextPageData.hpLoss; // Update character HP
369
  console.log(`Lost ${nextPageData.hpLoss} HP.`);
370
+ if (gameState.character.stats.hp <= 0) {
371
+ gameState.character.stats.hp = 0;
372
  console.log("Player died from HP loss!");
373
+ renderCharacterSheet(); // Update sheet before showing game over
374
+ renderPage(99); // Go to game over page
375
+ return;
376
  }
377
  }
378
+
379
+ // --- Apply Rewards (New) ---
380
+ if (nextPageData.reward) {
381
+ if (nextPageData.reward.xp) {
382
+ gameState.character.xp += nextPageData.reward.xp;
383
+ console.log(`✨ Gained ${nextPageData.reward.xp} XP!`);
 
384
  }
385
+ if (nextPageData.reward.statIncrease) {
386
+ const stat = nextPageData.reward.statIncrease.stat;
387
+ const amount = nextPageData.reward.statIncrease.amount;
388
+ if (gameState.character.stats.hasOwnProperty(stat)) {
389
+ gameState.character.stats[stat] += amount;
390
+ console.log(`πŸ“ˆ Stat ${stat} increased by ${amount}!`);
391
+ // Update derived stats if needed (e.g., CON -> HP)
392
+ if (stat === 'constitution') { /* ... update maxHP ... */ }
393
+ }
394
+ }
395
+ // Add other reward types here (e.g., items, stat points)
396
+ if(nextPageData.reward.addItem && !gameState.character.inventory.includes(nextPageData.reward.addItem)){
397
+ gameState.character.inventory.push(nextPageData.reward.addItem);
398
+ console.log(`🎁 Found item: ${nextPageData.reward.addItem}`);
399
+ }
400
+ }
401
+
402
+ // Check if landing page is game over
403
+ if (nextPageData.gameOver) {
404
+ console.log("Reached Game Over page.");
405
+ renderCharacterSheet(); // Update sheet one last time
406
+ renderPage(nextPageId);
407
+ return;
408
  }
 
 
 
 
 
 
409
 
410
  } else {
411
  console.error(`Data for page ${nextPageId} not found!`);
412
+ renderCharacterSheet();
413
+ renderPage(99); // Fallback to game over
414
  return;
415
  }
416
 
417
+ // Render the character sheet (updates XP, stats, inventory) BEFORE rendering page
418
+ renderCharacterSheet();
419
+ // Render the new story page
420
  renderPage(nextPageId);
421
  }
422
 
423
+ // --- REMOVE/REPLACE Old UI Updates ---
424
+ // Remove the old updateStatsDisplay() and updateInventoryDisplay() functions
425
+ // as renderCharacterSheet() now handles this. Make sure no code is still calling them.