Spaces:
Running
Running
Create script.js
Browse files
script.js
ADDED
@@ -0,0 +1,426 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
// Removed old stats/inventory elements
|
11 |
+
// const statsElement = document.getElementById('stats-display');
|
12 |
+
// const inventoryElement = document.getElementById('inventory-display');
|
13 |
+
|
14 |
+
// Character Sheet Elements
|
15 |
+
const charNameInput = document.getElementById('char-name');
|
16 |
+
const charRaceSpan = document.getElementById('char-race');
|
17 |
+
const charAlignmentSpan = document.getElementById('char-alignment');
|
18 |
+
const charClassSpan = document.getElementById('char-class');
|
19 |
+
const charLevelSpan = document.getElementById('char-level');
|
20 |
+
const charXPSpan = document.getElementById('char-xp');
|
21 |
+
const charXPNextSpan = document.getElementById('char-xp-next');
|
22 |
+
const charHPSpan = document.getElementById('char-hp');
|
23 |
+
const charMaxHPSpan = document.getElementById('char-max-hp');
|
24 |
+
const charInventoryList = document.getElementById('char-inventory-list');
|
25 |
+
const statSpans = {
|
26 |
+
strength: document.getElementById('stat-strength'), intelligence: document.getElementById('stat-intelligence'),
|
27 |
+
wisdom: document.getElementById('stat-wisdom'), dexterity: document.getElementById('stat-dexterity'),
|
28 |
+
constitution: document.getElementById('stat-constitution'), charisma: document.getElementById('stat-charisma'),
|
29 |
+
};
|
30 |
+
const statIncreaseButtons = document.querySelectorAll('.stat-increase');
|
31 |
+
const levelUpButton = document.getElementById('levelup-btn');
|
32 |
+
const saveCharButton = document.getElementById('save-char-btn');
|
33 |
+
const exportCharButton = document.getElementById('export-char-btn');
|
34 |
+
const statIncreaseCostSpan = document.getElementById('stat-increase-cost');
|
35 |
+
const statPointsAvailableSpan = document.getElementById('stat-points-available');
|
36 |
+
|
37 |
+
|
38 |
+
// --- Three.js Setup ---
|
39 |
+
let scene, camera, renderer;
|
40 |
+
let currentAssemblyGroup = null;
|
41 |
+
let directionalLight;
|
42 |
+
let sunAngle = Math.PI / 4; // Start sun partway through morning
|
43 |
+
const clock = new THREE.Clock();
|
44 |
+
let clouds = [];
|
45 |
+
// let controls;
|
46 |
+
|
47 |
+
// --- Shared Materials ---
|
48 |
+
const stoneMaterial = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8, metalness: 0.1 });
|
49 |
+
const woodMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.7, metalness: 0 });
|
50 |
+
const darkWoodMaterial = new THREE.MeshStandardMaterial({ color: 0x5C3D20, roughness: 0.7, metalness: 0 });
|
51 |
+
const leafMaterial = new THREE.MeshStandardMaterial({ color: 0x2E8B57, roughness: 0.6, metalness: 0 });
|
52 |
+
const pineLeafMaterial = new THREE.MeshStandardMaterial({ color: 0x1A5A2A, roughness: 0.7, metalness: 0 });
|
53 |
+
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x556B2F, roughness: 0.9, metalness: 0 });
|
54 |
+
const metalMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.8, roughness: 0.3 });
|
55 |
+
const fabricMaterial = new THREE.MeshStandardMaterial({ color: 0x696969, roughness: 0.9, metalness: 0 });
|
56 |
+
const waterMaterial = new THREE.MeshStandardMaterial({ color: 0x60A3D9, roughness: 0.2, metalness: 0.1, transparent: true, opacity: 0.7 });
|
57 |
+
const templeMaterial = new THREE.MeshStandardMaterial({ color: 0xA99B78, roughness: 0.7, metalness: 0.1 });
|
58 |
+
const fireMaterial = new THREE.MeshStandardMaterial({ color: 0xFF4500, emissive: 0xff6600, roughness: 0.5, metalness: 0 });
|
59 |
+
const errorMaterial = new THREE.MeshStandardMaterial({ color: 0xffa500, roughness: 0.5 });
|
60 |
+
const gameOverMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.5 });
|
61 |
+
const windowMaterial = new THREE.MeshStandardMaterial({ color: 0x334455, roughness: 0.3, metalness: 0, transparent: true, opacity: 0.6 });
|
62 |
+
|
63 |
+
|
64 |
+
function initThreeJS() {
|
65 |
+
scene = new THREE.Scene();
|
66 |
+
|
67 |
+
// Skybox Loading
|
68 |
+
const loader = new THREE.CubeTextureLoader();
|
69 |
+
// !!! IMPORTANT: Replace this path with the correct one for your textures !!!
|
70 |
+
const texturePath = 'textures/skybox/';
|
71 |
+
const textureFiles = ['posx.jpg', 'negx.jpg', 'posy.jpg', 'negy.jpg', 'posz.jpg', 'negz.jpg'];
|
72 |
+
|
73 |
+
try {
|
74 |
+
const texture = loader.setPath(texturePath).load(textureFiles,
|
75 |
+
() => { console.log("Skybox loaded"); scene.background = texture; },
|
76 |
+
undefined,
|
77 |
+
(err) => { console.error(`Skybox loading error from ${texturePath}:`, err); scene.background = new THREE.Color(0x557799); }
|
78 |
+
);
|
79 |
+
} catch (e) {
|
80 |
+
console.error("Error initiating skybox load (check path format maybe?):", e);
|
81 |
+
scene.background = new THREE.Color(0x557799); // Fallback
|
82 |
+
}
|
83 |
+
|
84 |
+
|
85 |
+
camera = new THREE.PerspectiveCamera(60, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000);
|
86 |
+
camera.position.set(0, 3, 9);
|
87 |
+
camera.lookAt(0, 1, 0);
|
88 |
+
|
89 |
+
renderer = new THREE.WebGLRenderer({ antialias: true });
|
90 |
+
renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
|
91 |
+
renderer.shadowMap.enabled = true;
|
92 |
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
93 |
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
94 |
+
renderer.toneMappingExposure = 1.0;
|
95 |
+
sceneContainer.appendChild(renderer.domElement);
|
96 |
+
|
97 |
+
// Lighting
|
98 |
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
99 |
+
scene.add(ambientLight);
|
100 |
+
|
101 |
+
directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
|
102 |
+
directionalLight.position.set(20, 30, 15); // Initial position, updated in animate
|
103 |
+
directionalLight.target.position.set(0, 0, 0);
|
104 |
+
directionalLight.castShadow = true;
|
105 |
+
directionalLight.shadow.mapSize.width = 1024;
|
106 |
+
directionalLight.shadow.mapSize.height = 1024;
|
107 |
+
directionalLight.shadow.camera.near = 1;
|
108 |
+
directionalLight.shadow.camera.far = 100;
|
109 |
+
const shadowCamSize = 25;
|
110 |
+
directionalLight.shadow.camera.left = -shadowCamSize; directionalLight.shadow.camera.right = shadowCamSize;
|
111 |
+
directionalLight.shadow.camera.top = shadowCamSize; directionalLight.shadow.camera.bottom = -shadowCamSize;
|
112 |
+
directionalLight.shadow.bias = -0.001;
|
113 |
+
scene.add(directionalLight);
|
114 |
+
scene.add(directionalLight.target);
|
115 |
+
|
116 |
+
// Clouds
|
117 |
+
createClouds(15);
|
118 |
+
|
119 |
+
window.addEventListener('resize', onWindowResize, false);
|
120 |
+
animate();
|
121 |
+
}
|
122 |
+
|
123 |
+
// Helper function to create meshes
|
124 |
+
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 }) {
|
125 |
+
const mesh = new THREE.Mesh(geometry, material);
|
126 |
+
mesh.position.set(position.x, position.y, position.z);
|
127 |
+
mesh.rotation.set(rotation.x, rotation.y, rotation.z);
|
128 |
+
mesh.scale.set(scale.x, scale.y, scale.z);
|
129 |
+
mesh.castShadow = true;
|
130 |
+
mesh.receiveShadow = true;
|
131 |
+
return mesh;
|
132 |
+
}
|
133 |
+
|
134 |
+
|
135 |
+
// Cloud Creation
|
136 |
+
function createClouds(count) {
|
137 |
+
const textureLoader = new THREE.TextureLoader();
|
138 |
+
// !!! IMPORTANT: Replace this path with the correct one for your cloud texture !!!
|
139 |
+
const cloudTexturePath = 'textures/cloud.png';
|
140 |
+
try {
|
141 |
+
const cloudTexture = textureLoader.load(cloudTexturePath,
|
142 |
+
() => { console.log("Cloud texture loaded"); }, undefined,
|
143 |
+
(err) => { console.error(`Cloud texture loading error from ${cloudTexturePath}:`, err); }
|
144 |
+
);
|
145 |
+
|
146 |
+
const cloudMaterial = new THREE.MeshBasicMaterial({
|
147 |
+
map: cloudTexture, transparent: true, alphaTest: 0.2,
|
148 |
+
depthWrite: false, side: THREE.DoubleSide,
|
149 |
+
});
|
150 |
+
const cloudGeo = new THREE.PlaneGeometry(6, 3); // Slightly larger clouds
|
151 |
+
|
152 |
+
for (let i = 0; i < count; i++) {
|
153 |
+
const cloud = new THREE.Mesh(cloudGeo, cloudMaterial.clone()); // Clone material needed if alphaTest differs per cloud later
|
154 |
+
cloud.position.set( (Math.random() - 0.5) * 80, 15 + Math.random() * 5, (Math.random() - 0.5) * 50 );
|
155 |
+
cloud.rotation.y = Math.random() * Math.PI * 2;
|
156 |
+
cloud.rotation.z = Math.random() * 0.2 - 0.1;
|
157 |
+
cloud.userData.speed = (Math.random() * 0.05 + 0.02);
|
158 |
+
clouds.push(cloud);
|
159 |
+
scene.add(cloud);
|
160 |
+
}
|
161 |
+
console.log(`Created ${clouds.length} clouds (or tried to).`);
|
162 |
+
} catch(e) {
|
163 |
+
console.error("Error initiating cloud texture load (check path format maybe?):", e);
|
164 |
+
}
|
165 |
+
}
|
166 |
+
|
167 |
+
|
168 |
+
// --- Procedural Generation Functions ---
|
169 |
+
|
170 |
+
function createGroundPlane(material = groundMaterial, size = 20) {
|
171 |
+
const groundGeo = new THREE.PlaneGeometry(size, size);
|
172 |
+
const ground = new THREE.Mesh(groundGeo, material);
|
173 |
+
ground.rotation.x = -Math.PI / 2;
|
174 |
+
ground.position.y = -0.05;
|
175 |
+
ground.receiveShadow = true;
|
176 |
+
ground.castShadow = false;
|
177 |
+
return ground;
|
178 |
+
}
|
179 |
+
|
180 |
+
function createDefaultAssembly() { /* ... (same as before) ... */
|
181 |
+
const group = new THREE.Group(); const sphereGeo = new THREE.SphereGeometry(0.5, 16, 16);
|
182 |
+
group.add(createMesh(sphereGeo, stoneMaterial, { x: 0, y: 0.5, z: 0 })); group.add(createGroundPlane()); return group;
|
183 |
+
}
|
184 |
+
function createCityGatesAssembly() { /* ... (same as before) ... */
|
185 |
+
const group = new THREE.Group(); const gateWallHeight = 4; const gateWallWidth = 1.5; const gateWallDepth = 0.8; const archHeight = 1; const archWidth = 3;
|
186 |
+
const towerLeftGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth); group.add(createMesh(towerLeftGeo, stoneMaterial, { x: -(archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 }));
|
187 |
+
const towerRightGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth); group.add(createMesh(towerRightGeo, stoneMaterial, { x: (archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 }));
|
188 |
+
const archGeo = new THREE.BoxGeometry(archWidth, archHeight, gateWallDepth); group.add(createMesh(archGeo, stoneMaterial, { x: 0, y: gateWallHeight - archHeight / 2, z: 0 }));
|
189 |
+
const crenellationSize = 0.4; const crenGeo = new THREE.BoxGeometry(crenellationSize, crenellationSize, gateWallDepth * 1.1);
|
190 |
+
for (let i = -Math.floor(gateWallWidth / (crenellationSize * 1.5)); i <= Math.floor(gateWallWidth / (crenellationSize * 1.5)); i++) { const xPosTower = i * crenellationSize * 1.5; const crenMeshLeft = createMesh(crenGeo.clone(), stoneMaterial, { x: -(archWidth / 2 + gateWallWidth / 2) + xPosTower, y: gateWallHeight + crenellationSize / 2, z: 0 }); const crenMeshRight = createMesh(crenGeo.clone(), stoneMaterial, { x: (archWidth / 2 + gateWallWidth / 2) + xPosTower, y: gateWallHeight + crenellationSize / 2, z: 0 }); group.add(crenMeshLeft); group.add(crenMeshRight); }
|
191 |
+
for (let i = -Math.floor(archWidth / (crenellationSize * 1.5 * 2)); i <= Math.floor(archWidth / (crenellationSize * 1.5 * 2)); i++){ const xPosArch = i * crenellationSize * 1.5; const crenMeshArch = createMesh(crenGeo.clone(), stoneMaterial, { x: xPosArch, y: gateWallHeight + archHeight - crenellationSize/2, z: 0 }); group.add(crenMeshArch); }
|
192 |
+
group.add(createGroundPlane(stoneMaterial)); return group;
|
193 |
+
}
|
194 |
+
function createWeaponsmithAssembly() { /* ... (enhanced version from before) ... */
|
195 |
+
const group = new THREE.Group(); const buildingWidth = 3; const buildingHeight = 2.5; const buildingDepth = 3.5; const roofPitch = Math.random() * 0.3 + 0.4; const roofHeight = (buildingDepth / 2) * roofPitch; const roofOverhang = 0.2;
|
196 |
+
const wallMaterial = Math.random() < 0.6 ? darkWoodMaterial : stoneMaterial; const buildingGeo = new THREE.BoxGeometry(buildingWidth, buildingHeight, buildingDepth); const mainBuilding = createMesh(buildingGeo, wallMaterial, { x: 0, y: buildingHeight / 2, z: 0 }); group.add(mainBuilding);
|
197 |
+
const roofMaterial = Math.random() < 0.7 ? woodMaterial : darkWoodMaterial; const roofLength = Math.sqrt(Math.pow(buildingDepth / 2 + roofOverhang, 2) + Math.pow(roofHeight, 2)); const roofGeo = new THREE.PlaneGeometry(buildingWidth + roofOverhang * 2, roofLength); const roofAngle = Math.atan2(roofHeight, buildingDepth / 2); const roofY = buildingHeight + roofHeight / 2 - (roofOverhang * Math.sin(roofAngle)) / 2; const roofZ = (buildingDepth / 4 + roofOverhang / 4) * Math.cos(roofAngle); const roofLeft = createMesh(roofGeo, roofMaterial, { x: 0, y: roofY, z: -roofZ }, { x: roofAngle, y: 0, z: 0 }); const roofRight = createMesh(roofGeo.clone(), roofMaterial, { x: 0, y: roofY, z: roofZ }, { x: -roofAngle, y: 0, z: 0 }); group.add(roofLeft); group.add(roofRight);
|
198 |
+
const gableShape = new THREE.Shape(); gableShape.moveTo(-buildingWidth / 2, buildingHeight); gableShape.lineTo(buildingWidth / 2, buildingHeight); gableShape.lineTo(buildingWidth/2, buildingHeight + roofHeight); gableShape.lineTo(-buildingWidth/2, buildingHeight + roofHeight); gableShape.closePath(); const gableGeo = new THREE.ShapeGeometry(gableShape); group.add(createMesh(gableGeo, wallMaterial, { x: 0, y: 0, z: buildingDepth / 2 }, { x: 0, y: 0, z: 0 })); group.add(createMesh(gableGeo.clone(), wallMaterial, { x: 0, y: 0, z: -buildingDepth / 2 }, { x: 0, y: Math.PI, z: 0 }));
|
199 |
+
const doorHeight = 1.8; const doorWidth = 0.8; const windowSize = 0.6; const frameThickness = 0.05; const doorGeo = new THREE.BoxGeometry(doorWidth, doorHeight, 0.05); const doorFrameGeo = new THREE.BoxGeometry(doorWidth + frameThickness*2, doorHeight + frameThickness*2, 0.06); const doorSide = Math.random() < 0.5 ? 'front' : 'side'; if (doorSide === 'front') { group.add(createMesh(doorFrameGeo, darkWoodMaterial, { x: 0, y: doorHeight / 2, z: buildingDepth / 2 + 0.03 })); group.add(createMesh(doorGeo, darkWoodMaterial, { x: 0, y: doorHeight / 2, z: buildingDepth / 2 + 0.05 })); } else { group.add(createMesh(doorFrameGeo, darkWoodMaterial, { x: buildingWidth / 2 + 0.03, y: doorHeight / 2, z: 0}, {y: Math.PI/2})); group.add(createMesh(doorGeo, darkWoodMaterial, { x: buildingWidth / 2 + 0.05, y: doorHeight / 2, z: 0}, {y: Math.PI/2})); }
|
200 |
+
const windowGeo = new THREE.BoxGeometry(windowSize, windowSize, 0.05); const windowFrameGeo = new THREE.BoxGeometry(windowSize + frameThickness*2, windowSize + frameThickness*2, 0.06); if (Math.random() < 0.7 && doorSide !== 'front') { const winX = (Math.random() - 0.5) * (buildingWidth - windowSize - 0.5); group.add(createMesh(windowFrameGeo, darkWoodMaterial, {x: winX, y: buildingHeight * 0.6, z: buildingDepth / 2 + 0.03})); group.add(createMesh(windowGeo, windowMaterial, { x: winX, y: buildingHeight * 0.6, z: buildingDepth / 2 + 0.05 })); }
|
201 |
+
if (Math.random() < 0.6 && doorSide !== 'side') { const winZ = (Math.random() - 0.5) * (buildingDepth - windowSize); group.add(createMesh(windowFrameGeo.clone(), darkWoodMaterial, {x: buildingWidth / 2 + 0.03, y: buildingHeight * 0.6, z: winZ}, {y: Math.PI/2})); group.add(createMesh(windowGeo.clone(), windowMaterial, { x: buildingWidth / 2 + 0.05, y: buildingHeight * 0.6, z: winZ }, {y: Math.PI/2})); } if (Math.random() < 0.6) { const winZ = (Math.random() - 0.5) * (buildingDepth - windowSize); group.add(createMesh(windowFrameGeo.clone(), darkWoodMaterial, {x: -buildingWidth / 2 - 0.03, y: buildingHeight * 0.6, z: winZ}, {y: -Math.PI/2})); group.add(createMesh(windowGeo.clone(), windowMaterial, { x: -buildingWidth / 2 - 0.05, y: buildingHeight * 0.6, z: winZ }, {y: -Math.PI/2})); }
|
202 |
+
const forgeHeight = buildingHeight + roofHeight + 0.5; const forgeGeo = new THREE.CylinderGeometry(0.3, 0.4, forgeHeight, 8); group.add(createMesh(forgeGeo, stoneMaterial, { x: buildingWidth * 0.3, y: forgeHeight / 2, z: -buildingDepth * 0.3 }));
|
203 |
+
const anvilGeo = new THREE.BoxGeometry(0.4, 0.5, 0.7); group.add(createMesh(anvilGeo, metalMaterial, { x: -buildingWidth * 0.2, y: 0.25, z: buildingDepth * 0.2 })); group.add(createGroundPlane()); return group;
|
204 |
+
}
|
205 |
+
function createTempleAssembly() { /* ... (same as before) ... */
|
206 |
+
const group = new THREE.Group(); const baseSize = 5; const baseHeight = 0.5; const columnHeight = 3; const columnRadius = 0.25; const roofHeight = 1; const baseGeo = new THREE.BoxGeometry(baseSize, baseHeight, baseSize); group.add(createMesh(baseGeo, templeMaterial, { x: 0, y: baseHeight / 2, z: 0 })); const colPositions = [ { x: -baseSize / 3, z: -baseSize / 3 }, { x: baseSize / 3, z: -baseSize / 3 }, { x: -baseSize / 3, z: baseSize / 3 }, { x: baseSize / 3, z: baseSize / 3 }, ]; const colGeo = new THREE.CylinderGeometry(columnRadius, columnRadius, columnHeight, 12); colPositions.forEach(pos => { group.add(createMesh(colGeo.clone(), templeMaterial, { x: pos.x, y: baseHeight + columnHeight / 2, z: pos.z })); }); const roofGeo = new THREE.BoxGeometry(baseSize * 0.8, roofHeight / 2, baseSize * 0.8); group.add(createMesh(roofGeo, templeMaterial, { x: 0, y: baseHeight + columnHeight + roofHeight / 4, z: 0 })); const pyramidGeo = new THREE.ConeGeometry(baseSize * 0.5, roofHeight * 1.5, 4); group.add(createMesh(pyramidGeo, templeMaterial, { x: 0, y: baseHeight + columnHeight + roofHeight *0.75, z: 0 }, { x: 0, y: Math.PI / 4, z: 0 })); group.add(createGroundPlane()); return group;
|
207 |
+
}
|
208 |
+
function createResistanceMeetingAssembly() { /* ... (same as before) ... */
|
209 |
+
const group = new THREE.Group(); const tableWidth = 2; const tableHeight = 0.8; const tableDepth = 1; const tableThickness = 0.1; const tableTopGeo = new THREE.BoxGeometry(tableWidth, tableThickness, tableDepth); group.add(createMesh(tableTopGeo, woodMaterial, { x: 0, y: tableHeight - tableThickness / 2, z: 0 })); const legHeight = tableHeight - tableThickness; const legSize = 0.1; const legGeo = new THREE.BoxGeometry(legSize, legHeight, legSize); const legOffsetW = tableWidth / 2 - legSize * 1.5; const legOffsetD = tableDepth / 2 - legSize * 1.5; group.add(createMesh(legGeo, woodMaterial, { x: -legOffsetW, y: legHeight / 2, z: -legOffsetD })); group.add(createMesh(legGeo.clone(), woodMaterial, { x: legOffsetW, y: legHeight / 2, z: -legOffsetD })); group.add(createMesh(legGeo.clone(), woodMaterial, { x: -legOffsetW, y: legHeight / 2, z: legOffsetD })); group.add(createMesh(legGeo.clone(), woodMaterial, { x: legOffsetW, y: legHeight / 2, z: legOffsetD })); const stoolSize = 0.4; const stoolGeo = new THREE.BoxGeometry(stoolSize, stoolSize * 0.8, stoolSize); group.add(createMesh(stoolGeo, darkWoodMaterial, { x: -tableWidth * 0.6, y: stoolSize * 0.4, z: 0 })); group.add(createMesh(stoolGeo.clone(), darkWoodMaterial, { x: tableWidth * 0.6, y: stoolSize * 0.4, z: 0 })); group.add(createMesh(stoolGeo.clone(), darkWoodMaterial, { x: 0, y: stoolSize * 0.4, z: -tableDepth * 0.7 })); const wallHeight = 3; const wallThickness = 0.2; const roomSize = 5; const wallBackGeo = new THREE.BoxGeometry(roomSize, wallHeight, wallThickness); group.add(createMesh(wallBackGeo, stoneMaterial, { x: 0, y: wallHeight / 2, z: -roomSize / 2 }, {})); const wallLeftGeo = new THREE.BoxGeometry(wallThickness, wallHeight, roomSize); group.add(createMesh(wallLeftGeo, stoneMaterial, { x: -roomSize / 2, y: wallHeight / 2, z: 0 }, {})); group.add(createGroundPlane(stoneMaterial)); return group;
|
210 |
+
}
|
211 |
+
function createForestAssembly(treeCount = 15, area = 12) { /* ... (enhanced version from before) ... */
|
212 |
+
const group = new THREE.Group();
|
213 |
+
const createTree = (x, z, type) => { const treeGroup = new THREE.Group(); let trunkHeight, trunkRadius, leafMat; if (type === 'pine') { trunkHeight = Math.random() * 3 + 4; trunkRadius = Math.random() * 0.1 + 0.1; leafMat = pineLeafMaterial; } else { trunkHeight = Math.random() * 2 + 2.5; trunkRadius = Math.random() * 0.2 + 0.15; leafMat = leafMaterial; } const trunkGeo = new THREE.CylinderGeometry(trunkRadius * 0.7, trunkRadius, trunkHeight, 8); const trunkMesh = createMesh(trunkGeo, woodMaterial, { x: 0, y: trunkHeight / 2, z: 0 }); treeGroup.add(trunkMesh); const branchCount = Math.floor(Math.random() * 5) + 3; const branchStartHeight = trunkHeight * (0.4 + Math.random() * 0.3); const branchGeo = new THREE.CylinderGeometry(trunkRadius * 0.1, trunkRadius * 0.3, trunkHeight * 0.4, 5); for (let i = 0; i < branchCount; i++) { const yPos = branchStartHeight + Math.random() * (trunkHeight - branchStartHeight) * 0.8; const angleY = Math.random() * Math.PI * 2; const angleX = Math.PI / 3 + Math.random() * Math.PI / 4; const branch = createMesh(branchGeo.clone(), woodMaterial, { x: 0, y: yPos, z: 0 }, { x: angleX, y: angleY, z: 0 } ); const branchLength = trunkHeight * 0.1; branch.position.x = Math.sin(angleY) * Math.sin(angleX) * branchLength; branch.position.z = Math.cos(angleY) * Math.sin(angleX) * branchLength; branch.position.y = yPos; treeGroup.add(branch); } const foliageBaseY = trunkHeight * 0.8; const foliageClusterRadius = trunkRadius * 5 + Math.random() * 1; if (type === 'pine') { const numCones = 3; for(let i=0; i<numCones; i++){ const coneRadius = foliageClusterRadius * (1 - i*0.25); const coneHeight = trunkHeight * 0.5 * (1 - i*0.15); const coneY = foliageBaseY + i * coneHeight * 0.4; const coneGeo = new THREE.ConeGeometry(coneRadius, coneHeight, 8); treeGroup.add(createMesh(coneGeo, leafMat, { x: 0, y: coneY, z: 0 })); } } else { const foliageCount = Math.floor(Math.random() * 5) + 5; const sphereRadius = foliageClusterRadius * 0.3 + Math.random() * 0.2; const sphereGeo = new THREE.SphereGeometry(sphereRadius, 6, 5); for (let i = 0; i < foliageCount; i++) { const offsetX = (Math.random() - 0.5) * foliageClusterRadius * 0.8; const offsetY = Math.random() * foliageClusterRadius * 0.5; const offsetZ = (Math.random() - 0.5) * foliageClusterRadius * 0.8; treeGroup.add(createMesh(sphereGeo.clone(), leafMat, { x: offsetX, y: foliageBaseY + offsetY, z: offsetZ })); } } treeGroup.position.set(x, 0, z); treeGroup.rotation.y = Math.random() * Math.PI * 2; return treeGroup; };
|
214 |
+
for (let i = 0; i < treeCount; i++) { const x = (Math.random() - 0.5) * area; const z = (Math.random() - 0.5) * area; const treeType = Math.random() < 0.3 ? 'pine' : 'deciduous'; if (Math.sqrt(x * x + z * z) > 1.5) { group.add(createTree(x, z, treeType)); } else if (i < treeCount / 2) { group.add(createTree(x, z, treeType)); } } group.add(createGroundPlane(groundMaterial, area * 1.1)); return group;
|
215 |
+
}
|
216 |
+
function createRoadAmbushAssembly() { /* ... (same as before) ... */
|
217 |
+
const group = new THREE.Group(); const area = 12; const forestGroup = createForestAssembly(10, area); group.add(forestGroup); const roadWidth = 3; const roadLength = area * 1.5; const roadGeo = new THREE.PlaneGeometry(roadWidth, roadLength); const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x966F33, roughness: 0.9 }); const road = createMesh(roadGeo, roadMaterial, {x: 0, y: 0.01, z: 0}, {x: -Math.PI / 2}); road.receiveShadow = true; group.add(road); const rockGeo = new THREE.DodecahedronGeometry(0.6, 0); const rockMaterial = new THREE.MeshStandardMaterial({ color: 0x666666, roughness: 0.8 }); group.add(createMesh(rockGeo, rockMaterial, {x: roadWidth * 0.7, y: 0.3, z: 1}, {y: Math.random() * Math.PI})); group.add(createMesh(rockGeo.clone().scale(0.7,0.7,0.7), rockMaterial, {x: -roadWidth * 0.8, y: 0.25, z: -2}, {y: Math.random() * Math.PI, x: Math.random()*0.2})); group.add(createMesh(new THREE.DodecahedronGeometry(0.8, 0), rockMaterial, {x: roadWidth * 0.9, y: 0.4, z: -3}, {y: Math.random() * Math.PI})); return group;
|
218 |
+
}
|
219 |
+
function createForestEdgeAssembly() { /* ... (same as before) ... */
|
220 |
+
const group = new THREE.Group(); const area = 15; const forestGroup = new THREE.Group();
|
221 |
+
// Reusing createForestAssembly logic more cleanly might require refactoring createTree out,
|
222 |
+
// but this temporary approach works for now.
|
223 |
+
const tempTreeCreator = (x, z, type) => { /* Simplified copy or refactor needed */ const treeGroup = new THREE.Group(); let trunkHeight=2, trunkRadius=0.2, leafMat = leafMaterial; if(type==='pine'){trunkHeight=5; trunkRadius=0.1; leafMat=pineLeafMaterial;} const trunkGeo = new THREE.CylinderGeometry(trunkRadius * 0.7, trunkRadius, trunkHeight, 8); treeGroup.add(createMesh(trunkGeo, woodMaterial, { x: 0, y: trunkHeight / 2, z: 0 })); const foliageGeo = new THREE.SphereGeometry(trunkRadius*5, 6, 5); treeGroup.add(createMesh(foliageGeo, leafMat, {x:0, y: trunkHeight*0.9, z:0})); treeGroup.position.set(x, 0, z); treeGroup.rotation.y = Math.random() * Math.PI * 2; return treeGroup; };
|
224 |
+
for (let i = 0; i < 20; i++) { const x = (Math.random() - 0.9) * area / 2; const z = (Math.random() - 0.5) * area; const treeType = Math.random() < 0.3 ? 'pine' : 'deciduous'; forestGroup.add(tempTreeCreator(x,z,treeType)); } group.add(forestGroup); group.add(createGroundPlane(groundMaterial, area * 1.2)); return group;
|
225 |
+
}
|
226 |
+
function createPrisonerCellAssembly() { /* ... (same as before) ... */
|
227 |
+
const group = new THREE.Group(); const cellSize = 3; const wallHeight = 2.5; const wallThickness = 0.2; const barRadius = 0.04; const barSpacing = 0.2; const cellFloorMaterial = stoneMaterial.clone(); cellFloorMaterial.color.setHex(0x555555); group.add(createGroundPlane(cellFloorMaterial, cellSize)); const wallBackGeo = new THREE.BoxGeometry(cellSize, wallHeight, wallThickness); group.add(createMesh(wallBackGeo, stoneMaterial, { x: 0, y: wallHeight / 2, z: -cellSize / 2 })); const wallSideGeo = new THREE.BoxGeometry(wallThickness, wallHeight, cellSize); group.add(createMesh(wallSideGeo, stoneMaterial, { x: -cellSize / 2, y: wallHeight / 2, z: 0 })); group.add(createMesh(wallSideGeo.clone(), stoneMaterial, { x: cellSize / 2, y: wallHeight / 2, z: 0 })); const barGeo = new THREE.CylinderGeometry(barRadius, barRadius, wallHeight, 6); const numBars = Math.floor(cellSize / barSpacing); for (let i = 0; i <= numBars; i++) { const xPos = -cellSize / 2 + i * barSpacing + barSpacing/2; group.add(createMesh(barGeo.clone(), metalMaterial, { x: xPos, y: wallHeight / 2, z: cellSize / 2 })); } const horizBarGeo = new THREE.BoxGeometry(cellSize + barSpacing, barRadius * 2.5, barRadius * 2.5); group.add(createMesh(horizBarGeo, metalMaterial, {x: 0, y: wallHeight - barRadius*1.25, z: cellSize/2})); group.add(createMesh(horizBarGeo.clone(), metalMaterial, {x: 0, y: barRadius*1.25, z: cellSize/2})); return group;
|
228 |
+
}
|
229 |
+
function createGameOverAssembly() { /* ... (same as before) ... */
|
230 |
+
const group = new THREE.Group(); const boxGeo = new THREE.BoxGeometry(2, 2, 2); group.add(createMesh(boxGeo, gameOverMaterial, { x: 0, y: 1, z: 0 })); group.add(createGroundPlane(stoneMaterial.clone().set({color: 0x333333}))); return group;
|
231 |
+
}
|
232 |
+
function createErrorAssembly() { /* ... (same as before) ... */
|
233 |
+
const group = new THREE.Group(); const coneGeo = new THREE.ConeGeometry( 0.8, 1.5, 8 ); group.add(createMesh(coneGeo, errorMaterial, { x: 0, y: 0.75, z: 0 })); group.add(createGroundPlane()); return group;
|
234 |
+
}
|
235 |
+
|
236 |
+
// Window Resize
|
237 |
+
function onWindowResize() {
|
238 |
+
if (!renderer || !camera) return;
|
239 |
+
camera.aspect = sceneContainer.clientWidth / sceneContainer.clientHeight;
|
240 |
+
camera.updateProjectionMatrix();
|
241 |
+
renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
|
242 |
+
}
|
243 |
+
|
244 |
+
// Animation Loop
|
245 |
+
function animate() {
|
246 |
+
requestAnimationFrame(animate);
|
247 |
+
const delta = clock.getDelta();
|
248 |
+
|
249 |
+
// Sun Movement
|
250 |
+
const sunSpeed = 0.05; sunAngle += delta * sunSpeed;
|
251 |
+
const sunDistance = 40; const sunHeight = 30; const duskAngle = Math.PI * 0.15;
|
252 |
+
directionalLight.position.x = Math.cos(sunAngle) * sunDistance;
|
253 |
+
directionalLight.position.y = Math.max(0.1, Math.sin(sunAngle) * sunHeight);
|
254 |
+
directionalLight.position.z = Math.sin(sunAngle * 0.75) * sunDistance * 0.6;
|
255 |
+
|
256 |
+
// Sun Color/Intensity
|
257 |
+
const normalizedY = directionalLight.position.y / sunHeight;
|
258 |
+
directionalLight.intensity = Math.max(0.1, normalizedY * 1.5);
|
259 |
+
const white = new THREE.Color(0xffffff); const dusk = new THREE.Color(0xFFAB6B);
|
260 |
+
const blendFactor = Math.max(0, Math.min(1, Math.pow(1 - normalizedY, 2)));
|
261 |
+
if (Math.sin(sunAngle) > 0) { directionalLight.color.lerpColors(white, dusk, blendFactor); }
|
262 |
+
else { directionalLight.intensity = 0.05; directionalLight.color.set(0x6688cc); }
|
263 |
+
|
264 |
+
// Cloud Movement
|
265 |
+
clouds.forEach(cloud => {
|
266 |
+
cloud.position.x += cloud.userData.speed * delta * 50;
|
267 |
+
if (cloud.position.x > 60) { cloud.position.x = -60; cloud.position.z = (Math.random() - 0.5) * 50; }
|
268 |
+
cloud.lookAt(camera.position); // Billboarding
|
269 |
+
});
|
270 |
+
|
271 |
+
// Render
|
272 |
+
if (renderer && scene && camera) { renderer.render(scene, camera); }
|
273 |
+
}
|
274 |
+
|
275 |
+
|
276 |
+
// --- Game Data ---
|
277 |
+
const itemsData = {
|
278 |
+
"Flaming Sword": { type: "weapon", description: "A fiery blade" }, "Whispering Bow": { type: "weapon", description: "A silent bow" }, "Guardian Shield": { type: "armor", description: "A protective shield" }, "Healing Light Spell": { type: "spell", description: "Mends minor wounds" }, "Shield of Faith Spell": { type: "spell", description: "Temporary shield" }, "Binding Runes Scroll": { type: "spell", description: "Binds an enemy" }, "Secret Tunnel Map": { type: "quest", description: "Shows a hidden path" }, "Poison Daggers": { type: "weapon", description: "Daggers with poison" }, "Master Key": { type: "quest", description: "Unlocks many doors" },
|
279 |
+
"Scout's Pouch": { type: "quest", description: "Contains odds and ends."} // Added item from example reward
|
280 |
+
};
|
281 |
+
|
282 |
+
const gameData = {
|
283 |
+
"1": { title: "The Beginning", content: `<p>...</p>`, options: [ { text: "Visit the local weaponsmith", next: 2 }, { text: "Seek wisdom at the temple", next: 3 }, { text: "Meet the resistance leader", next: 4 } ], illustration: "city-gates" },
|
284 |
+
"2": { title: "The Weaponsmith", content: `<p>...</p>`, options: [ { text: "Take the Flaming Sword", next: 5, addItem: "Flaming Sword" }, { text: "Choose the Whispering Bow", next: 5, addItem: "Whispering Bow" }, { text: "Select the Guardian Shield", next: 5, addItem: "Guardian Shield" } ], illustration: "weaponsmith" },
|
285 |
+
"3": { title: "The Ancient Temple", content: `<p>...</p>`, options: [ { text: "Learn Healing Light", next: 5, addItem: "Healing Light Spell" }, { text: "Master Shield of Faith", next: 5, addItem: "Shield of Faith Spell" }, { text: "Study Binding Runes", next: 5, addItem: "Binding Runes Scroll" } ], illustration: "temple" },
|
286 |
+
"4": { title: "The Resistance Leader", content: `<p>...</p>`, options: [ { text: "Take the Secret Tunnel Map", next: 5, addItem: "Secret Tunnel Map" }, { text: "Accept Poison Daggers", next: 5, addItem: "Poison Daggers" }, { text: "Choose the Master Key", next: 5, addItem: "Master Key" } ], illustration: "resistance-meeting" },
|
287 |
+
"5": { title: "The Journey Begins", content: `<p>...</p>`, options: [ { text: "Take the main road", next: 6 }, { text: "Follow the river path", next: 7 }, { text: "Brave the ruins shortcut", next: 8 } ], illustration: "shadowwood-forest" },
|
288 |
+
"6": { title: "Ambush!", content: "<p>...</p>", options: [{ text: "Fight!", next: 9 }, { text: "Try to flee!", next: 10 }], illustration: "road-ambush" },
|
289 |
+
"7": { title: "River Path", content: "<p>...</p>", options: [{ text: "Continue", next: 11 }, { text: "Investigate", next: 12 }], illustration: "river-spirit" /* TODO */ },
|
290 |
+
"8": { title: "Ancient Ruins", content: "<p>...</p>", options: [{ text: "Search", next: 13 }, { text: "Look for passages", next: 14 }], illustration: "ancient-ruins" /* TODO */ },
|
291 |
+
"9": { title: "Victory!", content: "<p>...</p>", options: [{ text: "Proceed", next: 15 }], illustration: "forest-edge", reward: { xp: 75, statIncrease: { stat: "strength", amount: 1 }, addItem: "Scout's Pouch" } }, // Example Reward
|
292 |
+
"10": { title: "Captured!", content: "<p>...</p>", options: [{ text: "Wait", next: 20 }], illustration: "prisoner-cell" },
|
293 |
+
// Add many more pages...
|
294 |
+
"15": { title: "Fortress Plains", content: "<p>...</p>", options: [{ text: "Approach gate", next: 30 }, { text: "Scout", next: 31 }], illustration: "fortress-plains" /* TODO */ },
|
295 |
+
"20": { title: "Inside the Cell", content: "<p>...</p>", options: [{ text: "Look for weaknesses", next: 21 }, { text: "Talk to guard", next: 22 }], illustration: "prisoner-cell" },
|
296 |
+
"99": { title: "Game Over", content: "<p>Your adventure ends here.</p>", options: [{ text: "Restart", next: 1 }], illustration: "game-over", gameOver: true }
|
297 |
+
};
|
298 |
+
|
299 |
+
|
300 |
+
// --- Game State ---
|
301 |
+
let gameState = {
|
302 |
+
currentPageId: 1,
|
303 |
+
character: {
|
304 |
+
name: "Hero", race: "Human", alignment: "Neutral Good", class: "Fighter",
|
305 |
+
level: 1, xp: 0, xpToNextLevel: 100, statPointsPerLevel: 1, availableStatPoints: 0,
|
306 |
+
stats: { strength: 7, intelligence: 5, wisdom: 5, dexterity: 6, constitution: 6, charisma: 5, hp: 30, maxHp: 30 },
|
307 |
+
inventory: []
|
308 |
+
}
|
309 |
+
};
|
310 |
+
|
311 |
+
|
312 |
+
// --- Character Sheet Functions ---
|
313 |
+
|
314 |
+
function renderCharacterSheet() {
|
315 |
+
const char = gameState.character;
|
316 |
+
charNameInput.value = char.name;
|
317 |
+
charRaceSpan.textContent = char.race; charAlignmentSpan.textContent = char.alignment; charClassSpan.textContent = char.class;
|
318 |
+
charLevelSpan.textContent = char.level; charXPSpan.textContent = char.xp; charXPNextSpan.textContent = char.xpToNextLevel;
|
319 |
+
char.stats.hp = Math.min(char.stats.hp, char.stats.maxHp); charHPSpan.textContent = char.stats.hp; charMaxHPSpan.textContent = char.stats.maxHp;
|
320 |
+
for (const stat in statSpans) { if (statSpans.hasOwnProperty(stat) && char.stats.hasOwnProperty(stat)) { statSpans[stat].textContent = char.stats[stat]; } }
|
321 |
+
charInventoryList.innerHTML = ''; const maxSlots = 15;
|
322 |
+
for (let i = 0; i < maxSlots; i++) { const li = document.createElement('li'); if (i < char.inventory.length) { const item = char.inventory[i]; const itemInfo = itemsData[item] || { type: 'unknown', description: '???' }; const itemSpan = document.createElement('span'); itemSpan.classList.add(`item-${itemInfo.type || 'unknown'}`); itemSpan.title = itemInfo.description; itemSpan.textContent = item; li.appendChild(itemSpan); } else { const emptySlotSpan = document.createElement('span'); emptySlotSpan.classList.add('item-slot'); emptySlotSpan.textContent = '[Empty]'; li.appendChild(emptySlotSpan); } charInventoryList.appendChild(li); }
|
323 |
+
updateLevelUpAvailability();
|
324 |
+
}
|
325 |
+
|
326 |
+
function calculateStatIncreaseCost() { return (gameState.character.level * 10) + 5; }
|
327 |
+
|
328 |
+
function updateLevelUpAvailability() {
|
329 |
+
const char = gameState.character; const canLevelUp = char.xp >= char.xpToNextLevel; levelUpButton.disabled = !canLevelUp;
|
330 |
+
const cost = calculateStatIncreaseCost(); const canIncreaseWithXP = char.xp >= cost; const canIncreaseWithPoints = char.availableStatPoints > 0;
|
331 |
+
statIncreaseButtons.forEach(button => { button.disabled = !(canIncreaseWithPoints || canIncreaseWithXP); });
|
332 |
+
statIncreaseCostSpan.textContent = cost; statPointsAvailableSpan.textContent = char.availableStatPoints;
|
333 |
+
}
|
334 |
+
|
335 |
+
function handleLevelUp() {
|
336 |
+
const char = gameState.character; if (char.xp >= char.xpToNextLevel) { char.level++; char.xp -= char.xpToNextLevel; char.xpToNextLevel = Math.floor(char.xpToNextLevel * 1.6); char.availableStatPoints += char.statPointsPerLevel; const conModifier = Math.floor((char.stats.constitution - 10) / 2); const hpGain = Math.max(1, Math.floor(Math.random() * 6) + 1 + conModifier); char.stats.maxHp += hpGain; char.stats.hp = char.stats.maxHp; console.log(`Leveled Up to ${char.level}! Gained ${char.statPointsPerLevel} stat point(s) and ${hpGain} HP.`); renderCharacterSheet(); } else { console.warn("Not enough XP to level up yet."); }
|
337 |
+
}
|
338 |
+
|
339 |
+
function handleStatIncrease(statName) {
|
340 |
+
const char = gameState.character; const cost = calculateStatIncreaseCost();
|
341 |
+
if (char.availableStatPoints > 0) { char.stats[statName]++; char.availableStatPoints--; console.log(`Increased ${statName} using a point. ${char.availableStatPoints} points remaining.`); if (statName === 'constitution') { const oldMod = Math.floor((char.stats.constitution - 1 - 10) / 2); const newMod = Math.floor((char.stats.constitution - 10) / 2); const hpBonus = Math.max(0, newMod - oldMod) * char.level; if(hpBonus > 0){ char.stats.maxHp += hpBonus; char.stats.hp += hpBonus; console.log(`+${hpBonus} HP from CON.`);} } renderCharacterSheet(); return; }
|
342 |
+
if (char.xp >= cost) { char.stats[statName]++; char.xp -= cost; console.log(`Increased ${statName} for ${cost} XP.`); if (statName === 'constitution') { const oldMod = Math.floor((char.stats.constitution - 1 - 10) / 2); const newMod = Math.floor((char.stats.constitution - 10) / 2); const hpBonus = Math.max(0, newMod - oldMod) * char.level; if(hpBonus > 0){ char.stats.maxHp += hpBonus; char.stats.hp += hpBonus; console.log(`+${hpBonus} HP from CON.`);} } renderCharacterSheet(); } else { console.warn(`Not enough XP or points to increase ${statName}.`); }
|
343 |
+
}
|
344 |
+
|
345 |
+
function saveCharacter() { try { localStorage.setItem('textAdventureCharacter', JSON.stringify(gameState.character)); console.log('Character saved locally.'); saveCharButton.textContent = 'Saved!'; saveCharButton.disabled = true; setTimeout(() => { saveCharButton.textContent = 'Save'; saveCharButton.disabled = false; }, 1500); } catch (e) { console.error('Error saving character:', e); alert('Failed to save character.'); } }
|
346 |
+
|
347 |
+
function loadCharacter() { try { const savedData = localStorage.getItem('textAdventureCharacter'); if (savedData) { const loadedChar = JSON.parse(savedData); gameState.character = { ...gameState.character, ...loadedChar, stats: { ...gameState.character.stats, ...(loadedChar.stats || {}) }, inventory: loadedChar.inventory || [] }; console.log('Character loaded from local storage.'); return true; } } catch (e) { console.error('Error loading character:', e); } return false; }
|
348 |
+
|
349 |
+
function exportCharacter() { try { const charJson = JSON.stringify(gameState.character, null, 2); const blob = new Blob([charJson], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const filename = `${gameState.character.name.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'character'}_save.json`; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); console.log(`Character exported as ${filename}`); } catch (e) { console.error('Error exporting character:', e); alert('Failed to export character data.'); } }
|
350 |
+
|
351 |
+
// Event Listeners
|
352 |
+
charNameInput.addEventListener('change', () => { gameState.character.name = charNameInput.value.trim() || "Hero"; console.log(`Name changed to: ${gameState.character.name}`); });
|
353 |
+
levelUpButton.addEventListener('click', handleLevelUp);
|
354 |
+
statIncreaseButtons.forEach(button => { button.addEventListener('click', () => { const statToIncrease = button.dataset.stat; if (statToIncrease) { handleStatIncrease(statToIncrease); } }); });
|
355 |
+
saveCharButton.addEventListener('click', saveCharacter);
|
356 |
+
exportCharButton.addEventListener('click', exportCharacter);
|
357 |
+
|
358 |
+
|
359 |
+
// --- Game Logic Functions ---
|
360 |
+
|
361 |
+
function startGame() {
|
362 |
+
if (!loadCharacter()) { console.log("No saved character found, starting new."); }
|
363 |
+
// Ensure full character structure after load
|
364 |
+
const defaultChar = { name: "Hero", race: "Human", alignment: "Neutral Good", class: "Fighter", level: 1, xp: 0, xpToNextLevel: 100, statPointsPerLevel: 1, availableStatPoints: 0, stats: { strength: 7, intelligence: 5, wisdom: 5, dexterity: 6, constitution: 6, charisma: 5, hp: 30, maxHp: 30 }, inventory: [] };
|
365 |
+
gameState.character = { ...defaultChar, ...gameState.character };
|
366 |
+
gameState.character.stats = { ...defaultChar.stats, ...(gameState.character.stats || {}) };
|
367 |
+
|
368 |
+
gameState.currentPageId = 1;
|
369 |
+
renderCharacterSheet();
|
370 |
+
renderPage(gameState.currentPageId);
|
371 |
+
}
|
372 |
+
|
373 |
+
function renderPage(pageId) {
|
374 |
+
const page = gameData[pageId];
|
375 |
+
if (!page) { console.error(`Error: Page data not found for ID: ${pageId}`); storyTitleElement.textContent = "Error"; storyContentElement.innerHTML = "<p>Could not load page data.</p>"; choicesElement.innerHTML = '<button class="choice-button" onclick="handleChoiceClick({ nextPage: 1 })">Restart</button>'; updateScene('error'); return; }
|
376 |
+
storyTitleElement.textContent = page.title || "Untitled Page"; storyContentElement.innerHTML = page.content || "<p>...</p>";
|
377 |
+
choicesElement.innerHTML = '';
|
378 |
+
if (page.options && page.options.length > 0) {
|
379 |
+
page.options.forEach(option => {
|
380 |
+
const button = document.createElement('button'); button.classList.add('choice-button'); button.textContent = option.text; let requirementMet = true;
|
381 |
+
if (option.requireItem && !gameState.character.inventory.includes(option.requireItem)) { requirementMet = false; button.title = `Requires: ${option.requireItem}`; button.disabled = true; }
|
382 |
+
if (requirementMet) { const choiceData = { nextPage: option.next }; if (option.addItem) { choiceData.addItem = option.addItem; } button.addEventListener('click', () => handleChoiceClick(choiceData)); } else { button.classList.add('disabled'); } choicesElement.appendChild(button); });
|
383 |
+
} else if (page.gameOver) { const button = document.createElement('button'); button.classList.add('choice-button'); button.textContent = "Restart Adventure"; button.addEventListener('click', () => handleChoiceClick({ nextPage: 1 })); choicesElement.appendChild(button); } else { choicesElement.innerHTML = '<p><i>There are no further paths from here.</i></p>'; const button = document.createElement('button'); button.classList.add('choice-button'); button.textContent = "Restart Adventure"; button.addEventListener('click', () => handleChoiceClick({ nextPage: 1 })); choicesElement.appendChild(button); }
|
384 |
+
updateScene(page.illustration || 'default');
|
385 |
+
}
|
386 |
+
|
387 |
+
function handleChoiceClick(choiceData) {
|
388 |
+
const nextPageId = parseInt(choiceData.nextPage); const itemToAdd = choiceData.addItem; if (isNaN(nextPageId)) { console.error("Invalid nextPageId:", choiceData.nextPage); return; }
|
389 |
+
if (itemToAdd && !gameState.character.inventory.includes(itemToAdd)) { gameState.character.inventory.push(itemToAdd); console.log("Added item:", itemToAdd); }
|
390 |
+
gameState.currentPageId = nextPageId; const nextPageData = gameData[nextPageId];
|
391 |
+
if (nextPageData) { if (nextPageData.hpLoss) { gameState.character.stats.hp -= nextPageData.hpLoss; console.log(`Lost ${nextPageData.hpLoss} HP.`); if (gameState.character.stats.hp <= 0) { gameState.character.stats.hp = 0; console.log("Player died!"); renderCharacterSheet(); renderPage(99); return; } }
|
392 |
+
if (nextPageData.reward) { if (nextPageData.reward.xp) { gameState.character.xp += nextPageData.reward.xp; console.log(`Gained ${nextPageData.reward.xp} XP!`); } if (nextPageData.reward.statIncrease) { const stat = nextPageData.reward.statIncrease.stat; const amount = nextPageData.reward.statIncrease.amount; if (gameState.character.stats.hasOwnProperty(stat)) { gameState.character.stats[stat] += amount; console.log(`Stat ${stat} increased by ${amount}!`); if (stat === 'constitution') { const oldMod = Math.floor((gameState.character.stats.constitution - amount - 10) / 2); const newMod = Math.floor((gameState.character.stats.constitution - 10) / 2); const hpBonus = Math.max(0, newMod - oldMod) * gameState.character.level; if(hpBonus > 0){ gameState.character.stats.maxHp += hpBonus; gameState.character.stats.hp += hpBonus; console.log(`+${hpBonus} HP from CON.`);} } } } if(nextPageData.reward.addItem && !gameState.character.inventory.includes(nextPageData.reward.addItem)){ gameState.character.inventory.push(nextPageData.reward.addItem); console.log(`Found item: ${nextPageData.reward.addItem}`); } }
|
393 |
+
if (nextPageData.gameOver) { console.log("Reached Game Over."); renderCharacterSheet(); renderPage(nextPageId); return; }
|
394 |
+
} else { console.error(`Data for page ${nextPageId} not found!`); renderCharacterSheet(); renderPage(99); return; }
|
395 |
+
renderCharacterSheet(); renderPage(nextPageId);
|
396 |
+
}
|
397 |
+
|
398 |
+
// Scene Update Function
|
399 |
+
function updateScene(illustrationKey) {
|
400 |
+
console.log(`Updating scene for key: "${illustrationKey}"`);
|
401 |
+
if (currentAssemblyGroup) { scene.remove(currentAssemblyGroup); /* TODO: Dispose if needed */ }
|
402 |
+
currentAssemblyGroup = null; let assemblyFunction;
|
403 |
+
switch (illustrationKey) {
|
404 |
+
case 'city-gates': assemblyFunction = createCityGatesAssembly; break;
|
405 |
+
case 'weaponsmith': assemblyFunction = createWeaponsmithAssembly; break;
|
406 |
+
case 'temple': assemblyFunction = createTempleAssembly; break;
|
407 |
+
case 'resistance-meeting': assemblyFunction = createResistanceMeetingAssembly; break;
|
408 |
+
case 'shadowwood-forest': assemblyFunction = createForestAssembly; break;
|
409 |
+
case 'road-ambush': assemblyFunction = createRoadAmbushAssembly; break;
|
410 |
+
case 'forest-edge': assemblyFunction = createForestEdgeAssembly; break;
|
411 |
+
case 'prisoner-cell': assemblyFunction = createPrisonerCellAssembly; break;
|
412 |
+
case 'game-over': assemblyFunction = createGameOverAssembly; break;
|
413 |
+
case 'error': assemblyFunction = createErrorAssembly; break;
|
414 |
+
case 'river-spirit': console.warn("Scene 'river-spirit' not implemented."); assemblyFunction = createDefaultAssembly; break;
|
415 |
+
case 'ancient-ruins': console.warn("Scene 'ancient-ruins' not implemented."); assemblyFunction = createDefaultAssembly; break;
|
416 |
+
case 'fortress-plains': console.warn("Scene 'fortress-plains' not implemented."); assemblyFunction = createDefaultAssembly; break;
|
417 |
+
default: console.warn(`Unknown illustration key: "${illustrationKey}". Using default.`); assemblyFunction = createDefaultAssembly; break;
|
418 |
+
}
|
419 |
+
try { currentAssemblyGroup = assemblyFunction(); } catch (error) { console.error(`Error creating assembly for ${illustrationKey}:`, error); currentAssemblyGroup = createErrorAssembly(); }
|
420 |
+
if (currentAssemblyGroup) { scene.add(currentAssemblyGroup); } else { console.error(`Assembly failed for ${illustrationKey}.`); currentAssemblyGroup = createErrorAssembly(); scene.add(currentAssemblyGroup); }
|
421 |
+
}
|
422 |
+
|
423 |
+
|
424 |
+
// --- Initialization ---
|
425 |
+
initThreeJS(); // Set up the 3D scene first
|
426 |
+
startGame(); // Load data, render character sheet, and show first page
|