diff --git a/bc.js b/bc.js index 5474738..72fffc8 100644 --- a/bc.js +++ b/bc.js @@ -1,42 +1,50 @@ // bc.js (или server.js - ваш основной файл сервера) -// КОД ОСТАЕТСЯ БЕЗ ИЗМЕНЕНИЙ ОТНОСИТЕЛЬНО ПРЕДЫДУЩЕЙ ВЕРСИИ, -// ГДЕ УЖЕ БЫЛА ДОБАВЛЕНА ПЕРЕДАЧА characterKey В GameManager - const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const path = require('path'); // Серверные модули -const GameManager = require('./server_modules/gameManager'); // GameManager будет изменен -const authController = require('./server_modules/auth'); +const GameManager = require('./server_modules/gameManager'); +const authController = require('./server_modules/auth'); // Ваш модуль аутентификации +// const GAME_CONFIG = require('./server_modules/config'); // Не используется напрямую здесь, но может быть полезен для отладки -const hostname = 'localhost'; +const hostname = 'localhost'; // или '0.0.0.0' для доступа извне const app = express(); const server = http.createServer(app); -const io = socketIo(server); +const io = socketIo(server, { + cors: { + origin: "*", // Разрешить все источники для простоты разработки. В продакшене укажите конкретный домен клиента. + methods: ["GET", "POST"] + } +}); const PORT = process.env.PORT || 3200; +// Статическое обслуживание файлов из папки 'public' app.use(express.static(path.join(__dirname, 'public'))); -const gameManager = new GameManager(io); // GameManager будет содержать новую логику +// Создание экземпляра GameManager +const gameManager = new GameManager(io); io.on('connection', (socket) => { - console.log('[Server] New client connected:', socket.id); + console.log(`[Server BC.JS] New client connected: ${socket.id}`); + // При подключении нового клиента, отправляем ему текущий список доступных PvP игр const availableGames = gameManager.getAvailablePvPGamesListForClient(); socket.emit('availablePvPGamesList', availableGames); + // Обработчик запроса на обновление списка PvP игр socket.on('requestPvPGameList', () => { const currentAvailableGames = gameManager.getAvailablePvPGamesListForClient(); socket.emit('availablePvPGamesList', currentAvailableGames); }); + // --- Аутентификация --- socket.on('register', async (data) => { console.log(`[Server BC.JS] Received 'register' event from ${socket.id} with username: ${data?.username}`); if (!data || typeof data.username !== 'string' || typeof data.password !== 'string') { - socket.emit('registerResponse', { success: false, message: 'Некорректные данные запроса.' }); + socket.emit('registerResponse', { success: false, message: 'Некорректные данные запроса для регистрации.' }); return; } const result = await authController.registerUser(data.username, data.password); @@ -46,12 +54,13 @@ io.on('connection', (socket) => { socket.on('login', async (data) => { console.log(`[Server BC.JS] Received 'login' event from ${socket.id} with username: ${data?.username}`); if (!data || typeof data.username !== 'string' || typeof data.password !== 'string') { - socket.emit('loginResponse', { success: false, message: 'Некорректные данные запроса.' }); + socket.emit('loginResponse', { success: false, message: 'Некорректные данные запроса для входа.' }); return; } const result = await authController.loginUser(data.username, data.password); if (result.success) { - socket.userData = { userId: result.userId, username: result.username }; // Сохраняем userId + // Сохраняем данные пользователя в объекте сокета для последующего использования + socket.userData = { userId: result.userId, username: result.username }; console.log(`[Server BC.JS] User ${result.username} (ID: ${result.userId}) associated with socket ${socket.id}. Welcome!`); } socket.emit('loginResponse', result); @@ -61,21 +70,24 @@ io.on('connection', (socket) => { const username = socket.userData?.username || socket.id; console.log(`[Server BC.JS] Received 'logout' event from ${username}.`); if (socket.userData) { - gameManager.handleDisconnect(socket.id); // Обработает выход из игр - delete socket.userData; + // При выходе пользователя, обрабатываем его возможное участие в играх + gameManager.handleDisconnect(socket.id, socket.userData.userId); // Используем userId для более точной обработки + delete socket.userData; // Удаляем данные пользователя из сокета console.log(`[Server BC.JS] User data cleared for ${username}.`); } + // Можно отправить подтверждение выхода, если нужно + // socket.emit('logoutResponse', { success: true, message: 'Вы успешно вышли.' }); }); + // --- Управление Играми --- socket.on('createGame', (data) => { if (!socket.userData) { socket.emit('gameError', { message: "Ошибка: Вы не авторизованы для создания игры." }); return; } - const mode = data?.mode || 'ai'; - const characterKey = (data?.characterKey === 'almagest') ? 'almagest' : 'elena'; + const mode = data?.mode || 'ai'; // 'ai' или 'pvp' + const characterKey = (data?.characterKey === 'almagest') ? 'almagest' : 'elena'; // По умолчанию Елена console.log(`[Server BC.JS] User ${socket.userData.username} (socket: ${socket.id}) requests createGame. Mode: ${mode}, Character: ${characterKey}`); - // Передаем socket.userData.userId в GameManager, если он нужен для идентификации пользователя между сессиями gameManager.createGame(socket, mode, characterKey, socket.userData.userId); }); @@ -86,7 +98,6 @@ io.on('connection', (socket) => { } console.log(`[Server BC.JS] User ${socket.userData.username} (socket: ${socket.id}) requests joinGame for ID: ${data?.gameId}`); if (data && typeof data.gameId === 'string') { - // Передаем socket.userData.userId gameManager.joinGame(socket, data.gameId, socket.userData.userId); } else { socket.emit('gameError', { message: 'Ошибка присоединения: неверный формат ID игры.' }); @@ -100,38 +111,56 @@ io.on('connection', (socket) => { } const characterKey = (data?.characterKey === 'almagest') ? 'almagest' : 'elena'; console.log(`[Server BC.JS] User ${socket.userData.username} (socket: ${socket.id}) requests findRandomGame. Preferred Character: ${characterKey}`); - // Передаем socket.userData.userId gameManager.findAndJoinRandomPvPGame(socket, characterKey, socket.userData.userId); }); + // --- Игровые Действия --- socket.on('playerAction', (data) => { - if (!socket.userData) return; + if (!socket.userData) { + // Если пользователь не авторизован, но пытается совершить действие (маловероятно при правильной логике клиента) + socket.emit('gameError', { message: "Ошибка: Вы не авторизованы для совершения этого действия." }); + return; + } + // GameManager сам проверит, принадлежит ли этот сокет к активной игре gameManager.handlePlayerAction(socket.id, data); }); - socket.on('requestRestart', (data) => { - if (!socket.userData) { - socket.emit('gameError', { message: "Ошибка: Вы не авторизованы для запроса рестарта." }); - return; - } - console.log(`[Server BC.JS] User ${socket.userData.username} (socket: ${socket.id}) requests restart for game ID: ${data?.gameId}`); - if (data && typeof data.gameId === 'string') { - gameManager.requestRestart(socket.id, data.gameId); - } else { - socket.emit('gameError', { message: 'Ошибка рестарта: неверный формат ID игры.' }); - } - }); + // Обработчик 'requestRestart' удален, так как эта функциональность заменена на "возврат в меню" + // --- Отключение Клиента --- socket.on('disconnect', (reason) => { const username = socket.userData?.username || socket.id; - console.log(`[Server] Client ${username} disconnected. Reason: ${reason}. Socket ID: ${socket.id}`); + console.log(`[Server BC.JS] Client ${username} disconnected. Reason: ${reason}. Socket ID: ${socket.id}`); // Передаем userId, если он есть, для более точной обработки в GameManager + // (например, для удаления его ожидающих игр или корректного завершения активной игры) const userId = socket.userData?.userId; gameManager.handleDisconnect(socket.id, userId); - // socket.userData автоматически очистится для этого объекта socket + // socket.userData автоматически очистится для этого объекта socket при его удалении из io.sockets }); + + // Для отладки: вывод списка активных игр каждые N секунд + // setInterval(() => { + // console.log("--- Active Games ---"); + // const activeGames = gameManager.getActiveGamesList(); + // if (activeGames.length > 0) { + // activeGames.forEach(game => { + // console.log(`ID: ${game.id}, Mode: ${game.mode}, Players: ${game.playerCount}, GameOver: ${game.isGameOver}, P1: ${game.playerSlot}, P2: ${game.opponentSlot}, Owner: ${game.ownerUserId}, Pending: ${game.pending}`); + // }); + // } else { + // console.log("No active games."); + // } + // console.log("--- Pending PvP Games IDs ---"); + // console.log(gameManager.pendingPvPGames.map(id => id.substring(0,8))); + // console.log("--- User to Pending Game Map ---"); + // console.log(gameManager.userToPendingGame); + // console.log("---------------------"); + // }, 30000); // Каждые 30 секунд }); server.listen(PORT, hostname, () => { - console.log(`Server listening on http://${hostname}:${PORT}`); + console.log(`==== Medieval Clash Server ====`); + console.log(` Listening on http://${hostname}:${PORT}`); + console.log(` Public files served from: ${path.join(__dirname, 'public')}`); + console.log(` Waiting for connections...`); + console.log(`===============================`); }); \ No newline at end of file diff --git a/public/index.html b/public/index.html index d39f2f9..51d02a5 100644 --- a/public/index.html +++ b/public/index.html @@ -9,196 +9,6 @@ - @@ -233,7 +43,7 @@ -
@@ -323,7 +129,6 @@
-

Ход: Игрок 1

@@ -332,7 +137,6 @@

Способности

-

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

@@ -346,10 +150,8 @@
- Аватар игрока 2
-

Балард

