diff --git a/src/engine/data/Heroes.js b/src/engine/data/Heroes.js index 116d388..6887522 100644 --- a/src/engine/data/Heroes.js +++ b/src/engine/data/Heroes.js @@ -38,6 +38,7 @@ export const HERO_DEFINITIONS = { stats: { move: 4, ws: 4, + bs: 4, // Added for Bow to_hit_missile: 4, // 4+ to hit with ranged str: 3, toughness: 3, diff --git a/src/engine/dungeon/GridSystem.js b/src/engine/dungeon/GridSystem.js index bcb3524..da3cb7c 100644 --- a/src/engine/dungeon/GridSystem.js +++ b/src/engine/dungeon/GridSystem.js @@ -16,6 +16,11 @@ export class GridSystem { this.tiles = []; } + isWall(x, y) { + const key = `${x},${y}`; + return !this.occupiedCells.has(key); + } + /** * Checks if a specific VARIANT can be placed at anchorX, anchorY. * Does NOT rotate anything. Assumes variant is already the correct shape. diff --git a/src/engine/game/CombatMechanics.js b/src/engine/game/CombatMechanics.js index b43652b..9456d49 100644 --- a/src/engine/game/CombatMechanics.js +++ b/src/engine/game/CombatMechanics.js @@ -100,6 +100,73 @@ export class CombatMechanics { return log; } + static resolveRangedAttack(attacker, defender, gameEngine = null) { + const log = { + attackerId: attacker.id, + defenderId: defender.id, + hitSuccess: false, + damageTotal: 0, + woundsCaused: 0, + defenderDied: false, + message: '' + }; + + // 1. Roll To Hit (BS vs WS) + // Use attacker BS or default to WS if missing (fallback). + const attackerBS = attacker.stats.bs || attacker.stats.ws; + const defenderWS = defender.stats.ws; + + const toHitTarget = this.getToHitTarget(attackerBS, defenderWS); + const hitRoll = this.rollD6(); + log.hitRoll = hitRoll; + log.toHitTarget = toHitTarget; + + if (hitRoll === 1) { + log.hitSuccess = false; + log.message = `${attacker.name} dispara y falla (1 es fallo automático)`; + return log; + } + + if (hitRoll < toHitTarget) { + log.hitSuccess = false; + log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`; + return log; + } + + log.hitSuccess = true; + + // 2. Roll Damage + // Elf Bow Strength = 3 + const weaponStrength = 3; + const damageRoll = this.rollD6(); + const damageTotal = weaponStrength + damageRoll; + log.damageRoll = damageRoll; + log.damageTotal = damageTotal; + + // 3. Compare vs Toughness + const defTough = defender.stats.toughness || 1; + const wounds = Math.max(0, damageTotal - defTough); + + log.woundsCaused = wounds; + + // 4. Build Message + if (wounds > 0) { + log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`; + } else { + log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`; + } + + // 5. Apply Damage + this.applyDamage(defender, wounds, gameEngine); + + if (defender.isDead) { + log.defenderDied = true; + log.message += ` ¡${defender.name} ha muerto!`; + } + + return log; + } + static getToHitTarget(attackerWS, defenderWS) { // Adjust for 0-index array const row = attackerWS - 1; diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index 57079e3..9dbe042 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -25,7 +25,9 @@ export class GameEngine { this.onEntityUpdate = null; this.onEntityMove = null; this.onEntitySelect = null; + this.onRangedTarget = null; // New: For ranged targeting visualization this.onEntityActive = null; // New: When entity starts/ends turn + this.onShowMessage = null; // New: Generic temporary message UI callback this.onEntityHit = null; // New: When entity takes damage this.onEntityDeath = null; // New: When entity dies this.onPathChange = null; @@ -146,6 +148,27 @@ export class GameEngine { } onCellClick(x, y) { + // RANGED TARGETING LOGIC + if (this.targetingMode === 'ranged') { + const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null; + if (clickedMonster) { + if (this.selectedEntity && this.selectedEntity.type === 'hero') { + const los = this.checkLineOfSightStrict(this.selectedEntity, clickedMonster); + this.selectedMonster = clickedMonster; + if (this.onRangedTarget) { + this.onRangedTarget(clickedMonster, los); + } + } + } else { + // Determine if we clicked something else relevant or empty space + // If clicked self (hero), maybe cancel? + // For now, any non-monster click cancels targeting + // Unless it's just a UI click (handled by DOM) + this.cancelTargeting(); + } + return; + } + // 1. Identify clicked contents const clickedHero = this.heroes.find(h => h.x === x && h.y === y); const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null; @@ -186,6 +209,15 @@ export class GameEngine { if (this.onEntitySelect) { this.onEntitySelect(clickedEntity.id, true); } + + // Check Pinned Status + if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') { + if (this.isEntityPinned(clickedEntity)) { + if (this.onShowMessage) { + this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.'); + } + } + } } } return; @@ -193,6 +225,10 @@ export class GameEngine { // 2. PLAN MOVEMENT (If entity selected and we clicked empty space) if (this.selectedEntity) { + if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) { + if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.'); + return; + } this.planStep(x, y); } } @@ -223,6 +259,61 @@ export class GameEngine { return { success: true, result }; } + isEntityPinned(entity) { + if (!this.monsters || this.monsters.length === 0) return false; + + return this.monsters.some(m => { + if (m.isDead) return false; + const dx = Math.abs(entity.x - m.x); + const dy = Math.abs(entity.y - m.y); + + // 1. Must be Adjacent (Manhattan distance 1) + if (dx + dy !== 1) return false; + + // 2. Check Logical Connectivity (Wall check) + const grid = this.dungeon.grid; + const key1 = `${entity.x},${entity.y}`; + const key2 = `${m.x},${m.y}`; + + const data1 = grid.cellData.get(key1); + const data2 = grid.cellData.get(key2); + + if (!data1 || !data2) return false; + + // Same Tile -> Connected + if (data1.tileId === data2.tileId) return true; + + // Different Tile -> Must be connected by a Door + const isDoor1 = grid.doorCells.has(key1); + const isDoor2 = grid.doorCells.has(key2); + + if (!isDoor1 && !isDoor2) return false; + + return true; + }); + } + + performRangedAttack(targetMonsterId) { + const hero = this.selectedEntity; + const monster = this.monsters.find(m => m.id === targetMonsterId); + + if (!hero || !monster) return null; + + if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' }; + if (hero.hasAttacked) return { success: false, reason: 'cooldown' }; + if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' }; + + // LOS Check should be done before calling this, but we can double check or assume UI did it. + // For simplicity, we execute the attack here assuming validation passed. + + const result = CombatMechanics.resolveRangedAttack(hero, monster, this); + hero.hasAttacked = true; + + if (this.onCombatResult) this.onCombatResult(result); + + return { success: true, result }; + } + deselectEntity() { if (!this.selectedEntity) return; const id = this.selectedEntity.id; @@ -546,4 +637,220 @@ export class GameEngine { } return false; } + startRangedTargeting() { + this.targetingMode = 'ranged'; + console.log("Ranged Targeting Mode ON"); + } + + cancelTargeting() { + this.targetingMode = null; + if (this.onRangedTarget) { + this.onRangedTarget(null, null); + } + } + + checkLineOfSight(hero, target) { + // Robust Grid Traversal (Amanatides & Woo) + const x = hero.x + 0.5; + const y = hero.y + 0.5; + const endX = target.x + 0.5; + const endY = target.y + 0.5; + + const dx = endX - x; + const dy = endY - y; + + let currentX = Math.floor(x); + let currentY = Math.floor(y); + const targetX = Math.floor(endX); + const targetY = Math.floor(endY); + + const stepX = Math.sign(dx); + const stepY = Math.sign(dy); + + const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity; + const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity; + + let tMaxX = stepX > 0 ? (Math.floor(x) + 1 - x) * tDeltaX : (x - Math.floor(x)) * tDeltaX; + let tMaxY = stepY > 0 ? (Math.floor(y) + 1 - y) * tDeltaY : (y - Math.floor(y)) * tDeltaY; + + if (isNaN(tMaxX)) tMaxX = Infinity; + if (isNaN(tMaxY)) tMaxY = Infinity; + + const path = []; + let blocked = false; + + // Safety limit + const maxSteps = Math.abs(targetX - currentX) + Math.abs(targetY - currentY) + 20; + + for (let i = 0; i < maxSteps; i++) { + path.push({ x: currentX, y: currentY }); + + if (!(currentX === hero.x && currentY === hero.y) && !(currentX === target.x && currentY === target.y)) { + if (this.dungeon.grid.isWall(currentX, currentY)) { + blocked = true; + break; + } + + const blockerMonster = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id); + if (blockerMonster) { blocked = true; break; } + + const blockerHero = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id); + if (blockerHero) { blocked = true; break; } + } + + if (currentX === targetX && currentY === targetY) { + break; + } + + if (tMaxX < tMaxY) { + tMaxX += tDeltaX; + currentX += stepX; + } else { + tMaxY += tDeltaY; + currentY += stepY; + } + } + + return { clear: !blocked, path: path }; + } + checkLineOfSightPermissive(hero, target) { + const startX = hero.x + 0.5; + const startY = hero.y + 0.5; + const endX = target.x + 0.5; + const endY = target.y + 0.5; + + const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2); + const steps = Math.ceil(dist * 5); + + const path = []; + const visited = new Set(); + let blocked = false; + let blocker = null; + + const MARGIN = 0.2; + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = startX + (endX - startX) * t; + const y = startY + (endY - startY) * t; + + const gx = Math.floor(x); + const gy = Math.floor(y); + const key = `${gx},${gy}`; + + if (!visited.has(key)) { + path.push({ x: gx, y: gy, xWorld: x, yWorld: y }); + visited.add(key); + } + + if ((gx === hero.x && gy === hero.y) || (gx === target.x && gy === target.y)) { + continue; + } + + if (this.dungeon.grid.isWall(gx, gy)) { + const lx = x - gx; + const ly = y - gy; + if (lx > MARGIN && lx < (1 - MARGIN) && ly > MARGIN && ly < (1 - MARGIN)) { + blocked = true; + blocker = { type: 'wall', x: gx, y: gy }; + console.log(`[LOS] Blocked by WALL at ${gx},${gy}`); + break; + } + } + + const blockerMonster = this.monsters.find(m => m.x === gx && m.y === gy && !m.isDead && m.id !== target.id); + if (blockerMonster) { + blocked = true; + blocker = { type: 'monster', entity: blockerMonster }; + console.log(`[LOS] Blocked by MONSTER: ${blockerMonster.name}`); + break; + } + + const blockerHero = this.heroes.find(h => h.x === gx && h.y === gy && h.id !== hero.id); + if (blockerHero) { + blocked = true; + blocker = { type: 'hero', entity: blockerHero }; + console.log(`[LOS] Blocked by HERO: ${blockerHero.name}`); + break; + } + } + + return { clear: !blocked, path: path, blocker: blocker }; + } + checkLineOfSightStrict(hero, target) { + // STRICT Grid Traversal (Amanatides & Woo) + const x1 = hero.x + 0.5; + const y1 = hero.y + 0.5; + const x2 = target.x + 0.5; + const y2 = target.y + 0.5; + + let currentX = Math.floor(x1); + let currentY = Math.floor(y1); + const endX = Math.floor(x2); + const endY = Math.floor(y2); + + const dx = x2 - x1; + const dy = y2 - y1; + const stepX = Math.sign(dx); + const stepY = Math.sign(dy); + + const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity; + const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity; + + let tMaxX = stepX > 0 ? (Math.floor(x1) + 1 - x1) * tDeltaX : (x1 - Math.floor(x1)) * tDeltaX; + let tMaxY = stepY > 0 ? (Math.floor(y1) + 1 - y1) * tDeltaY : (y1 - Math.floor(y1)) * tDeltaY; + + if (isNaN(tMaxX)) tMaxX = Infinity; + if (isNaN(tMaxY)) tMaxY = Infinity; + + const path = []; + let blocked = false; + let blocker = null; + + const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20; + + for (let i = 0; i < maxSteps; i++) { + path.push({ x: currentX, y: currentY }); + + const isStart = (currentX === hero.x && currentY === hero.y); + const isEnd = (currentX === target.x && currentY === target.y); + + if (!isStart && !isEnd) { + if (this.dungeon.grid.isWall(currentX, currentY)) { + blocked = true; + blocker = { type: 'wall', x: currentX, y: currentY }; + console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`); + break; + } + + const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id); + if (m) { + blocked = true; + blocker = { type: 'monster', entity: m }; + console.log(`[LOS] Blocked by MONSTER: ${m.name}`); + break; + } + + const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id); + if (h) { + blocked = true; + blocker = { type: 'hero', entity: h }; + console.log(`[LOS] Blocked by HERO: ${h.name}`); + break; + } + } + + if (currentX === endX && currentY === endY) break; + + if (tMaxX < tMaxY) { + tMaxX += tDeltaX; + currentX += stepX; + } else { + tMaxY += tDeltaY; + currentY += stepY; + } + } + + return { clear: !blocked, path, blocker }; + } } diff --git a/src/main.js b/src/main.js index d0b1b32..72ebc9e 100644 --- a/src/main.js +++ b/src/main.js @@ -115,6 +115,30 @@ game.onEntityDeath = (entityId) => { renderer.triggerDeathAnimation(entityId); }; +game.onRangedTarget = (targetMonster, losResult) => { + // 1. Draw Visuals + renderer.showRangedTargeting(game.selectedEntity, targetMonster, losResult); + + // 2. UI + if (targetMonster && losResult && losResult.clear) { + ui.showRangedAttackUI(targetMonster); + } else { + ui.hideMonsterCard(); + if (targetMonster && losResult && !losResult.clear && losResult.blocker) { + let msg = 'Línea de visión bloqueada.'; + if (losResult.blocker.type === 'hero') msg = `Bloqueado por aliado: ${losResult.blocker.entity.name}`; + if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`; + if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`; + + ui.showTemporaryMessage('Objetivo Bloqueado', msg, 1500); + } + } +}; + +game.onShowMessage = (title, message, duration) => { + ui.showTemporaryMessage(title, message, duration); +}; + // game.onEntitySelect is now handled by UIManager to wrap the renderer call renderer.onHeroFinishedMove = (x, y) => { diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index fcd87d2..b144e6a 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -44,6 +44,9 @@ export class GameRenderer { this.highlightGroup = new THREE.Group(); this.scene.add(this.highlightGroup); + this.rangedGroup = new THREE.Group(); + this.scene.add(this.rangedGroup); + this.entities = new Map(); } @@ -1055,4 +1058,76 @@ export class GameRenderer { } } + clearRangedTargeting() { + if (this.rangedGroup) { + while (this.rangedGroup.children.length > 0) { + const child = this.rangedGroup.children[0]; + this.rangedGroup.remove(child); + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (Array.isArray(child.material)) child.material.forEach(m => m.dispose()); + else child.material.dispose(); + } + } + } + } + + showRangedTargeting(hero, monster, losResult) { + this.clearRangedTargeting(); + if (!hero || !monster || !losResult) return; + + // 1. Orange Fluorescence Ring on Monster + const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0xFFA500, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.8 + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.set(monster.x, 0.05, -monster.y); + this.rangedGroup.add(ring); + + // 2. Dashed Line logic (Center to Center at approx waist height) + const points = []; + points.push(new THREE.Vector3(hero.x, 0.8, -hero.y)); + points.push(new THREE.Vector3(monster.x, 0.8, -monster.y)); + + const lineGeo = new THREE.BufferGeometry().setFromPoints(points); + const lineMat = new THREE.LineDashedMaterial({ + color: losResult.clear ? 0x00FF00 : 0xFF0000, + dashSize: 0.2, + gapSize: 0.1, + }); + + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this.rangedGroup.add(line); + + // 3. Blocker Visualization (Red Ring) + if (!losResult.clear && losResult.blocker) { + const b = losResult.blocker; + // If blocker is Entity (Hero/Monster), show bright red ring + if (b.type === 'hero' || b.type === 'monster') { + const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32); + const blockRingMat = new THREE.MeshBasicMaterial({ + color: 0xFF0000, + side: THREE.DoubleSide, + transparent: true, + opacity: 1.0, + depthTest: false // Always visible on top + }); + const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat); + blockRing.rotation.x = -Math.PI / 2; + + const bx = b.entity ? b.entity.x : b.x; + const by = b.entity ? b.entity.y : b.y; + + blockRing.position.set(bx, 0.1, -by); + this.rangedGroup.add(blockRing); + } + // Walls are implicit (Line just turns red and stops/passes through) + } + } } diff --git a/src/view/UIManager.js b/src/view/UIManager.js index efc36e2..2c3bddb 100644 --- a/src/view/UIManager.js +++ b/src/view/UIManager.js @@ -515,6 +515,42 @@ export class UIManager { }); card.appendChild(statsGrid); + + // Ranged Attack Button (Elf Only) + if (hero.key === 'elf') { + const isPinned = this.game.isEntityPinned(hero); + const hasAttacked = hero.hasAttacked; + + const bowBtn = document.createElement('button'); + bowBtn.textContent = hasAttacked ? '🏹 YA DISPARADO' : '🏹 DISPARAR ARCO'; + bowBtn.style.width = '100%'; + bowBtn.style.padding = '8px'; + bowBtn.style.marginTop = '8px'; + + const isDisabled = isPinned || hasAttacked; + + bowBtn.style.backgroundColor = isDisabled ? '#555' : '#2E8B57'; // SeaGreen + bowBtn.style.color = '#fff'; + bowBtn.style.border = '1px solid #fff'; + bowBtn.style.borderRadius = '4px'; + bowBtn.style.fontFamily = '"Cinzel", serif'; + bowBtn.style.cursor = isDisabled ? 'not-allowed' : 'pointer'; + + if (isPinned) { + bowBtn.title = "¡Estás trabado en combate cuerpo a cuerpo!"; + } else if (hasAttacked) { + bowBtn.title = "Ya has atacado en esta fase."; + } else { + bowBtn.onclick = (e) => { + e.stopPropagation(); // Prevent card click propagation if any + this.game.startRangedTargeting(); + // Provide immediate feedback? + this.showModal('Modo Disparo', 'Selecciona un enemigo visible para disparar.'); + }; + } + card.appendChild(bowBtn); + } + card.dataset.heroId = hero.id; return card; @@ -719,6 +755,40 @@ export class UIManager { this.cardsContainer.appendChild(this.attackButton); } + showRangedAttackUI(monster) { + this.showMonsterCard(monster); + + if (this.attackButton) { + this.attackButton.textContent = '🏹 DISPARAR'; + this.attackButton.style.backgroundColor = '#2E8B57'; + this.attackButton.style.border = '2px solid #32CD32'; + + this.attackButton.onclick = () => { + const result = this.game.performRangedAttack(monster.id); + if (result && result.success) { + this.game.cancelTargeting(); + this.hideMonsterCard(); // Hide UI + // Also clear renderer + this.game.deselectEntity(); // Deselect hero too? "desparecerá todo". + // Let's interpret "desaparecerá todo" as targeting visuals and Shoot button. + // But usually in game we keep hero selected. + // If we deselect everything: + // this.game.deselectEntity(); + // Let's keep hero selected for flow, but clear targeting. + } + }; + + this.attackButton.onmouseenter = () => { + this.attackButton.style.backgroundColor = '#3CB371'; + this.attackButton.style.transform = 'scale(1.05)'; + }; + this.attackButton.onmouseleave = () => { + this.attackButton.style.backgroundColor = '#2E8B57'; + this.attackButton.style.transform = 'scale(1)'; + }; + } + } + hideMonsterCard() { if (this.currentMonsterCard && this.currentMonsterCard.parentNode) { this.cardsContainer.removeChild(this.currentMonsterCard); @@ -1022,7 +1092,7 @@ export class UIManager { this.phaseInfo.style.fontSize = '20px'; this.phaseInfo.style.textAlign = 'center'; this.phaseInfo.style.textTransform = 'uppercase'; - this.phaseInfo.style.minWidth = '200px'; + this.phaseInfo.style.width = '300px'; // Match button width this.phaseInfo.innerHTML = `
Turn 1
Setup
@@ -1034,7 +1104,7 @@ export class UIManager { this.endPhaseBtn = document.createElement('button'); this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS'; this.endPhaseBtn.style.marginTop = '10px'; - this.endPhaseBtn.style.width = '100%'; + this.endPhaseBtn.style.width = '300px'; // Fixed width to prevent resizing with messages this.endPhaseBtn.style.padding = '8px'; this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold this.endPhaseBtn.style.color = '#000'; @@ -1059,6 +1129,7 @@ export class UIManager { // Notification Area (Power Roll results, etc) this.notificationArea = document.createElement('div'); this.notificationArea.style.marginTop = '10px'; + this.notificationArea.style.maxWidth = '600px'; // Prevent very wide messages this.notificationArea.style.transition = 'opacity 0.5s'; this.notificationArea.style.opacity = '0'; this.statusPanel.appendChild(this.notificationArea); @@ -1107,10 +1178,6 @@ export class UIManager { this.endPhaseBtn.style.display = 'block'; this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS'; this.endPhaseBtn.title = "Pasar a la Fase de Monstruos"; - } else if (phase === 'monster') { - this.endPhaseBtn.style.display = 'block'; - this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS'; - this.endPhaseBtn.title = "Pasar a Fase de Exploración"; } else if (phase === 'exploration') { this.endPhaseBtn.style.display = 'block'; this.endPhaseBtn.textContent = 'ACABAR TURNO'; @@ -1180,4 +1247,43 @@ export class UIManager { if (this.notificationArea) this.notificationArea.style.opacity = '0'; }, 3000); } + showTemporaryMessage(title, message, duration = 2000) { + const modal = document.createElement('div'); + Object.assign(modal.style, { + position: 'absolute', + top: '25%', + left: '50%', + transform: 'translate(-50%, -50%)', + backgroundColor: 'rgba(139, 0, 0, 0.9)', + color: '#fff', + padding: '15px 30px', + borderRadius: '8px', + border: '2px solid #ff4444', + fontFamily: '"Cinzel", serif', + fontSize: '20px', + textShadow: '2px 2px 4px black', + zIndex: '2000', + pointerEvents: 'none', + opacity: '0', + transition: 'opacity 0.5s ease-in-out' + }); + + modal.innerHTML = ` +

⚠️ ${title}

+
${message}
+ `; + + document.body.appendChild(modal); + + // Fade In + requestAnimationFrame(() => { modal.style.opacity = '1'; }); + + // Fade Out and Remove + setTimeout(() => { + modal.style.opacity = '0'; + setTimeout(() => { + if (modal.parentNode) document.body.removeChild(modal); + }, 500); + }, duration); + } }