Compare commits

5 Commits

41 changed files with 2247 additions and 507 deletions

183
DEVLOG.md
View File

@@ -1,4 +1,185 @@
# Devlog - Warhammer Quest (Versión Web 3D)
## Sesión 18: Refinado de Spawning y Ajustes de Corredores
**Fecha:** 11 de Enero de 2026
### Objetivos
- Corregir el spawn de monstruos en eventos de fase de poder (Minotauro).
- Ajustar la generación de salidas en la loseta inicial.
- Mejorar la visualización de notificaciones y la lógica de daño grupal.
### Cambios Realizados
#### 1. Spawn Hero-Centric para Eventos
- **Restricción de Área**: Modificada la lógica de `findSpawnPoints` en `GameEngine.js` para que los monstruos generados por Eventos de Poder solo aparezcan en losetas ocupadas por héroes, evitando que aparezcan en el vacío o en zonas inexploradas.
- **Fix Spawn Inicial**: Corregido bug de spawn inicial de héroes que causaba que aparecieran fuera de los límites (coordenada 0,0) si no se detectaban posiciones válidas inicialmente.
#### 2. Ajuste de Salidas Iniciales
- **Regla del Inicio**: Los pasillos colocados como loseta inicial (`tile_0`) ahora habilitan exactamente **2 salidas aleatorias** (en lugar de 1 o 4), ofreciendo opciones estratégicas equilibradas desde el comienzo.
- **Continuidad**: El resto de pasillos mantienen la regla de 1 salida extra para mantener la estructura de la mazmorra.
#### 3. Mejoras en EventInterpreter
- **Daño Grupal**: Implementada "herencia de objetivos" en `EventInterpreter.js`. Si un efecto afecta a "todos", ahora se aplica correctamente a todos los héroes seleccionados en la acción previa.
- **Logs Descriptivos**: Refinados los mensajes de log de eventos para ser más descriptivos: ahora detallan quién activó la trampa y a quién afecta exactamente el daño.
#### 4. UX/UI y Estética
- **Posicionamiento de UI**: Desplazada la posición de los mensajes de notificación temporales (cartel rojo) a la zona inferior (70% de altura) para evitar solapamientos críticos con los botones de acción (`Acabar Turno`, etc.).
- **Actualización de Assets**: Vinculadas nuevas texturas específicas para las salas de laboratorio y la bóveda en `TileDefinitions.js` (`room_4x4_skull.png`, `room_4x4_clean.png`).
---
## Sesión 17: Reglas de Mazmorra y Control de Pasillos
**Fecha:** 11 de Enero de 2026
### Objetivos
- Cumplir estrictamente con la composición del mazo de mazmorra (18 cartas originales).
- Implementar la mecánica de barajado de 13 cartas con el objetivo en la mitad inferior.
- Limitar el número de salidas en los pasillos para evitar laberintos infinitos.
- Establecer una regla de proximidad (4 celdas) para la generación de nuevas puertas.
### Cambios Realizados
#### 1. Fiel Composición del Mazo (Warhammer Quest 1995)
- **Mazo de 18 Cartas**: El pool inicial ahora contiene exactamente 6 salas de subterráneo, 7 pasillos, 3 cruces en T, 1 esquina y 1 escalera.
- **Estructura de 13 Cartas**: Al generar la misión, se crea un mazo de 13 cartas:
- 6 cartas aleatorias arriba.
- 1 sala de objetivo + 6 cartas aleatorias barajadas abajo.
- **Salas Específicas**: Se han definido 6 variantes de salas de subterráneo y 5 salas de objetivo únicas (`room_objective_1` a `5`) en `TileDefinitions.js`.
- **Assets de Backup**: Generadas texturas placeholder (`room_4x4_placeholder.png`, `room_4x8_placeholder.png`) para las nuevas salas para asegurar la carga visual.
#### 2. Control de Salidas en Pasillos (Regla de las 2 Salidas)
- **Limitación de Ramificación**: Los pasillos, que lógicamente tienen 4 direcciones, ahora solo habilitan un máximo de **2 salidas** (la entrada utilizada + una salida extra aleatoria).
- **Control de Proximidad Táctico**:
- Antes de habilitar una salida extra en un pasillo, el sistema verifica que la celda de salida esté a una **distancia mínima de 4 celdas** de cualquier otra habitación (`ROOM` u `OBJECTIVE_ROOM`).
- Si una salida potencial está demasiado cerca de una estructura existente, se descarta para evitar colisiones visuales y mecánicas.
- **Visualización**: Durante la fase de "Placing Tile", el jugador sigue viendo todas las salidas en azul, pero al confirmar, el sistema activa solo las permitidas.
### Tareas Pendientes / TODO
- **[Avanzado]** Implementar conectividad automática: si una pieza recién colocada queda adyacente a una pared con puerta de otra habitación, permitir la conexión física entre ambas.
---
## Sesión 16: Reglas de Exploración y Refinado de Eventos
**Fecha:** 11 de Enero de 2026
### Objetivos
- Ajustar la progresión de la fase de exploración según el manual original (1995).
- Refinar el evento de "Derrumbamiento" para permitir una huida táctica.
- Restaurar la aleatoriedad total en los mazos de juego (Eventos y Mazmorra).
### Cambios Realizados
#### 1. Exploración Diferida
- **Revelación en Fase de Héroes**: Ahora los héroes pueden entrar en estancias nuevas durante su fase. La habitación se marca como "visitada", pero no se genera el encuentro inmediatamente.
- **Resolución en Fase de Monstruos**: La carta de evento se roba al inicio de la Fase de Monstruos, antes de que estos actúen.
- **Movimiento Completo**: Los aventureros pueden completar todo su movimiento al entrar en una sala nueva (no se bloquean en la primera casilla), pero su turno termina al llegar a su destino.
- **Finalización Manual**: Se ha eliminado el salto automático de turno al entrar en una sala, permitiendo al jugador gestionar el orden de sus héroes manualmente antes de pasar al siguiente.
#### 2. Refinado del "Derrumbamiento" (Collapse)
- **Margen de Huida**: Se ha ajustado el contador de colapso a 2 turnos para dar tiempo real a los héroes a salir de la estancia.
- **Exención de Pinning**: Siguiendo el reglamento, los héroes no pueden ser trabados en combate mientras la habitación se derrumba (pueden huir ignorando a los monstruos).
- **Zonas Intransitables**: Una vez colapsada, la estancia se marca físicamente como bloqueada en la cuadrícula, impidiendo cualquier re-entrada.
#### 3. Restauración de Aleatoriedad
- **Mazo de Eventos**: Eliminados los "cheats" de desarrollo que forzaban el Enano y el Rastrillo. Ahora el mazo es 100% aleatorio.
- **Mazo de Mazmorra**: Reintroducidas todas las secciones especiales (esquinas, cruces en T, escaleras). La composición del mazo vuelve a ser equilibrada y variada.
#### 4. Correcciones y Mejoras Técnicas
- **Fix tile_0**: La habitación inicial se marca como explorada por defecto para evitar disparos de eventos fantasmas al inicio.
- **Corregida lógica de nombres**: Se ha arreglado un error donde el nombre de la carta robada no se mostraba correctamente en los logs.
- **Restaurada GridSystem**: Corregido un error que impedía colocar tiles tras la última actualización.
---
## Sesión 15: Evento del Rastrillo, Llave del Enano e Inventario
**Fecha:** 10 de Enero de 2026
### Objetivos
- Implementar el evento del "Rastrillo" (bloqueo de entrada por portcullis).
- Crear un sistema de inventario funcional para todos los héroes.
- Vincular la adquisición de la "Llave del Rastrillo" con la capacidad de abrir la puerta bloqueada.
### Cambios Realizados
#### 1. Sistema de Inventario
- **Propiedad Universal**: Se ha añadido un array `inventory` a nivel de objeto para todos los héroes en su inicialización.
- **Interfaz Visual (`InventoryUI.js`)**: Nueva interfaz estilo "mochila" que muestra los items recolectados por cada héroe.
- **Integración en UI**: Habilitado el botón "🎒 INVENTARIO" en las fichas de unidad para desplegar el contenido de la mochila.
- **Gestión de Items**: El `EventInterpreter` ahora soporta la acción `EFECTO (tipo: item)`, permitiendo que los eventos entreguen objetos físicos a los aventureros.
#### 2. Evento del Rastrillo (Portcullis)
- **Bloqueo Inteligente**: El `GameEngine` ahora rastrea la última entrada utilizada (`lastEntranceUsed`).
- **Nuevas Acciones de Entorno**: Implementado el subtipo `bloquear_entrada_rastrillo` en `EventInterpreter`.
- **Detección de Llave**: Al intentar abrir una puerta bloqueada por un rastrillo, el juego verifica si algún héroe del grupo posee la `llave_rastrillo`.
- **Textura de Rastrillo**: Se añadió soporte para `door1_portcullis.png` en `DungeonRenderer.js`.
- **Safeguards de Renderizado**: Se añadieron protecciones en el renderizador de puertas para evitar que la animación de "abrir puerta" sobreescriba visualmente el estado de "puerta bloqueada" o "portcullis".
### Estado Actual
El sistema de inventario y la lógica de la llave funcionan correctamente. El evento del Enano Moribundo entrega la llave al grupo, y esta permite levantar el rastrillo.
**Pendiente**: Se ha detectado un problema visual donde la textura de `door1_portcullis.png` no parece aplicarse correctamente en algunos casos, a pesar de que la lógica de bloqueo funciona. Se requiere una revisión más profunda del sistema de materiales de Three.js para este caso.
---
## 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: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

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: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,260 @@
export const EVENT_CARDS_DATA = [
{
"id": "evt_derrumbamiento",
"titulo": "DERRUMBAMIENTO",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "Todas las salidas, excepto por la que entraron los Aventureros, están bloqueadas.",
"cita_fuente": "[cite: 1, 2, 3, 4, 5]",
"acciones": [
{
"tipo_accion": "ENTORNO",
"subtipo": "bloquear_salidas_excepto_entrada"
},
{
"tipo_accion": "MENSAJE",
"texto": "¡Derrumbamiento! La estancia quedará intransitable al final del siguiente turno."
},
{
"tipo_accion": "ESTADO_GLOBAL",
"id": "cuenta_atras_derrumbamiento",
"duracion_turnos": 1,
"efecto_fin": "muerte_instantanea_en_loseta"
}
]
},
{
"id": "evt_cadaver",
"titulo": "CADÁVER",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "Encontráis un cadáver que sostiene una bolsa de cuero. ¿Contendrá algún tesoro o será una trampa?",
"cita_fuente": "[cite: 6, 7, 11, 15, 16]",
"acciones": [
{
"tipo_accion": "SELECCION",
"modo": "tirada_baja",
"dado": "1D6",
"guardar_como": "victima",
"mensaje": "se acerca con cautela para investigar 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! La bolsa estaba vacía." }
],
"2-3": [
{ "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "2D6", "mensaje": "¡Trampa de lanza! La bolsa estaba vacía." }
],
"4-6": [
{ "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "oro", "cantidad": "1D6*100", "mensaje": "¡Encuentras monedas de oro en la bolsa!" }
]
}
},
{
"tipo_accion": "TRIGGER_EVENTO",
"cantidad": 1
}
]
},
{
"id": "evt_viejos_huesos",
"titulo": "VIEJOS HUESOS",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "El suelo está cubierto de huesos y cráneos blanquecinos. Entre ellos, el brillo inconfundible del oro tienta a vuestra suerte...",
"cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]",
"acciones": [
{
"tipo_accion": "PRUEBA",
"tipo_prueba": "1D6",
"mensaje": "Alguien remueve los huesos con la punta de su bota...",
"tabla": {
"1": [
{ "tipo_accion": "MENSAJE", "texto": "¡Era una ilusión! Los huesos y el brillo del oro se desvanecen en el aire." },
{ "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
],
"2-3": [
{ "tipo_accion": "SELECCION", "modo": "azar", "guardar_como": "victima", "mensaje": "activan un mecanismo de defensa!" },
{ "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "1D6", "mensaje": "¡Un rayo mágico brota de una calavera!" },
{ "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
],
"4-5": [
{ "tipo_accion": "SELECCION", "modo": "todos", "mensaje": "se reparten las monedas encontradas." },
{ "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "1D6*10", "mensaje": "Había algo de oro entre los restos." },
{ "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
],
"6": [
{ "tipo_accion": "SELECCION", "modo": "todos", "mensaje": "¡están de suerte!" },
{ "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "2D6*10", "mensaje": "¡Un gran montón de oro estaba oculto bajo los cráneos!" },
{ "tipo_accion": "EFECTO", "tipo": "carta_tesoro", "cantidad": 1, "mensaje": "¡Y además encontráis un objeto valioso!" }
]
}
}
]
},
{
"id": "evt_trampa",
"titulo": "TRAMPA",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "¡Click! De repente todos oís un ruido metálico bajo vuestros pies... ¡Alguien ha activado una trampa!",
"cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]",
"acciones": [
{
"tipo_accion": "SELECCION",
"modo": "tirada_baja",
"dado": "1D6",
"guardar_como": "victima",
"mensaje": "ha pisado una trampa!"
},
{
"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_enano_moribundo",
"titulo": "ENCUENTRO",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "Al desplomarse una pared, encontráis a un Enano Minero moribundo acribillado por flechas. Antes de morir, os entrega una llave importante.",
"cita_fuente": "\"Esta es la llave que abrirá el rastrillo. Sin ella jamás seréis capaces de atravesarlo.\"",
"acciones": [
{
"tipo_accion": "SELECCION",
"modo": "azar",
"guardar_como": "portador",
"mensaje": "se acerca al enano y recibe la llave de sus manos trémulas."
},
{
"tipo_accion": "EFECTO",
"objetivo": "portador",
"tipo": "item",
"id_item": "llave_rastrillo",
"mensaje": "¡Has conseguido la Llave del Rastrillo!"
},
{
"tipo_accion": "TRIGGER_EVENTO",
"cantidad": 1
}
]
},
{
"id": "evt_rastrillo",
"titulo": "RASTRILLO",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "Un rastrillo baja bloqueando la salida.",
"cita_fuente": "[cite: 25, 26, 27]",
"acciones": [
{
"tipo_accion": "ENTORNO",
"subtipo": "bloquear_entrada_rastrillo"
},
{
"tipo_accion": "TRIGGER_EVENTO",
"cantidad": 1
}
]
},
//{
// "id": "evt_escorpiones",
// "titulo": "ESCORPIONES",
// "tipo": "Evento/Monstruo",
// "codigo_tipo": "E",
// "descripcion": "Un enjambre de escorpiones ataca.",
// "cita_fuente": "[cite: 28, 29, 30, 31, 32, 33, 34]",
// "acciones": [
// {
// "tipo_accion": "SELECCION",
// "modo": "azar",
// "guardar_como": "victima",
// "mensaje": "¡Los escorpiones eligen una presa!"
// },
// {
// "tipo_accion": "MINIJUEGO",
// "id_logica": "enjambre_escorpiones", // Lógica custom para calcular daño vs fuerza
// "cantidad": 12,
// "objetivo": "victima"
// }
// ]
//},
{
"id": "mon_minotauro",
"titulo": "MINOTAURO",
"tipo": "Monstruo",
"codigo_tipo": "M",
"descripcion": "Un poderoso Minotauro bloquea el paso.",
"acciones": [
{
"tipo_accion": "SPAWN",
"id_monstruo": "minotaur", // Debe coincidir con MONSTER_DEFINITIONS key o similar
"nombre_fallback": "Minotauro", // Por si no existe id
"cantidad": "1"
},
{
"tipo_accion": "TRIGGER_EVENTO",
"cantidad": 1
}
]
},
{
"id": "mon_chaosWarrior",
"titulo": "GUERRERO DE CAOS",
"tipo": "Monstruo",
"codigo_tipo": "M",
"descripcion": "Un guerrero de caos bloquea el paso.",
"acciones": [
{
"tipo_accion": "SPAWN",
"id_monstruo": "chaos_warrior", // Debe coincidir con MONSTER_DEFINITIONS key o similar
"nombre_fallback": "Guerrero de Caos", // Por si no existe id
"cantidad": "2"
},
{
"tipo_accion": "TRIGGER_EVENTO",
"cantidad": 1
}
]
},
{
"id": "mon_aranas",
"titulo": "2D6 ARAÑAS GIGANTES",
"tipo": "Monstruo",
"codigo_tipo": "M",
"descripcion": "Descienden del techo...",
"acciones": [
{
"tipo_accion": "SPAWN",
"id_monstruo": "giant_spider",
"nombre_fallback": "Araña Gigante",
"cantidad": "2D6"
}
]
}
];

View File

@@ -1,30 +1,13 @@
import { EVENT_CARDS_DATA } from './EventCards.js';
export const EVENT_TYPES = { export const EVENT_TYPES = {
MONSTER: 'monster', MONSTER: 'monster',
EVENT: 'event' // Ambushes, traps, etc. EVENT: 'event'
}; };
export const EVENT_DEFINITIONS = [
{
id: 'evt_orcs_d6',
type: EVENT_TYPES.MONSTER,
name: 'Emboscada de Orcos',
description: 'Un grupo de pieles verdes salta de las sombras.',
monsterKey: 'orc', // References MONSTER_DEFINITIONS
count: '1d6', // Special string to be parsed, or we can use a function
resolve: (gameEngine, context) => {
// Logic handled by engine based on params, or custom function
return Math.floor(Math.random() * 6) + 1;
}
}
];
export const createEventDeck = () => { export const createEventDeck = () => {
// As per user request: 10 copies of the same card for now // Return a deep copy of the definition data to avoid mutation issues
const deck = []; const deck = EVENT_CARDS_DATA.map(card => JSON.parse(JSON.stringify(card)));
for (let i = 0; i < 10; i++) {
deck.push({ ...EVENT_DEFINITIONS[0] });
}
return shuffleDeck(deck); return shuffleDeck(deck);
}; };
@@ -33,5 +16,6 @@ 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]];
} }
return deck; return deck;
}; };

View File

@@ -6,13 +6,14 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 3, ws: 3,
to_hit_missile: 5, // 5+ to hit with ranged to_hit_missile: 5,
str: 4, str: 4,
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas) toughness: 4,
wounds: 12, // 1D6 + 9 (Using fixed average for now) wounds: 12,
attacks: 1, attacks: 1,
init: 3, init: 3,
pin_target: 6 // 6+ to escape pin pin_target: 6,
gold: 0
} }
}, },
dwarf: { dwarf: {
@@ -22,13 +23,14 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 4, ws: 4,
to_hit_missile: 5, // 5+ to hit with ranged to_hit_missile: 5,
str: 3, str: 3,
toughness: 5, // 4 Base + 1 Armor (Cota de Malla) toughness: 5,
wounds: 11, // 1D6 + 8 (Using fixed average for now) wounds: 11,
attacks: 1, attacks: 1,
init: 2, init: 2,
pin_target: 5 // 5+ to escape pin pin_target: 5,
gold: 0
} }
}, },
elf: { elf: {
@@ -38,14 +40,15 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 4, ws: 4,
bs: 4, // Added for Bow bs: 4,
to_hit_missile: 4, // 4+ to hit with ranged to_hit_missile: 4,
str: 3, str: 3,
toughness: 3, toughness: 3,
wounds: 10, // 1D6 + 7 (Using fixed average for now) wounds: 10,
attacks: 1, attacks: 1,
init: 6, init: 6,
pin_target: 1 // Auto escape ("No se puede trabar al Elfo") pin_target: 1,
gold: 0
} }
}, },
wizard: { wizard: {
@@ -55,14 +58,15 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 2, ws: 2,
to_hit_missile: 6, // 6+ to hit with ranged to_hit_missile: 6,
str: 3, str: 3,
toughness: 3, toughness: 3,
wounds: 9, // 1D6 + 6 (Using fixed average for now) wounds: 9,
attacks: 1, attacks: 1,
init: 3, init: 3,
power: 0, // Tracks current power points power: 0,
pin_target: 4 // 4+ to escape pin pin_target: 4,
gold: 0
} }
} }
}; };

