bc/server_modules/gameManager.js
2025-05-15 16:20:25 +00:00

792 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /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<object>} Массив объектов с информацией об играх.
*/
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<object>} Список объектов с краткой информацией об играх.
*/
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;