From d1e6291d94a88b1c832478c4fb32be911649ba93 Mon Sep 17 00:00:00 2001 From: "svoboda200786@gmail.com" Date: Tue, 20 May 2025 12:13:08 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B8=20=D1=81=D0=B2=D0=B0=D0=B9=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 5 + .idea/battle_club_git.iml | 12 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + server/auth/authService.js | 133 ++++++++ server/bc.js | 190 +++++++++++ server/core/config.js | 111 +++++++ server/core/db.js | 90 +++++ server/core/logger.js | 93 ++++++ server/data/characterAbilities.js | 178 ++++++++++ server/data/characterStats.js | 47 +++ server/data/dataUtils.js | 72 ++++ server/data/index.js | 75 +++++ server/data/taunts.js | 118 +++++++ server/game/GameManager.js | 343 +++++++++++++++++++ server/game/instance/GameInstance.js | 474 +++++++++++++++++++++++++++ server/game/instance/Player.js | 0 server/game/instance/TurnTimer.js | 120 +++++++ server/game/logic/aiLogic.js | 133 ++++++++ server/game/logic/combatLogic.js | 313 ++++++++++++++++++ server/game/logic/cooldownLogic.js | 154 +++++++++ server/game/logic/effectsLogic.js | 153 +++++++++ server/game/logic/gameStateLogic.js | 133 ++++++++ server/game/logic/index.js | 66 ++++ server/game/logic/tauntLogic.js | 90 +++++ server/services/SocketService.js | 0 26 files changed, 3117 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/battle_club_git.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 server/auth/authService.js create mode 100644 server/bc.js create mode 100644 server/core/config.js create mode 100644 server/core/db.js create mode 100644 server/core/logger.js create mode 100644 server/data/characterAbilities.js create mode 100644 server/data/characterStats.js create mode 100644 server/data/dataUtils.js create mode 100644 server/data/index.js create mode 100644 server/data/taunts.js create mode 100644 server/game/GameManager.js create mode 100644 server/game/instance/GameInstance.js create mode 100644 server/game/instance/Player.js create mode 100644 server/game/instance/TurnTimer.js create mode 100644 server/game/logic/aiLogic.js create mode 100644 server/game/logic/combatLogic.js create mode 100644 server/game/logic/cooldownLogic.js create mode 100644 server/game/logic/effectsLogic.js create mode 100644 server/game/logic/gameStateLogic.js create mode 100644 server/game/logic/index.js create mode 100644 server/game/logic/tauntLogic.js create mode 100644 server/services/SocketService.js diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/battle_club_git.iml b/.idea/battle_club_git.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/battle_club_git.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9f07350 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/server/auth/authService.js b/server/auth/authService.js new file mode 100644 index 0000000..f0a6e79 --- /dev/null +++ b/server/auth/authService.js @@ -0,0 +1,133 @@ +// /server/auth/authService.js +const bcrypt = require('bcryptjs'); // Для хеширования паролей +const db = require('../core/db'); // Путь к вашему модулю для работы с базой данных (в папке core) + +const SALT_ROUNDS = 10; // Количество раундов для генерации соли bcrypt + +/** + * Регистрирует нового пользователя. + * @param {string} username - Имя пользователя. + * @param {string} password - Пароль пользователя. + * @returns {Promise} Объект с результатом: { success: boolean, message: string, userId?: number, username?: string } + */ +async function registerUser(username, password) { + console.log(`[AuthService DEBUG] registerUser called with username: "${username}"`); + + if (!username || !password) { + console.warn('[AuthService DEBUG] Validation failed: Username or password empty.'); + return { success: false, message: 'Имя пользователя и пароль не могут быть пустыми.' }; + } + if (password.length < 6) { + console.warn(`[AuthService DEBUG] Validation failed for "${username}": Password too short.`); + return { success: false, message: 'Пароль должен содержать не менее 6 символов.' }; + } + + try { + // Этап A: Проверка существующего пользователя + console.log(`[AuthService DEBUG] Stage A: Checking if user "${username}" exists...`); + // Предполагаем, что db.query возвращает массив, где первый элемент - это массив строк (результатов) + const [existingUsers] = await db.query('SELECT id FROM users WHERE username = ?', [username]); + console.log(`[AuthService DEBUG] Stage A: existingUsers query result length: ${existingUsers.length}`); + + if (existingUsers.length > 0) { + console.warn(`[AuthService DEBUG] Registration declined for "${username}": Username already taken.`); + return { success: false, message: 'Это имя пользователя уже занято.' }; + } + console.log(`[AuthService DEBUG] Stage A: Username "${username}" is available.`); + + // Этап B: Хеширование пароля + console.log(`[AuthService DEBUG] Stage B: Hashing password for user "${username}"...`); + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + console.log(`[AuthService DEBUG] Stage B: Password for "${username}" hashed successfully.`); + + // Этап C: Сохранение пользователя в БД + console.log(`[AuthService DEBUG] Stage C: Attempting to insert user "${username}" into DB...`); + // Предполагаем, что db.query для INSERT возвращает объект результата с insertId + const [result] = await db.query( + 'INSERT INTO users (username, password_hash) VALUES (?, ?)', + [username, hashedPassword] + ); + console.log(`[AuthService DEBUG] Stage C: DB insert result for "${username}":`, result); + + if (result && result.insertId) { + console.log(`[AuthService] Пользователь "${username}" успешно зарегистрирован с ID: ${result.insertId}.`); + return { + success: true, + message: 'Регистрация прошла успешно!', + userId: result.insertId, + username: username // Возвращаем и имя пользователя + }; + } else { + console.error(`[AuthService] Ошибка БД при регистрации пользователя "${username}": Запись не была вставлена или insertId отсутствует. Result:`, result); + return { success: false, message: 'Ошибка сервера при регистрации (данные не сохранены). Попробуйте позже.' }; + } + + } catch (error) { + console.error(`[AuthService] КРИТИЧЕСКАЯ ОШИБКА (catch block) при регистрации пользователя "${username}":`, error); + if (error.sqlMessage) { + console.error(`[AuthService] MySQL Error Message: ${error.sqlMessage}`); + console.error(`[AuthService] MySQL Error Code: ${error.code}`); + console.error(`[AuthService] MySQL Errno: ${error.errno}`); + } + return { success: false, message: 'Внутренняя ошибка сервера при регистрации.' }; + } +} + +/** + * Выполняет вход пользователя. + * @param {string} username - Имя пользователя. + * @param {string} password - Пароль пользователя. + * @returns {Promise} Объект с результатом: { success: boolean, message: string, userId?: number, username?: string } + */ +async function loginUser(username, password) { + console.log(`[AuthService DEBUG] loginUser called with username: "${username}"`); + + if (!username || !password) { + console.warn('[AuthService DEBUG] Login validation failed: Username or password empty.'); + return { success: false, message: 'Имя пользователя и пароль не могут быть пустыми.' }; + } + + try { + console.log(`[AuthService DEBUG] Searching for user "${username}" in DB...`); + const [users] = await db.query('SELECT id, username, password_hash FROM users WHERE username = ?', [username]); + console.log(`[AuthService DEBUG] DB query result for user "${username}" (length): ${users.length}`); + + if (users.length === 0) { + console.warn(`[AuthService DEBUG] Login failed: User "${username}" not found.`); + return { success: false, message: 'Неверное имя пользователя или пароль.' }; + } + + const user = users[0]; + console.log(`[AuthService DEBUG] User "${username}" found. ID: ${user.id}. Comparing password...`); + + const passwordMatch = await bcrypt.compare(password, user.password_hash); + console.log(`[AuthService DEBUG] Password comparison result for "${username}": ${passwordMatch}`); + + if (passwordMatch) { + console.log(`[AuthService] Пользователь "${user.username}" (ID: ${user.id}) успешно вошел в систему.`); + return { + success: true, + message: 'Вход выполнен успешно!', + userId: user.id, + username: user.username // Возвращаем имя пользователя + }; + } else { + console.warn(`[AuthService DEBUG] Login failed for user "${user.username}": Incorrect password.`); + return { success: false, message: 'Неверное имя пользователя или пароль.' }; + } + + } catch (error) { + console.error(`[AuthService] КРИТИЧЕСКАЯ ОШИБКА (catch block) при входе пользователя "${username}":`, error); + if (error.sqlMessage) { + console.error(`[AuthService] MySQL Error Message: ${error.sqlMessage}`); + console.error(`[AuthService] MySQL Error Code: ${error.code}`); + console.error(`[AuthService] MySQL Errno: ${error.errno}`); + } + return { success: false, message: 'Внутренняя ошибка сервера при входе.' }; + } +} + +module.exports = { + registerUser, + loginUser +}; \ No newline at end of file diff --git a/server/bc.js b/server/bc.js new file mode 100644 index 0000000..64a0289 --- /dev/null +++ b/server/bc.js @@ -0,0 +1,190 @@ +// /server/bc.js - Главный файл сервера Battle Club + +const express = require('express'); +const http = require('http'); +const { Server } = require('socket.io'); +const path = require('path'); + +// Импорт серверных модулей из их новых местоположений +const authService = require('./auth/authService'); // Сервис аутентификации +const GameManager = require('./game/GameManager'); // Менеджер игр +const db = require('./core/db'); // Модуль базы данных (для инициализации) +const GAME_CONFIG = require('./core/config'); // Глобальный конфиг игры +// data.js (теперь data/index.js) и gameLogic.js (теперь game/logic/index.js) +// импортируются внутри GameManager и GameInstance или их компонентов. + +const app = express(); +const server = http.createServer(app); + +// Настройка Socket.IO +const io = new Server(server, { + cors: { + origin: "https://pavel-chagovsky.com:3200", // Для разработки. В продакшене укажите домен клиента. + methods: ["GET", "POST"] + }, + // Можно настроить pingInterval и pingTimeout для более быстрого обнаружения дисконнектов + // pingInterval: 10000, // 10 секунд + // pingTimeout: 5000, // 5 секунд (клиент должен ответить в течение этого времени) +}); + +// Раздача статических файлов из папки 'public' +// __dirname будет указывать на папку server/, поэтому нужно подняться на уровень выше +app.use(express.static(path.join(__dirname, '..', 'public'))); + +// Создаем экземпляр GameManager +const gameManager = new GameManager(io); + +// Хранилище информации о залогиненных пользователях по socket.id +// (Временное решение, в продакшене лучше использовать Redis или БД для сессий) +const loggedInUsers = {}; // { socket.id: { userId: ..., username: ... } } + +// Обработка подключений Socket.IO +io.on('connection', (socket) => { + console.log(`[Socket.IO] Пользователь подключился: ${socket.id}`); + + // Привязываем user data к сокету (пока пустые, заполняются при логине) + socket.userData = null; // { userId: ..., username: ... } + + // --- Обработчики событий Аутентификации --- + socket.on('register', async (data) => { + console.log(`[Socket.IO] Register attempt for username: "${data?.username}" from ${socket.id}`); + const result = await authService.registerUser(data?.username, data?.password); + if (result.success) { + console.log(`[Socket.IO] Registration successful for ${result.username} (${result.userId})`); + } else { + console.warn(`[Socket.IO] Registration failed for "${data?.username}": ${result.message}`); + } + socket.emit('registerResponse', result); + }); + + socket.on('login', async (data) => { + console.log(`[Socket.IO] Login attempt for username: "${data?.username}" from ${socket.id}`); + const result = await authService.loginUser(data?.username, data?.password); + if (result.success && result.userId && result.username) { // Убедимся, что userId и username есть + console.log(`[Socket.IO] Login successful for ${result.username} (${result.userId}). Assigning to socket ${socket.id}.`); + socket.userData = { userId: result.userId, username: result.username }; + loggedInUsers[socket.id] = socket.userData; // Сохраняем для быстрого доступа, если нужно + + // После успешного логина, просим GameManager проверить, не был ли этот пользователь в игре + if (gameManager && typeof gameManager.handleRequestGameState === 'function') { + gameManager.handleRequestGameState(socket, result.userId); + } + } else { + console.warn(`[Socket.IO] Login failed for "${data?.username}": ${result.message}`); + socket.userData = null; + if (loggedInUsers[socket.id]) delete loggedInUsers[socket.id]; + } + socket.emit('loginResponse', result); // Отправляем результат клиенту + }); + + socket.on('logout', () => { + const username = socket.userData?.username || 'UnknownUser'; + const userId = socket.userData?.userId; + console.log(`[Socket.IO] Logout request from user ${username} (ID: ${userId}, Socket: ${socket.id})`); + + if (gameManager && typeof gameManager.handleDisconnect === 'function' && userId) { + // Уведомляем GameManager о "дисконнекте" этого пользователя из его игры, если он там был. + // handleDisconnect использует identifier (userId в данном случае) для поиска игры. + // Передаем socket.id на случай, если игра была AI и identifier был socket.id (хотя при logout должен быть userId). + gameManager.handleDisconnect(socket.id, userId); + } + + if (loggedInUsers[socket.id]) { + delete loggedInUsers[socket.id]; + } + socket.userData = null; + // Клиент сам обработает UI после logout (например, покажет экран логина) + // Можно отправить подтверждение, но обычно не требуется: socket.emit('logoutResponse', { success: true }); + console.log(`[Socket.IO] User ${username} (Socket: ${socket.id}) logged out.`); + }); + + // --- Обработчики событий Управления Играми --- + // Все эти события делегируются в GameManager + + socket.on('createGame', (data) => { + const identifier = socket.userData?.userId || socket.id; // userId для залогиненных, socket.id для гостей (AI игра) + const mode = data?.mode || 'ai'; + + if (mode === 'pvp' && !socket.userData) { + socket.emit('gameError', { message: 'Необходимо войти в систему для создания PvP игры.' }); + return; + } + console.log(`[Socket.IO] Create Game from ${socket.userData?.username || socket.id} (ID: ${identifier}). Mode: ${mode}, Char: ${data?.characterKey}`); + gameManager.createGame(socket, mode, data?.characterKey, identifier); + }); + + socket.on('joinGame', (data) => { + if (!socket.userData?.userId) { + socket.emit('gameError', { message: 'Необходимо войти для присоединения к PvP игре.' }); + return; + } + console.log(`[Socket.IO] Join Game from ${socket.userData.username} (ID: ${socket.userData.userId}). GameID: ${data?.gameId}`); + gameManager.joinGame(socket, data?.gameId, socket.userData.userId); + }); + + socket.on('findRandomGame', (data) => { + if (!socket.userData?.userId) { + socket.emit('gameError', { message: 'Необходимо войти для поиска случайной PvP игры.' }); + return; + } + console.log(`[Socket.IO] Find Random Game from ${socket.userData.username} (ID: ${socket.userData.userId}). PrefChar: ${data?.characterKey}`); + gameManager.findAndJoinRandomPvPGame(socket, data?.characterKey, socket.userData.userId); + }); + + socket.on('requestPvPGameList', () => { + // console.log(`[Socket.IO] Request PvP Game List from ${socket.userData?.username || socket.id}`); + const availableGames = gameManager.getAvailablePvPGamesListForClient(); + socket.emit('availablePvPGamesList', availableGames); + }); + + socket.on('requestGameState', () => { + if (!socket.userData?.userId) { + // console.log(`[Socket.IO] Request Game State from unauthenticated socket ${socket.id}.`); + socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' }); + return; + } + // console.log(`[Socket.IO] Request Game State from ${socket.userData.username} (ID: ${socket.userData.userId}).`); + gameManager.handleRequestGameState(socket, socket.userData.userId); + }); + + // --- Обработчик события Игрового Действия --- + socket.on('playerAction', (actionData) => { + const identifier = socket.userData?.userId || socket.id; // Идентификатор для GameManager + // console.log(`[Socket.IO] Player Action from ${identifier} (socket ${socket.id}):`, actionData); + gameManager.handlePlayerAction(identifier, actionData); + }); + + // --- Обработчик отключения сокета --- + socket.on('disconnect', (reason) => { + const identifier = socket.userData?.userId || socket.id; + console.log(`[Socket.IO] Пользователь отключился: ${socket.id} (Причина: ${reason}). Identifier: ${identifier}`); + + gameManager.handleDisconnect(socket.id, identifier); // Передаем и socketId, и identifier + + if (loggedInUsers[socket.id]) { + delete loggedInUsers[socket.id]; + } + // socket.userData очистится автоматически при уничтожении объекта socket + }); +}); + +// Запуск HTTP сервера +const PORT = process.env.PORT || 3200; +server.listen(PORT, () => { + console.log(`[Server] Запущен на порту ${PORT}`); + console.log(`[Server] Раздача статики из: ${path.join(__dirname, '..', 'public')}`); + // db.js уже выводит сообщение о подключении к БД +}); + +// Обработка необработанных промис-ошибок +process.on('unhandledRejection', (reason, promise) => { + console.error('[Server FATAL] Unhandled Rejection at:', promise, 'reason:', reason); + // В продакшене здесь может быть более сложная логика или перезапуск процесса + // process.exit(1); +}); + +process.on('uncaughtException', (err) => { + console.error('[Server FATAL] Uncaught Exception:', err); + // Критическая ошибка, обычно требует перезапуска приложения + process.exit(1); // Аварийное завершение процесса +}); \ No newline at end of file diff --git a/server/core/config.js b/server/core/config.js new file mode 100644 index 0000000..864355a --- /dev/null +++ b/server/core/config.js @@ -0,0 +1,111 @@ +// /server/core/config.js + +const GAME_CONFIG = { + // --- Баланс Игры --- + BLOCK_DAMAGE_REDUCTION: 0.5, // Множитель урона при блоке (0.5 = 50% снижение) + DAMAGE_VARIATION_MIN: 0.9, // Минимальный множитель урона (0.9 = 90%) + DAMAGE_VARIATION_RANGE: 0.2, // Диапазон вариации урона (0.2 = от 90% до 110%) + HEAL_VARIATION_MIN: 0.8, // Минимальный множитель лечения (0.8 = 80%) + HEAL_VARIATION_RANGE: 0.4, // Диапазон вариации лечения (0.4 = от 80% до 120%) + NATURE_STRENGTH_MANA_REGEN: 10, // Количество маны, восстанавливаемое "Силой природы" (и ее аналогом) + + // --- Условия ИИ и Игрока --- + OPPONENT_HEAL_THRESHOLD_PERCENT: 50, // Процент HP Баларда, НИЖЕ которого он будет пытаться лечиться (для AI) + PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT: 60, // Процент HP Баларда, НИЖЕ которого Елена использует "доминирующие" насмешки (для AI/текстов) + PLAYER_HP_BLEED_THRESHOLD_PERCENT: 60, // % HP Елены, НИЖЕ которого Балард предпочитает Кровотечение Безмолвию (для AI) + BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD: 60, // % Маны Елены, ВЫШЕ которого Балард может использовать "Похищение Света" (для AI) + + // --- Способности Баларда (AI) - Конфигурация --- + SILENCE_DURATION: 3, // Длительность Безмолвия в ходах Елены (после хода Баларда) + SILENCE_SUCCESS_RATE: 0.7, // Шанс успеха наложения Безмолвия (70%) + BALARD_SILENCE_ABILITY_COST: 15, // Стоимость "Эха Безмолвия" в Ярости + BALARD_SILENCE_INTERNAL_COOLDOWN: 5, // К-во ходов Баларда КД ПОСЛЕ успешного использования Безмолвия + // BALARD_BLEED_COST: 15, // Если будете добавлять способность Кровотечение + // BALARD_BLEED_POWER: 5, + // BALARD_BLEED_DURATION: 2, + // BALARD_BLEED_COOLDOWN: 3, + + // --- Таймер Хода --- + TURN_DURATION_SECONDS: 60, // Длительность хода в секундах + TURN_DURATION_MS: 60 * 1000, // Длительность хода в миллисекундах + TIMER_UPDATE_INTERVAL_MS: 1000, // Интервал обновления таймера на клиенте (в мс) + + // --- Идентификаторы и Типы --- + PLAYER_ID: 'player', // Технический идентификатор для слота 'Игрок 1' + OPPONENT_ID: 'opponent', // Технический идентификатор для слота 'Игрок 2' / 'Противник' + ACTION_TYPE_HEAL: 'heal', + ACTION_TYPE_DAMAGE: 'damage', + ACTION_TYPE_BUFF: 'buff', + ACTION_TYPE_DISABLE: 'disable', // Тип для контроля (безмолвие, стан и т.п.) + ACTION_TYPE_DEBUFF: 'debuff', // Тип для ослаблений, DoT и т.п. + ACTION_TYPE_DRAIN: 'drain', // Тип для Похищения Света + + // --- Строки для UI (могут быть полезны и на сервере для логов) --- + STATUS_READY: 'Готов(а)', // Сделал универсальным + STATUS_BLOCKING: 'Защищается', + + // --- Типы Логов (для CSS классов на клиенте, и для структурирования на сервере) --- + LOG_TYPE_INFO: 'info', + LOG_TYPE_DAMAGE: 'damage', + LOG_TYPE_HEAL: 'heal', + LOG_TYPE_TURN: 'turn', + LOG_TYPE_SYSTEM: 'system', + LOG_TYPE_BLOCK: 'block', + LOG_TYPE_EFFECT: 'effect', + + // --- CSS Классы (в основном для клиента, но константы могут быть полезны для согласованности) --- + CSS_CLASS_BLOCKING: 'blocking', + CSS_CLASS_NOT_ENOUGH_RESOURCE: 'not-enough-resource', + CSS_CLASS_BUFF_IS_ACTIVE: 'buff-is-active', + CSS_CLASS_ATTACK_BUFFED: 'attack-buffed', + CSS_CLASS_SHAKING: 'is-shaking', + CSS_CLASS_CASTING_PREFIX: 'is-casting-', // Например: is-casting-fireball + CSS_CLASS_HIDDEN: 'hidden', + CSS_CLASS_ABILITY_BUTTON: 'ability-button', + CSS_CLASS_ABILITY_SILENCED: 'is-silenced', + CSS_CLASS_ABILITY_ON_COOLDOWN: 'is-on-cooldown', // Для отображения кулдауна + + // --- Задержки (в миллисекундах) --- + // Эти задержки теперь в основном будут управляться сервером при отправке событий или планировании AI ходов + DELAY_OPPONENT_TURN: 1200, // Задержка перед ходом AI + DELAY_AFTER_PLAYER_ACTION: 500, // Сервер может использовать это для паузы перед следующим событием + // DELAY_AFTER_BLOCK: 500, // Менее релевантно для сервера напрямую + DELAY_INIT: 100, // Для клиентской инициализации, если нужна + DELAY_BEFORE_VICTORY_MODAL: 1500, // Для клиента, после получения gameOver + MODAL_TRANSITION_DELAY: 10, // Для анимации модалки на клиенте + + // --- Длительности анимаций (в миллисекундах, в основном для клиента, но сервер может знать для таймингов) --- + ANIMATION_SHAKE_DURATION: 400, + ANIMATION_CAST_DURATION: 600, + ANIMATION_DISSOLVE_DURATION: 6000, // var(--dissolve-duration) из CSS + + // --- Внутренние ID способностей (для удобства в логике, нужны и на сервере, и на клиенте) --- + // Игрока (Елена) + ABILITY_ID_HEAL: 'heal', // Малое Исцеление + ABILITY_ID_FIREBALL: 'fireball', // Огненный Шар + ABILITY_ID_NATURE_STRENGTH: 'naturesStrength', // Сила Природы + ABILITY_ID_DEFENSE_AURA: 'defenseAura', // Аура Защиты + ABILITY_ID_HYPNOTIC_GAZE: 'hypnoticGaze', // Гипнотический взгляд + ABILITY_ID_SEAL_OF_WEAKNESS: 'sealOfWeakness', // Печать Слабости + + // Противника (Балард - AI) + ABILITY_ID_BALARD_HEAL: 'darkPatronage', // Покровительство Тьмы + ABILITY_ID_BALARD_SILENCE: 'echoesOfSilence', // Эхо Безмолвия + ABILITY_ID_BALARD_MANA_DRAIN: 'manaDrainHeal', // Похищение Света + // ABILITY_ID_BALARD_BLEED: 'balardBleed', // Если будете добавлять + + // Противника (Альмагест - PvP - зеркало Елены) + ABILITY_ID_ALMAGEST_HEAL: 'darkHeal', // Темное Восстановление (Аналог heal) + ABILITY_ID_ALMAGEST_DAMAGE: 'shadowBolt', // Теневой Сгусток (Аналог fireball) + ABILITY_ID_ALMAGEST_BUFF_ATTACK: 'shadowEmpowerment', // Усиление Тьмой (Аналог naturesStrength) + ABILITY_ID_ALMAGEST_BUFF_DEFENSE: 'voidShield', // Щит Пустоты (Аналог defenseAura) + ABILITY_ID_ALMAGEST_DISABLE: 'mindShatter', // Раскол Разума (Аналог hypnoticGaze) + ABILITY_ID_ALMAGEST_DEBUFF: 'curseOfDecay', // Проклятие Увядания (Аналог sealOfWeakness) +}; + +// Для использования в Node.js модулях +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = GAME_CONFIG; +} + +// console.log("config.js loaded from server/core/ and GAME_CONFIG object created/exported."); \ No newline at end of file diff --git a/server/core/db.js b/server/core/db.js new file mode 100644 index 0000000..151545b --- /dev/null +++ b/server/core/db.js @@ -0,0 +1,90 @@ +// /server/core/db.js +const mysql = require('mysql2'); // Используем mysql2 для поддержки промисов и улучшенной производительности + +// Конфигурация подключения к вашей базе данных MySQL +// ЗАМЕНИТЕ значения на ваши реальные данные! +const dbConfig = { //Данные для сервера user phpmyadmin password Innamorato8Art + host: 'localhost', // или IP-адрес вашего MySQL сервера + user: 'phpmyadmin', // Имя пользователя MySQL (например, 'root' для локальной разработки) + password: 'Innamorato8Art', // Пароль пользователя MySQL + database: 'your_game_db', // Имя вашей базы данных (например, 'your_game_db') + port: 3306, // Стандартный порт MySQL, измените если у вас другой + waitForConnections: true, // Ожидать доступного соединения, если все заняты + connectionLimit: 10, // Максимальное количество соединений в пуле + queueLimit: 0 // Максимальное количество запросов в очереди (0 = безлимитно) +}; + +// Создаем пул соединений. Пул более эффективен для веб-приложений, +// чем создание нового соединения для каждого запроса. +let pool; +try { + pool = mysql.createPool(dbConfig); + console.log('[DB] Пул соединений MySQL успешно создан.'); +} catch (error) { + console.error('[DB FATAL] Не удалось создать пул соединений MySQL. Проверьте конфигурацию `dbConfig`. Ошибка:', error); + // Если пул не создался, дальнейшая работа с БД невозможна. + // Завершаем приложение, так как без БД оно не сможет работать корректно. + process.exit(1); +} + +// Обертка для выполнения запросов с использованием промисов из пула +// Мы экспортируем именно эту обертку. +const promisePool = pool.promise(); + +// Проверка соединения (опционально, но полезно для отладки при запуске) +// Делаем это после экспорта, чтобы модуль мог быть загружен даже если проверка упадет, +// хотя в данном случае мы завершаем процесс, если пул не создался. +if (promisePool) { // Проверяем, что promisePool был успешно создан + promisePool.getConnection() + .then(connection => { + console.log('[DB] Успешно подключено к базе данных MySQL и получено соединение из пула.'); + connection.release(); // Важно!!! Возвращаем соединение в пул + console.log('[DB] Соединение возвращено в пул.'); + }) + .catch(err => { + console.error('[DB] Ошибка при попытке получить соединение из пула или при подключении к MySQL:', err.message); + // Выводим полный объект ошибки для диагностики, если это не просто ошибка конфигурации + if (err.code !== 'ER_ACCESS_DENIED_ERROR' && err.code !== 'ER_BAD_DB_ERROR' && err.code !== 'ECONNREFUSED') { + console.error('[DB] Полные детали ошибки:', err); + } + + if (err.code === 'PROTOCOL_CONNECTION_LOST') { + console.error('[DB] Соединение с БД было потеряно.'); + } else if (err.code === 'ER_CON_COUNT_ERROR') { + console.error('[DB] В БД слишком много соединений.'); + } else if (err.code === 'ECONNREFUSED') { + console.error(`[DB] Соединение с БД было отклонено. Убедитесь, что сервер MySQL запущен и доступен по адресу ${dbConfig.host}:${dbConfig.port}.`); + } else if (err.code === 'ER_ACCESS_DENIED_ERROR') { + console.error(`[DB] Доступ к БД запрещен для пользователя '${dbConfig.user}'. Проверьте имя пользователя и пароль в server/core/db.js.`); + } else if (err.code === 'ER_BAD_DB_ERROR') { + console.error(`[DB] База данных "${dbConfig.database}" не найдена. Убедитесь, что она создана на сервере MySQL.`); + } else { + console.error(`[DB] Неизвестная ошибка подключения к MySQL. Код: ${err.code}`); + } + // В продакшене здесь может быть логика переподключения или более изящного завершения работы. + // Для разработки важно видеть эти ошибки. + // Можно раскомментировать process.exit(1), если хотите, чтобы приложение падало при ошибке подключения к БД. + // process.exit(1); + }); +} else { + // Эта ветка не должна выполниться, если pool.promise() не выбросил ошибку выше. + // Но на всякий случай оставляем лог. + console.error('[DB FATAL] promisePool не был создан. Проверьте создание `pool`.'); + process.exit(1); // Завершаем, так как это критическая ошибка +} + +// Экспортируем пул с промисами, чтобы его можно было использовать в других модулях (например, в authService.js) +module.exports = promisePool; + +/* +Пример SQL для создания таблицы пользователей (если ее еще нет): + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +*/ \ No newline at end of file diff --git a/server/core/logger.js b/server/core/logger.js new file mode 100644 index 0000000..1a8485d --- /dev/null +++ b/server/core/logger.js @@ -0,0 +1,93 @@ +// /server/core/logger.js + +/** + * Простой логгер-обертка. + * В будущем можно заменить на более продвинутое решение (Winston, Pino), + * сохранив этот же интерфейс. + */ + +const LOG_LEVELS = { + DEBUG: 'DEBUG', + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', + FATAL: 'FATAL' +}; + +// Можно установить минимальный уровень логирования из переменной окружения или конфига +const CURRENT_LOG_LEVEL = process.env.LOG_LEVEL || LOG_LEVELS.INFO; + +function shouldLog(level) { + const levelsOrder = [LOG_LEVELS.DEBUG, LOG_LEVELS.INFO, LOG_LEVELS.WARN, LOG_LEVELS.ERROR, LOG_LEVELS.FATAL]; + return levelsOrder.indexOf(level) >= levelsOrder.indexOf(CURRENT_LOG_LEVEL); +} + +function formatMessage(level, moduleName, message, ...optionalParams) { + const timestamp = new Date().toISOString(); + let formattedMessage = `${timestamp} [${level}]`; + if (moduleName) { + formattedMessage += ` [${moduleName}]`; + } + formattedMessage += `: ${message}`; + + // Обработка дополнительных параметров (например, объектов ошибок) + const paramsString = optionalParams.map(param => { + if (param instanceof Error) { + return `\n${param.stack || param.message}`; + } + if (typeof param === 'object') { + try { + return `\n${JSON.stringify(param, null, 2)}`; + } catch (e) { + return '\n[Unserializable Object]'; + } + } + return param; + }).join(' '); + + return `${formattedMessage}${paramsString ? ' ' + paramsString : ''}`; +} + +const logger = { + debug: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.DEBUG)) { + console.debug(formatMessage(LOG_LEVELS.DEBUG, moduleName, message, ...optionalParams)); + } + }, + info: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.INFO)) { + console.info(formatMessage(LOG_LEVELS.INFO, moduleName, message, ...optionalParams)); + } + }, + warn: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.WARN)) { + console.warn(formatMessage(LOG_LEVELS.WARN, moduleName, message, ...optionalParams)); + } + }, + error: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.ERROR)) { + console.error(formatMessage(LOG_LEVELS.ERROR, moduleName, message, ...optionalParams)); + } + }, + fatal: (moduleName, message, ...optionalParams) => { // Fatal обычно означает, что приложение не может продолжать работу + if (shouldLog(LOG_LEVELS.FATAL)) { + console.error(formatMessage(LOG_LEVELS.FATAL, moduleName, message, ...optionalParams)); + // В реальном приложении здесь может быть process.exit(1) после логирования + } + }, + // Generic log function if needed, defaults to INFO + log: (moduleName, message, ...optionalParams) => { + logger.info(moduleName, message, ...optionalParams); + } +}; + +module.exports = logger; + +/* +Пример использования в другом файле: +const logger = require('../core/logger'); // Путь зависит от местоположения + +logger.info('GameManager', 'Новая игра создана', { gameId: '123', mode: 'pvp' }); +logger.error('AuthService', 'Ошибка аутентификации пользователя', new Error('Пароль неверный')); +logger.debug('GameInstance', 'Состояние игрока обновлено:', playerStateObject); +*/ \ No newline at end of file diff --git a/server/data/characterAbilities.js b/server/data/characterAbilities.js new file mode 100644 index 0000000..c90b926 --- /dev/null +++ b/server/data/characterAbilities.js @@ -0,0 +1,178 @@ +// /server/data/characterAbilities.js + +const GAME_CONFIG = require('../core/config'); // Путь к конфигу из server/data/ в server/core/ + +// Способности Игрока (Елена) +const elenaAbilities = [ + { + id: GAME_CONFIG.ABILITY_ID_HEAL, + name: 'Малое Исцеление', + cost: 20, + type: GAME_CONFIG.ACTION_TYPE_HEAL, + power: 30, + description: 'Восстанавливает ~30 HP' + }, + { + id: GAME_CONFIG.ABILITY_ID_FIREBALL, + name: 'Огненный Шар', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DAMAGE, + power: 25, + description: 'Наносит ~25 урона врагу' + }, + { + id: GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH, + name: 'Сила Природы', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 4, // Общая длительность эффекта + // Описание теперь может использовать configToUse (который будет GAME_CONFIG) + descriptionFunction: (configToUse, opponentBaseStats) => `Восст. ${configToUse.NATURE_STRENGTH_MANA_REGEN} маны при след. атаке. Эффект длится ${4 - 1} хода после применения.`, + isDelayed: true // Этот эффект применяется ПОСЛЕ следующей атаки, а не сразу + }, + { + id: GAME_CONFIG.ABILITY_ID_DEFENSE_AURA, + name: 'Аура Защиты', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 3, + grantsBlock: true, // Дает эффект блока на время действия + descriptionFunction: (configToUse, opponentBaseStats) => `Снижает урон на ${configToUse.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)` + }, + { + id: GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE, + name: 'Гипнотический взгляд', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DISABLE, + effectDuration: 2, // Длительность безмолвия в ходах противника + cooldown: 6, + power: 5, // Урон в ход от взгляда + description: 'Накладывает на противника полное безмолвие на 2 хода и наносит 5 урона каждый его ход. КД: 6 х.' + }, + { + id: GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS, + name: 'Печать Слабости', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DEBUFF, + effectDuration: 3, // Длительность дебаффа + power: 10, // Количество ресурса противника, сжигаемое каждый ход + cooldown: 5, + // Описание теперь может адаптироваться к ресурсу оппонента + descriptionFunction: (configToUse, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats ? oppStats.resourceName : 'ресурса'} противника каждый его ход в течение 3 ходов. КД: 5 х.` + } +]; + +// Способности Противника (Балард - AI) +const balardAbilities = [ + { + id: GAME_CONFIG.ABILITY_ID_BALARD_HEAL, + name: 'Покровительство Тьмы', + cost: 20, + type: GAME_CONFIG.ACTION_TYPE_HEAL, + power: 25, + successRate: 0.60, // Шанс успеха + description: 'Исцеляет ~25 HP с 60% шансом', + // Условие для AI: HP ниже порога + condition: (opSt, plSt, currentGameState, configToUse) => { + return (opSt.currentHp / opSt.maxHp) * 100 < configToUse.OPPONENT_HEAL_THRESHOLD_PERCENT; + } + }, + { + id: GAME_CONFIG.ABILITY_ID_BALARD_SILENCE, + name: 'Эхо Безмолвия', + cost: GAME_CONFIG.BALARD_SILENCE_ABILITY_COST, + type: GAME_CONFIG.ACTION_TYPE_DISABLE, + descriptionFunction: (configToUse, opponentBaseStats) => `Шанс ${configToUse.SILENCE_SUCCESS_RATE * 100}% заглушить случайное заклинание Елены на ${configToUse.SILENCE_DURATION} х.`, + condition: (opSt, plSt, currentGameState, configToUse) => { + const hpPercent = (opSt.currentHp / opSt.maxHp) * 100; + const isElenaAlreadySilenced = currentGameState?.player.disabledAbilities?.length > 0 || + currentGameState?.player.activeEffects?.some(eff => eff.id.startsWith('playerSilencedOn_')); // Проверяем и специфичное, и общее безмолвие на цели + const isElenaFullySilenced = currentGameState?.player.activeEffects?.some(eff => eff.isFullSilence && eff.turnsLeft > 0); + + return hpPercent >= configToUse.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && !isElenaFullySilenced && (opSt.silenceCooldownTurns === undefined || opSt.silenceCooldownTurns <= 0); + }, + successRateFromConfig: 'SILENCE_SUCCESS_RATE', + durationFromConfig: 'SILENCE_DURATION', + internalCooldownFromConfig: 'BALARD_SILENCE_INTERNAL_COOLDOWN' + }, + { + id: GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN, + name: 'Похищение Света', + cost: 10, + type: GAME_CONFIG.ACTION_TYPE_DRAIN, + powerManaDrain: 5, + powerDamage: 5, + powerHealthGainFactor: 1.0, + description: `Вытягивает 5 Маны у Елены, наносит 5 урона и восстанавливает себе здоровье (100% от украденного).`, + condition: (opSt, plSt, currentGameState, configToUse) => { + const playerManaPercent = (plSt.currentResource / plSt.maxResource) * 100; + const playerHasHighMana = playerManaPercent > (configToUse.BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD || 60); + return playerHasHighMana && (opSt.manaDrainCooldownTurns === undefined || opSt.manaDrainCooldownTurns <= 0); + }, + internalCooldownValue: 3 + } +]; + +// Способности Альмагест (PvP - зеркало Елены) +const almagestAbilities = [ + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL, + name: 'Темное Восстановление', + cost: 20, + type: GAME_CONFIG.ACTION_TYPE_HEAL, + power: 30, + description: 'Поглощает жизненные тени, восстанавливая ~30 HP' + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE, + name: 'Теневой Сгусток', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DAMAGE, + power: 25, + description: 'Запускает сгусток чистой тьмы, нанося ~25 урона врагу' + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK, + name: 'Усиление Тьмой', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 4, + descriptionFunction: (configToUse, opponentBaseStats) => `Восст. ${configToUse.NATURE_STRENGTH_MANA_REGEN} Темной Энергии при след. атаке. Эффект длится ${4 - 1} хода после применения.`, + isDelayed: true + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE, + name: 'Щит Пустоты', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 3, + grantsBlock: true, + descriptionFunction: (configToUse, opponentBaseStats) => `Создает щит, снижающий урон на ${configToUse.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)` + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE, + name: 'Раскол Разума', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DISABLE, + effectDuration: 2, + cooldown: 6, + power: 5, + description: 'Вторгается в разум противника, накладывая полное безмолвие на 2 хода и нанося 5 урона каждый его ход. КД: 6 х.' + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF, + name: 'Проклятие Увядания', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DEBUFF, + effectDuration: 3, + power: 10, + cooldown: 5, + descriptionFunction: (configToUse, oppStats) => `Накладывает проклятие, истощающее 10 ${oppStats ? oppStats.resourceName : 'ресурса'} противника каждый его ход в течение 3 ходов. КД: 5 х.` + } +]; + +module.exports = { + elenaAbilities, + balardAbilities, + almagestAbilities +}; \ No newline at end of file diff --git a/server/data/characterStats.js b/server/data/characterStats.js new file mode 100644 index 0000000..3695bc4 --- /dev/null +++ b/server/data/characterStats.js @@ -0,0 +1,47 @@ +// /server/data/characterStats.js + +const GAME_CONFIG = require('../core/config'); // Путь к конфигу из server/data/ в server/core/ + +// --- Базовые Статы Персонажей --- + +const elenaBaseStats = { + id: GAME_CONFIG.PLAYER_ID, // Технический ID слота (может быть player или opponent в PvP) + characterKey: 'elena', // Уникальный ключ персонажа + name: "Елена", + maxHp: 120, + maxResource: 150, + attackPower: 15, + resourceName: "Мана", + avatarPath: 'images/elena_avatar.webp' // Путь к аватару +}; + +const balardBaseStats = { // Балард (для AI и, возможно, PvP) + id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота (обычно opponent) + characterKey: 'balard', // Уникальный ключ персонажа + name: "Балард", + maxHp: 140, + maxResource: 100, + attackPower: 20, + resourceName: "Ярость", + avatarPath: 'images/balard_avatar.jpg' // Путь к аватару +}; + +const almagestBaseStats = { // Альмагест (для PvP) + id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота (может быть player или opponent в PvP) + characterKey: 'almagest', // Уникальный ключ персонажа + name: "Альмагест", + maxHp: 120, // Статы как у Елены для зеркальности + maxResource: 150, + attackPower: 15, + resourceName: "Темная Энергия", + avatarPath: 'images/almagest_avatar.webp' // Путь к аватару +}; + +// Можно добавить других персонажей здесь, если потребуется + +module.exports = { + elenaBaseStats, + balardBaseStats, + almagestBaseStats + // ...и другие персонажи +}; \ No newline at end of file diff --git a/server/data/dataUtils.js b/server/data/dataUtils.js new file mode 100644 index 0000000..e9f0c9b --- /dev/null +++ b/server/data/dataUtils.js @@ -0,0 +1,72 @@ +// /server/data/dataUtils.js + +// Импортируем непосредственно определенные статы и способности +const { elenaBaseStats, balardBaseStats, almagestBaseStats } = require('./characterStats'); +const { elenaAbilities, balardAbilities, almagestAbilities } = require('./characterAbilities'); +// const { tauntSystem } = require('./taunts'); // Если нужны утилиты для насмешек + +/** + * Получает полный набор данных для персонажа по его ключу. + * Включает базовые статы и список способностей. + * @param {string} characterKey - Ключ персонажа ('elena', 'balard', 'almagest'). + * @returns {{baseStats: object, abilities: Array}|null} Объект с данными или null, если ключ неизвестен. + */ +function getCharacterData(characterKey) { + if (!characterKey) { + console.warn("[DataUtils] getCharacterData_called_with_null_or_undefined_key"); + return null; + } + switch (characterKey.toLowerCase()) { // Приводим к нижнему регистру для надежности + case 'elena': + return { baseStats: elenaBaseStats, abilities: elenaAbilities }; + case 'balard': + return { baseStats: balardBaseStats, abilities: balardAbilities }; + case 'almagest': + return { baseStats: almagestBaseStats, abilities: almagestAbilities }; + default: + console.error(`[DataUtils] getCharacterData: Unknown character key "${characterKey}"`); + return null; + } +} + +/** + * Получает только базовые статы для персонажа по его ключу. + * @param {string} characterKey - Ключ персонажа. + * @returns {object|null} Объект базовых статов или null. + */ +function getCharacterBaseStats(characterKey) { + const charData = getCharacterData(characterKey); + return charData ? charData.baseStats : null; +} + +/** + * Получает только список способностей для персонажа по его ключу. + * @param {string} characterKey - Ключ персонажа. + * @returns {Array|null} Массив способностей или null. + */ +function getCharacterAbilities(characterKey) { + const charData = getCharacterData(characterKey); + return charData ? charData.abilities : null; +} + +/** + * Получает имя персонажа по его ключу. + * @param {string} characterKey - Ключ персонажа. + * @returns {string|null} Имя персонажа или null. + */ +function getCharacterName(characterKey) { + const baseStats = getCharacterBaseStats(characterKey); + return baseStats ? baseStats.name : null; +} + +// Можно добавить другие утилитарные функции по мере необходимости, +// например, для поиска конкретной способности по ID у персонажа, +// или для получения данных для инициализации gameState и т.д. + +module.exports = { + getCharacterData, + getCharacterBaseStats, + getCharacterAbilities, + getCharacterName + // ...другие экспортируемые утилиты +}; \ No newline at end of file diff --git a/server/data/index.js b/server/data/index.js new file mode 100644 index 0000000..b126ba9 --- /dev/null +++ b/server/data/index.js @@ -0,0 +1,75 @@ +// /server/data/index.js + +// Импортируем отдельные части игровых данных +const { elenaBaseStats, balardBaseStats, almagestBaseStats } = require('./characterStats'); +const { elenaAbilities, balardAbilities, almagestAbilities } = require('./characterAbilities'); +const { tauntSystem } = require('./taunts'); // Предполагается, что taunts.js экспортирует объект tauntSystem + +// Собираем все данные в один объект gameData, +// который будет использоваться в других частях серверной логики (например, gameLogic, GameInstance). +// Эта структура аналогична той, что была в вашем исходном большом файле data.js. +const gameData = { + // Базовые статы персонажей по их ключам для удобного доступа + // (хотя dataUtils.js теперь предоставляет функции для этого, + // можно оставить и такую структуру для обратной совместимости или прямого доступа, если нужно) + baseStats: { + elena: elenaBaseStats, + balard: balardBaseStats, + almagest: almagestBaseStats + }, + + // Способности персонажей по их ключам + abilities: { + elena: elenaAbilities, + balard: balardAbilities, + almagest: almagestAbilities + }, + + // Система насмешек + tauntSystem: tauntSystem, + + + // Если вы хотите сохранить оригинальную структуру вашего предыдущего data.js, + // где были прямые ссылки на playerBaseStats, opponentBaseStats и т.д., + // вы можете добавить их сюда. Однако, с новой структурой dataUtils.js + // это становится менее необходимым, так как dataUtils предоставляет + // функции для получения данных по characterKey. + // Для примера, если бы playerBaseStats всегда был Елена, а opponentBaseStats всегда Балард: + // playerBaseStats: elenaBaseStats, // Обычно Елена + // opponentBaseStats: balardBaseStats, // Обычно Балард (AI) + // almagestBaseStats: almagestBaseStats, // Для Альмагест (PvP) + // playerAbilities: elenaAbilities, + // opponentAbilities: balardAbilities, // Способности Баларда (AI) + // almagestAbilities: almagestAbilities, + + // Рекомендуемый подход: экспортировать данные, сгруппированные по персонажам, + // а для получения данных конкретного "игрока" или "оппонента" в игре + // использовать dataUtils.getCharacterData(characterKey) в GameInstance/GameManager. + // Это более гибко, так как в PvP Елена может быть оппонентом, а Альмагест - игроком. +}; + +// Экспортируем собранный объект gameData +module.exports = gameData; + +/* +Примечание: +В GameInstance, GameManager, gameLogic и других модулях, где раньше был: +const gameData = require('./data'); // или другой путь к старому data.js + +Теперь будет: +const gameData = require('../data'); // или '../data/index.js' - Node.js поймет и так +или +const dataUtils = require('../data/dataUtils'); + +И если вы используете gameData напрямую: +const elenaStats = gameData.baseStats.elena; +const balardAbils = gameData.abilities.balard; + +Если используете dataUtils: +const elenaFullData = dataUtils.getCharacterData('elena'); +const balardAbils = dataUtils.getCharacterAbilities('balard'); + +Выбор зависит от того, насколько гранулированный доступ вам нужен в каждом конкретном месте. +Объект gameData, экспортируемый этим файлом, может быть полезен для gameLogic, +где функции могут ожидать всю структуру данных сразу. +*/ \ No newline at end of file diff --git a/server/data/taunts.js b/server/data/taunts.js new file mode 100644 index 0000000..d0f5fce --- /dev/null +++ b/server/data/taunts.js @@ -0,0 +1,118 @@ +// /server/data/taunts.js + +// Предполагается, что GAME_CONFIG будет доступен в контексте, где используются эти насмешки, +// обычно он передается в функции игровой логики (например, serverGameLogic.getRandomTaunt). +// Если вы хотите использовать GAME_CONFIG.ABILITY_ID_... прямо здесь, вам нужно его импортировать: +const GAME_CONFIG = require('../core/config'); // Путь к конфигу + +const tauntSystem = { + elena: { // Насмешки Елены + balard: { // Против Баларда (AI) + // Триггер: Елена использует СВОЮ способность + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", "Я черпаю силы в Истине." ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", "Пусть твой мрак сгорит!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", "Гармония природы со мной." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", "Моя вера - моя защита." ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри мне в глаза, Балард. И слушай тишину.", "Твой разум - в моей власти." ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя ярость иссякнет, как вода в песке, Балард!", "Твоя сила угасает." ] + }, + // Триггер: Противник (Балард) совершает действие + onOpponentAction: { + [GAME_CONFIG.ABILITY_ID_BALARD_HEAL]: [ "Пытаешься отсрочить неизбежное жалкой темной силой?" ], + [GAME_CONFIG.ABILITY_ID_BALARD_SILENCE]: { // Реакция на "Эхо Безмолвия" Баларда + success: [ "(Сдавленный вздох)... Ничтожная попытка заглушить Слово!" ], // Если Балард успешно заглушил Елену + fail: [ "Твой шепот Тьмы слаб против Света Истины!" ] // Если попытка Баларда провалилась + }, + [GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN]: [ "Ты питаешься Светом, как паразит?!" ], + // Эти два триггера используются, когда АТАКА ОППОНЕНТА (Баларда) попадает по Елене или блокируется Еленой + attackBlocked: [ "Твои удары тщетны перед щитом Порядка." ], // Елена блокирует атаку Баларда + attackHits: [ "(Шипение боли)... Боль – лишь напоминание о твоем предательстве." ] // Атака Баларда попадает по Елене + }, + // Триггер: Базовая атака Елены + basicAttack: { + // 'merciful' и 'dominating' используются в gameLogic.getRandomTaunt в зависимости от HP Баларда + merciful: [ "Балард, прошу, остановись. Еще не поздно.", "Подумай о том, что потерял." ], + dominating: [ + "Глина не спорит с гончаром, Балард!", + "Ты ИЗБРАЛ эту гниль! Получай возмездие!", + "Самый страшный грех - грех неблагодарности!", + "Я сотру тебя с лика этой земли!" + ], + general: [ // Общие фразы, если специфичные не подходят (например, если PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT не используется) + "Свет покарает тебя, Балард!", + "За все свои деяния ты ответишь!" + ] + }, + // Триггер: Изменение состояния боя + onBattleState: { + start: [ "Балард, есть ли еще путь назад?" ], // Начало AI боя с Балардом + opponentNearDefeat: [ "Конец близок, Балард. Прими свою судьбу." ] // Балард почти побежден + } + }, + almagest: { // Против Альмагест (PvP) + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Я исцеляюсь Светом, который ты отвергла.", "Жизнь восторжествует над твоей некромантией!", "Мое сияние не померкнет." ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Очищающий огонь для твоей тьмы!", "Почувствуй гнев праведного Света!", "Это пламя ярче твоих теней!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Природа дает мне силу, а тебе - лишь презрение.", "Я черпаю из источника жизни, ты - из могилы." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Мой щит отразит твою злобу.", "Свет - лучшая защита.", "Твои темные чары не пройдут!" ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри в глаза Истине, колдунья!", "Твои лживые речи умолкнут!", "Хватит прятаться за иллюзиями!" ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя темная сила иссякнет!", "Я ослабляю твою связь с бездной!", "Почувствуй, как тает твоя энергия!" ] + }, + onOpponentAction: { // Реакции Елены на действия Альмагест + [GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Лечишь раны тьмой? Она лишь глубже проникнет в тебя.", "Твоя магия несет лишь порчу, даже исцеляя." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Твоя тень лишь царапает, не ранит.", "Слабый удар! Тьма делает тебя немощной." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Черпаешь силы из бездны? Она поглотит и тебя.", "Твое усиление - лишь агония искаженной энергии." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Щит из теней? Он рассыпется прахом!", "Твоя защита иллюзорна, как и твоя сила." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "(Сдавленно) Твои ментальные атаки отвратительны!", "Тьма в моей голове... я вырвусь!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Истощаешь мою силу? Я восстановлю ее Светом!", "Твое проклятие слабо." ], + attackBlocked: [ "Твоя атака разбилась о мой щит Света!", "Предсказуемо и слабо, Альмагест." ], + attackHits: [ "(Резкий вздох) Коснулась... Но Свет исцелит рану.", "Эта царапина - ничто!", "Ты заплатишь за это!" ] + }, + basicAttack: { + general: [ "Тьма не победит, Альмагест!", "Твои иллюзии рассеются перед Светом!", "Пока я стою, порядок будет восстановлен!" ] + }, + onBattleState: { + start: [ "Альмагест! Твоим темным делам пришел конец!", "Во имя Света, я остановлю тебя!", "Приготовься к битве, служительница тьмы!" ], + opponentNearDefeat: [ "Твоя тьма иссякает, колдунья!", "Сдавайся, пока Свет не испепелил тебя!", "Конец твоим злодеяниям близок!" ] + } + } + }, + almagest: { // Насмешки Альмагест + elena: { // Против Елены (PvP) + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Я питаюсь слабостью, Елена!", "Тьма дает мне силу!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Почувствуй холод бездны!", "Твой Свет померкнет перед моей тенью!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Силы Бездны со мной!", "Моя тень становится гуще!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Мой щит выкован из самой тьмы!", "Попробуй пробить это, служительница Света!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "Твой разум сломлен!", "Умолкни, Светлая!", "Я владею твоими мыслями!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Твоя сила тает!", "Почувствуй гниль!", "Я истощаю твой Свет!" ] + }, + onOpponentAction: { // Реакции Альмагест на действия Елены + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Исцеляешься? Твои раны слишком глубоки!" ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Жалкое пламя! Мои тени поглотят его!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сила земли? Смешно! Бездну ничто не остановит." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Твой щит из Света не спасет тебя от Тьмы!" ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "(Сдавленно, затем смех) Попытка управлять моим разумом? Жалко!", "Ты пытаешься заглянуть в Бездну?!" ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Моя энергия вечна, дура!", "Это лишь раздражение!" ], + attackBlocked: [ "Твой блок не спасет тебя вечно, Елена!", "Это лишь задержка." ], + attackHits: [ "Ха! Чувствуешь силу Тьмы?", "Это только начало!", "Слабость!" ] + }, + basicAttack: { + general: [ "Почувствуй мою силу!", "Тени атакуют!", "Я наношу удар!" ] + }, + onBattleState: { + start: [ "Тысяча лет в заточении лишь усилили меня, Елена!", "Твой Свет скоро погаснет!", "Пора положить конец твоему господству!" ], + opponentNearDefeat: [ "Твой Свет гаснет!", "Ты побеждена!", "Бездне нужен твой дух!" ] + } + } + // Можно добавить секцию для Альмагест против Баларда, если такой бой возможен и нужен + // balard: { ... } + } + // Балард пока не имеет своей системы насмешек (он AI и его "реплики" могут быть частью логов его действий) + // Если Балард станет играбельным PvP персонажем, сюда можно будет добавить секцию balard: { elena: {...}, almagest: {...} } +}; + +module.exports = { + tauntSystem +}; \ No newline at end of file diff --git a/server/game/GameManager.js b/server/game/GameManager.js new file mode 100644 index 0000000..6e15e5a --- /dev/null +++ b/server/game/GameManager.js @@ -0,0 +1,343 @@ +// /server/game/GameManager.js +const { v4: uuidv4 } = require('uuid'); +const GameInstance = require('./instance/GameInstance'); +const dataUtils = require('../data/dataUtils'); +const GAME_CONFIG = require('../core/config'); + +class GameManager { + constructor(io) { + this.io = io; + this.games = {}; + this.userIdentifierToGameId = {}; + this.pendingPvPGames = []; + console.log("[GameManager] Initialized."); + } + + _removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) { + const oldPendingGameId = this.userIdentifierToGameId[identifier]; + if (oldPendingGameId && oldPendingGameId !== excludeGameId && this.games[oldPendingGameId]) { + const gameToRemove = this.games[oldPendingGameId]; + if (gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) { + const oldOwnerInfo = Object.values(gameToRemove.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + if (oldOwnerInfo && (oldOwnerInfo.identifier === identifier)) { + console.log(`[GameManager] Пользователь ${identifier} (сокет: ${currentSocketId}) создал/присоединился к новой игре. Удаляем его предыдущую ожидающую игру: ${oldPendingGameId}`); + this._cleanupGame(oldPendingGameId, 'replaced_by_new_game'); + } + } + } + } + + createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', identifier) { + this._removePreviousPendingGames(socket.id, identifier); + if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { + socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); + this.handleRequestGameState(socket, identifier); + return; + } + + const gameId = uuidv4(); + const game = new GameInstance(gameId, this.io, mode, this); + game.ownerIdentifier = identifier; // Устанавливаем владельца игры + this.games[gameId] = game; + const charKeyForInstance = (mode === 'pvp') ? chosenCharacterKey : 'elena'; + + if (game.addPlayer(socket, charKeyForInstance, identifier)) { + this.userIdentifierToGameId[identifier] = gameId; + console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${identifier} (выбран: ${charKeyForInstance})`); + const assignedPlayerId = game.players[socket.id]?.id; + + if (!assignedPlayerId) { + this._cleanupGame(gameId, 'player_add_failed_no_role'); + socket.emit('gameError', { message: 'Ошибка сервера при создании игры (роль).' }); + return; + } + socket.emit('gameCreated', { gameId: gameId, mode: mode, yourPlayerId: assignedPlayerId }); + + if ((game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2)) { + const isInitialized = game.initializeGame(); + if (isInitialized) game.startGame(); + else this._cleanupGame(gameId, 'initialization_failed_on_create'); + + if (game.mode === 'pvp' && game.playerCount === 2) { // Если PvP заполнилась + const idx = this.pendingPvPGames.indexOf(gameId); + if (idx > -1) this.pendingPvPGames.splice(idx, 1); + this.broadcastAvailablePvPGames(); + } + } else if (mode === 'pvp' && game.playerCount === 1) { + if (!this.pendingPvPGames.includes(gameId)) this.pendingPvPGames.push(gameId); + game.initializeGame(); + socket.emit('waitingForOpponent'); + this.broadcastAvailablePvPGames(); + } + } else { + this._cleanupGame(gameId, 'player_add_failed_instance'); + } + } + + joinGame(socket, gameId, identifier) { // identifier - это userId присоединяющегося + const game = this.games[gameId]; + if (!game) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; } + if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться как к PvP.' }); return; } + if (game.playerCount >= 2) { socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; } + + // === ИЗМЕНЕНИЕ: Запрет присоединения к своей же игре === + if (game.ownerIdentifier === identifier) { + socket.emit('gameError', { message: 'Вы не можете присоединиться к игре, которую сами создали и ожидаете.' }); + // Можно отправить состояние этой игры, если она действительно ожидает + this.handleRequestGameState(socket, identifier); + return; + } + // === КОНЕЦ ИЗМЕНЕНИЯ === + + if (this.userIdentifierToGameId[identifier] && this.userIdentifierToGameId[identifier] !== gameId) { + socket.emit('gameError', { message: 'Вы уже находитесь в другой активной игре.' }); + this.handleRequestGameState(socket, identifier); + return; + } + // Проверка на случай, если игрок пытается присоединиться к игре, где он уже есть (хотя ownerIdentifier проверка выше это частично покрывает для создателя) + const existingPlayerInThisGame = Object.values(game.players).find(p => p.identifier === identifier); + if (existingPlayerInThisGame) { + socket.emit('gameError', { message: 'Вы уже находитесь в этой игре.' }); + this.handleRequestGameState(socket, identifier); // Отправляем состояние игры + return; + } + + + this._removePreviousPendingGames(socket.id, identifier, gameId); + + if (game.addPlayer(socket, null, identifier)) { + this.userIdentifierToGameId[identifier] = gameId; + console.log(`[GameManager] Игрок ${identifier} присоединился к PvP игре ${gameId}`); + + if (game.mode === 'pvp' && game.playerCount === 2) { + const isInitialized = game.initializeGame(); + if (isInitialized) game.startGame(); + else this._cleanupGame(gameId, 'initialization_failed_on_join'); + + const idx = this.pendingPvPGames.indexOf(gameId); + if (idx > -1) this.pendingPvPGames.splice(idx, 1); + this.broadcastAvailablePvPGames(); + } + } + } + + findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) { + this._removePreviousPendingGames(socket.id, identifier); + if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { + socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); + this.handleRequestGameState(socket, identifier); + return; + } + + let gameIdToJoin = null; + const preferredOpponentKey = chosenCharacterKeyForCreation === 'elena' ? 'almagest' : 'elena'; + + // Ищем игру, созданную НЕ текущим пользователем + for (const id of [...this.pendingPvPGames]) { + const pendingGame = this.games[id]; + // === ИЗМЕНЕНИЕ: Убеждаемся, что не присоединяемся к игре, которую сами создали и ожидаем === + if (pendingGame && pendingGame.mode === 'pvp' && pendingGame.playerCount === 1 && pendingGame.ownerIdentifier !== identifier) { + // === КОНЕЦ ИЗМЕНЕНИЯ === + const firstPlayerInfo = Object.values(pendingGame.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === preferredOpponentKey) { + gameIdToJoin = id; break; + } + if (!gameIdToJoin) gameIdToJoin = id; // Берем первую подходящую, если нет с нужным персонажем + } + } + + if (gameIdToJoin) { + this.joinGame(socket, gameIdToJoin, identifier); + } else { + this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier); + // Сообщение о создании новой игры отправляется из createGame/initializeGame/startGame + } + } + + handlePlayerAction(identifier, actionData) { + const gameId = this.userIdentifierToGameId[identifier]; + const game = this.games[gameId]; + if (game) { + const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); + const currentSocketId = playerInfo?.socket?.id; + if (playerInfo && currentSocketId) { + const actualSocket = this.io.sockets.sockets.get(currentSocketId); + if (actualSocket?.connected) game.processPlayerAction(currentSocketId, actionData); + else console.warn(`[GameManager] Игрок ${identifier}: действие, но сокет ${currentSocketId} отключен.`); + } else { + console.warn(`[GameManager] Игрок ${identifier}: действие для игры ${gameId}, но не найден в game.players.`); + delete this.userIdentifierToGameId[identifier]; + const s = this.io.sockets.sockets.get(identifier) || playerInfo?.socket; + if (s) s.emit('gameNotFound', { message: 'Ваша игровая сессия потеряна (ошибка игрока).' }); + } + } else { + console.warn(`[GameManager] Игрок ${identifier}: действие, но игра ${gameId} не найдена.`); + delete this.userIdentifierToGameId[identifier]; + const s = this.io.sockets.sockets.get(identifier); + if (s) s.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена.' }); + } + } + + handleDisconnect(socketId, identifier) { + const gameId = this.userIdentifierToGameId[identifier]; + const game = this.games[gameId]; + + if (game) { + // Ищем игрока по ИДЕНТИФИКАТОРУ, так как сокет мог уже обновиться при переподключении + const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); + + if (playerInfo) { + // Проверяем, действительно ли отключается АКТУАЛЬНЫЙ сокет этого игрока + if (playerInfo.socket.id === socketId) { + console.log(`[GameManager] Актуальный сокет ${socketId} игрока ${identifier} отключился из игры ${gameId}.`); + const dPlayerRole = playerInfo.id; + const dCharKey = playerInfo.chosenCharacterKey; + + game.removePlayer(socketId); // Удаляем именно этот сокет из игры + + if (game.playerCount === 0) { + this._cleanupGame(gameId, 'all_players_disconnected'); + } else if (game.mode === 'pvp' && game.playerCount === 1 && game.gameState && !game.gameState.isGameOver) { + game.endGameDueToDisconnect(socketId, dPlayerRole, dCharKey); + } else if (game.mode === 'ai' && game.playerCount === 0 && game.gameState && !game.gameState.isGameOver) { + game.endGameDueToDisconnect(socketId, dPlayerRole, dCharKey); // Завершаем AI игру, если игрок ушел + } + // Если игра уже была isGameOver, _cleanupGame был вызван ранее. + // userIdentifierToGameId[identifier] для отключившегося игрока УДАЛЯЕТСЯ здесь, + // чтобы он мог начать новую игру или переподключиться. + delete this.userIdentifierToGameId[identifier]; + } else { + // Отключился старый сокет (socketId), но у игрока (identifier) уже новый активный сокет. + // Ничего не делаем с игрой, так как игрок по-прежнему в ней с новым сокетом. + // Просто логируем, что старый сокет отвалился. + console.log(`[GameManager] Отключился старый сокет ${socketId} для игрока ${identifier}, который уже переподключился с сокетом ${playerInfo.socket.id} в игре ${gameId}.`); + // Связь userIdentifierToGameId[identifier] остается, так как он все еще в игре. + } + } else { + // Игрока с таким identifier нет в этой игре. + // Это может случиться, если игра была очищена до того, как пришло событие disconnect. + // console.log(`[GameManager] Отключившийся сокет ${socketId} (identifier: ${identifier}) не найден в активных игроках игры ${gameId} (возможно, игра уже очищена).`); + delete this.userIdentifierToGameId[identifier]; // На всякий случай. + } + } else { + // console.log(`[GameManager] Отключился сокет ${socketId} (identifier: ${identifier}). Активная игра не найдена по идентификатору.`); + delete this.userIdentifierToGameId[identifier]; + } + } + + _cleanupGame(gameId, reason = 'unknown') { + const game = this.games[gameId]; + if (!game) return false; + console.log(`[GameManager] Очистка игры ${gameId} (Причина: ${reason}).`); + + if (typeof game.turnTimer?.clear === 'function') game.turnTimer.clear(); + + Object.values(game.players).forEach(pInfo => { + if (pInfo?.identifier && this.userIdentifierToGameId[pInfo.identifier] === gameId) { + delete this.userIdentifierToGameId[pInfo.identifier]; + } + }); + if(game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId){ + delete this.userIdentifierToGameId[game.ownerIdentifier]; + } + + const pendingIdx = this.pendingPvPGames.indexOf(gameId); + if (pendingIdx > -1) this.pendingPvPGames.splice(pendingIdx, 1); + + delete this.games[gameId]; + this.broadcastAvailablePvPGames(); + return true; + } + + getAvailablePvPGamesListForClient() { + return this.pendingPvPGames.map(gameId => { + const game = this.games[gameId]; + if (game && game.mode === 'pvp' && game.playerCount === 1 && game.gameState && !game.gameState.isGameOver) { + const p1Info = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + let p1Username = 'Игрок'; + let p1CharName = ''; + let ownerId = game.ownerIdentifier; // === ИЗМЕНЕНИЕ: Получаем ownerId === + + if (p1Info) { + p1Username = p1Info.socket?.userData?.username || `User#${String(p1Info.identifier).substring(0,4)}`; + const charData = dataUtils.getCharacterBaseStats(p1Info.chosenCharacterKey); + p1CharName = charData?.name || p1Info.chosenCharacterKey; + } + return { + id: gameId, + status: `Ожидает (Создал: ${p1Username} за ${p1CharName})`, + ownerIdentifier: ownerId // === ИЗМЕНЕНИЕ: Отправляем ownerIdentifier клиенту === + }; + } + return null; + }).filter(info => info !== null); + } + + broadcastAvailablePvPGames() { + this.io.emit('availablePvPGamesList', this.getAvailablePvPGamesListForClient()); + } + + handleRequestGameState(socket, identifier) { + const gameId = this.userIdentifierToGameId[identifier]; + const game = gameId ? this.games[gameId] : null; + + if (game) { + const playerInfoInGameInstance = Object.values(game.players).find(p => p.identifier === identifier); + if (playerInfoInGameInstance) { + if (game.gameState?.isGameOver) { + delete this.userIdentifierToGameId[identifier]; + socket.emit('gameNotFound', { message: 'Ваша предыдущая игра уже завершена.' }); + return; + } + console.log(`[GameManager] Восстановление игры ${gameId} для ${identifier}. Новый сокет ${socket.id}.`); + const oldSocketId = playerInfoInGameInstance.socket?.id; // Добавил ?. на случай если сокета нет + if (oldSocketId && oldSocketId !== socket.id && game.players[oldSocketId]) { + delete game.players[oldSocketId]; + if(game.playerSockets[playerInfoInGameInstance.id]?.id === oldSocketId) { + delete game.playerSockets[playerInfoInGameInstance.id]; + } + } + playerInfoInGameInstance.socket = socket; + game.players[socket.id] = playerInfoInGameInstance; + game.playerSockets[playerInfoInGameInstance.id] = socket; + socket.join(game.id); + + const pCharKey = playerInfoInGameInstance.chosenCharacterKey; + const pData = dataUtils.getCharacterData(pCharKey); + const opponentRole = playerInfoInGameInstance.id === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const oCharKey = game.gameState?.[opponentRole]?.characterKey || (playerInfoInGameInstance.id === GAME_CONFIG.PLAYER_ID ? game.opponentCharacterKey : game.playerCharacterKey); + const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; // oData может быть null, если оппонента нет + + if (pData && (oData || !game.opponentCharacterKey) && game.gameState) { + socket.emit('gameStarted', { + gameId: game.id, yourPlayerId: playerInfoInGameInstance.id, initialGameState: game.gameState, + playerBaseStats: pData.baseStats, + opponentBaseStats: oData?.baseStats || dataUtils.getCharacterBaseStats(null) || {name: 'Ожидание...', maxHp:1}, // Заглушка если оппонента нет + playerAbilities: pData.abilities, + opponentAbilities: oData?.abilities || [], + log: game.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } + }); + if(game.mode === 'pvp' && game.playerCount === 1 && game.ownerIdentifier === identifier) socket.emit('waitingForOpponent'); + if (!game.gameState.isGameOver && game.turnTimer?.start) { + game.turnTimer.start(game.gameState.isPlayerTurn, (game.mode === 'ai' && !game.gameState.isPlayerTurn)); + } + } else { + this._handleGameRecoveryError(socket, gameId, identifier, 'data_load_fail_reconnect'); + } + } else { + this._handleGameRecoveryError(socket, gameId, identifier, 'player_not_in_instance_reconnect'); + } + } else { + socket.emit('gameNotFound', { message: 'Активная игровая сессия не найдена.' }); + } + } + + _handleGameRecoveryError(socket, gameId, identifier, reasonCode) { + console.error(`[GameManager] Ошибка восстановления игры ${gameId} для ${identifier} (причина: ${reasonCode}).`); + socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' }); + this._cleanupGame(gameId, `recovery_error_${reasonCode}`); + socket.emit('gameNotFound', { message: 'Ваша игровая сессия была завершена из-за ошибки.' }); + } +} + +module.exports = GameManager; \ No newline at end of file diff --git a/server/game/instance/GameInstance.js b/server/game/instance/GameInstance.js new file mode 100644 index 0000000..7ac1175 --- /dev/null +++ b/server/game/instance/GameInstance.js @@ -0,0 +1,474 @@ +// /server/game/instance/GameInstance.js +const { v4: uuidv4 } = require('uuid'); +const TurnTimer = require('./TurnTimer'); +const gameLogic = require('../logic'); // Импортирует index.js из папки logic +const dataUtils = require('../../data/dataUtils'); +const GAME_CONFIG = require('../../core/config'); // <--- УБЕДИТЕСЬ, ЧТО GAME_CONFIG ИМПОРТИРОВАН + +class GameInstance { + constructor(gameId, io, mode = 'ai', gameManager) { + this.id = gameId; + this.io = io; + this.mode = mode; + this.players = {}; + this.playerSockets = {}; + this.playerCount = 0; + this.gameState = null; + this.aiOpponent = (mode === 'ai'); + this.logBuffer = []; + this.playerCharacterKey = null; + this.opponentCharacterKey = null; + this.ownerIdentifier = null; + this.gameManager = gameManager; + + this.turnTimer = new TurnTimer( + GAME_CONFIG.TURN_DURATION_MS, + GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS, + () => this.handleTurnTimeout(), + (remainingTime, isPlayerTurnForTimer) => { + this.io.to(this.id).emit('turnTimerUpdate', { remainingTime, isPlayerTurn: isPlayerTurnForTimer }); + } + ); + + if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') { + console.error(`[GameInstance ${this.id}] CRITICAL ERROR: GameManager reference invalid.`); + } + console.log(`[GameInstance ${this.id}] Created. Mode: ${mode}.`); + } + + addPlayer(socket, chosenCharacterKey = 'elena', identifier) { + if (this.players[socket.id]) { + socket.emit('gameError', { message: 'Ваш сокет уже зарегистрирован в этой игре.' }); + return false; + } + const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier); + if (existingPlayerByIdentifier) { + socket.emit('gameError', { message: 'Вы уже находитесь в этой игре под другим подключением.' }); + return false; + } + if (this.playerCount >= 2) { + socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); + return false; + } + + let assignedPlayerId; + let actualCharacterKey; + + if (this.mode === 'ai') { + if (this.playerCount > 0) { + socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); + return false; + } + assignedPlayerId = GAME_CONFIG.PLAYER_ID; + actualCharacterKey = 'elena'; + this.ownerIdentifier = identifier; + } else { // PvP + if (this.playerCount === 0) { + assignedPlayerId = GAME_CONFIG.PLAYER_ID; + actualCharacterKey = (chosenCharacterKey === 'almagest') ? 'almagest' : 'elena'; + this.ownerIdentifier = identifier; + } else { + assignedPlayerId = GAME_CONFIG.OPPONENT_ID; + const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + actualCharacterKey = (firstPlayerInfo?.chosenCharacterKey === 'elena') ? 'almagest' : 'elena'; + } + } + + this.players[socket.id] = { + id: assignedPlayerId, socket: socket, + chosenCharacterKey: actualCharacterKey, identifier: identifier + }; + this.playerSockets[assignedPlayerId] = socket; + this.playerCount++; + socket.join(this.id); + + const characterBaseStats = dataUtils.getCharacterBaseStats(actualCharacterKey); + console.log(`[GameInstance ${this.id}] Игрок ${identifier} (сокет ${socket.id}) (${characterBaseStats?.name || 'N/A'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Игроков: ${this.playerCount}.`); + return true; + } + + removePlayer(socketId) { + const playerInfo = this.players[socketId]; + if (playerInfo) { + const playerRole = playerInfo.id; + console.log(`[GameInstance ${this.id}] Игрок ${playerInfo.identifier} (сокет: ${socketId}, роль: ${playerRole}) покинул игру.`); + if (playerInfo.socket) { try { playerInfo.socket.leave(this.id); } catch (e) { /* ignore */ } } + delete this.players[socketId]; + this.playerCount--; + if (this.playerSockets[playerRole]?.id === socketId) { + delete this.playerSockets[playerRole]; + } + if (this.gameState && !this.gameState.isGameOver) { + const isTurnOfDisconnected = (this.gameState.isPlayerTurn && playerRole === GAME_CONFIG.PLAYER_ID) || + (!this.gameState.isPlayerTurn && playerRole === GAME_CONFIG.OPPONENT_ID); + if (isTurnOfDisconnected) this.turnTimer.clear(); + } + } + } + + initializeGame() { + console.log(`[GameInstance ${this.id}] Инициализация состояния игры. Режим: ${this.mode}. Игроков: ${this.playerCount}.`); + if (this.mode === 'ai' && this.playerCount === 1) { + this.playerCharacterKey = 'elena'; this.opponentCharacterKey = 'balard'; + } else if (this.mode === 'pvp' && this.playerCount === 2) { + const p1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + this.playerCharacterKey = p1Info?.chosenCharacterKey || 'elena'; + this.opponentCharacterKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; + } else if (this.mode === 'pvp' && this.playerCount === 1) { + const p1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + this.playerCharacterKey = p1Info?.chosenCharacterKey || 'elena'; + this.opponentCharacterKey = null; + } else { + console.error(`[GameInstance ${this.id}] Некорректное состояние для инициализации!`); return false; + } + + const playerData = dataUtils.getCharacterData(this.playerCharacterKey); + let opponentData = null; + const isOpponentDefined = !!this.opponentCharacterKey; + if (isOpponentDefined) opponentData = dataUtils.getCharacterData(this.opponentCharacterKey); + + if (!playerData || (isOpponentDefined && !opponentData)) { + this._handleCriticalError('init_char_data_fail', 'Ошибка загрузки данных персонажей при инициализации.'); + return false; + } + if (isOpponentDefined && (!opponentData.baseStats.maxHp || opponentData.baseStats.maxHp <= 0)) { + this._handleCriticalError('init_opponent_hp_fail', 'Некорректные HP оппонента при инициализации.'); + return false; + } + + this.gameState = { + player: this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities), + opponent: isOpponentDefined ? + this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities) : + this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: 'Ожидание игрока...', maxHp: 1, maxResource: 0, resourceName: 'Ресурс', attackPower: 0, characterKey: null }, []), // Плейсхолдер + isPlayerTurn: isOpponentDefined ? Math.random() < 0.5 : true, + isGameOver: false, turnNumber: 1, gameMode: this.mode + }; + + if (isOpponentDefined) { + this.logBuffer = []; + this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); + const pCharKey = this.gameState.player.characterKey; + const oCharKey = this.gameState.opponent.characterKey; // Нужен ключ оппонента для контекста + if ((pCharKey === 'elena' || pCharKey === 'almagest') && oCharKey) { + const opponentFullDataForTaunt = dataUtils.getCharacterData(oCharKey); // Получаем полные данные оппонента + const startTaunt = gameLogic.getRandomTaunt(pCharKey, 'battleStart', {}, GAME_CONFIG, opponentFullDataForTaunt, this.gameState); + if (startTaunt !== "(Молчание)") this.addToLog(`${this.gameState.player.name}: "${startTaunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + } + } + console.log(`[GameInstance ${this.id}] Состояние игры инициализировано. Готовность к старту: ${isOpponentDefined}`); + return isOpponentDefined; + } + + _createFighterState(roleId, baseStats, abilities) { + const fighterState = { + id: roleId, characterKey: baseStats.characterKey, name: baseStats.name, + currentHp: baseStats.maxHp, maxHp: baseStats.maxHp, + currentResource: baseStats.maxResource, maxResource: baseStats.maxResource, + resourceName: baseStats.resourceName, attackPower: baseStats.attackPower, + isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {} + }; + (abilities || []).forEach(ability => { // Добавлена проверка abilities + if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { + fighterState.abilityCooldowns[ability.id] = 0; + } + }); + if (baseStats.characterKey === 'balard') { + fighterState.silenceCooldownTurns = 0; + fighterState.manaDrainCooldownTurns = 0; + } + return fighterState; + } + + startGame() { + if (!this.gameState || !this.gameState.opponent?.characterKey) { + this._handleCriticalError('start_game_not_ready', 'Попытка старта не полностью готовой игры.'); + return; + } + console.log(`[GameInstance ${this.id}] Запуск игры.`); + + const pData = dataUtils.getCharacterData(this.playerCharacterKey); + const oData = dataUtils.getCharacterData(this.opponentCharacterKey); + if (!pData || !oData) { this._handleCriticalError('start_char_data_fail', 'Ошибка данных персонажей при старте.'); return; } + + Object.values(this.players).forEach(playerInfo => { + if (playerInfo.socket?.connected) { + const dataForClient = playerInfo.id === GAME_CONFIG.PLAYER_ID ? + { playerBaseStats: pData.baseStats, opponentBaseStats: oData.baseStats, playerAbilities: pData.abilities, opponentAbilities: oData.abilities } : + { playerBaseStats: oData.baseStats, opponentBaseStats: pData.baseStats, playerAbilities: oData.abilities, opponentAbilities: pData.abilities }; + playerInfo.socket.emit('gameStarted', { + gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, + ...dataForClient, log: this.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } + }); + } + }); + + const firstTurnActor = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; + this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${firstTurnActor.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + this.broadcastLogUpdate(); + this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); + + if (!this.gameState.isPlayerTurn && this.aiOpponent) { + setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); + } + } + + processPlayerAction(requestingSocketId, actionData) { + if (!this.gameState || this.gameState.isGameOver) return; + const actingPlayerInfo = this.players[requestingSocketId]; + if (!actingPlayerInfo) { console.error(`[GameInstance ${this.id}] Действие от неизвестного сокета ${requestingSocketId}`); return; } + + const actingPlayerRole = actingPlayerInfo.id; + const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || + (!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID); + if (!isCorrectTurn) { console.warn(`[GameInstance ${this.id}] Действие от ${actingPlayerInfo.identifier}: не его ход.`); return; } + + this.turnTimer.clear(); + + const attackerState = this.gameState[actingPlayerRole]; + const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const defenderState = this.gameState[defenderRole]; + const attackerData = dataUtils.getCharacterData(attackerState.characterKey); + const defenderData = dataUtils.getCharacterData(defenderState.characterKey); + + if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail', 'Ошибка данных персонажа при действии.'); return; } + + let actionValid = true; + let tauntContextTargetData = defenderData; // Данные цели для контекста насмешек + + if (actionData.actionType === 'attack') { + const taunt = gameLogic.getRandomTaunt(attackerState.characterKey, 'basicAttack', {}, GAME_CONFIG, tauntContextTargetData, this.gameState); + if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + gameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); + const delayedBuff = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); + if (delayedBuff && !delayedBuff.justCast) { + const regen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerData.baseStats.maxResource - attackerState.currentResource); + if (regen > 0) { + attackerState.currentResource = Math.round(attackerState.currentResource + regen); + this.addToLog(`🌿 ${attackerState.name} восстанавливает ${regen} ${attackerState.resourceName} от "${delayedBuff.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) { + actionValid = false; + actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); + this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); // Перезапуск таймера + return; + } + const validityCheck = gameLogic.checkAbilityValidity(ability, attackerState, defenderState, GAME_CONFIG); + if (!validityCheck.isValid) { + this.addToLog(validityCheck.reason, GAME_CONFIG.LOG_TYPE_INFO); + actionValid = false; + } + + if (actionValid) { + attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); + const taunt = gameLogic.getRandomTaunt(attackerState.characterKey, 'selfCastAbility', { abilityId: ability.id }, GAME_CONFIG, tauntContextTargetData, this.gameState); + if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + gameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); + gameLogic.setAbilityCooldown(ability, attackerState, GAME_CONFIG); + } + } else { + console.warn(`[GameInstance ${this.id}] Неизвестный тип действия: ${actionData?.actionType}`); + actionValid = false; + } + + if (this.checkGameOver()) { + this.broadcastGameStateUpdate(); return; + } + if (actionValid) { + setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + } else { + this.broadcastLogUpdate(); + this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); // Перезапуск таймера + } + } + + switchTurn() { + if (!this.gameState || this.gameState.isGameOver) return; + this.turnTimer.clear(); + + const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + const endingTurnActor = this.gameState[endingTurnActorRole]; + const endingTurnData = dataUtils.getCharacterData(endingTurnActor.characterKey); + + if (!endingTurnData) { this._handleCriticalError('switch_turn_data_fail', 'Ошибка данных при смене хода.'); return; } + + gameLogic.processEffects(endingTurnActor.activeEffects, endingTurnActor, endingTurnData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils); + gameLogic.updateBlockingStatus(this.gameState.player); + gameLogic.updateBlockingStatus(this.gameState.opponent); + if (endingTurnActor.abilityCooldowns && endingTurnData.abilities) gameLogic.processPlayerAbilityCooldowns(endingTurnActor.abilityCooldowns, endingTurnData.abilities, endingTurnActor.name, this.addToLog.bind(this), GAME_CONFIG); + if (endingTurnActor.characterKey === 'balard') gameLogic.processBalardSpecialCooldowns(endingTurnActor); + if (endingTurnActor.disabledAbilities?.length > 0 && endingTurnData.abilities) gameLogic.processDisabledAbilities(endingTurnActor.disabledAbilities, endingTurnData.abilities, endingTurnActor.name, this.addToLog.bind(this), GAME_CONFIG); + + if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } + + this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; + if (this.gameState.isPlayerTurn) this.gameState.turnNumber++; + + const currentTurnActor = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; + this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${currentTurnActor.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + this.broadcastGameStateUpdate(); + this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); + + if (!this.gameState.isPlayerTurn && this.aiOpponent) { + setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); + } + } + + processAiTurn() { + if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent || this.gameState.opponent?.characterKey !== 'balard') { + if (this.gameState && !this.gameState.isGameOver) this.switchTurn(); + return; + } + + const attacker = this.gameState.opponent; + const defender = this.gameState.player; + const attackerData = dataUtils.getCharacterData('balard'); + const defenderData = dataUtils.getCharacterData(defender.characterKey); + + if (!attackerData || !defenderData) { this._handleCriticalError('ai_char_data_fail', 'Ошибка данных AI.'); this.switchTurn(); return; } + + if (gameLogic.isCharacterFullySilenced(attacker, GAME_CONFIG)) { + this.addToLog(`😵 ${attacker.name} под действием Безмолвия! Атакует в смятении.`, GAME_CONFIG.LOG_TYPE_EFFECT); + gameLogic.performAttack(attacker, defender, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, defenderData); + if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } + setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + return; + } + + const aiDecision = gameLogic.decideAiAction(this.gameState, dataUtils, GAME_CONFIG, this.addToLog.bind(this)); + let tauntContextTargetData = defenderData; + + if (aiDecision.actionType === 'attack') { + gameLogic.performAttack(attacker, defender, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); + } else if (aiDecision.actionType === 'ability' && aiDecision.ability) { + attacker.currentResource = Math.round(attacker.currentResource - aiDecision.ability.cost); + gameLogic.applyAbilityEffect(aiDecision.ability, attacker, defender, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); + gameLogic.setAbilityCooldown(aiDecision.ability, attacker, GAME_CONFIG); + } // 'pass' уже залогирован в decideAiAction + + if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } + setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + } + + checkGameOver() { + if (!this.gameState || this.gameState.isGameOver) return this.gameState?.isGameOver ?? true; + if (!this.gameState.player || !this.gameState.opponent?.characterKey) return false; + + const gameOverResult = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode); + if (gameOverResult.isOver) { + this.gameState.isGameOver = true; + this.turnTimer.clear(); + this.addToLog(gameOverResult.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + + const winnerState = this.gameState[gameOverResult.winnerRole]; + const loserState = this.gameState[gameOverResult.loserRole]; + if (winnerState && (winnerState.characterKey === 'elena' || winnerState.characterKey === 'almagest') && loserState) { + const loserFullData = dataUtils.getCharacterData(loserState.characterKey); + if (loserFullData) { // Убедимся, что данные проигравшего есть + const taunt = gameLogic.getRandomTaunt(winnerState.characterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, loserFullData, this.gameState); + if (taunt !== "(Молчание)") this.addToLog(`${winnerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + } + } + if (loserState) { + if (loserState.characterKey === 'balard') this.addToLog(`Елена исполнила свой тяжкий долг. ${loserState.name} развоплощен...`, GAME_CONFIG.LOG_TYPE_SYSTEM); + else if (loserState.characterKey === 'almagest') this.addToLog(`Над полем битвы воцаряется тишина. ${loserState.name} побежден(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); + else if (loserState.characterKey === 'elena') this.addToLog(`Свет погас. ${loserState.name} повержен(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } + + console.log(`[GameInstance ${this.id}] Игра окончена. Победитель: ${gameOverResult.winnerRole || 'Нет'}. Причина: ${gameOverResult.reason}.`); + this.io.to(this.id).emit('gameOver', { + winnerId: gameOverResult.winnerRole, reason: gameOverResult.reason, + finalGameState: this.gameState, log: this.consumeLogBuffer(), + loserCharacterKey: loserState?.characterKey || 'unknown' + }); + this.gameManager._cleanupGame(this.id, gameOverResult.reason); + return true; + } + return false; + } + + endGameDueToDisconnect(disconnectedSocketId, disconnectedPlayerRole, disconnectedCharacterKey) { + if (this.gameState && !this.gameState.isGameOver) { + this.gameState.isGameOver = true; + this.turnTimer.clear(); + + const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'opponent_disconnected', + disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID, // winner + disconnectedPlayerRole // loser + ); + + this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + + console.log(`[GameInstance ${this.id}] Игра завершена из-за дисконнекта. Победитель: ${result.winnerRole || 'Нет'}. Отключился: ${disconnectedPlayerRole}.`); + this.io.to(this.id).emit('gameOver', { + winnerId: result.winnerRole, reason: result.reason, + finalGameState: this.gameState, log: this.consumeLogBuffer(), + loserCharacterKey: disconnectedCharacterKey // Ключ того, кто отключился + }); + this.gameManager._cleanupGame(this.id, result.reason); + } + } + + handleTurnTimeout() { + if (!this.gameState || this.gameState.isGameOver) return; + // this.turnTimer.clear(); // TurnTimer сам себя очистит при вызове onTimeout + + const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + const winnerPlayerRole = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + + const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerPlayerRole, timedOutPlayerRole); + + if (!this.gameState[winnerPlayerRole]?.characterKey) { // Если победитель не определен (например, ожидание в PvP) + this._handleCriticalError('timeout_winner_undefined', `Таймаут, но победитель (${winnerPlayerRole}) не определен.`); + return; + } + + this.gameState.isGameOver = true; // Устанавливаем здесь, т.к. getGameOverResult мог не знать, что игра уже окончена + this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + console.log(`[GameInstance ${this.id}] Таймаут хода для ${this.gameState[timedOutPlayerRole]?.name}. Победитель: ${this.gameState[winnerPlayerRole]?.name}.`); + + this.io.to(this.id).emit('gameOver', { + winnerId: result.winnerRole, reason: result.reason, + finalGameState: this.gameState, log: this.consumeLogBuffer(), + loserCharacterKey: this.gameState[timedOutPlayerRole]?.characterKey || 'unknown' + }); + this.gameManager._cleanupGame(this.id, result.reason); + } + + _handleCriticalError(reasonCode, logMessage) { + console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: ${logMessage} (Код: ${reasonCode})`); + if (this.gameState && !this.gameState.isGameOver) this.gameState.isGameOver = true; + this.turnTimer.clear(); + this.addToLog(`Критическая ошибка сервера: ${logMessage}`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.io.to(this.id).emit('gameOver', { + winnerId: null, reason: `server_error_${reasonCode}`, + finalGameState: this.gameState, log: this.consumeLogBuffer(), + loserCharacterKey: 'unknown' + }); + if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { + this.gameManager._cleanupGame(this.id, `critical_error_${reasonCode}`); + } + } + + addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { + if (!message) return; + this.logBuffer.push({ message, type, timestamp: Date.now() }); + } + 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() }); + } + broadcastLogUpdate() { + if (this.logBuffer.length > 0) { + this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() }); + } + } +} + +module.exports = GameInstance; \ No newline at end of file diff --git a/server/game/instance/Player.js b/server/game/instance/Player.js new file mode 100644 index 0000000..e69de29 diff --git a/server/game/instance/TurnTimer.js b/server/game/instance/TurnTimer.js new file mode 100644 index 0000000..e8b3c87 --- /dev/null +++ b/server/game/instance/TurnTimer.js @@ -0,0 +1,120 @@ +// /server/game/instance/TurnTimer.js + +class TurnTimer { + /** + * Конструктор таймера хода. + * @param {number} turnDurationMs - Длительность хода в миллисекундах. + * @param {number} updateIntervalMs - Интервал для отправки обновлений времени клиентам (в мс). + * @param {function} onTimeoutCallback - Колбэк, вызываемый при истечении времени хода. + * @param {function} onTickCallback - Колбэк, вызываемый на каждом тике обновления (передает remainingTime, isPlayerTurnForTimer). + */ + constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback) { + this.turnDurationMs = turnDurationMs; + this.updateIntervalMs = updateIntervalMs; + this.onTimeoutCallback = onTimeoutCallback; + this.onTickCallback = onTickCallback; + + this.timerId = null; // ID для setTimeout (обработка таймаута) + this.updateIntervalId = null; // ID для setInterval (обновление клиента) + this.startTime = 0; // Время начала текущего отсчета (Date.now()) + this.isRunning = false; + this.isCurrentPlayerActualTurnForTick = false; // Храним, для чьего хода запущен таймер (для onTickCallback) + this.isAiCurrentlyMakingMove = false; // Флаг, что сейчас ход AI (таймер не тикает для игрока) + + // console.log(`[TurnTimer] Initialized with duration: ${turnDurationMs}ms, update interval: ${updateIntervalMs}ms`); + } + + /** + * Запускает или перезапускает таймер хода. + * @param {boolean} isPlayerTurn - true, если сейчас ход слота 'player', false - если ход слота 'opponent'. + * @param {boolean} isAiTurn - true, если текущий ход делает AI (в этом случае таймер для реального игрока не тикает). + */ + start(isPlayerTurn, isAiTurn = false) { + this.clear(); // Сначала очищаем предыдущие таймеры + + this.isCurrentPlayerActualTurnForTick = isPlayerTurn; // Сохраняем, чей ход для onTick + this.isAiCurrentlyMakingMove = isAiTurn; + + // Таймер и отсчет времени запускаются только если это НЕ ход AI + if (this.isAiCurrentlyMakingMove) { + this.isRunning = false; + // console.log(`[TurnTimer] Start called, but it's AI's turn. Timer not started for player.`); + // Уведомляем один раз, что таймер неактивен (ход AI) + if (this.onTickCallback) { + this.onTickCallback(null, this.isCurrentPlayerActualTurnForTick); + } + return; + } + + this.startTime = Date.now(); + this.isRunning = true; + // console.log(`[TurnTimer] Started for ${isPlayerTurn ? 'Player' : 'Opponent'} at ${new Date(this.startTime).toLocaleTimeString()}. AI turn: ${isAiTurn}`); + + // Таймер на истечение общего времени хода + this.timerId = setTimeout(() => { + // console.log(`[TurnTimer] Timeout occurred! Was running: ${this.isRunning}`); + if (this.isRunning) { // Дополнительная проверка, что таймер все еще должен был работать + this.isRunning = false; // Помечаем, что таймер больше не работает + if (this.onTimeoutCallback) { + this.onTimeoutCallback(); + } + this.clear(); // Очищаем и интервал обновления после таймаута + } + }, this.turnDurationMs); + + // Интервал для отправки обновлений клиентам + this.updateIntervalId = setInterval(() => { + if (!this.isRunning) { // Если таймер был остановлен (например, ход сделан или игра окончена) + this.clear(); // Убедимся, что интервал тоже очищен + return; + } + + const elapsedTime = Date.now() - this.startTime; + const remainingTime = Math.max(0, this.turnDurationMs - elapsedTime); + + if (this.onTickCallback) { + // Передаем isCurrentPlayerActualTurnForTick, чтобы клиент знал, для чьего хода это время + this.onTickCallback(remainingTime, this.isCurrentPlayerActualTurnForTick); + } + + if (remainingTime <= 0 && this.isRunning) { // Если время вышло по интервалу (на всякий случай, setTimeout должен сработать) + // console.log(`[TurnTimer] Remaining time reached 0 in interval. Forcing timeout logic.`); + // Не вызываем onTimeoutCallback здесь напрямую, чтобы избежать двойного вызова, + // setTimeout должен это обработать. Просто очищаем интервал. + this.clear(); // Очищаем интервал, setTimeout сработает для onTimeoutCallback + } + }, this.updateIntervalMs); + + // Отправляем начальное значение немедленно + if (this.onTickCallback) { + this.onTickCallback(this.turnDurationMs, this.isCurrentPlayerActualTurnForTick); + } + } + + /** + * Очищает (останавливает) все активные таймеры (setTimeout и setInterval). + */ + clear() { + if (this.timerId) { + clearTimeout(this.timerId); + this.timerId = null; + } + if (this.updateIntervalId) { + clearInterval(this.updateIntervalId); + this.updateIntervalId = null; + } + this.isRunning = false; + this.startTime = 0; + // console.log(`[TurnTimer] Cleared.`); + } + + /** + * Проверяет, активен ли таймер в данный момент. + * @returns {boolean} + */ + isActive() { + return this.isRunning; + } +} + +module.exports = TurnTimer; \ No newline at end of file diff --git a/server/game/logic/aiLogic.js b/server/game/logic/aiLogic.js new file mode 100644 index 0000000..8c2ef34 --- /dev/null +++ b/server/game/logic/aiLogic.js @@ -0,0 +1,133 @@ +// /server/game/logic/aiLogic.js + +// GAME_CONFIG и gameData (или dataUtils) будут передаваться в decideAiAction как параметры, +// но для удобства можно импортировать GAME_CONFIG здесь, если он нужен для внутренних констант AI, +// не зависящих от передаваемого конфига. +// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужно для чего-то внутреннего + +/** + * Логика принятия решения для AI (Балард). + * @param {object} currentGameState - Текущее состояние игры. + * @param {object} dataUtils - Утилиты для доступа к данным игры (getCharacterData, getCharacterAbilities и т.д.). + * @param {object} configToUse - Конфигурационный объект игры (переданный GAME_CONFIG). + * @param {function} addToLogCallback - Функция для добавления лога (опционально, если AI должен логировать свои "мысли"). + * @returns {object} Объект с действием AI ({ actionType: 'attack' | 'ability' | 'pass', ability?: object, logMessage?: {message, type} }). + */ +function decideAiAction(currentGameState, dataUtils, configToUse, addToLogCallback) { + const opponentState = currentGameState.opponent; // AI Балард всегда в слоте opponent + const playerState = currentGameState.player; // Игрок всегда в слоте player (в AI режиме) + + // Убеждаемся, что это AI Балард и есть необходимые данные + if (opponentState.characterKey !== 'balard' || !dataUtils) { + console.warn("[AI Logic] decideAiAction called for non-Balard opponent or missing dataUtils. Passing turn."); + if (addToLogCallback) addToLogCallback(`${opponentState.name || 'AI'} пропускает ход из-за внутренней ошибки.`, configToUse.LOG_TYPE_SYSTEM); + return { actionType: 'pass', logMessage: { message: `${opponentState.name || 'AI'} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; + } + + const balardCharacterData = dataUtils.getCharacterData('balard'); + if (!balardCharacterData || !balardCharacterData.abilities) { + console.warn("[AI Logic] Failed to get Balard's character data or abilities. Passing turn."); + if (addToLogCallback) addToLogCallback(`AI Балард пропускает ход из-за ошибки загрузки данных.`, configToUse.LOG_TYPE_SYSTEM); + return { actionType: 'pass', logMessage: { message: `Балард пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; + } + const balardAbilities = balardCharacterData.abilities; + + // Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены и т.п.) + const isBalardFullySilenced = opponentState.activeEffects.some( + eff => eff.isFullSilence && eff.turnsLeft > 0 + ); + + if (isBalardFullySilenced) { + // AI под полным безмолвием просто атакует. + // Лог о безмолвии добавляется в GameInstance перед вызовом этой функции или при обработке атаки. + // Здесь можно добавить лог о "вынужденной" атаке, если нужно. + if (addToLogCallback) { + // Проверяем, не был ли лог о безмолвии уже добавлен в этом ходу (чтобы не спамить) + // Это упрощенная проверка, в реальном приложении можно использовать флаги или более сложную логику. + // if (!currentGameState.logContainsThisTurn || !currentGameState.logContainsThisTurn.includes('под действием Безмолвия')) { + // addToLogCallback(`😵 ${opponentState.name} под действием Безмолвия! Атакует в смятении.`, configToUse.LOG_TYPE_EFFECT); + // if(currentGameState) currentGameState.logContainsThisTurn = (currentGameState.logContainsThisTurn || "") + 'под действием Безмолвия'; + // } + } + return { actionType: 'attack' }; + } + + const availableActions = []; + + // 1. Проверяем способность "Покровительство Тьмы" (Лечение) + const healAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_HEAL); + if (healAbility && opponentState.currentResource >= healAbility.cost && + (opponentState.abilityCooldowns?.[healAbility.id] || 0) <= 0 && // Общий КД + healAbility.condition(opponentState, playerState, currentGameState, configToUse)) { + availableActions.push({ weight: 80, type: 'ability', ability: healAbility, requiresSuccessCheck: true, successRate: healAbility.successRate }); + } + + // 2. Проверяем способность "Эхо Безмолвия" + const silenceAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_SILENCE); + if (silenceAbility && opponentState.currentResource >= silenceAbility.cost && + (opponentState.silenceCooldownTurns === undefined || opponentState.silenceCooldownTurns <= 0) && // Спец. КД + (opponentState.abilityCooldowns?.[silenceAbility.id] || 0) <= 0 && // Общий КД + silenceAbility.condition(opponentState, playerState, currentGameState, configToUse)) { + // Условие в silenceAbility.condition уже проверяет, что Елена не под безмолвием + availableActions.push({ weight: 60, type: 'ability', ability: silenceAbility, requiresSuccessCheck: true, successRate: configToUse.SILENCE_SUCCESS_RATE }); + } + + // 3. Проверяем способность "Похищение Света" (Вытягивание маны и урон) + const drainAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN); + if (drainAbility && opponentState.currentResource >= drainAbility.cost && + (opponentState.manaDrainCooldownTurns === undefined || opponentState.manaDrainCooldownTurns <= 0) && // Спец. КД + (opponentState.abilityCooldowns?.[drainAbility.id] || 0) <= 0 && // Общий КД + drainAbility.condition(opponentState, playerState, currentGameState, configToUse)) { + availableActions.push({ weight: 50, type: 'ability', ability: drainAbility }); + } + + // 4. Базовая атака - всегда доступна как запасной вариант с низким весом + availableActions.push({ weight: 30, type: 'attack' }); + + + if (availableActions.length === 0) { + // Этого не должно происходить, так как атака всегда добавляется + if (addToLogCallback) addToLogCallback(`${opponentState.name} не может совершить действие (нет доступных).`, configToUse.LOG_TYPE_INFO); + return { actionType: 'pass', logMessage: { message: `${opponentState.name} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; + } + + // Сортируем действия по весу в порядке убывания (самые приоритетные в начале) + availableActions.sort((a, b) => b.weight - a.weight); + + // console.log(`[AI Logic] Available actions for Balard, sorted by weight:`, JSON.stringify(availableActions.map(a => ({type: a.type, name: a.ability?.name, weight: a.weight})), null, 2)); + + + // Перебираем действия в порядке приоритета и выбираем первое подходящее + for (const action of availableActions) { + if (action.type === 'ability') { + if (action.requiresSuccessCheck) { + // Для способностей с шансом успеха, "бросаем кубик" + if (Math.random() < action.successRate) { + if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} решает использовать "${action.ability.name}" (попытка успешна)...`, configToUse.LOG_TYPE_INFO); + return { actionType: action.type, ability: action.ability }; + } else { + // Провал шанса, добавляем лог и ИИ переходит к следующему действию в списке (если есть) + if (addToLogCallback) addToLogCallback(`💨 ${opponentState.name} пытался использовать "${action.ability.name}", но шанс не сработал!`, configToUse.LOG_TYPE_INFO); + continue; // Пробуем следующее приоритетное действие + } + } else { + // Способность без проверки шанса (например, Похищение Света) + if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} решает использовать "${action.ability.name}"...`, configToUse.LOG_TYPE_INFO); + return { actionType: action.type, ability: action.ability }; + } + } else if (action.type === 'attack') { + // Атака - если дошли до нее, значит, более приоритетные способности не были выбраны или провалили шанс + if (addToLogCallback) addToLogCallback(`🦶 ${opponentState.name} решает атаковать...`, configToUse.LOG_TYPE_INFO); + return { actionType: 'attack' }; + } + } + + // Фоллбэк, если по какой-то причине ни одно действие не было выбрано (не должно происходить, если атака всегда есть) + console.warn("[AI Logic] AI Balard failed to select any action after iterating. Defaulting to pass."); + if (addToLogCallback) addToLogCallback(`${opponentState.name} не смог выбрать подходящее действие. Пропускает ход.`, configToUse.LOG_TYPE_INFO); + return { actionType: 'pass', logMessage: { message: `${opponentState.name} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; +} + +module.exports = { + decideAiAction +}; \ No newline at end of file diff --git a/server/game/logic/combatLogic.js b/server/game/logic/combatLogic.js new file mode 100644 index 0000000..ade9b45 --- /dev/null +++ b/server/game/logic/combatLogic.js @@ -0,0 +1,313 @@ +// /server/game/logic/combatLogic.js + +// GAME_CONFIG и gameData/dataUtils будут передаваться в функции как параметры. +// const GAME_CONFIG_STATIC = require('../../core/config'); // Можно импортировать для внутренних нужд, если не все приходит через параметры + +/** + * Обрабатывает базовую атаку одного бойца по другому. + * @param {object} attackerState - Состояние атакующего бойца из gameState. + * @param {object} defenderState - Состояние защищающегося бойца из gameState. + * @param {object} attackerBaseStats - Базовые статы атакующего (из dataUtils.getCharacterBaseStats). + * @param {object} defenderBaseStats - Базовые статы защищающегося (из dataUtils.getCharacterBaseStats). + * @param {object} currentGameState - Текущее полное состояние игры (для getRandomTaunt). + * @param {function} addToLogCallback - Функция для добавления сообщений в лог игры. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @param {object} defenderFullData - Полные данные защищающегося персонажа (baseStats, abilities) из dataUtils.getCharacterData(defenderKey), для getRandomTaunt. + */ +function performAttack( + attackerState, + defenderState, + attackerBaseStats, + defenderBaseStats, + currentGameState, // Добавлен для контекста насмешек + addToLogCallback, + configToUse, + defenderFullData // Добавлен для контекста насмешек цели +) { + // Расчет базового урона с вариацией + let damage = Math.floor( + attackerBaseStats.attackPower * + (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE) + ); + let tauntMessagePart = ""; + + // Проверка на блок + if (defenderState.isBlocking) { + const initialDamage = damage; + damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); + + // Проверка на насмешку ОТ защищающегося (если это Елена или Альмагест) при блокировании атаки + if (defenderState.characterKey === 'elena' || defenderState.characterKey === 'almagest') { + // Импортируем getRandomTaunt здесь или передаем как параметр, если он в другом файле logic + // Предположим, getRandomTaunt доступен в gameLogic (который будет передан или импортирован) + // Для примера, если бы он был в этом же файле или импортирован: + // const blockTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameData, currentGameState); + // Поскольку getRandomTaunt теперь в gameLogic.js, он должен быть вызван оттуда или передан. + // В GameInstance.js мы вызываем gameLogic.getRandomTaunt, так что здесь это дублирование. + // Лучше, чтобы GameInstance сам обрабатывал насмешки или передавал их как результат. + // Для простоты здесь оставим, но это кандидат на рефакторинг вызова насмешек в GameInstance. + // Однако, если defenderFullData передается, мы можем вызвать его, предполагая, что gameLogic.getRandomTaunt будет импортирован + // или доступен в объекте gameLogic, переданном в GameInstance. + // const blockTaunt = require('./index').getRandomTaunt(...) // Пример циклической зависимости, так не надо + // Будем считать, что GameInstance готовит насмешку заранее или эта функция вызывается с уже готовой насмешкой. + // Либо, если getRandomTaunt - это часть 'gameLogic' объекта, то: + // const blockTaunt = gameLogicFunctions.getRandomTaunt(...) + // Сейчас для простоты оставим вызов, но это архитектурный момент. + // Предположим, что gameLogic.getRandomTaunt доступен через какой-то объект, например, `sharedLogic` + } + + + if (addToLogCallback) { + addToLogCallback( + `🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, + configToUse.LOG_TYPE_BLOCK + ); + } + } else { + // Насмешка при попадании также должна обрабатываться централизованно или передаваться + if (addToLogCallback) { + addToLogCallback( + `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.${tauntMessagePart}`, + configToUse.LOG_TYPE_DAMAGE + ); + } + } + + // Применяем урон, убеждаемся, что HP не ниже нуля + defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage)); +} + + +/** + * Применяет эффект способности (урон, лечение, наложение баффа/дебаффа и т.д.). + * Насмешки, связанные с самим КАСТОМ способности (selfCastAbility), должны быть обработаны до вызова этой функции. + * Насмешки, связанные с РЕАКЦИЕЙ цели на эффект, могут быть обработаны здесь или после. + * @param {object} ability - Объект способности. + * @param {object} casterState - Состояние бойца, применившего способность. + * @param {object} targetState - Состояние цели способности. + * @param {object} casterBaseStats - Базовые статы кастера. + * @param {object} targetBaseStats - Базовые статы цели. + * @param {object} currentGameState - Текущее полное состояние игры (для getRandomTaunt, если он здесь вызывается). + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурация игры. + * @param {object} targetFullData - Полные данные цели (baseStats, abilities) для getRandomTaunt. + */ +function applyAbilityEffect( + ability, + casterState, + targetState, + casterBaseStats, + targetBaseStats, + currentGameState, + addToLogCallback, + configToUse, + targetFullData // Для насмешек цели +) { + let tauntMessagePart = ""; // Для насмешки цели + + // Насмешка цели (если это Елена/Альмагест) на применение способности противником + // Этот вызов лучше делать в GameInstance, передавая результат сюда, или эта функция должна иметь доступ к getRandomTaunt + // if ((targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') && casterState.id !== targetState.id) { + // const reactionTaunt = require('./index').getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id }, configToUse, targetFullData, currentGameState); + // if (reactionTaunt !== "(Молчание)") tauntMessagePart = ` (${reactionTaunt})`; + // } + + switch (ability.type) { + case configToUse.ACTION_TYPE_HEAL: + const healAmount = Math.floor(ability.power * (configToUse.HEAL_VARIATION_MIN + Math.random() * configToUse.HEAL_VARIATION_RANGE)); + const actualHeal = Math.min(healAmount, casterBaseStats.maxHp - casterState.currentHp); + if (actualHeal > 0) { + casterState.currentHp = Math.round(casterState.currentHp + actualHeal); + if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет "${ability.name}" и восстанавливает ${actualHeal} HP!${tauntMessagePart}`, configToUse.LOG_TYPE_HEAL); + } else { + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения.${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + } + break; + + case configToUse.ACTION_TYPE_DAMAGE: + let damage = Math.floor(ability.power * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE)); + if (targetState.isBlocking) { + const initialDamage = damage; + damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); + if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK); + } + targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage)); + if (addToLogCallback && !targetState.isBlocking) { + addToLogCallback(`💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!${tauntMessagePart}`, configToUse.LOG_TYPE_DAMAGE); + } + break; + + case configToUse.ACTION_TYPE_BUFF: + // Проверка на уже активный бафф должна быть сделана до вызова этой функции (в GameInstance) + let effectDescriptionBuff = ability.description; + if (typeof ability.descriptionFunction === 'function') { + // Для описания баффа может потребоваться информация о противнике (цели баффа, если бафф накладывается на другого) + // В данном случае, баффы накладываются на себя, так что targetBaseStats не всегда релевантен для описания. + // Передаем targetBaseStats (оппонента кастера), если описание функции его ожидает. + effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); + } + casterState.activeEffects.push({ + id: ability.id, name: ability.name, description: effectDescriptionBuff, + type: ability.type, duration: ability.duration, + turnsLeft: ability.duration, // Длительность эффекта в ходах владельца + grantsBlock: !!ability.grantsBlock, + isDelayed: !!ability.isDelayed, + justCast: true + }); + if (ability.grantsBlock) require('./effectsLogic').updateBlockingStatus(casterState); // Обновляем статус блока + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + break; + + case configToUse.ACTION_TYPE_DISABLE: + // Логика для 'Гипнотический взгляд' / 'Раскол Разума' (полное безмолвие) + if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) { + const effectIdFullSilence = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest'; + if (!targetState.activeEffects.some(e => e.id === effectIdFullSilence)) { + targetState.activeEffects.push({ + id: effectIdFullSilence, name: ability.name, description: ability.description, + type: ability.type, duration: ability.effectDuration, turnsLeft: ability.effectDuration, + power: ability.power, isFullSilence: true, justCast: true + }); + if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}! Способности заблокированы на ${ability.effectDuration} хода и наносится урон!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + } + } + // Логика для 'Эхо Безмолвия' Баларда + else if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && casterState.characterKey === 'balard') { + const success = Math.random() < configToUse.SILENCE_SUCCESS_RATE; + // Насмешка цели на успех/провал должна быть обработана в GameInstance, т.к. результат известен только здесь + if (success) { + const targetAbilitiesList = require('../../data/dataUtils').getCharacterAbilities(targetState.characterKey); // Получаем абилки цели + const availableAbilitiesToSilence = targetAbilitiesList.filter(pa => + !targetState.disabledAbilities?.some(d => d.abilityId === pa.id) && + !targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`) + ); + if (availableAbilitiesToSilence.length > 0) { + const abilityToSilence = availableAbilitiesToSilence[Math.floor(Math.random() * availableAbilitiesToSilence.length)]; + const turns = configToUse.SILENCE_DURATION; + targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); + targetState.activeEffects.push({ + id: `playerSilencedOn_${abilityToSilence.id}`, name: `Безмолвие: ${abilityToSilence.name}`, + description: `Способность "${abilityToSilence.name}" временно недоступна.`, + type: configToUse.ACTION_TYPE_DISABLE, sourceAbilityId: ability.id, + duration: turns, turnsLeft: turns + 1, justCast: true + }); + if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" у ${targetBaseStats.name} заблокировано на ${turns} хода!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + } + } else { + if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + } + } + break; + + case configToUse.ACTION_TYPE_DEBUFF: + // Логика для 'Печать Слабости' / 'Проклятие Увядания' + if (ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF) { + const effectIdDebuff = 'effect_' + ability.id; + if (!targetState.activeEffects.some(e => e.id === effectIdDebuff)) { + let effectDescriptionDebuff = ability.description; + if (typeof ability.descriptionFunction === 'function') { + effectDescriptionDebuff = ability.descriptionFunction(configToUse, targetBaseStats); + } + targetState.activeEffects.push({ + id: effectIdDebuff, name: ability.name, description: effectDescriptionDebuff, + type: configToUse.ACTION_TYPE_DEBUFF, sourceAbilityId: ability.id, + duration: ability.effectDuration, turnsLeft: ability.effectDuration, + power: ability.power, justCast: true + }); + if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Ресурс будет сжигаться.${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + } + } + break; + + case configToUse.ACTION_TYPE_DRAIN: // Похищение Света Баларда + if (casterState.characterKey === 'balard') { + let manaDrained = 0; let healthGained = 0; let damageDealtDrain = 0; + if (ability.powerDamage > 0) { + let baseDamageDrain = ability.powerDamage; + if (targetState.isBlocking) baseDamageDrain = Math.floor(baseDamageDrain * configToUse.BLOCK_DAMAGE_REDUCTION); + damageDealtDrain = Math.max(0, baseDamageDrain); + targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damageDealtDrain)); + } + const potentialDrain = ability.powerManaDrain; + const actualDrain = Math.min(potentialDrain, targetState.currentResource); + if (actualDrain > 0) { + targetState.currentResource = Math.max(0, Math.round(targetState.currentResource - actualDrain)); + manaDrained = actualDrain; + const potentialHeal = Math.floor(manaDrained * ability.powerHealthGainFactor); + const actualHealGain = Math.min(potentialHeal, casterBaseStats.maxHp - casterState.currentHp); + casterState.currentHp = Math.round(casterState.currentHp + actualHealGain); + healthGained = actualHealGain; + } + let logMsgDrain = `⚡ ${casterBaseStats.name} применяет "${ability.name}"! `; + if (damageDealtDrain > 0) logMsgDrain += `Наносит ${damageDealtDrain} урона. `; + if (manaDrained > 0) logMsgDrain += `Вытягивает ${manaDrained} ${targetBaseStats.resourceName} у ${targetBaseStats.name} и исцеляется на ${healthGained} HP!`; + else if (damageDealtDrain > 0) logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; + else logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; + logMsgDrain += tauntMessagePart; + if (addToLogCallback) addToLogCallback(logMsgDrain, (manaDrained > 0 || damageDealtDrain > 0) ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO); + } + break; + + default: + if (addToLogCallback) addToLogCallback(`Неизвестный тип способности: ${ability?.type} для "${ability?.name}"`, configToUse.LOG_TYPE_SYSTEM); + console.warn(`applyAbilityEffect: Неизвестный тип способности: ${ability?.type}`); + } +} + +/** + * Проверяет валидность использования способности (ресурс, КД, безмолвие и т.д.). + * @param {object} ability - Объект способности. + * @param {object} casterState - Состояние кастера. + * @param {object} targetState - Состояние цели. + * @param {object} configToUse - Конфигурация игры. + * @returns {{isValid: boolean, reason: string|null}} Результат проверки. + */ +function checkAbilityValidity(ability, casterState, targetState, configToUse) { + if (!ability) return { isValid: false, reason: "Способность не найдена." }; + + if (casterState.currentResource < ability.cost) { + return { isValid: false, reason: `${casterState.name} пытается применить "${ability.name}", но не хватает ${casterState.resourceName}!` }; + } + if ((casterState.abilityCooldowns?.[ability.id] || 0) > 0) { + return { isValid: false, reason: `"${ability.name}" еще на перезарядке.` }; + } + // Проверка специальных КД Баларда + if (casterState.characterKey === 'balard') { + if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && (casterState.silenceCooldownTurns || 0) > 0) { + return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке.` }; + } + if (ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN && (casterState.manaDrainCooldownTurns || 0) > 0) { + return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке.` }; + } + } + + const isCasterFullySilenced = casterState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0); + const isAbilitySpecificallySilenced = casterState.disabledAbilities?.some(dis => dis.abilityId === ability.id && dis.turnsLeft > 0); + if (isCasterFullySilenced || isAbilitySpecificallySilenced) { + return { isValid: false, reason: `${casterState.name} не может использовать способности из-за безмолвия!` }; + } + + if (ability.type === configToUse.ACTION_TYPE_BUFF && casterState.activeEffects.some(e => e.id === ability.id)) { + return { isValid: false, reason: `Эффект "${ability.name}" уже активен!` }; + } + + const isTargetedDebuff = ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF; + if (isTargetedDebuff && targetState.activeEffects.some(e => e.id === 'effect_' + ability.id)) { + return { isValid: false, reason: `Эффект "${ability.name}" уже наложен на ${targetState.name}!` }; + } + + return { isValid: true, reason: null }; +} + + +module.exports = { + performAttack, + applyAbilityEffect, + checkAbilityValidity // Экспортируем новую функцию +}; \ No newline at end of file diff --git a/server/game/logic/cooldownLogic.js b/server/game/logic/cooldownLogic.js new file mode 100644 index 0000000..73e48a7 --- /dev/null +++ b/server/game/logic/cooldownLogic.js @@ -0,0 +1,154 @@ +// /server/game/logic/cooldownLogic.js + +// GAME_CONFIG будет передаваться в функции как параметр configToUse +// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужен для внутренних констант + +/** + * Обрабатывает отсчет общих кулдаунов для способностей игрока в конце его хода. + * Длительность кулдауна уменьшается на 1. + * @param {object} cooldownsObject - Объект с кулдаунами способностей ({ abilityId: turnsLeft }). + * @param {Array} characterAbilities - Полный список способностей персонажа (для получения имени). + * @param {string} characterName - Имя персонажа (для лога). + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + */ +function processPlayerAbilityCooldowns(cooldownsObject, characterAbilities, characterName, addToLogCallback, configToUse) { + if (!cooldownsObject || !characterAbilities) { + // console.warn(`[CooldownLogic] processPlayerAbilityCooldowns: Missing cooldownsObject or characterAbilities for ${characterName}`); + return; + } + + for (const abilityId in cooldownsObject) { + // Проверяем, что свойство принадлежит самому объекту, а не прототипу, и что кулдаун активен + if (Object.prototype.hasOwnProperty.call(cooldownsObject, abilityId) && cooldownsObject[abilityId] > 0) { + cooldownsObject[abilityId]--; // Уменьшаем кулдаун + + if (cooldownsObject[abilityId] === 0) { + const ability = characterAbilities.find(ab => ab.id === abilityId); + if (ability && addToLogCallback) { + addToLogCallback( + `Способность "${ability.name}" персонажа ${characterName} снова готова!`, + configToUse.LOG_TYPE_INFO // Используем LOG_TYPE_INFO из переданного конфига + ); + } + } + } + } +} + +/** + * Обрабатывает отсчет для отключенных (заглушенных) способностей игрока в конце его хода. + * Длительность заглушения уменьшается на 1. + * @param {Array} disabledAbilitiesArray - Массив объектов заглушенных способностей. + * @param {Array} characterAbilities - Полный список способностей персонажа (для получения имени). + * @param {string} characterName - Имя персонажа (для лога). + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + */ +function processDisabledAbilities(disabledAbilitiesArray, characterAbilities, characterName, addToLogCallback, configToUse) { + if (!disabledAbilitiesArray || disabledAbilitiesArray.length === 0) { + return; + } + + const stillDisabled = []; // Новый массив для активных заглушений + for (let i = 0; i < disabledAbilitiesArray.length; i++) { + const dis = disabledAbilitiesArray[i]; + dis.turnsLeft--; // Уменьшаем длительность заглушения + + if (dis.turnsLeft > 0) { + stillDisabled.push(dis); + } else { + // Заглушение закончилось + if (addToLogCallback) { + const ability = characterAbilities.find(ab => ab.id === dis.abilityId); + if (ability) { + addToLogCallback( + `Способность "${ability.name}" персонажа ${characterName} больше не заглушена!`, + configToUse.LOG_TYPE_INFO + ); + } else { + // Если способность не найдена по ID (маловероятно, но возможно при ошибках данных) + addToLogCallback( + `Заглушение для неизвестной способности персонажа ${characterName} (ID: ${dis.abilityId}) закончилось.`, + configToUse.LOG_TYPE_INFO + ); + } + } + // Также нужно удалить соответствующий эффект из activeEffects, если он там был (например, playerSilencedOn_X) + // Это должно происходить в effectsLogic.processEffects, когда эффект с id `playerSilencedOn_${dis.abilityId}` истекает. + // Здесь мы только управляем массивом `disabledAbilities`. + } + } + + // Обновляем исходный массив, удаляя истекшие заглушения + disabledAbilitiesArray.length = 0; // Очищаем массив (сохраняя ссылку, если она используется где-то еще) + disabledAbilitiesArray.push(...stillDisabled); // Добавляем обратно только те, что еще активны +} + +/** + * Устанавливает или обновляет кулдаун для способности. + * Также обрабатывает специальные внутренние кулдауны для Баларда. + * @param {object} ability - Объект способности, для которой устанавливается кулдаун. + * @param {object} casterState - Состояние персонажа, применившего способность. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + */ +function setAbilityCooldown(ability, casterState, configToUse) { + if (!ability || !casterState || !casterState.abilityCooldowns) { + console.warn("[CooldownLogic] setAbilityCooldown: Missing ability, casterState, or casterState.abilityCooldowns."); + return; + } + + let baseCooldown = 0; + if (typeof ability.cooldown === 'number' && ability.cooldown > 0) { // Убедимся, что исходный КД > 0 + baseCooldown = ability.cooldown; + } + + // Специальные внутренние КД для Баларда - они могут перебивать общий КД + if (casterState.characterKey === 'balard') { + if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && + typeof ability.internalCooldownFromConfig === 'string' && // Проверяем, что есть ключ для конфига + typeof configToUse[ability.internalCooldownFromConfig] === 'number') { + // Устанавливаем значение для специального счетчика КД Баларда + casterState.silenceCooldownTurns = configToUse[ability.internalCooldownFromConfig]; + // Этот специальный КД также становится текущим общим КД для этой способности + baseCooldown = configToUse[ability.internalCooldownFromConfig]; + } else if (ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN && + typeof ability.internalCooldownValue === 'number') { // Здесь КД задан прямо в данных способности + casterState.manaDrainCooldownTurns = ability.internalCooldownValue; + baseCooldown = ability.internalCooldownValue; + } + } + + if (baseCooldown > 0) { + // Устанавливаем кулдаун. Добавляем +1, так как кулдаун уменьшится в конце текущего хода + // (когда будет вызван processPlayerAbilityCooldowns для этого персонажа). + casterState.abilityCooldowns[ability.id] = baseCooldown + 1; + } else { + // Если у способности нет базового кулдауна (baseCooldown === 0), + // убеждаемся, что в abilityCooldowns для нее стоит 0. + casterState.abilityCooldowns[ability.id] = 0; + } +} + +/** + * Обрабатывает специальные кулдауны для Баларда в конце его хода. + * @param {object} balardState - Состояние Баларда. + */ +function processBalardSpecialCooldowns(balardState) { + if (balardState.characterKey !== 'balard') return; + + if (balardState.silenceCooldownTurns !== undefined && balardState.silenceCooldownTurns > 0) { + balardState.silenceCooldownTurns--; + } + if (balardState.manaDrainCooldownTurns !== undefined && balardState.manaDrainCooldownTurns > 0) { + balardState.manaDrainCooldownTurns--; + } +} + + +module.exports = { + processPlayerAbilityCooldowns, + processDisabledAbilities, + setAbilityCooldown, + processBalardSpecialCooldowns +}; \ No newline at end of file diff --git a/server/game/logic/effectsLogic.js b/server/game/logic/effectsLogic.js new file mode 100644 index 0000000..69295d5 --- /dev/null +++ b/server/game/logic/effectsLogic.js @@ -0,0 +1,153 @@ +// /server/game/logic/effectsLogic.js + +// GAME_CONFIG и dataUtils будут передаваться в функции как параметры. +// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужен для внутренних констант +// const DATA_UTILS_STATIC = require('../../data/dataUtils'); // Если нужен для внутренних констант + +/** + * Обрабатывает активные эффекты (баффы/дебаффы) для бойца в конце его хода. + * Длительность эффекта уменьшается на 1. + * Периодические эффекты (DoT, сжигание ресурса и т.п.) срабатывают, если эффект не "justCast" в этом ходу. + * @param {Array} activeEffectsArray - Массив активных эффектов бойца (из gameState.player.activeEffects или gameState.opponent.activeEffects). + * @param {object} ownerState - Состояние бойца, на котором эффекты (currentHp, currentResource и т.д.). + * @param {object} ownerBaseStats - Базовые статы бойца (включая characterKey, name, maxHp, maxResource). + * @param {string} ownerRoleInGame - Роль бойца в игре ('player' или 'opponent'), для контекста. + * @param {object} currentGameState - Полное текущее состояние игры. + * @param {function} addToLogCallback - Функция для добавления сообщений в лог игры. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @param {object} dataUtils - Утилиты для доступа к данным игры (getCharacterData, getCharacterAbilities и т.д.). + */ +function processEffects( + activeEffectsArray, + ownerState, + ownerBaseStats, + ownerRoleInGame, // 'player' или 'opponent' + currentGameState, + addToLogCallback, + configToUse, + dataUtils +) { + if (!activeEffectsArray || activeEffectsArray.length === 0) { + return; + } + + const ownerName = ownerBaseStats.name; + const effectsToRemoveIndexes = []; + + for (let i = 0; i < activeEffectsArray.length; i++) { + const effect = activeEffectsArray[i]; + + // --- Применяем периодический эффект (DoT, сжигание ресурса и т.п.), если он не только что наложен --- + if (!effect.justCast) { + // 1. Урон от эффектов полного безмолвия (Гипнотический Взгляд, Раскол Разума) + // Эти эффекты наносят урон цели В КОНЦЕ ее хода. + if (effect.isFullSilence && typeof effect.power === 'number' && effect.power > 0) { + const damage = effect.power; // Урон, заложенный в эффекте + ownerState.currentHp = Math.max(0, Math.round(ownerState.currentHp - damage)); + if (addToLogCallback) { + addToLogCallback( + `😵 Эффект "${effect.name}" наносит ${damage} урона персонажу ${ownerName}! (HP: ${ownerState.currentHp}/${ownerBaseStats.maxHp})`, + configToUse.LOG_TYPE_DAMAGE + ); + } + } + + // 2. Сжигание ресурса (Печать Слабости, Проклятие Увядания) + // Эти эффекты сжигают ресурс цели В КОНЦЕ ее хода. + // ID эффекта на цели имеет префикс 'effect_' + ID способности, которая его наложила. + const isResourceBurnDebuff = effect.id === 'effect_' + configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || + effect.id === 'effect_' + configToUse.ABILITY_ID_ALMAGEST_DEBUFF; + if (isResourceBurnDebuff && typeof effect.power === 'number' && effect.power > 0) { + const resourceToBurn = effect.power; // Количество ресурса, сжигаемое за ход + if (ownerState.currentResource > 0) { + const actualBurn = Math.min(ownerState.currentResource, resourceToBurn); + ownerState.currentResource = Math.max(0, Math.round(ownerState.currentResource - actualBurn)); + if (addToLogCallback) { + addToLogCallback( + `🔥 Эффект "${effect.name}" сжигает ${actualBurn} ${ownerBaseStats.resourceName} у ${ownerName}! (Ресурс: ${ownerState.currentResource}/${ownerBaseStats.maxResource})`, + configToUse.LOG_TYPE_EFFECT + ); + } + } + } + // Примечание: Отложенные эффекты (isDelayed: true, например, Сила Природы) + // применяют свою основную силу в GameInstance.processPlayerAction (после атаки), а не здесь. + // Здесь они просто тикают по длительности. + } + + // --- Уменьшаем длительность --- + effect.turnsLeft--; + effect.justCast = false; // Эффект больше не считается "just cast" после обработки этого хода + + // --- Отмечаем для удаления, если длительность закончилась --- + if (effect.turnsLeft <= 0) { + effectsToRemoveIndexes.push(i); + if (addToLogCallback) { + addToLogCallback( + `Эффект "${effect.name}" на персонаже ${ownerName} закончился.`, + configToUse.LOG_TYPE_EFFECT + ); + } + // Если это был эффект, дающий блок, нужно обновить статус блокировки + if (effect.grantsBlock) { + updateBlockingStatus(ownerState); // Вызываем сразу, т.к. эффект удаляется + } + // Если это был эффект заглушения конкретной способности (playerSilencedOn_X), + // то соответствующая запись в ownerState.disabledAbilities должна быть удалена в cooldownLogic.processDisabledAbilities. + // Здесь мы просто удаляем сам эффект из activeEffects. + } + } + + // Удаляем эффекты с конца массива, чтобы не нарушить индексы при удалении + for (let i = effectsToRemoveIndexes.length - 1; i >= 0; i--) { + activeEffectsArray.splice(effectsToRemoveIndexes[i], 1); + } + + // После удаления всех истекших эффектов, еще раз обновляем статус блока, + // так как какой-то из удаленных эффектов мог быть последним дающим блок. + // (хотя updateBlockingStatus вызывается и при удалении конкретного блокирующего эффекта) + updateBlockingStatus(ownerState); +} + +/** + * Обновляет статус 'isBlocking' для бойца на основе его активных эффектов. + * Боец считается блокирующим, если у него есть хотя бы один активный эффект с флагом grantsBlock: true. + * @param {object} fighterState - Состояние бойца (объект из gameState.player или gameState.opponent). + */ +function updateBlockingStatus(fighterState) { + if (!fighterState || !fighterState.activeEffects) { + // console.warn("[EffectsLogic] updateBlockingStatus: fighterState or activeEffects missing."); + if (fighterState) fighterState.isBlocking = false; // Если нет эффектов, то точно не блокирует + return; + } + // Боец блокирует, если есть ХОТЯ БЫ ОДИН активный эффект, дающий блок + const wasBlocking = fighterState.isBlocking; + fighterState.isBlocking = fighterState.activeEffects.some(eff => eff.grantsBlock && eff.turnsLeft > 0); + + // Можно добавить лог, если статус блока изменился, для отладки + // if (wasBlocking !== fighterState.isBlocking && addToLogCallback) { + // addToLogCallback(`${fighterState.name} ${fighterState.isBlocking ? 'встает в защиту' : 'перестает защищаться'} из-за эффектов.`, 'info'); + // } +} + +/** + * Проверяет, находится ли персонаж под действием полного безмолвия. + * @param {object} characterState - Состояние персонажа из gameState. + * @param {object} configToUse - Конфигурационный объект игры. + * @returns {boolean} true, если персонаж под полным безмолвием, иначе false. + */ +function isCharacterFullySilenced(characterState, configToUse) { + if (!characterState || !characterState.activeEffects) { + return false; + } + return characterState.activeEffects.some( + eff => eff.isFullSilence && eff.turnsLeft > 0 + ); +} + + +module.exports = { + processEffects, + updateBlockingStatus, + isCharacterFullySilenced +}; \ No newline at end of file diff --git a/server/game/logic/gameStateLogic.js b/server/game/logic/gameStateLogic.js new file mode 100644 index 0000000..cafe475 --- /dev/null +++ b/server/game/logic/gameStateLogic.js @@ -0,0 +1,133 @@ +// /server/game/logic/gameStateLogic.js + +// GAME_CONFIG будет передаваться в функции как параметр configToUse. +// dataUtils также может передаваться, если нужен для какой-то логики здесь. + +/** + * Внутренняя проверка условий конца игры (основано на HP). + * @param {object} currentGameState - Текущее состояние игры. + * // configToUse и dataUtils здесь не используются, но могут понадобиться для более сложных условий + * @param {object} configToUse - Конфигурация игры. + * @param {object} dataUtils - Утилиты для доступа к данным. + * @returns {boolean} true, если игра окончена по HP, иначе false. + */ +function checkGameOverInternal(currentGameState, configToUse, dataUtils) { + if (!currentGameState || currentGameState.isGameOver) { + // Если игра уже помечена как оконченная, или нет состояния, возвращаем текущий статус + return currentGameState ? currentGameState.isGameOver : true; + } + + // Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами + if (!currentGameState.player || !currentGameState.opponent || + !currentGameState.player.characterKey || !currentGameState.opponent.characterKey || // Проверяем, что персонажи назначены + currentGameState.opponent.name === 'Ожидание игрока...' || // Дополнительная проверка на плейсхолдер + !currentGameState.opponent.maxHp || currentGameState.opponent.maxHp <= 0) { + return false; // Игра не может закончиться по HP, если один из бойцов не готов/не определен + } + + const playerDead = currentGameState.player.currentHp <= 0; + const opponentDead = currentGameState.opponent.currentHp <= 0; + + return playerDead || opponentDead; // Игра окончена, если хотя бы один мертв +} + +/** + * Определяет результат завершения игры (победитель, проигравший, причина). + * Вызывается, когда checkGameOverInternal вернул true или игра завершается по другой причине (дисконнект, таймаут). + * @param {object} currentGameState - Текущее состояние игры. + * @param {object} configToUse - Конфигурация игры (GAME_CONFIG). + * @param {string} gameMode - Режим игры ('ai' или 'pvp'). + * @param {string} [explicitReason=null] - Явная причина завершения (например, 'turn_timeout', 'opponent_disconnected'). + * Если null, причина определяется по HP. + * @param {string} [explicitWinnerRole=null] - Явный победитель (если известен, например, при дисконнекте). + * @param {string} [explicitLoserRole=null] - Явный проигравший (если известен). + * @returns {{isOver: boolean, winnerRole: string|null, loserRole: string|null, reason: string, logMessage: string}} + */ +function getGameOverResult( + currentGameState, + configToUse, + gameMode, + explicitReason = null, + explicitWinnerRole = null, + explicitLoserRole = null +) { + if (!currentGameState) { + return { isOver: true, winnerRole: null, loserRole: null, reason: 'error_no_gamestate', logMessage: 'Ошибка: нет состояния игры.' }; + } + + // Если причина уже задана (например, дисконнект или таймаут), используем ее + if (explicitReason) { + let winnerName = explicitWinnerRole ? (currentGameState[explicitWinnerRole]?.name || explicitWinnerRole) : 'Никто'; + let loserName = explicitLoserRole ? (currentGameState[explicitLoserRole]?.name || explicitLoserRole) : 'Никто'; + let logMsg = ""; + + if (explicitReason === 'turn_timeout') { + logMsg = `⏱️ Время хода для ${loserName} истекло! Победа присуждается ${winnerName}!`; + } else if (explicitReason === 'opponent_disconnected') { + logMsg = `🔌 Игрок ${loserName} отключился. Победа присуждается ${winnerName}!`; + if (gameMode === 'ai' && explicitLoserRole === configToUse.PLAYER_ID) { // Игрок отключился в AI игре + winnerName = currentGameState.opponent?.name || 'AI'; // AI "выиграл" по факту, но не формально + logMsg = `🔌 Игрок ${loserName} отключился. Игра завершена.`; + explicitWinnerRole = null; // В AI режиме нет формального победителя при дисконнекте игрока + } + } else { + logMsg = `Игра завершена. Причина: ${explicitReason}. Победитель: ${winnerName}.`; + } + + return { + isOver: true, + winnerRole: explicitWinnerRole, + loserRole: explicitLoserRole, + reason: explicitReason, + logMessage: logMsg + }; + } + + // Если явной причины нет, проверяем по HP + const playerDead = currentGameState.player?.currentHp <= 0; + const opponentDead = currentGameState.opponent?.currentHp <= 0; + + if (!playerDead && !opponentDead) { + return { isOver: false, winnerRole: null, loserRole: null, reason: 'not_over_hp', logMessage: "" }; // Игра еще не окончена по HP + } + + let winnerRole = null; + let loserRole = null; + let reason = 'hp_zero'; + let logMessage = ""; + + if (gameMode === 'ai') { + if (playerDead) { // Игрок проиграл AI + winnerRole = configToUse.OPPONENT_ID; // AI победил + loserRole = configToUse.PLAYER_ID; + logMessage = `😭 ПОРАЖЕНИЕ! ${currentGameState.opponent.name} оказался сильнее! 😭`; + } else { // Игрок победил AI (opponentDead) + winnerRole = configToUse.PLAYER_ID; + loserRole = configToUse.OPPONENT_ID; + logMessage = `🏁 ПОБЕДА! Вы одолели ${currentGameState.opponent.name}! 🏁`; + } + } else { // PvP режим + if (playerDead && opponentDead) { // Ничья - победа присуждается игроку в слоте 'player' (или по другим правилам) + winnerRole = configToUse.PLAYER_ID; + loserRole = configToUse.OPPONENT_ID; + logMessage = `⚔️ Ничья! Оба бойца пали! Победа присуждается ${currentGameState.player.name} по правилам арены!`; + reason = 'draw_player_wins'; + } else if (playerDead) { + winnerRole = configToUse.OPPONENT_ID; + loserRole = configToUse.PLAYER_ID; + logMessage = `🏁 ПОБЕДА! ${currentGameState.opponent.name} одолел(а) ${currentGameState.player.name}! 🏁`; + } else { // opponentDead + winnerRole = configToUse.PLAYER_ID; + loserRole = configToUse.OPPONENT_ID; + logMessage = `🏁 ПОБЕДА! ${currentGameState.player.name} одолел(а) ${currentGameState.opponent.name}! 🏁`; + } + } + + return { isOver: true, winnerRole, loserRole, reason, logMessage }; +} + + +module.exports = { + checkGameOverInternal, + getGameOverResult +}; \ No newline at end of file diff --git a/server/game/logic/index.js b/server/game/logic/index.js new file mode 100644 index 0000000..780102b --- /dev/null +++ b/server/game/logic/index.js @@ -0,0 +1,66 @@ +// /server/game/logic/index.js + +// Импортируем функции из всех специализированных логических модулей + +const { + performAttack, + applyAbilityEffect, + checkAbilityValidity +} = require('./combatLogic'); + +const { + processPlayerAbilityCooldowns, + processDisabledAbilities, + setAbilityCooldown, + processBalardSpecialCooldowns +} = require('./cooldownLogic'); + +const { + processEffects, + updateBlockingStatus, + isCharacterFullySilenced +} = require('./effectsLogic'); + +const { + decideAiAction +} = require('./aiLogic'); + +const { + getRandomTaunt +} = require('./tauntLogic'); // Предполагаем, что getRandomTaunt вынесен в tauntLogic.js + +const { + checkGameOverInternal, // Внутренняя проверка на HP + getGameOverResult // Определяет победителя и причину для checkGameOver в GameInstance +} = require('./gameStateLogic'); // Предполагаем, что логика завершения игры вынесена + + +// Экспортируем все импортированные функции, чтобы они были доступны +// через единый объект 'gameLogic' в GameInstance.js +module.exports = { + // Combat Logic + performAttack, + applyAbilityEffect, + checkAbilityValidity, + + // Cooldown Logic + processPlayerAbilityCooldowns, + processDisabledAbilities, + setAbilityCooldown, + processBalardSpecialCooldowns, + + // Effects Logic + processEffects, + updateBlockingStatus, + isCharacterFullySilenced, + + // AI Logic + decideAiAction, + + // Taunt Logic + getRandomTaunt, + + // Game State Logic (например, для условий завершения) + checkGameOverInternal, + getGameOverResult +}; \ No newline at end of file diff --git a/server/game/logic/tauntLogic.js b/server/game/logic/tauntLogic.js new file mode 100644 index 0000000..89eb243 --- /dev/null +++ b/server/game/logic/tauntLogic.js @@ -0,0 +1,90 @@ +// /server/game/logic/tauntLogic.js +const GAME_CONFIG = require('../../core/config'); // Путь к config.js +// Вам понадобится доступ к gameData.tauntSystem здесь. +// Либо импортируйте весь gameData, либо только tauntSystem из data/taunts.js +const gameData = require('../../data'); // Импортируем собранный gameData из data/index.js + +/** + * Получает случайную насмешку из системы насмешек. + * (Ваша существующая функция getRandomTaunt) + */ +function getRandomTaunt(speakerCharacterKey, trigger, context = {}, configToUse, opponentFullData, currentGameState) { + // Проверяем наличие системы насмешек для говорящего персонажа + const speakerTauntSystem = gameData.tauntSystem?.[speakerCharacterKey]; // Используем gameData.tauntSystem + if (!speakerTauntSystem) return "(Молчание)"; + + const opponentCharacterKey = opponentFullData?.baseStats?.characterKey || currentGameState?.opponent?.characterKey; // Получаем ключ оппонента + if (!opponentCharacterKey) { // Если оппонент не определен (например, начало игры с AI, где оппонент еще не fully в gameState) + // console.warn(`getRandomTaunt: Opponent character key not determined for speaker ${speakerCharacterKey}, trigger ${trigger}`); + // Можно попробовать определить оппонента по-другому или вернуть общую фразу / молчание + if (trigger === 'battleStart' && speakerCharacterKey === 'elena' && currentGameState.gameMode === 'ai') { + // Для Елены против AI Баларда в начале боя + const balardTaunts = speakerTauntSystem.balard; + if (balardTaunts?.onBattleState?.start) { + const potentialTaunts = balardTaunts.onBattleState.start; + return potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)] || "(Молчание)"; + } + } + return "(Молчание)"; + } + + + const tauntBranch = speakerTauntSystem[opponentCharacterKey]; + if (!tauntBranch) { + return "(Молчание)"; + } + + let potentialTaunts = []; + + if (trigger === 'battleStart') { + potentialTaunts = tauntBranch.onBattleState?.start; + } else if (trigger === 'opponentNearDefeatCheck') { + const opponentState = currentGameState?.player?.characterKey === opponentCharacterKey ? currentGameState.player : currentGameState.opponent; + if (opponentState && opponentState.maxHp > 0 && opponentState.currentHp / opponentState.maxHp < 0.20) { + potentialTaunts = tauntBranch.onBattleState?.opponentNearDefeat; + } + } else if (trigger === 'selfCastAbility' && context.abilityId) { + potentialTaunts = tauntBranch.selfCastAbility?.[context.abilityId]; + } else if (trigger === 'basicAttack' && tauntBranch.basicAttack) { + const opponentState = currentGameState?.player?.characterKey === opponentCharacterKey ? currentGameState.player : currentGameState.opponent; + if (speakerCharacterKey === 'elena' && opponentCharacterKey === 'balard' && opponentState) { + const opponentHpPerc = (opponentState.currentHp / opponentState.maxHp) * 100; + if (opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT) { + potentialTaunts = tauntBranch.basicAttack.dominating; + } else { + potentialTaunts = tauntBranch.basicAttack.merciful; + } + } else { + potentialTaunts = tauntBranch.basicAttack.general || []; // Фоллбэк на пустой массив + } + } else if (trigger === 'onOpponentAction' && context.abilityId) { + const actionResponses = tauntBranch.onOpponentAction?.[context.abilityId]; + if (actionResponses) { + if (typeof actionResponses === 'object' && !Array.isArray(actionResponses) && context.outcome && context.outcome in actionResponses) { + potentialTaunts = actionResponses[context.outcome]; + } else if (Array.isArray(actionResponses)) { + potentialTaunts = actionResponses; + } + } + } else if (trigger === 'onOpponentAttackBlocked' && tauntBranch.onOpponentAction?.attackBlocked) { + potentialTaunts = tauntBranch.onOpponentAction.attackBlocked; + } else if (trigger === 'onOpponentAttackHit' && tauntBranch.onOpponentAction?.attackHits) { + potentialTaunts = tauntBranch.onOpponentAction.attackHits; + } + + if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) { + // Фоллбэк на общие фразы при basicAttack, если специфичные не найдены + if (trigger === 'basicAttack' && tauntBranch.basicAttack?.general && tauntBranch.basicAttack.general.length > 0) { + potentialTaunts = tauntBranch.basicAttack.general; + } else { + return "(Молчание)"; + } + } + + const selectedTaunt = potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)]; + return selectedTaunt || "(Молчание)"; +} + +module.exports = { + getRandomTaunt +}; \ No newline at end of file diff --git a/server/services/SocketService.js b/server/services/SocketService.js new file mode 100644 index 0000000..e69de29