// /server_modules/gameManager.js const { v4: uuidv4 } = require('uuid'); // Убедитесь, что uuidv4 установлен: npm install uuid const GameInstance = require('./gameInstance'); // Убедитесь, что GameInstance экспортируется из gameInstance.js const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient и данных персонажей const GAME_CONFIG = require('./config'); // Нужен для GAME_CONFIG.PLAYER_ID и других констант class GameManager { constructor(io) { this.io = io; // Ссылка на Socket.IO сервер для широковещательных рассылок this.games = {}; // { gameId: GameInstance } - Все активные или ожидающие игры this.userIdentifierToGameId = {}; // { userId|socketId: gameId } - Какому пользователю какая игра соответствует (более стабильно, чем socket.id) this.pendingPvPGames = []; // [gameId] - ID PvP игр, ожидающих второго игрока // Навешиваем обработчик события 'gameOver' на Socket.IO сервер // Это событие исходит от экземпляра GameInstance при завершении игры (по HP или дисконнекту) // Мы слушаем его здесь, чтобы GameManager мог очистить ссылки. // Примечание: Это событие отправляется всем в комнате игры. GameManager слушает его через io.sockets.sockets.on, // но удобнее слушать его на уровне io, если возможно, или добавить специальный emit из GameInstance. // Текущая архитектура (GameInstance напрямую вызывает io.to(...).emit('gameOver', ...)) уже рабочая. // GameManager сам должен отреагировать на завершение, проверяя gameState.isGameOver после каждого действия/хода. // Или GameInstance должен вызвать специальный метод GameManager при gameOver. // Давайте сделаем GameInstance вызывать метод GameManager при gameOver. } /** * Удаляет предыдущую ожидающую игру пользователя, если таковая существует. * Это предотвращает создание множества пустых игр одним пользователем. * @param {string} currentSocketId - ID текущего сокета. * @param {string|number} identifier - userId или socketId пользователя. * @param {string|null} excludeGameId - ID игры, которую НЕ нужно удалять (например, если пользователь присоединяется к своей же игре). */ _removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) { // Ищем игру по идентификатору пользователя const oldPendingGameId = this.userIdentifierToGameId[identifier]; // Проверяем, что нашли игру, она не исключена, и она все еще существует в списке игр if (oldPendingGameId && oldPendingGameId !== excludeGameId && this.games[oldPendingGameId]) { const gameToRemove = this.games[oldPendingGameId]; // Проверяем, что игра является ожидающей PvP игрой с одним игроком if (gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) { // Проверяем, что этот пользователь является владельцем этой ожидающей игры // Владелец в pendingPvPGames - это всегда тот, кто ее создал (первый игрок в слоте PLAYER_ID) const oldOwnerInfo = Object.values(gameToRemove.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); // Проверяем, что владелец игры существует и его идентификатор совпадает if (oldOwnerInfo && (oldOwnerInfo.identifier === identifier)) { console.log(`[GameManager] Пользователь ${identifier} (сокет: ${currentSocketId}) создал/присоединился к новой игре. Удаляем его предыдущую ожидающую игру: ${oldPendingGameId}`); // Используем централизованную функцию очистки this._cleanupGame(oldPendingGameId, 'replaced_by_new_game'); // Оповещаем клиентов об обновленном списке игр (уже внутри _cleanupGame) // this.broadcastAvailablePvPGames(); } } else { // Если игра не соответствует критериям ожидающей игры, но идентификатор был связан с ней, // это может означать, что игра уже началась или была завершена. // Просто очищаем ссылку, если она не ведет в исключенную игру. // Идентификатор должен был быть очищен из userIdentifierToGameId при старте или завершении игры. // На всякий случай убеждаемся, что мы не удаляем ссылку на игру, к которой только что присоединились. if (this.userIdentifierToGameId[identifier] !== excludeGameId) { console.warn(`[GameManager] Удаление потенциально некорректной ссылки userIdentifierToGameId[${identifier}] на игру ${oldPendingGameId}.`); delete this.userIdentifierToGameId[identifier]; } } } // Если oldPendingGameId не найдена, или она равна excludeGameId, ничего не делаем. } /** * Создает новую игру. * @param {object} socket - Сокет игрока, создающего игру. * @param {string} [mode='ai'] - Режим игры ('ai' или 'pvp'). * @param {string} [chosenCharacterKey='elena'] - Выбранный персонаж для первого игрока в PvP. * @param {string|number} identifier - ID пользователя (userId или socketId). */ createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', identifier) { // Удаляем старые ожидающие игры этого пользователя, прежде чем создавать новую this._removePreviousPendingGames(socket.id, identifier); // Проверяем, не находится ли пользователь уже в какой-то игре (активной или ожидающей) // Проверяем наличие ссылки на игру по идентификатору пользователя if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на создание.`); socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); // Можно попробовать отправить состояние текущей игры пользователю this.handleRequestGameState(socket, identifier); return; } const gameId = uuidv4(); // Генерируем уникальный ID для игры // Передаем ссылку на GameManager в GameInstance, чтобы он мог вызвать _notifyGameEnded const game = new GameInstance(gameId, this.io, mode, this); // <-- ПЕРЕДАЕМ GameManager game.ownerIdentifier = identifier; // Сохраняем идентификатор создателя this.games[gameId] = game; // Добавляем игру в список активных игр // В AI режиме игрок всегда Елена, в PvP - тот, кого выбрали при создании const charKeyForInstance = (mode === 'pvp') ? chosenCharacterKey : 'elena'; // Добавляем игрока в созданный экземпляр игры, передавая идентификатор // GameInstance.addPlayer принимает socket, chosenCharacterKey, identifier if (game.addPlayer(socket, charKeyForInstance, identifier)) { this.userIdentifierToGameId[identifier] = gameId; // Связываем идентификатор пользователя с этой игрой console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${identifier} (сокет: ${socket.id}, выбран: ${charKeyForInstance})`); // Уведомляем игрока, что игра создана, и передаем его технический ID слота const assignedPlayerId = game.players[socket.id]?.id; // ID слота все еще берем из playerInfo по socket.id if (!assignedPlayerId) { // Если по какой-то причине не удалось назначить ID игрока, удаляем игру и отправляем ошибку // Используем централизованную функцию очистки this._cleanupGame(gameId, 'player_add_failed'); console.error(`[GameManager] Ошибка при создании игры ${gameId}: Не удалось назначить ID игрока сокету ${socket.id} (идентификатор ${identifier}).`); socket.emit('gameError', { message: 'Ошибка сервера при создании игры.' }); return; } socket.emit('gameCreated', { gameId: gameId, mode: mode, yourPlayerId: assignedPlayerId }); // --- Логика старта игры --- // Если игра AI и теперь с 1 игроком, или PvP и теперь с 2 игроками, запускаем ее немедленно if ((game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2)) { console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`); // Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены. const isInitialized = game.initializeGame(); if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние game.startGame(); // Запускаем игру } else { console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`); // initializeGame уже должен был добавить ошибку в лог игры и отправить gameError клиентам // Возможно, стоит вызвать cleanupGame здесь при ошибке инициализации this._cleanupGame(gameId, 'initialization_failed'); } // Если игра PvP и только что заполнилась, удаляем ее из списка ожидающих // Идентификаторы игроков остаются связанными с игрой в userIdentifierToGameId до ее завершения. if (game.mode === 'pvp' && game.playerCount === 2) { const gameIndex = this.pendingPvPGames.indexOf(gameId); if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1); // Связи userIdentifierToGameId[identifier] НЕ УДАЛЯЕМ! Они нужны для активной игры. this.broadcastAvailablePvPGames(); // Обновляем список у всех клиентов } } else if (mode === 'pvp' && game.playerCount === 1) { // Если игра PvP и ожидает второго игрока, добавляем ее в список ожидающих if (!this.pendingPvPGames.includes(gameId)) { this.pendingPvPGames.push(gameId); // Добавляем ID игры в список ожидающих } // userIdentifierToGameId для создателя уже установлен выше // Частичная инициализация gameState для отображения Player 1 на UI ожидания // initializeGame вызывается при playerCount === 1 в GameInstance game.initializeGame(); this.broadcastAvailablePvPGames(); // Обновляем список у всех } // --- КОНЕЦ Логики старта игры --- } else { // Если не удалось добавить игрока в GameInstance (например, уже 2 игрока - хотя проверили выше), удаляем игру // Используем централизованную функцию очистки this._cleanupGame(gameId, 'player_add_failed'); // GameInstance.addPlayer уже отправил ошибку клиенту console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}. Игра удалена.`); } } /** * Присоединяет игрока к существующей игре по ID. * @param {object} socket - Сокет игрока. * @param {string} gameId - ID игры, к которой нужно присоединиться. * @param {string|number} identifier - ID пользователя (userId). */ joinGame(socket, gameId, identifier) { // В joinGame всегда передается userId, т.к. PvP требует логина const game = this.games[gameId]; // Находим игру по ID // Проверки перед присоединением 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 (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]] && this.userIdentifierToGameId[identifier] !== gameId) { console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на присоединение.`); socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); this.handleRequestGameState(socket, identifier); // Попробуем отправить состояние текущей игры return; } if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре.' }); return;} // Проверка на повторное присоединение по текущему сокету (хотя userIdentifierToGameId должен это предотвратить) // Удаляем старые ожидающие игры этого пользователя, исключая текущую игру, к которой присоединяемся this._removePreviousPendingGames(socket.id, identifier, gameId); // addPlayer в GameInstance сам определит персонажа для второго игрока на основе первого // GameInstance.addPlayer принимает socket, chosenCharacterKey (null для присоединения), identifier if (game.addPlayer(socket, null, identifier)) { // chosenCharacterKey для присоединяющегося игрока не нужен, передаем null this.userIdentifierToGameId[identifier] = gameId; // Связываем идентификатор пользователя с этой игрой console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) присоединился к PvP игре ${gameId}`); // --- Логика старта игры --- // Если игра PvP и теперь с 2 игроками, запускаем ее немедленно if (game.mode === 'pvp' && game.playerCount === 2) { console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`); // Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены. const isInitialized = game.initializeGame(); if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние game.startGame(); // Запускаем игру } else { console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`); // initializeGame уже должен был добавить ошибку в лог игры и отправить gameError клиентам // Возможно, стоит вызвать cleanupGame здесь при ошибке инициализации this._cleanupGame(gameId, 'initialization_failed'); } // Если игра PvP и только что заполнилась, удаляем ее из списка ожидающих const gameIndex = this.pendingPvPGames.indexOf(gameId); if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1); // Связи userIdentifierToGameId[identifier] НЕ УДАЛЯЕМ! Они нужны для активной игры. // ownerIdentifier игры (идентификатор создателя) также остается. this.broadcastAvailablePvPGames(); // Обновляем список у всех клиентов } // --- КОНЕЦ Логики старта игры --- } else { // Сообщение об ошибке отправляется из game.addPlayer console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}.`); } } /** * Ищет случайную ожидающую PvP игру и присоединяет игрока к ней. * Если подходящих игр нет, создает новую ожидающую игру. * @param {object} socket - Сокет игрока. * @param {string} [chosenCharacterKeyForCreation='elena'] - Выбранный персонаж, если придется создавать новую игру. * @param {string|number} identifier - ID пользователя (userId). */ findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) { // В findRandomGame всегда передается userId // Удаляем старые ожидающие игры этого пользователя this._removePreviousPendingGames(socket.id, identifier); // Проверяем, не находится ли пользователь уже в какой-то игре if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на поиск.`); socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); this.handleRequestGameState(socket, identifier); // Попробуем отправить состояние текущей игры return; } let gameIdToJoin = null; // Персонаж, которого мы бы хотели видеть у оппонента (зеркальный нашему выбору для создания) const preferredOpponentKey = chosenCharacterKeyForCreation === 'elena' ? 'almagest' : 'elena'; // Ищем свободную игру в списке ожидающих for (const id of this.pendingPvPGames) { const pendingGame = this.games[id]; // Проверяем, что игра существует, PvP, в ней только 1 игрок и это НЕ игра, которую создал сам текущий пользователь // Игрок не должен присоединяться к игре, которую создал сам. if (pendingGame && pendingGame.mode === 'pvp' && pendingGame.playerCount === 1 && pendingGame.ownerIdentifier !== identifier) { // Нашли потенциальную игру. Проверяем предпочтительного оппонента. const firstPlayerInfo = Object.values(pendingGame.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); // В ожидающей игре всегда 1 игрок, он и есть players[0] if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === preferredOpponentKey) { gameIdToJoin = id; // Нашли игру с предпочтительным оппонентом break; // Выходим из цикла, т.к. нашли лучший вариант } // Если предпочтительного не нашли в этом цикле, сохраняем ID первой попавшейся (не своей) игры if (!gameIdToJoin) gameIdToJoin = id; // Сохраняем, но продолжаем искать предпочтительную } } if (gameIdToJoin) { // Присоединяемся к найденной игре. GameInstance.addPlayer сам назначит нужного персонажа второму игроку. console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) нашел игру ${gameIdToJoin} и присоединяется.`); this.joinGame(socket, gameIdToJoin, identifier); // Используем joinGame, т.к. логика присоединения одинакова } else { // Если свободных игр нет, создаем новую с выбранным персонажем console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) не нашел свободных игр. Создает новую.`); this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier); // Используем createGame // Клиент получит 'gameCreated', а 'noPendingGamesFound' используется для информационного сообщения // userIdentifierToGameId уже обновлен в createGame socket.emit('noPendingGamesFound', { message: 'Свободных PvP игр не найдено. Создана новая игра для вас. Ожидайте противника.', gameId: this.userIdentifierToGameId[identifier], // ID только что созданной игры yourPlayerId: GAME_CONFIG.PLAYER_ID // При создании всегда PLAYER_ID }); } } /** * Перенаправляет действие игрока соответствующему экземпляру игры. * @param {string|number} identifier - ID пользователя (userId или socketId). * @param {object} actionData - Данные о действии. */ handlePlayerAction(identifier, actionData) { // Теперь принимаем identifier const gameId = this.userIdentifierToGameId[identifier]; // Находим ID игры по идентификатору пользователя const game = this.games[gameId]; // Находим экземпляр игры if (game && game.players) { // Находим текущий сокет ID пользователя в списке игроков этой игры const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); const currentSocketId = playerInfo?.socket?.id; if (playerInfo && currentSocketId) { // Проверяем, что сокет с этим ID еще подключен. // Это дополнительная проверка, чтобы не обрабатывать действия от "зомби"-сокетов const actualSocket = this.io.sockets.sockets.get(currentSocketId); if (actualSocket && actualSocket.connected) { // Передаем действие экземпляру игры, используя ТЕКУЩИЙ Socket ID game.processPlayerAction(currentSocketId, actionData); // processPlayerAction в GameInstance использует socketId } else { // Если сокет не найден или не подключен, это может быть старое действие от отключившегося сокета console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его текущий сокет (${currentSocketId}) не найден или отключен.`); // Не отправляем ошибку клиенту, так как он, вероятно, уже отключен или переподключается // Клиент получит gameNotFound при следующем запросе состояния или gameError, если игра еще активна } } else { // Игрок не найден в списке players этой игры по идентификатору console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}) для игры ${gameId}, но его запись не найдена в game.players.`); // В таком случае, возможно, состояние userIdentifierToGameId некорректно. // Удаляем некорректную ссылку. delete this.userIdentifierToGameId[identifier]; // Оповещаем клиента, что игра не найдена (он должен будет запросить состояние) const playerSocket = this.io.sockets.sockets.get(identifier); // Попробуем найти сокет по идентификатору (если он был socket.id) if (!playerSocket && playerInfo?.socket) { // Если не нашли по identifier, попробуем по сокету из playerInfo playerSocket = playerInfo.socket; } if (playerSocket) { playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' }); } } } else { // Если игра не найдена по userIdentifierToGameId[identifier] console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его игра (ID: ${gameId}) не найдена в GameManager.`); // Удаляем некорректную ссылку delete this.userIdentifierToGameId[identifier]; // Отправляем gameNotFound клиенту, если можем его найти (по identifier, если это socket.id) const playerSocket = this.io.sockets.sockets.get(identifier); if (playerSocket) { playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' }); } } } /** * Обрабатывает отключение сокета игрока. * Вызывается из bc.js при событии 'disconnect'. * @param {string} socketId - ID отключившегося сокета. * @param {string|number} identifier - ID пользователя (userId или socketId). */ handleDisconnect(socketId, identifier) { // Принимаем и socketId, и identifier // Ищем игру по идентификатору пользователя (более надежный способ после переподключения) const gameId = this.userIdentifierToGameId[identifier]; const game = this.games[gameId]; // Если игра найдена и в ней есть игрок с этим идентификатором (или сокетом) if (game && game.players) { // Находим информацию об игроке по идентификатору const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); if (playerInfo) { console.log(`[GameManager] Игрок ${identifier} (сокет: ${socketId}) отключился. В игре ${gameId}.`); // Удаляем игрока из экземпляра игры, передавая Socket ID, который отключился // GameInstance.removePlayer принимает socketId game.removePlayer(socketId); // Передаем socketId для удаления конкретного сокета // После удаления игрока из GameInstance, проверяем состояние игры и GameManager if (game.playerCount === 0) { // Если в игре больше нет игроков, удаляем ее из GameManager console.log(`[GameManager] Игра ${gameId} пуста после дисконнекта ${socketId} (идентификатор ${identifier}). Удаляем.`); // Используем централизованную функцию очистки this._cleanupGame(gameId, 'player_count_zero_on_disconnect'); } else if (game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) { // Если игра PvP, остался 1 игрок, и она еще не окончена (из-за дисконнекта), // возвращаем ее в список ожидающих. console.log(`[GameManager] Игра ${gameId} (PvP) теперь с 1 игроком после дисконнекта ${socketId} (идентификатор ${identifier}). Возвращаем в список ожидания.`); if (!this.pendingPvPGames.includes(gameId)) { this.pendingPvPGames.push(gameId); } // Удаляем ссылку на игру только для отключившегося идентификатора delete this.userIdentifierToGameId[identifier]; // ownerIdentifier игры (если был userId) останется тем же, даже если отключился владелец. // Это OK, ownerIdentifier используется для _removePreviousPendingGames. this.broadcastAvailablePvPGames(); // Обновляем список у всех } else if (game.gameState?.isGameOver) { // Если игра была окончена (например, дисконнект приводил к gameOver), // просто удаляем ссылку на игру для отключившегося идентификатора. console.log(`[GameManager] Игрок ${identifier} отключился из завершенной игры ${gameId}. Удаляем ссылку.`); delete this.userIdentifierToGameId[identifier]; } else { // Игра не пуста и не вернулась в ожидание (например, AI игра, где остался игрок, // или PvP игра с 2 игроками, где один отключился, а второй остался) // Ссылка userIdentifierToGameId[identifier] для отключившегося игрока должна быть удалена. console.log(`[GameManager] Игрок ${identifier} отключился из активной игры ${gameId} (mode: ${game.mode}, players: ${game.playerCount}). Удаляем ссылку.`); delete this.userIdentifierToGameId[identifier]; } } else { // Игра найдена, но игрока с этим идентификатором или сокетом в game.players нет. // Это может означать, что сокет отключился, но запись игрока была удалена раньше, // или identifier некорректен. console.warn(`[GameManager] Игрок с идентификатором ${identifier} (сокет: ${socketId}) не найден в game.players для игры ${gameId}.`); // Удаляем ссылку на игру для этого идентификатора, если она есть. delete this.userIdentifierToGameId[identifier]; // Проверяем, возможно, этот сокет был в другой игре по старой ссылке socketToGame (удалено), // или это просто отключившийся сокет без активной игры. } } else { // Если игра не найдена по userIdentifierToGameId[identifier] console.log(`[GameManager] Отключился сокет ${socketId} (идентификатор ${identifier}). Игровая сессия по этому идентификатору не найдена.`); // Убеждаемся, что ссылка userIdentifierToGameId[identifier] удалена delete this.userIdentifierToGameId[identifier]; } } /** * Централизованная функция для очистки игры после ее завершения. * Удаляет экземпляр игры и все связанные с ней ссылки. * Вызывается из GameInstance при gameOver (по HP или дисконнекту). * @param {string} gameId - ID завершенной игры. * @param {string} reason - Причина завершения (для логирования). * @returns {boolean} true, если игра найдена и очищена, иначе false. */ _cleanupGame(gameId, reason = 'unknown_reason') { // <-- НОВЫЙ ПРИВАТНЫЙ МЕТОД const game = this.games[gameId]; if (!game) { console.warn(`[GameManager] _cleanupGame called for unknown game ID: ${gameId}`); return false; } console.log(`[GameManager] Cleaning up game ${gameId} (Mode: ${game.mode}, Reason: ${reason})...`); // Удаляем ссылку userIdentifierToGameId для всех игроков, которые были в этой игре // Перебираем players в GameInstance, чтобы получить идентификаторы Object.values(game.players).forEach(playerInfo => { if (playerInfo && playerInfo.identifier && this.userIdentifierToGameId[playerInfo.identifier] === gameId) { delete this.userIdentifierToGameId[playerInfo.identifier]; console.log(`[GameManager] Removed userIdentifierToGameId for ${playerInfo.identifier}.`); } else if (playerInfo && playerInfo.identifier) { console.warn(`[GameManager] User ${playerInfo.identifier} in game ${gameId} has incorrect userIdentifierToGameId reference.`); // Если ссылка некорректна, ничего не удаляем. } }); // Удаляем ID игры из списка ожидающих, если она там была const pendingIndex = this.pendingPvPGames.indexOf(gameId); if (pendingIndex > -1) { this.pendingPvPGames.splice(pendingIndex, 1); console.log(`[GameManager] Removed game ${gameId} from pendingPvPGames.`); } // Удаляем сам экземпляр игры delete this.games[gameId]; console.log(`[GameManager] Deleted GameInstance for game ${gameId}.`); // Оповещаем клиентов об обновленном списке игр (может понадобиться, если удалена ожидающая игра) // Или если активная игра была удалена, и игроки вернутся в лобби. this.broadcastAvailablePvPGames(); return true; } /** * Формирует список доступных для присоединения PvP игр для клиента. * @returns {Array} Массив объектов с информацией об играх. */ getAvailablePvPGamesListForClient() { return this.pendingPvPGames .map(gameId => { const game = this.games[gameId]; // Проверяем, что игра существует, это PvP, в ней 1 игрок, и она не окончена // gameState.isGameOver проверяется, чтобы исключить игры, которые могли завершиться сразу (очень маловероятно) if (game && game.mode === 'pvp' && game.playerCount === 1 && game.gameState && !game.gameState.isGameOver) { let firstPlayerUsername = 'Игрок'; let firstPlayerCharacterName = ''; // Находим информацию о первом игроке (он всегда в слоте GAME_CONFIG.PLAYER_ID в ожидающей игре) const firstPlayerInfo = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); if (firstPlayerInfo) { // Получаем имя пользователя из userData, если залогинен if (firstPlayerInfo.socket?.userData?.username) { firstPlayerUsername = firstPlayerInfo.socket.userData.username; } else { // Если нет userData.username, используем часть identifier firstPlayerUsername = `User#${String(firstPlayerInfo.identifier).substring(0,6)}`; // Приводим identifier к строке } // Получаем имя персонажа из chosenCharacterKey const charKey = firstPlayerInfo.chosenCharacterKey; if (charKey) { // Используем _getCharacterBaseData напрямую, т.к. gameData доступен const charBaseStats = this._getCharacterBaseData(charKey); 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}.`); } } else { console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo (Player 1) не найдена для ожидающей игры ${gameId}.`); firstPlayerUsername = 'Неизвестный игрок'; // Если даже игрока не нашли в players } // Формируем строку статуса для отображения в списке let statusString = `Ожидает 1 игрока (Создал: ${firstPlayerUsername}`; if (firstPlayerCharacterName) { statusString += ` за ${firstPlayerCharacterName}`; } statusString += `)`; return { id: gameId, // Отправляем полный ID, но в списке UI показываем обрезанный status: statusString }; } // Если игра не соответствует критериям ожидающей (например, пуста, заполнена, окончена), не включаем ее if (game && !this.pendingPvPGames.includes(gameId)) { // Если игра есть, но не в pendingPvPGames, она не должна тут обрабатываться. } else if (game && game.playerCount === 1 && (game.gameState?.isGameOver || !game.gameState)) { // Игра с 1 игроком, но окончена или не инициализирована - не показывать } else if (game && game.playerCount === 2) { // Игра заполнена - не показывать } else if (game && game.playerCount === 0) { // Игра пуста - ее надо было удалить при дисконнекте последнего игрока. // Возможно, тут нужна очистка таких "потерянных" игр. console.warn(`[GameManager] getAvailablePvPGamesList: Найдена пустая игра ${gameId} в games. Удаляем.`); delete this.games[gameId]; // Удаляем потерянную игру // Очистка из pendingPvPGames не нужна, т.к. она удаляется при playerCount === 0 } return null; // Исключаем игры, не соответствующие критериям или удаленные }) .filter(info => info !== null); // Удаляем null из результатов map } /** * Отправляет обновленный список доступных PvP игр всем подключенным клиентам. */ broadcastAvailablePvPGames() { const availableGames = this.getAvailablePvPGamesListForClient(); this.io.emit('availablePvPGamesList', availableGames); console.log(`[GameManager] Обновлен список доступных PvP игр. Всего: ${availableGames.length}`); } /** * Получает список активных игр для отладки на сервере. * @returns {Array} Список объектов с краткой информацией об играх. */ getActiveGamesList() { // Для отладки на сервере return Object.values(this.games).map(game => { // Получаем имена персонажей из gameState, если игра инициализирована, иначе из chosenCharacterKey/default let playerSlotCharName = game.gameState?.player?.name || (game.playerCharacterKey ? this._getCharacterBaseData(game.playerCharacterKey)?.name : 'N/A (ожидание)'); let opponentSlotCharName = game.gameState?.opponent?.name || (game.opponentCharacterKey ? this._getCharacterBaseData(game.opponentCharacterKey)?.name : 'N/A (ожидание)'); // Проверяем наличие игроков в слотах, чтобы уточнить статус const playerInSlot1 = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); const playerInSlot2 = Object.values(game.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); if (!playerInSlot1) playerSlotCharName = 'Пусто'; if (!playerInSlot2 && game.mode === 'pvp') opponentSlotCharName = 'Ожидание...'; // В PvP слоты могут быть пустыми if (!playerInSlot2 && game.mode === 'ai' && game.aiOpponent) opponentSlotCharName = 'Балард (AI)'; // В AI слоте оппонента всегда AI return { id: game.id.substring(0,8), // Обрезанный ID для удобства mode: game.mode, playerCount: game.playerCount, isGameOver: game.gameState ? game.gameState.isGameOver : 'N/A (Не инициализирована)', playerSlot: playerSlotCharName, opponentSlot: opponentSlotCharName, ownerIdentifier: game.ownerIdentifier || 'N/A', pending: this.pendingPvPGames.includes(game.id), turn: game.gameState ? `Ход ${game.gameState.turnNumber}, ${game.gameState.isPlayerTurn ? (playerInSlot1?.identifier || 'Player Slot') : (playerInSlot2?.identifier || 'Opponent Slot')}` : 'N/A' }; }); } /** * Обрабатывает запрос клиента на gameState (например, при переподключении). * Находит игру пользователя по его идентификатору и отправляет ему актуальное состояние. * Также обновляет ссылку на сокет в GameInstance. * @param {object} socket - Сокет клиента, запросившего состояние. * @param {string|number} identifier - ID пользователя (userId или socketId). */ handleRequestGameState(socket, identifier) { // Принимаем socket и identifier // Ищем игру пользователя по его идентификатору const gameId = this.userIdentifierToGameId[identifier]; let game = null; if (gameId) { game = this.games[gameId]; } // Если игра найдена и она существует, и в ней есть игрок с этим идентификатором if (game && game.players) { const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); if (playerInfo) { // Проверяем, если игра окончена, не восстанавливаем состояние, а информируем if (game.gameState?.isGameOver) { console.log(`[GameManager] Reconnected user ${identifier} to game ${gameId} which is already over. Sending gameNotFound.`); // Удаляем ссылку на оконченную игру для этого пользователя delete this.userIdentifierToGameId[identifier]; // Отправляем gameNotFound, чтобы клиент вернулся в меню socket.emit('gameNotFound', { message: 'Ваша предыдущая игровая сессия уже завершена.' }); return; // Прекращаем обработку } console.log(`[GameManager] Found game ${gameId} for identifier ${identifier} (role ${playerInfo.id}). Reconnecting socket ${socket.id}.`); // --- Обновляем GameInstance: заменяем старый сокет на новый для этого игрока --- // Удаляем старую запись игрока по старому socket.id, если она есть и отличается const oldSocketId = playerInfo.socket?.id; if (oldSocketId && oldSocketId !== socket.id && game.players[oldSocketId]) { console.log(`[GameManager] Updating socket ID for player ${identifier} from ${oldSocketId} to ${socket.id} in game ${gameId}.`); delete game.players[oldSocketId]; // Удаляем запись по старому socketId // playerCount не уменьшаем/увеличиваем, т.к. это тот же игрок, просто сменил сокет // Удаляем ссылку на старый сокет по роли if (game.playerSockets[playerInfo.id]?.id === oldSocketId) { delete game.playerSockets[playerInfo.id]; } } // Добавляем или обновляем запись для нового сокета, связывая его с существующим идентификатором игрока game.players[socket.id] = playerInfo; // Переиспользуем существующий объект playerInfo game.players[socket.id].socket = socket; // Обновляем объект сокета // Ensure the identifier and role are correct on the new socket entry game.players[socket.id].identifier = identifier; // Make sure identifier is set (уже должно быть, но на всякий случай) // playerInfo.id should already be correct (player/opponent role) game.playerSockets[playerInfo.id] = socket; // Обновляем ссылку на сокет по роли // Убеждаемся, что новый socket.id теперь связан с этой игрой в GameManager - НЕ НУЖНО, socketToGame удален // this.socketToGame[socket.id] = game.id; // Присоединяем новый сокет к комнате Socket.IO socket.join(game.id); // --- КОНЕЦ Обновления сокета --- // Получаем данные персонажей с точки зрения этого клиента // playerInfo.chosenCharacterKey - это персонаж этого клиента const playerCharDataForClient = this._getCharacterData(playerInfo.chosenCharacterKey); // Определяем ключ персонажа оппонента с точки зрения этого клиента const opponentActualSlotId = playerInfo.id === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const opponentCharacterKeyForClient = game.gameState?.[opponentActualSlotId]?.characterKey || null; // Берем из gameState, т.к. там актуальное состояние слотов // Если оппонент еще не определен в gameState (PvP ожидание), используем playerCharacterKey/opponentCharacterKey из gameInstance // ВАЖНО: при переподключении к *активной* игре, gameState.opponent.characterKey ДОЛЖЕН БЫТЬ определен. // Если он null, это может быть PvP ожидание или некорректное состояние. if (!opponentCharacterKeyForClient) { // Попробуем найти ключ из GameInstance properties (они устанавливаются при инициализации) const opponentSlotKeyInInstance = playerInfo.id === GAME_CONFIG.PLAYER_ID ? game.playerCharacterKey : game.opponentCharacterKey; // ИСПРАВЛЕНО: Логика получения ключа оппонента opponentCharacterKeyForClient = opponentSlotKeyInInstance; // Если даже из GameInstance properties ключ null, это точно PvP ожидание или критическая ошибка } const opponentCharDataForClient = this._getCharacterData(opponentCharacterKeyForClient); // Данные оппонента с т.з. клиента if (playerCharDataForClient && opponentCharDataForClient && game.gameState) { // Проверяем, готово ли gameState к игре (определены оба бойца) const isGameReadyForPlay = (game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2); const isOpponentDefinedInState = game.gameState.opponent?.characterKey && game.gameState.opponent?.name !== 'Ожидание игрока...'; socket.emit('gameState', { gameId: game.id, yourPlayerId: playerInfo.id, // ID слота этого клиента в игре gameState: game.gameState, playerBaseStats: playerCharDataForClient.baseStats, // Статы "моего" персонажа для клиента opponentBaseStats: opponentCharDataForClient.baseStats, // Статы "моего" оппонента для клиента playerAbilities: playerCharDataForClient.abilities, // Абилки "моего" персонажа для клиента opponentAbilities: opponentCharDataForClient.abilities, // Абилки "моего" оппонента для клиента log: game.consumeLogBuffer(), // Отправляем текущий лог и очищаем буфер игры clientConfig: { ...GAME_CONFIG } // Отправляем копию конфига }); console.log(`[GameManager] Sent gameState to socket ${socket.id} (identifier: ${identifier}) for game ${game.id}.`); // Логика старта игры при переподключении (если она еще не началась) // Эта логика должна быть только для случая, когда переподключившийся игрок ЗАВЕРШАЕТ состав игры // (например, второй игрок в PvP переподключился к ожидающей игре). // Если игра уже началась, startGame не должен вызываться повторно. // Проверяем: игра не окончена, готова к игре (2 игрока или AI), и состояние оппонента НЕ БЫЛО определено до этого запроса (признак не полностью стартовавшей игры) if (!game.gameState.isGameOver && isGameReadyForPlay && !isOpponentDefinedInState) { console.log(`[GameManager] Game ${game.id} found ready but not fully started on reconnect (Opponent state missing). Initializing/Starting.`); // Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены. const isInitialized = game.initializeGame(); // Переинициализируем state полностью с обоими персонажами if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние game.startGame(); // Запускаем игру (это отправит gameStarted всем, включая этого клиента) } else { console.error(`[GameManager] Failed to initialize game ${game.id} on reconnect. Cannot start.`); // Дополнительная обработка ошибки, возможно, уведомить игроков this.io.to(game.id).emit('gameError', { message: 'Ошибка сервера при старте игры после переподключения. Не удалось инициализировать игру.' }); // Если инициализация провалилась, игра в некорректном состоянии, нужно ее удалить this._cleanupGame(gameId, 'reconnect_initialization_failed'); } } // Если игра уже активно идет (не окончена, не ожидание) и состояние оппонента БЫЛО определено, // то startGame не вызывается повторно. Клиент получит gameStateUpdate от обычного хода игры. // Если игра PvP ожидающая (1 игрок), startGame не вызывается, isGameReadyForPlay будет false. else if (!isGameReadyForPlay) { console.log(`[GameManager] Reconnected user ${identifier} to pending game ${gameId}. Sending gameState and waiting status.`); // Если это ожидающая игра, убедимся, что клиент получает статус ожидания socket.emit('waitingForOpponent'); } else if (game.gameState.isGameOver) { console.log(`[GameManager] Reconnected to game ${gameId} which is already over. Sending gameNotFound.`); // Если игра окончена, client.js должен по gameState.isGameOver показать модалку. // Но чтобы гарантировать возврат в меню при последующих запросах, лучше отправить gameNotFound. // Удаляем ссылку на оконченную игру для этого пользователя delete this.userIdentifierToGameId[identifier]; // Отправляем gameNotFound socket.emit('gameNotFound', { message: 'Ваша предыдущая игровая сессия уже завершена.' }); } else { // Переподключение к активной игре, которая уже полностью стартовала. console.log(`[GameManager] Reconnected user ${identifier} to active game ${gameId}. gameState sent.`); } } else { console.error(`[GameManager] Failed to send gameState to ${socket.id} (identifier ${identifier}) for game ${gameId}: missing character data or gameState.`); socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' }); // Если данные для отправки некорректны, игра в некорректном состоянии, нужно ее удалить this._cleanupGame(gameId, 'reconnect_send_failed'); socket.emit('gameNotFound', { message: 'Ваша игровая сессия в некорректном состоянии и была завершена.' }); } } else { // Игра найдена по идентификатору пользователя, но игрока с этим идентификатором нет в players этой игры. // Это очень странная ситуация, возможно, state userIdentifierToGameId некорректен. console.warn(`[GameManager] Found game ${gameId} by identifier ${identifier}, but player with this identifier not found in game.players.`); // Удаляем некорректную ссылку и отправляем gameNotFound delete this.userIdentifierToGameId[identifier]; socket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена. Возможно, идентификатор пользователя некорректен.' }); } } else { // Игра не найдена по userIdentifierToGameId[identifier] console.log(`[GameManager] No active or pending game found for identifier ${identifier}.`); socket.emit('gameNotFound', { message: 'Игровая сессия не найдена.' }); // Уведомляем клиента, что игра не найдена } } // --- Вспомогательные функции для получения данных персонажа из data.js --- // Скопировано из gameInstance.js, т.к. gameManager тоже использует gameData напрямую /** * Получает базовые статы и список способностей для персонажа по ключу. * Эти функции предназначены для использования ВНУТРИ GameManager или GameInstance. * @param {string} key - Ключ персонажа ('elena', 'balard', 'almagest'). * @returns {{baseStats: object, abilities: array}|null} Объект с базовыми статами и способностями, или null. */ _getCharacterData(key) { if (!key) { console.warn("GameManager::_getCharacterData called with null/undefined key."); return null; } switch (key) { case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities }; case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; // Балард использует opponentAbilities из data.js case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; // Альмагест использует almagestAbilities из data.js default: console.error(`GameManager::_getCharacterData: Unknown character key "${key}"`); return null; } } /** * Получает только базовые статы для персонажа по ключу. * @param {string} key - Ключ персонажа. * @returns {object|null} Базовые статы или null. */ _getCharacterBaseData(key) { const charData = this._getCharacterData(key); return charData ? charData.baseStats : null; } /** * Получает только список способностей для персонажа по ключу. * @param {string} key - Ключ персонажа. * @returns {array|null} Список способностей или null. */ _getCharacterAbilities(key) { const charData = this._getCharacterData(key); return charData ? charData.abilities : null; } } module.exports = GameManager;