Sesión 15: Implementación de Inventario y lógica de evento de Rastrillo/Llave

This commit is contained in:
2026-01-10 17:41:32 +01:00
parent 82bdcacf95
commit 83882b25ba
23 changed files with 588 additions and 273 deletions

View File

@@ -1,5 +1,35 @@
# Devlog - Warhammer Quest (Versión Web 3D) # Devlog - Warhammer Quest (Versión Web 3D)
## Sesión 15: Evento del Rastrillo, Llave del Enano e Inventario
**Fecha:** 10 de Enero de 2026
### Objetivos
- Implementar el evento del "Rastrillo" (bloqueo de entrada por portcullis).
- Crear un sistema de inventario funcional para todos los héroes.
- Vincular la adquisición de la "Llave del Rastrillo" con la capacidad de abrir la puerta bloqueada.
### Cambios Realizados
#### 1. Sistema de Inventario
- **Propiedad Universal**: Se ha añadido un array `inventory` a nivel de objeto para todos los héroes en su inicialización.
- **Interfaz Visual (`InventoryUI.js`)**: Nueva interfaz estilo "mochila" que muestra los items recolectados por cada héroe.
- **Integración en UI**: Habilitado el botón "🎒 INVENTARIO" en las fichas de unidad para desplegar el contenido de la mochila.
- **Gestión de Items**: El `EventInterpreter` ahora soporta la acción `EFECTO (tipo: item)`, permitiendo que los eventos entreguen objetos físicos a los aventureros.
#### 2. Evento del Rastrillo (Portcullis)
- **Bloqueo Inteligente**: El `GameEngine` ahora rastrea la última entrada utilizada (`lastEntranceUsed`).
- **Nuevas Acciones de Entorno**: Implementado el subtipo `bloquear_entrada_rastrillo` en `EventInterpreter`.
- **Detección de Llave**: Al intentar abrir una puerta bloqueada por un rastrillo, el juego verifica si algún héroe del grupo posee la `llave_rastrillo`.
- **Textura de Rastrillo**: Se añadió soporte para `door1_portcullis.png` en `DungeonRenderer.js`.
- **Safeguards de Renderizado**: Se añadieron protecciones en el renderizador de puertas para evitar que la animación de "abrir puerta" sobreescriba visualmente el estado de "puerta bloqueada" o "portcullis".
### Estado Actual
El sistema de inventario y la lógica de la llave funcionan correctamente. El evento del Enano Moribundo entrega la llave al grupo, y esta permite levantar el rastrillo.
**Pendiente**: Se ha detectado un problema visual donde la textura de `door1_portcullis.png` no parece aplicarse correctamente en algunos casos, a pesar de que la lógica de bloqueo funciona. Se requiere una revisión más profunda del sistema de materiales de Three.js para este caso.
---
## Sesión 14: Estabilización del Flujo de Juego y Bugfix Crítico ## Sesión 14: Estabilización del Flujo de Juego y Bugfix Crítico
**Fecha:** 10 de Enero de 2026 **Fecha:** 10 de Enero de 2026

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

View File

