Compare commits

7 Commits

Author SHA1 Message Date
c0a9299dc5 Implement Elf Ranged Combat and Pinned Mechanic
- Added 'Shoot Bow' action for Elf with Ballistic Skill mechanics (1995 rules).
- Implemented strict Line of Sight (LOS) raycasting (Amanatides & Woo) with UI feedback for blockers.
- Added 'Pinned' status: Heroes adjacent to monsters (without intervening walls) cannot move.
- Enhanced UI with visual indicators for blocked shots (red circles) and temporary modals.
- Polished 'End Phase' button layout and hidden it during Monster phase.
2026-01-06 20:05:56 +01:00
7b28fcf1b0 feat: Sistema de combate completo con tarjetas de personajes y animaciones
- Tarjetas de héroes y monstruos con tokens circulares
- Sistema de selección: héroe + monstruo para atacar
- Botón de ATACAR en tarjeta de monstruo
- Animación de muerte: fade-out + hundimiento (1.5s)
- Visualización de estadísticas completas (WS, BS, S, T, W, I, A, Mov)
- Placeholder cuando no hay héroe seleccionado
- Tokens de héroes y monstruos en formato circular
- Deselección correcta de monstruos
- Fix: paso de gameEngine a CombatMechanics para callbacks de muerte
2026-01-06 18:43:09 +01:00
3efbf8d5fb Implement advanced pathfinding and combat visual effects
- Add monster turn visual feedback (green ring on attacker, red ring on victim)
- Implement proper attack sequence with timing and animations
- Add room boundary and height level pathfinding system
- Monsters now respect room walls and can only pass through doors
- Add height level support (1-8) with stairs (9) for level transitions
- Fix attack validation to prevent attacks through walls
- Speed up hero movement animation (300ms per tile)
- Fix exploration phase message to not show on initial tile placement
- Disable hero movement during exploration phase (doors only)
2026-01-06 16:18:46 +01:00
dd7356f1bd Millorats personatges sense voreres negres 2026-01-06 11:06:24 +01:00
78b7486dd2 fix: allow hero movement in exploration phase (reset moves) 2026-01-05 23:15:07 +01:00
77c0c07a44 feat(game-loop): implement strict phase rules, exploration stops, and hero attacks 2026-01-05 23:11:31 +01:00
b619e4cee4 feat: Implement Event Deck, Monster Spawning, and AI Movement 2026-01-05 00:40:12 +01:00
40 changed files with 3299 additions and 143 deletions

View File

@@ -1,5 +1,38 @@
# 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

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.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

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.

Before

Width:  |  Height:  |  Size: 5.6 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

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.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

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

View File

