diff --git a/DEVLOG.md b/DEVLOG.md
index 08935aa..b6b25d9 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -1,5 +1,38 @@
# Devlog - Warhammer Quest (Versión Web 3D)
+## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
+
+### Objetivos Completados
+1. **Reglas de Juego Oficiales (WHQ 1995)**:
+ - Se ha implementado un estricto control de fases: **Exploración**, **Aventureros** y **Monstruos**.
+ - **Exploración Realista**: Colocar una loseta finaliza el turno inmediatamente.
+ - **Tensión en Nuevas Áreas**: Al entrar en una nueva habitación, el héroe se detiene OBLIGATORIAMENTE (haya monstruos o no) y se revela el evento.
+ - **Combate Continuo**: Si hay monstruos vivos, se elimina la Fase de Exploración del ciclo y se salta la Fase de Poder para mantener un bucle de combate frenético (Aventureros <-> Monstruos).
+
+2. **Movimiento y Eventos**:
+ - Refinamiento de `executeMovePath` en `GameEngine`:
+ - Detecta entrada en nuevos tiles.
+ - Diferencia entre **Habitaciones** (Trigger Event + Stop) y **Pasillos** (Solo marcar visitado).
+ - Detiene el movimiento sin penalizar los pasos no dados.
+
+3. **Interacción de Héroes**:
+ - Implementado ataque básico haciendo clic izquierdo en monstruos adyacentes durante el turno propio.
+ - Permitido movimiento en fases de Exploración para facilitar el posicionamiento táctico antes de abrir puertas.
+
+4. **Monstruos e IA**:
+ - Los monstruos de habitación ya no sufren "mareo de invocación" y atacan en el turno siguiente a su aparición.
+ - Ajustada la IA para operar correctamente dentro del nuevo flujo de fases.
+
+### Estado Actual
+El núcleo del juego ("Game Loop") es funcional y fiel a las reglas de mesa. Se puede explorar, revelar salas, combatir y gestionar los turnos con las restricciones correctas.
+
+### Próximos Pasos
+- Implementar sistema completo de combate (tiradas de dados visibles, daño variable, muerte de héroes).
+- Refinar la interfaz de usuario para mostrar estadísticas en tiempo real.
+
+---
+
+
## Sesión 5: Refinamiento de UX y Jugabilidad (3 Enero 2026)
### Objetivos Completados
diff --git a/implementación/task.md b/implementación/task.md
index f6849b7..ed1f34e 100644
--- a/implementación/task.md
+++ b/implementación/task.md
@@ -37,7 +37,9 @@
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js)
- [x] Implement Hero Movement Logic (Grid-based, Interactive)
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
- - [ ] Implement Combat Logic (Attack Rolls, Damage)
+ - [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
+ - [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
+ - [ ] Refine Combat System (Ranged weapons, Special Monster Rules, Magic)
## Phase 4: Campaign System
- [ ] **Campaign Manager**
diff --git a/src/engine/data/Heroes.js b/src/engine/data/Heroes.js
index 7550dd3..116d388 100644
--- a/src/engine/data/Heroes.js
+++ b/src/engine/data/Heroes.js
@@ -5,14 +5,14 @@ export const HERO_DEFINITIONS = {
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
stats: {
move: 4,
- ws: 4, // Weapon Skill
- bs: 5, // Ballistic Skill (3+ to hit, often lower is better in WHQ, let's use standard table numbers for now)
+ ws: 3,
+ to_hit_missile: 5, // 5+ to hit with ranged
str: 4,
- toughness: 4,
- wounds: 12,
+ toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas)
+ wounds: 12, // 1D6 + 9 (Using fixed average for now)
attacks: 1,
init: 3,
- luck: 2 // Rerolls??
+ pin_target: 6 // 6+ to escape pin
}
},
dwarf: {
@@ -20,15 +20,15 @@ export const HERO_DEFINITIONS = {
name: 'Enano',
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
stats: {
- move: 3,
- ws: 5,
- bs: 5,
+ move: 4,
+ ws: 4,
+ to_hit_missile: 5, // 5+ to hit with ranged
str: 3,
- toughness: 5,
- wounds: 13,
+ toughness: 5, // 4 Base + 1 Armor (Cota de Malla)
+ wounds: 11, // 1D6 + 8 (Using fixed average for now)
attacks: 1,
init: 2,
- luck: 0
+ pin_target: 5 // 5+ to escape pin
}
},
elf: {
@@ -36,15 +36,15 @@ export const HERO_DEFINITIONS = {
name: 'Elfa',
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
stats: {
- move: 5,
+ move: 4,
ws: 4,
- bs: 2, // Amazing shot
+ to_hit_missile: 4, // 4+ to hit with ranged
str: 3,
toughness: 3,
- wounds: 10,
+ wounds: 10, // 1D6 + 7 (Using fixed average for now)
attacks: 1,
init: 6,
- luck: 1
+ pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
}
},
wizard: {
@@ -53,15 +53,15 @@ export const HERO_DEFINITIONS = {
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
stats: {
move: 4,
- ws: 3,
- bs: 6,
+ ws: 2,
+ to_hit_missile: 6, // 6+ to hit with ranged
str: 3,
toughness: 3,
- wounds: 9,
+ wounds: 9, // 1D6 + 6 (Using fixed average for now)
attacks: 1,
- init: 4,
- luck: 1,
- power: 0 // Special mechanic
+ init: 3,
+ power: 0, // Tracks current power points
+ pin_target: 4 // 4+ to escape pin
}
}
};
diff --git a/src/engine/data/Monsters.js b/src/engine/data/Monsters.js
index e00c61f..019b6f6 100644
--- a/src/engine/data/Monsters.js
+++ b/src/engine/data/Monsters.js
@@ -1,32 +1,92 @@
export const MONSTER_DEFINITIONS = {
orc: {
id: 'orc',
- name: 'Orco',
+ name: 'Guerrero Orco',
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
stats: {
move: 4,
ws: 3,
- bs: 3, // Standard Orc BS
str: 3,
toughness: 4,
- wounds: 3,
+ wounds: 1, // Card: "Heridas: 1" (Wait, Orcs usually have 1, check image: YES "Heridas: 1")
attacks: 1,
- gold: 15
+ gold: 55 // Card: "Valor 55x Unidad"
}
},
- chaos_warrior: {
- id: 'chaos_warrior',
- name: 'Guerrero del Caos',
- portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
+ goblin_spearman: {
+ id: 'goblin_spearman',
+ name: 'Lancero Goblin',
+ portrait: '/assets/images/dungeon1/standees/enemies/goblin.png',
stats: {
move: 4,
- ws: 5,
- bs: 0,
- str: 5,
- toughness: 5,
- wounds: 8,
+ ws: 2,
+ str: 3,
+ toughness: 3,
+ wounds: 3,
+ wounds: 1,
+ attacks: 1,
+ gold: 20,
+ specialRules: ['reach_attack'] // "Puede atacar a dos casillas"
+ }
+ },
+ giant_rat: {
+ id: 'giant_rat',
+ name: 'Rata Gigante',
+ portrait: '/assets/images/dungeon1/standees/enemies/rat.png',
+ stats: {
+ move: 6,
+ ws: 2,
+ str: 2,
+ toughness: 3,
+ wounds: 1,
+ attacks: 1,
+ gold: 20,
+ specialRules: ['death_frenzy', 'sudden_death'] // "Frenesí suicida", "Muerte Súbita"
+ }
+ },
+ giant_spider: {
+ id: 'giant_spider',
+ name: 'Araña Gigante',
+ portrait: '/assets/images/dungeon1/standees/enemies/spider.png',
+ stats: {
+ move: 6,
+ ws: 2,
+ str: 3, // Card says "Fuerza: Especial", but base STR needed? Web attack deals auto 1D3. If not trapped, check hit normally.
+ toughness: 2,
+ wounds: 1,
+ attacks: 1,
+ gold: 15,
+ specialRules: ['web_attack']
+ }
+ },
+ giant_bat: {
+ id: 'giant_bat',
+ name: 'Murciélago Gigante',
+ portrait: '/assets/images/dungeon1/standees/enemies/bat.png',
+ stats: {
+ move: 8,
+ ws: 2,
+ str: 2,
+ toughness: 2,
+ wounds: 1,
+ attacks: 1,
+ gold: 15,
+ specialRules: ['fly', 'ambush_attack'] // "Nunca se traban", "Atacan tan pronto son colocados"
+ }
+ },
+ minotaur: {
+ id: 'minotaur',
+ name: 'Minotauro',
+ portrait: '/assets/images/dungeon1/standees/enemies/minotaur.png',
+ stats: {
+ move: 6,
+ ws: 4,
+ str: 4,
+ toughness: 4,
+ wounds: 15,
attacks: 2,
- gold: 150
+ gold: 440,
+ damageDice: 2 // "Tira 2 dados para herir"
}
}
};
diff --git a/src/engine/game/CombatMechanics.js b/src/engine/game/CombatMechanics.js
new file mode 100644
index 0000000..bca3c2a
--- /dev/null
+++ b/src/engine/game/CombatMechanics.js
@@ -0,0 +1,145 @@
+export const TO_HIT_CHART = [
+ // Defender WS 1 2 3 4 5 6 7 8 9 10
+ /* Attacker 1 */[4, 4, 5, 6, 6, 6, 6, 6, 6, 6],
+ /* Attacker 2 */[3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
+ /* Attacker 3 */[2, 3, 4, 4, 4, 4, 5, 5, 5, 6],
+ /* Attacker 4 */[2, 3, 3, 4, 4, 4, 4, 4, 5, 5],
+ /* Attacker 5 */[2, 2, 3, 3, 4, 4, 4, 4, 4, 4],
+ /* Attacker 6 */[2, 2, 3, 3, 3, 4, 4, 4, 4, 4],
+ /* Attacker 7 */[2, 2, 2, 3, 3, 3, 4, 4, 4, 4],
+ /* Attacker 8 */[2, 2, 2, 3, 3, 3, 3, 4, 4, 4],
+ /* Attacker 9 */[2, 2, 2, 2, 3, 3, 3, 3, 4, 4],
+ /* Attacker 10*/[2, 2, 2, 2, 3, 3, 3, 3, 3, 4]
+];
+
+export class CombatMechanics {
+
+ /**
+ * Resolves a melee attack sequence between two entities.
+ * @param {Object} attacker
+ * @param {Object} defender
+ * @returns {Object} Result log
+ */
+ static resolveMeleeAttack(attacker, defender) {
+ const log = {
+ attackerId: attacker.id,
+ defenderId: defender.id,
+ hitRoll: 0,
+ targetToHit: 0,
+ hitSuccess: false,
+ damageRoll: 0,
+ damageTotal: 0,
+ woundsCaused: 0,
+ defenderDied: false,
+ message: ''
+ };
+
+ // 1. Determine Stats
+ // Use stats object if available, otherwise direct property (fallback)
+ const attStats = attacker.stats || attacker;
+ const defStats = defender.stats || defender;
+
+ const attWS = Math.min(Math.max(attStats.ws || 1, 1), 10);
+ const defWS = Math.min(Math.max(defStats.ws || 1, 1), 10);
+
+ // 2. Roll To Hit
+ log.targetToHit = this.getToHitTarget(attWS, defWS);
+ log.hitRoll = this.rollD6();
+
+ // Debug
+ // console.log(`Combat: ${attacker.name} (WS${attWS}) vs ${defender.name} (WS${defWS}) -> Need ${log.targetToHit}+. Rolled ${log.hitRoll}`);
+
+ if (log.hitRoll < log.targetToHit) {
+ log.hitSuccess = false;
+ log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
+ return log;
+ }
+
+ log.hitSuccess = true;
+
+ // 3. Roll To Damage
+ const attStr = attStats.str || 3;
+ const defTough = defStats.toughness || 3;
+ const damageDice = attStats.damageDice || 1; // Default 1D6
+
+ let damageSum = 0;
+ let rolls = [];
+ for (let i = 0; i < damageDice; i++) {
+ const r = this.rollD6();
+ rolls.push(r);
+ damageSum += r;
+ }
+
+ log.damageRoll = damageSum; // Just sum for simple log, or we could array it
+ log.damageTotal = damageSum + attStr;
+
+ // 4. Calculate Wounds
+ // Wounds = (Dice + Str) - Toughness
+ let wounds = log.damageTotal - defTough;
+ if (wounds < 0) wounds = 0;
+
+ log.woundsCaused = wounds;
+
+ // 5. Build Message
+ if (wounds > 0) {
+ log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
+ } else {
+ log.message = `${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
+ }
+
+ // 6. Apply Damage to Defender State
+ this.applyDamage(defender, wounds);
+
+ if (defender.isDead) {
+ log.defenderDied = true;
+ log.message += ` ¡${defender.name} ha muerto!`;
+ } else if (defender.isUnconscious) {
+ log.message += ` ¡${defender.name} cae inconsciente!`;
+ }
+
+ return log;
+ }
+
+ static getToHitTarget(attackerWS, defenderWS) {
+ // Adjust for 0-index array
+ const row = attackerWS - 1;
+ const col = defenderWS - 1;
+ if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
+ return TO_HIT_CHART[row][col];
+ }
+ return 6; // Fallback
+ }
+
+ static applyDamage(entity, amount) {
+ if (!entity.stats) entity.stats = {};
+
+ // If entity doesn't have current wounds tracked, init it from max
+ if (entity.currentWounds === undefined) {
+ // For Heros it is 'wounds', for Monsters typical just 'wounds' in def
+ // We assume entity has been initialized properly before,
+ // but if not, we grab max from definition
+ entity.currentWounds = entity.stats.wounds || 1;
+ }
+
+ entity.currentWounds -= amount;
+
+ // Check Status
+ if (entity.type === 'hero') {
+ if (entity.currentWounds <= 0) {
+ entity.currentWounds = 0;
+ entity.isConscious = false;
+ // entity.isDead is not immediate for heroes usually, but let's handle via isConscious
+ }
+ } else {
+ // Monsters die at 0
+ if (entity.currentWounds <= 0) {
+ entity.currentWounds = 0;
+ entity.isDead = true;
+ }
+ }
+ }
+
+ static rollD6() {
+ return Math.floor(Math.random() * 6) + 1;
+ }
+}
diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js
index 022587b..91860ba 100644
--- a/src/engine/game/GameEngine.js
+++ b/src/engine/game/GameEngine.js
@@ -1,6 +1,7 @@
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { MonsterAI } from './MonsterAI.js';
+import { CombatMechanics } from './CombatMechanics.js';
import { HERO_DEFINITIONS } from '../data/Heroes.js';
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
@@ -17,6 +18,7 @@ export class GameEngine {
this.selectedEntity = null;
this.isRunning = false;
this.plannedPath = []; // Array of {x,y}
+ this.visitedRoomIds = new Set(); // Track tiles triggered
this.eventDeck = createEventDeck();
// Callbacks
@@ -102,7 +104,7 @@ export class GameEngine {
this.player = this.heroes[0];
}
- spawnMonster(monsterKey, x, y) {
+ spawnMonster(monsterKey, x, y, options = {}) {
const definition = MONSTER_DEFINITIONS[monsterKey];
if (!definition) {
console.error(`Monster definition not found: ${monsterKey}`);
@@ -126,8 +128,9 @@ export class GameEngine {
texturePath: definition.portrait,
stats: { ...definition.stats },
// Game State
- currentWounds: definition.stats.wounds,
- isDead: false
+ currentWounds: definition.stats.wounds || 1,
+ isDead: false,
+ skipTurn: !!options.skipTurn // Summoning sickness flag
};
this.monsters.push(monster);
@@ -140,9 +143,19 @@ export class GameEngine {
}
onCellClick(x, y) {
- // 1. Check for Hero/Monster Selection
+ // 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) : null;
+ const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
+
+ // COMBAT: Hero Attack Check
+ if (clickedMonster && this.selectedEntity && this.selectedEntity.type === 'hero') {
+ const attackResult = this.performHeroAttack(clickedMonster.id);
+ if (attackResult && attackResult.success) {
+ // Attack performed, do not deselect hero
+ return;
+ }
+ // If attack failed (e.g. not adjacent), proceeds to select the monster
+ }
const clickedEntity = clickedHero || clickedMonster;
@@ -168,6 +181,32 @@ export class GameEngine {
}
}
+ performHeroAttack(targetMonsterId) {
+ const hero = this.selectedEntity;
+ const monster = this.monsters.find(m => m.id === targetMonsterId);
+
+ if (!hero || !monster) return null;
+
+ // Check Phase
+ if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
+
+ // Check Adjacency
+ const dx = Math.abs(hero.x - monster.x);
+ const dy = Math.abs(hero.y - monster.y);
+ if (dx + dy !== 1) return { success: false, reason: 'range' };
+
+ // Check Action Economy
+ if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
+
+ // Execute Attack
+ const result = CombatMechanics.resolveMeleeAttack(hero, monster);
+ hero.hasAttacked = true;
+
+ if (this.onCombatResult) this.onCombatResult(result);
+
+ return { success: true, result };
+ }
+
deselectEntity() {
if (!this.selectedEntity) return;
const id = this.selectedEntity.id;
@@ -185,6 +224,13 @@ export class GameEngine {
planStep(x, y) {
if (!this.selectedEntity) return;
+ // Valid Phase Check
+ // Allow movement in Hero Phase AND Exploration Phase (for positioning ease)
+ const phase = this.turnManager.currentPhase;
+ if (phase !== 'hero' && phase !== 'exploration' && this.selectedEntity.type === 'hero') {
+ return;
+ }
+
// Determine start point
const lastStep = this.plannedPath.length > 0
? this.plannedPath[this.plannedPath.length - 1]
@@ -234,28 +280,90 @@ export class GameEngine {
executeMovePath() {
if (!this.selectedEntity || !this.plannedPath.length) return;
- const path = [...this.plannedPath];
+ const fullPath = [...this.plannedPath];
const entity = this.selectedEntity;
- // Update verify immediately
- const finalDest = path[path.length - 1];
- entity.x = finalDest.x;
- entity.y = finalDest.y;
+ let stepsTaken = 0;
+ let triggeredEvents = false;
- // Visual animation
- if (this.onEntityMove) {
- this.onEntityMove(entity, path);
+ // 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++;
+
+ // 2. Check for New Tile Entry
+ const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
+
+ if (tileId && !this.visitedRoomIds.has(tileId)) {
+
+ // Mark as visited immediatley
+ this.visitedRoomIds.add(tileId);
+
+ // Check Tile Type (Room vs Corridor)
+ const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId);
+ const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective'));
+
+ if (isRoom) {
+ console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`);
+
+ // Disparar Evento (need cells)
+ const newCells = [];
+ for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
+ if (tid === tileId) {
+ const [cx, cy] = key.split(',').map(Number);
+ newCells.push({ x: cx, y: cy });
+ }
+ }
+
+ // Call Event Logic
+ const eventResult = this.onRoomRevealed(newCells);
+
+ // Always stop for Rooms
+ if (eventResult) {
+ console.log("Movement stopped by Room Entry!");
+ triggeredEvents = true;
+
+ // Notify UI via callback
+ if (this.onEventTriggered) {
+ this.onEventTriggered(eventResult);
+ }
+
+ // Send PARTIAL path to renderer (from 0 to current step i+1)
+ if (this.onEntityMove) {
+ this.onEntityMove(entity, fullPath.slice(0, i + 1));
+ }
+
+ break; // Stop loop
+ }
+ } else {
+ console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
+ }
+ }
+ }
+
+ // If NO interruption, send full path
+ if (!triggeredEvents) {
+ if (this.onEntityMove) {
+ this.onEntityMove(entity, fullPath);
+ }
}
// Deduct Moves
if (entity.currentMoves !== undefined) {
- entity.currentMoves -= path.length;
+ // Only deduct steps actually taken. No penalty.
+ entity.currentMoves -= stepsTaken;
if (entity.currentMoves < 0) entity.currentMoves = 0;
}
this.deselectEntity();
}
+
+
canMoveTo(x, y) {
// Check if cell is walkable (occupied by a tile)
return this.dungeon.grid.isOccupied(x, y);
@@ -268,45 +376,17 @@ export class GameEngine {
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
}
- // Check if the Leader (Lamp Bearer) is adjacent to the door
- isLeaderAdjacentToDoor(doorCells) {
- 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;
- }
-
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];
}
- // Deprecated generic adjacency (kept for safety or other interactions)
- isPlayerAdjacentToDoor(doorCells) {
- return this.isLeaderAdjacentToDoor(doorCells);
- }
-
update(time) {
// Minimal update loop
}
+
findSpawnPoints(count) {
const points = [];
- const queue = [{ x: 1, y: 1 }]; // Start search near origin but ensure not 0,0 which might be tricky if it's door
- // Actually, just scan the grid or BFS from center of first tile?
- // First tile is placed at 0,0. Let's scan from 0,0.
-
const startNode = { x: 0, y: 0 };
const searchQueue = [startNode];
const visited = new Set(['0,0']);
@@ -384,7 +464,11 @@ export class GameEngine {
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!");
@@ -400,16 +484,21 @@ export class GameEngine {
};
}
- return null;
+ // Return event info even if empty, so movement stops
+ return {
+ type: 'EVENT',
+ cardName: card.name,
+ message: 'La sala parece despejada.'
+ };
}
// =========================================
// MONSTER AI & TURN LOGIC
// =========================================
- playMonsterTurn() {
+ async playMonsterTurn() {
if (this.ai) {
- this.ai.executeTurn();
+ await this.ai.executeTurn();
}
}
diff --git a/src/engine/game/MonsterAI.js b/src/engine/game/MonsterAI.js
index bfe1ad9..980e92a 100644
--- a/src/engine/game/MonsterAI.js
+++ b/src/engine/game/MonsterAI.js
@@ -1,3 +1,5 @@
+import { CombatMechanics } from './CombatMechanics.js';
+
export class MonsterAI {
constructor(gameEngine) {
this.game = gameEngine;
@@ -13,9 +15,19 @@ export class MonsterAI {
// Sequential execution with delay
for (const monster of this.game.monsters) {
- // Check if monster still exists (e.g. didn't die from a trap in previous move - unlikely but good practice)
+ // Check if monster still exists
if (monster.isDead) continue;
+ // Check for Summoning Sickness / Ambush delay
+ if (monster.skipTurn) {
+ console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
+ monster.skipTurn = false; // Ready for next turn
+
+ // Add a small visual delay even if skipping, to show focus?
+ // No, better to just skip significantly to keep flow fast.
+ continue;
+ }
+
await this.processMonster(monster);
}
}
@@ -23,33 +35,26 @@ export class MonsterAI {
processMonster(monster) {
return new Promise(resolve => {
// Calculate delay based on potential move distance to ensure animation finishes
- // Renderer takes ~300ms per step.
- // Move is max 4 usually -> 1200ms.
- // We use simple heuristic: wait for max possible animation time
-
- const moveTime = (monster.stats.move * 300) + 200; // +buffer
+ const moveTime = (monster.stats.move * 300) + 500; // +buffer for attack logic
setTimeout(() => {
- this.moveMonster(monster);
-
- // IMPORTANT: The moveMonster function initiates the animation.
- // We should technically resolve AFTER the animation time.
- // moveMonster returns instantly.
- // So we wait here.
+ this.actMonster(monster);
setTimeout(resolve, moveTime);
-
}, 100);
});
}
- moveMonster(monster) {
- // 1. Check if already adjacent (Engaged)
- if (this.isEntityAdjacentToHero(monster)) {
- console.log(`[MonsterAI] ${monster.id} is already engaged.`);
+ actMonster(monster) {
+ // 1. Check if already adjacent (Engaged) -> ATTACK
+ const adjacentHero = this.getAdjacentHero(monster);
+
+ if (adjacentHero) {
+ console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
+ this.performAttack(monster, adjacentHero);
return;
}
- // 2. Find Closest Hero
+ // 2. Find Closest Hero to Move Towards
const targetHero = this.getClosestHero(monster);
if (!targetHero) {
console.log(`[MonsterAI] ${monster.id} has no targets.`);
@@ -57,7 +62,6 @@ export class MonsterAI {
}
// 3. Calculate Path (BFS with fallback)
- // We use a flexible limit.
const path = this.findPath(monster, targetHero, 30);
if (!path || path.length === 0) {
@@ -73,33 +77,22 @@ export class MonsterAI {
// 5. Update Renderer ONCE with full path
if (this.game.onEntityMove) {
- // We need the full path for the renderer to animate smoothly step by step
- // The renderer logic expects a queue of steps. we should pass `actualPath`
this.game.onEntityMove(monster, actualPath);
}
- // 6. Final Update Logic (Instant state update for AI calculation)
- // But wait! If we update state instantly, the next monster will see this monster at end position.
- // This is correct behavior for sequential movement logic.
- // The VISUALS might be lagging, but the LOGIC is solid.
- // However, we want the "wait" in processMonster to actually wait for the visual animation.
-
- // Let's verify Renderer duration: 300ms per step.
- // If path is 4 steps -> 1200ms.
- // Our wait is 600ms constant. This is why it looks jumpy or sync issues?
-
- // Actually, let's update the coordinates sequentially too if we want AI to respect intermediate blocking?
- // No, standard turn-based usually calculates full path then executes.
-
+ // 6. Final Logic Update (Instant coordinates)
const finalDest = actualPath[actualPath.length - 1];
monster.x = finalDest.x;
monster.y = finalDest.y;
- // We do NOT loop with breaks here anymore for visual steps, because we pass the full path to renderer.
- // We only check for end condition (adjacency) to potentially truncate the path if we want to stop early?
- // But we already calculated the path to stop at adjacency.
-
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
+
+ // 7. Check if NOW adjacent after move -> ATTACK
+ const postMoveHero = this.getAdjacentHero(monster);
+ if (postMoveHero) {
+ console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
+ this.performAttack(monster, postMoveHero);
+ }
}
getClosestHero(monster) {
@@ -199,4 +192,31 @@ export class MonsterAI {
// Only return if we actually have a path to move (length > 0)
return bestPath;
}
+
+ performAttack(monster, hero) {
+ const result = CombatMechanics.resolveMeleeAttack(monster, hero);
+ console.log(`[COMBAT] ${result.message}`);
+
+ // Notify UI/GameEngine about damage (if we had a hook)
+ if (this.game.onCombatResult) {
+ this.game.onCombatResult(result);
+ }
+ }
+
+ getAdjacentHero(entity) {
+ return this.game.heroes.find(hero => {
+ // Check conscious or allow beating unconscious? standard rules say monsters attack unconscious heroes until death.
+ // But let's check basic mechanics first.
+ // "Cuando al Aventurero no le quedan más Heridas cae al suelo inconsciente... El Aventurero no está necesariamente muerto"
+ // "Continúa anotando el número de Heridas hasta que no le quedan más... nunca puede bajar de 0."
+ // Implicitly, they can still be attacked.
+
+ if (hero.isDead) return false;
+
+ const dx = Math.abs(entity.x - hero.x);
+ const dy = Math.abs(entity.y - hero.y);
+ // Orthogonal adjacency only (Manhattan dist 1)
+ return (dx + dy) === 1;
+ });
+ }
}
diff --git a/src/main.js b/src/main.js
index 33d45d3..fa4bd52 100644
--- a/src/main.js
+++ b/src/main.js
@@ -37,15 +37,15 @@ generator.grid.placeTile = (instance, variant, card) => {
setTimeout(() => {
renderer.renderExits(generator.availableExits);
- // Check if new tile is a ROOM to trigger events
- // Note: 'room_dungeon' includes standard room card types
- if (card.type === 'room' || card.id.startsWith('room')) {
- const eventResult = game.onRoomRevealed(cells);
- if (eventResult && eventResult.count > 0) {
- // Show notification?
- ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen ${eventResult.count} Orcos!`);
+ // NEW RULE: Exploration ends turn immediately. No monsters yet.
+ // Monsters appear when a hero ENTERS the new room in the next turn.
+ ui.showModal('Exploración Completada',
+ 'Has colocado una nueva sección de mazmorra.
El turno termina aquí.',
+ () => {
+ game.turnManager.endTurn();
}
- }
+ );
+
}, 50);
};
@@ -63,12 +63,37 @@ game.onEntityUpdate = (entity) => {
game.turnManager.on('phase_changed', (phase) => {
if (phase === 'monster') {
- setTimeout(() => {
- game.playMonsterTurn();
+ setTimeout(async () => {
+ await game.playMonsterTurn();
+
+ // Logic: Skip Exploration if monsters are alive
+ const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
+
+ if (hasActiveMonsters) {
+ ui.showModal('¡Combate en curso!',
+ 'Aún quedan monstruos vivos. Se salta la Fase de Exploración.
Preparaos para la Fase de Poder del siguiente turno.',
+ () => {
+ // Combat Loop: Power -> Hero -> Monster -> (Skip Exp) -> Power...
+ game.turnManager.endTurn();
+ }
+ );
+ } else {
+ ui.showModal('Zona Despejada',
+ 'Fase de Monstruos Finalizada.
Pulsa para continuar a la Fase de Exploración.',
+ () => {
+ game.turnManager.nextPhase(); // Go to Exploration
+ }
+ );
+ }
+
}, 500); // Slight delay for visual impact
}
});
+game.onCombatResult = (log) => {
+ ui.showCombatLog(log);
+};
+
game.onEntityMove = (entity, path) => {
renderer.moveEntityAlongPath(entity, path);
};
@@ -109,9 +134,11 @@ game.onPathChange = (path) => {
// 6. Handle Clicks
const handleClick = (x, y, doorMesh) => {
+ const currentPhase = game.turnManager.currentPhase;
+ const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
+
// PRIORITY 1: Tile Placement Mode - ignore all clicks
if (generator.state === 'PLACING_TILE') {
-
return;
}
@@ -124,6 +151,18 @@ const handleClick = (x, y, doorMesh) => {
if (!doorMesh.userData.isOpen) {
+ // CHECK PHASE: Exploration Only
+ if (currentPhase !== 'exploration') {
+ ui.showModal('Fase Incorrecta', 'Solo puedes explorar (abrir puertas) durante la Fase de Exploración.');
+ return;
+ }
+
+ // CHECK MONSTERS: Must be clear
+ if (hasActiveMonsters) {
+ ui.showModal('¡Peligro!', 'No puedes explorar mientras hay Monstruos cerca. ¡Acaba con ellos primero!');
+ return;
+ }
+
// 1. Check Selection and Leadership (STRICT)
const selectedHero = game.selectedEntity;
@@ -138,8 +177,6 @@ const handleClick = (x, y, doorMesh) => {
}
// 2. Check Adjacency
- // Since we know selectedHero IS the leader, we can just check if *this* hero is adjacent.
- // game.isLeaderAdjacentToDoor checks the 'getLeader()' position, which aligns with selectedHero here.
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
// Open door visually
@@ -149,11 +186,6 @@ const handleClick = (x, y, doorMesh) => {
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
-
- // Allow UI to update phase if not already
- // if (game.turnManager.currentPhase !== 'exploration') {
- // game.turnManager.setPhase('exploration');
- // }
} else {
console.error('[Main] Door missing exitData');
}
@@ -166,6 +198,17 @@ const handleClick = (x, y, doorMesh) => {
// PRIORITY 3: Normal cell click (player selection/movement)
if (x !== null && y !== null) {
+ // Restrict Hero Selection/Movement to Hero Phase (and verify logic in GameEngine handle selection)
+ // Actually, we might want to select heroes in other phases to see stats, but MOVE only in Hero Phase.
+ // GameEngine.planStep handles planning.
+
+ // We let GameEngine handle selection. But for movement planning...
+ // Let's modify onCellClick inside GameEngine or just block here?
+ // Blocking execution is safer.
+
+ // Wait, onCellClick handles Selection AND Planning.
+ // We'll let it select. But we hook executeMovePath separately.
+
game.onCellClick(x, y);
}
};
@@ -193,10 +236,31 @@ window.addEventListener('keydown', (e) => {
}
});
+game.onEventTriggered = (eventResult) => {
+ if (eventResult) {
+ if (eventResult.type === 'MONSTER_SPAWN') {
+ const count = eventResult.count || 0;
+ ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen ${count} Enemigos!
Tu movimiento se detiene.`);
+ } else if (eventResult.message) {
+ ui.showModal('Zona Explorada', `${eventResult.message}
Tu movimiento se detiene.`);
+ }
+ }
+};
+
// 7. Start
game.startMission(mission);
+// Mark initial tile as visited to prevent immediate trigger
+if (game.heroes && game.heroes.length > 0) {
+ const h = game.heroes[0];
+ const initialTileId = game.dungeon.grid.occupiedCells.get(`${h.x},${h.y}`);
+ if (initialTileId) {
+ game.visitedRoomIds.add(initialTileId);
+ console.log(`[Main] Initial tile ${initialTileId} marked as visited.`);
+ }
+}
+
// 8. Render Loop
const animate = (time) => {
requestAnimationFrame(animate);
diff --git a/src/view/CameraManager.js b/src/view/CameraManager.js
index 2da8c64..047fa10 100644
--- a/src/view/CameraManager.js
+++ b/src/view/CameraManager.js
@@ -123,7 +123,7 @@ export class CameraManager {
// Direction: Dragging the "World"
// Mouse Left (dx < 0) -> Camera moves Right (+X)
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
- const moveX = -dx * moveSpeed;
+ const moveX = dx * moveSpeed;
const moveY = dy * moveSpeed;
// Apply to Camera (Local Space)
diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js
index b7b74f4..092bc55 100644
--- a/src/view/GameRenderer.js
+++ b/src/view/GameRenderer.js
@@ -77,8 +77,9 @@ export class GameRenderer {
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
if (doorIntersects.length > 0) {
const doorMesh = doorIntersects[0].object;
- if (doorMesh.userData.isDoor) {
- // Clicked on a door! Call onClick with a special door object
+ // Only capture click if it is a door AND it is NOT open
+ if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
+ // Clicked on a CLOSED door! Call onClick with a special door object
onClick(null, null, doorMesh);
return;
}
diff --git a/src/view/UIManager.js b/src/view/UIManager.js
index 6f2be69..e64b727 100644
--- a/src/view/UIManager.js
+++ b/src/view/UIManager.js
@@ -390,7 +390,7 @@ export class UIManager {
ctx.stroke();
}
- showModal(title, message) {
+ showModal(title, message, onClose) {
// Overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
@@ -442,6 +442,7 @@ export class UIManager {
btn.style.border = '1px solid #888';
btn.onclick = () => {
this.container.removeChild(overlay);
+ if (onClose) onClose();
};
content.appendChild(btn);
@@ -449,6 +450,40 @@ export class UIManager {
this.container.appendChild(overlay);
}
+ showCombatLog(log) {
+ if (!this.notificationArea) return;
+
+ const isHit = log.hitSuccess;
+ const color = isHit ? '#ff4444' : '#aaaaaa';
+ const title = isHit ? 'GOLPE!' : 'FALLO';
+
+ let detailHtml = '';
+ if (isHit) {
+ if (log.woundsCaused > 0) {
+ detailHtml = `