Обработка ситуаций рекконекта.

This commit is contained in:
PsiMagistr 2025-05-29 13:33:32 +03:00
parent ab03488c95
commit 59ac3520f1
6 changed files with 1022 additions and 784 deletions

View File

@ -1,4 +1,4 @@
// /public/js/gameplay.js (Откаченная версия, совместимая с последним GameInstance.js)
// /public/js/gameplay.js
export function initGameplay(dependencies) {
const { socket, clientState, ui } = dependencies;
@ -7,21 +7,49 @@ export function initGameplay(dependencies) {
const attackButton = document.getElementById('button-attack');
const abilitiesGrid = document.getElementById('abilities-grid');
// Инициализируем флаг в clientState, если он еще не существует (лучше делать в main.js)
if (typeof clientState.isActionInProgress === 'undefined') {
clientState.isActionInProgress = false;
}
// --- Вспомогательные функции ---
function enableGameControls(enableAttack = true, enableAbilities = true) {
// console.log(`[GP] enableGameControls called. enableAttack: ${enableAttack}, enableAbilities: ${enableAbilities}, isActionInProgress: ${clientState.isActionInProgress}`);
if (clientState.isActionInProgress) {
if (attackButton) attackButton.disabled = true;
if (abilitiesGrid) {
const config = window.GAME_CONFIG || {};
const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = true; });
}
// console.log(`[GP] Action in progress, controls remain disabled.`);
if (window.gameUI?.updateUI) requestAnimationFrame(() => window.gameUI.updateUI());
return;
}
if (attackButton) attackButton.disabled = !enableAttack;
if (abilitiesGrid) {
const config = window.GAME_CONFIG || {};
const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = !enableAbilities; });
}
// console.log(`[GP] Controls set. Attack disabled: ${attackButton ? attackButton.disabled : 'N/A'}`);
if (window.gameUI?.updateUI) {
requestAnimationFrame(() => window.gameUI.updateUI());
requestAnimationFrame(() => window.gameUI.updateUI()); // Обновляем UI, чтобы 반영 반영反映 изменения в disabled
}
}
function disableGameControls() {
enableGameControls(false, false);
// console.log(`[GP] disableGameControls called.`);
if (attackButton) attackButton.disabled = true;
if (abilitiesGrid) {
const config = window.GAME_CONFIG || {};
const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = true; });
}
if (window.gameUI?.updateUI) {
requestAnimationFrame(() => window.gameUI.updateUI()); // Обновляем UI, чтобы 반영 반영反映 изменения в disabled
}
}
function initializeAbilityButtons() {
@ -63,31 +91,45 @@ export function initGameplay(dependencies) {
function handleAbilityButtonClick(event) {
const abilityId = event.currentTarget.dataset.abilityId;
const username = clientState.loggedInUsername || 'N/A';
console.log(`[CLIENT ${username}] handleAbilityButtonClick. AbilityID: ${abilityId}, isActionInProgress: ${clientState.isActionInProgress}`);
if (clientState.isLoggedIn &&
clientState.isInGame &&
clientState.currentGameId &&
abilityId &&
clientState.currentGameState &&
!clientState.currentGameState.isGameOver) {
!clientState.currentGameState.isGameOver &&
!clientState.isActionInProgress) { // <--- ПРОВЕРКА ФЛАГА
console.log(`[CLIENT ${username}] Emitting playerAction (ability: ${abilityId}). Setting isActionInProgress = true.`);
clientState.isActionInProgress = true; // <--- УСТАНОВКА ФЛАГА
disableGameControls(); // <--- БЛОКИРОВКА СРАЗУ
socket.emit('playerAction', { actionType: 'ability', abilityId: abilityId });
disableGameControls();
} else {
console.warn("Cannot perform ability action, invalid state");
console.warn(`[CLIENT ${username}] Cannot perform ability action. Conditions not met or action in progress. InGame: ${clientState.isInGame}, GameOver: ${clientState.currentGameState?.isGameOver}, ActionInProgress: ${clientState.isActionInProgress}`);
}
}
// --- Обработчики событий DOM ---
if (attackButton) {
attackButton.addEventListener('click', () => {
const username = clientState.loggedInUsername || 'N/A';
console.log(`[CLIENT ${username}] Attack button clicked. isActionInProgress: ${clientState.isActionInProgress}`);
if (clientState.isLoggedIn &&
clientState.isInGame &&
clientState.currentGameId &&
clientState.currentGameState &&
!clientState.currentGameState.isGameOver) {
!clientState.currentGameState.isGameOver &&
!clientState.isActionInProgress) { // <--- ПРОВЕРКА ФЛАГА
console.log(`[CLIENT ${username}] Emitting playerAction (attack). Setting isActionInProgress = true.`);
clientState.isActionInProgress = true; // <--- УСТАНОВКА ФЛАГА
disableGameControls(); // <--- БЛОКИРОВКА СРАЗУ
socket.emit('playerAction', { actionType: 'attack' });
disableGameControls();
} else {
console.warn("Cannot perform attack action, invalid state.");
console.warn(`[CLIENT ${username}] Cannot perform attack action. Conditions not met or action in progress. InGame: ${clientState.isInGame}, GameOver: ${clientState.currentGameState?.isGameOver}, ActionInProgress: ${clientState.isActionInProgress}`);
}
});
}
@ -98,7 +140,8 @@ export function initGameplay(dependencies) {
ui.showAuthScreen();
return;
}
returnToMenuButton.disabled = true;
returnToMenuButton.disabled = true; // Блокируем сразу, чтобы избежать двойных кликов
clientState.isActionInProgress = false; // Сбрасываем на всякий случай, если покидаем игру
clientState.isInGame = false;
disableGameControls();
ui.showGameSelectionScreen(clientState.loggedInUsername);
@ -108,11 +151,14 @@ export function initGameplay(dependencies) {
// --- ОБЩИЙ ОБРАБОТЧИК ДЛЯ ЗАПУСКА/ВОССТАНОВЛЕНИЯ ИГРЫ ---
function handleGameDataReceived(data, eventName = "unknown") {
if (!clientState.isLoggedIn) return;
const username = clientState.loggedInUsername || 'N/A'; // Для логов
console.log(`[CLIENT ${username}] ${eventName} received.`);
// if (data.log) console.log(`[CLIENT ${username}] ${eventName} log content:`, JSON.parse(JSON.stringify(data.log)));
if (!clientState.isLoggedIn) {
console.warn(`[CLIENT] handleGameDataReceived (${eventName}) called, but client not logged in. Ignoring.`);
return;
}
const username = clientState.loggedInUsername || 'N/A';
console.log(`[CLIENT ${username}] handleGameDataReceived from event: ${eventName}. GameID: ${data.gameId}, YourPlayerID: ${data.yourPlayerId}, GS.isPlayerTurn: ${data.initialGameState?.isPlayerTurn || data.gameState?.isPlayerTurn}`);
clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА при получении нового полного состояния
clientState.currentGameId = data.gameId;
clientState.myPlayerId = data.yourPlayerId;
@ -133,7 +179,7 @@ export function initGameplay(dependencies) {
if (data.clientConfig) {
window.GAME_CONFIG = { ...window.GAME_CONFIG, ...data.clientConfig };
} else if (!window.GAME_CONFIG) {
window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' };
window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' }; // Базовый конфиг
}
ui.updateGlobalWindowVariablesForUI();
@ -148,13 +194,10 @@ export function initGameplay(dependencies) {
initializeAbilityButtons();
if (window.gameUI?.uiElements?.log?.list) {
// console.log(`[CLIENT ${username}] Log BEFORE clear in ${eventName}:`, window.gameUI.uiElements.log.list.innerHTML.substring(0,100));
window.gameUI.uiElements.log.list.innerHTML = ''; // Очищаем UI-лог перед добавлением новых
// console.log(`[CLIENT ${username}] Log AFTER clear in ${eventName}:`, window.gameUI.uiElements.log.list.innerHTML);
window.gameUI.uiElements.log.list.innerHTML = '';
}
if (window.gameUI?.addToLog && data.log) {
data.log.forEach(logEntry => {
// console.log(`[CLIENT ${username}] Adding to UI log from ${eventName}: "${logEntry.message}"`);
window.gameUI.addToLog(logEntry.message, logEntry.type);
});
}
@ -168,45 +211,50 @@ export function initGameplay(dependencies) {
const isMyActualTurn = clientState.myPlayerId &&
((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) ||
(!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID));
console.log(`[CLIENT ${username}] handleGameDataReceived - Determining controls. isMyActualTurn: ${isMyActualTurn}`);
if (isMyActualTurn) {
enableGameControls();
} else {
disableGameControls();
}
} else if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
console.log(`[CLIENT ${username}] handleGameDataReceived - Game is over, disabling controls.`);
disableGameControls();
}
});
// Управление gameStatusMessage
if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
// gameOver имеет свой обработчик статуса (внутри socket.on('gameOver',...))
// Обработка gameOver уже есть в своем обработчике
} else if (eventName === 'gameStarted' || eventName === 'gameState (reconnect)') {
// Это начало игры или восстановление сессии, статус должен быть чистым
console.log(`[CLIENT ${username}] ${eventName} - Clearing game status message because it's a fresh game/state load.`);
ui.setGameStatusMessage("");
} else {
// Для gameStateUpdate и других событий, не являющихся полной перезагрузкой,
// gameStatusMessage будет управляться в их обработчиках или через turnTimerUpdate.
// Если игра продолжается и не gameOver, общее сообщение "Ожидание" должно сниматься.
if (clientState.isInGame) {
ui.setGameStatusMessage("");
// Если это просто gameStateUpdate, и игра активна, убедимся, что нет сообщения об ожидании
const statusMsgElement = document.getElementById('game-status-message');
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
if (!currentStatusText.toLowerCase().includes("отключился")) { // Не стираем сообщение об отключении оппонента
ui.setGameStatusMessage("");
}
}
}
// Если игра пришла завершенной, то showGameOver должен быть вызван.
if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
if (window.gameUI?.showGameOver && !document.getElementById('game-over-screen').classList.contains('hidden')) {
if (window.gameUI?.showGameOver && !document.getElementById('game-over-screen').classList.contains(window.GAME_CONFIG?.CSS_CLASS_HIDDEN || 'hidden')) {
// Экран уже показан
} else if (window.gameUI?.showGameOver) {
let playerWon = false;
if (data.winnerId) {
playerWon = data.winnerId === clientState.myPlayerId;
} else if (clientState.currentGameState.player && clientState.currentGameState.opponent) {
// Дополнительная логика определения победителя, если winnerId нет (маловероятно при корректной работе сервера)
if (clientState.currentGameState.player.currentHp > 0 && clientState.currentGameState.opponent.currentHp <=0) {
playerWon = clientState.myPlayerId === clientState.currentGameState.player.id;
} else if (clientState.currentGameState.opponent.currentHp > 0 && clientState.currentGameState.player.currentHp <=0) {
playerWon = clientState.myPlayerId === clientState.currentGameState.opponent.id;
}
}
window.gameUI.showGameOver(playerWon, data.reason || "Игра завершена", clientState.opponentCharacterKey, { finalGameState: clientState.currentGameState, ...data });
window.gameUI.showGameOver(playerWon, data.reason || "Игра завершена", clientState.opponentCharacterKey || data.loserCharacterKey, { finalGameState: clientState.currentGameState, ...data });
}
if (returnToMenuButton) returnToMenuButton.disabled = false;
}
@ -218,15 +266,16 @@ export function initGameplay(dependencies) {
handleGameDataReceived(data, 'gameStarted');
});
socket.on('gameState', (data) => { // Это событие было добавлено для поддержки reconnect из старого GameInstance
socket.on('gameState', (data) => {
handleGameDataReceived(data, 'gameState (reconnect)');
});
socket.on('gameStateUpdate', (data) => {
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
const username = clientState.loggedInUsername || 'N/A';
console.log(`[CLIENT ${username}] Event: gameStateUpdate.`);
console.log(`[CLIENT ${username}] Event: gameStateUpdate. GS.isPlayerTurn: ${data.gameState?.isPlayerTurn}`);
clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА
clientState.currentGameState = data.gameState;
ui.updateGlobalWindowVariablesForUI();
@ -239,17 +288,22 @@ export function initGameplay(dependencies) {
((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) ||
(!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID));
console.log(`[CLIENT ${username}] gameStateUpdate - Determining controls. isMyActualTurn: ${isMyActualTurn}`);
if (isMyActualTurn) {
enableGameControls();
} else {
disableGameControls();
}
console.log(`[CLIENT ${username}] gameStateUpdate - Clearing game status message as game is active.`);
ui.setGameStatusMessage(""); // Очищаем статус, если игра активна
const statusMsgElement = document.getElementById('game-status-message');
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
if (!currentStatusText.toLowerCase().includes("отключился")) {
ui.setGameStatusMessage("");
}
} else if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
disableGameControls(); // Отключаем управление, если игра закончилась этим обновлением
console.log(`[CLIENT ${username}] gameStateUpdate - Game is over, disabling controls.`);
disableGameControls();
}
});
}
@ -261,7 +315,7 @@ export function initGameplay(dependencies) {
socket.on('logUpdate', (data) => {
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
const username = clientState.loggedInUsername || 'N/A';
// const username = clientState.loggedInUsername || 'N/A';
// console.log(`[CLIENT ${username}] Event: logUpdate. Logs:`, data.log);
if (window.gameUI?.addToLog && data.log) {
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
@ -270,106 +324,121 @@ export function initGameplay(dependencies) {
socket.on('gameOver', (data) => {
if (!clientState.isLoggedIn || !clientState.currentGameId || !window.GAME_CONFIG) {
// Если нет ID игры, но залогинен, возможно, стоит запросить состояние
if (!clientState.currentGameId && clientState.isLoggedIn) socket.emit('requestGameState');
else if (!clientState.isLoggedIn) ui.showAuthScreen(); // Если не залогинен, показать экран входа
else if (!clientState.isLoggedIn) ui.showAuthScreen();
return;
}
const username = clientState.loggedInUsername || 'N/A';
console.log(`[CLIENT ${username}] Event: gameOver.`);
console.log(`[CLIENT ${username}] Event: gameOver. WinnerID: ${data.winnerId}, Reason: ${data.reason}`);
clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА
const playerWon = data.winnerId === clientState.myPlayerId;
clientState.currentGameState = data.finalGameState; // Обновляем состояние последним полученным
clientState.isInGame = false; // Игра точно закончена
clientState.currentGameState = data.finalGameState;
clientState.isInGame = false;
ui.updateGlobalWindowVariablesForUI(); // Обновляем глобальные переменные для ui.js
ui.updateGlobalWindowVariablesForUI();
if (window.gameUI?.updateUI) requestAnimationFrame(() => window.gameUI.updateUI()); // Обновляем UI один раз
if (window.gameUI?.updateUI) requestAnimationFrame(() => window.gameUI.updateUI());
if (window.gameUI?.addToLog && data.log) {
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
}
if (window.gameUI?.showGameOver) {
const oppKey = clientState.opponentBaseStatsServer?.characterKey; // Используем сохраненные данные оппонента
const oppKey = clientState.opponentCharacterKey || data.loserCharacterKey;
window.gameUI.showGameOver(playerWon, data.reason, oppKey, data);
}
if (returnToMenuButton) returnToMenuButton.disabled = false;
// `ui.setGameStatusMessage` будет установлено специфичным сообщением о результате игры
// ui.setGameStatusMessage("Игра окончена. " + (playerWon ? "Вы победили!" : "Вы проиграли."));
if (window.gameUI?.updateTurnTimerDisplay) {
window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState?.gameMode); // Сбрасываем таймер
window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState?.gameMode);
}
disableGameControls(); // Отключаем управление игрой
disableGameControls();
});
socket.on('opponentDisconnected', (data) => {
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
const username = clientState.loggedInUsername || 'N/A';
console.log(`[CLIENT ${username}] Event: opponentDisconnected.`);
console.log(`[CLIENT ${username}] Event: opponentDisconnected. PlayerID: ${data.disconnectedPlayerId}`);
const name = data.disconnectedCharacterName || clientState.opponentBaseStatsServer?.name || 'Противник';
// Сообщение об отключении оппонента должно приходить через 'logUpdate' от сервера
// if (window.gameUI?.addToLog) {
// window.gameUI.addToLog(`🔌 Противник (${name}) отключился.`, 'system');
// }
if (clientState.currentGameState && !clientState.currentGameState.isGameOver) {
ui.setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true); // Показываем сообщение ожидания
disableGameControls(); // Отключаем управление на время ожидания
ui.setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true);
disableGameControls();
}
});
socket.on('playerReconnected', (data) => { // Обработчик события, что оппонент переподключился
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
const username = clientState.loggedInUsername || 'N/A';
console.log(`[CLIENT ${username}] Event: playerReconnected. PlayerID: ${data.reconnectedPlayerId}, Name: ${data.reconnectedPlayerName}`);
// const name = data.reconnectedPlayerName || clientState.opponentBaseStatsServer?.name || 'Противник';
if (clientState.currentGameState && !clientState.currentGameState.isGameOver) {
// Сообщение о переподключении оппонента обычно приходит через 'logUpdate'
// Но если нужно немедленно убрать статус "Ожидание...", можно сделать здесь:
const statusMsgElement = document.getElementById('game-status-message');
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
if (currentStatusText.toLowerCase().includes("отключился")) {
ui.setGameStatusMessage(""); // Очищаем сообщение об ожидании
}
// Логика enable/disableGameControls будет вызвана следующим gameStateUpdate или turnTimerUpdate
}
});
socket.on('turnTimerUpdate', (data) => {
// Проверяем, в игре ли мы и есть ли gameState, прежде чем обновлять таймер
if (!clientState.isInGame || !clientState.currentGameState || !window.GAME_CONFIG) {
// Если не в игре, но gameState есть (например, игра завершена, но экран еще не обновился),
// то таймер нужно сбросить/скрыть.
if (window.gameUI?.updateTurnTimerDisplay && clientState.currentGameState && !clientState.currentGameState.isGameOver) {
window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState.gameMode);
}
return;
}
// Если игра завершена, таймер не должен обновляться или должен быть сброшен
if (clientState.currentGameState.isGameOver) {
if (window.gameUI?.updateTurnTimerDisplay) {
window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState.gameMode);
}
disableGameControls(); // Убедимся, что управление отключено
// disableGameControls() уже должен быть вызван в gameOver
return;
}
// const username = clientState.loggedInUsername || 'N/A';
// console.log(`[CLIENT ${username}] Event: turnTimerUpdate. Remaining: ${data.remainingTime}, isPlayerTurnForTimer: ${data.isPlayerTurn}, isPaused: ${data.isPaused}`);
const username = clientState.loggedInUsername || 'N/A';
// console.log(`[CLIENT ${username}] Event: turnTimerUpdate.`);
if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') {
const config = window.GAME_CONFIG;
const isMyActualTurn = clientState.myPlayerId && clientState.currentGameState &&
((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) ||
(!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID));
const isMyTurnForTimer = clientState.myPlayerId && clientState.currentGameState &&
((data.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) || // Серверное data.isPlayerTurn здесь авторитетно для таймера
(!data.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID));
window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyActualTurn, clientState.currentGameState.gameMode);
window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyTurnForTimer, clientState.currentGameState.gameMode);
// Включаем/отключаем управление в зависимости от хода
if (isMyActualTurn) {
enableGameControls();
} else {
disableGameControls();
}
// Если игра НЕ на паузе (серверной или клиентской из-за дисконнекта оппонента)
if (!data.isPaused) {
// Управление кнопками должно быть на основе isPlayerTurn из gameState, а не из turnTimerUpdate
// gameStateUpdate обработает это. Здесь только если нужно немедленно реагировать на isPlayerTurn из таймера,
// но это может привести к конфликтам с gameState.isPlayerTurn.
// Лучше положиться на gameStateUpdate.
// Однако, если ТАЙМЕР НЕ ПРИОСТАНОВЛЕН и это МОЙ ХОД по таймеру, то кнопки должны быть активны.
// Это может быть полезно, если gameStateUpdate запаздывает.
if (isMyTurnForTimer && !clientState.currentGameState.isGameOver) { // Дополнительная проверка на GameOver
enableGameControls();
} else if (!isMyTurnForTimer && !clientState.currentGameState.isGameOver){ // Иначе, если не мой ход
disableGameControls();
}
// Если таймер активен и игра не закончена, общее сообщение "Ожидание" должно быть снято
// (если оно не специфично для дисконнекта оппонента)
if (!clientState.currentGameState.isGameOver) {
// Проверяем, не показывается ли уже сообщение о дисконнекте оппонента
const statusMsgElement = document.getElementById('game-status-message');
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
if (!currentStatusText.toLowerCase().includes("отключился")) {
console.log(`[CLIENT ${username}] turnTimerUpdate - Clearing game status message as timer is active.`);
if (!currentStatusText.toLowerCase().includes("отключился") && !clientState.currentGameState.isGameOver) {
// console.log(`[CLIENT ${username}] turnTimerUpdate - Clearing game status message as timer is active and not paused.`);
ui.setGameStatusMessage("");
}
} else { // Если игра на паузе (по данным таймера)
// console.log(`[CLIENT ${username}] turnTimerUpdate - Game is paused, disabling controls.`);
disableGameControls(); // Отключаем управление, если таймер говорит, что игра на паузе
}
}
});
// Начальная деактивация
// Начальная деактивация (на всякий случай, хотя showAuthScreen/showGameSelectionScreen должны это делать)
disableGameControls();
}

View File