@@ -5,14 +5,14 @@ export const HERO_DEFINITIONS = {
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
stats: {
move: 4,
ws: 4, // Weapon Skill
bs: 5, // Ballistic Skill (3+ to hit, often lower is better in WHQ, let's use standard table numbers for now)
ws: 3,
to_hit_missile: 5, // 5+ to hit with ranged
str: 4,
toughness: 4,
wounds: 12,
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas)
wounds: 12, // 1D6 + 9 (Using fixed average for now)
attacks: 1,
init: 3,
luck: 2 // Rerolls??
pin_target: 6 // 6+ to escape pin
}
},
dwarf: {
@@ -20,15 +20,15 @@ export const HERO_DEFINITIONS = {
name: 'Enano',
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
stats: {
move: 3,
ws: 5,
bs: 5,
move: 4,
ws: 4,
to_hit_missile: 5, // 5+ to hit with ranged
str: 3,
toughness: 5,
wounds: 13,
toughness: 5, // 4 Base + 1 Armor (Cota de Malla)
wounds: 11, // 1D6 + 8 (Using fixed average for now)
attacks: 1,
init: 2,
luck: 0
pin_target: 5 // 5+ to escape pin
}
},
elf: {
@@ -36,15 +36,16 @@ export const HERO_DEFINITIONS = {
name: 'Elfa',
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
stats: {
move: 5,
move: 4,
ws: 4,
bs: 2, // Amazing shot
bs: 4, // Added for Bow
to_hit_missile: 4, // 4+ to hit with ranged
str: 3,
toughness: 3,
wounds: 10,
wounds: 10, // 1D6 + 7 (Using fixed average for now)
attacks: 1,
init: 6,
luck: 1
pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
}
},
wizard: {
@@ -53,15 +54,15 @@ export const HERO_DEFINITIONS = {
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
stats: {
move: 4,
ws: 3,
bs: 6,
ws: 2,
to_hit_missile: 6, // 6+ to hit with ranged
str: 3,
toughness: 3,
wounds: 9,
wounds: 9, // 1D6 + 6 (Using fixed average for now)
attacks: 1,
init: 4,
luck: 1,
power: 0 // Special mechanic
init: 3,
power: 0, // Tracks current power points
pin_target: 4 // 4+ to escape pin
}
}
};

View File

@@ -1,32 +1,92 @@
export const MONSTER_DEFINITIONS = {
orc: {
id: 'orc',
name: 'Orco',
name: 'Guerrero Orco',
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
stats: {
move: 4,
ws: 3,
bs: 5,
str: 3,
toughness: 4,
wounds: 4,
wounds: 1, // Card: "Heridas: 1" (Wait, Orcs usually have 1, check image: YES "Heridas: 1")
attacks: 1,
gold: 15
gold: 55 // Card: "Valor 55x Unidad"
}
},
chaos_warrior: {
id: 'chaos_warrior',
name: 'Guerrero del Caos',
portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
goblin_spearman: {
id: 'goblin_spearman',
name: 'Lancero Goblin',
portrait: '/assets/images/dungeon1/standees/enemies/goblin.png',
stats: {
move: 4,
ws: 5,
bs: 0,
str: 5,
toughness: 5,
wounds: 8,
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: 150
gold: 440,
damageDice: 2 // "Tira 2 dados para herir"
}
}
};

View File

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

View File

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

View File

@@ -1,7 +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
@@ -10,15 +13,23 @@ 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.onRangedTarget = null; // New: For ranged targeting visualization
this.onEntityActive = null; // New: When entity starts/ends turn
this.onShowMessage = null; // New: Generic temporary message UI callback
this.onEntityHit = null; // New: When entity takes damage
this.onEntityDeath = null; // New: When entity dies
this.onPathChange = null;
}
@@ -34,7 +45,7 @@ export class GameEngine {
// Listen for Phase Changes to Reset Moves
this.turnManager.on('phase_changed', (phase) => {
if (phase === 'hero') {
if (phase === 'hero' || phase === 'exploration') {
this.resetHeroMoves();
}
});
@@ -98,14 +109,19 @@ export class GameEngine {
this.player = this.heroes[0];
}
spawnMonster(monsterKey, x, y) {
spawnMonster(monsterKey, x, y, options = {}) {
const definition = MONSTER_DEFINITIONS[monsterKey];
if (!definition) {
console.error(`Monster definition not found: ${monsterKey}`);
return;
}
const id = `monster_${monsterKey}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
// 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,
@@ -117,8 +133,9 @@ export class GameEngine {
texturePath: definition.portrait,
stats: { ...definition.stats },
// Game State
currentWounds: definition.stats.wounds,
isDead: false
currentWounds: definition.stats.wounds || 1,
isDead: false,
skipTurn: !!options.skipTurn // Summoning sickness flag
};
this.monsters.push(monster);
@@ -131,9 +148,30 @@ export class GameEngine {
}
onCellClick(x, y) {
// 1. Check for Hero/Monster Selection
// RANGED TARGETING LOGIC
if (this.targetingMode === 'ranged') {
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
if (clickedMonster) {
if (this.selectedEntity && this.selectedEntity.type === 'hero') {
const los = this.checkLineOfSightStrict(this.selectedEntity, clickedMonster);
this.selectedMonster = clickedMonster;
if (this.onRangedTarget) {
this.onRangedTarget(clickedMonster, los);
}
}
} else {
// Determine if we clicked something else relevant or empty space
// If clicked self (hero), maybe cancel?
// For now, any non-monster click cancels targeting
// Unless it's just a UI click (handled by DOM)
this.cancelTargeting();
}
return;
}
// 1. Identify clicked contents
const clickedHero = this.heroes.find(h => h.x === x && h.y === y);
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y) : null;
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
const clickedEntity = clickedHero || clickedMonster;
@@ -141,13 +179,45 @@ export class GameEngine {
if (this.selectedEntity === clickedEntity) {
// Toggle Deselect
this.deselectEntity();
} else {
// Select new entity
if (this.selectedEntity) this.deselectEntity();
this.selectedEntity = clickedEntity;
} 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(clickedEntity.id, true);
this.onEntitySelect(monsterId, false);
}
} else {
// Select new entity (don't deselect hero if clicking monster)
if (clickedMonster && this.selectedEntity && this.selectedEntity.type === 'hero') {
// Deselect previous monster if any
if (this.selectedMonster) {
const prevMonsterId = this.selectedMonster.id;
if (this.onEntitySelect) {
this.onEntitySelect(prevMonsterId, false);
}
}
// Keep hero selected, also select monster
this.selectedMonster = clickedMonster;
if (this.onEntitySelect) {
this.onEntitySelect(clickedMonster.id, true);
}
} else {
// Normal selection (deselect previous)
if (this.selectedEntity) this.deselectEntity();
this.selectedEntity = clickedEntity;
if (this.onEntitySelect) {
this.onEntitySelect(clickedEntity.id, true);
}
// Check Pinned Status
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
if (this.isEntityPinned(clickedEntity)) {
if (this.onShowMessage) {
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.');
}
}
}
}
}
return;
@@ -155,10 +225,95 @@ export class GameEngine {
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
if (this.selectedEntity) {
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.');
return;
}
this.planStep(x, y);
}
}
performHeroAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
if (!hero || !monster) return null;
// Check Phase
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
// Check Adjacency
const dx = Math.abs(hero.x - monster.x);
const dy = Math.abs(hero.y - monster.y);
if (dx + dy !== 1) return { success: false, reason: 'range' };
// Check Action Economy
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
// Execute Attack
const result = CombatMechanics.resolveMeleeAttack(hero, monster, this);
hero.hasAttacked = true;
if (this.onCombatResult) this.onCombatResult(result);
return { success: true, result };
}
isEntityPinned(entity) {
if (!this.monsters || this.monsters.length === 0) return false;
return this.monsters.some(m => {
if (m.isDead) return false;
const dx = Math.abs(entity.x - m.x);
const dy = Math.abs(entity.y - m.y);
// 1. Must be Adjacent (Manhattan distance 1)
if (dx + dy !== 1) return false;
// 2. Check Logical Connectivity (Wall check)
const grid = this.dungeon.grid;
const key1 = `${entity.x},${entity.y}`;
const key2 = `${m.x},${m.y}`;
const data1 = grid.cellData.get(key1);
const data2 = grid.cellData.get(key2);
if (!data1 || !data2) return false;
// Same Tile -> Connected
if (data1.tileId === data2.tileId) return true;
// Different Tile -> Must be connected by a Door
const isDoor1 = grid.doorCells.has(key1);
const isDoor2 = grid.doorCells.has(key2);
if (!isDoor1 && !isDoor2) return false;
return true;
});
}
performRangedAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
if (!hero || !monster) return null;
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' };
// LOS Check should be done before calling this, but we can double check or assume UI did it.
// For simplicity, we execute the attack here assuming validation passed.
const result = CombatMechanics.resolveRangedAttack(hero, monster, this);
hero.hasAttacked = true;
if (this.onCombatResult) this.onCombatResult(result);
return { success: true, result };
}
deselectEntity() {
if (!this.selectedEntity) return;
const id = this.selectedEntity.id;
@@ -166,6 +321,13 @@ export class GameEngine {
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
@@ -176,6 +338,14 @@ export class GameEngine {
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]
@@ -225,28 +395,90 @@ export class GameEngine {
executeMovePath() {
if (!this.selectedEntity || !this.plannedPath.length) return;
const path = [...this.plannedPath];
const fullPath = [...this.plannedPath];
const entity = this.selectedEntity;
// Update verify immediately
const finalDest = path[path.length - 1];
entity.x = finalDest.x;
entity.y = finalDest.y;
let stepsTaken = 0;
let triggeredEvents = false;
// Visual animation
if (this.onEntityMove) {
this.onEntityMove(entity, path);
// 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) {
entity.currentMoves -= path.length;
// 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);
@@ -259,45 +491,17 @@ export class GameEngine {
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
}
// Check if the Leader (Lamp Bearer) is adjacent to the door
isLeaderAdjacentToDoor(doorCells) {
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;
}
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];
}
// Deprecated generic adjacency (kept for safety or other interactions)
isPlayerAdjacentToDoor(doorCells) {
return this.isLeaderAdjacentToDoor(doorCells);
}
update(time) {
// Minimal update loop
}
findSpawnPoints(count) {
const points = [];
const queue = [{ x: 1, y: 1 }]; // Start search near origin but ensure not 0,0 which might be tricky if it's door
// Actually, just scan the grid or BFS from center of first tile?
// First tile is placed at 0,0. Let's scan from 0,0.
const startNode = { x: 0, y: 0 };
const searchQueue = [startNode];
const visited = new Set(['0,0']);
@@ -330,4 +534,323 @@ export class GameEngine {
return points;
}
onRoomRevealed(cells) {
console.log("[GameEngine] Room Revealed!");
// 1. Draw Event Card
if (this.eventDeck.length === 0) {
console.warn("Event deck empty, reshaping...");
this.eventDeck = createEventDeck();
}
const card = this.eventDeck.pop();
console.log(`[GameEngine] Event Drawn: ${card.name}`);
if (card.type === EVENT_TYPES.MONSTER) {
// 2. Determine Count
let count = 0;
if (typeof card.resolve === 'function') {
count = card.resolve(this, { cells });
} else {
count = 1; // Fallback
}
console.log(`[GameEngine] Spawning ${count} ${card.monsterKey}s`);
// 3. Find valid spawn spots
const availableCells = cells.filter(cell => {
const isHero = this.heroes.some(h => h.x === cell.x && h.y === cell.y);
const isMonster = this.monsters.some(m => m.x === cell.x && m.y === cell.y);
return !isHero && !isMonster;
});
console.log(`[GameEngine] Available Spawn Cells: ${availableCells.length}`, availableCells);
// Shuffle
for (let i = availableCells.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableCells[i], availableCells[j]] = [availableCells[j], availableCells[i]];
}
// 4. Spawn
let spawnedCount = 0;
for (let i = 0; i < count; i++) {
if (i < availableCells.length) {
const pos = availableCells[i];
console.log(`[GameEngine] Spawning at ${pos.x},${pos.y}`);
// Monster Spawn (Step-and-Stop rule)
// Monsters act in the upcoming Monster Phase.
this.spawnMonster(card.monsterKey, pos.x, pos.y);
spawnedCount++;
} else {
console.warn("[GameEngine] Not enough space!");
break;
}
}
return {
type: 'MONSTER_SPAWN',
monsterKey: card.monsterKey,
count: spawnedCount,
cardName: card.name
};
}
// Return event info even if empty, so movement stops
return {
type: 'EVENT',
cardName: card.name,
message: 'La sala parece despejada.'
};
}
// =========================================
// MONSTER AI & TURN LOGIC
// =========================================
async playMonsterTurn() {
if (this.ai) {
await this.ai.executeTurn();
}
}
// AI Helper methods moved to MonsterAI.js
isLeaderAdjacentToDoor(doorCells) {
// ... (Keep this one as it's used by main.js logic for doors)
if (!this.heroes || this.heroes.length === 0) return false;
const leader = this.getLeader();
if (!leader) return false;
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
for (const cell of cells) {
const dx = Math.abs(leader.x - cell.x);
const dy = Math.abs(leader.y - cell.y);
// Orthogonal adjacency check (Manhattan distance === 1)
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
return true;
}
}
return false;
}
startRangedTargeting() {
this.targetingMode = 'ranged';
console.log("Ranged Targeting Mode ON");
}
cancelTargeting() {
this.targetingMode = null;
if (this.onRangedTarget) {
this.onRangedTarget(null, null);
}
}
checkLineOfSight(hero, target) {
// Robust Grid Traversal (Amanatides & Woo)
const x = hero.x + 0.5;
const y = hero.y + 0.5;
const endX = target.x + 0.5;
const endY = target.y + 0.5;
const dx = endX - x;
const dy = endY - y;
let currentX = Math.floor(x);
let currentY = Math.floor(y);
const targetX = Math.floor(endX);
const targetY = Math.floor(endY);
const stepX = Math.sign(dx);
const stepY = Math.sign(dy);
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
let tMaxX = stepX > 0 ? (Math.floor(x) + 1 - x) * tDeltaX : (x - Math.floor(x)) * tDeltaX;
let tMaxY = stepY > 0 ? (Math.floor(y) + 1 - y) * tDeltaY : (y - Math.floor(y)) * tDeltaY;
if (isNaN(tMaxX)) tMaxX = Infinity;
if (isNaN(tMaxY)) tMaxY = Infinity;
const path = [];
let blocked = false;
// Safety limit
const maxSteps = Math.abs(targetX - currentX) + Math.abs(targetY - currentY) + 20;
for (let i = 0; i < maxSteps; i++) {
path.push({ x: currentX, y: currentY });
if (!(currentX === hero.x && currentY === hero.y) && !(currentX === target.x && currentY === target.y)) {
if (this.dungeon.grid.isWall(currentX, currentY)) {
blocked = true;
break;
}
const blockerMonster = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
if (blockerMonster) { blocked = true; break; }
const blockerHero = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
if (blockerHero) { blocked = true; break; }
}
if (currentX === targetX && currentY === targetY) {
break;
}
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
currentX += stepX;
} else {
tMaxY += tDeltaY;
currentY += stepY;
}
}
return { clear: !blocked, path: path };
}
checkLineOfSightPermissive(hero, target) {
const startX = hero.x + 0.5;
const startY = hero.y + 0.5;
const endX = target.x + 0.5;
const endY = target.y + 0.5;
const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
const steps = Math.ceil(dist * 5);
const path = [];
const visited = new Set();
let blocked = false;
let blocker = null;
const MARGIN = 0.2;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = startX + (endX - startX) * t;
const y = startY + (endY - startY) * t;
const gx = Math.floor(x);
const gy = Math.floor(y);
const key = `${gx},${gy}`;
if (!visited.has(key)) {
path.push({ x: gx, y: gy, xWorld: x, yWorld: y });
visited.add(key);
}
if ((gx === hero.x && gy === hero.y) || (gx === target.x && gy === target.y)) {
continue;
}
if (this.dungeon.grid.isWall(gx, gy)) {
const lx = x - gx;
const ly = y - gy;
if (lx > MARGIN && lx < (1 - MARGIN) && ly > MARGIN && ly < (1 - MARGIN)) {
blocked = true;
blocker = { type: 'wall', x: gx, y: gy };
console.log(`[LOS] Blocked by WALL at ${gx},${gy}`);
break;
}
}
const blockerMonster = this.monsters.find(m => m.x === gx && m.y === gy && !m.isDead && m.id !== target.id);
if (blockerMonster) {
blocked = true;
blocker = { type: 'monster', entity: blockerMonster };
console.log(`[LOS] Blocked by MONSTER: ${blockerMonster.name}`);
break;
}
const blockerHero = this.heroes.find(h => h.x === gx && h.y === gy && h.id !== hero.id);
if (blockerHero) {
blocked = true;
blocker = { type: 'hero', entity: blockerHero };
console.log(`[LOS] Blocked by HERO: ${blockerHero.name}`);
break;
}
}
return { clear: !blocked, path: path, blocker: blocker };
}
checkLineOfSightStrict(hero, target) {
// STRICT Grid Traversal (Amanatides & Woo)
const x1 = hero.x + 0.5;
const y1 = hero.y + 0.5;
const x2 = target.x + 0.5;
const y2 = target.y + 0.5;
let currentX = Math.floor(x1);
let currentY = Math.floor(y1);
const endX = Math.floor(x2);
const endY = Math.floor(y2);
const dx = x2 - x1;
const dy = y2 - y1;
const stepX = Math.sign(dx);
const stepY = Math.sign(dy);
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
let tMaxX = stepX > 0 ? (Math.floor(x1) + 1 - x1) * tDeltaX : (x1 - Math.floor(x1)) * tDeltaX;
let tMaxY = stepY > 0 ? (Math.floor(y1) + 1 - y1) * tDeltaY : (y1 - Math.floor(y1)) * tDeltaY;
if (isNaN(tMaxX)) tMaxX = Infinity;
if (isNaN(tMaxY)) tMaxY = Infinity;
const path = [];
let blocked = false;
let blocker = null;
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
for (let i = 0; i < maxSteps; i++) {
path.push({ x: currentX, y: currentY });
const isStart = (currentX === hero.x && currentY === hero.y);
const isEnd = (currentX === target.x && currentY === target.y);
if (!isStart && !isEnd) {
if (this.dungeon.grid.isWall(currentX, currentY)) {
blocked = true;
blocker = { type: 'wall', x: currentX, y: currentY };
console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`);
break;
}
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
if (m) {
blocked = true;
blocker = { type: 'monster', entity: m };
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
break;
}
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
if (h) {
blocked = true;
blocker = { type: 'hero', entity: h };
console.log(`[LOS] Blocked by HERO: ${h.name}`);
break;
}
}
if (currentX === endX && currentY === endY) break;
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
currentX += stepX;
} else {
tMaxY += tDeltaY;
currentY += stepY;
}
}
return { clear: !blocked, path, blocker };
}
}

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

