Spaces:
Running
Running
Update game.js
Browse files
game.js
CHANGED
@@ -1,634 +1,520 @@
|
|
1 |
import * as THREE from 'three';
|
2 |
-
|
|
|
3 |
|
4 |
// --- DOM Elements ---
|
5 |
const sceneContainer = document.getElementById('scene-container');
|
6 |
-
const storyTitleElement = document.getElementById('story-title');
|
7 |
-
const storyContentElement = document.getElementById('story-content');
|
8 |
-
const choicesElement = document.getElementById('choices');
|
9 |
const statsElement = document.getElementById('stats-display');
|
10 |
const inventoryElement = document.getElementById('inventory-display');
|
|
|
11 |
|
12 |
// --- Config ---
|
13 |
-
const ROOM_SIZE = 10;
|
14 |
const WALL_HEIGHT = 4;
|
15 |
const WALL_THICKNESS = 0.5;
|
16 |
-
const
|
|
|
|
|
|
|
|
|
|
|
17 |
|
18 |
// --- Three.js Setup ---
|
19 |
let scene, camera, renderer;
|
20 |
-
let
|
21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
function initThreeJS() {
|
24 |
scene = new THREE.Scene();
|
25 |
-
scene.background = new THREE.Color(0x111111);
|
26 |
-
scene.fog = new THREE.Fog(0x111111, CAMERA_HEIGHT * 1.5, CAMERA_HEIGHT * 3); // Add fog for depth
|
27 |
|
28 |
camera = new THREE.PerspectiveCamera(60, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000);
|
29 |
-
//
|
30 |
-
camera.position.set(0,
|
31 |
camera.lookAt(0, 0, 0);
|
32 |
|
33 |
renderer = new THREE.WebGLRenderer({ antialias: true });
|
34 |
renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
|
35 |
-
renderer.shadowMap.enabled = true;
|
36 |
sceneContainer.appendChild(renderer.domElement);
|
37 |
|
38 |
// Lighting
|
39 |
-
const ambientLight = new THREE.AmbientLight(0xffffff, 0.
|
40 |
scene.add(ambientLight);
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
scene.add(
|
45 |
-
// We will move this light with the camera/player later
|
46 |
-
|
47 |
-
// Map Group
|
48 |
-
mapGroup = new THREE.Group();
|
49 |
-
scene.add(mapGroup);
|
50 |
|
51 |
-
//
|
52 |
-
|
53 |
-
|
54 |
-
// controls.target.set(0, 0, 0); // Ensure controls start looking at origin
|
55 |
|
56 |
window.addEventListener('resize', onWindowResize, false);
|
57 |
-
animate();
|
58 |
}
|
59 |
|
60 |
-
function
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
}
|
66 |
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
73 |
}
|
74 |
|
75 |
-
|
76 |
-
const
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
floor.position.set(x * ROOM_SIZE, 0, z * ROOM_SIZE);
|
102 |
-
floor.receiveShadow = true;
|
103 |
-
return floor;
|
104 |
}
|
105 |
|
106 |
-
function
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
wall.position.set(base_x + half_room, wall_y, base_z);
|
125 |
-
break;
|
126 |
-
case 'west':
|
127 |
-
wall = new THREE.Mesh(geometries.wall_side, materials.wall);
|
128 |
-
wall.position.set(base_x - half_room, wall_y, base_z);
|
129 |
-
break;
|
130 |
-
default: return null;
|
131 |
-
}
|
132 |
-
wall.castShadow = true;
|
133 |
-
wall.receiveShadow = true;
|
134 |
-
return wall;
|
135 |
}
|
136 |
|
137 |
-
function
|
138 |
-
const
|
139 |
-
const
|
140 |
-
|
141 |
-
// Example: Add a river feature
|
142 |
-
if (feature === 'river') {
|
143 |
-
mesh = new THREE.Mesh(geometries.river, materials.river);
|
144 |
-
mesh.rotation.x = -Math.PI / 2;
|
145 |
-
mesh.position.set(base_x, 0.05, base_z); // Slightly above floor
|
146 |
-
}
|
147 |
-
// Add more features: 'door_north', 'chest', 'tree' etc.
|
148 |
-
// For doors, you might modify/omit wall segments instead of adding an object
|
149 |
return mesh;
|
150 |
}
|
151 |
|
152 |
-
|
153 |
-
function
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
164 |
}
|
165 |
|
166 |
-
// ---
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
{
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
},
|
222 |
-
"7": { // River Path East
|
223 |
-
title: "Misty River", content: "<p>A spirit appears...</p>", illustration: "river-spirit",
|
224 |
-
mapX: 1, mapY: 1, type: 'forest', features: ['river', 'path_west'],
|
225 |
-
challenge: { title: "River Riddle", stat: "wisdom", difficulty: 6, success: 11, failure: 12 },
|
226 |
-
options: []
|
227 |
-
},
|
228 |
-
"8": { // Ruins Path West
|
229 |
-
title: "Forgotten Ruins", content: "<p>Whispers echo...</p>", illustration: "ancient-ruins",
|
230 |
-
mapX: -1, mapY: 1, type: 'ruins', features: ['path_east'],
|
231 |
-
challenge: { title: "Navigate Ruins", stat: "wisdom", difficulty: 5, success: 13, failure: 14 },
|
232 |
-
options: []
|
233 |
-
},
|
234 |
-
// --- Corresponding Challenge Outcomes ---
|
235 |
-
"9": { // Success Ambush (Continue North)
|
236 |
-
title: "Breaking Through", content: "<p>You fought them off!</p>", illustration: "forest-edge",
|
237 |
-
mapX: 0, mapY: 3, type: 'forest', features: ['path_north', 'path_south'], // Edge of forest
|
238 |
-
options: [ {text: "Cross the plains", next: 15} ] // To page 15 (fortress approach)
|
239 |
-
},
|
240 |
-
"10": { // Failure Ambush (Captured) -> Leads to different location potentially? Let's put it nearby for now.
|
241 |
-
title: "Captured!", content: "<p>They drag you to an outpost.</p>", illustration: "prisoner-cell",
|
242 |
-
mapX: 1, mapY: 2, type: 'cave', // Represent outpost as cave/cell
|
243 |
-
options: [ { text: "Wait...", next: 20 } ] // Link to page 20 logic
|
244 |
-
},
|
245 |
-
"11": { // Success Riddle
|
246 |
-
title: "Spirit's Blessing", content: "<p>The spirit blesses you.</p>", illustration: "spirit-blessing",
|
247 |
-
mapX: 1, mapY: 1, type: 'forest', features: ['river', 'path_west'], // Stay in same spot, get item/stat
|
248 |
-
addItem: "Water Spirit's Blessing", statIncrease: { stat: "wisdom", amount: 2 },
|
249 |
-
options: [ { text: "Continue journey", next: 15 } ] // Option to move on
|
250 |
-
},
|
251 |
-
"12": { // Failure Riddle
|
252 |
-
title: "Spirit's Wrath", content: "<p>Pulled underwater!</p>", illustration: "river-danger",
|
253 |
-
mapX: 1, mapY: 1, type: 'forest', features: ['river', 'path_west'], // Stay in same spot, lose HP
|
254 |
-
hpLoss: 8,
|
255 |
-
options: [ { text: "Struggle onwards", next: 15 } ]
|
256 |
-
},
|
257 |
-
"13": { // Success Ruins
|
258 |
-
title: "Ancient Allies", content: "<p>The spirits offer help.</p>", illustration: "ancient-spirits",
|
259 |
-
mapX: -1, mapY: 1, type: 'ruins', features: ['path_east'],
|
260 |
-
addItem: "Ancient Amulet", statIncrease: { stat: "wisdom", amount: 1 },
|
261 |
-
options: [ { text: "Accept amulet and continue", next: 15 } ]
|
262 |
-
},
|
263 |
-
"14": { // Failure Ruins
|
264 |
-
title: "Lost in Time", content: "<p>Exhausted from the maze.</p>", illustration: "lost-ruins",
|
265 |
-
mapX: -1, mapY: 1, type: 'ruins', features: ['path_east'],
|
266 |
-
hpLoss: 10,
|
267 |
-
options: [ { text: "Push onwards, weakened", next: 15 } ]
|
268 |
-
},
|
269 |
-
"15": { // Fortress Approach (example)
|
270 |
-
title: "The Looming Fortress", content: "<p>The dark fortress rises...</p>", illustration: "evil-fortress",
|
271 |
-
mapX: 0, mapY: 4, type: 'plains', // Barren plains
|
272 |
-
options: [ /* ... options to infiltrate ... */ {text: "Survey the area", next: 16} ]
|
273 |
-
},
|
274 |
-
// ... Add ALL other pages with mapX, mapY, type, features ...
|
275 |
-
"99": {
|
276 |
-
title: "Game Over", content: "<p>Your adventure ends here.</p>",
|
277 |
-
mapX: 0, mapY: 0, type: 'gameover', // Special type
|
278 |
-
options: [{ text: "Restart", next: 1 }],
|
279 |
-
illustration: "game-over",
|
280 |
-
gameOver: true
|
281 |
-
}
|
282 |
-
};
|
283 |
-
|
284 |
-
const itemsData = { // (Keep this data as before)
|
285 |
-
"Flaming Sword": { type: "weapon", description: "A fiery blade. +3 Attack.", attackBonus: 3 },
|
286 |
-
"Whispering Bow": { type: "weapon", description: "A silent bow. +2 Attack.", attackBonus: 2 },
|
287 |
-
// ... other items ...
|
288 |
-
"Secret Tunnel Map": { type: "quest", description: "Shows a hidden path" },
|
289 |
-
"Master Key": { type: "quest", description: "Unlocks many doors" },
|
290 |
-
"Ancient Amulet": { type: "armor", description: "Wards off some dark magic. +1 Defense", defenseBonus: 1}, // Made armor type
|
291 |
-
};
|
292 |
|
293 |
-
//
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
//
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
|
|
309 |
}
|
310 |
-
renderPage(gameState.currentPageId);
|
311 |
}
|
312 |
|
313 |
-
function
|
314 |
-
const
|
315 |
-
if (!
|
316 |
-
console.
|
317 |
-
// Handle error state more gracefully if needed
|
318 |
-
storyTitleElement.textContent = "Error";
|
319 |
-
storyContentElement.innerHTML = "<p>Adventure lost in the void!</p>";
|
320 |
-
choicesElement.innerHTML = ''; // Clear choices
|
321 |
-
addChoiceButton("Restart", 1); // Add only restart
|
322 |
-
updateScene(null); // Clear the scene or show error state
|
323 |
return;
|
324 |
}
|
325 |
|
326 |
-
//
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
}
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
hasAvailableOptions = true; // Allow restart
|
350 |
-
} else if (page.challenge) {
|
351 |
-
choicesElement.innerHTML = `<p><i>A challenge blocks your path...</i></p>`;
|
352 |
-
// Challenge resolved in handleChoiceClick *after* rendering this page
|
353 |
-
} else if (!hasAvailableOptions && !page.gameOver && !page.challenge) {
|
354 |
-
choicesElement.innerHTML = '<p><i>No further options available from here.</i></p>';
|
355 |
-
addChoiceButton("Restart Adventure", 1);
|
356 |
-
}
|
357 |
-
|
358 |
-
// Update 3D Scene based on current page's coordinates
|
359 |
-
updateScene(pageId);
|
360 |
}
|
361 |
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
button.disabled = disabled;
|
368 |
-
|
369 |
-
// Store data
|
370 |
-
button.dataset.nextPage = nextPage;
|
371 |
-
if (addItem) button.dataset.addItem = addItem;
|
372 |
-
|
373 |
-
// Create tooltip for disabled buttons based on requirement
|
374 |
-
if (disabled) {
|
375 |
-
if (requireItem) button.title = `Requires: ${requireItem}`;
|
376 |
-
else if (requireAnyItem) button.title = `Requires one of: ${requireAnyItem.join(', ')}`;
|
377 |
-
else button.title = `Cannot choose this option now.`;
|
378 |
}
|
379 |
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
385 |
}
|
386 |
|
|
|
|
|
387 |
|
388 |
-
|
389 |
-
|
390 |
-
const reqItem = option.requireItem;
|
391 |
-
if (reqItem && !inventory.includes(reqItem)) {
|
392 |
-
return false; // Specific item required but not present
|
393 |
-
}
|
394 |
-
const reqAnyItem = option.requireAnyItem;
|
395 |
-
if (reqAnyItem && !reqAnyItem.some(item => inventory.includes(item))) {
|
396 |
-
return false; // Required one item from list, but none present
|
397 |
-
}
|
398 |
-
return true; // All requirements met or no requirements
|
399 |
-
}
|
400 |
-
|
401 |
-
function handleChoiceClick(dataset) {
|
402 |
-
const nextPageId = parseInt(dataset.nextPage);
|
403 |
-
const itemToAdd = dataset.addItem;
|
404 |
|
405 |
-
if (
|
406 |
-
|
407 |
-
|
|
|
408 |
}
|
409 |
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
// Add feedback to player? Maybe via story text update?
|
415 |
}
|
416 |
|
417 |
-
//
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
console.error(`Data for page ${nextPageId} not found!`);
|
424 |
-
renderPage(99); // Go to game over page as fallback
|
425 |
-
return;
|
426 |
-
}
|
427 |
-
|
428 |
-
// Add coordinates to visited set
|
429 |
-
if (nextPageData.mapX !== undefined && nextPageData.mapY !== undefined) {
|
430 |
-
visitedCoords.add(`${nextPageData.mapX},${nextPageData.mapY}`);
|
431 |
-
}
|
432 |
-
|
433 |
|
434 |
-
|
435 |
-
|
|
|
|
|
436 |
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
console.log("Player died from HP loss!");
|
443 |
-
gameState.stats.hp = 0;
|
444 |
-
renderPage(99); // Go to a specific game over page ID
|
445 |
-
return;
|
446 |
-
}
|
447 |
-
}
|
448 |
-
// Apply stat increase defined on the *landing* page
|
449 |
-
if (nextPageData.statIncrease) {
|
450 |
-
const stat = nextPageData.statIncrease.stat;
|
451 |
-
const amount = nextPageData.statIncrease.amount;
|
452 |
-
if (gameState.stats.hasOwnProperty(stat)) {
|
453 |
-
gameState.stats[stat] += amount;
|
454 |
-
console.log(`Stat ${stat} increased by ${amount}.`);
|
455 |
-
}
|
456 |
}
|
457 |
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
const difficulty = challenge.difficulty;
|
463 |
-
const successPage = challenge.success;
|
464 |
-
const failurePage = challenge.failure;
|
465 |
-
|
466 |
-
if (reqStat && difficulty !== undefined && successPage !== undefined && failurePage !== undefined) {
|
467 |
-
const currentStatValue = gameState.stats[reqStat] || 0;
|
468 |
-
const roll = Math.floor(Math.random() * 6) + 1;
|
469 |
-
const total = roll + currentStatValue;
|
470 |
-
const success = total >= difficulty;
|
471 |
-
|
472 |
-
console.log(`Challenge: ${challenge.title || 'Test'}. Roll(${roll}) + ${reqStat}(${currentStatValue}) = ${total} vs DC ${difficulty}. Success: ${success}`);
|
473 |
-
|
474 |
-
// Update page based on challenge result
|
475 |
-
currentPageIdAfterEffects = success ? successPage : failurePage;
|
476 |
-
|
477 |
-
// Apply stat change from challenge outcome (optional: only on success/failure?)
|
478 |
-
// This example applies basic +/- based on outcome
|
479 |
-
if(gameState.stats.hasOwnProperty(reqStat)) {
|
480 |
-
gameState.stats[reqStat] += success ? 1 : -1;
|
481 |
-
gameState.stats[reqStat] = Math.max(1, gameState.stats[reqStat]); // Don't go below 1
|
482 |
-
}
|
483 |
-
|
484 |
-
// Add challenge result to story? (Needs modification of renderPage or state)
|
485 |
-
|
486 |
-
// If challenge leads to non-existent page, handle error
|
487 |
-
if (!gameData[currentPageIdAfterEffects]) {
|
488 |
-
console.error(`Challenge outcome page ${currentPageIdAfterEffects} not found!`);
|
489 |
-
renderPage(99); return;
|
490 |
-
}
|
491 |
-
// Update visited coords for the challenge outcome page
|
492 |
-
const challengeOutcomePageData = gameData[currentPageIdAfterEffects];
|
493 |
-
if (challengeOutcomePageData.mapX !== undefined && challengeOutcomePageData.mapY !== undefined) {
|
494 |
-
visitedCoords.add(`${challengeOutcomePageData.mapX},${challengeOutcomePageData.mapY}`);
|
495 |
-
}
|
496 |
-
|
497 |
-
|
498 |
-
} else {
|
499 |
-
console.error("Malformed challenge data on page", nextPageId);
|
500 |
-
}
|
501 |
-
gameState.currentPageId = currentPageIdAfterEffects; // Update state with final page after challenge
|
502 |
-
}
|
503 |
-
|
504 |
-
|
505 |
-
// --- Render the final page after all effects/challenges ---
|
506 |
-
renderPage(gameState.currentPageId);
|
507 |
-
}
|
508 |
-
|
509 |
-
|
510 |
-
function updateStatsDisplay() {
|
511 |
-
let statsHTML = ''; // Start fresh
|
512 |
-
statsHTML += `<span>HP: ${gameState.stats.hp}/${gameState.stats.maxHp}</span>`;
|
513 |
-
statsHTML += `<span>Str: ${gameState.stats.strength}</span>`;
|
514 |
-
statsHTML += `<span>Wis: ${gameState.stats.wisdom}</span>`;
|
515 |
-
statsHTML += `<span>Cor: ${gameState.stats.courage}</span>`;
|
516 |
-
statsElement.innerHTML = statsHTML;
|
517 |
-
}
|
518 |
-
|
519 |
-
function updateInventoryDisplay() {
|
520 |
-
let inventoryHTML = ''; // Start fresh
|
521 |
-
if (gameState.inventory.length === 0) {
|
522 |
-
inventoryHTML += '<em>Empty</em>';
|
523 |
-
} else {
|
524 |
-
gameState.inventory.forEach(item => {
|
525 |
-
const itemInfo = itemsData[item] || { type: 'unknown', description: '???' };
|
526 |
-
const itemClass = `item-${itemInfo.type || 'unknown'}`;
|
527 |
-
inventoryHTML += `<span class="${itemClass}" title="${itemInfo.description}">${item}</span>`;
|
528 |
-
});
|
529 |
}
|
530 |
-
inventoryElement.innerHTML = inventoryHTML;
|
531 |
}
|
532 |
|
533 |
-
// ---
|
534 |
-
function
|
535 |
-
if (!
|
536 |
-
|
537 |
-
|
538 |
-
|
539 |
-
|
540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
541 |
|
542 |
-
|
543 |
-
|
544 |
-
|
545 |
-
|
546 |
-
|
547 |
-
|
548 |
-
|
549 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
550 |
}
|
551 |
|
552 |
-
|
553 |
-
|
554 |
-
|
555 |
-
|
556 |
-
|
557 |
-
|
558 |
-
|
559 |
-
|
560 |
-
|
561 |
-
|
562 |
-
|
563 |
-
|
564 |
-
// Define view distance (how many neighbors to render)
|
565 |
-
const viewDistance = 2;
|
566 |
-
|
567 |
-
// Render current room and neighbors
|
568 |
-
for (let dx = -viewDistance; dx <= viewDistance; dx++) {
|
569 |
-
for (let dy = -viewDistance; dy <= viewDistance; dy++) {
|
570 |
-
const checkX = currentX + dx;
|
571 |
-
const checkY = currentY + dy;
|
572 |
-
const coordString = `${checkX},${checkY}`;
|
573 |
-
|
574 |
-
// Find page ID for this coordinate (requires efficient lookup - maybe build map index?)
|
575 |
-
let pageIdAtCoord = null;
|
576 |
-
for (const id in gameData) {
|
577 |
-
if (gameData[id].mapX === checkX && gameData[id].mapY === checkY) {
|
578 |
-
pageIdAtCoord = id;
|
579 |
-
break;
|
580 |
-
}
|
581 |
-
}
|
582 |
-
|
583 |
-
if (pageIdAtCoord) {
|
584 |
-
const pageDataAtCoord = gameData[pageIdAtCoord];
|
585 |
-
const isVisited = visitedCoords.has(coordString);
|
586 |
-
const isCurrent = (checkX === currentX && checkY === currentY);
|
587 |
-
|
588 |
-
if (isCurrent || isVisited) {
|
589 |
-
// Render visited/current room
|
590 |
-
const floor = createFloor(pageDataAtCoord.type, checkX, checkY);
|
591 |
-
mapGroup.add(floor);
|
592 |
-
|
593 |
-
// Add walls (simplified: add all 4 unless a feature indicates door/opening)
|
594 |
-
// More complex: check connections to neighbors
|
595 |
-
const features = pageDataAtCoord.features || [];
|
596 |
-
if (!features.includes('door_north')) mapGroup.add(createWall('north', checkX, checkY));
|
597 |
-
if (!features.includes('door_south')) mapGroup.add(createWall('south', checkX, checkY));
|
598 |
-
if (!features.includes('door_east')) mapGroup.add(createWall('east', checkX, checkY));
|
599 |
-
if (!features.includes('door_west')) mapGroup.add(createWall('west', checkX, checkY));
|
600 |
-
|
601 |
-
// Add other features
|
602 |
-
features.forEach(feature => {
|
603 |
-
const featureMesh = createFeature(feature, checkX, checkY);
|
604 |
-
if (featureMesh) mapGroup.add(featureMesh);
|
605 |
-
});
|
606 |
-
|
607 |
-
|
608 |
-
// Add marker
|
609 |
-
if (isCurrent) {
|
610 |
-
mapGroup.add(createMarker('current', checkX, checkY));
|
611 |
-
} else {
|
612 |
-
mapGroup.add(createMarker('visited', checkX, checkY));
|
613 |
-
}
|
614 |
-
|
615 |
-
} else {
|
616 |
-
// Render unexplored marker for adjacent non-visited rooms
|
617 |
-
if(Math.abs(dx) <= 1 && Math.abs(dy) <= 1 && !(dx === 0 && dy ===0)) { // Only immediate neighbors
|
618 |
-
mapGroup.add(createMarker('unexplored', checkX, checkY));
|
619 |
-
}
|
620 |
-
}
|
621 |
-
} else {
|
622 |
-
// Optional: Render something if coordinate is empty but adjacent (like a boundary wall)
|
623 |
-
// Or just leave it empty (part of the fog)
|
624 |
-
}
|
625 |
-
}
|
626 |
}
|
627 |
-
// Update controls target if using OrbitControls
|
628 |
-
// if (controls) controls.target.set(targetX, 0, targetZ);
|
629 |
}
|
630 |
|
631 |
-
|
632 |
-
//
|
633 |
-
|
634 |
-
startGame(); // Start the game after setting up Three.js
|
|
|
1 |
import * as THREE from 'three';
|
2 |
+
import * as CANNON from 'cannon-es';
|
3 |
+
// import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Optional for debugging
|
4 |
|
5 |
// --- DOM Elements ---
|
6 |
const sceneContainer = document.getElementById('scene-container');
|
|
|
|
|
|
|
7 |
const statsElement = document.getElementById('stats-display');
|
8 |
const inventoryElement = document.getElementById('inventory-display');
|
9 |
+
const logElement = document.getElementById('log-display');
|
10 |
|
11 |
// --- Config ---
|
12 |
+
const ROOM_SIZE = 10;
|
13 |
const WALL_HEIGHT = 4;
|
14 |
const WALL_THICKNESS = 0.5;
|
15 |
+
const CAMERA_Y_OFFSET = 15; // Camera height
|
16 |
+
const PLAYER_SPEED = 5; // Movement speed units/sec
|
17 |
+
const PLAYER_RADIUS = 0.5;
|
18 |
+
const PLAYER_HEIGHT = 1.8;
|
19 |
+
const PROJECTILE_SPEED = 15;
|
20 |
+
const PROJECTILE_RADIUS = 0.2;
|
21 |
|
22 |
// --- Three.js Setup ---
|
23 |
let scene, camera, renderer;
|
24 |
+
let playerMesh; // Visual representation of the player
|
25 |
+
const meshesToSync = []; // Array to hold { mesh, body } pairs for sync
|
26 |
+
const projectiles = []; // Active projectiles { mesh, body, lifetime }
|
27 |
+
|
28 |
+
// --- Physics Setup ---
|
29 |
+
let world;
|
30 |
+
let playerBody; // Physics body for the player
|
31 |
+
const physicsBodies = []; // Keep track of bodies to remove later if needed
|
32 |
+
const cannonDebugRenderer = null; // Optional: Use cannon-es-debugger
|
33 |
+
|
34 |
+
// --- Game State ---
|
35 |
+
let gameState = {
|
36 |
+
inventory: [],
|
37 |
+
stats: { hp: 30, maxHp: 30, strength: 7, wisdom: 5, courage: 6 },
|
38 |
+
position: { x: 0, z: 0 }, // Player's logical grid position (optional)
|
39 |
+
monsters: [], // Store active monster data { id, hp, body, mesh, ... }
|
40 |
+
items: [], // Store active item data { id, name, body, mesh, ... }
|
41 |
+
};
|
42 |
+
const keysPressed = {}; // Track currently pressed keys
|
43 |
+
|
44 |
+
// --- Game Data (Keep relevant parts, add monster/item placements) ---
|
45 |
+
const gameData = {
|
46 |
+
// Structure: "x,y": { type, features, items?, monsters? }
|
47 |
+
"0,0": { type: 'city', features: ['door_north'] },
|
48 |
+
"0,1": { type: 'forest', features: ['path_north', 'door_south', 'item_potion'] }, // Add item marker
|
49 |
+
"0,2": { type: 'forest', features: ['path_south', 'monster_goblin'] }, // Add monster marker
|
50 |
+
// ... Add many more locations based on your design ...
|
51 |
+
};
|
52 |
+
|
53 |
+
const itemsData = {
|
54 |
+
"Healing Potion": { type: "consumable", description: "Restores 10 HP.", hpRestore: 10, model: 'sphere_red' },
|
55 |
+
"Key": { type: "quest", description: "Unlocks a door.", model: 'box_gold'},
|
56 |
+
// ...
|
57 |
+
};
|
58 |
+
|
59 |
+
const monstersData = {
|
60 |
+
"goblin": { hp: 15, attack: 4, defense: 1, speed: 2, model: 'capsule_green', xp: 5 },
|
61 |
+
// ...
|
62 |
+
};
|
63 |
+
|
64 |
+
// --- Initialization ---
|
65 |
+
|
66 |
+
function init() {
|
67 |
+
initThreeJS();
|
68 |
+
initPhysics();
|
69 |
+
initPlayer();
|
70 |
+
generateMap(); // Generate based on gameData
|
71 |
+
setupInputListeners();
|
72 |
+
animate(); // Start the game loop
|
73 |
+
updateUI(); // Initial UI update
|
74 |
+
addLog("Welcome! Move with WASD/Arrows. Space to Attack.", "info");
|
75 |
+
}
|
76 |
|
77 |
function initThreeJS() {
|
78 |
scene = new THREE.Scene();
|
79 |
+
scene.background = new THREE.Color(0x111111);
|
|
|
80 |
|
81 |
camera = new THREE.PerspectiveCamera(60, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 1000);
|
82 |
+
// Start camera slightly offset, will follow player
|
83 |
+
camera.position.set(0, CAMERA_Y_OFFSET, 5); // Look slightly forward initially
|
84 |
camera.lookAt(0, 0, 0);
|
85 |
|
86 |
renderer = new THREE.WebGLRenderer({ antialias: true });
|
87 |
renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
|
88 |
+
renderer.shadowMap.enabled = true;
|
89 |
sceneContainer.appendChild(renderer.domElement);
|
90 |
|
91 |
// Lighting
|
92 |
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
93 |
scene.add(ambientLight);
|
94 |
+
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
95 |
+
dirLight.position.set(10, 20, 5);
|
96 |
+
dirLight.castShadow = true; // Enable shadows for this light
|
97 |
+
scene.add(dirLight);
|
|
|
|
|
|
|
|
|
|
|
98 |
|
99 |
+
// Configure shadow properties if needed
|
100 |
+
dirLight.shadow.mapSize.width = 1024;
|
101 |
+
dirLight.shadow.mapSize.height = 1024;
|
|
|
102 |
|
103 |
window.addEventListener('resize', onWindowResize, false);
|
|
|
104 |
}
|
105 |
|
106 |
+
function initPhysics() {
|
107 |
+
world = new CANNON.World({
|
108 |
+
gravity: new CANNON.Vec3(0, -9.82, 0) // Standard gravity
|
109 |
+
});
|
110 |
+
world.broadphase = new CANNON.NaiveBroadphase(); // Simple broadphase for now
|
111 |
+
// world.solver.iterations = 10; // Adjust solver iterations if needed
|
112 |
+
|
113 |
+
// Ground plane (physics only)
|
114 |
+
const groundShape = new CANNON.Plane();
|
115 |
+
const groundBody = new CANNON.Body({ mass: 0 }); // Mass 0 means static
|
116 |
+
groundBody.addShape(groundShape);
|
117 |
+
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); // Rotate plane to be horizontal
|
118 |
+
world.addBody(groundBody);
|
119 |
}
|
120 |
|
121 |
+
// --- Primitive Assembly Functions ---
|
122 |
+
function createPlayerMesh() {
|
123 |
+
const group = new THREE.Group();
|
124 |
+
// Simple capsule: cylinder + sphere cap
|
125 |
+
const bodyMat = new THREE.MeshLambertMaterial({ color: 0x0077ff }); // Blue player
|
126 |
+
const bodyGeom = new THREE.CylinderGeometry(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT - (PLAYER_RADIUS * 2), 16);
|
127 |
+
const body = new THREE.Mesh(bodyGeom, bodyMat);
|
128 |
+
body.position.y = PLAYER_RADIUS; // Position cylinder part correctly
|
129 |
+
body.castShadow = true;
|
130 |
+
|
131 |
+
const headGeom = new THREE.SphereGeometry(PLAYER_RADIUS, 16, 16);
|
132 |
+
const head = new THREE.Mesh(headGeom, bodyMat);
|
133 |
+
head.position.y = PLAYER_HEIGHT - PLAYER_RADIUS; // Position head on top
|
134 |
+
head.castShadow = true;
|
135 |
+
|
136 |
+
group.add(body);
|
137 |
+
group.add(head);
|
138 |
+
|
139 |
+
// Add a "front" indicator (e.g., small cone)
|
140 |
+
const noseGeom = new THREE.ConeGeometry(PLAYER_RADIUS * 0.3, PLAYER_RADIUS * 0.5, 8);
|
141 |
+
const noseMat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // Yellow nose
|
142 |
+
const nose = new THREE.Mesh(noseGeom, noseMat);
|
143 |
+
nose.position.set(0, PLAYER_HEIGHT * 0.7, PLAYER_RADIUS * 0.7); // Position in front, slightly down
|
144 |
+
nose.rotation.x = Math.PI / 2; // Point forward
|
145 |
+
group.add(nose);
|
146 |
+
|
147 |
+
return group;
|
148 |
}
|
149 |
|
150 |
+
function createSimpleMonsterMesh(modelType = 'capsule_green') {
|
151 |
+
const group = new THREE.Group();
|
152 |
+
let color = 0xff0000; // Default red
|
153 |
+
let geom;
|
154 |
+
let mat;
|
155 |
+
|
156 |
+
if (modelType === 'capsule_green') {
|
157 |
+
color = 0x00ff00; // Green
|
158 |
+
mat = new THREE.MeshLambertMaterial({ color: color });
|
159 |
+
const bodyGeom = new THREE.CylinderGeometry(0.4, 0.4, 1.0, 12);
|
160 |
+
const headGeom = new THREE.SphereGeometry(0.4, 12, 12);
|
161 |
+
const body = new THREE.Mesh(bodyGeom, mat);
|
162 |
+
const head = new THREE.Mesh(headGeom, mat);
|
163 |
+
body.position.y = 0.5;
|
164 |
+
head.position.y = 1.0 + 0.4;
|
165 |
+
group.add(body);
|
166 |
+
group.add(head);
|
167 |
+
} else { // Default box monster
|
168 |
+
geom = new THREE.BoxGeometry(0.8, 1.2, 0.8);
|
169 |
+
mat = new THREE.MeshLambertMaterial({ color: color });
|
170 |
+
const mesh = new THREE.Mesh(geom, mat);
|
171 |
+
mesh.position.y = 0.6;
|
172 |
+
group.add(mesh);
|
173 |
+
}
|
174 |
+
group.traverse(child => { if (child.isMesh) child.castShadow = true; });
|
175 |
+
return group;
|
|
|
|
|
|
|
176 |
}
|
177 |
|
178 |
+
function createSimpleItemMesh(modelType = 'sphere_red') {
|
179 |
+
let geom, mat;
|
180 |
+
let color = 0xffffff; // Default white
|
181 |
+
|
182 |
+
if(modelType === 'sphere_red') {
|
183 |
+
color = 0xff0000;
|
184 |
+
geom = new THREE.SphereGeometry(0.3, 16, 16);
|
185 |
+
} else if (modelType === 'box_gold') {
|
186 |
+
color = 0xffd700;
|
187 |
+
geom = new THREE.BoxGeometry(0.4, 0.4, 0.4);
|
188 |
+
} else { // Default sphere
|
189 |
+
geom = new THREE.SphereGeometry(0.3, 16, 16);
|
190 |
+
}
|
191 |
+
mat = new THREE.MeshStandardMaterial({ color: color, metalness: 0.3, roughness: 0.6 });
|
192 |
+
const mesh = new THREE.Mesh(geom, mat);
|
193 |
+
mesh.position.y = PLAYER_RADIUS; // Place at player radius height
|
194 |
+
mesh.castShadow = true;
|
195 |
+
return mesh;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
196 |
}
|
197 |
|
198 |
+
function createProjectileMesh() {
|
199 |
+
const geom = new THREE.SphereGeometry(PROJECTILE_RADIUS, 8, 8);
|
200 |
+
const mat = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // Bright yellow
|
201 |
+
const mesh = new THREE.Mesh(geom, mat);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
202 |
return mesh;
|
203 |
}
|
204 |
|
205 |
+
// --- Player Setup ---
|
206 |
+
function initPlayer() {
|
207 |
+
// Visuals
|
208 |
+
playerMesh = createPlayerMesh();
|
209 |
+
playerMesh.position.y = PLAYER_HEIGHT / 2; // Adjust initial position based on model
|
210 |
+
scene.add(playerMesh);
|
211 |
+
|
212 |
+
// Physics
|
213 |
+
// Using a capsule shape approximation (sphere + cylinder + sphere) is complex in Cannon.
|
214 |
+
// Let's use a simpler Sphere or Box for now. Sphere is often better for rolling/movement.
|
215 |
+
const playerShape = new CANNON.Sphere(PLAYER_RADIUS);
|
216 |
+
playerBody = new CANNON.Body({
|
217 |
+
mass: 5, // Give player some mass
|
218 |
+
shape: playerShape,
|
219 |
+
position: new CANNON.Vec3(0, PLAYER_HEIGHT / 2, 0), // Start at origin, slightly above ground
|
220 |
+
linearDamping: 0.9, // Add damping to prevent sliding forever
|
221 |
+
angularDamping: 0.9, // Prevent spinning wildly
|
222 |
+
fixedRotation: true, // Prevent player body from tipping over (optional but good for top-down)
|
223 |
+
});
|
224 |
+
playerBody.addEventListener("collide", handlePlayerCollision); // Add collision listener
|
225 |
+
world.addBody(playerBody);
|
226 |
+
|
227 |
+
// Add to sync list
|
228 |
+
meshesToSync.push({ mesh: playerMesh, body: playerBody });
|
229 |
}
|
230 |
|
231 |
+
// --- Map Generation ---
|
232 |
+
function generateMap() {
|
233 |
+
const wallMaterial = new CANNON.Material("wallMaterial"); // For physics interactions
|
234 |
+
|
235 |
+
for (const coordString in gameData) {
|
236 |
+
const [xStr, yStr] = coordString.split(',');
|
237 |
+
const x = parseInt(xStr);
|
238 |
+
const y = parseInt(yStr); // Represents Z in 3D
|
239 |
+
const data = gameData[coordString];
|
240 |
+
|
241 |
+
// Create Floor Mesh (visual only, physics ground plane handles floor collision)
|
242 |
+
const floorMesh = createFloor(data.type, x, y);
|
243 |
+
scene.add(floorMesh); // Add floor directly to scene, not mapGroup if physics handles it
|
244 |
+
|
245 |
+
// Create Wall Meshes and Physics Bodies
|
246 |
+
const features = data.features || [];
|
247 |
+
const wallPositions = [
|
248 |
+
{ dir: 'north', xOffset: 0, zOffset: -0.5, geom: geometries.wall },
|
249 |
+
{ dir: 'south', xOffset: 0, zOffset: 0.5, geom: geometries.wall },
|
250 |
+
{ dir: 'east', xOffset: 0.5, zOffset: 0, geom: geometries.wall_side },
|
251 |
+
{ dir: 'west', xOffset: -0.5, zOffset: 0, geom: geometries.wall_side },
|
252 |
+
];
|
253 |
+
|
254 |
+
wallPositions.forEach(wp => {
|
255 |
+
// Check if a feature indicates an opening in this direction
|
256 |
+
const doorFeature = `door_${wp.dir}`;
|
257 |
+
const pathFeature = `path_${wp.dir}`; // Consider paths as openings too
|
258 |
+
if (!features.includes(doorFeature) && !features.includes(pathFeature)) {
|
259 |
+
// Add Visual Wall Mesh
|
260 |
+
const wallMesh = new THREE.Mesh(wp.geom, materials.wall);
|
261 |
+
wallMesh.position.set(
|
262 |
+
x * ROOM_SIZE + wp.xOffset * ROOM_SIZE,
|
263 |
+
WALL_HEIGHT / 2,
|
264 |
+
y * ROOM_SIZE + wp.zOffset * ROOM_SIZE
|
265 |
+
);
|
266 |
+
wallMesh.castShadow = true;
|
267 |
+
wallMesh.receiveShadow = true;
|
268 |
+
scene.add(wallMesh); // Add walls directly to scene
|
269 |
+
|
270 |
+
// Add Physics Wall Body
|
271 |
+
const wallShape = new CANNON.Box(new CANNON.Vec3(
|
272 |
+
wp.geom.parameters.width / 2,
|
273 |
+
wp.geom.parameters.height / 2,
|
274 |
+
wp.geom.parameters.depth / 2
|
275 |
+
));
|
276 |
+
const wallBody = new CANNON.Body({
|
277 |
+
mass: 0, // Static
|
278 |
+
shape: wallShape,
|
279 |
+
position: new CANNON.Vec3(wallMesh.position.x, wallMesh.position.y, wallMesh.position.z),
|
280 |
+
material: wallMaterial // Assign physics material
|
281 |
+
});
|
282 |
+
world.addBody(wallBody);
|
283 |
+
physicsBodies.push(wallBody); // Keep track if needed for removal
|
284 |
+
}
|
285 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
286 |
|
287 |
+
// Spawn Items
|
288 |
+
if (data.items) {
|
289 |
+
data.items.forEach(itemName => spawnItem(itemName, x, y));
|
290 |
+
}
|
291 |
+
// Spawn Monsters
|
292 |
+
if (data.monsters) {
|
293 |
+
data.monsters.forEach(monsterType => spawnMonster(monsterType, x, y));
|
294 |
+
}
|
295 |
+
|
296 |
+
// Add other features visually (rivers, etc. - physics interaction TBD)
|
297 |
+
features.forEach(feature => {
|
298 |
+
if (feature === 'river') {
|
299 |
+
const riverMesh = createFeature(feature, x, y);
|
300 |
+
if (riverMesh) scene.add(riverMesh);
|
301 |
+
}
|
302 |
+
// Handle other features
|
303 |
+
});
|
304 |
}
|
|
|
305 |
}
|
306 |
|
307 |
+
function spawnItem(itemName, gridX, gridY) {
|
308 |
+
const itemData = itemsData[itemName];
|
309 |
+
if (!itemData) {
|
310 |
+
console.warn(`Item data not found for ${itemName}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
311 |
return;
|
312 |
}
|
313 |
|
314 |
+
const x = gridX * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.5); // Randomize position within cell
|
315 |
+
const z = gridY * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.5);
|
316 |
+
const y = PLAYER_RADIUS; // Place at reachable height
|
317 |
+
|
318 |
+
// Visual Mesh
|
319 |
+
const mesh = createSimpleItemMesh(itemData.model);
|
320 |
+
mesh.position.set(x, y, z);
|
321 |
+
mesh.userData = { type: 'item', name: itemName }; // Store game data on mesh
|
322 |
+
scene.add(mesh);
|
323 |
+
|
324 |
+
// Physics Body (Static Sensor)
|
325 |
+
const shape = new CANNON.Sphere(0.4); // Slightly larger than visual for easier pickup
|
326 |
+
const body = new CANNON.Body({
|
327 |
+
mass: 0,
|
328 |
+
isTrigger: true, // Sensor - detects collision but doesn't cause physical reaction
|
329 |
+
shape: shape,
|
330 |
+
position: new CANNON.Vec3(x, y, z)
|
331 |
+
});
|
332 |
+
body.userData = { type: 'item', name: itemName, mesh }; // Link body back to mesh
|
333 |
+
world.addBody(body);
|
334 |
+
|
335 |
+
gameState.items.push({ id: body.id, name: itemName, body: body, mesh: mesh });
|
336 |
+
physicsBodies.push(body);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
337 |
}
|
338 |
|
339 |
+
function spawnMonster(monsterType, gridX, gridY) {
|
340 |
+
const monsterData = monstersData[monsterType];
|
341 |
+
if (!monsterData) {
|
342 |
+
console.warn(`Monster data not found for ${monsterType}`);
|
343 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
344 |
}
|
345 |
|
346 |
+
const x = gridX * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.3); // Randomize position
|
347 |
+
const z = gridY * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.3);
|
348 |
+
const y = PLAYER_HEIGHT / 2; // Start at roughly player height
|
349 |
+
|
350 |
+
// Visual Mesh
|
351 |
+
const mesh = createSimpleMonsterMesh(monsterData.model);
|
352 |
+
mesh.position.set(x, y, z);
|
353 |
+
mesh.userData = { type: 'monster', monsterType: monsterType };
|
354 |
+
scene.add(mesh);
|
355 |
+
|
356 |
+
// Physics Body (Dynamic)
|
357 |
+
// Using a simple sphere collider for monsters for now
|
358 |
+
const shape = new CANNON.Sphere(PLAYER_RADIUS * 0.8); // Slightly smaller than player
|
359 |
+
const body = new CANNON.Body({
|
360 |
+
mass: 10, // Give mass
|
361 |
+
shape: shape,
|
362 |
+
position: new CANNON.Vec3(x, y, z),
|
363 |
+
linearDamping: 0.8,
|
364 |
+
angularDamping: 0.9,
|
365 |
+
fixedRotation: true, // Prevent tipping
|
366 |
+
});
|
367 |
+
body.userData = { type: 'monster', monsterType: monsterType, mesh: mesh, hp: monsterData.hp }; // Store HP on body userData
|
368 |
+
world.addBody(body);
|
369 |
+
|
370 |
+
gameState.monsters.push({ id: body.id, type: monsterType, hp: monsterData.hp, body: body, mesh: mesh });
|
371 |
+
meshesToSync.push({ mesh: mesh, body: body }); // Add monster to sync list
|
372 |
+
physicsBodies.push(body);
|
373 |
+
}
|
374 |
+
|
375 |
+
|
376 |
+
// --- Input Handling ---
|
377 |
+
function setupInputListeners() {
|
378 |
+
window.addEventListener('keydown', (event) => {
|
379 |
+
keysPressed[event.key.toLowerCase()] = true;
|
380 |
+
keysPressed[event.code] = true; // Also store by code (e.g., Space)
|
381 |
+
});
|
382 |
+
window.addEventListener('keyup', (event) => {
|
383 |
+
keysPressed[event.key.toLowerCase()] = false;
|
384 |
+
keysPressed[event.code] = false;
|
385 |
+
});
|
386 |
}
|
387 |
|
388 |
+
function handleInput(deltaTime) {
|
389 |
+
if (!playerBody) return;
|
390 |
|
391 |
+
const moveDirection = new CANNON.Vec3(0, 0, 0);
|
392 |
+
const moveSpeed = PLAYER_SPEED;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
393 |
|
394 |
+
if (keysPressed['w'] || keysPressed['arrowup']) {
|
395 |
+
moveDirection.z = -1;
|
396 |
+
} else if (keysPressed['s'] || keysPressed['arrowdown']) {
|
397 |
+
moveDirection.z = 1;
|
398 |
}
|
399 |
|
400 |
+
if (keysPressed['a'] || keysPressed['arrowleft']) {
|
401 |
+
moveDirection.x = -1;
|
402 |
+
} else if (keysPressed['d'] || keysPressed['arrowright']) {
|
403 |
+
moveDirection.x = 1;
|
|
|
404 |
}
|
405 |
|
406 |
+
// Normalize diagonal movement and apply speed
|
407 |
+
if (moveDirection.lengthSquared() > 0) { // Only normalize if there's movement
|
408 |
+
moveDirection.normalize();
|
409 |
+
// Apply velocity directly - better for responsive character control than forces
|
410 |
+
playerBody.velocity.x = moveDirection.x * moveSpeed;
|
411 |
+
playerBody.velocity.z = moveDirection.z * moveSpeed;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
412 |
|
413 |
+
// Make player mesh face movement direction (optional)
|
414 |
+
const angle = Math.atan2(moveDirection.x, moveDirection.z);
|
415 |
+
// Smooth rotation? Lerp quaternion later. For now, direct set.
|
416 |
+
playerMesh.quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle);
|
417 |
|
418 |
+
} else {
|
419 |
+
// If no movement keys pressed, gradually stop (handled by linearDamping)
|
420 |
+
// Or set velocity to zero for instant stop:
|
421 |
+
// playerBody.velocity.x = 0;
|
422 |
+
// playerBody.velocity.z = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
423 |
}
|
424 |
|
425 |
+
// Handle 'Fire' (Space bar)
|
426 |
+
if (keysPressed['space']) {
|
427 |
+
fireProjectile();
|
428 |
+
keysPressed['space'] = false; // Prevent holding space for continuous fire (debounce)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
429 |
}
|
|
|
430 |
}
|
431 |
|
432 |
+
// --- Combat ---
|
433 |
+
function fireProjectile() {
|
434 |
+
if (!playerBody || !playerMesh) return;
|
435 |
+
addLog("Pew!", "combat"); // Simple log
|
436 |
+
|
437 |
+
// Create Mesh
|
438 |
+
const projectileMesh = createProjectileMesh();
|
439 |
+
// Create Physics Body
|
440 |
+
const projectileShape = new CANNON.Sphere(PROJECTILE_RADIUS);
|
441 |
+
const projectileBody = new CANNON.Body({
|
442 |
+
mass: 0.1, // Very light
|
443 |
+
shape: projectileShape,
|
444 |
+
linearDamping: 0.01, // Minimal damping
|
445 |
+
angularDamping: 0.01,
|
446 |
+
});
|
447 |
+
projectileBody.addEventListener("collide", handleProjectileCollision);
|
448 |
+
|
449 |
+
// Calculate initial position and velocity
|
450 |
+
// Start slightly in front of player, based on player's rotation
|
451 |
+
const offsetDistance = PLAYER_RADIUS + PROJECTILE_RADIUS + 0.1; // Start just outside player radius
|
452 |
+
const direction = new THREE.Vector3(0, 0, -1); // Base direction (forward Z)
|
453 |
+
direction.applyQuaternion(playerMesh.quaternion); // Rotate based on player mesh orientation
|
454 |
+
|
455 |
+
const startPos = new CANNON.Vec3().copy(playerBody.position).vadd(
|
456 |
+
new CANNON.Vec3(direction.x, 0, direction.z).scale(offsetDistance) // Offset horizontally
|
457 |
+
);
|
458 |
+
startPos.y = PLAYER_HEIGHT * 0.7; // Fire from "head" height approx
|
459 |
+
|
460 |
+
projectileBody.position.copy(startPos);
|
461 |
+
projectileMesh.position.copy(startPos); // Sync initial mesh position
|
462 |
+
|
463 |
+
// Set velocity in the direction the player is facing
|
464 |
+
projectileBody.velocity = new CANNON.Vec3(direction.x, 0, direction.z).scale(PROJECTILE_SPEED);
|
465 |
+
|
466 |
+
// Add to scene and world
|
467 |
+
scene.add(projectileMesh);
|
468 |
+
world.addBody(projectileBody);
|
469 |
+
|
470 |
+
// Add to sync list and active projectiles list
|
471 |
+
const projectileData = { mesh: projectileMesh, body: projectileBody, lifetime: 3.0 }; // 3 second lifetime
|
472 |
+
meshesToSync.push(projectileData);
|
473 |
+
projectiles.push(projectileData);
|
474 |
+
physicsBodies.push(projectileBody);
|
475 |
+
|
476 |
+
// Link body and mesh
|
477 |
+
projectileBody.userData = { type: 'projectile', mesh: projectileMesh, data: projectileData };
|
478 |
+
projectileMesh.userData = { type: 'projectile', body: projectileBody, data: projectileData };
|
479 |
+
}
|
480 |
|
481 |
+
// --- Collision Handling ---
|
482 |
+
function handlePlayerCollision(event) {
|
483 |
+
const otherBody = event.body; // The body the player collided with
|
484 |
+
if (!otherBody.userData) return;
|
485 |
+
|
486 |
+
// Player <-> Item Collision
|
487 |
+
if (otherBody.userData.type === 'item') {
|
488 |
+
const itemName = otherBody.userData.name;
|
489 |
+
const itemIndex = gameState.items.findIndex(item => item.id === otherBody.id);
|
490 |
+
|
491 |
+
if (itemIndex > -1 && !gameState.inventory.includes(itemName)) {
|
492 |
+
gameState.inventory.push(itemName);
|
493 |
+
addLog(`Picked up ${itemName}!`, "pickup");
|
494 |
+
updateInventoryDisplay(); // Update UI immediately
|
495 |
+
|
496 |
+
// Remove item from world
|
497 |
+
scene.remove(otherBody.userData.mesh);
|
498 |
+
world.removeBody(otherBody);
|
499 |
+
meshesToSync = meshesToSync.filter(sync => sync.body.id !== otherBody.id);
|
500 |
+
physicsBodies = physicsBodies.filter(body => body.id !== otherBody.id);
|
501 |
+
gameState.items.splice(itemIndex, 1);
|
502 |
+
}
|
503 |
}
|
504 |
|
505 |
+
// Player <-> Monster Collision (Simple damage placeholder)
|
506 |
+
else if (otherBody.userData.type === 'monster') {
|
507 |
+
// Example: Player takes damage on touch
|
508 |
+
// Implement cooldown later
|
509 |
+
gameState.stats.hp -= 1; // Monster touch damage
|
510 |
+
addLog(`Touched by ${otherBody.userData.monsterType}! HP: ${gameState.stats.hp}`, "combat");
|
511 |
+
updateStatsDisplay();
|
512 |
+
if (gameState.stats.hp <= 0) {
|
513 |
+
gameOver("Defeated by a monster!");
|
514 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
515 |
}
|
|
|
|
|
516 |
}
|
517 |
|
518 |
+
function handleProjectileCollision(event) {
|
519 |
+
const projectileBody = event.target; // The projectile body itself
|
520 |
+
const otherBody = event.body;
|
|