svoboda200786-patch-2 #2
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
12
.idea/battle_club_git.iml
generated
Normal file
12
.idea/battle_club_git.iml
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/battle_club_git.iml" filepath="$PROJECT_DIR$/.idea/battle_club_git.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
133
server/auth/authService.js
Normal file
133
server/auth/authService.js
Normal file
@ -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<object>} Объект с результатом: { 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<object>} Объект с результатом: { 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
|
||||||
|
};
|
190
server/bc.js
Normal file
190
server/bc.js
Normal file
@ -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); // Аварийное завершение процесса
|
||||||
|
});
|
111
server/core/config.js
Normal file
111
server/core/config.js
Normal file
@ -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.");
|
90
server/core/db.js
Normal file
90
server/core/db.js
Normal file
@ -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;
|
||||||
|
|
||||||
|
*/
|
93
server/core/logger.js
Normal file
93
server/core/logger.js
Normal file
@ -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);
|
||||||
|
*/
|
178
server/data/characterAbilities.js
Normal file
178
server/data/characterAbilities.js
Normal file
@ -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
|
||||||
|
};
|
47
server/data/characterStats.js
Normal file
47
server/data/characterStats.js
Normal file
@ -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
|
||||||
|
// ...и другие персонажи
|
||||||
|
};
|
72
server/data/dataUtils.js
Normal file
72
server/data/dataUtils.js
Normal file
@ -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<object>}|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<object>|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
|
||||||
|
// ...другие экспортируемые утилиты
|
||||||
|
};
|
75
server/data/index.js
Normal file
75
server/data/index.js
Normal file
@ -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,
|
||||||
|
где функции могут ожидать всю структуру данных сразу.
|
||||||
|
*/
|
118
server/data/taunts.js
Normal file
118
server/data/taunts.js
Normal file
@ -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
|
||||||
|
};
|
343
server/game/GameManager.js
Normal file
343
server/game/GameManager.js
Normal file
@ -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;
|
474
server/game/instance/GameInstance.js
Normal file
474
server/game/instance/GameInstance.js
Normal file
@ -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;
|
0
server/game/instance/Player.js
Normal file
0
server/game/instance/Player.js
Normal file
120
server/game/instance/TurnTimer.js
Normal file
120
server/game/instance/TurnTimer.js
Normal file
@ -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;
|
133
server/game/logic/aiLogic.js
Normal file
133
server/game/logic/aiLogic.js
Normal file
@ -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
|
||||||
|
};
|
313
server/game/logic/combatLogic.js
Normal file
313
server/game/logic/combatLogic.js
Normal file
@ -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 // Экспортируем новую функцию
|
||||||
|
};
|
154
server/game/logic/cooldownLogic.js
Normal file
154
server/game/logic/cooldownLogic.js
Normal file
@ -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<object>} 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<object>} disabledAbilitiesArray - Массив объектов заглушенных способностей.
|
||||||
|
* @param {Array<object>} 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
|
||||||
|
};
|
153
server/game/logic/effectsLogic.js
Normal file
153
server/game/logic/effectsLogic.js
Normal file
@ -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<object>} 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
|
||||||
|
};
|
133
server/game/logic/gameStateLogic.js
Normal file
133
server/game/logic/gameStateLogic.js
Normal file
@ -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
|
||||||
|
};
|
66
server/game/logic/index.js
Normal file
66
server/game/logic/index.js
Normal file
@ -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
|
||||||
|
};
|
90
server/game/logic/tauntLogic.js
Normal file
90
server/game/logic/tauntLogic.js
Normal file
@ -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
|
||||||
|
};
|
0
server/services/SocketService.js
Normal file
0
server/services/SocketService.js
Normal file
Loading…
x
Reference in New Issue
Block a user