Compare commits

30 Commits

Author SHA1 Message Date
613fa843ee Refactor V2: GameRenderer Modularization (Quirurgical Approach) - Success 2026-01-09 17:20:54 +01:00
5888c59ba4 DEVLOG update: Documenting reversion of GameRenderer refactoring to stable state 2026-01-09 17:04:53 +01:00
009c2a4135 Refine FOW visuals, Turn Skipping logic, and UI Polish 2026-01-09 15:44:04 +01:00
b08a922c00 Implement Initiative Turn System and Fog of War 2026-01-09 14:12:40 +01:00
e45207807d Refactor: UIManager modularization and Devlog update 2026-01-08 23:53:39 +01:00
85a390b94a Actualizado Devlog 2026-01-08 23:42:17 +01:00
0685c1249e feat: spell book UI, iron skin spell, buffs system and devlog update 2026-01-08 23:35:01 +01:00
f2f399c296 feat: magic system visuals, audio sfx, and ui polish 2026-01-07 22:42:34 +01:00
df3f892eb2 feat: Add floating combat text and damage flash feedback 2026-01-07 20:16:55 +01:00
5c5cc13903 feat: Implement combat and movement sound effects with looping footsteps 2026-01-07 20:01:58 +01:00
180cf3ab94 feat: Add SoundManager for bg music and opendoor sfx 2026-01-06 23:40:32 +01:00
377096c530 fix: Refine LOS wall detection to use connectivity rules (canMoveBetween) 2026-01-06 22:30:01 +01:00
61c7cc3313 feat: Implement 2D tactical view and refine LOS with corner detection 2026-01-06 20:50:46 +01:00
c0a9299dc5 Implement Elf Ranged Combat and Pinned Mechanic
- Added 'Shoot Bow' action for Elf with Ballistic Skill mechanics (1995 rules).
- Implemented strict Line of Sight (LOS) raycasting (Amanatides & Woo) with UI feedback for blockers.
- Added 'Pinned' status: Heroes adjacent to monsters (without intervening walls) cannot move.
- Enhanced UI with visual indicators for blocked shots (red circles) and temporary modals.
- Polished 'End Phase' button layout and hidden it during Monster phase.
2026-01-06 20:05:56 +01:00
7b28fcf1b0 feat: Sistema de combate completo con tarjetas de personajes y animaciones
- Tarjetas de héroes y monstruos con tokens circulares
- Sistema de selección: héroe + monstruo para atacar
- Botón de ATACAR en tarjeta de monstruo
- Animación de muerte: fade-out + hundimiento (1.5s)
- Visualización de estadísticas completas (WS, BS, S, T, W, I, A, Mov)
- Placeholder cuando no hay héroe seleccionado
- Tokens de héroes y monstruos en formato circular
- Deselección correcta de monstruos
- Fix: paso de gameEngine a CombatMechanics para callbacks de muerte
2026-01-06 18:43:09 +01:00
3efbf8d5fb Implement advanced pathfinding and combat visual effects
- Add monster turn visual feedback (green ring on attacker, red ring on victim)
- Implement proper attack sequence with timing and animations
- Add room boundary and height level pathfinding system
- Monsters now respect room walls and can only pass through doors
- Add height level support (1-8) with stairs (9) for level transitions
- Fix attack validation to prevent attacks through walls
- Speed up hero movement animation (300ms per tile)
- Fix exploration phase message to not show on initial tile placement
- Disable hero movement during exploration phase (doors only)
2026-01-06 16:18:46 +01:00
dd7356f1bd Millorats personatges sense voreres negres 2026-01-06 11:06:24 +01:00
78b7486dd2 fix: allow hero movement in exploration phase (reset moves) 2026-01-05 23:15:07 +01:00
77c0c07a44 feat(game-loop): implement strict phase rules, exploration stops, and hero attacks 2026-01-05 23:11:31 +01:00
b619e4cee4 feat: Implement Event Deck, Monster Spawning, and AI Movement 2026-01-05 00:40:12 +01:00
056217437c Implement Lantern Bearer logic, Phase buttons, and Monster spawning basics 2026-01-04 23:48:53 +01:00
4c8b58151b Refactor: Organize standee assets and prepare for Motor OK tag 2026-01-04 22:32:37 +01:00
3bfe9e4809 Update DEVLOG for Session 5 2026-01-03 00:30:32 +01:00
2f63e54d13 Fix camera panning logic to update target position.
Previously, panning only moved the camera, causing orbital rotation issues when changing views or centering because the target reference point wasn't updated. Now both camera and target move in sync.
2026-01-03 00:28:52 +01:00
46b5466701 Adjust zoom settings and sync slider with mouse wheel.
- Changed default zoom from 2.5 to 6.0 (further away).
- Reduced max zoom distance from 30 to 15.
- Fixed slider not updating when using mouse wheel zoom.
2026-01-03 00:27:09 +01:00
019e527441 Fix duplicate room_objective definition in TileDefinitions.js 2026-01-03 00:21:45 +01:00
cd6abb016f Implement randomized tile textures.
- DungeonGenerator: Selects a random texture from the card definition when finalizing tile placement.
- GameRenderer: Renders the specific chosen texture for each tile instance instead of the default.
2026-01-03 00:19:30 +01:00
7462dd7fed Implement manual player movement planning (steps) and hopping animation
- GameEngine: Added path planning logic (click to add step, re-click to undo).
- GameRenderer: Added path visualization (numbered yellow squares).
- GameRenderer: Updated animation to include 'hopping' effect and clear path markers on visit.
- UIManager: Replaced alerts with modals.
- Main: Wired right-click to execute movement.
2026-01-03 00:15:28 +01:00
dbed4468c5 Fix tile alignment: Enforce strict connection for multi-cell doors and fix exit reference logic 2026-01-03 00:00:36 +01:00
ac536ac96c Implement tile discarding, blocked doors, and correct corridor exits
- Updated TileDefinitions.js: Added 4-way exits to corridor_straight and corridor_steps (N/S y=3,4; E/W x=3,4).
- Updated DungeonGenerator.js: Added cancelPlacement() logic and onDoorBlocked callback.
- Updated GameRenderer.js: Implemented blockDoor() to visualize blocked passages, and improved isPlayerAdjacentToDoor.
- Updated UIManager.js: Added custom showModal/showConfirm and Discard button for tile placement.
- Updated main.js: Handled blocked door clicks and hooked up UI events.
- Updated GameEngine.js: Improved door adjacency checks.
- Updated CameraManager.js: Preserved camera rotation on centerOn.
- Added door1_blocked.png asset.
2026-01-02 23:48:42 +01:00
75 changed files with 7575 additions and 1221 deletions

324
DEVLOG.md
View File

@@ -1,5 +1,329 @@
# 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)
### Objetivos Completados
1. **Reglas de Juego Oficiales (WHQ 1995)**:
- Se ha implementado un estricto control de fases: **Exploración**, **Aventureros** y **Monstruos**.
- **Exploración Realista**: Colocar una loseta finaliza el turno inmediatamente.
- **Tensión en Nuevas Áreas**: Al entrar en una nueva habitación, el héroe se detiene OBLIGATORIAMENTE (haya monstruos o no) y se revela el evento.
- **Combate Continuo**: Si hay monstruos vivos, se elimina la Fase de Exploración del ciclo y se salta la Fase de Poder para mantener un bucle de combate frenético (Aventureros <-> Monstruos).
2. **Movimiento y Eventos**:
- Refinamiento de `executeMovePath` en `GameEngine`:
- Detecta entrada en nuevos tiles.
- Diferencia entre **Habitaciones** (Trigger Event + Stop) y **Pasillos** (Solo marcar visitado).
- Detiene el movimiento sin penalizar los pasos no dados.
3. **Interacción de Héroes**:
- Implementado ataque básico haciendo clic izquierdo en monstruos adyacentes durante el turno propio.
- Permitido movimiento en fases de Exploración para facilitar el posicionamiento táctico antes de abrir puertas.
4. **Monstruos e IA**:
- Los monstruos de habitación ya no sufren "mareo de invocación" y atacan en el turno siguiente a su aparición.
- Ajustada la IA para operar correctamente dentro del nuevo flujo de fases.
### Estado Actual
El núcleo del juego ("Game Loop") es funcional y fiel a las reglas de mesa. Se puede explorar, revelar salas, combatir y gestionar los turnos con las restricciones correctas.
### Próximos Pasos
- Implementar sistema completo de combate (tiradas de dados visibles, daño variable, muerte de héroes).
- Refinar la interfaz de usuario para mostrar estadísticas en tiempo real.
---
## Sesión 5: Refinamiento de UX y Jugabilidad (3 Enero 2026)
### Objetivos Completados
1. **Validación Estricta de Puertas**:
- Implementado control riguroso para puertas multicelda.
- Si una puerta tiene 2 celdas, la tile conectada DEBE tener 2 salidas alineadas.
- Fix: `selectDoor` ahora recupera el objeto salida completo (con `tileId`) para poder validar correctamente el grupo de celdas.
2. **UX de Colocación**:
- Reemplazados `alert()` nativos con modales `showModal()` y `showConfirm()` estilizados.
- Implementado botón de **Descarte** para bloquear puertas cuando una tile no cabe o no interesa.
3. **Sistema de Movimiento Táctico**:
- **Planificación**: Click en jugador para seleccionar → Clicks en celdas contiguas para trazar ruta (1, 2, 3...).
- **Deshacer**: Click en el último paso para eliminarlo.
- **Ejecución**: Click derecho para iniciar el movimiento.
- **Animación**: Implementado efecto de "botecito" (salto sinusoidal) al mover entre casillas.
- **Visualización**: Marcadores amarillos con números sobre las casillas planificadas.
4. **Aleatoriedad Visual**:
- Implementado sistema de **Texturas Aleatorias** para losetas con múltiples variantes (Rooms).
- `DungeonGenerator` elige una textura al instanciar, `GameRenderer` la pinta.
- Corrección de definición duplicada de `room_objective` en `TileDefinitions.js` (eliminada versión incorrecta de 4x4).
5. **Mejoras de Cámara**:
- **Zoom Ajustado**: Rango modificado a 3-15 (default 6) para una vista más lejana y cómoda.
- **Sincronización**: El slider de zoom ahora se actualiza al usar la rueda del ratón.
- **Paneo**: Se modificó la lógica de paneo para mover tanto la cámara como el objetivo (`target`), evitando el efecto de órbita indeseado. *Estado final: Pendiente de validación por reporte de fallo en controles.*
### Estado Actual
El juego es completamente jugable en cuanto a exploración y movimiento. La generación de mazmorras es robusta y visualmente más variada gracias a las texturas aleatorias. La interfaz es consistente y amigable.
### Próximos Pasos
- Revisar controles de cámara (Paneo).
- Implementar sistema de turnos / fases de juego.
- Añadir enemigos y lógica de combate.
---
## Sesión 4: Sistema de Colocación Manual - Estado Actual (2 Enero 2026)
### Objetivo

57
Reglas/Fases.md Normal file
View File

@@ -0,0 +1,57 @@
En Warhammer Quest, el juego se desarrolla en turnos divididos en **cuatro fases principales** que deben seguirse en orden estricto. Aquí tienes qué ocurre en cada una de ellas:
### 1. Fase de Poder
En esta fase, el Hechicero determina cuánta energía mágica tendrá disponible para el turno lanzando **1D6**.
*
**Puntos de Poder:** El resultado del dado indica los puntos que puede gastar para lanzar hechizos.
* **Eventos Inesperados:** Si el Hechicero saca un **1**, ocurre un evento inesperado. Se debe robar una carta del mazo de Eventos y seguir sus instrucciones (que suelen implicar la aparición repentina de monstruos).
### 2. Fase de los Aventureros
Es el momento en que los héroes actúan. El orden de actuación comienza siempre por el **Líder** (quien lleva la Lámpara, normalmente el Bárbaro) y continúa según el valor de **Iniciativa** de los demás (de mayor a mayor).
*
**Movimiento:** Cada aventurero puede moverse tantas casillas como su atributo de Movimiento, a menos que esté "trabado" (adyacente a un monstruo).
*
**Combate:** Después de moverse, el aventurero puede atacar a los monstruos adyacentes en combate cuerpo a cuerpo o disparar si tiene un arma de proyectiles y no está trabado.
*
**Exploración preliminar:** Si un aventurero entra en una nueva sección de tablero, su turno termina inmediatamente.
### 3. Fase de los Monstruos
Si hay monstruos en el tablero, es su turno de devolver el golpe.
*
**Nuevas Estancias:** Si los aventureros acaban de entrar en una **Estancia de Subterráneo**, se roba una carta de Evento para ver qué enemigos o peligros hay dentro.
*
**Ataque de Monstruos:** Los monstruos que ya estaban en juego se mueven hacia los aventureros y atacan siguiendo la regla de **"Uno contra Uno"** (repartiéndose equitativamente entre los héroes). Los monstruos que acaban de ser colocados en esta fase no atacan hasta el siguiente turno.
### 4. Fase de Exploración
Esta fase ocurre solo si no hay monstruos en la misma sección que el Líder.
*
**Revelar la mazmorra:** El Líder, si está junto a una puerta inexplorada, puede declarar que explora.
* **Nuevas secciones:** Se roba una carta del mazo de Mazmorra y se coloca la sección de tablero correspondiente. Los aventureros no podrán entrar en esta nueva zona hasta la Fase de Aventureros del siguiente turno.
**Nota importante:** Existe la **"Regla del 1 y el 6"**: un 1 natural siempre es un fallo y un 6 natural siempre es un éxito, sin importar los modificadores.

View File

@@ -24,13 +24,28 @@
- [x] Tile Model/Texture Loading <!-- id: 23 -->
- [x] dynamic Tile Instancing based on Grid State <!-- id: 24 -->
## Phase 3: Game Mechanics (Loop)
- [ ] **Turn System**
- [ ] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
- [ ] Implement Turn State Machine <!-- id: 31 -->
- [ ] **Entity System**
- [ ] Define Hero/Monster Stats <!-- id: 32 -->
- [ ] Implement Movement Logic (Grid-based) <!-- id: 33 -->
## Phase 3: Game Mechanics (Loop) - [IN PROGRESS]
- [x] **Turn System**
- [x] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
- [x] Implement Turn State Machine (Phases now functional and dispatch events) <!-- id: 31 -->
- [x] Implement Power Phase (Rolls 1d6)
- [x] **Event System**
- [x] Implement Event Deck (Events.js)
- [x] Trigger Random Events on Power Roll of 1 or Room Reveal
- [x] Spawn Monsters from Event Cards (1d6 Orcs)
- [x] **Entity System**
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
- [x] Refine Combat System (Ranged weapons, Area Magic, Damage Feedback)
- [x] Implement Audio System (SFX, Footsteps, Ambience)
- [x] UI Improvements (Spanish Stats, Tooltips)
- [x] Implement Turn Initiative System (Strict Order, Leader First)
- [x] Implement Fog of War (Lamp Rule based on Board Sections)
- [x] Refine FOW (Entity Hiding, Turn Skipping)
- [x] UI Polish (End Turn placement, Target Rings, Clean Monster Phase)
## Phase 4: Campaign System
- [ ] **Campaign Manager**

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -0,0 +1 @@
goblin.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