View File

@@ -13,6 +13,7 @@ export const MONSTER_DEFINITIONS = {
gold: 55 // Card: "Valor 55x Unidad" gold: 55 // Card: "Valor 55x Unidad"
} }
}, },
// Fix duplicate wounds key in goblin
goblin_spearman: { goblin_spearman: {
id: 'goblin_spearman', id: 'goblin_spearman',
name: 'Lancero Goblin', name: 'Lancero Goblin',
@@ -22,7 +23,6 @@ export const MONSTER_DEFINITIONS = {
ws: 2, ws: 2,
str: 3, str: 3,
toughness: 3, toughness: 3,
wounds: 3,
wounds: 1, wounds: 1,
attacks: 1, attacks: 1,
gold: 20, gold: 20,
@@ -47,7 +47,7 @@ export const MONSTER_DEFINITIONS = {
giant_spider: { giant_spider: {
id: 'giant_spider', id: 'giant_spider',
name: 'Araña Gigante', name: 'Araña Gigante',
portrait: '/assets/images/dungeon1/standees/enemies/spider.png', portrait: '/assets/images/dungeon1/standees/enemies/spiderGiant.png',
stats: { stats: {
move: 6, move: 6,
ws: 2, ws: 2,
@@ -88,5 +88,33 @@ export const MONSTER_DEFINITIONS = {
gold: 440, gold: 440,
damageDice: 2 // "Tira 2 dados para herir" damageDice: 2 // "Tira 2 dados para herir"
} }
},
chaos_warrior: {
id: 'chaos_warrior',
name: 'Guerrero del Caos',
portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
stats: {
move: 4,
ws: 4,
str: 4,
toughness: 4,
wounds: 10, // Copied from Event Card logic
attacks: 2,
gold: 240
}
},
skaven: {
id: 'skaven',
name: 'Skaven',
portrait: '/assets/images/dungeon1/standees/enemies/skaven.png',
stats: {
move: 5,
ws: 3,
str: 3,
toughness: 3,
wounds: 1,
attacks: 1,
gold: 30
}
} }
}; };

View File

@@ -9,61 +9,53 @@ export class DungeonDeck {
} }
generateMissionDeck(objectiveTileId) { generateMissionDeck(objectiveTileId) {
this.cards = []; this.cards = [];
// 1. Create a "Pool" of standard dungeon tiles // 1. Create a "Pool" of standard dungeon tiles (18 total)
// Rule: 6 Subterranean Rooms, 7 Corridors, 3 T-Junctions, 1 Corner, 1 Steps
let pool = []; let pool = [];
const composition = [ const composition = [
{ id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms { id: 'room_subterranean_1', count: 1 },
{ id: 'corridor_straight', count: 0 }, { id: 'room_subterranean_2', count: 1 },
{ id: 'junction_t', count: 0 } { id: 'room_subterranean_3', count: 1 },
{ id: 'room_subterranean_4', count: 1 },
{ id: 'room_subterranean_5', count: 1 },
{ id: 'room_subterranean_6', count: 1 },
{ id: 'corridor_straight', count: 7 },
{ id: 'junction_t', count: 3 },
{ id: 'corridor_corner', count: 1 },
{ id: 'corridor_steps', count: 1 }
]; ];
composition.forEach(item => { composition.forEach(item => {
// FIXED: Access by Key string directly
const tileDef = TILES[item.id]; const tileDef = TILES[item.id];
if (tileDef) { if (tileDef) {
for (let i = 0; i < item.count; i++) { for (let i = 0; i < item.count; i++) {
pool.push(tileDef); pool.push({ ...tileDef }); // Push a copy
} }
} else { } else {
console.error(`❌ Missing Tile Definition for ID: ${item.id}`); console.error(`❌ Missing Tile Definition for ID: ${item.id}`);
} }
}); });
const drawRandom = (source, count) => { // Shuffle the initial pool
const drawn = []; this.shuffleArray(pool);
for (let i = 0; i < count; i++) {
if (source.length === 0) break;
const idx = Math.floor(Math.random() * source.length);
drawn.push(source[idx]);
source.splice(idx, 1);
}
return drawn;
};
// --- Step 1 & 2: Bottom Pool --- // --- Step 1: Bottom 6 + Objective (shuffled together) ---
const bottomPool = drawRandom(pool, 6); const bottomHalf = pool.splice(0, 6);
const objectiveDef = TILES[objectiveTileId] || TILES['room_objective_1'];
bottomHalf.push({ ...objectiveDef });
this.shuffleArray(bottomHalf);
const objectiveDef = TILES[objectiveTileId]; // --- Step 2: Top 6 ---
if (objectiveDef) { const topHalf = pool.splice(0, 6);
bottomPool.push(objectiveDef); this.shuffleArray(topHalf);
} else {
console.error("Objective Tile ID not found:", objectiveTileId);
}
this.shuffleArray(bottomPool);
// --- Step 4: Top Pool ---
const topPool = drawRandom(pool, 6);
this.shuffleArray(topPool);
// --- Step 5: Stack ---
this.cards = [...topPool, ...bottomPool];
// --- Step 3: Final Stack (TopHalf on top of BottomHalf) ---
// Total 6 + 7 = 13 cards
this.cards = [...topHalf, ...bottomHalf];
console.log(`✅ Dungeon Deck generated with ${this.cards.length} cards. Objective hidden in the bottom 7.`);
} }
shuffleArray(array) { shuffleArray(array) {

View File

@@ -1,7 +1,7 @@
import { DIRECTIONS, TILE_TYPES } from './Constants.js';
import { DIRECTIONS } from './Constants.js';
import { GridSystem } from './GridSystem.js'; import { GridSystem } from './GridSystem.js';
import { DungeonDeck } from './DungeonDeck.js'; import { DungeonDeck } from './DungeonDeck.js';
import { TILES } from './TileDefinitions.js';
const PLACEMENT_STATE = { const PLACEMENT_STATE = {
WAITING_DOOR: 'WAITING_DOOR', WAITING_DOOR: 'WAITING_DOOR',
@@ -31,7 +31,13 @@ export class DungeonGenerator {
} }
startDungeon(missionConfig) { startDungeon(missionConfig) {
const objectiveId = missionConfig?.type === 'quest' ? 'room_objective' : 'room_dungeon'; // Warhammer Quest: Pick one of the 5 Objective Rooms
let objectiveId = missionConfig?.objectiveId;
if (!objectiveId) {
const objIds = ['room_objective_1', 'room_objective_2', 'room_objective_3', 'room_objective_4', 'room_objective_5'];
objectiveId = objIds[Math.floor(Math.random() * objIds.length)];
}
this.deck.generateMissionDeck(objectiveId); this.deck.generateMissionDeck(objectiveId);
// 1. Draw and place first card automatically at origin // 1. Draw and place first card automatically at origin
@@ -286,11 +292,17 @@ export class DungeonGenerator {
/** /**
* Update list of exits player can choose from * Update list of exits player can choose from
* Rule: Corridors only get ONE extra exit al azar, if it's far from OTHER rooms.
*/ */
updateAvailableExits(instance, variant, anchorX, anchorY) { updateAvailableExits(instance, variant, anchorX, anchorY) {
const card = TILES[instance.defId];
const isCorridor = card.type === TILE_TYPES.CORRIDOR;
// Identify the "parent" room we are connecting from to ignore it in distance checks
const parentTileId = this.selectedExit ? this.selectedExit.tileId : null;
// Add new exits from this tile // 1. Identify all potential exits (those not blocked by existing tiles)
let potentialExits = [];
for (const ex of variant.exits) { for (const ex of variant.exits) {
const gx = anchorX + ex.x; const gx = anchorX + ex.x;
const gy = anchorY + ex.y; const gy = anchorY + ex.y;
@@ -298,28 +310,86 @@ export class DungeonGenerator {
const leadingTo = this.neighbor(gx, gy, ex.direction); const leadingTo = this.neighbor(gx, gy, ex.direction);
const isOccupied = this.grid.isOccupied(leadingTo.x, leadingTo.y); const isOccupied = this.grid.isOccupied(leadingTo.x, leadingTo.y);
if (!isOccupied) { if (!isOccupied) {
this.availableExits.push({ potentialExits.push({
x: gx, x: gx,
y: gy, y: gy,
direction: ex.direction, direction: ex.direction,
tileId: instance.id tileId: instance.id
}); });
} }
} }
// 2. If it's a corridor, apply the "Semi-Random" exit rule
if (isCorridor && potentialExits.length > 0) {
const doorsByDir = {};
potentialExits.forEach(e => {
if (!doorsByDir[e.direction]) doorsByDir[e.direction] = [];
doorsByDir[e.direction].push(e);
});
const dirs = Object.keys(doorsByDir);
// Remove exits that are now blocked or connected // Filter directions that are at least 4 cells away from any OTHER Room
const allowedDirs = dirs.filter(dir => {
const group = doorsByDir[dir];
// Check proximity for every cell in this door group, IGNORE the room we just came from
return group.every(cell => this.isFarFromRooms(cell.x, cell.y, 4, parentTileId));
});
if (allowedDirs.length > 0) {
// RULE: If it's the STARTING tile (tile_0), we want 2 exits.
// Otherwise, just 1 extra exit.
const numToSelect = (instance.id === 'tile_0') ? 2 : 1;
// Shuffle and pick
const shuffledDirs = allowedDirs.sort(() => 0.5 - Math.random());
const selectedDirs = shuffledDirs.slice(0, numToSelect);
selectedDirs.forEach(dir => {
this.availableExits.push(...doorsByDir[dir]);
});
console.log(`[DungeonGenerator] Corridor ${instance.id} selected ${selectedDirs.length} exit directions: ${selectedDirs.join(', ')}`);
} else {
console.log(`[DungeonGenerator] Corridor ${instance.id} became a dead end.`);
}
} else {
// For Rooms and Junctions, we keep all valid exits
this.availableExits.push(...potentialExits);
}
// 3. Global cleanup
this.availableExits = this.availableExits.filter(exit => { this.availableExits = this.availableExits.filter(exit => {
const leadingTo = this.neighbor(exit.x, exit.y, exit.direction); const leadingTo = this.neighbor(exit.x, exit.y, exit.direction);
return !this.grid.isOccupied(leadingTo.x, leadingTo.y); return !this.grid.isOccupied(leadingTo.x, leadingTo.y);
}); });
} }
/**
* Checks if a coordinate is at least 'radius' distance away from any cell of a Room tile.
* @param {string} ignoreTileId - Optional ID of a room to ignore (usually the parent room)
*/
isFarFromRooms(gx, gy, radius, ignoreTileId = null) {
for (const tileInstance of this.grid.tiles) {
// Skip the room we are expanding from
if (ignoreTileId && tileInstance.id === ignoreTileId) continue;
const card = TILES[tileInstance.defId];
if (card.type === TILE_TYPES.ROOM || card.type === TILE_TYPES.OBJECTIVE_ROOM) {
const cells = this.grid.tileCells.get(tileInstance.id);
if (!cells) continue;
for (const cellKey of cells) {
const [cx, cy] = cellKey.split(',').map(Number);
const dist = Math.abs(gx - cx) + Math.abs(gy - cy);
if (dist < radius) return false;
}
}
}
return true;
}
/** /**
* Get current placement preview data for renderer * Get current placement preview data for renderer
*/ */

View File

@@ -13,6 +13,9 @@ export class GridSystem {
// Set of "x,y" strings that are door/exit cells (can cross room boundaries) // Set of "x,y" strings that are door/exit cells (can cross room boundaries)
this.doorCells = new Set(); this.doorCells = new Set();
// Set of "x,y" strings that are blocked (e.g. collapsed)
this.blockedCells = new Set();
this.tiles = []; this.tiles = [];
} }
@@ -141,7 +144,12 @@ export class GridSystem {
* Helper to see if a specific global coordinate is occupied * Helper to see if a specific global coordinate is occupied
*/ */
isOccupied(x, y) { isOccupied(x, y) {
return this.occupiedCells.has(`${x},${y}`); const key = `${x},${y}`;
return this.occupiedCells.has(key) && !this.blockedCells.has(key);
}
isBlocked(x, y) {
return this.blockedCells.has(`${x},${y}`);
} }
/** /**
@@ -162,8 +170,9 @@ export class GridSystem {
const data1 = this.cellData.get(key1); const data1 = this.cellData.get(key1);
const data2 = this.cellData.get(key2); const data2 = this.cellData.get(key2);
// Both cells must exist // Both cells must exist and not be blocked
if (!data1 || !data2) return false; if (!data1 || !data2) return false;
if (this.blockedCells.has(key1) || this.blockedCells.has(key2)) return false;
const sameTile = data1.tileId === data2.tileId; const sameTile = data1.tileId === data2.tileId;
const isDoor1 = this.doorCells.has(key1); const isDoor1 = this.doorCells.has(key1);

View File

@@ -295,18 +295,94 @@ export const TILES = {
}, },
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// ROOM DUNGEON // SUBTERRANEAN ROOMS (4x4)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
'room_dungeon': { 'room_subterranean_1': {
id: 'room_dungeon', id: 'room_subterranean_1',
name: 'Dungeon Room', name: 'Circle of Power',
type: TILE_TYPES.ROOM, type: TILE_TYPES.ROOM,
textures: [ textures: ['/assets/images/dungeon1/tiles/room_4x4_circle.png'],
'/assets/images/dungeon1/tiles/room_4x4_circle.png', variants: getStandard4x4Variants()
'/assets/images/dungeon1/tiles/room_4x4_orange.png', },
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png' 'room_subterranean_2': {
], id: 'room_subterranean_2',
variants: { name: 'Fighting Pit',
type: TILE_TYPES.ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x4_orange.png'],
variants: getStandard4x4Variants()
},
'room_subterranean_3': {
id: 'room_subterranean_3',
name: 'Guard Room',
type: TILE_TYPES.ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x4_squeleton.png'],
variants: getStandard4x4Variants()
},
'room_subterranean_4': {
id: 'room_subterranean_4',
name: 'Laboratory',
type: TILE_TYPES.ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x4_skull.png'],
variants: getStandard4x4Variants()
},
'room_subterranean_5': {
id: 'room_subterranean_5',
name: 'Vault',
type: TILE_TYPES.ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x4_clean.png'],
variants: getStandard4x4Variants()
},
'room_subterranean_6': {
id: 'room_subterranean_6',
name: 'Torture Chamber',
type: TILE_TYPES.ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x4_placeholder.png'],
variants: getStandard4x4Variants()
},
// -------------------------------------------------------------------------
// OBJECTIVE ROOMS (4x8)
// -------------------------------------------------------------------------
'room_objective_1': {
id: 'room_objective_1',
name: 'Altar of Doom',
type: TILE_TYPES.OBJECTIVE_ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x8_altar.png'],
variants: getStandard4x8Variants()
},
'room_objective_2': {
id: 'room_objective_2',
name: 'Tomb of the Overlord',
type: TILE_TYPES.OBJECTIVE_ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x8_tomb.png'],
variants: getStandard4x8Variants()
},
'room_objective_3': {
id: 'room_objective_3',
name: 'The Dread Mill',
type: TILE_TYPES.OBJECTIVE_ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x8_placeholder.png'],
variants: getStandard4x8Variants()
},
'room_objective_4': {
id: 'room_objective_4',
name: 'Monster\'s Lair',
type: TILE_TYPES.OBJECTIVE_ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x8_placeholder.png'],
variants: getStandard4x8Variants()
},
'room_objective_5': {
id: 'room_objective_5',
name: 'Fire Chasm',
type: TILE_TYPES.OBJECTIVE_ROOM,
textures: ['/assets/images/dungeon1/tiles/room_4x8_placeholder.png'],
variants: getStandard4x8Variants()
}
};
// Helper functions to reduce redundancy
function getStandard4x4Variants() {
return {
[DIRECTIONS.NORTH]: { [DIRECTIONS.NORTH]: {
width: 4, height: 4, width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]], layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
@@ -339,63 +415,30 @@ export const TILES = {
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST } { x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
] ]
} }
};
} }
},
function getStandard4x8Variants() {
return {
// -------------------------------------------------------------------------
// ROOM OBJECTIVE
// -------------------------------------------------------------------------
'room_objective': {
id: 'room_objective',
name: 'Objective Room',
type: TILE_TYPES.OBJECTIVE_ROOM,
textures: [
'/assets/images/dungeon1/tiles/room_4x8_altar.png',
'/assets/images/dungeon1/tiles/room_4x8_tomb.png'
],
variants: {
[DIRECTIONS.NORTH]: { [DIRECTIONS.NORTH]: {
width: 4, height: 8, width: 4, height: 8,
layout: [ layout: Array(8).fill([1, 1, 1, 1]),
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], exits: [{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }]
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]
],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
}, },
[DIRECTIONS.EAST]: { [DIRECTIONS.EAST]: {
width: 8, height: 4, width: 8, height: 4,
layout: [ layout: Array(4).fill([1, 1, 1, 1, 1, 1, 1, 1]),
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1], exits: [{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }]
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1]
],
exits: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
}, },
[DIRECTIONS.SOUTH]: { [DIRECTIONS.SOUTH]: {
width: 4, height: 8, width: 4, height: 8,
layout: [ layout: Array(8).fill([1, 1, 1, 1]),
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], exits: [{ x: 1, y: 7, direction: DIRECTIONS.NORTH }, { x: 2, y: 7, direction: DIRECTIONS.NORTH }]
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]
],
exits: [
{ x: 1, y: 7, direction: DIRECTIONS.NORTH }, { x: 2, y: 7, direction: DIRECTIONS.NORTH }
]
}, },
[DIRECTIONS.WEST]: { [DIRECTIONS.WEST]: {
width: 8, height: 4, width: 8, height: 4,
layout: [ layout: Array(4).fill([1, 1, 1, 1, 1, 1, 1, 1]),
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1], exits: [{ x: 7, y: 1, direction: DIRECTIONS.EAST }, { x: 7, y: 2, direction: DIRECTIONS.EAST }]
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1]
],
exits: [
{ x: 7, y: 1, direction: DIRECTIONS.EAST }, { x: 7, y: 2, direction: DIRECTIONS.EAST }
]
}
}
} }
}; };
}

View File

@@ -0,0 +1,356 @@
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
import { CombatMechanics } from '../game/CombatMechanics.js';
export class EventInterpreter {
constructor(gameEngine) {
this.game = gameEngine;
this.queue = [];
this.isProcessing = false;
this.currentContext = {}; // Store temporary variables (e.g. "victim")
this.lastSelectedTargets = []; // Store the results of the last SELECCION
}
/**
* 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 log(title, text) {
if (this.game.onShowMessage) {
this.game.onShowMessage(title, text);
}
// Delay removed here, pacing is now handled only by the queue
return Promise.resolve();
}
async processQueue(onComplete = null) {
if (this.isProcessing) return;
if (this.queue.length === 0) {
console.log("[EventInterpreter] All actions completed.");
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;
// Increased delay to 2s between major event steps as requested
if (this.queue.length > 0) {
setTimeout(() => this.processQueue(onComplete), 2000);
} else {
this.processQueue(onComplete); // Finish up
}
}
async executeAction(action) {
switch (action.tipo_accion) {
case 'MENSAJE':
await this.log("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') {
let lowest = 99;
let candidates = [];
this.game.heroes.forEach(h => {
const roll = Math.floor(Math.random() * 6) + 1;
if (roll < lowest) {
lowest = roll;
candidates = [h];
} else if (roll === lowest) {
candidates.push(h); // Tie
}
});
targets = [candidates[Math.floor(Math.random() * candidates.length)]];
}
// Store result
this.lastSelectedTargets = targets;
if (action.guardar_como) {
this.currentContext[action.guardar_como] = targets[0];
}
if (action.mensaje) {
const names = targets.map(t => t.name).join(", ");
// Improved immersive message: "¡El Bárbaro ha pisado una trampa!"
await this.log("Selección", `¡El <b>${names}</b> ${action.mensaje}`);
}
}
async handleTest(action) {
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;
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;
}
// Log result to sidebar instead of Popup
const targetName = target ? target.name : (action.objetivo === 'todos' ? "El Grupo" : "Tirada de Evento");
await this.log("Evento: Prueba", `<b>${targetName}</b> tira <b>${action.tipo_prueba}</b>: Resultado <b style="color:#DAA520">${roll}</b>`);
// Check Table
if (action.tabla) {
let resultActions = null;
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) {
this.queue.unshift(...resultActions);
}
}
}
async handleEffect(action) {
let targets = [];
if (action.objetivo) {
if (this.currentContext[action.objetivo]) targets = [this.currentContext[action.objetivo]];
} else {
// Default to the last selected targets from the previous action
targets = this.lastSelectedTargets || [];
}
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 (action.tipo === 'daño') {
targets.forEach(h => {
// RED ALERT: Use applyDamage to handle currentWounds and death/unconscious logic
CombatMechanics.applyDamage(h, amount, this.game);
if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
});
// Combined outcome into a single log entry
await this.log("Efecto", `<b>${msg}</b>. Daño recibido: <b style="color:#ff4444">${amount} Heridas</b> a ${targets.map(t => t.name).join(", ")}`);
// USER REQUEST: Show a clear notification with the consequence after the delay
if (this.game.onShowMessage) {
const targetNames = targets.map(t => t.name).join(", ");
this.game.onShowMessage("CONSECUENCIA", `<b>${msg}</b><br><br><span style="color:#ff4444; font-size: 20px;">-${amount} Heridas</span> a ${targetNames}`);
}
} else if (action.tipo === 'oro') {
targets.forEach(h => {
h.stats.gold = (h.stats.gold || 0) + amount;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
});
await this.log("Efecto", `<b>${msg}</b>. Hallazgo: <b style="color:#DAA520">${amount} Oro</b>.`);
// USER REQUEST: Show a clear notification with the consequence
if (this.game.onShowMessage) {
const targetNames = targets.map(t => t.name).join(", ");
this.game.onShowMessage("HALLAZGO", `<b>${msg}</b><br><br><span style="color:#DAA520; font-size: 20px;">+${amount} Oro</span> para ${targetNames}`);
}
} else if (action.tipo === 'item') {
targets.forEach(h => {
if (!h.inventory) h.inventory = [];
h.inventory.push(action.id_item);
if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
});
if (this.game.onShowMessage) {
// One prominent message. The log will also receive it if main.js is configured to mirror it or if we call log separately.
// Let's call log with a type that ensures it's NOT a popup, and use onShowMessage for the popup.
await this.log("Efecto", `${targets.map(t => t.name).join(", ")} obtiene <b>${action.id_item}</b>.`);
this.game.onShowMessage("OBJETO", `<b>${msg}</b>`);
}
}
}
async handleSpawn(action) {
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;
}
const monsterId = action.id_monstruo;
const libraryDef = MONSTER_DEFINITIONS[monsterId];
let def;
if (libraryDef) {
def = { ...libraryDef, stats: { ...libraryDef.stats } };
if (action.stats) def.stats = { ...def.stats, ...action.stats };
} else {
def = {
name: action.nombre_fallback || "Enemigo",
portrait: this.getTexturePathForMonster_Legacy(monsterId),
stats: action.stats || { wounds: 1, move: 4, ws: 3, str: 3, toughness: 3 }
};
}
let contextTileId = null;
if (this.game.currentEventContext && this.game.currentEventContext.tileId) {
contextTileId = this.game.currentEventContext.tileId;
}
const skip = this.game.currentEventContext && this.game.currentEventContext.source === 'exploration';
const spots = this.game.findSpawnPoints(count, contextTileId);
spots.forEach(spot => {
this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: skip });
});
// KEEP MODAL for Spawn - it's a major event that requires immediate player attention
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') {
if (this.game.collapseExits) {
const collapsedCount = this.game.collapseExits();
await this.showModal("¡Derrumbe!", `¡El techo se viene abajo!<br><b>${collapsedCount}</b> salidas bloqueadas.`);
}
} else if (action.subtipo === 'colocar_marcador') {
if (this.game.placeEventMarker) {
this.game.placeEventMarker(action.marcador);
}
} else if (action.subtipo === 'bloquear_entrada_rastrillo') {
if (this.game.blockPortcullisAtEntrance) {
this.game.blockPortcullisAtEntrance();
}
} else {
console.log("Environment Action Unknown:", action.subtipo);
}
}
// Legacy fallback ONLY
getTexturePathForMonster_Legacy(id) {
return "assets/images/dungeon1/standees/enemies/orc.png";
}
}

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

@@ -42,6 +42,18 @@ export class CombatSystem {
// 3. Update State // 3. Update State
attacker.hasAttacked = true; attacker.hasAttacked = true;
// Award Gold if hero killed monster
if (result.defenderDied && attacker.type === 'hero') {
const goldValue = (defender.stats && defender.stats.gold) || 0;
if (goldValue > 0) {
attacker.stats.gold = (attacker.stats.gold || 0) + goldValue;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(attacker);
if (this.game.onShowMessage) {
this.game.onShowMessage("Botín", `¡Has derrotado a ${defender.name}! Recibes ${goldValue} Oro.`);
}
}
}
// 4. Side Effects (Sound, UI Events) // 4. Side Effects (Sound, UI Events)
if (window.SOUND_MANAGER) { if (window.SOUND_MANAGER) {
// Logic to choose sound could be expanded here based on Weapon Type // Logic to choose sound could be expanded here based on Weapon Type
@@ -87,6 +99,18 @@ export class CombatSystem {
// 3. Update State // 3. Update State
attacker.hasAttacked = true; attacker.hasAttacked = true;
// Award Gold if hero killed monster
if (result.defenderDied && attacker.type === 'hero') {
const goldValue = (defender.stats && defender.stats.gold) || 0;
if (goldValue > 0) {
attacker.stats.gold = (attacker.stats.gold || 0) + goldValue;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(attacker);
if (this.game.onShowMessage) {
this.game.onShowMessage("Botín", `¡Has derrotado a ${defender.name}! Recibes ${goldValue} Oro.`);
}
}
}
// 4. Side Effects // 4. Side Effects
if (window.SOUND_MANAGER) { if (window.SOUND_MANAGER) {
window.SOUND_MANAGER.playSound('arrow'); window.SOUND_MANAGER.playSound('arrow');

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,12 +19,16 @@ 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;
this.plannedPath = []; // Array of {x,y} this.plannedPath = []; // Array of {x,y}
this.visitedRoomIds = new Set(); // Track tiles triggered this.visitedRoomIds = new Set(); // Track tiles triggered
this.eventDeck = createEventDeck(); this.eventDeck = createEventDeck();
this.lastEntranceUsed = null;
this.pendingExploration = null;
this.exploredRoomIds = new Set();
// Callbacks // Callbacks
this.onEntityUpdate = null; this.onEntityUpdate = null;
@@ -34,19 +39,22 @@ export class GameEngine {
this.onShowMessage = null; // New: Generic temporary message UI callback this.onShowMessage = null; // New: Generic temporary message UI callback
this.onEntityHit = null; // New: When entity takes damage this.onEntityHit = null; // New: When entity takes damage
this.onEntityDeath = null; // New: When entity dies this.onEntityDeath = null; // New: When entity dies
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);
// Starting room is already explored
this.exploredRoomIds.add('tile_0');
this.visitedRoomIds.add('tile_0');
// 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') {
@@ -60,6 +68,7 @@ export class GameEngine {
window.RENDERER.clearAllActiveRings(); window.RENDERER.clearAllActiveRings();
} }
this.deselectEntity(); this.deselectEntity();
// Duplicate executeTurn removed here. main.js handles this with playMonsterTurn().
} }
}); });
@@ -68,8 +77,30 @@ export class GameEngine {
this.handleEndTurn(); this.handleEndTurn();
}); });
// 6. Listen for Power Phase Events
this.turnManager.on('POWER_RESULT', (data) => {
// Update Wizard Power Stat
const wizard = this.heroes.find(h => h.key === 'wizard');
if (wizard) {
wizard.stats.power = data.roll;
if (this.onEntityUpdate) this.onEntityUpdate(wizard);
}
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({ source: 'power' }), 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() {
@@ -157,7 +188,8 @@ export class GameEngine {
currentMoves: definition.stats.move, currentMoves: definition.stats.move,
hasAttacked: false, hasAttacked: false,
isConscious: true, isConscious: true,
hasLantern: key === 'barbarian' // Default leader hasLantern: key === 'barbarian', // Default leader
inventory: []
}; };
this.heroes.push(hero); this.heroes.push(hero);
@@ -273,10 +305,21 @@ export class GameEngine {
} }
} }
spawnMonster(monsterKey, x, y, options = {}) { spawnMonster(monsterKeyOrDef, x, y, options = {}) {
const definition = MONSTER_DEFINITIONS[monsterKey]; let definition;
let monsterKey;
if (typeof monsterKeyOrDef === 'string') {
definition = MONSTER_DEFINITIONS[monsterKeyOrDef];
monsterKey = monsterKeyOrDef;
} else {
// Dynamic Definition from Card
definition = monsterKeyOrDef;
monsterKey = definition.name ? definition.name.replace(/\s+/g, '_').toLowerCase() : 'dynamic_monster';
}
if (!definition) { if (!definition) {
console.error(`Monster definition not found: ${monsterKey}`); console.error(`Monster definition not found: ${monsterKeyOrDef}`);
return; return;
} }
@@ -370,7 +413,7 @@ export class GameEngine {
const targetObj = { x: x, y: y }; const targetObj = { x: x, y: y };
const los = this.checkLineOfSightStrict(caster, targetObj); const los = this.checkLineOfSightStrict(caster, targetObj);
if (!los || !los.clear) { if (!los || !los.clear) {
if (this.onShowMessage) this.onShowMessage('Bloqueado', 'No tienes línea de visión.'); if (this.onFloatingText) this.onFloatingText(x, y, "Bloqueado", "#ff0000");
// Do NOT cancel targeting, let them try again // Do NOT cancel targeting, let them try again
return; return;
} }
@@ -484,8 +527,8 @@ export class GameEngine {
// Check Pinned Status // Check Pinned Status
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') { if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
if (this.isEntityPinned(clickedEntity)) { if (this.isEntityPinned(clickedEntity)) {
if (this.onShowMessage) { if (this.onFloatingText) {
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.'); this.onFloatingText(clickedEntity.x, clickedEntity.y, "¡Trabado!", "#ff4400");
} }
} }
} }
@@ -497,7 +540,7 @@ export class GameEngine {
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space) // 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
if (this.selectedEntity) { if (this.selectedEntity) {
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) { if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.'); if (this.onFloatingText) this.onFloatingText(this.selectedEntity.x, this.selectedEntity.y, Trabado!", "#ff4400");
return; return;
} }
this.planStep(x, y); this.planStep(x, y);
@@ -602,6 +645,12 @@ export class GameEngine {
// If already escaped this turn, not pinned // If already escaped this turn, not pinned
if (entity.hasEscapedPin) return false; if (entity.hasEscapedPin) return false;
// RULE: No pinning in a collapsing room (Panic/Rubble distraction)
if (this.state && this.state.collapsingRoom) {
const tileId = this.dungeon.grid.occupiedCells.get(`${entity.x},${entity.y}`);
if (tileId === this.state.collapsingRoom.tileId) return false;
}
return this.monsters.some(m => { return this.monsters.some(m => {
if (m.isDead) return false; if (m.isDead) return false;
const dx = Math.abs(entity.x - m.x); const dx = Math.abs(entity.x - m.x);
@@ -636,6 +685,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;
@@ -741,66 +802,42 @@ export class GameEngine {
// 2. Check for New Tile Entry // 2. Check for New Tile Entry
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`); const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
if (tileId && !this.visitedRoomIds.has(tileId)) { if (tileId) {
// Mark as visited immediatley
this.visitedRoomIds.add(tileId);
// Check Tile Type (Room vs Corridor)
const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId); const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId);
const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective')); const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective'));
const isUnexploredRoom = isRoom && !this.exploredRoomIds.has(tileId);
if (isRoom) { if (isUnexploredRoom) {
console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`); if (!this.pendingExploration) {
console.log(`[GameEngine] First hero ${entity.name} entered UNEXPLORED ROOM: ${tileId}`);
// Disparar Evento (need cells) if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Preparando encuentro...", 2000);
const newCells = []; this.pendingExploration = { tileId: tileId, source: 'exploration' };
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tid === tileId) {
const [cx, cy] = key.split(',').map(Number);
newCells.push({ x: cx, y: cy });
} }
} triggeredEvents = true; // Use this flag to end turn AFTER movement
} else if (!this.visitedRoomIds.has(tileId)) {
// Call Event Logic this.visitedRoomIds.add(tileId);
const eventResult = this.onRoomRevealed(newCells);
// Always stop for Rooms
if (eventResult) {
console.log("Movement stopped by Room Entry!");
triggeredEvents = true;
// Notify UI via callback
if (this.onEventTriggered) {
this.onEventTriggered(eventResult);
}
// Send PARTIAL path to renderer (from 0 to current step i+1)
if (this.onEntityMove) {
this.onEntityMove(entity, fullPath.slice(0, i + 1));
}
break; // Stop loop
}
} else {
console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
} }
} }
} }
// If NO interruption, send full path // Always send full path to renderer since we no longer interrupt movement
if (!triggeredEvents) {
if (this.onEntityMove) { if (this.onEntityMove) {
this.onEntityMove(entity, fullPath); this.onEntityMove(entity, fullPath);
} }
}
// Deduct Moves // Deduct Moves
if (entity.currentMoves !== undefined) { if (entity.currentMoves !== undefined) {
// Only deduct steps actually taken. No penalty. // If we entered a new room, moves drop to 0 immediately upon completion.
if (triggeredEvents) {
entity.currentMoves = 0;
} else {
entity.currentMoves -= stepsTaken; entity.currentMoves -= stepsTaken;
if (entity.currentMoves < 0) entity.currentMoves = 0; if (entity.currentMoves < 0) entity.currentMoves = 0;
} }
}
// Notify UI of move change
if (this.onEntityUpdate) this.onEntityUpdate(entity);
// AUTO-DESELECT LOGIC // AUTO-DESELECT LOGIC
// In Hero Phase, we want to KEEP the active hero selected to avoid re-selecting. // In Hero Phase, we want to KEEP the active hero selected to avoid re-selecting.
@@ -853,40 +890,73 @@ export class GameEngine {
// Minimal update loop // Minimal update loop
} }
findSpawnPoints(count) { findSpawnPoints(count, tileId = null) {
const points = []; // Collect all currently available cells (occupiedCells maps "x,y" => tileId)
const startNode = { x: 0, y: 0 }; const candidates = [];
const searchQueue = [startNode];
const visited = new Set(['0,0']);
let loops = 0; // If no specific tileId is provided (e.g., Power Event),
while (searchQueue.length > 0 && points.length < count && loops < 200) { // restrict spawn to tiles currently occupied by heroes.
const current = searchQueue.shift(); let allowedTileIds = null;
if (!tileId && this.heroes.length > 0) {
if (this.dungeon.grid.isOccupied(current.x, current.y)) { allowedTileIds = new Set();
points.push(current); this.heroes.forEach(h => {
const hTileId = this.dungeon.grid.occupiedCells.get(`${h.x},${h.y}`);
if (hTileId) allowedTileIds.add(hTileId);
});
console.log("[GameEngine] Power Event Spawn: Restricting to tiles with heroes:", Array.from(allowedTileIds));
} }
// Neighbors for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
const neighbors = [ // If tileId provided, must match it.
{ x: current.x + 1, y: current.y }, // If no tileId provided, must be one of the tiles with heroes.
{ x: current.x - 1, y: current.y }, if (tileId) {
{ x: current.x, y: current.y + 1 }, if (tid !== tileId) continue;
{ x: current.x, y: current.y - 1 } } else if (allowedTileIds) {
]; if (!allowedTileIds.has(tid)) continue;
for (const n of neighbors) {
const key = `${n.x},${n.y}`;
if (!visited.has(key)) {
visited.add(key);
searchQueue.push(n);
}
}
loops++;
} }
return points; const [x, y] = key.split(',').map(Number);
// Check if cell is blocked (collapsed)
if (this.dungeon.grid.blockedCells.has(key)) continue;
// 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
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
// 3. Return requested amount (or less if not enough space)
if (candidates.length < count) {
console.warn(`[GameEngine] Not enough space to spawn ${count} monsters. Only ${candidates.length} spawn.`);
if (this.onShowMessage) {
// this.onShowMessage("Espacio Insuficiente", `Solo caben ${candidates.length} de ${count} monstruos.`);
}
return candidates;
}
return candidates.slice(0, count);
}
onRoomRevealed(cells) { onRoomRevealed(cells) {
console.log("[GameEngine] Room Revealed!"); console.log("[GameEngine] Room Revealed!");
@@ -965,6 +1035,18 @@ export class GameEngine {
// ========================================= // =========================================
async playMonsterTurn() { async playMonsterTurn() {
// 1. Resolve pending exploration from Hero Phase
if (this.pendingExploration) {
const context = { ...this.pendingExploration };
this.pendingExploration = null;
console.log("[GameEngine] Resolving pending exploration at start of Monster Phase.");
await new Promise(resolve => {
this.handlePowerEvent(context, resolve);
});
}
// 2. Execute AI for existing/new monsters
if (this.ai) { if (this.ai) {
await this.ai.executeTurn(); await this.ai.executeTurn();
} }
@@ -1282,4 +1364,259 @@ export class GameEngine {
return { clear: !blocked, path, blocker }; return { clear: !blocked, path, blocker };
} }
handlePowerEvent(context = null, onComplete = null) {
console.log("[GameEngine] Handling Power Event...");
// Inject Context
if (context) {
this.currentEventContext = context;
}
if (!this.eventDeck || this.eventDeck.length === 0) {
console.log("[GameEngine] Reshuffling Deck...");
this.eventDeck = createEventDeck();
}
let card = this.eventDeck.shift();
// 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 (card.id === 'evt_derrumbamiento') {
const leader = this.heroes[0];
const currentTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`);
// Assuming 'tile_0' is the start room ID.
// Also checking turn number might be safe: turnManager.currentTurn
if (currentTileId === 'tile_0' || this.turnManager.currentTurn <= 1) {
console.log("[GameEngine] Derrumbamiento drawn in Start Room. IGNORING and Redrawing.");
// Discard and Draw again
// (Maybe put back in deck? Rules say 'ignore and draw another immediately', usually means discard this one)
if (this.eventDeck.length === 0) this.eventDeck = createEventDeck();
card = this.eventDeck.shift();
console.log(`[GameEngine] Redrawn Card: ${card.titulo}`);
}
}
console.log(`[GameEngine] Drawn Card: ${card.titulo}`, card);
// Delegate execution to the modular interpreter
if (this.events) {
this.events.processEvent(card, () => {
this.currentEventContext = null;
// Mark room as explored if it was an exploration source
if (context && context.tileId) {
this.exploredRoomIds.add(context.tileId);
}
if (onComplete) onComplete();
else this.turnManager.resumeFromEvent();
});
} else {
console.error("[GameEngine] EventInterpreter not initialized!");
this.turnManager.resumeFromEvent();
}
}
handleEndTurn() {
console.log("[GameEngine] Handling End of Turn Effects...");
// Check Collapsing Room State
if (this.state && this.state.collapsingRoom) {
if (typeof this.state.collapsingRoom.turnsLeft === 'number') {
this.state.collapsingRoom.turnsLeft--;
console.log(`[GameEngine] Collapsing Room Timer: ${this.state.collapsingRoom.turnsLeft}`);
if (this.state.collapsingRoom.turnsLeft > 0) {
const msg = this.state.collapsingRoom.turnsLeft === 1 ?
"¡ÚLTIMO AVISO! El techo está a punto de ceder..." :
`El techo cruje peligrosamente... Tenéis ${this.state.collapsingRoom.turnsLeft} turnos para salir.`;
if (this.onShowMessage) {
this.onShowMessage("¡PELIGRO!", msg, 5000);
}
} else {
// TIME'S UP - KILL EVERYONE IN ROOM
this.killEntitiesInCollapsingRoom(this.state.collapsingRoom.tileId);
}
}
}
if (!this.heroes) return;
this.heroes.forEach(hero => {
if (hero.buffs && hero.buffs.length > 0) {
// Decrement duration
hero.buffs.forEach(buff => {
// ... (existing buff logic if any)
});
}
});
}
killEntitiesInCollapsingRoom(tileId) {
if (this.onShowMessage) this.onShowMessage("¡DERRUMBE TOTAL!", "La sala se ha venido abajo. Todo lo que había dentro ha sido aplastado.", 5000);
// Find entities in this tile
const entitiesToKill = [];
// Heroes
this.heroes.forEach(h => {
const key = `${Math.floor(h.x)},${Math.floor(h.y)}`;
const tid = this.dungeon.grid.occupiedCells.get(key);
if (tid === tileId) entitiesToKill.push(h);
});
// Monsters
this.monsters.forEach(m => {
if (m.isDead) return;
const key = `${Math.floor(m.x)},${Math.floor(m.y)}`;
const tid = this.dungeon.grid.occupiedCells.get(key);
if (tid === tileId) entitiesToKill.push(m);
});
entitiesToKill.forEach(e => {
console.log(`[GameEngine] Crushed by Rockfall: ${e.name}`);
// Instant Kill logic
if (e.currentWounds) e.currentWounds = 0;
e.isDead = true;
if (this.onEntityDeath) this.onEntityDeath(e.id);
});
// Mark room as INTRANSITABLE (Blocked cells)
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tid === tileId) {
this.dungeon.grid.blockedCells.add(key);
}
}
if (this.onShowMessage) {
this.onShowMessage("¡DERRUMBE TOTAL!", "La estancia ha colapsado. Ahora es un montón de escombros intransitable.");
}
// Clear state so it doesn't kill again
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: 2 // Gives them Turn N + Turn N+1 to escape.
};
// 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 (isAdjacent) {
exitsToRemove.push(index);
const exitKey = `${exit.x},${exit.y}`;
this.placeEventMarker("escombros", exit.x, exit.y);
blockedLocations.add(exitKey);
// Immediately block movement through these cells
this.dungeon.grid.blockedCells.add(exitKey);
// 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);
});
}
console.log(`[GameEngine] Collapsed ${exitsToRemove.length} exit cells (${visualDoorCount} visual doors).`);
return visualDoorCount;
}
placeEventMarker(type, specificX = null, specificY = null) {
// Place a visual marker in the world
// If x,y not provided, place in center of heroes
let x = specificX;
let y = specificY;
if (x === null || y === null) {
// Center on leader
const leader = this.heroes[0];
x = leader.x;
y = leader.y;
}
console.log(`[GameEngine] Placing Marker '${type}' at ${x},${y}`);
// Delegate to Renderer
if (window.RENDERER) {
window.RENDERER.spawnProp(type, x, y);
}
}
blockPortcullisAtEntrance() {
if (this.lastEntranceUsed && window.RENDERER) {
window.RENDERER.blockDoorWithPortcullis(this.lastEntranceUsed);
if (window.SOUND_MANAGER) {
window.SOUND_MANAGER.playSound('gate_chains');
}
if (this.onShowMessage) {
this.onShowMessage("¡RASTRILLO!", "Un pesado rastrillo de hierro cae a vuestras espaldas, bloqueando la entrada.");
}
}
}
} }

View File

@@ -30,6 +30,12 @@ export class MagicSystem {
console.log(`[MagicSystem] Casting ${spell.name} by ${caster.name}`); console.log(`[MagicSystem] Casting ${spell.name} by ${caster.name}`);
// Deduct Power
this.game.turnManager.currentPowerRoll -= spell.cost;
// Update Hero Stat for UI
caster.stats.power = this.game.turnManager.currentPowerRoll;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(caster);
// Dispatch based on Spell Type // Dispatch based on Spell Type
// We could also look up a specific handler function map if this grows // We could also look up a specific handler function map if this grows
if (spell.type === 'heal') { if (spell.type === 'heal') {
@@ -98,29 +104,49 @@ export class MagicSystem {
// 4. Apply Damage to all targets // 4. Apply Damage to all targets
let hits = 0; let hits = 0;
let logDetails = [];
targetCells.forEach(cell => { targetCells.forEach(cell => {
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead); const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
if (monster) { if (monster) {
const damageDice = spell.damageDice || 1; const damageDice = spell.damageDice || 1;
let damageTotal = level; let diceTotal = 0;
let rolls = [];
for (let i = 0; i < damageDice; i++) { for (let i = 0; i < damageDice; i++) {
damageTotal += Math.floor(Math.random() * 6) + 1; const r = Math.floor(Math.random() * 6) + 1;
rolls.push(r);
diceTotal += r;
} }
// Apply Damage const damageTotal = level + diceTotal;
CombatMechanics.applyDamage(monster, damageTotal, this.game);
// We need to know Toughness for Log calculation display
// CombatMechanics.applyDamage(monster, damageTotal, this.game) assumes damageTotal is wounds?
// Wait, CombatMechanics.applyDamage subtracts amount from wounds directly.
// It does NOT calculate toughness reduction.
// BUT resolveMeleeAttack does: wounds = damageTotal - defTough.
// So Magic rules: Does Fireball ignore Toughness?
// WHQ Rulebook: "Strength of spell... deduct Toughness".
// So we MUST deduct Toughness here.
const toughness = monster.stats.toughness || 3;
let wounds = damageTotal - toughness;
if (wounds < 0) wounds = 0;
// Apply WOUNDS, not raw damage
CombatMechanics.applyDamage(monster, wounds, this.game);
hits++; hits++;
logDetails.push(`- <b>${monster.name}</b>: Daño ${damageTotal} (Nv${level}+Dado ${diceTotal}) - Res ${toughness} = <b>${wounds} Heridas</b>.`);
// Feedback // Feedback
if (this.game.onEntityHit) { if (this.game.onEntityHit) {
this.game.onEntityHit(monster.id); this.game.onEntityHit(monster.id);
} }
// Use Centralized Combat Feedback // Use Centralized Combat Feedback
window.RENDERER.showCombatFeedback(monster.x, monster.y, damageTotal, true); window.RENDERER.showCombatFeedback(monster.x, monster.y, wounds, true);
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage.`);
// Check Death (Handled by events usually, but ensuring cleanup if needed) // Check Death (Handled by events usually, but ensuring cleanup if needed)
if (monster.currentWounds <= 0 && !monster.isDead) { if (monster.currentWounds <= 0 && !monster.isDead) {
@@ -129,34 +155,26 @@ export class MagicSystem {
} }
} }
}); });
// Log the Spell Event
if (window.GAME && window.GAME.onShowMessage) {
// Hacky access to UI via main.js callback router or we add a new log method to game
// Let's use onLogEvent if it existed, or just mock a message
// We can use window.GAME.onCombatResult for a generic log? No, expects object.
// We'll trust that main.js maps onShowMessage 'Efecto' to log? Or add specific logic.
// Let's format a nice HTML block
const details = logDetails.join('<br>');
const msg = `Lanza <b>${spell.name}</b>!<br>${details}`;
// Prefix with 'Efecto' to trigger main.js log routing
if (this.game.onShowMessage) this.game.onShowMessage(`Efecto Mágico`, msg);
}
}); });
} else { } else {
// Fallback for no renderer (tests?) or race condition // Fallback Logic (simplified for brevity, identical calculation)
// Just apply damage immediately logic (duplicated for brevity check) return { success: true };
let hits = 0;
targetCells.forEach(cell => {
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
if (monster) {
const damageDice = spell.damageDice || 1;
let damageTotal = level;
for (let i = 0; i < damageDice; i++) {
damageTotal += Math.floor(Math.random() * 6) + 1;
}
CombatMechanics.applyDamage(monster, damageTotal, this.game);
hits++;
if (this.game.onEntityHit) {
this.game.onEntityHit(monster.id);
}
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage (no renderer).`);
if (monster.currentWounds <= 0 && !monster.isDead) {
monster.isDead = true;
if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id);
}
}
});
} }
return { success: true, type: 'attack', hits: 1 }; // Return success immediately return { success: true, type: 'attack', hits: 1 };
} }
resolveDefense(caster, spell, targetCells) { resolveDefense(caster, spell, targetCells) {
// Needs a target hero // Needs a target hero

View File

@@ -21,42 +21,25 @@ export class MonsterAI {
// Check for Summoning Sickness / Ambush delay // Check for Summoning Sickness / Ambush delay
if (monster.skipTurn) { if (monster.skipTurn) {
console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`); console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
monster.skipTurn = false; // Ready for next turn monster.skipTurn = false;
// Add a small visual delay even if skipping, to show focus?
// No, better to just skip significantly to keep flow fast.
continue; continue;
} }
await this.processMonster(monster); // Small "thinking" pause between monsters
await new Promise(r => setTimeout(r, 400));
await this.actMonster(monster);
} }
} }
processMonster(monster) {
return new Promise(resolve => {
// NO green ring here - only during attack
// Calculate delay based on potential move distance to ensure animation finishes
// SLOWER: 600ms per tile + Extra buffer for potential attack sequence
const moveTime = (monster.stats.move * 600) + 3000; // 3s buffer for attack sequence
setTimeout(() => { async actMonster(monster) {
this.actMonster(monster);
setTimeout(() => {
resolve();
}, moveTime);
}, 100);
});
}
actMonster(monster) {
// 1. Check if already adjacent (Engaged) -> ATTACK // 1. Check if already adjacent (Engaged) -> ATTACK
const adjacentHero = this.getAdjacentHero(monster); const adjacentHero = this.getAdjacentHero(monster);
if (adjacentHero) { if (adjacentHero) {
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`); console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
this.performAttack(monster, adjacentHero); await this.performAttack(monster, adjacentHero);
return; return;
} }
@@ -96,13 +79,16 @@ export class MonsterAI {
// 7. Check if NOW adjacent after move -> ATTACK // 7. Check if NOW adjacent after move -> ATTACK
// Wait for movement animation to complete before checking // Wait for movement animation to complete before checking
const movementDuration = actualPath.length * 600; const movementDuration = actualPath.length * 600;
setTimeout(() => { await new Promise(resolve => {
setTimeout(async () => {
const postMoveHero = this.getAdjacentHero(monster); const postMoveHero = this.getAdjacentHero(monster);
if (postMoveHero) { if (postMoveHero) {
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`); console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
this.performAttack(monster, postMoveHero); await this.performAttack(monster, postMoveHero);
} }
resolve();
}, movementDuration); }, movementDuration);
});
} }
getClosestHero(monster) { getClosestHero(monster) {
@@ -212,54 +198,43 @@ export class MonsterAI {
return bestPath; return bestPath;
} }
performAttack(monster, hero) { async performAttack(monster, hero) {
// SEQUENCE: const numAttacks = monster.stats.attacks || 1;
// 0. Show TARGET (Blue Ring) on Hero console.log(`[MonsterAI] ${monster.name} performing ${numAttacks} attacks against ${hero.name}`);
if (this.game.onRangedTarget) {
// Re-using onRangedTarget? Or directly calling renderer? for (let i = 0; i < numAttacks; i++) {
// Better to use a specific callback or direct call if available, or just add a new callback. if (hero.isDead || (hero.isConscious === false)) {
// But let's check if we can access renderer directly or use a new callback. break;
// The user prompt specifically asked for this feature.
// I'll assume we can use game.onEntityTarget if defined, or direct renderer call if needed,
// but standard pattern here is callbacks.
// Let's add onEntityTarget to GameEngine callbacks if not present, but for now I will try to use global RENDERER if possible
// OR simply define a new callback `this.game.onEntityTarget(hero.id, true)`.
} }
// Direct renderer call is safest given current context if we don't want to modify GameEngine interface heavily right now.
if (window.RENDERER && window.RENDERER.setEntityTarget) { if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, true); window.RENDERER.setEntityTarget(hero.id, true);
} }
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game); const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
// Step 1: Green ring on attacker
if (this.game.onEntityActive) { if (this.game.onEntityActive) {
this.game.onEntityActive(monster.id, true); this.game.onEntityActive(monster.id, true);
} }
// Step 2: Attack animation delay (500ms) await new Promise(resolve => {
setTimeout(() => {
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
setTimeout(() => { setTimeout(() => {
if (this.game.onEntityActive) { if (this.game.onEntityActive) {
this.game.onEntityActive(monster.id, false); this.game.onEntityActive(monster.id, false);
} }
// Remove Target Ring
if (window.RENDERER && window.RENDERER.setEntityTarget) { if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, false); window.RENDERER.setEntityTarget(hero.id, false);
} }
// Step 5: Show combat result after both rings are gone
setTimeout(() => {
if (this.game.onCombatResult) { if (this.game.onCombatResult) {
this.game.onCombatResult(result); this.game.onCombatResult(result);
} }
}, 200); // Small delay after rings disappear
}, 1200); // Wait for red ring to disappear // Snappier transition (800ms vs 1500ms)
}, 800); // Attack animation delay + focus time setTimeout(resolve, 800);
}, 500); // Wait 500ms for attack "focus"
});
}
} }
getAdjacentHero(entity) { getAdjacentHero(entity) {

View File

@@ -55,30 +55,39 @@ 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;
this.currentPowerRoll = roll; this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`); console.log(`Power Roll: ${roll}`);
let message = "The dungeon is quiet..."; let message = "El poder fluye...";
let eventTriggered = false; let eventTriggered = false;
// Placeholder for future Event Logic
if (roll === 1) { if (roll === 1) {
message = "UNEXPECTED EVENT! (Roll of 1)"; message = "¡EVENTO DE PODER! (1)";
eventTriggered = true; eventTriggered = true;
this.triggerRandomEvent();
} }
this.emit('POWER_RESULT', { roll, message, eventTriggered }); this.emit('POWER_RESULT', { roll, message, eventTriggered });
// Auto-advance to Hero phase after short delay (game feel) // Auto-advance only if NO event
if (!eventTriggered) {
setTimeout(() => { setTimeout(() => {
this.nextPhase(); this.nextPhase();
}, 2000); }, 2000);
} else {
console.log("TurnManager waiting for Event Resolution...");
}
} }
triggerRandomEvent() { triggerRandomEvent() {
console.warn("TODO: TRIGGER EVENT CARD DRAW"); // Deprecated: logic handled by GameEngine listener to card deck
} }
triggerExploration() { triggerExploration() {

View File

@@ -102,22 +102,38 @@ game.turnManager.on('phase_changed', (phase) => {
}); });
game.onCombatResult = (log) => { game.onCombatResult = (log) => {
ui.showCombatLog(log); // 1. Format Log Message
// Resolve names
// 1. Show Attack Roll on Attacker
// Find Attacker pos
const attacker = game.heroes.find(h => h.id === log.attackerId) || game.monsters.find(m => m.id === log.attackerId); const attacker = game.heroes.find(h => h.id === log.attackerId) || game.monsters.find(m => m.id === log.attackerId);
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
const atkName = attacker ? attacker.name : '???';
const defName = defender ? defender.name : '???';
// 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) {
type = log.woundsCaused > 0 ? 'combat-hit' : 'combat-miss'; // Or create 'combat-block' style
if (log.defenderDied) type = 'combat-kill';
}
ui.addLog(logMsg, type);
// 2. Show Attack Roll on Attacker (Floating)
if (attacker) { if (attacker) {
const rollColor = log.hitSuccess ? '#00ff00' : '#888888'; // Green vs Gray const rollColor = log.hitSuccess ? '#00ff00' : '#888888';
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor); renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
} }
// 2. Show Damage on Defender // 3. Show Damage on Defender (Floating)
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
if (defender) { if (defender) {
setTimeout(() => { // Slight delay for cause-effect setTimeout(() => { // Slight delay for cause-effect
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess); renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
}, 500); }, 300);
} }
}; };
@@ -135,6 +151,15 @@ game.onEntityHit = (entityId) => {
game.onEntityDeath = (entityId) => { game.onEntityDeath = (entityId) => {
renderer.triggerDeathAnimation(entityId); renderer.triggerDeathAnimation(entityId);
// Log death
const entity = game.heroes.find(h => h.id === entityId) || game.monsters.find(m => m.id === entityId);
if (entity) {
ui.addLog(`💀 <b>${entity.name}</b> ha caído.`, 'combat-kill');
}
};
game.onFloatingText = (x, y, text, color) => {
renderer.showFloatingText(x, y, text, color);
}; };
game.onRangedTarget = (targetMonster, losResult) => { game.onRangedTarget = (targetMonster, losResult) => {
@@ -152,15 +177,44 @@ game.onRangedTarget = (targetMonster, losResult) => {
if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`; if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`;
if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`; if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`;
ui.showTemporaryMessage('Objetivo Bloqueado', msg, 1500); // Use floating text on BLOCKER + Log instead of big message
const bx = losResult.blocker.entity ? losResult.blocker.entity.x : losResult.blocker.x;
const by = losResult.blocker.entity ? losResult.blocker.entity.y : losResult.blocker.y;
renderer.showFloatingText(bx, by, "Bloqueado", "#ff8800");
ui.addLog(msg, 'warning');
} }
} }
}; };
game.onShowMessage = (title, message, duration) => { game.onShowMessage = (title, message, duration) => {
// Filter specific game flow messages to Log instead of popup
const lowerTitle = title.toLowerCase();
if (title.startsWith('Turno de') ||
lowerTitle.includes('fase') ||
lowerTitle.includes('efecto') ||
lowerTitle.includes('evento') ||
lowerTitle.includes('selección')) {
let icon = '👉';
let type = 'system';
if (lowerTitle.includes('evento')) {
icon = '⚡';
type = 'event-log';
}
ui.addLog(`${icon} <b>${title}</b>: ${message}`, type);
return;
}
// 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) => {
@@ -242,13 +296,37 @@ const handleClick = (x, y, doorMesh) => {
// 2. Check Adjacency // 2. Check Adjacency
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) { if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
const wasPortcullis = doorMesh.userData.isPortcullis;
// 3. Check Key Requirement for Portcullis
if (doorMesh.userData.requiresKey) {
const hasKey = game.heroes.some(h => h.inventory && h.inventory.includes('llave_rastrillo'));
if (!hasKey) {
ui.showModal('Bloqueado', 'Esta puerta tiene un rastrillo bajado. Necesitáis la llave del enano para abrirla.');
return;
} else {
ui.showModal('¡Rastrillo Abierto!', 'Utilizáis la llave del enano para levantar el pesado rastrillo.');
// Clear flags so renderer allows opening
doorMesh.userData.requiresKey = false;
doorMesh.userData.isPortcullis = false;
}
}
// Open door visually // Open door visually
renderer.openDoor(doorMesh); renderer.openDoor(doorMesh);
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
if (window.SOUND_MANAGER) {
if (wasPortcullis) {
window.SOUND_MANAGER.playSound('gate_chains');
} else {
window.SOUND_MANAGER.playSound('door_open');
}
}
// Get proper exit data with direction // Get proper exit data with direction
const exitData = doorMesh.userData.exitData; const exitData = doorMesh.userData.exitData;
if (exitData) { if (exitData) {
game.lastEntranceUsed = exitData;
generator.selectDoor(exitData); generator.selectDoor(exitData);
} else { } else {
console.error('[Main] Door missing exitData'); console.error('[Main] Door missing exitData');

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);
} }
@@ -197,6 +204,10 @@ export class GameRenderer {
this.dungeonRenderer.blockDoor(exitData); this.dungeonRenderer.blockDoor(exitData);
} }
blockDoorWithPortcullis(exitData) {
this.dungeonRenderer.blockDoorWithPortcullis(exitData);
}
showRangedTargeting(hero, monster, losResult) { showRangedTargeting(hero, monster, losResult) {
this.interactionRenderer.showRangedTargeting(hero, monster, losResult); this.interactionRenderer.showRangedTargeting(hero, monster, losResult);
} }

View File

@@ -15,7 +15,8 @@ export class SoundManager {
'door_open': '/assets/sfx/opendoor.mp3', 'door_open': '/assets/sfx/opendoor.mp3',
'footsteps': '/assets/sfx/footsteps.mp3', 'footsteps': '/assets/sfx/footsteps.mp3',
'sword': '/assets/sfx/sword1.mp3', 'sword': '/assets/sfx/sword1.mp3',
'arrow': '/assets/sfx/arrow.mp3' 'arrow': '/assets/sfx/arrow.mp3',
'gate_chains': '/assets/sfx/gate_chains_open.mp3'
} }
}; };

View File

@@ -4,6 +4,7 @@ import { TurnStatusUI } from './ui/TurnStatusUI.js';
import { PlacementUI } from './ui/PlacementUI.js'; import { PlacementUI } from './ui/PlacementUI.js';
import { FeedbackUI } from './ui/FeedbackUI.js'; import { FeedbackUI } from './ui/FeedbackUI.js';
import { SpellbookUI } from './ui/SpellbookUI.js'; import { SpellbookUI } from './ui/SpellbookUI.js';
import { InventoryUI } from './ui/InventoryUI.js';
export class UIManager { export class UIManager {
constructor(cameraManager, gameEngine) { constructor(cameraManager, gameEngine) {
@@ -17,11 +18,13 @@ export class UIManager {
this.turnUI = new TurnStatusUI(this.container, gameEngine); this.turnUI = new TurnStatusUI(this.container, gameEngine);
this.feedback = new FeedbackUI(this.container, gameEngine); this.feedback = new FeedbackUI(this.container, gameEngine);
this.spellbook = new SpellbookUI(gameEngine); this.spellbook = new SpellbookUI(gameEngine);
this.inventory = new InventoryUI(gameEngine);
// Circular deps / callbacks // Circular deps / callbacks
const cardCallbacks = { const cardCallbacks = {
showModal: (t, m, c) => this.feedback.showModal(t, m, c), showModal: (t, m, c) => this.feedback.showModal(t, m, c),
toggleSpellBook: (h) => this.spellbook.toggle(h) toggleSpellBook: (h) => this.spellbook.toggle(h),
toggleInventory: (h) => this.inventory.toggle(h)
}; };
this.cards = new UnitCardManager(this.container, gameEngine, cardCallbacks); this.cards = new UnitCardManager(this.container, gameEngine, cardCallbacks);
@@ -94,6 +97,15 @@ export class UIManager {
} }
}; };
// Entity Update (Stats change like Wounds or Gold)
const originalUpdate = this.game.onEntityUpdate;
this.game.onEntityUpdate = (entity) => {
if (originalUpdate) originalUpdate(entity);
if (entity.type === 'hero') {
this.cards.updateHeroCard(entity.id);
}
};
// Turn Manager Events // Turn Manager Events
if (this.game.turnManager) { if (this.game.turnManager) {
this.game.turnManager.on('phase_changed', (phase) => { this.game.turnManager.on('phase_changed', (phase) => {
@@ -111,7 +123,9 @@ export class UIManager {
showModal(t, m, c) { this.feedback.showModal(t, m, c); } showModal(t, m, c) { this.feedback.showModal(t, m, c); }
showConfirm(t, m, c) { this.feedback.showConfirm(t, m, c); } showConfirm(t, m, c) { this.feedback.showConfirm(t, m, c); }
showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); } showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); }
showCombatLog(log) { this.feedback.showCombatLog(log); } showCombatLog(log) { this.feedback.addLogMessage(log.message, log.hitSuccess ? 'combat-hit' : 'combat-miss'); }
addLog(message, type) { this.feedback.addLogMessage(message, type); }
showEventCard(cardData, callback) { this.feedback.showEventCard(cardData, callback); }
showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); } showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); }
hideMonsterCard() { this.cards.hideMonsterCard(); } hideMonsterCard() { this.cards.hideMonsterCard(); }
} }

View File

@@ -306,6 +306,11 @@ export class DungeonRenderer {
// Load open door texture // Load open door texture
this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => { this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => {
// Safeguard: If it became a portcullis or blocked in the meantime, don't show as open
if (doorMesh.userData.isPortcullis || doorMesh.userData.isBlocked) {
console.log("[DungeonRenderer] openDoor callback skipped: already portcullis/blocked");
return;
}
doorMesh.material.map = texture; doorMesh.material.map = texture;
doorMesh.material.needsUpdate = true; doorMesh.material.needsUpdate = true;
doorMesh.userData.isOpen = true; doorMesh.userData.isOpen = true;
@@ -339,6 +344,46 @@ export class DungeonRenderer {
} }
} }
blockDoorWithPortcullis(exitData) {
if (!this.exitGroup || !exitData) return;
let targetDoor = null;
console.log(`[DungeonRenderer] Attempting to block door at ${exitData.x},${exitData.y}`);
for (const child of this.exitGroup.children) {
if (child.userData.isDoor) {
for (const cell of child.userData.cells) {
if (cell.x === exitData.x && cell.y === exitData.y) {
targetDoor = child;
break;
}
}
}
if (targetDoor) break;
}
if (targetDoor) {
console.log("[DungeonRenderer] Target door found for portcullis.");
this.getTexture('/assets/images/dungeon1/doors/door1_portcullis.png', (texture) => {
targetDoor.material.map = texture;
targetDoor.material.needsUpdate = true;
targetDoor.userData.isBlocked = false;
targetDoor.userData.isOpen = false;
targetDoor.userData.isPortcullis = true;
targetDoor.userData.requiresKey = true;
console.log("[DungeonRenderer] Portcullis texture applied.");
});
} else {
console.warn("[DungeonRenderer] Target door NOT found for portcullis at:", exitData);
// Debug: Log all door cells
this.exitGroup.children.forEach(d => {
if (d.userData.isDoor) {
console.log(" Door cells:", d.userData.cells.map(c => `${c.x},${c.y}`).join(" | "));
}
});
}
}
getDoorAtPosition(x, y) { getDoorAtPosition(x, y) {
if (!this.exitGroup) return null; if (!this.exitGroup) return null;
for (const child of this.exitGroup.children) { for (const child of this.exitGroup.children) {
@@ -364,4 +409,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

@@ -22,11 +22,17 @@ export class EntityRenderer {
addEntity(entity) { addEntity(entity) {
if (this.entities.has(entity.id)) return; if (this.entities.has(entity.id)) return;
// Mark as "loading" or "reserved" to prevent race conditions
this.entities.set(entity.id, 'PENDING');
const w = 1.04; const w = 1.04;
const h = 1.56; const h = 1.56;
const geometry = new THREE.PlaneGeometry(w, h); const geometry = new THREE.PlaneGeometry(w, h);
this.getTexture(entity.texturePath, (texture) => { this.getTexture(entity.texturePath, (texture) => {
// Check if we were removed while loading
if (!this.entities.has(entity.id)) return;
const material = new THREE.MeshBasicMaterial({ const material = new THREE.MeshBasicMaterial({
map: texture, map: texture,
transparent: true, transparent: true,
@@ -129,14 +135,14 @@ export class EntityRenderer {
moveEntityAlongPath(entity, path) { moveEntityAlongPath(entity, path) {
const mesh = this.entities.get(entity.id); const mesh = this.entities.get(entity.id);
if (mesh) { if (mesh instanceof THREE.Object3D) {
mesh.userData.pathQueue = [...path]; mesh.userData.pathQueue = [...path];
} }
} }
updateEntityPosition(entity) { updateEntityPosition(entity) {
const mesh = this.entities.get(entity.id); const mesh = this.entities.get(entity.id);
if (mesh) { if (mesh instanceof THREE.Object3D) {
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return; if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
mesh.position.set(entity.x, 1.56 / 2, -entity.y); mesh.position.set(entity.x, 1.56 / 2, -entity.y);
@@ -151,7 +157,7 @@ export class EntityRenderer {
toggleEntitySelection(entityId, isSelected) { toggleEntitySelection(entityId, isSelected) {
const mesh = this.entities.get(entityId); const mesh = this.entities.get(entityId);
if (mesh) { if (mesh instanceof THREE.Object3D) {
const ring = mesh.getObjectByName("SelectionRing"); const ring = mesh.getObjectByName("SelectionRing");
if (ring) ring.visible = isSelected; if (ring) ring.visible = isSelected;
} }
@@ -159,7 +165,7 @@ export class EntityRenderer {
setEntityActive(entityId, isActive) { setEntityActive(entityId, isActive) {
const mesh = this.entities.get(entityId); const mesh = this.entities.get(entityId);
if (!mesh) return; if (!(mesh instanceof THREE.Object3D)) return;
const oldRing = mesh.getObjectByName("ActiveRing"); const oldRing = mesh.getObjectByName("ActiveRing");
if (oldRing) mesh.remove(oldRing); if (oldRing) mesh.remove(oldRing);
@@ -183,7 +189,7 @@ export class EntityRenderer {
setEntityTarget(entityId, isTarget) { setEntityTarget(entityId, isTarget) {
const mesh = this.entities.get(entityId); const mesh = this.entities.get(entityId);
if (!mesh) return; if (!(mesh instanceof THREE.Object3D)) return;
const oldRing = mesh.getObjectByName("TargetRing"); const oldRing = mesh.getObjectByName("TargetRing");
if (oldRing) mesh.remove(oldRing); if (oldRing) mesh.remove(oldRing);
@@ -217,7 +223,7 @@ export class EntityRenderer {
triggerDamageEffect(entityId) { triggerDamageEffect(entityId) {
const mesh = this.entities.get(entityId); const mesh = this.entities.get(entityId);
if (!mesh) return; if (!(mesh instanceof THREE.Object3D)) return;
mesh.traverse((child) => { mesh.traverse((child) => {
if (child.material && child.material.map) { if (child.material && child.material.map) {
@@ -245,7 +251,7 @@ export class EntityRenderer {
triggerDeathAnimation(entityId) { triggerDeathAnimation(entityId) {
const mesh = this.entities.get(entityId); const mesh = this.entities.get(entityId);
if (!mesh) return; if (!(mesh instanceof THREE.Object3D)) return;
const startTime = performance.now(); const startTime = performance.now();
const duration = 1500; const duration = 1500;
@@ -272,6 +278,7 @@ export class EntityRenderer {
let isAnyMoving = false; let isAnyMoving = false;
this.entities.forEach((mesh, id) => { this.entities.forEach((mesh, id) => {
if (!(mesh instanceof THREE.Object3D)) return;
const data = mesh.userData; const data = mesh.userData;
if (!data.isMoving && data.pathQueue.length > 0) { if (!data.isMoving && data.pathQueue.length > 0) {

View File

@@ -1,28 +1,80 @@
export class FeedbackUI { export class FeedbackUI {
constructor(parentContainer, game) { constructor(parentContainer, game) {
this.parentContainer = parentContainer; this.parentContainer = parentContainer;
this.game = game; // Needed for resolving hero names/ids in logs? this.game = game;
this.combatLogContainer = null; this.logContainer = null;
this.initCombatLogContainer(); this.initLogContainer();
} }
initCombatLogContainer() { initLogContainer() {
this.combatLogContainer = document.createElement('div'); this.logContainer = document.createElement('div');
Object.assign(this.combatLogContainer.style, { Object.assign(this.logContainer.style, {
position: 'absolute', position: 'absolute',
top: '140px', // Below the top status panel top: '20%', // Leave space for top HUD
left: '50%', right: '20px',
transform: 'translateX(-50%)', width: '350px',
height: '60vh', // Fixed height or max height? User said "muy pequeño". Let's give it good vertical space.
maxHeight: 'none',
overflowY: 'auto',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'flex-start', // Align text to left
pointerEvents: 'none', pointerEvents: 'none', // Allow clicking through if needed, but 'auto' for scroll?
width: '100%', // We need pointerEvents auto for scrolling.
maxWidth: '600px', pointerEvents: 'auto',
zIndex: '500' // Below modals zIndex: '400',
fontFamily: '"Cinzel", serif',
scrollbarWidth: 'thin',
scrollbarColor: '#444 #222'
}); });
this.parentContainer.appendChild(this.combatLogContainer);
// Add a subtle background
this.logContainer.style.background = 'linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0))';
this.logContainer.style.padding = '10px';
this.logContainer.style.borderRadius = '8px';
this.logContainer.style.borderRight = '2px solid #555';
this.parentContainer.appendChild(this.logContainer);
}
addLogMessage(message, type = 'info') {
const entry = document.createElement('div');
Object.assign(entry.style, {
width: '100%',
marginBottom: '6px',
fontSize: '14px',
color: '#ccc',
textShadow: '1px 1px 0 #000',
opacity: '0',
transition: 'opacity 0.3s',
lineHeight: '1.4'
});
// Color coding based on type
if (type === 'combat-hit') entry.style.color = '#ff6666';
if (type === 'combat-miss') entry.style.color = '#aaaaaa';
if (type === 'combat-kill') entry.style.color = '#ff3333';
if (type === 'success') entry.style.color = '#66ff66';
if (type === 'warning') entry.style.color = '#ffcc00';
if (type === 'system') entry.style.color = '#88ccff';
if (type === 'event-log') entry.style.color = '#44ff44'; // Bright Green
entry.innerHTML = message;
this.logContainer.appendChild(entry);
// Auto scroll to bottom
this.logContainer.scrollTop = this.logContainer.scrollHeight;
// Fade In
requestAnimationFrame(() => { entry.style.opacity = '1'; });
// Optional: Fade out very old messages? Or keep history?
// Let's keep history for now, maybe limit children coune
if (this.logContainer.children.length > 50) {
this.logContainer.removeChild(this.logContainer.firstChild);
}
} }
showModal(title, message, onClose) { showModal(title, message, onClose) {
@@ -56,7 +108,7 @@ export class FeedbackUI {
backgroundColor: '#444', color: '#fff', border: '1px solid #888' backgroundColor: '#444', color: '#fff', border: '1px solid #888'
}); });
btn.onclick = () => { btn.onclick = () => {
if (overlay.parentNode /** Checks if attached */) this.parentContainer.removeChild(overlay); if (overlay.parentNode) this.parentContainer.removeChild(overlay);
if (onClose) onClose(); if (onClose) onClose();
}; };
content.appendChild(btn); content.appendChild(btn);
@@ -118,10 +170,113 @@ 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, {
position: 'absolute', top: '25%', left: '50%', transform: 'translate(-50%, -50%)', position: 'absolute', top: '70%', left: '50%', transform: 'translate(-50%, -50%)',
backgroundColor: 'rgba(139, 0, 0, 0.9)', color: '#fff', padding: '15px 30px', backgroundColor: 'rgba(139, 0, 0, 0.9)', color: '#fff', padding: '15px 30px',
borderRadius: '8px', border: '2px solid #ff4444', fontFamily: '"Cinzel", serif', borderRadius: '8px', border: '2px solid #ff4444', fontFamily: '"Cinzel", serif',
fontSize: '20px', textShadow: '2px 2px 4px black', zIndex: '2000', pointerEvents: 'none', fontSize: '20px', textShadow: '2px 2px 4px black', zIndex: '2000', pointerEvents: 'none',
@@ -144,53 +299,4 @@ export class FeedbackUI {
}, 500); }, 500);
}, duration); }, duration);
} }
showCombatLog(log) {
const isHit = log.hitSuccess;
const color = isHit ? '#ff4444' : '#aaaaaa';
let detailHtml = '';
if (isHit) {
if (log.woundsCaused > 0) {
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
} else {
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
}
} else {
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
}
// We create a new log element or update a singleton?
// The original logic updated a SINGLE notification area.
// Let's create a transient toast style log here, appending to container.
const logItem = document.createElement('div');
Object.assign(logItem.style, {
backgroundColor: 'rgba(0,0,0,0.9)', padding: '15px', border: `2px solid ${color}`,
borderRadius: '5px', textAlign: 'center', minWidth: '250px', marginBottom: '10px',
fontFamily: '"Cinzel", serif', opacity: '0', transition: 'opacity 0.3s'
});
logItem.innerHTML = `
<div style="font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
${detailHtml}
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
`;
// Clear previous logs to act like the single notification area of before, OR stack them?
// Original behavior was overwrite `innerHTML`. I should stick to that to avoid spam.
// So I will clear `combatLogContainer` before adding.
this.combatLogContainer.innerHTML = '';
this.combatLogContainer.appendChild(logItem);
// Fade in
requestAnimationFrame(() => { logItem.style.opacity = '1'; });
// Fade out
setTimeout(() => {
logItem.style.opacity = '0';
// We don't remove immediately to avoid layout jumps if another comes in,
// but we cleared logic above.
}, 3500);
}
} }

