Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Wacky D&D Shapes Adventure! (Var Fix)</title> | |
<style> | |
/* --- Base Styles (Same as before) --- */ | |
body { font-family: 'Verdana', sans-serif; background-color: #2a3a4a; color: #f0f0f0; 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: 3px solid #506070; min-width: 250px; background-color: #101820; height: 100%; box-sizing: border-box; overflow: hidden; cursor: default; } | |
#ui-container { flex-grow: 2; padding: 25px; overflow-y: auto; background-color: #3a4a5a; min-width: 320px; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; } | |
#scene-container canvas { display: block; } | |
#story-title { color: #ffd700; margin: 0 0 15px 0; padding-bottom: 10px; border-bottom: 2px solid #506070; font-size: 1.8em; font-weight: bold; text-shadow: 1px 1px 2px #111; } | |
#story-content { margin-bottom: 25px; line-height: 1.7; flex-grow: 1; font-size: 1.1em; color: #e8e8e8;} | |
#stats-inventory-container { margin-bottom: 25px; padding: 15px; border: 1px solid #506070; border-radius: 5px; background-color: #4a5a6a; font-size: 0.95em; } | |
#stats-display, #inventory-display { margin-bottom: 10px; line-height: 1.8; } | |
#stats-display span, #inventory-display .item-tag { display: inline-block; background-color: #5a6a7a; padding: 4px 10px; border-radius: 15px; margin: 0 8px 5px 0; border: 1px solid #7a8a9a; white-space: nowrap; box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); } | |
#stats-display strong, #inventory-display strong { color: #e0e0e0; margin-right: 6px; font-weight:bold; } | |
#inventory-display em { color: #aabbcc; font-style: normal; } | |
.item-tool { background-color: #a0522d; border-color: #cd853f; color: #fff;} | |
.item-key { background-color: #ffd700; border-color: #f0e68c; color: #333;} | |
.item-treasure { background-color: #20b2aa; border-color: #48d1cc; color: #fff;} | |
.item-food { background-color: #ff6347; border-color: #fa8072; color: #fff;} | |
.item-quest { background-color: #da70d6; border-color: #ee82ee; color: #fff;} | |
.item-unknown { background-color: #778899; border-color: #b0c4de;} | |
#choices-container { margin-top: auto; padding-top: 20px; border-top: 2px solid #506070; } | |
#choices-container h3 { margin-top: 0; margin-bottom: 12px; color: #e0e0e0; font-size: 1.2em; font-weight: bold;} | |
#choices { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 15px;} | |
#action-choices { display: flex; flex-direction: column; gap: 12px; margin-top: 15px; border-top: 1px dashed #607080; padding-top: 15px;} | |
.choice-button { display: block; width: 100%; padding: 13px 16px; margin-bottom: 0; background-color: #607080; color: #fff; border: 1px solid #8090a0; border-radius: 5px; cursor: pointer; text-align: center; font-family: 'Verdana', sans-serif; font-size: 1.0em; font-weight: bold; transition: background-color 0.2s, transform 0.1s; box-sizing: border-box; letter-spacing: 0.5px; } | |
.choice-button.action { text-align: left; grid-column: span 2;} | |
.choice-button:hover:not(:disabled) { background-color: #ffc107; color: #222; border-color: #ffca2c; transform: translateY(-1px); } | |
.choice-button:disabled { background-color: #4e5a66; color: #8a9aab; cursor: not-allowed; border-color: #607080; opacity: 0.6; transform: none; box-shadow: none;} | |
.choice-button[title]:disabled::after { content: ' (' attr(title) ')'; font-style: italic; font-size: 0.9em; margin-left: 5px; } | |
.message { padding: 8px 12px; margin: 10px 0; border-left-width: 4px; border-left-style: solid; font-size: 1em; background-color: rgba(0, 0, 0, 0.1); border-radius: 3px; } | |
.message-info { color: #ccc; border-left-color: #666; } | |
.message-item { color: #9cf; border-left-color: #48a; } | |
.message-xp { color: #af8; border-left-color: #6a4; } | |
.message-warning { color: #f99; border-left-color: #c66; } | |
.message-success { color: #8f8; border-left-color: #4a4; } | |
.message-failure { color: #f88; border-left-color: #a44; } | |
.message-combat { color: #f98; border-left-color: #c64; font-weight: bold;} | |
.combat-button { background-color: #a33; border-color: #c66; color: #fff; font-weight: bold; text-align: center;} | |
.combat-button:hover:not(:disabled) { background-color: #d44; border-color: #f88;} | |
#action-info { position: absolute; bottom: 10px; left: 10px; background-color: rgba(0,0,0,0.7); color: #ffcc66; padding: 5px 10px; border-radius: 3px; font-size: 0.9em; display: block; z-index: 10;} | |
</style> | |
</head> | |
<body> | |
<div id="game-container"> | |
<div id="scene-container"> | |
<div id="action-info">Initializing...</div> | |
</div> | |
<div id="ui-container"> | |
<h2 id="story-title">Initializing...</h2> | |
<div id="story-content"><p>Loading assets...</p></div> | |
<div id="stats-inventory-container"> | |
<div id="stats-display">Loading Stats...</div> | |
<div id="inventory-display">Inventory: ...</div> | |
</div> | |
<div id="choices-container"> | |
<h3>Navigation</h3> | |
<div id="choices">Loading...</div> | |
<div id="action-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'; | |
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Re-added controls | |
import { FontLoader } from 'three/addons/loaders/FontLoader.js'; | |
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js'; | |
console.log("Script module execution started."); | |
// --- 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 actionChoicesElement = document.getElementById('action-choices'); | |
const statsElement = document.getElementById('stats-display'); | |
const inventoryElement = document.getElementById('inventory-display'); | |
const actionInfoElement = document.getElementById('action-info'); | |
console.log("DOM elements obtained."); | |
// --- Core Three.js Variables --- | |
let scene, camera, renderer, clock, controls, raycaster, mouse; // Added controls, raycaster, mouse back | |
let currentSceneGroup = null; | |
let currentLights = []; | |
let threeFont = null; | |
let currentMessage = ""; | |
let activeTimeouts = []; // Clear timeouts on scene change | |
// --- Materials --- | |
const MAT = { | |
stone_grey: new THREE.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 0.8 }), | |
stone_brown: new THREE.MeshStandardMaterial({ color: 0x9d8468, roughness: 0.85 }), | |
wood_light: new THREE.MeshStandardMaterial({ color: 0xcdaa7d, roughness: 0.7 }), | |
wood_dark: new THREE.MeshStandardMaterial({ color: 0x6f4e2d, roughness: 0.75 }), | |
leaf_green: new THREE.MeshStandardMaterial({ color: 0x4caf50, roughness: 0.6, side: THREE.DoubleSide }), | |
leaf_autumn: new THREE.MeshStandardMaterial({ color: 0xffa040, roughness: 0.65, side: THREE.DoubleSide }), | |
ground_dirt: new THREE.MeshStandardMaterial({ color: 0x8b5e3c, roughness: 0.9 }), | |
ground_grass: new THREE.MeshStandardMaterial({ color: 0x558b2f, roughness: 0.85 }), | |
metal_shiny: new THREE.MeshStandardMaterial({ color: 0xc0c0c0, roughness: 0.2, metalness: 0.8 }), | |
metal_rusty: new THREE.MeshStandardMaterial({ color: 0xb7410e, roughness: 0.9, metalness: 0.2 }), | |
crystal_blue: new THREE.MeshStandardMaterial({ color: 0x87cefa, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.8, emissive:0x205080, emissiveIntensity: 0.4 }), | |
crystal_red: new THREE.MeshStandardMaterial({ color: 0xff7f7f, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.8, emissive:0x802020, emissiveIntensity: 0.4 }), | |
bright_yellow: new THREE.MeshStandardMaterial({ color: 0xffeb3b, roughness: 0.6 }), | |
deep_purple: new THREE.MeshStandardMaterial({ color: 0x9c27b0, roughness: 0.7 }), | |
sky_blue: new THREE.MeshStandardMaterial({ color: 0x03a9f4, roughness: 0.5 }), | |
town_wood: new THREE.MeshStandardMaterial({ color: 0xae8a63, roughness: 0.7 }), | |
town_roof: new THREE.MeshStandardMaterial({ color: 0x8b4513, roughness: 0.8 }), | |
goblin_skin: new THREE.MeshStandardMaterial({ color: 0x8FBC8F, roughness: 0.8 }), | |
text_material: new THREE.MeshBasicMaterial({ color: 0xffddaa, transparent: true }), // Make text transparent for fade | |
}; | |
// --- Game State --- | |
let gameState = {}; | |
// --- Item Data --- | |
const itemsData = { | |
"Sturdy Stick": {type:"tool", description:"Surprisingly pointy."}, | |
"Wobbly Key": {type:"key", description:"Might fit a wobbly lock?"}, | |
"Goblin's Favorite Sock": {type:"quest", description:"Smells... unique."}, | |
"Shiny Rock": {type:"treasure", description:"Distractingly shiny."}, | |
"Suspicious Mushroom": {type:"food", description:"Maybe... just maybe..."}, | |
"Cave Crystal": {type:"unknown", description:"A faintly glowing crystal shard."} | |
}; | |
// --- Enemy Data --- | |
const enemyData = { | |
'goblin': { name: "Grumpy Goblin", hp: 12, defense: 12, attackBonus: 1, damageDice: 4, xp: 25, drops: ["Goblin's Favorite Sock", "Shiny Rock"] }, | |
'skeleton': { name: "Clattering Skeleton", hp: 10, defense: 13, attackBonus: 2, damageDice: 4, xp: 20, drops: ["Ancient Coin"] }, // Added skeleton | |
'spider': { name: "Hairy Spider", hp: 15, defense: 11, attackBonus: 2, damageDice: 3, xp: 25, drops: ["Cave Crystal"] } // Adjusted spider | |
}; | |
// --- Procedural Shapes --- | |
const SHAPE_GENERATORS = { | |
'pointy_cone': (size) => new THREE.ConeGeometry(size * 0.5, size * 1.2, 6 + Math.floor(Math.random() * 5)), | |
'round_blob': (size) => new THREE.SphereGeometry(size * 0.6, 12, 8), | |
'spinny_torus': (size) => new THREE.TorusGeometry(size * 0.5, size * 0.15, 8, 16), | |
'boxy_chunk': (size) => new THREE.BoxGeometry(size, size * (0.8 + Math.random() * 0.4), size * (0.8 + Math.random() * 0.4)), | |
'spiky_ball': (size) => new THREE.IcosahedronGeometry(size * 0.7, 0), | |
'flat_plate': (size) => new THREE.BoxGeometry(size * 1.5, size * 0.1, size * 1.5), | |
'tall_cylinder': (size) => new THREE.CylinderGeometry(size * 0.3, size * 0.3, size * 2.0, 8), | |
'knotty_thing': (size) => new THREE.TorusKnotGeometry(size * 0.4, size * 0.1, 50, 8), | |
'gem_shape': (size) => new THREE.OctahedronGeometry(size * 0.6, 0), | |
'basic_tetra': (size) => new THREE.TetrahedronGeometry(size * 0.7, 0), | |
'squashed_ball': (size) => new THREE.SphereGeometry(size * 0.6, 12, 8).scale(1, 0.6, 1), | |
'holed_box': (size) => { | |
const shape = new THREE.Shape(); | |
shape.moveTo(-size/2, -size/2); shape.lineTo(-size/2, size/2); shape.lineTo(size/2, size/2); shape.lineTo(size/2, -size/2); shape.closePath(); | |
const hole = new THREE.Path(); | |
hole.absellipse(0, 0, size*0.2, size*0.2, 0, Math.PI*2, false); | |
shape.holes.push(hole); | |
return new THREE.ExtrudeGeometry(shape, {depth: size*0.3, bevelEnabled: false}); | |
} | |
}; | |
const shapeKeys = Object.keys(SHAPE_GENERATORS); | |
// --- Game Data (More Pages) --- | |
const gameData = { | |
1: { // Snoring Meadows (Start) | |
title: "Snoring Meadows", | |
content: "<p>You wake with a start! Not from a nightmare, but because the grass around you is... snoring softly? Weird. A path leads North into the Wiggly Woods and East towards some noisy-looking Clanky Caves.</p>", | |
options: [ | |
{ text: "Venture into Wiggly Woods", next: 2 }, | |
{ text: "Investigate Clanky Caves", next: 5 }, | |
{ text: "Poke the snoring grass? (WIS Check DC 10)", check: { stat: 'wisdom', dc: 10, next: 10, onFailure: 11 } } | |
], | |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' } | |
}, | |
2: { // Wiggly Woods Entrance | |
title: "Wiggly Woods", | |
content: "<p>Whoa! These trees are doing the wiggle! It's quite groovy, but makes walking tricky. A particularly grumpy-looking Goblin blocks the path deeper (North).</p>", | |
options: [ | |
{ text: "Politely ask the Goblin to move", next: 3 }, | |
{ text: "Try to wiggle past (DEX Check DC 12)", check: { stat: 'dexterity', dc: 12, next: 4, onFailure: 3 } }, // Fail leads to talk | |
{ text: "Wiggle back to the Meadows (South)", next: 1 } | |
], | |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple], arrangement: 'cluster', clusterRadius: 10 } | |
}, | |
3: { // Confront Goblin | |
title: "Grumpy Goblin Guard", | |
content: "<p>'Oi! Wiggler!' the Goblin shouts, adjusting his single, smelly sock. 'Dis MY patch o' wiggles! Whatcha want?' He doesn't look like he enjoys conversation. Or baths.</p>", | |
options: [ | |
{ text: "'Just passing through!' (CHA Check DC 14)", check: { stat: 'charisma', dc: 14, next: 4, onFailure: 12 } }, | |
{ text: "Offer 'Shiny Rock' as tribute", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 15} }, | |
{ text: "Prepare for a Wobbly Tussle!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 }, | |
{ text: "'Nevermind!' (Retreat)", next: 2 } | |
], | |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn', 'basic_tetra'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple, MAT.goblin_skin], arrangement: 'cluster', clusterRadius: 10 } | |
}, | |
4: { // Past Goblin | |
title: "Further Into the Woods", | |
content: "<p>You made it past the grumpy guardian! The woods get thicker here, but you see a strange, perfectly Square Clearing to the North.</p>", | |
options: [ | |
{ text: "Investigate the Square Clearing (North)", next: 7 }, | |
{ text: "Go Back Past Goblin Spot (South)", next: 2 } // Assume goblin is gone/pacified | |
], | |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'round_blob'], accents: ['leaf_green'], count: 25, scaleRange: [1.0, 2.8], colorTheme: [MAT.wood_light, MAT.leaf_green, MAT.stone_brown], arrangement: 'scatter' } | |
}, | |
5: { // Cave Entrance | |
title: "Clanky Caves Entrance", | |
content: "<p>CLANK! WHOOSH! A rusty metal sign hangs crookedly: 'Clanky Caves - Enter At Yer Own Risk & Volume Level'. It smells faintly of oil and... ozone?</p>", | |
options: [ { text: "Bravely Enter!", next: 8 }, { text: "Nope Out (West)", next: 1 } ], | |
assemblyParams: { baseShape: 'stone_grey', baseSize: 20, mainShapes: ['spiky_ball', 'spinny_torus', 'holed_box'], accents: ['metal_rusty'], count: 25, scaleRange: [0.5, 1.8], colorTheme: [MAT.stone_grey, MAT.metal_rusty, MAT.metal_shiny], arrangement: 'cluster', clusterRadius: 8 } | |
}, | |
6: { // Placeholder - Reached from old page 3, needs rewrite | |
title: "Gem Patch Revisited", | |
content: "<p>You return to the shiny spot. The remaining gems twinkle invitingly.</p>", | |
options: [ { text: "Go Back to Meadow Path", next: 1 } ], // Simple exit | |
assemblyParams: { baseShape: 'ground_grass', baseSize: 15, mainShapes: ['gem_shape', 'basic_tetra'], accents: ['pointy_cone'], count: 30, scaleRange: [0.2, 0.7], colorTheme: [MAT.crystal_blue, MAT.crystal_red, MAT.stone_grey], arrangement: 'patch', patchPos: {x:0, y:0.1, z:0}, patchRadius: 5 } | |
}, | |
7: { // Square Clearing | |
title: "The Square Clearing", | |
content: "<p>Wow, they weren't kidding. Everything is suspiciously square - the rocks, the flowers... In the center sits a locked chest, also square, with a distinctly wobbly keyhole.</p>", | |
options: [ | |
{ text: "Try Wobbly Key", requires: "Wobbly Key", consume: true, next: 13 }, | |
{ text: "Try Sturdy Stick (Strength Check DC 13 to pry)", requires: "Sturdy Stick", check: { stat: 'strength', dc: 13, next: 14, onFailure: 14 } }, // Fail still goes to 14 but maybe adds message | |
{ text: "Leave the Creepy Clearing (South)", next: 4 } | |
], | |
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: ['basic_tetra'], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass, MAT.stone_grey], arrangement: 'patch', patchRadius: 8 } | |
}, | |
8: { // Inside Clanky Caves | |
title: "Clanky Caves - Junction", | |
content: "<p>Clank! Whirr! Sproing! It's a cacophony! Gears spin wildly, pistons pump uselessly. Passages lead West (Clankier?) and East (Quieter?).</p>", | |
options: [ { text: "Go West (More Clanks!)", next: 9 }, { text: "Go East (Less Clanky?)", next: 5 } ], | |
assemblyParams: { baseShape: 'stone_grey', baseSize: 25, mainShapes: ['spinny_torus', 'holed_box', 'tall_cylinder'], accents: ['metal_shiny', 'basic_tetra'], count: 30, scaleRange: [0.6, 2.0], colorTheme: [MAT.stone_grey, MAT.metal_shiny, MAT.metal_rusty, MAT.bright_yellow], arrangement: 'cluster', clusterRadius: 10 } | |
}, | |
9: { // Deeper Cave / Sprocket Stash | |
title: "Sprocket Stash", | |
content: "<p>Jackpot! A huge pile of Shiny Sprockets! They spin hypnotically. Also, some weird purple mushrooms pulse nearby.</p>", | |
options: [ { text: "Grab a Sprocket!", reward: { addItem: "Shiny Sprocket", xp: 10 }, next: 9 }, { text: "Lick a Mushroom? (CON Check DC 11)", check: {stat:'constitution', dc: 11, next: 15, onFailure: 16 } }, { text: "Go Back East", next: 8 } ], | |
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape', 'pointy_cone'], accents: ['spinny_torus', 'metal_shiny'], count: 25, scaleRange: [0.4, 1.0], colorTheme: [MAT.metal_shiny, MAT.bright_yellow, MAT.deep_purple], arrangement: 'patch', patchRadius: 4 } | |
}, | |
10: { // Success poking grass | |
title: "Grass Tickled!", | |
content: "<p>Hehe! The blade of grass giggles (yes, really!) and spits out a very Wobbly Key!</p>", | |
options: [ { text: "Nab the Key!", reward: { addItem: "Wobbly Key", xp: 5 }, next: 1 } ], | |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' } | |
}, | |
11: { // Fail poking grass | |
title: "Grass Annoyed!", | |
content: "<p>The snoring grass grumbles and rolls over, hiding whatever might have been there. Maybe try again later?</p>", | |
options: [ { text: "Okay, okay, sorry!", next: 1 } ], | |
assemblyParams: { baseShape: 'ground_grass', baseSize: 30, mainShapes: ['squashed_ball', 'pointy_cone'], accents: [], count: 30, scaleRange: [0.5, 1.2], colorTheme: [MAT.grass, MAT.leaf_green, MAT.sky_blue], arrangement: 'scatter' } | |
}, | |
12: { // Fail charisma check on goblin | |
title: "Goblin Scowls", | |
content: "<p>'Bah! Words are cheap!' The goblin tightens his grip on the spear. 'Try again, maybe with shiny stuff?'</p>", | |
options: [ | |
{ text: "Attack!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 }, | |
{ text: "Offer 'Shiny Rock'", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 10} }, | |
{ text: "Flee!", next: 2 } | |
], | |
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn', 'basic_tetra'], count: 20, scaleRange: [1.0, 3.0], colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple, MAT.goblin_skin], arrangement: 'cluster', clusterRadius: 10 } | |
}, | |
13: { // Open chest with key | |
title: "Wobbly Chest Opens!", | |
content: "<p>Wiggle, jiggle... click! The key works! Inside is... a single, slightly Suspicious Mushroom. Huh.</p>", | |
options: [ { text: "Take the Mushroom", reward: { addItem: "Suspicious Mushroom", xp: 15 }, next: 7 } ], | |
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: [], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass, MAT.stone_grey], arrangement: 'patch', patchRadius: 8 } | |
}, | |
14: { // Whack chest / Fail Str check | |
title: "Thwack! Ow.", | |
content: "<p>You whack the chest. It remains stubbornly square and closed. Your stick might have a splinter. The lock still wobbles mockingly.</p>", | |
options: [ { text: "Try Wobbly Key", requires: "Wobbly Key", consume: true, next: 13 }, { text: "Leave it", next: 7 } ], | |
assemblyParams: { baseShape: 'ground_grass', baseSize: 20, mainShapes: ['boxy_chunk', 'holed_box'], accents: [], count: 15, scaleRange: [0.8, 1.5], colorTheme: [MAT.stone_brown, MAT.grass, MAT.stone_grey], arrangement: 'patch', patchRadius: 8 } | |
}, | |
15: { // Success licking Mushroom | |
title: "Tingly!", | |
content: "<p>You lick the mushroom. It tastes like purple and static! You feel... stronger! (+1 Strength!)</p>", | |
options: [ { text: "Whoa! Go Back.", next: 8 } ], | |
reward: { statGain: { strength: 1 }, xp: 5 }, // Example stat gain | |
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape'], accents: ['spinny_torus', 'metal_shiny'], count: 20, scaleRange: [0.4, 1.0], colorTheme: [MAT.metal_shiny, MAT.bright_yellow, MAT.deep_purple], arrangement: 'patch', patchRadius: 4 } | |
}, | |
16: { // Fail licking Mushroom | |
title: "Blech!", | |
content: "<p>You lick the mushroom. It tastes like old socks and regret. Your tongue feels fuzzy. (-1 Charisma temporarily? TBC)</p>", | |
options: [ { text: "Ugh! Go Back.", next: 8 } ], | |
// Add temporary effect later if needed | |
assemblyParams: { baseShape: 'stone_grey', baseSize: 15, mainShapes: ['gem_shape'], accents: ['spinny_torus', 'metal_shiny'], count: 20, scaleRange: [0.4, 1.0], colorTheme: [MAT.metal_shiny, MAT.bright_yellow, MAT.deep_purple], arrangement: 'patch', patchRadius: 4 } | |
}, | |
98: { // Lose combat | |
title: "Bonked!", | |
content: "<p>Ouch! That hurt. You wake up back in the Snoring Meadows, feeling rather silly.</p>", | |
options: [ { text: "Try again?", next: 1 } ], | |
assemblyParams: { baseShape: 'ground_grass', count: 5, mainShapes:['round_blob'], colorTheme:[MAT.grass]} | |
}, | |
99: { // Generic End/TBC | |
title: "To Be Continued... Maybe!", | |
content: "<p>That's the end of this particular wacky path. Was there a point? Who knows! Adventure!</p>", | |
options: [ { text: "Start Over?", next: 1 } ], | |
assemblyParams: { baseShape: 'flat_plate', baseSize: 10, mainShapes: ['basic_tetra'], count: 5, scaleRange: [1, 2], colorTheme: [MAT.sky_blue, MAT.bright_yellow], arrangement: 'center_stack', stackHeight: 3 } | |
} | |
}; | |
// --- Core Functions --- | |
function initThreeJS() { | |
console.log("initThreeJS started."); | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x2a3a4a); | |
clock = new THREE.Clock(); | |
raycaster = new THREE.Raycaster(); | |
mouse = new THREE.Vector2(); | |
const width = sceneContainer.clientWidth || 1; | |
const height = sceneContainer.clientHeight || 1; | |
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000); | |
camera.position.set(0, 8, 15); | |
camera.lookAt(0, 1, 0); | |
renderer = new THREE.WebGLRenderer({ antialias: true }); | |
renderer.setSize(width, height); | |
renderer.shadowMap.enabled = true; | |
renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
sceneContainer.appendChild(renderer.domElement); | |
// Controls removed for page-based navigation focus | |
// controls = new OrbitControls(camera, renderer.domElement); | |
window.addEventListener('resize', onWindowResize, false); | |
renderer.domElement.addEventListener('click', onMouseClick, false); | |
setTimeout(onWindowResize, 100); | |
animate(); | |
console.log("initThreeJS finished."); | |
} | |
function loadFontAndStart() { | |
console.log("Loading font..."); | |
const loader = new FontLoader(); | |
// Ensure the path to the font is correct or use a reliable CDN | |
loader.load('https://unpkg.com/[email protected]/examples/fonts/helvetiker_regular.typeface.json', function (font) { | |
threeFont = font; | |
console.log("Font loaded."); | |
startGame(); | |
}, undefined, function (error) { | |
console.error('Font loading failed:', error); | |
threeFont = null; | |
startGame(); | |
}); | |
} | |
function onWindowResize() { | |
if (!renderer || !camera || !sceneContainer) return; | |
const width = sceneContainer.clientWidth || 1; | |
const height = sceneContainer.clientHeight || 1; | |
if (width > 0 && height > 0) { | |
camera.aspect = width / height; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(width, height); | |
} | |
} | |
function onMouseClick( event ) { | |
const rect = renderer.domElement.getBoundingClientRect(); | |
mouse.x = ( (event.clientX - rect.left) / rect.width ) * 2 - 1; | |
mouse.y = - ( (event.clientY - rect.top) / rect.height ) * 2 + 1; | |
pickupItem(); // Only use click for pickup now | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
const delta = clock.getDelta(); | |
const time = clock.getElapsedTime(); | |
// controls?.update(); // Controls removed | |
if (currentSceneGroup) { | |
currentSceneGroup.traverse(obj => { | |
if (obj.userData.update) obj.userData.update(time, delta); | |
}); | |
} | |
if (renderer && scene && camera) renderer.render(scene, camera); | |
} | |
function createMesh(geometry, material, pos = {x:0,y:0,z:0}, rot = {x:0,y:0,z:0}, scale = {x:1,y:1,z:1}) { | |
const mesh = new THREE.Mesh(geometry, material); | |
mesh.position.set(pos.x, pos.y, pos.z); | |
mesh.rotation.set(rot.x, rot.y, rot.z); | |
mesh.scale.set(scale.x, scale.y, scale.z); | |
mesh.castShadow = true; mesh.receiveShadow = true; | |
return mesh; | |
} | |
function setupLighting(type = 'default') { // Simplified lighting | |
currentLights.forEach(light => { if (light.parent) light.parent.remove(light); if(scene.children.includes(light)) scene.remove(light); }); | |
currentLights = []; | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); | |
scene.add(ambientLight); | |
currentLights.push(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); | |
directionalLight.position.set(8, 15, 10); | |
directionalLight.castShadow = true; | |
directionalLight.shadow.mapSize.set(1024, 1024); | |
directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 50; | |
const sb = 25; | |
directionalLight.shadow.camera.left = -sb; directionalLight.shadow.camera.right = sb; | |
directionalLight.shadow.camera.top = sb; directionalLight.shadow.camera.bottom = -sb; | |
directionalLight.shadow.bias = -0.001; // Adjusted bias slightly | |
scene.add(directionalLight); | |
currentLights.push(directionalLight); | |
} | |
function createProceduralAssembly(params) { | |
console.log("Creating procedural assembly with params:", params); | |
const group = new THREE.Group(); | |
const { | |
baseShape = 'ground_grass', baseSize = 20, | |
mainShapes = ['boxy_chunk'], accents = ['pointy_cone'], | |
count = 10, scaleRange = [0.5, 1.5], | |
colorTheme = [MAT.stone_grey, MAT.wood_light, MAT.leaf_green], | |
arrangement = 'scatter', | |
stackHeight = 5, | |
clusterRadius = 5, | |
patchPos = {x:0, y:0, z:0}, patchRadius = 3 | |
} = params; | |
let baseMesh; | |
if (baseShape.startsWith('ground_')) { | |
const groundMat = MAT[baseShape] || MAT.ground_grass; | |
const groundGeo = new THREE.PlaneGeometry(baseSize, baseSize); | |
baseMesh = new THREE.Mesh(groundGeo, groundMat); | |
baseMesh.rotation.x = -Math.PI / 2; baseMesh.position.y = 0; | |
baseMesh.receiveShadow = true; baseMesh.castShadow = false; | |
baseMesh.userData.isGround = true; | |
group.add(baseMesh); | |
} else { | |
const baseGeoFunc = SHAPE_GENERATORS[baseShape] || SHAPE_GENERATORS['flat_plate']; | |
const baseGeo = baseGeoFunc(baseSize); | |
baseMesh = createMesh(baseGeo, colorTheme[0] || MAT.stone_grey, {y:0.1}); | |
baseMesh.receiveShadow = true; baseMesh.castShadow = false; | |
baseMesh.userData.isGround = true; | |
group.add(baseMesh); | |
} | |
const allShapes = [...mainShapes, ...accents]; | |
let lastY = 0.1; | |
let stackCount = 0; | |
for (let i = 0; i < count; i++) { | |
if(allShapes.length === 0) break; | |
const shapeKey = allShapes[Math.floor(Math.random() * allShapes.length)]; | |
const geoFunc = SHAPE_GENERATORS[shapeKey]; | |
if (!geoFunc) { console.warn(`Shape generator missing: ${shapeKey}`); continue; } | |
const scaleFactor = scaleRange[0] + Math.random() * (scaleRange[1] - scaleRange[0]); | |
const geometry = geoFunc(scaleFactor); | |
if (!geometry) { console.warn(`Geometry creation failed for ${shapeKey}`); continue; } // Check geometry creation | |
const material = colorTheme[Math.floor(Math.random() * colorTheme.length)]; | |
const mesh = createMesh(geometry, material); | |
try { | |
geometry.computeBoundingBox(); | |
} catch(e) { console.error("Error computing bounding box", e, geometry); continue; } | |
let height = 0; | |
if (geometry.boundingBox) { | |
height = (geometry.boundingBox.max.y - geometry.boundingBox.min.y) * mesh.scale.y; | |
} else { | |
height = scaleFactor; | |
} | |
height = Math.max(0.1, height); | |
let position = { x:0, y:0, z:0 }; | |
let isValidPosition = false; | |
switch(arrangement) { | |
case 'stack': | |
if (stackCount < stackHeight) { | |
position.y = lastY + height / 2; | |
position.x = (Math.random() - 0.5) * 0.5 * scaleFactor; | |
position.z = (Math.random() - 0.5) * 0.5 * scaleFactor; | |
lastY += height * 0.9; | |
stackCount++; isValidPosition = true; | |
} break; | |
case 'center_stack': | |
if (stackCount < stackHeight) { | |
position.y = lastY + height / 2; | |
lastY += height * 0.95; | |
stackCount++; isValidPosition = true; | |
} break; | |
case 'cluster': | |
const angle = Math.random() * Math.PI * 2; | |
const radius = Math.random() * clusterRadius; | |
position.x = Math.cos(angle) * radius; | |
position.z = Math.sin(angle) * radius; | |
position.y = height / 2; isValidPosition = true; break; | |
case 'patch': | |
const pAngle = Math.random() * Math.PI * 2; | |
const pRadius = Math.random() * patchRadius; | |
position.x = patchPos.x + Math.cos(pAngle) * pRadius; | |
position.z = patchPos.z + Math.sin(pAngle) * pRadius; | |
position.y = (patchPos.y || 0) + height / 2; isValidPosition = true; break; | |
case 'scatter': default: | |
position.x = (Math.random() - 0.5) * baseSize * 0.8; | |
position.z = (Math.random() - 0.5) * baseSize * 0.8; | |
position.y = height / 2; isValidPosition = true; break; | |
} | |
if (!isValidPosition) continue; | |
mesh.position.set(position.x, position.y, position.z); | |
mesh.rotation.set( | |
Math.random() * (arrangement.includes('stack') ? 0.2 : Math.PI), | |
Math.random() * Math.PI * 2, | |
Math.random() * (arrangement.includes('stack') ? 0.2 : Math.PI) | |
); | |
if (Math.random() < 0.3) { | |
mesh.userData.rotSpeed = (Math.random() - 0.5) * 0.4; | |
mesh.userData.bobSpeed = 1 + Math.random(); | |
mesh.userData.bobAmount = 0.05 + Math.random() * 0.05; | |
mesh.userData.startY = mesh.position.y; | |
mesh.userData.update = (time, delta) => { | |
mesh.rotation.y += mesh.userData.rotSpeed * delta; | |
mesh.position.y = mesh.userData.startY + Math.sin(time * mesh.userData.bobSpeed) * mesh.userData.bobAmount; | |
}; | |
} | |
group.add(mesh); | |
} | |
console.log(`Assembly created with ${group.children.length -1} procedural objects.`); | |
return group; | |
} | |
function updateScene(assemblyParams) { | |
console.log("updateScene called"); | |
activeTimeouts.forEach(id => clearTimeout(id)); // Clear pending timeouts | |
activeTimeouts = []; | |
if (currentSceneGroup) { | |
scene.remove(currentSceneGroup); // Remove group cleanly | |
// Dispose geometries in the removed group | |
currentSceneGroup.traverse(child => { | |
if (child.isMesh && child.geometry) { | |
child.geometry.dispose(); | |
} | |
}); | |
currentSceneGroup = null; | |
} | |
setupLighting('default'); | |
if (!assemblyParams) { | |
console.warn("No assemblyParams provided, creating default scene."); | |
assemblyParams = { baseShape: 'ground_dirt', count: 5, mainShapes: ['boxy_chunk'] }; | |
} | |
try { | |
currentSceneGroup = createProceduralAssembly(assemblyParams); | |
scene.add(currentSceneGroup); | |
console.log("New scene group added."); | |
} catch (error) { | |
console.error("Error creating procedural assembly:", error); | |
} | |
} | |
function startGame() { | |
console.log("startGame called."); | |
const defaultChar = { | |
name: "Player", | |
stats: { hp: 25, maxHp: 25, xp: 0, strength: 12, dexterity: 11, constitution: 13, intelligence: 9, wisdom: 10, charisma: 8 }, | |
inventory: ["Sturdy Stick"] | |
}; | |
gameState = { | |
currentPageId: 1, | |
character: JSON.parse(JSON.stringify(defaultChar)), | |
combat: null | |
}; | |
renderPage(gameState.currentPageId); | |
console.log("startGame finished."); | |
} | |
function handleChoiceClick(option) { // Renamed parameter for clarity | |
console.log("Choice clicked:", option); | |
currentMessage = ""; | |
// Check requirements first | |
if (option.requires && !gameState.character.inventory.includes(option.requires)) { | |
currentMessage = `<p class="message message-warning">You need a ${option.requires} for that!</p>`; | |
renderPage(gameState.currentPageId); // Re-render current page with message | |
return; | |
} | |
// Consume item if required and successful (or if action doesn't fail) | |
let consumedItem = false; | |
if (option.requires && option.consume) { | |
gameState.character.inventory = gameState.character.inventory.filter(i => i !== option.requires); | |
currentMessage += `<p class="message message-item">Used your ${option.requires}.</p>`; | |
consumedItem = true; // Mark as consumed | |
} | |
// Handle different action types | |
if (option.action === 'triggerCombat' && option.enemy) { | |
startCombat(option.enemy, option.nextOnWin, option.nextOnLoss); | |
// Combat flow handles the next steps and UI render | |
} else if (option.check) { | |
performSkillCheck(option.check, option.next, option.onFailure); | |
// Skill check flow handles the next steps and UI render | |
} else if (option.next) { | |
// Simple navigation or action leading to next page | |
let nextPageId = parseInt(option.next); | |
const targetPageData = gameData[nextPageId]; | |
if (!targetPageData) { | |
console.error(`Invalid next page ID: ${nextPageId}`); | |
currentMessage += `<p class="message message-warning">That path is mysteriously blocked!</p>`; | |
nextPageId = gameState.currentPageId; // Stay on current page | |
} | |
// Apply rewards associated *with this specific choice* | |
if (option.reward) { | |
applyReward(option.reward); | |
} | |
gameState.currentPageId = nextPageId; | |
renderPage(nextPageId); // Render the next page | |
} else { | |
console.warn("Choice has no action or next page:", option); | |
currentMessage += `<p class="message message-info">Nothing seems to happen...</p>`; | |
renderPage(gameState.currentPageId); // Re-render current page | |
} | |
} | |
window.handleChoiceClick = handleChoiceClick; // Expose for inline handlers | |
function performSkillCheck(checkData, successPageId, failurePageId) { | |
const {stat, dc} = checkData; | |
const baseStat = gameState.character.stats[stat] || 10; | |
const modifier = Math.floor((baseStat - 10) / 2); | |
const roll = Math.floor(Math.random() * 20) + 1; | |
const total = roll + modifier; | |
const success = roll === 20 || (roll !== 1 && total >= dc); // Nat 20 success, Nat 1 fail | |
console.log(`Skill Check ${stat}: Roll ${roll} + Mod ${modifier} = ${total} vs DC ${dc}`); | |
currentMessage += `<p class="message message-info"><em>Rolling ${stat}... (Rolled ${total} vs DC ${dc})</em></p>`; | |
displayDiceRoll(roll, success); | |
if (success) { | |
currentMessage += `<p class="message message-success"><em>Success!</em></p>`; | |
gameState.currentPageId = successPageId; | |
} else { | |
currentMessage += `<p class="message message-failure"><em>Failed!</em></p>`; | |
gameState.currentPageId = failurePageId; | |
} | |
// Add delay before showing result page | |
activeTimeouts.push(setTimeout(() => renderPage(gameState.currentPageId), 2600)); // Longer delay | |
} | |
function applyReward(rewardData) { | |
// Refactored reward application | |
if(!rewardData) return; | |
if(rewardData.addItem && itemsData[rewardData.addItem]) { | |
if (!gameState.character.inventory.includes(rewardData.addItem)) { | |
gameState.character.inventory.push(rewardData.addItem); | |
currentMessage += `<p class="message message-item">You got a ${rewardData.addItem}!</p>`; | |
} else { | |
currentMessage += `<p class="message message-info">You found another ${rewardData.addItem}.</p>`; | |
} | |
} | |
if(rewardData.xp) { | |
gameState.character.stats.xp += rewardData.xp; | |
currentMessage += `<p class="message message-xp">Gained ${rewardData.xp} XP!</p>`; | |
} | |
if(rewardData.hpGain) { | |
gameState.character.stats.hp = Math.min(gameState.character.stats.maxHp, gameState.character.stats.hp + rewardData.hpGain); | |
currentMessage += `<p class="message message-success">Recovered ${rewardData.hpGain} HP.</p>`; | |
} | |
if(rewardData.statGain) { | |
for (const stat in rewardData.statGain) { | |
if (gameState.character.stats.hasOwnProperty(stat)) { | |
const increase = rewardData.statGain[stat]; | |
gameState.character.stats[stat] += increase; | |
currentMessage += `<p class="message message-success">${stat.charAt(0).toUpperCase() + stat.slice(1)} increased by ${increase}!</p>`; | |
if (stat === 'maxHp' && increase > 0) { // Also heal when maxHP increases | |
gameState.character.stats.hp += increase; | |
} | |
// Potentially recalculate maxHP if constitution changes (add recalculateMaxHp function if needed) | |
} | |
} | |
} | |
} | |
function renderPage(pageId) { | |
console.log(`Rendering page ${pageId}`); | |
const pageData = gameData[pageId]; | |
if (!pageData) { /* Error handling */ console.error(`No page data for ID: ${pageId}`); return; } | |
storyTitleElement.textContent = pageData.title || "An Unnamed Place"; | |
storyContentElement.innerHTML = currentMessage + (pageData.content || "<p>...</p>"); | |
choicesElement.innerHTML = ''; | |
actionChoicesElement.innerHTML = ''; | |
// Navigation Buttons - Always show 4, disable if no exit | |
const neighbors = {}; // For page-based, neighbours aren't fixed grid, maybe infer from options? Or ignore for now. | |
const navOptions = pageData.options.filter(opt => opt.text.toLowerCase().includes("go ")); // Basic filter for nav text | |
['North', 'South', 'East', 'West'].forEach(dir => { | |
const button = document.createElement('button'); | |
button.classList.add('choice-button'); | |
button.textContent = `Go ${dir}`; | |
// Find if an option corresponds to this direction (simplistic check) | |
const matchingOption = pageData.options.find(opt => opt.text.toLowerCase().includes(`(${dir.toLowerCase()})`) || opt.text.toLowerCase().includes(`go ${dir.toLowerCase()}`)); | |
if (matchingOption) { | |
button.onclick = () => handleChoiceClick(matchingOption); | |
// Check requirements for this specific nav option | |
if (matchingOption.requires && !gameState.character.inventory.includes(matchingOption.requires)) { | |
button.disabled = true; | |
button.title = `Requires: ${matchingOption.requires}`; | |
} | |
} else { | |
button.disabled = true; // Disable if no matching option found | |
} | |
choicesElement.appendChild(button); | |
}); | |
// Action Buttons (Non-navigational) | |
if (pageData.options) { | |
pageData.options.filter(opt => !opt.text.toLowerCase().includes("go ")).forEach(option => { // Filter out nav buttons | |
const button = document.createElement('button'); | |
button.classList.add('choice-button', 'action'); | |
if (option.action === 'triggerCombat') button.classList.add('combat-button'); // Style combat triggers | |
button.textContent = option.text; | |
let requirementMet = true; | |
if (option.requires && !gameState.character.inventory.includes(option.requires)) { | |
requirementMet = false; | |
button.title = `Requires: ${option.requires}`; | |
} | |
button.disabled = !requirementMet; | |
button.onclick = () => handleChoiceClick(option); | |
actionChoicesElement.appendChild(button); | |
}); | |
} | |
if (actionChoicesElement.innerHTML === '') { | |
actionChoicesElement.innerHTML = '<p><i>Nothing else seems possible here.</i></p>'; | |
} | |
// Combat UI | |
if (gameState.combat?.active) { | |
actionChoicesElement.innerHTML += ` | |
<div id="combat-ui"> | |
<p class="message message-combat">Fighting ${gameState.combat.enemyName}! (Enemy HP: ${gameState.combat.enemyHp})</p> | |
<button class="choice-button combat-button action" onclick="handleCombatAction('attack')">Attack!</button> | |
</div>`; | |
} | |
updateStatsDisplay(); | |
updateInventoryDisplay(); | |
updateActionInfo(); | |
updateScene(pageData.assemblyParams); | |
} | |
function updateStatsDisplay() { | |
if (!gameState.character || !statsElement) return; | |
const stats = gameState.character.stats; | |
const hpColor = stats.hp / stats.maxHp < 0.3 ? '#f88' : (stats.hp / stats.maxHp < 0.6 ? '#fd5' : '#8f8'); | |
const statBonus = (statVal) => { | |
const mod = Math.floor((statVal - 10) / 2); | |
return `${statVal} (${mod >= 0 ? '+' : ''}${mod})`; | |
}; | |
statsElement.innerHTML = `<strong>Stats:</strong> | |
<span style="color:${hpColor}">HP: ${stats.hp}/${stats.maxHp}</span> <span>XP: ${stats.xp}</span><br> | |
<span>Str: ${statBonus(stats.strength)}</span> <span>Dex: ${statBonus(stats.dexterity)}</span> <span>Con: ${statBonus(stats.constitution)}</span> | |
<span>Int: ${statBonus(stats.intelligence)}</span> <span>Wis: ${statBonus(stats.wisdom)}</span> <span>Cha: ${statBonus(stats.charisma)}</span>`; | |
} | |
function updateInventoryDisplay() { | |
if (!gameState.character || !inventoryElement) return; | |
let invHtml = '<strong>Inventory:</strong> '; | |
if (gameState.character.inventory.length === 0) { | |
invHtml += '<em>Empty</em>'; | |
} else { | |
gameState.character.inventory.forEach(item => { | |
const itemDef = itemsData[item] || { type: 'unknown', description: '???' }; | |
const itemClass = `item-${itemDef.type || 'unknown'}`; | |
invHtml += `<span class="item-tag ${itemClass}" title="${itemDef.description}">${item}</span>`; | |
}); | |
} | |
inventoryElement.innerHTML = invHtml; | |
} | |
function updateActionInfo() { | |
if (!actionInfoElement || !gameState ) return; | |
const mode = gameState.combat?.active ? "Combat!" : "Exploring"; | |
actionInfoElement.textContent = `Location: ${gameData[gameState.currentPageId]?.title || 'Unknown'} | ${mode}`; | |
} | |
// --- Combat & Item Functions --- | |
function startCombat(enemyTypeId, nextOnWin, nextOnLoss) { | |
const enemyBase = enemyData[enemyTypeId]; | |
if (!enemyBase) { console.error("Unknown enemy type:", enemyTypeId); return; } | |
gameState.combat = { | |
active: true, enemyId: enemyTypeId, enemyName: enemyBase.name, | |
enemyHp: enemyBase.hp, enemyMaxHp: enemyBase.hp, | |
enemyDefense: enemyBase.defense, enemyAttackBonus: enemyBase.attackBonus || 2, | |
enemyDamageDice: enemyBase.damageDice || 4, enemyXp: enemyBase.xp, | |
enemyDrops: enemyBase.drops || [], | |
nextOnWin: nextOnWin, nextOnLoss: nextOnLoss | |
}; | |
currentMessage = `<p class="message message-combat">Watch out! A ${enemyBase.name} attacks!</p>`; | |
renderPage(gameState.currentPageId); // Re-render to show combat UI | |
} | |
function handleCombatAction(action) { | |
if (!gameState.combat?.active) return; | |
currentMessage = ""; | |
if (action === 'attack') { | |
// Player Attack | |
const playerRoll = Math.floor(Math.random() * 20) + 1; | |
const playerAtkBonus = Math.floor((gameState.character.stats.strength - 10) / 2); | |
const playerTotalAttack = playerRoll + playerAtkBonus; | |
const playerHit = playerRoll === 20 || (playerRoll !== 1 && playerTotalAttack >= gameState.combat.enemyDefense); | |
displayDiceRoll(playerRoll, playerHit); | |
if (playerHit) { | |
const weapon = gameState.character.inventory.find(i => itemsData[i]?.type === 'weapon'); | |
const baseDamage = itemsData[weapon]?.baseDamage || 2; // Use weapon or 1d2 for unarmed | |
const damageRoll = Math.max(1, Math.floor(Math.random() * baseDamage) + 1 + playerAtkBonus); // Add STR mod to damage | |
gameState.combat.enemyHp -= damageRoll; | |
currentMessage += `<p class="message message-success">You hit for ${damageRoll} damage! (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`; | |
} else { | |
currentMessage += `<p class="message message-failure">You missed. (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`; | |
} | |
// Check Enemy Defeat | |
if (gameState.combat.enemyHp <= 0) { | |
currentMessage += `<p class="message message-success"><b>You defeated the ${gameState.combat.enemyName}!</b></p>`; | |
applyReward({ xp: gameState.combat.enemyXp }); // Apply XP reward | |
// Handle Drops | |
if (gameState.combat.enemyDrops.length > 0) { | |
const droppedItemName = gameState.combat.enemyDrops[Math.floor(Math.random() * gameState.combat.enemyDrops.length)]; | |
dropItemInScene(droppedItemName, new THREE.Vector3(Math.random()*2-1, 0.2, Math.random()*2-1)); | |
currentMessage += `<p class="message message-item"><em>The ${gameState.combat.enemyName} dropped a ${droppedItemName}! Click it to pick up.</em></p>`; | |
} | |
const winPage = gameState.combat.nextOnWin; | |
gameState.combat = null; | |
gameState.currentPageId = winPage; | |
setTimeout(() => renderPage(gameState.currentPageId), 500); // Short delay after win message | |
return; | |
} | |
// Enemy Attack (if enemy still alive) | |
const enemyRoll = Math.floor(Math.random() * 20) + 1; | |
const enemyTotalAttack = enemyRoll + gameState.combat.enemyAttackBonus; | |
const playerAC = 10 + Math.floor((gameState.character.stats.dexterity - 10) / 2); | |
const enemyHit = enemyRoll === 20 || (enemyRoll !== 1 && enemyTotalAttack >= playerAC); | |
activeTimeouts.push( setTimeout(() => displayDiceRoll(enemyRoll, enemyHit), 600) ); // Display enemy roll slightly later | |
if (enemyHit) { | |
const damageRoll = Math.max(1, Math.floor(Math.random() * gameState.combat.enemyDamageDice) + 1); | |
gameState.character.stats.hp -= damageRoll; | |
currentMessage += `<p class="message message-failure">The ${gameState.combat.enemyName} hits you for ${damageRoll} damage! (Rolled ${enemyTotalAttack} vs AC ${playerAC})</p>`; | |
if (gameState.character.stats.hp <= 0) { | |
currentMessage += `<p class="message message-failure"><b>You have been defeated!</b></p>`; | |
const lossPage = gameState.combat.nextOnLoss; | |
gameState.combat = null; | |
gameState.currentPageId = lossPage; | |
activeTimeouts.push( setTimeout(() => renderPage(gameState.currentPageId), 2600) ); // Delay transition | |
return; | |
} | |
} else { | |
currentMessage += `<p class="message message-info">The ${gameState.combat.enemyName} misses you. (Rolled ${enemyTotalAttack} vs AC ${playerAC})</p>`; | |
} | |
activeTimeouts.push( setTimeout(() => renderPage(gameState.currentPageId), 2600) ); // Re-render UI after delay | |
} | |
} | |
window.handleCombatAction = handleCombatAction; | |
function displayDiceRoll(result, success) { | |
if (!threeFont) { return; } | |
activeTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); activeTimeouts = []; | |
scene.children.filter(c => c.userData?.isDiceRoll).forEach(c => scene.remove(c)); | |
const textGeo = new TextGeometry(result.toString(), { font: threeFont, size: 1.0, height: 0.1, curveSegments: 4 }); | |
textGeo.computeBoundingBox(); textGeo.center(); // Center geometry | |
const textMat = MAT.text_material.clone(); | |
textMat.color.setHex(success ? 0x88ff88 : 0xff8888); | |
textMat.opacity = 1.0; // Start fully opaque | |
const textMesh = new THREE.Mesh(textGeo, textMat); | |
textMesh.userData.isDiceRoll = true; | |
const distance = 5; // Distance in front of camera | |
const cameraDirection = camera.getWorldDirection(new THREE.Vector3()); | |
const textPos = camera.position.clone().add(cameraDirection.multiplyScalar(distance)); | |
textMesh.position.copy(textPos); | |
textMesh.position.y += 1.2; // Position slightly higher | |
textMesh.quaternion.copy(camera.quaternion); // Face camera | |
scene.add(textMesh); | |
const duration = 2.5; | |
const fadeStart = 1.5; // Start fading after 1.5s | |
const startTime = clock.getElapsedTime(); | |
textMesh.userData.startTime = startTime; | |
textMesh.userData.update = (time) => { | |
const elapsed = time - textMesh.userData.startTime; | |
if (elapsed >= duration) { | |
if (textMesh.parent) textMesh.parent.remove(textMesh); | |
delete textMesh.userData.update; | |
// Remove from timeout tracking - not strictly needed as it stops updating | |
} else { | |
textMesh.position.y += 0.015; // Float up | |
if (elapsed > fadeStart) { | |
textMesh.material.opacity = 1.0 - ((elapsed - fadeStart) / (duration - fadeStart)); | |
} | |
} | |
}; | |
} | |
function dropItemInScene(itemName, positionOffset = new THREE.Vector3(0, 0, 0)) { | |
const currentGroup = currentSceneGroup; // Drop relative to current scene group | |
if (!currentGroup || !itemsData[itemName]) return; | |
const itemDef = itemsData[itemName]; | |
const itemGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4); | |
const itemMat = MAT.simple.clone(); | |
if(itemDef.type === 'weapon') itemMat.color.setHex(0xcc6666); | |
else if(itemDef.type === 'consumable') itemMat.color.setHex(0xcc9966); | |
else if(itemDef.type === 'quest') itemMat.color.setHex(0xcccc66); | |
else itemMat.color.setHex(0xaaaaee); | |
const dropPos = new THREE.Vector3(positionOffset.x, 0.2, positionOffset.z); | |
const droppedMesh = createMesh(itemGeo, itemMat, dropPos); | |
droppedMesh.userData = { isPickupable: true, itemName: itemName, description: `Dropped ${itemName}` }; | |
currentGroup.add(droppedMesh); // Add item to the current scene group | |
console.log(`Dropped ${itemName} in scene`); | |
} | |
function pickupItem() { | |
if (gameState.combat?.active) return; | |
raycaster.setFromCamera(mouse, camera); | |
const currentGroup = currentSceneGroup; | |
if (!currentGroup) return; | |
const pickupables = []; | |
currentGroup.traverseVisible(child => { // Only check visible objects | |
if (child.userData.isPickupable) pickupables.push(child); | |
}); | |
const intersects = raycaster.intersectObjects(pickupables, false); | |
if (intersects.length > 0) { | |
const clickedObject = intersects[0].object; | |
const itemName = clickedObject.userData.itemName; | |
if (itemName && itemsData[itemName]) { | |
console.log(`Picked up: ${itemName}`); | |
currentMessage = `<p class="message message-item"><em>Picked up: ${itemName}</em></p>`; | |
if (!gameState.character.inventory.includes(itemName)) { | |
gameState.character.inventory.push(itemName); | |
} else { | |
currentMessage += `<p class="message message-info"><em>(You already have a ${itemName})</em></p>`; | |
} | |
if(clickedObject.parent) clickedObject.parent.remove(clickedObject); | |
if(clickedObject.geometry) clickedObject.geometry.dispose(); | |
renderCurrentPageUI(); | |
} | |
} | |
} | |
// --- Initialization --- | |
document.addEventListener('DOMContentLoaded', () => { | |
console.log("DOM Ready - Initializing Wacky D&D Shapes Adventure!"); | |
try { | |
initThreeJS(); | |
if (!scene || !camera || !renderer) throw new Error("Three.js failed to initialize."); | |
loadFontAndStart(); // Load font, then start game | |
console.log("Initialization sequence started."); | |
} catch (error) { | |
console.error("Initialization failed:", error); | |
storyTitleElement.textContent = "Initialization Error"; | |
storyContentElement.innerHTML = `<p style="color:red;">Failed to start game:</p><pre style="color:red; white-space: pre-wrap;">${error.stack || error}</pre><p style="color:yellow;">Check console (F12) for details.</p>`; | |
if(sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding: 20px;">3D Scene Failed</p>'; | |
const statsInvContainer = document.getElementById('stats-inventory-container'); | |
const choicesCont = document.getElementById('choices-container'); | |
if (statsInvContainer) statsInvContainer.style.display = 'none'; | |
if (choicesCont) choicesCont.style.display = 'none'; | |
} | |
}); | |
</script> | |
</body> | |
</html> |