dok/public/js/buildings/arena.js
2026-04-09 08:53:51 +01:00

565 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export async function loadArena() {
const ui = document.querySelector(".building-ui");
ui.innerHTML = `
<div id="arena-ui">
<!-- Modus-Auswahl -->
<div id="arena-mode-screen">
<div class="arena-title">⚔️ Kampfarena</div>
<p class="arena-subtitle">Wähle deinen Kampfmodus</p>
<div class="arena-modes">
<div class="arena-mode-card" data-mode="1v1">
<div class="arena-mode-icon">🗡️</div>
<div class="arena-mode-label">1v1</div>
<div class="arena-mode-desc">Einzelkampf Beweis deine Stärke im Duell</div>
</div>
<div class="arena-mode-card" data-mode="2v2">
<div class="arena-mode-icon">⚔️</div>
<div class="arena-mode-label">2v2</div>
<div class="arena-mode-desc">Verbünde dich mit einem Kameraden im Kampf</div>
</div>
<div class="arena-mode-card" data-mode="4v4">
<div class="arena-mode-icon">🛡️</div>
<div class="arena-mode-label">4v4</div>
<div class="arena-mode-desc">Schlachtruf Führe deine Truppe zum Sieg</div>
</div>
</div>
<div id="arena-queue-status" style="display:none;"></div>
</div>
<!-- 2v2 Lobby-Screen -->
<div id="arena-2v2-screen" style="display:none;">
<div class="arena-lobby-header">
<button class="arena-back-btn" data-back="2v2">← Zurück</button>
<div class="arena-title" style="margin:0;">⚔️ 2v2 Team-Lobby</div>
</div>
<div id="arena-2v2-error" style="display:none;"></div>
<div id="arena-2v2-team-panel" style="display:none;">
<div class="arena-team-box">
<div class="arena-team-title">Dein Team (2v2)</div>
<div id="arena-2v2-team-players"></div>
<div id="arena-2v2-team-actions"></div>
</div>
<div id="arena-2v2-team-status"></div>
</div>
<div id="arena-2v2-lobby-section">
<div class="arena-lobby-actions">
<button class="arena-btn-create" data-create="2v2"> Eigenes Team erstellen</button>
</div>
<div class="arena-lobby-title">Offene Teams</div>
<div id="arena-2v2-lobby-list"><div class="arena-lobby-empty">Keine offenen Teams vorhanden.</div></div>
</div>
</div>
<!-- 4v4 Lobby-Screen -->
<div id="arena-4v4-screen" style="display:none;">
<div class="arena-lobby-header">
<button class="arena-back-btn" data-back="4v4">← Zurück</button>
<div class="arena-title" style="margin:0;">🛡️ 4v4 Team-Lobby</div>
</div>
<div id="arena-4v4-error" style="display:none;"></div>
<div id="arena-4v4-team-panel" style="display:none;">
<div class="arena-team-box">
<div class="arena-team-title">Dein Team (4v4)</div>
<div id="arena-4v4-team-players"></div>
<div id="arena-4v4-team-actions"></div>
</div>
<div id="arena-4v4-team-status"></div>
</div>
<div id="arena-4v4-lobby-section">
<div class="arena-lobby-actions">
<button class="arena-btn-create" data-create="4v4"> Eigenes Team erstellen</button>
</div>
<div class="arena-lobby-title">Offene Teams</div>
<div id="arena-4v4-lobby-list"><div class="arena-lobby-empty">Keine offenen Teams vorhanden.</div></div>
</div>
</div>
</div>
`;
injectArenaStyles();
initArenaModes();
}
/* ── Styles ─────────────────────────────────────────────────────────────────── */
function injectArenaStyles() {
if (document.getElementById("arena-popup-styles")) return;
const style = document.createElement("style");
style.id = "arena-popup-styles";
style.textContent = `
@keyframes arenaFadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes arenaScaleIn { from { transform:scale(0.94); opacity:0; } to { transform:scale(1); opacity:1; } }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.5;} }
#arena-queue-status {
margin-top:18px; padding:12px 20px; border-radius:10px;
background:rgba(255,215,80,0.08); border:1px solid rgba(255,215,80,0.3);
color:#dceb15; font-family:"Cinzel",serif; font-size:13px;
letter-spacing:1px; text-align:center; animation:pulse 2s ease-in-out infinite;
}
#arena-queue-status .qs-cancel {
display:inline-block; margin-top:8px; font-size:11px;
color:rgba(255,100,100,0.8); cursor:pointer; text-decoration:underline; animation:none;
}
#arena-queue-status .qs-cancel:hover { color:#e74c3c; }
#arena-2v2-error, #arena-4v4-error {
padding:10px 14px; border-radius:8px; margin-bottom:8px;
background:rgba(231,76,60,0.12); border:1px solid rgba(231,76,60,0.4);
color:#e74c3c; font-family:"Cinzel",serif; font-size:12px; text-align:center;
}
#arena-backdrop { position:fixed; inset:0; background:rgba(0,0,0,0.82); backdrop-filter:blur(5px); z-index:9998; animation:arenaFadeIn 0.25s ease; }
#arena-popup {
position:fixed; inset:50px; z-index:9999; display:flex; flex-direction:column;
border-radius:14px; overflow:hidden;
box-shadow: 0 0 0 1px rgba(255,215,80,0.35), 0 30px 90px rgba(0,0,0,0.85);
animation:arenaScaleIn 0.28s cubic-bezier(0.22,1,0.36,1);
}
#arena-popup-titlebar {
display:flex; align-items:center; justify-content:space-between;
background:rgba(10,8,5,0.95); border-bottom:1px solid rgba(255,215,80,0.3);
padding:0 16px; height:42px; flex-shrink:0;
}
#arena-popup-titlebar .ap-title { font-family:"Cinzel",serif; font-size:13px; letter-spacing:4px; color:rgba(255,215,80,0.85); text-transform:uppercase; }
#arena-popup-titlebar .ap-url { font-size:11px; color:rgba(255,255,255,0.22); }
#arena-popup iframe { flex:1; border:none; width:100%; display:block; }
#match-found-overlay {
position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,0.9);
display:flex; flex-direction:column; align-items:center; justify-content:center;
animation:arenaFadeIn 0.3s ease;
}
#match-found-overlay .mfo-title { font-family:"Cinzel",serif; font-size:36px; color:#ffd750; text-shadow:0 0 30px rgba(255,215,80,0.6); letter-spacing:6px; margin-bottom:12px; }
#match-found-overlay .mfo-vs { font-family:"Cinzel",serif; font-size:18px; color:rgba(255,255,255,0.75); letter-spacing:3px; }
#match-found-overlay .mfo-bar { width:300px; height:4px; background:rgba(255,215,80,0.2); border-radius:2px; margin-top:24px; overflow:hidden; }
#match-found-overlay .mfo-bar-fill { height:100%; background:#ffd750; width:0%; border-radius:2px; transition:width 1.5s ease; }
.arena-mode-card.searching { opacity:0.6; pointer-events:none; border-color:rgba(255,215,80,0.5)!important; }
#arena-2v2-screen, #arena-4v4-screen {
flex-direction:column; gap:14px; padding:16px; height:100%; overflow-y:auto;
}
.arena-lobby-header { display:flex; align-items:center; gap:16px; }
.arena-back-btn {
background:none; border:1px solid rgba(255,200,80,0.3); color:#c8960c;
font-family:"Cinzel",serif; font-size:12px; padding:4px 12px; border-radius:4px; cursor:pointer;
}
.arena-back-btn:hover { background:rgba(200,150,12,0.15); }
.arena-lobby-actions { display:flex; gap:10px; margin-bottom:4px; }
.arena-btn-create {
background:linear-gradient(#4a3018,#2a1a08); border:2px solid #8b6a3c; border-radius:7px;
color:#f0d9a6; font-family:"Cinzel",serif; font-size:12px; padding:8px 16px; cursor:pointer; transition:0.2s;
}
.arena-btn-create:hover { border-color:#f0d060; }
.arena-lobby-title { font-family:"Cinzel",serif; font-size:13px; color:#a08060; letter-spacing:1px; margin-bottom:6px; }
.arena-lobby-empty { font-family:"Cinzel",serif; font-size:12px; color:#606060; padding:12px 0; }
.arena-lobby-row {
display:flex; align-items:center; justify-content:space-between;
background:linear-gradient(#2a1a08,#1a0f04); border:1px solid #6b4b2a;
border-radius:8px; padding:10px 14px; margin-bottom:6px;
}
.arena-lobby-row-info { display:flex; align-items:center; gap:12px; }
.arena-lobby-leader { font-family:"Cinzel",serif; font-size:13px; color:#f0d9a6; }
.arena-lobby-level { font-size:11px; color:#a08060; }
.arena-lobby-count { font-size:11px; color:#6a9a4a; }
.arena-btn-join {
background:linear-gradient(#1a4a18,#0f2a0e); border:2px solid #4a8a3c; border-radius:6px;
color:#a0e090; font-family:"Cinzel",serif; font-size:11px; padding:5px 12px; cursor:pointer; transition:0.2s;
}
.arena-btn-join:hover { border-color:#8ae060; color:#c0f0a0; }
.arena-team-box { background:linear-gradient(#2a1a08,#1a0f04); border:2px solid #6b4b2a; border-radius:10px; padding:14px; }
.arena-team-title { font-family:"Cinzel",serif; font-size:13px; color:#f0d060; letter-spacing:2px; margin-bottom:10px; }
.arena-team-player {
display:flex; align-items:center; gap:10px; padding:7px 10px;
border:1px solid #3a2810; border-radius:6px; margin-bottom:5px; background:rgba(255,255,255,0.03);
}
.arena-team-player.ready { border-color:#4a8a3c; background:rgba(74,138,60,0.1); }
.arena-team-player-name { font-family:"Cinzel",serif; font-size:12px; color:#f0d9a6; flex:1; }
.arena-team-player-level { font-size:11px; color:#a08060; }
.arena-team-player-status { font-size:11px; }
.arena-waiting-partner { font-family:"Cinzel",serif; font-size:11px; color:#a08060; text-align:center; padding:8px; animation:pulse 2s ease-in-out infinite; }
.arena-btn-ready {
width:100%; margin-top:10px; background:linear-gradient(#1a4a18,#0f2a0e); border:2px solid #4a8a3c;
border-radius:8px; color:#a0e090; font-family:"Cinzel",serif; font-size:14px; padding:10px; cursor:pointer; transition:0.2s;
}
.arena-btn-ready:hover:not([disabled]) { border-color:#8ae060; background:linear-gradient(#2a6a28,#1a3a18); }
.arena-btn-ready.active { border-color:#8ae060; color:#c0f0a0; cursor:default; }
.arena-searching-box {
font-family:"Cinzel",serif; font-size:12px; color:#dceb15; text-align:center;
padding:10px; border:1px solid rgba(255,215,80,0.3); border-radius:8px; margin-top:8px;
animation:pulse 2s ease-in-out infinite;
}
.arena-team-slots {
font-family:"Cinzel",serif; font-size:11px; color:#a08060;
text-align:center; padding:4px 0 8px;
}
`;
document.head.appendChild(style);
}
/* ── Socket ─────────────────────────────────────────────────────────────────── */
function getSocket() { return window._socket || null; }
/* ── State ──────────────────────────────────────────────────────────────────── */
let myArenaData = null;
let my2v2TeamId = null;
let my4v4TeamId = null;
/* ── Modus-Klicks ───────────────────────────────────────────────────────────── */
function initArenaModes() {
document.querySelectorAll(".arena-mode-card").forEach(card => {
card.addEventListener("click", () => {
const mode = card.dataset.mode;
if (mode === "1v1") handle1v1Click(card);
else if (mode === "2v2") openTeamLobby("2v2");
else if (mode === "4v4") openTeamLobby("4v4");
});
});
/* Zurück-Buttons */
document.addEventListener("click", e => {
const btn = e.target.closest(".arena-back-btn");
if (!btn) return;
const mode = btn.dataset.back;
if (!mode) return;
leaveTeam(mode);
document.getElementById(`arena-${mode}-screen`).style.display = "none";
document.getElementById("arena-mode-screen").style.display = "";
if (mode === "2v2") my2v2TeamId = null;
if (mode === "4v4") my4v4TeamId = null;
});
/* Team erstellen Buttons */
document.addEventListener("click", e => {
const btn = e.target.closest(".arena-btn-create");
if (!btn) return;
const mode = btn.dataset.create;
if (!mode) return;
const socket = getSocket();
if (!socket) return showModeError(mode, "Keine Verbindung zum Server.");
console.log(`[${mode}] Team erstellen sende create_${mode}_team mit:`, myArenaData);
socket.emit(`create_${mode}_team`, myArenaData);
});
}
/* ── Team-Lobby öffnen (2v2 oder 4v4) ──────────────────────────────────────── */
async function openTeamLobby(mode) {
const socket = getSocket();
/* Screen sofort wechseln */
document.getElementById("arena-mode-screen").style.display = "none";
document.getElementById(`arena-${mode}-screen`).style.display = "flex";
if (!socket) {
showModeError(mode, "Keine Verbindung zum Server. Bitte Seite neu laden.");
return;
}
/* Spielerdaten laden */
if (!myArenaData) {
try {
const res = await fetch("/arena/me");
if (!res.ok) throw new Error("HTTP " + res.status);
myArenaData = await res.json();
console.log(`[${mode}] Spielerdaten:`, myArenaData);
} catch (err) {
console.error(`[${mode}] Spielerdaten Fehler:`, err);
showModeError(mode, "Spielerdaten konnten nicht geladen werden. (Eingeloggt?)");
return;
}
}
/* Lobbyliste anfordern */
socket.emit(`get_${mode}_lobbies`);
/* Alte Listener entfernen */
socket.off(`${mode}_lobbies`);
socket.off(`${mode}_team_joined`);
socket.off(`${mode}_team_update`);
socket.off(`${mode}_partner_left`);
socket.off(`${mode}_searching`);
socket.off(`match_found_${mode}`);
socket.off(`${mode}_error`);
/* Listener registrieren */
socket.on(`${mode}_lobbies`, list => renderLobbyList(list, socket, mode));
socket.on(`${mode}_team_joined`, data => {
if (mode === "2v2") my2v2TeamId = data.teamId;
if (mode === "4v4") my4v4TeamId = data.teamId;
document.getElementById(`arena-${mode}-team-panel`).style.display = "block";
document.getElementById(`arena-${mode}-lobby-section`).style.display = "none";
hideModeError(mode);
});
socket.on(`${mode}_team_update`, data => renderTeamPanel(data, socket, mode));
socket.on(`${mode}_partner_left`, data => {
const status = document.getElementById(`arena-${mode}-team-status`);
if (status) status.innerHTML = `<span style="color:#e74c3c;">⚠️ ${data.name} hat das Team verlassen.</span>`;
const teamId = mode === "2v2" ? my2v2TeamId : my4v4TeamId;
renderTeamPanel({ teamId, players: [{ name: myArenaData.name, level: myArenaData.level, ready: false }], count: 1, max: mode === "4v4" ? 4 : 2 }, socket, mode);
});
socket.on(`${mode}_searching`, () => {
const status = document.getElementById(`arena-${mode}-team-status`);
const actions = document.getElementById(`arena-${mode}-team-actions`);
if (status) status.innerHTML = `<div class="arena-searching-box">⏳ Suche nach Gegnerteam…</div>`;
if (actions) actions.innerHTML = "";
});
socket.on(`match_found_${mode}`, data => {
socket.off(`${mode}_lobbies`);
socket.off(`${mode}_team_update`);
socket.off(`${mode}_partner_left`);
socket.off(`${mode}_searching`);
showMatchFoundOverlay(myArenaData.name, `Team ${data.myTeam === 1 ? 2 : 1}`, () => {
document.getElementById(`arena-${mode}-screen`).style.display = "none";
document.getElementById("arena-mode-screen").style.display = "";
openArenaPopup(
`/arena/${mode}?match=${encodeURIComponent(data.matchId)}&slot=${encodeURIComponent(data.mySlot)}`,
data.opponents?.join(" & ") || "Gegner",
data.matchId,
);
});
});
socket.on(`${mode}_error`, data => {
console.warn(`[${mode}] Fehler:`, data.message);
showModeError(mode, data.message);
});
}
/* ── Lobby-Liste rendern ────────────────────────────────────────────────────── */
function renderLobbyList(list, socket, mode) {
const el = document.getElementById(`arena-${mode}-lobby-list`);
if (!el) return;
const max = mode === "4v4" ? 4 : 2;
if (!list.length) {
el.innerHTML = `<div class="arena-lobby-empty">Keine offenen Teams vorhanden.</div>`;
return;
}
el.innerHTML = list.map(team => `
<div class="arena-lobby-row">
<div class="arena-lobby-row-info">
<span class="arena-lobby-leader">⚔️ ${team.leader}</span>
<span class="arena-lobby-level">Lvl ${team.leaderLevel}</span>
<span class="arena-lobby-count">${team.count}/${max} Spieler</span>
</div>
<button class="arena-btn-join" data-teamid="${team.teamId}">Beitreten</button>
</div>
`).join("");
el.querySelectorAll(".arena-btn-join").forEach(btn => {
btn.addEventListener("click", () => {
socket.emit(`join_${mode}_team`, { teamId: btn.dataset.teamid, playerData: myArenaData });
});
});
}
/* ── Team-Panel rendern ─────────────────────────────────────────────────────── */
function renderTeamPanel(data, socket, mode) {
const playersEl = document.getElementById(`arena-${mode}-team-players`);
const actionsEl = document.getElementById(`arena-${mode}-team-actions`);
if (!playersEl || !actionsEl) return;
const max = data.max || (mode === "4v4" ? 4 : 2);
const teamId = mode === "2v2" ? my2v2TeamId : my4v4TeamId;
playersEl.innerHTML =
`<div class="arena-team-slots">${data.count}/${max} Spieler</div>` +
data.players.map(p => `
<div class="arena-team-player ${p.ready ? "ready" : ""}">
<span class="arena-team-player-name">${p.name}</span>
<span class="arena-team-player-level">Lvl ${p.level}</span>
<span class="arena-team-player-status">${p.ready ? "✅ Bereit" : "⌛ Wartet"}</span>
</div>
`).join("") +
/* Leere Slots anzeigen */
Array(max - data.count).fill(0).map(() =>
`<div class="arena-team-player" style="opacity:0.3;">
<span class="arena-team-player-name">— Wartet auf Spieler —</span>
</div>`
).join("");
if (data.count < max) {
actionsEl.innerHTML = `<div class="arena-waiting-partner">Warte auf ${max - data.count} weiteren Spieler…</div>`;
} else {
const myEntry = data.players.find(p => p.name === myArenaData?.name);
const iAmReady = myEntry?.ready;
actionsEl.innerHTML = iAmReady
? `<button class="arena-btn-ready active" disabled>✅ Du bist bereit</button>`
: `<button class="arena-btn-ready" id="arena-${mode}-ready-btn">⚔️ Bereit</button>`;
if (!iAmReady) {
document.getElementById(`arena-${mode}-ready-btn`)?.addEventListener("click", () => {
socket.emit(`${mode}_player_ready`, { teamId });
});
}
}
}
/* ── Team verlassen ─────────────────────────────────────────────────────────── */
function leaveTeam(mode) {
const socket = getSocket();
if (socket) {
socket.emit(`leave_${mode}_team`);
socket.off(`${mode}_lobbies`);
socket.off(`${mode}_team_joined`);
socket.off(`${mode}_team_update`);
socket.off(`${mode}_partner_left`);
socket.off(`${mode}_searching`);
socket.off(`match_found_${mode}`);
socket.off(`${mode}_error`);
}
}
/* ── Fehleranzeige ──────────────────────────────────────────────────────────── */
function showModeError(mode, msg) {
const el = document.getElementById(`arena-${mode}-error`);
if (!el) return;
el.textContent = "❌ " + msg;
el.style.display = "block";
setTimeout(() => { el.style.display = "none"; }, 4000);
}
function hideModeError(mode) {
const el = document.getElementById(`arena-${mode}-error`);
if (el) el.style.display = "none";
}
/* ── 1v1 ────────────────────────────────────────────────────────────────────── */
async function handle1v1Click(card) {
const socket = getSocket();
if (!socket) { showArenaError("Keine Verbindung zum Server."); return; }
if (card.classList.contains("searching")) return;
let me;
try {
const res = await fetch("/arena/me");
if (!res.ok) throw new Error("Status " + res.status);
me = await res.json();
} catch (err) {
showArenaError("Spielerdaten konnten nicht geladen werden.");
return;
}
setCardSearching(card, true);
showQueueStatus(me.level);
socket.off("match_found");
socket.off("queue_status");
socket.on("queue_status", data => {
if (data.status === "waiting") showQueueStatus(me.level, data.poolSize);
else if (data.status === "left") { setCardSearching(card, false); hideQueueStatus(); }
});
socket.once("match_found", data => {
socket.off("queue_status");
setCardSearching(card, false);
hideQueueStatus();
showMatchFoundOverlay(me.name, data.opponent.name, () => {
openArenaPopup(
`/arena/1v1?match=${encodeURIComponent(data.matchId)}&slot=${encodeURIComponent(data.mySlot)}`,
data.opponent.name, data.matchId,
);
});
});
socket.emit("join_1v1", { id: me.id, name: me.name, level: me.level });
}
function cancelQueue(card) {
const socket = getSocket();
if (socket) { socket.emit("leave_1v1"); socket.off("match_found"); socket.off("queue_status"); }
setCardSearching(card, false);
hideQueueStatus();
}
/* ── Match-Found Splash ─────────────────────────────────────────────────────── */
function showMatchFoundOverlay(myName, opponentName, onDone) {
if (document.getElementById("match-found-overlay")) return;
const overlay = document.createElement("div");
overlay.id = "match-found-overlay";
overlay.innerHTML = `
<div class="mfo-title">⚔️ Match gefunden!</div>
<div class="mfo-vs">${myName} &nbsp;vs&nbsp; ${opponentName}</div>
<div class="mfo-bar"><div class="mfo-bar-fill" id="mfBar"></div></div>
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => { const b = document.getElementById("mfBar"); if (b) b.style.width = "100%"; });
setTimeout(() => { overlay.remove(); onDone(); }, 1600);
}
/* ── Popup öffnen ───────────────────────────────────────────────────────────── */
function openArenaPopup(src, opponentName, matchId) {
document.getElementById("arena-backdrop")?.remove();
document.getElementById("arena-popup")?.remove();
const backdrop = document.createElement("div");
backdrop.id = "arena-backdrop";
const popup = document.createElement("div");
popup.id = "arena-popup";
popup.innerHTML = `
<div id="arena-popup-titlebar">
<div class="ap-left"><span class="ap-title">⚔️ Arena · vs ${opponentName}</span></div>
<span class="ap-url">${matchId || src}</span>
</div>
<iframe src="${src}" allowfullscreen></iframe>
`;
document.body.appendChild(backdrop);
document.body.appendChild(popup);
}
/* ── UI Hilfsfunktionen ─────────────────────────────────────────────────────── */
function setCardSearching(card, searching) {
const label = card.querySelector(".arena-mode-label");
const desc = card.querySelector(".arena-mode-desc");
if (searching) {
card.classList.add("searching");
label.textContent = "⏳ Suche…";
desc.textContent = "Warte auf passenden Gegner…";
} else {
card.classList.remove("searching");
label.textContent = "1v1";
desc.textContent = "Einzelkampf Beweis deine Stärke im Duell";
}
}
function showQueueStatus(myLevel, poolSize) {
const box = document.getElementById("arena-queue-status");
if (!box) return;
const pool = poolSize ? ` · ${poolSize} Spieler im Pool` : "";
box.style.display = "block";
box.innerHTML = `
⏳ Suche Gegner (Level ${Math.max(1, myLevel-5)}${myLevel+5})${pool}
<br><span class="qs-cancel" id="qs-cancel-btn">Suche abbrechen</span>
`;
document.getElementById("qs-cancel-btn")?.addEventListener("click", () => {
const card = document.querySelector(".arena-mode-card[data-mode='1v1']");
if (card) cancelQueue(card);
});
}
function hideQueueStatus() {
const box = document.getElementById("arena-queue-status");
if (box) box.style.display = "none";
}
function showArenaError(msg) {
const box = document.getElementById("arena-queue-status");
if (!box) return;
box.style.display = "block"; box.style.animation = "none";
box.style.borderColor = "rgba(231,76,60,0.5)"; box.style.color = "#e74c3c";
box.textContent = "❌ " + msg;
setTimeout(() => {
box.style.display = "none"; box.style.animation = "";
box.style.borderColor = ""; box.style.color = "";
}, 3000);
}