Spaces:
Running
Running
| 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'); | |
| const statsElement = document.getElementById('stats-display'); | |
| const inventoryElement = document.getElementById('inventory-display'); | |
| // --- Three.js Setup --- | |
| let scene, camera, renderer; // Basic scene objects | |
| let currentAssemblyGroup = null; // Group to hold the current scene's objects | |
| // let controls; // Optional OrbitControls | |
| // --- Shared Materials (Define common materials here for reuse) --- | |
| 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 }); // SeaGreen | |
| const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x556B2F, roughness: 0.9, metalness: 0 }); // DarkOliveGreen | |
| 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 }); // DimGray | |
| 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 }); // Light stone/sandstone | |
| const fireMaterial = new THREE.MeshStandardMaterial({ color: 0xFF4500, emissive: 0xff6600, roughness: 0.5, metalness: 0 }); // OrangeRed emissive | |
| const errorMaterial = new THREE.MeshStandardMaterial({ color: 0xffa500, roughness: 0.5 }); // Orange | |
| const gameOverMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.5 }); // Red | |
| function initThreeJS() { | |
| // Scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x222222); // Match body background | |
| // scene.fog = new THREE.Fog(0x222222, 8, 20); // Optional: Add fog for depth | |
| // Camera | |
| camera = new THREE.PerspectiveCamera(75, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000); | |
| camera.position.set(0, 2.5, 7); // Adjusted camera position for better view | |
| camera.lookAt(0, 0, 0); | |
| // Renderer | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); | |
| renderer.shadowMap.enabled = true; // Enable shadows | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows | |
| sceneContainer.appendChild(renderer.domElement); | |
| // Basic Lighting | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // Soft white light | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); | |
| directionalLight.position.set(8, 15, 10); | |
| directionalLight.castShadow = true; // Enable shadow casting | |
| // Configure shadow properties (optional, adjust for quality/performance) | |
| directionalLight.shadow.mapSize.width = 1024; | |
| directionalLight.shadow.mapSize.height = 1024; | |
| directionalLight.shadow.camera.near = 0.5; | |
| directionalLight.shadow.camera.far = 50; | |
| directionalLight.shadow.camera.left = -15; | |
| directionalLight.shadow.camera.right = 15; | |
| directionalLight.shadow.camera.top = 15; | |
| directionalLight.shadow.camera.bottom = -15; | |
| scene.add(directionalLight); | |
| // const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 5); // Helper to visualize light | |
| // scene.add(lightHelper); | |
| // const shadowCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera); // Helper for shadow camera | |
| // scene.add(shadowCameraHelper); | |
| // REMOVED: Basic Object (Placeholder) - Will be added dynamically | |
| // Optional Controls | |
| // controls = new OrbitControls(camera, renderer.domElement); | |
| // controls.enableDamping = true; | |
| // controls.target.set(0, 1, 0); // Adjust target if needed | |
| // Handle Resize | |
| window.addEventListener('resize', onWindowResize, false); | |
| // Start Animation Loop | |
| 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; | |
| } | |
| // --- 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; // Rotate flat | |
| ground.position.y = -0.05; // Slightly below origin | |
| ground.receiveShadow = true; | |
| return ground; | |
| } | |
| function createDefaultAssembly() { | |
| 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() { | |
| const group = new THREE.Group(); | |
| const gateWallHeight = 4; | |
| const gateWallWidth = 1.5; | |
| const gateWallDepth = 0.8; | |
| const archHeight = 1; | |
| const archWidth = 3; | |
| // Left Tower | |
| const towerLeftGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth); | |
| group.add(createMesh(towerLeftGeo, stoneMaterial, { x: -(archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 })); | |
| // Right Tower | |
| const towerRightGeo = new THREE.BoxGeometry(gateWallWidth, gateWallHeight, gateWallDepth); | |
| group.add(createMesh(towerRightGeo, stoneMaterial, { x: (archWidth / 2 + gateWallWidth / 2), y: gateWallHeight / 2, z: 0 })); | |
| // Arch Top | |
| const archGeo = new THREE.BoxGeometry(archWidth, archHeight, gateWallDepth); | |
| group.add(createMesh(archGeo, stoneMaterial, { x: 0, y: gateWallHeight - archHeight / 2, z: 0 })); | |
| // Optional: Add crenellations (battlements) | |
| const crenellationSize = 0.4; | |
| for (let i = -2; i <= 2; i += 1) { | |
| const crenGeo = new THREE.BoxGeometry(crenellationSize, crenellationSize, gateWallDepth * 1.1); | |
| group.add(createMesh(crenGeo, stoneMaterial, { x: -(archWidth / 2 + gateWallWidth / 2) + i * crenellationSize * 1.5, y: gateWallHeight + crenellationSize / 2, z: 0 })); | |
| group.add(createMesh(crenGeo, stoneMaterial, { x: (archWidth / 2 + gateWallWidth / 2) + i * crenellationSize * 1.5, y: gateWallHeight + crenellationSize / 2, z: 0 })); | |
| } | |
| group.add(createGroundPlane(stoneMaterial)); // Stone ground | |
| return group; | |
| } | |
| function createWeaponsmithAssembly() { | |
| const group = new THREE.Group(); | |
| const buildingWidth = 3; | |
| const buildingHeight = 2.5; | |
| const buildingDepth = 3.5; | |
| const roofHeight = 1; | |
| // Main Building | |
| const buildingGeo = new THREE.BoxGeometry(buildingWidth, buildingHeight, buildingDepth); | |
| group.add(createMesh(buildingGeo, darkWoodMaterial, { x: 0, y: buildingHeight / 2, z: 0 })); | |
| // Roof (simple triangular prism shape made of two planes) | |
| const roofGeo = new THREE.PlaneGeometry(buildingWidth * 1.1, Math.sqrt(Math.pow(buildingDepth / 2, 2) + Math.pow(roofHeight, 2))); | |
| const roofLeft = createMesh(roofGeo, woodMaterial, { x: 0, y: buildingHeight + roofHeight / 2, z: -buildingDepth / 4 }, { x: 0, y: 0, z: Math.atan2(roofHeight, buildingDepth / 2) }); | |
| const roofRight = createMesh(roofGeo, woodMaterial, { x: 0, y: buildingHeight + roofHeight / 2, z: buildingDepth / 4 }, { x: 0, y: Math.PI, z: -Math.atan2(roofHeight, buildingDepth / 2) }); | |
| group.add(roofLeft); | |
| group.add(roofRight); | |
| // Add gable ends (triangles) | |
| const gableShape = new THREE.Shape(); | |
| gableShape.moveTo(-buildingWidth/2, buildingHeight); | |
| gableShape.lineTo(buildingWidth/2, buildingHeight); | |
| gableShape.lineTo(0, buildingHeight + roofHeight); | |
| gableShape.closePath(); | |
| const gableGeo = new THREE.ShapeGeometry(gableShape); | |
| group.add(createMesh(gableGeo, woodMaterial, {x: 0, y: 0, z: buildingDepth/2}, {x: 0, y: 0, z: 0})); | |
| group.add(createMesh(gableGeo, woodMaterial, {x: 0, y: 0, z: -buildingDepth/2}, {x: 0, y: Math.PI, z: 0})); | |
| // Forge/Chimney (simple representation) | |
| const forgeHeight = 3; | |
| 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 })); | |
| // Anvil (simple block) | |
| 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() { | |
| const group = new THREE.Group(); | |
| const baseSize = 5; | |
| const baseHeight = 0.5; | |
| const columnHeight = 3; | |
| const columnRadius = 0.25; | |
| const roofHeight = 1; | |
| // Base Platform | |
| const baseGeo = new THREE.BoxGeometry(baseSize, baseHeight, baseSize); | |
| group.add(createMesh(baseGeo, templeMaterial, { x: 0, y: baseHeight / 2, z: 0 })); | |
| // Columns (example: 4 columns) | |
| 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, templeMaterial, { x: pos.x, y: baseHeight + columnHeight / 2, z: pos.z })); | |
| }); | |
| // Simple Roof Slab | |
| 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 })); | |
| // Optional: Pyramid roof top | |
| const pyramidGeo = new THREE.ConeGeometry(baseSize * 0.5, roofHeight * 1.5, 4); // 4 sides for pyramid | |
| group.add(createMesh(pyramidGeo, templeMaterial, { x: 0, y: baseHeight + columnHeight + roofHeight *0.75, z: 0 }, { x: 0, y: Math.PI / 4, z: 0 })); // Rotate for alignment | |
| group.add(createGroundPlane()); | |
| return group; | |
| } | |
| function createResistanceMeetingAssembly() { | |
| const group = new THREE.Group(); | |
| const tableWidth = 2; | |
| const tableHeight = 0.8; | |
| const tableDepth = 1; | |
| const tableThickness = 0.1; | |
| // Table Top | |
| const tableTopGeo = new THREE.BoxGeometry(tableWidth, tableThickness, tableDepth); | |
| group.add(createMesh(tableTopGeo, woodMaterial, { x: 0, y: tableHeight - tableThickness / 2, z: 0 })); | |
| // Table Legs | |
| 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, woodMaterial, { x: legOffsetW, y: legHeight / 2, z: -legOffsetD })); | |
| group.add(createMesh(legGeo, woodMaterial, { x: -legOffsetW, y: legHeight / 2, z: legOffsetD })); | |
| group.add(createMesh(legGeo, woodMaterial, { x: legOffsetW, y: legHeight / 2, z: legOffsetD })); | |
| // Simple Stools/Boxes for people to sit on | |
| 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, darkWoodMaterial, { x: tableWidth * 0.6, y: stoolSize * 0.4, z: 0 })); | |
| group.add(createMesh(stoolGeo, darkWoodMaterial, { x: 0, y: stoolSize * 0.4, z: -tableDepth * 0.7 })); | |
| // Dim room feeling - maybe add simple walls | |
| 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)); // Stone floor | |
| return group; | |
| } | |
| function createForestAssembly(treeCount = 15, area = 10) { | |
| const group = new THREE.Group(); | |
| // Tree generation function | |
| const createTree = (x, z) => { | |
| const treeGroup = new THREE.Group(); | |
| const trunkHeight = Math.random() * 2 + 2; // Random height between 2 and 4 | |
| const trunkRadius = Math.random() * 0.15 + 0.1; // Random radius | |
| const trunkGeo = new THREE.CylinderGeometry(trunkRadius * 0.7, trunkRadius, trunkHeight, 8); | |
| treeGroup.add(createMesh(trunkGeo, woodMaterial, { x: 0, y: trunkHeight / 2, z: 0 })); | |
| // Foliage (simple sphere or cone) | |
| const foliageType = Math.random(); | |
| const foliageHeight = trunkHeight * (Math.random() * 0.5 + 0.8); // Relative to trunk | |
| if (foliageType < 0.6) { // Sphere foliage | |
| const foliageRadius = trunkHeight * 0.4; | |
| const foliageGeo = new THREE.SphereGeometry(foliageRadius, 8, 6); | |
| treeGroup.add(createMesh(foliageGeo, leafMaterial, { x: 0, y: trunkHeight * 0.9 + foliageRadius * 0.5, z: 0 })); | |
| } else { // Cone foliage | |
| const foliageRadius = trunkHeight * 0.5; | |
| const coneGeo = new THREE.ConeGeometry(foliageRadius, foliageHeight, 8); | |
| treeGroup.add(createMesh(coneGeo, leafMaterial, { x: 0, y: trunkHeight * 0.9 + foliageHeight * 0.5, z: 0 })); | |
| } | |
| treeGroup.position.set(x, 0, z); // Set position for the whole tree | |
| // Slight random rotation for variation | |
| treeGroup.rotation.y = Math.random() * Math.PI * 2; | |
| return treeGroup; | |
| }; | |
| // Scatter trees | |
| for (let i = 0; i < treeCount; i++) { | |
| const x = (Math.random() - 0.5) * area; | |
| const z = (Math.random() - 0.5) * area; | |
| // Basic check to avoid trees too close to the center (optional) | |
| if (Math.sqrt(x*x + z*z) > 1.5) { | |
| group.add(createTree(x, z)); | |
| } | |
| } | |
| group.add(createGroundPlane()); // Forest floor | |
| return group; | |
| } | |
| function createRoadAmbushAssembly() { | |
| const group = new THREE.Group(); | |
| const area = 12; | |
| // Add some forest elements | |
| const forestGroup = createForestAssembly(10, area); | |
| group.add(forestGroup); // Reuse forest generation | |
| // Add a simple road (a flat, wider plane) | |
| 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 }); // Muddy brown | |
| const road = createMesh(roadGeo, roadMaterial, {x: 0, y: 0.01, z: 0}, {x: -Math.PI / 2}); // Slightly above ground | |
| road.receiveShadow = true; // Ensure road receives shadows too | |
| group.add(road); | |
| // Add some rocks/bushes for cover (simple spheres/low boxes) | |
| const rockGeo = new THREE.SphereGeometry(0.5, 5, 4); | |
| const rockMaterial = new THREE.MeshStandardMaterial({ color: 0x666666, roughness: 0.8 }); | |
| group.add(createMesh(rockGeo, rockMaterial, {x: roadWidth * 0.7, y: 0.25, z: 1}, {y: Math.random() * Math.PI})); | |
| group.add(createMesh(rockGeo, rockMaterial, {x: -roadWidth * 0.8, y: 0.3, z: -2}, {y: Math.random() * Math.PI, x: Math.random()*0.2})); | |
| group.add(createMesh(new THREE.SphereGeometry(0.7, 5, 4), rockMaterial, {x: roadWidth * 0.9, y: 0.35, z: -3}, {y: Math.random() * Math.PI})); | |
| // Suggestion: You could add simple cylinder/box figures near cover later for the ambushers | |
| // Ground plane is added by createForestAssembly | |
| return group; | |
| } | |
| function createForestEdgeAssembly() { | |
| const group = new THREE.Group(); | |
| const area = 15; | |
| // Dense forest on one side | |
| const forestGroup = new THREE.Group(); | |
| for (let i = 0; i < 20; i++) { // More trees, denser area | |
| const x = (Math.random() - 0.9) * area / 2; // Skew to one side (negative X) | |
| const z = (Math.random() - 0.5) * area; | |
| forestGroup.add(createForestAssembly(1, 0).children[0].position.set(x,0,z)); // Add single tree procedurally | |
| } | |
| group.add(forestGroup); | |
| // Open plains on the other side (just ground) | |
| group.add(createGroundPlane(groundMaterial, area * 1.2)); // Larger ground plane | |
| return group; | |
| } | |
| function createPrisonerCellAssembly() { | |
| const group = new THREE.Group(); | |
| const cellSize = 3; | |
| const wallHeight = 2.5; | |
| const wallThickness = 0.2; | |
| const barRadius = 0.04; | |
| const barSpacing = 0.2; | |
| // Floor | |
| group.add(createGroundPlane(stoneMaterial, cellSize)); | |
| // Back Wall | |
| const wallBackGeo = new THREE.BoxGeometry(cellSize, wallHeight, wallThickness); | |
| group.add(createMesh(wallBackGeo, stoneMaterial, { x: 0, y: wallHeight / 2, z: -cellSize / 2 })); | |
| // Left Wall | |
| const wallSideGeo = new THREE.BoxGeometry(wallThickness, wallHeight, cellSize); | |
| group.add(createMesh(wallSideGeo, stoneMaterial, { x: -cellSize / 2, y: wallHeight / 2, z: 0 })); | |
| // Right Wall (Partial or Full) | |
| group.add(createMesh(wallSideGeo, stoneMaterial, { x: cellSize / 2, y: wallHeight / 2, z: 0 })); | |
| // Ceiling (optional) | |
| // const ceilingGeo = new THREE.BoxGeometry(cellSize, wallThickness, cellSize); | |
| // group.add(createMesh(ceilingGeo, stoneMaterial, { x: 0, y: wallHeight, z: 0 })); | |
| // Bars for the front | |
| const barGeo = new THREE.CylinderGeometry(barRadius, barRadius, wallHeight, 8); | |
| const numBars = Math.floor(cellSize / barSpacing); | |
| for (let i = 0; i <= numBars; i++) { | |
| const xPos = -cellSize / 2 + i * barSpacing; | |
| group.add(createMesh(barGeo, metalMaterial, { x: xPos, y: wallHeight / 2, z: cellSize / 2 })); | |
| } | |
| // Horizontal bars (top/bottom) | |
| const horizBarGeo = new THREE.BoxGeometry(cellSize, barRadius * 2, barRadius * 2); | |
| group.add(createMesh(horizBarGeo, metalMaterial, {x: 0, y: wallHeight - barRadius, z: cellSize/2})); | |
| group.add(createMesh(horizBarGeo, metalMaterial, {x: 0, y: barRadius, z: cellSize/2})); | |
| return group; | |
| } | |
| function createGameOverAssembly() { | |
| 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}))); // Darker ground | |
| return group; | |
| } | |
| function createErrorAssembly() { | |
| 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; | |
| } | |
| function onWindowResize() { | |
| if (!renderer || !camera) return; | |
| camera.aspect = sceneContainer.clientWidth / sceneContainer.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Optional: Add subtle animation to the entire assembly | |
| if (currentAssemblyGroup) { | |
| // Example: Very slow rotation | |
| // currentAssemblyGroup.rotation.y += 0.0005; | |
| } | |
| // if (controls) controls.update(); // If using OrbitControls | |
| if (renderer && scene && camera) { | |
| renderer.render(scene, camera); | |
| } | |
| } | |
| // --- Game Data (Ported from Python, simplified for now) --- | |
| const gameData = { | |
| "1": { | |
| title: "The Beginning", | |
| content: `<p>The Evil Power Master has been terrorizing the land... You stand at the entrance to Silverhold, ready to begin your quest.</p><p>How will you prepare?</p>`, | |
| options: [ | |
| { text: "Visit the local weaponsmith", next: 2, /* addItem: "..." */ }, | |
| { text: "Seek wisdom at the temple", next: 3, /* addItem: "..." */ }, | |
| { text: "Meet the resistance leader", next: 4, /* addItem: "..." */ } | |
| ], | |
| illustration: "city-gates" // Key for Three.js scene | |
| }, | |
| "2": { | |
| title: "The Weaponsmith", | |
| content: `<p>Gorn the weaponsmith welcomes you. "You'll need more than common steel," he says, offering weapons.</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" | |
| }, | |
| "3": { | |
| title: "The Ancient Temple", | |
| content: `<p>High Priestess Alara greets you. "Prepare your mind and spirit." She offers to teach you a secret art.</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" | |
| }, | |
| "4": { | |
| title: "The Resistance Leader", | |
| content: `<p>Lyra, the resistance leader, shows you a map. "His fortress has three possible entry points." She offers an item.</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" | |
| }, | |
| "5": { | |
| title: "The Journey Begins", | |
| content: `<p>You leave Silverhold and enter the corrupted Shadowwood Forest. Strange sounds echo. Which path will you take?</p>`, | |
| options: [ | |
| { text: "Take the main road", next: 6 }, // Leads to page 6 (Ambush) | |
| { text: "Follow the river path", next: 7 }, // Leads to page 7 (River Spirit) - NEEDS 3D Scene | |
| { text: "Brave the ruins shortcut", next: 8 } // Leads to page 8 (Ruins) - NEEDS 3D Scene | |
| ], | |
| illustration: "shadowwood-forest" // Key for Three.js scene | |
| // Add more pages here... | |
| }, | |
| // Add placeholder pages 6, 7, 8 etc. to continue the story | |
| "6": { | |
| title: "Ambush!", | |
| content: "<p>Scouts jump out from behind rocks and trees! 'Surrender!'</p>", | |
| options: [{ text: "Fight!", next: 9 }, { text: "Try to flee!", next: 10 }], // Example links | |
| illustration: "road-ambush" | |
| }, | |
| "7": { // Placeholder - NEEDS 3D Scene function | |
| title: "River Path", | |
| content: "<p>You follow the winding river. The water seems unnaturally dark.</p>", | |
| options: [{ text: "Continue along the river", next: 11 }, { text: "Investigate strange glow", next: 12 }], | |
| illustration: "river-spirit" // Needs createRiverSpiritAssembly() | |
| }, | |
| "8": { // Placeholder - NEEDS 3D Scene function | |
| title: "Ancient Ruins", | |
| content: "<p>Crumbling stones and overgrown vines mark ancient ruins. It feels watched.</p>", | |
| options: [{ text: "Search the main structure", next: 13 }, { text: "Look for hidden passages", next: 14 }], | |
| illustration: "ancient-ruins" // Needs createRuinsAssembly() | |
| }, | |
| "9": { // Example continuation | |
| title: "Victory!", | |
| content: "<p>You defeat the scouts and retrieve some basic supplies. The forest edge is near.</p>", | |
| options: [{ text: "Proceed to the fortress plains", next: 15 }], | |
| illustration: "forest-edge" | |
| }, | |
| "10": { // Example continuation | |
| title: "Captured!", | |
| content: "<p>Your attempt to flee fails! You are knocked out and awaken in a dark, damp cell.</p>", | |
| options: [{ text: "Wait and observe", next: 20 }], // Go to prison observation page | |
| illustration: "prisoner-cell" | |
| }, | |
| // ... Add many more pages based on your Python data ... | |
| "15": { // Placeholder for plains | |
| title: "Fortress Plains", | |
| content: "<p>You emerge from the forest onto windswept plains. The dark fortress looms ahead.</p>", | |
| options: [{ text: "Approach the main gate", next: 30 }, { text: "Scout the perimeter", next: 31 }], | |
| illustration: "fortress-plains" // Needs createFortressPlainsAssembly() | |
| }, | |
| "20": { // Placeholder for cell observation | |
| title: "Inside the Cell", | |
| content: "<p>The cell is small and cold. You hear guards patrolling outside.</p>", | |
| options: [{ text: "Look for weaknesses in the bars", next: 21 }, { text: "Try to talk to a guard", next: 22 }], | |
| illustration: "prisoner-cell" // Reuse cell | |
| }, | |
| // Game Over placeholder | |
| "99": { | |
| title: "Game Over", | |
| content: "<p>Your adventure ends here.</p>", | |
| options: [{ text: "Restart", next: 1 }], // Link back to start | |
| illustration: "game-over", | |
| gameOver: true | |
| } | |
| }; | |
| const itemsData = { // Simplified item data | |
| "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" }, | |
| // Add other items... | |
| }; | |
| // --- Game State --- | |
| let gameState = { | |
| currentPageId: 1, | |
| inventory: [], | |
| stats: { | |
| courage: 7, | |
| wisdom: 5, | |
| strength: 6, | |
| hp: 30, | |
| maxHp: 30 | |
| } | |
| }; | |
| // --- Game Logic Functions --- | |
| function startGame() { | |
| gameState = { // Reset state | |
| currentPageId: 1, | |
| inventory: [], | |
| stats: { courage: 7, wisdom: 5, strength: 6, hp: 30, maxHp: 30 } | |
| }; | |
| 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 = "<p>Could not load page data. Adventure halted.</p>"; | |
| choicesElement.innerHTML = '<button class="choice-button" onclick="handleChoiceClick({ nextPage: 1 })">Restart</button>'; // Provide restart option | |
| updateScene('error'); // Show error scene | |
| return; | |
| } | |
| // Update UI | |
| storyTitleElement.textContent = page.title || "Untitled Page"; | |
| storyContentElement.innerHTML = page.content || "<p>...</p>"; | |
| updateStatsDisplay(); | |
| updateInventoryDisplay(); | |
| // Update Choices | |
| choicesElement.innerHTML = ''; // Clear old choices | |
| 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; | |
| // Check requirements (basic check for now) | |
| let requirementMet = true; | |
| if (option.requireItem && !gameState.inventory.includes(option.requireItem)) { | |
| requirementMet = false; | |
| button.title = `Requires: ${option.requireItem}`; // Tooltip | |
| button.disabled = true; | |
| } | |
| // Add requireAnyItem check here later if needed | |
| if (requirementMet) { | |
| // Store data needed for handling the choice using dataset | |
| const choiceData = { nextPage: option.next }; // Always include next page | |
| if (option.addItem) { | |
| choiceData.addItem = option.addItem; | |
| } | |
| // Add other potential effects as data attributes if needed (e.g., data-stat-change="strength:1") | |
| // Use an event listener instead of inline onclick for better practice | |
| button.addEventListener('click', () => handleChoiceClick(choiceData)); | |
| } else { | |
| button.classList.add('disabled'); // Style disabled buttons | |
| } | |
| 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 })); // Restart goes to page 1 | |
| choicesElement.appendChild(button); | |
| } else { | |
| // Handle dead ends where no options are defined and it's not game over | |
| 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 })); // Restart goes to page 1 | |
| choicesElement.appendChild(button); | |
| } | |
| // Update 3D Scene | |
| updateScene(page.illustration || 'default'); | |
| } | |
| // Modified handleChoiceClick to accept an object | |
| function handleChoiceClick(choiceData) { | |
| const nextPageId = parseInt(choiceData.nextPage); // Ensure it's a number | |
| const itemToAdd = choiceData.addItem; | |
| // Add other potential effects from choiceData here (e.g., stat changes tied to the *choice itself*) | |
| if (isNaN(nextPageId)) { | |
| console.error("Invalid nextPageId:", choiceData.nextPage); | |
| return; | |
| } | |
| // --- Process Effects of Making the Choice --- | |
| // Add item if specified and not already present | |
| if (itemToAdd && !gameState.inventory.includes(itemToAdd)) { | |
| gameState.inventory.push(itemToAdd); | |
| console.log("Added item:", itemToAdd); | |
| } | |
| // Add stat changes/hp loss *linked to the choice itself* here if needed | |
| // --- Move to Next Page and Process Landing Effects --- | |
| gameState.currentPageId = nextPageId; | |
| const nextPageData = gameData[nextPageId]; | |
| if (nextPageData) { | |
| // Apply HP loss defined on the *landing* page | |
| if (nextPageData.hpLoss) { | |
| gameState.stats.hp -= nextPageData.hpLoss; | |
| console.log(`Lost ${nextPageData.hpLoss} HP.`); | |
| if (gameState.stats.hp <= 0) { | |
| console.log("Player died from HP loss!"); | |
| gameState.stats.hp = 0; | |
| renderPage(99); // Go to a specific game over page ID | |
| return; // Stop further processing | |
| } | |
| } | |
| // Apply stat increase defined on the *landing* page | |
| if (nextPageData.statIncrease) { | |
| const stat = nextPageData.statIncrease.stat; | |
| const amount = nextPageData.statIncrease.amount; | |
| if (gameState.stats.hasOwnProperty(stat)) { | |
| gameState.stats[stat] += amount; | |
| console.log(`Stat ${stat} increased by ${amount}.`); | |
| } | |
| } | |
| // Check if landing page is game over | |
| if (nextPageData.gameOver) { | |
| console.log("Reached Game Over page."); | |
| renderPage(nextPageId); | |
| return; | |
| } | |
| } else { | |
| console.error(`Data for page ${nextPageId} not found!`); | |
| // Optionally go to an error page or restart | |
| renderPage(99); // Go to game over page as fallback | |
| return; | |
| } | |
| // Render the new page | |
| renderPage(nextPageId); | |
| } | |
| function updateStatsDisplay() { | |
| let statsHTML = '<strong>Stats:</strong> '; | |
| statsHTML += `<span>HP: ${gameState.stats.hp}/${gameState.stats.maxHp}</span>`; | |
| statsHTML += `<span>Str: ${gameState.stats.strength}</span>`; | |
| statsHTML += `<span>Wis: ${gameState.stats.wisdom}</span>`; | |
| statsHTML += `<span>Cor: ${gameState.stats.courage}</span>`; | |
| statsElement.innerHTML = statsHTML; | |
| } | |
| function updateInventoryDisplay() { | |
| let inventoryHTML = '<strong>Inventory:</strong> '; | |
| if (gameState.inventory.length === 0) { | |
| inventoryHTML += '<em>Empty</em>'; | |
| } else { | |
| gameState.inventory.forEach(item => { | |
| const itemInfo = itemsData[item] || { type: 'unknown', description: '???' }; | |
| // Add class based on item type for styling | |
| const itemClass = `item-${itemInfo.type || 'unknown'}`; | |
| inventoryHTML += `<span class="${itemClass}" title="${itemInfo.description}">${item}</span>`; | |
| }); | |
| } | |
| inventoryElement.innerHTML = inventoryHTML; | |
| } | |
| function updateScene(illustrationKey) { | |
| console.log("Updating scene for:", illustrationKey); | |
| // 1. Remove the old assembly if it exists | |
| if (currentAssemblyGroup) { | |
| scene.remove(currentAssemblyGroup); | |
| // Optional: Dispose of geometries and materials if scenes get complex | |
| // currentAssemblyGroup.traverse(child => { | |
| // if (child.isMesh) { | |
| // if(child.geometry) child.geometry.dispose(); | |
| // // Dispose materials carefully if they are shared! | |
| // // If not shared: if(child.material) child.material.dispose(); | |
| // } | |
| // }); | |
| } | |
| currentAssemblyGroup = null; // Reset the reference | |
| // 2. Select the generation function based on the key | |
| 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; | |
| // --- Add cases for new/missing scenes --- | |
| // case 'river-spirit': assemblyFunction = createRiverSpiritAssembly; break; // TODO | |
| // case 'ancient-ruins': assemblyFunction = createRuinsAssembly; break; // TODO | |
| // case 'fortress-plains': assemblyFunction = createFortressPlainsAssembly; break; // TODO | |
| default: | |
| console.warn(`No specific assembly function found for key: ${illustrationKey}. Using default.`); | |
| assemblyFunction = createDefaultAssembly; | |
| break; | |
| } | |
| // 3. Create the new assembly | |
| try { | |
| currentAssemblyGroup = assemblyFunction(); | |
| } catch (error) { | |
| console.error(`Error creating assembly for key ${illustrationKey}:`, error); | |
| currentAssemblyGroup = createErrorAssembly(); // Show error scene on generation failure | |
| } | |
| // 4. Add the new assembly to the scene | |
| if (currentAssemblyGroup) { | |
| scene.add(currentAssemblyGroup); | |
| // Optional: Slightly randomize overall rotation/position for non-fixed scenes like forests | |
| if (['shadowwood-forest', 'road-ambush', 'forest-edge'].includes(illustrationKey)) { | |
| currentAssemblyGroup.rotation.y = Math.random() * 0.1 - 0.05; // Small random Y rotation | |
| } | |
| } else { | |
| console.error(`Assembly function for ${illustrationKey} did not return a group.`); | |
| currentAssemblyGroup = createErrorAssembly(); // Fallback | |
| scene.add(currentAssemblyGroup); | |
| } | |
| } | |
| // --- Initialization --- | |
| initThreeJS(); | |
| startGame(); // Start the game after setting up Three.js | |
| // Removed global handleChoiceClick - now using event listeners in renderPage | |
| // window.handleChoiceClick = handleChoiceClick; |