Refactor: Notifications, Event System (Bypass), and Logging improvements

This commit is contained in:
2026-01-09 20:18:09 +01:00
parent 613fa843ee
commit e22cd071c4
8 changed files with 409 additions and 172 deletions

View 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."
]
}
];

View File

@@ -1,30 +1,24 @@
import { EVENT_CARDS_DATA } from './EventCards.js';
export const EVENT_TYPES = {
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 = () => {
// As per user request: 10 copies of the same card for now
const deck = [];
for (let i = 0; i < 10; i++) {
deck.push({ ...EVENT_DEFINITIONS[0] });
}
// Convert raw JSON data to engine-compatible card objects
const deck = EVENT_CARDS_DATA.map(data => {
const isMonster = data.tipo.includes('Monstruo');
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);
};

View File

@@ -34,6 +34,7 @@ export class GameEngine {
this.onShowMessage = null; // New: Generic temporary message UI callback
this.onEntityHit = null; // New: When entity takes damage
this.onEntityDeath = null; // New: When entity dies
this.onFloatingText = null; // New: For overhead text feedback
this.onPathChange = null;
}
@@ -68,6 +69,13 @@ export class GameEngine {
this.handleEndTurn();
});
// 6. Listen for Power Phase Events
this.turnManager.on('POWER_RESULT', (data) => {
if (data.eventTriggered) {
setTimeout(() => this.handlePowerEvent(), 1500);
}
});
// Initial Light Update
setTimeout(() => this.updateLighting(), 500);
}
@@ -273,10 +281,21 @@ export class GameEngine {
}
}
spawnMonster(monsterKey, x, y, options = {}) {
const definition = MONSTER_DEFINITIONS[monsterKey];
spawnMonster(monsterKeyOrDef, x, y, options = {}) {
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) {
console.error(`Monster definition not found: ${monsterKey}`);
console.error(`Monster definition not found: ${monsterKeyOrDef}`);
return;
}
@@ -370,7 +389,7 @@ export class GameEngine {
const targetObj = { x: x, y: y };
const los = this.checkLineOfSightStrict(caster, targetObj);
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
return;
}
@@ -484,8 +503,8 @@ export class GameEngine {
// Check Pinned Status
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
if (this.isEntityPinned(clickedEntity)) {
if (this.onShowMessage) {
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.');
if (this.onFloatingText) {
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)
if (this.selectedEntity) {
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.');
if (this.onFloatingText) this.onFloatingText(this.selectedEntity.x, this.selectedEntity.y, Trabado!", "#ff4400");
return;
}
this.planStep(x, y);
@@ -854,38 +873,23 @@ export class GameEngine {
}
findSpawnPoints(count) {
const points = [];
const startNode = { x: 0, y: 0 };
const searchQueue = [startNode];
const visited = new Set(['0,0']);
// Collect all currently available cells (occupiedCells maps "x,y" => tileId)
// At the start of the game, this typically contains only the cells of the starting room.
const candidates = [];
let loops = 0;
while (searchQueue.length > 0 && points.length < count && loops < 200) {
const current = searchQueue.shift();
if (this.dungeon.grid.isOccupied(current.x, current.y)) {
points.push(current);
}
// Neighbors
const neighbors = [
{ x: current.x + 1, y: current.y },
{ x: current.x - 1, y: current.y },
{ x: current.x, y: current.y + 1 },
{ x: current.x, y: current.y - 1 }
];
for (const n of neighbors) {
const key = `${n.x},${n.y}`;
if (!visited.has(key)) {
visited.add(key);
searchQueue.push(n);
}
}
loops++;
for (const key of this.dungeon.grid.occupiedCells.keys()) {
const [x, y] = key.split(',').map(Number);
candidates.push({ x, y });
}
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) {
@@ -1282,4 +1286,72 @@ export class GameEngine {
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);
}
}

View File

@@ -98,29 +98,49 @@ export class MagicSystem {
// 4. Apply Damage to all targets
let hits = 0;
let logDetails = [];
targetCells.forEach(cell => {
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
if (monster) {
const damageDice = spell.damageDice || 1;
let damageTotal = level;
let diceTotal = 0;
let rolls = [];
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
CombatMechanics.applyDamage(monster, damageTotal, this.game);
const damageTotal = level + diceTotal;
// 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++;
logDetails.push(`- <b>${monster.name}</b>: Daño ${damageTotal} (Nv${level}+Dado ${diceTotal}) - Res ${toughness} = <b>${wounds} Heridas</b>.`);
// Feedback
if (this.game.onEntityHit) {
this.game.onEntityHit(monster.id);
}
// Use Centralized Combat Feedback
window.RENDERER.showCombatFeedback(monster.x, monster.y, damageTotal, true);
console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage.`);
window.RENDERER.showCombatFeedback(monster.x, monster.y, wounds, true);
// Check Death (Handled by events usually, but ensuring cleanup if needed)
if (monster.currentWounds <= 0 && !monster.isDead) {
@@ -129,34 +149,26 @@ export class MagicSystem {
}
}
});
});
} else {
// Fallback for no renderer (tests?) or race condition
// Just apply damage immediately logic (duplicated for brevity check)
let hits = 0;
targetCells.forEach(cell => {
const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead);
if (monster) {
const damageDice = spell.damageDice || 1;
let damageTotal = level;
for (let i = 0; i < damageDice; i++) {
damageTotal += Math.floor(Math.random() * 6) + 1;
}
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);
}
// Log the Spell Event
if (window.GAME && window.GAME.onShowMessage) {
// Hacky access to UI via main.js callback router or we add a new log method to game
// Let's use onLogEvent if it existed, or just mock a message
// We can use window.GAME.onCombatResult for a generic log? No, expects object.
// We'll trust that main.js maps onShowMessage 'Efecto' to log? Or add specific logic.
// Let's format a nice HTML block
const details = logDetails.join('<br>');
const msg = `Lanza <b>${spell.name}</b>!<br>${details}`;
// Prefix with 'Efecto' to trigger main.js log routing
if (this.game.onShowMessage) this.game.onShowMessage(`Efecto Mágico`, msg);
}
});
} 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) {
// Needs a target hero

View File

@@ -60,25 +60,30 @@ export class TurnManager {
this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`);
let message = "The dungeon is quiet...";
let message = "El poder fluye...";
let eventTriggered = false;
if (roll === 1) {
message = "UNEXPECTED EVENT! (Roll of 1)";
eventTriggered = true;
this.triggerRandomEvent();
message = "¡EVENTO DE PODER! (1) (Bypass Temporal)";
eventTriggered = false; // Bypass for now to prevent freeze
// eventTriggered = true;
// logic delegated to listeners
}
this.emit('POWER_RESULT', { roll, message, eventTriggered });
// Auto-advance to Hero phase after short delay (game feel)
setTimeout(() => {
this.nextPhase();
}, 2000);
// Auto-advance only if NO event
if (!eventTriggered) {
setTimeout(() => {
this.nextPhase();
}, 2000);
} else {
console.log("TurnManager waiting for Event Resolution...");
}
}
triggerRandomEvent() {
console.warn("TODO: TRIGGER EVENT CARD DRAW");
// Deprecated: logic handled by GameEngine listener to card deck
}
triggerExploration() {

View File

@@ -102,22 +102,39 @@ game.turnManager.on('phase_changed', (phase) => {
});
game.onCombatResult = (log) => {
ui.showCombatLog(log);
// 1. Show Attack Roll on Attacker
// Find Attacker pos
// 1. Format Log Message
// Resolve names
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) {
const rollColor = log.hitSuccess ? '#00ff00' : '#888888'; // Green vs Gray
const rollColor = log.hitSuccess ? '#00ff00' : '#888888';
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
}
// 2. Show Damage on Defender
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
// 3. Show Damage on Defender (Floating)
if (defender) {
setTimeout(() => { // Slight delay for cause-effect
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
}, 500);
}, 300);
}
};
@@ -135,6 +152,15 @@ game.onEntityHit = (entityId) => {
game.onEntityDeath = (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) => {
@@ -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 === '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) => {
// 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);
};

View File

@@ -111,7 +111,8 @@ export class UIManager {
showModal(t, m, c) { this.feedback.showModal(t, m, c); }
showConfirm(t, m, c) { this.feedback.showConfirm(t, m, c); }
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); }
hideMonsterCard() { this.cards.hideMonsterCard(); }
}

View File

@@ -1,28 +1,79 @@
export class FeedbackUI {
constructor(parentContainer, game) {
this.parentContainer = parentContainer;
this.game = game; // Needed for resolving hero names/ids in logs?
this.game = game;
this.combatLogContainer = null;
this.initCombatLogContainer();
this.logContainer = null;
this.initLogContainer();
}
initCombatLogContainer() {
this.combatLogContainer = document.createElement('div');
Object.assign(this.combatLogContainer.style, {
initLogContainer() {
this.logContainer = document.createElement('div');
Object.assign(this.logContainer.style, {
position: 'absolute',
top: '140px', // Below the top status panel
left: '50%',
transform: 'translateX(-50%)',
top: '20%', // Leave space for top HUD
right: '20px',
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',
flexDirection: 'column',
alignItems: 'center',
pointerEvents: 'none',
width: '100%',
maxWidth: '600px',
zIndex: '500' // Below modals
alignItems: 'flex-start', // Align text to left
pointerEvents: 'none', // Allow clicking through if needed, but 'auto' for scroll?
// We need pointerEvents auto for scrolling.
pointerEvents: 'auto',
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) {
@@ -56,7 +107,7 @@ export class FeedbackUI {
backgroundColor: '#444', color: '#fff', border: '1px solid #888'
});
btn.onclick = () => {
if (overlay.parentNode /** Checks if attached */) this.parentContainer.removeChild(overlay);
if (overlay.parentNode) this.parentContainer.removeChild(overlay);
if (onClose) onClose();
};
content.appendChild(btn);
@@ -144,53 +195,4 @@ export class FeedbackUI {
}, 500);
}, 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);
}
}