File size: 36,698 Bytes
ff7e99a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
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
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
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
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
import * as THREE from 'three';
import * as CANNON from 'cannon-es';
// import CannonDebugger from 'cannon-es-debugger'; // Keep commented for now

// --- DOM Elements ---
const sceneContainer = document.getElementById('scene-container');
const statsElement = document.getElementById('stats-display');
const inventoryElement = document.getElementById('inventory-display');
const logElement = document.getElementById('log-display');

// --- Config ---
const ROOM_SIZE = 10;
const WALL_HEIGHT = 3; // Slightly shorter walls
const WALL_THICKNESS = 0.5;
const CAMERA_Y_OFFSET = 25; // Higher camera
const PLAYER_SPEED = 6;
const PLAYER_RADIUS = 0.5;
const PLAYER_HEIGHT = 1.8; // Visual height
const PLAYER_JUMP_IMPULSE = 7; // Impulse force for jump
const PROJECTILE_SPEED = 15;
const PROJECTILE_RADIUS = 0.2;
const PICKUP_RADIUS = 1.5;
const PICKUP_RADIUS_SQ = PICKUP_RADIUS * PICKUP_RADIUS; // Use squared distance

// --- Three.js Setup ---
let scene, camera, renderer;
let playerMesh;
const meshesToSync = []; // Holds { mesh: THREE.Mesh, body: CANNON.Body }
const projectiles = []; // Holds { mesh, body, lifetime }
let axesHelper;

// --- Physics Setup ---
let world;
let playerBody;
const physicsBodies = new Map(); // Use Map<id, CANNON.Body> for easier removal
let groundMaterial, playerMaterial, wallMaterial, monsterMaterial, itemMaterial; // Physics materials
let cannonDebugger = null;

// --- Game State ---
let gameState = {};
const keysPressed = {};
let gameLoopActive = false;
let animationFrameId = null;
let selectedTool = null; // For building tools later

// --- Game Data (Ensure starting point 0,0 exists!) ---
const gameData = {
    // Structure: "x,y": { type, features?, items?, monsters?, name? }
    "0,0": { type: 'city', features: ['door_north', 'item_Key'], name: "City Square"},
    "0,1": { type: 'forest', features: ['path_north', 'door_south', 'item_Healing_Potion'], name: "Forest Entrance" },
    "0,2": { type: 'forest', features: ['path_south', 'monster_goblin'], name: "Deep Forest"},
    "1,1": { type: 'forest', features: ['river', 'path_west'], name: "River Bend" },
    "-1,1": { type: 'ruins', features: ['path_east'], name: "Old Ruins"},
    "1,0": { type: 'plains', features: ['door_west'], name: "East Plains"},
    "-1,0": { type: 'plains', features: ['door_east'], name: "West Plains"},
};

const itemsData = { // Add 'name' for consistency
    "Healing Potion": { name: "Healing Potion", type: "consumable", description: "Restores 10 HP.", hpRestore: 10, model: 'sphere_red' },
    "Key": { name: "Key", type: "quest", description: "A rusty key.", model: 'box_gold'},
};

const monstersData = { // Add 'name'
    "goblin": { name: "Goblin", hp: 15, attack: 4, defense: 1, speed: 2.5, model: 'capsule_green', xp: 5 },
};

// --- Materials and Geometries (Defined once) ---
// Using basic wireframe materials for initial debugging
const debugMaterials = {
    player: new THREE.MeshBasicMaterial({ color: 0x0077ff, wireframe: true }),
    wall: new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true }),
    item: new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }),
    monster: new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true }),
    projectile: new THREE.MeshBasicMaterial({ color: 0xffff00 }), // Keep projectiles solid
    floor_default: new THREE.MeshBasicMaterial({ color: 0x555555, wireframe: true }), // Wireframe floor
};
// Normal Materials (Use after debugging)
// const normalMaterials = {
//     player: new THREE.MeshLambertMaterial({ color: 0x0077ff }),
//     wall: new THREE.MeshLambertMaterial({ color: 0x888888 }),
//     item_consumable: new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.6 }),
//     item_quest: new THREE.MeshStandardMaterial({ color: 0xffd700, roughness: 0.4 }),
//     monster_goblin: new THREE.MeshLambertMaterial({ color: 0x00ff00 }),
//     floor_city: new THREE.MeshLambertMaterial({ color: 0xdeb887 }),
//     floor_forest: new THREE.MeshLambertMaterial({ color: 0x228B22 }),
//      // ... add all floor types
//     floor_default: new THREE.MeshLambertMaterial({ color: 0xaaaaaa })
// };
const activeMaterials = debugMaterials; // Switch to normalMaterials when ready