@@ -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);
};
@@ -51,10 +66,79 @@ game.onEntityUpdate = (entity) => {
}
};
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.onEntityActive = (entityId, isActive) => {
renderer.setEntityActive(entityId, isActive);
};
game.onEntityHit = (entityId) => {
renderer.triggerDamageEffect(entityId);
};
game.onEntityDeath = (entityId) => {
renderer.triggerDeathAnimation(entityId);
};
game.onRangedTarget = (targetMonster, losResult) => {
// 1. Draw Visuals
renderer.showRangedTargeting(game.selectedEntity, targetMonster, losResult);
// 2. UI
if (targetMonster && losResult && losResult.clear) {
ui.showRangedAttackUI(targetMonster);
} else {
ui.hideMonsterCard();
if (targetMonster && losResult && !losResult.clear && losResult.blocker) {
let msg = 'Línea de visión bloqueada.';
if (losResult.blocker.type === 'hero') msg = `Bloqueado por aliado: ${losResult.blocker.entity.name}`;
if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`;
if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`;
ui.showTemporaryMessage('Objetivo Bloqueado', msg, 1500);
}
}
};
game.onShowMessage = (title, message, duration) => {
ui.showTemporaryMessage(title, message, duration);
};
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
renderer.onHeroFinishedMove = (x, y) => {
@@ -91,9 +175,11 @@ game.onPathChange = (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;
}
@@ -106,6 +192,18 @@ const handleClick = (x, y, doorMesh) => {
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;
@@ -120,8 +218,6 @@ const handleClick = (x, y, doorMesh) => {
}
// 2. Check Adjacency
// Since we know selectedHero IS the leader, we can just check if *this* hero is adjacent.
// game.isLeaderAdjacentToDoor checks the 'getLeader()' position, which aligns with selectedHero here.
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
// Open door visually
@@ -131,11 +227,6 @@ const handleClick = (x, y, doorMesh) => {
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
// Allow UI to update phase if not already
// if (game.turnManager.currentPhase !== 'exploration') {
// game.turnManager.setPhase('exploration');
// }
} else {
console.error('[Main] Door missing exitData');
}
@@ -148,6 +239,17 @@ const handleClick = (x, y, doorMesh) => {
// 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);
}
};
@@ -175,10 +277,31 @@ window.addEventListener('keydown', (e) => {
}
});
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

