diff --git a/DEVLOG.md b/DEVLOG.md index e9039ab..9461a9a 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,5 +1,39 @@ # Devlog - Warhammer Quest (Versión Web 3D) +## Sesión 16: Reglas de Exploración y Refinado de Eventos +**Fecha:** 11 de Enero de 2026 + +### Objetivos +- Ajustar la progresión de la fase de exploración según el manual original (1995). +- Refinar el evento de "Derrumbamiento" para permitir una huida táctica. +- Restaurar la aleatoriedad total en los mazos de juego (Eventos y Mazmorra). + +### Cambios Realizados + +#### 1. Exploración Diferida +- **Revelación en Fase de Héroes**: Ahora los héroes pueden entrar en estancias nuevas durante su fase. La habitación se marca como "visitada", pero no se genera el encuentro inmediatamente. +- **Resolución en Fase de Monstruos**: La carta de evento se roba al inicio de la Fase de Monstruos, antes de que estos actúen. +- **Movimiento Completo**: Los aventureros pueden completar todo su movimiento al entrar en una sala nueva (no se bloquean en la primera casilla), pero su turno termina al llegar a su destino. +- **Finalización Manual**: Se ha eliminado el salto automático de turno al entrar en una sala, permitiendo al jugador gestionar el orden de sus héroes manualmente antes de pasar al siguiente. + +#### 2. Refinado del "Derrumbamiento" (Collapse) +- **Margen de Huida**: Se ha ajustado el contador de colapso a 2 turnos para dar tiempo real a los héroes a salir de la estancia. +- **Exención de Pinning**: Siguiendo el reglamento, los héroes no pueden ser trabados en combate mientras la habitación se derrumba (pueden huir ignorando a los monstruos). +- **Zonas Intransitables**: Una vez colapsada, la estancia se marca físicamente como bloqueada en la cuadrícula, impidiendo cualquier re-entrada. + +#### 3. Restauración de Aleatoriedad +- **Mazo de Eventos**: Eliminados los "cheats" de desarrollo que forzaban el Enano y el Rastrillo. Ahora el mazo es 100% aleatorio. +- **Mazo de Mazmorra**: Reintroducidas todas las secciones especiales (esquinas, cruces en T, escaleras). La composición del mazo vuelve a ser equilibrada y variada. + +#### 4. Correcciones y Mejoras Técnicas +- **Fix tile_0**: La habitación inicial se marca como explorada por defecto para evitar disparos de eventos fantasmas al inicio. +- **Corregida lógica de nombres**: Se ha arreglado un error donde el nombre de la carta robada no se mostraba correctamente en los logs. +- **Restaurada GridSystem**: Corregido un error que impedía colocar tiles tras la última actualización. + +--- + + + ## Sesión 15: Evento del Rastrillo, Llave del Enano e Inventario **Fecha:** 10 de Enero de 2026 diff --git a/public/assets/sfx/gate_chains_close.mp3 b/public/assets/sfx/gate_chains_close.mp3 new file mode 100644 index 0000000..20c6513 Binary files /dev/null and b/public/assets/sfx/gate_chains_close.mp3 differ diff --git a/public/assets/sfx/gate_chains_open.mp3 b/public/assets/sfx/gate_chains_open.mp3 new file mode 100644 index 0000000..d7e649b Binary files /dev/null and b/public/assets/sfx/gate_chains_open.mp3 differ diff --git a/src/engine/data/Events.js b/src/engine/data/Events.js index 6686997..c9cd7a0 100644 --- a/src/engine/data/Events.js +++ b/src/engine/data/Events.js @@ -17,30 +17,5 @@ const shuffleDeck = (deck) => { [deck[i], deck[j]] = [deck[j], deck[i]]; } - // DEBUG: Force ENANO and RASTRILLO to TOP - const enanoIdx = deck.findIndex(c => c.id === 'evt_enano_moribundo'); - const rastrilloIdx = deck.findIndex(c => c.id === 'evt_rastrillo'); - - // Reverse order for unshift - if (rastrilloIdx !== -1) { - const card = deck.splice(rastrilloIdx, 1)[0]; - deck.unshift(card); - } - if (enanoIdx !== -1) { - const card = deck.splice(enanoIdx, 1)[0]; - deck.unshift(card); - console.log("DEBUG: Forced ENANO MORIBUNDO and RASTRILLO to top of deck."); - } - - // DEBUG: Force Chaos Warrior to TOP - /* - const cwIdx = deck.findIndex(c => c.id === 'mon_chaosWarrior'); - if (cwIdx !== -1) { - const card = deck.splice(cwIdx, 1)[0]; - deck.unshift(card); - console.log("DEBUG: Forced CHAOS WARRIOR to top of deck."); - } - */ - return deck; }; diff --git a/src/engine/dungeon/DungeonDeck.js b/src/engine/dungeon/DungeonDeck.js index 49ad4df..8a23a03 100644 --- a/src/engine/dungeon/DungeonDeck.js +++ b/src/engine/dungeon/DungeonDeck.js @@ -15,9 +15,11 @@ export class DungeonDeck { // 1. Create a "Pool" of standard dungeon tiles let pool = []; const composition = [ - { id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms - { id: 'corridor_straight', count: 0 }, - { id: 'junction_t', count: 0 } + { id: 'room_dungeon', count: 12 }, + { id: 'corridor_straight', count: 8 }, + { id: 'corridor_steps', count: 4 }, + { id: 'corridor_corner', count: 4 }, + { id: 'junction_t', count: 4 } ]; composition.forEach(item => { @@ -44,23 +46,19 @@ export class DungeonDeck { return drawn; }; - // --- Step 1 & 2: Bottom Pool --- + // --- Step 1 & 2: Bottom Pool (6 random tiles + Objective) --- const bottomPool = drawRandom(pool, 6); - const objectiveDef = TILES[objectiveTileId]; if (objectiveDef) { bottomPool.push(objectiveDef); - } else { - console.error("Objective Tile ID not found:", objectiveTileId); } - this.shuffleArray(bottomPool); - // --- Step 4: Top Pool --- - const topPool = drawRandom(pool, 6); + // --- Step 3: Top Pool (All remaining tiles in the pool) --- + const topPool = [...pool]; // pool already has those 6 removed by drawRandom this.shuffleArray(topPool); - // --- Step 5: Stack --- + // --- Step 4: Final Stack --- this.cards = [...topPool, ...bottomPool]; diff --git a/src/engine/dungeon/GridSystem.js b/src/engine/dungeon/GridSystem.js index da3cb7c..f15ed37 100644 --- a/src/engine/dungeon/GridSystem.js +++ b/src/engine/dungeon/GridSystem.js @@ -13,6 +13,9 @@ export class GridSystem { // Set of "x,y" strings that are door/exit cells (can cross room boundaries) this.doorCells = new Set(); + // Set of "x,y" strings that are blocked (e.g. collapsed) + this.blockedCells = new Set(); + this.tiles = []; } @@ -141,7 +144,12 @@ export class GridSystem { * Helper to see if a specific global coordinate is occupied */ isOccupied(x, y) { - return this.occupiedCells.has(`${x},${y}`); + const key = `${x},${y}`; + return this.occupiedCells.has(key) && !this.blockedCells.has(key); + } + + isBlocked(x, y) { + return this.blockedCells.has(`${x},${y}`); } /** @@ -162,8 +170,9 @@ export class GridSystem { const data1 = this.cellData.get(key1); const data2 = this.cellData.get(key2); - // Both cells must exist + // Both cells must exist and not be blocked if (!data1 || !data2) return false; + if (this.blockedCells.has(key1) || this.blockedCells.has(key2)) return false; const sameTile = data1.tileId === data2.tileId; const isDoor1 = this.doorCells.has(key1); diff --git a/src/engine/events/EventInterpreter.js b/src/engine/events/EventInterpreter.js index 39cf54e..9199775 100644 --- a/src/engine/events/EventInterpreter.js +++ b/src/engine/events/EventInterpreter.js @@ -275,9 +275,11 @@ export class EventInterpreter { h.inventory.push(action.id_item); if (this.game.onEntityUpdate) this.game.onEntityUpdate(h); }); - await this.log("Hallazgo", `${msg}: ${targets.map(t => t.name).join(", ")} obtiene ${action.id_item}.`); if (this.game.onShowMessage) { + // One prominent message. The log will also receive it if main.js is configured to mirror it or if we call log separately. + // Let's call log with a type that ensures it's NOT a popup, and use onShowMessage for the popup. + await this.log("Efecto", `${targets.map(t => t.name).join(", ")} obtiene ${action.id_item}.`); this.game.onShowMessage("OBJETO", `${msg}`); } } @@ -312,9 +314,11 @@ export class EventInterpreter { contextTileId = this.game.currentEventContext.tileId; } + const skip = this.game.currentEventContext && this.game.currentEventContext.source === 'exploration'; + const spots = this.game.findSpawnPoints(count, contextTileId); spots.forEach(spot => { - this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: false }); + this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: skip }); }); // KEEP MODAL for Spawn - it's a major event that requires immediate player attention diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index 01cd463..fc85a83 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -27,6 +27,8 @@ export class GameEngine { this.visitedRoomIds = new Set(); // Track tiles triggered this.eventDeck = createEventDeck(); this.lastEntranceUsed = null; + this.pendingExploration = null; + this.exploredRoomIds = new Set(); // Callbacks this.onEntityUpdate = null; @@ -46,7 +48,10 @@ export class GameEngine { this.dungeon.startDungeon(missionConfig); - // Create Party (4 Heroes) + // Starting room is already explored + this.exploredRoomIds.add('tile_0'); + this.visitedRoomIds.add('tile_0'); + // Create Party (4 Heroes) this.createParty(); @@ -84,7 +89,7 @@ export class GameEngine { if (data.eventTriggered) { console.log("[GameEngine] Power Event Triggered! Waiting to handle..."); // Determine if we need to draw a card or if it's a specific message - setTimeout(() => this.handlePowerEvent(), 1500); + setTimeout(() => this.handlePowerEvent({ source: 'power' }), 1500); } }); @@ -640,6 +645,12 @@ export class GameEngine { // If already escaped this turn, not pinned if (entity.hasEscapedPin) return false; + // RULE: No pinning in a collapsing room (Panic/Rubble distraction) + if (this.state && this.state.collapsingRoom) { + const tileId = this.dungeon.grid.occupiedCells.get(`${entity.x},${entity.y}`); + if (tileId === this.state.collapsingRoom.tileId) return false; + } + return this.monsters.some(m => { if (m.isDead) return false; const dx = Math.abs(entity.x - m.x); @@ -791,63 +802,43 @@ export class GameEngine { // 2. Check for New Tile Entry const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`); - if (tileId && !this.visitedRoomIds.has(tileId)) { - - // Mark as visited immediatley - this.visitedRoomIds.add(tileId); - - // Check Tile Type (Room vs Corridor) + if (tileId) { const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId); const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective')); + const isUnexploredRoom = isRoom && !this.exploredRoomIds.has(tileId); - if (isRoom) { - console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`); - - triggeredEvents = true; // Stop movement forces end of hero action - entity.currentMoves = 0; - - // Send PARTIAL path to renderer (from 0 to current step i+1) - if (this.onEntityMove) { - this.onEntityMove(entity, fullPath.slice(0, i + 1)); + if (isUnexploredRoom) { + if (!this.pendingExploration) { + console.log(`[GameEngine] First hero ${entity.name} entered UNEXPLORED ROOM: ${tileId}`); + if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Preparando encuentro...", 2000); + this.pendingExploration = { tileId: tileId, source: 'exploration' }; } - - if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Explorando...", 2000); - - // IMMEDIATE EVENT RESOLUTION - this.handlePowerEvent({ tileId: tileId }, () => { - console.log("[GameEngine] Room Event Resolved."); - - // Check for Monsters - const hasMonsters = this.monsters.some(m => !m.isDead); - if (hasMonsters) { - console.log("[GameEngine] Monsters Spawned! Ending Hero Phase."); - this.turnManager.setPhase('monster'); - } else { - console.log("[GameEngine] No Monsters. Staying in Hero Phase."); - } - }); - - break; // Stop loop - } else { - console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`); + triggeredEvents = true; // Use this flag to end turn AFTER movement + } else if (!this.visitedRoomIds.has(tileId)) { + this.visitedRoomIds.add(tileId); } } } - // If NO interruption, send full path - if (!triggeredEvents) { - if (this.onEntityMove) { - this.onEntityMove(entity, fullPath); - } + // Always send full path to renderer since we no longer interrupt movement + if (this.onEntityMove) { + this.onEntityMove(entity, fullPath); } // Deduct Moves if (entity.currentMoves !== undefined) { - // Only deduct steps actually taken. No penalty. - entity.currentMoves -= stepsTaken; - if (entity.currentMoves < 0) entity.currentMoves = 0; + // If we entered a new room, moves drop to 0 immediately upon completion. + if (triggeredEvents) { + entity.currentMoves = 0; + } else { + entity.currentMoves -= stepsTaken; + if (entity.currentMoves < 0) entity.currentMoves = 0; + } } + // Notify UI of move change + if (this.onEntityUpdate) this.onEntityUpdate(entity); + // AUTO-DESELECT LOGIC // In Hero Phase, we want to KEEP the active hero selected to avoid re-selecting. const isHeroPhase = this.turnManager.currentPhase === 'hero'; @@ -909,6 +900,9 @@ export class GameEngine { const [x, y] = key.split(',').map(Number); + // Check if cell is blocked (collapsed) + if (this.dungeon.grid.blockedCells.has(key)) continue; + // Check Collision: Do not spawn on Heroes or existing Monsters const isHero = this.heroes.some(h => h.x === x && h.y === y); const isMonster = this.monsters.some(m => m.x === x && m.y === y && !m.isDead); @@ -1024,6 +1018,18 @@ export class GameEngine { // ========================================= async playMonsterTurn() { + // 1. Resolve pending exploration from Hero Phase + if (this.pendingExploration) { + const context = { ...this.pendingExploration }; + this.pendingExploration = null; + + console.log("[GameEngine] Resolving pending exploration at start of Monster Phase."); + await new Promise(resolve => { + this.handlePowerEvent(context, resolve); + }); + } + + // 2. Execute AI for existing/new monsters if (this.ai) { await this.ai.executeTurn(); } @@ -1373,16 +1379,20 @@ export class GameEngine { // (Maybe put back in deck? Rules say 'ignore and draw another immediately', usually means discard this one) if (this.eventDeck.length === 0) this.eventDeck = createEventDeck(); card = this.eventDeck.shift(); - console.log(`[GameEngine] Redrawn Card: ${card.name}`); + console.log(`[GameEngine] Redrawn Card: ${card.titulo}`); } } - console.log(`[GameEngine] Drawn Card: ${card.name}`, card); + console.log(`[GameEngine] Drawn Card: ${card.titulo}`, card); // Delegate execution to the modular interpreter if (this.events) { this.events.processEvent(card, () => { this.currentEventContext = null; + // Mark room as explored if it was an exploration source + if (context && context.tileId) { + this.exploredRoomIds.add(context.tileId); + } if (onComplete) onComplete(); else this.turnManager.resumeFromEvent(); }); @@ -1402,7 +1412,13 @@ export class GameEngine { console.log(`[GameEngine] Collapsing Room Timer: ${this.state.collapsingRoom.turnsLeft}`); if (this.state.collapsingRoom.turnsLeft > 0) { - if (this.onShowMessage) this.onShowMessage("¡PELIGRO!", `El techo cruje... ¡Queda ${this.state.collapsingRoom.turnsLeft} turno para salir!`, 4000); + const msg = this.state.collapsingRoom.turnsLeft === 1 ? + "¡ÚLTIMO AVISO! El techo está a punto de ceder..." : + `El techo cruje peligrosamente... Tenéis ${this.state.collapsingRoom.turnsLeft} turnos para salir.`; + + if (this.onShowMessage) { + this.onShowMessage("¡PELIGRO!", msg, 5000); + } } else { // TIME'S UP - KILL EVERYONE IN ROOM this.killEntitiesInCollapsingRoom(this.state.collapsingRoom.tileId); @@ -1451,7 +1467,18 @@ export class GameEngine { if (this.onEntityDeath) this.onEntityDeath(e.id); }); - // Clear state so it doesn't kill again (optional, room is gone) + // Mark room as INTRANSITABLE (Blocked cells) + for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) { + if (tid === tileId) { + this.dungeon.grid.blockedCells.add(key); + } + } + + if (this.onShowMessage) { + this.onShowMessage("¡DERRUMBE TOTAL!", "La estancia ha colapsado. Ahora es un montón de escombros intransitable."); + } + + // Clear state so it doesn't kill again this.state.collapsingRoom = null; } collapseExits() { @@ -1473,7 +1500,7 @@ export class GameEngine { if (!this.state) this.state = {}; this.state.collapsingRoom = { tileId: currentTileId, - turnsLeft: 1 // Next monster phase ends turn -> Next power phase -> Next monster phase = Death + turnsLeft: 2 // Gives them Turn N + Turn N+1 to escape. }; // 2. Scan Available Exits to see which align with this Room @@ -1502,9 +1529,13 @@ export class GameEngine { if (isAdjacent) { exitsToRemove.push(index); + const exitKey = `${exit.x},${exit.y}`; this.placeEventMarker("escombros", exit.x, exit.y); - blockedLocations.add(`${exit.x},${exit.y}`); + blockedLocations.add(exitKey); + + // Immediately block movement through these cells + this.dungeon.grid.blockedCells.add(exitKey); // Also block the door visually in Renderer if (window.RENDERER) { @@ -1563,6 +1594,9 @@ export class GameEngine { blockPortcullisAtEntrance() { if (this.lastEntranceUsed && window.RENDERER) { window.RENDERER.blockDoorWithPortcullis(this.lastEntranceUsed); + if (window.SOUND_MANAGER) { + window.SOUND_MANAGER.playSound('gate_chains'); + } if (this.onShowMessage) { this.onShowMessage("¡RASTRILLO!", "Un pesado rastrillo de hierro cae a vuestras espaldas, bloqueando la entrada."); } diff --git a/src/engine/game/TurnManager.js b/src/engine/game/TurnManager.js index 160dde1..f00b891 100644 --- a/src/engine/game/TurnManager.js +++ b/src/engine/game/TurnManager.js @@ -61,8 +61,7 @@ export class TurnManager { } rollPowerDice() { - // const roll = Math.floor(Math.random() * 6) + 1; - const roll = 1; // DEBUG: Force Event for testing + const roll = Math.floor(Math.random() * 6) + 1; this.currentPowerRoll = roll; console.log(`Power Roll: ${roll}`); diff --git a/src/main.js b/src/main.js index 73464e2..6d14580 100644 --- a/src/main.js +++ b/src/main.js @@ -189,11 +189,17 @@ game.onRangedTarget = (targetMonster, losResult) => { game.onShowMessage = (title, message, duration) => { // Filter specific game flow messages to Log instead of popup - if (title.startsWith('Turno de') || title.includes('Fase') || title.includes('Efecto') || title.includes('Evento')) { + const lowerTitle = title.toLowerCase(); + if (title.startsWith('Turno de') || + lowerTitle.includes('fase') || + lowerTitle.includes('efecto') || + lowerTitle.includes('evento') || + lowerTitle.includes('selección')) { + let icon = '👉'; let type = 'system'; - if (title.includes('Evento')) { + if (lowerTitle.includes('evento')) { icon = '⚡'; type = 'event-log'; } @@ -290,6 +296,8 @@ const handleClick = (x, y, doorMesh) => { // 2. Check Adjacency if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) { + const wasPortcullis = doorMesh.userData.isPortcullis; + // 3. Check Key Requirement for Portcullis if (doorMesh.userData.requiresKey) { const hasKey = game.heroes.some(h => h.inventory && h.inventory.includes('llave_rastrillo')); @@ -298,12 +306,22 @@ const handleClick = (x, y, doorMesh) => { return; } else { ui.showModal('¡Rastrillo Abierto!', 'Utilizáis la llave del enano para levantar el pesado rastrillo.'); + // Clear flags so renderer allows opening + doorMesh.userData.requiresKey = false; + doorMesh.userData.isPortcullis = false; } } // Open door visually renderer.openDoor(doorMesh); - if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open'); + + if (window.SOUND_MANAGER) { + if (wasPortcullis) { + window.SOUND_MANAGER.playSound('gate_chains'); + } else { + window.SOUND_MANAGER.playSound('door_open'); + } + } // Get proper exit data with direction const exitData = doorMesh.userData.exitData; diff --git a/src/view/SoundManager.js b/src/view/SoundManager.js index 616407a..5c21f59 100644 --- a/src/view/SoundManager.js +++ b/src/view/SoundManager.js @@ -15,7 +15,8 @@ export class SoundManager { 'door_open': '/assets/sfx/opendoor.mp3', 'footsteps': '/assets/sfx/footsteps.mp3', 'sword': '/assets/sfx/sword1.mp3', - 'arrow': '/assets/sfx/arrow.mp3' + 'arrow': '/assets/sfx/arrow.mp3', + 'gate_chains': '/assets/sfx/gate_chains_open.mp3' } }; diff --git a/src/view/render/EntityRenderer.js b/src/view/render/EntityRenderer.js index bfcc2c2..fa18edf 100644 --- a/src/view/render/EntityRenderer.js +++ b/src/view/render/EntityRenderer.js @@ -22,11 +22,17 @@ export class EntityRenderer { addEntity(entity) { if (this.entities.has(entity.id)) return; + // Mark as "loading" or "reserved" to prevent race conditions + this.entities.set(entity.id, 'PENDING'); + const w = 1.04; const h = 1.56; const geometry = new THREE.PlaneGeometry(w, h); this.getTexture(entity.texturePath, (texture) => { + // Check if we were removed while loading + if (!this.entities.has(entity.id)) return; + const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, @@ -129,14 +135,14 @@ export class EntityRenderer { moveEntityAlongPath(entity, path) { const mesh = this.entities.get(entity.id); - if (mesh) { + if (mesh instanceof THREE.Object3D) { mesh.userData.pathQueue = [...path]; } } updateEntityPosition(entity) { const mesh = this.entities.get(entity.id); - if (mesh) { + if (mesh instanceof THREE.Object3D) { if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return; mesh.position.set(entity.x, 1.56 / 2, -entity.y); @@ -151,7 +157,7 @@ export class EntityRenderer { toggleEntitySelection(entityId, isSelected) { const mesh = this.entities.get(entityId); - if (mesh) { + if (mesh instanceof THREE.Object3D) { const ring = mesh.getObjectByName("SelectionRing"); if (ring) ring.visible = isSelected; } @@ -159,7 +165,7 @@ export class EntityRenderer { setEntityActive(entityId, isActive) { const mesh = this.entities.get(entityId); - if (!mesh) return; + if (!(mesh instanceof THREE.Object3D)) return; const oldRing = mesh.getObjectByName("ActiveRing"); if (oldRing) mesh.remove(oldRing); @@ -183,7 +189,7 @@ export class EntityRenderer { setEntityTarget(entityId, isTarget) { const mesh = this.entities.get(entityId); - if (!mesh) return; + if (!(mesh instanceof THREE.Object3D)) return; const oldRing = mesh.getObjectByName("TargetRing"); if (oldRing) mesh.remove(oldRing); @@ -217,7 +223,7 @@ export class EntityRenderer { triggerDamageEffect(entityId) { const mesh = this.entities.get(entityId); - if (!mesh) return; + if (!(mesh instanceof THREE.Object3D)) return; mesh.traverse((child) => { if (child.material && child.material.map) { @@ -245,7 +251,7 @@ export class EntityRenderer { triggerDeathAnimation(entityId) { const mesh = this.entities.get(entityId); - if (!mesh) return; + if (!(mesh instanceof THREE.Object3D)) return; const startTime = performance.now(); const duration = 1500; @@ -272,6 +278,7 @@ export class EntityRenderer { let isAnyMoving = false; this.entities.forEach((mesh, id) => { + if (!(mesh instanceof THREE.Object3D)) return; const data = mesh.userData; if (!data.isMoving && data.pathQueue.length > 0) {