Spaces:
Running
Running
Create index.html
Browse files- index.html +990 -0
index.html
ADDED
@@ -0,0 +1,990 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Wacky D&D Shapes Adventure!</title>
|
7 |
+
<style>
|
8 |
+
/* --- Base Styles --- */
|
9 |
+
body { font-family: 'Verdana', sans-serif; background-color: #2a3a4a; color: #f0f0f0; margin: 0; padding: 0; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
|
10 |
+
#game-container { display: flex; flex-grow: 1; overflow: hidden; }
|
11 |
+
#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; }
|
12 |
+
#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; }
|
13 |
+
#scene-container canvas { display: block; }
|
14 |
+
|
15 |
+
/* --- UI Elements --- */
|
16 |
+
#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; }
|
17 |
+
#story-content { margin-bottom: 25px; line-height: 1.7; flex-grow: 1; font-size: 1.1em; color: #e8e8e8;}
|
18 |
+
#stats-inventory-container { margin-bottom: 25px; padding: 15px; border: 1px solid #506070; border-radius: 5px; background-color: #4a5a6a; font-size: 0.95em; }
|
19 |
+
#stats-display, #inventory-display { margin-bottom: 10px; line-height: 1.8; }
|
20 |
+
#stats-display span { 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); }
|
21 |
+
#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); cursor: default; }
|
22 |
+
#stats-display strong, #inventory-display strong { color: #e0e0e0; margin-right: 6px; font-weight:bold; }
|
23 |
+
#inventory-display em { color: #aabbcc; font-style: normal; }
|
24 |
+
.item-tool { background-color: #a0522d; border-color: #cd853f; color: #fff;}
|
25 |
+
.item-key { background-color: #ffd700; border-color: #f0e68c; color: #333;}
|
26 |
+
.item-treasure { background-color: #20b2aa; border-color: #48d1cc; color: #fff;}
|
27 |
+
.item-food { background-color: #ff6347; border-color: #fa8072; color: #fff;}
|
28 |
+
.item-quest { background-color: #da70d6; border-color: #ee82ee; color: #fff;} /* Orchid/Violet */
|
29 |
+
.item-unknown { background-color: #778899; border-color: #b0c4de;}
|
30 |
+
|
31 |
+
/* --- Choices & Messages --- */
|
32 |
+
#choices-container { margin-top: auto; padding-top: 20px; border-top: 2px solid #506070; }
|
33 |
+
#choices-container h3 { margin-top: 0; margin-bottom: 12px; color: #e0e0e0; font-size: 1.2em; font-weight: bold;}
|
34 |
+
#choices { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 15px;} /* Grid for nav */
|
35 |
+
#action-choices { display: flex; flex-direction: column; gap: 12px; margin-top: 15px; border-top: 1px dashed #607080; padding-top: 15px;} /* Separate area for actions */
|
36 |
+
.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; }
|
37 |
+
.choice-button.action { text-align: left; grid-column: span 2;} /* Action buttons span full width */
|
38 |
+
.choice-button:hover:not(:disabled) { background-color: #ffc107; color: #222; border-color: #ffca2c; transform: translateY(-1px); }
|
39 |
+
.choice-button:disabled { background-color: #4e5a66; color: #8a9aab; cursor: not-allowed; border-color: #607080; opacity: 0.6; transform: none; box-shadow: none;}
|
40 |
+
.choice-button[title]:disabled::after { content: ' (' attr(title) ')'; font-style: italic; font-size: 0.9em; margin-left: 5px; }
|
41 |
+
.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; }
|
42 |
+
.message-info { color: #ccc; border-left-color: #666; }
|
43 |
+
.message-item { color: #9cf; border-left-color: #48a; }
|
44 |
+
.message-xp { color: #af8; border-left-color: #6a4; }
|
45 |
+
.message-warning { color: #f99; border-left-color: #c66; }
|
46 |
+
.message-success { color: #8f8; border-left-color: #4a4; }
|
47 |
+
.message-failure { color: #f88; border-left-color: #a44; }
|
48 |
+
.message-combat { color: #f98; border-left-color: #c64; font-weight: bold;}
|
49 |
+
.combat-button { background-color: #a33; border-color: #c66; color: #fff; font-weight: bold; text-align: center;}
|
50 |
+
.combat-button:hover:not(:disabled) { background-color: #d44; border-color: #f88;}
|
51 |
+
|
52 |
+
/* --- Action Info --- */
|
53 |
+
#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;}
|
54 |
+
</style>
|
55 |
+
</head>
|
56 |
+
<body>
|
57 |
+
<div id="game-container">
|
58 |
+
<div id="scene-container">
|
59 |
+
<div id="action-info">Initializing...</div>
|
60 |
+
</div>
|
61 |
+
<div id="ui-container">
|
62 |
+
<h2 id="story-title">Initializing...</h2>
|
63 |
+
<div id="story-content"><p>Loading world...</p></div>
|
64 |
+
<div id="stats-inventory-container">
|
65 |
+
<div id="stats-display">Loading Stats...</div>
|
66 |
+
<div id="inventory-display">Inventory: ...</div>
|
67 |
+
</div>
|
68 |
+
<div id="choices-container">
|
69 |
+
<h3>Navigation</h3>
|
70 |
+
<div id="choices">Loading...</div>
|
71 |
+
<div id="action-choices"></div>
|
72 |
+
</div>
|
73 |
+
</div>
|
74 |
+
</div>
|
75 |
+
|
76 |
+
<script type="importmap">
|
77 |
+
{ "imports": {
|
78 |
+
"three": "https://unpkg.com/[email protected]/build/three.module.js",
|
79 |
+
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
|
80 |
+
}}
|
81 |
+
</script>
|
82 |
+
|
83 |
+
<script type="module">
|
84 |
+
import * as THREE from 'three';
|
85 |
+
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
86 |
+
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
|
87 |
+
// Orbit Controls removed again for simplicity focus
|
88 |
+
|
89 |
+
console.log("Script module execution started.");
|
90 |
+
|
91 |
+
// --- DOM Elements ---
|
92 |
+
const sceneContainer = document.getElementById('scene-container');
|
93 |
+
const storyTitleElement = document.getElementById('story-title');
|
94 |
+
const storyContentElement = document.getElementById('story-content');
|
95 |
+
const choicesElement = document.getElementById('choices'); // Nav buttons grid
|
96 |
+
const actionChoicesElement = document.getElementById('action-choices'); // Action buttons list
|
97 |
+
const statsElement = document.getElementById('stats-display');
|
98 |
+
const inventoryElement = document.getElementById('inventory-display');
|
99 |
+
const actionInfoElement = document.getElementById('action-info');
|
100 |
+
|
101 |
+
console.log("DOM elements obtained.");
|
102 |
+
|
103 |
+
// --- Core Three.js Variables ---
|
104 |
+
let scene, camera, renderer, clock;
|
105 |
+
let currentSceneGroup = null;
|
106 |
+
let currentLights = [];
|
107 |
+
let threeFont = null;
|
108 |
+
let currentMessage = "";
|
109 |
+
|
110 |
+
// --- Materials ---
|
111 |
+
const MAT = {
|
112 |
+
stone_grey: new THREE.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 0.8 }),
|
113 |
+
stone_brown: new THREE.MeshStandardMaterial({ color: 0x9d8468, roughness: 0.85 }),
|
114 |
+
wood_light: new THREE.MeshStandardMaterial({ color: 0xcdaa7d, roughness: 0.7 }),
|
115 |
+
wood_dark: new THREE.MeshStandardMaterial({ color: 0x6f4e2d, roughness: 0.75 }),
|
116 |
+
leaf_green: new THREE.MeshStandardMaterial({ color: 0x4caf50, roughness: 0.6, side: THREE.DoubleSide }),
|
117 |
+
leaf_autumn: new THREE.MeshStandardMaterial({ color: 0xffa040, roughness: 0.65, side: THREE.DoubleSide }),
|
118 |
+
ground_dirt: new THREE.MeshStandardMaterial({ color: 0x8b5e3c, roughness: 0.9 }),
|
119 |
+
ground_grass: new THREE.MeshStandardMaterial({ color: 0x558b2f, roughness: 0.85 }),
|
120 |
+
metal_shiny: new THREE.MeshStandardMaterial({ color: 0xc0c0c0, roughness: 0.2, metalness: 0.8 }),
|
121 |
+
metal_rusty: new THREE.MeshStandardMaterial({ color: 0xb7410e, roughness: 0.9, metalness: 0.2 }),
|
122 |
+
crystal_blue: new THREE.MeshStandardMaterial({ color: 0x87cefa, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.8, emissive:0x205080, emissiveIntensity: 0.4 }),
|
123 |
+
crystal_red: new THREE.MeshStandardMaterial({ color: 0xff7f7f, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.8, emissive:0x802020, emissiveIntensity: 0.4 }),
|
124 |
+
bright_yellow: new THREE.MeshStandardMaterial({ color: 0xffeb3b, roughness: 0.6 }),
|
125 |
+
deep_purple: new THREE.MeshStandardMaterial({ color: 0x9c27b0, roughness: 0.7 }),
|
126 |
+
sky_blue: new THREE.MeshStandardMaterial({ color: 0x03a9f4, roughness: 0.5 }),
|
127 |
+
town_wood: new THREE.MeshStandardMaterial({ color: 0xae8a63, roughness: 0.7 }),
|
128 |
+
town_roof: new THREE.MeshStandardMaterial({ color: 0x8b4513, roughness: 0.8 }),
|
129 |
+
goblin_skin: new THREE.MeshStandardMaterial({ color: 0x8FBC8F, roughness: 0.8 }), // DarkSeaGreen
|
130 |
+
text_material: new THREE.MeshBasicMaterial({ color: 0xffddaa }),
|
131 |
+
};
|
132 |
+
|
133 |
+
// --- Game State ---
|
134 |
+
let gameState = {};
|
135 |
+
|
136 |
+
// --- Item Data ---
|
137 |
+
const itemsData = {
|
138 |
+
"Sturdy Stick": {type:"tool", description:"Surprisingly pointy."},
|
139 |
+
"Wobbly Key": {type:"key", description:"Might fit a wobbly lock?"},
|
140 |
+
"Goblin's Favorite Sock": {type:"quest", description:"Smells... unique."},
|
141 |
+
"Shiny Rock": {type:"treasure", description:"Distractingly shiny."},
|
142 |
+
"Suspicious Mushroom": {type:"food", description:"Maybe... just maybe..."},
|
143 |
+
};
|
144 |
+
|
145 |
+
// --- Enemy Data ---
|
146 |
+
const enemyData = {
|
147 |
+
'goblin': { name: "Grumpy Goblin", hp: 12, defense: 12, attackBonus: 1, damageDice: 4, xp: 25, drops: ["Goblin's Favorite Sock", "Shiny Rock"] },
|
148 |
+
// Add more enemies later
|
149 |
+
};
|
150 |
+
|
151 |
+
// --- Procedural Assembly Shapes ---
|
152 |
+
const SHAPE_GENERATORS = {
|
153 |
+
'pointy_cone': (size) => new THREE.ConeGeometry(size * 0.5, size * 1.2, 6 + Math.floor(Math.random() * 5)),
|
154 |
+
'round_blob': (size) => new THREE.SphereGeometry(size * 0.6, 12, 8),
|
155 |
+
'spinny_torus': (size) => new THREE.TorusGeometry(size * 0.5, size * 0.15, 8, 16),
|
156 |
+
'boxy_chunk': (size) => new THREE.BoxGeometry(size, size * (0.8 + Math.random() * 0.4), size * (0.8 + Math.random() * 0.4)),
|
157 |
+
'spiky_ball': (size) => new THREE.IcosahedronGeometry(size * 0.7, 0),
|
158 |
+
'flat_plate': (size) => new THREE.BoxGeometry(size * 1.5, size * 0.1, size * 1.5),
|
159 |
+
'tall_cylinder': (size) => new THREE.CylinderGeometry(size * 0.3, size * 0.3, size * 2.0, 8),
|
160 |
+
'knotty_thing': (size) => new THREE.TorusKnotGeometry(size * 0.4, size * 0.1, 50, 8),
|
161 |
+
'gem_shape': (size) => new THREE.OctahedronGeometry(size * 0.6, 0),
|
162 |
+
'basic_tetra': (size) => new THREE.TetrahedronGeometry(size * 0.7, 0),
|
163 |
+
'squashed_ball': (size) => new THREE.SphereGeometry(size * 0.6, 12, 8).scale(1, 0.6, 1),
|
164 |
+
'holed_box': (size) => {
|
165 |
+
const shape = new THREE.Shape();
|
166 |
+
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();
|
167 |
+
const hole = new THREE.Path();
|
168 |
+
hole.absellipse(0, 0, size*0.2, size*0.2, 0, Math.PI*2, false);
|
169 |
+
shape.holes.push(hole);
|
170 |
+
return new THREE.ExtrudeGeometry(shape, {depth: size*0.3, bevelEnabled: false});
|
171 |
+
}
|
172 |
+
};
|
173 |
+
const shapeKeys = Object.keys(SHAPE_GENERATORS);
|
174 |
+
|
175 |
+
// --- Game Data (Page-Based, Expanded Story) ---
|
176 |
+
const gameData = {
|
177 |
+
1: {
|
178 |
+
title: "Snoring Meadows",
|
179 |
+
content: "<p>You wake up in a field of gently snoring grass. Yup, snoring. To the North are some Wiggly Woods. East leads to the Clanky Caves entrance.</p>",
|
180 |
+
options: [
|
181 |
+
{ text: "Enter Wiggly Woods (North)", next: 2 },
|
182 |
+
{ text: "Approach Clanky Caves (East)", next: 5 },
|
183 |
+
{ text: "Poke the snoring grass (Wisdom Check DC 10)", check: { stat: 'wisdom', dc: 10, next: 10, onFailure: 11 } }
|
184 |
+
],
|
185 |
+
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' }
|
186 |
+
},
|
187 |
+
2: {
|
188 |
+
title: "Wiggly Woods",
|
189 |
+
content: "<p>These trees really do wiggle! It's making you a bit dizzy. A grumpy-looking goblin blocks the path deeper into the woods (South?).</p>",
|
190 |
+
options: [
|
191 |
+
{ text: "Talk to the Goblin", next: 3 },
|
192 |
+
{ text: "Try to sneak past (Dexterity Check DC 12)", check: { stat: 'dexterity', dc: 12, next: 4, onFailure: 3 } },
|
193 |
+
{ text: "Go Back (South)", next: 1 }
|
194 |
+
],
|
195 |
+
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 }
|
196 |
+
},
|
197 |
+
3: {
|
198 |
+
title: "Grumpy Goblin",
|
199 |
+
content: "<p>'Oi! You wanna pass?' grunts the goblin, pointing a wobbly spear. 'Dis my wood! Whatcha gonna do?'</p>",
|
200 |
+
options: [
|
201 |
+
{ text: "Fight the Grumpy Goblin!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 },
|
202 |
+
{ text: "Offer him a Shiny Rock (Requires Shiny Rock)", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 10} },
|
203 |
+
{ text: "Try to reason with him (Charisma Check DC 14)", check: { stat: 'charisma', dc: 14, next: 4, onFailure: 12 } },
|
204 |
+
{ text: "Run Away!", next: 2 }
|
205 |
+
],
|
206 |
+
assemblyParams: { // Similar to woods, maybe add goblin representation
|
207 |
+
baseShape: 'ground_dirt', baseSize: 25,
|
208 |
+
mainShapes: ['tall_cylinder', 'knotty_thing'], accents: ['leaf_autumn', 'basic_tetra'],
|
209 |
+
count: 20, scaleRange: [1.0, 3.0],
|
210 |
+
colorTheme: [MAT.wood_dark, MAT.leaf_autumn, MAT.deep_purple, MAT.goblin_skin],
|
211 |
+
arrangement: 'cluster', clusterRadius: 10
|
212 |
+
}
|
213 |
+
},
|
214 |
+
4: {
|
215 |
+
title: "Past the Goblin!",
|
216 |
+
content: "<p>You somehow got past the goblin! The path continues North to a weirdly Square Clearing. Or you can go back South.</p>",
|
217 |
+
options: [
|
218 |
+
{ text: "Go to Square Clearing (North)", next: 7 },
|
219 |
+
{ text: "Go Back (South)", next: 2 }
|
220 |
+
],
|
221 |
+
assemblyParams: { baseShape: 'ground_dirt', baseSize: 25, mainShapes: ['tall_cylinder'], accents: ['leaf_green'], count: 15, scaleRange: [1.0, 2.5], colorTheme: [MAT.wood_light, MAT.leaf_green], arrangement: 'scatter' }
|
222 |
+
},
|
223 |
+
5: {
|
224 |
+
title: "Clanky Caves Entrance",
|
225 |
+
content: "<p>The entrance to the cave clanks and whirs softly. It smells like old metal and damp socks. A sign reads 'Beware of... Clanks?'</p>",
|
226 |
+
options: [ { text: "Enter the Caves", next: 8 }, { text: "Go Back West", next: 1 } ],
|
227 |
+
assemblyParams: { baseShape: 'stone_grey', baseSize: 20, mainShapes: ['spiky_ball', 'spinny_torus'], accents: ['metal_rusty', 'basic_tetra'], count: 25, scaleRange: [0.5, 1.8], colorTheme: [MAT.stone_grey, MAT.metal_rusty], arrangement: 'cluster', clusterRadius: 8 }
|
228 |
+
},
|
229 |
+
7: { // Added North from page 4
|
230 |
+
title: "The Square Clearing",
|
231 |
+
content: "<p>This clearing is unnervingly square. Even the rocks look cubic! In the center is a perfectly square chest with a wobbly-looking lock.</p>",
|
232 |
+
options: [
|
233 |
+
{ text: "Try the Wobbly Key (Requires Wobbly Key)", requires: "Wobbly Key", consume: true, next: 13 },
|
234 |
+
{ text: "Whack it with a Stick (Requires Sturdy Stick)", requires: "Sturdy Stick", next: 14 },
|
235 |
+
{ text: "Go Back South", next: 4 }
|
236 |
+
],
|
237 |
+
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], arrangement: 'patch', patchRadius: 8 }
|
238 |
+
},
|
239 |
+
8: { // Inside Clanky Caves
|
240 |
+
title: "Clanky Caves - Junction",
|
241 |
+
content: "<p>Clank! Whirr! Sproing! The cave is full of noisy, spinning metal things. Passages lead West and East.</p>",
|
242 |
+
options: [ { text: "Go West (Deeper?)", next: 9 }, { text: "Go East (Exit?)", next: 5 } ],
|
243 |
+
assemblyParams: { baseShape: 'stone_grey', baseSize: 25, mainShapes: ['spinny_torus', 'holed_box'], 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 }
|
244 |
+
},
|
245 |
+
9: { // Deeper Cave
|
246 |
+
title: "Sprocket Stash",
|
247 |
+
content: "<p>You found a pile of shiny sprockets! And maybe some suspicious mushrooms growing nearby.</p>",
|
248 |
+
options: [ { text: "Take a Sprocket!", reward: { addItem: "Shiny Sprocket", xp: 10 }, next: 8 }, { text: "Eat a Mushroom?", next: 15 }, { text: "Go Back East", next: 8 } ],
|
249 |
+
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 }
|
250 |
+
},
|
251 |
+
10: { // Success poking grass
|
252 |
+
title: "Grass Tickled!",
|
253 |
+
content: "<p>You gently poke the snoring grass. It giggles and coughs up a Wobbly Key! Weird.</p>",
|
254 |
+
options: [ { text: "Grab the Key!", reward: { addItem: "Wobbly Key", xp: 5 }, next: 1 } ],
|
255 |
+
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' }
|
256 |
+
},
|
257 |
+
11: { // Fail poking grass
|
258 |
+
title: "Grass Bites Back!",
|
259 |
+
content: "<p>Ouch! You poked too hard. The grass blade snapped back like a tiny whip! (-1 HP)</p>",
|
260 |
+
options: [ { text: "Okay, fine, leave it alone.", next: 1 } ],
|
261 |
+
hpLoss: 1,
|
262 |
+
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' }
|
263 |
+
},
|
264 |
+
12: { // Fail charisma check on goblin
|
265 |
+
title: "Goblin Unimpressed",
|
266 |
+
content: "<p>'Nice try, smooth-talker!' the goblin sneers, waving his spear more menacingly. 'Now what?'</p>",
|
267 |
+
options: [
|
268 |
+
{ text: "Fight him!", action: 'triggerCombat', enemy: 'goblin', nextOnWin: 4, nextOnLoss: 98 },
|
269 |
+
{ text: "Offer a Shiny Rock (If you have one)", requires: "Shiny Rock", consume: true, next: 4, reward: {xp: 10} },
|
270 |
+
{ text: "Run Away!", next: 2 }
|
271 |
+
],
|
272 |
+
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 }
|
273 |
+
},
|
274 |
+
13: { // Open chest with key
|
275 |
+
title: "Chest Opens!",
|
276 |
+
content: "<p>Click! The wobbly key fits the wobbly lock! Inside you find... another Shiny Rock?</p>",
|
277 |
+
options: [ { text: "Take the rock!", reward: { addItem: "Shiny Rock", xp: 15 }, next: 7 } ],
|
278 |
+
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], arrangement: 'patch', patchRadius: 8 }
|
279 |
+
},
|
280 |
+
14: { // Whack chest
|
281 |
+
title: "Thwack!",
|
282 |
+
content: "<p>You whack the square chest with your Sturdy Stick. It makes a dull *thud*. Nothing happens, but the stick feels slightly less sturdy.</p>",
|
283 |
+
options: [ { text: "Try the Wobbly Key (Requires Wobbly Key)", requires: "Wobbly Key", consume: true, next: 13 }, { text: "Give up and go back", next: 7 } ],
|
284 |
+
// Maybe add consequence: Sturdy Stick breaks? Requires more state tracking.
|
285 |
+
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], arrangement: 'patch', patchRadius: 8 }
|
286 |
+
},
|
287 |
+
15: { // Eat Mushroom
|
288 |
+
title: "Gulp!",
|
289 |
+
content: "<p>You bravely (or foolishly?) eat the Suspicious Mushroom. Your vision goes purple for a second, then... you feel... bouncy! (+5 Max HP! HP Restored!)</p>",
|
290 |
+
options: [ { text: "Weird! Go back.", next: 8 } ],
|
291 |
+
reward: { statGain: { maxHp: 5 }, hpGain: 5, xp: 5 }, // Example stat gain
|
292 |
+
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 }
|
293 |
+
},
|
294 |
+
98: { // Lose combat
|
295 |
+
title: "Oof!",
|
296 |
+
content: "<p>You were defeated! You wake up back where you started, feeling dizzy.</p>",
|
297 |
+
options: [ { text: "Okay...", next: 1 } ],
|
298 |
+
assemblyParams: { baseShape: 'ground_grass', count: 5, mainShapes:['round_blob'], colorTheme:[MAT.grass]} // Simple scene
|
299 |
+
},
|
300 |
+
99: { // Generic End/TBC
|
301 |
+
title: "Adventure Paused!",
|
302 |
+
content: "<p>That's as far as this path goes for now. What an adventure!</p>",
|
303 |
+
options: [ { text: "Start Over?", next: 1 } ],
|
304 |
+
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 }
|
305 |
+
}
|
306 |
+
};
|
307 |
+
|
308 |
+
// --- Core Functions ---
|
309 |
+
|
310 |
+
function initThreeJS() {
|
311 |
+
console.log("initThreeJS started.");
|
312 |
+
scene = new THREE.Scene();
|
313 |
+
scene.background = new THREE.Color(0x2a3a4a);
|
314 |
+
clock = new THREE.Clock();
|
315 |
+
raycaster = new THREE.Raycaster();
|
316 |
+
mouse = new THREE.Vector2();
|
317 |
+
|
318 |
+
const width = sceneContainer.clientWidth || 1;
|
319 |
+
const height = sceneContainer.clientHeight || 1;
|
320 |
+
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
|
321 |
+
camera.position.set(0, 8, 15);
|
322 |
+
camera.lookAt(0, 1, 0);
|
323 |
+
|
324 |
+
renderer = new THREE.WebGLRenderer({ antialias: true });
|
325 |
+
renderer.setSize(width, height);
|
326 |
+
renderer.shadowMap.enabled = true;
|
327 |
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
328 |
+
sceneContainer.appendChild(renderer.domElement);
|
329 |
+
|
330 |
+
// OrbitControls removed for focus on page-based nav
|
331 |
+
// controls = new OrbitControls(camera, renderer.domElement);
|
332 |
+
|
333 |
+
window.addEventListener('resize', onWindowResize, false);
|
334 |
+
renderer.domElement.addEventListener('click', onMouseClick, false); // For pickup
|
335 |
+
setTimeout(onWindowResize, 100);
|
336 |
+
animate();
|
337 |
+
console.log("initThreeJS finished.");
|
338 |
+
}
|
339 |
+
|
340 |
+
function loadFontAndStart() {
|
341 |
+
console.log("Loading font...");
|
342 |
+
const loader = new FontLoader();
|
343 |
+
loader.load('https://unpkg.com/[email protected]/examples/fonts/helvetiker_regular.typeface.json', function (font) {
|
344 |
+
threeFont = font;
|
345 |
+
console.log("Font loaded.");
|
346 |
+
startGame();
|
347 |
+
}, undefined, function (error) {
|
348 |
+
console.error('Font loading failed:', error);
|
349 |
+
threeFont = null; // Ensure font is null if failed
|
350 |
+
startGame(); // Start anyway
|
351 |
+
});
|
352 |
+
}
|
353 |
+
|
354 |
+
|
355 |
+
function onWindowResize() {
|
356 |
+
if (!renderer || !camera || !sceneContainer) return;
|
357 |
+
const width = sceneContainer.clientWidth || 1;
|
358 |
+
const height = sceneContainer.clientHeight || 1;
|
359 |
+
if (width > 0 && height > 0) {
|
360 |
+
camera.aspect = width / height;
|
361 |
+
camera.updateProjectionMatrix();
|
362 |
+
renderer.setSize(width, height);
|
363 |
+
}
|
364 |
+
}
|
365 |
+
|
366 |
+
function onMouseClick( event ) {
|
367 |
+
// Calculate mouse position in normalized device coordinates (-1 to +1)
|
368 |
+
const rect = renderer.domElement.getBoundingClientRect();
|
369 |
+
mouse.x = ( (event.clientX - rect.left) / rect.width ) * 2 - 1;
|
370 |
+
mouse.y = - ( (event.clientY - rect.top) / rect.height ) * 2 + 1;
|
371 |
+
pickupItem(); // Only pickup is enabled via click now
|
372 |
+
}
|
373 |
+
|
374 |
+
|
375 |
+
function animate() {
|
376 |
+
requestAnimationFrame(animate);
|
377 |
+
const delta = clock.getDelta();
|
378 |
+
const time = clock.getElapsedTime();
|
379 |
+
|
380 |
+
// controls?.update(); // OrbitControls removed
|
381 |
+
|
382 |
+
if (currentSceneGroup) {
|
383 |
+
currentSceneGroup.traverse(obj => {
|
384 |
+
if (obj.userData.update) obj.userData.update(time, delta);
|
385 |
+
});
|
386 |
+
}
|
387 |
+
|
388 |
+
if (renderer && scene && camera) renderer.render(scene, camera);
|
389 |
+
}
|
390 |
+
|
391 |
+
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}) {
|
392 |
+
const mesh = new THREE.Mesh(geometry, material);
|
393 |
+
mesh.position.set(pos.x, pos.y, pos.z);
|
394 |
+
mesh.rotation.set(rot.x, rot.y, rot.z);
|
395 |
+
mesh.scale.set(scale.x, scale.y, scale.z);
|
396 |
+
mesh.castShadow = true; mesh.receiveShadow = true;
|
397 |
+
return mesh;
|
398 |
+
}
|
399 |
+
|
400 |
+
function setupLighting(type = 'default') {
|
401 |
+
currentLights.forEach(light => { if (light.parent) light.parent.remove(light); if(scene.children.includes(light)) scene.remove(light); });
|
402 |
+
currentLights = [];
|
403 |
+
|
404 |
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); // Brighter ambient for kid-friendly
|
405 |
+
scene.add(ambientLight);
|
406 |
+
currentLights.push(ambientLight);
|
407 |
+
|
408 |
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); // Brighter directional
|
409 |
+
directionalLight.position.set(8, 12, 10);
|
410 |
+
directionalLight.castShadow = true;
|
411 |
+
directionalLight.shadow.mapSize.set(1024, 1024);
|
412 |
+
directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 50;
|
413 |
+
const sb = 25;
|
414 |
+
directionalLight.shadow.camera.left = -sb; directionalLight.shadow.camera.right = sb;
|
415 |
+
directionalLight.shadow.camera.top = sb; directionalLight.shadow.camera.bottom = -sb;
|
416 |
+
directionalLight.shadow.bias = -0.0005;
|
417 |
+
scene.add(directionalLight);
|
418 |
+
currentLights.push(directionalLight);
|
419 |
+
}
|
420 |
+
|
421 |
+
function createProceduralAssembly(params) {
|
422 |
+
console.log("Creating procedural assembly with params:", params);
|
423 |
+
const group = new THREE.Group();
|
424 |
+
const {
|
425 |
+
baseShape = 'ground_grass', baseSize = 20,
|
426 |
+
mainShapes = ['boxy_chunk'], accents = ['pointy_cone'],
|
427 |
+
count = 10, scaleRange = [0.5, 1.5],
|
428 |
+
colorTheme = [MAT.stone_grey, MAT.wood_light, MAT.leaf_green],
|
429 |
+
arrangement = 'scatter',
|
430 |
+
stackHeight = 5,
|
431 |
+
clusterRadius = 5,
|
432 |
+
patchPos = {x:0, y:0, z:0}, patchRadius = 3
|
433 |
+
} = params;
|
434 |
+
|
435 |
+
let baseMesh;
|
436 |
+
if (baseShape.startsWith('ground_')) {
|
437 |
+
const groundMat = MAT[baseShape] || MAT.ground_grass;
|
438 |
+
const groundGeo = new THREE.PlaneGeometry(baseSize, baseSize);
|
439 |
+
baseMesh = new THREE.Mesh(groundGeo, groundMat);
|
440 |
+
baseMesh.rotation.x = -Math.PI / 2; baseMesh.position.y = 0;
|
441 |
+
baseMesh.receiveShadow = true; baseMesh.castShadow = false;
|
442 |
+
baseMesh.userData.isGround = true; // Mark for placement/raycasting
|
443 |
+
group.add(baseMesh);
|
444 |
+
} else {
|
445 |
+
const baseGeoFunc = SHAPE_GENERATORS[baseShape] || SHAPE_GENERATORS['flat_plate'];
|
446 |
+
const baseGeo = baseGeoFunc(baseSize);
|
447 |
+
baseMesh = createMesh(baseGeo, colorTheme[0] || MAT.stone_grey, {y:0.1});
|
448 |
+
baseMesh.receiveShadow = true; baseMesh.castShadow = false;
|
449 |
+
baseMesh.userData.isGround = true; // Allow placing on non-plane bases
|
450 |
+
group.add(baseMesh);
|
451 |
+
}
|
452 |
+
|
453 |
+
const allShapes = [...mainShapes, ...accents];
|
454 |
+
let lastY = 0.1; // Start stacking slightly above base
|
455 |
+
let stackCount = 0;
|
456 |
+
|
457 |
+
for (let i = 0; i < count; i++) {
|
458 |
+
if(allShapes.length === 0) break;
|
459 |
+
const shapeKey = allShapes[Math.floor(Math.random() * allShapes.length)];
|
460 |
+
const geoFunc = SHAPE_GENERATORS[shapeKey];
|
461 |
+
if (!geoFunc) { console.warn(`Shape generator missing: ${shapeKey}`); continue; }
|
462 |
+
|
463 |
+
const scaleFactor = scaleRange[0] + Math.random() * (scaleRange[1] - scaleRange[0]);
|
464 |
+
const geometry = geoFunc(scaleFactor);
|
465 |
+
const material = colorTheme[Math.floor(Math.random() * colorTheme.length)];
|
466 |
+
const mesh = createMesh(geometry, material);
|
467 |
+
|
468 |
+
geometry.computeBoundingBox(); // Needed for height calculation
|
469 |
+
let height = 0;
|
470 |
+
if (geometry.boundingBox) {
|
471 |
+
height = (geometry.boundingBox.max.y - geometry.boundingBox.min.y) * mesh.scale.y;
|
472 |
+
} else {
|
473 |
+
console.warn("Geometry missing boundingBox", geometry);
|
474 |
+
height = scaleFactor; // Estimate height if bbox failed
|
475 |
+
}
|
476 |
+
height = Math.max(0.1, height); // Ensure minimum height
|
477 |
+
|
478 |
+
|
479 |
+
let position = { x:0, y:0, z:0 };
|
480 |
+
let isValidPosition = false; // Flag to check if position calculation succeeded
|
481 |
+
|
482 |
+
switch(arrangement) {
|
483 |
+
case 'stack':
|
484 |
+
if (stackCount < stackHeight) {
|
485 |
+
position.y = lastY + height / 2;
|
486 |
+
position.x = (Math.random() - 0.5) * 0.5 * scaleFactor;
|
487 |
+
position.z = (Math.random() - 0.5) * 0.5 * scaleFactor;
|
488 |
+
lastY += height * 0.9;
|
489 |
+
stackCount++;
|
490 |
+
isValidPosition = true;
|
491 |
+
}
|
492 |
+
break;
|
493 |
+
case 'center_stack':
|
494 |
+
if (stackCount < stackHeight) {
|
495 |
+
position.y = lastY + height / 2;
|
496 |
+
lastY += height * 0.95;
|
497 |
+
stackCount++;
|
498 |
+
isValidPosition = true;
|
499 |
+
}
|
500 |
+
break;
|
501 |
+
case 'cluster':
|
502 |
+
const angle = Math.random() * Math.PI * 2;
|
503 |
+
const radius = Math.random() * clusterRadius;
|
504 |
+
position.x = Math.cos(angle) * radius;
|
505 |
+
position.z = Math.sin(angle) * radius;
|
506 |
+
position.y = height / 2;
|
507 |
+
isValidPosition = true;
|
508 |
+
break;
|
509 |
+
case 'patch':
|
510 |
+
const pAngle = Math.random() * Math.PI * 2;
|
511 |
+
const pRadius = Math.random() * patchRadius;
|
512 |
+
position.x = patchPos.x + Math.cos(pAngle) * pRadius;
|
513 |
+
position.z = patchPos.z + Math.sin(pAngle) * pRadius;
|
514 |
+
position.y = (patchPos.y || 0) + height / 2;
|
515 |
+
isValidPosition = true;
|
516 |
+
break;
|
517 |
+
case 'scatter':
|
518 |
+
default:
|
519 |
+
position.x = (Math.random() - 0.5) * baseSize * 0.8;
|
520 |
+
position.z = (Math.random() - 0.5) * baseSize * 0.8;
|
521 |
+
position.y = height / 2;
|
522 |
+
isValidPosition = true;
|
523 |
+
break;
|
524 |
+
}
|
525 |
+
|
526 |
+
if (!isValidPosition) continue; // Skip if arrangement logic failed (e.g., stack full)
|
527 |
+
|
528 |
+
mesh.position.set(position.x, position.y, position.z);
|
529 |
+
mesh.rotation.set(
|
530 |
+
Math.random() * (arrangement.includes('stack') ? 0.2 : Math.PI),
|
531 |
+
Math.random() * Math.PI * 2,
|
532 |
+
Math.random() * (arrangement.includes('stack') ? 0.2 : Math.PI)
|
533 |
+
);
|
534 |
+
|
535 |
+
if (Math.random() < 0.3) {
|
536 |
+
mesh.userData.rotSpeed = (Math.random() - 0.5) * 0.4;
|
537 |
+
mesh.userData.bobSpeed = 1 + Math.random();
|
538 |
+
mesh.userData.bobAmount = 0.05 + Math.random() * 0.05;
|
539 |
+
mesh.userData.startY = mesh.position.y;
|
540 |
+
mesh.userData.update = (time, delta) => {
|
541 |
+
mesh.rotation.y += mesh.userData.rotSpeed * delta;
|
542 |
+
mesh.position.y = mesh.userData.startY + Math.sin(time * mesh.userData.bobSpeed) * mesh.userData.bobAmount;
|
543 |
+
};
|
544 |
+
}
|
545 |
+
group.add(mesh);
|
546 |
+
}
|
547 |
+
console.log(`Assembly created with ${group.children.length -1} procedural objects.`); // -1 for base
|
548 |
+
return group;
|
549 |
+
}
|
550 |
+
|
551 |
+
function updateScene(assemblyParams) {
|
552 |
+
console.log("updateScene called");
|
553 |
+
if (currentSceneGroup) {
|
554 |
+
currentSceneGroup.traverse(child => {
|
555 |
+
if (child.isMesh) {
|
556 |
+
if(child.geometry) child.geometry.dispose();
|
557 |
+
}
|
558 |
+
});
|
559 |
+
scene.remove(currentSceneGroup);
|
560 |
+
currentSceneGroup = null;
|
561 |
+
}
|
562 |
+
setupLighting('default'); // Use default lighting for simplicity now
|
563 |
+
|
564 |
+
if (!assemblyParams) {
|
565 |
+
console.warn("No assemblyParams provided, creating default scene.");
|
566 |
+
assemblyParams = { baseShape: 'ground_dirt', count: 5, mainShapes: ['boxy_chunk'] };
|
567 |
+
}
|
568 |
+
|
569 |
+
try {
|
570 |
+
currentSceneGroup = createProceduralAssembly(assemblyParams);
|
571 |
+
scene.add(currentSceneGroup);
|
572 |
+
console.log("New scene group added.");
|
573 |
+
} catch (error) {
|
574 |
+
console.error("Error creating procedural assembly:", error);
|
575 |
+
// Add error indicator?
|
576 |
+
}
|
577 |
+
}
|
578 |
+
|
579 |
+
function startGame() {
|
580 |
+
console.log("startGame called.");
|
581 |
+
const defaultChar = {
|
582 |
+
name: "Player",
|
583 |
+
stats: { hp: 25, maxHp: 25, xp: 0, strength: 12, dexterity: 11, constitution: 13, intelligence: 9, wisdom: 10, charisma: 8 },
|
584 |
+
inventory: ["Sturdy Stick"]
|
585 |
+
};
|
586 |
+
gameState = {
|
587 |
+
currentPageId: 1,
|
588 |
+
character: JSON.parse(JSON.stringify(defaultChar)),
|
589 |
+
combat: null // Ensure combat is null at start
|
590 |
+
};
|
591 |
+
renderPage(gameState.currentPageId);
|
592 |
+
console.log("startGame finished.");
|
593 |
+
}
|
594 |
+
|
595 |
+
function handleChoiceClick(choiceData) {
|
596 |
+
console.log("Choice clicked:", choiceData);
|
597 |
+
currentMessage = "";
|
598 |
+
|
599 |
+
let nextPageId = parseInt(choiceData.next);
|
600 |
+
const currentPageData = gameData[gameState.currentPageId];
|
601 |
+
const option = currentPageData?.options?.find(opt => opt.next === nextPageId || (opt.action === choiceData.action && opt.enemy === choiceData.enemy)); // Find the specific option
|
602 |
+
|
603 |
+
// 1. Check Requirements
|
604 |
+
if (option && option.requires) {
|
605 |
+
if (!gameState.character.inventory.includes(option.requires)) {
|
606 |
+
currentMessage = `<p class="message message-warning">You need a ${option.requires} for that!</p>`;
|
607 |
+
renderPage(gameState.currentPageId); // Re-render current page with message
|
608 |
+
return;
|
609 |
+
}
|
610 |
+
if (option.consume) {
|
611 |
+
gameState.character.inventory = gameState.character.inventory.filter(i => i !== option.requires);
|
612 |
+
currentMessage += `<p class="message message-item">Used your ${option.requires}.</p>`;
|
613 |
+
}
|
614 |
+
}
|
615 |
+
|
616 |
+
// 2. Handle Actions (Combat, Checks) before transitioning
|
617 |
+
if (option && option.action === 'triggerCombat' && option.enemy) {
|
618 |
+
startCombat(option.enemy, option.nextOnWin, option.nextOnLoss);
|
619 |
+
return; // Combat handles the next transition
|
620 |
+
}
|
621 |
+
if (option && option.check) {
|
622 |
+
performSkillCheck(option.check, option.next, option.onFailure);
|
623 |
+
return; // Skill check handles the next transition
|
624 |
+
}
|
625 |
+
|
626 |
+
// 3. Apply Rewards (if navigating directly)
|
627 |
+
if (option && option.reward) {
|
628 |
+
applyReward(option.reward);
|
629 |
+
}
|
630 |
+
|
631 |
+
// 4. Transition to Next Page (if not handled by action/check)
|
632 |
+
const targetPageData = gameData[nextPageId];
|
633 |
+
if (!targetPageData) {
|
634 |
+
console.error(`Invalid next page ID: ${nextPageId}`);
|
635 |
+
currentMessage += `<p class="message message-warning">That path is mysteriously blocked!</p>`;
|
636 |
+
nextPageId = gameState.currentPageId;
|
637 |
+
}
|
638 |
+
|
639 |
+
gameState.currentPageId = nextPageId;
|
640 |
+
renderPage(nextPageId);
|
641 |
+
}
|
642 |
+
|
643 |
+
function performSkillCheck(checkData, successPageId, failurePageId) {
|
644 |
+
const {stat, dc} = checkData;
|
645 |
+
const baseStat = gameState.character.stats[stat] || 10;
|
646 |
+
const modifier = Math.floor((baseStat - 10) / 2);
|
647 |
+
const roll = Math.floor(Math.random() * 20) + 1;
|
648 |
+
const total = roll + modifier;
|
649 |
+
const success = total >= dc;
|
650 |
+
|
651 |
+
console.log(`Skill Check ${stat}: Roll ${roll} + Mod ${modifier} = ${total} vs DC ${dc}`);
|
652 |
+
currentMessage += `<p class="message message-info"><em>Rolling ${stat}... (Rolled ${total} vs DC ${dc})</em></p>`;
|
653 |
+
displayDiceRoll(roll, success); // Show the roll
|
654 |
+
|
655 |
+
if (success) {
|
656 |
+
currentMessage += `<p class="message message-success"><em>Success!</em></p>`;
|
657 |
+
gameState.currentPageId = successPageId;
|
658 |
+
} else {
|
659 |
+
currentMessage += `<p class="message message-failure"><em>Failed!</em></p>`;
|
660 |
+
gameState.currentPageId = failurePageId;
|
661 |
+
}
|
662 |
+
// Add short delay before rendering next page to see dice roll
|
663 |
+
setTimeout(() => renderPage(gameState.currentPageId), 1500);
|
664 |
+
}
|
665 |
+
|
666 |
+
function applyReward(rewardData) {
|
667 |
+
if(rewardData.addItem && itemsData[rewardData.addItem]) {
|
668 |
+
if (!gameState.character.inventory.includes(rewardData.addItem)) {
|
669 |
+
gameState.character.inventory.push(rewardData.addItem);
|
670 |
+
currentMessage += `<p class="message message-item">You found a ${rewardData.addItem}!</p>`;
|
671 |
+
} else {
|
672 |
+
currentMessage += `<p class="message message-info">You found another ${rewardData.addItem}, but can't carry more.</p>`;
|
673 |
+
}
|
674 |
+
}
|
675 |
+
if(rewardData.xp) {
|
676 |
+
gameState.character.stats.xp += rewardData.xp;
|
677 |
+
currentMessage += `<p class="message message-xp">You gained ${rewardData.xp} XP!</p>`;
|
678 |
+
}
|
679 |
+
if(rewardData.hpGain) {
|
680 |
+
gameState.character.stats.hp = Math.min(gameState.character.stats.maxHp, gameState.character.stats.hp + rewardData.hpGain);
|
681 |
+
currentMessage += `<p class="message message-success">You feel better! (+${rewardData.hpGain} HP)</p>`;
|
682 |
+
}
|
683 |
+
if(rewardData.statGain) { // Example: { statGain: { maxHp: 5 } }
|
684 |
+
for (const stat in rewardData.statGain) {
|
685 |
+
if (gameState.character.stats.hasOwnProperty(stat)) {
|
686 |
+
gameState.character.stats[stat] += rewardData.statGain[stat];
|
687 |
+
currentMessage += `<p class="message message-success">Your ${stat} increased by ${rewardData.statGain[stat]}!</p>`;
|
688 |
+
// If maxHp increases, maybe heal too?
|
689 |
+
if (stat === 'maxHp' && rewardData.statGain[stat] > 0) {
|
690 |
+
gameState.character.stats.hp += rewardData.statGain[stat];
|
691 |
+
}
|
692 |
+
}
|
693 |
+
}
|
694 |
+
}
|
695 |
+
}
|
696 |
+
|
697 |
+
|
698 |
+
function renderPage(pageId) {
|
699 |
+
console.log(`Rendering page ${pageId}`);
|
700 |
+
const pageData = gameData[pageId];
|
701 |
+
|
702 |
+
if (!pageData) { /* Error handling as before */ return; }
|
703 |
+
|
704 |
+
storyTitleElement.textContent = pageData.title || "An Unnamed Place";
|
705 |
+
storyContentElement.innerHTML = currentMessage + (pageData.content || "<p>...</p>");
|
706 |
+
choicesElement.innerHTML = '';
|
707 |
+
actionChoicesElement.innerHTML = ''; // Clear action choices too
|
708 |
+
|
709 |
+
// Setup Navigation Buttons (Always show, disable if unavailable)
|
710 |
+
const neighbors = getZoneNeighbors(gameState.currentZoneId || getZoneId(1,1)); // Use placeholder if needed
|
711 |
+
const directions = {'north': 'North', 'south': 'South', 'east': 'East', 'west': 'West'};
|
712 |
+
for(const dir in directions) {
|
713 |
+
const neighborId = neighbors[dir]; // Note: This uses grid logic, pageData uses .next
|
714 |
+
const button = document.createElement('button');
|
715 |
+
button.classList.add('choice-button');
|
716 |
+
button.textContent = `Go ${directions[dir]}`;
|
717 |
+
// For now, navigation uses pageData.options, not grid neighbors
|
718 |
+
button.disabled = true; // Disable all grid nav by default
|
719 |
+
choicesElement.appendChild(button);
|
720 |
+
}
|
721 |
+
|
722 |
+
|
723 |
+
// Add options from pageData to Action Choices area
|
724 |
+
if (pageData.options && pageData.options.length > 0) {
|
725 |
+
pageData.options.forEach(option => {
|
726 |
+
const button = document.createElement('button');
|
727 |
+
button.classList.add('choice-button', 'action'); // Mark as action
|
728 |
+
button.textContent = option.text;
|
729 |
+
|
730 |
+
// Disable if required item is missing
|
731 |
+
let requirementMet = true;
|
732 |
+
if (option.requires && !gameState.character.inventory.includes(option.requires)) {
|
733 |
+
requirementMet = false;
|
734 |
+
button.title = `Requires: ${option.requires}`;
|
735 |
+
}
|
736 |
+
button.disabled = !requirementMet;
|
737 |
+
|
738 |
+
button.onclick = () => handleChoiceClick(option); // Pass full option data
|
739 |
+
actionChoicesElement.appendChild(button);
|
740 |
+
});
|
741 |
+
} else {
|
742 |
+
actionChoicesElement.innerHTML = '<p><i>Nothing else to do here right now.</i></p>';
|
743 |
+
}
|
744 |
+
|
745 |
+
updateStatsDisplay();
|
746 |
+
updateInventoryDisplay();
|
747 |
+
updateActionInfo();
|
748 |
+
updateScene(pageData.assemblyParams);
|
749 |
+
}
|
750 |
+
window.handleChoiceClick = handleChoiceClick; // Make global
|
751 |
+
|
752 |
+
|
753 |
+
function updateStatsDisplay() {
|
754 |
+
if (!gameState.character || !statsElement) return;
|
755 |
+
const stats = gameState.character.stats;
|
756 |
+
const hpColor = stats.hp / stats.maxHp < 0.3 ? '#f88' : (stats.hp / stats.maxHp < 0.6 ? '#fd5' : '#8f8');
|
757 |
+
const statBonus = (statVal) => {
|
758 |
+
const mod = Math.floor((statVal - 10) / 2);
|
759 |
+
return `${statVal} (${mod >= 0 ? '+' : ''}${mod})`;
|
760 |
+
};
|
761 |
+
|
762 |
+
statsElement.innerHTML = `<strong>Stats:</strong>
|
763 |
+
<span style="color:${hpColor}">HP: ${stats.hp}/${stats.maxHp}</span> <span>XP: ${stats.xp}</span><br>
|
764 |
+
<span>Str: ${statBonus(stats.strength)}</span> <span>Dex: ${statBonus(stats.dexterity)}</span> <span>Con: ${statBonus(stats.constitution)}</span>
|
765 |
+
<span>Int: ${statBonus(stats.intelligence)}</span> <span>Wis: ${statBonus(stats.wisdom)}</span> <span>Cha: ${statBonus(stats.charisma)}</span>`;
|
766 |
+
}
|
767 |
+
|
768 |
+
function updateInventoryDisplay() {
|
769 |
+
if (!gameState.character || !inventoryElement) return;
|
770 |
+
let invHtml = '<strong>Inventory:</strong> ';
|
771 |
+
if (gameState.character.inventory.length === 0) {
|
772 |
+
invHtml += '<em>Empty</em>';
|
773 |
+
} else {
|
774 |
+
gameState.character.inventory.forEach(item => {
|
775 |
+
const itemDef = itemsData[item] || { type: 'unknown', description: '???' };
|
776 |
+
const itemClass = `item-${itemDef.type || 'unknown'}`;
|
777 |
+
invHtml += `<span class="item-tag ${itemClass}" title="${itemDef.description}">${item}</span>`;
|
778 |
+
});
|
779 |
+
}
|
780 |
+
inventoryElement.innerHTML = invHtml;
|
781 |
+
}
|
782 |
+
|
783 |
+
function updateActionInfo() {
|
784 |
+
if (!actionInfoElement || !gameState ) return;
|
785 |
+
const mode = gameState.combat?.active ? "Combat!" : "Exploring";
|
786 |
+
actionInfoElement.textContent = `Location: ${gameData[gameState.currentPageId]?.title || 'Unknown'} | ${mode}`;
|
787 |
+
}
|
788 |
+
|
789 |
+
// --- Combat ---
|
790 |
+
function startCombat(enemyTypeId, nextOnWin, nextOnLoss) {
|
791 |
+
const enemyBase = enemyData[enemyTypeId];
|
792 |
+
if (!enemyBase) { console.error("Unknown enemy type:", enemyTypeId); return; }
|
793 |
+
gameState.combat = {
|
794 |
+
active: true, enemyId: enemyTypeId, enemyName: enemyBase.name,
|
795 |
+
enemyHp: enemyBase.hp, enemyMaxHp: enemyBase.hp,
|
796 |
+
enemyDefense: enemyBase.defense, enemyAttackBonus: 2, // Simplified bonus
|
797 |
+
enemyDamageDice: enemyBase.damageDice || 4, enemyXp: enemyBase.xp,
|
798 |
+
enemyDrops: enemyBase.drops || [],
|
799 |
+
nextOnWin: nextOnWin, nextOnLoss: nextOnLoss
|
800 |
+
};
|
801 |
+
currentMessage = `<p class="message message-combat">A ${enemyBase.name} blocks your path!</p>`;
|
802 |
+
renderCurrentPageUI(); // Update UI to show combat mode
|
803 |
+
}
|
804 |
+
|
805 |
+
function handleCombatAction(action) {
|
806 |
+
if (!gameState.combat?.active) return;
|
807 |
+
currentMessage = ""; // Clear previous messages
|
808 |
+
|
809 |
+
if (action === 'attack') {
|
810 |
+
// Player Attack
|
811 |
+
const playerRoll = Math.floor(Math.random() * 20) + 1;
|
812 |
+
const playerAtkBonus = Math.floor((gameState.character.stats.strength - 10) / 2);
|
813 |
+
const playerTotalAttack = playerRoll + playerAtkBonus;
|
814 |
+
const playerHit = playerRoll === 20 || (playerRoll !== 1 && playerTotalAttack >= gameState.combat.enemyDefense);
|
815 |
+
|
816 |
+
displayDiceRoll(playerRoll, playerHit); // Show roll
|
817 |
+
|
818 |
+
if (playerHit) {
|
819 |
+
const weapon = gameState.character.inventory.find(i => itemsData[i]?.type === 'weapon');
|
820 |
+
const baseDamage = itemsData[weapon]?.baseDamage || 2; // Unarmed = 1d2 ?
|
821 |
+
const damageRoll = Math.max(1, Math.floor(Math.random() * baseDamage) + 1 + playerAtkBonus);
|
822 |
+
gameState.combat.enemyHp -= damageRoll;
|
823 |
+
currentMessage += `<p class="message message-success">You hit the ${gameState.combat.enemyName} for ${damageRoll} damage! (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
|
824 |
+
} else {
|
825 |
+
currentMessage += `<p class="message message-failure">You missed the ${gameState.combat.enemyName}. (Rolled ${playerTotalAttack} vs AC ${gameState.combat.enemyDefense})</p>`;
|
826 |
+
}
|
827 |
+
|
828 |
+
// Check Enemy Defeat
|
829 |
+
if (gameState.combat.enemyHp <= 0) {
|
830 |
+
currentMessage += `<p class="message message-success"><b>You defeated the ${gameState.combat.enemyName}!</b></p>`;
|
831 |
+
applyReward({ xp: gameState.combat.enemyXp });
|
832 |
+
// Handle Drops (Simplified: drop one random item from list)
|
833 |
+
if (gameState.combat.enemyDrops.length > 0) {
|
834 |
+
const droppedItemName = gameState.combat.enemyDrops[Math.floor(Math.random() * gameState.combat.enemyDrops.length)];
|
835 |
+
dropItemInScene(droppedItemName, new THREE.Vector3(Math.random()*2-1, 0.2, Math.random()*2-1));
|
836 |
+
currentMessage += `<p class="message message-item"><em>The ${gameState.combat.enemyName} dropped a ${droppedItemName}! Click it to pick up.</em></p>`;
|
837 |
+
}
|
838 |
+
const winPage = gameState.combat.nextOnWin;
|
839 |
+
gameState.combat = null; // End combat
|
840 |
+
gameState.currentPageId = winPage; // Go to win page
|
841 |
+
renderPage(gameState.currentPageId);
|
842 |
+
return;
|
843 |
+
}
|
844 |
+
|
845 |
+
// Enemy Attack (if enemy still alive)
|
846 |
+
const enemyRoll = Math.floor(Math.random() * 20) + 1;
|
847 |
+
const enemyTotalAttack = enemyRoll + gameState.combat.enemyAttackBonus;
|
848 |
+
const playerAC = 10 + Math.floor((gameState.character.stats.dexterity - 10) / 2);
|
849 |
+
const enemyHit = enemyRoll === 20 || (enemyRoll !== 1 && enemyTotalAttack >= playerAC);
|
850 |
+
|
851 |
+
// Add slight delay for enemy roll display maybe?
|
852 |
+
setTimeout(() => displayDiceRoll(enemyRoll, enemyHit), 500); // Display enemy roll slightly later
|
853 |
+
|
854 |
+
if (enemyHit) {
|
855 |
+
const damageRoll = Math.max(1, Math.floor(Math.random() * gameState.combat.enemyDamageDice) + 1);
|
856 |
+
gameState.character.stats.hp -= damageRoll;
|
857 |
+
currentMessage += `<p class="message message-failure">The ${gameState.combat.enemyName} hits you for ${damageRoll} damage! (Rolled ${enemyTotalAttack} vs AC ${playerAC})</p>`;
|
858 |
+
if (gameState.character.stats.hp <= 0) {
|
859 |
+
currentMessage += `<p class="message message-failure"><b>You have been defeated!</b></p>`;
|
860 |
+
const lossPage = gameState.combat.nextOnLoss;
|
861 |
+
gameState.combat = null; // End combat
|
862 |
+
gameState.currentPageId = lossPage; // Go to loss page
|
863 |
+
setTimeout(() => renderPage(gameState.currentPageId), 1500); // Delay transition after showing roll
|
864 |
+
return;
|
865 |
+
}
|
866 |
+
} else {
|
867 |
+
currentMessage += `<p class="message message-info">The ${gameState.combat.enemyName} misses you. (Rolled ${enemyTotalAttack} vs AC ${playerAC})</p>`;
|
868 |
+
}
|
869 |
+
setTimeout(() => renderPage(gameState.currentPageId), 1500); // Re-render UI after delay
|
870 |
+
}
|
871 |
+
}
|
872 |
+
window.handleCombatAction = handleCombatAction; // Make global
|
873 |
+
|
874 |
+
function displayDiceRoll(result, success) {
|
875 |
+
if (!threeFont) { console.warn("Font not loaded for dice roll."); return; }
|
876 |
+
activeTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); activeTimeouts = [];
|
877 |
+
scene.children.filter(c => c.userData?.isDiceRoll).forEach(c => scene.remove(c)); // Remove old rolls from main scene
|
878 |
+
|
879 |
+
const textGeo = new TextGeometry(result.toString(), { font: threeFont, size: 1.0, height: 0.1, curveSegments: 4 });
|
880 |
+
textGeo.computeBoundingBox();
|
881 |
+
const textWidth = textGeo.boundingBox.max.x - textGeo.boundingBox.min.x;
|
882 |
+
const textMat = MAT.text_material.clone();
|
883 |
+
textMat.color.setHex(success ? 0x88ff88 : 0xff8888);
|
884 |
+
textMat.transparent = true; // Needed for opacity fade
|
885 |
+
|
886 |
+
const textMesh = new THREE.Mesh(textGeo, textMat);
|
887 |
+
textMesh.userData.isDiceRoll = true;
|
888 |
+
|
889 |
+
const distance = 6;
|
890 |
+
const textPos = camera.position.clone().add(camera.getWorldDirection(new THREE.Vector3()).multiplyScalar(distance));
|
891 |
+
textMesh.position.copy(textPos);
|
892 |
+
textMesh.position.y += 1.0; // Adjust vertical position
|
893 |
+
textMesh.position.x -= textWidth / 2 * textMesh.scale.x; // Center horizontally
|
894 |
+
textMesh.quaternion.copy(camera.quaternion);
|
895 |
+
|
896 |
+
scene.add(textMesh);
|
897 |
+
|
898 |
+
const duration = 2.5; // seconds
|
899 |
+
const startTime = clock.getElapsedTime();
|
900 |
+
textMesh.userData.startTime = startTime;
|
901 |
+
textMesh.userData.update = (time) => {
|
902 |
+
const elapsed = time - textMesh.userData.startTime;
|
903 |
+
if (elapsed >= duration) {
|
904 |
+
if (textMesh.parent) textMesh.parent.remove(textMesh);
|
905 |
+
delete textMesh.userData.update;
|
906 |
+
} else {
|
907 |
+
textMesh.position.y += 0.01;
|
908 |
+
textMesh.material.opacity = 1.0 - (elapsed / duration);
|
909 |
+
}
|
910 |
+
};
|
911 |
+
}
|
912 |
+
|
913 |
+
function dropItemInScene(itemName, positionOffset = new THREE.Vector3(0, 0, 0)) {
|
914 |
+
const currentGroup = currentSceneGroup; // Items are dropped relative to the current scene group origin
|
915 |
+
if (!currentGroup || !itemsData[itemName]) return;
|
916 |
+
|
917 |
+
const itemDef = itemsData[itemName];
|
918 |
+
const itemGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4); // Simple representation
|
919 |
+
const itemMat = MAT.simple.clone();
|
920 |
+
if(itemDef.type === 'weapon') itemMat.color.setHex(0xcc6666);
|
921 |
+
else if(itemDef.type === 'consumable') itemMat.color.setHex(0xcc9966);
|
922 |
+
else if(itemDef.type === 'quest') itemMat.color.setHex(0xcccc66);
|
923 |
+
else itemMat.color.setHex(0xaaaaee);
|
924 |
+
|
925 |
+
const dropPos = new THREE.Vector3(positionOffset.x, 0.2, positionOffset.z);
|
926 |
+
const droppedMesh = createMesh(itemGeo, itemMat, dropPos);
|
927 |
+
droppedMesh.userData = { isPickupable: true, itemName: itemName, description: `Dropped ${itemName}` };
|
928 |
+
currentGroup.add(droppedMesh); // Add item to the current scene group
|
929 |
+
console.log(`Dropped ${itemName} in scene`);
|
930 |
+
}
|
931 |
+
|
932 |
+
function pickupItem() { // Re-enabled pickup
|
933 |
+
if (gameState.combat?.active) return; // No pickup during combat
|
934 |
+
|
935 |
+
raycaster.setFromCamera(mouse, camera);
|
936 |
+
const currentGroup = currentSceneGroup;
|
937 |
+
if (!currentGroup) return;
|
938 |
+
|
939 |
+
const pickupables = [];
|
940 |
+
currentGroup.traverseVisible(child => { // Check only visible objects in current group
|
941 |
+
if (child.userData.isPickupable) pickupables.push(child);
|
942 |
+
});
|
943 |
+
|
944 |
+
const intersects = raycaster.intersectObjects(pickupables, false);
|
945 |
+
|
946 |
+
if (intersects.length > 0) {
|
947 |
+
const clickedObject = intersects[0].object;
|
948 |
+
const itemName = clickedObject.userData.itemName;
|
949 |
+
|
950 |
+
if (itemName && itemsData[itemName]) {
|
951 |
+
console.log(`Picked up: ${itemName}`);
|
952 |
+
currentMessage = `<p class="message message-item"><em>Picked up: ${itemName}</em></p>`;
|
953 |
+
|
954 |
+
if (!gameState.character.inventory.includes(itemName)) {
|
955 |
+
gameState.character.inventory.push(itemName);
|
956 |
+
} else {
|
957 |
+
currentMessage += `<p class="message message-info"><em>(Cannot carry more ${itemName})</em></p>`;
|
958 |
+
}
|
959 |
+
|
960 |
+
// Remove object fully after pickup
|
961 |
+
if(clickedObject.parent) clickedObject.parent.remove(clickedObject);
|
962 |
+
if(clickedObject.geometry) clickedObject.geometry.dispose();
|
963 |
+
// We assume material is shared from MAT, so don't dispose
|
964 |
+
|
965 |
+
renderCurrentPageUI(); // Update inventory and messages
|
966 |
+
}
|
967 |
+
}
|
968 |
+
}
|
969 |
+
|
970 |
+
|
971 |
+
document.addEventListener('DOMContentLoaded', () => {
|
972 |
+
console.log("DOM Ready - Initializing Wacky D&D Shapes Adventure!");
|
973 |
+
try {
|
974 |
+
initThreeJS();
|
975 |
+
if (!scene || !camera || !renderer) throw new Error("Three.js failed to initialize.");
|
976 |
+
loadFontAndStart(); // Load font, then start game
|
977 |
+
console.log("Initialization sequence started.");
|
978 |
+
} catch (error) {
|
979 |
+
console.error("Initialization failed:", error);
|
980 |
+
storyTitleElement.textContent = "Initialization Error";
|
981 |
+
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>`;
|
982 |
+
if(sceneContainer) sceneContainer.innerHTML = '<p style="color:red; padding: 20px;">3D Scene Failed</p>';
|
983 |
+
document.getElementById('stats-inventory-container').style.display = 'none';
|
984 |
+
document.getElementById('choices-container').style.display = 'none';
|
985 |
+
}
|
986 |
+
});
|
987 |
+
|
988 |
+
</script>
|
989 |
+
</body>
|
990 |
+
</html>
|