diff --git a/bc.js b/bc.js index c280e5e..b28f6d7 100644 --- a/bc.js +++ b/bc.js @@ -1,7 +1,7 @@ // bc.js - Главный файл сервера Battle Club const express = require('express'); -const http = require('http'); // Используем HTTP, так как SSL будет на Node.js прокси (server.js) +const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); @@ -16,13 +16,10 @@ const app = express(); const server = http.createServer(app); // Настройка Socket.IO +// cors options могут потребоваться, если клиент и сервер работают на разных портах/доменах const io = new Server(server, { cors: { - origin: "https://pavel-chagovsky.com:3200", // Указываем точный origin, включая порт, откуда придет запрос К ПРОКСИ - // Если доступ будет с нескольких доменов или портов, можно использовать массив: - // origin: ["https://pavel-chagovsky.com:3200", "https://oleg-okhotnikov.ru:3200"], - // Или для разработки можно временно использовать "*", но это менее безопасно: - // origin: "*", + origin: "*", // Разрешить подключение с любого домена (для разработки). В продакшене лучше указать конкретный домен клиента. methods: ["GET", "POST"] } }); @@ -34,132 +31,198 @@ app.use(express.static(path.join(__dirname, 'public'))); const gameManager = new GameManager(io); // Хранилище информации о залогиненных пользователях по socket.id +// В более сложном приложении здесь может быть Redis или другое внешнее хранилище сессий const loggedInUsers = {}; // { socket.id: { userId: ..., username: ... } } // Обработка подключений Socket.IO io.on('connection', (socket) => { - console.log(`[BC App HTTP] Socket.IO User connected: ${socket.id}`); + console.log(`[Socket.IO] Пользователь подключился: ${socket.id}`); - socket.userData = null; + // Привязываем user data к сокету (пока пустые) + socket.userData = null; // { userId: ..., username: ... } + // При подключении клиента, если он уже залогинен (например, по cookie/token, что здесь не реализовано, + // но может быть добавлено), нужно восстановить его user data и проверить, не в игре ли он. + // В текущей простой реализации, мы полагаемся на то, что клиент после коннекта сам отправит логин, + // если он был залогинен. Но если бы была проверка сессии, логика была бы тут. + // Добавляем вызов handleRequestGameState при коннекте, если есть user data (для примера, + // но для полной реализации нужны cookies/токены) + // if (socket.userData?.userId) { // Эта проверка сработает только после успешного логина в текущей сессии + // gameManager.handleRequestGameState(socket, socket.userData.userId); // Передаем объект socket + // } + + + // --- Обработчики событий Аутентификации --- socket.on('register', async (data) => { - console.log(`[BC App HTTP Socket.IO] Register attempt for username: "${data?.username}" from ${socket.id}`); + console.log(`[Socket.IO] Register attempt for username: "${data?.username}" from ${socket.id}`); const result = await auth.registerUser(data?.username, data?.password); if (result.success) { - console.log(`[BC App HTTP Socket.IO] Registration successful for ${result.username} (${result.userId})`); + console.log(`[Socket.IO] Registration successful for ${result.username} (${result.userId})`); } else { - console.warn(`[BC App HTTP Socket.IO] Registration failed for "${data?.username}": ${result.message}`); + console.warn(`[Socket.IO] Registration failed for "${data?.username}": ${result.message}`); } socket.emit('registerResponse', result); }); socket.on('login', async (data) => { - console.log(`[BC App HTTP Socket.IO] Login attempt for username: "${data?.username}" from ${socket.id}`); + console.log(`[Socket.IO] Login attempt for username: "${data?.username}" from ${socket.id}`); const result = await auth.loginUser(data?.username, data?.password); if (result.success) { - console.log(`[BC App HTTP Socket.IO] Login successful for ${result.username} (${result.userId}). Assigning to socket ${socket.id}.`); + console.log(`[Socket.IO] Login successful for ${result.username} (${result.userId}). Assigning to socket ${socket.id}.`); + // Сохраняем информацию о пользователе в сессии сокета socket.userData = { userId: result.userId, username: result.username }; loggedInUsers[socket.id] = socket.userData; + + // Проверяем, есть ли у пользователя активная игра при логине (если он был отключен) + // ИСПРАВЛЕНИЕ: Передаем объект socket gameManager.handleRequestGameState(socket, socket.userData.userId); + } else { - console.warn(`[BC App HTTP Socket.IO] Login failed for "${data?.username}": ${result.message}`); - socket.userData = null; + console.warn(`[Socket.IO] Login failed for "${data?.username}": ${result.message}`); + socket.userData = null; // Убеждаемся, что данные пользователя на сокете сброшены if (loggedInUsers[socket.id]) delete loggedInUsers[socket.id]; } socket.emit('loginResponse', result); }); socket.on('logout', () => { - console.log(`[BC App HTTP Socket.IO] Logout for user ${socket.userData?.username || socket.id}`); + console.log(`[Socket.IO] Logout for user ${socket.userData?.username || socket.id}`); + // Уведомляем gameManager о дисконнекте (для корректного выхода из игры, если в ней был) + // Game Manager сам очистит ссылку socketToGame[socket.id] при handleDisconnect + // ИСПРАВЛЕНИЕ: Передаем userId или socket.id в handleDisconnect gameManager.handleDisconnect(socket.id, socket.userData?.userId || socket.id); + + // Очищаем информацию о пользователе на сокете и в хранилище socket.userData = null; if (loggedInUsers[socket.id]) delete loggedInUsers[socket.id]; + + // Клиент должен сам переключиться на экран аутентификации }); + // --- Обработчики событий Управления Играми --- + socket.on('createGame', (data) => { - const identifier = socket.userData?.userId || socket.id; + // Пользователь, даже не залогиненный, может создать AI игру (идентифицируется по socket.id) + // Для PvP игры нужна аутентификация (идентификация по userId) + const identifier = socket.userData?.userId || socket.id; // Используем userId для залогиненных, socket.id для гостей const mode = data?.mode || 'ai'; + if (mode === 'pvp' && !socket.userData) { socket.emit('gameError', { message: 'Необходимо войти в систему для создания PvP игры.' }); return; } - console.log(`[BC App HTTP Socket.IO] Create Game request from ${socket.userData?.username || socket.id} (Identifier: ${identifier}). Mode: ${mode}, Character: ${data?.characterKey}`); - const characterKey = data?.characterKey || 'elena'; - gameManager.createGame(socket, mode, characterKey, identifier); + + console.log(`[Socket.IO] Create Game request from ${socket.userData?.username || socket.id} (Identifier: ${identifier}). Mode: ${mode}, Character: ${data?.characterKey}`); + + const characterKey = data?.characterKey || 'elena'; // По умолчанию Елена + gameManager.createGame(socket, mode, characterKey, identifier); // Передаем идентификатор + }); socket.on('joinGame', (data) => { - if (!socket.userData) { + if (!socket.userData) { // Проверяем, залогинен ли пользователь socket.emit('gameError', { message: 'Необходимо войти в систему для присоединения к игре.' }); return; } - console.log(`[BC App HTTP Socket.IO] Join Game request from ${socket.userData.username} (${socket.id}). Game ID: ${data?.gameId}`); + console.log(`[Socket.IO] Join Game request from ${socket.userData.username} (${socket.id}). Game ID: ${data?.gameId}`); const gameId = data?.gameId; - const identifier = socket.userData.userId; + const identifier = socket.userData.userId; // Присоединиться может только залогиненный + if (gameId) { - gameManager.joinGame(socket, gameId, identifier); + gameManager.joinGame(socket, gameId, identifier); // Передаем идентификатор } else { socket.emit('gameError', { message: 'Не указан ID игры для присоединения.' }); } }); socket.on('findRandomGame', (data) => { - if (!socket.userData) { + if (!socket.userData) { // Проверяем, залогинен ли пользователь socket.emit('gameError', { message: 'Необходимо войти в систему для поиска игры.' }); return; } - console.log(`[BC App HTTP Socket.IO] Find Random Game request from ${socket.userData.username} (${socket.id}). Preferred Character: ${data?.characterKey}`); - const characterKey = data?.characterKey || 'elena'; - const identifier = socket.userData.userId; - gameManager.findAndJoinRandomPvPGame(socket, characterKey, identifier); + console.log(`[Socket.IO] Find Random Game request from ${socket.userData.username} (${socket.id}). Preferred Character: ${data?.characterKey}`); + const characterKey = data?.characterKey || 'elena'; // Предпочитаемый персонаж для создания, если не найдено + const identifier = socket.userData.userId; // Ищет и создает только залогиненный + + gameManager.findAndJoinRandomPvPGame(socket, characterKey, identifier); // Передаем идентификатор }); socket.on('requestPvPGameList', () => { - console.log(`[BC App HTTP Socket.IO] Request PvP Game List from ${socket.userData?.username || socket.id}`); + // Список игр доступен всем, даже не залогиненным, но присоединиться можно только залогиненным + // if (!socket.userData) { + // socket.emit('gameError', { message: 'Необходимо войти в систему для просмотра игр.' }); + // return; + // } + console.log(`[Socket.IO] Request PvP Game List from ${socket.userData?.username || socket.id}`); const availableGames = gameManager.getAvailablePvPGamesListForClient(); socket.emit('availablePvPGamesList', availableGames); }); + // Обработчик для клиента, запрашивающего состояние игры (например, при переподключении) socket.on('requestGameState', () => { + // Запрашивать состояние игры может только залогиненный пользователь, т.к. только у них есть userId для идентификации if (!socket.userData) { - console.log(`[BC App HTTP Socket.IO] Request Game State from unauthenticated socket ${socket.id}.`); + console.log(`[Socket.IO] Request Game State from unauthenticated socket ${socket.id}.`); socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' }); return; } - console.log(`[BC App HTTP Socket.IO] Request Game State from ${socket.userData.username} (${socket.id}).`); + console.log(`[Socket.IO] Request Game State from ${socket.userData.username} (${socket.id}).`); + // ИСПРАВЛЕНИЕ: Передаем объект socket и identifier (userId) gameManager.handleRequestGameState(socket, socket.userData.userId); }); + + // --- Обработчик события Игрового Действия --- socket.on('playerAction', (actionData) => { + // Действие в игре может совершить как залогиненный (PvP), так и не залогиненный (AI) игрок. + // Используем userId для залогиненных, socket.id для гостей. const identifier = socket.userData?.userId || socket.id; - gameManager.handlePlayerAction(identifier, actionData); + + // Game Manager сам проверит, находится ли идентификатор в игре и его ли сейчас ход + // ИСПРАВЛЕНИЕ: Передаем идентификатор вместо socket.id + gameManager.handlePlayerAction(identifier, actionData); // Передаем идентификатор }); + + // --- Обработчик отключения сокета --- socket.on('disconnect', (reason) => { - const identifier = socket.userData?.userId || socket.id; - console.log(`[BC App HTTP Socket.IO] User disconnected: ${socket.id} (Причина: ${reason}). Identifier: ${identifier}`); - gameManager.handleDisconnect(socket.id, identifier); + const identifier = socket.userData?.userId || socket.id; // Используем userId для залогиненных, socket.id для гостей + console.log(`[Socket.IO] Пользователь отключился: ${socket.id} (Причина: ${reason}). Identifier: ${identifier}`); + + // Уведомляем gameManager о дисконнекте, чтобы он обновил состояние игры. + // Передаем идентификатор пользователя. + gameManager.handleDisconnect(socket.id, identifier); // Передаем как socketId, так и identifier + + // Удаляем пользователя из списка залогиненных, если был там if (loggedInUsers[socket.id]) { delete loggedInUsers[socket.id]; } + // Если сокет не был залогинен, его identifier был socket.id. + // Связь userIdentifierToGameId будет очищена в gameManager.handleDisconnect, если игра пуста. }); + + // Опционально: отправка списка активных игр на сервере для отладки (по запросу с консоли или админки) + // global.getActiveGames = () => gameManager.getActiveGamesList(); + // console.log("Type getActiveGames() in server console to list games."); }); // Запуск HTTP сервера -const PORT = process.env.BC_INTERNAL_PORT || 3200; // Внутренний порт для bc.js -const HOSTNAME = '127.0.0.1'; // Слушать ТОЛЬКО на localhost - -server.listen(PORT, HOSTNAME, () => { // Явно указываем HOSTNAME - console.log(`Battle Club HTTP Application Server running at http://${HOSTNAME}:${PORT}`); - console.log(`This server should only be accessed locally by the reverse proxy.`); +const PORT = process.env.PORT || 3200; // Использовать порт из переменных окружения или 3000 по умолчанию +server.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); console.log(`Serving static files from: ${path.join(__dirname, 'public')}`); + // console.log("Database connection pool created/checked (from db.js require)."); // db.js уже логирует }); // Обработка необработанных промис-ошибок process.on('unhandledRejection', (reason, promise) => { - console.error('[BC App HTTP UNHANDLED REJECTION] Unhandled Rejection at:', promise, 'reason:', reason); + console.error('[UNHANDLED REJECTION] Unhandled Rejection at:', promise, 'reason:', reason); + // Логировать ошибку, возможно, завершить процесс в продакшене }); process.on('uncaughtException', (err) => { - console.error('[BC App HTTP UNCAUGHT EXCEPTION] Caught exception:', err); + console.error('[UNCAUGHT EXCEPTION] Caught exception:', err); + // Логировать ошибку, выполнить очистку ресурсов, и завершить процесс + // В продакшене здесь может быть более сложная логика, например, graceful shutdown + // process.exit(1); // Аварийное завершение процесса - можно раскомментировать в продакшене }); \ No newline at end of file diff --git a/public/js/ui.js b/public/js/ui.js index bf2149d..ae35db4 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -14,6 +14,7 @@ status: document.getElementById('player-status'), effectsContainer: document.getElementById('player-effects'), buffsList: document.getElementById('player-effects')?.querySelector('.player-buffs'), + // ИСПРАВЛЕНО: Селектор для списка дебаффов игрока debuffsList: document.getElementById('player-effects')?.querySelector('.player-debuffs') }, opponent: { // Панель для персонажа-противника ЭТОГО клиента @@ -24,7 +25,9 @@ resourceFill: document.getElementById('opponent-resource-fill'), resourceText: document.getElementById('opponent-resource-text'), status: document.getElementById('opponent-status'), effectsContainer: document.getElementById('opponent-effects'), + // ИСПРАВЛЕНО: Селектор для списка баффов оппонента buffsList: document.getElementById('opponent-effects')?.querySelector('.opponent-buffs'), + // ИСПРАВЛЕНО: Селектор для списка дебаффов оппонента debuffsList: document.getElementById('opponent-effects')?.querySelector('.opponent-debuffs') }, controls: { @@ -39,8 +42,7 @@ gameOver: { screen: document.getElementById('game-over-screen'), message: document.getElementById('result-message'), - // restartButton: document.getElementById('restart-game-button'), // Старый ID, заменен - returnToMenuButton: document.getElementById('return-to-menu-button'), // Новый ID + returnToMenuButton: document.getElementById('return-to-menu-button'), modalContent: document.getElementById('game-over-screen')?.querySelector('.modal-content') }, gameHeaderTitle: document.querySelector('.game-header h1'), @@ -55,7 +57,7 @@ if (!logListElement) return; const li = document.createElement('li'); li.textContent = message; - const config = window.GAME_CONFIG || {}; // Получаем конфиг из глобальной области + const config = window.GAME_CONFIG || {}; // Формируем класс для лога на основе типа (используем константы из конфига или фоллбэк) const logTypeClass = config[`LOG_TYPE_${type.toUpperCase()}`] ? `log-${config[`LOG_TYPE_${type.toUpperCase()}`]}` : `log-${type}`; li.className = logTypeClass; @@ -70,63 +72,69 @@ // Базовая проверка наличия необходимых элементов и данных if (!elements || !elements.hpFill || !elements.hpText || !elements.resourceFill || !elements.resourceText || !elements.status || !fighterState || !fighterBaseStats) { - // console.warn(`updateFighterPanelUI: Отсутствуют элементы UI, состояние бойца или базовые статы для панели ${panelRole}.`); // Если панель должна быть видима, но нет данных, можно ее скрыть или показать плейсхолдер if (elements && elements.panel && elements.panel.style.display !== 'none') { // console.warn(`updateFighterPanelUI: Нет данных для видимой панели ${panelRole}.`); // elements.panel.style.opacity = '0.5'; // Пример: сделать полупрозрачной, если нет данных } + // ВАЖНО: Очистить содержимое панели, если данных нет. + if (elements) { + if(elements.name) elements.name.innerHTML = (panelRole === 'player') ? ' Ожидание данных...' : ' Ожидание игрока...'; + if(elements.hpText) elements.hpText.textContent = 'N/A'; + if(elements.resourceText) elements.resourceText.textContent = 'N/A'; + if(elements.status) elements.status.textContent = 'Неизвестно'; + if(elements.buffsList) elements.buffsList.innerHTML = 'Нет'; + if(elements.debuffsList) elements.debuffsList.innerHTML = 'Нет'; + if(elements.avatar) elements.avatar.src = 'images/default_avatar.png'; + if(panelRole === 'player' && uiElements.playerResourceTypeIcon) uiElements.playerResourceTypeIcon.className = 'fas fa-question'; + if(panelRole === 'opponent' && uiElements.opponentResourceTypeIcon) uiElements.opponentResourceTypeIcon.className = 'fas fa-question'; + if(panelRole === 'player' && uiElements.playerResourceBarContainer) uiElements.playerResourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy'); + if(panelRole === 'opponent' && uiElements.opponentResourceBarContainer) uiElements.opponentResourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy'); + if(elements.panel) elements.panel.style.opacity = '0.5'; // Затемняем + } return; } - // Если панель была полупрозрачной (из-за отсутствия данных), а теперь данные есть, делаем ее полностью видимой - // if (elements.panel && elements.panel.style.opacity !== '1' && fighterState && fighterBaseStats) { - // elements.panel.style.opacity = '1'; - // } + if (elements.panel) elements.panel.style.opacity = '1'; // Делаем видимой, если данные есть // Обновление имени и иконки персонажа if (elements.name) { let iconClass = 'fa-question'; // Иконка по умолчанию - // let accentColor = 'var(--text-muted)'; // Цвет по умолчанию - теперь берется из CSS через классы иконок const characterKey = fighterBaseStats.characterKey; // Определяем класс иконки в зависимости от персонажа - if (characterKey === 'elena') { iconClass = 'fa-hat-wizard icon-player'; } // icon-player имеет цвет через CSS - else if (characterKey === 'almagest') { iconClass = 'fa-staff-aesculapius icon-almagest'; } // icon-almagest имеет цвет через CSS - else if (characterKey === 'balard') { iconClass = 'fa-khanda icon-opponent'; } // icon-opponent имеет цвет через CSS + if (characterKey === 'elena') { iconClass = 'fa-hat-wizard icon-player'; } + else if (characterKey === 'almagest') { iconClass = 'fa-staff-aesculapius icon-almagest'; } + else if (characterKey === 'balard') { iconClass = 'fa-khanda icon-opponent'; } else { /* console.warn(`updateFighterPanelUI: Неизвестный characterKey "${characterKey}" для иконки имени.`); */ } - // Обновляем innerHTML имени, включая иконку и текст. Добавляем "(Вы)" для управляемого персонажа. let nameHtml = ` ${fighterBaseStats.name || 'Неизвестно'}`; if (isControlledByThisClient) nameHtml += " (Вы)"; elements.name.innerHTML = nameHtml; - // Цвет имени теперь задается CSS через классы icon-player/opponent/almagest, примененные к самой иконке - // elements.name.style.color = accentColor; // Эту строку можно удалить, если цвет задан через CSS } // Обновление аватара if (elements.avatar && fighterBaseStats.avatarPath) { elements.avatar.src = fighterBaseStats.avatarPath; // Обновляем рамку аватара в зависимости от персонажа - elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard'); // Убираем старые классы - elements.avatar.classList.add(`avatar-${fighterBaseStats.characterKey}`); // Добавляем класс для текущего персонажа + elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard'); + elements.avatar.classList.add(`avatar-${fighterBaseStats.characterKey}`); } else if (elements.avatar) { - elements.avatar.src = 'images/default_avatar.png'; // Запасной аватар - elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard'); // Убираем старые классы + elements.avatar.src = 'images/default_avatar.png'; + elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard'); } // Обновление полос здоровья и ресурса - const maxHp = Math.max(1, fighterBaseStats.maxHp); // Избегаем деления на ноль + const maxHp = Math.max(1, fighterBaseStats.maxHp); const maxRes = Math.max(1, fighterBaseStats.maxResource); const currentHp = Math.max(0, fighterState.currentHp); const currentRes = Math.max(0, fighterState.currentResource); elements.hpFill.style.width = `${(currentHp / maxHp) * 100}%`; - elements.hpText.textContent = `${Math.round(currentHp)} / ${fighterBaseStats.maxHp}`; - // ИСПРАВЛЕНО: Убрано округление для отображения текущего ресурса + elements.hpText.textContent = `${Math.round(currentHp)} / ${fighterBaseStats.maxHp}`; // Здоровье округляем elements.resourceFill.style.width = `${(currentRes / maxRes) * 100}%`; - elements.resourceText.textContent = `${currentRes} / ${fighterBaseStats.maxResource}`; // <-- ИСПРАВЛЕНО + elements.resourceText.textContent = `${currentRes} / ${fighterBaseStats.maxResource}`; // Ресурс не округляем // Обновление типа ресурса и иконки (mana/stamina/dark-energy) @@ -134,124 +142,158 @@ const resourceIconElementToUpdate = (panelRole === 'player') ? uiElements.playerResourceTypeIcon : uiElements.opponentResourceTypeIcon; if (resourceBarContainerToUpdate && resourceIconElementToUpdate) { - resourceBarContainerToUpdate.classList.remove('mana', 'stamina', 'dark-energy'); // Сначала удаляем все классы ресурсов - let resourceClass = 'mana'; let iconClass = 'fa-flask'; // Значения по умолчанию (для Маны) + resourceBarContainerToUpdate.classList.remove('mana', 'stamina', 'dark-energy'); + let resourceClass = 'mana'; let iconClass = 'fa-flask'; if (fighterBaseStats.resourceName === 'Ярость') { resourceClass = 'stamina'; iconClass = 'fa-fire-alt'; } - else if (fighterBaseStats.resourceName === 'Темная Энергия') { resourceClass = 'dark-energy'; iconClass = 'fa-skull'; } // Или другую иконку для темной энергии + else if (fighterBaseStats.resourceName === 'Темная Энергия') { resourceClass = 'dark-energy'; iconClass = 'fa-skull'; } // или fa-wand-magic-sparkles, fa-star-half-alt и т.д. + else { console.warn(`updateFighterPanelUI: Unknown resource name "${fighterBaseStats.resourceName}" for icon/color.`); iconClass = 'fa-question-circle'; } resourceBarContainerToUpdate.classList.add(resourceClass); - resourceIconElementToUpdate.className = `fas ${iconClass}`; // Обновляем класс иконки + resourceIconElementToUpdate.className = `fas ${iconClass}`; } // Обновление статуса (Готов/Защищается) const statusText = fighterState.isBlocking ? (config.STATUS_BLOCKING || 'Защищается') : (config.STATUS_READY || 'Готов(а)'); elements.status.textContent = statusText; - elements.status.classList.toggle(config.CSS_CLASS_BLOCKING || 'blocking', fighterState.isBlocking); // Применяем класс для стилизации статуса "Защищается" + elements.status.classList.toggle(config.CSS_CLASS_BLOCKING || 'blocking', fighterState.isBlocking); // Обновление подсветки и рамки панели (в зависимости от персонажа) if (elements.panel) { - let borderColorVar = 'var(--panel-border)'; // Цвет по умолчанию - // Снимаем все старые классы для рамки + let borderColorVar = 'var(--panel-border)'; elements.panel.classList.remove('panel-elena', 'panel-almagest', 'panel-balard'); - // Применяем класс для рамки в зависимости от персонажа - if (fighterBaseStats.characterKey === 'elena') { elements.panel.classList.add('panel-elena'); borderColorVar = 'var(--accent-player)'; } // Цвет рамки через CSS переменную - else if (fighterBaseStats.characterKey === 'almagest') { elements.panel.classList.add('panel-almagest'); borderColorVar = 'var(--accent-almagest)'; } // Цвет рамки через CSS переменную - else if (fighterBaseStats.characterKey === 'balard') { elements.panel.classList.add('panel-balard'); borderColorVar = 'var(--accent-opponent)'; } // Цвет рамки через CSS переменную + if (fighterBaseStats.characterKey === 'elena') { elements.panel.classList.add('panel-elena'); borderColorVar = 'var(--accent-player)'; } + else if (fighterBaseStats.characterKey === 'almagest') { elements.panel.classList.add('panel-almagest'); borderColorVar = 'var(--accent-almagest)'; } + else if (fighterBaseStats.characterKey === 'balard') { elements.panel.classList.add('panel-balard'); borderColorVar = 'var(--accent-opponent)'; } + else { console.warn(`updateFighterPanelUI: Unknown character key "${fighterBaseStats.characterKey}" for panel border color.`); } + - // Обновляем тень (свечение). Цвет свечения тоже может быть переменной. let glowColorVar = 'rgba(0, 0, 0, 0.4)'; // Базовая тень if (fighterBaseStats.characterKey === 'elena') glowColorVar = 'var(--panel-glow-player)'; - else if (fighterBaseStats.characterKey === 'almagest' || fighterBaseStats.characterKey === 'balard') glowColorVar = 'var(--panel-glow-opponent)'; // Используем одну тень для всех оппонентов (Балард/Альмагест) + // В твоем CSS --panel-glow-opponent используется для обоих Баларда и Альмагест + else if (fighterBaseStats.characterKey === 'almagest' || fighterBaseStats.characterKey === 'balard') glowColorVar = 'var(--panel-glow-opponent)'; - // Устанавливаем рамку и тень elements.panel.style.borderColor = borderColorVar; - // Используем переменную для свечения. Базовая тень inset оставлена как есть. elements.panel.style.boxShadow = `0 0 15px ${glowColorVar}, inset 0 0 10px rgba(0, 0, 0, 0.3)`; } } + /** + * Генерирует HTML для списка эффектов. + * @param {Array} effectsArray - Массив объектов эффектов, УЖЕ отфильтрованных и отсортированных. + * @returns {string} HTML-строка для отображения списка эффектов. + */ function generateEffectsHTML(effectsArray) { const config = window.GAME_CONFIG || {}; if (!effectsArray || effectsArray.length === 0) return 'Нет'; - // Сортируем эффекты: сначала положительные (buff, block, heal), затем отрицательные (debuff, disable) - // иконка для стана/безмолвия, иконка для ослабления, иконка для усиления - const sortedEffects = [...effectsArray].sort((a, b) => { - const typeOrder = { - [config.ACTION_TYPE_BUFF]: 1, - grantsBlock: 2, - [config.ACTION_TYPE_HEAL]: 3, // HoT эффекты - [config.ACTION_TYPE_DEBUFF]: 4, // DoT, ресурсные дебаффы - [config.ACTION_TYPE_DISABLE]: 5 // Silence, Stun - }; - // Определяем порядок для эффекта A - let orderA = typeOrder[a.type]; - if (a.grantsBlock) orderA = typeOrder.grantsBlock; - if (a.isFullSilence || a.id.startsWith('playerSilencedOn_')) orderA = typeOrder[config.ACTION_TYPE_DISABLE]; + // ВАЖНО: Сортировка теперь выполняется ВНЕ этой функции (в updateEffectsUI) - // Определяем порядок для эффекта B - let orderB = typeOrder[b.type]; - if (b.grantsBlock) orderB = typeOrder.grantsBlock; - if (b.isFullSilence || b.id.startsWith('playerSilencedOn_')) orderB = typeOrder[config.ACTION_TYPE_DISABLE]; - - return (orderA || 99) - (orderB || 99); // Сортируем по порядку, неизвестные типы в конец - }); - - - return sortedEffects.map(eff => { + return effectsArray.map(eff => { let effectClasses = config.CSS_CLASS_EFFECT || 'effect'; // Базовый класс для всех эффектов - // Формируем заголовок тултипа const title = `${eff.name}${eff.description ? ` - ${eff.description}` : ''} (Осталось: ${eff.turnsLeft} х.)`; - // Текст, отображаемый на самой плашке эффекта const displayText = `${eff.name} (${eff.turnsLeft} х.)`; // Добавляем специфичные классы для стилизации по типу эффекта - if (eff.isFullSilence || eff.id.startsWith('playerSilencedOn_') || (eff.type === config.ACTION_TYPE_DISABLE)) { // Эффекты полного безмолвия или специфичного заглушения - effectClasses += ' effect-stun'; // Класс для стана/безмолвия + // Логика определения класса должна соответствовать логике разделения на баффы/дебаффы + if (eff.isFullSilence || eff.id.startsWith('playerSilencedOn_') || eff.type === config.ACTION_TYPE_DISABLE) { + // Эффекты полного безмолвия, заглушения абилок или типа DISABLE + effectClasses += ' effect-stun'; // Класс для стана/безмолвия (красный/желтый) } else if (eff.grantsBlock) { // Эффекты, дающие блок - effectClasses += ' effect-block'; // Класс для эффектов блока - } else if (eff.type === config.ACTION_TYPE_DEBUFF) { // Явные дебаффы (например, сжигание ресурса, DoT) - effectClasses += ' effect-debuff'; // Класс для ослаблений - } else if (eff.type === config.ACTION_TYPE_BUFF || eff.type === config.ACTION_TYPE_HEAL) { // Явные баффы или эффекты HoT - effectClasses += ' effect-buff'; // Класс для усилений - } else { - // console.warn(`generateEffectsHTML: Неизвестный тип эффекта для стилизации: ${eff.type} (ID: ${eff.id})`); - effectClasses += ' effect-info'; // Класс по умолчанию или информационный + effectClasses += ' effect-block'; // Класс для эффектов блока (синий) + } else if (eff.type === config.ACTION_TYPE_DEBUFF) { // Явные дебаффы (например, сжигание ресурса) + effectClasses += ' effect-debuff'; // Класс для ослаблений (красноватый) + } else if (eff.type === config.ACTION_TYPE_BUFF) { // Явные баффы (например, усиление атаки) + effectClasses += ' effect-buff'; // Класс для усилений (зеленый) + } else if (eff.type === config.ACTION_TYPE_HEAL) { // Эффекты лечения (HoT) + effectClasses += ' effect-buff'; // HoT стилизуем как бафф (зеленый) + } + // Если есть другие типы (DoT, Drain и т.п.), которые не входят в эти категории, + // их нужно добавить или стилизовать как info. + // DoT можно стилизовать как effect-debuff or effect-damage, Drain as effect-debuff. + // Например: else if (eff.type === config.ACTION_TYPE_DAMAGE) { effectClasses += ' effect-debuff'; } // DoT как дебафф + // else if (eff.type === config.ACTION_TYPE_DRAIN) { effectClasses += ' effect-debuff'; } // Drain как дебафф + else { + //console.warn(`generateEffectsHTML: Эффект ID "${eff.id}" с типом "${eff.type}" не имеет специфичного класса стилизации.`); + effectClasses += ' effect-info'; // Класс по умолчанию или информационный (серый/синий) } return `${displayText}`; - }).join(' '); // Объединяем все HTML-строки эффектов в одну + }).join(' '); } function updateEffectsUI(currentGameState) { if (!currentGameState || !window.GAME_CONFIG) { return; } - const mySlotId = window.myPlayerId; // Технический ID слота этого клиента + const mySlotId = window.myPlayerId; const config = window.GAME_CONFIG; if (!mySlotId) { return; } const opponentSlotId = mySlotId === config.PLAYER_ID ? config.OPPONENT_ID : config.PLAYER_ID; - const myState = currentGameState[mySlotId]; // Состояние персонажа этого клиента + const myState = currentGameState[mySlotId]; + const opponentState = currentGameState[opponentSlotId]; + + // --- Логика сортировки эффектов (для использования как для баффов, так и для дебаффов) --- + // Сортируем эффекты по типу: сначала позитивные, потом негативные, потом контроля + const typeOrder = { + [config.ACTION_TYPE_BUFF]: 1, + grantsBlock: 2, + [config.ACTION_TYPE_HEAL]: 3, // HoT эффекты + [config.ACTION_TYPE_DEBUFF]: 4, // DoT, ресурсные дебаффы + [config.ACTION_TYPE_DISABLE]: 5 // Silence, Stun + // Добавьте другие типы, если нужно сортировать + }; + const sortEffects = (a, b) => { + // Определяем порядок для эффекта A + let orderA = typeOrder[a.type] || 99; + if (a.grantsBlock) orderA = typeOrder.grantsBlock; + // isFullSilence и playerSilencedOn_X - это эффекты типа DISABLE, но их можно поставить выше в приоритете дебаффов + if (a.isFullSilence || a.id.startsWith('playerSilencedOn_')) orderA = typeOrder[config.ACTION_TYPE_DISABLE]; + // Добавьте сюда другие специфичные проверки, если нужно изменить стандартный порядок по типу + + // Определяем порядок для эффекта B + let orderB = typeOrder[b.type] || 99; + if (b.grantsBlock) orderB = typeOrder.grantsBlock; + if (b.isFullSilence || b.id.startsWith('playerSilencedOn_')) orderB = typeOrder[config.ACTION_TYPE_DISABLE]; + + return (orderA || 99) - (orderB || 99); // Сортируем по порядку + }; + // --- Конец логики сортировки --- + + + // --- Обработка эффектов Игрока (My Player) --- if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList && myState && myState.activeEffects) { - // Разделяем эффекты на баффы и дебаффы для отображения - // Критерии разделения могут быть специфичны для игры: - // Баффы: тип BUFF, эффекты дающие блок (grantsBlock), эффекты лечения (HoT) - // Дебаффы: тип DEBUFF, тип DISABLE (кроме grantsBlock), эффекты урона (DoT) - // Включаем isFullSilence и playerSilencedOn_X в "дебаффы" для отображения (можно сделать отдельную категорию) - const myBuffs = myState.activeEffects.filter(e => - e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || (e.type === config.ACTION_TYPE_HEAL) // HoT как бафф - ); - const myDebuffs = myState.activeEffects.filter(e => - e.type === config.ACTION_TYPE_DEBUFF || - e.type === config.ACTION_TYPE_DISABLE // Disable как дебафф - // || (e.type === config.ACTION_TYPE_DAMAGE) // DoT как дебафф, если есть - ); - // Специально добавляем полные безмолвия и заглушения абилок в дебаффы, даже если их тип не DEBUFF/DISABLE - myDebuffs.push(...myState.activeEffects.filter(e => e.isFullSilence || e.id.startsWith('playerSilencedOn_') && !myDebuffs.some(d => d.id === e.id))); // Избегаем дублирования + const myBuffs = []; + const myDebuffs = []; + + // ИСПРАВЛЕНО: Проходим по массиву activeEffects один раз и пушим в нужный список + myState.activeEffects.forEach(e => { + // Определяем, является ли эффект баффом + const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL; // HoT как бафф + + // Определяем, является ли эффект дебаффом + // Учитываем типы DEBUFF, DISABLE, а также специфические флаги/ID для полного безмолвия и заглушения конкретных абилок + const isDebuff = e.type === config.ACTION_TYPE_DEBUFF || e.type === config.ACTION_TYPE_DISABLE || e.isFullSilence || e.id.startsWith('playerSilencedOn_'); + + // Добавляем эффект в соответствующий список (каждый эффект должен попасть только в один) + if (isBuff) { + myBuffs.push(e); + } else if (isDebuff) { + myDebuffs.push(e); + } else { + // Если эффект не попал ни в одну категорию (например, новый тип?) + //console.warn(`updateEffectsUI: Эффект ID "${e.id}" с типом "${e.type}" не отнесен ни к баффам, ни к дебаффам для Игрока.`); + myDebuffs.push(e); // Добавим в дебаффы по умолчанию + } + }); + + // Сортируем списки баффов и дебаффов перед генерацией HTML + myBuffs.sort(sortEffects); + myDebuffs.sort(sortEffects); uiElements.player.buffsList.innerHTML = generateEffectsHTML(myBuffs); uiElements.player.debuffsList.innerHTML = generateEffectsHTML(myDebuffs); + } else if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList) { // Если нет активных эффектов или состояния, очищаем списки uiElements.player.buffsList.innerHTML = 'Нет'; @@ -259,21 +301,41 @@ } - const opponentState = currentGameState[opponentSlotId]; // Состояние оппонента этого клиента + // --- Обработка эффектов Оппонента (Opponent Player) --- + // Логика аналогична игроку, но условия дебаффов могут немного отличаться + // (например, префикс ID заглушения абилок) if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList && opponentState && opponentState.activeEffects) { - const opponentBuffs = opponentState.activeEffects.filter(e => - e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || (e.type === config.ACTION_TYPE_HEAL) // HoT как бафф - ); - const opponentDebuffs = opponentState.activeEffects.filter(e => - e.type === config.ACTION_TYPE_DEBUFF || - e.type === config.ACTION_TYPE_DISABLE // Disable как дебафф - // || (e.type === config.ACTION_TYPE_DAMAGE) // DoT как дебафф, если есть - ); - // Специально добавляем полные безмолвия и заглушения абилок в дебаффы оппонента, даже если их тип не DEBUFF/DISABLE - opponentDebuffs.push(...opponentState.activeEffects.filter(e => e.isFullSilence || e.id.startsWith('playerSilencedOn_') && !opponentDebuffs.some(d => d.id === e.id))); // Избегаем дублирования + const opponentBuffs = []; + const opponentDebuffs = []; + + // ИСПРАВЛЕНО: Проходим по массиву activeEffects оппонента один раз и пушим в нужный список + opponentState.activeEffects.forEach(e => { + const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL; // HoT как бафф + + // Определяем, является ли эффект дебаффом для ОППОНЕНТА + // Учитываем типы DEBUFF, DISABLE, isFullSilence. + // id.startsWith('playerSilencedOn_') специфично для игрока, + // id.startsWith('effect_') используется для дебаффов, наложенных на цель (например, Seal of Weakness) + const isDebuff = e.type === config.ACTION_TYPE_DEBUFF || e.type === config.ACTION_TYPE_DISABLE || e.isFullSilence || e.id.startsWith('effect_'); + // Если у оппонента есть свои специфичные эффекты заглушения с другим префиксом, его тоже нужно добавить сюда. + + if (isBuff) { + opponentBuffs.push(e); + } else if (isDebuff) { + opponentDebuffs.push(e); + } else { + //console.warn(`updateEffectsUI: Эффект ID "${e.id}" с типом "${e.type}" не отнесен ни к баффам, ни к дебаффам для Оппонента.`); + opponentDebuffs.push(e); // Добавим в дебаффы по умолчанию + } + }); + + // Сортируем списки баффов и дебаффов оппонента + opponentBuffs.sort(sortEffects); + opponentDebuffs.sort(sortEffects); uiElements.opponent.buffsList.innerHTML = generateEffectsHTML(opponentBuffs); uiElements.opponent.debuffsList.innerHTML = generateEffectsHTML(opponentDebuffs); + } else if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList) { // Если нет активных эффектов или состояния оппонента, очищаем списки uiElements.opponent.buffsList.innerHTML = 'Нет'; @@ -288,7 +350,17 @@ const myActualPlayerId = window.myPlayerId; // Технический ID слота этого клиента if (!currentGameState || !gameDataGlobal || !configGlobal || !myActualPlayerId) { - console.warn("updateUI: Отсутствуют глобальные gameState, gameData, GAME_CONFIG или myActualPlayerId."); + // console.warn("updateUI: Отсутствуют глобальные gameState, gameData, GAME_CONFIG или myActualPlayerId."); + // Сбрасываем UI панелей, если данные отсутствуют + updateFighterPanelUI('player', null, null, true); + updateFighterPanelUI('opponent', null, null, false); + // Скрываем/очищаем остальные элементы UI игры + if(uiElements.gameHeaderTitle) uiElements.gameHeaderTitle.innerHTML = `Ожидание данных...`; + if(uiElements.controls.turnIndicator) uiElements.controls.turnIndicator.textContent = "Ожидание данных..."; + if(uiElements.controls.buttonAttack) uiElements.controls.buttonAttack.disabled = true; + if(uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true; + if(uiElements.controls.abilitiesGrid) uiElements.controls.abilitiesGrid.innerHTML = '

