Files
WarhammerQuest/src/engine/game/GameEngine.js

1606 lines
60 KiB
JavaScript

import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { MonsterAI } from './MonsterAI.js';
import { MagicSystem } from './MagicSystem.js';
import { CombatSystem } from './CombatSystem.js';
import { CombatMechanics } from './CombatMechanics.js';
import { EventInterpreter } from '../events/EventInterpreter.js'; // Import
import { HERO_DEFINITIONS } from '../data/Heroes.js';
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
/**
* GameEngine for Manual Dungeon Construction with Player Movement
*/
export class GameEngine {
constructor() {
this.dungeon = new DungeonGenerator();
this.turnManager = new TurnManager();
this.ai = new MonsterAI(this); // Init AI
this.magicSystem = new MagicSystem(this); // Init Magic
this.combatSystem = new CombatSystem(this); // Init Combat
this.events = new EventInterpreter(this); // Init Events Engine
this.player = null;
this.selectedEntity = null;
this.isRunning = false;
this.plannedPath = []; // Array of {x,y}
this.visitedRoomIds = new Set(); // Track tiles triggered
this.eventDeck = createEventDeck();
this.lastEntranceUsed = null;
this.pendingExploration = null;
this.exploredRoomIds = new Set();
// Callbacks
this.onEntityUpdate = null;
this.onEntityMove = null;
this.onEntitySelect = null;
this.onRangedTarget = null; // New: For ranged targeting visualization
this.onEntityActive = null; // New: When entity starts/ends turn
this.onShowMessage = null; // New: Generic temporary message UI callback
this.onEntityHit = null; // New: When entity takes damage
this.onEntityDeath = null; // New: When entity dies
this.onFloatingText = null; // New: For overhead text feedback
this.onPathChange = null;
this.onShowEvent = null; // New: For styled event cards
}
startMission(missionConfig) {
this.dungeon.startDungeon(missionConfig);
// Starting room is already explored
this.exploredRoomIds.add('tile_0');
this.visitedRoomIds.add('tile_0');
// Create Party (4 Heroes)
this.createParty();
// Listen for Phase Changes to Reset Moves
this.turnManager.on('phase_changed', (phase) => {
if (phase === 'hero' || phase === 'exploration') {
this.resetHeroMoves();
}
if (phase === 'hero') {
this.initializeTurnOrder();
}
if (phase === 'monster') {
if (window.RENDERER && window.RENDERER.clearAllActiveRings) {
window.RENDERER.clearAllActiveRings();
}
this.deselectEntity();
// Duplicate executeTurn removed here. main.js handles this with playMonsterTurn().
}
});
// End of Turn Logic (Buffs, cooldowns, etc)
this.turnManager.on('turn_ended', (turn) => {
this.handleEndTurn();
});
// 6. Listen for Power Phase Events
this.turnManager.on('POWER_RESULT', (data) => {
// Update Wizard Power Stat
const wizard = this.heroes.find(h => h.key === 'wizard');
if (wizard) {
wizard.stats.power = data.roll;
if (this.onEntityUpdate) this.onEntityUpdate(wizard);
}
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({ source: 'power' }), 1500);
}
});
// Initial Light Update
setTimeout(() => this.updateLighting(), 500);
// Start Game Loop (Now that listeners are ready)
this.isRunning = true;
this.turnManager.startGame();
}
resetHeroMoves() {
if (!this.heroes) return;
this.heroes.forEach(hero => {
hero.currentMoves = hero.stats.move;
hero.hasAttacked = false;
hero.hasEscapedPin = false; // Reset pin escape status
});
console.log("Refilled Hero Moves");
}
handleEndTurn() {
console.log("[GameEngine] Handling End of Turn Effects...");
if (!this.heroes) return;
this.heroes.forEach(hero => {
if (hero.buffs && hero.buffs.length > 0) {
// Decrement duration
hero.buffs.forEach(buff => {
buff.duration--;
});
// Remove expired
const activeBuffs = [];
const expiredBuffs = [];
hero.buffs.forEach(buff => {
if (buff.duration > 0) {
activeBuffs.push(buff);
} else {
expiredBuffs.push(buff);
}
});
// Revert expired
expiredBuffs.forEach(buff => {
if (buff.stat === 'toughness') {
hero.stats.toughness -= buff.value;
if (hero.tempStats && hero.tempStats.toughnessBonus) {
hero.tempStats.toughnessBonus -= buff.value;
}
console.log(`[GameEngine] Buff expired: ${buff.id} on ${hero.name}. -${buff.value} ${buff.stat}`);
if (this.onShowMessage) {
this.onShowMessage("Efecto Finalizado", `La ${buff.id === 'iron_skin' ? 'Piel de Hierro' : 'Magia'} de ${hero.name} se desvanece.`);
}
}
});
hero.buffs = activeBuffs;
}
});
}
createParty() {
this.heroes = [];
this.monsters = []; // Initialize monsters array
// Definition Keys
const heroKeys = ['barbarian', 'dwarf', 'elf', 'wizard'];
// Find valid spawn points dynamically
const startPositions = this.findSpawnPoints(4);
if (startPositions.length < 4) {
console.error("Could not find enough spawn points!");
// Fallback
startPositions.push({ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 1, y: 1 });
}
heroKeys.forEach((key, index) => {
const definition = HERO_DEFINITIONS[key];
const pos = startPositions[index];
const hero = {
id: `hero_${key}`,
type: 'hero',
key: key,
name: definition.name,
x: pos.x,
y: pos.y,
texturePath: definition.portrait,
stats: { ...definition.stats },
// Game State
currentMoves: definition.stats.move,
hasAttacked: false,
isConscious: true,
hasLantern: key === 'barbarian', // Default leader
inventory: []
};
this.heroes.push(hero);
if (this.onEntityUpdate) {
this.onEntityUpdate(hero);
}
});
// Set First Player as Active
this.activeHeroIndex = 0;
// Legacy support for single player var (getter proxy)
this.player = this.heroes[0];
}
initializeTurnOrder() {
console.log("[GameEngine] Initializing Turn Order...");
// 1. Identify Leader
const leader = this.getLeader();
// 2. Sort Rest by Initiative (Descending)
// Note: Sort is stable or we rely on index? Array.sort is stable in modern JS.
const others = this.heroes.filter(h => h !== leader);
others.sort((a, b) => b.stats.init - a.stats.init);
// 3. Construct Order
this.heroTurnOrder = [leader, ...others];
this.currentTurnIndex = 0;
console.log("Turn Order:", this.heroTurnOrder.map(h => `${h.name} (${h.stats.init})`));
// 4. Activate First
this.activateHero(this.heroTurnOrder[0]);
}
activateHero(hero) {
this.selectedEntity = hero;
// Update selection UI
if (this.onEntitySelect) {
// Deselect all keys first?
this.heroes.forEach(h => this.onEntitySelect(h.id, false));
this.onEntitySelect(hero.id, true);
}
// Notify UI about active turn
if (this.onShowMessage) {
this.onShowMessage(`Turno de ${hero.name}`, "Mueve y Ataca.");
}
// Mark as active in renderer (Green Ring vs Yellow Selection)
if (window.RENDERER) {
this.heroes.forEach(h => window.RENDERER.setEntityActive(h.id, false));
window.RENDERER.setEntityActive(hero.id, true);
}
}
nextHeroTurn() {
this.currentTurnIndex++;
// Loop to find next VALID hero (visible)
while (this.currentTurnIndex < this.heroTurnOrder.length) {
const nextHero = this.heroTurnOrder[this.currentTurnIndex];
// Check visibility
// Exception: Leader (hasLantern) is ALWAYS visible.
if (nextHero.hasLantern) {
this.activateHero(nextHero);
return;
}
// Check if hero is in a visible tile
// Get hero tile ID
const heroTileId = this.dungeon.grid.occupiedCells.get(`${nextHero.x},${nextHero.y}`);
// If currentVisibleTileIds is defined, enforce it.
if (this.currentVisibleTileIds) {
if (heroTileId && this.currentVisibleTileIds.has(heroTileId)) {
this.activateHero(nextHero);
return;
} else {
console.log(`Skipping turn for ${nextHero.name} (In Darkness)`);
if (this.onShowMessage) {
// Optional: Small notification or log
// this.onShowMessage("Perdido en la oscuridad", `${nextHero.name} pierde su turno.`);
}
this.currentTurnIndex++; // Skip and continue loop
}
} else {
// Should not happen if updateLighting runs, but fallback
this.activateHero(nextHero);
return;
}
}
// If loop finishes, no more heroes
if (this.currentTurnIndex >= this.heroTurnOrder.length) {
console.log("All heroes acted. Ending Phase sequence if auto?");
this.deselectEntity();
if (window.RENDERER) {
this.heroes.forEach(h => window.RENDERER.setEntityActive(h.id, false));
}
if (this.onShowMessage) {
this.onShowMessage("Fase de Aventureros Terminada", "Pasando a Monstruos...");
}
// Auto Advance Phase? Or Manual?
// Usually manual "End Turn" button triggers nextHeroTurn.
// When last hero ends, we trigger nextPhase.
setTimeout(() => {
this.turnManager.nextPhase();
}, 1000);
}
}
spawnMonster(monsterKeyOrDef, x, y, options = {}) {
let definition;
let monsterKey;
if (typeof monsterKeyOrDef === 'string') {
definition = MONSTER_DEFINITIONS[monsterKeyOrDef];
monsterKey = monsterKeyOrDef;
} else {
// Dynamic Definition from Card
definition = monsterKeyOrDef;
monsterKey = definition.name ? definition.name.replace(/\s+/g, '_').toLowerCase() : 'dynamic_monster';
}
if (!definition) {
console.error(`Monster definition not found: ${monsterKeyOrDef}`);
return;
}
// Ensure unique ID even in tight loops
if (!this._monsterIdCounter) this._monsterIdCounter = 0;
this._monsterIdCounter++;
const id = `monster_${monsterKey}_${Date.now()}_${this._monsterIdCounter}`;
console.log(`[GameEngine] Creating monster ${id} at ${x},${y}`);
const monster = {
id: id,
type: 'monster',
key: monsterKey,
name: definition.name,
x: x,
y: y,
texturePath: definition.portrait,
stats: { ...definition.stats },
// Game State
currentWounds: definition.stats.wounds || 1,
isDead: false,
skipTurn: !!options.skipTurn // Summoning sickness flag
};
this.monsters.push(monster);
if (this.onEntityUpdate) {
this.onEntityUpdate(monster);
}
return monster;
}
onCellHover(x, y) {
if (this.targetingMode === 'spell' && this.currentSpell) {
const area = this.currentSpell.area || 1;
const cells = [];
if (area === 2) {
cells.push({ x: x, y: y });
cells.push({ x: x + 1, y: y });
cells.push({ x: x, y: y + 1 });
cells.push({ x: x + 1, y: y + 1 });
} else {
cells.push({ x: x, y: y });
}
// LOS Check for Color
let color = 0xffffff; // Default White
const caster = this.selectedEntity;
if (caster) {
// Check LOS to the center/anchor cell (x,y)
const targetObj = { x: x, y: y };
const los = this.checkLineOfSightStrict(caster, targetObj);
if (los && los.clear) {
color = 0x00ff00; // Green (Good)
} else {
color = 0xff0000; // Red (Blocked)
}
}
// Show Preview
if (window.RENDERER) {
window.RENDERER.showAreaPreview(cells, color);
}
} else {
if (window.RENDERER) window.RENDERER.hideAreaPreview();
}
}
onCellClick(x, y) {
// SPELL TARGETING LOGIC
if (this.targetingMode === 'spell' && this.currentSpell) {
const area = this.currentSpell.area || 1;
const targetCells = [];
if (area === 2) {
targetCells.push({ x: x, y: y });
targetCells.push({ x: x + 1, y: y });
targetCells.push({ x: x, y: y + 1 });
targetCells.push({ x: x + 1, y: y + 1 });
} else {
targetCells.push({ x: x, y: y });
}
// NEW: Enforce LOS Check before execution
const caster = this.selectedEntity;
if (caster) {
const targetObj = { x: x, y: y };
const los = this.checkLineOfSightStrict(caster, targetObj);
if (!los || !los.clear) {
if (this.onFloatingText) this.onFloatingText(x, y, "Bloqueado", "#ff0000");
// Do NOT cancel targeting, let them try again
return;
}
}
// Execute Spell
const result = this.executeSpell(this.currentSpell, targetCells);
if (result.success) {
// Success
this.cancelTargeting();
if (window.RENDERER) window.RENDERER.hideAreaPreview();
} else {
if (this.onShowMessage) this.onShowMessage('Fallo', result.reason || 'No se pudo lanzar el hechizo.');
this.cancelTargeting(); // Cancel on error? maybe keep open? usually cancel.
if (window.RENDERER) window.RENDERER.hideAreaPreview();
}
return;
}
// RANGED TARGETING LOGIC
if (this.targetingMode === 'ranged') {
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
if (clickedMonster) {
if (this.selectedEntity && this.selectedEntity.type === 'hero') {
const los = this.checkLineOfSightStrict(this.selectedEntity, clickedMonster);
this.selectedMonster = clickedMonster;
if (this.onRangedTarget) {
this.onRangedTarget(clickedMonster, los);
}
}
} else {
// Determine if we clicked something else relevant or empty space
// If clicked self (hero), maybe cancel?
// For now, any non-monster click cancels targeting
// Unless it's just a UI click (handled by DOM)
this.cancelTargeting();
}
return;
}
// 1. Identify clicked contents
const clickedHero = this.heroes.find(h => h.x === x && h.y === y);
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
const clickedEntity = clickedHero || clickedMonster;
if (clickedEntity) {
// STRICT TURN ORDER CHECK
if (this.turnManager.currentPhase === 'hero' && clickedHero) {
const currentActiveHero = this.heroTurnOrder ? this.heroTurnOrder[this.currentTurnIndex] : null;
if (currentActiveHero && clickedHero.id !== currentActiveHero.id) {
if (this.onShowMessage) this.onShowMessage("No es su turno", `Es el turno de ${currentActiveHero.name}.`);
return;
}
}
if (this.selectedEntity === clickedEntity) {
// Toggle Deselect
// EXCEPTION: In Hero Phase, if I click MYSELF (Active Hero), do NOT deselect.
// It's annoying to lose the card.
const isHeroPhase = this.turnManager.currentPhase === 'hero';
let isActiveTurnHero = false;
if (isHeroPhase && this.heroTurnOrder && this.currentTurnIndex !== undefined) {
const activeHero = this.heroTurnOrder[this.currentTurnIndex];
if (activeHero && activeHero.id === clickedEntity.id) {
isActiveTurnHero = true;
}
}
if (isActiveTurnHero) {
// Do nothing (keep selected)
// Maybe blink the card or something?
} else {
this.deselectEntity();
}
} else if (this.selectedMonster === clickedMonster && clickedMonster) {
// Clicking on already selected monster - deselect it
const monsterId = this.selectedMonster.id;
this.selectedMonster = null;
if (this.onEntitySelect) {
this.onEntitySelect(monsterId, false);
}
} else {
// Select new entity (don't deselect hero if clicking monster)
if (clickedMonster && this.selectedEntity && this.selectedEntity.type === 'hero') {
// Deselect previous monster if any
if (this.selectedMonster) {
const prevMonsterId = this.selectedMonster.id;
if (this.onEntitySelect) {
this.onEntitySelect(prevMonsterId, false);
}
}
// Keep hero selected, also select monster
this.selectedMonster = clickedMonster;
if (this.onEntitySelect) {
this.onEntitySelect(clickedMonster.id, true);
}
} else {
// Normal selection (deselect previous)
if (this.selectedEntity) this.deselectEntity();
this.selectedEntity = clickedEntity;
if (this.onEntitySelect) {
this.onEntitySelect(clickedEntity.id, true);
}
// Check Pinned Status
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
if (this.isEntityPinned(clickedEntity)) {
if (this.onFloatingText) {
this.onFloatingText(clickedEntity.x, clickedEntity.y, "¡Trabado!", "#ff4400");
}
}
}
}
}
return;
}
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
if (this.selectedEntity) {
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
if (this.onFloatingText) this.onFloatingText(this.selectedEntity.x, this.selectedEntity.y, "¡Trabado!", "#ff4400");
return;
}
this.planStep(x, y);
}
}
performHeroAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
// Attack ends turn logic could be here? Assuming user clicks "End Turn" manually for now.
// Or if standard rules: 1 attack per turn.
// For now, just attack.
return this.combatSystem.handleMeleeAttack(hero, monster);
}
updateLighting() {
if (!window.RENDERER) return;
const leader = this.getLeader();
if (!leader) return;
// 1. Get Leader Tile ID
const leaderTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`);
if (!leaderTileId) return;
const visibleTileIds = new Set([leaderTileId]);
// 2. Find Neighbor Tiles (Connected Board Sections)
// Iterate grid occupied cells to find cells belonging to leaderTileId
// Then check their neighbors for DIFFERENT tile IDs that are connected.
// Optimization: We could cache this or iterate efficiently
// For now, scan occupiedCells (Map)
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tid === leaderTileId) {
const [cx, cy] = key.split(',').map(Number);
// Check 4 neighbors
const neighbors = [
{ x: cx + 1, y: cy }, { x: cx - 1, y: cy },
{ x: cx, y: cy + 1 }, { x: cx, y: cy - 1 }
];
for (const n of neighbors) {
const nKey = `${n.x},${n.y}`;
const nTileId = this.dungeon.grid.occupiedCells.get(nKey);
if (nTileId && nTileId !== leaderTileId) {
// Found a neighbor tile!
// Check connectivity logic (Walls/Doors)
if (this.dungeon.grid.canMoveBetween(cx, cy, n.x, n.y)) {
visibleTileIds.add(nTileId);
}
}
}
}
}
// Store active visibility sets for Turn Logic
this.currentVisibleTileIds = visibleTileIds;
window.RENDERER.updateFogOfWar(Array.from(visibleTileIds));
}
performRangedAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
return this.combatSystem.handleRangedAttack(hero, monster);
}
canCastSpell(spell) {
return this.magicSystem.canCastSpell(this.selectedEntity, spell);
}
executeSpell(spell, targetCells = []) {
if (!this.selectedEntity) return { success: false, reason: 'no_caster' };
return this.magicSystem.executeSpell(this.selectedEntity, spell, targetCells);
}
deselectEntity() {
if (!this.selectedEntity) return;
const id = this.selectedEntity.id;
this.selectedEntity = null;
this.plannedPath = [];
if (this.onEntitySelect) this.onEntitySelect(id, false);
if (this.onPathChange) this.onPathChange([]);
// Also deselect monster if selected
if (this.selectedMonster) {
const monsterId = this.selectedMonster.id;
this.selectedMonster = null;
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
}
}
isEntityPinned(entity) {
if (!this.monsters || this.monsters.length === 0) return false;
// 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);
const dy = Math.abs(entity.y - m.y);
// 1. Must be Adjacent (Manhattan distance 1)
if (dx + dy !== 1) return false;
// 2. Check Logical Connectivity (Wall check)
const grid = this.dungeon.grid;
const key1 = `${entity.x},${entity.y}`;
const key2 = `${m.x},${m.y}`;
const data1 = grid.cellData.get(key1);
const data2 = grid.cellData.get(key2);
if (!data1 || !data2) return false;
// Same Tile -> Connected
if (data1.tileId === data2.tileId) return true;
// Different Tile -> Must be connected by a Door
const isDoor1 = grid.doorCells.has(key1);
const isDoor2 = grid.doorCells.has(key2);
if (!isDoor1 && !isDoor2) return false;
return true;
});
}
attemptBreakAway(hero) {
if (!hero || hero.hasEscapedPin) return { success: false, roll: 0 };
// RULE: If Derrumbamiento, escape is free
if (this.state && this.state.collapsingRoom && this.state.collapsingRoom.tileId) {
// Check if hero is in the collapsing room
const key = `${Math.floor(hero.x)},${Math.floor(hero.y)}`;
const tid = this.dungeon.grid.occupiedCells.get(key);
if (tid === this.state.collapsingRoom.tileId) {
console.log("[GameEngine] Free BreakAway due to Collapsing Room!");
hero.hasEscapedPin = true;
return { success: true, roll: "AUTO", target: 0 };
}
}
const roll = Math.floor(Math.random() * 6) + 1;
const target = hero.stats.pin_target || 6;
const success = roll >= target;
if (success) {
hero.hasEscapedPin = true;
} else {
// Failed to escape: Unit loses movement and ranged attacks?
// "The Adventurer must stay where he is and fight"
// So movement becomes 0.
hero.currentMoves = 0;
}
return { success, roll, target };
}
// Alias for legacy calls if any
deselectPlayer() {
this.deselectEntity();
}
planStep(x, y) {
if (!this.selectedEntity) return;
// Valid Phase Check
// Allow movement ONLY in Hero Phase.
// Exploration Phase is for opening doors only (no movement).
const phase = this.turnManager.currentPhase;
if (phase !== 'hero' && this.selectedEntity.type === 'hero') {
return;
}
// Determine start point
const lastStep = this.plannedPath.length > 0
? this.plannedPath[this.plannedPath.length - 1]
: { x: this.selectedEntity.x, y: this.selectedEntity.y };
// Check Adjacency
const dx = Math.abs(x - lastStep.x);
const dy = Math.abs(y - lastStep.y);
const isAdjacent = (dx + dy) === 1;
// Check Walkability
const isWalkable = this.canMoveTo(x, y);
// Check against Max Move Stats
const maxMove = this.selectedEntity.currentMoves || 0;
// Also account for the potential next step
if (this.plannedPath.length >= maxMove && !(this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y)) {
// Allow undo (next block), but block new steps
if (isAdjacent && isWalkable) {
// Prevent adding more steps
return;
}
}
// Undo Logic
if (this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y) {
this.plannedPath.pop();
this.onPathChange && this.onPathChange(this.plannedPath);
return;
}
if (isAdjacent && isWalkable) {
const alreadyInPath = this.plannedPath.some(p => p.x === x && p.y === y);
const isEntityPos = this.selectedEntity.x === x && this.selectedEntity.y === y;
// Also check if occupied by OTHER heroes?
const isOccupiedByHero = this.heroes.some(h => h.x === x && h.y === y && h !== this.selectedEntity);
if (!alreadyInPath && !isEntityPos && !isOccupiedByHero) {
this.plannedPath.push({ x, y });
this.onPathChange && this.onPathChange(this.plannedPath);
}
}
}
executeMovePath() {
if (!this.selectedEntity || !this.plannedPath.length) return;
const fullPath = [...this.plannedPath];
const entity = this.selectedEntity;
let stepsTaken = 0;
let triggeredEvents = false;
// Step-by-step execution to check for triggers
for (let i = 0; i < fullPath.length; i++) {
const step = fullPath[i];
// 1. Move Entity State
entity.x = step.x;
entity.y = step.y;
stepsTaken++;
// Update Light if Lantern Bearer
if (entity.hasLantern) {
this.updateLighting();
}
// 2. Check for New Tile Entry
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
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 (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' };
}
triggeredEvents = true; // Use this flag to end turn AFTER movement
} else if (!this.visitedRoomIds.has(tileId)) {
this.visitedRoomIds.add(tileId);
}
}
}
// 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) {
// 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';
// Check if entity is the currently active turn hero
let isActiveTurnHero = false;
if (isHeroPhase && this.heroTurnOrder && this.currentTurnIndex !== undefined) {
const activeHero = this.heroTurnOrder[this.currentTurnIndex];
if (activeHero && activeHero.id === entity.id) {
isActiveTurnHero = true;
}
}
if (isActiveTurnHero) {
// Do NOT deselect. Just clear path.
this.plannedPath = [];
if (this.onPathChange) this.onPathChange([]);
// Also force update UI/Card (stats changed)
if (this.onEntitySelect) {
// Re-trigger selection to ensure UI is fresh?
// UIManager listens to onEntityMove to update stats, so that should be covered.
// But purely being consistent:
}
} else {
this.deselectEntity();
}
}
canMoveTo(x, y) {
// Check if cell is walkable (occupied by a tile)
return this.dungeon.grid.isOccupied(x, y);
}
// Deprecated direct move
movePlayer(x, y) {
this.player.x = x;
this.player.y = y;
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
}
getLeader() {
// Find hero with lantern, default to barbarian if something breaks, or first hero
return this.heroes.find(h => h.hasLantern) || this.heroes.find(h => h.key === 'barbarian') || this.heroes[0];
}
update(time) {
// Minimal update loop
}
findSpawnPoints(count, tileId = null) {
// Collect all currently available cells (occupiedCells maps "x,y" => tileId)
// If tileId is provided, filter ONLY cells belonging to that tile.
const candidates = [];
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tileId && tid !== tileId) continue;
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);
if (!isHero && !isMonster) {
candidates.push({ x, y });
}
}
// If localized spawn fails (full room), maybe allow spill over to neighbors? (Rules say: "adjacent board sections")
if (tileId && candidates.length < count) {
console.warn(`[GameEngine] Room ${tileId} full? Searching neighbors...`);
// NOTE: Neighbor search logic is complex, skipping for MVP.
// Fallback to global search if desperate?
}
// 2. Shuffle candidates (Fisher-Yates) to ensure random but valid placement
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
// 3. Return requested amount (or less if not enough space)
if (candidates.length < count) {
console.warn(`[GameEngine] Not enough space to spawn ${count} monsters. Only ${candidates.length} spawn.`);
if (this.onShowMessage) {
// this.onShowMessage("Espacio Insuficiente", `Solo caben ${candidates.length} de ${count} monstruos.`);
}
return candidates;
}
return candidates.slice(0, count);
}
onRoomRevealed(cells) {
console.log("[GameEngine] Room Revealed!");
// 1. Draw Event Card
if (this.eventDeck.length === 0) {
console.warn("Event deck empty, reshaping...");
this.eventDeck = createEventDeck();
}
const card = this.eventDeck.pop();
console.log(`[GameEngine] Event Drawn: ${card.name}`);
if (card.type === EVENT_TYPES.MONSTER) {
// 2. Determine Count
let count = 0;
if (typeof card.resolve === 'function') {
count = card.resolve(this, { cells });
} else {
count = 1; // Fallback
}
console.log(`[GameEngine] Spawning ${count} ${card.monsterKey}s`);
// 3. Find valid spawn spots
const availableCells = cells.filter(cell => {
const isHero = this.heroes.some(h => h.x === cell.x && h.y === cell.y);
const isMonster = this.monsters.some(m => m.x === cell.x && m.y === cell.y);
return !isHero && !isMonster;
});
console.log(`[GameEngine] Available Spawn Cells: ${availableCells.length}`, availableCells);
// Shuffle
for (let i = availableCells.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableCells[i], availableCells[j]] = [availableCells[j], availableCells[i]];
}
// 4. Spawn
let spawnedCount = 0;
for (let i = 0; i < count; i++) {
if (i < availableCells.length) {
const pos = availableCells[i];
console.log(`[GameEngine] Spawning at ${pos.x},${pos.y}`);
// Monster Spawn (Step-and-Stop rule)
// Monsters act in the upcoming Monster Phase.
this.spawnMonster(card.monsterKey, pos.x, pos.y);
spawnedCount++;
} else {
console.warn("[GameEngine] Not enough space!");
break;
}
}
return {
type: 'MONSTER_SPAWN',
monsterKey: card.monsterKey,
count: spawnedCount,
cardName: card.name
};
}
// Return event info even if empty, so movement stops
return {
type: 'EVENT',
cardName: card.name,
message: 'La sala parece despejada.'
};
}
// =========================================
// MONSTER AI & TURN LOGIC
// =========================================
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();
}
}
// AI Helper methods moved to MonsterAI.js
isLeaderAdjacentToDoor(doorCells) {
// ... (Keep this one as it's used by main.js logic for doors)
if (!this.heroes || this.heroes.length === 0) return false;
const leader = this.getLeader();
if (!leader) return false;
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
for (const cell of cells) {
const dx = Math.abs(leader.x - cell.x);
const dy = Math.abs(leader.y - cell.y);
// Orthogonal adjacency check (Manhattan distance === 1)
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
return true;
}
}
return false;
}
startRangedTargeting() {
this.targetingMode = 'ranged';
console.log("Ranged Targeting Mode ON");
}
startSpellTargeting(spell) {
this.targetingMode = 'spell';
this.currentSpell = spell;
console.log(`Spell Targeting Mode ON: ${spell.name}`);
if (this.onShowMessage) this.onShowMessage(spell.name, 'Selecciona el objetivo (Monstruo o Casilla).');
}
cancelTargeting() {
this.targetingMode = null;
this.currentSpell = null;
if (this.onRangedTarget) {
this.onRangedTarget(null, null);
}
}
checkLineOfSight(hero, target) {
// Robust Grid Traversal (Amanatides & Woo)
const x = hero.x + 0.5;
const y = hero.y + 0.5;
const endX = target.x + 0.5;
const endY = target.y + 0.5;
const dx = endX - x;
const dy = endY - y;
let currentX = Math.floor(x);
let currentY = Math.floor(y);
const targetX = Math.floor(endX);
const targetY = Math.floor(endY);
const stepX = Math.sign(dx);
const stepY = Math.sign(dy);
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
let tMaxX = stepX > 0 ? (Math.floor(x) + 1 - x) * tDeltaX : (x - Math.floor(x)) * tDeltaX;
let tMaxY = stepY > 0 ? (Math.floor(y) + 1 - y) * tDeltaY : (y - Math.floor(y)) * tDeltaY;
if (isNaN(tMaxX)) tMaxX = Infinity;
if (isNaN(tMaxY)) tMaxY = Infinity;
const path = [];
let blocked = false;
// Safety limit
const maxSteps = Math.abs(targetX - currentX) + Math.abs(targetY - currentY) + 20;
for (let i = 0; i < maxSteps; i++) {
path.push({ x: currentX, y: currentY });
if (!(currentX === hero.x && currentY === hero.y) && !(currentX === target.x && currentY === target.y)) {
if (this.dungeon.grid.isWall(currentX, currentY)) {
blocked = true;
break;
}
const blockerMonster = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
if (blockerMonster) { blocked = true; break; }
const blockerHero = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
if (blockerHero) { blocked = true; break; }
}
if (currentX === targetX && currentY === targetY) {
break;
}
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
currentX += stepX;
} else {
tMaxY += tDeltaY;
currentY += stepY;
}
}
return { clear: !blocked, path: path };
}
checkLineOfSightPermissive(hero, target) {
const startX = hero.x + 0.5;
const startY = hero.y + 0.5;
const endX = target.x + 0.5;
const endY = target.y + 0.5;
const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
const steps = Math.ceil(dist * 5);
const path = [];
const visited = new Set();
let blocked = false;
let blocker = null;
const MARGIN = 0.2;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = startX + (endX - startX) * t;
const y = startY + (endY - startY) * t;
const gx = Math.floor(x);
const gy = Math.floor(y);
const key = `${gx},${gy}`;
if (!visited.has(key)) {
path.push({ x: gx, y: gy, xWorld: x, yWorld: y });
visited.add(key);
}
if ((gx === hero.x && gy === hero.y) || (gx === target.x && gy === target.y)) {
continue;
}
if (this.dungeon.grid.isWall(gx, gy)) {
const lx = x - gx;
const ly = y - gy;
if (lx > MARGIN && lx < (1 - MARGIN) && ly > MARGIN && ly < (1 - MARGIN)) {
blocked = true;
blocker = { type: 'wall', x: gx, y: gy };
console.log(`[LOS] Blocked by WALL at ${gx},${gy}`);
break;
}
}
const blockerMonster = this.monsters.find(m => m.x === gx && m.y === gy && !m.isDead && m.id !== target.id);
if (blockerMonster) {
blocked = true;
blocker = { type: 'monster', entity: blockerMonster };
console.log(`[LOS] Blocked by MONSTER: ${blockerMonster.name}`);
break;
}
const blockerHero = this.heroes.find(h => h.x === gx && h.y === gy && h.id !== hero.id);
if (blockerHero) {
blocked = true;
blocker = { type: 'hero', entity: blockerHero };
console.log(`[LOS] Blocked by HERO: ${blockerHero.name}`);
break;
}
}
return { clear: !blocked, path: path, blocker: blocker };
}
checkLineOfSightStrict(hero, target) {
// STRICT Grid Traversal (Amanatides & Woo)
const x1 = hero.x + 0.5;
const y1 = hero.y + 0.5;
const x2 = target.x + 0.5;
const y2 = target.y + 0.5;
let currentX = Math.floor(x1);
let currentY = Math.floor(y1);
const endX = Math.floor(x2);
const endY = Math.floor(y2);
const dx = x2 - x1;
const dy = y2 - y1;
const stepX = Math.sign(dx);
const stepY = Math.sign(dy);
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
let tMaxX = stepX > 0 ? (Math.floor(x1) + 1 - x1) * tDeltaX : (x1 - Math.floor(x1)) * tDeltaX;
let tMaxY = stepY > 0 ? (Math.floor(y1) + 1 - y1) * tDeltaY : (y1 - Math.floor(y1)) * tDeltaY;
if (isNaN(tMaxX)) tMaxX = Infinity;
if (isNaN(tMaxY)) tMaxY = Infinity;
const path = [];
let blocked = false;
let blocker = null;
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
let prevX = null;
let prevY = null;
for (let i = 0; i < maxSteps; i++) {
path.push({ x: currentX, y: currentY });
const isStart = (currentX === hero.x && currentY === hero.y);
const isEnd = (currentX === target.x && currentY === target.y);
if (!isStart && !isEnd) {
// WALL CHECK: Use Connectvity (canMoveBetween)
// This detects walls between tiles even if both tiles are floor.
// It also detects VOID cells (because canMoveBetween returns false if destination is void).
if (prevX !== null) {
if (!this.dungeon.grid.canMoveBetween(prevX, prevY, currentX, currentY)) {
blocked = true;
blocker = { type: 'wall', x: currentX, y: currentY };
console.log(`[LOS] Blocked by WALL/BORDER between ${prevX},${prevY} and ${currentX},${currentY}`);
break;
}
} else if (this.dungeon.grid.isWall(currentX, currentY)) {
// Fallback for start/isolated case (should rarely happen for LOS path)
blocked = true;
blocker = { type: 'wall', x: currentX, y: currentY };
break;
}
// Helper: Distance from Cell Center to Ray (for grazing tolerance)
const getDist = () => {
const cx = currentX + 0.5;
const cy = currentY + 0.5;
const len = Math.sqrt(dx * dx + dy * dy);
if (len === 0) return 0;
return Math.abs(dy * cx - dx * cy + dx * y1 - dy * x1) / len;
};
// Tolerance: Allow shots to pass if they graze the edge (0.5 is full width)
// 0.4 means the outer 20% of the tile is "safe" to shoot through.
const ENTITY_HITBOX_RADIUS = 0.4;
// 2. Monster Check
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
if (m) {
if (getDist() < ENTITY_HITBOX_RADIUS) {
blocked = true;
blocker = { type: 'monster', entity: m };
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
break;
} else {
console.log(`[LOS] Grazed MONSTER ${m.name} (Dist: ${getDist().toFixed(2)})`);
}
}
// 3. Hero Check
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
if (h) {
if (getDist() < ENTITY_HITBOX_RADIUS) {
blocked = true;
blocker = { type: 'hero', entity: h };
console.log(`[LOS] Blocked by HERO: ${h.name}`);
break;
} else {
console.log(`[LOS] Grazed HERO ${h.name} (Dist: ${getDist().toFixed(2)})`);
}
}
}
if (currentX === endX && currentY === endY) break;
// CORNER CROSSING CHECK: Prevent diagonal wall leaking
// When tMaxX ≈ tMaxY, the ray passes through a vertex shared by 4 cells.
// Standard algorithm only visits 2 of them. We must check BOTH neighbors.
const CORNER_EPSILON = 0.001;
const cornerCrossing = Math.abs(tMaxX - tMaxY) < CORNER_EPSILON;
if (cornerCrossing) {
// Check connectivity to both orthogonal neighbors
const neighborX = currentX + stepX;
const neighborY = currentY + stepY;
// Check horizontal neighbor connectivity
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, neighborX, currentY)) {
blocked = true;
blocker = { type: 'wall', x: neighborX, y: currentY };
console.log(`[LOS] Blocked by CORNER WALL (H) at ${neighborX},${currentY}`);
break;
}
// Check vertical neighbor connectivity
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, currentX, neighborY)) {
blocked = true;
blocker = { type: 'wall', x: currentX, y: neighborY };
console.log(`[LOS] Blocked by CORNER WALL (V) at ${currentX},${neighborY}`);
break;
}
}
// Update Previous
prevX = currentX;
prevY = currentY;
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
currentX += stepX;
} else {
tMaxY += tDeltaY;
currentY += stepY;
}
}
return { clear: !blocked, path, blocker };
}
handlePowerEvent(context = null, onComplete = null) {
console.log("[GameEngine] Handling Power Event...");
// Inject Context
if (context) {
this.currentEventContext = context;
}
if (!this.eventDeck || this.eventDeck.length === 0) {
console.log("[GameEngine] Reshuffling Deck...");
this.eventDeck = createEventDeck();
}
let card = this.eventDeck.shift();
// RULE: Ignore Derrumbamiento in Start Room
// Check if we are in turn 1 of setup or if heroes are in the first tile (0,0)
// Or simpler: Check if card is 'evt_derrumbamiento' and currentTileId is 'tile_0'
if (card.id === 'evt_derrumbamiento') {
const leader = this.heroes[0];
const currentTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`);
// Assuming 'tile_0' is the start room ID.
// Also checking turn number might be safe: turnManager.currentTurn
if (currentTileId === 'tile_0' || this.turnManager.currentTurn <= 1) {
console.log("[GameEngine] Derrumbamiento drawn in Start Room. IGNORING and Redrawing.");
// Discard and Draw again
// (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.titulo}`);
}
}
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();
});
} else {
console.error("[GameEngine] EventInterpreter not initialized!");
this.turnManager.resumeFromEvent();
}
}
handleEndTurn() {
console.log("[GameEngine] Handling End of Turn Effects...");
// Check Collapsing Room State
if (this.state && this.state.collapsingRoom) {
if (typeof this.state.collapsingRoom.turnsLeft === 'number') {
this.state.collapsingRoom.turnsLeft--;
console.log(`[GameEngine] Collapsing Room Timer: ${this.state.collapsingRoom.turnsLeft}`);
if (this.state.collapsingRoom.turnsLeft > 0) {
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);
}
}
}
if (!this.heroes) return;
this.heroes.forEach(hero => {
if (hero.buffs && hero.buffs.length > 0) {
// Decrement duration
hero.buffs.forEach(buff => {
// ... (existing buff logic if any)
});
}
});
}
killEntitiesInCollapsingRoom(tileId) {
if (this.onShowMessage) this.onShowMessage("¡DERRUMBE TOTAL!", "La sala se ha venido abajo. Todo lo que había dentro ha sido aplastado.", 5000);
// Find entities in this tile
const entitiesToKill = [];
// Heroes
this.heroes.forEach(h => {
const key = `${Math.floor(h.x)},${Math.floor(h.y)}`;
const tid = this.dungeon.grid.occupiedCells.get(key);
if (tid === tileId) entitiesToKill.push(h);
});
// Monsters
this.monsters.forEach(m => {
if (m.isDead) return;
const key = `${Math.floor(m.x)},${Math.floor(m.y)}`;
const tid = this.dungeon.grid.occupiedCells.get(key);
if (tid === tileId) entitiesToKill.push(m);
});
entitiesToKill.forEach(e => {
console.log(`[GameEngine] Crushed by Rockfall: ${e.name}`);
// Instant Kill logic
if (e.currentWounds) e.currentWounds = 0;
e.isDead = true;
if (this.onEntityDeath) this.onEntityDeath(e.id);
});
// 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() {
// Logic: Find the room the heroes are in, identify its UNOPENED exits (which are in availableExits), and remove them.
// 1. Find Current Room ID based on Leader
const leader = this.heroes[0]; // Assume leader is first
if (!leader) return 0;
const currentTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`);
if (!currentTileId) {
console.warn("CollapseExits: Leader not on a valid tile.");
return 0;
}
console.log(`[GameEngine] Collapse Triggered in ${currentTileId}`);
// Start Countdown State
if (!this.state) this.state = {};
this.state.collapsingRoom = {
tileId: currentTileId,
turnsLeft: 2 // Gives them Turn N + Turn N+1 to escape.
};
// 2. Scan Available Exits to see which align with this Room
// An exit is "part" of a room if it is adjacent to any cell of that room.
// Or simpler: The DungeonGenerator keeps track of exits. We scan them.
// We need to identify cells belonging to currentTileId first.
const roomCells = [];
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tid === currentTileId) {
const [x, y] = key.split(',').map(Number);
roomCells.push({ x, y });
}
}
const exitsToRemove = [];
const blockedLocations = new Set();
this.dungeon.availableExits.forEach((exit, index) => {
// Check adjacency to any room cell
const isAdjacent = roomCells.some(cell => {
const dx = Math.abs(cell.x - exit.x);
const dy = Math.abs(cell.y - exit.y);
return (dx + dy === 1);
});
if (isAdjacent) {
exitsToRemove.push(index);
const exitKey = `${exit.x},${exit.y}`;
this.placeEventMarker("escombros", 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) {
window.RENDERER.blockDoor({ x: exit.x, y: exit.y });
}
}
});
// 3. Remove them (iterate backwards to avoid index shuffle issues)
exitsToRemove.sort((a, b) => b - a);
exitsToRemove.forEach(idx => {
this.dungeon.availableExits.splice(idx, 1);
});
// 4. Calculate Logical "Exits" Count (Approximation)
let visualDoorCount = 0;
const processedLocs = new Set();
for (const loc of blockedLocations) {
if (processedLocs.has(loc)) continue;
visualDoorCount++;
processedLocs.add(loc);
const [x, y] = loc.split(',').map(Number);
const neighbors = [`${x + 1},${y}`, `${x - 1},${y}`, `${x},${y + 1}`, `${x},${y - 1}`];
neighbors.forEach(n => {
if (blockedLocations.has(n)) processedLocs.add(n);
});
}
console.log(`[GameEngine] Collapsed ${exitsToRemove.length} exit cells (${visualDoorCount} visual doors).`);
return visualDoorCount;
}
placeEventMarker(type, specificX = null, specificY = null) {
// Place a visual marker in the world
// If x,y not provided, place in center of heroes
let x = specificX;
let y = specificY;
if (x === null || y === null) {
// Center on leader
const leader = this.heroes[0];
x = leader.x;
y = leader.y;
}
console.log(`[GameEngine] Placing Marker '${type}' at ${x},${y}`);
// Delegate to Renderer
if (window.RENDERER) {
window.RENDERER.spawnProp(type, x, y);
}
}
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.");
}
}
}
}