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, {