@@ -123,7 +123,7 @@ export class CameraManager {
// 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 moveX = dx * moveSpeed;
const moveY = dy * moveSpeed;
// Apply to Camera (Local Space)

View File

@@ -44,6 +44,9 @@ export class GameRenderer {
this.highlightGroup = new THREE.Group();
this.scene.add(this.highlightGroup);
this.rangedGroup = new THREE.Group();
this.scene.add(this.rangedGroup);
this.entities = new Map();
}
@@ -77,8 +80,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;
}
@@ -216,6 +220,105 @@ export class GameRenderer {
}
}
setEntityActive(entityId, isActive) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
// Remove existing active ring if any
const oldRing = mesh.getObjectByName("ActiveRing");
if (oldRing) mesh.remove(oldRing);
if (isActive) {
// Phosphorescent Green Ring - MATCHING SIZE (0.3 - 0.4)
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
// Basic Material does not support emissive. Use color + opacity for "glow" feel.
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00ff00, // Green
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
// Align with floor (relative to mesh center)
const h = 1.56;
ring.position.y = -h / 2 + 0.05;
ring.name = "ActiveRing";
mesh.add(ring);
}
}
triggerDamageEffect(entityId) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
// 1. Red Halo (Temporary) - MATCHING ATTACKER SIZE (0.3 - 0.4)
const hitRingGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const hitRingMat = new THREE.MeshBasicMaterial({
color: 0xff0000,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.9
});
const hitRing = new THREE.Mesh(hitRingGeom, hitRingMat);
hitRing.rotation.x = -Math.PI / 2;
// Align with floor
const h = 1.56;
hitRing.position.y = -h / 2 + 0.05;
hitRing.name = "HitRing";
mesh.add(hitRing);
// Remove Red Halo after 1200ms (matching the timing in MonsterAI)
setTimeout(() => {
if (mesh && hitRing) mesh.remove(hitRing);
}, 1200);
// 2. Shake Animation (800ms)
const originalPos = mesh.position.clone();
const startTime = performance.now();
const duration = 800; // ms
mesh.userData.shake = {
startTime: startTime,
duration: duration,
magnitude: 0.1,
originalPos: originalPos
};
}
triggerDeathAnimation(entityId) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
console.log(`[GameRenderer] Triggering death animation for ${entityId}`);
// Start fade-out animation
const startTime = performance.now();
const duration = 1500; // 1.5 seconds fade out
mesh.userData.death = {
startTime: startTime,
duration: duration,
initialOpacity: 1.0
};
// Remove entity from map after animation completes
setTimeout(() => {
if (mesh && mesh.parent) {
mesh.parent.remove(mesh);
}
this.entities.delete(entityId);
console.log(`[GameRenderer] Removed entity ${entityId} from scene`);
}, duration);
}
moveEntityAlongPath(entity, path) {
const mesh = this.entities.get(entity.id);
if (mesh) {
@@ -248,7 +351,7 @@ export class GameRenderer {
}
if (data.isMoving) {
const duration = 300; // Faster jump (300ms)
const duration = 300; // Hero movement speed (300ms per tile)
const elapsed = time - data.startTime;
const progress = Math.min(elapsed / duration, 1);
@@ -277,6 +380,57 @@ export class GameRenderer {
}
}
}
} 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;
}
}
@@ -467,33 +621,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] [Checked] ✗ 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) {
@@ -885,4 +1058,76 @@ export class GameRenderer {
}
}
clearRangedTargeting() {
if (this.rangedGroup) {
while (this.rangedGroup.children.length > 0) {
const child = this.rangedGroup.children[0];
this.rangedGroup.remove(child);
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
else child.material.dispose();
}
}
}
}
showRangedTargeting(hero, monster, losResult) {
this.clearRangedTargeting();
if (!hero || !monster || !losResult) return;
// 1. Orange Fluorescence Ring on Monster
const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: 0xFFA500,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.set(monster.x, 0.05, -monster.y);
this.rangedGroup.add(ring);
// 2. Dashed Line logic (Center to Center at approx waist height)
const points = [];
points.push(new THREE.Vector3(hero.x, 0.8, -hero.y));
points.push(new THREE.Vector3(monster.x, 0.8, -monster.y));
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
const lineMat = new THREE.LineDashedMaterial({
color: losResult.clear ? 0x00FF00 : 0xFF0000,
dashSize: 0.2,
gapSize: 0.1,
});
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
this.rangedGroup.add(line);
// 3. Blocker Visualization (Red Ring)
if (!losResult.clear && losResult.blocker) {
const b = losResult.blocker;
// If blocker is Entity (Hero/Monster), show bright red ring
if (b.type === 'hero' || b.type === 'monster') {
const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32);
const blockRingMat = new THREE.MeshBasicMaterial({
color: 0xFF0000,
side: THREE.DoubleSide,
transparent: true,
opacity: 1.0,
depthTest: false // Always visible on top
});
const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat);
blockRing.rotation.x = -Math.PI / 2;
const bx = b.entity ? b.entity.x : b.x;
const by = b.entity ? b.entity.y : b.y;
blockRing.position.set(bx, 0.1, -by);
this.rangedGroup.add(blockRing);
}
// Walls are implicit (Line just turns red and stops/passes through)
}
}
}

