Compare commits
5 Commits
613fa843ee
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2046aa7117 | |||
| da4c93bf98 | |||
| 83882b25ba | |||
| 82bdcacf95 | |||
| e22cd071c4 |
183
DEVLOG.md
@@ -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)
|
||||
**Fecha:** 9 de Enero de 2026
|
||||
|
||||
BIN
public/assets/images/dungeon1/doors/door1_portcullis.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/assets/images/dungeon1/markers/rubbles.png
Normal file
|
After Width: | Height: | Size: 13 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 2.3 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/Lordwarlock_1.png
Normal file
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/minotaur.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/images/dungeon1/tiles/room_4x4_clean.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/assets/images/dungeon1/tiles/room_4x4_placeholder.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/images/dungeon1/tiles/room_4x4_skull.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/assets/images/dungeon1/tiles/room_4x8_placeholder.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/assets/images/dungeon1/tokens/enemies/minotaur.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
public/assets/sfx/gate_chains_close.mp3
Normal file
BIN
public/assets/sfx/gate_chains_open.mp3
Normal file
260
src/engine/data/EventCards.js
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -1,30 +1,13 @@
|
||||
import { EVENT_CARDS_DATA } from './EventCards.js';
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
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 = () => {
|
||||
// As per user request: 10 copies of the same card for now
|
||||
const deck = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
deck.push({ ...EVENT_DEFINITIONS[0] });
|
||||
}
|
||||
// Return a deep copy of the definition data to avoid mutation issues
|
||||
const deck = EVENT_CARDS_DATA.map(card => JSON.parse(JSON.stringify(card)));
|
||||
return shuffleDeck(deck);
|
||||
};
|
||||
|
||||
@@ -33,5 +16,6 @@ const shuffleDeck = (deck) => {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[deck[i], deck[j]] = [deck[j], deck[i]];
|
||||
}
|
||||
|
||||
return deck;
|
||||
};
|
||||
|
||||
@@ -6,13 +6,14 @@ export const HERO_DEFINITIONS = {
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 3,
|
||||
to_hit_missile: 5, // 5+ to hit with ranged
|
||||
to_hit_missile: 5,
|
||||
str: 4,
|
||||
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas)
|
||||
wounds: 12, // 1D6 + 9 (Using fixed average for now)
|
||||
toughness: 4,
|
||||
wounds: 12,
|
||||
attacks: 1,
|
||||
init: 3,
|
||||
pin_target: 6 // 6+ to escape pin
|
||||
pin_target: 6,
|
||||
gold: 0
|
||||
}
|
||||
},
|
||||
dwarf: {
|
||||
@@ -22,13 +23,14 @@ export const HERO_DEFINITIONS = {
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 4,
|
||||
to_hit_missile: 5, // 5+ to hit with ranged
|
||||
to_hit_missile: 5,
|
||||
str: 3,
|
||||
toughness: 5, // 4 Base + 1 Armor (Cota de Malla)
|
||||
wounds: 11, // 1D6 + 8 (Using fixed average for now)
|
||||
toughness: 5,
|
||||
wounds: 11,
|
||||
attacks: 1,
|
||||
init: 2,
|
||||
pin_target: 5 // 5+ to escape pin
|
||||
pin_target: 5,
|
||||
gold: 0
|
||||
}
|
||||
},
|
||||
elf: {
|
||||
@@ -38,14 +40,15 @@ export const HERO_DEFINITIONS = {
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 4,
|
||||
bs: 4, // Added for Bow
|
||||
to_hit_missile: 4, // 4+ to hit with ranged
|
||||
bs: 4,
|
||||
to_hit_missile: 4,
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 10, // 1D6 + 7 (Using fixed average for now)
|
||||
wounds: 10,
|
||||
attacks: 1,
|
||||
init: 6,
|
||||
pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
|
||||
pin_target: 1,
|
||||
gold: 0
|
||||
}
|
||||
},
|
||||
wizard: {
|
||||
@@ -55,14 +58,15 @@ export const HERO_DEFINITIONS = {
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 2,
|
||||
to_hit_missile: 6, // 6+ to hit with ranged
|
||||
to_hit_missile: 6,
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 9, // 1D6 + 6 (Using fixed average for now)
|
||||
wounds: 9,
|
||||
attacks: 1,
|
||||
init: 3,
|
||||
power: 0, // Tracks current power points
|
||||
pin_target: 4 // 4+ to escape pin
|
||||
power: 0,
|
||||
pin_target: 4,
|
||||
gold: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export const MONSTER_DEFINITIONS = {
|
||||
gold: 55 // Card: "Valor 55x Unidad"
|
||||
}
|
||||
},
|
||||
// Fix duplicate wounds key in goblin
|
||||
goblin_spearman: {
|
||||
id: 'goblin_spearman',
|
||||
name: 'Lancero Goblin',
|
||||
@@ -22,7 +23,6 @@ export const MONSTER_DEFINITIONS = {
|
||||
ws: 2,
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 3,
|
||||
wounds: 1,
|
||||
attacks: 1,
|
||||
gold: 20,
|
||||
@@ -47,7 +47,7 @@ export const MONSTER_DEFINITIONS = {
|
||||
giant_spider: {
|
||||
id: 'giant_spider',
|
||||
name: 'Araña Gigante',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/spider.png',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/spiderGiant.png',
|
||||
stats: {
|
||||
move: 6,
|
||||
ws: 2,
|
||||
@@ -88,5 +88,33 @@ export const MONSTER_DEFINITIONS = {
|
||||
gold: 440,
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,61 +9,53 @@ export class DungeonDeck {
|
||||
}
|
||||
|
||||
generateMissionDeck(objectiveTileId) {
|
||||
|
||||
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 = [];
|
||||
const composition = [
|
||||
{ id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms
|
||||
{ id: 'corridor_straight', count: 0 },
|
||||
{ id: 'junction_t', count: 0 }
|
||||
{ id: 'room_subterranean_1', count: 1 },
|
||||
{ id: 'room_subterranean_2', count: 1 },
|
||||
{ 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 => {
|
||||
// FIXED: Access by Key string directly
|
||||
const tileDef = TILES[item.id];
|
||||
|
||||
if (tileDef) {
|
||||
for (let i = 0; i < item.count; i++) {
|
||||
pool.push(tileDef);
|
||||
pool.push({ ...tileDef }); // Push a copy
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Missing Tile Definition for ID: ${item.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
const drawRandom = (source, count) => {
|
||||
const drawn = [];
|
||||
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;
|
||||
};
|
||||
// Shuffle the initial pool
|
||||
this.shuffleArray(pool);
|
||||
|
||||
// --- Step 1 & 2: Bottom Pool ---
|
||||
const bottomPool = drawRandom(pool, 6);
|
||||
// --- Step 1: Bottom 6 + Objective (shuffled together) ---
|
||||
const bottomHalf = pool.splice(0, 6);
|
||||
const objectiveDef = TILES[objectiveTileId] || TILES['room_objective_1'];
|
||||
bottomHalf.push({ ...objectiveDef });
|
||||
this.shuffleArray(bottomHalf);
|
||||
|
||||
const objectiveDef = TILES[objectiveTileId];
|
||||
if (objectiveDef) {
|
||||
bottomPool.push(objectiveDef);
|
||||
} 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 2: Top 6 ---
|
||||
const topHalf = pool.splice(0, 6);
|
||||
this.shuffleArray(topHalf);
|
||||
|
||||
// --- 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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { DIRECTIONS } from './Constants.js';
|
||||
import { DIRECTIONS, TILE_TYPES } from './Constants.js';
|
||||
import { GridSystem } from './GridSystem.js';
|
||||
import { DungeonDeck } from './DungeonDeck.js';
|
||||
import { TILES } from './TileDefinitions.js';
|
||||
|
||||
const PLACEMENT_STATE = {
|
||||
WAITING_DOOR: 'WAITING_DOOR',
|
||||
@@ -31,7 +31,13 @@ export class DungeonGenerator {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 1. Draw and place first card automatically at origin
|
||||
@@ -286,11 +292,17 @@ export class DungeonGenerator {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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) {
|
||||
const gx = anchorX + ex.x;
|
||||
const gy = anchorY + ex.y;
|
||||
@@ -298,28 +310,86 @@ export class DungeonGenerator {
|
||||
const leadingTo = this.neighbor(gx, gy, ex.direction);
|
||||
const isOccupied = this.grid.isOccupied(leadingTo.x, leadingTo.y);
|
||||
|
||||
|
||||
|
||||
if (!isOccupied) {
|
||||
this.availableExits.push({
|
||||
potentialExits.push({
|
||||
x: gx,
|
||||
y: gy,
|
||||
direction: ex.direction,
|
||||
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 => {
|
||||
const leadingTo = this.neighbor(exit.x, exit.y, exit.direction);
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,9 @@ export class GridSystem {
|
||||
// Set of "x,y" strings that are door/exit cells (can cross room boundaries)
|
||||
this.doorCells = new Set();
|
||||
|
||||
// Set of "x,y" strings that are blocked (e.g. collapsed)
|
||||
this.blockedCells = new Set();
|
||||
|
||||
this.tiles = [];
|
||||
}
|
||||
|
||||
@@ -141,7 +144,12 @@ export class GridSystem {
|
||||
* Helper to see if a specific global coordinate is occupied
|
||||
*/
|
||||
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 data2 = this.cellData.get(key2);
|
||||
|
||||
// Both cells must exist
|
||||
// Both cells must exist and not be blocked
|
||||
if (!data1 || !data2) return false;
|
||||
if (this.blockedCells.has(key1) || this.blockedCells.has(key2)) return false;
|
||||
|
||||
const sameTile = data1.tileId === data2.tileId;
|
||||
const isDoor1 = this.doorCells.has(key1);
|
||||
|
||||
@@ -295,18 +295,94 @@ export const TILES = {
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ROOM DUNGEON
|
||||
// SUBTERRANEAN ROOMS (4x4)
|
||||
// -------------------------------------------------------------------------
|
||||
'room_dungeon': {
|
||||
id: 'room_dungeon',
|
||||
name: 'Dungeon Room',
|
||||
'room_subterranean_1': {
|
||||
id: 'room_subterranean_1',
|
||||
name: 'Circle of Power',
|
||||
type: TILE_TYPES.ROOM,
|
||||
textures: [
|
||||
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
|
||||
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
|
||||
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
|
||||
],
|
||||
variants: {
|
||||
textures: ['/assets/images/dungeon1/tiles/room_4x4_circle.png'],
|
||||
variants: getStandard4x4Variants()
|
||||
},
|
||||
'room_subterranean_2': {
|
||||
id: 'room_subterranean_2',
|
||||
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]: {
|
||||
width: 4, height: 4,
|
||||
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 }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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: {
|
||||
function getStandard4x8Variants() {
|
||||
return {
|
||||
[DIRECTIONS.NORTH]: {
|
||||
width: 4, height: 8,
|
||||
layout: [
|
||||
[1, 1, 1, 1], [1, 1, 1, 1], [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: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
|
||||
]
|
||||
layout: Array(8).fill([1, 1, 1, 1]),
|
||||
exits: [{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }]
|
||||
},
|
||||
[DIRECTIONS.EAST]: {
|
||||
width: 8, height: 4,
|
||||
layout: [
|
||||
[1, 1, 1, 1, 1, 1, 1, 1], [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 }
|
||||
]
|
||||
layout: Array(4).fill([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]: {
|
||||
width: 4, height: 8,
|
||||
layout: [
|
||||
[1, 1, 1, 1], [1, 1, 1, 1], [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: 1, y: 7, direction: DIRECTIONS.NORTH }, { x: 2, y: 7, direction: DIRECTIONS.NORTH }
|
||||
]
|
||||
layout: Array(8).fill([1, 1, 1, 1]),
|
||||
exits: [{ x: 1, y: 7, direction: DIRECTIONS.NORTH }, { x: 2, y: 7, direction: DIRECTIONS.NORTH }]
|
||||
},
|
||||
[DIRECTIONS.WEST]: {
|
||||
width: 8, height: 4,
|
||||
layout: [
|
||||
[1, 1, 1, 1, 1, 1, 1, 1], [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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
layout: Array(4).fill([1, 1, 1, 1, 1, 1, 1, 1]),
|
||||
exits: [{ x: 7, y: 1, direction: DIRECTIONS.EAST }, { x: 7, y: 2, direction: DIRECTIONS.EAST }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
356
src/engine/events/EventInterpreter.js
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export class CombatMechanics {
|
||||
|
||||
if (log.hitRoll < log.targetToHit) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
|
||||
log.message = `💨 ${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
|
||||
return log;
|
||||
}
|
||||
|
||||
@@ -70,11 +70,10 @@ export class CombatMechanics {
|
||||
damageSum += r;
|
||||
}
|
||||
|
||||
log.damageRoll = damageSum; // Just sum for simple log, or we could array it
|
||||
log.damageRoll = damageSum;
|
||||
log.damageTotal = damageSum + attStr;
|
||||
|
||||
// 4. Calculate Wounds
|
||||
// Wounds = (Dice + Str) - Toughness
|
||||
let wounds = log.damageTotal - defTough;
|
||||
if (wounds < 0) wounds = 0;
|
||||
|
||||
@@ -82,9 +81,9 @@ export class CombatMechanics {
|
||||
|
||||
// 5. Build Message
|
||||
if (wounds > 0) {
|
||||
log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||
log.message = `⚔️ ${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||
} else {
|
||||
log.message = `${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||
log.message = `🛡️ ${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||
}
|
||||
|
||||
// 6. Apply Damage to Defender State
|
||||
@@ -92,9 +91,9 @@ export class CombatMechanics {
|
||||
|
||||
if (defender.isDead) {
|
||||
log.defenderDied = true;
|
||||
log.message += ` ¡${defender.name} ha muerto!`;
|
||||
log.message += ` 💀 ¡${defender.name} ha muerto!`;
|
||||
} else if (defender.isUnconscious) {
|
||||
log.message += ` ¡${defender.name} cae inconsciente!`;
|
||||
log.message += ` 💀 ¡${defender.name} cae inconsciente!`;
|
||||
}
|
||||
|
||||
return log;
|
||||
@@ -123,20 +122,19 @@ export class CombatMechanics {
|
||||
|
||||
if (hitRoll === 1) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `${attacker.name} dispara y falla (1 es fallo automático)`;
|
||||
log.message = `💨 ${attacker.name} dispara y falla (1 es fallo automático)`;
|
||||
return log;
|
||||
}
|
||||
|
||||
if (hitRoll < toHitTarget) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
|
||||
log.message = `💨 ${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
|
||||
return log;
|
||||
}
|
||||
|
||||
log.hitSuccess = true;
|
||||
|
||||
// 2. Roll Damage
|
||||
// Elf Bow Strength = 3
|
||||
const weaponStrength = 3;
|
||||
const damageRoll = this.rollD6();
|
||||
const damageTotal = weaponStrength + damageRoll;
|
||||
@@ -151,9 +149,9 @@ export class CombatMechanics {
|
||||
|
||||
// 4. Build Message
|
||||
if (wounds > 0) {
|
||||
log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||
log.message = `⚔️ ${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||
} else {
|
||||
log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||
log.message = `🛡️ ${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||
}
|
||||
|
||||
// 5. Apply Damage
|
||||
@@ -161,7 +159,7 @@ export class CombatMechanics {
|
||||
|
||||
if (defender.isDead) {
|
||||
log.defenderDied = true;
|
||||
log.message += ` ¡${defender.name} ha muerto!`;
|
||||
log.message += ` 💀 ¡${defender.name} ha muerto!`;
|
||||
}
|
||||
|
||||
return log;
|
||||
|
||||
@@ -42,6 +42,18 @@ export class CombatSystem {
|
||||
// 3. Update State
|
||||
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)
|
||||
if (window.SOUND_MANAGER) {
|
||||
// Logic to choose sound could be expanded here based on Weapon Type
|
||||
@@ -87,6 +99,18 @@ export class CombatSystem {
|
||||
// 3. Update State
|
||||
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
|
||||
if (window.SOUND_MANAGER) {
|
||||
window.SOUND_MANAGER.playSound('arrow');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MonsterAI } from './MonsterAI.js';
|
||||
import { MagicSystem } from './MagicSystem.js';
|
||||
import { CombatSystem } from './CombatSystem.js';
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
import { EventInterpreter } from '../events/EventInterpreter.js'; // Import
|
||||
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
||||
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
||||
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
|
||||
@@ -18,12 +19,16 @@ export class GameEngine {
|
||||
this.ai = new MonsterAI(this); // Init AI
|
||||
this.magicSystem = new MagicSystem(this); // Init Magic
|
||||
this.combatSystem = new CombatSystem(this); // Init Combat
|
||||
this.events = new EventInterpreter(this); // Init Events Engine
|
||||
this.player = null;
|
||||
this.selectedEntity = null;
|
||||
this.isRunning = false;
|
||||
this.plannedPath = []; // Array of {x,y}
|
||||
this.visitedRoomIds = new Set(); // Track tiles triggered
|
||||
this.eventDeck = createEventDeck();
|
||||
this.lastEntranceUsed = null;
|
||||
this.pendingExploration = null;
|
||||
this.exploredRoomIds = new Set();
|
||||
|
||||
// Callbacks
|
||||
this.onEntityUpdate = null;
|
||||
@@ -34,19 +39,22 @@ export class GameEngine {
|
||||
this.onShowMessage = null; // New: Generic temporary message UI callback
|
||||
this.onEntityHit = null; // New: When entity takes damage
|
||||
this.onEntityDeath = null; // New: When entity dies
|
||||
this.onFloatingText = null; // New: For overhead text feedback
|
||||
this.onPathChange = null;
|
||||
this.onShowEvent = null; // New: For styled event cards
|
||||
}
|
||||
|
||||
startMission(missionConfig) {
|
||||
|
||||
this.dungeon.startDungeon(missionConfig);
|
||||
|
||||
// Starting room is already explored
|
||||
this.exploredRoomIds.add('tile_0');
|
||||
this.visitedRoomIds.add('tile_0');
|
||||
|
||||
// Create Party (4 Heroes)
|
||||
this.createParty();
|
||||
|
||||
this.isRunning = true;
|
||||
this.turnManager.startGame();
|
||||
|
||||
// Listen for Phase Changes to Reset Moves
|
||||
this.turnManager.on('phase_changed', (phase) => {
|
||||
if (phase === 'hero' || phase === 'exploration') {
|
||||
@@ -60,6 +68,7 @@ export class GameEngine {
|
||||
window.RENDERER.clearAllActiveRings();
|
||||
}
|
||||
this.deselectEntity();
|
||||
// Duplicate executeTurn removed here. main.js handles this with playMonsterTurn().
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,8 +77,30 @@ export class GameEngine {
|
||||
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
|
||||
setTimeout(() => this.updateLighting(), 500);
|
||||
|
||||
// Start Game Loop (Now that listeners are ready)
|
||||
this.isRunning = true;
|
||||
this.turnManager.startGame();
|
||||
}
|
||||
|
||||
resetHeroMoves() {
|
||||
@@ -157,7 +188,8 @@ export class GameEngine {
|
||||
currentMoves: definition.stats.move,
|
||||
hasAttacked: false,
|
||||
isConscious: true,
|
||||
hasLantern: key === 'barbarian' // Default leader
|
||||
hasLantern: key === 'barbarian', // Default leader
|
||||
inventory: []
|
||||
};
|
||||
|
||||
this.heroes.push(hero);
|
||||
@@ -273,10 +305,21 @@ export class GameEngine {
|
||||
}
|
||||
}
|
||||
|
||||
spawnMonster(monsterKey, x, y, options = {}) {
|
||||
const definition = MONSTER_DEFINITIONS[monsterKey];
|
||||
spawnMonster(monsterKeyOrDef, x, y, options = {}) {
|
||||
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) {
|
||||
console.error(`Monster definition not found: ${monsterKey}`);
|
||||
console.error(`Monster definition not found: ${monsterKeyOrDef}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -370,7 +413,7 @@ export class GameEngine {
|
||||
const targetObj = { x: x, y: y };
|
||||
const los = this.checkLineOfSightStrict(caster, targetObj);
|
||||
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
|
||||
return;
|
||||
}
|
||||
@@ -484,8 +527,8 @@ export class GameEngine {
|
||||
// Check Pinned Status
|
||||
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
|
||||
if (this.isEntityPinned(clickedEntity)) {
|
||||
if (this.onShowMessage) {
|
||||
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.');
|
||||
if (this.onFloatingText) {
|
||||
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)
|
||||
if (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;
|
||||
}
|
||||
this.planStep(x, y);
|
||||
@@ -602,6 +645,12 @@ export class GameEngine {
|
||||
// If already escaped this turn, not pinned
|
||||
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 => {
|
||||
if (m.isDead) return false;
|
||||
const dx = Math.abs(entity.x - m.x);
|
||||
@@ -636,6 +685,18 @@ export class GameEngine {
|
||||
attemptBreakAway(hero) {
|
||||
if (!hero || hero.hasEscapedPin) return { success: false, roll: 0 };
|
||||
|
||||
// RULE: If Derrumbamiento, escape is free
|
||||
if (this.state && this.state.collapsingRoom && this.state.collapsingRoom.tileId) {
|
||||
// Check if hero is in the collapsing room
|
||||
const key = `${Math.floor(hero.x)},${Math.floor(hero.y)}`;
|
||||
const tid = this.dungeon.grid.occupiedCells.get(key);
|
||||
if (tid === this.state.collapsingRoom.tileId) {
|
||||
console.log("[GameEngine] Free BreakAway due to Collapsing Room!");
|
||||
hero.hasEscapedPin = true;
|
||||
return { success: true, roll: "AUTO", target: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const roll = Math.floor(Math.random() * 6) + 1;
|
||||
const target = hero.stats.pin_target || 6;
|
||||
|
||||
@@ -741,66 +802,42 @@ export class GameEngine {
|
||||
// 2. Check for New Tile Entry
|
||||
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
|
||||
|
||||
if (tileId && !this.visitedRoomIds.has(tileId)) {
|
||||
|
||||
// Mark as visited immediatley
|
||||
this.visitedRoomIds.add(tileId);
|
||||
|
||||
// Check Tile Type (Room vs Corridor)
|
||||
if (tileId) {
|
||||
const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId);
|
||||
const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective'));
|
||||
const isUnexploredRoom = isRoom && !this.exploredRoomIds.has(tileId);
|
||||
|
||||
if (isRoom) {
|
||||
console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`);
|
||||
|
||||
// Disparar Evento (need cells)
|
||||
const newCells = [];
|
||||
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
|
||||
if (tid === tileId) {
|
||||
const [cx, cy] = key.split(',').map(Number);
|
||||
newCells.push({ x: cx, y: cy });
|
||||
if (isUnexploredRoom) {
|
||||
if (!this.pendingExploration) {
|
||||
console.log(`[GameEngine] First hero ${entity.name} entered UNEXPLORED ROOM: ${tileId}`);
|
||||
if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Preparando encuentro...", 2000);
|
||||
this.pendingExploration = { tileId: tileId, source: 'exploration' };
|
||||
}
|
||||
}
|
||||
|
||||
// Call Event Logic
|
||||
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)`);
|
||||
triggeredEvents = true; // Use this flag to end turn AFTER movement
|
||||
} else if (!this.visitedRoomIds.has(tileId)) {
|
||||
this.visitedRoomIds.add(tileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If NO interruption, send full path
|
||||
if (!triggeredEvents) {
|
||||
// Always send full path to renderer since we no longer interrupt movement
|
||||
if (this.onEntityMove) {
|
||||
this.onEntityMove(entity, fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct Moves
|
||||
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;
|
||||
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify UI of move change
|
||||
if (this.onEntityUpdate) this.onEntityUpdate(entity);
|
||||
|
||||
// AUTO-DESELECT LOGIC
|
||||
// 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
|
||||
}
|
||||
|
||||
findSpawnPoints(count) {
|
||||
const points = [];
|
||||
const startNode = { x: 0, y: 0 };
|
||||
const searchQueue = [startNode];
|
||||
const visited = new Set(['0,0']);
|
||||
findSpawnPoints(count, tileId = null) {
|
||||
// Collect all currently available cells (occupiedCells maps "x,y" => tileId)
|
||||
const candidates = [];
|
||||
|
||||
let loops = 0;
|
||||
while (searchQueue.length > 0 && points.length < count && loops < 200) {
|
||||
const current = searchQueue.shift();
|
||||
|
||||
if (this.dungeon.grid.isOccupied(current.x, current.y)) {
|
||||
points.push(current);
|
||||
// If no specific tileId is provided (e.g., Power Event),
|
||||
// restrict spawn to tiles currently occupied by heroes.
|
||||
let allowedTileIds = null;
|
||||
if (!tileId && this.heroes.length > 0) {
|
||||
allowedTileIds = new Set();
|
||||
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
|
||||
const neighbors = [
|
||||
{ x: current.x + 1, y: current.y },
|
||||
{ x: current.x - 1, y: current.y },
|
||||
{ x: current.x, y: current.y + 1 },
|
||||
{ x: current.x, y: current.y - 1 }
|
||||
];
|
||||
|
||||
for (const n of neighbors) {
|
||||
const key = `${n.x},${n.y}`;
|
||||
if (!visited.has(key)) {
|
||||
visited.add(key);
|
||||
searchQueue.push(n);
|
||||
}
|
||||
}
|
||||
loops++;
|
||||
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
|
||||
// If tileId provided, must match it.
|
||||
// If no tileId provided, must be one of the tiles with heroes.
|
||||
if (tileId) {
|
||||
if (tid !== tileId) continue;
|
||||
} else if (allowedTileIds) {
|
||||
if (!allowedTileIds.has(tid)) continue;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log("[GameEngine] Room Revealed!");
|
||||
@@ -965,6 +1035,18 @@ export class GameEngine {
|
||||
// =========================================
|
||||
|
||||
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) {
|
||||
await this.ai.executeTurn();
|
||||
}
|
||||
@@ -1282,4 +1364,259 @@ export class GameEngine {
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ export class MagicSystem {
|
||||
|
||||
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
|
||||
// We could also look up a specific handler function map if this grows
|
||||
if (spell.type === 'heal') {
|
||||
@@ -98,29 +104,49 @@ export class MagicSystem {
|
||||
|
||||
// 4. Apply Damage to all targets
|
||||
let hits = 0;
|
||||
let logDetails = [];
|
||||
|
||||
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;
|
||||
let diceTotal = 0;
|
||||
let rolls = [];
|
||||
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
|
||||
CombatMechanics.applyDamage(monster, damageTotal, this.game);
|
||||
const damageTotal = level + diceTotal;
|
||||
|
||||
// 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++;
|
||||
|
||||
logDetails.push(`- <b>${monster.name}</b>: Daño ${damageTotal} (Nv${level}+Dado ${diceTotal}) - Res ${toughness} = <b>${wounds} Heridas</b>.`);
|
||||
|
||||
// Feedback
|
||||
if (this.game.onEntityHit) {
|
||||
this.game.onEntityHit(monster.id);
|
||||
}
|
||||
|
||||
// Use Centralized Combat Feedback
|
||||
window.RENDERER.showCombatFeedback(monster.x, monster.y, damageTotal, true);
|
||||
|
||||
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage.`);
|
||||
window.RENDERER.showCombatFeedback(monster.x, monster.y, wounds, true);
|
||||
|
||||
// Check Death (Handled by events usually, but ensuring cleanup if needed)
|
||||
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 {
|
||||
// Fallback for no renderer (tests?) or race condition
|
||||
// Just apply damage immediately logic (duplicated for brevity check)
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fallback Logic (simplified for brevity, identical calculation)
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: true, type: 'attack', hits: 1 }; // Return success immediately
|
||||
return { success: true, type: 'attack', hits: 1 };
|
||||
}
|
||||
resolveDefense(caster, spell, targetCells) {
|
||||
// Needs a target hero
|
||||
|
||||
@@ -21,42 +21,25 @@ export class MonsterAI {
|
||||
// Check for Summoning Sickness / Ambush delay
|
||||
if (monster.skipTurn) {
|
||||
console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
|
||||
monster.skipTurn = false; // Ready for next turn
|
||||
|
||||
// Add a small visual delay even if skipping, to show focus?
|
||||
// No, better to just skip significantly to keep flow fast.
|
||||
monster.skipTurn = false;
|
||||
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(() => {
|
||||
this.actMonster(monster);
|
||||
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, moveTime);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
actMonster(monster) {
|
||||
async actMonster(monster) {
|
||||
// 1. Check if already adjacent (Engaged) -> ATTACK
|
||||
const adjacentHero = this.getAdjacentHero(monster);
|
||||
|
||||
if (adjacentHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
|
||||
this.performAttack(monster, adjacentHero);
|
||||
await this.performAttack(monster, adjacentHero);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,13 +79,16 @@ export class MonsterAI {
|
||||
// 7. Check if NOW adjacent after move -> ATTACK
|
||||
// Wait for movement animation to complete before checking
|
||||
const movementDuration = actualPath.length * 600;
|
||||
setTimeout(() => {
|
||||
await new Promise(resolve => {
|
||||
setTimeout(async () => {
|
||||
const postMoveHero = this.getAdjacentHero(monster);
|
||||
if (postMoveHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
|
||||
this.performAttack(monster, postMoveHero);
|
||||
await this.performAttack(monster, postMoveHero);
|
||||
}
|
||||
resolve();
|
||||
}, movementDuration);
|
||||
});
|
||||
}
|
||||
|
||||
getClosestHero(monster) {
|
||||
@@ -212,54 +198,43 @@ export class MonsterAI {
|
||||
return bestPath;
|
||||
}
|
||||
|
||||
performAttack(monster, hero) {
|
||||
// SEQUENCE:
|
||||
// 0. Show TARGET (Blue Ring) on Hero
|
||||
if (this.game.onRangedTarget) {
|
||||
// Re-using onRangedTarget? Or directly calling renderer?
|
||||
// Better to use a specific callback or direct call if available, or just add a new callback.
|
||||
// But let's check if we can access renderer directly or use a new callback.
|
||||
// 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)`.
|
||||
async performAttack(monster, hero) {
|
||||
const numAttacks = monster.stats.attacks || 1;
|
||||
console.log(`[MonsterAI] ${monster.name} performing ${numAttacks} attacks against ${hero.name}`);
|
||||
|
||||
for (let i = 0; i < numAttacks; i++) {
|
||||
if (hero.isDead || (hero.isConscious === false)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
window.RENDERER.setEntityTarget(hero.id, true);
|
||||
}
|
||||
|
||||
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
|
||||
|
||||
// Step 1: Green ring on attacker
|
||||
if (this.game.onEntityActive) {
|
||||
this.game.onEntityActive(monster.id, true);
|
||||
}
|
||||
|
||||
// Step 2: Attack animation delay (500ms)
|
||||
setTimeout(() => {
|
||||
|
||||
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
if (this.game.onEntityActive) {
|
||||
this.game.onEntityActive(monster.id, false);
|
||||
}
|
||||
|
||||
// Remove Target Ring
|
||||
if (window.RENDERER && window.RENDERER.setEntityTarget) {
|
||||
window.RENDERER.setEntityTarget(hero.id, false);
|
||||
}
|
||||
|
||||
// Step 5: Show combat result after both rings are gone
|
||||
setTimeout(() => {
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
}
|
||||
}, 200); // Small delay after rings disappear
|
||||
}, 1200); // Wait for red ring to disappear
|
||||
}, 800); // Attack animation delay + focus time
|
||||
|
||||
// Snappier transition (800ms vs 1500ms)
|
||||
setTimeout(resolve, 800);
|
||||
}, 500); // Wait 500ms for attack "focus"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getAdjacentHero(entity) {
|
||||
|
||||
@@ -55,30 +55,39 @@ export class TurnManager {
|
||||
this.rollPowerDice();
|
||||
}
|
||||
|
||||
resumeFromEvent() {
|
||||
console.log("Resuming from Event...");
|
||||
this.nextPhase();
|
||||
}
|
||||
|
||||
rollPowerDice() {
|
||||
const roll = Math.floor(Math.random() * 6) + 1;
|
||||
this.currentPowerRoll = roll;
|
||||
console.log(`Power Roll: ${roll}`);
|
||||
|
||||
let message = "The dungeon is quiet...";
|
||||
let message = "El poder fluye...";
|
||||
let eventTriggered = false;
|
||||
|
||||
// Placeholder for future Event Logic
|
||||
if (roll === 1) {
|
||||
message = "UNEXPECTED EVENT! (Roll of 1)";
|
||||
message = "¡EVENTO DE PODER! (1)";
|
||||
eventTriggered = true;
|
||||
this.triggerRandomEvent();
|
||||
}
|
||||
|
||||
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(() => {
|
||||
this.nextPhase();
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log("TurnManager waiting for Event Resolution...");
|
||||
}
|
||||
}
|
||||
|
||||
triggerRandomEvent() {
|
||||
console.warn("TODO: TRIGGER EVENT CARD DRAW");
|
||||
// Deprecated: logic handled by GameEngine listener to card deck
|
||||
}
|
||||
|
||||
triggerExploration() {
|
||||
|
||||
98
src/main.js
@@ -102,22 +102,38 @@ game.turnManager.on('phase_changed', (phase) => {
|
||||
});
|
||||
|
||||
game.onCombatResult = (log) => {
|
||||
ui.showCombatLog(log);
|
||||
|
||||
// 1. Show Attack Roll on Attacker
|
||||
// Find Attacker pos
|
||||
// 1. Format Log Message
|
||||
// Resolve names
|
||||
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) {
|
||||
const rollColor = log.hitSuccess ? '#00ff00' : '#888888'; // Green vs Gray
|
||||
const rollColor = log.hitSuccess ? '#00ff00' : '#888888';
|
||||
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
|
||||
}
|
||||
|
||||
// 2. Show Damage on Defender
|
||||
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
|
||||
// 3. Show Damage on Defender (Floating)
|
||||
if (defender) {
|
||||
setTimeout(() => { // Slight delay for cause-effect
|
||||
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
|
||||
}, 500);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,6 +151,15 @@ game.onEntityHit = (entityId) => {
|
||||
|
||||
game.onEntityDeath = (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) => {
|
||||
@@ -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 === '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) => {
|
||||
// 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);
|
||||
};
|
||||
|
||||
game.onShowEvent = (cardData, callback) => {
|
||||
ui.showEventCard(cardData, callback);
|
||||
};
|
||||
|
||||
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
|
||||
|
||||
renderer.onHeroFinishedMove = (x, y) => {
|
||||
@@ -242,13 +296,37 @@ const handleClick = (x, y, doorMesh) => {
|
||||
// 2. Check Adjacency
|
||||
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
|
||||
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
|
||||
const exitData = doorMesh.userData.exitData;
|
||||
if (exitData) {
|
||||
game.lastEntranceUsed = exitData;
|
||||
generator.selectDoor(exitData);
|
||||
} else {
|
||||
console.error('[Main] Door missing exitData');
|
||||
|
||||
@@ -141,6 +141,13 @@ export class GameRenderer {
|
||||
this.entityRenderer.triggerDeathAnimation(entityId);
|
||||
}
|
||||
|
||||
spawnProp(type, x, y) {
|
||||
// Delegate to DungeonRenderer as it handles static props
|
||||
// Or if it's an effect, EffectsRenderer.
|
||||
// For "escombros" marker, it's a static prop.
|
||||
this.dungeonRenderer.spawnProp(type, x, y);
|
||||
}
|
||||
|
||||
triggerVisualEffect(type, x, y) {
|
||||
this.effectsRenderer.triggerVisualEffect(type, x, y);
|
||||
}
|
||||
@@ -197,6 +204,10 @@ export class GameRenderer {
|
||||
this.dungeonRenderer.blockDoor(exitData);
|
||||
}
|
||||
|
||||
blockDoorWithPortcullis(exitData) {
|
||||
this.dungeonRenderer.blockDoorWithPortcullis(exitData);
|
||||
}
|
||||
|
||||
showRangedTargeting(hero, monster, losResult) {
|
||||
this.interactionRenderer.showRangedTargeting(hero, monster, losResult);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ export class SoundManager {
|
||||
'door_open': '/assets/sfx/opendoor.mp3',
|
||||
'footsteps': '/assets/sfx/footsteps.mp3',
|
||||
'sword': '/assets/sfx/sword1.mp3',
|
||||
'arrow': '/assets/sfx/arrow.mp3'
|
||||
'arrow': '/assets/sfx/arrow.mp3',
|
||||
'gate_chains': '/assets/sfx/gate_chains_open.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TurnStatusUI } from './ui/TurnStatusUI.js';
|
||||
import { PlacementUI } from './ui/PlacementUI.js';
|
||||
import { FeedbackUI } from './ui/FeedbackUI.js';
|
||||
import { SpellbookUI } from './ui/SpellbookUI.js';
|
||||
import { InventoryUI } from './ui/InventoryUI.js';
|
||||
|
||||
export class UIManager {
|
||||
constructor(cameraManager, gameEngine) {
|
||||
@@ -17,11 +18,13 @@ export class UIManager {
|
||||
this.turnUI = new TurnStatusUI(this.container, gameEngine);
|
||||
this.feedback = new FeedbackUI(this.container, gameEngine);
|
||||
this.spellbook = new SpellbookUI(gameEngine);
|
||||
this.inventory = new InventoryUI(gameEngine);
|
||||
|
||||
// Circular deps / callbacks
|
||||
const cardCallbacks = {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
if (this.game.turnManager) {
|
||||
this.game.turnManager.on('phase_changed', (phase) => {
|
||||
@@ -111,7 +123,9 @@ export class UIManager {
|
||||
showModal(t, m, c) { this.feedback.showModal(t, m, c); }
|
||||
showConfirm(t, m, c) { this.feedback.showConfirm(t, m, c); }
|
||||
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); }
|
||||
hideMonsterCard() { this.cards.hideMonsterCard(); }
|
||||
}
|
||||
|
||||
@@ -306,6 +306,11 @@ export class DungeonRenderer {
|
||||
|
||||
// Load open door 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.needsUpdate = 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) {
|
||||
if (!this.exitGroup) return null;
|
||||
for (const child of this.exitGroup.children) {
|
||||
@@ -364,4 +409,28 @@ export class DungeonRenderer {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
spawnProp(type, x, y) {
|
||||
// Simple Prop System for Events
|
||||
const textureMap = {
|
||||
'escombros': '/assets/images/dungeon1/props/debris.png', // Fallback needed?
|
||||
'piedras': '/assets/images/dungeon1/props/rocks.png'
|
||||
};
|
||||
|
||||
// Fallback for missing assets: reuse known specific textures or generic
|
||||
let path = textureMap[type] || '/assets/images/dungeon1/doors/door1_blocked.png'; // Use blocked door as generic debris for now if others missing
|
||||
|
||||
this.getTexture(path, (texture) => {
|
||||
const mat = new THREE.SpriteMaterial({ map: texture });
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
|
||||
// Positioning
|
||||
sprite.position.set(x, 0.5, -y);
|
||||
sprite.scale.set(1, 1, 1);
|
||||
|
||||
this.dungeonGroup.add(sprite);
|
||||
|
||||
// Add simple logic to remove it later if needed? For now permanent.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,17 @@ export class EntityRenderer {
|
||||
addEntity(entity) {
|
||||
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 h = 1.56;
|
||||
const geometry = new THREE.PlaneGeometry(w, h);
|
||||
|
||||
this.getTexture(entity.texturePath, (texture) => {
|
||||
// Check if we were removed while loading
|
||||
if (!this.entities.has(entity.id)) return;
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
@@ -129,14 +135,14 @@ export class EntityRenderer {
|
||||
|
||||
moveEntityAlongPath(entity, path) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
if (mesh instanceof THREE.Object3D) {
|
||||
mesh.userData.pathQueue = [...path];
|
||||
}
|
||||
}
|
||||
|
||||
updateEntityPosition(entity) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
if (mesh instanceof THREE.Object3D) {
|
||||
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
|
||||
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
|
||||
|
||||
@@ -151,7 +157,7 @@ export class EntityRenderer {
|
||||
|
||||
toggleEntitySelection(entityId, isSelected) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (mesh) {
|
||||
if (mesh instanceof THREE.Object3D) {
|
||||
const ring = mesh.getObjectByName("SelectionRing");
|
||||
if (ring) ring.visible = isSelected;
|
||||
}
|
||||
@@ -159,7 +165,7 @@ export class EntityRenderer {
|
||||
|
||||
setEntityActive(entityId, isActive) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
const oldRing = mesh.getObjectByName("ActiveRing");
|
||||
if (oldRing) mesh.remove(oldRing);
|
||||
@@ -183,7 +189,7 @@ export class EntityRenderer {
|
||||
|
||||
setEntityTarget(entityId, isTarget) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
const oldRing = mesh.getObjectByName("TargetRing");
|
||||
if (oldRing) mesh.remove(oldRing);
|
||||
@@ -217,7 +223,7 @@ export class EntityRenderer {
|
||||
|
||||
triggerDamageEffect(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
mesh.traverse((child) => {
|
||||
if (child.material && child.material.map) {
|
||||
@@ -245,7 +251,7 @@ export class EntityRenderer {
|
||||
|
||||
triggerDeathAnimation(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 1500;
|
||||
@@ -272,6 +278,7 @@ export class EntityRenderer {
|
||||
let isAnyMoving = false;
|
||||
|
||||
this.entities.forEach((mesh, id) => {
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
const data = mesh.userData;
|
||||
|
||||
if (!data.isMoving && data.pathQueue.length > 0) {
|
||||
|
||||
@@ -1,28 +1,80 @@
|
||||
export class FeedbackUI {
|
||||
constructor(parentContainer, game) {
|
||||
this.parentContainer = parentContainer;
|
||||
this.game = game; // Needed for resolving hero names/ids in logs?
|
||||
this.game = game;
|
||||
|
||||
this.combatLogContainer = null;
|
||||
this.initCombatLogContainer();
|
||||
this.logContainer = null;
|
||||
this.initLogContainer();
|
||||
}
|
||||
|
||||
initCombatLogContainer() {
|
||||
this.combatLogContainer = document.createElement('div');
|
||||
Object.assign(this.combatLogContainer.style, {
|
||||
initLogContainer() {
|
||||
this.logContainer = document.createElement('div');
|
||||
Object.assign(this.logContainer.style, {
|
||||
position: 'absolute',
|
||||
top: '140px', // Below the top status panel
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
top: '20%', // Leave space for top HUD
|
||||
right: '20px',
|
||||
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',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
zIndex: '500' // Below modals
|
||||
alignItems: 'flex-start', // Align text to left
|
||||
pointerEvents: 'none', // Allow clicking through if needed, but 'auto' for scroll?
|
||||
// We need pointerEvents auto for scrolling.
|
||||
pointerEvents: 'auto',
|
||||
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) {
|
||||
@@ -56,7 +108,7 @@ export class FeedbackUI {
|
||||
backgroundColor: '#444', color: '#fff', border: '1px solid #888'
|
||||
});
|
||||
btn.onclick = () => {
|
||||
if (overlay.parentNode /** Checks if attached */) this.parentContainer.removeChild(overlay);
|
||||
if (overlay.parentNode) this.parentContainer.removeChild(overlay);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
content.appendChild(btn);
|
||||
@@ -118,10 +170,113 @@ export class FeedbackUI {
|
||||
this.parentContainer.appendChild(overlay);
|
||||
}
|
||||
|
||||
showEventCard(cardData, callback) {
|
||||
const overlay = document.createElement('div');
|
||||
Object.assign(overlay.style, {
|
||||
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)', display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
pointerEvents: 'auto', zIndex: '2000'
|
||||
});
|
||||
|
||||
// Card Container
|
||||
const card = document.createElement('div');
|
||||
Object.assign(card.style, {
|
||||
backgroundColor: '#1a1a1a',
|
||||
backgroundImage: 'repeating-linear-gradient(45deg, #222 25%, transparent 25%, transparent 75%, #222 75%, #222), repeating-linear-gradient(45deg, #222 25%, #1a1a1a 25%, #1a1a1a 75%, #222 75%, #222)',
|
||||
backgroundPosition: '0 0, 10px 10px',
|
||||
backgroundSize: '20px 20px',
|
||||
border: '4px solid #8b0000',
|
||||
borderRadius: '12px',
|
||||
padding: '30px',
|
||||
width: '320px',
|
||||
textAlign: 'center',
|
||||
color: '#fff',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
boxShadow: '0 0 30px rgba(139, 0, 0, 0.6), inset 0 0 50px rgba(0,0,0,0.8)',
|
||||
position: 'relative'
|
||||
});
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = cardData.titulo || "Evento";
|
||||
Object.assign(titleEl.style, {
|
||||
marginTop: '0',
|
||||
marginBottom: '10px',
|
||||
color: '#ff4444',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '2px',
|
||||
fontSize: '24px',
|
||||
textShadow: '2px 2px 0 #000'
|
||||
});
|
||||
card.appendChild(titleEl);
|
||||
|
||||
// Subtitle/Type
|
||||
if (cardData.tipo) {
|
||||
const typeEl = document.createElement('div');
|
||||
typeEl.textContent = cardData.tipo;
|
||||
Object.assign(typeEl.style, {
|
||||
fontSize: '12px',
|
||||
color: '#aaa',
|
||||
marginBottom: '20px',
|
||||
textTransform: 'uppercase',
|
||||
borderBottom: '1px solid #444',
|
||||
paddingBottom: '5px'
|
||||
});
|
||||
card.appendChild(typeEl);
|
||||
}
|
||||
|
||||
// Image Placeholder (Optional)
|
||||
// const img = document.createElement('div'); ...
|
||||
|
||||
// Message
|
||||
const msgEl = document.createElement('p');
|
||||
// If it's pure text or HTML
|
||||
msgEl.innerHTML = cardData.descripcion || cardData.texto || "";
|
||||
Object.assign(msgEl.style, {
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
color: '#ddd',
|
||||
textAlign: 'justify',
|
||||
fontStyle: 'italic',
|
||||
marginBottom: '25px'
|
||||
});
|
||||
card.appendChild(msgEl);
|
||||
|
||||
// Action Button
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'CONTINUAR';
|
||||
Object.assign(btn.style, {
|
||||
padding: '12px 30px',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#8b0000',
|
||||
color: '#fff',
|
||||
border: '2px solid #ff4444',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
boxShadow: '0 4px 0 #440000',
|
||||
transition: 'transform 0.1s'
|
||||
});
|
||||
|
||||
btn.onmousedown = () => btn.style.transform = 'translateY(2px)';
|
||||
btn.onmouseup = () => btn.style.transform = 'translateY(0)';
|
||||
|
||||
btn.onclick = () => {
|
||||
if (overlay.parentNode) this.parentContainer.removeChild(overlay);
|
||||
if (callback) callback();
|
||||
};
|
||||
card.appendChild(btn);
|
||||
|
||||
overlay.appendChild(card);
|
||||
this.parentContainer.appendChild(overlay);
|
||||
}
|
||||
|
||||
showTemporaryMessage(title, message, duration = 2000) {
|
||||
const modal = document.createElement('div');
|
||||
Object.assign(modal.style, {
|
||||
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',
|
||||
borderRadius: '8px', border: '2px solid #ff4444', fontFamily: '"Cinzel", serif',
|
||||
fontSize: '20px', textShadow: '2px 2px 4px black', zIndex: '2000', pointerEvents: 'none',
|
||||
@@ -144,53 +299,4 @@ export class FeedbackUI {
|
||||
}, 500);
|
||||
}, 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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,9 @@ export class TurnStatusUI {
|
||||
<div style="font-size: 14px;">
|
||||
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
|
||||
</div>
|
||||
<div style="font-size: 14px;">
|
||||
Oro: <span style="color: #DAA520; font-weight: bold;">${hero.stats.gold || 0}</span> 🪙
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -7,6 +7,7 @@ export class UnitCardManager {
|
||||
this.cardsContainer = null;
|
||||
this.currentHeroCard = null;
|
||||
this.currentMonsterCard = null;
|
||||
this.monsterContainer = null;
|
||||
this.placeholderCard = null;
|
||||
this.attackButton = null;
|
||||
|
||||
@@ -20,10 +21,10 @@ export class UnitCardManager {
|
||||
left: '10px',
|
||||
top: '220px', // Below minimap
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
pointerEvents: 'auto',
|
||||
width: '200px'
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: '15px',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
this.parentContainer.appendChild(this.cardsContainer);
|
||||
|
||||
@@ -106,18 +107,18 @@ export class UnitCardManager {
|
||||
const hero = this.game.heroes.find(h => h.id === heroId);
|
||||
if (!hero) return;
|
||||
|
||||
const statsGrid = this.currentHeroCard.querySelector('div[style*="grid-template-columns"]');
|
||||
if (statsGrid) {
|
||||
const statDivs = statsGrid.children;
|
||||
// Assumed order: 4 -> Heridas, 7 -> Movimiento
|
||||
if (statDivs[4]) {
|
||||
const wValue = statDivs[4].querySelector('span:last-child');
|
||||
if (wValue) wValue.textContent = `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`;
|
||||
}
|
||||
if (statDivs[7]) {
|
||||
const movValue = statDivs[7].querySelector('span:last-child');
|
||||
if (movValue) movValue.textContent = `${hero.currentMoves || 0}/${hero.stats.move}`;
|
||||
}
|
||||
// NEW: Update stats using data-attributes for robustness
|
||||
const updateStat = (key, value) => {
|
||||
const el = this.currentHeroCard.querySelector(`[data-stat="${key}"]`);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
|
||||
updateStat('Her', `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`);
|
||||
updateStat('Mov', `${hero.currentMoves || 0}/${hero.stats.move}`);
|
||||
updateStat('Oro', hero.stats.gold || 0);
|
||||
|
||||
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: 'Ini', value: hero.stats.initiative || 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 => {
|
||||
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' });
|
||||
|
||||
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';
|
||||
v.dataset.stat = stat.label; // Add data attribute for easier updates
|
||||
|
||||
el.appendChild(l); el.appendChild(v);
|
||||
statsGrid.appendChild(el);
|
||||
@@ -280,11 +288,14 @@ export class UnitCardManager {
|
||||
const invBtn = document.createElement('button');
|
||||
invBtn.textContent = '🎒 INVENTARIO';
|
||||
Object.assign(invBtn.style, {
|
||||
width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#444',
|
||||
color: '#fff', border: '1px solid #777', borderRadius: '4px',
|
||||
fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer' // Changed cursor to pointer for feel, though functionality implies future
|
||||
width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#5D4037',
|
||||
color: '#fff', border: '1px solid #8B4513', borderRadius: '4px',
|
||||
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);
|
||||
|
||||
|
||||
@@ -365,8 +376,17 @@ export class UnitCardManager {
|
||||
|
||||
showMonsterCard(monster) {
|
||||
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.cardsContainer.appendChild(this.currentMonsterCard);
|
||||
this.monsterContainer.appendChild(this.currentMonsterCard);
|
||||
|
||||
this.attackButton = document.createElement('button');
|
||||
this.attackButton.textContent = '⚔️ ATACAR';
|
||||
@@ -384,7 +404,6 @@ export class UnitCardManager {
|
||||
const result = this.game.performHeroAttack(monster.id);
|
||||
if (result && result.success) {
|
||||
this.hideMonsterCard();
|
||||
// Optional: deselect monster logic if managed externally
|
||||
if (this.game.selectedMonster) {
|
||||
if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false);
|
||||
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) {
|
||||
@@ -417,13 +437,11 @@ export class UnitCardManager {
|
||||
}
|
||||
|
||||
hideMonsterCard() {
|
||||
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) {
|
||||
this.cardsContainer.removeChild(this.currentMonsterCard);
|
||||
this.currentMonsterCard = null;
|
||||
if (this.monsterContainer && this.monsterContainer.parentNode) {
|
||||
this.cardsContainer.removeChild(this.monsterContainer);
|
||||
}
|
||||
if (this.attackButton && this.attackButton.parentNode) {
|
||||
this.cardsContainer.removeChild(this.attackButton);
|
||||
this.monsterContainer = null;
|
||||
this.currentMonsterCard = null;
|
||||
this.attackButton = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||