BIN
public/assets/sfx/arrow.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

37
src/engine/data/Events.js Normal file
View File

@@ -0,0 +1,37 @@
export const EVENT_TYPES = {
MONSTER: 'monster',
EVENT: 'event' // Ambushes, traps, etc.
};
export const EVENT_DEFINITIONS = [
{
id: 'evt_orcs_d6',
type: EVENT_TYPES.MONSTER,
name: 'Emboscada de Orcos',
description: 'Un grupo de pieles verdes salta de las sombras.',
monsterKey: 'orc', // References MONSTER_DEFINITIONS
count: '1d6', // Special string to be parsed, or we can use a function
resolve: (gameEngine, context) => {
// Logic handled by engine based on params, or custom function
return Math.floor(Math.random() * 6) + 1;
}
}
];
export const createEventDeck = () => {
// As per user request: 10 copies of the same card for now
const deck = [];
for (let i = 0; i < 10; i++) {
deck.push({ ...EVENT_DEFINITIONS[0] });
}
return shuffleDeck(deck);
};
const shuffleDeck = (deck) => {
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
};

68
src/engine/data/Heroes.js Normal file
View File

@@ -0,0 +1,68 @@
export const HERO_DEFINITIONS = {
barbarian: {
id: 'barbarian',
name: 'Bárbaro',
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
stats: {
move: 4,
ws: 3,
to_hit_missile: 5, // 5+ to hit with ranged
str: 4,
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas)
wounds: 12, // 1D6 + 9 (Using fixed average for now)
attacks: 1,
init: 3,
pin_target: 6 // 6+ to escape pin
}
},
dwarf: {
id: 'dwarf',
name: 'Enano',
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
stats: {
move: 4,
ws: 4,
to_hit_missile: 5, // 5+ to hit with ranged
str: 3,
toughness: 5, // 4 Base + 1 Armor (Cota de Malla)
wounds: 11, // 1D6 + 8 (Using fixed average for now)
attacks: 1,
init: 2,
pin_target: 5 // 5+ to escape pin
}
},
elf: {
id: 'elf',
name: 'Elfa',
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
stats: {
move: 4,
ws: 4,
bs: 4, // Added for Bow
to_hit_missile: 4, // 4+ to hit with ranged
str: 3,
toughness: 3,
wounds: 10, // 1D6 + 7 (Using fixed average for now)
attacks: 1,
init: 6,
pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
}
},
wizard: {
id: 'wizard',
name: 'Hechicero',
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
stats: {
move: 4,
ws: 2,
to_hit_missile: 6, // 6+ to hit with ranged
str: 3,
toughness: 3,
wounds: 9, // 1D6 + 6 (Using fixed average for now)
attacks: 1,
init: 3,
power: 0, // Tracks current power points
pin_target: 4 // 4+ to escape pin
}
}
};

View File

@@ -0,0 +1,92 @@
export const MONSTER_DEFINITIONS = {
orc: {
id: 'orc',
name: 'Guerrero Orco',
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
stats: {
move: 4,
ws: 3,
str: 3,
toughness: 4,
wounds: 1, // Card: "Heridas: 1" (Wait, Orcs usually have 1, check image: YES "Heridas: 1")
attacks: 1,
gold: 55 // Card: "Valor 55x Unidad"
}
},
goblin_spearman: {
id: 'goblin_spearman',
name: 'Lancero Goblin',
portrait: '/assets/images/dungeon1/standees/enemies/goblin.png',
stats: {
move: 4,
ws: 2,
str: 3,
toughness: 3,
wounds: 3,
wounds: 1,
attacks: 1,
gold: 20,
specialRules: ['reach_attack'] // "Puede atacar a dos casillas"
}
},
giant_rat: {
id: 'giant_rat',
name: 'Rata Gigante',
portrait: '/assets/images/dungeon1/standees/enemies/rat.png',
stats: {
move: 6,
ws: 2,
str: 2,
toughness: 3,
wounds: 1,
attacks: 1,
gold: 20,
specialRules: ['death_frenzy', 'sudden_death'] // "Frenesí suicida", "Muerte Súbita"
}
},
giant_spider: {
id: 'giant_spider',
name: 'Araña Gigante',
portrait: '/assets/images/dungeon1/standees/enemies/spider.png',
stats: {
move: 6,
ws: 2,
str: 3, // Card says "Fuerza: Especial", but base STR needed? Web attack deals auto 1D3. If not trapped, check hit normally.
toughness: 2,
wounds: 1,
attacks: 1,
gold: 15,
specialRules: ['web_attack']
}
},
giant_bat: {
id: 'giant_bat',
name: 'Murciélago Gigante',
portrait: '/assets/images/dungeon1/standees/enemies/bat.png',
stats: {
move: 8,
ws: 2,
str: 2,
toughness: 2,
wounds: 1,
attacks: 1,
gold: 15,
specialRules: ['fly', 'ambush_attack'] // "Nunca se traban", "Atacan tan pronto son colocados"
}
},
minotaur: {
id: 'minotaur',
name: 'Minotauro',
portrait: '/assets/images/dungeon1/standees/enemies/minotaur.png',
stats: {
move: 6,
ws: 4,
str: 4,
toughness: 4,
wounds: 15,
attacks: 2,
gold: 440,
damageDice: 2 // "Tira 2 dados para herir"
}
}
};

38
src/engine/data/Spells.js Normal file
View 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."
}
];

View File

