Implement Elf Ranged Combat and Pinned Mechanic

- Added 'Shoot Bow' action for Elf with Ballistic Skill mechanics (1995 rules).
- Implemented strict Line of Sight (LOS) raycasting (Amanatides & Woo) with UI feedback for blockers.
- Added 'Pinned' status: Heroes adjacent to monsters (without intervening walls) cannot move.
- Enhanced UI with visual indicators for blocked shots (red circles) and temporary modals.
- Polished 'End Phase' button layout and hidden it during Monster phase.
This commit is contained in:
2026-01-06 20:05:56 +01:00
parent 7b28fcf1b0
commit c0a9299dc5
7 changed files with 591 additions and 6 deletions

View File

@@ -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,

View File

@@ -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.

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -44,6 +44,9 @@ export class GameRenderer {
this.highlightGroup = new THREE.Group();
this.scene.add(this.highlightGroup);
this.rangedGroup = new THREE.Group();
this.scene.add(this.rangedGroup);
this.entities = new Map();
}
@@ -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)
}
}
}

View File

@@ -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 = `
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
<div style="font-size: 24px; color: #daa520;">Setup</div>
@@ -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 = `
<h3 style="margin:0; text-align:center; color: #FFD700; text-transform: uppercase;">⚠️ ${title}</h3>
<div style="margin-top:5px; font-size: 16px;">${message}</div>
`;
document.body.appendChild(modal);
// Fade In
requestAnimationFrame(() => { modal.style.opacity = '1'; });
// Fade Out and Remove
setTimeout(() => {
modal.style.opacity = '0';
setTimeout(() => {
if (modal.parentNode) document.body.removeChild(modal);
}, 500);
}, duration);
}
}