145
src/view/ui/InventoryUI.js Normal file
View File

@@ -0,0 +1,145 @@
export class InventoryUI {
constructor(game) {
this.game = game;
this.container = null;
}
toggle(hero) {
if (this.container) {
document.body.removeChild(this.container);
this.container = null;
return;
}
if (!hero) return;
const container = document.createElement('div');
Object.assign(container.style, {
position: 'absolute',
bottom: '140px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
flexDirection: 'column',
gap: '15px',
backgroundColor: 'rgba(30, 20, 10, 0.95)',
padding: '20px',
borderRadius: '12px',
border: '3px solid #8B4513',
zIndex: '1500',
boxShadow: '0 0 30px rgba(139, 69, 19, 0.6)',
minWidth: '300px',
maxWidth: '600px',
transition: 'all 0.3s ease-out',
pointerEvents: 'auto'
});
const title = document.createElement('div');
title.textContent = `MOCHILA DE ${hero.name.toUpperCase()}`;
Object.assign(title.style, {
textAlign: 'center',
color: '#DAA520',
fontFamily: '"Cinzel", serif',
fontSize: '22px',
marginBottom: '10px',
textShadow: '2px 2px 4px #000',
borderBottom: '1px solid #555',
paddingBottom: '10px'
});
container.appendChild(title);
const itemsContainer = document.createElement('div');
Object.assign(itemsContainer.style, {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))',
gap: '10px',
maxHeight: '300px',
overflowY: 'auto',
padding: '5px'
});
container.appendChild(itemsContainer);
const inventory = hero.inventory || [];
if (inventory.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = "La mochila está vacía...";
Object.assign(emptyMsg.style, {
textAlign: 'center',
color: '#888',
fontStyle: 'italic',
gridColumn: '1 / -1',
padding: '20px'
});
itemsContainer.appendChild(emptyMsg);
} else {
inventory.forEach((itemId, index) => {
const itemEl = this.createItemElement(itemId);
itemsContainer.appendChild(itemEl);
});
}
// Close button
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Cerrar';
Object.assign(closeBtn.style, {
marginTop: '15px',
padding: '8px',
backgroundColor: '#444',
color: '#fff',
border: '1px solid #777',
borderRadius: '4px',
cursor: 'pointer',
fontFamily: '"Cinzel", serif'
});
closeBtn.onclick = () => this.toggle();
container.appendChild(closeBtn);
document.body.appendChild(container);
this.container = container;
}
createItemElement(itemId) {
const item = document.createElement('div');
Object.assign(item.style, {
width: '80px',
height: '80px',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
border: '2px solid #DAA520',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden'
});
const icon = document.createElement('div');
icon.style.fontSize = '32px';
const label = document.createElement('div');
label.style.fontSize = '10px';
label.style.textAlign = 'center';
label.style.marginTop = '4px';
// Item Database (Simple)
if (itemId === 'llave_rastrillo') {
icon.textContent = '🔑';
label.textContent = 'Llave Rastrillo';
} else {
icon.textContent = '📦';
label.textContent = itemId;
}
item.appendChild(icon);
item.appendChild(label);
item.onmouseenter = () => { item.style.backgroundColor = 'rgba(218, 165, 32, 0.2)'; };
item.onmouseleave = () => { item.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; };
return item;
}
}

