import * as THREE from 'three'; // Optional: Add OrbitControls for debugging/viewing scene // import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // --- DOM Elements --- const sceneContainer = document.getElementById('scene-container'); const storyTitleElement = document.getElementById('story-title'); const storyContentElement = document.getElementById('story-content'); const choicesElement = document.getElementById('choices'); // Removed old stats/inventory elements // const statsElement = document.getElementById('stats-display'); // const inventoryElement = document.getElementById('inventory-display'); // Character Sheet Elements const charNameInput = document.getElementById('char-name'); const charRaceSpan = document.getElementById('char-race'); const charAlignmentSpan = document.getElementById('char-alignment'); const charClassSpan = document.getElementById('char-class'); const charLevelSpan = document.getElementById('char-level'); const charXPSpan = document.getElementById('char-xp'); const charXPNextSpan = document.getElementById('char-xp-next'); const charHPSpan = document.getElementById('char-hp'); const charMaxHPSpan = document.getElementById('char-max-hp'); const charInventoryList = document.getElementById('char-inventory-list'); const statSpans = { strength: document.getElementById('stat-strength'), intelligence: document.getElementById('stat-intelligence'), wisdom: document.getElementById('stat-wisdom'), dexterity: document.getElementById('stat-dexterity'), constitution: document.getElementById('stat-constitution'), charisma: document.getElementById('stat-charisma'), }; const statIncreaseButtons = document.querySelectorAll('.stat-increase'); const levelUpButton = document.getElementById('levelup-btn'); const saveCharButton = document.getElementById('save-char-btn'); const exportCharButton = document.getElementById('export-char-btn'); const statIncreaseCostSpan = document.getElementById('stat-increase-cost'); const statPointsAvailableSpan = document.getElementById('stat-points-available'); // --- Three.js Setup --- let scene, camera, renderer; let currentAssemblyGroup = null; let directionalLight; let sunAngle = Math.PI / 4; // Start sun partway through morning const clock = new THREE.Clock(); let clouds = []; // let controls; // --- Shared Materials --- const stoneMaterial = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8, metalness: 0.1 }); const woodMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.7, metalness: 0 }); const darkWoodMaterial = new THREE.MeshStandardMaterial({ color: 0x5C3D20, roughness: 0.7, metalness: 0 }); const leafMaterial = new THREE.MeshStandardMaterial({ color: 0x2E8B57, roughness: 0.6, metalness: 0 }); const pineLeafMaterial = new THREE.MeshStandardMaterial({ color: 0x1A5A2A, roughness: 0.7, metalness: 0 }); const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x556B2F, roughness: 0.9, metalness: 0 }); const metalMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.8, roughness: 0.3 }); const fabricMaterial = new THREE.MeshStandardMaterial({ color: 0x696969, roughness: 0.9, metalness: 0 }); const waterMaterial = new THREE.MeshStandardMaterial({ color: 0x60A3D9, roughness: 0.2, metalness: 0.1, transparent: true, opacity: 0.7 }); const templeMaterial = new THREE.MeshStandardMaterial({ color: 0xA99B78, roughness: 0.7, metalness: 0.1 }); const fireMaterial = new THREE.MeshStandardMaterial({ color: 0xFF4500, emissive: 0xff6600, roughness: 0.5, metalness: 0 }); const errorMaterial = new THREE.MeshStandardMaterial({ color: 0xffa500, roughness: 0.5 }); const gameOverMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.5 }); const windowMaterial = new THREE.MeshStandardMaterial({ color: 0x334455, roughness: 0.3, metalness: 0, transparent: true, opacity: 0.6 }); function initThreeJS() { scene = new THREE.Scene(); // Skybox Loading const loader = new THREE.CubeTextureLoader(); // !!! IMPORTANT: Replace this path with the correct one for your textures !!! const texturePath = 'textures/skybox/'; const textureFiles = ['posx.jpg', 'negx.jpg', 'posy.jpg', 'negy.jpg', 'posz.jpg', 'negz.jpg']; try { const texture = loader.setPath(texturePath).load(textureFiles, () => { console.log("Skybox loaded"); scene.background = texture; }, undefined, (err) => { console.error(`Skybox loading error from ${texturePath}:`, err); scene.background = new THREE.Color(0x557799); } ); } catch (e) { console.error("Error initiating skybox load (check path format maybe?):", e); scene.background = new THREE.Color(0x557799); // Fallback } camera = new THREE.PerspectiveCamera(60, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000); camera.position.set(0, 3, 9); camera.lookAt(0, 1, 0); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; sceneContainer.appendChild(renderer.domElement); // Lighting const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); directionalLight = new THREE.DirectionalLight(0xffffff, 1.5); directionalLight.position.set(20, 30, 15); // Initial position, updated in animate directionalLight.target.position.set(0, 0, 0); directionalLight.castShadow = true; directionalLight.shadow.mapSize.width = 1024; directionalLight.shadow.mapSize.height = 1024; directionalLight.shadow.camera.near = 1; directionalLight.shadow.camera.far = 100; const shadowCamSize = 25; directionalLight.shadow.camera.left = -shadowCamSize; directionalLight.shadow.camera.right = shadowCamSize; directionalLight.shadow.camera.top = shadowCamSize; directionalLight.shadow.camera.bottom = -shadowCamSize; directionalLight.shadow.bias = -0.001; scene.add(directionalLight); scene.add(directionalLight.target); // Clouds createClouds(15); window.addEventListener('resize', onWindowResize, false); animate(); } // Helper function to create meshes 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 }) { const mesh = new THREE.Mesh(geometry, material); mesh.position.set(position.x, position.y, position.z); mesh.rotation.set(rotation.x, rotation.y, rotation.z); mesh.scale.set(scale.x, scale.y, scale.z); mesh.castShadow = true; mesh.receiveShadow = true; return mesh; } // Cloud Creation function createClouds(count) { const textureLoader = new THREE.TextureLoader(); // !!! IMPORTANT: Replace this path with the correct one for your cloud texture !!! const cloudTexturePath = 'textures/cloud.png'; try { const cloudTexture = textureLoader.load(cloudTexturePath, () => { console.log("Cloud texture loaded"); }, undefined, (err) => { console.error(`Cloud texture loading error from ${cloudTexturePath}:`, err); } ); const cloudMaterial = new THREE.MeshBasicMaterial({ map: cloudTexture, transparent: true, alphaTest: 0.2, depthWrite: false, side: THREE.DoubleSide, }); const cloudGeo = new THREE.PlaneGeometry(6, 3); // Slightly larger clouds for (let i = 0; i < count; i++) { const cloud = new THREE.Mesh(cloudGeo, cloudMaterial.clone()); // Clone material needed if alphaTest differs per cloud later cloud.position.set( (Math.random() - 0.5) * 80, 15 + Math.random() * 5, (Math.random() - 0.5) * 50 ); cloud.rotation.y = Math.random() * Math.PI * 2; cloud.rotation.z = Math.random() * 0.2 - 0.1; cloud.userData.speed = (Math.random() * 0.05 + 0.02); clouds.push(cloud); scene.add(cloud); } console.log(`Created ${clouds.length} clouds (or tried to).`); } catch(e) { console.error("Error initiating cloud texture load (check path format maybe?):", e); } } // --- Procedural Generation Functions --- function createGroundPlane(material = groundMaterial, size = 20) { const groundGeo = new THREE.PlaneGeometry(size, size); const ground = new THREE.Mesh(groundGeo, material); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.05; ground.receiveShadow = true; ground.castShadow = false; return ground; } function createDefaultAssembly() { /* ... (same as before) ... */ const group = new THREE.Group(); const sphereGeo = new THREE.SphereGeometry(0.5, 16, 16); group.add(createMesh(sphereGeo, stoneMaterial, { x: 0, y: 0.5, z: 0 })); group.add(createGroundPlane()); return group; } function createCityGatesAssembly() { /* ... (same as before) ... */ const group = new THREE.Group(); const gateWallHeight = 4; const gateWallWidth = 1.5; const gateWallDepth = 0.8; const archHeight = 1; const archWidth = 3; const towerLeftGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth); group.add(createMesh(towerLeftGeo, stoneMaterial, { x: -(archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 })); const towerRightGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth); group.add(createMesh(towerRightGeo, stoneMaterial, { x: (archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 })); const archGeo = new THREE.BoxGeometry(archWidth, archHeight, gateWallDepth); group.add(createMesh(archGeo, stoneMaterial, { x: 0, y: gateWallHeight - archHeight / 2, z: 0 })); const crenellationSize = 0.4; const crenGeo = new THREE.BoxGeometry(crenellationSize, crenellationSize, gateWallDepth * 1.1); 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); } 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); } group.add(createGroundPlane(stoneMaterial)); return group; } function createWeaponsmithAssembly() { /* ... (enhanced version from before) ... */ 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; 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); 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); 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 })); 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})); } 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 })); } 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})); } 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 })); 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; } function createTempleAssembly() { /* ... (same as before) ... */ 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; } function createResistanceMeetingAssembly() { /* ... (same as before) ... */ 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; } function createForestAssembly(treeCount = 15, area = 12) { /* ... (enhanced version from before) ... */ const group = new THREE.Group(); 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 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; } function createRoadAmbushAssembly() { /* ... (same as before) ... */ 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; } function createForestEdgeAssembly() { /* ... (same as before) ... */ const group = new THREE.Group(); const area = 15; const forestGroup = new THREE.Group(); // Reusing createForestAssembly logic more cleanly might require refactoring createTree out, // but this temporary approach works for now. 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; }; 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; } function createPrisonerCellAssembly() { /* ... (same as before) ... */ 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; } function createGameOverAssembly() { /* ... (same as before) ... */ 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; } function createErrorAssembly() { /* ... (same as before) ... */ 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; } // Window Resize function onWindowResize() { if (!renderer || !camera) return; camera.aspect = sceneContainer.clientWidth / sceneContainer.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); } // Animation Loop function animate() { requestAnimationFrame(animate); const delta = clock.getDelta(); // Sun Movement const sunSpeed = 0.05; sunAngle += delta * sunSpeed; const sunDistance = 40; const sunHeight = 30; const duskAngle = Math.PI * 0.15; directionalLight.position.x = Math.cos(sunAngle) * sunDistance; directionalLight.position.y = Math.max(0.1, Math.sin(sunAngle) * sunHeight); directionalLight.position.z = Math.sin(sunAngle * 0.75) * sunDistance * 0.6; // Sun Color/Intensity const normalizedY = directionalLight.position.y / sunHeight; directionalLight.intensity = Math.max(0.1, normalizedY * 1.5); const white = new THREE.Color(0xffffff); const dusk = new THREE.Color(0xFFAB6B); const blendFactor = Math.max(0, Math.min(1, Math.pow(1 - normalizedY, 2))); if (Math.sin(sunAngle) > 0) { directionalLight.color.lerpColors(white, dusk, blendFactor); } else { directionalLight.intensity = 0.05; directionalLight.color.set(0x6688cc); } // Cloud Movement clouds.forEach(cloud => { cloud.position.x += cloud.userData.speed * delta * 50; if (cloud.position.x > 60) { cloud.position.x = -60; cloud.position.z = (Math.random() - 0.5) * 50; } cloud.lookAt(camera.position); // Billboarding }); // Render if (renderer && scene && camera) { renderer.render(scene, camera); } } // --- Game Data --- const itemsData = { "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" }, "Scout's Pouch": { type: "quest", description: "Contains odds and ends."} // Added item from example reward }; const gameData = { "1": { title: "The Beginning", content: `

