// /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'), }, 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'), }; 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]; // 'player' или 'opponent' const config = window.GAME_CONFIG || {}; // Базовая проверка наличия необходимых элементов и данных if (!elements || !elements.hpFill || !elements.hpText || !elements.resourceFill || !elements.resourceText || !elements.status || !fighterState || !fighterBaseStats) { // Если панель должна быть видима, но нет данных, можно ее скрыть или показать плейсхолдер if (elements && elements.panel && elements.panel.style.display !== 'none') { // console.warn(`updateFighterPanelUI: Нет данных для видимой панели ${panelRole}.`); // elements.panel.style.opacity = '0.5'; // Пример: сделать полупрозрачной, если нет данных } // ВАЖНО: Очистить содержимое панели, если данных нет. if (elements) { if(elements.name) elements.name.innerHTML = (panelRole === 'player') ? ' Ожидание данных...' : ' Ожидание игрока...'; 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-player'; } else if (characterKey === 'almagest') { iconClass = 'fa-staff-aesculapius icon-almagest'; } else if (characterKey === 'balard') { iconClass = 'fa-khanda icon-opponent'; } else { /* console.warn(`updateFighterPanelUI: Неизвестный characterKey "${characterKey}" для иконки имени.`); */ } let nameHtml = ` ${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}`; // Ресурс не округляем // Обновление типа ресурса и иконки (mana/stamina/dark-energy) 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'; } // или fa-wand-magic-sparkles, fa-star-half-alt и т.д. else { console.warn(`updateFighterPanelUI: Unknown resource name "${fighterBaseStats.resourceName}" for icon/color.`); iconClass = 'fa-question-circle'; } 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)'; } else { console.warn(`updateFighterPanelUI: Unknown character key "${fighterBaseStats.characterKey}" for panel border color.`); } let glowColorVar = 'rgba(0, 0, 0, 0.4)'; // Базовая тень if (fighterBaseStats.characterKey === 'elena') glowColorVar = 'var(--panel-glow-player)'; // В твоем CSS --panel-glow-opponent используется для обоих Баларда и Альмагест else if (fighterBaseStats.characterKey === 'almagest' || 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)`; } } /** * Генерирует HTML для списка эффектов. * @param {Array} effectsArray - Массив объектов эффектов, УЖЕ отфильтрованных и отсортированных. * @returns {string} HTML-строка для отображения списка эффектов. */ function generateEffectsHTML(effectsArray) { const config = window.GAME_CONFIG || {}; if (!effectsArray || effectsArray.length === 0) return 'Нет'; // ВАЖНО: Сортировка теперь выполняется ВНЕ этой функции (в updateEffectsUI) 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) { // Эффекты полного безмолвия, заглушения абилок или типа 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) { // Явные баффы (например, усиление атаки) effectClasses += ' effect-buff'; // Класс для усилений (зеленый) } else if (eff.type === config.ACTION_TYPE_HEAL) { // Эффекты лечения (HoT) effectClasses += ' effect-buff'; // HoT стилизуем как бафф (зеленый) } // Если есть другие типы (DoT, Drain и т.п.), которые не входят в эти категории, // их нужно добавить или стилизовать как info. // DoT можно стилизовать как effect-debuff or effect-damage, Drain as effect-debuff. // Например: else if (eff.type === config.ACTION_TYPE_DAMAGE) { effectClasses += ' effect-debuff'; } // DoT как дебафф // else if (eff.type === config.ACTION_TYPE_DRAIN) { effectClasses += ' effect-debuff'; } // Drain как дебафф else { //console.warn(`generateEffectsHTML: Эффект ID "${eff.id}" с типом "${eff.type}" не имеет специфичного класса стилизации.`); effectClasses += ' effect-info'; // Класс по умолчанию или информационный (серый/синий) } return `${displayText}`; }).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, // HoT эффекты [config.ACTION_TYPE_DEBUFF]: 4, // DoT, ресурсные дебаффы [config.ACTION_TYPE_DISABLE]: 5 // Silence, Stun // Добавьте другие типы, если нужно сортировать }; const sortEffects = (a, b) => { // Определяем порядок для эффекта A let orderA = typeOrder[a.type] || 99; if (a.grantsBlock) orderA = typeOrder.grantsBlock; // isFullSilence и playerSilencedOn_X - это эффекты типа DISABLE, но их можно поставить выше в приоритете дебаффов if (a.isFullSilence || a.id.startsWith('playerSilencedOn_')) orderA = typeOrder[config.ACTION_TYPE_DISABLE]; // Добавьте сюда другие специфичные проверки, если нужно изменить стандартный порядок по типу // Определяем порядок для эффекта B 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); // Сортируем по порядку }; // --- Конец логики сортировки --- // --- Обработка эффектов Игрока (My Player) --- if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList && myState && myState.activeEffects) { const myBuffs = []; const myDebuffs = []; // ИСПРАВЛЕНО: Проходим по массиву activeEffects один раз и пушим в нужный список myState.activeEffects.forEach(e => { // Определяем, является ли эффект баффом const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL; // HoT как бафф // Определяем, является ли эффект дебаффом // Учитываем типы DEBUFF, DISABLE, а также специфические флаги/ID для полного безмолвия и заглушения конкретных абилок 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 { // Если эффект не попал ни в одну категорию (например, новый тип?) //console.warn(`updateEffectsUI: Эффект ID "${e.id}" с типом "${e.type}" не отнесен ни к баффам, ни к дебаффам для Игрока.`); myDebuffs.push(e); // Добавим в дебаффы по умолчанию } }); // Сортируем списки баффов и дебаффов перед генерацией HTML 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 = 'Нет'; } // --- Обработка эффектов Оппонента (Opponent Player) --- // Логика аналогична игроку, но условия дебаффов могут немного отличаться // (например, префикс ID заглушения абилок) if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList && opponentState && opponentState.activeEffects) { const opponentBuffs = []; const opponentDebuffs = []; // ИСПРАВЛЕНО: Проходим по массиву activeEffects оппонента один раз и пушим в нужный список opponentState.activeEffects.forEach(e => { const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL; // HoT как бафф // Определяем, является ли эффект дебаффом для ОППОНЕНТА // Учитываем типы DEBUFF, DISABLE, isFullSilence. // id.startsWith('playerSilencedOn_') специфично для игрока, // id.startsWith('effect_') используется для дебаффов, наложенных на цель (например, Seal of Weakness) 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 { //console.warn(`updateEffectsUI: Эффект ID "${e.id}" с типом "${e.type}" не отнесен ни к баффам, ни к дебаффам для Оппонента.`); 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 updateUI() { const currentGameState = window.gameState; // Глобальное состояние игры const gameDataGlobal = window.gameData; // Глобальные данные ( статы, абилки ) для этого клиента const configGlobal = window.GAME_CONFIG; // Глобальный конфиг const myActualPlayerId = window.myPlayerId; // Технический ID слота этого клиента if (!currentGameState || !gameDataGlobal || !configGlobal || !myActualPlayerId) { // console.warn("updateUI: Отсутствуют глобальные gameState, gameData, GAME_CONFIG или myActualPlayerId."); // Сбрасываем UI панелей, если данные отсутствуют updateFighterPanelUI('player', null, null, true); updateFighterPanelUI('opponent', null, null, false); // Скрываем/очищаем остальные элементы UI игры if(uiElements.gameHeaderTitle) uiElements.gameHeaderTitle.innerHTML = `Ожидание данных...`; 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 = '

