Compare commits

16 Commits

Author SHA1 Message Date
7b28fcf1b0 feat: Sistema de combate completo con tarjetas de personajes y animaciones
- Tarjetas de héroes y monstruos con tokens circulares
- Sistema de selección: héroe + monstruo para atacar
- Botón de ATACAR en tarjeta de monstruo
- Animación de muerte: fade-out + hundimiento (1.5s)
- Visualización de estadísticas completas (WS, BS, S, T, W, I, A, Mov)
- Placeholder cuando no hay héroe seleccionado
- Tokens de héroes y monstruos en formato circular
- Deselección correcta de monstruos
- Fix: paso de gameEngine a CombatMechanics para callbacks de muerte
2026-01-06 18:43:09 +01:00
3efbf8d5fb Implement advanced pathfinding and combat visual effects
- Add monster turn visual feedback (green ring on attacker, red ring on victim)
- Implement proper attack sequence with timing and animations
- Add room boundary and height level pathfinding system
- Monsters now respect room walls and can only pass through doors
- Add height level support (1-8) with stairs (9) for level transitions
- Fix attack validation to prevent attacks through walls
- Speed up hero movement animation (300ms per tile)
- Fix exploration phase message to not show on initial tile placement
- Disable hero movement during exploration phase (doors only)
2026-01-06 16:18:46 +01:00
dd7356f1bd Millorats personatges sense voreres negres 2026-01-06 11:06:24 +01:00
78b7486dd2 fix: allow hero movement in exploration phase (reset moves) 2026-01-05 23:15:07 +01:00
77c0c07a44 feat(game-loop): implement strict phase rules, exploration stops, and hero attacks 2026-01-05 23:11:31 +01:00
b619e4cee4 feat: Implement Event Deck, Monster Spawning, and AI Movement 2026-01-05 00:40:12 +01:00
056217437c Implement Lantern Bearer logic, Phase buttons, and Monster spawning basics 2026-01-04 23:48:53 +01:00
4c8b58151b Refactor: Organize standee assets and prepare for Motor OK tag 2026-01-04 22:32:37 +01:00
3bfe9e4809 Update DEVLOG for Session 5 2026-01-03 00:30:32 +01:00
2f63e54d13 Fix camera panning logic to update target position.
Previously, panning only moved the camera, causing orbital rotation issues when changing views or centering because the target reference point wasn't updated. Now both camera and target move in sync.
2026-01-03 00:28:52 +01:00
46b5466701 Adjust zoom settings and sync slider with mouse wheel.
- Changed default zoom from 2.5 to 6.0 (further away).
- Reduced max zoom distance from 30 to 15.
- Fixed slider not updating when using mouse wheel zoom.
2026-01-03 00:27:09 +01:00
019e527441 Fix duplicate room_objective definition in TileDefinitions.js 2026-01-03 00:21:45 +01:00
cd6abb016f Implement randomized tile textures.
- DungeonGenerator: Selects a random texture from the card definition when finalizing tile placement.
- GameRenderer: Renders the specific chosen texture for each tile instance instead of the default.
2026-01-03 00:19:30 +01:00
7462dd7fed Implement manual player movement planning (steps) and hopping animation
- GameEngine: Added path planning logic (click to add step, re-click to undo).
- GameRenderer: Added path visualization (numbered yellow squares).
- GameRenderer: Updated animation to include 'hopping' effect and clear path markers on visit.
- UIManager: Replaced alerts with modals.
- Main: Wired right-click to execute movement.
2026-01-03 00:15:28 +01:00
dbed4468c5 Fix tile alignment: Enforce strict connection for multi-cell doors and fix exit reference logic 2026-01-03 00:00:36 +01:00
ac536ac96c Implement tile discarding, blocked doors, and correct corridor exits
- Updated TileDefinitions.js: Added 4-way exits to corridor_straight and corridor_steps (N/S y=3,4; E/W x=3,4).
- Updated DungeonGenerator.js: Added cancelPlacement() logic and onDoorBlocked callback.
- Updated GameRenderer.js: Implemented blockDoor() to visualize blocked passages, and improved isPlayerAdjacentToDoor.
- Updated UIManager.js: Added custom showModal/showConfirm and Discard button for tile placement.
- Updated main.js: Handled blocked door clicks and hooked up UI events.
- Updated GameEngine.js: Improved door adjacency checks.
- Updated CameraManager.js: Preserved camera rotation on centerOn.
- Added door1_blocked.png asset.
2026-01-02 23:48:42 +01:00
50 changed files with 3866 additions and 241 deletions

View File

@@ -1,5 +1,77 @@
# 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)
### Objetivo

57
Reglas/Fases.md Normal file
View File

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

View File

@@ -24,13 +24,22 @@
- [x] Tile Model/Texture Loading <!-- id: 23 -->
- [x] dynamic Tile Instancing based on Grid State <!-- id: 24 -->
## Phase 3: Game Mechanics (Loop)
- [ ] **Turn System**
- [ ] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
- [ ] Implement Turn State Machine <!-- id: 31 -->
- [ ] **Entity System**
- [ ] Define Hero/Monster Stats <!-- id: 32 -->
- [ ] Implement Movement Logic (Grid-based) <!-- id: 33 -->
## Phase 3: Game Mechanics (Loop) - [IN PROGRESS]
- [x] **Turn System**
- [x] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
- [x] Implement Turn State Machine (Phases now functional and dispatch events) <!-- id: 31 -->
- [x] Implement Power Phase (Rolls 1d6)
- [x] **Event System**
- [x] Implement Event Deck (Events.js)
- [x] Trigger Random Events on Power Roll of 1 or Room Reveal
- [x] Spawn Monsters from Event Cards (1d6 Orcs)
- [x] **Entity System**
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
- [ ] Refine Combat System (Ranged weapons, Special Monster Rules, Magic)
## Phase 4: Campaign System
- [ ] **Campaign Manager**

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

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

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

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

