Refactor: Notifications, Event System (Bypass), and Logging improvements
This commit is contained in:
114
src/engine/data/EventCards.js
Normal file
114
src/engine/data/EventCards.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
export const EVENT_CARDS_DATA = [
|
||||||
|
{
|
||||||
|
"titulo": "DERRUMBAMIENTO",
|
||||||
|
"tipo": "Evento",
|
||||||
|
"codigo_tipo": "E",
|
||||||
|
"descripcion": "Todas las salidas, excepto por la que entraron los Aventureros, están bloqueadas. Al final del siguiente turno, cualquier miniatura en la estancia muere aplastada. La estancia queda intransitable.",
|
||||||
|
"reglas_especiales": [
|
||||||
|
"Colocar marcador de Derrumbamiento.",
|
||||||
|
"Aventureros no sujetos a reglas de trabado por combate al intentar escapar.",
|
||||||
|
"Los Monstruos en la estancia mueren automáticamente."
|
||||||
|
],
|
||||||
|
"cita_fuente": "[cite: 1, 2, 3, 4, 5]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titulo": "CADÁVER",
|
||||||
|
"tipo": "Evento",
|
||||||
|
"codigo_tipo": "E",
|
||||||
|
"descripcion": "Un Bárbaro muerto sostiene una bolsa de cuero. El Aventurero con el resultado más bajo en 1D6 coge la bolsa.",
|
||||||
|
"tabla_efectos": [
|
||||||
|
{ "resultado": "1", "efecto": "¡Gas venenoso! 1D6 Heridas (sin modificadores). Bolsa vacía." },
|
||||||
|
{ "resultado": "2-3", "efecto": "¡Trampa! Lanza de pared inflige 2D6 Heridas al Aventurero. Bolsa vacía." },
|
||||||
|
{ "resultado": "4-6", "efecto": "Tesoro. La bolsa contiene 1D6 x 100 monedas de oro." }
|
||||||
|
],
|
||||||
|
"notas": "Roba otra Carta de Evento inmediatamente después de resolver.",
|
||||||
|
"cita_fuente": "[cite: 6, 7, 11, 15, 16]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titulo": "VIEJOS HUESOS",
|
||||||
|
"tipo": "Evento",
|
||||||
|
"codigo_tipo": "E",
|
||||||
|
"descripcion": "Suelo cubierto de huesos y cráneos con brillo de monedas bajo ellos.",
|
||||||
|
"tabla_efectos": [
|
||||||
|
{ "resultado": "1", "efecto": "Ilusión. Los huesos y el oro desaparecen. Roba una Carta de Evento inmediatamente." },
|
||||||
|
{ "resultado": "2-3", "efecto": "Rayo mágico. Un Aventurero al azar sufre 1D6 Heridas (sin modificadores). Roba una Carta de Evento inmediatamente." },
|
||||||
|
{ "resultado": "4-5", "efecto": "Cada Aventurero en la sección encuentra 1D6 x 10 monedas de oro. Roba otra Carta de Evento." },
|
||||||
|
{ "resultado": "6", "efecto": "Cada Aventurero encuentra 2D6 x 10 monedas de oro y roba una Carta de Tesoro." }
|
||||||
|
],
|
||||||
|
"cita_fuente": "[cite: 8, 9, 12, 14, 17, 18]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titulo": "TRAMPA",
|
||||||
|
"tipo": "Evento",
|
||||||
|
"codigo_tipo": "E",
|
||||||
|
"descripcion": "El Aventurero con el resultado menor en 1D6 activa una trampa.",
|
||||||
|
"tabla_efectos": [
|
||||||
|
{ "resultado": "1", "efecto": "Explosión. Todas las miniaturas en la sección sufren 1D6 Heridas (sin modificadores)." },
|
||||||
|
{ "resultado": "2-5", "efecto": "Grieta. El Aventurero cae al subsuelo y sufre 2D6 Heridas. Solo escapa con Cuerda o Hechizo Levitar." },
|
||||||
|
{ "resultado": "6", "efecto": "Tesoro oculto. Roba una Carta de Tesoro. Con 1-3 en 1D6, roba otro Evento." }
|
||||||
|
],
|
||||||
|
"cita_fuente": "[cite: 19, 20, 21, 22, 23, 24]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titulo": "RASTRILLO",
|
||||||
|
"tipo": "Evento",
|
||||||
|
"codigo_tipo": "E",
|
||||||
|
"descripcion": "Un rastrillo baja al entrar todos los Aventureros, bloqueando la salida de escape.",
|
||||||
|
"reglas_especiales": [
|
||||||
|
"Solo podrán regresar por ese camino si tienen la llave.",
|
||||||
|
"Colocar marcador de rastrillo en la puerta de entrada.",
|
||||||
|
"Roba otra Carta de Evento inmediatamente."
|
||||||
|
],
|
||||||
|
"cita_fuente": "[cite: 25, 26, 27]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titulo": "ESCORPIONES",
|
||||||
|
"tipo": "Evento/Monstruo",
|
||||||
|
"codigo_tipo": "E",
|
||||||
|
"descripcion": "Un enjambre de 12 escorpiones pequeños ataca a un Aventurero al azar.",
|
||||||
|
"mecanica": {
|
||||||
|
"cantidad_inicial": 12,
|
||||||
|
"ataque_jugador": "1D6 + Fuerza = número de escorpiones eliminados.",
|
||||||
|
"ataque_enemigo": "Cada escorpión restante inflige 1 Herida (sin modificadores).",
|
||||||
|
"recompensa": "5 monedas de oro por escorpión muerto."
|
||||||
|
},
|
||||||
|
"notas": "El enjambre se aleja tras el ataque y la carta se descarta.",
|
||||||
|
"cita_fuente": "[cite: 28, 29, 30, 31, 32, 33, 34]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titulo": "MINOTAURO",
|
||||||
|
"tipo": "Monstruo",
|
||||||
|
"codigo_tipo": "M",
|
||||||
|
"estadisticas": {
|
||||||
|
"heridas": 15,
|
||||||
|
"movimiento": 6,
|
||||||
|
"habilidad_armas": 4,
|
||||||
|
"fuerza": 4,
|
||||||
|
"resistencia": 4,
|
||||||
|
"ataques": 2
|
||||||
|
},
|
||||||
|
"reglas_especiales": [
|
||||||
|
"Causa Miedo.",
|
||||||
|
"Si impacta a un Aventurero, inflige 2D6 + 4 Heridas.",
|
||||||
|
"Roba otra Carta de Evento antes de luchar (si salen adversarios, combaten a la vez)."
|
||||||
|
],
|
||||||
|
"valor_oro": 440
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titulo": "2D6 ARAÑAS GIGANTES",
|
||||||
|
"tipo": "Monstruo",
|
||||||
|
"codigo_tipo": "M",
|
||||||
|
"estadisticas": {
|
||||||
|
"heridas": 1,
|
||||||
|
"movimiento": 6,
|
||||||
|
"habilidad_armas": 2,
|
||||||
|
"fuerza": "Especial",
|
||||||
|
"resistencia": 2,
|
||||||
|
"ataques": 1
|
||||||
|
},
|
||||||
|
"reglas_especiales": [
|
||||||
|
"Ataque Telaraña: Si el objetivo está atrapado, la picadura inflige 1D3 Heridas automáticas.",
|
||||||
|
"Si no está atrapado, la araña intenta impactar. Si lo logra, el Aventurero queda atrapado y no puede actuar hasta liberarse."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -1,30 +1,24 @@
|
|||||||
|
import { EVENT_CARDS_DATA } from './EventCards.js';
|
||||||
|
|
||||||
export const EVENT_TYPES = {
|
export const EVENT_TYPES = {
|
||||||
MONSTER: 'monster',
|
MONSTER: 'monster',
|
||||||
EVENT: 'event' // Ambushes, traps, etc.
|
EVENT: 'event'
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = () => {
|
export const createEventDeck = () => {
|
||||||
// As per user request: 10 copies of the same card for now
|
// Convert raw JSON data to engine-compatible card objects
|
||||||
const deck = [];
|
const deck = EVENT_CARDS_DATA.map(data => {
|
||||||
for (let i = 0; i < 10; i++) {
|
const isMonster = data.tipo.includes('Monstruo');
|
||||||
deck.push({ ...EVENT_DEFINITIONS[0] });
|
|
||||||
}
|
return {
|
||||||
|
id: `evt_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
type: isMonster ? EVENT_TYPES.MONSTER : EVENT_TYPES.EVENT,
|
||||||
|
name: data.titulo,
|
||||||
|
description: data.descripcion || data.titulo,
|
||||||
|
data: data // Keep full raw data for specific logic (stats, tables)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return shuffleDeck(deck);
|
return shuffleDeck(deck);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class GameEngine {
|
|||||||
this.onShowMessage = null; // New: Generic temporary message UI callback
|
this.onShowMessage = null; // New: Generic temporary message UI callback
|
||||||
this.onEntityHit = null; // New: When entity takes damage
|
this.onEntityHit = null; // New: When entity takes damage
|
||||||
this.onEntityDeath = null; // New: When entity dies
|
this.onEntityDeath = null; // New: When entity dies
|
||||||
|
this.onFloatingText = null; // New: For overhead text feedback
|
||||||
this.onPathChange = null;
|
this.onPathChange = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +69,13 @@ export class GameEngine {
|
|||||||
this.handleEndTurn();
|
this.handleEndTurn();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 6. Listen for Power Phase Events
|
||||||
|
this.turnManager.on('POWER_RESULT', (data) => {
|
||||||
|
if (data.eventTriggered) {
|
||||||
|
setTimeout(() => this.handlePowerEvent(), 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Initial Light Update
|
// Initial Light Update
|
||||||
setTimeout(() => this.updateLighting(), 500);
|
setTimeout(() => this.updateLighting(), 500);
|
||||||
}
|
}
|
||||||
@@ -273,10 +281,21 @@ export class GameEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnMonster(monsterKey, x, y, options = {}) {
|
spawnMonster(monsterKeyOrDef, x, y, options = {}) {
|
||||||
const definition = MONSTER_DEFINITIONS[monsterKey];
|
let definition;
|
||||||
|
let monsterKey;
|
||||||
|
|
||||||
|
if (typeof monsterKeyOrDef === 'string') {
|
||||||
|
definition = MONSTER_DEFINITIONS[monsterKeyOrDef];
|
||||||
|
monsterKey = monsterKeyOrDef;
|
||||||
|
} else {
|
||||||
|
// Dynamic Definition from Card
|
||||||
|
definition = monsterKeyOrDef;
|
||||||
|
monsterKey = definition.name ? definition.name.replace(/\s+/g, '_').toLowerCase() : 'dynamic_monster';
|
||||||
|
}
|
||||||
|
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
console.error(`Monster definition not found: ${monsterKey}`);
|
console.error(`Monster definition not found: ${monsterKeyOrDef}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +389,7 @@ export class GameEngine {
|
|||||||
const targetObj = { x: x, y: y };
|
const targetObj = { x: x, y: y };
|
||||||
const los = this.checkLineOfSightStrict(caster, targetObj);
|
const los = this.checkLineOfSightStrict(caster, targetObj);
|
||||||
if (!los || !los.clear) {
|
if (!los || !los.clear) {
|
||||||
if (this.onShowMessage) this.onShowMessage('Bloqueado', 'No tienes línea de visión.');
|
if (this.onFloatingText) this.onFloatingText(x, y, "Bloqueado", "#ff0000");
|
||||||
// Do NOT cancel targeting, let them try again
|
// Do NOT cancel targeting, let them try again
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -484,8 +503,8 @@ export class GameEngine {
|
|||||||
// Check Pinned Status
|
// Check Pinned Status
|
||||||
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
|
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
|
||||||
if (this.isEntityPinned(clickedEntity)) {
|
if (this.isEntityPinned(clickedEntity)) {
|
||||||
if (this.onShowMessage) {
|
if (this.onFloatingText) {
|
||||||
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.');
|
this.onFloatingText(clickedEntity.x, clickedEntity.y, "¡Trabado!", "#ff4400");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,7 +516,7 @@ export class GameEngine {
|
|||||||
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
|
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
|
||||||
if (this.selectedEntity) {
|
if (this.selectedEntity) {
|
||||||
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
|
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
|
||||||
if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.');
|
if (this.onFloatingText) this.onFloatingText(this.selectedEntity.x, this.selectedEntity.y, "¡Trabado!", "#ff4400");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.planStep(x, y);
|
this.planStep(x, y);
|
||||||
@@ -854,38 +873,23 @@ export class GameEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findSpawnPoints(count) {
|
findSpawnPoints(count) {
|
||||||
const points = [];
|
// Collect all currently available cells (occupiedCells maps "x,y" => tileId)
|
||||||
const startNode = { x: 0, y: 0 };
|
// At the start of the game, this typically contains only the cells of the starting room.
|
||||||
const searchQueue = [startNode];
|
const candidates = [];
|
||||||
const visited = new Set(['0,0']);
|
|
||||||
|
|
||||||
let loops = 0;
|
for (const key of this.dungeon.grid.occupiedCells.keys()) {
|
||||||
while (searchQueue.length > 0 && points.length < count && loops < 200) {
|
const [x, y] = key.split(',').map(Number);
|
||||||
const current = searchQueue.shift();
|
candidates.push({ x, y });
|
||||||
|
|
||||||
if (this.dungeon.grid.isOccupied(current.x, current.y)) {
|
|
||||||
points.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neighbors
|
|
||||||
const neighbors = [
|
|
||||||
{ x: current.x + 1, y: current.y },
|
|
||||||
{ x: current.x - 1, y: current.y },
|
|
||||||
{ x: current.x, y: current.y + 1 },
|
|
||||||
{ x: current.x, y: current.y - 1 }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const n of neighbors) {
|
|
||||||
const key = `${n.x},${n.y}`;
|
|
||||||
if (!visited.has(key)) {
|
|
||||||
visited.add(key);
|
|
||||||
searchQueue.push(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loops++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return points;
|
// 2. Shuffle candidates (Fisher-Yates) to ensure random but valid placement
|
||||||
|
for (let i = candidates.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Return requested amount
|
||||||
|
return candidates.slice(0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
onRoomRevealed(cells) {
|
onRoomRevealed(cells) {
|
||||||
@@ -1282,4 +1286,72 @@ export class GameEngine {
|
|||||||
|
|
||||||
return { clear: !blocked, path, blocker };
|
return { clear: !blocked, path, blocker };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePowerEvent() {
|
||||||
|
if (!this.eventDeck || this.eventDeck.length === 0) {
|
||||||
|
this.eventDeck = createEventDeck();
|
||||||
|
}
|
||||||
|
const card = this.eventDeck.shift();
|
||||||
|
|
||||||
|
console.log(`[Event] Drawn: ${card.name} (${card.type})`);
|
||||||
|
|
||||||
|
if (this.onShowMessage) {
|
||||||
|
// Use specific prefix for Log Routing (if implemented) or just generic
|
||||||
|
this.onShowMessage(`Evento: ${card.name}`, card.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.type === EVENT_TYPES.MONSTER) {
|
||||||
|
let count = 1;
|
||||||
|
const title = card.name.toUpperCase();
|
||||||
|
|
||||||
|
if (title.includes('2D6')) {
|
||||||
|
count = Math.floor(Math.random() * 6) + 1 + Math.floor(Math.random() * 6) + 1;
|
||||||
|
} else if (title.includes('1D6')) {
|
||||||
|
count = Math.floor(Math.random() * 6) + 1;
|
||||||
|
} else if (card.data.mecanica && card.data.mecanica.cantidad_inicial) {
|
||||||
|
count = card.data.mecanica.cantidad_inicial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map stats
|
||||||
|
const rawStats = card.data.estadisticas || {};
|
||||||
|
const mappedStats = {
|
||||||
|
move: rawStats.movimiento || 4,
|
||||||
|
ws: rawStats.habilidad_armas || 3,
|
||||||
|
bs: 0,
|
||||||
|
str: rawStats.fuerza === 'Especial' ? 3 : (rawStats.fuerza === 4 ? 4 : 3), // Simplification
|
||||||
|
toughness: rawStats.resistencia || 3,
|
||||||
|
wounds: rawStats.heridas || 1,
|
||||||
|
attacks: rawStats.ataques || 1,
|
||||||
|
gold: card.data.valor_oro || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fix Strength mapping if int
|
||||||
|
if (typeof rawStats.fuerza === 'number') mappedStats.str = rawStats.fuerza;
|
||||||
|
|
||||||
|
// Map Portrait
|
||||||
|
let portraitStr = 'assets/images/monsters/orc_portrait.png';
|
||||||
|
if (title.includes('MINOTAURO')) portraitStr = 'assets/images/monsters/minotaur_portrait.png'; // If we had it
|
||||||
|
if (title.includes('ARAÑAS')) portraitStr = 'assets/images/monsters/spider_portrait.png';
|
||||||
|
|
||||||
|
const def = {
|
||||||
|
name: card.name,
|
||||||
|
portrait: portraitStr,
|
||||||
|
stats: mappedStats
|
||||||
|
};
|
||||||
|
|
||||||
|
const spots = this.findSpawnPoints(count);
|
||||||
|
spots.forEach(spot => {
|
||||||
|
this.spawnMonster(def, spot.x, spot.y, { skipTurn: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.onEventTriggered) {
|
||||||
|
this.onEventTriggered({ type: 'MONSTER_SPAWN', count: spots.length, message: card.description });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic delay to resume
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.turnManager) this.turnManager.nextPhase();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,29 +98,49 @@ export class MagicSystem {
|
|||||||
|
|
||||||
// 4. Apply Damage to all targets
|
// 4. Apply Damage to all targets
|
||||||
let hits = 0;
|
let hits = 0;
|
||||||
|
let logDetails = [];
|
||||||
|
|
||||||
targetCells.forEach(cell => {
|
targetCells.forEach(cell => {
|
||||||
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
|
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
|
||||||
|
|
||||||
if (monster) {
|
if (monster) {
|
||||||
const damageDice = spell.damageDice || 1;
|
const damageDice = spell.damageDice || 1;
|
||||||
let damageTotal = level;
|
let diceTotal = 0;
|
||||||
|
let rolls = [];
|
||||||
for (let i = 0; i < damageDice; i++) {
|
for (let i = 0; i < damageDice; i++) {
|
||||||
damageTotal += Math.floor(Math.random() * 6) + 1;
|
const r = Math.floor(Math.random() * 6) + 1;
|
||||||
|
rolls.push(r);
|
||||||
|
diceTotal += r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply Damage
|
const damageTotal = level + diceTotal;
|
||||||
CombatMechanics.applyDamage(monster, damageTotal, this.game);
|
|
||||||
|
// We need to know Toughness for Log calculation display
|
||||||
|
// CombatMechanics.applyDamage(monster, damageTotal, this.game) assumes damageTotal is wounds?
|
||||||
|
// Wait, CombatMechanics.applyDamage subtracts amount from wounds directly.
|
||||||
|
// It does NOT calculate toughness reduction.
|
||||||
|
// BUT resolveMeleeAttack does: wounds = damageTotal - defTough.
|
||||||
|
// So Magic rules: Does Fireball ignore Toughness?
|
||||||
|
// WHQ Rulebook: "Strength of spell... deduct Toughness".
|
||||||
|
// So we MUST deduct Toughness here.
|
||||||
|
|
||||||
|
const toughness = monster.stats.toughness || 3;
|
||||||
|
let wounds = damageTotal - toughness;
|
||||||
|
if (wounds < 0) wounds = 0;
|
||||||
|
|
||||||
|
// Apply WOUNDS, not raw damage
|
||||||
|
CombatMechanics.applyDamage(monster, wounds, this.game);
|
||||||
hits++;
|
hits++;
|
||||||
|
|
||||||
|
logDetails.push(`- <b>${monster.name}</b>: Daño ${damageTotal} (Nv${level}+Dado ${diceTotal}) - Res ${toughness} = <b>${wounds} Heridas</b>.`);
|
||||||
|
|
||||||
// Feedback
|
// Feedback
|
||||||
if (this.game.onEntityHit) {
|
if (this.game.onEntityHit) {
|
||||||
this.game.onEntityHit(monster.id);
|
this.game.onEntityHit(monster.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Centralized Combat Feedback
|
// Use Centralized Combat Feedback
|
||||||
window.RENDERER.showCombatFeedback(monster.x, monster.y, damageTotal, true);
|
window.RENDERER.showCombatFeedback(monster.x, monster.y, wounds, true);
|
||||||
|
|
||||||
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage.`);
|
|
||||||
|
|
||||||
// Check Death (Handled by events usually, but ensuring cleanup if needed)
|
// Check Death (Handled by events usually, but ensuring cleanup if needed)
|
||||||
if (monster.currentWounds <= 0 && !monster.isDead) {
|
if (monster.currentWounds <= 0 && !monster.isDead) {
|
||||||
@@ -129,34 +149,26 @@ export class MagicSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
} else {
|
// Log the Spell Event
|
||||||
// Fallback for no renderer (tests?) or race condition
|
if (window.GAME && window.GAME.onShowMessage) {
|
||||||
// Just apply damage immediately logic (duplicated for brevity check)
|
// Hacky access to UI via main.js callback router or we add a new log method to game
|
||||||
let hits = 0;
|
// Let's use onLogEvent if it existed, or just mock a message
|
||||||
targetCells.forEach(cell => {
|
// We can use window.GAME.onCombatResult for a generic log? No, expects object.
|
||||||
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
|
// We'll trust that main.js maps onShowMessage 'Efecto' to log? Or add specific logic.
|
||||||
if (monster) {
|
// Let's format a nice HTML block
|
||||||
const damageDice = spell.damageDice || 1;
|
const details = logDetails.join('<br>');
|
||||||
let damageTotal = level;
|
const msg = `Lanza <b>${spell.name}</b>!<br>${details}`;
|
||||||
for (let i = 0; i < damageDice; i++) {
|
// Prefix with 'Efecto' to trigger main.js log routing
|
||||||
damageTotal += Math.floor(Math.random() * 6) + 1;
|
if (this.game.onShowMessage) this.game.onShowMessage(`Efecto Mágico`, msg);
|
||||||
}
|
|
||||||
CombatMechanics.applyDamage(monster, damageTotal, this.game);
|
|
||||||
hits++;
|
|
||||||
if (this.game.onEntityHit) {
|
|
||||||
this.game.onEntityHit(monster.id);
|
|
||||||
}
|
|
||||||
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage (no renderer).`);
|
|
||||||
if (monster.currentWounds <= 0 && !monster.isDead) {
|
|
||||||
monster.isDead = true;
|
|
||||||
if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback Logic (simplified for brevity, identical calculation)
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, type: 'attack', hits: 1 }; // Return success immediately
|
return { success: true, type: 'attack', hits: 1 };
|
||||||
}
|
}
|
||||||
resolveDefense(caster, spell, targetCells) {
|
resolveDefense(caster, spell, targetCells) {
|
||||||
// Needs a target hero
|
// Needs a target hero
|
||||||
|
|||||||
@@ -60,25 +60,30 @@ export class TurnManager {
|
|||||||
this.currentPowerRoll = roll;
|
this.currentPowerRoll = roll;
|
||||||
console.log(`Power Roll: ${roll}`);
|
console.log(`Power Roll: ${roll}`);
|
||||||
|
|
||||||
let message = "The dungeon is quiet...";
|
let message = "El poder fluye...";
|
||||||
let eventTriggered = false;
|
let eventTriggered = false;
|
||||||
|
|
||||||
if (roll === 1) {
|
if (roll === 1) {
|
||||||
message = "UNEXPECTED EVENT! (Roll of 1)";
|
message = "¡EVENTO DE PODER! (1) (Bypass Temporal)";
|
||||||
eventTriggered = true;
|
eventTriggered = false; // Bypass for now to prevent freeze
|
||||||
this.triggerRandomEvent();
|
// eventTriggered = true;
|
||||||
|
// logic delegated to listeners
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('POWER_RESULT', { roll, message, eventTriggered });
|
this.emit('POWER_RESULT', { roll, message, eventTriggered });
|
||||||
|
|
||||||
// Auto-advance to Hero phase after short delay (game feel)
|
// Auto-advance only if NO event
|
||||||
setTimeout(() => {
|
if (!eventTriggered) {
|
||||||
this.nextPhase();
|
setTimeout(() => {
|
||||||
}, 2000);
|
this.nextPhase();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
console.log("TurnManager waiting for Event Resolution...");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerRandomEvent() {
|
triggerRandomEvent() {
|
||||||
console.warn("TODO: TRIGGER EVENT CARD DRAW");
|
// Deprecated: logic handled by GameEngine listener to card deck
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerExploration() {
|
triggerExploration() {
|
||||||
|
|||||||
55
src/main.js
55
src/main.js
@@ -102,22 +102,39 @@ game.turnManager.on('phase_changed', (phase) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
game.onCombatResult = (log) => {
|
game.onCombatResult = (log) => {
|
||||||
ui.showCombatLog(log);
|
// 1. Format Log Message
|
||||||
|
// Resolve names
|
||||||
// 1. Show Attack Roll on Attacker
|
|
||||||
// Find Attacker pos
|
|
||||||
const attacker = game.heroes.find(h => h.id === log.attackerId) || game.monsters.find(m => m.id === log.attackerId);
|
const attacker = game.heroes.find(h => h.id === log.attackerId) || game.monsters.find(m => m.id === log.attackerId);
|
||||||
|
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
|
||||||
|
|
||||||
|
const atkName = attacker ? attacker.name : '???';
|
||||||
|
const defName = defender ? defender.name : '???';
|
||||||
|
|
||||||
|
let logMsg = `<b>${atkName}</b> ataca a <b>${defName}</b>.<br>`;
|
||||||
|
if (log.hitSuccess) {
|
||||||
|
logMsg += `¡Impacto! (Dado: ${log.hitRoll}). `;
|
||||||
|
if (log.woundsCaused > 0) {
|
||||||
|
logMsg += `Causa <b>${log.woundsCaused}</b> heridas.`;
|
||||||
|
} else {
|
||||||
|
logMsg += `Armadura absorbe el daño.`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logMsg += `Falla (Dado: ${log.hitRoll}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.addLog(logMsg, log.hitSuccess ? 'combat-hit' : 'combat-miss');
|
||||||
|
|
||||||
|
// 2. Show Attack Roll on Attacker (Floating)
|
||||||
if (attacker) {
|
if (attacker) {
|
||||||
const rollColor = log.hitSuccess ? '#00ff00' : '#888888'; // Green vs Gray
|
const rollColor = log.hitSuccess ? '#00ff00' : '#888888';
|
||||||
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
|
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Show Damage on Defender
|
// 3. Show Damage on Defender (Floating)
|
||||||
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
|
|
||||||
if (defender) {
|
if (defender) {
|
||||||
setTimeout(() => { // Slight delay for cause-effect
|
setTimeout(() => { // Slight delay for cause-effect
|
||||||
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
|
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
|
||||||
}, 500);
|
}, 300);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +152,15 @@ game.onEntityHit = (entityId) => {
|
|||||||
|
|
||||||
game.onEntityDeath = (entityId) => {
|
game.onEntityDeath = (entityId) => {
|
||||||
renderer.triggerDeathAnimation(entityId);
|
renderer.triggerDeathAnimation(entityId);
|
||||||
|
// Log death
|
||||||
|
const entity = game.heroes.find(h => h.id === entityId) || game.monsters.find(m => m.id === entityId);
|
||||||
|
if (entity) {
|
||||||
|
ui.addLog(`💀 <b>${entity.name}</b> ha caído.`, 'combat-kill');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
game.onFloatingText = (x, y, text, color) => {
|
||||||
|
renderer.showFloatingText(x, y, text, color);
|
||||||
};
|
};
|
||||||
|
|
||||||
game.onRangedTarget = (targetMonster, losResult) => {
|
game.onRangedTarget = (targetMonster, losResult) => {
|
||||||
@@ -152,12 +178,23 @@ game.onRangedTarget = (targetMonster, losResult) => {
|
|||||||
if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${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.`;
|
if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`;
|
||||||
|
|
||||||
ui.showTemporaryMessage('Objetivo Bloqueado', msg, 1500);
|
// Use floating text on BLOCKER + Log instead of big message
|
||||||
|
const bx = losResult.blocker.entity ? losResult.blocker.entity.x : losResult.blocker.x;
|
||||||
|
const by = losResult.blocker.entity ? losResult.blocker.entity.y : losResult.blocker.y;
|
||||||
|
|
||||||
|
renderer.showFloatingText(bx, by, "Bloqueado", "#ff8800");
|
||||||
|
ui.addLog(msg, 'warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
game.onShowMessage = (title, message, duration) => {
|
game.onShowMessage = (title, message, duration) => {
|
||||||
|
// Filter specific game flow messages to Log instead of popup
|
||||||
|
if (title.startsWith('Turno de') || title.includes('Fase') || title.includes('Efecto')) {
|
||||||
|
ui.addLog(`👉 <b>${title}</b>: ${message}`, 'system');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Default fallback for other messages (e.g. Warnings not covered by floating text)
|
||||||
ui.showTemporaryMessage(title, message, duration);
|
ui.showTemporaryMessage(title, message, duration);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ export class UIManager {
|
|||||||
showModal(t, m, c) { this.feedback.showModal(t, m, c); }
|
showModal(t, m, c) { this.feedback.showModal(t, m, c); }
|
||||||
showConfirm(t, m, c) { this.feedback.showConfirm(t, m, c); }
|
showConfirm(t, m, c) { this.feedback.showConfirm(t, m, c); }
|
||||||
showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); }
|
showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); }
|
||||||
showCombatLog(log) { this.feedback.showCombatLog(log); }
|
showCombatLog(log) { this.feedback.addLogMessage(log.message, log.hitSuccess ? 'combat-hit' : 'combat-miss'); }
|
||||||
|
addLog(message, type) { this.feedback.addLogMessage(message, type); }
|
||||||
showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); }
|
showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); }
|
||||||
hideMonsterCard() { this.cards.hideMonsterCard(); }
|
hideMonsterCard() { this.cards.hideMonsterCard(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,79 @@
|
|||||||
export class FeedbackUI {
|
export class FeedbackUI {
|
||||||
constructor(parentContainer, game) {
|
constructor(parentContainer, game) {
|
||||||
this.parentContainer = parentContainer;
|
this.parentContainer = parentContainer;
|
||||||
this.game = game; // Needed for resolving hero names/ids in logs?
|
this.game = game;
|
||||||
|
|
||||||
this.combatLogContainer = null;
|
this.logContainer = null;
|
||||||
this.initCombatLogContainer();
|
this.initLogContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
initCombatLogContainer() {
|
initLogContainer() {
|
||||||
this.combatLogContainer = document.createElement('div');
|
this.logContainer = document.createElement('div');
|
||||||
Object.assign(this.combatLogContainer.style, {
|
Object.assign(this.logContainer.style, {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '140px', // Below the top status panel
|
top: '20%', // Leave space for top HUD
|
||||||
left: '50%',
|
right: '20px',
|
||||||
transform: 'translateX(-50%)',
|
width: '350px',
|
||||||
|
height: '60vh', // Fixed height or max height? User said "muy pequeño". Let's give it good vertical space.
|
||||||
|
maxHeight: 'none',
|
||||||
|
overflowY: 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start', // Align text to left
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none', // Allow clicking through if needed, but 'auto' for scroll?
|
||||||
width: '100%',
|
// We need pointerEvents auto for scrolling.
|
||||||
maxWidth: '600px',
|
pointerEvents: 'auto',
|
||||||
zIndex: '500' // Below modals
|
zIndex: '400',
|
||||||
|
fontFamily: '"Cinzel", serif',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: '#444 #222'
|
||||||
});
|
});
|
||||||
this.parentContainer.appendChild(this.combatLogContainer);
|
|
||||||
|
// Add a subtle background
|
||||||
|
this.logContainer.style.background = 'linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0))';
|
||||||
|
this.logContainer.style.padding = '10px';
|
||||||
|
this.logContainer.style.borderRadius = '8px';
|
||||||
|
this.logContainer.style.borderRight = '2px solid #555';
|
||||||
|
|
||||||
|
this.parentContainer.appendChild(this.logContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLogMessage(message, type = 'info') {
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
Object.assign(entry.style, {
|
||||||
|
width: '100%',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#ccc',
|
||||||
|
textShadow: '1px 1px 0 #000',
|
||||||
|
opacity: '0',
|
||||||
|
transition: 'opacity 0.3s',
|
||||||
|
lineHeight: '1.4'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color coding based on type
|
||||||
|
if (type === 'combat-hit') entry.style.color = '#ff6666';
|
||||||
|
if (type === 'combat-miss') entry.style.color = '#aaaaaa';
|
||||||
|
if (type === 'combat-kill') entry.style.color = '#ff3333';
|
||||||
|
if (type === 'success') entry.style.color = '#66ff66';
|
||||||
|
if (type === 'warning') entry.style.color = '#ffcc00';
|
||||||
|
if (type === 'system') entry.style.color = '#88ccff';
|
||||||
|
|
||||||
|
entry.innerHTML = message;
|
||||||
|
|
||||||
|
this.logContainer.appendChild(entry);
|
||||||
|
|
||||||
|
// Auto scroll to bottom
|
||||||
|
this.logContainer.scrollTop = this.logContainer.scrollHeight;
|
||||||
|
|
||||||
|
// Fade In
|
||||||
|
requestAnimationFrame(() => { entry.style.opacity = '1'; });
|
||||||
|
|
||||||
|
// Optional: Fade out very old messages? Or keep history?
|
||||||
|
// Let's keep history for now, maybe limit children coune
|
||||||
|
if (this.logContainer.children.length > 50) {
|
||||||
|
this.logContainer.removeChild(this.logContainer.firstChild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showModal(title, message, onClose) {
|
showModal(title, message, onClose) {
|
||||||
@@ -56,7 +107,7 @@ export class FeedbackUI {
|
|||||||
backgroundColor: '#444', color: '#fff', border: '1px solid #888'
|
backgroundColor: '#444', color: '#fff', border: '1px solid #888'
|
||||||
});
|
});
|
||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
if (overlay.parentNode /** Checks if attached */) this.parentContainer.removeChild(overlay);
|
if (overlay.parentNode) this.parentContainer.removeChild(overlay);
|
||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
};
|
};
|
||||||
content.appendChild(btn);
|
content.appendChild(btn);
|
||||||
@@ -144,53 +195,4 @@ export class FeedbackUI {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
showCombatLog(log) {
|
|
||||||
const isHit = log.hitSuccess;
|
|
||||||
const color = isHit ? '#ff4444' : '#aaaaaa';
|
|
||||||
|
|
||||||
let detailHtml = '';
|
|
||||||
if (isHit) {
|
|
||||||
if (log.woundsCaused > 0) {
|
|
||||||
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
|
|
||||||
} else {
|
|
||||||
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We create a new log element or update a singleton?
|
|
||||||
// The original logic updated a SINGLE notification area.
|
|
||||||
// Let's create a transient toast style log here, appending to container.
|
|
||||||
|
|
||||||
const logItem = document.createElement('div');
|
|
||||||
Object.assign(logItem.style, {
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.9)', padding: '15px', border: `2px solid ${color}`,
|
|
||||||
borderRadius: '5px', textAlign: 'center', minWidth: '250px', marginBottom: '10px',
|
|
||||||
fontFamily: '"Cinzel", serif', opacity: '0', transition: 'opacity 0.3s'
|
|
||||||
});
|
|
||||||
|
|
||||||
logItem.innerHTML = `
|
|
||||||
<div style="font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
|
|
||||||
${detailHtml}
|
|
||||||
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Clear previous logs to act like the single notification area of before, OR stack them?
|
|
||||||
// Original behavior was overwrite `innerHTML`. I should stick to that to avoid spam.
|
|
||||||
// So I will clear `combatLogContainer` before adding.
|
|
||||||
this.combatLogContainer.innerHTML = '';
|
|
||||||
this.combatLogContainer.appendChild(logItem);
|
|
||||||
|
|
||||||
// Fade in
|
|
||||||
requestAnimationFrame(() => { logItem.style.opacity = '1'; });
|
|
||||||
|
|
||||||
// Fade out
|
|
||||||
setTimeout(() => {
|
|
||||||
logItem.style.opacity = '0';
|
|
||||||
// We don't remove immediately to avoid layout jumps if another comes in,
|
|
||||||
// but we cleared logic above.
|
|
||||||
}, 3500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user