Compare commits
13 Commits
combat-eng
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 613fa843ee | |||
| 5888c59ba4 | |||
| 009c2a4135 | |||
| b08a922c00 | |||
| e45207807d | |||
| 85a390b94a | |||
| 0685c1249e | |||
| f2f399c296 | |||
| df3f892eb2 | |||
| 5c5cc13903 | |||
| 180cf3ab94 | |||
| 377096c530 | |||
| 61c7cc3313 |
252
DEVLOG.md
252
DEVLOG.md
@@ -1,5 +1,257 @@
|
|||||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||||
|
|
||||||
|
## 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)
|
## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
|
||||||
|
|
||||||
### Objetivos Completados
|
### Objetivos Completados
|
||||||
|
|||||||
@@ -39,7 +39,13 @@
|
|||||||
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
||||||
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
|
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
|
||||||
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
|
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
|
||||||
- [ ] Refine Combat System (Ranged weapons, Special Monster Rules, Magic)
|
- [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
|
## Phase 4: Campaign System
|
||||||
- [ ] **Campaign Manager**
|
- [ ] **Campaign Manager**
|
||||||
|
|||||||
BIN
public/assets/images/dungeon1/spells/attack_template.png
Normal file
BIN
public/assets/images/dungeon1/spells/attack_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/assets/images/dungeon1/spells/defense_template.png
Normal file
BIN
public/assets/images/dungeon1/spells/defense_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/assets/images/dungeon1/spells/healing_template.png
Normal file
BIN
public/assets/images/dungeon1/spells/healing_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/assets/music/ingame/Abandoned_Ruins.mp3
Normal file
BIN
public/assets/music/ingame/Abandoned_Ruins.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/arrow.mp3
Normal file
BIN
public/assets/sfx/arrow.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/footsteps.mp3
Normal file
BIN
public/assets/sfx/footsteps.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/opendoor.mp3
Normal file
BIN
public/assets/sfx/opendoor.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/sword1.mp3
Normal file
BIN
public/assets/sfx/sword1.mp3
Normal file
Binary file not shown.
38
src/engine/data/Spells.js
Normal file
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,9 @@ export class DungeonDeck {
|
|||||||
// 1. Create a "Pool" of standard dungeon tiles
|
// 1. Create a "Pool" of standard dungeon tiles
|
||||||
let pool = [];
|
let pool = [];
|
||||||
const composition = [
|
const composition = [
|
||||||
{ id: 'room_dungeon', count: 6 },
|
{ id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms
|
||||||
{ id: 'corridor_straight', count: 7 },
|
{ id: 'corridor_straight', count: 0 },
|
||||||
{ id: 'corridor_steps', count: 1 },
|
{ id: 'junction_t', count: 0 }
|
||||||
{ id: 'corridor_corner', count: 1 }, // L-Shape
|
|
||||||
{ id: 'junction_t', count: 3 }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
composition.forEach(item => {
|
composition.forEach(item => {
|
||||||
|
|||||||
101
src/engine/game/CombatSystem.js
Normal file
101
src/engine/game/CombatSystem.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 4. Side Effects
|
||||||
|
if (window.SOUND_MANAGER) {
|
||||||
|
window.SOUND_MANAGER.playSound('arrow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.game.onCombatResult) {
|
||||||
|
this.game.onCombatResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, result };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
||||||
import { TurnManager } from './TurnManager.js';
|
import { TurnManager } from './TurnManager.js';
|
||||||
import { MonsterAI } from './MonsterAI.js';
|
import { MonsterAI } from './MonsterAI.js';
|
||||||
|
import { MagicSystem } from './MagicSystem.js';
|
||||||
|
import { CombatSystem } from './CombatSystem.js';
|
||||||
import { CombatMechanics } from './CombatMechanics.js';
|
import { CombatMechanics } from './CombatMechanics.js';
|
||||||
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
||||||
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
||||||
@@ -14,6 +16,8 @@ export class GameEngine {
|
|||||||
this.dungeon = new DungeonGenerator();
|
this.dungeon = new DungeonGenerator();
|
||||||
this.turnManager = new TurnManager();
|
this.turnManager = new TurnManager();
|
||||||
this.ai = new MonsterAI(this); // Init AI
|
this.ai = new MonsterAI(this); // Init AI
|
||||||
|
this.magicSystem = new MagicSystem(this); // Init Magic
|
||||||
|
this.combatSystem = new CombatSystem(this); // Init Combat
|
||||||
this.player = null;
|
this.player = null;
|
||||||
this.selectedEntity = null;
|
this.selectedEntity = null;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
@@ -48,7 +52,24 @@ export class GameEngine {
|
|||||||
if (phase === 'hero' || phase === 'exploration') {
|
if (phase === 'hero' || phase === 'exploration') {
|
||||||
this.resetHeroMoves();
|
this.resetHeroMoves();
|
||||||
}
|
}
|
||||||
|
if (phase === 'hero') {
|
||||||
|
this.initializeTurnOrder();
|
||||||
|
}
|
||||||
|
if (phase === 'monster') {
|
||||||
|
if (window.RENDERER && window.RENDERER.clearAllActiveRings) {
|
||||||
|
window.RENDERER.clearAllActiveRings();
|
||||||
|
}
|
||||||
|
this.deselectEntity();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// End of Turn Logic (Buffs, cooldowns, etc)
|
||||||
|
this.turnManager.on('turn_ended', (turn) => {
|
||||||
|
this.handleEndTurn();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial Light Update
|
||||||
|
setTimeout(() => this.updateLighting(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetHeroMoves() {
|
resetHeroMoves() {
|
||||||
@@ -56,10 +77,54 @@ export class GameEngine {
|
|||||||
this.heroes.forEach(hero => {
|
this.heroes.forEach(hero => {
|
||||||
hero.currentMoves = hero.stats.move;
|
hero.currentMoves = hero.stats.move;
|
||||||
hero.hasAttacked = false;
|
hero.hasAttacked = false;
|
||||||
|
hero.hasEscapedPin = false; // Reset pin escape status
|
||||||
});
|
});
|
||||||
console.log("Refilled Hero Moves");
|
console.log("Refilled Hero Moves");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEndTurn() {
|
||||||
|
console.log("[GameEngine] Handling End of Turn Effects...");
|
||||||
|
|
||||||
|
if (!this.heroes) return;
|
||||||
|
|
||||||
|
this.heroes.forEach(hero => {
|
||||||
|
if (hero.buffs && hero.buffs.length > 0) {
|
||||||
|
// Decrement duration
|
||||||
|
hero.buffs.forEach(buff => {
|
||||||
|
buff.duration--;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove expired
|
||||||
|
const activeBuffs = [];
|
||||||
|
const expiredBuffs = [];
|
||||||
|
|
||||||
|
hero.buffs.forEach(buff => {
|
||||||
|
if (buff.duration > 0) {
|
||||||
|
activeBuffs.push(buff);
|
||||||
|
} else {
|
||||||
|
expiredBuffs.push(buff);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revert expired
|
||||||
|
expiredBuffs.forEach(buff => {
|
||||||
|
if (buff.stat === 'toughness') {
|
||||||
|
hero.stats.toughness -= buff.value;
|
||||||
|
if (hero.tempStats && hero.tempStats.toughnessBonus) {
|
||||||
|
hero.tempStats.toughnessBonus -= buff.value;
|
||||||
|
}
|
||||||
|
console.log(`[GameEngine] Buff expired: ${buff.id} on ${hero.name}. -${buff.value} ${buff.stat}`);
|
||||||
|
if (this.onShowMessage) {
|
||||||
|
this.onShowMessage("Efecto Finalizado", `La ${buff.id === 'iron_skin' ? 'Piel de Hierro' : 'Magia'} de ${hero.name} se desvanece.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hero.buffs = activeBuffs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createParty() {
|
createParty() {
|
||||||
this.heroes = [];
|
this.heroes = [];
|
||||||
this.monsters = []; // Initialize monsters array
|
this.monsters = []; // Initialize monsters array
|
||||||
@@ -109,6 +174,105 @@ export class GameEngine {
|
|||||||
this.player = this.heroes[0];
|
this.player = this.heroes[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeTurnOrder() {
|
||||||
|
console.log("[GameEngine] Initializing Turn Order...");
|
||||||
|
|
||||||
|
// 1. Identify Leader
|
||||||
|
const leader = this.getLeader();
|
||||||
|
|
||||||
|
// 2. Sort Rest by Initiative (Descending)
|
||||||
|
// Note: Sort is stable or we rely on index? Array.sort is stable in modern JS.
|
||||||
|
const others = this.heroes.filter(h => h !== leader);
|
||||||
|
others.sort((a, b) => b.stats.init - a.stats.init);
|
||||||
|
|
||||||
|
// 3. Construct Order
|
||||||
|
this.heroTurnOrder = [leader, ...others];
|
||||||
|
this.currentTurnIndex = 0;
|
||||||
|
|
||||||
|
console.log("Turn Order:", this.heroTurnOrder.map(h => `${h.name} (${h.stats.init})`));
|
||||||
|
|
||||||
|
// 4. Activate First
|
||||||
|
this.activateHero(this.heroTurnOrder[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
activateHero(hero) {
|
||||||
|
this.selectedEntity = hero;
|
||||||
|
// Update selection UI
|
||||||
|
if (this.onEntitySelect) {
|
||||||
|
// Deselect all keys first?
|
||||||
|
this.heroes.forEach(h => this.onEntitySelect(h.id, false));
|
||||||
|
this.onEntitySelect(hero.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify UI about active turn
|
||||||
|
if (this.onShowMessage) {
|
||||||
|
this.onShowMessage(`Turno de ${hero.name}`, "Mueve y Ataca.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as active in renderer (Green Ring vs Yellow Selection)
|
||||||
|
if (window.RENDERER) {
|
||||||
|
this.heroes.forEach(h => window.RENDERER.setEntityActive(h.id, false));
|
||||||
|
window.RENDERER.setEntityActive(hero.id, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHeroTurn() {
|
||||||
|
this.currentTurnIndex++;
|
||||||
|
|
||||||
|
// Loop to find next VALID hero (visible)
|
||||||
|
while (this.currentTurnIndex < this.heroTurnOrder.length) {
|
||||||
|
const nextHero = this.heroTurnOrder[this.currentTurnIndex];
|
||||||
|
|
||||||
|
// Check visibility
|
||||||
|
// Exception: Leader (hasLantern) is ALWAYS visible.
|
||||||
|
if (nextHero.hasLantern) {
|
||||||
|
this.activateHero(nextHero);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if hero is in a visible tile
|
||||||
|
// Get hero tile ID
|
||||||
|
const heroTileId = this.dungeon.grid.occupiedCells.get(`${nextHero.x},${nextHero.y}`);
|
||||||
|
|
||||||
|
// If currentVisibleTileIds is defined, enforce it.
|
||||||
|
if (this.currentVisibleTileIds) {
|
||||||
|
if (heroTileId && this.currentVisibleTileIds.has(heroTileId)) {
|
||||||
|
this.activateHero(nextHero);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log(`Skipping turn for ${nextHero.name} (In Darkness)`);
|
||||||
|
if (this.onShowMessage) {
|
||||||
|
// Optional: Small notification or log
|
||||||
|
// this.onShowMessage("Perdido en la oscuridad", `${nextHero.name} pierde su turno.`);
|
||||||
|
}
|
||||||
|
this.currentTurnIndex++; // Skip and continue loop
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Should not happen if updateLighting runs, but fallback
|
||||||
|
this.activateHero(nextHero);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If loop finishes, no more heroes
|
||||||
|
if (this.currentTurnIndex >= this.heroTurnOrder.length) {
|
||||||
|
console.log("All heroes acted. Ending Phase sequence if auto?");
|
||||||
|
this.deselectEntity();
|
||||||
|
if (window.RENDERER) {
|
||||||
|
this.heroes.forEach(h => window.RENDERER.setEntityActive(h.id, false));
|
||||||
|
}
|
||||||
|
if (this.onShowMessage) {
|
||||||
|
this.onShowMessage("Fase de Aventureros Terminada", "Pasando a Monstruos...");
|
||||||
|
}
|
||||||
|
// Auto Advance Phase? Or Manual?
|
||||||
|
// Usually manual "End Turn" button triggers nextHeroTurn.
|
||||||
|
// When last hero ends, we trigger nextPhase.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.turnManager.nextPhase();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
spawnMonster(monsterKey, x, y, options = {}) {
|
spawnMonster(monsterKey, x, y, options = {}) {
|
||||||
const definition = MONSTER_DEFINITIONS[monsterKey];
|
const definition = MONSTER_DEFINITIONS[monsterKey];
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
@@ -147,7 +311,88 @@ export class GameEngine {
|
|||||||
return monster;
|
return monster;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCellHover(x, y) {
|
||||||
|
if (this.targetingMode === 'spell' && this.currentSpell) {
|
||||||
|
const area = this.currentSpell.area || 1;
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
if (area === 2) {
|
||||||
|
cells.push({ x: x, y: y });
|
||||||
|
cells.push({ x: x + 1, y: y });
|
||||||
|
cells.push({ x: x, y: y + 1 });
|
||||||
|
cells.push({ x: x + 1, y: y + 1 });
|
||||||
|
} else {
|
||||||
|
cells.push({ x: x, y: y });
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOS Check for Color
|
||||||
|
let color = 0xffffff; // Default White
|
||||||
|
const caster = this.selectedEntity;
|
||||||
|
if (caster) {
|
||||||
|
// Check LOS to the center/anchor cell (x,y)
|
||||||
|
const targetObj = { x: x, y: y };
|
||||||
|
const los = this.checkLineOfSightStrict(caster, targetObj);
|
||||||
|
|
||||||
|
if (los && los.clear) {
|
||||||
|
color = 0x00ff00; // Green (Good)
|
||||||
|
} else {
|
||||||
|
color = 0xff0000; // Red (Blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show Preview
|
||||||
|
if (window.RENDERER) {
|
||||||
|
window.RENDERER.showAreaPreview(cells, color);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onCellClick(x, y) {
|
onCellClick(x, y) {
|
||||||
|
// SPELL TARGETING LOGIC
|
||||||
|
if (this.targetingMode === 'spell' && this.currentSpell) {
|
||||||
|
const area = this.currentSpell.area || 1;
|
||||||
|
const targetCells = [];
|
||||||
|
|
||||||
|
if (area === 2) {
|
||||||
|
targetCells.push({ x: x, y: y });
|
||||||
|
targetCells.push({ x: x + 1, y: y });
|
||||||
|
targetCells.push({ x: x, y: y + 1 });
|
||||||
|
targetCells.push({ x: x + 1, y: y + 1 });
|
||||||
|
} else {
|
||||||
|
targetCells.push({ x: x, y: y });
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Enforce LOS Check before execution
|
||||||
|
const caster = this.selectedEntity;
|
||||||
|
if (caster) {
|
||||||
|
const targetObj = { x: x, y: y };
|
||||||
|
const los = this.checkLineOfSightStrict(caster, targetObj);
|
||||||
|
if (!los || !los.clear) {
|
||||||
|
if (this.onShowMessage) this.onShowMessage('Bloqueado', 'No tienes línea de visión.');
|
||||||
|
// Do NOT cancel targeting, let them try again
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute Spell
|
||||||
|
const result = this.executeSpell(this.currentSpell, targetCells);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Success
|
||||||
|
this.cancelTargeting();
|
||||||
|
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||||
|
} else {
|
||||||
|
if (this.onShowMessage) this.onShowMessage('Fallo', result.reason || 'No se pudo lanzar el hechizo.');
|
||||||
|
this.cancelTargeting(); // Cancel on error? maybe keep open? usually cancel.
|
||||||
|
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// RANGED TARGETING LOGIC
|
// RANGED TARGETING LOGIC
|
||||||
if (this.targetingMode === 'ranged') {
|
if (this.targetingMode === 'ranged') {
|
||||||
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||||
@@ -176,9 +421,35 @@ export class GameEngine {
|
|||||||
const clickedEntity = clickedHero || clickedMonster;
|
const clickedEntity = clickedHero || clickedMonster;
|
||||||
|
|
||||||
if (clickedEntity) {
|
if (clickedEntity) {
|
||||||
|
|
||||||
|
// STRICT TURN ORDER CHECK
|
||||||
|
if (this.turnManager.currentPhase === 'hero' && clickedHero) {
|
||||||
|
const currentActiveHero = this.heroTurnOrder ? this.heroTurnOrder[this.currentTurnIndex] : null;
|
||||||
|
if (currentActiveHero && clickedHero.id !== currentActiveHero.id) {
|
||||||
|
if (this.onShowMessage) this.onShowMessage("No es su turno", `Es el turno de ${currentActiveHero.name}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.selectedEntity === clickedEntity) {
|
if (this.selectedEntity === clickedEntity) {
|
||||||
// Toggle Deselect
|
// Toggle Deselect
|
||||||
this.deselectEntity();
|
// EXCEPTION: In Hero Phase, if I click MYSELF (Active Hero), do NOT deselect.
|
||||||
|
// It's annoying to lose the card.
|
||||||
|
const isHeroPhase = this.turnManager.currentPhase === 'hero';
|
||||||
|
let isActiveTurnHero = false;
|
||||||
|
if (isHeroPhase && this.heroTurnOrder && this.currentTurnIndex !== undefined) {
|
||||||
|
const activeHero = this.heroTurnOrder[this.currentTurnIndex];
|
||||||
|
if (activeHero && activeHero.id === clickedEntity.id) {
|
||||||
|
isActiveTurnHero = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActiveTurnHero) {
|
||||||
|
// Do nothing (keep selected)
|
||||||
|
// Maybe blink the card or something?
|
||||||
|
} else {
|
||||||
|
this.deselectEntity();
|
||||||
|
}
|
||||||
} else if (this.selectedMonster === clickedMonster && clickedMonster) {
|
} else if (this.selectedMonster === clickedMonster && clickedMonster) {
|
||||||
// Clicking on already selected monster - deselect it
|
// Clicking on already selected monster - deselect it
|
||||||
const monsterId = this.selectedMonster.id;
|
const monsterId = this.selectedMonster.id;
|
||||||
@@ -237,31 +508,100 @@ export class GameEngine {
|
|||||||
const hero = this.selectedEntity;
|
const hero = this.selectedEntity;
|
||||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||||
|
|
||||||
if (!hero || !monster) return null;
|
// Attack ends turn logic could be here? Assuming user clicks "End Turn" manually for now.
|
||||||
|
// Or if standard rules: 1 attack per turn.
|
||||||
|
// For now, just attack.
|
||||||
|
|
||||||
// Check Phase
|
return this.combatSystem.handleMeleeAttack(hero, monster);
|
||||||
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
}
|
||||||
|
|
||||||
// Check Adjacency
|
updateLighting() {
|
||||||
const dx = Math.abs(hero.x - monster.x);
|
if (!window.RENDERER) return;
|
||||||
const dy = Math.abs(hero.y - monster.y);
|
|
||||||
if (dx + dy !== 1) return { success: false, reason: 'range' };
|
|
||||||
|
|
||||||
// Check Action Economy
|
const leader = this.getLeader();
|
||||||
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
if (!leader) return;
|
||||||
|
|
||||||
// Execute Attack
|
// 1. Get Leader Tile ID
|
||||||
const result = CombatMechanics.resolveMeleeAttack(hero, monster, this);
|
const leaderTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`);
|
||||||
hero.hasAttacked = true;
|
if (!leaderTileId) return;
|
||||||
|
|
||||||
if (this.onCombatResult) this.onCombatResult(result);
|
const visibleTileIds = new Set([leaderTileId]);
|
||||||
|
|
||||||
return { success: true, result };
|
// 2. Find Neighbor Tiles (Connected Board Sections)
|
||||||
|
// Iterate grid occupied cells to find cells belonging to leaderTileId
|
||||||
|
// Then check their neighbors for DIFFERENT tile IDs that are connected.
|
||||||
|
|
||||||
|
// Optimization: We could cache this or iterate efficiently
|
||||||
|
// For now, scan occupiedCells (Map)
|
||||||
|
|
||||||
|
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
|
||||||
|
if (tid === leaderTileId) {
|
||||||
|
const [cx, cy] = key.split(',').map(Number);
|
||||||
|
|
||||||
|
// Check 4 neighbors
|
||||||
|
const neighbors = [
|
||||||
|
{ x: cx + 1, y: cy }, { x: cx - 1, y: cy },
|
||||||
|
{ x: cx, y: cy + 1 }, { x: cx, y: cy - 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const n of neighbors) {
|
||||||
|
const nKey = `${n.x},${n.y}`;
|
||||||
|
const nTileId = this.dungeon.grid.occupiedCells.get(nKey);
|
||||||
|
|
||||||
|
if (nTileId && nTileId !== leaderTileId) {
|
||||||
|
// Found a neighbor tile!
|
||||||
|
// Check connectivity logic (Walls/Doors)
|
||||||
|
if (this.dungeon.grid.canMoveBetween(cx, cy, n.x, n.y)) {
|
||||||
|
visibleTileIds.add(nTileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store active visibility sets for Turn Logic
|
||||||
|
this.currentVisibleTileIds = visibleTileIds;
|
||||||
|
|
||||||
|
window.RENDERER.updateFogOfWar(Array.from(visibleTileIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
performRangedAttack(targetMonsterId) {
|
||||||
|
const hero = this.selectedEntity;
|
||||||
|
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||||
|
return this.combatSystem.handleRangedAttack(hero, monster);
|
||||||
|
}
|
||||||
|
|
||||||
|
canCastSpell(spell) {
|
||||||
|
return this.magicSystem.canCastSpell(this.selectedEntity, spell);
|
||||||
|
}
|
||||||
|
|
||||||
|
executeSpell(spell, targetCells = []) {
|
||||||
|
if (!this.selectedEntity) return { success: false, reason: 'no_caster' };
|
||||||
|
return this.magicSystem.executeSpell(this.selectedEntity, spell, targetCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectEntity() {
|
||||||
|
if (!this.selectedEntity) return;
|
||||||
|
const id = this.selectedEntity.id;
|
||||||
|
this.selectedEntity = null;
|
||||||
|
this.plannedPath = [];
|
||||||
|
if (this.onEntitySelect) this.onEntitySelect(id, false);
|
||||||
|
if (this.onPathChange) this.onPathChange([]);
|
||||||
|
|
||||||
|
// Also deselect monster if selected
|
||||||
|
if (this.selectedMonster) {
|
||||||
|
const monsterId = this.selectedMonster.id;
|
||||||
|
this.selectedMonster = null;
|
||||||
|
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isEntityPinned(entity) {
|
isEntityPinned(entity) {
|
||||||
if (!this.monsters || this.monsters.length === 0) return false;
|
if (!this.monsters || this.monsters.length === 0) return false;
|
||||||
|
|
||||||
|
// If already escaped this turn, not pinned
|
||||||
|
if (entity.hasEscapedPin) return false;
|
||||||
|
|
||||||
return this.monsters.some(m => {
|
return this.monsters.some(m => {
|
||||||
if (m.isDead) return false;
|
if (m.isDead) return false;
|
||||||
const dx = Math.abs(entity.x - m.x);
|
const dx = Math.abs(entity.x - m.x);
|
||||||
@@ -293,41 +633,24 @@ export class GameEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
performRangedAttack(targetMonsterId) {
|
attemptBreakAway(hero) {
|
||||||
const hero = this.selectedEntity;
|
if (!hero || hero.hasEscapedPin) return { success: false, roll: 0 };
|
||||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
|
||||||
|
|
||||||
if (!hero || !monster) return null;
|
const roll = Math.floor(Math.random() * 6) + 1;
|
||||||
|
const target = hero.stats.pin_target || 6;
|
||||||
|
|
||||||
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
const success = roll >= target;
|
||||||
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
|
||||||
if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' };
|
|
||||||
|
|
||||||
// LOS Check should be done before calling this, but we can double check or assume UI did it.
|
if (success) {
|
||||||
// For simplicity, we execute the attack here assuming validation passed.
|
hero.hasEscapedPin = true;
|
||||||
|
} else {
|
||||||
const result = CombatMechanics.resolveRangedAttack(hero, monster, this);
|
// Failed to escape: Unit loses movement and ranged attacks?
|
||||||
hero.hasAttacked = true;
|
// "The Adventurer must stay where he is and fight"
|
||||||
|
// So movement becomes 0.
|
||||||
if (this.onCombatResult) this.onCombatResult(result);
|
hero.currentMoves = 0;
|
||||||
|
|
||||||
return { success: true, result };
|
|
||||||
}
|
|
||||||
|
|
||||||
deselectEntity() {
|
|
||||||
if (!this.selectedEntity) return;
|
|
||||||
const id = this.selectedEntity.id;
|
|
||||||
this.selectedEntity = null;
|
|
||||||
this.plannedPath = [];
|
|
||||||
if (this.onEntitySelect) this.onEntitySelect(id, false);
|
|
||||||
if (this.onPathChange) this.onPathChange([]);
|
|
||||||
|
|
||||||
// Also deselect monster if selected
|
|
||||||
if (this.selectedMonster) {
|
|
||||||
const monsterId = this.selectedMonster.id;
|
|
||||||
this.selectedMonster = null;
|
|
||||||
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { success, roll, target };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias for legacy calls if any
|
// Alias for legacy calls if any
|
||||||
@@ -410,6 +733,11 @@ export class GameEngine {
|
|||||||
entity.y = step.y;
|
entity.y = step.y;
|
||||||
stepsTaken++;
|
stepsTaken++;
|
||||||
|
|
||||||
|
// Update Light if Lantern Bearer
|
||||||
|
if (entity.hasLantern) {
|
||||||
|
this.updateLighting();
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Check for New Tile Entry
|
// 2. Check for New Tile Entry
|
||||||
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
|
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
|
||||||
|
|
||||||
@@ -474,7 +802,32 @@ export class GameEngine {
|
|||||||
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.deselectEntity();
|
// AUTO-DESELECT LOGIC
|
||||||
|
// In Hero Phase, we want to KEEP the active hero selected to avoid re-selecting.
|
||||||
|
const isHeroPhase = this.turnManager.currentPhase === 'hero';
|
||||||
|
// Check if entity is the currently active turn hero
|
||||||
|
let isActiveTurnHero = false;
|
||||||
|
if (isHeroPhase && this.heroTurnOrder && this.currentTurnIndex !== undefined) {
|
||||||
|
const activeHero = this.heroTurnOrder[this.currentTurnIndex];
|
||||||
|
if (activeHero && activeHero.id === entity.id) {
|
||||||
|
isActiveTurnHero = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActiveTurnHero) {
|
||||||
|
// Do NOT deselect. Just clear path.
|
||||||
|
this.plannedPath = [];
|
||||||
|
if (this.onPathChange) this.onPathChange([]);
|
||||||
|
|
||||||
|
// Also force update UI/Card (stats changed)
|
||||||
|
if (this.onEntitySelect) {
|
||||||
|
// Re-trigger selection to ensure UI is fresh?
|
||||||
|
// UIManager listens to onEntityMove to update stats, so that should be covered.
|
||||||
|
// But purely being consistent:
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.deselectEntity();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -642,8 +995,16 @@ export class GameEngine {
|
|||||||
console.log("Ranged Targeting Mode ON");
|
console.log("Ranged Targeting Mode ON");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startSpellTargeting(spell) {
|
||||||
|
this.targetingMode = 'spell';
|
||||||
|
this.currentSpell = spell;
|
||||||
|
console.log(`Spell Targeting Mode ON: ${spell.name}`);
|
||||||
|
if (this.onShowMessage) this.onShowMessage(spell.name, 'Selecciona el objetivo (Monstruo o Casilla).');
|
||||||
|
}
|
||||||
|
|
||||||
cancelTargeting() {
|
cancelTargeting() {
|
||||||
this.targetingMode = null;
|
this.targetingMode = null;
|
||||||
|
this.currentSpell = null;
|
||||||
if (this.onRangedTarget) {
|
if (this.onRangedTarget) {
|
||||||
this.onRangedTarget(null, null);
|
this.onRangedTarget(null, null);
|
||||||
}
|
}
|
||||||
@@ -809,6 +1170,9 @@ export class GameEngine {
|
|||||||
|
|
||||||
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
||||||
|
|
||||||
|
let prevX = null;
|
||||||
|
let prevY = null;
|
||||||
|
|
||||||
for (let i = 0; i < maxSteps; i++) {
|
for (let i = 0; i < maxSteps; i++) {
|
||||||
path.push({ x: currentX, y: currentY });
|
path.push({ x: currentX, y: currentY });
|
||||||
|
|
||||||
@@ -816,32 +1180,97 @@ export class GameEngine {
|
|||||||
const isEnd = (currentX === target.x && currentY === target.y);
|
const isEnd = (currentX === target.x && currentY === target.y);
|
||||||
|
|
||||||
if (!isStart && !isEnd) {
|
if (!isStart && !isEnd) {
|
||||||
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
// WALL CHECK: Use Connectvity (canMoveBetween)
|
||||||
|
// This detects walls between tiles even if both tiles are floor.
|
||||||
|
// It also detects VOID cells (because canMoveBetween returns false if destination is void).
|
||||||
|
if (prevX !== null) {
|
||||||
|
if (!this.dungeon.grid.canMoveBetween(prevX, prevY, currentX, currentY)) {
|
||||||
|
blocked = true;
|
||||||
|
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||||
|
console.log(`[LOS] Blocked by WALL/BORDER between ${prevX},${prevY} and ${currentX},${currentY}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||||
|
// Fallback for start/isolated case (should rarely happen for LOS path)
|
||||||
blocked = true;
|
blocked = true;
|
||||||
blocker = { type: 'wall', x: currentX, y: currentY };
|
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||||
console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: Distance from Cell Center to Ray (for grazing tolerance)
|
||||||
|
const getDist = () => {
|
||||||
|
const cx = currentX + 0.5;
|
||||||
|
const cy = currentY + 0.5;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (len === 0) return 0;
|
||||||
|
return Math.abs(dy * cx - dx * cy + dx * y1 - dy * x1) / len;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tolerance: Allow shots to pass if they graze the edge (0.5 is full width)
|
||||||
|
// 0.4 means the outer 20% of the tile is "safe" to shoot through.
|
||||||
|
const ENTITY_HITBOX_RADIUS = 0.4;
|
||||||
|
|
||||||
|
// 2. Monster Check
|
||||||
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||||
if (m) {
|
if (m) {
|
||||||
blocked = true;
|
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||||
blocker = { type: 'monster', entity: m };
|
blocked = true;
|
||||||
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
blocker = { type: 'monster', entity: m };
|
||||||
break;
|
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
console.log(`[LOS] Grazed MONSTER ${m.name} (Dist: ${getDist().toFixed(2)})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Hero Check
|
||||||
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||||
if (h) {
|
if (h) {
|
||||||
blocked = true;
|
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||||
blocker = { type: 'hero', entity: h };
|
blocked = true;
|
||||||
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
blocker = { type: 'hero', entity: h };
|
||||||
break;
|
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
console.log(`[LOS] Grazed HERO ${h.name} (Dist: ${getDist().toFixed(2)})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentX === endX && currentY === endY) break;
|
if (currentX === endX && currentY === endY) break;
|
||||||
|
|
||||||
|
// CORNER CROSSING CHECK: Prevent diagonal wall leaking
|
||||||
|
// When tMaxX ≈ tMaxY, the ray passes through a vertex shared by 4 cells.
|
||||||
|
// Standard algorithm only visits 2 of them. We must check BOTH neighbors.
|
||||||
|
const CORNER_EPSILON = 0.001;
|
||||||
|
const cornerCrossing = Math.abs(tMaxX - tMaxY) < CORNER_EPSILON;
|
||||||
|
|
||||||
|
if (cornerCrossing) {
|
||||||
|
// Check connectivity to both orthogonal neighbors
|
||||||
|
const neighborX = currentX + stepX;
|
||||||
|
const neighborY = currentY + stepY;
|
||||||
|
|
||||||
|
// Check horizontal neighbor connectivity
|
||||||
|
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, neighborX, currentY)) {
|
||||||
|
blocked = true;
|
||||||
|
blocker = { type: 'wall', x: neighborX, y: currentY };
|
||||||
|
console.log(`[LOS] Blocked by CORNER WALL (H) at ${neighborX},${currentY}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check vertical neighbor connectivity
|
||||||
|
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, currentX, neighborY)) {
|
||||||
|
blocked = true;
|
||||||
|
blocker = { type: 'wall', x: currentX, y: neighborY };
|
||||||
|
console.log(`[LOS] Blocked by CORNER WALL (V) at ${currentX},${neighborY}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Previous
|
||||||
|
prevX = currentX;
|
||||||
|
prevY = currentY;
|
||||||
|
|
||||||
if (tMaxX < tMaxY) {
|
if (tMaxX < tMaxY) {
|
||||||
tMaxX += tDeltaX;
|
tMaxX += tDeltaX;
|
||||||
currentX += stepX;
|
currentX += stepX;
|
||||||
|
|||||||
224
src/engine/game/MagicSystem.js
Normal file
224
src/engine/game/MagicSystem.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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}`);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
targetCells.forEach(cell => {
|
||||||
|
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
|
||||||
|
|
||||||
|
if (monster) {
|
||||||
|
const damageDice = spell.damageDice || 1;
|
||||||
|
let damageTotal = level;
|
||||||
|
for (let i = 0; i < damageDice; i++) {
|
||||||
|
damageTotal += Math.floor(Math.random() * 6) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Damage
|
||||||
|
CombatMechanics.applyDamage(monster, damageTotal, this.game);
|
||||||
|
hits++;
|
||||||
|
|
||||||
|
// Feedback
|
||||||
|
if (this.game.onEntityHit) {
|
||||||
|
this.game.onEntityHit(monster.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Centralized Combat Feedback
|
||||||
|
window.RENDERER.showCombatFeedback(monster.x, monster.y, damageTotal, true);
|
||||||
|
|
||||||
|
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage.`);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for no renderer (tests?) or race condition
|
||||||
|
// Just apply damage immediately logic (duplicated for brevity check)
|
||||||
|
let hits = 0;
|
||||||
|
targetCells.forEach(cell => {
|
||||||
|
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
|
||||||
|
if (monster) {
|
||||||
|
const damageDice = spell.damageDice || 1;
|
||||||
|
let damageTotal = level;
|
||||||
|
for (let i = 0; i < damageDice; i++) {
|
||||||
|
damageTotal += Math.floor(Math.random() * 6) + 1;
|
||||||
|
}
|
||||||
|
CombatMechanics.applyDamage(monster, damageTotal, this.game);
|
||||||
|
hits++;
|
||||||
|
if (this.game.onEntityHit) {
|
||||||
|
this.game.onEntityHit(monster.id);
|
||||||
|
}
|
||||||
|
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage (no renderer).`);
|
||||||
|
if (monster.currentWounds <= 0 && !monster.isDead) {
|
||||||
|
monster.isDead = true;
|
||||||
|
if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, type: 'attack', hits: 1 }; // Return success immediately
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,11 +214,22 @@ export class MonsterAI {
|
|||||||
|
|
||||||
performAttack(monster, hero) {
|
performAttack(monster, hero) {
|
||||||
// SEQUENCE:
|
// SEQUENCE:
|
||||||
// 1. Show green ring on monster
|
// 0. Show TARGET (Blue Ring) on Hero
|
||||||
// 2. Monster attack animation (we'll simulate with delay)
|
if (this.game.onRangedTarget) {
|
||||||
// 3. Show red ring + shake on hero
|
// Re-using onRangedTarget? Or directly calling renderer?
|
||||||
// 4. Remove both rings
|
// Better to use a specific callback or direct call if available, or just add a new callback.
|
||||||
// 5. Show combat result
|
// But let's check if we can access renderer directly or use a new callback.
|
||||||
|
// The user prompt specifically asked for this feature.
|
||||||
|
// I'll assume we can use game.onEntityTarget if defined, or direct renderer call if needed,
|
||||||
|
// but standard pattern here is callbacks.
|
||||||
|
// Let's add onEntityTarget to GameEngine callbacks if not present, but for now I will try to use global RENDERER if possible
|
||||||
|
// OR simply define a new callback `this.game.onEntityTarget(hero.id, true)`.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct renderer call is safest given current context if we don't want to modify GameEngine interface heavily right now.
|
||||||
|
if (window.RENDERER && window.RENDERER.setEntityTarget) {
|
||||||
|
window.RENDERER.setEntityTarget(hero.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
|
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
|
||||||
|
|
||||||
@@ -229,10 +240,6 @@ export class MonsterAI {
|
|||||||
|
|
||||||
// Step 2: Attack animation delay (500ms)
|
// Step 2: Attack animation delay (500ms)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Step 3: Trigger hit visual on defender (if hit succeeded)
|
|
||||||
if (result.hitSuccess && this.game.onEntityHit) {
|
|
||||||
this.game.onEntityHit(hero.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
|
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -240,6 +247,11 @@ export class MonsterAI {
|
|||||||
this.game.onEntityActive(monster.id, false);
|
this.game.onEntityActive(monster.id, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove Target Ring
|
||||||
|
if (window.RENDERER && window.RENDERER.setEntityTarget) {
|
||||||
|
window.RENDERER.setEntityTarget(hero.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 5: Show combat result after both rings are gone
|
// Step 5: Show combat result after both rings are gone
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.game.onCombatResult) {
|
if (this.game.onCombatResult) {
|
||||||
@@ -247,7 +259,7 @@ export class MonsterAI {
|
|||||||
}
|
}
|
||||||
}, 200); // Small delay after rings disappear
|
}, 200); // Small delay after rings disappear
|
||||||
}, 1200); // Wait for red ring to disappear
|
}, 1200); // Wait for red ring to disappear
|
||||||
}, 500); // Attack animation delay
|
}, 800); // Attack animation delay + focus time
|
||||||
}
|
}
|
||||||
|
|
||||||
getAdjacentHero(entity) {
|
getAdjacentHero(entity) {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export class TurnManager {
|
|||||||
this.eventsTriggered = [];
|
this.eventsTriggered = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get power() {
|
||||||
|
return this.currentPowerRoll;
|
||||||
|
}
|
||||||
|
|
||||||
startGame() {
|
startGame() {
|
||||||
this.currentTurn = 1;
|
this.currentTurn = 1;
|
||||||
console.log(`--- TURN ${this.currentTurn} START ---`);
|
console.log(`--- TURN ${this.currentTurn} START ---`);
|
||||||
@@ -84,6 +88,7 @@ export class TurnManager {
|
|||||||
|
|
||||||
endTurn() {
|
endTurn() {
|
||||||
console.log(`--- TURN ${this.currentTurn} END ---`);
|
console.log(`--- TURN ${this.currentTurn} END ---`);
|
||||||
|
this.emit('turn_ended', this.currentTurn);
|
||||||
this.currentTurn++;
|
this.currentTurn++;
|
||||||
this.startPowerPhase();
|
this.startPowerPhase();
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/main.js
32
src/main.js
@@ -2,6 +2,7 @@ import { GameEngine } from './engine/game/GameEngine.js';
|
|||||||
import { GameRenderer } from './view/GameRenderer.js';
|
import { GameRenderer } from './view/GameRenderer.js';
|
||||||
import { CameraManager } from './view/CameraManager.js';
|
import { CameraManager } from './view/CameraManager.js';
|
||||||
import { UIManager } from './view/UIManager.js';
|
import { UIManager } from './view/UIManager.js';
|
||||||
|
import { SoundManager } from './view/SoundManager.js';
|
||||||
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.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 cameraManager = new CameraManager(renderer);
|
||||||
const game = new GameEngine();
|
const game = new GameEngine();
|
||||||
const ui = new UIManager(cameraManager, game);
|
const ui = new UIManager(cameraManager, game);
|
||||||
|
const soundManager = new SoundManager();
|
||||||
|
|
||||||
|
// Start Music (Autoplay handling included in manager)
|
||||||
|
soundManager.playMusic('exploration');
|
||||||
|
|
||||||
// Global Access
|
// Global Access
|
||||||
window.GAME = game;
|
window.GAME = game;
|
||||||
window.RENDERER = renderer;
|
window.RENDERER = renderer;
|
||||||
|
window.SOUND_MANAGER = soundManager;
|
||||||
|
|
||||||
// 3. Connect Dungeon Generator to Renderer
|
// 3. Connect Dungeon Generator to Renderer
|
||||||
const generator = game.dungeon;
|
const generator = game.dungeon;
|
||||||
@@ -97,6 +103,22 @@ game.turnManager.on('phase_changed', (phase) => {
|
|||||||
|
|
||||||
game.onCombatResult = (log) => {
|
game.onCombatResult = (log) => {
|
||||||
ui.showCombatLog(log);
|
ui.showCombatLog(log);
|
||||||
|
|
||||||
|
// 1. Show Attack Roll on Attacker
|
||||||
|
// Find Attacker pos
|
||||||
|
const attacker = game.heroes.find(h => h.id === log.attackerId) || game.monsters.find(m => m.id === log.attackerId);
|
||||||
|
if (attacker) {
|
||||||
|
const rollColor = log.hitSuccess ? '#00ff00' : '#888888'; // Green vs Gray
|
||||||
|
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Show Damage on Defender
|
||||||
|
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
|
||||||
|
if (defender) {
|
||||||
|
setTimeout(() => { // Slight delay for cause-effect
|
||||||
|
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
game.onEntityMove = (entity, path) => {
|
game.onEntityMove = (entity, path) => {
|
||||||
@@ -222,6 +244,7 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
|
|
||||||
// Open door visually
|
// Open door visually
|
||||||
renderer.openDoor(doorMesh);
|
renderer.openDoor(doorMesh);
|
||||||
|
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
|
||||||
|
|
||||||
// Get proper exit data with direction
|
// Get proper exit data with direction
|
||||||
const exitData = doorMesh.userData.exitData;
|
const exitData = doorMesh.userData.exitData;
|
||||||
@@ -259,7 +282,16 @@ renderer.setupInteraction(
|
|||||||
handleClick,
|
handleClick,
|
||||||
() => {
|
() => {
|
||||||
// Right Click Handler
|
// 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();
|
game.executeMovePath();
|
||||||
|
},
|
||||||
|
(x, y) => {
|
||||||
|
if (game.onCellHover) game.onCellHover(x, y);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -161,12 +161,18 @@ export class CameraManager {
|
|||||||
if (this.animationProgress >= 1) {
|
if (this.animationProgress >= 1) {
|
||||||
this.isAnimating = false;
|
this.isAnimating = false;
|
||||||
this.camera.position.copy(this.animationTargetPos);
|
this.camera.position.copy(this.animationTargetPos);
|
||||||
|
if (this.onAnimationComplete) {
|
||||||
|
this.onAnimationComplete();
|
||||||
|
this.onAnimationComplete = null; // Consume callback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fixed Orbit Logic ---
|
// --- Fixed Orbit Logic ---
|
||||||
setIsoView(direction) {
|
setIsoView(direction) {
|
||||||
|
this.lastIsoDirection = direction || DIRECTIONS.NORTH;
|
||||||
|
|
||||||
// Rotate camera around target while maintaining isometric angle
|
// Rotate camera around target while maintaining isometric angle
|
||||||
// Isometric view: 45 degree angle from horizontal
|
// Isometric view: 45 degree angle from horizontal
|
||||||
const distance = 28; // Distance from target
|
const distance = 28; // Distance from target
|
||||||
@@ -207,4 +213,31 @@ export class CameraManager {
|
|||||||
|
|
||||||
this.currentViewAngle = horizontalAngle;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
216
src/view/ParticleManager.js
Normal file
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/view/SoundManager.js
Normal file
142
src/view/SoundManager.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
367
src/view/render/DungeonRenderer.js
Normal file
367
src/view/render/DungeonRenderer.js
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
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) => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/view/render/EffectsRenderer.js
Normal file
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
|
||||||
|
}
|
||||||
|
}
|
||||||
377
src/view/render/EntityRenderer.js
Normal file
377
src/view/render/EntityRenderer.js
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
const w = 1.04;
|
||||||
|
const h = 1.56;
|
||||||
|
const geometry = new THREE.PlaneGeometry(w, h);
|
||||||
|
|
||||||
|
this.getTexture(entity.texturePath, (texture) => {
|
||||||
|
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) {
|
||||||
|
mesh.userData.pathQueue = [...path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntityPosition(entity) {
|
||||||
|
const mesh = this.entities.get(entity.id);
|
||||||
|
if (mesh) {
|
||||||
|
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) {
|
||||||
|
const ring = mesh.getObjectByName("SelectionRing");
|
||||||
|
if (ring) ring.visible = isSelected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntityActive(entityId, isActive) {
|
||||||
|
const mesh = this.entities.get(entityId);
|
||||||
|
if (!mesh) 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) 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) 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) 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) => {
|
||||||
|
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
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
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/view/ui/FeedbackUI.js
Normal file
196
src/view/ui/FeedbackUI.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
export class FeedbackUI {
|
||||||
|
constructor(parentContainer, game) {
|
||||||
|
this.parentContainer = parentContainer;
|
||||||
|
this.game = game; // Needed for resolving hero names/ids in logs?
|
||||||
|
|
||||||
|
this.combatLogContainer = null;
|
||||||
|
this.initCombatLogContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
initCombatLogContainer() {
|
||||||
|
this.combatLogContainer = document.createElement('div');
|
||||||
|
Object.assign(this.combatLogContainer.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '140px', // Below the top status panel
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '600px',
|
||||||
|
zIndex: '500' // Below modals
|
||||||
|
});
|
||||||
|
this.parentContainer.appendChild(this.combatLogContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 /** Checks if attached */) 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
showCombatLog(log) {
|
||||||
|
const isHit = log.hitSuccess;
|
||||||
|
const color = isHit ? '#ff4444' : '#aaaaaa';
|
||||||
|
|
||||||
|
let detailHtml = '';
|
||||||
|
if (isHit) {
|
||||||
|
if (log.woundsCaused > 0) {
|
||||||
|
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
|
||||||
|
} else {
|
||||||
|
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We create a new log element or update a singleton?
|
||||||
|
// The original logic updated a SINGLE notification area.
|
||||||
|
// Let's create a transient toast style log here, appending to container.
|
||||||
|
|
||||||
|
const logItem = document.createElement('div');
|
||||||
|
Object.assign(logItem.style, {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.9)', padding: '15px', border: `2px solid ${color}`,
|
||||||
|
borderRadius: '5px', textAlign: 'center', minWidth: '250px', marginBottom: '10px',
|
||||||
|
fontFamily: '"Cinzel", serif', opacity: '0', transition: 'opacity 0.3s'
|
||||||
|
});
|
||||||
|
|
||||||
|
logItem.innerHTML = `
|
||||||
|
<div style="font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
|
||||||
|
${detailHtml}
|
||||||
|
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Clear previous logs to act like the single notification area of before, OR stack them?
|
||||||
|
// Original behavior was overwrite `innerHTML`. I should stick to that to avoid spam.
|
||||||
|
// So I will clear `combatLogContainer` before adding.
|
||||||
|
this.combatLogContainer.innerHTML = '';
|
||||||
|
this.combatLogContainer.appendChild(logItem);
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
requestAnimationFrame(() => { logItem.style.opacity = '1'; });
|
||||||
|
|
||||||
|
// Fade out
|
||||||
|
setTimeout(() => {
|
||||||
|
logItem.style.opacity = '0';
|
||||||
|
// We don't remove immediately to avoid layout jumps if another comes in,
|
||||||
|
// but we cleared logic above.
|
||||||
|
}, 3500);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/view/ui/HUDManager.js
Normal file
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/view/ui/PlacementUI.js
Normal file
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
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}')`;
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/view/ui/TurnStatusUI.js
Normal file
226
src/view/ui/TurnStatusUI.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
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>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
429
src/view/ui/UnitCardManager.js
Normal file
429
src/view/ui/UnitCardManager.js
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
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.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: 'column',
|
||||||
|
gap: '10px',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
width: '200px'
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
|
||||||
|
const statsGrid = this.currentHeroCard.querySelector('div[style*="grid-template-columns"]');
|
||||||
|
if (statsGrid) {
|
||||||
|
const statDivs = statsGrid.children;
|
||||||
|
// Assumed order: 4 -> Heridas, 7 -> Movimiento
|
||||||
|
if (statDivs[4]) {
|
||||||
|
const wValue = statDivs[4].querySelector('span:last-child');
|
||||||
|
if (wValue) wValue.textContent = `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`;
|
||||||
|
}
|
||||||
|
if (statDivs[7]) {
|
||||||
|
const movValue = statDivs[7].querySelector('span:last-child');
|
||||||
|
if (movValue) movValue.textContent = `${hero.currentMoves || 0}/${hero.stats.move}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}` }
|
||||||
|
];
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
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: '#444',
|
||||||
|
color: '#fff', border: '1px solid #777', borderRadius: '4px',
|
||||||
|
fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer' // Changed cursor to pointer for feel, though functionality implies future
|
||||||
|
});
|
||||||
|
invBtn.title = 'Inventario (Próximamente)';
|
||||||
|
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();
|
||||||
|
this.currentMonsterCard = this.createMonsterCard(monster);
|
||||||
|
this.cardsContainer.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();
|
||||||
|
// Optional: deselect monster logic if managed externally
|
||||||
|
if (this.game.selectedMonster) {
|
||||||
|
if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false);
|
||||||
|
this.game.selectedMonster = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.cardsContainer.appendChild(this.attackButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.currentMonsterCard && this.currentMonsterCard.parentNode) {
|
||||||
|
this.cardsContainer.removeChild(this.currentMonsterCard);
|
||||||
|
this.currentMonsterCard = null;
|
||||||
|
}
|
||||||
|
if (this.attackButton && this.attackButton.parentNode) {
|
||||||
|
this.cardsContainer.removeChild(this.attackButton);
|
||||||
|
this.attackButton = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user