svoboda200786-patch-2 #2

Merged
svoboda200786 merged 2 commits from svoboda200786-patch-2 into main 2025-05-20 09:22:40 +00:00
36 changed files with 3964 additions and 3439 deletions

5
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

165
bc.js
View File

@ -1,165 +0,0 @@
// bc.js - Главный файл сервера Battle Club
const express = require('express');
const http = require('http'); // Используем HTTP, так как SSL будет на Node.js прокси (server.js)
const { Server } = require('socket.io');
const path = require('path');
//
// Импорт серверных модулей
const auth = require('./server_modules/auth');
const GameManager = require('./server_modules/gameManager');
const db = require('./server_modules/db'); // Импорт для инициализации соединения с БД (хотя пул создается при require)
const GAME_CONFIG = require('./server_modules/config'); // Конфиг игры
// gameData импортируется внутри GameInstance и GameLogic
const app = express();
const server = http.createServer(app);
// Настройка Socket.IO
const io = new Server(server, {
cors: {
origin: "https://pavel-chagovsky.com:3200", // Указываем точный origin, включая порт, откуда придет запрос К ПРОКСИ
// Если доступ будет с нескольких доменов или портов, можно использовать массив:
// origin: ["https://pavel-chagovsky.com:3200", "https://oleg-okhotnikov.ru:3200"],
// Или для разработки можно временно использовать "*", но это менее безопасно:
// origin: "*",
methods: ["GET", "POST"]
}
});
// Раздача статических файлов из папки 'public'
app.use(express.static(path.join(__dirname, 'public')));
// Создаем экземпляр GameManager
const gameManager = new GameManager(io);
// Хранилище информации о залогиненных пользователях по socket.id
const loggedInUsers = {}; // { socket.id: { userId: ..., username: ... } }
// Обработка подключений Socket.IO
io.on('connection', (socket) => {
console.log(`[BC App HTTP] Socket.IO User connected: ${socket.id}`);
socket.userData = null;
socket.on('register', async (data) => {
console.log(`[BC App HTTP Socket.IO] Register attempt for username: "${data?.username}" from ${socket.id}`);
const result = await auth.registerUser(data?.username, data?.password);
if (result.success) {
console.log(`[BC App HTTP Socket.IO] Registration successful for ${result.username} (${result.userId})`);
} else {
console.warn(`[BC App HTTP Socket.IO] Registration failed for "${data?.username}": ${result.message}`);
}
socket.emit('registerResponse', result);
});
socket.on('login', async (data) => {
console.log(`[BC App HTTP Socket.IO] Login attempt for username: "${data?.username}" from ${socket.id}`);
const result = await auth.loginUser(data?.username, data?.password);
if (result.success) {
console.log(`[BC App HTTP 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.handleRequestGameState(socket, socket.userData.userId);
} else {
console.warn(`[BC App HTTP 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', () => {
console.log(`[BC App HTTP Socket.IO] Logout for user ${socket.userData?.username || socket.id}`);
gameManager.handleDisconnect(socket.id, socket.userData?.userId || socket.id);
socket.userData = null;
if (loggedInUsers[socket.id]) delete loggedInUsers[socket.id];
});
socket.on('createGame', (data) => {
const identifier = socket.userData?.userId || socket.id;
const mode = data?.mode || 'ai';
if (mode === 'pvp' && !socket.userData) {
socket.emit('gameError', { message: 'Необходимо войти в систему для создания PvP игры.' });
return;
}
console.log(`[BC App HTTP Socket.IO] Create Game request from ${socket.userData?.username || socket.id} (Identifier: ${identifier}). Mode: ${mode}, Character: ${data?.characterKey}`);
const characterKey = data?.characterKey || 'elena';
gameManager.createGame(socket, mode, characterKey, identifier);
});
socket.on('joinGame', (data) => {
if (!socket.userData) {
socket.emit('gameError', { message: 'Необходимо войти в систему для присоединения к игре.' });
return;
}
console.log(`[BC App HTTP Socket.IO] Join Game request from ${socket.userData.username} (${socket.id}). Game ID: ${data?.gameId}`);
const gameId = data?.gameId;
const identifier = socket.userData.userId;
if (gameId) {
gameManager.joinGame(socket, gameId, identifier);
} else {
socket.emit('gameError', { message: 'Не указан ID игры для присоединения.' });
}
});
socket.on('findRandomGame', (data) => {
if (!socket.userData) {
socket.emit('gameError', { message: 'Необходимо войти в систему для поиска игры.' });
return;
}
console.log(`[BC App HTTP Socket.IO] Find Random Game request from ${socket.userData.username} (${socket.id}). Preferred Character: ${data?.characterKey}`);
const characterKey = data?.characterKey || 'elena';
const identifier = socket.userData.userId;
gameManager.findAndJoinRandomPvPGame(socket, characterKey, identifier);
});
socket.on('requestPvPGameList', () => {
console.log(`[BC App HTTP 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) {
console.log(`[BC App HTTP Socket.IO] Request Game State from unauthenticated socket ${socket.id}.`);
socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' });
return;
}
console.log(`[BC App HTTP Socket.IO] Request Game State from ${socket.userData.username} (${socket.id}).`);
gameManager.handleRequestGameState(socket, socket.userData.userId);
});
socket.on('playerAction', (actionData) => {
const identifier = socket.userData?.userId || socket.id;
gameManager.handlePlayerAction(identifier, actionData);
});
socket.on('disconnect', (reason) => {
const identifier = socket.userData?.userId || socket.id;
console.log(`[BC App HTTP Socket.IO] User disconnected: ${socket.id} (Причина: ${reason}). Identifier: ${identifier}`);
gameManager.handleDisconnect(socket.id, identifier);
if (loggedInUsers[socket.id]) {
delete loggedInUsers[socket.id];
}
});
});
// Запуск HTTP сервера
const PORT = process.env.BC_INTERNAL_PORT || 3200; // Внутренний порт для bc.js
const HOSTNAME = '127.0.0.1'; // Слушать ТОЛЬКО на localhost
server.listen(PORT, HOSTNAME, () => { // Явно указываем HOSTNAME
console.log(`Battle Club HTTP Application Server running at http://${HOSTNAME}:${PORT}`);
console.log(`This server should only be accessed locally by the reverse proxy.`);
console.log(`Serving static files from: ${path.join(__dirname, 'public')}`);
});
// Обработка необработанных промис-ошибок
process.on('unhandledRejection', (reason, promise) => {
console.error('[BC App HTTP UNHANDLED REJECTION] Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (err) => {
console.error('[BC App HTTP UNCAUGHT EXCEPTION] Caught exception:', err);
});

View File

@ -11,14 +11,8 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
<div id="user-info" style="display:none;"> <!-- Информация о пользователе и кнопка выхода -->
<div>
<p>Привет, <span id="logged-in-username"></span>!</p>
<button id="logout-button"><i class="fas fa-sign-out-alt"></i> Выход</button>
</div>
</div>
<div class="auth-game-setup-wrapper"> <!-- Обертка для экранов до начала игры -->
<div class="auth-game-setup-wrapper"> <!-- Обертка для экранов до начала игры -->
<div id="status-container">
<div id="auth-message"></div>
<div id="game-status-message">Ожидание подключения к серверу...</div>
@ -41,7 +35,10 @@
</form>
</div>
<!-- Место для #user-info было здесь, но теперь оно выше для стилизации -->
<div id="user-info" style="display:none;"> <!-- Информация о пользователе и кнопка выхода -->
<p>Привет, <span id="logged-in-username"></span>!</p>
<button id="logout-button"><i class="fas fa-sign-out-alt"></i> Выход</button>
</div>
<div id="game-setup" style="display: none;"> <!-- Секция Настройки Игры (после логина) -->
<h2>Настройка Игры</h2>
@ -52,6 +49,7 @@
<div>
<h3>PvP (Игрок против Игрока)</h3>
<!-- === Блок выбора персонажа === -->
<div class="character-selection">
<h4>Выберите персонажа для PvP:</h4>
<input type="radio" id="char-elena" name="pvp-character" value="elena" checked>
@ -60,6 +58,7 @@
<input type="radio" id="char-almagest" name="pvp-character" value="almagest">
<label for="char-almagest"><i class="fas fa-staff-aesculapius"></i> Альмагест</label>
</div>
<!-- === Конец блока выбора персонажа === -->
<button id="create-pvp-game">Создать PvP Игру</button>
<button id="find-random-pvp-game">Найти случайную PvP Игру</button>
@ -77,11 +76,21 @@
<div class="game-wrapper" style="display: none;"> <!-- Игровая арена, изначально скрыта -->
<!-- === ИЗМЕНЕНИЕ: Удален game-header === -->
<!-- <header class="game-header">
<header class="game-header">
<h1><span class="title-player">Игрок 1</span> <span class="separator"><i class="fas fa-fist-raised"></i></span> <span class="title-opponent">Игрок 2</span></h1>
</header> -->
<!-- === КОНЕЦ ИЗМЕНЕНИЯ === -->
</header>
<!-- === НОВЫЙ БЛОК: Контролы переключения панелей (для мобильных) === -->
<!-- CSS будет управлять видимостью этого блока (display: none по умолчанию, display: flex в медиа-запросе) -->
<div class="panel-switcher-controls" style="display: none;">
<button id="show-player-panel-btn" class="panel-switch-button active">
<i class="fas fa-user"></i> <span class="button-text">Игрок</span>
</button>
<button id="show-opponent-panel-btn" class="panel-switch-button">
<i class="fas fa-ghost"></i> <span class="button-text">Противник</span>
</button>
</div>
<!-- === КОНЕЦ НОВОГО БЛОКА === -->
<main class="battle-arena-container">
<!-- Колонка Игрока (Панель 1 в UI) -->
@ -221,7 +230,7 @@
</div> <!-- Конец .game-wrapper -->
<script src="/socket.io/socket.io.js"></script>
<script src="./js/ui.js"></script> <!-- ui.js теперь перед client.js -->
<script src="./js/ui.js"></script>
<script src="./js/client.js"></script>
</body>
</html>

View File

@ -3,11 +3,13 @@
document.addEventListener('DOMContentLoaded', () => {
const socket = io({
// Опции Socket.IO, если нужны
// transports: ['websocket'], // Можно попробовать для отладки, если есть проблемы с polling
});
// --- Состояние клиента ---
let currentGameState = null;
let myPlayerId = null;
let myPlayerId = null; // Технический ID слота в игре ('player' или 'opponent')
let myUserId = null; // ID залогиненного пользователя (из БД)
let myCharacterKey = null;
let opponentCharacterKey = null;
let currentGameId = null;
@ -20,7 +22,6 @@ document.addEventListener('DOMContentLoaded', () => {
let isInGame = false;
// --- DOM Элементы ---
// Аутентификация
const authSection = document.getElementById('auth-section');
const registerForm = document.getElementById('register-form');
const loginForm = document.getElementById('login-form');
@ -30,125 +31,97 @@ document.addEventListener('DOMContentLoaded', () => {
const loggedInUsernameSpan = document.getElementById('logged-in-username');
const logoutButton = document.getElementById('logout-button');
// Настройка игры
const gameSetupDiv = document.getElementById('game-setup');
const createAIGameButton = document.getElementById('create-ai-game');
const createPvPGameButton = document.getElementById('create-pvp-game');
const joinPvPGameButton = document.getElementById('join-pvP-game'); // Опечатка в ID, должно быть join-pvp-game
const joinPvPGameButton = document.getElementById('join-pvp-game'); // Убедитесь, что ID в HTML 'join-pvp-game'
const findRandomPvPGameButton = document.getElementById('find-random-pvp-game');
const gameIdInput = document.getElementById('game-id-input');
const availableGamesDiv = document.getElementById('available-games-list');
const gameStatusMessage = document.getElementById('game-status-message');
const pvpCharacterRadios = document.querySelectorAll('input[name="pvp-character"]');
// Игровая Арена
const gameWrapper = document.querySelector('.game-wrapper');
const attackButton = document.getElementById('button-attack');
const returnToMenuButton = document.getElementById('return-to-menu-button');
const gameOverScreen = document.getElementById('game-over-screen');
const abilitiesGrid = document.getElementById('abilities-grid');
// === ИЗМЕНЕНИЕ: DOM элемент для таймера ===
const turnTimerSpan = document.getElementById('turn-timer'); // Элемент для отображения времени
const turnTimerContainer = document.getElementById('turn-timer-container'); // Контейнер таймера для управления видимостью
// === КОНЕЦ ИЗМЕНЕНИЯ ===
console.log('Client.js DOMContentLoaded. Initializing elements...');
const turnTimerSpan = document.getElementById('turn-timer');
const turnTimerContainer = document.getElementById('turn-timer-container');
// --- Функции управления UI ---
function showAuthScreen() {
console.log('[UI] Showing Auth Screen');
if (authSection) authSection.style.display = 'block';
if (userInfoDiv) userInfoDiv.style.display = 'none';
if (gameSetupDiv) gameSetupDiv.style.display = 'none';
if (gameWrapper) gameWrapper.style.display = 'none';
authSection.style.display = 'block';
userInfoDiv.style.display = 'none';
gameSetupDiv.style.display = 'none';
gameWrapper.style.display = 'none';
hideGameOverModal();
setAuthMessage("Ожидание подключения к серверу...");
if (statusContainer) statusContainer.style.display = 'block';
statusContainer.style.display = 'block';
isInGame = false;
disableGameControls();
resetGameVariables();
// === ИЗМЕНЕНИЕ: Скрываем таймер при выходе на экран аутентификации ===
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
// === КОНЕЦ ИЗМЕНЕНИЯ ===
}
function showGameSelectionScreen(username) {
console.log('[UI] Showing Game Selection Screen for:', username);
if (authSection) authSection.style.display = 'none';
if (userInfoDiv) {
authSection.style.display = 'none';
userInfoDiv.style.display = 'block';
if (loggedInUsernameSpan) loggedInUsernameSpan.textContent = username;
}
if (gameSetupDiv) gameSetupDiv.style.display = 'block';
if (gameWrapper) gameWrapper.style.display = 'none';
loggedInUsernameSpan.textContent = username;
gameSetupDiv.style.display = 'block';
gameWrapper.style.display = 'none';
hideGameOverModal();
setGameStatusMessage("Выберите режим игры или присоединитесь к существующей.");
if (statusContainer) statusContainer.style.display = 'block';
statusContainer.style.display = 'block';
socket.emit('requestPvPGameList');
updateAvailableGamesList([]);
updateAvailableGamesList([]); // Очищаем перед запросом
if (gameIdInput) gameIdInput.value = '';
const elenaRadio = document.getElementById('char-elena');
if (elenaRadio) elenaRadio.checked = true;
isInGame = false;
disableGameControls();
resetGameVariables();
// === ИЗМЕНЕНИЕ: Скрываем таймер при выходе на экран выбора игры ===
resetGameVariables(); // Сбрасываем игровые переменные при выходе в меню
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
// === КОНЕЦ ИЗМЕНЕНИЯ ===
enableSetupButtons(); // Включаем кнопки на экране выбора игры
}
function showGameScreen() {
console.log('[UI] Showing Game Screen');
hideGameOverModal();
if (authSection) authSection.style.display = 'none';
if (userInfoDiv) userInfoDiv.style.display = 'block'; // Оставляем видимым, чтобы видеть "Привет, username"
if (gameSetupDiv) gameSetupDiv.style.display = 'none';
if (gameWrapper) gameWrapper.style.display = 'flex';
setGameStatusMessage("");
if (statusContainer) statusContainer.style.display = 'none';
authSection.style.display = 'none';
userInfoDiv.style.display = 'block'; // Оставляем инфо о пользователе
gameSetupDiv.style.display = 'none';
gameWrapper.style.display = 'flex';
setGameStatusMessage(""); // Очищаем статус, т.к. есть индикатор хода
statusContainer.style.display = 'none'; // Скрываем общий статус контейнер
isInGame = true;
disableGameControls();
// === ИЗМЕНЕНИЕ: Показываем контейнер таймера, когда игра начинается ===
if (turnTimerContainer) turnTimerContainer.style.display = 'block';
disableGameControls(); // Кнопки включатся, когда будет ход игрока
if (turnTimerContainer) turnTimerContainer.style.display = 'block'; // Показываем таймер
if (turnTimerSpan) turnTimerSpan.textContent = '--'; // Начальное значение
// === КОНЕЦ ИЗМЕНЕНИЯ ===
}
function resetGameVariables() {
currentGameId = null;
currentGameState = null;
myPlayerId = null;
myCharacterKey = null;
opponentCharacterKey = null;
playerBaseStatsServer = null;
opponentBaseStatsServer = null;
playerAbilitiesServer = null;
opponentAbilitiesServer = null;
window.gameState = null;
window.gameData = null;
window.myPlayerId = null;
currentGameId = null; currentGameState = null; myPlayerId = null;
myCharacterKey = null; opponentCharacterKey = null;
playerBaseStatsServer = null; opponentBaseStatsServer = null;
playerAbilitiesServer = null; opponentAbilitiesServer = null;
window.gameState = null; window.gameData = null; window.myPlayerId = null;
}
function hideGameOverModal() {
const hiddenClass = (window.GAME_CONFIG && window.GAME_CONFIG.CSS_CLASS_HIDDEN) ? window.GAME_CONFIG.CSS_CLASS_HIDDEN : 'hidden';
const hiddenClass = window.GAME_CONFIG?.CSS_CLASS_HIDDEN || 'hidden';
if (gameOverScreen && !gameOverScreen.classList.contains(hiddenClass)) {
console.log('[Client.js DEBUG] Hiding GameOver Modal.');
gameOverScreen.classList.add(hiddenClass);
if (window.gameUI?.uiElements?.gameOver?.modalContent) {
window.gameUI.uiElements.gameOver.modalContent.style.transform = 'scale(0.8) translateY(30px)';
window.gameUI.uiElements.gameOver.modalContent.style.opacity = '0';
}
if (window.gameUI?.uiElements?.opponent?.panel) {
const opponentPanel = window.gameUI.uiElements.opponent.panel;
if (opponentPanel.classList.contains('dissolving')) {
const opponentPanel = window.gameUI?.uiElements?.opponent?.panel;
if (opponentPanel?.classList.contains('dissolving')) {
opponentPanel.classList.remove('dissolving');
opponentPanel.style.opacity = '1';
opponentPanel.style.transform = 'scale(1) translateY(0)';
}
opponentPanel.style.opacity = '1'; opponentPanel.style.transform = 'scale(1) translateY(0)';
}
}
}
@ -183,143 +156,102 @@ document.addEventListener('DOMContentLoaded', () => {
function enableGameControls(enableAttack = true, enableAbilities = true) {
if (attackButton) attackButton.disabled = !enableAttack;
if (abilitiesGrid) {
const abilityButtonClass = window.GAME_CONFIG?.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
abilitiesGrid.querySelectorAll(`.${abilityButtonClass}`).forEach(button => { button.disabled = !enableAbilities; });
const cls = window.GAME_CONFIG?.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = !enableAbilities; });
}
if (window.gameUI?.uiElements?.controls?.buttonBlock) window.gameUI.uiElements.controls.buttonBlock.disabled = true;
}
function disableGameControls() { enableGameControls(false, false); }
function disableGameControls() {
enableGameControls(false, false);
}
// Инициализация кнопок и обработчиков
if (registerForm) {
registerForm.addEventListener('submit', (e) => {
e.preventDefault();
const usernameInput = document.getElementById('register-username');
const passwordInput = document.getElementById('register-password');
if (usernameInput && passwordInput) {
registerForm.querySelector('button').disabled = true;
if (loginForm) loginForm.querySelector('button').disabled = true;
socket.emit('register', { username: usernameInput.value, password: passwordInput.value });
} else { setAuthMessage("Ошибка: поля ввода не найдены.", true); }
});
}
if (loginForm) {
loginForm.addEventListener('submit', (e) => {
e.preventDefault();
const usernameInput = document.getElementById('login-username');
const passwordInput = document.getElementById('login-password');
if (usernameInput && passwordInput) {
if (registerForm) registerForm.querySelector('button').disabled = true;
loginForm.querySelector('button').disabled = true;
socket.emit('login', { username: usernameInput.value, password: passwordInput.value });
} else { setAuthMessage("Ошибка: поля ввода не найдены.", true); }
});
}
if (logoutButton) {
logoutButton.addEventListener('click', () => {
logoutButton.disabled = true;
socket.emit('logout');
isLoggedIn = false; loggedInUsername = '';
resetGameVariables(); isInGame = false; disableGameControls();
showAuthScreen();
setGameStatusMessage("Вы вышли из системы.");
logoutButton.disabled = false;
});
}
if (createAIGameButton) {
createAIGameButton.addEventListener('click', () => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите, чтобы начать игру.", true); return; }
disableSetupButtons();
socket.emit('createGame', { mode: 'ai', characterKey: 'elena' });
setGameStatusMessage("Создание игры против AI...");
});
}
if (createPvPGameButton) {
createPvPGameButton.addEventListener('click', () => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите, чтобы начать игру.", true); return; }
disableSetupButtons();
const selectedCharacter = getSelectedCharacterKey();
socket.emit('createGame', { mode: 'pvp', characterKey: selectedCharacter });
setGameStatusMessage(`Создание PvP игры за ${selectedCharacter === 'elena' ? 'Елену' : 'Альмагест'}...`);
});
}
// Исправляем селектор для joinPvPGameButton, если ID в HTML был join-pvP-game
const actualJoinPvPGameButton = document.getElementById('join-pvp-game') || document.getElementById('join-pvP-game');
if (actualJoinPvPGameButton && gameIdInput) {
actualJoinPvPGameButton.addEventListener('click', () => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите, чтобы присоединиться к игре.", true); return; }
const gameIdToJoin = gameIdInput.value.trim();
if (gameIdToJoin) {
disableSetupButtons();
socket.emit('joinGame', { gameId: gameIdToJoin });
setGameStatusMessage(`Присоединение к игре ${gameIdToJoin}...`);
} else { setGameStatusMessage("Пожалуйста, введите ID игры для присоединения.", true); }
});
}
if (findRandomPvPGameButton) {
findRandomPvPGameButton.addEventListener('click', () => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите, чтобы найти игру.", true); return; }
disableSetupButtons();
const selectedCharacter = getSelectedCharacterKey();
socket.emit('findRandomGame', { characterKey: selectedCharacter });
setGameStatusMessage(`Поиск случайной PvP игры (предпочтение: ${selectedCharacter === 'elena' ? 'Елена' : 'Альмагест'})...`);
});
}
function disableSetupButtons() {
if (createAIGameButton) createAIGameButton.disabled = true;
if (createPvPGameButton) createPvPGameButton.disabled = true;
if (actualJoinPvPGameButton) actualJoinPvPGameButton.disabled = true;
if (findRandomPvPGameButton) findRandomPvPGameButton.disabled = true;
if (availableGamesDiv) availableGamesDiv.querySelectorAll('button').forEach(btn => btn.disabled = true);
if(createAIGameButton) createAIGameButton.disabled = true;
if(createPvPGameButton) createPvPGameButton.disabled = true;
if(joinPvPGameButton) joinPvPGameButton.disabled = true;
if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = true;
if(availableGamesDiv) availableGamesDiv.querySelectorAll('button').forEach(btn => btn.disabled = true);
}
function enableSetupButtons() {
if (createAIGameButton) createAIGameButton.disabled = false;
if (createPvPGameButton) createPvPGameButton.disabled = false;
if (actualJoinPvPGameButton) actualJoinPvPGameButton.disabled = false;
if (findRandomPvPGameButton) findRandomPvPGameButton.disabled = false;
if(createAIGameButton) createAIGameButton.disabled = false;
if(createPvPGameButton) createPvPGameButton.disabled = false;
if(joinPvPGameButton) joinPvPGameButton.disabled = false;
if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = false;
// Кнопки в списке игр включаются в updateAvailableGamesList
}
if (attackButton) {
attackButton.addEventListener('click', () => {
// --- Инициализация обработчиков событий ---
if (registerForm) registerForm.addEventListener('submit', (e) => {
e.preventDefault();
const u = document.getElementById('register-username').value;
const p = document.getElementById('register-password').value;
registerForm.querySelector('button').disabled = true;
if(loginForm) loginForm.querySelector('button').disabled = true;
socket.emit('register', { username: u, password: p });
});
if (loginForm) loginForm.addEventListener('submit', (e) => {
e.preventDefault();
const u = document.getElementById('login-username').value;
const p = document.getElementById('login-password').value;
if(registerForm) registerForm.querySelector('button').disabled = true;
loginForm.querySelector('button').disabled = true;
socket.emit('login', { username: u, password: p });
});
if (logoutButton) logoutButton.addEventListener('click', () => {
logoutButton.disabled = true; socket.emit('logout');
isLoggedIn = false; loggedInUsername = ''; myUserId = null;
resetGameVariables(); isInGame = false; disableGameControls();
showAuthScreen(); setGameStatusMessage("Вы вышли из системы.");
logoutButton.disabled = false;
});
if (createAIGameButton) createAIGameButton.addEventListener('click', () => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; }
disableSetupButtons();
socket.emit('createGame', { mode: 'ai', characterKey: 'elena' }); // AI всегда за Елену
setGameStatusMessage("Создание игры против AI...");
});
if (createPvPGameButton) createPvPGameButton.addEventListener('click', () => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; }
disableSetupButtons();
socket.emit('createGame', { mode: 'pvp', characterKey: getSelectedCharacterKey() });
setGameStatusMessage("Создание PvP игры...");
});
if (joinPvPGameButton) joinPvPGameButton.addEventListener('click', () => { // Убедитесь, что ID кнопки 'join-pvp-game'
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; }
const gameId = gameIdInput.value.trim();
if (gameId) {
disableSetupButtons();
socket.emit('joinGame', { gameId: gameId });
setGameStatusMessage(`Присоединение к игре ${gameId}...`);
} else setGameStatusMessage("Введите ID игры.", true);
});
if (findRandomPvPGameButton) findRandomPvPGameButton.addEventListener('click', () => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; }
disableSetupButtons();
socket.emit('findRandomGame', { characterKey: getSelectedCharacterKey() });
setGameStatusMessage("Поиск случайной PvP игры...");
});
if (attackButton) attackButton.addEventListener('click', () => {
if (isLoggedIn && isInGame && currentGameId && currentGameState && !currentGameState.isGameOver) {
socket.emit('playerAction', { actionType: 'attack' });
} else {
console.warn('[Client] Попытка действия (атака) вне допустимого состояния игры. isLogged:', isLoggedIn, 'isInGame:', isInGame);
disableGameControls();
if (isLoggedIn && !isInGame) showGameSelectionScreen(loggedInUsername);
else if (!isLoggedIn) showAuthScreen();
}
} else { /* обработка ошибки/некорректного состояния */ }
});
}
function handleAbilityButtonClick(event) {
const button = event.currentTarget;
const abilityId = button.dataset.abilityId;
const abilityId = event.currentTarget.dataset.abilityId;
if (isLoggedIn && isInGame && currentGameId && abilityId && currentGameState && !currentGameState.isGameOver) {
socket.emit('playerAction', { actionType: 'ability', abilityId: abilityId });
} else {
console.warn('[Client] Попытка действия (способность) вне допустимого состояния игры. isLogged:', isLoggedIn, 'isInGame:', isInGame);
disableGameControls();
if (isLoggedIn && !isInGame) showGameSelectionScreen(loggedInUsername);
else if (!isLoggedIn) showAuthScreen();
} else { /* обработка ошибки/некорректного состояния */ }
}
}
if (returnToMenuButton) {
returnToMenuButton.addEventListener('click', () => {
if (returnToMenuButton) returnToMenuButton.addEventListener('click', () => {
if (!isLoggedIn) { showAuthScreen(); return; }
returnToMenuButton.disabled = true;
console.log('[Client] Return to menu button clicked. Resetting game state and showing selection screen.');
resetGameVariables(); isInGame = false; disableGameControls(); hideGameOverModal();
showGameSelectionScreen(loggedInUsername);
showGameSelectionScreen(loggedInUsername); // Возвращаемся на экран выбора
// Кнопка включится при следующем показе модалки
});
}
function initializeAbilityButtons() {
// ... (код без изменений, как был)
if (!abilitiesGrid || !window.gameUI || !window.GAME_CONFIG) {
if (abilitiesGrid) abilitiesGrid.innerHTML = '<p class="placeholder-text">Ошибка загрузки способностей.</p>';
console.error('[Client.js] initializeAbilityButtons failed: abilitiesGrid, gameUI, or GAME_CONFIG not found.');
return;
}
abilitiesGrid.innerHTML = '';
@ -363,78 +295,104 @@ document.addEventListener('DOMContentLoaded', () => {
const li = document.createElement('li');
li.textContent = `ID: ${game.id.substring(0, 8)}... - ${game.status || 'Ожидает игрока'}`;
const joinBtn = document.createElement('button');
joinBtn.textContent = 'Присоединиться'; joinBtn.dataset.gameId = game.id;
joinBtn.textContent = 'Присоединиться';
joinBtn.dataset.gameId = game.id;
// === ИЗМЕНЕНИЕ: Деактивация кнопки "Присоединиться" для своих игр ===
if (isLoggedIn && myUserId && game.ownerIdentifier === myUserId) {
joinBtn.disabled = true;
joinBtn.title = "Вы не можете присоединиться к своей же ожидающей игре.";
} else {
joinBtn.disabled = false;
}
// === КОНЕЦ ИЗМЕНЕНИЯ ===
joinBtn.addEventListener('click', (e) => {
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите, чтобы присоединиться к игре.", true); return; }
if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; }
if (e.target.disabled) return; // Не обрабатывать клик по отключенной кнопке
disableSetupButtons();
socket.emit('joinGame', { gameId: e.target.dataset.gameId });
});
li.appendChild(joinBtn); ul.appendChild(li);
li.appendChild(joinBtn);
ul.appendChild(li);
}
});
availableGamesDiv.appendChild(ul);
availableGamesDiv.querySelectorAll('button').forEach(btn => btn.disabled = false);
} else { availableGamesDiv.innerHTML += '<p>Нет доступных игр. Создайте свою!</p>'; }
enableSetupButtons();
} else {
availableGamesDiv.innerHTML += '<p>Нет доступных игр. Создайте свою!</p>';
}
enableSetupButtons(); // Включаем основные кнопки создания/поиска
}
// --- Обработчики событий Socket.IO ---
socket.on('connect', () => {
console.log('[Client] Socket connected to server! Socket ID:', socket.id);
if (isLoggedIn) {
console.log(`[Client] Reconnected as ${loggedInUsername}. Requesting state.`);
socket.emit('requestGameState');
} else { showAuthScreen(); }
console.log('[Client] Socket connected:', socket.id);
if (isLoggedIn && myUserId) { // Проверяем и isLoggedIn и myUserId
socket.emit('requestGameState'); // Запрашиваем состояние, если были залогинены
} else {
showAuthScreen(); // Иначе показываем экран логина
}
});
socket.on('registerResponse', (data) => {
setAuthMessage(data.message, !data.success);
if (data.success && registerForm) registerForm.reset();
if (registerForm) registerForm.querySelector('button').disabled = false;
if (loginForm) loginForm.querySelector('button').disabled = false;
if(registerForm) registerForm.querySelector('button').disabled = false;
if(loginForm) loginForm.querySelector('button').disabled = false;
});
socket.on('loginResponse', (data) => {
setAuthMessage(data.message, !data.success);
if (data.success) {
isLoggedIn = true; loggedInUsername = data.username; setAuthMessage("");
isLoggedIn = true;
loggedInUsername = data.username;
myUserId = data.userId; // === ИЗМЕНЕНИЕ: Сохраняем ID пользователя ===
setAuthMessage("");
showGameSelectionScreen(data.username);
} else {
isLoggedIn = false; loggedInUsername = '';
if (registerForm) registerForm.querySelector('button').disabled = false;
if (loginForm) loginForm.querySelector('button').disabled = false;
isLoggedIn = false; loggedInUsername = ''; myUserId = null;
if(registerForm) registerForm.querySelector('button').disabled = false;
if(loginForm) loginForm.querySelector('button').disabled = false;
}
});
socket.on('gameNotFound', (data) => {
console.log('[Client] Game not found response:', data?.message);
console.log('[Client] Game not found/ended:', data?.message);
resetGameVariables(); isInGame = false; disableGameControls(); hideGameOverModal();
if (turnTimerContainer) turnTimerContainer.style.display = 'none'; // Скрываем таймер
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
if (isLoggedIn) {
showGameSelectionScreen(loggedInUsername);
setGameStatusMessage("Выберите режим игры или присоединитесь к существующей.");
enableSetupButtons();
setGameStatusMessage(data?.message || "Активная игровая сессия не найдена.");
} else {
showAuthScreen();
setAuthMessage(data?.message || "Пожалуйста, войдите, чтобы начать новую игру.", false);
setAuthMessage(data?.message || "Пожалуйста, войдите.");
}
});
socket.on('disconnect', (reason) => {
console.log('[Client] Disconnected from server:', reason);
setGameStatusMessage(`Отключено от сервера: ${reason}. Пожалуйста, обновите страницу.`, true);
console.log('[Client] Disconnected:', reason);
setGameStatusMessage(`Отключено: ${reason}. Обновите страницу.`, true);
disableGameControls();
// === ИЗМЕНЕНИЕ: При дисконнекте останавливаем таймер (если он виден) ===
if (turnTimerSpan) turnTimerSpan.textContent = 'Отключено';
// Не скрываем контейнер, чтобы было видно сообщение "Отключено"
// === КОНЕЦ ИЗМЕНЕНИЯ ===
if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.';
// Не сбрасываем isLoggedIn, чтобы при переподключении можно было восстановить сессию
});
socket.on('gameStarted', (data) => {
if (!isLoggedIn) { console.warn('[Client] Ignoring gameStarted: Not logged in.'); return; }
console.log('[Client] Event "gameStarted" received:', data);
socket.on('gameCreated', (data) => { // Сервер присылает это после успешного createGame
console.log('[Client] Game created by this client:', data);
currentGameId = data.gameId;
myPlayerId = data.yourPlayerId; // Сервер должен прислать роль создателя
// Остальные данные (gameState, baseStats) придут с gameStarted или gameState (если это PvP ожидание)
// Если это PvP и игра ожидает, сервер может прислать waitingForOpponent
});
socket.on('gameStarted', (data) => {
if (!isLoggedIn) return;
console.log('[Client] Game started:', data);
// ... (остальной код gameStarted без изменений, как был)
if (window.gameUI?.uiElements?.opponent?.panel) {
const opponentPanel = window.gameUI.uiElements.opponent.panel;
if (opponentPanel.classList.contains('dissolving')) {
@ -450,7 +408,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (data.clientConfig) window.GAME_CONFIG = { ...data.clientConfig };
else if (!window.GAME_CONFIG) {
window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' };
console.warn('[Client.js gameStarted] No clientConfig received from server. Using fallback.');
}
window.gameState = currentGameState;
window.gameData = { playerBaseStats: playerBaseStatsServer, opponentBaseStats: opponentBaseStatsServer, playerAbilities: playerAbilitiesServer, opponentAbilities: opponentAbilitiesServer };
@ -463,108 +420,131 @@ document.addEventListener('DOMContentLoaded', () => {
}
requestAnimationFrame(() => {
if (window.gameUI && typeof window.gameUI.updateUI === 'function') {
console.log('[Client] Calling gameUI.updateUI() after gameStarted.');
window.gameUI.updateUI();
}
});
hideGameOverModal(); setGameStatusMessage("");
});
socket.on('gameStateUpdate', (data) => {
if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) {
console.warn('[Client] Ignoring gameStateUpdate: Not logged in or not in game context.');
return;
// Используется для восстановления состояния уже идущей игры
socket.on('gameState', (data) => {
if (!isLoggedIn) return;
console.log('[Client] Received full gameState (e.g. on reconnect):', data);
// Это событие теперь может дублировать 'gameStarted' для переподключения.
// Убедимся, что логика похожа на gameStarted.
currentGameId = data.gameId;
myPlayerId = data.yourPlayerId;
currentGameState = data.gameState; // Используем gameState вместо initialGameState
playerBaseStatsServer = data.playerBaseStats;
opponentBaseStatsServer = data.opponentBaseStats;
playerAbilitiesServer = data.playerAbilities;
opponentAbilitiesServer = data.opponentAbilities;
myCharacterKey = playerBaseStatsServer?.characterKey;
opponentCharacterKey = opponentBaseStatsServer?.characterKey;
if (data.clientConfig) window.GAME_CONFIG = { ...data.clientConfig };
else if (!window.GAME_CONFIG) {
window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' };
}
window.gameState = currentGameState;
window.gameData = { playerBaseStats: playerBaseStatsServer, opponentBaseStats: opponentBaseStatsServer, playerAbilities: playerAbilitiesServer, opponentAbilities: opponentAbilitiesServer };
window.myPlayerId = myPlayerId;
if (!isInGame) showGameScreen(); // Показываем экран игры, если еще не там
initializeAbilityButtons(); // Переинициализируем кнопки
// Лог при 'gameState' может быть уже накопленным, добавляем его
if (window.gameUI?.uiElements?.log?.list && data.log) { // Очищаем лог перед добавлением нового при полном обновлении
window.gameUI.uiElements.log.list.innerHTML = '';
}
currentGameState = data.gameState; window.gameState = currentGameState;
if (window.gameUI && typeof window.gameUI.updateUI === 'function') window.gameUI.updateUI();
if (window.gameUI && typeof window.gameUI.addToLog === 'function' && data.log) {
data.log.forEach(logEntry => window.gameUI.addToLog(logEntry.message, logEntry.type));
}
requestAnimationFrame(() => {
if (window.gameUI && typeof window.gameUI.updateUI === 'function') {
window.gameUI.updateUI();
}
});
hideGameOverModal();
// Таймер будет обновлен следующим событием 'turnTimerUpdate'
});
socket.on('gameStateUpdate', (data) => {
if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) return;
currentGameState = data.gameState; window.gameState = currentGameState;
if (window.gameUI?.updateUI) window.gameUI.updateUI();
if (window.gameUI?.addToLog && data.log) {
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
}
});
socket.on('logUpdate', (data) => {
if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) {
console.warn('[Client] Ignoring logUpdate: Not logged in or not in game context.');
return;
}
if (window.gameUI && typeof window.gameUI.addToLog === 'function' && data.log) {
data.log.forEach(logEntry => window.gameUI.addToLog(logEntry.message, logEntry.type));
if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) return;
if (window.gameUI?.addToLog && data.log) {
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
}
});
socket.on('gameOver', (data) => {
// ... (код без изменений, как был)
if (!isLoggedIn || !currentGameId || !window.GAME_CONFIG) {
console.warn('[Client] Ignoring gameOver: Not logged in or currentGameId is null/stale.');
if (!currentGameId && isLoggedIn) socket.emit('requestGameState');
else if (!isLoggedIn) showAuthScreen();
return;
}
console.log(`[Client gameOver] Received for game ${currentGameId}. My technical slot ID (myPlayerId): ${myPlayerId}, Winner's slot ID from server (data.winnerId): ${data.winnerId}`);
const playerWon = data.winnerId === myPlayerId;
console.log(`[Client gameOver] Calculated playerWon for this client: ${playerWon}`);
currentGameState = data.finalGameState; window.gameState = currentGameState;
console.log('[Client gameOver] Final GameState:', currentGameState);
if (window.gameData) console.log(`[Client gameOver] For ui.js, myName: ${window.gameData.playerBaseStats?.name}, opponentName: ${window.gameData.opponentBaseStats?.name}`);
if (window.gameUI && typeof window.gameUI.updateUI === 'function') window.gameUI.updateUI();
if (window.gameUI && typeof window.gameUI.addToLog === 'function' && data.log) {
data.log.forEach(logEntry => window.gameUI.addToLog(logEntry.message, logEntry.type));
if (window.gameUI?.updateUI) window.gameUI.updateUI();
if (window.gameUI?.addToLog && data.log) {
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
}
if (window.gameUI && typeof window.gameUI.showGameOver === 'function') {
const opponentKeyForModal = window.gameData?.opponentBaseStats?.characterKey;
window.gameUI.showGameOver(playerWon, data.reason, opponentKeyForModal, data);
if (window.gameUI?.showGameOver) {
const oppKey = window.gameData?.opponentBaseStats?.characterKey;
window.gameUI.showGameOver(playerWon, data.reason, oppKey, data);
}
if (returnToMenuButton) returnToMenuButton.disabled = false;
setGameStatusMessage("Игра окончена. " + (playerWon ? "Вы победили!" : "Вы проиграли."));
// === ИЗМЕНЕНИЕ: При gameOver скрываем таймер или показываем "Игра окончена" ===
if (turnTimerContainer) turnTimerContainer.style.display = 'block'; // Оставляем видимым
if (turnTimerSpan) turnTimerSpan.textContent = 'Конец';
// === КОНЕЦ ИЗМЕНЕНИЯ ===
if (window.gameUI?.updateTurnTimerDisplay) { // Обновляем UI таймера
window.gameUI.updateTurnTimerDisplay(null, false, currentGameState?.gameMode); // Передаем null, чтобы показать "Конец" или скрыть
}
});
socket.on('waitingForOpponent', () => {
if (!isLoggedIn) return;
setGameStatusMessage("Ожидание присоединения оппонента...");
disableGameControls();
enableSetupButtons(); // Можно оставить возможность отменить, если долго ждет
// === ИЗМЕНЕНИЕ: При ожидании оппонента таймер неактивен ===
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
// === КОНЕЦ ИЗМЕНЕНИЯ ===
disableGameControls(); // Боевые кнопки неактивны
disableSetupButtons(); // Кнопки создания/присоединения тоже, пока ждем
if (createPvPGameButton) createPvPGameButton.disabled = false; // Оставляем активной "Создать PvP" для отмены
if (window.gameUI?.updateTurnTimerDisplay) {
window.gameUI.updateTurnTimerDisplay(null, false, 'pvp'); // Таймер неактивен
}
});
socket.on('opponentDisconnected', (data) => {
if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) {
console.warn('[Client] Ignoring opponentDisconnected: Not logged in or not in game context.');
return;
}
const systemLogType = (window.GAME_CONFIG?.LOG_TYPE_SYSTEM) || 'system';
const disconnectedCharacterName = data.disconnectedCharacterName || 'Противник';
if (window.gameUI && typeof window.gameUI.addToLog === 'function') {
window.gameUI.addToLog(`🔌 Противник (${disconnectedCharacterName}) отключился.`, systemLogType);
}
if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) return;
const name = data.disconnectedCharacterName || 'Противник';
if (window.gameUI?.addToLog) window.gameUI.addToLog(`🔌 Противник (${name}) отключился.`, 'system');
if (currentGameState && !currentGameState.isGameOver) {
setGameStatusMessage(`Противник (${disconnectedCharacterName}) отключился. Ожидание завершения игры сервером...`, true);
setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true);
disableGameControls();
}
});
socket.on('gameError', (data) => {
console.error('[Client] Server error:', data.message);
const systemLogType = (window.GAME_CONFIG?.LOG_TYPE_SYSTEM) || 'system';
if (isLoggedIn && isInGame && currentGameId && currentGameState && !currentGameState.isGameOver && window.gameUI && typeof window.gameUI.addToLog === 'function') {
window.gameUI.addToLog(`❌ Ошибка игры: ${data.message}`, systemLogType);
disableGameControls();
setGameStatusMessage(`Ошибка в игре: ${data.message}.`, true);
if (isLoggedIn && isInGame && currentGameState && !currentGameState.isGameOver && window.gameUI?.addToLog) {
window.gameUI.addToLog(`❌ Ошибка игры: ${data.message}`, 'system');
disableGameControls(); setGameStatusMessage(`Ошибка: ${data.message}.`, true);
} else {
setGameStatusMessage(`❌ Ошибка игры: ${data.message}`, true);
resetGameVariables(); isInGame = false; disableGameControls();
if (isLoggedIn && loggedInUsername) showGameSelectionScreen(loggedInUsername);
else showAuthScreen();
setGameStatusMessage(`❌ Ошибка: ${data.message}`, true);
if (isLoggedIn) enableSetupButtons(); // Если на экране выбора игры, включаем кнопки
else { // Если на экране логина
if(registerForm) registerForm.querySelector('button').disabled = false;
if(loginForm) loginForm.querySelector('button').disabled = false;
}
}
if (!isLoggedIn) {
if (registerForm) registerForm.querySelector('button').disabled = false;
if (loginForm) loginForm.querySelector('button').disabled = false;
} else if (!isInGame) { enableSetupButtons(); }
});
socket.on('availablePvPGamesList', (games) => {
@ -572,58 +552,32 @@ document.addEventListener('DOMContentLoaded', () => {
updateAvailableGamesList(games);
});
socket.on('noPendingGamesFound', (data) => {
socket.on('noPendingGamesFound', (data) => { // Вызывается, когда создается новая игра после поиска
if (!isLoggedIn) return;
setGameStatusMessage(data.message || "Свободных игр не найдено. Создана новая для вас, ожидайте оппонента.");
updateAvailableGamesList([]);
isInGame = false; disableGameControls(); disableSetupButtons();
// === ИЗМЕНЕНИЕ: При ожидании оппонента (создана новая игра) таймер неактивен ===
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
if (turnTimerSpan) turnTimerSpan.textContent = '--';
// === КОНЕЦ ИЗМЕНЕНИЯ ===
setGameStatusMessage(data.message || "Свободных игр не найдено. Создана новая для вас.");
updateAvailableGamesList([]); // Очищаем список
// currentGameId и myPlayerId должны были прийти с gameCreated
isInGame = false; // Еще не в активной фазе боя
disableGameControls();
disableSetupButtons(); // Мы в ожидающей игре
if (window.gameUI?.updateTurnTimerDisplay) {
window.gameUI.updateTurnTimerDisplay(null, false, 'pvp');
}
});
// === ИЗМЕНЕНИЕ: Обработчик события обновления таймера ===
socket.on('turnTimerUpdate', (data) => {
if (!isInGame || !currentGameState || currentGameState.isGameOver) {
// Если игра не активна, или уже завершена, или нет состояния, игнорируем обновление таймера
if (turnTimerContainer && !currentGameState?.isGameOver) turnTimerContainer.style.display = 'none'; // Скрываем, если не game over
if (turnTimerSpan && !currentGameState?.isGameOver) turnTimerSpan.textContent = '--';
if (window.gameUI?.updateTurnTimerDisplay && !currentGameState?.isGameOver) { // Только если не game over
window.gameUI.updateTurnTimerDisplay(null, false, currentGameState?.gameMode);
}
return;
}
if (turnTimerSpan && turnTimerContainer) {
if (data.remainingTime === null || data.remainingTime === undefined) {
// Сервер сигнализирует, что таймер неактивен (например, ход AI)
turnTimerContainer.style.display = 'block'; // Контейнер может быть видимым
// Определяем, чей ход, чтобы показать соответствующее сообщение
if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') {
// Определяем, является ли текущий ход ходом этого клиента
const isMyActualTurn = myPlayerId && currentGameState.isPlayerTurn === (myPlayerId === GAME_CONFIG.PLAYER_ID);
if (!data.isPlayerTurn && currentGameState.gameMode === 'ai') { // Ход AI
turnTimerSpan.textContent = 'Ход ИИ';
turnTimerSpan.classList.remove('low-time');
} else if (!isMyActualTurn && currentGameState.gameMode === 'pvp' && !data.isPlayerTurn !== (myPlayerId === GAME_CONFIG.PLAYER_ID)) { // Ход оппонента в PvP
turnTimerSpan.textContent = 'Ход оппонента';
turnTimerSpan.classList.remove('low-time');
} else { // Ход текущего игрока, но сервер прислал null - странно, но покажем '--'
turnTimerSpan.textContent = '--';
turnTimerSpan.classList.remove('low-time');
}
} else {
turnTimerContainer.style.display = 'block'; // Убедимся, что контейнер виден
const seconds = Math.ceil(data.remainingTime / 1000);
turnTimerSpan.textContent = `0:${seconds < 10 ? '0' : ''}${seconds}`;
// Добавляем/удаляем класс для предупреждения, если времени мало
if (seconds <= 10) { // Например, 10 секунд - порог
turnTimerSpan.classList.add('low-time');
} else {
turnTimerSpan.classList.remove('low-time');
}
}
window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyActualTurn, currentGameState.gameMode);
}
});
// === КОНЕЦ ИЗМЕНЕНИЯ ===
showAuthScreen();
showAuthScreen(); // Начальный экран
});

View File

@ -32,10 +32,8 @@
buttonAttack: document.getElementById('button-attack'),
buttonBlock: document.getElementById('button-block'),
abilitiesGrid: document.getElementById('abilities-grid'),
// === ИЗМЕНЕНИЕ: Добавлены элементы таймера ===
turnTimerContainer: document.getElementById('turn-timer-container'),
turnTimerSpan: document.getElementById('turn-timer')
// === КОНЕЦ ИЗМЕНЕНИЯ ===
},
log: {
list: document.getElementById('log-list'),
@ -51,6 +49,15 @@
opponentResourceTypeIcon: document.getElementById('opponent-resource-bar')?.closest('.stat-bar-container')?.querySelector('.bar-icon i'),
playerResourceBarContainer: document.getElementById('player-resource-bar')?.closest('.stat-bar-container'),
opponentResourceBarContainer: document.getElementById('opponent-resource-bar')?.closest('.stat-bar-container'),
// === НОВЫЕ ЭЛЕМЕНТЫ для переключателя панелей ===
panelSwitcher: {
controlsContainer: document.querySelector('.panel-switcher-controls'),
showPlayerBtn: document.getElementById('show-player-panel-btn'),
showOpponentBtn: document.getElementById('show-opponent-panel-btn')
},
battleArenaContainer: document.querySelector('.battle-arena-container')
// === КОНЕЦ НОВЫХ ЭЛЕМЕНТОВ ===
};
function addToLog(message, type = 'info') {
@ -91,9 +98,9 @@
if (elements.name) {
let iconClass = 'fa-question';
const characterKey = fighterBaseStats.characterKey;
if (characterKey === 'elena') { iconClass = 'fa-hat-wizard icon-elena'; } // Используем специфичный класс для цвета
if (characterKey === 'elena') { iconClass = 'fa-hat-wizard icon-elena'; }
else if (characterKey === 'almagest') { iconClass = 'fa-staff-aesculapius icon-almagest'; }
else if (characterKey === 'balard') { iconClass = 'fa-khanda icon-balard'; } // Для Баларда тоже специфичный
else if (characterKey === 'balard') { iconClass = 'fa-khanda icon-balard'; }
let nameHtml = `<i class="fas ${iconClass}"></i> ${fighterBaseStats.name || 'Неизвестно'}`;
if (isControlledByThisClient) nameHtml += " (Вы)";
elements.name.innerHTML = nameHtml;
@ -140,7 +147,7 @@
else if (fighterBaseStats.characterKey === 'balard') { elements.panel.classList.add('panel-balard'); borderColorVar = 'var(--accent-opponent)'; }
let glowColorVar = 'rgba(0, 0, 0, 0.4)';
if (fighterBaseStats.characterKey === 'elena') glowColorVar = 'var(--panel-glow-player)';
else if (fighterBaseStats.characterKey === 'almagest') glowColorVar = 'var(--panel-glow-almagest)'; // Отдельный цвет для Альмагест
else if (fighterBaseStats.characterKey === 'almagest') glowColorVar = 'var(--panel-glow-almagest)';
else if (fighterBaseStats.characterKey === 'balard') glowColorVar = 'var(--panel-glow-opponent)';
elements.panel.style.borderColor = borderColorVar;
elements.panel.style.boxShadow = `0 0 15px ${glowColorVar}, inset 0 0 10px rgba(0, 0, 0, 0.3)`;
@ -207,22 +214,14 @@
}
}
// === ИЗМЕНЕНИЕ: Новая функция для обновления таймера ===
/**
* Обновляет отображение таймера хода.
* @param {number|null} remainingTimeMs - Оставшееся время в миллисекундах, или null если таймер неактивен.
* @param {boolean} isCurrentPlayerActualTurn - Флаг, является ли текущий ход ходом этого клиента.
* @param {string} gameMode - Режим игры ('ai' или 'pvp').
*/
function updateTurnTimerDisplay(remainingTimeMs, isCurrentPlayerActualTurn, gameMode) {
const timerSpan = uiElements.controls.turnTimerSpan;
const timerContainer = uiElements.controls.turnTimerContainer;
const config = window.GAME_CONFIG || {};
if (!timerSpan || !timerContainer) return;
if (window.gameState && window.gameState.isGameOver) {
timerContainer.style.display = 'block'; // Может быть 'flex' или другой, в зависимости от CSS
timerContainer.style.display = 'block';
timerSpan.textContent = 'Конец';
timerSpan.classList.remove('low-time');
return;
@ -231,11 +230,11 @@
if (remainingTimeMs === null || remainingTimeMs === undefined) {
timerContainer.style.display = 'block';
timerSpan.classList.remove('low-time');
if (gameMode === 'ai' && !isCurrentPlayerActualTurn) { // Предполагаем, что если не ход игрока в AI, то ход AI
if (gameMode === 'ai' && !isCurrentPlayerActualTurn) {
timerSpan.textContent = 'Ход ИИ';
} else if (gameMode === 'pvp' && !isCurrentPlayerActualTurn) {
timerSpan.textContent = 'Ход оппонента';
} else { // Ход текущего игрока, но нет времени (например, ожидание первого хода)
} else {
timerSpan.textContent = '--';
}
} else {
@ -243,14 +242,13 @@
const seconds = Math.ceil(remainingTimeMs / 1000);
timerSpan.textContent = `0:${seconds < 10 ? '0' : ''}${seconds}`;
if (seconds <= 10 && isCurrentPlayerActualTurn) { // Предупреждение только если это мой ход
if (seconds <= 10 && isCurrentPlayerActualTurn) {
timerSpan.classList.add('low-time');
} else {
timerSpan.classList.remove('low-time');
}
}
}
// === КОНЕЦ ИЗМЕНЕНИЯ ===
function updateUI() {
@ -267,13 +265,11 @@
if(uiElements.controls.buttonAttack) uiElements.controls.buttonAttack.disabled = true;
if(uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true;
if(uiElements.controls.abilitiesGrid) uiElements.controls.abilitiesGrid.innerHTML = '<p class="placeholder-text">Загрузка способностей...</p>';
// === ИЗМЕНЕНИЕ: Сбрасываем таймер, если нет данных ===
if (uiElements.controls.turnTimerContainer) uiElements.controls.turnTimerContainer.style.display = 'none';
if (uiElements.controls.turnTimerSpan) {
uiElements.controls.turnTimerSpan.textContent = '--';
uiElements.controls.turnTimerSpan.classList.remove('low-time');
}
// === КОНЕЦ ИЗМЕНЕНИЯ ===
return;
}
if (!uiElements.player.panel || !uiElements.opponent.panel || !uiElements.controls.turnIndicator || !uiElements.controls.abilitiesGrid || !uiElements.log.list) {
@ -423,8 +419,7 @@
const currentActualGameState = window.gameState;
const gameOverScreenElement = uiElements.gameOver.screen;
console.log(`[UI.JS DEBUG] showGameOver CALLED. PlayerWon: ${playerWon}, Reason: ${reason}`);
if (!gameOverScreenElement) { console.warn("[UI.JS DEBUG] showGameOver: gameOverScreenElement not found."); return; }
if (!gameOverScreenElement) { return; }
const resultMsgElement = uiElements.gameOver.message;
const myNameForResult = clientSpecificGameData?.playerBaseStats?.name || "Игрок";
@ -433,19 +428,16 @@
if (resultMsgElement) {
let winText = `Победа! ${myNameForResult} празднует!`;
let loseText = `Поражение! ${opponentNameForResult} оказался(лась) сильнее!`;
// === ИЗМЕНЕНИЕ: Добавляем обработку причины 'turn_timeout' ===
if (reason === 'opponent_disconnected') {
let disconnectedName = data?.disconnectedCharacterName || opponentNameForResult;
winText = `${disconnectedName} покинул(а) игру. Победа присуждается вам!`;
} else if (reason === 'turn_timeout') {
// Если текущий игрок (чей ход был) проиграл по таймауту
if (!playerWon) { // playerWon здесь будет false, если победил оппонент (т.е. мой таймаут)
if (!playerWon) {
loseText = `Время на ход истекло! Поражение. ${opponentNameForResult} побеждает!`;
} else { // Если я победил, потому что у оппонента истекло время
} else {
winText = `Время на ход у ${opponentNameForResult} истекло! Победа!`;
}
}
// === КОНЕЦ ИЗМЕНЕНИЯ ===
resultMsgElement.textContent = playerWon ? winText : loseText;
resultMsgElement.style.color = playerWon ? 'var(--heal-color)' : 'var(--damage-color)';
}
@ -468,7 +460,7 @@
opponentPanelElement.style.transition = '';
}
setTimeout((finalStateInTimeout, wonInTimeout, reasonInTimeout, keyInTimeout, dataInTimeout) => {
setTimeout((finalStateInTimeout) => {
if (gameOverScreenElement && finalStateInTimeout && finalStateInTimeout.isGameOver === true) {
if (gameOverScreenElement.classList.contains(config.CSS_CLASS_HIDDEN || 'hidden')) {
gameOverScreenElement.classList.remove(config.CSS_CLASS_HIDDEN || 'hidden');
@ -496,16 +488,47 @@
gameOverScreenElement.offsetHeight;
}
}
}, config.DELAY_BEFORE_VICTORY_MODAL || 1500, currentActualGameState, playerWon, reason, opponentCharacterKeyFromClient, data);
}, config.DELAY_BEFORE_VICTORY_MODAL || 1500, currentActualGameState);
}
// === НОВАЯ ФУНКЦИЯ для настройки переключателя панелей ===
function setupPanelSwitcher() {
const { showPlayerBtn, showOpponentBtn } = uiElements.panelSwitcher;
const battleArena = uiElements.battleArenaContainer;
if (showPlayerBtn && showOpponentBtn && battleArena) {
showPlayerBtn.addEventListener('click', () => {
battleArena.classList.remove('show-opponent-panel');
showPlayerBtn.classList.add('active');
showOpponentBtn.classList.remove('active');
});
showOpponentBtn.addEventListener('click', () => {
battleArena.classList.add('show-opponent-panel');
showOpponentBtn.classList.add('active');
showPlayerBtn.classList.remove('active');
});
// По умолчанию при загрузке (если кнопки видимы) панель игрока активна
// CSS уже должен это обеспечивать, но для надежности можно убедиться
if (window.getComputedStyle(uiElements.panelSwitcher.controlsContainer).display !== 'none') {
battleArena.classList.remove('show-opponent-panel');
showPlayerBtn.classList.add('active');
showOpponentBtn.classList.remove('active');
}
}
}
// === КОНЕЦ НОВОЙ ФУНКЦИИ ===
window.gameUI = {
uiElements,
addToLog,
updateUI,
showGameOver,
// === ИЗМЕНЕНИЕ: Экспортируем функцию обновления таймера ===
updateTurnTimerDisplay
// === КОНЕЦ ИЗМЕНЕНИЯ ===
};
// Настраиваем переключатель панелей при загрузке скрипта
setupPanelSwitcher();
})();

File diff suppressed because it is too large Load Diff

133
server/auth/authService.js Normal file
View 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
View 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); // Аварийное завершение процесса
});

View File

@ -1,4 +1,4 @@
// /server_modules/config.js
// /server/core/config.js
const GAME_CONFIG = {
// --- Баланс Игры ---
@ -103,15 +103,9 @@ const GAME_CONFIG = {
ABILITY_ID_ALMAGEST_DEBUFF: 'curseOfDecay', // Проклятие Увядания (Аналог sealOfWeakness)
};
// Для использования в Node.js модулях (например, server_modules/gameInstance.js)
// Для использования в Node.js модулях
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = GAME_CONFIG;
}
// Для использования в браузере (если этот файл подключается напрямую через <script>)
// В нашем случае, для клиента, мы будем передавать нужные конфиги от сервера,
// либо создадим урезанный config_client.js.
// else {
// window.GAME_CONFIG = GAME_CONFIG;
// }
// console.log("config.js loaded and GAME_CONFIG object created/exported.");
// console.log("config.js loaded from server/core/ and GAME_CONFIG object created/exported.");

View File

@ -1,12 +1,12 @@
// /server_modules/db.js
// /server/core/db.js
const mysql = require('mysql2'); // Используем mysql2 для поддержки промисов и улучшенной производительности
// Конфигурация подключения к вашей базе данных MySQL
// ЗАМЕНИТЕ значения на ваши реальные данные!
const dbConfig = {
const dbConfig = { //Данные для сервера user phpmyadmin password Innamorato8Art
host: 'localhost', // или IP-адрес вашего MySQL сервера
user: 'phpmyadmin', // Имя пользователя MySQL (например, 'root' для локальной разработки)
password: 'Innamorato8Art', // Пароль пользователя MySQL (убедитесь, что он правильный)
password: 'Innamorato8Art', // Пароль пользователя MySQL
database: 'your_game_db', // Имя вашей базы данных (например, 'your_game_db')
port: 3306, // Стандартный порт MySQL, измените если у вас другой
waitForConnections: true, // Ожидать доступного соединения, если все заняты
@ -19,16 +19,14 @@ const dbConfig = {
let pool;
try {
pool = mysql.createPool(dbConfig);
console.log('[DB] Пул соединений MySQL успешно создан.');
} catch (error) {
console.error('[DB FATAL] Не удалось создать пул соединений MySQL. Проверьте конфигурацию `dbConfig`. Ошибка:', error);
// Если пул не создался, дальнейшая работа с БД невозможна.
// Можно завершить приложение или попытаться пересоздать пул позже.
// Для простоты, мы просто выведем ошибку. Приложение, скорее всего, упадет при попытке использовать `pool`.
// В реальном приложении здесь нужна более надежная обработка.
process.exit(1); // Завершаем приложение, так как без БД оно не сможет работать
// Завершаем приложение, так как без БД оно не сможет работать корректно.
process.exit(1);
}
// Обертка для выполнения запросов с использованием промисов из пула
// Мы экспортируем именно эту обертку.
const promisePool = pool.promise();
@ -45,48 +43,48 @@ if (promisePool) { // Проверяем, что promisePool был успешн
})
.catch(err => {
console.error('[DB] Ошибка при попытке получить соединение из пула или при подключении к MySQL:', err.message);
console.error('[DB] Полные детали ошибки:', err); // Выводим полный объект ошибки для диагностики
// Выводим полный объект ошибки для диагностики, если это не просто ошибка конфигурации
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 запущен и доступен по указанному хосту и порту.');
console.error(`[DB] Соединение с БД было отклонено. Убедитесь, что сервер MySQL запущен и доступен по адресу ${dbConfig.host}:${dbConfig.port}.`);
} else if (err.code === 'ER_ACCESS_DENIED_ERROR') {
console.error(`[DB] Доступ к БД запрещен для пользователя '${dbConfig.user}'. Проверьте имя пользователя и пароль в db.js.`);
console.error(`[DB] Доступ к БД запрещен для пользователя '${dbConfig.user}'. Проверьте имя пользователя и пароль в server/core/db.js.`);
} else if (err.code === 'ER_BAD_DB_ERROR') {
console.error(`[DB] База данных "${dbConfig.database}" не найдена. Убедитесь, что она создана.`);
console.error(`[DB] База данных "${dbConfig.database}" не найдена. Убедитесь, что она создана на сервере MySQL.`);
} else {
console.error(`[DB] Неизвестная ошибка подключения к MySQL. Код: ${err.code}`);
}
// В продакшене здесь может быть логика переподключения или более изящного завершения работы.
// Для отладки сейчас важно видеть эти ошибки.
// process.exit(1); // Можно раскомментировать, если хотите жестко падать при ошибке подключения
// Для разработки важно видеть эти ошибки.
// Можно раскомментировать process.exit(1), если хотите, чтобы приложение падало при ошибке подключения к БД.
// process.exit(1);
});
} else {
// Эта ветка не должна выполниться, если pool.promise() не выбросил ошибку выше.
// Но на всякий случай оставляем лог.
console.error('[DB FATAL] promisePool не был создан. Проверьте создание `pool`.');
process.exit(1); // Завершаем, так как это критическая ошибка
}
// Экспортируем пул с промисами, чтобы его можно было использовать в других модулях (например, в auth.js)
// Экспортируем пул с промисами, чтобы его можно было использовать в других модулях (например, в authService.js)
module.exports = promisePool;
/*
Пример использования в другом файле (например, auth.js):
Пример SQL для создания таблицы пользователей (если ее еще нет):
const db = require('./db'); // db здесь будет promisePool
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;
async function getUser(username) {
if (!db) { // Дополнительная проверка на случай, если db не экспортировался
console.error("DB модуль не загружен или не экспортировал promisePool!");
throw new Error("DB connection is not available.");
}
try {
const [rows, fields] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
return rows[0]; // Возвращаем первого найденного пользователя или undefined
} catch (error) {
console.error("Ошибка при получении пользователя:", error);
throw error; // или обработать ошибку иначе
}
}
*/

93
server/core/logger.js Normal file
View 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);
*/

View 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
};

View 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
View 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
View 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
View 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
View 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;

View 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;

View File

View 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;

View 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
};

View 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 // Экспортируем новую функцию
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View File

View File

@ -1,127 +0,0 @@
// /server_modules/auth.js
const bcrypt = require('bcryptjs'); // Для хеширования паролей
const db = require('./db'); // Ваш модуль для работы с базой данных
const SALT_ROUNDS = 10; // Количество раундов для генерации соли bcrypt
/**
* Регистрирует нового пользователя.
* @param {string} username - Имя пользователя.
* @param {string} password - Пароль пользователя.
* @returns {Promise<object>} Объект с результатом: { success: boolean, message: string, userId?: number }
*/
async function registerUser(username, password) {
console.log(`[Auth DEBUG] registerUser called with username: "${username}"`); // 1. Начало функции
if (!username || !password) {
console.warn('[Auth DEBUG] Validation failed: Username or password empty.');
return { success: false, message: 'Имя пользователя и пароль не могут быть пустыми.' };
}
if (password.length < 6) {
console.warn(`[Auth DEBUG] Validation failed for "${username}": Password too short.`);
return { success: false, message: 'Пароль должен содержать не менее 6 символов.' };
}
try {
// Этап A: Проверка существующего пользователя
console.log(`[Auth DEBUG] Stage A: Checking if user "${username}" exists...`);
const [existingUsers] = await db.query('SELECT id FROM users WHERE username = ?', [username]);
console.log(`[Auth DEBUG] Stage A: existingUsers query result length: ${existingUsers.length}`);
if (existingUsers.length > 0) {
console.warn(`[Auth DEBUG] Registration declined for "${username}": Username already taken.`);
return { success: false, message: 'Это имя пользователя уже занято.' };
}
console.log(`[Auth DEBUG] Stage A: Username "${username}" is available.`);
// Этап B: Хеширование пароля
console.log(`[Auth DEBUG] Stage B: Hashing password for user "${username}"...`);
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
console.log(`[Auth DEBUG] Stage B: Password for "${username}" hashed successfully.`);
// Этап C: Сохранение пользователя в БД
console.log(`[Auth DEBUG] Stage C: Attempting to insert user "${username}" into DB...`);
const [result] = await db.query(
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
[username, hashedPassword]
);
console.log(`[Auth DEBUG] Stage C: DB insert result for "${username}":`, result);
if (result && result.insertId) { // Добавлена проверка на существование result
console.log(`[Auth] Пользователь "${username}" успешно зарегистрирован с ID: ${result.insertId}.`);
return { success: true, message: 'Регистрация прошла успешно!', userId: result.insertId };
} else {
console.error(`[Auth] Ошибка БД при регистрации пользователя "${username}": Запись не была вставлена или insertId отсутствует. Result:`, result);
return { success: false, message: 'Ошибка сервера при регистрации (данные не сохранены). Попробуйте позже.' };
}
} catch (error) {
console.error(`[Auth] КРИТИЧЕСКАЯ ОШИБКА (catch block) при регистрации пользователя "${username}":`, error);
// Логируем более детальную информацию об ошибке, если это ошибка MySQL
if (error.sqlMessage) {
console.error(`[Auth] MySQL Error Message: ${error.sqlMessage}`);
console.error(`[Auth] MySQL Error Code: ${error.code}`);
console.error(`[Auth] 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(`[Auth DEBUG] loginUser called with username: "${username}"`);
if (!username || !password) {
console.warn('[Auth DEBUG] Login validation failed: Username or password empty.');
return { success: false, message: 'Имя пользователя и пароль не могут быть пустыми.' };
}
try {
console.log(`[Auth DEBUG] Searching for user "${username}" in DB...`);
const [users] = await db.query('SELECT id, username, password_hash FROM users WHERE username = ?', [username]);
console.log(`[Auth DEBUG] DB query result for user "${username}" (length): ${users.length}`);
if (users.length === 0) {
console.warn(`[Auth DEBUG] Login failed: User "${username}" not found.`);
return { success: false, message: 'Неверное имя пользователя или пароль.' };
}
const user = users[0];
console.log(`[Auth DEBUG] User "${username}" found. ID: ${user.id}. Comparing password...`);
const passwordMatch = await bcrypt.compare(password, user.password_hash);
console.log(`[Auth DEBUG] Password comparison result for "${username}": ${passwordMatch}`);
if (passwordMatch) {
console.log(`[Auth] Пользователь "${user.username}" (ID: ${user.id}) успешно вошел в систему.`);
return {
success: true,
message: 'Вход выполнен успешно!',
userId: user.id,
username: user.username
};
} else {
console.warn(`[Auth DEBUG] Login failed for user "${user.username}": Incorrect password.`);
return { success: false, message: 'Неверное имя пользователя или пароль.' };
}
} catch (error) {
console.error(`[Auth] КРИТИЧЕСКАЯ ОШИБКА (catch block) при входе пользователя "${username}":`, error);
if (error.sqlMessage) {
console.error(`[Auth] MySQL Error Message: ${error.sqlMessage}`);
console.error(`[Auth] MySQL Error Code: ${error.code}`);
console.error(`[Auth] MySQL Errno: ${error.errno}`);
}
return { success: false, message: 'Внутренняя ошибка сервера при входе.' };
}
}
module.exports = {
registerUser,
loginUser
};

View File

@ -1,370 +0,0 @@
// /server_modules/data.js
const GAME_CONFIG = require('./config'); // Подключаем конфиг для ID способностей и других значений
// --- Базовые Статы Персонажей ---
const playerBaseStats = {
id: GAME_CONFIG.PLAYER_ID, // Технический ID слота
characterKey: 'elena', // Уникальный ключ персонажа
name: "Елена",
maxHp: 120,
maxResource: 150,
attackPower: 15,
resourceName: "Мана",
avatarPath: 'images/elena_avatar.webp' // Пример пути к аватару
};
const opponentBaseStats = { // Балард (для AI)
id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота
characterKey: 'balard', // Уникальный ключ персонажа
name: "Балард",
maxHp: 140,
maxResource: 100,
attackPower: 20,
resourceName: "Ярость",
avatarPath: 'images/balard_avatar.jpg' // Пример пути к аватару
};
const almagestBaseStats = { // Альмагест (для PvP)
id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота (занимает слот оппонента)
characterKey: 'almagest', // Уникальный ключ персонажа
name: "Альмагест",
maxHp: 120, // Статы как у Елены для зеркальности
maxResource: 150,
attackPower: 15,
resourceName: "Темная Энергия",
avatarPath: 'images/almagest_avatar.webp' // Пример пути к аватару (нужно создать изображение)
};
// --- Способности Персонажей ---
// Способности Игрока (Елена)
const playerAbilities = [
{
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,
descriptionFunction: (config) => `Восст. ${config.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: (config) => `Снижает урон на ${config.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: (config, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats.resourceName} противника каждый его ход в течение 3 ходов. КД: 5 х.'`
}
];
// Способности Противника (Балард - AI)
const opponentAbilities = [
{
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, config) => {
return (opSt.currentHp / opSt.maxHp) * 100 < config.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: (config) => `Шанс ${config.SILENCE_SUCCESS_RATE * 100}% заглушить случайное заклинание Елены на ${config.SILENCE_DURATION} х.`,
// Условие для AI: HP выше порога лечения, Елена не заглушена, не на спец. КД
condition: (opSt, plSt, currentGameState, config) => {
const hpPercent = (opSt.currentHp / opSt.maxHp) * 100;
const isElenaAlreadySilenced = currentGameState?.player.disabledAbilities?.length > 0 ||
currentGameState?.player.activeEffects?.some(eff => eff.id.startsWith('playerSilencedOn_'));
return hpPercent >= config.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && 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% от украденного).`,
// Условие для AI: У Елены достаточно маны, не на спец. КД
condition: (opSt, plSt, currentGameState, config) => {
const playerManaPercent = (plSt.currentResource / plSt.maxResource) * 100;
const playerHasHighMana = playerManaPercent > (config.BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD || 60);
return playerHasHighMana && 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: (config) => `Восст. ${config.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: (config) => `Создает щит, снижающий урон на ${config.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: (config, oppStats) => `Накладывает проклятие, истощающее 10 ${oppStats.resourceName} противника каждый его ход в течение 3 ходов. КД: 5 х.`
}
];
// --- Система Насмешек ---
// Ключи верхнего уровня: characterKey того, кто произносит насмешку ('elena', 'almagest')
// Внутри каждого characterKey: ключи characterKey противника ('balard', 'almagest', 'elena')
// Внутри каждого противника: секции по триггерам (battleStart, selfCastAbility, onOpponentAction, etc.)
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: [ // Когда HP Баларда ниже порога 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: [ "(Резкий вздох) Коснулась... Но Свет исцелит рану.", "Эта царапина - ничто!", "Ты заплатишь за это!" ] // При попадании атаки Альмагест
},
// Триггер: Базовая атака Елены (PvP)
basicAttack: {
general: [ "Тьма не победит, Альмагест!", "Твои иллюзии рассеются перед Светом!", "Пока я стою, порядок будет восстановлен!" ]
},
// Триггер: Изменение состояния боя
onBattleState: {
start: [ "Альмагест! Твоим темным делам пришел конец!", "Во имя Света, я остановлю тебя!", "Приготовься к битве, служительница тьмы!" ], // Начало PvP боя с Альмагест
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: [ "Тысяча лет в заточении лишь усилили меня, Елена!", "Твой Свет скоро погаснет!", "Пора положить конец твоему господству!" ], // Начало PvP боя с Еленой
opponentNearDefeat: [ "Твой Свет гаснет!", "Ты побеждена!", "Бездне нужен твой дух!" ] // Елена почти побеждена
}
}
}
};
// --- Экспорт Данных ---
const gameData = {
// Базовые статы
playerBaseStats,
opponentBaseStats, // Балард
almagestBaseStats, // Альмагест
// Способности
playerAbilities, // Елена
opponentAbilities, // Балард
almagestAbilities, // Альмагест
// Система насмешек (с разделением по персонажам и противникам)
tauntSystem // Переименовали для лучшей структуры
};
// --- Вспомогательные функции для использования ВНУТРИ data.js или модулей, которые его используют ---
// Эти функции НЕ являются частью экспортируемого объекта gameData,
// но могут быть вызваны внутри этого файла, например, в descriptionFunction
// Или их можно было бы экспортировать отдельно, если они нужны другим модулям, не имеющим gameData в аргументах.
// Но в текущей структуре gameLogic они не нужны, так как gameLogic получает gameData и имеет свои локальные хелперы.
// function _getCharacterData(key) {
// if (!key) return null;
// switch (key) {
// case 'elena': return { baseStats: playerBaseStats, abilities: playerAbilities };
// case 'balard': return { baseStats: opponentBaseStats, abilities: opponentAbilities };
// case 'almagest': return { baseStats: almagestBaseStats, abilities: almagestAbilities };
// default: console.error(`data.js::_getCharacterData: Unknown character key "${key}"`); return null;
// }
// }
// function _getCharacterBaseData(key) {
// const charData = _getCharacterData(key);
// return charData ? charData.baseStats : null;
// }
// function _getCharacterAbilities(key) {
// const charData = _getCharacterData(key);
// return charData ? charData.abilities : null;
// }
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = gameData;
}
// console.log("data.js загружен и gameData объект создан/экспортирован.");

View File

@ -1,788 +0,0 @@
// /server_modules/gameInstance.js
const { v4: uuidv4 } = require('uuid');
const gameData = require('./data');
const GAME_CONFIG = require('./config');
const serverGameLogic = require('./gameLogic');
class GameInstance {
constructor(gameId, io, mode = 'ai', gameManager) {
this.id = gameId;
this.io = io;
this.mode = mode;
this.players = {}; // { socket.id: { id: 'player'/'opponent', socket: socketObject, chosenCharacterKey?: 'elena'/'almagest', identifier: userId|socketId } }
this.playerSockets = {}; // { 'player': socketObject, 'opponent': socketObject }
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.turnTimerId = null; // ID для setTimeout (обработка таймаута)
this.turnTimerUpdateIntervalId = null; // ID для setInterval (обновление клиента)
this.turnStartTime = 0; // Время начала текущего хода (Date.now())
if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') {
console.error(`[Game ${this.id}] CRITICAL ERROR: GameInstance created without valid GameManager reference! Cleanup will fail.`);
}
}
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'; // В AI режиме игрок всегда Елена
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 characterData = this._getCharacterBaseData(actualCharacterKey);
console.log(`[Game ${this.id}] Игрок ${identifier} (сокет ${socket.id}) (${characterData?.name || 'Неизвестно'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Всего игроков: ${this.playerCount}. Owner: ${this.ownerIdentifier || 'N/A'}`);
return true;
}
removePlayer(socketId) {
const playerInfo = this.players[socketId];
if (playerInfo) {
const playerRole = playerInfo.id;
const identifierOfLeavingPlayer = playerInfo.identifier;
const characterKeyOfLeavingPlayer = playerInfo.chosenCharacterKey;
const characterData = this._getCharacterBaseData(characterKeyOfLeavingPlayer);
console.log(`[Game ${this.id}] Игрок ${identifierOfLeavingPlayer} (сокет: ${socketId}, роль: ${playerRole}, персонаж: ${characterKeyOfLeavingPlayer || 'N/A'}) покинул игру.`);
if (playerInfo.socket) {
const actualSocket = this.io.sockets.sockets.get(socketId);
if (actualSocket && actualSocket.id === socketId) {
actualSocket.leave(this.id);
} else if (playerInfo.socket.id === socketId) {
try { playerInfo.socket.leave(this.id); } catch (e) { console.warn(`[Game ${this.id}] Error leaving room for old socket ${socketId}: ${e.message}`); }
}
}
delete this.players[socketId];
this.playerCount--;
if (this.playerSockets[playerRole] && this.playerSockets[playerRole].id === socketId) {
delete this.playerSockets[playerRole];
}
// Если игра не окончена и это был ход отключившегося игрока, очищаем таймер
if (this.gameState && !this.gameState.isGameOver) {
const isTurnOfDisconnectedPlayer = (this.gameState.isPlayerTurn && playerRole === GAME_CONFIG.PLAYER_ID) ||
(!this.gameState.isPlayerTurn && playerRole === GAME_CONFIG.OPPONENT_ID);
if (isTurnOfDisconnectedPlayer) {
this.clearTurnTimer();
}
}
} else {
console.warn(`[Game ${this.id}] removePlayer called for unknown socketId ${socketId}.`);
}
}
initializeGame() {
console.log(`[Game ${this.id}] Initializing game state. Mode: ${this.mode}. Current PlayerCount: ${this.playerCount}.`);
if (this.mode === 'ai' && this.playerCount === 1) {
this.playerCharacterKey = 'elena';
this.opponentCharacterKey = 'balard';
} else if (this.mode === 'pvp' && this.playerCount === 2) {
const player1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
const player2Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID);
this.playerCharacterKey = player1Info?.chosenCharacterKey || 'elena';
const expectedOpponentKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena';
if (player2Info && player2Info.chosenCharacterKey !== expectedOpponentKey) {
console.warn(`[Game ${this.id}] initializeGame: Expected opponent character ${expectedOpponentKey} but player2Info had ${player2Info.chosenCharacterKey}.`);
}
this.opponentCharacterKey = expectedOpponentKey;
} else if (this.mode === 'pvp' && this.playerCount === 1) {
const player1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
this.playerCharacterKey = player1Info?.chosenCharacterKey || 'elena';
this.opponentCharacterKey = null;
} else {
console.error(`[Game ${this.id}] Unexpected state for initialization! Mode: ${this.mode}, PlayerCount: ${this.playerCount}. Cannot initialize gameState.`);
this.gameState = null;
return false;
}
console.log(`[Game ${this.id}] Finalizing characters for gameState - Player Slot ('${GAME_CONFIG.PLAYER_ID}'): ${this.playerCharacterKey}, Opponent Slot ('${GAME_CONFIG.OPPONENT_ID}'): ${this.opponentCharacterKey || 'N/A (Waiting)'}`);
const playerBase = this._getCharacterBaseData(this.playerCharacterKey);
const playerAbilities = this._getCharacterAbilities(this.playerCharacterKey);
let opponentBase = null;
let opponentAbilities = null;
const isOpponentDefined = !!this.opponentCharacterKey;
if (isOpponentDefined) {
opponentBase = this._getCharacterBaseData(this.opponentCharacterKey);
opponentAbilities = this._getCharacterAbilities(this.opponentCharacterKey);
}
if (!playerBase || !playerAbilities || (isOpponentDefined && (!opponentBase || !opponentAbilities))) {
const errorMsg = `[Game ${this.id}] CRITICAL ERROR: initializeGame - Failed to load character data! PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}.`;
console.error(errorMsg);
this.logBuffer = [];
this.addToLog('Критическая ошибка сервера при инициализации персонажей!', GAME_CONFIG.LOG_TYPE_SYSTEM);
Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера: не удалось загрузить данные персонажей.' }));
this.gameState = null;
return false;
}
if (isOpponentDefined && (!opponentBase.maxHp || opponentBase.maxHp <= 0)) {
console.error(`[Game ${this.id}] CRITICAL ERROR: initializeGame - Opponent has invalid maxHp (${opponentBase.maxHp}) for key ${this.opponentCharacterKey}.`);
this.logBuffer = [];
this.addToLog('Критическая ошибка сервера: некорректные данные оппонента!', GAME_CONFIG.LOG_TYPE_SYSTEM);
Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера: некорректные данные персонажа оппонента.' }));
this.gameState = null;
return false;
}
this.gameState = {
player: {
id: GAME_CONFIG.PLAYER_ID, characterKey: this.playerCharacterKey, name: playerBase.name,
currentHp: playerBase.maxHp, maxHp: playerBase.maxHp,
currentResource: playerBase.maxResource, maxResource: playerBase.maxResource,
resourceName: playerBase.resourceName, attackPower: playerBase.attackPower,
isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {}
},
opponent: {
id: GAME_CONFIG.OPPONENT_ID, characterKey: this.opponentCharacterKey,
name: opponentBase?.name || 'Ожидание игрока...',
currentHp: opponentBase?.maxHp || 1, maxHp: opponentBase?.maxHp || 1,
currentResource: opponentBase?.maxResource || 0, maxResource: opponentBase?.maxResource || 0,
resourceName: opponentBase?.resourceName || 'Ресурс', attackPower: opponentBase?.attackPower || 0,
isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {},
silenceCooldownTurns: (this.opponentCharacterKey === 'balard') ? 0 : undefined,
manaDrainCooldownTurns: (this.opponentCharacterKey === 'balard') ? 0 : undefined,
},
isPlayerTurn: isOpponentDefined ? Math.random() < 0.5 : true,
isGameOver: false,
turnNumber: 1,
gameMode: this.mode
};
if (playerAbilities) {
playerAbilities.forEach(ability => {
if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) {
this.gameState.player.abilityCooldowns[ability.id] = 0;
}
});
}
if (isOpponentDefined && opponentAbilities) {
opponentAbilities.forEach(ability => {
let cd = 0;
if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) cd = ability.cooldown;
if (this.opponentCharacterKey === 'balard') {
if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') cd = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN;
else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') cd = ability.internalCooldownValue;
}
if (cd > 0) this.gameState.opponent.abilityCooldowns[ability.id] = 0;
});
}
if (isOpponentDefined) {
const isRestart = this.logBuffer.length > 0;
this.logBuffer = [];
this.addToLog(isRestart ? '⚔️ Игра перезапущена! ⚔️' : '⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM);
const playerCharKey = this.gameState.player.characterKey;
if (playerCharKey === 'elena' || playerCharKey === 'almagest') {
const startTaunt = serverGameLogic.getRandomTaunt(playerCharKey, 'battleStart', {}, GAME_CONFIG, gameData, this.gameState);
if (startTaunt !== "(Молчание)") this.addToLog(`${this.gameState.player.name}: "${startTaunt}"`, GAME_CONFIG.LOG_TYPE_INFO);
}
}
console.log(`[Game ${this.id}] Game state initialized. isGameOver: ${this.gameState?.isGameOver}. First turn (if ready): ${this.gameState?.isPlayerTurn ? this.gameState?.player?.name : (this.gameState?.opponent?.name || 'Оппонент')}. Opponent Defined (Ready for Start): ${isOpponentDefined}`);
return isOpponentDefined;
}
startGame() {
if (!this.gameState || !this.gameState.player || !this.gameState.opponent || !this.opponentCharacterKey || this.gameState.opponent.name === 'Ожидание игрока...' || !this.gameState.opponent.maxHp || this.gameState.opponent.maxHp <= 0) {
console.error(`[Game ${this.id}] startGame: Game state is not fully ready for start. Aborting.`);
return;
}
if (this.playerCount === 0 || (this.mode === 'pvp' && this.playerCount === 1)) {
console.warn(`[Game ${this.id}] startGame called with insufficient players (${this.playerCount}). Mode: ${this.mode}. Aborting start.`);
return;
}
console.log(`[Game ${this.id}] Starting game. Broadcasting 'gameStarted' to players. isGameOver: ${this.gameState.isGameOver}`);
const playerCharDataForSlotPlayer = this._getCharacterData(this.playerCharacterKey);
const opponentCharDataForSlotOpponent = this._getCharacterData(this.opponentCharacterKey);
if (!playerCharDataForSlotPlayer || !opponentCharDataForSlotOpponent) {
console.error(`[Game ${this.id}] CRITICAL ERROR: startGame - Failed to load character data! PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}`);
this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при старте игры (не удалось загрузить данные персонажей).' });
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'start_data_load_failed');
}
return;
}
Object.values(this.players).forEach(playerInfo => {
if (playerInfo.socket && playerInfo.socket.connected) {
let dataForThisClient;
if (playerInfo.id === GAME_CONFIG.PLAYER_ID) {
dataForThisClient = {
gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState,
playerBaseStats: playerCharDataForSlotPlayer.baseStats, opponentBaseStats: opponentCharDataForSlotOpponent.baseStats,
playerAbilities: playerCharDataForSlotPlayer.abilities, opponentAbilities: opponentCharDataForSlotOpponent.abilities,
log: this.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG }
};
} else {
dataForThisClient = {
gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState,
playerBaseStats: opponentCharDataForSlotOpponent.baseStats, opponentBaseStats: playerCharDataForSlotPlayer.baseStats,
playerAbilities: opponentCharDataForSlotOpponent.abilities, opponentAbilities: playerCharDataForSlotPlayer.abilities,
log: this.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG }
};
}
playerInfo.socket.emit('gameStarted', dataForThisClient);
console.log(`[Game ${this.id}] Sent gameStarted to ${playerInfo.identifier} (socket ${playerInfo.socket.id}).`);
} else {
console.warn(`[Game ${this.id}] Player ${playerInfo.identifier} (socket ${playerInfo.socket?.id}) is disconnected. Cannot send gameStarted.`);
}
});
const firstTurnActorState = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent;
this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${firstTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN);
this.broadcastLogUpdate(); // Отправляем лог с сообщением о начале хода
this.startTurnTimer(); // Запускаем таймер для первого хода
if (!this.gameState.isPlayerTurn && this.aiOpponent && this.opponentCharacterKey === 'balard') {
console.log(`[Game ${this.id}] AI (Балард) ходит первым. Запускаем AI turn.`);
setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN || 1200);
} else {
console.log(`[Game ${this.id}] Ход реального игрока ${firstTurnActorState.name} (роль: ${firstTurnActorState.id}).`);
}
}
processPlayerAction(requestingSocketId, actionData) {
if (!this.gameState || this.gameState.isGameOver) {
const playerSocket = this.io.sockets.sockets.get(requestingSocketId);
if (playerSocket) playerSocket.emit('gameError', { message: 'Игра уже завершена или неактивна.' });
return;
}
const actingPlayerInfo = this.players[requestingSocketId];
if (!actingPlayerInfo) {
console.error(`[Game ${this.id}] Action from socket ${requestingSocketId} not found in players map.`);
const playerSocket = this.io.sockets.sockets.get(requestingSocketId);
if (playerSocket && playerSocket.connected) playerSocket.disconnect(true);
return;
}
const actingPlayerRole = actingPlayerInfo.id;
const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) ||
(!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID);
if (!isCorrectTurn) {
console.warn(`[Game ${this.id}] Action from ${actingPlayerInfo.identifier} (socket ${requestingSocketId}): Not their turn.`);
return; // Игнорируем действие, таймер хода не сбрасывается и не перезапускается
}
// Игрок сделал ход, очищаем таймер
this.clearTurnTimer();
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 = this._getCharacterData(attackerState.characterKey);
const defenderData = this._getCharacterData(defenderState.characterKey);
if (!attackerData || !defenderData) {
console.error(`[Game ${this.id}] CRITICAL ERROR: processPlayerAction - Failed to load character data! AttackerKey: ${attackerState.characterKey}, DefenderKey: ${defenderState.characterKey}`);
this.addToLog('Критическая ошибка сервера при обработке действия (не найдены данные персонажа)!', GAME_CONFIG.LOG_TYPE_SYSTEM);
this.broadcastLogUpdate();
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'action_data_load_failed');
}
return;
}
let actionValid = true;
if (actionData.actionType === 'attack') {
const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'basicAttack', {}, GAME_CONFIG, gameData, this.gameState);
if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO);
serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData);
const delayedAttackBuffEffect = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK));
if (delayedAttackBuffEffect && !delayedAttackBuffEffect.justCast) {
const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerData.baseStats.maxResource - attackerState.currentResource);
if (actualRegen > 0) {
attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen);
this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${delayedAttackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL);
}
}
} else if (actionData.actionType === 'ability' && actionData.abilityId) {
const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId);
if (!ability) {
actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." });
console.warn(`[Game ${this.id}] Игрок ${actingPlayerInfo.identifier} (сокет ${requestingSocketId}) попытался использовать неизвестную способность ID: ${actionData.abilityId}.`);
this.startTurnTimer(); // Перезапускаем таймер, так как ход не был совершен
return;
}
const hasEnoughResource = attackerState.currentResource >= ability.cost;
const isOnCooldown = (attackerState.abilityCooldowns?.[ability.id] || 0) > 0;
const isCasterFullySilenced = attackerState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0);
const isAbilitySpecificallySilenced = attackerState.disabledAbilities?.some(dis => dis.abilityId === ability.id && dis.turnsLeft > 0);
const isSilenced = isCasterFullySilenced || isAbilitySpecificallySilenced;
let isOnSpecialCooldown = false;
if (attackerState.characterKey === 'balard') {
if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && attackerState.silenceCooldownTurns !== undefined && attackerState.silenceCooldownTurns > 0) isOnSpecialCooldown = true;
if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && attackerState.manaDrainCooldownTurns !== undefined && attackerState.manaDrainCooldownTurns > 0) isOnSpecialCooldown = true;
}
const isBuffAlreadyActive = ability.type === GAME_CONFIG.ACTION_TYPE_BUFF && attackerState.activeEffects.some(e => e.id === ability.id);
const isTargetedDebuff = ability.id === GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF;
const effectIdForDebuff = 'effect_' + ability.id;
const isDebuffAlreadyOnTarget = isTargetedDebuff && defenderState.activeEffects.some(e => e.id === effectIdForDebuff);
if (!hasEnoughResource) { this.addToLog(`${attackerState.name} пытается применить "${ability.name}", но не хватает ${attackerState.resourceName}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; }
if (actionValid && (isOnCooldown || isOnSpecialCooldown)) { this.addToLog(`"${ability.name}" еще на перезарядке.`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; }
if (actionValid && isSilenced) { this.addToLog(`${attackerState.name} не может использовать способности из-за безмолвия!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; }
if (actionValid && isBuffAlreadyActive) { this.addToLog(`Эффект "${ability.name}" уже активен!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; }
if (actionValid && isDebuffAlreadyOnTarget) { this.addToLog(`Эффект "${ability.name}" уже наложен на ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; }
if (actionValid) {
attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost);
const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'selfCastAbility', { abilityId: ability.id }, GAME_CONFIG, gameData, this.gameState);
if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO);
serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData);
let baseCooldown = 0;
if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) baseCooldown = ability.cooldown;
if (attackerState.characterKey === 'balard') {
if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; }
else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue; }
}
if (baseCooldown > 0 && attackerState.abilityCooldowns) attackerState.abilityCooldowns[ability.id] = baseCooldown + 1;
}
} else {
actingPlayerInfo.socket.emit('gameError', { message: `Неизвестный тип действия: ${actionData?.actionType}` });
console.warn(`[Game ${this.id}] Получен неизвестный тип действия от ${actingPlayerInfo.identifier} (сокет ${requestingSocketId}): ${actionData?.actionType}`);
actionValid = false;
}
if (this.checkGameOver()) {
this.broadcastGameStateUpdate(); // Отправляем финальное состояние, включая лог
// Очистка игры и таймеров происходит в checkGameOver
return;
}
if (actionValid) {
console.log(`[Game ${this.id}] Player action valid. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`);
setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500);
} else {
// Если действие было невалидным, ход не передается.
// Отправляем лог и перезапускаем таймер для текущего игрока.
console.log(`[Game ${this.id}] Player action invalid. Broadcasting log update and restarting timer for current player.`);
this.broadcastLogUpdate();
this.startTurnTimer(); // Перезапускаем таймер, так как ход не был совершен
}
}
switchTurn() {
if (!this.gameState || this.gameState.isGameOver) return;
// Очищаем таймер предыдущего хода ПЕРЕД обработкой эффектов,
// так как эффекты могут изменить состояние игры и повлиять на следующий запуск таймера.
this.clearTurnTimer();
const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
const endingTurnActorState = this.gameState[endingTurnActorRole];
const endingTurnCharacterData = this._getCharacterData(endingTurnActorState.characterKey);
if (!endingTurnCharacterData) {
console.error(`[Game ${this.id}] SwitchTurn Error: No character data found for ending turn actor role ${endingTurnActorRole} with key ${endingTurnActorState.characterKey}. Cannot process end-of-turn effects.`);
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'switch_turn_data_error');
}
return;
} else {
serverGameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnCharacterData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData);
serverGameLogic.updateBlockingStatus(this.gameState.player);
serverGameLogic.updateBlockingStatus(this.gameState.opponent);
if (endingTurnActorState.abilityCooldowns) serverGameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnCharacterData.abilities, endingTurnActorState.name, this.addToLog.bind(this));
if (endingTurnActorState.characterKey === 'balard') {
if (endingTurnActorState.silenceCooldownTurns !== undefined && endingTurnActorState.silenceCooldownTurns > 0) endingTurnActorState.silenceCooldownTurns--;
if (endingTurnActorState.manaDrainCooldownTurns !== undefined && endingTurnActorState.manaDrainCooldownTurns > 0) endingTurnActorState.manaDrainCooldownTurns--;
}
if (endingTurnActorState.disabledAbilities?.length > 0) {
const charAbilitiesForDisabledCheck = this._getCharacterAbilities(endingTurnActorState.characterKey);
if (charAbilitiesForDisabledCheck) serverGameLogic.processDisabledAbilities(endingTurnActorState.disabledAbilities, charAbilitiesForDisabledCheck, endingTurnActorState.name, this.addToLog.bind(this));
else console.warn(`[Game ${this.id}] SwitchTurn: Cannot process disabledAbilities for ${endingTurnActorState.name}: character abilities data not found.`);
}
}
if (this.checkGameOver()) {
this.broadcastGameStateUpdate(); // Отправляем финальное состояние, включая лог
// Очистка игры и таймеров происходит в checkGameOver
return;
}
this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn;
if (this.gameState.isPlayerTurn) this.gameState.turnNumber++;
const currentTurnActorState = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent;
this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN);
this.broadcastGameStateUpdate(); // Отправляем обновленное состояние и лог
this.startTurnTimer(); // Запускаем таймер для нового хода
if (!this.gameState.isPlayerTurn && this.aiOpponent && this.opponentCharacterKey === 'balard') {
console.log(`[Game ${this.id}] Ход AI (Балард). Запускаем AI turn.`);
setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN || 1200);
} else {
console.log(`[Game ${this.id}] Ход реального игрока ${currentTurnActorState.name} (роль: ${currentTurnActorState.id}).`);
}
}
processAiTurn() {
if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent || this.gameState.opponent?.characterKey !== 'balard') {
if (!this.gameState || this.gameState.isGameOver) return;
// Если по какой-то причине сюда попали не во время хода AI, переключаем ход
console.warn(`[Game ${this.id}] processAiTurn called incorrectly. Attempting to switch turn.`);
this.switchTurn(); // Это запустит таймер для игрока, если это его ход
return;
}
// AI не использует таймер, поэтому this.clearTurnTimer() не нужен перед его действием.
// Таймер для игрока был очищен в switchTurn(), когда ход перешел к AI.
const attackerState = this.gameState.opponent;
const defenderState = this.gameState.player;
const attackerData = this._getCharacterData('balard');
const defenderData = this._getCharacterData('elena');
if (!attackerData || !defenderData) {
console.error(`[Game ${this.id}] CRITICAL ERROR: processAiTurn - Failed to load character data!`);
this.addToLog("AI не может действовать: ошибка данных персонажа.", GAME_CONFIG.LOG_TYPE_SYSTEM);
this.broadcastLogUpdate();
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'ai_data_load_failed');
}
this.switchTurn(); // Переключаем ход обратно (запустит таймер для игрока)
return;
}
const isBalardFullySilenced = attackerState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0);
if (isBalardFullySilenced) {
serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData);
if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; }
console.log(`[Game ${this.id}] AI (Балард) attacked while silenced. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`);
setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); // switchTurn запустит таймер для игрока
return;
}
const aiDecision = serverGameLogic.decideAiAction(this.gameState, gameData, GAME_CONFIG, this.addToLog.bind(this));
if (aiDecision.actionType === 'attack') {
serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData);
} else if (aiDecision.actionType === 'ability' && aiDecision.ability) {
const ability = aiDecision.ability;
attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost);
serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData);
let baseCooldown = 0;
if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) baseCooldown = ability.cooldown;
if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; }
else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue; }
if (baseCooldown > 0 && attackerState.abilityCooldowns) attackerState.abilityCooldowns[ability.id] = baseCooldown + 1;
} else if (aiDecision.actionType === 'pass') {
if (aiDecision.logMessage) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type);
else this.addToLog(`${attackerState.name} обдумывает свой следующий ход...`, GAME_CONFIG.LOG_TYPE_INFO);
} else {
console.error(`[Game ${this.id}] AI (Балард) chose an invalid action type: ${aiDecision.actionType}. Defaulting to pass and logging error.`);
this.addToLog(`AI ${attackerState.name} не смог выбрать действие из-за ошибки. Пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO);
}
if (this.checkGameOver()) {
this.broadcastGameStateUpdate(); // Отправляем финальное состояние, включая лог
// Очистка игры и таймеров происходит в checkGameOver
return;
}
console.log(`[Game ${this.id}] AI action complete. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`);
setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); // switchTurn запустит таймер для игрока
}
checkGameOver() {
if (!this.gameState || this.gameState.isGameOver) return this.gameState ? this.gameState.isGameOver : true;
if (!this.gameState.player || !this.gameState.opponent || this.gameState.opponent.maxHp <= 0) return false;
const isOver = serverGameLogic.checkGameOverInternal(this.gameState, GAME_CONFIG, gameData);
if (isOver && !this.gameState.isGameOver) { // Игра только что завершилась
this.gameState.isGameOver = true;
this.clearTurnTimer(); // Очищаем все таймеры
const playerDead = this.gameState.player?.currentHp <= 0;
const opponentDead = this.gameState.opponent?.currentHp <= 0;
let winnerRole = null; let loserRole = null;
if (this.mode === 'ai') {
winnerRole = playerDead ? null : GAME_CONFIG.PLAYER_ID; // В AI игре AI не "побеждает", побеждает только игрок
loserRole = playerDead ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
} else { // PvP
if (playerDead && opponentDead) { winnerRole = GAME_CONFIG.PLAYER_ID; loserRole = GAME_CONFIG.OPPONENT_ID; } // Ничья - победа игрока 1
else if (playerDead) { winnerRole = GAME_CONFIG.OPPONENT_ID; loserRole = GAME_CONFIG.PLAYER_ID; }
else if (opponentDead) { winnerRole = GAME_CONFIG.PLAYER_ID; loserRole = GAME_CONFIG.OPPONENT_ID; }
else { this.gameState.isGameOver = false; this.startTurnTimer(); return false; } // Ошибка, игра не окончена, перезапускаем таймер
}
const winnerState = this.gameState[winnerRole];
const loserState = this.gameState[loserRole];
const winnerName = winnerState?.name || (winnerRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник");
const loserName = loserState?.name || (loserRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник");
const loserCharacterKey = loserState?.characterKey || 'unknown';
if (this.mode === 'ai') {
if (winnerRole === GAME_CONFIG.PLAYER_ID) this.addToLog(`🏁 ПОБЕДА! Вы одолели ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM);
else this.addToLog(`😭 ПОРАЖЕНИЕ! ${winnerName} оказался(лась) сильнее! 😭`, GAME_CONFIG.LOG_TYPE_SYSTEM);
} else {
this.addToLog(`🏁 ПОБЕДА! ${winnerName} одолел(а) ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM);
}
const winningCharacterKey = winnerState?.characterKey;
if (this.mode === 'ai' && winningCharacterKey === 'elena') {
const taunt = serverGameLogic.getRandomTaunt(winningCharacterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, gameData, this.gameState);
if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerName}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO);
} else if (this.mode === 'pvp' && (winningCharacterKey === 'elena' || winningCharacterKey === 'almagest')) {
const taunt = serverGameLogic.getRandomTaunt(winningCharacterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, gameData, this.gameState);
if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerName}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO);
}
if (loserCharacterKey === 'balard') this.addToLog(`Елена исполнила свой тяжкий долг. ${loserName} развоплощен...`, GAME_CONFIG.LOG_TYPE_SYSTEM);
else if (loserCharacterKey === 'almagest') this.addToLog(`Над полем битвы воцаряется тишина. ${loserName} побежден(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM);
else if (loserCharacterKey === 'elena') this.addToLog(`Свет погас. ${loserName} повержен(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM);
console.log(`[Game ${this.id}] Game is over. Winner: ${winnerName} (${winnerRole}). Loser: ${loserName} (${loserRole}). Reason: HP <= 0.`);
this.io.to(this.id).emit('gameOver', {
winnerId: this.mode === 'ai' ? (winnerRole === GAME_CONFIG.PLAYER_ID ? winnerRole : null) : winnerRole,
reason: 'hp_zero', // Используем стандартизированную причину
finalGameState: this.gameState,
log: this.consumeLogBuffer(),
loserCharacterKey: loserCharacterKey
});
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'hp_zero');
}
return true;
}
return isOver;
}
endGameDueToDisconnect(disconnectedSocketId, disconnectedPlayerRole, disconnectedCharacterKey) {
if (this.gameState && !this.gameState.isGameOver) {
this.gameState.isGameOver = true;
this.clearTurnTimer(); // Очищаем все таймеры
const winnerRole = disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
const disconnectedCharacterData = this._getCharacterBaseData(disconnectedCharacterKey);
const winnerCharacterKey = (winnerRole === GAME_CONFIG.PLAYER_ID) ? this.playerCharacterKey : this.opponentCharacterKey;
const winnerCharacterData = this._getCharacterBaseData(winnerCharacterKey);
this.addToLog(`🔌 Игрок ${disconnectedCharacterData?.name || 'Неизвестный'} (${disconnectedPlayerRole}) отключился. Игра завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
if (this.mode === 'pvp') { // Только в PvP присуждаем победу оставшемуся
this.addToLog(`🏁 Победа присуждается ${winnerCharacterData?.name || winnerRole}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM);
} else { // В AI игре, если игрок отключается, AI не "побеждает"
this.addToLog(`Игра завершена досрочно.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
}
this.io.to(this.id).emit('gameOver', {
winnerId: this.mode === 'pvp' ? winnerRole : null, // В AI режиме нет победителя при дисконнекте игрока
reason: 'opponent_disconnected',
finalGameState: this.gameState,
log: this.consumeLogBuffer(),
loserCharacterKey: disconnectedCharacterKey
});
console.log(`[Game ${this.id}] Game ended due to disconnect. Winner (PvP only): ${winnerCharacterData?.name || winnerRole}. Disconnected: ${disconnectedCharacterData?.name || disconnectedPlayerRole}.`);
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'opponent_disconnected');
}
}
}
// --- Методы для управления таймером хода ---
startTurnTimer() {
this.clearTurnTimer();
if (!this.gameState || this.gameState.isGameOver) return;
// Определяем, является ли текущий ход ходом реального игрока
let isRealPlayerTurn = false;
if (this.gameState.isPlayerTurn) { // Ход слота 'player'
const playerInSlot = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
// Если это AI режим, и в слоте 'player' не Балард (т.е. это человек), то это ход реального игрока
if (this.mode === 'ai' && playerInSlot?.chosenCharacterKey !== 'balard') isRealPlayerTurn = true;
// Если это PvP режим, и в слоте 'player' есть игрок, это ход реального игрока
else if (this.mode === 'pvp' && playerInSlot) isRealPlayerTurn = true;
} else { // Ход слота 'opponent'
const opponentInSlot = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID);
// Если это AI режим, то в слоте 'opponent' всегда AI, это НЕ ход реального игрока
// Если это PvP режим, и в слоте 'opponent' есть игрок, это ход реального игрока
if (this.mode === 'pvp' && opponentInSlot) isRealPlayerTurn = true;
}
if (!isRealPlayerTurn) { // Если это ход AI или слот пуст
// console.log(`[Game ${this.id}] AI's turn or empty slot. Timer not started for player.`);
// Сообщаем клиентам, что таймер неактивен или это ход AI
// Передаем isPlayerTurn, чтобы клиент знал, чей ход вообще.
// Если isPlayerTurn=true, но isRealPlayerTurn=false (не должно быть, но на всякий),
// то это ошибка, и таймер все равно не стартует.
this.io.to(this.id).emit('turnTimerUpdate', { remainingTime: null, isPlayerTurn: this.gameState.isPlayerTurn });
return;
}
this.turnStartTime = Date.now();
const turnDuration = GAME_CONFIG.TURN_DURATION_MS;
const currentTurnActor = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent;
console.log(`[Game ${this.id}] Starting turn timer (${turnDuration / 1000}s) for ${currentTurnActor.name}.`);
this.turnTimerId = setTimeout(() => {
this.handleTurnTimeout();
}, turnDuration);
this.turnTimerUpdateIntervalId = setInterval(() => {
if (!this.gameState || this.gameState.isGameOver) {
this.clearTurnTimer();
return;
}
const elapsedTime = Date.now() - this.turnStartTime;
const remainingTime = Math.max(0, turnDuration - elapsedTime);
// Отправляем оставшееся время и чей сейчас ход
this.io.to(this.id).emit('turnTimerUpdate', { remainingTime, isPlayerTurn: this.gameState.isPlayerTurn });
}, GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS);
// Отправляем начальное значение таймера сразу
this.io.to(this.id).emit('turnTimerUpdate', { remainingTime: turnDuration, isPlayerTurn: this.gameState.isPlayerTurn });
}
clearTurnTimer() {
if (this.turnTimerId) {
clearTimeout(this.turnTimerId);
this.turnTimerId = null;
}
if (this.turnTimerUpdateIntervalId) {
clearInterval(this.turnTimerUpdateIntervalId);
this.turnTimerUpdateIntervalId = null;
}
// console.log(`[Game ${this.id}] Turn timer cleared.`);
}
handleTurnTimeout() {
if (!this.gameState || this.gameState.isGameOver) return;
this.clearTurnTimer();
const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
const timedOutPlayerState = this.gameState[timedOutPlayerRole];
const winnerPlayerRole = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
const winnerPlayerState = this.gameState[winnerPlayerRole];
// Убедимся, что у победителя есть имя
if (!winnerPlayerState || !winnerPlayerState.name || winnerPlayerState.name === 'Ожидание игрока...') {
// Это может произойти, если игрок выходит из игры PvP, когда он один, и его таймер истекает
// (хотя дисконнект должен был обработать это раньше).
// Или если в AI игре timedOutPlayer был AI (что не должно случаться с текущей логикой таймера).
console.error(`[Game ${this.id}] Turn timeout, but winner state is invalid. Timed out: ${timedOutPlayerState?.name}. Game will be cleaned up.`);
this.gameState.isGameOver = true; // Помечаем как оконченную
this.addToLog(`⏱️ Время хода для ${timedOutPlayerState?.name || 'игрока'} истекло. Игра завершена некорректно.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
this.io.to(this.id).emit('gameOver', {
winnerId: null, // Неопределенный победитель
reason: 'timeout_error_state',
finalGameState: this.gameState,
log: this.consumeLogBuffer(),
loserCharacterKey: timedOutPlayerState?.characterKey || 'unknown'
});
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'timeout_error_state');
}
return;
}
this.gameState.isGameOver = true;
this.addToLog(`⏱️ Время хода для ${timedOutPlayerState.name} истекло!`, GAME_CONFIG.LOG_TYPE_SYSTEM);
this.addToLog(`🏁 Победа присуждается ${winnerPlayerState.name}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM);
console.log(`[Game ${this.id}] Turn timed out for ${timedOutPlayerState.name}. Winner: ${winnerPlayerState.name}.`);
this.io.to(this.id).emit('gameOver', {
winnerId: winnerPlayerRole,
reason: 'turn_timeout',
finalGameState: this.gameState,
log: this.consumeLogBuffer(),
loserCharacterKey: timedOutPlayerState.characterKey
});
if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') {
this.gameManager._cleanupGame(this.id, 'turn_timeout');
}
}
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() });
}
}
_getCharacterData(key) {
if (!key) { console.warn("GameInstance::_getCharacterData called with null/undefined key."); return null; }
switch (key) {
case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities };
case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities };
case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities };
default: console.error(`GameInstance::_getCharacterData: Unknown character key "${key}"`); return null;
}
}
_getCharacterBaseData(key) {
const charData = this._getCharacterData(key);
return charData ? charData.baseStats : null;
}
_getCharacterAbilities(key) {
const charData = this._getCharacterData(key);
return charData ? charData.abilities : null;
}
}
module.exports = GameInstance;