View File

@@ -8,6 +8,7 @@ export class UIManager {
this.selectedHero = null;
this.createHUD();
this.createHeroCardsPanel(); // NEW: Hero stat cards
this.createGameStatusPanel(); // New Panel
this.setupMinimapLoop();
this.setupGameListeners(); // New Listeners
@@ -25,11 +26,28 @@ export class UIManager {
// 2. Update UI
if (isSelected) {
const hero = this.game.heroes.find(h => h.id === id);
this.selectedHero = hero; // Store state
this.updateHeroStats(hero);
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 {
this.selectedHero = null;
this.updateHeroStats(null);
// 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();
}
}
};
@@ -37,6 +55,10 @@ export class UIManager {
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);
}
};
}
@@ -303,6 +325,482 @@ export class UIManager {
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);
// Ranged Attack Button (Elf Only)
if (hero.key === 'elf') {
const isPinned = this.game.isEntityPinned(hero);
const hasAttacked = hero.hasAttacked;
const bowBtn = document.createElement('button');
bowBtn.textContent = hasAttacked ? '🏹 YA DISPARADO' : '🏹 DISPARAR ARCO';
bowBtn.style.width = '100%';
bowBtn.style.padding = '8px';
bowBtn.style.marginTop = '8px';
const isDisabled = isPinned || hasAttacked;
bowBtn.style.backgroundColor = isDisabled ? '#555' : '#2E8B57'; // SeaGreen
bowBtn.style.color = '#fff';
bowBtn.style.border = '1px solid #fff';
bowBtn.style.borderRadius = '4px';
bowBtn.style.fontFamily = '"Cinzel", serif';
bowBtn.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
if (isPinned) {
bowBtn.title = "¡Estás trabado en combate cuerpo a cuerpo!";
} else if (hasAttacked) {
bowBtn.title = "Ya has atacado en esta fase.";
} else {
bowBtn.onclick = (e) => {
e.stopPropagation(); // Prevent card click propagation if any
this.game.startRangedTargeting();
// Provide immediate feedback?
this.showModal('Modo Disparo', 'Selecciona un enemigo visible para disparar.');
};
}
card.appendChild(bowBtn);
}
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);
}
showRangedAttackUI(monster) {
this.showMonsterCard(monster);
if (this.attackButton) {
this.attackButton.textContent = '🏹 DISPARAR';
this.attackButton.style.backgroundColor = '#2E8B57';
this.attackButton.style.border = '2px solid #32CD32';
this.attackButton.onclick = () => {
const result = this.game.performRangedAttack(monster.id);
if (result && result.success) {
this.game.cancelTargeting();
this.hideMonsterCard(); // Hide UI
// Also clear renderer
this.game.deselectEntity(); // Deselect hero too? "desparecerá todo".
// Let's interpret "desaparecerá todo" as targeting visuals and Shoot button.
// But usually in game we keep hero selected.
// If we deselect everything:
// this.game.deselectEntity();
// Let's keep hero selected for flow, but clear targeting.
}
};
this.attackButton.onmouseenter = () => {
this.attackButton.style.backgroundColor = '#3CB371';
this.attackButton.style.transform = 'scale(1.05)';
};
this.attackButton.onmouseleave = () => {
this.attackButton.style.backgroundColor = '#2E8B57';
this.attackButton.style.transform = 'scale(1)';
};
}
}
hideMonsterCard() {
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) {
this.cardsContainer.removeChild(this.currentMonsterCard);
this.currentMonsterCard = null;
}
if (this.attackButton && this.attackButton.parentNode) {
this.cardsContainer.removeChild(this.attackButton);
this.attackButton = null;
}
}
showPlacementControls(show) {
if (this.placementPanel) {
this.placementPanel.style.display = show ? 'block' : 'none';
@@ -390,7 +888,7 @@ export class UIManager {
ctx.stroke();
}
showModal(title, message) {
showModal(title, message, onClose) {
// Overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
@@ -442,6 +940,7 @@ export class UIManager {
btn.style.border = '1px solid #888';
btn.onclick = () => {
this.container.removeChild(overlay);
if (onClose) onClose();
};
content.appendChild(btn);
@@ -449,6 +948,47 @@ export class UIManager {
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');
@@ -552,7 +1092,7 @@ export class UIManager {
this.phaseInfo.style.fontSize = '20px';
this.phaseInfo.style.textAlign = 'center';
this.phaseInfo.style.textTransform = 'uppercase';
this.phaseInfo.style.minWidth = '200px';
this.phaseInfo.style.width = '300px'; // Match button width
this.phaseInfo.innerHTML = `
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
<div style="font-size: 24px; color: #daa520;">Setup</div>
@@ -564,7 +1104,7 @@ export class UIManager {
this.endPhaseBtn = document.createElement('button');
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.style.marginTop = '10px';
this.endPhaseBtn.style.width = '100%';
this.endPhaseBtn.style.width = '300px'; // Fixed width to prevent resizing with messages
this.endPhaseBtn.style.padding = '8px';
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
this.endPhaseBtn.style.color = '#000';
@@ -589,6 +1129,7 @@ export class UIManager {
// Notification Area (Power Roll results, etc)
this.notificationArea = document.createElement('div');
this.notificationArea.style.marginTop = '10px';
this.notificationArea.style.maxWidth = '600px'; // Prevent very wide messages
this.notificationArea.style.transition = 'opacity 0.5s';
this.notificationArea.style.opacity = '0';
this.statusPanel.appendChild(this.notificationArea);
@@ -637,10 +1178,6 @@ export class UIManager {
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';
@@ -710,4 +1247,43 @@ export class UIManager {
if (this.notificationArea) this.notificationArea.style.opacity = '0';
}, 3000);
}
showTemporaryMessage(title, message, duration = 2000) {
const modal = document.createElement('div');
Object.assign(modal.style, {
position: 'absolute',
top: '25%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'rgba(139, 0, 0, 0.9)',
color: '#fff',
padding: '15px 30px',
borderRadius: '8px',
border: '2px solid #ff4444',
fontFamily: '"Cinzel", serif',
fontSize: '20px',
textShadow: '2px 2px 4px black',
zIndex: '2000',
pointerEvents: 'none',
opacity: '0',
transition: 'opacity 0.5s ease-in-out'
});
modal.innerHTML = `
<h3 style="margin:0; text-align:center; color: #FFD700; text-transform: uppercase;">⚠️ ${title}</h3>
<div style="margin-top:5px; font-size: 16px;">${message}</div>
`;
document.body.appendChild(modal);
// Fade In
requestAnimationFrame(() => { modal.style.opacity = '1'; });
// Fade Out and Remove
setTimeout(() => {
modal.style.opacity = '0';
setTimeout(() => {
if (modal.parentNode) document.body.removeChild(modal);
}, 500);
}, duration);
}
}

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