// /server_modules/gameInstance.js const { v4: uuidv4 } = require('uuid'); // Убедитесь, что uuidv4 установлен: npm install uuid // Removed the self-import: const GameInstance = require('./gameInstance'); // Не нужно импортировать себя const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient и данных персонажей const GAME_CONFIG = require('./config'); // Нужен для GAME_CONFIG.PLAYER_ID и других констант const serverGameLogic = require('./gameLogic'); // Подключаем игровую логику class GameInstance { // ДОБАВЛЕН АРГУМЕНТ gameManager В КОНСТРУКТОР constructor(gameId, io, mode = 'ai', gameManager) { this.id = gameId; this.io = io; // Ссылка на Socket.IO сервер для широковещательных рассылок this.mode = mode; // 'ai' или 'pvp' // players теперь { socket.id: { id: 'player'/'opponent', socket: socketObject, chosenCharacterKey?: 'elena'/'almagest', identifier: userId|socketId } } this.players = {}; // playerSockets { 'player': socketObject, 'opponent': socketObject } - для быстрого доступа к сокету по роли // Этот маппинг хранит *текущие* сокеты. При переподключении ссылка обновляется в GameManager. this.playerSockets = {}; this.playerCount = 0; // Количество подключенных сокетов (активных игроков) в игре this.gameState = null; // Хранит текущее состояние игры (HP, ресурсы, эффекты, чей ход и т.д.) this.aiOpponent = (mode === 'ai'); // Флаг, является ли оппонент AI this.logBuffer = []; // Буфер для сообщений лога боя (ожидающих отправки клиентам) // Ключи персонажей для слотов 'player' и 'opponent' в текущей игре // Определяются один раз при инициализации gameState this.playerCharacterKey = null; // Ключ персонажа в слоте 'player' (Елена или Альмагест) this.opponentCharacterKey = null; // Ключ персонажа в слоте 'opponent' (Балард, Елена или Альмагест) this.ownerIdentifier = null; // Идентификатор создателя игры (userId или socketId) // СОХРАНЯЕМ ССЫЛКУ НА GAMEMANAGER this.gameManager = gameManager; if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') { console.error(`[Game ${this.id}] CRITICAL ERROR: GameInstance created without valid GameManager reference! Cleanup will fail.`); // Если GameManager не передан или не имеет метода _cleanupGame, логика очистки не будет работать. // В продакшене, возможно, стоит выбрасывать ошибку или завершать процесс. } } /** * Добавляет игрока в игру. * Вызывается GameManager при создании новой игры или присоединении второго игрока. * @param {object} socket - Объект сокета игрока. * @param {string} [chosenCharacterKey='elena'] - Выбранный игроком персонаж (только для первого игрока в PvP). * @param {string|number} identifier - Идентификатор пользователя (userId или socketId). * @returns {boolean} true, если игрок успешно добавлен, иначе false. */ addPlayer(socket, chosenCharacterKey = 'elena', identifier) { // <--- ДОБАВЛЕН identifier // Проверка, не пытается ли сокет, который уже есть в этой игре, добавиться снова if (this.players[socket.id]) { console.warn(`[Game ${this.id}] Игрок ${identifier} (сокет ${socket.id}) попытался присоединиться к игре, в которой его сокет уже зарегистрирован.`); // Возможно, это быстрый повторный запрос. // Отправляем gameError и возвращаем false. socket.emit('gameError', { message: 'Ваш сокет уже зарегистрирован в этой игре.' }); return false; } // Проверка, не пытается ли пользователь, который уже есть в этой игре (но с другим сокетом), добавиться через addPlayer // Этого не должно случаться, если GameManager.handleRequestGameState корректно обновляет сокет для существующего identifier. // AddPlayer вызывается только для *нового* игрока в GameInstance. const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier); if (existingPlayerByIdentifier) { console.warn(`[Game ${this.id}] Игрок с идентификатором ${identifier} (${socket.id}) уже находится в игре под сокетом ${existingPlayerByIdentifier.socket.id}. Игнорируем добавление.`); socket.emit('gameError', { message: 'Вы уже находитесь в этой игре под другим подключением.' }); return false; } if (this.playerCount >= 2) { socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); return false; } let assignedPlayerId; // 'player' или 'opponent' (технический ID слота в рамках этой игры) let actualCharacterKey; // 'elena', 'almagest' (balard только для AI-опонента) if (this.mode === 'ai') { if (this.playerCount > 0) { // AI игра только для одного игрока socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); return false; } assignedPlayerId = GAME_CONFIG.PLAYER_ID; actualCharacterKey = 'elena'; // В AI режиме игрок всегда Елена this.ownerIdentifier = identifier; // Сохраняем идентификатор создателя AI игры } else { // PvP режим if (this.playerCount === 0) { // Первый игрок в PvP assignedPlayerId = GAME_CONFIG.PLAYER_ID; actualCharacterKey = (chosenCharacterKey === 'almagest') ? 'almagest' : 'elena'; this.ownerIdentifier = identifier; // Сохраняем идентификатор создателя PvP игры } else { // Segundo jugador en PvP assignedPlayerId = GAME_CONFIG.OPPONENT_ID; const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); // Находим первого игрока по его роли // Segundo jugador recibe automáticamente un personaje "espejo" del primero. actualCharacterKey = (firstPlayerInfo?.chosenCharacterKey === 'elena') ? 'almagest' : 'elena'; // El identificador de ownerIdentifier sigue siendo el del creador de la partida (el primer jugador). } } // Добавляем информацию об игроке в map players, ключом является socket.id this.players[socket.id] = { id: assignedPlayerId, // Технический ID слота ('player' или 'opponent') socket: socket, // Объект сокета chosenCharacterKey: actualCharacterKey, // Выбранный/назначенный персонаж identifier: identifier // <--- ДОБАВЛЕНО: Идентификатор пользователя }; this.playerSockets[assignedPlayerId] = socket; // Связываем роль в игре с объектом сокета this.playerCount++; // Увеличиваем количество активных сокетов socket.join(this.id); // Присоединяем сокет к комнате игры Socket.IO const characterData = this._getCharacterBaseData(actualCharacterKey); console.log(`[Game ${this.id}] Игрок ${identifier} (сокет ${socket.id}) (${characterData?.name || 'Неизвестно'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Всего игроков: ${this.playerCount}. Owner: ${this.ownerIdentifier || 'N/A'}`); // Логика старта игры перемещена в GameManager после вызова addPlayer. // Логика уведомления "Ожидание оппонента" тоже перемещена в GameManager. return true; // Игрок успешно добавлен } /** * Удаляет игрока из игры (например, при дисконнекте сокета). * Вызывается GameManager при событии 'disconnect'. * @param {string} socketId - ID сокета игрока, который отключился. */ removePlayer(socketId) { // <--- Принимает socketId const playerInfo = this.players[socketId]; // Находим информацию об игроке по socketId if (playerInfo) { const playerRole = playerInfo.id; // 'player' or 'opponent' const identifierOfLeavingPlayer = playerInfo.identifier; // Идентификатор уходящего игрока const characterKeyOfLeavingPlayer = playerInfo.chosenCharacterKey; const characterData = this._getCharacterBaseData(characterKeyOfLeavingPlayer); console.log(`[Game ${this.id}] Игрок ${identifierOfLeavingPlayer} (сокет: ${socketId}, роль: ${playerRole}, персонаж: ${characterKeyOfLeavingPlayer || 'N/A'}) покинул игру.`); // Удаляем сокет из комнаты Socket.IO (если сокет объект еще валиден) if (playerInfo.socket) { // Проверяем, что это именно тот сокет, который отключился, // чтобы не пытаться удалить сокет, если он уже был обновлен на новый при переподключении. const actualSocket = this.io.sockets.sockets.get(socketId); if (actualSocket && actualSocket.id === socketId) { // Убедимся, что это тот же сокет actualSocket.leave(this.id); } else if (playerInfo.socket.id === socketId) { // Если сокет объект в playerInfo совпадает с socketId // Это может произойти, если Socket.IO уже удалил сокет из io.sockets.sockets, // но ссылка в playerInfo еще существует. // Попробуем выполнить leave на старом объекте сокета (может не работать, но безопасно) try { playerInfo.socket.leave(this.id); } catch(e) { console.warn(`[Game ${this.id}] Error leaving room for old socket ${socketId}: ${e.message}`); } } } // Удаляем запись об игроке из map players по socketId delete this.players[socketId]; this.playerCount--; // Уменьшаем количество активных сокетов (сокетов, подключенных к игре) // Удаляем ссылку на сокет из playerSockets по роли, ТОЛЬКО если она указывала на отключившийся сокет if (this.playerSockets[playerRole] && this.playerSockets[playerRole].id === socketId) { delete this.playerSockets[playerRole]; } // Логика завершения игры при дисконнекте полностью в GameManager.handleDisconnect. // GameManager проверит playerCount после вызова removePlayer и примет решение. // GameManager также управляет userIdentifierToGameId и pendingPvPGames. // Если игра была активна и еще не окончена, GameManager вызовет endGameDueToDisconnect - Moved } else { console.warn(`[Game ${this.id}] removePlayer called for unknown socketId ${socketId}.`); } } /** * Инициализирует или перезапускает состояние игры. * Вызывается GameManager, когда игра полностью готова (PvP 2 игрока, AI 1 игрок) ИЛИ * для частичной инициализации PvP игры с 1 игроком. * @returns {boolean} true, если оба бойца определены и gameState создан корректно, иначе false. */ initializeGame() { console.log(`[Game ${this.id}] Initializing game state. Mode: ${this.mode}. Current PlayerCount: ${this.playerCount}.`); // Определяем ключи персонажей для слотов 'player' и 'opponent' в этой игре // В AI режиме всегда Елена vs Балард if (this.mode === 'ai' && this.playerCount === 1) { this.playerCharacterKey = 'elena'; this.opponentCharacterKey = 'balard'; } else if (this.mode === 'pvp' && this.playerCount === 2) { // PvP режим с 2 игроками // Находим информацию об игроках по их ролям const player1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); const player2Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); // Первый игрок (в слоте PLAYER_ID) сохраняет свой выбранный персонаж this.playerCharacterKey = player1Info?.chosenCharacterKey || 'elena'; // Фоллбэк // Второй игрок (в слоте OPPONENT_ID) имеет зеркального персонажа const expectedOpponentKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; // Убеждаемся, что у второго игрока действительно назначен этот персонаж. // В addPlayer мы уже стараемся назначить правильно, но тут финальное подтверждение. if (player2Info && player2Info.chosenCharacterKey !== expectedOpponentKey) { console.warn(`[Game ${this.id}] initializeGame: Expected opponent character ${expectedOpponentKey} but player2Info had ${player2Info.chosenCharacterKey}.`); // Не меняем chosenCharacterKey здесь, это данные игрока. // Просто используем expectedOpponentKey для определения персонажа в слоте gameState. } this.opponentCharacterKey = expectedOpponentKey; // Устанавливаем ключ для слота } else if (this.mode === 'pvp' && this.playerCount === 1) { // PvP игра ожидает второго игрока. Инициализируем gameState только для Player 1. const player1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); this.playerCharacterKey = player1Info?.chosenCharacterKey || 'elena'; this.opponentCharacterKey = null; // Оппонент еще не определен } else { console.error(`[Game ${this.id}] Unexpected state for initialization! Mode: ${this.mode}, PlayerCount: ${this.playerCount}. Cannot initialize gameState.`); this.gameState = null; // Не создаем gameState return false; // <-- Возвращаем false при ошибке } console.log(`[Game ${this.id}] Finalizing characters for gameState - Player Slot ('${GAME_CONFIG.PLAYER_ID}'): ${this.playerCharacterKey}, Opponent Slot ('${GAME_CONFIG.OPPONENT_ID}'): ${this.opponentCharacterKey || 'N/A (Waiting)'}`); const playerBase = this._getCharacterBaseData(this.playerCharacterKey); const playerAbilities = this._getCharacterAbilities(this.playerCharacterKey); let opponentBase = null; let opponentAbilities = null; // Загружаем данные оппонента, только если он определен (т.е. PvP игра с 2 игроками или AI игра) const isOpponentDefined = !!this.opponentCharacterKey; if (isOpponentDefined) { opponentBase = this._getCharacterBaseData(this.opponentCharacterKey); opponentAbilities = this._getCharacterAbilities(this.opponentCharacterKey); } // Проверяем наличие данных для создания базового gameState if (!playerBase || !playerAbilities) { console.error(`[Game ${this.id}] CRITICAL ERROR: initializeGame - Failed to load player character data! PlayerKey: ${this.playerCharacterKey}`); this.logBuffer = []; // Очищаем лог this.addToLog('Критическая ошибка сервера при инициализации персонажа игрока!', GAME_CONFIG.LOG_TYPE_SYSTEM); // Уведомляем игроков в комнате об ошибке (если есть) Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера при инициализации игры. Не удалось загрузить данные персонажа игрока.' })); this.gameState = null; // Не создаем gameState return false; // <-- Возвращаем false при ошибке } // Проверяем наличие данных оппонента, если он должен быть определен if (isOpponentDefined && (!opponentBase || !opponentAbilities)) { console.error(`[Game ${this.id}] CRITICAL ERROR: initializeGame - Failed to load opponent character data! OpponentKey: ${this.opponentCharacterKey}`); this.logBuffer = []; this.addToLog('Критическая ошибка сервера при инициализации персонажа оппонента!', GAME_CONFIG.LOG_TYPE_SYSTEM); Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера при инициализации игры. Не удалось загрузить данные персонажа оппонента.' })); this.gameState = null; return false; // <-- Возвращаем false при ошибке } // Проверка, если оппонент определен, что у него есть maxHp > 0 (для startGame) if (isOpponentDefined && (!opponentBase.maxHp || opponentBase.maxHp <= 0)) { console.error(`[Game ${this.id}] CRITICAL ERROR: initializeGame - Opponent has invalid maxHp (${opponentBase.maxHp}) for key ${this.opponentCharacterKey}.`); this.logBuffer = []; this.addToLog('Критическая ошибка сервера: некорректные данные оппонента!', GAME_CONFIG.LOG_TYPE_SYSTEM); Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера: некорректные данные персонажа оппонента.' })); this.gameState = null; return false; // <-- Возвращаем false при ошибке } // Создаем начальное gameState this.gameState = { player: { // Состояние игрока в слоте 'player' id: GAME_CONFIG.PLAYER_ID, characterKey: this.playerCharacterKey, name: playerBase.name, currentHp: playerBase.maxHp, maxHp: playerBase.maxHp, currentResource: playerBase.maxResource, maxResource: playerBase.maxResource, resourceName: playerBase.resourceName, attackPower: playerBase.attackPower, isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {} }, opponent: { // Состояние оппонента в слоте 'opponent'. Если оппонент еще не определен (PvP ожидание), будут плейсхолдеры. id: GAME_CONFIG.OPPONENT_ID, characterKey: this.opponentCharacterKey, name: opponentBase?.name || 'Ожидание игрока...', currentHp: opponentBase?.maxHp || 1, maxHp: opponentBase?.maxHp || 1, // HP > 0, чтобы не триггерить победу сразу, если оппонент не определен currentResource: opponentBase?.maxResource || 0, maxResource: opponentBase?.maxResource || 0, resourceName: opponentBase?.resourceName || 'Ресурс', attackPower: opponentBase?.attackPower || 0, isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {}, // Специальные кулдауны для Баларда (AI) - undefined для других персонажей silenceCooldownTurns: (this.opponentCharacterKey === 'balard') ? 0 : undefined, manaDrainCooldownTurns: (this.opponentCharacterKey === 'balard') ? 0 : undefined, }, // Случайный первый ход только если оба игрока (бойца) определены (т.е. игра готова к старту) isPlayerTurn: isOpponentDefined ? Math.random() < 0.5 : true, // Если ожидаем, всегда ход первого игрока (кто создал) isGameOver: false, turnNumber: 1, gameMode: this.mode }; // Инициализация кулдаунов способностей для обоих бойцов (если они определены) // Для игрока (player slot) if (playerAbilities) { playerAbilities.forEach(ability => { if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { this.gameState.player.abilityCooldowns[ability.id] = 0; } }); } else { console.error(`[Game ${this.id}] Cannot initialize player abilities cooldowns: playerAbilities is null.`); } // Для оппонента (opponent slot), только если он определен if (isOpponentDefined && opponentAbilities) { opponentAbilities.forEach(ability => { let cd = 0; if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { cd = ability.cooldown; } if (this.opponentCharacterKey === 'balard') { // Специальные КД для Баларда if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { cd = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; } else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { cd = ability.internalCooldownValue; } } if (cd > 0) { this.gameState.opponent.abilityCooldowns[ability.id] = 0; } }); } else if (isOpponentDefined) { console.warn(`[Game ${this.id}] Cannot initialize opponent abilities cooldowns: opponentAbilities is null for key ${this.opponentCharacterKey}.`); } // Добавляем начальное сообщение в лог и стартовую насмешку, только если игра полностью готова к старту (оба бойца определены) if (isOpponentDefined) { // Проверяем, определен ли оппонент (значит, игра полностью готова) const isRestart = this.logBuffer.length > 0; // Сейчас этот буфер всегда пустой при инициализации новой игры this.logBuffer = []; // Очищаем лог перед новой игрой this.addToLog(isRestart ? '⚔️ Игра перезапущена! ⚔️' : '⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); // Добавляем стартовую насмешку игрока (в слоте 'player'), если она есть const playerCharKey = this.gameState.player.characterKey; if (playerCharKey === 'elena' || playerCharKey === 'almagest') { // getRandomTaunt теперь принимает gameDataForLogic и gameState const startTaunt = serverGameLogic.getRandomTaunt(playerCharKey, 'battleStart', {}, GAME_CONFIG, gameData, this.gameState); if (startTaunt !== "(Молчание)") this.addToLog(`${this.gameState.player.name}: "${startTaunt}"`, GAME_CONFIG.LOG_TYPE_INFO); } } // Если оппонент не определен (PvP ожидание),gameState инициализируется частично, но не считается готовым к старту. // LogBuffer не очищается, waitingForOpponent отправлено клиенту. console.log(`[Game ${this.id}] Game state initialized. isGameOver: ${this.gameState?.isGameOver}. First turn (if ready): ${this.gameState?.isPlayerTurn ? this.gameState?.player?.name : (this.gameState?.opponent?.name || 'Оппонент')}. Opponent Defined (Ready for Start): ${isOpponentDefined}`); // Возвращаем флаг, готово ли gameState к началу игры (т.е. определены ли оба бойца) return isOpponentDefined; // <-- Важно: возвращаем, готова ли игра к старту } /** * Запускает игру (отправляет начальное состояние клиентам и начинает первый ход). * Вызывается GameManager после успешной инициализации gameState и наличия 2 игроков (PvP) или 1 (AI), * и если initializeGame вернул true. */ startGame() { // Проверяем, что игра полностью готова к запуску (gameState инициализирован с обоими персонажами) if (!this.gameState || !this.gameState.player || !this.gameState.opponent || !this.opponentCharacterKey || this.gameState.opponent.name === 'Ожидание игрока...' || !this.gameState.opponent.maxHp || this.gameState.opponent.maxHp <= 0) { console.error(`[Game ${this.id}] startGame: Game state is not fully ready for start. Aborting.`); // initializeGame должен был вернуть false и добавить ошибку в лог. // GameManager должен был вызвать cleanupGame при ошибке initializeGame. return; } // Убеждаемся, что у нас есть 1 или 2 игрока (сокета) - GameManager должен гарантировать это перед вызовом startGame if (this.playerCount === 0 || (this.mode === 'pvp' && this.playerCount === 1)) { console.warn(`[Game ${this.id}] startGame called with insufficient players (${this.playerCount}). Mode: ${this.mode}. Aborting start.`); return; } console.log(`[Game ${this.id}] Starting game. Broadcasting 'gameStarted' to players. isGameOver: ${this.gameState.isGameOver}`); // Получаем данные персонажей для отправки клиентам const playerCharDataForSlotPlayer = this._getCharacterData(this.playerCharacterKey); const opponentCharDataForSlotOpponent = this._getCharacterData(this.opponentCharacterKey); if (!playerCharDataForSlotPlayer || !opponentCharDataForSlotOpponent) { console.error(`[Game ${this.id}] CRITICAL ERROR: startGame - Failed to load character data! PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}`); this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при старте игры (не удалось загрузить данные персонажей).' }); // Критическая ошибка, игра должна быть очищена if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { this.gameManager._cleanupGame(this.id, 'start_data_load_failed'); } return; } // Отправляем каждому игроку его персональные данные для игры и начальное состояние Object.values(this.players).forEach(playerInfo => { // Проверяем, что сокет игрока еще подключен if (playerInfo.socket && playerInfo.socket.connected) { let dataForThisClient; // playerInfo.id - это технический ID слота этого конкретного клиента в рамках игры ('player' или 'opponent') if (playerInfo.id === GAME_CONFIG.PLAYER_ID) { // Этот клиент играет за слот 'player' dataForThisClient = { gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, playerBaseStats: playerCharDataForSlotPlayer.baseStats, opponentBaseStats: opponentCharDataForSlotOpponent.baseStats, playerAbilities: playerCharDataForSlotPlayer.abilities, opponentAbilities: opponentCharDataForSlotOpponent.abilities, log: this.consumeLogBuffer(), // Игрок в слоте player получает весь накопленный лог clientConfig: { ...GAME_CONFIG } // Копия конфига для клиента }; } else { // Этот клиент играет за слот 'opponent' (только в PvP) dataForThisClient = { gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, // Меняем местами статы и абилки, чтобы клиент видел себя как 'player', а противника как 'opponent' playerBaseStats: opponentCharDataForSlotOpponent.baseStats, opponentBaseStats: playerCharDataForSlotPlayer.baseStats, playerAbilities: opponentCharDataForSlotOpponent.abilities, opponentAbilities: playerCharDataForSlotPlayer.abilities, log: this.consumeLogBuffer(), // Второй игрок тоже получает весь накопленный лог clientConfig: { ...GAME_CONFIG } }; } playerInfo.socket.emit('gameStarted', dataForThisClient); console.log(`[Game ${this.id}] Sent gameStarted to ${playerInfo.identifier} (socket ${playerInfo.socket.id}).`); } else { console.warn(`[Game ${this.id}] Player ${playerInfo.identifier} (socket ${playerInfo.socket?.id}) is disconnected. Cannot send gameStarted.`); // Если один из игроков отключен в момент старта PvP игры, она должна быть завершена. // GameManager.handleDisconnect уже должен был вызвать endGameDueToDisconnect. } }); // Добавляем лог о начале хода после отправки gameStarted, чтобы лог был актуальным const firstTurnActorState = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${firstTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); // Отправляем этот лог всем клиентам this.broadcastLogUpdate(); // Если ход AI, запускаем его логику с небольшой задержкой // AI Балард всегда в слоте 'opponent' в AI режиме, и его characterKey === 'balard' if (!this.gameState.isPlayerTurn && this.aiOpponent && this.opponentCharacterKey === 'balard') { console.log(`[Game ${this.id}] AI (Балард) ходит первым. Запускаем AI turn.`); // Небольшая задержка для старта AI хода, чтобы клиент успел отрисовать setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN || 1200); } else { // Ход реального игрока (первого или второго в PvP) // Игрок, чей ход, получит индикатор хода и активные кнопки через gameStateUpdate в клиентском ui.js console.log(`[Game ${this.id}] Ход реального игрока ${firstTurnActorState.name} (роль: ${firstTurnActorState.id}).`); } } /** * Обрабатывает действие игрока, полученное от клиента. * Вызывается GameManager при событии 'playerAction'. * @param {string} requestingSocketId - ID сокета игрока, запросившего действие. * @param {object} actionData - Данные о действии ({ actionType: 'attack' | 'ability', abilityId?: string }). */ processPlayerAction(requestingSocketId, actionData) { // Проверяем, что игра активна и не окончена if (!this.gameState || this.gameState.isGameOver) { const playerSocket = this.io.sockets.sockets.get(requestingSocketId); if (playerSocket) playerSocket.emit('gameError', { message: 'Игра уже завершена или неактивна.' }); return; } // Находим информацию об игроке по socketId (используем текущий socketId, т.к. он пришел в запросе) const actingPlayerInfo = this.players[requestingSocketId]; if (!actingPlayerInfo) { // Этого не должно происходить, если GameManager корректно ищет игру по идентификатору и передает // текущий socketId. Но если произошло, возможно, сокет отправил действие после удаления из players. console.error(`[Game ${this.id}] Action from socket ${requestingSocketId} not found in players map.`); const playerSocket = this.io.sockets.sockets.get(requestingSocketId); if (playerSocket && playerSocket.connected) playerSocket.disconnect(true); // Отключаем подозрительный сокет return; } const actingPlayerRole = actingPlayerInfo.id; // 'player' или 'opponent' (технический ID слота) const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || (!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID); if (!isCorrectTurn) { // Неправильный ход - просто игнорируем действие. Клиентский UI должен предотвращать такое. // actingPlayerInfo.socket.emit('gameError', { message: "Сейчас не ваш ход!" }); // Можно отправлять ошибку, но это спамит лог console.warn(`[Game ${this.id}] Action from ${actingPlayerInfo.identifier} (socket ${requestingSocketId}): Not their turn.`); return; // Игнорируем действие } const attackerState = this.gameState[actingPlayerRole]; // Состояние того, кто ходит (в gameState по роли) const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const defenderState = this.gameState[defenderRole]; // Состояние противника (в gameState по роли) // Получаем базовые данные персонажей для логики const attackerData = this._getCharacterData(attackerState.characterKey); const defenderData = this._getCharacterData(defenderState.characterKey); if (!attackerData || !defenderData) { console.error(`[Game ${this.id}] CRITICAL ERROR: processPlayerAction - Failed to load character data! AttackerKey: ${attackerState.characterKey}, DefenderKey: ${defenderState.characterKey}`); this.addToLog('Критическая ошибка сервера при обработке действия (не найдены данные персонажа)!', GAME_CONFIG.LOG_TYPE_SYSTEM); this.broadcastLogUpdate(); // Уведомляем клиентов об ошибке через лог // Критическая ошибка, игра должна быть очищена if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { this.gameManager._cleanupGame(this.id, 'action_data_load_failed'); } return; } let actionValid = true; // Флаг валидности действия (логической, не очередности хода) // --- Обработка базовой атаки --- if (actionData.actionType === 'attack') { // Добавляем насмешку при базовой атаке (если есть такие для говорящего) // Насмешки при атаке могут быть специфичны для говорящего и противника // getRandomTaunt теперь принимает gameDataForLogic и gameState const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'basicAttack', {}, GAME_CONFIG, gameData, this.gameState); if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); // Выполняем логику атаки через gameLogic // performAttack теперь принимает gameDataForLogic и gameState serverGameLogic.performAttack( attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData ); // Логика для "Силы Природы" и аналогов - бафф применяется после атаки // Проверяем, есть ли активный "отложенный" бафф (isDelayed) на атакующем const delayedAttackBuffEffect = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); if (delayedAttackBuffEffect && !delayedAttackBuffEffect.justCast) { // Если эффект активен и не только что наложен в этом ходу const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerData.baseStats.maxResource - attackerState.currentResource); if (actualRegen > 0) { attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen); this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${delayedAttackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); } } // --- Обработка использования способности --- } else if (actionData.actionType === 'ability' && actionData.abilityId) { const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId); if (!ability) { actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); console.warn(`[Game ${this.id}] Игрок ${actingPlayerInfo.identifier} (сокет ${requestingSocketId}) попытался использовать неизвестную способность ID: ${actionData.abilityId}.`); return; // Неизвестная способность } // Проверки валидности использования способности (ресурс, КД, безмолвие и т.д.) // Эти проверки дублируют клиентские, но нужны на сервере для безопасности const hasEnoughResource = attackerState.currentResource >= ability.cost; const isOnCooldown = (attackerState.abilityCooldowns?.[ability.id] || 0) > 0; const isCasterFullySilenced = attackerState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0); const isAbilitySpecificallySilenced = attackerState.disabledAbilities?.some(dis => dis.abilityId === ability.id && dis.turnsLeft > 0); const isSilenced = isCasterFullySilenced || isAbilitySpecificallySilenced; // Специальные КД для Баларда (AI) - эти поля undefined для других персонажей let isOnSpecialCooldown = false; if (attackerState.characterKey === 'balard') { if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && attackerState.silenceCooldownTurns !== undefined && attackerState.silenceCooldownTurns > 0) isOnSpecialCooldown = true; if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && attackerState.manaDrainCooldownTurns !== undefined && attackerState.manaDrainCooldownTurns > 0) isOnSpecialCooldown = true; } // Нельзя кастовать бафф, если он уже активен (для баффов, которые не должны стакаться) const isBuffAlreadyActive = ability.type === GAME_CONFIG.ACTION_TYPE_BUFF && attackerState.activeEffects.some(e => e.id === ability.id); // Нельзя кастовать дебафф на цель, если он уже на ней (для определенных дебаффов) const isTargetedDebuff = ability.id === GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF; const effectIdForDebuff = 'effect_' + ability.id; const isDebuffAlreadyOnTarget = isTargetedDebuff && defenderState.activeEffects.some(e => e.id === effectIdForDebuff); // Проверка всех условий if (!hasEnoughResource) { this.addToLog(`${attackerState.name} пытается применить "${ability.name}", но не хватает ${attackerState.resourceName}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } if (actionValid && (isOnCooldown || isOnSpecialCooldown)) { this.addToLog(`"${ability.name}" еще на перезарядке.`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } if (actionValid && isSilenced) { this.addToLog(`${attackerState.name} не может использовать способности из-за безмолвия!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } if (actionValid && isBuffAlreadyActive) { this.addToLog(`Эффект "${ability.name}" уже активен!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } if (actionValid && isDebuffAlreadyOnTarget) { this.addToLog(`Эффект "${ability.name}" уже наложен на ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } // Если все проверки пройдены, действие считается логически валидным if (actionValid) { attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); // Расходуем ресурс // Добавляем насмешку ПРИ КАСТЕ СПОСОБНОСТИ (если есть такие для говорящего) // getRandomTaunt теперь принимает gameDataForLogic и gameState const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'selfCastAbility', { abilityId: ability.id }, GAME_CONFIG, gameData, this.gameState); if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); // Применяем эффекты способности через gameLogic // applyAbilityEffect теперь принимает gameDataForLogic и gameState serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); // Установка кулдауна способности (после успешного каста) let baseCooldown = 0; if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { baseCooldown = ability.cooldown; } // Специальные внутренние КД для Баларда - перебивают общий КД, если заданы if (attackerState.characterKey === 'balard') { if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; // Специальный КД становится актуальным кулдауном } else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue; // Специальный КД становится актуальным кулдауном } } if (baseCooldown > 0 && attackerState.abilityCooldowns) { // Устанавливаем кулдаун. Добавляем +1, т.к. кулдаун уменьшится в конце этого хода. attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; } } } else { // Неизвестный тип действия actingPlayerInfo.socket.emit('gameError', { message: `Неизвестный тип действия: ${actionData?.actionType}` }); console.warn(`[Game ${this.id}] Получен неизвестный тип действия от ${actingPlayerInfo.identifier} (сокет ${requestingSocketId}): ${actionData?.actionType}`); actionValid = false; // Помечаем как невалидное } // --- Завершение хода --- // Проверяем конец игры после выполнения действия if (this.checkGameOver()) { this.broadcastGameStateUpdate(); // Отправляем финальное состояние и лог всем // Очистка игры теперь происходит ВНУТРИ checkGameOver через вызов _notifyGameEnded return; // Если игра окончена, не переключаем ход } // Если действие было логически валидным и игра не окончена, переключаем ход с задержкой if (actionValid) { console.log(`[Game ${this.id}] Player action valid. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`); setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); } else { // Если действие невалидно, просто обновляем лог (если что-то было добавлено), ход не переключается console.log(`[Game ${this.id}] Player action invalid. Broadcasting log update.`); this.broadcastLogUpdate(); } } /** * Переключает ход на следующего бойца, обрабатывает эффекты конца хода и кулдауны. */ switchTurn() { // Проверяем, что игра активна и не окончена if (!this.gameState || this.gameState.isGameOver) return; // Не переключаем ход, если игра окончена или состояние некорректно // Определяем бойца, чей ход только что закончился const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const endingTurnActorState = this.gameState[endingTurnActorRole]; const endingTurnCharacterData = this._getCharacterData(endingTurnActorState.characterKey); // Получаем данные персонажа // Проверяем наличие данных для обработки эффектов и КД if (!endingTurnCharacterData) { console.error(`[Game ${this.id}] SwitchTurn Error: No character data found for ending turn actor role ${endingTurnActorRole} with key ${endingTurnActorState.characterKey}. Cannot process end-of-turn effects.`); // Критическая ошибка, игра должна быть очищена if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { this.gameManager._cleanupGame(this.id, 'switch_turn_data_error'); } return; } else { // Обработка активных эффектов в конце хода (DoT, HoT, истечение баффов/debuffs) для бойца, чей ход закончился // processEffects теперь принимает gameDataForLogic и gameState serverGameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnCharacterData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); // Обновляем статус блока для обоих бойцов после обработки эффектов (т.к. эффекты блока могли закончиться) // Важно обновить статус для ОБОИХ, т.к. эффекты на одном могут зависеть от статуса блока другого. serverGameLogic.updateBlockingStatus(this.gameState.player); serverGameLogic.updateBlockingStatus(this.gameState.opponent); // Уменьшение общих кулдаунов способностей для бойца, чей ход закончился if (endingTurnActorState.abilityCooldowns) { // processPlayerAbilityCooldowns теперь принимает gameDataForLogic (хотя он там не используется напрямую) serverGameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnCharacterData.abilities, endingTurnActorState.name, this.addToLog.bind(this)); } // Специальные КД для Баларда (если он в слоте, который сейчас заканчивает ход) if (endingTurnActorState.characterKey === 'balard') { if (endingTurnActorState.silenceCooldownTurns !== undefined && endingTurnActorState.silenceCooldownTurns > 0) endingTurnActorState.silenceCooldownTurns--; if (endingTurnActorState.manaDrainCooldownTurns !== undefined && endingTurnActorState.manaDrainCooldownTurns > 0) endingTurnActorState.manaDrainCooldownTurns--; } // Уменьшение длительности заглушения конкретных способностей (если это ход того, кто был заглушен) if (endingTurnActorState.disabledAbilities?.length > 0) { const charAbilitiesForDisabledCheck = this._getCharacterAbilities(endingTurnActorState.characterKey); // Список абилок для поиска по ID if (charAbilitiesForDisabledCheck) { // processDisabledAbilities теперь принимает gameDataForLogic (хотя он там не используется напрямую) serverGameLogic.processDisabledAbilities(endingTurnActorState.disabledAbilities, charAbilitiesForDisabledCheck, endingTurnActorState.name, this.addToLog.bind(this)); } else { console.warn(`[Game ${this.id}] SwitchTurn: Cannot process disabledAbilities for ${endingTurnActorState.name}: character abilities data not found.`); } } } // Проверяем конец игры после обработки эффектов конца хода if (this.checkGameOver()) { this.broadcastGameStateUpdate(); // Отправляем финальное состояние и лог всем // Очистка игры теперь происходит ВНУТРИ checkGameOver через вызов _notifyGameEnded return; // Если игра окончена, останавливаемся } // Если игра не окончена, переключаем ход на следующего бойца с задержкой this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; // Если ход переключился на первого игрока (player), увеличиваем номер хода if (this.gameState.isPlayerTurn) { this.gameState.turnNumber++; } // Определяем бойца, чей ход начинается const currentTurnActorState = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); // Отправляем обновленное состояние игры и лог всем клиентам в комнате this.broadcastGameStateUpdate(); // Если ход AI, запускаем его логику с задержкой // AI Балард всегда в слоте 'opponent' в AI режиме, и его characterKey === 'balard' if (!this.gameState.isPlayerTurn && this.aiOpponent && this.opponentCharacterKey === 'balard') { console.log(`[Game ${this.id}] Ход AI (Балард). Запускаем AI turn.`); setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN || 1200); } else { // Ход реального игрока (первого или второго в PvP) // Игрок, чей ход, получит индикатор хода и активные кнопки через gameStateUpdate в клиентском ui.js console.log(`[Game ${this.id}] Ход реального игрока ${currentTurnActorState.name} (роль: ${currentTurnActorState.id}).`); } } /** * Обрабатывает ход AI (Балард). * Вызывается из switchTurn, если следующий ход принадлежит AI. */ processAiTurn() { // Проверка: это точно ход AI Баларда и игра активна? // AI Балард только в режиме 'ai', и его characterKey в gameState должен быть 'balard'. if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent || this.gameState.opponent?.characterKey !== 'balard') { if(!this.gameState || this.gameState.isGameOver) return; // Если игра закончена, ничего не делаем // Если не ход AI или это не AI Балард, выходим (на всякий случай) // console.warn(`[Game ${this.id}] processAiTurn called when it's not AI Balard's turn or not AI mode.`); // Если AI ход по какой-то причине пропущен, переключаем ход на игрока console.warn(`[Game ${this.id}] Skipping AI turn logic (not AI Balard's turn or game not ready). Switching turn.`); this.switchTurn(); // Пропускаем AI ход и переключаем обратно return; } const attackerState = this.gameState.opponent; // AI Балард всегда в слоте 'opponent' в AI режиме const defenderState = this.gameState.player; // Игрок всегда в слоте 'player' в AI режиме // Получаем базовые данные персонажей для логики const attackerData = this._getCharacterData('balard'); // Базовые данные Баларда const defenderData = this._getCharacterData('elena'); // Базовые данные Елены (противник AI) if (!attackerData || !defenderData) { console.error(`[Game ${this.id}] CRITICAL ERROR: processAiTurn - Failed to load character data!`); this.addToLog("AI не может действовать: ошибка данных персонажа.", GAME_CONFIG.LOG_TYPE_SYSTEM); this.broadcastLogUpdate(); // Отправляем ошибку в лог // Критическая ошибка, игра должна быть очищена if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { this.gameManager._cleanupGame(this.id, 'ai_data_load_failed'); } this.switchTurn(); // Пропускаем ход AI и переключаем обратно на игрока return; } // Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены или Раскола Разума Альмагест) - повторная проверка из decideAiAction, на всякий случай const isBalardFullySilenced = attackerState.activeEffects.some( eff => eff.isFullSilence && eff.turnsLeft > 0 ); if (isBalardFullySilenced) { // AI под полным безмолвием просто атакует // Лог о безмолвии и атаке в смятении добавляется в processAiTurn перед вызовом performAttack. // decideAiAction просто возвращает действие. // if (this.logBuffer.filter(log => log.message.includes('под действием Безмолвия')).length === 0) { // Логируем только если еще не логировали в decideAiAction // this.addToLog(`😵 ${attackerState.name} под действием Безмолвия! Атакует в смятении.`, GAME_CONFIG.LOG_TYPE_EFFECT); // } serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); // Логика для "Силы Природы" и аналогов (неактуально для Баларда) const delayedAttackBuffEffect = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); if (delayedAttackBuffEffect && !delayedAttackBuffEffect.justCast) { const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerState.maxResource - attackerState.currentResource); if (actualRegen > 0) { attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen); this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${delayedAttackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); } } // После атаки под безмолвием, переключаем ход if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } // Проверяем Game Over console.log(`[Game ${this.id}] AI (Балард) attacked while silenced. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`); setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); return; // Завершаем обработку AI хода } // AI принимает решение о действии, используя логику из gameLogic // decideAiAction теперь принимает gameDataForLogic и gameState const aiDecision = serverGameLogic.decideAiAction(this.gameState, gameData, GAME_CONFIG, this.addToLog.bind(this)); // --- Выполнение выбранного AI действия --- if (aiDecision.actionType === 'attack') { // AI Балард пока не имеет специфических насмешек при базовой атаке serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); // Логика для "Силы Природы" и аналогов (неактуально для Баларда) const delayedAttackBuffEffect = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); if (delayedAttackBuffEffect && !delayedAttackBuffEffect.justCast) { const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerState.maxResource - attackerState.currentResource); if (actualRegen > 0) { attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen); this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${delayedAttackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); } } } else if (aiDecision.actionType === 'ability' && aiDecision.ability) { const ability = aiDecision.ability; // decideAiAction уже проверил ресурс, КД и специфические условия перед тем, как выбрать эту способность. // Теперь просто выполняем ее. attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); // Расходуем ресурс // Добавляем насмешку ПРИ КАСТЕ СПОСОБНОСТИ Балардом (если есть такие для говорящего) // В текущей реализации Балард не имеет секции selfCastAbility // const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'selfCastAbility', { abilityId: ability.id }, GAME_CONFIG, gameData, this.gameState); // if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); // applyAbilityEffect теперь принимает gameDataForLogic и gameState serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); // Установка кулдауна способности Баларда (после успешного каста) let baseCooldown = 0; if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { baseCooldown = ability.cooldown; } // Специальные внутренние КД для Баларда - перебивают общий КД, если заданы if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; // Специальный КД становится актуальным кулдауном } else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue; // Специальный КД становится актуальным кулдауном } if (baseCooldown > 0 && attackerState.abilityCooldowns) { // Устанавливаем кулдаун. Добавляем +1, т.к. кулдаун уменьшится в конце этого хода. attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; } } else if (aiDecision.actionType === 'pass') { // Если AI решил пропустить ход if (aiDecision.logMessage) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type); else this.addToLog(`${attackerState.name} обдумывает свой следующий ход...`, GAME_CONFIG.LOG_TYPE_INFO); } else { // Неизвестное решение AI или ошибка в логике decideAiAction console.error(`[Game ${this.id}] AI (Балард) chose an invalid action type: ${aiDecision.actionType}. Defaulting to pass and logging error.`); this.addToLog(`AI ${attackerState.name} не смог выбрать действие из-за ошибки. Пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); // Критическая ошибка в логике AI, возможно, стоит завершить игру? // Но пропуск хода - это более мягкое решение. } // Проверяем конец игры после выполнения действия AI if (this.checkGameOver()) { this.broadcastGameStateUpdate(); // Отправляем финальное состояние и лог всем // Очистка игры теперь происходит ВНУТРИ checkGameOver через вызов _notifyGameEnded return; // Если игра окончена, останавливаемся } // Если игра не окончена, переключаем ход на игрока с задержкой console.log(`[Game ${this.id}] AI action complete. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`); setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); } /** * Проверяет, окончена ли игра (по HP бойцов). * Если да, обновляет gameState, отправляет событие gameOver и уведомляет GameManager. * Вызывается после каждого действия (игрока или AI) и после обработки эффектов конца хода. * @returns {boolean} true, если игра окончена, иначе false. */ checkGameOver() { // Проверка на конец игры происходит только если gameState существует и игра еще не помечена как оконченная if (!this.gameState || this.gameState.isGameOver) return this.gameState ? this.gameState.isGameOver : true; // Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами // Проверка maxHp > 0 в gameState.opponent гарантирует, что оппонент не плейсхолдер if (!this.gameState.player || !this.gameState.opponent || this.gameState.opponent.maxHp <= 0) { // Если один из бойцов не готов (например, PvP игра ожидает второго игрока), игра не может закончиться по HP return false; } // Используем внутреннюю функцию gameLogic для проверки условий победы/поражения по HP // checkGameOverInternal теперь принимает gameDataForLogic и gameState const isOver = serverGameLogic.checkGameOverInternal(this.gameState, GAME_CONFIG, gameData); // Если игра только что стала оконченной (был false, стал true) if (isOver && !this.gameState.isGameOver) { this.gameState.isGameOver = true; // Устанавливаем флаг const playerDead = this.gameState.player?.currentHp <= 0; const opponentDead = this.gameState.opponent?.currentHp <= 0; // Определяем победителя и проигравшего по ролям // Если оба мертвы одновременно, побеждает тот, кто не был убит последним действием, // или определяется по правилам (здесь: player побеждает при одновременной смерти) let winnerRole = null; let loserRole = null; // В AI режиме победителем всегда считается Player (если выжил), даже если AI тоже погиб. // При дисконнекте в AI режиме победителя нет. if (this.mode === 'ai') { winnerRole = playerDead ? null : GAME_CONFIG.PLAYER_ID; // Player победил, если не умер. AI не "побеждает". loserRole = playerDead ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; } else { // PvP режим if (playerDead && opponentDead) { // Оба мертвы, побеждает игрок (по правилам игры) winnerRole = GAME_CONFIG.PLAYER_ID; loserRole = GAME_CONFIG.OPPONENT_ID; } else if (playerDead) { // Игрок мертв, побеждает оппонент winnerRole = GAME_CONFIG.OPPONENT_ID; loserRole = GAME_CONFIG.PLAYER_ID; } else if (opponentDead) { // Оппонент мертв, побеждает игрок winnerRole = GAME_CONFIG.PLAYER_ID; loserRole = GAME_CONFIG.OPPONENT_ID; } else { // Этого не должно произойти, если isOver = true, но никто не мертв console.error(`[Game ${this.id}] checkGameOverInternal returned true, but neither fighter is dead. GameState:`, this.gameState); this.gameState.isGameOver = false; // Сбрасываем флаг, игра не окончена return false; } } const winnerState = this.gameState[winnerRole]; const loserState = this.gameState[loserRole]; const winnerName = winnerState?.name || (winnerRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник"); const loserName = loserState?.name || (loserRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник"); const loserCharacterKey = loserState?.characterKey || 'unknown'; // Ключ персонажа проигравшего // Добавляем сообщение о победе в лог // В AI режиме при победе игрока лог специфичный. Если AI "победил" (игрок умер), лог тоже специфичный. if (this.mode === 'ai') { if (winnerRole === GAME_CONFIG.PLAYER_ID) { // Игрок победил this.addToLog(`🏁 ПОБЕДА! Вы одолели ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); } else { // Игрок проиграл AI this.addToLog(`😭 ПОРАЖЕНИЕ! ${winnerName} оказался(лась) сильнее! 😭`, GAME_CONFIG.LOG_TYPE_SYSTEM); } } else { // PvP режим this.addToLog(`🏁 ПОБЕДА! ${winnerName} одолел(а) ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); } // Добавляем дополнительные сообщения о конце игры (насмешка победителя) // В AI режиме AI Балард не "говорит" в конце. Говорит только игрок, если победил. const winningCharacterKey = winnerState?.characterKey; if (this.mode === 'ai' && winningCharacterKey === 'elena') { const taunt = serverGameLogic.getRandomTaunt(winningCharacterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, gameData, this.gameState); if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerName}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); } else if (this.mode === 'pvp' && (winningCharacterKey === 'elena' || winningCharacterKey === 'almagest')) { const taunt = serverGameLogic.getRandomTaunt(winningCharacterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, gameData, this.gameState); if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerName}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); } // Специальные системные логи в конце игры, зависящие от проигравшего if (loserCharacterKey === 'balard') { this.addToLog(`Елена исполнила свой тяжкий долг. ${loserName} развоплощен...`, GAME_CONFIG.LOG_TYPE_SYSTEM); } else if (loserCharacterKey === 'almagest') { this.addToLog(`Над полем битвы воцаряется тишина. ${loserName} побежден(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); } else if (loserCharacterKey === 'elena') { this.addToLog(`Свет погас. ${loserName} повержен(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); } console.log(`[Game ${this.id}] Game is over. Winner: ${winnerName} (${winnerRole}). Loser: ${loserName} (${loserRole}). Reason: HP <= 0.`); // Отправляем событие конца игры всем клиентам в комнате this.io.to(this.id).emit('gameOver', { // В AI режиме winnerId отправляем только если победил игрок, иначе null winnerId: this.mode === 'ai' ? (winnerRole === GAME_CONFIG.PLAYER_ID ? winnerRole : null) : winnerRole, reason: `${loserName} побежден(а)`, // Причина для отображения на клиенте finalGameState: this.gameState, // Финальное состояние игры для отображения log: this.consumeLogBuffer(), // Отправляем весь накопленный лог (включая финальные сообщения) loserCharacterKey: loserCharacterKey // Передаем characterKey проигравшего для клиентской анимации }); // УВЕДОМЛЯЕМ GAMEMANAGER ОБ ОКОНЧАНИИ ИГРЫ ДЛЯ ОЧИСТКИ if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { this.gameManager._cleanupGame(this.id, 'hp_zero'); } else { console.error(`[Game ${this.id}] GameManager reference missing or _cleanupGame not found! Game state will not be cleaned.`); } return true; // Игра окончена } // Если isOver было false, или gameState был некорректен, игра не окончена return isOver; } /** * Завершает игру из-за отключения одного из игроков. * Вызывается GameManager. * @param {string} disconnectedSocketId - socketId отключившегося игрока. * @param {string} disconnectedPlayerRole - Роль ('player' или 'opponent') отключившегося игрока. * @param {string} disconnectedCharacterKey - Ключ персонажа отключившегося игрока. */ endGameDueToDisconnect(disconnectedSocketId, disconnectedPlayerRole, disconnectedCharacterKey) { if (this.gameState && !this.gameState.isGameOver) { this.gameState.isGameOver = true; // Помечаем игру как оконченную // Победитель - тот, кто остался. Его роль противоположна роли отключившегося. const winnerRole = disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const disconnectedCharacterData = this._getCharacterBaseData(disconnectedCharacterKey); // Данные уходящего персонажа // Ключ персонажа победителя берем из gameState, т.к. там актуальное состояние слотов const winnerCharacterKey = (winnerRole === GAME_CONFIG.PLAYER_ID) ? this.playerCharacterKey : this.opponentCharacterKey; const winnerCharacterData = this._getCharacterBaseData(winnerCharacterKey); // Данные оставшегося персонажа // Добавляем сообщение о дисконнекте в лог this.addToLog(`🔌 Игрок ${disconnectedCharacterData?.name || 'Неизвестный'} (${disconnectedPlayerRole}) отключился. Игра завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM); // В AI режиме, если игрок отключился, AI не "побеждает" в стандартном смысле. Можно сделать специфичный лог. if (this.mode === 'pvp') { this.addToLog(`🏁 Победа присуждается ${winnerCharacterData?.name || winnerRole}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); } else { // AI режим // AI игра завершается без формальной победы AI, если игрок ушел // Лог выше уже достаточен } // Отправляем событие конца игры всем клиентам в комнате // GameManager отвечает за удаление игры и очистку ссылок userIdentifierToGameId. // Здесь мы только уведомляем клиентов. this.io.to(this.id).emit('gameOver', { // В AI режиме winnerId отправляем null, т.к. нет формального победителя winnerId: this.mode === 'pvp' ? winnerRole : null, reason: 'opponent_disconnected', // Причина для клиента finalGameState: this.gameState, // Финальное состояние игры для отображения log: this.consumeLogBuffer(), // Отправляем весь накопленный лог // Передаем characterKey проигравшего (того, кто отключился) для клиентской анимации (растворения) loserCharacterKey: disconnectedCharacterKey }); console.log(`[Game ${this.id}] Game ended due to disconnect. Winner (PvP only): ${winnerCharacterData?.name || winnerRole}. Disconnected: ${disconnectedCharacterData?.name || disconnectedPlayerRole}.`); // УВЕДОМЛЯЕМ GAMEMANAGER ОБ ОКОНЧАНИИ ИГРЫ ДЛЯ ОЧИСТКИ if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { this.gameManager._cleanupGame(this.id, 'opponent_disconnected'); } else { console.error(`[Game ${this.id}] GameManager reference missing or _cleanupGame not found! Game state will not be cleaned.`); } } } /** * Добавляет сообщение в буфер лога игры. * @param {string} message - Текст сообщения. * @param {string} [type=GAME_CONFIG.LOG_TYPE_INFO] - Тип сообщения (для стилей на клиенте). */ addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { if (!message) return; this.logBuffer.push({ message, type, timestamp: Date.now() }); // В активной игре можно сразу отправлять лог, не дожидаясь gameStateUpdate // Это может улучшить отзывчивость лога. // if (this.gameState && !this.gameState.isGameOver) { // this.broadcastLogUpdate(); // } } /** * Очищает буфер лога и возвращает его содержимое. * @returns {Array} Массив сообщений лога. */ consumeLogBuffer() { const logs = [...this.logBuffer]; this.logBuffer = []; return logs; } /** * Отправляет текущее состояние игры и содержимое лога всем клиентам в комнате игры. */ broadcastGameStateUpdate() { if (!this.gameState) return; // Отправляем полное состояние игры и буфер лога всем клиентам в комнате this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); } /** * Отправляет только содержимое лога всем клиентам в комнате игры (если нужно обновить только лог). * Полезно для сообщений, которые не меняют gameState, но должны отобразиться. */ broadcastLogUpdate() { if (this.logBuffer.length > 0) { this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() }); } } // --- Вспомогательные функции для получения данных персонажа из data.js --- // Скопировано из gameManager.js, т.к. GameInstance тоже использует gameData напрямую /** * Получает базовые статы и список способностей для персонажа по ключу. * Эти функции предназначены для использования ВНУТРИ GameManager или GameInstance. * @param {string} key - Ключ персонажа ('elena', 'balard', 'almagest'). * @returns {{baseStats: object, abilities: array}|null} Объект с базовыми статами и способностями, или null. */ _getCharacterData(key) { if (!key) { console.warn("GameInstance::_getCharacterData called with null/undefined key."); return null; } switch (key) { case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities }; case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; // Балард использует opponentAbilities из data.js case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; // Альмагест использует almagestAbilities из data.js default: console.error(`GameInstance::_getCharacterData: Unknown character key "${key}"`); return null; } } /** * Получает только базовые статы для персонажа по ключу. * @param {string} key - Ключ персонажа. * @returns {object|null} Базовые статы или null. */ _getCharacterBaseData(key) { const charData = this._getCharacterData(key); return charData ? charData.baseStats : null; } /** * Получает только список способностей для персонажа по ключу. * @param {string} key - Ключ персонажа. * @returns {array|null} Список способностей или null. */ _getCharacterAbilities(key) { const charData = this._getCharacterData(key); return charData ? charData.abilities : null; } } module.exports = GameInstance;