const geometries = { // Keep geometries simple
    floor: new THREE.PlaneGeometry(ROOM_SIZE, ROOM_SIZE),
    wallNS: new THREE.BoxGeometry(ROOM_SIZE, WALL_HEIGHT, WALL_THICKNESS),
    wallEW: new THREE.BoxGeometry(WALL_THICKNESS, WALL_HEIGHT, ROOM_SIZE),
    playerCylinder: new THREE.CylinderGeometry(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT - (PLAYER_RADIUS * 2), 8),
    playerSphere: new THREE.SphereGeometry(PLAYER_RADIUS, 8, 8),
    playerNose: new THREE.ConeGeometry(PLAYER_RADIUS * 0.3, PLAYER_RADIUS * 0.5, 4),
    monsterCapsuleBody: new THREE.CylinderGeometry(0.4, 0.4, 1.0, 8),
    monsterCapsuleHead: new THREE.SphereGeometry(0.4, 8, 8),
    itemSphere: new THREE.SphereGeometry(0.3, 8, 8),
    itemBox: new THREE.BoxGeometry(0.4, 0.4, 0.4),
    projectile: new THREE.SphereGeometry(PROJECTILE_RADIUS, 6, 6),
};


// --- Initialization ---

function init() {
    console.log("--- Initializing Game ---");
    // Reset state and clear arrays/maps
    gameState = { inventory: [], stats: { hp: 30, maxHp: 30, strength: 7, wisdom: 5, courage: 6 }, monsters: [], items: [] };
    keysPressed = {};
    meshesToSync.length = 0;
    projectiles.length = 0;
    physicsBodies.clear(); // Clear the map

    // Clear previous scene if restarting
    if (scene) {
        while (scene.children.length > 0) scene.remove(scene.children[0]);
    } else {
        scene = new THREE.Scene(); // Create scene only if it doesn't exist
    }
    scene.background = new THREE.Color(0x1a1a1a);

    // Clear previous renderer if restarting
    if (renderer) {
        renderer.dispose();
        if (renderer.domElement.parentNode) {
            renderer.domElement.parentNode.removeChild(renderer.domElement);
        }
    }

    initThreeJS(); // Setup camera, renderer, lights
    initPhysics(); // Setup world, materials
    initPlayer();  // Setup player mesh + body
    generateMap(); // Setup static map meshes + bodies
    setupInputListeners();
    updateUI();
    addLog("Welcome! Move: QWEASDZXC, Jump: X, Attack: Space, Pickup: F", "info");
    console.log("--- Initialization Complete ---");

    if (animationFrameId) cancelAnimationFrame(animationFrameId);
    gameLoopActive = true;
    animate();
}

function initThreeJS() {
    console.log("Initializing Three.js...");
    const aspect = sceneContainer.clientWidth / sceneContainer.clientHeight;
    camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000);
    // Set camera directly overhead looking down
    camera.position.set(0, CAMERA_Y_OFFSET, 0);
    camera.lookAt(0, 0, 0); // Look straight down at origin initially
    camera.rotation.x = -Math.PI / 2; // Explicitly set rotation if lookAt isn't enough

    renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    sceneContainer.appendChild(renderer.domElement);

    // Lighting
    scene.add(new THREE.AmbientLight(0xffffff, 0.7)); // Brighter ambient for debug
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
    dirLight.position.set(10, 30, 20); // Angled light
    dirLight.castShadow = true;
    dirLight.shadow.mapSize.width = 1024;
    dirLight.shadow.mapSize.height = 1024;
    scene.add(dirLight);

    // Axes Helper
    axesHelper = new THREE.AxesHelper(ROOM_SIZE * 0.5);
    axesHelper.position.set(0, 0.01, 0);
    scene.add(axesHelper);
    console.log("Three.js Initialized. Camera at:", camera.position);

    window.addEventListener('resize', onWindowResize, false);
}

