/** * Star Wars Hearthstone - Client * PvP over LAN (Radmin VPN), 2-4 players */ (function () { 'use strict'; let socket = null; let gameState = null; let cardDb = {}; let yourIndex = -1; let attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 }; let spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; let heroAbilityMode = { active: false }; let stealCardsMode = { active: false, handIndex: -1, targetPlayerIndex: null, targetDeck: [], selectedIndices: [] }; let battlecryTargetMode = { active: false, battlecryId: '', cardId: '', minionId: '', targetPlayerIndex: null, targetBoardIndex: null }; const seenMinions = new Set(); let lastHandLength = 0; let prevGameState = null; let combatTimeout = null; const DEFAULT_PORT = 3542; // Touch support variables let touchState = { startX: 0, startY: 0, startTime: 0, target: null, isSwipe: false, minSwipeDistance: 50, maxSwipeTime: 500, longPressTimer: null, longPressDelay: 500 }; const $ = (id) => document.getElementById(id); const $all = (sel) => document.querySelectorAll(sel); // Helper to detect if device is touch-enabled const isTouchDevice = () => { return 'ontouchstart' in window || navigator.maxTouchPoints > 0; }; // Helper to get touch coordinates const getTouchCoords = (e) => { if (e.touches && e.touches.length > 0) { return { x: e.touches[0].clientX, y: e.touches[0].clientY }; } return { x: e.clientX, y: e.clientY }; }; // Helper to find element at coordinates const elementFromPoint = (x, y) => { const el = document.elementFromPoint(x, y); if (!el) return null; return el.closest('.card-wrap, .drop-target, .hero-target, .spell-target, .hero-ability-target'); }; function spawnEffect(type, x, y) { const layer = $('effects-layer'); if (!layer) return; const el = document.createElement('div'); el.className = 'effect effect-' + type; el.style.left = (typeof x === 'number' ? x : 0) + 'px'; el.style.top = (typeof y === 'number' ? y : 0) + 'px'; layer.appendChild(el); setTimeout(function () { el.remove(); }, 800); } function showScreen(id) { $all('.screen').forEach((s) => s.classList.add('hidden')); const el = $(id); if (el) el.classList.remove('hidden'); } function showError(msg) { const el = $('lobby-error'); if (!el) return; el.textContent = msg; el.classList.remove('hidden'); } function clearError() { const el = $('lobby-error'); if (el) el.classList.add('hidden'); } function setLobbyPanel(name) { $all('#lobby .panel').forEach((p) => { p.classList.remove('active', 'hidden'); if (p.id !== name + '-panel') p.classList.add('hidden'); else p.classList.add('active'); }); } function renderPlayerList(listId, players) { const ul = $(listId); if (!ul) return; ul.innerHTML = (players || []).map((p) => `
  • ${escapeHtml(p.name)}
  • `).join(''); } function escapeHtml(s) { const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } function connect(url) { if (socket) { socket.disconnect(); socket.removeAllListeners(); } socket = io(url, { transports: ['websocket', 'polling'], reconnection: false }); socket.on('connect', () => { clearError(); }); socket.on('connect_error', (e) => { const errorMsg = e.message || 'Не удалось подключиться к серверу'; showError(errorMsg + '. Проверьте, что сервер запущен и используйте расширенные настройки для указания IP вручную.'); }); socket.on('error', (msg) => showError(msg)); socket.on('roomCreated', (data) => { const codeEl = $('room-code-display'); if (codeEl) { codeEl.textContent = data.code; codeEl.onclick = function () { navigator.clipboard.writeText(data.code).then(() => { const old = codeEl.textContent; codeEl.textContent = 'Скопировано!'; setTimeout(() => { codeEl.textContent = old; }, 1500); }).catch(() => {}); }; } const connectInfoEl = $('room-connect-info'); if (connectInfoEl && data.serverIP && data.serverPort) { const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const serverAddr = isLocal ? 'localhost:' + data.serverPort : data.serverIP + ':' + data.serverPort; const fullInfo = serverAddr + ' → код: ' + data.code; connectInfoEl.textContent = fullInfo; connectInfoEl.onclick = function () { navigator.clipboard.writeText(fullInfo).then(() => { const old = connectInfoEl.textContent; connectInfoEl.textContent = 'Скопировано!'; setTimeout(() => { connectInfoEl.textContent = old; }, 1500); }).catch(() => {}); }; } setLobbyPanel('room'); renderPlayerList('player-list', data.lobby); const portEl = $('display-port'); if (portEl) portEl.textContent = data.serverPort || window.location.port || DEFAULT_PORT; }); socket.on('roomJoined', (data) => { setLobbyPanel('connect'); renderPlayerList('connect-player-list', data.lobby); const statusEl = $('connect-status'); if (statusEl) statusEl.textContent = 'Подключено к комнате. Ожидание начала игры...'; }); socket.on('lobbyUpdate', (lobby) => { renderPlayerList('player-list', lobby); renderPlayerList('connect-player-list', lobby); }); socket.on('backToLobby', () => { showScreen('lobby'); clearError(); setLobbyPanel('host'); const hp = $('host-panel'); if (hp) hp.classList.add('active'); }); socket.on('turnTime', function (data) { var el = $('turn-timer'); if (!el) return; var m = Math.floor(data.left / 60); var s = data.left % 60; el.textContent = m + ':' + (s < 10 ? '0' : '') + s; el.classList.toggle('timer-warn', data.left > 0 && data.left <= 15); el.classList.toggle('timer-ended', data.ended); if (data.warn && typeof window.Sounds !== 'undefined') window.Sounds.timerWarning(); if (data.ended && typeof window.Sounds !== 'undefined') window.Sounds.timerEnd(); }); socket.on('chatMessage', (data) => { const chatMessagesEl = $('chat-messages'); if (chatMessagesEl && data) { const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const messageEl = document.createElement('div'); messageEl.className = 'chat-message user'; messageEl.innerHTML = '' + escapeHtml(data.playerName || 'Игрок') + ': ' + escapeHtml(data.message || '') + ' ' + escapeHtml(time) + ''; chatMessagesEl.appendChild(messageEl); chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; } }); socket.on('gameState', (state) => { if (combatTimeout) { clearTimeout(combatTimeout); combatTimeout = null; } const wasLobby = $('game')?.classList.contains('hidden') === true; if (wasLobby) { seenMinions.clear(); lastHandLength = 0; prevGameState = null; } prevGameState = gameState && state.phase !== 'ended' ? { ...gameState, players: gameState.players.map((p) => ({ ...p, board: (p.board || []).map((m) => ({ ...m })) })) } : null; showScreen('game'); // Инициализация чата при первом запуске игры if (wasLobby) { const chatMessagesEl = $('chat-messages'); if (chatMessagesEl) { chatMessagesEl.innerHTML = ''; const welcomeMsg = document.createElement('div'); welcomeMsg.className = 'chat-message system'; welcomeMsg.textContent = 'Игра началась!'; chatMessagesEl.appendChild(welcomeMsg); } } gameState = state; cardDb = state.cardDb || {}; yourIndex = state.yourIndex ?? 0; if (state.phase === 'ended') { showGameOver(state); renderGameEnded(state); return; } renderGame(state); }); socket.on('stealCardsRequest', (data) => { if (!gameState) return; // Находим handIndex заклинания "Грабеж" в текущей руке const you = gameState.players[gameState.yourIndex]; if (!you || !you.hand) return; // Ищем заклинание "Грабеж" в руке let handIndex = -1; for (let i = 0; i < you.hand.length; i++) { const card = cardDb[you.hand[i]]; if (card && card.spellEffect === 'steal_cards') { handIndex = i; break; } } if (handIndex < 0) return; stealCardsMode.active = true; stealCardsMode.handIndex = handIndex; stealCardsMode.targetPlayerIndex = data.targetPlayerIndex; stealCardsMode.targetDeck = []; stealCardsMode.selectedIndices = []; // Получаем колоду противника из gameState const targetPlayer = gameState.players[data.targetPlayerIndex]; if (targetPlayer && targetPlayer.deck) { stealCardsMode.targetDeck = [...targetPlayer.deck]; } showStealCardsModal(gameState, data); }); socket.on('battlecryTargetRequest', (data) => { if (data.battlecryId === 'return_hand_enemy') { // Показываем модальное окно для выбора противника и его миньона showBattlecryTargetModal(gameState, data); } }); return socket; } function showBattlecryTargetModal(state, data) { battlecryTargetMode = { active: true, battlecryId: data.battlecryId, cardId: data.cardId, minionId: data.minionId }; // Активируем режим выбора цели spellMode = { active: true, handIndex: -1, cardId: data.cardId, spellTarget: 'enemy_minion', battlecryMode: true, minionId: data.minionId }; $('spell-mode')?.classList.remove('hidden'); const spellModeText = $('spell-mode')?.querySelector('p'); if (spellModeText) { spellModeText.textContent = 'Выберите миньона противника для возврата в руку'; } renderGame(state); } function renderGameEnded(state) { const badge = $('turn-badge'); if (badge) badge.textContent = 'Игра окончена'; const endBtn = $('btn-end-turn'); if (endBtn) endBtn.disabled = true; const resetBtn = $('btn-reset-lobby'); if (resetBtn) { resetBtn.classList.toggle('hidden', state.yourIndex !== 0); } bindGameEvents(state); } function showGameOver(state) { const winner = state.winner; const modal = $('modal-overlay'); const title = $('modal-title'); const body = $('modal-body'); if (!modal || !title || !body) return; if (winner && winner.id === socket?.id) { title.textContent = 'Победа!'; title.style.color = 'var(--amber)'; body.textContent = 'Светлая сторона Силы с тобой.'; if (typeof window.Sounds !== 'undefined') window.Sounds.victory(); } else if (winner) { title.textContent = 'Поражение'; title.style.color = 'var(--red)'; body.textContent = 'Победу одержал ' + (winner.name || 'противник') + '.'; if (typeof window.Sounds !== 'undefined') window.Sounds.defeat(); } else { title.textContent = 'Игра окончена'; title.style.color = 'var(--cyan)'; body.textContent = 'Победитель не определён.'; } modal.classList.remove('hidden'); } function showTurnNotification(state) { const notification = $('turn-notification'); const notificationText = $('turn-notification-text'); if (!notification || !notificationText) return; const currentPlayer = state.players[state.currentPlayerIndex]; const isYourTurn = state.currentPlayerIndex === state.yourIndex; const playerName = currentPlayer?.name || `Игрок ${state.currentPlayerIndex + 1}`; if (isYourTurn) { notificationText.textContent = 'ВАШ ХОД'; notificationText.parentElement.classList.add('your-turn'); } else { notificationText.textContent = `ХОД: ${playerName.toUpperCase()}`; notificationText.parentElement.classList.remove('your-turn'); } // Показываем нотификацию на 2 секунды notification.classList.add('show'); setTimeout(() => { notification.classList.remove('show'); }, 2000); } function renderBoards(state) { const you = state.players[yourIndex]; if (!you) return; const opponentsArea = $('opponents-area'); const yourBoard = $('your-board'); if (opponentsArea) { opponentsArea.innerHTML = state.players .map((p, i) => { if (i === yourIndex) return ''; const isCurrent = state.currentPlayerIndex === i; const isDead = p.health <= 0 || p.isDead; const name = p.name || 'Игрок ' + (i + 1); const minions = (p.board || []).map((m, j) => renderBoardMinion(m, i, j, state, true, false)); const heroBar = renderHeroTarget(i, state); const heroDrop = renderHeroDropZone(i, state); const canSteal = state.currentPlayerIndex === state.yourIndex && spellMode.active && spellMode.cardId && cardDb[spellMode.cardId]?.spellEffect === 'steal_cards'; return `
    ${escapeHtml(name)}${isDead ? ' ' : ''}
    ❤ ${p.health ?? 30} 🔵 ${p.mana ?? 0}/${p.maxMana ?? 0} ${p.deck && p.deck.length > 0 ? `📚 ${p.deck.length}` : ''}
    ${heroDrop}${heroBar}${minions.join('')}
    `; }) .filter(Boolean) .join(''); } if (yourBoard) { yourBoard.innerHTML = (you.board || []).map((m, j) => { const isNew = !seenMinions.has(m.id); if (isNew) seenMinions.add(m.id); return renderBoardMinion(m, yourIndex, j, state, false, isNew); }).join(''); } } function showDamageEffect(element, damage, isHero) { if (!element) return; const damageEl = document.createElement('div'); damageEl.className = 'damage-indicator'; damageEl.textContent = '-' + damage; damageEl.style.position = 'absolute'; const rect = element.getBoundingClientRect(); damageEl.style.left = (rect.left + rect.width / 2) + 'px'; damageEl.style.top = (rect.top + rect.height / 2) + 'px'; damageEl.style.pointerEvents = 'none'; damageEl.style.zIndex = '1000'; document.body.appendChild(damageEl); setTimeout(() => { damageEl.style.animation = 'damageFloat 1.5s ease-out forwards'; setTimeout(() => damageEl.remove(), 1000); // Сократил с 1500 до 1000 мс }, 10); } function showAttackAnnouncement(state, logEntry) { const announcementEl = $('attack-announcement'); if (!announcementEl || !logEntry) return; // Обработка специальных событий if (logEntry.type === 'skipTurn') { const playerName = state.players[logEntry.playerIndex]?.name || `Игрок ${logEntry.playerIndex + 1}`; announcementEl.textContent = `⏭ ${playerName} пропускает ход`; announcementEl.classList.remove('hidden'); setTimeout(() => { announcementEl.classList.add('hidden'); }, 2000); return; } if (logEntry.type === 'playerDefeated') { const playerName = state.players[logEntry.playerIndex]?.name || `Игрок ${logEntry.playerIndex + 1}`; announcementEl.textContent = `💀 ${playerName} выбыл из игры`; announcementEl.classList.remove('hidden'); setTimeout(() => { announcementEl.classList.add('hidden'); }, 2500); return; } const who = (i) => state.players[i]?.name || ('Игрок ' + (i + 1)); let text = ''; if (logEntry.type === 'attack' && logEntry.attackerMinionId && logEntry.targetMinionId) { const attackerPlayer = state.players.find(p => p.board?.some(m => m.id === logEntry.attackerMinionId)); const targetPlayer = state.players.find(p => p.board?.some(m => m.id === logEntry.targetMinionId)); if (attackerPlayer && targetPlayer) { const attackerIdx = state.players.indexOf(attackerPlayer); const targetIdx = state.players.indexOf(targetPlayer); const attackerMinion = attackerPlayer.board.find(m => m.id === logEntry.attackerMinionId); const targetMinion = targetPlayer.board.find(m => m.id === logEntry.targetMinionId); const attackerCard = cardDb[attackerMinion?.cardId]; const targetCard = cardDb[targetMinion?.cardId]; const attackerName = attackerCard?.name || 'Миньон'; const targetName = targetCard?.name || 'Миньон'; const damage = logEntry.damage || attackerMinion?.attack || 0; text = who(attackerIdx) + ' → ' + attackerName + ' ⚔ атакует ' + targetName + ' (' + damage + ' урона) → ' + who(targetIdx); } } else if (logEntry.type === 'attackHero' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) { const attackerPlayer = state.players[logEntry.fromPlayer]; const targetPlayer = state.players[logEntry.toPlayer]; if (attackerPlayer && targetPlayer) { if (logEntry.attackerMinionId) { const attackerMinion = attackerPlayer.board?.find(m => m.id === logEntry.attackerMinionId); const attackerCard = cardDb[attackerMinion?.cardId]; const attackerName = attackerCard?.name || 'Миньон'; const damage = logEntry.damage || attackerMinion?.attack || 0; text = who(logEntry.fromPlayer) + ' → ' + attackerName + ' ⚔ атакует героя ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!'; } else { const damage = logEntry.damage || 0; text = who(logEntry.fromPlayer) + ' ⚔ атакует героя ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!'; } } } else if (logEntry.type === 'spell' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) { const spellCard = cardDb[logEntry.spell]; const spellName = spellCard?.name || 'Заклинание'; if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) { const targetPlayer = state.players[logEntry.toPlayer]; const targetMinion = targetPlayer?.board?.[logEntry.toIdx]; const targetCard = cardDb[targetMinion?.cardId]; const targetName = targetCard?.name || 'Миньон'; const damage = logEntry.damage || ''; const damageText = damage ? ' (' + damage + ' урона)' : ''; text = who(logEntry.fromPlayer) + ' → ' + spellName + ' ✨ по ' + targetName + damageText + ' → ' + who(logEntry.toPlayer); } else { const damage = logEntry.damage || ''; const damageText = damage ? ' (' + damage + ' урона)' : ''; text = who(logEntry.fromPlayer) + ' → ' + spellName + ' ✨ по герою ' + who(logEntry.toPlayer) + damageText + '!'; } } else if (logEntry.type === 'heroAbility' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) { if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) { const targetPlayer = state.players[logEntry.toPlayer]; const targetMinion = targetPlayer?.board?.[logEntry.toIdx]; const targetCard = cardDb[targetMinion?.cardId]; const targetName = targetCard?.name || 'Миньон'; const damage = logEntry.damage || 1; text = who(logEntry.fromPlayer) + ' → Геройская способность ⚡ по ' + targetName + ' (' + damage + ' урона) → ' + who(logEntry.toPlayer); } else { const damage = logEntry.damage || 1; text = who(logEntry.fromPlayer) + ' → Геройская способность ⚡ по герою ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!'; } } else if (logEntry.type === 'battlecry' && logEntry.damage && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) { const attackerPlayer = state.players[logEntry.fromPlayer]; const targetPlayer = state.players[logEntry.toPlayer]; if (attackerPlayer && targetPlayer) { if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) { const targetMinion = targetPlayer?.board?.[logEntry.toIdx]; const targetCard = cardDb[targetMinion?.cardId]; const targetName = targetCard?.name || 'Миньон'; text = who(logEntry.fromPlayer) + ' → Battlecry 💥 по ' + targetName + ' (' + logEntry.damage + ' урона) → ' + who(logEntry.toPlayer); } else { text = who(logEntry.fromPlayer) + ' → Battlecry 💥 по герою ' + who(logEntry.toPlayer) + ' (' + logEntry.damage + ' урона)!'; } } } if (text) { announcementEl.textContent = text; announcementEl.classList.remove('hidden'); setTimeout(() => { announcementEl.classList.add('hidden'); }, 2000); // Сократил с 3500 до 2000 мс } } function runCombatAnimation(state) { const lastLog = state.log && state.log.length ? state.log[state.log.length - 1] : null; if (!lastLog) return false; if (lastLog.type === 'attack' && lastLog.attackerMinionId && lastLog.targetMinionId) { const attackerWrap = document.querySelector(`[data-minion-id="${lastLog.attackerMinionId}"]`); const targetWrap = document.querySelector(`[data-minion-id="${lastLog.targetMinionId}"]`); if (!attackerWrap || !targetWrap) return false; // Вычисляем позиции для точного перемещения карты const attackerRect = attackerWrap.getBoundingClientRect(); const targetRect = targetWrap.getBoundingClientRect(); // Вычисляем смещение const dx = targetRect.left + targetRect.width / 2 - (attackerRect.left + attackerRect.width / 2); const dy = targetRect.top + targetRect.height / 2 - (attackerRect.top + attackerRect.height / 2); // Сохраняем начальную позицию const startX = attackerRect.left; const startY = attackerRect.top; const startWidth = attackerRect.width; const startHeight = attackerRect.height; // Устанавливаем fixed positioning для наложения attackerWrap.style.position = 'fixed'; attackerWrap.style.left = startX + 'px'; attackerWrap.style.top = startY + 'px'; attackerWrap.style.width = startWidth + 'px'; attackerWrap.style.height = startHeight + 'px'; attackerWrap.style.zIndex = '1000'; attackerWrap.style.transition = 'none'; // Добавляем класс для анимации attackerWrap.classList.add('combat-lunge-overlay'); targetWrap.classList.add('combat-hit'); // Устанавливаем CSS переменные для перемещения attackerWrap.style.setProperty('--attack-dx', dx + 'px'); attackerWrap.style.setProperty('--attack-dy', dy + 'px'); if (lastLog.damage) showDamageEffect(targetWrap, lastLog.damage, false); if (typeof window.Sounds !== 'undefined') window.Sounds.attack(); // Анимация удара setTimeout(() => { targetWrap.classList.add('combat-hit-impact'); }, 200); // Возврат карты и очистка setTimeout(() => { // Если атакующий не умер, возвращаем карту на место if (!lastLog.attackerDied) { attackerWrap.classList.remove('combat-lunge-overlay'); attackerWrap.style.position = ''; attackerWrap.style.left = ''; attackerWrap.style.top = ''; attackerWrap.style.width = ''; attackerWrap.style.height = ''; attackerWrap.style.zIndex = ''; attackerWrap.style.setProperty('--attack-dx', ''); attackerWrap.style.setProperty('--attack-dy', ''); attackerWrap.style.transition = ''; } else { // Если атакующий умер, сразу применяем анимацию смерти attackerWrap.classList.remove('combat-lunge-overlay'); attackerWrap.style.position = ''; attackerWrap.style.left = ''; attackerWrap.style.top = ''; attackerWrap.style.width = ''; attackerWrap.style.height = ''; attackerWrap.style.zIndex = ''; attackerWrap.style.setProperty('--attack-dx', ''); attackerWrap.style.setProperty('--attack-dy', ''); attackerWrap.style.transition = ''; attackerWrap.classList.add('combat-death'); showDamageEffect(attackerWrap, '💀', false); } if (lastLog.targetDied) { targetWrap.classList.add('combat-death'); showDamageEffect(targetWrap, '💀', false); } targetWrap.classList.remove('combat-hit-impact'); }, 500); combatTimeout = setTimeout(function () { combatTimeout = null; renderBoards(state); bindGameEvents(state); }, 600); return true; } else if (lastLog.type === 'attackHero' && lastLog.toPlayer !== undefined && lastLog.attackerMinionId) { const attackerWrap = document.querySelector(`[data-minion-id="${lastLog.attackerMinionId}"]`); const targetPlayer = state.players[lastLog.toPlayer]; if (attackerWrap && targetPlayer) { const heroEl = document.querySelector(`[data-player-index="${lastLog.toPlayer}"].hero-target, .opponent-block[data-opponent-index="${lastLog.toPlayer}"] .drop-target-hero`); if (heroEl) { // Вычисляем позиции для атаки по герою const attackerRect = attackerWrap.getBoundingClientRect(); const heroRect = heroEl.getBoundingClientRect(); const dx = heroRect.left + heroRect.width / 2 - (attackerRect.left + attackerRect.width / 2); const dy = heroRect.top + heroRect.height / 2 - (attackerRect.top + attackerRect.height / 2); const startX = attackerRect.left; const startY = attackerRect.top; const startWidth = attackerRect.width; const startHeight = attackerRect.height; // Устанавливаем fixed positioning attackerWrap.style.position = 'fixed'; attackerWrap.style.left = startX + 'px'; attackerWrap.style.top = startY + 'px'; attackerWrap.style.width = startWidth + 'px'; attackerWrap.style.height = startHeight + 'px'; attackerWrap.style.zIndex = '1000'; attackerWrap.style.transition = 'none'; attackerWrap.classList.add('combat-lunge-overlay'); attackerWrap.style.setProperty('--attack-dx', dx + 'px'); attackerWrap.style.setProperty('--attack-dy', dy + 'px'); heroEl.classList.add('hero-damage-flash', 'combat-hit-impact'); if (lastLog.damage) showDamageEffect(heroEl, lastLog.damage, true); if (typeof window.Sounds !== 'undefined') window.Sounds.attack(); setTimeout(() => { attackerWrap.classList.remove('combat-lunge-overlay'); attackerWrap.style.position = ''; attackerWrap.style.left = ''; attackerWrap.style.top = ''; attackerWrap.style.width = ''; attackerWrap.style.height = ''; attackerWrap.style.zIndex = ''; attackerWrap.style.setProperty('--attack-dx', ''); attackerWrap.style.setProperty('--attack-dy', ''); attackerWrap.style.transition = ''; heroEl.classList.remove('hero-damage-flash', 'combat-hit-impact'); }, 500); } } return true; } else if ((lastLog.type === 'battlecry' || lastLog.type === 'spell' || lastLog.type === 'heroAbility') && lastLog.damage && lastLog.toPlayer !== undefined) { const targetPlayer = state.players[lastLog.toPlayer]; if (targetPlayer) { if (lastLog.toIdx !== undefined && lastLog.toIdx >= 0 && targetPlayer.board && targetPlayer.board[lastLog.toIdx]) { const minionEl = document.querySelector(`[data-player-index="${lastLog.toPlayer}"][data-board-index="${lastLog.toIdx}"]`); if (minionEl) { minionEl.classList.add('spell-hit'); showDamageEffect(minionEl, lastLog.damage, false); setTimeout(() => minionEl.classList.remove('spell-hit'), 500); } } else { const heroEl = document.querySelector(`[data-player-index="${lastLog.toPlayer}"].hero-target, .opponent-block[data-opponent-index="${lastLog.toPlayer}"] .drop-target-hero`); if (heroEl) { heroEl.classList.add('hero-damage-flash'); showDamageEffect(heroEl, lastLog.damage, true); setTimeout(() => heroEl.classList.remove('hero-damage-flash'), 500); } } } return true; } return false; } function renderGame(state) { const you = state.players[yourIndex]; if (!you) return; $('your-mana').textContent = you.mana ?? 0; $('your-max-mana').textContent = you.maxMana ?? 0; const healthEl = $('your-health'); if (healthEl) { const newHealth = you.health ?? 30; const oldHealth = parseInt(healthEl.textContent) || 30; healthEl.textContent = newHealth; // Добавляем визуальный эффект при изменении HP if (newHealth !== oldHealth) { healthEl.classList.add('health-changed'); setTimeout(() => healthEl.classList.remove('health-changed'), 500); } } $('your-deck').textContent = state.yourDeckCount ?? you.deck?.length ?? 0; const lastLog = state.log && state.log.length ? state.log[state.log.length - 1] : null; // Добавляем новые логи в чат if (state.log && prevGameState) { const chatMessagesEl = $('chat-messages'); if (chatMessagesEl) { const prevLogLength = prevGameState.log ? prevGameState.log.length : 0; const newLogs = state.log.slice(prevLogLength); newLogs.forEach(entry => { if (!entry) return; const who = (i) => state.players[i]?.name || ('Игрок ' + (i + 1)); let message = ''; if (entry.type === 'play') { const c = cardDb[entry.cardId]; message = who(entry.playerIndex) + ' разыграл ' + (c?.name || entry.cardId); } else if (entry.type === 'turn') { message = 'Ход: ' + who(entry.to); } else if (entry.type === 'skipTurn') { message = who(entry.playerIndex) + ' пропускает ход'; } else if (entry.type === 'playerDefeated') { message = who(entry.playerIndex) + ' выбыл из игры'; } else if (entry.type === 'attackHero') { message = 'Атака по герою!'; } else if (entry.type === 'attack') { message = 'Бой!'; } else if (entry.type === 'draw') { message = who(entry.playerIndex) + ' вытянул карту'; } else if (entry.type === 'fatigue') { message = who(entry.playerIndex) + ' усталость (' + (entry.damage || 0) + ' урона)'; } else if (entry.type === 'spell') { message = who(entry.fromPlayer) + ' заклинание'; } else if (entry.type === 'heroAbility') { message = who(entry.fromPlayer) + ' — геройская способность'; } else if (entry.type === 'battlecry' || entry.type === 'deathrattle') { return; // Пропускаем эти типы } if (message) { const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const messageEl = document.createElement('div'); messageEl.className = 'chat-message system'; messageEl.innerHTML = '' + escapeHtml(time) + ' ' + escapeHtml(message); chatMessagesEl.appendChild(messageEl); chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; } }); } } if (lastLog?.type === 'attackHero' && typeof window.Sounds !== 'undefined') window.Sounds.attack(); const isYourTurn = state.currentPlayerIndex === yourIndex; const endBtn = $('btn-end-turn'); if (endBtn) endBtn.disabled = !isYourTurn; const heroBtn = $('btn-hero-ability'); if (heroBtn) { var canHero = isYourTurn && (you.mana ?? 0) >= 2 && !state.yourHeroAbilityUsed && !attackMode.active && !spellMode.active && !heroAbilityMode.active; heroBtn.disabled = !canHero; heroBtn.classList.toggle('hidden', !isYourTurn); } const resetBtnEl = $('btn-reset-lobby'); if (resetBtnEl) resetBtnEl.classList.add('hidden'); const badge = $('turn-badge'); if (badge) { badge.classList.toggle('your-turn', isYourTurn); badge.textContent = isYourTurn ? 'ВАШ ХОД' : 'Ход ' + (state.turn || 1); } // Показываем нотификацию о том, кто ходит showTurnNotification(state); var skipBoardUpdate = false; if (lastLog?.type === 'attack' && lastLog.attackerMinionId && lastLog.targetMinionId && prevGameState && runCombatAnimation(state)) { skipBoardUpdate = true; showAttackAnnouncement(state, lastLog); } else if (lastLog && (lastLog.type === 'attackHero' || lastLog.type === 'spell' || lastLog.type === 'heroAbility' || (lastLog.type === 'battlecry' && lastLog.damage))) { showAttackAnnouncement(state, lastLog); } else if (lastLog?.type === 'attack' && lastLog.attackerMinionId && lastLog.targetMinionId) { showAttackAnnouncement(state, lastLog); } else if (lastLog?.type === 'skipTurn' || lastLog?.type === 'playerDefeated') { showAttackAnnouncement(state, lastLog); } if (!skipBoardUpdate) { renderBoards(state); } const hand = you.hand || []; var justDrew = hand.length > lastHandLength && lastHandLength > 0; if (justDrew && typeof window.Sounds !== 'undefined') window.Sounds.drawCard(); lastHandLength = hand.length; const handEl = $('your-hand'); if (handEl) { handEl.innerHTML = hand .map((cid, i) => { var meta = cardDb[cid]; var cost = meta?.cost ?? 0; var playable = false; var notEnoughMana = false; if (isYourTurn) { if (meta?.type === 'minion') { playable = you.mana >= cost && (you.board?.length ?? 0) < 7; notEnoughMana = you.mana < cost; } else if (meta?.type === 'spell') { playable = you.mana >= cost && !attackMode.active && !spellMode.active && !heroAbilityMode.active; notEnoughMana = you.mana < cost; } } var drawAnim = justDrew && i === hand.length - 1; return renderHandCard(cid, i, hand.length, playable, drawAnim, notEnoughMana); }) .join(''); } const deckCount = state.yourDeckCount ?? you.deck?.length ?? 0; const canDraw = isYourTurn && !state.yourManualDrawUsed && deckCount > 0 && (you.hand?.length ?? 0) < 10; renderDeckFan(deckCount, justDrew, canDraw); bindGameEvents(state); } function renderDeckFan(deckCount, justDrew, canDraw) { const fan = $('deck-fan'); const stack = $('deck-stack'); const badge = $('deck-count-badge'); if (!stack) return; const n = Math.min(5, Math.max(0, deckCount)); let html = ''; for (let i = 0; i < 5; i++) { html += i < n ? '
    ' : ''; } stack.innerHTML = html; if (badge) badge.textContent = String(deckCount); if (fan) { fan.classList.toggle('deck-just-drew', !!justDrew); fan.classList.toggle('deck-drawable', !!canDraw); fan.title = canDraw ? 'Клик — вытянуть карту' : 'Колода'; if (justDrew) setTimeout(function () { fan.classList.remove('deck-just-drew'); }, 400); } } function abilityTags(meta) { var tags = []; if (meta.battlecry) tags.push('Вой:' + escapeHtml(meta.battlecry)); if (meta.deathrattle) tags.push('Предсм.:' + escapeHtml(meta.deathrattle)); if (!tags.length) return ''; return '
    ' + tags.join(' ') + '
    '; } function renderHandCard(cardId, handIndex, handSize, playable, drawFromDeck, notEnoughMana) { var meta = cardDb[cardId]; if (!meta) return ''; var name = meta.name || cardId; var cost = meta.cost ?? 0; var atk = meta.attack ?? 0; var hp = meta.health ?? 0; var art = getCardArt(meta); var faction = meta.faction || 'neutral'; var rotate = handSize <= 1 ? 0 : -10 + (handIndex / Math.max(1, handSize - 1)) * 20; var isMinion = meta.type === 'minion'; var statsHtml = isMinion ? '
    Мана' + cost + '
    ' + '
    Атака' + atk + '
    ' + '
    Здоровье' + hp + '
    ' : '
    Мана' + cost + '
    '; var textHtml = meta.text ? '
    ' + escapeHtml(meta.text) + '
    ' : ''; var abilHtml = abilityTags(meta); var fictionalClass = meta.fictional ? ' fictional-card' : ''; var notEnoughManaClass = notEnoughMana ? ' not-enough-mana' : ''; return '
    ' + '
    ' + '
    ' + art + '
    ' + '
    ' + '
    ' + escapeHtml(name) + '
    ' + textHtml + abilHtml + '
    ' + statsHtml + '
    ' + '
    ' + '
    '; } var swgMap = { r2d2: 'r2d2', c3po: 'c3po', yoda: 'yoda', boba: 'bobbafett', vader: 'darthvader', stormtrooper: 'stormtrooper', deathstar: 'deathstar', atat: 'atat', xwing: 'xwing', tie: 'tie', falcon: 'falcon', leia: 'leia', chewie: 'wookie', ackbar: 'akbar', kylo: 'kylo', bb8: 'bb8', phasma: 'phasma', trooper: 'stormtrooper', jawas: 'galrep', sandcrawler: 'galrep', tusken: 'galrep', sarlacc: 'galrep', bantha: 'galrep', rancor: 'galrep', tauntaun: 'galrep', temple: 'jediorder', cantina: 'galrep', void: 'galrep', storm: 'jediorder', malice: 'darthvader', zephyr: 'galrep', walker: 'galrep', crystal: 'jediorder', shadow: 'darthvader', nebula: 'galrep', elite: 'galrep', anakin: 'saberjedi', kanan: 'saberjedi', ezra: 'saberjedi', cal_kestis: 'saberjedi', plo_koon: 'saberjedi', ki_adi: 'saberjedi', aayla: 'saberjedi', shaak_ti: 'saberjedi', savage: 'sabersith', jar_jar: 'galrep', tarkin: 'galemp', starkiller: 'sabersith', moff_gideon: 'galemp', rebel_trooper: 'galrep', rebel_commando: 'galrep', rebel_scout: 'galrep', clone_trooper: 'stormtrooper', clone_commando: 'stormtrooper', arc_trooper: 'stormtrooper', battle_droid: 'combatdrone', super_battle_droid: 'combatdrone', droideka: 'combatdrone', cody: 'stormtrooper', rex: 'stormtrooper', fives: 'stormtrooper', echo: 'stormtrooper', father: 'jediorder', son: 'sith', daughter: 'jediorder', korriban: 'galemp', tatooine: 'galrep', coruscant: 'galrep', zakkuul: 'galemp', kamino: 'galrep', mustafar: 'galemp', naboo: 'galrep', endor: 'galrep', hoth: 'galrep', dagobah: 'jediorder', star_destroyer: 'stardestroyer', speeder: 'landspeeder', wedge: 'xwing', nebulon: 'xwing', droideka: 'combatdrone', jango: 'bobbafett', luke: 'saberjedi', obiwan: 'saberjedi', quigon: 'saberjedi', mace: 'saberjedi', dooku: 'sabersith', maul: 'sabersith', snoke: 'sith', palpatine: 'sith', han: 'falcon', rey: 'newrep', finn: 'newrep', padme: 'galrep', jabba: 'carbonite', lando: 'cantina', ewok: 'jediorder', wicket: 'jediorder', scout: 'stormtrooper', guard: 'stormtrooper', probe: 'r2d2', ahsoka: 'saberjedi', ventress: 'sabersith', rex: 'stormtrooper', thrawn: 'galemp', mando: 'mandalorian', grogu: 'yoda', jyn: 'newrep', cassian: 'galrep', k2so: 'r2d2', chirrut: 'jediorder', baze: 'stormtrooper', poe: 'xwing', hux: 'stormtrooper', rose: 'galrep', lightsaber: 'saberjedi', force: 'saberjedi', blaster: 'tie', hyperspace: 'xwing', fear: 'sith', hope: 'galrep', }; function getCardArt(meta) { // Проверяем наличие кастомных иконок в папке extraicons const extraIconMap = { 'ezra': 'ezra-bridger.png', 'ezra_bridger': 'ezra-bridger.png', 'father': 'the-father.png', 'the_father': 'the-father.png', 'ahsoka': 'ashoka-tano.png', 'ashoka_tano': 'ashoka-tano.png', 'cal': 'CalCestis.png', 'cal_kestis': 'CalCestis.png', 'nihilus': 'darthnihilus.png', 'darth_nihilus': 'darthnihilus.png', 'plagueis': 'dartplagues.png', 'darth_plagueis': 'dartplagues.png', 'plo': 'plokoon.png', 'plo_koon': 'plokoon.png', 'revan': 'revan.png' }; const extraIcon = extraIconMap[meta.art]; if (extraIcon) { return '' + (meta.name || '') + ''; } // Используем стандартные SWG иконки var swg = swgMap[meta.art]; if (swg) return ''; return ''; } function renderBoardMinion(m, playerIndex, boardIndex, state, isOpponent, summonAnim) { const meta = cardDb[m.cardId]; const name = meta?.name || m.cardId; const art = meta ? getCardArt(meta) : '✦'; const faction = meta?.faction || 'neutral'; const isYourTurn = state.currentPlayerIndex === state.yourIndex; const canAttackTwice = meta && meta.canAttackTwice; const attacksUsed = m.attacksUsed || 0; const canAttack = !isOpponent && m.canAttack && isYourTurn && (!canAttackTwice || attacksUsed < 2); const attackable = attackMode.active && attackMode.attackerPlayer === state.yourIndex && attackMode.attackerBoard === boardIndex; const targetable = attackMode.active && attackMode.attackerPlayer === state.yourIndex && playerIndex !== state.yourIndex; // Учитываем бонусы синергий const synergyAttack = m.synergyAttackBonus || 0; const synergyHealth = m.synergyHealthBonus || 0; const displayAttack = (m.attack || 0) + synergyAttack; const displayHealth = (m.health || 0) + synergyHealth; const hasSynergy = synergyAttack > 0 || synergyHealth > 0; const cls = ['card-wrap']; if (canAttack && !attackMode.active) cls.push('attackable'); if (attackable) cls.push('attackable'); if (targetable) cls.push('targetable'); if (isOpponent && isYourTurn) cls.push('drop-target'); if (hasSynergy) cls.push('has-synergy'); var spellTarget = false; if (spellMode.active && spellMode.spellTarget) { var st = spellMode.spellTarget; if (isOpponent && (st === 'any' || st === 'enemy_minion' || st === 'any_minion')) spellTarget = true; if (!isOpponent && (st === 'friendly_minion' || st === 'any_minion')) spellTarget = true; } var heroTarget = heroAbilityMode.active && isOpponent; if (heroTarget) cls.push('hero-ability-target'); if (spellTarget) cls.push('spell-target'); if (m.frozen) cls.push('frozen'); if (meta && meta.fictional) cls.push('fictional-card'); const draggable = !isOpponent && canAttack && !m.frozen ? ' draggable="true"' : ''; const cardCls = 'card faction-' + faction + (summonAnim ? ' summon-anim' : '') + (m.frozen ? ' frozen-card' : '') + (meta && meta.fictional ? ' fictional-card' : ''); const dropAttrs = (isOpponent && isYourTurn) || spellTarget || heroTarget ? ` data-drop-player="${playerIndex}" data-drop-board="${boardIndex}"` : ''; const dragTitle = !isOpponent && canAttack && !m.frozen ? ' title="Перетащи на врага для атаки"' : (m.frozen ? ' title="Заморожен (не может атаковать)"' : ''); var abilHtml = meta ? abilityTags(meta) : ''; var textHtml = meta && meta.text ? '
    ' + escapeHtml(meta.text) + '
    ' : ''; var frozenIcon = m.frozen ? '
    ' : ''; return '
    ' + '
    ' + frozenIcon + '
    ' + art + '
    ' + '
    ' + '
    ' + escapeHtml(name) + '
    ' + textHtml + abilHtml + '
    ' + '
    Атака' + displayAttack + (synergyAttack > 0 ? '+' + synergyAttack + '' : '') + '
    ' + '
    Здоровье' + displayHealth + (synergyHealth > 0 ? '+' + synergyHealth + '' : '') + '
    ' + '
    ' + '
    ' + '
    '; } function renderHeroDropZone(playerIndex, state) { const isYourTurn = state.currentPlayerIndex === state.yourIndex; if (!isYourTurn || playerIndex === state.yourIndex) return ''; if (attackMode.active && !spellMode.active && !heroAbilityMode.active) return ''; var spellHero = spellMode.active && spellMode.spellTarget === 'any'; var heroAb = heroAbilityMode.active; const p = state.players[playerIndex]; const name = p?.name || 'Игрок ' + (playerIndex + 1); var heroCls = 'drop-target drop-target-hero' + (spellHero ? ' spell-target' : '') + (heroAb ? ' hero-ability-target' : ''); var title = spellHero ? 'Цель заклинания' : (heroAb ? 'Цель геройской способности' : 'Перетащи сюда миньона для атаки по лидеру'); return '
    ' + '' + '' + escapeHtml(name) + '' + '
    '; } function renderHeroTarget(playerIndex, state) { if (!attackMode.active || attackMode.attackerPlayer !== state.yourIndex || playerIndex === state.yourIndex) return ''; const p = state.players[playerIndex]; const name = p?.name || 'Игрок ' + (playerIndex + 1); return `
    ${escapeHtml(name)} (лидер)
    ${p?.health ?? 30}
    `; } function bindGameEvents(state) { const you = state.players[state.yourIndex]; const isYourTurn = state.currentPlayerIndex === state.yourIndex; const sidebar = $('forge-sidebar'); const isForgeOpen = sidebar && !sidebar.classList.contains('hidden'); // Chat handlers const chatContainer = $('chat-container'); const chatToggle = $('btn-chat-toggle'); const chatHeader = $('chat-header'); const chatInput = $('chat-input'); const chatSendBtn = $('btn-chat-send'); if (chatToggle) { chatToggle.onclick = function() { if (chatContainer) { chatContainer.classList.toggle('collapsed'); chatToggle.textContent = chatContainer.classList.contains('collapsed') ? '▲' : '▼'; } }; } if (chatHeader) { chatHeader.onclick = function() { if (chatContainer) { chatContainer.classList.toggle('collapsed'); if (chatToggle) { chatToggle.textContent = chatContainer.classList.contains('collapsed') ? '▲' : '▼'; } } }; } function sendChatMessage() { if (!chatInput || !socket) return; const message = chatInput.value.trim(); if (message.length === 0) return; socket.emit('chatMessage', { message: message }); chatInput.value = ''; } if (chatSendBtn) { chatSendBtn.onclick = sendChatMessage; } if (chatInput) { chatInput.onkeypress = function(e) { if (e.key === 'Enter') { sendChatMessage(); } }; } $all('.card-wrap.in-hand').forEach((wrap) => { wrap.classList.toggle('disabled', !isYourTurn); const handIndex = parseInt(wrap.dataset.handIndex, 10); const meta = cardDb[wrap.dataset.cardId]; const isMinion = meta && meta.type === 'minion'; const canPlay = isYourTurn && !attackMode.active && !heroAbilityMode.active && meta && you.mana >= (meta.cost || 0); // Если кузница открыта и карта - миньон, делаем её перетаскиваемой для кузницы if (isForgeOpen && isMinion) { wrap.draggable = true; wrap.ondragstart = function (e) { e.dataTransfer.setData('text/plain', 'hand:' + handIndex); e.dataTransfer.setData('source', 'hand'); wrap.classList.add('dragging'); }; wrap.ondragend = function () { wrap.classList.remove('dragging'); }; } else if (isMinion && canPlay && (you.board?.length ?? 0) < 7) { wrap.draggable = true; wrap.ondragstart = function (e) { if (!canPlay) return; e.dataTransfer.setData('text/plain', 'hand:' + handIndex); e.dataTransfer.effectAllowed = 'move'; wrap.classList.add('dragging'); }; wrap.ondragend = function () { wrap.classList.remove('dragging'); $all('.drop-target').forEach(function (t) { t.classList.remove('drop-over'); }); }; } else { wrap.draggable = false; wrap.ondragstart = null; wrap.ondragend = null; } wrap.onclick = function (e) { if (e.target.closest('.card-btn-info')) return; if (!isYourTurn || attackMode.active || heroAbilityMode.active) return; if (!meta) return; if (you.mana < (meta.cost || 0)) return; if (meta.type === 'minion') { if ((you.board?.length ?? 0) >= 7) return; if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); var r = wrap.getBoundingClientRect(); spawnEffect('play', r.left + r.width / 2, r.top + r.height / 2); wrap.querySelector('.card')?.classList.add('play-anim'); socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length }); setTimeout(function () { wrap.querySelector('.card')?.classList.remove('play-anim'); }, 500); return; } if (meta.type === 'spell') { if (spellMode.active || heroAbilityMode.active || stealCardsMode.active) return; var needTarget = meta.spellTarget && meta.spellTarget !== 'none'; // Специальная обработка для Грабежа if (meta.spellEffect === 'steal_cards') { if (meta.spellTarget === 'enemy_player') { // Открываем модальное окно для выбора противника spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget }; $('spell-mode')?.classList.remove('hidden'); renderGame(state); return; } } if (!needTarget) { if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); socket.emit('playSpell', { handIndex: handIndex }); return; } spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget }; const spellModeText = $('spell-mode')?.querySelector('p'); if (spellModeText && meta.spellEffect === 'steal_cards') { spellModeText.textContent = 'Выберите противника для грабежа (кликните на блок противника)'; } else if (spellModeText) { spellModeText.textContent = 'Выберите цель для заклинания'; } $('spell-mode')?.classList.remove('hidden'); renderGame(state); return; } }; // Touch support for mobile if (isTouchDevice()) { wrap.ontouchstart = function (e) { if (e.target.closest('.card-btn-info')) { // Handle info button separately const btn = e.target.closest('.card-btn-info'); if (btn) { const cid = btn.dataset.cardId; if (cid) { const meta = cardDb[cid]; if (meta) { const nameEl = $('card-info-name'); const metaEl = $('card-info-meta'); const textEl = $('card-info-text'); const abilEl = $('card-info-abilities'); const bioEl = $('card-info-bio'); if (nameEl) nameEl.textContent = meta.name || cid; if (metaEl) { if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0); else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0); } if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); } if (abilEl) { var parts = []; if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry)); if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle)); abilEl.innerHTML = parts.length ? parts.join('
    ') : ''; abilEl.classList.toggle('hidden', !parts.length); } if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); } $('card-info-overlay')?.classList.remove('hidden'); } } } return; } if (!isYourTurn || attackMode.active || heroAbilityMode.active) return; if (!meta) return; const touch = e.touches[0]; touchState.startX = touch.clientX; touchState.startY = touch.clientY; touchState.startTime = Date.now(); touchState.target = wrap; touchState.isSwipe = false; wrap.classList.add('touch-active'); // Long press for card info touchState.longPressTimer = setTimeout(function() { if (touchState.target === wrap && meta) { const nameEl = $('card-info-name'); const metaEl = $('card-info-meta'); const textEl = $('card-info-text'); const abilEl = $('card-info-abilities'); const bioEl = $('card-info-bio'); if (nameEl) nameEl.textContent = meta.name || wrap.dataset.cardId; if (metaEl) { if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0); else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0); } if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); } if (abilEl) { var parts = []; if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry)); if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle)); abilEl.innerHTML = parts.length ? parts.join('
    ') : ''; abilEl.classList.toggle('hidden', !parts.length); } if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); } $('card-info-overlay')?.classList.remove('hidden'); wrap.classList.add('long-pressed'); } }, touchState.longPressDelay); }; wrap.ontouchmove = function (e) { if (!touchState.target || touchState.target !== wrap) return; // Cancel long press if moved if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; } const touch = e.touches[0]; const dx = touch.clientX - touchState.startX; const dy = touch.clientY - touchState.startY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > touchState.minSwipeDistance) { touchState.isSwipe = true; wrap.classList.add('swiping'); wrap.classList.remove('long-pressed'); } }; wrap.ontouchend = function (e) { if (!touchState.target || touchState.target !== wrap) return; // Cancel long press timer if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; } wrap.classList.remove('touch-active', 'swiping', 'long-pressed'); const touch = e.changedTouches[0]; const dx = touch.clientX - touchState.startX; const dy = touch.clientY - touchState.startY; const distance = Math.sqrt(dx * dx + dy * dy); const time = Date.now() - touchState.startTime; // If it was a swipe or long press, don't trigger card play if (touchState.isSwipe || distance > touchState.minSwipeDistance || time > touchState.longPressDelay) { touchState.target = null; return; } // Tap - trigger card play if (time < touchState.maxSwipeTime && distance < 10 && you.mana >= (meta?.cost || 0)) { if (meta?.type === 'minion' && (you.board?.length ?? 0) < 7) { if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); var r = wrap.getBoundingClientRect(); spawnEffect('play', r.left + r.width / 2, r.top + r.height / 2); wrap.querySelector('.card')?.classList.add('play-anim'); socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length }); setTimeout(function () { wrap.querySelector('.card')?.classList.remove('play-anim'); }, 500); } else if (meta?.type === 'spell') { if (spellMode.active || heroAbilityMode.active) return; var needTarget = meta.spellTarget && meta.spellTarget !== 'none'; if (!needTarget) { if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); socket.emit('playSpell', { handIndex: handIndex }); } else { spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget }; $('spell-mode')?.classList.remove('hidden'); renderGame(state); } } } touchState.target = null; }; wrap.ontouchcancel = function () { if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; } wrap.classList.remove('touch-active', 'swiping', 'long-pressed'); touchState.target = null; }; } }); $all('.card-btn-info').forEach(function (btn) { btn.onclick = function (e) { e.stopPropagation(); var cid = btn.dataset.cardId; if (!cid) return; var meta = cardDb[cid]; if (!meta) return; var nameEl = $('card-info-name'); var metaEl = $('card-info-meta'); var textEl = $('card-info-text'); var abilEl = $('card-info-abilities'); var bioEl = $('card-info-bio'); if (nameEl) nameEl.textContent = meta.name || cid; if (metaEl) { if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0); else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0); } if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); } if (abilEl) { var parts = []; if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry)); if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle)); abilEl.innerHTML = parts.length ? parts.join('
    ') : ''; abilEl.classList.toggle('hidden', !parts.length); } if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); } $('card-info-overlay')?.classList.remove('hidden'); }; }); var closeInfo = $('btn-card-info-close'); if (closeInfo) closeInfo.onclick = function () { $('card-info-overlay')?.classList.add('hidden'); }; $all('.card-wrap.attackable:not(.targetable)').forEach((wrap) => { wrap.onclick = () => { if (!isYourTurn || attackMode.active || spellMode.active || heroAbilityMode.active) return; const playerIndex = parseInt(wrap.dataset.playerIndex, 10); const boardIndex = parseInt(wrap.dataset.boardIndex, 10); if (playerIndex !== state.yourIndex) return; attackMode = { active: true, attackerPlayer: playerIndex, attackerBoard: boardIndex }; $('attack-mode')?.classList.remove('hidden'); renderGame(state); }; // Touch support for attack if (isTouchDevice()) { wrap.ontouchstart = function (e) { if (!isYourTurn || attackMode.active || spellMode.active || heroAbilityMode.active) return; const playerIndex = parseInt(wrap.dataset.playerIndex, 10); const boardIndex = parseInt(wrap.dataset.boardIndex, 10); if (playerIndex !== state.yourIndex) return; const touch = e.touches[0]; touchState.startX = touch.clientX; touchState.startY = touch.clientY; touchState.startTime = Date.now(); touchState.target = wrap; }; wrap.ontouchend = function (e) { if (!touchState.target || touchState.target !== wrap) return; const touch = e.changedTouches[0]; const dx = touch.clientX - touchState.startX; const dy = touch.clientY - touchState.startY; const distance = Math.sqrt(dx * dx + dy * dy); const time = Date.now() - touchState.startTime; if (time < touchState.maxSwipeTime && distance < 20) { const playerIndex = parseInt(wrap.dataset.playerIndex, 10); const boardIndex = parseInt(wrap.dataset.boardIndex, 10); if (playerIndex === state.yourIndex) { attackMode = { active: true, attackerPlayer: playerIndex, attackerBoard: boardIndex }; $('attack-mode')?.classList.remove('hidden'); renderGame(state); } } touchState.target = null; }; } }); $all('.card-wrap.targetable, .card-wrap.hero-target').forEach((wrap) => { wrap.onclick = () => { if (spellMode.active || heroAbilityMode.active) return; if (!attackMode.active) return; const targetPlayer = parseInt(wrap.dataset.playerIndex ?? wrap.dataset.dropPlayer, 10); const targetBoard = parseInt(wrap.dataset.boardIndex ?? wrap.dataset.dropBoard, 10); if (typeof window.Sounds !== 'undefined') window.Sounds.attack(); socket.emit('attack', { attackerPlayerIndex: state.yourIndex, attackerBoardIndex: attackMode.attackerBoard, targetPlayerIndex: targetPlayer, targetBoardIndex: targetBoard, }); attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 }; $('attack-mode')?.classList.add('hidden'); }; // Touch support for target selection if (isTouchDevice()) { wrap.ontouchstart = function (e) { if (spellMode.active || heroAbilityMode.active) return; if (!attackMode.active) return; e.preventDefault(); const targetPlayer = parseInt(wrap.dataset.playerIndex ?? wrap.dataset.dropPlayer, 10); const targetBoard = parseInt(wrap.dataset.boardIndex ?? wrap.dataset.dropBoard, 10); if (typeof window.Sounds !== 'undefined') window.Sounds.attack(); socket.emit('attack', { attackerPlayerIndex: state.yourIndex, attackerBoardIndex: attackMode.attackerBoard, targetPlayerIndex: targetPlayer, targetBoardIndex: targetBoard, }); attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 }; $('attack-mode')?.classList.add('hidden'); }; } }); $all('.spell-target, .drop-target-hero.spell-target').forEach(function (el) { el.onclick = function (e) { if (!spellMode.active && !battlecryTargetMode.active) return; e.stopPropagation(); var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10); var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10); // Обработка для battlecry (Ezra Bridger) if (battlecryTargetMode.active && battlecryTargetMode.battlecryId === 'return_hand_enemy') { if (tp !== state.yourIndex && tp >= 0 && tb >= 0) { const targetPlayer = state.players[tp]; if (targetPlayer && targetPlayer.board && targetPlayer.board[tb]) { socket.emit('battlecryTarget', { battlecryId: 'return_hand_enemy', targetPlayerIndex: tp, targetBoardIndex: tb }); battlecryTargetMode = { active: false, battlecryId: '', cardId: '', minionId: '', targetPlayerIndex: null, targetBoardIndex: null }; spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; $('spell-mode')?.classList.add('hidden'); renderGame(state); return; } } return; } // Специальная обработка для Грабежа - открываем модальное окно const spellCard = cardDb[spellMode.cardId]; if (spellCard && spellCard.spellEffect === 'steal_cards' && spellCard.spellTarget === 'enemy_player') { // Выбираем противника (tp должен быть индексом противника, tb игнорируем для enemy_player) if (tp !== state.yourIndex && tp >= 0) { const targetPlayer = state.players[tp]; if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) { stealCardsMode.active = true; stealCardsMode.handIndex = spellMode.handIndex; stealCardsMode.targetPlayerIndex = tp; // Получаем актуальную колоду из gameState stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : []; stealCardsMode.selectedIndices = []; showStealCardsModal(state, { targetPlayerIndex: tp, targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`, targetDeckSize: targetPlayer.deck.length, maxCards: Math.min(2, targetPlayer.deck.length) }); spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; $('spell-mode')?.classList.add('hidden'); return; } } return; } if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: tb }); spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; $('spell-mode')?.classList.add('hidden'); renderGame(state); }; // Touch support for spell targets if (isTouchDevice()) { el.ontouchstart = function (e) { if (!spellMode.active) return; e.preventDefault(); e.stopPropagation(); var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10); var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10); // Специальная обработка для Грабежа const spellCard = cardDb[spellMode.cardId]; if (spellCard && spellCard.spellEffect === 'steal_cards' && spellCard.spellTarget === 'enemy_player') { if (tp !== state.yourIndex && tp >= 0) { const targetPlayer = state.players[tp]; if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) { stealCardsMode.active = true; stealCardsMode.handIndex = spellMode.handIndex; stealCardsMode.targetPlayerIndex = tp; // Получаем актуальную колоду из gameState stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : []; stealCardsMode.selectedIndices = []; showStealCardsModal(state, { targetPlayerIndex: tp, targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`, targetDeckSize: targetPlayer.deck.length, maxCards: Math.min(2, targetPlayer.deck.length) }); spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; $('spell-mode')?.classList.add('hidden'); return; } } return; } if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: tb }); spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; $('spell-mode')?.classList.add('hidden'); renderGame(state); }; } }); $all('.hero-ability-target, .drop-target-hero.hero-ability-target').forEach(function (el) { el.onclick = function (e) { if (!heroAbilityMode.active) return; e.stopPropagation(); var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10); var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10); if (typeof window.Sounds !== 'undefined') window.Sounds.attack(); socket.emit('heroAbility', { targetPlayerIndex: tp, targetBoardIndex: tb }); heroAbilityMode = { active: false }; $('hero-ability-mode')?.classList.add('hidden'); renderGame(state); }; // Touch support for hero ability targets if (isTouchDevice()) { el.ontouchstart = function (e) { if (!heroAbilityMode.active) return; e.preventDefault(); e.stopPropagation(); var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10); var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10); if (typeof window.Sounds !== 'undefined') window.Sounds.attack(); socket.emit('heroAbility', { targetPlayerIndex: tp, targetBoardIndex: tb }); heroAbilityMode = { active: false }; $('hero-ability-mode')?.classList.add('hidden'); renderGame(state); }; } }); // Обработка выбора противника для Грабежа $all('.opponent-block.steal-target').forEach(function (el) { el.onclick = function (e) { if (!spellMode.active) return; const spellCard = cardDb[spellMode.cardId]; if (!spellCard || spellCard.spellEffect !== 'steal_cards' || spellCard.spellTarget !== 'enemy_player') return; e.stopPropagation(); var tp = parseInt(el.dataset.playerIndex ?? el.dataset.opponentIndex, 10); if (tp === state.yourIndex || tp < 0) return; const targetPlayer = state.players[tp]; if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) { stealCardsMode.active = true; stealCardsMode.handIndex = spellMode.handIndex; stealCardsMode.targetPlayerIndex = tp; // Получаем актуальную колоду из gameState stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : []; stealCardsMode.selectedIndices = []; showStealCardsModal(state, { targetPlayerIndex: tp, targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`, targetDeckSize: targetPlayer.deck.length, maxCards: Math.min(2, targetPlayer.deck.length) }); spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; $('spell-mode')?.classList.add('hidden'); } }; // Touch support if (isTouchDevice()) { el.ontouchstart = function (e) { if (!spellMode.active) return; const spellCard = cardDb[spellMode.cardId]; if (!spellCard || spellCard.spellEffect !== 'steal_cards' || spellCard.spellTarget !== 'enemy_player') return; e.preventDefault(); e.stopPropagation(); var tp = parseInt(el.dataset.playerIndex ?? el.dataset.opponentIndex, 10); if (tp === state.yourIndex || tp < 0) return; const targetPlayer = state.players[tp]; if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) { stealCardsMode.active = true; stealCardsMode.handIndex = spellMode.handIndex; stealCardsMode.targetPlayerIndex = tp; // Получаем актуальную колоду из gameState stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : []; stealCardsMode.selectedIndices = []; showStealCardsModal(state, { targetPlayerIndex: tp, targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`, targetDeckSize: targetPlayer.deck.length, maxCards: Math.min(2, targetPlayer.deck.length) }); spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; $('spell-mode')?.classList.add('hidden'); } }; } }); var heroAbilityBtn = $('btn-hero-ability'); if (heroAbilityBtn) heroAbilityBtn.onclick = function () { if (heroAbilityBtn.disabled || heroAbilityMode.active) return; heroAbilityMode = { active: true }; $('hero-ability-mode')?.classList.remove('hidden'); renderGame(state); }; var forgeBtn = $('btn-forge'); if (forgeBtn && isYourTurn && you.mana >= 2) { forgeBtn.disabled = false; forgeBtn.onclick = function () { if (attackMode.active || spellMode.active || heroAbilityMode.active) return; const sidebar = $('forge-sidebar'); if (sidebar) { sidebar.classList.remove('hidden'); forgeSelected = []; // Сбрасываем выбор при открытии renderForgeHand(state); renderForgeDeck(state); renderForgeSelected(); updateForgeCraftButton(); } }; } else if (forgeBtn) { forgeBtn.disabled = true; } var cancelSpellBtn = $('btn-cancel-spell'); if (cancelSpellBtn) cancelSpellBtn.onclick = function () { spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 }; heroAbilityMode = { active: false }; stealCardsMode = { active: false, handIndex: -1, targetPlayerIndex: null, targetDeck: [], selectedIndices: [] }; $('spell-mode')?.classList.add('hidden'); $('attack-mode')?.classList.add('hidden'); $('hero-ability-mode')?.classList.add('hidden'); $('steal-cards-overlay')?.classList.add('hidden'); renderGame(state); }; var cancelHeroBtn = $('btn-cancel-hero'); if (cancelHeroBtn) cancelHeroBtn.onclick = function () { heroAbilityMode = { active: false }; $('hero-ability-mode')?.classList.add('hidden'); renderGame(state); }; const cancelBtn = $('btn-cancel-attack'); if (cancelBtn) { cancelBtn.onclick = function () { attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 }; spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' }; heroAbilityMode = { active: false }; stealCardsMode = { active: false, handIndex: -1, targetPlayerIndex: null, targetDeck: [], selectedIndices: [] }; $('attack-mode')?.classList.add('hidden'); $('spell-mode')?.classList.add('hidden'); $('hero-ability-mode')?.classList.add('hidden'); $('steal-cards-overlay')?.classList.add('hidden'); renderGame(state); }; } const endBtn = $('btn-end-turn'); if (endBtn) { endBtn.onclick = isYourTurn ? () => { if (typeof window.Sounds !== 'undefined') window.Sounds.endTurn(); socket.emit('endTurn'); } : null; } const resetBtn = $('btn-reset-lobby'); if (resetBtn) { resetBtn.onclick = state.yourIndex === 0 ? () => socket.emit('resetToLobby') : null; } var deckEl = $('deck-fan'); if (deckEl && deckEl.classList.contains('deck-drawable')) { deckEl.onclick = function () { var r = deckEl.getBoundingClientRect(); spawnEffect('draw', r.left + r.width / 2, r.top + r.height / 2); socket.emit('drawCard'); }; deckEl.style.cursor = 'pointer'; // Touch support for deck if (isTouchDevice()) { deckEl.ontouchstart = function (e) { e.preventDefault(); var r = deckEl.getBoundingClientRect(); spawnEffect('draw', r.left + r.width / 2, r.top + r.height / 2); socket.emit('drawCard'); }; } } else if (deckEl) { deckEl.onclick = null; deckEl.style.cursor = 'default'; if (isTouchDevice()) { deckEl.ontouchstart = null; } } $all('.card-wrap.attackable[draggable="true"]').forEach(function (wrap) { wrap.ondragstart = function (e) { var idx = wrap.dataset.attackerBoardIndex; if (idx === undefined) return; e.dataTransfer.setData('text/plain', idx); e.dataTransfer.effectAllowed = 'move'; wrap.classList.add('dragging'); }; wrap.ondragend = function () { wrap.classList.remove('dragging'); $all('.drop-target').forEach(function (t) { t.classList.remove('drop-over'); }); }; }); $all('.drop-target').forEach(function (target) { target.ondragover = function (e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; target.classList.add('drop-over'); }; target.ondragleave = function () { target.classList.remove('drop-over'); }; target.ondrop = function (e) { e.preventDefault(); target.classList.remove('drop-over'); var data = e.dataTransfer.getData('text/plain'); if (data === '') return; if (data.startsWith('hand:')) { var handIndex = parseInt(data.split(':')[1], 10); if (isNaN(handIndex)) return; var meta = cardDb[you.hand?.[handIndex]]; if (!meta || meta.type !== 'minion') return; if (you.mana < (meta.cost || 0)) return; if ((you.board?.length ?? 0) >= 7) return; if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); var rect = target.getBoundingClientRect(); spawnEffect('play', rect.left + rect.width / 2, rect.top + rect.height / 2); socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length }); return; } var attackerBoard = parseInt(data, 10); var targetPlayer = parseInt(target.dataset.dropPlayer, 10); var targetBoard = target.dataset.dropBoard !== undefined ? parseInt(target.dataset.dropBoard, 10) : -1; if (isNaN(attackerBoard) || isNaN(targetPlayer)) return; if (typeof window.Sounds !== 'undefined') window.Sounds.attack(); var rect = target.getBoundingClientRect(); spawnEffect('attack', rect.left + rect.width / 2, rect.top + rect.height / 2); socket.emit('attack', { attackerPlayerIndex: state.yourIndex, attackerBoardIndex: attackerBoard, targetPlayerIndex: targetPlayer, targetBoardIndex: isNaN(targetBoard) ? -1 : targetBoard, }); attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 }; $('attack-mode')?.classList.add('hidden'); }; }); var yourBoardEl = $('your-board'); if (yourBoardEl && isYourTurn && !attackMode.active && !spellMode.active && !heroAbilityMode.active) { yourBoardEl.classList.add('drop-target'); yourBoardEl.ondragover = function (e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; yourBoardEl.classList.add('drop-over'); }; yourBoardEl.ondragleave = function () { yourBoardEl.classList.remove('drop-over'); }; yourBoardEl.ondrop = function (e) { e.preventDefault(); yourBoardEl.classList.remove('drop-over'); var data = e.dataTransfer.getData('text/plain'); if (!data.startsWith('hand:')) return; var handIndex = parseInt(data.split(':')[1], 10); if (isNaN(handIndex)) return; var meta = cardDb[you.hand?.[handIndex]]; if (!meta || meta.type !== 'minion') return; if (you.mana < (meta.cost || 0)) return; if ((you.board?.length ?? 0) >= 7) return; if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); var rect = yourBoardEl.getBoundingClientRect(); spawnEffect('play', rect.left + rect.width / 2, rect.top + rect.height / 2); socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length }); }; } else if (yourBoardEl) { yourBoardEl.classList.remove('drop-target'); yourBoardEl.ondragover = null; yourBoardEl.ondragleave = null; yourBoardEl.ondrop = null; } // Обновляем кузницу, если она открыта if (isForgeOpen) { renderForgeHand(state); renderForgeDeck(state); renderForgeSelected(); updateForgeCraftButton(); } } function initLobby() { const hostTab = document.querySelector('.tab[data-tab="host"]'); const joinTab = document.querySelector('.tab[data-tab="join"]'); hostTab?.addEventListener('click', () => { $all('.tab').forEach((t) => t.classList.remove('active')); hostTab.classList.add('active'); $('host-panel')?.classList.add('active'); $('join-panel')?.classList.remove('active'); clearError(); }); joinTab?.addEventListener('click', () => { $all('.tab').forEach((t) => t.classList.remove('active')); joinTab.classList.add('active'); $('join-panel')?.classList.add('active'); $('host-panel')?.classList.remove('active'); clearError(); }); const btnHost = $('btn-host'); if (btnHost) { // Удаляем старые обработчики, если есть const newBtn = btnHost.cloneNode(true); btnHost.parentNode.replaceChild(newBtn, btnHost); newBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); console.log('Кнопка "Создать комнату" нажата'); clearError(); const name = ($('host-name')?.value || '').trim() || 'Игрок 1'; console.log('Имя игрока:', name); const currentHost = window.location.hostname; let url = window.location.origin; if (currentHost !== 'localhost' && currentHost !== '127.0.0.1') { url = window.location.origin; } if (!socket || !socket.connected) { console.log('Подключение к серверу:', url); connect(url); socket.once('connect', () => { console.log('Подключено, создаём комнату'); const aiMode = $('ai-mode')?.checked || false; socket.emit('createRoom', { name, aiMode }); }); socket.once('connect_error', (e) => { console.error('Ошибка подключения:', e); showError('Не удалось подключиться к серверу. Проверьте, что сервер запущен.'); }); } else { console.log('Уже подключен, создаём комнату'); // Уже подключен, сразу отправляем const aiMode = $('ai-mode')?.checked || false; socket.emit('createRoom', { name, aiMode }); } }); // Также добавляем обработчик через onclick для надёжности newBtn.onclick = function(e) { e.preventDefault(); e.stopPropagation(); console.log('Кнопка "Создать комнату" нажата (onclick)'); const name = ($('host-name')?.value || '').trim() || 'Игрок 1'; const currentHost = window.location.hostname; let url = window.location.origin; if (currentHost !== 'localhost' && currentHost !== '127.0.0.1') { url = window.location.origin; } if (!socket || !socket.connected) { connect(url); socket.once('connect', () => { const aiMode = $('ai-mode')?.checked || false; socket.emit('createRoom', { name, aiMode }); }); socket.once('connect_error', (e) => { showError('Не удалось подключиться к серверу. Проверьте, что сервер запущен.'); }); } else { const aiMode = $('ai-mode')?.checked || false; socket.emit('createRoom', { name, aiMode }); } }; } else { console.error('Кнопка btn-host не найдена! Проверьте, что элемент существует в DOM.'); } $('btn-join')?.addEventListener('click', () => { clearError(); const addr = ($('join-addr')?.value || '').trim(); let url; if (addr && addr !== 'Оставьте пустым для авто') { const parts = addr.split(':'); const host = parts[0] || 'localhost'; const port = parts[1] || DEFAULT_PORT; url = `http://${host}:${port}`; } else { url = window.location.origin; } connect(url); socket.once('connect', () => { setLobbyPanel('connect'); $('connect-panel')?.classList.add('active'); }); socket.once('connect_error', () => { const detailsEl = document.querySelector('#join-panel details'); if (detailsEl && !detailsEl.open) { detailsEl.open = true; showError('Не удалось подключиться автоматически. Проверьте расширенные настройки и введите IP сервера вручную.'); } }); }); var joinCodeHandler = function () { clearError(); const code = ($('join-code')?.value || '').trim().toUpperCase(); if (!code || code.length !== 5) { showError('Введите код комнаты (5 символов)'); return; } const name = ($('join-name')?.value || '').trim() || 'Игрок 2'; if (!socket || !socket.connected) { const addr = ($('join-addr')?.value || '').trim(); let url; if (addr && addr !== 'Оставьте пустым для авто') { const parts = addr.split(':'); const host = parts[0] || 'localhost'; const port = parts[1] || DEFAULT_PORT; url = `http://${host}:${port}`; } else { url = window.location.origin; } connect(url); socket.once('connect', () => { socket.emit('joinRoom', { code, name }); }); } else { socket.emit('joinRoom', { code, name }); } }; $('btn-join-code')?.addEventListener('click', joinCodeHandler); $('join-code')?.addEventListener('keypress', function (e) { if (e.key === 'Enter') joinCodeHandler(); }); $('btn-start')?.addEventListener('click', () => { clearError(); var faction = $('room-faction')?.value || ''; socket?.emit('startGame', { faction: faction || undefined }); }); $('btn-leave')?.addEventListener('click', () => { socket?.emit('leaveLobby'); setLobbyPanel('host'); $('host-panel')?.classList.add('active'); showScreen('lobby'); }); $('btn-leave-join')?.addEventListener('click', () => { socket?.emit('leaveLobby'); socket?.disconnect(); setLobbyPanel('join'); $('join-panel')?.classList.add('active'); showScreen('lobby'); }); $('btn-modal-close')?.addEventListener('click', () => { $('modal-overlay')?.classList.add('hidden'); }); $('btn-instructions')?.addEventListener('click', () => { $('instructions-overlay')?.classList.remove('hidden'); }); $('btn-instructions-lobby')?.addEventListener('click', () => { $('instructions-overlay')?.classList.remove('hidden'); }); $('btn-instructions-close')?.addEventListener('click', () => { $('instructions-overlay')?.classList.add('hidden'); }); $('btn-cards-gallery')?.addEventListener('click', () => { $('cards-gallery-overlay')?.classList.remove('hidden'); renderCardsGallery(); }); $('btn-cards-gallery-game')?.addEventListener('click', () => { $('cards-gallery-overlay')?.classList.remove('hidden'); renderCardsGallery(); }); $('btn-gallery-close')?.addEventListener('click', () => { $('cards-gallery-overlay')?.classList.add('hidden'); }); $('cards-gallery-overlay')?.addEventListener('click', (e) => { if (e.target.id === 'cards-gallery-overlay') { $('cards-gallery-overlay')?.classList.add('hidden'); } }); $('gallery-faction-filter')?.addEventListener('change', renderCardsGallery); $('gallery-type-filter')?.addEventListener('change', renderCardsGallery); $('gallery-search')?.addEventListener('input', renderCardsGallery); let galleryCardDb = null; function renderCardsGallery() { const grid = $('cards-gallery-grid'); if (!grid) return; if (!galleryCardDb) { fetch('/api/cards') .then(res => res.json()) .then(data => { galleryCardDb = data; renderCardsGallery(); }) .catch(() => { grid.innerHTML = '

    Не удалось загрузить карты

    '; }); return; } const factionFilter = ($('gallery-faction-filter')?.value || '').toLowerCase(); const typeFilter = ($('gallery-type-filter')?.value || '').toLowerCase(); const searchText = ($('gallery-search')?.value || '').toLowerCase(); const allCards = Object.keys(galleryCardDb || {}).map(id => ({ id, ...galleryCardDb[id] })); const filtered = allCards.filter(card => { if (factionFilter && card.faction !== factionFilter) return false; if (typeFilter && card.type !== typeFilter) return false; if (searchText && !card.name.toLowerCase().includes(searchText)) return false; return true; }); grid.innerHTML = filtered.map(card => { const isFictional = card.fictional || false; const cost = card.cost || 0; const attack = card.attack !== undefined ? card.attack : ''; const health = card.health !== undefined ? card.health : ''; const stats = card.type === 'minion' ? `
    ${attack}${health}
    ` : ''; const costDisplay = card.type === 'spell' ? `
    ${cost}
    ` : ''; const art = getCardArt ? getCardArt(card) : '✦'; return ``; }).join(''); $all('.gallery-card').forEach(cardEl => { cardEl.onclick = function() { const cardId = cardEl.dataset.cardId; const card = galleryCardDb[cardId]; if (!card) return; const nameEl = $('card-info-name'); const metaEl = $('card-info-meta'); const textEl = $('card-info-text'); const abilEl = $('card-info-abilities'); const bioEl = $('card-info-bio'); if (nameEl) nameEl.textContent = card.name; if (metaEl) { const parts = []; if (card.type === 'minion') { parts.push(`Стоимость: ${card.cost || 0}`); parts.push(`Атака: ${card.attack !== undefined ? card.attack : 0}`); parts.push(`Здоровье: ${card.health !== undefined ? card.health : 0}`); } else { parts.push(`Стоимость: ${card.cost || 0}`); } if (card.faction) { const factionNames = { rebellion: 'Сопротивление', empire: 'Империя', pirates: 'Пираты', mandalorians: 'Мандалорцы', neutral: 'Нейтральная' }; parts.push(`Фракция: ${factionNames[card.faction] || card.faction}`); } if (card.legendary) parts.push('Легендарная'); if (card.fictional) parts.push('⚡ ВЫМЫШЛЕННЫЙ ПЕРСОНАЖ'); metaEl.textContent = parts.join(' | '); } if (textEl) textEl.textContent = card.text || ''; if (abilEl) { let abil = ''; if (card.battlecry) abil += `Battlecry: ${card.battlecry} `; if (card.deathrattle) abil += `Deathrattle: ${card.deathrattle} `; abilEl.textContent = abil || 'Нет способностей'; } if (bioEl) bioEl.textContent = card.bio || 'Нет описания'; $('card-info-overlay')?.classList.remove('hidden'); $('cards-gallery-overlay')?.classList.add('hidden'); }; }); } $('btn-settings')?.addEventListener('click', () => { $('settings-overlay')?.classList.remove('hidden'); }); $('btn-settings-lobby')?.addEventListener('click', () => { $('settings-overlay')?.classList.remove('hidden'); }); $('btn-settings-close')?.addEventListener('click', () => { $('settings-overlay')?.classList.add('hidden'); }); $('settings-overlay')?.addEventListener('click', (e) => { if (e.target.id === 'settings-overlay') { $('settings-overlay')?.classList.add('hidden'); } }); $('btn-forge-close')?.addEventListener('click', () => { const sidebar = $('forge-sidebar'); if (sidebar) { sidebar.classList.add('hidden'); forgeSelected = []; renderForgeSelected(); } }); $('btn-forge-clear')?.addEventListener('click', () => { forgeSelected = []; renderForgeSelected(); updateForgeCraftButton(); if (gameState) { renderForgeHand(gameState); renderForgeDeck(gameState); } }); $('btn-steal-close')?.addEventListener('click', () => { $('steal-cards-overlay')?.classList.add('hidden'); stealCardsMode.active = false; stealCardsMode.handIndex = -1; stealCardsMode.targetPlayerIndex = null; stealCardsMode.targetDeck = []; stealCardsMode.selectedIndices = []; }); $('steal-cards-overlay')?.addEventListener('click', (e) => { if (e.target.id === 'steal-cards-overlay') { $('steal-cards-overlay')?.classList.add('hidden'); stealCardsMode.active = false; stealCardsMode.handIndex = -1; stealCardsMode.targetPlayerIndex = null; stealCardsMode.targetDeck = []; stealCardsMode.selectedIndices = []; } }); function showStealCardsModal(state, data) { const deckList = $('steal-deck-list'); const selectedEl = $('steal-selected'); const confirmBtn = $('btn-steal-confirm'); const hintEl = $('steal-cards-hint'); const targetSelect = $('steal-target-select'); if (!deckList || !selectedEl || !confirmBtn) return; // Если противник уже выбран, показываем его колоду if (stealCardsMode.targetPlayerIndex !== null) { // Обновляем колоду из актуального gameState const targetPlayer = state.players[stealCardsMode.targetPlayerIndex]; if (targetPlayer && targetPlayer.deck) { stealCardsMode.targetDeck = [...targetPlayer.deck]; } if (stealCardsMode.targetDeck.length === 0) { // Колода пуста, закрываем модальное окно $('steal-cards-overlay')?.classList.add('hidden'); stealCardsMode.active = false; return; } targetSelect.classList.add('hidden'); deckList.classList.remove('hidden'); if (hintEl) { hintEl.textContent = `Выберите до 2 карт из колоды ${targetPlayer?.name || `Игрока ${stealCardsMode.targetPlayerIndex + 1}`} (${stealCardsMode.targetDeck.length} карт)`; } deckList.innerHTML = stealCardsMode.targetDeck.map((cardId, idx) => { const meta = cardDb[cardId]; if (!meta) return ''; const isSelected = stealCardsMode.selectedIndices.includes(idx); const cost = meta.cost || 0; const attack = meta.attack !== undefined ? meta.attack : ''; const health = meta.health !== undefined ? meta.health : ''; const stats = meta.type === 'minion' ? `
    ${attack}${health}
    ` : ''; const costDisplay = `
    ${cost}
    `; const art = getCardArt ? getCardArt(meta) : '✦'; return `
    ${art}
    ${escapeHtml(meta.name)}
    ${stats} ${costDisplay}
    `; }).filter(Boolean).join(''); $all('.steal-deck-card').forEach(card => { card.onclick = function() { const idx = parseInt(card.dataset.deckIndex, 10); const selectedIdx = stealCardsMode.selectedIndices.indexOf(idx); if (selectedIdx >= 0) { stealCardsMode.selectedIndices.splice(selectedIdx, 1); } else if (stealCardsMode.selectedIndices.length < 2) { stealCardsMode.selectedIndices.push(idx); } showStealCardsModal(state, data); }; }); selectedEl.innerHTML = stealCardsMode.selectedIndices.length ? stealCardsMode.selectedIndices.map(idx => { const cardId = stealCardsMode.targetDeck[idx]; const meta = cardDb[cardId]; if (!meta) return ''; return `
    ${getCardArt(meta)}
    ${escapeHtml(meta.name)}
    `; }).join('') : '
    Выберите до 2 карт
    '; confirmBtn.disabled = stealCardsMode.selectedIndices.length === 0; confirmBtn.onclick = function() { if (confirmBtn.disabled || stealCardsMode.selectedIndices.length === 0) return; if (typeof window.Sounds !== 'undefined') window.Sounds.playCard(); socket.emit('stealCards', { handIndex: stealCardsMode.handIndex, targetPlayerIndex: stealCardsMode.targetPlayerIndex, cardIndices: stealCardsMode.selectedIndices }); stealCardsMode.active = false; stealCardsMode.handIndex = -1; stealCardsMode.targetPlayerIndex = null; stealCardsMode.targetDeck = []; stealCardsMode.selectedIndices = []; $('steal-cards-overlay')?.classList.add('hidden'); }; } else { // Показываем выбор противника (если еще не выбран) targetSelect.classList.remove('hidden'); deckList.classList.add('hidden'); if (hintEl) { hintEl.textContent = 'Выберите противника для грабежа'; } const enemies = state.players.filter((p, i) => i !== state.yourIndex && p.health > 0 && p.deck && p.deck.length > 0); targetSelect.innerHTML = enemies.map((enemy, idx) => { const enemyIdx = state.players.indexOf(enemy); return `
    ${escapeHtml(enemy.name || `Игрок ${enemyIdx + 1}`)}
    Колода: ${enemy.deck.length} карт
    `; }).join(''); $all('.steal-target-option').forEach(option => { option.onclick = function() { const playerIdx = parseInt(option.dataset.playerIndex, 10); const targetPlayer = state.players[playerIdx]; if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) { stealCardsMode.targetPlayerIndex = playerIdx; // Получаем актуальную колоду из gameState stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : []; showStealCardsModal(state, { targetPlayerIndex: playerIdx, targetPlayerName: targetPlayer.name || `Игрок ${playerIdx + 1}`, targetDeckSize: targetPlayer.deck.length, maxCards: Math.min(2, targetPlayer.deck.length) }); } }; }); } $('steal-cards-overlay')?.classList.remove('hidden'); } let forgeSelected = []; function renderForgeSelected() { const selectedEl = $('forge-selected'); const countEl = $('forge-selected-count'); if (!selectedEl) return; if (countEl) { countEl.textContent = forgeSelected.length; } if (forgeSelected.length === 0) { selectedEl.innerHTML = '
    Кликните на карты чтобы выбрать
    '; } else { selectedEl.innerHTML = forgeSelected.map((cardId, idx) => { const meta = cardDb[cardId]; if (!meta) return ''; return `
    ${getCardArt(meta)}
    ${escapeHtml(meta.name)}
    ${meta.type === 'minion' ? `
    ${meta.attack || 0}${meta.health || 0}
    ` : ''}
    `; }).filter(Boolean).join(''); // Добавляем обработчики удаления $all('.forge-selected-card').forEach(card => { card.onclick = function(e) { e.stopPropagation(); const cardId = card.dataset.cardId; removeCardFromForge(cardId); }; }); } } function updateForgeCraftButton() { const craftBtn = $('btn-forge-craft'); const you = gameState?.players?.[gameState?.yourIndex]; if (craftBtn && you) { const canCraft = forgeSelected.length >= 2 && forgeSelected.length <= 3 && (you.mana || 0) >= 2; craftBtn.disabled = !canCraft; } } function addCardToForge(cardId, source) { if (!cardId) return; if (forgeSelected.includes(cardId)) { // Если карта уже выбрана, убираем её removeCardFromForge(cardId); return; } if (forgeSelected.length >= 3) { // Показываем сообщение, что максимум 3 карты const selectedEl = $('forge-selected'); if (selectedEl) { selectedEl.style.animation = 'shake 0.5s'; setTimeout(() => selectedEl.style.animation = '', 500); } return; } const meta = cardDb[cardId]; if (!meta || meta.type !== 'minion') return; // Только миньоны forgeSelected.push(cardId); renderForgeSelected(); updateForgeCraftButton(); // Обновляем отображение в источниках if (gameState) { renderForgeHand(gameState); renderForgeDeck(gameState); } } function removeCardFromForge(cardId) { const idx = forgeSelected.indexOf(cardId); if (idx >= 0) { forgeSelected.splice(idx, 1); renderForgeSelected(); updateForgeCraftButton(); // Обновляем отображение в источниках if (gameState) { renderForgeHand(gameState); renderForgeDeck(gameState); } } } function renderForgeHand(state) { const you = state.players[state.yourIndex]; const handList = $('forge-hand-list'); if (!handList) return; const hand = you.hand || state.yourHand || []; if (!hand || hand.length === 0) { handList.innerHTML = '
    Рука пуста
    '; return; } // Фильтруем только миньонов const minionCards = hand.filter(cardId => { const meta = cardDb[cardId]; return meta && meta.type === 'minion'; }); if (minionCards.length === 0) { handList.innerHTML = '
    Нет миньонов в руке
    '; return; } handList.innerHTML = minionCards.map((cardId) => { const meta = cardDb[cardId]; if (!meta) return ''; const isSelected = forgeSelected.includes(cardId); return `
    ${getCardArt(meta)}
    ${escapeHtml(meta.name)}
    ${meta.attack || 0} ${meta.health || 0}
    `; }).filter(Boolean).join(''); // Обработчики клика для карт из руки $all('#forge-hand-list .card-wrap').forEach(card => { card.onclick = function(e) { e.stopPropagation(); const cardId = card.dataset.cardId; if (cardId) { addCardToForge(cardId, 'hand'); } }; }); } function renderForgeDeck(state) { const deck = state.yourDeck || []; const deckList = $('forge-deck-list'); if (!deckList) return; if (!deck || deck.length === 0) { deckList.innerHTML = '
    Колода пуста
    '; return; } // Фильтруем только миньонов const minionCards = deck.filter(cardId => { const meta = cardDb[cardId]; return meta && meta.type === 'minion'; }); if (minionCards.length === 0) { deckList.innerHTML = '
    Нет миньонов в колоде
    '; return; } deckList.innerHTML = minionCards.map((cardId) => { const meta = cardDb[cardId]; if (!meta) return ''; const isSelected = forgeSelected.includes(cardId); return `
    ${getCardArt(meta)}
    ${escapeHtml(meta.name)}
    ${meta.attack || 0} ${meta.health || 0}
    `; }).filter(Boolean).join(''); // Обработчики клика для карт из колоды $all('#forge-deck-list .card-wrap').forEach(card => { card.onclick = function(e) { e.stopPropagation(); const cardId = card.dataset.cardId; if (cardId) { addCardToForge(cardId, 'deck'); } }; }); } // Обновляем drag-and-drop при каждом обновлении руки, если кузница открыта // Это делается в bindGameEvents // Обработчик создания улучшенной карты const craftBtn = $('btn-forge-craft'); if (craftBtn) { craftBtn.onclick = function() { if (craftBtn.disabled) return; const you = gameState?.players?.[gameState?.yourIndex]; if (!you || forgeSelected.length < 2 || forgeSelected.length > 3 || (you.mana || 0) < 2) return; socket.emit('forgeCard', { cardIds: forgeSelected }); forgeSelected = []; renderForgeSelected(); updateForgeCraftButton(); const sidebar = $('forge-sidebar'); if (sidebar) { sidebar.classList.add('hidden'); } }; } $('music-toggle')?.addEventListener('click', () => { if (window.Music) { window.Music.toggle(); } }); $('music-volume-slider')?.addEventListener('input', (e) => { const volume = parseFloat(e.target.value) / 100; if (window.Music) { window.Music.setVolume(volume); } }); } function init() { initLobby(); showScreen('lobby'); socket = null; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { init(); if (window.Music) { window.Music.init(); } }); } else { init(); if (window.Music) { window.Music.init(); } } })();