@@ -0,0 +1,67 @@
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,
to_hit_missile: 4, // 4+ to hit with ranged
str: 3,
toughness: 3,
wounds: 10, // 1D6 + 7 (Using fixed average for now)
attacks: 1,
init: 6,
pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
}
},
wizard: {
id: 'wizard',
name: 'Hechicero',
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
stats: {
move: 4,
ws: 2,
to_hit_missile: 6, // 6+ to hit with ranged
str: 3,
toughness: 3,
wounds: 9, // 1D6 + 6 (Using fixed average for now)
attacks: 1,
init: 3,
power: 0, // Tracks current power points
pin_target: 4 // 4+ to escape pin
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,149 @@
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 getToHitTarget(attackerWS, defenderWS) {
// Adjust for 0-index array
const row = attackerWS - 1;
const col = defenderWS - 1;
if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
return TO_HIT_CHART[row][col];
}
return 6; // Fallback
}
static applyDamage(entity, amount, gameEngine = null) {
if (!entity.stats) entity.stats = {};
// If entity doesn't have current wounds tracked, init it from max
if (entity.currentWounds === undefined) {
// For Heros it is 'wounds', for Monsters typical just 'wounds' in def
// We assume entity has been initialized properly before,
// but if not, we grab max from definition
entity.currentWounds = entity.stats.wounds || 1;
}
entity.currentWounds -= amount;
// Check Status
if (entity.type === 'hero') {
if (entity.currentWounds <= 0) {
entity.currentWounds = 0;
entity.isConscious = false;
// entity.isDead is not immediate for heroes usually, but let's handle via isConscious
}
} else {
// Monsters die at 0
if (entity.currentWounds <= 0) {
entity.currentWounds = 0;
entity.isDead = true;
// Trigger death callback if available
if (gameEngine && gameEngine.onEntityDeath) {
gameEngine.onEntityDeath(entity.id);
}
}
}
}
static rollD6() {
return Math.floor(Math.random() * 6) + 1;
}
}

View File

