Fix(GameEngine): Critical Room Reveal hang resolve and Devlog update

This commit is contained in:
2026-01-10 00:18:43 +01:00
parent e22cd071c4
commit 82bdcacf95
18 changed files with 1117 additions and 195 deletions

View File

@@ -1,5 +1,64 @@
# Devlog - Warhammer Quest (Versión Web 3D) # 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) ## Sesión 12 (Continuación): Refactorización y Renderizado (Intento II - Exitoso)
**Fecha:** 9 de Enero de 2026 **Fecha:** 9 de Enero de 2026

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,114 +1,251 @@
export const EVENT_CARDS_DATA = [ export const EVENT_CARDS_DATA = [
{ {
"id": "evt_derrumbamiento",
"titulo": "DERRUMBAMIENTO", "titulo": "DERRUMBAMIENTO",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "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.", "descripcion": "Todas las salidas, excepto por la que entraron los Aventureros, están bloqueadas.",
"reglas_especiales": [ "cita_fuente": "[cite: 1, 2, 3, 4, 5]",
"Colocar marcador de Derrumbamiento.", "acciones": [
"Aventureros no sujetos a reglas de trabado por combate al intentar escapar.", {
"Los Monstruos en la estancia mueren automáticamente." "tipo_accion": "ENTORNO",
], "subtipo": "bloquear_salidas_excepto_entrada"
"cita_fuente": "[cite: 1, 2, 3, 4, 5]" },
{
"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", "titulo": "CADÁVER",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "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.", "descripcion": "Un Bárbaro muerto sostiene una bolsa de cuero.",
"tabla_efectos": [ "cita_fuente": "[cite: 6, 7, 11, 15, 16]",
{ "resultado": "1", "efecto": "¡Gas venenoso! 1D6 Heridas (sin modificadores). Bolsa vacía." }, "acciones": [
{ "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." } "tipo_accion": "SELECCION",
], "modo": "tirada_baja",
"notas": "Roba otra Carta de Evento inmediatamente después de resolver.", "dado": "1D6",
"cita_fuente": "[cite: 6, 7, 11, 15, 16]" "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", "titulo": "VIEJOS HUESOS",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "codigo_tipo": "E",
"descripcion": "Suelo cubierto de huesos y cráneos con brillo de monedas bajo ellos.", "descripcion": "Suelo cubierto de huesos y cráneos con brillo de monedas bajo ellos.",
"tabla_efectos": [ "cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]",
{ "resultado": "1", "efecto": "Ilusión. Los huesos y el oro desaparecen. Roba una Carta de Evento inmediatamente." }, "acciones": [
{ "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." }, "tipo_accion": "PRUEBA",
{ "resultado": "6", "efecto": "Cada Aventurero encuentra 2D6 x 10 monedas de oro y roba una Carta de Tesoro." } "tipo_prueba": "1D6_GLOBAL",
], "mensaje": "Tira 1D6 para ver qué esconden los huesos.",
"cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]" "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", "titulo": "TRAMPA",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "codigo_tipo": "E",
"descripcion": "El Aventurero con el resultado menor en 1D6 activa una trampa.", "descripcion": "El Aventurero con el resultado menor activa una trampa.",
"tabla_efectos": [ "cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]",
{ "resultado": "1", "efecto": "Explosión. Todas las miniaturas en la sección sufren 1D6 Heridas (sin modificadores)." }, "acciones": [
{ "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." } "tipo_accion": "SELECCION",
], "modo": "tirada_baja",
"cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]" "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", "titulo": "RASTRILLO",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "codigo_tipo": "E",
"descripcion": "Un rastrillo baja al entrar todos los Aventureros, bloqueando la salida de escape.", "descripcion": "Un rastrillo baja bloqueando la salida.",
"reglas_especiales": [ "cita_fuente": "[cite: 25, 26, 27]",
"Solo podrán regresar por ese camino si tienen la llave.", "acciones": [
"Colocar marcador de rastrillo en la puerta de entrada.", {
"Roba otra Carta de Evento inmediatamente." "tipo_accion": "ENTORNO",
], "subtipo": "bloquear_salida_entrada"
"cita_fuente": "[cite: 25, 26, 27]" },
}, {
{ "tipo_accion": "ENTORNO",
"titulo": "ESCORPIONES", "subtipo": "colocar_marcador",
"tipo": "Evento/Monstruo", "marcador": "rastrillo",
"codigo_tipo": "E", "posicion": "entrada"
"descripcion": "Un enjambre de 12 escorpiones pequeños ataca a un Aventurero al azar.", },
"mecanica": { {
"cantidad_inicial": 12, "tipo_accion": "TRIGGER_EVENTO",
"ataque_jugador": "1D6 + Fuerza = número de escorpiones eliminados.", "cantidad": 1
"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]"
}, },
//{
// "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", "titulo": "MINOTAURO",
"tipo": "Monstruo", "tipo": "Monstruo",
"codigo_tipo": "M", "codigo_tipo": "M",
"estadisticas": { "descripcion": "Un poderoso Minotauro bloquea el paso.",
"heridas": 15, "acciones": [
"movimiento": 6, {
"habilidad_armas": 4, "tipo_accion": "SPAWN",
"fuerza": 4, "id_monstruo": "minotaur", // Debe coincidir con MONSTER_DEFINITIONS key o similar
"resistencia": 4, "nombre_fallback": "Minotauro", // Por si no existe id
"ataques": 2 "cantidad": "1",
}, "stats": { // Opcional, si queremos sobreescribir o definir aquí
"reglas_especiales": [ "wounds": 15, "move": 6, "ws": 4, "str": 4, "toughness": 4, "attacks": 2, "gold": 440
"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)." {
], "tipo_accion": "TRIGGER_EVENTO",
"valor_oro": 440 "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", "titulo": "2D6 ARAÑAS GIGANTES",
"tipo": "Monstruo", "tipo": "Monstruo",
"codigo_tipo": "M", "codigo_tipo": "M",
"estadisticas": { "descripcion": "Descienden del techo...",
"heridas": 1, "acciones": [
"movimiento": 6, {
"habilidad_armas": 2, "tipo_accion": "SPAWN",
"fuerza": "Especial", "id_monstruo": "giant_spider",
"resistencia": 2, "nombre_fallback": "Araña Gigante",
"ataques": 1 "cantidad": "2D6",
}, "stats": {
"reglas_especiales": [ "wounds": 1, "move": 6, "ws": 2, "str": 3, "toughness": 2, "attacks": 1
"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." "reglas_especiales": ["telarana"]
}
] ]
} }
]; ];

View File

@@ -6,19 +6,8 @@ export const EVENT_TYPES = {
}; };
export const createEventDeck = () => { export const createEventDeck = () => {
// Convert raw JSON data to engine-compatible card objects // Return a deep copy of the definition data to avoid mutation issues
const deck = EVENT_CARDS_DATA.map(data => { const deck = EVENT_CARDS_DATA.map(card => JSON.parse(JSON.stringify(card)));
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 shuffleDeck(deck); return shuffleDeck(deck);
}; };
@@ -27,5 +16,16 @@ const shuffleDeck = (deck) => {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]]; [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; return deck;
}; };

View File

@@ -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}<br><br><b>Objetivo: ${names}</b>`);
}
}
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", `<b>${targetName}</b> realiza una prueba de <b>${action.tipo_prueba}</b>...<br>Resultado: <b>${roll}</b>`);
// 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}<br><b>${amount} Heridas</b> 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}<br>Ganan <b>${amount}</b> 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 <b>${count} ${def.name}</b>!<br>¡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!<br><b>${collapsedCount}</b> 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}`;
}
}

View File

@@ -51,7 +51,7 @@ export class CombatMechanics {
if (log.hitRoll < log.targetToHit) { if (log.hitRoll < log.targetToHit) {
log.hitSuccess = false; 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; return log;
} }
@@ -70,11 +70,10 @@ export class CombatMechanics {
damageSum += r; damageSum += r;
} }
log.damageRoll = damageSum; // Just sum for simple log, or we could array it log.damageRoll = damageSum;
log.damageTotal = damageSum + attStr; log.damageTotal = damageSum + attStr;
// 4. Calculate Wounds // 4. Calculate Wounds
// Wounds = (Dice + Str) - Toughness
let wounds = log.damageTotal - defTough; let wounds = log.damageTotal - defTough;
if (wounds < 0) wounds = 0; if (wounds < 0) wounds = 0;
@@ -82,9 +81,9 @@ export class CombatMechanics {
// 5. Build Message // 5. Build Message
if (wounds > 0) { 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 { } 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 // 6. Apply Damage to Defender State
@@ -92,9 +91,9 @@ export class CombatMechanics {
if (defender.isDead) { if (defender.isDead) {
log.defenderDied = true; log.defenderDied = true;
log.message += ` ¡${defender.name} ha muerto!`; log.message += ` 💀 ¡${defender.name} ha muerto!`;
} else if (defender.isUnconscious) { } else if (defender.isUnconscious) {
log.message += ` ¡${defender.name} cae inconsciente!`; log.message += ` 💀 ¡${defender.name} cae inconsciente!`;
} }
return log; return log;
@@ -123,20 +122,19 @@ export class CombatMechanics {
if (hitRoll === 1) { if (hitRoll === 1) {
log.hitSuccess = false; 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; return log;
} }
if (hitRoll < toHitTarget) { if (hitRoll < toHitTarget) {
log.hitSuccess = false; log.hitSuccess = false;
log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`; log.message = `💨 ${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
return log; return log;
} }
log.hitSuccess = true; log.hitSuccess = true;
// 2. Roll Damage // 2. Roll Damage
// Elf Bow Strength = 3
const weaponStrength = 3; const weaponStrength = 3;
const damageRoll = this.rollD6(); const damageRoll = this.rollD6();
const damageTotal = weaponStrength + damageRoll; const damageTotal = weaponStrength + damageRoll;
@@ -151,9 +149,9 @@ export class CombatMechanics {
// 4. Build Message // 4. Build Message
if (wounds > 0) { 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 { } 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 // 5. Apply Damage
@@ -161,7 +159,7 @@ export class CombatMechanics {
if (defender.isDead) { if (defender.isDead) {
log.defenderDied = true; log.defenderDied = true;
log.message += ` ¡${defender.name} ha muerto!`; log.message += ` 💀 ¡${defender.name} ha muerto!`;
} }
return log; return log;

View File

@@ -4,6 +4,7 @@ import { MonsterAI } from './MonsterAI.js';
import { MagicSystem } from './MagicSystem.js'; import { MagicSystem } from './MagicSystem.js';
import { CombatSystem } from './CombatSystem.js'; import { CombatSystem } from './CombatSystem.js';
import { CombatMechanics } from './CombatMechanics.js'; import { CombatMechanics } from './CombatMechanics.js';
import { EventInterpreter } from '../events/EventInterpreter.js'; // Import
import { HERO_DEFINITIONS } from '../data/Heroes.js'; import { HERO_DEFINITIONS } from '../data/Heroes.js';
import { MONSTER_DEFINITIONS } from '../data/Monsters.js'; import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
import { createEventDeck, EVENT_TYPES } from '../data/Events.js'; import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
@@ -18,6 +19,7 @@ export class GameEngine {
this.ai = new MonsterAI(this); // Init AI this.ai = new MonsterAI(this); // Init AI
this.magicSystem = new MagicSystem(this); // Init Magic this.magicSystem = new MagicSystem(this); // Init Magic
this.combatSystem = new CombatSystem(this); // Init Combat this.combatSystem = new CombatSystem(this); // Init Combat
this.events = new EventInterpreter(this); // Init Events Engine
this.player = null; this.player = null;
this.selectedEntity = null; this.selectedEntity = null;
this.isRunning = false; this.isRunning = false;
@@ -36,18 +38,17 @@ export class GameEngine {
this.onEntityDeath = null; // New: When entity dies this.onEntityDeath = null; // New: When entity dies
this.onFloatingText = null; // New: For overhead text feedback this.onFloatingText = null; // New: For overhead text feedback
this.onPathChange = null; this.onPathChange = null;
this.onShowEvent = null; // New: For styled event cards
} }
startMission(missionConfig) { startMission(missionConfig) {
this.dungeon.startDungeon(missionConfig); this.dungeon.startDungeon(missionConfig);
// Create Party (4 Heroes)
// Create Party (4 Heroes) // Create Party (4 Heroes)
this.createParty(); this.createParty();
this.isRunning = true;
this.turnManager.startGame();
// Listen for Phase Changes to Reset Moves // Listen for Phase Changes to Reset Moves
this.turnManager.on('phase_changed', (phase) => { this.turnManager.on('phase_changed', (phase) => {
if (phase === 'hero' || phase === 'exploration') { if (phase === 'hero' || phase === 'exploration') {
@@ -61,6 +62,7 @@ export class GameEngine {
window.RENDERER.clearAllActiveRings(); window.RENDERER.clearAllActiveRings();
} }
this.deselectEntity(); this.deselectEntity();
this.ai.executeTurn();
} }
}); });
@@ -72,12 +74,20 @@ export class GameEngine {
// 6. Listen for Power Phase Events // 6. Listen for Power Phase Events
this.turnManager.on('POWER_RESULT', (data) => { this.turnManager.on('POWER_RESULT', (data) => {
if (data.eventTriggered) { 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); setTimeout(() => this.handlePowerEvent(), 1500);
} }
}); });
// Initial Light Update // Initial Light Update
setTimeout(() => this.updateLighting(), 500); setTimeout(() => this.updateLighting(), 500);
// Start Game Loop (Now that listeners are ready)
this.isRunning = true;
this.turnManager.startGame();
} }
resetHeroMoves() { resetHeroMoves() {
@@ -655,6 +665,18 @@ export class GameEngine {
attemptBreakAway(hero) { attemptBreakAway(hero) {
if (!hero || hero.hasEscapedPin) return { success: false, roll: 0 }; 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 roll = Math.floor(Math.random() * 6) + 1;
const target = hero.stats.pin_target || 6; const target = hero.stats.pin_target || 6;
@@ -772,35 +794,31 @@ export class GameEngine {
if (isRoom) { if (isRoom) {
console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`); console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`);
// Disparar Evento (need cells) triggeredEvents = true; // Stop movement forces end of hero action
const newCells = []; entity.currentMoves = 0;
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tid === tileId) { // Send PARTIAL path to renderer (from 0 to current step i+1)
const [cx, cy] = key.split(',').map(Number); if (this.onEntityMove) {
newCells.push({ x: cx, y: cy }); this.onEntityMove(entity, fullPath.slice(0, i + 1));
}
} }
// Call Event Logic if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Explorando...", 2000);
const eventResult = this.onRoomRevealed(newCells);
// Always stop for Rooms // IMMEDIATE EVENT RESOLUTION
if (eventResult) { this.handlePowerEvent({ tileId: tileId }, () => {
console.log("Movement stopped by Room Entry!"); console.log("[GameEngine] Room Event Resolved.");
triggeredEvents = true;
// Notify UI via callback // Check for Monsters
if (this.onEventTriggered) { const hasMonsters = this.monsters.some(m => !m.isDead);
this.onEventTriggered(eventResult); 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) break; // Stop loop
if (this.onEntityMove) {
this.onEntityMove(entity, fullPath.slice(0, i + 1));
}
break; // Stop loop
}
} else { } else {
console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`); console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
} }
@@ -872,14 +890,30 @@ export class GameEngine {
// Minimal update loop // Minimal update loop
} }
findSpawnPoints(count) { findSpawnPoints(count, tileId = null) {
// Collect all currently available cells (occupiedCells maps "x,y" => tileId) // 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 = []; 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); 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 // 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]]; [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); return candidates.slice(0, count);
} }
onRoomRevealed(cells) { onRoomRevealed(cells) {
console.log("[GameEngine] Room Revealed!"); console.log("[GameEngine] Room Revealed!");
@@ -1287,71 +1333,221 @@ export class GameEngine {
return { clear: !blocked, path, blocker }; 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) { if (!this.eventDeck || this.eventDeck.length === 0) {
console.log("[GameEngine] Reshuffling Deck...");
this.eventDeck = createEventDeck(); 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) { if (card.id === 'evt_derrumbamiento') {
// Use specific prefix for Log Routing (if implemented) or just generic const leader = this.heroes[0];
this.onShowMessage(`Evento: ${card.name}`, card.description); 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) { console.log(`[GameEngine] Drawn Card: ${card.name}`, card);
let count = 1;
const title = card.name.toUpperCase();
if (title.includes('2D6')) { // Delegate execution to the modular interpreter
count = Math.floor(Math.random() * 6) + 1 + Math.floor(Math.random() * 6) + 1; if (this.events) {
} else if (title.includes('1D6')) { this.events.processEvent(card, () => {
count = Math.floor(Math.random() * 6) + 1; this.currentEventContext = null;
} else if (card.data.mecanica && card.data.mecanica.cantidad_inicial) { if (onComplete) onComplete();
count = card.data.mecanica.cantidad_inicial; 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 if (!this.heroes) return;
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
};
// Fix Strength mapping if int this.heroes.forEach(hero => {
if (typeof rawStats.fuerza === 'number') mappedStats.str = rawStats.fuerza; if (hero.buffs && hero.buffs.length > 0) {
// Decrement duration
hero.buffs.forEach(buff => {
// ... (existing buff logic if any)
});
}
});
}
// Map Portrait killEntitiesInCollapsingRoom(tileId) {
let portraitStr = 'assets/images/monsters/orc_portrait.png'; if (this.onShowMessage) this.onShowMessage("¡DERRUMBE TOTAL!", "La sala se ha venido abajo. Todo lo que había dentro ha sido aplastado.", 5000);
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';
const def = { // Find entities in this tile
name: card.name, const entitiesToKill = [];
portrait: portraitStr,
stats: mappedStats
};
const spots = this.findSpawnPoints(count); // Heroes
spots.forEach(spot => { this.heroes.forEach(h => {
this.spawnMonster(def, spot.x, spot.y, { skipTurn: false }); 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) { if (isAdjacent) {
this.onEventTriggered({ type: 'MONSTER_SPAWN', count: spots.length, message: card.description }); 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 console.log(`[GameEngine] Collapsed ${exitsToRemove.length} exit cells (${visualDoorCount} visual doors).`);
setTimeout(() => { return visualDoorCount;
if (this.turnManager) this.turnManager.nextPhase(); }
}, 3000);
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);
}
} }
} }

View File

@@ -55,19 +55,24 @@ export class TurnManager {
this.rollPowerDice(); this.rollPowerDice();
} }
resumeFromEvent() {
console.log("Resuming from Event...");
this.nextPhase();
}
rollPowerDice() { rollPowerDice() {
const roll = Math.floor(Math.random() * 6) + 1; const roll = Math.floor(Math.random() * 6) + 1;
// const roll = 1; // DEBUG: Force Event for testing
this.currentPowerRoll = roll; this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`); console.log(`Power Roll: ${roll}`);
let message = "El poder fluye..."; let message = "El poder fluye...";
let eventTriggered = false; let eventTriggered = false;
// Placeholder for future Event Logic
if (roll === 1) { if (roll === 1) {
message = "¡EVENTO DE PODER! (1) (Bypass Temporal)"; message = "¡EVENTO DE PODER! (1)";
eventTriggered = false; // Bypass for now to prevent freeze eventTriggered = true;
// eventTriggered = true;
// logic delegated to listeners
} }
this.emit('POWER_RESULT', { roll, message, eventTriggered }); this.emit('POWER_RESULT', { roll, message, eventTriggered });

View File

@@ -110,19 +110,18 @@ game.onCombatResult = (log) => {
const atkName = attacker ? attacker.name : '???'; const atkName = attacker ? attacker.name : '???';
const defName = defender ? defender.name : '???'; const defName = defender ? defender.name : '???';
let logMsg = `<b>${atkName}</b> ataca a <b>${defName}</b>.<br>`; // 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) { if (log.hitSuccess) {
logMsg += `¡Impacto! (Dado: ${log.hitRoll}). `; type = log.woundsCaused > 0 ? 'combat-hit' : 'combat-miss'; // Or create 'combat-block' style
if (log.woundsCaused > 0) { if (log.defenderDied) type = 'combat-kill';
logMsg += `Causa <b>${log.woundsCaused}</b> heridas.`;
} else {
logMsg += `Armadura absorbe el daño.`;
}
} else {
logMsg += `Falla (Dado: ${log.hitRoll}).`;
} }
ui.addLog(logMsg, log.hitSuccess ? 'combat-hit' : 'combat-miss'); ui.addLog(logMsg, type);
// 2. Show Attack Roll on Attacker (Floating) // 2. Show Attack Roll on Attacker (Floating)
if (attacker) { if (attacker) {
@@ -190,14 +189,26 @@ game.onRangedTarget = (targetMonster, losResult) => {
game.onShowMessage = (title, message, duration) => { game.onShowMessage = (title, message, duration) => {
// Filter specific game flow messages to Log instead of popup // Filter specific game flow messages to Log instead of popup
if (title.startsWith('Turno de') || title.includes('Fase') || title.includes('Efecto')) { if (title.startsWith('Turno de') || title.includes('Fase') || title.includes('Efecto') || title.includes('Evento')) {
ui.addLog(`👉 <b>${title}</b>: ${message}`, 'system'); let icon = '👉';
let type = 'system';
if (title.includes('Evento')) {
icon = '⚡';
type = 'event-log';
}
ui.addLog(`${icon} <b>${title}</b>: ${message}`, type);
return; return;
} }
// Default fallback for other messages (e.g. Warnings not covered by floating text) // Default fallback for other messages (e.g. Warnings not covered by floating text)
ui.showTemporaryMessage(title, message, duration); 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 // game.onEntitySelect is now handled by UIManager to wrap the renderer call
renderer.onHeroFinishedMove = (x, y) => { renderer.onHeroFinishedMove = (x, y) => {

View File

@@ -141,6 +141,13 @@ export class GameRenderer {
this.entityRenderer.triggerDeathAnimation(entityId); 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) { triggerVisualEffect(type, x, y) {
this.effectsRenderer.triggerVisualEffect(type, x, y); this.effectsRenderer.triggerVisualEffect(type, x, y);
} }

View File

@@ -113,6 +113,7 @@ export class UIManager {
showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); } showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); }
showCombatLog(log) { this.feedback.addLogMessage(log.message, log.hitSuccess ? 'combat-hit' : 'combat-miss'); } showCombatLog(log) { this.feedback.addLogMessage(log.message, log.hitSuccess ? 'combat-hit' : 'combat-miss'); }
addLog(message, type) { this.feedback.addLogMessage(message, type); } addLog(message, type) { this.feedback.addLogMessage(message, type); }
showEventCard(cardData, callback) { this.feedback.showEventCard(cardData, callback); }
showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); } showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); }
hideMonsterCard() { this.cards.hideMonsterCard(); } hideMonsterCard() { this.cards.hideMonsterCard(); }
} }

View File

@@ -364,4 +364,28 @@ export class DungeonRenderer {
} }
return false; 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.
});
}
} }

View File

@@ -58,6 +58,7 @@ export class FeedbackUI {
if (type === 'success') entry.style.color = '#66ff66'; if (type === 'success') entry.style.color = '#66ff66';
if (type === 'warning') entry.style.color = '#ffcc00'; if (type === 'warning') entry.style.color = '#ffcc00';
if (type === 'system') entry.style.color = '#88ccff'; if (type === 'system') entry.style.color = '#88ccff';
if (type === 'event-log') entry.style.color = '#44ff44'; // Bright Green
entry.innerHTML = message; entry.innerHTML = message;
@@ -169,6 +170,109 @@ export class FeedbackUI {
this.parentContainer.appendChild(overlay); 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) { showTemporaryMessage(title, message, duration = 2000) {
const modal = document.createElement('div'); const modal = document.createElement('div');
Object.assign(modal.style, { Object.assign(modal.style, {