feat(game-loop): implement strict phase rules, exploration stops, and hero attacks

This commit is contained in:
2026-01-05 23:11:31 +01:00
parent b619e4cee4
commit 77c0c07a44
11 changed files with 591 additions and 142 deletions

View File

@@ -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

View File

@@ -37,7 +37,9 @@
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
- [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**

View File

@@ -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
}
}
};

View File

@@ -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"
}
}
};

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
});
}
}

View File

@@ -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 <b>${eventResult.count} Orcos</b>!`);
// 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.<br>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.<br>Preparaos para la <b>Fase de Poder</b> del siguiente turno.',
() => {
// Combat Loop: Power -> Hero -> Monster -> (Skip Exp) -> Power...
game.turnManager.endTurn();
}
);
} else {
ui.showModal('Zona Despejada',
'Fase de Monstruos Finalizada.<br>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 <b>Fase de Exploración</b>.');
return;
}
// CHECK MONSTERS: Must be clear
if (hasActiveMonsters) {
ui.showModal('¡Peligro!', 'No puedes explorar mientras hay <b>Monstruos</b> 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 <b>${count} Enemigos</b>!<br>Tu movimiento se detiene.`);
} else if (eventResult.message) {
ui.showModal('Zona Explorada', `${eventResult.message}<br>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);

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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 = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
} else {
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
}
} else {
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
}
// Show simplified but impactful message
this.notificationArea.innerHTML = `
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 2px solid ${color}; border-radius: 5px; text-align: center; min-width: 250px;">
<div style="font-family: 'Cinzel'; font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
${detailHtml}
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
</div>
`;
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');