Files
Star-wars-card-game/public/game.js
2026-01-26 15:39:13 +03:00

2115 lines
97 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 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: [] };
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('backToLobby', () => {
showScreen('lobby');
clearError();
setLobbyPanel('host');
const hp = $('host-panel');
if (hp) hp.classList.add('active');
});
socket.on('turnTime', function (data) {
var el = $('turn-timer');
if (!el) return;
var m = Math.floor(data.left / 60);
var s = data.left % 60;
el.textContent = m + ':' + (s < 10 ? '0' : '') + s;
el.classList.toggle('timer-warn', data.left > 0 && data.left <= 15);
el.classList.toggle('timer-ended', data.ended);
if (data.warn && typeof window.Sounds !== 'undefined') window.Sounds.timerWarning();
if (data.ended && typeof window.Sounds !== 'undefined') window.Sounds.timerEnd();
});
socket.on('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');
gameState = state;
cardDb = state.cardDb || {};
yourIndex = state.yourIndex ?? 0;
if (state.phase === 'ended') {
showGameOver(state);
renderGameEnded(state);
return;
}
renderGame(state);
});
socket.on('stealCardsRequest', (data) => {
if (!gameState) return;
// Находим handIndex заклинания "Грабеж" в текущей руке
const you = gameState.players[gameState.yourIndex];
if (!you || !you.hand) return;
// Ищем заклинание "Грабеж" в руке
let handIndex = -1;
for (let i = 0; i < you.hand.length; i++) {
const card = cardDb[you.hand[i]];
if (card && card.spellEffect === 'steal_cards') {
handIndex = i;
break;
}
}
if (handIndex < 0) return;
stealCardsMode.active = true;
stealCardsMode.handIndex = handIndex;
stealCardsMode.targetPlayerIndex = data.targetPlayerIndex;
stealCardsMode.targetDeck = [];
stealCardsMode.selectedIndices = [];
// Получаем колоду противника из gameState
const targetPlayer = gameState.players[data.targetPlayerIndex];
if (targetPlayer && targetPlayer.deck) {
stealCardsMode.targetDeck = [...targetPlayer.deck];
}
showStealCardsModal(gameState, data);
});
return socket;
}
function renderGameEnded(state) {
const badge = $('turn-badge');
if (badge) badge.textContent = 'Игра окончена';
const endBtn = $('btn-end-turn');
if (endBtn) endBtn.disabled = true;
const resetBtn = $('btn-reset-lobby');
if (resetBtn) {
resetBtn.classList.toggle('hidden', state.yourIndex !== 0);
}
bindGameEvents(state);
}
function showGameOver(state) {
const winner = state.winner;
const modal = $('modal-overlay');
const title = $('modal-title');
const body = $('modal-body');
if (!modal || !title || !body) return;
if (winner && winner.id === socket?.id) {
title.textContent = 'Победа!';
title.style.color = 'var(--amber)';
body.textContent = 'Светлая сторона Силы с тобой.';
if (typeof window.Sounds !== 'undefined') window.Sounds.victory();
} else if (winner) {
title.textContent = 'Поражение';
title.style.color = 'var(--red)';
body.textContent = 'Победу одержал ' + (winner.name || 'противник') + '.';
if (typeof window.Sounds !== 'undefined') window.Sounds.defeat();
} else {
title.textContent = 'Игра окончена';
title.style.color = 'var(--cyan)';
body.textContent = 'Победитель не определён.';
}
modal.classList.remove('hidden');
}
function renderBoards(state) {
const you = state.players[yourIndex];
if (!you) return;
const opponentsArea = $('opponents-area');
const yourBoard = $('your-board');
if (opponentsArea) {
opponentsArea.innerHTML = state.players
.map((p, i) => {
if (i === yourIndex) return '';
const isCurrent = state.currentPlayerIndex === i;
const name = p.name || 'Игрок ' + (i + 1);
const minions = (p.board || []).map((m, j) => renderBoardMinion(m, i, j, state, true, false));
const heroBar = renderHeroTarget(i, state);
const heroDrop = renderHeroDropZone(i, state);
const canSteal = state.currentPlayerIndex === state.yourIndex && spellMode.active && spellMode.cardId && cardDb[spellMode.cardId]?.spellEffect === 'steal_cards';
return `
<div class="opponent-block ${isCurrent ? 'current-turn' : ''} ${canSteal ? 'steal-target' : ''}" data-opponent-index="${i}" data-player-index="${i}">
<div class="opponent-name">${escapeHtml(name)}</div>
<div class="opponent-stats">
<span>❤ ${p.health}</span>
<span>🔵 ${p.mana}/${p.maxMana}</span>
${p.deck && p.deck.length > 0 ? `<span>📚 ${p.deck.length}</span>` : ''}
</div>
<div class="opponent-board">${heroDrop}${heroBar}${minions.join('')}</div>
</div>`;
})
.filter(Boolean)
.join('');
}
if (yourBoard) {
yourBoard.innerHTML = (you.board || []).map((m, j) => {
const isNew = !seenMinions.has(m.id);
if (isNew) seenMinions.add(m.id);
return renderBoardMinion(m, yourIndex, j, state, false, isNew);
}).join('');
}
}
function showDamageEffect(element, damage, isHero) {
if (!element) return;
const damageEl = document.createElement('div');
damageEl.className = 'damage-indicator';
damageEl.textContent = '-' + damage;
damageEl.style.position = 'absolute';
const rect = element.getBoundingClientRect();
damageEl.style.left = (rect.left + rect.width / 2) + 'px';
damageEl.style.top = (rect.top + rect.height / 2) + 'px';
damageEl.style.pointerEvents = 'none';
damageEl.style.zIndex = '1000';
document.body.appendChild(damageEl);
setTimeout(() => {
damageEl.style.animation = 'damageFloat 1.5s ease-out forwards';
setTimeout(() => damageEl.remove(), 1000); // Сократил с 1500 до 1000 мс
}, 10);
}
function showAttackAnnouncement(state, logEntry) {
const announcementEl = $('attack-announcement');
if (!announcementEl || !logEntry) return;
// Обработка специальных событий
if (logEntry.type === 'skipTurn') {
const playerName = state.players[logEntry.playerIndex]?.name || `Игрок ${logEntry.playerIndex + 1}`;
announcementEl.textContent = `${playerName} пропускает ход`;
announcementEl.classList.remove('hidden');
setTimeout(() => {
announcementEl.classList.add('hidden');
}, 2000);
return;
}
if (logEntry.type === 'playerDefeated') {
const playerName = state.players[logEntry.playerIndex]?.name || `Игрок ${logEntry.playerIndex + 1}`;
announcementEl.textContent = `💀 ${playerName} выбыл из игры`;
announcementEl.classList.remove('hidden');
setTimeout(() => {
announcementEl.classList.add('hidden');
}, 2500);
return;
}
const who = (i) => state.players[i]?.name || ('Игрок ' + (i + 1));
let text = '';
if (logEntry.type === 'attack' && logEntry.attackerMinionId && logEntry.targetMinionId) {
const attackerPlayer = state.players.find(p => p.board?.some(m => m.id === logEntry.attackerMinionId));
const targetPlayer = state.players.find(p => p.board?.some(m => m.id === logEntry.targetMinionId));
if (attackerPlayer && targetPlayer) {
const attackerIdx = state.players.indexOf(attackerPlayer);
const targetIdx = state.players.indexOf(targetPlayer);
const attackerMinion = attackerPlayer.board.find(m => m.id === logEntry.attackerMinionId);
const targetMinion = targetPlayer.board.find(m => m.id === logEntry.targetMinionId);
const attackerCard = cardDb[attackerMinion?.cardId];
const targetCard = cardDb[targetMinion?.cardId];
const attackerName = attackerCard?.name || 'Миньон';
const targetName = targetCard?.name || 'Миньон';
const damage = logEntry.damage || attackerMinion?.attack || 0;
text = who(attackerIdx) + ' → ' + attackerName + ' ⚔ атакует ' + targetName + ' (' + damage + ' урона) → ' + who(targetIdx);
}
} else if (logEntry.type === 'attackHero' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
const attackerPlayer = state.players[logEntry.fromPlayer];
const targetPlayer = state.players[logEntry.toPlayer];
if (attackerPlayer && targetPlayer) {
if (logEntry.attackerMinionId) {
const attackerMinion = attackerPlayer.board?.find(m => m.id === logEntry.attackerMinionId);
const attackerCard = cardDb[attackerMinion?.cardId];
const attackerName = attackerCard?.name || 'Миньон';
const damage = logEntry.damage || attackerMinion?.attack || 0;
text = who(logEntry.fromPlayer) + ' → ' + attackerName + ' ⚔ атакует героя ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!';
} else {
const damage = logEntry.damage || 0;
text = who(logEntry.fromPlayer) + ' ⚔ атакует героя ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!';
}
}
} else if (logEntry.type === 'spell' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
const spellCard = cardDb[logEntry.spell];
const spellName = spellCard?.name || 'Заклинание';
if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) {
const targetPlayer = state.players[logEntry.toPlayer];
const targetMinion = targetPlayer?.board?.[logEntry.toIdx];
const targetCard = cardDb[targetMinion?.cardId];
const targetName = targetCard?.name || 'Миньон';
const damage = logEntry.damage || '';
const damageText = damage ? ' (' + damage + ' урона)' : '';
text = who(logEntry.fromPlayer) + ' → ' + spellName + ' ✨ по ' + targetName + damageText + ' → ' + who(logEntry.toPlayer);
} else {
const damage = logEntry.damage || '';
const damageText = damage ? ' (' + damage + ' урона)' : '';
text = who(logEntry.fromPlayer) + ' → ' + spellName + ' ✨ по герою ' + who(logEntry.toPlayer) + damageText + '!';
}
} else if (logEntry.type === 'heroAbility' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) {
const targetPlayer = state.players[logEntry.toPlayer];
const targetMinion = targetPlayer?.board?.[logEntry.toIdx];
const targetCard = cardDb[targetMinion?.cardId];
const targetName = targetCard?.name || 'Миньон';
const damage = logEntry.damage || 1;
text = who(logEntry.fromPlayer) + ' → Геройская способность ⚡ по ' + targetName + ' (' + damage + ' урона) → ' + who(logEntry.toPlayer);
} else {
const damage = logEntry.damage || 1;
text = who(logEntry.fromPlayer) + ' → Геройская способность ⚡ по герою ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!';
}
} else if (logEntry.type === 'battlecry' && logEntry.damage && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
const attackerPlayer = state.players[logEntry.fromPlayer];
const targetPlayer = state.players[logEntry.toPlayer];
if (attackerPlayer && targetPlayer) {
if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) {
const targetMinion = targetPlayer?.board?.[logEntry.toIdx];
const targetCard = cardDb[targetMinion?.cardId];
const targetName = targetCard?.name || 'Миньон';
text = who(logEntry.fromPlayer) + ' → Battlecry 💥 по ' + targetName + ' (' + logEntry.damage + ' урона) → ' + who(logEntry.toPlayer);
} else {
text = who(logEntry.fromPlayer) + ' → Battlecry 💥 по герою ' + who(logEntry.toPlayer) + ' (' + logEntry.damage + ' урона)!';
}
}
}
if (text) {
announcementEl.textContent = text;
announcementEl.classList.remove('hidden');
setTimeout(() => {
announcementEl.classList.add('hidden');
}, 2000); // Сократил с 3500 до 2000 мс
}
}
function runCombatAnimation(state) {
const lastLog = state.log && state.log.length ? state.log[state.log.length - 1] : null;
if (!lastLog) return false;
if (lastLog.type === 'attack' && lastLog.attackerMinionId && lastLog.targetMinionId) {
const attackerWrap = document.querySelector(`[data-minion-id="${lastLog.attackerMinionId}"]`);
const targetWrap = document.querySelector(`[data-minion-id="${lastLog.targetMinionId}"]`);
if (!attackerWrap || !targetWrap) return false;
// Вычисляем позиции для точного перемещения карты
const attackerRect = attackerWrap.getBoundingClientRect();
const targetRect = targetWrap.getBoundingClientRect();
// Вычисляем смещение
const dx = targetRect.left + targetRect.width / 2 - (attackerRect.left + attackerRect.width / 2);
const dy = targetRect.top + targetRect.height / 2 - (attackerRect.top + attackerRect.height / 2);
// Сохраняем начальную позицию
const startX = attackerRect.left;
const startY = attackerRect.top;
const startWidth = attackerRect.width;
const startHeight = attackerRect.height;
// Устанавливаем fixed positioning для наложения
attackerWrap.style.position = 'fixed';
attackerWrap.style.left = startX + 'px';
attackerWrap.style.top = startY + 'px';
attackerWrap.style.width = startWidth + 'px';
attackerWrap.style.height = startHeight + 'px';
attackerWrap.style.zIndex = '1000';
attackerWrap.style.transition = 'none';
// Добавляем класс для анимации
attackerWrap.classList.add('combat-lunge-overlay');
targetWrap.classList.add('combat-hit');
// Устанавливаем CSS переменные для перемещения
attackerWrap.style.setProperty('--attack-dx', dx + 'px');
attackerWrap.style.setProperty('--attack-dy', dy + 'px');
if (lastLog.damage) showDamageEffect(targetWrap, lastLog.damage, false);
if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
// Анимация удара
setTimeout(() => {
targetWrap.classList.add('combat-hit-impact');
}, 200);
// Возврат карты и очистка
setTimeout(() => {
// Если атакующий не умер, возвращаем карту на место
if (!lastLog.attackerDied) {
attackerWrap.classList.remove('combat-lunge-overlay');
attackerWrap.style.position = '';
attackerWrap.style.left = '';
attackerWrap.style.top = '';
attackerWrap.style.width = '';
attackerWrap.style.height = '';
attackerWrap.style.zIndex = '';
attackerWrap.style.setProperty('--attack-dx', '');
attackerWrap.style.setProperty('--attack-dy', '');
attackerWrap.style.transition = '';
} else {
// Если атакующий умер, сразу применяем анимацию смерти
attackerWrap.classList.remove('combat-lunge-overlay');
attackerWrap.style.position = '';
attackerWrap.style.left = '';
attackerWrap.style.top = '';
attackerWrap.style.width = '';
attackerWrap.style.height = '';
attackerWrap.style.zIndex = '';
attackerWrap.style.setProperty('--attack-dx', '');
attackerWrap.style.setProperty('--attack-dy', '');
attackerWrap.style.transition = '';
attackerWrap.classList.add('combat-death');
showDamageEffect(attackerWrap, '💀', false);
}
if (lastLog.targetDied) {
targetWrap.classList.add('combat-death');
showDamageEffect(targetWrap, '💀', false);
}
targetWrap.classList.remove('combat-hit-impact');
}, 500);
combatTimeout = setTimeout(function () {
combatTimeout = null;
renderBoards(state);
bindGameEvents(state);
}, 600);
return true;
} else if (lastLog.type === 'attackHero' && lastLog.toPlayer !== undefined && lastLog.attackerMinionId) {
const attackerWrap = document.querySelector(`[data-minion-id="${lastLog.attackerMinionId}"]`);
const targetPlayer = state.players[lastLog.toPlayer];
if (attackerWrap && targetPlayer) {
const heroEl = document.querySelector(`[data-player-index="${lastLog.toPlayer}"].hero-target, .opponent-block[data-opponent-index="${lastLog.toPlayer}"] .drop-target-hero`);
if (heroEl) {
// Вычисляем позиции для атаки по герою
const attackerRect = attackerWrap.getBoundingClientRect();
const heroRect = heroEl.getBoundingClientRect();
const dx = heroRect.left + heroRect.width / 2 - (attackerRect.left + attackerRect.width / 2);
const dy = heroRect.top + heroRect.height / 2 - (attackerRect.top + attackerRect.height / 2);
const startX = attackerRect.left;
const startY = attackerRect.top;
const startWidth = attackerRect.width;
const startHeight = attackerRect.height;
// Устанавливаем fixed positioning
attackerWrap.style.position = 'fixed';
attackerWrap.style.left = startX + 'px';
attackerWrap.style.top = startY + 'px';
attackerWrap.style.width = startWidth + 'px';
attackerWrap.style.height = startHeight + 'px';
attackerWrap.style.zIndex = '1000';
attackerWrap.style.transition = 'none';
attackerWrap.classList.add('combat-lunge-overlay');
attackerWrap.style.setProperty('--attack-dx', dx + 'px');
attackerWrap.style.setProperty('--attack-dy', dy + 'px');
heroEl.classList.add('hero-damage-flash', 'combat-hit-impact');
if (lastLog.damage) showDamageEffect(heroEl, lastLog.damage, true);
if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
setTimeout(() => {
attackerWrap.classList.remove('combat-lunge-overlay');
attackerWrap.style.position = '';
attackerWrap.style.left = '';
attackerWrap.style.top = '';
attackerWrap.style.width = '';
attackerWrap.style.height = '';
attackerWrap.style.zIndex = '';
attackerWrap.style.setProperty('--attack-dx', '');
attackerWrap.style.setProperty('--attack-dy', '');
attackerWrap.style.transition = '';
heroEl.classList.remove('hero-damage-flash', 'combat-hit-impact');
}, 500);
}
}
return true;
} else if ((lastLog.type === 'battlecry' || lastLog.type === 'spell' || lastLog.type === 'heroAbility') && lastLog.damage && lastLog.toPlayer !== undefined) {
const targetPlayer = state.players[lastLog.toPlayer];
if (targetPlayer) {
if (lastLog.toIdx !== undefined && lastLog.toIdx >= 0 && targetPlayer.board && targetPlayer.board[lastLog.toIdx]) {
const minionEl = document.querySelector(`[data-player-index="${lastLog.toPlayer}"][data-board-index="${lastLog.toIdx}"]`);
if (minionEl) {
minionEl.classList.add('spell-hit');
showDamageEffect(minionEl, lastLog.damage, false);
setTimeout(() => minionEl.classList.remove('spell-hit'), 500);
}
} else {
const heroEl = document.querySelector(`[data-player-index="${lastLog.toPlayer}"].hero-target, .opponent-block[data-opponent-index="${lastLog.toPlayer}"] .drop-target-hero`);
if (heroEl) {
heroEl.classList.add('hero-damage-flash');
showDamageEffect(heroEl, lastLog.damage, true);
setTimeout(() => heroEl.classList.remove('hero-damage-flash'), 500);
}
}
}
return true;
}
return false;
}
function renderGame(state) {
const you = state.players[yourIndex];
if (!you) return;
$('your-mana').textContent = you.mana;
$('your-max-mana').textContent = you.maxMana;
$('your-health').textContent = you.health;
$('your-deck').textContent = state.yourDeckCount ?? you.deck?.length ?? 0;
const lastLog = state.log && state.log.length ? state.log[state.log.length - 1] : null;
const logEl = $('game-log');
if (logEl) {
function logLine(entry) {
if (!entry) return '';
var who = function (i) { return state.players[i]?.name || ('Игрок ' + (i + 1)); };
if (entry.type === 'play') {
var c = cardDb[entry.cardId];
return who(entry.playerIndex) + ' разыграл ' + (c?.name || entry.cardId);
}
if (entry.type === 'turn') return 'Ход: ' + who(entry.to);
if (entry.type === 'skipTurn') return who(entry.playerIndex) + ' пропускает ход';
if (entry.type === 'playerDefeated') return who(entry.playerIndex) + ' выбыл из игры';
if (entry.type === 'attackHero') return 'Атака по герою!';
if (entry.type === 'attack') return 'Бой!';
if (entry.type === 'draw') return who(entry.playerIndex) + ' вытянул карту';
if (entry.type === 'fatigue') return who(entry.playerIndex) + ' усталость (' + (entry.damage || 0) + ' урона)';
if (entry.type === 'spell') return who(entry.fromPlayer) + ' заклинание';
if (entry.type === 'heroAbility') return who(entry.fromPlayer) + ' — геройская способность';
if (entry.type === 'battlecry' || entry.type === 'deathrattle') return '';
return '';
}
var lines = (state.log || []).slice(-10).map(logLine).filter(Boolean);
logEl.innerHTML = lines.map(function (s) { return '<div class="game-log-line">' + escapeHtml(s) + '</div>'; }).join('');
logEl.scrollTop = logEl.scrollHeight;
}
if (lastLog?.type === 'attackHero' && typeof window.Sounds !== 'undefined') window.Sounds.attack();
const isYourTurn = state.currentPlayerIndex === yourIndex;
const endBtn = $('btn-end-turn');
if (endBtn) endBtn.disabled = !isYourTurn;
const heroBtn = $('btn-hero-ability');
if (heroBtn) {
var canHero = isYourTurn && (you.mana ?? 0) >= 2 && !state.yourHeroAbilityUsed && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
heroBtn.disabled = !canHero;
heroBtn.classList.toggle('hidden', !isYourTurn);
}
const resetBtnEl = $('btn-reset-lobby');
if (resetBtnEl) resetBtnEl.classList.add('hidden');
const badge = $('turn-badge');
if (badge) {
badge.classList.toggle('your-turn', isYourTurn);
badge.textContent = isYourTurn ? 'ВАШ ХОД' : 'Ход ' + (state.turn || 1);
}
var skipBoardUpdate = false;
if (lastLog?.type === 'attack' && lastLog.attackerMinionId && lastLog.targetMinionId && prevGameState && runCombatAnimation(state)) {
skipBoardUpdate = true;
showAttackAnnouncement(state, lastLog);
} else if (lastLog && (lastLog.type === 'attackHero' || lastLog.type === 'spell' || lastLog.type === 'heroAbility' || (lastLog.type === 'battlecry' && lastLog.damage))) {
showAttackAnnouncement(state, lastLog);
} else if (lastLog?.type === 'attack' && lastLog.attackerMinionId && lastLog.targetMinionId) {
showAttackAnnouncement(state, lastLog);
} else if (lastLog?.type === 'skipTurn' || lastLog?.type === 'playerDefeated') {
showAttackAnnouncement(state, lastLog);
}
if (!skipBoardUpdate) {
renderBoards(state);
}
const hand = you.hand || [];
var justDrew = hand.length > lastHandLength && lastHandLength > 0;
if (justDrew && typeof window.Sounds !== 'undefined') window.Sounds.drawCard();
lastHandLength = hand.length;
const handEl = $('your-hand');
if (handEl) {
handEl.innerHTML = hand
.map((cid, i) => {
var meta = cardDb[cid];
var cost = meta?.cost ?? 0;
var playable = false;
if (meta?.type === 'minion') playable = you.mana >= cost && (you.board?.length ?? 0) < 7;
else if (meta?.type === 'spell') playable = you.mana >= cost && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
var drawAnim = justDrew && i === hand.length - 1;
return renderHandCard(cid, i, hand.length, playable, drawAnim);
})
.join('');
}
const deckCount = state.yourDeckCount ?? you.deck?.length ?? 0;
const canDraw = isYourTurn && !state.yourManualDrawUsed && deckCount > 0 && (you.hand?.length ?? 0) < 10;
renderDeckFan(deckCount, justDrew, canDraw);
bindGameEvents(state);
}
function renderDeckFan(deckCount, justDrew, canDraw) {
const fan = $('deck-fan');
const stack = $('deck-stack');
const badge = $('deck-count-badge');
if (!stack) return;
const n = Math.min(5, Math.max(0, deckCount));
let html = '';
for (let i = 0; i < 5; i++) {
html += i < n ? '<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) {
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' : '';
return '<div class="card-wrap in-hand ' + (playable ? 'playable' : '') + ' ' + (drawFromDeck ? 'draw-from-deck' : '') + fictionalClass + '" data-hand-index="' + handIndex + '" data-card-id="' + cardId + '" style="--hand-rotate: ' + rotate + 'deg" title="' + escapeHtml(name) + (meta.text ? ': ' + meta.text : '') + (meta.fictional ? ' [ВЫМЫШЛЕННЫЙ]' : '') + '">'
+ '<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) {
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 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');
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">' + m.attack + '</span></div>'
+ '<div class="card-hp-wrap"><span class="card-stat-label">Здоровье</span><span class="hp">' + m.health + '</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">❤</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">❤</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;
$all('.card-wrap.in-hand').forEach((wrap) => {
wrap.classList.toggle('disabled', !isYourTurn);
const handIndex = parseInt(wrap.dataset.handIndex, 10);
const meta = cardDb[wrap.dataset.cardId];
const isMinion = meta && meta.type === 'minion';
const canPlay = isYourTurn && !attackMode.active && !heroAbilityMode.active && meta && you.mana >= (meta.cost || 0);
if (isMinion && canPlay && (you.board?.length ?? 0) < 7) {
wrap.draggable = true;
wrap.ondragstart = function (e) {
if (!canPlay) return;
e.dataTransfer.setData('text/plain', 'hand:' + handIndex);
e.dataTransfer.effectAllowed = 'move';
wrap.classList.add('dragging');
};
wrap.ondragend = function () {
wrap.classList.remove('dragging');
$all('.drop-target').forEach(function (t) { t.classList.remove('drop-over'); });
};
} else {
wrap.draggable = false;
wrap.ondragstart = null;
wrap.ondragend = null;
}
wrap.onclick = function (e) {
if (e.target.closest('.card-btn-info')) return;
if (!isYourTurn || attackMode.active || heroAbilityMode.active) return;
if (!meta) return;
if (you.mana < (meta.cost || 0)) return;
if (meta.type === 'minion') {
if ((you.board?.length ?? 0) >= 7) return;
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
var r = wrap.getBoundingClientRect();
spawnEffect('play', r.left + r.width / 2, r.top + r.height / 2);
wrap.querySelector('.card')?.classList.add('play-anim');
socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length });
setTimeout(function () { wrap.querySelector('.card')?.classList.remove('play-anim'); }, 500);
return;
}
if (meta.type === 'spell') {
if (spellMode.active || heroAbilityMode.active || stealCardsMode.active) return;
var needTarget = meta.spellTarget && meta.spellTarget !== 'none';
// Специальная обработка для Грабежа
if (meta.spellEffect === 'steal_cards') {
if (meta.spellTarget === 'enemy_player') {
// Открываем модальное окно для выбора противника
spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget };
$('spell-mode')?.classList.remove('hidden');
renderGame(state);
return;
}
}
if (!needTarget) {
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
socket.emit('playSpell', { handIndex: handIndex });
return;
}
spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget };
const spellModeText = $('spell-mode')?.querySelector('p');
if (spellModeText && meta.spellEffect === 'steal_cards') {
spellModeText.textContent = 'Выберите противника для грабежа (кликните на блок противника)';
} else if (spellModeText) {
spellModeText.textContent = 'Выберите цель для заклинания';
}
$('spell-mode')?.classList.remove('hidden');
renderGame(state);
return;
}
};
// Touch support for mobile
if (isTouchDevice()) {
wrap.ontouchstart = function (e) {
if (e.target.closest('.card-btn-info')) {
// Handle info button separately
const btn = e.target.closest('.card-btn-info');
if (btn) {
const cid = btn.dataset.cardId;
if (cid) {
const meta = cardDb[cid];
if (meta) {
const nameEl = $('card-info-name');
const metaEl = $('card-info-meta');
const textEl = $('card-info-text');
const abilEl = $('card-info-abilities');
const bioEl = $('card-info-bio');
if (nameEl) nameEl.textContent = meta.name || cid;
if (metaEl) {
if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0);
else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0);
}
if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); }
if (abilEl) {
var parts = [];
if (meta.battlecry) parts.push('<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) return;
e.stopPropagation();
var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10);
var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10);
// Специальная обработка для Грабежа - открываем модальное окно
const spellCard = cardDb[spellMode.cardId];
if (spellCard && spellCard.spellEffect === 'steal_cards' && spellCard.spellTarget === 'enemy_player') {
// Выбираем противника (tp должен быть индексом противника, tb игнорируем для enemy_player)
if (tp !== state.yourIndex && tp >= 0) {
const targetPlayer = state.players[tp];
if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) {
stealCardsMode.active = true;
stealCardsMode.handIndex = spellMode.handIndex;
stealCardsMode.targetPlayerIndex = tp;
// Получаем актуальную колоду из gameState
stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : [];
stealCardsMode.selectedIndices = [];
showStealCardsModal(state, {
targetPlayerIndex: tp,
targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`,
targetDeckSize: targetPlayer.deck.length,
maxCards: Math.min(2, targetPlayer.deck.length)
});
spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
$('spell-mode')?.classList.add('hidden');
return;
}
}
return;
}
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: tb });
spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
$('spell-mode')?.classList.add('hidden');
renderGame(state);
};
// Touch support for spell targets
if (isTouchDevice()) {
el.ontouchstart = function (e) {
if (!spellMode.active) return;
e.preventDefault();
e.stopPropagation();
var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10);
var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10);
// Специальная обработка для Грабежа
const spellCard = cardDb[spellMode.cardId];
if (spellCard && spellCard.spellEffect === 'steal_cards' && spellCard.spellTarget === 'enemy_player') {
if (tp !== state.yourIndex && tp >= 0) {
const targetPlayer = state.players[tp];
if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) {
stealCardsMode.active = true;
stealCardsMode.handIndex = spellMode.handIndex;
stealCardsMode.targetPlayerIndex = tp;
// Получаем актуальную колоду из gameState
stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : [];
stealCardsMode.selectedIndices = [];
showStealCardsModal(state, {
targetPlayerIndex: tp,
targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`,
targetDeckSize: targetPlayer.deck.length,
maxCards: Math.min(2, targetPlayer.deck.length)
});
spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
$('spell-mode')?.classList.add('hidden');
return;
}
}
return;
}
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: tb });
spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
$('spell-mode')?.classList.add('hidden');
renderGame(state);
};
}
});
$all('.hero-ability-target, .drop-target-hero.hero-ability-target').forEach(function (el) {
el.onclick = function (e) {
if (!heroAbilityMode.active) return;
e.stopPropagation();
var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10);
var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10);
if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
socket.emit('heroAbility', { targetPlayerIndex: tp, targetBoardIndex: tb });
heroAbilityMode = { active: false };
$('hero-ability-mode')?.classList.add('hidden');
renderGame(state);
};
// Touch support for hero ability targets
if (isTouchDevice()) {
el.ontouchstart = function (e) {
if (!heroAbilityMode.active) return;
e.preventDefault();
e.stopPropagation();
var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10);
var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10);
if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
socket.emit('heroAbility', { targetPlayerIndex: tp, targetBoardIndex: tb });
heroAbilityMode = { active: false };
$('hero-ability-mode')?.classList.add('hidden');
renderGame(state);
};
}
});
// Обработка выбора противника для Грабежа
$all('.opponent-block.steal-target').forEach(function (el) {
el.onclick = function (e) {
if (!spellMode.active) return;
const spellCard = cardDb[spellMode.cardId];
if (!spellCard || spellCard.spellEffect !== 'steal_cards' || spellCard.spellTarget !== 'enemy_player') return;
e.stopPropagation();
var tp = parseInt(el.dataset.playerIndex ?? el.dataset.opponentIndex, 10);
if (tp === state.yourIndex || tp < 0) return;
const targetPlayer = state.players[tp];
if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) {
stealCardsMode.active = true;
stealCardsMode.handIndex = spellMode.handIndex;
stealCardsMode.targetPlayerIndex = tp;
// Получаем актуальную колоду из gameState
stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : [];
stealCardsMode.selectedIndices = [];
showStealCardsModal(state, {
targetPlayerIndex: tp,
targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`,
targetDeckSize: targetPlayer.deck.length,
maxCards: Math.min(2, targetPlayer.deck.length)
});
spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
$('spell-mode')?.classList.add('hidden');
}
};
// Touch support
if (isTouchDevice()) {
el.ontouchstart = function (e) {
if (!spellMode.active) return;
const spellCard = cardDb[spellMode.cardId];
if (!spellCard || spellCard.spellEffect !== 'steal_cards' || spellCard.spellTarget !== 'enemy_player') return;
e.preventDefault();
e.stopPropagation();
var tp = parseInt(el.dataset.playerIndex ?? el.dataset.opponentIndex, 10);
if (tp === state.yourIndex || tp < 0) return;
const targetPlayer = state.players[tp];
if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) {
stealCardsMode.active = true;
stealCardsMode.handIndex = spellMode.handIndex;
stealCardsMode.targetPlayerIndex = tp;
// Получаем актуальную колоду из gameState
stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : [];
stealCardsMode.selectedIndices = [];
showStealCardsModal(state, {
targetPlayerIndex: tp,
targetPlayerName: targetPlayer.name || `Игрок ${tp + 1}`,
targetDeckSize: targetPlayer.deck.length,
maxCards: Math.min(2, targetPlayer.deck.length)
});
spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
$('spell-mode')?.classList.add('hidden');
}
};
}
});
var heroAbilityBtn = $('btn-hero-ability');
if (heroAbilityBtn) heroAbilityBtn.onclick = function () {
if (heroAbilityBtn.disabled || heroAbilityMode.active) return;
heroAbilityMode = { active: true };
$('hero-ability-mode')?.classList.remove('hidden');
renderGame(state);
};
var forgeBtn = $('btn-forge');
if (forgeBtn && isYourTurn && you.mana >= 2) {
forgeBtn.disabled = false;
forgeBtn.onclick = function () {
if (attackMode.active || spellMode.active || heroAbilityMode.active) return;
$('forge-overlay')?.classList.remove('hidden');
renderForgeDeck(state);
};
} 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;
}
}
function initLobby() {
const hostTab = document.querySelector('.tab[data-tab="host"]');
const joinTab = document.querySelector('.tab[data-tab="join"]');
hostTab?.addEventListener('click', () => {
$all('.tab').forEach((t) => t.classList.remove('active'));
hostTab.classList.add('active');
$('host-panel')?.classList.add('active');
$('join-panel')?.classList.remove('active');
clearError();
});
joinTab?.addEventListener('click', () => {
$all('.tab').forEach((t) => t.classList.remove('active'));
joinTab.classList.add('active');
$('join-panel')?.classList.add('active');
$('host-panel')?.classList.remove('active');
clearError();
});
$('btn-host')?.addEventListener('click', () => {
clearError();
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', () => {
socket.emit('createRoom', name);
});
socket.once('connect_error', (e) => {
showError('Не удалось подключиться к серверу. Проверьте, что сервер запущен.');
});
} else {
// Уже подключен, сразу отправляем
socket.emit('createRoom', name);
}
});
$('btn-join')?.addEventListener('click', () => {
clearError();
const addr = ($('join-addr')?.value || '').trim();
let url;
if (addr && addr !== 'Оставьте пустым для авто') {
const parts = addr.split(':');
const host = parts[0] || 'localhost';
const port = parts[1] || DEFAULT_PORT;
url = `http://${host}:${port}`;
} else {
url = window.location.origin;
}
connect(url);
socket.once('connect', () => {
setLobbyPanel('connect');
$('connect-panel')?.classList.add('active');
});
socket.once('connect_error', () => {
const detailsEl = document.querySelector('#join-panel details');
if (detailsEl && !detailsEl.open) {
detailsEl.open = true;
showError('Не удалось подключиться автоматически. Проверьте расширенные настройки и введите IP сервера вручную.');
}
});
});
var joinCodeHandler = function () {
clearError();
const code = ($('join-code')?.value || '').trim().toUpperCase();
if (!code || code.length !== 5) {
showError('Введите код комнаты (5 символов)');
return;
}
const name = ($('join-name')?.value || '').trim() || 'Игрок 2';
if (!socket || !socket.connected) {
const addr = ($('join-addr')?.value || '').trim();
let url;
if (addr && addr !== 'Оставьте пустым для авто') {
const parts = addr.split(':');
const host = parts[0] || 'localhost';
const port = parts[1] || DEFAULT_PORT;
url = `http://${host}:${port}`;
} else {
url = window.location.origin;
}
connect(url);
socket.once('connect', () => {
socket.emit('joinRoom', { code, name });
});
} else {
socket.emit('joinRoom', { code, name });
}
};
$('btn-join-code')?.addEventListener('click', joinCodeHandler);
$('join-code')?.addEventListener('keypress', function (e) {
if (e.key === 'Enter') joinCodeHandler();
});
$('btn-start')?.addEventListener('click', () => {
clearError();
var faction = $('room-faction')?.value || '';
socket?.emit('startGame', { faction: faction || undefined });
});
$('btn-leave')?.addEventListener('click', () => {
socket?.emit('leaveLobby');
setLobbyPanel('host');
$('host-panel')?.classList.add('active');
showScreen('lobby');
});
$('btn-leave-join')?.addEventListener('click', () => {
socket?.emit('leaveLobby');
socket?.disconnect();
setLobbyPanel('join');
$('join-panel')?.classList.add('active');
showScreen('lobby');
});
$('btn-modal-close')?.addEventListener('click', () => {
$('modal-overlay')?.classList.add('hidden');
});
$('btn-instructions')?.addEventListener('click', () => {
$('instructions-overlay')?.classList.remove('hidden');
});
$('btn-instructions-lobby')?.addEventListener('click', () => {
$('instructions-overlay')?.classList.remove('hidden');
});
$('btn-instructions-close')?.addEventListener('click', () => {
$('instructions-overlay')?.classList.add('hidden');
});
$('btn-cards-gallery')?.addEventListener('click', () => {
$('cards-gallery-overlay')?.classList.remove('hidden');
renderCardsGallery();
});
$('btn-cards-gallery-game')?.addEventListener('click', () => {
$('cards-gallery-overlay')?.classList.remove('hidden');
renderCardsGallery();
});
$('btn-gallery-close')?.addEventListener('click', () => {
$('cards-gallery-overlay')?.classList.add('hidden');
});
$('cards-gallery-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'cards-gallery-overlay') {
$('cards-gallery-overlay')?.classList.add('hidden');
}
});
$('gallery-faction-filter')?.addEventListener('change', renderCardsGallery);
$('gallery-type-filter')?.addEventListener('change', renderCardsGallery);
$('gallery-search')?.addEventListener('input', renderCardsGallery);
let galleryCardDb = null;
function renderCardsGallery() {
const grid = $('cards-gallery-grid');
if (!grid) return;
if (!galleryCardDb) {
fetch('/api/cards')
.then(res => res.json())
.then(data => {
galleryCardDb = data;
renderCardsGallery();
})
.catch(() => {
grid.innerHTML = '<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', () => {
$('forge-overlay')?.classList.add('hidden');
});
$('forge-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'forge-overlay') {
$('forge-overlay')?.classList.add('hidden');
}
});
$('btn-steal-close')?.addEventListener('click', () => {
$('steal-cards-overlay')?.classList.add('hidden');
stealCardsMode.active = false;
stealCardsMode.handIndex = -1;
stealCardsMode.targetPlayerIndex = null;
stealCardsMode.targetDeck = [];
stealCardsMode.selectedIndices = [];
});
$('steal-cards-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'steal-cards-overlay') {
$('steal-cards-overlay')?.classList.add('hidden');
stealCardsMode.active = false;
stealCardsMode.handIndex = -1;
stealCardsMode.targetPlayerIndex = null;
stealCardsMode.targetDeck = [];
stealCardsMode.selectedIndices = [];
}
});
function showStealCardsModal(state, data) {
const deckList = $('steal-deck-list');
const selectedEl = $('steal-selected');
const confirmBtn = $('btn-steal-confirm');
const hintEl = $('steal-cards-hint');
const targetSelect = $('steal-target-select');
if (!deckList || !selectedEl || !confirmBtn) return;
// Если противник уже выбран, показываем его колоду
if (stealCardsMode.targetPlayerIndex !== null) {
// Обновляем колоду из актуального gameState
const targetPlayer = state.players[stealCardsMode.targetPlayerIndex];
if (targetPlayer && targetPlayer.deck) {
stealCardsMode.targetDeck = [...targetPlayer.deck];
}
if (stealCardsMode.targetDeck.length === 0) {
// Колода пуста, закрываем модальное окно
$('steal-cards-overlay')?.classList.add('hidden');
stealCardsMode.active = false;
return;
}
targetSelect.classList.add('hidden');
deckList.classList.remove('hidden');
if (hintEl) {
hintEl.textContent = `Выберите до 2 карт из колоды ${targetPlayer?.name || `Игрока ${stealCardsMode.targetPlayerIndex + 1}`} (${stealCardsMode.targetDeck.length} карт)`;
}
deckList.innerHTML = stealCardsMode.targetDeck.map((cardId, idx) => {
const meta = cardDb[cardId];
if (!meta) return '';
const isSelected = stealCardsMode.selectedIndices.includes(idx);
const cost = meta.cost || 0;
const attack = meta.attack !== undefined ? meta.attack : '';
const health = meta.health !== undefined ? meta.health : '';
const stats = meta.type === 'minion' ? `<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 = getCardArt ? getCardArt(meta) : '✦';
return `<div class="card-wrap steal-deck-card ${isSelected ? 'selected' : ''}" data-card-id="${cardId}" data-deck-index="${idx}" style="width: 100px; height: 140px; cursor: pointer;">
<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)}</div>
${stats}
${costDisplay}
</div>
</div>
</div>`;
}).filter(Boolean).join('');
$all('.steal-deck-card').forEach(card => {
card.onclick = function() {
const idx = parseInt(card.dataset.deckIndex, 10);
const selectedIdx = stealCardsMode.selectedIndices.indexOf(idx);
if (selectedIdx >= 0) {
stealCardsMode.selectedIndices.splice(selectedIdx, 1);
} else if (stealCardsMode.selectedIndices.length < 2) {
stealCardsMode.selectedIndices.push(idx);
}
showStealCardsModal(state, data);
};
});
selectedEl.innerHTML = stealCardsMode.selectedIndices.length ? stealCardsMode.selectedIndices.map(idx => {
const cardId = stealCardsMode.targetDeck[idx];
const meta = cardDb[cardId];
if (!meta) return '';
return `<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 = state.players.filter((p, i) => i !== state.yourIndex && p.health > 0 && p.deck && p.deck.length > 0);
targetSelect.innerHTML = enemies.map((enemy, idx) => {
const enemyIdx = state.players.indexOf(enemy);
return `<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 => {
option.onclick = function() {
const playerIdx = parseInt(option.dataset.playerIndex, 10);
const targetPlayer = state.players[playerIdx];
if (targetPlayer && targetPlayer.deck && targetPlayer.deck.length > 0) {
stealCardsMode.targetPlayerIndex = playerIdx;
// Получаем актуальную колоду из gameState
stealCardsMode.targetDeck = targetPlayer.deck ? [...targetPlayer.deck] : [];
showStealCardsModal(state, {
targetPlayerIndex: playerIdx,
targetPlayerName: targetPlayer.name || `Игрок ${playerIdx + 1}`,
targetDeckSize: targetPlayer.deck.length,
maxCards: Math.min(2, targetPlayer.deck.length)
});
}
};
});
}
$('steal-cards-overlay')?.classList.remove('hidden');
}
let forgeSelected = [];
function renderForgeDeck(state) {
const you = state.players[state.yourIndex];
const deckList = $('forge-deck-list');
const selectedEl = $('forge-selected');
const craftBtn = $('btn-forge-craft');
if (!deckList || !selectedEl) return;
deckList.innerHTML = you.deck.map((cardId, idx) => {
const meta = cardDb[cardId];
if (!meta || meta.type !== 'minion') return '';
const isSelected = forgeSelected.includes(cardId);
return `<div class="card-wrap forge-deck-card ${isSelected ? 'selected' : ''}" data-card-id="${cardId}" data-deck-index="${idx}" 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-deck-card').forEach(card => {
card.onclick = function() {
const cardId = card.dataset.cardId;
const idx = forgeSelected.indexOf(cardId);
if (idx >= 0) {
forgeSelected.splice(idx, 1);
} else if (forgeSelected.length < 3) {
forgeSelected.push(cardId);
}
renderForgeDeck(state);
};
});
selectedEl.innerHTML = forgeSelected.length ? forgeSelected.map(cardId => {
const meta = cardDb[cardId];
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="forge-selected-empty">Выберите 2-3 карты</div>';
if (craftBtn) {
craftBtn.disabled = forgeSelected.length < 2 || forgeSelected.length > 3 || (you.mana || 0) < 2;
craftBtn.onclick = function() {
if (craftBtn.disabled) return;
socket.emit('forgeCard', { cardIds: forgeSelected });
forgeSelected = [];
$('forge-overlay')?.classList.add('hidden');
};
}
}
$('music-toggle')?.addEventListener('click', () => {
if (window.Music) {
window.Music.toggle();
}
});
$('music-volume-slider')?.addEventListener('input', (e) => {
const volume = parseFloat(e.target.value) / 100;
if (window.Music) {
window.Music.setVolume(volume);
}
});
}
function init() {
initLobby();
showScreen('lobby');
socket = null;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
init();
if (window.Music) {
window.Music.init();
}
});
} else {
init();
if (window.Music) {
window.Music.init();
}
}
})();