dok/views/1v1-battlefield.ejs
2026-04-11 12:37:10 +01:00

1175 lines
45 KiB
Plaintext
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.

<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title || "Spielfeld" %></title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/css/1v1.css" />
<style>
#match-result-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
z-index: 9999;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: "Cinzel", serif;
animation: fadeIn 0.4s ease;
}
#match-result-overlay.show {
display: flex;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.result-title {
font-size: 48px;
letter-spacing: 6px;
margin-bottom: 12px;
text-shadow: 0 0 30px currentColor;
}
.result-title.win {
color: #f0d060;
}
.result-title.lose {
color: #c84040;
}
.result-points {
font-size: 22px;
color: #c8a860;
margin-bottom: 6px;
letter-spacing: 2px;
}
.result-levelup {
font-size: 18px;
color: #60e060;
margin-bottom: 20px;
letter-spacing: 2px;
animation: pulse 1s ease-in-out infinite;
}
.result-progress-wrap {
width: 320px;
margin-bottom: 24px;
}
.result-progress-label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #a08060;
margin-bottom: 6px;
}
.result-progress-track {
height: 10px;
background: rgba(255, 255, 255, 0.08);
border-radius: 5px;
overflow: hidden;
}
.result-progress-fill {
height: 100%;
background: linear-gradient(90deg, #c8960c, #f0d060);
border-radius: 5px;
transition: width 1s ease;
}
.result-close-btn {
background: linear-gradient(#4a3010, #2a1a08);
border: 2px solid #c8960c;
color: #f0d9a6;
font-family: "Cinzel", serif;
font-size: 14px;
padding: 10px 30px;
border-radius: 8px;
cursor: pointer;
letter-spacing: 2px;
margin-top: 10px;
transition: 0.2s;
}
.result-close-btn:hover {
border-color: #f0d060;
filter: brightness(1.2);
}
</style>
<style>
/* ── Deck-Stapel ────────────────────────── */
.hand-slot-deck {
position: relative;
cursor: pointer;
}
.deck-stack-wrap {
position: relative;
width: 100%;
height: calc(100% - 18px);
}
.deck-card-back,
.deck-card-top {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 7px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
}
.deck-shadow-3 {
transform: translate(-4px, -4px);
}
.deck-shadow-2 {
transform: translate(-2px, -2px);
}
.deck-shadow-1 {
transform: translate(-1px, -1px);
}
.deck-card-top {
transform: translate(0, 0);
border: 1px solid rgba(200, 150, 42, 0.5);
}
.deck-count {
position: absolute;
bottom: 2px;
left: 0;
right: 0;
text-align: center;
font-family: "Cinzel", serif;
font-size: 10px;
color: #f0d060;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.hand-slot-card {
border: 1px solid rgba(200, 150, 42, 0.4) !important;
}
.hand-slot-card > img {
border-radius: calc(var(--s) * 7);
overflow: hidden;
}
.hand-slot-card:hover {
border-color: rgba(200, 150, 42, 0.9) !important;
transform: translateY(-4px);
transition: transform 0.15s;
}
/* ── Reichweite & Laufen Badges (Hand + Board) ── */
.cs-range, .cs-race {
position: absolute;
display: flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
border-radius: 20px;
font-family: "Cinzel", serif;
font-size: 8px;
font-weight: bold;
line-height: 1;
z-index: 6;
pointer-events: none;
}
.cs-range {
bottom: 22px; left: 3px;
background: rgba(30,20,0,0.85);
border: 1px solid #e8b84b;
color: #e8b84b;
}
.cs-race {
bottom: 6px; left: 3px;
background: rgba(0,25,0,0.85);
border: 1px solid #7de87d;
color: #7de87d;
}
</style>
</head>
<body>
<div id="connecting-overlay">
<div class="spinner"></div>
<div>Warte auf Gegner…</div>
<p>Verbindung wird hergestellt</p>
</div>
<div class="board">
<div class="top-bar">
<div class="game-title"><%= title || "Spielfeld" %></div>
<!-- Zug-Timer mittig -->
<div id="turn-timer-wrap" style="display: none">
<svg id="turn-timer-svg" viewBox="0 0 44 44" width="44" height="44">
<circle cx="22" cy="22" r="18" class="tt-track" />
<circle cx="22" cy="22" r="18" class="tt-fill" id="tt-circle" />
</svg>
<div id="turn-timer-num">20</div>
</div>
<div class="top-icons">
<div class="top-icon">⚙</div>
<div class="top-icon">🗺</div>
<div class="top-icon">📖</div>
<div class="top-icon">🏆</div>
</div>
<div class="top-bar-actions">
<button class="end-turn-btn" id="end-turn-btn" disabled>
Zug beenden
</button>
<button class="aufgeben-btn" id="aufgeben-btn">🏳 Aufgeben</button>
</div>
</div>
<div id="board-lock-overlay" style="display: none">
<div id="ready-timer-box">
<div id="ready-timer-label">Bereit machen</div>
<div id="ready-timer-ring">
<svg viewBox="0 0 80 80" width="80" height="80">
<circle cx="40" cy="40" r="34" class="timer-track" />
<circle
cx="40"
cy="40"
r="34"
class="timer-fill"
id="timer-circle"
/>
</svg>
<div id="ready-timer-number">30</div>
</div>
<div id="ready-timer-sub">Beide Spieler müssen BEREIT klicken</div>
<div id="ready-status-row">
<div class="ready-pip" id="pip-player1">⬜ Spieler 1</div>
<div class="ready-pip" id="pip-player2">⬜ Spieler 2</div>
</div>
<button class="bereit-btn" id="bereit-btn">✔ BEREIT</button>
</div>
</div>
<div class="avatar avatar-left" id="avLeft">
<input type="file" accept="image/*" id="fileInputLeft" />
<img id="avImgL" class="av-img" />
<div class="av-placeholder" id="avPhL">
<div class="av-icon">⚔</div>
</div>
<div class="av-name" id="nameLeft"><%= player1 || "Spieler 1" %></div>
<div class="av-stats">
<div class="stat hp">
<span class="s-icon">❤</span
><span class="s-val" id="hpLeft"><%= player1hp || 20 %></span>
</div>
<div class="stat mana">
<span class="s-icon">💧</span
><span class="s-val" id="manaLeft"><%= player1mana || 3 %></span>
</div>
</div>
<div class="hp-orb" id="orbLeft"><%= player1hp || 15 %></div>
</div>
<div class="avatar avatar-right" id="avRight">
<input type="file" accept="image/*" id="fileInputRight" />
<img id="avImgR" class="av-img" />
<div class="av-placeholder" id="avPhR">
<div class="av-icon">🛡</div>
</div>
<div class="av-name" id="nameRight"><%= player2 || "Gegner" %></div>
<div class="av-stats">
<div class="stat hp">
<span class="s-icon">❤</span
><span class="s-val" id="hpRight"><%= player2hp || 20 %></span>
</div>
<div class="stat mana">
<span class="s-icon">💧</span
><span class="s-val" id="manaRight"><%= player2mana || 3 %></span>
</div>
</div>
<div class="hp-orb" id="orbRight"><%= player2hp || 15 %></div>
</div>
<div class="card-area">
<div class="row-label"></div>
<div class="card-row" id="row1"></div>
<div class="row-label"></div>
<div class="card-row" id="row2"></div>
</div>
<div class="bottom-bar">
<div class="hand-area" id="handArea"></div>
<div class="action-hud">
<div class="action-row">
<div class="action-btn" title="Angriff">⚔</div>
<div class="action-btn" title="Magie">✨</div>
<div class="action-btn" title="Verteidigung">🛡</div>
</div>
<div class="action-row">
<div class="action-btn" title="Heilen">💊</div>
<div class="action-btn" title="Karte ziehen">🃏</div>
<div class="action-btn" title="Einstellungen">⚙</div>
</div>
</div>
</div>
</div>
<!-- Match-Ergebnis Overlay -->
<div id="match-result-overlay">
<div class="result-title" id="result-title"></div>
<div class="result-points" id="result-points"></div>
<div
class="result-levelup"
id="result-levelup"
style="display: none"
></div>
<div
class="result-progress-wrap"
id="result-progress-wrap"
style="display: none"
>
<div class="result-progress-label">
<span id="result-level-label">Level</span>
<span id="result-pts-label"></span>
</div>
<div class="result-progress-track">
<div
class="result-progress-fill"
id="result-progress-fill"
style="width: 0%"
></div>
</div>
</div>
<button class="result-close-btn" id="result-close-btn">Schließen</button>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
/* ── Spielfeld aufbauen ─────────────────────────────── */
["row1", "row2"].forEach((id) => {
const row = document.getElementById(id);
for (let i = 1; i <= 11; i++) {
const s = document.createElement("div");
s.className = "card-slot";
s.innerHTML =
'<span class="slot-icon">✦</span><span class="slot-num">' +
i +
"</span>";
row.appendChild(s);
}
});
const hand = document.getElementById("handArea");
// ── Slot 1: Deck-Stapel (Rückseite oben) ─────────────
const deckSlot = document.createElement("div");
deckSlot.className = "hand-slot hand-slot-deck";
deckSlot.id = "deck-stack";
deckSlot.title = "Dein Deck";
deckSlot.innerHTML = `
<div class="deck-stack-wrap">
<img class="deck-card-back deck-shadow-3" src="/images/items/rueckseite.png">
<img class="deck-card-back deck-shadow-2" src="/images/items/rueckseite.png">
<img class="deck-card-back deck-shadow-1" src="/images/items/rueckseite.png">
<img class="deck-card-top" src="/images/items/rueckseite.png">
</div>
<span class="deck-count" id="deck-count">—</span>`;
hand.appendChild(deckSlot);
// ── Slots 24: aufgedeckte Handkarten (leer bis API lädt) ───
const handCardIds = ["hand-card-1", "hand-card-2", "hand-card-3"];
handCardIds.forEach((id) => {
const s = document.createElement("div");
s.className = "hand-slot hand-slot-card";
s.id = id;
s.innerHTML = '<span class="hs-icon">🃏</span>';
hand.appendChild(s);
});
// ── Slots 58: leere Slots ───────────────────────────
for (let i = 0; i < 4; i++) {
const s = document.createElement("div");
s.className = "hand-slot";
s.innerHTML = '<span class="hs-icon">🃏</span>';
hand.appendChild(s);
}
// ══════════════════════════════════════════════════════
// HAND & DECK SYSTEM
// ══════════════════════════════════════════════════════
let deckQueue = [];
// State pro Slot: { card, currentCd } oder null = leer
const handSlotState = {};
handCardIds.forEach((id) => {
handSlotState[id] = null;
});
/* ── Stat-Icon SVGs ─────────────────────────────────── */
const SVG_RANGE = `<svg viewBox="0 0 16 16" width="10" height="10" style="display:inline;vertical-align:middle;flex-shrink:0" fill="none" stroke="#e8b84b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2 Q1 8 4 14"/><line x1="4" y1="2" x2="4" y2="14" stroke-width="0.7" stroke-dasharray="2,1.5"/><line x1="4" y1="8" x2="13" y2="8"/><polyline points="11,6 13,8 11,10"/><line x1="5" y1="7" x2="4" y2="8"/><line x1="5" y1="9" x2="4" y2="8"/></svg>`;
const SVG_RACE = `<svg viewBox="0 0 16 16" width="10" height="10" style="display:inline;vertical-align:middle;flex-shrink:0" fill="none" stroke="#7de87d" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="2.5" r="1.4" fill="#7de87d" stroke="none"/><line x1="9" y1="4" x2="8" y2="9"/><line x1="8" y1="9" x2="10" y2="14"/><line x1="8" y1="9" x2="6" y2="13"/><line x1="8.5" y1="5.5" x2="11" y2="8"/><line x1="8.5" y1="5.5" x2="6" y2="7"/></svg>`;
/* ── Slot rendern ──────────────────────────────────── */
function renderHandSlot(id) {
const slot = document.getElementById(id);
const state = handSlotState[id];
if (!slot) return;
if (!state) {
slot.innerHTML = '<span class="hs-icon">🃏</span>';
slot.classList.remove("hand-slot-ready");
return;
}
const { card, currentCd } = state;
const isReady = currentCd <= 0;
const atkVal = card.attack ?? null;
const defVal = card.defends ?? null;
const rngVal = card.range ?? null;
const rceVal = card.race ?? null;
const statsHtml = `
<div class="card-stat-overlay">
${atkVal != null ? `<span class="cs-atk">${atkVal}</span>` : ""}
${defVal != null ? `<span class="cs-def">${defVal}</span>` : ""}
${
card.cooldown != null
? `<span class="cs-cd ${isReady ? "cs-cd-ready" : ""}">${isReady ? "✓" : currentCd}</span>`
: ""
}
${rngVal != null ? `<span class="cs-range">${SVG_RANGE}&thinsp;${rngVal}</span>` : ""}
${rceVal != null ? `<span class="cs-race">${SVG_RACE}&thinsp;${rceVal}</span>` : ""}
</div>`;
const readyBadge = isReady
? `<div class="hand-slot-ready-badge">SPIELEN</div>`
: "";
slot.innerHTML = card.image
? `<img src="/images/cards/${card.image}"
onerror="this.src='/images/items/rueckseite.png'"
title="${card.name}"
style="width:100%;height:100%;object-fit:cover;border-radius:7px;display:block;">
${statsHtml}${readyBadge}`
: `<div style="display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100%;gap:4px;font-family:Cinzel,serif;">
<span style="font-size:18px;">⚔️</span>
<span style="font-size:9px;color:#f0d9a6;text-align:center;">${card.name}</span>
</div>${statsHtml}${readyBadge}`;
slot.classList.toggle("hand-slot-ready", isReady);
}
/* ── Karte in Hand-Slot legen ──────────────────────── */
function setHandSlot(id, card) {
handSlotState[id] = { card, currentCd: card.cooldown ?? 0 };
renderHandSlot(id);
}
/* ── Karte vom Deck ziehen ─────────────────────────── */
function drawNextCard() {
if (deckQueue.length === 0) return;
const freeSlot = handCardIds.find((id) => handSlotState[id] === null);
if (!freeSlot) return; // alle Slots belegt → Karte bleibt im Deck
const card = deckQueue.shift();
setHandSlot(freeSlot, card);
const countEl = document.getElementById("deck-count");
if (countEl) countEl.textContent = deckQueue.length;
}
/* ── Cooldowns beim Zug-Ende reduzieren ────────────── */
function tickHandCooldowns() {
handCardIds.forEach((id) => {
const state = handSlotState[id];
if (!state) return;
if (state.currentCd > 0) state.currentCd--;
renderHandSlot(id);
});
}
/* ── Deck laden ────────────────────────────────────── */
(async () => {
try {
const urlP = new URLSearchParams(window.location.search);
const deckId =
urlP.get("deck") || sessionStorage.getItem("selectedDeckId");
if (!deckId) return;
const cardsRes = await fetch("/api/decks/" + deckId + "/cards");
if (!cardsRes.ok) return;
const cards = await cardsRes.json();
// Karten nach amount auffalten
const expanded = [];
cards.forEach((card) => {
for (let i = 0; i < (card.amount ?? 1); i++) expanded.push(card);
});
// Mischen (Fisher-Yates)
for (let i = expanded.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[expanded[i], expanded[j]] = [expanded[j], expanded[i]];
}
// Erste 3 in die Hand
handCardIds.forEach((id, i) => {
if (expanded[i]) setHandSlot(id, expanded[i]);
});
// Rest als Deck-Queue
deckQueue = expanded.slice(3);
const countEl = document.getElementById("deck-count");
if (countEl) countEl.textContent = deckQueue.length;
} catch (e) {
console.error("[Battlefield] Deck laden:", e);
}
})();
/* ══════════════════════════════════════════════════════
ZUG-TIMER (20 Sekunden)
══════════════════════════════════════════════════════ */
const TURN_SECONDS = 20;
const TT_CIRCUM = 2 * Math.PI * 18; // r=18
let turnTimerInt = null;
let turnSecsLeft = TURN_SECONDS;
function startTurnTimer() {
clearInterval(turnTimerInt);
turnSecsLeft = TURN_SECONDS;
updateTimerUI(turnSecsLeft);
const wrap = document.getElementById("turn-timer-wrap");
if (wrap) wrap.style.display = "flex";
turnTimerInt = setInterval(() => {
turnSecsLeft--;
updateTimerUI(turnSecsLeft);
if (turnSecsLeft <= 0) {
clearInterval(turnTimerInt);
if (isMyTurn) {
tickHandCooldowns();
drawNextCard();
setTurnState(false);
socket.emit("end_turn", { matchId, slot: mySlot });
// Watchdog: falls turn_change nach 5s nicht ankommt → selbst freischalten
setTimeout(() => {
if (!isMyTurn) {
console.warn(
"[1v1] Kein turn_change nach 5s Zug lokal übergeben",
);
setTurnState(true);
}
}, 5000);
}
}
}, 1000);
}
function stopTurnTimer() {
clearInterval(turnTimerInt);
const wrap = document.getElementById("turn-timer-wrap");
if (wrap) wrap.style.display = "none";
}
function updateTimerUI(secs) {
const num = document.getElementById("turn-timer-num");
const circle = document.getElementById("tt-circle");
if (num) num.textContent = secs;
if (circle) {
circle.style.strokeDashoffset = TT_CIRCUM * (1 - secs / TURN_SECONDS);
circle.style.stroke =
secs > 10 ? "#27ae60" : secs > 5 ? "#f39c12" : "#e74c3c";
if (secs <= 5) {
num.style.color = "#e74c3c";
num.style.animation = "tt-pulse 0.5s ease-in-out infinite";
} else {
num.style.color = "#fff";
num.style.animation = "none";
}
}
}
/* ══════════════════════════════════════════════════════
ZUG-SYSTEM
══════════════════════════════════════════════════════ */
// Bin ich der linke Spieler? Wird nach Seitenzuweisung gesetzt.
let amILeftPlayer = null;
let isMyTurn = false;
function setTurnState(myTurn) {
isMyTurn = myTurn;
const btn = document.getElementById("end-turn-btn");
if (!btn) return;
if (myTurn) {
btn.disabled = false;
btn.textContent = "Zug beenden";
btn.style.opacity = "1";
document.getElementById("turn-indicator")?.remove();
startTurnTimer();
// Watchdog für wartenden Spieler abbrechen falls vorhanden
if (window._waitWatchdog) {
clearTimeout(window._waitWatchdog);
window._waitWatchdog = null;
}
} else {
btn.disabled = true;
btn.style.opacity = "0.4";
stopTurnTimer();
showTurnIndicator();
// Watchdog: falls nach 26s kein turn_change → eigenen Zug erzwingen
if (window._waitWatchdog) clearTimeout(window._waitWatchdog);
window._waitWatchdog = setTimeout(() => {
if (!isMyTurn) {
console.warn(
"[1v1] Kein turn_change nach 26s Zug lokal übernommen",
);
setTurnState(true);
}
}, 26000);
}
}
function showTurnIndicator() {
document.getElementById("turn-indicator")?.remove();
const ind = document.createElement("div");
ind.id = "turn-indicator";
ind.style.cssText = `
position:fixed; bottom:calc(var(--s)*200); left:50%;
transform:translateX(-50%);
background:linear-gradient(135deg,rgba(20,20,40,0.95),rgba(10,10,25,0.95));
border:1px solid rgba(200,160,60,0.5);
border-radius:calc(var(--s)*8);
color:rgba(255,215,80,0.75);
font-family:'Cinzel',serif;
font-size:calc(var(--s)*11);
letter-spacing:calc(var(--s)*3);
padding:calc(var(--s)*8) calc(var(--s)*20);
z-index:100;
pointer-events:none;
text-transform:uppercase;
box-shadow:0 4px 20px rgba(0,0,0,0.6);
`;
ind.textContent = "⏳ Gegner ist am Zug";
document.body.appendChild(ind);
}
/* ── Zug beenden: CD ticken + Karte ziehen + Server informieren ── */
document.getElementById("end-turn-btn")?.addEventListener("click", () => {
if (!isMyTurn) return;
clearInterval(turnTimerInt);
tickHandCooldowns();
drawNextCard();
setTurnState(false);
socket.emit("end_turn", { matchId, slot: mySlot });
});
/* ── Hilfsfunktion: Karte mit Stats in einen Slot rendern ── */
function renderCardInSlot(slot, card) {
if (!slot || !card) return;
const atkVal = card.attack ?? null;
const defVal = card.defends ?? null;
const cdVal = card.cooldown ?? null;
const rngVal = card.range ?? null;
const rceVal = card.race ?? null;
const hasAtk = atkVal != null;
const hasDef = defVal != null;
const hasCd = cdVal != null;
const hasRng = rngVal != null;
const hasRce = rceVal != null;
const statsHtml =
hasAtk || hasDef || hasCd || hasRng || hasRce
? `
<div class="card-stat-overlay">
${hasAtk ? `<span class="cs-atk">${atkVal}</span>` : ""}
${hasDef ? `<span class="cs-def">${defVal}</span>` : ""}
${hasCd ? `<span class="cs-cd">${cdVal}</span>` : ""}
${hasRng ? `<span class="cs-range">${SVG_RANGE}&thinsp;${rngVal}</span>` : ""}
${hasRce ? `<span class="cs-race">${SVG_RACE}&thinsp;${rceVal}</span>` : ""}
</div>`
: "";
// note: SVG_RACE is defined as the walking icon above
slot.classList.add("slot-occupied");
slot.innerHTML = card.image
? `<img src="/images/cards/${card.image}"
onerror="this.src='/images/items/rueckseite.png'"
title="${card.name}"
style="width:100%;height:100%;object-fit:cover;border-radius:7px;display:block;">
${statsHtml}`
: `<div style="display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100%;gap:4px;font-family:Cinzel,serif;padding:4px;">
<span style="font-size:18px;">⚔️</span>
<span style="font-size:9px;color:#f0d9a6;text-align:center;">${card.name}</span>
</div>
${statsHtml}`;
}
/* ── Match-Daten ────────────────────────────────────── */
const urlParams = new URLSearchParams(window.location.search);
const matchId = urlParams.get("match") || "<%= matchId || '' %>";
const mySlot = urlParams.get("slot") || "<%= mySlot || 'player1' %>";
const amIPlayer1 = mySlot === "player1";
/* ── Socket ─────────────────────────────────────────── */
const socket = io();
/* Account-ID laden dann arena_join senden */
let myIngameName = null;
function emitArenaJoin(me) {
myIngameName =
(me && (me.ingame_name || me.username || me.name || String(me.id))) ||
"Spieler";
const myNameEl = document.getElementById(
amIPlayer1 ? "nameLeft" : "nameRight",
);
if (myNameEl) myNameEl.textContent = myIngameName;
console.log("[1v1] emitArenaJoin →", {
matchId,
slot: mySlot,
name: myIngameName,
});
socket.emit("arena_join", {
matchId,
slot: mySlot,
accountId: me?.id ?? null,
playerName: myIngameName,
});
}
function fetchAndJoin() {
fetch("/arena/me")
.then((r) => r.json())
.then((me) => emitArenaJoin(me))
.catch(() => {
console.warn("[1v1] /arena/me fehlgeschlagen join ohne Account");
emitArenaJoin(null);
});
}
// Sofort emittieren (socket.io puffert bis verbunden)
fetchAndJoin();
// Sicherheits-Retry nach 2s falls connect noch aussteht
socket.on("connect", () => {
console.log("[1v1] Socket connected:", socket.id);
fetchAndJoin();
});
socket.on("connect_error", (err) => {
console.error("[1v1] Socket connect_error:", err.message);
});
// Fallback: Falls nach 10s kein arena-Event → Bereit-Box erzwingen
const readyFallbackTimer = setTimeout(() => {
console.warn("[1v1] Kein arena-Event nach 10s Bereit-Box erzwingen");
document.getElementById("connecting-overlay")?.remove();
const lockOverlay = document.getElementById("board-lock-overlay");
if (lockOverlay) lockOverlay.style.display = "flex";
}, 10000);
/* ── Gegner verbunden ───────────────────────────────── */
socket.on("arena_opponent_joined", (data) => {
console.log("[Arena] arena_opponent_joined:", data);
clearTimeout(readyFallbackTimer);
const name = data.name || "Gegner";
document.getElementById(
amIPlayer1 ? "nameRight" : "nameLeft",
).textContent = name;
document.getElementById("connecting-overlay")?.remove();
const lockOverlay = document.getElementById("board-lock-overlay");
if (lockOverlay) lockOverlay.style.display = "flex";
});
socket.on("arena_ready", (data) => {
console.log("[Arena] arena_ready:", data);
clearTimeout(readyFallbackTimer);
document.getElementById("connecting-overlay")?.remove();
// Gegner-Name aus server-Daten setzen (immer, außer leer)
const oppName = amIPlayer1 ? data.player2 : data.player1;
const oppEl = document.getElementById(
amIPlayer1 ? "nameRight" : "nameLeft",
);
if (oppEl && oppName) oppEl.textContent = oppName;
// Eigenen Namen sichern falls noch nicht gesetzt
const myEl = document.getElementById(
amIPlayer1 ? "nameLeft" : "nameRight",
);
if (myEl && myIngameName) myEl.textContent = myIngameName;
const lockOverlay = document.getElementById("board-lock-overlay");
if (lockOverlay) lockOverlay.style.display = "flex";
});
/* ── Server: Zugwechsel ──────────────────────────────── */
socket.on("turn_change", (data) => {
const nowMyTurn = data.activeSlot === mySlot;
console.log(
"[1v1] turn_change:",
data.activeSlot,
"| ich bin:",
mySlot,
"| meinZug:",
nowMyTurn,
);
setTurnState(nowMyTurn);
});
socket.on("turn_started", (data) => {
setTurnState(data.slot === mySlot);
});
/* ── Bereit-System ──────────────────────────────────── */
let myReady = false;
function handleBereit() {
if (myReady) return;
myReady = true;
const btn = document.getElementById("bereit-btn");
btn.textContent = "✔ BEREIT";
btn.classList.add("bereit-clicked");
btn.disabled = true;
socket.emit("player_ready", { matchId, slot: mySlot });
}
const CIRCUMFERENCE = 2 * Math.PI * 34;
const timerCircle = document.getElementById("timer-circle");
if (timerCircle) timerCircle.style.strokeDasharray = CIRCUMFERENCE;
socket.on("ready_timer", (data) => {
const num = document.getElementById("ready-timer-number");
if (num) num.textContent = data.remaining;
if (timerCircle) {
timerCircle.style.strokeDashoffset =
CIRCUMFERENCE * (1 - data.remaining / 30);
timerCircle.style.stroke =
data.remaining > 15
? "#27ae60"
: data.remaining > 7
? "#f39c12"
: "#e74c3c";
}
});
socket.on("ready_status", (data) => {
const pip1 = document.getElementById("pip-player1");
const pip2 = document.getElementById("pip-player2");
if (data.readySlots) {
if (data.readySlots.includes("player1") && pip1)
pip1.textContent =
"✅ " +
(document.getElementById("nameLeft")?.textContent || "Spieler 1");
if (data.readySlots.includes("player2") && pip2)
pip2.textContent =
"✅ " +
(document.getElementById("nameRight")?.textContent ||
"Spieler 2");
}
if (data.readyCount === 2) {
// ── Zufällige Seitenzuweisung ──────────────────────
const seed = matchId
.split("")
.reduce((a, c) => a + c.charCodeAt(0), 0);
const flip = seed % 2 === 1;
// Eigenen Namen immer aus myIngameName, Gegner aus DOM
const myName = myIngameName || "Spieler";
const oppEl = document.getElementById(
amIPlayer1 ? "nameRight" : "nameLeft",
);
const oppName = oppEl?.textContent || "Gegner";
// player1-Name und player2-Name bestimmen
const p1Name = amIPlayer1 ? myName : oppName;
const p2Name = amIPlayer1 ? oppName : myName;
const leftName = flip ? p2Name : p1Name;
const rightName = flip ? p1Name : p2Name;
// Namen in Avatar-Slots schreiben
document.getElementById("nameLeft").textContent = leftName;
document.getElementById("nameRight").textContent = rightName;
// Platzhalter: Ingame-Namen anzeigen
["avLeft", "avRight"].forEach((avId) => {
const av = document.getElementById(avId);
const ph = av?.querySelector(".av-placeholder");
const name = avId === "avLeft" ? leftName : rightName;
if (ph)
ph.innerHTML = `
<div style="
font-family:'Cinzel',serif;
font-size:calc(var(--s)*13);
font-weight:700;
color:#ffd750;
text-align:center;
padding:0 8px;
word-break:break-word;
line-height:1.4;
text-shadow:0 2px 8px rgba(0,0,0,0.9),0 0 20px rgba(0,0,0,0.8);
">${name}</div>`;
});
document.getElementById("board-lock-overlay")?.remove();
// Festlegen ob ich der linke Spieler bin
// flip=false: player1=links, flip=true: player1=rechts
amILeftPlayer = flip ? mySlot === "player2" : mySlot === "player1";
// Linker Spieler beginnt
setTurnState(amILeftPlayer);
socket.emit("end_turn_init", {
matchId,
starterSlot: flip ? "player2" : "player1",
});
}
});
/* ── Aufgeben Modal ─────────────────────────────────── */
function handleAufgeben() {
// Eigenes Modal statt browser confirm()
const modal = document.createElement("div");
modal.id = "surrender-modal";
modal.style.cssText =
"position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;";
modal.innerHTML = `
<div style="background:linear-gradient(#2a1a08,#1a0f04);border:2px solid #c8960c;
border-radius:14px;padding:32px 40px;text-align:center;max-width:360px;
box-shadow:0 20px 60px rgba(0,0,0,0.9);font-family:'Cinzel',serif;">
<div style="font-size:40px;margin-bottom:12px;">🏳️</div>
<div style="font-size:18px;color:#f0d060;letter-spacing:3px;margin-bottom:10px;">AUFGEBEN?</div>
<p style="color:#a08060;font-size:12px;line-height:1.7;margin-bottom:24px;">
Willst du wirklich aufgeben?<br>
Du erhältst <strong style="color:#e74c3c;">keine Punkte</strong> für dieses Match.
</p>
<div style="display:flex;gap:12px;justify-content:center;">
<button id="surrender-no" style="padding:10px 24px;background:linear-gradient(#1a4a18,#0f2a0e);
border:2px solid #4a8a3c;border-radius:8px;color:#a0e090;font-family:'Cinzel',serif;
font-size:12px;cursor:pointer;">✖ Weiterkämpfen</button>
<button id="surrender-yes" style="padding:10px 24px;background:linear-gradient(#4a1010,#2a0808);
border:2px solid #8a3030;border-radius:8px;color:#e07070;font-family:'Cinzel',serif;
font-size:12px;cursor:pointer;">🏳 Aufgeben</button>
</div>
</div>`;
document.body.appendChild(modal);
document
.getElementById("surrender-no")
.addEventListener("click", () => modal.remove());
document
.getElementById("surrender-yes")
.addEventListener("click", () => {
modal.remove();
socket.emit("player_surrender", { matchId, slot: mySlot });
// Eigene Weiterleitung nach kurzer Verzögerung
showSurrenderMessage(false);
});
}
/* ── Zurück zum Arena-Popup (nicht zum Launcher) ─────── */
function closeToArena() {
if (window.parent && window.parent !== window) {
window.parent.document.getElementById("arena-backdrop")?.remove();
window.parent.document.getElementById("arena-popup")?.remove();
} else {
window.location.href = "/launcher";
}
}
/* ── Nachricht anzeigen + weiterleiten ──────────────── */
function showSurrenderMessage(iWon) {
const overlay = document.createElement("div");
overlay.id = "surrender-result-overlay";
overlay.style.cssText = `
position:fixed;inset:0;z-index:9999;
background:rgba(0,0,0,0.92);
display:flex;flex-direction:column;
align-items:center;justify-content:center;
font-family:'Cinzel',serif;
animation:fadeIn 0.4s ease;`;
if (iWon) {
overlay.innerHTML = `
<div style="font-size:64px;margin-bottom:20px;filter:drop-shadow(0 0 20px rgba(255,215,0,0.6));">🏆</div>
<div style="font-size:34px;color:#f0d060;letter-spacing:6px;margin-bottom:12px;
text-shadow:0 0 30px rgba(255,215,0,0.5);">SIEG!</div>
<div style="font-size:16px;color:#a0d090;letter-spacing:2px;margin-bottom:6px;">
Dein Gegner hat aufgegeben.</div>
<div style="font-size:12px;color:#606060;margin-bottom:32px;letter-spacing:1px;">
Du erhältst die Arena-Punkte für dieses Match.</div>
<button id="surrender-close-btn" style="
background:linear-gradient(135deg,#1a5a18,#27ae60);
border:2px solid rgba(100,220,100,0.6);
border-radius:10px;color:#fff;
font-family:'Cinzel',serif;font-size:14px;
letter-spacing:3px;padding:12px 36px;
cursor:pointer;transition:0.2s;
box-shadow:0 4px 20px rgba(0,150,0,0.4);">
✔ WEITER
</button>`;
} else {
overlay.innerHTML = `
<div style="font-size:64px;margin-bottom:20px;opacity:0.7;">🏳️</div>
<div style="font-size:34px;color:#e74c3c;letter-spacing:6px;margin-bottom:12px;">AUFGEGEBEN</div>
<div style="font-size:16px;color:#a08060;letter-spacing:2px;margin-bottom:6px;">
Du hast das Match aufgegeben.</div>
<div style="font-size:12px;color:#606060;margin-bottom:32px;letter-spacing:1px;">
Keine Punkte für dieses Match.</div>
<button id="surrender-close-btn" style="
background:linear-gradient(135deg,#3a2810,#2a1a08);
border:2px solid rgba(200,150,60,0.5);
border-radius:10px;color:#c8a860;
font-family:'Cinzel',serif;font-size:14px;
letter-spacing:3px;padding:12px 36px;
cursor:pointer;transition:0.2s;">
← ZURÜCK ZUR ARENA
</button>`;
}
document.body.appendChild(overlay);
// Button-Klick → zurück zum Arena-Popup
document
.getElementById("surrender-close-btn")
.addEventListener("click", closeToArena);
// Auto-Close nach 8s
setTimeout(closeToArena, 8000);
}
/* ── Spielende Events ───────────────────────────────── */
socket.on("player_surrendered", (data) => {
const iLost = data.slot === mySlot;
if (!iLost) {
// Ich habe gewonnen Gegner hat aufgegeben
showSurrenderMessage(true);
}
// Wenn ich selbst aufgegeben habe, läuft showSurrenderMessage(false) schon
});
/* Punkte-Ergebnis vom Server (kommt kurz nach player_surrendered) */
socket.on("match_result", (data) => {
updateResultWithPoints(data);
});
socket.on("match_cancelled", () => {
closePopup();
});
/* ── Ergebnis-Overlay ───────────────────────────────── */
function showResultOverlay(won, data) {
const overlay = document.getElementById("match-result-overlay");
const titleEl = document.getElementById("result-title");
const pointsEl = document.getElementById("result-points");
titleEl.textContent = won ? "⚔️ SIEG!" : "💀 NIEDERLAGE";
titleEl.className = "result-title " + (won ? "win" : "lose");
pointsEl.textContent = "Punkte werden berechnet…";
overlay.classList.add("show");
if (data) updateResultWithPoints(data);
}
function updateResultWithPoints(data) {
const pointsEl = document.getElementById("result-points");
const levelupEl = document.getElementById("result-levelup");
const progressEl = document.getElementById("result-progress-wrap");
const fillEl = document.getElementById("result-progress-fill");
const lvlLbl = document.getElementById("result-level-label");
const ptsLbl = document.getElementById("result-pts-label");
/* Overlay anzeigen falls noch nicht sichtbar */
const overlay = document.getElementById("match-result-overlay");
if (!overlay.classList.contains("show")) {
document.getElementById("result-title").textContent = data.won
? "⚔️ SIEG!"
: "💀 NIEDERLAGE";
document.getElementById("result-title").className =
"result-title " + (data.won ? "win" : "lose");
overlay.classList.add("show");
}
pointsEl.textContent =
data.awarded > 0
? "+" + data.awarded + " Arena-Punkte"
: "Keine Punkte (Aufgabe zu früh oder Tageslimit erreicht)";
if (data.level_up) {
levelupEl.style.display = "block";
levelupEl.textContent = "⬆ LEVEL UP! → Level " + data.new_level;
}
/* Fortschrittsbalken vom Server laden */
fetch("/api/points/me")
.then((r) => r.json())
.then((me) => {
progressEl.style.display = "block";
lvlLbl.textContent =
"Level " +
me.current_level +
(me.next_level ? " → " + me.next_level : " (MAX)");
ptsLbl.textContent =
me.points_this_level + " / " + me.points_for_next + " Pts";
requestAnimationFrame(() => {
fillEl.style.width = me.progress_percent + "%";
});
})
.catch(() => {});
}
/* ── Popup schließen → zurück zum Arena-Popup ────────── */
function closePopup() {
closeToArena();
}
document
.getElementById("result-close-btn")
.addEventListener("click", closePopup);
/* ── Avatar ─────────────────────────────────────────── */
function loadAvatar(input, imgId, parentId) {
const file = input.files[0];
if (!file) return;
const r = new FileReader();
r.onload = (e) => {
const img = document.getElementById(imgId);
img.src = e.target.result;
img.style.display = "block";
document
.getElementById(parentId)
?.querySelector(".av-placeholder")
?.style.setProperty("display", "none");
};
r.readAsDataURL(file);
}
/* ── Event-Listener ─────────────────────────────────── */
document
.getElementById("bereit-btn")
?.addEventListener("click", handleBereit);
document
.getElementById("aufgeben-btn")
?.addEventListener("click", handleAufgeben);
document
.getElementById("fileInputLeft")
?.addEventListener("change", function () {
loadAvatar(this, "avImgL", "avLeft");
});
document
.getElementById("fileInputRight")
?.addEventListener("change", function () {
loadAvatar(this, "avImgR", "avRight");
});
</script>
</body>
</html>