2333 lines
105 KiB
JavaScript
2333 lines
105 KiB
JavaScript
/**
|
||
* 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;
|
||
const sidebar = $('forge-sidebar');
|
||
const isForgeOpen = sidebar && !sidebar.classList.contains('hidden');
|
||
|
||
$all('.card-wrap.in-hand').forEach((wrap) => {
|
||
wrap.classList.toggle('disabled', !isYourTurn);
|
||
const handIndex = parseInt(wrap.dataset.handIndex, 10);
|
||
const meta = cardDb[wrap.dataset.cardId];
|
||
const isMinion = meta && meta.type === 'minion';
|
||
const canPlay = isYourTurn && !attackMode.active && !heroAbilityMode.active && meta && you.mana >= (meta.cost || 0);
|
||
|
||
// Если кузница открыта и карта - миньон, делаем её перетаскиваемой для кузницы
|
||
if (isForgeOpen && isMinion) {
|
||
wrap.draggable = true;
|
||
wrap.ondragstart = function (e) {
|
||
e.dataTransfer.setData('text/plain', 'hand:' + handIndex);
|
||
e.dataTransfer.setData('source', 'hand');
|
||
wrap.classList.add('dragging');
|
||
};
|
||
wrap.ondragend = function () {
|
||
wrap.classList.remove('dragging');
|
||
};
|
||
} else if (isMinion && canPlay && (you.board?.length ?? 0) < 7) {
|
||
wrap.draggable = true;
|
||
wrap.ondragstart = function (e) {
|
||
if (!canPlay) return;
|
||
e.dataTransfer.setData('text/plain', 'hand:' + handIndex);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
wrap.classList.add('dragging');
|
||
};
|
||
wrap.ondragend = function () {
|
||
wrap.classList.remove('dragging');
|
||
$all('.drop-target').forEach(function (t) { t.classList.remove('drop-over'); });
|
||
};
|
||
} else {
|
||
wrap.draggable = false;
|
||
wrap.ondragstart = null;
|
||
wrap.ondragend = null;
|
||
}
|
||
|
||
wrap.onclick = function (e) {
|
||
if (e.target.closest('.card-btn-info')) return;
|
||
if (!isYourTurn || attackMode.active || heroAbilityMode.active) return;
|
||
if (!meta) return;
|
||
if (you.mana < (meta.cost || 0)) return;
|
||
if (meta.type === 'minion') {
|
||
if ((you.board?.length ?? 0) >= 7) return;
|
||
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
|
||
var r = wrap.getBoundingClientRect();
|
||
spawnEffect('play', r.left + r.width / 2, r.top + r.height / 2);
|
||
wrap.querySelector('.card')?.classList.add('play-anim');
|
||
socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length });
|
||
setTimeout(function () { wrap.querySelector('.card')?.classList.remove('play-anim'); }, 500);
|
||
return;
|
||
}
|
||
if (meta.type === 'spell') {
|
||
if (spellMode.active || heroAbilityMode.active || stealCardsMode.active) return;
|
||
var needTarget = meta.spellTarget && meta.spellTarget !== 'none';
|
||
|
||
// Специальная обработка для Грабежа
|
||
if (meta.spellEffect === 'steal_cards') {
|
||
if (meta.spellTarget === 'enemy_player') {
|
||
// Открываем модальное окно для выбора противника
|
||
spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget };
|
||
$('spell-mode')?.classList.remove('hidden');
|
||
renderGame(state);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!needTarget) {
|
||
if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
|
||
socket.emit('playSpell', { handIndex: handIndex });
|
||
return;
|
||
}
|
||
spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget };
|
||
const spellModeText = $('spell-mode')?.querySelector('p');
|
||
if (spellModeText && meta.spellEffect === 'steal_cards') {
|
||
spellModeText.textContent = 'Выберите противника для грабежа (кликните на блок противника)';
|
||
} else if (spellModeText) {
|
||
spellModeText.textContent = 'Выберите цель для заклинания';
|
||
}
|
||
$('spell-mode')?.classList.remove('hidden');
|
||
renderGame(state);
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Touch support for mobile
|
||
if (isTouchDevice()) {
|
||
wrap.ontouchstart = function (e) {
|
||
if (e.target.closest('.card-btn-info')) {
|
||
// Handle info button separately
|
||
const btn = e.target.closest('.card-btn-info');
|
||
if (btn) {
|
||
const cid = btn.dataset.cardId;
|
||
if (cid) {
|
||
const meta = cardDb[cid];
|
||
if (meta) {
|
||
const nameEl = $('card-info-name');
|
||
const metaEl = $('card-info-meta');
|
||
const textEl = $('card-info-text');
|
||
const abilEl = $('card-info-abilities');
|
||
const bioEl = $('card-info-bio');
|
||
if (nameEl) nameEl.textContent = meta.name || cid;
|
||
if (metaEl) {
|
||
if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0);
|
||
else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0);
|
||
}
|
||
if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); }
|
||
if (abilEl) {
|
||
var parts = [];
|
||
if (meta.battlecry) parts.push('<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;
|
||
const sidebar = $('forge-sidebar');
|
||
if (sidebar) {
|
||
sidebar.classList.remove('hidden');
|
||
renderForgeDeck(state);
|
||
setupForgeDragAndDrop(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;
|
||
}
|
||
|
||
// Настраиваем drag-and-drop для кузницы, если она открыта
|
||
const sidebar = $('forge-sidebar');
|
||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||
setTimeout(() => setupForgeDragAndDrop(state), 100);
|
||
}
|
||
}
|
||
|
||
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', () => {
|
||
const sidebar = $('forge-sidebar');
|
||
if (sidebar) {
|
||
sidebar.classList.add('hidden');
|
||
forgeSelected = [];
|
||
renderForgeSelected();
|
||
// Обновляем отображение руки и колоды
|
||
if (gameState) {
|
||
renderHand(gameState);
|
||
}
|
||
}
|
||
});
|
||
|
||
$('btn-steal-close')?.addEventListener('click', () => {
|
||
$('steal-cards-overlay')?.classList.add('hidden');
|
||
stealCardsMode.active = false;
|
||
stealCardsMode.handIndex = -1;
|
||
stealCardsMode.targetPlayerIndex = null;
|
||
stealCardsMode.targetDeck = [];
|
||
stealCardsMode.selectedIndices = [];
|
||
});
|
||
|
||
$('steal-cards-overlay')?.addEventListener('click', (e) => {
|
||
if (e.target.id === 'steal-cards-overlay') {
|
||
$('steal-cards-overlay')?.classList.add('hidden');
|
||
stealCardsMode.active = false;
|
||
stealCardsMode.handIndex = -1;
|
||
stealCardsMode.targetPlayerIndex = null;
|
||
stealCardsMode.targetDeck = [];
|
||
stealCardsMode.selectedIndices = [];
|
||
}
|
||
});
|
||
|
||
function showStealCardsModal(state, data) {
|
||
const deckList = $('steal-deck-list');
|
||
const selectedEl = $('steal-selected');
|
||
const confirmBtn = $('btn-steal-confirm');
|
||
const hintEl = $('steal-cards-hint');
|
||
const targetSelect = $('steal-target-select');
|
||
|
||
if (!deckList || !selectedEl || !confirmBtn) return;
|
||
|
||
// Если противник уже выбран, показываем его колоду
|
||
if (stealCardsMode.targetPlayerIndex !== null) {
|
||
// Обновляем колоду из актуального gameState
|
||
const targetPlayer = state.players[stealCardsMode.targetPlayerIndex];
|
||
if (targetPlayer && targetPlayer.deck) {
|
||
stealCardsMode.targetDeck = [...targetPlayer.deck];
|
||
}
|
||
|
||
if (stealCardsMode.targetDeck.length === 0) {
|
||
// Колода пуста, закрываем модальное окно
|
||
$('steal-cards-overlay')?.classList.add('hidden');
|
||
stealCardsMode.active = false;
|
||
return;
|
||
}
|
||
|
||
targetSelect.classList.add('hidden');
|
||
deckList.classList.remove('hidden');
|
||
|
||
if (hintEl) {
|
||
hintEl.textContent = `Выберите до 2 карт из колоды ${targetPlayer?.name || `Игрока ${stealCardsMode.targetPlayerIndex + 1}`} (${stealCardsMode.targetDeck.length} карт)`;
|
||
}
|
||
|
||
deckList.innerHTML = stealCardsMode.targetDeck.map((cardId, idx) => {
|
||
const meta = cardDb[cardId];
|
||
if (!meta) return '';
|
||
const isSelected = stealCardsMode.selectedIndices.includes(idx);
|
||
const cost = meta.cost || 0;
|
||
const attack = meta.attack !== undefined ? meta.attack : '';
|
||
const health = meta.health !== undefined ? meta.health : '';
|
||
const stats = meta.type === 'minion' ? `<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 renderForgeSelected() {
|
||
const selectedEl = $('forge-selected');
|
||
if (!selectedEl) return;
|
||
|
||
if (forgeSelected.length === 0) {
|
||
selectedEl.innerHTML = '<div class="forge-selected-empty">Перетащите карты сюда</div>';
|
||
selectedEl.classList.remove('drag-over');
|
||
} else {
|
||
selectedEl.innerHTML = forgeSelected.map((cardId, idx) => {
|
||
const meta = cardDb[cardId];
|
||
if (!meta) return '';
|
||
return `<div class="card-wrap forge-selected-card" data-card-id="${cardId}" data-forge-index="${idx}" style="width: 80px; height: 112px; position: relative;">
|
||
<div class="card faction-${meta.faction || 'neutral'}">
|
||
<div class="card-art">${getCardArt(meta)}</div>
|
||
<div class="card-info">
|
||
<div class="card-name" style="font-size: 0.6rem;">${escapeHtml(meta.name)}</div>
|
||
${meta.type === 'minion' ? `<div class="card-stats"><span class="atk">${meta.attack || 0}</span><span class="hp">${meta.health || 0}</span></div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).filter(Boolean).join('');
|
||
|
||
// Добавляем обработчики удаления
|
||
$all('.forge-selected-card').forEach(card => {
|
||
card.onclick = function(e) {
|
||
e.stopPropagation();
|
||
const cardId = card.dataset.cardId;
|
||
const idx = forgeSelected.indexOf(cardId);
|
||
if (idx >= 0) {
|
||
forgeSelected.splice(idx, 1);
|
||
renderForgeSelected();
|
||
updateForgeCraftButton();
|
||
// Обновляем отображение в источнике
|
||
if (gameState) {
|
||
renderForgeDeck(gameState);
|
||
renderHand(gameState);
|
||
}
|
||
}
|
||
};
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateForgeCraftButton() {
|
||
const craftBtn = $('btn-forge-craft');
|
||
const you = gameState?.players?.[gameState?.yourIndex];
|
||
if (craftBtn && you) {
|
||
craftBtn.disabled = forgeSelected.length < 2 || forgeSelected.length > 3 || (you.mana || 0) < 2;
|
||
}
|
||
}
|
||
|
||
function addCardToForge(cardId, source) {
|
||
if (forgeSelected.includes(cardId)) return; // Уже добавлена
|
||
if (forgeSelected.length >= 3) return; // Максимум 3 карты
|
||
|
||
const meta = cardDb[cardId];
|
||
if (!meta || meta.type !== 'minion') return; // Только миньоны
|
||
|
||
forgeSelected.push(cardId);
|
||
renderForgeSelected();
|
||
updateForgeCraftButton();
|
||
|
||
// Обновляем отображение в источнике (рука или колода)
|
||
if (gameState) {
|
||
if (source === 'hand') {
|
||
renderHand(gameState);
|
||
} else if (source === 'deck') {
|
||
renderForgeDeck(gameState);
|
||
}
|
||
}
|
||
}
|
||
|
||
function setupForgeDragAndDrop(state) {
|
||
const selectedEl = $('forge-selected');
|
||
const sidebar = $('forge-sidebar');
|
||
if (!selectedEl || !sidebar || sidebar.classList.contains('hidden')) return;
|
||
|
||
// Обработка drop в область выбранных карт
|
||
const handleDragOver = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
selectedEl.classList.add('drag-over');
|
||
};
|
||
|
||
const handleDragLeave = (e) => {
|
||
if (!selectedEl.contains(e.relatedTarget)) {
|
||
selectedEl.classList.remove('drag-over');
|
||
}
|
||
};
|
||
|
||
const handleDrop = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
selectedEl.classList.remove('drag-over');
|
||
|
||
let cardId = null;
|
||
let source = null;
|
||
|
||
// Проверяем данные из dataTransfer
|
||
const data = e.dataTransfer.getData('text/plain');
|
||
if (data) {
|
||
if (data.startsWith('hand:')) {
|
||
const handIndex = parseInt(data.split(':')[1], 10);
|
||
const you = state.players[state.yourIndex];
|
||
if (you.hand && you.hand[handIndex]) {
|
||
cardId = you.hand[handIndex];
|
||
source = 'hand';
|
||
}
|
||
} else {
|
||
cardId = data;
|
||
source = e.dataTransfer.getData('source') || 'deck';
|
||
}
|
||
}
|
||
|
||
if (cardId) {
|
||
addCardToForge(cardId, source);
|
||
}
|
||
};
|
||
|
||
// Удаляем старые обработчики, если есть
|
||
selectedEl.removeEventListener('dragover', handleDragOver);
|
||
selectedEl.removeEventListener('dragleave', handleDragLeave);
|
||
selectedEl.removeEventListener('drop', handleDrop);
|
||
|
||
// Добавляем новые обработчики
|
||
selectedEl.addEventListener('dragover', handleDragOver);
|
||
selectedEl.addEventListener('dragleave', handleDragLeave);
|
||
selectedEl.addEventListener('drop', handleDrop);
|
||
|
||
// Делаем карты в руке перетаскиваемыми в кузницу
|
||
$all('#your-hand .card-wrap').forEach(cardWrap => {
|
||
const cardId = cardWrap.dataset.cardId;
|
||
const meta = cardDb[cardId];
|
||
if (cardId && meta && meta.type === 'minion') {
|
||
// Сохраняем оригинальный draggable, если он был
|
||
const originalDraggable = cardWrap.draggable;
|
||
const originalOndragstart = cardWrap.ondragstart;
|
||
|
||
// Добавляем дополнительный обработчик для кузницы
|
||
const forgeDragStart = (e) => {
|
||
// Проверяем, открыта ли кузница
|
||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||
e.dataTransfer.setData('text/plain', cardId);
|
||
e.dataTransfer.setData('source', 'hand');
|
||
cardWrap.classList.add('dragging');
|
||
}
|
||
};
|
||
|
||
cardWrap.addEventListener('dragstart', forgeDragStart);
|
||
|
||
// Сохраняем ссылку для последующего удаления
|
||
cardWrap._forgeDragStart = forgeDragStart;
|
||
}
|
||
});
|
||
|
||
// Делаем карты в колоде перетаскиваемыми
|
||
$all('.forge-deck-card').forEach(cardWrap => {
|
||
const cardId = cardWrap.dataset.cardId;
|
||
if (cardId) {
|
||
cardWrap.draggable = true;
|
||
|
||
const deckDragStart = (e) => {
|
||
e.dataTransfer.setData('text/plain', cardId);
|
||
e.dataTransfer.setData('source', 'deck');
|
||
cardWrap.classList.add('dragging');
|
||
};
|
||
|
||
const deckDragEnd = () => {
|
||
cardWrap.classList.remove('dragging');
|
||
};
|
||
|
||
// Удаляем старые обработчики
|
||
cardWrap.removeEventListener('dragstart', cardWrap._deckDragStart);
|
||
cardWrap.removeEventListener('dragend', cardWrap._deckDragEnd);
|
||
|
||
// Добавляем новые
|
||
cardWrap.addEventListener('dragstart', deckDragStart);
|
||
cardWrap.addEventListener('dragend', deckDragEnd);
|
||
|
||
// Сохраняем ссылки
|
||
cardWrap._deckDragStart = deckDragStart;
|
||
cardWrap._deckDragEnd = deckDragEnd;
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderForgeDeck(state) {
|
||
const you = state.players[state.yourIndex];
|
||
const deckList = $('forge-deck-list');
|
||
if (!deckList) 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}" draggable="true" style="width: 90px; height: 126px; cursor: grab;">
|
||
<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.65rem;">${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;
|
||
addCardToForge(cardId, 'deck');
|
||
};
|
||
});
|
||
|
||
renderForgeSelected();
|
||
updateForgeCraftButton();
|
||
|
||
// Настраиваем drag-and-drop после рендеринга
|
||
setTimeout(() => setupForgeDragAndDrop(state), 100);
|
||
}
|
||
|
||
// Обновляем drag-and-drop при каждом обновлении руки, если кузница открыта
|
||
// Это делается в bindGameEvents
|
||
|
||
// Обработчик создания улучшенной карты
|
||
const craftBtn = $('btn-forge-craft');
|
||
if (craftBtn) {
|
||
craftBtn.onclick = function() {
|
||
if (craftBtn.disabled) return;
|
||
const you = gameState?.players?.[gameState?.yourIndex];
|
||
if (!you || forgeSelected.length < 2 || forgeSelected.length > 3 || (you.mana || 0) < 2) return;
|
||
|
||
socket.emit('forgeCard', { cardIds: forgeSelected });
|
||
forgeSelected = [];
|
||
renderForgeSelected();
|
||
const sidebar = $('forge-sidebar');
|
||
if (sidebar) {
|
||
sidebar.classList.add('hidden');
|
||
}
|
||
};
|
||
}
|
||
|
||
$('music-toggle')?.addEventListener('click', () => {
|
||
if (window.Music) {
|
||
window.Music.toggle();
|
||
}
|
||
});
|
||
|
||
$('music-volume-slider')?.addEventListener('input', (e) => {
|
||
const volume = parseFloat(e.target.value) / 100;
|
||
if (window.Music) {
|
||
window.Music.setVolume(volume);
|
||
}
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
initLobby();
|
||
showScreen('lobby');
|
||
socket = null;
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
init();
|
||
if (window.Music) {
|
||
window.Music.init();
|
||
}
|
||
});
|
||
} else {
|
||
init();
|
||
if (window.Music) {
|
||
window.Music.init();
|
||
}
|
||
}
|
||
})();
|