awacke1's picture
Update index.html
8a79bb7 verified
raw
history blame
24.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Choose Your Own Procedural Adventure</title>
<style>
body {
font-family: 'Courier New', monospace;
background-color: #222;
color: #eee;
margin: 0;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100vh;
}
#game-container {
display: flex;
flex-grow: 1;
overflow: hidden;
}
#scene-container {
flex-grow: 3;
position: relative;
border-right: 2px solid #555;
min-width: 200px;
background-color: #1a1a1a;
height: 100%;
box-sizing: border-box;
}
#ui-container {
flex-grow: 2;
padding: 20px;
overflow-y: auto;
background-color: #333;
min-width: 280px;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
#scene-container canvas { display: block; }
#story-title {
color: #ffcc66;
margin-top: 0;
margin-bottom: 15px;
border-bottom: 1px solid #555;
padding-bottom: 10px;
font-size: 1.4em;
}
#story-content {
margin-bottom: 20px;
line-height: 1.6;
flex-grow: 1;
}
#story-content p { margin-bottom: 1em; }
#story-content p:last-child { margin-bottom: 0; }
#stats-inventory-container {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #555;
font-size: 0.9em;
}
#stats-display, #inventory-display {
margin-bottom: 10px;
line-height: 1.8;
}
#stats-display span, #inventory-display span {
display: inline-block;
background-color: #444;
padding: 3px 8px;
border-radius: 15px;
margin-right: 8px;
margin-bottom: 5px;
border: 1px solid #666;
white-space: nowrap;
}
#stats-display strong, #inventory-display strong { color: #aaa; margin-right: 5px; }
#inventory-display em { color: #888; font-style: normal; }
#inventory-display .item-quest { background-color: #666030; border-color: #999048;}
#inventory-display .item-weapon { background-color: #663030; border-color: #994848;}
#inventory-display .item-armor { background-color: #306630; border-color: #489948;}
#inventory-display .item-spell { background-color: #303066; border-color: #484899;}
#inventory-display .item-unknown { background-color: #555; border-color: #777;}
#choices-container {
margin-top: auto;
padding-top: 15px;
border-top: 1px solid #555;
}
#choices-container h3 { margin-top: 0; margin-bottom: 10px; color: #aaa; }
#choices { display: flex; flex-direction: column; gap: 10px; }
.choice-button {
display: block; width: 100%; padding: 10px 12px; margin-bottom: 0;
background-color: #555; color: #eee; border: 1px solid #777;
border-radius: 5px; cursor: pointer; text-align: left;
font-family: 'Courier New', monospace; font-size: 1em;
transition: background-color 0.2s, border-color 0.2s;
box-sizing: border-box;
}
.choice-button:hover:not(:disabled) { background-color: #d4a017; color: #222; border-color: #b8860b; }
.choice-button:disabled { background-color: #444; color: #888; cursor: not-allowed; border-color: #666; opacity: 0.7; }
.roll-success { color: #7f7; border-left: 3px solid #4a4; padding-left: 8px; margin-bottom: 1em; font-size: 0.9em; }
.roll-failure { color: #f77; border-left: 3px solid #a44; padding-left: 8px; margin-bottom: 1em; font-size: 0.9em; }
</style>
</head>
<body>
<div id="game-container">
<div id="scene-container"></div>
<div id="ui-container">
<h2 id="story-title">Loading Adventure...</h2>
<div id="story-content">
<p>Please wait while the adventure loads.</p>
</div>
<div id="stats-inventory-container">
<div id="stats-display"></div>
<div id="inventory-display"></div>
</div>
<div id="choices-container">
<h3>What will you do?</h3>
<div id="choices"></div>
</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
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');
let scene, camera, renderer;
let currentAssemblyGroup = null;
// 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 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 templeMaterial = new THREE.MeshStandardMaterial({ color: 0xA99B78, roughness: 0.7, metalness: 0.1 });
const errorMaterial = new THREE.MeshStandardMaterial({ color: 0xffa500, roughness: 0.5 });
const gameOverMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.5 });
const dirtMaterial = new THREE.MeshStandardMaterial({ color: 0x8B5E3C, roughness: 0.9 });
const grassMaterial = new THREE.MeshStandardMaterial({ color: 0x3CB371, roughness: 0.8 });
const oceanMaterial = new THREE.MeshStandardMaterial({ color: 0x1E90FF, roughness: 0.5, metalness: 0.2 });
const sandMaterial = new THREE.MeshStandardMaterial({ color: 0xF4A460, roughness: 0.9 });
const wetStoneMaterial = new THREE.MeshStandardMaterial({ color: 0x2F4F4F, roughness: 0.7 });
const glowMaterial = new THREE.MeshStandardMaterial({ color: 0x00FFAA, emissive: 0x00FFAA, emissiveIntensity: 0.5 });
function initThreeJS() {
if (!sceneContainer) { console.error("Scene container not found!"); return; }
scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);
const width = sceneContainer.clientWidth;
const height = sceneContainer.clientHeight;
camera = new THREE.PerspectiveCamera(75, (width / height) || 1, 0.1, 1000);
camera.position.set(0, 2.5, 7);
camera.lookAt(0, 0.5, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width || 400, height || 300);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
sceneContainer.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
window.addEventListener('resize', onWindowResize, false);
setTimeout(onWindowResize, 100);
animate();
}
function onWindowResize() {
if (!renderer || !camera || !sceneContainer) return;
const width = sceneContainer.clientWidth;
const height = sceneContainer.clientHeight;
if (width > 0 && height > 0) {
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
}
function animate() {
requestAnimationFrame(animate);
const time = performance.now() * 0.001;
scene.traverse(obj => {
if (obj.userData.update) obj.userData.update(time);
});
if (renderer && scene && camera) {
renderer.render(scene, camera);
}
}
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;
}
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;
}
// [Procedural generation functions here: createDefaultAssembly, createCityGatesAssembly, etc.]
// Game Data
const itemsData = {
"Flaming Sword":{type:"weapon", description:"A fiery blade"},
// … [other items]
};
const gameData = {
"1": { title: "The Crossroads", content: `<p>…</p>`, options: [ { text: "Enter the Shadowwood Forest (North)", next: 5 }, /* … */ ], illustration: "crossroads-signpost-sunny" },
// … [all other pages]
};
// Game State
let gameState = {
currentPageId: 1,
character: {
name: "Hero", race: "Human", alignment: "Neutral Good", class: "Adventurer",
level: 1, xp: 0, xpToNextLevel: 100,
stats: { strength: 8, intelligence: 10, wisdom: 10, dexterity: 10, constitution: 10, charisma: 8, hp: 12, maxHp: 12 },
inventory: []
}
};
// Game Logic Functions
function startGame() {
const defaultChar = {
name: "Hero", race: "Human", alignment: "Neutral Good", class: "Adventurer",
level: 1, xp: 0, xpToNextLevel: 100,
stats: { strength: 8, intelligence: 10, wisdom: 10, dexterity: 10, constitution: 10, charisma: 8, hp: 12, maxHp: 12 },
inventory: []
};
gameState = { currentPageId: 1, character: JSON.parse(JSON.stringify(defaultChar)) };
renderPage(gameState.currentPageId);
}
function handleChoiceClick(choiceData) {
// FIXED: use `choiceData.next` (which we actually pass) instead of undefined `choiceData.nextPage`
const optionNextPageId = Number(choiceData.next);
const itemToAdd = choiceData.addItem;
let nextPageId = optionNextPageId;
let rollResultMessage = "";
const check = choiceData.check;
if (isNaN(optionNextPageId) && !check && choiceData.next !== 1) {
console.error("Invalid choice data:", choiceData);
renderPageInternal(99, gameData[99] || { title: "Error", content: "<p>Invalid Choice Data!</p>", illustration: "error", gameOver: true }, "<p><em>Error: Invalid choice data encountered!</em></p>");
return;
}
if (choiceData.next === 1 && gameState.currentPageId === 99) {
startGame();
return;
}
if (check) {
const statValue = gameState.character.stats[check.stat] || 10;
const modifier = Math.floor((statValue - 10) / 2);
const roll = Math.floor(Math.random() * 20) + 1;
const totalResult = roll + modifier;
const dc = check.dc;
console.log(`Check: ${check.stat} (DC ${dc}) | Roll: ${roll} + Mod: ${modifier} = ${totalResult}`);
if (totalResult >= dc) {
nextPageId = optionNextPageId;
rollResultMessage = `<p class="roll-success"><em>${check.stat.charAt(0).toUpperCase() + check.stat.slice(1)} Check Success! (Rolled ${roll} + ${modifier} = ${totalResult} vs DC ${dc})</em></p>`;
} else {
nextPageId = parseInt(check.onFailure, 10);
rollResultMessage = `<p class="roll-failure"><em>${check.stat.charAt(0).toUpperCase() + check.stat.slice(1)} Check Failed! (Rolled ${roll} + ${modifier} = ${totalResult} vs DC ${dc})</em></p>`;
if (isNaN(nextPageId)) {
console.error("Invalid onFailure ID:", check.onFailure);
nextPageId = 99;
rollResultMessage += "<p><em>Error: Invalid failure path!</em></p>";
}
}
}
const targetPageData = gameData[nextPageId];
if (targetPageData) {
if (targetPageData.hpLoss) {
gameState.character.stats.hp -= targetPageData.hpLoss;
if (gameState.character.stats.hp <= 0) {
gameState.character.stats.hp = 0;
nextPageId = 99;
rollResultMessage += "<p><em>You have succumbed to your injuries!</em></p>";
}
}
if (targetPageData.reward) {
if (targetPageData.reward.xp) {
gameState.character.xp += targetPageData.reward.xp;
}
if (targetPageData.reward.statIncrease) {
const { stat, amount } = targetPageData.reward.statIncrease;
if (gameState.character.stats.hasOwnProperty(stat)) {
gameState.character.stats[stat] += amount;
if (stat === 'constitution') {
const conMod = Math.floor((gameState.character.stats.constitution - 10) / 2);
gameState.character.stats.maxHp = 10 + (conMod * gameState.character.level);
gameState.character.stats.hp = Math.min(gameState.character.stats.hp, gameState.character.stats.maxHp);
}
}
}
if (targetPageData.reward.addItem && !gameState.character.inventory.includes(targetPageData.reward.addItem)) {
gameState.character.inventory.push(targetPageData.reward.addItem);
}
}
if (itemToAdd && !gameState.character.inventory.includes(itemToAdd)) {
gameState.character.inventory.push(itemToAdd);
}
} else {
console.error(`Data for page ${nextPageId} not found!`);
renderPageInternal(99, gameData[99], "<p><em>Error: Next page data missing!</em></p>");
return;
}
gameState.currentPageId = nextPageId;
const conModifier = Math.floor((gameState.character.stats.constitution - 10) / 2);
gameState.character.stats.maxHp = 10 + conModifier;
gameState.character.stats.hp = Math.min(gameState.character.stats.hp, gameState.character.stats.maxHp);
renderPageInternal(nextPageId, gameData[nextPageId], rollResultMessage);
}
function renderPageInternal(pageId, pageData, message = "") {
if (!pageData) {
pageData = gameData[99];
message += "<p><em>Render Error: Page data missing!</em></p>";
pageId = 99;
}
storyTitleElement.textContent = pageData.title;
storyContentElement.innerHTML = message + pageData.content;
updateStatsDisplay();
updateInventoryDisplay();
choicesElement.innerHTML = '';
const options = pageData.options || [];
const isGameOverOrEnd = pageData.gameOver || options.length === 0;
if (!isGameOverOrEnd) {
options.forEach(option => {
const button = document.createElement('button');
button.classList.add('choice-button');
button.textContent = option.text;
let requirementMet = true;
let requirementText = "";
if (option.requireItem && !gameState.character.inventory.includes(option.requireItem)) {
requirementMet = false;
requirementText = `Requires: ${option.requireItem}`;
}
button.disabled = !requirementMet;
if (requirementMet) {
const choiceData = {
next: option.next,
addItem: option.addItem,
check: option.check
};
button.onclick = () => handleChoiceClick(choiceData);
} else {
button.title = requirementText;
button.classList.add('disabled');
}
choicesElement.appendChild(button);
});
} else {
const button = document.createElement('button');
button.classList.add('choice-button');
button.textContent = pageData.gameOver ? "Restart Adventure" : "The Path Ends Here (Restart?)";
button.onclick = () => handleChoiceClick({ next: 1 });
choicesElement.appendChild(button);
if (!pageData.gameOver) {
choicesElement.insertAdjacentHTML('afterbegin', '<p><i>There are no further paths from here.</i></p>');
}
}
updateScene(pageData.illustration || 'default');
}
function renderPage(pageId) { renderPageInternal(pageId, gameData[pageId]); }
function updateStatsDisplay() {
const c = gameState.character;
statsElement.innerHTML =
`<strong>Stats:</strong>
<span>Lvl: ${c.level}</span>
<span>XP: ${c.xp}/${c.xpToNextLevel}</span>
<span>HP: ${c.stats.hp}/${c.stats.maxHp}</span>
<span>Str: ${c.stats.strength}</span>
<span>Int: ${c.stats.intelligence}</span>
<span>Wis: ${c.stats.wisdom}</span>
<span>Dex: ${c.stats.dexterity}</span>
<span>Con: ${c.stats.constitution}</span>
<span>Cha: ${c.stats.charisma}</span>`;
}
function updateInventoryDisplay() {
let html = '<strong>Inventory:</strong> ';
if (gameState.character.inventory.length === 0) {
html += '<em>Empty</em>';
} else {
gameState.character.inventory.forEach(itemName => {
const item = itemsData[itemName] || { type: 'unknown', description: 'An unknown item.' };
const cls = `item-${item.type || 'unknown'}`;
html += `<span class="${cls}" title="${item.description}">${itemName}</span>`;
});
}
inventoryElement.innerHTML = html;
}
function updateScene(illustrationKey) {
if (!scene) return;
if (currentAssemblyGroup) scene.remove(currentAssemblyGroup);
scene.fog = null;
scene.background = new THREE.Color(0x222222);
camera.position.set(0, 2.5, 7);
camera.lookAt(0, 0.5, 0);
let assemblyFunction;
switch (illustrationKey) {
case 'crossroads-signpost-sunny':
scene.fog = new THREE.Fog(0x87CEEB, 10, 30);
scene.background = new THREE.Color(0x87CEEB);
camera.position.set(0, 3, 10); camera.lookAt(0, 1, 0);
assemblyFunction = createCrossroadsAssembly;
break;
// … [all other cases]
default:
assemblyFunction = createDefaultAssembly;
break;
}
try {
currentAssemblyGroup = assemblyFunction();
scene.add(currentAssemblyGroup);
adjustLighting(illustrationKey);
} catch (e) {
console.error(`Error building scene "${illustrationKey}":`, e);
currentAssemblyGroup = createErrorAssembly();
scene.add(currentAssemblyGroup);
adjustLighting('error');
}
onWindowResize();
}
function adjustLighting(illustrationKey) {
if (!scene) return;
const toRemove = scene.children.filter(c => c.isLight && !c.isAmbientLight);
toRemove.forEach(l => scene.remove(l));
const ambient = scene.children.find(c => c.isAmbientLight);
if (!ambient) scene.add(new THREE.AmbientLight(0xffffff, 0.5));
let intensity = 1.2, amb = 0.5, color = 0xffffff, pos = { x: 8, y: 15, z: 10 };
switch (illustrationKey) {
case 'crossroads-signpost-sunny':
amb = 0.8; intensity = 1.5; color = 0xFFF8E1; pos = { x: 10, y: 15, z: 10 };
break;
// … [lighting cases]
case 'error':
amb = 0.4; intensity = 1.0; color = 0xFFCC00; pos = { x: 0, y: 5, z: 5 };
break;
default:
break;
}
const ambLight = scene.children.find(c => c.isAmbientLight);
if (ambLight) ambLight.intensity = amb;
const dir = new THREE.DirectionalLight(color, intensity);
dir.position.set(pos.x, pos.y, pos.z);
dir.castShadow = true;
dir.shadow.mapSize.set(1024, 1024);
dir.shadow.camera.near = 0.5;
dir.shadow.camera.far = 50;
dir.shadow.camera.left = -15;
dir.shadow.camera.right = 15;
dir.shadow.camera.top = 15;
dir.shadow.camera.bottom = -15;
scene.add(dir);
}
document.addEventListener('DOMContentLoaded', () => {
try {
initThreeJS();
if (scene && camera && renderer) {
startGame();
} else {
throw new Error("Three.js failed to initialize.");
}
} catch (err) {
console.error("Initialization error:", err);
storyTitleElement.textContent = "Initialization Error";
storyContentElement.innerHTML = `<p>Unable to start the game. Check console for details.</p><pre>${err.stack}</pre>`;
choicesElement.innerHTML = '<p>Cannot proceed.</p>';
if (sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding:10px;">3D Scene Failed</p>';
}
});
</script>
</body>
</html>