function initPhysics() {
    console.log("Initializing Cannon-es...");
    world = new CANNON.World({ gravity: new CANNON.Vec3(0, -15, 0) });
    world.broadphase = new CANNON.SAPBroadphase(world);
    world.allowSleep = true;

    // Define materials
    groundMaterial = new CANNON.Material("ground");
    playerMaterial = new CANNON.Material("player");
    wallMaterial = new CANNON.Material("wall");
    monsterMaterial = new CANNON.Material("monster");
    itemMaterial = new CANNON.Material("item"); // Not used for physics bodies anymore

    // Ground physics body
    const groundShape = new CANNON.Plane();
    const groundBody = new CANNON.Body({ mass: 0, material: groundMaterial });
    groundBody.addShape(groundShape);
    groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
    world.addBody(groundBody);
    physicsBodies.set(groundBody.id, groundBody); // Track it

    // Contact Materials
    const playerGround = new CANNON.ContactMaterial(playerMaterial, groundMaterial, { friction: 0.1, restitution: 0.1 }); // Low friction
    const playerWall = new CANNON.ContactMaterial(playerMaterial, wallMaterial, { friction: 0.01, restitution: 0.1 }); // Very low friction vs walls
    const monsterGround = new CANNON.ContactMaterial(monsterMaterial, groundMaterial, { friction: 0.4, restitution: 0.1 });
    const monsterWall = new CANNON.ContactMaterial(monsterMaterial, wallMaterial, { friction: 0.1, restitution: 0.2 });

    world.addContactMaterial(playerGround);
    world.addContactMaterial(playerWall);
    world.addContactMaterial(monsterGround);
    world.addContactMaterial(monsterWall);
    console.log("Physics World Initialized.");

    // Optional: Physics Debugger Init
    // try { cannonDebugger = new CannonDebugger(scene, world, { color: 0x00ff00 }); console.log("Debugger Init."); }
    // catch (e) { console.error("Failed CannonDebugger.", e); }
}

// --- Primitive Assembly Functions ---
function createPlayerMesh() {
    const group = new THREE.Group();
    const bodyMat = activeMaterials.player; // Use active material set
    const body = new THREE.Mesh(geometries.playerCylinder, bodyMat);
    body.position.y = PLAYER_RADIUS; body.castShadow = true;
    const head = new THREE.Mesh(geometries.playerSphere, bodyMat);
    head.position.y = PLAYER_HEIGHT - PLAYER_RADIUS; head.castShadow = true;
    group.add(body); group.add(head);
    const nose = new THREE.Mesh(geometries.playerNose, new THREE.MeshBasicMaterial({ color: 0xffff00 })); // Nose always bright yellow
    nose.position.set(0, PLAYER_HEIGHT * 0.6, PLAYER_RADIUS); // Point forward +Z
    // No rotation needed if pointing +Z
    group.add(nose);
    // Important: Set group's origin to the intended base/center of the physics body
    group.position.set(0, 0, 0); // Position will be controlled by sync
    return group;
 }
function createSimpleMonsterMesh(modelType = 'capsule_green') {
    const group = new THREE.Group();
    const mat = activeMaterials.monster; // Use active debug/normal material
    if (modelType === 'capsule_green') {
        const body = new THREE.Mesh(geometries.monsterCapsuleBody, mat);
        const head = new THREE.Mesh(geometries.monsterCapsuleHead, mat);
        body.position.y = 0.5; head.position.y = 1.0 + 0.4;
        group.add(body); group.add(head);
    } else { /* Add other monster types if needed */ }
    group.traverse(child => { if (child.isMesh) child.castShadow = true; });
    group.position.set(0, 0, 0); // Position set by sync
    return group;
}
function createSimpleItemMesh(modelType = 'sphere_red') {
     let geom;
     const mat = activeMaterials.item; // Use active debug/normal material
     if(modelType === 'sphere_red') geom = geometries.itemSphere;
     else if (modelType === 'box_gold') geom = geometries.itemBox;
     else geom = geometries.itemSphere; // Default
     const mesh = new THREE.Mesh(geom, mat);
     mesh.position.y = PLAYER_RADIUS; // Default height
     mesh.castShadow = true;
     return mesh;
}
function createProjectileMesh() {
    const mat = activeMaterials.projectile; // Use active material
    const mesh = new THREE.Mesh(geometries.projectile, mat);
    return mesh;
}

// --- Player Setup ---
function initPlayer() {
    console.log("Initializing Player...");
    // Visual Mesh
    playerMesh = createPlayerMesh();
    // Set initial position high to avoid spawning inside floor
    playerMesh.position.set(0, PLAYER_HEIGHT + 1, 0);
    scene.add(playerMesh);
    console.log("Player mesh added to scene at initial pos:", playerMesh.position);

    // Physics Body - Start at center of 0,0 cell, slightly above ground
    const startX = 0;
    const startZ = 0;
    const startY = PLAYER_HEIGHT * 1.5; // Start higher
    const playerShape = new CANNON.Sphere(PLAYER_RADIUS);
    playerBody = new CANNON.Body({
        mass: 70,
        shape: playerShape,
        position: new CANNON.Vec3(startX, startY, startZ),
        linearDamping: 0.95,
        angularDamping: 1.0, // Completely prevent angular rotation from physics
        material: playerMaterial // Use defined material
    });
    playerBody.allowSleep = false;
    playerBody.addEventListener("collide", handlePlayerCollision);
    world.addBody(playerBody);
    physicsBodies.set(playerBody.id, playerBody); // Track body using Map
    console.log(`Player physics body added at ${startX}, ${startY}, ${startZ}`);

    // Add to sync list
    meshesToSync.push({ mesh: playerMesh, body: playerBody });
    console.log("Player added to sync list.");
}

