From 59ac3520f13be33ae4bf97bfbcad4efcb27ea24a Mon Sep 17 00:00:00 2001 From: PsiMagistr Date: Thu, 29 May 2025 13:33:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=B8=D1=82=D1=83=D0=B0=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BA=D0=BA=D0=BE=D0=BD=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/gameplay.js | 227 ++++++--- public/js/main.js | 174 +++---- server/game/GameManager.js | 270 +++++----- server/game/instance/GameInstance.js | 259 ++++++---- .../game/instance/PlayerConnectionHandler.js | 468 +++++++++++------- server/game/instance/TurnTimer.js | 408 ++++++++------- 6 files changed, 1022 insertions(+), 784 deletions(-) diff --git a/public/js/gameplay.js b/public/js/gameplay.js index d86757d..95110dd 100644 --- a/public/js/gameplay.js +++ b/public/js/gameplay.js @@ -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(); } \ No newline at end of file diff --git a/public/js/main.js b/public/js/main.js index f366a17..111dc69 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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 = '

Доступные PvP игры:

Загрузка...

'; 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.'); }); \ No newline at end of file diff --git a/server/game/GameManager.js b/server/game/GameManager.js index 17d306e..f16b30b 100644 --- a/server/game/GameManager.js +++ b/server/game/GameManager.js @@ -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: 'Ваша игровая сессия была завершена из-за ошибки. Пожалуйста, войдите снова.' }); } diff --git a/server/game/instance/GameInstance.js b/server/game/instance/GameInstance.js index 9b5a239..c6cd72d 100644 --- a/server/game/instance/GameInstance.js +++ b/server/game/instance/GameInstance.js @@ -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) { diff --git a/server/game/instance/PlayerConnectionHandler.js b/server/game/instance/PlayerConnectionHandler.js index 44e7892..1b15d06 100644 --- a/server/game/instance/PlayerConnectionHandler.js +++ b/server/game/instance/PlayerConnectionHandler.js @@ -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 }; } diff --git a/server/game/instance/TurnTimer.js b/server/game/instance/TurnTimer.js index 2da77dc..2274458 100644 --- a/server/game/instance/TurnTimer.js +++ b/server/game/instance/TurnTimer.js @@ -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; \ No newline at end of file