Compare commits
17 Commits
8bb0dd8780
...
combat-eng
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a9299dc5 | |||
| 7b28fcf1b0 | |||
| 3efbf8d5fb | |||
| dd7356f1bd | |||
| 78b7486dd2 | |||
| 77c0c07a44 | |||
| b619e4cee4 | |||
| 056217437c | |||
| 4c8b58151b | |||
| 3bfe9e4809 | |||
| 2f63e54d13 | |||
| 46b5466701 | |||
| 019e527441 | |||
| cd6abb016f | |||
| 7462dd7fed | |||
| dbed4468c5 | |||
| ac536ac96c |
72
DEVLOG.md
@@ -1,5 +1,77 @@
|
|||||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||||
|
|
||||||
|
## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
|
||||||
|
|
||||||
|
### Objetivos Completados
|
||||||
|
1. **Reglas de Juego Oficiales (WHQ 1995)**:
|
||||||
|
- Se ha implementado un estricto control de fases: **Exploración**, **Aventureros** y **Monstruos**.
|
||||||
|
- **Exploración Realista**: Colocar una loseta finaliza el turno inmediatamente.
|
||||||
|
- **Tensión en Nuevas Áreas**: Al entrar en una nueva habitación, el héroe se detiene OBLIGATORIAMENTE (haya monstruos o no) y se revela el evento.
|
||||||
|
- **Combate Continuo**: Si hay monstruos vivos, se elimina la Fase de Exploración del ciclo y se salta la Fase de Poder para mantener un bucle de combate frenético (Aventureros <-> Monstruos).
|
||||||
|
|
||||||
|
2. **Movimiento y Eventos**:
|
||||||
|
- Refinamiento de `executeMovePath` en `GameEngine`:
|
||||||
|
- Detecta entrada en nuevos tiles.
|
||||||
|
- Diferencia entre **Habitaciones** (Trigger Event + Stop) y **Pasillos** (Solo marcar visitado).
|
||||||
|
- Detiene el movimiento sin penalizar los pasos no dados.
|
||||||
|
|
||||||
|
3. **Interacción de Héroes**:
|
||||||
|
- Implementado ataque básico haciendo clic izquierdo en monstruos adyacentes durante el turno propio.
|
||||||
|
- Permitido movimiento en fases de Exploración para facilitar el posicionamiento táctico antes de abrir puertas.
|
||||||
|
|
||||||
|
4. **Monstruos e IA**:
|
||||||
|
- Los monstruos de habitación ya no sufren "mareo de invocación" y atacan en el turno siguiente a su aparición.
|
||||||
|
- Ajustada la IA para operar correctamente dentro del nuevo flujo de fases.
|
||||||
|
|
||||||
|
### Estado Actual
|
||||||
|
El núcleo del juego ("Game Loop") es funcional y fiel a las reglas de mesa. Se puede explorar, revelar salas, combatir y gestionar los turnos con las restricciones correctas.
|
||||||
|
|
||||||
|
### Próximos Pasos
|
||||||
|
- Implementar sistema completo de combate (tiradas de dados visibles, daño variable, muerte de héroes).
|
||||||
|
- Refinar la interfaz de usuario para mostrar estadísticas en tiempo real.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Sesión 5: Refinamiento de UX y Jugabilidad (3 Enero 2026)
|
||||||
|
|
||||||
|
### Objetivos Completados
|
||||||
|
1. **Validación Estricta de Puertas**:
|
||||||
|
- Implementado control riguroso para puertas multicelda.
|
||||||
|
- Si una puerta tiene 2 celdas, la tile conectada DEBE tener 2 salidas alineadas.
|
||||||
|
- Fix: `selectDoor` ahora recupera el objeto salida completo (con `tileId`) para poder validar correctamente el grupo de celdas.
|
||||||
|
|
||||||
|
2. **UX de Colocación**:
|
||||||
|
- Reemplazados `alert()` nativos con modales `showModal()` y `showConfirm()` estilizados.
|
||||||
|
- Implementado botón de **Descarte** para bloquear puertas cuando una tile no cabe o no interesa.
|
||||||
|
|
||||||
|
3. **Sistema de Movimiento Táctico**:
|
||||||
|
- **Planificación**: Click en jugador para seleccionar → Clicks en celdas contiguas para trazar ruta (1, 2, 3...).
|
||||||
|
- **Deshacer**: Click en el último paso para eliminarlo.
|
||||||
|
- **Ejecución**: Click derecho para iniciar el movimiento.
|
||||||
|
- **Animación**: Implementado efecto de "botecito" (salto sinusoidal) al mover entre casillas.
|
||||||
|
- **Visualización**: Marcadores amarillos con números sobre las casillas planificadas.
|
||||||
|
|
||||||
|
4. **Aleatoriedad Visual**:
|
||||||
|
- Implementado sistema de **Texturas Aleatorias** para losetas con múltiples variantes (Rooms).
|
||||||
|
- `DungeonGenerator` elige una textura al instanciar, `GameRenderer` la pinta.
|
||||||
|
- Corrección de definición duplicada de `room_objective` en `TileDefinitions.js` (eliminada versión incorrecta de 4x4).
|
||||||
|
|
||||||
|
5. **Mejoras de Cámara**:
|
||||||
|
- **Zoom Ajustado**: Rango modificado a 3-15 (default 6) para una vista más lejana y cómoda.
|
||||||
|
- **Sincronización**: El slider de zoom ahora se actualiza al usar la rueda del ratón.
|
||||||
|
- **Paneo**: Se modificó la lógica de paneo para mover tanto la cámara como el objetivo (`target`), evitando el efecto de órbita indeseado. *Estado final: Pendiente de validación por reporte de fallo en controles.*
|
||||||
|
|
||||||
|
### Estado Actual
|
||||||
|
El juego es completamente jugable en cuanto a exploración y movimiento. La generación de mazmorras es robusta y visualmente más variada gracias a las texturas aleatorias. La interfaz es consistente y amigable.
|
||||||
|
|
||||||
|
### Próximos Pasos
|
||||||
|
- Revisar controles de cámara (Paneo).
|
||||||
|
- Implementar sistema de turnos / fases de juego.
|
||||||
|
- Añadir enemigos y lógica de combate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sesión 4: Sistema de Colocación Manual - Estado Actual (2 Enero 2026)
|
## Sesión 4: Sistema de Colocación Manual - Estado Actual (2 Enero 2026)
|
||||||
|
|
||||||
### Objetivo
|
### Objetivo
|
||||||
|
|||||||
57
Reglas/Fases.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
En Warhammer Quest, el juego se desarrolla en turnos divididos en **cuatro fases principales** que deben seguirse en orden estricto. Aquí tienes qué ocurre en cada una de ellas:
|
||||||
|
|
||||||
|
### 1. Fase de Poder
|
||||||
|
|
||||||
|
En esta fase, el Hechicero determina cuánta energía mágica tendrá disponible para el turno lanzando **1D6**.
|
||||||
|
|
||||||
|
*
|
||||||
|
**Puntos de Poder:** El resultado del dado indica los puntos que puede gastar para lanzar hechizos.
|
||||||
|
|
||||||
|
|
||||||
|
* **Eventos Inesperados:** Si el Hechicero saca un **1**, ocurre un evento inesperado. Se debe robar una carta del mazo de Eventos y seguir sus instrucciones (que suelen implicar la aparición repentina de monstruos).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Fase de los Aventureros
|
||||||
|
|
||||||
|
Es el momento en que los héroes actúan. El orden de actuación comienza siempre por el **Líder** (quien lleva la Lámpara, normalmente el Bárbaro) y continúa según el valor de **Iniciativa** de los demás (de mayor a mayor).
|
||||||
|
|
||||||
|
*
|
||||||
|
**Movimiento:** Cada aventurero puede moverse tantas casillas como su atributo de Movimiento, a menos que esté "trabado" (adyacente a un monstruo).
|
||||||
|
|
||||||
|
|
||||||
|
*
|
||||||
|
**Combate:** Después de moverse, el aventurero puede atacar a los monstruos adyacentes en combate cuerpo a cuerpo o disparar si tiene un arma de proyectiles y no está trabado.
|
||||||
|
|
||||||
|
|
||||||
|
*
|
||||||
|
**Exploración preliminar:** Si un aventurero entra en una nueva sección de tablero, su turno termina inmediatamente.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 3. Fase de los Monstruos
|
||||||
|
|
||||||
|
Si hay monstruos en el tablero, es su turno de devolver el golpe.
|
||||||
|
|
||||||
|
*
|
||||||
|
**Nuevas Estancias:** Si los aventureros acaban de entrar en una **Estancia de Subterráneo**, se roba una carta de Evento para ver qué enemigos o peligros hay dentro.
|
||||||
|
|
||||||
|
|
||||||
|
*
|
||||||
|
**Ataque de Monstruos:** Los monstruos que ya estaban en juego se mueven hacia los aventureros y atacan siguiendo la regla de **"Uno contra Uno"** (repartiéndose equitativamente entre los héroes). Los monstruos que acaban de ser colocados en esta fase no atacan hasta el siguiente turno.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 4. Fase de Exploración
|
||||||
|
|
||||||
|
Esta fase ocurre solo si no hay monstruos en la misma sección que el Líder.
|
||||||
|
|
||||||
|
*
|
||||||
|
**Revelar la mazmorra:** El Líder, si está junto a una puerta inexplorada, puede declarar que explora.
|
||||||
|
|
||||||
|
|
||||||
|
* **Nuevas secciones:** Se roba una carta del mazo de Mazmorra y se coloca la sección de tablero correspondiente. Los aventureros no podrán entrar en esta nueva zona hasta la Fase de Aventureros del siguiente turno.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Nota importante:** Existe la **"Regla del 1 y el 6"**: un 1 natural siempre es un fallo y un 6 natural siempre es un éxito, sin importar los modificadores.
|
||||||
@@ -24,13 +24,22 @@
|
|||||||
- [x] Tile Model/Texture Loading <!-- id: 23 -->
|
- [x] Tile Model/Texture Loading <!-- id: 23 -->
|
||||||
- [x] dynamic Tile Instancing based on Grid State <!-- id: 24 -->
|
- [x] dynamic Tile Instancing based on Grid State <!-- id: 24 -->
|
||||||
|
|
||||||
## Phase 3: Game Mechanics (Loop)
|
## Phase 3: Game Mechanics (Loop) - [IN PROGRESS]
|
||||||
- [ ] **Turn System**
|
- [x] **Turn System**
|
||||||
- [ ] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
|
- [x] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
|
||||||
- [ ] Implement Turn State Machine <!-- id: 31 -->
|
- [x] Implement Turn State Machine (Phases now functional and dispatch events) <!-- id: 31 -->
|
||||||
- [ ] **Entity System**
|
- [x] Implement Power Phase (Rolls 1d6)
|
||||||
- [ ] Define Hero/Monster Stats <!-- id: 32 -->
|
- [x] **Event System**
|
||||||
- [ ] Implement Movement Logic (Grid-based) <!-- id: 33 -->
|
- [x] Implement Event Deck (Events.js)
|
||||||
|
- [x] Trigger Random Events on Power Roll of 1 or Room Reveal
|
||||||
|
- [x] Spawn Monsters from Event Cards (1d6 Orcs)
|
||||||
|
- [x] **Entity System**
|
||||||
|
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
|
||||||
|
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
|
||||||
|
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
||||||
|
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
|
||||||
|
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
|
||||||
|
- [ ] Refine Combat System (Ranged weapons, Special Monster Rules, Magic)
|
||||||
|
|
||||||
## Phase 4: Campaign System
|
## Phase 4: Campaign System
|
||||||
- [ ] **Campaign Manager**
|
- [ ] **Campaign Manager**
|
||||||
|
|||||||
BIN
public/assets/images/dungeon1/doors/door1_blocked.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 80 KiB |
BIN
public/assets/images/dungeon1/standees/descartes/dwarf_low.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
public/assets/images/dungeon1/standees/descartes/elfa_low.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
public/assets/images/dungeon1/standees/descartes/warlock_low.png
Normal file
|
After Width: | Height: | Size: 317 KiB |
BIN
public/assets/images/dungeon1/standees/enemies/Lordwarlock.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/bat.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
BIN
public/assets/images/dungeon1/standees/enemies/chaosWarrior.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/goblin.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
public/assets/images/dungeon1/standees/enemies/orc.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/rat.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/skaven.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/spiderGiant.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 468 KiB |
BIN
public/assets/images/dungeon1/standees/heroes/bakup/dwarf.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/bakup/elfa.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/bakup/warlock.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/barbarian.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
public/assets/images/dungeon1/standees/heroes/dwarf.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/elfa.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/warlock.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/assets/images/dungeon1/tokens/enemies/chaosWarrior.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
public/assets/images/dungeon1/tokens/enemies/goblin.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
1
public/assets/images/dungeon1/tokens/enemies/goblin_spearman.png
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
goblin.png
|
||||||
BIN
public/assets/images/dungeon1/tokens/enemies/orc.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/barbarian.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/dwarf.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/elf.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
public/assets/images/dungeon1/tokens/heroes/wizard.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/assets/videos/Intro/intro_barbarian.mp4
Normal file
37
src/engine/data/Events.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
export const EVENT_TYPES = {
|
||||||
|
MONSTER: 'monster',
|
||||||
|
EVENT: 'event' // Ambushes, traps, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
id: 'evt_orcs_d6',
|
||||||
|
type: EVENT_TYPES.MONSTER,
|
||||||
|
name: 'Emboscada de Orcos',
|
||||||
|
description: 'Un grupo de pieles verdes salta de las sombras.',
|
||||||
|
monsterKey: 'orc', // References MONSTER_DEFINITIONS
|
||||||
|
count: '1d6', // Special string to be parsed, or we can use a function
|
||||||
|
resolve: (gameEngine, context) => {
|
||||||
|
// Logic handled by engine based on params, or custom function
|
||||||
|
return Math.floor(Math.random() * 6) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createEventDeck = () => {
|
||||||
|
// As per user request: 10 copies of the same card for now
|
||||||
|
const deck = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
deck.push({ ...EVENT_DEFINITIONS[0] });
|
||||||
|
}
|
||||||
|
return shuffleDeck(deck);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shuffleDeck = (deck) => {
|
||||||
|
for (let i = deck.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[deck[i], deck[j]] = [deck[j], deck[i]];
|
||||||
|
}
|
||||||
|
return deck;
|
||||||
|
};
|
||||||
68
src/engine/data/Heroes.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export const HERO_DEFINITIONS = {
|
||||||
|
barbarian: {
|
||||||
|
id: 'barbarian',
|
||||||
|
name: 'Bárbaro',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
|
||||||
|
stats: {
|
||||||
|
move: 4,
|
||||||
|
ws: 3,
|
||||||
|
to_hit_missile: 5, // 5+ to hit with ranged
|
||||||
|
str: 4,
|
||||||
|
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas)
|
||||||
|
wounds: 12, // 1D6 + 9 (Using fixed average for now)
|
||||||
|
attacks: 1,
|
||||||
|
init: 3,
|
||||||
|
pin_target: 6 // 6+ to escape pin
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dwarf: {
|
||||||
|
id: 'dwarf',
|
||||||
|
name: 'Enano',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
|
||||||
|
stats: {
|
||||||
|
move: 4,
|
||||||
|
ws: 4,
|
||||||
|
to_hit_missile: 5, // 5+ to hit with ranged
|
||||||
|
str: 3,
|
||||||
|
toughness: 5, // 4 Base + 1 Armor (Cota de Malla)
|
||||||
|
wounds: 11, // 1D6 + 8 (Using fixed average for now)
|
||||||
|
attacks: 1,
|
||||||
|
init: 2,
|
||||||
|
pin_target: 5 // 5+ to escape pin
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elf: {
|
||||||
|
id: 'elf',
|
||||||
|
name: 'Elfa',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
|
||||||
|
stats: {
|
||||||
|
move: 4,
|
||||||
|
ws: 4,
|
||||||
|
bs: 4, // Added for Bow
|
||||||
|
to_hit_missile: 4, // 4+ to hit with ranged
|
||||||
|
str: 3,
|
||||||
|
toughness: 3,
|
||||||
|
wounds: 10, // 1D6 + 7 (Using fixed average for now)
|
||||||
|
attacks: 1,
|
||||||
|
init: 6,
|
||||||
|
pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wizard: {
|
||||||
|
id: 'wizard',
|
||||||
|
name: 'Hechicero',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
|
||||||
|
stats: {
|
||||||
|
move: 4,
|
||||||
|
ws: 2,
|
||||||
|
to_hit_missile: 6, // 6+ to hit with ranged
|
||||||
|
str: 3,
|
||||||
|
toughness: 3,
|
||||||
|
wounds: 9, // 1D6 + 6 (Using fixed average for now)
|
||||||
|
attacks: 1,
|
||||||
|
init: 3,
|
||||||
|
power: 0, // Tracks current power points
|
||||||
|
pin_target: 4 // 4+ to escape pin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
92
src/engine/data/Monsters.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
export const MONSTER_DEFINITIONS = {
|
||||||
|
orc: {
|
||||||
|
id: 'orc',
|
||||||
|
name: 'Guerrero Orco',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
|
||||||
|
stats: {
|
||||||
|
move: 4,
|
||||||
|
ws: 3,
|
||||||
|
str: 3,
|
||||||
|
toughness: 4,
|
||||||
|
wounds: 1, // Card: "Heridas: 1" (Wait, Orcs usually have 1, check image: YES "Heridas: 1")
|
||||||
|
attacks: 1,
|
||||||
|
gold: 55 // Card: "Valor 55x Unidad"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goblin_spearman: {
|
||||||
|
id: 'goblin_spearman',
|
||||||
|
name: 'Lancero Goblin',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/goblin.png',
|
||||||
|
stats: {
|
||||||
|
move: 4,
|
||||||
|
ws: 2,
|
||||||
|
str: 3,
|
||||||
|
toughness: 3,
|
||||||
|
wounds: 3,
|
||||||
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 20,
|
||||||
|
specialRules: ['reach_attack'] // "Puede atacar a dos casillas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
giant_rat: {
|
||||||
|
id: 'giant_rat',
|
||||||
|
name: 'Rata Gigante',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/rat.png',
|
||||||
|
stats: {
|
||||||
|
move: 6,
|
||||||
|
ws: 2,
|
||||||
|
str: 2,
|
||||||
|
toughness: 3,
|
||||||
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 20,
|
||||||
|
specialRules: ['death_frenzy', 'sudden_death'] // "Frenesí suicida", "Muerte Súbita"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
giant_spider: {
|
||||||
|
id: 'giant_spider',
|
||||||
|
name: 'Araña Gigante',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/spider.png',
|
||||||
|
stats: {
|
||||||
|
move: 6,
|
||||||
|
ws: 2,
|
||||||
|
str: 3, // Card says "Fuerza: Especial", but base STR needed? Web attack deals auto 1D3. If not trapped, check hit normally.
|
||||||
|
toughness: 2,
|
||||||
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 15,
|
||||||
|
specialRules: ['web_attack']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
giant_bat: {
|
||||||
|
id: 'giant_bat',
|
||||||
|
name: 'Murciélago Gigante',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/bat.png',
|
||||||
|
stats: {
|
||||||
|
move: 8,
|
||||||
|
ws: 2,
|
||||||
|
str: 2,
|
||||||
|
toughness: 2,
|
||||||
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 15,
|
||||||
|
specialRules: ['fly', 'ambush_attack'] // "Nunca se traban", "Atacan tan pronto son colocados"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minotaur: {
|
||||||
|
id: 'minotaur',
|
||||||
|
name: 'Minotauro',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/minotaur.png',
|
||||||
|
stats: {
|
||||||
|
move: 6,
|
||||||
|
ws: 4,
|
||||||
|
str: 4,
|
||||||
|
toughness: 4,
|
||||||
|
wounds: 15,
|
||||||
|
attacks: 2,
|
||||||
|
gold: 440,
|
||||||
|
damageDice: 2 // "Tira 2 dados para herir"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ export class DungeonGenerator {
|
|||||||
// Callbacks for UI
|
// Callbacks for UI
|
||||||
this.onStateChange = null;
|
this.onStateChange = null;
|
||||||
this.onPlacementUpdate = null;
|
this.onPlacementUpdate = null;
|
||||||
|
this.onDoorBlocked = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
startDungeon(missionConfig) {
|
startDungeon(missionConfig) {
|
||||||
@@ -64,17 +65,17 @@ export class DungeonGenerator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate exit exists
|
// Find the full exit object from availableExits (so we have tileId, etc.)
|
||||||
const exitExists = this.availableExits.some(
|
const foundExit = this.availableExits.find(
|
||||||
e => e.x === exitPoint.x && e.y === exitPoint.y && e.direction === exitPoint.direction
|
e => e.x === exitPoint.x && e.y === exitPoint.y && e.direction === exitPoint.direction
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!exitExists) {
|
if (!foundExit) {
|
||||||
console.warn("Invalid exit selected");
|
console.warn("Invalid exit selected");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedExit = exitPoint;
|
this.selectedExit = foundExit;
|
||||||
|
|
||||||
// Draw next card
|
// Draw next card
|
||||||
this.currentCard = this.deck.draw();
|
this.currentCard = this.deck.draw();
|
||||||
@@ -149,7 +150,68 @@ export class DungeonGenerator {
|
|||||||
if (!this.currentCard || this.state !== PLACEMENT_STATE.PLACING_TILE) return false;
|
if (!this.currentCard || this.state !== PLACEMENT_STATE.PLACING_TILE) return false;
|
||||||
|
|
||||||
const variant = this.currentCard.variants[this.placementRotation];
|
const variant = this.currentCard.variants[this.placementRotation];
|
||||||
return this.grid.canPlace(variant, this.placementX, this.placementY);
|
|
||||||
|
// 1. Basic Grid Collision
|
||||||
|
if (!this.grid.canPlace(variant, this.placementX, this.placementY)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Strict Door Alignment Check
|
||||||
|
if (this.selectedExit) {
|
||||||
|
// Identify the full "door" group (e.g., the pair of cells forming the exit)
|
||||||
|
// We look for other available exits on the same tile, facing the same way, and adjacent.
|
||||||
|
const sourceExits = this.availableExits.filter(e =>
|
||||||
|
e.tileId === this.selectedExit.tileId &&
|
||||||
|
e.direction === this.selectedExit.direction &&
|
||||||
|
(Math.abs(e.x - this.selectedExit.x) + Math.abs(e.y - this.selectedExit.y)) <= 1
|
||||||
|
);
|
||||||
|
|
||||||
|
// For every cell in the source door, the new tile MUST have a connecting exit
|
||||||
|
for (const source of sourceExits) {
|
||||||
|
// The coordinate where the new tile's exit should be
|
||||||
|
const targetPos = this.neighbor(source.x, source.y, source.direction);
|
||||||
|
const requiredDirection = this.opposite(source.direction);
|
||||||
|
|
||||||
|
// Does the new tile provide an exit here?
|
||||||
|
const hasMatch = variant.exits.some(localExit => {
|
||||||
|
const gx = this.placementX + localExit.x;
|
||||||
|
const gy = this.placementY + localExit.y;
|
||||||
|
return gx === targetPos.x &&
|
||||||
|
gy === targetPos.y &&
|
||||||
|
localExit.direction === requiredDirection;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasMatch) {
|
||||||
|
return false; // Misalignment: New tile doesn't connect to all parts of the door
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPlacement() {
|
||||||
|
if (this.state !== PLACEMENT_STATE.PLACING_TILE) return;
|
||||||
|
|
||||||
|
// 1. Mark door as blocked visually
|
||||||
|
if (this.onDoorBlocked && this.selectedExit) {
|
||||||
|
this.onDoorBlocked(this.selectedExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove the selected exit from available exits
|
||||||
|
if (this.selectedExit) {
|
||||||
|
this.availableExits = this.availableExits.filter(e =>
|
||||||
|
!(e.x === this.selectedExit.x && e.y === this.selectedExit.y && e.direction === this.selectedExit.direction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reset state
|
||||||
|
this.currentCard = null;
|
||||||
|
this.selectedExit = null;
|
||||||
|
this.state = PLACEMENT_STATE.WAITING_DOOR;
|
||||||
|
|
||||||
|
this.notifyPlacementUpdate();
|
||||||
|
this.notifyStateChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,11 +261,20 @@ export class DungeonGenerator {
|
|||||||
placeCardFinal(card, x, y, rotation) {
|
placeCardFinal(card, x, y, rotation) {
|
||||||
const variant = card.variants[rotation];
|
const variant = card.variants[rotation];
|
||||||
|
|
||||||
|
// Randomize Texture if multiple are available
|
||||||
|
let selectedTexture = null;
|
||||||
|
if (card.textures && card.textures.length > 0) {
|
||||||
|
const idx = Math.floor(Math.random() * card.textures.length);
|
||||||
|
selectedTexture = card.textures[idx];
|
||||||
|
console.log(`[DungeonGenerator] Selected texture for ${card.id}:`, selectedTexture);
|
||||||
|
}
|
||||||
|
|
||||||
const instance = {
|
const instance = {
|
||||||
id: `tile_${this.placedTiles.length}`,
|
id: `tile_${this.placedTiles.length}`,
|
||||||
defId: card.id,
|
defId: card.id,
|
||||||
x, y, rotation,
|
x, y, rotation,
|
||||||
name: card.name
|
name: card.name,
|
||||||
|
texture: selectedTexture
|
||||||
};
|
};
|
||||||
|
|
||||||
this.grid.placeTile(instance, variant, card);
|
this.grid.placeTile(instance, variant, card);
|
||||||
|
|||||||
@@ -3,9 +3,24 @@ export class GridSystem {
|
|||||||
constructor() {
|
constructor() {
|
||||||
// Map "x,y" -> "tileId"
|
// Map "x,y" -> "tileId"
|
||||||
this.occupiedCells = new Map();
|
this.occupiedCells = new Map();
|
||||||
|
|
||||||
|
// Map "x,y" -> { tileId: string, height: number (1-9) }
|
||||||
|
this.cellData = new Map();
|
||||||
|
|
||||||
|
// Map "tileId" -> Set of "x,y" strings (all cells belonging to this tile)
|
||||||
|
this.tileCells = new Map();
|
||||||
|
|
||||||
|
// Set of "x,y" strings that are door/exit cells (can cross room boundaries)
|
||||||
|
this.doorCells = new Set();
|
||||||
|
|
||||||
this.tiles = [];
|
this.tiles = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isWall(x, y) {
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
return !this.occupiedCells.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a specific VARIANT can be placed at anchorX, anchorY.
|
* Checks if a specific VARIANT can be placed at anchorX, anchorY.
|
||||||
* Does NOT rotate anything. Assumes variant is already the correct shape.
|
* Does NOT rotate anything. Assumes variant is already the correct shape.
|
||||||
@@ -48,13 +63,20 @@ export class GridSystem {
|
|||||||
const rows = layout.length;
|
const rows = layout.length;
|
||||||
const anchorX = tileInstance.x;
|
const anchorX = tileInstance.x;
|
||||||
const anchorY = tileInstance.y;
|
const anchorY = tileInstance.y;
|
||||||
|
const tileId = tileInstance.id;
|
||||||
|
|
||||||
|
// Initialize tile cell set
|
||||||
|
if (!this.tileCells.has(tileId)) {
|
||||||
|
this.tileCells.set(tileId, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
for (let row = 0; row < rows; row++) {
|
for (let row = 0; row < rows; row++) {
|
||||||
const rowData = layout[row];
|
const rowData = layout[row];
|
||||||
const cols = rowData.length;
|
const cols = rowData.length;
|
||||||
|
|
||||||
for (let col = 0; col < cols; col++) {
|
for (let col = 0; col < cols; col++) {
|
||||||
if (rowData[col] === 0) continue;
|
const heightValue = rowData[col];
|
||||||
|
if (heightValue === 0) continue;
|
||||||
|
|
||||||
const lx = col;
|
const lx = col;
|
||||||
const ly = (rows - 1) - row;
|
const ly = (rows - 1) - row;
|
||||||
@@ -63,9 +85,28 @@ export class GridSystem {
|
|||||||
const gy = anchorY + ly;
|
const gy = anchorY + ly;
|
||||||
const key = `${gx},${gy}`;
|
const key = `${gx},${gy}`;
|
||||||
|
|
||||||
this.occupiedCells.set(key, tileInstance.id);
|
// Store basic occupation
|
||||||
|
this.occupiedCells.set(key, tileId);
|
||||||
|
|
||||||
|
// Store detailed cell data (height level)
|
||||||
|
this.cellData.set(key, {
|
||||||
|
tileId: tileId,
|
||||||
|
height: heightValue // 1-8 = levels, 9 = stairs
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to tile's cell set
|
||||||
|
this.tileCells.get(tileId).add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark exit/door cells
|
||||||
|
if (variant.exits) {
|
||||||
|
variant.exits.forEach(exit => {
|
||||||
|
const exitKey = `${anchorX + exit.x},${anchorY + exit.y}`;
|
||||||
|
this.doorCells.add(exitKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.tiles.push(tileInstance);
|
this.tiles.push(tileInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,4 +143,52 @@ export class GridSystem {
|
|||||||
isOccupied(x, y) {
|
isOccupied(x, y) {
|
||||||
return this.occupiedCells.has(`${x},${y}`);
|
return this.occupiedCells.has(`${x},${y}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cell data (tileId, height) for a coordinate
|
||||||
|
*/
|
||||||
|
getCellData(x, y) {
|
||||||
|
return this.cellData.get(`${x},${y}`) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if movement from (x1,y1) to (x2,y2) is valid
|
||||||
|
* considering room boundaries, height levels, and stairs
|
||||||
|
*/
|
||||||
|
canMoveBetween(x1, y1, x2, y2) {
|
||||||
|
const key1 = `${x1},${y1}`;
|
||||||
|
const key2 = `${x2},${y2}`;
|
||||||
|
|
||||||
|
const data1 = this.cellData.get(key1);
|
||||||
|
const data2 = this.cellData.get(key2);
|
||||||
|
|
||||||
|
// Both cells must exist
|
||||||
|
if (!data1 || !data2) return false;
|
||||||
|
|
||||||
|
const sameTile = data1.tileId === data2.tileId;
|
||||||
|
const isDoor1 = this.doorCells.has(key1);
|
||||||
|
const isDoor2 = this.doorCells.has(key2);
|
||||||
|
|
||||||
|
// If different tiles, at least one must be a door
|
||||||
|
if (!sameTile && !isDoor1 && !isDoor2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height validation
|
||||||
|
const h1 = data1.height;
|
||||||
|
const h2 = data2.height;
|
||||||
|
|
||||||
|
// Stairs (9) can connect to any level
|
||||||
|
if (h1 === 9 || h2 === 9) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same height level is always OK
|
||||||
|
if (h1 === h2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different heights require stairs - not allowed directly
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ export const TILES = {
|
|||||||
id: 'corridor_straight',
|
id: 'corridor_straight',
|
||||||
name: 'Corridor',
|
name: 'Corridor',
|
||||||
type: TILE_TYPES.CORRIDOR,
|
type: TILE_TYPES.CORRIDOR,
|
||||||
textures: ['/assets/images/dungeon1/tiles/corridor1.png'],
|
textures: ['/assets/images/dungeon1/tiles/corridor1.png',
|
||||||
|
'/assets/images/dungeon1/tiles/corridor2.png',
|
||||||
|
'/assets/images/dungeon1/tiles/corridor3.png',
|
||||||
|
],
|
||||||
variants: {
|
variants: {
|
||||||
[DIRECTIONS.NORTH]: {
|
[DIRECTIONS.NORTH]: {
|
||||||
width: 2, height: 6,
|
width: 2, height: 6,
|
||||||
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
|
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
|
||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
|
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
|
||||||
|
{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
|
||||||
|
{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[DIRECTIONS.SOUTH]: {
|
[DIRECTIONS.SOUTH]: {
|
||||||
@@ -24,7 +29,9 @@ export const TILES = {
|
|||||||
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
|
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
|
||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
|
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
|
||||||
|
{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
|
||||||
|
{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[DIRECTIONS.EAST]: {
|
[DIRECTIONS.EAST]: {
|
||||||
@@ -32,7 +39,9 @@ export const TILES = {
|
|||||||
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
|
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
|
||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
||||||
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
|
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
|
||||||
|
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
|
{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[DIRECTIONS.WEST]: {
|
[DIRECTIONS.WEST]: {
|
||||||
@@ -40,7 +49,9 @@ export const TILES = {
|
|||||||
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
|
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
|
||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
||||||
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
|
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
|
||||||
|
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
|
{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +72,8 @@ export const TILES = {
|
|||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
|
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
|
||||||
|
//{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
|
||||||
|
//{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[DIRECTIONS.SOUTH]: {
|
[DIRECTIONS.SOUTH]: {
|
||||||
@@ -69,6 +82,8 @@ export const TILES = {
|
|||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
|
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
|
||||||
|
//{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
|
||||||
|
//{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[DIRECTIONS.EAST]: {
|
[DIRECTIONS.EAST]: {
|
||||||
@@ -77,6 +92,8 @@ export const TILES = {
|
|||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
||||||
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
|
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
|
||||||
|
//{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
|
//{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[DIRECTIONS.WEST]: {
|
[DIRECTIONS.WEST]: {
|
||||||
@@ -85,6 +102,8 @@ export const TILES = {
|
|||||||
exits: [
|
exits: [
|
||||||
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
|
||||||
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
|
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
|
||||||
|
//{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
|
||||||
|
//{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,53 +342,7 @@ export const TILES = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// ROOM OBJECTIVE
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
'room_objective': {
|
|
||||||
id: 'room_objective',
|
|
||||||
name: 'Dungeon Room',
|
|
||||||
type: TILE_TYPES.ROOM,
|
|
||||||
textures: [
|
|
||||||
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
|
|
||||||
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
|
|
||||||
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
|
|
||||||
],
|
|
||||||
variants: {
|
|
||||||
[DIRECTIONS.NORTH]: {
|
|
||||||
width: 4, height: 4,
|
|
||||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
|
||||||
exits: [
|
|
||||||
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH },
|
|
||||||
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
[DIRECTIONS.EAST]: {
|
|
||||||
width: 4, height: 4,
|
|
||||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
|
||||||
exits: [
|
|
||||||
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST },
|
|
||||||
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
[DIRECTIONS.SOUTH]: {
|
|
||||||
width: 4, height: 4,
|
|
||||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
|
||||||
exits: [
|
|
||||||
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH },
|
|
||||||
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
[DIRECTIONS.WEST]: {
|
|
||||||
width: 4, height: 4,
|
|
||||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
|
||||||
exits: [
|
|
||||||
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST },
|
|
||||||
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// ROOM OBJECTIVE
|
// ROOM OBJECTIVE
|
||||||
|
|||||||
216
src/engine/game/CombatMechanics.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
export const TO_HIT_CHART = [
|
||||||
|
// Defender WS 1 2 3 4 5 6 7 8 9 10
|
||||||
|
/* Attacker 1 */[4, 4, 5, 6, 6, 6, 6, 6, 6, 6],
|
||||||
|
/* Attacker 2 */[3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
|
||||||
|
/* Attacker 3 */[2, 3, 4, 4, 4, 4, 5, 5, 5, 6],
|
||||||
|
/* Attacker 4 */[2, 3, 3, 4, 4, 4, 4, 4, 5, 5],
|
||||||
|
/* Attacker 5 */[2, 2, 3, 3, 4, 4, 4, 4, 4, 4],
|
||||||
|
/* Attacker 6 */[2, 2, 3, 3, 3, 4, 4, 4, 4, 4],
|
||||||
|
/* Attacker 7 */[2, 2, 2, 3, 3, 3, 4, 4, 4, 4],
|
||||||
|
/* Attacker 8 */[2, 2, 2, 3, 3, 3, 3, 4, 4, 4],
|
||||||
|
/* Attacker 9 */[2, 2, 2, 2, 3, 3, 3, 3, 4, 4],
|
||||||
|
/* Attacker 10*/[2, 2, 2, 2, 3, 3, 3, 3, 3, 4]
|
||||||
|
];
|
||||||
|
|
||||||
|
export class CombatMechanics {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a melee attack sequence between two entities.
|
||||||
|
* @param {Object} attacker
|
||||||
|
* @param {Object} defender
|
||||||
|
* @returns {Object} Result log
|
||||||
|
*/
|
||||||
|
static resolveMeleeAttack(attacker, defender, gameEngine = null) {
|
||||||
|
const log = {
|
||||||
|
attackerId: attacker.id,
|
||||||
|
defenderId: defender.id,
|
||||||
|
hitRoll: 0,
|
||||||
|
targetToHit: 0,
|
||||||
|
hitSuccess: false,
|
||||||
|
damageRoll: 0,
|
||||||
|
damageTotal: 0,
|
||||||
|
woundsCaused: 0,
|
||||||
|
defenderDied: false,
|
||||||
|
message: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Determine Stats
|
||||||
|
// Use stats object if available, otherwise direct property (fallback)
|
||||||
|
const attStats = attacker.stats || attacker;
|
||||||
|
const defStats = defender.stats || defender;
|
||||||
|
|
||||||
|
const attWS = Math.min(Math.max(attStats.ws || 1, 1), 10);
|
||||||
|
const defWS = Math.min(Math.max(defStats.ws || 1, 1), 10);
|
||||||
|
|
||||||
|
// 2. Roll To Hit
|
||||||
|
log.targetToHit = this.getToHitTarget(attWS, defWS);
|
||||||
|
log.hitRoll = this.rollD6();
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
// console.log(`Combat: ${attacker.name} (WS${attWS}) vs ${defender.name} (WS${defWS}) -> Need ${log.targetToHit}+. Rolled ${log.hitRoll}`);
|
||||||
|
|
||||||
|
if (log.hitRoll < log.targetToHit) {
|
||||||
|
log.hitSuccess = false;
|
||||||
|
log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.hitSuccess = true;
|
||||||
|
|
||||||
|
// 3. Roll To Damage
|
||||||
|
const attStr = attStats.str || 3;
|
||||||
|
const defTough = defStats.toughness || 3;
|
||||||
|
const damageDice = attStats.damageDice || 1; // Default 1D6
|
||||||
|
|
||||||
|
let damageSum = 0;
|
||||||
|
let rolls = [];
|
||||||
|
for (let i = 0; i < damageDice; i++) {
|
||||||
|
const r = this.rollD6();
|
||||||
|
rolls.push(r);
|
||||||
|
damageSum += r;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.damageRoll = damageSum; // Just sum for simple log, or we could array it
|
||||||
|
log.damageTotal = damageSum + attStr;
|
||||||
|
|
||||||
|
// 4. Calculate Wounds
|
||||||
|
// Wounds = (Dice + Str) - Toughness
|
||||||
|
let wounds = log.damageTotal - defTough;
|
||||||
|
if (wounds < 0) wounds = 0;
|
||||||
|
|
||||||
|
log.woundsCaused = wounds;
|
||||||
|
|
||||||
|
// 5. Build Message
|
||||||
|
if (wounds > 0) {
|
||||||
|
log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||||
|
} else {
|
||||||
|
log.message = `${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Apply Damage to Defender State
|
||||||
|
this.applyDamage(defender, wounds, gameEngine);
|
||||||
|
|
||||||
|
if (defender.isDead) {
|
||||||
|
log.defenderDied = true;
|
||||||
|
log.message += ` ¡${defender.name} ha muerto!`;
|
||||||
|
} else if (defender.isUnconscious) {
|
||||||
|
log.message += ` ¡${defender.name} cae inconsciente!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
static resolveRangedAttack(attacker, defender, gameEngine = null) {
|
||||||
|
const log = {
|
||||||
|
attackerId: attacker.id,
|
||||||
|
defenderId: defender.id,
|
||||||
|
hitSuccess: false,
|
||||||
|
damageTotal: 0,
|
||||||
|
woundsCaused: 0,
|
||||||
|
defenderDied: false,
|
||||||
|
message: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Roll To Hit (BS vs WS)
|
||||||
|
// Use attacker BS or default to WS if missing (fallback).
|
||||||
|
const attackerBS = attacker.stats.bs || attacker.stats.ws;
|
||||||
|
const defenderWS = defender.stats.ws;
|
||||||
|
|
||||||
|
const toHitTarget = this.getToHitTarget(attackerBS, defenderWS);
|
||||||
|
const hitRoll = this.rollD6();
|
||||||
|
log.hitRoll = hitRoll;
|
||||||
|
log.toHitTarget = toHitTarget;
|
||||||
|
|
||||||
|
if (hitRoll === 1) {
|
||||||
|
log.hitSuccess = false;
|
||||||
|
log.message = `${attacker.name} dispara y falla (1 es fallo automático)`;
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hitRoll < toHitTarget) {
|
||||||
|
log.hitSuccess = false;
|
||||||
|
log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.hitSuccess = true;
|
||||||
|
|
||||||
|
// 2. Roll Damage
|
||||||
|
// Elf Bow Strength = 3
|
||||||
|
const weaponStrength = 3;
|
||||||
|
const damageRoll = this.rollD6();
|
||||||
|
const damageTotal = weaponStrength + damageRoll;
|
||||||
|
log.damageRoll = damageRoll;
|
||||||
|
log.damageTotal = damageTotal;
|
||||||
|
|
||||||
|
// 3. Compare vs Toughness
|
||||||
|
const defTough = defender.stats.toughness || 1;
|
||||||
|
const wounds = Math.max(0, damageTotal - defTough);
|
||||||
|
|
||||||
|
log.woundsCaused = wounds;
|
||||||
|
|
||||||
|
// 4. Build Message
|
||||||
|
if (wounds > 0) {
|
||||||
|
log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||||
|
} else {
|
||||||
|
log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Apply Damage
|
||||||
|
this.applyDamage(defender, wounds, gameEngine);
|
||||||
|
|
||||||
|
if (defender.isDead) {
|
||||||
|
log.defenderDied = true;
|
||||||
|
log.message += ` ¡${defender.name} ha muerto!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getToHitTarget(attackerWS, defenderWS) {
|
||||||
|
// Adjust for 0-index array
|
||||||
|
const row = attackerWS - 1;
|
||||||
|
const col = defenderWS - 1;
|
||||||
|
if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
|
||||||
|
return TO_HIT_CHART[row][col];
|
||||||
|
}
|
||||||
|
return 6; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
static applyDamage(entity, amount, gameEngine = null) {
|
||||||
|
if (!entity.stats) entity.stats = {};
|
||||||
|
|
||||||
|
// If entity doesn't have current wounds tracked, init it from max
|
||||||
|
if (entity.currentWounds === undefined) {
|
||||||
|
// For Heros it is 'wounds', for Monsters typical just 'wounds' in def
|
||||||
|
// We assume entity has been initialized properly before,
|
||||||
|
// but if not, we grab max from definition
|
||||||
|
entity.currentWounds = entity.stats.wounds || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.currentWounds -= amount;
|
||||||
|
|
||||||
|
// Check Status
|
||||||
|
if (entity.type === 'hero') {
|
||||||
|
if (entity.currentWounds <= 0) {
|
||||||
|
entity.currentWounds = 0;
|
||||||
|
entity.isConscious = false;
|
||||||
|
// entity.isDead is not immediate for heroes usually, but let's handle via isConscious
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Monsters die at 0
|
||||||
|
if (entity.currentWounds <= 0) {
|
||||||
|
entity.currentWounds = 0;
|
||||||
|
entity.isDead = true;
|
||||||
|
// Trigger death callback if available
|
||||||
|
if (gameEngine && gameEngine.onEntityDeath) {
|
||||||
|
gameEngine.onEntityDeath(entity.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static rollD6() {
|
||||||
|
return Math.floor(Math.random() * 6) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
||||||
|
import { TurnManager } from './TurnManager.js';
|
||||||
|
import { MonsterAI } from './MonsterAI.js';
|
||||||
|
import { CombatMechanics } from './CombatMechanics.js';
|
||||||
|
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
||||||
|
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
||||||
|
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GameEngine for Manual Dungeon Construction with Player Movement
|
* GameEngine for Manual Dungeon Construction with Player Movement
|
||||||
@@ -6,14 +12,24 @@ import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
|||||||
export class GameEngine {
|
export class GameEngine {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dungeon = new DungeonGenerator();
|
this.dungeon = new DungeonGenerator();
|
||||||
|
this.turnManager = new TurnManager();
|
||||||
|
this.ai = new MonsterAI(this); // Init AI
|
||||||
this.player = null;
|
this.player = null;
|
||||||
this.selectedEntity = null;
|
this.selectedEntity = null;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
this.plannedPath = []; // Array of {x,y}
|
||||||
|
this.visitedRoomIds = new Set(); // Track tiles triggered
|
||||||
|
this.eventDeck = createEventDeck();
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
this.onEntityUpdate = null;
|
this.onEntityUpdate = null;
|
||||||
this.onEntityMove = null;
|
this.onEntityMove = null;
|
||||||
this.onEntitySelect = null;
|
this.onEntitySelect = null;
|
||||||
|
this.onRangedTarget = null; // New: For ranged targeting visualization
|
||||||
|
this.onEntityActive = null; // New: When entity starts/ends turn
|
||||||
|
this.onShowMessage = null; // New: Generic temporary message UI callback
|
||||||
|
this.onEntityHit = null; // New: When entity takes damage
|
||||||
|
this.onEntityDeath = null; // New: When entity dies
|
||||||
this.onPathChange = null;
|
this.onPathChange = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,85 +37,820 @@ export class GameEngine {
|
|||||||
|
|
||||||
this.dungeon.startDungeon(missionConfig);
|
this.dungeon.startDungeon(missionConfig);
|
||||||
|
|
||||||
// Create player at center of first tile
|
// Create Party (4 Heroes)
|
||||||
this.createPlayer(1.5, 2.5); // Center of 2x6 corridor
|
this.createParty();
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
this.turnManager.startGame();
|
||||||
|
|
||||||
|
// Listen for Phase Changes to Reset Moves
|
||||||
|
this.turnManager.on('phase_changed', (phase) => {
|
||||||
|
if (phase === 'hero' || phase === 'exploration') {
|
||||||
|
this.resetHeroMoves();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createPlayer(x, y) {
|
resetHeroMoves() {
|
||||||
this.player = {
|
if (!this.heroes) return;
|
||||||
id: 'p1',
|
this.heroes.forEach(hero => {
|
||||||
name: 'Barbarian',
|
hero.currentMoves = hero.stats.move;
|
||||||
x: Math.floor(x),
|
hero.hasAttacked = false;
|
||||||
y: Math.floor(y),
|
});
|
||||||
texturePath: '/assets/images/dungeon1/standees/barbaro.png'
|
console.log("Refilled Hero Moves");
|
||||||
};
|
}
|
||||||
|
|
||||||
if (this.onEntityUpdate) {
|
createParty() {
|
||||||
this.onEntityUpdate(this.player);
|
this.heroes = [];
|
||||||
|
this.monsters = []; // Initialize monsters array
|
||||||
|
|
||||||
|
// Definition Keys
|
||||||
|
const heroKeys = ['barbarian', 'dwarf', 'elf', 'wizard'];
|
||||||
|
// Find valid spawn points dynamically
|
||||||
|
const startPositions = this.findSpawnPoints(4);
|
||||||
|
|
||||||
|
if (startPositions.length < 4) {
|
||||||
|
console.error("Could not find enough spawn points!");
|
||||||
|
// Fallback
|
||||||
|
startPositions.push({ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 1, y: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
heroKeys.forEach((key, index) => {
|
||||||
|
const definition = HERO_DEFINITIONS[key];
|
||||||
|
const pos = startPositions[index];
|
||||||
|
|
||||||
|
const hero = {
|
||||||
|
id: `hero_${key}`,
|
||||||
|
type: 'hero',
|
||||||
|
key: key,
|
||||||
|
name: definition.name,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
texturePath: definition.portrait,
|
||||||
|
stats: { ...definition.stats },
|
||||||
|
// Game State
|
||||||
|
currentMoves: definition.stats.move,
|
||||||
|
hasAttacked: false,
|
||||||
|
isConscious: true,
|
||||||
|
hasLantern: key === 'barbarian' // Default leader
|
||||||
|
};
|
||||||
|
|
||||||
|
this.heroes.push(hero);
|
||||||
|
|
||||||
|
if (this.onEntityUpdate) {
|
||||||
|
this.onEntityUpdate(hero);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set First Player as Active
|
||||||
|
this.activeHeroIndex = 0;
|
||||||
|
|
||||||
|
// Legacy support for single player var (getter proxy)
|
||||||
|
this.player = this.heroes[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
onCellClick(x, y) {
|
spawnMonster(monsterKey, x, y, options = {}) {
|
||||||
// If no player selected, select player on click
|
const definition = MONSTER_DEFINITIONS[monsterKey];
|
||||||
if (!this.selectedEntity && this.player && x === this.player.x && y === this.player.y) {
|
if (!definition) {
|
||||||
this.selectedEntity = this.player;
|
console.error(`Monster definition not found: ${monsterKey}`);
|
||||||
if (this.onEntitySelect) {
|
|
||||||
this.onEntitySelect(this.player.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If player selected, move to clicked cell
|
// Ensure unique ID even in tight loops
|
||||||
if (this.selectedEntity === this.player) {
|
if (!this._monsterIdCounter) this._monsterIdCounter = 0;
|
||||||
if (this.canMoveTo(x, y)) {
|
this._monsterIdCounter++;
|
||||||
this.movePlayer(x, y);
|
const id = `monster_${monsterKey}_${Date.now()}_${this._monsterIdCounter}`;
|
||||||
} else {
|
|
||||||
|
|
||||||
|
console.log(`[GameEngine] Creating monster ${id} at ${x},${y}`);
|
||||||
|
|
||||||
|
const monster = {
|
||||||
|
id: id,
|
||||||
|
type: 'monster',
|
||||||
|
key: monsterKey,
|
||||||
|
name: definition.name,
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
texturePath: definition.portrait,
|
||||||
|
stats: { ...definition.stats },
|
||||||
|
// Game State
|
||||||
|
currentWounds: definition.stats.wounds || 1,
|
||||||
|
isDead: false,
|
||||||
|
skipTurn: !!options.skipTurn // Summoning sickness flag
|
||||||
|
};
|
||||||
|
|
||||||
|
this.monsters.push(monster);
|
||||||
|
|
||||||
|
if (this.onEntityUpdate) {
|
||||||
|
this.onEntityUpdate(monster);
|
||||||
|
}
|
||||||
|
|
||||||
|
return monster;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCellClick(x, y) {
|
||||||
|
// RANGED TARGETING LOGIC
|
||||||
|
if (this.targetingMode === 'ranged') {
|
||||||
|
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||||
|
if (clickedMonster) {
|
||||||
|
if (this.selectedEntity && this.selectedEntity.type === 'hero') {
|
||||||
|
const los = this.checkLineOfSightStrict(this.selectedEntity, clickedMonster);
|
||||||
|
this.selectedMonster = clickedMonster;
|
||||||
|
if (this.onRangedTarget) {
|
||||||
|
this.onRangedTarget(clickedMonster, los);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Determine if we clicked something else relevant or empty space
|
||||||
|
// If clicked self (hero), maybe cancel?
|
||||||
|
// For now, any non-monster click cancels targeting
|
||||||
|
// Unless it's just a UI click (handled by DOM)
|
||||||
|
this.cancelTargeting();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Identify clicked contents
|
||||||
|
const clickedHero = this.heroes.find(h => h.x === x && h.y === y);
|
||||||
|
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||||
|
|
||||||
|
const clickedEntity = clickedHero || clickedMonster;
|
||||||
|
|
||||||
|
if (clickedEntity) {
|
||||||
|
if (this.selectedEntity === clickedEntity) {
|
||||||
|
// Toggle Deselect
|
||||||
|
this.deselectEntity();
|
||||||
|
} else if (this.selectedMonster === clickedMonster && clickedMonster) {
|
||||||
|
// Clicking on already selected monster - deselect it
|
||||||
|
const monsterId = this.selectedMonster.id;
|
||||||
|
this.selectedMonster = null;
|
||||||
|
if (this.onEntitySelect) {
|
||||||
|
this.onEntitySelect(monsterId, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Select new entity (don't deselect hero if clicking monster)
|
||||||
|
if (clickedMonster && this.selectedEntity && this.selectedEntity.type === 'hero') {
|
||||||
|
// Deselect previous monster if any
|
||||||
|
if (this.selectedMonster) {
|
||||||
|
const prevMonsterId = this.selectedMonster.id;
|
||||||
|
if (this.onEntitySelect) {
|
||||||
|
this.onEntitySelect(prevMonsterId, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep hero selected, also select monster
|
||||||
|
this.selectedMonster = clickedMonster;
|
||||||
|
if (this.onEntitySelect) {
|
||||||
|
this.onEntitySelect(clickedMonster.id, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal selection (deselect previous)
|
||||||
|
if (this.selectedEntity) this.deselectEntity();
|
||||||
|
|
||||||
|
this.selectedEntity = clickedEntity;
|
||||||
|
if (this.onEntitySelect) {
|
||||||
|
this.onEntitySelect(clickedEntity.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Pinned Status
|
||||||
|
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
|
||||||
|
if (this.isEntityPinned(clickedEntity)) {
|
||||||
|
if (this.onShowMessage) {
|
||||||
|
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
|
||||||
|
if (this.selectedEntity) {
|
||||||
|
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
|
||||||
|
if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.planStep(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
performHeroAttack(targetMonsterId) {
|
||||||
|
const hero = this.selectedEntity;
|
||||||
|
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||||
|
|
||||||
|
if (!hero || !monster) return null;
|
||||||
|
|
||||||
|
// Check Phase
|
||||||
|
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
||||||
|
|
||||||
|
// 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' };
|
||||||
|
|
||||||
|
// Check Action Economy
|
||||||
|
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
||||||
|
|
||||||
|
// Execute Attack
|
||||||
|
const result = CombatMechanics.resolveMeleeAttack(hero, monster, this);
|
||||||
|
hero.hasAttacked = true;
|
||||||
|
|
||||||
|
if (this.onCombatResult) this.onCombatResult(result);
|
||||||
|
|
||||||
|
return { success: true, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
isEntityPinned(entity) {
|
||||||
|
if (!this.monsters || this.monsters.length === 0) return false;
|
||||||
|
|
||||||
|
return this.monsters.some(m => {
|
||||||
|
if (m.isDead) return false;
|
||||||
|
const dx = Math.abs(entity.x - m.x);
|
||||||
|
const dy = Math.abs(entity.y - m.y);
|
||||||
|
|
||||||
|
// 1. Must be Adjacent (Manhattan distance 1)
|
||||||
|
if (dx + dy !== 1) return false;
|
||||||
|
|
||||||
|
// 2. Check Logical Connectivity (Wall check)
|
||||||
|
const grid = this.dungeon.grid;
|
||||||
|
const key1 = `${entity.x},${entity.y}`;
|
||||||
|
const key2 = `${m.x},${m.y}`;
|
||||||
|
|
||||||
|
const data1 = grid.cellData.get(key1);
|
||||||
|
const data2 = grid.cellData.get(key2);
|
||||||
|
|
||||||
|
if (!data1 || !data2) return false;
|
||||||
|
|
||||||
|
// Same Tile -> Connected
|
||||||
|
if (data1.tileId === data2.tileId) return true;
|
||||||
|
|
||||||
|
// Different Tile -> Must be connected by a Door
|
||||||
|
const isDoor1 = grid.doorCells.has(key1);
|
||||||
|
const isDoor2 = grid.doorCells.has(key2);
|
||||||
|
|
||||||
|
if (!isDoor1 && !isDoor2) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
planStep(x, y) {
|
||||||
|
if (!this.selectedEntity) return;
|
||||||
|
|
||||||
|
// Valid Phase Check
|
||||||
|
// Allow movement ONLY in Hero Phase.
|
||||||
|
// Exploration Phase is for opening doors only (no movement).
|
||||||
|
const phase = this.turnManager.currentPhase;
|
||||||
|
if (phase !== 'hero' && this.selectedEntity.type === 'hero') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine start point
|
||||||
|
const lastStep = this.plannedPath.length > 0
|
||||||
|
? this.plannedPath[this.plannedPath.length - 1]
|
||||||
|
: { x: this.selectedEntity.x, y: this.selectedEntity.y };
|
||||||
|
|
||||||
|
// Check Adjacency
|
||||||
|
const dx = Math.abs(x - lastStep.x);
|
||||||
|
const dy = Math.abs(y - lastStep.y);
|
||||||
|
const isAdjacent = (dx + dy) === 1;
|
||||||
|
|
||||||
|
// Check Walkability
|
||||||
|
const isWalkable = this.canMoveTo(x, y);
|
||||||
|
|
||||||
|
// Check against Max Move Stats
|
||||||
|
const maxMove = this.selectedEntity.currentMoves || 0;
|
||||||
|
|
||||||
|
// Also account for the potential next step
|
||||||
|
if (this.plannedPath.length >= maxMove && !(this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y)) {
|
||||||
|
// Allow undo (next block), but block new steps
|
||||||
|
if (isAdjacent && isWalkable) {
|
||||||
|
// Prevent adding more steps
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo Logic
|
||||||
|
if (this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y) {
|
||||||
|
this.plannedPath.pop();
|
||||||
|
this.onPathChange && this.onPathChange(this.plannedPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdjacent && isWalkable) {
|
||||||
|
const alreadyInPath = this.plannedPath.some(p => p.x === x && p.y === y);
|
||||||
|
const isEntityPos = this.selectedEntity.x === x && this.selectedEntity.y === y;
|
||||||
|
|
||||||
|
// Also check if occupied by OTHER heroes?
|
||||||
|
const isOccupiedByHero = this.heroes.some(h => h.x === x && h.y === y && h !== this.selectedEntity);
|
||||||
|
|
||||||
|
if (!alreadyInPath && !isEntityPos && !isOccupiedByHero) {
|
||||||
|
this.plannedPath.push({ x, y });
|
||||||
|
this.onPathChange && this.onPathChange(this.plannedPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
executeMovePath() {
|
||||||
|
if (!this.selectedEntity || !this.plannedPath.length) return;
|
||||||
|
|
||||||
|
const fullPath = [...this.plannedPath];
|
||||||
|
const entity = this.selectedEntity;
|
||||||
|
|
||||||
|
let stepsTaken = 0;
|
||||||
|
let triggeredEvents = false;
|
||||||
|
|
||||||
|
// Step-by-step execution to check for triggers
|
||||||
|
for (let i = 0; i < fullPath.length; i++) {
|
||||||
|
const step = fullPath[i];
|
||||||
|
|
||||||
|
// 1. Move Entity State
|
||||||
|
entity.x = step.x;
|
||||||
|
entity.y = step.y;
|
||||||
|
stepsTaken++;
|
||||||
|
|
||||||
|
// 2. Check for New Tile Entry
|
||||||
|
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
|
||||||
|
|
||||||
|
if (tileId && !this.visitedRoomIds.has(tileId)) {
|
||||||
|
|
||||||
|
// Mark as visited immediatley
|
||||||
|
this.visitedRoomIds.add(tileId);
|
||||||
|
|
||||||
|
// Check Tile Type (Room vs Corridor)
|
||||||
|
const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId);
|
||||||
|
const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective'));
|
||||||
|
|
||||||
|
if (isRoom) {
|
||||||
|
console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`);
|
||||||
|
|
||||||
|
// Disparar Evento (need cells)
|
||||||
|
const newCells = [];
|
||||||
|
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
|
||||||
|
if (tid === tileId) {
|
||||||
|
const [cx, cy] = key.split(',').map(Number);
|
||||||
|
newCells.push({ x: cx, y: cy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Event Logic
|
||||||
|
const eventResult = this.onRoomRevealed(newCells);
|
||||||
|
|
||||||
|
// Always stop for Rooms
|
||||||
|
if (eventResult) {
|
||||||
|
console.log("Movement stopped by Room Entry!");
|
||||||
|
triggeredEvents = true;
|
||||||
|
|
||||||
|
// Notify UI via callback
|
||||||
|
if (this.onEventTriggered) {
|
||||||
|
this.onEventTriggered(eventResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send PARTIAL path to renderer (from 0 to current step i+1)
|
||||||
|
if (this.onEntityMove) {
|
||||||
|
this.onEntityMove(entity, fullPath.slice(0, i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // Stop loop
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If NO interruption, send full path
|
||||||
|
if (!triggeredEvents) {
|
||||||
|
if (this.onEntityMove) {
|
||||||
|
this.onEntityMove(entity, fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct Moves
|
||||||
|
if (entity.currentMoves !== undefined) {
|
||||||
|
// Only deduct steps actually taken. No penalty.
|
||||||
|
entity.currentMoves -= stepsTaken;
|
||||||
|
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deselectEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
canMoveTo(x, y) {
|
canMoveTo(x, y) {
|
||||||
// Check if cell is walkable (occupied by a tile)
|
// Check if cell is walkable (occupied by a tile)
|
||||||
return this.dungeon.grid.isOccupied(x, y);
|
return this.dungeon.grid.isOccupied(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated direct move
|
||||||
movePlayer(x, y) {
|
movePlayer(x, y) {
|
||||||
// Simple direct movement (no pathfinding for now)
|
|
||||||
const path = [{ x, y }];
|
|
||||||
|
|
||||||
this.player.x = x;
|
this.player.x = x;
|
||||||
this.player.y = y;
|
this.player.y = y;
|
||||||
|
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
|
||||||
if (this.onEntityMove) {
|
|
||||||
this.onEntityMove(this.player, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deselect after move
|
|
||||||
this.selectedEntity = null;
|
|
||||||
if (this.onEntitySelect) {
|
|
||||||
this.onEntitySelect(this.player.id, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlayerAdjacentToDoor(doorExit) {
|
getLeader() {
|
||||||
if (!this.player) return false;
|
// Find hero with lantern, default to barbarian if something breaks, or first hero
|
||||||
|
return this.heroes.find(h => h.hasLantern) || this.heroes.find(h => h.key === 'barbarian') || this.heroes[0];
|
||||||
const dx = Math.abs(this.player.x - doorExit.x);
|
|
||||||
const dy = Math.abs(this.player.y - doorExit.y);
|
|
||||||
|
|
||||||
// Adjacent means distance of 1 in one direction and 0 in the other
|
|
||||||
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(time) {
|
update(time) {
|
||||||
// Minimal update loop
|
// Minimal update loop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findSpawnPoints(count) {
|
||||||
|
const points = [];
|
||||||
|
const startNode = { x: 0, y: 0 };
|
||||||
|
const searchQueue = [startNode];
|
||||||
|
const visited = new Set(['0,0']);
|
||||||
|
|
||||||
|
let loops = 0;
|
||||||
|
while (searchQueue.length > 0 && points.length < count && loops < 200) {
|
||||||
|
const current = searchQueue.shift();
|
||||||
|
|
||||||
|
if (this.dungeon.grid.isOccupied(current.x, current.y)) {
|
||||||
|
points.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbors
|
||||||
|
const neighbors = [
|
||||||
|
{ x: current.x + 1, y: current.y },
|
||||||
|
{ x: current.x - 1, y: current.y },
|
||||||
|
{ x: current.x, y: current.y + 1 },
|
||||||
|
{ x: current.x, y: current.y - 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const n of neighbors) {
|
||||||
|
const key = `${n.x},${n.y}`;
|
||||||
|
if (!visited.has(key)) {
|
||||||
|
visited.add(key);
|
||||||
|
searchQueue.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loops++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoomRevealed(cells) {
|
||||||
|
console.log("[GameEngine] Room Revealed!");
|
||||||
|
|
||||||
|
// 1. Draw Event Card
|
||||||
|
if (this.eventDeck.length === 0) {
|
||||||
|
console.warn("Event deck empty, reshaping...");
|
||||||
|
this.eventDeck = createEventDeck();
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = this.eventDeck.pop();
|
||||||
|
console.log(`[GameEngine] Event Drawn: ${card.name}`);
|
||||||
|
|
||||||
|
if (card.type === EVENT_TYPES.MONSTER) {
|
||||||
|
// 2. Determine Count
|
||||||
|
let count = 0;
|
||||||
|
if (typeof card.resolve === 'function') {
|
||||||
|
count = card.resolve(this, { cells });
|
||||||
|
} else {
|
||||||
|
count = 1; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[GameEngine] Spawning ${count} ${card.monsterKey}s`);
|
||||||
|
|
||||||
|
// 3. Find valid spawn spots
|
||||||
|
const availableCells = cells.filter(cell => {
|
||||||
|
const isHero = this.heroes.some(h => h.x === cell.x && h.y === cell.y);
|
||||||
|
const isMonster = this.monsters.some(m => m.x === cell.x && m.y === cell.y);
|
||||||
|
return !isHero && !isMonster;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[GameEngine] Available Spawn Cells: ${availableCells.length}`, availableCells);
|
||||||
|
|
||||||
|
// Shuffle
|
||||||
|
for (let i = availableCells.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[availableCells[i], availableCells[j]] = [availableCells[j], availableCells[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Spawn
|
||||||
|
let spawnedCount = 0;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (i < availableCells.length) {
|
||||||
|
const pos = availableCells[i];
|
||||||
|
console.log(`[GameEngine] Spawning at ${pos.x},${pos.y}`);
|
||||||
|
|
||||||
|
// Monster Spawn (Step-and-Stop rule)
|
||||||
|
// Monsters act in the upcoming Monster Phase.
|
||||||
|
this.spawnMonster(card.monsterKey, pos.x, pos.y);
|
||||||
|
|
||||||
|
spawnedCount++;
|
||||||
|
} else {
|
||||||
|
console.warn("[GameEngine] Not enough space!");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'MONSTER_SPAWN',
|
||||||
|
monsterKey: card.monsterKey,
|
||||||
|
count: spawnedCount,
|
||||||
|
cardName: card.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return event info even if empty, so movement stops
|
||||||
|
return {
|
||||||
|
type: 'EVENT',
|
||||||
|
cardName: card.name,
|
||||||
|
message: 'La sala parece despejada.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// MONSTER AI & TURN LOGIC
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
async playMonsterTurn() {
|
||||||
|
if (this.ai) {
|
||||||
|
await this.ai.executeTurn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Helper methods moved to MonsterAI.js
|
||||||
|
isLeaderAdjacentToDoor(doorCells) {
|
||||||
|
// ... (Keep this one as it's used by main.js logic for doors)
|
||||||
|
if (!this.heroes || this.heroes.length === 0) return false;
|
||||||
|
|
||||||
|
const leader = this.getLeader();
|
||||||
|
if (!leader) return false;
|
||||||
|
|
||||||
|
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
const dx = Math.abs(leader.x - cell.x);
|
||||||
|
const dy = Math.abs(leader.y - cell.y);
|
||||||
|
// Orthogonal adjacency check (Manhattan distance === 1)
|
||||||
|
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
startRangedTargeting() {
|
||||||
|
this.targetingMode = 'ranged';
|
||||||
|
console.log("Ranged Targeting Mode ON");
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTargeting() {
|
||||||
|
this.targetingMode = null;
|
||||||
|
if (this.onRangedTarget) {
|
||||||
|
this.onRangedTarget(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkLineOfSight(hero, target) {
|
||||||
|
// Robust Grid Traversal (Amanatides & Woo)
|
||||||
|
const x = hero.x + 0.5;
|
||||||
|
const y = hero.y + 0.5;
|
||||||
|
const endX = target.x + 0.5;
|
||||||
|
const endY = target.y + 0.5;
|
||||||
|
|
||||||
|
const dx = endX - x;
|
||||||
|
const dy = endY - y;
|
||||||
|
|
||||||
|
let currentX = Math.floor(x);
|
||||||
|
let currentY = Math.floor(y);
|
||||||
|
const targetX = Math.floor(endX);
|
||||||
|
const targetY = Math.floor(endY);
|
||||||
|
|
||||||
|
const stepX = Math.sign(dx);
|
||||||
|
const stepY = Math.sign(dy);
|
||||||
|
|
||||||
|
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
|
||||||
|
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||||
|
|
||||||
|
let tMaxX = stepX > 0 ? (Math.floor(x) + 1 - x) * tDeltaX : (x - Math.floor(x)) * tDeltaX;
|
||||||
|
let tMaxY = stepY > 0 ? (Math.floor(y) + 1 - y) * tDeltaY : (y - Math.floor(y)) * tDeltaY;
|
||||||
|
|
||||||
|
if (isNaN(tMaxX)) tMaxX = Infinity;
|
||||||
|
if (isNaN(tMaxY)) tMaxY = Infinity;
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
let blocked = false;
|
||||||
|
|
||||||
|
// Safety limit
|
||||||
|
const maxSteps = Math.abs(targetX - currentX) + Math.abs(targetY - currentY) + 20;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxSteps; i++) {
|
||||||
|
path.push({ x: currentX, y: currentY });
|
||||||
|
|
||||||
|
if (!(currentX === hero.x && currentY === hero.y) && !(currentX === target.x && currentY === target.y)) {
|
||||||
|
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||||
|
blocked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockerMonster = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||||
|
if (blockerMonster) { blocked = true; break; }
|
||||||
|
|
||||||
|
const blockerHero = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||||
|
if (blockerHero) { blocked = true; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentX === targetX && currentY === targetY) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tMaxX < tMaxY) {
|
||||||
|
tMaxX += tDeltaX;
|
||||||
|
currentX += stepX;
|
||||||
|
} else {
|
||||||
|
tMaxY += tDeltaY;
|
||||||
|
currentY += stepY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clear: !blocked, path: path };
|
||||||
|
}
|
||||||
|
checkLineOfSightPermissive(hero, target) {
|
||||||
|
const startX = hero.x + 0.5;
|
||||||
|
const startY = hero.y + 0.5;
|
||||||
|
const endX = target.x + 0.5;
|
||||||
|
const endY = target.y + 0.5;
|
||||||
|
|
||||||
|
const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
||||||
|
const steps = Math.ceil(dist * 5);
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
const visited = new Set();
|
||||||
|
let blocked = false;
|
||||||
|
let blocker = null;
|
||||||
|
|
||||||
|
const MARGIN = 0.2;
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const x = startX + (endX - startX) * t;
|
||||||
|
const y = startY + (endY - startY) * t;
|
||||||
|
|
||||||
|
const gx = Math.floor(x);
|
||||||
|
const gy = Math.floor(y);
|
||||||
|
const key = `${gx},${gy}`;
|
||||||
|
|
||||||
|
if (!visited.has(key)) {
|
||||||
|
path.push({ x: gx, y: gy, xWorld: x, yWorld: y });
|
||||||
|
visited.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((gx === hero.x && gy === hero.y) || (gx === target.x && gy === target.y)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dungeon.grid.isWall(gx, gy)) {
|
||||||
|
const lx = x - gx;
|
||||||
|
const ly = y - gy;
|
||||||
|
if (lx > MARGIN && lx < (1 - MARGIN) && ly > MARGIN && ly < (1 - MARGIN)) {
|
||||||
|
blocked = true;
|
||||||
|
blocker = { type: 'wall', x: gx, y: gy };
|
||||||
|
console.log(`[LOS] Blocked by WALL at ${gx},${gy}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockerMonster = this.monsters.find(m => m.x === gx && m.y === gy && !m.isDead && m.id !== target.id);
|
||||||
|
if (blockerMonster) {
|
||||||
|
blocked = true;
|
||||||
|
blocker = { type: 'monster', entity: blockerMonster };
|
||||||
|
console.log(`[LOS] Blocked by MONSTER: ${blockerMonster.name}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockerHero = this.heroes.find(h => h.x === gx && h.y === gy && h.id !== hero.id);
|
||||||
|
if (blockerHero) {
|
||||||
|
blocked = true;
|
||||||
|
blocker = { type: 'hero', entity: blockerHero };
|
||||||
|
console.log(`[LOS] Blocked by HERO: ${blockerHero.name}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clear: !blocked, path: path, blocker: blocker };
|
||||||
|
}
|
||||||
|
checkLineOfSightStrict(hero, target) {
|
||||||
|
// STRICT Grid Traversal (Amanatides & Woo)
|
||||||
|
const x1 = hero.x + 0.5;
|
||||||
|
const y1 = hero.y + 0.5;
|
||||||
|
const x2 = target.x + 0.5;
|
||||||
|
const y2 = target.y + 0.5;
|
||||||
|
|
||||||
|
let currentX = Math.floor(x1);
|
||||||
|
let currentY = Math.floor(y1);
|
||||||
|
const endX = Math.floor(x2);
|
||||||
|
const endY = Math.floor(y2);
|
||||||
|
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const stepX = Math.sign(dx);
|
||||||
|
const stepY = Math.sign(dy);
|
||||||
|
|
||||||
|
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
|
||||||
|
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||||
|
|
||||||
|
let tMaxX = stepX > 0 ? (Math.floor(x1) + 1 - x1) * tDeltaX : (x1 - Math.floor(x1)) * tDeltaX;
|
||||||
|
let tMaxY = stepY > 0 ? (Math.floor(y1) + 1 - y1) * tDeltaY : (y1 - Math.floor(y1)) * tDeltaY;
|
||||||
|
|
||||||
|
if (isNaN(tMaxX)) tMaxX = Infinity;
|
||||||
|
if (isNaN(tMaxY)) tMaxY = Infinity;
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
let blocked = false;
|
||||||
|
let blocker = null;
|
||||||
|
|
||||||
|
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxSteps; i++) {
|
||||||
|
path.push({ x: currentX, y: currentY });
|
||||||
|
|
||||||
|
const isStart = (currentX === hero.x && currentY === hero.y);
|
||||||
|
const isEnd = (currentX === target.x && currentY === target.y);
|
||||||
|
|
||||||
|
if (!isStart && !isEnd) {
|
||||||
|
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||||
|
blocked = true;
|
||||||
|
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||||
|
console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (currentX === endX && currentY === endY) break;
|
||||||
|
|
||||||
|
if (tMaxX < tMaxY) {
|
||||||
|
tMaxX += tDeltaX;
|
||||||
|
currentX += stepX;
|
||||||
|
} else {
|
||||||
|
tMaxY += tDeltaY;
|
||||||
|
currentY += stepY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clear: !blocked, path, blocker };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
278
src/engine/game/MonsterAI.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { CombatMechanics } from './CombatMechanics.js';
|
||||||
|
|
||||||
|
export class MonsterAI {
|
||||||
|
constructor(gameEngine) {
|
||||||
|
this.game = gameEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeTurn() {
|
||||||
|
console.log("[MonsterAI] --- TURN START ---");
|
||||||
|
|
||||||
|
if (!this.game.monsters || this.game.monsters.length === 0) {
|
||||||
|
console.log("[MonsterAI] No monsters active.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequential execution with delay
|
||||||
|
for (const monster of this.game.monsters) {
|
||||||
|
// Check if monster still exists
|
||||||
|
if (monster.isDead) continue;
|
||||||
|
|
||||||
|
// Check for Summoning Sickness / Ambush delay
|
||||||
|
if (monster.skipTurn) {
|
||||||
|
console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
|
||||||
|
monster.skipTurn = false; // Ready for next turn
|
||||||
|
|
||||||
|
// Add a small visual delay even if skipping, to show focus?
|
||||||
|
// No, better to just skip significantly to keep flow fast.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.processMonster(monster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processMonster(monster) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
// NO green ring here - only during attack
|
||||||
|
|
||||||
|
// Calculate delay based on potential move distance to ensure animation finishes
|
||||||
|
// SLOWER: 600ms per tile + Extra buffer for potential attack sequence
|
||||||
|
const moveTime = (monster.stats.move * 600) + 3000; // 3s buffer for attack sequence
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.actMonster(monster);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, moveTime);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
actMonster(monster) {
|
||||||
|
// 1. Check if already adjacent (Engaged) -> ATTACK
|
||||||
|
const adjacentHero = this.getAdjacentHero(monster);
|
||||||
|
|
||||||
|
if (adjacentHero) {
|
||||||
|
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
|
||||||
|
this.performAttack(monster, adjacentHero);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find Closest Hero to Move Towards
|
||||||
|
const targetHero = this.getClosestHero(monster);
|
||||||
|
if (!targetHero) {
|
||||||
|
console.log(`[MonsterAI] ${monster.id} has no targets.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Calculate Path (BFS with fallback)
|
||||||
|
const path = this.findPath(monster, targetHero, 30);
|
||||||
|
|
||||||
|
if (!path || path.length === 0) {
|
||||||
|
console.log(`[MonsterAI] ${monster.id} NO PATH (blocked) to ${targetHero.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Execute Move
|
||||||
|
const moveDist = monster.stats.move;
|
||||||
|
const actualPath = path.slice(0, moveDist);
|
||||||
|
|
||||||
|
console.log(`[MonsterAI] ${monster.id} moving towards ${targetHero.name}`, actualPath);
|
||||||
|
|
||||||
|
// 5. Update Renderer ONCE with full path
|
||||||
|
if (this.game.onEntityMove) {
|
||||||
|
this.game.onEntityMove(monster, actualPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Final Logic Update (Instant coordinates)
|
||||||
|
const finalDest = actualPath[actualPath.length - 1];
|
||||||
|
monster.x = finalDest.x;
|
||||||
|
monster.y = finalDest.y;
|
||||||
|
|
||||||
|
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
|
||||||
|
|
||||||
|
// 7. Check if NOW adjacent after move -> ATTACK
|
||||||
|
// Wait for movement animation to complete before checking
|
||||||
|
const movementDuration = actualPath.length * 600;
|
||||||
|
setTimeout(() => {
|
||||||
|
const postMoveHero = this.getAdjacentHero(monster);
|
||||||
|
if (postMoveHero) {
|
||||||
|
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
|
||||||
|
this.performAttack(monster, postMoveHero);
|
||||||
|
}
|
||||||
|
}, movementDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
getClosestHero(monster) {
|
||||||
|
let nearest = null;
|
||||||
|
let minDist = Infinity;
|
||||||
|
|
||||||
|
this.game.heroes.forEach(hero => {
|
||||||
|
if (!hero.isConscious && hero.isDead) return;
|
||||||
|
|
||||||
|
const dist = Math.abs(monster.x - hero.x) + Math.abs(monster.y - hero.y);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
nearest = hero;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEntityAdjacentToHero(entity) {
|
||||||
|
return this.game.heroes.some(hero => {
|
||||||
|
const dx = Math.abs(entity.x - hero.x);
|
||||||
|
const dy = Math.abs(entity.y - hero.y);
|
||||||
|
return (dx + dy) === 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isOccupied(x, y, fromX, fromY) {
|
||||||
|
// Check if target cell exists in grid
|
||||||
|
if (!this.game.dungeon.grid.isOccupied(x, y)) {
|
||||||
|
return true; // Wall/Void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Heroes
|
||||||
|
if (this.game.heroes.some(h => h.x === x && h.y === y)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Monsters
|
||||||
|
if (this.game.monsters.some(m => m.x === x && m.y === y)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Check if movement is valid (room boundaries, height levels, stairs)
|
||||||
|
if (fromX !== undefined && fromY !== undefined) {
|
||||||
|
if (!this.game.dungeon.grid.canMoveBetween(fromX, fromY, x, y)) {
|
||||||
|
return true; // Movement blocked by room boundary or height restriction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPath(start, goal, limit = 50) {
|
||||||
|
const queue = [{ x: start.x, y: start.y, path: [] }];
|
||||||
|
const visited = new Set([`${start.x},${start.y}`]);
|
||||||
|
|
||||||
|
let bestPath = null;
|
||||||
|
let minDistToGoal = Infinity;
|
||||||
|
|
||||||
|
// Init min dist (Manhattan)
|
||||||
|
minDistToGoal = Math.abs(start.x - goal.x) + Math.abs(start.y - goal.y);
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
|
||||||
|
const dist = Math.abs(current.x - goal.x) + Math.abs(current.y - goal.y);
|
||||||
|
|
||||||
|
// Success: Adjacent to goal
|
||||||
|
if (dist === 1) {
|
||||||
|
return current.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Best Fallback: closest we got to the target so far
|
||||||
|
if (dist < minDistToGoal) {
|
||||||
|
minDistToGoal = dist;
|
||||||
|
bestPath = current.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.path.length >= limit) continue;
|
||||||
|
|
||||||
|
const neighbors = [
|
||||||
|
{ x: current.x + 1, y: current.y },
|
||||||
|
{ x: current.x - 1, y: current.y },
|
||||||
|
{ x: current.x, y: current.y + 1 },
|
||||||
|
{ x: current.x, y: current.y - 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const n of neighbors) {
|
||||||
|
// Check if movement from current to neighbor is valid
|
||||||
|
// This now includes room boundary, height, and stair checks
|
||||||
|
if (this.isOccupied(n.x, n.y, current.x, current.y)) continue;
|
||||||
|
|
||||||
|
const key = `${n.x},${n.y}`;
|
||||||
|
if (!visited.has(key)) {
|
||||||
|
visited.add(key);
|
||||||
|
queue.push({
|
||||||
|
x: n.x,
|
||||||
|
y: n.y,
|
||||||
|
path: [...current.path, { x: n.x, y: n.y }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we exhausted reachable tiles or limit, return the best path found (e.g. getting closer)
|
||||||
|
// Only return if we actually have a path to move (length > 0)
|
||||||
|
return bestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
performAttack(monster, hero) {
|
||||||
|
// SEQUENCE:
|
||||||
|
// 1. Show green ring on monster
|
||||||
|
// 2. Monster attack animation (we'll simulate with delay)
|
||||||
|
// 3. Show red ring + shake on hero
|
||||||
|
// 4. Remove both rings
|
||||||
|
// 5. Show combat result
|
||||||
|
|
||||||
|
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
|
||||||
|
|
||||||
|
// Step 1: Green ring on attacker
|
||||||
|
if (this.game.onEntityActive) {
|
||||||
|
this.game.onEntityActive(monster.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Attack animation delay (500ms)
|
||||||
|
setTimeout(() => {
|
||||||
|
// Step 3: Trigger hit visual on defender (if hit succeeded)
|
||||||
|
if (result.hitSuccess && this.game.onEntityHit) {
|
||||||
|
this.game.onEntityHit(hero.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.game.onEntityActive) {
|
||||||
|
this.game.onEntityActive(monster.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Show combat result after both rings are gone
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.game.onCombatResult) {
|
||||||
|
this.game.onCombatResult(result);
|
||||||
|
}
|
||||||
|
}, 200); // Small delay after rings disappear
|
||||||
|
}, 1200); // Wait for red ring to disappear
|
||||||
|
}, 500); // Attack animation delay
|
||||||
|
}
|
||||||
|
|
||||||
|
getAdjacentHero(entity) {
|
||||||
|
return this.game.heroes.find(hero => {
|
||||||
|
// Check conscious or allow beating unconscious? standard rules say monsters attack unconscious heroes until death.
|
||||||
|
// But let's check basic mechanics first.
|
||||||
|
// "Cuando al Aventurero no le quedan más Heridas cae al suelo inconsciente... El Aventurero no está necesariamente muerto"
|
||||||
|
// "Continúa anotando el número de Heridas hasta que no le quedan más... nunca puede bajar de 0."
|
||||||
|
// Implicitly, they can still be attacked.
|
||||||
|
|
||||||
|
if (hero.isDead) return false;
|
||||||
|
|
||||||
|
const dx = Math.abs(entity.x - hero.x);
|
||||||
|
const dy = Math.abs(entity.y - hero.y);
|
||||||
|
|
||||||
|
// Must be orthogonally adjacent (Manhattan dist 1)
|
||||||
|
if ((dx + dy) !== 1) return false;
|
||||||
|
|
||||||
|
// NEW: Check if movement between monster and hero is valid
|
||||||
|
// This prevents attacking through walls/room boundaries
|
||||||
|
if (!this.game.dungeon.grid.canMoveBetween(entity.x, entity.y, hero.x, hero.y)) {
|
||||||
|
return false; // Wall or room boundary blocks attack
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,30 +5,36 @@ export class TurnManager {
|
|||||||
this.currentTurn = 0;
|
this.currentTurn = 0;
|
||||||
this.currentPhase = GAME_PHASES.SETUP;
|
this.currentPhase = GAME_PHASES.SETUP;
|
||||||
this.listeners = {}; // Simple event system
|
this.listeners = {}; // Simple event system
|
||||||
|
|
||||||
|
// Power Phase State
|
||||||
|
this.currentPowerRoll = 0;
|
||||||
|
this.eventsTriggered = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
startGame() {
|
startGame() {
|
||||||
this.currentTurn = 1;
|
this.currentTurn = 1;
|
||||||
this.setPhase(GAME_PHASES.HERO); // Jump straight to Hero phase for now
|
|
||||||
console.log(`--- TURN ${this.currentTurn} START ---`);
|
console.log(`--- TURN ${this.currentTurn} START ---`);
|
||||||
|
this.startPowerPhase();
|
||||||
}
|
}
|
||||||
|
|
||||||
nextPhase() {
|
nextPhase() {
|
||||||
// Simple sequential flow for now
|
// Simple sequential flow
|
||||||
switch (this.currentPhase) {
|
switch (this.currentPhase) {
|
||||||
case GAME_PHASES.POWER:
|
case GAME_PHASES.POWER:
|
||||||
this.setPhase(GAME_PHASES.HERO);
|
this.setPhase(GAME_PHASES.HERO);
|
||||||
break;
|
break;
|
||||||
case GAME_PHASES.HERO:
|
case GAME_PHASES.HERO:
|
||||||
// Usually goes to Exploration if at edge, or Monster if not.
|
// Move to Monster Phase
|
||||||
// For this dev stage, let's allow manual triggering of Exploration
|
|
||||||
// via UI, so we stay in HERO until confirmed done.
|
|
||||||
this.setPhase(GAME_PHASES.MONSTER);
|
this.setPhase(GAME_PHASES.MONSTER);
|
||||||
break;
|
break;
|
||||||
case GAME_PHASES.MONSTER:
|
case GAME_PHASES.MONSTER:
|
||||||
|
// Move to Exploration Phase
|
||||||
|
this.setPhase(GAME_PHASES.EXPLORATION);
|
||||||
|
break;
|
||||||
|
case GAME_PHASES.EXPLORATION:
|
||||||
|
// End Turn and restart
|
||||||
this.endTurn();
|
this.endTurn();
|
||||||
break;
|
break;
|
||||||
// Exploration is usually triggered as an interrupt, not strictly sequential
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +46,37 @@ export class TurnManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startPowerPhase() {
|
||||||
|
this.setPhase(GAME_PHASES.POWER);
|
||||||
|
this.rollPowerDice();
|
||||||
|
}
|
||||||
|
|
||||||
|
rollPowerDice() {
|
||||||
|
const roll = Math.floor(Math.random() * 6) + 1;
|
||||||
|
this.currentPowerRoll = roll;
|
||||||
|
console.log(`Power Roll: ${roll}`);
|
||||||
|
|
||||||
|
let message = "The dungeon is quiet...";
|
||||||
|
let eventTriggered = false;
|
||||||
|
|
||||||
|
if (roll === 1) {
|
||||||
|
message = "UNEXPECTED EVENT! (Roll of 1)";
|
||||||
|
eventTriggered = true;
|
||||||
|
this.triggerRandomEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('POWER_RESULT', { roll, message, eventTriggered });
|
||||||
|
|
||||||
|
// Auto-advance to Hero phase after short delay (game feel)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.nextPhase();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerRandomEvent() {
|
||||||
|
console.warn("TODO: TRIGGER EVENT CARD DRAW");
|
||||||
|
}
|
||||||
|
|
||||||
triggerExploration() {
|
triggerExploration() {
|
||||||
this.setPhase(GAME_PHASES.EXPLORATION);
|
this.setPhase(GAME_PHASES.EXPLORATION);
|
||||||
// Logic to return to HERO phase would handle elsewhere
|
// Logic to return to HERO phase would handle elsewhere
|
||||||
@@ -48,7 +85,7 @@ export class TurnManager {
|
|||||||
endTurn() {
|
endTurn() {
|
||||||
console.log(`--- TURN ${this.currentTurn} END ---`);
|
console.log(`--- TURN ${this.currentTurn} END ---`);
|
||||||
this.currentTurn++;
|
this.currentTurn++;
|
||||||
this.setPhase(GAME_PHASES.POWER);
|
this.startPowerPhase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Simple Observer Pattern --
|
// -- Simple Observer Pattern --
|
||||||
|
|||||||
224
src/main.js
@@ -36,6 +36,21 @@ generator.grid.placeTile = (instance, variant, card) => {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
renderer.renderExits(generator.availableExits);
|
renderer.renderExits(generator.availableExits);
|
||||||
|
|
||||||
|
// Don't show modal if we are not in Exploration phase (e.g. during Setup)
|
||||||
|
if (game.turnManager.currentPhase !== 'exploration') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW RULE: Exploration ends turn immediately. No monsters yet.
|
||||||
|
// Monsters appear when a hero ENTERS the new room in the next turn.
|
||||||
|
ui.showModal('Exploración Completada',
|
||||||
|
'Has colocado una nueva sección de mazmorra.<br>El turno termina aquí.',
|
||||||
|
() => {
|
||||||
|
game.turnManager.endTurn();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,21 +59,88 @@ game.onEntityUpdate = (entity) => {
|
|||||||
renderer.addEntity(entity);
|
renderer.addEntity(entity);
|
||||||
renderer.updateEntityPosition(entity);
|
renderer.updateEntityPosition(entity);
|
||||||
|
|
||||||
// Center camera on player spawn
|
// Center camera on FIRST hero spawn
|
||||||
if (entity.id === 'p1' && !entity._centered) {
|
if (game.heroes && game.heroes[0] && entity.id === game.heroes[0].id && !window._cameraCentered) {
|
||||||
cameraManager.centerOn(entity.x, entity.y);
|
cameraManager.centerOn(entity.x, entity.y);
|
||||||
entity._centered = true;
|
window._cameraCentered = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
game.turnManager.on('phase_changed', (phase) => {
|
||||||
|
if (phase === 'monster') {
|
||||||
|
setTimeout(async () => {
|
||||||
|
await game.playMonsterTurn();
|
||||||
|
|
||||||
|
// Logic: Skip Exploration if monsters are alive
|
||||||
|
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
|
||||||
|
|
||||||
|
if (hasActiveMonsters) {
|
||||||
|
ui.showModal('¡Combate en curso!',
|
||||||
|
'Aún quedan monstruos vivos. Se salta la Fase de Exploración.<br>Preparaos para la <b>Fase de Poder</b> del siguiente turno.',
|
||||||
|
() => {
|
||||||
|
// Combat Loop: Power -> Hero -> Monster -> (Skip Exp) -> Power...
|
||||||
|
game.turnManager.endTurn();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.showModal('Zona Despejada',
|
||||||
|
'Fase de Monstruos Finalizada.<br>Pulsa para continuar a la Fase de Exploración.',
|
||||||
|
() => {
|
||||||
|
game.turnManager.nextPhase(); // Go to Exploration
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 500); // Slight delay for visual impact
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
game.onCombatResult = (log) => {
|
||||||
|
ui.showCombatLog(log);
|
||||||
|
};
|
||||||
|
|
||||||
game.onEntityMove = (entity, path) => {
|
game.onEntityMove = (entity, path) => {
|
||||||
renderer.moveEntityAlongPath(entity, path);
|
renderer.moveEntityAlongPath(entity, path);
|
||||||
};
|
};
|
||||||
|
|
||||||
game.onEntitySelect = (entityId, isSelected) => {
|
game.onEntityActive = (entityId, isActive) => {
|
||||||
renderer.toggleEntitySelection(entityId, isSelected);
|
renderer.setEntityActive(entityId, isActive);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
game.onEntityHit = (entityId) => {
|
||||||
|
renderer.triggerDamageEffect(entityId);
|
||||||
|
};
|
||||||
|
|
||||||
|
game.onEntityDeath = (entityId) => {
|
||||||
|
renderer.triggerDeathAnimation(entityId);
|
||||||
|
};
|
||||||
|
|
||||||
|
game.onRangedTarget = (targetMonster, losResult) => {
|
||||||
|
// 1. Draw Visuals
|
||||||
|
renderer.showRangedTargeting(game.selectedEntity, targetMonster, losResult);
|
||||||
|
|
||||||
|
// 2. UI
|
||||||
|
if (targetMonster && losResult && losResult.clear) {
|
||||||
|
ui.showRangedAttackUI(targetMonster);
|
||||||
|
} else {
|
||||||
|
ui.hideMonsterCard();
|
||||||
|
if (targetMonster && losResult && !losResult.clear && losResult.blocker) {
|
||||||
|
let msg = 'Línea de visión bloqueada.';
|
||||||
|
if (losResult.blocker.type === 'hero') msg = `Bloqueado por aliado: ${losResult.blocker.entity.name}`;
|
||||||
|
if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`;
|
||||||
|
if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`;
|
||||||
|
|
||||||
|
ui.showTemporaryMessage('Objetivo Bloqueado', msg, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
game.onShowMessage = (title, message, duration) => {
|
||||||
|
ui.showTemporaryMessage(title, message, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
|
||||||
|
|
||||||
renderer.onHeroFinishedMove = (x, y) => {
|
renderer.onHeroFinishedMove = (x, y) => {
|
||||||
cameraManager.centerOn(x, y);
|
cameraManager.centerOn(x, y);
|
||||||
};
|
};
|
||||||
@@ -83,39 +165,91 @@ generator.onPlacementUpdate = (preview) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
generator.onDoorBlocked = (exitData) => {
|
||||||
|
renderer.blockDoor(exitData);
|
||||||
|
};
|
||||||
|
|
||||||
|
game.onPathChange = (path) => {
|
||||||
|
renderer.updatePathVisualization(path);
|
||||||
|
};
|
||||||
|
|
||||||
// 6. Handle Clicks
|
// 6. Handle Clicks
|
||||||
const handleClick = (x, y, doorMesh) => {
|
const handleClick = (x, y, doorMesh) => {
|
||||||
|
const currentPhase = game.turnManager.currentPhase;
|
||||||
|
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
|
||||||
|
|
||||||
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
||||||
if (generator.state === 'PLACING_TILE') {
|
if (generator.state === 'PLACING_TILE') {
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 2: Door Click (must be adjacent to player)
|
// PRIORITY 2: Door Click (must be adjacent to player)
|
||||||
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
if (doorMesh && doorMesh.userData.isDoor) {
|
||||||
const doorExit = doorMesh.userData.cells[0];
|
if (doorMesh.userData.isBlocked) {
|
||||||
|
ui.showModal('¡Derrumbe!', 'Esta puerta está bloqueada por un derrumbe. No se puede pasar.');
|
||||||
if (game.isPlayerAdjacentToDoor(doorExit)) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Open door visually
|
if (!doorMesh.userData.isOpen) {
|
||||||
renderer.openDoor(doorMesh);
|
|
||||||
|
// CHECK PHASE: Exploration Only
|
||||||
// Get proper exit data with direction
|
if (currentPhase !== 'exploration') {
|
||||||
const exitData = doorMesh.userData.exitData;
|
ui.showModal('Fase Incorrecta', 'Solo puedes explorar (abrir puertas) durante la <b>Fase de Exploración</b>.');
|
||||||
if (exitData) {
|
return;
|
||||||
generator.selectDoor(exitData);
|
}
|
||||||
} else {
|
|
||||||
console.error('[Main] Door missing exitData');
|
// CHECK MONSTERS: Must be clear
|
||||||
}
|
if (hasActiveMonsters) {
|
||||||
} else {
|
ui.showModal('¡Peligro!', 'No puedes explorar mientras hay <b>Monstruos</b> cerca. ¡Acaba con ellos primero!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check Selection and Leadership (STRICT)
|
||||||
|
const selectedHero = game.selectedEntity;
|
||||||
|
|
||||||
|
if (!selectedHero) {
|
||||||
|
ui.showModal('Ningún Héroe seleccionado', 'Selecciona al <b>Líder (Portador de la Lámpara)</b> para abrir la puerta.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedHero.hasLantern) {
|
||||||
|
ui.showModal('Acción no permitida', `<b>${selectedHero.name}</b> no lleva la Lámpara. Solo el <b>Líder</b> puede explorar.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Adjacency
|
||||||
|
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
|
||||||
|
|
||||||
|
// Open door visually
|
||||||
|
renderer.openDoor(doorMesh);
|
||||||
|
|
||||||
|
// Get proper exit data with direction
|
||||||
|
const exitData = doorMesh.userData.exitData;
|
||||||
|
if (exitData) {
|
||||||
|
generator.selectDoor(exitData);
|
||||||
|
} else {
|
||||||
|
console.error('[Main] Door missing exitData');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.showModal('Demasiado lejos', 'El Líder debe estar <b>adyacente</b> a la puerta para abrirla.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 3: Normal cell click (player selection/movement)
|
// PRIORITY 3: Normal cell click (player selection/movement)
|
||||||
if (x !== null && y !== null) {
|
if (x !== null && y !== null) {
|
||||||
|
// Restrict Hero Selection/Movement to Hero Phase (and verify logic in GameEngine handle selection)
|
||||||
|
// Actually, we might want to select heroes in other phases to see stats, but MOVE only in Hero Phase.
|
||||||
|
// GameEngine.planStep handles planning.
|
||||||
|
|
||||||
|
// We let GameEngine handle selection. But for movement planning...
|
||||||
|
// Let's modify onCellClick inside GameEngine or just block here?
|
||||||
|
// Blocking execution is safer.
|
||||||
|
|
||||||
|
// Wait, onCellClick handles Selection AND Planning.
|
||||||
|
// We'll let it select. But we hook executeMovePath separately.
|
||||||
|
|
||||||
game.onCellClick(x, y);
|
game.onCellClick(x, y);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -123,13 +257,51 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
renderer.setupInteraction(
|
renderer.setupInteraction(
|
||||||
() => cameraManager.getCamera(),
|
() => cameraManager.getCamera(),
|
||||||
handleClick,
|
handleClick,
|
||||||
() => { } // No right-click
|
() => {
|
||||||
|
// Right Click Handler
|
||||||
|
game.executeMovePath();
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Debug: Spawn Monster
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'm' || e.key === 'M') {
|
||||||
|
const x = game.player.x + 2;
|
||||||
|
const y = game.player.y;
|
||||||
|
if (game.dungeon.grid.isOccupied(x, y)) {
|
||||||
|
console.log("Spawning Orc...");
|
||||||
|
game.spawnMonster('orc', x, y);
|
||||||
|
} else {
|
||||||
|
console.log("Cannot spawn here");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
game.onEventTriggered = (eventResult) => {
|
||||||
|
if (eventResult) {
|
||||||
|
if (eventResult.type === 'MONSTER_SPAWN') {
|
||||||
|
const count = eventResult.count || 0;
|
||||||
|
ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen <b>${count} Enemigos</b>!<br>Tu movimiento se detiene.`);
|
||||||
|
} else if (eventResult.message) {
|
||||||
|
ui.showModal('Zona Explorada', `${eventResult.message}<br>Tu movimiento se detiene.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 7. Start
|
// 7. Start
|
||||||
|
|
||||||
game.startMission(mission);
|
game.startMission(mission);
|
||||||
|
|
||||||
|
// Mark initial tile as visited to prevent immediate trigger
|
||||||
|
if (game.heroes && game.heroes.length > 0) {
|
||||||
|
const h = game.heroes[0];
|
||||||
|
const initialTileId = game.dungeon.grid.occupiedCells.get(`${h.x},${h.y}`);
|
||||||
|
if (initialTileId) {
|
||||||
|
game.visitedRoomIds.add(initialTileId);
|
||||||
|
console.log(`[Main] Initial tile ${initialTileId} marked as visited.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 8. Render Loop
|
// 8. Render Loop
|
||||||
const animate = (time) => {
|
const animate = (time) => {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ export class CameraManager {
|
|||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
// Configuration
|
// Configuration
|
||||||
this.zoomLevel = 2.5; // Orthographic zoom factor (Lower = Closer)
|
this.zoomLevel = 6.0; // Started further back as requested
|
||||||
this.aspect = window.innerWidth / window.innerHeight;
|
this.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
|
||||||
|
this.onZoomChange = null;
|
||||||
|
|
||||||
// Isometric Setup: Orthographic Camera
|
// Isometric Setup: Orthographic Camera
|
||||||
this.camera = new THREE.OrthographicCamera(
|
this.camera = new THREE.OrthographicCamera(
|
||||||
-this.zoomLevel * this.aspect,
|
-this.zoomLevel * this.aspect,
|
||||||
@@ -52,9 +54,14 @@ export class CameraManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
centerOn(x, y) {
|
centerOn(x, y) {
|
||||||
// Grid (x, y) -> World (x, 0, -y)
|
// Calculate current offset relative to OLD target
|
||||||
|
const currentOffset = this.camera.position.clone().sub(this.target);
|
||||||
|
|
||||||
|
// Update target: Grid (x, y) -> World (x, 0, -y)
|
||||||
this.target.set(x, 0, -y);
|
this.target.set(x, 0, -y);
|
||||||
this.camera.position.copy(this.target).add(this.isoOffset);
|
|
||||||
|
// Restore position with new target + same relative offset
|
||||||
|
this.camera.position.copy(this.target).add(currentOffset);
|
||||||
this.camera.lookAt(this.target);
|
this.camera.lookAt(this.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +71,10 @@ export class CameraManager {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Adjust Zoom Level property
|
// Adjust Zoom Level property
|
||||||
if (e.deltaY < 0) this.zoomLevel = Math.max(3, this.zoomLevel - 1);
|
if (e.deltaY < 0) this.zoomLevel = Math.max(3, this.zoomLevel - 1);
|
||||||
else this.zoomLevel = Math.min(30, this.zoomLevel + 1);
|
else this.zoomLevel = Math.min(15, this.zoomLevel + 1);
|
||||||
|
|
||||||
this.updateProjection();
|
this.updateProjection();
|
||||||
|
if (this.onZoomChange) this.onZoomChange(this.zoomLevel);
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
// Pan Listeners (Middle Click)
|
// Pan Listeners (Middle Click)
|
||||||
@@ -109,28 +117,30 @@ export class CameraManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pan(dx, dy) {
|
pan(dx, dy) {
|
||||||
// Move Target and Camera together
|
// Move Speed Factor
|
||||||
// We pan on the logical "Ground Plane" relative to screen movement
|
|
||||||
|
|
||||||
const moveSpeed = this.panSpeed * 0.05 * (this.zoomLevel / 10);
|
const moveSpeed = this.panSpeed * 0.05 * (this.zoomLevel / 10);
|
||||||
|
|
||||||
// Transform screen delta to world delta
|
// Direction: Dragging the "World"
|
||||||
// In Iso view, Right on screen = (1, 0, 1) in world?
|
// Mouse Left (dx < 0) -> Camera moves Right (+X)
|
||||||
// Or using camera right/up vectors
|
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
|
||||||
|
const moveX = dx * moveSpeed;
|
||||||
|
const moveY = dy * moveSpeed;
|
||||||
|
|
||||||
const right = new THREE.Vector3(1, 0, 1).normalize(); // Approx logic for standard Iso
|
// Apply to Camera (Local Space)
|
||||||
const forward = new THREE.Vector3(-1, 0, 1).normalize();
|
this.camera.translateX(moveX);
|
||||||
|
this.camera.translateY(moveY);
|
||||||
|
|
||||||
// Let's use camera vectors for generic support
|
// Calculate World Movement to update Target
|
||||||
// Project camera right/up onto XZ plane
|
const vRight = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion);
|
||||||
// Or just direct translation:
|
const vUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.camera.quaternion);
|
||||||
|
|
||||||
this.camera.translateX(dx * moveSpeed);
|
const worldTranslation = new THREE.Vector3()
|
||||||
this.camera.translateY(dy * moveSpeed);
|
.addScaledVector(vRight, moveX)
|
||||||
|
.addScaledVector(vUp, moveY);
|
||||||
|
|
||||||
// This moves camera. We need to update target reference too if we want to snap back correctly
|
// Apply same movement to Target so relative offset is preserved
|
||||||
// But for now, simple pan is "offsetting everything".
|
// This ensures lookAt() doesn't pivot the camera around the old center
|
||||||
// centerOn resets this.
|
this.target.add(worldTranslation);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(deltaTime) {
|
update(deltaTime) {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export class GameRenderer {
|
|||||||
this.highlightGroup = new THREE.Group();
|
this.highlightGroup = new THREE.Group();
|
||||||
this.scene.add(this.highlightGroup);
|
this.scene.add(this.highlightGroup);
|
||||||
|
|
||||||
|
this.rangedGroup = new THREE.Group();
|
||||||
|
this.scene.add(this.rangedGroup);
|
||||||
|
|
||||||
this.entities = new Map();
|
this.entities = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +80,9 @@ export class GameRenderer {
|
|||||||
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
|
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
|
||||||
if (doorIntersects.length > 0) {
|
if (doorIntersects.length > 0) {
|
||||||
const doorMesh = doorIntersects[0].object;
|
const doorMesh = doorIntersects[0].object;
|
||||||
if (doorMesh.userData.isDoor) {
|
// Only capture click if it is a door AND it is NOT open
|
||||||
// Clicked on a door! Call onClick with a special door object
|
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||||
|
// Clicked on a CLOSED door! Call onClick with a special door object
|
||||||
onClick(null, null, doorMesh);
|
onClick(null, null, doorMesh);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -185,6 +189,14 @@ export class GameRenderer {
|
|||||||
|
|
||||||
mesh.position.set(entity.x, h / 2, -entity.y);
|
mesh.position.set(entity.x, h / 2, -entity.y);
|
||||||
|
|
||||||
|
// Clear old children if re-adding (to prevent multiple rings)
|
||||||
|
for (let i = mesh.children.length - 1; i >= 0; i--) {
|
||||||
|
const child = mesh.children[i];
|
||||||
|
if (child.name === "SelectionRing") {
|
||||||
|
mesh.remove(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Selection Circle
|
// Selection Circle
|
||||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||||
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 });
|
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 });
|
||||||
@@ -208,6 +220,105 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEntityActive(entityId, isActive) {
|
||||||
|
const mesh = this.entities.get(entityId);
|
||||||
|
if (!mesh) return;
|
||||||
|
|
||||||
|
// Remove existing active ring if any
|
||||||
|
const oldRing = mesh.getObjectByName("ActiveRing");
|
||||||
|
if (oldRing) mesh.remove(oldRing);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
// Phosphorescent Green Ring - MATCHING SIZE (0.3 - 0.4)
|
||||||
|
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||||
|
|
||||||
|
// Basic Material does not support emissive. Use color + opacity for "glow" feel.
|
||||||
|
const ringMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x00ff00, // Green
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8
|
||||||
|
});
|
||||||
|
|
||||||
|
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
|
||||||
|
// Align with floor (relative to mesh center)
|
||||||
|
const h = 1.56;
|
||||||
|
ring.position.y = -h / 2 + 0.05;
|
||||||
|
|
||||||
|
ring.name = "ActiveRing";
|
||||||
|
mesh.add(ring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerDamageEffect(entityId) {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
const startTime = performance.now();
|
||||||
|
const duration = 800; // ms
|
||||||
|
|
||||||
|
mesh.userData.shake = {
|
||||||
|
startTime: startTime,
|
||||||
|
duration: duration,
|
||||||
|
magnitude: 0.1,
|
||||||
|
originalPos: originalPos
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerDeathAnimation(entityId) {
|
||||||
|
const mesh = this.entities.get(entityId);
|
||||||
|
if (!mesh) return;
|
||||||
|
|
||||||
|
console.log(`[GameRenderer] Triggering death animation for ${entityId}`);
|
||||||
|
|
||||||
|
// Start fade-out animation
|
||||||
|
const startTime = performance.now();
|
||||||
|
const duration = 1500; // 1.5 seconds fade out
|
||||||
|
|
||||||
|
mesh.userData.death = {
|
||||||
|
startTime: startTime,
|
||||||
|
duration: duration,
|
||||||
|
initialOpacity: 1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove entity from map after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mesh && mesh.parent) {
|
||||||
|
mesh.parent.remove(mesh);
|
||||||
|
}
|
||||||
|
this.entities.delete(entityId);
|
||||||
|
console.log(`[GameRenderer] Removed entity ${entityId} from scene`);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
moveEntityAlongPath(entity, path) {
|
moveEntityAlongPath(entity, path) {
|
||||||
const mesh = this.entities.get(entity.id);
|
const mesh = this.entities.get(entity.id);
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
@@ -240,31 +351,97 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.isMoving) {
|
if (data.isMoving) {
|
||||||
const duration = 400; // ms per tile
|
const duration = 300; // Hero movement speed (300ms per tile)
|
||||||
const elapsed = time - data.startTime;
|
const elapsed = time - data.startTime;
|
||||||
const t = Math.min(elapsed / duration, 1);
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
// Lerp X/Z
|
// Lerp X/Z
|
||||||
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, t);
|
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, t);
|
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress);
|
||||||
|
|
||||||
// Jump Arc
|
// Hop (Botecito)
|
||||||
|
const jumpHeight = 0.5;
|
||||||
const baseHeight = 1.56 / 2;
|
const baseHeight = 1.56 / 2;
|
||||||
mesh.position.y = baseHeight + (0.5 * Math.sin(t * Math.PI));
|
mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight;
|
||||||
|
|
||||||
if (t >= 1) {
|
if (progress >= 1) {
|
||||||
mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z);
|
|
||||||
data.isMoving = false;
|
data.isMoving = false;
|
||||||
|
mesh.position.y = baseHeight; // Reset height
|
||||||
|
|
||||||
// IF Finished Sequence (Queue empty)
|
// Remove the visualization tile for this step
|
||||||
if (data.pathQueue.length === 0) {
|
if (this.pathGroup) {
|
||||||
// Check if it's the player (id 'p1')
|
for (let i = this.pathGroup.children.length - 1; i >= 0; i--) {
|
||||||
if (id === 'p1' && this.onHeroFinishedMove) {
|
const child = this.pathGroup.children[i];
|
||||||
// Grid Coords from World Coords (X, -Z)
|
// Match X and Z (ignoring small float errors)
|
||||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
if (Math.abs(child.position.x - data.targetPos.x) < 0.1 &&
|
||||||
|
Math.abs(child.position.z - data.targetPos.z) < 0.1) {
|
||||||
|
this.pathGroup.remove(child);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (data.shake) {
|
||||||
|
// HANDLE SHAKE
|
||||||
|
const elapsed = time - data.shake.startTime;
|
||||||
|
if (elapsed < data.shake.duration) {
|
||||||
|
const progress = elapsed / data.shake.duration;
|
||||||
|
// Dampen over time
|
||||||
|
const mag = data.shake.magnitude * (1 - progress);
|
||||||
|
|
||||||
|
// Random jitter
|
||||||
|
const offsetX = (Math.random() - 0.5) * mag * 2;
|
||||||
|
const offsetZ = (Math.random() - 0.5) * mag * 2;
|
||||||
|
|
||||||
|
mesh.position.x = data.shake.originalPos.x + offsetX;
|
||||||
|
mesh.position.z = data.shake.originalPos.z + offsetZ;
|
||||||
|
} else {
|
||||||
|
// Reset
|
||||||
|
mesh.position.copy(data.shake.originalPos);
|
||||||
|
delete data.shake;
|
||||||
|
}
|
||||||
|
} else if (data.death) {
|
||||||
|
// HANDLE DEATH FADE-OUT
|
||||||
|
const elapsed = time - data.death.startTime;
|
||||||
|
const progress = Math.min(elapsed / data.death.duration, 1);
|
||||||
|
|
||||||
|
// Fade out opacity
|
||||||
|
const opacity = data.death.initialOpacity * (1 - progress);
|
||||||
|
|
||||||
|
// Apply opacity to all materials in the mesh
|
||||||
|
mesh.traverse((child) => {
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach(mat => {
|
||||||
|
mat.transparent = true;
|
||||||
|
mat.opacity = opacity;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
child.material.transparent = true;
|
||||||
|
child.material.opacity = opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also fade down (sink into ground)
|
||||||
|
if (data.death.initialY === undefined) {
|
||||||
|
data.death.initialY = mesh.position.y;
|
||||||
|
}
|
||||||
|
mesh.position.y = data.death.initialY - (progress * 0.5);
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
delete data.death;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// IF Finished Sequence (Queue empty)
|
||||||
|
if (data.pathQueue.length === 0) {
|
||||||
|
// Check if it's the player (id 'p1')
|
||||||
|
if (id === 'p1' && this.onHeroFinishedMove) {
|
||||||
|
// Grid Coords from World Coords (X, -Z)
|
||||||
|
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -444,33 +621,52 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimized getTexture with pending request queue
|
||||||
getTexture(path, onLoad) {
|
getTexture(path, onLoad) {
|
||||||
if (!this.textureCache.has(path)) {
|
// 1. Check Cache
|
||||||
|
if (this.textureCache.has(path)) {
|
||||||
const tex = this.textureLoader.load(
|
const tex = this.textureCache.get(path);
|
||||||
path,
|
if (onLoad) onLoad(tex);
|
||||||
(texture) => {
|
return;
|
||||||
|
|
||||||
texture.needsUpdate = true;
|
|
||||||
if (onLoad) onLoad(texture);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
(err) => {
|
|
||||||
console.error(`[TextureLoader] ✗ Failed to load: ${path}`, err);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
tex.magFilter = THREE.NearestFilter;
|
|
||||||
tex.minFilter = THREE.NearestFilter;
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
this.textureCache.set(path, tex);
|
|
||||||
} else {
|
|
||||||
// Already cached, call onLoad immediately if texture is ready
|
|
||||||
const cachedTex = this.textureCache.get(path);
|
|
||||||
if (onLoad && cachedTex.image) {
|
|
||||||
onLoad(cachedTex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return this.textureCache.get(path);
|
|
||||||
|
// 2. Check Pending Requests (Deduplication)
|
||||||
|
if (!this._pendingTextureRequests) this._pendingTextureRequests = new Map();
|
||||||
|
|
||||||
|
if (this._pendingTextureRequests.has(path)) {
|
||||||
|
this._pendingTextureRequests.get(path).push(onLoad);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Start Load
|
||||||
|
this._pendingTextureRequests.set(path, [onLoad]);
|
||||||
|
|
||||||
|
this.textureLoader.load(
|
||||||
|
path,
|
||||||
|
(texture) => {
|
||||||
|
// Success
|
||||||
|
texture.magFilter = THREE.NearestFilter;
|
||||||
|
texture.minFilter = THREE.NearestFilter;
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
this.textureCache.set(path, texture);
|
||||||
|
|
||||||
|
// Execute all waiting callbacks
|
||||||
|
const callbacks = this._pendingTextureRequests.get(path);
|
||||||
|
if (callbacks) {
|
||||||
|
callbacks.forEach(cb => { if (cb) cb(texture); });
|
||||||
|
this._pendingTextureRequests.delete(path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
undefined, // onProgress
|
||||||
|
(err) => {
|
||||||
|
console.error(`[GameRenderer] Failed to load texture: ${path}`, err);
|
||||||
|
const callbacks = this._pendingTextureRequests.get(path);
|
||||||
|
if (callbacks) {
|
||||||
|
this._pendingTextureRequests.delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addTile(cells, type, tileDef, tileInstance) {
|
addTile(cells, type, tileDef, tileInstance) {
|
||||||
@@ -483,7 +679,8 @@ export class GameRenderer {
|
|||||||
// Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD
|
// Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD
|
||||||
if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) {
|
if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) {
|
||||||
|
|
||||||
const texturePath = tileDef.textures[0];
|
// Use specific texture if assigned (randomized), otherwise default to first
|
||||||
|
const texturePath = tileInstance.texture || tileDef.textures[0];
|
||||||
|
|
||||||
// Load texture with callback
|
// Load texture with callback
|
||||||
this.getTexture(texturePath, (texture) => {
|
this.getTexture(texturePath, (texture) => {
|
||||||
@@ -585,6 +782,65 @@ export class GameRenderer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== PATH VISUALIZATION ==========
|
||||||
|
|
||||||
|
updatePathVisualization(path) {
|
||||||
|
if (!this.pathGroup) {
|
||||||
|
this.pathGroup = new THREE.Group();
|
||||||
|
this.scene.add(this.pathGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pathGroup.clear();
|
||||||
|
|
||||||
|
if (!path || path.length === 0) return;
|
||||||
|
|
||||||
|
path.forEach((step, index) => {
|
||||||
|
const geometry = new THREE.PlaneGeometry(0.8, 0.8);
|
||||||
|
const texture = this.createNumberTexture(index + 1);
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8, // Texture itself has opacity
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
const plane = new THREE.Mesh(geometry, material);
|
||||||
|
plane.position.set(step.x, 0.02, -step.y); // Slightly above floor
|
||||||
|
plane.rotation.x = -Math.PI / 2;
|
||||||
|
|
||||||
|
// Store step index to identify it later if needed
|
||||||
|
plane.userData.stepIndex = index;
|
||||||
|
|
||||||
|
this.pathGroup.add(plane);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createNumberTexture(number) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 64;
|
||||||
|
canvas.height = 64;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Yellow background with 50% opacity
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
||||||
|
ctx.fillRect(0, 0, 64, 64);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = '#EDA900';
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.strokeRect(0, 0, 64, 64);
|
||||||
|
|
||||||
|
// Text
|
||||||
|
ctx.font = 'bold 36px Arial';
|
||||||
|
ctx.fillStyle = 'black';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(number.toString(), 32, 32);
|
||||||
|
|
||||||
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
|
// tex.magFilter = THREE.NearestFilter; // Optional, might look pixelated
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
|
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
|
||||||
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
||||||
|
|
||||||
@@ -601,6 +857,36 @@ export class GameRenderer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blockDoor(exitData) {
|
||||||
|
if (!this.exitGroup || !exitData) return;
|
||||||
|
|
||||||
|
// Find the door mesh
|
||||||
|
let targetDoor = null;
|
||||||
|
|
||||||
|
for (const child of this.exitGroup.children) {
|
||||||
|
if (child.userData.isDoor) {
|
||||||
|
// Check if this door corresponds to the exitData
|
||||||
|
// exitData has x,y of one of the cells
|
||||||
|
for (const cell of child.userData.cells) {
|
||||||
|
if (cell.x === exitData.x && cell.y === exitData.y) {
|
||||||
|
targetDoor = child;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetDoor) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetDoor) {
|
||||||
|
this.getTexture('/assets/images/dungeon1/doors/door1_blocked.png', (texture) => {
|
||||||
|
targetDoor.material.map = texture;
|
||||||
|
targetDoor.material.needsUpdate = true;
|
||||||
|
targetDoor.userData.isBlocked = true;
|
||||||
|
targetDoor.userData.isOpen = false; // Ensure strictly not open
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== MANUAL PLACEMENT SYSTEM ==========
|
// ========== MANUAL PLACEMENT SYSTEM ==========
|
||||||
|
|
||||||
enableDoorSelection(enabled) {
|
enableDoorSelection(enabled) {
|
||||||
@@ -727,12 +1013,31 @@ export class GameRenderer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. GROUND PROJECTION (Green/Red)
|
// 2. GROUND PROJECTION (Green/Red/Blue)
|
||||||
const projectionColor = isValid ? 0x00ff00 : 0xff0000;
|
const baseColor = isValid ? 0x00ff00 : 0xff0000;
|
||||||
|
|
||||||
|
// Calculate global exit positions
|
||||||
|
const exitKeys = new Set();
|
||||||
|
if (preview.variant && preview.variant.exits) {
|
||||||
|
preview.variant.exits.forEach(ex => {
|
||||||
|
const gx = x + ex.x;
|
||||||
|
const gy = y + ex.y;
|
||||||
|
exitKeys.add(`${gx},${gy}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cells.forEach(cell => {
|
cells.forEach(cell => {
|
||||||
|
const key = `${cell.x},${cell.y}`;
|
||||||
|
let color = baseColor;
|
||||||
|
|
||||||
|
// If this cell is an exit, color it Blue
|
||||||
|
if (exitKeys.has(key)) {
|
||||||
|
color = 0x0000ff; // Blue
|
||||||
|
}
|
||||||
|
|
||||||
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
|
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
|
||||||
const material = new THREE.MeshBasicMaterial({
|
const material = new THREE.MeshBasicMaterial({
|
||||||
color: projectionColor,
|
color: color,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
side: THREE.DoubleSide
|
side: THREE.DoubleSide
|
||||||
@@ -753,4 +1058,76 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearRangedTargeting() {
|
||||||
|
if (this.rangedGroup) {
|
||||||
|
while (this.rangedGroup.children.length > 0) {
|
||||||
|
const child = this.rangedGroup.children[0];
|
||||||
|
this.rangedGroup.remove(child);
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
|
||||||
|
else child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showRangedTargeting(hero, monster, losResult) {
|
||||||
|
this.clearRangedTargeting();
|
||||||
|
if (!hero || !monster || !losResult) return;
|
||||||
|
|
||||||
|
// 1. Orange Fluorescence Ring on Monster
|
||||||
|
const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32);
|
||||||
|
const ringMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xFFA500,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8
|
||||||
|
});
|
||||||
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
ring.position.set(monster.x, 0.05, -monster.y);
|
||||||
|
this.rangedGroup.add(ring);
|
||||||
|
|
||||||
|
// 2. Dashed Line logic (Center to Center at approx waist height)
|
||||||
|
const points = [];
|
||||||
|
points.push(new THREE.Vector3(hero.x, 0.8, -hero.y));
|
||||||
|
points.push(new THREE.Vector3(monster.x, 0.8, -monster.y));
|
||||||
|
|
||||||
|
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
||||||
|
const lineMat = new THREE.LineDashedMaterial({
|
||||||
|
color: losResult.clear ? 0x00FF00 : 0xFF0000,
|
||||||
|
dashSize: 0.2,
|
||||||
|
gapSize: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const line = new THREE.Line(lineGeo, lineMat);
|
||||||
|
line.computeLineDistances();
|
||||||
|
this.rangedGroup.add(line);
|
||||||
|
|
||||||
|
// 3. Blocker Visualization (Red Ring)
|
||||||
|
if (!losResult.clear && losResult.blocker) {
|
||||||
|
const b = losResult.blocker;
|
||||||
|
// If blocker is Entity (Hero/Monster), show bright red ring
|
||||||
|
if (b.type === 'hero' || b.type === 'monster') {
|
||||||
|
const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32);
|
||||||
|
const blockRingMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xFF0000,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 1.0,
|
||||||
|
depthTest: false // Always visible on top
|
||||||
|
});
|
||||||
|
const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat);
|
||||||
|
blockRing.rotation.x = -Math.PI / 2;
|
||||||
|
|
||||||
|
const bx = b.entity ? b.entity.x : b.x;
|
||||||
|
const by = b.entity ? b.entity.y : b.y;
|
||||||
|
|
||||||
|
blockRing.position.set(bx, 0.1, -by);
|
||||||
|
this.rangedGroup.add(blockRing);
|
||||||
|
}
|
||||||
|
// Walls are implicit (Line just turns red and stops/passes through)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
965
src/view/UIManager.js.backup
Normal file
@@ -0,0 +1,965 @@
|
|||||||
|
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||||
|
|
||||||
|
export class UIManager {
|
||||||
|
constructor(cameraManager, gameEngine) {
|
||||||
|
this.cameraManager = cameraManager;
|
||||||
|
this.game = gameEngine;
|
||||||
|
this.dungeon = gameEngine.dungeon;
|
||||||
|
this.selectedHero = null;
|
||||||
|
|
||||||
|
this.createHUD();
|
||||||
|
this.createHeroCardsPanel(); // NEW: Hero stat cards
|
||||||
|
this.createGameStatusPanel(); // New Panel
|
||||||
|
this.setupMinimapLoop();
|
||||||
|
this.setupGameListeners(); // New Listeners
|
||||||
|
|
||||||
|
// Hook into engine callbacks for UI updates
|
||||||
|
const originalSelect = this.game.onEntitySelect;
|
||||||
|
this.game.onEntitySelect = (id, isSelected) => {
|
||||||
|
// 1. Call Renderer (was in main.js)
|
||||||
|
if (this.cameraManager && this.cameraManager.renderer) {
|
||||||
|
this.cameraManager.renderer.toggleEntitySelection(id, isSelected);
|
||||||
|
} else if (window.RENDERER) {
|
||||||
|
window.RENDERER.toggleEntitySelection(id, isSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update UI
|
||||||
|
if (isSelected) {
|
||||||
|
const hero = this.game.heroes.find(h => h.id === id);
|
||||||
|
this.selectedHero = hero; // Store state
|
||||||
|
this.updateHeroStats(hero);
|
||||||
|
} else {
|
||||||
|
this.selectedHero = null;
|
||||||
|
this.updateHeroStats(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalMove = this.game.onEntityMove;
|
||||||
|
this.game.onEntityMove = (entity, path) => {
|
||||||
|
if (originalMove) originalMove(entity, path);
|
||||||
|
this.updateHeroStats(entity);
|
||||||
|
// Update hero card if it's a hero
|
||||||
|
if (entity.type === 'hero') {
|
||||||
|
this.updateHeroCard(entity.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createHUD() {
|
||||||
|
// Container
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.style.position = 'absolute';
|
||||||
|
this.container.style.top = '0';
|
||||||
|
this.container.style.left = '0';
|
||||||
|
this.container.style.width = '100%';
|
||||||
|
this.container.style.height = '100%';
|
||||||
|
this.container.style.pointerEvents = 'none'; // Click through to 3D scene
|
||||||
|
document.body.appendChild(this.container);
|
||||||
|
|
||||||
|
// --- Minimap (Top Left) ---
|
||||||
|
this.minimapCanvas = document.createElement('canvas');
|
||||||
|
this.minimapCanvas.width = 200;
|
||||||
|
this.minimapCanvas.height = 200;
|
||||||
|
this.minimapCanvas.style.position = 'absolute';
|
||||||
|
this.minimapCanvas.style.top = '10px';
|
||||||
|
this.minimapCanvas.style.left = '10px';
|
||||||
|
this.minimapCanvas.style.border = '2px solid #444';
|
||||||
|
this.minimapCanvas.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
this.minimapCanvas.style.pointerEvents = 'auto'; // Allow interaction if needed
|
||||||
|
this.container.appendChild(this.minimapCanvas);
|
||||||
|
|
||||||
|
this.ctx = this.minimapCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// --- Camera Controls (Top Right) ---
|
||||||
|
const controlsContainer = document.createElement('div');
|
||||||
|
controlsContainer.style.position = 'absolute';
|
||||||
|
controlsContainer.style.top = '20px';
|
||||||
|
controlsContainer.style.right = '20px';
|
||||||
|
controlsContainer.style.display = 'flex';
|
||||||
|
controlsContainer.style.gap = '10px';
|
||||||
|
controlsContainer.style.alignItems = 'center';
|
||||||
|
controlsContainer.style.pointerEvents = 'auto';
|
||||||
|
this.container.appendChild(controlsContainer);
|
||||||
|
|
||||||
|
// Zoom slider (vertical)
|
||||||
|
const zoomContainer = document.createElement('div');
|
||||||
|
zoomContainer.style.display = 'flex';
|
||||||
|
zoomContainer.style.flexDirection = 'column';
|
||||||
|
zoomContainer.style.alignItems = 'center';
|
||||||
|
zoomContainer.style.gap = '0px';
|
||||||
|
zoomContainer.style.height = '140px'; // Fixed height to accommodate label + slider
|
||||||
|
|
||||||
|
// Zoom label
|
||||||
|
const zoomLabel = document.createElement('div');
|
||||||
|
zoomLabel.textContent = 'Zoom';
|
||||||
|
zoomLabel.style.color = '#fff';
|
||||||
|
zoomLabel.style.fontSize = '15px';
|
||||||
|
zoomLabel.style.fontFamily = 'sans-serif';
|
||||||
|
zoomLabel.style.marginBottom = '10px';
|
||||||
|
zoomLabel.style.marginTop = '0px';
|
||||||
|
|
||||||
|
const zoomSlider = document.createElement('input');
|
||||||
|
zoomSlider.type = 'range';
|
||||||
|
zoomSlider.min = '3';
|
||||||
|
zoomSlider.max = '15';
|
||||||
|
zoomSlider.value = '6';
|
||||||
|
zoomSlider.step = '0.5';
|
||||||
|
zoomSlider.style.width = '100px';
|
||||||
|
zoomSlider.style.transform = 'rotate(-90deg)';
|
||||||
|
zoomSlider.style.transformOrigin = 'center';
|
||||||
|
zoomSlider.style.cursor = 'pointer';
|
||||||
|
zoomSlider.style.marginTop = '40px';
|
||||||
|
|
||||||
|
this.zoomSlider = zoomSlider;
|
||||||
|
|
||||||
|
// Set initial zoom
|
||||||
|
this.cameraManager.zoomLevel = 6;
|
||||||
|
this.cameraManager.updateProjection();
|
||||||
|
|
||||||
|
this.cameraManager.onZoomChange = (val) => {
|
||||||
|
if (this.zoomSlider) this.zoomSlider.value = val;
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomSlider.oninput = (e) => {
|
||||||
|
this.cameraManager.zoomLevel = parseFloat(e.target.value);
|
||||||
|
this.cameraManager.updateProjection();
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomContainer.appendChild(zoomLabel);
|
||||||
|
zoomContainer.appendChild(zoomSlider);
|
||||||
|
|
||||||
|
// Direction buttons grid
|
||||||
|
const buttonsGrid = document.createElement('div');
|
||||||
|
buttonsGrid.style.display = 'grid';
|
||||||
|
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||||
|
buttonsGrid.style.gap = '5px';
|
||||||
|
|
||||||
|
controlsContainer.appendChild(zoomContainer);
|
||||||
|
controlsContainer.appendChild(buttonsGrid);
|
||||||
|
|
||||||
|
const createBtn = (label, dir) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.style.width = '40px';
|
||||||
|
btn.style.height = '40px';
|
||||||
|
btn.style.backgroundColor = '#333';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.border = '1px solid #666';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.style.transition = 'background-color 0.2s';
|
||||||
|
btn.dataset.direction = dir; // Store direction for later reference
|
||||||
|
btn.onclick = () => {
|
||||||
|
this.cameraManager.setIsoView(dir);
|
||||||
|
this.updateActiveViewButton(dir);
|
||||||
|
};
|
||||||
|
return btn;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Layout: [N]
|
||||||
|
// [W] [E]
|
||||||
|
// [S]
|
||||||
|
|
||||||
|
// Grid cells: 1 2 3
|
||||||
|
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
|
||||||
|
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
|
||||||
|
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
|
||||||
|
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
|
||||||
|
|
||||||
|
buttonsGrid.appendChild(btnN);
|
||||||
|
buttonsGrid.appendChild(btnW);
|
||||||
|
buttonsGrid.appendChild(btnE);
|
||||||
|
buttonsGrid.appendChild(btnS);
|
||||||
|
|
||||||
|
// Store button references for later updates
|
||||||
|
this.viewButtons = [btnN, btnE, btnS, btnW];
|
||||||
|
|
||||||
|
// Set initial active button (North)
|
||||||
|
this.updateActiveViewButton(DIRECTIONS.NORTH);
|
||||||
|
|
||||||
|
// --- Tile Placement Controls (Bottom Center) ---
|
||||||
|
this.placementPanel = document.createElement('div');
|
||||||
|
this.placementPanel.style.position = 'absolute';
|
||||||
|
this.placementPanel.style.bottom = '20px';
|
||||||
|
this.placementPanel.style.left = '50%';
|
||||||
|
this.placementPanel.style.transform = 'translateX(-50%)';
|
||||||
|
this.placementPanel.style.display = 'none'; // Hidden by default
|
||||||
|
this.placementPanel.style.pointerEvents = 'auto';
|
||||||
|
this.placementPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||||
|
this.placementPanel.style.padding = '15px';
|
||||||
|
this.placementPanel.style.borderRadius = '8px';
|
||||||
|
this.placementPanel.style.border = '2px solid #666';
|
||||||
|
this.container.appendChild(this.placementPanel);
|
||||||
|
|
||||||
|
// Status text
|
||||||
|
this.placementStatus = document.createElement('div');
|
||||||
|
this.placementStatus.style.color = '#fff';
|
||||||
|
this.placementStatus.style.fontSize = '16px';
|
||||||
|
this.placementStatus.style.fontFamily = 'sans-serif';
|
||||||
|
this.placementStatus.style.marginBottom = '10px';
|
||||||
|
this.placementStatus.style.textAlign = 'center';
|
||||||
|
this.placementStatus.textContent = 'Coloca la loseta';
|
||||||
|
this.placementPanel.appendChild(this.placementStatus);
|
||||||
|
|
||||||
|
// Controls container
|
||||||
|
const placementControls = document.createElement('div');
|
||||||
|
placementControls.style.display = 'flex';
|
||||||
|
placementControls.style.gap = '15px';
|
||||||
|
placementControls.style.alignItems = 'center';
|
||||||
|
this.placementPanel.appendChild(placementControls);
|
||||||
|
|
||||||
|
// Movement arrows (4-way grid)
|
||||||
|
const arrowGrid = document.createElement('div');
|
||||||
|
arrowGrid.style.display = 'grid';
|
||||||
|
arrowGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||||
|
arrowGrid.style.gap = '3px';
|
||||||
|
|
||||||
|
const createArrow = (label, dx, dy) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.style.width = '40px';
|
||||||
|
btn.style.height = '40px';
|
||||||
|
btn.style.backgroundColor = '#444';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.border = '1px solid #888';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.style.fontSize = '18px';
|
||||||
|
btn.onclick = () => {
|
||||||
|
if (this.dungeon) {
|
||||||
|
this.dungeon.movePlacement(dx, dy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return btn;
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrowUp = createArrow('↑', 0, 1);
|
||||||
|
const arrowLeft = createArrow('←', -1, 0);
|
||||||
|
const arrowRight = createArrow('→', 1, 0);
|
||||||
|
const arrowDown = createArrow('↓', 0, -1);
|
||||||
|
|
||||||
|
arrowUp.style.gridColumn = '2';
|
||||||
|
arrowLeft.style.gridColumn = '1';
|
||||||
|
arrowRight.style.gridColumn = '3';
|
||||||
|
arrowDown.style.gridColumn = '2';
|
||||||
|
|
||||||
|
arrowGrid.appendChild(arrowUp);
|
||||||
|
arrowGrid.appendChild(arrowLeft);
|
||||||
|
arrowGrid.appendChild(arrowRight);
|
||||||
|
arrowGrid.appendChild(arrowDown);
|
||||||
|
|
||||||
|
placementControls.appendChild(arrowGrid);
|
||||||
|
|
||||||
|
// Rotate button
|
||||||
|
this.rotateBtn = document.createElement('button');
|
||||||
|
this.rotateBtn.textContent = '🔄 Rotar';
|
||||||
|
this.rotateBtn.style.padding = '10px 20px';
|
||||||
|
this.rotateBtn.style.backgroundColor = '#555';
|
||||||
|
this.rotateBtn.style.color = '#fff';
|
||||||
|
this.rotateBtn.style.border = '1px solid #888';
|
||||||
|
this.rotateBtn.style.cursor = 'pointer';
|
||||||
|
this.rotateBtn.style.fontSize = '16px';
|
||||||
|
this.rotateBtn.style.borderRadius = '4px';
|
||||||
|
this.rotateBtn.onclick = () => {
|
||||||
|
if (this.dungeon) {
|
||||||
|
this.dungeon.rotatePlacement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
placementControls.appendChild(this.rotateBtn);
|
||||||
|
|
||||||
|
this.placeBtn = document.createElement('button');
|
||||||
|
this.placeBtn.textContent = '⬇ Bajar';
|
||||||
|
this.placeBtn.style.padding = '10px 20px';
|
||||||
|
this.placeBtn.style.backgroundColor = '#2a5';
|
||||||
|
this.placeBtn.style.color = '#fff';
|
||||||
|
this.placeBtn.style.border = '1px solid #888';
|
||||||
|
this.placeBtn.style.cursor = 'pointer';
|
||||||
|
this.placeBtn.style.fontSize = '16px';
|
||||||
|
this.placeBtn.style.borderRadius = '4px';
|
||||||
|
this.placeBtn.onclick = () => {
|
||||||
|
if (this.dungeon) {
|
||||||
|
const success = this.dungeon.confirmPlacement();
|
||||||
|
if (!success) {
|
||||||
|
this.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
placementControls.appendChild(this.placeBtn);
|
||||||
|
|
||||||
|
// Discard button
|
||||||
|
this.discardBtn = document.createElement('button');
|
||||||
|
this.discardBtn.textContent = '❌ Cancelar';
|
||||||
|
this.discardBtn.style.padding = '10px 20px';
|
||||||
|
this.discardBtn.style.backgroundColor = '#d33';
|
||||||
|
this.discardBtn.style.color = '#fff';
|
||||||
|
this.discardBtn.style.border = '1px solid #888';
|
||||||
|
this.discardBtn.style.cursor = 'pointer';
|
||||||
|
this.discardBtn.style.fontSize = '16px';
|
||||||
|
this.discardBtn.style.borderRadius = '4px';
|
||||||
|
this.discardBtn.onclick = () => {
|
||||||
|
if (this.dungeon) {
|
||||||
|
this.showConfirm(
|
||||||
|
'Confirmar acción',
|
||||||
|
'¿Quieres descartar esta loseta y bloquear la puerta?',
|
||||||
|
() => {
|
||||||
|
this.dungeon.cancelPlacement();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
placementControls.appendChild(this.discardBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
createHeroCardsPanel() {
|
||||||
|
// Container for character cards (left side)
|
||||||
|
this.cardsContainer = document.createElement('div');
|
||||||
|
this.cardsContainer.style.position = 'absolute';
|
||||||
|
this.cardsContainer.style.left = '10px';
|
||||||
|
this.cardsContainer.style.top = '220px'; // Below minimap
|
||||||
|
this.cardsContainer.style.display = 'flex';
|
||||||
|
this.cardsContainer.style.flexDirection = 'column';
|
||||||
|
this.cardsContainer.style.gap = '10px';
|
||||||
|
this.cardsContainer.style.pointerEvents = 'auto';
|
||||||
|
this.cardsContainer.style.width = '200px';
|
||||||
|
this.container.appendChild(this.cardsContainer);
|
||||||
|
|
||||||
|
// Create placeholder card
|
||||||
|
this.createPlaceholderCard();
|
||||||
|
|
||||||
|
// Store references
|
||||||
|
this.currentHeroCard = null;
|
||||||
|
this.currentMonsterCard = null;
|
||||||
|
this.attackButton = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlaceholderCard() {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.style.width = '180px';
|
||||||
|
card.style.height = '280px';
|
||||||
|
card.style.backgroundColor = 'rgba(20, 20, 20, 0.95)';
|
||||||
|
card.style.border = '2px solid #8B4513';
|
||||||
|
card.style.borderRadius = '8px';
|
||||||
|
card.style.padding = '10px';
|
||||||
|
card.style.fontFamily = '"Cinzel", serif';
|
||||||
|
card.style.color = '#888';
|
||||||
|
card.style.display = 'flex';
|
||||||
|
card.style.flexDirection = 'column';
|
||||||
|
card.style.alignItems = 'center';
|
||||||
|
card.style.justifyContent = 'center';
|
||||||
|
card.style.textAlign = 'center';
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.textContent = '🎴';
|
||||||
|
icon.style.fontSize = '64px';
|
||||||
|
icon.style.marginBottom = '20px';
|
||||||
|
card.appendChild(icon);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.textContent = 'Selecciona un Aventurero';
|
||||||
|
text.style.fontSize = '14px';
|
||||||
|
text.style.color = '#DAA520';
|
||||||
|
card.appendChild(text);
|
||||||
|
|
||||||
|
this.placeholderCard = card;
|
||||||
|
this.cardsContainer.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
createHeroCard(hero) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.style.width = '180px';
|
||||||
|
card.style.backgroundColor = 'rgba(20, 20, 20, 0.95)';
|
||||||
|
card.style.border = '2px solid #8B4513';
|
||||||
|
card.style.borderRadius = '8px';
|
||||||
|
card.style.padding = '10px';
|
||||||
|
card.style.fontFamily = '"Cinzel", serif';
|
||||||
|
card.style.color = '#fff';
|
||||||
|
card.style.transition = 'all 0.3s';
|
||||||
|
card.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
// Hover effect
|
||||||
|
card.onmouseenter = () => {
|
||||||
|
card.style.borderColor = '#DAA520';
|
||||||
|
card.style.transform = 'scale(1.05)';
|
||||||
|
};
|
||||||
|
card.onmouseleave = () => {
|
||||||
|
card.style.borderColor = '#8B4513';
|
||||||
|
card.style.transform = 'scale(1)';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Click to select hero
|
||||||
|
card.onclick = () => {
|
||||||
|
if (this.game.onCellClick) {
|
||||||
|
this.game.onCellClick(hero.x, hero.y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Portrait
|
||||||
|
const portrait = document.createElement('div');
|
||||||
|
portrait.style.width = '100%';
|
||||||
|
portrait.style.height = '100px';
|
||||||
|
portrait.style.borderRadius = '5px';
|
||||||
|
portrait.style.overflow = 'hidden';
|
||||||
|
portrait.style.border = '2px solid #DAA520';
|
||||||
|
portrait.style.marginBottom = '8px';
|
||||||
|
portrait.style.backgroundColor = '#000';
|
||||||
|
portrait.style.display = 'flex';
|
||||||
|
portrait.style.alignItems = 'center';
|
||||||
|
portrait.style.justifyContent = 'center';
|
||||||
|
|
||||||
|
// Use token image (placeholder for now)
|
||||||
|
const tokenPath = `/assets/images/dungeon1/tokens/heroes/${hero.key}.png`;
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = tokenPath;
|
||||||
|
img.style.width = '100%';
|
||||||
|
img.style.height = '100%';
|
||||||
|
img.style.objectFit = 'cover';
|
||||||
|
|
||||||
|
// Fallback if image doesn't exist
|
||||||
|
img.onerror = () => {
|
||||||
|
portrait.innerHTML = `<div style="color: #DAA520; font-size: 48px;">?</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
portrait.appendChild(img);
|
||||||
|
card.appendChild(portrait);
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.textContent = hero.name;
|
||||||
|
name.style.fontSize = '16px';
|
||||||
|
name.style.fontWeight = 'bold';
|
||||||
|
name.style.color = '#DAA520';
|
||||||
|
name.style.textAlign = 'center';
|
||||||
|
name.style.marginBottom = '8px';
|
||||||
|
name.style.textTransform = 'uppercase';
|
||||||
|
card.appendChild(name);
|
||||||
|
|
||||||
|
// Lantern indicator
|
||||||
|
if (hero.hasLantern) {
|
||||||
|
const lantern = document.createElement('div');
|
||||||
|
lantern.textContent = '🏮 Portador de la Lámpara';
|
||||||
|
lantern.style.fontSize = '10px';
|
||||||
|
lantern.style.color = '#FFA500';
|
||||||
|
lantern.style.textAlign = 'center';
|
||||||
|
lantern.style.marginBottom = '8px';
|
||||||
|
card.appendChild(lantern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats grid
|
||||||
|
const statsGrid = document.createElement('div');
|
||||||
|
statsGrid.style.display = 'grid';
|
||||||
|
statsGrid.style.gridTemplateColumns = '1fr 1fr';
|
||||||
|
statsGrid.style.gap = '4px';
|
||||||
|
statsGrid.style.fontSize = '12px';
|
||||||
|
statsGrid.style.marginBottom = '8px';
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'WS', value: hero.stats.ws || 0 },
|
||||||
|
{ label: 'BS', value: hero.stats.bs || 0 },
|
||||||
|
{ label: 'S', value: hero.stats.str || 0 },
|
||||||
|
{ label: 'T', value: hero.stats.toughness || 0 },
|
||||||
|
{ label: 'W', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` },
|
||||||
|
{ label: 'I', value: hero.stats.initiative || 0 },
|
||||||
|
{ label: 'A', value: hero.stats.attacks || 0 },
|
||||||
|
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` }
|
||||||
|
];
|
||||||
|
|
||||||
|
stats.forEach(stat => {
|
||||||
|
const statEl = document.createElement('div');
|
||||||
|
statEl.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||||
|
statEl.style.padding = '3px 5px';
|
||||||
|
statEl.style.borderRadius = '3px';
|
||||||
|
statEl.style.display = 'flex';
|
||||||
|
statEl.style.justifyContent = 'space-between';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = stat.label + ':';
|
||||||
|
label.style.color = '#AAA';
|
||||||
|
|
||||||
|
const value = document.createElement('span');
|
||||||
|
value.textContent = stat.value;
|
||||||
|
value.style.color = '#FFF';
|
||||||
|
value.style.fontWeight = 'bold';
|
||||||
|
|
||||||
|
statEl.appendChild(label);
|
||||||
|
statEl.appendChild(value);
|
||||||
|
statsGrid.appendChild(statEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(statsGrid);
|
||||||
|
|
||||||
|
// Store reference
|
||||||
|
this.heroCards.set(hero.id, card);
|
||||||
|
this.heroCardsContainer.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHeroCard(heroId) {
|
||||||
|
const card = this.heroCards.get(heroId);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const hero = this.game.heroes.find(h => h.id === heroId);
|
||||||
|
if (!hero) return;
|
||||||
|
|
||||||
|
// Update wounds and moves in the stats grid
|
||||||
|
const statsGrid = card.querySelector('div[style*="grid-template-columns"]');
|
||||||
|
if (statsGrid) {
|
||||||
|
const statDivs = statsGrid.children;
|
||||||
|
// W is at index 4, Mov is at index 7
|
||||||
|
if (statDivs[4]) {
|
||||||
|
const wValue = statDivs[4].querySelector('span:last-child');
|
||||||
|
if (wValue) wValue.textContent = `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`;
|
||||||
|
}
|
||||||
|
if (statDivs[7]) {
|
||||||
|
const movValue = statDivs[7].querySelector('span:last-child');
|
||||||
|
if (movValue) movValue.textContent = `${hero.currentMoves || 0}/${hero.stats.move}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showPlacementControls(show) {
|
||||||
|
if (this.placementPanel) {
|
||||||
|
this.placementPanel.style.display = show ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlacementStatus(isValid) {
|
||||||
|
if (this.placementStatus) {
|
||||||
|
if (isValid) {
|
||||||
|
this.placementStatus.textContent = '✅ Posición válida';
|
||||||
|
this.placementStatus.style.color = '#0f0';
|
||||||
|
this.placeBtn.style.backgroundColor = '#2a5';
|
||||||
|
this.placeBtn.style.cursor = 'pointer';
|
||||||
|
} else {
|
||||||
|
this.placementStatus.textContent = '❌ Posición inválida';
|
||||||
|
this.placementStatus.style.color = '#f44';
|
||||||
|
this.placeBtn.style.backgroundColor = '#555';
|
||||||
|
this.placeBtn.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveViewButton(activeDirection) {
|
||||||
|
// Reset all buttons to default color
|
||||||
|
this.viewButtons.forEach(btn => {
|
||||||
|
btn.style.backgroundColor = '#333';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight the active button
|
||||||
|
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
|
||||||
|
if (activeBtn) {
|
||||||
|
activeBtn.style.backgroundColor = '#f0c040'; // Yellow/gold color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMinimapLoop() {
|
||||||
|
const loop = () => {
|
||||||
|
this.drawMinimap();
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMinimap() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const w = this.minimapCanvas.width;
|
||||||
|
const h = this.minimapCanvas.height;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const cellSize = 5;
|
||||||
|
const centerX = w / 2;
|
||||||
|
const centerY = h / 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#666'; // Generic floor
|
||||||
|
|
||||||
|
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
|
||||||
|
const [x, y] = key.split(',').map(Number);
|
||||||
|
const cx = centerX + (x * cellSize);
|
||||||
|
const cy = centerY - (y * cellSize);
|
||||||
|
|
||||||
|
if (tileId.includes('room')) ctx.fillStyle = '#55a';
|
||||||
|
else ctx.fillStyle = '#aaa';
|
||||||
|
|
||||||
|
ctx.fillRect(cx, cy, cellSize, cellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Exits (Available)
|
||||||
|
ctx.fillStyle = '#0f0'; // Green dots for open exits
|
||||||
|
if (this.dungeon.availableExits) {
|
||||||
|
this.dungeon.availableExits.forEach(exit => {
|
||||||
|
const ex = centerX + (exit.x * cellSize);
|
||||||
|
const ey = centerY - (exit.y * cellSize);
|
||||||
|
ctx.fillRect(ex, ey, cellSize, cellSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Entry (0,0) cross
|
||||||
|
ctx.strokeStyle = '#f00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX - 5, centerY);
|
||||||
|
ctx.lineTo(centerX + 5, centerY);
|
||||||
|
ctx.moveTo(centerX, centerY - 5);
|
||||||
|
ctx.lineTo(centerX, centerY + 5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal(title, message, onClose) {
|
||||||
|
// Overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.position = 'absolute';
|
||||||
|
overlay.style.top = '0';
|
||||||
|
overlay.style.left = '0';
|
||||||
|
overlay.style.width = '100%';
|
||||||
|
overlay.style.height = '100%';
|
||||||
|
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.style.justifyContent = 'center';
|
||||||
|
overlay.style.alignItems = 'center';
|
||||||
|
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||||
|
overlay.style.zIndex = '1000';
|
||||||
|
|
||||||
|
// Content Box
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.backgroundColor = '#222';
|
||||||
|
content.style.border = '2px solid #888';
|
||||||
|
content.style.borderRadius = '8px';
|
||||||
|
content.style.padding = '20px';
|
||||||
|
content.style.width = '300px';
|
||||||
|
content.style.textAlign = 'center';
|
||||||
|
content.style.color = '#fff';
|
||||||
|
content.style.fontFamily = 'sans-serif';
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const titleEl = document.createElement('h2');
|
||||||
|
titleEl.textContent = title;
|
||||||
|
titleEl.style.marginTop = '0';
|
||||||
|
titleEl.style.color = '#f44'; // Reddish for importance
|
||||||
|
content.appendChild(titleEl);
|
||||||
|
|
||||||
|
// Message
|
||||||
|
const msgEl = document.createElement('p');
|
||||||
|
msgEl.innerHTML = message;
|
||||||
|
msgEl.style.fontSize = '16px';
|
||||||
|
msgEl.style.lineHeight = '1.5';
|
||||||
|
content.appendChild(msgEl);
|
||||||
|
|
||||||
|
// OK Button
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = 'Entendido';
|
||||||
|
btn.style.marginTop = '20px';
|
||||||
|
btn.style.padding = '10px 20px';
|
||||||
|
btn.style.fontSize = '16px';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.style.backgroundColor = '#444';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.border = '1px solid #888';
|
||||||
|
btn.onclick = () => {
|
||||||
|
this.container.removeChild(overlay);
|
||||||
|
if (onClose) onClose();
|
||||||
|
};
|
||||||
|
content.appendChild(btn);
|
||||||
|
|
||||||
|
overlay.appendChild(content);
|
||||||
|
this.container.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
showCombatLog(log) {
|
||||||
|
if (!this.notificationArea) return;
|
||||||
|
|
||||||
|
const isHit = log.hitSuccess;
|
||||||
|
const color = isHit ? '#ff4444' : '#aaaaaa';
|
||||||
|
const title = isHit ? 'GOLPE!' : 'FALLO';
|
||||||
|
|
||||||
|
let detailHtml = '';
|
||||||
|
if (isHit) {
|
||||||
|
if (log.woundsCaused > 0) {
|
||||||
|
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
|
||||||
|
} else {
|
||||||
|
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show simplified but impactful message
|
||||||
|
this.notificationArea.innerHTML = `
|
||||||
|
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 2px solid ${color}; border-radius: 5px; text-align: center; min-width: 250px;">
|
||||||
|
<div style="font-family: 'Cinzel'; font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
|
||||||
|
${detailHtml}
|
||||||
|
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.notificationArea.style.opacity = '1';
|
||||||
|
|
||||||
|
// Update hero card if defender is a hero
|
||||||
|
const defender = this.game.heroes.find(h => h.id === log.defenderId) ||
|
||||||
|
this.game.monsters.find(m => m.id === log.defenderId);
|
||||||
|
if (defender && defender.type === 'hero') {
|
||||||
|
this.updateHeroCard(defender.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||||
|
}, 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
showConfirm(title, message, onConfirm) {
|
||||||
|
// Overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.position = 'absolute';
|
||||||
|
overlay.style.top = '0';
|
||||||
|
overlay.style.left = '0';
|
||||||
|
overlay.style.width = '100%';
|
||||||
|
overlay.style.height = '100%';
|
||||||
|
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.style.justifyContent = 'center';
|
||||||
|
overlay.style.alignItems = 'center';
|
||||||
|
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||||
|
overlay.style.zIndex = '1000';
|
||||||
|
|
||||||
|
// Content Box
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.backgroundColor = '#222';
|
||||||
|
content.style.border = '2px solid #888';
|
||||||
|
content.style.borderRadius = '8px';
|
||||||
|
content.style.padding = '20px';
|
||||||
|
content.style.width = '300px';
|
||||||
|
content.style.textAlign = 'center';
|
||||||
|
content.style.color = '#fff';
|
||||||
|
content.style.fontFamily = 'sans-serif';
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const titleEl = document.createElement('h2');
|
||||||
|
titleEl.textContent = title;
|
||||||
|
titleEl.style.marginTop = '0';
|
||||||
|
titleEl.style.color = '#f44';
|
||||||
|
content.appendChild(titleEl);
|
||||||
|
|
||||||
|
// Message
|
||||||
|
const msgEl = document.createElement('p');
|
||||||
|
msgEl.innerHTML = message;
|
||||||
|
msgEl.style.fontSize = '16px';
|
||||||
|
msgEl.style.lineHeight = '1.5';
|
||||||
|
content.appendChild(msgEl);
|
||||||
|
|
||||||
|
// Buttons Container
|
||||||
|
const buttons = document.createElement('div');
|
||||||
|
buttons.style.display = 'flex';
|
||||||
|
buttons.style.justifyContent = 'space-around';
|
||||||
|
buttons.style.marginTop = '20px';
|
||||||
|
|
||||||
|
// Cancel Button
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.textContent = 'Cancelar';
|
||||||
|
cancelBtn.style.padding = '10px 20px';
|
||||||
|
cancelBtn.style.fontSize = '16px';
|
||||||
|
cancelBtn.style.cursor = 'pointer';
|
||||||
|
cancelBtn.style.backgroundColor = '#555';
|
||||||
|
cancelBtn.style.color = '#fff';
|
||||||
|
cancelBtn.style.border = '1px solid #888';
|
||||||
|
cancelBtn.onclick = () => {
|
||||||
|
this.container.removeChild(overlay);
|
||||||
|
};
|
||||||
|
buttons.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
// Confirm Button
|
||||||
|
const confirmBtn = document.createElement('button');
|
||||||
|
confirmBtn.textContent = 'Aceptar';
|
||||||
|
confirmBtn.style.padding = '10px 20px';
|
||||||
|
confirmBtn.style.fontSize = '16px';
|
||||||
|
confirmBtn.style.cursor = 'pointer';
|
||||||
|
confirmBtn.style.backgroundColor = '#2a5';
|
||||||
|
confirmBtn.style.color = '#fff';
|
||||||
|
confirmBtn.style.border = '1px solid #888';
|
||||||
|
confirmBtn.onclick = () => {
|
||||||
|
if (onConfirm) onConfirm();
|
||||||
|
this.container.removeChild(overlay);
|
||||||
|
};
|
||||||
|
buttons.appendChild(confirmBtn);
|
||||||
|
|
||||||
|
content.appendChild(buttons);
|
||||||
|
overlay.appendChild(content);
|
||||||
|
this.container.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGameStatusPanel() {
|
||||||
|
// Top Center Panel
|
||||||
|
this.statusPanel = document.createElement('div');
|
||||||
|
this.statusPanel.style.position = 'absolute';
|
||||||
|
this.statusPanel.style.top = '20px';
|
||||||
|
this.statusPanel.style.left = '50%';
|
||||||
|
this.statusPanel.style.transform = 'translateX(-50%)';
|
||||||
|
this.statusPanel.style.display = 'flex';
|
||||||
|
this.statusPanel.style.flexDirection = 'column';
|
||||||
|
this.statusPanel.style.alignItems = 'center';
|
||||||
|
this.statusPanel.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Turn/Phase Info
|
||||||
|
this.phaseInfo = document.createElement('div');
|
||||||
|
this.phaseInfo.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
this.phaseInfo.style.padding = '10px 20px';
|
||||||
|
this.phaseInfo.style.border = '2px solid #daa520'; // GoldenRod
|
||||||
|
this.phaseInfo.style.borderRadius = '5px';
|
||||||
|
this.phaseInfo.style.color = '#fff';
|
||||||
|
this.phaseInfo.style.fontFamily = '"Cinzel", serif';
|
||||||
|
this.phaseInfo.style.fontSize = '20px';
|
||||||
|
this.phaseInfo.style.textAlign = 'center';
|
||||||
|
this.phaseInfo.style.textTransform = 'uppercase';
|
||||||
|
this.phaseInfo.style.minWidth = '200px';
|
||||||
|
this.phaseInfo.innerHTML = `
|
||||||
|
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
|
||||||
|
<div style="font-size: 24px; color: #daa520;">Setup</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.statusPanel.appendChild(this.phaseInfo);
|
||||||
|
|
||||||
|
// End Phase Button
|
||||||
|
this.endPhaseBtn = document.createElement('button');
|
||||||
|
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||||
|
this.endPhaseBtn.style.marginTop = '10px';
|
||||||
|
this.endPhaseBtn.style.width = '100%';
|
||||||
|
this.endPhaseBtn.style.padding = '8px';
|
||||||
|
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
|
||||||
|
this.endPhaseBtn.style.color = '#000';
|
||||||
|
this.endPhaseBtn.style.border = '1px solid #8B4513';
|
||||||
|
this.endPhaseBtn.style.borderRadius = '3px';
|
||||||
|
this.endPhaseBtn.style.fontWeight = 'bold';
|
||||||
|
this.endPhaseBtn.style.cursor = 'pointer';
|
||||||
|
this.endPhaseBtn.style.display = 'none'; // Hidden by default
|
||||||
|
this.endPhaseBtn.style.fontFamily = '"Cinzel", serif';
|
||||||
|
this.endPhaseBtn.style.fontSize = '12px';
|
||||||
|
this.endPhaseBtn.style.pointerEvents = 'auto'; // Enable clicking
|
||||||
|
|
||||||
|
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
|
||||||
|
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
|
||||||
|
|
||||||
|
this.endPhaseBtn.onclick = () => {
|
||||||
|
console.log('[UIManager] End Phase Button Clicked', this.game.turnManager.currentPhase);
|
||||||
|
this.game.turnManager.nextPhase();
|
||||||
|
};
|
||||||
|
this.statusPanel.appendChild(this.endPhaseBtn);
|
||||||
|
|
||||||
|
// Notification Area (Power Roll results, etc)
|
||||||
|
this.notificationArea = document.createElement('div');
|
||||||
|
this.notificationArea.style.marginTop = '10px';
|
||||||
|
this.notificationArea.style.transition = 'opacity 0.5s';
|
||||||
|
this.notificationArea.style.opacity = '0';
|
||||||
|
this.statusPanel.appendChild(this.notificationArea);
|
||||||
|
|
||||||
|
this.container.appendChild(this.statusPanel);
|
||||||
|
|
||||||
|
// Inject Font
|
||||||
|
if (!document.getElementById('game-font')) {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.id = 'game-font';
|
||||||
|
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGameListeners() {
|
||||||
|
if (this.game.turnManager) {
|
||||||
|
this.game.turnManager.on('phase_changed', (phase) => {
|
||||||
|
this.updatePhaseDisplay(phase);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.game.turnManager.on('POWER_RESULT', (data) => {
|
||||||
|
this.showPowerRollResult(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePhaseDisplay(phase) {
|
||||||
|
if (!this.phaseInfo) return;
|
||||||
|
const turn = this.game.turnManager.currentTurn;
|
||||||
|
|
||||||
|
let content = `
|
||||||
|
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
|
||||||
|
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (this.selectedHero) {
|
||||||
|
content += this.getHeroStatsHTML(this.selectedHero);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.phaseInfo.innerHTML = content;
|
||||||
|
|
||||||
|
if (this.endPhaseBtn) {
|
||||||
|
if (phase === 'hero') {
|
||||||
|
this.endPhaseBtn.style.display = 'block';
|
||||||
|
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||||
|
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
|
||||||
|
} else if (phase === 'monster') {
|
||||||
|
this.endPhaseBtn.style.display = 'block';
|
||||||
|
this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS';
|
||||||
|
this.endPhaseBtn.title = "Pasar a Fase de Exploración";
|
||||||
|
} else if (phase === 'exploration') {
|
||||||
|
this.endPhaseBtn.style.display = 'block';
|
||||||
|
this.endPhaseBtn.textContent = 'ACABAR TURNO';
|
||||||
|
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
|
||||||
|
} else {
|
||||||
|
this.endPhaseBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHeroStats(hero) {
|
||||||
|
if (!this.phaseInfo) return;
|
||||||
|
|
||||||
|
const turn = this.game.turnManager.currentTurn;
|
||||||
|
const phase = this.game.turnManager.currentPhase;
|
||||||
|
|
||||||
|
let content = `
|
||||||
|
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
|
||||||
|
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (hero) {
|
||||||
|
content += this.getHeroStatsHTML(hero);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.phaseInfo.innerHTML = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeroStatsHTML(hero) {
|
||||||
|
const portraitUrl = hero.texturePath || '';
|
||||||
|
|
||||||
|
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||||
|
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
|
||||||
|
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
|
||||||
|
</div>
|
||||||
|
<div style="text-align: left;">
|
||||||
|
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
|
||||||
|
${hero.name} ${lanternIcon}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 14px;">
|
||||||
|
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPowerRollResult(data) {
|
||||||
|
if (!this.notificationArea) return;
|
||||||
|
const { roll, message, eventTriggered } = data;
|
||||||
|
const color = eventTriggered ? '#ff4444' : '#44ff44';
|
||||||
|
|
||||||
|
this.notificationArea.innerHTML = `
|
||||||
|
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
|
||||||
|
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
|
||||||
|
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
|
||||||
|
<div style="font-size: 14px; color: #ccc;">${message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.notificationArea.style.opacity = '1';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||