@@ -29,7 +29,7 @@ export const EVENT_CARDS_DATA = [
"titulo": "CADÁVER", "titulo": "CADÁVER",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "codigo_tipo": "E",
"descripcion": "Un Bárbaro muerto sostiene una bolsa de cuero.", "descripcion": "Encontráis un cadáver que sostiene una bolsa de cuero. ¿Contendrá algún tesoro o será una trampa?",
"cita_fuente": "[cite: 6, 7, 11, 15, 16]", "cita_fuente": "[cite: 6, 7, 11, 15, 16]",
"acciones": [ "acciones": [
{ {
@@ -37,7 +37,7 @@ export const EVENT_CARDS_DATA = [
"modo": "tirada_baja", "modo": "tirada_baja",
"dado": "1D6", "dado": "1D6",
"guardar_como": "victima", "guardar_como": "victima",
"mensaje": "Cada jugador tira 1D6. El más bajo investiga el cadáver." "mensaje": "se acerca con cautela para investigar el cadáver..."
}, },
{ {
"tipo_accion": "PRUEBA", "tipo_accion": "PRUEBA",
@@ -45,15 +45,13 @@ export const EVENT_CARDS_DATA = [
"tipo_prueba": "1D6", "tipo_prueba": "1D6",
"tabla": { "tabla": {
"1": [ "1": [
{ "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "1D6", "mensaje": "¡Gas venenoso!" }, { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "1D6", "mensaje": "¡Gas venenoso! La bolsa estaba vacía." }
{ "tipo_accion": "MENSAJE", "texto": "La bolsa estaba vacía." }
], ],
"2-3": [ "2-3": [
{ "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "2D6", "mensaje": "¡Trampa de lanza!" }, { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "2D6", "mensaje": "¡Trampa de lanza! La bolsa estaba vacía." }
{ "tipo_accion": "MENSAJE", "texto": "La bolsa estaba vacía." }
], ],
"4-6": [ "4-6": [
{ "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "oro", "cantidad": "1D6*100", "mensaje": "¡Encuentras oro!" } { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "oro", "cantidad": "1D6*100", "mensaje": "¡Encuentras monedas de oro en la bolsa!" }
] ]
} }
}, },
@@ -68,32 +66,32 @@ export const EVENT_CARDS_DATA = [
"titulo": "VIEJOS HUESOS", "titulo": "VIEJOS HUESOS",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "codigo_tipo": "E",
"descripcion": "Suelo cubierto de huesos y cráneos con brillo de monedas bajo ellos.", "descripcion": "El suelo está cubierto de huesos y cráneos blanquecinos. Entre ellos, el brillo inconfundible del oro tienta a vuestra suerte...",
"cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]", "cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]",
"acciones": [ "acciones": [
{ {
"tipo_accion": "PRUEBA", "tipo_accion": "PRUEBA",
"tipo_prueba": "1D6_GLOBAL", "tipo_prueba": "1D6",
"mensaje": "Tira 1D6 para ver qué esconden los huesos.", "mensaje": "Alguien remueve los huesos con la punta de su bota...",
"tabla": { "tabla": {
"1": [ "1": [
{ "tipo_accion": "MENSAJE", "texto": "Ilusión. Los huesos y el oro desaparecen." }, { "tipo_accion": "MENSAJE", "texto": "¡Era una ilusión! Los huesos y el brillo del oro se desvanecen en el aire." },
{ "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 } { "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
], ],
"2-3": [ "2-3": [
{ "tipo_accion": "SELECCION", "modo": "azar", "guardar_como": "victima" }, { "tipo_accion": "SELECCION", "modo": "azar", "guardar_como": "victima", "mensaje": "activan un mecanismo de defensa!" },
{ "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "1D6", "mensaje": Rayo mágico!" }, { "tipo_accion": "EFECTO", "objetivo": "victima", "tipo": "daño", "cantidad": "1D6", "mensaje": Un rayo mágico brota de una calavera!" },
{ "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 } { "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
], ],
"4-5": [ "4-5": [
{ "tipo_accion": "SELECCION", "modo": "todos" }, { "tipo_accion": "SELECCION", "modo": "todos", "mensaje": "se reparten las monedas encontradas." },
{ "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "1D6*10", "mensaje": "Cada aventurero encuentra monedas." }, { "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "1D6*10", "mensaje": "Había algo de oro entre los restos." },
{ "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 } { "tipo_accion": "TRIGGER_EVENTO", "cantidad": 1 }
], ],
"6": [ "6": [
{ "tipo_accion": "SELECCION", "modo": "todos" }, { "tipo_accion": "SELECCION", "modo": "todos", "mensaje": "¡están de suerte!" },
{ "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "2D6*10", "mensaje": "¡Un gran hallazgo de oro!" }, { "tipo_accion": "EFECTO", "tipo": "oro", "cantidad": "2D6*10", "mensaje": "¡Un gran montón de oro estaba oculto bajo los cráneos!" },
{ "tipo_accion": "EFECTO", "tipo": "carta_tesoro", "cantidad": 1, "mensaje": "Robas una Carta de Tesoro." } { "tipo_accion": "EFECTO", "tipo": "carta_tesoro", "cantidad": 1, "mensaje": "¡Y además encontráis un objeto valioso!" }
] ]
} }
} }
@@ -104,7 +102,7 @@ export const EVENT_CARDS_DATA = [
"titulo": "TRAMPA", "titulo": "TRAMPA",
"tipo": "Evento", "tipo": "Evento",
"codigo_tipo": "E", "codigo_tipo": "E",
"descripcion": "El Aventurero con el resultado menor activa una trampa.", "descripcion": "¡Click! De repente todos oís un ruido metálico bajo vuestros pies... ¡Alguien ha activado una trampa!",
"cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]", "cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]",
"acciones": [ "acciones": [
{ {
@@ -112,7 +110,7 @@ export const EVENT_CARDS_DATA = [
"modo": "tirada_baja", "modo": "tirada_baja",
"dado": "1D6", "dado": "1D6",
"guardar_como": "victima", "guardar_como": "victima",
"mensaje": "¡Click! Alguien ha pisado algo..." "mensaje": "ha pisado una trampa!"
}, },
{ {
"tipo_accion": "PRUEBA", "tipo_accion": "PRUEBA",
@@ -139,6 +137,33 @@ export const EVENT_CARDS_DATA = [
} }
] ]
}, },
{
"id": "evt_enano_moribundo",
"titulo": "ENCUENTRO",
"tipo": "Evento",
"codigo_tipo": "E",
"descripcion": "Al desplomarse una pared, encontráis a un Enano Minero moribundo acribillado por flechas. Antes de morir, os entrega una llave importante.",
"cita_fuente": "\"Esta es la llave que abrirá el rastrillo. Sin ella jamás seréis capaces de atravesarlo.\"",
"acciones": [
{
"tipo_accion": "SELECCION",
"modo": "azar",
"guardar_como": "portador",
"mensaje": "se acerca al enano y recibe la llave de sus manos trémulas."
},
{
"tipo_accion": "EFECTO",
"objetivo": "portador",
"tipo": "item",
"id_item": "llave_rastrillo",
"mensaje": "¡Has conseguido la Llave del Rastrillo!"
},
{
"tipo_accion": "TRIGGER_EVENTO",
"cantidad": 1
}
]
},
{ {
"id": "evt_rastrillo", "id": "evt_rastrillo",
"titulo": "RASTRILLO", "titulo": "RASTRILLO",
@@ -149,13 +174,7 @@ export const EVENT_CARDS_DATA = [
"acciones": [ "acciones": [
{ {
"tipo_accion": "ENTORNO", "tipo_accion": "ENTORNO",
"subtipo": "bloquear_salida_entrada" "subtipo": "bloquear_entrada_rastrillo"
},
{
"tipo_accion": "ENTORNO",
"subtipo": "colocar_marcador",
"marcador": "rastrillo",
"posicion": "entrada"
}, },
{ {
"tipo_accion": "TRIGGER_EVENTO", "tipo_accion": "TRIGGER_EVENTO",
@@ -196,10 +215,7 @@ export const EVENT_CARDS_DATA = [
"tipo_accion": "SPAWN", "tipo_accion": "SPAWN",
"id_monstruo": "minotaur", // Debe coincidir con MONSTER_DEFINITIONS key o similar "id_monstruo": "minotaur", // Debe coincidir con MONSTER_DEFINITIONS key o similar
"nombre_fallback": "Minotauro", // Por si no existe id "nombre_fallback": "Minotauro", // Por si no existe id
"cantidad": "1", "cantidad": "1"
"stats": { // Opcional, si queremos sobreescribir o definir aquí
"wounds": 15, "move": 6, "ws": 4, "str": 4, "toughness": 4, "attacks": 2, "gold": 440
}
}, },
{ {
"tipo_accion": "TRIGGER_EVENTO", "tipo_accion": "TRIGGER_EVENTO",
@@ -218,10 +234,7 @@ export const EVENT_CARDS_DATA = [
"tipo_accion": "SPAWN", "tipo_accion": "SPAWN",
"id_monstruo": "chaos_warrior", // Debe coincidir con MONSTER_DEFINITIONS key o similar "id_monstruo": "chaos_warrior", // Debe coincidir con MONSTER_DEFINITIONS key o similar
"nombre_fallback": "Guerrero de Caos", // Por si no existe id "nombre_fallback": "Guerrero de Caos", // Por si no existe id
"cantidad": "1D6-2", "cantidad": "2"
"stats": { // Opcional, si queremos sobreescribir o definir aquí
"wounds": 10, "move": 4, "ws": 4, "str": 4, "toughness": 4, "attacks": 2, "gold": 240
}
}, },
{ {
"tipo_accion": "TRIGGER_EVENTO", "tipo_accion": "TRIGGER_EVENTO",
@@ -240,11 +253,7 @@ export const EVENT_CARDS_DATA = [
"tipo_accion": "SPAWN", "tipo_accion": "SPAWN",
"id_monstruo": "giant_spider", "id_monstruo": "giant_spider",
"nombre_fallback": "Araña Gigante", "nombre_fallback": "Araña Gigante",
"cantidad": "2D6", "cantidad": "2D6"
"stats": {
"wounds": 1, "move": 6, "ws": 2, "str": 3, "toughness": 2, "attacks": 1
},
"reglas_especiales": ["telarana"]
} }
] ]
} }

View File

@@ -17,13 +17,28 @@ const shuffleDeck = (deck) => {
[deck[i], deck[j]] = [deck[j], deck[i]]; [deck[i], deck[j]] = [deck[j], deck[i]];
} }
// DEBUG: Move Derrumbamiento to TOP // DEBUG: Force ENANO and RASTRILLO to TOP
/* const enanoIdx = deck.findIndex(c => c.id === 'evt_enano_moribundo');
const cardIdx = deck.findIndex(c => c.id === 'evt_derrumbamiento'); const rastrilloIdx = deck.findIndex(c => c.id === 'evt_rastrillo');
if (cardIdx !== -1) {
const card = deck.splice(cardIdx, 1)[0]; // Reverse order for unshift
if (rastrilloIdx !== -1) {
const card = deck.splice(rastrilloIdx, 1)[0];
deck.unshift(card); deck.unshift(card);
console.log("DEBUG: Forced DERRUMBAMIENTO to top of deck."); }
if (enanoIdx !== -1) {
const card = deck.splice(enanoIdx, 1)[0];
deck.unshift(card);
console.log("DEBUG: Forced ENANO MORIBUNDO and RASTRILLO to top of deck.");
}
// DEBUG: Force Chaos Warrior to TOP
/*
const cwIdx = deck.findIndex(c => c.id === 'mon_chaosWarrior');
if (cwIdx !== -1) {
const card = deck.splice(cwIdx, 1)[0];
deck.unshift(card);
console.log("DEBUG: Forced CHAOS WARRIOR to top of deck.");
} }
*/ */

View File

@@ -6,13 +6,14 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 3, ws: 3,
to_hit_missile: 5, // 5+ to hit with ranged to_hit_missile: 5,
str: 4, str: 4,
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas) toughness: 4,
wounds: 12, // 1D6 + 9 (Using fixed average for now) wounds: 12,
attacks: 1, attacks: 1,
init: 3, init: 3,
pin_target: 6 // 6+ to escape pin pin_target: 6,
gold: 0
} }
}, },
dwarf: { dwarf: {
@@ -22,13 +23,14 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 4, ws: 4,
to_hit_missile: 5, // 5+ to hit with ranged to_hit_missile: 5,
str: 3, str: 3,
toughness: 5, // 4 Base + 1 Armor (Cota de Malla) toughness: 5,
wounds: 11, // 1D6 + 8 (Using fixed average for now) wounds: 11,
attacks: 1, attacks: 1,
init: 2, init: 2,
pin_target: 5 // 5+ to escape pin pin_target: 5,
gold: 0
} }
}, },
elf: { elf: {
@@ -38,14 +40,15 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 4, ws: 4,
bs: 4, // Added for Bow bs: 4,
to_hit_missile: 4, // 4+ to hit with ranged to_hit_missile: 4,
str: 3, str: 3,
toughness: 3, toughness: 3,
wounds: 10, // 1D6 + 7 (Using fixed average for now) wounds: 10,
attacks: 1, attacks: 1,
init: 6, init: 6,
pin_target: 1 // Auto escape ("No se puede trabar al Elfo") pin_target: 1,
gold: 0
} }
}, },
wizard: { wizard: {
@@ -55,14 +58,15 @@ export const HERO_DEFINITIONS = {
stats: { stats: {
move: 4, move: 4,
ws: 2, ws: 2,
to_hit_missile: 6, // 6+ to hit with ranged to_hit_missile: 6,
str: 3, str: 3,
toughness: 3, toughness: 3,
wounds: 9, // 1D6 + 6 (Using fixed average for now) wounds: 9,
attacks: 1, attacks: 1,
init: 3, init: 3,
power: 0, // Tracks current power points power: 0,
pin_target: 4 // 4+ to escape pin pin_target: 4,
gold: 0
} }
} }
}; };

View File

@@ -13,6 +13,7 @@ export const MONSTER_DEFINITIONS = {
gold: 55 // Card: "Valor 55x Unidad" gold: 55 // Card: "Valor 55x Unidad"
} }
}, },
// Fix duplicate wounds key in goblin
goblin_spearman: { goblin_spearman: {
id: 'goblin_spearman', id: 'goblin_spearman',
name: 'Lancero Goblin', name: 'Lancero Goblin',
@@ -22,7 +23,6 @@ export const MONSTER_DEFINITIONS = {
ws: 2, ws: 2,
str: 3, str: 3,
toughness: 3, toughness: 3,
wounds: 3,
wounds: 1, wounds: 1,
attacks: 1, attacks: 1,
gold: 20, gold: 20,
@@ -47,7 +47,7 @@ export const MONSTER_DEFINITIONS = {
giant_spider: { giant_spider: {
id: 'giant_spider', id: 'giant_spider',
name: 'Araña Gigante', name: 'Araña Gigante',
portrait: '/assets/images/dungeon1/standees/enemies/spider.png', portrait: '/assets/images/dungeon1/standees/enemies/spiderGiant.png',
stats: { stats: {
move: 6, move: 6,
ws: 2, ws: 2,
@@ -88,5 +88,33 @@ export const MONSTER_DEFINITIONS = {
gold: 440, gold: 440,
damageDice: 2 // "Tira 2 dados para herir" damageDice: 2 // "Tira 2 dados para herir"
} }
},
chaos_warrior: {
id: 'chaos_warrior',
name: 'Guerrero del Caos',
portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
stats: {
move: 4,
ws: 4,
str: 4,
toughness: 4,
wounds: 10, // Copied from Event Card logic
attacks: 2,
gold: 240
}
},
skaven: {
id: 'skaven',
name: 'Skaven',
portrait: '/assets/images/dungeon1/standees/enemies/skaven.png',
stats: {
move: 5,
ws: 3,
str: 3,
toughness: 3,
wounds: 1,
attacks: 1,
gold: 30
}
} }
}; };

View File

@@ -1,9 +1,5 @@
/** import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
* EventInterpreter.js import { CombatMechanics } from '../game/CombatMechanics.js';
*
* Takes high-level Action Instructions from Event Cards (JSON)
* and executes them using the Game Engine's low-level systems.
*/
export class EventInterpreter { export class EventInterpreter {
constructor(gameEngine) { constructor(gameEngine) {
@@ -72,24 +68,19 @@ export class EventInterpreter {
}); });
} }
async log(title, text) {
if (this.game.onShowMessage) {
this.game.onShowMessage(title, text);
}
// Delay removed here, pacing is now handled only by the queue
return Promise.resolve();
}
async processQueue(onComplete = null) { async processQueue(onComplete = null) {
if (this.isProcessing) return; if (this.isProcessing) return;
if (this.queue.length === 0) { if (this.queue.length === 0) {
console.log("[EventInterpreter] All actions completed."); console.log("[EventInterpreter] All actions completed.");
// Event Log Summary
if (this.game.onShowMessage) {
// We use onShowMessage as Log (via main.js filtering) or specialized logger
// Actually, let's inject a "System Log" call if possible.
// Main.js redirects "Efecto" titles to log, but let's be explicit.
// Using generic onShowMessage with a distinct title for logging.
// Or better, let's call a log method if exposed on game.
// Let's assume onShowMessage("LOG", ...) goes to log if we tweak main.js,
// OR we just use a title that main.js recognizes as loggable.
// But wait, the USER wants a log of the CARD itself.
}
if (onComplete) { if (onComplete) {
onComplete(); onComplete();
} else { } else {
@@ -109,9 +100,9 @@ export class EventInterpreter {
this.isProcessing = false; this.isProcessing = false;
// Next step // Increased delay to 2s between major event steps as requested
if (this.queue.length > 0) { if (this.queue.length > 0) {
setTimeout(() => this.processQueue(onComplete), 500); setTimeout(() => this.processQueue(onComplete), 2000);
} else { } else {
this.processQueue(onComplete); // Finish up this.processQueue(onComplete); // Finish up
} }
@@ -120,7 +111,7 @@ export class EventInterpreter {
async executeAction(action) { async executeAction(action) {
switch (action.tipo_accion) { switch (action.tipo_accion) {
case 'MENSAJE': case 'MENSAJE':
await this.showModal("Evento", action.texto || action.mensaje); await this.log("Evento", action.texto || action.mensaje);
break; break;
case 'SELECCION': case 'SELECCION':
@@ -167,14 +158,11 @@ export class EventInterpreter {
const idx = Math.floor(Math.random() * this.game.heroes.length); const idx = Math.floor(Math.random() * this.game.heroes.length);
targets = [this.game.heroes[idx]]; targets = [this.game.heroes[idx]];
} else if (action.modo === 'tirada_baja') { } else if (action.modo === 'tirada_baja') {
// Roll D6 for each, pick lowest
// For now, SIMULATED logic without UI prompts
let lowest = 99; let lowest = 99;
let candidates = []; let candidates = [];
this.game.heroes.forEach(h => { this.game.heroes.forEach(h => {
const roll = Math.floor(Math.random() * 6) + 1; const roll = Math.floor(Math.random() * 6) + 1;
// console.log(`${h.name} rolled ${roll}`);
if (roll < lowest) { if (roll < lowest) {
lowest = roll; lowest = roll;
candidates = [h]; candidates = [h];
@@ -182,24 +170,22 @@ export class EventInterpreter {
candidates.push(h); // Tie candidates.push(h); // Tie
} }
}); });
// If tie, pick random from candidates
targets = [candidates[Math.floor(Math.random() * candidates.length)]]; targets = [candidates[Math.floor(Math.random() * candidates.length)]];
} }
// Store result // Store result
if (action.guardar_como) { if (action.guardar_como) {
this.currentContext[action.guardar_como] = targets[0]; // Simplification for Single Target this.currentContext[action.guardar_como] = targets[0];
} }
if (action.mensaje) { if (action.mensaje) {
const names = targets.map(t => t.name).join(", "); const names = targets.map(t => t.name).join(", ");
// BLOCKING MODAL // Improved immersive message: "¡El Bárbaro ha pisado una trampa!"
await this.showModal("Selección", `${action.mensaje}<br><br><b>Objetivo: ${names}</b>`); await this.log("Selección", `¡El <b>${names}</b> ${action.mensaje}`);
} }
} }
async handleTest(action) { async handleTest(action) {
// Resolve target
const target = action.origen ? this.currentContext[action.origen] : null; const target = action.origen ? this.currentContext[action.origen] : null;
if (!target && action.origen) { if (!target && action.origen) {
console.error("Test target not found in context:", action.origen); console.error("Test target not found in context:", action.origen);
@@ -207,21 +193,19 @@ export class EventInterpreter {
} }
let roll = 0; let roll = 0;
// Parse dice string "1D6", "2D6"
if (action.tipo_prueba.includes('D6')) { if (action.tipo_prueba.includes('D6')) {
const count = parseInt(action.tipo_prueba) || 1; const count = parseInt(action.tipo_prueba) || 1;
for (let i = 0; i < count; i++) roll += Math.floor(Math.random() * 6) + 1; for (let i = 0; i < count; i++) roll += Math.floor(Math.random() * 6) + 1;
} }
// BLOCKING MODAL: Show the roll result // Log result to sidebar instead of Popup
const targetName = target ? target.name : "Nadie"; const targetName = target ? target.name : (action.objetivo === 'todos' ? "El Grupo" : "Tirada de Evento");
await this.showModal("Prueba", `<b>${targetName}</b> realiza una prueba de <b>${action.tipo_prueba}</b>...<br>Resultado: <b>${roll}</b>`); await this.log("Evento: Prueba", `<b>${targetName}</b> tira <b>${action.tipo_prueba}</b>: Resultado <b style="color:#DAA520">${roll}</b>`);
// Check Table // Check Table
if (action.tabla) { if (action.tabla) {
let resultActions = null; let resultActions = null;
// Iterate keys "1", "2-3", "4-6"
for (const key of Object.keys(action.tabla)) { for (const key of Object.keys(action.tabla)) {
if (key.includes('-')) { if (key.includes('-')) {
const [min, max] = key.split('-').map(Number); const [min, max] = key.split('-').map(Number);
@@ -232,24 +216,17 @@ export class EventInterpreter {
} }
if (resultActions) { if (resultActions) {
// Prepend these new actions to the FRONT of the queue to execute immediately
console.log(`Test Roll: ${roll} -> Result found`, resultActions);
this.queue.unshift(...resultActions); this.queue.unshift(...resultActions);
} }
} }
} }
async handleEffect(action) { async handleEffect(action) {
// Resolve Target
let targets = []; let targets = [];
if (action.objetivo) { if (action.objetivo) {
if (this.currentContext[action.objetivo]) targets = [this.currentContext[action.objetivo]]; if (this.currentContext[action.objetivo]) targets = [this.currentContext[action.objetivo]];
} else {
// Implicit context? Default to all?
// Better to be explicit in JSON mostly
} }
// Parse Count
let amount = 0; let amount = 0;
let isDice = false; let isDice = false;
if (typeof action.cantidad === 'number') amount = action.cantidad; if (typeof action.cantidad === 'number') amount = action.cantidad;
@@ -265,30 +242,48 @@ export class EventInterpreter {
} }
let msg = action.mensaje || "Efecto aplicado"; let msg = action.mensaje || "Efecto aplicado";
if (isDice) msg += ` (Dado: ${amount})`;
if (action.tipo === 'daño') { if (action.tipo === 'daño') {
targets.forEach(h => { targets.forEach(h => {
// Apply Damage Logic // RED ALERT: Use applyDamage to handle currentWounds and death/unconscious logic
// this.game.combatSystem.applyDamage(h, amount)... CombatMechanics.applyDamage(h, amount, this.game);
console.log(`Applying ${amount} Damage to ${h.name}`);
h.stats.wounds -= amount; // Simple direct manipulation for now
if (this.game.onEntityUpdate) this.game.onEntityUpdate(h); if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
// Visual feedback?
}); });
await this.showModal("Daño", `${msg}<br><b>${amount} Heridas</b> a ${targets.map(t => t.name).join(", ")}`); // Combined outcome into a single log entry
await this.log("Efecto", `<b>${msg}</b>. Daño recibido: <b style="color:#ff4444">${amount} Heridas</b> a ${targets.map(t => t.name).join(", ")}`);
// USER REQUEST: Show a clear notification with the consequence after the delay
if (this.game.onShowMessage) {
const targetNames = targets.map(t => t.name).join(", ");
this.game.onShowMessage("CONSECUENCIA", `<b>${msg}</b><br><br><span style="color:#ff4444; font-size: 20px;">-${amount} Heridas</span> a ${targetNames}`);
}
} else if (action.tipo === 'oro') { } else if (action.tipo === 'oro') {
targets.forEach(h => { targets.forEach(h => {
console.log(`Giving ${amount} Gold to ${h.name}`); h.stats.gold = (h.stats.gold || 0) + amount;
h.gold = (h.gold || 0) + amount;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(h); if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
}); });
await this.showModal("Oro", `${msg}<br>Ganan <b>${amount}</b> de Oro.`); await this.log("Efecto", `<b>${msg}</b>. Hallazgo: <b style="color:#DAA520">${amount} Oro</b>.`);
// USER REQUEST: Show a clear notification with the consequence
if (this.game.onShowMessage) {
const targetNames = targets.map(t => t.name).join(", ");
this.game.onShowMessage("HALLAZGO", `<b>${msg}</b><br><br><span style="color:#DAA520; font-size: 20px;">+${amount} Oro</span> para ${targetNames}`);
}
} else if (action.tipo === 'item') {
targets.forEach(h => {
if (!h.inventory) h.inventory = [];
h.inventory.push(action.id_item);
if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
});
await this.log("Hallazgo", `<b>${msg}</b>: ${targets.map(t => t.name).join(", ")} obtiene <b>${action.id_item}</b>.`);
if (this.game.onShowMessage) {
this.game.onShowMessage("OBJETO", `<b>${msg}</b>`);
}
} }
} }
async handleSpawn(action) { async handleSpawn(action) {
// Parse Amount
let count = 1; let count = 1;
if (typeof action.cantidad === 'string' && action.cantidad.includes('D6')) { if (typeof action.cantidad === 'string' && action.cantidad.includes('D6')) {
const numDice = parseInt(action.cantidad) || 1; const numDice = parseInt(action.cantidad) || 1;
@@ -297,84 +292,56 @@ export class EventInterpreter {
count = parseInt(action.cantidad) || 1; count = parseInt(action.cantidad) || 1;
} }
// Resolve ID/Stats const monsterId = action.id_monstruo;
// Map to Monster Definitions? const libraryDef = MONSTER_DEFINITIONS[monsterId];
// For now, construct dynamic definition
const def = { let def;
if (libraryDef) {
def = { ...libraryDef, stats: { ...libraryDef.stats } };
if (action.stats) def.stats = { ...def.stats, ...action.stats };
} else {
def = {
name: action.nombre_fallback || "Enemigo", name: action.nombre_fallback || "Enemigo",
// Texture mapping based on ID portrait: this.getTexturePathForMonster_Legacy(monsterId),
portrait: this.getTexturePathForMonster(action.id_monstruo),
stats: action.stats || { wounds: 1, move: 4, ws: 3, str: 3, toughness: 3 } stats: action.stats || { wounds: 1, move: 4, ws: 3, str: 3, toughness: 3 }
}; };
}
// Check if there is an Exploration Context (TileID)
let contextTileId = null; let contextTileId = null;
if (this.game.currentEventContext && this.game.currentEventContext.tileId) { if (this.game.currentEventContext && this.game.currentEventContext.tileId) {
contextTileId = this.game.currentEventContext.tileId; contextTileId = this.game.currentEventContext.tileId;
} }
const spots = this.game.findSpawnPoints(count, contextTileId); const spots = this.game.findSpawnPoints(count, contextTileId);
spots.forEach(spot => { spots.forEach(spot => {
// Monsters spawned via Event Card in Exploration phase should NOT skip turn?
// "Monsters placed... move and attack as described in Power Phase" -> Wait.
// Power Phase monsters ATTACK immediately. Exploration monsters DO NOT?
// Rules say: "If Monsters, place them... see Power Phase for details"
// Actually, "In the Monster Phase... determine how many... place them... Keep the card handy... information needed later"
// Usually ambushes act immediately, but room dwellers act in the Monster Phase.
// Since we are triggering this AT THE START of Monster Phase, they will act in this phase naturally ONLY IF we don't skip turn.
// However, GameEngine monster AI loop iterates all monsters.
// Newly added monster might be picked up immediately if added to the array?
// Yes, standard is they act.
this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: false }); this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: false });
}); });
// Clear context after use to prevent leakage? // KEEP MODAL for Spawn - it's a major event that requires immediate player attention
// Or keep it for the duration of the event chain?
// Safest to keep until event ends, GameEngine clears it?
// We leave it, GameEngine overrides or clears it when needed.
await this.showModal("¡Emboscada!", `Aparecen <b>${count} ${def.name}</b>!<br>¡Prepárate para luchar!`); await this.showModal("¡Emboscada!", `Aparecen <b>${count} ${def.name}</b>!<br>¡Prepárate para luchar!`);
} }
async handleEnvironment(action) { async handleEnvironment(action) {
if (action.subtipo === 'bloquear_salidas_excepto_entrada') { if (action.subtipo === 'bloquear_salidas_excepto_entrada') {
console.log("[Event] Collapsing Exits...");
if (this.game.collapseExits) { if (this.game.collapseExits) {
const collapsedCount = this.game.collapseExits(); const collapsedCount = this.game.collapseExits();
await this.showModal("¡Derrumbe!", `¡El techo se viene abajo!<br><b>${collapsedCount}</b> salidas han quedado bloqueadas por escombros.`); await this.showModal("¡Derrumbe!", `¡El techo se viene abajo!<br><b>${collapsedCount}</b> salidas bloqueadas.`);
} else {
console.error("GameEngine.collapseExits not implemented!");
await this.showModal("Error", "GameEngine.collapseExits no implementado.");
} }
} else if (action.subtipo === 'colocar_marcador') { } else if (action.subtipo === 'colocar_marcador') {
console.log(`[Event] Placing Marker: ${action.marcador}`);
if (this.game.placeEventMarker) { if (this.game.placeEventMarker) {
// Determine position based on context (e.g. center of current room)
// For now, pass null so GameEngine decides based on player location
this.game.placeEventMarker(action.marcador); this.game.placeEventMarker(action.marcador);
} }
} else if (action.subtipo === 'bloquear_entrada_rastrillo') {
if (this.game.blockPortcullisAtEntrance) {
this.game.blockPortcullisAtEntrance();
}
} else { } else {
console.log("Environment Action Unknown:", action.subtipo); console.log("Environment Action Unknown:", action.subtipo);
} }
} }
getTexturePathForMonster(id) { // Legacy fallback ONLY
// Map JSON IDs to Files getTexturePathForMonster_Legacy(id) {
// id_monstruo: "minotaur" -> "dungeon1/standees/enemies/minotaur.png" (if exists) or fallback return "assets/images/dungeon1/standees/enemies/orc.png";
// We checked file list earlier:
// bat.png, goblin.png, orc.png, skaven.png, chaosWarrior.png, Lordwarlock.png, rat.png, spiderGiant.png
const map = {
"giant_spider": "spiderGiant.png",
"orc": "orc.png",
"goblin": "goblin.png",
"skaven": "skaven.png",
"minotaur": "minotaur.png"
};
const filename = map[id] || "orc.png";
return `assets/images/dungeon1/standees/enemies/${filename}`;
} }
} }

View File

@@ -42,6 +42,18 @@ export class CombatSystem {
// 3. Update State // 3. Update State
attacker.hasAttacked = true; attacker.hasAttacked = true;
// Award Gold if hero killed monster
if (result.defenderDied && attacker.type === 'hero') {
const goldValue = (defender.stats && defender.stats.gold) || 0;
if (goldValue > 0) {
attacker.stats.gold = (attacker.stats.gold || 0) + goldValue;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(attacker);
if (this.game.onShowMessage) {
this.game.onShowMessage("Botín", `¡Has derrotado a ${defender.name}! Recibes ${goldValue} Oro.`);
}
}
}
// 4. Side Effects (Sound, UI Events) // 4. Side Effects (Sound, UI Events)
if (window.SOUND_MANAGER) { if (window.SOUND_MANAGER) {
// Logic to choose sound could be expanded here based on Weapon Type // Logic to choose sound could be expanded here based on Weapon Type
@@ -87,6 +99,18 @@ export class CombatSystem {
// 3. Update State // 3. Update State
attacker.hasAttacked = true; attacker.hasAttacked = true;
// Award Gold if hero killed monster
if (result.defenderDied && attacker.type === 'hero') {
const goldValue = (defender.stats && defender.stats.gold) || 0;
if (goldValue > 0) {
attacker.stats.gold = (attacker.stats.gold || 0) + goldValue;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(attacker);
if (this.game.onShowMessage) {
this.game.onShowMessage("Botín", `¡Has derrotado a ${defender.name}! Recibes ${goldValue} Oro.`);
}
}
}
// 4. Side Effects // 4. Side Effects
if (window.SOUND_MANAGER) { if (window.SOUND_MANAGER) {
window.SOUND_MANAGER.playSound('arrow'); window.SOUND_MANAGER.playSound('arrow');

View File

@@ -26,6 +26,7 @@ export class GameEngine {
this.plannedPath = []; // Array of {x,y} this.plannedPath = []; // Array of {x,y}
this.visitedRoomIds = new Set(); // Track tiles triggered this.visitedRoomIds = new Set(); // Track tiles triggered
this.eventDeck = createEventDeck(); this.eventDeck = createEventDeck();
this.lastEntranceUsed = null;
// Callbacks // Callbacks
this.onEntityUpdate = null; this.onEntityUpdate = null;
@@ -62,7 +63,7 @@ export class GameEngine {
window.RENDERER.clearAllActiveRings(); window.RENDERER.clearAllActiveRings();
} }
this.deselectEntity(); this.deselectEntity();
this.ai.executeTurn(); // Duplicate executeTurn removed here. main.js handles this with playMonsterTurn().
} }
}); });
@@ -73,6 +74,13 @@ export class GameEngine {
// 6. Listen for Power Phase Events // 6. Listen for Power Phase Events
this.turnManager.on('POWER_RESULT', (data) => { this.turnManager.on('POWER_RESULT', (data) => {
// Update Wizard Power Stat
const wizard = this.heroes.find(h => h.key === 'wizard');
if (wizard) {
wizard.stats.power = data.roll;
if (this.onEntityUpdate) this.onEntityUpdate(wizard);
}
if (data.eventTriggered) { if (data.eventTriggered) {
console.log("[GameEngine] Power Event Triggered! Waiting to handle..."); console.log("[GameEngine] Power Event Triggered! Waiting to handle...");
// Determine if we need to draw a card or if it's a specific message // Determine if we need to draw a card or if it's a specific message
@@ -175,7 +183,8 @@ export class GameEngine {
currentMoves: definition.stats.move, currentMoves: definition.stats.move,
hasAttacked: false, hasAttacked: false,
isConscious: true, isConscious: true,
hasLantern: key === 'barbarian' // Default leader hasLantern: key === 'barbarian', // Default leader
inventory: []
}; };
this.heroes.push(hero); this.heroes.push(hero);
@@ -1550,4 +1559,13 @@ export class GameEngine {
window.RENDERER.spawnProp(type, x, y); window.RENDERER.spawnProp(type, x, y);
} }
} }
blockPortcullisAtEntrance() {
if (this.lastEntranceUsed && window.RENDERER) {
window.RENDERER.blockDoorWithPortcullis(this.lastEntranceUsed);
if (this.onShowMessage) {
this.onShowMessage("¡RASTRILLO!", "Un pesado rastrillo de hierro cae a vuestras espaldas, bloqueando la entrada.");
}
}
}
} }

View File

@@ -30,6 +30,12 @@ export class MagicSystem {
console.log(`[MagicSystem] Casting ${spell.name} by ${caster.name}`); console.log(`[MagicSystem] Casting ${spell.name} by ${caster.name}`);
// Deduct Power
this.game.turnManager.currentPowerRoll -= spell.cost;
// Update Hero Stat for UI
caster.stats.power = this.game.turnManager.currentPowerRoll;
if (this.game.onEntityUpdate) this.game.onEntityUpdate(caster);
// Dispatch based on Spell Type // Dispatch based on Spell Type
// We could also look up a specific handler function map if this grows // We could also look up a specific handler function map if this grows
if (spell.type === 'heal') { if (spell.type === 'heal') {

View File

@@ -21,42 +21,25 @@ export class MonsterAI {
// Check for Summoning Sickness / Ambush delay // Check for Summoning Sickness / Ambush delay
if (monster.skipTurn) { if (monster.skipTurn) {
console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`); console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
monster.skipTurn = false; // Ready for next turn monster.skipTurn = false;
// Add a small visual delay even if skipping, to show focus?
// No, better to just skip significantly to keep flow fast.
continue; continue;
} }
await this.processMonster(monster); // Small "thinking" pause between monsters
await new Promise(r => setTimeout(r, 400));
await this.actMonster(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(() => { async actMonster(monster) {
this.actMonster(monster);
setTimeout(() => {
resolve();
}, moveTime);
}, 100);
});
}
actMonster(monster) {
// 1. Check if already adjacent (Engaged) -> ATTACK // 1. Check if already adjacent (Engaged) -> ATTACK
const adjacentHero = this.getAdjacentHero(monster); const adjacentHero = this.getAdjacentHero(monster);
if (adjacentHero) { if (adjacentHero) {
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`); console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
this.performAttack(monster, adjacentHero); await this.performAttack(monster, adjacentHero);
return; return;
} }
@@ -96,13 +79,16 @@ export class MonsterAI {
// 7. Check if NOW adjacent after move -> ATTACK // 7. Check if NOW adjacent after move -> ATTACK
// Wait for movement animation to complete before checking // Wait for movement animation to complete before checking
const movementDuration = actualPath.length * 600; const movementDuration = actualPath.length * 600;
setTimeout(() => { await new Promise(resolve => {
setTimeout(async () => {
const postMoveHero = this.getAdjacentHero(monster); const postMoveHero = this.getAdjacentHero(monster);
if (postMoveHero) { if (postMoveHero) {
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`); console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
this.performAttack(monster, postMoveHero); await this.performAttack(monster, postMoveHero);
} }
resolve();
}, movementDuration); }, movementDuration);
});
} }
getClosestHero(monster) { getClosestHero(monster) {
@@ -212,54 +198,43 @@ export class MonsterAI {
return bestPath; return bestPath;
} }
performAttack(monster, hero) { async performAttack(monster, hero) {
// SEQUENCE: const numAttacks = monster.stats.attacks || 1;
// 0. Show TARGET (Blue Ring) on Hero console.log(`[MonsterAI] ${monster.name} performing ${numAttacks} attacks against ${hero.name}`);
if (this.game.onRangedTarget) {
// Re-using onRangedTarget? Or directly calling renderer? for (let i = 0; i < numAttacks; i++) {
// Better to use a specific callback or direct call if available, or just add a new callback. if (hero.isDead || (hero.isConscious === false)) {
// But let's check if we can access renderer directly or use a new callback. break;
// The user prompt specifically asked for this feature.
// I'll assume we can use game.onEntityTarget if defined, or direct renderer call if needed,
// but standard pattern here is callbacks.
// Let's add onEntityTarget to GameEngine callbacks if not present, but for now I will try to use global RENDERER if possible
// OR simply define a new callback `this.game.onEntityTarget(hero.id, true)`.
} }
// Direct renderer call is safest given current context if we don't want to modify GameEngine interface heavily right now.
if (window.RENDERER && window.RENDERER.setEntityTarget) { if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, true); window.RENDERER.setEntityTarget(hero.id, true);
} }
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game); const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
// Step 1: Green ring on attacker
if (this.game.onEntityActive) { if (this.game.onEntityActive) {
this.game.onEntityActive(monster.id, true); this.game.onEntityActive(monster.id, true);
} }
// Step 2: Attack animation delay (500ms) await new Promise(resolve => {
setTimeout(() => {
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
setTimeout(() => { setTimeout(() => {
if (this.game.onEntityActive) { if (this.game.onEntityActive) {
this.game.onEntityActive(monster.id, false); this.game.onEntityActive(monster.id, false);
} }
// Remove Target Ring
if (window.RENDERER && window.RENDERER.setEntityTarget) { if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, false); window.RENDERER.setEntityTarget(hero.id, false);
} }
// Step 5: Show combat result after both rings are gone
setTimeout(() => {
if (this.game.onCombatResult) { if (this.game.onCombatResult) {
this.game.onCombatResult(result); this.game.onCombatResult(result);
} }
}, 200); // Small delay after rings disappear
}, 1200); // Wait for red ring to disappear // Snappier transition (800ms vs 1500ms)
}, 800); // Attack animation delay + focus time setTimeout(resolve, 800);
}, 500); // Wait 500ms for attack "focus"
});
}
} }
getAdjacentHero(entity) { getAdjacentHero(entity) {

View File

@@ -61,8 +61,8 @@ export class TurnManager {
} }
rollPowerDice() { rollPowerDice() {
const roll = Math.floor(Math.random() * 6) + 1; // const roll = Math.floor(Math.random() * 6) + 1;
// const roll = 1; // DEBUG: Force Event for testing const roll = 1; // DEBUG: Force Event for testing
this.currentPowerRoll = roll; this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`); console.log(`Power Roll: ${roll}`);

View File

@@ -290,6 +290,17 @@ const handleClick = (x, y, doorMesh) => {
// 2. Check Adjacency // 2. Check Adjacency
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) { if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
// 3. Check Key Requirement for Portcullis
if (doorMesh.userData.requiresKey) {
const hasKey = game.heroes.some(h => h.inventory && h.inventory.includes('llave_rastrillo'));
if (!hasKey) {
ui.showModal('Bloqueado', 'Esta puerta tiene un rastrillo bajado. Necesitáis la llave del enano para abrirla.');
return;
} else {
ui.showModal('¡Rastrillo Abierto!', 'Utilizáis la llave del enano para levantar el pesado rastrillo.');
}
}
// Open door visually // Open door visually
renderer.openDoor(doorMesh); renderer.openDoor(doorMesh);
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open'); if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
@@ -297,6 +308,7 @@ const handleClick = (x, y, doorMesh) => {
// Get proper exit data with direction // Get proper exit data with direction
const exitData = doorMesh.userData.exitData; const exitData = doorMesh.userData.exitData;
if (exitData) { if (exitData) {
game.lastEntranceUsed = exitData;
generator.selectDoor(exitData); generator.selectDoor(exitData);
} else { } else {
console.error('[Main] Door missing exitData'); console.error('[Main] Door missing exitData');

View File

@@ -204,6 +204,10 @@ export class GameRenderer {
this.dungeonRenderer.blockDoor(exitData); this.dungeonRenderer.blockDoor(exitData);
} }
blockDoorWithPortcullis(exitData) {
this.dungeonRenderer.blockDoorWithPortcullis(exitData);
}
showRangedTargeting(hero, monster, losResult) { showRangedTargeting(hero, monster, losResult) {
this.interactionRenderer.showRangedTargeting(hero, monster, losResult); this.interactionRenderer.showRangedTargeting(hero, monster, losResult);
} }

View File

@@ -4,6 +4,7 @@ import { TurnStatusUI } from './ui/TurnStatusUI.js';
import { PlacementUI } from './ui/PlacementUI.js'; import { PlacementUI } from './ui/PlacementUI.js';
import { FeedbackUI } from './ui/FeedbackUI.js'; import { FeedbackUI } from './ui/FeedbackUI.js';
import { SpellbookUI } from './ui/SpellbookUI.js'; import { SpellbookUI } from './ui/SpellbookUI.js';
import { InventoryUI } from './ui/InventoryUI.js';
export class UIManager { export class UIManager {
constructor(cameraManager, gameEngine) { constructor(cameraManager, gameEngine) {
@@ -17,11 +18,13 @@ export class UIManager {
this.turnUI = new TurnStatusUI(this.container, gameEngine); this.turnUI = new TurnStatusUI(this.container, gameEngine);
this.feedback = new FeedbackUI(this.container, gameEngine); this.feedback = new FeedbackUI(this.container, gameEngine);
this.spellbook = new SpellbookUI(gameEngine); this.spellbook = new SpellbookUI(gameEngine);
this.inventory = new InventoryUI(gameEngine);
// Circular deps / callbacks // Circular deps / callbacks
const cardCallbacks = { const cardCallbacks = {
showModal: (t, m, c) => this.feedback.showModal(t, m, c), showModal: (t, m, c) => this.feedback.showModal(t, m, c),
toggleSpellBook: (h) => this.spellbook.toggle(h) toggleSpellBook: (h) => this.spellbook.toggle(h),
toggleInventory: (h) => this.inventory.toggle(h)
}; };
this.cards = new UnitCardManager(this.container, gameEngine, cardCallbacks); this.cards = new UnitCardManager(this.container, gameEngine, cardCallbacks);
@@ -94,6 +97,15 @@ export class UIManager {
} }
}; };
// Entity Update (Stats change like Wounds or Gold)
const originalUpdate = this.game.onEntityUpdate;
this.game.onEntityUpdate = (entity) => {
if (originalUpdate) originalUpdate(entity);
if (entity.type === 'hero') {
this.cards.updateHeroCard(entity.id);
}
};
// Turn Manager Events // Turn Manager Events
if (this.game.turnManager) { if (this.game.turnManager) {
this.game.turnManager.on('phase_changed', (phase) => { this.game.turnManager.on('phase_changed', (phase) => {

View File

@@ -306,6 +306,11 @@ export class DungeonRenderer {
// Load open door texture // Load open door texture
this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => { this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => {
// Safeguard: If it became a portcullis or blocked in the meantime, don't show as open
if (doorMesh.userData.isPortcullis || doorMesh.userData.isBlocked) {
console.log("[DungeonRenderer] openDoor callback skipped: already portcullis/blocked");
return;
}
doorMesh.material.map = texture; doorMesh.material.map = texture;
doorMesh.material.needsUpdate = true; doorMesh.material.needsUpdate = true;
doorMesh.userData.isOpen = true; doorMesh.userData.isOpen = true;
@@ -339,6 +344,46 @@ export class DungeonRenderer {
} }
} }
blockDoorWithPortcullis(exitData) {
if (!this.exitGroup || !exitData) return;
let targetDoor = null;
console.log(`[DungeonRenderer] Attempting to block door at ${exitData.x},${exitData.y}`);
for (const child of this.exitGroup.children) {
if (child.userData.isDoor) {
for (const cell of child.userData.cells) {
if (cell.x === exitData.x && cell.y === exitData.y) {
targetDoor = child;
break;
}
}
}
if (targetDoor) break;
}
if (targetDoor) {
console.log("[DungeonRenderer] Target door found for portcullis.");
this.getTexture('/assets/images/dungeon1/doors/door1_portcullis.png', (texture) => {
targetDoor.material.map = texture;
targetDoor.material.needsUpdate = true;
targetDoor.userData.isBlocked = false;
targetDoor.userData.isOpen = false;
targetDoor.userData.isPortcullis = true;
targetDoor.userData.requiresKey = true;
console.log("[DungeonRenderer] Portcullis texture applied.");
});
} else {
console.warn("[DungeonRenderer] Target door NOT found for portcullis at:", exitData);
// Debug: Log all door cells
this.exitGroup.children.forEach(d => {
if (d.userData.isDoor) {
console.log(" Door cells:", d.userData.cells.map(c => `${c.x},${c.y}`).join(" | "));
}
});
}
}
getDoorAtPosition(x, y) { getDoorAtPosition(x, y) {
if (!this.exitGroup) return null; if (!this.exitGroup) return null;
for (const child of this.exitGroup.children) { for (const child of this.exitGroup.children) {

145
src/view/ui/InventoryUI.js Normal file
View File

@@ -0,0 +1,145 @@
export class InventoryUI {
constructor(game) {
this.game = game;
this.container = null;
}
toggle(hero) {
if (this.container) {
document.body.removeChild(this.container);
this.container = null;
return;
}
if (!hero) return;
const container = document.createElement('div');
Object.assign(container.style, {
position: 'absolute',
bottom: '140px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
flexDirection: 'column',
gap: '15px',
backgroundColor: 'rgba(30, 20, 10, 0.95)',
padding: '20px',
borderRadius: '12px',
border: '3px solid #8B4513',
zIndex: '1500',
boxShadow: '0 0 30px rgba(139, 69, 19, 0.6)',
minWidth: '300px',
maxWidth: '600px',
transition: 'all 0.3s ease-out',
pointerEvents: 'auto'
});
const title = document.createElement('div');
title.textContent = `MOCHILA DE ${hero.name.toUpperCase()}`;
Object.assign(title.style, {
textAlign: 'center',
color: '#DAA520',
fontFamily: '"Cinzel", serif',
fontSize: '22px',
marginBottom: '10px',
textShadow: '2px 2px 4px #000',
borderBottom: '1px solid #555',
paddingBottom: '10px'
});
container.appendChild(title);
const itemsContainer = document.createElement('div');
Object.assign(itemsContainer.style, {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))',
gap: '10px',
maxHeight: '300px',
overflowY: 'auto',
padding: '5px'
});
container.appendChild(itemsContainer);
const inventory = hero.inventory || [];
if (inventory.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = "La mochila está vacía...";
Object.assign(emptyMsg.style, {
textAlign: 'center',
color: '#888',
fontStyle: 'italic',
gridColumn: '1 / -1',
padding: '20px'
});
itemsContainer.appendChild(emptyMsg);
} else {
inventory.forEach((itemId, index) => {
const itemEl = this.createItemElement(itemId);
itemsContainer.appendChild(itemEl);
});
}
// Close button
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Cerrar';
Object.assign(closeBtn.style, {
marginTop: '15px',
padding: '8px',
backgroundColor: '#444',
color: '#fff',
border: '1px solid #777',
borderRadius: '4px',
cursor: 'pointer',
fontFamily: '"Cinzel", serif'
});
closeBtn.onclick = () => this.toggle();
container.appendChild(closeBtn);
document.body.appendChild(container);
this.container = container;
}
createItemElement(itemId) {
const item = document.createElement('div');
Object.assign(item.style, {
width: '80px',
height: '80px',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
border: '2px solid #DAA520',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden'
});
const icon = document.createElement('div');
icon.style.fontSize = '32px';
const label = document.createElement('div');
label.style.fontSize = '10px';
label.style.textAlign = 'center';
label.style.marginTop = '4px';
// Item Database (Simple)
if (itemId === 'llave_rastrillo') {
icon.textContent = '🔑';
label.textContent = 'Llave Rastrillo';
} else {
icon.textContent = '📦';
label.textContent = itemId;
}
item.appendChild(icon);
item.appendChild(label);
item.onmouseenter = () => { item.style.backgroundColor = 'rgba(218, 165, 32, 0.2)'; };
item.onmouseleave = () => { item.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; };
return item;
}
}

View File

@@ -199,6 +199,9 @@ export class TurnStatusUI {
<div style="font-size: 14px;"> <div style="font-size: 14px;">
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move} Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
</div> </div>
<div style="font-size: 14px;">
Oro: <span style="color: #DAA520; font-weight: bold;">${hero.stats.gold || 0}</span> 🪙
</div>
</div> </div>
</div> </div>
`; `;

View File

@@ -7,6 +7,7 @@ export class UnitCardManager {
this.cardsContainer = null; this.cardsContainer = null;
this.currentHeroCard = null; this.currentHeroCard = null;
this.currentMonsterCard = null; this.currentMonsterCard = null;
this.monsterContainer = null;
this.placeholderCard = null; this.placeholderCard = null;
this.attackButton = null; this.attackButton = null;
@@ -20,10 +21,10 @@ export class UnitCardManager {
left: '10px', left: '10px',
top: '220px', // Below minimap top: '220px', // Below minimap
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'row',
gap: '10px', alignItems: 'flex-start',
pointerEvents: 'auto', gap: '15px',
width: '200px' pointerEvents: 'auto'
}); });
this.parentContainer.appendChild(this.cardsContainer); this.parentContainer.appendChild(this.cardsContainer);
@@ -106,18 +107,18 @@ export class UnitCardManager {
const hero = this.game.heroes.find(h => h.id === heroId); const hero = this.game.heroes.find(h => h.id === heroId);
if (!hero) return; if (!hero) return;
const statsGrid = this.currentHeroCard.querySelector('div[style*="grid-template-columns"]'); // NEW: Update stats using data-attributes for robustness
if (statsGrid) { const updateStat = (key, value) => {
const statDivs = statsGrid.children; const el = this.currentHeroCard.querySelector(`[data-stat="${key}"]`);
// Assumed order: 4 -> Heridas, 7 -> Movimiento if (el) el.textContent = value;
if (statDivs[4]) { };
const wValue = statDivs[4].querySelector('span:last-child');
if (wValue) wValue.textContent = `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`; updateStat('Her', `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`);
} updateStat('Mov', `${hero.currentMoves || 0}/${hero.stats.move}`);
if (statDivs[7]) { updateStat('Oro', hero.stats.gold || 0);
const movValue = statDivs[7].querySelector('span:last-child');
if (movValue) movValue.textContent = `${hero.currentMoves || 0}/${hero.stats.move}`; if (hero.key === 'wizard') {
} updateStat('Pod', hero.stats.power || 0);
} }
} }
@@ -191,15 +192,22 @@ export class UnitCardManager {
{ label: 'Her', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` }, { label: 'Her', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` },
{ label: 'Ini', value: hero.stats.initiative || 0 }, { label: 'Ini', value: hero.stats.initiative || 0 },
{ label: 'Ata', value: hero.stats.attacks || 0 }, { label: 'Ata', value: hero.stats.attacks || 0 },
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` } { label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` },
{ label: 'Oro', value: hero.stats.gold || 0 }
]; ];
// USER REQUEST: Show Power for Wizard
if (hero.key === 'wizard') {
stats.push({ label: 'Pod', value: hero.stats.power || 0 });
}
stats.forEach(stat => { stats.forEach(stat => {
const el = document.createElement('div'); const el = document.createElement('div');
Object.assign(el.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)', padding: '3px 5px', borderRadius: '3px', display: 'flex', justifyContent: 'space-between' }); Object.assign(el.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)', padding: '3px 5px', borderRadius: '3px', display: 'flex', justifyContent: 'space-between' });
const l = document.createElement('span'); l.textContent = stat.label + ':'; l.style.color = '#AAA'; const l = document.createElement('span'); l.textContent = stat.label + ':'; l.style.color = '#AAA';
const v = document.createElement('span'); v.textContent = stat.value; v.style.color = '#FFF'; v.style.fontWeight = 'bold'; const v = document.createElement('span'); v.textContent = stat.value; v.style.color = '#FFF'; v.style.fontWeight = 'bold';
v.dataset.stat = stat.label; // Add data attribute for easier updates
el.appendChild(l); el.appendChild(v); el.appendChild(l); el.appendChild(v);
statsGrid.appendChild(el); statsGrid.appendChild(el);
@@ -280,11 +288,14 @@ export class UnitCardManager {
const invBtn = document.createElement('button'); const invBtn = document.createElement('button');
invBtn.textContent = '🎒 INVENTARIO'; invBtn.textContent = '🎒 INVENTARIO';
Object.assign(invBtn.style, { Object.assign(invBtn.style, {
width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#444', width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#5D4037',
color: '#fff', border: '1px solid #777', borderRadius: '4px', color: '#fff', border: '1px solid #8B4513', borderRadius: '4px',
fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer' // Changed cursor to pointer for feel, though functionality implies future fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer'
}); });
invBtn.title = 'Inventario (Próximamente)'; invBtn.onclick = (e) => {
e.stopPropagation();
if (this.callbacks.toggleInventory) this.callbacks.toggleInventory(hero);
};
card.appendChild(invBtn); card.appendChild(invBtn);
@@ -365,8 +376,17 @@ export class UnitCardManager {
showMonsterCard(monster) { showMonsterCard(monster) {
this.hideMonsterCard(); this.hideMonsterCard();
// Create a sub-container for monster card + button
this.monsterContainer = document.createElement('div');
Object.assign(this.monsterContainer.style, {
display: 'flex',
flexDirection: 'column',
gap: '8px'
});
this.currentMonsterCard = this.createMonsterCard(monster); this.currentMonsterCard = this.createMonsterCard(monster);
this.cardsContainer.appendChild(this.currentMonsterCard); this.monsterContainer.appendChild(this.currentMonsterCard);
this.attackButton = document.createElement('button'); this.attackButton = document.createElement('button');
this.attackButton.textContent = '⚔️ ATACAR'; this.attackButton.textContent = '⚔️ ATACAR';
@@ -384,7 +404,6 @@ export class UnitCardManager {
const result = this.game.performHeroAttack(monster.id); const result = this.game.performHeroAttack(monster.id);
if (result && result.success) { if (result && result.success) {
this.hideMonsterCard(); this.hideMonsterCard();
// Optional: deselect monster logic if managed externally
if (this.game.selectedMonster) { if (this.game.selectedMonster) {
if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false); if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false);
this.game.selectedMonster = null; this.game.selectedMonster = null;
@@ -392,7 +411,8 @@ export class UnitCardManager {
} }
} }
}; };
this.cardsContainer.appendChild(this.attackButton); this.monsterContainer.appendChild(this.attackButton);
this.cardsContainer.appendChild(this.monsterContainer);
} }
showRangedAttackUI(monster) { showRangedAttackUI(monster) {
@@ -417,13 +437,11 @@ export class UnitCardManager {
} }
hideMonsterCard() { hideMonsterCard() {
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) { if (this.monsterContainer && this.monsterContainer.parentNode) {
this.cardsContainer.removeChild(this.currentMonsterCard); this.cardsContainer.removeChild(this.monsterContainer);
this.currentMonsterCard = null;
} }
if (this.attackButton && this.attackButton.parentNode) { this.monsterContainer = null;
this.cardsContainer.removeChild(this.attackButton); this.currentMonsterCard = null;
this.attackButton = null; this.attackButton = null;
} }
}
} }