awacke1's picture
Update game.js
44717a8 verified
raw
history blame
36.9 kB
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;