1606 lines
60 KiB
JavaScript
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.");
|
|
}
|
|
}
|
|
}
|
|
}
|