@@ -1,4 +1,10 @@
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
@@ -6,14 +12,22 @@ import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
export class GameEngine {
constructor() {
this.dungeon = new DungeonGenerator();
this.turnManager = new TurnManager();
this.ai = new MonsterAI(this); // Init AI
this.player = null;
this.selectedEntity = null;
this.isRunning = false;
this.plannedPath = []; // Array of {x,y}
this.visitedRoomIds = new Set(); // Track tiles triggered
this.eventDeck = createEventDeck();
// Callbacks
this.onEntityUpdate = null;
this.onEntityMove = null;
this.onEntitySelect = null;
this.onEntityActive = null; // New: When entity starts/ends turn
this.onEntityHit = null; // New: When entity takes damage
this.onEntityDeath = null; // New: When entity dies
this.onPathChange = null;
}
@@ -21,85 +35,515 @@ export class GameEngine {
this.dungeon.startDungeon(missionConfig);
// Create player at center of first tile
this.createPlayer(1.5, 2.5); // Center of 2x6 corridor
// Create Party (4 Heroes)
this.createParty();
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) {
this.player = {
id: 'p1',
name: 'Barbarian',
x: Math.floor(x),
y: Math.floor(y),
texturePath: '/assets/images/dungeon1/standees/barbaro.png'
};
resetHeroMoves() {
if (!this.heroes) return;
this.heroes.forEach(hero => {
hero.currentMoves = hero.stats.move;
hero.hasAttacked = false;
});
console.log("Refilled Hero Moves");
}
if (this.onEntityUpdate) {
this.onEntityUpdate(this.player);
createParty() {
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) {
// If no player selected, select player on click
if (!this.selectedEntity && this.player && x === this.player.x && y === this.player.y) {
this.selectedEntity = this.player;
if (this.onEntitySelect) {
this.onEntitySelect(this.player.id, true);
}
spawnMonster(monsterKey, x, y, options = {}) {
const definition = MONSTER_DEFINITIONS[monsterKey];
if (!definition) {
console.error(`Monster definition not found: ${monsterKey}`);
return;
}
// If player selected, move to clicked cell
if (this.selectedEntity === this.player) {
if (this.canMoveTo(x, y)) {
this.movePlayer(x, y);
} else {
// Ensure unique ID even in tight loops
if (!this._monsterIdCounter) this._monsterIdCounter = 0;
this._monsterIdCounter++;
const id = `monster_${monsterKey}_${Date.now()}_${this._monsterIdCounter}`;
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) {
// 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);
}
}
}
return;
}
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
if (this.selectedEntity) {
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 };
}
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) {
// Check if cell is walkable (occupied by a tile)
return this.dungeon.grid.isOccupied(x, y);
}
// Deprecated direct move
movePlayer(x, y) {
// Simple direct movement (no pathfinding for now)
const path = [{ x, y }];
this.player.x = x;
this.player.y = y;
if (this.onEntityMove) {
this.onEntityMove(this.player, path);
}
// Deselect after move
this.selectedEntity = null;
if (this.onEntitySelect) {
this.onEntitySelect(this.player.id, false);
}
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
}
isPlayerAdjacentToDoor(doorExit) {
if (!this.player) return false;
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);
getLeader() {
// 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];
}
update(time) {
// 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;
}
}

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

View File

@@ -5,30 +5,36 @@ export class TurnManager {
this.currentTurn = 0;
this.currentPhase = GAME_PHASES.SETUP;
this.listeners = {}; // Simple event system
// Power Phase State
this.currentPowerRoll = 0;
this.eventsTriggered = [];
}
startGame() {
this.currentTurn = 1;
this.setPhase(GAME_PHASES.HERO); // Jump straight to Hero phase for now
console.log(`--- TURN ${this.currentTurn} START ---`);
this.startPowerPhase();
}
nextPhase() {
// Simple sequential flow for now
// Simple sequential flow
switch (this.currentPhase) {
case GAME_PHASES.POWER:
this.setPhase(GAME_PHASES.HERO);
break;
case GAME_PHASES.HERO:
// Usually goes to Exploration if at edge, or Monster if not.
// For this dev stage, let's allow manual triggering of Exploration
// via UI, so we stay in HERO until confirmed done.
// Move to Monster Phase
this.setPhase(GAME_PHASES.MONSTER);
break;
case GAME_PHASES.MONSTER:
// Move to Exploration Phase
this.setPhase(GAME_PHASES.EXPLORATION);
break;
case GAME_PHASES.EXPLORATION:
// End Turn and restart
this.endTurn();
break;
// Exploration is usually triggered as an interrupt, not strictly sequential
}
}
@@ -40,6 +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() {
this.setPhase(GAME_PHASES.EXPLORATION);
// Logic to return to HERO phase would handle elsewhere
@@ -48,7 +85,7 @@ export class TurnManager {
endTurn() {
console.log(`--- TURN ${this.currentTurn} END ---`);
this.currentTurn++;
this.setPhase(GAME_PHASES.POWER);
this.startPowerPhase();
}
// -- Simple Observer Pattern --

View File

@@ -36,6 +36,21 @@ generator.grid.placeTile = (instance, variant, card) => {
setTimeout(() => {
renderer.renderExits(generator.availableExits);
// Don't show modal if we are not in Exploration phase (e.g. during Setup)
if (game.turnManager.currentPhase !== 'exploration') {
return;
}
// NEW RULE: Exploration ends turn immediately. No monsters yet.
// Monsters appear when a hero ENTERS the new room in the next turn.
ui.showModal('Exploración Completada',
'Has colocado una nueva sección de mazmorra.<br>El turno termina aquí.',
() => {
game.turnManager.endTurn();
}
);
}, 50);
};
@@ -44,21 +59,64 @@ game.onEntityUpdate = (entity) => {
renderer.addEntity(entity);
renderer.updateEntityPosition(entity);
// Center camera on player spawn
if (entity.id === 'p1' && !entity._centered) {
// Center camera on FIRST hero spawn
if (game.heroes && game.heroes[0] && entity.id === game.heroes[0].id && !window._cameraCentered) {
cameraManager.centerOn(entity.x, entity.y);
entity._centered = true;
window._cameraCentered = true;
}
};
game.turnManager.on('phase_changed', (phase) => {
if (phase === 'monster') {
setTimeout(async () => {
await game.playMonsterTurn();
// Logic: Skip Exploration if monsters are alive
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
if (hasActiveMonsters) {
ui.showModal('¡Combate en curso!',
'Aún quedan monstruos vivos. Se salta la Fase de Exploración.<br>Preparaos para la <b>Fase de Poder</b> del siguiente turno.',
() => {
// Combat Loop: Power -> Hero -> Monster -> (Skip Exp) -> Power...
game.turnManager.endTurn();
}
);
} else {
ui.showModal('Zona Despejada',
'Fase de Monstruos Finalizada.<br>Pulsa para continuar a la Fase de Exploración.',
() => {
game.turnManager.nextPhase(); // Go to Exploration
}
);
}
}, 500); // Slight delay for visual impact
}
});
game.onCombatResult = (log) => {
ui.showCombatLog(log);
};
game.onEntityMove = (entity, path) => {
renderer.moveEntityAlongPath(entity, path);
};
game.onEntitySelect = (entityId, isSelected) => {
renderer.toggleEntitySelection(entityId, isSelected);
game.onEntityActive = (entityId, isActive) => {
renderer.setEntityActive(entityId, isActive);
};
game.onEntityHit = (entityId) => {
renderer.triggerDamageEffect(entityId);
};
game.onEntityDeath = (entityId) => {
renderer.triggerDeathAnimation(entityId);
};
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
renderer.onHeroFinishedMove = (x, y) => {
cameraManager.centerOn(x, y);
};
@@ -83,39 +141,91 @@ generator.onPlacementUpdate = (preview) => {
}
};
generator.onDoorBlocked = (exitData) => {
renderer.blockDoor(exitData);
};
game.onPathChange = (path) => {
renderer.updatePathVisualization(path);
};
// 6. Handle Clicks
const handleClick = (x, y, doorMesh) => {
const currentPhase = game.turnManager.currentPhase;
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
// PRIORITY 1: Tile Placement Mode - ignore all clicks
if (generator.state === 'PLACING_TILE') {
return;
}
// PRIORITY 2: Door Click (must be adjacent to player)
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
const doorExit = doorMesh.userData.cells[0];
if (game.isPlayerAdjacentToDoor(doorExit)) {
// Open door visually
renderer.openDoor(doorMesh);
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
} else {
console.error('[Main] Door missing exitData');
}
} else {
if (doorMesh && doorMesh.userData.isDoor) {
if (doorMesh.userData.isBlocked) {
ui.showModal('¡Derrumbe!', 'Esta puerta está bloqueada por un derrumbe. No se puede pasar.');
return;
}
if (!doorMesh.userData.isOpen) {
// CHECK PHASE: Exploration Only
if (currentPhase !== 'exploration') {
ui.showModal('Fase Incorrecta', 'Solo puedes explorar (abrir puertas) durante la <b>Fase de Exploración</b>.');
return;
}
// CHECK MONSTERS: Must be clear
if (hasActiveMonsters) {
ui.showModal('¡Peligro!', 'No puedes explorar mientras hay <b>Monstruos</b> cerca. ¡Acaba con ellos primero!');
return;
}
// 1. Check Selection and Leadership (STRICT)
const selectedHero = game.selectedEntity;
if (!selectedHero) {
ui.showModal('Ningún Héroe seleccionado', 'Selecciona al <b>Líder (Portador de la Lámpara)</b> para abrir la puerta.');
return;
}
if (!selectedHero.hasLantern) {
ui.showModal('Acción no permitida', `<b>${selectedHero.name}</b> no lleva la Lámpara. Solo el <b>Líder</b> puede explorar.`);
return;
}
// 2. Check Adjacency
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
// Open door visually
renderer.openDoor(doorMesh);
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
} else {
console.error('[Main] Door missing exitData');
}
} else {
ui.showModal('Demasiado lejos', 'El Líder debe estar <b>adyacente</b> a la puerta para abrirla.');
}
return;
}
return;
}
// PRIORITY 3: Normal cell click (player selection/movement)
if (x !== null && y !== null) {
// Restrict Hero Selection/Movement to Hero Phase (and verify logic in GameEngine handle selection)
// Actually, we might want to select heroes in other phases to see stats, but MOVE only in Hero Phase.
// GameEngine.planStep handles planning.
// We let GameEngine handle selection. But for movement planning...
// Let's modify onCellClick inside GameEngine or just block here?
// Blocking execution is safer.
// Wait, onCellClick handles Selection AND Planning.
// We'll let it select. But we hook executeMovePath separately.
game.onCellClick(x, y);
}
};
@@ -123,13 +233,51 @@ const handleClick = (x, y, doorMesh) => {
renderer.setupInteraction(
() => cameraManager.getCamera(),
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
game.startMission(mission);
// Mark initial tile as visited to prevent immediate trigger
if (game.heroes && game.heroes.length > 0) {
const h = game.heroes[0];
const initialTileId = game.dungeon.grid.occupiedCells.get(`${h.x},${h.y}`);
if (initialTileId) {
game.visitedRoomIds.add(initialTileId);
console.log(`[Main] Initial tile ${initialTileId} marked as visited.`);
}
}
// 8. Render Loop
const animate = (time) => {
requestAnimationFrame(animate);

View File

@@ -7,9 +7,11 @@ export class CameraManager {
// Configuration
// Configuration
this.zoomLevel = 2.5; // Orthographic zoom factor (Lower = Closer)
this.zoomLevel = 6.0; // Started further back as requested
this.aspect = window.innerWidth / window.innerHeight;
this.onZoomChange = null;
// Isometric Setup: Orthographic Camera
this.camera = new THREE.OrthographicCamera(
-this.zoomLevel * this.aspect,
@@ -52,9 +54,14 @@ export class CameraManager {
}
centerOn(x, y) {
// Grid (x, y) -> World (x, 0, -y)
// Calculate current offset relative to OLD target
const currentOffset = this.camera.position.clone().sub(this.target);
// Update target: Grid (x, y) -> World (x, 0, -y)
this.target.set(x, 0, -y);
this.camera.position.copy(this.target).add(this.isoOffset);
// Restore position with new target + same relative offset
this.camera.position.copy(this.target).add(currentOffset);
this.camera.lookAt(this.target);
}
@@ -64,9 +71,10 @@ export class CameraManager {
e.preventDefault();
// Adjust Zoom Level property
if (e.deltaY < 0) this.zoomLevel = Math.max(3, this.zoomLevel - 1);
else this.zoomLevel = Math.min(30, this.zoomLevel + 1);
else this.zoomLevel = Math.min(15, this.zoomLevel + 1);
this.updateProjection();
if (this.onZoomChange) this.onZoomChange(this.zoomLevel);
}, { passive: false });
// Pan Listeners (Middle Click)
@@ -109,28 +117,30 @@ export class CameraManager {
}
pan(dx, dy) {
// Move Target and Camera together
// We pan on the logical "Ground Plane" relative to screen movement
// Move Speed Factor
const moveSpeed = this.panSpeed * 0.05 * (this.zoomLevel / 10);
// Transform screen delta to world delta
// In Iso view, Right on screen = (1, 0, 1) in world?
// Or using camera right/up vectors
// Direction: Dragging the "World"
// Mouse Left (dx < 0) -> Camera moves Right (+X)
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
const moveX = dx * moveSpeed;
const moveY = dy * moveSpeed;
const right = new THREE.Vector3(1, 0, 1).normalize(); // Approx logic for standard Iso
const forward = new THREE.Vector3(-1, 0, 1).normalize();
// Apply to Camera (Local Space)
this.camera.translateX(moveX);
this.camera.translateY(moveY);
// Let's use camera vectors for generic support
// Project camera right/up onto XZ plane
// Or just direct translation:
// Calculate World Movement to update Target
const vRight = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion);
const vUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.camera.quaternion);
this.camera.translateX(dx * moveSpeed);
this.camera.translateY(dy * moveSpeed);
const worldTranslation = new THREE.Vector3()
.addScaledVector(vRight, moveX)
.addScaledVector(vUp, moveY);
// This moves camera. We need to update target reference too if we want to snap back correctly
// But for now, simple pan is "offsetting everything".
// centerOn resets this.
// Apply same movement to Target so relative offset is preserved
// This ensures lookAt() doesn't pivot the camera around the old center
this.target.add(worldTranslation);
}
update(deltaTime) {

View File

@@ -77,8 +77,9 @@ export class GameRenderer {
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
if (doorIntersects.length > 0) {
const doorMesh = doorIntersects[0].object;
if (doorMesh.userData.isDoor) {
// Clicked on a door! Call onClick with a special door object
// Only capture click if it is a door AND it is NOT open
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
// Clicked on a CLOSED door! Call onClick with a special door object
onClick(null, null, doorMesh);
return;
}
@@ -185,6 +186,14 @@ export class GameRenderer {
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
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 });
@@ -208,6 +217,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) {
const mesh = this.entities.get(entity.id);
if (mesh) {
@@ -240,31 +348,97 @@ export class GameRenderer {
}
if (data.isMoving) {
const duration = 400; // ms per tile
const duration = 300; // Hero movement speed (300ms per tile)
const elapsed = time - data.startTime;
const t = Math.min(elapsed / duration, 1);
const progress = Math.min(elapsed / duration, 1);
// Lerp X/Z
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, t);
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, t);
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress);
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress);
// Jump Arc
// Hop (Botecito)
const jumpHeight = 0.5;
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) {
mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z);
if (progress >= 1) {
data.isMoving = false;
mesh.position.y = baseHeight; // Reset height
// 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);
// Remove the visualization tile for this step
if (this.pathGroup) {
for (let i = this.pathGroup.children.length - 1; i >= 0; i--) {
const child = this.pathGroup.children[i];
// Match X and Z (ignoring small float errors)
if (Math.abs(child.position.x - data.targetPos.x) < 0.1 &&
Math.abs(child.position.z - data.targetPos.z) < 0.1) {
this.pathGroup.remove(child);
}
}
}
}
} 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 +618,52 @@ export class GameRenderer {
}
}
// Optimized getTexture with pending request queue
getTexture(path, onLoad) {
if (!this.textureCache.has(path)) {
const tex = this.textureLoader.load(
path,
(texture) => {
texture.needsUpdate = true;
if (onLoad) onLoad(texture);
},
undefined,
(err) => {
console.error(`[TextureLoader] ✗ Failed to load: ${path}`, err);
}
);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
this.textureCache.set(path, tex);
} else {
// Already cached, call onLoad immediately if texture is ready
const cachedTex = this.textureCache.get(path);
if (onLoad && cachedTex.image) {
onLoad(cachedTex);
}
// 1. Check Cache
if (this.textureCache.has(path)) {
const tex = this.textureCache.get(path);
if (onLoad) onLoad(tex);
return;
}
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) {
@@ -483,7 +676,8 @@ export class GameRenderer {
// Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD
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
this.getTexture(texturePath, (texture) => {
@@ -585,6 +779,65 @@ export class GameRenderer {
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) {
if (!doorMesh || !doorMesh.userData.isDoor) return false;
@@ -601,6 +854,36 @@ export class GameRenderer {
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 ==========
enableDoorSelection(enabled) {
@@ -727,12 +1010,31 @@ export class GameRenderer {
});
}
// 2. GROUND PROJECTION (Green/Red)
const projectionColor = isValid ? 0x00ff00 : 0xff0000;
// 2. GROUND PROJECTION (Green/Red/Blue)
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 => {
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 material = new THREE.MeshBasicMaterial({
color: projectionColor,
color: color,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide

View File

@@ -5,9 +5,61 @@ export class UIManager {
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);
const monster = this.game.monsters ? this.game.monsters.find(m => m.id === id) : null;
if (hero) {
this.selectedHero = hero;
this.updateHeroStats(hero);
this.showHeroCard(hero);
this.hideMonsterCard(); // Hide monster card if showing
} else if (monster && this.selectedHero && this.game.turnManager.currentPhase === 'hero') {
// Show monster card only if a hero is selected (for attacking)
this.showMonsterCard(monster);
}
} else {
// Deselection - check what type was deselected
if (this.selectedHero && this.selectedHero.id === id) {
// Hero was deselected
this.selectedHero = null;
this.updateHeroStats(null);
this.hideHeroCard();
} else {
// Monster was deselected
this.hideMonsterCard();
}
}
};
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() {
@@ -65,20 +117,26 @@ export class UIManager {
const zoomSlider = document.createElement('input');
zoomSlider.type = 'range';
zoomSlider.min = '2.5'; // Closest zoom
zoomSlider.max = '30'; // Farthest zoom
zoomSlider.value = '2.5'; // Start at closest
zoomSlider.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'; // Push slider down to make room for label
zoomSlider.style.marginTop = '40px';
// Set initial zoom to closest
this.cameraManager.zoomLevel = 2.5;
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();
@@ -224,7 +282,6 @@ export class UIManager {
};
placementControls.appendChild(this.rotateBtn);
// Place button
this.placeBtn = document.createElement('button');
this.placeBtn.textContent = '⬇ Bajar';
this.placeBtn.style.padding = '10px 20px';
@@ -238,11 +295,440 @@ export class UIManager {
if (this.dungeon) {
const success = this.dungeon.confirmPlacement();
if (!success) {
alert('❌ No se puede colocar la loseta en esta posición');
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';
// Circular icon container
const iconContainer = document.createElement('div');
iconContainer.style.width = '100px';
iconContainer.style.height = '100px';
iconContainer.style.borderRadius = '50%';
iconContainer.style.border = '2px solid #8B4513';
iconContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
iconContainer.style.display = 'flex';
iconContainer.style.alignItems = 'center';
iconContainer.style.justifyContent = 'center';
iconContainer.style.marginBottom = '20px';
const icon = document.createElement('div');
icon.textContent = '🎴';
icon.style.fontSize = '48px';
iconContainer.appendChild(icon);
card.appendChild(iconContainer);
const text = document.createElement('div');
text.textContent = 'Selecciona un Aventurero';
text.style.fontSize = '14px';
text.style.color = '#DAA520';
card.appendChild(text);
this.placeholderCard = card;
this.cardsContainer.appendChild(card);
}
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 (circular)
const portrait = document.createElement('div');
portrait.style.width = '100px';
portrait.style.height = '100px';
portrait.style.borderRadius = '50%';
portrait.style.overflow = 'hidden';
portrait.style.border = '2px solid #DAA520';
portrait.style.marginBottom = '8px';
portrait.style.marginLeft = 'auto';
portrait.style.marginRight = 'auto';
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?v=2`;
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);
card.dataset.heroId = hero.id;
return card;
}
showHeroCard(hero) {
// Remove placeholder if present
if (this.placeholderCard && this.placeholderCard.parentNode) {
this.cardsContainer.removeChild(this.placeholderCard);
}
// Remove previous hero card if present
if (this.currentHeroCard && this.currentHeroCard.parentNode) {
this.cardsContainer.removeChild(this.currentHeroCard);
}
// Create and show new hero card
this.currentHeroCard = this.createHeroCard(hero);
this.cardsContainer.insertBefore(this.currentHeroCard, this.cardsContainer.firstChild);
}
hideHeroCard() {
// Remove hero card
if (this.currentHeroCard && this.currentHeroCard.parentNode) {
this.cardsContainer.removeChild(this.currentHeroCard);
this.currentHeroCard = null;
}
// Show placeholder if no cards are visible
if (!this.currentMonsterCard && this.placeholderCard && !this.placeholderCard.parentNode) {
this.cardsContainer.appendChild(this.placeholderCard);
}
}
updateHeroCard(heroId) {
if (!this.currentHeroCard || this.currentHeroCard.dataset.heroId !== heroId) {
return;
}
const hero = this.game.heroes.find(h => h.id === heroId);
if (!hero) return;
// Update wounds and moves in the stats grid
const statsGrid = this.currentHeroCard.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}`;
}
}
}
createMonsterCard(monster) {
const card = document.createElement('div');
card.style.width = '180px';
card.style.backgroundColor = 'rgba(40, 20, 20, 0.95)';
card.style.border = '2px solid #8B0000';
card.style.borderRadius = '8px';
card.style.padding = '10px';
card.style.fontFamily = '"Cinzel", serif';
card.style.color = '#fff';
const portrait = document.createElement('div');
portrait.style.width = '100px';
portrait.style.height = '100px';
portrait.style.borderRadius = '50%';
portrait.style.overflow = 'hidden';
portrait.style.border = '2px solid #8B0000';
portrait.style.marginBottom = '8px';
portrait.style.marginLeft = 'auto';
portrait.style.marginRight = 'auto';
portrait.style.backgroundColor = '#000';
portrait.style.display = 'flex';
portrait.style.alignItems = 'center';
portrait.style.justifyContent = 'center';
const tokenPath = `/assets/images/dungeon1/tokens/enemies/${monster.key}.png?v=2`;
const img = document.createElement('img');
img.src = tokenPath;
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.onerror = () => {
portrait.innerHTML = `<div style="color: #8B0000; font-size: 48px;">👹</div>`;
};
portrait.appendChild(img);
card.appendChild(portrait);
const name = document.createElement('div');
name.textContent = monster.name;
name.style.fontSize = '16px';
name.style.fontWeight = 'bold';
name.style.color = '#FF4444';
name.style.textAlign = 'center';
name.style.marginBottom = '8px';
name.style.textTransform = 'uppercase';
card.appendChild(name);
const statsGrid = document.createElement('div');
statsGrid.style.display = 'grid';
statsGrid.style.gridTemplateColumns = '1fr 1fr';
statsGrid.style.gap = '4px';
statsGrid.style.fontSize = '12px';
const stats = [
{ label: 'WS', value: monster.stats.ws || 0 },
{ label: 'S', value: monster.stats.str || 0 },
{ label: 'T', value: monster.stats.toughness || 0 },
{ label: 'W', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` },
{ label: 'I', value: monster.stats.initiative || 0 },
{ label: 'A', value: monster.stats.attacks || 0 }
];
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);
card.dataset.monsterId = monster.id;
return card;
}
showMonsterCard(monster) {
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) {
this.cardsContainer.removeChild(this.currentMonsterCard);
}
if (this.attackButton && this.attackButton.parentNode) {
this.cardsContainer.removeChild(this.attackButton);
}
this.currentMonsterCard = this.createMonsterCard(monster);
this.cardsContainer.appendChild(this.currentMonsterCard);
this.attackButton = document.createElement('button');
this.attackButton.textContent = '⚔️ ATACAR';
this.attackButton.style.width = '180px';
this.attackButton.style.padding = '12px';
this.attackButton.style.backgroundColor = '#8B0000';
this.attackButton.style.color = '#fff';
this.attackButton.style.border = '2px solid #FF4444';
this.attackButton.style.borderRadius = '8px';
this.attackButton.style.fontFamily = '"Cinzel", serif';
this.attackButton.style.fontSize = '16px';
this.attackButton.style.fontWeight = 'bold';
this.attackButton.style.cursor = 'pointer';
this.attackButton.style.transition = 'all 0.2s';
this.attackButton.onmouseenter = () => {
this.attackButton.style.backgroundColor = '#FF0000';
this.attackButton.style.transform = 'scale(1.05)';
};
this.attackButton.onmouseleave = () => {
this.attackButton.style.backgroundColor = '#8B0000';
this.attackButton.style.transform = 'scale(1)';
};
this.attackButton.onclick = () => {
if (this.game.performHeroAttack) {
const result = this.game.performHeroAttack(monster.id);
if (result && result.success) {
// Attack successful, hide monster card
this.hideMonsterCard();
// Deselect monster
if (this.game.selectedMonster) {
if (this.game.onEntitySelect) {
this.game.onEntitySelect(this.game.selectedMonster.id, false);
}
this.game.selectedMonster = null;
}
}
}
};
this.cardsContainer.appendChild(this.attackButton);
}
hideMonsterCard() {
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) {
this.cardsContainer.removeChild(this.currentMonsterCard);
this.currentMonsterCard = null;
}
if (this.attackButton && this.attackButton.parentNode) {
this.cardsContainer.removeChild(this.attackButton);
this.attackButton = null;
}
}
showPlacementControls(show) {
@@ -295,37 +781,17 @@ export class UIManager {
ctx.clearRect(0, 0, w, h);
// Center the view on 0,0 or the average?
// Let's rely on fixed scale for now
const cellSize = 5;
const centerX = w / 2;
const centerY = h / 2;
// Draw placed tiles
// We can access this.dungeon.grid.occupiedCells for raw occupied spots
// Or this.dungeon.placedTiles for structural info (type, color)
ctx.fillStyle = '#666'; // Generic floor
// Iterate over grid occupied cells
// But grid is a Map, iterating keys is slow.
// Better to iterate placedTiles which is an Array
// Simpler approach: Iterate the Grid Map directly
// It's a Map<"x,y", tileId>
// Use an iterator
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
const [x, y] = key.split(',').map(Number);
// Coordinate transformation to Canvas
// Dungeon (0,0) -> Canvas (CenterX, CenterY)
// Y in dungeon is Up/North. Y in Canvas is Down.
// So CanvasY = CenterY - (DungeonY * size)
const cx = centerX + (x * cellSize);
const cy = centerY - (y * cellSize);
// Color based on TileId type?
if (tileId.includes('room')) ctx.fillStyle = '#55a';
else ctx.fillStyle = '#aaa';
@@ -351,4 +817,367 @@ export class UIManager {
ctx.lineTo(centerX, centerY + 5);
ctx.stroke();
}
showModal(title, message, onClose) {
// Overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.pointerEvents = 'auto'; // Block clicks behind
overlay.style.zIndex = '1000';
// Content Box
const content = document.createElement('div');
content.style.backgroundColor = '#222';
content.style.border = '2px solid #888';
content.style.borderRadius = '8px';
content.style.padding = '20px';
content.style.width = '300px';
content.style.textAlign = 'center';
content.style.color = '#fff';
content.style.fontFamily = 'sans-serif';
// Title
const titleEl = document.createElement('h2');
titleEl.textContent = title;
titleEl.style.marginTop = '0';
titleEl.style.color = '#f44'; // Reddish for importance
content.appendChild(titleEl);
// Message
const msgEl = document.createElement('p');
msgEl.innerHTML = message;
msgEl.style.fontSize = '16px';
msgEl.style.lineHeight = '1.5';
content.appendChild(msgEl);
// OK Button
const btn = document.createElement('button');
btn.textContent = 'Entendido';
btn.style.marginTop = '20px';
btn.style.padding = '10px 20px';
btn.style.fontSize = '16px';
btn.style.cursor = 'pointer';
btn.style.backgroundColor = '#444';
btn.style.color = '#fff';
btn.style.border = '1px solid #888';
btn.onclick = () => {
this.container.removeChild(overlay);
if (onClose) onClose();
};
content.appendChild(btn);
overlay.appendChild(content);
this.container.appendChild(overlay);
}
showCombatLog(log) {
if (!this.notificationArea) return;
const isHit = log.hitSuccess;
const color = isHit ? '#ff4444' : '#aaaaaa';
const title = isHit ? 'GOLPE!' : 'FALLO';
let detailHtml = '';
if (isHit) {
if (log.woundsCaused > 0) {
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
} else {
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
}
} else {
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
}
// Show simplified but impactful message
this.notificationArea.innerHTML = `
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 2px solid ${color}; border-radius: 5px; text-align: center; min-width: 250px;">
<div style="font-family: 'Cinzel'; font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
${detailHtml}
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
</div>
`;
this.notificationArea.style.opacity = '1';
// Update hero card if defender is a hero
const defender = this.game.heroes.find(h => h.id === log.defenderId) ||
this.game.monsters.find(m => m.id === log.defenderId);
if (defender && defender.type === 'hero') {
this.updateHeroCard(defender.id);
}
setTimeout(() => {
if (this.notificationArea) this.notificationArea.style.opacity = '0';
}, 3500);
}
showConfirm(title, message, onConfirm) {
// Overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.pointerEvents = 'auto'; // Block clicks behind
overlay.style.zIndex = '1000';
// Content Box
const content = document.createElement('div');
content.style.backgroundColor = '#222';
content.style.border = '2px solid #888';
content.style.borderRadius = '8px';
content.style.padding = '20px';
content.style.width = '300px';
content.style.textAlign = 'center';
content.style.color = '#fff';
content.style.fontFamily = 'sans-serif';
// Title
const titleEl = document.createElement('h2');
titleEl.textContent = title;
titleEl.style.marginTop = '0';
titleEl.style.color = '#f44';
content.appendChild(titleEl);
// Message
const msgEl = document.createElement('p');
msgEl.innerHTML = message;
msgEl.style.fontSize = '16px';
msgEl.style.lineHeight = '1.5';
content.appendChild(msgEl);
// Buttons Container
const buttons = document.createElement('div');
buttons.style.display = 'flex';
buttons.style.justifyContent = 'space-around';
buttons.style.marginTop = '20px';
// Cancel Button
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancelar';
cancelBtn.style.padding = '10px 20px';
cancelBtn.style.fontSize = '16px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.backgroundColor = '#555';
cancelBtn.style.color = '#fff';
cancelBtn.style.border = '1px solid #888';
cancelBtn.onclick = () => {
this.container.removeChild(overlay);
};
buttons.appendChild(cancelBtn);
// Confirm Button
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Aceptar';
confirmBtn.style.padding = '10px 20px';
confirmBtn.style.fontSize = '16px';
confirmBtn.style.cursor = 'pointer';
confirmBtn.style.backgroundColor = '#2a5';
confirmBtn.style.color = '#fff';
confirmBtn.style.border = '1px solid #888';
confirmBtn.onclick = () => {
if (onConfirm) onConfirm();
this.container.removeChild(overlay);
};
buttons.appendChild(confirmBtn);
content.appendChild(buttons);
overlay.appendChild(content);
this.container.appendChild(overlay);
}
createGameStatusPanel() {
// Top Center Panel
this.statusPanel = document.createElement('div');
this.statusPanel.style.position = 'absolute';
this.statusPanel.style.top = '20px';
this.statusPanel.style.left = '50%';
this.statusPanel.style.transform = 'translateX(-50%)';
this.statusPanel.style.display = 'flex';
this.statusPanel.style.flexDirection = 'column';
this.statusPanel.style.alignItems = 'center';
this.statusPanel.style.pointerEvents = 'none';
// Turn/Phase Info
this.phaseInfo = document.createElement('div');
this.phaseInfo.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
this.phaseInfo.style.padding = '10px 20px';
this.phaseInfo.style.border = '2px solid #daa520'; // GoldenRod
this.phaseInfo.style.borderRadius = '5px';
this.phaseInfo.style.color = '#fff';
this.phaseInfo.style.fontFamily = '"Cinzel", serif';
this.phaseInfo.style.fontSize = '20px';
this.phaseInfo.style.textAlign = 'center';
this.phaseInfo.style.textTransform = 'uppercase';
this.phaseInfo.style.minWidth = '200px';
this.phaseInfo.innerHTML = `
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
<div style="font-size: 24px; color: #daa520;">Setup</div>
`;
this.statusPanel.appendChild(this.phaseInfo);
// End Phase Button
this.endPhaseBtn = document.createElement('button');
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.style.marginTop = '10px';
this.endPhaseBtn.style.width = '100%';
this.endPhaseBtn.style.padding = '8px';
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
this.endPhaseBtn.style.color = '#000';
this.endPhaseBtn.style.border = '1px solid #8B4513';
this.endPhaseBtn.style.borderRadius = '3px';
this.endPhaseBtn.style.fontWeight = 'bold';
this.endPhaseBtn.style.cursor = 'pointer';
this.endPhaseBtn.style.display = 'none'; // Hidden by default
this.endPhaseBtn.style.fontFamily = '"Cinzel", serif';
this.endPhaseBtn.style.fontSize = '12px';
this.endPhaseBtn.style.pointerEvents = 'auto'; // Enable clicking
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
this.endPhaseBtn.onclick = () => {
console.log('[UIManager] End Phase Button Clicked', this.game.turnManager.currentPhase);
this.game.turnManager.nextPhase();
};
this.statusPanel.appendChild(this.endPhaseBtn);
// Notification Area (Power Roll results, etc)
this.notificationArea = document.createElement('div');
this.notificationArea.style.marginTop = '10px';
this.notificationArea.style.transition = 'opacity 0.5s';
this.notificationArea.style.opacity = '0';
this.statusPanel.appendChild(this.notificationArea);
this.container.appendChild(this.statusPanel);
// Inject Font
if (!document.getElementById('game-font')) {
const link = document.createElement('link');
link.id = 'game-font';
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
}
setupGameListeners() {
if (this.game.turnManager) {
this.game.turnManager.on('phase_changed', (phase) => {
this.updatePhaseDisplay(phase);
});
this.game.turnManager.on('POWER_RESULT', (data) => {
this.showPowerRollResult(data);
});
}
}
updatePhaseDisplay(phase) {
if (!this.phaseInfo) return;
const turn = this.game.turnManager.currentTurn;
let content = `
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
`;
if (this.selectedHero) {
content += this.getHeroStatsHTML(this.selectedHero);
}
this.phaseInfo.innerHTML = content;
if (this.endPhaseBtn) {
if (phase === 'hero') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
} else if (phase === 'monster') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS';
this.endPhaseBtn.title = "Pasar a Fase de Exploración";
} else if (phase === 'exploration') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR TURNO';
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
} else {
this.endPhaseBtn.style.display = 'none';
}
}
}
updateHeroStats(hero) {
if (!this.phaseInfo) return;
const turn = this.game.turnManager.currentTurn;
const phase = this.game.turnManager.currentPhase;
let content = `
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
`;
if (hero) {
content += this.getHeroStatsHTML(hero);
}
this.phaseInfo.innerHTML = content;
}
getHeroStatsHTML(hero) {
const portraitUrl = hero.texturePath || '';
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
return `
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
</div>
<div style="text-align: left;">
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
${hero.name} ${lanternIcon}
</div>
<div style="font-size: 14px;">
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
</div>
</div>
</div>
`;
}
showPowerRollResult(data) {
if (!this.notificationArea) return;
const { roll, message, eventTriggered } = data;
const color = eventTriggered ? '#ff4444' : '#44ff44';
this.notificationArea.innerHTML = `
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
<div style="font-size: 14px; color: #ccc;">${message}</div>
</div>
`;
this.notificationArea.style.opacity = '1';
setTimeout(() => {
if (this.notificationArea) this.notificationArea.style.opacity = '0';
}, 3000);
}
}

View File

@@ -0,0 +1,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);
}
}