awacke1 commited on
Commit
fa40038
·
verified ·
1 Parent(s): 49d65ee

Update game.js

Browse files
Files changed (1) hide show
  1. game.js +444 -558
game.js CHANGED
@@ -1,634 +1,520 @@
1
  import * as THREE from 'three';
2
- // import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Keep commented unless needed
 
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; // Size of each map grid cell in 3D space
14
  const WALL_HEIGHT = 4;
15
  const WALL_THICKNESS = 0.5;
16
- const CAMERA_HEIGHT = 15; // How high the overhead camera is
 
 
 
 
 
17
 
18
  // --- Three.js Setup ---
19
  let scene, camera, renderer;
20
- let mapGroup; // A group to hold all map related objects
21
- // let controls; // Optional OrbitControls
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  function initThreeJS() {
24
  scene = new THREE.Scene();
25
- scene.background = new THREE.Color(0x111111); // Darker background for contrast
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
- // Set initial overhead position (will be updated)
30
- camera.position.set(0, CAMERA_HEIGHT, 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; // Enable shadows if using lights that cast them
36
  sceneContainer.appendChild(renderer.domElement);
37
 
38
  // Lighting
39
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // Softer ambient
40
  scene.add(ambientLight);
41
- // Light source directly above the center (acts like a player flashlight)
42
- const playerLight = new THREE.PointLight(0xffffff, 1.5, ROOM_SIZE * 3); // Intensity, Distance
43
- playerLight.position.set(0, CAMERA_HEIGHT - 2, 0); // Position slightly below camera
44
- scene.add(playerLight);
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
- // Optional Controls (for debugging)
52
- // controls = new OrbitControls(camera, renderer.domElement);
53
- // controls.enableDamping = true;
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 onWindowResize() {
61
- if (!renderer || !camera) return;
62
- camera.aspect = sceneContainer.clientWidth / sceneContainer.clientHeight;
63
- camera.updateProjectionMatrix();
64
- renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
 
 
 
 
 
 
 
 
65
  }
66
 
67
- function animate() {
68
- requestAnimationFrame(animate);
69
- // if (controls) controls.update();
70
- if (renderer && scene && camera) {
71
- renderer.render(scene, camera);
72
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
 
75
- // --- Primitive Creation Functions ---
76
- const materials = {
77
- floor_forest: new THREE.MeshLambertMaterial({ color: 0x228B22 }), // Green
78
- floor_cave: new THREE.MeshLambertMaterial({ color: 0x696969 }), // Grey
79
- floor_room: new THREE.MeshLambertMaterial({ color: 0xaaaaaa }), // Light Grey
80
- floor_city: new THREE.MeshLambertMaterial({ color: 0xdeb887 }), // Tan/Pavement
81
- wall: new THREE.MeshLambertMaterial({ color: 0x888888 }),
82
- door: new THREE.MeshLambertMaterial({ color: 0xcd853f }), // Peru (brownish)
83
- river: new THREE.MeshBasicMaterial({ color: 0x4682B4, transparent: true, opacity: 0.7 }), // Steel Blue
84
- unexplored: new THREE.MeshBasicMaterial({ color: 0x555555, wireframe: true }),
85
- visited_marker: new THREE.MeshBasicMaterial({ color: 0xaaaaaa, wireframe: true, transparent: true, opacity: 0.5 }), // Faint marker for visited
86
- current_marker: new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true }), // Yellow marker
87
- };
88
-
89
- const geometries = {
90
- floor: new THREE.PlaneGeometry(ROOM_SIZE, ROOM_SIZE),
91
- wall: new THREE.BoxGeometry(ROOM_SIZE, WALL_HEIGHT, WALL_THICKNESS),
92
- wall_side: new THREE.BoxGeometry(WALL_THICKNESS, WALL_HEIGHT, ROOM_SIZE),
93
- marker: new THREE.BoxGeometry(ROOM_SIZE * 0.8, 0.1, ROOM_SIZE * 0.8), // Flat marker
94
- river: new THREE.PlaneGeometry(ROOM_SIZE * 0.3, ROOM_SIZE), // Thin river strip
95
- };
96
-
97
- function createFloor(type = 'room', x = 0, z = 0) {
98
- const material = materials[`floor_${type}`] || materials.floor_room;
99
- const floor = new THREE.Mesh(geometries.floor, material);
100
- floor.rotation.x = -Math.PI / 2; // Rotate to be flat
101
- floor.position.set(x * ROOM_SIZE, 0, z * ROOM_SIZE);
102
- floor.receiveShadow = true;
103
- return floor;
104
  }
105
 
106
- function createWall(direction, x = 0, z = 0) {
107
- let wall;
108
- const base_x = x * ROOM_SIZE;
109
- const base_z = z * ROOM_SIZE;
110
- const half_room = ROOM_SIZE / 2;
111
- const wall_y = WALL_HEIGHT / 2; // Position center of wall at half height
112
-
113
- switch (direction) {
114
- case 'north':
115
- wall = new THREE.Mesh(geometries.wall, materials.wall);
116
- wall.position.set(base_x, wall_y, base_z - half_room);
117
- break;
118
- case 'south':
119
- wall = new THREE.Mesh(geometries.wall, materials.wall);
120
- wall.position.set(base_x, wall_y, base_z + half_room);
121
- break;
122
- case 'east':
123
- wall = new THREE.Mesh(geometries.wall_side, materials.wall);
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 createFeature(feature, x = 0, z = 0) {
138
- const base_x = x * ROOM_SIZE;
139
- const base_z = z * ROOM_SIZE;
140
- let mesh = null;
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 createMarker(type, x = 0, z = 0) {
154
- let material;
155
- switch (type) {
156
- case 'current': material = materials.current_marker; break;
157
- case 'visited': material = materials.visited_marker; break;
158
- case 'unexplored': material = materials.unexplored; break;
159
- default: return null;
160
- }
161
- const marker = new THREE.Mesh(geometries.marker, material);
162
- marker.position.set(x * ROOM_SIZE, 0.1, z * ROOM_SIZE); // Slightly above floor
163
- return marker;
 
 
 
 
 
 
 
 
 
 
 
 
164
  }
165
 
166
- // --- Game Data (ADD MAP COORDINATES and TYPE/FEATURES) ---
167
- const gameData = {
168
- // Page ID: { title, content, options, illustration(optional), mapX, mapY, type, features? }
169
- "1": {
170
- title: "The Beginning", content: "<p>...</p>", illustration: "city-gates",
171
- mapX: 0, mapY: 0, type: 'city', features: ['door_north'], // Example feature
172
- options: [
173
- { text: "Visit the local weaponsmith", next: 2 },
174
- { text: "Seek wisdom at the temple", next: 3 },
175
- { text: "Meet the resistance leader", next: 4 }
176
- ]
177
- },
178
- "2": { // Weaponsmith - let's place it near the start
179
- title: "The Weaponsmith", content: "<p>...</p>", illustration: "weaponsmith",
180
- mapX: -1, mapY: 0, type: 'room', features: ['door_east'],
181
- options: [
182
- { text: "Take Flaming Sword", next: 5, addItem: "Flaming Sword" },
183
- // ... other weapon choices ...
184
- { text: "Return to city square", next: 1 }
185
- ]
186
- },
187
- "3": { // Temple
188
- title: "The Ancient Temple", content: "<p>...</p>", illustration: "temple",
189
- mapX: 1, mapY: 0, type: 'room', features: ['door_west'],
190
- options: [
191
- { text: "Learn Healing Light", next: 5, addItem: "Healing Light Spell" },
192
- // ... other spell choices ...
193
- { text: "Return to city square", next: 1 }
194
- ]
195
- },
196
- "4": { // Resistance Tavern
197
- title: "The Resistance Leader", content: "<p>...</p>", illustration: "resistance-meeting",
198
- mapX: 0, mapY: -1, type: 'room', features: ['door_north'],
199
- options: [
200
- { text: "Take Secret Tunnel Map", next: 5, addItem: "Secret Tunnel Map" },
201
- // ... other item choices ...
202
- { text: "Return to city square", next: 1 }
203
- ]
204
- },
205
- "5": { // Start of Forest Path (North of City)
206
- title: "The Journey Begins", content: "<p>You leave Silverhold...</p>", illustration: "shadowwood-forest",
207
- mapX: 0, mapY: 1, type: 'forest', features: ['path_north', 'path_east', 'path_west', 'door_south'],
208
- options: [
209
- { text: "Take the main road (North)", next: 6 },
210
- { text: "Follow the river path (East)", next: 7 },
211
- { text: "Brave the ruins shortcut (West)", next: 8 },
212
- { text: "Return to the City Gate", next: 1} // Link back
213
- ]
214
- },
215
- "6": { // Forest Path North
216
- title: "Ambush!", content: "<p>Scouts jump out!</p>", illustration: "road-ambush",
217
- mapX: 0, mapY: 2, type: 'forest', features: ['path_north', 'path_south'],
218
- // Challenge will determine next step (9 or 10)
219
- challenge: { title: "Escape Ambush", stat: "courage", difficulty: 5, success: 9, failure: 10 },
220
- options: [] // Options determined by challenge
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
- // --- Game State ---
294
- let gameState = {}; // Initialize in startGame
295
- let visitedCoords = new Set(); // Track visited map coordinates as "x,y" strings
296
-
297
- // --- Game Logic Functions ---
298
-
299
- function startGame() {
300
- gameState = { // Reset state
301
- currentPageId: 1,
302
- inventory: [],
303
- stats: { courage: 7, wisdom: 5, strength: 6, hp: 30, maxHp: 30 }
304
- };
305
- visitedCoords = new Set(); // Reset visited locations
306
- const startPage = gameData[gameState.currentPageId];
307
- if (startPage && startPage.mapX !== undefined && startPage.mapY !== undefined) {
308
- visitedCoords.add(`${startPage.mapX},${startPage.mapY}`); // Mark start as visited
 
309
  }
310
- renderPage(gameState.currentPageId);
311
  }
312
 
313
- function renderPage(pageId) {
314
- const page = gameData[pageId];
315
- if (!page) {
316
- console.error(`Error: Page data not found for ID: ${pageId}`);
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
- // Update UI Text
327
- storyTitleElement.textContent = page.title || "Untitled Location";
328
- storyContentElement.innerHTML = page.content || "<p>...</p>";
329
- updateStatsDisplay();
330
- updateInventoryDisplay();
331
-
332
- // Update Choices based on requirements
333
- choicesElement.innerHTML = ''; // Clear old choices
334
- let hasAvailableOptions = false;
335
- if (page.options && !page.challenge && !page.gameOver) { // Don't show choices if there's a challenge or game over
336
- page.options.forEach(option => {
337
- // Check requirements before adding the button
338
- const requirementFailed = !checkChoiceRequirements(option, gameState.inventory);
339
- const button = addChoiceButton(option.text, option.next, option.addItem, requirementFailed);
340
- if (!requirementFailed) {
341
- hasAvailableOptions = true;
342
- }
343
- });
344
- }
345
-
346
- // Handle Game Over / No Options / Challenge Page
347
- if (page.gameOver) {
348
- addChoiceButton("Restart Adventure", 1);
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
- // Helper to add choice buttons
363
- function addChoiceButton(text, nextPage, addItem = null, disabled = false, requireItem = null, requireAnyItem = null) {
364
- const button = document.createElement('button');
365
- button.classList.add('choice-button');
366
- button.textContent = text;
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
- if (!disabled) {
381
- button.onclick = () => handleChoiceClick(button.dataset);
382
- }
383
- choicesElement.appendChild(button);
384
- return button; // Return button in case needed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  }
386
 
 
 
387
 
388
- // Check choice requirements (returns true if requirements MET)
389
- function checkChoiceRequirements(option, inventory) {
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 (isNaN(nextPageId)) {
406
- console.error("Invalid nextPageId:", dataset.nextPage);
407
- return;
 
408
  }
409
 
410
- // --- Process Effects of Making the Choice ---
411
- if (itemToAdd && !gameState.inventory.includes(itemToAdd)) {
412
- gameState.inventory.push(itemToAdd);
413
- console.log("Added item:", itemToAdd);
414
- // Add feedback to player? Maybe via story text update?
415
  }
416
 
417
- // --- Move to Next Page ---
418
- const previousPageId = gameState.currentPageId;
419
- gameState.currentPageId = nextPageId;
420
-
421
- const nextPageData = gameData[nextPageId];
422
- if (!nextPageData) {
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
- // --- Process Landing Effects & Challenges ---
435
- let currentPageIdAfterEffects = nextPageId; // Start with the target page ID
 
 
436
 
437
- // Apply HP loss defined on the *landing* page
438
- if (nextPageData.hpLoss) {
439
- gameState.stats.hp -= nextPageData.hpLoss;
440
- console.log(`Lost ${nextPageData.hpLoss} HP. Current HP: ${gameState.stats.hp}`);
441
- if (gameState.stats.hp <= 0) {
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
- // Handle Challenge on the landing page
459
- if (nextPageData.challenge) {
460
- const challenge = nextPageData.challenge;
461
- const reqStat = challenge.stat;
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
- // --- Map and Scene Update Logic ---
534
- function updateScene(currentPageId) {
535
- if (!mapGroup) return;
536
-
537
- // Clear previous map elements
538
- while (mapGroup.children.length > 0) {
539
- mapGroup.remove(mapGroup.children[0]);
540
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
- const currentPageData = gameData[currentPageId];
543
- if (!currentPageData || currentPageData.mapX === undefined || currentPageData.mapY === undefined) {
544
- console.warn("Cannot update scene, current page has no map coordinates:", currentPageId);
545
- // Maybe show a default "limbo" state?
546
- camera.position.set(0, CAMERA_HEIGHT, 0);
547
- camera.lookAt(0, 0, 0);
548
- if (scene.getObjectByName("playerLight")) scene.getObjectByName("playerLight").position.set(0, CAMERA_HEIGHT - 2, 0);
549
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  }
551
 
552
- const currentX = currentPageData.mapX;
553
- const currentY = currentPageData.mapY; // Using Y for Z in 3D
554
-
555
- // Update camera and light position to center on current room
556
- const targetX = currentX * ROOM_SIZE;
557
- const targetZ = currentY * ROOM_SIZE;
558
- camera.position.set(targetX, CAMERA_HEIGHT, targetZ + 0.1); // Offset slightly to avoid z-fighting if looking straight down
559
- camera.lookAt(targetX, 0, targetZ);
560
- const playerLight = scene.getObjectByName("playerLight"); // Get light added in init
561
- if (playerLight) playerLight.position.set(targetX, CAMERA_HEIGHT - 2, targetZ);
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
- // --- Initialization ---
633
- initThreeJS();
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;