Загрузка способностей...

'; return; } if (!uiElements.player.panel || !uiElements.opponent.panel || !uiElements.controls.turnIndicator || !uiElements.controls.abilitiesGrid || !uiElements.log.list) { console.warn("updateUI: Некоторые базовые uiElements не найдены."); return; } // Определяем, чей сейчас ход по ID слота const actorSlotWhoseTurnItIs = currentGameState.isPlayerTurn ? configGlobal.PLAYER_ID : configGlobal.OPPONENT_ID; // Определяем ID слота оппонента для этого клиента const opponentActualSlotId = myActualPlayerId === configGlobal.PLAYER_ID ? configGlobal.OPPONENT_ID : configGlobal.PLAYER_ID; // Обновление панели "моего" персонажа const myStateInGameState = currentGameState[myActualPlayerId]; const myBaseStatsForUI = gameDataGlobal.playerBaseStats; // playerBaseStats в gameData - это всегда статы персонажа этого клиента if (myStateInGameState && myBaseStatsForUI) { updateFighterPanelUI('player', myStateInGameState, myBaseStatsForUI, true); } else { updateFighterPanelUI('player', null, null, true); // Нет данных, показываем состояние ожидания } // Обновление панели "моего оппонента" const opponentStateInGameState = currentGameState[opponentActualSlotId]; const opponentBaseStatsForUI = gameDataGlobal.opponentBaseStats; // opponentBaseStats в gameData - это всегда статы оппонента этого клиента // Если игра окончена и игрок победил, возможно, панель оппонента уже анимирована на исчезновение. // Не сбрасываем ее opacity/transform здесь, если она в состоянии dissolving. const isOpponentPanelDissolving = uiElements.opponent.panel?.classList.contains('dissolving'); if (opponentStateInGameState && opponentBaseStatsForUI) { // Если игра не окончена, а панель оппонента "тает" или не полностью видна, восстанавливаем это // Но не если она активно в анимации растворения (dissolving) if (uiElements.opponent.panel && (uiElements.opponent.panel.style.opacity !== '1' || (uiElements.opponent.panel.classList.contains('dissolving') && currentGameState.isGameOver === false) )) { // console.log("[UI UPDATE DEBUG] Opponent panel not fully visible/dissolving but game not over. Restoring opacity/transform."); const panel = uiElements.opponent.panel; if (panel.classList.contains('dissolving')) { panel.classList.remove('dissolving'); panel.style.transition = 'none'; // Отключаем переход временно panel.offsetHeight; // Trigger reflow 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)'; // В случае если просто opacity < 1 } } else if (uiElements.opponent.panel && !isOpponentPanelDissolving) { uiElements.opponent.panel.style.opacity = '1'; // Убеждаемся, что видна, если есть данные и не растворяется } updateFighterPanelUI('opponent', opponentStateInGameState, opponentBaseStatsForUI, false); } else { // Нет данных оппонента ( например, PvP игра ожидает игрока). Затемняем панель и очищаем. // Но не сбрасываем opacity/transform, если она активно в анимации растворения if (!isOpponentPanelDissolving) { updateFighterPanelUI('opponent', null, null, false); // Нет данных, показываем состояние ожидания/пустоты } else { // Если панель растворяется, не обновляем ее содержимое и оставляем текущие стили opacity/transform console.log("[UI UPDATE DEBUG] Opponent panel is dissolving, skipping content update."); } } // Обновление эффектов updateEffectsUI(currentGameState); // Обновление заголовка игры ( Имя1 vs Имя2) 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'; if (myKey === 'elena') myClass = 'title-enchantress'; else if (myKey === 'almagest') myClass = 'title-sorceress'; else if (myKey === 'balard') myClass = 'title-knight'; // Вдруг AI Балард в PvP let opponentClass = 'title-opponent'; if (opponentKey === 'elena') opponentClass = 'title-enchantress'; else if (opponentKey === 'almagest') opponentClass = 'title-sorceress'; else if (opponentKey === 'balard') opponentClass = 'title-knight'; uiElements.gameHeaderTitle.innerHTML = `${myName} ${opponentName}`; } 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 = `${myName} Ожидание игрока...`; } // Управление активностью кнопок и индикатор хода 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 || 'Неизвестно'}`; // Управляем цветом индикатора хода if (currentTurnActor?.id === myActualPlayerId) { uiElements.controls.turnIndicator.style.color = 'var(--turn-color)'; // Свой ход - желтый } else { uiElements.controls.turnIndicator.style.color = 'var(--text-muted)'; // Ход противника - приглушенный } } else { uiElements.controls.turnIndicator.textContent = "Игра окончена"; // Или можно скрыть его uiElements.controls.turnIndicator.style.color = 'var(--text-muted)'; } } // Кнопка атаки if (uiElements.controls.buttonAttack) { // Кнопка атаки активна, если это ход этого клиента и игра активна (полное безмолвие не блокирует базовую атаку) // ИСПРАВЛЕНО: Убрана проверка !isFullySilenced из условия disabled для базовой атаки 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) { // Проверяем, есть ли активный "отложенный" бафф (isDelayed=true) на атакующем, // который готов сработать на следующую атаку. 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 => { // Получаем актуальное состояние способности из actingPlayerState (которое пришло с сервера) 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; // Пропускаем дальнейшую логику обновления кнопки, если она должна быть disabled по базовым условиям } // Проверяем условия доступности способности из актуального состояния игры (actingPlayerState) const hasEnoughResource = actingPlayerState.currentResource >= abilityDataFromGameData.cost; const isOnCooldown = (actingPlayerState.abilityCooldowns?.[abilityId] || 0) > 0; // Проверяем КД по ID способности из актуального состояния // Под полным безмолвием const isGenerallySilenced = actingPlayerState.activeEffects?.some(eff => eff.isFullSilence && eff.turnsLeft > 0); // Под специфическим заглушением этой способности (ищем в disabledAbilities актуального состояния) 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; // Ищем эффект с префиксом effect_ на цели (оппоненте) const isDebuffAlreadyOnTarget = isTargetedDebuffAbility && opponentStateForDebuffCheck && opponentStateForDebuffCheck.activeEffects?.some(e => e.id === effectIdForDebuff); // Кнопка способности активна, если: // - Это ход этого клиента (проверено выше: canThisClientAct) // - Игра активна (проверено выше: isGameActive) // - Достаточно ресурса // - Бафф не активен (если это бафф) // - Не на кулдауне // - Не под безмолвием (полным или специфическим) <--- ЭТО УСЛОВИЕ ОСТАЕТСЯ ДЛЯ СПОСОБНОСТЕЙ // - Дебафф не активен на цели (если это такой дебафф) button.disabled = !hasEnoughResource || isBuffAlreadyActive || isSilenced || // Способности БЛОКИРУЮТСЯ полным безмолвием isOnCooldown || isDebuffAlreadyOnTarget; // Управление классами для стилизации кнопки (применяются независимо от окончательного disabled состояния) 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'; // Скрываем, если нет ни КД, ни безмолвия // Добавляем классы для визуальной обратной связи, ЕСЛИ кнопка НЕ задизейблена по КД или Безмолвию // (т.е. эти классы показывают *другие* причины, по которым кнопка может быть disabled) 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); // Если дебафф уже на цели, но кнопка не задизейблена по другим причинам, можно добавить отдельный класс для стилизации // button.classList.toggle('debuff-on-target', isDebuffAlreadyOnTarget); } } // Обновление title (всплывающей подсказки) - показываем полную информацию // Используем abilityDataFromGameData для базовой информации 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} х.)`; } // Добавляем информацию о текущем состоянии (КД, безмолвие, активный бафф/debuff) в тултип, если применимо if (isOnCooldown) { titleText += ` | На перезарядке! Осталось: ${actingPlayerState.abilityCooldowns[abilityId]} х.`; } if (isSilenced) { titleText += ` | Под безмолвием! Осталось: ${silenceTurnsLeft} х.`; } if (isBuffAlreadyActive) { const activeEffect = actingPlayerState.activeEffects?.find(eff => eff.id === abilityId); // Ищем активный эффект по ID способности // Если бафф имеет свойство 'justCast' и наложен в этом ходу, он не "готов" сработать на ЭТОМ ходу. // Это может быть важно для тултипа, если нужно отличать "только что наложен" от "готов к следующему действию". // Для "Силы Природы" (isDelayed=true) состояние "активен" означает "готов сработать на следующую атаку". 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); }); } /** * Показывает модальное окно конца игры. * @param {boolean} playerWon - Флаг, выиграл ли игрок, управляющий этим клиентом. * @param {string} [reason=""] - Причина завершения игры. * @param {string|null} opponentCharacterKeyFromClient - Ключ персонажа оппонента с т.з. клиента. * @param {object} [data=null] - Полный объект данных из события gameOver (включает disconnectedCharacterName и т.д.) */ function showGameOver(playerWon, reason = "", opponentCharacterKeyFromClient = null, data = null) { const config = window.GAME_CONFIG || {}; const clientSpecificGameData = window.gameData; // Используем gameData, сохраненное в client.js const currentActualGameState = window.gameState; // Самое актуальное состояние игры const gameOverScreenElement = uiElements.gameOver.screen; console.log(`[UI.JS DEBUG] showGameOver CALLED. PlayerWon: ${playerWon}, Reason: ${reason}`); console.log(`[UI.JS DEBUG] captured currentActualGameState?.isGameOver at call time: ${currentActualGameState?.isGameOver}`); // Log state at call time console.log(`[UI.JS DEBUG] Opponent Character Key (from client via param): ${opponentCharacterKeyFromClient}`); console.log(`[UI.JS DEBUG] My Character Name (from window.gameData): ${clientSpecificGameData?.playerBaseStats?.name}`); console.log(`[UI.JS DEBUG] Opponent Character Name (from window.gameData): ${clientSpecificGameData?.opponentBaseStats?.name}`); console.log(`[UI.JS DEBUG] Full game over data received:`, data); if (!gameOverScreenElement) { console.warn("[UI.JS DEBUG] showGameOver: gameOverScreenElement not found."); 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 = "Противник"; // Если в данных gameOver есть имя отключившегося персонажа, используем его if (data && data.disconnectedCharacterName) { disconnectedName = data.disconnectedCharacterName; } else { // Фоллбэк на имя оппонента с точки зрения клиента disconnectedName = opponentNameForResult; } winText = `${disconnectedName} покинул(а) игру. Победа присуждается вам!`; // В PvP, если оппонент отключился, а текущий игрок проиграл (что странно, но возможно), // сообщение о поражении может быть стандартным или специфичным. // В AI режиме, если игрок отключился, нет формального победителя AI. // Пусть будет стандартное поражение, если playerWon === false if (!playerWon) { // Возможно, специфичный текст для дисконнекта, когда ты проиграл? // loseText = `Игра завершена из-за отключения ${disconnectedName}. Вы проиграли.` } } else if (reason === 'hp_zero') { // Стандартное завершение по HP - тексты определены выше } // Добавьте обработку других причин завершения, если они будут else { // Неизвестная причина завершения winText = `Игра окончена. Победа! (${reason})`; loseText = `Игра окончена. Поражение. (${reason})`; } 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'; // Временно отключаем transition opponentPanelElement.offsetHeight; // Trigger reflow to apply style instantly // Используем characterKey проигравшего (переданный из GameInstance), // так как анимация растворения должна быть специфична для проигравшего персонажа, // который может быть Балардом или Альмагест. const loserCharacterKeyForDissolve = data?.loserCharacterKey; // Применяем анимацию растворения только если игра окончена, игрок победил, // и проигравший был Балардом или Альмагест (у которых есть эта анимация). // Исключаем случай дисконнекта, если анимация должна быть только при "убийстве" по HP. // В текущем CSS анимация растворения не зависит от причины, но зависит от класса 'dissolving'. // Добавляем класс, если игра окончена, игрок победил, и проигравший персонаж - Балард или Альмагест. // Если игра окончена И игрок проиграл И оппонент был Балардом/Альмагест, но игрок проиграл, анимация растворения НЕ применяется к панели оппонента. // Поэтому условие playerWon && ... корректно. if (currentActualGameState && currentActualGameState.isGameOver === true && playerWon) { // Проверяем, является ли проигравший (т.е. оппонент этого клиента) Балардом или Альмагест if (loserCharacterKeyForDissolve === 'balard' || loserCharacterKeyForDissolve === 'almagest') { console.log(`[UI.JS DEBUG] ADDING .dissolving to opponent panel.`); opponentPanelElement.classList.add('dissolving'); // Убеждаемся, что панель станет полностью прозрачной и сместится после анимации. // Конечные стили (opacity: 0, transform) могут быть заданы в CSS для класса .dissolving, // но их можно также установить здесь после добавления класса для гарантии. opponentPanelElement.style.opacity = '0'; // Конечный стиль для transition // opponentPanelElement.style.transform = 'scale(0.9) translateY(20px)'; // Конечный стиль для transition, если нужен } else { console.log(`[UI.JS DEBUG] NOT adding .dissolving (loser key mismatch: ${loserCharacterKeyForDissolve}).`); // Если анимация не применяется, убеждаемся, что панель полностью видна opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)'; } } else { console.log(`[UI.JS DEBUG] NOT adding .dissolving. Conditions NOT met: isGameOver=${currentActualGameState?.isGameOver}, playerWon=${playerWon}.`); // Если игра не окончена или игрок проиграл, убеждаемся, что панель полностью видна opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)'; } opponentPanelElement.style.transition = ''; // Восстанавливаем transition после установки начальных/конечных стилей } // Показываем модальное окно конца игры с небольшой задержкой // Передаем аргументы в колбэк, чтобы не полагаться на глобальный gameState в момент срабатывания setTimeout setTimeout((finalStateInTimeout, wonInTimeout, reasonInTimeout, keyInTimeout, dataInTimeout) => { // Use distinct names in timeout console.log("[UI.JS DEBUG] Timeout callback fired for showGameOver."); console.log("[UI.JS DEBUG] State object received in timeout:", finalStateInTimeout); // Check the whole object console.log("[UI.JS DEBUG] isGameOver in state (TIMEOUT):", finalStateInTimeout?.isGameOver); // Check property console.log("[UI.JS DEBUG] playerWon flag (TIMEOUT):", wonInTimeout); // Check playerWon flag passed // Проверяем условия для показа модального окна: элемент существует И состояние игры помечено как оконченное // ИСПРАВЛЕНО: Убрана проверка gameOverScreenElement.offsetParent !== null if (gameOverScreenElement && finalStateInTimeout && finalStateInTimeout.isGameOver === true) { console.log(`[UI.JS DEBUG] Modal SHOW condition met: gameOverScreenElement exists, finalState exists, isGameOver is true.`); // Убеждаемся, что modal не имеет display: none перед запуском transition opacity if (gameOverScreenElement.classList.contains(config.CSS_CLASS_HIDDEN || 'hidden')) { gameOverScreenElement.classList.remove(config.CSS_CLASS_HIDDEN || 'hidden'); } // Применяем display: flex (или другой нужный) только один раз, если нужно if(window.getComputedStyle(gameOverScreenElement).display === 'none') { gameOverScreenElement.style.display = 'flex'; // Или какой там display в CSS для .modal } gameOverScreenElement.style.opacity = '0'; // Start from hidden opacity requestAnimationFrame(() => { console.log("[UI.JS DEBUG] RequestAnimationFrame callback fired, animating modal."); // Animate to visible 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'; // Убеждаемся, что transition включен uiElements.gameOver.modalContent.style.transform = 'scale(1) translateY(0)'; uiElements.gameOver.modalContent.style.opacity = '1'; // uiElements.gameOver.modalContent.style.transition = ''; // Можно и так, если не отключали ранее } }); } else { console.log(`[UI.JS DEBUG] Modal SHOW condition NOT met.`); console.log(`[UI.JS DEBUG] Details: gameOverScreenElement=${!!gameOverScreenElement}, finalState=${!!finalStateInTimeout}, finalState?.isGameOver=${finalStateInTimeout?.isGameOver}. Hiding modal.`); // More details // Убеждаемся, что модалка скрыта, если условия не выполняются if (gameOverScreenElement) { // Ensure transition is off when hiding instantly 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'; } // Trigger reflow to ensure transition is off before hiding gameOverScreenElement.offsetHeight; } } }, config.DELAY_BEFORE_VICTORY_MODAL || 1500, currentActualGameState, playerWon, reason, opponentCharacterKeyFromClient, data); // Pass captured state and other values } // Экспортируем функции UI для использования в client.js window.gameUI = { uiElements, addToLog, updateUI, showGameOver }; })();