View File

@ -1,727 +0,0 @@
// /server_modules/gameLogic.js
const GAME_CONFIG = require('./config');
const gameData = require('./data'); // Загружаем один раз на уровне модуля
// --- Вспомогательные Функции для gameLogic ---
// Вспомогательная функция для получения данных персонажа (baseStats и abilities)
// Нужна здесь, так как объект gameData сам по себе не имеет этих методов.
// Принимает gameDataForLogic как аргумент для гибкости, по умолчанию использует глобальный gameData.
function _getCharacterDataForLogic(key, gameDataForLogic = gameData) {
if (!key) return null;
switch (key) {
case 'elena': return { baseStats: gameDataForLogic.playerBaseStats, abilities: gameDataForLogic.playerAbilities };
case 'balard': return { baseStats: gameDataForLogic.opponentBaseStats, abilities: gameDataForLogic.opponentAbilities }; // Балард использует opponentAbilities
case 'almagest': return { baseStats: gameDataForLogic.almagestBaseStats, abilities: gameDataForLogic.almagestAbilities }; // Альмагест использует almagestAbilities
default: console.error(`_getCharacterDataForLogic: Неизвестный ключ персонажа "${key}"`); return null;
}
}
// Вспомогательная функция для получения только базовых статов персонажа
function _getCharacterBaseDataForLogic(key, gameDataForLogic = gameData) {
const charData = _getCharacterDataForLogic(key, gameDataForLogic);
return charData ? charData.baseStats : null;
}
// Вспомогательная функция для получения только способностей персонажа
function _getCharacterAbilitiesForLogic(key, gameDataForLogic = gameData) {
const charData = _getCharacterDataForLogic(key, gameDataForLogic);
return charData ? charData.abilities : null;
}
/**
* Обрабатывает активные эффекты (баффы/дебаффы) для бойца в конце его хода.
* Длительность эффекта уменьшается на 1.
* Периодические эффекты (DoT, ресурсный дебафф и т.п.) срабатывают, если эффект не "justCast" в этом ходу.
* @param {Array} effectsArray - Массив активных эффектов бойца.
* @param {Object} ownerState - Состояние бойца (currentHp, currentResource и т.д.).
* @param {Object} ownerBaseStats - Базовые статы бойца (включая characterKey).
* @param {String} ownerId - Технический ID слота бойца ('player' или 'opponent').
* @param {Object} currentGameState - Полное состояние игры.
* @param {Function} addToLogCallback - Функция для добавления сообщений в лог игры.
* @param {Object} configToUse - Конфигурационный объект игры.
* @param {Object} gameDataForLogic - Полный объект gameData (для доступа к способностям и т.д.).
* @returns {void} - Модифицирует effectsArray и ownerState напрямую.
*/
function processEffects(effectsArray, ownerState, ownerBaseStats, ownerId, currentGameState, addToLogCallback, configToUse, gameDataForLogic = gameData) {
if (!effectsArray) return;
const ownerName = ownerBaseStats.name;
let effectsToRemoveIndexes = [];
// Важно: Сначала обрабатываем эффекты, затем уменьшаем длительность, затем удаляем.
for (let i = 0; i < effectsArray.length; i++) {
const eff = effectsArray[i];
// --- Применяем эффект (DoT, сжигание ресурса и т.п.), если он не только что наложен в этом ходу ---
if (!eff.justCast) {
// Обработка урона от эффектов полного безмолвия (Гипнотический Взгляд, Раскол Разума)
// Эти эффекты наносят урон цели В КОНЦЕ ее хода
if (eff.isFullSilence && typeof eff.power === 'number' && eff.power > 0) {
const damage = eff.power;
// ИСПРАВЛЕНО: Округляем результат вычитания HP
ownerState.currentHp = Math.max(0, Math.round(ownerState.currentHp - damage));
if (addToLogCallback) addToLogCallback(`😵 Эффект "${eff.name}" наносит ${damage} урона ${ownerName}!`, configToUse.LOG_TYPE_DAMAGE);
}
// Обработка сжигания ресурса (Печать Слабости, Проклятие Увядания)
// Эти эффекты сжигают ресурс цели В КОНЦЕ ее хода
if ((eff.id === 'effect_' + configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || eff.id === 'effect_' + configToUse.ABILITY_ID_ALMAGEST_DEBUFF) && eff.power > 0) {
const resourceToBurn = eff.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(`🔥 Эффект "${eff.name}" сжигает ${actualBurn} ${ownerBaseStats.resourceName} у ${ownerName}!`, configToUse.LOG_TYPE_EFFECT);
}
}
// Примечание: Отложенные эффекты (например, Сила Природы) применяют свою силу в gameInstance.processPlayerAction (после атаки), а не здесь.
}
// --- Уменьшаем длительность ---
eff.turnsLeft--;
eff.justCast = false; // Эффект больше не считается "just cast" после обработки этого хода
// --- Отмечаем для удаления, если длительность закончилась ---
if (eff.turnsLeft <= 0) {
effectsToRemoveIndexes.push(i);
if (addToLogCallback) {
addToLogCallback(`Эффект "${eff.name}" на ${ownerName} закончился.`, configToUse.LOG_TYPE_EFFECT);
}
}
}
// Удаляем эффекты с конца, чтобы не нарушить индексы
for (let i = effectsToRemoveIndexes.length - 1; i >= 0; i--) {
effectsArray.splice(effectsToRemoveIndexes[i], 1);
}
}
/**
* Обрабатывает отсчет для отключенных (заглушенных) способностей игрока в конце его хода.
* Длительность заглушения уменьшается на 1.
* @param {Array<object>} disabledAbilitiesArray - Массив объектов заглушенных способностей.
* @param {Array<object>} characterAbilities - Полный список способностей персонажа (для получения имени).
* @param {string} characterName - Имя персонажа (для лога).
* @param {function} addToLogCallback - Функция для добавления лога.
* @returns {void} - Модифицирует disabledAbilitiesArray напрямую.
*/
function processDisabledAbilities(disabledAbilitiesArray, characterAbilities, characterName, addToLogCallback) {
if (!disabledAbilitiesArray || disabledAbilitiesArray.length === 0) return;
const stillDisabled = [];
disabledAbilitiesArray.forEach(dis => {
dis.turnsLeft--; // Уменьшаем длительность заглушения
if (dis.turnsLeft > 0) {
stillDisabled.push(dis);
} else {
if (addToLogCallback) {
const ability = characterAbilities.find(ab => ab.id === dis.abilityId);
// Проверка на заглушающий эффект тоже должна быть удалена из activeEffects в processEffects
// Здесь мы только обрабатываем список disabledAbilities, удаляя запись
if (ability) addToLogCallback(`Способность ${characterName} "${ability.name}" больше не заглушена!`, GAME_CONFIG.LOG_TYPE_INFO);
}
}
});
// Очищаем исходный массив и добавляем только те, что еще активны
disabledAbilitiesArray.length = 0;
disabledAbilitiesArray.push(...stillDisabled);
}
/**
* Обрабатывает отсчет общих кулдаунов для способностей в конце хода.
* Длительность кулдауна уменьшается на 1.
* @param {object} cooldownsObject - Объект с кулдаунами способностей ({ abilityId: turnsLeft }).
* @param {Array<object>} abilitiesList - Полный список способностей персонажа (для получения имени).
* @param {string} ownerName - Имя персонажа (для лога).
* @param {function} addToLogCallback - Функция для добавления лога.
* @returns {void} - Модифицирует cooldownsObject напрямую.
*/
function processPlayerAbilityCooldowns(cooldownsObject, abilitiesList, ownerName, addToLogCallback) {
if (!cooldownsObject || !abilitiesList) return;
for (const abilityId in cooldownsObject) {
if (cooldownsObject.hasOwnProperty(abilityId) && cooldownsObject[abilityId] > 0) {
cooldownsObject[abilityId]--; // Уменьшаем кулдаун
if (cooldownsObject[abilityId] === 0) {
const ability = abilitiesList.find(ab => ab.id === abilityId);
if (ability && addToLogCallback) {
addToLogCallback(`Способность ${ownerName} "${ability.name}" снова готова!`, GAME_CONFIG.LOG_TYPE_INFO);
}
}
}
}
}
/**
* Обновляет статус 'isBlocking' на основе активных эффектов.
* @param {object} fighterState - Состояние бойца.
* @returns {void} - Модифицирует fighterState.isBlocking.
*/
function updateBlockingStatus(fighterState) {
if (!fighterState) return;
// Боец считается блокирующим, если у него есть активный эффект, дающий блок (grantsBlock: true) с turnsLeft > 0
fighterState.isBlocking = fighterState.activeEffects.some(eff => eff.grantsBlock && eff.turnsLeft > 0);
}
/**
* Получает случайную насмешку из системы насмешек для определенного персонажа.
* Ищет фразу в gameData.tauntSystem[speakerCharacterKey][opponentCharacterKey][trigger][context].
* @param {string} speakerCharacterKey - Ключ персонажа, который произносит насмешку ('elena' или 'almagest' или 'balard').
* @param {string} trigger - Тип события, вызвавшего насмешку (например, 'selfCastAbility', 'onOpponentAction', 'battleStart', 'basicAttack', 'opponentNearDefeatCheck').
* @param {object} context - Дополнительный контекст (например, { abilityId: 'fireball' }, { outcome: 'success' }).
* @param {object} configToUse - Конфигурационный объект игры.
* @param {object} gameDataForLogic - Полный объект gameData.
* @param {object} currentGameState - Текущее состояние игры.
* @returns {string} Текст насмешки или "(Молчание)".
*/
function getRandomTaunt(speakerCharacterKey, trigger, context = {}, configToUse, gameDataForLogic = gameData, currentGameState) {
// Проверяем наличие системы насмешек для говорящего персонажа
const speakerTauntSystem = gameDataForLogic?.tauntSystem?.[speakerCharacterKey];
if (!speakerTauntSystem) return "(Молчание)"; // Нет насмешек для этого персонажа
// Определяем противника, чтобы выбрать соответствующую ветку насмешек
// Для этого нужно найти в gameState, кто из player/opponent имеет characterKey говорящего,
// и взять characterKey другого.
const speakerRole = currentGameState?.player?.characterKey === speakerCharacterKey ?
GAME_CONFIG.PLAYER_ID :
(currentGameState?.opponent?.characterKey === speakerCharacterKey ?
GAME_CONFIG.OPPONENT_ID : null);
if (speakerRole === null) {
console.warn(`getRandomTaunt: Speaker character key "${speakerCharacterKey}" not found in current game state roles.`);
return "(Молчание)";
}
const opponentRole = speakerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
const opponentCharacterKey = currentGameState?.[opponentRole]?.characterKey;
const tauntBranch = speakerTauntSystem[opponentCharacterKey];
if (!tauntBranch) {
// console.warn(`getRandomTaunt: No taunt branch found for speaker "${speakerCharacterKey}" against opponent "${opponentCharacterKey}".`);
return "(Молчание)"; // Нет насмешек против этого оппонента
}
let potentialTaunts = [];
// Навигация по структуре tauntSystem в зависимости от триггера и контекста
if (trigger === 'battleStart') {
potentialTaunts = tauntBranch.onBattleState?.start;
}
else if (trigger === 'opponentNearDefeatCheck') { // Проверка на низкое HP противника для специальных фраз
const opponentState = currentGameState?.[opponentRole]; // Состояние противника для проверки HP
// Проверяем, что состояние оппонента существует и его HP ниже порога (например, 20%)
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?.[opponentRole]; // Состояние противника
// Специальная логика для базовой атаки Елены против Баларда (милосердие/доминирование)
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 { // Общая логика для PvP или Елена/Балард вне порога
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]; // Например, onOpponentAction.silence.success
} else if (Array.isArray(actionResponses)) {
potentialTaunts = actionResponses; // Прямой массив фраз для способности
}
}
}
// Реакция на попадание/блок атаки противника
// Примечание: Эти триггеры срабатывают, когда по ГОВОРЯЩЕМУ попала атака или он ее заблокировал.
// Вызываются из performAttack, где известно, кто атакует и кто защищается.
else if (trigger === 'onOpponentAttackBlocked' && tauntBranch.onOpponentAction?.attackBlocked) {
potentialTaunts = tauntBranch.onOpponentAction.attackBlocked;
}
else if (trigger === 'onOpponentAttackHit' && tauntBranch.onOpponentAction?.attackHits) {
potentialTaunts = tauntBranch.onOpponentAction.attackHits;
}
// Если по прямому триггеру не найдено, возвращаем "(Молчание)".
// Можно добавить фоллбэк на общие фразы, если требуется более разговорчивый персонаж.
// Например: if ((!potentialTaunts || potentialTaunts.length === 0) && tauntBranch.basicAttack?.general) { potentialTaunts = tauntBranch.basicAttack.general; }
if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) {
return "(Молчание)"; // Возвращаем молчание, если ничего не найдено
}
// Возвращаем случайную фразу из найденного массива
const selectedTaunt = potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)];
return selectedTaunt || "(Молчание)"; // Фоллбэк на "(Молчание)" если массив был пуст после всех проверок
}
// --- Основные Игровые Функции ---
/**
* Обрабатывает базовую атаку одного бойца по другому.
* @param {object} attackerState - Состояние атакующего бойца.
* @param {object} defenderState - Состояние защищающегося бойца.
* @param {object} attackerBaseStats - Базовые статы атакующего.
* @param {object} defenderBaseStats - Базовые статы защищающегося.
* @param {object} currentGameState - Текущее состояние игры (для насмешек).
* @param {function} addToLogCallback - Функция для добавления лога.
* @param {object} configToUse - Конфигурация игры.
* @param {object} gameDataForLogic - Данные игры (для насмешек).
*/
function performAttack(attackerState, defenderState, attackerBaseStats, defenderBaseStats, currentGameState, addToLogCallback, configToUse, gameDataForLogic = gameData) {
// Расчет базового урона с вариацией
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 принимает speaker (защищающийся), trigger, context, config, gameData, gameState
const blockTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState);
if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`;
}
if (addToLogCallback) addToLogCallback(`🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK);
} else {
let hitMessage = `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`;
// Проверка на насмешку ОТ защищающегося (Елены или Альмагест) при попадании атаки
if (defenderState.characterKey === 'elena' || defenderState.characterKey === 'almagest') {
// getRandomTaunt принимает speaker (защищающийся), trigger, context, config, gameData, gameState
const hitTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackHit', {}, configToUse, gameDataForLogic, currentGameState);
if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`;
}
if (addToLogCallback) addToLogCallback(hitMessage, configToUse.LOG_TYPE_DAMAGE);
}
// Применяем урон, убеждаемся, что HP не ниже нуля
// ИСПРАВЛЕНО: Округляем результат вычитания HP
defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage));
}
/**
* Применяет эффект способности.
* @param {object} ability - Объект способности.
* @param {object} casterState - Состояние бойца, применившего способность.
* @param {object} targetState - Состояние цели способности.
* @param {object} casterBaseStats - Базовые статы кастера.
* @param {object} targetBaseStats - Базовые статы цели.
* @param {object} currentGameState - Текущее состояние игры (для насмешек).
* @param {function} addToLogCallback - Функция для добавления лога.
* @param {object} configToUse - Конфигурация игры.
* @param {object} gameDataForLogic - Данные игры (для насмешек).
*/
function applyAbilityEffect(ability, casterState, targetState, casterBaseStats, targetBaseStats, currentGameState, addToLogCallback, configToUse, gameDataForLogic = gameData) {
let tauntMessagePart = ""; // Переменная для насмешки, если она связана с результатом эффекта или реакцией цели
// Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под способность противника
if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
// Триггер 'onOpponentAction' с abilityId противника
const reactionTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id }, configToUse, gameDataForLogic, currentGameState);
if (reactionTaunt !== "(Молчание)") tauntMessagePart = ` (${reactionTaunt})`;
} else {
tauntMessagePart = ""; // Другие персонажи (Балард) не имеют реакционных насмешек такого типа
}
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) {
// ИСПРАВЛЕНО: Округляем результат прибавления HP
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 (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
// const blockTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAttackBlocked', {abilityId: ability.id} , configToUse, gameDataForLogic, currentGameState);
// if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`;
// }
if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK);
}
// Применяем урон, убеждаемся, что HP не ниже нуля
// ИСПРАВЛЕНО: Округляем результат вычитания HP
targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage));
if (addToLogCallback && !targetState.isBlocking) {
let hitMessage = `💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!${tauntMessagePart}`;
// Проверка на насмешку ОТ цели (Елены или Альмагест), если по ней попала способность - перенесено наверх
// if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
// const hitTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
// if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`;
// }
addToLogCallback(hitMessage, configToUse.LOG_TYPE_DAMAGE);
}
break;
case configToUse.ACTION_TYPE_BUFF:
// Если бафф уже активен, не применяем его повторно (эта проверка уже есть в gameInstance)
// Проверка на .some здесь опциональна, т.к. вызывающий код должен гарантировать уникальность
if (!casterState.activeEffects.some(e => e.id === ability.id)) {
let effectDescription = ability.description;
if (typeof ability.descriptionFunction === 'function') {
// Для описания баффа может потребоваться информация о противнике
const opponentRole = casterState.id === configToUse.PLAYER_ID ? configToUse.OPPONENT_ID : configToUse.PLAYER_ID;
const opponentCurrentState = currentGameState[opponentRole];
// Получаем базовые статы противника, если он определен, для функции описания
const opponentDataForDesc = opponentCurrentState?.characterKey ? _getCharacterBaseDataForLogic(opponentCurrentState.characterKey, gameDataForLogic) : null; // ИСПОЛЬЗУЕМ _getCharacterBaseDataForLogic
effectDescription = ability.descriptionFunction(configToUse, opponentDataForDesc);
}
// isDelayed: true используется для эффектов, которые срабатывают ПОСЛЕ следующего действия (например, Сила Природы).
// duration: исходная длительность из данных, turnsLeft: сколько ходов осталось (включая текущий, если !justCast)
casterState.activeEffects.push({
id: ability.id, name: ability.name, description: effectDescription,
type: ability.type, duration: ability.duration, // Сохраняем исходную длительность для отображения в UI или логики
turnsLeft: ability.duration, // Длительность жизни эффекта в ходах владельца
grantsBlock: !!ability.grantsBlock,
isDelayed: !!ability.isDelayed, // Флаг, что эффект отложенный (срабатывает после действия)
justCast: true // Флаг, что эффект только что наложен (для логики processEffects)
});
if (ability.grantsBlock) updateBlockingStatus(casterState); // Обновляем статус блока кастера, если бафф его дает
// Насмешки при применении баффа (selfCastAbility) добавляются в GameInstance перед вызовом applyAbilityEffect
if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} накладывает эффект "${ability.name}"!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT);
} else {
// Сообщение "уже активен" отправляется из gameInstance перед вызовом applyAbilityEffect
}
break;
case configToUse.ACTION_TYPE_DISABLE: // Безмолвие, Стан и т.п.
// Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под дизейбл противника - перенесено наверх
// if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
// const disableTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
// if (disableTaunt !== "(Молчание)") tauntMessagePart = ` (${disableTaunt})`;
// }
// Гипнотический взгляд Елены / Раскол Разума Альмагест (полное безмолвие)
if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) {
const effectId = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest';
// Не накладываем повторно, если эффект уже есть на цели
if (!targetState.activeEffects.some(e => e.id === effectId)) {
targetState.activeEffects.push({
id: effectId, 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}! Способности ${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;
const silenceOutcome = success ? 'success' : 'fail';
// Реакция цели (Елены) на успех/провал безмолвия Баларда - перенесено наверх, но с context.outcome
// if (targetState.characterKey === 'elena') { // Балард применяет это только на Елену
// const specificSilenceTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState);
// tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : "";
// } else {
// tauntMessagePart = ""; // Другие персонажи не реагируют на Безмолвие Баларда
// }
// Нужно получить насмешку с outcome здесь, так как она зависит от результата броска шанса
// Временно сохраняем общую насмешку и получаем специфичную
let specificSilenceTaunt = "(Молчание)";
if (targetState.characterKey === 'elena') { // Балард применяет это только на Елену
specificSilenceTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState);
}
tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : tauntMessagePart; // Используем специфичную, если найдена, иначе общую (хотя общая для этого абилки вряд ли есть)
if (success) {
const targetAbilities = _getCharacterAbilitiesForLogic(targetState.characterKey, gameDataForLogic); // Глушим абилки цели
// Фильтруем способности, которые еще не заглушены этим типом безмолвия
const availableAbilities = targetAbilities.filter(pa =>
!targetState.disabledAbilities?.some(d => d.abilityId === pa.id) &&
!targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`) // Проверка на эффект заглушения для UI/ProcessEffects
);
if (availableAbilities.length > 0) {
const abilityToSilence = availableAbilities[Math.floor(Math.random() * availableAbilities.length)];
const turns = configToUse.SILENCE_DURATION; // Длительность из конфига (в ходах цели)
// Добавляем запись о заглушенной способности в disabledAbilities цели
targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); // +1, т.к. длительность уменьшается в конце хода цели
// Добавляем эффект заглушения в activeEffects цели (для UI и ProcessEffects)
const silenceEffectIdOnPlayer = `playerSilencedOn_${abilityToSilence.id}`;
targetState.activeEffects.push({
id: silenceEffectIdOnPlayer, name: `Безмолвие: ${abilityToSilence.name}`,
description: `Способность "${abilityToSilence.name}" временно недоступна.`,
type: configToUse.ACTION_TYPE_DISABLE, sourceAbilityId: ability.id, // Добавлено sourceAbilityId
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: // Ослабления, DoT и т.п.
// Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под дебафф противника - перенесено наверх
// if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
// const debuffTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
// if (debuffTaunt !== "(Молчание)") tauntMessagePart = ` (${debuffTaunt})`;
// }
// Печать Слабости Елены / Проклятие Увядания Альмагест (сжигание ресурса)
if (ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF) {
const effectIdForDebuff = 'effect_' + ability.id; // Уникальный ID эффекта на цели
// Не накладываем повторно, если эффект уже есть на цели
if (!targetState.activeEffects.some(e => e.id === effectIdForDebuff)) {
let effectDescription = ability.description;
if (typeof ability.descriptionFunction === 'function') {
effectDescription = ability.descriptionFunction(configToUse, targetBaseStats); // Описание может зависеть от цели
}
targetState.activeEffects.push({
id: effectIdForDebuff, name: ability.name, description: effectDescription,
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;
// tauntMessagePart уже получена в начале функции
// Сначала урон от способности
if (ability.powerDamage > 0) {
let baseDamageDrain = ability.powerDamage;
// Проверка на блок цели
if (targetState.isBlocking) {
baseDamageDrain = Math.floor(baseDamageDrain * configToUse.BLOCK_DAMAGE_REDUCTION);
let blockDrainTaunt = "";
// Реакция цели (Елены/Альмагест) на блок урона от дрейна
if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
blockDrainTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState);
if (blockDrainTaunt !== "(Молчание)") blockDrainTaunt = ` (${blockDrainTaunt})`;
}
if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует часть урона от "${ability.name}" от ${casterBaseStats.name}! Урон снижен до ${baseDamageDrain}.${blockDrainTaunt}`, configToUse.LOG_TYPE_BLOCK);
}
damageDealtDrain = Math.max(0, baseDamageDrain);
// ИСПРАВЛЕНО: Округляем результат вычитания HP
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);
// ИСПРАВЛЕНО: Округляем результат прибавления HP
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 { // Ни урона, ни вытягивания ресурса
// ИСПРАВЛЕНО: targetBaseStats.resourceName -> targetState.resourceName (или defenderBaseStats.resourceName, если он передается)
logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; // Оставляем targetBaseStats.resourceName, т.к. он точнее для лога
// Если урон был 0, и ресурса нет, можно уточнить лог
if(damageDealtDrain === 0 && potentialDrain > 0) logMsgDrain += ` Урон не прошел или равен нулю, ресурс не похищен.`;
}
logMsgDrain += tauntMessagePart; // Добавляем насмешку цели, если была
if (addToLogCallback) addToLogCallback(logMsgDrain, manaDrained > 0 || damageDealtDrain > 0 ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO);
} else {
console.warn(`applyAbilityEffect: Drain type ability ${ability?.name} used by non-Balard character ${casterState.characterKey}`);
}
break;
default:
console.warn(`applyAbilityEffect: Неизвестный тип способности: ${ability?.type} для "${ability?.name}"`);
}
}
/**
* Логика принятия решения для AI (Балард).
* @param {object} currentGameState - Текущее состояние игры.
* @param {object} gameDataForLogic - Данные игры.
* @param {object} configToUse - Конфигурация игры.
* @param {function} addToLogCallback - Функция для добавления лога.
* @returns {object} Объект с действием AI ({ actionType: 'attack' | 'ability', ability?: object }).
*/
function decideAiAction(currentGameState, gameDataForLogic = gameData, configToUse, addToLogCallback) {
const opponentState = currentGameState.opponent; // AI Балард всегда в слоте opponent
const playerState = currentGameState.player; // Игрок всегда в слоте player (в AI режиме)
// Убеждаемся, что это AI Балард
if (opponentState.characterKey !== 'balard') {
console.warn("[AI DEBUG] decideAiAction called for non-Balard opponent. This should not happen.");
return { actionType: 'pass', logMessage: { message: `${opponentState.name} (не AI) пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
}
// Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены или Раскола Разума Альмагест)
const isBalardFullySilenced = opponentState.activeEffects.some(
eff => eff.isFullSilence && eff.turnsLeft > 0
);
if (isBalardFullySilenced) {
// AI под полным безмолвием просто атакует
// Лог о безмолвии и атаке в смятении добавляется в processAiTurn перед вызовом performAttack.
// decideAiAction просто возвращает действие.
return { actionType: 'attack' };
}
const availableActions = [];
const opponentAbilities = gameDataForLogic.opponentAbilities; // Способности Баларда
// Проверяем доступность способностей AI и добавляем их в список возможных действий с весом
// Вес определяет приоритет: выше вес -> выше шанс выбора (после сортировки)
const healAbility = opponentAbilities.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 });
}
const silenceAbility = opponentAbilities.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)) {
const playerHpPercent = (playerState.currentHp / playerState.maxHp) * 100;
// Балард предпочитает безмолвие, если HP Елены не слишком низкое (позволяет ей лечиться, чтобы игра длилась дольше)
if (playerHpPercent > (configToUse.PLAYER_HP_BLEED_THRESHOLD_PERCENT || 60)) { // Используем порог для текстов Елены как пример
availableActions.push({ weight: 60, type: 'ability', ability: silenceAbility, requiresSuccessCheck: true, successRate: configToUse.SILENCE_SUCCESS_RATE });
}
}
const drainAbility = opponentAbilities.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 });
}
// Базовая атака - всегда доступна как запасной вариант с низким весом
availableActions.push({ weight: 30, type: 'attack' });
// Если по какой-то причине список доступных действий пуст (не должно быть, т.к. атака всегда есть)
if (availableActions.length === 0) {
return { actionType: 'pass', logMessage: { message: `${opponentState.name} не может совершить действие.`, type: configToUse.LOG_TYPE_INFO } };
}
// Сортируем действия по весу в порядке убывания
availableActions.sort((a, b) => b.weight - a.weight);
// Перебираем действия в порядке приоритета и выбираем первое возможное
for (const action of availableActions) {
if (action.type === 'ability') {
// Если способность требует проверки успеха (например, Безмолвие Баларда)
if (action.requiresSuccessCheck) {
if (Math.random() < action.successRate) {
// Успех, добавляем лог о попытке (чтобы было видно, что AI пытался)
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' };
}
// 'pass' не должен быть в доступных действиях, если атака всегда доступна
}
// Если все попытки выбрать способность или атаку провалились (очень маловероятно, если атака всегда в списке), пропуск хода
console.warn("[AI DEBUG] AI failed to select any action. Defaulting to pass.");
return { actionType: 'pass', logMessage: { message: `${opponentState.name} не смог выбрать подходящее действие. Пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
}
/**
* Внутренняя проверка условий конца игры (основано на HP).
* @param {object} currentGameState - Текущее состояние игры.
* @param {object} configToUse - Конфигурация игры.
* @param {object} gameDataForLogic - Данные игры.
* @returns {boolean} true, если игра окончена, иначе false.
*/
function checkGameOverInternal(currentGameState, configToUse, gameDataForLogic = gameData) {
// Проверка на конец игры происходит только если gameState существует и игра еще не помечена как оконченная
if (!currentGameState || currentGameState.isGameOver) return currentGameState ? currentGameState.isGameOver : true;
// Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами
// Проверка maxHp > 0 в gameState.opponent гарантирует, что оппонент не плейсхолдер
if (!currentGameState.player || !currentGameState.opponent || currentGameState.opponent.maxHp <= 0) {
// Если один из бойцов не готов (например, PvP игра ожидает второго игрока), игра не может закончиться по HP
return false;
}
const playerDead = currentGameState.player.currentHp <= 0;
const opponentDead = currentGameState.opponent.currentHp <= 0;
// Игра окончена, если один или оба бойца мертвы
return playerDead || opponentDead;
}
// Экспортируем все функции, которые используются в других модулях
module.exports = {
processEffects,
processDisabledAbilities,
processPlayerAbilityCooldowns,
updateBlockingStatus,
getRandomTaunt, // Экспортируем переименованную функцию
performAttack,
applyAbilityEffect,
decideAiAction,
checkGameOverInternal
};

View File

@ -1,479 +0,0 @@
// /server_modules/gameManager.js
const { v4: uuidv4 } = require('uuid');
const GameInstance = require('./gameInstance');
const gameData = require('./data');
const GAME_CONFIG = require('./config');
class GameManager {
constructor(io) {
this.io = io;
this.games = {}; // { gameId: GameInstance }
this.userIdentifierToGameId = {}; // { userId|socketId: gameId }
this.pendingPvPGames = []; // [gameId]
}
_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');
}
} else {
if (this.userIdentifierToGameId[identifier] !== excludeGameId) {
console.warn(`[GameManager] Удаление потенциально некорректной ссылки userIdentifierToGameId[${identifier}] на игру ${oldPendingGameId}.`);
delete this.userIdentifierToGameId[identifier];
}
}
}
}
createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', identifier) {
this._removePreviousPendingGames(socket.id, identifier);
if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) {
console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${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} (сокет: ${socket.id}, выбран: ${charKeyForInstance})`);
const assignedPlayerId = game.players[socket.id]?.id;
if (!assignedPlayerId) {
this._cleanupGame(gameId, 'player_add_failed');
console.error(`[GameManager] Ошибка при создании игры ${gameId}: Не удалось назначить ID игрока сокету ${socket.id} (идентификатор ${identifier}).`);
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)) {
console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`);
const isInitialized = game.initializeGame();
if (isInitialized) {
game.startGame();
} else {
console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`);
this._cleanupGame(gameId, 'initialization_failed');
}
if (game.mode === 'pvp' && game.playerCount === 2) {
const gameIndex = this.pendingPvPGames.indexOf(gameId);
if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1);
this.broadcastAvailablePvPGames();
}
} else if (mode === 'pvp' && game.playerCount === 1) {
if (!this.pendingPvPGames.includes(gameId)) {
this.pendingPvPGames.push(gameId);
}
game.initializeGame(); // Частичная инициализация
this.broadcastAvailablePvPGames();
}
} else {
this._cleanupGame(gameId, 'player_add_failed');
console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}. Игра удалена.`);
}
}
joinGame(socket, gameId, identifier) {
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 (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]] && this.userIdentifierToGameId[identifier] !== gameId) {
console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на присоединение.`);
socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' });
this.handleRequestGameState(socket, identifier);
return;
}
if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре.' }); return; }
this._removePreviousPendingGames(socket.id, identifier, gameId);
if (game.addPlayer(socket, null, identifier)) {
this.userIdentifierToGameId[identifier] = gameId;
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) присоединился к PvP игре ${gameId}`);
if (game.mode === 'pvp' && game.playerCount === 2) {
console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`);
const isInitialized = game.initializeGame();
if (isInitialized) {
game.startGame();
} else {
console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`);
this._cleanupGame(gameId, 'initialization_failed');
}
const gameIndex = this.pendingPvPGames.indexOf(gameId);
if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1);
this.broadcastAvailablePvPGames();
}
} else {
console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}.`);
}
}
findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) {
this._removePreviousPendingGames(socket.id, identifier);
if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) {
console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${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) {
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) нашел игру ${gameIdToJoin} и присоединяется.`);
this.joinGame(socket, gameIdToJoin, identifier);
} else {
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) не нашел свободных игр. Создает новую.`);
this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier);
socket.emit('noPendingGamesFound', {
message: 'Свободных PvP игр не найдено. Создана новая игра для вас. Ожидайте противника.',
gameId: this.userIdentifierToGameId[identifier],
yourPlayerId: GAME_CONFIG.PLAYER_ID
});
}
}
handlePlayerAction(identifier, actionData) {
const gameId = this.userIdentifierToGameId[identifier];
const game = this.games[gameId];
if (game && game.players) {
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 && actualSocket.connected) {
game.processPlayerAction(currentSocketId, actionData);
} else {
console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его текущий сокет (${currentSocketId}) не найден или отключен.`);
}
} else {
console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}) для игры ${gameId}, но его запись не найдена в game.players.`);
delete this.userIdentifierToGameId[identifier];
const playerSocket = this.io.sockets.sockets.get(identifier) || playerInfo?.socket;
if (playerSocket) {
playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' });
}
}
} else {
console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его игра (ID: ${gameId}) не найдена в GameManager.`);
delete this.userIdentifierToGameId[identifier];
const playerSocket = this.io.sockets.sockets.get(identifier);
if (playerSocket) {
playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' });
}
}
}
handleDisconnect(socketId, identifier) {
const gameId = this.userIdentifierToGameId[identifier];
const game = this.games[gameId];
if (game && game.players) {
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
if (playerInfo) {
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socketId}) отключился. В игре ${gameId}.`);
const disconnectedPlayerRole = playerInfo.id;
const disconnectedCharacterKey = playerInfo.chosenCharacterKey;
game.removePlayer(socketId); // Удаляем именно этот сокет
if (game.playerCount === 0) {
console.log(`[GameManager] Игра ${gameId} пуста после дисконнекта ${socketId} (идентификатор ${identifier}). Удаляем.`);
this._cleanupGame(gameId, 'player_count_zero_on_disconnect');
} else if (game.mode === 'pvp' && game.playerCount === 1 && game.gameState && !game.gameState.isGameOver) {
// Если это PvP игра и остался 1 игрок, и игра НЕ была завершена дисконнектом этого игрока
// (т.е. другой игрок еще в игре)
// Тогда игра переходит в состояние ожидания.
const remainingPlayerInfo = Object.values(game.players)[0]; // Единственный оставшийся игрок
if (remainingPlayerInfo) {
// Проверяем, что это не тот же игрок, что и отключившийся
if (remainingPlayerInfo.identifier !== identifier) {
game.endGameDueToDisconnect(socketId, disconnectedPlayerRole, disconnectedCharacterKey);
// _cleanupGame будет вызван из endGameDueToDisconnect
} else {
// Отключился единственный оставшийся игрок в ожидающей игре.
// _cleanupGame должен быть вызван.
console.log(`[GameManager] Отключился единственный игрок ${identifier} из ожидающей PvP игры ${gameId}. Удаляем игру.`);
this._cleanupGame(gameId, 'last_player_disconnected_from_pending');
}
} else {
// Оставшегося игрока нет, хотя playerCount > 0 - это ошибка, очищаем.
console.error(`[GameManager] Ошибка: playerCount > 0 в игре ${gameId}, но не найден оставшийся игрок после дисконнекта ${identifier}. Очищаем.`);
this._cleanupGame(gameId, 'error_no_remaining_player');
}
} else if (game.gameState && !game.gameState.isGameOver) {
// Если игра была активна (не ожидала) и еще не была завершена,
// дисконнект одного из игроков завершает игру.
game.endGameDueToDisconnect(socketId, disconnectedPlayerRole, disconnectedCharacterKey);
// _cleanupGame будет вызван из endGameDueToDisconnect
} else if (game.gameState?.isGameOver) {
// Если игра уже была завершена до этого дисконнекта, просто удаляем ссылку на игру для отключившегося.
console.log(`[GameManager] Игрок ${identifier} отключился из уже завершенной игры ${gameId}. Удаляем ссылку.`);
delete this.userIdentifierToGameId[identifier];
// _cleanupGame уже был вызван при завершении игры.
} else {
// Другие случаи (например, AI игра, где игрок остался, или ошибка)
console.log(`[GameManager] Игрок ${identifier} отключился из активной игры ${gameId} (mode: ${game.mode}, players: ${game.playerCount}). Удаляем ссылку.`);
delete this.userIdentifierToGameId[identifier];
}
} else {
console.warn(`[GameManager] Игрок с идентификатором ${identifier} (сокет: ${socketId}) не найден в game.players для игры ${gameId}.`);
delete this.userIdentifierToGameId[identifier];
}
} else {
console.log(`[GameManager] Отключился сокет ${socketId} (идентификатор ${identifier}). Игровая сессия по этому идентификатору не найдена.`);
delete this.userIdentifierToGameId[identifier];
}
}
_cleanupGame(gameId, reason = 'unknown_reason') {
const game = this.games[gameId];
if (!game) {
console.warn(`[GameManager] _cleanupGame called for unknown game ID: ${gameId}`);
return false;
}
console.log(`[GameManager] Cleaning up game ${gameId} (Mode: ${game.mode}, Reason: ${reason})...`);
// Очищаем таймеры, если они были активны
if (typeof game.clearTurnTimer === 'function') {
game.clearTurnTimer();
}
Object.values(game.players).forEach(playerInfo => {
if (playerInfo && playerInfo.identifier && this.userIdentifierToGameId[playerInfo.identifier] === gameId) {
delete this.userIdentifierToGameId[playerInfo.identifier];
console.log(`[GameManager] Removed userIdentifierToGameId for ${playerInfo.identifier}.`);
}
});
const pendingIndex = this.pendingPvPGames.indexOf(gameId);
if (pendingIndex > -1) {
this.pendingPvPGames.splice(pendingIndex, 1);
console.log(`[GameManager] Removed game ${gameId} from pendingPvPGames.`);
}
delete this.games[gameId];
console.log(`[GameManager] Deleted GameInstance for game ${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) {
let firstPlayerUsername = 'Игрок';
let firstPlayerCharacterName = '';
const firstPlayerInfo = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
if (firstPlayerInfo) {
if (firstPlayerInfo.socket?.userData?.username) {
firstPlayerUsername = firstPlayerInfo.socket.userData.username;
} else {
firstPlayerUsername = `User#${String(firstPlayerInfo.identifier).substring(0, 6)}`;
}
const charKey = firstPlayerInfo.chosenCharacterKey;
if (charKey) {
const charBaseStats = this._getCharacterBaseData(charKey);
if (charBaseStats && charBaseStats.name) {
firstPlayerCharacterName = charBaseStats.name;
} else {
firstPlayerCharacterName = charKey;
}
}
} else {
console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo (Player 1) не найдена для ожидающей игры ${gameId}.`);
firstPlayerUsername = 'Неизвестный игрок';
}
let statusString = `Ожидает 1 игрока (Создал: ${firstPlayerUsername}`;
if (firstPlayerCharacterName) statusString += ` за ${firstPlayerCharacterName}`;
statusString += `)`;
return { id: gameId, status: statusString };
}
if (game && !this.pendingPvPGames.includes(gameId)) { /* Game not pending */ }
else if (game && game.playerCount === 1 && (game.gameState?.isGameOver || !game.gameState)) { /* Game over or not initialized */ }
else if (game && game.playerCount === 2) { /* Game full */ }
else if (game && game.playerCount === 0) {
console.warn(`[GameManager] getAvailablePvPGamesList: Найдена пустая игра ${gameId} в games. Удаляем.`);
delete this.games[gameId];
}
return null;
})
.filter(info => info !== null);
}
broadcastAvailablePvPGames() {
const availableGames = this.getAvailablePvPGamesListForClient();
this.io.emit('availablePvPGamesList', availableGames);
console.log(`[GameManager] Обновлен список доступных PvP игр. Всего: ${availableGames.length}`);
}
getActiveGamesList() {
return Object.values(this.games).map(game => {
let playerSlotCharName = game.gameState?.player?.name || (game.playerCharacterKey ? this._getCharacterBaseData(game.playerCharacterKey)?.name : 'N/A (ожидание)');
let opponentSlotCharName = game.gameState?.opponent?.name || (game.opponentCharacterKey ? this._getCharacterBaseData(game.opponentCharacterKey)?.name : 'N/A (ожидание)');
const playerInSlot1 = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
const playerInSlot2 = Object.values(game.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID);
if (!playerInSlot1) playerSlotCharName = 'Пусто';
if (!playerInSlot2 && game.mode === 'pvp') opponentSlotCharName = 'Ожидание...';
if (!playerInSlot2 && game.mode === 'ai' && game.aiOpponent) opponentSlotCharName = 'Балард (AI)';
return {
id: game.id.substring(0, 8),
mode: game.mode,
playerCount: game.playerCount,
isGameOver: game.gameState ? game.gameState.isGameOver : 'N/A (Не инициализирована)',
playerSlot: playerSlotCharName,
opponentSlot: opponentSlotCharName,
ownerIdentifier: game.ownerIdentifier || 'N/A',
pending: this.pendingPvPGames.includes(game.id),
turn: game.gameState ? `Ход ${game.gameState.turnNumber}, ${game.gameState.isPlayerTurn ? (playerInSlot1?.identifier || 'Player Slot') : (playerInSlot2?.identifier || 'Opponent Slot')}` : 'N/A'
};
});
}
handleRequestGameState(socket, identifier) {
const gameId = this.userIdentifierToGameId[identifier];
let game = gameId ? this.games[gameId] : null;
if (game && game.players) {
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
if (playerInfo) {
if (game.gameState?.isGameOver) {
console.log(`[GameManager] Reconnected user ${identifier} to game ${gameId} which is already over. Sending gameNotFound.`);
delete this.userIdentifierToGameId[identifier];
socket.emit('gameNotFound', { message: 'Ваша предыдущая игровая сессия уже завершена.' });
return;
}
console.log(`[GameManager] Found game ${gameId} for identifier ${identifier} (role ${playerInfo.id}). Reconnecting socket ${socket.id}.`);
const oldSocketId = playerInfo.socket?.id;
if (oldSocketId && oldSocketId !== socket.id && game.players[oldSocketId]) {
console.log(`[GameManager] Updating socket ID for player ${identifier} from ${oldSocketId} to ${socket.id} in game ${gameId}.`);
delete game.players[oldSocketId];
if (game.playerSockets[playerInfo.id]?.id === oldSocketId) {
delete game.playerSockets[playerInfo.id];
}
}
game.players[socket.id] = playerInfo;
game.players[socket.id].socket = socket;
game.playerSockets[playerInfo.id] = socket;
socket.join(game.id);
const playerCharDataForClient = this._getCharacterData(playerInfo.chosenCharacterKey);
const opponentActualSlotId = playerInfo.id === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
let opponentCharacterKeyForClient = game.gameState?.[opponentActualSlotId]?.characterKey || null;
if (!opponentCharacterKeyForClient) {
opponentCharacterKeyForClient = playerInfo.id === GAME_CONFIG.PLAYER_ID ? game.opponentCharacterKey : game.playerCharacterKey;
}
const opponentCharDataForClient = this._getCharacterData(opponentCharacterKeyForClient);
if (playerCharDataForClient && opponentCharDataForClient && game.gameState) {
const isGameReadyForPlay = (game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2);
const isOpponentDefinedInState = game.gameState.opponent?.characterKey && game.gameState.opponent?.name !== 'Ожидание игрока...';
socket.emit('gameState', {
gameId: game.id, yourPlayerId: playerInfo.id, gameState: game.gameState,
playerBaseStats: playerCharDataForClient.baseStats, opponentBaseStats: opponentCharDataForClient.baseStats,
playerAbilities: playerCharDataForClient.abilities, opponentAbilities: opponentCharDataForClient.abilities,
log: game.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG }
});
console.log(`[GameManager] Sent gameState to socket ${socket.id} (identifier: ${identifier}) for game ${game.id}.`);
if (!game.gameState.isGameOver && isGameReadyForPlay && !isOpponentDefinedInState) {
console.log(`[GameManager] Game ${game.id} found ready but not fully started on reconnect. Initializing/Starting.`);
const isInitialized = game.initializeGame();
if (isInitialized) {
game.startGame();
} else {
console.error(`[GameManager] Failed to initialize game ${game.id} on reconnect. Cannot start.`);
this.io.to(game.id).emit('gameError', { message: 'Ошибка сервера при старте игры после переподключения.' });
this._cleanupGame(gameId, 'reconnect_initialization_failed');
}
} else if (!isGameReadyForPlay) {
console.log(`[GameManager] Reconnected user ${identifier} to pending game ${gameId}. Sending gameState and waiting status.`);
socket.emit('waitingForOpponent');
} else if (game.gameState.isGameOver) { // Повторная проверка, т.к. startGame мог завершить игру
console.log(`[GameManager] Reconnected to game ${gameId} which is now over (after re-init). Sending gameNotFound.`);
delete this.userIdentifierToGameId[identifier];
socket.emit('gameNotFound', { message: 'Ваша игровая сессия завершилась во время переподключения.' });
} else {
console.log(`[GameManager] Reconnected user ${identifier} to active game ${gameId}. gameState sent.`);
// Важно: если игра активна, нужно отправить и текущее состояние таймера.
// Это можно сделать, вызвав game.startTurnTimer() (он отправит update),
// но только если это ход этого игрока и игра не AI (или ход игрока в AI).
// Или добавить отдельный метод в GameInstance для отправки текущего состояния таймера.
if (typeof game.startTurnTimer === 'function') { // Проверяем, что метод существует
// Перезапуск таймера здесь может быть некорректным, если ход не этого игрока
// Лучше, чтобы gameInstance сам отправлял 'turnTimerUpdate' при gameState
// Либо добавить специальный метод в gameInstance для отправки текущего значения таймера
// Пока оставим так, startTurnTimer сам проверит, чей ход.
game.startTurnTimer();
}
}
} else {
console.error(`[GameManager] Failed to send gameState to ${socket.id} (identifier ${identifier}) for game ${gameId}: missing character data or gameState.`);
socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' });
this._cleanupGame(gameId, 'reconnect_send_failed');
socket.emit('gameNotFound', { message: 'Ваша игровая сессия в некорректном состоянии и была завершена.' });
}
} else {
console.warn(`[GameManager] Found game ${gameId} by identifier ${identifier}, but player with this identifier not found in game.players.`);
delete this.userIdentifierToGameId[identifier];
socket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена. Возможно, идентификатор пользователя некорректен.' });
}
} else {
console.log(`[GameManager] No active or pending game found for identifier ${identifier}.`);
socket.emit('gameNotFound', { message: 'Игровая сессия не найдена.' });
}
}
_getCharacterData(key) {
if (!key) { console.warn("GameManager::_getCharacterData called with null/undefined key."); return null; }
switch (key) {
case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities };
case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities };
case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities };
default: console.error(`GameManager::_getCharacterData: Unknown character key "${key}"`); return null;
}
}
_getCharacterBaseData(key) {
const charData = this._getCharacterData(key);
return charData ? charData.baseStats : null;
}
_getCharacterAbilities(key) {
const charData = this._getCharacterData(key);
return charData ? charData.abilities : null;
}
}
module.exports = GameManager;