Files
Star-wars-card-game/public/game.js
2026-01-27 22:59:37 +03:00

2804 lines
129 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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 forgePreviewRequestId = 0;
let forgePreviewPendingIds = null;
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) => `<li>${escapeHtml(p.name)}</li>`).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('roomsList', (roomsList) => {
if (typeof window.handleRoomsList === 'function') window.handleRoomsList(roomsList);
});
socket.on('joinedAsSpectator', (data) => {
if (data.gameState) {
gameState = data.gameState;
gameState.isSpectator = true;
gameState.yourIndex = -1;
showScreen('game');
renderGame(gameState);
}
});
socket.on('backToLobby', (data) => {
showScreen('lobby');
clearError();
if (data && data.lobby) {
setLobbyPanel('room');
renderPlayerList('player-list', data.lobby);
} else {
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('forgePreviewResult', (data) => {
if (!data) return;
if (data.requestId != null && data.requestId !== forgePreviewRequestId) return;
const preview = $('forge-preview');
const wrap = $('forge-preview-card-wrap');
const confirmBtn = $('btn-forge-confirm');
if (!preview) return;
if (data.error || !data.resultCardId) {
preview.classList.add('hidden');
if (wrap) wrap.innerHTML = '';
if (confirmBtn) confirmBtn.disabled = true;
forgePreviewPendingIds = null;
return;
}
const meta = (gameState?.cardDb || cardDb)[data.resultCardId];
if (!meta) {
preview.classList.add('hidden');
forgePreviewPendingIds = null;
return;
}
const you = gameState?.isSpectator ? null : (gameState?.players?.[gameState?.yourIndex] || null);
const canCraft = !gameState?.isSpectator && you && (you.mana || 0) >= 2;
if (wrap) {
const art = typeof getCardArt === 'function' ? getCardArt(meta) : '';
wrap.innerHTML = '<div class="card-wrap forge-preview-card" style="width:110px;height:154px;"><div class="card faction-' + (meta.faction || 'neutral') + '">' +
'<div class="card-art">' + art + '</div>' +
'<div class="card-info"><div class="card-name" style="font-size:0.75rem">' + escapeHtml(meta.name || '') + '</div>' +
'<div class="card-stats"><span class="atk">' + (meta.attack || 0) + '</span><span class="hp">' + (meta.health || 0) + '</span></div></div></div></div>';
}
if (confirmBtn) confirmBtn.disabled = !canCraft;
preview.classList.remove('hidden');
});
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 = '<span class="chat-username">' + escapeHtml(data.playerName || 'Игрок') + ':</span> ' +
escapeHtml(data.message || '') +
' <span class="chat-time">' + escapeHtml(time) + '</span>';
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.isSpectator) {
yourIndex = -1;
const spectatorBadge = $('spectator-badge');
if (!spectatorBadge) {
const badge = document.createElement('div');
badge.id = 'spectator-badge';
badge.style.cssText = 'position: fixed; top: 1rem; right: 1rem; background: rgba(212,168,75,0.9); color: #000; padding: 0.5rem 1rem; border-radius: 8px; font-weight: 700; z-index: 200; display: flex; align-items: center; gap: 0.5rem;';
badge.innerHTML = '<i data-lucide="eye" style="width: 18px; height: 18px;"></i><span>НАБЛЮДАТЕЛЬ</span>';
document.body.appendChild(badge);
if (typeof lucide !== 'undefined') lucide.createIcons();
}
} else {
const spectatorBadge = $('spectator-badge');
if (spectatorBadge) spectatorBadge.remove();
}
if (state.phase === 'ended') {
showGameOver(state);
renderGameEnded(state);
return;
}
renderGame(state);
});
socket.on('stealCardsRequest', (data) => {
if (!gameState) return;
// Находим handIndex заклинания "Грабеж" в текущей руке
const you = gameState.isSpectator ? null : (gameState.players[gameState.yourIndex] || null);
if (!you || !you.hand || gameState.isSpectator) 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.board) {
stealCardsMode.targetDeck = targetPlayer.board.map(m => m.cardId);
}
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);
// Обновляем иконки Lucide после рендеринга
if (typeof lucide !== 'undefined') {
setTimeout(() => lucide.createIcons(), 100);
}
}
function showGameOver(state) {
const winner = state.winner;
const modal = $('modal-overlay');
const title = $('modal-title');
const body = $('modal-body');
const newGameBtn = $('btn-new-game');
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 = 'Победитель не определён.';
}
// Показываем кнопку "Новая игра" только хосту (не наблюдателю)
if (newGameBtn && !state.isSpectator && state.yourIndex === 0) {
newGameBtn.style.display = 'block';
} else if (newGameBtn) {
newGameBtn.style.display = 'none';
}
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.isSpectator && 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.isSpectator ? null : (state.players[yourIndex] || null);
const opponentsArea = $('opponents-area');
const yourBoard = $('your-board');
if (opponentsArea) {
opponentsArea.innerHTML = state.players
.map((p, i) => {
if (!state.isSpectator && i === yourIndex) return '';
const isCurrent = state.currentPlayerIndex === i;
const isDead = p.health <= 0 || p.isDead;
const name = p.name || 'Игрок ' + (i + 1);
const isAI = p.isAI || false;
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.isSpectator && state.currentPlayerIndex === state.yourIndex && spellMode.active && spellMode.cardId && cardDb[spellMode.cardId]?.spellEffect === 'steal_cards';
return `
<div class="opponent-block ${isCurrent ? 'current-turn' : ''} ${canSteal ? 'steal-target' : ''} ${isDead ? 'defeated' : ''}" data-opponent-index="${i}" data-player-index="${i}">
<div class="opponent-name ${isDead ? 'defeated-name' : ''}">
${isAI ? '<i data-lucide="bot" style="width: 16px; height: 16px; vertical-align: middle; margin-right: 0.3rem; color: var(--cyan);"></i>' : ''}
${escapeHtml(name)}${isDead ? ' <span class="defeated-badge"><i data-lucide="skull" style="width: 14px; height: 14px; vertical-align: middle;"></i></span>' : ''}
</div>
<div class="opponent-stats ${isDead ? 'defeated-stats' : ''}">
<span class="opponent-health"><i data-lucide="heart" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 0.2rem; color: #e63946;"></i>${p.health ?? 30}</span>
${(p.armor || 0) > 0 ? `<span class="opponent-armor"><i data-lucide="shield" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 0.2rem; color: #5eb3e8;"></i>${p.armor}</span>` : ''}
<span><i data-lucide="zap" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 0.2rem; color: #00d4ff;"></i>${p.mana ?? 0}/${p.maxMana ?? 0}</span>
${p.deck && p.deck.length > 0 ? `<span><i data-lucide="book-open" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 0.2rem; color: var(--gold);"></i>${p.deck.length}</span>` : ''}
</div>
<div class="opponent-board">${heroDrop}${heroBar}${minions.join('')}</div>
</div>`;
})
.filter(Boolean)
.join('');
}
if (yourBoard) {
if (state.isSpectator) {
yourBoard.innerHTML = '<div style="padding: 1rem; text-align: center; color: #94a3b8; font-size: 0.85rem;">Режим наблюдателя — вы не играете</div>';
} else if (you) {
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);
}, 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 + ' <i data-lucide="swords" style="width: 14px; height: 14px; vertical-align: middle; display: inline-block;"></i> атакует ' + 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 + ' <i data-lucide="swords" style="width: 14px; height: 14px; vertical-align: middle; display: inline-block;"></i> атакует героя ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!';
} else {
const damage = logEntry.damage || 0;
text = who(logEntry.fromPlayer) + ' <i data-lucide="swords" style="width: 14px; height: 14px; vertical-align: middle; display: inline-block;"></i> атакует героя ' + 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.isSpectator ? null : (state.players[yourIndex] || null);
$('your-mana').textContent = state.isSpectator ? '—' : (you?.mana ?? 0);
$('your-max-mana').textContent = state.isSpectator ? '—' : (you?.maxMana ?? 10);
const healthEl = $('your-health');
if (healthEl) {
const newHealth = state.isSpectator ? '—' : (you?.health ?? 30);
const oldHealthText = healthEl.textContent;
const oldHealth = state.isSpectator ? '—' : (parseInt(oldHealthText) || 30);
healthEl.textContent = newHealth;
// Добавляем визуальный эффект при изменении HP
if (!state.isSpectator && typeof newHealth === 'number' && typeof oldHealth === 'number' && newHealth !== oldHealth) {
healthEl.classList.add('health-changed');
setTimeout(() => healthEl.classList.remove('health-changed'), 500);
}
}
// Отображение брони
const armorEl = $('your-armor');
const armorDisplayEl = $('armor-display');
if (armorEl && armorDisplayEl) {
const armor = state.isSpectator ? 0 : (state.yourArmor || you?.armor || 0);
armorEl.textContent = armor;
if (armor > 0) {
armorDisplayEl.style.display = 'inline-flex';
} else {
armorDisplayEl.style.display = 'none';
}
}
$('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 = '<span class="chat-time">' + escapeHtml(time) + '</span> ' + 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 && !state.isSpectator;
const endBtn = $('btn-end-turn');
if (endBtn) {
endBtn.disabled = !isYourTurn || state.isSpectator;
if (state.isSpectator) endBtn.title = 'Режим наблюдателя';
}
const heroBtn = $('btn-hero-ability');
if (heroBtn) {
var canHero = isYourTurn && !state.isSpectator && (you.mana ?? 0) >= 2 && !state.yourHeroAbilityUsed && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
heroBtn.disabled = !canHero || state.isSpectator;
heroBtn.classList.toggle('hidden', !isYourTurn || state.isSpectator);
}
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 = state.isSpectator ? [] : (you.hand || []);
var justDrew = !state.isSpectator && hand.length > lastHandLength && lastHandLength > 0;
if (justDrew && typeof window.Sounds !== 'undefined') window.Sounds.drawCard();
lastHandLength = hand.length;
const handEl = $('your-hand');
if (handEl) {
if (state.isSpectator) {
handEl.innerHTML = '<div style="padding: 2rem; text-align: center; color: #94a3b8; font-size: 0.9rem;">Режим наблюдателя — вы не можете играть</div>';
} else {
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 && you.mana >= cost && (you.board?.length ?? 0) < 7;
notEnoughMana = you && you.mana < cost;
} else if (meta?.type === 'spell') {
playable = you && you.mana >= cost && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
notEnoughMana = you && you.mana < cost;
}
}
var drawAnim = justDrew && i === hand.length - 1;
return renderHandCard(cid, i, hand.length, playable, drawAnim, notEnoughMana);
})
.join('');
}
}
const deckCount = state.isSpectator ? 0 : (state.yourDeckCount ?? you?.deck?.length ?? 0);
const canDraw = isYourTurn && !state.isSpectator && !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 ? '<div class="deck-card"></div>' : '';
}
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('<span class="ability-tag">Вой:</span>' + escapeHtml(meta.battlecry));
if (meta.deathrattle) tags.push('<span class="ability-tag">Предсм.:</span>' + escapeHtml(meta.deathrattle));
if (!tags.length) return '';
return '<div class="card-abilities">' + tags.join(' ') + '</div>';
}
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
? '<div class="card-cost-wrap"><span class="card-stat-label">Мана</span><span class="card-cost">' + cost + '</span></div>'
+ '<div class="card-atk-wrap"><span class="card-stat-label">Атака</span><span class="atk">' + atk + '</span></div>'
+ '<div class="card-hp-wrap"><span class="card-stat-label">Здоровье</span><span class="hp">' + hp + '</span></div>'
: '<div class="card-cost-wrap"><span class="card-stat-label">Мана</span><span class="card-cost">' + cost + '</span></div>';
var textHtml = meta.text ? '<div class="card-text">' + escapeHtml(meta.text) + '</div>' : '';
var abilHtml = abilityTags(meta);
var fictionalClass = meta.fictional ? ' fictional-card' : '';
var notEnoughManaClass = notEnoughMana ? ' not-enough-mana' : '';
return '<div class="card-wrap in-hand ' + (playable ? 'playable' : '') + ' ' + (drawFromDeck ? 'draw-from-deck' : '') + fictionalClass + notEnoughManaClass + '" data-hand-index="' + handIndex + '" data-card-id="' + cardId + '" style="--hand-rotate: ' + rotate + 'deg" title="' + escapeHtml(name) + (meta.text ? ': ' + meta.text : '') + (meta.fictional ? ' [ВЫМЫШЛЕННЫЙ]' : '') + (notEnoughMana ? ' (Недостаточно маны)' : '') + '">'
+ '<div class="card faction-' + faction + fictionalClass + '">'
+ '<div class="card-art">' + art + '</div>'
+ '<div class="card-info">'
+ '<div class="card-name">' + escapeHtml(name) + '</div>'
+ textHtml + abilHtml
+ '<div class="card-stats">' + statsHtml + '</div>'
+ '<div class="card-info-row"><span></span><button type="button" class="card-btn-info" data-card-id="' + cardId + '" title="Описание">i</button></div>'
+ '</div></div></div>';
}
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 '<img src="extraicons/' + extraIcon + '" class="card-art-image" alt="' + (meta.name || '') + '" />';
}
// Используем стандартные SWG иконки
var swg = swgMap[meta.art];
if (swg) return '<i class="swg swg-lg swg-' + swg + ' card-art-swg" aria-hidden="true"></i>';
return '<span class="card-art-fallback">✦</span>';
}
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 ? '<div class="card-text">' + escapeHtml(meta.text) + '</div>' : '';
var frozenIcon = m.frozen ? '<div class="frozen-icon">❄</div>' : '';
return '<div class="' + cls.join(' ') + '" data-player-index="' + playerIndex + '" data-board-index="' + boardIndex + '" data-minion-id="' + m.id + '" data-attacker-board-index="' + (!isOpponent && canAttack && !m.frozen ? boardIndex : '') + '"' + draggable + dropAttrs + dragTitle + '>'
+ '<div class="' + cardCls + '">'
+ frozenIcon
+ '<div class="card-art">' + art + '</div>'
+ '<div class="card-info">'
+ '<div class="card-name">' + escapeHtml(name) + '</div>'
+ textHtml + abilHtml
+ '<div class="card-stats">'
+ '<div class="card-atk-wrap"><span class="card-stat-label">Атака</span><span class="atk">' + displayAttack + (synergyAttack > 0 ? '<span class="synergy-bonus">+' + synergyAttack + '</span>' : '') + '</span></div>'
+ '<div class="card-hp-wrap"><span class="card-stat-label">Здоровье</span><span class="hp">' + displayHealth + (synergyHealth > 0 ? '<span class="synergy-bonus">+' + synergyHealth + '</span>' : '') + '</span></div>'
+ '</div>'
+ '<div class="card-info-row"><span></span><button type="button" class="card-btn-info" data-card-id="' + escapeHtml(m.cardId) + '" title="Описание">i</button></div>'
+ '</div></div></div>';
}
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 '<div class="' + heroCls + '" data-drop-player="' + playerIndex + '" data-drop-board="-1" title="' + title + '">'
+ '<span class="drop-hero-icon"><i data-lucide="heart" style="width: 18px; height: 18px; color: #e63946;"></i></span>'
+ '<span class="drop-hero-name">' + escapeHtml(name) + '</span>'
+ '</div>';
}
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 `
<div class="card-wrap targetable hero-target drop-target" data-player-index="${playerIndex}" data-board-index="-1" data-drop-player="${playerIndex}" data-drop-board="-1">
<div class="card faction-neutral">
<div class="card-art"><i data-lucide="heart" style="width: 48px; height: 48px; color: #e63946;"></i></div>
<div class="card-info">
<div class="card-name">${escapeHtml(name)} (лидер)</div>
<div class="card-stats"><span class="hp">${p?.health ?? 30}</span></div>
</div>
</div>
</div>`;
}
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 && !state.isSpectator && !attackMode.active && !heroAbilityMode.active && meta && you && you.mana >= (meta.cost || 0);
// Если кузница открыта и карта - миньон, делаем её перетаскиваемой для кузницы
if (isForgeOpen && isMinion && !state.isSpectator) {
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 && !state.isSpectator && you && (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 (state.isSpectator || 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;
}
}
// Если это Грабеж, но spellTarget не enemy_player, все равно активируем режим выбора цели
if (meta.spellEffect === 'steal_cards' && !meta.spellTarget) {
spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: 'enemy_player' };
$('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('<strong>Вой:</strong> ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('<strong>Предсмертный хрип:</strong> ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('<br>') : '';
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('<strong>Вой:</strong> ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('<strong>Предсмертный хрип:</strong> ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('<br>') : '';
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('<strong>Вой:</strong> ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('<strong>Предсмертный хрип:</strong> ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('<br>') : '';
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;
}
// Специальная обработка для Грабежа - отправляем playSpell на сервер
const spellCard = cardDb[spellMode.cardId];
if (spellCard && spellCard.spellEffect === 'steal_cards' && (spellCard.spellTarget === 'enemy_player' || spellCard.spellTarget === 'enemy_board')) {
// Выбираем противника (tp должен быть индексом противника, tb игнорируем для enemy_player)
if (tp !== state.yourIndex && tp >= 0) {
const targetPlayer = state.players[tp];
if (targetPlayer && targetPlayer.board && targetPlayer.board.length > 0) {
// Отправляем playSpell на сервер, чтобы сервер знал о выборе противника
socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: null });
// Сервер отправит stealCardsRequest, который откроет модальное окно
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);
// Специальная обработка для Грабежа - отправляем playSpell на сервер
const spellCard = cardDb[spellMode.cardId];
if (spellCard && spellCard.spellEffect === 'steal_cards' && (spellCard.spellTarget === 'enemy_player' || spellCard.spellTarget === 'enemy_board')) {
if (tp !== state.yourIndex && tp >= 0) {
const targetPlayer = state.players[tp];
if (targetPlayer && targetPlayer.board && targetPlayer.board.length > 0) {
// Отправляем playSpell на сервер, чтобы сервер знал о выборе противника
socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: null });
// Сервер отправит stealCardsRequest, который откроет модальное окно
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' && spellCard.spellTarget !== 'enemy_board')) 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.board && targetPlayer.board.length > 0) {
// Отправляем playSpell на сервер, чтобы сервер знал о выборе противника
socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: null });
// Сервер отправит stealCardsRequest, который откроет модальное окно
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' && spellCard.spellTarget !== 'enemy_board')) 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.board && targetPlayer.board.length > 0) {
// Отправляем playSpell на сервер, чтобы сервер знал о выборе противника
socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: null });
// Сервер отправит stealCardsRequest, который откроет модальное окно
spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
$('spell-mode')?.classList.add('hidden');
}
};
}
});
var heroAbilityBtn = $('btn-hero-ability');
if (heroAbilityBtn) heroAbilityBtn.onclick = function () {
if (state.isSpectator || heroAbilityBtn.disabled || heroAbilityMode.active) return;
heroAbilityMode = { active: true };
$('hero-ability-mode')?.classList.remove('hidden');
renderGame(state);
};
var forgeBtn = $('btn-forge');
if (forgeBtn && isYourTurn && !state.isSpectator && you && you.mana >= 2) {
forgeBtn.disabled = false;
forgeBtn.onclick = function () {
if (state.isSpectator || attackMode.active || spellMode.active || heroAbilityMode.active) return;
const sidebar = $('forge-sidebar');
if (sidebar) {
sidebar.classList.remove('hidden');
forgeSelected = []; // Сбрасываем выбор при открытии
renderForgeHand(state);
renderForgeSelected();
updateForgePreview();
}
};
} 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);
renderForgeSelected();
updateForgePreview();
}
}
function initLobby() {
const hostTab = document.querySelector('.tab[data-tab="host"]');
const joinTab = document.querySelector('.tab[data-tab="join"]');
const browseTab = document.querySelector('.tab[data-tab="browse"]');
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');
$('browse-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');
$('browse-panel')?.classList.remove('active');
clearError();
});
browseTab?.addEventListener('click', () => {
$all('.tab').forEach((t) => t.classList.remove('active'));
browseTab.classList.add('active');
$('browse-panel')?.classList.add('active');
$('host-panel')?.classList.remove('active');
$('join-panel')?.classList.remove('active');
clearError();
// Подключаемся к серверу если еще не подключены
if (!socket || !socket.connected) {
const url = window.location.origin;
connect(url);
socket.once('connect', () => {
socket.emit('getRoomsList');
});
} else {
socket.emit('getRoomsList');
}
});
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');
const newGameBtn = $('btn-new-game');
if (newGameBtn) newGameBtn.style.display = 'none';
});
$('btn-new-game')?.addEventListener('click', () => {
if (socket && socket.connected) {
socket.emit('resetToLobby');
$('modal-overlay')?.classList.add('hidden');
const newGameBtn = $('btn-new-game');
if (newGameBtn) newGameBtn.style.display = 'none';
}
});
$('btn-refresh-rooms')?.addEventListener('click', () => {
if (socket && socket.connected) {
socket.emit('getRoomsList');
} else {
const url = window.location.origin;
connect(url);
socket.once('connect', () => {
socket.emit('getRoomsList');
});
}
});
// Обработчик списка комнат
window.handleRoomsList = function(roomsList) {
const roomsListEl = $('rooms-list');
if (!roomsListEl) return;
if (!roomsList || roomsList.length === 0) {
roomsListEl.innerHTML = '<p class="hint" style="text-align: center; padding: 2rem; color: #94a3b8;">Нет доступных игр</p>';
return;
}
roomsListEl.innerHTML = roomsList.map((room) => {
const status = room.gameStarted ? 'Игра идёт' : 'Ожидание игроков';
const statusColor = room.gameStarted ? 'var(--amber)' : 'var(--cyan)';
const playersText = room.players.length > 0 ? room.players.join(', ') : 'Нет игроков';
const canJoin = !room.gameStarted && room.playerCount < room.maxPlayers;
const actionIcon = room.gameStarted ? '<i data-lucide="eye" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 0.3rem;"></i>' : (canJoin ? '<i data-lucide="play" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 0.3rem;"></i>' : '');
const actionText = room.gameStarted ? 'Наблюдать' : (canJoin ? 'Подключиться' : 'Полная');
const actionClass = room.gameStarted ? 'btn-ghost' : (canJoin ? 'btn-primary' : 'btn-ghost');
const actionDisabled = !room.gameStarted && !canJoin;
return `<div class="room-item" style="padding: 1rem; margin-bottom: 0.75rem; background: rgba(0,0,0,0.3); border: 2px solid rgba(212,168,75,0.2); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<div>
<div style="font-weight: 700; color: var(--cyan); font-size: 1.1rem; letter-spacing: 0.1em;">${escapeHtml(room.code)}</div>
<div style="font-size: 0.85rem; color: ${statusColor}; margin-top: 0.25rem;">${status}</div>
</div>
<div style="text-align: right; font-size: 0.85rem; color: #94a3b8;">
<div>Игроки: ${room.playerCount}/${room.maxPlayers}</div>
${room.spectators > 0 ? `<div>Наблюдателей: ${room.spectators}</div>` : ''}
${room.hasAI ? '<div style="color: var(--amber); display: inline-flex; align-items: center; gap: 0.3rem;"><i data-lucide="bot" style="width: 14px; height: 14px;"></i>ИИ</div>' : ''}
</div>
</div>
<div style="font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.5rem;">${escapeHtml(playersText)}</div>
<button type="button" class="btn ${actionClass}" data-room-code="${room.code}" ${actionDisabled ? 'disabled' : ''} style="width: 100%;">${actionIcon}${actionText}</button>
</div>`;
}).join('');
// Обработчики подключения к комнатам
$all('.room-item button').forEach(btn => {
btn.addEventListener('click', function() {
const code = this.dataset.roomCode;
const name = ($('browse-name')?.value || '').trim() || 'Игрок';
if (!code) return;
if (!socket || !socket.connected) {
const url = window.location.origin;
connect(url);
socket.once('connect', () => {
socket.emit('joinRoom', { code, name });
});
} else {
socket.emit('joinRoom', { code, name });
}
});
});
// Обновляем иконки Lucide после создания кнопок
if (typeof lucide !== 'undefined') {
setTimeout(() => lucide.createIcons(), 100);
}
};
$('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 = '<p style="color: #94a3b8; text-align: center; padding: 2rem;">Не удалось загрузить карты</p>';
});
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' ? `<div class="card-stats"><span class="atk">${attack}</span><span class="hp">${health}</span></div>` : '';
const costDisplay = card.type === 'spell' ? `<div class="card-cost-wrap"><span class="card-cost">${cost}</span></div>` : '';
const art = getCardArt ? getCardArt(card) : '✦';
return `<div class="card-wrap gallery-card ${isFictional ? 'fictional' : ''}" data-card-id="${card.id}" style="width: 140px; height: 196px;">
<div class="card faction-${card.faction || 'neutral'}">
<div class="card-art">${art}</div>
<div class="card-info">
<div class="card-name" style="font-size: 0.75rem;">${escapeHtml(card.name)}</div>
${card.text ? `<div class="card-text" style="font-size: 0.65rem;">${escapeHtml(card.text)}</div>` : ''}
${stats}
${costDisplay}
</div>
</div>
</div>`;
}).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 = [];
forgePreviewPendingIds = null;
const preview = $('forge-preview');
if (preview) preview.classList.add('hidden');
renderForgeSelected();
updateForgePreview();
}
});
$('btn-forge-clear')?.addEventListener('click', () => {
forgeSelected = [];
forgePreviewPendingIds = null;
renderForgeSelected();
updateForgePreview();
if (gameState) renderForgeHand(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) {
console.error('Steal cards modal elements not found');
return;
}
const currentState = gameState || state;
if (!currentState || !currentState.players) {
console.error('Invalid game state for steal cards modal');
return;
}
// Если противник уже выбран, показываем его доску (карты на столе)
if (stealCardsMode.targetPlayerIndex !== null) {
// Обновляем доску из актуального gameState
const targetPlayer = currentState.players[stealCardsMode.targetPlayerIndex];
if (targetPlayer && targetPlayer.board) {
stealCardsMode.targetDeck = targetPlayer.board.map(m => m.cardId);
} else {
console.warn('Target player not found or board is empty:', stealCardsMode.targetPlayerIndex);
}
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 = targetPlayer.board.map((minion, idx) => {
const cardId = minion.cardId;
const cardDbToUse = currentState?.cardDb || cardDb;
const meta = cardDbToUse[cardId];
if (!meta) {
console.warn('Card not found in cardDb:', cardId, 'Available keys:', Object.keys(cardDbToUse).slice(0, 10));
return `<div class="card-wrap steal-deck-card" data-card-id="${cardId}" data-deck-index="${idx}" style="width: 100px; height: 140px; cursor: pointer; border: 2px solid red; position: relative; z-index: 10;">
<div style="padding: 0.5rem; text-align: center; font-size: 0.7rem; color: #ff6b6b;">Карта ${cardId}</div>
</div>`;
}
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' ? `<div class="card-stats"><span class="atk">${attack}</span><span class="hp">${health}</span></div>` : '';
const costDisplay = `<div class="card-cost-wrap"><span class="card-cost">${cost}</span></div>`;
const art = typeof getCardArt === 'function' ? getCardArt(meta) : '✦';
return `<div class="card-wrap steal-deck-card ${isSelected ? 'selected' : ''}" data-card-id="${cardId}" data-board-index="${idx}" style="width: 100px; height: 140px; cursor: pointer; position: relative; z-index: 10;">
<div class="card faction-${meta.faction || 'neutral'}">
<div class="card-art">${art}</div>
<div class="card-info">
<div class="card-name" style="font-size: 0.7rem;">${escapeHtml(meta.name || cardId)}</div>
${stats}
${costDisplay}
</div>
</div>
</div>`;
}).join('');
$all('.steal-deck-card').forEach(card => {
const handleCardClick = function(e) {
e.preventDefault();
e.stopPropagation();
const idx = parseInt(card.dataset.boardIndex, 10);
if (isNaN(idx)) {
console.warn('Invalid board index:', card.dataset.boardIndex);
return;
}
const selectedIdx = stealCardsMode.selectedIndices.indexOf(idx);
if (selectedIdx >= 0) {
stealCardsMode.selectedIndices.splice(selectedIdx, 1);
} else if (stealCardsMode.selectedIndices.length < 2) {
stealCardsMode.selectedIndices.push(idx);
}
const currentState = gameState || state;
showStealCardsModal(currentState, data || {
targetPlayerIndex: stealCardsMode.targetPlayerIndex,
targetPlayerName: currentState?.players?.[stealCardsMode.targetPlayerIndex]?.name || `Игрок ${stealCardsMode.targetPlayerIndex + 1}`,
targetBoardSize: currentState?.players?.[stealCardsMode.targetPlayerIndex]?.board?.length || 0,
maxCards: Math.min(2, currentState?.players?.[stealCardsMode.targetPlayerIndex]?.board?.length || 0)
});
};
card.onclick = handleCardClick;
card.style.pointerEvents = 'auto';
if (isTouchDevice()) {
card.ontouchstart = function(e) {
e.preventDefault();
handleCardClick(e);
};
}
});
selectedEl.innerHTML = stealCardsMode.selectedIndices.length ? stealCardsMode.selectedIndices.map(idx => {
const cardId = stealCardsMode.targetDeck[idx];
const meta = cardDb[cardId];
if (!meta) return '';
return `<div class="card-wrap" style="width: 80px; height: 112px;"><div class="card faction-${meta.faction || 'neutral'}"><div class="card-art">${getCardArt(meta)}</div><div class="card-info"><div class="card-name" style="font-size: 0.6rem;">${escapeHtml(meta.name)}</div></div></div></div>`;
}).join('') : '<div class="steal-selected-empty">Выберите до 2 карт</div>';
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 = currentState.players.filter((p, i) => i !== currentState.yourIndex && p.health > 0 && p.deck && p.deck.length > 0);
if (enemies.length === 0) {
if (hintEl) hintEl.textContent = 'Нет доступных противников для грабежа';
targetSelect.innerHTML = '';
return;
}
targetSelect.innerHTML = enemies.map((enemy, idx) => {
const enemyIdx = currentState.players.indexOf(enemy);
return `<div class="steal-target-option" data-player-index="${enemyIdx}" style="padding: 1rem; margin: 0.5rem 0; background: rgba(0,0,0,0.3); border: 2px solid rgba(0,180,255,0.3); border-radius: 8px; cursor: pointer; transition: all 0.2s;">
<div style="font-weight: 700; color: var(--cyan);">${escapeHtml(enemy.name || `Игрок ${enemyIdx + 1}`)}</div>
<div style="font-size: 0.85rem; color: #94a3b8; margin-top: 0.25rem;">Колода: ${enemy.deck.length} карт</div>
</div>`;
}).join('');
$all('.steal-target-option').forEach(option => {
const handleOptionClick = function(e) {
e.preventDefault();
e.stopPropagation();
const playerIdx = parseInt(option.dataset.playerIndex, 10);
if (isNaN(playerIdx)) return;
const currentState = gameState || state;
if (!currentState || !currentState.players) {
console.error('Invalid state when selecting target player');
return;
}
const targetPlayer = currentState.players[playerIdx];
if (targetPlayer && targetPlayer.board && targetPlayer.board.length > 0) {
stealCardsMode.targetPlayerIndex = playerIdx;
stealCardsMode.targetDeck = targetPlayer.board ? targetPlayer.board.map(m => m.cardId) : [];
console.log('Selected target player:', playerIdx, 'Board size:', stealCardsMode.targetDeck.length);
showStealCardsModal(currentState, {
targetPlayerIndex: playerIdx,
targetPlayerName: targetPlayer.name || `Игрок ${playerIdx + 1}`,
targetBoardSize: targetPlayer.board.length,
maxCards: Math.min(2, targetPlayer.board.length)
});
} else {
console.warn('Target player has no board or is invalid:', playerIdx, targetPlayer);
}
};
option.onclick = handleOptionClick;
option.style.pointerEvents = 'auto';
option.style.cursor = 'pointer';
if (isTouchDevice()) {
option.ontouchstart = function(e) {
e.preventDefault();
handleOptionClick(e);
};
}
});
}
$('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 = '<div class="forge-selected-empty">Кликните на карты из руки</div>';
} else {
selectedEl.innerHTML = forgeSelected.map((cardId, idx) => {
const meta = cardDb[cardId];
if (!meta) return '';
return `<div class="card-wrap forge-selected-card" data-card-id="${cardId}" data-forge-index="${idx}" style="width: 90px; height: 126px; position: relative;">
<div class="card faction-${meta.faction || 'neutral'}">
<div class="card-art">${getCardArt(meta)}</div>
<div class="card-info">
<div class="card-name" style="font-size: 0.7rem;">${escapeHtml(meta.name)}</div>
${meta.type === 'minion' ? `<div class="card-stats"><span class="atk">${meta.attack || 0}</span><span class="hp">${meta.health || 0}</span></div>` : ''}
</div>
</div>
</div>`;
}).filter(Boolean).join('');
// Добавляем обработчики удаления
$all('.forge-selected-card').forEach(card => {
card.onclick = function(e) {
e.stopPropagation();
const cardId = card.dataset.cardId;
removeCardFromForge(cardId);
};
});
}
}
function updateForgePreview() {
const preview = $('forge-preview');
const wrap = $('forge-preview-card-wrap');
const confirmBtn = $('btn-forge-confirm');
if (!preview) return;
if (forgeSelected.length < 2 || forgeSelected.length > 3) {
preview.classList.add('hidden');
if (wrap) wrap.innerHTML = '';
if (confirmBtn) confirmBtn.disabled = true;
forgePreviewPendingIds = null;
return;
}
forgePreviewRequestId = Date.now();
forgePreviewPendingIds = [...forgeSelected];
if (wrap) wrap.innerHTML = '<p class="forge-preview-loading">Загрузка…</p>';
if (confirmBtn) confirmBtn.disabled = true;
preview.classList.remove('hidden');
socket.emit('forgePreview', { cardIds: forgePreviewPendingIds, requestId: forgePreviewRequestId });
}
function addCardToForge(cardId, source) {
if (!cardId) return;
if (source !== 'hand') 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();
updateForgePreview();
if (gameState) renderForgeHand(gameState);
}
function removeCardFromForge(cardId) {
const idx = forgeSelected.indexOf(cardId);
if (idx >= 0) {
forgeSelected.splice(idx, 1);
renderForgeSelected();
updateForgePreview();
if (gameState) renderForgeHand(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 = '<div style="color: #94a3b8; text-align: center; padding: 2rem; font-size: 0.9rem;">Рука пуста</div>';
return;
}
// Фильтруем только миньонов
const minionCards = hand.filter(cardId => {
const meta = cardDb[cardId];
return meta && meta.type === 'minion';
});
if (minionCards.length === 0) {
handList.innerHTML = '<div style="color: #94a3b8; text-align: center; padding: 2rem; font-size: 0.9rem;">Нет миньонов в руке</div>';
return;
}
handList.innerHTML = minionCards.map((cardId) => {
const meta = cardDb[cardId];
if (!meta) return '';
const isSelected = forgeSelected.includes(cardId);
return `<div class="card-wrap ${isSelected ? 'selected' : ''}" data-card-id="${cardId}" style="width: 100px; height: 140px; cursor: pointer;">
<div class="card faction-${meta.faction || 'neutral'}">
<div class="card-art">${getCardArt(meta)}</div>
<div class="card-info">
<div class="card-name" style="font-size: 0.7rem;">${escapeHtml(meta.name)}</div>
<div class="card-stats">
<span class="atk">${meta.attack || 0}</span>
<span class="hp">${meta.health || 0}</span>
</div>
</div>
</div>
</div>`;
}).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');
}
};
});
}
const confirmBtn = $('btn-forge-confirm');
if (confirmBtn) {
confirmBtn.onclick = function() {
if (confirmBtn.disabled || !forgePreviewPendingIds || forgePreviewPendingIds.length < 2) return;
const you = gameState?.players?.[gameState?.yourIndex];
if (!you || (you.mana || 0) < 2) return;
socket.emit('forgeCard', { cardIds: forgePreviewPendingIds });
if (typeof window.Sounds !== 'undefined' && window.Sounds.forge) window.Sounds.forge();
forgeSelected = [];
forgePreviewPendingIds = null;
const preview = $('forge-preview');
if (preview) preview.classList.add('hidden');
renderForgeSelected();
updateForgePreview();
if (gameState) renderForgeHand(gameState);
const sidebar = $('forge-sidebar');
if (sidebar) sidebar.classList.add('hidden');
};
}
const cancelPreviewBtn = $('btn-forge-cancel-preview');
if (cancelPreviewBtn) {
cancelPreviewBtn.onclick = function() {
const preview = $('forge-preview');
if (preview) preview.classList.add('hidden');
forgePreviewPendingIds = null;
const wrap = $('forge-preview-card-wrap');
if (wrap) wrap.innerHTML = '';
const cb = $('btn-forge-confirm');
if (cb) cb.disabled = true;
};
}
$('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();
}
// Инициализация Lucide Icons после загрузки DOM
if (typeof lucide !== 'undefined') {
lucide.createIcons();
// Обновляем иконки при изменении DOM
const observer = new MutationObserver(() => {
lucide.createIcons();
});
observer.observe(document.body, { childList: true, subtree: true });
}
});
} else {
init();
if (typeof lucide !== 'undefined') lucide.createIcons();
if (window.Music) {
window.Music.init();
}
}
})();