// --- Map Generation ---
function generateMap() {
    console.log("Generating Map...");
    const wallPhysicsMaterial = world.materials.find(m => m.name === "wall");
    const groundPhysicsMaterial = world.materials.find(m => m.name === "ground");

    // DEBUG: Only generate the starting cell "0,0"
    const startCoord = "0,0";
    const data = gameData[startCoord];
    if (!data) {
        console.error("CRITICAL: No gameData found for starting cell '0,0'!");
        return;
    }

    const x = 0;
    const z = 0;
    const type = data.type || 'default';
    console.log(`Generating START cell: ${startCoord} (Type: ${type})`);

    // Create Floor Mesh
    const floorMat = activeMaterials[`floor_${type}`] || activeMaterials.floor_default; // Use debug mats
    const floorMesh = new THREE.Mesh(geometries.floor, floorMat);
    floorMesh.rotation.x = -Math.PI / 2;
    floorMesh.position.set(x * ROOM_SIZE, 0, z * ROOM_SIZE);
    floorMesh.receiveShadow = true; // Floors should receive shadows
    scene.add(floorMesh);
    console.log(`Added floor at ${x},${z}`);

    // Create Walls (Visual + Physics) for the single cell
    const features = data.features || [];
    const wallDefs = [
        ['north', geometries.wallNS, 0, -0.5], ['south', geometries.wallNS, 0, 0.5],
        ['east', geometries.wallEW, 0.5, 0], ['west', geometries.wallEW, -0.5, 0],
    ];

    wallDefs.forEach(([dir, geom, xOff, zOff]) => {
        const doorFeature = `door_${dir}`; const pathFeature = `path_${dir}`;
        if (!features.includes(doorFeature) && !features.includes(pathFeature)) {
            const wallX = x * ROOM_SIZE + xOff * ROOM_SIZE;
            const wallZ = z * ROOM_SIZE + zOff * ROOM_SIZE;
            const wallY = WALL_HEIGHT / 2;

            // Visual Wall (DEBUG)
            const wallMesh = new THREE.Mesh(geom, activeMaterials.wall); // Use debug wall mat
            wallMesh.position.set(wallX, wallY, wallZ);
            wallMesh.castShadow = true; wallMesh.receiveShadow = true;
            scene.add(wallMesh);

            // Physics Wall
            const wallShape = new CANNON.Box(new CANNON.Vec3(geom.parameters.width / 2, geom.parameters.height / 2, geom.parameters.depth / 2));
            const wallBody = new CANNON.Body({ mass: 0, shape: wallShape, position: new CANNON.Vec3(wallX, wallY, wallZ), material: wallPhysicsMaterial });
            world.addBody(wallBody);
            physicsBodies.set(wallBody.id, wallBody); // Track it
            // console.log(`Added ${dir} wall for cell ${x},${z}`);
        } else {
             console.log(`Skipping ${dir} wall for cell ${x},${z} due to feature.`);
        }
    });

    // Spawn Items & Monsters based on features for the single cell
    features.forEach(feature => {
         if (feature.startsWith('item_')) {
             const itemName = feature.substring(5).replace(/_/g, ' ');
             if (itemsData[itemName]) spawnItem(itemName, x, z);
             else console.warn(`Item feature found but no data for: ${itemName}`);
         } else if (feature.startsWith('monster_')) {
             const monsterType = feature.substring(8);
             if (monstersData[monsterType]) spawnMonster(monsterType, x, z);
             else console.warn(`Monster feature found but no data for: ${monsterType}`);
         }
     });

    console.log("Map Generation Complete (Debug Mode - Single Cell).");
}

// --- Item/Monster Spawning (Simplified for Pickup 'F') ---
function spawnItem(itemName, gridX, gridZ) {
    const itemData = itemsData[itemName];
    if (!itemData) return;
    const x = gridX * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.4);
    const z = gridZ * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.4);
    const y = PLAYER_RADIUS; // Item height

    // Use DEBUG material
    const mesh = createSimpleItemMesh(itemData.model); // Use debug mat via create func
    if (!mesh) return; // Safety check
    mesh.position.set(x, y, z);
    mesh.userData = { type: 'item', name: itemName }; // Store data on mesh
    mesh.castShadow = true;
    scene.add(mesh);

    // Store item in game state WITHOUT physics body
    gameState.items.push({
        id: mesh.uuid, // Use mesh UUID as item ID
        name: itemName,
        mesh: mesh,
        position: mesh.position // Store position for proximity check
    });
    console.log(`Spawned item ${itemName} at ${x.toFixed(1)}, ${y.toFixed(1)}, ${z.toFixed(1)} (visual only)`);
}