...

`, 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" }, "2": { title: "The Weaponsmith", content: `

...

`, 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" }, "3": { title: "The Ancient Temple", content: `

...

`, 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" }, "4": { title: "The Resistance Leader", content: `

...

`, 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" }, "5": { title: "The Journey Begins", content: `

...

`, 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" }, "6": { title: "Ambush!", content: "

...

", options: [{ text: "Fight!", next: 9 }, { text: "Try to flee!", next: 10 }], illustration: "road-ambush" }, "7": { title: "River Path", content: "

...

", options: [{ text: "Continue", next: 11 }, { text: "Investigate", next: 12 }], illustration: "river-spirit" /* TODO */ }, "8": { title: "Ancient Ruins", content: "

...

", options: [{ text: "Search", next: 13 }, { text: "Look for passages", next: 14 }], illustration: "ancient-ruins" /* TODO */ }, "9": { title: "Victory!", content: "

...

", options: [{ text: "Proceed", next: 15 }], illustration: "forest-edge", reward: { xp: 75, statIncrease: { stat: "strength", amount: 1 }, addItem: "Scout's Pouch" } }, // Example Reward "10": { title: "Captured!", content: "

...

", options: [{ text: "Wait", next: 20 }], illustration: "prisoner-cell" }, // Add many more pages... "15": { title: "Fortress Plains", content: "

...

", options: [{ text: "Approach gate", next: 30 }, { text: "Scout", next: 31 }], illustration: "fortress-plains" /* TODO */ }, "20": { title: "Inside the Cell", content: "

...

", options: [{ text: "Look for weaknesses", next: 21 }, { text: "Talk to guard", next: 22 }], illustration: "prisoner-cell" }, "99": { title: "Game Over", content: "

Your adventure ends here.

", options: [{ text: "Restart", next: 1 }], illustration: "game-over", gameOver: true } }; // --- Game State --- let gameState = { currentPageId: 1, character: { 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: [] } }; // --- Character Sheet Functions --- function renderCharacterSheet() { const char = gameState.character; charNameInput.value = char.name; charRaceSpan.textContent = char.race; charAlignmentSpan.textContent = char.alignment; charClassSpan.textContent = char.class; charLevelSpan.textContent = char.level; charXPSpan.textContent = char.xp; charXPNextSpan.textContent = char.xpToNextLevel; char.stats.hp = Math.min(char.stats.hp, char.stats.maxHp); charHPSpan.textContent = char.stats.hp; charMaxHPSpan.textContent = char.stats.maxHp; for (const stat in statSpans) { if (statSpans.hasOwnProperty(stat) && char.stats.hasOwnProperty(stat)) { statSpans[stat].textContent = char.stats[stat]; } } charInventoryList.innerHTML = ''; const maxSlots = 15; 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); } updateLevelUpAvailability(); } function calculateStatIncreaseCost() { return (gameState.character.level * 10) + 5; } function updateLevelUpAvailability() { const char = gameState.character; const canLevelUp = char.xp >= char.xpToNextLevel; levelUpButton.disabled = !canLevelUp; const cost = calculateStatIncreaseCost(); const canIncreaseWithXP = char.xp >= cost; const canIncreaseWithPoints = char.availableStatPoints > 0; statIncreaseButtons.forEach(button => { button.disabled = !(canIncreaseWithPoints || canIncreaseWithXP); }); statIncreaseCostSpan.textContent = cost; statPointsAvailableSpan.textContent = char.availableStatPoints; } function handleLevelUp() { 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."); } } function handleStatIncrease(statName) { const char = gameState.character; const cost = calculateStatIncreaseCost(); 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; } 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}.`); } } 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.'); } } 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; } 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.'); } } // Event Listeners charNameInput.addEventListener('change', () => { gameState.character.name = charNameInput.value.trim() || "Hero"; console.log(`Name changed to: ${gameState.character.name}`); }); levelUpButton.addEventListener('click', handleLevelUp); statIncreaseButtons.forEach(button => { button.addEventListener('click', () => { const statToIncrease = button.dataset.stat; if (statToIncrease) { handleStatIncrease(statToIncrease); } }); }); saveCharButton.addEventListener('click', saveCharacter); exportCharButton.addEventListener('click', exportCharacter); // --- Game Logic Functions --- function startGame() { if (!loadCharacter()) { console.log("No saved character found, starting new."); } // Ensure full character structure after load 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: [] }; gameState.character = { ...defaultChar, ...gameState.character }; gameState.character.stats = { ...defaultChar.stats, ...(gameState.character.stats || {}) }; gameState.currentPageId = 1; renderCharacterSheet(); renderPage(gameState.currentPageId); } function renderPage(pageId) { const page = gameData[pageId]; if (!page) { console.error(`Error: Page data not found for ID: ${pageId}`); storyTitleElement.textContent = "Error"; storyContentElement.innerHTML = "

