From 77c0c07a4481bfc33d9a75405a14501bd3256e94 Mon Sep 17 00:00:00 2001 From: Marti Vich Date: Mon, 5 Jan 2026 23:11:31 +0100 Subject: [PATCH] feat(game-loop): implement strict phase rules, exploration stops, and hero attacks --- DEVLOG.md | 33 ++++++ implementación/task.md | 4 +- src/engine/data/Heroes.js | 42 +++---- src/engine/data/Monsters.js | 88 +++++++++++--- src/engine/game/CombatMechanics.js | 145 +++++++++++++++++++++++ src/engine/game/GameEngine.js | 181 +++++++++++++++++++++-------- src/engine/game/MonsterAI.js | 96 +++++++++------ src/main.js | 100 +++++++++++++--- src/view/CameraManager.js | 2 +- src/view/GameRenderer.js | 5 +- src/view/UIManager.js | 37 +++++- 11 files changed, 591 insertions(+), 142 deletions(-) create mode 100644 src/engine/game/CombatMechanics.js 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 = `
-${log.woundsCaused} HP
`; + } else { + detailHtml = `
Sin Heridas (Armadura)
`; + } + } else { + detailHtml = `
Esquivado / Fallado
`; + } + + // Show simplified but impactful message + this.notificationArea.innerHTML = ` +
+
${log.attackerId.split('_')[0]} ATACA
+ ${detailHtml} +
${log.message}
+
+ `; + + this.notificationArea.style.opacity = '1'; + + setTimeout(() => { + if (this.notificationArea) this.notificationArea.style.opacity = '0'; + }, 3500); + } + showConfirm(title, message, onConfirm) { // Overlay const overlay = document.createElement('div');