function spawnMonster(monsterType, gridX, gridZ) {
    const monsterData = monstersData[monsterType];
    if (!monsterData) return;
    const x = gridX * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.4);
    const z = gridZ * ROOM_SIZE + (Math.random() - 0.5) * (ROOM_SIZE * 0.4);
    const y = PLAYER_HEIGHT; // Start higher

    // Use DEBUG material
    const mesh = createSimpleMonsterMesh(monsterData.model); // Uses debug mat via create func
    if (!mesh) return; // Safety check
    mesh.position.set(x, y, z); // Initial visual position
    mesh.userData = { type: 'monster', monsterType: monsterType };
    scene.add(mesh);

    // Physics Body
    const shape = new CANNON.Sphere(PLAYER_RADIUS * 0.8);
    const body = new CANNON.Body({
        mass: 10, shape: shape, position: new CANNON.Vec3(x, y, z),
        linearDamping: 0.8, angularDamping: 0.9,
        material: world.materials.find(m => m.name === "monster") || new CANNON.Material("monster")
    });
    body.allowSleep = true;
    // Store reference to mesh and HP on body's userData
    body.userData = { type: 'monster', monsterType: monsterType, mesh: mesh, hp: monsterData.hp };
    world.addBody(body);

    gameState.monsters.push({ id: body.id, type: monsterType, hp: monsterData.hp, body: body, mesh: mesh });
    meshesToSync.push({ mesh: mesh, body: body });
    physicsBodies.set(body.id, body); // Use map for tracking
    console.log(`Spawned monster ${monsterType} at ${x.toFixed(1)}, ${y.toFixed(1)}, ${z.toFixed(1)}`);
}