Could not load page data.

"; choicesElement.innerHTML = ''; updateScene('error'); return; } storyTitleElement.textContent = page.title || "Untitled Page"; storyContentElement.innerHTML = page.content || "

...

"; choicesElement.innerHTML = ''; if (page.options && page.options.length > 0) { page.options.forEach(option => { const button = document.createElement('button'); button.classList.add('choice-button'); button.textContent = option.text; let requirementMet = true; if (option.requireItem && !gameState.character.inventory.includes(option.requireItem)) { requirementMet = false; button.title = `Requires: ${option.requireItem}`; button.disabled = true; } 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); }); } 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 = '

There are no further paths from here.

'; const button = document.createElement('button'); button.classList.add('choice-button'); button.textContent = "Restart Adventure"; button.addEventListener('click', () => handleChoiceClick({ nextPage: 1 })); choicesElement.appendChild(button); } updateScene(page.illustration || 'default'); } function handleChoiceClick(choiceData) { const nextPageId = parseInt(choiceData.nextPage); const itemToAdd = choiceData.addItem; if (isNaN(nextPageId)) { console.error("Invalid nextPageId:", choiceData.nextPage); return; } if (itemToAdd && !gameState.character.inventory.includes(itemToAdd)) { gameState.character.inventory.push(itemToAdd); console.log("Added item:", itemToAdd); } gameState.currentPageId = nextPageId; const nextPageData = gameData[nextPageId]; 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; } } 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}`); } } if (nextPageData.gameOver) { console.log("Reached Game Over."); renderCharacterSheet(); renderPage(nextPageId); return; } } else { console.error(`Data for page ${nextPageId} not found!`); renderCharacterSheet(); renderPage(99); return; } renderCharacterSheet(); renderPage(nextPageId); } // Scene Update Function function updateScene(illustrationKey) { console.log(`Updating scene for key: "${illustrationKey}"`); if (currentAssemblyGroup) { scene.remove(currentAssemblyGroup); /* TODO: Dispose if needed */ } currentAssemblyGroup = null; let assemblyFunction; switch (illustrationKey) { case 'city-gates': assemblyFunction = createCityGatesAssembly; break; case 'weaponsmith': assemblyFunction = createWeaponsmithAssembly; break; case 'temple': assemblyFunction = createTempleAssembly; break; case 'resistance-meeting': assemblyFunction = createResistanceMeetingAssembly; break; case 'shadowwood-forest': assemblyFunction = createForestAssembly; break; case 'road-ambush': assemblyFunction = createRoadAmbushAssembly; break; case 'forest-edge': assemblyFunction = createForestEdgeAssembly; break; case 'prisoner-cell': assemblyFunction = createPrisonerCellAssembly; break; case 'game-over': assemblyFunction = createGameOverAssembly; break; case 'error': assemblyFunction = createErrorAssembly; break; case 'river-spirit': console.warn("Scene 'river-spirit' not implemented."); assemblyFunction = createDefaultAssembly; break; case 'ancient-ruins': console.warn("Scene 'ancient-ruins' not implemented."); assemblyFunction = createDefaultAssembly; break; case 'fortress-plains': console.warn("Scene 'fortress-plains' not implemented."); assemblyFunction = createDefaultAssembly; break; default: console.warn(`Unknown illustration key: "${illustrationKey}". Using default.`); assemblyFunction = createDefaultAssembly; break; } try { currentAssemblyGroup = assemblyFunction(); } catch (error) { console.error(`Error creating assembly for ${illustrationKey}:`, error); currentAssemblyGroup = createErrorAssembly(); } if (currentAssemblyGroup) { scene.add(currentAssemblyGroup); } else { console.error(`Assembly failed for ${illustrationKey}.`); currentAssemblyGroup = createErrorAssembly(); scene.add(currentAssemblyGroup); } } // --- Initialization --- initThreeJS(); // Set up the 3D scene first startGame(); // Load data, render character sheet, and show first page