@ -9,12 +9,10 @@ import { initGameplay } from './gameplay.js';
function parseJwtPayloadForValidation(token) {
try {
if (typeof token !== 'string') {
// console.warn("[Main.js parseJwtPayloadForValidation] Token is not a string:", token);
return null;
}
const parts = token.split('.');
if (parts.length !== 3) {
// console.warn("[Main.js parseJwtPayloadForValidation] Token does not have 3 parts:", token);
return null;
}
const base64Url = parts[1];
@ -31,22 +29,18 @@ function parseJwtPayloadForValidation(token) {
function isTokenValid(token) {
if (!token) {
// console.log("[Main.js isTokenValid] No token provided.");
return false;
}
const decodedToken = parseJwtPayloadForValidation(token);
if (!decodedToken || typeof decodedToken.exp !== 'number') {
// console.warn("[Main.js isTokenValid] Token invalid or no 'exp' field. Clearing token from storage.");
localStorage.removeItem('jwtToken'); // Удаляем невалидный токен
localStorage.removeItem('jwtToken');
return false;
}
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
if (decodedToken.exp < currentTimeInSeconds) {
// console.warn("[Main.js isTokenValid] Token expired. Clearing token from storage.");
localStorage.removeItem('jwtToken'); // Удаляем истекший токен
localStorage.removeItem('jwtToken');
return false;
}
// console.log("[Main.js isTokenValid] Token is valid.");
return true;
}
// --- КОНЕЦ ВСПОМОГАТЕЛЬНЫХ ФУНКЦИЙ ДЛЯ JWT ---
@ -65,31 +59,29 @@ document.addEventListener('DOMContentLoaded', () => {
isInGame: false,
currentGameId: null,
currentGameState: null,
myPlayerId: null,
myPlayerId: null, // Роль в текущей игре (player/opponent)
myCharacterKey: null,
opponentCharacterKey: null,
playerBaseStatsServer: null,
opponentBaseStatsServer: null,
playerAbilitiesServer: null,
opponentAbilitiesServer: null,
isActionInProgress: false, // <--- ВАЖНО: Флаг для предотвращения двойных действий
};
// Проверяем валидность initialToken перед установкой clientState
if (initialToken && isTokenValid(initialToken)) { // Используем нашу новую функцию
const decodedToken = parseJwtPayloadForValidation(initialToken); // Повторно парсим, т.к. isTokenValid не возвращает payload
if (initialToken && isTokenValid(initialToken)) {
const decodedToken = parseJwtPayloadForValidation(initialToken);
if (decodedToken && decodedToken.userId && decodedToken.username) {
console.log("[Main.js] Token found and confirmed valid, pre-populating clientState:", decodedToken);
clientState.isLoggedIn = true;
clientState.myUserId = decodedToken.userId;
clientState.myUserId = decodedToken.userId; // Это ID пользователя из БД
clientState.loggedInUsername = decodedToken.username;
} else {
// Этого не должно случиться, если isTokenValid прошла, но на всякий случай
console.warn("[Main.js] Token deemed valid by isTokenValid, but payload incomplete. Clearing.");
localStorage.removeItem('jwtToken');
}
} else if (initialToken) { // Токен был, но isTokenValid его отверг (и удалил)
} else if (initialToken) {
console.warn("[Main.js] Initial token was present but invalid/expired. It has been cleared.");
// clientState остается по умолчанию (isLoggedIn: false)
} else {
console.log("[Main.js] No initial token found in localStorage.");
}
@ -98,9 +90,9 @@ document.addEventListener('DOMContentLoaded', () => {
console.log('[Main.js] Initializing Socket.IO client...');
const socket = io({
path:base_path + "/socket.io",
autoConnect: false,
auth: { token: localStorage.getItem('jwtToken') } // Передаем токен (может быть null, если был очищен)
path:base_path + "/socket.io", // base_path определяется в HTML
autoConnect: false, // Подключаемся вручную после инициализации всего
auth: { token: localStorage.getItem('jwtToken') }
});
console.log('[Main.js] Socket.IO client initialized.');
@ -110,7 +102,7 @@ document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
const authMessage = document.getElementById('auth-message');
const statusContainer = document.getElementById('status-container');
const statusContainer = document.getElementById('status-container'); // Общий контейнер для сообщений
const userInfoDiv = document.getElementById('user-info');
const loggedInUsernameSpan = document.getElementById('logged-in-username');
const logoutButton = document.getElementById('logout-button');
@ -121,18 +113,13 @@ document.addEventListener('DOMContentLoaded', () => {
const findRandomPvPGameButton = document.getElementById('find-random-pvp-game');
const gameIdInput = document.getElementById('game-id-input');
const availableGamesDiv = document.getElementById('available-games-list');
const gameStatusMessage = document.getElementById('game-status-message');
const gameStatusMessage = document.getElementById('game-status-message'); // Сообщение на экране выбора игры
const pvpCharacterRadios = document.querySelectorAll('input[name="pvp-character"]');
const gameWrapper = document.querySelector('.game-wrapper');
const returnToMenuButton = document.getElementById('return-to-menu-button');
const returnToMenuButton = document.getElementById('return-to-menu-button'); // Кнопка в gameOver модальном окне
const turnTimerContainer = document.getElementById('turn-timer-container');
const turnTimerSpan = document.getElementById('turn-timer');
console.log('[Main.js DOM Check] authSection:', !!authSection);
console.log('[Main.js DOM Check] loginForm:', !!loginForm);
console.log('[Main.js DOM Check] registerForm:', !!registerForm);
console.log('[Main.js DOM Check] logoutButton:', !!logoutButton);
// --- Функции обновления UI и состояния ---
function updateGlobalWindowVariablesForUI() {
window.gameState = clientState.currentGameState;
@ -142,7 +129,7 @@ document.addEventListener('DOMContentLoaded', () => {
playerAbilities: clientState.playerAbilitiesServer,
opponentAbilities: clientState.opponentAbilitiesServer
};
window.myPlayerId = clientState.myPlayerId;
window.myPlayerId = clientState.myPlayerId; // Роль игрока (player/opponent)
}
function resetGameVariables() {
@ -156,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => {
clientState.opponentBaseStatsServer = null;
clientState.playerAbilitiesServer = null;
clientState.opponentAbilitiesServer = null;
clientState.isActionInProgress = false; // <--- Сброс флага
updateGlobalWindowVariablesForUI();
console.log("[Main.js resetGameVariables] Game variables reset. State AFTER:", JSON.parse(JSON.stringify(clientState)));
}
@ -180,20 +168,20 @@ document.addEventListener('DOMContentLoaded', () => {
}
function showAuthScreen() {
console.log("[Main.js showAuthScreen] Showing Auth Screen. Resetting game state if not already done.");
console.log("[Main.js showAuthScreen] Showing Auth Screen. Resetting game state.");
if(authSection) authSection.style.display = 'block';
if(userInfoDiv) userInfoDiv.style.display = 'none';
if(gameSetupDiv) gameSetupDiv.style.display = 'none';
if(gameWrapper) gameWrapper.style.display = 'none';
explicitlyHideGameOverModal();
if(statusContainer) statusContainer.style.display = 'block';
if(statusContainer) statusContainer.style.display = 'block'; // Показываем общий контейнер для сообщений
clientState.isInGame = false;
resetGameVariables();
resetGameVariables(); // Включает сброс isActionInProgress
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
if(registerForm && registerForm.querySelector('button')) registerForm.querySelector('button').disabled = false;
if(loginForm && loginForm.querySelector('button')) loginForm.querySelector('button').disabled = false;
if(logoutButton) logoutButton.disabled = true;
if(logoutButton) logoutButton.disabled = true; // Кнопка выхода неактивна на экране логина
}
function showGameSelectionScreen(username) {
@ -213,34 +201,38 @@ document.addEventListener('DOMContentLoaded', () => {
socket.emit('requestPvPGameList');
} else {
console.warn("[Main.js showGameSelectionScreen] Socket not connected, cannot request PvP game list yet.");
// Можно попробовать подключить сокет, если он не подключен
// socket.connect(); // Или дождаться авто-реконнекта
}
if (availableGamesDiv) availableGamesDiv.innerHTML = '<h3>Доступные PvP игры:</h3><p>Загрузка...</p>';
if (gameIdInput) gameIdInput.value = '';
const elenaRadio = document.getElementById('char-elena');
if (elenaRadio) elenaRadio.checked = true;
if (elenaRadio) elenaRadio.checked = true; // Персонаж по умолчанию
clientState.isInGame = false;
clientState.isActionInProgress = false; // <--- Сброс флага при переходе в меню
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
enableSetupButtons();
enableSetupButtons(); // Включаем кнопки настройки игры
if (window.gameUI?.uiElements?.gameOver?.returnToMenuButton) {
window.gameUI.uiElements.gameOver.returnToMenuButton.disabled = false;
window.gameUI.uiElements.gameOver.returnToMenuButton.disabled = false; // Убедимся, что кнопка в модалке gameOver активна
}
}
function showGameScreen() {
console.log("[Main.js showGameScreen] Showing Game Screen.");
if(authSection) authSection.style.display = 'none';
if(userInfoDiv) userInfoDiv.style.display = 'block';
if(userInfoDiv) userInfoDiv.style.display = 'block'; // userInfo (имя, выход) остается видимым
if(logoutButton) logoutButton.disabled = false;
if(gameSetupDiv) gameSetupDiv.style.display = 'none';
if(gameWrapper) gameWrapper.style.display = 'flex';
setGameStatusMessage("");
if(statusContainer) statusContainer.style.display = 'none';
if(gameWrapper) gameWrapper.style.display = 'flex'; // Используем flex для game-wrapper
setGameStatusMessage(""); // Очищаем сообщение статуса игры при входе на экран игры
if(statusContainer) statusContainer.style.display = 'none'; // Скрываем общий статус-контейнер на игровом экране
clientState.isInGame = true;
// clientState.isActionInProgress остается false до первого действия игрока
updateGlobalWindowVariablesForUI();
if (turnTimerContainer) turnTimerContainer.style.display = 'block';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
if (turnTimerSpan) turnTimerSpan.textContent = '--'; // Таймер обновится по событию
}
function setAuthMessage(message, isError = false) {
@ -250,7 +242,10 @@ document.addEventListener('DOMContentLoaded', () => {
authMessage.className = isError ? 'error' : 'success';
authMessage.style.display = message ? 'block' : 'none';
}
if (message && gameStatusMessage && gameStatusMessage.style.display !== 'none') gameStatusMessage.style.display = 'none';
// Если показываем authMessage, скрываем gameStatusMessage
if (message && gameStatusMessage && gameStatusMessage.style.display !== 'none') {
gameStatusMessage.style.display = 'none';
}
}
function setGameStatusMessage(message, isError = false) {
@ -258,10 +253,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (gameStatusMessage) {
gameStatusMessage.textContent = message;
gameStatusMessage.style.display = message ? 'block' : 'none';
gameStatusMessage.style.color = isError ? 'var(--damage-color, red)' : 'var(--turn-color, yellow)';
gameStatusMessage.style.color = isError ? 'var(--damage-color, red)' : 'var(--turn-color, yellow)'; // или другой цвет для обычных сообщений
// Управляем видимостью общего контейнера статуса
if (statusContainer) statusContainer.style.display = message ? 'block' : 'none';
}
if (message && authMessage && authMessage.style.display !== 'none') authMessage.style.display = 'none';
// Если показываем gameStatusMessage, скрываем authMessage
if (message && authMessage && authMessage.style.display !== 'none') {
authMessage.style.display = 'none';
}
}
function disableSetupButtons() {
@ -276,29 +275,27 @@ document.addEventListener('DOMContentLoaded', () => {
if(createPvPGameButton) createPvPGameButton.disabled = false;
if(joinPvPGameButton) joinPvPGameButton.disabled = false;
if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = false;
// Кнопки в списке доступных игр управляются в updateAvailableGamesList
// Кнопки в списке доступных игр управляются в updateAvailableGamesList в gameSetup.js
}
// --- НОВАЯ ФУНКЦИЯ ДЛЯ ПЕРЕНАПРАВЛЕНИЯ НА ЛОГИН ---
function redirectToLogin(message) {
console.log(`[Main.js redirectToLogin] Redirecting to login. Message: "${message}"`);
clientState.isLoggedIn = false;
clientState.loggedInUsername = '';
clientState.myUserId = null;
clientState.isInGame = false; // Важно сбросить, если он пытался войти в игру
clientState.isInGame = false;
localStorage.removeItem('jwtToken');
resetGameVariables(); // Сбрасываем все игровые переменные
resetGameVariables(); // Сбрасываем все игровые переменные, включая isActionInProgress
if (socket.auth) socket.auth.token = null;
if (socket.auth) socket.auth.token = null; // Обновляем auth объект сокета
if (socket.connected) {
console.log("[Main.js redirectToLogin] Socket connected, disconnecting before showing auth screen.");
socket.disconnect(); // Отключаем текущий сокет, чтобы он не пытался переподключиться с невалидными данными
socket.disconnect();
}
showAuthScreen();
setAuthMessage(message || "Для продолжения необходимо войти или обновить сессию.", true);
}
// --- КОНЕЦ НОВОЙ ФУНКЦИИ ---
// --- Сборка зависимостей для модулей ---
console.log('[Main.js] Preparing dependencies for modules...');
@ -315,16 +312,20 @@ document.addEventListener('DOMContentLoaded', () => {
updateGlobalWindowVariablesForUI,
disableSetupButtons,
enableSetupButtons,
redirectToLogin, // <-- ДОБАВЛЕНО
redirectToLogin,
elements: {
loginForm, registerForm, logoutButton,
createAIGameButton, createPvPGameButton, joinPvPGameButton,
findRandomPvPGameButton, gameIdInput, availableGamesDiv,
pvpCharacterRadios, returnToMenuButton,
// Не передаем сюда все элементы из ui.js, так как ui.js сам их менеджит.
// Если какой-то модуль должен напрямую менять что-то из ui.js.uiElements,
// то можно передать ui.js.uiElements целиком или конкретные элементы.
}
},
utils: { // <-- ДОБАВЛЕН ОБЪЕКТ UTILS
isTokenValid // <-- ДОБАВЛЕНО
utils: {
isTokenValid,
parseJwtPayloadForValidation // На всякий случай, если понадобится где-то еще
}
};
@ -338,28 +339,29 @@ document.addEventListener('DOMContentLoaded', () => {
// --- Обработчики событий Socket.IO ---
socket.on('connect', () => {
const currentToken = localStorage.getItem('jwtToken'); // Получаем актуальный токен
socket.auth.token = currentToken; // Убедимся, что сокет использует актуальный токен для этого соединения
// (хотя handshake.auth устанавливается при io(), это для ясности)
const currentToken = localStorage.getItem('jwtToken');
if (socket.auth) socket.auth.token = currentToken; // Убедимся, что auth объект сокета обновлен
else socket.auth = { token: currentToken }; // Если auth объекта не было
console.log('[Main.js Socket.IO] Event: connect. Socket ID:', socket.id, 'Auth token associated with this connection attempt:', !!currentToken);
if (clientState.isLoggedIn && clientState.myUserId && isTokenValid(currentToken)) { // Дополнительная проверка токена
if (clientState.isLoggedIn && clientState.myUserId && isTokenValid(currentToken)) {
console.log(`[Main.js Socket.IO] Client state indicates logged in as ${clientState.loggedInUsername} (ID: ${clientState.myUserId}) and token is valid. Requesting game state.`);
if (!clientState.isInGame && (authSection.style.display === 'block' || gameSetupDiv.style.display === 'block')) {
// Если мы на экране выбора игры, показываем сообщение о восстановлении
if (!clientState.isInGame && (gameSetupDiv.style.display === 'block' || authSection.style.display === 'block')) {
setGameStatusMessage("Восстановление игровой сессии...");
}
socket.emit('requestGameState');
} else {
// Если clientState говорит, что залогинен, но токен невалиден, или если не залогинен
if (clientState.isLoggedIn && !isTokenValid(currentToken)) {
console.warn('[Main.js Socket.IO connect] Client state says logged in, but token is invalid/expired. Redirecting to login.');
redirectToLogin("Ваша сессия истекла. Пожалуйста, войдите снова.");
} else {
console.log('[Main.js Socket.IO connect] Client state indicates NOT logged in or no valid token. Showing auth screen if not already visible.');
if (authSection.style.display !== 'block') {
showAuthScreen();
showAuthScreen(); // Показываем экран логина, если еще не на нем
}
setAuthMessage("Пожалуйста, войдите или зарегистрируйтесь.");
setAuthMessage("Пожалуйста, войдите или зарегистрируйтесь."); // Сообщение по умолчанию для экрана логина
}
}
});
@ -378,12 +380,12 @@ document.addEventListener('DOMContentLoaded', () => {
let currentScreenMessageFunc = setAuthMessage;
if (clientState.isLoggedIn && clientState.isInGame) {
currentScreenMessageFunc = setGameStatusMessage;
} else if (clientState.isLoggedIn) {
} else if (clientState.isLoggedIn) { // Если залогинен, но не в игре (на экране выбора)
currentScreenMessageFunc = setGameStatusMessage;
}
currentScreenMessageFunc(`Ошибка подключения: ${err.message}. Попытка переподключения...`, true);
if (authSection.style.display !== 'block' && !clientState.isLoggedIn) {
// Если не залогинены и не на экране логина, показать экран логина
// Если не залогинены и не на экране авторизации, показываем его
if (!clientState.isLoggedIn && authSection.style.display !== 'block') {
showAuthScreen();
}
}
@ -392,30 +394,32 @@ document.addEventListener('DOMContentLoaded', () => {
socket.on('disconnect', (reason) => {
console.warn('[Main.js Socket.IO] Event: disconnect. Reason:', reason);
let messageFunc = setAuthMessage;
if (clientState.isInGame) { // Если были в игре, сообщение на игровом статусе
let messageFunc = setAuthMessage; // По умолчанию сообщение для экрана авторизации
if (clientState.isInGame) {
messageFunc = setGameStatusMessage;
} else if (clientState.isLoggedIn && gameSetupDiv.style.display === 'block') { // Если были на экране выбора игры
} else if (clientState.isLoggedIn && gameSetupDiv.style.display === 'block') {
messageFunc = setGameStatusMessage;
}
if (reason === 'io server disconnect') { // Сервер принудительно отключил
if (reason === 'io server disconnect') {
messageFunc("Соединение разорвано сервером. Пожалуйста, попробуйте войти снова.", true);
// Можно сразу перенаправить на логин, если это означает проблему с сессией
redirectToLogin("Соединение разорвано сервером. Пожалуйста, войдите снова.");
} else if (reason !== 'io client disconnect') { // Если это не преднамеренный дисконнект клиента (например, при logout)
} else if (reason === 'io client disconnect') {
// Это преднамеренный дисконнект (например, при logout или смене токена).
// Сообщение уже должно быть установлено функцией, вызвавшей дисконнект.
// Ничего не делаем здесь, чтобы не перезаписать его.
console.log('[Main.js Socket.IO] Disconnect was intentional (io client disconnect). No additional message needed.');
} else { // Другие причины (например, проблемы с сетью)
messageFunc(`Потеряно соединение: ${reason}. Попытка переподключения...`, true);
}
if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.';
// Не вызываем redirectToLogin здесь автоматически при каждом дисконнекте,
// так как Socket.IO будет пытаться переподключиться.
// redirectToLogin будет вызван из connect_error или connect, если токен окажется невалидным.
clientState.isActionInProgress = false; // На всякий случай сбрасываем флаг при дисконнекте
});
socket.on('gameError', (data) => {
console.error('[Main.js Socket.IO] Event: gameError. Message:', data.message, 'Data:', JSON.stringify(data));
clientState.isActionInProgress = false; // Сбрасываем флаг при ошибке сервера
// Проверка на специфичные ошибки, требующие перелогина
if (data.message && (data.message.toLowerCase().includes("сессия истекла") || data.message.toLowerCase().includes("необходимо войти"))) {
redirectToLogin(data.message);
return;
@ -423,9 +427,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (clientState.isInGame && window.gameUI?.addToLog) {
window.gameUI.addToLog(`❌ Ошибка сервера: ${data.message}`, 'system');
// Если ошибка произошла в игре, но игра не закончилась, кнопки могут остаться заблокированными.
// Возможно, стоит проверить, чей ход, и разблокировать, если ход игрока и игра не окончена.
// Но это зависит от типа ошибки. Сейчас просто логируем.
} else if (clientState.isLoggedIn) {
setGameStatusMessage(`❌ Ошибка: ${data.message}`, true);
enableSetupButtons(); // Разблокируем кнопки, если произошла ошибка на экране выбора игры
enableSetupButtons(); // Разблокируем кнопки, если ошибка на экране выбора игры
} else {
setAuthMessage(`❌ Ошибка: ${data.message}`, true);
if(registerForm && registerForm.querySelector('button')) registerForm.querySelector('button').disabled = false;
@ -436,17 +443,17 @@ document.addEventListener('DOMContentLoaded', () => {
socket.on('gameNotFound', (data) => {
console.log('[Main.js Socket.IO] Event: gameNotFound. Message:', data?.message, 'Data:', JSON.stringify(data));
clientState.isInGame = false;
resetGameVariables();
resetGameVariables(); // Включает сброс isActionInProgress
explicitlyHideGameOverModal();
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
if (clientState.isLoggedIn && isTokenValid(localStorage.getItem('jwtToken'))) { // Проверяем, что токен еще валиден
if (gameSetupDiv.style.display !== 'block') {
if (clientState.isLoggedIn && isTokenValid(localStorage.getItem('jwtToken'))) {
if (gameSetupDiv.style.display !== 'block') { // Если мы не на экране выбора игры, показываем его
showGameSelectionScreen(clientState.loggedInUsername);
}
setGameStatusMessage(data?.message || "Активная игровая сессия не найдена. Выберите новую игру.");
} else { // Если не залогинен или токен истек
} else {
redirectToLogin(data?.message || "Пожалуйста, войдите для продолжения.");
}
});
@ -457,11 +464,12 @@ document.addEventListener('DOMContentLoaded', () => {
if(gameSetupDiv) gameSetupDiv.style.display = 'none';
if(gameWrapper) gameWrapper.style.display = 'none';
if(userInfoDiv) userInfoDiv.style.display = 'none';
if(statusContainer) statusContainer.style.display = 'block';
if(statusContainer) statusContainer.style.display = 'block'; // Показываем общий контейнер для сообщений
if (clientState.isLoggedIn) { // isLoggedIn уже учитывает валидность токена при начальной загрузке
if (clientState.isLoggedIn) {
console.log('[Main.js] Client is considered logged in. Will attempt session recovery on socket connect.');
setGameStatusMessage("Подключение и восстановление сессии...");
// Не показываем экран выбора игры сразу, дожидаемся 'connect' и 'requestGameState'
setAuthMessage("Подключение и восстановление сессии..."); // Используем authMessage для начального сообщения
} else {
console.log('[Main.js] Client is NOT considered logged in. Showing auth screen.');
showAuthScreen();
@ -469,6 +477,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
console.log('[Main.js] Attempting to connect socket...');
socket.connect();
socket.connect(); // Подключаемся здесь, после всей инициализации
console.log('[Main.js] socket.connect() called.');
});

View File

@ -10,7 +10,7 @@ class GameManager {
this.games = {}; // { gameId: GameInstance }
this.userIdentifierToGameId = {}; // { userId: gameId }
this.pendingPvPGames = []; // Массив gameId ожидающих PvP игр
console.log("[GameManager] Initialized.");
console.log("[GameManager] Инициализирован.");
}
_cleanupPreviousPendingGameForUser(identifier, reasonSuffix = 'unknown_cleanup_reason') {
@ -24,89 +24,71 @@ class GameManager {
this.pendingPvPGames.includes(oldPendingGameId) && // Игра в списке ожидающих
(!gameToRemove.gameState || !gameToRemove.gameState.isGameOver) // И она не завершена
) {
console.log(`[GameManager._cleanupPreviousPendingGameForUser] User ${identifier} has an existing pending PvP game ${oldPendingGameId}. Removing it. Reason: ${reasonSuffix}`);
console.log(`[GameManager._cleanupPreviousPendingGameForUser] Пользователь ${identifier} имеет существующую ожидающую PvP игру ${oldPendingGameId}. Удаление. Причина: ${reasonSuffix}`);
this._cleanupGame(oldPendingGameId, `owner_action_removed_pending_pvp_game_${reasonSuffix}`);
// _cleanupGame должен удалить запись из userIdentifierToGameId
return true; // Успешно очистили
return true;
}
}
return false; // Нечего было очищать или условия не совпали
return false;
}
createGame(socket, mode = 'ai', chosenCharacterKey = null, identifier) {
console.log(`[GameManager.createGame] User: ${identifier} (Socket: ${socket.id}), Mode: ${mode}, Char: ${chosenCharacterKey || 'Default'}`);
console.log(`[GameManager.createGame] Пользователь: ${identifier} (Socket: ${socket.id}), Режим: ${mode}, Персонаж: ${chosenCharacterKey || 'По умолчанию'}`);
const existingGameIdForUser = this.userIdentifierToGameId[identifier];
// 1. Проверить, не находится ли пользователь уже в какой-либо АКТИВНОЙ игре.
if (existingGameIdForUser && this.games[existingGameIdForUser]) {
const existingGame = this.games[existingGameIdForUser];
if (existingGame.gameState && existingGame.gameState.isGameOver) {
console.warn(`[GameManager.createGame] User ${identifier} was in a finished game ${existingGameIdForUser}. Cleaning it up before creating new.`);
console.warn(`[GameManager.createGame] Пользователь ${identifier} был в завершенной игре ${existingGameIdForUser}. Очистка перед созданием новой.`);
this._cleanupGame(existingGameIdForUser, `stale_finished_on_create_${identifier}`);
// После _cleanupGame, existingGameIdForUser в userIdentifierToGameId[identifier] должен быть удален
} else {
// Пользователь в активной игре.
// Если это ЕГО ОЖИДАЮЩАЯ PvP игра, и он пытается создать НОВУЮ (любую), то ее нужно будет удалить ниже.
// Если это ДРУГАЯ активная игра (не его ожидающая PvP), то отказать.
const isHisOwnPendingPvp = existingGame.mode === 'pvp' &&
existingGame.ownerIdentifier === identifier &&
existingGame.playerCount === 1 &&
this.pendingPvPGames.includes(existingGameIdForUser);
if (!isHisOwnPendingPvp) {
// Он в другой активной игре (AI, или PvP с оппонентом, или PvP другого игрока)
console.warn(`[GameManager.createGame] User ${identifier} is already in an active game ${existingGameIdForUser} (mode: ${existingGame.mode}, owner: ${existingGame.ownerIdentifier}). Cannot create new.`);
console.warn(`[GameManager.createGame] Пользователь ${identifier} уже в активной игре ${existingGameIdForUser} (режим: ${existingGame.mode}, владелец: ${existingGame.ownerIdentifier}). Невозможно создать новую.`);
socket.emit('gameError', { message: 'Вы уже находитесь в активной игре.' });
this.handleRequestGameState(socket, identifier); // Попытаться вернуть в ту игру
this.handleRequestGameState(socket, identifier);
return;
}
// Если это его ожидающая PvP, то _cleanupPreviousPendingGameForUser ниже ее удалит.
}
}
// 2. Удалить предыдущую ОЖИДАЮЩУЮ PvP игру этого пользователя, если он создает новую любую игру.
// Это важно сделать ДО создания новой игры, чтобы освободить userIdentifierToGameId.
const cleanedUp = this._cleanupPreviousPendingGameForUser(identifier, `creating_new_game_mode_${mode}`);
if (cleanedUp) {
console.log(`[GameManager.createGame] Successfully cleaned up previous pending PvP game for ${identifier}.`);
} else {
console.log(`[GameManager.createGame] No previous pending PvP game found or needed cleanup for ${identifier}.`);
}
console.log(`[GameManager.createGame] After potential cleanup, user ${identifier} mapping: ${this.userIdentifierToGameId[identifier]}`);
this._cleanupPreviousPendingGameForUser(identifier, `creating_new_game_mode_${mode}`);
console.log(`[GameManager.createGame] После возможной очистки, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`);
// 3. Окончательная проверка: если ПОСЛЕ очистки пользователь все еще привязан к какой-то активной игре
// (Это может случиться, если _cleanupPreviousPendingGameForUser не нашла ожидающую, но он был в другой игре, что было бы ошибкой логики выше)
const stillExistingGameIdAfterCleanup = this.userIdentifierToGameId[identifier];
if (stillExistingGameIdAfterCleanup && this.games[stillExistingGameIdAfterCleanup] && !this.games[stillExistingGameIdAfterCleanup].gameState?.isGameOver) {
console.error(`[GameManager.createGame] CRITICAL LOGIC ERROR: User ${identifier} still mapped to active game ${stillExistingGameIdAfterCleanup} after cleanup attempt. Denying creation.`);
console.error(`[GameManager.createGame] КРИТИЧЕСКАЯ ОШИБКА ЛОГИКИ: Пользователь ${identifier} все еще сопоставлен с активной игрой ${stillExistingGameIdAfterCleanup} после попытки очистки. Создание отклонено.`);
socket.emit('gameError', { message: 'Ошибка: не удалось освободить предыдущую игровую сессию.' });
this.handleRequestGameState(socket, identifier);
return;
}
const gameId = uuidv4();
console.log(`[GameManager.createGame] New GameID: ${gameId}`);
console.log(`[GameManager.createGame] Новый GameID: ${gameId}`);
const game = new GameInstance(gameId, this.io, mode, this);
this.games[gameId] = game;
const charKeyForPlayer = mode === 'ai' ? (chosenCharacterKey || 'elena') : (chosenCharacterKey || 'elena');
if (game.addPlayer(socket, charKeyForPlayer, identifier)) {
this.userIdentifierToGameId[identifier] = gameId; // Связываем пользователя с НОВОЙ игрой
this.userIdentifierToGameId[identifier] = gameId;
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
const assignedPlayerId = playerInfo?.id;
const actualCharacterKey = playerInfo?.chosenCharacterKey;
if (!assignedPlayerId || !actualCharacterKey) {
console.error(`[GameManager.createGame] CRITICAL: Failed to get player role/charKey after addPlayer for ${identifier} in game ${gameId}. Cleaning up.`);
console.error(`[GameManager.createGame] КРИТИЧЕСКИ: Не удалось получить роль/ключ персонажа после addPlayer для ${identifier} в игре ${gameId}. Очистка.`);
this._cleanupGame(gameId, 'player_info_missing_after_add_on_create');
socket.emit('gameError', { message: 'Ошибка сервера при создании роли в игре.' });
return;
}
console.log(`[GameManager.createGame] Player ${identifier} added to game ${gameId} as ${assignedPlayerId}. User map updated. Current map for ${identifier}: ${this.userIdentifierToGameId[identifier]}`);
console.log(`[GameManager.createGame] Игрок ${identifier} добавлен в игру ${gameId} как ${assignedPlayerId}. Карта пользователя обновлена. Текущая карта для ${identifier}: ${this.userIdentifierToGameId[identifier]}`);
socket.emit('gameCreated', {
gameId: gameId,
mode: mode,
@ -116,33 +98,32 @@ class GameManager {
if (mode === 'ai') {
if (game.initializeGame()) {
console.log(`[GameManager.createGame] AI game ${gameId} initialized by GameManager, starting...`);
console.log(`[GameManager.createGame] AI игра ${gameId} инициализирована GameManager, запуск...`);
game.startGame();
} else {
console.error(`[GameManager.createGame] AI game ${gameId} init failed in GameManager. Cleaning up.`);
console.error(`[GameManager.createGame] Инициализация AI игры ${gameId} не удалась в GameManager. Очистка.`);
this._cleanupGame(gameId, 'init_fail_ai_create_gm');
}
} else if (mode === 'pvp') {
if (game.initializeGame()) { // Для PvP инициализируем даже с одним игроком
if (game.initializeGame()) {
if (!this.pendingPvPGames.includes(gameId)) {
this.pendingPvPGames.push(gameId);
}
socket.emit('waitingForOpponent');
this.broadcastAvailablePvPGames();
} else {
console.error(`[GameManager.createGame] PvP game ${gameId} (single player) init failed. Cleaning up.`);
console.error(`[GameManager.createGame] Инициализация PvP игры ${gameId} (один игрок) не удалась. Очистка.`);
this._cleanupGame(gameId, 'init_fail_pvp_create_gm_single_player');
}
}
} else {
console.error(`[GameManager.createGame] game.addPlayer failed for ${identifier} in ${gameId}. Cleaning up.`);
console.error(`[GameManager.createGame] game.addPlayer не удалось для ${identifier} в ${gameId}. Очистка.`);
this._cleanupGame(gameId, 'player_add_failed_in_instance_gm_on_create');
// game.addPlayer должен был сам отправить ошибку клиенту
}
}
joinGame(socket, gameIdToJoin, identifier, chosenCharacterKey = null) {
console.log(`[GameManager.joinGame] User: ${identifier} (Socket: ${socket.id}) attempts to join ${gameIdToJoin} with char ${chosenCharacterKey || 'Default'}`);
console.log(`[GameManager.joinGame] Пользователь: ${identifier} (Socket: ${socket.id}) пытается присоединиться к ${gameIdToJoin} с персонажем ${chosenCharacterKey || 'По умолчанию'}`);
const gameToJoin = this.games[gameIdToJoin];
if (!gameToJoin) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; }
@ -153,22 +134,20 @@ class GameManager {
if (gameToJoin.playerCount >= 2 && !playerInfoInTargetGame?.isTemporarilyDisconnected) {
socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return;
}
// Запрещаем владельцу "присоединяться" к своей ожидающей игре как новый игрок, если он не был временно отключен.
// Если он хочет вернуться, он должен использовать requestGameState.
if (gameToJoin.ownerIdentifier === identifier && !playerInfoInTargetGame?.isTemporarilyDisconnected) {
console.warn(`[GameManager.joinGame] User ${identifier} trying to join their own game ${gameIdToJoin} where they are owner and not disconnected. Treating as reconnect request.`);
console.warn(`[GameManager.joinGame] Пользователь ${identifier} пытается присоединиться к своей игре ${gameIdToJoin}, где он владелец и не отключен. Обработка как запрос на переподключение.`);
this.handleRequestGameState(socket, identifier);
return;
}
// 1. Очистка завершенной игры пользователя, если такая есть
const currentActiveGameIdUserIsIn = this.userIdentifierToGameId[identifier];
if (currentActiveGameIdUserIsIn && this.games[currentActiveGameIdUserIsIn] && this.games[currentActiveGameIdUserIsIn].gameState?.isGameOver) {
console.warn(`[GameManager.joinGame] User ${identifier} was in a finished game ${currentActiveGameIdUserIsIn} while trying to join ${gameIdToJoin}. Cleaning old one.`);
console.warn(`[GameManager.joinGame] Пользователь ${identifier} был в завершенной игре ${currentActiveGameIdUserIsIn} при попытке присоединиться к ${gameIdToJoin}. Очистка старой.`);
this._cleanupGame(currentActiveGameIdUserIsIn, `stale_finished_on_join_attempt_${identifier}`);
}
// 2. Если пользователь УЖЕ ПРИВЯЗАН к какой-то ДРУГОЙ АКТИВНОЙ игре (не той, к которой пытается присоединиться),
// и это НЕ его собственная ожидающая PvP игра, то отказать.
// Если это ЕГО ОЖИДАЮЩАЯ PvP игра, то ее нужно удалить.
const stillExistingGameIdForUser = this.userIdentifierToGameId[identifier];
if (stillExistingGameIdForUser && stillExistingGameIdForUser !== gameIdToJoin && this.games[stillExistingGameIdForUser] && !this.games[stillExistingGameIdForUser].gameState?.isGameOver) {
const usersCurrentGame = this.games[stillExistingGameIdForUser];
@ -178,30 +157,29 @@ class GameManager {
this.pendingPvPGames.includes(stillExistingGameIdForUser);
if (isHisOwnPendingPvp) {
console.log(`[GameManager.joinGame] User ${identifier} is owner of pending game ${stillExistingGameIdForUser}, but wants to join ${gameIdToJoin}. Cleaning up old game.`);
console.log(`[GameManager.joinGame] Пользователь ${identifier} является владельцем ожидающей игры ${stillExistingGameIdForUser}, но хочет присоединиться к ${gameIdToJoin}. Очистка старой игры.`);
this._cleanupPreviousPendingGameForUser(identifier, `joining_another_game_${gameIdToJoin}`);
} else {
// Пользователь в другой активной игре (не своей ожидающей)
console.warn(`[GameManager.joinGame] User ${identifier} is in another active game ${stillExistingGameIdForUser}. Cannot join ${gameIdToJoin}.`);
console.warn(`[GameManager.joinGame] Пользователь ${identifier} находится в другой активной игре ${stillExistingGameIdForUser}. Невозможно присоединиться к ${gameIdToJoin}.`);
socket.emit('gameError', { message: 'Вы уже находитесь в другой активной игре.' });
this.handleRequestGameState(socket, identifier); // Попытаться вернуть в ту игру
this.handleRequestGameState(socket, identifier);
return;
}
}
console.log(`[GameManager.joinGame] After potential cleanup before join, user ${identifier} mapping: ${this.userIdentifierToGameId[identifier]}`);
console.log(`[GameManager.joinGame] После возможной очистки перед присоединением, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`);
const charKeyForJoin = chosenCharacterKey || 'elena';
if (gameToJoin.addPlayer(socket, charKeyForJoin, identifier)) {
this.userIdentifierToGameId[identifier] = gameIdToJoin; // Связываем пользователя с игрой, к которой он присоединился
this.userIdentifierToGameId[identifier] = gameIdToJoin;
const joinedPlayerInfo = Object.values(gameToJoin.players).find(p => p.identifier === identifier);
if (!joinedPlayerInfo || !joinedPlayerInfo.id || !joinedPlayerInfo.chosenCharacterKey) {
console.error(`[GameManager.joinGame] CRITICAL: Failed to get player role/charKey after addPlayer for ${identifier} joining ${gameIdToJoin}.`);
console.error(`[GameManager.joinGame] КРИТИЧЕСКИ: Не удалось получить роль/ключ персонажа после addPlayer для ${identifier}, присоединяющегося к ${gameIdToJoin}.`);
socket.emit('gameError', { message: 'Ошибка сервера при назначении роли в игре.' });
if (this.userIdentifierToGameId[identifier] === gameIdToJoin) delete this.userIdentifierToGameId[identifier];
return;
}
console.log(`[GameManager.joinGame] Player ${identifier} added/reconnected to ${gameIdToJoin} as ${joinedPlayerInfo.id}. User map updated. Current map for ${identifier}: ${this.userIdentifierToGameId[identifier]}`);
console.log(`[GameManager.joinGame] Игрок ${identifier} добавлен/переподключен к ${gameIdToJoin} как ${joinedPlayerInfo.id}. Карта пользователя обновлена. Текущая карта для ${identifier}: ${this.userIdentifierToGameId[identifier]}`);
socket.emit('gameCreated', {
gameId: gameIdToJoin,
mode: gameToJoin.mode,
@ -210,7 +188,8 @@ class GameManager {
});
if (gameToJoin.playerCount === 2) {
console.log(`[GameManager.joinGame] Game ${gameIdToJoin} is now full. Initializing and starting.`);
console.log(`[GameManager.joinGame] Игра ${gameIdToJoin} теперь заполнена. Инициализация и запуск.`);
// Важно! Инициализация может обновить ключи персонажей, если они были одинаковыми.
if (gameToJoin.initializeGame()) {
gameToJoin.startGame();
} else {
@ -221,60 +200,57 @@ class GameManager {
this.broadcastAvailablePvPGames();
}
} else {
console.warn(`[GameManager.joinGame] gameToJoin.addPlayer returned false for user ${identifier} in game ${gameIdToJoin}.`);
// GameInstance должен был отправить причину
console.warn(`[GameManager.joinGame] gameToJoin.addPlayer вернул false для пользователя ${identifier} в игре ${gameIdToJoin}.`);
}
}
findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) {
console.log(`[GameManager.findRandomPvPGame] User: ${identifier} (Socket: ${socket.id}), CharForCreation: ${chosenCharacterKeyForCreation}`);
console.log(`[GameManager.findRandomPvPGame] Пользователь: ${identifier} (Socket: ${socket.id}), Персонаж для создания: ${chosenCharacterKeyForCreation}`);
const existingGameIdForUser = this.userIdentifierToGameId[identifier];
if (existingGameIdForUser && this.games[existingGameIdForUser]) {
const existingGame = this.games[existingGameIdForUser];
if (existingGame.gameState && existingGame.gameState.isGameOver) {
console.warn(`[GameManager.findRandomPvPGame] User ${identifier} was in a finished game ${existingGameIdForUser}. Cleaning it up.`);
console.warn(`[GameManager.findRandomPvPGame] Пользователь ${identifier} был в завершенной игре ${existingGameIdForUser}. Очистка.`);
this._cleanupGame(existingGameIdForUser, `stale_finished_on_find_random_${identifier}`);
} else {
console.warn(`[GameManager.findRandomPvPGame] User ${identifier} is already in an active/pending game ${existingGameIdForUser}. Cannot find random.`);
console.warn(`[GameManager.findRandomPvPGame] Пользователь ${identifier} уже в активной/ожидающей игре ${existingGameIdForUser}. Невозможно найти случайную.`);
socket.emit('gameError', { message: 'Вы уже в активной или ожидающей игре.' });
this.handleRequestGameState(socket, identifier); return;
}
}
// Удалить предыдущую ОЖИДАЮЩУЮ PvP игру этого пользователя, если он ищет новую.
this._cleanupPreviousPendingGameForUser(identifier, `finding_random_game`);
console.log(`[GameManager.findRandomPvPGame] After potential cleanup, user ${identifier} mapping: ${this.userIdentifierToGameId[identifier]}`);
console.log(`[GameManager.findRandomPvPGame] После возможной очистки, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`);
// Если после очистки пользователь все еще привязан к какой-то *другой* активной игре
const stillExistingGameIdAfterCleanup = this.userIdentifierToGameId[identifier];
if (stillExistingGameIdAfterCleanup && this.games[stillExistingGameIdAfterCleanup] && !this.games[stillExistingGameIdAfterCleanup].gameState?.isGameOver) {
console.error(`[GameManager.findRandomPvPGame] CRITICAL LOGIC ERROR: User ${identifier} still mapped to active game ${stillExistingGameIdAfterCleanup} after cleanup attempt. Denying find random.`);
console.error(`[GameManager.findRandomPvPGame] КРИТИЧЕСКАЯ ОШИБКА ЛОГИКИ: Пользователь ${identifier} все еще сопоставлен с активной игрой ${stillExistingGameIdAfterCleanup} после попытки очистки. Поиск случайной игры отклонен.`);
socket.emit('gameError', { message: 'Ошибка: не удалось освободить предыдущую игровую сессию для поиска.' });
this.handleRequestGameState(socket, identifier);
return;
}
let gameIdToJoin = null;
for (const id of [...this.pendingPvPGames]) { // Итерируем копию
for (const id of [...this.pendingPvPGames]) {
const pendingGame = this.games[id];
if (pendingGame && pendingGame.mode === 'pvp' &&
pendingGame.playerCount === 1 &&
pendingGame.ownerIdentifier !== identifier &&
(!pendingGame.gameState || !pendingGame.gameState.isGameOver)) {
gameIdToJoin = id; break;
} else if (!pendingGame || (pendingGame.gameState && pendingGame.gameState.isGameOver)) {
console.warn(`[GameManager.findRandomPvPGame] Found stale/finished pending game ${id}. Cleaning up.`);
} else if (!pendingGame || (pendingGame?.gameState && pendingGame.gameState.isGameOver)) {
console.warn(`[GameManager.findRandomPvPGame] Найдена устаревшая/завершенная ожидающая игра ${id}. Очистка.`);
this._cleanupGame(id, `stale_finished_pending_on_find_random`);
}
}
if (gameIdToJoin) {
console.log(`[GameManager.findRandomPvPGame] Found pending game ${gameIdToJoin} for ${identifier}. Joining...`);
console.log(`[GameManager.findRandomPvPGame] Найдена ожидающая игра ${gameIdToJoin} для ${identifier}. Присоединение...`);
const randomJoinCharKey = ['elena', 'almagest', 'balard'][Math.floor(Math.random() * 3)];
this.joinGame(socket, gameIdToJoin, identifier, randomJoinCharKey);
} else {
console.log(`[GameManager.findRandomPvPGame] No suitable pending game. Creating new PvP game for ${identifier}.`);
console.log(`[GameManager.findRandomPvPGame] Подходящая ожидающая игра не найдена. Создание новой PvP игры для ${identifier}.`);
this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier);
}
}
@ -286,17 +262,17 @@ class GameManager {
if (game.gameState?.isGameOver) {
const playerSocket = Object.values(game.players).find(p => p.identifier === identifier)?.socket;
if (playerSocket) {
console.warn(`[GameManager.handlePlayerAction] Action from ${identifier} for game ${gameId}, but game is over. Requesting state.`);
console.warn(`[GameManager.handlePlayerAction] Действие от ${identifier} для игры ${gameId}, но игра завершена. Запрос состояния.`);
this.handleRequestGameState(playerSocket, identifier);
} else {
console.warn(`[GameManager.handlePlayerAction] Action from ${identifier} for game ${gameId}, game over, but no socket found for user.`);
console.warn(`[GameManager.handlePlayerAction] Действие от ${identifier} для игры ${gameId}, игра завершена, но сокет для пользователя не найден.`);
this._cleanupGame(gameId, `action_on_over_no_socket_gm_${identifier}`);
}
return;
}
game.processPlayerAction(identifier, actionData);
} else {
console.warn(`[GameManager.handlePlayerAction] No game found for user ${identifier} (mapped to game ${gameId}). Clearing map entry.`);
console.warn(`[GameManager.handlePlayerAction] Игра для пользователя ${identifier} не найдена (сопоставлена с игрой ${gameId}). Очистка записи в карте.`);
delete this.userIdentifierToGameId[identifier];
const clientSocket = this._findClientSocketByIdentifier(identifier);
if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена при совершении действия.' });
@ -305,53 +281,52 @@ class GameManager {
handlePlayerSurrender(identifier) {
const gameId = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handlePlayerSurrender] User: ${identifier} surrendered. GameID from map: ${gameId}`);
console.log(`[GameManager.handlePlayerSurrender] Пользователь: ${identifier} сдался. GameID из карты: ${gameId}`);
const game = this.games[gameId];
if (game) {
if (game.gameState?.isGameOver) {
console.warn(`[GameManager.handlePlayerSurrender] User ${identifier} in game ${gameId} surrender, but game ALREADY OVER.`);
// Не удаляем из userIdentifierToGameId здесь, _cleanupGame сделает это.
console.warn(`[GameManager.handlePlayerSurrender] Пользователь ${identifier} в игре ${gameId} сдается, но игра УЖЕ ЗАВЕРШЕНА.`);
return;
}
if (typeof game.playerDidSurrender === 'function') game.playerDidSurrender(identifier);
else { console.error(`[GameManager.handlePlayerSurrender] CRITICAL: GameInstance ${gameId} missing playerDidSurrender!`); this._cleanupGame(gameId, "surrender_missing_method_gm"); }
else { console.error(`[GameManager.handlePlayerSurrender] КРИТИЧЕСКИ: GameInstance ${gameId} отсутствует playerDidSurrender!`); this._cleanupGame(gameId, "surrender_missing_method_gm"); }
} else {
console.warn(`[GameManager.handlePlayerSurrender] No game found for user ${identifier}. Clearing map entry.`);
console.warn(`[GameManager.handlePlayerSurrender] Игра для пользователя ${identifier} не найдена. Очистка записи в карте.`);
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
}
}
handleLeaveAiGame(identifier) {
const gameId = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handleLeaveAiGame] User: ${identifier} leaving AI game. GameID from map: ${gameId}`);
console.log(`[GameManager.handleLeaveAiGame] Пользователь: ${identifier} покидает AI игру. GameID из карты: ${gameId}`);
const game = this.games[gameId];
if (game) {
if (game.gameState?.isGameOver) {
console.warn(`[GameManager.handleLeaveAiGame] User ${identifier} game ${gameId} leaving, but game ALREADY OVER.`);
console.warn(`[GameManager.handleLeaveAiGame] Пользователь ${identifier} в игре ${gameId} выходит, но игра УЖЕ ЗАВЕРШЕНА.`);
return;
}
if (game.mode === 'ai') {
if (typeof game.playerExplicitlyLeftAiGame === 'function') {
game.playerExplicitlyLeftAiGame(identifier);
} else {
console.error(`[GameManager.handleLeaveAiGame] CRITICAL: GameInstance ${gameId} missing playerExplicitlyLeftAiGame! Cleaning up directly.`);
console.error(`[GameManager.handleLeaveAiGame] КРИТИЧЕСКИ: GameInstance ${gameId} отсутствует playerExplicitlyLeftAiGame! Прямая очистка.`);
this._cleanupGame(gameId, "leave_ai_missing_method_gm");
}
} else {
console.warn(`[GameManager.handleLeaveAiGame] User ${identifier} sent leaveAiGame, but game ${gameId} is not AI mode (${game.mode}).`);
socket.emit('gameError', { message: 'Вы не в AI игре.' }); // Сообщить клиенту об ошибке
console.warn(`[GameManager.handleLeaveAiGame] Пользователь ${identifier} отправил leaveAiGame, но игра ${gameId} не в режиме AI (${game.mode}).`);
const clientSocket = this._findClientSocketByIdentifier(identifier);
if(clientSocket) clientSocket.emit('gameError', { message: 'Вы не в AI игре.' });
}
} else {
console.warn(`[GameManager.handleLeaveAiGame] No game found for user ${identifier}. Clearing map entry.`);
console.warn(`[GameManager.handleLeaveAiGame] Игра для пользователя ${identifier} не найдена. Очистка записи в карте.`);
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
// Сообщить клиенту, что игра не найдена
const clientSocket = this._findClientSocketByIdentifier(identifier);
if(clientSocket) clientSocket.emit('gameNotFound', { message: 'AI игра не найдена для выхода.' });
}
}
_findClientSocketByIdentifier(identifier) {
for (const s of this.io.sockets.sockets.values()) { // Использование .values() для итератора
for (const s of this.io.sockets.sockets.values()) {
if (s && s.userData && s.userData.userId === identifier && s.connected) return s;
}
return null;
@ -359,103 +334,99 @@ class GameManager {
handleDisconnect(socketId, identifier) {
const gameIdFromMap = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handleDisconnect] Socket: ${socketId}, User: ${identifier}, GameID from map: ${gameIdFromMap}`);
console.log(`[GameManager.handleDisconnect] Socket: ${socketId}, Пользователь: ${identifier}, GameID из карты: ${gameIdFromMap}`);
const game = gameIdFromMap ? this.games[gameIdFromMap] : null;
if (game) {
if (game.gameState?.isGameOver) {
console.log(`[GameManager.handleDisconnect] Game ${gameIdFromMap} for user ${identifier} (socket ${socketId}) ALREADY OVER. Game will be cleaned up by its own logic or next relevant action.`);
console.log(`[GameManager.handleDisconnect] Игра ${gameIdFromMap} для пользователя ${identifier} (сокет ${socketId}) УЖЕ ЗАВЕРШЕНА. Игра будет очищена своей собственной логикой или следующим релевантным действием.`);
return;
}
const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier);
if (playerInfoInGame && playerInfoInGame.socket?.id === socketId && !playerInfoInGame.isTemporarilyDisconnected) {
console.log(`[GameManager.handleDisconnect] Disconnecting socket ${socketId} matches active player ${identifier} (Role: ${playerInfoInGame.id}) in game ${gameIdFromMap}. Notifying GameInstance.`);
if (playerInfoInGame) { // Игрок существует в этой игре
console.log(`[GameManager.handleDisconnect] Отключающийся сокет ${socketId} для пользователя ${identifier} (Роль: ${playerInfoInGame.id}) в игре ${gameIdFromMap}. Уведомление GameInstance.`);
if (typeof game.handlePlayerPotentiallyLeft === 'function') {
game.handlePlayerPotentiallyLeft(playerInfoInGame.id, identifier, playerInfoInGame.chosenCharacterKey);
// Передаем фактический socketId, который отключился. PCH определит, устарел ли он.
game.handlePlayerPotentiallyLeft(playerInfoInGame.id, identifier, playerInfoInGame.chosenCharacterKey, socketId);
} else {
console.error(`[GameManager.handleDisconnect] CRITICAL: GameInstance ${gameIdFromMap} missing handlePlayerPotentiallyLeft!`);
console.error(`[GameManager.handleDisconnect] КРИТИЧЕСКИ: GameInstance ${gameIdFromMap} отсутствует handlePlayerPotentiallyLeft!`);
this._cleanupGame(gameIdFromMap, "missing_reconnect_logic_on_disconnect_gm");
}
} else if (playerInfoInGame && playerInfoInGame.socket?.id !== socketId) {
console.log(`[GameManager.handleDisconnect] Disconnected socket ${socketId} is STALE for user ${identifier}. Active socket in game: ${playerInfoInGame.socket?.id}. No action taken by GM.`);
} else if (playerInfoInGame && playerInfoInGame.isTemporarilyDisconnected) {
console.log(`[GameManager.handleDisconnect] User ${identifier} (socket ${socketId}) disconnected while ALREADY temp disconnected. Reconnect timer in GameInstance handles final cleanup.`);
} else if (!playerInfoInGame) {
console.warn(`[GameManager.handleDisconnect] User ${identifier} mapped to game ${gameIdFromMap}, but not found in game.players. This might indicate a stale userIdentifierToGameId entry. Clearing map for this user.`);
} else {
console.warn(`[GameManager.handleDisconnect] Пользователь ${identifier} сопоставлен с игрой ${gameIdFromMap}, но не найден в game.players. Это может указывать на устаревшую запись userIdentifierToGameId. Очистка карты для этого пользователя.`);
if (this.userIdentifierToGameId[identifier] === gameIdFromMap) {
delete this.userIdentifierToGameId[identifier];
}
}
} else {
if (this.userIdentifierToGameId[identifier]) {
console.warn(`[GameManager.handleDisconnect] No game instance found for gameId ${gameIdFromMap} (user ${identifier}). Clearing stale map entry.`);
console.warn(`[GameManager.handleDisconnect] Экземпляр игры для gameId ${gameIdFromMap} (пользователь ${identifier}) не найден. Очистка устаревшей записи в карте.`);
delete this.userIdentifierToGameId[identifier];
}
}
}
_cleanupGame(gameId, reason = 'unknown') {
console.log(`[GameManager._cleanupGame] Attempting cleanup for GameID: ${gameId}, Reason: ${reason}`);
console.log(`[GameManager._cleanupGame] Попытка очистки для GameID: ${gameId}, Причина: ${reason}`);
const game = this.games[gameId];
if (!game) {
console.warn(`[GameManager._cleanupGame] Game instance for ${gameId} not found in this.games. Cleaning up associated records.`);
console.warn(`[GameManager._cleanupGame] Экземпляр игры для ${gameId} не найден в this.games. Очистка связанных записей.`);
const pendingIdx = this.pendingPvPGames.indexOf(gameId);
if (pendingIdx > -1) {
this.pendingPvPGames.splice(pendingIdx, 1);
console.log(`[GameManager._cleanupGame] Removed ${gameId} from pendingPvPGames.`);
console.log(`[GameManager._cleanupGame] ${gameId} удален из pendingPvPGames.`);
}
// Важно: итерируем по ключам, так как удаление может изменить объект
Object.keys(this.userIdentifierToGameId).forEach(idKey => {
if (this.userIdentifierToGameId[idKey] === gameId) {
delete this.userIdentifierToGameId[idKey];
console.log(`[GameManager._cleanupGame] Removed mapping for user ${idKey} to game ${gameId}.`);
console.log(`[GameManager._cleanupGame] Удалено сопоставление для пользователя ${idKey} с игрой ${gameId}.`);
}
});
this.broadcastAvailablePvPGames();
return false;
}
console.log(`[GameManager._cleanupGame] Cleaning up game ${game.id}. Owner: ${game.ownerIdentifier}. Reason: ${reason}. Players in game: ${game.playerCount}`);
console.log(`[GameManager._cleanupGame] Очистка игры ${game.id}. Владелец: ${game.ownerIdentifier}. Причина: ${reason}. Игроков в игре: ${game.playerCount}`);
if (typeof game.turnTimer?.clear === 'function') game.turnTimer.clear();
if (typeof game.clearAllReconnectTimers === 'function') game.clearAllReconnectTimers();
if (game.gameState && !game.gameState.isGameOver) {
console.log(`[GameManager._cleanupGame] Marking game ${game.id} as game over because it's being cleaned up while active.`);
console.log(`[GameManager._cleanupGame] Пометка игры ${game.id} как завершенной, так как она очищается во время активности.`);
game.gameState.isGameOver = true;
// Можно рассмотреть отправку gameOver, если игра прерывается извне
// game.io.to(game.id).emit('gameOver', { reason: `game_cleanup_${reason}`, finalGameState: game.gameState, log: game.consumeLogBuffer() });
// game.io.to(game.id).emit('gameOver', { winnerId: null, reason: `game_cleanup_${reason}`, finalGameState: game.gameState, log: game.consumeLogBuffer() });
}
Object.values(game.players).forEach(pInfo => {
if (pInfo?.identifier && this.userIdentifierToGameId[pInfo.identifier] === gameId) {
delete this.userIdentifierToGameId[pInfo.identifier];
console.log(`[GameManager._cleanupGame] Cleared userIdentifierToGameId for player ${pInfo.identifier}.`);
console.log(`[GameManager._cleanupGame] Очищено userIdentifierToGameId для игрока ${pInfo.identifier}.`);
}
});
// Дополнительная проверка для владельца, если он не был в списке игроков (маловероятно, но для полноты)
if (game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId) {
if (!Object.values(game.players).some(p => p.identifier === game.ownerIdentifier)) {
delete this.userIdentifierToGameId[game.ownerIdentifier];
console.log(`[GameManager._cleanupGame] Cleared userIdentifierToGameId for owner ${game.ownerIdentifier} (was not in players list).`);
console.log(`[GameManager._cleanupGame] Очищено userIdentifierToGameId для владельца ${game.ownerIdentifier} (не был в списке игроков).`);
}
}
const pendingIdx = this.pendingPvPGames.indexOf(gameId);
if (pendingIdx > -1) {
this.pendingPvPGames.splice(pendingIdx, 1);
console.log(`[GameManager._cleanupGame] Removed ${gameId} from pendingPvPGames.`);
console.log(`[GameManager._cleanupGame] ${gameId} удален из pendingPvPGames.`);
}
delete this.games[gameId];
console.log(`[GameManager._cleanupGame] Game ${gameId} instance deleted. Games left: ${Object.keys(this.games).length}. Pending: ${this.pendingPvPGames.length}. User map size: ${Object.keys(this.userIdentifierToGameId).length}`);
console.log(`[GameManager._cleanupGame] Экземпляр игры ${gameId} удален. Осталось игр: ${Object.keys(this.games).length}. Ожидающих: ${this.pendingPvPGames.length}. Размер карты пользователей: ${Object.keys(this.userIdentifierToGameId).length}`);
this.broadcastAvailablePvPGames();
return true;
}
getAvailablePvPGamesListForClient() {
// Итерируем копию массива pendingPvPGames, так как _cleanupGame может его изменять
return [...this.pendingPvPGames]
.map(gameId => {
const game = this.games[gameId];
@ -463,22 +434,22 @@ class GameManager {
const p1Entry = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected);
let p1Username = 'Игрок';
let p1CharName = 'Неизвестный';
const ownerId = game.ownerIdentifier; // Это должен быть identifier создателя
const ownerId = game.ownerIdentifier;
if (p1Entry && p1Entry.socket?.userData) {
p1Username = p1Entry.socket.userData.username || `User#${String(p1Entry.identifier).substring(0,4)}`;
if (p1Entry) { // Используем данные из p1Entry, если он есть (более надежно)
p1Username = p1Entry.socket?.userData?.username || `User#${String(p1Entry.identifier).substring(0,4)}`;
const charData = dataUtils.getCharacterBaseStats(p1Entry.chosenCharacterKey);
p1CharName = charData?.name || p1Entry.chosenCharacterKey || 'Не выбран';
} else if (ownerId){
} else if (ownerId){ // Резервный вариант, если p1Entry почему-то нет
const ownerSocket = this._findClientSocketByIdentifier(ownerId);
p1Username = ownerSocket?.userData?.username || `Owner#${String(ownerId).substring(0,4)}`;
const ownerCharKey = game.playerCharacterKey; // Это ключ персонажа для роли PLAYER_ID в этой игре
const ownerCharKey = game.playerCharacterKey;
const charData = ownerCharKey ? dataUtils.getCharacterBaseStats(ownerCharKey) : null;
p1CharName = charData?.name || ownerCharKey || 'Не выбран';
}
return { id: gameId, status: `Ожидает (${p1Username} за ${p1CharName})`, ownerIdentifier: ownerId };
} else if (game && (game.playerCount !== 1 || game.gameState?.isGameOver)) {
console.warn(`[GameManager.getAvailablePvPGamesListForClient] Game ${gameId} is in pendingPvPGames but is not a valid pending game (players: ${game.playerCount}, over: ${game.gameState?.isGameOver}). Removing.`);
console.warn(`[GameManager.getAvailablePvPGamesListForClient] Игра ${gameId} находится в pendingPvPGames, но не является допустимой ожидающей игрой (игроков: ${game.playerCount}, завершена: ${game.gameState?.isGameOver}). Удаление.`);
this._cleanupGame(gameId, 'invalid_pending_game_in_list');
}
return null;
@ -493,7 +464,7 @@ class GameManager {
handleRequestGameState(socket, identifier) {
const gameIdFromMap = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handleRequestGameState] User: ${identifier} (Socket: ${socket.id}) requests state. GameID from map: ${gameIdFromMap}`);
console.log(`[GameManager.handleRequestGameState] Пользователь: ${identifier} (Socket: ${socket.id}) запрашивает состояние. GameID из карты: ${gameIdFromMap}`);
const game = gameIdFromMap ? this.games[gameIdFromMap] : null;
if (game) {
@ -502,49 +473,84 @@ class GameManager {
if (playerInfoInGame) {
if (game.gameState?.isGameOver) {
socket.emit('gameNotFound', { message: 'Ваша предыдущая игра уже завершена.' });
// _cleanupGame будет вызвана, когда игра фактически завершается.
// Здесь не удаляем из userIdentifierToGameId, если игра еще есть в this.games.
// Не удаляем из userIdentifierToGameId здесь, _cleanupGame сделает это, если игра еще в this.games
return;
}
if (typeof game.handlePlayerReconnected === 'function') {
const reconnected = game.handlePlayerReconnected(playerInfoInGame.id, socket);
if (!reconnected) {
console.warn(`[GameManager.handleRequestGameState] game.handlePlayerReconnected for ${identifier} in ${game.id} returned false.`);
console.warn(`[GameManager.handleRequestGameState] game.handlePlayerReconnected для ${identifier} в ${game.id} вернул false.`);
// GameInstance должен был отправить ошибку.
}
} else {
console.error(`[GameManager.handleRequestGameState] CRITICAL: GameInstance ${game.id} missing handlePlayerReconnected!`);
console.error(`[GameManager.handleRequestGameState] КРИТИЧЕСКИ: GameInstance ${game.id} отсутствует handlePlayerReconnected!`);
this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_gm_on_request');
}
} else {
console.warn(`[GameManager.handleRequestGameState] User ${identifier} mapped to game ${gameIdFromMap}, but NOT FOUND in game.players. Cleaning map & sending gameNotFound.`);
this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_not_in_gi_players_but_mapped_on_request');
// Игрок сопоставлен с игрой, но НЕ НАЙДЕН в game.players. Это может произойти, если PCH еще не добавил игрока (например, F5 на экране создания игры).
// Попытаемся добавить игрока в игру, если это PvP и есть место, или если это его же игра в режиме AI.
console.warn(`[GameManager.handleRequestGameState] Пользователь ${identifier} сопоставлен с игрой ${gameIdFromMap}, но НЕ НАЙДЕН в game.players. Попытка добавить/переподключить.`);
if (game.mode === 'pvp') {
// Пытаемся присоединить, предполагая, что он мог быть удален или это F5 перед полным присоединением
const chosenCharKey = socket.handshake.query.chosenCharacterKey || 'elena'; // Получаем ключ из запроса или дефолтный
if (game.addPlayer(socket, chosenCharKey, identifier)) {
// Успешно добавили или переподключили через addPlayer -> handlePlayerReconnected
const newPlayerInfo = Object.values(game.players).find(p => p.identifier === identifier);
socket.emit('gameCreated', { // Отправляем событие, как при обычном присоединении
gameId: game.id,
mode: game.mode,
yourPlayerId: newPlayerInfo.id,
chosenCharacterKey: newPlayerInfo.chosenCharacterKey
});
if (game.playerCount === 2) { // Если игра стала полной
if(game.initializeGame()) game.startGame(); else this._cleanupGame(game.id, 'init_fail_pvp_readd_gm');
const idx = this.pendingPvPGames.indexOf(game.id);
if (idx > -1) this.pendingPvPGames.splice(idx, 1);
this.broadcastAvailablePvPGames();
}
} else {
// Не удалось добавить/переподключить через addPlayer
this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_readd_failed_in_gi_on_request');
}
} else if (game.mode === 'ai' && game.ownerIdentifier === identifier) {
// Для AI игры, если это владелец, пытаемся через handlePlayerReconnected
if (typeof game.handlePlayerReconnected === 'function') {
// Предполагаем, что роль PLAYER_ID, так как это AI игра и он владелец
const reconnected = game.handlePlayerReconnected(GAME_CONFIG.PLAYER_ID, socket);
if (!reconnected) {
this._handleGameRecoveryError(socket, game.id, identifier, 'ai_owner_reconnect_failed_on_request');
}
} else {
this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_ai_owner_on_request');
}
} else {
this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_not_in_gi_players_unhandled_case_on_request');
}
}
} else {
socket.emit('gameNotFound', { message: 'Активная игровая сессия не найдена.' });
if (this.userIdentifierToGameId[identifier]) {
console.warn(`[GameManager.handleRequestGameState] No game instance found for gameId ${gameIdFromMap} (user ${identifier}). Clearing stale map entry.`);
console.warn(`[GameManager.handleRequestGameState] Экземпляр игры для gameId ${gameIdFromMap} (пользователь ${identifier}) не найден. Очистка устаревшей записи в карте.`);
delete this.userIdentifierToGameId[identifier];
}
}
}
_handleGameRecoveryError(socket, gameId, identifier, reasonCode) {
console.error(`[GameManager._handleGameRecoveryError] Error recovering game (ID: ${gameId || 'N/A'}) for user ${identifier}. Reason: ${reasonCode}.`);
console.error(`[GameManager._handleGameRecoveryError] Ошибка восстановления игры (ID: ${gameId || 'N/A'}) для пользователя ${identifier}. Причина: ${reasonCode}.`);
socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры. Попробуйте войти снова.' });
if (gameId && this.games[gameId]) {
this._cleanupGame(gameId, `recovery_error_gm_${reasonCode}_for_${identifier}`);
} else if (this.userIdentifierToGameId[identifier]) {
const problematicGameIdForUser = this.userIdentifierToGameId[identifier];
// Если игра была удалена, но пользователь к ней привязан, просто чистим карту
delete this.userIdentifierToGameId[identifier];
console.log(`[GameManager._handleGameRecoveryError] Cleaned stale userIdentifierToGameId[${identifier}] pointing to ${problematicGameIdForUser}.`);
console.log(`[GameManager._handleGameRecoveryError] Очищено устаревшее userIdentifierToGameId[${identifier}], указывающее на ${problematicGameIdForUser}.`);
}
// Убедимся, что после всех очисток пользователь точно не привязан
if (this.userIdentifierToGameId[identifier]) {
if (this.userIdentifierToGameId[identifier]) { // Финальная проверка
delete this.userIdentifierToGameId[identifier];
console.warn(`[GameManager._handleGameRecoveryError] Force cleaned userIdentifierToGameId[${identifier}] as a final measure.`);
console.warn(`[GameManager._handleGameRecoveryError] Принудительно очищено userIdentifierToGameId[${identifier}] в качестве финальной меры.`);
}
socket.emit('gameNotFound', { message: 'Ваша игровая сессия была завершена из-за ошибки. Пожалуйста, войдите снова.' });
}

View File

@ -28,6 +28,8 @@ class GameInstance {
GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS,
() => this.handleTurnTimeout(),
(remainingTime, isPlayerTurnForTimer, isPaused) => {
// Логируем отправку обновления таймера
// console.log(`[GI TURN_TIMER_CB ${this.id}] Sending update. Remaining: ${remainingTime}, isPlayerT: ${isPlayerTurnForTimer}, isPaused (raw): ${isPaused}, effectivelyPaused: ${this.isGameEffectivelyPaused()}`);
this.io.to(this.id).emit('turnTimerUpdate', {
remainingTime,
isPlayerTurn: isPlayerTurnForTimer,
@ -38,28 +40,23 @@ class GameInstance {
);
if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') {
console.error(`[GameInstance ${this.id}] CRITICAL ERROR: GameManager reference invalid.`);
console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: Ссылка на GameManager недействительна.`);
}
console.log(`[GameInstance ${this.id}] Created. Mode: ${mode}. PlayerConnectionHandler also initialized.`);
console.log(`[GameInstance ${this.id}] Создан. Режим: ${mode}. PlayerConnectionHandler также инициализирован.`);
}
// --- Геттеры для GameManager и внутреннего использования ---
get playerCount() {
return this.playerConnectionHandler.playerCount;
}
// Этот геттер может быть полезен, если GameManager или другая часть GameInstance
// захочет получить доступ ко всем данным игроков, не зная о PCH.
get players() {
return this.playerConnectionHandler.getAllPlayersInfo();
}
// --- Сеттеры для PCH ---
setPlayerCharacterKey(key) { this.playerCharacterKey = key; }
setOpponentCharacterKey(key) { this.opponentCharacterKey = key; }
setOwnerIdentifier(identifier) { this.ownerIdentifier = identifier; }
// --- Методы, делегирующие PCH ---
addPlayer(socket, chosenCharacterKey, identifier) {
return this.playerConnectionHandler.addPlayer(socket, chosenCharacterKey, identifier);
}
@ -68,11 +65,12 @@ class GameInstance {
this.playerConnectionHandler.removePlayer(socketId, reason);
}
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) {
this.playerConnectionHandler.handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey);
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) {
this.playerConnectionHandler.handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId);
}
handlePlayerReconnected(playerIdRole, newSocket) {
console.log(`[GameInstance ${this.id}] Делегирование handlePlayerReconnected в PCH для роли ${playerIdRole}, сокет ${newSocket.id}`);
return this.playerConnectionHandler.handlePlayerReconnected(playerIdRole, newSocket);
}
@ -85,19 +83,17 @@ class GameInstance {
}
handlePlayerPermanentlyLeft(playerRole, characterKey, reason) {
console.log(`[GameInstance ${this.id}] Player permanently left. Role: ${playerRole}, Reason: ${reason}`);
console.log(`[GameInstance ${this.id}] Игрок окончательно покинул игру. Роль: ${playerRole}, Персонаж: ${characterKey}, Причина: ${reason}`);
if (this.gameState && !this.gameState.isGameOver) {
// Используем геттер playerCount
if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) {
this.endGameDueToDisconnect(playerRole, characterKey, "player_left_ai_game");
} else if (this.mode === 'pvp') {
if (this.playerCount < 2) {
// Используем геттер players для поиска оставшегося
const remainingActivePlayerEntry = Object.values(this.players).find(p => p.id !== playerRole && !p.isTemporarilyDisconnected);
this.endGameDueToDisconnect(playerRole, characterKey, "opponent_left_pvp_game", remainingActivePlayerEntry?.id);
}
}
} else if (!this.gameState && Object.keys(this.players).length === 0) { // Используем геттер players
} else if (!this.gameState && Object.keys(this.players).length === 0) {
this.gameManager._cleanupGame(this.id, "all_players_left_before_start_gi_via_pch");
}
}
@ -105,7 +101,7 @@ class GameInstance {
_sayTaunt(characterState, opponentCharacterKey, triggerType, subTriggerOrContext = null, contextOverrides = {}) {
if (!characterState || !characterState.characterKey) return;
if (!opponentCharacterKey) return;
if (!gameLogic.getRandomTaunt) { console.error(`[Taunt ${this.id}] _sayTaunt: gameLogic.getRandomTaunt is not available!`); return; }
if (!gameLogic.getRandomTaunt) { console.error(`[Taunt ${this.id}] _sayTaunt: gameLogic.getRandomTaunt недоступен!`); return; }
if (!this.gameState) return;
let context = {};
@ -146,24 +142,27 @@ class GameInstance {
}
initializeGame() {
// Используем геттеры
console.log(`[GameInstance ${this.id}] Initializing game state. Mode: ${this.mode}. Active players: ${this.playerCount}. Total entries: ${Object.keys(this.players).length}`);
console.log(`[GameInstance ${this.id}] Инициализация состояния игры. Режим: ${this.mode}. Активных игроков (PCH): ${this.playerCount}. Всего записей в PCH.players: ${Object.keys(this.players).length}. PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}`);
const p1ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected);
const p2ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected);
// Устанавливаем ключи персонажей, если они еще не установлены, на основе активных игроков в PCH
// Это важно, если initializeGame вызывается до того, как PCH успел обновить ключи в GI через сеттеры
if (p1ActiveEntry && !this.playerCharacterKey) this.playerCharacterKey = p1ActiveEntry.chosenCharacterKey;
if (p2ActiveEntry && !this.opponentCharacterKey && this.mode === 'pvp') this.opponentCharacterKey = p2ActiveEntry.chosenCharacterKey;
const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected);
const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected);
if (this.mode === 'ai') {
if (!p1Entry) { this._handleCriticalError('init_ai_no_active_player_gi_v4', 'AI game init: Human player not found or not active.'); return false; }
if (!this.playerCharacterKey) { this._handleCriticalError('init_ai_no_player_key_gi', 'AI game init: Player character key not set.'); return false;}
if (!p1ActiveEntry) { this._handleCriticalError('init_ai_no_active_player_gi', 'Инициализация AI игры: Игрок-человек не найден или не активен.'); return false; }
if (!this.playerCharacterKey) { this._handleCriticalError('init_ai_no_player_key_gi', 'Инициализация AI игры: Ключ персонажа игрока не установлен.'); return false;}
this.opponentCharacterKey = 'balard';
} else {
// Используем геттер playerCount
if (this.playerCount === 1 && p1Entry && !this.playerCharacterKey) {
this._handleCriticalError('init_pvp_single_player_no_key_gi', 'PvP init (1 player): Player char key missing.'); return false;
} else { // pvp
if (this.playerCount === 1 && p1ActiveEntry && !this.playerCharacterKey) {
this._handleCriticalError('init_pvp_single_player_no_key_gi', 'PvP инициализация (1 игрок): Ключ персонажа игрока отсутствует.'); return false;
}
if (this.playerCount === 2 && (!this.playerCharacterKey || !this.opponentCharacterKey)) {
console.error(`[GameInstance ${this.id}] PvP init error: activePlayerCount is 2, but keys not set. P1Key: ${this.playerCharacterKey}, P2Key: ${this.opponentCharacterKey}.`);
this._handleCriticalError('init_pvp_char_key_missing_gi_v4', `PvP init: activePlayerCount is 2, but a charKey is missing.`);
this._handleCriticalError('init_pvp_char_key_missing_gi', `Инициализация PvP: playerCount=2, но ключ персонажа отсутствует. P1Key: ${this.playerCharacterKey}, P2Key: ${this.opponentCharacterKey}.`);
return false;
}
}
@ -171,35 +170,44 @@ class GameInstance {
const playerData = this.playerCharacterKey ? dataUtils.getCharacterData(this.playerCharacterKey) : null;
const opponentData = this.opponentCharacterKey ? dataUtils.getCharacterData(this.opponentCharacterKey) : null;
const isPlayerSlotFilledAndActive = !!(playerData && p1Entry);
const isOpponentSlotFilledAndActive = !!(opponentData && (this.mode === 'ai' || p2Entry));
const isPlayerSlotFilledAndActive = !!(playerData && p1ActiveEntry);
const isOpponentSlotFilledAndActive = !!(opponentData && (this.mode === 'ai' || p2ActiveEntry));
if (this.mode === 'ai' && (!isPlayerSlotFilledAndActive || !isOpponentSlotFilledAndActive) ) {
this._handleCriticalError('init_ai_data_fail_gs_gi_v4', 'AI game init: Failed to load player or AI data for gameState (active check).'); return false;
if (this.mode === 'ai' && (!isPlayerSlotFilledAndActive || !opponentData) ) {
this._handleCriticalError('init_ai_data_fail_gs_gi', 'Инициализация AI игры: Не удалось загрузить данные игрока или AI для gameState.'); return false;
}
this.logBuffer = [];
// Имена берутся из playerData/opponentData, если они есть. PCH обновит их при реконнекте, если они изменились.
const playerName = playerData?.baseStats?.name || (p1ActiveEntry?.name || 'Ожидание Игрока 1...');
let opponentName;
if (this.mode === 'ai') {
opponentName = opponentData?.baseStats?.name || 'Противник AI';
} else {
opponentName = opponentData?.baseStats?.name || (p2ActiveEntry?.name || 'Ожидание Игрока 2...');
}
this.gameState = {
player: isPlayerSlotFilledAndActive ?
this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities) :
this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: 'Ожидание Игрока 1...', maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, []),
this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities, playerName) : // Передаем имя
this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: playerName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], playerName),
opponent: isOpponentSlotFilledAndActive ?
this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities) :
this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: (this.mode === 'pvp' ? 'Ожидание Игрока 2...' : 'Противник AI'), maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, []),
this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities, opponentName) : // Передаем имя
this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: opponentName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], opponentName),
isPlayerTurn: (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) ? (Math.random() < 0.5) : true,
isGameOver: false,
turnNumber: 1,
gameMode: this.mode
};
console.log(`[GameInstance ${this.id}] Game state initialized. Player: ${this.gameState.player.name}. Opponent: ${this.gameState.opponent.name}. Ready for start if both active: ${isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive}`);
return (this.mode === 'ai') ? (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) : isPlayerSlotFilledAndActive;
console.log(`[GameInstance ${this.id}] Состояние игры инициализировано. Игрок: ${this.gameState.player.name} (${this.gameState.player.characterKey}). Оппонент: ${this.gameState.opponent.name} (${this.gameState.opponent.characterKey}). IsPlayerTurn: ${this.gameState.isPlayerTurn}. Готово к старту: AI=${isPlayerSlotFilledAndActive && !!opponentData}, PvP1=${isPlayerSlotFilledAndActive}, PvP2=${isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive}`);
return (this.mode === 'ai') ? (isPlayerSlotFilledAndActive && !!opponentData) : isPlayerSlotFilledAndActive;
}
_createFighterState(roleId, baseStats, abilities) {
_createFighterState(roleId, baseStats, abilities, explicitName = null) {
const fighterState = {
id: roleId, characterKey: baseStats.characterKey, name: baseStats.name,
id: roleId, characterKey: baseStats.characterKey, name: explicitName || baseStats.name, // Используем explicitName если передано
currentHp: baseStats.maxHp, maxHp: baseStats.maxHp,
currentResource: baseStats.maxResource, maxResource: baseStats.maxResource,
resourceName: baseStats.resourceName, attackPower: baseStats.attackPower,
@ -218,40 +226,46 @@ class GameInstance {
}
startGame() {
console.log(`[GameInstance ${this.id}] Попытка запуска игры. Paused: ${this.isGameEffectivelyPaused()}`);
if (this.isGameEffectivelyPaused()) {
console.log(`[GameInstance ${this.id}] Start game deferred: game effectively paused.`);
console.log(`[GameInstance ${this.id}] Запуск игры отложен: игра на паузе.`);
return;
}
if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) {
console.warn(`[GameInstance ${this.id}] startGame: gameState or character keys not fully initialized. Attempting re-init.`);
console.warn(`[GameInstance ${this.id}] startGame: gameState или ключи персонажей не полностью инициализированы. Попытка повторной инициализации.`);
if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) {
this._handleCriticalError('start_game_reinit_failed_sg_gi_v5', 'Re-initialization before start failed or keys still missing in gameState.');
this._handleCriticalError('start_game_reinit_failed_sg_gi', 'Повторная инициализация перед стартом не удалась или ключи все еще отсутствуют в gameState.');
return;
}
}
console.log(`[GameInstance ${this.id}] Starting game. Player in GS: ${this.gameState.player.name} (${this.playerCharacterKey}), Opponent in GS: ${this.gameState.opponent.name} (${this.opponentCharacterKey})`);
console.log(`[GameInstance ${this.id}] Запуск игры. Игрок в GS: ${this.gameState.player.name} (${this.playerCharacterKey}), Оппонент в GS: ${this.gameState.opponent.name} (${this.opponentCharacterKey}). IsPlayerTurn: ${this.gameState.isPlayerTurn}`);
const pData = dataUtils.getCharacterData(this.playerCharacterKey);
const oData = dataUtils.getCharacterData(this.opponentCharacterKey);
if (!pData || !oData) {
this._handleCriticalError('start_char_data_fail_sg_gi_v6', `Failed to load character data at game start. PData: ${!!pData}, OData: ${!!oData}`);
this._handleCriticalError('start_char_data_fail_sg_gi', `Не удалось загрузить данные персонажей при старте игры. PData: ${!!pData}, OData: ${!!oData}`);
return;
}
// Обновляем имена в gameState на основе данных персонажей перед отправкой клиентам
// Это гарантирует, что имена из dataUtils (самые "правильные") попадут в первое gameStarted
if (this.gameState.player && pData?.baseStats?.name) this.gameState.player.name = pData.baseStats.name;
if (this.gameState.opponent && oData?.baseStats?.name) this.gameState.opponent.name = oData.baseStats.name;
this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM);
if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) {
this._sayTaunt(this.gameState.player, this.gameState.opponent.characterKey, 'onBattleState', 'start');
this._sayTaunt(this.gameState.opponent, this.gameState.player.characterKey, 'onBattleState', 'start');
} else {
console.warn(`[GameInstance ${this.id}] Could not say start taunts during startGame, gameState actors/keys not fully ready.`);
console.warn(`[GameInstance ${this.id}] Не удалось произнести стартовые насмешки во время startGame, gameState акторы/ключи не полностью готовы.`);
}
const initialLog = this.consumeLogBuffer();
// Используем геттер this.players
Object.values(this.players).forEach(playerInfo => {
if (playerInfo.socket?.connected && !playerInfo.isTemporarilyDisconnected) {
const dataForThisClient = playerInfo.id === GAME_CONFIG.PLAYER_ID ?
@ -259,8 +273,12 @@ class GameInstance {
{ playerBaseStats: oData.baseStats, opponentBaseStats: pData.baseStats, playerAbilities: oData.abilities, opponentAbilities: pData.abilities };
playerInfo.socket.emit('gameStarted', {
gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState,
...dataForThisClient, log: [...initialLog], clientConfig: { ...GAME_CONFIG }
gameId: this.id,
yourPlayerId: playerInfo.id,
initialGameState: this.gameState,
...dataForThisClient,
log: [...initialLog],
clientConfig: { ...GAME_CONFIG }
});
}
});
@ -270,18 +288,23 @@ class GameInstance {
this.broadcastLogUpdate();
const isFirstTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn;
console.log(`[GameInstance ${this.id}] Запуск таймера в startGame. isPlayerTurn: ${this.gameState.isPlayerTurn}, isFirstTurnAi: ${isFirstTurnAi}`);
this.turnTimer.start(this.gameState.isPlayerTurn, isFirstTurnAi);
if (isFirstTurnAi) {
setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN);
setTimeout(() => {
if (!this.isGameEffectivelyPaused() && this.gameState && !this.gameState.isGameOver && this.mode === 'ai' && !this.gameState.isPlayerTurn) {
this.processAiTurn();
}
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
}
}
processPlayerAction(identifier, actionData) {
// Используем геттер this.players
console.log(`[GameInstance ${this.id}] processPlayerAction от ${identifier}. Действие: ${actionData.actionType}. Текущий GS.isPlayerTurn: ${this.gameState?.isPlayerTurn}. Paused: ${this.isGameEffectivelyPaused()}`);
const actingPlayerInfo = Object.values(this.players).find(p => p.identifier === identifier);
if (!actingPlayerInfo || !actingPlayerInfo.socket) {
console.error(`[GameInstance ${this.id}] Action from unknown or socketless identifier ${identifier}.`); return;
console.error(`[GameInstance ${this.id}] Действие от неизвестного или безсокетного идентификатора ${identifier}.`); return;
}
if (this.isGameEffectivelyPaused()) {
@ -293,8 +316,14 @@ class GameInstance {
const actingPlayerRole = actingPlayerInfo.id;
const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) ||
(!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID);
if (!isCorrectTurn) { actingPlayerInfo.socket.emit('gameError', { message: "Не ваш ход." }); return; }
if (!isCorrectTurn) {
console.warn(`[GameInstance ${this.id}] Неверный ход! Игрок ${identifier} (роль ${actingPlayerRole}) пытался действовать. GS.isPlayerTurn: ${this.gameState.isPlayerTurn}`);
actingPlayerInfo.socket.emit('gameError', { message: "Не ваш ход." });
return;
}
console.log(`[GameInstance ${this.id}] Ход корректен. Очистка таймера.`);
if(this.turnTimer.isActive()) this.turnTimer.clear();
const attackerState = this.gameState[actingPlayerRole];
@ -302,11 +331,11 @@ class GameInstance {
const defenderState = this.gameState[defenderRole];
if (!attackerState || !attackerState.characterKey || !defenderState || !defenderState.characterKey) {
this._handleCriticalError('action_actor_state_invalid_gi_v4', `Attacker or Defender state/key invalid.`); return;
this._handleCriticalError('action_actor_state_invalid_gi', `Состояние/ключ Атакующего или Защитника недействительны.`); return;
}
const attackerData = dataUtils.getCharacterData(attackerState.characterKey);
const defenderData = dataUtils.getCharacterData(defenderState.characterKey);
if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail_process_gi_v4', 'Ошибка данных персонажа при действии.'); return; }
if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail_process_gi', 'Ошибка данных персонажа при действии.'); return; }
let actionIsValidAndPerformed = false;
@ -341,20 +370,22 @@ class GameInstance {
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
} else {
const isAiTurnForTimer = this.mode === 'ai' && !this.gameState.isPlayerTurn;
console.log(`[GameInstance ${this.id}] Действие не выполнено, перезапуск таймера. isPlayerTurn: ${this.gameState.isPlayerTurn}, isAiTurnForTimer: ${isAiTurnForTimer}`);
this.turnTimer.start(this.gameState.isPlayerTurn, isAiTurnForTimer);
}
}
switchTurn() {
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Switch turn deferred: game paused.`); return; }
console.log(`[GameInstance ${this.id}] Попытка смены хода. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}`);
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Смена хода отложена: игра на паузе.`); return; }
if (!this.gameState || this.gameState.isGameOver) { return; }
if(this.turnTimer.isActive()) this.turnTimer.clear();
const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
const endingTurnActorState = this.gameState[endingTurnActorRole];
if (!endingTurnActorState || !endingTurnActorState.characterKey) { this._handleCriticalError('switch_turn_ending_actor_invalid_gi', `Ending turn actor state or key invalid.`); return; }
if (!endingTurnActorState || !endingTurnActorState.characterKey) { this._handleCriticalError('switch_turn_ending_actor_invalid_gi', `Состояние или ключ актора, завершающего ход, недействительны.`); return; }
const endingTurnActorData = dataUtils.getCharacterData(endingTurnActorState.characterKey);
if (!endingTurnActorData) { this._handleCriticalError('switch_turn_char_data_fail_gi', `Char data missing.`); return; }
if (!endingTurnActorData) { this._handleCriticalError('switch_turn_char_data_fail_gi', `Отсутствуют данные персонажа.`); return; }
gameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnActorData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils);
gameLogic.updateBlockingStatus(endingTurnActorState);
@ -369,32 +400,42 @@ class GameInstance {
const currentTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
const currentTurnActorState = this.gameState[currentTurnActorRole];
if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid_gi', `Current turn actor state or name invalid.`); return; }
// Используем геттер this.players
const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole);
if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid_gi', `Состояние или имя текущего актора недействительны.`); return; }
this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN);
this.broadcastGameStateUpdate();
const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole);
if (currentTurnPlayerEntry && currentTurnPlayerEntry.isTemporarilyDisconnected) {
console.log(`[GameInstance ${this.id}] Turn switched to ${currentTurnActorRole}, but player ${currentTurnPlayerEntry.identifier} disconnected. Timer not started by switchTurn.`);
console.log(`[GameInstance ${this.id}] Ход перешел к ${currentTurnActorRole}, но игрок ${currentTurnPlayerEntry.identifier} отключен. Таймер не запущен switchTurn.`);
} else {
const isNextTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn;
console.log(`[GameInstance ${this.id}] Запуск таймера в switchTurn. isPlayerTurn: ${this.gameState.isPlayerTurn}, isNextTurnAi: ${isNextTurnAi}`);
this.turnTimer.start(this.gameState.isPlayerTurn, isNextTurnAi);
if (isNextTurnAi) setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN);
if (isNextTurnAi) {
setTimeout(() => {
if (!this.isGameEffectivelyPaused() && this.gameState && !this.gameState.isGameOver && this.mode === 'ai' && !this.gameState.isPlayerTurn) {
this.processAiTurn();
}
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
}
}
}
processAiTurn() { // Остается без изменений, так как использует this.gameState
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] AI turn deferred: game paused.`); return; }
processAiTurn() {
console.log(`[GameInstance ${this.id}] processAiTurn. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}, IsPlayerTurn: ${this.gameState?.isPlayerTurn}`);
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Ход AI отложен: игра на паузе.`); return; }
if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent) { return; }
if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) { console.error(`[GameInstance ${this.id}] AI is not Balard!`); this.switchTurn(); return; }
if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) {
console.error(`[GameInstance ${this.id}] AI не Балард! Персонаж AI: ${this.gameState.opponent?.characterKey}. Принудительная смена хода.`);
this.switchTurn();
return;
}
if(this.turnTimer.isActive()) this.turnTimer.clear();
const aiState = this.gameState.opponent;
const playerState = this.gameState.player;
if (!playerState || !playerState.characterKey) { this._handleCriticalError('ai_turn_player_state_invalid_gi', 'Player state invalid for AI turn.'); return; }
if (!playerState || !playerState.characterKey) { this._handleCriticalError('ai_turn_player_state_invalid_gi', 'Состояние игрока недействительно для хода AI.'); return; }
const aiDecision = gameLogic.decideAiAction(this.gameState, dataUtils, GAME_CONFIG, this.addToLog.bind(this));
let actionIsValidAndPerformedForAI = false;
@ -411,17 +452,21 @@ class GameInstance {
actionIsValidAndPerformedForAI = true;
} else if (aiDecision.actionType === 'pass') {
if (aiDecision.logMessage && this.addToLog) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type);
else if (this.addToLog) this.addToLog(`${aiState.name} пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO);
else if(this.addToLog) this.addToLog(`${aiState.name} пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO);
actionIsValidAndPerformedForAI = true;
}
if (this.checkGameOver()) return;
this.broadcastLogUpdate();
if (actionIsValidAndPerformedForAI) setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
else { console.error(`[GameInstance ${this.id}] AI failed action. Forcing switch.`); setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); }
if (actionIsValidAndPerformedForAI) {
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
} else {
console.error(`[GameInstance ${this.id}] AI не смог выполнить действие. Принудительная смена хода.`);
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
}
}
checkGameOver() { // Остается без изменений, так как использует this.gameState
checkGameOver() {
if (!this.gameState || this.gameState.isGameOver) return this.gameState?.isGameOver ?? true;
if (!this.gameState.isGameOver && this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) {
@ -429,8 +474,12 @@ class GameInstance {
const pData = dataUtils.getCharacterData(player.characterKey); const oData = dataUtils.getCharacterData(opponent.characterKey);
if (pData && oData) {
const nearDefeatThreshold = GAME_CONFIG.OPPONENT_NEAR_DEFEAT_THRESHOLD_PERCENT || 0.2;
if (opponent.currentHp > 0 && (opponent.currentHp / oData.baseStats.maxHp) <= nearDefeatThreshold) this._sayTaunt(player, opponent.characterKey, 'onBattleState', 'opponentNearDefeat');
if (player.currentHp > 0 && (player.currentHp / pData.baseStats.maxHp) <= nearDefeatThreshold) this._sayTaunt(opponent, player.characterKey, 'onBattleState', 'opponentNearDefeat');
if (opponent.currentHp > 0 && (opponent.currentHp / oData.baseStats.maxHp) <= nearDefeatThreshold) {
this._sayTaunt(player, opponent.characterKey, 'onBattleState', 'opponentNearDefeat');
}
if (player.currentHp > 0 && (player.currentHp / pData.baseStats.maxHp) <= nearDefeatThreshold) {
this._sayTaunt(opponent, player.characterKey, 'onBattleState', 'opponentNearDefeat');
}
}
}
@ -446,9 +495,8 @@ class GameInstance {
if (winnerState?.characterKey && loserState?.characterKey) {
this._sayTaunt(winnerState, loserState.characterKey, 'onBattleState', 'opponentNearDefeat');
}
if (loserState?.characterKey) { /* ... сюжетные логи ... */ }
console.log(`[GameInstance ${this.id}] Game over. Winner: ${gameOverResult.winnerRole || 'None'}. Reason: ${gameOverResult.reason}.`);
console.log(`[GameInstance ${this.id}] Игра окончена. Победитель: ${gameOverResult.winnerRole || 'Нет'}. Причина: ${gameOverResult.reason}.`);
this.io.to(this.id).emit('gameOver', {
winnerId: gameOverResult.winnerRole,
reason: gameOverResult.reason,
@ -472,7 +520,6 @@ class GameInstance {
let winnerActuallyExists = false;
if (actualWinnerRole) {
// Используем геттер this.players
const winnerPlayerEntry = Object.values(this.players).find(p => p.id === actualWinnerRole && !p.isTemporarilyDisconnected);
if (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID) {
winnerActuallyExists = !!this.gameState.opponent?.characterKey;
@ -483,7 +530,6 @@ class GameInstance {
if (!winnerActuallyExists) {
actualWinnerRole = (disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID);
// Используем геттер this.players
const defaultWinnerEntry = Object.values(this.players).find(p => p.id === actualWinnerRole && !p.isTemporarilyDisconnected);
if (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID) {
winnerActuallyExists = !!this.gameState.opponent?.characterKey;
@ -493,11 +539,10 @@ class GameInstance {
}
const finalWinnerRole = winnerActuallyExists ? actualWinnerRole : null;
const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, reason, finalWinnerRole, disconnectedPlayerRole);
this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM);
console.log(`[GameInstance ${this.id}] Game ended by disconnect: ${reason}. Winner: ${result.winnerRole || 'Нет'}.`);
console.log(`[GameInstance ${this.id}] Игра завершена из-за отключения: ${reason}. Победитель: ${result.winnerRole || 'Нет'}.`);
this.io.to(this.id).emit('gameOver', {
winnerId: result.winnerRole,
reason: result.reason,
@ -509,28 +554,28 @@ class GameInstance {
});
this.gameManager._cleanupGame(this.id, `disconnect_game_ended_gi_${result.reason}`);
} else if (this.gameState?.isGameOver) {
console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: already over.`);
console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: игра уже была завершена.`);
this.gameManager._cleanupGame(this.id, `already_over_on_disconnect_cleanup_gi`);
} else {
console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: no gameState.`);
console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: нет gameState.`);
this.gameManager._cleanupGame(this.id, `no_gamestate_on_disconnect_cleanup_gi`);
}
}
playerExplicitlyLeftAiGame(identifier) {
if (this.mode !== 'ai' || (this.gameState && this.gameState.isGameOver)) {
console.log(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame called, but not AI mode or game over.`);
console.log(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame вызван, но не режим AI или игра завершена.`);
if (this.gameState?.isGameOver) this.gameManager._cleanupGame(this.id, `player_left_ai_already_over_gi`);
return;
}
// Используем геттер this.players
const playerEntry = Object.values(this.players).find(p => p.identifier === identifier);
if (!playerEntry || playerEntry.id !== GAME_CONFIG.PLAYER_ID) {
console.warn(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame: Identifier ${identifier} is not the human player or not found.`);
console.warn(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame: Идентификатор ${identifier} не является игроком-человеком или не найден.`);
return;
}
console.log(`[GameInstance ${this.id}] Player ${identifier} explicitly left AI game.`);
console.log(`[GameInstance ${this.id}] Игрок ${identifier} явно покинул AI игру.`);
if (this.gameState) {
this.gameState.isGameOver = true;
this.addToLog(`Игрок покинул битву с ${this.gameState.opponent?.name || 'AI'}.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
@ -548,44 +593,42 @@ class GameInstance {
log: this.consumeLogBuffer(),
loserCharacterKey: playerEntry.chosenCharacterKey
});
this.gameManager._cleanupGame(this.id, 'player_left_ai_explicitly_gi');
}
playerDidSurrender(surrenderingPlayerIdentifier) {
console.log(`[GameInstance ${this.id}] playerDidSurrender called for identifier: ${surrenderingPlayerIdentifier}`);
console.log(`[GameInstance ${this.id}] playerDidSurrender вызван для идентификатора: ${surrenderingPlayerIdentifier}`);
if (!this.gameState || this.gameState.isGameOver) {
if (this.gameState?.isGameOver) { this.gameManager._cleanupGame(this.id, `surrender_on_finished_gi`); }
console.warn(`[GameInstance ${this.id}] Surrender attempt on inactive/finished game by ${surrenderingPlayerIdentifier}.`);
console.warn(`[GameInstance ${this.id}] Попытка сдачи в неактивной/завершенной игре от ${surrenderingPlayerIdentifier}.`);
return;
}
// Используем геттер this.players
const surrenderedPlayerEntry = Object.values(this.players).find(p => p.identifier === surrenderingPlayerIdentifier);
if (!surrenderedPlayerEntry) {
console.error(`[GameInstance ${this.id}] Surrendering player ${surrenderingPlayerIdentifier} not found.`);
console.error(`[GameInstance ${this.id}] Сдающийся игрок ${surrenderingPlayerIdentifier} не найден.`);
return;
}
const surrenderingPlayerRole = surrenderedPlayerEntry.id;
if (this.mode === 'ai') {
if (surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID) {
console.log(`[GameInstance ${this.id}] Player ${surrenderingPlayerIdentifier} "surrendered" (left) AI game.`);
console.log(`[GameInstance ${this.id}] Игрок ${surrenderingPlayerIdentifier} "сдался" (покинул) AI игру.`);
this.playerExplicitlyLeftAiGame(surrenderingPlayerIdentifier);
} else {
console.warn(`[GameInstance ${this.id}] Surrender in AI mode from non-player (role: ${surrenderingPlayerRole}).`);
console.warn(`[GameInstance ${this.id}] Сдача в AI режиме от не-игрока (роль: ${surrenderingPlayerRole}). Игнорируется.`);
}
return;
}
if (this.mode !== 'pvp') {
console.warn(`[GameInstance ${this.id}] Surrender called in non-PvP, non-AI mode: ${this.mode}. Ignoring.`);
console.warn(`[GameInstance ${this.id}] Сдача вызвана в не-PvP, не-AI режиме: ${this.mode}. Игнорируется.`);
return;
}
const surrenderedPlayerName = this.gameState[surrenderingPlayerRole]?.name || surrenderedPlayerEntry.chosenCharacterKey;
const surrenderedPlayerCharKey = this.gameState[surrenderingPlayerRole]?.characterKey || surrenderedPlayerEntry.chosenCharacterKey;
const winnerRole = surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
const winnerName = this.gameState[winnerRole]?.name || `Оппонент`;
const winnerCharKey = this.gameState[winnerRole]?.characterKey;
@ -595,7 +638,7 @@ class GameInstance {
this.clearAllReconnectTimers();
this.addToLog(`🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`, GAME_CONFIG.LOG_TYPE_SYSTEM);
console.log(`[GameInstance ${this.id}] Player ${surrenderedPlayerName} (Role: ${surrenderingPlayerRole}) surrendered. Winner: ${winnerName} (Role: ${winnerRole}).`);
console.log(`[GameInstance ${this.id}] Игрок ${surrenderedPlayerName} (Роль: ${surrenderingPlayerRole}) сдался. Победитель: ${winnerName} (Роль: ${winnerRole}).`);
if (winnerCharKey && surrenderedPlayerCharKey && this.gameState[winnerRole]) {
this._sayTaunt(this.gameState[winnerRole], surrenderedPlayerCharKey, 'onBattleState', 'opponentNearDefeat');
@ -611,7 +654,7 @@ class GameInstance {
handleTurnTimeout() {
if (!this.gameState || this.gameState.isGameOver) return;
console.log(`[GameInstance ${this.id}] Turn timeout occurred.`);
console.log(`[GameInstance ${this.id}] Произошел таймаут хода.`);
const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
const winnerPlayerRoleIfHuman = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
@ -620,7 +663,6 @@ class GameInstance {
if (this.mode === 'ai' && winnerPlayerRoleIfHuman === GAME_CONFIG.OPPONENT_ID) {
winnerActuallyExists = !!this.gameState.opponent?.characterKey;
} else {
// Используем геттер this.players
const winnerEntry = Object.values(this.players).find(p => p.id === winnerPlayerRoleIfHuman && !p.isTemporarilyDisconnected);
winnerActuallyExists = !!winnerEntry;
}
@ -634,7 +676,7 @@ class GameInstance {
if (result.winnerRole && this.gameState[result.winnerRole]?.characterKey && this.gameState[result.loserRole]?.characterKey) {
this._sayTaunt(this.gameState[result.winnerRole], this.gameState[result.loserRole].characterKey, 'onBattleState', 'opponentNearDefeat');
}
console.log(`[GameInstance ${this.id}] Turn timed out for ${this.gameState[timedOutPlayerRole]?.name || timedOutPlayerRole}. Winner: ${result.winnerRole ? (this.gameState[result.winnerRole]?.name || result.winnerRole) : 'Нет'}.`);
console.log(`[GameInstance ${this.id}] Ход истек для ${this.gameState[timedOutPlayerRole]?.name || timedOutPlayerRole}. Победитель: ${result.winnerRole ? (this.gameState[result.winnerRole]?.name || result.winnerRole) : 'Нет'}.`);
this.io.to(this.id).emit('gameOver', {
winnerId: result.winnerRole,
reason: result.reason,
@ -646,9 +688,11 @@ class GameInstance {
}
_handleCriticalError(reasonCode, logMessage) {
console.error(`[GameInstance ${this.id}] CRITICAL ERROR: ${logMessage} (Code: ${reasonCode})`);
console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: ${logMessage} (Код: ${reasonCode})`);
if (this.gameState && !this.gameState.isGameOver) this.gameState.isGameOver = true;
else if (!this.gameState) this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode };
else if (!this.gameState) {
this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode };
}
if(this.turnTimer.isActive()) this.turnTimer.clear();
this.clearAllReconnectTimers();
@ -667,6 +711,8 @@ class GameInstance {
addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) {
if (!message) return;
this.logBuffer.push({ message, type, timestamp: Date.now() });
// Раскомментируйте для немедленной отправки логов, если нужно (но обычно лучше батчинг)
// this.broadcastLogUpdate();
}
consumeLogBuffer() {
@ -676,8 +722,15 @@ class GameInstance {
}
broadcastGameStateUpdate() {
if (this.isGameEffectivelyPaused()) { return; }
if (!this.gameState) return;
if (this.isGameEffectivelyPaused()) {
console.log(`[GameInstance ${this.id}] broadcastGameStateUpdate отложено: игра на паузе.`);
return;
}
if (!this.gameState) {
console.warn(`[GameInstance ${this.id}] broadcastGameStateUpdate: gameState отсутствует.`);
return;
}
console.log(`[GameInstance ${this.id}] Отправка gameStateUpdate. IsPlayerTurn: ${this.gameState.isPlayerTurn}`);
this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() });
}
@ -687,7 +740,7 @@ class GameInstance {
if (systemLogs.length > 0) {
this.io.to(this.id).emit('logUpdate', { log: systemLogs });
}
this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM);
this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM); // Оставляем несистемные
return;
}
if (this.logBuffer.length > 0) {

View File

@ -1,6 +1,6 @@
// /server/game/instance/PlayerConnectionHandler.js
const GAME_CONFIG = require('../../core/config');
const dataUtils = require('../../data/dataUtils'); // Потребуется для получения данных персонажа при реконнекте
const dataUtils = require('../../data/dataUtils');
class PlayerConnectionHandler {
constructor(gameInstance) {
@ -9,47 +9,51 @@ class PlayerConnectionHandler {
this.gameId = gameInstance.id;
this.mode = gameInstance.mode;
this.players = {}; // { socket.id: { id, socket, chosenCharacterKey, identifier, isTemporarilyDisconnected } }
this.playerSockets = {}; // { playerIdRole: socket }
this.players = {}; // { socket.id: { id, socket, chosenCharacterKey, identifier, isTemporarilyDisconnected, name (optional from gameState) } }
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.`);
console.log(`[PCH for Game ${this.gameId}] Инициализирован.`);
}
addPlayer(socket, chosenCharacterKey = 'elena', identifier) {
console.log(`[PCH ${this.gameId}] addPlayer attempt. Socket: ${socket.id}, CharKey: ${chosenCharacterKey}, Identifier: ${identifier}`);
console.log(`[PCH ${this.gameId}] Попытка addPlayer. 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.`);
console.warn(`[PCH ${this.gameId}] Идентификатор ${identifier} уже связан с ролью игрока ${existingPlayerByIdentifier.id} (сокет ${existingPlayerByIdentifier.socket?.id}). Обрабатывается как возможное переподключение.`);
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.`);
console.warn(`[PCH ${this.gameId}] Игрок ${identifier} пытается (пере)присоединиться к уже завершенной игре. Отправка 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;
// Если игрок уже есть, и это не временное отключение, и сокет другой - это F5 или новая вкладка.
// GameManager должен был направить на handleRequestGameState, который вызовет handlePlayerReconnected.
// Прямой addPlayer в этом случае - редкий сценарий, но handlePlayerReconnected его обработает.
return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket);
}
if (Object.keys(this.players).length >= 2 && this.playerCount >=2) {
if (Object.keys(this.players).length >= 2 && this.playerCount >=2 && this.mode === 'pvp') { // В AI режиме только 1 человек
socket.emit('gameError', { message: 'Эта игра уже заполнена.' });
return false;
}
if (this.mode === 'ai' && this.playerCount >=1) {
socket.emit('gameError', { message: 'К AI игре может присоединиться только один игрок.'});
return false;
}
let assignedPlayerId;
let actualCharacterKey = chosenCharacterKey || 'elena';
const charData = dataUtils.getCharacterData(actualCharacterKey);
if (this.mode === 'ai') {
if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) {
socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' });
return false;
}
// if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { // Эта проверка уже покрыта playerCount >= 1 выше
// socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' });
// return false;
// }
assignedPlayerId = GAME_CONFIG.PLAYER_ID;
} else { // pvp
if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) {
@ -60,35 +64,39 @@ class PlayerConnectionHandler {
if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) {
if (actualCharacterKey === 'elena') actualCharacterKey = 'almagest';
else if (actualCharacterKey === 'almagest') actualCharacterKey = 'elena';
// Добавьте другие пары, если нужно, или более общую логику выбора другого персонажа
else actualCharacterKey = dataUtils.getAllCharacterKeys().find(k => k !== firstPlayerInfo.chosenCharacterKey) || 'elena';
}
} else {
socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' });
} else { // Оба слота заняты, но playerCount мог быть < 2 если кто-то в процессе дисконнекта
socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре (возможно, все заняты или в процессе переподключения).' });
return false;
}
}
// Если для этой роли уже был игрок (например, старый сокет), удаляем его
// Если для этой роли УЖЕ был игрок (например, старый сокет при F5 до того, как сработал disconnect),
// то handlePlayerReconnected должен был бы это обработать. Этот блок здесь - подстраховка,
// если addPlayer вызван напрямую в таком редком случае.
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){} } // Убедимся, что старый сокет покинул комнату
console.warn(`[PCH ${this.gameId}] addPlayer: Найден старый сокет ${oldPlayerInfo.socket?.id} для роли ${assignedPlayerId}. Удаляем его запись.`);
if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.gameId); oldPlayerInfo.socket.disconnect(true); } catch(e){} }
delete this.players[oldPlayerSocketIdForRole];
}
this.players[socket.id] = {
id: assignedPlayerId,
socket: socket,
chosenCharacterKey: actualCharacterKey,
identifier: identifier,
isTemporarilyDisconnected: false
isTemporarilyDisconnected: false,
name: charData?.baseStats?.name || actualCharacterKey
};
this.playerSockets[assignedPlayerId] = socket;
this.playerCount++;
socket.join(this.gameId);
console.log(`[PCH ${this.gameId}] Сокет ${socket.id} присоединен к комнате ${this.gameId} (addPlayer).`);
// Сообщаем GameInstance об установленных ключах и владельце
if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey);
else if (assignedPlayerId === GAME_CONFIG.OPPONENT_ID) this.gameInstance.setOpponentCharacterKey(actualCharacterKey);
@ -96,8 +104,7 @@ class PlayerConnectionHandler {
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}`);
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Socket: ${socket.id}) добавлен как ${assignedPlayerId} с персонажем ${this.players[socket.id].name}. Активных игроков: ${this.playerCount}. Владелец: ${this.gameInstance.ownerIdentifier}`);
return true;
}
@ -106,61 +113,72 @@ class PlayerConnectionHandler {
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}.`);
console.log(`[PCH ${this.gameId}] Окончательное удаление игрока ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Причина: ${reason}.`);
if (playerInfo.socket) {
try { playerInfo.socket.leave(this.gameId); } catch (e) { /* ignore */ }
try { playerInfo.socket.leave(this.gameId); } catch (e) { console.warn(`[PCH ${this.gameId}] Ошибка при playerInfo.socket.leave: ${e.message}`); }
}
if (!playerInfo.isTemporarilyDisconnected) { // Уменьшаем счетчик только если это был активный игрок, а не временное отключение
if (!playerInfo.isTemporarilyDisconnected) {
this.playerCount--;
}
delete this.players[socketId];
if (this.playerSockets[playerRole]?.id === socketId) { // Если это был текущий сокет для роли
if (this.playerSockets[playerRole]?.id === socketId) {
delete this.playerSockets[playerRole];
}
this.clearReconnectTimer(playerRole); // Очищаем таймер переподключения для этой роли
this.clearReconnectTimer(playerRole);
console.log(`[PCH ${this.gameId}] Player ${playerIdentifier} removed. Active players now: ${this.playerCount}.`);
// Сигнализируем GameInstance, чтобы он решил, нужно ли завершать игру
console.log(`[PCH ${this.gameId}] Игрок ${playerIdentifier} удален. Активных игроков сейчас: ${this.playerCount}.`);
this.gameInstance.handlePlayerPermanentlyLeft(playerRole, playerInfo.chosenCharacterKey, reason);
} else {
console.warn(`[PCH ${this.gameId}] removePlayer called for unknown socketId: ${socketId}`);
console.warn(`[PCH ${this.gameId}] removePlayer вызван для неизвестного socketId: ${socketId}`);
}
}
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) {
console.log(`[PCH ${this.gameId}] handlePlayerPotentiallyLeft for role ${playerIdRole}, id ${identifier}, char ${characterKey}`);
// Находим запись игрока по роли и идентификатору, так как сокет мог уже измениться или быть удален
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) {
console.log(`[PCH ${this.gameId}] handlePlayerPotentiallyLeft для роли ${playerIdRole}, id ${identifier}, char ${characterKey}, disconnectedSocketId ${disconnectedSocketId}`);
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.`);
console.warn(`[PCH ${this.gameId}] Запись игрока или сокет не найдены для ${identifier} (роль ${playerIdRole}) во время потенциального выхода. disconnectedSocketId: ${disconnectedSocketId}`);
// Если записи нет, возможно, игрок уже удален или это был очень старый сокет.
// Проверим, есть ли запись по disconnectedSocketId, и если да, удалим ее.
if (this.players[disconnectedSocketId]) {
console.warn(`[PCH ${this.gameId}] Найдена запись по disconnectedSocketId ${disconnectedSocketId}, удаляем ее.`);
this.removePlayer(disconnectedSocketId, 'stale_socket_disconnect_no_entry');
}
return;
}
if (playerEntry.socket.id !== disconnectedSocketId) {
console.log(`[PCH ${this.gameId}] Событие отключения для УСТАРЕВШЕГО сокета ${disconnectedSocketId} для игрока ${identifier} (Роль ${playerIdRole}). Текущий активный сокет: ${playerEntry.socket.id}. Игрок, вероятно, уже переподключился или сессия обновлена. Игнорируем дальнейшую логику "потенциального выхода" для этого устаревшего сокета.`);
if (this.players[disconnectedSocketId]) {
delete this.players[disconnectedSocketId]; // Удаляем только эту запись, не вызываем полный removePlayer
}
return;
}
if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) {
console.log(`[PCH ${this.gameId}] Game already over, not handling potential left for ${identifier}.`);
console.log(`[PCH ${this.gameId}] Игра уже завершена, не обрабатываем потенциальный выход для ${identifier}.`);
return;
}
if (playerEntry.isTemporarilyDisconnected) {
console.log(`[PCH ${this.gameId}] Player ${identifier} already marked as temp disconnected.`);
console.log(`[PCH ${this.gameId}] Игрок ${identifier} уже помечен как временно отключенный.`);
return;
}
playerEntry.isTemporarilyDisconnected = true;
this.playerCount--; // Уменьшаем счетчик активных игроков
console.log(`[PCH ${this.gameId}] Player ${identifier} (role ${playerIdRole}) temp disconnected. Active: ${this.playerCount}. Starting reconnect timer.`);
this.playerCount--;
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (роль ${playerIdRole}, сокет ${disconnectedSocketId}) временно отключен. Активных: ${this.playerCount}. Запускаем таймер переподключения.`);
const disconnectedName = this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`;
const disconnectedName = playerEntry.name || 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 otherSocket = this.playerSockets[otherPlayerRole];
const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole);
if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) {
@ -170,35 +188,37 @@ class PlayerConnectionHandler {
});
}
// Приостанавливаем таймер хода, если он активен
if (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isAiCurrentlyMakingMove) ) {
if (this.gameInstance.turnTimer && (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isConfiguredForAiMove))) {
this.pausedTurnState = this.gameInstance.turnTimer.pause();
console.log(`[PCH ${this.gameId}] Turn timer paused due to disconnect. State:`, JSON.stringify(this.pausedTurnState));
console.log(`[PCH ${this.gameId}] Таймер хода приостановлен из-за отключения. Состояние:`, JSON.stringify(this.pausedTurnState));
} else {
this.pausedTurnState = null; // Явно сбрасываем, если таймер не был активен
this.pausedTurnState = null;
}
this.clearReconnectTimer(playerIdRole); // Очищаем старый таймер, если был
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 (remaining <= 0 || !this.reconnectTimers[playerIdRole] || this.reconnectTimers[playerIdRole]?.timerId === null) { // Добавлена проверка на существование таймера
if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
if (this.reconnectTimers[playerIdRole]) this.reconnectTimers[playerIdRole].updateIntervalId = null; // Помечаем, что интервал очищен
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)
if (this.reconnectTimers[playerIdRole]?.updateIntervalId) { // Очищаем интервал, если он еще существует
clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
this.reconnectTimers[playerIdRole].updateIntervalId = null;
}
this.reconnectTimers[playerIdRole].timerId = null; // Помечаем, что основной таймаут сработал или очищен
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);
@ -206,158 +226,247 @@ class PlayerConnectionHandler {
}
handlePlayerReconnected(playerIdRole, newSocket) {
const identifier = newSocket.userData?.userId; // Получаем идентификатор из нового сокета
console.log(`[PCH ${this.gameId}] handlePlayerReconnected for role ${playerIdRole}, id ${identifier}, newSocket ${newSocket.id}`);
const identifier = newSocket.userData?.userId;
console.log(`[PCH RECONNECT_ATTEMPT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${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);
let playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
console.log(`[PCH RECONNECT_ATTEMPT] Found playerEntry:`, playerEntry ? {id: playerEntry.id, identifier: playerEntry.identifier, oldSocketId: playerEntry.socket?.id, isTempDisc: playerEntry.isTemporarilyDisconnected} : null);
if (playerEntry && playerEntry.isTemporarilyDisconnected) {
this.clearReconnectTimer(playerIdRole);
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сигнал, что таймер остановлен
if (playerEntry) {
const oldSocket = playerEntry.socket;
// Удаляем старую запись по socket.id, если сокет действительно новый
const oldSocketId = playerEntry.socket.id;
if (this.players[oldSocketId] && oldSocketId !== newSocket.id) {
delete this.players[oldSocketId];
// Обновляем сокет в playerEntry и в this.players / this.playerSockets, если сокет новый
if (oldSocket && oldSocket.id !== newSocket.id) {
console.log(`[PCH ${this.gameId}] New socket ${newSocket.id} for player ${identifier}. Old socket: ${oldSocket.id}. Updating records.`);
if (this.players[oldSocket.id]) delete this.players[oldSocket.id]; // Удаляем старую запись по старому socket.id
if (oldSocket.connected) { // Пытаемся корректно закрыть старый сокет
console.log(`[PCH ${this.gameId}] Disconnecting old stale socket ${oldSocket.id}.`);
oldSocket.disconnect(true);
}
}
playerEntry.socket = newSocket; // Обновляем сокет в существующей playerEntry
this.players[newSocket.id] = playerEntry; // Убеждаемся, что по новому ID есть актуальная запись
if (oldSocket && oldSocket.id !== newSocket.id && this.players[oldSocket.id] === playerEntry) {
// Если вдруг playerEntry был взят по старому socket.id, и этот ID теперь должен быть удален
delete this.players[oldSocket.id];
}
this.playerSockets[playerIdRole] = newSocket; // Обновляем авторитетный сокет для роли
// Обновляем запись игрока
playerEntry.socket = newSocket;
playerEntry.isTemporarilyDisconnected = false;
this.players[newSocket.id] = playerEntry; // Добавляем/обновляем запись с новым socket.id
this.playerSockets[playerIdRole] = newSocket; // Обновляем активный сокет для роли
this.playerCount++; // Восстанавливаем счетчик активных игроков
// Всегда заново присоединяем сокет к комнате
console.log(`[PCH ${this.gameId}] Forcing newSocket ${newSocket.id} (identifier: ${identifier}) to join room ${this.gameId} during reconnect.`);
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;
}
if (playerEntry.isTemporarilyDisconnected) {
console.log(`[PCH ${this.gameId}] Переподключение игрока ${identifier} (Роль: ${playerIdRole}), который был временно отключен.`);
this.clearReconnectTimer(playerIdRole); // Очищаем таймер реконнекта
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сообщаем UI, что таймер остановлен
playerEntry.isTemporarilyDisconnected = false;
this.playerCount++; // Восстанавливаем счетчик активных игроков
} else {
// Игрок не был помечен как временно отключенный.
// Это может быть F5 или запрос состояния на "том же" (или новом, но старый не отвалился) сокете.
// playerCount не меняется, т.к. игрок считался активным.
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Роль: ${playerIdRole}) переподключился/запросил состояние, не будучи помеченным как 'temporarilyDisconnected'. Old socket ID: ${oldSocket?.id}`);
}
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.gameInstance.gameState && this.gameInstance.gameState[playerIdRole]?.name) {
playerEntry.name = this.gameInstance.gameState[playerIdRole].name;
} else {
const charData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey);
playerEntry.name = charData?.baseStats?.name || playerEntry.chosenCharacterKey;
}
console.log(`[PCH ${this.gameId}] Имя игрока ${identifier} обновлено/установлено на: ${playerEntry.name}`);
// Если игра не на "эффективной" паузе и не закончена, возобновляем игру
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);
this.gameInstance.addToLog(`🔌 Игрок ${playerEntry.name || identifier} снова в игре! (Сессия обновлена)`, GAME_CONFIG.LOG_TYPE_SYSTEM);
this.sendFullGameStateOnReconnect(newSocket, playerEntry, playerIdRole);
if (playerEntry.isTemporarilyDisconnected === false && this.pausedTurnState) { // Если игрок был временно отключен, isTemporarilyDisconnected уже false
this.resumeGameLogicAfterReconnect(playerIdRole);
} else if (playerEntry.isTemporarilyDisconnected === false && !this.pausedTurnState) {
// Игрок не был temp disconnected, и не было сохраненного состояния таймера (значит, он и не останавливался из-за этого игрока)
// Просто отправляем текущее состояние таймера, если он активен
console.log(`[PCH ${this.gameId}] Player was not temp disconnected, and no pausedTurnState. Forcing timer update if active.`);
if (this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive() && this.gameInstance.turnTimer.onTickCallback) {
const tt = this.gameInstance.turnTimer;
const elapsedTime = Date.now() - tt.segmentStartTimeMs;
const currentRemaining = Math.max(0, tt.segmentDurationMs - elapsedTime);
tt.onTickCallback(currentRemaining, tt.isConfiguredForPlayerSlotTurn, tt.isManuallyPausedState);
} else if (this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused() && !this.isGameEffectivelyPaused()) {
// Если таймер не активен, не на паузе, и игра не на общей паузе - возможно, его нужно запустить (если сейчас ход этого игрока)
const gs = this.gameInstance.gameState;
if (gs && !gs.isGameOver) {
const isHisTurnNow = (gs.isPlayerTurn && playerIdRole === GAME_CONFIG.PLAYER_ID) || (!gs.isPlayerTurn && playerIdRole === GAME_CONFIG.OPPONENT_ID);
const isAiTurnNow = this.mode === 'ai' && !gs.isPlayerTurn;
if(isHisTurnNow || isAiTurnNow) {
console.log(`[PCH ${this.gameId}] Timer not active, not paused. Game not paused. Attempting to start timer for ${playerIdRole}. HisTurn: ${isHisTurnNow}, AITurn: ${isAiTurnNow}`);
this.gameInstance.turnTimer.start(gs.isPlayerTurn, isAiTurnNow);
if (isAiTurnNow && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) {
// Доп. проверка, чтобы AI точно пошел, если это его ход и таймер не стартовал для него как "AI move"
setTimeout(() => {
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) {
this.gameInstance.processAiTurn();
}
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
}
}
}
}
}
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: 'Не удалось восстановить сессию (запись игрока не найдена или сессия устарела).' });
} else { // playerEntry не найден
console.warn(`[PCH ${this.gameId}] Попытка переподключения для ${identifier} (Роль ${playerIdRole}), но запись playerEntry не найдена. Это может быть новый игрок или сессия истекла.`);
// Если это новый игрок для этой роли, то addPlayer должен был быть вызван GameManager'ом.
// Если PCH вызывается напрямую, и игрока нет, это ошибка или устаревший запрос.
newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена). Попробуйте создать игру заново.' });
return false;
}
}
sendFullGameStateOnReconnect(socket, playerEntry, playerIdRole) {
console.log(`[PCH SEND_STATE_RECONNECT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${playerEntry.identifier}`);
if (!this.gameInstance.gameState) {
console.log(`[PCH SEND_STATE_RECONNECT] gameState отсутствует, попытка инициализации...`);
if (!this.gameInstance.initializeGame()) { // initializeGame должен установить gameState
this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch_helper', 'PCH Helper: GS null после повторной инициализации при переподключении.');
return;
}
console.log(`[PCH SEND_STATE_RECONNECT] gameState инициализирован. Player: ${this.gameInstance.gameState.player.name}, Opponent: ${this.gameInstance.gameState.opponent.name}`);
}
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 на основе сохраненных в PCH или данных персонажей
if (this.gameInstance.gameState) {
if (this.gameInstance.gameState[playerIdRole]) {
this.gameInstance.gameState[playerIdRole].name = playerEntry.name || pData?.baseStats?.name || 'Игрок';
}
const opponentPCHEntry = Object.values(this.players).find(p => p.id === oppRoleKey);
if (this.gameInstance.gameState[oppRoleKey]) {
if (opponentPCHEntry?.name) {
this.gameInstance.gameState[oppRoleKey].name = opponentPCHEntry.name;
} else if (oData?.baseStats?.name) {
this.gameInstance.gameState[oppRoleKey].name = oData.baseStats.name;
} else if (this.mode === 'ai' && oppRoleKey === GAME_CONFIG.OPPONENT_ID) {
this.gameInstance.gameState[oppRoleKey].name = 'Балард'; // Фоллбэк для AI
} else {
this.gameInstance.gameState[oppRoleKey].name = 'Оппонент';
}
}
}
console.log(`[PCH SEND_STATE_RECONNECT] Отправка gameStarted. Player GS: ${this.gameInstance.gameState?.player?.name}, Opponent GS: ${this.gameInstance.gameState?.opponent?.name}. IsPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`);
socket.emit('gameStarted', { // Используем 'gameStarted' для полной синхронизации состояния
gameId: this.gameId,
yourPlayerId: playerIdRole,
initialGameState: this.gameInstance.gameState,
playerBaseStats: pData?.baseStats,
opponentBaseStats: oData?.baseStats || {name: (this.mode === 'pvp' ? 'Ожидание...' : 'Противник AI'), maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null},
playerAbilities: pData?.abilities,
opponentAbilities: oData?.abilities || [],
log: this.gameInstance.consumeLogBuffer(),
clientConfig: { ...GAME_CONFIG }
});
}
resumeGameLogicAfterReconnect(reconnectedPlayerIdRole) {
const playerEntry = Object.values(this.players).find(p => p.id === reconnectedPlayerIdRole);
const reconnectedName = playerEntry?.name || this.gameInstance.gameState?.[reconnectedPlayerIdRole]?.name || `Игрок (Роль ${reconnectedPlayerIdRole})`;
console.log(`[PCH RESUME_LOGIC] gameId: ${this.gameId}, Role: ${reconnectedPlayerIdRole}, Name: ${reconnectedName}, PausedState: ${JSON.stringify(this.pausedTurnState)}, TimerActive: ${this.gameInstance.turnTimer?.isActive()}, GS.isPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`);
const otherPlayerRole = reconnectedPlayerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
const otherSocket = this.playerSockets[otherPlayerRole];
const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole);
if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) {
otherSocket.emit('playerReconnected', {
reconnectedPlayerId: reconnectedPlayerIdRole,
reconnectedPlayerName: reconnectedName
});
if (this.gameInstance.logBuffer.length > 0) { // Отправляем накопившиеся логи другому игроку
otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() });
}
}
// Обновляем состояние для всех (включая переподключившегося, т.к. его лог мог быть уже потреблен)
this.gameInstance.broadcastGameStateUpdate(); // Это отправит gameState и оставшиеся логи
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) {
// this.gameInstance.broadcastGameStateUpdate(); // Перенесено выше
if (Object.keys(this.reconnectTimers).length === 0) { // Только если нет других ожидающих реконнекта
const currentTurnIsForPlayerInGS = this.gameInstance.gameState.isPlayerTurn;
const isCurrentTurnAiForTimer = this.mode === 'ai' && !currentTurnIsForPlayerInGS;
let resumedFromPausedState = false;
if (this.pausedTurnState && typeof this.pausedTurnState.remainingTime === 'number') {
const gsTurnMatchesPausedTurn = (currentTurnIsForPlayerInGS && this.pausedTurnState.forPlayerRoleIsPlayer) ||
(!currentTurnIsForPlayerInGS && !this.pausedTurnState.forPlayerRoleIsPlayer);
if (gsTurnMatchesPausedTurn) {
console.log(`[PCH ${this.gameId}] Возобновляем таймер хода из pausedTurnState. Время: ${this.pausedTurnState.remainingTime}мс. Для игрока (в pausedState): ${this.pausedTurnState.forPlayerRoleIsPlayer}. GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход (в pausedState): ${this.pausedTurnState.isAiCurrentlyMoving}`);
this.gameInstance.turnTimer.resume(
this.pausedTurnState.remainingTime,
this.pausedTurnState.forPlayerRoleIsPlayer, // Это isConfiguredForPlayerSlotTurn для таймера
this.pausedTurnState.isAiCurrentlyMoving // Это isConfiguredForAiMove для таймера
);
resumedFromPausedState = true;
} else {
console.warn(`[PCH ${this.gameId}] pausedTurnState (${JSON.stringify(this.pausedTurnState)}) не совпадает с текущим ходом в gameState (isPlayerTurn: ${currentTurnIsForPlayerInGS}). Сбрасываем pausedTurnState и запускаем таймер заново, если нужно.`);
}
this.pausedTurnState = null; // Сбрасываем в любом случае
}
if (!resumedFromPausedState && this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused()) {
console.log(`[PCH ${this.gameId}] Запускаем таймер хода заново после реконнекта (pausedState не использовался или был неактуален, таймер неактивен и не на паузе). GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход для таймера: ${isCurrentTurnAiForTimer}`);
this.gameInstance.turnTimer.start(currentTurnIsForPlayerInGS, isCurrentTurnAiForTimer);
if (isCurrentTurnAiForTimer && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) {
setTimeout(() => {
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) {
this.gameInstance.processAiTurn();
}
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
}
} else if (!resumedFromPausedState && this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive()){
console.log(`[PCH ${this.gameId}] Таймер уже был активен при попытке перезапуска после реконнекта (pausedTurnState не использовался/неактуален). Ничего не делаем с таймером.`);
}
} else {
console.log(`[PCH ${this.gameId}] Возобновление логики таймера отложено, есть другие активные таймеры реконнекта: ${Object.keys(this.reconnectTimers)}`);
}
} else {
console.log(`[PCH ${this.gameId}] Игра на паузе или завершена, логика таймера не возобновляется. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameInstance.gameState?.isGameOver}`);
}
}
clearReconnectTimer(playerIdRole) {
if (this.reconnectTimers[playerIdRole]) {
clearTimeout(this.reconnectTimers[playerIdRole].timerId);
this.reconnectTimers[playerIdRole].timerId = null; // Явно обнуляем
if (this.reconnectTimers[playerIdRole].updateIntervalId) {
clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
this.reconnectTimers[playerIdRole].updateIntervalId = null; // Явно обнуляем
}
delete this.reconnectTimers[playerIdRole];
console.log(`[PCH ${this.gameId}] Cleared reconnect timer for role ${playerIdRole}.`);
delete this.reconnectTimers[playerIdRole]; // Удаляем всю запись
console.log(`[PCH ${this.gameId}] Очищен таймер переподключения для роли ${playerIdRole}.`);
}
}
clearAllReconnectTimers() {
console.log(`[PCH ${this.gameId}] Clearing ALL reconnect timers.`);
console.log(`[PCH ${this.gameId}] Очистка ВСЕХ таймеров переподключения.`);
for (const roleId in this.reconnectTimers) {
this.clearReconnectTimer(roleId);
}
@ -365,30 +474,25 @@ class PlayerConnectionHandler {
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; // Игра на паузе, если один из игроков временно отключен
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; // В остальных случаях игра не считается на паузе из-за дисконнектов
return false;
}
// Вспомогательный метод для получения информации о всех игроках (может пригодиться GameInstance)
getAllPlayersInfo() {
return { ...this.players };
}
// Вспомогательный метод для получения сокетов (может пригодиться GameInstance)
getPlayerSockets() {
return { ...this.playerSockets };
}

View File

@ -1,197 +1,30 @@
// /server/game/instance/TurnTimer.js
class TurnTimer {
/**
* Конструктор таймера хода.
* @param {number} turnDurationMs - Изначальная длительность хода в миллисекундах.
* @param {number} updateIntervalMs - Интервал для отправки обновлений времени клиентам (в мс).
* @param {function} onTimeoutCallback - Колбэк, вызываемый при истечении времени хода.
* @param {function} onTickCallback - Колбэк, вызываемый на каждом тике обновления (передает remainingTime, isPlayerTurnForTimer, isPaused).
* @param {string} [gameIdForLogs=''] - (Опционально) ID игры для более понятных логов таймера.
*/
constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') {
this.initialTurnDurationMs = turnDurationMs; // Сохраняем начальную полную длительность хода
this.currentEffectiveDurationMs = turnDurationMs; // Длительность, с которой стартует текущий отсчет (может быть меньше initial при resume)
this.initialTurnDurationMs = turnDurationMs;
this.updateIntervalMs = updateIntervalMs;
this.onTimeoutCallback = onTimeoutCallback;
this.onTickCallback = onTickCallback;
this.gameId = gameIdForLogs; // Для логов
this.onTickCallback = onTickCallback; // (remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic)
this.gameId = gameIdForLogs;
this.timeoutId = null; // ID для setTimeout (обработка общего таймаута хода)
this.tickIntervalId = null; // ID для setInterval (периодическое обновление клиента)
this.timeoutId = null;
this.tickIntervalId = null;
this.startTimeMs = 0; // Время (Date.now()) начала текущего отсчета таймера
this.isRunning = false; // Активен ли таймер в данный момент (идет отсчет)
this.segmentStartTimeMs = 0; // Время начала текущего активного сегмента (после start/resume)
this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий сегмент
// Состояние, для которого был запущен/приостановлен таймер
this.isForPlayerTurn = false; // true, если таймер отсчитывает ход игрока (слот 'player')
this.isAiCurrentlyMoving = false; // true, если это ход AI, и таймер для реального игрока не должен "тикать"
this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе, не ход AI)
this.isManuallyPausedState = false; // Была ли вызвана pause()
this.isManuallyPaused = false; // Флаг, что таймер был приостановлен вызовом pause()
// console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`);
// Состояние, для которого таймер был запущен (или должен быть запущен)
this.isConfiguredForPlayerSlotTurn = false;
this.isConfiguredForAiMove = false;
console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`);
}
/**
* Запускает или перезапускает таймер хода.
* @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player', false - если ход слота 'opponent'.
* @param {boolean} isAiMakingMove - true, если текущий ход делает AI (таймер для реального игрока не тикает).
* @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого оставшегося времени.
*/
start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) {
this.clear(true); // Очищаем предыдущие таймеры, сохраняя флаг isManuallyPaused если это resume
this.isForPlayerTurn = isPlayerSlotTurn;
this.isAiCurrentlyMakingMove = isAiMakingMove;
// При явном старте (не resume) сбрасываем флаг ручной паузы
if (customRemainingTimeMs === null) {
this.isManuallyPaused = false;
}
if (this.isAiCurrentlyMakingMove) {
this.isRunning = false; // Для хода AI основной таймер не "бежит" для игрока
// console.log(`[TurnTimer ${this.gameId}] Start: AI's turn. Player timer not ticking.`);
if (this.onTickCallback) {
// Уведомляем один раз, что таймер неактивен (ход AI), передаем isPaused = false (т.к. это не ручная пауза)
// Время может быть полным или оставшимся, если AI "думает"
this.onTickCallback(this.initialTurnDurationMs, this.isForPlayerTurn, false);
}
return;
}
// Устанавливаем длительность для текущего запуска
this.currentEffectiveDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0)
? customRemainingTimeMs
: this.initialTurnDurationMs;
this.startTimeMs = Date.now();
this.isRunning = true;
// console.log(`[TurnTimer ${this.gameId}] Started. Effective Duration: ${this.currentEffectiveDurationMs}ms. For ${this.isForPlayerTurn ? 'PlayerSlot' : 'OpponentSlot'}. AI moving: ${this.isAiCurrentlyMakingMove}`);
// Основной таймер на истечение времени хода
this.timeoutId = setTimeout(() => {
// console.log(`[TurnTimer ${this.gameId}] Timeout occurred! Was running: ${this.isRunning}`);
if (this.isRunning) { // Доп. проверка, что таймер все еще должен был работать
this.isRunning = false;
if (this.onTimeoutCallback) {
this.onTimeoutCallback();
}
this.clear(); // Очищаем и интервал обновления после таймаута
}
}, this.currentEffectiveDurationMs);
// Интервал для отправки обновлений клиентам
this.tickIntervalId = setInterval(() => {
if (!this.isRunning) {
// Если таймер был остановлен (например, ход сделан, игра окончена, или pause вызван),
// но интервал еще не очищен - очищаем.
this.clear(this.isManuallyPaused); // Сохраняем флаг, если это была ручная пауза
return;
}
const elapsedTime = Date.now() - this.startTimeMs;
const remainingTime = Math.max(0, this.currentEffectiveDurationMs - elapsedTime);
if (this.onTickCallback) {
// isManuallyPaused здесь всегда false, т.к. если бы была пауза, isRunning был бы false
this.onTickCallback(remainingTime, this.isForPlayerTurn, false);
}
if (remainingTime <= 0 && this.isRunning) {
// Время вышло по интервалу (на всякий случай, setTimeout должен сработать)
// Не вызываем onTimeoutCallback здесь напрямую, чтобы избежать двойного вызова.
this.clear(this.isManuallyPaused); // Очищаем интервал, setTimeout сработает для onTimeoutCallback
}
}, this.updateIntervalMs);
// Отправляем начальное значение немедленно
if (this.onTickCallback) {
this.onTickCallback(this.currentEffectiveDurationMs, this.isForPlayerTurn, false);
}
}
/**
* Приостанавливает таймер и возвращает его текущее состояние.
* @returns {{remainingTime: number, forPlayerRoleIsPlayer: boolean, isAiCurrentlyMoving: boolean}}
* - remainingTime: Оставшееся время в мс.
* - forPlayerRoleIsPlayer: true, если таймер был для хода игрока (слот 'player').
* - isAiCurrentlyMoving: true, если это был ход AI.
*/
pause() {
// console.log(`[TurnTimer ${this.gameId}] Pause called. isRunning: ${this.isRunning}, isAiCurrentlyMoving: ${this.isAiCurrentlyMoving}`);
let remainingTime = 0;
const wasForPlayerTurn = this.isForPlayerTurn;
const wasAiMoving = this.isAiCurrentlyMoving;
if (this.isAiCurrentlyMakingMove) {
// Если это был ход AI, таймер для игрока не тикал, считаем, что у него полное время.
// Однако, если AI "думал" и мы хотим сохранить это, логика должна быть сложнее.
// Для простоты, если AI ход, то время "не шло" для игрока.
remainingTime = this.initialTurnDurationMs;
// console.log(`[TurnTimer ${this.gameId}] Paused during AI move. Effective remaining time for player turn: ${remainingTime}ms.`);
} else if (this.isRunning) {
const elapsedTime = Date.now() - this.startTimeMs;
remainingTime = Math.max(0, this.currentEffectiveDurationMs - elapsedTime);
// console.log(`[TurnTimer ${this.gameId}] Paused while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTime}ms.`);
} else {
// Если таймер не был запущен (например, уже истек или был очищен),
// или был уже на паузе, возвращаем 0 или последнее известное значение.
// Если isManuallyPaused уже true, то просто возвращаем то, что было.
remainingTime = this.isManuallyPaused ? this.currentEffectiveDurationMs : 0; // currentEffectiveDurationMs тут может быть уже оставшимся временем
// console.log(`[TurnTimer ${this.gameId}] Pause called, but timer not actively running or already paused. Returning current/zero remaining time: ${remainingTime}ms.`);
}
this.isManuallyPaused = true; // Устанавливаем флаг ручной паузы
this.clear(true); // Очищаем внутренние таймеры, сохраняя флаг isManuallyPaused
this.isRunning = false; // Явно указываем, что отсчет остановлен
// Уведомляем клиента, что таймер на паузе
if (this.onTickCallback) {
// console.log(`[TurnTimer ${this.gameId}] Notifying client of pause. Remaining: ${remainingTime}, ForPlayer: ${wasForPlayerTurn}`);
this.onTickCallback(remainingTime, wasForPlayerTurn, true); // isPaused = true
}
return { remainingTime, forPlayerRoleIsPlayer: wasForPlayerTurn, isAiCurrentlyMoving: wasAiMoving };
}
/**
* Возобновляет таймер с указанного оставшегося времени и для указанного состояния.
* @param {number} remainingTimeMs - Оставшееся время в миллисекундах для возобновления.
* @param {boolean} forPlayerSlotTurn - Для чьего хода (слот 'player' = true) возобновляется таймер.
* @param {boolean} isAiMakingMove - Был ли это ход AI, когда таймер приостановили (и возобновляем ли ход AI).
*/
resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) {
if (!this.isManuallyPaused) {
// console.warn(`[TurnTimer ${this.gameId}] Resume called, but timer was not manually paused. Starting normally or doing nothing.`);
// Если не был на ручной паузе, то либо запускаем заново (если не был ход AI), либо ничего не делаем
// if (!isAiMakingMove) this.start(forPlayerSlotTurn, false, remainingTimeMs > 0 ? remainingTimeMs : null);
// Безопаснее просто выйти, если не был на ручной паузе, GameInstance должен управлять этим.
return;
}
if (remainingTimeMs <= 0) {
// console.log(`[TurnTimer ${this.gameId}] Resume called with 0 or less time. Triggering timeout.`);
this.isManuallyPaused = false; // Сбрасываем флаг
if (this.onTimeoutCallback) {
this.onTimeoutCallback(); // Немедленный таймаут
}
return;
}
// console.log(`[TurnTimer ${this.gameId}] Resuming. Remaining: ${remainingTimeMs}ms. For ${forPlayerSlotTurn ? 'PlayerSlot' : 'OpponentSlot'}. AI moving: ${isAiMakingMove}`);
this.isManuallyPaused = false; // Сбрасываем флаг ручной паузы перед стартом
// Запускаем таймер с сохраненным состоянием и оставшимся временем
this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs);
}
/**
* Очищает (останавливает) все активные таймеры (setTimeout и setInterval).
* @param {boolean} [preserveManuallyPausedFlag=false] - Если true, не сбрасывает флаг isManuallyPaused.
* Используется внутренне при вызове clear из pause().
*/
clear(preserveManuallyPausedFlag = false) {
_clearInternalTimers() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
@ -200,40 +33,205 @@ class TurnTimer {
clearInterval(this.tickIntervalId);
this.tickIntervalId = null;
}
}
const wasPreviouslyRunning = this.isRunning; // Запоминаем, работал ли он до clear
this.isRunning = false;
// this.startTimeMs = 0; // Не сбрасываем startTime, чтобы pause мог корректно вычислить remainingTime
/**
* Запускает или перезапускает таймер хода.
* @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player'.
* @param {boolean} isAiMakingMove - true, если текущий ход делает AI.
* @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени.
*/
start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) {
console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, ManualPause: ${this.isManuallyPausedState}`);
this._clearInternalTimers(); // Всегда очищаем старые таймеры перед новым запуском
if (!preserveManuallyPausedFlag) {
this.isManuallyPaused = false;
this.isConfiguredForPlayerSlotTurn = isPlayerSlotTurn;
this.isConfiguredForAiMove = isAiMakingMove;
// Если это не resume (т.е. customRemainingTimeMs не передан явно как результат pause),
// то сбрасываем флаг ручной паузы.
if (customRemainingTimeMs === null) {
this.isManuallyPausedState = false;
}
// Если таймер был очищен не через pause(), он был активен (и это не был ход AI, который и так не тикает)
// то опционально можно уведомить клиента, что таймер больше не тикает (например, ход сделан)
// Это может быть полезно, чтобы клиент сбросил свой отображаемый таймер на '--'
// if (wasPreviouslyRunning && !this.isAiCurrentlyMakingMove && !this.isManuallyPaused && this.onTickCallback) {
// // console.log(`[TurnTimer ${this.gameId}] Cleared while running (not AI, not manual pause). Notifying client.`);
// this.onTickCallback(null, this.isForPlayerTurn, this.isManuallyPaused); // remainingTime = null
if (this.isConfiguredForAiMove) {
this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для игрока
console.log(`[TurnTimer ${this.gameId}] START: AI's turn. Player timer not actively ticking.`);
if (this.onTickCallback) {
// Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе
this.onTickCallback(this.initialTurnDurationMs, this.isConfiguredForPlayerSlotTurn, false);
}
return;
}
// Если это не ход AI, то таймер должен работать для игрока (или оппонента-человека)
this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0)
? customRemainingTimeMs
: this.initialTurnDurationMs;
this.segmentStartTimeMs = Date.now();
this.isCurrentlyRunning = true; // Таймер теперь активен
// this.isManuallyPausedState остается как есть, если это был resume, или false, если это новый start
console.log(`[TurnTimer ${this.gameId}] STARTED. Effective Duration: ${this.segmentDurationMs}ms. ForPlayer: ${this.isConfiguredForPlayerSlotTurn}. IsRunning: ${this.isCurrentlyRunning}. ManualPause: ${this.isManuallyPausedState}`);
this.timeoutId = setTimeout(() => {
console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT occurred. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`);
// Проверяем, что таймер все еще должен был работать и не был на паузе
if (this.isCurrentlyRunning && !this.isManuallyPausedState) {
this._clearInternalTimers(); // Очищаем все, включая интервал
this.isCurrentlyRunning = false;
if (this.onTimeoutCallback) {
this.onTimeoutCallback();
}
} else {
console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT ignored (not running or manually paused).`);
}
}, this.segmentDurationMs);
this.tickIntervalId = setInterval(() => {
// Таймер должен обновлять UI только если он isCurrentlyRunning и НЕ isManuallyPausedState
// isManuallyPausedState проверяется в onTickCallback, который должен передать "isPaused" клиенту
if (!this.isCurrentlyRunning) { // Если таймер был остановлен (clear/timeout)
this._clearInternalTimers(); // Убедимся, что этот интервал тоже остановлен
return;
}
const elapsedTime = Date.now() - this.segmentStartTimeMs;
const remainingTime = Math.max(0, this.segmentDurationMs - elapsedTime);
if (this.onTickCallback) {
// Передаем isManuallyPausedState как состояние "паузы" для клиента
this.onTickCallback(remainingTime, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
}
// Не очищаем интервал здесь при remainingTime <= 0, пусть setTimeout это сделает.
// Отправка 0 - это нормально.
}, this.updateIntervalMs);
// Немедленная первая отправка состояния таймера
if (this.onTickCallback) {
console.log(`[TurnTimer ${this.gameId}] Initial tick after START. Remaining: ${this.segmentDurationMs}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}, ManualPause: ${this.isManuallyPausedState}`);
this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
}
}
pause() {
console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, ManualPause: ${this.isManuallyPausedState}`);
if (this.isManuallyPausedState) { // Уже на ручной паузе
console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Returning previous pause state.`);
// Нужно вернуть актуальное оставшееся время, которое было на момент установки паузы.
// segmentDurationMs при паузе сохраняет это значение.
if (this.onTickCallback) { // Уведомляем клиента еще раз, что на паузе
this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, true);
}
return {
remainingTime: this.segmentDurationMs, // Это время, которое осталось на момент паузы
forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn,
isAiCurrentlyMoving: this.isConfiguredForAiMove // Важно сохранить, чей ход это был
};
}
let remainingTimeToSave;
if (this.isConfiguredForAiMove) {
// Если ход AI, таймер для игрока не тикал, у него полное время
remainingTimeToSave = this.initialTurnDurationMs;
console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining: ${remainingTimeToSave}ms for player turn.`);
} else if (this.isCurrentlyRunning) {
// Таймер активно работал для игрока/оппонента-человека
const elapsedTime = Date.now() - this.segmentStartTimeMs;
remainingTimeToSave = Math.max(0, this.segmentDurationMs - elapsedTime);
console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSave}ms from segment duration ${this.segmentDurationMs}ms.`);
} else {
// Таймер не был активен (например, уже истек, был очищен, или это был start() для AI)
// В этом случае, если не ход AI, то время 0
remainingTimeToSave = 0;
console.log(`[TurnTimer ${this.gameId}] PAUSE called, but timer not actively running (and not AI move). Remaining set to 0.`);
}
this._clearInternalTimers();
this.isCurrentlyRunning = false;
this.isManuallyPausedState = true;
this.segmentDurationMs = remainingTimeToSave; // Сохраняем оставшееся время для resume
if (this.onTickCallback) {
console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE. Remaining: ${remainingTimeToSave}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`);
this.onTickCallback(remainingTimeToSave, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true
}
return {
remainingTime: remainingTimeToSave,
forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, // Чей ход это был
isAiCurrentlyMoving: this.isConfiguredForAiMove // Был ли это ход AI
};
}
resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) {
console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. SavedRemaining: ${remainingTimeMs}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, ManualPauseBefore: ${this.isManuallyPausedState}`);
if (!this.isManuallyPausedState) {
console.warn(`[TurnTimer ${this.gameId}] RESUME called, but timer was not manually paused. Current state - IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}. Ignoring resume, let PCH handle start if needed.`);
// Если не был на ручной паузе, возможно, игра уже продолжается или была очищена.
// Не вызываем start() отсюда, чтобы избежать неожиданного поведения.
// PCH должен решить, нужен ли новый start().
// Однако, если текущий ход совпадает, и таймер просто неактивен, можно запустить.
// Но лучше, чтобы PCH всегда вызывал start() с нуля, если resume не применим.
// Просто отправим текущее состояние, если onTickCallback есть.
if (this.onTickCallback) {
const currentElapsedTime = this.isCurrentlyRunning ? (Date.now() - this.segmentStartTimeMs) : 0;
const currentRemaining = this.isCurrentlyRunning ? Math.max(0, this.segmentDurationMs - currentElapsedTime) : this.segmentDurationMs;
this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
}
return;
}
if (remainingTimeMs <= 0 && !isAiMakingMove) { // Если не ход AI и время вышло
console.log(`[TurnTimer ${this.gameId}] RESUME called with 0 or less time (and not AI move). Triggering timeout.`);
this.isManuallyPausedState = false; // Сбрасываем флаг
this._clearInternalTimers(); // Убедимся, что все остановлено
this.isCurrentlyRunning = false;
if (this.onTimeoutCallback) {
this.onTimeoutCallback();
}
return;
}
// Сбрасываем флаг ручной паузы и запускаем таймер с сохраненным состоянием
this.isManuallyPausedState = false;
this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs); // `start` теперь правильно обработает customRemainingTimeMs
}
clear() {
console.log(`[TurnTimer ${this.gameId}] CLEAR called. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`);
this._clearInternalTimers();
this.isCurrentlyRunning = false;
// При полном clear сбрасываем и ручную паузу, т.к. таймер полностью останавливается.
// `pause` использует этот метод, но затем сам выставляет isManuallyPausedState = true.
this.isManuallyPausedState = false;
this.segmentDurationMs = 0; // Сбрасываем сохраненную длительность
this.segmentStartTimeMs = 0;
// Опционально: уведомить клиента, что таймер остановлен (например, null или 0)
// if (this.onTickCallback) {
// this.onTickCallback(null, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true (т.к. он остановлен)
// }
// console.log(`[TurnTimer ${this.gameId}] Cleared. Was running: ${wasPreviouslyRunning}. PreservePaused: ${preserveManuallyPausedFlag}`);
}
/**
* Проверяет, активен ли таймер в данный момент (идет ли отсчет).
* @returns {boolean}
*/
isActive() {
return this.isRunning;
// Таймер активен, если он isCurrentlyRunning и не на ручной паузе
return this.isCurrentlyRunning && !this.isManuallyPausedState;
}
/**
* Проверяет, был ли таймер приостановлен вручную вызовом pause().
* @returns {boolean}
*/
isPaused() {
return this.isManuallyPaused;
isPaused() { // Возвращает, находится ли таймер в состоянии ручной паузы
return this.isManuallyPausedState;
}
// Этот геттер больше не нужен в таком виде, т.к. isConfiguredForAiMove хранит это состояние
// get isAiCurrentlyMakingMove() {
// return this.isConfiguredForAiMove && !this.isCurrentlyRunning;
// }
}
module.exports = TurnTimer;