bc/server/game/instance/PlayerConnectionHandler.js

397 lines
26 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/game/instance/PlayerConnectionHandler.js
const GAME_CONFIG = require('../../core/config');
const dataUtils = require('../../data/dataUtils'); // Потребуется для получения данных персонажа при реконнекте
class PlayerConnectionHandler {
constructor(gameInstance) {
this.gameInstance = gameInstance; // Ссылка на основной GameInstance
this.io = gameInstance.io;
this.gameId = gameInstance.id;
this.mode = gameInstance.mode;
this.players = {}; // { socket.id: { id, socket, chosenCharacterKey, identifier, isTemporarilyDisconnected } }
this.playerSockets = {}; // { playerIdRole: socket }
this.playerCount = 0;
this.reconnectTimers = {}; // { playerIdRole: { timerId, updateIntervalId, startTimeMs, durationMs } }
this.pausedTurnState = null; // { remainingTime, forPlayerRoleIsPlayer, isAiCurrentlyMoving }
console.log(`[PCH for Game ${this.gameId}] Initialized.`);
}
addPlayer(socket, chosenCharacterKey = 'elena', identifier) {
console.log(`[PCH ${this.gameId}] addPlayer attempt. Socket: ${socket.id}, CharKey: ${chosenCharacterKey}, Identifier: ${identifier}`);
const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier);
if (existingPlayerByIdentifier) {
console.warn(`[PCH ${this.gameId}] Identifier ${identifier} already associated with player role ${existingPlayerByIdentifier.id} (socket ${existingPlayerByIdentifier.socket?.id}). Handling as potential reconnect.`);
if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) {
console.warn(`[PCH ${this.gameId}] Player ${identifier} trying to (re)join an already finished game. Emitting gameError.`);
socket.emit('gameError', { message: 'Эта игра уже завершена.' });
this.gameInstance.gameManager._cleanupGame(this.gameId, `rejoin_attempt_to_finished_game_pch_${identifier}`);
return false;
}
if (existingPlayerByIdentifier.isTemporarilyDisconnected) {
return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket);
}
socket.emit('gameError', { message: 'Вы уже находитесь в этой игре. Попробуйте обновить страницу.' });
return false;
}
if (Object.keys(this.players).length >= 2 && this.playerCount >=2) {
socket.emit('gameError', { message: 'Эта игра уже заполнена.' });
return false;
}
let assignedPlayerId;
let actualCharacterKey = chosenCharacterKey || 'elena';
if (this.mode === 'ai') {
if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) {
socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' });
return false;
}
assignedPlayerId = GAME_CONFIG.PLAYER_ID;
} else { // pvp
if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) {
assignedPlayerId = GAME_CONFIG.PLAYER_ID;
} else if (!this.playerSockets[GAME_CONFIG.OPPONENT_ID]) {
assignedPlayerId = GAME_CONFIG.OPPONENT_ID;
const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) {
if (actualCharacterKey === 'elena') actualCharacterKey = 'almagest';
else if (actualCharacterKey === 'almagest') actualCharacterKey = 'elena';
// Добавьте другие пары, если нужно, или более общую логику выбора другого персонажа
}
} else {
socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' });
return false;
}
}
// Если для этой роли уже был игрок (например, старый сокет), удаляем его
const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId && this.players[sid].socket?.id !== socket.id);
if (oldPlayerSocketIdForRole) {
const oldPlayerInfo = this.players[oldPlayerSocketIdForRole];
if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.gameId); } catch(e){} } // Убедимся, что старый сокет покинул комнату
delete this.players[oldPlayerSocketIdForRole];
}
this.players[socket.id] = {
id: assignedPlayerId,
socket: socket,
chosenCharacterKey: actualCharacterKey,
identifier: identifier,
isTemporarilyDisconnected: false
};
this.playerSockets[assignedPlayerId] = socket;
this.playerCount++;
socket.join(this.gameId);
// Сообщаем GameInstance об установленных ключах и владельце
if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey);
else if (assignedPlayerId === GAME_CONFIG.OPPONENT_ID) this.gameInstance.setOpponentCharacterKey(actualCharacterKey);
if (!this.gameInstance.ownerIdentifier && (this.mode === 'ai' || (this.mode === 'pvp' && assignedPlayerId === GAME_CONFIG.PLAYER_ID))) {
this.gameInstance.setOwnerIdentifier(identifier);
}
const charData = dataUtils.getCharacterData(actualCharacterKey); // Используем dataUtils напрямую
console.log(`[PCH ${this.gameId}] Player ${identifier} (Socket: ${socket.id}) added as ${assignedPlayerId} with char ${charData?.baseStats?.name || actualCharacterKey}. Active players: ${this.playerCount}. Owner: ${this.gameInstance.ownerIdentifier}`);
return true;
}
removePlayer(socketId, reason = "unknown_reason_for_removal") {
const playerInfo = this.players[socketId];
if (playerInfo) {
const playerRole = playerInfo.id;
const playerIdentifier = playerInfo.identifier;
console.log(`[PCH ${this.gameId}] Final removal of player ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Reason: ${reason}.`);
if (playerInfo.socket) {
try { playerInfo.socket.leave(this.gameId); } catch (e) { /* ignore */ }
}
if (!playerInfo.isTemporarilyDisconnected) { // Уменьшаем счетчик только если это был активный игрок, а не временное отключение
this.playerCount--;
}
delete this.players[socketId];
if (this.playerSockets[playerRole]?.id === socketId) { // Если это был текущий сокет для роли
delete this.playerSockets[playerRole];
}
this.clearReconnectTimer(playerRole); // Очищаем таймер переподключения для этой роли
console.log(`[PCH ${this.gameId}] Player ${playerIdentifier} removed. Active players now: ${this.playerCount}.`);
// Сигнализируем GameInstance, чтобы он решил, нужно ли завершать игру
this.gameInstance.handlePlayerPermanentlyLeft(playerRole, playerInfo.chosenCharacterKey, reason);
} else {
console.warn(`[PCH ${this.gameId}] removePlayer called for unknown socketId: ${socketId}`);
}
}
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) {
console.log(`[PCH ${this.gameId}] handlePlayerPotentiallyLeft for role ${playerIdRole}, id ${identifier}, char ${characterKey}`);
// Находим запись игрока по роли и идентификатору, так как сокет мог уже измениться или быть удален
const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
if (!playerEntry || !playerEntry.socket) {
console.warn(`[PCH ${this.gameId}] No player entry or socket found for ${identifier} (role ${playerIdRole}) during potential left.`);
return;
}
if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) {
console.log(`[PCH ${this.gameId}] Game already over, not handling potential left for ${identifier}.`);
return;
}
if (playerEntry.isTemporarilyDisconnected) {
console.log(`[PCH ${this.gameId}] Player ${identifier} already marked as temp disconnected.`);
return;
}
playerEntry.isTemporarilyDisconnected = true;
this.playerCount--; // Уменьшаем счетчик активных игроков
console.log(`[PCH ${this.gameId}] Player ${identifier} (role ${playerIdRole}) temp disconnected. Active: ${this.playerCount}. Starting reconnect timer.`);
const disconnectedName = this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`;
this.gameInstance.addToLog(`🔌 Игрок ${disconnectedName} отключился. Ожидание переподключения...`, GAME_CONFIG.LOG_TYPE_SYSTEM);
this.gameInstance.broadcastLogUpdate();
// Уведомляем другого игрока, если он есть и подключен
const otherPlayerRole = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
const otherSocket = this.playerSockets[otherPlayerRole]; // Берем сокет из нашего this.playerSockets
const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole);
if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) {
otherSocket.emit('opponentDisconnected', {
disconnectedPlayerId: playerIdRole,
disconnectedCharacterName: disconnectedName,
});
}
// Приостанавливаем таймер хода, если он активен
if (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isAiCurrentlyMakingMove) ) {
this.pausedTurnState = this.gameInstance.turnTimer.pause();
console.log(`[PCH ${this.gameId}] Turn timer paused due to disconnect. State:`, JSON.stringify(this.pausedTurnState));
} else {
this.pausedTurnState = null; // Явно сбрасываем, если таймер не был активен
}
this.clearReconnectTimer(playerIdRole); // Очищаем старый таймер, если был
const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000;
const reconnectStartTime = Date.now();
// Таймер для обновления UI клиента
const updateInterval = setInterval(() => {
const remaining = reconnectDuration - (Date.now() - reconnectStartTime);
if (remaining <= 0) { // Если основной таймаут уже сработал или время вышло
if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 });
return;
}
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: Math.ceil(remaining) });
}, 1000);
// Основной таймер на окончательное удаление
const timeoutId = setTimeout(() => {
this.clearReconnectTimer(playerIdRole); // Очищаем таймеры (включая updateInterval)
const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) {
// Передаем socket.id из записи, а не старый socketId, который мог быть от предыдущего сокета
this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout");
}
}, reconnectDuration);
this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration };
}
handlePlayerReconnected(playerIdRole, newSocket) {
const identifier = newSocket.userData?.userId; // Получаем идентификатор из нового сокета
console.log(`[PCH ${this.gameId}] handlePlayerReconnected for role ${playerIdRole}, id ${identifier}, newSocket ${newSocket.id}`);
if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) {
newSocket.emit('gameError', { message: 'Игра уже завершена.' });
this.gameInstance.gameManager._cleanupGame(this.gameId, `reconnect_to_finished_game_pch_${identifier}`);
return false;
}
// Находим запись игрока по роли и идентификатору
const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
if (playerEntry && playerEntry.isTemporarilyDisconnected) {
this.clearReconnectTimer(playerIdRole);
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сигнал, что таймер остановлен
// Удаляем старую запись по socket.id, если сокет действительно новый
const oldSocketId = playerEntry.socket.id;
if (this.players[oldSocketId] && oldSocketId !== newSocket.id) {
delete this.players[oldSocketId];
}
// Обновляем запись игрока
playerEntry.socket = newSocket;
playerEntry.isTemporarilyDisconnected = false;
this.players[newSocket.id] = playerEntry; // Добавляем/обновляем запись с новым socket.id
this.playerSockets[playerIdRole] = newSocket; // Обновляем активный сокет для роли
this.playerCount++; // Восстанавливаем счетчик активных игроков
newSocket.join(this.gameId);
const reconnectedName = this.gameInstance.gameState?.[playerIdRole]?.name || playerEntry.chosenCharacterKey;
console.log(`[PCH ${this.gameId}] Player ${identifier} (${reconnectedName}) reconnected. Active: ${this.playerCount}.`);
this.gameInstance.addToLog(`🔌 Игрок ${reconnectedName} снова в игре!`, GAME_CONFIG.LOG_TYPE_SYSTEM);
const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey);
const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
// Получаем ключ персонажа оппонента из gameState ИЛИ из предварительно сохраненных ключей в GameInstance
let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey ||
(playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey);
const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null;
// Если gameState нет (маловероятно при реконнекте в активную игру, но возможно если это был первый игрок PvP)
// GameInstance должен сам решить, нужно ли ему initializeGame()
if (!this.gameInstance.gameState) {
// Пытаемся инициализировать игру, если она не была инициализирована
// Это важно, если первый игрок в PvP отключался до подключения второго
if (!this.gameInstance.initializeGame()) {
this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch', 'PCH: GS null after re-init on reconnect.');
return false;
}
}
newSocket.emit('gameStarted', {
gameId: this.gameId,
yourPlayerId: playerIdRole,
initialGameState: this.gameInstance.gameState, // Отправляем текущее состояние
playerBaseStats: pData?.baseStats, // Данные для этого игрока
opponentBaseStats: oData?.baseStats || dataUtils.getCharacterBaseStats(null) || {name: 'Ожидание...', maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null},
playerAbilities: pData?.abilities,
opponentAbilities: oData?.abilities || [],
log: this.gameInstance.consumeLogBuffer(),
clientConfig: { ...GAME_CONFIG } // Отправляем копию конфига
});
// Уведомляем другого игрока
const otherSocket = this.playerSockets[oppRoleKey];
const otherPlayerEntry = Object.values(this.players).find(p=> p.id === oppRoleKey);
if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) {
otherSocket.emit('playerReconnected', {
reconnectedPlayerId: playerIdRole,
reconnectedPlayerName: reconnectedName
});
if (this.gameInstance.logBuffer.length > 0) { // Отправляем накопившиеся логи, если есть
otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() });
}
}
// Если игра не на "эффективной" паузе и не закончена, возобновляем игру
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) {
this.gameInstance.broadcastGameStateUpdate(); // Обновляем состояние для всех
if (this.pausedTurnState && typeof this.pausedTurnState.remainingTime === 'number') {
this.gameInstance.turnTimer.resume(
this.pausedTurnState.remainingTime,
this.pausedTurnState.forPlayerRoleIsPlayer,
this.pausedTurnState.isAiCurrentlyMoving
);
this.pausedTurnState = null; // Сбрасываем сохраненное состояние таймера
} else {
// Если pausedTurnState нет, значит, таймер не был активен или это первый ход
// GameInstance.startGame или switchTurn должны запустить таймер корректно
// Но если это реконнект в середину игры, где ход уже чей-то, нужно запустить таймер
const currentTurnIsForPlayer = this.gameInstance.gameState.isPlayerTurn;
const isCurrentTurnAi = this.mode === 'ai' && !currentTurnIsForPlayer;
this.gameInstance.turnTimer.start(currentTurnIsForPlayer, isCurrentTurnAi);
}
}
return true;
} else if (playerEntry && !playerEntry.isTemporarilyDisconnected) {
// Игрок уже был подключен и не был отмечен как isTemporarilyDisconnected
// Это может быть попытка открыть игру в новой вкладке или "обновить сессию"
if (playerEntry.socket.id !== newSocket.id) {
newSocket.emit('gameError', {message: "Вы уже активно подключены с другой сессии."});
return false; // Не позволяем подключиться с нового сокета, если старый активен
}
// Если это тот же сокет (например, клиент запросил состояние), просто отправляем ему данные
if (!this.gameInstance.gameState) { // На всякий случай, если gameState вдруг нет
if (!this.gameInstance.initializeGame()) {
this.gameInstance._handleCriticalError('reconnect_same_socket_no_gs_pch','PCH: GS null on same socket reconnect.');
return false;
}
}
const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey);
const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey ||
(playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey);
const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null;
newSocket.emit('gameStarted', {
gameId: this.gameId,
yourPlayerId: playerIdRole,
initialGameState: this.gameInstance.gameState,
playerBaseStats: pData?.baseStats,
opponentBaseStats: oData?.baseStats, // Могут быть неполными, если оппонент еще не подключился
playerAbilities: pData?.abilities,
opponentAbilities: oData?.abilities,
log: this.gameInstance.consumeLogBuffer(),
clientConfig: { ...GAME_CONFIG }
});
return true;
} else {
// Запись игрока не найдена или он не был помечен как isTemporarilyDisconnected, но сокет новый.
// Это может быть попытка реконнекта к игре, из которой игрок был уже удален (например, по таймауту).
newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена или сессия устарела).' });
return false;
}
}
clearReconnectTimer(playerIdRole) {
if (this.reconnectTimers[playerIdRole]) {
clearTimeout(this.reconnectTimers[playerIdRole].timerId);
if (this.reconnectTimers[playerIdRole].updateIntervalId) {
clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
}
delete this.reconnectTimers[playerIdRole];
console.log(`[PCH ${this.gameId}] Cleared reconnect timer for role ${playerIdRole}.`);
}
}
clearAllReconnectTimers() {
console.log(`[PCH ${this.gameId}] Clearing ALL reconnect timers.`);
for (const roleId in this.reconnectTimers) {
this.clearReconnectTimer(roleId);
}
}
isGameEffectivelyPaused() {
if (this.mode === 'pvp') {
// Если игроков меньше 2, И есть хотя бы один игрок в this.players (ожидающий или в процессе дисконнекта)
if (this.playerCount < 2 && Object.keys(this.players).length > 0) {
// Проверяем, есть ли кто-то из них в состоянии временного дисконнекта
const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID);
if ((p1Entry && p1Entry.isTemporarilyDisconnected) || (p2Entry && p2Entry.isTemporarilyDisconnected)) {
return true; // Игра на паузе, если один из игроков временно отключен
}
}
} else if (this.mode === 'ai') {
// В AI режиме игра на паузе, если единственный человек-игрок временно отключен
const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
return humanPlayer?.isTemporarilyDisconnected ?? false; // Если игрока нет, не на паузе. Если есть - зависит от его состояния.
}
return false; // В остальных случаях игра не считается на паузе из-за дисконнектов
}
// Вспомогательный метод для получения информации о всех игроках (может пригодиться GameInstance)
getAllPlayersInfo() {
return { ...this.players };
}
// Вспомогательный метод для получения сокетов (может пригодиться GameInstance)
getPlayerSockets() {
return { ...this.playerSockets };
}
}
module.exports = PlayerConnectionHandler;