Spaces:
Running
Running
Update game.js
Browse files
game.js
CHANGED
@@ -1,825 +1,425 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
//
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
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 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
|
117 |
-
|
118 |
-
|
119 |
-
const
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
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 |
-
|
146 |
-
|
|
|
|
|
|
|
147 |
}
|
148 |
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
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 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
const
|
196 |
-
const
|
197 |
-
|
198 |
-
|
199 |
-
//
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
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 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
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 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
const
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
//
|
283 |
-
const
|
284 |
-
const
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
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 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
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 |
-
|
343 |
-
|
|
|
344 |
|
345 |
-
|
346 |
-
|
347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
348 |
|
349 |
-
|
350 |
-
|
351 |
-
|
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 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
445 |
}
|
|
|
446 |
}
|
447 |
|
448 |
-
|
449 |
-
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
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 |
-
|
593 |
-
|
594 |
-
|
595 |
-
|
596 |
-
|
597 |
-
|
598 |
-
|
599 |
-
|
600 |
-
|
601 |
-
|
602 |
-
|
603 |
-
|
604 |
-
|
605 |
-
|
606 |
-
|
607 |
-
|
608 |
-
|
609 |
-
|
610 |
-
|
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 |
-
|
627 |
-
|
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 |
-
|
638 |
-
button.classList.add('disabled'); // Style disabled buttons
|
639 |
-
}
|
640 |
|
641 |
-
|
642 |
-
|
643 |
-
|
644 |
-
|
645 |
-
|
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 |
-
//
|
661 |
-
|
662 |
}
|
663 |
|
664 |
-
|
665 |
-
// Modified handleChoiceClick to accept an object
|
666 |
function handleChoiceClick(choiceData) {
|
667 |
-
const nextPageId = parseInt(choiceData.nextPage);
|
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 |
-
//
|
677 |
-
|
678 |
-
|
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 |
-
//
|
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 |
-
|
696 |
-
renderPage(99); // Go to
|
697 |
-
return;
|
698 |
}
|
699 |
}
|
700 |
-
|
701 |
-
|
702 |
-
|
703 |
-
|
704 |
-
|
705 |
-
|
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 |
-
|
719 |
-
renderPage(99); //
|
720 |
return;
|
721 |
}
|
722 |
|
723 |
-
|
724 |
-
|
|
|
725 |
renderPage(nextPageId);
|
726 |
}
|
727 |
|
728 |
-
|
729 |
-
|
730 |
-
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|