Compare commits
9 Commits
combat-eng
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e45207807d | |||
| 85a390b94a | |||
| 0685c1249e | |||
| f2f399c296 | |||
| df3f892eb2 | |||
| 5c5cc13903 | |||
| 180cf3ab94 | |||
| 377096c530 | |||
| 61c7cc3313 |
154
DEVLOG.md
154
DEVLOG.md
@@ -1,5 +1,159 @@
|
||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
- [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)
|
||||
- [ ] Refine Combat System (Ranged weapons, Special Monster Rules, Magic)
|
||||
- [x] Refine Combat System (Ranged weapons, Area Magic, Damage Feedback)
|
||||
- [x] Implement Audio System (SFX, Footsteps, Ambience)
|
||||
- [x] UI Improvements (Spanish Stats, Tooltips)
|
||||
|
||||
## Phase 4: Campaign System
|
||||
- [ ] **Campaign Manager**
|
||||
|
||||
BIN
public/assets/images/dungeon1/spells/attack_template.png
Normal file
BIN
public/assets/images/dungeon1/spells/attack_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/assets/images/dungeon1/spells/defense_template.png
Normal file
BIN
public/assets/images/dungeon1/spells/defense_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/assets/images/dungeon1/spells/healing_template.png
Normal file
BIN
public/assets/images/dungeon1/spells/healing_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/assets/music/ingame/Abandoned_Ruins.mp3
Normal file
BIN
public/assets/music/ingame/Abandoned_Ruins.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/arrow.mp3
Normal file
BIN
public/assets/sfx/arrow.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/footsteps.mp3
Normal file
BIN
public/assets/sfx/footsteps.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/opendoor.mp3
Normal file
BIN
public/assets/sfx/opendoor.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/sword1.mp3
Normal file
BIN
public/assets/sfx/sword1.mp3
Normal file
Binary file not shown.
38
src/engine/data/Spells.js
Normal file
38
src/engine/data/Spells.js
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
export const SPELLS = [
|
||||
{
|
||||
id: 'fireball',
|
||||
name: 'Bola de Fuego',
|
||||
type: 'attack',
|
||||
cost: 5,
|
||||
range: 12, // Arbitrary line of sight
|
||||
damageDice: 1,
|
||||
damageBonus: 'hero_level', // Dynamic logic
|
||||
area: 2, // 2x2
|
||||
description: "Elige un área de 2x2 casillas en línea de visión. Cada miniatura sufre 1D6 + Nivel herois."
|
||||
},
|
||||
{
|
||||
id: 'iron_skin',
|
||||
name: 'Piel de Hierro',
|
||||
type: 'defense',
|
||||
cost: 1,
|
||||
range: 'board', // Anywhere on board
|
||||
target: 'single_hero', // Needs selection
|
||||
effect: {
|
||||
stat: 'toughness',
|
||||
value: 2,
|
||||
duration: 1
|
||||
},
|
||||
description: "Elige a un Aventurero. +2 a Resistencia durante este turno."
|
||||
},
|
||||
{
|
||||
id: 'healing_hands',
|
||||
name: 'Manos Curadoras',
|
||||
type: 'heal',
|
||||
cost: 2,
|
||||
range: 'board', // Same board section
|
||||
healAmount: 1,
|
||||
target: 'all_heroes',
|
||||
description: "Todos los Aventureros en la misma sección de tablero recuperan 1 Herida."
|
||||
}
|
||||
];
|
||||
@@ -15,11 +15,9 @@ export class DungeonDeck {
|
||||
// 1. Create a "Pool" of standard dungeon tiles
|
||||
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 => {
|
||||
|
||||
101
src/engine/game/CombatSystem.js
Normal file
101
src/engine/game/CombatSystem.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
|
||||
export class CombatSystem {
|
||||
constructor(gameEngine) {
|
||||
this.game = gameEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the complete flow of a Melee Attack request
|
||||
* @param {Object} attacker
|
||||
* @param {Object} defender
|
||||
* @returns {Object} Result object { success: boolean, result: logObject, reason: string }
|
||||
*/
|
||||
handleMeleeAttack(attacker, defender) {
|
||||
// 1. Validations
|
||||
if (!attacker || !defender) return { success: false, reason: 'invalid_target' };
|
||||
|
||||
// Check Phase (Hero Phase for heroes)
|
||||
// Note: Monsters use this too, but their phase check is in AI loop.
|
||||
// We might want to enforce "Monster Phase" check here later if we pass 'source' context.
|
||||
if (attacker.type === 'hero' && this.game.turnManager.currentPhase !== 'hero') {
|
||||
return { success: false, reason: 'phase' };
|
||||
}
|
||||
|
||||
// Check Action Economy (Cooldown)
|
||||
if (attacker.hasAttacked) {
|
||||
return { success: false, reason: 'cooldown' };
|
||||
}
|
||||
|
||||
// Check Adjacency (Melee Range)
|
||||
// Logic: Manhattan distance == 1
|
||||
const dx = Math.abs(attacker.x - defender.x);
|
||||
const dy = Math.abs(attacker.y - defender.y);
|
||||
if (dx + dy !== 1) {
|
||||
return { success: false, reason: 'range' };
|
||||
}
|
||||
|
||||
// 2. Execution (Math)
|
||||
// Calls the pure math module
|
||||
const result = CombatMechanics.resolveMeleeAttack(attacker, defender, this.game);
|
||||
|
||||
// 3. Update State
|
||||
attacker.hasAttacked = true;
|
||||
|
||||
// 4. Side Effects (Sound, UI Events)
|
||||
if (window.SOUND_MANAGER) {
|
||||
// Logic to choose sound could be expanded here based on Weapon Type
|
||||
window.SOUND_MANAGER.playSound('sword');
|
||||
}
|
||||
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
}
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the complete flow of a Ranged Attack request
|
||||
* @param {Object} attacker
|
||||
* @param {Object} defender
|
||||
* @returns {Object} Result object
|
||||
*/
|
||||
handleRangedAttack(attacker, defender) {
|
||||
if (!attacker || !defender) return { success: false, reason: 'invalid_target' };
|
||||
|
||||
// 1. Validations
|
||||
if (attacker.type === 'hero' && this.game.turnManager.currentPhase !== 'hero') {
|
||||
return { success: false, reason: 'phase' };
|
||||
}
|
||||
|
||||
if (attacker.hasAttacked) {
|
||||
return { success: false, reason: 'cooldown' };
|
||||
}
|
||||
|
||||
// Check "Pinned" Status (Can't shoot if enemies are adjacent)
|
||||
// Using GameEngine's helper for now as it holds entity lists
|
||||
if (this.game.isEntityPinned(attacker)) {
|
||||
return { success: false, reason: 'pinned' };
|
||||
}
|
||||
|
||||
// Line of Sight is assumed checked by UI/Input, but we could enforce it here if strict.
|
||||
|
||||
// 2. Execution (Math)
|
||||
const result = CombatMechanics.resolveRangedAttack(attacker, defender, this.game);
|
||||
|
||||
// 3. Update State
|
||||
attacker.hasAttacked = true;
|
||||
|
||||
// 4. Side Effects
|
||||
if (window.SOUND_MANAGER) {
|
||||
window.SOUND_MANAGER.playSound('arrow');
|
||||
}
|
||||
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
}
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
||||
import { TurnManager } from './TurnManager.js';
|
||||
import { MonsterAI } from './MonsterAI.js';
|
||||
import { MagicSystem } from './MagicSystem.js';
|
||||
import { CombatSystem } from './CombatSystem.js';
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
||||
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
||||
@@ -14,6 +16,8 @@ export class GameEngine {
|
||||
this.dungeon = new DungeonGenerator();
|
||||
this.turnManager = new TurnManager();
|
||||
this.ai = new MonsterAI(this); // Init AI
|
||||
this.magicSystem = new MagicSystem(this); // Init Magic
|
||||
this.combatSystem = new CombatSystem(this); // Init Combat
|
||||
this.player = null;
|
||||
this.selectedEntity = null;
|
||||
this.isRunning = false;
|
||||
@@ -49,6 +53,11 @@ export class GameEngine {
|
||||
this.resetHeroMoves();
|
||||
}
|
||||
});
|
||||
|
||||
// End of Turn Logic (Buffs, cooldowns, etc)
|
||||
this.turnManager.on('turn_ended', (turn) => {
|
||||
this.handleEndTurn();
|
||||
});
|
||||
}
|
||||
|
||||
resetHeroMoves() {
|
||||
@@ -60,6 +69,49 @@ export class GameEngine {
|
||||
console.log("Refilled Hero Moves");
|
||||
}
|
||||
|
||||
handleEndTurn() {
|
||||
console.log("[GameEngine] Handling End of Turn Effects...");
|
||||
|
||||
if (!this.heroes) return;
|
||||
|
||||
this.heroes.forEach(hero => {
|
||||
if (hero.buffs && hero.buffs.length > 0) {
|
||||
// Decrement duration
|
||||
hero.buffs.forEach(buff => {
|
||||
buff.duration--;
|
||||
});
|
||||
|
||||
// Remove expired
|
||||
const activeBuffs = [];
|
||||
const expiredBuffs = [];
|
||||
|
||||
hero.buffs.forEach(buff => {
|
||||
if (buff.duration > 0) {
|
||||
activeBuffs.push(buff);
|
||||
} else {
|
||||
expiredBuffs.push(buff);
|
||||
}
|
||||
});
|
||||
|
||||
// Revert expired
|
||||
expiredBuffs.forEach(buff => {
|
||||
if (buff.stat === 'toughness') {
|
||||
hero.stats.toughness -= buff.value;
|
||||
if (hero.tempStats && hero.tempStats.toughnessBonus) {
|
||||
hero.tempStats.toughnessBonus -= buff.value;
|
||||
}
|
||||
console.log(`[GameEngine] Buff expired: ${buff.id} on ${hero.name}. -${buff.value} ${buff.stat}`);
|
||||
if (this.onShowMessage) {
|
||||
this.onShowMessage("Efecto Finalizado", `La ${buff.id === 'iron_skin' ? 'Piel de Hierro' : 'Magia'} de ${hero.name} se desvanece.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hero.buffs = activeBuffs;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createParty() {
|
||||
this.heroes = [];
|
||||
this.monsters = []; // Initialize monsters array
|
||||
@@ -147,7 +199,88 @@ export class GameEngine {
|
||||
return monster;
|
||||
}
|
||||
|
||||
onCellHover(x, y) {
|
||||
if (this.targetingMode === 'spell' && this.currentSpell) {
|
||||
const area = this.currentSpell.area || 1;
|
||||
const cells = [];
|
||||
|
||||
if (area === 2) {
|
||||
cells.push({ x: x, y: y });
|
||||
cells.push({ x: x + 1, y: y });
|
||||
cells.push({ x: x, y: y + 1 });
|
||||
cells.push({ x: x + 1, y: y + 1 });
|
||||
} else {
|
||||
cells.push({ x: x, y: y });
|
||||
}
|
||||
|
||||
// LOS Check for Color
|
||||
let color = 0xffffff; // Default White
|
||||
const caster = this.selectedEntity;
|
||||
if (caster) {
|
||||
// Check LOS to the center/anchor cell (x,y)
|
||||
const targetObj = { x: x, y: y };
|
||||
const los = this.checkLineOfSightStrict(caster, targetObj);
|
||||
|
||||
if (los && los.clear) {
|
||||
color = 0x00ff00; // Green (Good)
|
||||
} else {
|
||||
color = 0xff0000; // Red (Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// Show Preview
|
||||
if (window.RENDERER) {
|
||||
window.RENDERER.showAreaPreview(cells, color);
|
||||
}
|
||||
} else {
|
||||
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||
}
|
||||
}
|
||||
|
||||
onCellClick(x, y) {
|
||||
// SPELL TARGETING LOGIC
|
||||
if (this.targetingMode === 'spell' && this.currentSpell) {
|
||||
const area = this.currentSpell.area || 1;
|
||||
const targetCells = [];
|
||||
|
||||
if (area === 2) {
|
||||
targetCells.push({ x: x, y: y });
|
||||
targetCells.push({ x: x + 1, y: y });
|
||||
targetCells.push({ x: x, y: y + 1 });
|
||||
targetCells.push({ x: x + 1, y: y + 1 });
|
||||
} else {
|
||||
targetCells.push({ x: x, y: y });
|
||||
}
|
||||
|
||||
// NEW: Enforce LOS Check before execution
|
||||
const caster = this.selectedEntity;
|
||||
if (caster) {
|
||||
const targetObj = { x: x, y: y };
|
||||
const los = this.checkLineOfSightStrict(caster, targetObj);
|
||||
if (!los || !los.clear) {
|
||||
if (this.onShowMessage) this.onShowMessage('Bloqueado', 'No tienes línea de visión.');
|
||||
// Do NOT cancel targeting, let them try again
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute Spell
|
||||
const result = this.executeSpell(this.currentSpell, targetCells);
|
||||
|
||||
if (result.success) {
|
||||
// Success
|
||||
this.cancelTargeting();
|
||||
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||
} else {
|
||||
if (this.onShowMessage) this.onShowMessage('Fallo', result.reason || 'No se pudo lanzar el hechizo.');
|
||||
this.cancelTargeting(); // Cancel on error? maybe keep open? usually cancel.
|
||||
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||
}
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// RANGED TARGETING LOGIC
|
||||
if (this.targetingMode === 'ranged') {
|
||||
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||
@@ -236,27 +369,38 @@ export class GameEngine {
|
||||
performHeroAttack(targetMonsterId) {
|
||||
const hero = this.selectedEntity;
|
||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||
return this.combatSystem.handleMeleeAttack(hero, monster);
|
||||
}
|
||||
|
||||
if (!hero || !monster) return null;
|
||||
performRangedAttack(targetMonsterId) {
|
||||
const hero = this.selectedEntity;
|
||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||
return this.combatSystem.handleRangedAttack(hero, monster);
|
||||
}
|
||||
|
||||
// Check Phase
|
||||
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
||||
canCastSpell(spell) {
|
||||
return this.magicSystem.canCastSpell(this.selectedEntity, spell);
|
||||
}
|
||||
|
||||
// Check Adjacency
|
||||
const dx = Math.abs(hero.x - monster.x);
|
||||
const dy = Math.abs(hero.y - monster.y);
|
||||
if (dx + dy !== 1) return { success: false, reason: 'range' };
|
||||
executeSpell(spell, targetCells = []) {
|
||||
if (!this.selectedEntity) return { success: false, reason: 'no_caster' };
|
||||
return this.magicSystem.executeSpell(this.selectedEntity, spell, targetCells);
|
||||
}
|
||||
|
||||
// Check Action Economy
|
||||
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
||||
deselectEntity() {
|
||||
if (!this.selectedEntity) return;
|
||||
const id = this.selectedEntity.id;
|
||||
this.selectedEntity = null;
|
||||
this.plannedPath = [];
|
||||
if (this.onEntitySelect) this.onEntitySelect(id, false);
|
||||
if (this.onPathChange) this.onPathChange([]);
|
||||
|
||||
// Execute Attack
|
||||
const result = CombatMechanics.resolveMeleeAttack(hero, monster, this);
|
||||
hero.hasAttacked = true;
|
||||
|
||||
if (this.onCombatResult) this.onCombatResult(result);
|
||||
|
||||
return { success: true, result };
|
||||
// Also deselect monster if selected
|
||||
if (this.selectedMonster) {
|
||||
const monsterId = this.selectedMonster.id;
|
||||
this.selectedMonster = null;
|
||||
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
|
||||
}
|
||||
}
|
||||
|
||||
isEntityPinned(entity) {
|
||||
@@ -293,43 +437,6 @@ export class GameEngine {
|
||||
});
|
||||
}
|
||||
|
||||
performRangedAttack(targetMonsterId) {
|
||||
const hero = this.selectedEntity;
|
||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||
|
||||
if (!hero || !monster) return null;
|
||||
|
||||
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
||||
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
||||
if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' };
|
||||
|
||||
// LOS Check should be done before calling this, but we can double check or assume UI did it.
|
||||
// For simplicity, we execute the attack here assuming validation passed.
|
||||
|
||||
const result = CombatMechanics.resolveRangedAttack(hero, monster, this);
|
||||
hero.hasAttacked = true;
|
||||
|
||||
if (this.onCombatResult) this.onCombatResult(result);
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
deselectEntity() {
|
||||
if (!this.selectedEntity) return;
|
||||
const id = this.selectedEntity.id;
|
||||
this.selectedEntity = null;
|
||||
this.plannedPath = [];
|
||||
if (this.onEntitySelect) this.onEntitySelect(id, false);
|
||||
if (this.onPathChange) this.onPathChange([]);
|
||||
|
||||
// Also deselect monster if selected
|
||||
if (this.selectedMonster) {
|
||||
const monsterId = this.selectedMonster.id;
|
||||
this.selectedMonster = null;
|
||||
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Alias for legacy calls if any
|
||||
deselectPlayer() {
|
||||
this.deselectEntity();
|
||||
@@ -642,8 +749,16 @@ export class GameEngine {
|
||||
console.log("Ranged Targeting Mode ON");
|
||||
}
|
||||
|
||||
startSpellTargeting(spell) {
|
||||
this.targetingMode = 'spell';
|
||||
this.currentSpell = spell;
|
||||
console.log(`Spell Targeting Mode ON: ${spell.name}`);
|
||||
if (this.onShowMessage) this.onShowMessage(spell.name, 'Selecciona el objetivo (Monstruo o Casilla).');
|
||||
}
|
||||
|
||||
cancelTargeting() {
|
||||
this.targetingMode = null;
|
||||
this.currentSpell = null;
|
||||
if (this.onRangedTarget) {
|
||||
this.onRangedTarget(null, null);
|
||||
}
|
||||
@@ -809,6 +924,9 @@ export class GameEngine {
|
||||
|
||||
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
||||
|
||||
let prevX = null;
|
||||
let prevY = null;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
path.push({ x: currentX, y: currentY });
|
||||
|
||||
@@ -816,32 +934,97 @@ export class GameEngine {
|
||||
const isEnd = (currentX === target.x && currentY === target.y);
|
||||
|
||||
if (!isStart && !isEnd) {
|
||||
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||
// WALL CHECK: Use Connectvity (canMoveBetween)
|
||||
// This detects walls between tiles even if both tiles are floor.
|
||||
// It also detects VOID cells (because canMoveBetween returns false if destination is void).
|
||||
if (prevX !== null) {
|
||||
if (!this.dungeon.grid.canMoveBetween(prevX, prevY, currentX, currentY)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||
console.log(`[LOS] Blocked by WALL/BORDER between ${prevX},${prevY} and ${currentX},${currentY}`);
|
||||
break;
|
||||
}
|
||||
} else if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||
// Fallback for start/isolated case (should rarely happen for LOS path)
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||
console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Helper: Distance from Cell Center to Ray (for grazing tolerance)
|
||||
const getDist = () => {
|
||||
const cx = currentX + 0.5;
|
||||
const cy = currentY + 0.5;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len === 0) return 0;
|
||||
return Math.abs(dy * cx - dx * cy + dx * y1 - dy * x1) / len;
|
||||
};
|
||||
|
||||
// Tolerance: Allow shots to pass if they graze the edge (0.5 is full width)
|
||||
// 0.4 means the outer 20% of the tile is "safe" to shoot through.
|
||||
const ENTITY_HITBOX_RADIUS = 0.4;
|
||||
|
||||
// 2. Monster Check
|
||||
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||
if (m) {
|
||||
blocked = true;
|
||||
blocker = { type: 'monster', entity: m };
|
||||
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
||||
break;
|
||||
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||
blocked = true;
|
||||
blocker = { type: 'monster', entity: m };
|
||||
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
||||
break;
|
||||
} else {
|
||||
console.log(`[LOS] Grazed MONSTER ${m.name} (Dist: ${getDist().toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hero Check
|
||||
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||
if (h) {
|
||||
blocked = true;
|
||||
blocker = { type: 'hero', entity: h };
|
||||
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
||||
break;
|
||||
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||
blocked = true;
|
||||
blocker = { type: 'hero', entity: h };
|
||||
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
||||
break;
|
||||
} else {
|
||||
console.log(`[LOS] Grazed HERO ${h.name} (Dist: ${getDist().toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX === endX && currentY === endY) break;
|
||||
|
||||
// CORNER CROSSING CHECK: Prevent diagonal wall leaking
|
||||
// When tMaxX ≈ tMaxY, the ray passes through a vertex shared by 4 cells.
|
||||
// Standard algorithm only visits 2 of them. We must check BOTH neighbors.
|
||||
const CORNER_EPSILON = 0.001;
|
||||
const cornerCrossing = Math.abs(tMaxX - tMaxY) < CORNER_EPSILON;
|
||||
|
||||
if (cornerCrossing) {
|
||||
// Check connectivity to both orthogonal neighbors
|
||||
const neighborX = currentX + stepX;
|
||||
const neighborY = currentY + stepY;
|
||||
|
||||
// Check horizontal neighbor connectivity
|
||||
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, neighborX, currentY)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: neighborX, y: currentY };
|
||||
console.log(`[LOS] Blocked by CORNER WALL (H) at ${neighborX},${currentY}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check vertical neighbor connectivity
|
||||
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, currentX, neighborY)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: currentX, y: neighborY };
|
||||
console.log(`[LOS] Blocked by CORNER WALL (V) at ${currentX},${neighborY}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Previous
|
||||
prevX = currentX;
|
||||
prevY = currentY;
|
||||
|
||||
if (tMaxX < tMaxY) {
|
||||
tMaxX += tDeltaX;
|
||||
currentX += stepX;
|
||||
|
||||
224
src/engine/game/MagicSystem.js
Normal file
224
src/engine/game/MagicSystem.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
|
||||
export class MagicSystem {
|
||||
constructor(gameEngine) {
|
||||
this.game = gameEngine;
|
||||
}
|
||||
|
||||
canCastSpell(caster, spell) {
|
||||
if (!caster || !spell) return false;
|
||||
|
||||
// 1. Check Class/Role Restriction
|
||||
// For now hardcoded validation, but could be part of Spell definition (e.g. spell.classes.includes(caster.key))
|
||||
if (caster.key !== 'wizard') return false;
|
||||
|
||||
// 2. Check Phase
|
||||
if (this.game.turnManager.currentPhase !== 'hero') return false;
|
||||
|
||||
// 3. Check Cost vs Power
|
||||
// Assuming TurnManager has a way to check available power
|
||||
const availablePower = this.game.turnManager.power;
|
||||
if (availablePower < spell.cost) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
executeSpell(caster, spell, targetCells = []) {
|
||||
if (!this.canCastSpell(caster, spell)) {
|
||||
return { success: false, reason: 'validation_failed' };
|
||||
}
|
||||
|
||||
console.log(`[MagicSystem] Casting ${spell.name} by ${caster.name}`);
|
||||
|
||||
// Dispatch based on Spell Type
|
||||
// We could also look up a specific handler function map if this grows
|
||||
if (spell.type === 'heal') {
|
||||
return this.resolveHeal(caster, spell);
|
||||
} else if (spell.type === 'attack') {
|
||||
return this.resolveAttack(caster, spell, targetCells);
|
||||
} else if (spell.type === 'defense') {
|
||||
return this.resolveDefense(caster, spell, targetCells);
|
||||
}
|
||||
|
||||
return { success: false, reason: 'unknown_spell_type' };
|
||||
}
|
||||
|
||||
resolveHeal(caster, spell) {
|
||||
// Default Logic: Heal all heroes in same section (simplified to all heroes)
|
||||
let totalHealed = 0;
|
||||
|
||||
this.game.heroes.forEach(h => {
|
||||
// Check if wounded
|
||||
if (h.currentWounds < h.stats.wounds) {
|
||||
const amount = spell.healAmount || 1;
|
||||
const oldWounds = h.currentWounds;
|
||||
h.currentWounds = Math.min(h.currentWounds + amount, h.stats.wounds);
|
||||
|
||||
const healed = h.currentWounds - oldWounds;
|
||||
if (healed > 0) {
|
||||
totalHealed += healed;
|
||||
if (this.game.onShowMessage) {
|
||||
this.game.onShowMessage('Curación', `${h.name} recupera ${healed} herida(s).`);
|
||||
}
|
||||
// Visuals
|
||||
if (window.RENDERER) {
|
||||
window.RENDERER.triggerVisualEffect('heal', h.x, h.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, type: 'heal', healedCount: totalHealed };
|
||||
}
|
||||
|
||||
resolveAttack(caster, spell, targetCells) {
|
||||
const level = caster.level || 1;
|
||||
|
||||
// 1. Calculate Center of Impact
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
targetCells.forEach(c => {
|
||||
if (c.x < minX) minX = c.x;
|
||||
if (c.x > maxX) maxX = c.x;
|
||||
if (c.y < minY) minY = c.y;
|
||||
if (c.y > maxY) maxY = c.y;
|
||||
});
|
||||
|
||||
// Exact center of the group
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
// 2. Launch Projectile
|
||||
if (window.RENDERER) {
|
||||
window.RENDERER.triggerProjectile(caster.x, caster.y, centerX, centerY, () => {
|
||||
|
||||
// --- IMPACT CALLBACK ---
|
||||
|
||||
// 3. Central Explosion
|
||||
window.RENDERER.triggerVisualEffect('fireball', centerX, centerY);
|
||||
|
||||
// 4. Apply Damage to all targets
|
||||
let hits = 0;
|
||||
targetCells.forEach(cell => {
|
||||
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
|
||||
|
||||
if (monster) {
|
||||
const damageDice = spell.damageDice || 1;
|
||||
let damageTotal = level;
|
||||
for (let i = 0; i < damageDice; i++) {
|
||||
damageTotal += Math.floor(Math.random() * 6) + 1;
|
||||
}
|
||||
|
||||
// Apply Damage
|
||||
CombatMechanics.applyDamage(monster, damageTotal, this.game);
|
||||
hits++;
|
||||
|
||||
// Feedback
|
||||
if (this.game.onEntityHit) {
|
||||
this.game.onEntityHit(monster.id);
|
||||
}
|
||||
|
||||
// Use Centralized Combat Feedback
|
||||
window.RENDERER.showCombatFeedback(monster.x, monster.y, damageTotal, true);
|
||||
|
||||
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage.`);
|
||||
|
||||
// Check Death (Handled by events usually, but ensuring cleanup if needed)
|
||||
if (monster.currentWounds <= 0 && !monster.isDead) {
|
||||
monster.isDead = true;
|
||||
if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Fallback for no renderer (tests?) or race condition
|
||||
// Just apply damage immediately logic (duplicated for brevity check)
|
||||
let hits = 0;
|
||||
targetCells.forEach(cell => {
|
||||
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
|
||||
if (monster) {
|
||||
const damageDice = spell.damageDice || 1;
|
||||
let damageTotal = level;
|
||||
for (let i = 0; i < damageDice; i++) {
|
||||
damageTotal += Math.floor(Math.random() * 6) + 1;
|
||||
}
|
||||
CombatMechanics.applyDamage(monster, damageTotal, this.game);
|
||||
hits++;
|
||||
if (this.game.onEntityHit) {
|
||||
this.game.onEntityHit(monster.id);
|
||||
}
|
||||
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage (no renderer).`);
|
||||
if (monster.currentWounds <= 0 && !monster.isDead) {
|
||||
monster.isDead = true;
|
||||
if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, type: 'attack', hits: 1 }; // Return success immediately
|
||||
}
|
||||
resolveDefense(caster, spell, targetCells) {
|
||||
// Needs a target hero
|
||||
let targetHero = null;
|
||||
|
||||
// Find hero in target cells
|
||||
for (const cell of targetCells) {
|
||||
const h = this.game.heroes.find(h => h.x === cell.x && h.y === cell.y);
|
||||
if (h) {
|
||||
targetHero = h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetHero) {
|
||||
return { success: false, reason: 'no_target_hero' };
|
||||
}
|
||||
|
||||
const effect = spell.effect;
|
||||
if (!effect) return { success: false };
|
||||
|
||||
// Apply Buff
|
||||
if (effect.stat === 'toughness') {
|
||||
// Store original if not already stored (simple buffering)
|
||||
if (!targetHero.tempStats) targetHero.tempStats = {};
|
||||
|
||||
// Stackable? Probably not for same spell.
|
||||
// Check if already has this buff?
|
||||
// For simplicity: Add modifier
|
||||
if (!targetHero.tempStats.toughnessBonus) targetHero.tempStats.toughnessBonus = 0;
|
||||
|
||||
targetHero.tempStats.toughnessBonus += effect.value;
|
||||
// Also modify actual stat for calculation access?
|
||||
// Usually stats are accessed via getter or direct.
|
||||
// If direct property, we modify it and store original?
|
||||
// Let's modify the stat directly for now and trust Turn Manager to revert or track it.
|
||||
// BETTER: modify 'toughness' in stats, store 'buff_iron_skin' in activeBuffs?
|
||||
|
||||
targetHero.stats.toughness += effect.value;
|
||||
|
||||
// Mark for cleanup (Pseudo-implementation for cleanup)
|
||||
if (!targetHero.buffs) targetHero.buffs = [];
|
||||
targetHero.buffs.push({
|
||||
id: spell.id,
|
||||
stat: 'toughness',
|
||||
value: effect.value,
|
||||
duration: effect.duration
|
||||
});
|
||||
|
||||
if (this.game.onShowMessage) {
|
||||
this.game.onShowMessage('Piel de Hierro', `Resistencia de ${targetHero.name} +${effect.value}`);
|
||||
}
|
||||
|
||||
// Visual Effect
|
||||
if (window.RENDERER) {
|
||||
window.RENDERER.triggerVisualEffect('defense_buff', targetHero.x, targetHero.y);
|
||||
// Highlight or keep aura?
|
||||
}
|
||||
|
||||
console.log(`[MagicSystem] Applied ${spell.name} to ${targetHero.name}`);
|
||||
}
|
||||
|
||||
return { success: true, type: 'defense', target: targetHero.name };
|
||||
}
|
||||
}
|
||||
@@ -230,9 +230,8 @@ export class MonsterAI {
|
||||
// Step 2: Attack animation delay (500ms)
|
||||
setTimeout(() => {
|
||||
// Step 3: Trigger hit visual on defender (if hit succeeded)
|
||||
if (result.hitSuccess && this.game.onEntityHit) {
|
||||
this.game.onEntityHit(hero.id);
|
||||
}
|
||||
// Step 3: Trigger hit visual on defender REMOVED (Handled by onCombatResult)
|
||||
|
||||
|
||||
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -11,6 +11,10 @@ export class TurnManager {
|
||||
this.eventsTriggered = [];
|
||||
}
|
||||
|
||||
get power() {
|
||||
return this.currentPowerRoll;
|
||||
}
|
||||
|
||||
startGame() {
|
||||
this.currentTurn = 1;
|
||||
console.log(`--- TURN ${this.currentTurn} START ---`);
|
||||
@@ -84,6 +88,7 @@ export class TurnManager {
|
||||
|
||||
endTurn() {
|
||||
console.log(`--- TURN ${this.currentTurn} END ---`);
|
||||
this.emit('turn_ended', this.currentTurn);
|
||||
this.currentTurn++;
|
||||
this.startPowerPhase();
|
||||
}
|
||||
|
||||
32
src/main.js
32
src/main.js
@@ -2,6 +2,7 @@ import { GameEngine } from './engine/game/GameEngine.js';
|
||||
import { GameRenderer } from './view/GameRenderer.js';
|
||||
import { 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;
|
||||
@@ -97,6 +103,22 @@ game.turnManager.on('phase_changed', (phase) => {
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
game.onEntityMove = (entity, path) => {
|
||||
@@ -222,6 +244,7 @@ const handleClick = (x, y, doorMesh) => {
|
||||
|
||||
// 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;
|
||||
@@ -259,7 +282,16 @@ renderer.setupInteraction(
|
||||
handleClick,
|
||||
() => {
|
||||
// Right Click Handler
|
||||
if (game.targetingMode === 'spell' || game.targetingMode === 'ranged') {
|
||||
game.cancelTargeting();
|
||||
if (window.RENDERER) window.RENDERER.hideAreaPreview();
|
||||
ui.showTemporaryMessage('Cancelado', 'Lanzamiento de hechizo cancelado.', 1000);
|
||||
return;
|
||||
}
|
||||
game.executeMovePath();
|
||||
},
|
||||
(x, y) => {
|
||||
if (game.onCellHover) game.onCellHover(x, y);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -161,12 +161,18 @@ export class CameraManager {
|
||||
if (this.animationProgress >= 1) {
|
||||
this.isAnimating = false;
|
||||
this.camera.position.copy(this.animationTargetPos);
|
||||
if (this.onAnimationComplete) {
|
||||
this.onAnimationComplete();
|
||||
this.onAnimationComplete = null; // Consume callback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fixed Orbit Logic ---
|
||||
setIsoView(direction) {
|
||||
this.lastIsoDirection = direction || DIRECTIONS.NORTH;
|
||||
|
||||
// Rotate camera around target while maintaining isometric angle
|
||||
// Isometric view: 45 degree angle from horizontal
|
||||
const distance = 28; // Distance from target
|
||||
@@ -207,4 +213,31 @@ export class CameraManager {
|
||||
|
||||
this.currentViewAngle = horizontalAngle;
|
||||
}
|
||||
|
||||
toggleViewMode() {
|
||||
if (this.viewMode === '2D') {
|
||||
this.viewMode = '3D';
|
||||
this.setIsoView(this.lastIsoDirection);
|
||||
return true; // Is 3D
|
||||
} else {
|
||||
this.viewMode = '2D';
|
||||
this.setZenithalView();
|
||||
return false; // Is 2D
|
||||
}
|
||||
}
|
||||
|
||||
setZenithalView() {
|
||||
// Top-down view (Zenithal)
|
||||
const height = 40;
|
||||
// Slight Z offset to Ensure North is Up (avoiding gimbal lock with Up=(0,1,0))
|
||||
const x = this.target.x;
|
||||
const z = this.target.z + 0.01;
|
||||
const y = height;
|
||||
|
||||
this.animationStartPos.copy(this.camera.position);
|
||||
this.animationTargetPos.set(x, y, z);
|
||||
this.animationProgress = 0;
|
||||
this.animationStartTime = performance.now();
|
||||
this.isAnimating = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import * as THREE from 'three';
|
||||
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||
import { ParticleManager } from './ParticleManager.js';
|
||||
|
||||
export class GameRenderer {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId) || document.body;
|
||||
this.width = this.container.clientWidth;
|
||||
this.height = this.container.clientHeight;
|
||||
|
||||
// 1. Scene
|
||||
// Scene Setup
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x1a1a1a);
|
||||
this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg
|
||||
|
||||
this.particleManager = new ParticleManager(this.scene); // Init Particles
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000);
|
||||
// 2. Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
@@ -47,6 +54,14 @@ export class GameRenderer {
|
||||
this.rangedGroup = new THREE.Group();
|
||||
this.scene.add(this.rangedGroup);
|
||||
|
||||
this.tokensGroup = new THREE.Group();
|
||||
this.scene.add(this.tokensGroup);
|
||||
|
||||
this.spellPreviewGroup = new THREE.Group();
|
||||
this.scene.add(this.spellPreviewGroup);
|
||||
|
||||
this.tokens = new Map();
|
||||
|
||||
this.entities = new Map();
|
||||
}
|
||||
|
||||
@@ -55,6 +70,10 @@ export class GameRenderer {
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
// Group for floating texts
|
||||
this.floatingTextGroup = new THREE.Group();
|
||||
this.scene.add(this.floatingTextGroup);
|
||||
|
||||
// Directional Light (Sun/Moon - creates shadows)
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
dirLight.position.set(50, 100, 50);
|
||||
@@ -62,7 +81,7 @@ export class GameRenderer {
|
||||
this.scene.add(dirLight);
|
||||
}
|
||||
|
||||
setupInteraction(cameraGetter, onClick, onRightClick) {
|
||||
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null) {
|
||||
const getMousePos = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
return {
|
||||
@@ -71,6 +90,21 @@ export class GameRenderer {
|
||||
};
|
||||
};
|
||||
|
||||
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());
|
||||
@@ -159,6 +193,30 @@ export class GameRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
showAreaPreview(cells, color = 0xffffff) {
|
||||
this.spellPreviewGroup.clear(); // Ensure cleared first
|
||||
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); // Slightly above floor/highlights
|
||||
this.spellPreviewGroup.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
hideAreaPreview() {
|
||||
this.spellPreviewGroup.clear();
|
||||
}
|
||||
|
||||
addEntity(entity) {
|
||||
if (this.entities.has(entity.id)) return;
|
||||
|
||||
@@ -256,29 +314,20 @@ export class GameRenderer {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
|
||||
// 1. Red Halo (Temporary) - MATCHING ATTACKER SIZE (0.3 - 0.4)
|
||||
const hitRingGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const hitRingMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff0000,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.9
|
||||
// 1. Flash Effect (White Flash)
|
||||
mesh.traverse((child) => {
|
||||
if (child.material && child.material.map) { // Texture mesh
|
||||
// Store original color if not stored
|
||||
if (!child.userData.originalColor) {
|
||||
child.userData.originalColor = child.material.color.clone();
|
||||
}
|
||||
// Set to red/white flash
|
||||
child.material.color.setHex(0xff0000);
|
||||
setTimeout(() => {
|
||||
if (child.material) child.material.color.copy(child.userData.originalColor);
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
const hitRing = new THREE.Mesh(hitRingGeom, hitRingMat);
|
||||
hitRing.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Align with floor
|
||||
const h = 1.56;
|
||||
hitRing.position.y = -h / 2 + 0.05;
|
||||
|
||||
hitRing.name = "HitRing";
|
||||
|
||||
mesh.add(hitRing);
|
||||
|
||||
// Remove Red Halo after 1200ms (matching the timing in MonsterAI)
|
||||
setTimeout(() => {
|
||||
if (mesh && hitRing) mesh.remove(hitRing);
|
||||
}, 1200);
|
||||
|
||||
// 2. Shake Animation (800ms)
|
||||
const originalPos = mesh.position.clone();
|
||||
@@ -293,6 +342,89 @@ export class GameRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
// Map Grid Y to World -Z
|
||||
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; // Rectangular
|
||||
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);
|
||||
|
||||
// Position slightly above head (standard height ~1.5)
|
||||
sprite.position.set(x, 2.0, -y);
|
||||
// Small initial random offset for stacking readability
|
||||
sprite.position.x += (Math.random() - 0.5) * 0.2;
|
||||
|
||||
// Scale down to world units
|
||||
sprite.scale.set(2, 1, 1);
|
||||
|
||||
sprite.userData = {
|
||||
startTime: performance.now(),
|
||||
duration: 2000, // 2 seconds life
|
||||
startY: sprite.position.y
|
||||
};
|
||||
|
||||
this.floatingTextGroup.add(sprite);
|
||||
}
|
||||
|
||||
showCombatFeedback(x, y, damage, isHit, defenseText = 'Block') {
|
||||
const entityKey = `${x},${y}`; // Approximate lookup if needed, but we pass coords.
|
||||
// Actually to trigger shake we need entity ID.
|
||||
// We can find entity at X,Y?
|
||||
let entityId = null;
|
||||
for (const [id, mesh] of this.entities.entries()) {
|
||||
// Check approximate position
|
||||
if (Math.abs(mesh.position.x - x) < 0.1 && Math.abs(mesh.position.z - (-y)) < 0.1) {
|
||||
entityId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isHit) {
|
||||
if (damage > 0) {
|
||||
// HIT and DAMAGE
|
||||
this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000');
|
||||
if (entityId) this.triggerDamageEffect(entityId);
|
||||
} else {
|
||||
// HIT but NO DAMAGE (Blocked)
|
||||
this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00');
|
||||
}
|
||||
} else {
|
||||
// MISS
|
||||
this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa');
|
||||
}
|
||||
}
|
||||
triggerDeathAnimation(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
@@ -319,6 +451,7 @@ export class GameRenderer {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
|
||||
moveEntityAlongPath(entity, path) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
@@ -333,10 +466,29 @@ export class GameRenderer {
|
||||
// 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);
|
||||
|
||||
// Sync Token
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(entity.id);
|
||||
if (token) {
|
||||
token.position.set(entity.x, 0.05, -entity.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateAnimations(time) {
|
||||
// Calculate Delta (Approx)
|
||||
if (!this.lastTime) this.lastTime = time;
|
||||
const delta = (time - this.lastTime) / 1000;
|
||||
this.lastTime = time;
|
||||
|
||||
if (this.particleManager) {
|
||||
this.particleManager.update(delta);
|
||||
}
|
||||
|
||||
let isAnyMoving = false;
|
||||
|
||||
this.entities.forEach((mesh, id) => {
|
||||
const data = mesh.userData;
|
||||
|
||||
@@ -350,6 +502,11 @@ export class GameRenderer {
|
||||
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
|
||||
}
|
||||
|
||||
// Check if this entity is contributing to movement sound
|
||||
if (data.isMoving || data.pathQueue.length > 0) {
|
||||
isAnyMoving = true;
|
||||
}
|
||||
|
||||
if (data.isMoving) {
|
||||
const duration = 300; // Hero movement speed (300ms per tile)
|
||||
const elapsed = time - data.startTime;
|
||||
@@ -359,6 +516,15 @@ export class GameRenderer {
|
||||
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);
|
||||
|
||||
// Sync Token
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(id);
|
||||
if (token) {
|
||||
token.position.x = mesh.position.x;
|
||||
token.position.z = mesh.position.z;
|
||||
}
|
||||
}
|
||||
|
||||
// Hop (Botecito)
|
||||
const jumpHeight = 0.5;
|
||||
const baseHeight = 1.56 / 2;
|
||||
@@ -436,14 +602,43 @@ export class GameRenderer {
|
||||
|
||||
|
||||
// IF Finished Sequence (Queue empty)
|
||||
if (data.pathQueue.length === 0) {
|
||||
// Check if it's the player (id 'p1')
|
||||
if (id === 'p1' && this.onHeroFinishedMove) {
|
||||
if (data.pathQueue.length === 0 && !data.isMoving) { // Ensure strict finished state
|
||||
// Check if it's the player (id 'p1') -- NOTE: ID might be hero_barbarian etc.
|
||||
// Using generic callback
|
||||
if (id === 'p1' && this.onHeroFinishedMove) { // Legacy check?
|
||||
// Grid Coords from World Coords (X, -Z)
|
||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Footsteps Audio Globally
|
||||
if (window.SOUND_MANAGER) {
|
||||
if (isAnyMoving) {
|
||||
window.SOUND_MANAGER.startLoop('footsteps');
|
||||
} else {
|
||||
window.SOUND_MANAGER.stopLoop('footsteps');
|
||||
}
|
||||
}
|
||||
}
|
||||
renderExits(exits) {
|
||||
// Cancel any pending render
|
||||
@@ -464,13 +659,13 @@ export class GameRenderer {
|
||||
this.exitGroup.children.forEach(child => {
|
||||
if (child.userData.isDoor) {
|
||||
child.userData.cells.forEach(cell => {
|
||||
existingDoorCells.add(`${cell.x},${cell.y}`);
|
||||
existingDoorCells.add(`${cell.x},${cell.y} `);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out exits that already have doors
|
||||
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y}`));
|
||||
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y} `));
|
||||
|
||||
if (newExits.length === 0) {
|
||||
|
||||
@@ -509,7 +704,7 @@ export class GameRenderer {
|
||||
};
|
||||
|
||||
newExits.forEach((ex, i) => {
|
||||
const key = `${ex.x},${ex.y}`;
|
||||
const key = `${ex.x},${ex.y} `;
|
||||
const exDir = normalizeDir(ex.direction);
|
||||
|
||||
if (processed.has(key)) {
|
||||
@@ -519,7 +714,7 @@ export class GameRenderer {
|
||||
let partner = null;
|
||||
for (let j = i + 1; j < newExits.length; j++) {
|
||||
const other = newExits[j];
|
||||
const otherKey = `${other.x},${other.y}`;
|
||||
const otherKey = `${other.x},${other.y} `;
|
||||
const otherDir = normalizeDir(other.direction);
|
||||
|
||||
if (processed.has(otherKey)) continue;
|
||||
@@ -546,7 +741,7 @@ export class GameRenderer {
|
||||
if (partner) {
|
||||
doors.push([ex, partner]);
|
||||
processed.add(key);
|
||||
processed.add(`${partner.x},${partner.y}`);
|
||||
processed.add(`${partner.x},${partner.y} `);
|
||||
} else {
|
||||
doors.push([ex]);
|
||||
processed.add(key);
|
||||
@@ -602,7 +797,7 @@ export class GameRenderer {
|
||||
direction: dirMap[dir] || 'N'
|
||||
}
|
||||
};
|
||||
mesh.name = `door_${idx}`;
|
||||
mesh.name = `door_${idx} `;
|
||||
|
||||
this.exitGroup.add(mesh);
|
||||
});
|
||||
@@ -660,7 +855,7 @@ export class GameRenderer {
|
||||
},
|
||||
undefined, // onProgress
|
||||
(err) => {
|
||||
console.error(`[GameRenderer] Failed to load texture: ${path}`, err);
|
||||
console.error(`[GameRenderer] Failed to load texture: ${path} `, err);
|
||||
const callbacks = this._pendingTextureRequests.get(path);
|
||||
if (callbacks) {
|
||||
this._pendingTextureRequests.delete(path);
|
||||
@@ -748,7 +943,7 @@ export class GameRenderer {
|
||||
|
||||
});
|
||||
} else {
|
||||
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`);
|
||||
console.warn(`[GameRenderer] details missing for texture render.def: ${!!tileDef}, inst: ${!!tileInstance} `);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,12 +1217,12 @@ export class GameRenderer {
|
||||
preview.variant.exits.forEach(ex => {
|
||||
const gx = x + ex.x;
|
||||
const gy = y + ex.y;
|
||||
exitKeys.add(`${gx},${gy}`);
|
||||
exitKeys.add(`${gx},${gy} `);
|
||||
});
|
||||
}
|
||||
|
||||
cells.forEach(cell => {
|
||||
const key = `${cell.x},${cell.y}`;
|
||||
const key = `${cell.x},${cell.y} `;
|
||||
let color = baseColor;
|
||||
|
||||
// If this cell is an exit, color it Blue
|
||||
@@ -1130,4 +1325,69 @@ export class GameRenderer {
|
||||
// Walls are implicit (Line just turns red and stops/passes through)
|
||||
}
|
||||
}
|
||||
showTokens(heroes, monsters) {
|
||||
this.hideTokens(); // Clear existing (makes invisible)
|
||||
if (this.tokensGroup) this.tokensGroup.visible = true; // Now force visible
|
||||
|
||||
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;
|
||||
|
||||
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 = '';
|
||||
// Ensure filename is safe (though keys usually are)
|
||||
const filename = subType;
|
||||
|
||||
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); // Reset to white to show texture
|
||||
token.material.needsUpdate = true;
|
||||
}, undefined, (err) => {
|
||||
console.warn(`[GameRenderer] 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();
|
||||
}
|
||||
}
|
||||
|
||||
216
src/view/ParticleManager.js
Normal file
216
src/view/ParticleManager.js
Normal file
@@ -0,0 +1,216 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class ParticleManager {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.particles = [];
|
||||
// Optional: Preload textures here if needed, or create them procedurally on canvas
|
||||
}
|
||||
|
||||
createTexture(color, type = 'circle') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (type === 'circle') {
|
||||
const grad = ctx.createRadialGradient(16, 16, 0, 16, 16, 16);
|
||||
grad.addColorStop(0, color);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
} else if (type === 'star') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(16, 0); ctx.lineTo(20, 12);
|
||||
ctx.lineTo(32, 16); ctx.lineTo(20, 20);
|
||||
ctx.lineTo(16, 32); ctx.lineTo(12, 20);
|
||||
ctx.lineTo(0, 16); ctx.lineTo(12, 12);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// Generic Emitter
|
||||
emit(x, y, z, options = {}) {
|
||||
const count = options.count || 10;
|
||||
const color = options.color || '#ffaa00';
|
||||
const speed = options.speed || 0.1;
|
||||
const life = options.life || 1.0; // seconds
|
||||
const type = options.type || 'circle';
|
||||
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: this.createTexture(color, type),
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.position.set(x, y, z);
|
||||
|
||||
// Random velocity
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.random() * Math.PI;
|
||||
const v = (Math.random() * 0.5 + 0.5) * speed;
|
||||
|
||||
sprite.userData = {
|
||||
velocity: new THREE.Vector3(
|
||||
Math.cos(theta) * Math.sin(phi) * v,
|
||||
Math.cos(phi) * v, // Upward bias?
|
||||
Math.sin(theta) * Math.sin(phi) * v
|
||||
),
|
||||
life: life,
|
||||
maxLife: life,
|
||||
scaleSpeed: options.scaleSpeed || 0
|
||||
};
|
||||
|
||||
// Scale variation
|
||||
const startScale = options.scale || 0.5;
|
||||
sprite.scale.setScalar(startScale);
|
||||
sprite.userData.startScale = startScale;
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.particles.push(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
spawnFireballExplosion(x, y) {
|
||||
// World coordinates: x, 0.5, y (assuming y is vertical, wait, 3D grid y is usually z?)
|
||||
// In our game: x is x, y is z (flat), y-up is height.
|
||||
// Let's check coordinates. Usually map x,y maps to 3D x,0,z or x,z, (-y).
|
||||
// GameRenderer uses x, 0, y for positions typically.
|
||||
|
||||
// Emitter
|
||||
this.emit(x, 0.5, y, {
|
||||
count: 20,
|
||||
color: '#ff4400',
|
||||
speed: 0.15,
|
||||
life: 0.8,
|
||||
type: 'circle',
|
||||
scale: 0.8,
|
||||
scaleSpeed: -1.0 // Shrink
|
||||
});
|
||||
this.emit(x, 0.5, y, {
|
||||
count: 10,
|
||||
color: '#ffff00',
|
||||
speed: 0.1,
|
||||
life: 0.5,
|
||||
type: 'circle',
|
||||
scale: 0.5
|
||||
});
|
||||
}
|
||||
|
||||
spawnHealEffect(x, y) {
|
||||
// Upward floating particles
|
||||
const count = 15;
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: this.createTexture('#00ff00', 'star'),
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sprite = new THREE.Sprite(material);
|
||||
// Random spread around center
|
||||
const ox = (Math.random() - 0.5) * 0.6;
|
||||
const oy = (Math.random() - 0.5) * 0.6;
|
||||
|
||||
sprite.position.set(x + ox, 0.2, y + oy);
|
||||
|
||||
sprite.userData = {
|
||||
velocity: new THREE.Vector3(0, 0.05 + Math.random() * 0.05, 0), // Up only
|
||||
life: 1.5,
|
||||
maxLife: 1.5
|
||||
};
|
||||
sprite.scale.setScalar(0.3);
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.particles.push(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
spawnProjectile(startX, startZ, endX, endZ, onHit) {
|
||||
// Simple Projectile (a sprite that moves)
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: this.createTexture('#ffaa00', 'circle'),
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.scale.setScalar(0.4);
|
||||
// Start height 1.5 (caster head level)
|
||||
sprite.position.set(startX, 1.5, startZ);
|
||||
|
||||
const speed = 15.0; // Units per second
|
||||
const dist = Math.sqrt((endX - startX) ** 2 + (endZ - startZ) ** 2);
|
||||
const duration = dist / speed;
|
||||
|
||||
sprite.userData = {
|
||||
isProjectile: true,
|
||||
startPos: new THREE.Vector3(startX, 1.5, startZ),
|
||||
targetPos: new THREE.Vector3(endX, 0.5, endZ), // Target floor/center
|
||||
time: 0,
|
||||
duration: duration,
|
||||
onHit: onHit
|
||||
};
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.particles.push(sprite);
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
for (let i = this.particles.length - 1; i >= 0; i--) {
|
||||
const p = this.particles[i];
|
||||
|
||||
if (p.userData.isProjectile) {
|
||||
p.userData.time += dt;
|
||||
const t = Math.min(1, p.userData.time / p.userData.duration);
|
||||
|
||||
p.position.lerpVectors(p.userData.startPos, p.userData.targetPos, t);
|
||||
|
||||
// Trail effect
|
||||
if (Math.random() > 0.5) {
|
||||
this.emit(p.position.x, p.position.y, p.position.z, {
|
||||
count: 1, color: '#ff4400', life: 0.3, scale: 0.2, speed: 0.05
|
||||
});
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
// Hit!
|
||||
if (p.userData.onHit) p.userData.onHit();
|
||||
this.scene.remove(p);
|
||||
this.particles.splice(i, 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal Particle Update
|
||||
// Move
|
||||
p.position.add(p.userData.velocity);
|
||||
|
||||
// Life
|
||||
p.userData.life -= dt;
|
||||
const progress = 1 - (p.userData.life / p.userData.maxLife);
|
||||
|
||||
// Opacity Fade
|
||||
p.material.opacity = p.userData.life / p.userData.maxLife;
|
||||
|
||||
// Scale Change
|
||||
if (p.userData.scaleSpeed) {
|
||||
const s = Math.max(0.01, p.userData.startScale + p.userData.scaleSpeed * progress);
|
||||
p.scale.setScalar(s);
|
||||
}
|
||||
|
||||
if (p.userData.life <= 0) {
|
||||
this.scene.remove(p);
|
||||
this.particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/view/SoundManager.js
Normal file
142
src/view/SoundManager.js
Normal file
@@ -0,0 +1,142 @@
|
||||
|
||||
export class SoundManager {
|
||||
constructor() {
|
||||
this.musicVolume = 0.3; // Default volume (not too loud)
|
||||
this.sfxVolume = 0.5;
|
||||
this.currentMusic = null;
|
||||
this.isMuted = false;
|
||||
|
||||
// Asset Library
|
||||
this.assets = {
|
||||
music: {
|
||||
'exploration': '/assets/music/ingame/Abandoned_Ruins.mp3'
|
||||
},
|
||||
sfx: {
|
||||
'door_open': '/assets/sfx/opendoor.mp3',
|
||||
'footsteps': '/assets/sfx/footsteps.mp3',
|
||||
'sword': '/assets/sfx/sword1.mp3',
|
||||
'arrow': '/assets/sfx/arrow.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
this.initialized = false;
|
||||
this.activeLoops = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the audio context if needed (browser restriction handling)
|
||||
* Can be called on the first user interaction (click)
|
||||
*/
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
console.log("[SoundManager] Audio System Initialized");
|
||||
}
|
||||
|
||||
playMusic(key) {
|
||||
if (this.isMuted) return;
|
||||
|
||||
const url = this.assets.music[key];
|
||||
if (!url) {
|
||||
console.warn(`[SoundManager] Music track not found: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If same track is playing, do nothing
|
||||
if (this.currentMusic && this.currentMusic.src.includes(url) && !this.currentMusic.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current
|
||||
this.stopMusic();
|
||||
|
||||
// Start new
|
||||
this.currentMusic = new Audio(url);
|
||||
this.currentMusic.loop = true;
|
||||
this.currentMusic.volume = this.musicVolume;
|
||||
|
||||
// Handle autoplay promises
|
||||
const playPromise = this.currentMusic.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(error => {
|
||||
console.log("[SoundManager] Autoplay prevented. Waiting for user interaction.");
|
||||
// We can add a one-time click listener to window to resume if needed
|
||||
const resume = () => {
|
||||
this.currentMusic.play();
|
||||
window.removeEventListener('click', resume);
|
||||
window.removeEventListener('keydown', resume);
|
||||
};
|
||||
window.addEventListener('click', resume);
|
||||
window.addEventListener('keydown', resume);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SoundManager] Playing music: ${key}`);
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.pause();
|
||||
this.currentMusic.currentTime = 0;
|
||||
this.currentMusic = null;
|
||||
}
|
||||
}
|
||||
|
||||
setMusicVolume(vol) {
|
||||
this.musicVolume = Math.max(0, Math.min(1, vol));
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.volume = this.musicVolume;
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.isMuted = !this.isMuted;
|
||||
if (this.isMuted) {
|
||||
if (this.currentMusic) this.currentMusic.pause();
|
||||
} else {
|
||||
if (this.currentMusic) this.currentMusic.play();
|
||||
}
|
||||
return this.isMuted;
|
||||
}
|
||||
|
||||
playSound(key) {
|
||||
if (this.isMuted) return;
|
||||
|
||||
const url = this.assets.sfx[key];
|
||||
if (!url) {
|
||||
console.warn(`[SoundManager] SFX not found: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.volume = this.sfxVolume;
|
||||
// Fire and forget, but catch errors
|
||||
audio.play().catch(e => {
|
||||
// Check if error is NotAllowedError (autoplay) - silently ignore usually for SFX
|
||||
// or log if needed
|
||||
});
|
||||
}
|
||||
|
||||
startLoop(key) {
|
||||
if (this.isMuted) return;
|
||||
if (this.activeLoops.has(key)) return; // Already playing
|
||||
|
||||
const url = this.assets.sfx[key];
|
||||
if (!url) return;
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.loop = true;
|
||||
audio.volume = this.sfxVolume;
|
||||
audio.play().catch(() => { });
|
||||
this.activeLoops.set(key, audio);
|
||||
}
|
||||
|
||||
stopLoop(key) {
|
||||
const audio = this.activeLoops.get(key);
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
this.activeLoops.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
196
src/view/ui/FeedbackUI.js
Normal file
196
src/view/ui/FeedbackUI.js
Normal file
@@ -0,0 +1,196 @@
|
||||
export class FeedbackUI {
|
||||
constructor(parentContainer, game) {
|
||||
this.parentContainer = parentContainer;
|
||||
this.game = game; // Needed for resolving hero names/ids in logs?
|
||||
|
||||
this.combatLogContainer = null;
|
||||
this.initCombatLogContainer();
|
||||
}
|
||||
|
||||
initCombatLogContainer() {
|
||||
this.combatLogContainer = document.createElement('div');
|
||||
Object.assign(this.combatLogContainer.style, {
|
||||
position: 'absolute',
|
||||
top: '140px', // Below the top status panel
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
zIndex: '500' // Below modals
|
||||
});
|
||||
this.parentContainer.appendChild(this.combatLogContainer);
|
||||
}
|
||||
|
||||
showModal(title, message, onClose) {
|
||||
const overlay = document.createElement('div');
|
||||
Object.assign(overlay.style, {
|
||||
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)', display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
pointerEvents: 'auto', zIndex: '1000'
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
Object.assign(content.style, {
|
||||
backgroundColor: '#222', border: '2px solid #888', borderRadius: '8px', padding: '20px',
|
||||
width: '300px', textAlign: 'center', color: '#fff', fontFamily: 'sans-serif'
|
||||
});
|
||||
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
Object.assign(titleEl.style, { marginTop: '0', color: '#f44' });
|
||||
content.appendChild(titleEl);
|
||||
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
Object.assign(msgEl.style, { fontSize: '16px', lineHeight: '1.5' });
|
||||
content.appendChild(msgEl);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Entendido';
|
||||
Object.assign(btn.style, {
|
||||
marginTop: '20px', padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
|
||||
backgroundColor: '#444', color: '#fff', border: '1px solid #888'
|
||||
});
|
||||
btn.onclick = () => {
|
||||
if (overlay.parentNode /** Checks if attached */) this.parentContainer.removeChild(overlay);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
content.appendChild(btn);
|
||||
|
||||
overlay.appendChild(content);
|
||||
this.parentContainer.appendChild(overlay);
|
||||
}
|
||||
|
||||
showConfirm(title, message, onConfirm) {
|
||||
const overlay = document.createElement('div');
|
||||
Object.assign(overlay.style, {
|
||||
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)', display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
pointerEvents: 'auto', zIndex: '1000'
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
Object.assign(content.style, {
|
||||
backgroundColor: '#222', border: '2px solid #888', borderRadius: '8px', padding: '20px',
|
||||
width: '300px', textAlign: 'center', color: '#fff', fontFamily: 'sans-serif'
|
||||
});
|
||||
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
Object.assign(titleEl.style, { marginTop: '0', color: '#f44' });
|
||||
content.appendChild(titleEl);
|
||||
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
Object.assign(msgEl.style, { fontSize: '16px', lineHeight: '1.5' });
|
||||
content.appendChild(msgEl);
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
Object.assign(buttons.style, { display: 'flex', justifyContent: 'space-around', marginTop: '20px' });
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancelar';
|
||||
Object.assign(cancelBtn.style, {
|
||||
padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
|
||||
backgroundColor: '#555', color: '#fff', border: '1px solid #888'
|
||||
});
|
||||
cancelBtn.onclick = () => { this.parentContainer.removeChild(overlay); };
|
||||
buttons.appendChild(cancelBtn);
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = 'Aceptar';
|
||||
Object.assign(confirmBtn.style, {
|
||||
padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
|
||||
backgroundColor: '#2a5', color: '#fff', border: '1px solid #888'
|
||||
});
|
||||
confirmBtn.onclick = () => {
|
||||
if (onConfirm) onConfirm();
|
||||
this.parentContainer.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(confirmBtn);
|
||||
|
||||
content.appendChild(buttons);
|
||||
overlay.appendChild(content);
|
||||
this.parentContainer.appendChild(overlay);
|
||||
}
|
||||
|
||||
showTemporaryMessage(title, message, duration = 2000) {
|
||||
const modal = document.createElement('div');
|
||||
Object.assign(modal.style, {
|
||||
position: 'absolute', top: '25%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||
backgroundColor: 'rgba(139, 0, 0, 0.9)', color: '#fff', padding: '15px 30px',
|
||||
borderRadius: '8px', border: '2px solid #ff4444', fontFamily: '"Cinzel", serif',
|
||||
fontSize: '20px', textShadow: '2px 2px 4px black', zIndex: '2000', pointerEvents: 'none',
|
||||
opacity: '0', transition: 'opacity 0.5s ease-in-out'
|
||||
});
|
||||
|
||||
modal.innerHTML = `
|
||||
<h3 style="margin:0; text-align:center; color: #FFD700; text-transform: uppercase;">⚠️ ${title}</h3>
|
||||
<div style="margin-top:5px; font-size: 16px;">${message}</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
requestAnimationFrame(() => { modal.style.opacity = '1'; });
|
||||
|
||||
setTimeout(() => {
|
||||
modal.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (modal.parentNode) document.body.removeChild(modal);
|
||||
}, 500);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
showCombatLog(log) {
|
||||
const isHit = log.hitSuccess;
|
||||
const color = isHit ? '#ff4444' : '#aaaaaa';
|
||||
|
||||
let detailHtml = '';
|
||||
if (isHit) {
|
||||
if (log.woundsCaused > 0) {
|
||||
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
|
||||
} else {
|
||||
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
|
||||
}
|
||||
} else {
|
||||
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
|
||||
}
|
||||
|
||||
// We create a new log element or update a singleton?
|
||||
// The original logic updated a SINGLE notification area.
|
||||
// Let's create a transient toast style log here, appending to container.
|
||||
|
||||
const logItem = document.createElement('div');
|
||||
Object.assign(logItem.style, {
|
||||
backgroundColor: 'rgba(0,0,0,0.9)', padding: '15px', border: `2px solid ${color}`,
|
||||
borderRadius: '5px', textAlign: 'center', minWidth: '250px', marginBottom: '10px',
|
||||
fontFamily: '"Cinzel", serif', opacity: '0', transition: 'opacity 0.3s'
|
||||
});
|
||||
|
||||
logItem.innerHTML = `
|
||||
<div style="font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
|
||||
${detailHtml}
|
||||
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
|
||||
`;
|
||||
|
||||
// Clear previous logs to act like the single notification area of before, OR stack them?
|
||||
// Original behavior was overwrite `innerHTML`. I should stick to that to avoid spam.
|
||||
// So I will clear `combatLogContainer` before adding.
|
||||
this.combatLogContainer.innerHTML = '';
|
||||
this.combatLogContainer.appendChild(logItem);
|
||||
|
||||
// Fade in
|
||||
requestAnimationFrame(() => { logItem.style.opacity = '1'; });
|
||||
|
||||
// Fade out
|
||||
setTimeout(() => {
|
||||
logItem.style.opacity = '0';
|
||||
// We don't remove immediately to avoid layout jumps if another comes in,
|
||||
// but we cleared logic above.
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
256
src/view/ui/HUDManager.js
Normal file
256
src/view/ui/HUDManager.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { DIRECTIONS } from '../../engine/dungeon/Constants.js';
|
||||
|
||||
export class HUDManager {
|
||||
constructor(gameContainer, cameraManager, game) {
|
||||
this.parentContainer = gameContainer;
|
||||
this.cameraManager = cameraManager;
|
||||
this.game = game; // Needed for dungeon grid access (minimap)
|
||||
|
||||
this.minimapCanvas = null;
|
||||
this.zoomSlider = null;
|
||||
this.viewButtons = [];
|
||||
this.ctx = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Minimap (Top Left) ---
|
||||
this.minimapCanvas = document.createElement('canvas');
|
||||
this.minimapCanvas.width = 200;
|
||||
this.minimapCanvas.height = 200;
|
||||
Object.assign(this.minimapCanvas.style, {
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
border: '2px solid #444',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
this.parentContainer.appendChild(this.minimapCanvas);
|
||||
this.ctx = this.minimapCanvas.getContext('2d');
|
||||
|
||||
// --- Camera Controls (Top Right) ---
|
||||
const controlsContainer = document.createElement('div');
|
||||
Object.assign(controlsContainer.style, {
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
this.parentContainer.appendChild(controlsContainer);
|
||||
|
||||
this.createZoomControls(controlsContainer);
|
||||
this.createViewControls(controlsContainer);
|
||||
|
||||
// Start Minimap Loop
|
||||
this.setupMinimapLoop();
|
||||
}
|
||||
|
||||
createZoomControls(container) {
|
||||
const zoomContainer = document.createElement('div');
|
||||
Object.assign(zoomContainer.style, {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0px',
|
||||
height: '140px'
|
||||
});
|
||||
|
||||
const zoomLabel = document.createElement('div');
|
||||
zoomLabel.textContent = 'Zoom';
|
||||
Object.assign(zoomLabel.style, {
|
||||
color: '#fff',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'sans-serif',
|
||||
marginBottom: '10px',
|
||||
marginTop: '0px'
|
||||
});
|
||||
|
||||
const zoomSlider = document.createElement('input');
|
||||
zoomSlider.type = 'range';
|
||||
zoomSlider.min = '3';
|
||||
zoomSlider.max = '15';
|
||||
zoomSlider.value = '6';
|
||||
zoomSlider.step = '0.5';
|
||||
Object.assign(zoomSlider.style, {
|
||||
width: '100px',
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: 'center',
|
||||
cursor: 'pointer',
|
||||
marginTop: '40px'
|
||||
});
|
||||
|
||||
this.zoomSlider = zoomSlider;
|
||||
|
||||
// Sync with Camera Manager
|
||||
this.cameraManager.zoomLevel = 6;
|
||||
this.cameraManager.updateProjection();
|
||||
|
||||
this.cameraManager.onZoomChange = (val) => {
|
||||
if (this.zoomSlider) this.zoomSlider.value = val;
|
||||
};
|
||||
|
||||
zoomSlider.oninput = (e) => {
|
||||
this.cameraManager.zoomLevel = parseFloat(e.target.value);
|
||||
this.cameraManager.updateProjection();
|
||||
};
|
||||
|
||||
zoomContainer.appendChild(zoomLabel);
|
||||
zoomContainer.appendChild(zoomSlider);
|
||||
|
||||
// Add 2D/3D Toggle (Left of Zoom)
|
||||
const toggleViewBtn = document.createElement('button');
|
||||
toggleViewBtn.textContent = '3D';
|
||||
toggleViewBtn.title = 'Cambiar vista 2D/3D';
|
||||
Object.assign(toggleViewBtn.style, {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid #aaa',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#daa520',
|
||||
cursor: 'pointer',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
});
|
||||
|
||||
toggleViewBtn.onmouseover = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'; toggleViewBtn.style.color = '#fff'; };
|
||||
toggleViewBtn.onmouseout = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; toggleViewBtn.style.color = '#daa520'; };
|
||||
|
||||
toggleViewBtn.onclick = () => {
|
||||
if (this.cameraManager) {
|
||||
this.cameraManager.onAnimationComplete = null;
|
||||
const isCurrently2D = (this.cameraManager.viewMode === '2D');
|
||||
if (isCurrently2D && this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.hideTokens();
|
||||
}
|
||||
|
||||
const is3D = this.cameraManager.toggleViewMode();
|
||||
toggleViewBtn.textContent = is3D ? '3D' : '2D';
|
||||
|
||||
if (!is3D) {
|
||||
this.cameraManager.onAnimationComplete = () => {
|
||||
if (this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.showTokens(this.game.heroes, this.game.monsters);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(toggleViewBtn);
|
||||
container.appendChild(zoomContainer);
|
||||
}
|
||||
|
||||
createViewControls(container) {
|
||||
const buttonsGrid = document.createElement('div');
|
||||
Object.assign(buttonsGrid.style, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px 40px 40px',
|
||||
gap: '5px'
|
||||
});
|
||||
|
||||
const createBtn = (label, dir) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
Object.assign(btn.style, {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #666',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
});
|
||||
btn.dataset.direction = dir;
|
||||
btn.onclick = () => {
|
||||
this.cameraManager.setIsoView(dir);
|
||||
this.updateActiveViewButton(dir);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
|
||||
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
|
||||
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
|
||||
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
|
||||
|
||||
buttonsGrid.appendChild(btnN);
|
||||
buttonsGrid.appendChild(btnW);
|
||||
buttonsGrid.appendChild(btnE);
|
||||
buttonsGrid.appendChild(btnS);
|
||||
|
||||
this.viewButtons = [btnN, btnE, btnS, btnW];
|
||||
this.updateActiveViewButton(DIRECTIONS.NORTH);
|
||||
|
||||
container.appendChild(buttonsGrid);
|
||||
}
|
||||
|
||||
updateActiveViewButton(activeDirection) {
|
||||
this.viewButtons.forEach(btn => btn.style.backgroundColor = '#333');
|
||||
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
|
||||
if (activeBtn) activeBtn.style.backgroundColor = '#f0c040';
|
||||
}
|
||||
|
||||
setupMinimapLoop() {
|
||||
const loop = () => {
|
||||
this.drawMinimap();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
drawMinimap() {
|
||||
if (!this.game.dungeon) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const w = this.minimapCanvas.width;
|
||||
const h = this.minimapCanvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cellSize = 5;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
|
||||
ctx.fillStyle = '#666';
|
||||
|
||||
for (const [key, tileId] of this.game.dungeon.grid.occupiedCells) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const cx = centerX + (x * cellSize);
|
||||
const cy = centerY - (y * cellSize);
|
||||
|
||||
if (tileId.includes('room')) ctx.fillStyle = '#55a';
|
||||
else ctx.fillStyle = '#aaa';
|
||||
|
||||
ctx.fillRect(cx, cy, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// Draw Exits
|
||||
ctx.fillStyle = '#0f0';
|
||||
if (this.game.dungeon.availableExits) {
|
||||
this.game.dungeon.availableExits.forEach(exit => {
|
||||
const ex = centerX + (exit.x * cellSize);
|
||||
const ey = centerY - (exit.y * cellSize);
|
||||
ctx.fillRect(ex, ey, cellSize, cellSize);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw Center Cross
|
||||
ctx.strokeStyle = '#f00';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 5, centerY);
|
||||
ctx.lineTo(centerX + 5, centerY);
|
||||
ctx.moveTo(centerX, centerY - 5);
|
||||
ctx.lineTo(centerX, centerY + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
140
src/view/ui/PlacementUI.js
Normal file
140
src/view/ui/PlacementUI.js
Normal file
@@ -0,0 +1,140 @@
|
||||
export class PlacementUI {
|
||||
constructor(parentContainer, game, callbacks) {
|
||||
this.parentContainer = parentContainer;
|
||||
this.game = game; // We need dynamic access to game.dungeon as it might change? Usually not. But we access game.dungeon.
|
||||
this.callbacks = callbacks || {}; // { showModal, showConfirm }
|
||||
|
||||
this.placementPanel = null;
|
||||
this.placementStatus = null;
|
||||
this.placeBtn = null;
|
||||
this.rotateBtn = null;
|
||||
this.discardBtn = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.placementPanel = document.createElement('div');
|
||||
Object.assign(this.placementPanel.style, {
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'none', // Hidden by default
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
padding: '15px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #666'
|
||||
});
|
||||
this.parentContainer.appendChild(this.placementPanel);
|
||||
|
||||
// Status text
|
||||
this.placementStatus = document.createElement('div');
|
||||
Object.assign(this.placementStatus.style, {
|
||||
color: '#fff', fontSize: '16px', fontFamily: 'sans-serif', marginBottom: '10px', textAlign: 'center'
|
||||
});
|
||||
this.placementStatus.textContent = 'Coloca la loseta';
|
||||
this.placementPanel.appendChild(this.placementStatus);
|
||||
|
||||
// Controls container
|
||||
const placementControls = document.createElement('div');
|
||||
Object.assign(placementControls.style, { display: 'flex', gap: '15px', alignItems: 'center' });
|
||||
this.placementPanel.appendChild(placementControls);
|
||||
|
||||
// Movement arrows
|
||||
const arrowGrid = document.createElement('div');
|
||||
Object.assign(arrowGrid.style, { display: 'grid', gridTemplateColumns: '40px 40px 40px', gap: '3px' });
|
||||
|
||||
const createArrow = (label, dx, dy) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
Object.assign(btn.style, {
|
||||
width: '40px', height: '40px', backgroundColor: '#444', color: '#fff',
|
||||
border: '1px solid #888', cursor: 'pointer', fontSize: '18px'
|
||||
});
|
||||
btn.onclick = () => {
|
||||
if (this.game.dungeon) this.game.dungeon.movePlacement(dx, dy);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
const arrowUp = createArrow('↑', 0, 1); arrowUp.style.gridColumn = '2';
|
||||
const arrowLeft = createArrow('←', -1, 0); arrowLeft.style.gridColumn = '1';
|
||||
const arrowRight = createArrow('→', 1, 0); arrowRight.style.gridColumn = '3';
|
||||
const arrowDown = createArrow('↓', 0, -1); arrowDown.style.gridColumn = '2';
|
||||
|
||||
arrowGrid.appendChild(arrowUp);
|
||||
arrowGrid.appendChild(arrowLeft);
|
||||
arrowGrid.appendChild(arrowRight);
|
||||
arrowGrid.appendChild(arrowDown);
|
||||
placementControls.appendChild(arrowGrid);
|
||||
|
||||
// Rotate button
|
||||
this.rotateBtn = document.createElement('button');
|
||||
this.rotateBtn.textContent = '🔄 Rotar';
|
||||
Object.assign(this.rotateBtn.style, {
|
||||
padding: '10px 20px', backgroundColor: '#555', color: '#fff', border: '1px solid #888',
|
||||
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
|
||||
});
|
||||
this.rotateBtn.onclick = () => { if (this.game.dungeon) this.game.dungeon.rotatePlacement(); };
|
||||
placementControls.appendChild(this.rotateBtn);
|
||||
|
||||
// Place button
|
||||
this.placeBtn = document.createElement('button');
|
||||
this.placeBtn.textContent = '⬇ Bajar';
|
||||
Object.assign(this.placeBtn.style, {
|
||||
padding: '10px 20px', backgroundColor: '#2a5', color: '#fff', border: '1px solid #888',
|
||||
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
|
||||
});
|
||||
this.placeBtn.onclick = () => {
|
||||
if (this.game.dungeon) {
|
||||
const success = this.game.dungeon.confirmPlacement();
|
||||
if (!success && this.callbacks.showModal) {
|
||||
this.callbacks.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
|
||||
}
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.placeBtn);
|
||||
|
||||
// Discard button
|
||||
this.discardBtn = document.createElement('button');
|
||||
this.discardBtn.textContent = '❌ Cancelar';
|
||||
Object.assign(this.discardBtn.style, {
|
||||
padding: '10px 20px', backgroundColor: '#d33', color: '#fff', border: '1px solid #888',
|
||||
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
|
||||
});
|
||||
this.discardBtn.onclick = () => {
|
||||
if (this.game.dungeon && this.callbacks.showConfirm) {
|
||||
this.callbacks.showConfirm(
|
||||
'Confirmar acción',
|
||||
'¿Quieres descartar esta loseta y bloquear la puerta?',
|
||||
() => { this.game.dungeon.cancelPlacement(); }
|
||||
);
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.discardBtn);
|
||||
}
|
||||
|
||||
showControls(show) {
|
||||
if (this.placementPanel) {
|
||||
this.placementPanel.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(isValid) {
|
||||
if (this.placementStatus) {
|
||||
if (isValid) {
|
||||
this.placementStatus.textContent = '✅ Posición válida';
|
||||
this.placementStatus.style.color = '#0f0';
|
||||
this.placeBtn.style.backgroundColor = '#2a5';
|
||||
this.placeBtn.style.cursor = 'pointer';
|
||||
} else {
|
||||
this.placementStatus.textContent = '❌ Posición inválida';
|
||||
this.placementStatus.style.color = '#f44';
|
||||
this.placeBtn.style.backgroundColor = '#555';
|
||||
this.placeBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/view/ui/SpellbookUI.js
Normal file
110
src/view/ui/SpellbookUI.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import { SPELLS } from '../../engine/data/Spells.js';
|
||||
|
||||
export class SpellbookUI {
|
||||
constructor(game) {
|
||||
this.game = game;
|
||||
this.spellBookContainer = null;
|
||||
}
|
||||
|
||||
toggle(hero) {
|
||||
if (this.spellBookContainer) {
|
||||
document.body.removeChild(this.spellBookContainer);
|
||||
this.spellBookContainer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
Object.assign(container.style, {
|
||||
position: 'absolute',
|
||||
bottom: '140px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '15px',
|
||||
backgroundColor: 'rgba(20, 10, 30, 0.9)',
|
||||
padding: '20px',
|
||||
borderRadius: '10px',
|
||||
border: '2px solid #9933ff',
|
||||
zIndex: '1500',
|
||||
boxShadow: '0 0 20px rgba(100, 0, 255, 0.5)'
|
||||
});
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.textContent = "LIBRO DE HECHIZOS";
|
||||
Object.assign(title.style, {
|
||||
position: 'absolute', top: '-30px', left: '0', width: '100%', textAlign: 'center',
|
||||
color: '#d8bfff', fontFamily: '"Cinzel", serif', fontSize: '18px', textShadow: '0 0 5px #8a2be2'
|
||||
});
|
||||
container.appendChild(title);
|
||||
|
||||
SPELLS.forEach(spell => {
|
||||
const canCast = this.game.canCastSpell(spell);
|
||||
|
||||
const card = document.createElement('div');
|
||||
Object.assign(card.style, {
|
||||
width: '180px', height: '260px', position: 'relative', cursor: canCast ? 'pointer' : 'not-allowed',
|
||||
transition: 'transform 0.2s', filter: canCast ? 'none' : 'grayscale(100%) brightness(50%)',
|
||||
backgroundImage: this.getSpellTemplate(spell.type), backgroundSize: 'cover'
|
||||
});
|
||||
|
||||
if (canCast) {
|
||||
card.onmouseenter = () => { card.style.transform = 'scale(1.1) translateY(-10px)'; card.style.zIndex = '10'; };
|
||||
card.onmouseleave = () => { card.style.transform = 'scale(1)'; card.style.zIndex = '1'; };
|
||||
|
||||
card.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
document.body.removeChild(this.spellBookContainer);
|
||||
this.spellBookContainer = null;
|
||||
|
||||
if (spell.type === 'attack' || spell.type === 'defense') {
|
||||
this.game.startSpellTargeting(spell);
|
||||
} else {
|
||||
// Global/Instant
|
||||
this.game.executeSpell(spell);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Cost Badge
|
||||
const costBadge = document.createElement('div');
|
||||
costBadge.textContent = spell.cost;
|
||||
Object.assign(costBadge.style, {
|
||||
position: 'absolute', top: '12px', left: '12px', width: '30px', height: '30px', borderRadius: '50%',
|
||||
backgroundColor: '#fff', color: '#000', fontWeight: 'bold', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: '2px solid #000', fontSize: '18px', fontFamily: 'serif'
|
||||
});
|
||||
card.appendChild(costBadge);
|
||||
|
||||
// Name
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.textContent = spell.name.toUpperCase();
|
||||
Object.assign(nameEl.style, {
|
||||
position: 'absolute', top: '45px', width: '100%', textAlign: 'center', fontSize: '14px',
|
||||
color: '#000', fontWeight: 'bold', fontFamily: '"Cinzel", serif', padding: '0 10px', boxSizing: 'border-box'
|
||||
});
|
||||
card.appendChild(nameEl);
|
||||
|
||||
// Description
|
||||
const descEl = document.createElement('div');
|
||||
descEl.textContent = spell.description;
|
||||
Object.assign(descEl.style, {
|
||||
position: 'absolute', bottom: '30px', left: '10px', width: '160px', height: '80px',
|
||||
fontSize: '11px', color: '#000', textAlign: 'center', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', fontFamily: 'serif', lineHeight: '1.2'
|
||||
});
|
||||
card.appendChild(descEl);
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
document.body.appendChild(container);
|
||||
this.spellBookContainer = container;
|
||||
}
|
||||
|
||||
getSpellTemplate(type) {
|
||||
let filename = 'attack_template.png';
|
||||
if (type === 'heal') filename = 'healing_template.png';
|
||||
if (type === 'defense') filename = 'defense_template.png';
|
||||
return `url('/assets/images/dungeon1/spells/${filename}')`;
|
||||
}
|
||||
}
|
||||
172
src/view/ui/TurnStatusUI.js
Normal file
172
src/view/ui/TurnStatusUI.js
Normal file
@@ -0,0 +1,172 @@
|
||||
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);
|
||||
|
||||
// End Phase Button
|
||||
this.endPhaseBtn = document.createElement('button');
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
Object.assign(this.endPhaseBtn.style, {
|
||||
marginTop: '10px',
|
||||
width: '300px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#daa520',
|
||||
color: '#000',
|
||||
border: '1px solid #8B4513',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
display: 'none',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
fontSize: '12px',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
|
||||
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
|
||||
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
|
||||
|
||||
this.endPhaseBtn.onclick = () => {
|
||||
console.log('[TurnStatusUI] End Phase Button Clicked', this.game.turnManager.currentPhase);
|
||||
this.game.turnManager.nextPhase();
|
||||
};
|
||||
this.statusPanel.appendChild(this.endPhaseBtn);
|
||||
|
||||
// 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.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 === '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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
382
src/view/ui/UnitCardManager.js
Normal file
382
src/view/ui/UnitCardManager.js
Normal file
@@ -0,0 +1,382 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// 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: 'not-allowed'
|
||||
});
|
||||
invBtn.title = 'Inventario (Próximamente)';
|
||||
card.appendChild(invBtn);
|
||||
|
||||
// Wizard Spells
|
||||
if (hero.key === 'wizard') {
|
||||
const spellsBtn = document.createElement('button');
|
||||
spellsBtn.textContent = '🔮 HECHIZOS';
|
||||
Object.assign(spellsBtn.style, {
|
||||
width: '100%', padding: '8px', marginTop: '5px', backgroundColor: '#4b0082',
|
||||
color: '#fff', border: '1px solid #8a2be2', borderRadius: '4px',
|
||||
fontFamily: '"Cinzel", serif', cursor: 'pointer'
|
||||
});
|
||||
spellsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.callbacks.toggleSpellBook) this.callbacks.toggleSpellBook(hero);
|
||||
};
|
||||
card.appendChild(spellsBtn);
|
||||
}
|
||||
|
||||
card.dataset.heroId = hero.id;
|
||||
return card;
|
||||
}
|
||||
|
||||
createMonsterCard(monster) {
|
||||
const card = document.createElement('div');
|
||||
Object.assign(card.style, {
|
||||
width: '180px', backgroundColor: 'rgba(40, 20, 20, 0.95)', border: '2px solid #8B0000',
|
||||
borderRadius: '8px', padding: '10px', fontFamily: '"Cinzel", serif', color: '#fff'
|
||||
});
|
||||
|
||||
// Portrait
|
||||
const portrait = document.createElement('div');
|
||||
Object.assign(portrait.style, {
|
||||
width: '100px', height: '100px', borderRadius: '50%', overflow: 'hidden',
|
||||
border: '2px solid #8B0000', marginBottom: '8px', marginLeft: 'auto', marginRight: 'auto',
|
||||
backgroundColor: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
});
|
||||
const img = document.createElement('img');
|
||||
img.src = `/assets/images/dungeon1/tokens/enemies/${monster.key}.png?v=2`;
|
||||
Object.assign(img.style, { width: '100%', height: '100%', objectFit: 'cover' });
|
||||
img.onerror = () => { portrait.innerHTML = `<div style="color: #8B0000; font-size: 48px;">👹</div>`; };
|
||||
portrait.appendChild(img);
|
||||
card.appendChild(portrait);
|
||||
|
||||
// Name
|
||||
const name = document.createElement('div');
|
||||
name.textContent = monster.name;
|
||||
Object.assign(name.style, {
|
||||
fontSize: '16px', fontWeight: 'bold', color: '#FF4444', textAlign: 'center', marginBottom: '8px', textTransform: 'uppercase'
|
||||
});
|
||||
card.appendChild(name);
|
||||
|
||||
// Stats
|
||||
const statsGrid = document.createElement('div');
|
||||
Object.assign(statsGrid.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', fontSize: '12px' });
|
||||
const stats = [
|
||||
{ label: 'H.C', value: monster.stats.ws || 0 },
|
||||
{ label: 'Fuer', value: monster.stats.str || 0 },
|
||||
{ label: 'Res', value: monster.stats.toughness || 0 },
|
||||
{ label: 'Her', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` },
|
||||
{ label: 'Ini', value: monster.stats.initiative || 0 },
|
||||
{ label: 'Ata', value: monster.stats.attacks || 0 }
|
||||
];
|
||||
|
||||
stats.forEach(stat => {
|
||||
const el = document.createElement('div');
|
||||
Object.assign(el.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)', padding: '3px 5px', borderRadius: '3px', display: 'flex', justifyContent: 'space-between' });
|
||||
const l = document.createElement('span'); l.style.color = '#AAA'; l.textContent = stat.label + ':';
|
||||
const v = document.createElement('span'); v.style.color = '#FFF'; v.textContent = stat.value; v.style.fontWeight = 'bold';
|
||||
el.appendChild(l); el.appendChild(v);
|
||||
statsGrid.appendChild(el);
|
||||
});
|
||||
card.appendChild(statsGrid);
|
||||
card.dataset.monsterId = monster.id;
|
||||
return card;
|
||||
}
|
||||
|
||||
showMonsterCard(monster) {
|
||||
this.hideMonsterCard();
|
||||
this.currentMonsterCard = this.createMonsterCard(monster);
|
||||
this.cardsContainer.appendChild(this.currentMonsterCard);
|
||||
|
||||
this.attackButton = document.createElement('button');
|
||||
this.attackButton.textContent = '⚔️ ATACAR';
|
||||
Object.assign(this.attackButton.style, {
|
||||
width: '180px', padding: '12px', backgroundColor: '#8B0000', color: '#fff',
|
||||
border: '2px solid #FF4444', borderRadius: '8px', fontFamily: '"Cinzel", serif',
|
||||
fontSize: '16px', fontWeight: 'bold', cursor: 'pointer', transition: 'all 0.2s'
|
||||
});
|
||||
|
||||
this.attackButton.onmouseenter = () => { this.attackButton.style.backgroundColor = '#FF0000'; this.attackButton.style.transform = 'scale(1.05)'; };
|
||||
this.attackButton.onmouseleave = () => { this.attackButton.style.backgroundColor = '#8B0000'; this.attackButton.style.transform = 'scale(1)'; };
|
||||
|
||||
this.attackButton.onclick = () => {
|
||||
if (this.game.performHeroAttack) {
|
||||
const result = this.game.performHeroAttack(monster.id);
|
||||
if (result && result.success) {
|
||||
this.hideMonsterCard();
|
||||
// Optional: deselect monster logic if managed externally
|
||||
if (this.game.selectedMonster) {
|
||||
if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false);
|
||||
this.game.selectedMonster = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
this.cardsContainer.appendChild(this.attackButton);
|
||||
}
|
||||
|
||||
showRangedAttackUI(monster) {
|
||||
this.showMonsterCard(monster); // Creates button as "ATACAR"
|
||||
|
||||
if (this.attackButton) {
|
||||
this.attackButton.textContent = '🏹 DISPARAR';
|
||||
this.attackButton.style.backgroundColor = '#2E8B57';
|
||||
this.attackButton.style.border = '2px solid #32CD32';
|
||||
|
||||
this.attackButton.onclick = () => {
|
||||
const result = this.game.performRangedAttack(monster.id);
|
||||
if (result && result.success) {
|
||||
this.game.cancelTargeting();
|
||||
this.hideMonsterCard();
|
||||
}
|
||||
};
|
||||
|
||||
this.attackButton.onmouseenter = () => { this.attackButton.style.backgroundColor = '#3CB371'; this.attackButton.style.transform = 'scale(1.05)'; };
|
||||
this.attackButton.onmouseleave = () => { this.attackButton.style.backgroundColor = '#2E8B57'; this.attackButton.style.transform = 'scale(1)'; };
|
||||
}
|
||||
}
|
||||
|
||||
hideMonsterCard() {
|
||||
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) {
|
||||
this.cardsContainer.removeChild(this.currentMonsterCard);
|
||||
this.currentMonsterCard = null;
|
||||
}
|
||||
if (this.attackButton && this.attackButton.parentNode) {
|
||||
this.cardsContainer.removeChild(this.attackButton);
|
||||
this.attackButton = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user