diff --git a/DEVLOG.md b/DEVLOG.md
index 80b4da1..539f349 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -1,5 +1,64 @@
# Devlog - Warhammer Quest (Versión Web 3D)
+## Sesión 14: Estabilización del Flujo de Juego y Bugfix Crítico
+**Fecha:** 10 de Enero de 2026
+
+### Objetivos
+- Solucionar el bloqueo crítico al revelar nuevas estancias ("Estancia Revelada - Preparando encuentro...").
+- Refinar el sistema híbrido de notificaciones (Log persistente + Texto flotante).
+
+### Cambios Realizados
+
+#### 1. Corrección del Bloqueo en "Estancia Revelada"
+- **Síntoma**: Al entrar en una habitación nueva, el juego mostraba el mensaje de "Preparando encuentro..." pero se quedaba congelado en la fase de Héroes, impidiendo que los monstruos o el evento se activaran.
+- **Causa**: La lógica de eventos difería la resolución a la Fase de Monstruos (`pendingExploration`), pero el cambio de fase no se disparaba automáticamente si no había enemigos previos, dejando al juego en un estado indeterminado ("limbo").
+- **Solución**:
+ - Se ha modificado `GameEngine.js` para resolver el evento de exploración **inmediatamente** al entrar en la sala (callback en `executeMovePath`).
+ - Si surgen monstruos, se fuerza el cambio a Fase de Monstruos.
+ - Si la sala está despejada, se mantiene la Fase de Héroes, permitiendo continuar el turno.
+- **Mejora en `EventInterpreter`**: Se añadió soporte para `callbacks` de finalización (`onComplete`), permitiendo un control de flujo más granular en lugar de depender ciegamente de `resumeFromEvent`.
+
+#### 2. Sistema de Notificaciones Híbrido
+- **Consola Persistente**: Se ha refinado el panel de log en la esquina inferior izquierda (`FeedbackUI.js`) para mantener un historial de eventos de combate y reglas.
+- **Texto Flotante**: Se mantiene `renderer.showFloatingText` para feedback inmediato y efímero sobre las unidades (daño, estados).
+- **Integración**: `GameEngine` ahora redirigide los mensajes de eventos críticos a ambos sistemas según su importancia.
+
+### Estado Actual
+El juego es ahora estable en el ciclo principal de exploración y combate. La transición entre descubrir una sala y combatir monstruos es fluida e inmediata, sin pausas extrañas ni bloqueos.
+
+---
+
+## Sesión 13: Eventos de Exploración y Regla de Derrumbamiento
+**Fecha:** 9 de Enero de 2026
+
+### Objetivos
+- Implementar la mecánica de "Derrumbamiento" (Collapse) con todas sus reglas asociadas.
+- Establecer el sistema de Eventos de Exploración al entrar en nuevas estancias ("Room Revealed").
+
+### Cambios Realizados
+
+#### 1. Mecánica de Derrumbamiento ("Collapse")
+- **Regla de "Sala Inicial"**: Si se roba la carta de Derrumbamiento en la sala de inicio (tile_0) o en el turno 1, se ignora y se roba otra carta automáticamente para evitar un "Game Over" inmediato antes de empezar.
+- **Bloqueo Visual de Salidas**: Implementada la función `collapseExits` que:
+ - Identifica las salidas no abiertas de la sala actual.
+ - Coloca marcadores visuales de "Escombros" y cambia la textura de las puertas a "Bloqueada".
+ - Elimina lógicamente las salidas del generador de mazmorras.
+ - Agrupa celdas de puerta adyacentes para reportar un conteo de puertas "humanas" (visuales) en lugar de celdas individuales en el log.
+- **Cuenta Atrás Mortal**: Implementado estado de `collapsingRoom`. Al final del siguiente turno, cualquier entidad (héroe o monstruo) que permanezca en la sala muere aplastada instantáneamente.
+- **Destrabarse Gratis**: Modificada la lógica `attemptBreakAway`. Si la sala se está derrumbando, los héroes ignoran las zonas de control de los monstruos y escapan automáticamente (éxito garantizado) para intentar salvar su vida.
+
+#### 2. Eventos de Exploración (Nuevas Estancias)
+- **Detección de Entrada**: El sistema de movimiento (`executeMovePath`) ahora detecta si el héroe entra en una celda de tipo `ROOM` que no ha sido `visited`.
+- **Interrupción de Movimiento**: Al entrar en una habitación nueva, el héroe se detiene instantáneamente y pierde el resto de su movimiento ("Stop").
+- **Fase de Monstruos**: Al inicio de la Fase de Monstruos, si hay una exploración pendiente:
+ - Se roba una carta del Mazo de Eventos.
+ - Si es un Evento, se resuelve normalmente.
+ - Si son Monstruos, se generan **DENTRO** de la sala recién revelada (gracias a la inyección de contexto `tileId` en `findSpawnPoints`).
+
+### Estado Actual
+El juego ahora soporta situaciones de crisis extremas (Derrumbes) y la tensión natural de abrir puertas desconocidas. La exploración ya no es segura; cada nueva sala es una amenaza potencial que se activa en el turno de los monstruos.
+
+
## Sesión 12 (Continuación): Refactorización y Renderizado (Intento II - Exitoso)
**Fecha:** 9 de Enero de 2026
diff --git a/public/assets/images/dungeon1/markers/rubbles.png b/public/assets/images/dungeon1/markers/rubbles.png
new file mode 100644
index 0000000..8baa3ce
Binary files /dev/null and b/public/assets/images/dungeon1/markers/rubbles.png differ
diff --git a/public/assets/images/dungeon1/standees/enemies/araña_gigante.png b/public/assets/images/dungeon1/standees/enemies/araña_gigante.png
new file mode 100644
index 0000000..d7881ff
Binary files /dev/null and b/public/assets/images/dungeon1/standees/enemies/araña_gigante.png differ
diff --git a/public/assets/images/dungeon1/standees/enemies/minotaur.png b/public/assets/images/dungeon1/standees/enemies/minotaur.png
new file mode 100644
index 0000000..f0b2e5d
Binary files /dev/null and b/public/assets/images/dungeon1/standees/enemies/minotaur.png differ
diff --git a/public/assets/images/dungeon1/standees/enemies/spiderGiant.png b/public/assets/images/dungeon1/standees/enemies/spiderGiant.png
index b02f9e0..ffefbb2 100644
Binary files a/public/assets/images/dungeon1/standees/enemies/spiderGiant.png and b/public/assets/images/dungeon1/standees/enemies/spiderGiant.png differ
diff --git a/public/assets/images/dungeon1/standees/enemies/spiderGiant_bak.png b/public/assets/images/dungeon1/standees/enemies/spiderGiant_bak.png
new file mode 100644
index 0000000..b02f9e0
Binary files /dev/null and b/public/assets/images/dungeon1/standees/enemies/spiderGiant_bak.png differ
diff --git a/public/assets/images/dungeon1/tokens/enemies/minotaur.png b/public/assets/images/dungeon1/tokens/enemies/minotaur.png
new file mode 100644
index 0000000..5f403fa
Binary files /dev/null and b/public/assets/images/dungeon1/tokens/enemies/minotaur.png differ
diff --git a/src/engine/data/EventCards.js b/src/engine/data/EventCards.js
index 3c23f79..0108dab 100644
--- a/src/engine/data/EventCards.js
+++ b/src/engine/data/EventCards.js
@@ -1,114 +1,251 @@
export const EVENT_CARDS_DATA = [
{
+ "id": "evt_derrumbamiento",
"titulo": "DERRUMBAMIENTO",
"tipo": "Evento",
"codigo_tipo": "E",
- "descripcion": "Todas las salidas, excepto por la que entraron los Aventureros, están bloqueadas. Al final del siguiente turno, cualquier miniatura en la estancia muere aplastada. La estancia queda intransitable.",
- "reglas_especiales": [
- "Colocar marcador de Derrumbamiento.",
- "Aventureros no sujetos a reglas de trabado por combate al intentar escapar.",
- "Los Monstruos en la estancia mueren automáticamente."
- ],
- "cita_fuente": "[cite: 1, 2, 3, 4, 5]"
+ "descripcion": "Todas las salidas, excepto por la que entraron los Aventureros, están bloqueadas.",
+ "cita_fuente": "[cite: 1, 2, 3, 4, 5]",
+ "acciones": [
+ {
+ "tipo_accion": "ENTORNO",
+ "subtipo": "bloquear_salidas_excepto_entrada"
+ },
+
+ {
+ "tipo_accion": "MENSAJE",
+ "texto": "¡Derrumbamiento! La estancia quedará intransitable al final del siguiente turno."
+ },
+ {
+ "tipo_accion": "ESTADO_GLOBAL",
+ "id": "cuenta_atras_derrumbamiento",
+ "duracion_turnos": 1,
+ "efecto_fin": "muerte_instantanea_en_loseta"
+ }
+ ]
},
{
+ "id": "evt_cadaver",
"titulo": "CADÁVER",
"tipo": "Evento",
"codigo_tipo": "E",
- "descripcion": "Un Bárbaro muerto sostiene una bolsa de cuero. El Aventurero con el resultado más bajo en 1D6 coge la bolsa.",
- "tabla_efectos": [
- { "resultado": "1", "efecto": "¡Gas venenoso! 1D6 Heridas (sin modificadores). Bolsa vacía." },
- { "resultado": "2-3", "efecto": "¡Trampa! Lanza de pared inflige 2D6 Heridas al Aventurero. Bolsa vacía." },
- { "resultado": "4-6", "efecto": "Tesoro. La bolsa contiene 1D6 x 100 monedas de oro." }
- ],
- "notas": "Roba otra Carta de Evento inmediatamente después de resolver.",
- "cita_fuente": "[cite: 6, 7, 11, 15, 16]"
+ "descripcion": "Un Bárbaro muerto sostiene una bolsa de cuero.",
+ "cita_fuente": "[cite: 6, 7, 11, 15, 16]",
+ "acciones": [
+ {
+ "tipo_accion": "SELECCION",
+ "modo": "tirada_baja",
+ "dado": "1D6",
+ "guardar_como": "victima",
+ "mensaje": "Cada jugador tira 1D6. El más bajo investiga el cadáver."
+ },
+ {
+ "tipo_accion": "PRUEBA",
+ "origen": "victima",
+ "tipo_prueba": "1D6",
+ "tabla": {
+ "1": [
+ { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "1D6", "mensaje": "¡Gas venenoso!" },
+ { "tipo_accion": "MENSAJE", "texto": "La bolsa estaba vacía." }
+ ],
+ "2-3": [
+ { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "2D6", "mensaje": "¡Trampa de lanza!" },
+ { "tipo_accion": "MENSAJE", "texto": "La bolsa estaba vacía." }
+ ],
+ "4-6": [
+ { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "oro", "cantidad": "1D6*100", "mensaje": "¡Encuentras oro!" }
+ ]
+ }
+ },
+ {
+ "tipo_accion": "TRIGGER_EVENTO",
+ "cantidad": 1
+ }
+ ]
},
{
+ "id": "evt_viejos_huesos",
"titulo": "VIEJOS HUESOS",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "Suelo cubierto de huesos y cráneos con brillo de monedas bajo ellos.",
- "tabla_efectos": [
- { "resultado": "1", "efecto": "Ilusión. Los huesos y el oro desaparecen. Roba una Carta de Evento inmediatamente." },
- { "resultado": "2-3", "efecto": "Rayo mágico. Un Aventurero al azar sufre 1D6 Heridas (sin modificadores). Roba una Carta de Evento inmediatamente." },
- { "resultado": "4-5", "efecto": "Cada Aventurero en la sección encuentra 1D6 x 10 monedas de oro. Roba otra Carta de Evento." },
- { "resultado": "6", "efecto": "Cada Aventurero encuentra 2D6 x 10 monedas de oro y roba una Carta de Tesoro." }
- ],
- "cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]"
+ "cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]",
+ "acciones": [
+ {
+ "tipo_accion": "PRUEBA",
+ "tipo_prueba": "1D6_GLOBAL",
+ "mensaje": "Tira 1D6 para ver qué esconden los huesos.",
+ "tabla": {
+ "1": [
+ { "tipo_accion": "MENSAJE", "texto": "Ilusión. Los huesos y el oro desaparecen." },
+ { "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
+ ],
+ "2-3": [
+ { "tipo_accion": "SELECCION", "modo": "azar", "guardar_como": "victima" },
+ { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "1D6", "mensaje": "¡Rayo mágico!" },
+ { "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
+ ],
+ "4-5": [
+ { "tipo_accion": "SELECCION", "modo": "todos" },
+ { "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "1D6*10", "mensaje": "Cada aventurero encuentra monedas." },
+ { "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
+ ],
+ "6": [
+ { "tipo_accion": "SELECCION", "modo": "todos" },
+ { "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "2D6*10", "mensaje": "¡Un gran hallazgo de oro!" },
+ { "tipo_accion": "EFECTO", "tipo": "carta_tesoro", "cantidad": 1, "mensaje": "Robas una Carta de Tesoro." }
+ ]
+ }
+ }
+ ]
},
{
+ "id": "evt_trampa",
"titulo": "TRAMPA",
"tipo": "Evento",
"codigo_tipo": "E",
- "descripcion": "El Aventurero con el resultado menor en 1D6 activa una trampa.",
- "tabla_efectos": [
- { "resultado": "1", "efecto": "Explosión. Todas las miniaturas en la sección sufren 1D6 Heridas (sin modificadores)." },
- { "resultado": "2-5", "efecto": "Grieta. El Aventurero cae al subsuelo y sufre 2D6 Heridas. Solo escapa con Cuerda o Hechizo Levitar." },
- { "resultado": "6", "efecto": "Tesoro oculto. Roba una Carta de Tesoro. Con 1-3 en 1D6, roba otro Evento." }
- ],
- "cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]"
+ "descripcion": "El Aventurero con el resultado menor activa una trampa.",
+ "cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]",
+ "acciones": [
+ {
+ "tipo_accion": "SELECCION",
+ "modo": "tirada_baja",
+ "dado": "1D6",
+ "guardar_como": "victima",
+ "mensaje": "¡Click! Alguien ha pisado algo..."
+ },
+ {
+ "tipo_accion": "PRUEBA",
+ "origen": "victima",
+ "tipo_prueba": "1D6",
+ "tabla": {
+ "1": [
+ { "tipo_accion": "SELECCION", "modo": "todos" },
+ { "tipo_accion": "EFECTO", "tipo": "daño", "cantidad": "1D6", "mensaje": "¡Explosión! Todos sufren daño." }
+ ],
+ "2-5": [
+ { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "2D6", "mensaje": "¡Caes por una grieta!" },
+ { "tipo_accion": "MENSAJE", "texto": "Solo escapas con Cuerda o Hechizo Levitar (Lógica pendiente)." }
+ ],
+ "6": [
+ { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "carta_tesoro", "cantidad": 1, "mensaje": "¡Era una trampa falsa! Encuentras un tesoro oculto." },
+ {
+ "tipo_accion": "PRUEBA", "tipo_prueba": "1D6", "tabla": {
+ "1-3": [{ "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }]
+ }
+ }
+ ]
+ }
+ }
+ ]
},
{
+ "id": "evt_rastrillo",
"titulo": "RASTRILLO",
"tipo": "Evento",
"codigo_tipo": "E",
- "descripcion": "Un rastrillo baja al entrar todos los Aventureros, bloqueando la salida de escape.",
- "reglas_especiales": [
- "Solo podrán regresar por ese camino si tienen la llave.",
- "Colocar marcador de rastrillo en la puerta de entrada.",
- "Roba otra Carta de Evento inmediatamente."
- ],
- "cita_fuente": "[cite: 25, 26, 27]"
- },
- {
- "titulo": "ESCORPIONES",
- "tipo": "Evento/Monstruo",
- "codigo_tipo": "E",
- "descripcion": "Un enjambre de 12 escorpiones pequeños ataca a un Aventurero al azar.",
- "mecanica": {
- "cantidad_inicial": 12,
- "ataque_jugador": "1D6 + Fuerza = número de escorpiones eliminados.",
- "ataque_enemigo": "Cada escorpión restante inflige 1 Herida (sin modificadores).",
- "recompensa": "5 monedas de oro por escorpión muerto."
- },
- "notas": "El enjambre se aleja tras el ataque y la carta se descarta.",
- "cita_fuente": "[cite: 28, 29, 30, 31, 32, 33, 34]"
+ "descripcion": "Un rastrillo baja bloqueando la salida.",
+ "cita_fuente": "[cite: 25, 26, 27]",
+ "acciones": [
+ {
+ "tipo_accion": "ENTORNO",
+ "subtipo": "bloquear_salida_entrada"
+ },
+ {
+ "tipo_accion": "ENTORNO",
+ "subtipo": "colocar_marcador",
+ "marcador": "rastrillo",
+ "posicion": "entrada"
+ },
+ {
+ "tipo_accion": "TRIGGER_EVENTO",
+ "cantidad": 1
+ }
+ ]
},
+ //{
+ // "id": "evt_escorpiones",
+ // "titulo": "ESCORPIONES",
+ // "tipo": "Evento/Monstruo",
+ // "codigo_tipo": "E",
+ // "descripcion": "Un enjambre de escorpiones ataca.",
+ // "cita_fuente": "[cite: 28, 29, 30, 31, 32, 33, 34]",
+ // "acciones": [
+ // {
+ // "tipo_accion": "SELECCION",
+ // "modo": "azar",
+ // "guardar_como": "victima",
+ // "mensaje": "¡Los escorpiones eligen una presa!"
+ // },
+ // {
+ // "tipo_accion": "MINIJUEGO",
+ // "id_logica": "enjambre_escorpiones", // Lógica custom para calcular daño vs fuerza
+ // "cantidad": 12,
+ // "objetivo": "victima"
+ // }
+ // ]
+ //},
{
+ "id": "mon_minotauro",
"titulo": "MINOTAURO",
"tipo": "Monstruo",
"codigo_tipo": "M",
- "estadisticas": {
- "heridas": 15,
- "movimiento": 6,
- "habilidad_armas": 4,
- "fuerza": 4,
- "resistencia": 4,
- "ataques": 2
- },
- "reglas_especiales": [
- "Causa Miedo.",
- "Si impacta a un Aventurero, inflige 2D6 + 4 Heridas.",
- "Roba otra Carta de Evento antes de luchar (si salen adversarios, combaten a la vez)."
- ],
- "valor_oro": 440
+ "descripcion": "Un poderoso Minotauro bloquea el paso.",
+ "acciones": [
+ {
+ "tipo_accion": "SPAWN",
+ "id_monstruo": "minotaur", // Debe coincidir con MONSTER_DEFINITIONS key o similar
+ "nombre_fallback": "Minotauro", // Por si no existe id
+ "cantidad": "1",
+ "stats": { // Opcional, si queremos sobreescribir o definir aquí
+ "wounds": 15, "move": 6, "ws": 4, "str": 4, "toughness": 4, "attacks": 2, "gold": 440
+ }
+ },
+ {
+ "tipo_accion": "TRIGGER_EVENTO",
+ "cantidad": 1
+ }
+ ]
},
{
+ "id": "mon_chaosWarrior",
+ "titulo": "GUERRERO DE CAOS",
+ "tipo": "Monstruo",
+ "codigo_tipo": "M",
+ "descripcion": "Un guerrero de caos bloquea el paso.",
+ "acciones": [
+ {
+ "tipo_accion": "SPAWN",
+ "id_monstruo": "chaos_warrior", // Debe coincidir con MONSTER_DEFINITIONS key o similar
+ "nombre_fallback": "Guerrero de Caos", // Por si no existe id
+ "cantidad": "1D6-2",
+ "stats": { // Opcional, si queremos sobreescribir o definir aquí
+ "wounds": 10, "move": 4, "ws": 4, "str": 4, "toughness": 4, "attacks": 2, "gold": 240
+ }
+ },
+ {
+ "tipo_accion": "TRIGGER_EVENTO",
+ "cantidad": 1
+ }
+ ]
+ },
+ {
+ "id": "mon_aranas",
"titulo": "2D6 ARAÑAS GIGANTES",
"tipo": "Monstruo",
"codigo_tipo": "M",
- "estadisticas": {
- "heridas": 1,
- "movimiento": 6,
- "habilidad_armas": 2,
- "fuerza": "Especial",
- "resistencia": 2,
- "ataques": 1
- },
- "reglas_especiales": [
- "Ataque Telaraña: Si el objetivo está atrapado, la picadura inflige 1D3 Heridas automáticas.",
- "Si no está atrapado, la araña intenta impactar. Si lo logra, el Aventurero queda atrapado y no puede actuar hasta liberarse."
+ "descripcion": "Descienden del techo...",
+ "acciones": [
+ {
+ "tipo_accion": "SPAWN",
+ "id_monstruo": "giant_spider",
+ "nombre_fallback": "Araña Gigante",
+ "cantidad": "2D6",
+ "stats": {
+ "wounds": 1, "move": 6, "ws": 2, "str": 3, "toughness": 2, "attacks": 1
+ },
+ "reglas_especiales": ["telarana"]
+ }
]
}
];
diff --git a/src/engine/data/Events.js b/src/engine/data/Events.js
index 55c3224..59a7531 100644
--- a/src/engine/data/Events.js
+++ b/src/engine/data/Events.js
@@ -6,19 +6,8 @@ export const EVENT_TYPES = {
};
export const createEventDeck = () => {
- // Convert raw JSON data to engine-compatible card objects
- const deck = EVENT_CARDS_DATA.map(data => {
- const isMonster = data.tipo.includes('Monstruo');
-
- return {
- id: `evt_${Math.random().toString(36).substr(2, 9)}`,
- type: isMonster ? EVENT_TYPES.MONSTER : EVENT_TYPES.EVENT,
- name: data.titulo,
- description: data.descripcion || data.titulo,
- data: data // Keep full raw data for specific logic (stats, tables)
- };
- });
-
+ // Return a deep copy of the definition data to avoid mutation issues
+ const deck = EVENT_CARDS_DATA.map(card => JSON.parse(JSON.stringify(card)));
return shuffleDeck(deck);
};
@@ -27,5 +16,16 @@ const shuffleDeck = (deck) => {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
+
+ // DEBUG: Move Derrumbamiento to TOP
+ /*
+ const cardIdx = deck.findIndex(c => c.id === 'evt_derrumbamiento');
+ if (cardIdx !== -1) {
+ const card = deck.splice(cardIdx, 1)[0];
+ deck.unshift(card);
+ console.log("DEBUG: Forced DERRUMBAMIENTO to top of deck.");
+ }
+ */
+
return deck;
};
diff --git a/src/engine/events/EventInterpreter.js b/src/engine/events/EventInterpreter.js
new file mode 100644
index 0000000..cb811bc
--- /dev/null
+++ b/src/engine/events/EventInterpreter.js
@@ -0,0 +1,380 @@
+/**
+ * EventInterpreter.js
+ *
+ * Takes high-level Action Instructions from Event Cards (JSON)
+ * and executes them using the Game Engine's low-level systems.
+ */
+
+export class EventInterpreter {
+ constructor(gameEngine) {
+ this.game = gameEngine;
+ this.queue = [];
+ this.isProcessing = false;
+ this.currentContext = {}; // Store temporary variables (e.g. "victim")
+ }
+
+ /**
+ * Entry point to execute a card's actions
+ * @param {Object} card The event card data
+ */
+ async processEvent(card, onComplete = null) {
+ if (!card.acciones || !Array.isArray(card.acciones)) {
+ console.warn("[EventInterpreter] Card has no actions:", card);
+ if (onComplete) onComplete();
+ return;
+ }
+
+ console.log(`[EventInterpreter] Processing ${card.titulo}`);
+
+ // Log Entry (System Trace)
+ if (this.game.onShowMessage) {
+ this.game.onShowMessage("Evento de Poder", `${card.titulo}`);
+ }
+
+ // STEP 1: Show the Card to the User
+ await this.showEventCard(card);
+
+ // Load actions into queue
+ this.queue = [...card.acciones];
+ this.processQueue(onComplete);
+ }
+
+ // Helper wrapper for async UI
+ async showEventCard(card) {
+ return new Promise(resolve => {
+ if (this.game.onShowEvent) {
+ this.game.onShowEvent(card, resolve);
+ } else {
+ console.warn("[EventInterpreter] onShowEvent not bound, auto-resolving");
+ resolve();
+ }
+ });
+ }
+
+ async showModal(title, text) {
+ return new Promise(resolve => {
+ // Use GameEngine's generic message system IF it supports callback-based waiting
+ // Currently onShowMessage is usually non-blocking temporary.
+ // We need a blocking Modal.
+ // Let's assume onShowEvent can be reused or we use a specific one.
+ // For now, let's reuse onShowEvent with a generic structure or define a new callback.
+ // Actually, we can just construct a mini-card object for onShowEvent
+ const miniCard = {
+ titulo: title,
+ texto: text,
+ tipo: 'Resolución'
+ };
+ if (this.game.onShowEvent) {
+ this.game.onShowEvent(miniCard, resolve);
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ async processQueue(onComplete = null) {
+ if (this.isProcessing) return;
+ if (this.queue.length === 0) {
+ console.log("[EventInterpreter] All actions completed.");
+
+ // Event Log Summary
+ if (this.game.onShowMessage) {
+ // We use onShowMessage as Log (via main.js filtering) or specialized logger
+ // Actually, let's inject a "System Log" call if possible.
+ // Main.js redirects "Efecto" titles to log, but let's be explicit.
+ // Using generic onShowMessage with a distinct title for logging.
+ // Or better, let's call a log method if exposed on game.
+
+ // Let's assume onShowMessage("LOG", ...) goes to log if we tweak main.js,
+ // OR we just use a title that main.js recognizes as loggable.
+ // But wait, the USER wants a log of the CARD itself.
+ }
+
+ if (onComplete) {
+ onComplete();
+ } else {
+ this.game.turnManager.resumeFromEvent(); // Resume turn flow
+ }
+ return;
+ }
+
+ this.isProcessing = true;
+ const action = this.queue.shift();
+
+ try {
+ await this.executeAction(action);
+ } catch (error) {
+ console.error("[EventInterpreter] Error executing action:", error);
+ }
+
+ this.isProcessing = false;
+
+ // Next step
+ if (this.queue.length > 0) {
+ setTimeout(() => this.processQueue(onComplete), 500);
+ } else {
+ this.processQueue(onComplete); // Finish up
+ }
+ }
+
+ async executeAction(action) {
+ switch (action.tipo_accion) {
+ case 'MENSAJE':
+ await this.showModal("Evento", action.texto || action.mensaje);
+ break;
+
+ case 'SELECCION':
+ await this.handleSelection(action);
+ break;
+
+ case 'PRUEBA':
+ await this.handleTest(action);
+ break;
+
+ case 'EFECTO':
+ await this.handleEffect(action);
+ break;
+
+ case 'SPAWN':
+ await this.handleSpawn(action);
+ break;
+
+ case 'ENTORNO':
+ await this.handleEnvironment(action);
+ break;
+
+ case 'ESTADO_GLOBAL':
+ if (!this.game.state) this.game.state = {};
+ this.game.state[action.clave] = action.valor;
+ console.log(`[Event] Global State Update: ${action.clave} = ${action.valor}`);
+ break;
+
+ case 'TRIGGER_EVENTO':
+ console.log("TODO: Chain Event Draw");
+ break;
+
+ default:
+ console.warn("Unknown Action:", action.tipo_accion);
+ }
+ }
+
+ async handleSelection(action) {
+ let targets = [];
+
+ if (action.modo === 'todos') {
+ targets = [...this.game.heroes];
+ } else if (action.modo === 'azar') {
+ const idx = Math.floor(Math.random() * this.game.heroes.length);
+ targets = [this.game.heroes[idx]];
+ } else if (action.modo === 'tirada_baja') {
+ // Roll D6 for each, pick lowest
+ // For now, SIMULATED logic without UI prompts
+ let lowest = 99;
+ let candidates = [];
+
+ this.game.heroes.forEach(h => {
+ const roll = Math.floor(Math.random() * 6) + 1;
+ // console.log(`${h.name} rolled ${roll}`);
+ if (roll < lowest) {
+ lowest = roll;
+ candidates = [h];
+ } else if (roll === lowest) {
+ candidates.push(h); // Tie
+ }
+ });
+ // If tie, pick random from candidates
+ targets = [candidates[Math.floor(Math.random() * candidates.length)]];
+ }
+
+ // Store result
+ if (action.guardar_como) {
+ this.currentContext[action.guardar_como] = targets[0]; // Simplification for Single Target
+ }
+
+ if (action.mensaje) {
+ const names = targets.map(t => t.name).join(", ");
+ // BLOCKING MODAL
+ await this.showModal("Selección", `${action.mensaje}
Objetivo: ${names}`);
+ }
+ }
+
+ async handleTest(action) {
+ // Resolve target
+ const target = action.origen ? this.currentContext[action.origen] : null;
+ if (!target && action.origen) {
+ console.error("Test target not found in context:", action.origen);
+ return;
+ }
+
+ let roll = 0;
+ // Parse dice string "1D6", "2D6"
+ if (action.tipo_prueba.includes('D6')) {
+ const count = parseInt(action.tipo_prueba) || 1;
+ for (let i = 0; i < count; i++) roll += Math.floor(Math.random() * 6) + 1;
+ }
+
+ // BLOCKING MODAL: Show the roll result
+ const targetName = target ? target.name : "Nadie";
+ await this.showModal("Prueba", `${targetName} realiza una prueba de ${action.tipo_prueba}...
Resultado: ${roll}`);
+
+ // Check Table
+ if (action.tabla) {
+ let resultActions = null;
+
+ // Iterate keys "1", "2-3", "4-6"
+ for (const key of Object.keys(action.tabla)) {
+ if (key.includes('-')) {
+ const [min, max] = key.split('-').map(Number);
+ if (roll >= min && roll <= max) resultActions = action.tabla[key];
+ } else {
+ if (roll === Number(key)) resultActions = action.tabla[key];
+ }
+ }
+
+ if (resultActions) {
+ // Prepend these new actions to the FRONT of the queue to execute immediately
+ console.log(`Test Roll: ${roll} -> Result found`, resultActions);
+ this.queue.unshift(...resultActions);
+ }
+ }
+ }
+
+ async handleEffect(action) {
+ // Resolve Target
+ let targets = [];
+ if (action.objetivo) {
+ if (this.currentContext[action.objetivo]) targets = [this.currentContext[action.objetivo]];
+ } else {
+ // Implicit context? Default to all?
+ // Better to be explicit in JSON mostly
+ }
+
+ // Parse Count
+ let amount = 0;
+ let isDice = false;
+ if (typeof action.cantidad === 'number') amount = action.cantidad;
+ else if (typeof action.cantidad === 'string' && action.cantidad.includes('D6')) {
+ isDice = true;
+ const parts = action.cantidad.split('*');
+ const dicePart = parts[0];
+ const multiplier = parts[1] ? parseInt(parts[1]) : 1;
+ let roll = 0;
+ const numDice = parseInt(dicePart) || 1;
+ for (let i = 0; i < numDice; i++) roll += Math.floor(Math.random() * 6) + 1;
+ amount = roll * multiplier;
+ }
+
+ let msg = action.mensaje || "Efecto aplicado";
+ if (isDice) msg += ` (Dado: ${amount})`;
+
+ if (action.tipo === 'daño') {
+ targets.forEach(h => {
+ // Apply Damage Logic
+ // this.game.combatSystem.applyDamage(h, amount)...
+ console.log(`Applying ${amount} Damage to ${h.name}`);
+ h.stats.wounds -= amount; // Simple direct manipulation for now
+ if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
+ // Visual feedback?
+ });
+ await this.showModal("Daño", `${msg}
${amount} Heridas a ${targets.map(t => t.name).join(", ")}`);
+ } else if (action.tipo === 'oro') {
+ targets.forEach(h => {
+ console.log(`Giving ${amount} Gold to ${h.name}`);
+ h.gold = (h.gold || 0) + amount;
+ if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
+ });
+ await this.showModal("Oro", `${msg}
Ganan ${amount} de Oro.`);
+ }
+ }
+
+ async handleSpawn(action) {
+ // Parse Amount
+ let count = 1;
+ if (typeof action.cantidad === 'string' && action.cantidad.includes('D6')) {
+ const numDice = parseInt(action.cantidad) || 1;
+ for (let i = 0; i < numDice; i++) count += Math.floor(Math.random() * 6) + 1;
+ } else {
+ count = parseInt(action.cantidad) || 1;
+ }
+
+ // Resolve ID/Stats
+ // Map to Monster Definitions?
+ // For now, construct dynamic definition
+ const def = {
+ name: action.nombre_fallback || "Enemigo",
+ // Texture mapping based on ID
+ portrait: this.getTexturePathForMonster(action.id_monstruo),
+ stats: action.stats || { wounds: 1, move: 4, ws: 3, str: 3, toughness: 3 }
+ };
+
+ // Check if there is an Exploration Context (TileID)
+ let contextTileId = null;
+ if (this.game.currentEventContext && this.game.currentEventContext.tileId) {
+ contextTileId = this.game.currentEventContext.tileId;
+ }
+
+ const spots = this.game.findSpawnPoints(count, contextTileId);
+
+ spots.forEach(spot => {
+ // Monsters spawned via Event Card in Exploration phase should NOT skip turn?
+ // "Monsters placed... move and attack as described in Power Phase" -> Wait.
+ // Power Phase monsters ATTACK immediately. Exploration monsters DO NOT?
+ // Rules say: "If Monsters, place them... see Power Phase for details"
+ // Actually, "In the Monster Phase... determine how many... place them... Keep the card handy... information needed later"
+ // Usually ambushes act immediately, but room dwellers act in the Monster Phase.
+ // Since we are triggering this AT THE START of Monster Phase, they will act in this phase naturally ONLY IF we don't skip turn.
+
+ // However, GameEngine monster AI loop iterates all monsters.
+ // Newly added monster might be picked up immediately if added to the array?
+ // Yes, standard is they act.
+ this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: false });
+ });
+
+ // Clear context after use to prevent leakage?
+ // Or keep it for the duration of the event chain?
+ // Safest to keep until event ends, GameEngine clears it?
+ // We leave it, GameEngine overrides or clears it when needed.
+
+ await this.showModal("¡Emboscada!", `Aparecen ${count} ${def.name}!
¡Prepárate para luchar!`);
+ }
+
+ async handleEnvironment(action) {
+ if (action.subtipo === 'bloquear_salidas_excepto_entrada') {
+ console.log("[Event] Collapsing Exits...");
+ if (this.game.collapseExits) {
+ const collapsedCount = this.game.collapseExits();
+ await this.showModal("¡Derrumbe!", `¡El techo se viene abajo!
${collapsedCount} salidas han quedado bloqueadas por escombros.`);
+ } else {
+ console.error("GameEngine.collapseExits not implemented!");
+ await this.showModal("Error", "GameEngine.collapseExits no implementado.");
+ }
+ } else if (action.subtipo === 'colocar_marcador') {
+ console.log(`[Event] Placing Marker: ${action.marcador}`);
+ if (this.game.placeEventMarker) {
+ // Determine position based on context (e.g. center of current room)
+ // For now, pass null so GameEngine decides based on player location
+ this.game.placeEventMarker(action.marcador);
+ }
+ } else {
+ console.log("Environment Action Unknown:", action.subtipo);
+ }
+ }
+
+ getTexturePathForMonster(id) {
+ // Map JSON IDs to Files
+ // id_monstruo: "minotaur" -> "dungeon1/standees/enemies/minotaur.png" (if exists) or fallback
+ // We checked file list earlier:
+ // bat.png, goblin.png, orc.png, skaven.png, chaosWarrior.png, Lordwarlock.png, rat.png, spiderGiant.png
+
+ const map = {
+ "giant_spider": "spiderGiant.png",
+ "orc": "orc.png",
+ "goblin": "goblin.png",
+ "skaven": "skaven.png",
+ "minotaur": "minotaur.png"
+ };
+
+ const filename = map[id] || "orc.png";
+ return `assets/images/dungeon1/standees/enemies/${filename}`;
+ }
+}
diff --git a/src/engine/game/CombatMechanics.js b/src/engine/game/CombatMechanics.js
index 9456d49..70c63b1 100644
--- a/src/engine/game/CombatMechanics.js
+++ b/src/engine/game/CombatMechanics.js
@@ -51,7 +51,7 @@ export class CombatMechanics {
if (log.hitRoll < log.targetToHit) {
log.hitSuccess = false;
- log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
+ log.message = `💨 ${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
return log;
}
@@ -70,11 +70,10 @@ export class CombatMechanics {
damageSum += r;
}
- log.damageRoll = damageSum; // Just sum for simple log, or we could array it
+ log.damageRoll = damageSum;
log.damageTotal = damageSum + attStr;
// 4. Calculate Wounds
- // Wounds = (Dice + Str) - Toughness
let wounds = log.damageTotal - defTough;
if (wounds < 0) wounds = 0;
@@ -82,9 +81,9 @@ export class CombatMechanics {
// 5. Build Message
if (wounds > 0) {
- log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
+ 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})`;
+ log.message = `🛡️ ${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
}
// 6. Apply Damage to Defender State
@@ -92,9 +91,9 @@ export class CombatMechanics {
if (defender.isDead) {
log.defenderDied = true;
- log.message += ` ¡${defender.name} ha muerto!`;
+ log.message += ` 💀 ¡${defender.name} ha muerto!`;
} else if (defender.isUnconscious) {
- log.message += ` ¡${defender.name} cae inconsciente!`;
+ log.message += ` 💀 ¡${defender.name} cae inconsciente!`;
}
return log;
@@ -123,20 +122,19 @@ export class CombatMechanics {
if (hitRoll === 1) {
log.hitSuccess = false;
- log.message = `${attacker.name} dispara y falla (1 es fallo automático)`;
+ log.message = `💨 ${attacker.name} dispara y falla (1 es fallo automático)`;
return log;
}
if (hitRoll < toHitTarget) {
log.hitSuccess = false;
- log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
+ log.message = `💨 ${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
return log;
}
log.hitSuccess = true;
// 2. Roll Damage
- // Elf Bow Strength = 3
const weaponStrength = 3;
const damageRoll = this.rollD6();
const damageTotal = weaponStrength + damageRoll;
@@ -151,9 +149,9 @@ export class CombatMechanics {
// 4. Build Message
if (wounds > 0) {
- log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
+ log.message = `⚔️ ${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
} else {
- log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
+ log.message = `🛡️ ${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
}
// 5. Apply Damage
@@ -161,7 +159,7 @@ export class CombatMechanics {
if (defender.isDead) {
log.defenderDied = true;
- log.message += ` ¡${defender.name} ha muerto!`;
+ log.message += ` 💀 ¡${defender.name} ha muerto!`;
}
return log;
diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js
index 93fcdee..7aa63e9 100644
--- a/src/engine/game/GameEngine.js
+++ b/src/engine/game/GameEngine.js
@@ -4,6 +4,7 @@ import { MonsterAI } from './MonsterAI.js';
import { MagicSystem } from './MagicSystem.js';
import { CombatSystem } from './CombatSystem.js';
import { CombatMechanics } from './CombatMechanics.js';
+import { EventInterpreter } from '../events/EventInterpreter.js'; // Import
import { HERO_DEFINITIONS } from '../data/Heroes.js';
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
@@ -18,6 +19,7 @@ export class GameEngine {
this.ai = new MonsterAI(this); // Init AI
this.magicSystem = new MagicSystem(this); // Init Magic
this.combatSystem = new CombatSystem(this); // Init Combat
+ this.events = new EventInterpreter(this); // Init Events Engine
this.player = null;
this.selectedEntity = null;
this.isRunning = false;
@@ -36,18 +38,17 @@ export class GameEngine {
this.onEntityDeath = null; // New: When entity dies
this.onFloatingText = null; // New: For overhead text feedback
this.onPathChange = null;
+ this.onShowEvent = null; // New: For styled event cards
}
startMission(missionConfig) {
this.dungeon.startDungeon(missionConfig);
+ // Create Party (4 Heroes)
// Create Party (4 Heroes)
this.createParty();
- this.isRunning = true;
- this.turnManager.startGame();
-
// Listen for Phase Changes to Reset Moves
this.turnManager.on('phase_changed', (phase) => {
if (phase === 'hero' || phase === 'exploration') {
@@ -61,6 +62,7 @@ export class GameEngine {
window.RENDERER.clearAllActiveRings();
}
this.deselectEntity();
+ this.ai.executeTurn();
}
});
@@ -72,12 +74,20 @@ export class GameEngine {
// 6. Listen for Power Phase Events
this.turnManager.on('POWER_RESULT', (data) => {
if (data.eventTriggered) {
+ console.log("[GameEngine] Power Event Triggered! Waiting to handle...");
+ // Determine if we need to draw a card or if it's a specific message
setTimeout(() => this.handlePowerEvent(), 1500);
}
});
+
+
// Initial Light Update
setTimeout(() => this.updateLighting(), 500);
+
+ // Start Game Loop (Now that listeners are ready)
+ this.isRunning = true;
+ this.turnManager.startGame();
}
resetHeroMoves() {
@@ -655,6 +665,18 @@ export class GameEngine {
attemptBreakAway(hero) {
if (!hero || hero.hasEscapedPin) return { success: false, roll: 0 };
+ // RULE: If Derrumbamiento, escape is free
+ if (this.state && this.state.collapsingRoom && this.state.collapsingRoom.tileId) {
+ // Check if hero is in the collapsing room
+ const key = `${Math.floor(hero.x)},${Math.floor(hero.y)}`;
+ const tid = this.dungeon.grid.occupiedCells.get(key);
+ if (tid === this.state.collapsingRoom.tileId) {
+ console.log("[GameEngine] Free BreakAway due to Collapsing Room!");
+ hero.hasEscapedPin = true;
+ return { success: true, roll: "AUTO", target: 0 };
+ }
+ }
+
const roll = Math.floor(Math.random() * 6) + 1;
const target = hero.stats.pin_target || 6;
@@ -772,35 +794,31 @@ export class GameEngine {
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 });
- }
+ triggeredEvents = true; // Stop movement forces end of hero action
+ entity.currentMoves = 0;
+
+ // Send PARTIAL path to renderer (from 0 to current step i+1)
+ if (this.onEntityMove) {
+ this.onEntityMove(entity, fullPath.slice(0, i + 1));
}
- // Call Event Logic
- const eventResult = this.onRoomRevealed(newCells);
+ if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Explorando...", 2000);
- // Always stop for Rooms
- if (eventResult) {
- console.log("Movement stopped by Room Entry!");
- triggeredEvents = true;
+ // IMMEDIATE EVENT RESOLUTION
+ this.handlePowerEvent({ tileId: tileId }, () => {
+ console.log("[GameEngine] Room Event Resolved.");
- // Notify UI via callback
- if (this.onEventTriggered) {
- this.onEventTriggered(eventResult);
+ // Check for Monsters
+ const hasMonsters = this.monsters.some(m => !m.isDead);
+ if (hasMonsters) {
+ console.log("[GameEngine] Monsters Spawned! Ending Hero Phase.");
+ this.turnManager.setPhase('monster');
+ } else {
+ console.log("[GameEngine] No Monsters. Staying in Hero Phase.");
}
+ });
- // 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
- }
+ break; // Stop loop
} else {
console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
}
@@ -872,14 +890,30 @@ export class GameEngine {
// Minimal update loop
}
- findSpawnPoints(count) {
+ findSpawnPoints(count, tileId = null) {
// Collect all currently available cells (occupiedCells maps "x,y" => tileId)
- // At the start of the game, this typically contains only the cells of the starting room.
+ // If tileId is provided, filter ONLY cells belonging to that tile.
const candidates = [];
- for (const key of this.dungeon.grid.occupiedCells.keys()) {
+ for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
+ if (tileId && tid !== tileId) continue;
+
const [x, y] = key.split(',').map(Number);
- candidates.push({ x, y });
+
+ // Check Collision: Do not spawn on Heroes or existing Monsters
+ const isHero = this.heroes.some(h => h.x === x && h.y === y);
+ const isMonster = this.monsters.some(m => m.x === x && m.y === y && !m.isDead);
+
+ if (!isHero && !isMonster) {
+ candidates.push({ x, y });
+ }
+ }
+
+ // If localized spawn fails (full room), maybe allow spill over to neighbors? (Rules say: "adjacent board sections")
+ if (tileId && candidates.length < count) {
+ console.warn(`[GameEngine] Room ${tileId} full? Searching neighbors...`);
+ // NOTE: Neighbor search logic is complex, skipping for MVP.
+ // Fallback to global search if desperate?
}
// 2. Shuffle candidates (Fisher-Yates) to ensure random but valid placement
@@ -888,10 +922,22 @@ export class GameEngine {
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
- // 3. Return requested amount
+ // 3. Return requested amount (or less if not enough space)
+ if (candidates.length < count) {
+ console.warn(`[GameEngine] Not enough space to spawn ${count} monsters. Only ${candidates.length} spawn.`);
+ if (this.onShowMessage) {
+ // this.onShowMessage("Espacio Insuficiente", `Solo caben ${candidates.length} de ${count} monstruos.`);
+ }
+ return candidates;
+ }
+
return candidates.slice(0, count);
}
+
+
+
+
onRoomRevealed(cells) {
console.log("[GameEngine] Room Revealed!");
@@ -1287,71 +1333,221 @@ export class GameEngine {
return { clear: !blocked, path, blocker };
}
- handlePowerEvent() {
+
+ handlePowerEvent(context = null, onComplete = null) {
+ console.log("[GameEngine] Handling Power Event...");
+
+ // Inject Context
+ if (context) {
+ this.currentEventContext = context;
+ }
+
if (!this.eventDeck || this.eventDeck.length === 0) {
+ console.log("[GameEngine] Reshuffling Deck...");
this.eventDeck = createEventDeck();
}
- const card = this.eventDeck.shift();
+ let card = this.eventDeck.shift();
- console.log(`[Event] Drawn: ${card.name} (${card.type})`);
+ // RULE: Ignore Derrumbamiento in Start Room
+ // Check if we are in turn 1 of setup or if heroes are in the first tile (0,0)
+ // Or simpler: Check if card is 'evt_derrumbamiento' and currentTileId is 'tile_0'
- if (this.onShowMessage) {
- // Use specific prefix for Log Routing (if implemented) or just generic
- this.onShowMessage(`Evento: ${card.name}`, card.description);
+ if (card.id === 'evt_derrumbamiento') {
+ const leader = this.heroes[0];
+ const currentTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`);
+
+ // Assuming 'tile_0' is the start room ID.
+ // Also checking turn number might be safe: turnManager.currentTurn
+ if (currentTileId === 'tile_0' || this.turnManager.currentTurn <= 1) {
+ console.log("[GameEngine] Derrumbamiento drawn in Start Room. IGNORING and Redrawing.");
+ // Discard and Draw again
+ // (Maybe put back in deck? Rules say 'ignore and draw another immediately', usually means discard this one)
+ if (this.eventDeck.length === 0) this.eventDeck = createEventDeck();
+ card = this.eventDeck.shift();
+ console.log(`[GameEngine] Redrawn Card: ${card.name}`);
+ }
}
- if (card.type === EVENT_TYPES.MONSTER) {
- let count = 1;
- const title = card.name.toUpperCase();
+ console.log(`[GameEngine] Drawn Card: ${card.name}`, card);
- if (title.includes('2D6')) {
- count = Math.floor(Math.random() * 6) + 1 + Math.floor(Math.random() * 6) + 1;
- } else if (title.includes('1D6')) {
- count = Math.floor(Math.random() * 6) + 1;
- } else if (card.data.mecanica && card.data.mecanica.cantidad_inicial) {
- count = card.data.mecanica.cantidad_inicial;
+ // Delegate execution to the modular interpreter
+ if (this.events) {
+ this.events.processEvent(card, () => {
+ this.currentEventContext = null;
+ if (onComplete) onComplete();
+ else this.turnManager.resumeFromEvent();
+ });
+ } else {
+ console.error("[GameEngine] EventInterpreter not initialized!");
+ this.turnManager.resumeFromEvent();
+ }
+ }
+
+ handleEndTurn() {
+ console.log("[GameEngine] Handling End of Turn Effects...");
+
+ // Check Collapsing Room State
+ if (this.state && this.state.collapsingRoom) {
+ if (typeof this.state.collapsingRoom.turnsLeft === 'number') {
+ this.state.collapsingRoom.turnsLeft--;
+ console.log(`[GameEngine] Collapsing Room Timer: ${this.state.collapsingRoom.turnsLeft}`);
+
+ if (this.state.collapsingRoom.turnsLeft > 0) {
+ if (this.onShowMessage) this.onShowMessage("¡PELIGRO!", `El techo cruje... ¡Queda ${this.state.collapsingRoom.turnsLeft} turno para salir!`, 4000);
+ } else {
+ // TIME'S UP - KILL EVERYONE IN ROOM
+ this.killEntitiesInCollapsingRoom(this.state.collapsingRoom.tileId);
+ }
}
+ }
- // Map stats
- const rawStats = card.data.estadisticas || {};
- const mappedStats = {
- move: rawStats.movimiento || 4,
- ws: rawStats.habilidad_armas || 3,
- bs: 0,
- str: rawStats.fuerza === 'Especial' ? 3 : (rawStats.fuerza === 4 ? 4 : 3), // Simplification
- toughness: rawStats.resistencia || 3,
- wounds: rawStats.heridas || 1,
- attacks: rawStats.ataques || 1,
- gold: card.data.valor_oro || 0
- };
+ if (!this.heroes) return;
- // Fix Strength mapping if int
- if (typeof rawStats.fuerza === 'number') mappedStats.str = rawStats.fuerza;
+ this.heroes.forEach(hero => {
+ if (hero.buffs && hero.buffs.length > 0) {
+ // Decrement duration
+ hero.buffs.forEach(buff => {
+ // ... (existing buff logic if any)
+ });
+ }
+ });
+ }
- // Map Portrait
- let portraitStr = 'assets/images/monsters/orc_portrait.png';
- if (title.includes('MINOTAURO')) portraitStr = 'assets/images/monsters/minotaur_portrait.png'; // If we had it
- if (title.includes('ARAÑAS')) portraitStr = 'assets/images/monsters/spider_portrait.png';
+ killEntitiesInCollapsingRoom(tileId) {
+ if (this.onShowMessage) this.onShowMessage("¡DERRUMBE TOTAL!", "La sala se ha venido abajo. Todo lo que había dentro ha sido aplastado.", 5000);
- const def = {
- name: card.name,
- portrait: portraitStr,
- stats: mappedStats
- };
+ // Find entities in this tile
+ const entitiesToKill = [];
- const spots = this.findSpawnPoints(count);
- spots.forEach(spot => {
- this.spawnMonster(def, spot.x, spot.y, { skipTurn: false });
+ // Heroes
+ this.heroes.forEach(h => {
+ const key = `${Math.floor(h.x)},${Math.floor(h.y)}`;
+ const tid = this.dungeon.grid.occupiedCells.get(key);
+ if (tid === tileId) entitiesToKill.push(h);
+ });
+
+ // Monsters
+ this.monsters.forEach(m => {
+ if (m.isDead) return;
+ const key = `${Math.floor(m.x)},${Math.floor(m.y)}`;
+ const tid = this.dungeon.grid.occupiedCells.get(key);
+ if (tid === tileId) entitiesToKill.push(m);
+ });
+
+ entitiesToKill.forEach(e => {
+ console.log(`[GameEngine] Crushed by Rockfall: ${e.name}`);
+ // Instant Kill logic
+ if (e.currentWounds) e.currentWounds = 0;
+ e.isDead = true;
+ if (this.onEntityDeath) this.onEntityDeath(e.id);
+ });
+
+ // Clear state so it doesn't kill again (optional, room is gone)
+ this.state.collapsingRoom = null;
+ }
+ collapseExits() {
+ // Logic: Find the room the heroes are in, identify its UNOPENED exits (which are in availableExits), and remove them.
+
+ // 1. Find Current Room ID based on Leader
+ const leader = this.heroes[0]; // Assume leader is first
+ if (!leader) return 0;
+
+ const currentTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`);
+ if (!currentTileId) {
+ console.warn("CollapseExits: Leader not on a valid tile.");
+ return 0;
+ }
+
+ console.log(`[GameEngine] Collapse Triggered in ${currentTileId}`);
+
+ // Start Countdown State
+ if (!this.state) this.state = {};
+ this.state.collapsingRoom = {
+ tileId: currentTileId,
+ turnsLeft: 1 // Next monster phase ends turn -> Next power phase -> Next monster phase = Death
+ };
+
+ // 2. Scan Available Exits to see which align with this Room
+ // An exit is "part" of a room if it is adjacent to any cell of that room.
+ // Or simpler: The DungeonGenerator keeps track of exits. We scan them.
+
+ // We need to identify cells belonging to currentTileId first.
+ const roomCells = [];
+ for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
+ if (tid === currentTileId) {
+ const [x, y] = key.split(',').map(Number);
+ roomCells.push({ x, y });
+ }
+ }
+
+ const exitsToRemove = [];
+ const blockedLocations = new Set();
+
+ this.dungeon.availableExits.forEach((exit, index) => {
+ // Check adjacency to any room cell
+ const isAdjacent = roomCells.some(cell => {
+ const dx = Math.abs(cell.x - exit.x);
+ const dy = Math.abs(cell.y - exit.y);
+ return (dx + dy === 1);
});
- if (this.onEventTriggered) {
- this.onEventTriggered({ type: 'MONSTER_SPAWN', count: spots.length, message: card.description });
+ if (isAdjacent) {
+ exitsToRemove.push(index);
+
+ this.placeEventMarker("escombros", exit.x, exit.y);
+ blockedLocations.add(`${exit.x},${exit.y}`);
+
+ // Also block the door visually in Renderer
+ if (window.RENDERER) {
+ window.RENDERER.blockDoor({ x: exit.x, y: exit.y });
+ }
}
+ });
+
+ // 3. Remove them (iterate backwards to avoid index shuffle issues)
+ exitsToRemove.sort((a, b) => b - a);
+ exitsToRemove.forEach(idx => {
+ this.dungeon.availableExits.splice(idx, 1);
+ });
+
+ // 4. Calculate Logical "Exits" Count (Approximation)
+ let visualDoorCount = 0;
+ const processedLocs = new Set();
+
+ for (const loc of blockedLocations) {
+ if (processedLocs.has(loc)) continue;
+ visualDoorCount++;
+ processedLocs.add(loc);
+
+ const [x, y] = loc.split(',').map(Number);
+ const neighbors = [`${x + 1},${y}`, `${x - 1},${y}`, `${x},${y + 1}`, `${x},${y - 1}`];
+ neighbors.forEach(n => {
+ if (blockedLocations.has(n)) processedLocs.add(n);
+ });
}
- // Generic delay to resume
- setTimeout(() => {
- if (this.turnManager) this.turnManager.nextPhase();
- }, 3000);
+ console.log(`[GameEngine] Collapsed ${exitsToRemove.length} exit cells (${visualDoorCount} visual doors).`);
+ return visualDoorCount;
+ }
+
+ placeEventMarker(type, specificX = null, specificY = null) {
+ // Place a visual marker in the world
+ // If x,y not provided, place in center of heroes
+ let x = specificX;
+ let y = specificY;
+
+ if (x === null || y === null) {
+ // Center on leader
+ const leader = this.heroes[0];
+ x = leader.x;
+ y = leader.y;
+ }
+
+ console.log(`[GameEngine] Placing Marker '${type}' at ${x},${y}`);
+
+ // Delegate to Renderer
+ if (window.RENDERER) {
+ window.RENDERER.spawnProp(type, x, y);
+ }
}
}
diff --git a/src/engine/game/TurnManager.js b/src/engine/game/TurnManager.js
index e3b0652..bc41b08 100644
--- a/src/engine/game/TurnManager.js
+++ b/src/engine/game/TurnManager.js
@@ -55,19 +55,24 @@ export class TurnManager {
this.rollPowerDice();
}
+ resumeFromEvent() {
+ console.log("Resuming from Event...");
+ this.nextPhase();
+ }
+
rollPowerDice() {
const roll = Math.floor(Math.random() * 6) + 1;
+ // const roll = 1; // DEBUG: Force Event for testing
this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`);
let message = "El poder fluye...";
let eventTriggered = false;
+ // Placeholder for future Event Logic
if (roll === 1) {
- message = "¡EVENTO DE PODER! (1) (Bypass Temporal)";
- eventTriggered = false; // Bypass for now to prevent freeze
- // eventTriggered = true;
- // logic delegated to listeners
+ message = "¡EVENTO DE PODER! (1)";
+ eventTriggered = true;
}
this.emit('POWER_RESULT', { roll, message, eventTriggered });
diff --git a/src/main.js b/src/main.js
index 744aeb2..478c349 100644
--- a/src/main.js
+++ b/src/main.js
@@ -110,19 +110,18 @@ game.onCombatResult = (log) => {
const atkName = attacker ? attacker.name : '???';
const defName = defender ? defender.name : '???';
- let logMsg = `${atkName} ataca a ${defName}.
`;
+ // 1. Format Log Message
+ // Use the comprehensive message generated by CombatMechanics
+ let logMsg = log.message;
+
+ // Determine type for color
+ let type = 'combat-miss';
if (log.hitSuccess) {
- logMsg += `¡Impacto! (Dado: ${log.hitRoll}). `;
- if (log.woundsCaused > 0) {
- logMsg += `Causa ${log.woundsCaused} heridas.`;
- } else {
- logMsg += `Armadura absorbe el daño.`;
- }
- } else {
- logMsg += `Falla (Dado: ${log.hitRoll}).`;
+ type = log.woundsCaused > 0 ? 'combat-hit' : 'combat-miss'; // Or create 'combat-block' style
+ if (log.defenderDied) type = 'combat-kill';
}
- ui.addLog(logMsg, log.hitSuccess ? 'combat-hit' : 'combat-miss');
+ ui.addLog(logMsg, type);
// 2. Show Attack Roll on Attacker (Floating)
if (attacker) {
@@ -190,14 +189,26 @@ game.onRangedTarget = (targetMonster, losResult) => {
game.onShowMessage = (title, message, duration) => {
// Filter specific game flow messages to Log instead of popup
- if (title.startsWith('Turno de') || title.includes('Fase') || title.includes('Efecto')) {
- ui.addLog(`👉 ${title}: ${message}`, 'system');
+ if (title.startsWith('Turno de') || title.includes('Fase') || title.includes('Efecto') || title.includes('Evento')) {
+ let icon = '👉';
+ let type = 'system';
+
+ if (title.includes('Evento')) {
+ icon = '⚡';
+ type = 'event-log';
+ }
+
+ ui.addLog(`${icon} ${title}: ${message}`, type);
return;
}
// Default fallback for other messages (e.g. Warnings not covered by floating text)
ui.showTemporaryMessage(title, message, duration);
};
+game.onShowEvent = (cardData, callback) => {
+ ui.showEventCard(cardData, callback);
+};
+
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
renderer.onHeroFinishedMove = (x, y) => {
diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js
index b8bfa8c..25688cc 100644
--- a/src/view/GameRenderer.js
+++ b/src/view/GameRenderer.js
@@ -141,6 +141,13 @@ export class GameRenderer {
this.entityRenderer.triggerDeathAnimation(entityId);
}
+ spawnProp(type, x, y) {
+ // Delegate to DungeonRenderer as it handles static props
+ // Or if it's an effect, EffectsRenderer.
+ // For "escombros" marker, it's a static prop.
+ this.dungeonRenderer.spawnProp(type, x, y);
+ }
+
triggerVisualEffect(type, x, y) {
this.effectsRenderer.triggerVisualEffect(type, x, y);
}
diff --git a/src/view/UIManager.js b/src/view/UIManager.js
index 32e0147..c3d8d63 100644
--- a/src/view/UIManager.js
+++ b/src/view/UIManager.js
@@ -113,6 +113,7 @@ export class UIManager {
showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); }
showCombatLog(log) { this.feedback.addLogMessage(log.message, log.hitSuccess ? 'combat-hit' : 'combat-miss'); }
addLog(message, type) { this.feedback.addLogMessage(message, type); }
+ showEventCard(cardData, callback) { this.feedback.showEventCard(cardData, callback); }
showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); }
hideMonsterCard() { this.cards.hideMonsterCard(); }
}
diff --git a/src/view/render/DungeonRenderer.js b/src/view/render/DungeonRenderer.js
index 35973b4..44a6433 100644
--- a/src/view/render/DungeonRenderer.js
+++ b/src/view/render/DungeonRenderer.js
@@ -364,4 +364,28 @@ export class DungeonRenderer {
}
return false;
}
+
+ spawnProp(type, x, y) {
+ // Simple Prop System for Events
+ const textureMap = {
+ 'escombros': '/assets/images/dungeon1/props/debris.png', // Fallback needed?
+ 'piedras': '/assets/images/dungeon1/props/rocks.png'
+ };
+
+ // Fallback for missing assets: reuse known specific textures or generic
+ let path = textureMap[type] || '/assets/images/dungeon1/doors/door1_blocked.png'; // Use blocked door as generic debris for now if others missing
+
+ this.getTexture(path, (texture) => {
+ const mat = new THREE.SpriteMaterial({ map: texture });
+ const sprite = new THREE.Sprite(mat);
+
+ // Positioning
+ sprite.position.set(x, 0.5, -y);
+ sprite.scale.set(1, 1, 1);
+
+ this.dungeonGroup.add(sprite);
+
+ // Add simple logic to remove it later if needed? For now permanent.
+ });
+ }
}
diff --git a/src/view/ui/FeedbackUI.js b/src/view/ui/FeedbackUI.js
index 2b1571f..bb2dccc 100644
--- a/src/view/ui/FeedbackUI.js
+++ b/src/view/ui/FeedbackUI.js
@@ -58,6 +58,7 @@ export class FeedbackUI {
if (type === 'success') entry.style.color = '#66ff66';
if (type === 'warning') entry.style.color = '#ffcc00';
if (type === 'system') entry.style.color = '#88ccff';
+ if (type === 'event-log') entry.style.color = '#44ff44'; // Bright Green
entry.innerHTML = message;
@@ -169,6 +170,109 @@ export class FeedbackUI {
this.parentContainer.appendChild(overlay);
}
+ showEventCard(cardData, callback) {
+ const overlay = document.createElement('div');
+ Object.assign(overlay.style, {
+ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
+ backgroundColor: 'rgba(0, 0, 0, 0.85)', display: 'flex', justifyContent: 'center', alignItems: 'center',
+ pointerEvents: 'auto', zIndex: '2000'
+ });
+
+ // Card Container
+ const card = document.createElement('div');
+ Object.assign(card.style, {
+ backgroundColor: '#1a1a1a',
+ backgroundImage: 'repeating-linear-gradient(45deg, #222 25%, transparent 25%, transparent 75%, #222 75%, #222), repeating-linear-gradient(45deg, #222 25%, #1a1a1a 25%, #1a1a1a 75%, #222 75%, #222)',
+ backgroundPosition: '0 0, 10px 10px',
+ backgroundSize: '20px 20px',
+ border: '4px solid #8b0000',
+ borderRadius: '12px',
+ padding: '30px',
+ width: '320px',
+ textAlign: 'center',
+ color: '#fff',
+ fontFamily: '"Cinzel", serif',
+ boxShadow: '0 0 30px rgba(139, 0, 0, 0.6), inset 0 0 50px rgba(0,0,0,0.8)',
+ position: 'relative'
+ });
+
+ // Title
+ const titleEl = document.createElement('h2');
+ titleEl.textContent = cardData.titulo || "Evento";
+ Object.assign(titleEl.style, {
+ marginTop: '0',
+ marginBottom: '10px',
+ color: '#ff4444',
+ textTransform: 'uppercase',
+ letterSpacing: '2px',
+ fontSize: '24px',
+ textShadow: '2px 2px 0 #000'
+ });
+ card.appendChild(titleEl);
+
+ // Subtitle/Type
+ if (cardData.tipo) {
+ const typeEl = document.createElement('div');
+ typeEl.textContent = cardData.tipo;
+ Object.assign(typeEl.style, {
+ fontSize: '12px',
+ color: '#aaa',
+ marginBottom: '20px',
+ textTransform: 'uppercase',
+ borderBottom: '1px solid #444',
+ paddingBottom: '5px'
+ });
+ card.appendChild(typeEl);
+ }
+
+ // Image Placeholder (Optional)
+ // const img = document.createElement('div'); ...
+
+ // Message
+ const msgEl = document.createElement('p');
+ // If it's pure text or HTML
+ msgEl.innerHTML = cardData.descripcion || cardData.texto || "";
+ Object.assign(msgEl.style, {
+ fontSize: '16px',
+ lineHeight: '1.6',
+ color: '#ddd',
+ textAlign: 'justify',
+ fontStyle: 'italic',
+ marginBottom: '25px'
+ });
+ card.appendChild(msgEl);
+
+ // Action Button
+ const btn = document.createElement('button');
+ btn.textContent = 'CONTINUAR';
+ Object.assign(btn.style, {
+ padding: '12px 30px',
+ fontSize: '16px',
+ cursor: 'pointer',
+ backgroundColor: '#8b0000',
+ color: '#fff',
+ border: '2px solid #ff4444',
+ borderRadius: '4px',
+ fontFamily: 'inherit',
+ fontWeight: 'bold',
+ textTransform: 'uppercase',
+ boxShadow: '0 4px 0 #440000',
+ transition: 'transform 0.1s'
+ });
+
+ btn.onmousedown = () => btn.style.transform = 'translateY(2px)';
+ btn.onmouseup = () => btn.style.transform = 'translateY(0)';
+
+ btn.onclick = () => {
+ if (overlay.parentNode) this.parentContainer.removeChild(overlay);
+ if (callback) callback();
+ };
+ card.appendChild(btn);
+
+ overlay.appendChild(card);
+ this.parentContainer.appendChild(overlay);
+ }
+
showTemporaryMessage(title, message, duration = 2000) {
const modal = document.createElement('div');
Object.assign(modal.style, {