Загрузка способностей...

'; + return; } if (!uiElements.player.panel || !uiElements.opponent.panel || !uiElements.controls.turnIndicator || !uiElements.controls.abilitiesGrid || !uiElements.log.list) { @@ -298,54 +370,61 @@ // Определяем, чей сейчас ход по ID слота const actorSlotWhoseTurnItIs = currentGameState.isPlayerTurn ? configGlobal.PLAYER_ID : configGlobal.OPPONENT_ID; - // Определяем ID слота оппонента для этого клиента ( необходимо для определения, чьи панели обновлять как "мои" и "противника") + // Определяем ID слота оппонента для этого клиента const opponentActualSlotId = myActualPlayerId === configGlobal.PLAYER_ID ? configGlobal.OPPONENT_ID : configGlobal.PLAYER_ID; - // Обновление панели "моего" персонажа ( которым управляет этот клиент) + // Обновление панели "моего" персонажа const myStateInGameState = currentGameState[myActualPlayerId]; const myBaseStatsForUI = gameDataGlobal.playerBaseStats; // playerBaseStats в gameData - это всегда статы персонажа этого клиента if (myStateInGameState && myBaseStatsForUI) { - if (uiElements.player.panel) uiElements.player.panel.style.opacity = '1'; // Делаем панель полностью видимой, если есть данные updateFighterPanelUI('player', myStateInGameState, myBaseStatsForUI, true); } else { - if (uiElements.player.panel) uiElements.player.panel.style.opacity = '0.5'; // Затемняем, если нет данных + updateFighterPanelUI('player', null, null, true); // Нет данных, показываем состояние ожидания } - // Обновление панели "моего оппонента" ( персонажа в слоте противника для этого клиента) + // Обновление панели "моего оппонента" const opponentStateInGameState = currentGameState[opponentActualSlotId]; const opponentBaseStatsForUI = gameDataGlobal.opponentBaseStats; // opponentBaseStats в gameData - это всегда статы оппонента этого клиента + + // Если игра окончена и игрок победил, возможно, панель оппонента уже анимирована на исчезновение. + // Не сбрасываем ее opacity/transform здесь, если она в состоянии dissolving. + const isOpponentPanelDissolving = uiElements.opponent.panel?.classList.contains('dissolving'); + if (opponentStateInGameState && opponentBaseStatsForUI) { // Если игра не окончена, а панель оппонента "тает" или не полностью видна, восстанавливаем это - if (uiElements.opponent.panel && (uiElements.opponent.panel.style.opacity !== '1' || uiElements.opponent.panel.classList.contains('dissolving')) && currentGameState.isGameOver === false ) { - console.log("[UI UPDATE DEBUG] Opponent panel not fully visible/dissolving but game not over. Restoring opacity/transform."); + // Но не если она активно в анимации растворения (dissolving) + if (uiElements.opponent.panel && (uiElements.opponent.panel.style.opacity !== '1' || (uiElements.opponent.panel.classList.contains('dissolving') && currentGameState.isGameOver === false) )) { + // console.log("[UI UPDATE DEBUG] Opponent panel not fully visible/dissolving but game not over. Restoring opacity/transform."); const panel = uiElements.opponent.panel; - panel.classList.remove('dissolving'); - // Force reflow before applying instant style change - panel.offsetHeight; // Trigger reflow - panel.style.opacity = '1'; - panel.style.transform = 'scale(1) translateY(0)'; - } else if (uiElements.opponent.panel) { - uiElements.opponent.panel.style.opacity = '1'; // Убеждаемся, что видна, если есть данные + if (panel.classList.contains('dissolving')) { + panel.classList.remove('dissolving'); + panel.style.transition = 'none'; // Отключаем переход временно + panel.offsetHeight; // Trigger reflow + panel.style.opacity = '1'; + panel.style.transform = 'scale(1) translateY(0)'; + panel.style.transition = ''; // Восстанавливаем переход + } else { + panel.style.opacity = '1'; + panel.style.transform = 'scale(1) translateY(0)'; // В случае если просто opacity < 1 + } + } else if (uiElements.opponent.panel && !isOpponentPanelDissolving) { + uiElements.opponent.panel.style.opacity = '1'; // Убеждаемся, что видна, если есть данные и не растворяется } updateFighterPanelUI('opponent', opponentStateInGameState, opponentBaseStatsForUI, false); } else { - // Нет данных оппонента ( например, PvP игра ожидает игрока). Затемняем панель. - if (uiElements.opponent.panel) { - uiElements.opponent.panel.style.opacity = '0.5'; - // Очищаем панель, если данных нет - if(uiElements.opponent.name) uiElements.opponent.name.innerHTML = ' Ожидание игрока...'; - if(uiElements.opponent.hpText) uiElements.opponent.hpText.textContent = 'N/A'; - if(uiElements.opponent.resourceText) uiElements.opponent.resourceText.textContent = 'N/A'; - if(uiElements.opponent.status) uiElements.opponent.status.textContent = 'Не готов'; - if(uiElements.opponent.buffsList) uiElements.opponent.buffsList.innerHTML = 'Нет'; - if(uiElements.opponent.debuffsList) uiElements.opponent.debuffsList.innerHTML = 'Нет'; - if(uiElements.opponent.avatar) uiElements.opponent.avatar.src = 'images/default_avatar.png'; - if(uiElements.opponentResourceTypeIcon) uiElements.opponentResourceTypeIcon.className = 'fas fa-question'; - if(uiElements.opponentResourceBarContainer) uiElements.opponentResourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy'); + // Нет данных оппонента ( например, PvP игра ожидает игрока). Затемняем панель и очищаем. + // Но не сбрасываем opacity/transform, если она активно в анимации растворения + if (!isOpponentPanelDissolving) { + updateFighterPanelUI('opponent', null, null, false); // Нет данных, показываем состояние ожидания/пустоты + } else { + // Если панель растворяется, не обновляем ее содержимое и оставляем текущие стили opacity/transform + console.log("[UI UPDATE DEBUG] Opponent panel is dissolving, skipping content update."); } + } + // Обновление эффектов updateEffectsUI(currentGameState); // Обновление заголовка игры ( Имя1 vs Имя2) @@ -355,41 +434,76 @@ const myKey = gameDataGlobal.playerBaseStats.characterKey; const opponentKey = gameDataGlobal.opponentBaseStats.characterKey; - let myClass = 'title-player'; // Базовый класс + let myClass = 'title-player'; if (myKey === 'elena') myClass = 'title-enchantress'; else if (myKey === 'almagest') myClass = 'title-sorceress'; + else if (myKey === 'balard') myClass = 'title-knight'; // Вдруг AI Балард в PvP - let opponentClass = 'title-opponent'; // Базовый класс + let opponentClass = 'title-opponent'; if (opponentKey === 'elena') opponentClass = 'title-enchantress'; else if (opponentKey === 'almagest') opponentClass = 'title-sorceress'; else if (opponentKey === 'balard') opponentClass = 'title-knight'; - // Используем имена персонажей, которые видит этот клиент uiElements.gameHeaderTitle.innerHTML = `${myName} ${opponentName}`; + } else if (uiElements.gameHeaderTitle) { + // Обновление заголовка в режиме ожидания + const myName = gameDataGlobal.playerBaseStats?.name || 'Игрок 1'; + const myKey = gameDataGlobal.playerBaseStats?.characterKey; + let myClass = 'title-player'; + if (myKey === 'elena') myClass = 'title-enchantress'; + else if (myKey === 'almagest') myClass = 'title-sorceress'; + + uiElements.gameHeaderTitle.innerHTML = `${myName} Ожидание игрока...`; } - // Управление активностью кнопок - // Этот клиент может действовать только если его технический слот ('player' или 'opponent') - // соответствует слоту, чей сейчас ход ( actorSlotWhoseTurnItIs). + + // Управление активностью кнопок и индикатор хода const canThisClientAct = actorSlotWhoseTurnItIs === myActualPlayerId; const isGameActive = !currentGameState.isGameOver; + const myCharacterState = currentGameState[myActualPlayerId]; + + // Обновление индикатора хода + if (uiElements.controls.turnIndicator) { + if (isGameActive) { + const currentTurnActor = currentGameState.isPlayerTurn ? currentGameState.player : currentGameState.opponent; + uiElements.controls.turnIndicator.textContent = `Ход ${currentGameState.turnNumber}: ${currentTurnActor?.name || 'Неизвестно'}`; + // Управляем цветом индикатора хода + if (currentTurnActor?.id === myActualPlayerId) { + uiElements.controls.turnIndicator.style.color = 'var(--turn-color)'; // Свой ход - желтый + } else { + uiElements.controls.turnIndicator.style.color = 'var(--text-muted)'; // Ход противника - приглушенный + } + } else { + uiElements.controls.turnIndicator.textContent = "Игра окончена"; // Или можно скрыть его + uiElements.controls.turnIndicator.style.color = 'var(--text-muted)'; + } + } + // Кнопка атаки if (uiElements.controls.buttonAttack) { - // Кнопка атаки активна, если это ход этого клиента и игра активна + // Кнопка атаки активна, если это ход этого клиента и игра активна (полное безмолвие не блокирует базовую атаку) + // ИСПРАВЛЕНО: Убрана проверка !isFullySilenced из условия disabled для базовой атаки uiElements.controls.buttonAttack.disabled = !(canThisClientAct && isGameActive); // Управление классом для подсветки бафнутой атаки const myCharKey = gameDataGlobal.playerBaseStats?.characterKey; - const myStateForAttackBuff = currentGameState[myActualPlayerId]; // Состояние моего персонажа let attackBuffId = null; if (myCharKey === 'elena') attackBuffId = configGlobal.ABILITY_ID_NATURE_STRENGTH; else if (myCharKey === 'almagest') attackBuffId = configGlobal.ABILITY_ID_ALMAGEST_BUFF_ATTACK; - if (attackBuffId && myStateForAttackBuff && myStateForAttackBuff.activeEffects) { - // Проверяем, есть ли активный бафф атаки И он не только что наложен в этом ходу - const isAttackBuffReady = myStateForAttackBuff.activeEffects.some(eff => eff.id === attackBuffId && !eff.justCast && eff.turnsLeft > 0); + if (attackBuffId && myCharacterState && myCharacterState.activeEffects) { + // Проверяем, есть ли активный "отложенный" бафф (isDelayed=true) на атакующем, + // который готов сработать на следующую атаку. + const isAttackBuffReady = myCharacterState.activeEffects.some( + eff => (eff.id === attackBuffId || eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK) + && eff.isDelayed // Явно проверяем, что это отложенный бафф + && eff.turnsLeft > 0 // Эффект должен еще действовать + && !eff.justCast // Не должен быть наложен в этом ходу, чтобы сработать НА ЭТОМ ходу + ); + // Подсветка активна, если бафф готов И это ход этого клиента И игра активна + // Подсветка не зависит от безмолвия, т.к. атака возможна и под безмолвием. uiElements.controls.buttonAttack.classList.toggle(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed', isAttackBuffReady && canThisClientAct && isGameActive); } else { uiElements.controls.buttonAttack.classList.remove(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed'); @@ -398,81 +512,70 @@ if (uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true; // Пока не используется // Кнопки способностей - const actingPlayerState = currentGameState[myActualPlayerId]; // Состояние моего персонажа + const actingPlayerState = myCharacterState; // Состояние моего персонажа const actingPlayerAbilities = gameDataGlobal.playerAbilities; // Способности моего персонажа (с точки зрения клиента) const actingPlayerResourceName = gameDataGlobal.playerBaseStats?.resourceName; // Имя ресурса моего персонажа + const opponentStateForDebuffCheck = currentGameState[opponentActualSlotId]; // Состояние оппонента этого клиента uiElements.controls.abilitiesGrid?.querySelectorAll(`.${configGlobal.CSS_CLASS_ABILITY_BUTTON || 'ability-button'}`).forEach(button => { - if (!(button instanceof HTMLButtonElement) || !actingPlayerState || !actingPlayerAbilities || !actingPlayerResourceName || !isGameActive) { - // Если нет необходимых данных или игра неактивна, дизейблим все кнопки способностей + // Получаем актуальное состояние способности из actingPlayerState (которое пришло с сервера) + const abilityId = button.dataset.abilityId; + const abilityDataFromGameData = actingPlayerAbilities?.find(ab => ab.id === abilityId); + + // Если игра неактивна, нет данных о бойце, способностях или ресурсе, дизейблим кнопку. + if (!(button instanceof HTMLButtonElement) || !isGameActive || !canThisClientAct || !actingPlayerState || !actingPlayerAbilities || !actingPlayerResourceName || !abilityDataFromGameData) { if(button instanceof HTMLButtonElement) button.disabled = true; button.classList.remove(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced', configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown'); const cooldownDisplay = button.querySelector('.ability-cooldown-display'); if (cooldownDisplay) cooldownDisplay.style.display = 'none'; - return; - } - const abilityId = button.dataset.abilityId; - const ability = actingPlayerAbilities.find(ab => ab.id === abilityId); - if (!ability) { - button.disabled = true; // Если способность не найдена в данных (ошибка) - button.classList.remove(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced', configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown'); - const cooldownDisplay = button.querySelector('.ability-cooldown-display'); - if (cooldownDisplay) cooldownDisplay.style.display = 'none'; - return; + return; // Пропускаем дальнейшую логику обновления кнопки, если она должна быть disabled по базовым условиям } - // Проверяем условия доступности способности - const hasEnoughResource = actingPlayerState.currentResource >= ability.cost; - // Бафф уже активен (для баффов, которые не стакаются) - const isBuffAlreadyActive = ability.type === configGlobal.ACTION_TYPE_BUFF && actingPlayerState.activeEffects?.some(eff => eff.id === ability.id); - // На общем кулдауне - const isOnCooldown = (actingPlayerState.abilityCooldowns?.[ability.id] || 0) > 0; + // Проверяем условия доступности способности из актуального состояния игры (actingPlayerState) + const hasEnoughResource = actingPlayerState.currentResource >= abilityDataFromGameData.cost; + const isOnCooldown = (actingPlayerState.abilityCooldowns?.[abilityId] || 0) > 0; // Проверяем КД по ID способности из актуального состояния // Под полным безмолвием const isGenerallySilenced = actingPlayerState.activeEffects?.some(eff => eff.isFullSilence && eff.turnsLeft > 0); - // Под специфическим заглушением этой способности - const specificSilenceEffect = actingPlayerState.disabledAbilities?.find(dis => dis.abilityId === abilityId && dis.turnsLeft > 0); - const isSpecificallySilenced = !!specificSilenceEffect; - const isSilenced = isGenerallySilenced || isSpecificallySilenced; // Считается заглушенным, если под полным или специфическим безмолвием + // Под специфическим заглушением этой способности (ищем в disabledAbilities актуального состояния) + const isAbilitySpecificallySilenced = actingPlayerState.disabledAbilities?.some(dis => dis.abilityId === abilityId && dis.turnsLeft > 0); + const isSilenced = isGenerallySilenced || isAbilitySpecificallySilenced; // Считается заглушенным, если под полным или специфическим безмолвием // Определяем длительность безмолвия для отображения (берем из специфического, если есть, иначе из полного) - const silenceTurnsLeft = isSpecificallySilenced - ? (specificSilenceEffect?.turnsLeft || 0) + const silenceTurnsLeft = isAbilitySpecificallySilenced + ? (actingPlayerState.disabledAbilities?.find(dis => dis.abilityId === abilityId)?.turnsLeft || 0) : (isGenerallySilenced ? (actingPlayerState.activeEffects.find(eff => eff.isFullSilence)?.turnsLeft || 0) : 0); + // Нельзя кастовать бафф, если он уже активен (для баффов, которые не стакаются) + const isBuffAlreadyActive = abilityDataFromGameData.type === configGlobal.ACTION_TYPE_BUFF && actingPlayerState.activeEffects?.some(eff => eff.id === abilityId); + // Нельзя кастовать дебафф на цель, если он уже на ней (для определенных дебаффов) - // Нужна проверка состояния ОППОНЕНТА этого клиента (т.е. цели) - const opponentStateForDebuffCheck = currentGameState[opponentActualSlotId]; - let isDebuffAlreadyOnTarget = false; - const isTargetedDebuffAbility = ability.id === configGlobal.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configGlobal.ABILITY_ID_ALMAGEST_DEBUFF; - if (isTargetedDebuffAbility && opponentStateForDebuffCheck && opponentStateForDebuffCheck.activeEffects) { - const effectIdForDebuff = 'effect_' + ability.id; // Ищем эффект с префиксом effect_ на цели - isDebuffAlreadyOnTarget = opponentStateForDebuffCheck.activeEffects.some(e => e.id === effectIdForDebuff); - } + const isTargetedDebuffAbility = abilityId === configGlobal.ABILITY_ID_SEAL_OF_WEAKNESS || abilityId === configGlobal.ABILITY_ID_ALMAGEST_DEBUFF; + const effectIdForDebuff = 'effect_' + abilityId; // Ищем эффект с префиксом effect_ на цели (оппоненте) + const isDebuffAlreadyOnTarget = isTargetedDebuffAbility && opponentStateForDebuffCheck && opponentStateForDebuffCheck.activeEffects?.some(e => e.id === effectIdForDebuff); - // Кнопка активна, если: - // - Это ход этого клиента - // - Игра активна + // Кнопка способности активна, если: + // - Это ход этого клиента (проверено выше: canThisClientAct) + // - Игра активна (проверено выше: isGameActive) // - Достаточно ресурса // - Бафф не активен (если это бафф) // - Не на кулдауне - // - Не под безмолвием (полным или специфическим) + // - Не под безмолвием (полным или специфическим) <--- ЭТО УСЛОВИЕ ОСТАЕТСЯ ДЛЯ СПОСОБНОСТЕЙ // - Дебафф не активен на цели (если это такой дебафф) - button.disabled = !(canThisClientAct && isGameActive) || - !hasEnoughResource || + button.disabled = !hasEnoughResource || isBuffAlreadyActive || - isSilenced || + isSilenced || // Способности БЛОКИРУЮТСЯ полным безмолвием isOnCooldown || isDebuffAlreadyOnTarget; - // Управление классами для стилизации кнопки + // Управление классами для стилизации кнопки (применяются независимо от окончательного disabled состояния) button.classList.remove(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced', configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown'); const cooldownDisplay = button.querySelector('.ability-cooldown-display'); if (isOnCooldown) { button.classList.add(configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown'); - if (cooldownDisplay) { cooldownDisplay.textContent = `КД: ${actingPlayerState.abilityCooldowns[ability.id]}`; cooldownDisplay.style.display = 'block'; } + if (cooldownDisplay) { cooldownDisplay.textContent = `КД: ${actingPlayerState.abilityCooldowns[abilityId]}`; cooldownDisplay.style.display = 'block'; } } else if (isSilenced) { button.classList.add(configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced'); if (cooldownDisplay) { @@ -482,49 +585,64 @@ } } else { if (cooldownDisplay) cooldownDisplay.style.display = 'none'; // Скрываем, если нет ни КД, ни безмолвия - // Добавляем классы, если действие возможно, но есть ограничения (недостаточно ресурса, бафф активен, дебафф на цели) - // Эти классы используются для визуальной обратной связи, когда кнопка *не* задизейблена по КД или безмолвию. - // Если кнопка disabled из-за !hasEnoughResource, классы not-enough-resource и buff-is-active все равно могут быть добавлены. - button.classList.toggle(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', !hasEnoughResource && !isBuffAlreadyActive && !isDebuffAlreadyOnTarget); - button.classList.toggle(configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', isBuffAlreadyActive && !isDebuffAlreadyOnTarget); - // Если дебафф уже на цели, но кнопка не задизейблена по другим причинам, можно добавить отдельный класс - // button.classList.toggle('debuff-on-target', isDebuffAlreadyOnTarget && !button.disabled); + + // Добавляем классы для визуальной обратной связи, ЕСЛИ кнопка НЕ задизейблена по КД или Безмолвию + // (т.е. эти классы показывают *другие* причины, по которым кнопка может быть disabled) + if (!isOnCooldown && !isSilenced) { + button.classList.toggle(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', !hasEnoughResource); + button.classList.toggle(configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', isBuffAlreadyActive); + // Если дебафф уже на цели, но кнопка не задизейблена по другим причинам, можно добавить отдельный класс для стилизации + // button.classList.toggle('debuff-on-target', isDebuffAlreadyOnTarget); + } } // Обновление title (всплывающей подсказки) - показываем полную информацию - let titleText = `${ability.name} (${ability.cost} ${actingPlayerResourceName})`; - let descriptionTextFull = ability.description; // Используем описание, пришедшее с сервера + // Используем abilityDataFromGameData для базовой информации + let titleText = `${abilityDataFromGameData.name} (${abilityDataFromGameData.cost} ${actingPlayerResourceName})`; + let descriptionTextFull = abilityDataFromGameData.description; // Используем описание, пришедшее с сервера + + // Если есть функция описания, используем ее + if (typeof abilityDataFromGameData.descriptionFunction === 'function') { + // Передаем конфиг и статы оппонента (цели) для генерации описания + const opponentBaseStatsForDesc = gameDataGlobal.opponentBaseStats; // Статы оппонента этого клиента + descriptionTextFull = abilityDataFromGameData.descriptionFunction(configGlobal, opponentBaseStatsForDesc); + } + if (descriptionTextFull) titleText += ` - ${descriptionTextFull}`; - let abilityBaseCooldown = ability.cooldown; // Исходный КД из данных - // Учитываем внутренние КД Баларда, если это способность Баларда (хотя игроку AI способности не отображаются, но для полноты) - // if (actingPlayerState.characterKey === 'balard') { - // if (ability.id === configGlobal.ABILITY_ID_BALARD_SILENCE) abilityBaseCooldown = configGlobal.BALARD_SILENCE_INTERNAL_COOLDOWN; - // else if (ability.id === configGlobal.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') abilityBaseCooldown = ability.internalCooldownValue; - // } - + // Добавляем информацию об исходном КД из данных способности + let abilityBaseCooldown = abilityDataFromGameData.cooldown; if (typeof abilityBaseCooldown === 'number' && abilityBaseCooldown > 0) { - titleText += ` (КД: ${abilityBaseCooldown} х.)`; + titleText += ` (Исходный КД: ${abilityBaseCooldown} х.)`; } // Добавляем информацию о текущем состоянии (КД, безмолвие, активный бафф/debuff) в тултип, если применимо if (isOnCooldown) { - titleText += ` | На перезарядке! Осталось: ${actingPlayerState.abilityCooldowns[ability.id]} х.`; + titleText += ` | На перезарядке! Осталось: ${actingPlayerState.abilityCooldowns[abilityId]} х.`; } if (isSilenced) { titleText += ` | Под безмолвием! Осталось: ${silenceTurnsLeft} х.`; } if (isBuffAlreadyActive) { - const activeEffect = actingPlayerState.activeEffects?.find(eff => eff.id === abilityId); - titleText += ` | Эффект уже активен${activeEffect ? ` (${activeEffect.turnsLeft} х.)` : ''}.`; + const activeEffect = actingPlayerState.activeEffects?.find(eff => eff.id === abilityId); // Ищем активный эффект по ID способности + // Если бафф имеет свойство 'justCast' и наложен в этом ходу, он не "готов" сработать на ЭТОМ ходу. + // Это может быть важно для тултипа, если нужно отличать "только что наложен" от "готов к следующему действию". + // Для "Силы Природы" (isDelayed=true) состояние "активен" означает "готов сработать на следующую атаку". + const isDelayedBuffReady = isBuffAlreadyActive && activeEffect && activeEffect.isDelayed && !activeEffect.justCast && activeEffect.turnsLeft > 0; + + if (isDelayedBuffReady) { + titleText += ` | Эффект активен и сработает при следующей базовой атаке (${activeEffect.turnsLeft} х.)`; + } else if (isBuffAlreadyActive) { + titleText += ` | Эффект уже активен${activeEffect ? ` (${activeEffect.turnsLeft} х.)` : ''}. Нельзя применить повторно.`; + } } if (isDebuffAlreadyOnTarget && opponentStateForDebuffCheck) { const activeDebuff = opponentStateForDebuffCheck.activeEffects?.find(e => e.id === 'effect_' + abilityId); - titleText += ` | Эффект уже наложен на ${opponentBaseStatsForUI?.name || 'противника'}${activeDebuff ? ` (${activeDebuff.turnsLeft} х.)` : ''}.`; + titleText += ` | Эффект уже наложен на ${gameDataGlobal.opponentBaseStats?.name || 'противника'}${activeDebuff ? ` (${activeDebuff.turnsLeft} х.)` : ''}.`; } if (!hasEnoughResource) { - titleText += ` | Недостаточно ${actingPlayerResourceName} (${actingPlayerState.currentResource}/${ability.cost})`; + titleText += ` | Недостаточно ${actingPlayerResourceName} (${actingPlayerState.currentResource}/${abilityDataFromGameData.cost})`; } @@ -539,19 +657,18 @@ * @param {string|null} opponentCharacterKeyFromClient - Ключ персонажа оппонента с т.з. клиента. * @param {object} [data=null] - Полный объект данных из события gameOver (включает disconnectedCharacterName и т.д.) */ - function showGameOver(playerWon, reason = "", opponentCharacterKeyFromClient = null, data = null) { // ИСПРАВЛЕНО: Добавлен аргумент data + function showGameOver(playerWon, reason = "", opponentCharacterKeyFromClient = null, data = null) { const config = window.GAME_CONFIG || {}; - // Используем gameData, сохраненное в client.js, так как оно отражает перспективу этого клиента - const clientSpecificGameData = window.gameData; + const clientSpecificGameData = window.gameData; // Используем gameData, сохраненное в client.js const currentActualGameState = window.gameState; // Самое актуальное состояние игры const gameOverScreenElement = uiElements.gameOver.screen; console.log(`[UI.JS DEBUG] showGameOver CALLED. PlayerWon: ${playerWon}, Reason: ${reason}`); - console.log(`[UI.JS DEBUG] Current window.gameState.isGameOver: ${currentActualGameState?.isGameOver}`); + console.log(`[UI.JS DEBUG] captured currentActualGameState?.isGameOver at call time: ${currentActualGameState?.isGameOver}`); // Log state at call time console.log(`[UI.JS DEBUG] Opponent Character Key (from client via param): ${opponentCharacterKeyFromClient}`); console.log(`[UI.JS DEBUG] My Character Name (from window.gameData): ${clientSpecificGameData?.playerBaseStats?.name}`); console.log(`[UI.JS DEBUG] Opponent Character Name (from window.gameData): ${clientSpecificGameData?.opponentBaseStats?.name}`); - console.log(`[UI.JS DEBUG] Full game over data received:`, data); // Добавьте этот лог + console.log(`[UI.JS DEBUG] Full game over data received:`, data); if (!gameOverScreenElement) { @@ -560,7 +677,6 @@ } const resultMsgElement = uiElements.gameOver.message; - // Имена для сообщения берем из clientSpecificGameData, т.к. они уже "перевернуты" для каждого клиента const myNameForResult = clientSpecificGameData?.playerBaseStats?.name || "Игрок"; const opponentNameForResult = clientSpecificGameData?.opponentBaseStats?.name || "Противник"; @@ -568,8 +684,8 @@ let winText = `Победа! ${myNameForResult} празднует!`; let loseText = `Поражение! ${opponentNameForResult} оказался(лась) сильнее!`; if (reason === 'opponent_disconnected') { - // Определяем, кто отключился, по данным из события gameOver let disconnectedName = "Противник"; + // Если в данных gameOver есть имя отключившегося персонажа, используем его if (data && data.disconnectedCharacterName) { disconnectedName = data.disconnectedCharacterName; } else { @@ -578,10 +694,25 @@ } winText = `${disconnectedName} покинул(а) игру. Победа присуждается вам!`; - // Если оппонент отключился, а мы проиграли (технически такое возможно, если сервер так решит) - // То текст поражения можно оставить стандартным или специфичным. - // Пока оставим стандартный, если playerWon = false и reason = opponent_disconnected. + // В PvP, если оппонент отключился, а текущий игрок проиграл (что странно, но возможно), + // сообщение о поражении может быть стандартным или специфичным. + // В AI режиме, если игрок отключился, нет формального победителя AI. + // Пусть будет стандартное поражение, если playerWon === false + if (!playerWon) { + // Возможно, специфичный текст для дисконнекта, когда ты проиграл? + // loseText = `Игра завершена из-за отключения ${disconnectedName}. Вы проиграли.` + } + } else if (reason === 'hp_zero') { + // Стандартное завершение по HP - тексты определены выше } + // Добавьте обработку других причин завершения, если они будут + else { + // Неизвестная причина завершения + winText = `Игра окончена. Победа! (${reason})`; + loseText = `Игра окончена. Поражение. (${reason})`; + } + + resultMsgElement.textContent = playerWon ? winText : loseText; resultMsgElement.style.color = playerWon ? 'var(--heal-color)' : 'var(--damage-color)'; } @@ -589,74 +720,104 @@ const opponentPanelElement = uiElements.opponent.panel; if (opponentPanelElement) { // Сначала убеждаемся, что анимация растворения снята, если она была активна от предыдущей попытки - // console.log(`[UI.JS DEBUG] Opponent panel classList before potential dissolve: ${opponentPanelElement.className}`); + // и не должна применяться сейчас. opponentPanelElement.classList.remove('dissolving'); - opponentPanelElement.offsetHeight; // Trigger reflow to reset state instantly + opponentPanelElement.style.transition = 'none'; // Временно отключаем transition + opponentPanelElement.offsetHeight; // Trigger reflow to apply style instantly - // Используем opponentCharacterKeyFromClient, так как это ключ реального персонажа оппонента с т.з. клиента - const keyForDissolveEffect = opponentCharacterKeyFromClient; + // Используем characterKey проигравшего (переданный из GameInstance), + // так как анимация растворения должна быть специфична для проигравшего персонажа, + // который может быть Балардом или Альмагест. + const loserCharacterKeyForDissolve = data?.loserCharacterKey; - // Применяем анимацию растворения только если игра окончена, игрок выиграл, и это не дисконнект, - // и противник был Балардом или Альмагест (у которых есть эта анимация). - if (currentActualGameState && currentActualGameState.isGameOver === true && playerWon && reason !== 'opponent_disconnected') { - if (keyForDissolveEffect === 'balard' || keyForDissolveEffect === 'almagest') { - console.log(`[UI.JS DEBUG] ADDING .dissolving to opponent panel. Conditions met.`); + // Применяем анимацию растворения только если игра окончена, игрок победил, + // и проигравший был Балардом или Альмагест (у которых есть эта анимация). + // Исключаем случай дисконнекта, если анимация должна быть только при "убийстве" по HP. + // В текущем CSS анимация растворения не зависит от причины, но зависит от класса 'dissolving'. + // Добавляем класс, если игра окончена, игрок победил, и проигравший персонаж - Балард или Альмагест. + // Если игра окончена И игрок проиграл И оппонент был Балардом/Альмагест, но игрок проиграл, анимация растворения НЕ применяется к панели оппонента. + // Поэтому условие playerWon && ... корректно. + if (currentActualGameState && currentActualGameState.isGameOver === true && playerWon) { + // Проверяем, является ли проигравший (т.е. оппонент этого клиента) Балардом или Альмагест + if (loserCharacterKeyForDissolve === 'balard' || loserCharacterKeyForDissolve === 'almagest') { + console.log(`[UI.JS DEBUG] ADDING .dissolving to opponent panel.`); opponentPanelElement.classList.add('dissolving'); - // Убеждаемся, что панель станет полностью прозрачной и сместится после анимации - opponentPanelElement.style.opacity = '0'; - // opponentPanelElement.style.transform = 'scale(0.9) translateY(20px)'; // Трансформация уже в CSS анимации + // Убеждаемся, что панель станет полностью прозрачной и сместится после анимации. + // Конечные стили (opacity: 0, transform) могут быть заданы в CSS для класса .dissolving, + // но их можно также установить здесь после добавления класса для гарантии. + opponentPanelElement.style.opacity = '0'; // Конечный стиль для transition + // opponentPanelElement.style.transform = 'scale(0.9) translateY(20px)'; // Конечный стиль для transition, если нужен } else { - console.log(`[UI.JS DEBUG] NOT adding .dissolving (opponent key mismatch for dissolve effect: ${keyForDissolveEffect} or reason: ${reason}).`); - // Если анимация не применяется, убеждаемся, что панель видна + console.log(`[UI.JS DEBUG] NOT adding .dissolving (loser key mismatch: ${loserCharacterKeyForDissolve}).`); + // Если анимация не применяется, убеждаемся, что панель полностью видна opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)'; } } else { - console.log(`[UI.JS DEBUG] NOT adding .dissolving. Conditions NOT met: isGameOver=${currentActualGameState?.isGameOver}, playerWon=${playerWon}, reason=${reason}.`); - // Если игра не окончена или игрок проиграл/оппонент отключился, убеждаемся, что панель видна + console.log(`[UI.JS DEBUG] NOT adding .dissolving. Conditions NOT met: isGameOver=${currentActualGameState?.isGameOver}, playerWon=${playerWon}.`); + // Если игра не окончена или игрок проиграл, убеждаемся, что панель полностью видна opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)'; } - // console.log(`[UI.JS DEBUG] Opponent panel classList FINAL in showGameOver (before timeout): ${opponentPanelElement.className}`); + opponentPanelElement.style.transition = ''; // Восстанавливаем transition после установки начальных/конечных стилей + + } // Показываем модальное окно конца игры с небольшой задержкой - // ИСПРАВЛЕНО: Передаем аргументы в колбэк, чтобы не полагаться на глобальный gameState - setTimeout((finalState, won, gameOverReason, opponentKeyForModalParam, eventData) => { // Названия аргументов, чтобы избежать путаницы - console.log("[UI.JS DEBUG] Timeout callback fired."); - console.log("[UI.JS DEBUG] State in timeout:", finalState); - console.log("[UI.JS DEBUG] isGameOver in state:", finalState?.isGameOver); + // Передаем аргументы в колбэк, чтобы не полагаться на глобальный gameState в момент срабатывания setTimeout + setTimeout((finalStateInTimeout, wonInTimeout, reasonInTimeout, keyInTimeout, dataInTimeout) => { // Use distinct names in timeout + console.log("[UI.JS DEBUG] Timeout callback fired for showGameOver."); + console.log("[UI.JS DEBUG] State object received in timeout:", finalStateInTimeout); // Check the whole object + console.log("[UI.JS DEBUG] isGameOver in state (TIMEOUT):", finalStateInTimeout?.isGameOver); // Check property + console.log("[UI.JS DEBUG] playerWon flag (TIMEOUT):", wonInTimeout); // Check playerWon flag passed - // Перепроверяем состояние перед показом на случай быстрых обновлений - if (finalState && finalState.isGameOver === true) { // Используем переданный state - console.log(`[UI.JS DEBUG] Condition (finalState && finalState.isGameOver === true) IS TRUE. Attempting to show modal.`); + + // Проверяем условия для показа модального окна: элемент существует И состояние игры помечено как оконченное + // ИСПРАВЛЕНО: Убрана проверка gameOverScreenElement.offsetParent !== null + if (gameOverScreenElement && finalStateInTimeout && finalStateInTimeout.isGameOver === true) { + console.log(`[UI.JS DEBUG] Modal SHOW condition met: gameOverScreenElement exists, finalState exists, isGameOver is true.`); // Убеждаемся, что modal не имеет display: none перед запуском transition opacity - // display: none полностью убирает элемент из потока и не позволяет анимировать opacity - // Переводим display в flex, если он был hidden (display: none !important в CSS) if (gameOverScreenElement.classList.contains(config.CSS_CLASS_HIDDEN || 'hidden')) { gameOverScreenElement.classList.remove(config.CSS_CLASS_HIDDEN || 'hidden'); } - // Убеждаемся, что opacity 0 для начала анимации - gameOverScreenElement.style.opacity = '0'; - // Убеждаемся, что display корректен - gameOverScreenElement.style.display = 'flex'; // Или какой там display в CSS для .modal + // Применяем display: flex (или другой нужный) только один раз, если нужно + if(window.getComputedStyle(gameOverScreenElement).display === 'none') { + gameOverScreenElement.style.display = 'flex'; // Или какой там display в CSS для .modal + } + gameOverScreenElement.style.opacity = '0'; // Start from hidden opacity requestAnimationFrame(() => { - // Применяем opacity 1 после display flex для анимации + console.log("[UI.JS DEBUG] RequestAnimationFrame callback fired, animating modal."); + // Animate to visible gameOverScreenElement.style.opacity = '1'; - // Запускаем анимацию контента модального окна (scale/translate) if (uiElements.gameOver.modalContent) { + uiElements.gameOver.modalContent.style.transition = 'transform 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.2), opacity 0.4s ease-out'; // Убеждаемся, что transition включен uiElements.gameOver.modalContent.style.transform = 'scale(1) translateY(0)'; uiElements.gameOver.modalContent.style.opacity = '1'; + // uiElements.gameOver.modalContent.style.transition = ''; // Можно и так, если не отключали ранее } }); } else { - console.log(`[UI.JS DEBUG] Condition (finalState && finalState.isGameOver === true) IS FALSE. Modal will NOT be shown.`); - // Убеждаемся, что модалка скрыта, если условия больше не выполняются - gameOverScreenElement.classList.add(config.CSS_CLASS_HIDDEN || 'hidden'); - gameOverScreenElement.style.opacity = '0'; // Убеждаемся, что opacity сброшен + console.log(`[UI.JS DEBUG] Modal SHOW condition NOT met.`); + console.log(`[UI.JS DEBUG] Details: gameOverScreenElement=${!!gameOverScreenElement}, finalState=${!!finalStateInTimeout}, finalState?.isGameOver=${finalStateInTimeout?.isGameOver}. Hiding modal.`); // More details + // Убеждаемся, что модалка скрыта, если условия не выполняются + if (gameOverScreenElement) { + // Ensure transition is off when hiding instantly + gameOverScreenElement.style.transition = 'none'; + if (uiElements.gameOver.modalContent) uiElements.gameOver.modalContent.style.transition = 'none'; + + gameOverScreenElement.classList.add(config.CSS_CLASS_HIDDEN || 'hidden'); + gameOverScreenElement.style.opacity = '0'; + if (uiElements.gameOver.modalContent) { + uiElements.gameOver.modalContent.style.transform = 'scale(0.8) translateY(30px)'; + uiElements.gameOver.modalContent.style.opacity = '0'; + } + // Trigger reflow to ensure transition is off before hiding + gameOverScreenElement.offsetHeight; + } } - }, config.DELAY_BEFORE_VICTORY_MODAL || 1500, currentActualGameState, playerWon, reason, opponentCharacterKeyFromClient, data); // ИСПРАВЛЕНО: Передаем аргументы + }, config.DELAY_BEFORE_VICTORY_MODAL || 1500, currentActualGameState, playerWon, reason, opponentCharacterKeyFromClient, data); // Pass captured state and other values }