This commit is contained in:
commit
9a8c802b96
9
.env.example
Normal file
9
.env.example
Normal file
@ -0,0 +1,9 @@
|
||||
DB_HOST=localhost
|
||||
DB_USER=your_mysql_user
|
||||
DB_PASSWORD=your_mysql_password
|
||||
DB_NAME=your_game_db
|
||||
DB_PORT=3306
|
||||
|
||||
BC_APP_PORT=3200
|
||||
BC_APP_HOSTNAME=127.0.0.1
|
||||
NODE_ENV=development
|
57
.gitea/workflows/deploy.yml
Normal file
57
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,57 @@
|
||||
name: Deploy Project BC
|
||||
|
||||
on:
|
||||
push: # Запускать при событии push
|
||||
branches:
|
||||
- main # Только для ветки main (или master, или ваша основная ветка)
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest # Указываем метку раннера, на котором должна выполняться задача.
|
||||
# Убедитесь, что ваш зарегистрированный раннер имеет эту метку.
|
||||
# Можно использовать и более специфичную, например, 'self-hosted' или имя вашего сервера, если вы так его пометили.
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3 # Стандартное действие для скачивания кода репозитория на раннер
|
||||
|
||||
- name: Setup Node.js # Если вам нужно определенная версия Node.js для npm install
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18' # Укажите нужную вам LTS или другую версию Node.js
|
||||
|
||||
- name: Install Dependencies
|
||||
run: | # Выполняем команды в рабочей директории раннера (куда был склонирован репозиторий)
|
||||
echo "Current directory: $(pwd)"
|
||||
if [ -f package.json ]; then
|
||||
echo "package.json found. Installing dependencies..."
|
||||
npm install --omit=dev
|
||||
else
|
||||
echo "package.json not found. Skipping npm install."
|
||||
fi
|
||||
working-directory: ./ # Указывает, что npm install нужно выполнять в корне склонированного репозитория
|
||||
# Если package.json для bc.js в подпапке server/, то:
|
||||
# working-directory: ./server
|
||||
|
||||
- name: Execute Deploy Script on Server
|
||||
uses: appleboy/ssh-action@master # Популярное действие для выполнения команд по SSH
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }} # IP или домен вашего сервера, где нужно выполнить деплой
|
||||
username: ${{ secrets.DEPLOY_USER }} # Имя пользователя для SSH-доступа
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }} # Приватный SSH-ключ для доступа
|
||||
port: ${{ secrets.DEPLOY_PORT || 22 }} # Порт SSH, по умолчанию 22
|
||||
script: |
|
||||
echo "Starting deployment on server for project bc..."
|
||||
cd /home/nodejs/bc/ # Путь к рабочей копии проекта на сервере
|
||||
git fetch origin main # Или ваша основная ветка
|
||||
git reset --hard origin/main
|
||||
|
||||
# Если npm install должен выполняться на сервере деплоя, а не на раннере:
|
||||
# if [ -f package.json ]; then
|
||||
# echo "Installing server-side npm dependencies..."
|
||||
# npm install --omit=dev
|
||||
# fi
|
||||
|
||||
echo "Restarting PM2 process for bc..."
|
||||
pm2 restart bc # Имя или ID вашего bc приложения в PM2
|
||||
echo "Deployment for bc finished."
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.env
|
||||
*.log
|
||||
.idea/
|
||||
node_modules/
|
||||
1.bat
|
1
bc
Submodule
1
bc
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 1cfb69af0a0a5c188661b1ba5fe60d8d4940c7e2
|
1552
package-lock.json
generated
Normal file
1552
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.5.0",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.14.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
}
|
BIN
public/images/almagest_avatar.webp
Normal file
BIN
public/images/almagest_avatar.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 402 KiB |
BIN
public/images/balard_avatar.jpg
Normal file
BIN
public/images/balard_avatar.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
public/images/elena_avatar.gif
Normal file
BIN
public/images/elena_avatar.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
BIN
public/images/elena_avatar.jpg
Normal file
BIN
public/images/elena_avatar.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
BIN
public/images/elena_avatar.webp
Normal file
BIN
public/images/elena_avatar.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 277 KiB |
212
public/js/auth.js
Normal file
212
public/js/auth.js
Normal file
@ -0,0 +1,212 @@
|
||||
// /public/js/auth.js
|
||||
|
||||
// Эта функция будет вызвана из main.js и получит необходимые зависимости
|
||||
export function initAuth(dependencies) {
|
||||
console.log('[Auth.js] initAuth called. Dependencies received:', !!dependencies); // <--- ДОБАВЛЕНО
|
||||
|
||||
const { socket, clientState, ui } = dependencies;
|
||||
const { loginForm, registerForm, logoutButton } = ui.elements;
|
||||
console.log('[Auth.js DOM Check] loginForm in initAuth:', loginForm); // <--- ДОБАВЛЕНО
|
||||
console.log('[Auth.js DOM Check] registerForm in initAuth:', registerForm); // <--- ДОБАВЛЕНО
|
||||
console.log('[Auth.js DOM Check] logoutButton in initAuth:', logoutButton); // <--- ДОБАВЛЕНО
|
||||
|
||||
const getApiUrl = (path) => `${window.location.origin}${base_path}${path}`;
|
||||
console.log('[Auth.js] API URLs will be relative to:', window.location.origin); // <--- ДОБАВЛЕНО
|
||||
|
||||
const JWT_TOKEN_KEY = 'jwtToken';
|
||||
|
||||
async function handleAuthResponse(response, formType) {
|
||||
console.log(`[Auth.js handleAuthResponse] Handling response for form: ${formType}. Response status: ${response.status}`); // <--- ДОБАВЛЕНО
|
||||
const regButton = registerForm ? registerForm.querySelector('button') : null;
|
||||
const loginButton = loginForm ? loginForm.querySelector('button') : null;
|
||||
|
||||
try {
|
||||
const data = await response.json();
|
||||
console.log(`[Auth.js handleAuthResponse] Parsed data for ${formType}:`, data); // <--- ДОБАВЛЕНО
|
||||
|
||||
if (response.ok && data.success && data.token) {
|
||||
console.log(`[Auth.js handleAuthResponse] ${formType} successful. Token received.`); // <--- ДОБАВЛЕНО
|
||||
localStorage.setItem(JWT_TOKEN_KEY, data.token);
|
||||
|
||||
clientState.isLoggedIn = true;
|
||||
clientState.loggedInUsername = data.username;
|
||||
clientState.myUserId = data.userId;
|
||||
console.log('[Auth.js handleAuthResponse] Client state updated:', JSON.parse(JSON.stringify(clientState))); // <--- ДОБАВЛЕНО
|
||||
|
||||
|
||||
ui.setAuthMessage('');
|
||||
ui.showGameSelectionScreen(data.username);
|
||||
|
||||
console.log('[Auth.js handleAuthResponse] Disconnecting and reconnecting socket with new token.'); // <--- ДОБАВЛЕНО
|
||||
if (socket.connected) {
|
||||
socket.disconnect();
|
||||
}
|
||||
socket.auth = { token: data.token };
|
||||
socket.connect();
|
||||
|
||||
} else {
|
||||
console.warn(`[Auth.js handleAuthResponse] ${formType} failed or token missing. Message: ${data.message}`); // <--- ДОБАВЛЕНО
|
||||
clientState.isLoggedIn = false;
|
||||
clientState.loggedInUsername = '';
|
||||
clientState.myUserId = null;
|
||||
localStorage.removeItem(JWT_TOKEN_KEY);
|
||||
ui.setAuthMessage(data.message || 'Ошибка сервера.', true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Auth.js handleAuthResponse] Error processing ${formType} response JSON or other:`, error); // <--- ДОБАВЛЕНО
|
||||
clientState.isLoggedIn = false;
|
||||
clientState.loggedInUsername = '';
|
||||
clientState.myUserId = null;
|
||||
localStorage.removeItem(JWT_TOKEN_KEY);
|
||||
ui.setAuthMessage('Произошла ошибка сети или ответа сервера. Попробуйте снова.', true);
|
||||
} finally {
|
||||
console.log(`[Auth.js handleAuthResponse] Re-enabling buttons for ${formType}.`); // <--- ДОБАВЛЕНО
|
||||
if (regButton) regButton.disabled = false;
|
||||
if (loginButton) loginButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Обработчики событий DOM ---
|
||||
if (registerForm) {
|
||||
console.log('[Auth.js] Attaching submit listener to registerForm.'); // <--- ДОБАВЛЕНО
|
||||
registerForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
console.log('[Auth.js] Register form submitted.'); // <--- ДОБАВЛЕНО
|
||||
|
||||
const usernameInput = document.getElementById('register-username');
|
||||
const passwordInput = document.getElementById('register-password');
|
||||
|
||||
if (!usernameInput || !passwordInput) {
|
||||
console.error('[Auth.js] Register form username or password input not found!'); // <--- ДОБАВЛЕНО
|
||||
return;
|
||||
}
|
||||
|
||||
const username = usernameInput.value;
|
||||
const password = passwordInput.value;
|
||||
console.log(`[Auth.js] Attempting to register with username: "${username}", password length: ${password.length}`); // <--- ДОБАВЛЕНО
|
||||
|
||||
const regButton = registerForm.querySelector('button');
|
||||
const loginButton = loginForm ? loginForm.querySelector('button') : null;
|
||||
if (regButton) regButton.disabled = true;
|
||||
if (loginButton) loginButton.disabled = true; // Блокируем обе кнопки на время запроса
|
||||
|
||||
ui.setAuthMessage('Регистрация...');
|
||||
const apiUrl = getApiUrl('/auth/register');
|
||||
console.log('[Auth.js] Sending register request to:', apiUrl); // <--- ДОБАВЛЕНО
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
console.log('[Auth.js] Received response from register request.'); // <--- ДОБАВЛЕНО
|
||||
await handleAuthResponse(response, 'register');
|
||||
if (response.ok && clientState.isLoggedIn && registerForm) {
|
||||
console.log('[Auth.js] Registration successful, resetting register form.'); // <--- ДОБАВЛЕНО
|
||||
registerForm.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth.js] Network error during registration fetch:', error); // <--- ДОБАВЛЕНО
|
||||
ui.setAuthMessage('Ошибка сети при регистрации. Пожалуйста, проверьте ваше подключение.', true);
|
||||
if (regButton) regButton.disabled = false;
|
||||
if (loginButton) loginButton.disabled = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('[Auth.js] registerForm element not found, listener not attached.'); // <--- ДОБАВЛЕНО
|
||||
}
|
||||
|
||||
if (loginForm) {
|
||||
console.log('[Auth.js] Attaching submit listener to loginForm.'); // <--- ДОБАВЛЕНО
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
console.log('[Auth.js] Login form submitted.'); // <--- ДОБАВЛЕНО
|
||||
|
||||
const usernameInput = document.getElementById('login-username');
|
||||
const passwordInput = document.getElementById('login-password');
|
||||
|
||||
if (!usernameInput || !passwordInput) {
|
||||
console.error('[Auth.js] Login form username or password input not found!'); // <--- ДОБАВЛЕНО
|
||||
return;
|
||||
}
|
||||
|
||||
const username = usernameInput.value;
|
||||
const password = passwordInput.value;
|
||||
console.log(`[Auth.js] Attempting to login with username: "${username}", password length: ${password.length}`); // <--- ДОБАВЛЕНО
|
||||
|
||||
const loginButton = loginForm.querySelector('button');
|
||||
const regButton = registerForm ? registerForm.querySelector('button') : null;
|
||||
if (loginButton) loginButton.disabled = true;
|
||||
if (regButton) regButton.disabled = true;
|
||||
|
||||
ui.setAuthMessage('Вход...');
|
||||
const apiUrl = getApiUrl('/auth/login');
|
||||
console.log('[Auth.js] Sending login request to:', apiUrl); // <--- ДОБАВЛЕНО
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
console.log('[Auth.js] Received response from login request.'); // <--- ДОБАВЛЕНО
|
||||
await handleAuthResponse(response, 'login');
|
||||
// Форма логина обычно не сбрасывается или перенаправляется немедленно,
|
||||
// это делает showGameSelectionScreen
|
||||
} catch (error) {
|
||||
console.error('[Auth.js] Network error during login fetch:', error); // <--- ДОБАВЛЕНО
|
||||
ui.setAuthMessage('Ошибка сети при входе. Пожалуйста, проверьте ваше подключение.', true);
|
||||
if (loginButton) loginButton.disabled = false;
|
||||
if (regButton) regButton.disabled = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('[Auth.js] loginForm element not found, listener not attached.'); // <--- ДОБАВЛЕНО
|
||||
}
|
||||
|
||||
if (logoutButton) {
|
||||
console.log('[Auth.js] Attaching click listener to logoutButton.'); // <--- ДОБАВЛЕНО
|
||||
logoutButton.addEventListener('click', () => {
|
||||
console.log('[Auth.js] Logout button clicked.'); // <--- ДОБАВЛЕНО
|
||||
logoutButton.disabled = true;
|
||||
|
||||
if (clientState.isLoggedIn && clientState.isInGame && clientState.currentGameId) {
|
||||
if (clientState.currentGameState &&
|
||||
clientState.currentGameState.gameMode === 'pvp' &&
|
||||
!clientState.currentGameState.isGameOver) {
|
||||
console.log('[Auth.js] Player is in an active PvP game. Emitting playerSurrender.');
|
||||
socket.emit('playerSurrender');
|
||||
}
|
||||
else if (clientState.currentGameState &&
|
||||
clientState.currentGameState.gameMode === 'ai' &&
|
||||
!clientState.currentGameState.isGameOver) {
|
||||
console.log('[Auth.js] Player is in an active AI game. Emitting leaveAiGame.');
|
||||
socket.emit('leaveAiGame');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Auth.js] Removing JWT token from localStorage.'); // <--- ДОБАВЛЕНО
|
||||
localStorage.removeItem(JWT_TOKEN_KEY);
|
||||
|
||||
clientState.isLoggedIn = false;
|
||||
clientState.loggedInUsername = '';
|
||||
clientState.myUserId = null;
|
||||
console.log('[Auth.js] Client state reset for logout.'); // <--- ДОБАВЛЕНО
|
||||
|
||||
ui.showAuthScreen();
|
||||
ui.setAuthMessage("Вы успешно вышли из системы.");
|
||||
|
||||
console.log('[Auth.js] Disconnecting and reconnecting socket after logout.'); // <--- ДОБАВЛЕНО
|
||||
if (socket.connected) {
|
||||
socket.disconnect();
|
||||
}
|
||||
socket.auth = { token: null };
|
||||
socket.connect(); // Это вызовет 'connect' в main.js, который затем вызовет showAuthScreen
|
||||
});
|
||||
} else {
|
||||
console.warn('[Auth.js] logoutButton element not found, listener not attached.'); // <--- ДОБАВЛЕНО
|
||||
}
|
||||
console.log('[Auth.js] initAuth finished.'); // <--- ДОБАВЛЕНО
|
||||
}
|
583
public/js/client_del.js
Normal file
583
public/js/client_del.js
Normal file
@ -0,0 +1,583 @@
|
||||
// /public/js/client.js
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const socket = io({
|
||||
// Опции Socket.IO, если нужны
|
||||
// transports: ['websocket'], // Можно попробовать для отладки, если есть проблемы с polling
|
||||
});
|
||||
|
||||
// --- Состояние клиента ---
|
||||
let currentGameState = null;
|
||||
let myPlayerId = null; // Технический ID слота в игре ('player' или 'opponent')
|
||||
let myUserId = null; // ID залогиненного пользователя (из БД)
|
||||
let myCharacterKey = null;
|
||||
let opponentCharacterKey = null;
|
||||
let currentGameId = null;
|
||||
let playerBaseStatsServer = null;
|
||||
let opponentBaseStatsServer = null;
|
||||
let playerAbilitiesServer = null;
|
||||
let opponentAbilitiesServer = null;
|
||||
let isLoggedIn = false;
|
||||
let loggedInUsername = '';
|
||||
let isInGame = false;
|
||||
|
||||
// --- DOM Элементы ---
|
||||
const authSection = document.getElementById('auth-section');
|
||||
const registerForm = document.getElementById('register-form');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const authMessage = document.getElementById('auth-message');
|
||||
const statusContainer = document.getElementById('status-container');
|
||||
const userInfoDiv = document.getElementById('user-info');
|
||||
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 в 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');
|
||||
|
||||
const turnTimerSpan = document.getElementById('turn-timer');
|
||||
const turnTimerContainer = document.getElementById('turn-timer-container');
|
||||
|
||||
// --- Функции управления UI ---
|
||||
function showAuthScreen() {
|
||||
authSection.style.display = 'block';
|
||||
userInfoDiv.style.display = 'none';
|
||||
gameSetupDiv.style.display = 'none';
|
||||
gameWrapper.style.display = 'none';
|
||||
hideGameOverModal();
|
||||
setAuthMessage("Ожидание подключения к серверу...");
|
||||
statusContainer.style.display = 'block';
|
||||
isInGame = false;
|
||||
disableGameControls();
|
||||
resetGameVariables();
|
||||
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = '--';
|
||||
}
|
||||
|
||||
function showGameSelectionScreen(username) {
|
||||
authSection.style.display = 'none';
|
||||
userInfoDiv.style.display = 'block';
|
||||
loggedInUsernameSpan.textContent = username;
|
||||
gameSetupDiv.style.display = 'block';
|
||||
gameWrapper.style.display = 'none';
|
||||
hideGameOverModal();
|
||||
setGameStatusMessage("Выберите режим игры или присоединитесь к существующей.");
|
||||
statusContainer.style.display = 'block';
|
||||
socket.emit('requestPvPGameList');
|
||||
updateAvailableGamesList([]); // Очищаем перед запросом
|
||||
if (gameIdInput) gameIdInput.value = '';
|
||||
const elenaRadio = document.getElementById('char-elena');
|
||||
if (elenaRadio) elenaRadio.checked = true;
|
||||
isInGame = false;
|
||||
disableGameControls();
|
||||
resetGameVariables(); // Сбрасываем игровые переменные при выходе в меню
|
||||
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = '--';
|
||||
enableSetupButtons(); // Включаем кнопки на экране выбора игры
|
||||
}
|
||||
|
||||
function showGameScreen() {
|
||||
hideGameOverModal();
|
||||
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'; // Показываем таймер
|
||||
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;
|
||||
}
|
||||
|
||||
function hideGameOverModal() {
|
||||
const hiddenClass = window.GAME_CONFIG?.CSS_CLASS_HIDDEN || 'hidden';
|
||||
if (gameOverScreen && !gameOverScreen.classList.contains(hiddenClass)) {
|
||||
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';
|
||||
}
|
||||
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)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAuthMessage(message, isError = false) {
|
||||
if (authMessage) {
|
||||
authMessage.textContent = message;
|
||||
authMessage.className = isError ? 'error' : 'success';
|
||||
authMessage.style.display = message ? 'block' : 'none';
|
||||
}
|
||||
if (message && gameStatusMessage) gameStatusMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
function setGameStatusMessage(message, isError = false) {
|
||||
if (gameStatusMessage) {
|
||||
gameStatusMessage.textContent = message;
|
||||
gameStatusMessage.style.display = message ? 'block' : 'none';
|
||||
gameStatusMessage.style.color = isError ? 'var(--damage-color, red)' : 'var(--turn-color, yellow)';
|
||||
if (statusContainer) statusContainer.style.display = message ? 'block' : 'none';
|
||||
}
|
||||
if (message && authMessage) authMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
function getSelectedCharacterKey() {
|
||||
let selectedKey = 'elena';
|
||||
if (pvpCharacterRadios) {
|
||||
pvpCharacterRadios.forEach(radio => { if (radio.checked) selectedKey = radio.value; });
|
||||
}
|
||||
return selectedKey;
|
||||
}
|
||||
|
||||
function enableGameControls(enableAttack = true, enableAbilities = true) {
|
||||
if (attackButton) attackButton.disabled = !enableAttack;
|
||||
if (abilitiesGrid) {
|
||||
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 disableSetupButtons() {
|
||||
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(joinPvPGameButton) joinPvPGameButton.disabled = false;
|
||||
if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = false;
|
||||
// Кнопки в списке игр включаются в updateAvailableGamesList
|
||||
}
|
||||
|
||||
// --- Инициализация обработчиков событий ---
|
||||
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 { /* обработка ошибки/некорректного состояния */ }
|
||||
});
|
||||
function handleAbilityButtonClick(event) {
|
||||
const abilityId = event.currentTarget.dataset.abilityId;
|
||||
if (isLoggedIn && isInGame && currentGameId && abilityId && currentGameState && !currentGameState.isGameOver) {
|
||||
socket.emit('playerAction', { actionType: 'ability', abilityId: abilityId });
|
||||
} else { /* обработка ошибки/некорректного состояния */ }
|
||||
}
|
||||
if (returnToMenuButton) returnToMenuButton.addEventListener('click', () => {
|
||||
if (!isLoggedIn) { showAuthScreen(); return; }
|
||||
returnToMenuButton.disabled = true;
|
||||
resetGameVariables(); isInGame = false; disableGameControls(); hideGameOverModal();
|
||||
showGameSelectionScreen(loggedInUsername); // Возвращаемся на экран выбора
|
||||
// Кнопка включится при следующем показе модалки
|
||||
});
|
||||
|
||||
function initializeAbilityButtons() {
|
||||
// ... (код без изменений, как был)
|
||||
if (!abilitiesGrid || !window.gameUI || !window.GAME_CONFIG) {
|
||||
if (abilitiesGrid) abilitiesGrid.innerHTML = '<p class="placeholder-text">Ошибка загрузки способностей.</p>';
|
||||
return;
|
||||
}
|
||||
abilitiesGrid.innerHTML = '';
|
||||
const config = window.GAME_CONFIG;
|
||||
const abilitiesToDisplay = playerAbilitiesServer;
|
||||
const baseStatsForResource = playerBaseStatsServer;
|
||||
|
||||
if (!abilitiesToDisplay || abilitiesToDisplay.length === 0 || !baseStatsForResource) {
|
||||
abilitiesGrid.innerHTML = '<p class="placeholder-text">Нет доступных способностей.</p>';
|
||||
return;
|
||||
}
|
||||
const resourceName = baseStatsForResource.resourceName || "Ресурс";
|
||||
const abilityButtonClass = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
|
||||
|
||||
abilitiesToDisplay.forEach(ability => {
|
||||
const button = document.createElement('button');
|
||||
button.id = `ability-btn-${ability.id}`;
|
||||
button.classList.add(abilityButtonClass);
|
||||
button.dataset.abilityId = ability.id;
|
||||
let cooldown = ability.cooldown;
|
||||
let cooldownText = (typeof cooldown === 'number' && cooldown > 0) ? ` (КД: ${cooldown} х.)` : "";
|
||||
let title = `${ability.name} (${ability.cost} ${resourceName})${cooldownText} - ${ability.description || 'Нет описания'}`;
|
||||
button.setAttribute('title', title);
|
||||
const nameSpan = document.createElement('span'); nameSpan.classList.add('ability-name'); nameSpan.textContent = ability.name; button.appendChild(nameSpan);
|
||||
const descSpan = document.createElement('span'); descSpan.classList.add('ability-desc'); descSpan.textContent = `(${ability.cost} ${resourceName})`; button.appendChild(descSpan);
|
||||
const cdDisplay = document.createElement('span'); cdDisplay.classList.add('ability-cooldown-display'); cdDisplay.style.display = 'none'; button.appendChild(cdDisplay);
|
||||
button.addEventListener('click', handleAbilityButtonClick);
|
||||
abilitiesGrid.appendChild(button);
|
||||
});
|
||||
const placeholder = abilitiesGrid.querySelector('.placeholder-text');
|
||||
if (placeholder) placeholder.remove();
|
||||
}
|
||||
|
||||
function updateAvailableGamesList(games) {
|
||||
if (!availableGamesDiv) return;
|
||||
availableGamesDiv.innerHTML = '<h3>Доступные PvP игры:</h3>';
|
||||
if (games && games.length > 0) {
|
||||
const ul = document.createElement('ul');
|
||||
games.forEach(game => {
|
||||
if (game && game.id) {
|
||||
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;
|
||||
|
||||
// === ИЗМЕНЕНИЕ: Деактивация кнопки "Присоединиться" для своих игр ===
|
||||
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 (e.target.disabled) return; // Не обрабатывать клик по отключенной кнопке
|
||||
disableSetupButtons();
|
||||
socket.emit('joinGame', { gameId: e.target.dataset.gameId });
|
||||
});
|
||||
li.appendChild(joinBtn);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
});
|
||||
availableGamesDiv.appendChild(ul);
|
||||
} else {
|
||||
availableGamesDiv.innerHTML += '<p>Нет доступных игр. Создайте свою!</p>';
|
||||
}
|
||||
enableSetupButtons(); // Включаем основные кнопки создания/поиска
|
||||
}
|
||||
|
||||
|
||||
// --- Обработчики событий Socket.IO ---
|
||||
socket.on('connect', () => {
|
||||
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;
|
||||
});
|
||||
|
||||
socket.on('loginResponse', (data) => {
|
||||
setAuthMessage(data.message, !data.success);
|
||||
if (data.success) {
|
||||
isLoggedIn = true;
|
||||
loggedInUsername = data.username;
|
||||
myUserId = data.userId; // === ИЗМЕНЕНИЕ: Сохраняем ID пользователя ===
|
||||
setAuthMessage("");
|
||||
showGameSelectionScreen(data.username);
|
||||
} else {
|
||||
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/ended:', data?.message);
|
||||
resetGameVariables(); isInGame = false; disableGameControls(); hideGameOverModal();
|
||||
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = '--';
|
||||
|
||||
if (isLoggedIn) {
|
||||
showGameSelectionScreen(loggedInUsername);
|
||||
setGameStatusMessage(data?.message || "Активная игровая сессия не найдена.");
|
||||
} else {
|
||||
showAuthScreen();
|
||||
setAuthMessage(data?.message || "Пожалуйста, войдите.");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('[Client] Disconnected:', reason);
|
||||
setGameStatusMessage(`Отключено: ${reason}. Обновите страницу.`, true);
|
||||
disableGameControls();
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.';
|
||||
// Не сбрасываем isLoggedIn, чтобы при переподключении можно было восстановить сессию
|
||||
});
|
||||
|
||||
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')) {
|
||||
opponentPanel.classList.remove('dissolving');
|
||||
opponentPanel.style.opacity = '1'; opponentPanel.style.transform = 'scale(1) translateY(0)';
|
||||
}
|
||||
}
|
||||
currentGameId = data.gameId; myPlayerId = data.yourPlayerId; currentGameState = data.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;
|
||||
|
||||
showGameScreen(); initializeAbilityButtons();
|
||||
if (window.gameUI?.uiElements?.log?.list) window.gameUI.uiElements.log.list.innerHTML = '';
|
||||
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(); setGameStatusMessage("");
|
||||
});
|
||||
|
||||
// Используется для восстановления состояния уже идущей игры
|
||||
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 = '';
|
||||
}
|
||||
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) 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) {
|
||||
if (!currentGameId && isLoggedIn) socket.emit('requestGameState');
|
||||
else if (!isLoggedIn) showAuthScreen();
|
||||
return;
|
||||
}
|
||||
const playerWon = data.winnerId === myPlayerId;
|
||||
currentGameState = data.finalGameState; 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));
|
||||
}
|
||||
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 ? "Вы победили!" : "Вы проиграли."));
|
||||
if (window.gameUI?.updateTurnTimerDisplay) { // Обновляем UI таймера
|
||||
window.gameUI.updateTurnTimerDisplay(null, false, currentGameState?.gameMode); // Передаем null, чтобы показать "Конец" или скрыть
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('waitingForOpponent', () => {
|
||||
if (!isLoggedIn) return;
|
||||
setGameStatusMessage("Ожидание присоединения оппонента...");
|
||||
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) return;
|
||||
const name = data.disconnectedCharacterName || 'Противник';
|
||||
if (window.gameUI?.addToLog) window.gameUI.addToLog(`🔌 Противник (${name}) отключился.`, 'system');
|
||||
if (currentGameState && !currentGameState.isGameOver) {
|
||||
setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true);
|
||||
disableGameControls();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('gameError', (data) => {
|
||||
console.error('[Client] Server error:', data.message);
|
||||
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);
|
||||
if (isLoggedIn) enableSetupButtons(); // Если на экране выбора игры, включаем кнопки
|
||||
else { // Если на экране логина
|
||||
if(registerForm) registerForm.querySelector('button').disabled = false;
|
||||
if(loginForm) loginForm.querySelector('button').disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('availablePvPGamesList', (games) => {
|
||||
if (!isLoggedIn) return;
|
||||
updateAvailableGamesList(games);
|
||||
});
|
||||
|
||||
socket.on('noPendingGamesFound', (data) => { // Вызывается, когда создается новая игра после поиска
|
||||
if (!isLoggedIn) return;
|
||||
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 (window.gameUI?.updateTurnTimerDisplay && !currentGameState?.isGameOver) { // Только если не game over
|
||||
window.gameUI.updateTurnTimerDisplay(null, false, currentGameState?.gameMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') {
|
||||
// Определяем, является ли текущий ход ходом этого клиента
|
||||
const isMyActualTurn = myPlayerId && currentGameState.isPlayerTurn === (myPlayerId === GAME_CONFIG.PLAYER_ID);
|
||||
window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyActualTurn, currentGameState.gameMode);
|
||||
}
|
||||
});
|
||||
|
||||
showAuthScreen(); // Начальный экран
|
||||
});
|
256
public/js/gameSetup.js
Normal file
256
public/js/gameSetup.js
Normal file
@ -0,0 +1,256 @@
|
||||
// /public/js/gameSetup.js
|
||||
|
||||
// ПРИМЕРНЫЕ РЕАЛИЗАЦИИ ВСПОМОГАТЕЛЬНЫХ ФУНКЦИЙ (лучше передавать из main.js)
|
||||
/*
|
||||
function parseJwtPayloadForValidation(token) {
|
||||
try {
|
||||
if (typeof token !== 'string') return null;
|
||||
const base64Url = token.split('.')[1];
|
||||
if (!base64Url) return null;
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenValid(token) {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const decodedToken = parseJwtPayloadForValidation(token);
|
||||
if (!decodedToken || !decodedToken.exp) {
|
||||
localStorage.removeItem('jwtToken');
|
||||
return false;
|
||||
}
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
if (decodedToken.exp < currentTimeInSeconds) {
|
||||
localStorage.removeItem('jwtToken');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
*/
|
||||
// Конец примерных реализаций
|
||||
|
||||
export function initGameSetup(dependencies) {
|
||||
const { socket, clientState, ui, utils } = dependencies; // Предполагаем, что utils.isTokenValid передается
|
||||
const {
|
||||
createAIGameButton, createPvPGameButton, joinPvPGameButton,
|
||||
findRandomPvPGameButton, gameIdInput, availableGamesDiv, pvpCharacterRadios
|
||||
} = ui.elements;
|
||||
|
||||
// Получаем функцию isTokenValid либо из utils, либо используем локальную, если она определена выше
|
||||
const checkTokenValidity = utils?.isTokenValid || window.isTokenValidFunction; // window.isTokenValidFunction - если вы определили ее глобально/локально
|
||||
|
||||
if (typeof checkTokenValidity !== 'function') {
|
||||
console.error("[GameSetup.js] CRITICAL: isTokenValid function is not available. Auth checks will fail.");
|
||||
// Можно добавить фоллбэк или аварийное поведение
|
||||
}
|
||||
|
||||
|
||||
// --- Вспомогательные функции ---
|
||||
function getSelectedCharacterKey() {
|
||||
let selectedKey = 'elena'; // Значение по умолчанию
|
||||
if (pvpCharacterRadios) {
|
||||
pvpCharacterRadios.forEach(radio => {
|
||||
if (radio.checked) {
|
||||
selectedKey = radio.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
return selectedKey;
|
||||
}
|
||||
|
||||
function updateAvailableGamesList(games) {
|
||||
if (!availableGamesDiv) return;
|
||||
availableGamesDiv.innerHTML = '<h3>Доступные PvP игры:</h3>';
|
||||
if (games && games.length > 0) {
|
||||
const ul = document.createElement('ul');
|
||||
games.forEach(game => {
|
||||
if (game && game.id) {
|
||||
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;
|
||||
|
||||
if (clientState.isLoggedIn && clientState.myUserId && game.ownerIdentifier === clientState.myUserId) {
|
||||
joinBtn.disabled = true;
|
||||
joinBtn.title = "Вы не можете присоединиться к своей же ожидающей игре.";
|
||||
} else {
|
||||
joinBtn.disabled = false;
|
||||
}
|
||||
|
||||
joinBtn.addEventListener('click', (e) => {
|
||||
// --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ ---
|
||||
if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) {
|
||||
if (typeof ui.redirectToLogin === 'function') {
|
||||
ui.redirectToLogin('Для присоединения к игре необходимо войти или обновить сессию.');
|
||||
} else {
|
||||
alert('Для присоединения к игре необходимо войти или обновить сессию.');
|
||||
window.location.href = '/'; // Фоллбэк
|
||||
}
|
||||
return;
|
||||
}
|
||||
// --- КОНЕЦ ПРОВЕРКИ ТОКЕНА ---
|
||||
|
||||
if (e.target.disabled) return;
|
||||
|
||||
ui.disableSetupButtons();
|
||||
socket.emit('joinGame', { gameId: e.target.dataset.gameId });
|
||||
ui.setGameStatusMessage(`Присоединение к игре ${e.target.dataset.gameId.substring(0, 8)}...`);
|
||||
});
|
||||
li.appendChild(joinBtn);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
});
|
||||
availableGamesDiv.appendChild(ul);
|
||||
} else {
|
||||
availableGamesDiv.innerHTML += '<p>Нет доступных игр. Создайте свою!</p>';
|
||||
}
|
||||
ui.enableSetupButtons();
|
||||
}
|
||||
|
||||
// --- Обработчики событий DOM ---
|
||||
if (createAIGameButton) {
|
||||
createAIGameButton.addEventListener('click', () => {
|
||||
// --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ ---
|
||||
if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) {
|
||||
if (typeof ui.redirectToLogin === 'function') {
|
||||
ui.redirectToLogin('Для создания игры необходимо войти или обновить сессию.');
|
||||
} else {
|
||||
alert('Для создания игры необходимо войти или обновить сессию.');
|
||||
window.location.href = '/'; // Фоллбэк
|
||||
}
|
||||
return;
|
||||
}
|
||||
// --- КОНЕЦ ПРОВЕРКИ ТОКЕНА ---
|
||||
|
||||
ui.disableSetupButtons();
|
||||
socket.emit('createGame', { mode: 'ai', characterKey: 'elena' }); // Персонаж для AI может быть фиксированным
|
||||
ui.setGameStatusMessage("Создание игры против AI...");
|
||||
});
|
||||
}
|
||||
|
||||
if (createPvPGameButton) {
|
||||
createPvPGameButton.addEventListener('click', () => {
|
||||
// --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ ---
|
||||
if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) {
|
||||
if (typeof ui.redirectToLogin === 'function') {
|
||||
ui.redirectToLogin('Для создания PvP игры необходимо войти или обновить сессию.');
|
||||
} else {
|
||||
alert('Для создания PvP игры необходимо войти или обновить сессию.');
|
||||
window.location.href = '/'; // Фоллбэк
|
||||
}
|
||||
return;
|
||||
}
|
||||
// --- КОНЕЦ ПРОВЕРКИ ТОКЕНА ---
|
||||
|
||||
ui.disableSetupButtons();
|
||||
const characterKey = getSelectedCharacterKey();
|
||||
socket.emit('createGame', { mode: 'pvp', characterKey: characterKey });
|
||||
ui.setGameStatusMessage("Создание PvP игры...");
|
||||
});
|
||||
}
|
||||
|
||||
if (joinPvPGameButton) {
|
||||
joinPvPGameButton.addEventListener('click', () => {
|
||||
// --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ ---
|
||||
if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) {
|
||||
if (typeof ui.redirectToLogin === 'function') {
|
||||
ui.redirectToLogin('Для присоединения к игре необходимо войти или обновить сессию.');
|
||||
} else {
|
||||
alert('Для присоединения к игре необходимо войти или обновить сессию.');
|
||||
window.location.href = '/'; // Фоллбэк
|
||||
}
|
||||
return;
|
||||
}
|
||||
// --- КОНЕЦ ПРОВЕРКИ ТОКЕНА ---
|
||||
|
||||
const gameId = gameIdInput ? gameIdInput.value.trim() : '';
|
||||
if (gameId) {
|
||||
ui.disableSetupButtons();
|
||||
socket.emit('joinGame', { gameId: gameId });
|
||||
ui.setGameStatusMessage(`Присоединение к игре ${gameId}...`);
|
||||
} else {
|
||||
ui.setGameStatusMessage("Введите ID игры, чтобы присоединиться.", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (findRandomPvPGameButton) {
|
||||
findRandomPvPGameButton.addEventListener('click', () => {
|
||||
// --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ ---
|
||||
if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) {
|
||||
if (typeof ui.redirectToLogin === 'function') {
|
||||
ui.redirectToLogin('Для поиска игры необходимо войти или обновить сессию.');
|
||||
} else {
|
||||
alert('Для поиска игры необходимо войти или обновить сессию.');
|
||||
window.location.href = '/'; // Фоллбэк
|
||||
}
|
||||
return;
|
||||
}
|
||||
// --- КОНЕЦ ПРОВЕРКИ ТОКЕНА ---
|
||||
|
||||
ui.disableSetupButtons();
|
||||
const characterKey = getSelectedCharacterKey();
|
||||
socket.emit('findRandomGame', { characterKey: characterKey });
|
||||
ui.setGameStatusMessage("Поиск случайной PvP игры...");
|
||||
});
|
||||
}
|
||||
|
||||
// --- Обработчики событий Socket.IO ---
|
||||
|
||||
socket.on('gameCreated', (data) => {
|
||||
if (!clientState.isLoggedIn) return;
|
||||
|
||||
console.log('[GameSetup] Game created by this client:', data);
|
||||
clientState.currentGameId = data.gameId;
|
||||
clientState.myPlayerId = data.yourPlayerId;
|
||||
ui.updateGlobalWindowVariablesForUI();
|
||||
});
|
||||
|
||||
socket.on('availablePvPGamesList', (games) => {
|
||||
// Проверяем, залогинен ли пользователь, ПЕРЕД обновлением списка.
|
||||
// Если пользователь разлогинился, а список пришел, его не нужно показывать на экране логина.
|
||||
if (!clientState.isLoggedIn) {
|
||||
if (availableGamesDiv) availableGamesDiv.innerHTML = ''; // Очищаем, если пользователь не залогинен
|
||||
return;
|
||||
}
|
||||
updateAvailableGamesList(games);
|
||||
});
|
||||
|
||||
socket.on('noPendingGamesFound', (data) => {
|
||||
if (!clientState.isLoggedIn) return;
|
||||
|
||||
ui.setGameStatusMessage(data.message || "Свободных игр не найдено. Создана новая для вас. Ожидание оппонента...");
|
||||
updateAvailableGamesList([]);
|
||||
|
||||
if (data.gameId) clientState.currentGameId = data.gameId;
|
||||
if (data.yourPlayerId) clientState.myPlayerId = data.yourPlayerId;
|
||||
ui.updateGlobalWindowVariablesForUI();
|
||||
|
||||
clientState.isInGame = false;
|
||||
ui.disableSetupButtons();
|
||||
|
||||
if (window.gameUI?.updateTurnTimerDisplay) {
|
||||
window.gameUI.updateTurnTimerDisplay(null, false, 'pvp');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('waitingForOpponent', () => {
|
||||
if (!clientState.isLoggedIn) return;
|
||||
|
||||
ui.setGameStatusMessage("Ожидание присоединения оппонента...");
|
||||
ui.disableSetupButtons();
|
||||
|
||||
if (window.gameUI?.updateTurnTimerDisplay) {
|
||||
window.gameUI.updateTurnTimerDisplay(null, false, 'pvp');
|
||||
}
|
||||
});
|
||||
}
|
444
public/js/gameplay.js
Normal file
444
public/js/gameplay.js
Normal file
@ -0,0 +1,444 @@
|
||||
// /public/js/gameplay.js
|
||||
|
||||
export function initGameplay(dependencies) {
|
||||
const { socket, clientState, ui } = dependencies;
|
||||
const { returnToMenuButton } = ui.elements;
|
||||
|
||||
const attackButton = document.getElementById('button-attack');
|
||||
const abilitiesGrid = document.getElementById('abilities-grid');
|
||||
|
||||
// Инициализируем флаг в clientState, если он еще не существует (лучше делать в main.js)
|
||||
if (typeof clientState.isActionInProgress === 'undefined') {
|
||||
clientState.isActionInProgress = false;
|
||||
}
|
||||
|
||||
// --- Вспомогательные функции ---
|
||||
function enableGameControls(enableAttack = true, enableAbilities = true) {
|
||||
// console.log(`[GP] enableGameControls called. enableAttack: ${enableAttack}, enableAbilities: ${enableAbilities}, isActionInProgress: ${clientState.isActionInProgress}`);
|
||||
if (clientState.isActionInProgress) {
|
||||
if (attackButton) attackButton.disabled = true;
|
||||
if (abilitiesGrid) {
|
||||
const config = window.GAME_CONFIG || {};
|
||||
const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
|
||||
abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = true; });
|
||||
}
|
||||
// console.log(`[GP] Action in progress, controls remain disabled.`);
|
||||
if (window.gameUI?.updateUI) requestAnimationFrame(() => window.gameUI.updateUI());
|
||||
return;
|
||||
}
|
||||
|
||||
if (attackButton) attackButton.disabled = !enableAttack;
|
||||
if (abilitiesGrid) {
|
||||
const config = window.GAME_CONFIG || {};
|
||||
const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
|
||||
abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = !enableAbilities; });
|
||||
}
|
||||
// console.log(`[GP] Controls set. Attack disabled: ${attackButton ? attackButton.disabled : 'N/A'}`);
|
||||
if (window.gameUI?.updateUI) {
|
||||
requestAnimationFrame(() => window.gameUI.updateUI()); // Обновляем UI, чтобы 반영 반영反映 изменения в disabled
|
||||
}
|
||||
}
|
||||
|
||||
function disableGameControls() {
|
||||
// console.log(`[GP] disableGameControls called.`);
|
||||
if (attackButton) attackButton.disabled = true;
|
||||
if (abilitiesGrid) {
|
||||
const config = window.GAME_CONFIG || {};
|
||||
const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
|
||||
abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = true; });
|
||||
}
|
||||
if (window.gameUI?.updateUI) {
|
||||
requestAnimationFrame(() => window.gameUI.updateUI()); // Обновляем UI, чтобы 반영 반영反映 изменения в disabled
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAbilityButtons() {
|
||||
if (!abilitiesGrid || !window.gameUI || !window.GAME_CONFIG) {
|
||||
if (abilitiesGrid) abilitiesGrid.innerHTML = '<p class="placeholder-text">Ошибка загрузки способностей.</p>';
|
||||
return;
|
||||
}
|
||||
abilitiesGrid.innerHTML = '';
|
||||
const config = window.GAME_CONFIG;
|
||||
const abilitiesToDisplay = clientState.playerAbilitiesServer;
|
||||
const baseStatsForResource = clientState.playerBaseStatsServer;
|
||||
|
||||
if (!abilitiesToDisplay || abilitiesToDisplay.length === 0 || !baseStatsForResource) {
|
||||
abilitiesGrid.innerHTML = '<p class="placeholder-text">Нет доступных способностей.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceName = baseStatsForResource.resourceName || "Ресурс";
|
||||
const abilityButtonClass = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button';
|
||||
|
||||
abilitiesToDisplay.forEach(ability => {
|
||||
const button = document.createElement('button');
|
||||
button.id = `ability-btn-${ability.id}`;
|
||||
button.classList.add(abilityButtonClass);
|
||||
button.dataset.abilityId = ability.id;
|
||||
let cooldown = ability.cooldown;
|
||||
let cooldownText = (typeof cooldown === 'number' && cooldown > 0) ? ` (КД: ${cooldown} х.)` : "";
|
||||
let title = `${ability.name} (${ability.cost} ${resourceName})${cooldownText} - ${ability.description || 'Нет описания'}`;
|
||||
button.setAttribute('title', title);
|
||||
const nameSpan = document.createElement('span'); nameSpan.classList.add('ability-name'); nameSpan.textContent = ability.name; button.appendChild(nameSpan);
|
||||
const descSpan = document.createElement('span'); descSpan.classList.add('ability-desc'); descSpan.textContent = `(${ability.cost} ${resourceName})`; button.appendChild(descSpan);
|
||||
const cdDisplay = document.createElement('span'); cdDisplay.classList.add('ability-cooldown-display'); cdDisplay.style.display = 'none'; button.appendChild(cdDisplay);
|
||||
button.addEventListener('click', handleAbilityButtonClick);
|
||||
abilitiesGrid.appendChild(button);
|
||||
});
|
||||
const placeholder = abilitiesGrid.querySelector('.placeholder-text');
|
||||
if (placeholder) placeholder.remove();
|
||||
}
|
||||
|
||||
function handleAbilityButtonClick(event) {
|
||||
const abilityId = event.currentTarget.dataset.abilityId;
|
||||
const username = clientState.loggedInUsername || 'N/A';
|
||||
console.log(`[CLIENT ${username}] handleAbilityButtonClick. AbilityID: ${abilityId}, isActionInProgress: ${clientState.isActionInProgress}`);
|
||||
|
||||
if (clientState.isLoggedIn &&
|
||||
clientState.isInGame &&
|
||||
clientState.currentGameId &&
|
||||
abilityId &&
|
||||
clientState.currentGameState &&
|
||||
!clientState.currentGameState.isGameOver &&
|
||||
!clientState.isActionInProgress) { // <--- ПРОВЕРКА ФЛАГА
|
||||
|
||||
console.log(`[CLIENT ${username}] Emitting playerAction (ability: ${abilityId}). Setting isActionInProgress = true.`);
|
||||
clientState.isActionInProgress = true; // <--- УСТАНОВКА ФЛАГА
|
||||
disableGameControls(); // <--- БЛОКИРОВКА СРАЗУ
|
||||
socket.emit('playerAction', { actionType: 'ability', abilityId: abilityId });
|
||||
} else {
|
||||
console.warn(`[CLIENT ${username}] Cannot perform ability action. Conditions not met or action in progress. InGame: ${clientState.isInGame}, GameOver: ${clientState.currentGameState?.isGameOver}, ActionInProgress: ${clientState.isActionInProgress}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Обработчики событий DOM ---
|
||||
if (attackButton) {
|
||||
attackButton.addEventListener('click', () => {
|
||||
const username = clientState.loggedInUsername || 'N/A';
|
||||
console.log(`[CLIENT ${username}] Attack button clicked. isActionInProgress: ${clientState.isActionInProgress}`);
|
||||
|
||||
if (clientState.isLoggedIn &&
|
||||
clientState.isInGame &&
|
||||
clientState.currentGameId &&
|
||||
clientState.currentGameState &&
|
||||
!clientState.currentGameState.isGameOver &&
|
||||
!clientState.isActionInProgress) { // <--- ПРОВЕРКА ФЛАГА
|
||||
|
||||
console.log(`[CLIENT ${username}] Emitting playerAction (attack). Setting isActionInProgress = true.`);
|
||||
clientState.isActionInProgress = true; // <--- УСТАНОВКА ФЛАГА
|
||||
disableGameControls(); // <--- БЛОКИРОВКА СРАЗУ
|
||||
socket.emit('playerAction', { actionType: 'attack' });
|
||||
} else {
|
||||
console.warn(`[CLIENT ${username}] Cannot perform attack action. Conditions not met or action in progress. InGame: ${clientState.isInGame}, GameOver: ${clientState.currentGameState?.isGameOver}, ActionInProgress: ${clientState.isActionInProgress}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (returnToMenuButton) {
|
||||
returnToMenuButton.addEventListener('click', () => {
|
||||
if (!clientState.isLoggedIn) {
|
||||
ui.showAuthScreen();
|
||||
return;
|
||||
}
|
||||
returnToMenuButton.disabled = true; // Блокируем сразу, чтобы избежать двойных кликов
|
||||
clientState.isActionInProgress = false; // Сбрасываем на всякий случай, если покидаем игру
|
||||
clientState.isInGame = false;
|
||||
disableGameControls();
|
||||
ui.showGameSelectionScreen(clientState.loggedInUsername);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- ОБЩИЙ ОБРАБОТЧИК ДЛЯ ЗАПУСКА/ВОССТАНОВЛЕНИЯ ИГРЫ ---
|
||||
function handleGameDataReceived(data, eventName = "unknown") {
|
||||
if (!clientState.isLoggedIn) {
|
||||
console.warn(`[CLIENT] handleGameDataReceived (${eventName}) called, but client not logged in. Ignoring.`);
|
||||
return;
|
||||
}
|
||||
const username = clientState.loggedInUsername || 'N/A';
|
||||
console.log(`[CLIENT ${username}] handleGameDataReceived from event: ${eventName}. GameID: ${data.gameId}, YourPlayerID: ${data.yourPlayerId}, GS.isPlayerTurn: ${data.initialGameState?.isPlayerTurn || data.gameState?.isPlayerTurn}`);
|
||||
|
||||
clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА при получении нового полного состояния
|
||||
|
||||
clientState.currentGameId = data.gameId;
|
||||
clientState.myPlayerId = data.yourPlayerId;
|
||||
clientState.currentGameState = data.initialGameState || data.gameState;
|
||||
clientState.playerBaseStatsServer = data.playerBaseStats;
|
||||
clientState.opponentBaseStatsServer = data.opponentBaseStats;
|
||||
clientState.playerAbilitiesServer = data.playerAbilities;
|
||||
clientState.opponentAbilitiesServer = data.opponentAbilities;
|
||||
clientState.myCharacterKey = data.playerBaseStats?.characterKey;
|
||||
clientState.opponentCharacterKey = data.opponentBaseStats?.characterKey;
|
||||
|
||||
if (clientState.currentGameState && !clientState.currentGameState.isGameOver) {
|
||||
clientState.isInGame = true;
|
||||
} else if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
|
||||
clientState.isInGame = false;
|
||||
}
|
||||
|
||||
if (data.clientConfig) {
|
||||
window.GAME_CONFIG = { ...window.GAME_CONFIG, ...data.clientConfig };
|
||||
} else if (!window.GAME_CONFIG) {
|
||||
window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' }; // Базовый конфиг
|
||||
}
|
||||
ui.updateGlobalWindowVariablesForUI();
|
||||
|
||||
const gameWrapperElement = document.querySelector('.game-wrapper');
|
||||
if (clientState.isInGame && clientState.currentGameState && !clientState.currentGameState.isGameOver) {
|
||||
const isGameWrapperVisible = gameWrapperElement && (gameWrapperElement.style.display === 'flex' || getComputedStyle(gameWrapperElement).display === 'flex');
|
||||
if (!isGameWrapperVisible) {
|
||||
ui.showGameScreen();
|
||||
}
|
||||
}
|
||||
|
||||
initializeAbilityButtons();
|
||||
|
||||
if (window.gameUI?.uiElements?.log?.list) {
|
||||
window.gameUI.uiElements.log.list.innerHTML = '';
|
||||
}
|
||||
if (window.gameUI?.addToLog && data.log) {
|
||||
data.log.forEach(logEntry => {
|
||||
window.gameUI.addToLog(logEntry.message, logEntry.type);
|
||||
});
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (window.gameUI?.updateUI) {
|
||||
window.gameUI.updateUI();
|
||||
}
|
||||
if (clientState.isInGame && clientState.currentGameState && !clientState.currentGameState.isGameOver && window.GAME_CONFIG) {
|
||||
const config = window.GAME_CONFIG;
|
||||
const isMyActualTurn = clientState.myPlayerId &&
|
||||
((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) ||
|
||||
(!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID));
|
||||
|
||||
console.log(`[CLIENT ${username}] handleGameDataReceived - Determining controls. isMyActualTurn: ${isMyActualTurn}`);
|
||||
if (isMyActualTurn) {
|
||||
enableGameControls();
|
||||
} else {
|
||||
disableGameControls();
|
||||
}
|
||||
} else if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
|
||||
console.log(`[CLIENT ${username}] handleGameDataReceived - Game is over, disabling controls.`);
|
||||
disableGameControls();
|
||||
}
|
||||
});
|
||||
|
||||
if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
|
||||
// Обработка gameOver уже есть в своем обработчике
|
||||
} else if (eventName === 'gameStarted' || eventName === 'gameState (reconnect)') {
|
||||
console.log(`[CLIENT ${username}] ${eventName} - Clearing game status message because it's a fresh game/state load.`);
|
||||
ui.setGameStatusMessage("");
|
||||
} else {
|
||||
if (clientState.isInGame) {
|
||||
// Если это просто gameStateUpdate, и игра активна, убедимся, что нет сообщения об ожидании
|
||||
const statusMsgElement = document.getElementById('game-status-message');
|
||||
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
|
||||
if (!currentStatusText.toLowerCase().includes("отключился")) { // Не стираем сообщение об отключении оппонента
|
||||
ui.setGameStatusMessage("");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
|
||||
if (window.gameUI?.showGameOver && !document.getElementById('game-over-screen').classList.contains(window.GAME_CONFIG?.CSS_CLASS_HIDDEN || 'hidden')) {
|
||||
// Экран уже показан
|
||||
} else if (window.gameUI?.showGameOver) {
|
||||
let playerWon = false;
|
||||
if (data.winnerId) {
|
||||
playerWon = data.winnerId === clientState.myPlayerId;
|
||||
} else if (clientState.currentGameState.player && clientState.currentGameState.opponent) {
|
||||
// Дополнительная логика определения победителя, если winnerId нет (маловероятно при корректной работе сервера)
|
||||
if (clientState.currentGameState.player.currentHp > 0 && clientState.currentGameState.opponent.currentHp <=0) {
|
||||
playerWon = clientState.myPlayerId === clientState.currentGameState.player.id;
|
||||
} else if (clientState.currentGameState.opponent.currentHp > 0 && clientState.currentGameState.player.currentHp <=0) {
|
||||
playerWon = clientState.myPlayerId === clientState.currentGameState.opponent.id;
|
||||
}
|
||||
}
|
||||
window.gameUI.showGameOver(playerWon, data.reason || "Игра завершена", clientState.opponentCharacterKey || data.loserCharacterKey, { finalGameState: clientState.currentGameState, ...data });
|
||||
}
|
||||
if (returnToMenuButton) returnToMenuButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Обработчики событий Socket.IO ---
|
||||
socket.on('gameStarted', (data) => {
|
||||
handleGameDataReceived(data, 'gameStarted');
|
||||
});
|
||||
|
||||
socket.on('gameState', (data) => {
|
||||
handleGameDataReceived(data, 'gameState (reconnect)');
|
||||
});
|
||||
|
||||
socket.on('gameStateUpdate', (data) => {
|
||||
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
|
||||
const username = clientState.loggedInUsername || 'N/A';
|
||||
console.log(`[CLIENT ${username}] Event: gameStateUpdate. GS.isPlayerTurn: ${data.gameState?.isPlayerTurn}`);
|
||||
|
||||
clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА
|
||||
clientState.currentGameState = data.gameState;
|
||||
ui.updateGlobalWindowVariablesForUI();
|
||||
|
||||
if (window.gameUI?.updateUI) {
|
||||
requestAnimationFrame(() => {
|
||||
window.gameUI.updateUI();
|
||||
if (clientState.isInGame && clientState.currentGameState && !clientState.currentGameState.isGameOver && window.GAME_CONFIG) {
|
||||
const config = window.GAME_CONFIG;
|
||||
const isMyActualTurn = clientState.myPlayerId &&
|
||||
((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) ||
|
||||
(!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID));
|
||||
|
||||
console.log(`[CLIENT ${username}] gameStateUpdate - Determining controls. isMyActualTurn: ${isMyActualTurn}`);
|
||||
if (isMyActualTurn) {
|
||||
enableGameControls();
|
||||
} else {
|
||||
disableGameControls();
|
||||
}
|
||||
|
||||
const statusMsgElement = document.getElementById('game-status-message');
|
||||
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
|
||||
if (!currentStatusText.toLowerCase().includes("отключился")) {
|
||||
ui.setGameStatusMessage("");
|
||||
}
|
||||
|
||||
} else if (clientState.currentGameState && clientState.currentGameState.isGameOver) {
|
||||
console.log(`[CLIENT ${username}] gameStateUpdate - Game is over, disabling controls.`);
|
||||
disableGameControls();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.gameUI?.addToLog && data.log) {
|
||||
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('logUpdate', (data) => {
|
||||
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
|
||||
// const username = clientState.loggedInUsername || 'N/A';
|
||||
// console.log(`[CLIENT ${username}] Event: logUpdate. Logs:`, data.log);
|
||||
if (window.gameUI?.addToLog && data.log) {
|
||||
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('gameOver', (data) => {
|
||||
if (!clientState.isLoggedIn || !clientState.currentGameId || !window.GAME_CONFIG) {
|
||||
if (!clientState.currentGameId && clientState.isLoggedIn) socket.emit('requestGameState');
|
||||
else if (!clientState.isLoggedIn) ui.showAuthScreen();
|
||||
return;
|
||||
}
|
||||
const username = clientState.loggedInUsername || 'N/A';
|
||||
console.log(`[CLIENT ${username}] Event: gameOver. WinnerID: ${data.winnerId}, Reason: ${data.reason}`);
|
||||
|
||||
clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА
|
||||
const playerWon = data.winnerId === clientState.myPlayerId;
|
||||
clientState.currentGameState = data.finalGameState;
|
||||
clientState.isInGame = false;
|
||||
|
||||
ui.updateGlobalWindowVariablesForUI();
|
||||
|
||||
if (window.gameUI?.updateUI) requestAnimationFrame(() => window.gameUI.updateUI());
|
||||
if (window.gameUI?.addToLog && data.log) {
|
||||
data.log.forEach(log => window.gameUI.addToLog(log.message, log.type));
|
||||
}
|
||||
if (window.gameUI?.showGameOver) {
|
||||
const oppKey = clientState.opponentCharacterKey || data.loserCharacterKey;
|
||||
window.gameUI.showGameOver(playerWon, data.reason, oppKey, data);
|
||||
}
|
||||
if (returnToMenuButton) returnToMenuButton.disabled = false;
|
||||
|
||||
if (window.gameUI?.updateTurnTimerDisplay) {
|
||||
window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState?.gameMode);
|
||||
}
|
||||
disableGameControls();
|
||||
});
|
||||
|
||||
socket.on('opponentDisconnected', (data) => {
|
||||
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
|
||||
const username = clientState.loggedInUsername || 'N/A';
|
||||
console.log(`[CLIENT ${username}] Event: opponentDisconnected. PlayerID: ${data.disconnectedPlayerId}`);
|
||||
const name = data.disconnectedCharacterName || clientState.opponentBaseStatsServer?.name || 'Противник';
|
||||
|
||||
if (clientState.currentGameState && !clientState.currentGameState.isGameOver) {
|
||||
ui.setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true);
|
||||
disableGameControls();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('playerReconnected', (data) => { // Обработчик события, что оппонент переподключился
|
||||
if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return;
|
||||
const username = clientState.loggedInUsername || 'N/A';
|
||||
console.log(`[CLIENT ${username}] Event: playerReconnected. PlayerID: ${data.reconnectedPlayerId}, Name: ${data.reconnectedPlayerName}`);
|
||||
// const name = data.reconnectedPlayerName || clientState.opponentBaseStatsServer?.name || 'Противник';
|
||||
|
||||
if (clientState.currentGameState && !clientState.currentGameState.isGameOver) {
|
||||
// Сообщение о переподключении оппонента обычно приходит через 'logUpdate'
|
||||
// Но если нужно немедленно убрать статус "Ожидание...", можно сделать здесь:
|
||||
const statusMsgElement = document.getElementById('game-status-message');
|
||||
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
|
||||
if (currentStatusText.toLowerCase().includes("отключился")) {
|
||||
ui.setGameStatusMessage(""); // Очищаем сообщение об ожидании
|
||||
}
|
||||
// Логика enable/disableGameControls будет вызвана следующим gameStateUpdate или turnTimerUpdate
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
socket.on('turnTimerUpdate', (data) => {
|
||||
if (!clientState.isInGame || !clientState.currentGameState || !window.GAME_CONFIG) {
|
||||
if (window.gameUI?.updateTurnTimerDisplay && clientState.currentGameState && !clientState.currentGameState.isGameOver) {
|
||||
window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState.gameMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientState.currentGameState.isGameOver) {
|
||||
if (window.gameUI?.updateTurnTimerDisplay) {
|
||||
window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState.gameMode);
|
||||
}
|
||||
// disableGameControls() уже должен быть вызван в gameOver
|
||||
return;
|
||||
}
|
||||
// const username = clientState.loggedInUsername || 'N/A';
|
||||
// console.log(`[CLIENT ${username}] Event: turnTimerUpdate. Remaining: ${data.remainingTime}, isPlayerTurnForTimer: ${data.isPlayerTurn}, isPaused: ${data.isPaused}`);
|
||||
|
||||
|
||||
if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') {
|
||||
const config = window.GAME_CONFIG;
|
||||
const isMyTurnForTimer = clientState.myPlayerId && clientState.currentGameState &&
|
||||
((data.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) || // Серверное data.isPlayerTurn здесь авторитетно для таймера
|
||||
(!data.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID));
|
||||
|
||||
window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyTurnForTimer, clientState.currentGameState.gameMode);
|
||||
|
||||
// Если игра НЕ на паузе (серверной или клиентской из-за дисконнекта оппонента)
|
||||
if (!data.isPaused) {
|
||||
// Управление кнопками должно быть на основе isPlayerTurn из gameState, а не из turnTimerUpdate
|
||||
// gameStateUpdate обработает это. Здесь только если нужно немедленно реагировать на isPlayerTurn из таймера,
|
||||
// но это может привести к конфликтам с gameState.isPlayerTurn.
|
||||
// Лучше положиться на gameStateUpdate.
|
||||
// Однако, если ТАЙМЕР НЕ ПРИОСТАНОВЛЕН и это МОЙ ХОД по таймеру, то кнопки должны быть активны.
|
||||
// Это может быть полезно, если gameStateUpdate запаздывает.
|
||||
if (isMyTurnForTimer && !clientState.currentGameState.isGameOver) { // Дополнительная проверка на GameOver
|
||||
enableGameControls();
|
||||
} else if (!isMyTurnForTimer && !clientState.currentGameState.isGameOver){ // Иначе, если не мой ход
|
||||
disableGameControls();
|
||||
}
|
||||
|
||||
const statusMsgElement = document.getElementById('game-status-message');
|
||||
const currentStatusText = statusMsgElement ? statusMsgElement.textContent : "";
|
||||
if (!currentStatusText.toLowerCase().includes("отключился") && !clientState.currentGameState.isGameOver) {
|
||||
// console.log(`[CLIENT ${username}] turnTimerUpdate - Clearing game status message as timer is active and not paused.`);
|
||||
ui.setGameStatusMessage("");
|
||||
}
|
||||
} else { // Если игра на паузе (по данным таймера)
|
||||
// console.log(`[CLIENT ${username}] turnTimerUpdate - Game is paused, disabling controls.`);
|
||||
disableGameControls(); // Отключаем управление, если таймер говорит, что игра на паузе
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Начальная деактивация (на всякий случай, хотя showAuthScreen/showGameSelectionScreen должны это делать)
|
||||
disableGameControls();
|
||||
}
|
482
public/js/main.js
Normal file
482
public/js/main.js
Normal file
@ -0,0 +1,482 @@
|
||||
// /public/js/main.js
|
||||
|
||||
import { initAuth } from './auth.js';
|
||||
import { initGameSetup } from './gameSetup.js';
|
||||
import { initGameplay } from './gameplay.js';
|
||||
// ui.js загружен глобально и ожидает window.* переменных
|
||||
|
||||
// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ РАБОТЫ С JWT (для isTokenValid) ---
|
||||
function parseJwtPayloadForValidation(token) {
|
||||
try {
|
||||
if (typeof token !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (e) {
|
||||
console.error("[Main.js parseJwtPayloadForValidation] Error parsing JWT payload:", e, "Token:", token);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenValid(token) {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const decodedToken = parseJwtPayloadForValidation(token);
|
||||
if (!decodedToken || typeof decodedToken.exp !== 'number') {
|
||||
localStorage.removeItem('jwtToken');
|
||||
return false;
|
||||
}
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
if (decodedToken.exp < currentTimeInSeconds) {
|
||||
localStorage.removeItem('jwtToken');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// --- КОНЕЦ ВСПОМОГАТЕЛЬНЫХ ФУНКЦИЙ ДЛЯ JWT ---
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('[Main.js] DOMContentLoaded event fired.');
|
||||
|
||||
const initialToken = localStorage.getItem('jwtToken');
|
||||
console.log('[Main.js] Initial token from localStorage:', initialToken ? 'Exists' : 'Not found');
|
||||
|
||||
let clientState = {
|
||||
isLoggedIn: false,
|
||||
loggedInUsername: '',
|
||||
myUserId: null,
|
||||
isInGame: false,
|
||||
currentGameId: null,
|
||||
currentGameState: null,
|
||||
myPlayerId: null, // Роль в текущей игре (player/opponent)
|
||||
myCharacterKey: null,
|
||||
opponentCharacterKey: null,
|
||||
playerBaseStatsServer: null,
|
||||
opponentBaseStatsServer: null,
|
||||
playerAbilitiesServer: null,
|
||||
opponentAbilitiesServer: null,
|
||||
isActionInProgress: false, // <--- ВАЖНО: Флаг для предотвращения двойных действий
|
||||
};
|
||||
|
||||
if (initialToken && isTokenValid(initialToken)) {
|
||||
const decodedToken = parseJwtPayloadForValidation(initialToken);
|
||||
if (decodedToken && decodedToken.userId && decodedToken.username) {
|
||||
console.log("[Main.js] Token found and confirmed valid, pre-populating clientState:", decodedToken);
|
||||
clientState.isLoggedIn = true;
|
||||
clientState.myUserId = decodedToken.userId; // Это ID пользователя из БД
|
||||
clientState.loggedInUsername = decodedToken.username;
|
||||
} else {
|
||||
console.warn("[Main.js] Token deemed valid by isTokenValid, but payload incomplete. Clearing.");
|
||||
localStorage.removeItem('jwtToken');
|
||||
}
|
||||
} else if (initialToken) {
|
||||
console.warn("[Main.js] Initial token was present but invalid/expired. It has been cleared.");
|
||||
} else {
|
||||
console.log("[Main.js] No initial token found in localStorage.");
|
||||
}
|
||||
|
||||
console.log('[Main.js] Initial clientState after token check:', JSON.parse(JSON.stringify(clientState)));
|
||||
|
||||
console.log('[Main.js] Initializing Socket.IO client...');
|
||||
const socket = io({
|
||||
path:base_path + "/socket.io", // base_path определяется в HTML
|
||||
autoConnect: false, // Подключаемся вручную после инициализации всего
|
||||
auth: { token: localStorage.getItem('jwtToken') }
|
||||
});
|
||||
console.log('[Main.js] Socket.IO client initialized.');
|
||||
|
||||
// --- DOM Элементы ---
|
||||
console.log('[Main.js] Getting DOM elements...');
|
||||
const authSection = document.getElementById('auth-section');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const registerForm = document.getElementById('register-form');
|
||||
const authMessage = document.getElementById('auth-message');
|
||||
const statusContainer = document.getElementById('status-container'); // Общий контейнер для сообщений
|
||||
const userInfoDiv = document.getElementById('user-info');
|
||||
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');
|
||||
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 returnToMenuButton = document.getElementById('return-to-menu-button'); // Кнопка в gameOver модальном окне
|
||||
const turnTimerContainer = document.getElementById('turn-timer-container');
|
||||
const turnTimerSpan = document.getElementById('turn-timer');
|
||||
|
||||
// --- Функции обновления UI и состояния ---
|
||||
function updateGlobalWindowVariablesForUI() {
|
||||
window.gameState = clientState.currentGameState;
|
||||
window.gameData = {
|
||||
playerBaseStats: clientState.playerBaseStatsServer,
|
||||
opponentBaseStats: clientState.opponentBaseStatsServer,
|
||||
playerAbilities: clientState.playerAbilitiesServer,
|
||||
opponentAbilities: clientState.opponentAbilitiesServer
|
||||
};
|
||||
window.myPlayerId = clientState.myPlayerId; // Роль игрока (player/opponent)
|
||||
}
|
||||
|
||||
function resetGameVariables() {
|
||||
console.log("[Main.js resetGameVariables] Resetting game variables. State BEFORE:", JSON.parse(JSON.stringify(clientState)));
|
||||
clientState.currentGameId = null;
|
||||
clientState.currentGameState = null;
|
||||
clientState.myPlayerId = null;
|
||||
clientState.myCharacterKey = null;
|
||||
clientState.opponentCharacterKey = null;
|
||||
clientState.playerBaseStatsServer = null;
|
||||
clientState.opponentBaseStatsServer = null;
|
||||
clientState.playerAbilitiesServer = null;
|
||||
clientState.opponentAbilitiesServer = null;
|
||||
clientState.isActionInProgress = false; // <--- Сброс флага
|
||||
updateGlobalWindowVariablesForUI();
|
||||
console.log("[Main.js resetGameVariables] Game variables reset. State AFTER:", JSON.parse(JSON.stringify(clientState)));
|
||||
}
|
||||
|
||||
function explicitlyHideGameOverModal() {
|
||||
if (window.gameUI?.uiElements?.gameOver?.screen && window.GAME_CONFIG) {
|
||||
const gameOverScreenElement = window.gameUI.uiElements.gameOver.screen;
|
||||
const modalContentElement = window.gameUI.uiElements.gameOver.modalContent;
|
||||
const messageElement = window.gameUI.uiElements.gameOver.message;
|
||||
const hiddenClass = window.GAME_CONFIG.CSS_CLASS_HIDDEN || 'hidden';
|
||||
|
||||
if (gameOverScreenElement && !gameOverScreenElement.classList.contains(hiddenClass)) {
|
||||
gameOverScreenElement.classList.add(hiddenClass);
|
||||
gameOverScreenElement.style.opacity = '0';
|
||||
if (modalContentElement) {
|
||||
modalContentElement.style.transform = 'scale(0.8) translateY(30px)';
|
||||
modalContentElement.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
if (messageElement) messageElement.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthScreen() {
|
||||
console.log("[Main.js showAuthScreen] Showing Auth Screen. Resetting game state.");
|
||||
if(authSection) authSection.style.display = 'block';
|
||||
if(userInfoDiv) userInfoDiv.style.display = 'none';
|
||||
if(gameSetupDiv) gameSetupDiv.style.display = 'none';
|
||||
if(gameWrapper) gameWrapper.style.display = 'none';
|
||||
explicitlyHideGameOverModal();
|
||||
if(statusContainer) statusContainer.style.display = 'block'; // Показываем общий контейнер для сообщений
|
||||
clientState.isInGame = false;
|
||||
resetGameVariables(); // Включает сброс isActionInProgress
|
||||
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = '--';
|
||||
if(registerForm && registerForm.querySelector('button')) registerForm.querySelector('button').disabled = false;
|
||||
if(loginForm && loginForm.querySelector('button')) loginForm.querySelector('button').disabled = false;
|
||||
if(logoutButton) logoutButton.disabled = true; // Кнопка выхода неактивна на экране логина
|
||||
}
|
||||
|
||||
function showGameSelectionScreen(username) {
|
||||
console.log(`[Main.js showGameSelectionScreen] Showing Game Selection Screen for ${username}.`);
|
||||
if(authSection) authSection.style.display = 'none';
|
||||
if(userInfoDiv) userInfoDiv.style.display = 'block';
|
||||
if(loggedInUsernameSpan) loggedInUsernameSpan.textContent = username;
|
||||
if(logoutButton) logoutButton.disabled = false;
|
||||
if(gameSetupDiv) gameSetupDiv.style.display = 'block';
|
||||
if(gameWrapper) gameWrapper.style.display = 'none';
|
||||
explicitlyHideGameOverModal();
|
||||
setGameStatusMessage("Выберите режим игры или присоединитесь к существующей.");
|
||||
if(statusContainer) statusContainer.style.display = 'block';
|
||||
|
||||
if (socket.connected) {
|
||||
console.log("[Main.js showGameSelectionScreen] Socket connected, requesting PvP game list.");
|
||||
socket.emit('requestPvPGameList');
|
||||
} else {
|
||||
console.warn("[Main.js showGameSelectionScreen] Socket not connected, cannot request PvP game list yet.");
|
||||
// Можно попробовать подключить сокет, если он не подключен
|
||||
// socket.connect(); // Или дождаться авто-реконнекта
|
||||
}
|
||||
|
||||
if (availableGamesDiv) availableGamesDiv.innerHTML = '<h3>Доступные PvP игры:</h3><p>Загрузка...</p>';
|
||||
if (gameIdInput) gameIdInput.value = '';
|
||||
const elenaRadio = document.getElementById('char-elena');
|
||||
if (elenaRadio) elenaRadio.checked = true; // Персонаж по умолчанию
|
||||
clientState.isInGame = false;
|
||||
clientState.isActionInProgress = false; // <--- Сброс флага при переходе в меню
|
||||
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = '--';
|
||||
enableSetupButtons(); // Включаем кнопки настройки игры
|
||||
if (window.gameUI?.uiElements?.gameOver?.returnToMenuButton) {
|
||||
window.gameUI.uiElements.gameOver.returnToMenuButton.disabled = false; // Убедимся, что кнопка в модалке gameOver активна
|
||||
}
|
||||
}
|
||||
|
||||
function showGameScreen() {
|
||||
console.log("[Main.js showGameScreen] Showing Game Screen.");
|
||||
if(authSection) authSection.style.display = 'none';
|
||||
if(userInfoDiv) userInfoDiv.style.display = 'block'; // userInfo (имя, выход) остается видимым
|
||||
if(logoutButton) logoutButton.disabled = false;
|
||||
if(gameSetupDiv) gameSetupDiv.style.display = 'none';
|
||||
if(gameWrapper) gameWrapper.style.display = 'flex'; // Используем flex для game-wrapper
|
||||
setGameStatusMessage(""); // Очищаем сообщение статуса игры при входе на экран игры
|
||||
if(statusContainer) statusContainer.style.display = 'none'; // Скрываем общий статус-контейнер на игровом экране
|
||||
clientState.isInGame = true;
|
||||
// clientState.isActionInProgress остается false до первого действия игрока
|
||||
updateGlobalWindowVariablesForUI();
|
||||
if (turnTimerContainer) turnTimerContainer.style.display = 'block';
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = '--'; // Таймер обновится по событию
|
||||
}
|
||||
|
||||
function setAuthMessage(message, isError = false) {
|
||||
console.log(`[Main.js setAuthMessage] Message: "${message}", isError: ${isError}`);
|
||||
if (authMessage) {
|
||||
authMessage.textContent = message;
|
||||
authMessage.className = isError ? 'error' : 'success';
|
||||
authMessage.style.display = message ? 'block' : 'none';
|
||||
}
|
||||
// Если показываем authMessage, скрываем gameStatusMessage
|
||||
if (message && gameStatusMessage && gameStatusMessage.style.display !== 'none') {
|
||||
gameStatusMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function setGameStatusMessage(message, isError = false) {
|
||||
console.log(`[Main.js setGameStatusMessage] Message: "${message}", isError: ${isError}`);
|
||||
if (gameStatusMessage) {
|
||||
gameStatusMessage.textContent = message;
|
||||
gameStatusMessage.style.display = message ? 'block' : 'none';
|
||||
gameStatusMessage.style.color = isError ? 'var(--damage-color, red)' : 'var(--turn-color, yellow)'; // или другой цвет для обычных сообщений
|
||||
// Управляем видимостью общего контейнера статуса
|
||||
if (statusContainer) statusContainer.style.display = message ? 'block' : 'none';
|
||||
}
|
||||
// Если показываем gameStatusMessage, скрываем authMessage
|
||||
if (message && authMessage && authMessage.style.display !== 'none') {
|
||||
authMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function disableSetupButtons() {
|
||||
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(joinPvPGameButton) joinPvPGameButton.disabled = false;
|
||||
if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = false;
|
||||
// Кнопки в списке доступных игр управляются в updateAvailableGamesList в gameSetup.js
|
||||
}
|
||||
|
||||
function redirectToLogin(message) {
|
||||
console.log(`[Main.js redirectToLogin] Redirecting to login. Message: "${message}"`);
|
||||
clientState.isLoggedIn = false;
|
||||
clientState.loggedInUsername = '';
|
||||
clientState.myUserId = null;
|
||||
clientState.isInGame = false;
|
||||
localStorage.removeItem('jwtToken');
|
||||
resetGameVariables(); // Сбрасываем все игровые переменные, включая isActionInProgress
|
||||
|
||||
if (socket.auth) socket.auth.token = null; // Обновляем auth объект сокета
|
||||
if (socket.connected) {
|
||||
console.log("[Main.js redirectToLogin] Socket connected, disconnecting before showing auth screen.");
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
showAuthScreen();
|
||||
setAuthMessage(message || "Для продолжения необходимо войти или обновить сессию.", true);
|
||||
}
|
||||
|
||||
// --- Сборка зависимостей для модулей ---
|
||||
console.log('[Main.js] Preparing dependencies for modules...');
|
||||
const dependencies = {
|
||||
socket,
|
||||
clientState,
|
||||
ui: {
|
||||
showAuthScreen,
|
||||
showGameSelectionScreen,
|
||||
showGameScreen,
|
||||
setAuthMessage,
|
||||
setGameStatusMessage,
|
||||
resetGameVariables,
|
||||
updateGlobalWindowVariablesForUI,
|
||||
disableSetupButtons,
|
||||
enableSetupButtons,
|
||||
redirectToLogin,
|
||||
elements: {
|
||||
loginForm, registerForm, logoutButton,
|
||||
createAIGameButton, createPvPGameButton, joinPvPGameButton,
|
||||
findRandomPvPGameButton, gameIdInput, availableGamesDiv,
|
||||
pvpCharacterRadios, returnToMenuButton,
|
||||
// Не передаем сюда все элементы из ui.js, так как ui.js сам их менеджит.
|
||||
// Если какой-то модуль должен напрямую менять что-то из ui.js.uiElements,
|
||||
// то можно передать ui.js.uiElements целиком или конкретные элементы.
|
||||
}
|
||||
},
|
||||
utils: {
|
||||
isTokenValid,
|
||||
parseJwtPayloadForValidation // На всякий случай, если понадобится где-то еще
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[Main.js] Initializing auth module...');
|
||||
initAuth(dependencies);
|
||||
console.log('[Main.js] Initializing gameSetup module...');
|
||||
initGameSetup(dependencies);
|
||||
console.log('[Main.js] Initializing gameplay module...');
|
||||
initGameplay(dependencies);
|
||||
console.log('[Main.js] All modules initialized.');
|
||||
|
||||
// --- Обработчики событий Socket.IO ---
|
||||
socket.on('connect', () => {
|
||||
const currentToken = localStorage.getItem('jwtToken');
|
||||
if (socket.auth) socket.auth.token = currentToken; // Убедимся, что auth объект сокета обновлен
|
||||
else socket.auth = { token: currentToken }; // Если auth объекта не было
|
||||
|
||||
console.log('[Main.js Socket.IO] Event: connect. Socket ID:', socket.id, 'Auth token associated with this connection attempt:', !!currentToken);
|
||||
|
||||
if (clientState.isLoggedIn && clientState.myUserId && isTokenValid(currentToken)) {
|
||||
console.log(`[Main.js Socket.IO] Client state indicates logged in as ${clientState.loggedInUsername} (ID: ${clientState.myUserId}) and token is valid. Requesting game state.`);
|
||||
// Если мы на экране выбора игры, показываем сообщение о восстановлении
|
||||
if (!clientState.isInGame && (gameSetupDiv.style.display === 'block' || authSection.style.display === 'block')) {
|
||||
setGameStatusMessage("Восстановление игровой сессии...");
|
||||
}
|
||||
socket.emit('requestGameState');
|
||||
} else {
|
||||
if (clientState.isLoggedIn && !isTokenValid(currentToken)) {
|
||||
console.warn('[Main.js Socket.IO connect] Client state says logged in, but token is invalid/expired. Redirecting to login.');
|
||||
redirectToLogin("Ваша сессия истекла. Пожалуйста, войдите снова.");
|
||||
} else {
|
||||
console.log('[Main.js Socket.IO connect] Client state indicates NOT logged in or no valid token. Showing auth screen if not already visible.');
|
||||
if (authSection.style.display !== 'block') {
|
||||
showAuthScreen(); // Показываем экран логина, если еще не на нем
|
||||
}
|
||||
setAuthMessage("Пожалуйста, войдите или зарегистрируйтесь."); // Сообщение по умолчанию для экрана логина
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('[Main.js Socket.IO] Event: connect_error. Message:', err.message, err.data ? JSON.stringify(err.data) : '');
|
||||
const errorMessageLower = err.message ? err.message.toLowerCase() : "";
|
||||
const isAuthError = errorMessageLower.includes('auth') || errorMessageLower.includes('token') ||
|
||||
errorMessageLower.includes('unauthorized') || err.message === 'invalid token' ||
|
||||
err.message === 'no token' || (err.data && typeof err.data === 'string' && err.data.toLowerCase().includes('auth'));
|
||||
|
||||
if (isAuthError) {
|
||||
console.warn('[Main.js Socket.IO connect_error] Authentication error during connection. Redirecting to login.');
|
||||
redirectToLogin("Ошибка аутентификации. Пожалуйста, войдите снова.");
|
||||
} else {
|
||||
let currentScreenMessageFunc = setAuthMessage;
|
||||
if (clientState.isLoggedIn && clientState.isInGame) {
|
||||
currentScreenMessageFunc = setGameStatusMessage;
|
||||
} else if (clientState.isLoggedIn) { // Если залогинен, но не в игре (на экране выбора)
|
||||
currentScreenMessageFunc = setGameStatusMessage;
|
||||
}
|
||||
currentScreenMessageFunc(`Ошибка подключения: ${err.message}. Попытка переподключения...`, true);
|
||||
// Если не залогинены и не на экране авторизации, показываем его
|
||||
if (!clientState.isLoggedIn && authSection.style.display !== 'block') {
|
||||
showAuthScreen();
|
||||
}
|
||||
}
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = 'Ошибка';
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.warn('[Main.js Socket.IO] Event: disconnect. Reason:', reason);
|
||||
let messageFunc = setAuthMessage; // По умолчанию сообщение для экрана авторизации
|
||||
if (clientState.isInGame) {
|
||||
messageFunc = setGameStatusMessage;
|
||||
} else if (clientState.isLoggedIn && gameSetupDiv.style.display === 'block') {
|
||||
messageFunc = setGameStatusMessage;
|
||||
}
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
messageFunc("Соединение разорвано сервером. Пожалуйста, попробуйте войти снова.", true);
|
||||
redirectToLogin("Соединение разорвано сервером. Пожалуйста, войдите снова.");
|
||||
} else if (reason === 'io client disconnect') {
|
||||
// Это преднамеренный дисконнект (например, при logout или смене токена).
|
||||
// Сообщение уже должно быть установлено функцией, вызвавшей дисконнект.
|
||||
// Ничего не делаем здесь, чтобы не перезаписать его.
|
||||
console.log('[Main.js Socket.IO] Disconnect was intentional (io client disconnect). No additional message needed.');
|
||||
} else { // Другие причины (например, проблемы с сетью)
|
||||
messageFunc(`Потеряно соединение: ${reason}. Попытка переподключения...`, true);
|
||||
}
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.';
|
||||
clientState.isActionInProgress = false; // На всякий случай сбрасываем флаг при дисконнекте
|
||||
});
|
||||
|
||||
socket.on('gameError', (data) => {
|
||||
console.error('[Main.js Socket.IO] Event: gameError. Message:', data.message, 'Data:', JSON.stringify(data));
|
||||
clientState.isActionInProgress = false; // Сбрасываем флаг при ошибке сервера
|
||||
|
||||
if (data.message && (data.message.toLowerCase().includes("сессия истекла") || data.message.toLowerCase().includes("необходимо войти"))) {
|
||||
redirectToLogin(data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientState.isInGame && window.gameUI?.addToLog) {
|
||||
window.gameUI.addToLog(`❌ Ошибка сервера: ${data.message}`, 'system');
|
||||
// Если ошибка произошла в игре, но игра не закончилась, кнопки могут остаться заблокированными.
|
||||
// Возможно, стоит проверить, чей ход, и разблокировать, если ход игрока и игра не окончена.
|
||||
// Но это зависит от типа ошибки. Сейчас просто логируем.
|
||||
} else if (clientState.isLoggedIn) {
|
||||
setGameStatusMessage(`❌ Ошибка: ${data.message}`, true);
|
||||
enableSetupButtons(); // Разблокируем кнопки, если ошибка на экране выбора игры
|
||||
} else {
|
||||
setAuthMessage(`❌ Ошибка: ${data.message}`, true);
|
||||
if(registerForm && registerForm.querySelector('button')) registerForm.querySelector('button').disabled = false;
|
||||
if(loginForm && loginForm.querySelector('button')) loginForm.querySelector('button').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('gameNotFound', (data) => {
|
||||
console.log('[Main.js Socket.IO] Event: gameNotFound. Message:', data?.message, 'Data:', JSON.stringify(data));
|
||||
clientState.isInGame = false;
|
||||
resetGameVariables(); // Включает сброс isActionInProgress
|
||||
explicitlyHideGameOverModal();
|
||||
if (turnTimerContainer) turnTimerContainer.style.display = 'none';
|
||||
if (turnTimerSpan) turnTimerSpan.textContent = '--';
|
||||
|
||||
if (clientState.isLoggedIn && isTokenValid(localStorage.getItem('jwtToken'))) {
|
||||
if (gameSetupDiv.style.display !== 'block') { // Если мы не на экране выбора игры, показываем его
|
||||
showGameSelectionScreen(clientState.loggedInUsername);
|
||||
}
|
||||
setGameStatusMessage(data?.message || "Активная игровая сессия не найдена. Выберите новую игру.");
|
||||
} else {
|
||||
redirectToLogin(data?.message || "Пожалуйста, войдите для продолжения.");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Инициализация UI ---
|
||||
console.log('[Main.js] Initializing UI visibility...');
|
||||
if(authSection) authSection.style.display = 'none';
|
||||
if(gameSetupDiv) gameSetupDiv.style.display = 'none';
|
||||
if(gameWrapper) gameWrapper.style.display = 'none';
|
||||
if(userInfoDiv) userInfoDiv.style.display = 'none';
|
||||
if(statusContainer) statusContainer.style.display = 'block'; // Показываем общий контейнер для сообщений
|
||||
|
||||
if (clientState.isLoggedIn) {
|
||||
console.log('[Main.js] Client is considered logged in. Will attempt session recovery on socket connect.');
|
||||
// Не показываем экран выбора игры сразу, дожидаемся 'connect' и 'requestGameState'
|
||||
setAuthMessage("Подключение и восстановление сессии..."); // Используем authMessage для начального сообщения
|
||||
} else {
|
||||
console.log('[Main.js] Client is NOT considered logged in. Showing auth screen.');
|
||||
showAuthScreen();
|
||||
setAuthMessage("Подключение к серверу...");
|
||||
}
|
||||
|
||||
console.log('[Main.js] Attempting to connect socket...');
|
||||
socket.connect(); // Подключаемся здесь, после всей инициализации
|
||||
console.log('[Main.js] socket.connect() called.');
|
||||
});
|
534
public/js/ui.js
Normal file
534
public/js/ui.js
Normal file
@ -0,0 +1,534 @@
|
||||
// /public/js/ui.js
|
||||
// Этот файл отвечает за обновление DOM на основе состояния игры,
|
||||
// полученного от client.js (который, в свою очередь, получает его от сервера).
|
||||
|
||||
(function() {
|
||||
// --- DOM Элементы ---
|
||||
const uiElements = {
|
||||
player: {
|
||||
panel: document.getElementById('player-panel'),
|
||||
name: document.getElementById('player-name'),
|
||||
avatar: document.getElementById('player-panel')?.querySelector('.player-avatar'),
|
||||
hpFill: document.getElementById('player-hp-fill'), hpText: document.getElementById('player-hp-text'),
|
||||
resourceFill: document.getElementById('player-resource-fill'), resourceText: document.getElementById('player-resource-text'),
|
||||
status: document.getElementById('player-status'),
|
||||
effectsContainer: document.getElementById('player-effects'),
|
||||
buffsList: document.getElementById('player-effects')?.querySelector('.player-buffs'),
|
||||
debuffsList: document.getElementById('player-effects')?.querySelector('.player-debuffs')
|
||||
},
|
||||
opponent: {
|
||||
panel: document.getElementById('opponent-panel'),
|
||||
name: document.getElementById('opponent-name'),
|
||||
avatar: document.getElementById('opponent-panel')?.querySelector('.opponent-avatar'),
|
||||
hpFill: document.getElementById('opponent-hp-fill'), hpText: document.getElementById('opponent-hp-text'),
|
||||
resourceFill: document.getElementById('opponent-resource-fill'), resourceText: document.getElementById('opponent-resource-text'),
|
||||
status: document.getElementById('opponent-status'),
|
||||
effectsContainer: document.getElementById('opponent-effects'),
|
||||
buffsList: document.getElementById('opponent-effects')?.querySelector('.opponent-buffs'),
|
||||
debuffsList: document.getElementById('opponent-effects')?.querySelector('.opponent-debuffs')
|
||||
},
|
||||
controls: {
|
||||
turnIndicator: document.getElementById('turn-indicator'),
|
||||
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'),
|
||||
},
|
||||
gameOver: {
|
||||
screen: document.getElementById('game-over-screen'),
|
||||
message: document.getElementById('result-message'),
|
||||
returnToMenuButton: document.getElementById('return-to-menu-button'),
|
||||
modalContent: document.getElementById('game-over-screen')?.querySelector('.modal-content')
|
||||
},
|
||||
gameHeaderTitle: document.querySelector('.game-header h1'),
|
||||
playerResourceTypeIcon: document.getElementById('player-resource-bar')?.closest('.stat-bar-container')?.querySelector('.bar-icon i'),
|
||||
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') {
|
||||
const logListElement = uiElements.log.list;
|
||||
if (!logListElement) return;
|
||||
const li = document.createElement('li');
|
||||
li.textContent = message;
|
||||
const config = window.GAME_CONFIG || {};
|
||||
const logTypeClass = config[`LOG_TYPE_${type.toUpperCase()}`] ? `log-${config[`LOG_TYPE_${type.toUpperCase()}`]}` : `log-${type}`;
|
||||
li.className = logTypeClass;
|
||||
logListElement.appendChild(li);
|
||||
requestAnimationFrame(() => { logListElement.scrollTop = logListElement.scrollHeight; });
|
||||
}
|
||||
|
||||
function updateFighterPanelUI(panelRole, fighterState, fighterBaseStats, isControlledByThisClient) {
|
||||
const elements = uiElements[panelRole];
|
||||
const config = window.GAME_CONFIG || {};
|
||||
|
||||
if (!elements || !elements.hpFill || !elements.hpText || !elements.resourceFill || !elements.resourceText || !elements.status || !fighterState || !fighterBaseStats) {
|
||||
if (elements) {
|
||||
if(elements.name) elements.name.innerHTML = (panelRole === 'player') ? '<i class="fas fa-question icon-player"></i> Ожидание данных...' : '<i class="fas fa-question icon-opponent"></i> Ожидание игрока...';
|
||||
if(elements.hpText) elements.hpText.textContent = 'N/A';
|
||||
if(elements.resourceText) elements.resourceText.textContent = 'N/A';
|
||||
if(elements.status) elements.status.textContent = 'Неизвестно';
|
||||
if(elements.buffsList) elements.buffsList.innerHTML = 'Нет';
|
||||
if(elements.debuffsList) elements.debuffsList.innerHTML = 'Нет';
|
||||
if(elements.avatar) elements.avatar.src = 'images/default_avatar.png';
|
||||
if(panelRole === 'player' && uiElements.playerResourceTypeIcon) uiElements.playerResourceTypeIcon.className = 'fas fa-question';
|
||||
if(panelRole === 'opponent' && uiElements.opponentResourceTypeIcon) uiElements.opponentResourceTypeIcon.className = 'fas fa-question';
|
||||
if(panelRole === 'player' && uiElements.playerResourceBarContainer) uiElements.playerResourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy');
|
||||
if(panelRole === 'opponent' && uiElements.opponentResourceBarContainer) uiElements.opponentResourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy');
|
||||
if(elements.panel) elements.panel.style.opacity = '0.5';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (elements.panel) elements.panel.style.opacity = '1';
|
||||
|
||||
if (elements.name) {
|
||||
let iconClass = 'fa-question';
|
||||
const characterKey = fighterBaseStats.characterKey;
|
||||
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'; }
|
||||
let nameHtml = `<i class="fas ${iconClass}"></i> ${fighterBaseStats.name || 'Неизвестно'}`;
|
||||
if (isControlledByThisClient) nameHtml += " (Вы)";
|
||||
elements.name.innerHTML = nameHtml;
|
||||
}
|
||||
|
||||
if (elements.avatar && fighterBaseStats.avatarPath) {
|
||||
elements.avatar.src = fighterBaseStats.avatarPath;
|
||||
elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard');
|
||||
elements.avatar.classList.add(`avatar-${fighterBaseStats.characterKey}`);
|
||||
} else if (elements.avatar) {
|
||||
elements.avatar.src = 'images/default_avatar.png';
|
||||
elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard');
|
||||
}
|
||||
|
||||
const maxHp = Math.max(1, fighterBaseStats.maxHp);
|
||||
const maxRes = Math.max(1, fighterBaseStats.maxResource);
|
||||
const currentHp = Math.max(0, fighterState.currentHp);
|
||||
const currentRes = Math.max(0, fighterState.currentResource);
|
||||
elements.hpFill.style.width = `${(currentHp / maxHp) * 100}%`;
|
||||
elements.hpText.textContent = `${Math.round(currentHp)} / ${fighterBaseStats.maxHp}`;
|
||||
elements.resourceFill.style.width = `${(currentRes / maxRes) * 100}%`;
|
||||
elements.resourceText.textContent = `${currentRes} / ${fighterBaseStats.maxResource}`;
|
||||
|
||||
const resourceBarContainerToUpdate = (panelRole === 'player') ? uiElements.playerResourceBarContainer : uiElements.opponentResourceBarContainer;
|
||||
const resourceIconElementToUpdate = (panelRole === 'player') ? uiElements.playerResourceTypeIcon : uiElements.opponentResourceTypeIcon;
|
||||
if (resourceBarContainerToUpdate && resourceIconElementToUpdate) {
|
||||
resourceBarContainerToUpdate.classList.remove('mana', 'stamina', 'dark-energy');
|
||||
let resourceClass = 'mana'; let iconClass = 'fa-flask';
|
||||
if (fighterBaseStats.resourceName === 'Ярость') { resourceClass = 'stamina'; iconClass = 'fa-fire-alt'; }
|
||||
else if (fighterBaseStats.resourceName === 'Темная Энергия') { resourceClass = 'dark-energy'; iconClass = 'fa-skull'; }
|
||||
resourceBarContainerToUpdate.classList.add(resourceClass);
|
||||
resourceIconElementToUpdate.className = `fas ${iconClass}`;
|
||||
}
|
||||
|
||||
const statusText = fighterState.isBlocking ? (config.STATUS_BLOCKING || 'Защищается') : (config.STATUS_READY || 'Готов(а)');
|
||||
elements.status.textContent = statusText;
|
||||
elements.status.classList.toggle(config.CSS_CLASS_BLOCKING || 'blocking', fighterState.isBlocking);
|
||||
|
||||
if (elements.panel) {
|
||||
let borderColorVar = 'var(--panel-border)';
|
||||
elements.panel.classList.remove('panel-elena', 'panel-almagest', 'panel-balard');
|
||||
if (fighterBaseStats.characterKey === 'elena') { elements.panel.classList.add('panel-elena'); borderColorVar = 'var(--accent-player)'; }
|
||||
else if (fighterBaseStats.characterKey === 'almagest') { elements.panel.classList.add('panel-almagest'); borderColorVar = 'var(--accent-almagest)'; }
|
||||
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 === '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)`;
|
||||
}
|
||||
}
|
||||
|
||||
function generateEffectsHTML(effectsArray) {
|
||||
const config = window.GAME_CONFIG || {};
|
||||
if (!effectsArray || effectsArray.length === 0) return 'Нет';
|
||||
return effectsArray.map(eff => {
|
||||
let effectClasses = config.CSS_CLASS_EFFECT || 'effect';
|
||||
const title = `${eff.name}${eff.description ? ` - ${eff.description}` : ''} (Осталось: ${eff.turnsLeft} х.)`;
|
||||
const displayText = `${eff.name} (${eff.turnsLeft} х.)`;
|
||||
if (eff.isFullSilence || eff.id.startsWith('playerSilencedOn_') || eff.type === config.ACTION_TYPE_DISABLE) effectClasses += ' effect-stun';
|
||||
else if (eff.grantsBlock) effectClasses += ' effect-block';
|
||||
else if (eff.type === config.ACTION_TYPE_DEBUFF) effectClasses += ' effect-debuff';
|
||||
else if (eff.type === config.ACTION_TYPE_BUFF || eff.type === config.ACTION_TYPE_HEAL) effectClasses += ' effect-buff';
|
||||
else effectClasses += ' effect-info';
|
||||
return `<span class="${effectClasses}" title="${title}">${displayText}</span>`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function updateEffectsUI(currentGameState) {
|
||||
if (!currentGameState || !window.GAME_CONFIG) return;
|
||||
const mySlotId = window.myPlayerId;
|
||||
const config = window.GAME_CONFIG;
|
||||
if (!mySlotId) return;
|
||||
const opponentSlotId = mySlotId === config.PLAYER_ID ? config.OPPONENT_ID : config.PLAYER_ID;
|
||||
const myState = currentGameState[mySlotId];
|
||||
const opponentState = currentGameState[opponentSlotId];
|
||||
const typeOrder = { [config.ACTION_TYPE_BUFF]: 1, grantsBlock: 2, [config.ACTION_TYPE_HEAL]: 3, [config.ACTION_TYPE_DEBUFF]: 4, [config.ACTION_TYPE_DISABLE]: 5 };
|
||||
const sortEffects = (a, b) => {
|
||||
let orderA = typeOrder[a.type] || 99; if (a.grantsBlock) orderA = typeOrder.grantsBlock; if (a.isFullSilence || a.id.startsWith('playerSilencedOn_')) orderA = typeOrder[config.ACTION_TYPE_DISABLE];
|
||||
let orderB = typeOrder[b.type] || 99; if (b.grantsBlock) orderB = typeOrder.grantsBlock; if (b.isFullSilence || b.id.startsWith('playerSilencedOn_')) orderB = typeOrder[config.ACTION_TYPE_DISABLE];
|
||||
return (orderA || 99) - (orderB || 99);
|
||||
};
|
||||
|
||||
if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList && myState && myState.activeEffects) {
|
||||
const myBuffs = []; const myDebuffs = [];
|
||||
myState.activeEffects.forEach(e => {
|
||||
const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL;
|
||||
const isDebuff = e.type === config.ACTION_TYPE_DEBUFF || e.type === config.ACTION_TYPE_DISABLE || e.isFullSilence || e.id.startsWith('playerSilencedOn_');
|
||||
if (isBuff) myBuffs.push(e); else if (isDebuff) myDebuffs.push(e); else myDebuffs.push(e);
|
||||
});
|
||||
myBuffs.sort(sortEffects); myDebuffs.sort(sortEffects);
|
||||
uiElements.player.buffsList.innerHTML = generateEffectsHTML(myBuffs);
|
||||
uiElements.player.debuffsList.innerHTML = generateEffectsHTML(myDebuffs);
|
||||
} else if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList) {
|
||||
uiElements.player.buffsList.innerHTML = 'Нет'; uiElements.player.debuffsList.innerHTML = 'Нет';
|
||||
}
|
||||
|
||||
if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList && opponentState && opponentState.activeEffects) {
|
||||
const opponentBuffs = []; const opponentDebuffs = [];
|
||||
opponentState.activeEffects.forEach(e => {
|
||||
const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL;
|
||||
const isDebuff = e.type === config.ACTION_TYPE_DEBUFF || e.type === config.ACTION_TYPE_DISABLE || e.isFullSilence || e.id.startsWith('effect_');
|
||||
if (isBuff) opponentBuffs.push(e); else if (isDebuff) opponentDebuffs.push(e); else opponentDebuffs.push(e);
|
||||
});
|
||||
opponentBuffs.sort(sortEffects); opponentDebuffs.sort(sortEffects);
|
||||
uiElements.opponent.buffsList.innerHTML = generateEffectsHTML(opponentBuffs);
|
||||
uiElements.opponent.debuffsList.innerHTML = generateEffectsHTML(opponentDebuffs);
|
||||
} else if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList) {
|
||||
uiElements.opponent.buffsList.innerHTML = 'Нет'; uiElements.opponent.debuffsList.innerHTML = 'Нет';
|
||||
}
|
||||
}
|
||||
|
||||
function updateTurnTimerDisplay(remainingTimeMs, isCurrentPlayerActualTurn, gameMode) {
|
||||
const timerSpan = uiElements.controls.turnTimerSpan;
|
||||
const timerContainer = uiElements.controls.turnTimerContainer;
|
||||
|
||||
if (!timerSpan || !timerContainer) return;
|
||||
|
||||
if (window.gameState && window.gameState.isGameOver) {
|
||||
timerContainer.style.display = 'block';
|
||||
timerSpan.textContent = 'Конец';
|
||||
timerSpan.classList.remove('low-time');
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingTimeMs === null || remainingTimeMs === undefined) {
|
||||
timerContainer.style.display = 'block';
|
||||
timerSpan.classList.remove('low-time');
|
||||
if (gameMode === 'ai' && !isCurrentPlayerActualTurn) {
|
||||
timerSpan.textContent = 'Ход ИИ';
|
||||
} else if (gameMode === 'pvp' && !isCurrentPlayerActualTurn) {
|
||||
timerSpan.textContent = 'Ход оппонента';
|
||||
} else {
|
||||
timerSpan.textContent = '--';
|
||||
}
|
||||
} else {
|
||||
timerContainer.style.display = 'block';
|
||||
const seconds = Math.ceil(remainingTimeMs / 1000);
|
||||
timerSpan.textContent = `0:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||
|
||||
if (seconds <= 10 && isCurrentPlayerActualTurn) {
|
||||
timerSpan.classList.add('low-time');
|
||||
} else {
|
||||
timerSpan.classList.remove('low-time');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function updateUI() {
|
||||
const currentGameState = window.gameState;
|
||||
const gameDataGlobal = window.gameData;
|
||||
const configGlobal = window.GAME_CONFIG;
|
||||
const myActualPlayerId = window.myPlayerId;
|
||||
|
||||
if (!currentGameState || !gameDataGlobal || !configGlobal || !myActualPlayerId) {
|
||||
updateFighterPanelUI('player', null, null, true);
|
||||
updateFighterPanelUI('opponent', null, null, false);
|
||||
if(uiElements.gameHeaderTitle) uiElements.gameHeaderTitle.innerHTML = `<span>Ожидание данных...</span>`;
|
||||
if(uiElements.controls.turnIndicator) uiElements.controls.turnIndicator.textContent = "Ожидание данных...";
|
||||
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) {
|
||||
console.warn("updateUI: Некоторые базовые uiElements не найдены.");
|
||||
return;
|
||||
}
|
||||
|
||||
const actorSlotWhoseTurnItIs = currentGameState.isPlayerTurn ? configGlobal.PLAYER_ID : configGlobal.OPPONENT_ID;
|
||||
const opponentActualSlotId = myActualPlayerId === configGlobal.PLAYER_ID ? configGlobal.OPPONENT_ID : configGlobal.PLAYER_ID;
|
||||
const myStateInGameState = currentGameState[myActualPlayerId];
|
||||
const myBaseStatsForUI = gameDataGlobal.playerBaseStats;
|
||||
if (myStateInGameState && myBaseStatsForUI) updateFighterPanelUI('player', myStateInGameState, myBaseStatsForUI, true);
|
||||
else updateFighterPanelUI('player', null, null, true);
|
||||
|
||||
const opponentStateInGameState = currentGameState[opponentActualSlotId];
|
||||
const opponentBaseStatsForUI = gameDataGlobal.opponentBaseStats;
|
||||
const isOpponentPanelDissolving = uiElements.opponent.panel?.classList.contains('dissolving');
|
||||
if (opponentStateInGameState && opponentBaseStatsForUI) {
|
||||
if (uiElements.opponent.panel && (uiElements.opponent.panel.style.opacity !== '1' || (uiElements.opponent.panel.classList.contains('dissolving') && currentGameState.isGameOver === false) )) {
|
||||
const panel = uiElements.opponent.panel;
|
||||
if (panel.classList.contains('dissolving')) {
|
||||
panel.classList.remove('dissolving'); panel.style.transition = 'none'; panel.offsetHeight;
|
||||
panel.style.opacity = '1'; panel.style.transform = 'scale(1) translateY(0)'; panel.style.transition = '';
|
||||
} else { panel.style.opacity = '1'; panel.style.transform = 'scale(1) translateY(0)'; }
|
||||
} else if (uiElements.opponent.panel && !isOpponentPanelDissolving) {
|
||||
uiElements.opponent.panel.style.opacity = '1';
|
||||
}
|
||||
updateFighterPanelUI('opponent', opponentStateInGameState, opponentBaseStatsForUI, false);
|
||||
} else {
|
||||
if (!isOpponentPanelDissolving) updateFighterPanelUI('opponent', null, null, false);
|
||||
else console.log("[UI UPDATE DEBUG] Opponent panel is dissolving, skipping content update.");
|
||||
}
|
||||
|
||||
updateEffectsUI(currentGameState);
|
||||
|
||||
if (uiElements.gameHeaderTitle && gameDataGlobal.playerBaseStats && gameDataGlobal.opponentBaseStats) {
|
||||
const myName = gameDataGlobal.playerBaseStats.name; const opponentName = gameDataGlobal.opponentBaseStats.name;
|
||||
const myKey = gameDataGlobal.playerBaseStats.characterKey; const opponentKey = gameDataGlobal.opponentBaseStats.characterKey;
|
||||
let myClass = 'title-player'; let opponentClass = 'title-opponent';
|
||||
if (myKey === 'elena') myClass = 'title-enchantress'; else if (myKey === 'almagest') myClass = 'title-sorceress'; else if (myKey === 'balard') myClass = 'title-knight';
|
||||
if (opponentKey === 'elena') opponentClass = 'title-enchantress'; else if (opponentKey === 'almagest') opponentClass = 'title-sorceress'; else if (opponentKey === 'balard') opponentClass = 'title-knight';
|
||||
uiElements.gameHeaderTitle.innerHTML = `<span class="${myClass}">${myName}</span> <span class="separator"><i class="fas fa-fist-raised"></i></span> <span class="${opponentClass}">${opponentName}</span>`;
|
||||
} else if (uiElements.gameHeaderTitle) {
|
||||
const myName = gameDataGlobal.playerBaseStats?.name || 'Игрок 1'; const myKey = gameDataGlobal.playerBaseStats?.characterKey;
|
||||
let myClass = 'title-player'; if (myKey === 'elena') myClass = 'title-enchantress'; else if (myKey === 'almagest') myClass = 'title-sorceress';
|
||||
uiElements.gameHeaderTitle.innerHTML = `<span class="${myClass}">${myName}</span> <span class="separator"><i class="fas fa-fist-raised"></i></span> <span class="title-opponent">Ожидание игрока...</span>`;
|
||||
}
|
||||
|
||||
const canThisClientAct = actorSlotWhoseTurnItIs === myActualPlayerId;
|
||||
const isGameActive = !currentGameState.isGameOver;
|
||||
const myCharacterState = currentGameState[myActualPlayerId];
|
||||
|
||||
if (uiElements.controls.turnIndicator) {
|
||||
if (isGameActive) {
|
||||
const currentTurnActor = currentGameState.isPlayerTurn ? currentGameState.player : currentGameState.opponent;
|
||||
uiElements.controls.turnIndicator.textContent = `Ход ${currentGameState.turnNumber}: ${currentTurnActor?.name || 'Неизвестно'}`;
|
||||
uiElements.controls.turnIndicator.style.color = (currentTurnActor?.id === myActualPlayerId) ? 'var(--turn-color)' : 'var(--text-muted)';
|
||||
} else {
|
||||
uiElements.controls.turnIndicator.textContent = "Игра окончена";
|
||||
uiElements.controls.turnIndicator.style.color = 'var(--text-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
if (uiElements.controls.buttonAttack) {
|
||||
uiElements.controls.buttonAttack.disabled = !(canThisClientAct && isGameActive);
|
||||
const myCharKey = gameDataGlobal.playerBaseStats?.characterKey;
|
||||
let attackBuffId = null;
|
||||
if (myCharKey === 'elena') attackBuffId = configGlobal.ABILITY_ID_NATURE_STRENGTH;
|
||||
else if (myCharKey === 'almagest') attackBuffId = configGlobal.ABILITY_ID_ALMAGEST_BUFF_ATTACK;
|
||||
if (attackBuffId && myCharacterState && myCharacterState.activeEffects) {
|
||||
const isAttackBuffReady = myCharacterState.activeEffects.some(eff => (eff.id === attackBuffId || eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK) && eff.isDelayed && eff.turnsLeft > 0 && !eff.justCast);
|
||||
uiElements.controls.buttonAttack.classList.toggle(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed', isAttackBuffReady && canThisClientAct && isGameActive);
|
||||
} else { uiElements.controls.buttonAttack.classList.remove(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed'); }
|
||||
}
|
||||
if (uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true;
|
||||
|
||||
const actingPlayerState = myCharacterState;
|
||||
const actingPlayerAbilities = gameDataGlobal.playerAbilities;
|
||||
const actingPlayerResourceName = gameDataGlobal.playerBaseStats?.resourceName;
|
||||
const opponentStateForDebuffCheck = currentGameState[opponentActualSlotId];
|
||||
|
||||
uiElements.controls.abilitiesGrid?.querySelectorAll(`.${configGlobal.CSS_CLASS_ABILITY_BUTTON || 'ability-button'}`).forEach(button => {
|
||||
const abilityId = button.dataset.abilityId;
|
||||
const abilityDataFromGameData = actingPlayerAbilities?.find(ab => ab.id === abilityId);
|
||||
if (!(button instanceof HTMLButtonElement) || !isGameActive || !canThisClientAct || !actingPlayerState || !actingPlayerAbilities || !actingPlayerResourceName || !abilityDataFromGameData) {
|
||||
if (button instanceof HTMLButtonElement) button.disabled = true;
|
||||
button.classList.remove(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced', configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown');
|
||||
const cooldownDisplay = button.querySelector('.ability-cooldown-display');
|
||||
if (cooldownDisplay) cooldownDisplay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const hasEnoughResource = actingPlayerState.currentResource >= abilityDataFromGameData.cost;
|
||||
const isOnCooldown = (actingPlayerState.abilityCooldowns?.[abilityId] || 0) > 0;
|
||||
const isGenerallySilenced = actingPlayerState.activeEffects?.some(eff => eff.isFullSilence && eff.turnsLeft > 0);
|
||||
const isAbilitySpecificallySilenced = actingPlayerState.disabledAbilities?.some(dis => dis.abilityId === abilityId && dis.turnsLeft > 0);
|
||||
const isSilenced = isGenerallySilenced || isAbilitySpecificallySilenced;
|
||||
const silenceTurnsLeft = isAbilitySpecificallySilenced ? (actingPlayerState.disabledAbilities?.find(dis => dis.abilityId === abilityId)?.turnsLeft || 0) : (isGenerallySilenced ? (actingPlayerState.activeEffects.find(eff => eff.isFullSilence)?.turnsLeft || 0) : 0);
|
||||
const isBuffAlreadyActive = abilityDataFromGameData.type === configGlobal.ACTION_TYPE_BUFF && actingPlayerState.activeEffects?.some(eff => eff.id === abilityId);
|
||||
const isTargetedDebuffAbility = abilityId === configGlobal.ABILITY_ID_SEAL_OF_WEAKNESS || abilityId === configGlobal.ABILITY_ID_ALMAGEST_DEBUFF;
|
||||
const effectIdForDebuff = 'effect_' + abilityId;
|
||||
const isDebuffAlreadyOnTarget = isTargetedDebuffAbility && opponentStateForDebuffCheck && opponentStateForDebuffCheck.activeEffects?.some(e => e.id === effectIdForDebuff);
|
||||
button.disabled = !hasEnoughResource || isBuffAlreadyActive || isSilenced || isOnCooldown || isDebuffAlreadyOnTarget;
|
||||
button.classList.remove(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced', configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown');
|
||||
const cooldownDisplay = button.querySelector('.ability-cooldown-display');
|
||||
if (isOnCooldown) {
|
||||
button.classList.add(configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown');
|
||||
if (cooldownDisplay) { cooldownDisplay.textContent = `КД: ${actingPlayerState.abilityCooldowns[abilityId]}`; cooldownDisplay.style.display = 'block'; }
|
||||
} else if (isSilenced) {
|
||||
button.classList.add(configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced');
|
||||
if (cooldownDisplay) { const icon = isGenerallySilenced ? '🔕' : '🔇'; cooldownDisplay.textContent = `${icon} ${silenceTurnsLeft}`; cooldownDisplay.style.display = 'block'; }
|
||||
} else {
|
||||
if (cooldownDisplay) cooldownDisplay.style.display = 'none';
|
||||
if (!isOnCooldown && !isSilenced) {
|
||||
button.classList.toggle(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', !hasEnoughResource);
|
||||
button.classList.toggle(configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', isBuffAlreadyActive);
|
||||
}
|
||||
}
|
||||
let titleText = `${abilityDataFromGameData.name} (${abilityDataFromGameData.cost} ${actingPlayerResourceName})`;
|
||||
let descriptionTextFull = abilityDataFromGameData.description;
|
||||
if (typeof abilityDataFromGameData.descriptionFunction === 'function') {
|
||||
const opponentBaseStatsForDesc = gameDataGlobal.opponentBaseStats;
|
||||
descriptionTextFull = abilityDataFromGameData.descriptionFunction(configGlobal, opponentBaseStatsForDesc);
|
||||
}
|
||||
if (descriptionTextFull) titleText += ` - ${descriptionTextFull}`;
|
||||
let abilityBaseCooldown = abilityDataFromGameData.cooldown;
|
||||
if (typeof abilityBaseCooldown === 'number' && abilityBaseCooldown > 0) titleText += ` (Исходный КД: ${abilityBaseCooldown} х.)`;
|
||||
if (isOnCooldown) titleText += ` | На перезарядке! Осталось: ${actingPlayerState.abilityCooldowns[abilityId]} х.`;
|
||||
if (isSilenced) titleText += ` | Под безмолвием! Осталось: ${silenceTurnsLeft} х.`;
|
||||
if (isBuffAlreadyActive) {
|
||||
const activeEffect = actingPlayerState.activeEffects?.find(eff => eff.id === abilityId);
|
||||
const isDelayedBuffReady = isBuffAlreadyActive && activeEffect && activeEffect.isDelayed && !activeEffect.justCast && activeEffect.turnsLeft > 0;
|
||||
if (isDelayedBuffReady) titleText += ` | Эффект активен и сработает при следующей базовой атаке (${activeEffect.turnsLeft} х.)`;
|
||||
else if (isBuffAlreadyActive) titleText += ` | Эффект уже активен${activeEffect ? ` (${activeEffect.turnsLeft} х.)` : ''}. Нельзя применить повторно.`;
|
||||
}
|
||||
if (isDebuffAlreadyOnTarget && opponentStateForDebuffCheck) {
|
||||
const activeDebuff = opponentStateForDebuffCheck.activeEffects?.find(e => e.id === 'effect_' + abilityId);
|
||||
titleText += ` | Эффект уже наложен на ${gameDataGlobal.opponentBaseStats?.name || 'противника'}${activeDebuff ? ` (${activeDebuff.turnsLeft} х.)` : ''}.`;
|
||||
}
|
||||
if (!hasEnoughResource) titleText += ` | Недостаточно ${actingPlayerResourceName} (${actingPlayerState.currentResource}/${abilityDataFromGameData.cost})`;
|
||||
button.setAttribute('title', titleText);
|
||||
});
|
||||
}
|
||||
|
||||
function showGameOver(playerWon, reason = "", opponentCharacterKeyFromClient = null, data = null) {
|
||||
const config = window.GAME_CONFIG || {};
|
||||
const clientSpecificGameData = window.gameData;
|
||||
const currentActualGameState = window.gameState;
|
||||
const gameOverScreenElement = uiElements.gameOver.screen;
|
||||
|
||||
if (!gameOverScreenElement) { return; }
|
||||
|
||||
const resultMsgElement = uiElements.gameOver.message;
|
||||
const myNameForResult = clientSpecificGameData?.playerBaseStats?.name || "Игрок";
|
||||
const opponentNameForResult = clientSpecificGameData?.opponentBaseStats?.name || "Противник";
|
||||
|
||||
if (resultMsgElement) {
|
||||
let winText = `Победа! ${myNameForResult} празднует!`;
|
||||
let loseText = `Поражение! ${opponentNameForResult} оказался(лась) сильнее!`;
|
||||
if (reason === 'opponent_disconnected') {
|
||||
let disconnectedName = data?.disconnectedCharacterName || opponentNameForResult;
|
||||
winText = `${disconnectedName} покинул(а) игру. Победа присуждается вам!`;
|
||||
} else if (reason === 'turn_timeout') {
|
||||
if (!playerWon) {
|
||||
loseText = `Время на ход истекло! Поражение. ${opponentNameForResult} побеждает!`;
|
||||
} else {
|
||||
winText = `Время на ход у ${opponentNameForResult} истекло! Победа!`;
|
||||
}
|
||||
}
|
||||
resultMsgElement.textContent = playerWon ? winText : loseText;
|
||||
resultMsgElement.style.color = playerWon ? 'var(--heal-color)' : 'var(--damage-color)';
|
||||
}
|
||||
|
||||
const opponentPanelElement = uiElements.opponent.panel;
|
||||
if (opponentPanelElement) {
|
||||
opponentPanelElement.classList.remove('dissolving');
|
||||
opponentPanelElement.style.transition = 'none'; opponentPanelElement.offsetHeight;
|
||||
const loserCharacterKeyForDissolve = data?.loserCharacterKey;
|
||||
if (currentActualGameState && currentActualGameState.isGameOver === true && playerWon) {
|
||||
if (loserCharacterKeyForDissolve === 'balard' || loserCharacterKeyForDissolve === 'almagest') {
|
||||
opponentPanelElement.classList.add('dissolving');
|
||||
opponentPanelElement.style.opacity = '0';
|
||||
} else {
|
||||
opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)';
|
||||
}
|
||||
} else {
|
||||
opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)';
|
||||
}
|
||||
opponentPanelElement.style.transition = '';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
if(window.getComputedStyle(gameOverScreenElement).display === 'none') gameOverScreenElement.style.display = 'flex';
|
||||
gameOverScreenElement.style.opacity = '0';
|
||||
requestAnimationFrame(() => {
|
||||
gameOverScreenElement.style.opacity = '1';
|
||||
if (uiElements.gameOver.modalContent) {
|
||||
uiElements.gameOver.modalContent.style.transition = 'transform 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.2), opacity 0.4s ease-out';
|
||||
uiElements.gameOver.modalContent.style.transform = 'scale(1) translateY(0)';
|
||||
uiElements.gameOver.modalContent.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (gameOverScreenElement) {
|
||||
gameOverScreenElement.style.transition = 'none';
|
||||
if (uiElements.gameOver.modalContent) uiElements.gameOver.modalContent.style.transition = 'none';
|
||||
gameOverScreenElement.classList.add(config.CSS_CLASS_HIDDEN || 'hidden');
|
||||
gameOverScreenElement.style.opacity = '0';
|
||||
if (uiElements.gameOver.modalContent) {
|
||||
uiElements.gameOver.modalContent.style.transform = 'scale(0.8) translateY(30px)';
|
||||
uiElements.gameOver.modalContent.style.opacity = '0';
|
||||
}
|
||||
gameOverScreenElement.offsetHeight;
|
||||
}
|
||||
}
|
||||
}, 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();
|
||||
|
||||
})();
|
1399
public/style_alt.css
Normal file
1399
public/style_alt.css
Normal file
File diff suppressed because it is too large
Load Diff
153
server/auth/authService.js
Normal file
153
server/auth/authService.js
Normal file
@ -0,0 +1,153 @@
|
||||
// /server/auth/authService.js
|
||||
const bcrypt = require('bcryptjs'); // Для хеширования паролей
|
||||
const jwt = require('jsonwebtoken'); // <<< ДОБАВЛЕНО
|
||||
const db = require('../core/db'); // Путь к вашему модулю для работы с базой данных
|
||||
|
||||
const SALT_ROUNDS = 10; // Количество раундов для генерации соли bcrypt
|
||||
|
||||
/**
|
||||
* Регистрирует нового пользователя и генерирует JWT.
|
||||
* @param {string} username - Имя пользователя.
|
||||
* @param {string} password - Пароль пользователя.
|
||||
* @returns {Promise<object>} Объект с результатом: { success: boolean, message: string, token?: 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) {
|
||||
const userId = result.insertId;
|
||||
// Генерируем JWT токен
|
||||
const tokenPayload = { userId: userId, username: username };
|
||||
const token = jwt.sign(
|
||||
tokenPayload,
|
||||
process.env.JWT_SECRET, // Используем секрет из .env
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '1h' } // Используем срок из .env или по умолчанию 1 час
|
||||
);
|
||||
|
||||
console.log(`[AuthService] Пользователь "${username}" успешно зарегистрирован с ID: ${userId}. Токен выдан.`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Регистрация прошла успешно! Вы вошли в систему.',
|
||||
token: token, // <<< ВОЗВРАЩАЕМ ТОКЕН
|
||||
userId: userId,
|
||||
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: 'Внутренняя ошибка сервера при регистрации.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет вход пользователя и генерирует JWT.
|
||||
* @param {string} username - Имя пользователя.
|
||||
* @param {string} password - Пароль пользователя.
|
||||
* @returns {Promise<object>} Объект с результатом: { success: boolean, message: string, token?: 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) {
|
||||
// Генерируем JWT токен
|
||||
const tokenPayload = { userId: user.id, username: user.username };
|
||||
const token = jwt.sign(
|
||||
tokenPayload,
|
||||
process.env.JWT_SECRET, // Используем секрет из .env
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '1h' } // Используем срок из .env или по умолчанию 1 час
|
||||
);
|
||||
|
||||
console.log(`[AuthService] Пользователь "${user.username}" (ID: ${user.id}) успешно вошел в систему. Токен выдан.`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Вход выполнен успешно!',
|
||||
token: token, // <<< ВОЗВРАЩАЕМ ТОКЕН
|
||||
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
|
||||
};
|
393
server/bc.js
Normal file
393
server/bc.js
Normal file
@ -0,0 +1,393 @@
|
||||
// /server/bc.js - Главный файл сервера Battle Club
|
||||
|
||||
require('dotenv').config({ path: require('node:path').resolve(process.cwd(), '.env') });
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const path = require('path');
|
||||
const APP_BASE_PATH = process.env.APP_BASE_PATH || "";
|
||||
const jwt = require('jsonwebtoken');
|
||||
const cors = require('cors');
|
||||
// const cookieParser = require('cookie-parser'); // Раскомментируйте, если решите использовать куки для токена напрямую
|
||||
|
||||
const authService = require('./auth/authService');
|
||||
const GameManager = require('./game/GameManager');
|
||||
const db = require('./core/db'); // Используется для auth, не для игр в этом варианте
|
||||
const GAME_CONFIG = require('./core/config');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// --- НАСТРОЙКА EXPRESS ---
|
||||
console.log(`[BC.JS CONFIG] Reading environment variables for Express CORS...`);
|
||||
console.log(`[BC.JS CONFIG] NODE_ENV: ${process.env.NODE_ENV}`);
|
||||
console.log(`[BC.JS CONFIG] process.env.CORS_ORIGIN_CLIENT: ${process.env.CORS_ORIGIN_CLIENT}`);
|
||||
const clientOrigin = process.env.CORS_ORIGIN_CLIENT || (process.env.NODE_ENV === 'development' ? '*' : undefined);
|
||||
console.log(`[BC.JS CONFIG] Effective clientOrigin for HTTP CORS: ${clientOrigin === '*' ? "'*'" : clientOrigin || 'NOT SET (CORS will likely fail if not development)'}`);
|
||||
|
||||
if (!clientOrigin && process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== undefined) {
|
||||
console.warn("[BC.JS CONFIG WARNING] CORS_ORIGIN_CLIENT is not set for a non-development and non-undefined NODE_ENV. HTTP API requests from browsers might be blocked by CORS.");
|
||||
}
|
||||
app.use(cors({
|
||||
origin: clientOrigin,
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true // Важно для работы с куками, если они используются для токенов
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
// app.use(cookieParser()); // Раскомментируйте, если JWT будет передаваться через httpOnly cookie
|
||||
|
||||
const publicPath = path.join(__dirname, '..', 'public');
|
||||
console.log(`[BC.JS CONFIG] Serving static files from: ${publicPath}`);
|
||||
app.use(express.static(publicPath));
|
||||
|
||||
// --- НАСТРОЙКА EJS ---
|
||||
app.set('view engine', 'ejs');
|
||||
// Указываем, где лежат шаблоны. Папка 'views' рядом с bc.js (т.е. server/views)
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
console.log(`[BC.JS CONFIG] EJS view engine configured. Views directory: ${app.get('views')}`);
|
||||
|
||||
|
||||
// --- HTTP МАРШРУТЫ ---
|
||||
|
||||
// Главная страница, рендеринг EJS шаблона
|
||||
app.get('/', (req, res) => {
|
||||
// Попытка извлечь токен из localStorage (недоступно на сервере напрямую)
|
||||
// или из cookie (если настроено).
|
||||
// Для EJS на сервере нам нужно определить состояние пользователя ДО рендеринга.
|
||||
// Это обычно делается через проверку сессии или токена в cookie.
|
||||
// Так как ваш клиент хранит токен в localStorage, при первом GET запросе
|
||||
// на сервер токен не будет доступен в заголовках или куках автоматически.
|
||||
//
|
||||
// Вариант 1: Клиент делает AJAX-запрос для проверки токена после загрузки,
|
||||
// а сервер отдает базовый HTML, который потом обновляется. (Текущий подход с main.js)
|
||||
//
|
||||
// Вариант 2: Сервер отдает базовый HTML, и клиент сам решает, что показывать,
|
||||
// основываясь на токене в localStorage. (Текущий подход с main.js)
|
||||
//
|
||||
// Вариант 3: Передавать токен в cookie (httpOnly для безопасности),
|
||||
// тогда сервер сможет его читать при GET запросе.
|
||||
//
|
||||
// Для простоты демонстрации EJS, предположим, что мы хотим передать
|
||||
// некоторую базовую информацию, а клиентская логика main.js все равно отработает.
|
||||
// Мы не можем здесь напрямую прочитать localStorage клиента.
|
||||
|
||||
res.render('index', { // Рендерим server/views/index.ejs
|
||||
title: 'Battle Club RPG', // Передаем заголовок страницы
|
||||
base_path:APP_BASE_PATH,
|
||||
// Можно передать базовую структуру HTML, а main.js заполнит остальное
|
||||
// Либо, если бы токен был в куках, мы могли бы здесь сделать:
|
||||
// const userData = authService.verifyTokenFromCookie(req.cookies.jwtToken);
|
||||
// isLoggedIn: !!userData, loggedInUsername: userData ? userData.username : '', ...
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// --- HTTP МАРШРУТЫ АУТЕНТИФИКАЦИИ ---
|
||||
app.post('/auth/register', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
console.log(`[BC HTTP /auth/register] Attempt for username: "${username}" from IP: ${req.ip}. Origin: ${req.headers.origin}`);
|
||||
if (!username || !password) {
|
||||
console.warn('[BC HTTP /auth/register] Bad request: Username or password missing.');
|
||||
return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' });
|
||||
}
|
||||
const result = await authService.registerUser(username, password);
|
||||
if (result.success) {
|
||||
console.log(`[BC HTTP /auth/register] Success for "${username}".`);
|
||||
// Если вы используете куки для токена:
|
||||
// res.cookie('jwtToken', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' });
|
||||
res.status(201).json(result);
|
||||
} else {
|
||||
console.warn(`[BC HTTP /auth/register] Failed for "${username}": ${result.message}`);
|
||||
res.status(400).json(result);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
console.log(`[BC HTTP /auth/login] Attempt for username: "${username}" from IP: ${req.ip}. Origin: ${req.headers.origin}`);
|
||||
if (!username || !password) {
|
||||
console.warn('[BC HTTP /auth/login] Bad request: Username or password missing.');
|
||||
return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' });
|
||||
}
|
||||
const result = await authService.loginUser(username, password);
|
||||
if (result.success) {
|
||||
console.log(`[BC HTTP /auth/login] Success for "${username}".`);
|
||||
// Если вы используете куки для токена:
|
||||
// res.cookie('jwtToken', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' });
|
||||
res.json(result);
|
||||
} else {
|
||||
console.warn(`[BC HTTP /auth/login] Failed for "${username}": ${result.message}`);
|
||||
res.status(401).json(result);
|
||||
}
|
||||
});
|
||||
|
||||
// --- НАСТРОЙКА SOCKET.IO ---
|
||||
console.log(`[BC.JS CONFIG] Reading environment variables for Socket.IO CORS...`);
|
||||
console.log(`[BC.JS CONFIG] process.env.CORS_ORIGIN_SOCKET: ${process.env.CORS_ORIGIN_SOCKET}`);
|
||||
const socketCorsOrigin = process.env.CORS_ORIGIN_SOCKET || (process.env.NODE_ENV === 'development' ? '*' : undefined);
|
||||
console.log(`[BC.JS CONFIG] Effective socketCorsOrigin for Socket.IO CORS: ${socketCorsOrigin === '*' ? "'*'" : socketCorsOrigin || 'NOT SET (Socket.IO CORS will likely fail if not development)'}`);
|
||||
|
||||
if (!socketCorsOrigin && process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== undefined) {
|
||||
console.warn("[BC.JS CONFIG WARNING] CORS_ORIGIN_SOCKET is not set for a non-development and non-undefined NODE_ENV. Socket.IO connections from browsers might be blocked by CORS.");
|
||||
}
|
||||
|
||||
const io = new Server(server, {
|
||||
path: '/socket.io/',
|
||||
cors: {
|
||||
origin: socketCorsOrigin,
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true
|
||||
},
|
||||
});
|
||||
console.log(`[BC.JS CONFIG] Socket.IO server configured with path: ${io.path()} and effective CORS origin: ${io.opts.cors.origin === '*' ? "'*'" : io.opts.cors.origin || 'NOT SET'}`);
|
||||
|
||||
const gameManager = new GameManager(io);
|
||||
const loggedInUsersBySocketId = {}; // Этот объект используется только для логирования в bc.js, основная логика в GameManager
|
||||
|
||||
// --- MIDDLEWARE АУТЕНТИФИКАЦИИ SOCKET.IO ---
|
||||
io.use(async (socket, next) => {
|
||||
const token = socket.handshake.auth.token;
|
||||
const clientIp = socket.handshake.headers['x-forwarded-for']?.split(',')[0].trim() || socket.handshake.address;
|
||||
const originHeader = socket.handshake.headers.origin;
|
||||
const socketPath = socket.nsp.name;
|
||||
|
||||
console.log(`[BC Socket.IO Middleware] Auth attempt for socket ${socket.id} from IP ${clientIp}. Token ${token ? 'present' : 'absent'}. Origin: ${originHeader}. Path: ${socketPath}`);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
socket.userData = { userId: decoded.userId, username: decoded.username };
|
||||
console.log(`[BC Socket.IO Middleware] Socket ${socket.id} authenticated for user ${decoded.username} (ID: ${decoded.userId}).`);
|
||||
return next();
|
||||
} catch (err) {
|
||||
console.warn(`[BC Socket.IO Middleware] Socket ${socket.id} auth failed: Invalid token. Error: ${err.message}. Proceeding as unauthenticated.`);
|
||||
// Не вызываем next(new Error(...)) чтобы не отключать сокет сразу,
|
||||
// а позволить клиенту обработать это (например, показать экран логина).
|
||||
// Однако, если бы мы хотели строго запретить неаутентифицированные сокеты:
|
||||
// return next(new Error('Authentication error: Invalid token'));
|
||||
}
|
||||
} else {
|
||||
console.log(`[BC Socket.IO Middleware] Socket ${socket.id} has no token. Proceeding as unauthenticated.`);
|
||||
}
|
||||
// Если токена нет или он невалиден, все равно вызываем next() без ошибки,
|
||||
// чтобы соединение установилось, но socket.userData не будет установлен.
|
||||
// Логика на стороне сервера должна будет проверять наличие socket.userData.
|
||||
next();
|
||||
});
|
||||
|
||||
// --- ОБРАБОТЧИКИ СОБЫТИЙ SOCKET.IO ---
|
||||
io.on('connection', (socket) => {
|
||||
const clientIp = socket.handshake.headers['x-forwarded-for']?.split(',')[0].trim() || socket.handshake.address;
|
||||
const originHeader = socket.handshake.headers.origin;
|
||||
const socketPath = socket.nsp.name;
|
||||
|
||||
if (socket.userData && socket.userData.userId) {
|
||||
console.log(`[BC Socket.IO Connection] Authenticated user ${socket.userData.username} (ID: ${socket.userData.userId}) connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}`);
|
||||
loggedInUsersBySocketId[socket.id] = socket.userData; // Для логирования здесь
|
||||
|
||||
if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') {
|
||||
console.log(`[BC Socket.IO Connection] Sending initial available PvP games list to authenticated user ${socket.userData.username} (Socket: ${socket.id})`);
|
||||
const availableGames = gameManager.getAvailablePvPGamesListForClient();
|
||||
socket.emit('availablePvPGamesList', availableGames);
|
||||
} else {
|
||||
console.error("[BC Socket.IO Connection] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found for sending initial list!");
|
||||
}
|
||||
|
||||
if (gameManager && typeof gameManager.handleRequestGameState === 'function') {
|
||||
gameManager.handleRequestGameState(socket, socket.userData.userId);
|
||||
} else {
|
||||
console.error("[BC Socket.IO Connection] CRITICAL: gameManager or handleRequestGameState not found for authenticated user!");
|
||||
}
|
||||
} else {
|
||||
console.log(`[BC Socket.IO Connection] Unauthenticated user connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}.`);
|
||||
// Неаутентифицированные пользователи не должны иметь доступа к игровым функциям,
|
||||
// но могут получать базовую информацию, если это предусмотрено.
|
||||
// Например, список игр, если он публичный (в данном проекте он для залогиненных).
|
||||
// socket.emit('authRequired', { message: 'Please login to access game features.' }); // Можно отправить такое сообщение
|
||||
}
|
||||
|
||||
socket.on('logout', () => { // Это событие инициируется клиентом, когда он нажимает "Выйти"
|
||||
const username = socket.userData?.username || 'UnknownUserOnLogout';
|
||||
const userId = socket.userData?.userId;
|
||||
console.log(`[BC Socket.IO 'logout' event] User: ${username} (ID: ${userId || 'N/A'}, Socket: ${socket.id}). Performing server-side cleanup for logout.`);
|
||||
|
||||
// Здесь важно не просто удалить данные из loggedInUsersBySocketId (это локальный объект для логов),
|
||||
// а также убедиться, что GameManager корректно обрабатывает выход игрока из игры, если он там был.
|
||||
// GameManager.handleDisconnect должен вызываться автоматически при socket.disconnect() со стороны клиента.
|
||||
// Дополнительно, если logout это не просто disconnect, а явное действие:
|
||||
if (userId && gameManager) {
|
||||
// Если игрок был в игре, GameManager.handleDisconnect должен был отработать при последующем socket.disconnect().
|
||||
// Если нужно специфическое действие для logout перед disconnect:
|
||||
// gameManager.handleExplicitLogout(userId, socket.id); // (потребовало бы добавить такой метод в GameManager)
|
||||
}
|
||||
|
||||
if (loggedInUsersBySocketId[socket.id]) {
|
||||
delete loggedInUsersBySocketId[socket.id];
|
||||
}
|
||||
socket.userData = null; // Очищаем данные пользователя на сокете
|
||||
// Клиент сам вызовет socket.disconnect() и socket.connect() с новым (null) токеном.
|
||||
console.log(`[BC Socket.IO 'logout' event] Session data for socket ${socket.id} cleared on server. Client is expected to disconnect and reconnect.`);
|
||||
});
|
||||
|
||||
|
||||
socket.on('playerSurrender', () => {
|
||||
if (!socket.userData?.userId) {
|
||||
console.warn(`[BC Socket.IO 'playerSurrender'] Denied for unauthenticated socket ${socket.id}.`);
|
||||
socket.emit('gameError', { message: 'Необходимо войти в систему, чтобы сдаться в игре.' });
|
||||
return;
|
||||
}
|
||||
const identifier = socket.userData.userId;
|
||||
const username = socket.userData.username;
|
||||
console.log(`[BC Socket.IO 'playerSurrender'] Request from user ${username} (ID: ${identifier}, Socket: ${socket.id})`);
|
||||
if (gameManager && typeof gameManager.handlePlayerSurrender === 'function') {
|
||||
gameManager.handlePlayerSurrender(identifier);
|
||||
} else {
|
||||
console.error("[BC Socket.IO 'playerSurrender'] CRITICAL: gameManager or handlePlayerSurrender method not found!");
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при обработке сдачи игры.' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('leaveAiGame', () => {
|
||||
if (!socket.userData?.userId) {
|
||||
console.warn(`[BC Socket.IO 'leaveAiGame'] Denied for unauthenticated socket ${socket.id}.`);
|
||||
socket.emit('gameError', { message: 'Необходимо войти в систему, чтобы покинуть AI игру.' });
|
||||
return;
|
||||
}
|
||||
const identifier = socket.userData.userId;
|
||||
const username = socket.userData.username;
|
||||
console.log(`[BC Socket.IO 'leaveAiGame'] Request from user ${username} (ID: ${identifier}, Socket: ${socket.id})`);
|
||||
|
||||
if (gameManager && typeof gameManager.handleLeaveAiGame === 'function') {
|
||||
gameManager.handleLeaveAiGame(identifier, socket); // Передаем сокет, если он нужен для ответа
|
||||
} else {
|
||||
console.error("[BC Socket.IO 'leaveAiGame'] CRITICAL: gameManager or handleLeaveAiGame method not found!");
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при выходе из AI игры.' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('createGame', (data) => {
|
||||
if (!socket.userData?.userId) {
|
||||
console.warn(`[BC Socket.IO 'createGame'] Denied for unauthenticated socket ${socket.id}.`);
|
||||
socket.emit('gameError', { message: 'Необходимо войти в систему для создания игры.' });
|
||||
return;
|
||||
}
|
||||
const identifier = socket.userData.userId;
|
||||
const mode = data?.mode || 'ai';
|
||||
const charKey = data?.characterKey;
|
||||
console.log(`[BC Socket.IO 'createGame'] Request from ${socket.userData.username} (ID: ${identifier}). Mode: ${mode}, Char: ${charKey}`);
|
||||
gameManager.createGame(socket, mode, charKey, identifier);
|
||||
});
|
||||
|
||||
socket.on('joinGame', (data) => {
|
||||
if (!socket.userData?.userId) {
|
||||
console.warn(`[BC Socket.IO 'joinGame'] Denied for unauthenticated socket ${socket.id}.`);
|
||||
socket.emit('gameError', { message: 'Необходимо войти для присоединения к PvP игре.' });
|
||||
return;
|
||||
}
|
||||
const gameId = data?.gameId;
|
||||
const userId = socket.userData.userId; // Используем userId из socket.userData
|
||||
const charKey = data?.characterKey; // Клиент может предлагать персонажа при присоединении
|
||||
console.log(`[BC Socket.IO 'joinGame'] Request from ${socket.userData.username} (ID: ${userId}). GameID: ${gameId}, Char: ${charKey}`);
|
||||
gameManager.joinGame(socket, gameId, userId, charKey);
|
||||
});
|
||||
|
||||
socket.on('findRandomGame', (data) => {
|
||||
if (!socket.userData?.userId) {
|
||||
console.warn(`[BC Socket.IO 'findRandomGame'] Denied for unauthenticated socket ${socket.id}.`);
|
||||
socket.emit('gameError', { message: 'Необходимо войти для поиска случайной PvP игры.' });
|
||||
return;
|
||||
}
|
||||
const userId = socket.userData.userId;
|
||||
const charKey = data?.characterKey;
|
||||
console.log(`[BC Socket.IO 'findRandomGame'] Request from ${socket.userData.username} (ID: ${userId}). PrefChar: ${charKey}`);
|
||||
gameManager.findAndJoinRandomPvPGame(socket, charKey, userId);
|
||||
});
|
||||
|
||||
socket.on('requestPvPGameList', () => {
|
||||
// Этот запрос может приходить и от неаутентифицированных, если дизайн это позволяет.
|
||||
// В текущей логике GameManager, список игр формируется для залогиненных.
|
||||
// Если не залогинен, можно отправить пустой список или специальное сообщение.
|
||||
console.log(`[BC Socket.IO 'requestPvPGameList'] Request from socket ${socket.id} (User: ${socket.userData?.username || 'Unauth'}).`);
|
||||
if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') {
|
||||
const availableGames = gameManager.getAvailablePvPGamesListForClient(); // GameManager сам решит, что вернуть
|
||||
socket.emit('availablePvPGamesList', availableGames);
|
||||
} else {
|
||||
console.error("[BC Socket.IO 'requestPvPGameList'] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found!");
|
||||
socket.emit('availablePvPGamesList', []);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('requestGameState', () => {
|
||||
if (!socket.userData?.userId) {
|
||||
console.warn(`[BC Socket.IO 'requestGameState'] Denied for unauthenticated socket ${socket.id}.`);
|
||||
// Важно! Клиент main.js ожидает gameNotFound, чтобы показать экран логина
|
||||
socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' });
|
||||
return;
|
||||
}
|
||||
const userId = socket.userData.userId;
|
||||
console.log(`[BC Socket.IO 'requestGameState'] Request from ${socket.userData.username} (ID: ${userId}, Socket: ${socket.id})`);
|
||||
gameManager.handleRequestGameState(socket, userId);
|
||||
});
|
||||
|
||||
socket.on('playerAction', (actionData) => {
|
||||
if (!socket.userData?.userId) {
|
||||
console.warn(`[BC Socket.IO 'playerAction'] Denied for unauthenticated socket ${socket.id}. Action: ${actionData?.actionType}`);
|
||||
socket.emit('gameError', { message: 'Действие не разрешено: пользователь не аутентифицирован.' });
|
||||
return;
|
||||
}
|
||||
const identifier = socket.userData.userId;
|
||||
console.log(`[BC Socket.IO 'playerAction'] Action from ${socket.userData.username} (ID: ${identifier}). Type: ${actionData?.actionType}, Details: ${JSON.stringify(actionData)}`);
|
||||
gameManager.handlePlayerAction(identifier, actionData);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
const identifier = socket.userData?.userId; // Получаем из socket.userData, если был аутентифицирован
|
||||
const username = socket.userData?.username || loggedInUsersBySocketId[socket.id]?.username || 'UnauthenticatedOrUnknown';
|
||||
|
||||
console.log(`[BC Socket.IO Disconnect] User ${username} (ID: ${identifier || 'N/A'}, Socket: ${socket.id}) disconnected. Reason: ${reason}.`);
|
||||
if (identifier && gameManager) { // Если пользователь был аутентифицирован
|
||||
gameManager.handleDisconnect(socket.id, identifier);
|
||||
}
|
||||
if (loggedInUsersBySocketId[socket.id]) { // Очистка из локального объекта для логов
|
||||
delete loggedInUsersBySocketId[socket.id];
|
||||
}
|
||||
// socket.userData автоматически очищается при дисконнекте самого объекта сокета
|
||||
});
|
||||
});
|
||||
|
||||
// --- ЗАПУСК СЕРВЕРА ---
|
||||
const PORT = parseInt(process.env.BC_APP_PORT || '3200', 10);
|
||||
const HOSTNAME = process.env.BC_APP_HOSTNAME || '127.0.0.1';
|
||||
|
||||
if (isNaN(PORT)) {
|
||||
console.error(`[BC Server FATAL] Invalid BC_APP_PORT: "${process.env.BC_APP_PORT}". Expected a number.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.listen(PORT, HOSTNAME, () => {
|
||||
console.log(`[BC Server Startup] Battle Club HTTP Application Server running at http://${HOSTNAME}:${PORT}`);
|
||||
if (HOSTNAME === '127.0.0.1') {
|
||||
console.log(`[BC Server Startup] Server is listening on localhost only.`);
|
||||
} else if (HOSTNAME === '0.0.0.0') {
|
||||
console.log(`[BC Server Startup] Server is listening on all available network interfaces.`);
|
||||
} else {
|
||||
console.log(`[BC Server Startup] Server is listening on a specific interface: ${HOSTNAME}.`);
|
||||
}
|
||||
console.log(`[BC Server Startup] Static files served from: ${publicPath}`);
|
||||
console.log(`[BC.JS Startup] EJS views directory: ${app.get('views')}`);
|
||||
console.log(`[BC.JS Startup] Socket.IO server effective path: ${io.path()}`);
|
||||
console.log(`[BC.JS Startup] HTTP API effective CORS origin: ${clientOrigin === '*' ? "'*'" : clientOrigin || 'NOT SET'}`);
|
||||
console.log(`[BC.JS Startup] Socket.IO effective CORS origin: ${io.opts.cors.origin === '*' ? "'*'" : io.opts.cors.origin || 'NOT SET'}`);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('[BC Server FATAL UnhandledRejection] Reason:', reason, 'Promise:', promise);
|
||||
// process.exit(1); // Можно раскомментировать для падения сервера при неперехваченных промисах
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[BC Server FATAL UncaughtException] Error:', err);
|
||||
process.exit(1); // Критические ошибки должны приводить к перезапуску через process manager
|
||||
});
|
112
server/core/config.js
Normal file
112
server/core/config.js
Normal file
@ -0,0 +1,112 @@
|
||||
// /server/core/config.js
|
||||
|
||||
const GAME_CONFIG = {
|
||||
// --- Баланс Игры ---
|
||||
BLOCK_DAMAGE_REDUCTION: 0.5, // Множитель урона при блоке (0.5 = 50% снижение)
|
||||
DAMAGE_VARIATION_MIN: 0.9, // Минимальный множитель урона (0.9 = 90%)
|
||||
DAMAGE_VARIATION_RANGE: 0.2, // Диапазон вариации урона (0.2 = от 90% до 110%)
|
||||
HEAL_VARIATION_MIN: 0.8, // Минимальный множитель лечения (0.8 = 80%)
|
||||
HEAL_VARIATION_RANGE: 0.4, // Диапазон вариации лечения (0.4 = от 80% до 120%)
|
||||
NATURE_STRENGTH_MANA_REGEN: 10, // Количество маны, восстанавливаемое "Силой природы" (и ее аналогом)
|
||||
|
||||
// --- Условия ИИ и Игрока ---
|
||||
OPPONENT_HEAL_THRESHOLD_PERCENT: 50, // Процент HP Баларда, НИЖЕ которого он будет пытаться лечиться (для AI)
|
||||
PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT: 60, // Процент HP Баларда, НИЖЕ которого Елена использует "доминирующие" насмешки (для AI/текстов)
|
||||
PLAYER_HP_BLEED_THRESHOLD_PERCENT: 60, // % HP Елены, НИЖЕ которого Балард предпочитает Кровотечение Безмолвию (для AI)
|
||||
BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD: 60, // % Маны Елены, ВЫШЕ которого Балард может использовать "Похищение Света" (для AI)
|
||||
|
||||
// --- Способности Баларда (AI) - Конфигурация ---
|
||||
SILENCE_DURATION: 3, // Длительность Безмолвия в ходах Елены (после хода Баларда)
|
||||
SILENCE_SUCCESS_RATE: 0.7, // Шанс успеха наложения Безмолвия (70%)
|
||||
BALARD_SILENCE_ABILITY_COST: 15, // Стоимость "Эха Безмолвия" в Ярости
|
||||
BALARD_SILENCE_INTERNAL_COOLDOWN: 5, // К-во ходов Баларда КД ПОСЛЕ успешного использования Безмолвия
|
||||
// BALARD_BLEED_COST: 15, // Если будете добавлять способность Кровотечение
|
||||
// BALARD_BLEED_POWER: 5,
|
||||
// BALARD_BLEED_DURATION: 2,
|
||||
// BALARD_BLEED_COOLDOWN: 3,
|
||||
|
||||
// --- Таймер Хода ---
|
||||
TURN_DURATION_SECONDS: 60, // Длительность хода в секундах
|
||||
TURN_DURATION_MS: 60 * 1000, // Длительность хода в миллисекундах
|
||||
TIMER_UPDATE_INTERVAL_MS: 1000, // Интервал обновления таймера на клиенте (в мс)
|
||||
RECONNECT_TIMEOUT_MS: 30000,
|
||||
|
||||
// --- Идентификаторы и Типы ---
|
||||
PLAYER_ID: 'player', // Технический идентификатор для слота 'Игрок 1'
|
||||
OPPONENT_ID: 'opponent', // Технический идентификатор для слота 'Игрок 2' / 'Противник'
|
||||
ACTION_TYPE_HEAL: 'heal',
|
||||
ACTION_TYPE_DAMAGE: 'damage',
|
||||
ACTION_TYPE_BUFF: 'buff',
|
||||
ACTION_TYPE_DISABLE: 'disable', // Тип для контроля (безмолвие, стан и т.п.)
|
||||
ACTION_TYPE_DEBUFF: 'debuff', // Тип для ослаблений, DoT и т.п.
|
||||
ACTION_TYPE_DRAIN: 'drain', // Тип для Похищения Света
|
||||
|
||||
// --- Строки для UI (могут быть полезны и на сервере для логов) ---
|
||||
STATUS_READY: 'Готов(а)', // Сделал универсальным
|
||||
STATUS_BLOCKING: 'Защищается',
|
||||
|
||||
// --- Типы Логов (для CSS классов на клиенте, и для структурирования на сервере) ---
|
||||
LOG_TYPE_INFO: 'info',
|
||||
LOG_TYPE_DAMAGE: 'damage',
|
||||
LOG_TYPE_HEAL: 'heal',
|
||||
LOG_TYPE_TURN: 'turn',
|
||||
LOG_TYPE_SYSTEM: 'system',
|
||||
LOG_TYPE_BLOCK: 'block',
|
||||
LOG_TYPE_EFFECT: 'effect',
|
||||
|
||||
// --- CSS Классы (в основном для клиента, но константы могут быть полезны для согласованности) ---
|
||||
CSS_CLASS_BLOCKING: 'blocking',
|
||||
CSS_CLASS_NOT_ENOUGH_RESOURCE: 'not-enough-resource',
|
||||
CSS_CLASS_BUFF_IS_ACTIVE: 'buff-is-active',
|
||||
CSS_CLASS_ATTACK_BUFFED: 'attack-buffed',
|
||||
CSS_CLASS_SHAKING: 'is-shaking',
|
||||
CSS_CLASS_CASTING_PREFIX: 'is-casting-', // Например: is-casting-fireball
|
||||
CSS_CLASS_HIDDEN: 'hidden',
|
||||
CSS_CLASS_ABILITY_BUTTON: 'ability-button',
|
||||
CSS_CLASS_ABILITY_SILENCED: 'is-silenced',
|
||||
CSS_CLASS_ABILITY_ON_COOLDOWN: 'is-on-cooldown', // Для отображения кулдауна
|
||||
|
||||
// --- Задержки (в миллисекундах) ---
|
||||
// Эти задержки теперь в основном будут управляться сервером при отправке событий или планировании AI ходов
|
||||
DELAY_OPPONENT_TURN: 1200, // Задержка перед ходом AI
|
||||
DELAY_AFTER_PLAYER_ACTION: 500, // Сервер может использовать это для паузы перед следующим событием
|
||||
// DELAY_AFTER_BLOCK: 500, // Менее релевантно для сервера напрямую
|
||||
DELAY_INIT: 100, // Для клиентской инициализации, если нужна
|
||||
DELAY_BEFORE_VICTORY_MODAL: 1500, // Для клиента, после получения gameOver
|
||||
MODAL_TRANSITION_DELAY: 10, // Для анимации модалки на клиенте
|
||||
|
||||
// --- Длительности анимаций (в миллисекундах, в основном для клиента, но сервер может знать для таймингов) ---
|
||||
ANIMATION_SHAKE_DURATION: 400,
|
||||
ANIMATION_CAST_DURATION: 600,
|
||||
ANIMATION_DISSOLVE_DURATION: 6000, // var(--dissolve-duration) из CSS
|
||||
|
||||
// --- Внутренние ID способностей (для удобства в логике, нужны и на сервере, и на клиенте) ---
|
||||
// Игрока (Елена)
|
||||
ABILITY_ID_HEAL: 'heal', // Малое Исцеление
|
||||
ABILITY_ID_FIREBALL: 'fireball', // Огненный Шар
|
||||
ABILITY_ID_NATURE_STRENGTH: 'naturesStrength', // Сила Природы
|
||||
ABILITY_ID_DEFENSE_AURA: 'defenseAura', // Аура Защиты
|
||||
ABILITY_ID_HYPNOTIC_GAZE: 'hypnoticGaze', // Гипнотический взгляд
|
||||
ABILITY_ID_SEAL_OF_WEAKNESS: 'sealOfWeakness', // Печать Слабости
|
||||
|
||||
// Противника (Балард - AI)
|
||||
ABILITY_ID_BALARD_HEAL: 'darkPatronage', // Покровительство Тьмы
|
||||
ABILITY_ID_BALARD_SILENCE: 'echoesOfSilence', // Эхо Безмолвия
|
||||
ABILITY_ID_BALARD_MANA_DRAIN: 'manaDrainHeal', // Похищение Света
|
||||
// ABILITY_ID_BALARD_BLEED: 'balardBleed', // Если будете добавлять
|
||||
|
||||
// Противника (Альмагест - PvP - зеркало Елены)
|
||||
ABILITY_ID_ALMAGEST_HEAL: 'darkHeal', // Темное Восстановление (Аналог heal)
|
||||
ABILITY_ID_ALMAGEST_DAMAGE: 'shadowBolt', // Теневой Сгусток (Аналог fireball)
|
||||
ABILITY_ID_ALMAGEST_BUFF_ATTACK: 'shadowEmpowerment', // Усиление Тьмой (Аналог naturesStrength)
|
||||
ABILITY_ID_ALMAGEST_BUFF_DEFENSE: 'voidShield', // Щит Пустоты (Аналог defenseAura)
|
||||
ABILITY_ID_ALMAGEST_DISABLE: 'mindShatter', // Раскол Разума (Аналог hypnoticGaze)
|
||||
ABILITY_ID_ALMAGEST_DEBUFF: 'curseOfDecay', // Проклятие Увядания (Аналог sealOfWeakness)
|
||||
};
|
||||
|
||||
// Для использования в Node.js модулях
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
||||
module.exports = GAME_CONFIG;
|
||||
}
|
||||
|
||||
// console.log("config.js loaded from server/core/ and GAME_CONFIG object created/exported.");
|
94
server/core/db.js
Normal file
94
server/core/db.js
Normal file
@ -0,0 +1,94 @@
|
||||
// /server/core/db.js
|
||||
require('dotenv').config({ path: require('node:path').resolve(process.cwd(), '.env') }); // Загружаем переменные из .env в process.env
|
||||
const mysql = require('mysql2'); // Используем mysql2 для поддержки промисов и улучшенной производительности
|
||||
|
||||
// Конфигурация подключения к вашей базе данных MySQL
|
||||
// Значения теперь берутся из переменных окружения (файла .env)
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER, // Обязательно должно быть задано в .env
|
||||
password: process.env.DB_PASSWORD, // Обязательно должно быть задано в .env
|
||||
database: process.env.DB_NAME, // Обязательно должно быть задано в .env
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10), // Порт по умолчанию 3306, если не указан
|
||||
waitForConnections: process.env.DB_WAIT_FOR_CONNECTIONS ? (process.env.DB_WAIT_FOR_CONNECTIONS === 'true') : true,
|
||||
connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT || '10', 10),
|
||||
queueLimit: parseInt(process.env.DB_QUEUE_LIMIT || '0', 10)
|
||||
};
|
||||
|
||||
// Проверка, что все обязательные переменные окружения для БД заданы
|
||||
if (!dbConfig.user || !dbConfig.password || !dbConfig.database || !dbConfig.host) {
|
||||
console.error('[DB FATAL] Не все обязательные переменные окружения для БД заданы!');
|
||||
console.error('Убедитесь, что у вас есть файл .env в корне проекта и он содержит как минимум:');
|
||||
console.error('DB_HOST, DB_USER, DB_PASSWORD, DB_NAME');
|
||||
console.error('Текущие загруженные (некоторые могут быть undefined):');
|
||||
console.error(` DB_HOST: ${process.env.DB_HOST}`);
|
||||
console.error(` DB_USER: ${process.env.DB_USER}`);
|
||||
console.error(` DB_PASSWORD: ${process.env.DB_PASSWORD ? '****** (задано)' : 'undefined'}`); // Не выводим пароль в лог
|
||||
console.error(` DB_NAME: ${process.env.DB_NAME}`);
|
||||
console.error(` DB_PORT: ${process.env.DB_PORT}`);
|
||||
process.exit(1); // Завершаем приложение, так как без БД оно не сможет работать корректно.
|
||||
}
|
||||
|
||||
// Создаем пул соединений.
|
||||
let pool;
|
||||
try {
|
||||
pool = mysql.createPool(dbConfig);
|
||||
console.log('[DB] Пул соединений MySQL успешно создан с конфигурацией из переменных окружения.');
|
||||
} catch (error) {
|
||||
console.error('[DB FATAL] Не удалось создать пул соединений MySQL. Проверьте конфигурацию и переменные окружения. Ошибка:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Обертка для выполнения запросов с использованием промисов из пула
|
||||
const promisePool = pool.promise();
|
||||
|
||||
// Проверка соединения (опционально, но полезно для отладки при запуске)
|
||||
if (promisePool) {
|
||||
promisePool.getConnection()
|
||||
.then(connection => {
|
||||
console.log(`[DB] Успешно подключено к базе данных MySQL (${dbConfig.database}) на ${dbConfig.host}:${dbConfig.port} и получено соединение из пула.`);
|
||||
connection.release();
|
||||
console.log('[DB] Соединение возвращено в пул.');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[DB] Ошибка при попытке получить соединение из пула или при подключении к MySQL:', err.message);
|
||||
// Выводим полный объект ошибки для диагностики, если это не просто ошибка конфигурации
|
||||
if (err.code !== 'ER_ACCESS_DENIED_ERROR' && err.code !== 'ER_BAD_DB_ERROR' && err.code !== 'ECONNREFUSED') {
|
||||
console.error('[DB] Полные детали ошибки:', err);
|
||||
}
|
||||
|
||||
if (err.code === 'PROTOCOL_CONNECTION_LOST') {
|
||||
console.error('[DB] Соединение с БД было потеряно.');
|
||||
} else if (err.code === 'ER_CON_COUNT_ERROR') {
|
||||
console.error('[DB] В БД слишком много соединений.');
|
||||
} else if (err.code === 'ECONNREFUSED') {
|
||||
console.error(`[DB] Соединение с БД было отклонено. Убедитесь, что сервер MySQL запущен и доступен по адресу ${dbConfig.host}:${dbConfig.port}.`);
|
||||
} else if (err.code === 'ER_ACCESS_DENIED_ERROR') {
|
||||
console.error(`[DB] Доступ к БД запрещен для пользователя '${dbConfig.user}'. Проверьте имя пользователя и пароль в вашем файле .env.`);
|
||||
} else if (err.code === 'ER_BAD_DB_ERROR') {
|
||||
console.error(`[DB] База данных "${dbConfig.database}" не найдена. Убедитесь, что она создана на сервере MySQL и указана верно в .env (DB_NAME).`);
|
||||
} else {
|
||||
console.error(`[DB] Неизвестная ошибка подключения к MySQL. Код: ${err.code}`);
|
||||
}
|
||||
// process.exit(1); // Раскомментируйте, если хотите падать при ошибке подключения
|
||||
});
|
||||
} else {
|
||||
console.error('[DB FATAL] promisePool не был создан. Это не должно было случиться.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Экспортируем пул с промисами
|
||||
module.exports = promisePool;
|
||||
|
||||
/*
|
||||
Пример SQL для создания таблицы пользователей (если ее еще нет):
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
*/
|
93
server/core/logger.js
Normal file
93
server/core/logger.js
Normal file
@ -0,0 +1,93 @@
|
||||
// /server/core/logger.js
|
||||
|
||||
/**
|
||||
* Простой логгер-обертка.
|
||||
* В будущем можно заменить на более продвинутое решение (Winston, Pino),
|
||||
* сохранив этот же интерфейс.
|
||||
*/
|
||||
|
||||
const LOG_LEVELS = {
|
||||
DEBUG: 'DEBUG',
|
||||
INFO: 'INFO',
|
||||
WARN: 'WARN',
|
||||
ERROR: 'ERROR',
|
||||
FATAL: 'FATAL'
|
||||
};
|
||||
|
||||
// Можно установить минимальный уровень логирования из переменной окружения или конфига
|
||||
const CURRENT_LOG_LEVEL = process.env.LOG_LEVEL || LOG_LEVELS.INFO;
|
||||
|
||||
function shouldLog(level) {
|
||||
const levelsOrder = [LOG_LEVELS.DEBUG, LOG_LEVELS.INFO, LOG_LEVELS.WARN, LOG_LEVELS.ERROR, LOG_LEVELS.FATAL];
|
||||
return levelsOrder.indexOf(level) >= levelsOrder.indexOf(CURRENT_LOG_LEVEL);
|
||||
}
|
||||
|
||||
function formatMessage(level, moduleName, message, ...optionalParams) {
|
||||
const timestamp = new Date().toISOString();
|
||||
let formattedMessage = `${timestamp} [${level}]`;
|
||||
if (moduleName) {
|
||||
formattedMessage += ` [${moduleName}]`;
|
||||
}
|
||||
formattedMessage += `: ${message}`;
|
||||
|
||||
// Обработка дополнительных параметров (например, объектов ошибок)
|
||||
const paramsString = optionalParams.map(param => {
|
||||
if (param instanceof Error) {
|
||||
return `\n${param.stack || param.message}`;
|
||||
}
|
||||
if (typeof param === 'object') {
|
||||
try {
|
||||
return `\n${JSON.stringify(param, null, 2)}`;
|
||||
} catch (e) {
|
||||
return '\n[Unserializable Object]';
|
||||
}
|
||||
}
|
||||
return param;
|
||||
}).join(' ');
|
||||
|
||||
return `${formattedMessage}${paramsString ? ' ' + paramsString : ''}`;
|
||||
}
|
||||
|
||||
const logger = {
|
||||
debug: (moduleName, message, ...optionalParams) => {
|
||||
if (shouldLog(LOG_LEVELS.DEBUG)) {
|
||||
console.debug(formatMessage(LOG_LEVELS.DEBUG, moduleName, message, ...optionalParams));
|
||||
}
|
||||
},
|
||||
info: (moduleName, message, ...optionalParams) => {
|
||||
if (shouldLog(LOG_LEVELS.INFO)) {
|
||||
console.info(formatMessage(LOG_LEVELS.INFO, moduleName, message, ...optionalParams));
|
||||
}
|
||||
},
|
||||
warn: (moduleName, message, ...optionalParams) => {
|
||||
if (shouldLog(LOG_LEVELS.WARN)) {
|
||||
console.warn(formatMessage(LOG_LEVELS.WARN, moduleName, message, ...optionalParams));
|
||||
}
|
||||
},
|
||||
error: (moduleName, message, ...optionalParams) => {
|
||||
if (shouldLog(LOG_LEVELS.ERROR)) {
|
||||
console.error(formatMessage(LOG_LEVELS.ERROR, moduleName, message, ...optionalParams));
|
||||
}
|
||||
},
|
||||
fatal: (moduleName, message, ...optionalParams) => { // Fatal обычно означает, что приложение не может продолжать работу
|
||||
if (shouldLog(LOG_LEVELS.FATAL)) {
|
||||
console.error(formatMessage(LOG_LEVELS.FATAL, moduleName, message, ...optionalParams));
|
||||
// В реальном приложении здесь может быть process.exit(1) после логирования
|
||||
}
|
||||
},
|
||||
// Generic log function if needed, defaults to INFO
|
||||
log: (moduleName, message, ...optionalParams) => {
|
||||
logger.info(moduleName, message, ...optionalParams);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
|
||||
/*
|
||||
Пример использования в другом файле:
|
||||
const logger = require('../core/logger'); // Путь зависит от местоположения
|
||||
|
||||
logger.info('GameManager', 'Новая игра создана', { gameId: '123', mode: 'pvp' });
|
||||
logger.error('AuthService', 'Ошибка аутентификации пользователя', new Error('Пароль неверный'));
|
||||
logger.debug('GameInstance', 'Состояние игрока обновлено:', playerStateObject);
|
||||
*/
|
178
server/data/characterAbilities.js
Normal file
178
server/data/characterAbilities.js
Normal file
@ -0,0 +1,178 @@
|
||||
// /server/data/characterAbilities.js
|
||||
|
||||
const GAME_CONFIG = require('../core/config'); // Путь к конфигу из server/data/ в server/core/
|
||||
|
||||
// Способности Игрока (Елена)
|
||||
const elenaAbilities = [
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_HEAL,
|
||||
name: 'Малое Исцеление',
|
||||
cost: 20,
|
||||
type: GAME_CONFIG.ACTION_TYPE_HEAL,
|
||||
power: 30,
|
||||
description: 'Восстанавливает ~30 HP'
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_FIREBALL,
|
||||
name: 'Огненный Шар',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DAMAGE,
|
||||
power: 25,
|
||||
description: 'Наносит ~25 урона врагу'
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH,
|
||||
name: 'Сила Природы',
|
||||
cost: 15,
|
||||
type: GAME_CONFIG.ACTION_TYPE_BUFF,
|
||||
duration: 4, // Общая длительность эффекта
|
||||
// Описание теперь может использовать configToUse (который будет GAME_CONFIG)
|
||||
descriptionFunction: (configToUse, opponentBaseStats) => `Восст. ${configToUse.NATURE_STRENGTH_MANA_REGEN} маны при след. атаке. Эффект длится ${4 - 1} хода после применения.`,
|
||||
isDelayed: true // Этот эффект применяется ПОСЛЕ следующей атаки, а не сразу
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_DEFENSE_AURA,
|
||||
name: 'Аура Защиты',
|
||||
cost: 15,
|
||||
type: GAME_CONFIG.ACTION_TYPE_BUFF,
|
||||
duration: 3,
|
||||
grantsBlock: true, // Дает эффект блока на время действия
|
||||
descriptionFunction: (configToUse, opponentBaseStats) => `Снижает урон на ${configToUse.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)`
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE,
|
||||
name: 'Гипнотический взгляд',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DISABLE,
|
||||
effectDuration: 2, // Длительность безмолвия в ходах противника
|
||||
cooldown: 6,
|
||||
power: 5, // Урон в ход от взгляда
|
||||
description: 'Накладывает на противника полное безмолвие на 2 хода и наносит 5 урона каждый его ход. КД: 6 х.'
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS,
|
||||
name: 'Печать Слабости',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DEBUFF,
|
||||
effectDuration: 3, // Длительность дебаффа
|
||||
power: 10, // Количество ресурса противника, сжигаемое каждый ход
|
||||
cooldown: 5,
|
||||
// Описание теперь может адаптироваться к ресурсу оппонента
|
||||
descriptionFunction: (configToUse, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats ? oppStats.resourceName : 'ресурса'} противника каждый его ход в течение 3 ходов. КД: 5 х.`
|
||||
}
|
||||
];
|
||||
|
||||
// Способности Противника (Балард - AI)
|
||||
const balardAbilities = [
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_BALARD_HEAL,
|
||||
name: 'Покровительство Тьмы',
|
||||
cost: 20,
|
||||
type: GAME_CONFIG.ACTION_TYPE_HEAL,
|
||||
power: 25,
|
||||
successRate: 0.60, // Шанс успеха
|
||||
description: 'Исцеляет ~25 HP с 60% шансом',
|
||||
// Условие для AI: HP ниже порога
|
||||
condition: (opSt, plSt, currentGameState, configToUse) => {
|
||||
return (opSt.currentHp / opSt.maxHp) * 100 < configToUse.OPPONENT_HEAL_THRESHOLD_PERCENT;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_BALARD_SILENCE,
|
||||
name: 'Эхо Безмолвия',
|
||||
cost: GAME_CONFIG.BALARD_SILENCE_ABILITY_COST,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DISABLE,
|
||||
descriptionFunction: (configToUse, opponentBaseStats) => `Шанс ${configToUse.SILENCE_SUCCESS_RATE * 100}% заглушить случайное заклинание Елены на ${configToUse.SILENCE_DURATION} х.`,
|
||||
condition: (opSt, plSt, currentGameState, configToUse) => {
|
||||
const hpPercent = (opSt.currentHp / opSt.maxHp) * 100;
|
||||
const isElenaAlreadySilenced = currentGameState?.player.disabledAbilities?.length > 0 ||
|
||||
currentGameState?.player.activeEffects?.some(eff => eff.id.startsWith('playerSilencedOn_')); // Проверяем и специфичное, и общее безмолвие на цели
|
||||
const isElenaFullySilenced = currentGameState?.player.activeEffects?.some(eff => eff.isFullSilence && eff.turnsLeft > 0);
|
||||
|
||||
return hpPercent >= configToUse.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && !isElenaFullySilenced && (opSt.silenceCooldownTurns === undefined || opSt.silenceCooldownTurns <= 0);
|
||||
},
|
||||
successRateFromConfig: 'SILENCE_SUCCESS_RATE',
|
||||
durationFromConfig: 'SILENCE_DURATION',
|
||||
internalCooldownFromConfig: 'BALARD_SILENCE_INTERNAL_COOLDOWN'
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN,
|
||||
name: 'Похищение Света',
|
||||
cost: 10,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DRAIN,
|
||||
powerManaDrain: 5,
|
||||
powerDamage: 5,
|
||||
powerHealthGainFactor: 1.0,
|
||||
description: `Вытягивает 5 Маны у Елены, наносит 5 урона и восстанавливает себе здоровье (100% от украденного).`,
|
||||
condition: (opSt, plSt, currentGameState, configToUse) => {
|
||||
const playerManaPercent = (plSt.currentResource / plSt.maxResource) * 100;
|
||||
const playerHasHighMana = playerManaPercent > (configToUse.BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD || 60);
|
||||
return playerHasHighMana && (opSt.manaDrainCooldownTurns === undefined || opSt.manaDrainCooldownTurns <= 0);
|
||||
},
|
||||
internalCooldownValue: 3
|
||||
}
|
||||
];
|
||||
|
||||
// Способности Альмагест (PvP - зеркало Елены)
|
||||
const almagestAbilities = [
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL,
|
||||
name: 'Темное Восстановление',
|
||||
cost: 20,
|
||||
type: GAME_CONFIG.ACTION_TYPE_HEAL,
|
||||
power: 30,
|
||||
description: 'Поглощает жизненные тени, восстанавливая ~30 HP'
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE,
|
||||
name: 'Теневой Сгусток',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DAMAGE,
|
||||
power: 25,
|
||||
description: 'Запускает сгусток чистой тьмы, нанося ~25 урона врагу'
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK,
|
||||
name: 'Усиление Тьмой',
|
||||
cost: 15,
|
||||
type: GAME_CONFIG.ACTION_TYPE_BUFF,
|
||||
duration: 4,
|
||||
descriptionFunction: (configToUse, opponentBaseStats) => `Восст. ${configToUse.NATURE_STRENGTH_MANA_REGEN} Темной Энергии при след. атаке. Эффект длится ${4 - 1} хода после применения.`,
|
||||
isDelayed: true
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE,
|
||||
name: 'Щит Пустоты',
|
||||
cost: 15,
|
||||
type: GAME_CONFIG.ACTION_TYPE_BUFF,
|
||||
duration: 3,
|
||||
grantsBlock: true,
|
||||
descriptionFunction: (configToUse, opponentBaseStats) => `Создает щит, снижающий урон на ${configToUse.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)`
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE,
|
||||
name: 'Раскол Разума',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DISABLE,
|
||||
effectDuration: 2,
|
||||
cooldown: 6,
|
||||
power: 5,
|
||||
description: 'Вторгается в разум противника, накладывая полное безмолвие на 2 хода и нанося 5 урона каждый его ход. КД: 6 х.'
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF,
|
||||
name: 'Проклятие Увядания',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DEBUFF,
|
||||
effectDuration: 3,
|
||||
power: 10,
|
||||
cooldown: 5,
|
||||
descriptionFunction: (configToUse, oppStats) => `Накладывает проклятие, истощающее 10 ${oppStats ? oppStats.resourceName : 'ресурса'} противника каждый его ход в течение 3 ходов. КД: 5 х.`
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
elenaAbilities,
|
||||
balardAbilities,
|
||||
almagestAbilities
|
||||
};
|
47
server/data/characterStats.js
Normal file
47
server/data/characterStats.js
Normal file
@ -0,0 +1,47 @@
|
||||
// /server/data/characterStats.js
|
||||
|
||||
const GAME_CONFIG = require('../core/config'); // Путь к конфигу из server/data/ в server/core/
|
||||
|
||||
// --- Базовые Статы Персонажей ---
|
||||
|
||||
const elenaBaseStats = {
|
||||
id: GAME_CONFIG.PLAYER_ID, // Технический ID слота (может быть player или opponent в PvP)
|
||||
characterKey: 'elena', // Уникальный ключ персонажа
|
||||
name: "Елена",
|
||||
maxHp: 120,
|
||||
maxResource: 150,
|
||||
attackPower: 15,
|
||||
resourceName: "Мана",
|
||||
avatarPath: 'images/elena_avatar.webp' // Путь к аватару
|
||||
};
|
||||
|
||||
const balardBaseStats = { // Балард (для AI и, возможно, PvP)
|
||||
id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота (обычно opponent)
|
||||
characterKey: 'balard', // Уникальный ключ персонажа
|
||||
name: "Балард",
|
||||
maxHp: 140,
|
||||
maxResource: 100,
|
||||
attackPower: 20,
|
||||
resourceName: "Ярость",
|
||||
avatarPath: 'images/balard_avatar.jpg' // Путь к аватару
|
||||
};
|
||||
|
||||
const almagestBaseStats = { // Альмагест (для PvP)
|
||||
id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота (может быть player или opponent в PvP)
|
||||
characterKey: 'almagest', // Уникальный ключ персонажа
|
||||
name: "Альмагест",
|
||||
maxHp: 120, // Статы как у Елены для зеркальности
|
||||
maxResource: 150,
|
||||
attackPower: 15,
|
||||
resourceName: "Темная Энергия",
|
||||
avatarPath: 'images/almagest_avatar.webp' // Путь к аватару
|
||||
};
|
||||
|
||||
// Можно добавить других персонажей здесь, если потребуется
|
||||
|
||||
module.exports = {
|
||||
elenaBaseStats,
|
||||
balardBaseStats,
|
||||
almagestBaseStats
|
||||
// ...и другие персонажи
|
||||
};
|
72
server/data/dataUtils.js
Normal file
72
server/data/dataUtils.js
Normal file
@ -0,0 +1,72 @@
|
||||
// /server/data/dataUtils.js
|
||||
|
||||
// Импортируем непосредственно определенные статы и способности
|
||||
const { elenaBaseStats, balardBaseStats, almagestBaseStats } = require('./characterStats');
|
||||
const { elenaAbilities, balardAbilities, almagestAbilities } = require('./characterAbilities');
|
||||
// const { tauntSystem } = require('./taunts'); // Если нужны утилиты для насмешек
|
||||
|
||||
/**
|
||||
* Получает полный набор данных для персонажа по его ключу.
|
||||
* Включает базовые статы и список способностей.
|
||||
* @param {string} characterKey - Ключ персонажа ('elena', 'balard', 'almagest').
|
||||
* @returns {{baseStats: object, abilities: Array<object>}|null} Объект с данными или null, если ключ неизвестен.
|
||||
*/
|
||||
function getCharacterData(characterKey) {
|
||||
if (!characterKey) {
|
||||
console.warn("[DataUtils] getCharacterData_called_with_null_or_undefined_key");
|
||||
return null;
|
||||
}
|
||||
switch (characterKey.toLowerCase()) { // Приводим к нижнему регистру для надежности
|
||||
case 'elena':
|
||||
return { baseStats: elenaBaseStats, abilities: elenaAbilities };
|
||||
case 'balard':
|
||||
return { baseStats: balardBaseStats, abilities: balardAbilities };
|
||||
case 'almagest':
|
||||
return { baseStats: almagestBaseStats, abilities: almagestAbilities };
|
||||
default:
|
||||
console.error(`[DataUtils] getCharacterData: Unknown character key "${characterKey}"`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает только базовые статы для персонажа по его ключу.
|
||||
* @param {string} characterKey - Ключ персонажа.
|
||||
* @returns {object|null} Объект базовых статов или null.
|
||||
*/
|
||||
function getCharacterBaseStats(characterKey) {
|
||||
const charData = getCharacterData(characterKey);
|
||||
return charData ? charData.baseStats : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает только список способностей для персонажа по его ключу.
|
||||
* @param {string} characterKey - Ключ персонажа.
|
||||
* @returns {Array<object>|null} Массив способностей или null.
|
||||
*/
|
||||
function getCharacterAbilities(characterKey) {
|
||||
const charData = getCharacterData(characterKey);
|
||||
return charData ? charData.abilities : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает имя персонажа по его ключу.
|
||||
* @param {string} characterKey - Ключ персонажа.
|
||||
* @returns {string|null} Имя персонажа или null.
|
||||
*/
|
||||
function getCharacterName(characterKey) {
|
||||
const baseStats = getCharacterBaseStats(characterKey);
|
||||
return baseStats ? baseStats.name : null;
|
||||
}
|
||||
|
||||
// Можно добавить другие утилитарные функции по мере необходимости,
|
||||
// например, для поиска конкретной способности по ID у персонажа,
|
||||
// или для получения данных для инициализации gameState и т.д.
|
||||
|
||||
module.exports = {
|
||||
getCharacterData,
|
||||
getCharacterBaseStats,
|
||||
getCharacterAbilities,
|
||||
getCharacterName
|
||||
// ...другие экспортируемые утилиты
|
||||
};
|
75
server/data/index.js
Normal file
75
server/data/index.js
Normal file
@ -0,0 +1,75 @@
|
||||
// /server/data/index.js
|
||||
|
||||
// Импортируем отдельные части игровых данных
|
||||
const { elenaBaseStats, balardBaseStats, almagestBaseStats } = require('./characterStats');
|
||||
const { elenaAbilities, balardAbilities, almagestAbilities } = require('./characterAbilities');
|
||||
const { tauntSystem } = require('./taunts'); // Предполагается, что taunts.js экспортирует объект tauntSystem
|
||||
|
||||
// Собираем все данные в один объект gameData,
|
||||
// который будет использоваться в других частях серверной логики (например, gameLogic, GameInstance).
|
||||
// Эта структура аналогична той, что была в вашем исходном большом файле data.js.
|
||||
const gameData = {
|
||||
// Базовые статы персонажей по их ключам для удобного доступа
|
||||
// (хотя dataUtils.js теперь предоставляет функции для этого,
|
||||
// можно оставить и такую структуру для обратной совместимости или прямого доступа, если нужно)
|
||||
baseStats: {
|
||||
elena: elenaBaseStats,
|
||||
balard: balardBaseStats,
|
||||
almagest: almagestBaseStats
|
||||
},
|
||||
|
||||
// Способности персонажей по их ключам
|
||||
abilities: {
|
||||
elena: elenaAbilities,
|
||||
balard: balardAbilities,
|
||||
almagest: almagestAbilities
|
||||
},
|
||||
|
||||
// Система насмешек
|
||||
tauntSystem: tauntSystem,
|
||||
|
||||
|
||||
// Если вы хотите сохранить оригинальную структуру вашего предыдущего data.js,
|
||||
// где были прямые ссылки на playerBaseStats, opponentBaseStats и т.д.,
|
||||
// вы можете добавить их сюда. Однако, с новой структурой dataUtils.js
|
||||
// это становится менее необходимым, так как dataUtils предоставляет
|
||||
// функции для получения данных по characterKey.
|
||||
// Для примера, если бы playerBaseStats всегда был Елена, а opponentBaseStats всегда Балард:
|
||||
// playerBaseStats: elenaBaseStats, // Обычно Елена
|
||||
// opponentBaseStats: balardBaseStats, // Обычно Балард (AI)
|
||||
// almagestBaseStats: almagestBaseStats, // Для Альмагест (PvP)
|
||||
// playerAbilities: elenaAbilities,
|
||||
// opponentAbilities: balardAbilities, // Способности Баларда (AI)
|
||||
// almagestAbilities: almagestAbilities,
|
||||
|
||||
// Рекомендуемый подход: экспортировать данные, сгруппированные по персонажам,
|
||||
// а для получения данных конкретного "игрока" или "оппонента" в игре
|
||||
// использовать dataUtils.getCharacterData(characterKey) в GameInstance/GameManager.
|
||||
// Это более гибко, так как в PvP Елена может быть оппонентом, а Альмагест - игроком.
|
||||
};
|
||||
|
||||
// Экспортируем собранный объект gameData
|
||||
module.exports = gameData;
|
||||
|
||||
/*
|
||||
Примечание:
|
||||
В GameInstance, GameManager, gameLogic и других модулях, где раньше был:
|
||||
const gameData = require('./data'); // или другой путь к старому data.js
|
||||
|
||||
Теперь будет:
|
||||
const gameData = require('../data'); // или '../data/index.js' - Node.js поймет и так
|
||||
или
|
||||
const dataUtils = require('../data/dataUtils');
|
||||
|
||||
И если вы используете gameData напрямую:
|
||||
const elenaStats = gameData.baseStats.elena;
|
||||
const balardAbils = gameData.abilities.balard;
|
||||
|
||||
Если используете dataUtils:
|
||||
const elenaFullData = dataUtils.getCharacterData('elena');
|
||||
const balardAbils = dataUtils.getCharacterAbilities('balard');
|
||||
|
||||
Выбор зависит от того, насколько гранулированный доступ вам нужен в каждом конкретном месте.
|
||||
Объект gameData, экспортируемый этим файлом, может быть полезен для gameLogic,
|
||||
где функции могут ожидать всю структуру данных сразу.
|
||||
*/
|
118
server/data/taunts.js
Normal file
118
server/data/taunts.js
Normal file
@ -0,0 +1,118 @@
|
||||
// /server/data/taunts.js
|
||||
|
||||
// Предполагается, что GAME_CONFIG будет доступен в контексте, где используются эти насмешки,
|
||||
// обычно он передается в функции игровой логики (например, serverGameLogic.getRandomTaunt).
|
||||
// Если вы хотите использовать GAME_CONFIG.ABILITY_ID_... прямо здесь, вам нужно его импортировать:
|
||||
const GAME_CONFIG = require('../core/config'); // Путь к конфигу
|
||||
|
||||
const tauntSystem = {
|
||||
elena: { // Насмешки Елены
|
||||
balard: { // Против Баларда (AI)
|
||||
// Триггер: Елена использует СВОЮ способность
|
||||
selfCastAbility: {
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", "Я черпаю силы в Истине."],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", "Пусть твой мрак сгорит!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", "Гармония природы со мной." ],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", "Моя вера - моя защита." ],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри мне в глаза, Балард. И слушай тишину.", "Твой разум - в моей власти." ],
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя ярость иссякнет, как вода в песке, Балард!", "Твоя сила угасает." ]
|
||||
},
|
||||
// Триггер: Противник (Балард) совершает действие
|
||||
onOpponentAction: {
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_HEAL]: [ "Пытаешься отсрочить неизбежное жалкой темной силой?" ],
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_SILENCE]: { // Реакция на "Эхо Безмолвия" Баларда
|
||||
success: [ "(Сдавленный вздох)... Ничтожная попытка заглушить Слово!" ], // Если Балард успешно заглушил Елену
|
||||
fail: [ "Твой шепот Тьмы слаб против Света Истины!" ] // Если попытка Баларда провалилась
|
||||
},
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN]: [ "Ты питаешься Светом, как паразит?!" ],
|
||||
// Эти два триггера используются, когда АТАКА ОППОНЕНТА (Баларда) попадает по Елене или блокируется Еленой
|
||||
attackBlocked: [ "Твои удары тщетны перед щитом Порядка." ], // Елена блокирует атаку Баларда
|
||||
attackHits: [ "(Шипение боли)... Боль – лишь напоминание о твоем предательстве." ] // Атака Баларда попадает по Елене
|
||||
},
|
||||
// Триггер: Базовая атака Елены
|
||||
basicAttack: {
|
||||
// 'merciful' и 'dominating' используются в gameLogic.getRandomTaunt в зависимости от HP Баларда
|
||||
merciful: [ "Балард, прошу, остановись. Еще не поздно.", "Подумай о том, что потерял." ],
|
||||
dominating: [
|
||||
"Глина не спорит с гончаром, Балард!",
|
||||
"Ты ИЗБРАЛ эту гниль! Получай возмездие!",
|
||||
"Самый страшный грех - грех неблагодарности!",
|
||||
"Я сотру тебя с лика этой земли!"
|
||||
],
|
||||
general: [ // Общие фразы, если специфичные не подходят (например, если PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT не используется)
|
||||
"Свет покарает тебя, Балард!",
|
||||
"За все свои деяния ты ответишь!"
|
||||
]
|
||||
},
|
||||
// Триггер: Изменение состояния боя
|
||||
onBattleState: {
|
||||
start: [ "Балард, есть ли еще путь назад?" ], // Начало AI боя с Балардом
|
||||
opponentNearDefeat: [ "Конец близок, Балард. Прими свою судьбу." ] // Балард почти побежден
|
||||
}
|
||||
},
|
||||
almagest: { // Против Альмагест (PvP)
|
||||
selfCastAbility: {
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [ "Я исцеляюсь Светом, который ты отвергла.", "Жизнь восторжествует над твоей некромантией!", "Мое сияние не померкнет." ],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Очищающий огонь для твоей тьмы!", "Почувствуй гнев праведного Света!", "Это пламя ярче твоих теней!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Природа дает мне силу, а тебе - лишь презрение.", "Я черпаю из источника жизни, ты - из могилы." ],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Мой щит отразит твою злобу.", "Свет - лучшая защита.", "Твои темные чары не пройдут!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри в глаза Истине, колдунья!", "Твои лживые речи умолкнут!", "Хватит прятаться за иллюзиями!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя темная сила иссякнет!", "Я ослабляю твою связь с бездной!", "Почувствуй, как тает твоя энергия!" ]
|
||||
},
|
||||
onOpponentAction: { // Реакции Елены на действия Альмагест
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Лечишь раны тьмой? Она лишь глубже проникнет в тебя.", "Твоя магия несет лишь порчу, даже исцеляя." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Твоя тень лишь царапает, не ранит.", "Слабый удар! Тьма делает тебя немощной." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Черпаешь силы из бездны? Она поглотит и тебя.", "Твое усиление - лишь агония искаженной энергии." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Щит из теней? Он рассыпется прахом!", "Твоя защита иллюзорна, как и твоя сила." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "(Сдавленно) Твои ментальные атаки отвратительны!", "Тьма в моей голове... я вырвусь!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Истощаешь мою силу? Я восстановлю ее Светом!", "Твое проклятие слабо." ],
|
||||
attackBlocked: [ "Твоя атака разбилась о мой щит Света!", "Предсказуемо и слабо, Альмагест." ],
|
||||
attackHits: [ "(Резкий вздох) Коснулась... Но Свет исцелит рану.", "Эта царапина - ничто!", "Ты заплатишь за это!" ]
|
||||
},
|
||||
basicAttack: {
|
||||
general: [ "Тьма не победит, Альмагест!", "Твои иллюзии рассеются перед Светом!", "Пока я стою, порядок будет восстановлен!" ]
|
||||
},
|
||||
onBattleState: {
|
||||
start: [ "Альмагест! Твоим темным делам пришел конец!", "Во имя Света, я остановлю тебя!", "Приготовься к битве, служительница тьмы!" ],
|
||||
opponentNearDefeat: [ "Твоя тьма иссякает, колдунья!", "Сдавайся, пока Свет не испепелил тебя!", "Конец твоим злодеяниям близок!" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
almagest: { // Насмешки Альмагест
|
||||
elena: { // Против Елены (PvP)
|
||||
selfCastAbility: {
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Я питаюсь слабостью, Елена!", "Тьма дает мне силу!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Почувствуй холод бездны!", "Твой Свет померкнет перед моей тенью!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Силы Бездны со мной!", "Моя тень становится гуще!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Мой щит выкован из самой тьмы!", "Попробуй пробить это, служительница Света!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "Твой разум сломлен!", "Умолкни, Светлая!", "Я владею твоими мыслями!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Твоя сила тает!", "Почувствуй гниль!", "Я истощаю твой Свет!" ]
|
||||
},
|
||||
onOpponentAction: { // Реакции Альмагест на действия Елены
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [ "Исцеляешься? Твои раны слишком глубоки!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Жалкое пламя! Мои тени поглотят его!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сила земли? Смешно! Бездну ничто не остановит." ],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Твой щит из Света не спасет тебя от Тьмы!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "(Сдавленно, затем смех) Попытка управлять моим разумом? Жалко!", "Ты пытаешься заглянуть в Бездну?!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Моя энергия вечна, дура!", "Это лишь раздражение!" ],
|
||||
attackBlocked: [ "Твой блок не спасет тебя вечно, Елена!", "Это лишь задержка." ],
|
||||
attackHits: [ "Ха! Чувствуешь силу Тьмы?", "Это только начало!", "Слабость!" ]
|
||||
},
|
||||
basicAttack: {
|
||||
general: [ "Почувствуй мою силу!", "Тени атакуют!", "Я наношу удар!" ]
|
||||
},
|
||||
onBattleState: {
|
||||
start: [ "Тысяча лет в заточении лишь усилили меня, Елена!", "Твой Свет скоро погаснет!", "Пора положить конец твоему господству!" ],
|
||||
opponentNearDefeat: [ "Твой Свет гаснет!", "Ты побеждена!", "Бездне нужен твой дух!" ]
|
||||
}
|
||||
}
|
||||
// Можно добавить секцию для Альмагест против Баларда, если такой бой возможен и нужен
|
||||
// balard: { ... }
|
||||
}
|
||||
// Балард пока не имеет своей системы насмешек (он AI и его "реплики" могут быть частью логов его действий)
|
||||
// Если Балард станет играбельным PvP персонажем, сюда можно будет добавить секцию balard: { elena: {...}, almagest: {...} }
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
tauntSystem
|
||||
};
|
559
server/game/GameManager.js
Normal file
559
server/game/GameManager.js
Normal file
@ -0,0 +1,559 @@
|
||||
// /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 = {}; // { gameId: GameInstance }
|
||||
this.userIdentifierToGameId = {}; // { userId: gameId }
|
||||
this.pendingPvPGames = []; // Массив gameId ожидающих PvP игр
|
||||
console.log("[GameManager] Инициализирован.");
|
||||
}
|
||||
|
||||
_cleanupPreviousPendingGameForUser(identifier, reasonSuffix = 'unknown_cleanup_reason') {
|
||||
const oldPendingGameId = this.userIdentifierToGameId[identifier];
|
||||
if (oldPendingGameId && this.games[oldPendingGameId]) {
|
||||
const gameToRemove = this.games[oldPendingGameId];
|
||||
// Убеждаемся, что это именно ожидающая PvP игра этого пользователя
|
||||
if (gameToRemove.mode === 'pvp' &&
|
||||
gameToRemove.ownerIdentifier === identifier && // Он владелец
|
||||
gameToRemove.playerCount === 1 && // В игре только он
|
||||
this.pendingPvPGames.includes(oldPendingGameId) && // Игра в списке ожидающих
|
||||
(!gameToRemove.gameState || !gameToRemove.gameState.isGameOver) // И она не завершена
|
||||
) {
|
||||
console.log(`[GameManager._cleanupPreviousPendingGameForUser] Пользователь ${identifier} имеет существующую ожидающую PvP игру ${oldPendingGameId}. Удаление. Причина: ${reasonSuffix}`);
|
||||
this._cleanupGame(oldPendingGameId, `owner_action_removed_pending_pvp_game_${reasonSuffix}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
createGame(socket, mode = 'ai', chosenCharacterKey = null, identifier) {
|
||||
console.log(`[GameManager.createGame] Пользователь: ${identifier} (Socket: ${socket.id}), Режим: ${mode}, Персонаж: ${chosenCharacterKey || 'По умолчанию'}`);
|
||||
|
||||
const existingGameIdForUser = this.userIdentifierToGameId[identifier];
|
||||
|
||||
if (existingGameIdForUser && this.games[existingGameIdForUser]) {
|
||||
const existingGame = this.games[existingGameIdForUser];
|
||||
if (existingGame.gameState && existingGame.gameState.isGameOver) {
|
||||
console.warn(`[GameManager.createGame] Пользователь ${identifier} был в завершенной игре ${existingGameIdForUser}. Очистка перед созданием новой.`);
|
||||
this._cleanupGame(existingGameIdForUser, `stale_finished_on_create_${identifier}`);
|
||||
} else {
|
||||
const isHisOwnPendingPvp = existingGame.mode === 'pvp' &&
|
||||
existingGame.ownerIdentifier === identifier &&
|
||||
existingGame.playerCount === 1 &&
|
||||
this.pendingPvPGames.includes(existingGameIdForUser);
|
||||
|
||||
if (!isHisOwnPendingPvp) {
|
||||
console.warn(`[GameManager.createGame] Пользователь ${identifier} уже в активной игре ${existingGameIdForUser} (режим: ${existingGame.mode}, владелец: ${existingGame.ownerIdentifier}). Невозможно создать новую.`);
|
||||
socket.emit('gameError', { message: 'Вы уже находитесь в активной игре.' });
|
||||
this.handleRequestGameState(socket, identifier);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._cleanupPreviousPendingGameForUser(identifier, `creating_new_game_mode_${mode}`);
|
||||
console.log(`[GameManager.createGame] После возможной очистки, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`);
|
||||
|
||||
const stillExistingGameIdAfterCleanup = this.userIdentifierToGameId[identifier];
|
||||
if (stillExistingGameIdAfterCleanup && this.games[stillExistingGameIdAfterCleanup] && !this.games[stillExistingGameIdAfterCleanup].gameState?.isGameOver) {
|
||||
console.error(`[GameManager.createGame] КРИТИЧЕСКАЯ ОШИБКА ЛОГИКИ: Пользователь ${identifier} все еще сопоставлен с активной игрой ${stillExistingGameIdAfterCleanup} после попытки очистки. Создание отклонено.`);
|
||||
socket.emit('gameError', { message: 'Ошибка: не удалось освободить предыдущую игровую сессию.' });
|
||||
this.handleRequestGameState(socket, identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
const gameId = uuidv4();
|
||||
console.log(`[GameManager.createGame] Новый GameID: ${gameId}`);
|
||||
const game = new GameInstance(gameId, this.io, mode, this);
|
||||
this.games[gameId] = game;
|
||||
|
||||
const charKeyForPlayer = mode === 'ai' ? (chosenCharacterKey || 'elena') : (chosenCharacterKey || 'elena');
|
||||
|
||||
if (game.addPlayer(socket, charKeyForPlayer, identifier)) {
|
||||
this.userIdentifierToGameId[identifier] = gameId;
|
||||
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
|
||||
const assignedPlayerId = playerInfo?.id;
|
||||
const actualCharacterKey = playerInfo?.chosenCharacterKey;
|
||||
|
||||
if (!assignedPlayerId || !actualCharacterKey) {
|
||||
console.error(`[GameManager.createGame] КРИТИЧЕСКИ: Не удалось получить роль/ключ персонажа после addPlayer для ${identifier} в игре ${gameId}. Очистка.`);
|
||||
this._cleanupGame(gameId, 'player_info_missing_after_add_on_create');
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при создании роли в игре.' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[GameManager.createGame] Игрок ${identifier} добавлен в игру ${gameId} как ${assignedPlayerId}. Карта пользователя обновлена. Текущая карта для ${identifier}: ${this.userIdentifierToGameId[identifier]}`);
|
||||
socket.emit('gameCreated', {
|
||||
gameId: gameId,
|
||||
mode: mode,
|
||||
yourPlayerId: assignedPlayerId,
|
||||
chosenCharacterKey: actualCharacterKey
|
||||
});
|
||||
|
||||
if (mode === 'ai') {
|
||||
if (game.initializeGame()) {
|
||||
console.log(`[GameManager.createGame] AI игра ${gameId} инициализирована GameManager, запуск...`);
|
||||
game.startGame();
|
||||
} else {
|
||||
console.error(`[GameManager.createGame] Инициализация AI игры ${gameId} не удалась в GameManager. Очистка.`);
|
||||
this._cleanupGame(gameId, 'init_fail_ai_create_gm');
|
||||
}
|
||||
} else if (mode === 'pvp') {
|
||||
if (game.initializeGame()) {
|
||||
if (!this.pendingPvPGames.includes(gameId)) {
|
||||
this.pendingPvPGames.push(gameId);
|
||||
}
|
||||
socket.emit('waitingForOpponent');
|
||||
this.broadcastAvailablePvPGames();
|
||||
} else {
|
||||
console.error(`[GameManager.createGame] Инициализация PvP игры ${gameId} (один игрок) не удалась. Очистка.`);
|
||||
this._cleanupGame(gameId, 'init_fail_pvp_create_gm_single_player');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`[GameManager.createGame] game.addPlayer не удалось для ${identifier} в ${gameId}. Очистка.`);
|
||||
this._cleanupGame(gameId, 'player_add_failed_in_instance_gm_on_create');
|
||||
}
|
||||
}
|
||||
|
||||
joinGame(socket, gameIdToJoin, identifier, chosenCharacterKey = null) {
|
||||
console.log(`[GameManager.joinGame] Пользователь: ${identifier} (Socket: ${socket.id}) пытается присоединиться к ${gameIdToJoin} с персонажем ${chosenCharacterKey || 'По умолчанию'}`);
|
||||
const gameToJoin = this.games[gameIdToJoin];
|
||||
|
||||
if (!gameToJoin) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; }
|
||||
if (gameToJoin.gameState?.isGameOver) { socket.emit('gameError', { message: 'Эта игра уже завершена.' }); this._cleanupGame(gameIdToJoin, `attempt_join_finished_game_${identifier}`); return; }
|
||||
if (gameToJoin.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться (не PvP).' }); return; }
|
||||
|
||||
const playerInfoInTargetGame = Object.values(gameToJoin.players).find(p => p.identifier === identifier);
|
||||
if (gameToJoin.playerCount >= 2 && !playerInfoInTargetGame?.isTemporarilyDisconnected) {
|
||||
socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return;
|
||||
}
|
||||
// Запрещаем владельцу "присоединяться" к своей ожидающей игре как новый игрок, если он не был временно отключен.
|
||||
// Если он хочет вернуться, он должен использовать requestGameState.
|
||||
if (gameToJoin.ownerIdentifier === identifier && !playerInfoInTargetGame?.isTemporarilyDisconnected) {
|
||||
console.warn(`[GameManager.joinGame] Пользователь ${identifier} пытается присоединиться к своей игре ${gameIdToJoin}, где он владелец и не отключен. Обработка как запрос на переподключение.`);
|
||||
this.handleRequestGameState(socket, identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentActiveGameIdUserIsIn = this.userIdentifierToGameId[identifier];
|
||||
if (currentActiveGameIdUserIsIn && this.games[currentActiveGameIdUserIsIn] && this.games[currentActiveGameIdUserIsIn].gameState?.isGameOver) {
|
||||
console.warn(`[GameManager.joinGame] Пользователь ${identifier} был в завершенной игре ${currentActiveGameIdUserIsIn} при попытке присоединиться к ${gameIdToJoin}. Очистка старой.`);
|
||||
this._cleanupGame(currentActiveGameIdUserIsIn, `stale_finished_on_join_attempt_${identifier}`);
|
||||
}
|
||||
|
||||
const stillExistingGameIdForUser = this.userIdentifierToGameId[identifier];
|
||||
if (stillExistingGameIdForUser && stillExistingGameIdForUser !== gameIdToJoin && this.games[stillExistingGameIdForUser] && !this.games[stillExistingGameIdForUser].gameState?.isGameOver) {
|
||||
const usersCurrentGame = this.games[stillExistingGameIdForUser];
|
||||
const isHisOwnPendingPvp = usersCurrentGame.mode === 'pvp' &&
|
||||
usersCurrentGame.ownerIdentifier === identifier &&
|
||||
usersCurrentGame.playerCount === 1 &&
|
||||
this.pendingPvPGames.includes(stillExistingGameIdForUser);
|
||||
|
||||
if (isHisOwnPendingPvp) {
|
||||
console.log(`[GameManager.joinGame] Пользователь ${identifier} является владельцем ожидающей игры ${stillExistingGameIdForUser}, но хочет присоединиться к ${gameIdToJoin}. Очистка старой игры.`);
|
||||
this._cleanupPreviousPendingGameForUser(identifier, `joining_another_game_${gameIdToJoin}`);
|
||||
} else {
|
||||
console.warn(`[GameManager.joinGame] Пользователь ${identifier} находится в другой активной игре ${stillExistingGameIdForUser}. Невозможно присоединиться к ${gameIdToJoin}.`);
|
||||
socket.emit('gameError', { message: 'Вы уже находитесь в другой активной игре.' });
|
||||
this.handleRequestGameState(socket, identifier);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log(`[GameManager.joinGame] После возможной очистки перед присоединением, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`);
|
||||
|
||||
const charKeyForJoin = chosenCharacterKey || 'elena';
|
||||
if (gameToJoin.addPlayer(socket, charKeyForJoin, identifier)) {
|
||||
this.userIdentifierToGameId[identifier] = gameIdToJoin;
|
||||
const joinedPlayerInfo = Object.values(gameToJoin.players).find(p => p.identifier === identifier);
|
||||
|
||||
if (!joinedPlayerInfo || !joinedPlayerInfo.id || !joinedPlayerInfo.chosenCharacterKey) {
|
||||
console.error(`[GameManager.joinGame] КРИТИЧЕСКИ: Не удалось получить роль/ключ персонажа после addPlayer для ${identifier}, присоединяющегося к ${gameIdToJoin}.`);
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при назначении роли в игре.' });
|
||||
if (this.userIdentifierToGameId[identifier] === gameIdToJoin) delete this.userIdentifierToGameId[identifier];
|
||||
return;
|
||||
}
|
||||
console.log(`[GameManager.joinGame] Игрок ${identifier} добавлен/переподключен к ${gameIdToJoin} как ${joinedPlayerInfo.id}. Карта пользователя обновлена. Текущая карта для ${identifier}: ${this.userIdentifierToGameId[identifier]}`);
|
||||
socket.emit('gameCreated', {
|
||||
gameId: gameIdToJoin,
|
||||
mode: gameToJoin.mode,
|
||||
yourPlayerId: joinedPlayerInfo.id,
|
||||
chosenCharacterKey: joinedPlayerInfo.chosenCharacterKey
|
||||
});
|
||||
|
||||
if (gameToJoin.playerCount === 2) {
|
||||
console.log(`[GameManager.joinGame] Игра ${gameIdToJoin} теперь заполнена. Инициализация и запуск.`);
|
||||
// Важно! Инициализация может обновить ключи персонажей, если они были одинаковыми.
|
||||
if (gameToJoin.initializeGame()) {
|
||||
gameToJoin.startGame();
|
||||
} else {
|
||||
this._cleanupGame(gameIdToJoin, 'full_init_fail_pvp_join_gm'); return;
|
||||
}
|
||||
const idx = this.pendingPvPGames.indexOf(gameIdToJoin);
|
||||
if (idx > -1) this.pendingPvPGames.splice(idx, 1);
|
||||
this.broadcastAvailablePvPGames();
|
||||
}
|
||||
} else {
|
||||
console.warn(`[GameManager.joinGame] gameToJoin.addPlayer вернул false для пользователя ${identifier} в игре ${gameIdToJoin}.`);
|
||||
}
|
||||
}
|
||||
|
||||
findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) {
|
||||
console.log(`[GameManager.findRandomPvPGame] Пользователь: ${identifier} (Socket: ${socket.id}), Персонаж для создания: ${chosenCharacterKeyForCreation}`);
|
||||
|
||||
const existingGameIdForUser = this.userIdentifierToGameId[identifier];
|
||||
if (existingGameIdForUser && this.games[existingGameIdForUser]) {
|
||||
const existingGame = this.games[existingGameIdForUser];
|
||||
if (existingGame.gameState && existingGame.gameState.isGameOver) {
|
||||
console.warn(`[GameManager.findRandomPvPGame] Пользователь ${identifier} был в завершенной игре ${existingGameIdForUser}. Очистка.`);
|
||||
this._cleanupGame(existingGameIdForUser, `stale_finished_on_find_random_${identifier}`);
|
||||
} else {
|
||||
console.warn(`[GameManager.findRandomPvPGame] Пользователь ${identifier} уже в активной/ожидающей игре ${existingGameIdForUser}. Невозможно найти случайную.`);
|
||||
socket.emit('gameError', { message: 'Вы уже в активной или ожидающей игре.' });
|
||||
this.handleRequestGameState(socket, identifier); return;
|
||||
}
|
||||
}
|
||||
|
||||
this._cleanupPreviousPendingGameForUser(identifier, `finding_random_game`);
|
||||
console.log(`[GameManager.findRandomPvPGame] После возможной очистки, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`);
|
||||
|
||||
const stillExistingGameIdAfterCleanup = this.userIdentifierToGameId[identifier];
|
||||
if (stillExistingGameIdAfterCleanup && this.games[stillExistingGameIdAfterCleanup] && !this.games[stillExistingGameIdAfterCleanup].gameState?.isGameOver) {
|
||||
console.error(`[GameManager.findRandomPvPGame] КРИТИЧЕСКАЯ ОШИБКА ЛОГИКИ: Пользователь ${identifier} все еще сопоставлен с активной игрой ${stillExistingGameIdAfterCleanup} после попытки очистки. Поиск случайной игры отклонен.`);
|
||||
socket.emit('gameError', { message: 'Ошибка: не удалось освободить предыдущую игровую сессию для поиска.' });
|
||||
this.handleRequestGameState(socket, identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
let gameIdToJoin = null;
|
||||
for (const id of [...this.pendingPvPGames]) {
|
||||
const pendingGame = this.games[id];
|
||||
if (pendingGame && pendingGame.mode === 'pvp' &&
|
||||
pendingGame.playerCount === 1 &&
|
||||
pendingGame.ownerIdentifier !== identifier &&
|
||||
(!pendingGame.gameState || !pendingGame.gameState.isGameOver)) {
|
||||
gameIdToJoin = id; break;
|
||||
} else if (!pendingGame || (pendingGame?.gameState && pendingGame.gameState.isGameOver)) {
|
||||
console.warn(`[GameManager.findRandomPvPGame] Найдена устаревшая/завершенная ожидающая игра ${id}. Очистка.`);
|
||||
this._cleanupGame(id, `stale_finished_pending_on_find_random`);
|
||||
}
|
||||
}
|
||||
|
||||
if (gameIdToJoin) {
|
||||
console.log(`[GameManager.findRandomPvPGame] Найдена ожидающая игра ${gameIdToJoin} для ${identifier}. Присоединение...`);
|
||||
const randomJoinCharKey = ['elena', 'almagest', 'balard'][Math.floor(Math.random() * 3)];
|
||||
this.joinGame(socket, gameIdToJoin, identifier, randomJoinCharKey);
|
||||
} else {
|
||||
console.log(`[GameManager.findRandomPvPGame] Подходящая ожидающая игра не найдена. Создание новой PvP игры для ${identifier}.`);
|
||||
this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayerAction(identifier, actionData) {
|
||||
const gameId = this.userIdentifierToGameId[identifier];
|
||||
const game = this.games[gameId];
|
||||
if (game) {
|
||||
if (game.gameState?.isGameOver) {
|
||||
const playerSocket = Object.values(game.players).find(p => p.identifier === identifier)?.socket;
|
||||
if (playerSocket) {
|
||||
console.warn(`[GameManager.handlePlayerAction] Действие от ${identifier} для игры ${gameId}, но игра завершена. Запрос состояния.`);
|
||||
this.handleRequestGameState(playerSocket, identifier);
|
||||
} else {
|
||||
console.warn(`[GameManager.handlePlayerAction] Действие от ${identifier} для игры ${gameId}, игра завершена, но сокет для пользователя не найден.`);
|
||||
this._cleanupGame(gameId, `action_on_over_no_socket_gm_${identifier}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
game.processPlayerAction(identifier, actionData);
|
||||
} else {
|
||||
console.warn(`[GameManager.handlePlayerAction] Игра для пользователя ${identifier} не найдена (сопоставлена с игрой ${gameId}). Очистка записи в карте.`);
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
const clientSocket = this._findClientSocketByIdentifier(identifier);
|
||||
if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена при совершении действия.' });
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayerSurrender(identifier) {
|
||||
const gameId = this.userIdentifierToGameId[identifier];
|
||||
console.log(`[GameManager.handlePlayerSurrender] Пользователь: ${identifier} сдался. GameID из карты: ${gameId}`);
|
||||
const game = this.games[gameId];
|
||||
if (game) {
|
||||
if (game.gameState?.isGameOver) {
|
||||
console.warn(`[GameManager.handlePlayerSurrender] Пользователь ${identifier} в игре ${gameId} сдается, но игра УЖЕ ЗАВЕРШЕНА.`);
|
||||
return;
|
||||
}
|
||||
if (typeof game.playerDidSurrender === 'function') game.playerDidSurrender(identifier);
|
||||
else { console.error(`[GameManager.handlePlayerSurrender] КРИТИЧЕСКИ: GameInstance ${gameId} отсутствует playerDidSurrender!`); this._cleanupGame(gameId, "surrender_missing_method_gm"); }
|
||||
} else {
|
||||
console.warn(`[GameManager.handlePlayerSurrender] Игра для пользователя ${identifier} не найдена. Очистка записи в карте.`);
|
||||
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
|
||||
}
|
||||
}
|
||||
|
||||
handleLeaveAiGame(identifier) {
|
||||
const gameId = this.userIdentifierToGameId[identifier];
|
||||
console.log(`[GameManager.handleLeaveAiGame] Пользователь: ${identifier} покидает AI игру. GameID из карты: ${gameId}`);
|
||||
const game = this.games[gameId];
|
||||
if (game) {
|
||||
if (game.gameState?.isGameOver) {
|
||||
console.warn(`[GameManager.handleLeaveAiGame] Пользователь ${identifier} в игре ${gameId} выходит, но игра УЖЕ ЗАВЕРШЕНА.`);
|
||||
return;
|
||||
}
|
||||
if (game.mode === 'ai') {
|
||||
if (typeof game.playerExplicitlyLeftAiGame === 'function') {
|
||||
game.playerExplicitlyLeftAiGame(identifier);
|
||||
} else {
|
||||
console.error(`[GameManager.handleLeaveAiGame] КРИТИЧЕСКИ: GameInstance ${gameId} отсутствует playerExplicitlyLeftAiGame! Прямая очистка.`);
|
||||
this._cleanupGame(gameId, "leave_ai_missing_method_gm");
|
||||
}
|
||||
} else {
|
||||
console.warn(`[GameManager.handleLeaveAiGame] Пользователь ${identifier} отправил leaveAiGame, но игра ${gameId} не в режиме AI (${game.mode}).`);
|
||||
const clientSocket = this._findClientSocketByIdentifier(identifier);
|
||||
if(clientSocket) clientSocket.emit('gameError', { message: 'Вы не в AI игре.' });
|
||||
}
|
||||
} else {
|
||||
console.warn(`[GameManager.handleLeaveAiGame] Игра для пользователя ${identifier} не найдена. Очистка записи в карте.`);
|
||||
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
|
||||
const clientSocket = this._findClientSocketByIdentifier(identifier);
|
||||
if(clientSocket) clientSocket.emit('gameNotFound', { message: 'AI игра не найдена для выхода.' });
|
||||
}
|
||||
}
|
||||
|
||||
_findClientSocketByIdentifier(identifier) {
|
||||
for (const s of this.io.sockets.sockets.values()) {
|
||||
if (s && s.userData && s.userData.userId === identifier && s.connected) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handleDisconnect(socketId, identifier) {
|
||||
const gameIdFromMap = this.userIdentifierToGameId[identifier];
|
||||
console.log(`[GameManager.handleDisconnect] Socket: ${socketId}, Пользователь: ${identifier}, GameID из карты: ${gameIdFromMap}`);
|
||||
const game = gameIdFromMap ? this.games[gameIdFromMap] : null;
|
||||
|
||||
if (game) {
|
||||
if (game.gameState?.isGameOver) {
|
||||
console.log(`[GameManager.handleDisconnect] Игра ${gameIdFromMap} для пользователя ${identifier} (сокет ${socketId}) УЖЕ ЗАВЕРШЕНА. Игра будет очищена своей собственной логикой или следующим релевантным действием.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier);
|
||||
|
||||
if (playerInfoInGame) { // Игрок существует в этой игре
|
||||
console.log(`[GameManager.handleDisconnect] Отключающийся сокет ${socketId} для пользователя ${identifier} (Роль: ${playerInfoInGame.id}) в игре ${gameIdFromMap}. Уведомление GameInstance.`);
|
||||
if (typeof game.handlePlayerPotentiallyLeft === 'function') {
|
||||
// Передаем фактический socketId, который отключился. PCH определит, устарел ли он.
|
||||
game.handlePlayerPotentiallyLeft(playerInfoInGame.id, identifier, playerInfoInGame.chosenCharacterKey, socketId);
|
||||
} else {
|
||||
console.error(`[GameManager.handleDisconnect] КРИТИЧЕСКИ: GameInstance ${gameIdFromMap} отсутствует handlePlayerPotentiallyLeft!`);
|
||||
this._cleanupGame(gameIdFromMap, "missing_reconnect_logic_on_disconnect_gm");
|
||||
}
|
||||
} else {
|
||||
console.warn(`[GameManager.handleDisconnect] Пользователь ${identifier} сопоставлен с игрой ${gameIdFromMap}, но не найден в game.players. Это может указывать на устаревшую запись userIdentifierToGameId. Очистка карты для этого пользователя.`);
|
||||
if (this.userIdentifierToGameId[identifier] === gameIdFromMap) {
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.userIdentifierToGameId[identifier]) {
|
||||
console.warn(`[GameManager.handleDisconnect] Экземпляр игры для gameId ${gameIdFromMap} (пользователь ${identifier}) не найден. Очистка устаревшей записи в карте.`);
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_cleanupGame(gameId, reason = 'unknown') {
|
||||
console.log(`[GameManager._cleanupGame] Попытка очистки для GameID: ${gameId}, Причина: ${reason}`);
|
||||
const game = this.games[gameId];
|
||||
|
||||
if (!game) {
|
||||
console.warn(`[GameManager._cleanupGame] Экземпляр игры для ${gameId} не найден в this.games. Очистка связанных записей.`);
|
||||
const pendingIdx = this.pendingPvPGames.indexOf(gameId);
|
||||
if (pendingIdx > -1) {
|
||||
this.pendingPvPGames.splice(pendingIdx, 1);
|
||||
console.log(`[GameManager._cleanupGame] ${gameId} удален из pendingPvPGames.`);
|
||||
}
|
||||
Object.keys(this.userIdentifierToGameId).forEach(idKey => {
|
||||
if (this.userIdentifierToGameId[idKey] === gameId) {
|
||||
delete this.userIdentifierToGameId[idKey];
|
||||
console.log(`[GameManager._cleanupGame] Удалено сопоставление для пользователя ${idKey} с игрой ${gameId}.`);
|
||||
}
|
||||
});
|
||||
this.broadcastAvailablePvPGames();
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[GameManager._cleanupGame] Очистка игры ${game.id}. Владелец: ${game.ownerIdentifier}. Причина: ${reason}. Игроков в игре: ${game.playerCount}`);
|
||||
if (typeof game.turnTimer?.clear === 'function') game.turnTimer.clear();
|
||||
if (typeof game.clearAllReconnectTimers === 'function') game.clearAllReconnectTimers();
|
||||
|
||||
if (game.gameState && !game.gameState.isGameOver) {
|
||||
console.log(`[GameManager._cleanupGame] Пометка игры ${game.id} как завершенной, так как она очищается во время активности.`);
|
||||
game.gameState.isGameOver = true;
|
||||
// game.io.to(game.id).emit('gameOver', { winnerId: null, reason: `game_cleanup_${reason}`, finalGameState: game.gameState, log: game.consumeLogBuffer() });
|
||||
}
|
||||
|
||||
Object.values(game.players).forEach(pInfo => {
|
||||
if (pInfo?.identifier && this.userIdentifierToGameId[pInfo.identifier] === gameId) {
|
||||
delete this.userIdentifierToGameId[pInfo.identifier];
|
||||
console.log(`[GameManager._cleanupGame] Очищено userIdentifierToGameId для игрока ${pInfo.identifier}.`);
|
||||
}
|
||||
});
|
||||
// Дополнительная проверка для владельца, если он не был в списке игроков (маловероятно, но для полноты)
|
||||
if (game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId) {
|
||||
if (!Object.values(game.players).some(p => p.identifier === game.ownerIdentifier)) {
|
||||
delete this.userIdentifierToGameId[game.ownerIdentifier];
|
||||
console.log(`[GameManager._cleanupGame] Очищено userIdentifierToGameId для владельца ${game.ownerIdentifier} (не был в списке игроков).`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const pendingIdx = this.pendingPvPGames.indexOf(gameId);
|
||||
if (pendingIdx > -1) {
|
||||
this.pendingPvPGames.splice(pendingIdx, 1);
|
||||
console.log(`[GameManager._cleanupGame] ${gameId} удален из pendingPvPGames.`);
|
||||
}
|
||||
|
||||
delete this.games[gameId];
|
||||
console.log(`[GameManager._cleanupGame] Экземпляр игры ${gameId} удален. Осталось игр: ${Object.keys(this.games).length}. Ожидающих: ${this.pendingPvPGames.length}. Размер карты пользователей: ${Object.keys(this.userIdentifierToGameId).length}`);
|
||||
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 p1Entry = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected);
|
||||
let p1Username = 'Игрок';
|
||||
let p1CharName = 'Неизвестный';
|
||||
const ownerId = game.ownerIdentifier;
|
||||
|
||||
if (p1Entry) { // Используем данные из p1Entry, если он есть (более надежно)
|
||||
p1Username = p1Entry.socket?.userData?.username || `User#${String(p1Entry.identifier).substring(0,4)}`;
|
||||
const charData = dataUtils.getCharacterBaseStats(p1Entry.chosenCharacterKey);
|
||||
p1CharName = charData?.name || p1Entry.chosenCharacterKey || 'Не выбран';
|
||||
} else if (ownerId){ // Резервный вариант, если p1Entry почему-то нет
|
||||
const ownerSocket = this._findClientSocketByIdentifier(ownerId);
|
||||
p1Username = ownerSocket?.userData?.username || `Owner#${String(ownerId).substring(0,4)}`;
|
||||
const ownerCharKey = game.playerCharacterKey;
|
||||
const charData = ownerCharKey ? dataUtils.getCharacterBaseStats(ownerCharKey) : null;
|
||||
p1CharName = charData?.name || ownerCharKey || 'Не выбран';
|
||||
}
|
||||
return { id: gameId, status: `Ожидает (${p1Username} за ${p1CharName})`, ownerIdentifier: ownerId };
|
||||
} else if (game && (game.playerCount !== 1 || game.gameState?.isGameOver)) {
|
||||
console.warn(`[GameManager.getAvailablePvPGamesListForClient] Игра ${gameId} находится в pendingPvPGames, но не является допустимой ожидающей игрой (игроков: ${game.playerCount}, завершена: ${game.gameState?.isGameOver}). Удаление.`);
|
||||
this._cleanupGame(gameId, 'invalid_pending_game_in_list');
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(info => info !== null);
|
||||
}
|
||||
|
||||
broadcastAvailablePvPGames() {
|
||||
const list = this.getAvailablePvPGamesListForClient();
|
||||
this.io.emit('availablePvPGamesList', list);
|
||||
}
|
||||
|
||||
handleRequestGameState(socket, identifier) {
|
||||
const gameIdFromMap = this.userIdentifierToGameId[identifier];
|
||||
console.log(`[GameManager.handleRequestGameState] Пользователь: ${identifier} (Socket: ${socket.id}) запрашивает состояние. GameID из карты: ${gameIdFromMap}`);
|
||||
const game = gameIdFromMap ? this.games[gameIdFromMap] : null;
|
||||
|
||||
if (game) {
|
||||
const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier);
|
||||
|
||||
if (playerInfoInGame) {
|
||||
if (game.gameState?.isGameOver) {
|
||||
socket.emit('gameNotFound', { message: 'Ваша предыдущая игра уже завершена.' });
|
||||
// Не удаляем из userIdentifierToGameId здесь, _cleanupGame сделает это, если игра еще в this.games
|
||||
return;
|
||||
}
|
||||
if (typeof game.handlePlayerReconnected === 'function') {
|
||||
const reconnected = game.handlePlayerReconnected(playerInfoInGame.id, socket);
|
||||
if (!reconnected) {
|
||||
console.warn(`[GameManager.handleRequestGameState] game.handlePlayerReconnected для ${identifier} в ${game.id} вернул false.`);
|
||||
// GameInstance должен был отправить ошибку.
|
||||
}
|
||||
} else {
|
||||
console.error(`[GameManager.handleRequestGameState] КРИТИЧЕСКИ: GameInstance ${game.id} отсутствует handlePlayerReconnected!`);
|
||||
this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_gm_on_request');
|
||||
}
|
||||
} else {
|
||||
// Игрок сопоставлен с игрой, но НЕ НАЙДЕН в game.players. Это может произойти, если PCH еще не добавил игрока (например, F5 на экране создания игры).
|
||||
// Попытаемся добавить игрока в игру, если это PvP и есть место, или если это его же игра в режиме AI.
|
||||
console.warn(`[GameManager.handleRequestGameState] Пользователь ${identifier} сопоставлен с игрой ${gameIdFromMap}, но НЕ НАЙДЕН в game.players. Попытка добавить/переподключить.`);
|
||||
if (game.mode === 'pvp') {
|
||||
// Пытаемся присоединить, предполагая, что он мог быть удален или это F5 перед полным присоединением
|
||||
const chosenCharKey = socket.handshake.query.chosenCharacterKey || 'elena'; // Получаем ключ из запроса или дефолтный
|
||||
if (game.addPlayer(socket, chosenCharKey, identifier)) {
|
||||
// Успешно добавили или переподключили через addPlayer -> handlePlayerReconnected
|
||||
const newPlayerInfo = Object.values(game.players).find(p => p.identifier === identifier);
|
||||
socket.emit('gameCreated', { // Отправляем событие, как при обычном присоединении
|
||||
gameId: game.id,
|
||||
mode: game.mode,
|
||||
yourPlayerId: newPlayerInfo.id,
|
||||
chosenCharacterKey: newPlayerInfo.chosenCharacterKey
|
||||
});
|
||||
if (game.playerCount === 2) { // Если игра стала полной
|
||||
if(game.initializeGame()) game.startGame(); else this._cleanupGame(game.id, 'init_fail_pvp_readd_gm');
|
||||
const idx = this.pendingPvPGames.indexOf(game.id);
|
||||
if (idx > -1) this.pendingPvPGames.splice(idx, 1);
|
||||
this.broadcastAvailablePvPGames();
|
||||
}
|
||||
} else {
|
||||
// Не удалось добавить/переподключить через addPlayer
|
||||
this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_readd_failed_in_gi_on_request');
|
||||
}
|
||||
|
||||
} else if (game.mode === 'ai' && game.ownerIdentifier === identifier) {
|
||||
// Для AI игры, если это владелец, пытаемся через handlePlayerReconnected
|
||||
if (typeof game.handlePlayerReconnected === 'function') {
|
||||
// Предполагаем, что роль PLAYER_ID, так как это AI игра и он владелец
|
||||
const reconnected = game.handlePlayerReconnected(GAME_CONFIG.PLAYER_ID, socket);
|
||||
if (!reconnected) {
|
||||
this._handleGameRecoveryError(socket, game.id, identifier, 'ai_owner_reconnect_failed_on_request');
|
||||
}
|
||||
} else {
|
||||
this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_ai_owner_on_request');
|
||||
}
|
||||
} else {
|
||||
this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_not_in_gi_players_unhandled_case_on_request');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
socket.emit('gameNotFound', { message: 'Активная игровая сессия не найдена.' });
|
||||
if (this.userIdentifierToGameId[identifier]) {
|
||||
console.warn(`[GameManager.handleRequestGameState] Экземпляр игры для gameId ${gameIdFromMap} (пользователь ${identifier}) не найден. Очистка устаревшей записи в карте.`);
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleGameRecoveryError(socket, gameId, identifier, reasonCode) {
|
||||
console.error(`[GameManager._handleGameRecoveryError] Ошибка восстановления игры (ID: ${gameId || 'N/A'}) для пользователя ${identifier}. Причина: ${reasonCode}.`);
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры. Попробуйте войти снова.' });
|
||||
|
||||
if (gameId && this.games[gameId]) {
|
||||
this._cleanupGame(gameId, `recovery_error_gm_${reasonCode}_for_${identifier}`);
|
||||
} else if (this.userIdentifierToGameId[identifier]) {
|
||||
const problematicGameIdForUser = this.userIdentifierToGameId[identifier];
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
console.log(`[GameManager._handleGameRecoveryError] Очищено устаревшее userIdentifierToGameId[${identifier}], указывающее на ${problematicGameIdForUser}.`);
|
||||
}
|
||||
if (this.userIdentifierToGameId[identifier]) { // Финальная проверка
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
console.warn(`[GameManager._handleGameRecoveryError] Принудительно очищено userIdentifierToGameId[${identifier}] в качестве финальной меры.`);
|
||||
}
|
||||
socket.emit('gameNotFound', { message: 'Ваша игровая сессия была завершена из-за ошибки. Пожалуйста, войдите снова.' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GameManager;
|
752
server/game/instance/GameInstance.js
Normal file
752
server/game/instance/GameInstance.js
Normal file
@ -0,0 +1,752 @@
|
||||
// /server/game/instance/GameInstance.js
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const TurnTimer = require('./TurnTimer');
|
||||
const gameLogic = require('../logic');
|
||||
const dataUtils = require('../../data/dataUtils');
|
||||
const GAME_CONFIG = require('../../core/config');
|
||||
const PlayerConnectionHandler = require('./PlayerConnectionHandler');
|
||||
|
||||
class GameInstance {
|
||||
constructor(gameId, io, mode = 'ai', gameManager) {
|
||||
this.id = gameId;
|
||||
this.io = io;
|
||||
this.mode = mode;
|
||||
this.gameManager = gameManager;
|
||||
|
||||
this.playerConnectionHandler = new PlayerConnectionHandler(this);
|
||||
|
||||
this.gameState = null;
|
||||
this.aiOpponent = (mode === 'ai');
|
||||
this.logBuffer = [];
|
||||
|
||||
this.playerCharacterKey = null;
|
||||
this.opponentCharacterKey = null;
|
||||
this.ownerIdentifier = null;
|
||||
|
||||
this.turnTimer = new TurnTimer(
|
||||
GAME_CONFIG.TURN_DURATION_MS,
|
||||
GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS,
|
||||
() => this.handleTurnTimeout(),
|
||||
(remainingTime, isPlayerTurnForTimer, isPaused) => {
|
||||
// Логируем отправку обновления таймера
|
||||
// console.log(`[GI TURN_TIMER_CB ${this.id}] Sending update. Remaining: ${remainingTime}, isPlayerT: ${isPlayerTurnForTimer}, isPaused (raw): ${isPaused}, effectivelyPaused: ${this.isGameEffectivelyPaused()}`);
|
||||
this.io.to(this.id).emit('turnTimerUpdate', {
|
||||
remainingTime,
|
||||
isPlayerTurn: isPlayerTurnForTimer,
|
||||
isPaused: isPaused || this.isGameEffectivelyPaused()
|
||||
});
|
||||
},
|
||||
this.id
|
||||
);
|
||||
|
||||
if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') {
|
||||
console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: Ссылка на GameManager недействительна.`);
|
||||
}
|
||||
console.log(`[GameInstance ${this.id}] Создан. Режим: ${mode}. PlayerConnectionHandler также инициализирован.`);
|
||||
}
|
||||
|
||||
get playerCount() {
|
||||
return this.playerConnectionHandler.playerCount;
|
||||
}
|
||||
|
||||
get players() {
|
||||
return this.playerConnectionHandler.getAllPlayersInfo();
|
||||
}
|
||||
|
||||
setPlayerCharacterKey(key) { this.playerCharacterKey = key; }
|
||||
setOpponentCharacterKey(key) { this.opponentCharacterKey = key; }
|
||||
setOwnerIdentifier(identifier) { this.ownerIdentifier = identifier; }
|
||||
|
||||
addPlayer(socket, chosenCharacterKey, identifier) {
|
||||
return this.playerConnectionHandler.addPlayer(socket, chosenCharacterKey, identifier);
|
||||
}
|
||||
|
||||
removePlayer(socketId, reason) {
|
||||
this.playerConnectionHandler.removePlayer(socketId, reason);
|
||||
}
|
||||
|
||||
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) {
|
||||
this.playerConnectionHandler.handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId);
|
||||
}
|
||||
|
||||
handlePlayerReconnected(playerIdRole, newSocket) {
|
||||
console.log(`[GameInstance ${this.id}] Делегирование handlePlayerReconnected в PCH для роли ${playerIdRole}, сокет ${newSocket.id}`);
|
||||
return this.playerConnectionHandler.handlePlayerReconnected(playerIdRole, newSocket);
|
||||
}
|
||||
|
||||
clearAllReconnectTimers() {
|
||||
this.playerConnectionHandler.clearAllReconnectTimers();
|
||||
}
|
||||
|
||||
isGameEffectivelyPaused() {
|
||||
return this.playerConnectionHandler.isGameEffectivelyPaused();
|
||||
}
|
||||
|
||||
handlePlayerPermanentlyLeft(playerRole, characterKey, reason) {
|
||||
console.log(`[GameInstance ${this.id}] Игрок окончательно покинул игру. Роль: ${playerRole}, Персонаж: ${characterKey}, Причина: ${reason}`);
|
||||
if (this.gameState && !this.gameState.isGameOver) {
|
||||
if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) {
|
||||
this.endGameDueToDisconnect(playerRole, characterKey, "player_left_ai_game");
|
||||
} else if (this.mode === 'pvp') {
|
||||
if (this.playerCount < 2) {
|
||||
const remainingActivePlayerEntry = Object.values(this.players).find(p => p.id !== playerRole && !p.isTemporarilyDisconnected);
|
||||
this.endGameDueToDisconnect(playerRole, characterKey, "opponent_left_pvp_game", remainingActivePlayerEntry?.id);
|
||||
}
|
||||
}
|
||||
} else if (!this.gameState && Object.keys(this.players).length === 0) {
|
||||
this.gameManager._cleanupGame(this.id, "all_players_left_before_start_gi_via_pch");
|
||||
}
|
||||
}
|
||||
|
||||
_sayTaunt(characterState, opponentCharacterKey, triggerType, subTriggerOrContext = null, contextOverrides = {}) {
|
||||
if (!characterState || !characterState.characterKey) return;
|
||||
if (!opponentCharacterKey) return;
|
||||
if (!gameLogic.getRandomTaunt) { console.error(`[Taunt ${this.id}] _sayTaunt: gameLogic.getRandomTaunt недоступен!`); return; }
|
||||
if (!this.gameState) return;
|
||||
|
||||
let context = {};
|
||||
let subTrigger = null;
|
||||
|
||||
if (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number') {
|
||||
subTrigger = subTriggerOrContext;
|
||||
} else if (typeof subTriggerOrContext === 'object' && subTriggerOrContext !== null) {
|
||||
context = { ...subTriggerOrContext };
|
||||
}
|
||||
context = { ...context, ...contextOverrides };
|
||||
|
||||
if ((triggerType === 'selfCastAbility' || triggerType === 'onOpponentAction') &&
|
||||
(typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number')) {
|
||||
context.abilityId = subTriggerOrContext;
|
||||
subTrigger = subTriggerOrContext;
|
||||
} else if (triggerType === 'onBattleState' && typeof subTriggerOrContext === 'string') {
|
||||
subTrigger = subTriggerOrContext;
|
||||
} else if (triggerType === 'basicAttack' && typeof subTriggerOrContext === 'string') {
|
||||
subTrigger = subTriggerOrContext;
|
||||
}
|
||||
|
||||
const opponentFullData = dataUtils.getCharacterData(opponentCharacterKey);
|
||||
if (!opponentFullData) return;
|
||||
|
||||
const tauntText = gameLogic.getRandomTaunt(
|
||||
characterState.characterKey,
|
||||
triggerType,
|
||||
subTrigger || context,
|
||||
GAME_CONFIG,
|
||||
opponentFullData,
|
||||
this.gameState
|
||||
);
|
||||
|
||||
if (tauntText && tauntText !== "(Молчание)") {
|
||||
this.addToLog(`${characterState.name}: "${tauntText}"`, GAME_CONFIG.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
|
||||
initializeGame() {
|
||||
console.log(`[GameInstance ${this.id}] Инициализация состояния игры. Режим: ${this.mode}. Активных игроков (PCH): ${this.playerCount}. Всего записей в PCH.players: ${Object.keys(this.players).length}. PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}`);
|
||||
|
||||
const p1ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected);
|
||||
const p2ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected);
|
||||
|
||||
// Устанавливаем ключи персонажей, если они еще не установлены, на основе активных игроков в PCH
|
||||
// Это важно, если initializeGame вызывается до того, как PCH успел обновить ключи в GI через сеттеры
|
||||
if (p1ActiveEntry && !this.playerCharacterKey) this.playerCharacterKey = p1ActiveEntry.chosenCharacterKey;
|
||||
if (p2ActiveEntry && !this.opponentCharacterKey && this.mode === 'pvp') this.opponentCharacterKey = p2ActiveEntry.chosenCharacterKey;
|
||||
|
||||
|
||||
if (this.mode === 'ai') {
|
||||
if (!p1ActiveEntry) { this._handleCriticalError('init_ai_no_active_player_gi', 'Инициализация AI игры: Игрок-человек не найден или не активен.'); return false; }
|
||||
if (!this.playerCharacterKey) { this._handleCriticalError('init_ai_no_player_key_gi', 'Инициализация AI игры: Ключ персонажа игрока не установлен.'); return false;}
|
||||
this.opponentCharacterKey = 'balard';
|
||||
} else { // pvp
|
||||
if (this.playerCount === 1 && p1ActiveEntry && !this.playerCharacterKey) {
|
||||
this._handleCriticalError('init_pvp_single_player_no_key_gi', 'PvP инициализация (1 игрок): Ключ персонажа игрока отсутствует.'); return false;
|
||||
}
|
||||
if (this.playerCount === 2 && (!this.playerCharacterKey || !this.opponentCharacterKey)) {
|
||||
this._handleCriticalError('init_pvp_char_key_missing_gi', `Инициализация PvP: playerCount=2, но ключ персонажа отсутствует. P1Key: ${this.playerCharacterKey}, P2Key: ${this.opponentCharacterKey}.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const playerData = this.playerCharacterKey ? dataUtils.getCharacterData(this.playerCharacterKey) : null;
|
||||
const opponentData = this.opponentCharacterKey ? dataUtils.getCharacterData(this.opponentCharacterKey) : null;
|
||||
|
||||
const isPlayerSlotFilledAndActive = !!(playerData && p1ActiveEntry);
|
||||
const isOpponentSlotFilledAndActive = !!(opponentData && (this.mode === 'ai' || p2ActiveEntry));
|
||||
|
||||
if (this.mode === 'ai' && (!isPlayerSlotFilledAndActive || !opponentData) ) {
|
||||
this._handleCriticalError('init_ai_data_fail_gs_gi', 'Инициализация AI игры: Не удалось загрузить данные игрока или AI для gameState.'); return false;
|
||||
}
|
||||
|
||||
this.logBuffer = [];
|
||||
|
||||
// Имена берутся из playerData/opponentData, если они есть. PCH обновит их при реконнекте, если они изменились.
|
||||
const playerName = playerData?.baseStats?.name || (p1ActiveEntry?.name || 'Ожидание Игрока 1...');
|
||||
let opponentName;
|
||||
if (this.mode === 'ai') {
|
||||
opponentName = opponentData?.baseStats?.name || 'Противник AI';
|
||||
} else {
|
||||
opponentName = opponentData?.baseStats?.name || (p2ActiveEntry?.name || 'Ожидание Игрока 2...');
|
||||
}
|
||||
|
||||
|
||||
this.gameState = {
|
||||
player: isPlayerSlotFilledAndActive ?
|
||||
this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities, playerName) : // Передаем имя
|
||||
this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: playerName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], playerName),
|
||||
opponent: isOpponentSlotFilledAndActive ?
|
||||
this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities, opponentName) : // Передаем имя
|
||||
this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: opponentName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], opponentName),
|
||||
isPlayerTurn: (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) ? (Math.random() < 0.5) : true,
|
||||
isGameOver: false,
|
||||
turnNumber: 1,
|
||||
gameMode: this.mode
|
||||
};
|
||||
console.log(`[GameInstance ${this.id}] Состояние игры инициализировано. Игрок: ${this.gameState.player.name} (${this.gameState.player.characterKey}). Оппонент: ${this.gameState.opponent.name} (${this.gameState.opponent.characterKey}). IsPlayerTurn: ${this.gameState.isPlayerTurn}. Готово к старту: AI=${isPlayerSlotFilledAndActive && !!opponentData}, PvP1=${isPlayerSlotFilledAndActive}, PvP2=${isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive}`);
|
||||
return (this.mode === 'ai') ? (isPlayerSlotFilledAndActive && !!opponentData) : isPlayerSlotFilledAndActive;
|
||||
}
|
||||
|
||||
_createFighterState(roleId, baseStats, abilities, explicitName = null) {
|
||||
const fighterState = {
|
||||
id: roleId, characterKey: baseStats.characterKey, name: explicitName || baseStats.name, // Используем explicitName если передано
|
||||
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 => {
|
||||
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() {
|
||||
console.log(`[GameInstance ${this.id}] Попытка запуска игры. Paused: ${this.isGameEffectivelyPaused()}`);
|
||||
if (this.isGameEffectivelyPaused()) {
|
||||
console.log(`[GameInstance ${this.id}] Запуск игры отложен: игра на паузе.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) {
|
||||
console.warn(`[GameInstance ${this.id}] startGame: gameState или ключи персонажей не полностью инициализированы. Попытка повторной инициализации.`);
|
||||
if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) {
|
||||
this._handleCriticalError('start_game_reinit_failed_sg_gi', 'Повторная инициализация перед стартом не удалась или ключи все еще отсутствуют в gameState.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log(`[GameInstance ${this.id}] Запуск игры. Игрок в GS: ${this.gameState.player.name} (${this.playerCharacterKey}), Оппонент в GS: ${this.gameState.opponent.name} (${this.opponentCharacterKey}). IsPlayerTurn: ${this.gameState.isPlayerTurn}`);
|
||||
|
||||
const pData = dataUtils.getCharacterData(this.playerCharacterKey);
|
||||
const oData = dataUtils.getCharacterData(this.opponentCharacterKey);
|
||||
|
||||
if (!pData || !oData) {
|
||||
this._handleCriticalError('start_char_data_fail_sg_gi', `Не удалось загрузить данные персонажей при старте игры. PData: ${!!pData}, OData: ${!!oData}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем имена в gameState на основе данных персонажей перед отправкой клиентам
|
||||
// Это гарантирует, что имена из dataUtils (самые "правильные") попадут в первое gameStarted
|
||||
if (this.gameState.player && pData?.baseStats?.name) this.gameState.player.name = pData.baseStats.name;
|
||||
if (this.gameState.opponent && oData?.baseStats?.name) this.gameState.opponent.name = oData.baseStats.name;
|
||||
|
||||
|
||||
this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
|
||||
if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) {
|
||||
this._sayTaunt(this.gameState.player, this.gameState.opponent.characterKey, 'onBattleState', 'start');
|
||||
this._sayTaunt(this.gameState.opponent, this.gameState.player.characterKey, 'onBattleState', 'start');
|
||||
} else {
|
||||
console.warn(`[GameInstance ${this.id}] Не удалось произнести стартовые насмешки во время startGame, gameState акторы/ключи не полностью готовы.`);
|
||||
}
|
||||
|
||||
const initialLog = this.consumeLogBuffer();
|
||||
|
||||
Object.values(this.players).forEach(playerInfo => {
|
||||
if (playerInfo.socket?.connected && !playerInfo.isTemporarilyDisconnected) {
|
||||
const dataForThisClient = 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,
|
||||
...dataForThisClient,
|
||||
log: [...initialLog],
|
||||
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();
|
||||
|
||||
const isFirstTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn;
|
||||
console.log(`[GameInstance ${this.id}] Запуск таймера в startGame. isPlayerTurn: ${this.gameState.isPlayerTurn}, isFirstTurnAi: ${isFirstTurnAi}`);
|
||||
this.turnTimer.start(this.gameState.isPlayerTurn, isFirstTurnAi);
|
||||
|
||||
if (isFirstTurnAi) {
|
||||
setTimeout(() => {
|
||||
if (!this.isGameEffectivelyPaused() && this.gameState && !this.gameState.isGameOver && this.mode === 'ai' && !this.gameState.isPlayerTurn) {
|
||||
this.processAiTurn();
|
||||
}
|
||||
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
|
||||
}
|
||||
}
|
||||
|
||||
processPlayerAction(identifier, actionData) {
|
||||
console.log(`[GameInstance ${this.id}] processPlayerAction от ${identifier}. Действие: ${actionData.actionType}. Текущий GS.isPlayerTurn: ${this.gameState?.isPlayerTurn}. Paused: ${this.isGameEffectivelyPaused()}`);
|
||||
const actingPlayerInfo = Object.values(this.players).find(p => p.identifier === identifier);
|
||||
if (!actingPlayerInfo || !actingPlayerInfo.socket) {
|
||||
console.error(`[GameInstance ${this.id}] Действие от неизвестного или безсокетного идентификатора ${identifier}.`); return;
|
||||
}
|
||||
|
||||
if (this.isGameEffectivelyPaused()) {
|
||||
actingPlayerInfo.socket.emit('gameError', {message: "Действие невозможно: игра на паузе."});
|
||||
return;
|
||||
}
|
||||
if (!this.gameState || this.gameState.isGameOver) { 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}] Неверный ход! Игрок ${identifier} (роль ${actingPlayerRole}) пытался действовать. GS.isPlayerTurn: ${this.gameState.isPlayerTurn}`);
|
||||
actingPlayerInfo.socket.emit('gameError', { message: "Не ваш ход." });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[GameInstance ${this.id}] Ход корректен. Очистка таймера.`);
|
||||
if(this.turnTimer.isActive()) 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];
|
||||
|
||||
if (!attackerState || !attackerState.characterKey || !defenderState || !defenderState.characterKey) {
|
||||
this._handleCriticalError('action_actor_state_invalid_gi', `Состояние/ключ Атакующего или Защитника недействительны.`); return;
|
||||
}
|
||||
const attackerData = dataUtils.getCharacterData(attackerState.characterKey);
|
||||
const defenderData = dataUtils.getCharacterData(defenderState.characterKey);
|
||||
if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail_process_gi', 'Ошибка данных персонажа при действии.'); return; }
|
||||
|
||||
let actionIsValidAndPerformed = false;
|
||||
|
||||
if (actionData.actionType === 'attack') {
|
||||
this._sayTaunt(attackerState, defenderState.characterKey, 'basicAttack');
|
||||
gameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt);
|
||||
actionIsValidAndPerformed = true;
|
||||
} else if (actionData.actionType === 'ability' && actionData.abilityId) {
|
||||
const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId);
|
||||
if (!ability) {
|
||||
actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." });
|
||||
} else {
|
||||
const validityCheck = gameLogic.checkAbilityValidity(ability, attackerState, defenderState, GAME_CONFIG);
|
||||
if (validityCheck.isValid) {
|
||||
this._sayTaunt(attackerState, defenderState.characterKey, 'selfCastAbility', ability.id);
|
||||
attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost);
|
||||
gameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt, null);
|
||||
gameLogic.setAbilityCooldown(ability, attackerState, GAME_CONFIG);
|
||||
actionIsValidAndPerformed = true;
|
||||
} else {
|
||||
this.addToLog(validityCheck.reason || `${attackerState.name} не может использовать "${ability.name}".`, GAME_CONFIG.LOG_TYPE_INFO);
|
||||
actionIsValidAndPerformed = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
actionIsValidAndPerformed = false;
|
||||
}
|
||||
|
||||
if (this.checkGameOver()) return;
|
||||
this.broadcastLogUpdate();
|
||||
if (actionIsValidAndPerformed) {
|
||||
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
|
||||
} else {
|
||||
const isAiTurnForTimer = this.mode === 'ai' && !this.gameState.isPlayerTurn;
|
||||
console.log(`[GameInstance ${this.id}] Действие не выполнено, перезапуск таймера. isPlayerTurn: ${this.gameState.isPlayerTurn}, isAiTurnForTimer: ${isAiTurnForTimer}`);
|
||||
this.turnTimer.start(this.gameState.isPlayerTurn, isAiTurnForTimer);
|
||||
}
|
||||
}
|
||||
|
||||
switchTurn() {
|
||||
console.log(`[GameInstance ${this.id}] Попытка смены хода. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}`);
|
||||
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Смена хода отложена: игра на паузе.`); return; }
|
||||
if (!this.gameState || this.gameState.isGameOver) { return; }
|
||||
if(this.turnTimer.isActive()) this.turnTimer.clear();
|
||||
|
||||
const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
|
||||
const endingTurnActorState = this.gameState[endingTurnActorRole];
|
||||
if (!endingTurnActorState || !endingTurnActorState.characterKey) { this._handleCriticalError('switch_turn_ending_actor_invalid_gi', `Состояние или ключ актора, завершающего ход, недействительны.`); return; }
|
||||
const endingTurnActorData = dataUtils.getCharacterData(endingTurnActorState.characterKey);
|
||||
if (!endingTurnActorData) { this._handleCriticalError('switch_turn_char_data_fail_gi', `Отсутствуют данные персонажа.`); return; }
|
||||
|
||||
gameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnActorData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils);
|
||||
gameLogic.updateBlockingStatus(endingTurnActorState);
|
||||
if (endingTurnActorState.abilityCooldowns && endingTurnActorData.abilities) gameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnActorData.abilities, endingTurnActorState.name, this.addToLog.bind(this), GAME_CONFIG);
|
||||
if (endingTurnActorState.characterKey === 'balard') gameLogic.processBalardSpecialCooldowns(endingTurnActorState);
|
||||
if (endingTurnActorState.disabledAbilities?.length > 0 && endingTurnActorData.abilities) gameLogic.processDisabledAbilities(endingTurnActorState.disabledAbilities, endingTurnActorData.abilities, endingTurnActorState.name, this.addToLog.bind(this), GAME_CONFIG);
|
||||
|
||||
if (this.checkGameOver()) return;
|
||||
|
||||
this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn;
|
||||
if (this.gameState.isPlayerTurn) this.gameState.turnNumber++;
|
||||
|
||||
const currentTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
|
||||
const currentTurnActorState = this.gameState[currentTurnActorRole];
|
||||
if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid_gi', `Состояние или имя текущего актора недействительны.`); return; }
|
||||
|
||||
this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN);
|
||||
this.broadcastGameStateUpdate();
|
||||
|
||||
const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole);
|
||||
if (currentTurnPlayerEntry && currentTurnPlayerEntry.isTemporarilyDisconnected) {
|
||||
console.log(`[GameInstance ${this.id}] Ход перешел к ${currentTurnActorRole}, но игрок ${currentTurnPlayerEntry.identifier} отключен. Таймер не запущен switchTurn.`);
|
||||
} else {
|
||||
const isNextTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn;
|
||||
console.log(`[GameInstance ${this.id}] Запуск таймера в switchTurn. isPlayerTurn: ${this.gameState.isPlayerTurn}, isNextTurnAi: ${isNextTurnAi}`);
|
||||
this.turnTimer.start(this.gameState.isPlayerTurn, isNextTurnAi);
|
||||
if (isNextTurnAi) {
|
||||
setTimeout(() => {
|
||||
if (!this.isGameEffectivelyPaused() && this.gameState && !this.gameState.isGameOver && this.mode === 'ai' && !this.gameState.isPlayerTurn) {
|
||||
this.processAiTurn();
|
||||
}
|
||||
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processAiTurn() {
|
||||
console.log(`[GameInstance ${this.id}] processAiTurn. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}, IsPlayerTurn: ${this.gameState?.isPlayerTurn}`);
|
||||
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Ход AI отложен: игра на паузе.`); return; }
|
||||
if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent) { return; }
|
||||
if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) {
|
||||
console.error(`[GameInstance ${this.id}] AI не Балард! Персонаж AI: ${this.gameState.opponent?.characterKey}. Принудительная смена хода.`);
|
||||
this.switchTurn();
|
||||
return;
|
||||
}
|
||||
if(this.turnTimer.isActive()) this.turnTimer.clear();
|
||||
|
||||
const aiState = this.gameState.opponent;
|
||||
const playerState = this.gameState.player;
|
||||
if (!playerState || !playerState.characterKey) { this._handleCriticalError('ai_turn_player_state_invalid_gi', 'Состояние игрока недействительно для хода AI.'); return; }
|
||||
|
||||
const aiDecision = gameLogic.decideAiAction(this.gameState, dataUtils, GAME_CONFIG, this.addToLog.bind(this));
|
||||
let actionIsValidAndPerformedForAI = false;
|
||||
|
||||
if (aiDecision.actionType === 'attack') {
|
||||
this._sayTaunt(aiState, playerState.characterKey, 'basicAttack');
|
||||
gameLogic.performAttack(aiState, playerState, dataUtils.getCharacterBaseStats(aiState.characterKey), dataUtils.getCharacterBaseStats(playerState.characterKey), this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt);
|
||||
actionIsValidAndPerformedForAI = true;
|
||||
} else if (aiDecision.actionType === 'ability' && aiDecision.ability) {
|
||||
this._sayTaunt(aiState, playerState.characterKey, 'selfCastAbility', aiDecision.ability.id);
|
||||
aiState.currentResource = Math.round(aiState.currentResource - aiDecision.ability.cost);
|
||||
gameLogic.applyAbilityEffect(aiDecision.ability, aiState, playerState, dataUtils.getCharacterBaseStats(aiState.characterKey), dataUtils.getCharacterBaseStats(playerState.characterKey), this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt, null);
|
||||
gameLogic.setAbilityCooldown(aiDecision.ability, aiState, GAME_CONFIG);
|
||||
actionIsValidAndPerformedForAI = true;
|
||||
} else if (aiDecision.actionType === 'pass') {
|
||||
if (aiDecision.logMessage && this.addToLog) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type);
|
||||
else if(this.addToLog) this.addToLog(`${aiState.name} пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO);
|
||||
actionIsValidAndPerformedForAI = true;
|
||||
}
|
||||
|
||||
if (this.checkGameOver()) return;
|
||||
this.broadcastLogUpdate();
|
||||
if (actionIsValidAndPerformedForAI) {
|
||||
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
|
||||
} else {
|
||||
console.error(`[GameInstance ${this.id}] AI не смог выполнить действие. Принудительная смена хода.`);
|
||||
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
|
||||
}
|
||||
}
|
||||
|
||||
checkGameOver() {
|
||||
if (!this.gameState || this.gameState.isGameOver) return this.gameState?.isGameOver ?? true;
|
||||
|
||||
if (!this.gameState.isGameOver && this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) {
|
||||
const player = this.gameState.player; const opponent = this.gameState.opponent;
|
||||
const pData = dataUtils.getCharacterData(player.characterKey); const oData = dataUtils.getCharacterData(opponent.characterKey);
|
||||
if (pData && oData) {
|
||||
const nearDefeatThreshold = GAME_CONFIG.OPPONENT_NEAR_DEFEAT_THRESHOLD_PERCENT || 0.2;
|
||||
if (opponent.currentHp > 0 && (opponent.currentHp / oData.baseStats.maxHp) <= nearDefeatThreshold) {
|
||||
this._sayTaunt(player, opponent.characterKey, 'onBattleState', 'opponentNearDefeat');
|
||||
}
|
||||
if (player.currentHp > 0 && (player.currentHp / pData.baseStats.maxHp) <= nearDefeatThreshold) {
|
||||
this._sayTaunt(opponent, player.characterKey, 'onBattleState', 'opponentNearDefeat');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gameOverResult = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode);
|
||||
if (gameOverResult.isOver) {
|
||||
this.gameState.isGameOver = true;
|
||||
if(this.turnTimer.isActive()) this.turnTimer.clear();
|
||||
this.clearAllReconnectTimers();
|
||||
this.addToLog(gameOverResult.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
|
||||
const winnerState = this.gameState[gameOverResult.winnerRole];
|
||||
const loserState = this.gameState[gameOverResult.loserRole];
|
||||
if (winnerState?.characterKey && loserState?.characterKey) {
|
||||
this._sayTaunt(winnerState, loserState.characterKey, 'onBattleState', 'opponentNearDefeat');
|
||||
}
|
||||
|
||||
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, `game_ended_${gameOverResult.reason}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) {
|
||||
if (this.gameState && !this.gameState.isGameOver) {
|
||||
this.gameState.isGameOver = true;
|
||||
if(this.turnTimer.isActive()) this.turnTimer.clear();
|
||||
this.clearAllReconnectTimers();
|
||||
|
||||
let actualWinnerRole = winnerIfAny;
|
||||
let winnerActuallyExists = false;
|
||||
|
||||
if (actualWinnerRole) {
|
||||
const winnerPlayerEntry = Object.values(this.players).find(p => p.id === actualWinnerRole && !p.isTemporarilyDisconnected);
|
||||
if (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID) {
|
||||
winnerActuallyExists = !!this.gameState.opponent?.characterKey;
|
||||
} else if (winnerPlayerEntry) {
|
||||
winnerActuallyExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!winnerActuallyExists) {
|
||||
actualWinnerRole = (disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID);
|
||||
const defaultWinnerEntry = Object.values(this.players).find(p => p.id === actualWinnerRole && !p.isTemporarilyDisconnected);
|
||||
if (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID) {
|
||||
winnerActuallyExists = !!this.gameState.opponent?.characterKey;
|
||||
} else if (defaultWinnerEntry) {
|
||||
winnerActuallyExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
const finalWinnerRole = winnerActuallyExists ? actualWinnerRole : null;
|
||||
const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, reason, finalWinnerRole, disconnectedPlayerRole);
|
||||
|
||||
this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
console.log(`[GameInstance ${this.id}] Игра завершена из-за отключения: ${reason}. Победитель: ${result.winnerRole || 'Нет'}.`);
|
||||
this.io.to(this.id).emit('gameOver', {
|
||||
winnerId: result.winnerRole,
|
||||
reason: result.reason,
|
||||
finalGameState: this.gameState,
|
||||
log: this.consumeLogBuffer(),
|
||||
loserCharacterKey: disconnectedCharacterKey,
|
||||
disconnectedCharacterName: (reason === 'opponent_disconnected' || reason === 'player_left_ai_game' || reason === 'opponent_left_pvp_game') ?
|
||||
(this.gameState[disconnectedPlayerRole]?.name || disconnectedCharacterKey) : undefined
|
||||
});
|
||||
this.gameManager._cleanupGame(this.id, `disconnect_game_ended_gi_${result.reason}`);
|
||||
} else if (this.gameState?.isGameOver) {
|
||||
console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: игра уже была завершена.`);
|
||||
this.gameManager._cleanupGame(this.id, `already_over_on_disconnect_cleanup_gi`);
|
||||
} else {
|
||||
console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: нет gameState.`);
|
||||
this.gameManager._cleanupGame(this.id, `no_gamestate_on_disconnect_cleanup_gi`);
|
||||
}
|
||||
}
|
||||
|
||||
playerExplicitlyLeftAiGame(identifier) {
|
||||
if (this.mode !== 'ai' || (this.gameState && this.gameState.isGameOver)) {
|
||||
console.log(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame вызван, но не режим AI или игра завершена.`);
|
||||
if (this.gameState?.isGameOver) this.gameManager._cleanupGame(this.id, `player_left_ai_already_over_gi`);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerEntry = Object.values(this.players).find(p => p.identifier === identifier);
|
||||
if (!playerEntry || playerEntry.id !== GAME_CONFIG.PLAYER_ID) {
|
||||
console.warn(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame: Идентификатор ${identifier} не является игроком-человеком или не найден.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[GameInstance ${this.id}] Игрок ${identifier} явно покинул AI игру.`);
|
||||
if (this.gameState) {
|
||||
this.gameState.isGameOver = true;
|
||||
this.addToLog(`Игрок покинул битву с ${this.gameState.opponent?.name || 'AI'}.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
} else {
|
||||
this.addToLog(`Игрок покинул AI игру до ее полного начала.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
}
|
||||
|
||||
if (this.turnTimer.isActive()) this.turnTimer.clear();
|
||||
this.clearAllReconnectTimers();
|
||||
|
||||
this.io.to(this.id).emit('gameOver', {
|
||||
winnerId: GAME_CONFIG.OPPONENT_ID,
|
||||
reason: "player_left_ai_game",
|
||||
finalGameState: this.gameState,
|
||||
log: this.consumeLogBuffer(),
|
||||
loserCharacterKey: playerEntry.chosenCharacterKey
|
||||
});
|
||||
this.gameManager._cleanupGame(this.id, 'player_left_ai_explicitly_gi');
|
||||
}
|
||||
|
||||
playerDidSurrender(surrenderingPlayerIdentifier) {
|
||||
console.log(`[GameInstance ${this.id}] playerDidSurrender вызван для идентификатора: ${surrenderingPlayerIdentifier}`);
|
||||
|
||||
if (!this.gameState || this.gameState.isGameOver) {
|
||||
if (this.gameState?.isGameOver) { this.gameManager._cleanupGame(this.id, `surrender_on_finished_gi`); }
|
||||
console.warn(`[GameInstance ${this.id}] Попытка сдачи в неактивной/завершенной игре от ${surrenderingPlayerIdentifier}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const surrenderedPlayerEntry = Object.values(this.players).find(p => p.identifier === surrenderingPlayerIdentifier);
|
||||
if (!surrenderedPlayerEntry) {
|
||||
console.error(`[GameInstance ${this.id}] Сдающийся игрок ${surrenderingPlayerIdentifier} не найден.`);
|
||||
return;
|
||||
}
|
||||
const surrenderingPlayerRole = surrenderedPlayerEntry.id;
|
||||
|
||||
if (this.mode === 'ai') {
|
||||
if (surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID) {
|
||||
console.log(`[GameInstance ${this.id}] Игрок ${surrenderingPlayerIdentifier} "сдался" (покинул) AI игру.`);
|
||||
this.playerExplicitlyLeftAiGame(surrenderingPlayerIdentifier);
|
||||
} else {
|
||||
console.warn(`[GameInstance ${this.id}] Сдача в AI режиме от не-игрока (роль: ${surrenderingPlayerRole}). Игнорируется.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode !== 'pvp') {
|
||||
console.warn(`[GameInstance ${this.id}] Сдача вызвана в не-PvP, не-AI режиме: ${this.mode}. Игнорируется.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const surrenderedPlayerName = this.gameState[surrenderingPlayerRole]?.name || surrenderedPlayerEntry.chosenCharacterKey;
|
||||
const surrenderedPlayerCharKey = this.gameState[surrenderingPlayerRole]?.characterKey || surrenderedPlayerEntry.chosenCharacterKey;
|
||||
const winnerRole = surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
|
||||
const winnerName = this.gameState[winnerRole]?.name || `Оппонент`;
|
||||
const winnerCharKey = this.gameState[winnerRole]?.characterKey;
|
||||
|
||||
this.gameState.isGameOver = true;
|
||||
if(this.turnTimer.isActive()) this.turnTimer.clear();
|
||||
this.clearAllReconnectTimers();
|
||||
|
||||
this.addToLog(`🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
console.log(`[GameInstance ${this.id}] Игрок ${surrenderedPlayerName} (Роль: ${surrenderingPlayerRole}) сдался. Победитель: ${winnerName} (Роль: ${winnerRole}).`);
|
||||
|
||||
if (winnerCharKey && surrenderedPlayerCharKey && this.gameState[winnerRole]) {
|
||||
this._sayTaunt(this.gameState[winnerRole], surrenderedPlayerCharKey, 'onBattleState', 'opponentNearDefeat');
|
||||
}
|
||||
|
||||
this.io.to(this.id).emit('gameOver', {
|
||||
winnerId: winnerRole, reason: "player_surrendered",
|
||||
finalGameState: this.gameState, log: this.consumeLogBuffer(),
|
||||
loserCharacterKey: surrenderedPlayerCharKey
|
||||
});
|
||||
this.gameManager._cleanupGame(this.id, "player_surrendered_gi");
|
||||
}
|
||||
|
||||
handleTurnTimeout() {
|
||||
if (!this.gameState || this.gameState.isGameOver) return;
|
||||
console.log(`[GameInstance ${this.id}] Произошел таймаут хода.`);
|
||||
const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
|
||||
|
||||
const winnerPlayerRoleIfHuman = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
|
||||
let winnerActuallyExists = false;
|
||||
|
||||
if (this.mode === 'ai' && winnerPlayerRoleIfHuman === GAME_CONFIG.OPPONENT_ID) {
|
||||
winnerActuallyExists = !!this.gameState.opponent?.characterKey;
|
||||
} else {
|
||||
const winnerEntry = Object.values(this.players).find(p => p.id === winnerPlayerRoleIfHuman && !p.isTemporarilyDisconnected);
|
||||
winnerActuallyExists = !!winnerEntry;
|
||||
}
|
||||
|
||||
const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerActuallyExists ? winnerPlayerRoleIfHuman : null, timedOutPlayerRole);
|
||||
|
||||
this.gameState.isGameOver = true;
|
||||
this.clearAllReconnectTimers();
|
||||
|
||||
this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
if (result.winnerRole && this.gameState[result.winnerRole]?.characterKey && this.gameState[result.loserRole]?.characterKey) {
|
||||
this._sayTaunt(this.gameState[result.winnerRole], this.gameState[result.loserRole].characterKey, 'onBattleState', 'opponentNearDefeat');
|
||||
}
|
||||
console.log(`[GameInstance ${this.id}] Ход истек для ${this.gameState[timedOutPlayerRole]?.name || timedOutPlayerRole}. Победитель: ${result.winnerRole ? (this.gameState[result.winnerRole]?.name || result.winnerRole) : 'Нет'}.`);
|
||||
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, `timeout_gi_${result.reason}`);
|
||||
}
|
||||
|
||||
_handleCriticalError(reasonCode, logMessage) {
|
||||
console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: ${logMessage} (Код: ${reasonCode})`);
|
||||
if (this.gameState && !this.gameState.isGameOver) this.gameState.isGameOver = true;
|
||||
else if (!this.gameState) {
|
||||
this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode };
|
||||
}
|
||||
|
||||
if(this.turnTimer.isActive()) this.turnTimer.clear();
|
||||
this.clearAllReconnectTimers();
|
||||
|
||||
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'
|
||||
});
|
||||
this.gameManager._cleanupGame(this.id, `critical_error_gi_${reasonCode}`);
|
||||
}
|
||||
|
||||
addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) {
|
||||
if (!message) return;
|
||||
this.logBuffer.push({ message, type, timestamp: Date.now() });
|
||||
// Раскомментируйте для немедленной отправки логов, если нужно (но обычно лучше батчинг)
|
||||
// this.broadcastLogUpdate();
|
||||
}
|
||||
|
||||
consumeLogBuffer() {
|
||||
const logs = [...this.logBuffer];
|
||||
this.logBuffer = [];
|
||||
return logs;
|
||||
}
|
||||
|
||||
broadcastGameStateUpdate() {
|
||||
if (this.isGameEffectivelyPaused()) {
|
||||
console.log(`[GameInstance ${this.id}] broadcastGameStateUpdate отложено: игра на паузе.`);
|
||||
return;
|
||||
}
|
||||
if (!this.gameState) {
|
||||
console.warn(`[GameInstance ${this.id}] broadcastGameStateUpdate: gameState отсутствует.`);
|
||||
return;
|
||||
}
|
||||
console.log(`[GameInstance ${this.id}] Отправка gameStateUpdate. IsPlayerTurn: ${this.gameState.isPlayerTurn}`);
|
||||
this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() });
|
||||
}
|
||||
|
||||
broadcastLogUpdate() {
|
||||
if (this.isGameEffectivelyPaused() && this.logBuffer.some(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM)) {
|
||||
const systemLogs = this.logBuffer.filter(log => log.type === GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
if (systemLogs.length > 0) {
|
||||
this.io.to(this.id).emit('logUpdate', { log: systemLogs });
|
||||
}
|
||||
this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM); // Оставляем несистемные
|
||||
return;
|
||||
}
|
||||
if (this.logBuffer.length > 0) {
|
||||
this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GameInstance;
|
0
server/game/instance/Player.js
Normal file
0
server/game/instance/Player.js
Normal file
502
server/game/instance/PlayerConnectionHandler.js
Normal file
502
server/game/instance/PlayerConnectionHandler.js
Normal file
@ -0,0 +1,502 @@
|
||||
// /server/game/instance/PlayerConnectionHandler.js //
|
||||
const GAME_CONFIG = require('../../core/config');
|
||||
const dataUtils = require('../../data/dataUtils');
|
||||
|
||||
class PlayerConnectionHandler {
|
||||
constructor(gameInstance) {
|
||||
this.gameInstance = gameInstance; // Ссылка на основной GameInstance
|
||||
this.io = gameInstance.io;
|
||||
this.gameId = gameInstance.id;
|
||||
this.mode = gameInstance.mode;
|
||||
|
||||
this.players = {}; // { socket.id: { id, socket, chosenCharacterKey, identifier, isTemporarilyDisconnected, name (optional from gameState) } }
|
||||
this.playerSockets = {}; // { playerIdRole: socket } // Авторитетный сокет для роли
|
||||
this.playerCount = 0;
|
||||
|
||||
this.reconnectTimers = {}; // { playerIdRole: { timerId, updateIntervalId, startTimeMs, durationMs } }
|
||||
this.pausedTurnState = null; // { remainingTime, forPlayerRoleIsPlayer, isAiCurrentlyMoving }
|
||||
console.log(`[PCH for Game ${this.gameId}] Инициализирован.`);
|
||||
}
|
||||
|
||||
addPlayer(socket, chosenCharacterKey = 'elena', identifier) {
|
||||
console.log(`[PCH ${this.gameId}] Попытка addPlayer. Socket: ${socket.id}, CharKey: ${chosenCharacterKey}, Identifier: ${identifier}`);
|
||||
const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier);
|
||||
|
||||
if (existingPlayerByIdentifier) {
|
||||
console.warn(`[PCH ${this.gameId}] Идентификатор ${identifier} уже связан с ролью игрока ${existingPlayerByIdentifier.id} (сокет ${existingPlayerByIdentifier.socket?.id}). Обрабатывается как возможное переподключение.`);
|
||||
if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) {
|
||||
console.warn(`[PCH ${this.gameId}] Игрок ${identifier} пытается (пере)присоединиться к уже завершенной игре. Отправка gameError.`);
|
||||
socket.emit('gameError', { message: 'Эта игра уже завершена.' });
|
||||
return false;
|
||||
}
|
||||
// Если игрок уже есть, и это не временное отключение, и сокет другой - это F5 или новая вкладка.
|
||||
// GameManager должен был направить на handleRequestGameState, который вызовет handlePlayerReconnected.
|
||||
// Прямой addPlayer в этом случае - редкий сценарий, но handlePlayerReconnected его обработает.
|
||||
return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket);
|
||||
}
|
||||
|
||||
if (Object.keys(this.players).length >= 2 && this.playerCount >=2 && this.mode === 'pvp') { // В AI режиме только 1 человек
|
||||
socket.emit('gameError', { message: 'Эта игра уже заполнена.' });
|
||||
return false;
|
||||
}
|
||||
if (this.mode === 'ai' && this.playerCount >=1) {
|
||||
socket.emit('gameError', { message: 'К AI игре может присоединиться только один игрок.'});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
let assignedPlayerId;
|
||||
let actualCharacterKey = chosenCharacterKey || 'elena';
|
||||
const charData = dataUtils.getCharacterData(actualCharacterKey);
|
||||
|
||||
if (this.mode === 'ai') {
|
||||
// if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { // Эта проверка уже покрыта playerCount >= 1 выше
|
||||
// socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' });
|
||||
// return false;
|
||||
// }
|
||||
assignedPlayerId = GAME_CONFIG.PLAYER_ID;
|
||||
} else { // pvp
|
||||
if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) {
|
||||
assignedPlayerId = GAME_CONFIG.PLAYER_ID;
|
||||
} else if (!this.playerSockets[GAME_CONFIG.OPPONENT_ID]) {
|
||||
assignedPlayerId = GAME_CONFIG.OPPONENT_ID;
|
||||
const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
|
||||
if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) {
|
||||
if (actualCharacterKey === 'elena') actualCharacterKey = 'almagest';
|
||||
else if (actualCharacterKey === 'almagest') actualCharacterKey = 'elena';
|
||||
else actualCharacterKey = dataUtils.getAllCharacterKeys().find(k => k !== firstPlayerInfo.chosenCharacterKey) || 'elena';
|
||||
}
|
||||
} else { // Оба слота заняты, но playerCount мог быть < 2 если кто-то в процессе дисконнекта
|
||||
socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре (возможно, все заняты или в процессе переподключения).' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Если для этой роли УЖЕ был игрок (например, старый сокет при F5 до того, как сработал disconnect),
|
||||
// то handlePlayerReconnected должен был бы это обработать. Этот блок здесь - подстраховка,
|
||||
// если addPlayer вызван напрямую в таком редком случае.
|
||||
const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId && this.players[sid].socket?.id !== socket.id);
|
||||
if (oldPlayerSocketIdForRole) {
|
||||
const oldPlayerInfo = this.players[oldPlayerSocketIdForRole];
|
||||
console.warn(`[PCH ${this.gameId}] addPlayer: Найден старый сокет ${oldPlayerInfo.socket?.id} для роли ${assignedPlayerId}. Удаляем его запись.`);
|
||||
if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.gameId); oldPlayerInfo.socket.disconnect(true); } catch(e){} }
|
||||
delete this.players[oldPlayerSocketIdForRole];
|
||||
}
|
||||
|
||||
this.players[socket.id] = {
|
||||
id: assignedPlayerId,
|
||||
socket: socket,
|
||||
chosenCharacterKey: actualCharacterKey,
|
||||
identifier: identifier,
|
||||
isTemporarilyDisconnected: false,
|
||||
name: charData?.baseStats?.name || actualCharacterKey
|
||||
};
|
||||
this.playerSockets[assignedPlayerId] = socket;
|
||||
this.playerCount++;
|
||||
socket.join(this.gameId);
|
||||
console.log(`[PCH ${this.gameId}] Сокет ${socket.id} присоединен к комнате ${this.gameId} (addPlayer).`);
|
||||
|
||||
|
||||
if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey);
|
||||
else if (assignedPlayerId === GAME_CONFIG.OPPONENT_ID) this.gameInstance.setOpponentCharacterKey(actualCharacterKey);
|
||||
|
||||
if (!this.gameInstance.ownerIdentifier && (this.mode === 'ai' || (this.mode === 'pvp' && assignedPlayerId === GAME_CONFIG.PLAYER_ID))) {
|
||||
this.gameInstance.setOwnerIdentifier(identifier);
|
||||
}
|
||||
|
||||
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Socket: ${socket.id}) добавлен как ${assignedPlayerId} с персонажем ${this.players[socket.id].name}. Активных игроков: ${this.playerCount}. Владелец: ${this.gameInstance.ownerIdentifier}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
removePlayer(socketId, reason = "unknown_reason_for_removal") {
|
||||
const playerInfo = this.players[socketId];
|
||||
if (playerInfo) {
|
||||
const playerRole = playerInfo.id;
|
||||
const playerIdentifier = playerInfo.identifier;
|
||||
console.log(`[PCH ${this.gameId}] Окончательное удаление игрока ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Причина: ${reason}.`);
|
||||
|
||||
if (playerInfo.socket) {
|
||||
try { playerInfo.socket.leave(this.gameId); } catch (e) { console.warn(`[PCH ${this.gameId}] Ошибка при playerInfo.socket.leave: ${e.message}`); }
|
||||
}
|
||||
|
||||
if (!playerInfo.isTemporarilyDisconnected) {
|
||||
this.playerCount--;
|
||||
}
|
||||
|
||||
delete this.players[socketId];
|
||||
if (this.playerSockets[playerRole]?.id === socketId) {
|
||||
delete this.playerSockets[playerRole];
|
||||
}
|
||||
this.clearReconnectTimer(playerRole);
|
||||
|
||||
console.log(`[PCH ${this.gameId}] Игрок ${playerIdentifier} удален. Активных игроков сейчас: ${this.playerCount}.`);
|
||||
this.gameInstance.handlePlayerPermanentlyLeft(playerRole, playerInfo.chosenCharacterKey, reason);
|
||||
|
||||
} else {
|
||||
console.warn(`[PCH ${this.gameId}] removePlayer вызван для неизвестного socketId: ${socketId}`);
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) {
|
||||
console.log(`[PCH ${this.gameId}] handlePlayerPotentiallyLeft для роли ${playerIdRole}, id ${identifier}, char ${characterKey}, disconnectedSocketId ${disconnectedSocketId}`);
|
||||
const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
|
||||
|
||||
if (!playerEntry || !playerEntry.socket) {
|
||||
console.warn(`[PCH ${this.gameId}] Запись игрока или сокет не найдены для ${identifier} (роль ${playerIdRole}) во время потенциального выхода. disconnectedSocketId: ${disconnectedSocketId}`);
|
||||
// Если записи нет, возможно, игрок уже удален или это был очень старый сокет.
|
||||
// Проверим, есть ли запись по disconnectedSocketId, и если да, удалим ее.
|
||||
if (this.players[disconnectedSocketId]) {
|
||||
console.warn(`[PCH ${this.gameId}] Найдена запись по disconnectedSocketId ${disconnectedSocketId}, удаляем ее.`);
|
||||
this.removePlayer(disconnectedSocketId, 'stale_socket_disconnect_no_entry');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerEntry.socket.id !== disconnectedSocketId) {
|
||||
console.log(`[PCH ${this.gameId}] Событие отключения для УСТАРЕВШЕГО сокета ${disconnectedSocketId} для игрока ${identifier} (Роль ${playerIdRole}). Текущий активный сокет: ${playerEntry.socket.id}. Игрок, вероятно, уже переподключился или сессия обновлена. Игнорируем дальнейшую логику "потенциального выхода" для этого устаревшего сокета.`);
|
||||
if (this.players[disconnectedSocketId]) {
|
||||
delete this.players[disconnectedSocketId]; // Удаляем только эту запись, не вызываем полный removePlayer
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) {
|
||||
console.log(`[PCH ${this.gameId}] Игра уже завершена, не обрабатываем потенциальный выход для ${identifier}.`);
|
||||
return;
|
||||
}
|
||||
if (playerEntry.isTemporarilyDisconnected) {
|
||||
console.log(`[PCH ${this.gameId}] Игрок ${identifier} уже помечен как временно отключенный.`);
|
||||
return;
|
||||
}
|
||||
|
||||
playerEntry.isTemporarilyDisconnected = true;
|
||||
this.playerCount--;
|
||||
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (роль ${playerIdRole}, сокет ${disconnectedSocketId}) временно отключен. Активных: ${this.playerCount}. Запускаем таймер переподключения.`);
|
||||
|
||||
const disconnectedName = playerEntry.name || this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`;
|
||||
this.gameInstance.addToLog(`🔌 Игрок ${disconnectedName} отключился. Ожидание переподключения...`, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
this.gameInstance.broadcastLogUpdate();
|
||||
|
||||
const otherPlayerRole = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
|
||||
const otherSocket = this.playerSockets[otherPlayerRole];
|
||||
const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole);
|
||||
|
||||
if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) {
|
||||
otherSocket.emit('opponentDisconnected', {
|
||||
disconnectedPlayerId: playerIdRole,
|
||||
disconnectedCharacterName: disconnectedName,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.gameInstance.turnTimer && (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isConfiguredForAiMove))) {
|
||||
this.pausedTurnState = this.gameInstance.turnTimer.pause();
|
||||
console.log(`[PCH ${this.gameId}] Таймер хода приостановлен из-за отключения. Состояние:`, JSON.stringify(this.pausedTurnState));
|
||||
} else {
|
||||
this.pausedTurnState = null;
|
||||
}
|
||||
|
||||
this.clearReconnectTimer(playerIdRole);
|
||||
const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000;
|
||||
const reconnectStartTime = Date.now();
|
||||
|
||||
const updateInterval = setInterval(() => {
|
||||
const remaining = reconnectDuration - (Date.now() - reconnectStartTime);
|
||||
if (remaining <= 0 || !this.reconnectTimers[playerIdRole] || this.reconnectTimers[playerIdRole]?.timerId === null) { // Добавлена проверка на существование таймера
|
||||
if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
|
||||
if (this.reconnectTimers[playerIdRole]) this.reconnectTimers[playerIdRole].updateIntervalId = null; // Помечаем, что интервал очищен
|
||||
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 });
|
||||
return;
|
||||
}
|
||||
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: Math.ceil(remaining) });
|
||||
}, 1000);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.reconnectTimers[playerIdRole]?.updateIntervalId) { // Очищаем интервал, если он еще существует
|
||||
clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
|
||||
this.reconnectTimers[playerIdRole].updateIntervalId = null;
|
||||
}
|
||||
this.reconnectTimers[playerIdRole].timerId = null; // Помечаем, что основной таймаут сработал или очищен
|
||||
|
||||
const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
|
||||
if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) {
|
||||
this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout");
|
||||
}
|
||||
}, reconnectDuration);
|
||||
this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration };
|
||||
}
|
||||
|
||||
handlePlayerReconnected(playerIdRole, newSocket) {
|
||||
const identifier = newSocket.userData?.userId;
|
||||
console.log(`[PCH RECONNECT_ATTEMPT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${identifier}, NewSocket: ${newSocket.id}`);
|
||||
|
||||
if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) {
|
||||
newSocket.emit('gameError', { message: 'Игра уже завершена.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
let playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
|
||||
console.log(`[PCH RECONNECT_ATTEMPT] Found playerEntry:`, playerEntry ? {id: playerEntry.id, identifier: playerEntry.identifier, oldSocketId: playerEntry.socket?.id, isTempDisc: playerEntry.isTemporarilyDisconnected} : null);
|
||||
|
||||
if (playerEntry) {
|
||||
const oldSocket = playerEntry.socket;
|
||||
|
||||
// Обновляем сокет в playerEntry и в this.players / this.playerSockets, если сокет новый
|
||||
if (oldSocket && oldSocket.id !== newSocket.id) {
|
||||
console.log(`[PCH ${this.gameId}] New socket ${newSocket.id} for player ${identifier}. Old socket: ${oldSocket.id}. Updating records.`);
|
||||
if (this.players[oldSocket.id]) delete this.players[oldSocket.id]; // Удаляем старую запись по старому socket.id
|
||||
this.players[newSocket.id] = playerEntry; // Убеждаемся, что по новому ID есть актуальная запись
|
||||
if (oldSocket.connected) { // Пытаемся корректно закрыть старый сокет
|
||||
console.log(`[PCH ${this.gameId}] Disconnecting old stale socket ${oldSocket.id}.`);
|
||||
oldSocket.disconnect(true);
|
||||
}
|
||||
}
|
||||
playerEntry.socket = newSocket; // Обновляем сокет в существующей playerEntry
|
||||
|
||||
if (oldSocket && oldSocket.id !== newSocket.id && this.players[oldSocket.id] === playerEntry) {
|
||||
// Если вдруг playerEntry был взят по старому socket.id, и этот ID теперь должен быть удален
|
||||
delete this.players[oldSocket.id];
|
||||
}
|
||||
this.playerSockets[playerIdRole] = newSocket; // Обновляем авторитетный сокет для роли
|
||||
|
||||
// Всегда заново присоединяем сокет к комнате
|
||||
console.log(`[PCH ${this.gameId}] Forcing newSocket ${newSocket.id} (identifier: ${identifier}) to join room ${this.gameId} during reconnect.`);
|
||||
newSocket.join(this.gameId);
|
||||
|
||||
|
||||
if (playerEntry.isTemporarilyDisconnected) {
|
||||
console.log(`[PCH ${this.gameId}] Переподключение игрока ${identifier} (Роль: ${playerIdRole}), который был временно отключен.`);
|
||||
this.clearReconnectTimer(playerIdRole); // Очищаем таймер реконнекта
|
||||
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сообщаем UI, что таймер остановлен
|
||||
|
||||
playerEntry.isTemporarilyDisconnected = false;
|
||||
this.playerCount++; // Восстанавливаем счетчик активных игроков
|
||||
} else {
|
||||
// Игрок не был помечен как временно отключенный.
|
||||
// Это может быть F5 или запрос состояния на "том же" (или новом, но старый не отвалился) сокете.
|
||||
// playerCount не меняется, т.к. игрок считался активным.
|
||||
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Роль: ${playerIdRole}) переподключился/запросил состояние, не будучи помеченным как 'temporarilyDisconnected'. Old socket ID: ${oldSocket?.id}`);
|
||||
}
|
||||
|
||||
// Обновление имени
|
||||
if (this.gameInstance.gameState && this.gameInstance.gameState[playerIdRole]?.name) {
|
||||
playerEntry.name = this.gameInstance.gameState[playerIdRole].name;
|
||||
} else {
|
||||
const charData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey);
|
||||
playerEntry.name = charData?.baseStats?.name || playerEntry.chosenCharacterKey;
|
||||
}
|
||||
console.log(`[PCH ${this.gameId}] Имя игрока ${identifier} обновлено/установлено на: ${playerEntry.name}`);
|
||||
|
||||
this.gameInstance.addToLog(`🔌 Игрок ${playerEntry.name || identifier} снова в игре! (Сессия обновлена)`, GAME_CONFIG.LOG_TYPE_SYSTEM);
|
||||
this.sendFullGameStateOnReconnect(newSocket, playerEntry, playerIdRole);
|
||||
|
||||
if (playerEntry.isTemporarilyDisconnected === false && this.pausedTurnState) { // Если игрок был временно отключен, isTemporarilyDisconnected уже false
|
||||
this.resumeGameLogicAfterReconnect(playerIdRole);
|
||||
} else if (playerEntry.isTemporarilyDisconnected === false && !this.pausedTurnState) {
|
||||
// Игрок не был temp disconnected, и не было сохраненного состояния таймера (значит, он и не останавливался из-за этого игрока)
|
||||
// Просто отправляем текущее состояние таймера, если он активен
|
||||
console.log(`[PCH ${this.gameId}] Player was not temp disconnected, and no pausedTurnState. Forcing timer update if active.`);
|
||||
if (this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive() && this.gameInstance.turnTimer.onTickCallback) {
|
||||
const tt = this.gameInstance.turnTimer;
|
||||
const elapsedTime = Date.now() - tt.segmentStartTimeMs;
|
||||
const currentRemaining = Math.max(0, tt.segmentDurationMs - elapsedTime);
|
||||
tt.onTickCallback(currentRemaining, tt.isConfiguredForPlayerSlotTurn, tt.isManuallyPausedState);
|
||||
} else if (this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused() && !this.isGameEffectivelyPaused()) {
|
||||
// Если таймер не активен, не на паузе, и игра не на общей паузе - возможно, его нужно запустить (если сейчас ход этого игрока)
|
||||
const gs = this.gameInstance.gameState;
|
||||
if (gs && !gs.isGameOver) {
|
||||
const isHisTurnNow = (gs.isPlayerTurn && playerIdRole === GAME_CONFIG.PLAYER_ID) || (!gs.isPlayerTurn && playerIdRole === GAME_CONFIG.OPPONENT_ID);
|
||||
const isAiTurnNow = this.mode === 'ai' && !gs.isPlayerTurn;
|
||||
if(isHisTurnNow || isAiTurnNow) {
|
||||
console.log(`[PCH ${this.gameId}] Timer not active, not paused. Game not paused. Attempting to start timer for ${playerIdRole}. HisTurn: ${isHisTurnNow}, AITurn: ${isAiTurnNow}`);
|
||||
this.gameInstance.turnTimer.start(gs.isPlayerTurn, isAiTurnNow);
|
||||
if (isAiTurnNow && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) {
|
||||
// Доп. проверка, чтобы AI точно пошел, если это его ход и таймер не стартовал для него как "AI move"
|
||||
setTimeout(() => {
|
||||
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) {
|
||||
this.gameInstance.processAiTurn();
|
||||
}
|
||||
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
} else { // playerEntry не найден
|
||||
console.warn(`[PCH ${this.gameId}] Попытка переподключения для ${identifier} (Роль ${playerIdRole}), но запись playerEntry не найдена. Это может быть новый игрок или сессия истекла.`);
|
||||
// Если это новый игрок для этой роли, то addPlayer должен был быть вызван GameManager'ом.
|
||||
// Если PCH вызывается напрямую, и игрока нет, это ошибка или устаревший запрос.
|
||||
newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена). Попробуйте создать игру заново.' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sendFullGameStateOnReconnect(socket, playerEntry, playerIdRole) {
|
||||
console.log(`[PCH SEND_STATE_RECONNECT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${playerEntry.identifier}`);
|
||||
if (!this.gameInstance.gameState) {
|
||||
console.log(`[PCH SEND_STATE_RECONNECT] gameState отсутствует, попытка инициализации...`);
|
||||
if (!this.gameInstance.initializeGame()) { // initializeGame должен установить gameState
|
||||
this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch_helper', 'PCH Helper: GS null после повторной инициализации при переподключении.');
|
||||
return;
|
||||
}
|
||||
console.log(`[PCH SEND_STATE_RECONNECT] gameState инициализирован. Player: ${this.gameInstance.gameState.player.name}, Opponent: ${this.gameInstance.gameState.opponent.name}`);
|
||||
}
|
||||
|
||||
const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey);
|
||||
const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
|
||||
|
||||
// Получаем ключ оппонента из gameState ИЛИ из сохраненных ключей в GameInstance
|
||||
let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey ||
|
||||
(playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey);
|
||||
const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null;
|
||||
|
||||
// Обновляем имена в gameState на основе сохраненных в PCH или данных персонажей
|
||||
if (this.gameInstance.gameState) {
|
||||
if (this.gameInstance.gameState[playerIdRole]) {
|
||||
this.gameInstance.gameState[playerIdRole].name = playerEntry.name || pData?.baseStats?.name || 'Игрок';
|
||||
}
|
||||
const opponentPCHEntry = Object.values(this.players).find(p => p.id === oppRoleKey);
|
||||
if (this.gameInstance.gameState[oppRoleKey]) {
|
||||
if (opponentPCHEntry?.name) {
|
||||
this.gameInstance.gameState[oppRoleKey].name = opponentPCHEntry.name;
|
||||
} else if (oData?.baseStats?.name) {
|
||||
this.gameInstance.gameState[oppRoleKey].name = oData.baseStats.name;
|
||||
} else if (this.mode === 'ai' && oppRoleKey === GAME_CONFIG.OPPONENT_ID) {
|
||||
this.gameInstance.gameState[oppRoleKey].name = 'Балард'; // Фоллбэк для AI
|
||||
} else {
|
||||
this.gameInstance.gameState[oppRoleKey].name = 'Оппонент';
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[PCH SEND_STATE_RECONNECT] Отправка gameStarted. Player GS: ${this.gameInstance.gameState?.player?.name}, Opponent GS: ${this.gameInstance.gameState?.opponent?.name}. IsPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`);
|
||||
|
||||
socket.emit('gameStarted', { // Используем 'gameStarted' для полной синхронизации состояния
|
||||
gameId: this.gameId,
|
||||
yourPlayerId: playerIdRole,
|
||||
initialGameState: this.gameInstance.gameState,
|
||||
playerBaseStats: pData?.baseStats,
|
||||
opponentBaseStats: oData?.baseStats || {name: (this.mode === 'pvp' ? 'Ожидание...' : 'Противник AI'), maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null},
|
||||
playerAbilities: pData?.abilities,
|
||||
opponentAbilities: oData?.abilities || [],
|
||||
log: this.gameInstance.consumeLogBuffer(),
|
||||
clientConfig: { ...GAME_CONFIG }
|
||||
});
|
||||
}
|
||||
|
||||
resumeGameLogicAfterReconnect(reconnectedPlayerIdRole) {
|
||||
const playerEntry = Object.values(this.players).find(p => p.id === reconnectedPlayerIdRole);
|
||||
const reconnectedName = playerEntry?.name || this.gameInstance.gameState?.[reconnectedPlayerIdRole]?.name || `Игрок (Роль ${reconnectedPlayerIdRole})`;
|
||||
console.log(`[PCH RESUME_LOGIC] gameId: ${this.gameId}, Role: ${reconnectedPlayerIdRole}, Name: ${reconnectedName}, PausedState: ${JSON.stringify(this.pausedTurnState)}, TimerActive: ${this.gameInstance.turnTimer?.isActive()}, GS.isPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`);
|
||||
|
||||
const otherPlayerRole = reconnectedPlayerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
|
||||
const otherSocket = this.playerSockets[otherPlayerRole];
|
||||
const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole);
|
||||
if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) {
|
||||
otherSocket.emit('playerReconnected', {
|
||||
reconnectedPlayerId: reconnectedPlayerIdRole,
|
||||
reconnectedPlayerName: reconnectedName
|
||||
});
|
||||
if (this.gameInstance.logBuffer.length > 0) { // Отправляем накопившиеся логи другому игроку
|
||||
otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() });
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем состояние для всех (включая переподключившегося, т.к. его лог мог быть уже потреблен)
|
||||
this.gameInstance.broadcastGameStateUpdate(); // Это отправит gameState и оставшиеся логи
|
||||
|
||||
|
||||
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) {
|
||||
// this.gameInstance.broadcastGameStateUpdate(); // Перенесено выше
|
||||
|
||||
if (Object.keys(this.reconnectTimers).length === 0) { // Только если нет других ожидающих реконнекта
|
||||
const currentTurnIsForPlayerInGS = this.gameInstance.gameState.isPlayerTurn;
|
||||
const isCurrentTurnAiForTimer = this.mode === 'ai' && !currentTurnIsForPlayerInGS;
|
||||
let resumedFromPausedState = false;
|
||||
|
||||
if (this.pausedTurnState && typeof this.pausedTurnState.remainingTime === 'number') {
|
||||
const gsTurnMatchesPausedTurn = (currentTurnIsForPlayerInGS && this.pausedTurnState.forPlayerRoleIsPlayer) ||
|
||||
(!currentTurnIsForPlayerInGS && !this.pausedTurnState.forPlayerRoleIsPlayer);
|
||||
|
||||
if (gsTurnMatchesPausedTurn) {
|
||||
console.log(`[PCH ${this.gameId}] Возобновляем таймер хода из pausedTurnState. Время: ${this.pausedTurnState.remainingTime}мс. Для игрока (в pausedState): ${this.pausedTurnState.forPlayerRoleIsPlayer}. GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход (в pausedState): ${this.pausedTurnState.isAiCurrentlyMoving}`);
|
||||
this.gameInstance.turnTimer.resume(
|
||||
this.pausedTurnState.remainingTime,
|
||||
this.pausedTurnState.forPlayerRoleIsPlayer, // Это isConfiguredForPlayerSlotTurn для таймера
|
||||
this.pausedTurnState.isAiCurrentlyMoving // Это isConfiguredForAiMove для таймера
|
||||
);
|
||||
resumedFromPausedState = true;
|
||||
} else {
|
||||
console.warn(`[PCH ${this.gameId}] pausedTurnState (${JSON.stringify(this.pausedTurnState)}) не совпадает с текущим ходом в gameState (isPlayerTurn: ${currentTurnIsForPlayerInGS}). Сбрасываем pausedTurnState и запускаем таймер заново, если нужно.`);
|
||||
}
|
||||
this.pausedTurnState = null; // Сбрасываем в любом случае
|
||||
}
|
||||
|
||||
if (!resumedFromPausedState && this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused()) {
|
||||
console.log(`[PCH ${this.gameId}] Запускаем таймер хода заново после реконнекта (pausedState не использовался или был неактуален, таймер неактивен и не на паузе). GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход для таймера: ${isCurrentTurnAiForTimer}`);
|
||||
this.gameInstance.turnTimer.start(currentTurnIsForPlayerInGS, isCurrentTurnAiForTimer);
|
||||
if (isCurrentTurnAiForTimer && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) {
|
||||
setTimeout(() => {
|
||||
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) {
|
||||
this.gameInstance.processAiTurn();
|
||||
}
|
||||
}, GAME_CONFIG.DELAY_OPPONENT_TURN);
|
||||
}
|
||||
} else if (!resumedFromPausedState && this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive()){
|
||||
console.log(`[PCH ${this.gameId}] Таймер уже был активен при попытке перезапуска после реконнекта (pausedTurnState не использовался/неактуален). Ничего не делаем с таймером.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[PCH ${this.gameId}] Возобновление логики таймера отложено, есть другие активные таймеры реконнекта: ${Object.keys(this.reconnectTimers)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[PCH ${this.gameId}] Игра на паузе или завершена, логика таймера не возобновляется. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameInstance.gameState?.isGameOver}`);
|
||||
}
|
||||
}
|
||||
|
||||
clearReconnectTimer(playerIdRole) {
|
||||
if (this.reconnectTimers[playerIdRole]) {
|
||||
clearTimeout(this.reconnectTimers[playerIdRole].timerId);
|
||||
this.reconnectTimers[playerIdRole].timerId = null; // Явно обнуляем
|
||||
if (this.reconnectTimers[playerIdRole].updateIntervalId) {
|
||||
clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
|
||||
this.reconnectTimers[playerIdRole].updateIntervalId = null; // Явно обнуляем
|
||||
}
|
||||
delete this.reconnectTimers[playerIdRole]; // Удаляем всю запись
|
||||
console.log(`[PCH ${this.gameId}] Очищен таймер переподключения для роли ${playerIdRole}.`);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllReconnectTimers() {
|
||||
console.log(`[PCH ${this.gameId}] Очистка ВСЕХ таймеров переподключения.`);
|
||||
for (const roleId in this.reconnectTimers) {
|
||||
this.clearReconnectTimer(roleId);
|
||||
}
|
||||
}
|
||||
|
||||
isGameEffectivelyPaused() {
|
||||
if (this.mode === 'pvp') {
|
||||
if (this.playerCount < 2 && Object.keys(this.players).length > 0) {
|
||||
const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
|
||||
const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID);
|
||||
|
||||
if ((p1Entry && p1Entry.isTemporarilyDisconnected) || (p2Entry && p2Entry.isTemporarilyDisconnected)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (this.mode === 'ai') {
|
||||
const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
|
||||
return humanPlayer?.isTemporarilyDisconnected ?? false; // Если игрока нет, не на паузе. Если есть - зависит от его состояния.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getAllPlayersInfo() {
|
||||
return { ...this.players };
|
||||
}
|
||||
|
||||
getPlayerSockets() {
|
||||
return { ...this.playerSockets };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlayerConnectionHandler;
|
237
server/game/instance/TurnTimer.js
Normal file
237
server/game/instance/TurnTimer.js
Normal file
@ -0,0 +1,237 @@
|
||||
// /server/game/instance/TurnTimer.js
|
||||
|
||||
class TurnTimer {
|
||||
constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') {
|
||||
this.initialTurnDurationMs = turnDurationMs;
|
||||
this.updateIntervalMs = updateIntervalMs;
|
||||
this.onTimeoutCallback = onTimeoutCallback;
|
||||
this.onTickCallback = onTickCallback; // (remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic)
|
||||
this.gameId = gameIdForLogs;
|
||||
|
||||
this.timeoutId = null;
|
||||
this.tickIntervalId = null;
|
||||
|
||||
this.segmentStartTimeMs = 0; // Время начала текущего активного сегмента (после start/resume)
|
||||
this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий сегмент
|
||||
|
||||
this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе, не ход AI)
|
||||
this.isManuallyPausedState = false; // Была ли вызвана pause()
|
||||
|
||||
// Состояние, для которого таймер был запущен (или должен быть запущен)
|
||||
this.isConfiguredForPlayerSlotTurn = false;
|
||||
this.isConfiguredForAiMove = false;
|
||||
|
||||
console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`);
|
||||
}
|
||||
|
||||
_clearInternalTimers() {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
if (this.tickIntervalId) {
|
||||
clearInterval(this.tickIntervalId);
|
||||
this.tickIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает или перезапускает таймер хода.
|
||||
* @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player'.
|
||||
* @param {boolean} isAiMakingMove - true, если текущий ход делает AI.
|
||||
* @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени.
|
||||
*/
|
||||
start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) {
|
||||
console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, ManualPause: ${this.isManuallyPausedState}`);
|
||||
this._clearInternalTimers(); // Всегда очищаем старые таймеры перед новым запуском
|
||||
|
||||
this.isConfiguredForPlayerSlotTurn = isPlayerSlotTurn;
|
||||
this.isConfiguredForAiMove = isAiMakingMove;
|
||||
|
||||
// Если это не resume (т.е. customRemainingTimeMs не передан явно как результат pause),
|
||||
// то сбрасываем флаг ручной паузы.
|
||||
if (customRemainingTimeMs === null) {
|
||||
this.isManuallyPausedState = false;
|
||||
}
|
||||
|
||||
if (this.isConfiguredForAiMove) {
|
||||
this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для игрока
|
||||
console.log(`[TurnTimer ${this.gameId}] START: AI's turn. Player timer not actively ticking.`);
|
||||
if (this.onTickCallback) {
|
||||
// Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе
|
||||
this.onTickCallback(this.initialTurnDurationMs, this.isConfiguredForPlayerSlotTurn, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Если это не ход AI, то таймер должен работать для игрока (или оппонента-человека)
|
||||
this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0)
|
||||
? customRemainingTimeMs
|
||||
: this.initialTurnDurationMs;
|
||||
|
||||
this.segmentStartTimeMs = Date.now();
|
||||
this.isCurrentlyRunning = true; // Таймер теперь активен
|
||||
// this.isManuallyPausedState остается как есть, если это был resume, или false, если это новый start
|
||||
|
||||
console.log(`[TurnTimer ${this.gameId}] STARTED. Effective Duration: ${this.segmentDurationMs}ms. ForPlayer: ${this.isConfiguredForPlayerSlotTurn}. IsRunning: ${this.isCurrentlyRunning}. ManualPause: ${this.isManuallyPausedState}`);
|
||||
|
||||
this.timeoutId = setTimeout(() => {
|
||||
console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT occurred. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`);
|
||||
// Проверяем, что таймер все еще должен был работать и не был на паузе
|
||||
if (this.isCurrentlyRunning && !this.isManuallyPausedState) {
|
||||
this._clearInternalTimers(); // Очищаем все, включая интервал
|
||||
this.isCurrentlyRunning = false;
|
||||
if (this.onTimeoutCallback) {
|
||||
this.onTimeoutCallback();
|
||||
}
|
||||
} else {
|
||||
console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT ignored (not running or manually paused).`);
|
||||
}
|
||||
}, this.segmentDurationMs);
|
||||
|
||||
this.tickIntervalId = setInterval(() => {
|
||||
// Таймер должен обновлять UI только если он isCurrentlyRunning и НЕ isManuallyPausedState
|
||||
// isManuallyPausedState проверяется в onTickCallback, который должен передать "isPaused" клиенту
|
||||
if (!this.isCurrentlyRunning) { // Если таймер был остановлен (clear/timeout)
|
||||
this._clearInternalTimers(); // Убедимся, что этот интервал тоже остановлен
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedTime = Date.now() - this.segmentStartTimeMs;
|
||||
const remainingTime = Math.max(0, this.segmentDurationMs - elapsedTime);
|
||||
|
||||
if (this.onTickCallback) {
|
||||
// Передаем isManuallyPausedState как состояние "паузы" для клиента
|
||||
this.onTickCallback(remainingTime, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
|
||||
}
|
||||
|
||||
// Не очищаем интервал здесь при remainingTime <= 0, пусть setTimeout это сделает.
|
||||
// Отправка 0 - это нормально.
|
||||
}, this.updateIntervalMs);
|
||||
|
||||
// Немедленная первая отправка состояния таймера
|
||||
if (this.onTickCallback) {
|
||||
console.log(`[TurnTimer ${this.gameId}] Initial tick after START. Remaining: ${this.segmentDurationMs}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}, ManualPause: ${this.isManuallyPausedState}`);
|
||||
this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, ManualPause: ${this.isManuallyPausedState}`);
|
||||
|
||||
if (this.isManuallyPausedState) { // Уже на ручной паузе
|
||||
console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Returning previous pause state.`);
|
||||
// Нужно вернуть актуальное оставшееся время, которое было на момент установки паузы.
|
||||
// segmentDurationMs при паузе сохраняет это значение.
|
||||
if (this.onTickCallback) { // Уведомляем клиента еще раз, что на паузе
|
||||
this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, true);
|
||||
}
|
||||
return {
|
||||
remainingTime: this.segmentDurationMs, // Это время, которое осталось на момент паузы
|
||||
forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn,
|
||||
isAiCurrentlyMoving: this.isConfiguredForAiMove // Важно сохранить, чей ход это был
|
||||
};
|
||||
}
|
||||
|
||||
let remainingTimeToSave;
|
||||
|
||||
if (this.isConfiguredForAiMove) {
|
||||
// Если ход AI, таймер для игрока не тикал, у него полное время
|
||||
remainingTimeToSave = this.initialTurnDurationMs;
|
||||
console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining: ${remainingTimeToSave}ms for player turn.`);
|
||||
} else if (this.isCurrentlyRunning) {
|
||||
// Таймер активно работал для игрока/оппонента-человека
|
||||
const elapsedTime = Date.now() - this.segmentStartTimeMs;
|
||||
remainingTimeToSave = Math.max(0, this.segmentDurationMs - elapsedTime);
|
||||
console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSave}ms from segment duration ${this.segmentDurationMs}ms.`);
|
||||
} else {
|
||||
// Таймер не был активен (например, уже истек, был очищен, или это был start() для AI)
|
||||
// В этом случае, если не ход AI, то время 0
|
||||
remainingTimeToSave = 0;
|
||||
console.log(`[TurnTimer ${this.gameId}] PAUSE called, but timer not actively running (and not AI move). Remaining set to 0.`);
|
||||
}
|
||||
|
||||
this._clearInternalTimers();
|
||||
this.isCurrentlyRunning = false;
|
||||
this.isManuallyPausedState = true;
|
||||
this.segmentDurationMs = remainingTimeToSave; // Сохраняем оставшееся время для resume
|
||||
|
||||
if (this.onTickCallback) {
|
||||
console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE. Remaining: ${remainingTimeToSave}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`);
|
||||
this.onTickCallback(remainingTimeToSave, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true
|
||||
}
|
||||
|
||||
return {
|
||||
remainingTime: remainingTimeToSave,
|
||||
forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, // Чей ход это был
|
||||
isAiCurrentlyMoving: this.isConfiguredForAiMove // Был ли это ход AI
|
||||
};
|
||||
}
|
||||
|
||||
resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) {
|
||||
console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. SavedRemaining: ${remainingTimeMs}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, ManualPauseBefore: ${this.isManuallyPausedState}`);
|
||||
|
||||
if (!this.isManuallyPausedState) {
|
||||
console.warn(`[TurnTimer ${this.gameId}] RESUME called, but timer was not manually paused. Current state - IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}. Ignoring resume, let PCH handle start if needed.`);
|
||||
// Если не был на ручной паузе, возможно, игра уже продолжается или была очищена.
|
||||
// Не вызываем start() отсюда, чтобы избежать неожиданного поведения.
|
||||
// PCH должен решить, нужен ли новый start().
|
||||
// Однако, если текущий ход совпадает, и таймер просто неактивен, можно запустить.
|
||||
// Но лучше, чтобы PCH всегда вызывал start() с нуля, если resume не применим.
|
||||
// Просто отправим текущее состояние, если onTickCallback есть.
|
||||
if (this.onTickCallback) {
|
||||
const currentElapsedTime = this.isCurrentlyRunning ? (Date.now() - this.segmentStartTimeMs) : 0;
|
||||
const currentRemaining = this.isCurrentlyRunning ? Math.max(0, this.segmentDurationMs - currentElapsedTime) : this.segmentDurationMs;
|
||||
this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingTimeMs <= 0 && !isAiMakingMove) { // Если не ход AI и время вышло
|
||||
console.log(`[TurnTimer ${this.gameId}] RESUME called with 0 or less time (and not AI move). Triggering timeout.`);
|
||||
this.isManuallyPausedState = false; // Сбрасываем флаг
|
||||
this._clearInternalTimers(); // Убедимся, что все остановлено
|
||||
this.isCurrentlyRunning = false;
|
||||
if (this.onTimeoutCallback) {
|
||||
this.onTimeoutCallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Сбрасываем флаг ручной паузы и запускаем таймер с сохраненным состоянием
|
||||
this.isManuallyPausedState = false;
|
||||
this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs); // `start` теперь правильно обработает customRemainingTimeMs
|
||||
}
|
||||
|
||||
clear() {
|
||||
console.log(`[TurnTimer ${this.gameId}] CLEAR called. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`);
|
||||
this._clearInternalTimers();
|
||||
this.isCurrentlyRunning = false;
|
||||
// При полном clear сбрасываем и ручную паузу, т.к. таймер полностью останавливается.
|
||||
// `pause` использует этот метод, но затем сам выставляет isManuallyPausedState = true.
|
||||
this.isManuallyPausedState = false;
|
||||
this.segmentDurationMs = 0; // Сбрасываем сохраненную длительность
|
||||
this.segmentStartTimeMs = 0;
|
||||
|
||||
// Опционально: уведомить клиента, что таймер остановлен (например, null или 0)
|
||||
// if (this.onTickCallback) {
|
||||
// this.onTickCallback(null, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true (т.к. он остановлен)
|
||||
// }
|
||||
}
|
||||
|
||||
isActive() {
|
||||
// Таймер активен, если он isCurrentlyRunning и не на ручной паузе
|
||||
return this.isCurrentlyRunning && !this.isManuallyPausedState;
|
||||
}
|
||||
|
||||
isPaused() { // Возвращает, находится ли таймер в состоянии ручной паузы
|
||||
return this.isManuallyPausedState;
|
||||
}
|
||||
|
||||
// Этот геттер больше не нужен в таком виде, т.к. isConfiguredForAiMove хранит это состояние
|
||||
// get isAiCurrentlyMakingMove() {
|
||||
// return this.isConfiguredForAiMove && !this.isCurrentlyRunning;
|
||||
// }
|
||||
}
|
||||
|
||||
module.exports = TurnTimer;
|
133
server/game/logic/aiLogic.js
Normal file
133
server/game/logic/aiLogic.js
Normal file
@ -0,0 +1,133 @@
|
||||
// /server/game/logic/aiLogic.js
|
||||
|
||||
// GAME_CONFIG и gameData (или dataUtils) будут передаваться в decideAiAction как параметры,
|
||||
// но для удобства можно импортировать GAME_CONFIG здесь, если он нужен для внутренних констант AI,
|
||||
// не зависящих от передаваемого конфига.
|
||||
// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужно для чего-то внутреннего
|
||||
|
||||
/**
|
||||
* Логика принятия решения для AI (Балард).
|
||||
* @param {object} currentGameState - Текущее состояние игры.
|
||||
* @param {object} dataUtils - Утилиты для доступа к данным игры (getCharacterData, getCharacterAbilities и т.д.).
|
||||
* @param {object} configToUse - Конфигурационный объект игры (переданный GAME_CONFIG).
|
||||
* @param {function} addToLogCallback - Функция для добавления лога (опционально, если AI должен логировать свои "мысли").
|
||||
* @returns {object} Объект с действием AI ({ actionType: 'attack' | 'ability' | 'pass', ability?: object, logMessage?: {message, type} }).
|
||||
*/
|
||||
function decideAiAction(currentGameState, dataUtils, configToUse, addToLogCallback) {
|
||||
const opponentState = currentGameState.opponent; // AI Балард всегда в слоте opponent
|
||||
const playerState = currentGameState.player; // Игрок всегда в слоте player (в AI режиме)
|
||||
|
||||
// Убеждаемся, что это AI Балард и есть необходимые данные
|
||||
if (opponentState.characterKey !== 'balard' || !dataUtils) {
|
||||
console.warn("[AI Logic] decideAiAction called for non-Balard opponent or missing dataUtils. Passing turn.");
|
||||
if (addToLogCallback) addToLogCallback(`${opponentState.name || 'AI'} пропускает ход из-за внутренней ошибки.`, configToUse.LOG_TYPE_SYSTEM);
|
||||
return { actionType: 'pass', logMessage: { message: `${opponentState.name || 'AI'} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
|
||||
}
|
||||
|
||||
const balardCharacterData = dataUtils.getCharacterData('balard');
|
||||
if (!balardCharacterData || !balardCharacterData.abilities) {
|
||||
console.warn("[AI Logic] Failed to get Balard's character data or abilities. Passing turn.");
|
||||
if (addToLogCallback) addToLogCallback(`AI Балард пропускает ход из-за ошибки загрузки данных.`, configToUse.LOG_TYPE_SYSTEM);
|
||||
return { actionType: 'pass', logMessage: { message: `Балард пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
|
||||
}
|
||||
const balardAbilities = balardCharacterData.abilities;
|
||||
|
||||
// Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены и т.п.)
|
||||
const isBalardFullySilenced = opponentState.activeEffects.some(
|
||||
eff => eff.isFullSilence && eff.turnsLeft > 0
|
||||
);
|
||||
|
||||
if (isBalardFullySilenced) {
|
||||
// AI под полным безмолвием просто атакует.
|
||||
// Лог о безмолвии добавляется в GameInstance перед вызовом этой функции или при обработке атаки.
|
||||
// Здесь можно добавить лог о "вынужденной" атаке, если нужно.
|
||||
if (addToLogCallback) {
|
||||
// Проверяем, не был ли лог о безмолвии уже добавлен в этом ходу (чтобы не спамить)
|
||||
// Это упрощенная проверка, в реальном приложении можно использовать флаги или более сложную логику.
|
||||
// if (!currentGameState.logContainsThisTurn || !currentGameState.logContainsThisTurn.includes('под действием Безмолвия')) {
|
||||
// addToLogCallback(`😵 ${opponentState.name} под действием Безмолвия! Атакует в смятении.`, configToUse.LOG_TYPE_EFFECT);
|
||||
// if(currentGameState) currentGameState.logContainsThisTurn = (currentGameState.logContainsThisTurn || "") + 'под действием Безмолвия';
|
||||
// }
|
||||
}
|
||||
return { actionType: 'attack' };
|
||||
}
|
||||
|
||||
const availableActions = [];
|
||||
|
||||
// 1. Проверяем способность "Покровительство Тьмы" (Лечение)
|
||||
const healAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_HEAL);
|
||||
if (healAbility && opponentState.currentResource >= healAbility.cost &&
|
||||
(opponentState.abilityCooldowns?.[healAbility.id] || 0) <= 0 && // Общий КД
|
||||
healAbility.condition(opponentState, playerState, currentGameState, configToUse)) {
|
||||
availableActions.push({ weight: 80, type: 'ability', ability: healAbility, requiresSuccessCheck: true, successRate: healAbility.successRate });
|
||||
}
|
||||
|
||||
// 2. Проверяем способность "Эхо Безмолвия"
|
||||
const silenceAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_SILENCE);
|
||||
if (silenceAbility && opponentState.currentResource >= silenceAbility.cost &&
|
||||
(opponentState.silenceCooldownTurns === undefined || opponentState.silenceCooldownTurns <= 0) && // Спец. КД
|
||||
(opponentState.abilityCooldowns?.[silenceAbility.id] || 0) <= 0 && // Общий КД
|
||||
silenceAbility.condition(opponentState, playerState, currentGameState, configToUse)) {
|
||||
// Условие в silenceAbility.condition уже проверяет, что Елена не под безмолвием
|
||||
availableActions.push({ weight: 60, type: 'ability', ability: silenceAbility, requiresSuccessCheck: true, successRate: configToUse.SILENCE_SUCCESS_RATE });
|
||||
}
|
||||
|
||||
// 3. Проверяем способность "Похищение Света" (Вытягивание маны и урон)
|
||||
const drainAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN);
|
||||
if (drainAbility && opponentState.currentResource >= drainAbility.cost &&
|
||||
(opponentState.manaDrainCooldownTurns === undefined || opponentState.manaDrainCooldownTurns <= 0) && // Спец. КД
|
||||
(opponentState.abilityCooldowns?.[drainAbility.id] || 0) <= 0 && // Общий КД
|
||||
drainAbility.condition(opponentState, playerState, currentGameState, configToUse)) {
|
||||
availableActions.push({ weight: 50, type: 'ability', ability: drainAbility });
|
||||
}
|
||||
|
||||
// 4. Базовая атака - всегда доступна как запасной вариант с низким весом
|
||||
availableActions.push({ weight: 30, type: 'attack' });
|
||||
|
||||
|
||||
if (availableActions.length === 0) {
|
||||
// Этого не должно происходить, так как атака всегда добавляется
|
||||
if (addToLogCallback) addToLogCallback(`${opponentState.name} не может совершить действие (нет доступных).`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: 'pass', logMessage: { message: `${opponentState.name} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
|
||||
}
|
||||
|
||||
// Сортируем действия по весу в порядке убывания (самые приоритетные в начале)
|
||||
availableActions.sort((a, b) => b.weight - a.weight);
|
||||
|
||||
// console.log(`[AI Logic] Available actions for Balard, sorted by weight:`, JSON.stringify(availableActions.map(a => ({type: a.type, name: a.ability?.name, weight: a.weight})), null, 2));
|
||||
|
||||
|
||||
// Перебираем действия в порядке приоритета и выбираем первое подходящее
|
||||
for (const action of availableActions) {
|
||||
if (action.type === 'ability') {
|
||||
if (action.requiresSuccessCheck) {
|
||||
// Для способностей с шансом успеха, "бросаем кубик"
|
||||
if (Math.random() < action.successRate) {
|
||||
if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} решает использовать "${action.ability.name}" (попытка успешна)...`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: action.type, ability: action.ability };
|
||||
} else {
|
||||
// Провал шанса, добавляем лог и ИИ переходит к следующему действию в списке (если есть)
|
||||
if (addToLogCallback) addToLogCallback(`💨 ${opponentState.name} пытался использовать "${action.ability.name}", но шанс не сработал!`, configToUse.LOG_TYPE_INFO);
|
||||
continue; // Пробуем следующее приоритетное действие
|
||||
}
|
||||
} else {
|
||||
// Способность без проверки шанса (например, Похищение Света)
|
||||
if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} решает использовать "${action.ability.name}"...`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: action.type, ability: action.ability };
|
||||
}
|
||||
} else if (action.type === 'attack') {
|
||||
// Атака - если дошли до нее, значит, более приоритетные способности не были выбраны или провалили шанс
|
||||
if (addToLogCallback) addToLogCallback(`🦶 ${opponentState.name} решает атаковать...`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: 'attack' };
|
||||
}
|
||||
}
|
||||
|
||||
// Фоллбэк, если по какой-то причине ни одно действие не было выбрано (не должно происходить, если атака всегда есть)
|
||||
console.warn("[AI Logic] AI Balard failed to select any action after iterating. Defaulting to pass.");
|
||||
if (addToLogCallback) addToLogCallback(`${opponentState.name} не смог выбрать подходящее действие. Пропускает ход.`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: 'pass', logMessage: { message: `${opponentState.name} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decideAiAction
|
||||
};
|
472
server/game/logic/combatLogic.js
Normal file
472
server/game/logic/combatLogic.js
Normal file
@ -0,0 +1,472 @@
|
||||
// /server/game/logic/combatLogic.js
|
||||
|
||||
// GAME_CONFIG и dataUtils будут передаваться в функции как параметры.
|
||||
// effectsLogic может потребоваться для импорта, если updateBlockingStatus используется здесь напрямую,
|
||||
// но в вашем GameInstance.js он вызывается отдельно.
|
||||
// const effectsLogic = require('./effectsLogic'); // Если нужно
|
||||
|
||||
/**
|
||||
* Обрабатывает базовую атаку одного бойца по другому.
|
||||
* @param {object} attackerState - Состояние атакующего бойца из gameState.
|
||||
* @param {object} defenderState - Состояние защищающегося бойца из gameState.
|
||||
* @param {object} attackerBaseStats - Базовые статы атакующего (из dataUtils.getCharacterBaseStats).
|
||||
* @param {object} defenderBaseStats - Базовые статы защищающегося (из dataUtils.getCharacterBaseStats).
|
||||
* @param {object} currentGameState - Текущее полное состояние игры.
|
||||
* @param {function} addToLogCallback - Функция для добавления сообщений в лог игры.
|
||||
* @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG).
|
||||
* @param {object} dataUtils - Утилиты для доступа к данным игры.
|
||||
* @param {function} getRandomTauntFunction - Функция gameLogic.getRandomTaunt, переданная для использования.
|
||||
*/
|
||||
function performAttack(
|
||||
attackerState,
|
||||
defenderState,
|
||||
attackerBaseStats,
|
||||
defenderBaseStats,
|
||||
currentGameState,
|
||||
addToLogCallback,
|
||||
configToUse,
|
||||
dataUtils,
|
||||
getRandomTauntFunction
|
||||
) {
|
||||
// Расчет базового урона с вариацией
|
||||
let damage = Math.floor(
|
||||
attackerBaseStats.attackPower *
|
||||
(configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE)
|
||||
);
|
||||
let wasBlocked = false;
|
||||
let attackBonusesLog = []; // Для сбора информации о бонусах к атаке
|
||||
|
||||
// --- ПРОВЕРКА И ПРИМЕНЕНИЕ БОНУСА ОТ ОТЛОЖЕННОГО БАФФА АТАКИ ---
|
||||
const delayedAttackBuff = attackerState.activeEffects.find(eff =>
|
||||
eff.isDelayed &&
|
||||
(eff.id === configToUse.ABILITY_ID_NATURE_STRENGTH || eff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) &&
|
||||
eff.turnsLeft > 0 &&
|
||||
!eff.justCast
|
||||
);
|
||||
|
||||
if (delayedAttackBuff) {
|
||||
console.log(`[CombatLogic performAttack] Found active delayed buff: ${delayedAttackBuff.name} for ${attackerState.name}`);
|
||||
|
||||
let damageBonus = 0;
|
||||
// Если бы были прямые бонусы к урону атаки от этих баффов, они бы рассчитывались здесь
|
||||
// Например:
|
||||
// if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH && configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS) {
|
||||
// damageBonus = configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS;
|
||||
// } else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK && configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS) {
|
||||
// damageBonus = configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS;
|
||||
// }
|
||||
|
||||
if (damageBonus > 0) {
|
||||
damage += damageBonus;
|
||||
attackBonusesLog.push(`урон +${damageBonus} от "${delayedAttackBuff.name}"`);
|
||||
}
|
||||
|
||||
let resourceRegenConfigKey = null;
|
||||
if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH) {
|
||||
resourceRegenConfigKey = 'NATURE_STRENGTH_MANA_REGEN';
|
||||
} else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) {
|
||||
resourceRegenConfigKey = 'ALMAGEST_DARK_ENERGY_REGEN'; // Предположительный ключ
|
||||
}
|
||||
|
||||
if (resourceRegenConfigKey && configToUse[resourceRegenConfigKey]) {
|
||||
const regenAmount = configToUse[resourceRegenConfigKey];
|
||||
const actualRegen = Math.min(regenAmount, attackerBaseStats.maxResource - attackerState.currentResource);
|
||||
if (actualRegen > 0) {
|
||||
attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen);
|
||||
if (addToLogCallback) {
|
||||
addToLogCallback(
|
||||
`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от "${delayedAttackBuff.name}"!`,
|
||||
configToUse.LOG_TYPE_HEAL
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- КОНЕЦ ПРОВЕРКИ И ПРИМЕНЕНИЯ ОТЛОЖЕННОГО БАФФА АТАКИ ---
|
||||
|
||||
// Проверка на блок
|
||||
if (defenderState.isBlocking) {
|
||||
const initialDamage = damage;
|
||||
damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION);
|
||||
wasBlocked = true;
|
||||
if (addToLogCallback) {
|
||||
let blockLogMsg = `🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`;
|
||||
if (attackBonusesLog.length > 0) {
|
||||
blockLogMsg += ` (${attackBonusesLog.join(', ')})`;
|
||||
}
|
||||
addToLogCallback(blockLogMsg, configToUse.LOG_TYPE_BLOCK);
|
||||
}
|
||||
} else {
|
||||
if (addToLogCallback) {
|
||||
let hitLogMsg = `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`;
|
||||
if (attackBonusesLog.length > 0) {
|
||||
hitLogMsg += ` (${attackBonusesLog.join(', ')})`;
|
||||
}
|
||||
addToLogCallback(hitLogMsg, configToUse.LOG_TYPE_DAMAGE);
|
||||
}
|
||||
}
|
||||
|
||||
// Применяем урон, убеждаемся, что HP не ниже нуля
|
||||
const actualDamageDealtToHp = Math.min(defenderState.currentHp, damage); // Сколько HP реально отнято (не может быть больше текущего HP)
|
||||
defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage));
|
||||
|
||||
// --- Насмешка от защищающегося (defenderState) в ответ на атаку ---
|
||||
if (getRandomTauntFunction && dataUtils) {
|
||||
let subTriggerForTaunt = null;
|
||||
if (wasBlocked) {
|
||||
subTriggerForTaunt = 'attackBlocked';
|
||||
} else if (actualDamageDealtToHp > 0) { // Если не было блока, но был нанесен урон
|
||||
subTriggerForTaunt = 'attackHits';
|
||||
}
|
||||
// Можно добавить еще условие для промаха, если урон = 0 и не было блока (и actualDamageDealtToHp === 0)
|
||||
// else if (damage <= 0 && !wasBlocked) { subTriggerForTaunt = 'attackMissed'; } // Если есть такой триггер
|
||||
|
||||
if (subTriggerForTaunt) {
|
||||
const attackerFullDataForTaunt = dataUtils.getCharacterData(attackerState.characterKey);
|
||||
if (attackerFullDataForTaunt) {
|
||||
const reactionTaunt = getRandomTauntFunction(
|
||||
defenderState.characterKey, // Говорящий (защитник)
|
||||
'onOpponentAction', // Главный триггер
|
||||
subTriggerForTaunt, // Подтриггер: 'attackBlocked' или 'attackHits'
|
||||
configToUse,
|
||||
attackerFullDataForTaunt, // Оппонент (атакующий) для говорящего
|
||||
currentGameState
|
||||
);
|
||||
if (reactionTaunt && reactionTaunt !== "(Молчание)") {
|
||||
addToLogCallback(`${defenderState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* применяет111 эффект способности.
|
||||
* @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} dataUtils - Утилиты для доступа к данным игры.
|
||||
* @param {function} getRandomTauntFunction - Функция gameLogic.getRandomTaunt.
|
||||
* @param {function|null} checkIfActionWasSuccessfulFunction - (Опционально) Функция для проверки успеха действия для контекстных насмешек.
|
||||
*/
|
||||
function applyAbilityEffect(
|
||||
ability,
|
||||
casterState,
|
||||
targetState,
|
||||
casterBaseStats,
|
||||
targetBaseStats,
|
||||
currentGameState,
|
||||
addToLogCallback,
|
||||
configToUse,
|
||||
dataUtils,
|
||||
getRandomTauntFunction,
|
||||
checkIfActionWasSuccessfulFunction // Пока не используется активно, outcome определяется внутри
|
||||
) {
|
||||
let abilityApplicationSucceeded = true; // Флаг общего успеха применения способности
|
||||
let actionOutcomeForTaunt = null; // 'success' или 'fail' для специфичных насмешек (например, Безмолвие Баларда)
|
||||
|
||||
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} применяет111 "${ability.name}" и восстанавливает ${actualHeal} HP!`, configToUse.LOG_TYPE_HEAL);
|
||||
actionOutcomeForTaunt = 'success'; // Для реакции оппонента, если таковая есть на хил
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет111 "${ability.name}", но не получает лечения (HP уже полное или хил = 0).`, configToUse.LOG_TYPE_INFO);
|
||||
abilityApplicationSucceeded = false;
|
||||
actionOutcomeForTaunt = 'fail';
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_DAMAGE:
|
||||
let damage = Math.floor(ability.power * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE));
|
||||
let wasAbilityBlocked = false;
|
||||
let actualDamageDealtByAbility = 0;
|
||||
|
||||
if (targetState.isBlocking) {
|
||||
const initialDamage = damage;
|
||||
damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION);
|
||||
wasAbilityBlocked = true;
|
||||
if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`, configToUse.LOG_TYPE_BLOCK);
|
||||
}
|
||||
|
||||
actualDamageDealtByAbility = Math.min(targetState.currentHp, damage);
|
||||
targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage));
|
||||
|
||||
if (addToLogCallback && !wasAbilityBlocked) {
|
||||
addToLogCallback(`💥 ${casterBaseStats.name} применяет111 "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!`, configToUse.LOG_TYPE_DAMAGE);
|
||||
}
|
||||
|
||||
if (damage <= 0 && !wasAbilityBlocked) { // Если урон нулевой и не было блока (например, из-за резистов, которых пока нет)
|
||||
abilityApplicationSucceeded = false;
|
||||
actionOutcomeForTaunt = 'fail';
|
||||
} else if (wasAbilityBlocked) {
|
||||
actionOutcomeForTaunt = 'blocked'; // Специальный исход для реакции на блок способности
|
||||
} else if (actualDamageDealtByAbility > 0) {
|
||||
actionOutcomeForTaunt = 'hit'; // Специальный исход для реакции на попадание способностью
|
||||
} else {
|
||||
actionOutcomeForTaunt = 'fail'; // Если урон 0 и не было блока (например цель уже мертва и 0 хп)
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_BUFF:
|
||||
let effectDescriptionBuff = ability.description;
|
||||
if (typeof ability.descriptionFunction === 'function') {
|
||||
effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); // targetBaseStats здесь может быть casterBaseStats, если бафф на себя
|
||||
}
|
||||
// Обычно баффы накладываются на кастера
|
||||
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 && casterState.activeEffects.find(e => e.id === ability.id && e.grantsBlock)) {
|
||||
// Требуется effectsLogic.updateBlockingStatus(casterState);
|
||||
// но GameInstance вызывает его в switchTurn, так что здесь можно не дублировать, если эффект не мгновенный
|
||||
}
|
||||
if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!`, configToUse.LOG_TYPE_EFFECT);
|
||||
actionOutcomeForTaunt = 'success'; // Для реакции оппонента, если бафф на себя
|
||||
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} применяет111 "${ability.name}" на ${targetBaseStats.name}! Способности цели заблокированы на ${ability.effectDuration} хода!`, configToUse.LOG_TYPE_EFFECT);
|
||||
actionOutcomeForTaunt = 'success';
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO);
|
||||
abilityApplicationSucceeded = false;
|
||||
actionOutcomeForTaunt = 'fail';
|
||||
}
|
||||
}
|
||||
// Специальное Безмолвие Баларда
|
||||
else if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && casterState.characterKey === 'balard') {
|
||||
const success = Math.random() < configToUse.SILENCE_SUCCESS_RATE;
|
||||
actionOutcomeForTaunt = success ? 'success' : 'fail'; // Этот outcome используется в tauntLogic
|
||||
if (success) {
|
||||
const targetAbilitiesList = 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}`) &&
|
||||
pa.id !== configToUse.ABILITY_ID_NONE // Исключаем "пустую" абилку, если она есть
|
||||
);
|
||||
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} хода!`, configToUse.LOG_TYPE_EFFECT);
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!`, configToUse.LOG_TYPE_INFO);
|
||||
actionOutcomeForTaunt = 'fail'; // Переопределяем, т.к. нечего было глушить
|
||||
}
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_DEBUFF:
|
||||
const effectIdDebuff = 'effect_' + ability.id; // Уникальный 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}! Эффект продлится ${ability.effectDuration} хода.`, configToUse.LOG_TYPE_EFFECT);
|
||||
actionOutcomeForTaunt = 'success';
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO);
|
||||
abilityApplicationSucceeded = false;
|
||||
actionOutcomeForTaunt = 'fail';
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_DRAIN: // Пример для Манадрейна Баларда
|
||||
if (casterState.characterKey === 'balard' && ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN) {
|
||||
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 || 0)); // Убедимся, что фактор есть
|
||||
const actualHealGain = Math.min(potentialHeal, casterBaseStats.maxHp - casterState.currentHp);
|
||||
if (actualHealGain > 0) {
|
||||
casterState.currentHp = Math.round(casterState.currentHp + actualHealGain);
|
||||
healthGained = actualHealGain;
|
||||
}
|
||||
}
|
||||
|
||||
let logMsgDrain = `⚡ ${casterBaseStats.name} применяет1111 "${ability.name}"! `;
|
||||
if (damageDealtDrain > 0) logMsgDrain += `Наносит ${damageDealtDrain} урона ${targetBaseStats.name}. `;
|
||||
if (manaDrained > 0) {
|
||||
logMsgDrain += `Вытягивает ${manaDrained} ${targetBaseStats.resourceName} у ${targetBaseStats.name}`;
|
||||
if(healthGained > 0) logMsgDrain += ` и исцеляется на ${healthGained} HP!`; else logMsgDrain += `!`;
|
||||
} else if (damageDealtDrain > 0) {
|
||||
logMsgDrain += `${targetBaseStats.name} не имеет ${targetBaseStats.resourceName} для похищения.`;
|
||||
} else {
|
||||
logMsgDrain += `Не удалось ничего похитить у ${targetBaseStats.name}.`;
|
||||
}
|
||||
|
||||
if (addToLogCallback) addToLogCallback(logMsgDrain, (manaDrained > 0 || damageDealtDrain > 0) ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO);
|
||||
|
||||
if (manaDrained <= 0 && damageDealtDrain <= 0 && healthGained <= 0) {
|
||||
abilityApplicationSucceeded = false;
|
||||
actionOutcomeForTaunt = 'fail';
|
||||
} else {
|
||||
actionOutcomeForTaunt = 'success';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (addToLogCallback) addToLogCallback(`Неизвестный тип способности: ${ability?.type} для "${ability?.name}"`, configToUse.LOG_TYPE_SYSTEM);
|
||||
console.warn(`applyAbilityEffect: Неизвестный тип способности: ${ability?.type} для способности ${ability?.id}`);
|
||||
abilityApplicationSucceeded = false;
|
||||
actionOutcomeForTaunt = 'fail';
|
||||
}
|
||||
|
||||
// --- Насмешка от цели (targetState) в ответ на применение способности оппонентом (casterState) ---
|
||||
// Вызываем только если способность не была нацелена на самого себя И есть функция насмешек
|
||||
if (getRandomTauntFunction && dataUtils && casterState.id !== targetState.id) {
|
||||
const casterFullDataForTaunt = dataUtils.getCharacterData(casterState.characterKey);
|
||||
if (casterFullDataForTaunt) {
|
||||
let tauntContext = { abilityId: ability.id };
|
||||
|
||||
// Если для этой способности был определен исход (например, для безмолвия Баларда, или попадание/блок урона)
|
||||
// Используем actionOutcomeForTaunt, который мы установили в switch-case выше
|
||||
if (actionOutcomeForTaunt === 'success' || actionOutcomeForTaunt === 'fail' || actionOutcomeForTaunt === 'blocked' || actionOutcomeForTaunt === 'hit') {
|
||||
tauntContext.outcome = actionOutcomeForTaunt;
|
||||
}
|
||||
// Для способностей типа DAMAGE, 'blocked' и 'hit' будут ключами в taunts.js (например, Elena onOpponentAction -> ABILITY_ID_ALMAGEST_DAMAGE -> blocked: [...])
|
||||
// Это не стандартные 'attackBlocked' и 'attackHits', а специфичные для реакции на *способность*
|
||||
// Если вы хотите использовать общие 'attackBlocked'/'attackHits' и для способностей, вам нужно будет изменить логику в taunts.js
|
||||
// или передавать здесь другие subTrigger'ы, если способность заблокирована/попала.
|
||||
|
||||
const reactionTaunt = getRandomTauntFunction(
|
||||
targetState.characterKey, // Кто говорит (цель способности)
|
||||
'onOpponentAction', // Триггер
|
||||
tauntContext, // Контекст: ID способности кастера (оппонента) и, возможно, outcome
|
||||
configToUse,
|
||||
casterFullDataForTaunt, // Оппонент для говорящего - это кастер
|
||||
currentGameState
|
||||
);
|
||||
if (reactionTaunt && reactionTaunt !== "(Молчание)") {
|
||||
addToLogCallback(`${targetState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Проверяет валидность использования способности.
|
||||
* @param {object} ability - Объект способности.
|
||||
* @param {object} casterState - Состояние бойца, который пытается применить способность.
|
||||
* @param {object} targetState - Состояние цели (может быть тем же, что и casterState).
|
||||
* @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG).
|
||||
* @returns {object} - { 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} (${casterState.currentResource}/${ability.cost})!` };
|
||||
}
|
||||
|
||||
if ((casterState.abilityCooldowns?.[ability.id] || 0) > 0) {
|
||||
return { isValid: false, reason: `"${ability.name}" еще на перезарядке (${casterState.abilityCooldowns[ability.id]} х.).` };
|
||||
}
|
||||
|
||||
// Специальные кулдауны для Баларда
|
||||
if (casterState.characterKey === 'balard') {
|
||||
if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && (casterState.silenceCooldownTurns || 0) > 0) {
|
||||
return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке (${casterState.silenceCooldownTurns} х.).` };
|
||||
}
|
||||
if (ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN && (casterState.manaDrainCooldownTurns || 0) > 0) {
|
||||
return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке (${casterState.manaDrainCooldownTurns} х.).` };
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка на безмолвие
|
||||
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) {
|
||||
return { isValid: false, reason: `${casterState.name} не может использовать способности из-за полного безмолвия!` };
|
||||
}
|
||||
if (isAbilitySpecificallySilenced) {
|
||||
const specificSilenceEffect = casterState.disabledAbilities.find(dis => dis.abilityId === ability.id);
|
||||
return { isValid: false, reason: `Способность "${ability.name}" у ${casterState.name} временно заблокирована (${specificSilenceEffect.turnsLeft} х.)!` };
|
||||
}
|
||||
|
||||
|
||||
// Проверка наложения баффа, который уже активен (кроме обновляемых)
|
||||
if (ability.type === configToUse.ACTION_TYPE_BUFF && casterState.activeEffects.some(e => e.id === ability.id)) {
|
||||
// Исключение для "отложенных" баффов, которые можно обновлять (например, Сила Природы)
|
||||
if (!ability.isDelayed) { // Если isDelayed не true, то нельзя обновлять.
|
||||
return { isValid: false, reason: `Эффект "${ability.name}" уже активен у ${casterState.name}!` };
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка наложения дебаффа, который уже активен на цели
|
||||
const isTargetedDebuff = ability.type === configToUse.ACTION_TYPE_DEBUFF ||
|
||||
(ability.type === configToUse.ACTION_TYPE_DISABLE && ability.id !== configToUse.ABILITY_ID_BALARD_SILENCE); // Безмолвие Баларда может пытаться наложиться повторно (и провалиться)
|
||||
|
||||
if (isTargetedDebuff && targetState.id !== casterState.id) { // Убедимся, что это не бафф на себя, проверяемый как дебафф
|
||||
const effectIdToCheck = (ability.type === configToUse.ACTION_TYPE_DISABLE && ability.id !== configToUse.ABILITY_ID_BALARD_SILENCE) ?
|
||||
(ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest') :
|
||||
('effect_' + ability.id);
|
||||
|
||||
if (targetState.activeEffects.some(e => e.id === effectIdToCheck)) {
|
||||
return { isValid: false, reason: `Эффект "${ability.name}" уже наложен на ${targetState.name}!` };
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, reason: null };
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
performAttack,
|
||||
applyAbilityEffect,
|
||||
checkAbilityValidity
|
||||
};
|
154
server/game/logic/cooldownLogic.js
Normal file
154
server/game/logic/cooldownLogic.js
Normal file
@ -0,0 +1,154 @@
|
||||
// /server/game/logic/cooldownLogic.js
|
||||
|
||||
// GAME_CONFIG будет передаваться в функции как параметр configToUse
|
||||
// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужен для внутренних констант
|
||||
|
||||
/**
|
||||
* Обрабатывает отсчет общих кулдаунов для способностей игрока в конце его хода.
|
||||
* Длительность кулдауна уменьшается на 1.
|
||||
* @param {object} cooldownsObject - Объект с кулдаунами способностей ({ abilityId: turnsLeft }).
|
||||
* @param {Array<object>} characterAbilities - Полный список способностей персонажа (для получения имени).
|
||||
* @param {string} characterName - Имя персонажа (для лога).
|
||||
* @param {function} addToLogCallback - Функция для добавления лога.
|
||||
* @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG).
|
||||
*/
|
||||
function processPlayerAbilityCooldowns(cooldownsObject, characterAbilities, characterName, addToLogCallback, configToUse) {
|
||||
if (!cooldownsObject || !characterAbilities) {
|
||||
// console.warn(`[CooldownLogic] processPlayerAbilityCooldowns: Missing cooldownsObject or characterAbilities for ${characterName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const abilityId in cooldownsObject) {
|
||||
// Проверяем, что свойство принадлежит самому объекту, а не прототипу, и что кулдаун активен
|
||||
if (Object.prototype.hasOwnProperty.call(cooldownsObject, abilityId) && cooldownsObject[abilityId] > 0) {
|
||||
cooldownsObject[abilityId]--; // Уменьшаем кулдаун
|
||||
|
||||
if (cooldownsObject[abilityId] === 0) {
|
||||
const ability = characterAbilities.find(ab => ab.id === abilityId);
|
||||
if (ability && addToLogCallback) {
|
||||
addToLogCallback(
|
||||
`Способность "${ability.name}" персонажа ${characterName} снова готова!`,
|
||||
configToUse.LOG_TYPE_INFO // Используем LOG_TYPE_INFO из переданного конфига
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает отсчет для отключенных (заглушенных) способностей игрока в конце его хода.
|
||||
* Длительность заглушения уменьшается на 1.
|
||||
* @param {Array<object>} disabledAbilitiesArray - Массив объектов заглушенных способностей.
|
||||
* @param {Array<object>} characterAbilities - Полный список способностей персонажа (для получения имени).
|
||||
* @param {string} characterName - Имя персонажа (для лога).
|
||||
* @param {function} addToLogCallback - Функция для добавления лога.
|
||||
* @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG).
|
||||
*/
|
||||
function processDisabledAbilities(disabledAbilitiesArray, characterAbilities, characterName, addToLogCallback, configToUse) {
|
||||
if (!disabledAbilitiesArray || disabledAbilitiesArray.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stillDisabled = []; // Новый массив для активных заглушений
|
||||
for (let i = 0; i < disabledAbilitiesArray.length; i++) {
|
||||
const dis = disabledAbilitiesArray[i];
|
||||
dis.turnsLeft--; // Уменьшаем длительность заглушения
|
||||
|
||||
if (dis.turnsLeft > 0) {
|
||||
stillDisabled.push(dis);
|
||||
} else {
|
||||
// Заглушение закончилось
|
||||
if (addToLogCallback) {
|
||||
const ability = characterAbilities.find(ab => ab.id === dis.abilityId);
|
||||
if (ability) {
|
||||
addToLogCallback(
|
||||
`Способность "${ability.name}" персонажа ${characterName} больше не заглушена!`,
|
||||
configToUse.LOG_TYPE_INFO
|
||||
);
|
||||
} else {
|
||||
// Если способность не найдена по ID (маловероятно, но возможно при ошибках данных)
|
||||
addToLogCallback(
|
||||
`Заглушение для неизвестной способности персонажа ${characterName} (ID: ${dis.abilityId}) закончилось.`,
|
||||
configToUse.LOG_TYPE_INFO
|
||||
);
|
||||
}
|
||||
}
|
||||
// Также нужно удалить соответствующий эффект из activeEffects, если он там был (например, playerSilencedOn_X)
|
||||
// Это должно происходить в effectsLogic.processEffects, когда эффект с id `playerSilencedOn_${dis.abilityId}` истекает.
|
||||
// Здесь мы только управляем массивом `disabledAbilities`.
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем исходный массив, удаляя истекшие заглушения
|
||||
disabledAbilitiesArray.length = 0; // Очищаем массив (сохраняя ссылку, если она используется где-то еще)
|
||||
disabledAbilitiesArray.push(...stillDisabled); // Добавляем обратно только те, что еще активны
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает или обновляет кулдаун для способности.
|
||||
* Также обрабатывает специальные внутренние кулдауны для Баларда.
|
||||
* @param {object} ability - Объект способности, для которой устанавливается кулдаун.
|
||||
* @param {object} casterState - Состояние персонажа, применившего способность.
|
||||
* @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG).
|
||||
*/
|
||||
function setAbilityCooldown(ability, casterState, configToUse) {
|
||||
if (!ability || !casterState || !casterState.abilityCooldowns) {
|
||||
console.warn("[CooldownLogic] setAbilityCooldown: Missing ability, casterState, or casterState.abilityCooldowns.");
|
||||
return;
|
||||
}
|
||||
|
||||
let baseCooldown = 0;
|
||||
if (typeof ability.cooldown === 'number' && ability.cooldown > 0) { // Убедимся, что исходный КД > 0
|
||||
baseCooldown = ability.cooldown;
|
||||
}
|
||||
|
||||
// Специальные внутренние КД для Баларда - они могут перебивать общий КД
|
||||
if (casterState.characterKey === 'balard') {
|
||||
if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE &&
|
||||
typeof ability.internalCooldownFromConfig === 'string' && // Проверяем, что есть ключ для конфига
|
||||
typeof configToUse[ability.internalCooldownFromConfig] === 'number') {
|
||||
// Устанавливаем значение для специального счетчика КД Баларда
|
||||
casterState.silenceCooldownTurns = configToUse[ability.internalCooldownFromConfig];
|
||||
// Этот специальный КД также становится текущим общим КД для этой способности
|
||||
baseCooldown = configToUse[ability.internalCooldownFromConfig];
|
||||
} else if (ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN &&
|
||||
typeof ability.internalCooldownValue === 'number') { // Здесь КД задан прямо в данных способности
|
||||
casterState.manaDrainCooldownTurns = ability.internalCooldownValue;
|
||||
baseCooldown = ability.internalCooldownValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (baseCooldown > 0) {
|
||||
// Устанавливаем кулдаун. Добавляем +1, так как кулдаун уменьшится в конце текущего хода
|
||||
// (когда будет вызван processPlayerAbilityCooldowns для этого персонажа).
|
||||
casterState.abilityCooldowns[ability.id] = baseCooldown + 1;
|
||||
} else {
|
||||
// Если у способности нет базового кулдауна (baseCooldown === 0),
|
||||
// убеждаемся, что в abilityCooldowns для нее стоит 0.
|
||||
casterState.abilityCooldowns[ability.id] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает специальные кулдауны для Баларда в конце его хода.
|
||||
* @param {object} balardState - Состояние Баларда.
|
||||
*/
|
||||
function processBalardSpecialCooldowns(balardState) {
|
||||
if (balardState.characterKey !== 'balard') return;
|
||||
|
||||
if (balardState.silenceCooldownTurns !== undefined && balardState.silenceCooldownTurns > 0) {
|
||||
balardState.silenceCooldownTurns--;
|
||||
}
|
||||
if (balardState.manaDrainCooldownTurns !== undefined && balardState.manaDrainCooldownTurns > 0) {
|
||||
balardState.manaDrainCooldownTurns--;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
processPlayerAbilityCooldowns,
|
||||
processDisabledAbilities,
|
||||
setAbilityCooldown,
|
||||
processBalardSpecialCooldowns
|
||||
};
|
153
server/game/logic/effectsLogic.js
Normal file
153
server/game/logic/effectsLogic.js
Normal file
@ -0,0 +1,153 @@
|
||||
// /server/game/logic/effectsLogic.js
|
||||
|
||||
// GAME_CONFIG и dataUtils будут передаваться в функции как параметры.
|
||||
// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужен для внутренних констант
|
||||
// const DATA_UTILS_STATIC = require('../../data/dataUtils'); // Если нужен для внутренних констант
|
||||
|
||||
/**
|
||||
* Обрабатывает активные эффекты (баффы/дебаффы) для бойца в конце его хода.
|
||||
* Длительность эффекта уменьшается на 1.
|
||||
* Периодические эффекты (DoT, сжигание ресурса и т.п.) срабатывают, если эффект не "justCast" в этом ходу.
|
||||
* @param {Array<object>} activeEffectsArray - Массив активных эффектов бойца (из gameState.player.activeEffects или gameState.opponent.activeEffects).
|
||||
* @param {object} ownerState - Состояние бойца, на котором эффекты (currentHp, currentResource и т.д.).
|
||||
* @param {object} ownerBaseStats - Базовые статы бойца (включая characterKey, name, maxHp, maxResource).
|
||||
* @param {string} ownerRoleInGame - Роль бойца в игре ('player' или 'opponent'), для контекста.
|
||||
* @param {object} currentGameState - Полное текущее состояние игры.
|
||||
* @param {function} addToLogCallback - Функция для добавления сообщений в лог игры.
|
||||
* @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG).
|
||||
* @param {object} dataUtils - Утилиты для доступа к данным игры (getCharacterData, getCharacterAbilities и т.д.).
|
||||
*/
|
||||
function processEffects(
|
||||
activeEffectsArray,
|
||||
ownerState,
|
||||
ownerBaseStats,
|
||||
ownerRoleInGame, // 'player' или 'opponent'
|
||||
currentGameState,
|
||||
addToLogCallback,
|
||||
configToUse,
|
||||
dataUtils
|
||||
) {
|
||||
if (!activeEffectsArray || activeEffectsArray.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerName = ownerBaseStats.name;
|
||||
const effectsToRemoveIndexes = [];
|
||||
|
||||
for (let i = 0; i < activeEffectsArray.length; i++) {
|
||||
const effect = activeEffectsArray[i];
|
||||
|
||||
// --- Применяем периодический эффект (DoT, сжигание ресурса и т.п.), если он не только что наложен ---
|
||||
if (!effect.justCast) {
|
||||
// 1. Урон от эффектов полного безмолвия (Гипнотический Взгляд, Раскол Разума)
|
||||
// Эти эффекты наносят урон цели В КОНЦЕ ее хода.
|
||||
if (effect.isFullSilence && typeof effect.power === 'number' && effect.power > 0) {
|
||||
const damage = effect.power; // Урон, заложенный в эффекте
|
||||
ownerState.currentHp = Math.max(0, Math.round(ownerState.currentHp - damage));
|
||||
if (addToLogCallback) {
|
||||
addToLogCallback(
|
||||
`😵 Эффект "${effect.name}" наносит ${damage} урона персонажу ${ownerName}! (HP: ${ownerState.currentHp}/${ownerBaseStats.maxHp})`,
|
||||
configToUse.LOG_TYPE_DAMAGE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Сжигание ресурса (Печать Слабости, Проклятие Увядания)
|
||||
// Эти эффекты сжигают ресурс цели В КОНЦЕ ее хода.
|
||||
// ID эффекта на цели имеет префикс 'effect_' + ID способности, которая его наложила.
|
||||
const isResourceBurnDebuff = effect.id === 'effect_' + configToUse.ABILITY_ID_SEAL_OF_WEAKNESS ||
|
||||
effect.id === 'effect_' + configToUse.ABILITY_ID_ALMAGEST_DEBUFF;
|
||||
if (isResourceBurnDebuff && typeof effect.power === 'number' && effect.power > 0) {
|
||||
const resourceToBurn = effect.power; // Количество ресурса, сжигаемое за ход
|
||||
if (ownerState.currentResource > 0) {
|
||||
const actualBurn = Math.min(ownerState.currentResource, resourceToBurn);
|
||||
ownerState.currentResource = Math.max(0, Math.round(ownerState.currentResource - actualBurn));
|
||||
if (addToLogCallback) {
|
||||
addToLogCallback(
|
||||
`🔥 Эффект "${effect.name}" сжигает ${actualBurn} ${ownerBaseStats.resourceName} у ${ownerName}! (Ресурс: ${ownerState.currentResource}/${ownerBaseStats.maxResource})`,
|
||||
configToUse.LOG_TYPE_EFFECT
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Примечание: Отложенные эффекты (isDelayed: true, например, Сила Природы)
|
||||
// применяют свою основную силу в GameInstance.processPlayerAction (после атаки), а не здесь.
|
||||
// Здесь они просто тикают по длительности.
|
||||
}
|
||||
|
||||
// --- Уменьшаем длительность ---
|
||||
effect.turnsLeft--;
|
||||
effect.justCast = false; // Эффект больше не считается "just cast" после обработки этого хода
|
||||
|
||||
// --- Отмечаем для удаления, если длительность закончилась ---
|
||||
if (effect.turnsLeft <= 0) {
|
||||
effectsToRemoveIndexes.push(i);
|
||||
if (addToLogCallback) {
|
||||
addToLogCallback(
|
||||
`Эффект "${effect.name}" на персонаже ${ownerName} закончился.`,
|
||||
configToUse.LOG_TYPE_EFFECT
|
||||
);
|
||||
}
|
||||
// Если это был эффект, дающий блок, нужно обновить статус блокировки
|
||||
if (effect.grantsBlock) {
|
||||
updateBlockingStatus(ownerState); // Вызываем сразу, т.к. эффект удаляется
|
||||
}
|
||||
// Если это был эффект заглушения конкретной способности (playerSilencedOn_X),
|
||||
// то соответствующая запись в ownerState.disabledAbilities должна быть удалена в cooldownLogic.processDisabledAbilities.
|
||||
// Здесь мы просто удаляем сам эффект из activeEffects.
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем эффекты с конца массива, чтобы не нарушить индексы при удалении
|
||||
for (let i = effectsToRemoveIndexes.length - 1; i >= 0; i--) {
|
||||
activeEffectsArray.splice(effectsToRemoveIndexes[i], 1);
|
||||
}
|
||||
|
||||
// После удаления всех истекших эффектов, еще раз обновляем статус блока,
|
||||
// так как какой-то из удаленных эффектов мог быть последним дающим блок.
|
||||
// (хотя updateBlockingStatus вызывается и при удалении конкретного блокирующего эффекта)
|
||||
updateBlockingStatus(ownerState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет статус 'isBlocking' для бойца на основе его активных эффектов.
|
||||
* Боец считается блокирующим, если у него есть хотя бы один активный эффект с флагом grantsBlock: true.
|
||||
* @param {object} fighterState - Состояние бойца (объект из gameState.player или gameState.opponent).
|
||||
*/
|
||||
function updateBlockingStatus(fighterState) {
|
||||
if (!fighterState || !fighterState.activeEffects) {
|
||||
// console.warn("[EffectsLogic] updateBlockingStatus: fighterState or activeEffects missing.");
|
||||
if (fighterState) fighterState.isBlocking = false; // Если нет эффектов, то точно не блокирует
|
||||
return;
|
||||
}
|
||||
// Боец блокирует, если есть ХОТЯ БЫ ОДИН активный эффект, дающий блок
|
||||
const wasBlocking = fighterState.isBlocking;
|
||||
fighterState.isBlocking = fighterState.activeEffects.some(eff => eff.grantsBlock && eff.turnsLeft > 0);
|
||||
|
||||
// Можно добавить лог, если статус блока изменился, для отладки
|
||||
// if (wasBlocking !== fighterState.isBlocking && addToLogCallback) {
|
||||
// addToLogCallback(`${fighterState.name} ${fighterState.isBlocking ? 'встает в защиту' : 'перестает защищаться'} из-за эффектов.`, 'info');
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, находится ли персонаж под действием полного безмолвия.
|
||||
* @param {object} characterState - Состояние персонажа из gameState.
|
||||
* @param {object} configToUse - Конфигурационный объект игры.
|
||||
* @returns {boolean} true, если персонаж под полным безмолвием, иначе false.
|
||||
*/
|
||||
function isCharacterFullySilenced(characterState, configToUse) {
|
||||
if (!characterState || !characterState.activeEffects) {
|
||||
return false;
|
||||
}
|
||||
return characterState.activeEffects.some(
|
||||
eff => eff.isFullSilence && eff.turnsLeft > 0
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
processEffects,
|
||||
updateBlockingStatus,
|
||||
isCharacterFullySilenced
|
||||
};
|
133
server/game/logic/gameStateLogic.js
Normal file
133
server/game/logic/gameStateLogic.js
Normal file
@ -0,0 +1,133 @@
|
||||
// /server/game/logic/gameStateLogic.js
|
||||
|
||||
// GAME_CONFIG будет передаваться в функции как параметр configToUse.
|
||||
// dataUtils также может передаваться, если нужен для какой-то логики здесь.
|
||||
|
||||
/**
|
||||
* Внутренняя проверка условий конца игры (основано на HP).
|
||||
* @param {object} currentGameState - Текущее состояние игры.
|
||||
* // configToUse и dataUtils здесь не используются, но могут понадобиться для более сложных условий
|
||||
* @param {object} configToUse - Конфигурация игры.
|
||||
* @param {object} dataUtils - Утилиты для доступа к данным.
|
||||
* @returns {boolean} true, если игра окончена по HP, иначе false.
|
||||
*/
|
||||
function checkGameOverInternal(currentGameState, configToUse, dataUtils) {
|
||||
if (!currentGameState || currentGameState.isGameOver) {
|
||||
// Если игра уже помечена как оконченная, или нет состояния, возвращаем текущий статус
|
||||
return currentGameState ? currentGameState.isGameOver : true;
|
||||
}
|
||||
|
||||
// Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами
|
||||
if (!currentGameState.player || !currentGameState.opponent ||
|
||||
!currentGameState.player.characterKey || !currentGameState.opponent.characterKey || // Проверяем, что персонажи назначены
|
||||
currentGameState.opponent.name === 'Ожидание игрока...' || // Дополнительная проверка на плейсхолдер
|
||||
!currentGameState.opponent.maxHp || currentGameState.opponent.maxHp <= 0) {
|
||||
return false; // Игра не может закончиться по HP, если один из бойцов не готов/не определен
|
||||
}
|
||||
|
||||
const playerDead = currentGameState.player.currentHp <= 0;
|
||||
const opponentDead = currentGameState.opponent.currentHp <= 0;
|
||||
|
||||
return playerDead || opponentDead; // Игра окончена, если хотя бы один мертв
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет результат завершения игры (победитель, проигравший, причина).
|
||||
* Вызывается, когда checkGameOverInternal вернул true или игра завершается по другой причине (дисконнект, таймаут).
|
||||
* @param {object} currentGameState - Текущее состояние игры.
|
||||
* @param {object} configToUse - Конфигурация игры (GAME_CONFIG).
|
||||
* @param {string} gameMode - Режим игры ('ai' или 'pvp').
|
||||
* @param {string} [explicitReason=null] - Явная причина завершения (например, 'turn_timeout', 'opponent_disconnected').
|
||||
* Если null, причина определяется по HP.
|
||||
* @param {string} [explicitWinnerRole=null] - Явный победитель (если известен, например, при дисконнекте).
|
||||
* @param {string} [explicitLoserRole=null] - Явный проигравший (если известен).
|
||||
* @returns {{isOver: boolean, winnerRole: string|null, loserRole: string|null, reason: string, logMessage: string}}
|
||||
*/
|
||||
function getGameOverResult(
|
||||
currentGameState,
|
||||
configToUse,
|
||||
gameMode,
|
||||
explicitReason = null,
|
||||
explicitWinnerRole = null,
|
||||
explicitLoserRole = null
|
||||
) {
|
||||
if (!currentGameState) {
|
||||
return { isOver: true, winnerRole: null, loserRole: null, reason: 'error_no_gamestate', logMessage: 'Ошибка: нет состояния игры.' };
|
||||
}
|
||||
|
||||
// Если причина уже задана (например, дисконнект или таймаут), используем ее
|
||||
if (explicitReason) {
|
||||
let winnerName = explicitWinnerRole ? (currentGameState[explicitWinnerRole]?.name || explicitWinnerRole) : 'Никто';
|
||||
let loserName = explicitLoserRole ? (currentGameState[explicitLoserRole]?.name || explicitLoserRole) : 'Никто';
|
||||
let logMsg = "";
|
||||
|
||||
if (explicitReason === 'turn_timeout') {
|
||||
logMsg = `⏱️ Время хода для ${loserName} истекло! Победа присуждается ${winnerName}!`;
|
||||
} else if (explicitReason === 'opponent_disconnected') {
|
||||
logMsg = `🔌 Игрок ${loserName} отключился. Победа присуждается ${winnerName}!`;
|
||||
if (gameMode === 'ai' && explicitLoserRole === configToUse.PLAYER_ID) { // Игрок отключился в AI игре
|
||||
winnerName = currentGameState.opponent?.name || 'AI'; // AI "выиграл" по факту, но не формально
|
||||
logMsg = `🔌 Игрок ${loserName} отключился. Игра завершена.`;
|
||||
explicitWinnerRole = null; // В AI режиме нет формального победителя при дисконнекте игрока
|
||||
}
|
||||
} else {
|
||||
logMsg = `Игра завершена. Причина: ${explicitReason}. Победитель: ${winnerName}.`;
|
||||
}
|
||||
|
||||
return {
|
||||
isOver: true,
|
||||
winnerRole: explicitWinnerRole,
|
||||
loserRole: explicitLoserRole,
|
||||
reason: explicitReason,
|
||||
logMessage: logMsg
|
||||
};
|
||||
}
|
||||
|
||||
// Если явной причины нет, проверяем по HP
|
||||
const playerDead = currentGameState.player?.currentHp <= 0;
|
||||
const opponentDead = currentGameState.opponent?.currentHp <= 0;
|
||||
|
||||
if (!playerDead && !opponentDead) {
|
||||
return { isOver: false, winnerRole: null, loserRole: null, reason: 'not_over_hp', logMessage: "" }; // Игра еще не окончена по HP
|
||||
}
|
||||
|
||||
let winnerRole = null;
|
||||
let loserRole = null;
|
||||
let reason = 'hp_zero';
|
||||
let logMessage = "";
|
||||
|
||||
if (gameMode === 'ai') {
|
||||
if (playerDead) { // Игрок проиграл AI
|
||||
winnerRole = configToUse.OPPONENT_ID; // AI победил
|
||||
loserRole = configToUse.PLAYER_ID;
|
||||
logMessage = `😭 ПОРАЖЕНИЕ! ${currentGameState.opponent.name} оказался сильнее! 😭`;
|
||||
} else { // Игрок победил AI (opponentDead)
|
||||
winnerRole = configToUse.PLAYER_ID;
|
||||
loserRole = configToUse.OPPONENT_ID;
|
||||
logMessage = `🏁 ПОБЕДА! Вы одолели ${currentGameState.opponent.name}! 🏁`;
|
||||
}
|
||||
} else { // PvP режим
|
||||
if (playerDead && opponentDead) { // Ничья - победа присуждается игроку в слоте 'player' (или по другим правилам)
|
||||
winnerRole = configToUse.PLAYER_ID;
|
||||
loserRole = configToUse.OPPONENT_ID;
|
||||
logMessage = `⚔️ Ничья! Оба бойца пали! Победа присуждается ${currentGameState.player.name} по правилам арены!`;
|
||||
reason = 'draw_player_wins';
|
||||
} else if (playerDead) {
|
||||
winnerRole = configToUse.OPPONENT_ID;
|
||||
loserRole = configToUse.PLAYER_ID;
|
||||
logMessage = `🏁 ПОБЕДА! ${currentGameState.opponent.name} одолел(а) ${currentGameState.player.name}! 🏁`;
|
||||
} else { // opponentDead
|
||||
winnerRole = configToUse.PLAYER_ID;
|
||||
loserRole = configToUse.OPPONENT_ID;
|
||||
logMessage = `🏁 ПОБЕДА! ${currentGameState.player.name} одолел(а) ${currentGameState.opponent.name}! 🏁`;
|
||||
}
|
||||
}
|
||||
|
||||
return { isOver: true, winnerRole, loserRole, reason, logMessage };
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
checkGameOverInternal,
|
||||
getGameOverResult
|
||||
};
|
66
server/game/logic/index.js
Normal file
66
server/game/logic/index.js
Normal file
@ -0,0 +1,66 @@
|
||||
// /server/game/logic/index.js
|
||||
|
||||
// Импортируем функции из всех специализированных логических модулей
|
||||
|
||||
const {
|
||||
performAttack,
|
||||
applyAbilityEffect,
|
||||
checkAbilityValidity
|
||||
} = require('./combatLogic');
|
||||
|
||||
const {
|
||||
processPlayerAbilityCooldowns,
|
||||
processDisabledAbilities,
|
||||
setAbilityCooldown,
|
||||
processBalardSpecialCooldowns
|
||||
} = require('./cooldownLogic');
|
||||
|
||||
const {
|
||||
processEffects,
|
||||
updateBlockingStatus,
|
||||
isCharacterFullySilenced
|
||||
} = require('./effectsLogic');
|
||||
|
||||
const {
|
||||
decideAiAction
|
||||
} = require('./aiLogic');
|
||||
|
||||
const {
|
||||
getRandomTaunt
|
||||
} = require('./tauntLogic'); // Предполагаем, что getRandomTaunt вынесен в tauntLogic.js
|
||||
|
||||
const {
|
||||
checkGameOverInternal, // Внутренняя проверка на HP
|
||||
getGameOverResult // Определяет победителя и причину для checkGameOver в GameInstance
|
||||
} = require('./gameStateLogic'); // Предполагаем, что логика завершения игры вынесена
|
||||
|
||||
|
||||
// Экспортируем все импортированные функции, чтобы они были доступны
|
||||
// через единый объект 'gameLogic' в GameInstance.js
|
||||
module.exports = {
|
||||
// Combat Logic
|
||||
performAttack,
|
||||
applyAbilityEffect,
|
||||
checkAbilityValidity,
|
||||
|
||||
// Cooldown Logic
|
||||
processPlayerAbilityCooldowns,
|
||||
processDisabledAbilities,
|
||||
setAbilityCooldown,
|
||||
processBalardSpecialCooldowns,
|
||||
|
||||
// Effects Logic
|
||||
processEffects,
|
||||
updateBlockingStatus,
|
||||
isCharacterFullySilenced,
|
||||
|
||||
// AI Logic
|
||||
decideAiAction,
|
||||
|
||||
// Taunt Logic
|
||||
getRandomTaunt,
|
||||
|
||||
// Game State Logic (например, для условий завершения)
|
||||
checkGameOverInternal,
|
||||
getGameOverResult
|
||||
};
|
151
server/game/logic/tauntLogic.js
Normal file
151
server/game/logic/tauntLogic.js
Normal file
@ -0,0 +1,151 @@
|
||||
// /server/game/logic/tauntLogic.js
|
||||
const GAME_CONFIG = require('../../core/config');
|
||||
// Предполагаем, что gameData.tauntSystem импортируется или доступен.
|
||||
// Если tauntSystem экспортируется напрямую из data/taunts.js:
|
||||
// const { tauntSystem } = require('../../data/taunts');
|
||||
// Если он часть общего gameData, который собирается в data/index.js:
|
||||
const gameData = require('../../data'); // Тогда используем gameData.tauntSystem
|
||||
|
||||
/**
|
||||
* Получает случайную насмешку из системы насмешек.
|
||||
* @param {string} speakerCharacterKey - Ключ персонажа, который говорит.
|
||||
* @param {string} trigger - Тип триггера насмешки (например, 'selfCastAbility', 'onBattleState', 'onOpponentAction').
|
||||
* @param {string|number|object} [subTriggerOrContext={}] - Может быть ID способности, специфичный ключ состояния ('start', 'dominating') или объект контекста.
|
||||
* @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG).
|
||||
* @param {object} opponentFullData - Полные данные персонажа, к которому обращена насмешка (цель).
|
||||
* @param {object} currentGameState - Текущее полное состояние игры.
|
||||
* @returns {string} Текст насмешки или "(Молчание)".
|
||||
*/
|
||||
function getRandomTaunt(speakerCharacterKey, trigger, subTriggerOrContext = {}, configToUse, opponentFullData, currentGameState) {
|
||||
// console.log(`[TauntLogic DEBUG] Called with: speaker=${speakerCharacterKey}, trigger=${trigger}, subTriggerOrContext=`, subTriggerOrContext, `opponentKey=${opponentFullData?.baseStats?.characterKey}`);
|
||||
|
||||
const tauntSystemToUse = gameData.tauntSystem || (gameData.default && gameData.default.tauntSystem); // Совместимость, если gameData имеет default экспорт
|
||||
if (!tauntSystemToUse) {
|
||||
console.error("[TauntLogic ERROR] tauntSystem is not available from gameData import!");
|
||||
return "(Молчание)";
|
||||
}
|
||||
|
||||
const speakerTauntBranch = tauntSystemToUse[speakerCharacterKey];
|
||||
if (!speakerTauntBranch) {
|
||||
// console.log(`[TauntLogic] No taunt branch for speaker: ${speakerCharacterKey}`);
|
||||
return "(Молчание)";
|
||||
}
|
||||
|
||||
const opponentKeyForTaunts = opponentFullData?.baseStats?.characterKey;
|
||||
if (!opponentKeyForTaunts) {
|
||||
// console.log(`[TauntLogic] Opponent key for taunts not available for speaker ${speakerCharacterKey}, trigger ${trigger}. OpponentData:`, opponentFullData);
|
||||
// Особый случай для старта AI игры, где оппонент (AI Балард) может быть известен, даже если opponentFullData не полон
|
||||
if (trigger === 'onBattleState' && subTriggerOrContext === 'start' && speakerCharacterKey === 'elena' && currentGameState.gameMode === 'ai') {
|
||||
// Елена против Баларда (AI) в начале боя
|
||||
const elenaVsBalardStartTaunts = speakerTauntBranch.balard?.onBattleState?.start;
|
||||
if (Array.isArray(elenaVsBalardStartTaunts) && elenaVsBalardStartTaunts.length > 0) {
|
||||
return elenaVsBalardStartTaunts[Math.floor(Math.random() * elenaVsBalardStartTaunts.length)] || "(Молчание)";
|
||||
}
|
||||
}
|
||||
return "(Молчание)";
|
||||
}
|
||||
|
||||
const specificTauntBranch = speakerTauntBranch[opponentKeyForTaunts];
|
||||
if (!specificTauntBranch || !specificTauntBranch[trigger]) {
|
||||
// console.log(`[TauntLogic] No specific taunt branch or trigger branch for ${speakerCharacterKey} vs ${opponentKeyForTaunts}, trigger: ${trigger}`);
|
||||
return "(Молчание)";
|
||||
}
|
||||
|
||||
let tauntSet = specificTauntBranch[trigger];
|
||||
let context = {};
|
||||
let subTriggerKey = null; // Это будет ключ для прямого доступа к массиву насмешек, например, ID способности или 'start'
|
||||
|
||||
if (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number') {
|
||||
subTriggerKey = subTriggerOrContext;
|
||||
// Если subTriggerOrContext - это ID способности, помещаем его в контекст для onOpponentAction
|
||||
if (trigger === 'onOpponentAction' || trigger === 'selfCastAbility') {
|
||||
context.abilityId = subTriggerOrContext;
|
||||
}
|
||||
} else if (typeof subTriggerOrContext === 'object' && subTriggerOrContext !== null) {
|
||||
context = { ...subTriggerOrContext };
|
||||
// Если ID способности передан в контексте, используем его как subTriggerKey для прямого доступа
|
||||
if (context.abilityId && (trigger === 'selfCastAbility' || trigger === 'onOpponentAction')) {
|
||||
subTriggerKey = context.abilityId;
|
||||
} else if (trigger === 'onBattleState' && typeof context === 'string') { // на случай если GameInstance передает строку для onBattleState
|
||||
subTriggerKey = context;
|
||||
}
|
||||
}
|
||||
// Для basicAttack subTriggerKey может быть 'merciful', 'dominating' или null (тогда general)
|
||||
if (trigger === 'basicAttack' && typeof subTriggerOrContext === 'string') {
|
||||
subTriggerKey = subTriggerOrContext;
|
||||
}
|
||||
|
||||
|
||||
// console.log(`[TauntLogic DEBUG] Parsed: trigger=${trigger}, subTriggerKey=${subTriggerKey}, context=`, context);
|
||||
|
||||
let potentialTaunts = [];
|
||||
|
||||
if (subTriggerKey !== null && typeof tauntSet === 'object' && !Array.isArray(tauntSet) && tauntSet[subTriggerKey]) {
|
||||
// Если есть subTriggerKey и tauntSet - это объект (а не массив), то получаем вложенный набор
|
||||
tauntSet = tauntSet[subTriggerKey];
|
||||
} else if (Array.isArray(tauntSet)) {
|
||||
// Если tauntSet уже массив (например, для onOpponentAttackBlocked), используем его как есть
|
||||
potentialTaunts = tauntSet; // Присваиваем сразу
|
||||
} else if (typeof tauntSet === 'object' && tauntSet.general) { // Фоллбэк на general, если subTriggerKey не найден в объекте
|
||||
tauntSet = tauntSet.general;
|
||||
}
|
||||
|
||||
|
||||
// Специальная обработка для onOpponentAction с исходом (success/fail)
|
||||
if (trigger === 'onOpponentAction' && typeof tauntSet === 'object' && !Array.isArray(tauntSet) && context.outcome) {
|
||||
if (tauntSet[context.outcome]) {
|
||||
potentialTaunts = tauntSet[context.outcome];
|
||||
} else {
|
||||
// console.log(`[TauntLogic] No outcome '${context.outcome}' for onOpponentAction, abilityId ${context.abilityId}`);
|
||||
potentialTaunts = []; // Явно пустой, чтобы не упасть ниже
|
||||
}
|
||||
} else if (Array.isArray(tauntSet)) {
|
||||
potentialTaunts = tauntSet;
|
||||
}
|
||||
|
||||
|
||||
// Обработка basicAttack (merciful/dominating/general)
|
||||
if (trigger === 'basicAttack' && specificTauntBranch.basicAttack) { // Убедимся что ветка basicAttack существует
|
||||
const basicAttackBranch = specificTauntBranch.basicAttack;
|
||||
if (speakerCharacterKey === 'elena' && opponentKeyForTaunts === 'balard' && currentGameState && currentGameState[GAME_CONFIG.OPPONENT_ID]) {
|
||||
const opponentState = currentGameState[GAME_CONFIG.OPPONENT_ID]; // Балард всегда оппонент для Елены в этом контексте
|
||||
if (opponentState && opponentState.maxHp > 0) {
|
||||
const opponentHpPerc = (opponentState.currentHp / opponentState.maxHp) * 100;
|
||||
if (opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT && basicAttackBranch.dominating) {
|
||||
potentialTaunts = basicAttackBranch.dominating;
|
||||
} else if (basicAttackBranch.merciful) {
|
||||
potentialTaunts = basicAttackBranch.merciful;
|
||||
} else if (basicAttackBranch.general) { // Фоллбэк на general если нет merciful
|
||||
potentialTaunts = basicAttackBranch.general;
|
||||
}
|
||||
} else if (basicAttackBranch.general) { // Если нет HP данных, используем general
|
||||
potentialTaunts = basicAttackBranch.general;
|
||||
}
|
||||
} else if (basicAttackBranch.general) { // Общий случай для basicAttack
|
||||
potentialTaunts = basicAttackBranch.general;
|
||||
}
|
||||
// Если subTriggerKey был ('merciful'/'dominating') и он найден в basicAttackBranch, то tauntSet уже установлен выше
|
||||
// Этот блок if (trigger === 'basicAttack') должен быть более специфичным или объединен с логикой subTriggerKey выше.
|
||||
// Пока оставим как есть, предполагая, что subTriggerKey для basicAttack обрабатывается отдельно.
|
||||
// Если subTriggerKey был 'merciful' или 'dominating', и такой ключ есть в basicAttackBranch, то tauntSet уже должен быть им.
|
||||
if (subTriggerKey && basicAttackBranch[subTriggerKey]) {
|
||||
potentialTaunts = basicAttackBranch[subTriggerKey];
|
||||
} else if (potentialTaunts.length === 0 && basicAttackBranch.general) { // Если не нашли по subTriggerKey, берем general
|
||||
potentialTaunts = basicAttackBranch.general;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) {
|
||||
// console.log(`[TauntLogic] No potential taunts found or empty array for ${speakerCharacterKey} vs ${opponentKeyForTaunts}, trigger: ${trigger}, subTriggerKey: ${subTriggerKey}`);
|
||||
return "(Молчание)";
|
||||
}
|
||||
|
||||
const selectedTaunt = potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)];
|
||||
// console.log(`[TauntLogic] Selected for ${speakerCharacterKey} vs ${opponentKeyForTaunts} (Trigger: ${trigger}, SubTriggerKey: ${subTriggerKey}): "${selectedTaunt}"`);
|
||||
return selectedTaunt || "(Молчание)";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRandomTaunt
|
||||
};
|
0
server/services/SocketService.js
Normal file
0
server/services/SocketService.js
Normal file
237
server/views/index.ejs
Normal file
237
server/views/index.ejs
Normal file
@ -0,0 +1,237 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Битва: Елена vs Балард (Сетевая Версия)</title>
|
||||
<link rel="stylesheet" href="style_alt.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<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 id="status-container">
|
||||
<div id="auth-message"></div>
|
||||
<div id="game-status-message">Ожидание подключения к серверу...</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-section"> <!-- Секция Аутентификации -->
|
||||
<h2>Вход / Регистрация</h2>
|
||||
<form id="register-form">
|
||||
<h3>Регистрация</h3>
|
||||
<input type="text" id="register-username" placeholder="Имя пользователя" required autocomplete="username">
|
||||
<input type="password" id="register-password" placeholder="Пароль (мин. 6 симв.)" required autocomplete="new-password">
|
||||
<button type="submit">Зарегистрироваться</button>
|
||||
</form>
|
||||
<hr style="margin: 25px 0;">
|
||||
<form id="login-form">
|
||||
<h3>Вход</h3>
|
||||
<input type="text" id="login-username" placeholder="Имя пользователя" required autocomplete="username">
|
||||
<input type="password" id="login-password" placeholder="Пароль" required autocomplete="current-password">
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="game-setup" style="display: none;"> <!-- Секция Настройки Игры (после логина) -->
|
||||
<h2>Настройка Игры</h2>
|
||||
<div>
|
||||
<button id="create-ai-game">Играть против AI (Балард)</button>
|
||||
</div>
|
||||
<hr style="margin: 15px 0;">
|
||||
<div>
|
||||
<h3>PvP (Игрок против Игрока)</h3>
|
||||
|
||||
<div class="character-selection">
|
||||
<h4>Выберите персонажа для PvP:</h4>
|
||||
<input type="radio" id="char-elena" name="pvp-character" value="elena" checked>
|
||||
<label for="char-elena"><i class="fas fa-hat-wizard"></i> Елена</label>
|
||||
|
||||
<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>
|
||||
<br><br>
|
||||
<input type="text" id="game-id-input" placeholder="Введите ID игры для присоединения">
|
||||
<button id="join-pvp-game">Присоединиться к PvP по ID</button>
|
||||
</div>
|
||||
<div id="available-games-list">
|
||||
<h3>Доступные PvP игры:</h3>
|
||||
<p>Загрузка списка...</p>
|
||||
<!-- Список игр будет здесь -->
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- Конец .auth-game-setup-wrapper -->
|
||||
|
||||
|
||||
<div class="game-wrapper" style="display: none;"> <!-- Игровая арена, изначально скрыта -->
|
||||
<div class="panel-switcher-controls">
|
||||
<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) -->
|
||||
<div class="player-column">
|
||||
<section id="player-panel" class="fighter-panel">
|
||||
<div class="panel-header">
|
||||
<div class="character-visual">
|
||||
<img src="images/elena_avatar.webp" alt="Аватар игрока 1" class="avatar-image player-avatar">
|
||||
</div>
|
||||
<h2 id="player-name" class="fighter-name">
|
||||
<i class="fas fa-hat-wizard icon-player"></i> Елена
|
||||
</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="stat-bar-container health">
|
||||
<div class="bar-icon"><i class="fas fa-heart"></i></div>
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar health-bar" id="player-hp-bar">
|
||||
<div id="player-hp-fill" class="bar-fill" style="width: 100%;"></div>
|
||||
<span id="player-hp-text" class="bar-text">120 / 120</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-container resource mana">
|
||||
<div class="bar-icon"><i class="fas fa-flask"></i></div>
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar resource-bar" id="player-resource-bar">
|
||||
<div id="player-resource-fill" class="bar-fill" style="width: 100%;"></div>
|
||||
<span id="player-resource-text" class="bar-text">150 / 150</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-area" id="player-status-area">
|
||||
<i class="fas fa-shield-alt icon-status"></i> <strong>Статус:</strong> <span id="player-status">Готов(а)</span>
|
||||
</div>
|
||||
<div class="effects-area" id="player-effects">
|
||||
<div class="effect-category">
|
||||
<i class="fas fa-shield-alt icon-effects-buff"></i>
|
||||
<strong>Усиления:</strong>
|
||||
<span class="effect-list player-buffs">Нет</span>
|
||||
</div>
|
||||
<div class="effect-category">
|
||||
<i class="fas fa-skull-crossbones icon-effects-debuff"></i>
|
||||
<strong>Ослабления:</strong>
|
||||
<span class="effect-list player-debuffs">Нет</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="controls-panel" class="controls-panel-new">
|
||||
<h3 id="turn-indicator">Ход: Игрок 1</h3>
|
||||
<div id="turn-timer-container" class="turn-timer-display">
|
||||
<i class="fas fa-hourglass-half"></i> Время на ход: <span id="turn-timer">--</span>
|
||||
</div>
|
||||
<div class="controls-layout">
|
||||
<div class="control-group basic-actions">
|
||||
<button id="button-attack" class="action-button basic" data-action="BASIC_ATTACK" title="Базовая атака"><i class="fas fa-shoe-prints"></i> Атака</button>
|
||||
<button id="button-block" class="action-button basic" data-action="BLOCK" title="Встать в защиту (Завершает ход!)" disabled><i class="fas fa-shield-alt"></i> Защита</button>
|
||||
</div>
|
||||
<div class="control-group ability-list">
|
||||
<h4><i class="fas fa-book-sparkles"></i> Способности</h4>
|
||||
<div id="abilities-grid" class="abilities-grid">
|
||||
<p class="placeholder-text">Загрузка способностей...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div> <!-- Конец player-column -->
|
||||
|
||||
<!-- Колонка Противника (Панель 2 в UI) -->
|
||||
<div class="opponent-column">
|
||||
<section id="opponent-panel" class="fighter-panel">
|
||||
<div class="panel-header">
|
||||
<div class="character-visual">
|
||||
<img src="images/balard_avatar.jpg" alt="Аватар игрока 2" class="avatar-image opponent-avatar">
|
||||
</div>
|
||||
<h2 id="opponent-name" class="fighter-name">
|
||||
<i class="fas fa-khanda icon-opponent"></i> Балард
|
||||
</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="stat-bar-container health">
|
||||
<div class="bar-icon"><i class="fas fa-heart"></i></div>
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar health-bar" id="opponent-hp-bar">
|
||||
<div id="opponent-hp-fill" class="bar-fill" style="width: 100%;"></div>
|
||||
<span id="opponent-hp-text" class="bar-text">140 / 140</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-container resource stamina">
|
||||
<div class="bar-icon"><i class="fas fa-fire-alt"></i></div>
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar resource-bar" id="opponent-resource-bar">
|
||||
<div id="opponent-resource-fill" class="bar-fill" style="width: 100%;"></div>
|
||||
<span id="opponent-resource-text" class="bar-text">100 / 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-area" id="opponent-status-area">
|
||||
<i class="fas fa-shield-alt icon-status"></i> <strong>Статус:</strong> <span id="opponent-status">Готов(а)</span>
|
||||
</div>
|
||||
<div class="effects-area" id="opponent-effects">
|
||||
<div class="effect-category">
|
||||
<i class="fas fa-shield-alt icon-effects-buff"></i>
|
||||
<strong>Усиления:</strong>
|
||||
<span class="effect-list opponent-buffs">Нет</span>
|
||||
</div>
|
||||
<div class="effect-category">
|
||||
<i class="fas fa-skull-crossbones icon-effects-debuff"></i>
|
||||
<strong>Ослабления:</strong>
|
||||
<span class="effect-list opponent-debuffs">Нет</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="log-panel" class="battle-log-new">
|
||||
<h3><i class="fas fa-scroll"></i> Лог Боя</h3>
|
||||
<ul id="log-list">
|
||||
<li class="log-system">Ожидание подключения к серверу...</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div> <!-- Конец opponent-column -->
|
||||
</main> <!-- Конец .battle-arena-container -->
|
||||
|
||||
<!-- Модальное окно конца игры -->
|
||||
<div id="game-over-screen" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h2 id="result-message">Победа!</h2>
|
||||
<button id="return-to-menu-button" class="modal-action-button">
|
||||
<i class="fas fa-arrow-left"></i> В меню выбора игры
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- Конец .game-wrapper -->
|
||||
<script>
|
||||
const base_path = "<%=base_path%>"
|
||||
</script>
|
||||
<!-- Библиотека Socket.IO клиента -->
|
||||
<script src="<%=base_path%>/socket.io/socket.io.js"></script>
|
||||
|
||||
<!-- Ваш скрипт для UI, который может создавать глобальные объекты или функции -->
|
||||
<!-- Он должен быть загружен до main.js, если main.js ожидает window.gameUI -->
|
||||
<script src="./js/ui.js"></script>
|
||||
|
||||
<!-- Ваш основной клиентский скрипт, теперь как модуль -->
|
||||
<script type="module" src="./js/main.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user