// --- Input Handling (QWEASDZXC + Jump 'X' + F + Space) ---
function setupInputListeners() { /* ... same ... */
    console.log("Setting up input listeners.");
    window.addEventListener('keydown', (event) => { keysPressed[event.key.toLowerCase()] = true; keysPressed[event.code] = true; if (['space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'keyx', 'keyf'].includes(event.code.toLowerCase())) { event.preventDefault(); }});
    window.addEventListener('keyup', (event) => { keysPressed[event.key.toLowerCase()] = false; keysPressed[event.code] = false; });
}

function handleInput(deltaTime) {
    if (!playerBody) return;

    const moveSpeed = PLAYER_SPEED;
    // Correct forward/backward/strafe directions relative to world axes
    // Assume +Z is SOUTH, -Z is NORTH
    // Assume +X is EAST, -X is WEST
    let forceX = 0;
    let forceZ = 0;

    // Forward (-Z) / Backward (+Z)
    if (keysPressed['w']) forceZ -= 1; // W = North = -Z
    if (keysPressed['s']) forceZ += 1; // S = South = +Z

    // Strafe Left (-X) / Right (+X)
    if (keysPressed['a']) forceX -= 1; // A = West = -X
    if (keysPressed['d']) forceX += 1; // D = East = +X

    // Diagonals - combine basic directions
    if (keysPressed['q']) { forceZ -= 0.707; forceX -= 0.707; } // Q = NW
    if (keysPressed['e']) { forceZ -= 0.707; forceX += 0.707; } // E = NE
    if (keysPressed['z']) { forceZ += 0.707; forceX -= 0.707; } // Z = SW
    if (keysPressed['c']) { forceZ += 0.707; forceX += 0.707; } // C = SE

    // --- Calculate final velocity vector ---
    // We set velocity directly for more responsive control than forces
    const targetVelocity = new CANNON.Vec3(0, playerBody.velocity.y, 0); // Preserve Y velocity

    if (forceX !== 0 || forceZ !== 0) {
        const moveVec = new THREE.Vector2(forceX, forceZ);
        if (moveVec.lengthSq() > 1.0) { // Normalize only if combined magnitude > 1
            moveVec.normalize();
        }
        targetVelocity.x = moveVec.x * moveSpeed;
        targetVelocity.z = moveVec.y * moveSpeed;

        // --- Rotation ---
        // Rotate player mesh to face movement direction
        // Calculate angle from positive Z axis (adjust if your model faces differently)
        const angle = Math.atan2(targetVelocity.x, targetVelocity.z);
        const targetQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle);
        // Slerp for smooth rotation
        playerMesh.quaternion.slerp(targetQuaternion, 0.15); // Adjust 0.15 for responsiveness

    } else {
        // No movement keys pressed - rely on linear damping to stop
        // Or force stop X/Z velocity:
         targetVelocity.x = 0;
         targetVelocity.z = 0;
    }

    playerBody.velocity.x = targetVelocity.x;
    playerBody.velocity.z = targetVelocity.z;
    // Y velocity is controlled by gravity + jump impulse

    // --- Jump (X) ---
    if (keysPressed['x'] || keysPressed['keyx']) { // Check code too
        // !! Basic jump - Needs ground check via raycast or contact materials !!
        // Add a simple cooldown or only allow if Y velocity is low
         if (Math.abs(playerBody.velocity.y) < 0.5) { // Crude check if mostly grounded
             console.log("JUMP!");
             addLog("Jump!", "info");
             // Apply impulse upwards
             playerBody.applyImpulse(new CANNON.Vec3(0, PLAYER_JUMP_IMPULSE, 0), playerBody.position);
        }
        keysPressed['x'] = false; keysPressed['keyx'] = false; // Consume jump input
    }

    // --- Pickup (F) ---
    if (keysPressed['f'] || keysPressed['keyf']) {
        pickupNearbyItem();
        keysPressed['f'] = false; keysPressed['keyf'] = false; // Consume pickup input
    }

    // --- Fire (Space) ---
    if (keysPressed['space']) {
        fireProjectile();
        keysPressed['space'] = false; // Consume fire input
    }
}

// --- Interaction Logic ---
function pickupNearbyItem() {
    if (!playerBody) return;
    const playerPos = playerBody.position;
    let pickedUp = false;
    let closestItemIndex = -1;
    let minDistanceSq = PICKUP_RADIUS_SQ; // Use squared distance

    // Find the closest item within radius
    for (let i = 0; i < gameState.items.length; i++) {
        const item = gameState.items[i];
        const distanceSq = playerPos.distanceSquared(item.position);
        if (distanceSq < minDistanceSq) {
            minDistanceSq = distanceSq;
            closestItemIndex = i;
        }
    }

    // Pick up the closest item if found
    if (closestItemIndex > -1) {
        const item = gameState.items[closestItemIndex];
        if (!gameState.inventory.includes(item.name)) {
            gameState.inventory.push(item.name);
            addLog(`Picked up ${item.name}!`, "pickup");
            updateInventoryDisplay();

            // Remove item visually and from state
            if(item.mesh.parent) scene.remove(item.mesh);
            gameState.items.splice(closestItemIndex, 1);
            pickedUp = true;
        } else {
             addLog(`Already have ${item.name}.`, "info");
        }
    }

    if (!pickedUp) {
        addLog("Nothing nearby to pick up.", "info");
    }
}


// --- Combat (Projectile logic remains similar) ---
function fireProjectile() {
    if (!playerBody || !playerMesh) return;
    addLog("Pew!", "combat");
    const projectileMesh = createProjectileMesh();
    const projectileShape = new CANNON.Sphere(PROJECTILE_RADIUS);
    const projectileBody = new CANNON.Body({ mass: 0.1, shape: projectileShape, linearDamping: 0.01, angularDamping: 0.01 });
    projectileBody.addEventListener("collide", handleProjectileCollision);

    // Calculate start position and velocity based on player's *current* rotation
    const offsetDistance = PLAYER_RADIUS + PROJECTILE_RADIUS + 0.1;
    const direction = new THREE.Vector3(0, 0, 1); // Base direction +Z
    direction.applyQuaternion(playerMesh.quaternion); // Rotate based on player mesh

    const startPos = new CANNON.Vec3().copy(playerBody.position).vadd(
        new CANNON.Vec3(direction.x, 0, direction.z).scale(offsetDistance) // Offset horizontally
    );
    // Adjust start Y based on player body center + visual model height
    startPos.y = playerBody.position.y; // Fire from player center height

    projectileBody.position.copy(startPos);
    projectileMesh.position.copy(startPos);

    // Velocity in player facing direction
    projectileBody.velocity = new CANNON.Vec3(direction.x, 0, direction.z).scale(PROJECTILE_SPEED);

    scene.add(projectileMesh);
    world.addBody(projectileBody);
    const projectileData = { mesh: projectileMesh, body: projectileBody, lifetime: 2.0 }; // Shorter lifetime
    meshesToSync.push(projectileData);
    projectiles.push(projectileData);
    physicsBodies.set(projectileBody.id, projectileBody); // Track
    projectileBody.userData = { type: 'projectile', data: projectileData };
    projectileMesh.userData = { type: 'projectile', data: projectileData }; // No body ref needed on mesh
}

// --- Collision Handling ---
function handlePlayerCollision(event) {
    const otherBody = event.body;
    if (!otherBody || !otherBody.userData || !playerBody) return;

    // Player <-> Monster (Example: Simple damage, could add knockback)
    if (otherBody.userData.type === 'monster') {
        // Check for cooldown before applying damage again?
        console.log("Player collided with monster", otherBody.id);
        // gameState.stats.hp -= 1;
        // addLog(`Hit by ${otherBody.userData.monsterType || 'monster'}! HP: ${gameState.stats.hp}`, "combat");
        // updateStatsDisplay();
        // if (gameState.stats.hp <= 0) { gameOver("Defeated by a monster!"); }
         // Apply small impulse away from monster
         const impulse = playerBody.position.vsub(otherBody.position).unit().scale(20); // Knockback force
         playerBody.applyImpulse(impulse, playerBody.position);
    }
     // Player <-> Wall (Handled by physics engine)
     // Player <-> Item (Handled by 'F' key)
}

function handleProjectileCollision(event) {
    const projectileBody = event.target;
    const otherBody = event.body;
    if (!projectileBody?.userData || !otherBody?.userData) return; // Null checks

    const projectileData = projectileBody.userData.data;
    let shouldRemoveProjectile = true; // Assume removal unless hitting player

    if (otherBody.userData.type === 'monster') {
        const monsterId = otherBody.id;
        const monsterIndex = gameState.monsters.findIndex(m => m.id === monsterId);
        if (monsterIndex > -1) {
             const monster = gameState.monsters[monsterIndex];
             const damage = gameState.stats.strength + Math.floor(Math.random() * 3); // Add randomness
             monster.hp -= damage;
             otherBody.userData.hp = monster.hp;
             addLog(`Hit ${otherBody.userData.monsterType} for ${damage} damage! (HP: ${monster.hp})`, "combat");

             // Apply impulse to monster
             const impulseDir = projectileBody.velocity.unit();
             otherBody.applyImpulse(impulseDir.scale(damage * 5), projectileBody.position); // Knockback scales with damage


             if (monster.hp <= 0) {
                 addLog(`Defeated ${otherBody.userData.monsterType}!`, "info");
                 // Safely remove monster after physics step using setTimeout
                 setTimeout(() => {
                    if(monster.mesh.parent) scene.remove(monster.mesh);
                    if(world.bodies.includes(monster.body)) world.removeBody(monster.body);
                    meshesToSync = meshesToSync.filter(sync => sync.body.id !== monster.body.id);
                    physicsBodies.delete(monster.body.id);
                    gameState.monsters = gameState.monsters.filter(m => m.id !== monsterId);
                 }, 0);
             }
        }
    } else if (otherBody === playerBody) {
        shouldRemoveProjectile = false; // Don't destroy projectile if it hits the player who fired it
         console.log("Projectile hit player - ignored");
    } else if (otherBody.mass === 0) {
         // Hit a static object like a wall
         console.log("Projectile hit wall/static");
         // Optional: Add impact effect
    }


    if (shouldRemoveProjectile) {
        // Schedule removal might be safer than immediate
         setTimeout(() => removeProjectile(projectileData), 0);
    }
}

function removeProjectile(projectileData) {
    if (!projectileData) return;
    const index = projectiles.indexOf(projectileData);
    if (index === -1) return;

    if (projectileData.mesh?.parent) scene.remove(projectileData.mesh);
    if (projectileData.body && world.bodies.includes(projectileData.body)) world.removeBody(projectileData.body);

    meshesToSync = meshesToSync.filter(sync => sync.body?.id !== projectileData.body?.id);
    if (projectileData.body) physicsBodies.delete(projectileData.body.id);
    projectiles.splice(index, 1);
}


// --- Monster AI ---
function updateMonsters(deltaTime) {
     const agroRangeSq = (ROOM_SIZE * 2) ** 2; // Increased range
     if(!playerBody) return;

     gameState.monsters.forEach(monster => {
          if (!monster || !monster.body || monster.hp <= 0) return; // Skip dead or removed monsters

         const monsterPos = monster.body.position;
         const playerPos = playerBody.position;
         const distanceSq = playerPos.distanceSquared(monsterPos);

         if (distanceSq < agroRangeSq) { // If player is close
             const direction = playerPos.vsub(monsterPos);
             direction.y = 0; // Ignore height difference for movement plan
             if (distanceSq > (PLAYER_RADIUS * 2)**2 && direction.lengthSquared() > 0.1) { // If not too close and direction is valid
                 direction.normalize();
                 const monsterData = monstersData[monster.type];
                 const speed = monsterData?.speed || 1;

                  // Apply velocity for chasing
                  monster.body.velocity.x = direction.x * speed;
                  monster.body.velocity.z = direction.z * speed;

                  // Rotation
                  const angle = Math.atan2(direction.x, direction.z);
                  const targetQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle);
                  monster.mesh.quaternion.slerp(targetQuaternion, 0.1);
             } else { // Too close or direction invalid, stop horizontal movement
                  monster.body.velocity.x = 0;
                  monster.body.velocity.z = 0;
                  // TODO: Add attack logic here if close enough
             }
         } else { // Player too far, stop moving
              monster.body.velocity.x = 0;
              monster.body.velocity.z = 0;
         }
         // Wake up body if needed (should be awake if chasing)
         if(monster.body.sleepState === CANNON.Body.SLEEPING) monster.body.wakeUp();
     });
 }

// --- UI Update Functions ---
function updateUI() { updateStatsDisplay(); updateInventoryDisplay(); }
function updateStatsDisplay() { /* ... same ... */
    let statsHTML = ''; statsHTML += `<span>HP: ${gameState.stats.hp}/${gameState.stats.maxHp}</span>`; statsHTML += `<span>Str: ${gameState.stats.strength}</span>`; statsHTML += `<span>Wis: ${gameState.stats.wisdom}</span>`; statsHTML += `<span>Cor: ${gameState.stats.courage}</span>`; statsElement.innerHTML = statsHTML;
}
function updateInventoryDisplay() { /* ... same ... */
    let inventoryHTML = ''; if (gameState.inventory.length === 0) { inventoryHTML += '<em>Empty</em>'; } else { gameState.inventory.forEach(item => { const itemInfo = itemsData[item] || { type: 'unknown', description: '???' }; const itemClass = `item-${itemInfo.type || 'unknown'}`; inventoryHTML += `<span class="${itemClass}" title="${itemInfo.description}">${item}</span>`; }); } inventoryElement.innerHTML = inventoryHTML;
}
function addLog(message, type = "info") { /* ... same ... */
    const p = document.createElement('p'); p.classList.add(type); p.textContent = `[${new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}] ${message}`; logElement.appendChild(p); logElement.scrollTop = logElement.scrollHeight;
}

// --- Game Over ---
function gameOver(reason) { /* ... same ... */
     addLog(`GAME OVER: ${reason}`, "error"); console.log("Game Over:", reason);
     gameLoopActive = false; // Stop the game loop
     const restartButton = document.createElement('button'); restartButton.textContent = "RESTART GAME"; restartButton.style.cssText = `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 20px 40px; font-size: 2em; cursor: pointer; z-index: 1000; background-color: #ffc107; border: none; border-radius: 5px; color: #333; font-family: 'Courier New', monospace;`;
     restartButton.onclick = () => { if (restartButton.parentNode) restartButton.parentNode.removeChild(restartButton); init(); }; // Re-initialize
     sceneContainer.appendChild(restartButton);
}

// --- Main Game Loop ---
let lastTimestamp = 0;
function animate(timestamp) {
    if (!gameLoopActive) return;
    animationFrameId = requestAnimationFrame(animate);

    const deltaTime = (timestamp - lastTimestamp) * 0.001;
    lastTimestamp = timestamp;
    const dtClamped = Math.min(deltaTime, 1 / 20); // Clamp delta time (max 20 FPS step)

    if (!world || !playerBody || !scene || !camera || !renderer) {
        console.error("Core component missing, stopping loop.");
        gameLoopActive = false;
        return;
    }

    // 1. Input
    handleInput(dtClamped);

    // 2. AI
    updateMonsters(dtClamped);

    // 3. Physics Step
    try { world.step(dtClamped); }
    catch (e) { console.error("Physics step error:", e); gameLoopActive = false; return; } // Stop loop on error

    // 4. Projectiles Update
    for (let i = projectiles.length - 1; i >= 0; i--) {
        const p = projectiles[i];
        if (p) { p.lifetime -= dtClamped; if (p.lifetime <= 0) removeProjectile(p); }
    }

    // 5. Sync Visuals
    meshesToSync.forEach(item => {
        if (item?.body && item?.mesh && item.mesh.parent) { // Check existence
            item.mesh.position.copy(item.body.position);
            item.mesh.quaternion.copy(item.body.quaternion);
        }
    });

    // 6. Update Camera
    if (playerBody && camera) {
         const targetCameraPos = new THREE.Vector3(
             playerBody.position.x,
             CAMERA_Y_OFFSET,
             playerBody.position.z // Look straight down from above player
         );
         camera.position.lerp(targetCameraPos, 0.1); // Smooth follow
         // Camera already looking down due to initial setup / rotation
         // camera.lookAt(playerBody.position.x, 0, playerBody.position.z); // Keep looking at player's feet
    }

    // 7. Physics Debugger (Optional)
    // if (cannonDebugger) cannonDebugger.update();

    // 8. Render
    renderer.render(scene, camera);
}

// --- Window Resize ---
function onWindowResize() {
    if (!renderer || !camera) return;
    const width = sceneContainer.clientWidth;
    const height = sceneContainer.clientHeight;
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    renderer.setSize(width, height);
    console.log("Resized");
}

// --- Start Game ---
init();