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

1116 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// /server_modules/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<object>} Массив сообщений лога.
*/
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;