diff --git a/public/js/buildings/1v1.js b/public/js/buildings/1v1.js index 671edc4..f847388 100644 --- a/public/js/buildings/1v1.js +++ b/public/js/buildings/1v1.js @@ -289,6 +289,22 @@ document.getElementById('end-turn-btn')?.addEventListener('click', endMyTurn); */ const boardState = {}; +/* ── Kampf + HP Animationen ────────────────────────────── */ +(function() { + const s = document.createElement('style'); + s.textContent = ` + @keyframes dmg-float { + 0% { opacity:1; transform:translateX(-50%) translateY(0); } + 100% { opacity:0; transform:translateX(-50%) translateY(-44px); } + } + @keyframes combat-fade { + from { opacity:0; transform:translateX(-50%) scale(0.9); } + to { opacity:1; transform:translateX(-50%) scale(1); } + } + `; + document.head.appendChild(s); +})(); + /* ── Spieler-Farbe auf Slot oder Avatar anwenden ────────────────── */ function applyOwnerStyle(el, owner) { if (!el || !owner) return; @@ -418,6 +434,7 @@ socket.on('arena_ready', data => { clearTimeout(readyFallbackTimer); document.getElementById('connecting-overlay')?.remove(); if (data.boardSync) applyBoardSync(data.boardSync); + if (data.hp && data.maxHp) applyHpFromEvent(data); const oppName = amIPlayer1 ? data.player2 : data.player1; const oppEl = document.getElementById(amIPlayer1 ? 'nameRight' : 'nameLeft'); @@ -445,6 +462,7 @@ socket.on('turn_change', data => { const activeName = activeNameEl?.textContent || (nowMyTurn ? 'Du' : 'Gegner'); console.log(`[1v1] turn_change: ${activeSlot} | meinZug: ${nowMyTurn}`); setTurnState(nowMyTurn, activeName); + if (data.hp && data.maxHp) applyHpFromEvent(data); }); socket.on('turn_started', data => { @@ -804,6 +822,79 @@ socket.on('combat_phase', data => { }); +/* ═══════════════════════════════════════════════════════════ + AVATAR HP – Anzeige & Animationen +═══════════════════════════════════════════════════════════ */ + +function updateHpDisplay(slot, currentHp, maxHp) { + const isLeft = slot === (window._leftSlot || 'player1'); + const orbEl = document.getElementById(isLeft ? 'orbLeft' : 'orbRight'); + const hpEl = document.getElementById(isLeft ? 'hpLeft' : 'hpRight'); + const avEl = document.getElementById(isLeft ? 'avLeft' : 'avRight'); + + if (orbEl) orbEl.textContent = currentHp; + if (hpEl) hpEl.textContent = currentHp; + + /* Farbe je nach HP-Prozent */ + const pct = maxHp > 0 ? currentHp / maxHp : 0; + const color = pct > 0.5 ? '#e74c3c' : pct > 0.25 ? '#e67e22' : '#8b0000'; + if (orbEl) { + orbEl.style.background = `radial-gradient(circle at 40% 35%, ${color}, #3a0000)`; + orbEl.style.boxShadow = `0 0 12px ${color}cc`; + } + + /* Avatar-Schüttelanimation + roter Flash */ + if (avEl) { + avEl.style.transition = 'transform 0.07s ease'; + avEl.style.transform = 'scale(1.07)'; + setTimeout(() => { avEl.style.transform = 'scale(0.96)'; }, 70); + setTimeout(() => { avEl.style.transform = ''; }, 150); + + const flash = document.createElement('div'); + flash.style.cssText = 'position:absolute;inset:0;border-radius:inherit;background:rgba(220,50,50,0.4);z-index:20;pointer-events:none;'; + avEl.style.position = 'relative'; + avEl.appendChild(flash); + setTimeout(() => flash.remove(), 320); + } +} + +function applyHpFromEvent(data) { + if (!data.hp || !data.maxHp) return; + ['player1','player2'].forEach(slot => { + if (data.hp[slot] != null) updateHpDisplay(slot, data.hp[slot], data.maxHp[slot] ?? data.hp[slot]); + }); +} + +/* Initiale HP beim Spielstart */ +socket.on('hp_init', data => { + applyHpFromEvent(data); + console.log('[HP] Init:', data.hp); +}); + +/* Avatar getroffen */ +socket.on('avatar_damaged', data => { + const { slot, damage, remainingHp, maxHp } = data; + updateHpDisplay(slot, remainingHp, maxHp); + console.log(`[HP] ${slot} -${damage} → ${remainingHp}/${maxHp}`); + + /* Schadens-Zahl einblenden */ + const isLeft = slot === (window._leftSlot || 'player1'); + const avEl = document.getElementById(isLeft ? 'avLeft' : 'avRight'); + if (avEl) { + avEl.style.position = 'relative'; + const dmg = document.createElement('div'); + dmg.textContent = `-${damage}`; + dmg.style.cssText = ` + position:absolute;top:15%;left:50%;transform:translateX(-50%); + font-family:'Cinzel',serif;font-size:calc(var(--s)*24);font-weight:700; + color:#e74c3c;text-shadow:0 2px 10px rgba(0,0,0,0.95); + pointer-events:none;z-index:30; + animation:dmg-float 1s ease forwards;`; + avEl.appendChild(dmg); + setTimeout(() => dmg.remove(), 1000); + } +}); + /* ═══════════════════════════════════════════════════════════ AUFGEBEN ═══════════════════════════════════════════════════════════ */ @@ -878,35 +969,59 @@ function showResultOverlay(won, data) { } function updateResultWithPoints(data) { - const overlay = document.getElementById('match-result-overlay'); - 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'); + /* Verhindert doppeltes Aufrufen */ + if (document.getElementById('match-end-overlay')) return; - if (!overlay.classList.contains('show')) { - document.getElementById('result-title').textContent = data.won ? '\u2694\uFE0F SIEG!' : '\uD83D\uDC80 NIEDERLAGE'; - document.getElementById('result-title').className = 'result-title ' + (data.won ? 'win' : 'lose'); - overlay.classList.add('show'); + const img = data.won ? '/images/victory.jpeg' : '/images/defeat.jpeg'; + const awarded = data.awarded ?? 0; + + /* Fade-in Keyframe einmalig anlegen */ + if (!document.getElementById('_matchEndStyle')) { + const st = document.createElement('style'); + st.id = '_matchEndStyle'; + st.textContent = ` + @keyframes matchEndFadeIn { from{opacity:0} to{opacity:1} } + @keyframes matchPtsSlideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }`; + document.head.appendChild(st); } - pointsEl.textContent = data.awarded > 0 - ? '+' + data.awarded + ' Arena-Punkte' - : 'Keine Punkte (Aufgabe zu fr\u00FCh oder Tageslimit)'; + /* Punkte-Text aufbauen */ + const ptsLine = awarded > 0 + ? '+' + awarded + ' Arena-Punkte' + : 'Keine Punkte'; + const lvlLine = data.level_up + ? '⬆ LEVEL UP! → Level ' + data.new_level + : ''; - if (data.level_up) { - levelupEl.style.display = 'block'; - levelupEl.textContent = '\u2B06 LEVEL UP! \u2192 Level ' + data.new_level; - } + const overlay = document.createElement('div'); + overlay.id = 'match-end-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:#000;animation:matchEndFadeIn 0.5s ease forwards;'; + overlay.innerHTML = ` + +
+
${ptsLine}
+ ${lvlLine ? `
${lvlLine}
` : ''} +
`; + document.body.appendChild(overlay); + + /* Aktuellen Punktestand nachladen und anzeigen */ fetch('/api/points/me').then(r => r.json()).then(me => { - progressEl.style.display = 'block'; - lvlLbl.textContent = 'Level ' + me.current_level + (me.next_level ? ' \u2192 ' + me.next_level : ' (MAX)'); - ptsLbl.textContent = me.points_this_level + ' / ' + me.points_for_next + ' Pts'; - requestAnimationFrame(() => { fillEl.style.width = me.progress_percent + '%'; }); + const info = overlay.querySelector('div > div:first-child'); + if (info && awarded > 0) { + info.insertAdjacentHTML('afterend', + `
Gesamt: ${me.arena_points} Pts • Level ${me.level}
` + ); + } }).catch(() => {}); + + /* Nach 3 Sekunden zur Arena weiterleiten */ + setTimeout(() => closeToArena(), 3000); } function closePopup() { closeToArena(); } diff --git a/routes/arena.route.js b/routes/arena.route.js index be64c53..bb091aa 100644 --- a/routes/arena.route.js +++ b/routes/arena.route.js @@ -17,21 +17,21 @@ function requireLogin(req, res, next) { HELPER: Spieler-Stats laden ======================== */ +/* HP-Formel: 20 + (level-1)*2 | Level 1=20, Level 50=118 */ +function calcAvatarHp(level) { + return 20 + (Math.max(1, Math.min(50, level || 1)) - 1) * 2; +} + async function getPlayerStats(userId) { - let hp = 20, mana = 3; try { - const [[charStats]] = await db.query( - "SELECT hp, mana FROM characters WHERE account_id = ?", + const [[acc]] = await db.query( + "SELECT level FROM accounts WHERE id = ?", [userId] ); - if (charStats) { - hp = charStats.hp || 20; - mana = charStats.mana || 3; - } + return { hp: calcAvatarHp(acc?.level ?? 1), mana: 3 }; } catch { - // Tabelle existiert evtl. noch nicht – Defaults verwenden + return { hp: 20, mana: 3 }; } - return { hp, mana }; } /* ================================ diff --git a/routes/points.route.js b/routes/points.route.js index b9d3243..70f57b0 100644 --- a/routes/points.route.js +++ b/routes/points.route.js @@ -13,7 +13,7 @@ const db = require("../database/database"); /* ── Punkte-Konfiguration ───────────────────────────────── */ const POINTS = { - "1v1": { win: 15, lose: 3 }, + "1v1": { win: 15, lose: 5 }, "2v2": { win: 12, lose: 2 }, "4v4": { win: 10, lose: 2 }, }; @@ -230,4 +230,5 @@ router.get("/me", requireLogin, async (req, res) => { } }); +router.awardPoints = awardPoints; module.exports = router; diff --git a/sockets/arena.socket.js b/sockets/arena.socket.js index f1f1c73..5f7bb42 100644 --- a/sockets/arena.socket.js +++ b/sockets/arena.socket.js @@ -4,7 +4,14 @@ inkl. Kampfphase nach end_turn ============================================================ */ -const { runCombatPhase } = require('./combat'); // combat.js liegt im selben /sockets/ Ordner +const { runCombatPhase } = require('./combat'); +const db = require('../database/database'); +const pointsRoute = require('../routes/points.route'); + +/* ── HP-Formel (muss mit arena.route.js übereinstimmen) ── */ +function calcAvatarHp(level) { + return 20 + (Math.max(1, Math.min(50, level || 1)) - 1) * 2; +} const waitingPool = new Map(); const LEVEL_RANGE = 5; @@ -223,19 +230,20 @@ function stopReadyTimer(io, matchId) { damit turn_change erst NACH den Animationen gesendet wird. ═══════════════════════════════════════════════════════════ */ function calcCombatDuration(events) { - // Muss mit den COMBAT_DELAY_* Werten in 1v1.js übereinstimmen - const DELAY_BANNER = 600; // initiale Pause vor erstem Event - const DELAY_MOVE = 350; - const DELAY_ATTACK = 450; - const DELAY_DIE = 300; - const DELAY_FINAL = 500; // finalBoard sync - const BUFFER = 400; // Sicherheitspuffer + const DELAY_BANNER = 600; + const DELAY_MOVE = 350; + const DELAY_ATTACK = 450; + const DELAY_DIE = 300; + const DELAY_AVATAR_ATTACK= 500; + const DELAY_FINAL = 500; + const BUFFER = 400; let total = DELAY_BANNER + DELAY_FINAL + BUFFER; for (const ev of events) { - if (ev.type === 'move') total += DELAY_MOVE; - if (ev.type === 'attack') total += DELAY_ATTACK; - if (ev.type === 'die') total += DELAY_DIE; + if (ev.type === 'move') total += DELAY_MOVE; + if (ev.type === 'attack') total += DELAY_ATTACK; + if (ev.type === 'die') total += DELAY_DIE; + if (ev.type === 'avatar_attack')total += DELAY_AVATAR_ATTACK; } return total; } @@ -418,6 +426,142 @@ function emitToOpponent(io, matchId, senderSlot, event, data) { } } +/* ═══════════════════════════════════════════════════════════ + AVATAR-HP HELPERS +═══════════════════════════════════════════════════════════ */ + +/** HP für beide Spieler beim Spielstart initialisieren */ +async function initMatchHP(io, matchId, room) { + try { + for (const slot of ['player1', 'player2']) { + const accountId = room.accountIds[slot]; + if (!accountId) continue; + + const [[acc]] = await db.query( + "SELECT level FROM accounts WHERE id = ?", + [accountId] + ); + const level = acc?.level ?? 1; + const maxHp = calcAvatarHp(level); + + room.hp[slot] = maxHp; + room.maxHp[slot] = maxHp; + + await db.query( + `INSERT INTO arena_match_hp (match_id, slot, account_id, current_hp, max_hp) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE current_hp = ?, max_hp = ?`, + [matchId, slot, accountId, maxHp, maxHp, maxHp, maxHp] + ); + } + + // Initiale HP an beide Clients senden + emitToMatch(io, matchId, 'hp_init', { hp: room.hp, maxHp: room.maxHp }); + console.log(`[HP] Init Match ${matchId}: P1=${room.hp.player1}/${room.maxHp.player1}, P2=${room.hp.player2}/${room.maxHp.player2}`); + } catch (err) { + console.error('[HP] initMatchHP Fehler:', err); + } +} + +/** Avatar-Treffer aus Combat-Events verarbeiten, Match bei Tod beenden */ +async function processAvatarAttacks(io, matchId, room, events) { + const avatarEvents = events.filter(e => e.type === 'avatar_attack'); + if (avatarEvents.length === 0) return false; + + for (const ev of avatarEvents) { + const target = ev.target; // 'player1' | 'player2' + if (room.hp[target] == null) continue; + + room.hp[target] = Math.max(0, (room.hp[target] ?? 0) - ev.damage); + + // In DB persistieren + try { + await db.query( + `UPDATE arena_match_hp SET current_hp = ? + WHERE match_id = ? AND slot = ?`, + [room.hp[target], matchId, target] + ); + } catch (err) { + console.error('[HP] DB Update Fehler:', err); + } + + // Treffer an beide Clients senden + emitToMatch(io, matchId, 'avatar_damaged', { + slot : target, + damage : ev.damage, + remainingHp: room.hp[target], + maxHp : room.maxHp[target] ?? 20, + }); + + console.log(`[HP] ${target} trifft ${ev.damage} Schaden → verbleibend: ${room.hp[target]}`); + + // Match-Ende prüfen + if (room.hp[target] <= 0) { + await handleMatchEnd(io, matchId, room, target); + return true; // Match beendet + } + } + return false; +} + +/** Match beenden: Ergebnis emitieren + 15 Punkte an Gewinner vergeben */ +async function handleMatchEnd(io, matchId, room, loserSlot) { + if (room.gameOver) return; + room.gameOver = true; + + const winnerSlot = loserSlot === 'player1' ? 'player2' : 'player1'; + const winnerAccId = room.accountIds[winnerSlot]; + const loserAccId = room.accountIds[loserSlot]; + + console.log(`[Match] Ende: Gewinner=${winnerSlot} (${winnerAccId}), Verlierer=${loserSlot} (${loserAccId}) | ${matchId}`); + + // Gewinner: 15 Punkte | Verlierer: 5 Punkte + let winnerResult = { awarded: 0 }; + let loserResult = { awarded: 0 }; + + if (winnerAccId) { + try { + winnerResult = await pointsRoute.awardPoints(winnerAccId, 15); + } catch (err) { + console.error('[Match] Gewinner-Punkte Fehler:', err); + } + } + + if (loserAccId) { + try { + loserResult = await pointsRoute.awardPoints(loserAccId, 5); + } catch (err) { + console.error('[Match] Verlierer-Punkte Fehler:', err); + } + } + + // Ergebnis an beide Clients senden + const room_ = getRoom(io, matchId); + + if (room_.sockets[winnerSlot]) { + io.to(room_.sockets[winnerSlot]).emit('match_result', { + won : true, + awarded : winnerResult.awarded ?? 0, + level_up : winnerResult.level_up ?? false, + new_level: winnerResult.new_level ?? null, + }); + } + + if (room_.sockets[loserSlot]) { + io.to(room_.sockets[loserSlot]).emit('match_result', { + won : false, + awarded : loserResult.awarded ?? 0, + level_up : loserResult.level_up ?? false, + new_level: loserResult.new_level ?? null, + }); + } + + // HP-Eintrag bereinigen + try { + await db.query("DELETE FROM arena_match_hp WHERE match_id = ?", [matchId]); + } catch {} +} + /* ═══════════════════════════════════════════════════════════ HAUPT-HANDLER ═══════════════════════════════════════════════════════════ */ @@ -459,11 +603,15 @@ function registerArenaHandlers(io, socket) { if (!io._arenaRooms) io._arenaRooms = new Map(); if (!io._arenaRooms.has(matchId)) { io._arenaRooms.set(matchId, { - sockets : {}, - names : {}, - boardCards: [], // Array-Format für boardSync/reconnect - boardState: {}, // NEU: { [slotId]: { card, owner } } für Kampfphase - leftSlot : null, // NEU: welcher Slot ist der linke Spieler + sockets : {}, + names : {}, + accountIds : {}, // { player1: accountId, player2: accountId } + boardCards : [], + boardState : {}, + leftSlot : null, + hp : {}, // { player1: current, player2: current } in-memory + maxHp : {}, // { player1: max, player2: max } + gameOver : false, }); } @@ -476,6 +624,9 @@ function registerArenaHandlers(io, socket) { room.names[slot] = playerName || "Spieler"; } + // Account-ID speichern (wird für HP-Init und Punkte-Vergabe benötigt) + if (data.accountId) room.accountIds[slot] = data.accountId; + console.log( `[1v1] Name gesetzt: slot=${slot}, name=${room.names[slot]}, playerName=${playerName}`, ); @@ -495,6 +646,8 @@ function registerArenaHandlers(io, socket) { player1 : room.names["player1"] || "Spieler 1", player2 : room.names["player2"] || "Spieler 2", boardSync: room.boardCards || [], + hp : room.hp || {}, + maxHp : room.maxHp || {}, }); startReadyTimer(io, matchId); } else { @@ -561,7 +714,7 @@ function registerArenaHandlers(io, socket) { }); /* ── Startspieler festlegen ── */ - socket.on("start_turn_request", (data) => { + socket.on("start_turn_request", async (data) => { const { matchId, starterSlot } = data; if (!matchId || !starterSlot) return; if (!io._turnInit) io._turnInit = new Set(); @@ -573,10 +726,15 @@ function registerArenaHandlers(io, socket) { const room = io._arenaRooms?.get(matchId); if (room) room.leftSlot = starterSlot; + // Avatar-HP initialisieren + await initMatchHP(io, matchId, room); + const boardCards = room?.boardCards || []; emitToMatch(io, matchId, "turn_change", { activeSlot: starterSlot, boardSync : boardCards, + hp : room.hp, + maxHp : room.maxHp, }); console.log( `[1v1] Spiel startet → ${starterSlot} (linker Spieler) beginnt | Match ${matchId}`, @@ -614,12 +772,19 @@ function registerArenaHandlers(io, socket) { `[1v1] Kampfphase: ${combatEvents.length} Events | Zug: ${slot} → ${nextSlot} | Match ${matchId}`, ); + // Avatar-Schaden aus Combat-Events verarbeiten + const matchEnded = await processAvatarAttacks(io, matchId, room, combatEvents); + if (matchEnded) return; // Match vorbei – kein turn_change mehr + // Zugwechsel NACH den Client-Animationen senden const animDuration = calcCombatDuration(combatEvents); setTimeout(() => { + if (room.gameOver) return; emitToMatch(io, matchId, "turn_change", { activeSlot: nextSlot, - boardSync : room.boardCards, // aktualisierter Stand nach Kampf + boardSync : room.boardCards, + hp : room.hp, + maxHp : room.maxHp, }); console.log( `[1v1] turn_change gesendet nach ${animDuration}ms | ${slot} → ${nextSlot} | Match ${matchId}`, diff --git a/sockets/combat.js b/sockets/combat.js index bdb8e17..bff0dcd 100644 --- a/sockets/combat.js +++ b/sockets/combat.js @@ -2,104 +2,80 @@ * sockets/combat.js – Server-seitige Kampfphasen-Logik für 1v1 * * PRO KARTE: erst bewegen, dann sofort angreifen → dann nächste Karte. - * - * Reihenfolge (nur Karten des aktiven Spielers): - * Linker Spieler (dir +1): höchste Slot-Zahl zuerst (11→1) - * Rechter Spieler (dir -1): niedrigste Slot-Zahl zuerst (1→11) - * → Vorderste Karte verarbeitet zuerst, macht Platz für die dahinter. + * Avatar-Angriff wenn Range über das Spielfeld hinausreicht. */ - 'use strict'; -/** - * @param {Object} boardState – wird in-place verändert - * @param {string} leftSlot – wer links steht ('player1'|'player2') - * @param {string} activeSlot – wer gerade am Zug ist ('player1'|'player2') - * @returns {Array} – geordnete Event-Liste für den Client - */ function runCombatPhase(boardState, leftSlot, activeSlot) { - const events = []; + const events = []; const isActiveLeft = activeSlot === leftSlot; const dir = isActiveLeft ? 1 : -1; + const opponentSlot = activeSlot === 'player1' ? 'player2' : 'player1'; - /* ── Karten des aktiven Spielers sammeln ────────────────── */ const myCards = []; for (let slotIndex = 1; slotIndex <= 11; slotIndex++) { for (const row of ['row1', 'row2']) { const slotId = `${row}-slot-${slotIndex}`; - if (boardState[slotId]?.owner === activeSlot) { - myCards.push(slotId); - } + if (boardState[slotId]?.owner === activeSlot) myCards.push(slotId); } } - /* ── Vorderste Karte zuerst ─────────────────────────────── */ myCards.sort((a, b) => { const ia = parseInt(a.split('-slot-')[1], 10); const ib = parseInt(b.split('-slot-')[1], 10); return isActiveLeft ? ib - ia : ia - ib; }); - /* ── Jede Karte: erst bewegen, dann sofort angreifen ────── */ for (const startSlotId of myCards) { const entry = boardState[startSlotId]; - if (!entry) continue; // wurde durch vorherigen Angriff bereits entfernt (sollte nicht vorkommen, aber sicher ist sicher) + if (!entry) continue; const { card } = entry; const row = startSlotId.split('-slot-')[0]; const race = card.race ?? 0; + const atk = card.attack ?? 0; + const range = card.range ?? 0; let currentPos = parseInt(startSlotId.split('-slot-')[1], 10); let currentSlotId = startSlotId; - /* ── 1. BEWEGEN ─────────────────────────────────────── */ + /* ── BEWEGEN ── */ for (let step = 0; step < race; step++) { const nextPos = currentPos + dir; if (nextPos < 1 || nextPos > 11) break; - const nextSlotId = `${row}-slot-${nextPos}`; - if (boardState[nextSlotId]) break; // eigene oder feindliche Karte blockiert - + if (boardState[nextSlotId]) break; delete boardState[currentSlotId]; boardState[nextSlotId] = entry; - events.push({ type: 'move', from: currentSlotId, to: nextSlotId, owner: activeSlot }); - currentSlotId = nextSlotId; currentPos = nextPos; } - /* ── 2. ANGREIFEN (sofort nach der Bewegung) ────────── */ - const range = card.range ?? 0; - + /* ── ANGREIFEN ── */ for (let r = 1; r <= range; r++) { const targetPos = currentPos + dir * r; - if (targetPos < 1 || targetPos > 11) break; + + /* Avatar-Angriff: Range geht über das Spielfeld hinaus */ + if (targetPos < 1 || targetPos > 11) { + events.push({ type: 'avatar_attack', from: currentSlotId, target: opponentSlot, damage: atk }); + break; + } const targetSlotId = `${row}-slot-${targetPos}`; const target = boardState[targetSlotId]; - if (!target) continue; // leeres Feld → weiter scannen - if (target.owner === activeSlot) continue; // eigene Karte → Range geht hindurch + if (!target) continue; + if (target.owner === activeSlot) continue; - // Feindliche Karte gefunden → Angriff - const atk = card.attack ?? 0; target.card = { ...target.card, defends: (target.card.defends ?? 0) - atk }; - - events.push({ - type : 'attack', - from : currentSlotId, - to : targetSlotId, - damage : atk, - remainingDef: target.card.defends, - }); + events.push({ type: 'attack', from: currentSlotId, to: targetSlotId, damage: atk, remainingDef: target.card.defends }); if (target.card.defends <= 0) { delete boardState[targetSlotId]; events.push({ type: 'die', slotId: targetSlotId }); } - - break; // nur erste feindliche Karte angreifen + break; } }