Compare commits
24 Commits
056217437c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| da4c93bf98 | |||
| 83882b25ba | |||
| 82bdcacf95 | |||
| e22cd071c4 | |||
| 613fa843ee | |||
| 5888c59ba4 | |||
| 009c2a4135 | |||
| b08a922c00 | |||
| e45207807d | |||
| 85a390b94a | |||
| 0685c1249e | |||
| f2f399c296 | |||
| df3f892eb2 | |||
| 5c5cc13903 | |||
| 180cf3ab94 | |||
| 377096c530 | |||
| 61c7cc3313 | |||
| c0a9299dc5 | |||
| 7b28fcf1b0 | |||
| 3efbf8d5fb | |||
| dd7356f1bd | |||
| 78b7486dd2 | |||
| 77c0c07a44 | |||
| b619e4cee4 |
408
DEVLOG.md
@@ -1,5 +1,413 @@
|
||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||
|
||||
## 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
|
||||
|
||||
### Objetivos
|
||||
- Completar la refactorización de `GameRenderer.js` sin las regresiones visuales del primer intento.
|
||||
- Solucionar el error crítico de inicialización de módulos (`setPathGroup is not a function`).
|
||||
|
||||
### Cambios Realizados (Refactor V2 "Quirúrgica")
|
||||
- **Modularización Exitosa**:
|
||||
- `SceneManager.js`: Gestiona escena, cámara y luces. Se incluyó el fix de `window.innerHeight` para evitar la pantalla negra.
|
||||
- `DungeonRenderer.js`: Renderizado de tiles, puertas y Niebla de Guerra. Mantiene filtros `NearestFilter` y `SRGBColorSpace`.
|
||||
- `EntityRenderer.js`: Renderizado de héroes, monstruos y animaciones. Mantiene la lógica de limpieza de ruta paso a paso.
|
||||
- `InteractionRenderer.js`: Mantiene la visualización **exacta** de rutas (cuadrados amarillos con números) y gestión de input.
|
||||
- `EffectsRenderer.js`: Partículas y textos flotantes.
|
||||
- `GameRenderer.js`: Actúa como fachada (Facade) delegando llamadas a los módulos.
|
||||
|
||||
### Corrección de Errores (Hotfix)
|
||||
- **Error**: `Uncaught TypeError: this.entityRenderer.setPathGroup is not a function`.
|
||||
- **Causa**: El navegador mantenía una versión caché de `EntityRenderer.js` anterior a la implementación del método `setPathGroup`.
|
||||
- **Solución**:
|
||||
1. Se añadió un log de inicialización en el constructor de `EntityRenderer` (`V2.1`) para forzar la actualización del módulo.
|
||||
2. Se envolvió la llamada a `setPathGroup` en `GameRenderer` con una validación de tipo (`typeof ... === 'function'`) y un log de error explícito para evitar el crash de la aplicación.
|
||||
|
||||
### Estado Actual
|
||||
- El juego carga correctamente.
|
||||
- La estructura de código está modularizada y limpia.
|
||||
- **No hay regresiones visuales**: Los héroes se ven nítidos (pixel art) y la visualización de movimiento es la original.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 12: Refactorización y Renderizado (Intento I)
|
||||
**Fecha:** 9 de Enero de 2026
|
||||
|
||||
### Objetivos
|
||||
- Refactorizar visualmente el `GameRenderer.js` para dividirlo en submódulos ordenados (`Dungeon`, `Entity`, `Effect`, `Interaction`).
|
||||
- Mejorar la escalabilidad del código de renderizado y separar responsabilidades.
|
||||
|
||||
### Ocurrido
|
||||
- Se realizó un intento completo de refactorización que, aunque arquitectónicamente sólido, introdujo regresiones visuales críticas:
|
||||
- **Pantalla Negra**: Debido a una inicialización del canvas basada en un contenedor de altura 0, corregida con `window.innerHeight`.
|
||||
- **Pérdida de Estilo Píxel**: Las miniaturas de los héroes se veían borrosas/blanquecinas por olvidar `THREE.SRGBColorSpace` y `minFilter/magFilter: Nearest`.
|
||||
- **Cambio de Estilo**: La visualización de rutas de movimiento era diferente a la original.
|
||||
|
||||
### Acción Táctica
|
||||
- **Reversión (Rollback)**: Se decidió revertir todos los cambios de renderizado (`git restore`) para volver a un estado visualmente perfecto y comenzar la refactorización (v2) de forma segura y controlada, aplicando las lecciones aprendidas (altura del canvas, filtros de textura) desde el principio.
|
||||
|
||||
### Próximos Pasos (Inmediato)
|
||||
- Re-implementar la refactorización de `GameRenderer` de forma "Quirúrgica", copiando la lógica visual original línea por línea para no alterar la estética pixel-art ni el comportamiento del juego.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 11: Sistema de Iniciativa y Niebla de Guerra (Fog of War)
|
||||
**Fecha:** 9 de Enero de 2026
|
||||
|
||||
### Objetivos
|
||||
- Implementar el sistema de **Niebla de Guerra (Fog of War)** basado en la regla de la Lámpara: Visibilidad limitada a la sección actual y adyacentes.
|
||||
- Establecer un sistema de turnos estricto basado en **Iniciativa** para la fase de Aventureros.
|
||||
|
||||
### Cambios Realizados
|
||||
|
||||
#### 1. Sistema de Iniciativa y Turnos
|
||||
- **Orden de Turno**: Implementada la lógica `initializeTurnOrder` en `GameEngine`.
|
||||
- El **Portador de la Lámpara** (Líder) siempre actúa primero.
|
||||
- El resto de héroes se ordenan por su atributo de **Iniciativa** (Descendente).
|
||||
- **Control Estricto**:
|
||||
- Modificado `onCellClick` para impedir la selección y control de héroes que no sean el activo durante la Fase de Aventureros.
|
||||
- Se visualiza el héroe activo mediante un **Anillo Verde** (vs Amarillo de selección).
|
||||
- **Ciclo de Turnos**: Métodos `activateNextHero` y `nextHeroTurn` para avanzar ordenadamente.
|
||||
|
||||
#### 2. Niebla de Guerra (Lamp Rule)
|
||||
- **Lógica de Adyacencia de Secciones**:
|
||||
- Se abandonó la idea de radio por celdas simples.
|
||||
- Nueva lógica: Se identifica la **Sección de Tablero (Tile)** del Líder.
|
||||
- Se calculan las secciones conectadas físicamente (puertas/pasillos) mediante análisis de `canMoveBetween` en la rejilla.
|
||||
- **Renderizado Dinámico de Niebla**:
|
||||
- `GameRenderer` ahora agrupa las losetas en `dungeonGroup`.
|
||||
- Método `updateFogOfWar` que oculta/muestra losetas y **Entidades** (héroes/monstruos) basándose en la visibilidad de su posición.
|
||||
- La iluminación se actualiza en tiempo real con cada paso del portador de la lámpara.
|
||||
|
||||
#### 3. UX de Combate
|
||||
- **Feedback Visual de Objetivo**: Se ha añadido un **Anillo Azul** que señala al héroe objetivo de un monstruo *antes* de que se realice el ataque, permitiendo al jugador identificar la amenaza inmediatamente.
|
||||
- **Limpieza de UI**: Al comenzar la fase de monstruos, se eliminan automáticamente todos los indicadores de selección (anillos verdes/amarillos) para limpiar la escena.
|
||||
- **Persistencia de Héroe Activo**: El héroe activo ya no pierde su estado de selección al moverse o al hacer clic sobre sí mismo accidentalmente, mejorando la fluidez del turno.
|
||||
|
||||
#### 4. UX y Lógica de Juego
|
||||
- **Ocultación de Entidades en Niebla**: Los héroes y monstruos que quedan fuera del alcance de la lámpara (losetas no visibles) ahora desaparecen completamente de la vista, aumentando la inmersión.
|
||||
- **Salto de Turno Automático**: Si un héroe comienza su turno en una zona oscura (oculta por la Niebla de Guerra), pierde automáticamente su turno hasta que sea "rescatado" (iluminado de nuevo) por el Portador de la Lámpara.
|
||||
- **Botones de Fase**: Se ha reorganizado la barra superior. El botón de "Acabar Fase" ahora comparte espacio con un nuevo botón "Acabar Turno" específico para cada héroe, facilitando el flujo de juego sin tener que buscar en la ficha del personaje.
|
||||
- **Sistema de Destrabado (Break Away)**: Implementada la mecánica para escapar del combate cuerpo a cuerpo ("Trabado").
|
||||
- Los héroes trabados verán un botón de "Destrabarse" en su ficha.
|
||||
- Al pulsarlo, el sistema lanza 1D6 contra el valor `pin_target` del héroe.
|
||||
- Éxito: El héroe recupera su libertad de movimiento. Fallo: El héroe pierde su movimiento y debe luchar.
|
||||
|
||||
### Estado Actual
|
||||
El juego ahora respeta las reglas de visión y turno del juego de mesa original con una fidelidad visual alta. La sensación de exploración es más tensa al ocultarse las zonas lejanas ("se perderán en la oscuridad"), y el orden táctico es crucial. La UI es más intuitiva y limpia durante el combate.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 10: Refactorización Arquitectónica de UI
|
||||
**Fecha:** 8 de Enero de 2026
|
||||
|
||||
### Objetivos
|
||||
- Reducir la complejidad del `UIManager.js` (que superaba las 1500 líneas).
|
||||
- Modularizar la interfaz para facilitar el mantenimiento y la escalabilidad.
|
||||
- Separar responsabilidades claras entre HUD, Cartas de Unidad, Feedback, etc.
|
||||
|
||||
### Cambios Realizados
|
||||
|
||||
#### 1. Modularización de UIManager
|
||||
Se ha dividido el monolito `UIManager.js` en 6 componentes especializados ubicados en `src/view/ui/`:
|
||||
|
||||
* **`HUDManager.js`**:
|
||||
* Gestiona elementos estáticos de pantalla (Minimapa, Controles de Cámara, Zoom).
|
||||
* Mantiene el bucle de renderizado del minimapa 2D.
|
||||
* **`UnitCardManager.js`**:
|
||||
* Controla el panel lateral izquierdo con las fichas de Héroes y Monstruos.
|
||||
* Maneja los botones de acción contextual (Atacar, Disparar, Inventario).
|
||||
* **`TurnStatusUI.js`**:
|
||||
* Panel superior central. Muestra Fase actual, Turno y botón de "Fin de Fase".
|
||||
* Visualiza los resultados de la Fase de Poder.
|
||||
* **`PlacementUI.js`**:
|
||||
* Interfaz específica para la colocación de losetas (flechas de control, rotar, confirmar/cancelar).
|
||||
* **`FeedbackUI.js`**:
|
||||
* Sistema centralizado de comunicación con el usuario.
|
||||
* Gestiona Modales, Ventanas de Confirmación y Mensajes Flotantes.
|
||||
* Implementa el **Log de Combate** (anteriormente notificación simple).
|
||||
* **`SpellbookUI.js`**:
|
||||
* Módulo independiente para el libro de hechizos visual del Mago.
|
||||
|
||||
#### 2. UIManager como Orquestador
|
||||
El archivo principal `UIManager.js` se ha reducido drásticamente (~140 líneas). Ahora actúa únicamente como "pegamento":
|
||||
- Inicializa los subsistemas.
|
||||
- Escucha eventos del `GameEngine` (selección de entidades, cambio de fase).
|
||||
- Delega la actualización de la interfaz a los módulos correspondientes.
|
||||
|
||||
### Estado Actual
|
||||
La refactorización es totalmente transparente para el usuario final (la funcionalidad visual se mantiene idéntica), pero el código es ahora robusto, mantenible y listo para crecer sin convertirse en código espagueti.
|
||||
|
||||
### Próximos Pasos
|
||||
- Implementar la Gestión de Inventario real.
|
||||
- Pulir efectos visuales de hechizos y combate.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 9: Pulido de Combate, UI de Hechizos y Buffs
|
||||
**Fecha:** 8 de Enero de 2026
|
||||
|
||||
### Objetivos
|
||||
- Resolver la duplicación de animaciones en el ataque de los monstruos.
|
||||
- Mejorar la interfaz de usuario para el manejo de hechizos (Libro de Hechizos Visual).
|
||||
- Implementar validaciones de línea de visión (LOS) en el lanzamiento de hechizos.
|
||||
- Añadir nuevos hechizos ("Piel de Hierro") y sistema de duración de efectos (Buffs).
|
||||
|
||||
### Cambios Realizados
|
||||
|
||||
#### 1. Corrección de Animaciones y Audio
|
||||
- **Doble Animación**: Se eliminó la llamada redundante a `onEntityHit` dentro de `MonsterAI.js`. Ahora el feedback visual (destello rojo/temblor) se delega exclusivamente a `game.onCombatResult`, unificando el flujo entre héroes y monstruos y evitando que la animación se dispare dos veces.
|
||||
- **Audio**: Se investigó el retraso en el audio del golpe. Se decidió mantener el sonido actual (`sword1.mp3`) por el momento.
|
||||
|
||||
#### 2. Interfaz de Usuario (UI)
|
||||
- **Botón de Inventario**: Añadido un botón placeholder "🎒 INVENTARIO" a las fichas de todos los aventureros.
|
||||
- **Libro de Hechizos (Mago)**:
|
||||
- Se reemplazó la lista de botones de texto por un sistema visual de cartas.
|
||||
- Al hacer clic en "HECHIZOS", se despliega una mano de cartas generadas dinámicamente con plantillas (`attack_template`, `defense_template`, `healing_template`).
|
||||
- Las cartas muestran el coste de poder en la esquina y se oscurecen si no hay maná suficiente.
|
||||
- Implementado cierre automático al seleccionar o hacer clic fuera.
|
||||
|
||||
#### 3. Sistema de Magia y Buffs
|
||||
- **Validación LOS**: Corregido bug donde "Bola de Fuego" podía lanzarse a través de muros aunque la previsualización mostrara rojo. Ahora `onCellClick` valida estrictamente la línea de visión antes de ejecutar.
|
||||
- **Nuevo Hechizo: Piel de Hierro**:
|
||||
- Coste: 5. Tipo: Defensa.
|
||||
- Efecto: Otorga +2 a Resistencia durante 1 turno.
|
||||
- Requiere selección de objetivo (héroe).
|
||||
- **Sistema de Buffs Temporales**:
|
||||
- Implementado evento `turn_ended` en `TurnManager`.
|
||||
- Añadido método `handleEndTurn` en `GameEngine` para gestionar la duración de los efectos.
|
||||
- Los buffs ahora se limpian automáticamente cuando su duración llega a 0, revirtiendo los cambios en las estadísticas.
|
||||
|
||||
### Estado Actual
|
||||
El combate se siente mucho más sólido sin las animaciones dobles. La interfaz del mago es ahora visualmente atractiva y funcional. El sistema de magia soporta hechizos de defensa y buffs con duración limitada, abriendo la puerta a mecánicas más complejas.
|
||||
|
||||
### Próximos Pasos
|
||||
- Implementar la funcionalidad real del Inventario.
|
||||
- Añadir más cartas/hechizos y refinar el diseño visual de los textos en las cartas.
|
||||
- Ajustar el timing del sonido de ataque para sincronizarlo perfectamente con la animación de impacto.
|
||||
|
||||
## Sesión 8: Sistema de Magia, Audio y Pulido UI (7 Enero 2026)
|
||||
|
||||
### Objetivos Completados
|
||||
1. **Sistema de Audio Inmersivo**:
|
||||
- Implementada reproducción de efectos de sonido (SFX).
|
||||
- Pasos en bucle al mover entidades.
|
||||
- Sonidos de combate: Espadazos, flechas.
|
||||
- Sonido ambiental al abrir puertas.
|
||||
|
||||
2. **Sistema de Magia Avanzado (Bola de Fuego)**:
|
||||
- Implementada mecánica de selección de área de efecto (2x2).
|
||||
- **Feedback Visual**: Visualización de rango y línea de visión (Verde/Rojo) en tiempo real al apuntar.
|
||||
- **Secuencia de Ataque Completa**: Proyectil físico ➔ Impacto ➔ Explosión Central ➔ Daño en área.
|
||||
- Daño individual calculado para cada monstruo afectado.
|
||||
- Cancelación de hechizo mediante clic derecho.
|
||||
|
||||
3. **Feedback de Combate Unificado**:
|
||||
- Centralizada la lógica de visualización de daño en `showCombatFeedback`.
|
||||
- Muestra claramente: Daño (Rojo + Temblor), Bloqueos (Amarillo), Fallos (Gris).
|
||||
- Aplicado tanto a magia como a ataques físicos.
|
||||
|
||||
4. **Mejoras de UI**:
|
||||
- Las estadísticas de las cartas de personaje ahora usan abreviaturas en español claras (H.C, Fuer, Res, etc.) en lugar de siglas en inglés crípticas.
|
||||
|
||||
### Estado Actual
|
||||
El juego dispone de un sistema de combate rico visual y auditivamente. La magia se siente poderosa "gameplay-wise". La interfaz es más amigable para el usuario hispanohablante.
|
||||
|
||||
### Tareas Pendientes / Known Issues
|
||||
1. **Sincronización de Audio**: Los SFX de pasos a veces continúan un instante tras acabar la animación.
|
||||
2. **Animación Doble**: Ocasionalmente se reproducen dos animaciones de ataque o feedback superpuestos.
|
||||
3. **Interfaz de Hechizos**: Actualmente lista todos los hechizos en botones; se necesitará un seleccionador tipo "Libro de Hechizos" cuando el Mago tenga más opciones.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Objetivos Completados
|
||||
1. **Vista Táctica (Toggle 2D/3D)**:
|
||||
- Implementado botón en UI para alternar views.
|
||||
- **2D**: Cámara cenital pura (Top-Down) para planificación táctica.
|
||||
- **Visualización de Tokens**:
|
||||
- En modo 2D, las miniaturas 3D se complementan con Tokens planos.
|
||||
- **Imágenes Específicas**: Carga dinámica de assets para héroes (`heroes/barbarian.png`...) y monstruos (`enemies/orc.png`...).
|
||||
- **Sincronización**: Los tokens se mueven en tiempo real y desaparecen limpiamente al volver a 3D.
|
||||
- **UX**: Transiciones suaves y gestión robusta de visibilidad.
|
||||
|
||||
2. **Refinamiento de Línea de Visión (LOS)**:
|
||||
- Implementado algoritmo estricto (Amanatides & Woo) para evitar tiros a través de muros.
|
||||
- **Tolerancia de Rozamiento**: Añadido margen (hitbox 0.4) para permitir tiros que rozan el borde de una casilla de entidad.
|
||||
- **Corrección de "Diagonal Leaking"**: Solucionado el problema donde los disparos atravesaban esquinas diagonales entre muros (se verifican ambos vecinos en cruces de vértice).
|
||||
- **Detección de Muros por Conectividad**: Reemplazada la comprobación simple de vacío por `canMoveBetween`, asegurando que los muros entre habitaciones/pasillos contiguos bloquen la visión correctamente si no hay puerta, incluso si ambas celdas tienen suelo.
|
||||
|
||||
3. **Sistema de Audio**:
|
||||
- Implementado `SoundManager` para gestión centralizada de audio.
|
||||
- **Música Ambiental**: Reproducción de `Abandoned_Ruins.mp3` con loop y manejo de políticas de autoplay del navegador.
|
||||
- **Efectos de Sonido (SFX)**: Gatillo de sonido `opendoor.mp3` sincronizado con la apertura visual de puertas.
|
||||
|
||||
### Estado Actual
|
||||
El juego cuenta con una visualización táctica profesional y un sistema de línea de visión robusto y justo, eliminando los fallos de detección en esquinas y muros.
|
||||
|
||||
### Próximos Pasos
|
||||
- Sistema de combate completo (dados, daño).
|
||||
- UI de estadísticas y gestión de inventario.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
|
||||
|
||||
### Objetivos Completados
|
||||
1. **Reglas de Juego Oficiales (WHQ 1995)**:
|
||||
- Se ha implementado un estricto control de fases: **Exploración**, **Aventureros** y **Monstruos**.
|
||||
- **Exploración Realista**: Colocar una loseta finaliza el turno inmediatamente.
|
||||
- **Tensión en Nuevas Áreas**: Al entrar en una nueva habitación, el héroe se detiene OBLIGATORIAMENTE (haya monstruos o no) y se revela el evento.
|
||||
- **Combate Continuo**: Si hay monstruos vivos, se elimina la Fase de Exploración del ciclo y se salta la Fase de Poder para mantener un bucle de combate frenético (Aventureros <-> Monstruos).
|
||||
|
||||
2. **Movimiento y Eventos**:
|
||||
- Refinamiento de `executeMovePath` en `GameEngine`:
|
||||
- Detecta entrada en nuevos tiles.
|
||||
- Diferencia entre **Habitaciones** (Trigger Event + Stop) y **Pasillos** (Solo marcar visitado).
|
||||
- Detiene el movimiento sin penalizar los pasos no dados.
|
||||
|
||||
3. **Interacción de Héroes**:
|
||||
- Implementado ataque básico haciendo clic izquierdo en monstruos adyacentes durante el turno propio.
|
||||
- Permitido movimiento en fases de Exploración para facilitar el posicionamiento táctico antes de abrir puertas.
|
||||
|
||||
4. **Monstruos e IA**:
|
||||
- Los monstruos de habitación ya no sufren "mareo de invocación" y atacan en el turno siguiente a su aparición.
|
||||
- Ajustada la IA para operar correctamente dentro del nuevo flujo de fases.
|
||||
|
||||
### Estado Actual
|
||||
El núcleo del juego ("Game Loop") es funcional y fiel a las reglas de mesa. Se puede explorar, revelar salas, combatir y gestionar los turnos con las restricciones correctas.
|
||||
|
||||
### Próximos Pasos
|
||||
- Implementar sistema completo de combate (tiradas de dados visibles, daño variable, muerte de héroes).
|
||||
- Refinar la interfaz de usuario para mostrar estadísticas en tiempo real.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Sesión 5: Refinamiento de UX y Jugabilidad (3 Enero 2026)
|
||||
|
||||
### Objetivos Completados
|
||||
|
||||
@@ -24,13 +24,28 @@
|
||||
- [x] Tile Model/Texture Loading <!-- id: 23 -->
|
||||
- [x] dynamic Tile Instancing based on Grid State <!-- id: 24 -->
|
||||
|
||||
## Phase 3: Game Mechanics (Loop)
|
||||
- [ ] **Turn System**
|
||||
- [ ] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
|
||||
- [ ] Implement Turn State Machine <!-- id: 31 -->
|
||||
- [ ] **Entity System**
|
||||
- [ ] Define Hero/Monster Stats <!-- id: 32 -->
|
||||
- [ ] Implement Movement Logic (Grid-based) <!-- id: 33 -->
|
||||
## Phase 3: Game Mechanics (Loop) - [IN PROGRESS]
|
||||
- [x] **Turn System**
|
||||
- [x] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
|
||||
- [x] Implement Turn State Machine (Phases now functional and dispatch events) <!-- id: 31 -->
|
||||
- [x] Implement Power Phase (Rolls 1d6)
|
||||
- [x] **Event System**
|
||||
- [x] Implement Event Deck (Events.js)
|
||||
- [x] Trigger Random Events on Power Roll of 1 or Room Reveal
|
||||
- [x] Spawn Monsters from Event Cards (1d6 Orcs)
|
||||
- [x] **Entity System**
|
||||
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
|
||||
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
|
||||
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
||||
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
|
||||
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
|
||||
- [x] Refine Combat System (Ranged weapons, Area Magic, Damage Feedback)
|
||||
- [x] Implement Audio System (SFX, Footsteps, Ambience)
|
||||
- [x] UI Improvements (Spanish Stats, Tooltips)
|
||||
- [x] Implement Turn Initiative System (Strict Order, Leader First)
|
||||
- [x] Implement Fog of War (Lamp Rule based on Board Sections)
|
||||
- [x] Refine FOW (Entity Hiding, Turn Skipping)
|
||||
- [x] UI Polish (End Turn placement, Target Rings, Clean Monster Phase)
|
||||
|
||||
## Phase 4: Campaign System
|
||||
- [ ] **Campaign Manager**
|
||||
|
||||
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 1.3 MiB |
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 |
BIN
public/assets/images/dungeon1/spells/attack_template.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/assets/images/dungeon1/spells/defense_template.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/assets/images/dungeon1/spells/healing_template.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/Lordwarlock.png
Normal file
|
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/bat.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
|
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 2.9 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/goblin.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
public/assets/images/dungeon1/standees/enemies/minotaur.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 2.0 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/rat.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/skaven.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/spiderGiant.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 468 KiB |
BIN
public/assets/images/dungeon1/standees/heroes/bakup/dwarf.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/bakup/elfa.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/bakup/warlock.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 2.3 MiB |
BIN
public/assets/images/dungeon1/tokens/enemies/chaosWarrior.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
public/assets/images/dungeon1/tokens/enemies/goblin.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
1
public/assets/images/dungeon1/tokens/enemies/goblin_spearman.png
Symbolic link
@@ -0,0 +1 @@
|
||||
goblin.png
|
||||
BIN
public/assets/images/dungeon1/tokens/enemies/minotaur.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
public/assets/images/dungeon1/tokens/enemies/orc.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/barbarian.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/dwarf.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/elf.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/wizard.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/assets/music/ingame/Abandoned_Ruins.mp3
Normal file
BIN
public/assets/sfx/arrow.mp3
Normal file
BIN
public/assets/sfx/footsteps.mp3
Normal file
BIN
public/assets/sfx/gate_chains_close.mp3
Normal file
BIN
public/assets/sfx/gate_chains_open.mp3
Normal file
BIN
public/assets/sfx/opendoor.mp3
Normal file
BIN
public/assets/sfx/sword1.mp3
Normal file
BIN
public/assets/videos/Intro/intro_barbarian.mp4
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
21
src/engine/data/Events.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { EVENT_CARDS_DATA } from './EventCards.js';
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
MONSTER: 'monster',
|
||||
EVENT: 'event'
|
||||
};
|
||||
|
||||
export const createEventDeck = () => {
|
||||
// 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);
|
||||
};
|
||||
|
||||
const shuffleDeck = (deck) => {
|
||||
for (let i = deck.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[deck[i], deck[j]] = [deck[j], deck[i]];
|
||||
}
|
||||
|
||||
return deck;
|
||||
};
|
||||
@@ -5,14 +5,15 @@ export const HERO_DEFINITIONS = {
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 4, // Weapon Skill
|
||||
bs: 5, // Ballistic Skill (3+ to hit, often lower is better in WHQ, let's use standard table numbers for now)
|
||||
ws: 3,
|
||||
to_hit_missile: 5,
|
||||
str: 4,
|
||||
toughness: 4,
|
||||
wounds: 12,
|
||||
attacks: 1,
|
||||
init: 3,
|
||||
luck: 2 // Rerolls??
|
||||
pin_target: 6,
|
||||
gold: 0
|
||||
}
|
||||
},
|
||||
dwarf: {
|
||||
@@ -20,15 +21,16 @@ export const HERO_DEFINITIONS = {
|
||||
name: 'Enano',
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
|
||||
stats: {
|
||||
move: 3,
|
||||
ws: 5,
|
||||
bs: 5,
|
||||
move: 4,
|
||||
ws: 4,
|
||||
to_hit_missile: 5,
|
||||
str: 3,
|
||||
toughness: 5,
|
||||
wounds: 13,
|
||||
wounds: 11,
|
||||
attacks: 1,
|
||||
init: 2,
|
||||
luck: 0
|
||||
pin_target: 5,
|
||||
gold: 0
|
||||
}
|
||||
},
|
||||
elf: {
|
||||
@@ -36,15 +38,17 @@ export const HERO_DEFINITIONS = {
|
||||
name: 'Elfa',
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
|
||||
stats: {
|
||||
move: 5,
|
||||
move: 4,
|
||||
ws: 4,
|
||||
bs: 2, // Amazing shot
|
||||
bs: 4,
|
||||
to_hit_missile: 4,
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 10,
|
||||
attacks: 1,
|
||||
init: 6,
|
||||
luck: 1
|
||||
pin_target: 1,
|
||||
gold: 0
|
||||
}
|
||||
},
|
||||
wizard: {
|
||||
@@ -53,15 +57,16 @@ export const HERO_DEFINITIONS = {
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 3,
|
||||
bs: 6,
|
||||
ws: 2,
|
||||
to_hit_missile: 6,
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 9,
|
||||
attacks: 1,
|
||||
init: 4,
|
||||
luck: 1,
|
||||
power: 0 // Special mechanic
|
||||
init: 3,
|
||||
power: 0,
|
||||
pin_target: 4,
|
||||
gold: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,17 +1,92 @@
|
||||
export const MONSTER_DEFINITIONS = {
|
||||
orc: {
|
||||
id: 'orc',
|
||||
name: 'Orco',
|
||||
name: 'Guerrero Orco',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 3,
|
||||
bs: 5,
|
||||
str: 3,
|
||||
toughness: 4,
|
||||
wounds: 4,
|
||||
wounds: 1, // Card: "Heridas: 1" (Wait, Orcs usually have 1, check image: YES "Heridas: 1")
|
||||
attacks: 1,
|
||||
gold: 15
|
||||
gold: 55 // Card: "Valor 55x Unidad"
|
||||
}
|
||||
},
|
||||
// Fix duplicate wounds key in goblin
|
||||
goblin_spearman: {
|
||||
id: 'goblin_spearman',
|
||||
name: 'Lancero Goblin',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/goblin.png',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 2,
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 1,
|
||||
attacks: 1,
|
||||
gold: 20,
|
||||
specialRules: ['reach_attack'] // "Puede atacar a dos casillas"
|
||||
}
|
||||
},
|
||||
giant_rat: {
|
||||
id: 'giant_rat',
|
||||
name: 'Rata Gigante',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/rat.png',
|
||||
stats: {
|
||||
move: 6,
|
||||
ws: 2,
|
||||
str: 2,
|
||||
toughness: 3,
|
||||
wounds: 1,
|
||||
attacks: 1,
|
||||
gold: 20,
|
||||
specialRules: ['death_frenzy', 'sudden_death'] // "Frenesí suicida", "Muerte Súbita"
|
||||
}
|
||||
},
|
||||
giant_spider: {
|
||||
id: 'giant_spider',
|
||||
name: 'Araña Gigante',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/spiderGiant.png',
|
||||
stats: {
|
||||
move: 6,
|
||||
ws: 2,
|
||||
str: 3, // Card says "Fuerza: Especial", but base STR needed? Web attack deals auto 1D3. If not trapped, check hit normally.
|
||||
toughness: 2,
|
||||
wounds: 1,
|
||||
attacks: 1,
|
||||
gold: 15,
|
||||
specialRules: ['web_attack']
|
||||
}
|
||||
},
|
||||
giant_bat: {
|
||||
id: 'giant_bat',
|
||||
name: 'Murciélago Gigante',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/bat.png',
|
||||
stats: {
|
||||
move: 8,
|
||||
ws: 2,
|
||||
str: 2,
|
||||
toughness: 2,
|
||||
wounds: 1,
|
||||
attacks: 1,
|
||||
gold: 15,
|
||||
specialRules: ['fly', 'ambush_attack'] // "Nunca se traban", "Atacan tan pronto son colocados"
|
||||
}
|
||||
},
|
||||
minotaur: {
|
||||
id: 'minotaur',
|
||||
name: 'Minotauro',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/minotaur.png',
|
||||
stats: {
|
||||
move: 6,
|
||||
ws: 4,
|
||||
str: 4,
|
||||
toughness: 4,
|
||||
wounds: 15,
|
||||
attacks: 2,
|
||||
gold: 440,
|
||||
damageDice: 2 // "Tira 2 dados para herir"
|
||||
}
|
||||
},
|
||||
chaos_warrior: {
|
||||
@@ -20,13 +95,26 @@ export const MONSTER_DEFINITIONS = {
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 5,
|
||||
bs: 0,
|
||||
str: 5,
|
||||
toughness: 5,
|
||||
wounds: 8,
|
||||
ws: 4,
|
||||
str: 4,
|
||||
toughness: 4,
|
||||
wounds: 10, // Copied from Event Card logic
|
||||
attacks: 2,
|
||||
gold: 150
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
38
src/engine/data/Spells.js
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
export const SPELLS = [
|
||||
{
|
||||
id: 'fireball',
|
||||
name: 'Bola de Fuego',
|
||||
type: 'attack',
|
||||
cost: 5,
|
||||
range: 12, // Arbitrary line of sight
|
||||
damageDice: 1,
|
||||
damageBonus: 'hero_level', // Dynamic logic
|
||||
area: 2, // 2x2
|
||||
description: "Elige un área de 2x2 casillas en línea de visión. Cada miniatura sufre 1D6 + Nivel herois."
|
||||
},
|
||||
{
|
||||
id: 'iron_skin',
|
||||
name: 'Piel de Hierro',
|
||||
type: 'defense',
|
||||
cost: 1,
|
||||
range: 'board', // Anywhere on board
|
||||
target: 'single_hero', // Needs selection
|
||||
effect: {
|
||||
stat: 'toughness',
|
||||
value: 2,
|
||||
duration: 1
|
||||
},
|
||||
description: "Elige a un Aventurero. +2 a Resistencia durante este turno."
|
||||
},
|
||||
{
|
||||
id: 'healing_hands',
|
||||
name: 'Manos Curadoras',
|
||||
type: 'heal',
|
||||
cost: 2,
|
||||
range: 'board', // Same board section
|
||||
healAmount: 1,
|
||||
target: 'all_heroes',
|
||||
description: "Todos los Aventureros en la misma sección de tablero recuperan 1 Herida."
|
||||
}
|
||||
];
|
||||
@@ -15,11 +15,11 @@ export class DungeonDeck {
|
||||
// 1. Create a "Pool" of standard dungeon tiles
|
||||
let pool = [];
|
||||
const composition = [
|
||||
{ id: 'room_dungeon', count: 6 },
|
||||
{ id: 'corridor_straight', count: 7 },
|
||||
{ id: 'corridor_steps', count: 1 },
|
||||
{ id: 'corridor_corner', count: 1 }, // L-Shape
|
||||
{ id: 'junction_t', count: 3 }
|
||||
{ id: 'room_dungeon', count: 12 },
|
||||
{ id: 'corridor_straight', count: 8 },
|
||||
{ id: 'corridor_steps', count: 4 },
|
||||
{ id: 'corridor_corner', count: 4 },
|
||||
{ id: 'junction_t', count: 4 }
|
||||
];
|
||||
|
||||
composition.forEach(item => {
|
||||
@@ -46,23 +46,19 @@ export class DungeonDeck {
|
||||
return drawn;
|
||||
};
|
||||
|
||||
// --- Step 1 & 2: Bottom Pool ---
|
||||
// --- Step 1 & 2: Bottom Pool (6 random tiles + Objective) ---
|
||||
const bottomPool = drawRandom(pool, 6);
|
||||
|
||||
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);
|
||||
// --- Step 3: Top Pool (All remaining tiles in the pool) ---
|
||||
const topPool = [...pool]; // pool already has those 6 removed by drawRandom
|
||||
this.shuffleArray(topPool);
|
||||
|
||||
// --- Step 5: Stack ---
|
||||
// --- Step 4: Final Stack ---
|
||||
this.cards = [...topPool, ...bottomPool];
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,27 @@ export class GridSystem {
|
||||
constructor() {
|
||||
// Map "x,y" -> "tileId"
|
||||
this.occupiedCells = new Map();
|
||||
|
||||
// Map "x,y" -> { tileId: string, height: number (1-9) }
|
||||
this.cellData = new Map();
|
||||
|
||||
// Map "tileId" -> Set of "x,y" strings (all cells belonging to this tile)
|
||||
this.tileCells = new Map();
|
||||
|
||||
// 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 = [];
|
||||
}
|
||||
|
||||
isWall(x, y) {
|
||||
const key = `${x},${y}`;
|
||||
return !this.occupiedCells.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific VARIANT can be placed at anchorX, anchorY.
|
||||
* Does NOT rotate anything. Assumes variant is already the correct shape.
|
||||
@@ -48,13 +66,20 @@ export class GridSystem {
|
||||
const rows = layout.length;
|
||||
const anchorX = tileInstance.x;
|
||||
const anchorY = tileInstance.y;
|
||||
const tileId = tileInstance.id;
|
||||
|
||||
// Initialize tile cell set
|
||||
if (!this.tileCells.has(tileId)) {
|
||||
this.tileCells.set(tileId, new Set());
|
||||
}
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const rowData = layout[row];
|
||||
const cols = rowData.length;
|
||||
|
||||
for (let col = 0; col < cols; col++) {
|
||||
if (rowData[col] === 0) continue;
|
||||
const heightValue = rowData[col];
|
||||
if (heightValue === 0) continue;
|
||||
|
||||
const lx = col;
|
||||
const ly = (rows - 1) - row;
|
||||
@@ -63,9 +88,28 @@ export class GridSystem {
|
||||
const gy = anchorY + ly;
|
||||
const key = `${gx},${gy}`;
|
||||
|
||||
this.occupiedCells.set(key, tileInstance.id);
|
||||
// Store basic occupation
|
||||
this.occupiedCells.set(key, tileId);
|
||||
|
||||
// Store detailed cell data (height level)
|
||||
this.cellData.set(key, {
|
||||
tileId: tileId,
|
||||
height: heightValue // 1-8 = levels, 9 = stairs
|
||||
});
|
||||
|
||||
// Add to tile's cell set
|
||||
this.tileCells.get(tileId).add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark exit/door cells
|
||||
if (variant.exits) {
|
||||
variant.exits.forEach(exit => {
|
||||
const exitKey = `${anchorX + exit.x},${anchorY + exit.y}`;
|
||||
this.doorCells.add(exitKey);
|
||||
});
|
||||
}
|
||||
|
||||
this.tiles.push(tileInstance);
|
||||
}
|
||||
|
||||
@@ -100,6 +144,60 @@ 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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell data (tileId, height) for a coordinate
|
||||
*/
|
||||
getCellData(x, y) {
|
||||
return this.cellData.get(`${x},${y}`) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if movement from (x1,y1) to (x2,y2) is valid
|
||||
* considering room boundaries, height levels, and stairs
|
||||
*/
|
||||
canMoveBetween(x1, y1, x2, y2) {
|
||||
const key1 = `${x1},${y1}`;
|
||||
const key2 = `${x2},${y2}`;
|
||||
|
||||
const data1 = this.cellData.get(key1);
|
||||
const data2 = this.cellData.get(key2);
|
||||
|
||||
// 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);
|
||||
const isDoor2 = this.doorCells.has(key2);
|
||||
|
||||
// If different tiles, at least one must be a door
|
||||
if (!sameTile && !isDoor1 && !isDoor2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Height validation
|
||||
const h1 = data1.height;
|
||||
const h2 = data2.height;
|
||||
|
||||
// Stairs (9) can connect to any level
|
||||
if (h1 === 9 || h2 === 9) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same height level is always OK
|
||||
if (h1 === h2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Different heights require stairs - not allowed directly
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
351
src/engine/events/EventInterpreter.js
Normal file
@@ -0,0 +1,351 @@
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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]];
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
214
src/engine/game/CombatMechanics.js
Normal file
@@ -0,0 +1,214 @@
|
||||
export const TO_HIT_CHART = [
|
||||
// Defender WS 1 2 3 4 5 6 7 8 9 10
|
||||
/* Attacker 1 */[4, 4, 5, 6, 6, 6, 6, 6, 6, 6],
|
||||
/* Attacker 2 */[3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
|
||||
/* Attacker 3 */[2, 3, 4, 4, 4, 4, 5, 5, 5, 6],
|
||||
/* Attacker 4 */[2, 3, 3, 4, 4, 4, 4, 4, 5, 5],
|
||||
/* Attacker 5 */[2, 2, 3, 3, 4, 4, 4, 4, 4, 4],
|
||||
/* Attacker 6 */[2, 2, 3, 3, 3, 4, 4, 4, 4, 4],
|
||||
/* Attacker 7 */[2, 2, 2, 3, 3, 3, 4, 4, 4, 4],
|
||||
/* Attacker 8 */[2, 2, 2, 3, 3, 3, 3, 4, 4, 4],
|
||||
/* Attacker 9 */[2, 2, 2, 2, 3, 3, 3, 3, 4, 4],
|
||||
/* Attacker 10*/[2, 2, 2, 2, 3, 3, 3, 3, 3, 4]
|
||||
];
|
||||
|
||||
export class CombatMechanics {
|
||||
|
||||
/**
|
||||
* Resolves a melee attack sequence between two entities.
|
||||
* @param {Object} attacker
|
||||
* @param {Object} defender
|
||||
* @returns {Object} Result log
|
||||
*/
|
||||
static resolveMeleeAttack(attacker, defender, gameEngine = null) {
|
||||
const log = {
|
||||
attackerId: attacker.id,
|
||||
defenderId: defender.id,
|
||||
hitRoll: 0,
|
||||
targetToHit: 0,
|
||||
hitSuccess: false,
|
||||
damageRoll: 0,
|
||||
damageTotal: 0,
|
||||
woundsCaused: 0,
|
||||
defenderDied: false,
|
||||
message: ''
|
||||
};
|
||||
|
||||
// 1. Determine Stats
|
||||
// Use stats object if available, otherwise direct property (fallback)
|
||||
const attStats = attacker.stats || attacker;
|
||||
const defStats = defender.stats || defender;
|
||||
|
||||
const attWS = Math.min(Math.max(attStats.ws || 1, 1), 10);
|
||||
const defWS = Math.min(Math.max(defStats.ws || 1, 1), 10);
|
||||
|
||||
// 2. Roll To Hit
|
||||
log.targetToHit = this.getToHitTarget(attWS, defWS);
|
||||
log.hitRoll = this.rollD6();
|
||||
|
||||
// Debug
|
||||
// console.log(`Combat: ${attacker.name} (WS${attWS}) vs ${defender.name} (WS${defWS}) -> Need ${log.targetToHit}+. Rolled ${log.hitRoll}`);
|
||||
|
||||
if (log.hitRoll < log.targetToHit) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `💨 ${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
|
||||
return log;
|
||||
}
|
||||
|
||||
log.hitSuccess = true;
|
||||
|
||||
// 3. Roll To Damage
|
||||
const attStr = attStats.str || 3;
|
||||
const defTough = defStats.toughness || 3;
|
||||
const damageDice = attStats.damageDice || 1; // Default 1D6
|
||||
|
||||
let damageSum = 0;
|
||||
let rolls = [];
|
||||
for (let i = 0; i < damageDice; i++) {
|
||||
const r = this.rollD6();
|
||||
rolls.push(r);
|
||||
damageSum += r;
|
||||
}
|
||||
|
||||
log.damageRoll = damageSum;
|
||||
log.damageTotal = damageSum + attStr;
|
||||
|
||||
// 4. Calculate Wounds
|
||||
let wounds = log.damageTotal - defTough;
|
||||
if (wounds < 0) wounds = 0;
|
||||
|
||||
log.woundsCaused = wounds;
|
||||
|
||||
// 5. Build Message
|
||||
if (wounds > 0) {
|
||||
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})`;
|
||||
}
|
||||
|
||||
// 6. Apply Damage to Defender State
|
||||
this.applyDamage(defender, wounds, gameEngine);
|
||||
|
||||
if (defender.isDead) {
|
||||
log.defenderDied = true;
|
||||
log.message += ` 💀 ¡${defender.name} ha muerto!`;
|
||||
} else if (defender.isUnconscious) {
|
||||
log.message += ` 💀 ¡${defender.name} cae inconsciente!`;
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
static resolveRangedAttack(attacker, defender, gameEngine = null) {
|
||||
const log = {
|
||||
attackerId: attacker.id,
|
||||
defenderId: defender.id,
|
||||
hitSuccess: false,
|
||||
damageTotal: 0,
|
||||
woundsCaused: 0,
|
||||
defenderDied: false,
|
||||
message: ''
|
||||
};
|
||||
|
||||
// 1. Roll To Hit (BS vs WS)
|
||||
// Use attacker BS or default to WS if missing (fallback).
|
||||
const attackerBS = attacker.stats.bs || attacker.stats.ws;
|
||||
const defenderWS = defender.stats.ws;
|
||||
|
||||
const toHitTarget = this.getToHitTarget(attackerBS, defenderWS);
|
||||
const hitRoll = this.rollD6();
|
||||
log.hitRoll = hitRoll;
|
||||
log.toHitTarget = toHitTarget;
|
||||
|
||||
if (hitRoll === 1) {
|
||||
log.hitSuccess = false;
|
||||
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})`;
|
||||
return log;
|
||||
}
|
||||
|
||||
log.hitSuccess = true;
|
||||
|
||||
// 2. Roll Damage
|
||||
const weaponStrength = 3;
|
||||
const damageRoll = this.rollD6();
|
||||
const damageTotal = weaponStrength + damageRoll;
|
||||
log.damageRoll = damageRoll;
|
||||
log.damageTotal = damageTotal;
|
||||
|
||||
// 3. Compare vs Toughness
|
||||
const defTough = defender.stats.toughness || 1;
|
||||
const wounds = Math.max(0, damageTotal - defTough);
|
||||
|
||||
log.woundsCaused = wounds;
|
||||
|
||||
// 4. Build Message
|
||||
if (wounds > 0) {
|
||||
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})`;
|
||||
}
|
||||
|
||||
// 5. Apply Damage
|
||||
this.applyDamage(defender, wounds, gameEngine);
|
||||
|
||||
if (defender.isDead) {
|
||||
log.defenderDied = true;
|
||||
log.message += ` 💀 ¡${defender.name} ha muerto!`;
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
static getToHitTarget(attackerWS, defenderWS) {
|
||||
// Adjust for 0-index array
|
||||
const row = attackerWS - 1;
|
||||
const col = defenderWS - 1;
|
||||
if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
|
||||
return TO_HIT_CHART[row][col];
|
||||
}
|
||||
return 6; // Fallback
|
||||
}
|
||||
|
||||
static applyDamage(entity, amount, gameEngine = null) {
|
||||
if (!entity.stats) entity.stats = {};
|
||||
|
||||
// If entity doesn't have current wounds tracked, init it from max
|
||||
if (entity.currentWounds === undefined) {
|
||||
// For Heros it is 'wounds', for Monsters typical just 'wounds' in def
|
||||
// We assume entity has been initialized properly before,
|
||||
// but if not, we grab max from definition
|
||||
entity.currentWounds = entity.stats.wounds || 1;
|
||||
}
|
||||
|
||||
entity.currentWounds -= amount;
|
||||
|
||||
// Check Status
|
||||
if (entity.type === 'hero') {
|
||||
if (entity.currentWounds <= 0) {
|
||||
entity.currentWounds = 0;
|
||||
entity.isConscious = false;
|
||||
// entity.isDead is not immediate for heroes usually, but let's handle via isConscious
|
||||
}
|
||||
} else {
|
||||
// Monsters die at 0
|
||||
if (entity.currentWounds <= 0) {
|
||||
entity.currentWounds = 0;
|
||||
entity.isDead = true;
|
||||
// Trigger death callback if available
|
||||
if (gameEngine && gameEngine.onEntityDeath) {
|
||||
gameEngine.onEntityDeath(entity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static rollD6() {
|
||||
return Math.floor(Math.random() * 6) + 1;
|
||||
}
|
||||
}
|
||||
125
src/engine/game/CombatSystem.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
|
||||
export class CombatSystem {
|
||||
constructor(gameEngine) {
|
||||
this.game = gameEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the complete flow of a Melee Attack request
|
||||
* @param {Object} attacker
|
||||
* @param {Object} defender
|
||||
* @returns {Object} Result object { success: boolean, result: logObject, reason: string }
|
||||
*/
|
||||
handleMeleeAttack(attacker, defender) {
|
||||
// 1. Validations
|
||||
if (!attacker || !defender) return { success: false, reason: 'invalid_target' };
|
||||
|
||||
// Check Phase (Hero Phase for heroes)
|
||||
// Note: Monsters use this too, but their phase check is in AI loop.
|
||||
// We might want to enforce "Monster Phase" check here later if we pass 'source' context.
|
||||
if (attacker.type === 'hero' && this.game.turnManager.currentPhase !== 'hero') {
|
||||
return { success: false, reason: 'phase' };
|
||||
}
|
||||
|
||||
// Check Action Economy (Cooldown)
|
||||
if (attacker.hasAttacked) {
|
||||
return { success: false, reason: 'cooldown' };
|
||||
}
|
||||
|
||||
// Check Adjacency (Melee Range)
|
||||
// Logic: Manhattan distance == 1
|
||||
const dx = Math.abs(attacker.x - defender.x);
|
||||
const dy = Math.abs(attacker.y - defender.y);
|
||||
if (dx + dy !== 1) {
|
||||
return { success: false, reason: 'range' };
|
||||
}
|
||||
|
||||
// 2. Execution (Math)
|
||||
// Calls the pure math module
|
||||
const result = CombatMechanics.resolveMeleeAttack(attacker, defender, this.game);
|
||||
|
||||
// 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
|
||||
window.SOUND_MANAGER.playSound('sword');
|
||||
}
|
||||
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
}
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the complete flow of a Ranged Attack request
|
||||
* @param {Object} attacker
|
||||
* @param {Object} defender
|
||||
* @returns {Object} Result object
|
||||
*/
|
||||
handleRangedAttack(attacker, defender) {
|
||||
if (!attacker || !defender) return { success: false, reason: 'invalid_target' };
|
||||
|
||||
// 1. Validations
|
||||
if (attacker.type === 'hero' && this.game.turnManager.currentPhase !== 'hero') {
|
||||
return { success: false, reason: 'phase' };
|
||||
}
|
||||
|
||||
if (attacker.hasAttacked) {
|
||||
return { success: false, reason: 'cooldown' };
|
||||
}
|
||||
|
||||
// Check "Pinned" Status (Can't shoot if enemies are adjacent)
|
||||
// Using GameEngine's helper for now as it holds entity lists
|
||||
if (this.game.isEntityPinned(attacker)) {
|
||||
return { success: false, reason: 'pinned' };
|
||||
}
|
||||
|
||||
// Line of Sight is assumed checked by UI/Input, but we could enforce it here if strict.
|
||||
|
||||
// 2. Execution (Math)
|
||||
const result = CombatMechanics.resolveRangedAttack(attacker, defender, this.game);
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
}
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
}
|
||||
242
src/engine/game/MagicSystem.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
|
||||
export class MagicSystem {
|
||||
constructor(gameEngine) {
|
||||
this.game = gameEngine;
|
||||
}
|
||||
|
||||
canCastSpell(caster, spell) {
|
||||
if (!caster || !spell) return false;
|
||||
|
||||
// 1. Check Class/Role Restriction
|
||||
// For now hardcoded validation, but could be part of Spell definition (e.g. spell.classes.includes(caster.key))
|
||||
if (caster.key !== 'wizard') return false;
|
||||
|
||||
// 2. Check Phase
|
||||
if (this.game.turnManager.currentPhase !== 'hero') return false;
|
||||
|
||||
// 3. Check Cost vs Power
|
||||
// Assuming TurnManager has a way to check available power
|
||||
const availablePower = this.game.turnManager.power;
|
||||
if (availablePower < spell.cost) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
executeSpell(caster, spell, targetCells = []) {
|
||||
if (!this.canCastSpell(caster, spell)) {
|
||||
return { success: false, reason: 'validation_failed' };
|
||||
}
|
||||
|
||||
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') {
|
||||
return this.resolveHeal(caster, spell);
|
||||
} else if (spell.type === 'attack') {
|
||||
return this.resolveAttack(caster, spell, targetCells);
|
||||
} else if (spell.type === 'defense') {
|
||||
return this.resolveDefense(caster, spell, targetCells);
|
||||
}
|
||||
|
||||
return { success: false, reason: 'unknown_spell_type' };
|
||||
}
|
||||
|
||||
resolveHeal(caster, spell) {
|
||||
// Default Logic: Heal all heroes in same section (simplified to all heroes)
|
||||
let totalHealed = 0;
|
||||
|
||||
this.game.heroes.forEach(h => {
|
||||
// Check if wounded
|
||||
if (h.currentWounds < h.stats.wounds) {
|
||||
const amount = spell.healAmount || 1;
|
||||
const oldWounds = h.currentWounds;
|
||||
h.currentWounds = Math.min(h.currentWounds + amount, h.stats.wounds);
|
||||
|
||||
const healed = h.currentWounds - oldWounds;
|
||||
if (healed > 0) {
|
||||
totalHealed += healed;
|
||||
if (this.game.onShowMessage) {
|
||||
this.game.onShowMessage('Curación', `${h.name} recupera ${healed} herida(s).`);
|
||||
}
|
||||
// Visuals
|
||||
if (window.RENDERER) {
|
||||
window.RENDERER.triggerVisualEffect('heal', h.x, h.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, type: 'heal', healedCount: totalHealed };
|
||||
}
|
||||
|
||||
resolveAttack(caster, spell, targetCells) {
|
||||
const level = caster.level || 1;
|
||||
|
||||
// 1. Calculate Center of Impact
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
targetCells.forEach(c => {
|
||||
if (c.x < minX) minX = c.x;
|
||||
if (c.x > maxX) maxX = c.x;
|
||||
if (c.y < minY) minY = c.y;
|
||||
if (c.y > maxY) maxY = c.y;
|
||||
});
|
||||
|
||||
// Exact center of the group
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
// 2. Launch Projectile
|
||||
if (window.RENDERER) {
|
||||
window.RENDERER.triggerProjectile(caster.x, caster.y, centerX, centerY, () => {
|
||||
|
||||
// --- IMPACT CALLBACK ---
|
||||
|
||||
// 3. Central Explosion
|
||||
window.RENDERER.triggerVisualEffect('fireball', centerX, centerY);
|
||||
|
||||
// 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 diceTotal = 0;
|
||||
let rolls = [];
|
||||
for (let i = 0; i < damageDice; i++) {
|
||||
const r = Math.floor(Math.random() * 6) + 1;
|
||||
rolls.push(r);
|
||||
diceTotal += r;
|
||||
}
|
||||
|
||||
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, wounds, true);
|
||||
|
||||
// Check Death (Handled by events usually, but ensuring cleanup if needed)
|
||||
if (monster.currentWounds <= 0 && !monster.isDead) {
|
||||
monster.isDead = true;
|
||||
if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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 Logic (simplified for brevity, identical calculation)
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: true, type: 'attack', hits: 1 };
|
||||
}
|
||||
resolveDefense(caster, spell, targetCells) {
|
||||
// Needs a target hero
|
||||
let targetHero = null;
|
||||
|
||||
// Find hero in target cells
|
||||
for (const cell of targetCells) {
|
||||
const h = this.game.heroes.find(h => h.x === cell.x && h.y === cell.y);
|
||||
if (h) {
|
||||
targetHero = h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetHero) {
|
||||
return { success: false, reason: 'no_target_hero' };
|
||||
}
|
||||
|
||||
const effect = spell.effect;
|
||||
if (!effect) return { success: false };
|
||||
|
||||
// Apply Buff
|
||||
if (effect.stat === 'toughness') {
|
||||
// Store original if not already stored (simple buffering)
|
||||
if (!targetHero.tempStats) targetHero.tempStats = {};
|
||||
|
||||
// Stackable? Probably not for same spell.
|
||||
// Check if already has this buff?
|
||||
// For simplicity: Add modifier
|
||||
if (!targetHero.tempStats.toughnessBonus) targetHero.tempStats.toughnessBonus = 0;
|
||||
|
||||
targetHero.tempStats.toughnessBonus += effect.value;
|
||||
// Also modify actual stat for calculation access?
|
||||
// Usually stats are accessed via getter or direct.
|
||||
// If direct property, we modify it and store original?
|
||||
// Let's modify the stat directly for now and trust Turn Manager to revert or track it.
|
||||
// BETTER: modify 'toughness' in stats, store 'buff_iron_skin' in activeBuffs?
|
||||
|
||||
targetHero.stats.toughness += effect.value;
|
||||
|
||||
// Mark for cleanup (Pseudo-implementation for cleanup)
|
||||
if (!targetHero.buffs) targetHero.buffs = [];
|
||||
targetHero.buffs.push({
|
||||
id: spell.id,
|
||||
stat: 'toughness',
|
||||
value: effect.value,
|
||||
duration: effect.duration
|
||||
});
|
||||
|
||||
if (this.game.onShowMessage) {
|
||||
this.game.onShowMessage('Piel de Hierro', `Resistencia de ${targetHero.name} +${effect.value}`);
|
||||
}
|
||||
|
||||
// Visual Effect
|
||||
if (window.RENDERER) {
|
||||
window.RENDERER.triggerVisualEffect('defense_buff', targetHero.x, targetHero.y);
|
||||
// Highlight or keep aura?
|
||||
}
|
||||
|
||||
console.log(`[MagicSystem] Applied ${spell.name} to ${targetHero.name}`);
|
||||
}
|
||||
|
||||
return { success: true, type: 'defense', target: targetHero.name };
|
||||
}
|
||||
}
|
||||
265
src/engine/game/MonsterAI.js
Normal file
@@ -0,0 +1,265 @@
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
|
||||
export class MonsterAI {
|
||||
constructor(gameEngine) {
|
||||
this.game = gameEngine;
|
||||
}
|
||||
|
||||
async executeTurn() {
|
||||
console.log("[MonsterAI] --- TURN START ---");
|
||||
|
||||
if (!this.game.monsters || this.game.monsters.length === 0) {
|
||||
console.log("[MonsterAI] No monsters active.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sequential execution with delay
|
||||
for (const monster of this.game.monsters) {
|
||||
// Check if monster still exists
|
||||
if (monster.isDead) continue;
|
||||
|
||||
// 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;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Small "thinking" pause between monsters
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
await this.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!`);
|
||||
await this.performAttack(monster, adjacentHero);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Find Closest Hero to Move Towards
|
||||
const targetHero = this.getClosestHero(monster);
|
||||
if (!targetHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} has no targets.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Calculate Path (BFS with fallback)
|
||||
const path = this.findPath(monster, targetHero, 30);
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
console.log(`[MonsterAI] ${monster.id} NO PATH (blocked) to ${targetHero.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Execute Move
|
||||
const moveDist = monster.stats.move;
|
||||
const actualPath = path.slice(0, moveDist);
|
||||
|
||||
console.log(`[MonsterAI] ${monster.id} moving towards ${targetHero.name}`, actualPath);
|
||||
|
||||
// 5. Update Renderer ONCE with full path
|
||||
if (this.game.onEntityMove) {
|
||||
this.game.onEntityMove(monster, actualPath);
|
||||
}
|
||||
|
||||
// 6. Final Logic Update (Instant coordinates)
|
||||
const finalDest = actualPath[actualPath.length - 1];
|
||||
monster.x = finalDest.x;
|
||||
monster.y = finalDest.y;
|
||||
|
||||
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
|
||||
|
||||
// 7. Check if NOW adjacent after move -> ATTACK
|
||||
// Wait for movement animation to complete before checking
|
||||
const movementDuration = actualPath.length * 600;
|
||||
await new Promise(resolve => {
|
||||
setTimeout(async () => {
|
||||
const postMoveHero = this.getAdjacentHero(monster);
|
||||
if (postMoveHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
|
||||
await this.performAttack(monster, postMoveHero);
|
||||
}
|
||||
resolve();
|
||||
}, movementDuration);
|
||||
});
|
||||
}
|
||||
|
||||
getClosestHero(monster) {
|
||||
let nearest = null;
|
||||
let minDist = Infinity;
|
||||
|
||||
this.game.heroes.forEach(hero => {
|
||||
if (!hero.isConscious && hero.isDead) return;
|
||||
|
||||
const dist = Math.abs(monster.x - hero.x) + Math.abs(monster.y - hero.y);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
nearest = hero;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
isEntityAdjacentToHero(entity) {
|
||||
return this.game.heroes.some(hero => {
|
||||
const dx = Math.abs(entity.x - hero.x);
|
||||
const dy = Math.abs(entity.y - hero.y);
|
||||
return (dx + dy) === 1;
|
||||
});
|
||||
}
|
||||
|
||||
isOccupied(x, y, fromX, fromY) {
|
||||
// Check if target cell exists in grid
|
||||
if (!this.game.dungeon.grid.isOccupied(x, y)) {
|
||||
return true; // Wall/Void
|
||||
}
|
||||
|
||||
// Check Heroes
|
||||
if (this.game.heroes.some(h => h.x === x && h.y === y)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check Monsters
|
||||
if (this.game.monsters.some(m => m.x === x && m.y === y)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NEW: Check if movement is valid (room boundaries, height levels, stairs)
|
||||
if (fromX !== undefined && fromY !== undefined) {
|
||||
if (!this.game.dungeon.grid.canMoveBetween(fromX, fromY, x, y)) {
|
||||
return true; // Movement blocked by room boundary or height restriction
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
findPath(start, goal, limit = 50) {
|
||||
const queue = [{ x: start.x, y: start.y, path: [] }];
|
||||
const visited = new Set([`${start.x},${start.y}`]);
|
||||
|
||||
let bestPath = null;
|
||||
let minDistToGoal = Infinity;
|
||||
|
||||
// Init min dist (Manhattan)
|
||||
minDistToGoal = Math.abs(start.x - goal.x) + Math.abs(start.y - goal.y);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
|
||||
const dist = Math.abs(current.x - goal.x) + Math.abs(current.y - goal.y);
|
||||
|
||||
// Success: Adjacent to goal
|
||||
if (dist === 1) {
|
||||
return current.path;
|
||||
}
|
||||
|
||||
// Update Best Fallback: closest we got to the target so far
|
||||
if (dist < minDistToGoal) {
|
||||
minDistToGoal = dist;
|
||||
bestPath = current.path;
|
||||
}
|
||||
|
||||
if (current.path.length >= limit) continue;
|
||||
|
||||
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) {
|
||||
// Check if movement from current to neighbor is valid
|
||||
// This now includes room boundary, height, and stair checks
|
||||
if (this.isOccupied(n.x, n.y, current.x, current.y)) continue;
|
||||
|
||||
const key = `${n.x},${n.y}`;
|
||||
if (!visited.has(key)) {
|
||||
visited.add(key);
|
||||
queue.push({
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
path: [...current.path, { x: n.x, y: n.y }]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhausted reachable tiles or limit, return the best path found (e.g. getting closer)
|
||||
// Only return if we actually have a path to move (length > 0)
|
||||
return bestPath;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (window.RENDERER && window.RENDERER.setEntityTarget) {
|
||||
window.RENDERER.setEntityTarget(hero.id, true);
|
||||
}
|
||||
|
||||
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
|
||||
|
||||
if (this.game.onEntityActive) {
|
||||
this.game.onEntityActive(monster.id, true);
|
||||
}
|
||||
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
if (this.game.onEntityActive) {
|
||||
this.game.onEntityActive(monster.id, false);
|
||||
}
|
||||
if (window.RENDERER && window.RENDERER.setEntityTarget) {
|
||||
window.RENDERER.setEntityTarget(hero.id, false);
|
||||
}
|
||||
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
}
|
||||
|
||||
// Snappier transition (800ms vs 1500ms)
|
||||
setTimeout(resolve, 800);
|
||||
}, 500); // Wait 500ms for attack "focus"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getAdjacentHero(entity) {
|
||||
return this.game.heroes.find(hero => {
|
||||
// Check conscious or allow beating unconscious? standard rules say monsters attack unconscious heroes until death.
|
||||
// But let's check basic mechanics first.
|
||||
// "Cuando al Aventurero no le quedan más Heridas cae al suelo inconsciente... El Aventurero no está necesariamente muerto"
|
||||
// "Continúa anotando el número de Heridas hasta que no le quedan más... nunca puede bajar de 0."
|
||||
// Implicitly, they can still be attacked.
|
||||
|
||||
if (hero.isDead) return false;
|
||||
|
||||
const dx = Math.abs(entity.x - hero.x);
|
||||
const dy = Math.abs(entity.y - hero.y);
|
||||
|
||||
// Must be orthogonally adjacent (Manhattan dist 1)
|
||||
if ((dx + dy) !== 1) return false;
|
||||
|
||||
// NEW: Check if movement between monster and hero is valid
|
||||
// This prevents attacking through walls/room boundaries
|
||||
if (!this.game.dungeon.grid.canMoveBetween(entity.x, entity.y, hero.x, hero.y)) {
|
||||
return false; // Wall or room boundary blocks attack
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ export class TurnManager {
|
||||
this.eventsTriggered = [];
|
||||
}
|
||||
|
||||
get power() {
|
||||
return this.currentPowerRoll;
|
||||
}
|
||||
|
||||
startGame() {
|
||||
this.currentTurn = 1;
|
||||
console.log(`--- TURN ${this.currentTurn} START ---`);
|
||||
@@ -51,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() {
|
||||
@@ -84,6 +97,7 @@ export class TurnManager {
|
||||
|
||||
endTurn() {
|
||||
console.log(`--- TURN ${this.currentTurn} END ---`);
|
||||
this.emit('turn_ended', this.currentTurn);
|
||||
this.currentTurn++;
|
||||
this.startPowerPhase();
|
||||
}
|
||||
|
||||
249
src/main.js
@@ -2,6 +2,7 @@ import { GameEngine } from './engine/game/GameEngine.js';
|
||||
import { GameRenderer } from './view/GameRenderer.js';
|
||||
import { CameraManager } from './view/CameraManager.js';
|
||||
import { UIManager } from './view/UIManager.js';
|
||||
import { SoundManager } from './view/SoundManager.js';
|
||||
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
|
||||
|
||||
|
||||
@@ -19,10 +20,15 @@ const renderer = new GameRenderer('app');
|
||||
const cameraManager = new CameraManager(renderer);
|
||||
const game = new GameEngine();
|
||||
const ui = new UIManager(cameraManager, game);
|
||||
const soundManager = new SoundManager();
|
||||
|
||||
// Start Music (Autoplay handling included in manager)
|
||||
soundManager.playMusic('exploration');
|
||||
|
||||
// Global Access
|
||||
window.GAME = game;
|
||||
window.RENDERER = renderer;
|
||||
window.SOUND_MANAGER = soundManager;
|
||||
|
||||
// 3. Connect Dungeon Generator to Renderer
|
||||
const generator = game.dungeon;
|
||||
@@ -36,6 +42,21 @@ generator.grid.placeTile = (instance, variant, card) => {
|
||||
|
||||
setTimeout(() => {
|
||||
renderer.renderExits(generator.availableExits);
|
||||
|
||||
// Don't show modal if we are not in Exploration phase (e.g. during Setup)
|
||||
if (game.turnManager.currentPhase !== 'exploration') {
|
||||
return;
|
||||
}
|
||||
|
||||
// NEW RULE: Exploration ends turn immediately. No monsters yet.
|
||||
// Monsters appear when a hero ENTERS the new room in the next turn.
|
||||
ui.showModal('Exploración Completada',
|
||||
'Has colocado una nueva sección de mazmorra.<br>El turno termina aquí.',
|
||||
() => {
|
||||
game.turnManager.endTurn();
|
||||
}
|
||||
);
|
||||
|
||||
}, 50);
|
||||
};
|
||||
|
||||
@@ -51,10 +72,149 @@ game.onEntityUpdate = (entity) => {
|
||||
}
|
||||
};
|
||||
|
||||
game.turnManager.on('phase_changed', (phase) => {
|
||||
if (phase === 'monster') {
|
||||
setTimeout(async () => {
|
||||
await game.playMonsterTurn();
|
||||
|
||||
// Logic: Skip Exploration if monsters are alive
|
||||
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
|
||||
|
||||
if (hasActiveMonsters) {
|
||||
ui.showModal('¡Combate en curso!',
|
||||
'Aún quedan monstruos vivos. Se salta la Fase de Exploración.<br>Preparaos para la <b>Fase de Poder</b> del siguiente turno.',
|
||||
() => {
|
||||
// Combat Loop: Power -> Hero -> Monster -> (Skip Exp) -> Power...
|
||||
game.turnManager.endTurn();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
ui.showModal('Zona Despejada',
|
||||
'Fase de Monstruos Finalizada.<br>Pulsa para continuar a la Fase de Exploración.',
|
||||
() => {
|
||||
game.turnManager.nextPhase(); // Go to Exploration
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}, 500); // Slight delay for visual impact
|
||||
}
|
||||
});
|
||||
|
||||
game.onCombatResult = (log) => {
|
||||
// 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';
|
||||
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
|
||||
}
|
||||
|
||||
// 3. Show Damage on Defender (Floating)
|
||||
if (defender) {
|
||||
setTimeout(() => { // Slight delay for cause-effect
|
||||
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
game.onEntityMove = (entity, path) => {
|
||||
renderer.moveEntityAlongPath(entity, path);
|
||||
};
|
||||
|
||||
game.onEntityActive = (entityId, isActive) => {
|
||||
renderer.setEntityActive(entityId, isActive);
|
||||
};
|
||||
|
||||
game.onEntityHit = (entityId) => {
|
||||
renderer.triggerDamageEffect(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) => {
|
||||
// 1. Draw Visuals
|
||||
renderer.showRangedTargeting(game.selectedEntity, targetMonster, losResult);
|
||||
|
||||
// 2. UI
|
||||
if (targetMonster && losResult && losResult.clear) {
|
||||
ui.showRangedAttackUI(targetMonster);
|
||||
} else {
|
||||
ui.hideMonsterCard();
|
||||
if (targetMonster && losResult && !losResult.clear && losResult.blocker) {
|
||||
let msg = 'Línea de visión bloqueada.';
|
||||
if (losResult.blocker.type === 'hero') msg = `Bloqueado por aliado: ${losResult.blocker.entity.name}`;
|
||||
if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`;
|
||||
if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`;
|
||||
|
||||
// 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) => {
|
||||
@@ -91,9 +251,11 @@ game.onPathChange = (path) => {
|
||||
|
||||
// 6. Handle Clicks
|
||||
const handleClick = (x, y, doorMesh) => {
|
||||
const currentPhase = game.turnManager.currentPhase;
|
||||
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
|
||||
|
||||
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
||||
if (generator.state === 'PLACING_TILE') {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,6 +268,18 @@ const handleClick = (x, y, doorMesh) => {
|
||||
|
||||
if (!doorMesh.userData.isOpen) {
|
||||
|
||||
// CHECK PHASE: Exploration Only
|
||||
if (currentPhase !== 'exploration') {
|
||||
ui.showModal('Fase Incorrecta', 'Solo puedes explorar (abrir puertas) durante la <b>Fase de Exploración</b>.');
|
||||
return;
|
||||
}
|
||||
|
||||
// CHECK MONSTERS: Must be clear
|
||||
if (hasActiveMonsters) {
|
||||
ui.showModal('¡Peligro!', 'No puedes explorar mientras hay <b>Monstruos</b> cerca. ¡Acaba con ellos primero!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Check Selection and Leadership (STRICT)
|
||||
const selectedHero = game.selectedEntity;
|
||||
|
||||
@@ -120,22 +294,40 @@ const handleClick = (x, y, doorMesh) => {
|
||||
}
|
||||
|
||||
// 2. Check Adjacency
|
||||
// Since we know selectedHero IS the leader, we can just check if *this* hero is adjacent.
|
||||
// game.isLeaderAdjacentToDoor checks the 'getLeader()' position, which aligns with selectedHero here.
|
||||
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) {
|
||||
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);
|
||||
|
||||
// Allow UI to update phase if not already
|
||||
// if (game.turnManager.currentPhase !== 'exploration') {
|
||||
// game.turnManager.setPhase('exploration');
|
||||
// }
|
||||
} else {
|
||||
console.error('[Main] Door missing exitData');
|
||||
}
|
||||
@@ -148,6 +340,17 @@ const handleClick = (x, y, doorMesh) => {
|
||||
|
||||
// PRIORITY 3: Normal cell click (player selection/movement)
|
||||
if (x !== null && y !== null) {
|
||||
// Restrict Hero Selection/Movement to Hero Phase (and verify logic in GameEngine handle selection)
|
||||
// Actually, we might want to select heroes in other phases to see stats, but MOVE only in Hero Phase.
|
||||
// GameEngine.planStep handles planning.
|
||||
|
||||
// We let GameEngine handle selection. But for movement planning...
|
||||
// Let's modify onCellClick inside GameEngine or just block here?
|
||||
// Blocking execution is safer.
|
||||
|
||||
// Wait, onCellClick handles Selection AND Planning.
|
||||
// We'll let it select. But we hook executeMovePath separately.
|
||||
|
||||
game.onCellClick(x, y);
|
||||
}
|
||||
};
|
||||
@@ -157,7 +360,16 @@ renderer.setupInteraction(
|
||||
handleClick,
|
||||
() => {
|
||||
// Right Click Handler
|
||||
if (game.targetingMode === 'spell' || game.targetingMode === 'ranged') {
|
||||
game.cancelTargeting();
|
||||
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||
ui.showTemporaryMessage('Cancelado', 'Lanzamiento de hechizo cancelado.', 1000);
|
||||
return;
|
||||
}
|
||||
game.executeMovePath();
|
||||
},
|
||||
(x, y) => {
|
||||
if (game.onCellHover) game.onCellHover(x, y);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -175,10 +387,31 @@ window.addEventListener('keydown', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
game.onEventTriggered = (eventResult) => {
|
||||
if (eventResult) {
|
||||
if (eventResult.type === 'MONSTER_SPAWN') {
|
||||
const count = eventResult.count || 0;
|
||||
ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen <b>${count} Enemigos</b>!<br>Tu movimiento se detiene.`);
|
||||
} else if (eventResult.message) {
|
||||
ui.showModal('Zona Explorada', `${eventResult.message}<br>Tu movimiento se detiene.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 7. Start
|
||||
|
||||
game.startMission(mission);
|
||||
|
||||
// Mark initial tile as visited to prevent immediate trigger
|
||||
if (game.heroes && game.heroes.length > 0) {
|
||||
const h = game.heroes[0];
|
||||
const initialTileId = game.dungeon.grid.occupiedCells.get(`${h.x},${h.y}`);
|
||||
if (initialTileId) {
|
||||
game.visitedRoomIds.add(initialTileId);
|
||||
console.log(`[Main] Initial tile ${initialTileId} marked as visited.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Render Loop
|
||||
const animate = (time) => {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
@@ -123,7 +123,7 @@ export class CameraManager {
|
||||
// Direction: Dragging the "World"
|
||||
// Mouse Left (dx < 0) -> Camera moves Right (+X)
|
||||
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
|
||||
const moveX = -dx * moveSpeed;
|
||||
const moveX = dx * moveSpeed;
|
||||
const moveY = dy * moveSpeed;
|
||||
|
||||
// Apply to Camera (Local Space)
|
||||
@@ -161,12 +161,18 @@ export class CameraManager {
|
||||
if (this.animationProgress >= 1) {
|
||||
this.isAnimating = false;
|
||||
this.camera.position.copy(this.animationTargetPos);
|
||||
if (this.onAnimationComplete) {
|
||||
this.onAnimationComplete();
|
||||
this.onAnimationComplete = null; // Consume callback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fixed Orbit Logic ---
|
||||
setIsoView(direction) {
|
||||
this.lastIsoDirection = direction || DIRECTIONS.NORTH;
|
||||
|
||||
// Rotate camera around target while maintaining isometric angle
|
||||
// Isometric view: 45 degree angle from horizontal
|
||||
const distance = 28; // Distance from target
|
||||
@@ -207,4 +213,31 @@ export class CameraManager {
|
||||
|
||||
this.currentViewAngle = horizontalAngle;
|
||||
}
|
||||
|
||||
toggleViewMode() {
|
||||
if (this.viewMode === '2D') {
|
||||
this.viewMode = '3D';
|
||||
this.setIsoView(this.lastIsoDirection);
|
||||
return true; // Is 3D
|
||||
} else {
|
||||
this.viewMode = '2D';
|
||||
this.setZenithalView();
|
||||
return false; // Is 2D
|
||||
}
|
||||
}
|
||||
|
||||
setZenithalView() {
|
||||
// Top-down view (Zenithal)
|
||||
const height = 40;
|
||||
// Slight Z offset to Ensure North is Up (avoiding gimbal lock with Up=(0,1,0))
|
||||
const x = this.target.x;
|
||||
const z = this.target.z + 0.01;
|
||||
const y = height;
|
||||
|
||||
this.animationStartPos.copy(this.camera.position);
|
||||
this.animationTargetPos.set(x, y, z);
|
||||
this.animationProgress = 0;
|
||||
this.animationStartTime = performance.now();
|
||||
this.isAnimating = true;
|
||||
}
|
||||
}
|
||||
|
||||
216
src/view/ParticleManager.js
Normal file
@@ -0,0 +1,216 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class ParticleManager {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.particles = [];
|
||||
// Optional: Preload textures here if needed, or create them procedurally on canvas
|
||||
}
|
||||
|
||||
createTexture(color, type = 'circle') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (type === 'circle') {
|
||||
const grad = ctx.createRadialGradient(16, 16, 0, 16, 16, 16);
|
||||
grad.addColorStop(0, color);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
} else if (type === 'star') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(16, 0); ctx.lineTo(20, 12);
|
||||
ctx.lineTo(32, 16); ctx.lineTo(20, 20);
|
||||
ctx.lineTo(16, 32); ctx.lineTo(12, 20);
|
||||
ctx.lineTo(0, 16); ctx.lineTo(12, 12);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// Generic Emitter
|
||||
emit(x, y, z, options = {}) {
|
||||
const count = options.count || 10;
|
||||
const color = options.color || '#ffaa00';
|
||||
const speed = options.speed || 0.1;
|
||||
const life = options.life || 1.0; // seconds
|
||||
const type = options.type || 'circle';
|
||||
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: this.createTexture(color, type),
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.position.set(x, y, z);
|
||||
|
||||
// Random velocity
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.random() * Math.PI;
|
||||
const v = (Math.random() * 0.5 + 0.5) * speed;
|
||||
|
||||
sprite.userData = {
|
||||
velocity: new THREE.Vector3(
|
||||
Math.cos(theta) * Math.sin(phi) * v,
|
||||
Math.cos(phi) * v, // Upward bias?
|
||||
Math.sin(theta) * Math.sin(phi) * v
|
||||
),
|
||||
life: life,
|
||||
maxLife: life,
|
||||
scaleSpeed: options.scaleSpeed || 0
|
||||
};
|
||||
|
||||
// Scale variation
|
||||
const startScale = options.scale || 0.5;
|
||||
sprite.scale.setScalar(startScale);
|
||||
sprite.userData.startScale = startScale;
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.particles.push(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
spawnFireballExplosion(x, y) {
|
||||
// World coordinates: x, 0.5, y (assuming y is vertical, wait, 3D grid y is usually z?)
|
||||
// In our game: x is x, y is z (flat), y-up is height.
|
||||
// Let's check coordinates. Usually map x,y maps to 3D x,0,z or x,z, (-y).
|
||||
// GameRenderer uses x, 0, y for positions typically.
|
||||
|
||||
// Emitter
|
||||
this.emit(x, 0.5, y, {
|
||||
count: 20,
|
||||
color: '#ff4400',
|
||||
speed: 0.15,
|
||||
life: 0.8,
|
||||
type: 'circle',
|
||||
scale: 0.8,
|
||||
scaleSpeed: -1.0 // Shrink
|
||||
});
|
||||
this.emit(x, 0.5, y, {
|
||||
count: 10,
|
||||
color: '#ffff00',
|
||||
speed: 0.1,
|
||||
life: 0.5,
|
||||
type: 'circle',
|
||||
scale: 0.5
|
||||
});
|
||||
}
|
||||
|
||||
spawnHealEffect(x, y) {
|
||||
// Upward floating particles
|
||||
const count = 15;
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: this.createTexture('#00ff00', 'star'),
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sprite = new THREE.Sprite(material);
|
||||
// Random spread around center
|
||||
const ox = (Math.random() - 0.5) * 0.6;
|
||||
const oy = (Math.random() - 0.5) * 0.6;
|
||||
|
||||
sprite.position.set(x + ox, 0.2, y + oy);
|
||||
|
||||
sprite.userData = {
|
||||
velocity: new THREE.Vector3(0, 0.05 + Math.random() * 0.05, 0), // Up only
|
||||
life: 1.5,
|
||||
maxLife: 1.5
|
||||
};
|
||||
sprite.scale.setScalar(0.3);
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.particles.push(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
spawnProjectile(startX, startZ, endX, endZ, onHit) {
|
||||
// Simple Projectile (a sprite that moves)
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: this.createTexture('#ffaa00', 'circle'),
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.scale.setScalar(0.4);
|
||||
// Start height 1.5 (caster head level)
|
||||
sprite.position.set(startX, 1.5, startZ);
|
||||
|
||||
const speed = 15.0; // Units per second
|
||||
const dist = Math.sqrt((endX - startX) ** 2 + (endZ - startZ) ** 2);
|
||||
const duration = dist / speed;
|
||||
|
||||
sprite.userData = {
|
||||
isProjectile: true,
|
||||
startPos: new THREE.Vector3(startX, 1.5, startZ),
|
||||
targetPos: new THREE.Vector3(endX, 0.5, endZ), // Target floor/center
|
||||
time: 0,
|
||||
duration: duration,
|
||||
onHit: onHit
|
||||
};
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.particles.push(sprite);
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
for (let i = this.particles.length - 1; i >= 0; i--) {
|
||||
const p = this.particles[i];
|
||||
|
||||
if (p.userData.isProjectile) {
|
||||
p.userData.time += dt;
|
||||
const t = Math.min(1, p.userData.time / p.userData.duration);
|
||||
|
||||
p.position.lerpVectors(p.userData.startPos, p.userData.targetPos, t);
|
||||
|
||||
// Trail effect
|
||||
if (Math.random() > 0.5) {
|
||||
this.emit(p.position.x, p.position.y, p.position.z, {
|
||||
count: 1, color: '#ff4400', life: 0.3, scale: 0.2, speed: 0.05
|
||||
});
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
// Hit!
|
||||
if (p.userData.onHit) p.userData.onHit();
|
||||
this.scene.remove(p);
|
||||
this.particles.splice(i, 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal Particle Update
|
||||
// Move
|
||||
p.position.add(p.userData.velocity);
|
||||
|
||||
// Life
|
||||
p.userData.life -= dt;
|
||||
const progress = 1 - (p.userData.life / p.userData.maxLife);
|
||||
|
||||
// Opacity Fade
|
||||
p.material.opacity = p.userData.life / p.userData.maxLife;
|
||||
|
||||
// Scale Change
|
||||
if (p.userData.scaleSpeed) {
|
||||
const s = Math.max(0.01, p.userData.startScale + p.userData.scaleSpeed * progress);
|
||||
p.scale.setScalar(s);
|
||||
}
|
||||
|
||||
if (p.userData.life <= 0) {
|
||||
this.scene.remove(p);
|
||||
this.particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/view/SoundManager.js
Normal file
@@ -0,0 +1,143 @@
|
||||
|
||||
export class SoundManager {
|
||||
constructor() {
|
||||
this.musicVolume = 0.3; // Default volume (not too loud)
|
||||
this.sfxVolume = 0.5;
|
||||
this.currentMusic = null;
|
||||
this.isMuted = false;
|
||||
|
||||
// Asset Library
|
||||
this.assets = {
|
||||
music: {
|
||||
'exploration': '/assets/music/ingame/Abandoned_Ruins.mp3'
|
||||
},
|
||||
sfx: {
|
||||
'door_open': '/assets/sfx/opendoor.mp3',
|
||||
'footsteps': '/assets/sfx/footsteps.mp3',
|
||||
'sword': '/assets/sfx/sword1.mp3',
|
||||
'arrow': '/assets/sfx/arrow.mp3',
|
||||
'gate_chains': '/assets/sfx/gate_chains_open.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
this.initialized = false;
|
||||
this.activeLoops = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the audio context if needed (browser restriction handling)
|
||||
* Can be called on the first user interaction (click)
|
||||
*/
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
console.log("[SoundManager] Audio System Initialized");
|
||||
}
|
||||
|
||||
playMusic(key) {
|
||||
if (this.isMuted) return;
|
||||
|
||||
const url = this.assets.music[key];
|
||||
if (!url) {
|
||||
console.warn(`[SoundManager] Music track not found: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If same track is playing, do nothing
|
||||
if (this.currentMusic && this.currentMusic.src.includes(url) && !this.currentMusic.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current
|
||||
this.stopMusic();
|
||||
|
||||
// Start new
|
||||
this.currentMusic = new Audio(url);
|
||||
this.currentMusic.loop = true;
|
||||
this.currentMusic.volume = this.musicVolume;
|
||||
|
||||
// Handle autoplay promises
|
||||
const playPromise = this.currentMusic.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(error => {
|
||||
console.log("[SoundManager] Autoplay prevented. Waiting for user interaction.");
|
||||
// We can add a one-time click listener to window to resume if needed
|
||||
const resume = () => {
|
||||
this.currentMusic.play();
|
||||
window.removeEventListener('click', resume);
|
||||
window.removeEventListener('keydown', resume);
|
||||
};
|
||||
window.addEventListener('click', resume);
|
||||
window.addEventListener('keydown', resume);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SoundManager] Playing music: ${key}`);
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.pause();
|
||||
this.currentMusic.currentTime = 0;
|
||||
this.currentMusic = null;
|
||||
}
|
||||
}
|
||||
|
||||
setMusicVolume(vol) {
|
||||
this.musicVolume = Math.max(0, Math.min(1, vol));
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.volume = this.musicVolume;
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.isMuted = !this.isMuted;
|
||||
if (this.isMuted) {
|
||||
if (this.currentMusic) this.currentMusic.pause();
|
||||
} else {
|
||||
if (this.currentMusic) this.currentMusic.play();
|
||||
}
|
||||
return this.isMuted;
|
||||
}
|
||||
|
||||
playSound(key) {
|
||||
if (this.isMuted) return;
|
||||
|
||||
const url = this.assets.sfx[key];
|
||||
if (!url) {
|
||||
console.warn(`[SoundManager] SFX not found: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.volume = this.sfxVolume;
|
||||
// Fire and forget, but catch errors
|
||||
audio.play().catch(e => {
|
||||
// Check if error is NotAllowedError (autoplay) - silently ignore usually for SFX
|
||||
// or log if needed
|
||||
});
|
||||
}
|
||||
|
||||
startLoop(key) {
|
||||
if (this.isMuted) return;
|
||||
if (this.activeLoops.has(key)) return; // Already playing
|
||||
|
||||
const url = this.assets.sfx[key];
|
||||
if (!url) return;
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.loop = true;
|
||||
audio.volume = this.sfxVolume;
|
||||
audio.play().catch(() => { });
|
||||
this.activeLoops.set(key, audio);
|
||||
}
|
||||
|
||||
stopLoop(key) {
|
||||
const audio = this.activeLoops.get(key);
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
this.activeLoops.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,58 @@
|
||||
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||
import { HUDManager } from './ui/HUDManager.js';
|
||||
import { UnitCardManager } from './ui/UnitCardManager.js';
|
||||
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) {
|
||||
this.cameraManager = cameraManager;
|
||||
this.game = gameEngine;
|
||||
this.dungeon = gameEngine.dungeon;
|
||||
this.selectedHero = null;
|
||||
|
||||
this.createHUD();
|
||||
this.createGameStatusPanel(); // New Panel
|
||||
this.setupMinimapLoop();
|
||||
this.setupGameListeners(); // New Listeners
|
||||
this.container = this.createMainContainer();
|
||||
|
||||
// Hook into engine callbacks for UI updates
|
||||
// Instantiate Subsystems
|
||||
this.hud = new HUDManager(this.container, cameraManager, gameEngine);
|
||||
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),
|
||||
toggleInventory: (h) => this.inventory.toggle(h)
|
||||
};
|
||||
this.cards = new UnitCardManager(this.container, gameEngine, cardCallbacks);
|
||||
|
||||
const placementCallbacks = {
|
||||
showModal: (t, m) => this.feedback.showModal(t, m),
|
||||
showConfirm: (t, m, c) => this.feedback.showConfirm(t, m, c)
|
||||
};
|
||||
this.placement = new PlacementUI(this.container, gameEngine, placementCallbacks);
|
||||
|
||||
this.selectedHero = null; // State tracking for coordination
|
||||
|
||||
this.setupGameListeners();
|
||||
}
|
||||
|
||||
createMainContainer() {
|
||||
const c = document.createElement('div');
|
||||
Object.assign(c.style, {
|
||||
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none'
|
||||
});
|
||||
document.body.appendChild(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
setupGameListeners() {
|
||||
// Entity Selection
|
||||
const originalSelect = this.game.onEntitySelect;
|
||||
this.game.onEntitySelect = (id, isSelected) => {
|
||||
// 1. Call Renderer (was in main.js)
|
||||
// 1. Call Renderer
|
||||
if (this.cameraManager && this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.toggleEntitySelection(id, isSelected);
|
||||
} else if (window.RENDERER) {
|
||||
@@ -25,689 +62,70 @@ export class UIManager {
|
||||
// 2. Update UI
|
||||
if (isSelected) {
|
||||
const hero = this.game.heroes.find(h => h.id === id);
|
||||
this.selectedHero = hero; // Store state
|
||||
this.updateHeroStats(hero);
|
||||
const monster = this.game.monsters ? this.game.monsters.find(m => m.id === id) : null;
|
||||
|
||||
if (hero) {
|
||||
this.selectedHero = hero;
|
||||
this.turnUI.updateHeroStats(hero); // Update top panel info
|
||||
this.cards.showHeroCard(hero);
|
||||
this.cards.hideMonsterCard();
|
||||
} else if (monster) {
|
||||
// Check context: are we selecting a target?
|
||||
if (this.selectedHero && this.game.turnManager.currentPhase === 'hero') {
|
||||
this.cards.showMonsterCard(monster);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Deselection
|
||||
if (this.selectedHero && this.selectedHero.id === id) {
|
||||
this.selectedHero = null;
|
||||
this.updateHeroStats(null);
|
||||
this.turnUI.updateHeroStats(null);
|
||||
this.cards.hideHeroCard();
|
||||
} else {
|
||||
this.cards.hideMonsterCard();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Entity Move
|
||||
const originalMove = this.game.onEntityMove;
|
||||
this.game.onEntityMove = (entity, path) => {
|
||||
if (originalMove) originalMove(entity, path);
|
||||
this.updateHeroStats(entity);
|
||||
};
|
||||
}
|
||||
|
||||
createHUD() {
|
||||
// Container
|
||||
this.container = document.createElement('div');
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
this.container.style.pointerEvents = 'none'; // Click through to 3D scene
|
||||
document.body.appendChild(this.container);
|
||||
|
||||
// --- Minimap (Top Left) ---
|
||||
this.minimapCanvas = document.createElement('canvas');
|
||||
this.minimapCanvas.width = 200;
|
||||
this.minimapCanvas.height = 200;
|
||||
this.minimapCanvas.style.position = 'absolute';
|
||||
this.minimapCanvas.style.top = '10px';
|
||||
this.minimapCanvas.style.left = '10px';
|
||||
this.minimapCanvas.style.border = '2px solid #444';
|
||||
this.minimapCanvas.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||
this.minimapCanvas.style.pointerEvents = 'auto'; // Allow interaction if needed
|
||||
this.container.appendChild(this.minimapCanvas);
|
||||
|
||||
this.ctx = this.minimapCanvas.getContext('2d');
|
||||
|
||||
// --- Camera Controls (Top Right) ---
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.style.position = 'absolute';
|
||||
controlsContainer.style.top = '20px';
|
||||
controlsContainer.style.right = '20px';
|
||||
controlsContainer.style.display = 'flex';
|
||||
controlsContainer.style.gap = '10px';
|
||||
controlsContainer.style.alignItems = 'center';
|
||||
controlsContainer.style.pointerEvents = 'auto';
|
||||
this.container.appendChild(controlsContainer);
|
||||
|
||||
// Zoom slider (vertical)
|
||||
const zoomContainer = document.createElement('div');
|
||||
zoomContainer.style.display = 'flex';
|
||||
zoomContainer.style.flexDirection = 'column';
|
||||
zoomContainer.style.alignItems = 'center';
|
||||
zoomContainer.style.gap = '0px';
|
||||
zoomContainer.style.height = '140px'; // Fixed height to accommodate label + slider
|
||||
|
||||
// Zoom label
|
||||
const zoomLabel = document.createElement('div');
|
||||
zoomLabel.textContent = 'Zoom';
|
||||
zoomLabel.style.color = '#fff';
|
||||
zoomLabel.style.fontSize = '15px';
|
||||
zoomLabel.style.fontFamily = 'sans-serif';
|
||||
zoomLabel.style.marginBottom = '10px';
|
||||
zoomLabel.style.marginTop = '0px';
|
||||
|
||||
const zoomSlider = document.createElement('input');
|
||||
zoomSlider.type = 'range';
|
||||
zoomSlider.min = '3';
|
||||
zoomSlider.max = '15';
|
||||
zoomSlider.value = '6';
|
||||
zoomSlider.step = '0.5';
|
||||
zoomSlider.style.width = '100px';
|
||||
zoomSlider.style.transform = 'rotate(-90deg)';
|
||||
zoomSlider.style.transformOrigin = 'center';
|
||||
zoomSlider.style.cursor = 'pointer';
|
||||
zoomSlider.style.marginTop = '40px';
|
||||
|
||||
this.zoomSlider = zoomSlider;
|
||||
|
||||
// Set initial zoom
|
||||
this.cameraManager.zoomLevel = 6;
|
||||
this.cameraManager.updateProjection();
|
||||
|
||||
this.cameraManager.onZoomChange = (val) => {
|
||||
if (this.zoomSlider) this.zoomSlider.value = val;
|
||||
};
|
||||
|
||||
zoomSlider.oninput = (e) => {
|
||||
this.cameraManager.zoomLevel = parseFloat(e.target.value);
|
||||
this.cameraManager.updateProjection();
|
||||
};
|
||||
|
||||
zoomContainer.appendChild(zoomLabel);
|
||||
zoomContainer.appendChild(zoomSlider);
|
||||
|
||||
// Direction buttons grid
|
||||
const buttonsGrid = document.createElement('div');
|
||||
buttonsGrid.style.display = 'grid';
|
||||
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||
buttonsGrid.style.gap = '5px';
|
||||
|
||||
controlsContainer.appendChild(zoomContainer);
|
||||
controlsContainer.appendChild(buttonsGrid);
|
||||
|
||||
const createBtn = (label, dir) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.width = '40px';
|
||||
btn.style.height = '40px';
|
||||
btn.style.backgroundColor = '#333';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #666';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.transition = 'background-color 0.2s';
|
||||
btn.dataset.direction = dir; // Store direction for later reference
|
||||
btn.onclick = () => {
|
||||
this.cameraManager.setIsoView(dir);
|
||||
this.updateActiveViewButton(dir);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
// Layout: [N]
|
||||
// [W] [E]
|
||||
// [S]
|
||||
|
||||
// Grid cells: 1 2 3
|
||||
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
|
||||
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
|
||||
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
|
||||
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
|
||||
|
||||
buttonsGrid.appendChild(btnN);
|
||||
buttonsGrid.appendChild(btnW);
|
||||
buttonsGrid.appendChild(btnE);
|
||||
buttonsGrid.appendChild(btnS);
|
||||
|
||||
// Store button references for later updates
|
||||
this.viewButtons = [btnN, btnE, btnS, btnW];
|
||||
|
||||
// Set initial active button (North)
|
||||
this.updateActiveViewButton(DIRECTIONS.NORTH);
|
||||
|
||||
// --- Tile Placement Controls (Bottom Center) ---
|
||||
this.placementPanel = document.createElement('div');
|
||||
this.placementPanel.style.position = 'absolute';
|
||||
this.placementPanel.style.bottom = '20px';
|
||||
this.placementPanel.style.left = '50%';
|
||||
this.placementPanel.style.transform = 'translateX(-50%)';
|
||||
this.placementPanel.style.display = 'none'; // Hidden by default
|
||||
this.placementPanel.style.pointerEvents = 'auto';
|
||||
this.placementPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||
this.placementPanel.style.padding = '15px';
|
||||
this.placementPanel.style.borderRadius = '8px';
|
||||
this.placementPanel.style.border = '2px solid #666';
|
||||
this.container.appendChild(this.placementPanel);
|
||||
|
||||
// Status text
|
||||
this.placementStatus = document.createElement('div');
|
||||
this.placementStatus.style.color = '#fff';
|
||||
this.placementStatus.style.fontSize = '16px';
|
||||
this.placementStatus.style.fontFamily = 'sans-serif';
|
||||
this.placementStatus.style.marginBottom = '10px';
|
||||
this.placementStatus.style.textAlign = 'center';
|
||||
this.placementStatus.textContent = 'Coloca la loseta';
|
||||
this.placementPanel.appendChild(this.placementStatus);
|
||||
|
||||
// Controls container
|
||||
const placementControls = document.createElement('div');
|
||||
placementControls.style.display = 'flex';
|
||||
placementControls.style.gap = '15px';
|
||||
placementControls.style.alignItems = 'center';
|
||||
this.placementPanel.appendChild(placementControls);
|
||||
|
||||
// Movement arrows (4-way grid)
|
||||
const arrowGrid = document.createElement('div');
|
||||
arrowGrid.style.display = 'grid';
|
||||
arrowGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||
arrowGrid.style.gap = '3px';
|
||||
|
||||
const createArrow = (label, dx, dy) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.width = '40px';
|
||||
btn.style.height = '40px';
|
||||
btn.style.backgroundColor = '#444';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #888';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.fontSize = '18px';
|
||||
btn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
this.dungeon.movePlacement(dx, dy);
|
||||
this.turnUI.updateHeroStats(entity);
|
||||
if (entity.type === 'hero') {
|
||||
this.cards.updateHeroCard(entity.id);
|
||||
}
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
const arrowUp = createArrow('↑', 0, 1);
|
||||
const arrowLeft = createArrow('←', -1, 0);
|
||||
const arrowRight = createArrow('→', 1, 0);
|
||||
const arrowDown = createArrow('↓', 0, -1);
|
||||
|
||||
arrowUp.style.gridColumn = '2';
|
||||
arrowLeft.style.gridColumn = '1';
|
||||
arrowRight.style.gridColumn = '3';
|
||||
arrowDown.style.gridColumn = '2';
|
||||
|
||||
arrowGrid.appendChild(arrowUp);
|
||||
arrowGrid.appendChild(arrowLeft);
|
||||
arrowGrid.appendChild(arrowRight);
|
||||
arrowGrid.appendChild(arrowDown);
|
||||
|
||||
placementControls.appendChild(arrowGrid);
|
||||
|
||||
// Rotate button
|
||||
this.rotateBtn = document.createElement('button');
|
||||
this.rotateBtn.textContent = '🔄 Rotar';
|
||||
this.rotateBtn.style.padding = '10px 20px';
|
||||
this.rotateBtn.style.backgroundColor = '#555';
|
||||
this.rotateBtn.style.color = '#fff';
|
||||
this.rotateBtn.style.border = '1px solid #888';
|
||||
this.rotateBtn.style.cursor = 'pointer';
|
||||
this.rotateBtn.style.fontSize = '16px';
|
||||
this.rotateBtn.style.borderRadius = '4px';
|
||||
this.rotateBtn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
this.dungeon.rotatePlacement();
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.rotateBtn);
|
||||
|
||||
this.placeBtn = document.createElement('button');
|
||||
this.placeBtn.textContent = '⬇ Bajar';
|
||||
this.placeBtn.style.padding = '10px 20px';
|
||||
this.placeBtn.style.backgroundColor = '#2a5';
|
||||
this.placeBtn.style.color = '#fff';
|
||||
this.placeBtn.style.border = '1px solid #888';
|
||||
this.placeBtn.style.cursor = 'pointer';
|
||||
this.placeBtn.style.fontSize = '16px';
|
||||
this.placeBtn.style.borderRadius = '4px';
|
||||
this.placeBtn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
const success = this.dungeon.confirmPlacement();
|
||||
if (!success) {
|
||||
this.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
|
||||
}
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.placeBtn);
|
||||
|
||||
// Discard button
|
||||
this.discardBtn = document.createElement('button');
|
||||
this.discardBtn.textContent = '❌ Cancelar';
|
||||
this.discardBtn.style.padding = '10px 20px';
|
||||
this.discardBtn.style.backgroundColor = '#d33';
|
||||
this.discardBtn.style.color = '#fff';
|
||||
this.discardBtn.style.border = '1px solid #888';
|
||||
this.discardBtn.style.cursor = 'pointer';
|
||||
this.discardBtn.style.fontSize = '16px';
|
||||
this.discardBtn.style.borderRadius = '4px';
|
||||
this.discardBtn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
this.showConfirm(
|
||||
'Confirmar acción',
|
||||
'¿Quieres descartar esta loseta y bloquear la puerta?',
|
||||
() => {
|
||||
this.dungeon.cancelPlacement();
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.discardBtn);
|
||||
}
|
||||
|
||||
showPlacementControls(show) {
|
||||
if (this.placementPanel) {
|
||||
this.placementPanel.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updatePlacementStatus(isValid) {
|
||||
if (this.placementStatus) {
|
||||
if (isValid) {
|
||||
this.placementStatus.textContent = '✅ Posición válida';
|
||||
this.placementStatus.style.color = '#0f0';
|
||||
this.placeBtn.style.backgroundColor = '#2a5';
|
||||
this.placeBtn.style.cursor = 'pointer';
|
||||
} else {
|
||||
this.placementStatus.textContent = '❌ Posición inválida';
|
||||
this.placementStatus.style.color = '#f44';
|
||||
this.placeBtn.style.backgroundColor = '#555';
|
||||
this.placeBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveViewButton(activeDirection) {
|
||||
// Reset all buttons to default color
|
||||
this.viewButtons.forEach(btn => {
|
||||
btn.style.backgroundColor = '#333';
|
||||
});
|
||||
|
||||
// Highlight the active button
|
||||
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
|
||||
if (activeBtn) {
|
||||
activeBtn.style.backgroundColor = '#f0c040'; // Yellow/gold color
|
||||
}
|
||||
}
|
||||
|
||||
setupMinimapLoop() {
|
||||
const loop = () => {
|
||||
this.drawMinimap();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
drawMinimap() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.minimapCanvas.width;
|
||||
const h = this.minimapCanvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cellSize = 5;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
|
||||
ctx.fillStyle = '#666'; // Generic floor
|
||||
|
||||
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const cx = centerX + (x * cellSize);
|
||||
const cy = centerY - (y * cellSize);
|
||||
|
||||
if (tileId.includes('room')) ctx.fillStyle = '#55a';
|
||||
else ctx.fillStyle = '#aaa';
|
||||
|
||||
ctx.fillRect(cx, cy, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// Draw Exits (Available)
|
||||
ctx.fillStyle = '#0f0'; // Green dots for open exits
|
||||
if (this.dungeon.availableExits) {
|
||||
this.dungeon.availableExits.forEach(exit => {
|
||||
const ex = centerX + (exit.x * cellSize);
|
||||
const ey = centerY - (exit.y * cellSize);
|
||||
ctx.fillRect(ex, ey, cellSize, cellSize);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw Entry (0,0) cross
|
||||
ctx.strokeStyle = '#f00';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 5, centerY);
|
||||
ctx.lineTo(centerX + 5, centerY);
|
||||
ctx.moveTo(centerX, centerY - 5);
|
||||
ctx.lineTo(centerX, centerY + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
showModal(title, message) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||
overlay.style.zIndex = '1000';
|
||||
|
||||
// Content Box
|
||||
const content = document.createElement('div');
|
||||
content.style.backgroundColor = '#222';
|
||||
content.style.border = '2px solid #888';
|
||||
content.style.borderRadius = '8px';
|
||||
content.style.padding = '20px';
|
||||
content.style.width = '300px';
|
||||
content.style.textAlign = 'center';
|
||||
content.style.color = '#fff';
|
||||
content.style.fontFamily = 'sans-serif';
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.marginTop = '0';
|
||||
titleEl.style.color = '#f44'; // Reddish for importance
|
||||
content.appendChild(titleEl);
|
||||
|
||||
// Message
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
msgEl.style.fontSize = '16px';
|
||||
msgEl.style.lineHeight = '1.5';
|
||||
content.appendChild(msgEl);
|
||||
|
||||
// OK Button
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Entendido';
|
||||
btn.style.marginTop = '20px';
|
||||
btn.style.padding = '10px 20px';
|
||||
btn.style.fontSize = '16px';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.backgroundColor = '#444';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #888';
|
||||
btn.onclick = () => {
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
content.appendChild(btn);
|
||||
|
||||
overlay.appendChild(content);
|
||||
this.container.appendChild(overlay);
|
||||
}
|
||||
|
||||
showConfirm(title, message, onConfirm) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||
overlay.style.zIndex = '1000';
|
||||
|
||||
// Content Box
|
||||
const content = document.createElement('div');
|
||||
content.style.backgroundColor = '#222';
|
||||
content.style.border = '2px solid #888';
|
||||
content.style.borderRadius = '8px';
|
||||
content.style.padding = '20px';
|
||||
content.style.width = '300px';
|
||||
content.style.textAlign = 'center';
|
||||
content.style.color = '#fff';
|
||||
content.style.fontFamily = 'sans-serif';
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.marginTop = '0';
|
||||
titleEl.style.color = '#f44';
|
||||
content.appendChild(titleEl);
|
||||
|
||||
// Message
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
msgEl.style.fontSize = '16px';
|
||||
msgEl.style.lineHeight = '1.5';
|
||||
content.appendChild(msgEl);
|
||||
|
||||
// Buttons Container
|
||||
const buttons = document.createElement('div');
|
||||
buttons.style.display = 'flex';
|
||||
buttons.style.justifyContent = 'space-around';
|
||||
buttons.style.marginTop = '20px';
|
||||
|
||||
// Cancel Button
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancelar';
|
||||
cancelBtn.style.padding = '10px 20px';
|
||||
cancelBtn.style.fontSize = '16px';
|
||||
cancelBtn.style.cursor = 'pointer';
|
||||
cancelBtn.style.backgroundColor = '#555';
|
||||
cancelBtn.style.color = '#fff';
|
||||
cancelBtn.style.border = '1px solid #888';
|
||||
cancelBtn.onclick = () => {
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(cancelBtn);
|
||||
|
||||
// Confirm Button
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = 'Aceptar';
|
||||
confirmBtn.style.padding = '10px 20px';
|
||||
confirmBtn.style.fontSize = '16px';
|
||||
confirmBtn.style.cursor = 'pointer';
|
||||
confirmBtn.style.backgroundColor = '#2a5';
|
||||
confirmBtn.style.color = '#fff';
|
||||
confirmBtn.style.border = '1px solid #888';
|
||||
confirmBtn.onclick = () => {
|
||||
if (onConfirm) onConfirm();
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(confirmBtn);
|
||||
|
||||
content.appendChild(buttons);
|
||||
overlay.appendChild(content);
|
||||
this.container.appendChild(overlay);
|
||||
}
|
||||
|
||||
createGameStatusPanel() {
|
||||
// Top Center Panel
|
||||
this.statusPanel = document.createElement('div');
|
||||
this.statusPanel.style.position = 'absolute';
|
||||
this.statusPanel.style.top = '20px';
|
||||
this.statusPanel.style.left = '50%';
|
||||
this.statusPanel.style.transform = 'translateX(-50%)';
|
||||
this.statusPanel.style.display = 'flex';
|
||||
this.statusPanel.style.flexDirection = 'column';
|
||||
this.statusPanel.style.alignItems = 'center';
|
||||
this.statusPanel.style.pointerEvents = 'none';
|
||||
|
||||
// Turn/Phase Info
|
||||
this.phaseInfo = document.createElement('div');
|
||||
this.phaseInfo.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||
this.phaseInfo.style.padding = '10px 20px';
|
||||
this.phaseInfo.style.border = '2px solid #daa520'; // GoldenRod
|
||||
this.phaseInfo.style.borderRadius = '5px';
|
||||
this.phaseInfo.style.color = '#fff';
|
||||
this.phaseInfo.style.fontFamily = '"Cinzel", serif';
|
||||
this.phaseInfo.style.fontSize = '20px';
|
||||
this.phaseInfo.style.textAlign = 'center';
|
||||
this.phaseInfo.style.textTransform = 'uppercase';
|
||||
this.phaseInfo.style.minWidth = '200px';
|
||||
this.phaseInfo.innerHTML = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
|
||||
<div style="font-size: 24px; color: #daa520;">Setup</div>
|
||||
`;
|
||||
|
||||
this.statusPanel.appendChild(this.phaseInfo);
|
||||
|
||||
// End Phase Button
|
||||
this.endPhaseBtn = document.createElement('button');
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
this.endPhaseBtn.style.marginTop = '10px';
|
||||
this.endPhaseBtn.style.width = '100%';
|
||||
this.endPhaseBtn.style.padding = '8px';
|
||||
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
|
||||
this.endPhaseBtn.style.color = '#000';
|
||||
this.endPhaseBtn.style.border = '1px solid #8B4513';
|
||||
this.endPhaseBtn.style.borderRadius = '3px';
|
||||
this.endPhaseBtn.style.fontWeight = 'bold';
|
||||
this.endPhaseBtn.style.cursor = 'pointer';
|
||||
this.endPhaseBtn.style.display = 'none'; // Hidden by default
|
||||
this.endPhaseBtn.style.fontFamily = '"Cinzel", serif';
|
||||
this.endPhaseBtn.style.fontSize = '12px';
|
||||
this.endPhaseBtn.style.pointerEvents = 'auto'; // Enable clicking
|
||||
|
||||
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
|
||||
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
|
||||
|
||||
this.endPhaseBtn.onclick = () => {
|
||||
console.log('[UIManager] End Phase Button Clicked', this.game.turnManager.currentPhase);
|
||||
this.game.turnManager.nextPhase();
|
||||
};
|
||||
this.statusPanel.appendChild(this.endPhaseBtn);
|
||||
|
||||
// Notification Area (Power Roll results, etc)
|
||||
this.notificationArea = document.createElement('div');
|
||||
this.notificationArea.style.marginTop = '10px';
|
||||
this.notificationArea.style.transition = 'opacity 0.5s';
|
||||
this.notificationArea.style.opacity = '0';
|
||||
this.statusPanel.appendChild(this.notificationArea);
|
||||
|
||||
this.container.appendChild(this.statusPanel);
|
||||
|
||||
// Inject Font
|
||||
if (!document.getElementById('game-font')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'game-font';
|
||||
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
|
||||
link.rel = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
setupGameListeners() {
|
||||
// Turn Manager Events
|
||||
if (this.game.turnManager) {
|
||||
this.game.turnManager.on('phase_changed', (phase) => {
|
||||
this.updatePhaseDisplay(phase);
|
||||
this.turnUI.updatePhaseDisplay(phase, this.selectedHero);
|
||||
});
|
||||
|
||||
this.game.turnManager.on('POWER_RESULT', (data) => {
|
||||
this.showPowerRollResult(data);
|
||||
this.turnUI.showPowerRollResult(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updatePhaseDisplay(phase) {
|
||||
if (!this.phaseInfo) return;
|
||||
const turn = this.game.turnManager.currentTurn;
|
||||
|
||||
let content = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
|
||||
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
|
||||
`;
|
||||
|
||||
if (this.selectedHero) {
|
||||
content += this.getHeroStatsHTML(this.selectedHero);
|
||||
}
|
||||
|
||||
this.phaseInfo.innerHTML = content;
|
||||
|
||||
if (this.endPhaseBtn) {
|
||||
if (phase === 'hero') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
|
||||
} else if (phase === 'monster') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS';
|
||||
this.endPhaseBtn.title = "Pasar a Fase de Exploración";
|
||||
} else if (phase === 'exploration') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR TURNO';
|
||||
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
|
||||
} else {
|
||||
this.endPhaseBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateHeroStats(hero) {
|
||||
if (!this.phaseInfo) return;
|
||||
|
||||
const turn = this.game.turnManager.currentTurn;
|
||||
const phase = this.game.turnManager.currentPhase;
|
||||
|
||||
let content = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
|
||||
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
|
||||
`;
|
||||
|
||||
if (hero) {
|
||||
content += this.getHeroStatsHTML(hero);
|
||||
}
|
||||
|
||||
this.phaseInfo.innerHTML = content;
|
||||
}
|
||||
|
||||
getHeroStatsHTML(hero) {
|
||||
const portraitUrl = hero.texturePath || '';
|
||||
|
||||
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
|
||||
|
||||
return `
|
||||
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
|
||||
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
|
||||
</div>
|
||||
<div style="text-align: left;">
|
||||
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
|
||||
${hero.name} ${lanternIcon}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showPowerRollResult(data) {
|
||||
if (!this.notificationArea) return;
|
||||
const { roll, message, eventTriggered } = data;
|
||||
const color = eventTriggered ? '#ff4444' : '#44ff44';
|
||||
|
||||
this.notificationArea.innerHTML = `
|
||||
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
|
||||
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
|
||||
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
|
||||
<div style="font-size: 14px; color: #ccc;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.notificationArea.style.opacity = '1';
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||
}, 3000);
|
||||
}
|
||||
// Public API for GameEngine access
|
||||
showPlacementControls(show) { this.placement.showControls(show); }
|
||||
updatePlacementStatus(valid) { this.placement.updateStatus(valid); }
|
||||
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.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(); }
|
||||
}
|
||||
|
||||
965
src/view/UIManager.js.backup
Normal file
@@ -0,0 +1,965 @@
|
||||
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||
|
||||
export class UIManager {
|
||||
constructor(cameraManager, gameEngine) {
|
||||
this.cameraManager = cameraManager;
|
||||
this.game = gameEngine;
|
||||
this.dungeon = gameEngine.dungeon;
|
||||
this.selectedHero = null;
|
||||
|
||||
this.createHUD();
|
||||
this.createHeroCardsPanel(); // NEW: Hero stat cards
|
||||
this.createGameStatusPanel(); // New Panel
|
||||
this.setupMinimapLoop();
|
||||
this.setupGameListeners(); // New Listeners
|
||||
|
||||
// Hook into engine callbacks for UI updates
|
||||
const originalSelect = this.game.onEntitySelect;
|
||||
this.game.onEntitySelect = (id, isSelected) => {
|
||||
// 1. Call Renderer (was in main.js)
|
||||
if (this.cameraManager && this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.toggleEntitySelection(id, isSelected);
|
||||
} else if (window.RENDERER) {
|
||||
window.RENDERER.toggleEntitySelection(id, isSelected);
|
||||
}
|
||||
|
||||
// 2. Update UI
|
||||
if (isSelected) {
|
||||
const hero = this.game.heroes.find(h => h.id === id);
|
||||
this.selectedHero = hero; // Store state
|
||||
this.updateHeroStats(hero);
|
||||
} else {
|
||||
this.selectedHero = null;
|
||||
this.updateHeroStats(null);
|
||||
}
|
||||
};
|
||||
|
||||
const originalMove = this.game.onEntityMove;
|
||||
this.game.onEntityMove = (entity, path) => {
|
||||
if (originalMove) originalMove(entity, path);
|
||||
this.updateHeroStats(entity);
|
||||
// Update hero card if it's a hero
|
||||
if (entity.type === 'hero') {
|
||||
this.updateHeroCard(entity.id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
createHUD() {
|
||||
// Container
|
||||
this.container = document.createElement('div');
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
this.container.style.pointerEvents = 'none'; // Click through to 3D scene
|
||||
document.body.appendChild(this.container);
|
||||
|
||||
// --- Minimap (Top Left) ---
|
||||
this.minimapCanvas = document.createElement('canvas');
|
||||
this.minimapCanvas.width = 200;
|
||||
this.minimapCanvas.height = 200;
|
||||
this.minimapCanvas.style.position = 'absolute';
|
||||
this.minimapCanvas.style.top = '10px';
|
||||
this.minimapCanvas.style.left = '10px';
|
||||
this.minimapCanvas.style.border = '2px solid #444';
|
||||
this.minimapCanvas.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||
this.minimapCanvas.style.pointerEvents = 'auto'; // Allow interaction if needed
|
||||
this.container.appendChild(this.minimapCanvas);
|
||||
|
||||
this.ctx = this.minimapCanvas.getContext('2d');
|
||||
|
||||
// --- Camera Controls (Top Right) ---
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.style.position = 'absolute';
|
||||
controlsContainer.style.top = '20px';
|
||||
controlsContainer.style.right = '20px';
|
||||
controlsContainer.style.display = 'flex';
|
||||
controlsContainer.style.gap = '10px';
|
||||
controlsContainer.style.alignItems = 'center';
|
||||
controlsContainer.style.pointerEvents = 'auto';
|
||||
this.container.appendChild(controlsContainer);
|
||||
|
||||
// Zoom slider (vertical)
|
||||
const zoomContainer = document.createElement('div');
|
||||
zoomContainer.style.display = 'flex';
|
||||
zoomContainer.style.flexDirection = 'column';
|
||||
zoomContainer.style.alignItems = 'center';
|
||||
zoomContainer.style.gap = '0px';
|
||||
zoomContainer.style.height = '140px'; // Fixed height to accommodate label + slider
|
||||
|
||||
// Zoom label
|
||||
const zoomLabel = document.createElement('div');
|
||||
zoomLabel.textContent = 'Zoom';
|
||||
zoomLabel.style.color = '#fff';
|
||||
zoomLabel.style.fontSize = '15px';
|
||||
zoomLabel.style.fontFamily = 'sans-serif';
|
||||
zoomLabel.style.marginBottom = '10px';
|
||||
zoomLabel.style.marginTop = '0px';
|
||||
|
||||
const zoomSlider = document.createElement('input');
|
||||
zoomSlider.type = 'range';
|
||||
zoomSlider.min = '3';
|
||||
zoomSlider.max = '15';
|
||||
zoomSlider.value = '6';
|
||||
zoomSlider.step = '0.5';
|
||||
zoomSlider.style.width = '100px';
|
||||
zoomSlider.style.transform = 'rotate(-90deg)';
|
||||
zoomSlider.style.transformOrigin = 'center';
|
||||
zoomSlider.style.cursor = 'pointer';
|
||||
zoomSlider.style.marginTop = '40px';
|
||||
|
||||
this.zoomSlider = zoomSlider;
|
||||
|
||||
// Set initial zoom
|
||||
this.cameraManager.zoomLevel = 6;
|
||||
this.cameraManager.updateProjection();
|
||||
|
||||
this.cameraManager.onZoomChange = (val) => {
|
||||
if (this.zoomSlider) this.zoomSlider.value = val;
|
||||
};
|
||||
|
||||
zoomSlider.oninput = (e) => {
|
||||
this.cameraManager.zoomLevel = parseFloat(e.target.value);
|
||||
this.cameraManager.updateProjection();
|
||||
};
|
||||
|
||||
zoomContainer.appendChild(zoomLabel);
|
||||
zoomContainer.appendChild(zoomSlider);
|
||||
|
||||
// Direction buttons grid
|
||||
const buttonsGrid = document.createElement('div');
|
||||
buttonsGrid.style.display = 'grid';
|
||||
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||
buttonsGrid.style.gap = '5px';
|
||||
|
||||
controlsContainer.appendChild(zoomContainer);
|
||||
controlsContainer.appendChild(buttonsGrid);
|
||||
|
||||
const createBtn = (label, dir) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.width = '40px';
|
||||
btn.style.height = '40px';
|
||||
btn.style.backgroundColor = '#333';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #666';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.transition = 'background-color 0.2s';
|
||||
btn.dataset.direction = dir; // Store direction for later reference
|
||||
btn.onclick = () => {
|
||||
this.cameraManager.setIsoView(dir);
|
||||
this.updateActiveViewButton(dir);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
// Layout: [N]
|
||||
// [W] [E]
|
||||
// [S]
|
||||
|
||||
// Grid cells: 1 2 3
|
||||
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
|
||||
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
|
||||
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
|
||||
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
|
||||
|
||||
buttonsGrid.appendChild(btnN);
|
||||
buttonsGrid.appendChild(btnW);
|
||||
buttonsGrid.appendChild(btnE);
|
||||
buttonsGrid.appendChild(btnS);
|
||||
|
||||
// Store button references for later updates
|
||||
this.viewButtons = [btnN, btnE, btnS, btnW];
|
||||
|
||||
// Set initial active button (North)
|
||||
this.updateActiveViewButton(DIRECTIONS.NORTH);
|
||||
|
||||
// --- Tile Placement Controls (Bottom Center) ---
|
||||
this.placementPanel = document.createElement('div');
|
||||
this.placementPanel.style.position = 'absolute';
|
||||
this.placementPanel.style.bottom = '20px';
|
||||
this.placementPanel.style.left = '50%';
|
||||
this.placementPanel.style.transform = 'translateX(-50%)';
|
||||
this.placementPanel.style.display = 'none'; // Hidden by default
|
||||
this.placementPanel.style.pointerEvents = 'auto';
|
||||
this.placementPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||
this.placementPanel.style.padding = '15px';
|
||||
this.placementPanel.style.borderRadius = '8px';
|
||||
this.placementPanel.style.border = '2px solid #666';
|
||||
this.container.appendChild(this.placementPanel);
|
||||
|
||||
// Status text
|
||||
this.placementStatus = document.createElement('div');
|
||||
this.placementStatus.style.color = '#fff';
|
||||
this.placementStatus.style.fontSize = '16px';
|
||||
this.placementStatus.style.fontFamily = 'sans-serif';
|
||||
this.placementStatus.style.marginBottom = '10px';
|
||||
this.placementStatus.style.textAlign = 'center';
|
||||
this.placementStatus.textContent = 'Coloca la loseta';
|
||||
this.placementPanel.appendChild(this.placementStatus);
|
||||
|
||||
// Controls container
|
||||
const placementControls = document.createElement('div');
|
||||
placementControls.style.display = 'flex';
|
||||
placementControls.style.gap = '15px';
|
||||
placementControls.style.alignItems = 'center';
|
||||
this.placementPanel.appendChild(placementControls);
|
||||
|
||||
// Movement arrows (4-way grid)
|
||||
const arrowGrid = document.createElement('div');
|
||||
arrowGrid.style.display = 'grid';
|
||||
arrowGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||
arrowGrid.style.gap = '3px';
|
||||
|
||||
const createArrow = (label, dx, dy) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.width = '40px';
|
||||
btn.style.height = '40px';
|
||||
btn.style.backgroundColor = '#444';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #888';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.fontSize = '18px';
|
||||
btn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
this.dungeon.movePlacement(dx, dy);
|
||||
}
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
const arrowUp = createArrow('↑', 0, 1);
|
||||
const arrowLeft = createArrow('←', -1, 0);
|
||||
const arrowRight = createArrow('→', 1, 0);
|
||||
const arrowDown = createArrow('↓', 0, -1);
|
||||
|
||||
arrowUp.style.gridColumn = '2';
|
||||
arrowLeft.style.gridColumn = '1';
|
||||
arrowRight.style.gridColumn = '3';
|
||||
arrowDown.style.gridColumn = '2';
|
||||
|
||||
arrowGrid.appendChild(arrowUp);
|
||||
arrowGrid.appendChild(arrowLeft);
|
||||
arrowGrid.appendChild(arrowRight);
|
||||
arrowGrid.appendChild(arrowDown);
|
||||
|
||||
placementControls.appendChild(arrowGrid);
|
||||
|
||||
// Rotate button
|
||||
this.rotateBtn = document.createElement('button');
|
||||
this.rotateBtn.textContent = '🔄 Rotar';
|
||||
this.rotateBtn.style.padding = '10px 20px';
|
||||
this.rotateBtn.style.backgroundColor = '#555';
|
||||
this.rotateBtn.style.color = '#fff';
|
||||
this.rotateBtn.style.border = '1px solid #888';
|
||||
this.rotateBtn.style.cursor = 'pointer';
|
||||
this.rotateBtn.style.fontSize = '16px';
|
||||
this.rotateBtn.style.borderRadius = '4px';
|
||||
this.rotateBtn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
this.dungeon.rotatePlacement();
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.rotateBtn);
|
||||
|
||||
this.placeBtn = document.createElement('button');
|
||||
this.placeBtn.textContent = '⬇ Bajar';
|
||||
this.placeBtn.style.padding = '10px 20px';
|
||||
this.placeBtn.style.backgroundColor = '#2a5';
|
||||
this.placeBtn.style.color = '#fff';
|
||||
this.placeBtn.style.border = '1px solid #888';
|
||||
this.placeBtn.style.cursor = 'pointer';
|
||||
this.placeBtn.style.fontSize = '16px';
|
||||
this.placeBtn.style.borderRadius = '4px';
|
||||
this.placeBtn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
const success = this.dungeon.confirmPlacement();
|
||||
if (!success) {
|
||||
this.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
|
||||
}
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.placeBtn);
|
||||
|
||||
// Discard button
|
||||
this.discardBtn = document.createElement('button');
|
||||
this.discardBtn.textContent = '❌ Cancelar';
|
||||
this.discardBtn.style.padding = '10px 20px';
|
||||
this.discardBtn.style.backgroundColor = '#d33';
|
||||
this.discardBtn.style.color = '#fff';
|
||||
this.discardBtn.style.border = '1px solid #888';
|
||||
this.discardBtn.style.cursor = 'pointer';
|
||||
this.discardBtn.style.fontSize = '16px';
|
||||
this.discardBtn.style.borderRadius = '4px';
|
||||
this.discardBtn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
this.showConfirm(
|
||||
'Confirmar acción',
|
||||
'¿Quieres descartar esta loseta y bloquear la puerta?',
|
||||
() => {
|
||||
this.dungeon.cancelPlacement();
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.discardBtn);
|
||||
}
|
||||
|
||||
createHeroCardsPanel() {
|
||||
// Container for character cards (left side)
|
||||
this.cardsContainer = document.createElement('div');
|
||||
this.cardsContainer.style.position = 'absolute';
|
||||
this.cardsContainer.style.left = '10px';
|
||||
this.cardsContainer.style.top = '220px'; // Below minimap
|
||||
this.cardsContainer.style.display = 'flex';
|
||||
this.cardsContainer.style.flexDirection = 'column';
|
||||
this.cardsContainer.style.gap = '10px';
|
||||
this.cardsContainer.style.pointerEvents = 'auto';
|
||||
this.cardsContainer.style.width = '200px';
|
||||
this.container.appendChild(this.cardsContainer);
|
||||
|
||||
// Create placeholder card
|
||||
this.createPlaceholderCard();
|
||||
|
||||
// Store references
|
||||
this.currentHeroCard = null;
|
||||
this.currentMonsterCard = null;
|
||||
this.attackButton = null;
|
||||
}
|
||||
|
||||
createPlaceholderCard() {
|
||||
const card = document.createElement('div');
|
||||
card.style.width = '180px';
|
||||
card.style.height = '280px';
|
||||
card.style.backgroundColor = 'rgba(20, 20, 20, 0.95)';
|
||||
card.style.border = '2px solid #8B4513';
|
||||
card.style.borderRadius = '8px';
|
||||
card.style.padding = '10px';
|
||||
card.style.fontFamily = '"Cinzel", serif';
|
||||
card.style.color = '#888';
|
||||
card.style.display = 'flex';
|
||||
card.style.flexDirection = 'column';
|
||||
card.style.alignItems = 'center';
|
||||
card.style.justifyContent = 'center';
|
||||
card.style.textAlign = 'center';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.textContent = '🎴';
|
||||
icon.style.fontSize = '64px';
|
||||
icon.style.marginBottom = '20px';
|
||||
card.appendChild(icon);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.textContent = 'Selecciona un Aventurero';
|
||||
text.style.fontSize = '14px';
|
||||
text.style.color = '#DAA520';
|
||||
card.appendChild(text);
|
||||
|
||||
this.placeholderCard = card;
|
||||
this.cardsContainer.appendChild(card);
|
||||
}
|
||||
|
||||
createHeroCard(hero) {
|
||||
const card = document.createElement('div');
|
||||
card.style.width = '180px';
|
||||
card.style.backgroundColor = 'rgba(20, 20, 20, 0.95)';
|
||||
card.style.border = '2px solid #8B4513';
|
||||
card.style.borderRadius = '8px';
|
||||
card.style.padding = '10px';
|
||||
card.style.fontFamily = '"Cinzel", serif';
|
||||
card.style.color = '#fff';
|
||||
card.style.transition = 'all 0.3s';
|
||||
card.style.cursor = 'pointer';
|
||||
|
||||
// Hover effect
|
||||
card.onmouseenter = () => {
|
||||
card.style.borderColor = '#DAA520';
|
||||
card.style.transform = 'scale(1.05)';
|
||||
};
|
||||
card.onmouseleave = () => {
|
||||
card.style.borderColor = '#8B4513';
|
||||
card.style.transform = 'scale(1)';
|
||||
};
|
||||
|
||||
// Click to select hero
|
||||
card.onclick = () => {
|
||||
if (this.game.onCellClick) {
|
||||
this.game.onCellClick(hero.x, hero.y);
|
||||
}
|
||||
};
|
||||
|
||||
// Portrait
|
||||
const portrait = document.createElement('div');
|
||||
portrait.style.width = '100%';
|
||||
portrait.style.height = '100px';
|
||||
portrait.style.borderRadius = '5px';
|
||||
portrait.style.overflow = 'hidden';
|
||||
portrait.style.border = '2px solid #DAA520';
|
||||
portrait.style.marginBottom = '8px';
|
||||
portrait.style.backgroundColor = '#000';
|
||||
portrait.style.display = 'flex';
|
||||
portrait.style.alignItems = 'center';
|
||||
portrait.style.justifyContent = 'center';
|
||||
|
||||
// Use token image (placeholder for now)
|
||||
const tokenPath = `/assets/images/dungeon1/tokens/heroes/${hero.key}.png`;
|
||||
const img = document.createElement('img');
|
||||
img.src = tokenPath;
|
||||
img.style.width = '100%';
|
||||
img.style.height = '100%';
|
||||
img.style.objectFit = 'cover';
|
||||
|
||||
// Fallback if image doesn't exist
|
||||
img.onerror = () => {
|
||||
portrait.innerHTML = `<div style="color: #DAA520; font-size: 48px;">?</div>`;
|
||||
};
|
||||
|
||||
portrait.appendChild(img);
|
||||
card.appendChild(portrait);
|
||||
|
||||
// Name
|
||||
const name = document.createElement('div');
|
||||
name.textContent = hero.name;
|
||||
name.style.fontSize = '16px';
|
||||
name.style.fontWeight = 'bold';
|
||||
name.style.color = '#DAA520';
|
||||
name.style.textAlign = 'center';
|
||||
name.style.marginBottom = '8px';
|
||||
name.style.textTransform = 'uppercase';
|
||||
card.appendChild(name);
|
||||
|
||||
// Lantern indicator
|
||||
if (hero.hasLantern) {
|
||||
const lantern = document.createElement('div');
|
||||
lantern.textContent = '🏮 Portador de la Lámpara';
|
||||
lantern.style.fontSize = '10px';
|
||||
lantern.style.color = '#FFA500';
|
||||
lantern.style.textAlign = 'center';
|
||||
lantern.style.marginBottom = '8px';
|
||||
card.appendChild(lantern);
|
||||
}
|
||||
|
||||
// Stats grid
|
||||
const statsGrid = document.createElement('div');
|
||||
statsGrid.style.display = 'grid';
|
||||
statsGrid.style.gridTemplateColumns = '1fr 1fr';
|
||||
statsGrid.style.gap = '4px';
|
||||
statsGrid.style.fontSize = '12px';
|
||||
statsGrid.style.marginBottom = '8px';
|
||||
|
||||
const stats = [
|
||||
{ label: 'WS', value: hero.stats.ws || 0 },
|
||||
{ label: 'BS', value: hero.stats.bs || 0 },
|
||||
{ label: 'S', value: hero.stats.str || 0 },
|
||||
{ label: 'T', value: hero.stats.toughness || 0 },
|
||||
{ label: 'W', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` },
|
||||
{ label: 'I', value: hero.stats.initiative || 0 },
|
||||
{ label: 'A', value: hero.stats.attacks || 0 },
|
||||
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` }
|
||||
];
|
||||
|
||||
stats.forEach(stat => {
|
||||
const statEl = document.createElement('div');
|
||||
statEl.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||
statEl.style.padding = '3px 5px';
|
||||
statEl.style.borderRadius = '3px';
|
||||
statEl.style.display = 'flex';
|
||||
statEl.style.justifyContent = 'space-between';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = stat.label + ':';
|
||||
label.style.color = '#AAA';
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.textContent = stat.value;
|
||||
value.style.color = '#FFF';
|
||||
value.style.fontWeight = 'bold';
|
||||
|
||||
statEl.appendChild(label);
|
||||
statEl.appendChild(value);
|
||||
statsGrid.appendChild(statEl);
|
||||
});
|
||||
|
||||
card.appendChild(statsGrid);
|
||||
|
||||
// Store reference
|
||||
this.heroCards.set(hero.id, card);
|
||||
this.heroCardsContainer.appendChild(card);
|
||||
}
|
||||
|
||||
updateHeroCard(heroId) {
|
||||
const card = this.heroCards.get(heroId);
|
||||
if (!card) return;
|
||||
|
||||
const hero = this.game.heroes.find(h => h.id === heroId);
|
||||
if (!hero) return;
|
||||
|
||||
// Update wounds and moves in the stats grid
|
||||
const statsGrid = card.querySelector('div[style*="grid-template-columns"]');
|
||||
if (statsGrid) {
|
||||
const statDivs = statsGrid.children;
|
||||
// W is at index 4, Mov is at index 7
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showPlacementControls(show) {
|
||||
if (this.placementPanel) {
|
||||
this.placementPanel.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updatePlacementStatus(isValid) {
|
||||
if (this.placementStatus) {
|
||||
if (isValid) {
|
||||
this.placementStatus.textContent = '✅ Posición válida';
|
||||
this.placementStatus.style.color = '#0f0';
|
||||
this.placeBtn.style.backgroundColor = '#2a5';
|
||||
this.placeBtn.style.cursor = 'pointer';
|
||||
} else {
|
||||
this.placementStatus.textContent = '❌ Posición inválida';
|
||||
this.placementStatus.style.color = '#f44';
|
||||
this.placeBtn.style.backgroundColor = '#555';
|
||||
this.placeBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveViewButton(activeDirection) {
|
||||
// Reset all buttons to default color
|
||||
this.viewButtons.forEach(btn => {
|
||||
btn.style.backgroundColor = '#333';
|
||||
});
|
||||
|
||||
// Highlight the active button
|
||||
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
|
||||
if (activeBtn) {
|
||||
activeBtn.style.backgroundColor = '#f0c040'; // Yellow/gold color
|
||||
}
|
||||
}
|
||||
|
||||
setupMinimapLoop() {
|
||||
const loop = () => {
|
||||
this.drawMinimap();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
drawMinimap() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.minimapCanvas.width;
|
||||
const h = this.minimapCanvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cellSize = 5;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
|
||||
ctx.fillStyle = '#666'; // Generic floor
|
||||
|
||||
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const cx = centerX + (x * cellSize);
|
||||
const cy = centerY - (y * cellSize);
|
||||
|
||||
if (tileId.includes('room')) ctx.fillStyle = '#55a';
|
||||
else ctx.fillStyle = '#aaa';
|
||||
|
||||
ctx.fillRect(cx, cy, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// Draw Exits (Available)
|
||||
ctx.fillStyle = '#0f0'; // Green dots for open exits
|
||||
if (this.dungeon.availableExits) {
|
||||
this.dungeon.availableExits.forEach(exit => {
|
||||
const ex = centerX + (exit.x * cellSize);
|
||||
const ey = centerY - (exit.y * cellSize);
|
||||
ctx.fillRect(ex, ey, cellSize, cellSize);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw Entry (0,0) cross
|
||||
ctx.strokeStyle = '#f00';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 5, centerY);
|
||||
ctx.lineTo(centerX + 5, centerY);
|
||||
ctx.moveTo(centerX, centerY - 5);
|
||||
ctx.lineTo(centerX, centerY + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
showModal(title, message, onClose) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||
overlay.style.zIndex = '1000';
|
||||
|
||||
// Content Box
|
||||
const content = document.createElement('div');
|
||||
content.style.backgroundColor = '#222';
|
||||
content.style.border = '2px solid #888';
|
||||
content.style.borderRadius = '8px';
|
||||
content.style.padding = '20px';
|
||||
content.style.width = '300px';
|
||||
content.style.textAlign = 'center';
|
||||
content.style.color = '#fff';
|
||||
content.style.fontFamily = 'sans-serif';
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.marginTop = '0';
|
||||
titleEl.style.color = '#f44'; // Reddish for importance
|
||||
content.appendChild(titleEl);
|
||||
|
||||
// Message
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
msgEl.style.fontSize = '16px';
|
||||
msgEl.style.lineHeight = '1.5';
|
||||
content.appendChild(msgEl);
|
||||
|
||||
// OK Button
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Entendido';
|
||||
btn.style.marginTop = '20px';
|
||||
btn.style.padding = '10px 20px';
|
||||
btn.style.fontSize = '16px';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.backgroundColor = '#444';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #888';
|
||||
btn.onclick = () => {
|
||||
this.container.removeChild(overlay);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
content.appendChild(btn);
|
||||
|
||||
overlay.appendChild(content);
|
||||
this.container.appendChild(overlay);
|
||||
}
|
||||
|
||||
showCombatLog(log) {
|
||||
if (!this.notificationArea) return;
|
||||
|
||||
const isHit = log.hitSuccess;
|
||||
const color = isHit ? '#ff4444' : '#aaaaaa';
|
||||
const title = isHit ? 'GOLPE!' : 'FALLO';
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
// Show simplified but impactful message
|
||||
this.notificationArea.innerHTML = `
|
||||
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 2px solid ${color}; border-radius: 5px; text-align: center; min-width: 250px;">
|
||||
<div style="font-family: 'Cinzel'; 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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.notificationArea.style.opacity = '1';
|
||||
|
||||
// Update hero card if defender is a hero
|
||||
const defender = this.game.heroes.find(h => h.id === log.defenderId) ||
|
||||
this.game.monsters.find(m => m.id === log.defenderId);
|
||||
if (defender && defender.type === 'hero') {
|
||||
this.updateHeroCard(defender.id);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
showConfirm(title, message, onConfirm) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||
overlay.style.zIndex = '1000';
|
||||
|
||||
// Content Box
|
||||
const content = document.createElement('div');
|
||||
content.style.backgroundColor = '#222';
|
||||
content.style.border = '2px solid #888';
|
||||
content.style.borderRadius = '8px';
|
||||
content.style.padding = '20px';
|
||||
content.style.width = '300px';
|
||||
content.style.textAlign = 'center';
|
||||
content.style.color = '#fff';
|
||||
content.style.fontFamily = 'sans-serif';
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.marginTop = '0';
|
||||
titleEl.style.color = '#f44';
|
||||
content.appendChild(titleEl);
|
||||
|
||||
// Message
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
msgEl.style.fontSize = '16px';
|
||||
msgEl.style.lineHeight = '1.5';
|
||||
content.appendChild(msgEl);
|
||||
|
||||
// Buttons Container
|
||||
const buttons = document.createElement('div');
|
||||
buttons.style.display = 'flex';
|
||||
buttons.style.justifyContent = 'space-around';
|
||||
buttons.style.marginTop = '20px';
|
||||
|
||||
// Cancel Button
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancelar';
|
||||
cancelBtn.style.padding = '10px 20px';
|
||||
cancelBtn.style.fontSize = '16px';
|
||||
cancelBtn.style.cursor = 'pointer';
|
||||
cancelBtn.style.backgroundColor = '#555';
|
||||
cancelBtn.style.color = '#fff';
|
||||
cancelBtn.style.border = '1px solid #888';
|
||||
cancelBtn.onclick = () => {
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(cancelBtn);
|
||||
|
||||
// Confirm Button
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = 'Aceptar';
|
||||
confirmBtn.style.padding = '10px 20px';
|
||||
confirmBtn.style.fontSize = '16px';
|
||||
confirmBtn.style.cursor = 'pointer';
|
||||
confirmBtn.style.backgroundColor = '#2a5';
|
||||
confirmBtn.style.color = '#fff';
|
||||
confirmBtn.style.border = '1px solid #888';
|
||||
confirmBtn.onclick = () => {
|
||||
if (onConfirm) onConfirm();
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(confirmBtn);
|
||||
|
||||
content.appendChild(buttons);
|
||||
overlay.appendChild(content);
|
||||
this.container.appendChild(overlay);
|
||||
}
|
||||
|
||||
createGameStatusPanel() {
|
||||
// Top Center Panel
|
||||
this.statusPanel = document.createElement('div');
|
||||
this.statusPanel.style.position = 'absolute';
|
||||
this.statusPanel.style.top = '20px';
|
||||
this.statusPanel.style.left = '50%';
|
||||
this.statusPanel.style.transform = 'translateX(-50%)';
|
||||
this.statusPanel.style.display = 'flex';
|
||||
this.statusPanel.style.flexDirection = 'column';
|
||||
this.statusPanel.style.alignItems = 'center';
|
||||
this.statusPanel.style.pointerEvents = 'none';
|
||||
|
||||
// Turn/Phase Info
|
||||
this.phaseInfo = document.createElement('div');
|
||||
this.phaseInfo.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||
this.phaseInfo.style.padding = '10px 20px';
|
||||
this.phaseInfo.style.border = '2px solid #daa520'; // GoldenRod
|
||||
this.phaseInfo.style.borderRadius = '5px';
|
||||
this.phaseInfo.style.color = '#fff';
|
||||
this.phaseInfo.style.fontFamily = '"Cinzel", serif';
|
||||
this.phaseInfo.style.fontSize = '20px';
|
||||
this.phaseInfo.style.textAlign = 'center';
|
||||
this.phaseInfo.style.textTransform = 'uppercase';
|
||||
this.phaseInfo.style.minWidth = '200px';
|
||||
this.phaseInfo.innerHTML = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
|
||||
<div style="font-size: 24px; color: #daa520;">Setup</div>
|
||||
`;
|
||||
|
||||
this.statusPanel.appendChild(this.phaseInfo);
|
||||
|
||||
// End Phase Button
|
||||
this.endPhaseBtn = document.createElement('button');
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
this.endPhaseBtn.style.marginTop = '10px';
|
||||
this.endPhaseBtn.style.width = '100%';
|
||||
this.endPhaseBtn.style.padding = '8px';
|
||||
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
|
||||
this.endPhaseBtn.style.color = '#000';
|
||||
this.endPhaseBtn.style.border = '1px solid #8B4513';
|
||||
this.endPhaseBtn.style.borderRadius = '3px';
|
||||
this.endPhaseBtn.style.fontWeight = 'bold';
|
||||
this.endPhaseBtn.style.cursor = 'pointer';
|
||||
this.endPhaseBtn.style.display = 'none'; // Hidden by default
|
||||
this.endPhaseBtn.style.fontFamily = '"Cinzel", serif';
|
||||
this.endPhaseBtn.style.fontSize = '12px';
|
||||
this.endPhaseBtn.style.pointerEvents = 'auto'; // Enable clicking
|
||||
|
||||
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
|
||||
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
|
||||
|
||||
this.endPhaseBtn.onclick = () => {
|
||||
console.log('[UIManager] End Phase Button Clicked', this.game.turnManager.currentPhase);
|
||||
this.game.turnManager.nextPhase();
|
||||
};
|
||||
this.statusPanel.appendChild(this.endPhaseBtn);
|
||||
|
||||
// Notification Area (Power Roll results, etc)
|
||||
this.notificationArea = document.createElement('div');
|
||||
this.notificationArea.style.marginTop = '10px';
|
||||
this.notificationArea.style.transition = 'opacity 0.5s';
|
||||
this.notificationArea.style.opacity = '0';
|
||||
this.statusPanel.appendChild(this.notificationArea);
|
||||
|
||||
this.container.appendChild(this.statusPanel);
|
||||
|
||||
// Inject Font
|
||||
if (!document.getElementById('game-font')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'game-font';
|
||||
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
|
||||
link.rel = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
setupGameListeners() {
|
||||
if (this.game.turnManager) {
|
||||
this.game.turnManager.on('phase_changed', (phase) => {
|
||||
this.updatePhaseDisplay(phase);
|
||||
});
|
||||
|
||||
this.game.turnManager.on('POWER_RESULT', (data) => {
|
||||
this.showPowerRollResult(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updatePhaseDisplay(phase) {
|
||||
if (!this.phaseInfo) return;
|
||||
const turn = this.game.turnManager.currentTurn;
|
||||
|
||||
let content = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
|
||||
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
|
||||
`;
|
||||
|
||||
if (this.selectedHero) {
|
||||
content += this.getHeroStatsHTML(this.selectedHero);
|
||||
}
|
||||
|
||||
this.phaseInfo.innerHTML = content;
|
||||
|
||||
if (this.endPhaseBtn) {
|
||||
if (phase === 'hero') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
|
||||
} else if (phase === 'monster') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS';
|
||||
this.endPhaseBtn.title = "Pasar a Fase de Exploración";
|
||||
} else if (phase === 'exploration') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR TURNO';
|
||||
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
|
||||
} else {
|
||||
this.endPhaseBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateHeroStats(hero) {
|
||||
if (!this.phaseInfo) return;
|
||||
|
||||
const turn = this.game.turnManager.currentTurn;
|
||||
const phase = this.game.turnManager.currentPhase;
|
||||
|
||||
let content = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
|
||||
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
|
||||
`;
|
||||
|
||||
if (hero) {
|
||||
content += this.getHeroStatsHTML(hero);
|
||||
}
|
||||
|
||||
this.phaseInfo.innerHTML = content;
|
||||
}
|
||||
|
||||
getHeroStatsHTML(hero) {
|
||||
const portraitUrl = hero.texturePath || '';
|
||||
|
||||
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
|
||||
|
||||
return `
|
||||
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
|
||||
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
|
||||
</div>
|
||||
<div style="text-align: left;">
|
||||
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
|
||||
${hero.name} ${lanternIcon}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showPowerRollResult(data) {
|
||||
if (!this.notificationArea) return;
|
||||
const { roll, message, eventTriggered } = data;
|
||||
const color = eventTriggered ? '#ff4444' : '#44ff44';
|
||||
|
||||
this.notificationArea.innerHTML = `
|
||||
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
|
||||
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
|
||||
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
|
||||
<div style="font-size: 14px; color: #ccc;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.notificationArea.style.opacity = '1';
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
436
src/view/render/DungeonRenderer.js
Normal file
@@ -0,0 +1,436 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class DungeonRenderer {
|
||||
constructor(scene, getTextureCallback) {
|
||||
this.scene = scene;
|
||||
this.getTexture = getTextureCallback;
|
||||
|
||||
this.dungeonGroup = new THREE.Group();
|
||||
this.scene.add(this.dungeonGroup);
|
||||
|
||||
this.exitGroup = new THREE.Group();
|
||||
this.scene.add(this.exitGroup);
|
||||
|
||||
// Track pending renders
|
||||
this._pendingExitRender = false;
|
||||
}
|
||||
|
||||
addTile(cells, type, tileDef, tileInstance) {
|
||||
// cells: Array of {x, y} global coordinates
|
||||
// tileDef: The definition object (has textures, dimensions)
|
||||
// tileInstance: The instance object (has x, y, rotation, id)
|
||||
|
||||
// Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD
|
||||
if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) {
|
||||
// Use specific texture if assigned (randomized), otherwise default to first
|
||||
const texturePath = tileInstance.texture || tileDef.textures[0];
|
||||
|
||||
// Load texture with callback
|
||||
this.getTexture(texturePath, (texture) => {
|
||||
// --- NEW LOGIC: Calculate center based on DIMENSIONS, not CELLS ---
|
||||
|
||||
// 1. Get the specific variant for this rotation to know the VISUAL bounds
|
||||
const currentVariant = tileDef.variants[tileInstance.rotation];
|
||||
|
||||
if (!currentVariant) {
|
||||
console.error(`[DungeonRenderer] Missing variant for rotation ${tileInstance.rotation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rotWidth = currentVariant.width;
|
||||
const rotHeight = currentVariant.height;
|
||||
|
||||
// 2. Calculate the Geometric Center of the tile relative to the anchor
|
||||
const cx = tileInstance.x + (rotWidth - 1) / 2;
|
||||
const cy = tileInstance.y + (rotHeight - 1) / 2;
|
||||
|
||||
// 3. Use BASE dimensions from NORTH variant for the Plane
|
||||
const baseWidth = tileDef.variants.N.width;
|
||||
const baseHeight = tileDef.variants.N.height;
|
||||
|
||||
// Create Plane with BASE dimensions
|
||||
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.FrontSide, // Only visible from top
|
||||
alphaTest: 0.1
|
||||
});
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
|
||||
// Initial Rotation: Plane X-Y to X-Z (Flat on ground)
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Handle Rotation
|
||||
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
||||
const r = rotMap[tileInstance.rotation] !== undefined ? rotMap[tileInstance.rotation] : 0;
|
||||
|
||||
// Apply Tile Rotation (Z-axis of the plane corresponds to world Y axis rotation)
|
||||
plane.rotation.z = -r * (Math.PI / 2);
|
||||
|
||||
plane.position.set(cx, 0.01, -cy);
|
||||
plane.receiveShadow = true;
|
||||
|
||||
// Store Metadata for FOW
|
||||
plane.userData.tileId = tileInstance.id;
|
||||
plane.userData.cells = cells;
|
||||
|
||||
this.dungeonGroup.add(plane);
|
||||
});
|
||||
} else {
|
||||
console.warn(`[DungeonRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance} `);
|
||||
}
|
||||
}
|
||||
|
||||
renderExits(exits) {
|
||||
// Cancel any pending render
|
||||
if (this._pendingExitRender) {
|
||||
this._pendingExitRender = false;
|
||||
}
|
||||
|
||||
if (!exits || exits.length === 0) return;
|
||||
|
||||
// Get existing door cells to avoid duplicates
|
||||
const existingDoorCells = new Set();
|
||||
this.exitGroup.children.forEach(child => {
|
||||
if (child.userData.isDoor) {
|
||||
child.userData.cells.forEach(cell => {
|
||||
existingDoorCells.add(`${cell.x},${cell.y} `);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out exits that already have doors
|
||||
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y} `));
|
||||
|
||||
if (newExits.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flag for this render
|
||||
this._pendingExitRender = true;
|
||||
const thisRender = this._pendingExitRender;
|
||||
|
||||
// LOAD TEXTURE
|
||||
this.getTexture('/assets/images/dungeon1/doors/door1_closed.png', (texture) => {
|
||||
// Check if this render was cancelled
|
||||
if (!thisRender || this._pendingExitRender !== thisRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
// Grouping Logic
|
||||
const processed = new Set();
|
||||
const doors = [];
|
||||
|
||||
// Helper to normalize direction to number
|
||||
const normalizeDir = (dir) => {
|
||||
if (typeof dir === 'number') return dir;
|
||||
const map = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
||||
return map[dir] ?? dir;
|
||||
};
|
||||
|
||||
newExits.forEach((ex, i) => {
|
||||
const key = `${ex.x},${ex.y} `;
|
||||
const exDir = normalizeDir(ex.direction);
|
||||
|
||||
if (processed.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let partner = null;
|
||||
for (let j = i + 1; j < newExits.length; j++) {
|
||||
const other = newExits[j];
|
||||
const otherKey = `${other.x},${other.y} `;
|
||||
const otherDir = normalizeDir(other.direction);
|
||||
|
||||
if (processed.has(otherKey)) continue;
|
||||
|
||||
if (exDir !== otherDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let isAdj = false;
|
||||
if (exDir === 0 || exDir === 2) {
|
||||
// North/South: check if same Y and adjacent X
|
||||
isAdj = (ex.y === other.y && Math.abs(ex.x - other.x) === 1);
|
||||
} else {
|
||||
// East/West: check if same X and adjacent Y
|
||||
isAdj = (ex.x === other.x && Math.abs(ex.y - other.y) === 1);
|
||||
}
|
||||
|
||||
if (isAdj) {
|
||||
partner = other;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (partner) {
|
||||
doors.push([ex, partner]);
|
||||
processed.add(key);
|
||||
processed.add(`${partner.x},${partner.y} `);
|
||||
} else {
|
||||
doors.push([ex]);
|
||||
processed.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Render Doors
|
||||
doors.forEach((door, idx) => {
|
||||
const d1 = door[0];
|
||||
const d2 = door.length > 1 ? door[1] : d1;
|
||||
|
||||
const centerX = (d1.x + d2.x) / 2;
|
||||
const centerY = (d1.y + d2.y) / 2;
|
||||
const dir = normalizeDir(d1.direction);
|
||||
|
||||
let angle = 0;
|
||||
let worldX = centerX;
|
||||
let worldZ = -centerY;
|
||||
|
||||
if (dir === 0) {
|
||||
angle = 0;
|
||||
worldZ -= 0.5;
|
||||
} else if (dir === 2) {
|
||||
angle = 0;
|
||||
worldZ += 0.5;
|
||||
} else if (dir === 1) {
|
||||
angle = Math.PI / 2;
|
||||
worldX += 0.5;
|
||||
} else if (dir === 3) {
|
||||
angle = Math.PI / 2;
|
||||
worldX -= 0.5;
|
||||
}
|
||||
|
||||
const geom = new THREE.PlaneGeometry(2, 2);
|
||||
// Clone material for each door so they can have independent textures
|
||||
const doorMat = mat.clone();
|
||||
const mesh = new THREE.Mesh(geom, doorMat);
|
||||
|
||||
mesh.position.set(worldX, 1, worldZ);
|
||||
mesh.rotation.y = angle;
|
||||
|
||||
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
|
||||
mesh.userData = {
|
||||
isDoor: true,
|
||||
isOpen: false,
|
||||
cells: [d1, d2],
|
||||
direction: dir,
|
||||
exitData: {
|
||||
x: d1.x,
|
||||
y: d1.y,
|
||||
direction: dirMap[dir] || 'N'
|
||||
}
|
||||
};
|
||||
mesh.name = `door_${idx} `;
|
||||
|
||||
this.exitGroup.add(mesh);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateFogOfWar(visibleTileIds, entitiesMap) {
|
||||
if (!this.dungeonGroup) return;
|
||||
|
||||
const visibleSet = new Set(visibleTileIds);
|
||||
const visibleCellKeys = new Set();
|
||||
|
||||
// 1. Update Tile Visibility & Collect Visible Cells
|
||||
this.dungeonGroup.children.forEach(mesh => {
|
||||
const isVisible = visibleSet.has(mesh.userData.tileId);
|
||||
mesh.visible = isVisible;
|
||||
if (isVisible && mesh.userData.cells) {
|
||||
mesh.userData.cells.forEach(cell => {
|
||||
visibleCellKeys.add(`${cell.x},${cell.y}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Hide/Show Entities based on Tile Visibility
|
||||
if (entitiesMap) {
|
||||
entitiesMap.forEach((mesh, id) => {
|
||||
// Get grid coords (World X, -Z)
|
||||
const gx = Math.round(mesh.position.x);
|
||||
const gy = Math.round(-mesh.position.z);
|
||||
const key = `${gx},${gy}`;
|
||||
|
||||
// If the cell is visible, show the entity. Otherwise hide it.
|
||||
if (visibleCellKeys.has(key)) {
|
||||
mesh.visible = true;
|
||||
} else {
|
||||
mesh.visible = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also update Doors (in exitGroup)
|
||||
if (this.exitGroup) {
|
||||
this.exitGroup.children.forEach(door => {
|
||||
door.visible = false;
|
||||
});
|
||||
|
||||
// Re-iterate to show close doors
|
||||
this.dungeonGroup.children.forEach(tile => {
|
||||
if (tile.visible) {
|
||||
// Check doors near this tile
|
||||
if (this.exitGroup) {
|
||||
this.exitGroup.children.forEach(door => {
|
||||
if (door.visible) return; // Already shown
|
||||
|
||||
const tx = tile.position.x;
|
||||
const ty = -tile.position.z;
|
||||
|
||||
const dx = Math.abs(door.position.x - tx);
|
||||
const dy = Math.abs(door.position.z - (-ty)); // Z is neg
|
||||
|
||||
if (dx < 4 && dy < 4) {
|
||||
door.visible = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
openDoor(doorMesh) {
|
||||
if (!doorMesh || !doorMesh.userData.isDoor) return;
|
||||
if (doorMesh.userData.isOpen) return; // Already open
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
blockDoor(exitData) {
|
||||
if (!this.exitGroup || !exitData) return;
|
||||
|
||||
let targetDoor = null;
|
||||
|
||||
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) {
|
||||
this.getTexture('/assets/images/dungeon1/doors/door1_blocked.png', (texture) => {
|
||||
targetDoor.material.map = texture;
|
||||
targetDoor.material.needsUpdate = true;
|
||||
targetDoor.userData.isBlocked = true;
|
||||
targetDoor.userData.isOpen = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (child.userData.isDoor) {
|
||||
for (const cell of child.userData.cells) {
|
||||
if (cell.x === x && cell.y === y) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
|
||||
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
||||
for (const cell of doorMesh.userData.cells) {
|
||||
const dx = Math.abs(playerX - cell.x);
|
||||
const dy = Math.abs(playerY - cell.y);
|
||||
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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.
|
||||
});
|
||||
}
|
||||
}
|
||||
115
src/view/render/EffectsRenderer.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as THREE from 'three';
|
||||
import { ParticleManager } from '../ParticleManager.js';
|
||||
|
||||
export class EffectsRenderer {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.particleManager = new ParticleManager(scene);
|
||||
|
||||
this.floatingTextGroup = new THREE.Group();
|
||||
this.scene.add(this.floatingTextGroup);
|
||||
|
||||
this.lastTime = 0;
|
||||
}
|
||||
|
||||
update(time) {
|
||||
if (!this.lastTime) this.lastTime = time;
|
||||
const delta = (time - this.lastTime) / 1000;
|
||||
this.lastTime = time;
|
||||
|
||||
if (this.particleManager) {
|
||||
this.particleManager.update(delta);
|
||||
}
|
||||
|
||||
// Update Floating Texts
|
||||
const now = time;
|
||||
for (let i = this.floatingTextGroup.children.length - 1; i >= 0; i--) {
|
||||
const sprite = this.floatingTextGroup.children[i];
|
||||
const elapsed = now - sprite.userData.startTime;
|
||||
const progress = elapsed / sprite.userData.duration;
|
||||
|
||||
if (progress >= 1) {
|
||||
this.floatingTextGroup.remove(sprite);
|
||||
} else {
|
||||
// Float Up
|
||||
sprite.position.y = sprite.userData.startY + (progress * 1.5);
|
||||
// Fade Out in last half
|
||||
if (progress > 0.5) {
|
||||
sprite.material.opacity = 1 - ((progress - 0.5) * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerVisualEffect(type, x, y) {
|
||||
if (this.particleManager) {
|
||||
if (type === 'fireball') {
|
||||
this.particleManager.spawnFireballExplosion(x, -y);
|
||||
} else if (type === 'heal') {
|
||||
this.particleManager.spawnHealEffect(x, -y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerProjectile(startX, startY, endX, endY, onHitCallback) {
|
||||
if (this.particleManager) {
|
||||
this.particleManager.spawnProjectile(startX, -startY, endX, -endY, onHitCallback);
|
||||
} else {
|
||||
if (onHitCallback) onHitCallback();
|
||||
}
|
||||
}
|
||||
|
||||
showFloatingText(x, y, text, color = "#ffffff") {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.font = "bold 60px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeStyle = "black";
|
||||
ctx.strokeText(text, 128, 64);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, 128, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||
const sprite = new THREE.Sprite(material);
|
||||
|
||||
sprite.position.set(x, 2.0, -y);
|
||||
sprite.position.x += (Math.random() - 0.5) * 0.2;
|
||||
sprite.scale.set(2, 1, 1);
|
||||
|
||||
sprite.userData = {
|
||||
startTime: performance.now(),
|
||||
duration: 2000,
|
||||
startY: sprite.position.y
|
||||
};
|
||||
|
||||
this.floatingTextGroup.add(sprite);
|
||||
}
|
||||
|
||||
showCombatFeedback(x, y, damage, isHit, defenseText = 'Block', getEntityAtCallback) {
|
||||
// Trigger shake via entity found at position
|
||||
if (isHit && damage > 0 && getEntityAtCallback) {
|
||||
const entityId = getEntityAtCallback(x, y);
|
||||
// We return entity ID so the caller can trigger damage effect on EntityRenderer
|
||||
// But EffectsRenderer handles the TEXT part.
|
||||
}
|
||||
|
||||
if (isHit) {
|
||||
if (damage > 0) {
|
||||
this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000');
|
||||
} else {
|
||||
this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00');
|
||||
}
|
||||
} else {
|
||||
this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa');
|
||||
}
|
||||
|
||||
// Return info for EntityRenderer interaction if needed?
|
||||
// Actually, GameRenderer facade typically handles the split:
|
||||
// gameRenderer.showCombatFeedback calls effectsRenderer.showFloatingText AND entityRenderer.triggerDamageEffect
|
||||
}
|
||||
}
|
||||
384
src/view/render/EntityRenderer.js
Normal file
@@ -0,0 +1,384 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class EntityRenderer {
|
||||
constructor(scene, getTextureCallback) {
|
||||
console.log("EntityRenderer: Initializing (V2.1)");
|
||||
this.scene = scene;
|
||||
this.getTexture = getTextureCallback;
|
||||
this.entities = new Map();
|
||||
|
||||
// Callback for hero movement finish
|
||||
this.onHeroFinishedMove = null;
|
||||
this.pathGroup = null; // Will be injected
|
||||
|
||||
// Tokens
|
||||
this.tokensGroup = new THREE.Group();
|
||||
this.scene.add(this.tokensGroup);
|
||||
this.tokens = new Map();
|
||||
|
||||
this.lastTime = 0;
|
||||
}
|
||||
|
||||
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,
|
||||
side: THREE.DoubleSide,
|
||||
alphaTest: 0.1
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
mesh.userData = {
|
||||
pathQueue: [],
|
||||
isMoving: false,
|
||||
startPos: null,
|
||||
targetPos: null,
|
||||
startTime: 0
|
||||
};
|
||||
|
||||
mesh.position.set(entity.x, h / 2, -entity.y);
|
||||
|
||||
// Selection Circle
|
||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 });
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -h / 2 + 0.05;
|
||||
ring.visible = false;
|
||||
ring.name = "SelectionRing";
|
||||
mesh.add(ring);
|
||||
|
||||
this.scene.add(mesh);
|
||||
this.entities.set(entity.id, mesh);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Token Management ---
|
||||
showTokens(heroes, monsters) {
|
||||
this.hideTokens(); // Clear existing
|
||||
if (this.tokensGroup) this.tokensGroup.visible = true;
|
||||
|
||||
const createToken = (entity, type, subType) => {
|
||||
const geometry = new THREE.CircleGeometry(0.35, 32);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: (type === 'hero') ? 0x00BFFF : 0xDC143C, // Fallback color
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 1.0
|
||||
});
|
||||
const token = new THREE.Mesh(geometry, material);
|
||||
token.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Sync with 3D entity if it exists
|
||||
const mesh3D = this.entities.get(entity.id);
|
||||
if (mesh3D) {
|
||||
token.position.set(mesh3D.position.x, 0.05, mesh3D.position.z);
|
||||
} else {
|
||||
token.position.set(entity.x, 0.05, -entity.y);
|
||||
}
|
||||
|
||||
this.tokensGroup.add(token);
|
||||
this.tokens.set(entity.id, token);
|
||||
|
||||
// White Border Ring
|
||||
const borderGeo = new THREE.RingGeometry(0.35, 0.38, 32);
|
||||
const borderMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF, side: THREE.DoubleSide });
|
||||
const border = new THREE.Mesh(borderGeo, borderMat);
|
||||
border.position.z = 0.001;
|
||||
token.add(border);
|
||||
|
||||
// Load Image
|
||||
let path = '';
|
||||
const filename = subType; // Assuming subtype is filename/key
|
||||
|
||||
if (type === 'hero') {
|
||||
path = `/assets/images/dungeon1/tokens/heroes/${filename}.png`;
|
||||
} else {
|
||||
path = `/assets/images/dungeon1/tokens/enemies/${filename}.png`;
|
||||
}
|
||||
|
||||
this.getTexture(path, (texture) => {
|
||||
token.material.map = texture;
|
||||
token.material.color.setHex(0xFFFFFF);
|
||||
token.material.needsUpdate = true;
|
||||
}, undefined, (err) => {
|
||||
console.warn(`[EntityRenderer] Token texture missing: ${path} `);
|
||||
});
|
||||
};
|
||||
|
||||
if (heroes) heroes.forEach(h => createToken(h, 'hero', h.key));
|
||||
if (monsters) monsters.forEach(m => {
|
||||
if (!m.isDead) createToken(m, 'monster', m.key);
|
||||
});
|
||||
}
|
||||
|
||||
hideTokens() {
|
||||
if (this.tokensGroup) {
|
||||
this.tokensGroup.clear();
|
||||
this.tokensGroup.visible = false;
|
||||
}
|
||||
if (this.tokens) this.tokens.clear();
|
||||
}
|
||||
|
||||
moveEntityAlongPath(entity, path) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh instanceof THREE.Object3D) {
|
||||
mesh.userData.pathQueue = [...path];
|
||||
}
|
||||
}
|
||||
|
||||
updateEntityPosition(entity) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
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);
|
||||
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(entity.id);
|
||||
if (token) {
|
||||
token.position.set(entity.x, 0.05, -entity.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleEntitySelection(entityId, isSelected) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (mesh instanceof THREE.Object3D) {
|
||||
const ring = mesh.getObjectByName("SelectionRing");
|
||||
if (ring) ring.visible = isSelected;
|
||||
}
|
||||
}
|
||||
|
||||
setEntityActive(entityId, isActive) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
const oldRing = mesh.getObjectByName("ActiveRing");
|
||||
if (oldRing) mesh.remove(oldRing);
|
||||
|
||||
if (isActive) {
|
||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -1.56 / 2 + 0.05;
|
||||
ring.name = "ActiveRing";
|
||||
mesh.add(ring);
|
||||
}
|
||||
}
|
||||
|
||||
setEntityTarget(entityId, isTarget) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
const oldRing = mesh.getObjectByName("TargetRing");
|
||||
if (oldRing) mesh.remove(oldRing);
|
||||
|
||||
if (isTarget) {
|
||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00AADD,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -1.56 / 2 + 0.06;
|
||||
ring.name = "TargetRing";
|
||||
mesh.add(ring);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllActiveRings() {
|
||||
this.entities.forEach(mesh => {
|
||||
const ring = mesh.getObjectByName("ActiveRing");
|
||||
if (ring) mesh.remove(ring);
|
||||
const ring2 = mesh.getObjectByName("SelectionRing");
|
||||
if (ring2) ring2.visible = false;
|
||||
const ring3 = mesh.getObjectByName("TargetRing");
|
||||
if (ring3) mesh.remove(ring3);
|
||||
});
|
||||
}
|
||||
|
||||
triggerDamageEffect(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
mesh.traverse((child) => {
|
||||
if (child.material && child.material.map) {
|
||||
if (!child.userData.originalColor) {
|
||||
child.userData.originalColor = child.material.color.clone();
|
||||
}
|
||||
child.material.color.setHex(0xff0000);
|
||||
setTimeout(() => {
|
||||
if (child.material) child.material.color.copy(child.userData.originalColor);
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
|
||||
const originalPos = mesh.position.clone();
|
||||
const startTime = performance.now();
|
||||
const duration = 800;
|
||||
|
||||
mesh.userData.shake = {
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
magnitude: 0.1,
|
||||
originalPos: originalPos
|
||||
};
|
||||
}
|
||||
|
||||
triggerDeathAnimation(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!(mesh instanceof THREE.Object3D)) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 1500;
|
||||
|
||||
mesh.userData.death = {
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
initialOpacity: 1.0
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (mesh && mesh.parent) {
|
||||
mesh.parent.remove(mesh);
|
||||
}
|
||||
this.entities.delete(entityId);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
setPathGroup(group) {
|
||||
this.pathGroup = group;
|
||||
}
|
||||
|
||||
updateAnimations(time) {
|
||||
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) {
|
||||
const nextStep = data.pathQueue.shift();
|
||||
data.isMoving = true;
|
||||
data.startTime = time;
|
||||
data.startPos = mesh.position.clone();
|
||||
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
|
||||
}
|
||||
|
||||
if (data.isMoving || data.pathQueue.length > 0) {
|
||||
isAnyMoving = true;
|
||||
}
|
||||
|
||||
if (data.isMoving) {
|
||||
const duration = 300;
|
||||
const elapsed = time - data.startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress);
|
||||
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress);
|
||||
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(id);
|
||||
if (token) {
|
||||
token.position.x = mesh.position.x;
|
||||
token.position.z = mesh.position.z;
|
||||
}
|
||||
}
|
||||
|
||||
const jumpHeight = 0.5;
|
||||
const baseHeight = 1.56 / 2;
|
||||
mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight;
|
||||
|
||||
if (progress >= 1) {
|
||||
data.isMoving = false;
|
||||
mesh.position.y = baseHeight;
|
||||
|
||||
// Remove the visualization tile for this step
|
||||
if (this.pathGroup) {
|
||||
for (let i = this.pathGroup.children.length - 1; i >= 0; i--) {
|
||||
const child = this.pathGroup.children[i];
|
||||
// Match X and Z (ignoring small float errors)
|
||||
if (Math.abs(child.position.x - data.targetPos.x) < 0.1 &&
|
||||
Math.abs(child.position.z - data.targetPos.z) < 0.1) {
|
||||
this.pathGroup.remove(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (id === 'p1' && this.onHeroFinishedMove) {
|
||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
||||
}
|
||||
}
|
||||
} else if (data.shake) {
|
||||
const elapsed = time - data.shake.startTime;
|
||||
if (elapsed < data.shake.duration) {
|
||||
const progress = elapsed / data.shake.duration;
|
||||
const mag = data.shake.magnitude * (1 - progress);
|
||||
const offsetX = (Math.random() - 0.5) * mag * 2;
|
||||
const offsetZ = (Math.random() - 0.5) * mag * 2;
|
||||
mesh.position.x = data.shake.originalPos.x + offsetX;
|
||||
mesh.position.z = data.shake.originalPos.z + offsetZ;
|
||||
} else {
|
||||
mesh.position.copy(data.shake.originalPos);
|
||||
delete data.shake;
|
||||
}
|
||||
} else if (data.death) {
|
||||
const elapsed = time - data.death.startTime;
|
||||
const progress = Math.min(elapsed / data.death.duration, 1);
|
||||
const opacity = data.death.initialOpacity * (1 - progress);
|
||||
|
||||
mesh.traverse((child) => {
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach(mat => { mat.transparent = true; mat.opacity = opacity; });
|
||||
} else {
|
||||
child.material.transparent = true; child.material.opacity = opacity;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (data.death.initialY === undefined) data.death.initialY = mesh.position.y;
|
||||
mesh.position.y = data.death.initialY - (progress * 0.5);
|
||||
|
||||
if (progress >= 1) {
|
||||
delete data.death;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global Sound Logic for steps
|
||||
if (window.SOUND_MANAGER) {
|
||||
if (isAnyMoving) {
|
||||
window.SOUND_MANAGER.startLoop('footsteps');
|
||||
} else {
|
||||
window.SOUND_MANAGER.stopLoop('footsteps');
|
||||
}
|
||||
}
|
||||
|
||||
return isAnyMoving;
|
||||
}
|
||||
}
|
||||
397
src/view/render/InteractionRenderer.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class InteractionRenderer {
|
||||
constructor(scene, renderer, camera, interactionPlane, getTextureCallback) {
|
||||
this.scene = scene;
|
||||
this.renderer = renderer;
|
||||
this.camera = camera;
|
||||
this.interactionPlane = interactionPlane;
|
||||
this.getTexture = getTextureCallback;
|
||||
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.mouse = new THREE.Vector2();
|
||||
|
||||
this.highlightGroup = new THREE.Group();
|
||||
this.scene.add(this.highlightGroup);
|
||||
|
||||
this.previewGroup = new THREE.Group();
|
||||
this.scene.add(this.previewGroup);
|
||||
|
||||
this.projectionGroup = new THREE.Group();
|
||||
this.scene.add(this.projectionGroup);
|
||||
|
||||
this.spellPreviewGroup = new THREE.Group();
|
||||
this.scene.add(this.spellPreviewGroup);
|
||||
|
||||
this.rangedGroup = new THREE.Group();
|
||||
this.scene.add(this.rangedGroup);
|
||||
|
||||
this.exitHighlightGroup = new THREE.Group();
|
||||
this.scene.add(this.exitHighlightGroup);
|
||||
|
||||
this.pathGroup = new THREE.Group();
|
||||
this.scene.add(this.pathGroup);
|
||||
}
|
||||
|
||||
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null, getExitGroupCallback = null) {
|
||||
const getMousePos = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
y: -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
};
|
||||
};
|
||||
|
||||
const handleHover = (event) => {
|
||||
if (!onHover) return;
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onHover(x, y);
|
||||
}
|
||||
};
|
||||
|
||||
this.renderer.domElement.addEventListener('mousemove', handleHover);
|
||||
|
||||
this.renderer.domElement.addEventListener('click', (event) => {
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
|
||||
// First, check if we clicked on a door mesh
|
||||
if (getExitGroupCallback) {
|
||||
const exitGroup = getExitGroupCallback();
|
||||
if (exitGroup) {
|
||||
const doorIntersects = this.raycaster.intersectObjects(exitGroup.children, false);
|
||||
if (doorIntersects.length > 0) {
|
||||
const doorMesh = doorIntersects[0].object;
|
||||
// Only capture click if it is a door AND it is NOT open
|
||||
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||
// Clicked on a CLOSED door! Call onClick with a special door object
|
||||
onClick(null, null, doorMesh);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no door clicked, proceed with normal cell click
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onClick(x, y, null);
|
||||
}
|
||||
});
|
||||
|
||||
this.renderer.domElement.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onRightClick(x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
highlightCells(cells) {
|
||||
this.highlightGroup.clear();
|
||||
if (!cells || cells.length === 0) return;
|
||||
|
||||
cells.forEach((cell, index) => {
|
||||
// 1. Create Canvas with Number
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "rgba(255, 255, 0, 0.5)";
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = "rgba(255, 255, 0, 1)";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeRect(0, 0, 128, 128);
|
||||
|
||||
// Text (Step Number)
|
||||
ctx.font = "bold 60px Arial";
|
||||
ctx.fillStyle = "black";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText((index + 1).toString(), 64, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(cell.x, 0.05, -cell.y);
|
||||
|
||||
this.highlightGroup.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
showAreaPreview(cells, color = 0xffffff) {
|
||||
this.spellPreviewGroup.clear();
|
||||
if (!cells) return;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
cells.forEach(cell => {
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(cell.x, 0.06, -cell.y);
|
||||
this.spellPreviewGroup.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
hideAreaPreview() {
|
||||
this.spellPreviewGroup.clear();
|
||||
}
|
||||
|
||||
// ========== PATH VISUALIZATION (PRESERVED) ==========
|
||||
updatePathVisualization(path) {
|
||||
this.pathGroup.clear();
|
||||
|
||||
if (!path || path.length === 0) return;
|
||||
|
||||
path.forEach((step, index) => {
|
||||
const geometry = new THREE.PlaneGeometry(0.8, 0.8);
|
||||
const texture = this.createNumberTexture(index + 1);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
plane.position.set(step.x, 0.02, -step.y);
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
|
||||
plane.userData.stepIndex = index;
|
||||
|
||||
this.pathGroup.add(plane);
|
||||
});
|
||||
}
|
||||
|
||||
createNumberTexture(number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Yellow background with 50% opacity
|
||||
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#EDA900';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeRect(0, 0, 64, 64);
|
||||
|
||||
// Text
|
||||
ctx.font = 'bold 36px Arial';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(number.toString(), 32, 32);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// Manual Placement
|
||||
showPlacementPreview(preview) {
|
||||
if (!preview) {
|
||||
this.previewGroup.clear();
|
||||
this.projectionGroup.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.previewGroup.clear();
|
||||
this.projectionGroup.clear();
|
||||
|
||||
const { card, cells, isValid, x, y, rotation } = preview;
|
||||
|
||||
// 1. FLOATING TILE (Y = 3)
|
||||
if (card.textures && card.textures.length > 0) {
|
||||
this.getTexture(card.textures[0], (texture) => {
|
||||
const currentVariant = card.variants[rotation];
|
||||
const rotWidth = currentVariant.width;
|
||||
const rotHeight = currentVariant.height;
|
||||
const cx = x + (rotWidth - 1) / 2;
|
||||
const cy = y + (rotHeight - 1) / 2;
|
||||
|
||||
const baseWidth = card.variants.N.width;
|
||||
const baseHeight = card.variants.N.height;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const floatingTile = new THREE.Mesh(geometry, material);
|
||||
floatingTile.rotation.x = -Math.PI / 2;
|
||||
|
||||
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
||||
const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0;
|
||||
floatingTile.rotation.z = -r * (Math.PI / 2);
|
||||
|
||||
floatingTile.position.set(cx, 3, -cy);
|
||||
this.previewGroup.add(floatingTile);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. GROUND PROJECTION
|
||||
const baseColor = isValid ? 0x00ff00 : 0xff0000;
|
||||
const exitKeys = new Set();
|
||||
if (preview.variant && preview.variant.exits) {
|
||||
preview.variant.exits.forEach(ex => {
|
||||
const gx = x + ex.x;
|
||||
const gy = y + ex.y;
|
||||
exitKeys.add(`${gx},${gy} `);
|
||||
});
|
||||
}
|
||||
|
||||
cells.forEach(cell => {
|
||||
const key = `${cell.x},${cell.y} `;
|
||||
let color = baseColor;
|
||||
if (exitKeys.has(key)) {
|
||||
color = 0x0000ff;
|
||||
}
|
||||
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const projection = new THREE.Mesh(geometry, material);
|
||||
projection.rotation.x = -Math.PI / 2;
|
||||
projection.position.set(cell.x, 0.02, -cell.y);
|
||||
this.projectionGroup.add(projection);
|
||||
});
|
||||
}
|
||||
|
||||
hidePlacementPreview() {
|
||||
this.previewGroup.clear();
|
||||
this.projectionGroup.clear();
|
||||
}
|
||||
|
||||
showRangedTargeting(hero, monster, losResult) {
|
||||
this.rangedGroup.clear();
|
||||
if (!hero || !monster || !losResult) return;
|
||||
|
||||
// 1. Orange Fluorescence Ring on Monster
|
||||
const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xFFA500,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(monster.x, 0.05, -monster.y);
|
||||
this.rangedGroup.add(ring);
|
||||
|
||||
// 2. Dashed Line logic
|
||||
const points = [];
|
||||
points.push(new THREE.Vector3(hero.x, 0.8, -hero.y));
|
||||
points.push(new THREE.Vector3(monster.x, 0.8, -monster.y));
|
||||
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const lineMat = new THREE.LineDashedMaterial({
|
||||
color: losResult.clear ? 0x00FF00 : 0xFF0000,
|
||||
dashSize: 0.2,
|
||||
gapSize: 0.1,
|
||||
});
|
||||
|
||||
const line = new THREE.Line(lineGeo, lineMat);
|
||||
line.computeLineDistances();
|
||||
this.rangedGroup.add(line);
|
||||
|
||||
// 3. Blocker Visualization
|
||||
if (!losResult.clear && losResult.blocker) {
|
||||
const b = losResult.blocker;
|
||||
if (b.type === 'hero' || b.type === 'monster') {
|
||||
const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32);
|
||||
const blockRingMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xFF0000,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
depthTest: false
|
||||
});
|
||||
const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat);
|
||||
blockRing.rotation.x = -Math.PI / 2;
|
||||
const bx = b.entity ? b.entity.x : b.x;
|
||||
const by = b.entity ? b.entity.y : b.y;
|
||||
blockRing.position.set(bx, 0.1, -by);
|
||||
this.rangedGroup.add(blockRing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableDoorSelection(enabled, exitGroup) {
|
||||
if (enabled) {
|
||||
this.exitHighlightGroup.clear();
|
||||
if (exitGroup) {
|
||||
exitGroup.children.forEach(doorMesh => {
|
||||
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||
const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.6
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.copy(doorMesh.position);
|
||||
ring.position.y = 0.05;
|
||||
|
||||
// Store reference to door for click handling
|
||||
doorMesh.userData.isExit = true;
|
||||
const firstCell = doorMesh.userData.cells[0];
|
||||
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
|
||||
doorMesh.userData.exitData = {
|
||||
x: firstCell.x,
|
||||
y: firstCell.y,
|
||||
direction: dirMap[doorMesh.userData.direction] || 'N'
|
||||
};
|
||||
|
||||
this.exitHighlightGroup.add(ring);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.exitHighlightGroup.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/view/render/SceneManager.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class SceneManager {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId) || document.body;
|
||||
|
||||
// Fix: Use window dimensions if container has 0 height/width (Robustness legacy fix)
|
||||
this.width = this.container.clientWidth || window.innerWidth;
|
||||
this.height = this.container.clientHeight || window.innerHeight;
|
||||
|
||||
// Scene Setup
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000);
|
||||
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight); // Original code used window dimensions
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
|
||||
// Clear container to avoid duplicates
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
|
||||
// Debug Properties
|
||||
this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z
|
||||
|
||||
// Grid Helper
|
||||
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222);
|
||||
this.scene.add(gridHelper);
|
||||
|
||||
// Interaction Plane
|
||||
this.interactionPlane = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1000, 1000),
|
||||
new THREE.MeshBasicMaterial({ visible: false })
|
||||
);
|
||||
this.interactionPlane.rotation.x = -Math.PI / 2;
|
||||
this.scene.add(this.interactionPlane);
|
||||
|
||||
// Lights
|
||||
this.setupLights();
|
||||
|
||||
// Resize Handler
|
||||
window.addEventListener('resize', this.onWindowResize.bind(this));
|
||||
}
|
||||
|
||||
setupLights() {
|
||||
// Ambient Light
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
// Directional Light
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
dirLight.position.set(50, 100, 50);
|
||||
dirLight.castShadow = true;
|
||||
this.scene.add(dirLight);
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
this.width = this.container.clientWidth || window.innerWidth;
|
||||
this.height = this.container.clientHeight || window.innerHeight;
|
||||
|
||||
if (this.camera) {
|
||||
this.camera.aspect = this.width / this.height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
}
|
||||
|
||||
render(camera) {
|
||||
const cam = camera || this.camera;
|
||||
if (this.renderer && this.scene && cam) {
|
||||
this.renderer.render(this.scene, cam);
|
||||
}
|
||||
}
|
||||
}
|
||||
302
src/view/ui/FeedbackUI.js
Normal file
@@ -0,0 +1,302 @@
|
||||
export class FeedbackUI {
|
||||
constructor(parentContainer, game) {
|
||||
this.parentContainer = parentContainer;
|
||||
this.game = game;
|
||||
|
||||
this.logContainer = null;
|
||||
this.initLogContainer();
|
||||
}
|
||||
|
||||
initLogContainer() {
|
||||
this.logContainer = document.createElement('div');
|
||||
Object.assign(this.logContainer.style, {
|
||||
position: 'absolute',
|
||||
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: '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'
|
||||
});
|
||||
|
||||
// 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) {
|
||||
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.7)', display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
pointerEvents: 'auto', zIndex: '1000'
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
Object.assign(content.style, {
|
||||
backgroundColor: '#222', border: '2px solid #888', borderRadius: '8px', padding: '20px',
|
||||
width: '300px', textAlign: 'center', color: '#fff', fontFamily: 'sans-serif'
|
||||
});
|
||||
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
Object.assign(titleEl.style, { marginTop: '0', color: '#f44' });
|
||||
content.appendChild(titleEl);
|
||||
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
Object.assign(msgEl.style, { fontSize: '16px', lineHeight: '1.5' });
|
||||
content.appendChild(msgEl);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Entendido';
|
||||
Object.assign(btn.style, {
|
||||
marginTop: '20px', padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
|
||||
backgroundColor: '#444', color: '#fff', border: '1px solid #888'
|
||||
});
|
||||
btn.onclick = () => {
|
||||
if (overlay.parentNode) this.parentContainer.removeChild(overlay);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
content.appendChild(btn);
|
||||
|
||||
overlay.appendChild(content);
|
||||
this.parentContainer.appendChild(overlay);
|
||||
}
|
||||
|
||||
showConfirm(title, message, onConfirm) {
|
||||
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.7)', display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
pointerEvents: 'auto', zIndex: '1000'
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
Object.assign(content.style, {
|
||||
backgroundColor: '#222', border: '2px solid #888', borderRadius: '8px', padding: '20px',
|
||||
width: '300px', textAlign: 'center', color: '#fff', fontFamily: 'sans-serif'
|
||||
});
|
||||
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
Object.assign(titleEl.style, { marginTop: '0', color: '#f44' });
|
||||
content.appendChild(titleEl);
|
||||
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
Object.assign(msgEl.style, { fontSize: '16px', lineHeight: '1.5' });
|
||||
content.appendChild(msgEl);
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
Object.assign(buttons.style, { display: 'flex', justifyContent: 'space-around', marginTop: '20px' });
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancelar';
|
||||
Object.assign(cancelBtn.style, {
|
||||
padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
|
||||
backgroundColor: '#555', color: '#fff', border: '1px solid #888'
|
||||
});
|
||||
cancelBtn.onclick = () => { this.parentContainer.removeChild(overlay); };
|
||||
buttons.appendChild(cancelBtn);
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = 'Aceptar';
|
||||
Object.assign(confirmBtn.style, {
|
||||
padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
|
||||
backgroundColor: '#2a5', color: '#fff', border: '1px solid #888'
|
||||
});
|
||||
confirmBtn.onclick = () => {
|
||||
if (onConfirm) onConfirm();
|
||||
this.parentContainer.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(confirmBtn);
|
||||
|
||||
content.appendChild(buttons);
|
||||
overlay.appendChild(content);
|
||||
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%)',
|
||||
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',
|
||||
opacity: '0', transition: 'opacity 0.5s ease-in-out'
|
||||
});
|
||||
|
||||
modal.innerHTML = `
|
||||
<h3 style="margin:0; text-align:center; color: #FFD700; text-transform: uppercase;">⚠️ ${title}</h3>
|
||||
<div style="margin-top:5px; font-size: 16px;">${message}</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
requestAnimationFrame(() => { modal.style.opacity = '1'; });
|
||||
|
||||
setTimeout(() => {
|
||||
modal.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (modal.parentNode) document.body.removeChild(modal);
|
||||
}, 500);
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
256
src/view/ui/HUDManager.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { DIRECTIONS } from '../../engine/dungeon/Constants.js';
|
||||
|
||||
export class HUDManager {
|
||||
constructor(gameContainer, cameraManager, game) {
|
||||
this.parentContainer = gameContainer;
|
||||
this.cameraManager = cameraManager;
|
||||
this.game = game; // Needed for dungeon grid access (minimap)
|
||||
|
||||
this.minimapCanvas = null;
|
||||
this.zoomSlider = null;
|
||||
this.viewButtons = [];
|
||||
this.ctx = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Minimap (Top Left) ---
|
||||
this.minimapCanvas = document.createElement('canvas');
|
||||
this.minimapCanvas.width = 200;
|
||||
this.minimapCanvas.height = 200;
|
||||
Object.assign(this.minimapCanvas.style, {
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
border: '2px solid #444',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
this.parentContainer.appendChild(this.minimapCanvas);
|
||||
this.ctx = this.minimapCanvas.getContext('2d');
|
||||
|
||||
// --- Camera Controls (Top Right) ---
|
||||
const controlsContainer = document.createElement('div');
|
||||
Object.assign(controlsContainer.style, {
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
this.parentContainer.appendChild(controlsContainer);
|
||||
|
||||
this.createZoomControls(controlsContainer);
|
||||
this.createViewControls(controlsContainer);
|
||||
|
||||
// Start Minimap Loop
|
||||
this.setupMinimapLoop();
|
||||
}
|
||||
|
||||
createZoomControls(container) {
|
||||
const zoomContainer = document.createElement('div');
|
||||
Object.assign(zoomContainer.style, {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0px',
|
||||
height: '140px'
|
||||
});
|
||||
|
||||
const zoomLabel = document.createElement('div');
|
||||
zoomLabel.textContent = 'Zoom';
|
||||
Object.assign(zoomLabel.style, {
|
||||
color: '#fff',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'sans-serif',
|
||||
marginBottom: '10px',
|
||||
marginTop: '0px'
|
||||
});
|
||||
|
||||
const zoomSlider = document.createElement('input');
|
||||
zoomSlider.type = 'range';
|
||||
zoomSlider.min = '3';
|
||||
zoomSlider.max = '15';
|
||||
zoomSlider.value = '6';
|
||||
zoomSlider.step = '0.5';
|
||||
Object.assign(zoomSlider.style, {
|
||||
width: '100px',
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: 'center',
|
||||
cursor: 'pointer',
|
||||
marginTop: '40px'
|
||||
});
|
||||
|
||||
this.zoomSlider = zoomSlider;
|
||||
|
||||
// Sync with Camera Manager
|
||||
this.cameraManager.zoomLevel = 6;
|
||||
this.cameraManager.updateProjection();
|
||||
|
||||
this.cameraManager.onZoomChange = (val) => {
|
||||
if (this.zoomSlider) this.zoomSlider.value = val;
|
||||
};
|
||||
|
||||
zoomSlider.oninput = (e) => {
|
||||
this.cameraManager.zoomLevel = parseFloat(e.target.value);
|
||||
this.cameraManager.updateProjection();
|
||||
};
|
||||
|
||||
zoomContainer.appendChild(zoomLabel);
|
||||
zoomContainer.appendChild(zoomSlider);
|
||||
|
||||
// Add 2D/3D Toggle (Left of Zoom)
|
||||
const toggleViewBtn = document.createElement('button');
|
||||
toggleViewBtn.textContent = '3D';
|
||||
toggleViewBtn.title = 'Cambiar vista 2D/3D';
|
||||
Object.assign(toggleViewBtn.style, {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid #aaa',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#daa520',
|
||||
cursor: 'pointer',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
});
|
||||
|
||||
toggleViewBtn.onmouseover = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'; toggleViewBtn.style.color = '#fff'; };
|
||||
toggleViewBtn.onmouseout = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; toggleViewBtn.style.color = '#daa520'; };
|
||||
|
||||
toggleViewBtn.onclick = () => {
|
||||
if (this.cameraManager) {
|
||||
this.cameraManager.onAnimationComplete = null;
|
||||
const isCurrently2D = (this.cameraManager.viewMode === '2D');
|
||||
if (isCurrently2D && this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.hideTokens();
|
||||
}
|
||||
|
||||
const is3D = this.cameraManager.toggleViewMode();
|
||||
toggleViewBtn.textContent = is3D ? '3D' : '2D';
|
||||
|
||||
if (!is3D) {
|
||||
this.cameraManager.onAnimationComplete = () => {
|
||||
if (this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.showTokens(this.game.heroes, this.game.monsters);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(toggleViewBtn);
|
||||
container.appendChild(zoomContainer);
|
||||
}
|
||||
|
||||
createViewControls(container) {
|
||||
const buttonsGrid = document.createElement('div');
|
||||
Object.assign(buttonsGrid.style, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px 40px 40px',
|
||||
gap: '5px'
|
||||
});
|
||||
|
||||
const createBtn = (label, dir) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
Object.assign(btn.style, {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #666',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
});
|
||||
btn.dataset.direction = dir;
|
||||
btn.onclick = () => {
|
||||
this.cameraManager.setIsoView(dir);
|
||||
this.updateActiveViewButton(dir);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
|
||||
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
|
||||
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
|
||||
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
|
||||
|
||||
buttonsGrid.appendChild(btnN);
|
||||
buttonsGrid.appendChild(btnW);
|
||||
buttonsGrid.appendChild(btnE);
|
||||
buttonsGrid.appendChild(btnS);
|
||||
|
||||
this.viewButtons = [btnN, btnE, btnS, btnW];
|
||||
this.updateActiveViewButton(DIRECTIONS.NORTH);
|
||||
|
||||
container.appendChild(buttonsGrid);
|
||||
}
|
||||
|
||||
updateActiveViewButton(activeDirection) {
|
||||
this.viewButtons.forEach(btn => btn.style.backgroundColor = '#333');
|
||||
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
|
||||
if (activeBtn) activeBtn.style.backgroundColor = '#f0c040';
|
||||
}
|
||||
|
||||
setupMinimapLoop() {
|
||||
const loop = () => {
|
||||
this.drawMinimap();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
drawMinimap() {
|
||||
if (!this.game.dungeon) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const w = this.minimapCanvas.width;
|
||||
const h = this.minimapCanvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cellSize = 5;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
|
||||
ctx.fillStyle = '#666';
|
||||
|
||||
for (const [key, tileId] of this.game.dungeon.grid.occupiedCells) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const cx = centerX + (x * cellSize);
|
||||
const cy = centerY - (y * cellSize);
|
||||
|
||||
if (tileId.includes('room')) ctx.fillStyle = '#55a';
|
||||
else ctx.fillStyle = '#aaa';
|
||||
|
||||
ctx.fillRect(cx, cy, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// Draw Exits
|
||||
ctx.fillStyle = '#0f0';
|
||||
if (this.game.dungeon.availableExits) {
|
||||
this.game.dungeon.availableExits.forEach(exit => {
|
||||
const ex = centerX + (exit.x * cellSize);
|
||||
const ey = centerY - (exit.y * cellSize);
|
||||
ctx.fillRect(ex, ey, cellSize, cellSize);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw Center Cross
|
||||
ctx.strokeStyle = '#f00';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 5, centerY);
|
||||
ctx.lineTo(centerX + 5, centerY);
|
||||
ctx.moveTo(centerX, centerY - 5);
|
||||
ctx.lineTo(centerX, centerY + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
140
src/view/ui/PlacementUI.js
Normal file
@@ -0,0 +1,140 @@
|
||||
export class PlacementUI {
|
||||
constructor(parentContainer, game, callbacks) {
|
||||
this.parentContainer = parentContainer;
|
||||
this.game = game; // We need dynamic access to game.dungeon as it might change? Usually not. But we access game.dungeon.
|
||||
this.callbacks = callbacks || {}; // { showModal, showConfirm }
|
||||
|
||||
this.placementPanel = null;
|
||||
this.placementStatus = null;
|
||||
this.placeBtn = null;
|
||||
this.rotateBtn = null;
|
||||
this.discardBtn = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.placementPanel = document.createElement('div');
|
||||
Object.assign(this.placementPanel.style, {
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'none', // Hidden by default
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
padding: '15px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #666'
|
||||
});
|
||||
this.parentContainer.appendChild(this.placementPanel);
|
||||
|
||||
// Status text
|
||||
this.placementStatus = document.createElement('div');
|
||||
Object.assign(this.placementStatus.style, {
|
||||
color: '#fff', fontSize: '16px', fontFamily: 'sans-serif', marginBottom: '10px', textAlign: 'center'
|
||||
});
|
||||
this.placementStatus.textContent = 'Coloca la loseta';
|
||||
this.placementPanel.appendChild(this.placementStatus);
|
||||
|
||||
// Controls container
|
||||
const placementControls = document.createElement('div');
|
||||
Object.assign(placementControls.style, { display: 'flex', gap: '15px', alignItems: 'center' });
|
||||
this.placementPanel.appendChild(placementControls);
|
||||
|
||||
// Movement arrows
|
||||
const arrowGrid = document.createElement('div');
|
||||
Object.assign(arrowGrid.style, { display: 'grid', gridTemplateColumns: '40px 40px 40px', gap: '3px' });
|
||||
|
||||
const createArrow = (label, dx, dy) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
Object.assign(btn.style, {
|
||||
width: '40px', height: '40px', backgroundColor: '#444', color: '#fff',
|
||||
border: '1px solid #888', cursor: 'pointer', fontSize: '18px'
|
||||
});
|
||||
btn.onclick = () => {
|
||||
if (this.game.dungeon) this.game.dungeon.movePlacement(dx, dy);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
const arrowUp = createArrow('↑', 0, 1); arrowUp.style.gridColumn = '2';
|
||||
const arrowLeft = createArrow('←', -1, 0); arrowLeft.style.gridColumn = '1';
|
||||
const arrowRight = createArrow('→', 1, 0); arrowRight.style.gridColumn = '3';
|
||||
const arrowDown = createArrow('↓', 0, -1); arrowDown.style.gridColumn = '2';
|
||||
|
||||
arrowGrid.appendChild(arrowUp);
|
||||
arrowGrid.appendChild(arrowLeft);
|
||||
arrowGrid.appendChild(arrowRight);
|
||||
arrowGrid.appendChild(arrowDown);
|
||||
placementControls.appendChild(arrowGrid);
|
||||
|
||||
// Rotate button
|
||||
this.rotateBtn = document.createElement('button');
|
||||
this.rotateBtn.textContent = '🔄 Rotar';
|
||||
Object.assign(this.rotateBtn.style, {
|
||||
padding: '10px 20px', backgroundColor: '#555', color: '#fff', border: '1px solid #888',
|
||||
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
|
||||
});
|
||||
this.rotateBtn.onclick = () => { if (this.game.dungeon) this.game.dungeon.rotatePlacement(); };
|
||||
placementControls.appendChild(this.rotateBtn);
|
||||
|
||||
// Place button
|
||||
this.placeBtn = document.createElement('button');
|
||||
this.placeBtn.textContent = '⬇ Bajar';
|
||||
Object.assign(this.placeBtn.style, {
|
||||
padding: '10px 20px', backgroundColor: '#2a5', color: '#fff', border: '1px solid #888',
|
||||
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
|
||||
});
|
||||
this.placeBtn.onclick = () => {
|
||||
if (this.game.dungeon) {
|
||||
const success = this.game.dungeon.confirmPlacement();
|
||||
if (!success && this.callbacks.showModal) {
|
||||
this.callbacks.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
|
||||
}
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.placeBtn);
|
||||
|
||||
// Discard button
|
||||
this.discardBtn = document.createElement('button');
|
||||
this.discardBtn.textContent = '❌ Cancelar';
|
||||
Object.assign(this.discardBtn.style, {
|
||||
padding: '10px 20px', backgroundColor: '#d33', color: '#fff', border: '1px solid #888',
|
||||
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
|
||||
});
|
||||
this.discardBtn.onclick = () => {
|
||||
if (this.game.dungeon && this.callbacks.showConfirm) {
|
||||
this.callbacks.showConfirm(
|
||||
'Confirmar acción',
|
||||
'¿Quieres descartar esta loseta y bloquear la puerta?',
|
||||
() => { this.game.dungeon.cancelPlacement(); }
|
||||
);
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.discardBtn);
|
||||
}
|
||||
|
||||
showControls(show) {
|
||||
if (this.placementPanel) {
|
||||
this.placementPanel.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(isValid) {
|
||||
if (this.placementStatus) {
|
||||
if (isValid) {
|
||||
this.placementStatus.textContent = '✅ Posición válida';
|
||||
this.placementStatus.style.color = '#0f0';
|
||||
this.placeBtn.style.backgroundColor = '#2a5';
|
||||
this.placeBtn.style.cursor = 'pointer';
|
||||
} else {
|
||||
this.placementStatus.textContent = '❌ Posición inválida';
|
||||
this.placementStatus.style.color = '#f44';
|
||||
this.placeBtn.style.backgroundColor = '#555';
|
||||
this.placeBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/view/ui/SpellbookUI.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import { SPELLS } from '../../engine/data/Spells.js';
|
||||
|
||||
export class SpellbookUI {
|
||||
constructor(game) {
|
||||
this.game = game;
|
||||
this.spellBookContainer = null;
|
||||
}
|
||||
|
||||
toggle(hero) {
|
||||
if (this.spellBookContainer) {
|
||||
document.body.removeChild(this.spellBookContainer);
|
||||
this.spellBookContainer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
Object.assign(container.style, {
|
||||
position: 'absolute',
|
||||
bottom: '140px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '15px',
|
||||
backgroundColor: 'rgba(20, 10, 30, 0.9)',
|
||||
padding: '20px',
|
||||
borderRadius: '10px',
|
||||
border: '2px solid #9933ff',
|
||||
zIndex: '1500',
|
||||
boxShadow: '0 0 20px rgba(100, 0, 255, 0.5)'
|
||||
});
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.textContent = "LIBRO DE HECHIZOS";
|
||||
Object.assign(title.style, {
|
||||
position: 'absolute', top: '-30px', left: '0', width: '100%', textAlign: 'center',
|
||||
color: '#d8bfff', fontFamily: '"Cinzel", serif', fontSize: '18px', textShadow: '0 0 5px #8a2be2'
|
||||
});
|
||||
container.appendChild(title);
|
||||
|
||||
SPELLS.forEach(spell => {
|
||||
const canCast = this.game.canCastSpell(spell);
|
||||
|
||||
const card = document.createElement('div');
|
||||
Object.assign(card.style, {
|
||||
width: '180px', height: '260px', position: 'relative', cursor: canCast ? 'pointer' : 'not-allowed',
|
||||
transition: 'transform 0.2s', filter: canCast ? 'none' : 'grayscale(100%) brightness(50%)',
|
||||
backgroundImage: this.getSpellTemplate(spell.type), backgroundSize: 'cover'
|
||||
});
|
||||
|
||||
if (canCast) {
|
||||
card.onmouseenter = () => { card.style.transform = 'scale(1.1) translateY(-10px)'; card.style.zIndex = '10'; };
|
||||
card.onmouseleave = () => { card.style.transform = 'scale(1)'; card.style.zIndex = '1'; };
|
||||
|
||||
card.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
document.body.removeChild(this.spellBookContainer);
|
||||
this.spellBookContainer = null;
|
||||
|
||||
if (spell.type === 'attack' || spell.type === 'defense') {
|
||||
this.game.startSpellTargeting(spell);
|
||||
} else {
|
||||
// Global/Instant
|
||||
this.game.executeSpell(spell);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Cost Badge
|
||||
const costBadge = document.createElement('div');
|
||||
costBadge.textContent = spell.cost;
|
||||
Object.assign(costBadge.style, {
|
||||
position: 'absolute', top: '12px', left: '12px', width: '30px', height: '30px', borderRadius: '50%',
|
||||
backgroundColor: '#fff', color: '#000', fontWeight: 'bold', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: '2px solid #000', fontSize: '18px', fontFamily: 'serif'
|
||||
});
|
||||
card.appendChild(costBadge);
|
||||
|
||||
// Name
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.textContent = spell.name.toUpperCase();
|
||||
Object.assign(nameEl.style, {
|
||||
position: 'absolute', top: '45px', width: '100%', textAlign: 'center', fontSize: '14px',
|
||||
color: '#000', fontWeight: 'bold', fontFamily: '"Cinzel", serif', padding: '0 10px', boxSizing: 'border-box'
|
||||
});
|
||||
card.appendChild(nameEl);
|
||||
|
||||
// Description
|
||||
const descEl = document.createElement('div');
|
||||
descEl.textContent = spell.description;
|
||||
Object.assign(descEl.style, {
|
||||
position: 'absolute', bottom: '30px', left: '10px', width: '160px', height: '80px',
|
||||
fontSize: '11px', color: '#000', textAlign: 'center', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', fontFamily: 'serif', lineHeight: '1.2'
|
||||
});
|
||||
card.appendChild(descEl);
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
document.body.appendChild(container);
|
||||
this.spellBookContainer = container;
|
||||
}
|
||||
|
||||
getSpellTemplate(type) {
|
||||
let filename = 'attack_template.png';
|
||||
if (type === 'heal') filename = 'healing_template.png';
|
||||
if (type === 'defense') filename = 'defense_template.png';
|
||||
return `url('/assets/images/dungeon1/spells/${filename}')`;
|
||||
}
|
||||
}
|
||||
229
src/view/ui/TurnStatusUI.js
Normal file
@@ -0,0 +1,229 @@
|
||||
export class TurnStatusUI {
|
||||
constructor(parentContainer, game) {
|
||||
this.parentContainer = parentContainer;
|
||||
this.game = game;
|
||||
|
||||
this.statusPanel = null;
|
||||
this.phaseInfo = null;
|
||||
this.endPhaseBtn = null;
|
||||
this.notificationArea = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.statusPanel = document.createElement('div');
|
||||
Object.assign(this.statusPanel.style, {
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none'
|
||||
});
|
||||
|
||||
// Turn/Phase Info
|
||||
this.phaseInfo = document.createElement('div');
|
||||
Object.assign(this.phaseInfo.style, {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '10px 20px',
|
||||
border: '2px solid #daa520',
|
||||
borderRadius: '5px',
|
||||
color: '#fff',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
fontSize: '20px',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
width: '300px'
|
||||
});
|
||||
this.phaseInfo.innerHTML = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
|
||||
<div style="font-size: 24px; color: #daa520;">Setup</div>
|
||||
`;
|
||||
this.statusPanel.appendChild(this.phaseInfo);
|
||||
|
||||
// Button Container (Row for split buttons)
|
||||
this.buttonContainer = document.createElement('div');
|
||||
Object.assign(this.buttonContainer.style, {
|
||||
marginTop: '10px',
|
||||
width: '300px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '4px', // Space between buttons
|
||||
justifyItems: 'center',
|
||||
pointerEvents: 'none' // Container checks pointer events safely? inner btns will be auto.
|
||||
});
|
||||
this.statusPanel.appendChild(this.buttonContainer);
|
||||
|
||||
// End Phase Button (Left)
|
||||
this.endPhaseBtn = document.createElement('button');
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
Object.assign(this.endPhaseBtn.style, {
|
||||
flex: '1', // Take available space (50% if shared)
|
||||
padding: '8px',
|
||||
backgroundColor: '#daa520',
|
||||
color: '#000',
|
||||
border: '1px solid #8B4513',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
display: 'none',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
fontSize: '11px', // Slightly smaller text for split
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
|
||||
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
|
||||
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
|
||||
|
||||
this.endPhaseBtn.onclick = () => {
|
||||
// Only if visible!
|
||||
console.log('[TurnStatusUI] End Phase Button Clicked', this.game.turnManager.currentPhase);
|
||||
this.game.turnManager.nextPhase();
|
||||
};
|
||||
this.buttonContainer.appendChild(this.endPhaseBtn);
|
||||
|
||||
// End Turn Button (Right - Hero only)
|
||||
this.endTurnBtn = document.createElement('button');
|
||||
this.endTurnBtn.textContent = 'ACABAR TURNO';
|
||||
Object.assign(this.endTurnBtn.style, {
|
||||
flex: '1', // 50% width
|
||||
padding: '8px',
|
||||
backgroundColor: '#8B4513', // Different color (Dark Red/Wood)
|
||||
color: '#FFD700',
|
||||
border: '1px solid #DAA520',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
display: 'none',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
fontSize: '11px',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
|
||||
this.endTurnBtn.onmouseover = () => { this.endTurnBtn.style.backgroundColor = '#A0522D'; };
|
||||
this.endTurnBtn.onmouseout = () => { this.endTurnBtn.style.backgroundColor = '#8B4513'; };
|
||||
this.endTurnBtn.onclick = () => {
|
||||
if (this.game.nextHeroTurn) {
|
||||
this.game.nextHeroTurn();
|
||||
}
|
||||
};
|
||||
this.buttonContainer.appendChild(this.endTurnBtn);
|
||||
|
||||
// Notification Area (Power Roll)
|
||||
this.notificationArea = document.createElement('div');
|
||||
Object.assign(this.notificationArea.style, {
|
||||
marginTop: '10px',
|
||||
maxWidth: '600px',
|
||||
transition: 'opacity 0.5s',
|
||||
opacity: '0'
|
||||
});
|
||||
this.statusPanel.appendChild(this.notificationArea);
|
||||
|
||||
this.parentContainer.appendChild(this.statusPanel);
|
||||
|
||||
// Inject Font
|
||||
if (!document.getElementById('game-font')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'game-font';
|
||||
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
|
||||
link.rel = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
updatePhaseDisplay(phase, selectedHero) {
|
||||
if (!this.phaseInfo) return;
|
||||
const turn = this.game.turnManager.currentTurn;
|
||||
|
||||
let content = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
|
||||
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
|
||||
`;
|
||||
|
||||
if (selectedHero) {
|
||||
content += this.getHeroStatsHTML(selectedHero);
|
||||
}
|
||||
|
||||
this.phaseInfo.innerHTML = content;
|
||||
|
||||
if (this.buttonContainer) {
|
||||
this.buttonContainer.style.display = 'flex'; // Default
|
||||
|
||||
if (this.endPhaseBtn) this.endPhaseBtn.style.display = 'none';
|
||||
if (this.endTurnBtn) this.endTurnBtn.style.display = 'none';
|
||||
|
||||
if (phase === 'hero') {
|
||||
// Split Mode
|
||||
if (this.endPhaseBtn) {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE'; // Shorter text
|
||||
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
|
||||
}
|
||||
if (this.endTurnBtn) {
|
||||
this.endTurnBtn.style.display = 'block'; // Show right button
|
||||
}
|
||||
} else if (phase === 'exploration') {
|
||||
// Full Width Mode for End Phase (used as End Turn in exp)
|
||||
if (this.endPhaseBtn) {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR TURNO';
|
||||
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
|
||||
}
|
||||
} else {
|
||||
// Nothing visible
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateHeroStats(hero) {
|
||||
const phase = this.game.turnManager.currentPhase;
|
||||
this.updatePhaseDisplay(phase, hero);
|
||||
}
|
||||
|
||||
getHeroStatsHTML(hero) {
|
||||
const portraitUrl = hero.texturePath || '';
|
||||
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
|
||||
|
||||
return `
|
||||
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
|
||||
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
|
||||
</div>
|
||||
<div style="text-align: left;">
|
||||
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
|
||||
${hero.name} ${lanternIcon}
|
||||
</div>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
showPowerRollResult(data) {
|
||||
if (!this.notificationArea) return;
|
||||
const { roll, message, eventTriggered } = data;
|
||||
const color = eventTriggered ? '#ff4444' : '#44ff44';
|
||||
|
||||
this.notificationArea.innerHTML = `
|
||||
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
|
||||
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
|
||||
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
|
||||
<div style="font-size: 14px; color: #ccc;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.notificationArea.style.opacity = '1';
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
447
src/view/ui/UnitCardManager.js
Normal file
@@ -0,0 +1,447 @@
|
||||
export class UnitCardManager {
|
||||
constructor(parentContainer, game, callbacks) {
|
||||
this.parentContainer = parentContainer;
|
||||
this.game = game;
|
||||
this.callbacks = callbacks || {}; // { showModal, toggleSpellBook }
|
||||
|
||||
this.cardsContainer = null;
|
||||
this.currentHeroCard = null;
|
||||
this.currentMonsterCard = null;
|
||||
this.monsterContainer = null;
|
||||
this.placeholderCard = null;
|
||||
this.attackButton = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.cardsContainer = document.createElement('div');
|
||||
Object.assign(this.cardsContainer.style, {
|
||||
position: 'absolute',
|
||||
left: '10px',
|
||||
top: '220px', // Below minimap
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: '15px',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
this.parentContainer.appendChild(this.cardsContainer);
|
||||
|
||||
this.createPlaceholderCard();
|
||||
}
|
||||
|
||||
createPlaceholderCard() {
|
||||
const card = document.createElement('div');
|
||||
Object.assign(card.style, {
|
||||
width: '180px',
|
||||
height: '280px',
|
||||
backgroundColor: 'rgba(20, 20, 20, 0.95)',
|
||||
border: '2px solid #8B4513',
|
||||
borderRadius: '8px',
|
||||
padding: '10px',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
color: '#888',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center'
|
||||
});
|
||||
|
||||
const iconContainer = document.createElement('div');
|
||||
Object.assign(iconContainer.style, {
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid #8B4513',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '20px'
|
||||
});
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.textContent = '🎴';
|
||||
icon.style.fontSize = '48px';
|
||||
iconContainer.appendChild(icon);
|
||||
card.appendChild(iconContainer);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.textContent = 'Selecciona un Aventurero';
|
||||
text.style.fontSize = '14px';
|
||||
text.style.color = '#DAA520';
|
||||
card.appendChild(text);
|
||||
|
||||
this.placeholderCard = card;
|
||||
this.cardsContainer.appendChild(card);
|
||||
}
|
||||
|
||||
showHeroCard(hero) {
|
||||
if (this.placeholderCard && this.placeholderCard.parentNode) {
|
||||
this.cardsContainer.removeChild(this.placeholderCard);
|
||||
}
|
||||
if (this.currentHeroCard && this.currentHeroCard.parentNode) {
|
||||
this.cardsContainer.removeChild(this.currentHeroCard);
|
||||
}
|
||||
|
||||
this.currentHeroCard = this.createHeroCard(hero);
|
||||
this.cardsContainer.insertBefore(this.currentHeroCard, this.cardsContainer.firstChild);
|
||||
}
|
||||
|
||||
hideHeroCard() {
|
||||
if (this.currentHeroCard && this.currentHeroCard.parentNode) {
|
||||
this.cardsContainer.removeChild(this.currentHeroCard);
|
||||
this.currentHeroCard = null;
|
||||
}
|
||||
// Show placeholder only if no cards are visible
|
||||
if (!this.currentMonsterCard && this.placeholderCard && !this.placeholderCard.parentNode) {
|
||||
this.cardsContainer.appendChild(this.placeholderCard);
|
||||
}
|
||||
}
|
||||
|
||||
updateHeroCard(heroId) {
|
||||
if (!this.currentHeroCard || this.currentHeroCard.dataset.heroId !== heroId) return;
|
||||
|
||||
const hero = this.game.heroes.find(h => h.id === heroId);
|
||||
if (!hero) return;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
createHeroCard(hero) {
|
||||
const card = document.createElement('div');
|
||||
Object.assign(card.style, {
|
||||
width: '180px',
|
||||
backgroundColor: 'rgba(20, 20, 20, 0.95)',
|
||||
border: '2px solid #8B4513',
|
||||
borderRadius: '8px',
|
||||
padding: '10px',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
color: '#fff',
|
||||
transition: 'all 0.3s',
|
||||
cursor: 'pointer'
|
||||
});
|
||||
|
||||
card.onmouseenter = () => { card.style.borderColor = '#DAA520'; card.style.transform = 'scale(1.05)'; };
|
||||
card.onmouseleave = () => { card.style.borderColor = '#8B4513'; card.style.transform = 'scale(1)'; };
|
||||
card.onclick = () => { if (this.game.onCellClick) this.game.onCellClick(hero.x, hero.y); };
|
||||
|
||||
// Portrait
|
||||
const portrait = document.createElement('div');
|
||||
Object.assign(portrait.style, {
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #DAA520',
|
||||
marginBottom: '8px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
backgroundColor: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
});
|
||||
|
||||
const tokenPath = `/assets/images/dungeon1/tokens/heroes/${hero.key}.png?v=2`;
|
||||
const img = document.createElement('img');
|
||||
img.src = tokenPath;
|
||||
Object.assign(img.style, { width: '100%', height: '100%', objectFit: 'cover' });
|
||||
img.onerror = () => { portrait.innerHTML = `<div style="color: #DAA520; font-size: 48px;">?</div>`; };
|
||||
portrait.appendChild(img);
|
||||
card.appendChild(portrait);
|
||||
|
||||
// Name
|
||||
const name = document.createElement('div');
|
||||
name.textContent = hero.name;
|
||||
Object.assign(name.style, {
|
||||
fontSize: '16px', fontWeight: 'bold', color: '#DAA520', textAlign: 'center', marginBottom: '8px', textTransform: 'uppercase'
|
||||
});
|
||||
card.appendChild(name);
|
||||
|
||||
if (hero.hasLantern) {
|
||||
const lantern = document.createElement('div');
|
||||
lantern.textContent = '🏮 Portador de la Lámpara';
|
||||
Object.assign(lantern.style, { fontSize: '10px', color: '#FFA500', textAlign: 'center', marginBottom: '8px' });
|
||||
card.appendChild(lantern);
|
||||
}
|
||||
|
||||
// Stats
|
||||
const statsGrid = document.createElement('div');
|
||||
Object.assign(statsGrid.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', fontSize: '12px', marginBottom: '8px' });
|
||||
|
||||
const stats = [
|
||||
{ label: 'H.C', value: hero.stats.ws || 0 },
|
||||
{ label: 'H.P', value: hero.stats.bs || 0 },
|
||||
{ label: 'Fuer', value: hero.stats.str || 0 },
|
||||
{ label: 'Res', value: hero.stats.toughness || 0 },
|
||||
{ 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: '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);
|
||||
});
|
||||
card.appendChild(statsGrid);
|
||||
|
||||
// Elf Bow Button
|
||||
if (hero.key === 'elf') {
|
||||
const isPinned = this.game.isEntityPinned(hero);
|
||||
const hasAttacked = hero.hasAttacked;
|
||||
const bowBtn = document.createElement('button');
|
||||
bowBtn.textContent = hasAttacked ? '🏹 YA DISPARADO' : '🏹 DISPARAR ARCO';
|
||||
Object.assign(bowBtn.style, {
|
||||
width: '100%', padding: '8px', marginTop: '8px',
|
||||
color: '#fff', border: '1px solid #fff', borderRadius: '4px',
|
||||
fontFamily: '"Cinzel", serif', cursor: (isPinned || hasAttacked) ? 'not-allowed' : 'pointer',
|
||||
backgroundColor: (isPinned || hasAttacked) ? '#555' : '#2E8B57'
|
||||
});
|
||||
|
||||
if (isPinned) bowBtn.title = "¡Estás trabado en combate cuerpo a cuerpo!";
|
||||
else if (hasAttacked) bowBtn.title = "Ya has atacado en esta fase.";
|
||||
else {
|
||||
bowBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.game.startRangedTargeting();
|
||||
if (this.callbacks.showModal) this.callbacks.showModal('Modo Disparo', 'Selecciona un enemigo visible para disparar.');
|
||||
};
|
||||
}
|
||||
card.appendChild(bowBtn);
|
||||
}
|
||||
|
||||
// Break Away Button (Destrabarse)
|
||||
const isPinned = this.game.isEntityPinned(hero) && !hero.hasEscapedPin;
|
||||
const canTryBreak = isPinned && hero.currentMoves > 0 && !hero.hasAttacked; // Can only try if hasn't acted yet?
|
||||
// Rules say: "can attempt to escape... if achieved... moves as normal".
|
||||
// If fails "must stay and fight".
|
||||
|
||||
if (isPinned) {
|
||||
const breakBtn = document.createElement('button');
|
||||
const target = hero.stats.pin_target || 6;
|
||||
breakBtn.textContent = `🏃 DESTRABARSE (${target}+)`;
|
||||
Object.assign(breakBtn.style, {
|
||||
width: '100%', padding: '8px', marginTop: '8px',
|
||||
color: '#fff', border: '1px solid #FFA500', borderRadius: '4px',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
cursor: canTryBreak ? 'pointer' : 'not-allowed',
|
||||
backgroundColor: canTryBreak ? '#FF8C00' : '#555'
|
||||
});
|
||||
|
||||
if (!canTryBreak) {
|
||||
if (hero.hasAttacked) breakBtn.title = "Ya has atacado.";
|
||||
else if (hero.currentMoves <= 0) breakBtn.title = "No tienes movimiento.";
|
||||
} else {
|
||||
breakBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const result = this.game.attemptBreakAway(hero);
|
||||
|
||||
// Show result
|
||||
const color = result.success ? '#00ff00' : '#ff0000';
|
||||
const msg = result.success ? "¡Escapada con éxito!" : "¡Fallo! Debes luchar.";
|
||||
|
||||
if (this.callbacks.showModal) {
|
||||
this.callbacks.showModal(
|
||||
result.success ? '¡Destrabado!' : '¡Atrapado!',
|
||||
`Resultado del dado: <b style="color:${color}">${result.roll}</b> (Necesitabas ${result.target}+)<br>${msg}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update UI (Refresh card to show movement unlocked or locked)
|
||||
this.updateHeroCard(hero.id);
|
||||
if (this.callbacks.refresh) this.callbacks.refresh(); // Or just let update handle it
|
||||
};
|
||||
}
|
||||
card.appendChild(breakBtn);
|
||||
}
|
||||
|
||||
// Inventory
|
||||
const invBtn = document.createElement('button');
|
||||
invBtn.textContent = '🎒 INVENTARIO';
|
||||
Object.assign(invBtn.style, {
|
||||
width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#5D4037',
|
||||
color: '#fff', border: '1px solid #8B4513', borderRadius: '4px',
|
||||
fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer'
|
||||
});
|
||||
invBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.callbacks.toggleInventory) this.callbacks.toggleInventory(hero);
|
||||
};
|
||||
card.appendChild(invBtn);
|
||||
|
||||
|
||||
|
||||
// Wizard Spells
|
||||
if (hero.key === 'wizard') {
|
||||
const spellsBtn = document.createElement('button');
|
||||
spellsBtn.textContent = '🔮 HECHIZOS';
|
||||
Object.assign(spellsBtn.style, {
|
||||
width: '100%', padding: '8px', marginTop: '5px', backgroundColor: '#4b0082',
|
||||
color: '#fff', border: '1px solid #8a2be2', borderRadius: '4px',
|
||||
fontFamily: '"Cinzel", serif', cursor: 'pointer'
|
||||
});
|
||||
spellsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.callbacks.toggleSpellBook) this.callbacks.toggleSpellBook(hero);
|
||||
};
|
||||
card.appendChild(spellsBtn);
|
||||
}
|
||||
|
||||
card.dataset.heroId = hero.id;
|
||||
return card;
|
||||
}
|
||||
|
||||
createMonsterCard(monster) {
|
||||
const card = document.createElement('div');
|
||||
Object.assign(card.style, {
|
||||
width: '180px', backgroundColor: 'rgba(40, 20, 20, 0.95)', border: '2px solid #8B0000',
|
||||
borderRadius: '8px', padding: '10px', fontFamily: '"Cinzel", serif', color: '#fff'
|
||||
});
|
||||
|
||||
// Portrait
|
||||
const portrait = document.createElement('div');
|
||||
Object.assign(portrait.style, {
|
||||
width: '100px', height: '100px', borderRadius: '50%', overflow: 'hidden',
|
||||
border: '2px solid #8B0000', marginBottom: '8px', marginLeft: 'auto', marginRight: 'auto',
|
||||
backgroundColor: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
});
|
||||
const img = document.createElement('img');
|
||||
img.src = `/assets/images/dungeon1/tokens/enemies/${monster.key}.png?v=2`;
|
||||
Object.assign(img.style, { width: '100%', height: '100%', objectFit: 'cover' });
|
||||
img.onerror = () => { portrait.innerHTML = `<div style="color: #8B0000; font-size: 48px;">👹</div>`; };
|
||||
portrait.appendChild(img);
|
||||
card.appendChild(portrait);
|
||||
|
||||
// Name
|
||||
const name = document.createElement('div');
|
||||
name.textContent = monster.name;
|
||||
Object.assign(name.style, {
|
||||
fontSize: '16px', fontWeight: 'bold', color: '#FF4444', textAlign: 'center', marginBottom: '8px', textTransform: 'uppercase'
|
||||
});
|
||||
card.appendChild(name);
|
||||
|
||||
// Stats
|
||||
const statsGrid = document.createElement('div');
|
||||
Object.assign(statsGrid.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', fontSize: '12px' });
|
||||
const stats = [
|
||||
{ label: 'H.C', value: monster.stats.ws || 0 },
|
||||
{ label: 'Fuer', value: monster.stats.str || 0 },
|
||||
{ label: 'Res', value: monster.stats.toughness || 0 },
|
||||
{ label: 'Her', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` },
|
||||
{ label: 'Ini', value: monster.stats.initiative || 0 },
|
||||
{ label: 'Ata', value: monster.stats.attacks || 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.style.color = '#AAA'; l.textContent = stat.label + ':';
|
||||
const v = document.createElement('span'); v.style.color = '#FFF'; v.textContent = stat.value; v.style.fontWeight = 'bold';
|
||||
el.appendChild(l); el.appendChild(v);
|
||||
statsGrid.appendChild(el);
|
||||
});
|
||||
card.appendChild(statsGrid);
|
||||
card.dataset.monsterId = monster.id;
|
||||
return card;
|
||||
}
|
||||
|
||||
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.monsterContainer.appendChild(this.currentMonsterCard);
|
||||
|
||||
this.attackButton = document.createElement('button');
|
||||
this.attackButton.textContent = '⚔️ ATACAR';
|
||||
Object.assign(this.attackButton.style, {
|
||||
width: '180px', padding: '12px', backgroundColor: '#8B0000', color: '#fff',
|
||||
border: '2px solid #FF4444', borderRadius: '8px', fontFamily: '"Cinzel", serif',
|
||||
fontSize: '16px', fontWeight: 'bold', cursor: 'pointer', transition: 'all 0.2s'
|
||||
});
|
||||
|
||||
this.attackButton.onmouseenter = () => { this.attackButton.style.backgroundColor = '#FF0000'; this.attackButton.style.transform = 'scale(1.05)'; };
|
||||
this.attackButton.onmouseleave = () => { this.attackButton.style.backgroundColor = '#8B0000'; this.attackButton.style.transform = 'scale(1)'; };
|
||||
|
||||
this.attackButton.onclick = () => {
|
||||
if (this.game.performHeroAttack) {
|
||||
const result = this.game.performHeroAttack(monster.id);
|
||||
if (result && result.success) {
|
||||
this.hideMonsterCard();
|
||||
if (this.game.selectedMonster) {
|
||||
if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false);
|
||||
this.game.selectedMonster = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
this.monsterContainer.appendChild(this.attackButton);
|
||||
this.cardsContainer.appendChild(this.monsterContainer);
|
||||
}
|
||||
|
||||
showRangedAttackUI(monster) {
|
||||
this.showMonsterCard(monster); // Creates button as "ATACAR"
|
||||
|
||||
if (this.attackButton) {
|
||||
this.attackButton.textContent = '🏹 DISPARAR';
|
||||
this.attackButton.style.backgroundColor = '#2E8B57';
|
||||
this.attackButton.style.border = '2px solid #32CD32';
|
||||
|
||||
this.attackButton.onclick = () => {
|
||||
const result = this.game.performRangedAttack(monster.id);
|
||||
if (result && result.success) {
|
||||
this.game.cancelTargeting();
|
||||
this.hideMonsterCard();
|
||||
}
|
||||
};
|
||||
|
||||
this.attackButton.onmouseenter = () => { this.attackButton.style.backgroundColor = '#3CB371'; this.attackButton.style.transform = 'scale(1.05)'; };
|
||||
this.attackButton.onmouseleave = () => { this.attackButton.style.backgroundColor = '#2E8B57'; this.attackButton.style.transform = 'scale(1)'; };
|
||||
}
|
||||
}
|
||||
|
||||
hideMonsterCard() {
|
||||
if (this.monsterContainer && this.monsterContainer.parentNode) {
|
||||
this.cardsContainer.removeChild(this.monsterContainer);
|
||||
}
|
||||
this.monsterContainer = null;
|
||||
this.currentMonsterCard = null;
|
||||
this.attackButton = null;
|
||||
}
|
||||
}
|
||||