/**
* 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 };
const seenMinions = new Set();
let lastHandLength = 0;
let prevGameState = null;
let combatTimeout = null;
const DEFAULT_PORT = 3542;
// Touch support variables
let touchState = {
startX: 0,
startY: 0,
startTime: 0,
target: null,
isSwipe: false,
minSwipeDistance: 50,
maxSwipeTime: 500,
longPressTimer: null,
longPressDelay: 500
};
const $ = (id) => document.getElementById(id);
const $all = (sel) => document.querySelectorAll(sel);
// Helper to detect if device is touch-enabled
const isTouchDevice = () => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
};
// Helper to get touch coordinates
const getTouchCoords = (e) => {
if (e.touches && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
return { x: e.clientX, y: e.clientY };
};
// Helper to find element at coordinates
const elementFromPoint = (x, y) => {
const el = document.elementFromPoint(x, y);
if (!el) return null;
return el.closest('.card-wrap, .drop-target, .hero-target, .spell-target, .hero-ability-target');
};
function spawnEffect(type, x, y) {
const layer = $('effects-layer');
if (!layer) return;
const el = document.createElement('div');
el.className = 'effect effect-' + type;
el.style.left = (typeof x === 'number' ? x : 0) + 'px';
el.style.top = (typeof y === 'number' ? y : 0) + 'px';
layer.appendChild(el);
setTimeout(function () { el.remove(); }, 800);
}
function showScreen(id) {
$all('.screen').forEach((s) => s.classList.add('hidden'));
const el = $(id);
if (el) el.classList.remove('hidden');
}
function showError(msg) {
const el = $('lobby-error');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
}
function clearError() {
const el = $('lobby-error');
if (el) el.classList.add('hidden');
}
function setLobbyPanel(name) {
$all('#lobby .panel').forEach((p) => {
p.classList.remove('active', 'hidden');
if (p.id !== name + '-panel') p.classList.add('hidden');
else p.classList.add('active');
});
}
function renderPlayerList(listId, players) {
const ul = $(listId);
if (!ul) return;
ul.innerHTML = (players || []).map((p) => `
${escapeHtml(p.name)}`).join('');
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function connect(url) {
if (socket) {
socket.disconnect();
socket.removeAllListeners();
}
socket = io(url, { transports: ['websocket', 'polling'], reconnection: false });
socket.on('connect', () => {
clearError();
});
socket.on('connect_error', (e) => {
const errorMsg = e.message || 'Не удалось подключиться к серверу';
showError(errorMsg + '. Проверьте, что сервер запущен и используйте расширенные настройки для указания IP вручную.');
});
socket.on('error', (msg) => showError(msg));
socket.on('roomCreated', (data) => {
const codeEl = $('room-code-display');
if (codeEl) {
codeEl.textContent = data.code;
codeEl.onclick = function () {
navigator.clipboard.writeText(data.code).then(() => {
const old = codeEl.textContent;
codeEl.textContent = 'Скопировано!';
setTimeout(() => { codeEl.textContent = old; }, 1500);
}).catch(() => {});
};
}
const connectInfoEl = $('room-connect-info');
if (connectInfoEl && data.serverIP && data.serverPort) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const serverAddr = isLocal ? 'localhost:' + data.serverPort : data.serverIP + ':' + data.serverPort;
const fullInfo = serverAddr + ' → код: ' + data.code;
connectInfoEl.textContent = fullInfo;
connectInfoEl.onclick = function () {
navigator.clipboard.writeText(fullInfo).then(() => {
const old = connectInfoEl.textContent;
connectInfoEl.textContent = 'Скопировано!';
setTimeout(() => { connectInfoEl.textContent = old; }, 1500);
}).catch(() => {});
};
}
setLobbyPanel('room');
renderPlayerList('player-list', data.lobby);
const portEl = $('display-port');
if (portEl) portEl.textContent = data.serverPort || window.location.port || DEFAULT_PORT;
});
socket.on('roomJoined', (data) => {
setLobbyPanel('connect');
renderPlayerList('connect-player-list', data.lobby);
const statusEl = $('connect-status');
if (statusEl) statusEl.textContent = 'Подключено к комнате. Ожидание начала игры...';
});
socket.on('lobbyUpdate', (lobby) => {
renderPlayerList('player-list', lobby);
renderPlayerList('connect-player-list', lobby);
});
socket.on('backToLobby', () => {
showScreen('lobby');
clearError();
setLobbyPanel('host');
const hp = $('host-panel');
if (hp) hp.classList.add('active');
});
socket.on('turnTime', function (data) {
var el = $('turn-timer');
if (!el) return;
var m = Math.floor(data.left / 60);
var s = data.left % 60;
el.textContent = m + ':' + (s < 10 ? '0' : '') + s;
el.classList.toggle('timer-warn', data.left > 0 && data.left <= 15);
el.classList.toggle('timer-ended', data.ended);
if (data.warn && typeof window.Sounds !== 'undefined') window.Sounds.timerWarning();
if (data.ended && typeof window.Sounds !== 'undefined') window.Sounds.timerEnd();
});
socket.on('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);
});
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);
return `
${escapeHtml(name)}
❤ ${p.health}
🔵 ${p.mana}/${p.maxMana}
${heroDrop}${heroBar}${minions.join('')}
`;
})
.filter(Boolean)
.join('');
}
if (yourBoard) {
yourBoard.innerHTML = (you.board || []).map((m, j) => {
const isNew = !seenMinions.has(m.id);
if (isNew) seenMinions.add(m.id);
return renderBoardMinion(m, yourIndex, j, state, false, isNew);
}).join('');
}
}
function showDamageEffect(element, damage, isHero) {
if (!element) return;
const damageEl = document.createElement('div');
damageEl.className = 'damage-indicator';
damageEl.textContent = '-' + damage;
damageEl.style.position = 'absolute';
const rect = element.getBoundingClientRect();
damageEl.style.left = (rect.left + rect.width / 2) + 'px';
damageEl.style.top = (rect.top + rect.height / 2) + 'px';
damageEl.style.pointerEvents = 'none';
damageEl.style.zIndex = '1000';
document.body.appendChild(damageEl);
setTimeout(() => {
damageEl.style.animation = 'damageFloat 1.5s ease-out forwards';
setTimeout(() => damageEl.remove(), 1000); // Сократил с 1500 до 1000 мс
}, 10);
}
function showAttackAnnouncement(state, logEntry) {
const announcementEl = $('attack-announcement');
if (!announcementEl || !logEntry) return;
// Обработка специальных событий
if (logEntry.type === 'skipTurn') {
const playerName = state.players[logEntry.playerIndex]?.name || `Игрок ${logEntry.playerIndex + 1}`;
announcementEl.textContent = `⏭ ${playerName} пропускает ход`;
announcementEl.classList.remove('hidden');
setTimeout(() => {
announcementEl.classList.add('hidden');
}, 2000);
return;
}
if (logEntry.type === 'playerDefeated') {
const playerName = state.players[logEntry.playerIndex]?.name || `Игрок ${logEntry.playerIndex + 1}`;
announcementEl.textContent = `💀 ${playerName} выбыл из игры`;
announcementEl.classList.remove('hidden');
setTimeout(() => {
announcementEl.classList.add('hidden');
}, 2500);
return;
}
const who = (i) => state.players[i]?.name || ('Игрок ' + (i + 1));
let text = '';
if (logEntry.type === 'attack' && logEntry.attackerMinionId && logEntry.targetMinionId) {
const attackerPlayer = state.players.find(p => p.board?.some(m => m.id === logEntry.attackerMinionId));
const targetPlayer = state.players.find(p => p.board?.some(m => m.id === logEntry.targetMinionId));
if (attackerPlayer && targetPlayer) {
const attackerIdx = state.players.indexOf(attackerPlayer);
const targetIdx = state.players.indexOf(targetPlayer);
const attackerMinion = attackerPlayer.board.find(m => m.id === logEntry.attackerMinionId);
const targetMinion = targetPlayer.board.find(m => m.id === logEntry.targetMinionId);
const attackerCard = cardDb[attackerMinion?.cardId];
const targetCard = cardDb[targetMinion?.cardId];
const attackerName = attackerCard?.name || 'Миньон';
const targetName = targetCard?.name || 'Миньон';
const damage = logEntry.damage || attackerMinion?.attack || 0;
text = who(attackerIdx) + ' → ' + attackerName + ' ⚔ атакует ' + targetName + ' (' + damage + ' урона) → ' + who(targetIdx);
}
} else if (logEntry.type === 'attackHero' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
const attackerPlayer = state.players[logEntry.fromPlayer];
const targetPlayer = state.players[logEntry.toPlayer];
if (attackerPlayer && targetPlayer) {
if (logEntry.attackerMinionId) {
const attackerMinion = attackerPlayer.board?.find(m => m.id === logEntry.attackerMinionId);
const attackerCard = cardDb[attackerMinion?.cardId];
const attackerName = attackerCard?.name || 'Миньон';
const damage = logEntry.damage || attackerMinion?.attack || 0;
text = who(logEntry.fromPlayer) + ' → ' + attackerName + ' ⚔ атакует героя ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!';
} else {
const damage = logEntry.damage || 0;
text = who(logEntry.fromPlayer) + ' ⚔ атакует героя ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!';
}
}
} else if (logEntry.type === 'spell' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
const spellCard = cardDb[logEntry.spell];
const spellName = spellCard?.name || 'Заклинание';
if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) {
const targetPlayer = state.players[logEntry.toPlayer];
const targetMinion = targetPlayer?.board?.[logEntry.toIdx];
const targetCard = cardDb[targetMinion?.cardId];
const targetName = targetCard?.name || 'Миньон';
const damage = logEntry.damage || '';
const damageText = damage ? ' (' + damage + ' урона)' : '';
text = who(logEntry.fromPlayer) + ' → ' + spellName + ' ✨ по ' + targetName + damageText + ' → ' + who(logEntry.toPlayer);
} else {
const damage = logEntry.damage || '';
const damageText = damage ? ' (' + damage + ' урона)' : '';
text = who(logEntry.fromPlayer) + ' → ' + spellName + ' ✨ по герою ' + who(logEntry.toPlayer) + damageText + '!';
}
} else if (logEntry.type === 'heroAbility' && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) {
const targetPlayer = state.players[logEntry.toPlayer];
const targetMinion = targetPlayer?.board?.[logEntry.toIdx];
const targetCard = cardDb[targetMinion?.cardId];
const targetName = targetCard?.name || 'Миньон';
const damage = logEntry.damage || 1;
text = who(logEntry.fromPlayer) + ' → Геройская способность ⚡ по ' + targetName + ' (' + damage + ' урона) → ' + who(logEntry.toPlayer);
} else {
const damage = logEntry.damage || 1;
text = who(logEntry.fromPlayer) + ' → Геройская способность ⚡ по герою ' + who(logEntry.toPlayer) + ' (' + damage + ' урона)!';
}
} else if (logEntry.type === 'battlecry' && logEntry.damage && logEntry.fromPlayer !== undefined && logEntry.toPlayer !== undefined) {
const attackerPlayer = state.players[logEntry.fromPlayer];
const targetPlayer = state.players[logEntry.toPlayer];
if (attackerPlayer && targetPlayer) {
if (logEntry.toIdx !== undefined && logEntry.toIdx >= 0) {
const targetMinion = targetPlayer?.board?.[logEntry.toIdx];
const targetCard = cardDb[targetMinion?.cardId];
const targetName = targetCard?.name || 'Миньон';
text = who(logEntry.fromPlayer) + ' → Battlecry 💥 по ' + targetName + ' (' + logEntry.damage + ' урона) → ' + who(logEntry.toPlayer);
} else {
text = who(logEntry.fromPlayer) + ' → Battlecry 💥 по герою ' + who(logEntry.toPlayer) + ' (' + logEntry.damage + ' урона)!';
}
}
}
if (text) {
announcementEl.textContent = text;
announcementEl.classList.remove('hidden');
setTimeout(() => {
announcementEl.classList.add('hidden');
}, 2000); // Сократил с 3500 до 2000 мс
}
}
function runCombatAnimation(state) {
const lastLog = state.log && state.log.length ? state.log[state.log.length - 1] : null;
if (!lastLog) return false;
if (lastLog.type === 'attack' && lastLog.attackerMinionId && lastLog.targetMinionId) {
const attackerWrap = document.querySelector(`[data-minion-id="${lastLog.attackerMinionId}"]`);
const targetWrap = document.querySelector(`[data-minion-id="${lastLog.targetMinionId}"]`);
if (!attackerWrap || !targetWrap) return false;
attackerWrap.classList.add('combat-lunge');
targetWrap.classList.add('combat-hit');
if (lastLog.damage) showDamageEffect(targetWrap, lastLog.damage, false);
if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
if (lastLog.attackerDied) {
attackerWrap.classList.add('combat-death');
showDamageEffect(attackerWrap, '💀', false);
}
if (lastLog.targetDied) {
targetWrap.classList.add('combat-death');
showDamageEffect(targetWrap, '💀', false);
}
combatTimeout = setTimeout(function () {
combatTimeout = null;
renderBoards(state);
bindGameEvents(state);
}, 600);
return true;
} else if (lastLog.type === 'attackHero' && lastLog.toPlayer !== undefined) {
const targetPlayer = state.players[lastLog.toPlayer];
if (targetPlayer) {
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');
if (lastLog.damage) showDamageEffect(heroEl, lastLog.damage, true);
setTimeout(() => heroEl.classList.remove('hero-damage-flash'), 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 '' + escapeHtml(s) + '
'; }).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 ? '' : '';
}
stack.innerHTML = html;
if (badge) badge.textContent = String(deckCount);
if (fan) {
fan.classList.toggle('deck-just-drew', !!justDrew);
fan.classList.toggle('deck-drawable', !!canDraw);
fan.title = canDraw ? 'Клик — вытянуть карту' : 'Колода';
if (justDrew) setTimeout(function () { fan.classList.remove('deck-just-drew'); }, 400);
}
}
function abilityTags(meta) {
var tags = [];
if (meta.battlecry) tags.push('Вой:' + escapeHtml(meta.battlecry));
if (meta.deathrattle) tags.push('Предсм.:' + escapeHtml(meta.deathrattle));
if (!tags.length) return '';
return '' + tags.join(' ') + '
';
}
function renderHandCard(cardId, handIndex, handSize, playable, drawFromDeck) {
var meta = cardDb[cardId];
if (!meta) return '';
var name = meta.name || cardId;
var cost = meta.cost ?? 0;
var atk = meta.attack ?? 0;
var hp = meta.health ?? 0;
var art = getCardArt(meta);
var faction = meta.faction || 'neutral';
var rotate = handSize <= 1 ? 0 : -10 + (handIndex / Math.max(1, handSize - 1)) * 20;
var isMinion = meta.type === 'minion';
var statsHtml = isMinion
? 'Мана' + cost + '
'
+ 'Атака' + atk + '
'
+ 'Здоровье' + hp + '
'
: 'Мана' + cost + '
';
var textHtml = meta.text ? '' + escapeHtml(meta.text) + '
' : '';
var abilHtml = abilityTags(meta);
var fictionalClass = meta.fictional ? ' fictional-card' : '';
return ''
+ '
'
+ '
' + art + '
'
+ '
'
+ '
' + escapeHtml(name) + '
'
+ textHtml + abilHtml
+ '
' + statsHtml + '
'
+ '
'
+ '
';
}
var swgMap = {
r2d2: 'r2d2', c3po: 'c3po', yoda: 'yoda', boba: 'bobbafett', vader: 'darthvader',
stormtrooper: 'stormtrooper', deathstar: 'deathstar', atat: 'atat', xwing: 'xwing',
tie: 'tie', falcon: 'falcon', leia: 'leia', chewie: 'wookie', ackbar: 'akbar',
kylo: 'kylo', bb8: 'bb8', phasma: 'phasma', trooper: 'stormtrooper',
jawas: 'galrep', sandcrawler: 'galrep', tusken: 'galrep', sarlacc: 'galrep',
bantha: 'galrep', rancor: 'galrep', tauntaun: 'galrep', temple: 'jediorder',
cantina: 'galrep', void: 'galrep', storm: 'jediorder', malice: 'darthvader',
zephyr: 'galrep', walker: 'galrep', crystal: 'jediorder', shadow: 'darthvader',
nebula: 'galrep', elite: 'galrep',
anakin: 'saberjedi', kanan: 'saberjedi', ezra: 'saberjedi', cal_kestis: 'saberjedi',
plo_koon: 'saberjedi', ki_adi: 'saberjedi', aayla: 'saberjedi', shaak_ti: 'saberjedi',
savage: 'sabersith', jar_jar: 'galrep', tarkin: 'galemp', starkiller: 'sabersith',
moff_gideon: 'galemp', rebel_trooper: 'galrep', rebel_commando: 'galrep', rebel_scout: 'galrep',
clone_trooper: 'stormtrooper', clone_commando: 'stormtrooper', arc_trooper: 'stormtrooper',
battle_droid: 'combatdrone', super_battle_droid: 'combatdrone', droideka: 'combatdrone',
cody: 'stormtrooper', rex: 'stormtrooper', fives: 'stormtrooper', echo: 'stormtrooper',
father: 'jediorder', son: 'sith', daughter: 'jediorder',
korriban: 'galemp', tatooine: 'galrep', coruscant: 'galrep', zakkuul: 'galemp',
kamino: 'galrep', mustafar: 'galemp', naboo: 'galrep', endor: 'galrep', hoth: 'galrep', dagobah: 'jediorder',
star_destroyer: 'stardestroyer', speeder: 'landspeeder', wedge: 'xwing',
nebulon: 'xwing', droideka: 'combatdrone', jango: 'bobbafett',
luke: 'saberjedi', obiwan: 'saberjedi', quigon: 'saberjedi', mace: 'saberjedi',
dooku: 'sabersith', maul: 'sabersith', snoke: 'sith', palpatine: 'sith',
han: 'falcon', rey: 'newrep', finn: 'newrep', padme: 'galrep', jabba: 'carbonite',
lando: 'cantina', ewok: 'jediorder', wicket: 'jediorder', scout: 'stormtrooper',
guard: 'stormtrooper', probe: 'r2d2',
ahsoka: 'saberjedi', ventress: 'sabersith', rex: 'stormtrooper', thrawn: 'galemp',
mando: 'mandalorian', grogu: 'yoda', jyn: 'newrep', cassian: 'galrep',
k2so: 'r2d2', chirrut: 'jediorder', baze: 'stormtrooper', poe: 'xwing',
hux: 'stormtrooper', rose: 'galrep',
lightsaber: 'saberjedi', force: 'saberjedi', blaster: 'tie', hyperspace: 'xwing', fear: 'sith', hope: 'galrep',
};
function getCardArt(meta) {
var swg = swgMap[meta.art];
if (swg) return '';
return '✦';
}
function renderBoardMinion(m, playerIndex, boardIndex, state, isOpponent, summonAnim) {
const meta = cardDb[m.cardId];
const name = meta?.name || m.cardId;
const art = meta ? getCardArt(meta) : '✦';
const faction = meta?.faction || 'neutral';
const isYourTurn = state.currentPlayerIndex === state.yourIndex;
const canAttackTwice = meta && meta.canAttackTwice;
const attacksUsed = m.attacksUsed || 0;
const canAttack = !isOpponent && m.canAttack && isYourTurn && (!canAttackTwice || attacksUsed < 2);
const attackable = attackMode.active && attackMode.attackerPlayer === state.yourIndex && attackMode.attackerBoard === boardIndex;
const targetable = attackMode.active && attackMode.attackerPlayer === state.yourIndex && playerIndex !== state.yourIndex;
const 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 ? '' + escapeHtml(meta.text) + '
' : '';
var frozenIcon = m.frozen ? '❄
' : '';
return ''
+ '
'
+ frozenIcon
+ '
' + art + '
'
+ '
'
+ '
' + escapeHtml(name) + '
'
+ textHtml + abilHtml
+ '
'
+ '
Атака' + m.attack + '
'
+ '
Здоровье' + m.health + '
'
+ '
'
+ '
'
+ '
';
}
function renderHeroDropZone(playerIndex, state) {
const isYourTurn = state.currentPlayerIndex === state.yourIndex;
if (!isYourTurn || playerIndex === state.yourIndex) return '';
if (attackMode.active && !spellMode.active && !heroAbilityMode.active) return '';
var spellHero = spellMode.active && spellMode.spellTarget === 'any';
var heroAb = heroAbilityMode.active;
const p = state.players[playerIndex];
const name = p?.name || 'Игрок ' + (playerIndex + 1);
var heroCls = 'drop-target drop-target-hero' + (spellHero ? ' spell-target' : '') + (heroAb ? ' hero-ability-target' : '');
var title = spellHero ? 'Цель заклинания' : (heroAb ? 'Цель геройской способности' : 'Перетащи сюда миньона для атаки по лидеру');
return ''
+ '❤'
+ '' + escapeHtml(name) + ''
+ '
';
}
function renderHeroTarget(playerIndex, state) {
if (!attackMode.active || attackMode.attackerPlayer !== state.yourIndex || playerIndex === state.yourIndex) return '';
const p = state.players[playerIndex];
const name = p?.name || 'Игрок ' + (playerIndex + 1);
return `
❤
${escapeHtml(name)} (лидер)
${p?.health ?? 30}
`;
}
function bindGameEvents(state) {
const you = state.players[state.yourIndex];
const isYourTurn = state.currentPlayerIndex === state.yourIndex;
$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) return;
var needTarget = meta.spellTarget && meta.spellTarget !== 'none';
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 };
$('spell-mode')?.classList.remove('hidden');
renderGame(state);
return;
}
};
// Touch support for mobile
if (isTouchDevice()) {
wrap.ontouchstart = function (e) {
if (e.target.closest('.card-btn-info')) {
// Handle info button separately
const btn = e.target.closest('.card-btn-info');
if (btn) {
const cid = btn.dataset.cardId;
if (cid) {
const meta = cardDb[cid];
if (meta) {
const nameEl = $('card-info-name');
const metaEl = $('card-info-meta');
const textEl = $('card-info-text');
const abilEl = $('card-info-abilities');
const bioEl = $('card-info-bio');
if (nameEl) nameEl.textContent = meta.name || cid;
if (metaEl) {
if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0);
else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0);
}
if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); }
if (abilEl) {
var parts = [];
if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('
') : '';
abilEl.classList.toggle('hidden', !parts.length);
}
if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); }
$('card-info-overlay')?.classList.remove('hidden');
}
}
}
return;
}
if (!isYourTurn || attackMode.active || heroAbilityMode.active) return;
if (!meta) return;
const touch = e.touches[0];
touchState.startX = touch.clientX;
touchState.startY = touch.clientY;
touchState.startTime = Date.now();
touchState.target = wrap;
touchState.isSwipe = false;
wrap.classList.add('touch-active');
// Long press for card info
touchState.longPressTimer = setTimeout(function() {
if (touchState.target === wrap && meta) {
const nameEl = $('card-info-name');
const metaEl = $('card-info-meta');
const textEl = $('card-info-text');
const abilEl = $('card-info-abilities');
const bioEl = $('card-info-bio');
if (nameEl) nameEl.textContent = meta.name || wrap.dataset.cardId;
if (metaEl) {
if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0);
else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0);
}
if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); }
if (abilEl) {
var parts = [];
if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('
') : '';
abilEl.classList.toggle('hidden', !parts.length);
}
if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); }
$('card-info-overlay')?.classList.remove('hidden');
wrap.classList.add('long-pressed');
}
}, touchState.longPressDelay);
};
wrap.ontouchmove = function (e) {
if (!touchState.target || touchState.target !== wrap) return;
// Cancel long press if moved
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
const touch = e.touches[0];
const dx = touch.clientX - touchState.startX;
const dy = touch.clientY - touchState.startY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > touchState.minSwipeDistance) {
touchState.isSwipe = true;
wrap.classList.add('swiping');
wrap.classList.remove('long-pressed');
}
};
wrap.ontouchend = function (e) {
if (!touchState.target || touchState.target !== wrap) return;
// Cancel long press timer
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
wrap.classList.remove('touch-active', 'swiping', 'long-pressed');
const touch = e.changedTouches[0];
const dx = touch.clientX - touchState.startX;
const dy = touch.clientY - touchState.startY;
const distance = Math.sqrt(dx * dx + dy * dy);
const time = Date.now() - touchState.startTime;
// If it was a swipe or long press, don't trigger card play
if (touchState.isSwipe || distance > touchState.minSwipeDistance || time > touchState.longPressDelay) {
touchState.target = null;
return;
}
// Tap - trigger card play
if (time < touchState.maxSwipeTime && distance < 10 && you.mana >= (meta?.cost || 0)) {
if (meta?.type === 'minion' && (you.board?.length ?? 0) < 7) {
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
var r = wrap.getBoundingClientRect();
spawnEffect('play', r.left + r.width / 2, r.top + r.height / 2);
wrap.querySelector('.card')?.classList.add('play-anim');
socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length });
setTimeout(function () { wrap.querySelector('.card')?.classList.remove('play-anim'); }, 500);
} else if (meta?.type === 'spell') {
if (spellMode.active || heroAbilityMode.active) return;
var needTarget = meta.spellTarget && meta.spellTarget !== 'none';
if (!needTarget) {
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
socket.emit('playSpell', { handIndex: handIndex });
} else {
spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget };
$('spell-mode')?.classList.remove('hidden');
renderGame(state);
}
}
}
touchState.target = null;
};
wrap.ontouchcancel = function () {
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
wrap.classList.remove('touch-active', 'swiping', 'long-pressed');
touchState.target = null;
};
}
});
$all('.card-btn-info').forEach(function (btn) {
btn.onclick = function (e) {
e.stopPropagation();
var cid = btn.dataset.cardId;
if (!cid) return;
var meta = cardDb[cid];
if (!meta) return;
var nameEl = $('card-info-name');
var metaEl = $('card-info-meta');
var textEl = $('card-info-text');
var abilEl = $('card-info-abilities');
var bioEl = $('card-info-bio');
if (nameEl) nameEl.textContent = meta.name || cid;
if (metaEl) {
if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0);
else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0);
}
if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); }
if (abilEl) {
var parts = [];
if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('
') : '';
abilEl.classList.toggle('hidden', !parts.length);
}
if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); }
$('card-info-overlay')?.classList.remove('hidden');
};
});
var closeInfo = $('btn-card-info-close');
if (closeInfo) closeInfo.onclick = function () { $('card-info-overlay')?.classList.add('hidden'); };
$all('.card-wrap.attackable:not(.targetable)').forEach((wrap) => {
wrap.onclick = () => {
if (!isYourTurn || attackMode.active || spellMode.active || heroAbilityMode.active) return;
const playerIndex = parseInt(wrap.dataset.playerIndex, 10);
const boardIndex = parseInt(wrap.dataset.boardIndex, 10);
if (playerIndex !== state.yourIndex) return;
attackMode = { active: true, attackerPlayer: playerIndex, attackerBoard: boardIndex };
$('attack-mode')?.classList.remove('hidden');
renderGame(state);
};
// Touch support for attack
if (isTouchDevice()) {
wrap.ontouchstart = function (e) {
if (!isYourTurn || attackMode.active || spellMode.active || heroAbilityMode.active) return;
const playerIndex = parseInt(wrap.dataset.playerIndex, 10);
const boardIndex = parseInt(wrap.dataset.boardIndex, 10);
if (playerIndex !== state.yourIndex) return;
const touch = e.touches[0];
touchState.startX = touch.clientX;
touchState.startY = touch.clientY;
touchState.startTime = Date.now();
touchState.target = wrap;
};
wrap.ontouchend = function (e) {
if (!touchState.target || touchState.target !== wrap) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - touchState.startX;
const dy = touch.clientY - touchState.startY;
const distance = Math.sqrt(dx * dx + dy * dy);
const time = Date.now() - touchState.startTime;
if (time < touchState.maxSwipeTime && distance < 20) {
const playerIndex = parseInt(wrap.dataset.playerIndex, 10);
const boardIndex = parseInt(wrap.dataset.boardIndex, 10);
if (playerIndex === state.yourIndex) {
attackMode = { active: true, attackerPlayer: playerIndex, attackerBoard: boardIndex };
$('attack-mode')?.classList.remove('hidden');
renderGame(state);
}
}
touchState.target = null;
};
}
});
$all('.card-wrap.targetable, .card-wrap.hero-target').forEach((wrap) => {
wrap.onclick = () => {
if (spellMode.active || heroAbilityMode.active) return;
if (!attackMode.active) return;
const targetPlayer = parseInt(wrap.dataset.playerIndex ?? wrap.dataset.dropPlayer, 10);
const targetBoard = parseInt(wrap.dataset.boardIndex ?? wrap.dataset.dropBoard, 10);
if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
socket.emit('attack', {
attackerPlayerIndex: state.yourIndex,
attackerBoardIndex: attackMode.attackerBoard,
targetPlayerIndex: targetPlayer,
targetBoardIndex: targetBoard,
});
attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 };
$('attack-mode')?.classList.add('hidden');
};
// Touch support for target selection
if (isTouchDevice()) {
wrap.ontouchstart = function (e) {
if (spellMode.active || heroAbilityMode.active) return;
if (!attackMode.active) return;
e.preventDefault();
const targetPlayer = parseInt(wrap.dataset.playerIndex ?? wrap.dataset.dropPlayer, 10);
const targetBoard = parseInt(wrap.dataset.boardIndex ?? wrap.dataset.dropBoard, 10);
if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
socket.emit('attack', {
attackerPlayerIndex: state.yourIndex,
attackerBoardIndex: attackMode.attackerBoard,
targetPlayerIndex: targetPlayer,
targetBoardIndex: targetBoard,
});
attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 };
$('attack-mode')?.classList.add('hidden');
};
}
});
$all('.spell-target, .drop-target-hero.spell-target').forEach(function (el) {
el.onclick = function (e) {
if (!spellMode.active) 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.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);
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);
};
}
});
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 };
$('spell-mode')?.classList.add('hidden');
$('attack-mode')?.classList.add('hidden');
$('hero-ability-mode')?.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 };
$('attack-mode')?.classList.add('hidden');
$('spell-mode')?.classList.add('hidden');
$('hero-ability-mode')?.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 = 'Не удалось загрузить карты
';
});
return;
}
const factionFilter = ($('gallery-faction-filter')?.value || '').toLowerCase();
const typeFilter = ($('gallery-type-filter')?.value || '').toLowerCase();
const searchText = ($('gallery-search')?.value || '').toLowerCase();
const allCards = Object.keys(galleryCardDb || {}).map(id => ({ id, ...galleryCardDb[id] }));
const filtered = allCards.filter(card => {
if (factionFilter && card.faction !== factionFilter) return false;
if (typeFilter && card.type !== typeFilter) return false;
if (searchText && !card.name.toLowerCase().includes(searchText)) return false;
return true;
});
grid.innerHTML = filtered.map(card => {
const isFictional = card.fictional || false;
const cost = card.cost || 0;
const attack = card.attack !== undefined ? card.attack : '';
const health = card.health !== undefined ? card.health : '';
const stats = card.type === 'minion' ? `${attack}${health}
` : '';
const costDisplay = card.type === 'spell' ? `${cost}
` : '';
const art = getCardArt ? getCardArt(card) : '✦';
return `
${art}
${escapeHtml(card.name)}
${card.text ? `
${escapeHtml(card.text)}
` : ''}
${stats}
${costDisplay}
`;
}).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');
}
});
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 ``;
}).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 ``;
}).join('') : 'Выберите 2-3 карты
';
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();
}
}
})();