From 12d85b8385d1555381ddf1de191cc0e52c58329d Mon Sep 17 00:00:00 2001 From: PsiMagistr Date: Sun, 25 May 2025 18:34:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=8D=D1=84=D1=84=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B8=D0=BB=D0=B0=20=D0=BF=D1=80=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/game/instance/GameInstance.js | 109 +++++++-------- server/game/logic/combatLogic.js | 195 +++++++++++++++++---------- 2 files changed, 172 insertions(+), 132 deletions(-) diff --git a/server/game/instance/GameInstance.js b/server/game/instance/GameInstance.js index b925e9a..c479228 100644 --- a/server/game/instance/GameInstance.js +++ b/server/game/instance/GameInstance.js @@ -12,9 +12,9 @@ class GameInstance { this.mode = mode; this.gameManager = gameManager; - this.players = {}; // { socketId: { id (role), socket, chosenCharacterKey, identifier, isTemporarilyDisconnected }} - this.playerSockets = {}; // { roleId: socket } -> для быстрого доступа к сокету по роли - this.playerCount = 0; // Только активные, не isTemporarilyDisconnected + this.players = {}; + this.playerSockets = {}; + this.playerCount = 0; this.gameState = null; this.aiOpponent = (mode === 'ai'); @@ -24,8 +24,8 @@ class GameInstance { this.opponentCharacterKey = null; this.ownerIdentifier = null; - this.reconnectTimers = {}; // { roleId: { timerId, updateIntervalId, startTimeMs, durationMs } } - this.pausedTurnState = null; // { remainingTime: number, forPlayerRoleIsPlayer: boolean, isAiCurrentlyMoving: boolean } + this.reconnectTimers = {}; + this.pausedTurnState = null; this.turnTimer = new TurnTimer( GAME_CONFIG.TURN_DURATION_MS, @@ -49,11 +49,9 @@ class GameInstance { _sayTaunt(characterState, opponentCharacterKey, triggerType, subTriggerOrContext = null, contextOverrides = {}) { if (!characterState || !characterState.characterKey) { - // console.warn(`[Taunt ${this.id}] _sayTaunt: Caller character or characterKey is missing. Speaker: ${characterState?.name}, Trigger: ${triggerType}`); return; } if (!opponentCharacterKey) { - // console.warn(`[Taunt ${this.id}] _sayTaunt: Opponent characterKey is missing for ${characterState.name}. Trigger: ${triggerType}`); return; } if (!gameLogic.getRandomTaunt) { @@ -61,7 +59,6 @@ class GameInstance { return; } if (!this.gameState) { - // console.warn(`[Taunt ${this.id}] _sayTaunt: this.gameState is null. Speaker: ${characterState.name}, Trigger: ${triggerType}`); return; } @@ -87,7 +84,6 @@ class GameInstance { const opponentFullData = dataUtils.getCharacterData(opponentCharacterKey); if (!opponentFullData) { - // console.warn(`[Taunt ${this.id}] _sayTaunt: Could not get full data for opponent ${opponentCharacterKey} when ${characterState.name} tries to taunt.`); return; } @@ -106,6 +102,7 @@ class GameInstance { } addPlayer(socket, chosenCharacterKey = 'elena', identifier) { + // ... (Код addPlayer без изменений из предыдущего вашего файла) ... console.log(`[GameInstance ${this.id}] addPlayer attempt. Socket: ${socket.id}, CharKey: ${chosenCharacterKey}, Identifier: ${identifier}`); const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier); @@ -115,30 +112,29 @@ class GameInstance { console.warn(`[GameInstance ${this.id}] Player ${identifier} trying to (re)join an already finished game. Emitting gameError.`); socket.emit('gameError', { message: 'Эта игра уже завершена.' }); this.gameManager._cleanupGame(this.id, `rejoin_attempt_to_finished_game_${identifier}`); - return false; // Изменили возврат на boolean, как ожидает GameManager + return false; } if (existingPlayerByIdentifier.isTemporarilyDisconnected) { return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket); } socket.emit('gameError', { message: 'Вы уже находитесь в этой игре. Попробуйте обновить страницу.' }); - return false; // Изменили возврат + return false; } if (Object.keys(this.players).length >= 2 && this.playerCount >=2) { socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); - return false; // Изменили возврат + return false; } let assignedPlayerId; let actualCharacterKey = chosenCharacterKey || 'elena'; if (this.mode === 'ai') { - if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { // Проверяем, занят ли слот игрока-человека + if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); - return false; // Изменили возврат + return false; } assignedPlayerId = GAME_CONFIG.PLAYER_ID; - // this.ownerIdentifier устанавливается в GameManager } else { if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) { assignedPlayerId = GAME_CONFIG.PLAYER_ID; @@ -151,7 +147,7 @@ class GameInstance { } } else { socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' }); - return false; // Изменили возврат + return false; } } @@ -182,10 +178,10 @@ class GameInstance { const charData = dataUtils.getCharacterData(actualCharacterKey); console.log(`[GameInstance ${this.id}] Player ${identifier} (Socket: ${socket.id}) added as ${assignedPlayerId} with char ${charData?.baseStats?.name || actualCharacterKey}. Active players: ${this.playerCount}. Owner: ${this.ownerIdentifier}`); - return true; // Успешное добавление + return true; } - removePlayer(socketId, reason = "unknown_reason_for_removal") { + removePlayer(socketId, reason = "unknown_reason_for_removal") { /* ... Код без изменений ... */ const playerInfo = this.players[socketId]; if (playerInfo) { const playerRole = playerInfo.id; @@ -222,8 +218,7 @@ class GameInstance { } } } - - handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) { + handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) { /* ... Код без изменений, вызывает turnTimer.pause() ... */ console.log(`[GameInstance ${this.id}] handlePlayerPotentiallyLeft for role ${playerIdRole}, id ${identifier}, char ${characterKey}`); const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); @@ -241,8 +236,8 @@ class GameInstance { 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) { // Уведомляем только если другой активен + const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole); + if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) { otherSocket.emit('opponentDisconnected', { disconnectedPlayerId: playerIdRole, disconnectedCharacterName: disconnectedName, @@ -280,7 +275,7 @@ class GameInstance { this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration }; } - handlePlayerReconnected(playerIdRole, newSocket) { + handlePlayerReconnected(playerIdRole, newSocket) { /* ... Код без изменений, вызывает turnTimer.resume() или start() ... */ const identifier = newSocket.userData?.userId; console.log(`[GameInstance ${this.id}] handlePlayerReconnected for role ${playerIdRole}, id ${identifier}, newSocket ${newSocket.id}`); @@ -356,11 +351,11 @@ class GameInstance { newSocket.emit('gameError', {message: "Вы уже активно подключены с другой сессии."}); return false; } if (!this.gameState) { if (!this.initializeGame()) {this._handleCriticalError('reconnect_same_socket_no_gs','GS null on same socket'); return false;} } - const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); /* ... как выше ... */ + const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; let oCharKey = this.gameState?.[oppRoleKey]?.characterKey || (playerIdRole === GAME_CONFIG.PLAYER_ID ? this.opponentCharacterKey : this.playerCharacterKey); const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; - newSocket.emit('gameStarted', { /* ... как выше ... */ + newSocket.emit('gameStarted', { gameId: this.id, yourPlayerId: playerIdRole, initialGameState: this.gameState, playerBaseStats: pData?.baseStats, opponentBaseStats: oData?.baseStats, playerAbilities: pData?.abilities, opponentAbilities: oData?.abilities, @@ -380,18 +375,16 @@ class GameInstance { clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); } delete this.reconnectTimers[playerIdRole]; - // console.log(`[GameInstance ${this.id}] Reconnect timer & interval for role ${playerIdRole} cleared.`); } } clearAllReconnectTimers() { /* ... Код без изменений ... */ - // console.log(`[GameInstance ${this.id}] Clearing ALL reconnect timers.`); for (const roleId in this.reconnectTimers) { this.clearReconnectTimer(roleId); } } isGameEffectivelyPaused() { /* ... Код без изменений ... */ if (this.mode === 'pvp') { - if (this.playerCount < 2 && Object.keys(this.players).length > 0) { // Если игроков меньше двух, но хотя бы один есть + 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)) { @@ -412,7 +405,7 @@ class GameInstance { const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected); if (this.mode === 'ai') { - if (!p1Entry) { this._handleCriticalError('init_ai_no_active_player_v2', 'AI game init: Human player not found or not active.'); return false; } + if (!p1Entry) { this._handleCriticalError('init_ai_no_active_player_v3', 'AI game init: Human player not found or not active.'); return false; } this.playerCharacterKey = p1Entry.chosenCharacterKey; this.opponentCharacterKey = 'balard'; } else { @@ -420,8 +413,8 @@ class GameInstance { this.opponentCharacterKey = p2Entry ? p2Entry.chosenCharacterKey : null; if (this.playerCount === 2 && (!this.playerCharacterKey || !this.opponentCharacterKey)) { - console.error(`[GameInstance ${this.id}] PvP init error: playerCount is 2, but keys not set. P1Key: ${this.playerCharacterKey}, P2Key: ${this.opponentCharacterKey}. P1Info: ${!!p1Entry}, P2Info: ${!!p2Entry}`); - this._handleCriticalError('init_pvp_char_key_missing_v2', `PvP init: playerCount is 2, but a charKey is missing.`); + console.error(`[GameInstance ${this.id}] PvP init error: playerCount is 2, but keys not set. P1Key: ${this.playerCharacterKey}, P2Key: ${this.opponentCharacterKey}.`); + this._handleCriticalError('init_pvp_char_key_missing_v3', `PvP init: playerCount is 2, but a charKey is missing.`); return false; } } @@ -430,10 +423,10 @@ class GameInstance { const opponentData = this.opponentCharacterKey ? dataUtils.getCharacterData(this.opponentCharacterKey) : null; const isPlayerSlotFilledAndActive = !!playerData; - const isOpponentSlotFilledAndActive = !!(opponentData && (this.mode === 'ai' || p2Entry)); + const isOpponentSlotFilledAndActive = !!(opponentData && (this.mode === 'ai' || p2Entry)); // p2Entry будет null если его нет if (this.mode === 'ai' && (!isPlayerSlotFilledAndActive || !isOpponentSlotFilledAndActive) ) { - this._handleCriticalError('init_ai_data_fail_gs_v2', 'AI game init: Failed to load player or AI data for gameState (active check).'); return false; + this._handleCriticalError('init_ai_data_fail_gs_v3', 'AI game init: Failed to load player or AI data for gameState (active check).'); return false; } this.logBuffer = []; @@ -451,11 +444,8 @@ class GameInstance { gameMode: this.mode }; - if (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) { - this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); - } - - console.log(`[GameInstance ${this.id}] Game state initialized. Player: ${this.gameState.player.name} (Key: ${this.playerCharacterKey}). Opponent: ${this.gameState.opponent.name} (Key: ${this.opponentCharacterKey}). Ready for start: ${isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive}`); + // Не добавляем "Новая битва начинается" здесь, это будет в startGame, когда точно оба готовы + console.log(`[GameInstance ${this.id}] Game state initialized. Player: ${this.gameState.player.name}. Opponent: ${this.gameState.opponent.name}. Ready for start if both active: ${isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive}`); return isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive; } @@ -484,30 +474,35 @@ class GameInstance { console.log(`[GameInstance ${this.id}] Start game deferred: game effectively paused.`); return; } + // Перед стартом игры, убедимся, что gameState полностью инициализирован и содержит обоих персонажей. + // initializeGame должен был это сделать, но на всякий случай. if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) { - console.warn(`[GameInstance ${this.id}] startGame: GS or char keys not fully initialized. PKey: ${this.gameState?.player?.characterKey}, OKey: ${this.gameState?.opponent?.characterKey}. Attempting re-init.`); + console.warn(`[GameInstance ${this.id}] startGame: gameState or character keys not fully initialized. Attempting re-init one last time.`); if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) { - this._handleCriticalError('start_game_reinit_failed_sg_v3', 'Re-init before start failed or keys still missing.'); + this._handleCriticalError('start_game_reinit_failed_sg_v4', 'Re-initialization before start failed or keys still missing in gameState.'); return; } } - console.log(`[GameInstance ${this.id}] Starting game. Player in GS: ${this.gameState.player.name}, Opponent in GS: ${this.gameState.opponent.name}`); + console.log(`[GameInstance ${this.id}] Starting game. Player in GS: ${this.gameState.player.name} (${this.playerCharacterKey}), Opponent in GS: ${this.gameState.opponent.name} (${this.opponentCharacterKey})`); const pData = dataUtils.getCharacterData(this.playerCharacterKey); const oData = dataUtils.getCharacterData(this.opponentCharacterKey); if (!pData || !oData) { - this._handleCriticalError('start_char_data_fail_sg_v4', `Failed to load char data at game start. P: ${!!pData}, O: ${!!oData}`); + this._handleCriticalError('start_char_data_fail_sg_v5', `Failed to load character data at game start. PData: ${!!pData}, OData: ${!!oData}`); return; } + // Добавляем лог о начале битвы здесь, когда уверены, что оба игрока есть + this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); + // --- Начальные насмешки --- + // Убеждаемся, что объекты gameState.player и gameState.opponent существуют и имеют characterKey if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) { - // Убедимся, что gameState.player и .opponent существуют для передачи в _sayTaunt - if (this.gameState.player && this.gameState.opponent) { - this._sayTaunt(this.gameState.player, this.gameState.opponent.characterKey, 'onBattleState', 'start'); - this._sayTaunt(this.gameState.opponent, this.gameState.player.characterKey, 'onBattleState', 'start'); - } + 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}] Could not say start taunts during startGame, gameState actors/keys not fully ready. GSPlayer: ${this.gameState.player?.name}, GSOpponent: ${this.gameState.opponent?.name}`); } const initialLog = this.consumeLogBuffer(); @@ -542,7 +537,6 @@ class GameInstance { if (!actingPlayerInfo || !actingPlayerInfo.socket) { console.error(`[GameInstance ${this.id}] Action from unknown or socketless identifier ${identifier}.`); return; } - // const requestingSocketId = actingPlayerInfo.socket.id; if (this.isGameEffectivelyPaused()) { actingPlayerInfo.socket.emit('gameError', {message: "Действие невозможно: игра на паузе."}); @@ -574,16 +568,11 @@ class GameInstance { 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; - const delayedBuff = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK) && !eff.justCast); - if (delayedBuff) { - const manaRegenConfig = GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN || 0; - const regen = Math.min(manaRegenConfig, attackerData.baseStats.maxResource - attackerState.currentResource); - if (regen > 0) { - attackerState.currentResource = Math.round(attackerState.currentResource + regen); - this.addToLog(`🌿 ${attackerState.name} восстанавливает ${regen} ${attackerState.resourceName} от "${delayedBuff.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); - } - delayedBuff.turnsLeft = 0; - } + // --- ИСПРАВЛЕНИЕ ДЛЯ СИЛЫ ПРИРОДЫ --- + // Логика бонуса (реген маны) теперь полностью внутри performAttack в combatLogic.js. + // GameInstance НЕ ДОЛЖЕН здесь "потреблять" эффект (обнулять turnsLeft или удалять). + // Длительность эффекта управляется в effectsLogic.js. + // --- КОНЕЦ ИСПРАВЛЕНИЯ --- } else if (actionData.actionType === 'ability' && actionData.abilityId) { const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId); if (!ability) { @@ -823,7 +812,7 @@ class GameInstance { this.gameManager._cleanupGame(this.id, "player_surrendered"); } - handleTurnTimeout() { /* ... Код без изменений, с вызовом _sayTaunt ... */ + handleTurnTimeout() { /* ... Код без изменений ... */ if (!this.gameState || this.gameState.isGameOver) return; console.log(`[GameInstance ${this.id}] Turn timeout occurred.`); const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; @@ -840,7 +829,7 @@ class GameInstance { 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_${result.reason}`); } - _handleCriticalError(reasonCode, logMessage) { /* ... Код без изменений, с переводом логов ... */ + _handleCriticalError(reasonCode, logMessage) { /* ... Код без изменений ... */ console.error(`[GameInstance ${this.id}] CRITICAL ERROR: ${logMessage} (Code: ${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 }; diff --git a/server/game/logic/combatLogic.js b/server/game/logic/combatLogic.js index f955f5a..c2640aa 100644 --- a/server/game/logic/combatLogic.js +++ b/server/game/logic/combatLogic.js @@ -1,15 +1,13 @@ // /server/game/logic/combatLogic.js -// Предполагается, что gameLogic.getRandomTaunt и dataUtils будут доступны -// через параметры, передаваемые из GameInstance, или через объект gameLogic. -// const GAME_CONFIG_STATIC = require('../../core/config'); // Можно, если нужно +// GAME_CONFIG и dataUtils будут передаваться в функции как параметры. /** * Обрабатывает базовую атаку одного бойца по другому. * @param {object} attackerState - Состояние атакующего бойца из gameState. * @param {object} defenderState - Состояние защищающегося бойца из gameState. - * @param {object} attackerBaseStats - Базовые статы атакующего. - * @param {object} defenderBaseStats - Базовые статы защищающегося. + * @param {object} attackerBaseStats - Базовые статы атакующего (из dataUtils.getCharacterBaseStats). + * @param {object} defenderBaseStats - Базовые статы защищающегося (из dataUtils.getCharacterBaseStats). * @param {object} currentGameState - Текущее полное состояние игры. * @param {function} addToLogCallback - Функция для добавления сообщений в лог игры. * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). @@ -24,35 +22,97 @@ function performAttack( currentGameState, addToLogCallback, configToUse, - dataUtils, // Добавлен dataUtils - getRandomTauntFunction // Добавлена функция для насмешек + 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}`); + + // 1. Применяем бонус к урону (если он есть в конфиге/данных эффекта) + let damageBonus = 0; + if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH) { + // Предположим, что Сила Природы НЕ дает прямого бонуса к урону атаки, а только реген маны. + // Если бы давала, то: damageBonus = configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS || 0; + } else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) { + // Аналогично для Альмагест + // damageBonus = configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS || 0; + } + if (damageBonus > 0) { + damage += damageBonus; + attackBonusesLog.push(`урон +${damageBonus} от "${delayedAttackBuff.name}"`); + } + + // 2. Восстановление ресурса (для Силы Природы / Усиления Тьмой) + // Этот бонус (восстановление ресурса) срабатывает при каждой атаке, пока эффект активен + 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 + ); + } + // Не добавляем в attackBonusesLog, т.к. это отдельное событие, уже залогированное + } + } + + // Важно: НЕ МЕНЯЕМ здесь delayedAttackBuff.turnsLeft и НЕ УДАЛЯЕМ эффект. + // Его длительность будет уменьшаться в effectsLogic.processEffects каждый ход владельца эффекта. + } + // --- КОНЕЦ ПРОВЕРКИ И ПРИМЕНЕНИЯ ОТЛОЖЕННОГО БАФФА АТАКИ --- + + // Проверка на блок if (defenderState.isBlocking) { const initialDamage = damage; damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); wasBlocked = true; if (addToLogCallback) { - addToLogCallback( - `🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`, - configToUse.LOG_TYPE_BLOCK - ); + let blockLogMsg = `🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`; + if (attackBonusesLog.length > 0) { + blockLogMsg += ` (${attackBonusesLog.join(', ')})`; + } + addToLogCallback(blockLogMsg, configToUse.LOG_TYPE_BLOCK); } } else { if (addToLogCallback) { - addToLogCallback( - `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`, - configToUse.LOG_TYPE_DAMAGE - ); + let hitLogMsg = `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`; + if (attackBonusesLog.length > 0) { + hitLogMsg += ` (${attackBonusesLog.join(', ')})`; + } + addToLogCallback(hitLogMsg, configToUse.LOG_TYPE_DAMAGE); } } - const actualDamageDealt = defenderState.currentHp - Math.max(0, Math.round(defenderState.currentHp - damage)); + // Применяем урон, убеждаемся, что HP не ниже нуля + const actualDamageDealtToHp = defenderState.currentHp - Math.max(0, Math.round(defenderState.currentHp - damage)); // Сколько HP реально отнято defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage)); // --- Насмешка от защищающегося (defenderState) в ответ на атаку --- @@ -60,20 +120,20 @@ function performAttack( let reactionTauntTrigger = null; if (wasBlocked) { reactionTauntTrigger = 'onOpponentAttackBlocked'; - } else if (actualDamageDealt > 0) { // Если урон прошел + } else if (actualDamageDealtToHp > 0) { reactionTauntTrigger = 'onOpponentAttackHit'; } - // Можно добавить 'onOpponentAttackMissed' если actualDamageDealt === 0 и !wasBlocked + // Можно добавить еще условие для промаха, если урон = 0 и не было блока if (reactionTauntTrigger) { - const attackerFullData = dataUtils.getCharacterData(attackerState.characterKey); - if (attackerFullData) { // Убедимся, что данные атакующего есть + const attackerFullDataForTaunt = dataUtils.getCharacterData(attackerState.characterKey); + if (attackerFullDataForTaunt) { const reactionTaunt = getRandomTauntFunction( - defenderState.characterKey, // Кто говорит (защищающийся) - reactionTauntTrigger, // Триггер (onOpponentAttackBlocked или onOpponentAttackHit) - {}, // Контекст (пока пустой для этих реакций) + defenderState.characterKey, + reactionTauntTrigger, + {}, configToUse, - attackerFullData, // Оппонент для говорящего - это атакующий + attackerFullDataForTaunt, // Оппонент для говорящего (защитника) - это атакующий currentGameState ); if (reactionTaunt && reactionTaunt !== "(Молчание)") { @@ -97,7 +157,7 @@ function performAttack( * @param {object} configToUse - Конфигурация игры. * @param {object} dataUtils - Утилиты для доступа к данным игры. * @param {function} getRandomTauntFunction - Функция gameLogic.getRandomTaunt. - * @param {function} checkIfActionWasSuccessfulFunction - Функция для проверки успеха действия (для контекстных насмешек). + * @param {function|null} checkIfActionWasSuccessfulFunction - (Опционально) Функция для проверки успеха действия для контекстных насмешек. */ function applyAbilityEffect( ability, @@ -108,14 +168,13 @@ function applyAbilityEffect( currentGameState, addToLogCallback, configToUse, - dataUtils, // Добавлен dataUtils - getRandomTauntFunction, // Добавлена функция для насмешек - checkIfActionWasSuccessfulFunction // Добавлена функция для проверки успеха + dataUtils, + getRandomTauntFunction, + checkIfActionWasSuccessfulFunction // Пока не используется активно, outcome определяется внутри ) { - let abilityApplicationSucceeded = true; // Флаг для отслеживания, применилась ли способность успешно (для контекста насмешек) - let actionOutcomeForTaunt = null; // 'success' или 'fail' для способностей типа безмолвия + 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)); @@ -125,7 +184,7 @@ function applyAbilityEffect( if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет "${ability.name}" и восстанавливает ${actualHeal} HP!`, configToUse.LOG_TYPE_HEAL); } else { if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения.`, configToUse.LOG_TYPE_INFO); - abilityApplicationSucceeded = false; // Можно считать это "неудачей" для реакции, если хотите + abilityApplicationSucceeded = false; } break; @@ -142,21 +201,21 @@ function applyAbilityEffect( if (addToLogCallback && !wasAbilityBlocked) { addToLogCallback(`💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!`, configToUse.LOG_TYPE_DAMAGE); } - if (damage <= 0 && !wasAbilityBlocked) abilityApplicationSucceeded = false; // Если урона не было (например, из-за эффектов) + if (damage <= 0 && !wasAbilityBlocked) abilityApplicationSucceeded = false; break; case configToUse.ACTION_TYPE_BUFF: let effectDescriptionBuff = ability.description; if (typeof ability.descriptionFunction === 'function') { - effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); // targetBaseStats здесь оппонент кастера + effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); } casterState.activeEffects.push({ id: ability.id, name: ability.name, description: effectDescriptionBuff, type: ability.type, duration: ability.duration, - turnsLeft: ability.duration, + turnsLeft: ability.duration, // Эффект начнет тикать в конце текущего хода кастера grantsBlock: !!ability.grantsBlock, - isDelayed: !!ability.isDelayed, - justCast: true + isDelayed: !!ability.isDelayed, // Важно для "Силы Природы" + justCast: true // Помечаем, что только что наложен }); if (ability.grantsBlock) require('./effectsLogic').updateBlockingStatus(casterState); if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!`, configToUse.LOG_TYPE_EFFECT); @@ -164,8 +223,6 @@ function applyAbilityEffect( case configToUse.ACTION_TYPE_DISABLE: if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) { - // ... (логика полного безмолвия как у вас) - // Установите actionOutcomeForTaunt = 'success' или 'fail' если нужно const effectIdFullSilence = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest'; if (!targetState.activeEffects.some(e => e.id === effectIdFullSilence)) { targetState.activeEffects.push({ @@ -178,14 +235,13 @@ function applyAbilityEffect( } else { if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO); abilityApplicationSucceeded = false; - actionOutcomeForTaunt = 'fail'; // Считаем провалом, если уже активен + 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'; // Устанавливаем для контекста насмешки + actionOutcomeForTaunt = success ? 'success' : 'fail'; if (success) { - // ... (ваша логика наложения безмолвия на способность) const targetAbilitiesList = dataUtils.getCharacterAbilities(targetState.characterKey); const availableAbilitiesToSilence = targetAbilitiesList.filter(pa => !targetState.disabledAbilities?.some(d => d.abilityId === pa.id) && @@ -194,7 +250,7 @@ function applyAbilityEffect( 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.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); // +1 т.к. уменьшится в конце хода цели targetState.activeEffects.push({ id: `playerSilencedOn_${abilityToSilence.id}`, name: `Безмолвие: ${abilityToSilence.name}`, description: `Способность "${abilityToSilence.name}" временно недоступна.`, @@ -204,7 +260,7 @@ function applyAbilityEffect( 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'; // Провал, если нечего глушить + actionOutcomeForTaunt = 'fail'; } } else { if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!`, configToUse.LOG_TYPE_INFO); @@ -213,8 +269,6 @@ function applyAbilityEffect( break; case configToUse.ACTION_TYPE_DEBUFF: - // ... (логика дебаффа как у вас) - // Установите actionOutcomeForTaunt если нужно const effectIdDebuff = 'effect_' + ability.id; if (!targetState.activeEffects.some(e => e.id === effectIdDebuff)) { let effectDescriptionDebuff = ability.description; @@ -237,7 +291,6 @@ function applyAbilityEffect( break; case configToUse.ACTION_TYPE_DRAIN: - // ... (логика дрейна как у вас) if (casterState.characterKey === 'balard') { let manaDrained = 0; let healthGained = 0; let damageDealtDrain = 0; if (ability.powerDamage > 0) { @@ -262,7 +315,7 @@ function applyAbilityEffect( else if (damageDealtDrain > 0) logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; else logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; if (addToLogCallback) addToLogCallback(logMsgDrain, (manaDrained > 0 || damageDealtDrain > 0) ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO); - if (manaDrained <= 0 && damageDealtDrain <=0) abilityApplicationSucceeded = false; + if (manaDrained <= 0 && damageDealtDrain <=0 && healthGained <=0) abilityApplicationSucceeded = false; } break; @@ -273,32 +326,37 @@ function applyAbilityEffect( } // --- Насмешка от цели (targetState) в ответ на применение способности --- - // Вызываем только если способность не была нацелена на самого себя и успешно применилась (или как вы решите) - if (getRandomTauntFunction && dataUtils && casterState.id !== targetState.id && abilityApplicationSucceeded) { - const casterFullData = dataUtils.getCharacterData(casterState.characterKey); - if (casterFullData) { // Убедимся, что данные кастера есть + // Вызываем только если способность не была нацелена на самого себя + if (getRandomTauntFunction && dataUtils && casterState.id !== targetState.id) { + const casterFullDataForTaunt = dataUtils.getCharacterData(casterState.characterKey); + if (casterFullDataForTaunt) { let tauntContext = { abilityId: ability.id }; - if (actionOutcomeForTaunt) { // Если для этой способности важен исход (success/fail) + // Если для этой способности был определен исход (например, для безмолвия Баларда), используем его + if (actionOutcomeForTaunt) { tauntContext.outcome = actionOutcomeForTaunt; - } else if (checkIfActionWasSuccessfulFunction) { - // Если есть общая функция проверки успеха (менее специфично, чем actionOutcomeForTaunt) - // Это пример, вам нужно реализовать checkIfActionWasSuccessfulFunction - // const success = checkIfActionWasSuccessfulFunction(ability, casterState, targetState, currentGameState, configToUse); - // tauntContext.outcome = success ? 'success' : 'fail'; } + // Здесь можно было бы вызвать checkIfActionWasSuccessfulFunction, если бы он был и нужен для других способностей + // else if (checkIfActionWasSuccessfulFunction) { + // const success = checkIfActionWasSuccessfulFunction(ability, casterState, targetState, currentGameState, configToUse); + // tauntContext.outcome = success ? 'success' : 'fail'; + // } + // Вызываем насмешку, только если основное применение способности не считается полным провалом (опционально) + // Либо всегда вызываем, и пусть tauntLogic решает, есть ли реакция на "провальную" абилку + // if (abilityApplicationSucceeded || actionOutcomeForTaunt === 'fail') { // Например, реагируем даже на провал Эха Безмолвия const reactionTaunt = getRandomTauntFunction( targetState.characterKey, // Кто говорит (цель способности) 'onOpponentAction', // Триггер - tauntContext, // Контекст: ID способности и исход (если нужен) + tauntContext, // Контекст: ID способности и, возможно, outcome configToUse, - casterFullData, // Оппонент для говорящего - это кастер + casterFullDataForTaunt, // Оппонент для говорящего - это кастер currentGameState ); if (reactionTaunt && reactionTaunt !== "(Молчание)") { addToLogCallback(`${targetState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO); } + // } } } } @@ -306,16 +364,10 @@ function applyAbilityEffect( /** * Проверяет валидность использования способности. - * @param {object} ability - Объект способности. - * @param {object} casterState - Состояние кастера. - * @param {object} targetState - Состояние цели. - * @param {object} configToUse - Конфигурация игры. - * @returns {{isValid: boolean, reason: string|null}} Результат проверки. */ function checkAbilityValidity(ability, casterState, targetState, configToUse) { - // ... (существующий код checkAbilityValidity без изменений) ... + // ... (код checkAbilityValidity без изменений, как вы предоставили) ... if (!ability) return { isValid: false, reason: "Способность не найдена." }; - if (casterState.currentResource < ability.cost) { return { isValid: false, reason: `${casterState.name} пытается применить "${ability.name}", но не хватает ${casterState.resourceName}!` }; } @@ -330,22 +382,21 @@ function checkAbilityValidity(ability, casterState, targetState, configToUse) { return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке.` }; } } - const isCasterFullySilenced = casterState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0); const isAbilitySpecificallySilenced = casterState.disabledAbilities?.some(dis => dis.abilityId === ability.id && dis.turnsLeft > 0); if (isCasterFullySilenced || isAbilitySpecificallySilenced) { return { isValid: false, reason: `${casterState.name} не может использовать способности из-за безмолвия!` }; } - if (ability.type === configToUse.ACTION_TYPE_BUFF && casterState.activeEffects.some(e => e.id === ability.id)) { - return { isValid: false, reason: `Эффект "${ability.name}" уже активен!` }; + // Исключение для Силы Природы и Усиления Тьмой - их можно обновлять, если isDelayed + if (!ability.isDelayed) { + return { isValid: false, reason: `Эффект "${ability.name}" уже активен!` }; + } } - const isTargetedDebuff = ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF; if (isTargetedDebuff && targetState.activeEffects.some(e => e.id === 'effect_' + ability.id)) { return { isValid: false, reason: `Эффект "${ability.name}" уже наложен на ${targetState.name}!` }; } - return { isValid: true, reason: null }; }