@@ -364,7 +166,6 @@
-
@@ -405,13 +206,16 @@
- + \ No newline at end of file diff --git a/public/js/client.js b/public/js/client.js index 894c616..1d07c20 100644 --- a/public/js/client.js +++ b/public/js/client.js @@ -7,14 +7,14 @@ document.addEventListener('DOMContentLoaded', () => { // --- Состояние клиента --- let currentGameState = null; - let myPlayerId = null; // Технический ID слота ('player' или 'opponent') - let myCharacterKey = null; // Ключ моего персонажа ('elena' или 'almagest') - let opponentCharacterKey = null; // Ключ персонажа оппонента + let myPlayerId = null; // Технический ID слота, который занимает ЭТОТ клиент ('player' или 'opponent') + let myCharacterKey = null; + let opponentCharacterKey = null; let currentGameId = null; - let playerBaseStatsServer = null; // Статы персонажа, которым УПРАВЛЯЕТ этот клиент - let opponentBaseStatsServer = null; // Статы персонажа-противника этого клиента - let playerAbilitiesServer = null; // Способности персонажа, которым УПРАВЛЯЕТ этот клиент - let opponentAbilitiesServer = null; // Способности персонажа-противника + let playerBaseStatsServer = null; // Статы персонажа, которым УПРАВЛЯЕТ этот клиент (приходят от сервера как data.playerBaseStats) + let opponentBaseStatsServer = null; // Статы персонажа-оппонента этого клиента (приходят от сервера как data.opponentBaseStats) + let playerAbilitiesServer = null; + let opponentAbilitiesServer = null; let isLoggedIn = false; let loggedInUsername = ''; @@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => { // Игровая Арена const gameWrapper = document.querySelector('.game-wrapper'); const attackButton = document.getElementById('button-attack'); - const restartGameButton = document.getElementById('restart-game-button'); + const returnToMenuButton = document.getElementById('return-to-menu-button'); const gameOverScreen = document.getElementById('game-over-screen'); console.log('Client.js DOMContentLoaded. Initializing elements...'); @@ -91,11 +91,26 @@ document.addEventListener('DOMContentLoaded', () => { function hideGameOverModal() { const hiddenClass = (window.GAME_CONFIG && window.GAME_CONFIG.CSS_CLASS_HIDDEN) ? window.GAME_CONFIG.CSS_CLASS_HIDDEN : 'hidden'; if (gameOverScreen && !gameOverScreen.classList.contains(hiddenClass)) { + console.log('[Client.js DEBUG] Hiding GameOver Modal.'); gameOverScreen.classList.add(hiddenClass); if (window.gameUI && gameUI.uiElements && gameUI.uiElements.gameOver && gameUI.uiElements.gameOver.modalContent) { gameUI.uiElements.gameOver.modalContent.style.transform = 'scale(0.8) translateY(30px)'; gameUI.uiElements.gameOver.modalContent.style.opacity = '0'; } + if (window.gameUI && window.gameUI.uiElements && window.gameUI.uiElements.opponent && window.gameUI.uiElements.opponent.panel) { + const opponentPanel = window.gameUI.uiElements.opponent.panel; + if (opponentPanel.classList.contains('dissolving')) { + console.log('[Client.js DEBUG] Removing .dissolving from opponent panel during hideGameOverModal.'); + opponentPanel.classList.remove('dissolving'); + const originalTransition = opponentPanel.style.transition; + opponentPanel.style.transition = 'none'; + opponentPanel.style.opacity = '1'; + opponentPanel.style.transform = 'scale(1) translateY(0)'; + requestAnimationFrame(() => { + opponentPanel.style.transition = originalTransition || ''; + }); + } + } } } @@ -162,6 +177,9 @@ document.addEventListener('DOMContentLoaded', () => { loggedInUsername = ''; currentGameId = null; currentGameState = null; myPlayerId = null; myCharacterKey = null; opponentCharacterKey = null; + playerBaseStatsServer = null; opponentBaseStatsServer = null; + playerAbilitiesServer = null; opponentAbilitiesServer = null; + window.gameState = null; window.gameData = null; window.myPlayerId = null; showAuthScreen(); setGameStatusMessage("Вы вышли из системы."); }); @@ -227,24 +245,32 @@ document.addEventListener('DOMContentLoaded', () => { } } - if (restartGameButton) { - restartGameButton.addEventListener('click', () => { - if (currentGameId && currentGameState && currentGameState.isGameOver && isLoggedIn) { - socket.emit('requestRestart', { gameId: currentGameId }); - setGameStatusMessage("Запрос на рестарт отправлен..."); - restartGameButton.disabled = true; - } else { - if (!currentGameId && isLoggedIn) { - alert("Ошибка: ID текущей игры не определен. Невозможно запросить рестарт."); - showGameSelectionScreen(loggedInUsername); - } else if (!isLoggedIn) { - showAuthScreen(); - } + if (returnToMenuButton) { + returnToMenuButton.addEventListener('click', () => { + if (!isLoggedIn) { + showAuthScreen(); + return; } + + console.log('[Client] Return to menu button clicked.'); + currentGameId = null; + currentGameState = null; + myPlayerId = null; + myCharacterKey = null; + opponentCharacterKey = null; + playerBaseStatsServer = null; + opponentBaseStatsServer = null; + playerAbilitiesServer = null; + opponentAbilitiesServer = null; + + window.gameState = null; + window.gameData = null; + window.myPlayerId = null; + + showGameSelectionScreen(loggedInUsername); }); } - // --- Функции для UI игры --- function initializeAbilityButtons() { const abilitiesGrid = document.getElementById('abilities-grid'); if (!abilitiesGrid || !window.gameUI || !window.GAME_CONFIG) { @@ -254,8 +280,8 @@ document.addEventListener('DOMContentLoaded', () => { abilitiesGrid.innerHTML = ''; const config = window.GAME_CONFIG; - const abilitiesToDisplay = playerAbilitiesServer; - const baseStatsForResource = playerBaseStatsServer; + const abilitiesToDisplay = playerAbilitiesServer; // Используем данные, сохраненные при gameStarted + const baseStatsForResource = playerBaseStatsServer; // Используем данные, сохраненные при gameStarted if (!abilitiesToDisplay || abilitiesToDisplay.length === 0 || !baseStatsForResource) { abilitiesGrid.innerHTML = '

Нет доступных способностей.

'; @@ -271,7 +297,7 @@ document.addEventListener('DOMContentLoaded', () => { let descriptionText = ability.description; if (typeof ability.descriptionFunction === 'function') { - const targetStatsForDesc = opponentBaseStatsServer; + const targetStatsForDesc = opponentBaseStatsServer; // Используем данные, сохраненные при gameStarted descriptionText = ability.descriptionFunction(config, targetStatsForDesc); } @@ -336,6 +362,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!isLoggedIn) { showAuthScreen(); } else { + console.log(`[Client] Reconnected as ${loggedInUsername}. Requesting state or showing game selection.`); showGameSelectionScreen(loggedInUsername); } }); @@ -343,15 +370,6 @@ document.addEventListener('DOMContentLoaded', () => { socket.on('disconnect', (reason) => { console.log('[Client] Disconnected from server:', reason); setGameStatusMessage(`Отключено от сервера: ${reason}. Попробуйте обновить страницу.`, true); - if (currentGameId) { - currentGameState = null; currentGameId = null; myPlayerId = null; - myCharacterKey = null; opponentCharacterKey = null; - if (isLoggedIn && gameWrapper && gameWrapper.style.display !== 'none') { - showGameSelectionScreen(loggedInUsername); - } else if (!isLoggedIn){ - showAuthScreen(); - } - } hideGameOverModal(); }); @@ -373,7 +391,7 @@ document.addEventListener('DOMContentLoaded', () => { socket.on('gameCreated', (data) => { if (!isLoggedIn) return; currentGameId = data.gameId; - myPlayerId = data.yourPlayerId; + myPlayerId = data.yourPlayerId; // Запоминаем наш технический ID слота console.log(`[Client] Game created/joined: ${currentGameId}, Mode: ${data.mode}, You (${loggedInUsername}) are in slot: ${myPlayerId}`); if (data.mode === 'pvp') { if (gameIdInput) gameIdInput.value = currentGameId; @@ -386,18 +404,39 @@ document.addEventListener('DOMContentLoaded', () => { socket.on('gameStarted', (data) => { if (!isLoggedIn) return; console.log('[Client] Event "gameStarted" received:', data); + + if (window.gameUI && window.gameUI.uiElements && window.gameUI.uiElements.opponent && window.gameUI.uiElements.opponent.panel) { + const opponentPanel = window.gameUI.uiElements.opponent.panel; + opponentPanel.classList.remove('dissolving'); + const originalTransition = opponentPanel.style.transition; + opponentPanel.style.transition = 'none'; + opponentPanel.style.opacity = '1'; + opponentPanel.style.transform = 'scale(1) translateY(0)'; + requestAnimationFrame(() => { + opponentPanel.style.transition = originalTransition || ''; + }); + console.log('[Client RESTART FIX Improved] Opponent panel styles explicitly reset for new game.'); + } + currentGameId = data.gameId; - myPlayerId = data.yourPlayerId; + myPlayerId = data.yourPlayerId; // Сервер присылает ID слота, который занимает ЭТОТ клиент currentGameState = data.initialGameState; + // Сервер присылает playerBaseStats и opponentBaseStats ОТНОСИТЕЛЬНО этого клиента + // То есть, data.playerBaseStats - это статы персонажа, которым управляет этот клиент + // data.opponentBaseStats - это статы персонажа-оппонента для этого клиента playerBaseStatsServer = data.playerBaseStats; opponentBaseStatsServer = data.opponentBaseStats; playerAbilitiesServer = data.playerAbilities; opponentAbilitiesServer = data.opponentAbilities; - myCharacterKey = playerBaseStatsServer?.characterKey; - opponentCharacterKey = opponentBaseStatsServer?.characterKey; - console.log(`[Client] Game started! My Slot ID: ${myPlayerId}, My Character: ${myCharacterKey}, Opponent Character: ${opponentCharacterKey}`); + myCharacterKey = playerBaseStatsServer?.characterKey; // Ключ персонажа этого клиента + opponentCharacterKey = opponentBaseStatsServer?.characterKey; // Ключ персонажа оппонента этого клиента + + console.log(`[Client gameStarted] My Slot ID (technical): ${myPlayerId}`); + console.log(`[Client gameStarted] My Character: ${myCharacterKey} (Name: ${playerBaseStatsServer?.name})`); + console.log(`[Client gameStarted] Opponent Character: ${opponentCharacterKey} (Name: ${opponentBaseStatsServer?.name})`); + if (data.clientConfig) { window.GAME_CONFIG = { ...data.clientConfig }; @@ -405,14 +444,15 @@ document.addEventListener('DOMContentLoaded', () => { window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' }; } + // Глобальные переменные для ui.js window.gameState = currentGameState; - window.gameData = { - playerBaseStats: playerBaseStatsServer, - opponentBaseStats: opponentBaseStatsServer, - playerAbilities: playerAbilitiesServer, - opponentAbilities: opponentAbilitiesServer + window.gameData = { // Эти данные используются в ui.js для отображения панелей + playerBaseStats: playerBaseStatsServer, // Статы "моего" персонажа + opponentBaseStats: opponentBaseStatsServer, // Статы "моего оппонента" + playerAbilities: playerAbilitiesServer, // Способности "моего" персонажа + opponentAbilities: opponentAbilitiesServer // Способности "моего оппонента" }; - window.myPlayerId = myPlayerId; + window.myPlayerId = myPlayerId; // Технический ID слота этого клиента showGameScreen(); initializeAbilityButtons(); @@ -423,14 +463,17 @@ document.addEventListener('DOMContentLoaded', () => { if (window.gameUI && typeof gameUI.addToLog === 'function' && data.log) { data.log.forEach(logEntry => gameUI.addToLog(logEntry.message, logEntry.type)); } - if (window.gameUI && typeof gameUI.updateUI === 'function') { - gameUI.updateUI(); - } + + requestAnimationFrame(() => { + if (window.gameUI && typeof gameUI.updateUI === 'function') { + console.log('[Client] Calling gameUI.updateUI() after style reset and rAF in gameStarted.'); + gameUI.updateUI(); + } + }); hideGameOverModal(); - if (restartGameButton) { - restartGameButton.disabled = true; - restartGameButton.dataset.gameIdForRestart = ''; + if (returnToMenuButton) { + returnToMenuButton.disabled = true; } setGameStatusMessage(""); }); @@ -438,7 +481,7 @@ document.addEventListener('DOMContentLoaded', () => { socket.on('gameStateUpdate', (data) => { if (!isLoggedIn || !currentGameId) return; currentGameState = data.gameState; - window.gameState = currentGameState; + window.gameState = currentGameState; // ui.js использует это для обновления if (window.gameUI && typeof gameUI.updateUI === 'function') { gameUI.updateUI(); @@ -457,23 +500,35 @@ document.addEventListener('DOMContentLoaded', () => { socket.on('gameOver', (data) => { if (!isLoggedIn || !currentGameId) return; - console.log('[Client] Game over:', data); + + console.log(`[Client gameOver] Received. My technical slot ID (myPlayerId): ${myPlayerId}, Winner's slot ID from server (data.winnerId): ${data.winnerId}`); + const playerWon = data.winnerId === myPlayerId; // Определяем, выиграл ли ЭТОТ клиент + console.log(`[Client gameOver] Calculated playerWon for this client: ${playerWon}`); + currentGameState = data.finalGameState; window.gameState = currentGameState; + // Логи для отладки имен, которые будут использоваться в ui.js + if (window.gameData) { + console.log(`[Client gameOver] For ui.js, myName will be: ${window.gameData.playerBaseStats?.name}, opponentName will be: ${window.gameData.opponentBaseStats?.name}`); + } + + if (window.gameUI && typeof gameUI.updateUI === 'function') gameUI.updateUI(); if (window.gameUI && typeof gameUI.addToLog === 'function' && data.log) { data.log.forEach(logEntry => gameUI.addToLog(logEntry.message, logEntry.type)); } if (window.gameUI && typeof gameUI.showGameOver === 'function') { - const playerWon = data.winnerId === myPlayerId; - gameUI.showGameOver(playerWon, data.reason); - if (restartGameButton) { - restartGameButton.disabled = false; - restartGameButton.dataset.gameIdForRestart = currentGameId; + // opponentCharacterKeyFromClient передается, чтобы ui.js знал, какой персонаж был оппонентом + // и мог применить, например, анимацию .dissolving к правильному типу оппонента (Балард/Альмагест) + const opponentKeyForModal = window.gameData?.opponentBaseStats?.characterKey; + gameUI.showGameOver(playerWon, data.reason, opponentKeyForModal); + + if (returnToMenuButton) { + returnToMenuButton.disabled = false; } } - setGameStatusMessage("Игра окончена. " + (data.winnerId === myPlayerId ? "Вы победили!" : "Вы проиграли.")); + setGameStatusMessage("Игра окончена. " + (playerWon ? "Вы победили!" : "Вы проиграли.")); }); socket.on('waitingForOpponent', () => { @@ -485,37 +540,18 @@ document.addEventListener('DOMContentLoaded', () => { if (!isLoggedIn || !currentGameId) return; const systemLogType = (window.GAME_CONFIG?.LOG_TYPE_SYSTEM) || 'system'; if (window.gameUI && typeof gameUI.addToLog === 'function') { - gameUI.addToLog("Противник отключился.", systemLogType); + gameUI.addToLog(`Противник (${data.disconnectedCharacterName || 'Игрок'}) отключился.`, systemLogType); } if (currentGameState && !currentGameState.isGameOver) { - setGameStatusMessage("Противник отключился. Игра завершена. Вы можете начать новую.", true); - } - }); - - socket.on('turnNotification', (data) => { - // console.log("[Client] Turn notification for slot:", data.currentTurn, "My Slot ID is:", myPlayerId); - }); - - socket.on('waitingForRestartVote', (data) => { - if (!isLoggedIn || !currentGameId) return; - const systemLogType = (window.GAME_CONFIG?.LOG_TYPE_SYSTEM) || 'system'; - const voterName = data.voterCharacterName || 'Игрок'; - if (window.gameUI && typeof gameUI.addToLog === 'function') { - gameUI.addToLog( - `${voterName} (${data.voterRole}) проголосовал(а) за рестарт. Нужно еще ${data.votesNeeded} голосов.`, - systemLogType - ); - } - setGameStatusMessage(`Игрок ${voterName} проголосовал за рестарт. Ожидание вашего решения или решения другого игрока.`); - if (restartGameButton && currentGameState?.isGameOver) { - restartGameButton.disabled = false; + setGameStatusMessage("Противник отключился. Игра может быть завершена сервером.", true); + // Сервер должен прислать 'gameOver', если игра действительно завершается } }); socket.on('gameError', (data) => { console.error('[Client] Server error:', data.message); const systemLogType = (window.GAME_CONFIG?.LOG_TYPE_SYSTEM) || 'system'; - if (isLoggedIn && window.gameUI && typeof gameUI.addToLog === 'function' && currentGameId && currentGameState && !currentGameState.isGameOver) { + if (isLoggedIn && currentGameId && currentGameState && !currentGameState.isGameOver && window.gameUI && typeof gameUI.addToLog === 'function') { gameUI.addToLog(`Ошибка: ${data.message}`, systemLogType); } setGameStatusMessage(`Ошибка: ${data.message}`, true); @@ -532,7 +568,7 @@ document.addEventListener('DOMContentLoaded', () => { updateAvailableGamesList([]); if (data.gameId) { currentGameId = data.gameId; - myPlayerId = data.yourPlayerId; + myPlayerId = data.yourPlayerId; // Запоминаем наш технический ID слота if (gameIdInput) gameIdInput.value = currentGameId; console.log(`[Client] New game ${currentGameId} created after no pending games found. My slot: ${myPlayerId}`); } diff --git a/public/js/ui.js b/public/js/ui.js index fdc15bb..7896d02 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -30,7 +30,7 @@ controls: { turnIndicator: document.getElementById('turn-indicator'), buttonAttack: document.getElementById('button-attack'), - buttonBlock: document.getElementById('button-block'), + buttonBlock: document.getElementById('button-block'), // Защита пока не активна abilitiesGrid: document.getElementById('abilities-grid'), }, log: { @@ -39,7 +39,8 @@ gameOver: { screen: document.getElementById('game-over-screen'), message: document.getElementById('result-message'), - restartButton: document.getElementById('restart-game-button'), + // restartButton: document.getElementById('restart-game-button'), // Старый ID, заменен + returnToMenuButton: document.getElementById('return-to-menu-button'), // Новый ID modalContent: document.getElementById('game-over-screen')?.querySelector('.modal-content') }, gameHeaderTitle: document.querySelector('.game-header h1'), @@ -54,106 +55,146 @@ 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; logListElement.appendChild(li); + // Прокрутка лога вниз requestAnimationFrame(() => { logListElement.scrollTop = logListElement.scrollHeight; }); } function updateFighterPanelUI(panelRole, fighterState, fighterBaseStats, isControlledByThisClient) { - const elements = uiElements[panelRole]; + const elements = uiElements[panelRole]; // 'player' или 'opponent' const config = window.GAME_CONFIG || {}; + if (!elements || !elements.hpFill || !elements.hpText || !elements.resourceFill || !elements.resourceText || !elements.status || !fighterState || !fighterBaseStats) { - console.warn(`updateFighterPanelUI: Отсутствуют элементы/состояние/статы для панели ${panelRole}.`); + // console.warn(`updateFighterPanelUI: Отсутствуют элементы UI, состояние бойца или базовые статы для панели ${panelRole}.`); + // Если панель должна быть видима, но нет данных, можно ее скрыть или показать плейсхолдер + if (elements && elements.panel && elements.panel.style.display !== 'none' && (!fighterState || !fighterBaseStats)) { + // console.warn(`updateFighterPanelUI: Нет данных для видимой панели ${panelRole}.`); + // elements.panel.style.opacity = '0.3'; // Пример: сделать полупрозрачной + } return; } + // Если панель была полупрозрачной (из-за отсутствия данных), а теперь данные есть, делаем ее полностью видимой + // if (elements.panel && elements.panel.style.opacity !== '1' && fighterState && fighterBaseStats) { + // elements.panel.style.opacity = '1'; + // } + + // Обновление имени и иконки персонажа if (elements.name) { - let iconClass = 'fa-question'; let accentColor = 'var(--text-muted)'; + let iconClass = 'fa-question'; // Иконка по умолчанию + let accentColor = 'var(--text-muted)'; // Цвет по умолчанию const characterKey = fighterBaseStats.characterKey; + if (characterKey === 'elena') { iconClass = 'fa-hat-wizard icon-player'; accentColor = 'var(--accent-player)'; } - else if (characterKey === 'almagest') { iconClass = 'fa-staff-aesculapius icon-almagest'; accentColor = 'var(--accent-almagest)'; } // Используем новый цвет + else if (characterKey === 'almagest') { iconClass = 'fa-staff-aesculapius icon-almagest'; accentColor = 'var(--accent-almagest)'; } else if (characterKey === 'balard') { iconClass = 'fa-khanda icon-opponent'; accentColor = 'var(--accent-opponent)'; } - let nameHtml = ` ${fighterBaseStats.name}`; + else { /* console.warn(`updateFighterPanelUI: Неизвестный characterKey "${characterKey}" для иконки/цвета имени.`); */ } + + let nameHtml = ` ${fighterBaseStats.name || 'Неизвестно'}`; if (isControlledByThisClient) nameHtml += " (Вы)"; - elements.name.innerHTML = nameHtml; elements.name.style.color = accentColor; + elements.name.innerHTML = nameHtml; + elements.name.style.color = accentColor; } + // Обновление аватара if (elements.avatar && fighterBaseStats.avatarPath) elements.avatar.src = fighterBaseStats.avatarPath; - else if (elements.avatar) elements.avatar.src = 'images/default_avatar.png'; + else if (elements.avatar) elements.avatar.src = 'images/default_avatar.png'; // Запасной аватар - 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.resourceFill.style.width = `${(currentRes / maxRes) * 100}%`; elements.resourceText.textContent = `${Math.round(currentRes)} / ${fighterBaseStats.maxResource}`; - const resourceBarContainer = elements[`${panelRole}ResourceBarContainer`]; - const resourceIconElement = elements[`${panelRole}ResourceTypeIcon`]; - if (resourceBarContainer && resourceIconElement) { - resourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy'); - let resourceClass = 'mana'; let iconClass = 'fa-flask'; + // Обновление типа ресурса и иконки (mana/stamina/dark-energy) + const resourceBarContainerToUpdate = (panelRole === 'player') ? uiElements.playerResourceBarContainer : uiElements.opponentResourceBarContainer; + 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'; // Значения по умолчанию (для Елены) if (fighterBaseStats.resourceName === 'Ярость') { resourceClass = 'stamina'; iconClass = 'fa-fire-alt'; } else if (fighterBaseStats.resourceName === 'Темная Энергия') { resourceClass = 'dark-energy'; iconClass = 'fa-skull'; } - resourceBarContainer.classList.add(resourceClass); - resourceIconElement.className = `fas ${iconClass}`; + resourceBarContainerToUpdate.classList.add(resourceClass); + 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); + // Обновление подсветки и рамки панели if (elements.panel) { - let glowColorVar = '--panel-glow-opponent'; let borderColorVar = '--accent-opponent'; - if (fighterBaseStats.characterKey === 'elena') { glowColorVar = '--panel-glow-player'; borderColorVar = '--accent-player'; } - else if (fighterBaseStats.characterKey === 'almagest') { glowColorVar = '--panel-glow-opponent'; borderColorVar = 'var(--accent-almagest)'; } // Цвет рамки Альмагест - elements.panel.style.borderColor = borderColorVar; // Прямое присвоение, т.к. var() не сработает для accent-almagest если он не в :root - elements.panel.style.boxShadow = `0 0 15px var(${glowColorVar}), inset 0 0 10px rgba(0, 0, 0, 0.3)`; + let glowColorVar = '--panel-glow-opponent'; + let borderColorVar = 'var(--accent-opponent)'; // По умолчанию для оппонента + if (fighterBaseStats.characterKey === 'elena') { glowColorVar = '--panel-glow-player'; borderColorVar = 'var(--accent-player)'; } + else if (fighterBaseStats.characterKey === 'almagest') { glowColorVar = 'var(--panel-glow-opponent)'; borderColorVar = 'var(--accent-almagest)'; } // Для Альмагест используется ее цвет рамки + else if (fighterBaseStats.characterKey === 'balard') { glowColorVar = 'var(--panel-glow-opponent)'; borderColorVar = 'var(--accent-opponent)'; } + else { borderColorVar = 'var(--panel-border)'; } // Фоллбэк + + elements.panel.style.borderColor = borderColorVar; + // Убедимся, что переменная glowColorVar существует, иначе тень может не примениться или вызвать ошибку + elements.panel.style.boxShadow = glowColorVar ? `0 0 15px ${glowColorVar}, inset 0 0 10px rgba(0, 0, 0, 0.3)` : `0 0 15px rgba(0,0,0,0.4), inset 0 0 10px rgba(0,0,0,0.3)`; } } function generateEffectsHTML(effectsArray) { const config = window.GAME_CONFIG || {}; if (!effectsArray || effectsArray.length === 0) return 'Нет'; + 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.type === config.ACTION_TYPE_DISABLE || eff.isFullSilence || eff.id.startsWith('playerSilencedOn_')) effectClasses += ' effect-stun'; - else if (eff.type === config.ACTION_TYPE_DEBUFF || (eff.power && eff.power < 0) || eff.id.startsWith('effect_')) effectClasses += ' effect-debuff'; - else if (eff.grantsBlock) effectClasses += ' effect-block'; - else effectClasses += ' effect-buff'; + + if (eff.isFullSilence || eff.id.startsWith('playerSilencedOn_') || (eff.type === config.ACTION_TYPE_DISABLE && !eff.grantsBlock) ) { + effectClasses += ' effect-stun'; // Стан/безмолвие + } else if (eff.grantsBlock) { + effectClasses += ' effect-block'; // Эффект блока + } else if (eff.type === config.ACTION_TYPE_DEBUFF || (eff.power && eff.power < 0 && eff.type !== config.ACTION_TYPE_HEAL )) { + effectClasses += ' effect-debuff'; // Ослабления, DoT + } else { // ACTION_TYPE_BUFF или положительные эффекты (например, HoT) + effectClasses += ' effect-buff'; + } return `${displayText}`; }).join(' '); } function updateEffectsUI(currentGameState) { - if (!currentGameState || !uiElements.player.buffsList || !uiElements.opponent.buffsList) return; - const mySlotId = window.myPlayerId; // Наш слот ('player' или 'opponent') + if (!currentGameState || !window.GAME_CONFIG) { return; } + const mySlotId = window.myPlayerId; // Технический ID слота этого клиента + if (!mySlotId) { return; } + const opponentSlotId = mySlotId === window.GAME_CONFIG.PLAYER_ID ? window.GAME_CONFIG.OPPONENT_ID : window.GAME_CONFIG.PLAYER_ID; - const myState = currentGameState[mySlotId]; - if (uiElements.player && myState && myState.activeEffects) { - uiElements.player.buffsList.innerHTML = generateEffectsHTML(myState.activeEffects.filter(e => e.type === window.GAME_CONFIG.ACTION_TYPE_BUFF || e.grantsBlock)); - uiElements.player.debuffsList.innerHTML = generateEffectsHTML(myState.activeEffects.filter(e => e.type !== window.GAME_CONFIG.ACTION_TYPE_BUFF && !e.grantsBlock)); + const myState = currentGameState[mySlotId]; // Состояние персонажа этого клиента + if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList && myState && myState.activeEffects) { + uiElements.player.buffsList.innerHTML = generateEffectsHTML(myState.activeEffects.filter(e => e.type === window.GAME_CONFIG.ACTION_TYPE_BUFF || e.grantsBlock || (e.type === window.GAME_CONFIG.ACTION_TYPE_HEAL && e.turnsLeft > 0) )); + uiElements.player.debuffsList.innerHTML = generateEffectsHTML(myState.activeEffects.filter(e => e.type !== window.GAME_CONFIG.ACTION_TYPE_BUFF && !e.grantsBlock && !(e.type === window.GAME_CONFIG.ACTION_TYPE_HEAL && e.turnsLeft > 0) )); } - const opponentState = currentGameState[opponentSlotId]; - if (uiElements.opponent && opponentState && opponentState.activeEffects) { - uiElements.opponent.buffsList.innerHTML = generateEffectsHTML(opponentState.activeEffects.filter(e => e.type === window.GAME_CONFIG.ACTION_TYPE_BUFF || e.grantsBlock)); - uiElements.opponent.debuffsList.innerHTML = generateEffectsHTML(opponentState.activeEffects.filter(e => e.type !== window.GAME_CONFIG.ACTION_TYPE_BUFF && !e.grantsBlock)); + const opponentState = currentGameState[opponentSlotId]; // Состояние оппонента этого клиента + if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList && opponentState && opponentState.activeEffects) { + uiElements.opponent.buffsList.innerHTML = generateEffectsHTML(opponentState.activeEffects.filter(e => e.type === window.GAME_CONFIG.ACTION_TYPE_BUFF || e.grantsBlock || (e.type === window.GAME_CONFIG.ACTION_TYPE_HEAL && e.turnsLeft > 0) )); + uiElements.opponent.debuffsList.innerHTML = generateEffectsHTML(opponentState.activeEffects.filter(e => e.type !== window.GAME_CONFIG.ACTION_TYPE_BUFF && !e.grantsBlock && !(e.type === window.GAME_CONFIG.ACTION_TYPE_HEAL && e.turnsLeft > 0) )); } } function updateUI() { - const currentGameState = window.gameState; - const gameDataGlobal = window.gameData; - const configGlobal = window.GAME_CONFIG; - const myActualPlayerId = window.myPlayerId; // Слот, который занимает ЭТОТ клиент ('player' или 'opponent') + const currentGameState = window.gameState; // Глобальное состояние игры + const gameDataGlobal = window.gameData; // Глобальные данные (статы, абилки) для этого клиента + const configGlobal = window.GAME_CONFIG; // Глобальный конфиг + const myActualPlayerId = window.myPlayerId; // Технический ID слота этого клиента if (!currentGameState || !gameDataGlobal || !configGlobal || !myActualPlayerId) { console.warn("updateUI: Отсутствуют глобальные gameState, gameData, GAME_CONFIG или myActualPlayerId."); @@ -164,35 +205,64 @@ return; } - // Определяем ID слота того, кто сейчас ходит + // Определяем, чей сейчас ход по ID слота const actorSlotWhoseTurnItIs = currentGameState.isPlayerTurn ? configGlobal.PLAYER_ID : configGlobal.OPPONENT_ID; - - // Обновление панелей бойцов + // Определяем ID слота оппонента для этого клиента const opponentActualSlotId = myActualPlayerId === configGlobal.PLAYER_ID ? configGlobal.OPPONENT_ID : configGlobal.PLAYER_ID; - updateFighterPanelUI('player', currentGameState[myActualPlayerId], gameDataGlobal.playerBaseStats, true); - updateFighterPanelUI('opponent', currentGameState[opponentActualSlotId], gameDataGlobal.opponentBaseStats, false); + + // Обновление панели "моего" персонажа + if (gameDataGlobal.playerBaseStats && currentGameState[myActualPlayerId]) { + if (uiElements.player.panel && uiElements.player.panel.style.opacity !== '1') uiElements.player.panel.style.opacity = '1'; + updateFighterPanelUI('player', currentGameState[myActualPlayerId], gameDataGlobal.playerBaseStats, true); + } else { + if (uiElements.player.panel) uiElements.player.panel.style.opacity = '0.5'; + } + + // Обновление панели "моего оппонента" + if (gameDataGlobal.opponentBaseStats && currentGameState[opponentActualSlotId]) { + if (uiElements.opponent.panel && uiElements.opponent.panel.style.opacity !== '1' && currentGameState.isGameOver === false ) { + // Если панель оппонента была "затемнена" и игра не окончена, восстанавливаем видимость + uiElements.opponent.panel.style.opacity = '1'; + } + // Перед обновлением, если игра НЕ окончена, а панель "тает", отменяем это + if (uiElements.opponent.panel && uiElements.opponent.panel.classList.contains('dissolving') && currentGameState.isGameOver === false) { + console.warn("[UI UPDATE DEBUG] Opponent panel has .dissolving but game is NOT over. Forcing visible."); + const panel = uiElements.opponent.panel; + panel.classList.remove('dissolving'); + const originalTransition = panel.style.transition; panel.style.transition = 'none'; + panel.style.opacity = '1'; panel.style.transform = 'scale(1) translateY(0)'; + requestAnimationFrame(() => { panel.style.transition = originalTransition || ''; }); + } + updateFighterPanelUI('opponent', currentGameState[opponentActualSlotId], gameDataGlobal.opponentBaseStats, false); + } else { + if (uiElements.opponent.panel) uiElements.opponent.panel.style.opacity = '0.5'; + } updateEffectsUI(currentGameState); + // Обновление заголовка игры (Имя1 vs Имя2) if (uiElements.gameHeaderTitle && gameDataGlobal.playerBaseStats && gameDataGlobal.opponentBaseStats) { - const myName = gameDataGlobal.playerBaseStats.name; - const opponentName = gameDataGlobal.opponentBaseStats.name; + const myName = gameDataGlobal.playerBaseStats.name; // Имя моего персонажа + const opponentName = gameDataGlobal.opponentBaseStats.name; // Имя моего оппонента const myKey = gameDataGlobal.playerBaseStats.characterKey; const opponentKey = gameDataGlobal.opponentBaseStats.characterKey; + let myClass = 'title-player'; - if (myKey === 'elena') myClass = 'title-enchantress'; - else if (myKey === 'almagest') myClass = 'title-sorceress'; + if (myKey === 'elena') myClass = 'title-enchantress'; else if (myKey === 'almagest') myClass = 'title-sorceress'; + let opponentClass = 'title-opponent'; - if (opponentKey === 'elena') opponentClass = 'title-enchantress'; - else if (opponentKey === 'almagest') opponentClass = 'title-sorceress'; - else if (opponentKey === 'balard') opponentClass = 'title-knight'; + 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}`; } + // Обновление индикатора хода if (uiElements.controls.turnIndicator) { - const currentTurnActorState = currentGameState[actorSlotWhoseTurnItIs]; + // Имя того, чей ход, берем из gameState по ID слота + const currentTurnActorState = currentGameState[actorSlotWhoseTurnItIs]; // 'player' или 'opponent' const currentTurnName = currentTurnActorState?.name || 'Неизвестно'; uiElements.controls.turnIndicator.textContent = `Ход: ${currentTurnName}`; + const currentTurnCharacterKey = currentTurnActorState?.characterKey; let turnColor = 'var(--turn-color)'; if (currentTurnCharacterKey === 'elena') turnColor = 'var(--accent-player)'; @@ -201,31 +271,35 @@ uiElements.controls.turnIndicator.style.color = turnColor; } - const canThisClientAct = actorSlotWhoseTurnItIs === myActualPlayerId; + // Управление активностью кнопок + const canThisClientAct = actorSlotWhoseTurnItIs === myActualPlayerId; // Может ли ЭТОТ клиент ходить const isGameActive = !currentGameState.isGameOver; + // Кнопка атаки if (uiElements.controls.buttonAttack) { uiElements.controls.buttonAttack.disabled = !(canThisClientAct && isGameActive); - const myCharKey = gameDataGlobal.playerBaseStats.characterKey; - const myState = currentGameState[myActualPlayerId]; + 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 && myState) { - const isAttackBuffReady = myState.activeEffects.some(eff => eff.id === attackBuffId && !eff.justCast); + + if (attackBuffId && myStateForAttackBuff && myStateForAttackBuff.activeEffects) { + const isAttackBuffReady = myStateForAttackBuff.activeEffects.some(eff => eff.id === attackBuffId && !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'); } } - if (uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true; + if (uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true; // Пока не используется - const actingPlayerState = currentGameState[myActualPlayerId]; - const actingPlayerAbilities = gameDataGlobal.playerAbilities; - const actingPlayerResourceName = gameDataGlobal.playerBaseStats.resourceName; + // Кнопки способностей + const actingPlayerState = currentGameState[myActualPlayerId]; // Состояние моего персонажа + const actingPlayerAbilities = gameDataGlobal.playerAbilities; // Способности моего персонажа + const actingPlayerResourceName = gameDataGlobal.playerBaseStats?.resourceName; uiElements.controls.abilitiesGrid?.querySelectorAll(`.${configGlobal.CSS_CLASS_ABILITY_BUTTON || 'ability-button'}`).forEach(button => { - if (!(button instanceof HTMLButtonElement) || !actingPlayerState || !actingPlayerAbilities) { + if (!(button instanceof HTMLButtonElement) || !actingPlayerState || !actingPlayerAbilities || !actingPlayerResourceName) { if(button instanceof HTMLButtonElement) button.disabled = true; return; } @@ -234,17 +308,21 @@ if (!ability) { button.disabled = true; return; } const hasEnoughResource = actingPlayerState.currentResource >= ability.cost; - const isBuffAlreadyActive = ability.type === configGlobal.ACTION_TYPE_BUFF && actingPlayerState.activeEffects.some(eff => eff.id === ability.id); + const isBuffAlreadyActive = ability.type === configGlobal.ACTION_TYPE_BUFF && actingPlayerState.activeEffects?.some(eff => eff.id === ability.id); const isOnCooldown = (actingPlayerState.abilityCooldowns?.[ability.id] || 0) > 0; - const isGenerallySilenced = actingPlayerState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0); - const isSpecificallySilenced = actingPlayerState.disabledAbilities?.some(dis => dis.abilityId === abilityId && dis.turnsLeft > 0); + + 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; - const silenceTurnsLeft = isGenerallySilenced ? (actingPlayerState.activeEffects.find(eff => eff.isFullSilence)?.turnsLeft || 0) - : (isSpecificallySilenced ? (actingPlayerState.disabledAbilities.find(dis => dis.abilityId === abilityId)?.turnsLeft || 0) : 0); + const silenceTurnsLeft = isGenerallySilenced + ? (actingPlayerState.activeEffects.find(eff => eff.isFullSilence)?.turnsLeft || 0) + : (specificSilenceEffect?.turnsLeft || 0); let isDisabledByDebuffOnTarget = false; - const opponentStateForDebuffCheck = currentGameState[opponentActualSlotId]; - if ((ability.id === configGlobal.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configGlobal.ABILITY_ID_ALMAGEST_DEBUFF) && opponentStateForDebuffCheck) { + const opponentStateForDebuffCheck = currentGameState[opponentActualSlotId]; // Состояние моего оппонента + if (opponentStateForDebuffCheck && opponentStateForDebuffCheck.activeEffects && + (ability.id === configGlobal.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configGlobal.ABILITY_ID_ALMAGEST_DEBUFF)) { const effectIdForDebuff = 'effect_' + ability.id; isDisabledByDebuffOnTarget = opponentStateForDebuffCheck.activeEffects.some(e => e.id === effectIdForDebuff); } @@ -265,10 +343,11 @@ button.classList.toggle(configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', isBuffAlreadyActive && !isDisabledByDebuffOnTarget); } + // Обновление title (всплывающей подсказки) let titleText = `${ability.name} (${ability.cost} ${actingPlayerResourceName})`; let descriptionText = ability.description; if (typeof ability.descriptionFunction === 'function') { - descriptionText = ability.descriptionFunction(configGlobal, gameDataGlobal.opponentBaseStats); + descriptionText = ability.descriptionFunction(configGlobal, gameDataGlobal.opponentBaseStats); // Для описания используем статы оппонента этого клиента } if (descriptionText) titleText += ` - ${descriptionText}`; let abilityBaseCooldown = ability.cooldown; @@ -279,56 +358,101 @@ if (isOnCooldown) titleText = `${ability.name} - На перезарядке! Осталось: ${actingPlayerState.abilityCooldowns[ability.id]} х.`; else if (isSilenced) titleText = `Безмолвие! Осталось: ${silenceTurnsLeft} х.`; else if (isBuffAlreadyActive) { - const activeEffect = actingPlayerState.activeEffects.find(eff => eff.id === abilityId); + const activeEffect = actingPlayerState.activeEffects?.find(eff => eff.id === abilityId); titleText = `Эффект "${ability.name}" уже активен${activeEffect ? ` (${activeEffect.turnsLeft} х.)` : ''}`; } else if (isDisabledByDebuffOnTarget && opponentStateForDebuffCheck) { - const activeDebuff = opponentStateForDebuffCheck.activeEffects.find(e => e.id === 'effect_' + ability.id); + const activeDebuff = opponentStateForDebuffCheck.activeEffects?.find(e => e.id === 'effect_' + ability.id); titleText = `Эффект "${ability.name}" уже наложен на ${opponentStateForDebuffCheck.name}${activeDebuff ? ` (${activeDebuff.turnsLeft} х.)` : ''}`; } button.setAttribute('title', titleText); }); } - function showGameOver(playerWon, reason = "") { + function showGameOver(playerWon, reason = "", opponentCharacterKeyFromClient = null) { const config = window.GAME_CONFIG || {}; - const gameDataGlobal = window.gameData || {}; - const currentGameState = window.gameState; + // Используем gameData, сохраненное в client.js, так как оно отражает перспективу этого клиента + const clientSpecificGameData = window.gameData; + const currentActualGameState = window.gameState; // Самое актуальное состояние игры const gameOverScreenElement = uiElements.gameOver.screen; - if (!gameOverScreenElement || !currentGameState) return; + + 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] 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}`); + + + if (!gameOverScreenElement) { + console.warn("[UI.JS DEBUG] showGameOver: gameOverScreenElement not found."); + return; + } const resultMsgElement = uiElements.gameOver.message; - const opponentPanelElement = uiElements.opponent.panel; - const myName = gameDataGlobal.playerBaseStats?.name || "Игрок"; - const opponentName = gameDataGlobal.opponentBaseStats?.name || "Противник"; - const opponentCharacterKey = gameDataGlobal.opponentBaseStats?.characterKey; + // Имена для сообщения берем из clientSpecificGameData, т.к. они уже "перевернуты" для каждого клиента + const myNameForResult = clientSpecificGameData?.playerBaseStats?.name || "Игрок"; + const opponentNameForResult = clientSpecificGameData?.opponentBaseStats?.name || "Противник"; if (resultMsgElement) { - let winText = `Победа! ${myName} празднует!`; - let loseText = `Поражение! ${opponentName} оказался(лась) сильнее!`; - if (reason === 'opponent_disconnected') winText = `${opponentName} покинул(а) игру. Победа присуждается вам!`; + let winText = `Победа! ${myNameForResult} празднует!`; + let loseText = `Поражение! ${opponentNameForResult} оказался(лась) сильнее!`; + if (reason === 'opponent_disconnected') { + winText = `${opponentNameForResult} покинул(а) игру. Победа присуждается вам!`; + // Если оппонент отключился, а мы проиграли (технически такое возможно, если сервер так решит) + // То текст поражения можно оставить стандартным или специфичным. + // Пока оставим стандартный, если playerWon = false и reason = opponent_disconnected. + } resultMsgElement.textContent = playerWon ? winText : loseText; resultMsgElement.style.color = playerWon ? 'var(--heal-color)' : 'var(--damage-color)'; } + const opponentPanelElement = uiElements.opponent.panel; if (opponentPanelElement) { opponentPanelElement.classList.remove('dissolving'); - if (playerWon && reason !== 'opponent_disconnected' && (opponentCharacterKey === 'balard' || opponentCharacterKey === 'almagest')) { - opponentPanelElement.classList.add('dissolving'); + console.log(`[UI.JS DEBUG] Opponent panel classList after initial remove .dissolving: ${opponentPanelElement.className}`); + + // Используем opponentCharacterKeyFromClient, так как это ключ реального персонажа оппонента + const keyForDissolveEffect = opponentCharacterKeyFromClient; + + 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: isGameOver=${currentActualGameState.isGameOver}, playerWon=${playerWon}, opponentKeyForEffect=${keyForDissolveEffect}`); + opponentPanelElement.classList.add('dissolving'); + } else { + console.log(`[UI.JS DEBUG] NOT adding .dissolving (opponent key mismatch for dissolve effect). Key for effect: ${keyForDissolveEffect}`); + } + } else { + console.log(`[UI.JS DEBUG] NOT adding .dissolving. Conditions NOT met: isGameOver=${currentActualGameState?.isGameOver}, playerWon=${playerWon}, reason=${reason}`); + if (!currentActualGameState || currentActualGameState.isGameOver === false) { + console.log(`[UI.JS DEBUG] Ensuring opponent panel is visible because game is not 'isGameOver=true' or gameState missing.`); + const originalTransition = opponentPanelElement.style.transition; + opponentPanelElement.style.transition = 'none'; + opponentPanelElement.style.opacity = '1'; + opponentPanelElement.style.transform = 'scale(1) translateY(0)'; + requestAnimationFrame(() => { + opponentPanelElement.style.transition = originalTransition || ''; + }); + } } + console.log(`[UI.JS DEBUG] Opponent panel classList FINAL in showGameOver: ${opponentPanelElement.className}`); } setTimeout(() => { - gameOverScreenElement.classList.remove(config.CSS_CLASS_HIDDEN || 'hidden'); - requestAnimationFrame(() => { - gameOverScreenElement.style.opacity = '0'; - setTimeout(() => { - gameOverScreenElement.style.opacity = '1'; - if (uiElements.gameOver.modalContent) { - uiElements.gameOver.modalContent.style.transform = 'scale(1) translateY(0)'; - uiElements.gameOver.modalContent.style.opacity = '1'; - } - }, config.MODAL_TRANSITION_DELAY || 10); - }); + if (window.gameState && window.gameState.isGameOver === true) { // Перепроверяем перед показом + console.log(`[UI.JS DEBUG] Showing gameOverScreen modal (isGameOver is true).`); + gameOverScreenElement.classList.remove(config.CSS_CLASS_HIDDEN || 'hidden'); + requestAnimationFrame(() => { + gameOverScreenElement.style.opacity = '0'; + setTimeout(() => { + gameOverScreenElement.style.opacity = '1'; + if (uiElements.gameOver.modalContent) { + uiElements.gameOver.modalContent.style.transform = 'scale(1) translateY(0)'; + uiElements.gameOver.modalContent.style.opacity = '1'; + } + }, config.MODAL_TRANSITION_DELAY || 10); + }); + } else { + console.log(`[UI.JS DEBUG] NOT showing gameOverScreen modal (isGameOver is now false or gameState missing).`); + } }, config.DELAY_BEFORE_VICTORY_MODAL || 1500); } diff --git a/public/style_alt.css b/public/style_alt.css index 9ed6c47..cef8792 100644 --- a/public/style_alt.css +++ b/public/style_alt.css @@ -46,7 +46,7 @@ --scrollbar-track: #10121c; --shake-duration: 0.4s; --cast-duration: 0.6s; - --dissolve-duration: 6.0s; + --dissolve-duration: 6.0s; /* Убедитесь, что это значение используется в JS для синхронизации, если нужно */ --log-panel-fixed-height: 280px; } @@ -59,11 +59,11 @@ body { color: var(--text-light); line-height: 1.5; height: 100vh; - overflow: hidden; + overflow: hidden; /* Предотвращает прокрутку основного body, если контент не помещается */ display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: center; /* Центрирует auth-game-setup-wrapper */ padding: 10px; } h1, h2, h3, h4 { @@ -86,7 +86,7 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente .auth-game-setup-wrapper { width: 100%; max-width: 700px; - margin: 20px auto; + margin: 20px auto; /* auto для центрирования, если flex в body изменится */ padding: 25px 30px; background: var(--panel-bg); border: 1px solid var(--panel-border); @@ -94,6 +94,9 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5); color: var(--text-light); text-align: center; + /* Добавим overflow-y: auto для случаев, когда контент не помещается */ + max-height: calc(100vh - 40px); /* Чтобы не вылезал за экран */ + overflow-y: auto; } .auth-game-setup-wrapper h2, .auth-game-setup-wrapper h3 { @@ -104,6 +107,8 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente padding-bottom: 0.5em; } .auth-game-setup-wrapper h3 { font-size: 1.2em; margin-top: 1.5em; } + +/* Общие стили для кнопок в .auth-game-setup-wrapper и форм аутентификации */ .auth-game-setup-wrapper button, #auth-section form button { font-family: var(--font-main); @@ -138,6 +143,8 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente cursor: not-allowed; opacity: 0.7; } + +/* Стили для инпутов */ .auth-game-setup-wrapper input[type="text"], #auth-section input[type="text"], #auth-section input[type="password"] { @@ -148,10 +155,12 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente color: var(--text-light); margin: 5px 5px 10px 5px; font-size: 0.9em; - width: calc(100% - 22px); + width: calc(100% - 22px); /* Учитываем padding и border */ max-width: 300px; box-sizing: border-box; } + +/* Стили для списка доступных игр */ #available-games-list { margin-top: 20px; text-align: left; @@ -165,7 +174,13 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente #available-games-list ul { list-style: none; padding: 0; } #available-games-list li { padding: 10px; - border-bottom: 1px solid rgba(var(--log-border), 0.7); + border-bottom: 1px solid rgba(var(--log-border), 0.7); /* Некорректно, var() не работает в rgba() так */ + /* Исправлено: */ + border-bottom: 1px solid var(--log-border); /* Используем напрямую, или задаем цвет с альфа-каналом в переменной */ + /* Или, если нужен именно альфа-канал от существующего цвета: */ + /* border-bottom: 1px solid hsla(hue(var(--log-border)), saturation(var(--log-border)), lightness(var(--log-border)), 0.7); /* Это сложно, лучше отдельная переменная */ + /* Простой вариант - чуть светлее основной границы: */ + /* border-bottom: 1px solid #5a6082; /* Пример */ display: flex; justify-content: space-between; align-items: center; @@ -173,6 +188,8 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente } #available-games-list li:last-child { border-bottom: none; } #available-games-list li button { padding: 6px 10px; font-size: 0.8em; margin-left: 10px; } + +/* Контейнер для статусных сообщений */ #status-container { min-height: 2.5em; margin-bottom: 15px; } #game-status-message, #auth-message { color: var(--turn-color); @@ -186,20 +203,26 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente } #auth-message.success { color: var(--heal-color); } #auth-message.error { color: var(--damage-color); } + +/* Формы аутентификации */ #auth-section form { margin-bottom: 20px; } + +/* Информация о пользователе */ #user-info { padding: 10px; background-color: rgba(255,255,255,0.05); border-radius: 5px; margin-bottom: 20px; } #user-info p { margin: 0 0 10px 0; font-size: 1.1em; } -#logout-button { background: linear-gradient(145deg, #8c3a3a, #6b2b2b) !important; } +#logout-button { background: linear-gradient(145deg, #8c3a3a, #6b2b2b) !important; } /* !important чтобы перебить общий стиль кнопки */ #logout-button:hover { background: linear-gradient(145deg, #a04040, #8c3a3a) !important; } -/* Стили для выбора персонажа (перенесены из index.html) */ +/* Стили для выбора персонажа */ .character-selection { margin-top: 15px; margin-bottom: 15px; padding: 15px; background-color: rgba(0,0,0,0.2); border-radius: 6px; - border: 1px solid rgba(var(--panel-border), 0.5); + border: 1px solid rgba(var(--panel-border), 0.5); /* Тоже может быть проблемой с var() в rgba() */ + /* Исправлено: */ + /* border: 1px solid #353a52; /* Пример полупрозрачного panel-border */ } .character-selection h4 { font-size: 1.1em; @@ -222,10 +245,12 @@ i { margin-right: 6px; color: var(--icon-color); width: 1.2em; text-align: cente color: #fff; font-weight: bold; } +/* Стилизация для Елены */ .character-selection input[type="radio"][value="elena"]:checked + label { background-color: var(--accent-player); box-shadow: 0 0 8px rgba(108, 149, 255, 0.5); } +/* Стилизация для Альмагест */ .character-selection input[type="radio"][value="almagest"]:checked + label { background-color: var(--accent-almagest); /* Новый цвет для Альмагест */ box-shadow: 0 0 8px rgba(199, 108, 255, 0.5); /* Тень для Альмагест */ @@ -268,15 +293,15 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц flex-grow: 1; display: flex; gap: 10px; - overflow: hidden; + overflow: hidden; /* Предотвращает выпадение контента */ } .player-column, .opponent-column { flex: 1; display: flex; flex-direction: column; gap: 10px; - min-width: 0; - overflow: hidden; + min-width: 0; /* Для корректной работы flex с overflow */ + overflow: hidden; /* Если контент внутри колонок может быть больше */ } /* --- Стили Панелей Персонажей, Управления, Лога --- */ @@ -288,7 +313,7 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц padding: 15px; display: flex; flex-direction: column; - overflow: hidden; + overflow: hidden; /* Контент внутри панелей не должен выходить за их пределы */ transition: box-shadow 0.3s ease, border-color 0.3s ease, opacity 0.3s ease-out, transform 0.3s ease-out; } @@ -300,40 +325,40 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц gap: 10px; padding-bottom: 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); - margin-bottom: 0; + margin-bottom: 0; /* Убираем лишний отступ, если panel-content имеет свой margin-top */ } .fighter-name { font-size: 1.6em; margin: 0; flex-grow: 1; text-align: left; } -.fighter-name .icon-player { color: var(--accent-player); } /* Елена */ -.fighter-name .icon-opponent { color: var(--accent-opponent); } /* Балард */ -.fighter-name .icon-almagest { color: var(--accent-almagest); } /* Альмагест */ +.fighter-name .icon-player { color: var(--accent-player); } +.fighter-name .icon-opponent { color: var(--accent-opponent); } +.fighter-name .icon-almagest { color: var(--accent-almagest); } .character-visual { flex-shrink: 0; margin-bottom: 0; } .avatar-image { display: block; - max-width: 50px; - height: auto; + max-width: 50px; /* Фиксируем или делаем адаптивным */ + height: auto; /* Для сохранения пропорций */ border-radius: 50%; - border: 2px solid var(--panel-border); /* Цвет рамки будет изменен JS */ + border: 2px solid var(--panel-border); /* Цвет рамки будет изменен JS или специфичным классом */ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); } .panel-content { flex-grow: 1; - overflow-y: auto; - padding-right: 5px; + overflow-y: auto; /* Позволяет прокручивать контент, если он не помещается */ + padding-right: 5px; /* Для отступа от скроллбара */ display: flex; flex-direction: column; gap: 10px; - min-height: 0; - padding-top: 10px; - margin-top: 0; + min-height: 0; /* Для корректной работы flex с overflow */ + padding-top: 10px; /* Отступ от panel-header */ + margin-top: 0; /* Убрали margin-bottom у panel-header, добавили padding-top сюда */ } .stat-bar-container { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .stat-bar-container .bar-icon { flex-shrink: 0; font-size: 1.4em; } /* Цвета иконок ресурсов */ .stat-bar-container.health .bar-icon { color: var(--hp-color); } -.stat-bar-container.mana .bar-icon { color: var(--mana-color); } /* Мана Елены */ -.stat-bar-container.stamina .bar-icon { color: var(--stamina-color); } /* Ярость Баларда */ -.stat-bar-container.dark-energy .bar-icon { color: var(--dark-energy-color); } /* Темная Энергия Альмагест */ +.stat-bar-container.mana .bar-icon { color: var(--mana-color); } +.stat-bar-container.stamina .bar-icon { color: var(--stamina-color); } +.stat-bar-container.dark-energy .bar-icon { color: var(--dark-energy-color); } .bar-wrapper { flex-grow: 1; } .bar { @@ -342,7 +367,7 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц position: relative; background-color: var(--bar-bg); } .bar-fill { - display: block; height: 100%; border-radius: 3px; + display: block; height: 100%; border-radius: 3px; /* чуть меньше, чем у родителя */ position: relative; z-index: 2; transition: width 0.4s ease-out; } .bar-text { @@ -350,7 +375,7 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц z-index: 3; display: flex; justify-content: center; align-items: center; font-size: 0.75em; font-weight: bold; color: #fff; text-shadow: 1px 1px 1px rgba(0,0,0,0.9); padding: 0 5px; - white-space: nowrap; pointer-events: none; + white-space: nowrap; pointer-events: none; /* Чтобы текст не мешал кликам, если они есть */ } /* Цвета Заливки Баров */ .health .bar-fill { background-color: var(--hp-color); } @@ -362,30 +387,30 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц /* Статус и Эффекты */ .status-area { font-size: 0.9em; display: flex; align-items: baseline; gap: 5px; - flex-shrink: 0; min-height: 1.5em; + flex-shrink: 0; min-height: 1.5em; /* Чтобы не прыгал layout */ } -.status-area .icon-status { font-size: 1em; flex-shrink: 0; margin-top: 0.1em; } +.status-area .icon-status { font-size: 1em; flex-shrink: 0; margin-top: 0.1em; /* Подгонка выравнивания */ } .status-area strong { color: var(--text-muted); font-weight: normal; flex-shrink: 0; margin-right: 3px; } .status-area span { font-weight: bold; } .status-area span.blocking { color: var(--block-color); font-style: italic; } .effects-area { font-size: 0.9em; display: flex; flex-direction: column; gap: 8px; - flex-shrink: 0; min-height: 3em; + flex-shrink: 0; min-height: 3em; /* Резервируем место */ } .effect-category { display: flex; align-items: baseline; gap: 5px; } .effect-category strong { - color: var(--text-muted); font-weight: normal; font-family: var(--font-main); + color: var(--text-muted); font-weight: normal; font-family: var(--font-main); /* Убедимся, что шрифт основной */ font-size: 0.9em; flex-shrink: 0; margin-right: 3px; } .effect-category .icon-effects-buff, .effect-category .icon-effects-debuff { - font-size: 1em; flex-shrink: 0; margin-top: 0.1em; - width: 1.2em; text-align: center; + font-size: 1em; flex-shrink: 0; margin-top: 0.1em; /* Подгонка выравнивания */ + width: 1.2em; text-align: center; /* Для иконок */ } .effect-category .icon-effects-buff { color: var(--heal-color); } .effect-category .icon-effects-debuff { color: var(--damage-color); } -.effect-list { display: inline; line-height: 1.4; min-width: 0; font-weight: bold; } +.effect-list { display: inline; line-height: 1.4; min-width: 0; /* Для переноса, если нужно */ font-weight: bold; } .effect { display: inline-block; margin: 2px 3px 2px 0; padding: 1px 6px; font-size: 0.8em; border-radius: 10px; border: 1px solid; @@ -394,8 +419,8 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц } .effect-buff { border-color: var(--heal-color); color: var(--heal-color); } .effect-debuff { border-color: var(--damage-color); color: var(--damage-color); } -.effect-stun { border-color: var(--turn-color); color: var(--turn-color); } -.effect-block { border-color: var(--block-color); color: var(--block-color); } +.effect-stun { border-color: var(--turn-color); color: var(--turn-color); } /* Для безмолвия/стана */ +.effect-block { border-color: var(--block-color); color: var(--block-color); } /* Для эффектов блока */ /* --- Панель Управления --- */ @@ -424,11 +449,13 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц box-shadow: 0 3px 6px rgba(0,0,0,0.5); } .action-button.basic:active:enabled { transform: translateY(0px); box-shadow: 0 1px 2px rgba(0,0,0,0.4); } + +/* Стиль для бафнутой атаки */ #button-attack.attack-buffed:enabled { border: 2px solid var(--heal-color) !important; box-shadow: 0 0 10px 2px rgba(144, 238, 144, 0.6), 0 3px 6px rgba(0,0,0,0.5); - background: linear-gradient(145deg, #70c070, #5a9a5a); - transform: translateY(-1px); + background: linear-gradient(145deg, #70c070, #5a9a5a); /* Зеленый градиент */ + transform: translateY(-1px); /* Небольшой подъем */ } .ability-list { flex-grow: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } @@ -437,9 +464,9 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц display: grid; grid-template-columns: repeat(auto-fit, minmax(75px, 1fr)); gap: 8px; padding: 8px; background-color: rgba(0,0,0,0.2); border-radius: 4px; overflow-y: auto; border: 1px solid rgba(0,0,0,0.3); - flex-grow: 1; position: relative; + flex-grow: 1; position: relative; /* Для псевдоэлемента, если нужен */ } -.abilities-grid::after { content: ''; display: block; height: 10px; width: 100%; } +.abilities-grid::after { content: ''; display: block; height: 10px; width: 100%; } /* Отступ снизу для скролла */ .abilities-grid .placeholder-text { grid-column: 1 / -1; text-align: center; color: var(--text-muted); align-self: center; font-size: 0.9em; padding: 15px 0; @@ -458,7 +485,7 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц .ability-button .ability-desc { font-size: 0.65em; font-weight: normal; color: #aaccce; opacity: 0.8; text-shadow: none; max-height: 2em; overflow: hidden; width: 95%; - display: block; margin-top: auto; + display: block; margin-top: auto; /* Прижимает описание вниз */ } .ability-button:hover:enabled { transform: scale(1.03) translateY(-1px); background: var(--button-ability-hover-bg); @@ -470,27 +497,40 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц box-shadow: inset 0 1px 2px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.3); filter: brightness(0.9); } +/* Общие стили для неактивных кнопок (базовых и способностей) */ .ability-button:disabled, .action-button.basic:disabled { background: var(--button-disabled-bg) !important; border-color: transparent !important; color: var(--button-disabled-text) !important; cursor: not-allowed !important; transform: none !important; box-shadow: inset 0 1px 3px rgba(0,0,0,0.4) !important; opacity: 0.6; text-shadow: none !important; filter: grayscale(50%) !important; } +/* Нехватка ресурса */ .ability-button.not-enough-resource:not(:disabled) { border: 2px dashed var(--damage-color) !important; box-shadow: inset 0 0 8px rgba(255, 80, 80, 0.3), 0 3px 6px rgba(0,0,0,0.4) !important; animation: pulse-red-border 1s infinite ease-in-out; } -.ability-button.buff-is-active:disabled { filter: grayscale(80%) brightness(0.8) !important; opacity: 0.5 !important; } +/* Бафф уже активен (для кнопки способности) */ +.ability-button.buff-is-active:disabled { /* :disabled потому что кнопка будет задизейблена JS */ + filter: grayscale(80%) brightness(0.8) !important; opacity: 0.5 !important; +} @keyframes pulse-red-border { 0%, 100% { border-color: var(--damage-color); } 50% { border-color: #ffb3b3; } } -.ability-button.is-on-cooldown, .ability-button.is-silenced { filter: grayscale(70%) brightness(0.8) !important; } +/* Состояния на КД или под безмолвием */ +.ability-button.is-on-cooldown, .ability-button.is-silenced { + filter: grayscale(70%) brightness(0.8) !important; +} .ability-button.is-on-cooldown .ability-name, .ability-button.is-on-cooldown .ability-desc, -.ability-button.is-silenced .ability-name, .ability-button.is-silenced .ability-desc { opacity: 0.6; } -.ability-button.is-on-cooldown .ability-desc, .ability-button.is-silenced .ability-desc { display: none; } +.ability-button.is-silenced .ability-name, .ability-button.is-silenced .ability-desc { + opacity: 0.6; +} +/* Скрываем описание для КД и безмолвия, чтобы освободить место для таймера/иконки */ +.ability-button.is-on-cooldown .ability-desc, .ability-button.is-silenced .ability-desc { + display: none; +} .ability-cooldown-display { /* Также используется для отображения безмолвия */ position: absolute; bottom: 5px; left: 0; width: 100%; text-align: center; @@ -499,16 +539,7 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц display: none; line-height: 1; } .ability-button.is-on-cooldown .ability-cooldown-display, -.ability-button.is-silenced .ability-cooldown-display { display: block !important; } - -.ability-button.is-silenced { /* Дополнительные стили для безмолвия, если нужны */ - /* background: repeating-linear-gradient( -45deg, var(--button-disabled-bg), var(--button-disabled-bg) 8px, #4d3f50 8px, #4d3f50 16px ) !important; */ - /* border-color: #6a5f6b !important; */ -} -.ability-button.is-silenced::after { /* Иконка замка (опционально) */ - /* content: '\f023'; font-family: 'Font Awesome 6 Free'; font-weight: 900; */ - /* ... (стили для иконки замка, если используется) ... */ -} +.ability-button.is-silenced .ability-cooldown-display { display: block !important; } /* !important для переопределения display:none */ /* --- Панель Лога --- */ @@ -520,22 +551,23 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц #log-list { list-style: none; flex-grow: 1; overflow-y: auto; background-color: var(--log-bg); border: 1px solid var(--log-border); font-size: 0.85em; border-radius: 6px; - color: var(--log-text); padding: 10px; min-height: 0; + color: var(--log-text); padding: 10px; min-height: 0; /* Для корректной работы flex-grow и overflow */ } #log-list li { - padding: 4px 8px; border-bottom: 1px solid rgba(74, 80, 114, 0.5); + padding: 4px 8px; border-bottom: 1px solid rgba(74, 80, 114, 0.5); /* Полупрозрачная граница */ line-height: 1.35; word-break: break-word; transition: background-color 0.3s; } #log-list li:last-child { border-bottom: none; } -#log-list li:hover { background-color: rgba(255,255,255,0.03); } +#log-list li:hover { background-color: rgba(255,255,255,0.03); } /* Легкая подсветка при наведении */ +/* Стили для типов логов */ .log-damage { color: var(--damage-color); font-weight: 500; } .log-heal { color: var(--heal-color); font-weight: 500; } .log-block { color: var(--block-color); font-style: italic; } -.log-info { color: #b0c4de; } +.log-info { color: #b0c4de; } /* Светло-голубой для общей информации */ .log-turn { font-weight: bold; color: var(--turn-color); margin-top: 6px; border-top: 1px solid rgba(255, 215, 0, 0.3); padding-top: 6px; - font-size: 1.05em; display: block; + font-size: 1.05em; display: block; /* Чтобы занимал всю строку */ } .log-system { font-weight: bold; color: var(--system-color); font-style: italic; opacity: 0.8; } .log-effect { font-style: italic; color: var(--effect-color); } @@ -548,8 +580,9 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц align-items: center; z-index: 1000; backdrop-filter: blur(4px) brightness(0.7); opacity: 0; pointer-events: none; transition: opacity 0.4s ease-out; } -.modal.hidden { display: none !important; } +.modal.hidden { display: none !important; } /* Используем !important для переопределения display: flex */ .modal:not(.hidden) { opacity: 1; pointer-events: auto; } + .modal-content { background: var(--modal-content-bg); padding: 40px 50px; border-radius: 10px; text-align: center; border: 1px solid var(--panel-border); @@ -560,41 +593,73 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц } .modal:not(.hidden) .modal-content { transform: scale(1) translateY(0); opacity: 1; } .modal-content h2#result-message { margin-bottom: 25px; font-family: var(--font-fancy); font-size: 2.5em; line-height: 1.2; } -#restart-game-button { - padding: 12px 30px; font-size: 1.1em; cursor: pointer; background: var(--button-bg); - color: var(--button-text); border: 1px solid rgba(0,0,0,0.3); border-radius: 6px; - margin-top: 20px; font-weight: bold; text-transform: uppercase; - letter-spacing: 1px; transition: all 0.2s ease; box-shadow: 0 4px 8px rgba(0,0,0,0.4); + +/* Стили для кнопки "В меню выбора игры" - ОБЩИЙ КЛАСС */ +.modal-action-button { + padding: 12px 30px; + font-size: 1.1em; + cursor: pointer; + background: var(--button-bg); + color: var(--button-text); + border: 1px solid rgba(0,0,0,0.3); + border-radius: 6px; + margin-top: 20px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 1px; + transition: all 0.2s ease; + box-shadow: 0 4px 8px rgba(0,0,0,0.4); outline: none; } -#restart-game-button:hover:enabled { - background: var(--button-hover-bg); transform: scale(1.05) translateY(-1px); +.modal-action-button:hover:enabled { + background: var(--button-hover-bg); + transform: scale(1.05) translateY(-1px); box-shadow: 0 6px 12px rgba(0,0,0,0.5); } -#restart-game-button:active:enabled { transform: scale(1) translateY(0); box-shadow: 0 3px 6px rgba(0,0,0,0.4); } -#restart-game-button:disabled { background: var(--button-disabled-bg); color: var(--button-disabled-text); cursor: not-allowed; opacity: 0.7; } -#restart-game-button i { margin-right: 8px; } +.modal-action-button:active:enabled { + transform: scale(1) translateY(0); + box-shadow: 0 3px 6px rgba(0,0,0,0.4); +} +.modal-action-button:disabled { + background: var(--button-disabled-bg); + color: var(--button-disabled-text); + cursor: not-allowed; + opacity: 0.7; +} +.modal-action-button i { + margin-right: 8px; +} /* --- Анимации --- */ @keyframes flash-effect { - 0%, 100% { box-shadow: var(--initial-box-shadow, inherit); border-color: var(--initial-border-color, inherit); transform: scale(1); } + 0%, 100% { + box-shadow: var(--initial-box-shadow, 0 0 15px rgba(0, 0, 0, 0.4), inset 0 0 10px rgba(0, 0, 0, 0.3)); /* Возвращаем к исходной тени панели */ + border-color: var(--initial-border-color, var(--panel-border)); /* Возвращаем к исходному цвету рамки */ + transform: scale(1); + } 50% { box-shadow: 0 0 25px 10px var(--flash-color-outer, rgba(255, 255, 255, 0.7)), inset 0 0 15px var(--flash-color-inner, rgba(255, 255, 255, 0.4)), - 0 0 15px rgba(0, 0, 0, 0.4); + 0 0 15px rgba(0, 0, 0, 0.4); /* Сохраняем базовую тень */ border-color: var(--flash-border-color, #ffffff); - transform: scale(1.005); + transform: scale(1.005); /* Легкое увеличение */ } } /* Применение анимации каста к панели игрока (добавляется через JS) */ -#player-panel[class*="is-casting-"] { animation: flash-effect var(--cast-duration) ease-out; } +#player-panel[class*="is-casting-"] { /* Селектор для любого класса, начинающегося с is-casting- */ + animation: flash-effect var(--cast-duration) ease-out; + /* Сохраняем исходные значения для возврата в keyframes */ + --initial-box-shadow: 0 0 15px rgba(0, 0, 0, 0.4), inset 0 0 10px rgba(0, 0, 0, 0.3); + /* --initial-border-color: var(--panel-border); /* JS должен будет установить правильный исходный цвет рамки */ + /* Или, если мы знаем, что для player-panel это всегда --accent-player */ + --initial-border-color: var(--accent-player); /* Предполагая, что #player-panel всегда Елена */ +} /* Цвета для разных кастов (переменные для keyframes) - могут быть адаптированы или расширены */ #player-panel.is-casting-heal { --flash-color-outer: rgba(144, 238, 144, 0.7); --flash-color-inner: rgba(144, 238, 144, 0.4); --flash-border-color: var(--heal-color); } #player-panel.is-casting-fireball { --flash-color-outer: rgba(255, 100, 100, 0.7); --flash-color-inner: rgba(255, 100, 100, 0.4); --flash-border-color: var(--damage-color); } +#player-panel.is-casting-shadowBolt { --flash-color-outer: rgba(138, 43, 226, 0.6); --flash-color-inner: rgba(138, 43, 226, 0.3); --flash-border-color: var(--dark-energy-color); } /* ... Добавить для других способностей Елены и Альмагест, если нужна анимация каста ... */ -/* Например, для Теневого Сгустка Альмагест (если она в слоте игрока) */ -/* #player-panel.is-casting-shadowBolt { --flash-color-outer: rgba(138, 43, 226, 0.6); --flash-color-inner: rgba(138, 43, 226, 0.3); --flash-border-color: var(--dark-energy-color); } */ @keyframes shake-opponent { @@ -604,13 +669,24 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц } #opponent-panel.is-shaking { animation: shake-opponent var(--shake-duration) cubic-bezier(.36,.07,.19,.97) both; - transform: translate3d(0, 0, 0); backface-visibility: hidden; perspective: 1000px; + /* Дополнительные свойства для лучшей производительности анимации */ + transform: translate3d(0, 0, 0); + backface-visibility: hidden; + perspective: 1000px; } #opponent-panel.dissolving { - opacity: 0; transform: scale(0.9) translateY(20px); + /* opacity: 0; */ /* Управляется через transition */ + /* transform: scale(0.9) translateY(20px); */ /* Управляется через transition */ transition: opacity var(--dissolve-duration) ease-in, transform var(--dissolve-duration) ease-in; - pointer-events: none; + pointer-events: none; /* Чтобы нельзя было взаимодействовать во время исчезновения */ } +/* Состояние после завершения анимации dissolving, если класс остается */ +#opponent-panel.dissolved-state { /* Этот класс можно добавлять по завершению анимации через JS, если нужно */ + opacity: 0; + transform: scale(0.9) translateY(20px); +} + + @keyframes shake-short { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-3px); } @@ -622,20 +698,38 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц /* --- Отзывчивость (Медиа-запросы) --- */ @media (max-width: 900px) { - body { height: auto; overflow-y: auto; padding: 5px 0; font-size: 15px; justify-content: flex-start; } + body { + height: auto; /* Позволяем body расти по контенту */ + overflow-y: auto; /* Включаем прокрутку для body, если нужно */ + padding: 5px 0; /* Уменьшаем отступы */ + font-size: 15px; + justify-content: flex-start; /* Чтобы контент не пытался всегда быть по центру */ + } + .auth-game-setup-wrapper { + max-height: none; /* Убираем ограничение по высоте, body будет скроллиться */ + } .game-wrapper { padding: 5px; gap: 5px; height: auto; } .game-header h1 { font-size: 1.5em; } .battle-arena-container { flex-direction: column; height: auto; overflow: visible; } .player-column, .opponent-column { width: 100%; height: auto; overflow: visible; } - .fighter-panel, .controls-panel-new, .battle-log-new { min-height: auto; height: auto; padding: 10px; flex-grow: 0; flex-shrink: 1; } - .controls-panel-new { min-height: 200px; } - .battle-log-new { height: auto; min-height: 150px; } - #log-list { max-height: 200px; } + + .fighter-panel, .controls-panel-new, .battle-log-new { + min-height: auto; /* Убираем min-height, пусть контент определяет */ + height: auto; /* Высота по контенту */ + padding: 10px; + flex-grow: 0; /* Панели не должны растягиваться */ + flex-shrink: 1; /* Но могут сжиматься, если нужно */ + } + .controls-panel-new { min-height: 200px; /* Сохраняем для удобства клика */ } + .battle-log-new { height: auto; min-height: 150px; } /* Лог тоже по контенту */ + #log-list { max-height: 200px; } /* Ограничиваем высоту списка логов */ + .abilities-grid { max-height: none; overflow-y: visible; padding-bottom: 8px; } - .abilities-grid::after { display: none; } + .abilities-grid::after { display: none; } /* Убираем псевдоэлемент, т.к. нет скролла */ .ability-list, .controls-layout { overflow: visible; } + .fighter-name { font-size: 1.3em; } - .panel-content { margin-top: 10px; } + .panel-content { margin-top: 10px; } /* Восстанавливаем отступ, если был убран */ .stat-bar-container .bar-icon { font-size: 1.2em; } .bar { height: 18px; } .effects-area, .effect { font-size: 0.85em; } @@ -645,15 +739,18 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц .ability-button { font-size: 0.75em; padding: 5px; } .ability-button .ability-name { margin-bottom: 2px; } .ability-button .ability-desc { font-size: 0.65em; } + .modal-content { padding: 25px 30px; width: 90%; max-width: 400px; } .modal-content h2#result-message { font-size: 1.8em; } - #restart-game-button { font-size: 1em; padding: 10px 20px; } + .modal-action-button { font-size: 1em; padding: 10px 20px; } /* Адаптируем кнопку в модалке */ + + /* Стили для auth-game-setup на планшетах */ #game-setup { max-width: 95%; padding: 15px; } #game-setup h2 { font-size: 1.6em; } #game-setup h3 { font-size: 1.1em; } #game-setup button { padding: 8px 12px; font-size: 0.9em; } #game-setup input[type="text"] { width: calc(100% - 90px); max-width: 200px; padding: 8px;} #available-games-list { max-height: 180px; } - .character-selection label { margin: 0 10px; font-size: 1em; } /* Чуть меньше отступы для выбора на планшетах */ + .character-selection label { margin: 0 10px; font-size: 1em; } } @media (max-width: 480px) { @@ -663,23 +760,34 @@ label[for="char-almagest"] i { color: var(--accent-almagest); } /* Новый ц .abilities-grid { grid-template-columns: repeat(auto-fit, minmax(65px, 1fr)); gap: 5px; padding: 5px; } .ability-button { font-size: 0.7em; padding: 4px; } .ability-button .ability-name { margin-bottom: 1px; } - .ability-button .ability-desc { display: none; } + .ability-button .ability-desc { display: none; } /* Скрываем описание на маленьких экранах */ #log-list { font-size: 0.8em; max-height: 150px; } + .modal-content { padding: 20px; } .modal-content h2#result-message { font-size: 1.6em; } - #restart-game-button { font-size: 0.9em; padding: 8px 16px; } + .modal-action-button { font-size: 0.9em; padding: 8px 16px; } /* Адаптируем кнопку в модалке */ + .stat-bar-container .bar-icon { font-size: 1.1em; } .bar { height: 16px; } .bar-text { font-size: 0.7em; } .effects-area, .effect { font-size: 0.8em; } + + /* Стили для auth-game-setup на мобильных */ + .auth-game-setup-wrapper { padding: 15px; } #game-setup { padding: 10px; } #game-setup h2 { font-size: 1.4em; } #game-setup button { padding: 7px 10px; font-size: 0.85em; margin: 5px; } #game-setup input[type="text"] { width: 100%; max-width: none; margin-bottom: 10px; } - #game-setup div > button, #game-setup div > input[type="text"] { display: block; width: 100%; margin-left:0; margin-right:0; } - #game-setup div > input[type="text"] + button { margin-top: 5px;} + /* Делаем кнопки и инпуты в game-setup блочными для лучшего отображения на мобильных */ + #game-setup div > button, + #game-setup div > input[type="text"] { + display: block; width: 100%; margin-left:0; margin-right:0; + } + #game-setup div > input[type="text"] + button { margin-top: 5px;} /* Отступ для кнопки после инпута */ + #available-games-list { max-height: 120px; } #available-games-list li button { font-size: 0.75em; padding: 5px 8px;} + .character-selection { padding: 10px; } - .character-selection label { margin: 0 5px; font-size: 0.9em; display: block; margin-bottom: 5px; } /* Лейблы в столбик */ + .character-selection label { margin: 0 5px 5px 5px; font-size: 0.9em; display: block; } /* Лейблы в столбик */ .character-selection label i { margin-right: 5px;} } \ No newline at end of file diff --git a/server_modules/gameInstance.js b/server_modules/gameInstance.js index c594e2c..7930756 100644 --- a/server_modules/gameInstance.js +++ b/server_modules/gameInstance.js @@ -7,22 +7,22 @@ class GameInstance { constructor(gameId, io, mode = 'ai') { this.id = gameId; this.io = io; - this.mode = mode; + this.mode = mode; // 'ai' или 'pvp' this.players = {}; // { socket.id: { id: 'player'/'opponent', socket: socketObject, chosenCharacterKey?: 'elena'/'almagest' } } - this.playerSockets = {}; // { 'player': socketObject, 'opponent': socketObject } + this.playerSockets = {}; // { 'player': socketObject, 'opponent': socketObject } - для быстрого доступа к сокету по роли this.playerCount = 0; - this.gameState = null; + this.gameState = null; // Хранит текущее состояние игры (HP, ресурсы, эффекты, чей ход и т.д.) this.aiOpponent = (mode === 'ai'); - this.logBuffer = []; - this.restartVotes = new Set(); + this.logBuffer = []; // Буфер для сообщений лога боя + // this.restartVotes = new Set(); // Удалено, так как рестарт той же сессии убран - this.playerCharacterKey = null; - this.opponentCharacterKey = null; - this.ownerUserId = null; // userId создателя игры + // Ключи персонажей для текущей игры + this.playerCharacterKey = null; // Ключ персонажа в слоте 'player' (Елена или Альмагест) + this.opponentCharacterKey = null; // Ключ персонажа в слоте 'opponent' (Балард, Елена или Альмагест) + this.ownerUserId = null; // userId создателя игры (важно для PvP ожидающих игр) } addPlayer(socket, chosenCharacterKey = 'elena') { - // Проверка, не пытается ли игрок присоединиться к игре, в которой он уже есть if (this.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже находитесь в этой игре.' }); console.warn(`[Game ${this.id}] Игрок ${socket.id} попытался присоединиться к игре, в которой уже состоит.`); @@ -34,29 +34,30 @@ class GameInstance { return false; } - let assignedPlayerId; - let actualCharacterKey; + let assignedPlayerId; // 'player' или 'opponent' (технический ID слота) + let actualCharacterKey; // 'elena', 'almagest', 'balard' if (this.mode === 'ai') { - if (this.playerCount > 0) { // В AI игру может войти только один реальный игрок + if (this.playerCount > 0) { socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); return false; } assignedPlayerId = GAME_CONFIG.PLAYER_ID; - actualCharacterKey = 'elena'; + actualCharacterKey = 'elena'; // В AI режиме игрок всегда Елена if (socket.userData?.userId) { - this.ownerUserId = socket.userData.userId; + this.ownerUserId = socket.userData.userId; // Запоминаем создателя } - } else { // PvP - if (this.playerCount === 0) { // Первый игрок PvP + } else { // PvP режим + if (this.playerCount === 0) { // Первый игрок в PvP assignedPlayerId = GAME_CONFIG.PLAYER_ID; actualCharacterKey = (chosenCharacterKey === 'almagest') ? 'almagest' : 'elena'; if (socket.userData?.userId) { - this.ownerUserId = socket.userData.userId; + this.ownerUserId = socket.userData.userId; // Запоминаем создателя } - } else { // Второй игрок PvP + } else { // Второй игрок в PvP assignedPlayerId = GAME_CONFIG.OPPONENT_ID; - const firstPlayerInfo = Object.values(this.players)[0]; // Информация о первом игроке + const firstPlayerInfo = Object.values(this.players)[0]; + // Второй игрок автоматически получает "зеркального" персонажа actualCharacterKey = (firstPlayerInfo.chosenCharacterKey === 'elena') ? 'almagest' : 'elena'; } } @@ -64,15 +65,14 @@ class GameInstance { this.players[socket.id] = { id: assignedPlayerId, socket: socket, - chosenCharacterKey: actualCharacterKey + chosenCharacterKey: actualCharacterKey // Запоминаем ключ выбранного/назначенного персонажа }; this.playerSockets[assignedPlayerId] = socket; - this.playerCount++; - socket.join(this.id); + socket.join(this.id); // Присоединяем сокет к комнате игры const characterData = this._getCharacterBaseData(actualCharacterKey); - console.log(`[Game ${this.id}] Игрок ${socket.id} (userId: ${socket.userData?.userId || 'N/A'}) (${characterData?.name || 'Неизвестно'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Всего игроков: ${this.playerCount}. Owner: ${this.ownerUserId || 'N/A'}`); + console.log(`[Game ${this.id}] Игрок ${socket.userData?.username || socket.id} (userId: ${socket.userData?.userId || 'N/A'}) (${characterData?.name || 'Неизвестно'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Всего игроков: ${this.playerCount}. Owner: ${this.ownerUserId || 'N/A'}`); if (this.mode === 'pvp' && this.playerCount < 2) { socket.emit('waitingForOpponent'); @@ -80,12 +80,12 @@ class GameInstance { // Если игра готова к старту (2 игрока в PvP, или 1 в AI) if ((this.mode === 'ai' && this.playerCount === 1) || (this.mode === 'pvp' && this.playerCount === 2)) { - this.initializeGame(); + this.initializeGame(); // Инициализируем состояние игры if (this.gameState) { - this.startGame(); + this.startGame(); // Запускаем игру } else { - // Ошибка инициализации уже должна была быть залогирована и отправлена клиенту - console.error(`[Game ${this.id}] Не удалось запустить игру, так как gameState не был инициализирован.`); + console.error(`[Game ${this.id}] Не удалось запустить игру: gameState не был инициализирован.`); + // Ошибка должна была быть отправлена клиенту из initializeGame } } return true; @@ -94,20 +94,23 @@ class GameInstance { removePlayer(socketId) { const playerInfo = this.players[socketId]; if (playerInfo) { - const playerRole = playerInfo.id; - let characterKeyToRemove = playerInfo.chosenCharacterKey; + const playerRole = playerInfo.id; // 'player' or 'opponent' + let characterKeyOfLeavingPlayer = playerInfo.chosenCharacterKey; const userIdOfLeavingPlayer = playerInfo.socket?.userData?.userId; + const usernameOfLeavingPlayer = playerInfo.socket?.userData?.username || socketId; - if (this.mode === 'ai' && playerRole === GAME_CONFIG.OPPONENT_ID) { // AI оппонент не имеет chosenCharacterKey в this.players - characterKeyToRemove = 'balard'; - } else if (!characterKeyToRemove && this.gameState) { // Фоллбэк, если ключ не был в playerInfo - characterKeyToRemove = (playerRole === GAME_CONFIG.PLAYER_ID) + // Для AI оппонента, у него нет записи в this.players, но его ключ 'balard' + if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) { // Если уходит игрок из AI игры + // AI оппонент не имеет 'chosenCharacterKey' в this.players, так как он не сокет + } else if (!characterKeyOfLeavingPlayer && this.gameState) { + // Фоллбэк, если ключ не был в playerInfo (маловероятно для реальных игроков) + characterKeyOfLeavingPlayer = (playerRole === GAME_CONFIG.PLAYER_ID) ? this.gameState.player?.characterKey : this.gameState.opponent?.characterKey; } - const characterData = this._getCharacterBaseData(characterKeyToRemove); - console.log(`[Game ${this.id}] Игрок ${socketId} (userId: ${userIdOfLeavingPlayer || 'N/A'}) (${characterData?.name || 'Неизвестно'}, роль: ${playerRole}, персонаж: ${characterKeyToRemove || 'N/A'}) покинул игру.`); + const characterData = this._getCharacterBaseData(characterKeyOfLeavingPlayer); + console.log(`[Game ${this.id}] Игрок ${usernameOfLeavingPlayer} (socket: ${socketId}, userId: ${userIdOfLeavingPlayer || 'N/A'}) (${characterData?.name || 'Неизвестно'}, роль: ${playerRole}, персонаж: ${characterKeyOfLeavingPlayer || 'N/A'}) покинул игру.`); if (this.playerSockets[playerRole] && this.playerSockets[playerRole].id === socketId) { delete this.playerSockets[playerRole]; @@ -115,17 +118,19 @@ class GameInstance { delete this.players[socketId]; this.playerCount--; + // Если создатель PvP игры вышел, и остался один игрок, обновляем ownerUserId if (this.mode === 'pvp' && this.ownerUserId === userIdOfLeavingPlayer && this.playerCount === 1) { const remainingPlayerSocketId = Object.keys(this.players)[0]; const remainingPlayerSocket = this.players[remainingPlayerSocketId]?.socket; - this.ownerUserId = remainingPlayerSocket?.userData?.userId || null; - console.log(`[Game ${this.id}] Owner left. New potential owner for pending game: ${this.ownerUserId || remainingPlayerSocketId}`); + this.ownerUserId = remainingPlayerSocket?.userData?.userId || null; // Новый владелец - userId оставшегося или null + console.log(`[Game ${this.id}] Owner left PvP game. New potential owner for pending game: ${this.ownerUserId || remainingPlayerSocketId}`); } else if (this.playerCount === 0) { - this.ownerUserId = null; + this.ownerUserId = null; // Если игра пуста, нет владельца } + // Если игра была активна, завершаем ее из-за дисконнекта if (this.gameState && !this.gameState.isGameOver) { - this.endGameDueToDisconnect(playerRole, characterKeyToRemove); + this.endGameDueToDisconnect(playerRole, characterKeyOfLeavingPlayer || (playerRole === GAME_CONFIG.PLAYER_ID ? this.playerCharacterKey : this.opponentCharacterKey) ); } } } @@ -135,12 +140,15 @@ class GameInstance { this.gameState.isGameOver = true; const winnerRole = disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const disconnectedCharacterData = this._getCharacterBaseData(disconnectedCharacterKey); + const winnerCharacterKey = (winnerRole === GAME_CONFIG.PLAYER_ID) ? this.playerCharacterKey : this.opponentCharacterKey; + const winnerCharacterData = this._getCharacterBaseData(winnerCharacterKey); - this.addToLog(`Игрок ${disconnectedCharacterData?.name || 'Неизвестный'} покинул игру.`, GAME_CONFIG.LOG_TYPE_SYSTEM); - this.io.to(this.id).emit('opponentDisconnected', { disconnectedPlayerId: disconnectedPlayerRole }); + + this.addToLog(`Игрок ${disconnectedCharacterData?.name || 'Неизвестный'} покинул игру. Победа присуждается ${winnerCharacterData?.name || winnerRole}!`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.io.to(this.id).emit('opponentDisconnected', { disconnectedPlayerId: disconnectedPlayerRole, disconnectedCharacterName: disconnectedCharacterData?.name }); this.io.to(this.id).emit('gameOver', { - winnerId: (this.mode === 'pvp' || winnerRole === GAME_CONFIG.OPPONENT_ID) ? winnerRole : GAME_CONFIG.OPPONENT_ID, + winnerId: winnerRole, reason: 'opponent_disconnected', finalGameState: this.gameState, log: this.consumeLogBuffer() @@ -152,52 +160,59 @@ class GameInstance { console.log(`[Game ${this.id}] Initializing game state for (re)start... Mode: ${this.mode}`); if (this.mode === 'ai') { - this.playerCharacterKey = 'elena'; - this.opponentCharacterKey = 'balard'; + this.playerCharacterKey = 'elena'; // Игрок в AI всегда Елена + this.opponentCharacterKey = 'balard'; // AI всегда Балард } else { // pvp const playerSocketInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); const opponentSocketInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); - this.playerCharacterKey = playerSocketInfo?.chosenCharacterKey || 'elena'; // Игрок 1 (слот 'player') - if (this.playerCount === 2 && opponentSocketInfo) { // Если есть второй игрок (слот 'opponent') + this.playerCharacterKey = playerSocketInfo?.chosenCharacterKey || 'elena'; // Фоллбэк, если что-то пошло не так + + if (this.playerCount === 2 && opponentSocketInfo) { this.opponentCharacterKey = opponentSocketInfo.chosenCharacterKey; - // Убедимся, что персонажи разные + // Дополнительная проверка, чтобы персонажи были разными, если вдруг оба выбрали одного if (this.playerCharacterKey === this.opponentCharacterKey) { this.opponentCharacterKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; - opponentSocketInfo.chosenCharacterKey = this.opponentCharacterKey; // Обновляем и в информации о сокете - console.warn(`[Game ${this.id}] Corrected character selection in PvP. Opponent for slot ${GAME_CONFIG.OPPONENT_ID} is now ${this.opponentCharacterKey}`); + // Обновляем ключ у второго игрока, если он был изменен + if (opponentSocketInfo.chosenCharacterKey !== this.opponentCharacterKey) { + opponentSocketInfo.chosenCharacterKey = this.opponentCharacterKey; + console.warn(`[Game ${this.id}] PvP character conflict resolved. Opponent in slot '${GAME_CONFIG.OPPONENT_ID}' is now ${this.opponentCharacterKey}.`); + } } - } else if (this.playerCount === 1) { // Только один игрок в PvP - this.opponentCharacterKey = null; // Оппонент еще не определен - } else { + } else if (this.playerCount === 1) { // Только один игрок в PvP, оппонент еще не определен + this.opponentCharacterKey = null; + } else { // Неожиданная ситуация console.error(`[Game ${this.id}] Unexpected playerCount (${this.playerCount}) or missing socketInfo during PvP character key assignment.`); - this.opponentCharacterKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; // Фоллбэк + this.opponentCharacterKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; // Аварийный фоллбэк } } - - console.log(`[Game ${this.id}] Finalizing characters - Player Slot: ${this.playerCharacterKey}, Opponent Slot: ${this.opponentCharacterKey || 'N/A'}`); + console.log(`[Game ${this.id}] Finalizing characters - Player Slot ('${GAME_CONFIG.PLAYER_ID}'): ${this.playerCharacterKey}, Opponent Slot ('${GAME_CONFIG.OPPONENT_ID}'): ${this.opponentCharacterKey || 'N/A (Waiting)'}`); const playerBase = this._getCharacterBaseData(this.playerCharacterKey); const playerAbilities = this._getCharacterAbilities(this.playerCharacterKey); let opponentBase = null; let opponentAbilities = null; + // Загружаем данные оппонента, только если он определен (т.е. PvP игра с 2 игроками или AI игра) if (this.opponentCharacterKey) { opponentBase = this._getCharacterBaseData(this.opponentCharacterKey); opponentAbilities = this._getCharacterAbilities(this.opponentCharacterKey); } + // Проверяем, готовы ли мы к созданию полного игрового состояния const isReadyForFullGameState = (this.mode === 'ai') || (this.mode === 'pvp' && this.playerCount === 2 && opponentBase && opponentAbilities); - if (!playerBase || !playerAbilities || (!isReadyForFullGameState && !(this.mode === 'pvp' && this.playerCount === 1)) ) { - console.error(`[Game ${this.id}] CRITICAL ERROR: Failed to load necessary character data for initialization! Player: ${this.playerCharacterKey}, Opponent: ${this.opponentCharacterKey}, PlayerCount: ${this.playerCount}, Mode: ${this.mode}`); - this.logBuffer = []; + if (!playerBase || !playerAbilities || (!isReadyForFullGameState && !(this.mode === 'pvp' && this.playerCount === 1))) { + console.error(`[Game ${this.id}] CRITICAL ERROR: Failed to load necessary character data for initialization! PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}, PlayerCount: ${this.playerCount}, Mode: ${this.mode}`); + this.logBuffer = []; // Очищаем лог this.addToLog('Критическая ошибка сервера при инициализации персонажей!', GAME_CONFIG.LOG_TYPE_SYSTEM); - this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при инициализации игры.' }); - this.gameState = null; + // Уведомляем игроков в комнате об ошибке + this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при инициализации игры. Не удалось загрузить данные персонажей.' }); + this.gameState = null; // Не создаем gameState return; } + // Создаем gameState this.gameState = { player: { id: GAME_CONFIG.PLAYER_ID, characterKey: this.playerCharacterKey, name: playerBase.name, @@ -206,53 +221,64 @@ class GameInstance { resourceName: playerBase.resourceName, attackPower: playerBase.attackPower, isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {} }, - opponent: { + opponent: { // Данные оппонента, если он есть, иначе плейсхолдеры id: GAME_CONFIG.OPPONENT_ID, characterKey: this.opponentCharacterKey, - name: opponentBase?.name || 'Ожидание...', + name: opponentBase?.name || 'Ожидание игрока...', currentHp: opponentBase?.maxHp || 1, maxHp: opponentBase?.maxHp || 1, currentResource: opponentBase?.maxResource || 0, maxResource: opponentBase?.maxResource || 0, resourceName: opponentBase?.resourceName || 'Неизвестно', attackPower: opponentBase?.attackPower || 0, isBlocking: false, activeEffects: [], + // Специальные кулдауны для Баларда (AI) silenceCooldownTurns: this.opponentCharacterKey === 'balard' ? 0 : undefined, manaDrainCooldownTurns: this.opponentCharacterKey === 'balard' ? 0 : undefined, abilityCooldowns: {} }, - isPlayerTurn: Math.random() < 0.5, isGameOver: false, turnNumber: 1, gameMode: this.mode + isPlayerTurn: Math.random() < 0.5, // Случайный первый ход + isGameOver: false, + turnNumber: 1, + gameMode: this.mode }; + // Инициализация кулдаунов способностей playerAbilities.forEach(ability => { - if (typeof ability.cooldown === 'number' && ability.cooldown > 0) this.gameState.player.abilityCooldowns[ability.id] = 0; + if (typeof ability.cooldown === 'number' && ability.cooldown > 0) { + this.gameState.player.abilityCooldowns[ability.id] = 0; + } }); if (opponentAbilities) { opponentAbilities.forEach(ability => { let cd = 0; if (ability.cooldown) cd = ability.cooldown; - else if (this.opponentCharacterKey === 'balard') { - if (ability.internalCooldownFromConfig && GAME_CONFIG[ability.internalCooldownFromConfig]) cd = GAME_CONFIG[ability.internalCooldownFromConfig]; - else if (typeof ability.internalCooldownValue === 'number') cd = ability.internalCooldownValue; + else if (this.opponentCharacterKey === 'balard') { // Специальные внутренние КД для AI Баларда + if (ability.internalCooldownFromConfig && GAME_CONFIG[ability.internalCooldownFromConfig]) { + cd = GAME_CONFIG[ability.internalCooldownFromConfig]; + } else if (typeof ability.internalCooldownValue === 'number') { + cd = ability.internalCooldownValue; + } + } + if (cd > 0) { + this.gameState.opponent.abilityCooldowns[ability.id] = 0; } - if (cd > 0) this.gameState.opponent.abilityCooldowns[ability.id] = 0; }); } - this.restartVotes.clear(); - const isFullGameReadyForLog = (this.mode === 'ai' && this.playerCount === 1) || (this.mode === 'pvp' && this.playerCount === 2 && this.opponentCharacterKey); - const isRestart = this.logBuffer.length > 0 && isFullGameReadyForLog; - this.logBuffer = []; - if (isFullGameReadyForLog) { + const isRestart = this.logBuffer.length > 0 && isReadyForFullGameState; // Проверяем, был ли лог до этого (признак рестарта) + this.logBuffer = []; // Очищаем лог перед новой игрой/рестартом + if (isReadyForFullGameState) { // Лог о начале битвы только если игра полностью готова this.addToLog(isRestart ? '⚔️ Игра перезапущена! ⚔️' : '⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); } console.log(`[Game ${this.id}] Game state initialized. isGameOver: ${this.gameState.isGameOver}. First turn: ${this.gameState.isPlayerTurn ? this.gameState.player.name : (this.gameState.opponent?.name || 'Оппонент')}`); } startGame() { - if (!this.gameState || !this.gameState.player || !this.gameState.opponent || !this.opponentCharacterKey || this.gameState.opponent.name === 'Ожидание...') { + // Проверяем, что игра полностью готова к запуску (оба игрока есть и gameState инициализирован) + if (!this.gameState || !this.gameState.player || !this.gameState.opponent || !this.opponentCharacterKey || this.gameState.opponent.name === 'Ожидание игрока...') { if (this.mode === 'pvp' && this.playerCount === 1 && !this.opponentCharacterKey) { - console.log(`[Game ${this.id}] startGame: Waiting for opponent in PvP game.`); + console.log(`[Game ${this.id}] startGame: PvP игра ожидает второго игрока.`); } else if (!this.gameState) { console.error(`[Game ${this.id}] Game cannot start: gameState is null.`); } else { - console.warn(`[Game ${this.id}] Game not fully ready to start. OpponentKey: ${this.opponentCharacterKey}, OpponentName: ${this.gameState.opponent.name}, PlayerCount: ${this.playerCount}`); + console.warn(`[Game ${this.id}] Game not fully ready to start. OpponentKey: ${this.opponentCharacterKey}, OpponentName: ${this.gameState.opponent?.name}, PlayerCount: ${this.playerCount}`); } return; } @@ -263,84 +289,65 @@ class GameInstance { if (!playerCharData || !opponentCharData) { console.error(`[Game ${this.id}] CRITICAL ERROR: startGame - Failed to load character data! PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}`); - this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при старте игры (данные персонажей).' }); + this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при старте игры (не удалось загрузить данные персонажей).' }); return; } - Object.values(this.players).forEach(pInfo => { + // Отправляем каждому игроку его персональные данные для игры + Object.values(this.players).forEach(playerInfo => { let dataForThisClient; - if (pInfo.id === GAME_CONFIG.PLAYER_ID) { + if (playerInfo.id === GAME_CONFIG.PLAYER_ID) { // Этот клиент играет за слот 'player' dataForThisClient = { - gameId: this.id, yourPlayerId: pInfo.id, initialGameState: this.gameState, + gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, playerBaseStats: playerCharData.baseStats, opponentBaseStats: opponentCharData.baseStats, playerAbilities: playerCharData.abilities, opponentAbilities: opponentCharData.abilities, - log: this.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } + log: this.consumeLogBuffer(), // Первый игрок получает весь накопленный лог + clientConfig: { ...GAME_CONFIG } // Копия конфига для клиента }; - } else { + } else { // Этот клиент играет за слот 'opponent' dataForThisClient = { - gameId: this.id, yourPlayerId: pInfo.id, initialGameState: this.gameState, + gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, + // Меняем местами статы и абилки, чтобы клиент видел себя как 'player', а противника как 'opponent' playerBaseStats: opponentCharData.baseStats, opponentBaseStats: playerCharData.baseStats, playerAbilities: opponentCharData.abilities, opponentAbilities: playerCharData.abilities, - log: [], clientConfig: { ...GAME_CONFIG } + log: [], // Второй игрок не получает стартовый лог, чтобы избежать дублирования + clientConfig: { ...GAME_CONFIG } }; } - pInfo.socket.emit('gameStarted', dataForThisClient); + playerInfo.socket.emit('gameStarted', dataForThisClient); }); const firstTurnName = this.gameState.isPlayerTurn ? this.gameState.player.name : this.gameState.opponent.name; - this.addToLog(`--- ${firstTurnName} ходит первым! ---`, GAME_CONFIG.LOG_TYPE_TURN); - this.broadcastGameStateUpdate(); + this.addToLog(`--- ${firstTurnName} ходит первым! (Ход ${this.gameState.turnNumber}) ---`, GAME_CONFIG.LOG_TYPE_TURN); + this.broadcastGameStateUpdate(); // Отправляем начальное состояние и лог + // Если ход AI, запускаем его логику if (!this.gameState.isPlayerTurn) { if (this.aiOpponent && this.opponentCharacterKey === 'balard') { setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); - } else { + } else { // PvP, ход второго игрока this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.OPPONENT_ID }); } - } else { + } else { // Ход первого игрока (реального) this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.PLAYER_ID }); } } - handleVoteRestart(requestingSocketId) { - if (!this.gameState || !this.gameState.isGameOver) { - const playerSocket = this.players[requestingSocketId]?.socket || this.io.sockets.sockets.get(requestingSocketId); - if(playerSocket) playerSocket.emit('gameError', {message: "Нельзя рестартовать игру, которая не завершена."}); - return; - } - if (!this.players[requestingSocketId]) return; - - this.restartVotes.add(requestingSocketId); - const voterInfo = this.players[requestingSocketId]; - const voterCharacterKey = voterInfo.id === GAME_CONFIG.PLAYER_ID ? this.gameState.player.characterKey : this.gameState.opponent.characterKey; - const voterCharacterData = this._getCharacterBaseData(voterCharacterKey); - - this.addToLog(`Игрок ${voterCharacterData?.name || 'Неизвестный'} (${voterInfo.id}) голосует за рестарт.`, GAME_CONFIG.LOG_TYPE_SYSTEM); - this.broadcastLogUpdate(); - - const requiredVotes = this.playerCount > 0 ? this.playerCount : 1; - if (this.restartVotes.size >= requiredVotes) { - this.initializeGame(); - if (this.gameState) this.startGame(); - else console.error(`[Game ${this.id}] Failed to restart: gameState is null after re-initialization.`); - } else if (this.mode === 'pvp') { - this.io.to(this.id).emit('waitingForRestartVote', { - voterCharacterName: voterCharacterData?.name || 'Неизвестный', - voterRole: voterInfo.id, - votesNeeded: requiredVotes - this.restartVotes.size - }); - } - } + // Метод handleVoteRestart удален processPlayerAction(requestingSocketId, actionData) { if (!this.gameState || this.gameState.isGameOver) return; const actingPlayerInfo = this.players[requestingSocketId]; if (!actingPlayerInfo) { console.error(`[Game ${this.id}] Action from unknown socket ${requestingSocketId}`); return; } - const actingPlayerRole = actingPlayerInfo.id; + const actingPlayerRole = actingPlayerInfo.id; // 'player' или 'opponent' const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || (!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID); - if (!isCorrectTurn) { actingPlayerInfo.socket.emit('gameError', { message: "Сейчас не ваш ход!" }); return; } + + if (!isCorrectTurn) { + actingPlayerInfo.socket.emit('gameError', { message: "Сейчас не ваш ход!" }); + return; + } const attackerState = this.gameState[actingPlayerRole]; const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; @@ -350,196 +357,203 @@ class GameInstance { const defenderData = this._getCharacterData(defenderState.characterKey); if (!attackerData || !defenderData) { - this.addToLog('Критическая ошибка сервера при обработке действия (данные персонажа)!', GAME_CONFIG.LOG_TYPE_SYSTEM); + this.addToLog('Критическая ошибка сервера при обработке действия (не найдены данные персонажа)!', GAME_CONFIG.LOG_TYPE_SYSTEM); this.broadcastLogUpdate(); return; } - const attackerBaseStats = attackerData.baseStats; - const defenderBaseStats = defenderData.baseStats; - const attackerAbilities = attackerData.abilities; - let actionValid = true; + let actionValid = true; // Флаг валидности действия + // Обработка атаки if (actionData.actionType === 'attack') { - let taunt = ""; - if (attackerState.characterKey === 'elena') { - taunt = serverGameLogic.getElenaTaunt('playerBasicAttack', {}, GAME_CONFIG, gameData, this.gameState); - } - - const attackBuffAbilityId = attackerState.characterKey === 'elena' ? GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH - : (attackerState.characterKey === 'almagest' ? GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK : null); - let attackBuffEffect = null; - if (attackBuffAbilityId) { - attackBuffEffect = attackerState.activeEffects.find(eff => eff.id === attackBuffAbilityId); - } - - if (attackerState.characterKey === 'elena' && taunt && taunt !== "(Молчание)") { - this.addToLog(`${attackerState.name} атакует: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); - } else { - this.addToLog(`${attackerState.name} атакует ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); - } - - if (attackBuffEffect && !attackBuffEffect.justCast) { - this.addToLog(`✨ Эффект "${attackBuffEffect.name}" активен! Атака восстановит ${attackerState.resourceName}!`, GAME_CONFIG.LOG_TYPE_EFFECT); - } - serverGameLogic.performAttack( - attackerState, defenderState, attackerBaseStats, defenderBaseStats, + attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData ); - - if (attackBuffEffect) { - const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerBaseStats.maxResource - attackerState.currentResource); - if (actualRegen > 0) { - attackerState.currentResource += actualRegen; - this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${attackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); + // Логика для "Силы Природы" и аналогов - бафф применяется после атаки + const attackBuffAbilityId = attackerState.characterKey === 'elena' ? GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH + : (attackerState.characterKey === 'almagest' ? GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK : null); + if (attackBuffAbilityId) { + const attackBuffEffect = attackerState.activeEffects.find(eff => eff.id === attackBuffAbilityId); + if (attackBuffEffect && !attackBuffEffect.justCast) { // Эффект должен быть активен и не только что применен + const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerData.baseStats.maxResource - attackerState.currentResource); + if (actualRegen > 0) { + attackerState.currentResource += actualRegen; + this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${attackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); + } + // Не удаляем эффект, если он многоразовый. Если одноразовый - удалить тут. + // В текущей реализации Сила Природы имеет duration, поэтому управляется через processEffects. } - // Эффект НЕ удаляется здесь для многоразового действия } + // Обработка способности } else if (actionData.actionType === 'ability' && actionData.abilityId) { - const ability = attackerAbilities.find(ab => ab.id === actionData.abilityId); + const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId); if (!ability) { actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); return; } + // Проверки валидности использования способности if (attackerState.currentResource < ability.cost) { this.addToLog(`${attackerState.name} пытается применить "${ability.name}", но не хватает ${attackerState.resourceName}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } - if (actionValid && attackerState.abilityCooldowns && attackerState.abilityCooldowns[ability.id] > 0) { this.addToLog(`"${ability.name}" еще не готова (КД: ${attackerState.abilityCooldowns[ability.id]} х.).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (actionValid && attackerState.abilityCooldowns && attackerState.abilityCooldowns[ability.id] > 0) { this.addToLog(`"${ability.name}" еще на перезарядке (${attackerState.abilityCooldowns[ability.id]} х.).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + // Специальные КД для Баларда if (actionValid && attackerState.characterKey === 'balard') { - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && (attackerState.silenceCooldownTurns > 0 || (attackerState.abilityCooldowns && attackerState.abilityCooldowns[ability.id] > 0))) { this.addToLog(`"${ability.name}" еще не готова (спец. КД или общий КД).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && (attackerState.manaDrainCooldownTurns > 0 || (attackerState.abilityCooldowns && attackerState.abilityCooldowns[ability.id] > 0))) { this.addToLog(`"${ability.name}" еще не готова (спец. КД или общий КД).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && attackerState.silenceCooldownTurns > 0) { this.addToLog(`"${ability.name}" еще не готова (спец. КД).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && attackerState.manaDrainCooldownTurns > 0) { this.addToLog(`"${ability.name}" еще не готова (спец. КД).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } } + // Нельзя кастовать бафф, если он уже активен if (actionValid && ability.type === GAME_CONFIG.ACTION_TYPE_BUFF && attackerState.activeEffects.some(e => e.id === ability.id)) { this.addToLog(`Эффект "${ability.name}" уже активен!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } - const isDebuffAbility = ability.id === GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF; - if (actionValid && isDebuffAbility) { - if (defenderState.activeEffects.some(e => e.id === 'effect_' + ability.id)) { this.addToLog(`Эффект "${ability.name}" уже наложен на ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + // Нельзя кастовать дебафф на цель, если он уже на ней (для определенных дебаффов) + const isTargetedDebuff = ability.id === GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF; + if (actionValid && isTargetedDebuff) { + if (defenderState.activeEffects.some(e => e.id === 'effect_' + ability.id)) { // Ищем эффект с префиксом effect_ + this.addToLog(`Эффект "${ability.name}" уже наложен на ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); + actionValid = false; + } } if (actionValid) { attackerState.currentResource -= ability.cost; + // Установка кулдауна let baseCooldown = 0; if (ability.cooldown) baseCooldown = ability.cooldown; - else if (attackerState.characterKey === 'balard') { - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE) { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; } + else if (attackerState.characterKey === 'balard') { // Специальные внутренние КД для AI + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE) { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN;} else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && ability.internalCooldownValue) { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue; } else { if (ability.internalCooldownFromConfig && GAME_CONFIG[ability.internalCooldownFromConfig]) baseCooldown = GAME_CONFIG[ability.internalCooldownFromConfig]; else if (typeof ability.internalCooldownValue === 'number') baseCooldown = ability.internalCooldownValue; } } - if (baseCooldown > 0 && attackerState.abilityCooldowns) attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; + if (baseCooldown > 0 && attackerState.abilityCooldowns) attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; // +1, т.к. уменьшится в конце этого хода - let logMessage = `${attackerState.name} колдует "${ability.name}" (-${ability.cost} ${attackerState.resourceName})`; - if (attackerState.characterKey === 'elena') { - const taunt = serverGameLogic.getElenaTaunt('playerActionCast', { abilityId: ability.id }, GAME_CONFIG, gameData, this.gameState); - if (taunt && taunt !== "(Молчание)") logMessage += `: "${taunt}"`; - } - const logType = ability.type === GAME_CONFIG.ACTION_TYPE_HEAL ? GAME_CONFIG.LOG_TYPE_HEAL : ability.type === GAME_CONFIG.ACTION_TYPE_DAMAGE ? GAME_CONFIG.LOG_TYPE_DAMAGE : GAME_CONFIG.LOG_TYPE_EFFECT; - this.addToLog(logMessage, logType); - - const targetForAbility = (ability.type === GAME_CONFIG.ACTION_TYPE_HEAL || ability.type === GAME_CONFIG.ACTION_TYPE_BUFF) ? attackerState : defenderState; - const targetBaseStatsForAbility = (targetForAbility.id === defenderState.id ? defenderBaseStats : attackerBaseStats); - serverGameLogic.applyAbilityEffect(ability, attackerState, targetForAbility, attackerBaseStats, targetBaseStatsForAbility, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); } - } else { actionValid = false; } + } else { actionValid = false; } // Неизвестный тип действия - if (!actionValid) { this.broadcastLogUpdate(); return; } - if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } - setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + if (!actionValid) { this.broadcastLogUpdate(); return; } // Если действие невалидно, просто отправляем лог и выходим + + if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } // Проверяем конец игры после действия + setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); // Переключаем ход с задержкой } switchTurn() { if (!this.gameState || this.gameState.isGameOver) return; + const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const endingTurnActorState = this.gameState[endingTurnActorRole]; const endingTurnCharacterData = this._getCharacterData(endingTurnActorState.characterKey); if (!endingTurnCharacterData) { console.error(`SwitchTurn Error: No char data for ${endingTurnActorState.characterKey}`); return; } + // Обработка эффектов в конце хода (DoT, HoT, истечение баффов/дебаффов) serverGameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnCharacterData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); - serverGameLogic.updateBlockingStatus(this.gameState.player); + serverGameLogic.updateBlockingStatus(this.gameState.player); // Обновляем статус блока для обоих serverGameLogic.updateBlockingStatus(this.gameState.opponent); + // Уменьшение кулдаунов способностей if (endingTurnActorState.abilityCooldowns) { serverGameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnCharacterData.abilities, endingTurnActorState.name, this.addToLog.bind(this)); } + // Специальные КД для Баларда if (endingTurnActorState.characterKey === 'balard') { if (endingTurnActorState.silenceCooldownTurns !== undefined && endingTurnActorState.silenceCooldownTurns > 0) endingTurnActorState.silenceCooldownTurns--; if (endingTurnActorState.manaDrainCooldownTurns !== undefined && endingTurnActorState.manaDrainCooldownTurns > 0) endingTurnActorState.manaDrainCooldownTurns--; } - if (endingTurnActorRole === GAME_CONFIG.OPPONENT_ID) { - const playerStateInGame = this.gameState.player; + // Уменьшение длительности безмолвия на конкретные абилки (если это ход оппонента) + if (endingTurnActorRole === GAME_CONFIG.OPPONENT_ID) { // Если это был ход оппонента (AI или PvP) + const playerStateInGame = this.gameState.player; // Игрок, на которого могло быть наложено безмолвие if (playerStateInGame.disabledAbilities?.length > 0) { const playerCharAbilities = this._getCharacterAbilities(playerStateInGame.characterKey); if (playerCharAbilities) serverGameLogic.processDisabledAbilities(playerStateInGame.disabledAbilities, playerCharAbilities, playerStateInGame.name, this.addToLog.bind(this)); } } - if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } - this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; - if (this.gameState.isPlayerTurn) this.gameState.turnNumber++; + if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } // Проверяем конец игры после эффектов + + this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; // Меняем ход + if (this.gameState.isPlayerTurn) this.gameState.turnNumber++; // Новый ход игрока - увеличиваем номер хода + const currentTurnActorState = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); this.broadcastGameStateUpdate(); + // Если ход AI, запускаем его логику if (!this.gameState.isPlayerTurn) { if (this.aiOpponent && this.opponentCharacterKey === 'balard') { setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); - } else { + } else { // PvP, ход второго игрока this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.OPPONENT_ID }); } - } else { + } else { // Ход первого игрока this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.PLAYER_ID }); } } processAiTurn() { if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent || this.opponentCharacterKey !== 'balard') { - if(!this.gameState || this.gameState.isGameOver) return; + if(!this.gameState || this.gameState.isGameOver) return; // Если игра закончена, ничего не делаем + // Если не ход AI или это не AI Балард, выходим (хотя эта проверка должна быть раньше) return; } + const aiDecision = serverGameLogic.decideAiAction(this.gameState, gameData, GAME_CONFIG, this.addToLog.bind(this)); - const attackerState = this.gameState.opponent; + const attackerState = this.gameState.opponent; // AI всегда в слоте 'opponent' в AI режиме const defenderState = this.gameState.player; const attackerData = this._getCharacterData('balard'); - const defenderData = this._getCharacterData(defenderState.characterKey); - if (!attackerData || !defenderData) { this.switchTurn(); return; } + const defenderData = this._getCharacterData(defenderState.characterKey); // Обычно 'elena' + + if (!attackerData || !defenderData) { this.addToLog("AI не может действовать: ошибка данных персонажа.", GAME_CONFIG.LOG_TYPE_SYSTEM); this.switchTurn(); return; } let actionValid = true; if (aiDecision.actionType === 'attack') { - this.addToLog(`${attackerState.name} атакует ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); + // Лог атаки уже будет в performAttack serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); } else if (aiDecision.actionType === 'ability' && aiDecision.ability) { const ability = aiDecision.ability; + // Проверки валидности (ресурс, КД) для AI if (attackerState.currentResource < ability.cost || (attackerState.abilityCooldowns && attackerState.abilityCooldowns[ability.id] > 0) || (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && attackerState.silenceCooldownTurns > 0) || (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && attackerState.manaDrainCooldownTurns > 0) ) { actionValid = false; - this.addToLog(`AI ${attackerState.name} не смог применить "${ability.name}" (ресурс/КД).`, GAME_CONFIG.LOG_TYPE_INFO); + this.addToLog(`AI ${attackerState.name} не смог применить "${ability.name}" (недостаточно ресурса или на перезарядке). Решил атаковать.`, GAME_CONFIG.LOG_TYPE_INFO); + // Если выбранная способность невалидна, AI по умолчанию атакует + serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); } - if (actionValid) { + + if (actionValid) { // Если способность все еще валидна attackerState.currentResource -= ability.cost; + // Установка кулдауна для AI let baseCooldown = 0; - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE) { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; } - else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && ability.internalCooldownValue) { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue; } + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE) { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN;} + else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && ability.internalCooldownValue) { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue;} else { if (ability.internalCooldownFromConfig && GAME_CONFIG[ability.internalCooldownFromConfig]) baseCooldown = GAME_CONFIG[ability.internalCooldownFromConfig]; else if (typeof ability.internalCooldownValue === 'number') baseCooldown = ability.internalCooldownValue; } if (baseCooldown > 0 && attackerState.abilityCooldowns) attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; - this.addToLog(`${attackerState.name} применяет "${ability.name}"...`, GAME_CONFIG.LOG_TYPE_EFFECT); - const targetForAbility = (ability.type === GAME_CONFIG.ACTION_TYPE_HEAL) ? attackerState : defenderState; - const targetBaseStatsForAbility = (targetForAbility.id === defenderState.id ? defenderData.baseStats : attackerData.baseStats); - serverGameLogic.applyAbilityEffect(ability, attackerState, targetForAbility, attackerData.baseStats, targetBaseStatsForAbility, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); } - } else if (aiDecision.actionType === 'pass') { + } else if (aiDecision.actionType === 'pass') { // Если AI решил пропустить ход if (aiDecision.logMessage) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type); - else this.addToLog(`${attackerState.name} пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); - } else actionValid = false; + else this.addToLog(`${attackerState.name} обдумывает свой следующий ход...`, GAME_CONFIG.LOG_TYPE_INFO); + } else { // Неизвестное решение AI или ошибка + actionValid = false; + this.addToLog(`AI ${attackerState.name} не смог выбрать действие и атакует.`, GAME_CONFIG.LOG_TYPE_INFO); + serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + } + + // if (!actionValid && aiDecision.actionType !== 'pass') { + // this.addToLog(`${attackerState.name} не смог выполнить выбранное действие и пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); + // } - if (!actionValid) this.addToLog(`${attackerState.name} не смог выполнить выбранное действие и пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } - this.switchTurn(); + this.switchTurn(); // Переключаем ход после действия AI } checkGameOver() { - if (!this.gameState || this.gameState.isGameOver) return this.gameState ? this.gameState.isGameOver : true; + if (!this.gameState || this.gameState.isGameOver) return this.gameState ? this.gameState.isGameOver : true; // Если игра уже закончена, или нет gameState + const playerState = this.gameState.player; const opponentState = this.gameState.opponent; - if (!playerState || !opponentState) return false; + + if (!playerState || !opponentState || opponentState.name === 'Ожидание игрока...') { + // Если одного из игроков нет (например, PvP игра ожидает второго), игра не может закончиться по HP + return false; + } const playerDead = playerState.currentHp <= 0; const opponentDead = opponentState.currentHp <= 0; @@ -548,31 +562,78 @@ class GameInstance { this.gameState.isGameOver = true; const winnerRole = opponentDead ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const loserRole = opponentDead ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const winnerState = this.gameState[winnerRole]; const loserState = this.gameState[loserRole]; + const winnerName = winnerState?.name || (winnerRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник"); const loserName = loserState?.name || (loserRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник"); - this.addToLog(`ПОБЕДА! ${winnerName} одолел(а) ${loserName}!`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.addToLog(`🏁 ПОБЕДА! ${winnerName} одолел(а) ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); + + // Дополнительные сообщения о конце игры if (winnerState?.characterKey === 'elena') { - const taunt = serverGameLogic.getElenaTaunt('opponentNearDefeatCheck', {}, GAME_CONFIG, gameData, this.gameState); + const tauntContext = loserState?.characterKey === 'balard' ? 'opponentNearDefeatBalard' : 'opponentNearDefeatAlmagest'; + const taunt = serverGameLogic.getElenaTaunt(tauntContext, {}, GAME_CONFIG, gameData, this.gameState); if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + if (loserState?.characterKey === 'balard') this.addToLog(`Елена исполнила свой тяжкий долг. ${loserName} развоплощен...`, GAME_CONFIG.LOG_TYPE_SYSTEM); else if (loserState?.characterKey === 'almagest') this.addToLog(`Елена одержала победу над темной волшебницей ${loserName}!`, GAME_CONFIG.LOG_TYPE_SYSTEM); } - this.io.to(this.id).emit('gameOver', { winnerId: winnerRole, reason: `${loserName} побежден(а)`, finalGameState: this.gameState, log: this.consumeLogBuffer() }); + + this.io.to(this.id).emit('gameOver', { + winnerId: winnerRole, + reason: `${loserName} побежден(а)`, + finalGameState: this.gameState, + log: this.consumeLogBuffer() + }); return true; } return false; } - addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { if (!message) return; this.logBuffer.push({ message, type, timestamp: Date.now() }); } - consumeLogBuffer() { const logs = [...this.logBuffer]; this.logBuffer = []; return logs; } - broadcastGameStateUpdate() { if (!this.gameState) return; this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); } - broadcastLogUpdate() { if (this.logBuffer.length > 0) this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() });} - _getCharacterData(key) { if(!key) return null; switch (key) { case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities }; case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; default: console.error(`_getCharacterData: Unknown character key "${key}"`); return null; }} - _getCharacterBaseData(key) { if(!key) return null; switch (key) { case 'elena': return gameData.playerBaseStats; case 'balard': return gameData.opponentBaseStats; case 'almagest': return gameData.almagestBaseStats; default: console.error(`_getCharacterBaseData: Unknown character key "${key}"`); return null; }} - _getCharacterAbilities(key) { if(!key) return null; switch (key) { case 'elena': return gameData.playerAbilities; case 'balard': return gameData.opponentAbilities; case 'almagest': return gameData.almagestAbilities; default: console.error(`_getCharacterAbilities: Unknown character key "${key}"`); return null; }} + addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { + if (!message) return; + this.logBuffer.push({ message, type, timestamp: Date.now() }); + } + + consumeLogBuffer() { + const logs = [...this.logBuffer]; + this.logBuffer = []; + return logs; + } + + broadcastGameStateUpdate() { + if (!this.gameState) return; + this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); + } + + broadcastLogUpdate() { // Если нужно отправить только лог без полного gameState + if (this.logBuffer.length > 0) { + this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() }); + } + } + + // Вспомогательные функции для получения данных персонажа + _getCharacterData(key) { + if (!key) return null; + switch (key) { + case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities }; + case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; + case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; + default: console.error(`_getCharacterData: Unknown character key "${key}"`); return null; + } + } + _getCharacterBaseData(key) { + if (!key) return null; + const charData = this._getCharacterData(key); + return charData ? charData.baseStats : null; + } + _getCharacterAbilities(key) { + if (!key) return null; + const charData = this._getCharacterData(key); + return charData ? charData.abilities : null; + } } module.exports = GameInstance; \ No newline at end of file diff --git a/server_modules/gameManager.js b/server_modules/gameManager.js index 117c50e..b6c28ac 100644 --- a/server_modules/gameManager.js +++ b/server_modules/gameManager.js @@ -1,23 +1,18 @@ // /server_modules/gameManager.js const { v4: uuidv4 } = require('uuid'); const GameInstance = require('./gameInstance'); -const gameData = require('./data'); +const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient +const GAME_CONFIG = require('./config'); // Нужен для GAME_CONFIG.PLAYER_ID и других констант class GameManager { constructor(io) { this.io = io; this.games = {}; // { gameId: GameInstance } this.socketToGame = {}; // { socket.id: gameId } - this.pendingPvPGames = []; // [gameId] - this.userToPendingGame = {}; // { userId: gameId } или { socketId: gameId } + this.pendingPvPGames = []; // [gameId] - ID игр, ожидающих второго игрока в PvP + this.userToPendingGame = {}; // { userId: gameId } или { socketId: gameId } - для отслеживания созданных ожидающих игр } - /** - * Удаляет предыдущие ожидающие PvP игры, созданные этим же пользователем/сокетом. - * @param {string} currentSocketId - ID текущего сокета игрока. - * @param {number|string} [identifier] - userId игрока или socketId. - * @param {string} [excludeGameId] - ID игры, которую НЕ нужно удалять. - */ _removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) { const keyToUse = identifier || currentSocketId; const oldPendingGameId = this.userToPendingGame[keyToUse]; @@ -26,8 +21,6 @@ class GameManager { const gameToRemove = this.games[oldPendingGameId]; if (gameToRemove && gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) { const playersInOldGame = Object.values(gameToRemove.players); - // Убеждаемся, что единственный игрок в старой игре - это действительно тот, кто сейчас создает/присоединяется - // Либо по ID сокета, либо по userId, если он был владельцем const isOwnerBySocket = playersInOldGame.length === 1 && playersInOldGame[0].socket.id === currentSocketId; const isOwnerByUserId = identifier && gameToRemove.ownerUserId === identifier; @@ -38,43 +31,41 @@ class GameManager { const pendingIndex = this.pendingPvPGames.indexOf(oldPendingGameId); if (pendingIndex > -1) this.pendingPvPGames.splice(pendingIndex, 1); - // Удаляем привязки для старого сокета, если он там был if (playersInOldGame.length === 1 && this.socketToGame[playersInOldGame[0].socket.id] === oldPendingGameId) { delete this.socketToGame[playersInOldGame[0].socket.id]; } - delete this.userToPendingGame[keyToUse]; // Удаляем по ключу, который использовали для поиска + delete this.userToPendingGame[keyToUse]; this.broadcastAvailablePvPGames(); } } else if (oldPendingGameId === excludeGameId) { - // Это та же игра, ничего не делаем + // Это та же игра, к которой игрок присоединяется, ничего не делаем } else { - // Запись в userToPendingGame устарела или не соответствует условиям, чистим delete this.userToPendingGame[keyToUse]; } } } - createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', userId = null) { const identifier = userId || socket.id; - this._removePreviousPendingGames(socket.id, identifier); // Удаляем старые перед созданием новой + this._removePreviousPendingGames(socket.id, identifier); const gameId = uuidv4(); const game = new GameInstance(gameId, this.io, mode); - if (userId) game.ownerUserId = userId; // Устанавливаем владельца игры + if (userId) game.ownerUserId = userId; this.games[gameId] = game; + // В AI режиме игрок всегда Елена, в PvP - тот, кого выбрали const charKeyForInstance = (mode === 'pvp') ? chosenCharacterKey : 'elena'; - if (game.addPlayer(socket, charKeyForInstance)) { // addPlayer теперь сам установит userId в game.ownerUserId если это первый игрок - this.socketToGame[socket.id] = gameId; // Устанавливаем привязку после успешного добавления - console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${socket.id} (userId: ${userId}, выбран: ${charKeyForInstance})`); + if (game.addPlayer(socket, charKeyForInstance)) { + this.socketToGame[socket.id] = gameId; + console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${socket.userData?.username || socket.id} (userId: ${userId}, выбран: ${charKeyForInstance})`); const assignedPlayerId = game.players[socket.id]?.id; if (!assignedPlayerId) { delete this.games[gameId]; if(this.socketToGame[socket.id] === gameId) delete this.socketToGame[socket.id]; - socket.emit('gameError', { message: 'Ошибка сервера при создании игры (ID игрока).' }); return; + socket.emit('gameError', { message: 'Ошибка сервера при создании игры (не удалось назначить ID игрока).' }); return; } socket.emit('gameCreated', { gameId: gameId, mode: mode, yourPlayerId: assignedPlayerId }); @@ -84,10 +75,9 @@ class GameManager { this.broadcastAvailablePvPGames(); } } else { - delete this.games[gameId]; // game.addPlayer вернул false, чистим - // socketToGame не должен был быть установлен, если addPlayer вернул false + delete this.games[gameId]; if (this.socketToGame[socket.id] === gameId) delete this.socketToGame[socket.id]; - socket.emit('gameError', { message: 'Не удалось создать игру или добавить игрока.' }); + // Сообщение об ошибке отправляется из game.addPlayer } } @@ -98,22 +88,21 @@ class GameManager { if (!game) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; } if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'Эта игра не является PvP игрой.' }); return; } if (game.playerCount >= 2) { socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; } - if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре (попытка двойного присоединения).' }); return;} // Доп. проверка + if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре.' }); return;} - // Удаляем предыдущие ожидающие игры этого пользователя this._removePreviousPendingGames(socket.id, identifier, gameId); + // addPlayer в GameInstance сам определит персонажа для второго игрока на основе первого if (game.addPlayer(socket)) { this.socketToGame[socket.id] = gameId; - // console.log(`[GameManager] Игрок ${socket.id} (userId: ${userId}) присоединился к PvP игре ${gameId}`); + console.log(`[GameManager] Игрок ${socket.userData?.username || socket.id} (userId: ${userId}) присоединился к PvP игре ${gameId}`); const gameIndex = this.pendingPvPGames.indexOf(gameId); if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1); - // Очищаем запись о создателе из userToPendingGame, так как игра началась if (game.ownerUserId && this.userToPendingGame[game.ownerUserId] === gameId) { delete this.userToPendingGame[game.ownerUserId]; - } else { // Если ownerUserId не был или не совпал, пробуем по socketId первого игрока + } else { const firstPlayerSocketId = Object.keys(game.players).find(sId => game.players[sId].id === GAME_CONFIG.PLAYER_ID && game.players[sId].socket.id !== socket.id); if (firstPlayerSocketId && this.userToPendingGame[firstPlayerSocketId] === gameId) { delete this.userToPendingGame[firstPlayerSocketId]; @@ -121,28 +110,32 @@ class GameManager { } this.broadcastAvailablePvPGames(); } else { - socket.emit('gameError', { message: 'Не удалось присоединиться к игре (внутренняя ошибка).' }); + // Сообщение об ошибке отправляется из game.addPlayer } } - findAndJoinRandomPvPGame(socket, chosenCharacterKey = 'elena', userId = null) { + findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', userId = null) { const identifier = userId || socket.id; - this._removePreviousPendingGames(socket.id, identifier); // Удаляем старые перед поиском/созданием + this._removePreviousPendingGames(socket.id, identifier); let gameIdToJoin = null; - const preferredOpponentKey = chosenCharacterKey === 'elena' ? 'almagest' : 'elena'; + // Персонаж, которого мы бы хотели видеть у оппонента (зеркальный нашему выбору) + const preferredOpponentKey = chosenCharacterKeyForCreation === 'elena' ? 'almagest' : 'elena'; + // Сначала ищем игру, где первый игрок выбрал "зеркального" персонажа for (const id of this.pendingPvPGames) { const pendingGame = this.games[id]; if (pendingGame && pendingGame.playerCount === 1 && pendingGame.mode === 'pvp') { const firstPlayerInfo = Object.values(pendingGame.players)[0]; const isMyOwnGame = (userId && pendingGame.ownerUserId === userId) || (firstPlayerInfo.socket.id === socket.id); if (isMyOwnGame) continue; + if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === preferredOpponentKey) { gameIdToJoin = id; break; } } } + // Если не нашли с предпочтительным оппонентом, ищем любую свободную (не нашу) if (!gameIdToJoin && this.pendingPvPGames.length > 0) { for (const id of this.pendingPvPGames) { const pendingGame = this.games[id]; @@ -156,11 +149,16 @@ class GameManager { } if (gameIdToJoin) { + // Присоединяемся к найденной игре. GameInstance.addPlayer сам назначит нужного персонажа второму игроку. this.joinGame(socket, gameIdToJoin, userId); } else { - this.createGame(socket, 'pvp', chosenCharacterKey, userId); + // Если свободных игр нет, создаем новую с выбранным персонажем + this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, userId); + // Клиент получит 'gameCreated', а 'noPendingGamesFound' используется для информационного сообщения socket.emit('noPendingGamesFound', { message: 'Свободных PvP игр не найдено. Создана новая игра для вас. Ожидайте противника.', + gameId: this.userToPendingGame[identifier], // ID только что созданной игры + yourPlayerId: GAME_CONFIG.PLAYER_ID // При создании всегда PLAYER_ID }); } } @@ -172,17 +170,7 @@ class GameManager { game.processPlayerAction(socketId, actionData); } else { const playerSocket = this.io.sockets.sockets.get(socketId); - if (playerSocket) playerSocket.emit('gameError', { message: 'Ошибка: игровая сессия потеряна.' }); - } - } - - requestRestart(socketId, gameId) { - const game = this.games[gameId]; - if (game && game.players[socketId]) { - game.handleVoteRestart(socketId); - } else { - const playerSocket = this.io.sockets.sockets.get(socketId); - if (playerSocket) playerSocket.emit('gameError', { message: 'Не удалось перезапустить: сессия не найдена.' }); + if (playerSocket) playerSocket.emit('gameError', { message: 'Ошибка: игровая сессия потеряна для этого действия.' }); } } @@ -192,7 +180,9 @@ class GameManager { if (gameId && this.games[gameId]) { const game = this.games[gameId]; - console.log(`[GameManager] Игрок ${socketId} (userId: ${userId}) отключился от игры ${gameId}.`); + const playerInfo = game.players[socketId]; + const username = playerInfo?.socket?.userData?.username || socketId; + console.log(`[GameManager] Игрок ${username} (socket: ${socketId}, userId: ${userId}) отключился от игры ${gameId}.`); game.removePlayer(socketId); if (game.playerCount === 0) { @@ -200,7 +190,6 @@ class GameManager { delete this.games[gameId]; const gameIndexPending = this.pendingPvPGames.indexOf(gameId); if (gameIndexPending > -1) this.pendingPvPGames.splice(gameIndexPending, 1); - // Удаляем из userToPendingGame, если игра была там по любому ключу for (const key in this.userToPendingGame) { if (this.userToPendingGame[key] === gameId) delete this.userToPendingGame[key]; } @@ -209,26 +198,32 @@ class GameManager { if (!this.pendingPvPGames.includes(gameId)) { this.pendingPvPGames.push(gameId); } - // Обновляем ownerUserId и userToPendingGame для оставшегося игрока const remainingPlayerSocketId = Object.keys(game.players)[0]; const remainingPlayerSocket = game.players[remainingPlayerSocketId]?.socket; const remainingUserId = remainingPlayerSocket?.userData?.userId; const newIdentifier = remainingUserId || remainingPlayerSocketId; - game.ownerUserId = remainingUserId; // Устанавливаем нового владельца (может быть null) - this.userToPendingGame[newIdentifier] = gameId; // Связываем нового владельца + game.ownerUserId = remainingUserId; + this.userToPendingGame[newIdentifier] = gameId; - // Удаляем старую привязку отключившегося, если она была и не совпадает с новой if (identifier !== newIdentifier && this.userToPendingGame[identifier] === gameId) { delete this.userToPendingGame[identifier]; } console.log(`[GameManager] Игра ${gameId} возвращена в список ожидания PvP. Новый владелец: ${newIdentifier}`); this.broadcastAvailablePvPGames(); } - } else { // Если игрок не был в активной игре, но мог иметь ожидающую - this._removePreviousPendingGames(socketId, identifier); + } else { + const pendingGameIdToRemove = this.userToPendingGame[identifier]; + if (pendingGameIdToRemove && this.games[pendingGameIdToRemove] && this.games[pendingGameIdToRemove].playerCount === 1) { + console.log(`[GameManager] Игрок ${socketId} (identifier: ${identifier}) отключился, удаляем его ожидающую игру ${pendingGameIdToRemove}`); + delete this.games[pendingGameIdToRemove]; + const idx = this.pendingPvPGames.indexOf(pendingGameIdToRemove); + if (idx > -1) this.pendingPvPGames.splice(idx, 1); + delete this.userToPendingGame[identifier]; + this.broadcastAvailablePvPGames(); + } } - delete this.socketToGame[socketId]; // Всегда удаляем эту связь + delete this.socketToGame[socketId]; } getAvailablePvPGamesListForClient() { @@ -236,16 +231,50 @@ class GameManager { .map(gameId => { const game = this.games[gameId]; if (game && game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) { - let firstPlayerName = 'Игрок'; + let firstPlayerUsername = 'Игрок'; + let firstPlayerCharacterName = ''; + if (game.players && Object.keys(game.players).length > 0) { const firstPlayerSocketId = Object.keys(game.players)[0]; const firstPlayerInfo = game.players[firstPlayerSocketId]; - if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey) { - const charData = gameData[firstPlayerInfo.chosenCharacterKey + 'BaseStats']; - if (charData) firstPlayerName = charData.name; + + if (firstPlayerInfo) { + if (firstPlayerInfo.socket?.userData?.username) { + firstPlayerUsername = firstPlayerInfo.socket.userData.username; + } + + const charKey = firstPlayerInfo.chosenCharacterKey; + if (charKey) { + let charBaseStats; + if (charKey === 'elena') { + charBaseStats = gameData.playerBaseStats; + } else if (charKey === 'almagest') { + charBaseStats = gameData.almagestBaseStats; + } + // Баларда не должно быть в pending PvP как создателя + + if (charBaseStats && charBaseStats.name) { + firstPlayerCharacterName = charBaseStats.name; + } else { + console.warn(`[GameManager] getAvailablePvPGamesList: Не удалось найти имя для charKey '${charKey}' в gameData.`); + firstPlayerCharacterName = charKey; // В крайнем случае ключ + } + } else { + console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo.chosenCharacterKey отсутствует для игры ${gameId}`); + } } } - return { id: gameId, status: `Ожидает 1 игрока (Создал: ${firstPlayerName})` }; + + let statusString = `Ожидает 1 игрока (Создал: ${firstPlayerUsername}`; + if (firstPlayerCharacterName) { + statusString += ` за ${firstPlayerCharacterName}`; + } + statusString += `)`; + + return { + id: gameId, + status: statusString + }; } return null; }) @@ -256,17 +285,24 @@ class GameManager { this.io.emit('availablePvPGamesList', this.getAvailablePvPGamesListForClient()); } - getActiveGamesList() { + getActiveGamesList() { // Для отладки на сервере return Object.values(this.games).map(game => { - let playerSlotChar = game.gameState?.player?.name || (game.playerCharacterKey ? gameData[game.playerCharacterKey + 'BaseStats']?.name : 'N/A'); - let opponentSlotChar = game.gameState?.opponent?.name || (game.opponentCharacterKey ? gameData[game.opponentCharacterKey + 'BaseStats']?.name : 'N/A'); - if (game.mode === 'pvp' && game.playerCount === 1 && !game.opponentCharacterKey) opponentSlotChar = 'Ожидание...'; + let playerSlotChar = game.gameState?.player?.name || (game.playerCharacterKey ? gameData[game.playerCharacterKey === 'elena' ? 'playerBaseStats' : (game.playerCharacterKey === 'almagest' ? 'almagestBaseStats' : null)]?.name : 'N/A'); + let opponentSlotChar = game.gameState?.opponent?.name || (game.opponentCharacterKey ? gameData[game.opponentCharacterKey === 'elena' ? 'playerBaseStats' : (game.opponentCharacterKey === 'almagest' ? 'almagestBaseStats' : (game.opponentCharacterKey === 'balard' ? 'opponentBaseStats' : null))]?.name : 'N/A'); + + if (game.mode === 'pvp' && game.playerCount === 1 && !game.opponentCharacterKey && game.gameState && !game.gameState.isGameOver) { + opponentSlotChar = 'Ожидание...'; + } return { - id: game.id, mode: game.mode, playerCount: game.playerCount, + id: game.id.substring(0,8), + mode: game.mode, + playerCount: game.playerCount, isGameOver: game.gameState ? game.gameState.isGameOver : 'N/A', - playerSlot: playerSlotChar, opponentSlot: opponentSlotChar, - ownerUserId: game.ownerUserId || 'N/A' + playerSlot: playerSlotChar, + opponentSlot: opponentSlotChar, + ownerUserId: game.ownerUserId || 'N/A', + pending: this.pendingPvPGames.includes(game.id) }; }); }