@@ -15,11 +15,9 @@ export class DungeonDeck {
// 1. Create a "Pool" of standard dungeon tiles
let pool = [];
const composition = [
{ id: 'room_dungeon', count: 6 },
{ id: 'corridor_straight', count: 7 },
{ id: 'corridor_steps', count: 1 },
{ id: 'corridor_corner', count: 1 }, // L-Shape
{ id: 'junction_t', count: 3 }
{ id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms
{ id: 'corridor_straight', count: 0 },
{ id: 'junction_t', count: 0 }
];
composition.forEach(item => {

View File

@@ -27,6 +27,7 @@ export class DungeonGenerator {
// Callbacks for UI
this.onStateChange = null;
this.onPlacementUpdate = null;
this.onDoorBlocked = null;
}
startDungeon(missionConfig) {
@@ -64,17 +65,17 @@ export class DungeonGenerator {
return false;
}
// Validate exit exists
const exitExists = this.availableExits.some(
// Find the full exit object from availableExits (so we have tileId, etc.)
const foundExit = this.availableExits.find(
e => e.x === exitPoint.x && e.y === exitPoint.y && e.direction === exitPoint.direction
);
if (!exitExists) {
if (!foundExit) {
console.warn("Invalid exit selected");
return false;
}
this.selectedExit = exitPoint;
this.selectedExit = foundExit;
// Draw next card
this.currentCard = this.deck.draw();
@@ -149,7 +150,68 @@ export class DungeonGenerator {
if (!this.currentCard || this.state !== PLACEMENT_STATE.PLACING_TILE) return false;
const variant = this.currentCard.variants[this.placementRotation];
return this.grid.canPlace(variant, this.placementX, this.placementY);
// 1. Basic Grid Collision
if (!this.grid.canPlace(variant, this.placementX, this.placementY)) {
return false;
}
// 2. Strict Door Alignment Check
if (this.selectedExit) {
// Identify the full "door" group (e.g., the pair of cells forming the exit)
// We look for other available exits on the same tile, facing the same way, and adjacent.
const sourceExits = this.availableExits.filter(e =>
e.tileId === this.selectedExit.tileId &&
e.direction === this.selectedExit.direction &&
(Math.abs(e.x - this.selectedExit.x) + Math.abs(e.y - this.selectedExit.y)) <= 1
);
// For every cell in the source door, the new tile MUST have a connecting exit
for (const source of sourceExits) {
// The coordinate where the new tile's exit should be
const targetPos = this.neighbor(source.x, source.y, source.direction);
const requiredDirection = this.opposite(source.direction);
// Does the new tile provide an exit here?
const hasMatch = variant.exits.some(localExit => {
const gx = this.placementX + localExit.x;
const gy = this.placementY + localExit.y;
return gx === targetPos.x &&
gy === targetPos.y &&
localExit.direction === requiredDirection;
});
if (!hasMatch) {
return false; // Misalignment: New tile doesn't connect to all parts of the door
}
}
}
return true;
}
cancelPlacement() {
if (this.state !== PLACEMENT_STATE.PLACING_TILE) return;
// 1. Mark door as blocked visually
if (this.onDoorBlocked && this.selectedExit) {
this.onDoorBlocked(this.selectedExit);
}
// 2. Remove the selected exit from available exits
if (this.selectedExit) {
this.availableExits = this.availableExits.filter(e =>
!(e.x === this.selectedExit.x && e.y === this.selectedExit.y && e.direction === this.selectedExit.direction)
);
}
// 3. Reset state
this.currentCard = null;
this.selectedExit = null;
this.state = PLACEMENT_STATE.WAITING_DOOR;
this.notifyPlacementUpdate();
this.notifyStateChange();
}
/**
@@ -199,11 +261,20 @@ export class DungeonGenerator {
placeCardFinal(card, x, y, rotation) {
const variant = card.variants[rotation];
// Randomize Texture if multiple are available
let selectedTexture = null;
if (card.textures && card.textures.length > 0) {
const idx = Math.floor(Math.random() * card.textures.length);
selectedTexture = card.textures[idx];
console.log(`[DungeonGenerator] Selected texture for ${card.id}:`, selectedTexture);
}
const instance = {
id: `tile_${this.placedTiles.length}`,
defId: card.id,
x, y, rotation,
name: card.name
name: card.name,
texture: selectedTexture
};
this.grid.placeTile(instance, variant, card);

View File

@@ -3,9 +3,24 @@ export class GridSystem {
constructor() {
// Map "x,y" -> "tileId"
this.occupiedCells = new Map();
// Map "x,y" -> { tileId: string, height: number (1-9) }
this.cellData = new Map();
// Map "tileId" -> Set of "x,y" strings (all cells belonging to this tile)
this.tileCells = new Map();
// Set of "x,y" strings that are door/exit cells (can cross room boundaries)
this.doorCells = new Set();
this.tiles = [];
}
isWall(x, y) {
const key = `${x},${y}`;
return !this.occupiedCells.has(key);
}
/**
* Checks if a specific VARIANT can be placed at anchorX, anchorY.
* Does NOT rotate anything. Assumes variant is already the correct shape.
@@ -48,13 +63,20 @@ export class GridSystem {
const rows = layout.length;
const anchorX = tileInstance.x;
const anchorY = tileInstance.y;
const tileId = tileInstance.id;
// Initialize tile cell set
if (!this.tileCells.has(tileId)) {
this.tileCells.set(tileId, new Set());
}
for (let row = 0; row < rows; row++) {
const rowData = layout[row];
const cols = rowData.length;
for (let col = 0; col < cols; col++) {
if (rowData[col] === 0) continue;
const heightValue = rowData[col];
if (heightValue === 0) continue;
const lx = col;
const ly = (rows - 1) - row;
@@ -63,9 +85,28 @@ export class GridSystem {
const gy = anchorY + ly;
const key = `${gx},${gy}`;
this.occupiedCells.set(key, tileInstance.id);
// Store basic occupation
this.occupiedCells.set(key, tileId);
// Store detailed cell data (height level)
this.cellData.set(key, {
tileId: tileId,
height: heightValue // 1-8 = levels, 9 = stairs
});
// Add to tile's cell set
this.tileCells.get(tileId).add(key);
}
}
// Mark exit/door cells
if (variant.exits) {
variant.exits.forEach(exit => {
const exitKey = `${anchorX + exit.x},${anchorY + exit.y}`;
this.doorCells.add(exitKey);
});
}
this.tiles.push(tileInstance);
}
@@ -102,4 +143,52 @@ export class GridSystem {
isOccupied(x, y) {
return this.occupiedCells.has(`${x},${y}`);
}
/**
* Get cell data (tileId, height) for a coordinate
*/
getCellData(x, y) {
return this.cellData.get(`${x},${y}`) || null;
}
/**
* Check if movement from (x1,y1) to (x2,y2) is valid
* considering room boundaries, height levels, and stairs
*/
canMoveBetween(x1, y1, x2, y2) {
const key1 = `${x1},${y1}`;
const key2 = `${x2},${y2}`;
const data1 = this.cellData.get(key1);
const data2 = this.cellData.get(key2);
// Both cells must exist
if (!data1 || !data2) return false;
const sameTile = data1.tileId === data2.tileId;
const isDoor1 = this.doorCells.has(key1);
const isDoor2 = this.doorCells.has(key2);
// If different tiles, at least one must be a door
if (!sameTile && !isDoor1 && !isDoor2) {
return false;
}
// Height validation
const h1 = data1.height;
const h2 = data2.height;
// Stairs (9) can connect to any level
if (h1 === 9 || h2 === 9) {
return true;
}
// Same height level is always OK
if (h1 === h2) {
return true;
}
// Different heights require stairs - not allowed directly
return false;
}
}

View File

@@ -9,14 +9,19 @@ export const TILES = {
id: 'corridor_straight',
name: 'Corridor',
type: TILE_TYPES.CORRIDOR,
textures: ['/assets/images/dungeon1/tiles/corridor1.png'],
textures: ['/assets/images/dungeon1/tiles/corridor1.png',
'/assets/images/dungeon1/tiles/corridor2.png',
'/assets/images/dungeon1/tiles/corridor3.png',
],
variants: {
[DIRECTIONS.NORTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
@@ -24,7 +29,9 @@ export const TILES = {
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.EAST]: {
@@ -32,7 +39,9 @@ export const TILES = {
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.WEST]: {
@@ -40,7 +49,9 @@ export const TILES = {
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
]
}
}
@@ -61,6 +72,8 @@ export const TILES = {
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
//{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
//{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
@@ -69,6 +82,8 @@ export const TILES = {
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
//{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
//{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.EAST]: {
@@ -77,6 +92,8 @@ export const TILES = {
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
//{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
//{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.WEST]: {
@@ -85,6 +102,8 @@ export const TILES = {
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
//{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
//{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
]
}
}
@@ -323,53 +342,7 @@ export const TILES = {
}
},
// -------------------------------------------------------------------------
// ROOM OBJECTIVE
// -------------------------------------------------------------------------
'room_objective': {
id: 'room_objective',
name: 'Dungeon Room',
type: TILE_TYPES.ROOM,
textures: [
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
],
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST },
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST },
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
}
}
},
// -------------------------------------------------------------------------
// ROOM OBJECTIVE

View File

@@ -0,0 +1,216 @@
export const TO_HIT_CHART = [
// Defender WS 1 2 3 4 5 6 7 8 9 10
/* Attacker 1 */[4, 4, 5, 6, 6, 6, 6, 6, 6, 6],
/* Attacker 2 */[3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
/* Attacker 3 */[2, 3, 4, 4, 4, 4, 5, 5, 5, 6],
/* Attacker 4 */[2, 3, 3, 4, 4, 4, 4, 4, 5, 5],
/* Attacker 5 */[2, 2, 3, 3, 4, 4, 4, 4, 4, 4],
/* Attacker 6 */[2, 2, 3, 3, 3, 4, 4, 4, 4, 4],
/* Attacker 7 */[2, 2, 2, 3, 3, 3, 4, 4, 4, 4],
/* Attacker 8 */[2, 2, 2, 3, 3, 3, 3, 4, 4, 4],
/* Attacker 9 */[2, 2, 2, 2, 3, 3, 3, 3, 4, 4],
/* Attacker 10*/[2, 2, 2, 2, 3, 3, 3, 3, 3, 4]
];
export class CombatMechanics {
/**
* Resolves a melee attack sequence between two entities.
* @param {Object} attacker
* @param {Object} defender
* @returns {Object} Result log
*/
static resolveMeleeAttack(attacker, defender, gameEngine = null) {
const log = {
attackerId: attacker.id,
defenderId: defender.id,
hitRoll: 0,
targetToHit: 0,
hitSuccess: false,
damageRoll: 0,
damageTotal: 0,
woundsCaused: 0,
defenderDied: false,
message: ''
};
// 1. Determine Stats
// Use stats object if available, otherwise direct property (fallback)
const attStats = attacker.stats || attacker;
const defStats = defender.stats || defender;
const attWS = Math.min(Math.max(attStats.ws || 1, 1), 10);
const defWS = Math.min(Math.max(defStats.ws || 1, 1), 10);
// 2. Roll To Hit
log.targetToHit = this.getToHitTarget(attWS, defWS);
log.hitRoll = this.rollD6();
// Debug
// console.log(`Combat: ${attacker.name} (WS${attWS}) vs ${defender.name} (WS${defWS}) -> Need ${log.targetToHit}+. Rolled ${log.hitRoll}`);
if (log.hitRoll < log.targetToHit) {
log.hitSuccess = false;
log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
return log;
}
log.hitSuccess = true;
// 3. Roll To Damage
const attStr = attStats.str || 3;
const defTough = defStats.toughness || 3;
const damageDice = attStats.damageDice || 1; // Default 1D6
let damageSum = 0;
let rolls = [];
for (let i = 0; i < damageDice; i++) {
const r = this.rollD6();
rolls.push(r);
damageSum += r;
}
log.damageRoll = damageSum; // Just sum for simple log, or we could array it
log.damageTotal = damageSum + attStr;
// 4. Calculate Wounds
// Wounds = (Dice + Str) - Toughness
let wounds = log.damageTotal - defTough;
if (wounds < 0) wounds = 0;
log.woundsCaused = wounds;
// 5. Build Message
if (wounds > 0) {
log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
} else {
log.message = `${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
}
// 6. Apply Damage to Defender State
this.applyDamage(defender, wounds, gameEngine);
if (defender.isDead) {
log.defenderDied = true;
log.message += ` ¡${defender.name} ha muerto!`;
} else if (defender.isUnconscious) {
log.message += ` ¡${defender.name} cae inconsciente!`;
}
return log;
}
static resolveRangedAttack(attacker, defender, gameEngine = null) {
const log = {
attackerId: attacker.id,
defenderId: defender.id,
hitSuccess: false,
damageTotal: 0,
woundsCaused: 0,
defenderDied: false,
message: ''
};
// 1. Roll To Hit (BS vs WS)
// Use attacker BS or default to WS if missing (fallback).
const attackerBS = attacker.stats.bs || attacker.stats.ws;
const defenderWS = defender.stats.ws;
const toHitTarget = this.getToHitTarget(attackerBS, defenderWS);
const hitRoll = this.rollD6();
log.hitRoll = hitRoll;
log.toHitTarget = toHitTarget;
if (hitRoll === 1) {
log.hitSuccess = false;
log.message = `${attacker.name} dispara y falla (1 es fallo automático)`;
return log;
}
if (hitRoll < toHitTarget) {
log.hitSuccess = false;
log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
return log;
}
log.hitSuccess = true;
// 2. Roll Damage
// Elf Bow Strength = 3
const weaponStrength = 3;
const damageRoll = this.rollD6();
const damageTotal = weaponStrength + damageRoll;
log.damageRoll = damageRoll;
log.damageTotal = damageTotal;
// 3. Compare vs Toughness
const defTough = defender.stats.toughness || 1;
const wounds = Math.max(0, damageTotal - defTough);
log.woundsCaused = wounds;
// 4. Build Message
if (wounds > 0) {
log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
} else {
log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
}
// 5. Apply Damage
this.applyDamage(defender, wounds, gameEngine);
if (defender.isDead) {
log.defenderDied = true;
log.message += ` ¡${defender.name} ha muerto!`;
}
return log;
}
static getToHitTarget(attackerWS, defenderWS) {
// Adjust for 0-index array
const row = attackerWS - 1;
const col = defenderWS - 1;
if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
return TO_HIT_CHART[row][col];
}
return 6; // Fallback
}
static applyDamage(entity, amount, gameEngine = null) {
if (!entity.stats) entity.stats = {};
// If entity doesn't have current wounds tracked, init it from max
if (entity.currentWounds === undefined) {
// For Heros it is 'wounds', for Monsters typical just 'wounds' in def
// We assume entity has been initialized properly before,
// but if not, we grab max from definition
entity.currentWounds = entity.stats.wounds || 1;
}
entity.currentWounds -= amount;
// Check Status
if (entity.type === 'hero') {
if (entity.currentWounds <= 0) {
entity.currentWounds = 0;
entity.isConscious = false;
// entity.isDead is not immediate for heroes usually, but let's handle via isConscious
}
} else {
// Monsters die at 0
if (entity.currentWounds <= 0) {
entity.currentWounds = 0;
entity.isDead = true;
// Trigger death callback if available
if (gameEngine && gameEngine.onEntityDeath) {
gameEngine.onEntityDeath(entity.id);
}
}
}
}
static rollD6() {
return Math.floor(Math.random() * 6) + 1;
}
}

View 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 };
}
}

File diff suppressed because it is too large Load Diff

View 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 };
}
}

View File

@@ -0,0 +1,290 @@
import { CombatMechanics } from './CombatMechanics.js';
export class MonsterAI {
constructor(gameEngine) {
this.game = gameEngine;
}
async executeTurn() {
console.log("[MonsterAI] --- TURN START ---");
if (!this.game.monsters || this.game.monsters.length === 0) {
console.log("[MonsterAI] No monsters active.");
return;
}
// Sequential execution with delay
for (const monster of this.game.monsters) {
// Check if monster still exists
if (monster.isDead) continue;
// Check for Summoning Sickness / Ambush delay
if (monster.skipTurn) {
console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
monster.skipTurn = false; // Ready for next turn
// Add a small visual delay even if skipping, to show focus?
// No, better to just skip significantly to keep flow fast.
continue;
}
await this.processMonster(monster);
}
}
processMonster(monster) {
return new Promise(resolve => {
// NO green ring here - only during attack
// Calculate delay based on potential move distance to ensure animation finishes
// SLOWER: 600ms per tile + Extra buffer for potential attack sequence
const moveTime = (monster.stats.move * 600) + 3000; // 3s buffer for attack sequence
setTimeout(() => {
this.actMonster(monster);
setTimeout(() => {
resolve();
}, moveTime);
}, 100);
});
}
actMonster(monster) {
// 1. Check if already adjacent (Engaged) -> ATTACK
const adjacentHero = this.getAdjacentHero(monster);
if (adjacentHero) {
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
this.performAttack(monster, adjacentHero);
return;
}
// 2. Find Closest Hero to Move Towards
const targetHero = this.getClosestHero(monster);
if (!targetHero) {
console.log(`[MonsterAI] ${monster.id} has no targets.`);
return;
}
// 3. Calculate Path (BFS with fallback)
const path = this.findPath(monster, targetHero, 30);
if (!path || path.length === 0) {
console.log(`[MonsterAI] ${monster.id} NO PATH (blocked) to ${targetHero.name}`);
return;
}
// 4. Execute Move
const moveDist = monster.stats.move;
const actualPath = path.slice(0, moveDist);
console.log(`[MonsterAI] ${monster.id} moving towards ${targetHero.name}`, actualPath);
// 5. Update Renderer ONCE with full path
if (this.game.onEntityMove) {
this.game.onEntityMove(monster, actualPath);
}
// 6. Final Logic Update (Instant coordinates)
const finalDest = actualPath[actualPath.length - 1];
monster.x = finalDest.x;
monster.y = finalDest.y;
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
// 7. Check if NOW adjacent after move -> ATTACK
// Wait for movement animation to complete before checking
const movementDuration = actualPath.length * 600;
setTimeout(() => {
const postMoveHero = this.getAdjacentHero(monster);
if (postMoveHero) {
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
this.performAttack(monster, postMoveHero);
}
}, movementDuration);
}
getClosestHero(monster) {
let nearest = null;
let minDist = Infinity;
this.game.heroes.forEach(hero => {
if (!hero.isConscious && hero.isDead) return;
const dist = Math.abs(monster.x - hero.x) + Math.abs(monster.y - hero.y);
if (dist < minDist) {
minDist = dist;
nearest = hero;
}
});
return nearest;
}
isEntityAdjacentToHero(entity) {
return this.game.heroes.some(hero => {
const dx = Math.abs(entity.x - hero.x);
const dy = Math.abs(entity.y - hero.y);
return (dx + dy) === 1;
});
}
isOccupied(x, y, fromX, fromY) {
// Check if target cell exists in grid
if (!this.game.dungeon.grid.isOccupied(x, y)) {
return true; // Wall/Void
}
// Check Heroes
if (this.game.heroes.some(h => h.x === x && h.y === y)) {
return true;
}
// Check Monsters
if (this.game.monsters.some(m => m.x === x && m.y === y)) {
return true;
}
// NEW: Check if movement is valid (room boundaries, height levels, stairs)
if (fromX !== undefined && fromY !== undefined) {
if (!this.game.dungeon.grid.canMoveBetween(fromX, fromY, x, y)) {
return true; // Movement blocked by room boundary or height restriction
}
}
return false;
}
findPath(start, goal, limit = 50) {
const queue = [{ x: start.x, y: start.y, path: [] }];
const visited = new Set([`${start.x},${start.y}`]);
let bestPath = null;
let minDistToGoal = Infinity;
// Init min dist (Manhattan)
minDistToGoal = Math.abs(start.x - goal.x) + Math.abs(start.y - goal.y);
while (queue.length > 0) {
const current = queue.shift();
const dist = Math.abs(current.x - goal.x) + Math.abs(current.y - goal.y);
// Success: Adjacent to goal
if (dist === 1) {
return current.path;
}
// Update Best Fallback: closest we got to the target so far
if (dist < minDistToGoal) {
minDistToGoal = dist;
bestPath = current.path;
}
if (current.path.length >= limit) continue;
const neighbors = [
{ x: current.x + 1, y: current.y },
{ x: current.x - 1, y: current.y },
{ x: current.x, y: current.y + 1 },
{ x: current.x, y: current.y - 1 }
];
for (const n of neighbors) {
// Check if movement from current to neighbor is valid
// This now includes room boundary, height, and stair checks
if (this.isOccupied(n.x, n.y, current.x, current.y)) continue;
const key = `${n.x},${n.y}`;
if (!visited.has(key)) {
visited.add(key);
queue.push({
x: n.x,
y: n.y,
path: [...current.path, { x: n.x, y: n.y }]
});
}
}
}
// If we exhausted reachable tiles or limit, return the best path found (e.g. getting closer)
// Only return if we actually have a path to move (length > 0)
return bestPath;
}
performAttack(monster, hero) {
// SEQUENCE:
// 0. Show TARGET (Blue Ring) on Hero
if (this.game.onRangedTarget) {
// Re-using onRangedTarget? Or directly calling renderer?
// Better to use a specific callback or direct call if available, or just add a new callback.
// But let's check if we can access renderer directly or use a new callback.
// The user prompt specifically asked for this feature.
// I'll assume we can use game.onEntityTarget if defined, or direct renderer call if needed,
// but standard pattern here is callbacks.
// Let's add onEntityTarget to GameEngine callbacks if not present, but for now I will try to use global RENDERER if possible
// OR simply define a new callback `this.game.onEntityTarget(hero.id, true)`.
}
// Direct renderer call is safest given current context if we don't want to modify GameEngine interface heavily right now.
if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, true);
}
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
// Step 1: Green ring on attacker
if (this.game.onEntityActive) {
this.game.onEntityActive(monster.id, true);
}
// Step 2: Attack animation delay (500ms)
setTimeout(() => {
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
setTimeout(() => {
if (this.game.onEntityActive) {
this.game.onEntityActive(monster.id, false);
}
// Remove Target Ring
if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, false);
}
// Step 5: Show combat result after both rings are gone
setTimeout(() => {
if (this.game.onCombatResult) {
this.game.onCombatResult(result);
}
}, 200); // Small delay after rings disappear
}, 1200); // Wait for red ring to disappear
}, 800); // Attack animation delay + focus time
}
getAdjacentHero(entity) {
return this.game.heroes.find(hero => {
// Check conscious or allow beating unconscious? standard rules say monsters attack unconscious heroes until death.
// But let's check basic mechanics first.
// "Cuando al Aventurero no le quedan más Heridas cae al suelo inconsciente... El Aventurero no está necesariamente muerto"
// "Continúa anotando el número de Heridas hasta que no le quedan más... nunca puede bajar de 0."
// Implicitly, they can still be attacked.
if (hero.isDead) return false;
const dx = Math.abs(entity.x - hero.x);
const dy = Math.abs(entity.y - hero.y);
// Must be orthogonally adjacent (Manhattan dist 1)
if ((dx + dy) !== 1) return false;
// NEW: Check if movement between monster and hero is valid
// This prevents attacking through walls/room boundaries
if (!this.game.dungeon.grid.canMoveBetween(entity.x, entity.y, hero.x, hero.y)) {
return false; // Wall or room boundary blocks attack
}
return true;
});
}
}

View File

@@ -5,30 +5,40 @@ export class TurnManager {
this.currentTurn = 0;
this.currentPhase = GAME_PHASES.SETUP;
this.listeners = {}; // Simple event system
// Power Phase State
this.currentPowerRoll = 0;
this.eventsTriggered = [];
}
get power() {
return this.currentPowerRoll;
}
startGame() {
this.currentTurn = 1;
this.setPhase(GAME_PHASES.HERO); // Jump straight to Hero phase for now
console.log(`--- TURN ${this.currentTurn} START ---`);
this.startPowerPhase();
}
nextPhase() {
// Simple sequential flow for now
// Simple sequential flow
switch (this.currentPhase) {
case GAME_PHASES.POWER:
this.setPhase(GAME_PHASES.HERO);
break;
case GAME_PHASES.HERO:
// Usually goes to Exploration if at edge, or Monster if not.
// For this dev stage, let's allow manual triggering of Exploration
// via UI, so we stay in HERO until confirmed done.
// Move to Monster Phase
this.setPhase(GAME_PHASES.MONSTER);
break;
case GAME_PHASES.MONSTER:
// Move to Exploration Phase
this.setPhase(GAME_PHASES.EXPLORATION);
break;
case GAME_PHASES.EXPLORATION:
// End Turn and restart
this.endTurn();
break;
// Exploration is usually triggered as an interrupt, not strictly sequential
}
}
@@ -40,6 +50,37 @@ export class TurnManager {
}
}
startPowerPhase() {
this.setPhase(GAME_PHASES.POWER);
this.rollPowerDice();
}
rollPowerDice() {
const roll = Math.floor(Math.random() * 6) + 1;
this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`);
let message = "The dungeon is quiet...";
let eventTriggered = false;
if (roll === 1) {
message = "UNEXPECTED EVENT! (Roll of 1)";
eventTriggered = true;
this.triggerRandomEvent();
}
this.emit('POWER_RESULT', { roll, message, eventTriggered });
// Auto-advance to Hero phase after short delay (game feel)
setTimeout(() => {
this.nextPhase();
}, 2000);
}
triggerRandomEvent() {
console.warn("TODO: TRIGGER EVENT CARD DRAW");
}
triggerExploration() {
this.setPhase(GAME_PHASES.EXPLORATION);
// Logic to return to HERO phase would handle elsewhere
@@ -47,8 +88,9 @@ export class TurnManager {
endTurn() {
console.log(`--- TURN ${this.currentTurn} END ---`);
this.emit('turn_ended', this.currentTurn);
this.currentTurn++;
this.setPhase(GAME_PHASES.POWER);
this.startPowerPhase();
}
// -- Simple Observer Pattern --

View File

@@ -2,6 +2,7 @@ import { GameEngine } from './engine/game/GameEngine.js';
import { GameRenderer } from './view/GameRenderer.js';
import { CameraManager } from './view/CameraManager.js';
import { UIManager } from './view/UIManager.js';
import { SoundManager } from './view/SoundManager.js';
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
@@ -19,10 +20,15 @@ const renderer = new GameRenderer('app');
const cameraManager = new CameraManager(renderer);
const game = new GameEngine();
const ui = new UIManager(cameraManager, game);
const soundManager = new SoundManager();
// Start Music (Autoplay handling included in manager)
soundManager.playMusic('exploration');
// Global Access
window.GAME = game;
window.RENDERER = renderer;
window.SOUND_MANAGER = soundManager;
// 3. Connect Dungeon Generator to Renderer
const generator = game.dungeon;
@@ -36,6 +42,21 @@ generator.grid.placeTile = (instance, variant, card) => {
setTimeout(() => {
renderer.renderExits(generator.availableExits);
// Don't show modal if we are not in Exploration phase (e.g. during Setup)
if (game.turnManager.currentPhase !== 'exploration') {
return;
}
// NEW RULE: Exploration ends turn immediately. No monsters yet.
// Monsters appear when a hero ENTERS the new room in the next turn.
ui.showModal('Exploración Completada',
'Has colocado una nueva sección de mazmorra.<br>El turno termina aquí.',
() => {
game.turnManager.endTurn();
}
);
}, 50);
};
@@ -44,10 +65,59 @@ game.onEntityUpdate = (entity) => {
renderer.addEntity(entity);
renderer.updateEntityPosition(entity);
// Center camera on player spawn
if (entity.id === 'p1' && !entity._centered) {
// Center camera on FIRST hero spawn
if (game.heroes && game.heroes[0] && entity.id === game.heroes[0].id && !window._cameraCentered) {
cameraManager.centerOn(entity.x, entity.y);
entity._centered = true;
window._cameraCentered = true;
}
};
game.turnManager.on('phase_changed', (phase) => {
if (phase === 'monster') {
setTimeout(async () => {
await game.playMonsterTurn();
// Logic: Skip Exploration if monsters are alive
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
if (hasActiveMonsters) {
ui.showModal('¡Combate en curso!',
'Aún quedan monstruos vivos. Se salta la Fase de Exploración.<br>Preparaos para la <b>Fase de Poder</b> del siguiente turno.',
() => {
// Combat Loop: Power -> Hero -> Monster -> (Skip Exp) -> Power...
game.turnManager.endTurn();
}
);
} else {
ui.showModal('Zona Despejada',
'Fase de Monstruos Finalizada.<br>Pulsa para continuar a la Fase de Exploración.',
() => {
game.turnManager.nextPhase(); // Go to Exploration
}
);
}
}, 500); // Slight delay for visual impact
}
});
game.onCombatResult = (log) => {
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);
}
};
@@ -55,10 +125,44 @@ game.onEntityMove = (entity, path) => {
renderer.moveEntityAlongPath(entity, path);
};
game.onEntitySelect = (entityId, isSelected) => {
renderer.toggleEntitySelection(entityId, isSelected);
game.onEntityActive = (entityId, isActive) => {
renderer.setEntityActive(entityId, isActive);
};
game.onEntityHit = (entityId) => {
renderer.triggerDamageEffect(entityId);
};
game.onEntityDeath = (entityId) => {
renderer.triggerDeathAnimation(entityId);
};
game.onRangedTarget = (targetMonster, losResult) => {
// 1. Draw Visuals
renderer.showRangedTargeting(game.selectedEntity, targetMonster, losResult);
// 2. UI
if (targetMonster && losResult && losResult.clear) {
ui.showRangedAttackUI(targetMonster);
} else {
ui.hideMonsterCard();
if (targetMonster && losResult && !losResult.clear && losResult.blocker) {
let msg = 'Línea de visión bloqueada.';
if (losResult.blocker.type === 'hero') msg = `Bloqueado por aliado: ${losResult.blocker.entity.name}`;
if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`;
if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`;
ui.showTemporaryMessage('Objetivo Bloqueado', msg, 1500);
}
}
};
game.onShowMessage = (title, message, duration) => {
ui.showTemporaryMessage(title, message, duration);
};
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
renderer.onHeroFinishedMove = (x, y) => {
cameraManager.centerOn(x, y);
};
@@ -83,39 +187,92 @@ generator.onPlacementUpdate = (preview) => {
}
};
generator.onDoorBlocked = (exitData) => {
renderer.blockDoor(exitData);
};
game.onPathChange = (path) => {
renderer.updatePathVisualization(path);
};
// 6. Handle Clicks
const handleClick = (x, y, doorMesh) => {
const currentPhase = game.turnManager.currentPhase;
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
// PRIORITY 1: Tile Placement Mode - ignore all clicks
if (generator.state === 'PLACING_TILE') {
return;
}
// PRIORITY 2: Door Click (must be adjacent to player)
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
const doorExit = doorMesh.userData.cells[0];
if (game.isPlayerAdjacentToDoor(doorExit)) {
// Open door visually
renderer.openDoor(doorMesh);
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
} else {
console.error('[Main] Door missing exitData');
}
} else {
if (doorMesh && doorMesh.userData.isDoor) {
if (doorMesh.userData.isBlocked) {
ui.showModal('¡Derrumbe!', 'Esta puerta está bloqueada por un derrumbe. No se puede pasar.');
return;
}
if (!doorMesh.userData.isOpen) {
// CHECK PHASE: Exploration Only
if (currentPhase !== 'exploration') {
ui.showModal('Fase Incorrecta', 'Solo puedes explorar (abrir puertas) durante la <b>Fase de Exploración</b>.');
return;
}
// CHECK MONSTERS: Must be clear
if (hasActiveMonsters) {
ui.showModal('¡Peligro!', 'No puedes explorar mientras hay <b>Monstruos</b> cerca. ¡Acaba con ellos primero!');
return;
}
// 1. Check Selection and Leadership (STRICT)
const selectedHero = game.selectedEntity;
if (!selectedHero) {
ui.showModal('Ningún Héroe seleccionado', 'Selecciona al <b>Líder (Portador de la Lámpara)</b> para abrir la puerta.');
return;
}
if (!selectedHero.hasLantern) {
ui.showModal('Acción no permitida', `<b>${selectedHero.name}</b> no lleva la Lámpara. Solo el <b>Líder</b> puede explorar.`);
return;
}
// 2. Check Adjacency
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
// Open door visually
renderer.openDoor(doorMesh);
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
} else {
console.error('[Main] Door missing exitData');
}
} else {
ui.showModal('Demasiado lejos', 'El Líder debe estar <b>adyacente</b> a la puerta para abrirla.');
}
return;
}
return;
}
// PRIORITY 3: Normal cell click (player selection/movement)
if (x !== null && y !== null) {
// Restrict Hero Selection/Movement to Hero Phase (and verify logic in GameEngine handle selection)
// Actually, we might want to select heroes in other phases to see stats, but MOVE only in Hero Phase.
// GameEngine.planStep handles planning.
// We let GameEngine handle selection. But for movement planning...
// Let's modify onCellClick inside GameEngine or just block here?
// Blocking execution is safer.
// Wait, onCellClick handles Selection AND Planning.
// We'll let it select. But we hook executeMovePath separately.
game.onCellClick(x, y);
}
};
@@ -123,13 +280,60 @@ const handleClick = (x, y, doorMesh) => {
renderer.setupInteraction(
() => cameraManager.getCamera(),
handleClick,
() => { } // No right-click
() => {
// Right Click Handler
if (game.targetingMode === 'spell' || game.targetingMode === 'ranged') {
game.cancelTargeting();
if (window.RENDERER) window.RENDERER.hideAreaPreview();
ui.showTemporaryMessage('Cancelado', 'Lanzamiento de hechizo cancelado.', 1000);
return;
}
game.executeMovePath();
},
(x, y) => {
if (game.onCellHover) game.onCellHover(x, y);
}
);
// Debug: Spawn Monster
window.addEventListener('keydown', (e) => {
if (e.key === 'm' || e.key === 'M') {
const x = game.player.x + 2;
const y = game.player.y;
if (game.dungeon.grid.isOccupied(x, y)) {
console.log("Spawning Orc...");
game.spawnMonster('orc', x, y);
} else {
console.log("Cannot spawn here");
}
}
});
game.onEventTriggered = (eventResult) => {
if (eventResult) {
if (eventResult.type === 'MONSTER_SPAWN') {
const count = eventResult.count || 0;
ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen <b>${count} Enemigos</b>!<br>Tu movimiento se detiene.`);
} else if (eventResult.message) {
ui.showModal('Zona Explorada', `${eventResult.message}<br>Tu movimiento se detiene.`);
}
}
};
// 7. Start
game.startMission(mission);
// Mark initial tile as visited to prevent immediate trigger
if (game.heroes && game.heroes.length > 0) {
const h = game.heroes[0];
const initialTileId = game.dungeon.grid.occupiedCells.get(`${h.x},${h.y}`);
if (initialTileId) {
game.visitedRoomIds.add(initialTileId);
console.log(`[Main] Initial tile ${initialTileId} marked as visited.`);
}
}
// 8. Render Loop
const animate = (time) => {
requestAnimationFrame(animate);

View File

@@ -7,9 +7,11 @@ export class CameraManager {
// Configuration
// Configuration
this.zoomLevel = 2.5; // Orthographic zoom factor (Lower = Closer)
this.zoomLevel = 6.0; // Started further back as requested
this.aspect = window.innerWidth / window.innerHeight;
this.onZoomChange = null;
// Isometric Setup: Orthographic Camera
this.camera = new THREE.OrthographicCamera(
-this.zoomLevel * this.aspect,
@@ -52,9 +54,14 @@ export class CameraManager {
}
centerOn(x, y) {
// Grid (x, y) -> World (x, 0, -y)
// Calculate current offset relative to OLD target
const currentOffset = this.camera.position.clone().sub(this.target);
// Update target: Grid (x, y) -> World (x, 0, -y)
this.target.set(x, 0, -y);
this.camera.position.copy(this.target).add(this.isoOffset);
// Restore position with new target + same relative offset
this.camera.position.copy(this.target).add(currentOffset);
this.camera.lookAt(this.target);
}
@@ -64,9 +71,10 @@ export class CameraManager {
e.preventDefault();
// Adjust Zoom Level property
if (e.deltaY < 0) this.zoomLevel = Math.max(3, this.zoomLevel - 1);
else this.zoomLevel = Math.min(30, this.zoomLevel + 1);
else this.zoomLevel = Math.min(15, this.zoomLevel + 1);
this.updateProjection();
if (this.onZoomChange) this.onZoomChange(this.zoomLevel);
}, { passive: false });
// Pan Listeners (Middle Click)
@@ -109,28 +117,30 @@ export class CameraManager {
}
pan(dx, dy) {
// Move Target and Camera together
// We pan on the logical "Ground Plane" relative to screen movement
// Move Speed Factor
const moveSpeed = this.panSpeed * 0.05 * (this.zoomLevel / 10);
// Transform screen delta to world delta
// In Iso view, Right on screen = (1, 0, 1) in world?
// Or using camera right/up vectors
// Direction: Dragging the "World"
// Mouse Left (dx < 0) -> Camera moves Right (+X)
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
const moveX = dx * moveSpeed;
const moveY = dy * moveSpeed;
const right = new THREE.Vector3(1, 0, 1).normalize(); // Approx logic for standard Iso
const forward = new THREE.Vector3(-1, 0, 1).normalize();
// Apply to Camera (Local Space)
this.camera.translateX(moveX);
this.camera.translateY(moveY);
// Let's use camera vectors for generic support
// Project camera right/up onto XZ plane
// Or just direct translation:
// Calculate World Movement to update Target
const vRight = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion);
const vUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.camera.quaternion);
this.camera.translateX(dx * moveSpeed);
this.camera.translateY(dy * moveSpeed);
const worldTranslation = new THREE.Vector3()
.addScaledVector(vRight, moveX)
.addScaledVector(vUp, moveY);
// This moves camera. We need to update target reference too if we want to snap back correctly
// But for now, simple pan is "offsetting everything".
// centerOn resets this.
// Apply same movement to Target so relative offset is preserved
// This ensures lookAt() doesn't pivot the camera around the old center
this.target.add(worldTranslation);
}
update(deltaTime) {
@@ -151,12 +161,18 @@ export class CameraManager {
if (this.animationProgress >= 1) {
this.isAnimating = false;
this.camera.position.copy(this.animationTargetPos);
if (this.onAnimationComplete) {
this.onAnimationComplete();
this.onAnimationComplete = null; // Consume callback
}
}
}
}
// --- Fixed Orbit Logic ---
setIsoView(direction) {
this.lastIsoDirection = direction || DIRECTIONS.NORTH;
// Rotate camera around target while maintaining isometric angle
// Isometric view: 45 degree angle from horizontal
const distance = 28; // Distance from target
@@ -197,4 +213,31 @@ export class CameraManager {
this.currentViewAngle = horizontalAngle;
}
toggleViewMode() {
if (this.viewMode === '2D') {
this.viewMode = '3D';
this.setIsoView(this.lastIsoDirection);
return true; // Is 3D
} else {
this.viewMode = '2D';
this.setZenithalView();
return false; // Is 2D
}
}
setZenithalView() {
// Top-down view (Zenithal)
const height = 40;
// Slight Z offset to Ensure North is Up (avoiding gimbal lock with Up=(0,1,0))
const x = this.target.x;
const z = this.target.z + 0.01;
const y = height;
this.animationStartPos.copy(this.camera.position);
this.animationTargetPos.set(x, y, z);
this.animationProgress = 0;
this.animationStartTime = performance.now();
this.isAnimating = true;
}
}

View File

@@ -1,756 +1,269 @@
import * as THREE from 'three';
import { SceneManager } from './render/SceneManager.js';
import { DungeonRenderer } from './render/DungeonRenderer.js';
import { EntityRenderer } from './render/EntityRenderer.js';
import { InteractionRenderer } from './render/InteractionRenderer.js';
import { EffectsRenderer } from './render/EffectsRenderer.js';
export class GameRenderer {
constructor(containerId) {
this.container = document.getElementById(containerId) || document.body;
// 1. Scene & Camera Setup
this.sceneManager = new SceneManager(containerId);
// 1. Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1a1a1a);
// 2. Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.container.appendChild(this.renderer.domElement);
// 3. Default Lights
this.setupLights();
// Debug Properties
this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z
// Grid Helper: Size 100, Divisions 100 (1 unit per cell)
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222);
this.scene.add(gridHelper);
// 4. Resize Handler
window.addEventListener('resize', this.onWindowResize.bind(this));
// 5. Textures
// 2. Texture Management (Shared)
this.textureLoader = new THREE.TextureLoader();
this.textureCache = new Map();
// 6. Interaction
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.interactionPlane = new THREE.Mesh(
new THREE.PlaneGeometry(1000, 1000),
new THREE.MeshBasicMaterial({ visible: false })
this._pendingTextureRequests = new Map();
// Bind getTexture to ensure 'this' context when passed as callback
const getTexture = this.getTexture.bind(this);
// 3. specialized Renderers
this.dungeonRenderer = new DungeonRenderer(this.sceneManager.scene, getTexture);
this.entityRenderer = new EntityRenderer(this.sceneManager.scene, getTexture);
// For interaction, we need renderer properties
this.interactionRenderer = new InteractionRenderer(
this.sceneManager.scene,
this.sceneManager.renderer,
this.sceneManager.camera,
this.sceneManager.interactionPlane,
getTexture
);
this.interactionPlane.rotation.x = -Math.PI / 2;
this.scene.add(this.interactionPlane);
this.selectionMesh = null;
this.highlightGroup = new THREE.Group();
this.scene.add(this.highlightGroup);
this.entities = new Map();
}
setupLights() {
// Ambient Light (Base visibility)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
this.scene.add(ambientLight);
// Directional Light (Sun/Moon - creates shadows)
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
dirLight.position.set(50, 100, 50);
dirLight.castShadow = true;
this.scene.add(dirLight);
}
setupInteraction(cameraGetter, onClick, onRightClick) {
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
};
};
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 (this.exitGroup) {
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
if (doorIntersects.length > 0) {
const doorMesh = doorIntersects[0].object;
if (doorMesh.userData.isDoor) {
// Clicked on a 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);
});
}
addEntity(entity) {
if (this.entities.has(entity.id)) return;
// Standee: Larger Size (+30%)
// Old: 0.8 x 1.2 -> New: 1.04 x 1.56
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);
// Store target position for animation logic
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);
});
}
toggleEntitySelection(entityId, isSelected) {
const mesh = this.entities.get(entityId);
if (mesh) {
const ring = mesh.getObjectByName("SelectionRing");
if (ring) ring.visible = isSelected;
}
}
moveEntityAlongPath(entity, path) {
const mesh = this.entities.get(entity.id);
if (mesh) {
mesh.userData.pathQueue = [...path];
this.highlightGroup.clear();
}
}
updateEntityPosition(entity) {
const mesh = this.entities.get(entity.id);
if (mesh) {
// Prevent snapping if animation is active
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
}
}
updateAnimations(time) {
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();
// Target: x, y (grid) -> x, z (world)
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
}
if (data.isMoving) {
const duration = 400; // ms per tile
const elapsed = time - data.startTime;
const t = Math.min(elapsed / duration, 1);
// Lerp X/Z
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, t);
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, t);
// Jump Arc
const baseHeight = 1.56 / 2;
mesh.position.y = baseHeight + (0.5 * Math.sin(t * Math.PI));
if (t >= 1) {
mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z);
data.isMoving = false;
// IF Finished Sequence (Queue empty)
if (data.pathQueue.length === 0) {
// Check if it's the player (id 'p1')
if (id === 'p1' && this.onHeroFinishedMove) {
// Grid Coords from World Coords (X, -Z)
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
}
}
}
}
});
}
renderExits(exits) {
// Cancel any pending render
if (this._pendingExitRender) {
this._pendingExitRender = false;
// Inject pathGroup into EntityRenderer so it can clean up path tiles
if (typeof this.entityRenderer.setPathGroup === 'function') {
this.entityRenderer.setPathGroup(this.interactionRenderer.pathGroup);
} else {
console.error("CRITICAL: EntityRenderer.setPathGroup is missing! A hard reload might be required.");
}
// Create exitGroup if it doesn't exist
if (!this.exitGroup) {
this.exitGroup = new THREE.Group();
this.scene.add(this.exitGroup);
}
this.effectsRenderer = new EffectsRenderer(this.sceneManager.scene);
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;
// Store door data for interaction (new doors always start closed)
// Convert numeric direction to string for generator compatibility
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);
});
});
// Expose critical properties for compatibility with main.js and GameEngine
this.scene = this.sceneManager.scene;
this.camera = this.sceneManager.camera;
this.renderer = this.sceneManager.renderer;
}
onWindowResize() {
if (this.camera) {
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
// --- Facade Methods ---
// Expose Dimensions
get width() { return this.sceneManager.width; }
get height() { return this.sceneManager.height; }
// Proxy onHeroFinishedMove
get onHeroFinishedMove() { return this.entityRenderer.onHeroFinishedMove; }
set onHeroFinishedMove(cb) { this.entityRenderer.onHeroFinishedMove = cb; }
// Expose particleManager for direct access (legacy safety)
get particleManager() { return this.effectsRenderer.particleManager; }
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null) {
this.interactionRenderer.setupInteraction(
cameraGetter,
onClick,
onRightClick,
onHover,
() => this.dungeonRenderer.exitGroup // Pass callback to get current exit group
);
}
render(camera) {
if (camera) {
this.renderer.render(this.scene, camera);
}
}
getTexture(path, onLoad) {
if (!this.textureCache.has(path)) {
const tex = this.textureLoader.load(
path,
(texture) => {
texture.needsUpdate = true;
if (onLoad) onLoad(texture);
},
undefined,
(err) => {
console.error(`[TextureLoader] ✗ Failed to load: ${path}`, err);
}
);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
this.textureCache.set(path, tex);
} else {
// Already cached, call onLoad immediately if texture is ready
const cachedTex = this.textureCache.get(path);
if (onLoad && cachedTex.image) {
onLoad(cachedTex);
}
}
return this.textureCache.get(path);
this.sceneManager.render(camera);
}
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) {
const texturePath = 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
// (The shape the grid sees: e.g. 4x2 for East)
const currentVariant = tileDef.variants[tileInstance.rotation];
if (!currentVariant) {
console.error(`[GameRenderer] 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
// Formula: anchor + (dimension - 1) / 2
// (Subtract 1 because width 1 is just offset 0)
const cx = tileInstance.x + (rotWidth - 1) / 2;
const cy = tileInstance.y + (rotHeight - 1) / 2;
// 3. Use BASE dimensions from NORTH variant for the Plane
// (Since we are rotating the plane itself, we start with the un-rotated image size)
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 is Up in this local frame before X-rotation? No, after X-rot)
// Actually, standard hierarchy: Rotate Z first?
// ThreeJS rotation order XYZ.
// We want to rotate around the Y axis of the world (which is Z of the plane before x-rotation?)
// Simplest: Rotate Z of the plane, which corresponds to world Y.
// Note: We use negative rotation because ThreeJS is CCW, but our grid might be different,
// but usually -r * PI/2 works for this setup.
plane.rotation.z = -r * (Math.PI / 2);
// Position at the calculated center
// Notice: World Z is -Grid Y
plane.position.set(cx, 0.01, -cy);
plane.receiveShadow = true;
this.scene.add(plane);
});
} else {
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`);
}
this.dungeonRenderer.addTile(cells, type, tileDef, tileInstance);
}
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;
});
addEntity(entity) {
this.entityRenderer.addEntity(entity);
}
getDoorAtPosition(x, y) {
if (!this.exitGroup) return null;
updateAnimations(time) {
this.entityRenderer.updateAnimations(time);
this.effectsRenderer.update(time);
// InteractionRenderer doesn't need time update for now
}
// Check all doors in exitGroup
for (const child of this.exitGroup.children) {
if (child.userData.isDoor) {
// Check if any of the door's cells match the position
for (const cell of child.userData.cells) {
if (cell.x === x && cell.y === y) {
return child;
}
highlightCells(cells) {
this.interactionRenderer.highlightCells(cells);
}
showAreaPreview(cells, color) {
this.interactionRenderer.showAreaPreview(cells, color);
}
hideAreaPreview() {
this.interactionRenderer.hideAreaPreview();
}
renderExits(exits) {
this.dungeonRenderer.renderExits(exits);
}
updateFogOfWar(visibleTileIds) {
this.dungeonRenderer.updateFogOfWar(visibleTileIds, this.entityRenderer.entities);
}
toggleEntitySelection(entityId, isSelected) {
this.entityRenderer.toggleEntitySelection(entityId, isSelected);
}
setEntityActive(entityId, isActive) {
this.entityRenderer.setEntityActive(entityId, isActive);
}
setEntityTarget(id, isTarget) {
this.entityRenderer.setEntityTarget(id, isTarget);
}
clearAllActiveRings() {
this.entityRenderer.clearAllActiveRings();
}
moveEntityAlongPath(entity, path) {
this.entityRenderer.moveEntityAlongPath(entity, path);
// Clear highlights when movement starts
this.interactionRenderer.highlightGroup.clear();
}
updateEntityPosition(entity) {
this.entityRenderer.updateEntityPosition(entity);
}
triggerDamageEffect(entityId) {
this.entityRenderer.triggerDamageEffect(entityId);
}
triggerDeathAnimation(entityId) {
this.entityRenderer.triggerDeathAnimation(entityId);
}
triggerVisualEffect(type, x, y) {
this.effectsRenderer.triggerVisualEffect(type, x, y);
}
triggerProjectile(startX, startY, endX, endY, onHitCallback) {
this.effectsRenderer.triggerProjectile(startX, startY, endX, endY, onHitCallback);
}
showCombatFeedback(x, y, damage, isHit, defenseText) {
this.effectsRenderer.showCombatFeedback(x, y, damage, isHit, defenseText);
// Also find entity to trigger shake if hit
if (isHit && damage > 0) {
let entityId = null;
// Search entity by approximate position
for (const [id, mesh] of this.entityRenderer.entities.entries()) {
if (Math.abs(mesh.position.x - x) < 0.1 && Math.abs(mesh.position.z - (-y)) < 0.1) {
entityId = id;
break;
}
}
}
return null;
}
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
if (!doorMesh || !doorMesh.userData.isDoor) return false;
// Check if player is adjacent to any of the door's cells
for (const cell of doorMesh.userData.cells) {
const dx = Math.abs(playerX - cell.x);
const dy = Math.abs(playerY - cell.y);
// Adjacent means distance of 1 in one direction and 0 in the other
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
return true;
}
}
return false;
}
// ========== MANUAL PLACEMENT SYSTEM ==========
enableDoorSelection(enabled) {
this.doorSelectionEnabled = enabled;
if (enabled) {
// Highlight available exits
this.highlightAvailableExits();
} else {
// Remove highlights
if (this.exitHighlightGroup) {
this.exitHighlightGroup.clear();
if (entityId) {
this.entityRenderer.triggerDamageEffect(entityId);
}
}
}
highlightAvailableExits() {
if (!this.exitHighlightGroup) {
this.exitHighlightGroup = new THREE.Group();
this.scene.add(this.exitHighlightGroup);
}
this.exitHighlightGroup.clear();
// Highlight each exit door with a pulsing glow
if (this.exitGroup) {
this.exitGroup.children.forEach(doorMesh => {
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
// Create highlight ring
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;
// Create proper exit data with all required fields
const firstCell = doorMesh.userData.cells[0];
// Convert numeric direction (0,1,2,3) to string ('N','E','S','W')
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);
}
});
}
showFloatingText(x, y, text, color) {
this.effectsRenderer.showFloatingText(x, y, text, color);
}
// Pass-through for manual placement
showPlacementPreview(preview) {
if (!preview) {
this.hidePlacementPreview();
return;
}
// Create preview groups if they don't exist
if (!this.previewGroup) {
this.previewGroup = new THREE.Group();
this.scene.add(this.previewGroup);
}
if (!this.projectionGroup) {
this.projectionGroup = new THREE.Group();
this.scene.add(this.projectionGroup);
}
// Clear previous preview
this.previewGroup.clear();
this.projectionGroup.clear();
const { card, cells, isValid, x, y, rotation } = preview;
// Calculate bounds for tile - OLD LOGIC (Removed)
// Note: We ignore 'cells' for positioning the texture, but keep them for the Ground Projection (Green/Red squares)
// 1. FLOATING TILE (Y = 3)
if (card.textures && card.textures.length > 0) {
this.getTexture(card.textures[0], (texture) => {
// Get Current Rotation Variant for Dimensions
const currentVariant = card.variants[rotation];
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
// Calculate Center based on Anchor (x, y) and Dimensions
const cx = x + (rotWidth - 1) / 2;
const cy = y + (rotHeight - 1) / 2;
// Use BASE dimensions from NORTH variant
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;
// Apply Z rotation
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 (Green/Red)
const projectionColor = isValid ? 0x00ff00 : 0xff0000;
cells.forEach(cell => {
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
const material = new THREE.MeshBasicMaterial({
color: projectionColor,
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);
});
this.interactionRenderer.showPlacementPreview(preview);
}
hidePlacementPreview() {
if (this.previewGroup) {
this.previewGroup.clear();
}
if (this.projectionGroup) {
this.projectionGroup.clear();
}
this.interactionRenderer.hidePlacementPreview();
}
enableDoorSelection(enabled) {
this.interactionRenderer.enableDoorSelection(enabled, this.dungeonRenderer.exitGroup);
}
getDoorAtPosition(x, y) {
return this.dungeonRenderer.getDoorAtPosition(x, y);
}
openDoor(doorMesh) {
this.dungeonRenderer.openDoor(doorMesh);
}
blockDoor(exitData) {
this.dungeonRenderer.blockDoor(exitData);
}
showRangedTargeting(hero, monster, losResult) {
this.interactionRenderer.showRangedTargeting(hero, monster, losResult);
}
clearRangedTargeting() {
this.interactionRenderer.rangedGroup.clear();
}
showTokens(heroes, monsters) {
this.entityRenderer.showTokens(heroes, monsters);
}
hideTokens() {
this.entityRenderer.hideTokens();
}
updatePathVisualization(path) {
this.interactionRenderer.updatePathVisualization(path);
}
// Callbacks setter (Legacy method support)
setOnHeroFinishedMove(callback) {
this.entityRenderer.onHeroFinishedMove = callback;
}
// Texture Utility (Shared logic)
getTexture(path, onLoad, onProgress, onError) {
if (this.textureCache.has(path)) {
if (onLoad) onLoad(this.textureCache.get(path));
return;
}
if (!this._pendingTextureRequests) this._pendingTextureRequests = new Map();
if (this._pendingTextureRequests.has(path)) {
this._pendingTextureRequests.get(path).push(onLoad);
return;
}
this._pendingTextureRequests.set(path, [onLoad]);
this.textureLoader.load(
path,
(texture) => {
// IMPORTANT: Texture Settings for Pixel Art Check
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
this.textureCache.set(path, texture);
const callbacks = this._pendingTextureRequests.get(path);
if (callbacks) {
callbacks.forEach(cb => { if (cb) cb(texture); });
this._pendingTextureRequests.delete(path);
}
},
onProgress,
(err) => {
console.error(`[GameRenderer] Failed to load texture: ${path} `, err);
if (onError) onError(err);
const callbacks = this._pendingTextureRequests.get(path);
if (callbacks) {
this._pendingTextureRequests.delete(path);
}
}
);
}
}

216
src/view/ParticleManager.js Normal file
View 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
View 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);
}
}
}

View File

@@ -1,354 +1,117 @@
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
import { HUDManager } from './ui/HUDManager.js';
import { UnitCardManager } from './ui/UnitCardManager.js';
import { TurnStatusUI } from './ui/TurnStatusUI.js';
import { PlacementUI } from './ui/PlacementUI.js';
import { FeedbackUI } from './ui/FeedbackUI.js';
import { SpellbookUI } from './ui/SpellbookUI.js';
export class UIManager {
constructor(cameraManager, gameEngine) {
this.cameraManager = cameraManager;
this.game = gameEngine;
this.dungeon = gameEngine.dungeon;
this.createHUD();
this.setupMinimapLoop();
this.container = this.createMainContainer();
// Instantiate Subsystems
this.hud = new HUDManager(this.container, cameraManager, gameEngine);
this.turnUI = new TurnStatusUI(this.container, gameEngine);
this.feedback = new FeedbackUI(this.container, gameEngine);
this.spellbook = new SpellbookUI(gameEngine);
// Circular deps / callbacks
const cardCallbacks = {
showModal: (t, m, c) => this.feedback.showModal(t, m, c),
toggleSpellBook: (h) => this.spellbook.toggle(h)
};
this.cards = new UnitCardManager(this.container, gameEngine, cardCallbacks);
const placementCallbacks = {
showModal: (t, m) => this.feedback.showModal(t, m),
showConfirm: (t, m, c) => this.feedback.showConfirm(t, m, c)
};
this.placement = new PlacementUI(this.container, gameEngine, placementCallbacks);
this.selectedHero = null; // State tracking for coordination
this.setupGameListeners();
}
createHUD() {
// Container
this.container = document.createElement('div');
this.container.style.position = 'absolute';
this.container.style.top = '0';
this.container.style.left = '0';
this.container.style.width = '100%';
this.container.style.height = '100%';
this.container.style.pointerEvents = 'none'; // Click through to 3D scene
document.body.appendChild(this.container);
// --- Minimap (Top Left) ---
this.minimapCanvas = document.createElement('canvas');
this.minimapCanvas.width = 200;
this.minimapCanvas.height = 200;
this.minimapCanvas.style.position = 'absolute';
this.minimapCanvas.style.top = '10px';
this.minimapCanvas.style.left = '10px';
this.minimapCanvas.style.border = '2px solid #444';
this.minimapCanvas.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
this.minimapCanvas.style.pointerEvents = 'auto'; // Allow interaction if needed
this.container.appendChild(this.minimapCanvas);
this.ctx = this.minimapCanvas.getContext('2d');
// --- Camera Controls (Top Right) ---
const controlsContainer = document.createElement('div');
controlsContainer.style.position = 'absolute';
controlsContainer.style.top = '20px';
controlsContainer.style.right = '20px';
controlsContainer.style.display = 'flex';
controlsContainer.style.gap = '10px';
controlsContainer.style.alignItems = 'center';
controlsContainer.style.pointerEvents = 'auto';
this.container.appendChild(controlsContainer);
// Zoom slider (vertical)
const zoomContainer = document.createElement('div');
zoomContainer.style.display = 'flex';
zoomContainer.style.flexDirection = 'column';
zoomContainer.style.alignItems = 'center';
zoomContainer.style.gap = '0px';
zoomContainer.style.height = '140px'; // Fixed height to accommodate label + slider
// Zoom label
const zoomLabel = document.createElement('div');
zoomLabel.textContent = 'Zoom';
zoomLabel.style.color = '#fff';
zoomLabel.style.fontSize = '15px';
zoomLabel.style.fontFamily = 'sans-serif';
zoomLabel.style.marginBottom = '10px';
zoomLabel.style.marginTop = '0px';
const zoomSlider = document.createElement('input');
zoomSlider.type = 'range';
zoomSlider.min = '2.5'; // Closest zoom
zoomSlider.max = '30'; // Farthest zoom
zoomSlider.value = '2.5'; // Start at closest
zoomSlider.step = '0.5';
zoomSlider.style.width = '100px';
zoomSlider.style.transform = 'rotate(-90deg)';
zoomSlider.style.transformOrigin = 'center';
zoomSlider.style.cursor = 'pointer';
zoomSlider.style.marginTop = '40px'; // Push slider down to make room for label
// Set initial zoom to closest
this.cameraManager.zoomLevel = 2.5;
this.cameraManager.updateProjection();
zoomSlider.oninput = (e) => {
this.cameraManager.zoomLevel = parseFloat(e.target.value);
this.cameraManager.updateProjection();
};
zoomContainer.appendChild(zoomLabel);
zoomContainer.appendChild(zoomSlider);
// Direction buttons grid
const buttonsGrid = document.createElement('div');
buttonsGrid.style.display = 'grid';
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
buttonsGrid.style.gap = '5px';
controlsContainer.appendChild(zoomContainer);
controlsContainer.appendChild(buttonsGrid);
const createBtn = (label, dir) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.width = '40px';
btn.style.height = '40px';
btn.style.backgroundColor = '#333';
btn.style.color = '#fff';
btn.style.border = '1px solid #666';
btn.style.cursor = 'pointer';
btn.style.transition = 'background-color 0.2s';
btn.dataset.direction = dir; // Store direction for later reference
btn.onclick = () => {
this.cameraManager.setIsoView(dir);
this.updateActiveViewButton(dir);
};
return btn;
};
// Layout: [N]
// [W] [E]
// [S]
// Grid cells: 1 2 3
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
buttonsGrid.appendChild(btnN);
buttonsGrid.appendChild(btnW);
buttonsGrid.appendChild(btnE);
buttonsGrid.appendChild(btnS);
// Store button references for later updates
this.viewButtons = [btnN, btnE, btnS, btnW];
// Set initial active button (North)
this.updateActiveViewButton(DIRECTIONS.NORTH);
// --- Tile Placement Controls (Bottom Center) ---
this.placementPanel = document.createElement('div');
this.placementPanel.style.position = 'absolute';
this.placementPanel.style.bottom = '20px';
this.placementPanel.style.left = '50%';
this.placementPanel.style.transform = 'translateX(-50%)';
this.placementPanel.style.display = 'none'; // Hidden by default
this.placementPanel.style.pointerEvents = 'auto';
this.placementPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
this.placementPanel.style.padding = '15px';
this.placementPanel.style.borderRadius = '8px';
this.placementPanel.style.border = '2px solid #666';
this.container.appendChild(this.placementPanel);
// Status text
this.placementStatus = document.createElement('div');
this.placementStatus.style.color = '#fff';
this.placementStatus.style.fontSize = '16px';
this.placementStatus.style.fontFamily = 'sans-serif';
this.placementStatus.style.marginBottom = '10px';
this.placementStatus.style.textAlign = 'center';
this.placementStatus.textContent = 'Coloca la loseta';
this.placementPanel.appendChild(this.placementStatus);
// Controls container
const placementControls = document.createElement('div');
placementControls.style.display = 'flex';
placementControls.style.gap = '15px';
placementControls.style.alignItems = 'center';
this.placementPanel.appendChild(placementControls);
// Movement arrows (4-way grid)
const arrowGrid = document.createElement('div');
arrowGrid.style.display = 'grid';
arrowGrid.style.gridTemplateColumns = '40px 40px 40px';
arrowGrid.style.gap = '3px';
const createArrow = (label, dx, dy) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.width = '40px';
btn.style.height = '40px';
btn.style.backgroundColor = '#444';
btn.style.color = '#fff';
btn.style.border = '1px solid #888';
btn.style.cursor = 'pointer';
btn.style.fontSize = '18px';
btn.onclick = () => {
if (this.dungeon) {
this.dungeon.movePlacement(dx, dy);
}
};
return btn;
};
const arrowUp = createArrow('↑', 0, 1);
const arrowLeft = createArrow('←', -1, 0);
const arrowRight = createArrow('→', 1, 0);
const arrowDown = createArrow('↓', 0, -1);
arrowUp.style.gridColumn = '2';
arrowLeft.style.gridColumn = '1';
arrowRight.style.gridColumn = '3';
arrowDown.style.gridColumn = '2';
arrowGrid.appendChild(arrowUp);
arrowGrid.appendChild(arrowLeft);
arrowGrid.appendChild(arrowRight);
arrowGrid.appendChild(arrowDown);
placementControls.appendChild(arrowGrid);
// Rotate button
this.rotateBtn = document.createElement('button');
this.rotateBtn.textContent = '🔄 Rotar';
this.rotateBtn.style.padding = '10px 20px';
this.rotateBtn.style.backgroundColor = '#555';
this.rotateBtn.style.color = '#fff';
this.rotateBtn.style.border = '1px solid #888';
this.rotateBtn.style.cursor = 'pointer';
this.rotateBtn.style.fontSize = '16px';
this.rotateBtn.style.borderRadius = '4px';
this.rotateBtn.onclick = () => {
if (this.dungeon) {
this.dungeon.rotatePlacement();
}
};
placementControls.appendChild(this.rotateBtn);
// Place button
this.placeBtn = document.createElement('button');
this.placeBtn.textContent = '⬇ Bajar';
this.placeBtn.style.padding = '10px 20px';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.color = '#fff';
this.placeBtn.style.border = '1px solid #888';
this.placeBtn.style.cursor = 'pointer';
this.placeBtn.style.fontSize = '16px';
this.placeBtn.style.borderRadius = '4px';
this.placeBtn.onclick = () => {
if (this.dungeon) {
const success = this.dungeon.confirmPlacement();
if (!success) {
alert('❌ No se puede colocar la loseta en esta posición');
}
}
};
placementControls.appendChild(this.placeBtn);
}
showPlacementControls(show) {
if (this.placementPanel) {
this.placementPanel.style.display = show ? 'block' : 'none';
}
}
updatePlacementStatus(isValid) {
if (this.placementStatus) {
if (isValid) {
this.placementStatus.textContent = '✅ Posición válida';
this.placementStatus.style.color = '#0f0';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.cursor = 'pointer';
} else {
this.placementStatus.textContent = '❌ Posición inválida';
this.placementStatus.style.color = '#f44';
this.placeBtn.style.backgroundColor = '#555';
this.placeBtn.style.cursor = 'not-allowed';
}
}
}
updateActiveViewButton(activeDirection) {
// Reset all buttons to default color
this.viewButtons.forEach(btn => {
btn.style.backgroundColor = '#333';
createMainContainer() {
const c = document.createElement('div');
Object.assign(c.style, {
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none'
});
// Highlight the active button
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
if (activeBtn) {
activeBtn.style.backgroundColor = '#f0c040'; // Yellow/gold color
}
document.body.appendChild(c);
return c;
}
setupMinimapLoop() {
const loop = () => {
this.drawMinimap();
requestAnimationFrame(loop);
setupGameListeners() {
// Entity Selection
const originalSelect = this.game.onEntitySelect;
this.game.onEntitySelect = (id, isSelected) => {
// 1. Call Renderer
if (this.cameraManager && this.cameraManager.renderer) {
this.cameraManager.renderer.toggleEntitySelection(id, isSelected);
} else if (window.RENDERER) {
window.RENDERER.toggleEntitySelection(id, isSelected);
}
// 2. Update UI
if (isSelected) {
const hero = this.game.heroes.find(h => h.id === id);
const monster = this.game.monsters ? this.game.monsters.find(m => m.id === id) : null;
if (hero) {
this.selectedHero = hero;
this.turnUI.updateHeroStats(hero); // Update top panel info
this.cards.showHeroCard(hero);
this.cards.hideMonsterCard();
} else if (monster) {
// Check context: are we selecting a target?
if (this.selectedHero && this.game.turnManager.currentPhase === 'hero') {
this.cards.showMonsterCard(monster);
}
}
} else {
// Deselection
if (this.selectedHero && this.selectedHero.id === id) {
this.selectedHero = null;
this.turnUI.updateHeroStats(null);
this.cards.hideHeroCard();
} else {
this.cards.hideMonsterCard();
}
}
};
loop();
}
drawMinimap() {
const ctx = this.ctx;
const w = this.minimapCanvas.width;
const h = this.minimapCanvas.height;
// Entity Move
const originalMove = this.game.onEntityMove;
this.game.onEntityMove = (entity, path) => {
if (originalMove) originalMove(entity, path);
this.turnUI.updateHeroStats(entity);
if (entity.type === 'hero') {
this.cards.updateHeroCard(entity.id);
}
};
ctx.clearRect(0, 0, w, h);
// Center the view on 0,0 or the average?
// Let's rely on fixed scale for now
const cellSize = 5;
const centerX = w / 2;
const centerY = h / 2;
// Draw placed tiles
// We can access this.dungeon.grid.occupiedCells for raw occupied spots
// Or this.dungeon.placedTiles for structural info (type, color)
ctx.fillStyle = '#666'; // Generic floor
// Iterate over grid occupied cells
// But grid is a Map, iterating keys is slow.
// Better to iterate placedTiles which is an Array
// Simpler approach: Iterate the Grid Map directly
// It's a Map<"x,y", tileId>
// Use an iterator
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
const [x, y] = key.split(',').map(Number);
// Coordinate transformation to Canvas
// Dungeon (0,0) -> Canvas (CenterX, CenterY)
// Y in dungeon is Up/North. Y in Canvas is Down.
// So CanvasY = CenterY - (DungeonY * size)
const cx = centerX + (x * cellSize);
const cy = centerY - (y * cellSize);
// Color based on TileId type?
if (tileId.includes('room')) ctx.fillStyle = '#55a';
else ctx.fillStyle = '#aaa';
ctx.fillRect(cx, cy, cellSize, cellSize);
}
// Draw Exits (Available)
ctx.fillStyle = '#0f0'; // Green dots for open exits
if (this.dungeon.availableExits) {
this.dungeon.availableExits.forEach(exit => {
const ex = centerX + (exit.x * cellSize);
const ey = centerY - (exit.y * cellSize);
ctx.fillRect(ex, ey, cellSize, cellSize);
// Turn Manager Events
if (this.game.turnManager) {
this.game.turnManager.on('phase_changed', (phase) => {
this.turnUI.updatePhaseDisplay(phase, this.selectedHero);
});
this.game.turnManager.on('POWER_RESULT', (data) => {
this.turnUI.showPowerRollResult(data);
});
}
// Draw Entry (0,0) cross
ctx.strokeStyle = '#f00';
ctx.beginPath();
ctx.moveTo(centerX - 5, centerY);
ctx.lineTo(centerX + 5, centerY);
ctx.moveTo(centerX, centerY - 5);
ctx.lineTo(centerX, centerY + 5);
ctx.stroke();
}
// Public API for GameEngine access
showPlacementControls(show) { this.placement.showControls(show); }
updatePlacementStatus(valid) { this.placement.updateStatus(valid); }
showModal(t, m, c) { this.feedback.showModal(t, m, c); }
showConfirm(t, m, c) { this.feedback.showConfirm(t, m, c); }
showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); }
showCombatLog(log) { this.feedback.showCombatLog(log); }
showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); }
hideMonsterCard() { this.cards.hideMonsterCard(); }
}

View File

@@ -0,0 +1,965 @@
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
export class UIManager {
constructor(cameraManager, gameEngine) {
this.cameraManager = cameraManager;
this.game = gameEngine;
this.dungeon = gameEngine.dungeon;
this.selectedHero = null;
this.createHUD();
this.createHeroCardsPanel(); // NEW: Hero stat cards
this.createGameStatusPanel(); // New Panel
this.setupMinimapLoop();
this.setupGameListeners(); // New Listeners
// Hook into engine callbacks for UI updates
const originalSelect = this.game.onEntitySelect;
this.game.onEntitySelect = (id, isSelected) => {
// 1. Call Renderer (was in main.js)
if (this.cameraManager && this.cameraManager.renderer) {
this.cameraManager.renderer.toggleEntitySelection(id, isSelected);
} else if (window.RENDERER) {
window.RENDERER.toggleEntitySelection(id, isSelected);
}
// 2. Update UI
if (isSelected) {
const hero = this.game.heroes.find(h => h.id === id);
this.selectedHero = hero; // Store state
this.updateHeroStats(hero);
} else {
this.selectedHero = null;
this.updateHeroStats(null);
}
};
const originalMove = this.game.onEntityMove;
this.game.onEntityMove = (entity, path) => {
if (originalMove) originalMove(entity, path);
this.updateHeroStats(entity);
// Update hero card if it's a hero
if (entity.type === 'hero') {
this.updateHeroCard(entity.id);
}
};
}
createHUD() {
// Container
this.container = document.createElement('div');
this.container.style.position = 'absolute';
this.container.style.top = '0';
this.container.style.left = '0';
this.container.style.width = '100%';
this.container.style.height = '100%';
this.container.style.pointerEvents = 'none'; // Click through to 3D scene
document.body.appendChild(this.container);
// --- Minimap (Top Left) ---
this.minimapCanvas = document.createElement('canvas');
this.minimapCanvas.width = 200;
this.minimapCanvas.height = 200;
this.minimapCanvas.style.position = 'absolute';
this.minimapCanvas.style.top = '10px';
this.minimapCanvas.style.left = '10px';
this.minimapCanvas.style.border = '2px solid #444';
this.minimapCanvas.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
this.minimapCanvas.style.pointerEvents = 'auto'; // Allow interaction if needed
this.container.appendChild(this.minimapCanvas);
this.ctx = this.minimapCanvas.getContext('2d');
// --- Camera Controls (Top Right) ---
const controlsContainer = document.createElement('div');
controlsContainer.style.position = 'absolute';
controlsContainer.style.top = '20px';
controlsContainer.style.right = '20px';
controlsContainer.style.display = 'flex';
controlsContainer.style.gap = '10px';
controlsContainer.style.alignItems = 'center';
controlsContainer.style.pointerEvents = 'auto';
this.container.appendChild(controlsContainer);
// Zoom slider (vertical)
const zoomContainer = document.createElement('div');
zoomContainer.style.display = 'flex';
zoomContainer.style.flexDirection = 'column';
zoomContainer.style.alignItems = 'center';
zoomContainer.style.gap = '0px';
zoomContainer.style.height = '140px'; // Fixed height to accommodate label + slider
// Zoom label
const zoomLabel = document.createElement('div');
zoomLabel.textContent = 'Zoom';
zoomLabel.style.color = '#fff';
zoomLabel.style.fontSize = '15px';
zoomLabel.style.fontFamily = 'sans-serif';
zoomLabel.style.marginBottom = '10px';
zoomLabel.style.marginTop = '0px';
const zoomSlider = document.createElement('input');
zoomSlider.type = 'range';
zoomSlider.min = '3';
zoomSlider.max = '15';
zoomSlider.value = '6';
zoomSlider.step = '0.5';
zoomSlider.style.width = '100px';
zoomSlider.style.transform = 'rotate(-90deg)';
zoomSlider.style.transformOrigin = 'center';
zoomSlider.style.cursor = 'pointer';
zoomSlider.style.marginTop = '40px';
this.zoomSlider = zoomSlider;
// Set initial zoom
this.cameraManager.zoomLevel = 6;
this.cameraManager.updateProjection();
this.cameraManager.onZoomChange = (val) => {
if (this.zoomSlider) this.zoomSlider.value = val;
};
zoomSlider.oninput = (e) => {
this.cameraManager.zoomLevel = parseFloat(e.target.value);
this.cameraManager.updateProjection();
};
zoomContainer.appendChild(zoomLabel);
zoomContainer.appendChild(zoomSlider);
// Direction buttons grid
const buttonsGrid = document.createElement('div');
buttonsGrid.style.display = 'grid';
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
buttonsGrid.style.gap = '5px';
controlsContainer.appendChild(zoomContainer);
controlsContainer.appendChild(buttonsGrid);
const createBtn = (label, dir) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.width = '40px';
btn.style.height = '40px';
btn.style.backgroundColor = '#333';
btn.style.color = '#fff';
btn.style.border = '1px solid #666';
btn.style.cursor = 'pointer';
btn.style.transition = 'background-color 0.2s';
btn.dataset.direction = dir; // Store direction for later reference
btn.onclick = () => {
this.cameraManager.setIsoView(dir);
this.updateActiveViewButton(dir);
};
return btn;
};
// Layout: [N]
// [W] [E]
// [S]
// Grid cells: 1 2 3
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
buttonsGrid.appendChild(btnN);
buttonsGrid.appendChild(btnW);
buttonsGrid.appendChild(btnE);
buttonsGrid.appendChild(btnS);
// Store button references for later updates
this.viewButtons = [btnN, btnE, btnS, btnW];
// Set initial active button (North)
this.updateActiveViewButton(DIRECTIONS.NORTH);
// --- Tile Placement Controls (Bottom Center) ---
this.placementPanel = document.createElement('div');
this.placementPanel.style.position = 'absolute';
this.placementPanel.style.bottom = '20px';
this.placementPanel.style.left = '50%';
this.placementPanel.style.transform = 'translateX(-50%)';
this.placementPanel.style.display = 'none'; // Hidden by default
this.placementPanel.style.pointerEvents = 'auto';
this.placementPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
this.placementPanel.style.padding = '15px';
this.placementPanel.style.borderRadius = '8px';
this.placementPanel.style.border = '2px solid #666';
this.container.appendChild(this.placementPanel);
// Status text
this.placementStatus = document.createElement('div');
this.placementStatus.style.color = '#fff';
this.placementStatus.style.fontSize = '16px';
this.placementStatus.style.fontFamily = 'sans-serif';
this.placementStatus.style.marginBottom = '10px';
this.placementStatus.style.textAlign = 'center';
this.placementStatus.textContent = 'Coloca la loseta';
this.placementPanel.appendChild(this.placementStatus);
// Controls container
const placementControls = document.createElement('div');
placementControls.style.display = 'flex';
placementControls.style.gap = '15px';
placementControls.style.alignItems = 'center';
this.placementPanel.appendChild(placementControls);
// Movement arrows (4-way grid)
const arrowGrid = document.createElement('div');
arrowGrid.style.display = 'grid';
arrowGrid.style.gridTemplateColumns = '40px 40px 40px';
arrowGrid.style.gap = '3px';
const createArrow = (label, dx, dy) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.width = '40px';
btn.style.height = '40px';
btn.style.backgroundColor = '#444';
btn.style.color = '#fff';
btn.style.border = '1px solid #888';
btn.style.cursor = 'pointer';
btn.style.fontSize = '18px';
btn.onclick = () => {
if (this.dungeon) {
this.dungeon.movePlacement(dx, dy);
}
};
return btn;
};
const arrowUp = createArrow('↑', 0, 1);
const arrowLeft = createArrow('←', -1, 0);
const arrowRight = createArrow('→', 1, 0);
const arrowDown = createArrow('↓', 0, -1);
arrowUp.style.gridColumn = '2';
arrowLeft.style.gridColumn = '1';
arrowRight.style.gridColumn = '3';
arrowDown.style.gridColumn = '2';
arrowGrid.appendChild(arrowUp);
arrowGrid.appendChild(arrowLeft);
arrowGrid.appendChild(arrowRight);
arrowGrid.appendChild(arrowDown);
placementControls.appendChild(arrowGrid);
// Rotate button
this.rotateBtn = document.createElement('button');
this.rotateBtn.textContent = '🔄 Rotar';
this.rotateBtn.style.padding = '10px 20px';
this.rotateBtn.style.backgroundColor = '#555';
this.rotateBtn.style.color = '#fff';
this.rotateBtn.style.border = '1px solid #888';
this.rotateBtn.style.cursor = 'pointer';
this.rotateBtn.style.fontSize = '16px';
this.rotateBtn.style.borderRadius = '4px';
this.rotateBtn.onclick = () => {
if (this.dungeon) {
this.dungeon.rotatePlacement();
}
};
placementControls.appendChild(this.rotateBtn);
this.placeBtn = document.createElement('button');
this.placeBtn.textContent = '⬇ Bajar';
this.placeBtn.style.padding = '10px 20px';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.color = '#fff';
this.placeBtn.style.border = '1px solid #888';
this.placeBtn.style.cursor = 'pointer';
this.placeBtn.style.fontSize = '16px';
this.placeBtn.style.borderRadius = '4px';
this.placeBtn.onclick = () => {
if (this.dungeon) {
const success = this.dungeon.confirmPlacement();
if (!success) {
this.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
}
}
};
placementControls.appendChild(this.placeBtn);
// Discard button
this.discardBtn = document.createElement('button');
this.discardBtn.textContent = '❌ Cancelar';
this.discardBtn.style.padding = '10px 20px';
this.discardBtn.style.backgroundColor = '#d33';
this.discardBtn.style.color = '#fff';
this.discardBtn.style.border = '1px solid #888';
this.discardBtn.style.cursor = 'pointer';
this.discardBtn.style.fontSize = '16px';
this.discardBtn.style.borderRadius = '4px';
this.discardBtn.onclick = () => {
if (this.dungeon) {
this.showConfirm(
'Confirmar acción',
'¿Quieres descartar esta loseta y bloquear la puerta?',
() => {
this.dungeon.cancelPlacement();
}
);
}
};
placementControls.appendChild(this.discardBtn);
}
createHeroCardsPanel() {
// Container for character cards (left side)
this.cardsContainer = document.createElement('div');
this.cardsContainer.style.position = 'absolute';
this.cardsContainer.style.left = '10px';
this.cardsContainer.style.top = '220px'; // Below minimap
this.cardsContainer.style.display = 'flex';
this.cardsContainer.style.flexDirection = 'column';
this.cardsContainer.style.gap = '10px';
this.cardsContainer.style.pointerEvents = 'auto';
this.cardsContainer.style.width = '200px';
this.container.appendChild(this.cardsContainer);
// Create placeholder card
this.createPlaceholderCard();
// Store references
this.currentHeroCard = null;
this.currentMonsterCard = null;
this.attackButton = null;
}
createPlaceholderCard() {
const card = document.createElement('div');
card.style.width = '180px';
card.style.height = '280px';
card.style.backgroundColor = 'rgba(20, 20, 20, 0.95)';
card.style.border = '2px solid #8B4513';
card.style.borderRadius = '8px';
card.style.padding = '10px';
card.style.fontFamily = '"Cinzel", serif';
card.style.color = '#888';
card.style.display = 'flex';
card.style.flexDirection = 'column';
card.style.alignItems = 'center';
card.style.justifyContent = 'center';
card.style.textAlign = 'center';
const icon = document.createElement('div');
icon.textContent = '🎴';
icon.style.fontSize = '64px';
icon.style.marginBottom = '20px';
card.appendChild(icon);
const text = document.createElement('div');
text.textContent = 'Selecciona un Aventurero';
text.style.fontSize = '14px';
text.style.color = '#DAA520';
card.appendChild(text);
this.placeholderCard = card;
this.cardsContainer.appendChild(card);
}
createHeroCard(hero) {
const card = document.createElement('div');
card.style.width = '180px';
card.style.backgroundColor = 'rgba(20, 20, 20, 0.95)';
card.style.border = '2px solid #8B4513';
card.style.borderRadius = '8px';
card.style.padding = '10px';
card.style.fontFamily = '"Cinzel", serif';
card.style.color = '#fff';
card.style.transition = 'all 0.3s';
card.style.cursor = 'pointer';
// Hover effect
card.onmouseenter = () => {
card.style.borderColor = '#DAA520';
card.style.transform = 'scale(1.05)';
};
card.onmouseleave = () => {
card.style.borderColor = '#8B4513';
card.style.transform = 'scale(1)';
};
// Click to select hero
card.onclick = () => {
if (this.game.onCellClick) {
this.game.onCellClick(hero.x, hero.y);
}
};
// Portrait
const portrait = document.createElement('div');
portrait.style.width = '100%';
portrait.style.height = '100px';
portrait.style.borderRadius = '5px';
portrait.style.overflow = 'hidden';
portrait.style.border = '2px solid #DAA520';
portrait.style.marginBottom = '8px';
portrait.style.backgroundColor = '#000';
portrait.style.display = 'flex';
portrait.style.alignItems = 'center';
portrait.style.justifyContent = 'center';
// Use token image (placeholder for now)
const tokenPath = `/assets/images/dungeon1/tokens/heroes/${hero.key}.png`;
const img = document.createElement('img');
img.src = tokenPath;
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
// Fallback if image doesn't exist
img.onerror = () => {
portrait.innerHTML = `<div style="color: #DAA520; font-size: 48px;">?</div>`;
};
portrait.appendChild(img);
card.appendChild(portrait);
// Name
const name = document.createElement('div');
name.textContent = hero.name;
name.style.fontSize = '16px';
name.style.fontWeight = 'bold';
name.style.color = '#DAA520';
name.style.textAlign = 'center';
name.style.marginBottom = '8px';
name.style.textTransform = 'uppercase';
card.appendChild(name);
// Lantern indicator
if (hero.hasLantern) {
const lantern = document.createElement('div');
lantern.textContent = '🏮 Portador de la Lámpara';
lantern.style.fontSize = '10px';
lantern.style.color = '#FFA500';
lantern.style.textAlign = 'center';
lantern.style.marginBottom = '8px';
card.appendChild(lantern);
}
// Stats grid
const statsGrid = document.createElement('div');
statsGrid.style.display = 'grid';
statsGrid.style.gridTemplateColumns = '1fr 1fr';
statsGrid.style.gap = '4px';
statsGrid.style.fontSize = '12px';
statsGrid.style.marginBottom = '8px';
const stats = [
{ label: 'WS', value: hero.stats.ws || 0 },
{ label: 'BS', value: hero.stats.bs || 0 },
{ label: 'S', value: hero.stats.str || 0 },
{ label: 'T', value: hero.stats.toughness || 0 },
{ label: 'W', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` },
{ label: 'I', value: hero.stats.initiative || 0 },
{ label: 'A', value: hero.stats.attacks || 0 },
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` }
];
stats.forEach(stat => {
const statEl = document.createElement('div');
statEl.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
statEl.style.padding = '3px 5px';
statEl.style.borderRadius = '3px';
statEl.style.display = 'flex';
statEl.style.justifyContent = 'space-between';
const label = document.createElement('span');
label.textContent = stat.label + ':';
label.style.color = '#AAA';
const value = document.createElement('span');
value.textContent = stat.value;
value.style.color = '#FFF';
value.style.fontWeight = 'bold';
statEl.appendChild(label);
statEl.appendChild(value);
statsGrid.appendChild(statEl);
});
card.appendChild(statsGrid);
// Store reference
this.heroCards.set(hero.id, card);
this.heroCardsContainer.appendChild(card);
}
updateHeroCard(heroId) {
const card = this.heroCards.get(heroId);
if (!card) return;
const hero = this.game.heroes.find(h => h.id === heroId);
if (!hero) return;
// Update wounds and moves in the stats grid
const statsGrid = card.querySelector('div[style*="grid-template-columns"]');
if (statsGrid) {
const statDivs = statsGrid.children;
// W is at index 4, Mov is at index 7
if (statDivs[4]) {
const wValue = statDivs[4].querySelector('span:last-child');
if (wValue) wValue.textContent = `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`;
}
if (statDivs[7]) {
const movValue = statDivs[7].querySelector('span:last-child');
if (movValue) movValue.textContent = `${hero.currentMoves || 0}/${hero.stats.move}`;
}
}
}
showPlacementControls(show) {
if (this.placementPanel) {
this.placementPanel.style.display = show ? 'block' : 'none';
}
}
updatePlacementStatus(isValid) {
if (this.placementStatus) {
if (isValid) {
this.placementStatus.textContent = '✅ Posición válida';
this.placementStatus.style.color = '#0f0';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.cursor = 'pointer';
} else {
this.placementStatus.textContent = '❌ Posición inválida';
this.placementStatus.style.color = '#f44';
this.placeBtn.style.backgroundColor = '#555';
this.placeBtn.style.cursor = 'not-allowed';
}
}
}
updateActiveViewButton(activeDirection) {
// Reset all buttons to default color
this.viewButtons.forEach(btn => {
btn.style.backgroundColor = '#333';
});
// Highlight the active button
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
if (activeBtn) {
activeBtn.style.backgroundColor = '#f0c040'; // Yellow/gold color
}
}
setupMinimapLoop() {
const loop = () => {
this.drawMinimap();
requestAnimationFrame(loop);
};
loop();
}
drawMinimap() {
const ctx = this.ctx;
const w = this.minimapCanvas.width;
const h = this.minimapCanvas.height;
ctx.clearRect(0, 0, w, h);
const cellSize = 5;
const centerX = w / 2;
const centerY = h / 2;
ctx.fillStyle = '#666'; // Generic floor
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
const [x, y] = key.split(',').map(Number);
const cx = centerX + (x * cellSize);
const cy = centerY - (y * cellSize);
if (tileId.includes('room')) ctx.fillStyle = '#55a';
else ctx.fillStyle = '#aaa';
ctx.fillRect(cx, cy, cellSize, cellSize);
}
// Draw Exits (Available)
ctx.fillStyle = '#0f0'; // Green dots for open exits
if (this.dungeon.availableExits) {
this.dungeon.availableExits.forEach(exit => {
const ex = centerX + (exit.x * cellSize);
const ey = centerY - (exit.y * cellSize);
ctx.fillRect(ex, ey, cellSize, cellSize);
});
}
// Draw Entry (0,0) cross
ctx.strokeStyle = '#f00';
ctx.beginPath();
ctx.moveTo(centerX - 5, centerY);
ctx.lineTo(centerX + 5, centerY);
ctx.moveTo(centerX, centerY - 5);
ctx.lineTo(centerX, centerY + 5);
ctx.stroke();
}
showModal(title, message, onClose) {
// Overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.pointerEvents = 'auto'; // Block clicks behind
overlay.style.zIndex = '1000';
// Content Box
const content = document.createElement('div');
content.style.backgroundColor = '#222';
content.style.border = '2px solid #888';
content.style.borderRadius = '8px';
content.style.padding = '20px';
content.style.width = '300px';
content.style.textAlign = 'center';
content.style.color = '#fff';
content.style.fontFamily = 'sans-serif';
// Title
const titleEl = document.createElement('h2');
titleEl.textContent = title;
titleEl.style.marginTop = '0';
titleEl.style.color = '#f44'; // Reddish for importance
content.appendChild(titleEl);
// Message
const msgEl = document.createElement('p');
msgEl.innerHTML = message;
msgEl.style.fontSize = '16px';
msgEl.style.lineHeight = '1.5';
content.appendChild(msgEl);
// OK Button
const btn = document.createElement('button');
btn.textContent = 'Entendido';
btn.style.marginTop = '20px';
btn.style.padding = '10px 20px';
btn.style.fontSize = '16px';
btn.style.cursor = 'pointer';
btn.style.backgroundColor = '#444';
btn.style.color = '#fff';
btn.style.border = '1px solid #888';
btn.onclick = () => {
this.container.removeChild(overlay);
if (onClose) onClose();
};
content.appendChild(btn);
overlay.appendChild(content);
this.container.appendChild(overlay);
}
showCombatLog(log) {
if (!this.notificationArea) return;
const isHit = log.hitSuccess;
const color = isHit ? '#ff4444' : '#aaaaaa';
const title = isHit ? 'GOLPE!' : 'FALLO';
let detailHtml = '';
if (isHit) {
if (log.woundsCaused > 0) {
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
} else {
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
}
} else {
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
}
// Show simplified but impactful message
this.notificationArea.innerHTML = `
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 2px solid ${color}; border-radius: 5px; text-align: center; min-width: 250px;">
<div style="font-family: 'Cinzel'; font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
${detailHtml}
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
</div>
`;
this.notificationArea.style.opacity = '1';
// Update hero card if defender is a hero
const defender = this.game.heroes.find(h => h.id === log.defenderId) ||
this.game.monsters.find(m => m.id === log.defenderId);
if (defender && defender.type === 'hero') {
this.updateHeroCard(defender.id);
}
setTimeout(() => {
if (this.notificationArea) this.notificationArea.style.opacity = '0';
}, 3500);
}
showConfirm(title, message, onConfirm) {
// Overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.pointerEvents = 'auto'; // Block clicks behind
overlay.style.zIndex = '1000';
// Content Box
const content = document.createElement('div');
content.style.backgroundColor = '#222';
content.style.border = '2px solid #888';
content.style.borderRadius = '8px';
content.style.padding = '20px';
content.style.width = '300px';
content.style.textAlign = 'center';
content.style.color = '#fff';
content.style.fontFamily = 'sans-serif';
// Title
const titleEl = document.createElement('h2');
titleEl.textContent = title;
titleEl.style.marginTop = '0';
titleEl.style.color = '#f44';
content.appendChild(titleEl);
// Message
const msgEl = document.createElement('p');
msgEl.innerHTML = message;
msgEl.style.fontSize = '16px';
msgEl.style.lineHeight = '1.5';
content.appendChild(msgEl);
// Buttons Container
const buttons = document.createElement('div');
buttons.style.display = 'flex';
buttons.style.justifyContent = 'space-around';
buttons.style.marginTop = '20px';
// Cancel Button
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancelar';
cancelBtn.style.padding = '10px 20px';
cancelBtn.style.fontSize = '16px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.backgroundColor = '#555';
cancelBtn.style.color = '#fff';
cancelBtn.style.border = '1px solid #888';
cancelBtn.onclick = () => {
this.container.removeChild(overlay);
};
buttons.appendChild(cancelBtn);
// Confirm Button
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Aceptar';
confirmBtn.style.padding = '10px 20px';
confirmBtn.style.fontSize = '16px';
confirmBtn.style.cursor = 'pointer';
confirmBtn.style.backgroundColor = '#2a5';
confirmBtn.style.color = '#fff';
confirmBtn.style.border = '1px solid #888';
confirmBtn.onclick = () => {
if (onConfirm) onConfirm();
this.container.removeChild(overlay);
};
buttons.appendChild(confirmBtn);
content.appendChild(buttons);
overlay.appendChild(content);
this.container.appendChild(overlay);
}
createGameStatusPanel() {
// Top Center Panel
this.statusPanel = document.createElement('div');
this.statusPanel.style.position = 'absolute';
this.statusPanel.style.top = '20px';
this.statusPanel.style.left = '50%';
this.statusPanel.style.transform = 'translateX(-50%)';
this.statusPanel.style.display = 'flex';
this.statusPanel.style.flexDirection = 'column';
this.statusPanel.style.alignItems = 'center';
this.statusPanel.style.pointerEvents = 'none';
// Turn/Phase Info
this.phaseInfo = document.createElement('div');
this.phaseInfo.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
this.phaseInfo.style.padding = '10px 20px';
this.phaseInfo.style.border = '2px solid #daa520'; // GoldenRod
this.phaseInfo.style.borderRadius = '5px';
this.phaseInfo.style.color = '#fff';
this.phaseInfo.style.fontFamily = '"Cinzel", serif';
this.phaseInfo.style.fontSize = '20px';
this.phaseInfo.style.textAlign = 'center';
this.phaseInfo.style.textTransform = 'uppercase';
this.phaseInfo.style.minWidth = '200px';
this.phaseInfo.innerHTML = `
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
<div style="font-size: 24px; color: #daa520;">Setup</div>
`;
this.statusPanel.appendChild(this.phaseInfo);
// End Phase Button
this.endPhaseBtn = document.createElement('button');
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.style.marginTop = '10px';
this.endPhaseBtn.style.width = '100%';
this.endPhaseBtn.style.padding = '8px';
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
this.endPhaseBtn.style.color = '#000';
this.endPhaseBtn.style.border = '1px solid #8B4513';
this.endPhaseBtn.style.borderRadius = '3px';
this.endPhaseBtn.style.fontWeight = 'bold';
this.endPhaseBtn.style.cursor = 'pointer';
this.endPhaseBtn.style.display = 'none'; // Hidden by default
this.endPhaseBtn.style.fontFamily = '"Cinzel", serif';
this.endPhaseBtn.style.fontSize = '12px';
this.endPhaseBtn.style.pointerEvents = 'auto'; // Enable clicking
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
this.endPhaseBtn.onclick = () => {
console.log('[UIManager] End Phase Button Clicked', this.game.turnManager.currentPhase);
this.game.turnManager.nextPhase();
};
this.statusPanel.appendChild(this.endPhaseBtn);
// Notification Area (Power Roll results, etc)
this.notificationArea = document.createElement('div');
this.notificationArea.style.marginTop = '10px';
this.notificationArea.style.transition = 'opacity 0.5s';
this.notificationArea.style.opacity = '0';
this.statusPanel.appendChild(this.notificationArea);
this.container.appendChild(this.statusPanel);
// Inject Font
if (!document.getElementById('game-font')) {
const link = document.createElement('link');
link.id = 'game-font';
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
}
setupGameListeners() {
if (this.game.turnManager) {
this.game.turnManager.on('phase_changed', (phase) => {
this.updatePhaseDisplay(phase);
});
this.game.turnManager.on('POWER_RESULT', (data) => {
this.showPowerRollResult(data);
});
}
}
updatePhaseDisplay(phase) {
if (!this.phaseInfo) return;
const turn = this.game.turnManager.currentTurn;
let content = `
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
`;
if (this.selectedHero) {
content += this.getHeroStatsHTML(this.selectedHero);
}
this.phaseInfo.innerHTML = content;
if (this.endPhaseBtn) {
if (phase === 'hero') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
} else if (phase === 'monster') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS';
this.endPhaseBtn.title = "Pasar a Fase de Exploración";
} else if (phase === 'exploration') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR TURNO';
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
} else {
this.endPhaseBtn.style.display = 'none';
}
}
}
updateHeroStats(hero) {
if (!this.phaseInfo) return;
const turn = this.game.turnManager.currentTurn;
const phase = this.game.turnManager.currentPhase;
let content = `
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
`;
if (hero) {
content += this.getHeroStatsHTML(hero);
}
this.phaseInfo.innerHTML = content;
}
getHeroStatsHTML(hero) {
const portraitUrl = hero.texturePath || '';
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
return `
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
</div>
<div style="text-align: left;">
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
${hero.name} ${lanternIcon}
</div>
<div style="font-size: 14px;">
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
</div>
</div>
</div>
`;
}
showPowerRollResult(data) {
if (!this.notificationArea) return;
const { roll, message, eventTriggered } = data;
const color = eventTriggered ? '#ff4444' : '#44ff44';
this.notificationArea.innerHTML = `
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
<div style="font-size: 14px; color: #ccc;">${message}</div>
</div>
`;
this.notificationArea.style.opacity = '1';
setTimeout(() => {
if (this.notificationArea) this.notificationArea.style.opacity = '0';
}, 3000);
}
}

View 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;
}
}

View 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
}
}

View 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;
}
}

View 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();
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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;
}
}
}