View File

@@ -199,6 +199,9 @@ export class TurnStatusUI {
<div style="font-size: 14px;"> <div style="font-size: 14px;">
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move} Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
</div> </div>
<div style="font-size: 14px;">
Oro: <span style="color: #DAA520; font-weight: bold;">${hero.stats.gold || 0}</span> 🪙
</div>
</div> </div>
</div> </div>
`; `;

View File

@@ -7,6 +7,7 @@ export class UnitCardManager {
this.cardsContainer = null; this.cardsContainer = null;
this.currentHeroCard = null; this.currentHeroCard = null;
this.currentMonsterCard = null; this.currentMonsterCard = null;
this.monsterContainer = null;
this.placeholderCard = null; this.placeholderCard = null;
this.attackButton = null; this.attackButton = null;
@@ -20,10 +21,10 @@ export class UnitCardManager {
left: '10px', left: '10px',
top: '220px', // Below minimap top: '220px', // Below minimap
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'row',
gap: '10px', alignItems: 'flex-start',
pointerEvents: 'auto', gap: '15px',
width: '200px' pointerEvents: 'auto'
}); });
this.parentContainer.appendChild(this.cardsContainer); this.parentContainer.appendChild(this.cardsContainer);
@@ -106,18 +107,18 @@ export class UnitCardManager {
const hero = this.game.heroes.find(h => h.id === heroId); const hero = this.game.heroes.find(h => h.id === heroId);
if (!hero) return; if (!hero) return;
const statsGrid = this.currentHeroCard.querySelector('div[style*="grid-template-columns"]'); // NEW: Update stats using data-attributes for robustness
if (statsGrid) { const updateStat = (key, value) => {
const statDivs = statsGrid.children; const el = this.currentHeroCard.querySelector(`[data-stat="${key}"]`);
// Assumed order: 4 -> Heridas, 7 -> Movimiento if (el) el.textContent = value;
if (statDivs[4]) { };
const wValue = statDivs[4].querySelector('span:last-child');
if (wValue) wValue.textContent = `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`; updateStat('Her', `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`);
} updateStat('Mov', `${hero.currentMoves || 0}/${hero.stats.move}`);
if (statDivs[7]) { updateStat('Oro', hero.stats.gold || 0);
const movValue = statDivs[7].querySelector('span:last-child');
if (movValue) movValue.textContent = `${hero.currentMoves || 0}/${hero.stats.move}`; if (hero.key === 'wizard') {
} updateStat('Pod', hero.stats.power || 0);
} }
} }
@@ -191,15 +192,22 @@ export class UnitCardManager {
{ label: 'Her', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` }, { label: 'Her', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` },
{ label: 'Ini', value: hero.stats.initiative || 0 }, { label: 'Ini', value: hero.stats.initiative || 0 },
{ label: 'Ata', value: hero.stats.attacks || 0 }, { label: 'Ata', value: hero.stats.attacks || 0 },
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` } { label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` },
{ label: 'Oro', value: hero.stats.gold || 0 }
]; ];
// USER REQUEST: Show Power for Wizard
if (hero.key === 'wizard') {
stats.push({ label: 'Pod', value: hero.stats.power || 0 });
}
stats.forEach(stat => { stats.forEach(stat => {
const el = document.createElement('div'); const el = document.createElement('div');
Object.assign(el.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)', padding: '3px 5px', borderRadius: '3px', display: 'flex', justifyContent: 'space-between' }); Object.assign(el.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)', padding: '3px 5px', borderRadius: '3px', display: 'flex', justifyContent: 'space-between' });
const l = document.createElement('span'); l.textContent = stat.label + ':'; l.style.color = '#AAA'; const l = document.createElement('span'); l.textContent = stat.label + ':'; l.style.color = '#AAA';
const v = document.createElement('span'); v.textContent = stat.value; v.style.color = '#FFF'; v.style.fontWeight = 'bold'; const v = document.createElement('span'); v.textContent = stat.value; v.style.color = '#FFF'; v.style.fontWeight = 'bold';
v.dataset.stat = stat.label; // Add data attribute for easier updates
el.appendChild(l); el.appendChild(v); el.appendChild(l); el.appendChild(v);
statsGrid.appendChild(el); statsGrid.appendChild(el);
@@ -280,11 +288,14 @@ export class UnitCardManager {
const invBtn = document.createElement('button'); const invBtn = document.createElement('button');
invBtn.textContent = '🎒 INVENTARIO'; invBtn.textContent = '🎒 INVENTARIO';
Object.assign(invBtn.style, { Object.assign(invBtn.style, {
width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#444', width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#5D4037',
color: '#fff', border: '1px solid #777', borderRadius: '4px', color: '#fff', border: '1px solid #8B4513', borderRadius: '4px',
fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer' // Changed cursor to pointer for feel, though functionality implies future fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer'
}); });
invBtn.title = 'Inventario (Próximamente)'; invBtn.onclick = (e) => {
e.stopPropagation();
if (this.callbacks.toggleInventory) this.callbacks.toggleInventory(hero);
};
card.appendChild(invBtn); card.appendChild(invBtn);
@@ -365,8 +376,17 @@ export class UnitCardManager {
showMonsterCard(monster) { showMonsterCard(monster) {
this.hideMonsterCard(); this.hideMonsterCard();
// Create a sub-container for monster card + button
this.monsterContainer = document.createElement('div');
Object.assign(this.monsterContainer.style, {
display: 'flex',
flexDirection: 'column',
gap: '8px'
});
this.currentMonsterCard = this.createMonsterCard(monster); this.currentMonsterCard = this.createMonsterCard(monster);
this.cardsContainer.appendChild(this.currentMonsterCard); this.monsterContainer.appendChild(this.currentMonsterCard);
this.attackButton = document.createElement('button'); this.attackButton = document.createElement('button');
this.attackButton.textContent = '⚔️ ATACAR'; this.attackButton.textContent = '⚔️ ATACAR';
@@ -384,7 +404,6 @@ export class UnitCardManager {
const result = this.game.performHeroAttack(monster.id); const result = this.game.performHeroAttack(monster.id);
if (result && result.success) { if (result && result.success) {
this.hideMonsterCard(); this.hideMonsterCard();
// Optional: deselect monster logic if managed externally
if (this.game.selectedMonster) { if (this.game.selectedMonster) {
if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false); if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false);
this.game.selectedMonster = null; this.game.selectedMonster = null;
@@ -392,7 +411,8 @@ export class UnitCardManager {
} }
} }
}; };
this.cardsContainer.appendChild(this.attackButton); this.monsterContainer.appendChild(this.attackButton);
this.cardsContainer.appendChild(this.monsterContainer);
} }
showRangedAttackUI(monster) { showRangedAttackUI(monster) {
@@ -417,13 +437,11 @@ export class UnitCardManager {
} }
hideMonsterCard() { hideMonsterCard() {
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) { if (this.monsterContainer && this.monsterContainer.parentNode) {
this.cardsContainer.removeChild(this.currentMonsterCard); this.cardsContainer.removeChild(this.monsterContainer);
this.currentMonsterCard = null;
} }
if (this.attackButton && this.attackButton.parentNode) { this.monsterContainer = null;
this.cardsContainer.removeChild(this.attackButton); this.currentMonsterCard = null;
this.attackButton = null; this.attackButton = null;
} }
} }
}