Fix(GameEngine): Critical Room Reveal hang resolve and Devlog update
59
DEVLOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
BIN
public/assets/images/dungeon1/markers/rubbles.png
Normal file
|
After Width: | Height: | Size: 13 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/araña_gigante.png
Normal file
|
After Width: | Height: | Size: 646 KiB |
BIN
public/assets/images/dungeon1/standees/enemies/minotaur.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/images/dungeon1/tokens/enemies/minotaur.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
@@ -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"]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
380
src/engine/events/EventInterpreter.js
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
35
src/main.js
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||