// /public/js/ui.js
// Этот файл отвечает за обновление DOM на основе состояния игры,
// полученного от client.js (который, в свою очередь, получает его от сервера).
// console.log("Loading ui.js...");
// GAME_CONFIG и gameData теперь предполагается, что будут установлены в window
// из client.js после получения данных от сервера.
// Это не лучшая практика (глобальные переменные), но для адаптации
// существующего кода это может быть проще на первом этапе.
// В идеале, все функции updateUI должны принимать gameState и gameData как параметры.
(function() {
// --- DOM Элементы (остаются как были) ---
const uiElements = {
player: {
panel: document.getElementById('player-panel'),
name: document.getElementById('player-name'), // Для обновления имени в PvP
avatar: document.getElementById('player-panel')?.querySelector('.player-avatar'),
hpFill: document.getElementById('player-hp-fill'), hpText: document.getElementById('player-hp-text'), maxHpSpan: document.getElementById('player-max-hp'),
resourceFill: document.getElementById('player-resource-fill'), resourceText: document.getElementById('player-resource-text'), maxResourceSpan: document.getElementById('player-max-resource'),
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'), // Для обновления имени в PvP
avatar: document.getElementById('opponent-panel')?.querySelector('.opponent-avatar'),
hpFill: document.getElementById('opponent-hp-fill'), hpText: document.getElementById('opponent-hp-text'), maxHpSpan: document.getElementById('opponent-max-hp'),
resourceFill: document.getElementById('opponent-resource-fill'), resourceText: document.getElementById('opponent-resource-text'), maxResourceSpan: document.getElementById('opponent-max-resource'),
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'),
restartButton: document.getElementById('restart-game-button'),
modalContent: document.getElementById('game-over-screen')?.querySelector('.modal-content')
},
// Дополнительные элементы для PvP информации, если нужно
gameHeaderTitle: document.querySelector('.game-header h1') // Заголовок "Елена vs Балард"
};
/** Добавляет сообщение в лог. */
function addToLog(message, type = 'info') { // Используем строковый тип по умолчанию
const logListElement = uiElements.log.list;
if (!logListElement) {
console.warn("addToLog: Элемент списка логов не найден.");
return;
}
const li = document.createElement('li');
li.textContent = message;
// Используем типы логов из GAME_CONFIG, если он определен
// Иначе, используем переданный строковый тип как есть
const logTypeClass = window.GAME_CONFIG && window.GAME_CONFIG[`LOG_TYPE_${type.toUpperCase()}`]
? `log-${window.GAME_CONFIG[`LOG_TYPE_${type.toUpperCase()}`]}`
: `log-${type}`;
li.className = logTypeClass;
logListElement.appendChild(li);
// Плавная прокрутка к последнему сообщению
requestAnimationFrame(() => {
logListElement.scrollTop = logListElement.scrollHeight;
});
}
/** Обновляет бары, статус, имя и аватар бойца. */
function updateFighterUI(fighterRole, // 'player' (Елена) или 'opponent' (Балард) - технические роли
fighterActualId, // 'player' или 'opponent' - кто из gameState соответствует этой панели
fighterState, // gameState[fighterActualId]
fighterBaseStatsRef, // gameData[fighterActualId+'BaseStats']
isControlledByThisClient) { // true, если эта панель соответствует игроку за этим клиентом
const elements = uiElements[fighterRole]; // uiElements.player или uiElements.opponent
if (!elements || !elements.hpFill || !elements.hpText || !elements.resourceFill || !elements.resourceText || !elements.status || !fighterState || !fighterBaseStatsRef) {
console.warn(`updateFighterUI: Отсутствуют элементы/состояние/базовые статы для роли ${fighterRole} (id: ${fighterActualId}).`);
return;
}
// Обновление имени и иконки
if (elements.name) {
let nameHtml = ` ${fighterBaseStatsRef.name}`;
if (isControlledByThisClient) {
nameHtml += " (Вы)";
}
elements.name.innerHTML = nameHtml;
elements.name.style.color = fighterBaseStatsRef.id === 'player' ? 'var(--accent-player)' : 'var(--accent-opponent)';
}
// Обновление аватара (если путь к аватару есть в baseStats или приходит от сервера)
// if (elements.avatar && fighterBaseStatsRef.avatarPath) {
// elements.avatar.src = fighterBaseStatsRef.avatarPath;
// }
const maxHp = Math.max(1, fighterBaseStatsRef.maxHp);
const maxRes = Math.max(1, fighterBaseStatsRef.maxResource);
const currentHp = Math.max(0, fighterState.currentHp);
const currentRes = Math.max(0, fighterState.currentResource);
const hpPercent = (currentHp / maxHp) * 100;
const resourcePercent = (currentRes / maxRes) * 100;
elements.hpFill.style.width = `${hpPercent}%`;
elements.hpText.textContent = `${Math.round(currentHp)} / ${fighterBaseStatsRef.maxHp}`;
elements.resourceFill.style.width = `${resourcePercent}%`;
elements.resourceText.textContent = `${Math.round(currentRes)} / ${fighterBaseStatsRef.maxResource}`;
// Динамическое изменение цвета и иконки для ресурса Баларда (если он не мана)
const resourceBarContainer = elements.resourceFill.closest('.stat-bar-container');
if (resourceBarContainer) {
const iconElement = resourceBarContainer.querySelector('.bar-icon i');
if (fighterActualId === (window.GAME_CONFIG?.OPPONENT_ID || 'opponent') && fighterBaseStatsRef.resourceName !== "Мана") {
resourceBarContainer.classList.remove('mana');
resourceBarContainer.classList.add('stamina'); // Предполагаем, что это выносливость/ярость
if(iconElement) iconElement.className = 'fas fa-fire-alt'; // Иконка ярости
} else { // Для Елены или если у Баларда мана
resourceBarContainer.classList.remove('stamina');
resourceBarContainer.classList.add('mana');
if(iconElement) iconElement.className = 'fas fa-flask'; // Иконка маны
}
}
const statusText = fighterState.isBlocking ? (window.GAME_CONFIG?.STATUS_BLOCKING || 'Защищается') : (window.GAME_CONFIG?.STATUS_READY || 'Готов');
elements.status.textContent = statusText;
elements.status.classList.toggle(window.GAME_CONFIG?.CSS_CLASS_BLOCKING || 'blocking', fighterState.isBlocking);
// Обновление glow-эффекта панели
if (elements.panel) {
const accentColor = fighterActualId === (window.GAME_CONFIG?.PLAYER_ID || 'player')
? 'var(--accent-player)'
: 'var(--accent-opponent)';
const glowColor = fighterActualId === (window.GAME_CONFIG?.PLAYER_ID || 'player')
? 'var(--panel-glow-player)'
: 'var(--panel-glow-opponent)';
elements.panel.style.borderColor = accentColor;
elements.panel.style.boxShadow = `0 0 15px ${glowColor}, inset 0 0 10px rgba(0, 0, 0, 0.3)`;
}
}
function classifyAndGenerateEffectsHTMLArrays(effectsArray, fighterRole) {
const buffsHTMLStrings = [];
const debuffsHTMLStrings = [];
const config = window.GAME_CONFIG || {}; // Фоллбэк на пустой объект, если GAME_CONFIG не загружен
if (!effectsArray || effectsArray.length === 0) {
return { buffsHTMLStrings: ['Нет'], debuffsHTMLStrings: ['Нет'] };
}
effectsArray.forEach(eff => {
let isDebuff = false;
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.type === config.ACTION_TYPE_DISABLE ||
eff.type === config.ACTION_TYPE_DEBUFF ||
eff.id === 'fullSilenceByElena' || // Специфичный эффект Елены
eff.id.startsWith('playerSilencedOn_') // Эффект безмолвия от Баларда на конкретную абилку
) {
isDebuff = true;
}
if (isDebuff) {
if (eff.id === 'fullSilenceByElena' || eff.id.startsWith('playerSilencedOn_') || eff.type === config.ACTION_TYPE_DISABLE) {
effectClasses += ' effect-stun'; // Или ваш класс для дизейблов
} else {
effectClasses += ' effect-debuff';
}
debuffsHTMLStrings.push(`${displayText}`);
} else {
if (eff.grantsBlock) {
effectClasses += ' effect-block';
} else { // Все остальное считаем баффом
effectClasses += ' effect-buff';
}
buffsHTMLStrings.push(`${displayText}`);
}
});
return {
buffsHTMLStrings: buffsHTMLStrings.length > 0 ? buffsHTMLStrings : ['Нет'],
debuffsHTMLStrings: debuffsHTMLStrings.length > 0 ? debuffsHTMLStrings : ['Нет']
};
}
function updateEffectsUI(currentGameState, myPlayerId) {
if (!currentGameState || !window.gameData) return; // gameData тоже должен быть доступен
const playerRoleForUI = myPlayerId === currentGameState.player.id ? 'player' : 'opponent';
const opponentRoleForUI = myPlayerId === currentGameState.player.id ? 'opponent' : 'player';
// Обновление для панели, соответствующей myPlayerId
const myPanelElements = uiElements[playerRoleForUI]; // Это будет uiElements.player или uiElements.opponent
const myState = currentGameState[myPlayerId]; // Это будет gameState.player или gameState.opponent
if (myPanelElements && myState) {
const myClassifiedEffects = classifyAndGenerateEffectsHTMLArrays(myState.activeEffects, playerRoleForUI);
if (myPanelElements.buffsList) myPanelElements.buffsList.innerHTML = myClassifiedEffects.buffsHTMLStrings.join(' ');
if (myPanelElements.debuffsList) myPanelElements.debuffsList.innerHTML = myClassifiedEffects.debuffsHTMLStrings.join(' ');
}
// Обновление для панели противника
const opponentActualId = myPlayerId === currentGameState.player.id ? currentGameState.opponent.id : currentGameState.player.id;
const opponentPanelElements = uiElements[opponentRoleForUI];
const opponentState = currentGameState[opponentActualId];
if (opponentPanelElements && opponentState) {
const opponentClassifiedEffects = classifyAndGenerateEffectsHTMLArrays(opponentState.activeEffects, opponentRoleForUI);
if (opponentPanelElements.buffsList) opponentPanelElements.buffsList.innerHTML = opponentClassifiedEffects.buffsHTMLStrings.join(' ');
if (opponentPanelElements.debuffsList) opponentPanelElements.debuffsList.innerHTML = opponentClassifiedEffects.debuffsHTMLStrings.join(' ');
}
}
/** Главная функция обновления всего UI. */
function updateUI() {
// Используем глобальные gameState, gameData, GAME_CONFIG, установленные из client.js
const currentGameState = window.gameState;
const gameDataGlobal = window.gameData;
const configGlobal = window.GAME_CONFIG;
const myPlayerId = window.myPlayerId; // myPlayerId также должен быть установлен глобально или передан
if (!currentGameState || !gameDataGlobal || !configGlobal || !myPlayerId) {
console.warn("updateUI: Отсутствуют глобальные gameState, gameData, GAME_CONFIG или myPlayerId.");
return;
}
if (!uiElements.player.panel || !uiElements.opponent.panel || !uiElements.controls.turnIndicator || !uiElements.controls.abilitiesGrid || !uiElements.log.list) {
console.warn("updateUI: Некоторые базовые uiElements не найдены.");
return;
}
// Определяем, какая панель (player/opponent в uiElements) соответствует какому игроку (player/opponent в gameState)
// и кто из них управляется этим клиентом.
const playerPanelRepresents = currentGameState.player.id; // Кого отображает панель "player"
const opponentPanelRepresents = currentGameState.opponent.id; // Кого отображает панель "opponent"
updateFighterUI('player', playerPanelRepresents, currentGameState[playerPanelRepresents], gameDataGlobal[playerPanelRepresents === 'player' ? 'playerBaseStats' : 'opponentBaseStats'], playerPanelRepresents === myPlayerId);
updateFighterUI('opponent', opponentPanelRepresents, currentGameState[opponentPanelRepresents], gameDataGlobal[opponentPanelRepresents === 'player' ? 'playerBaseStats' : 'opponentBaseStats'], opponentPanelRepresents === myPlayerId);
updateEffectsUI(currentGameState, myPlayerId);
// Обновление заголовка игры (Елена vs Балард)
if (uiElements.gameHeaderTitle && gameDataGlobal.playerBaseStats && gameDataGlobal.opponentBaseStats) {
uiElements.gameHeaderTitle.innerHTML = `${gameDataGlobal.playerBaseStats.name} ${gameDataGlobal.opponentBaseStats.name}`;
}
if (uiElements.controls.turnIndicator) {
const currentTurnActorId = currentGameState.isPlayerTurn ? currentGameState.player.id : currentGameState.opponent.id;
const currentTurnName = currentGameState[currentTurnActorId].name;
uiElements.controls.turnIndicator.textContent = `Ход: ${currentTurnName}`;
uiElements.controls.turnIndicator.style.color = currentTurnActorId === configGlobal.PLAYER_ID ? 'var(--accent-player)' : 'var(--accent-opponent)';
}
const canThisClientAct = (currentGameState.isPlayerTurn && myPlayerId === currentGameState.player.id) ||
(!currentGameState.isPlayerTurn && myPlayerId === currentGameState.opponent.id);
const isGameActive = !currentGameState.isGameOver;
// Обновление кнопки атаки
if (uiElements.controls.buttonAttack) {
uiElements.controls.buttonAttack.disabled = !(canThisClientAct && isGameActive);
// Подсветка атаки от "Силы Природы" (только если myPlayerId - это Елена)
if (myPlayerId === configGlobal.PLAYER_ID) {
const isNatureBuffReady = currentGameState.player.activeEffects.some(eff => eff.id === configGlobal.ABILITY_ID_NATURE_STRENGTH && !eff.justCast);
uiElements.controls.buttonAttack.classList.toggle(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed', isNatureBuffReady && canThisClientAct && isGameActive);
} else {
uiElements.controls.buttonAttack.classList.remove(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed');
}
}
// Кнопка блока всегда отключена
if (uiElements.controls.buttonBlock) {
uiElements.controls.buttonBlock.disabled = true;
}
// Обновление кнопок способностей для текущего игрока (myPlayerId)
const actingPlayerState = currentGameState[myPlayerId];
const actingPlayerAbilities = myPlayerId === configGlobal.PLAYER_ID ? gameDataGlobal.playerAbilities : gameDataGlobal.opponentAbilities;
uiElements.controls.abilitiesGrid?.querySelectorAll(`.${configGlobal.CSS_CLASS_ABILITY_BUTTON || 'ability-button'}`).forEach(button => {
if (!(button instanceof HTMLButtonElement) || !actingPlayerState || !actingPlayerAbilities) {
if(button instanceof HTMLButtonElement) button.disabled = true;
return;
}
const abilityId = button.dataset.abilityId;
const ability = actingPlayerAbilities.find(ab => ab.id === abilityId);
if (!ability) { button.disabled = true; return; }
const hasEnoughResource = actingPlayerState.currentResource >= ability.cost;
const isBuffAlreadyActive = ability.type === configGlobal.ACTION_TYPE_BUFF && actingPlayerState.activeEffects.some(eff => eff.id === abilityId);
const silencedInfo = actingPlayerState.disabledAbilities?.find(dis => dis.abilityId === abilityId);
const isSilencedByOpponent = !!silencedInfo;
const cooldownTurnsLeft = actingPlayerState.abilityCooldowns?.[ability.id] || 0;
const isOnCooldown = cooldownTurnsLeft > 0;
let isDisabledByDebuffOnTarget = false; // Для специфичных дебаффов типа Печати Слабости
if (ability.id === configGlobal.ABILITY_ID_SEAL_OF_WEAKNESS && myPlayerId === configGlobal.PLAYER_ID) { // Только Елена может накладывать
const opponentActualId = myPlayerId === currentGameState.player.id ? currentGameState.opponent.id : currentGameState.player.id;
const effectIdForDebuff = 'effect_' + ability.id;
isDisabledByDebuffOnTarget = currentGameState[opponentActualId].activeEffects.some(e => e.id === effectIdForDebuff);
}
let finalDisabledState = !(canThisClientAct && isGameActive) || !hasEnoughResource || isBuffAlreadyActive || isSilencedByOpponent || isOnCooldown || isDisabledByDebuffOnTarget;
button.disabled = finalDisabledState;
// Стилизация кнопки
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 = `КД: ${cooldownTurnsLeft}`;
cooldownDisplay.style.display = 'block';
}
} else {
if (cooldownDisplay) cooldownDisplay.style.display = 'none';
// Применяем другие классы только если не на КД
button.classList.toggle(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE || 'not-enough-resource', canThisClientAct && isGameActive && !hasEnoughResource && !isBuffAlreadyActive && !isSilencedByOpponent && !isDisabledByDebuffOnTarget);
button.classList.toggle(configGlobal.CSS_CLASS_BUFF_IS_ACTIVE || 'buff-is-active', canThisClientAct && isGameActive && isBuffAlreadyActive && !isSilencedByOpponent); // Для баффов на себя
button.classList.toggle(configGlobal.CSS_CLASS_ABILITY_SILENCED || 'is-silenced', isSilencedByOpponent);
// Если Печать Слабости уже на цели, можно добавить особый класс или просто дизейблить
if (isDisabledByDebuffOnTarget) {
// button.classList.add('debuff-already-active'); // Пример
}
}
// Обновление Tooltip
const resourceName = actingPlayerState.resourceName;
let titleText = `${ability.name} (${ability.cost} ${resourceName}) - ${ability.description}`;
if (typeof ability.descriptionFunction === 'function') {
const configForDesc = window.clientSideConfig?.GAME_CONFIG_FOR_ABILITIES || configGlobal;
const targetStatsForDesc = (myPlayerId === configGlobal.PLAYER_ID)
? (window.gameData?.opponentBaseStats)
: (window.gameData?.playerBaseStats);
titleText = `${ability.name} (${ability.cost} ${resourceName}) - ${ability.descriptionFunction(configForDesc, targetStatsForDesc)}`;
}
let abilityBaseCooldown = ability.cooldown;
if (myPlayerId === configGlobal.OPPONENT_ID) { // Если Балард-игрок
if (ability.internalCooldownFromConfig && configGlobal) {
abilityBaseCooldown = configGlobal[ability.internalCooldownFromConfig];
} else if (ability.internalCooldownValue) {
abilityBaseCooldown = ability.internalCooldownValue;
}
}
if (abilityBaseCooldown) titleText += ` (КД: ${abilityBaseCooldown} х.)`;
if (isOnCooldown) {
titleText = `${ability.name} - На перезарядке! Осталось: ${cooldownTurnsLeft} х.`;
} else if (isSilencedByOpponent) {
titleText = `Заглушено! Осталось: ${silencedInfo.turnsLeft} х.`;
} else if (isBuffAlreadyActive) {
const activeEffect = actingPlayerState.activeEffects.find(eff => eff.id === abilityId);
titleText = `Эффект "${ability.name}" уже активен${activeEffect ? ` (${activeEffect.turnsLeft} х.)` : ''}`;
} else if (isDisabledByDebuffOnTarget) {
const opponentActualId = myPlayerId === currentGameState.player.id ? currentGameState.opponent.id : currentGameState.player.id;
const activeDebuff = currentGameState[opponentActualId].activeEffects.find(e => e.id === 'effect_' + ability.id);
titleText = `Эффект "${ability.name}" уже наложен на ${currentGameState[opponentActualId].name}${activeDebuff ? ` (${activeDebuff.turnsLeft} х.)` : ''}`;
}
button.setAttribute('title', titleText);
});
}
/** Показывает экран конца игры с задержкой. */
function showGameOver(playerWon, reason = "") {
const config = window.GAME_CONFIG || {};
const gameDataGlobal = window.gameData || {};
const gameOverScreenElement = uiElements.gameOver.screen;
if (!gameOverScreenElement) return;
const resultMsgElement = uiElements.gameOver.message;
const opponentPanelElement = uiElements.opponent.panel; // Панель Баларда в UI
if (resultMsgElement) {
let winText = `Победа! ${gameDataGlobal.playerBaseStats ? gameDataGlobal.playerBaseStats.name : 'Игрок'} празднует!`;
let loseText = `Поражение! ${gameDataGlobal.opponentBaseStats ? gameDataGlobal.opponentBaseStats.name : 'Противник'} оказался сильнее!`;
if (reason === 'opponent_disconnected') {
if (playerWon) { // Если "мы" победили из-за дисконнекта оппонента
winText = `Противник покинул игру. Победа присуждается вам!`;
} else { // Если "мы" были оппонентом, который остался
loseText = `Игрок покинул игру. Техническая победа.`;
}
}
if (playerWon) {
resultMsgElement.textContent = winText;
resultMsgElement.style.color = 'var(--heal-color)';
} else {
resultMsgElement.textContent = loseText;
resultMsgElement.style.color = 'var(--damage-color)';
}
}
// Анимация растворения панели Баларда, если победила Елена (и это не дисконнект)
// И если панель оппонента действительно отображает Баларда
const opponentPanelRepresents = window.gameState?.opponent.id; // Кто сейчас на панели оппонента
if (playerWon && reason !== 'opponent_disconnected' &&
opponentPanelElement && opponentPanelRepresents === config.OPPONENT_ID) {
// addToLog вызывается из client.js при получении gameOver от сервера
opponentPanelElement.classList.add('dissolving');
} else if (opponentPanelElement) {
// Убираем класс, если он был, на случай рестарта
opponentPanelElement.classList.remove('dissolving');
}
setTimeout(() => {
gameOverScreenElement.classList.remove(config.CSS_CLASS_HIDDEN || 'hidden');
requestAnimationFrame(() => { // Для плавности анимации появления
gameOverScreenElement.style.opacity = '0'; // Сначала делаем невидимым
setTimeout(() => { // Даем браузеру время на рендер невидимого состояния
gameOverScreenElement.style.opacity = '1'; // Анимируем появление
if (uiElements.gameOver.modalContent) {
uiElements.gameOver.modalContent.style.transform = 'scale(1) translateY(0)';
uiElements.gameOver.modalContent.style.opacity = '1';
}
}, config.MODAL_TRANSITION_DELAY || 10);
});
}, config.DELAY_BEFORE_VICTORY_MODAL || 1500);
}
// --- Экспорт ---
// Функции теперь доступны через window.gameUI
window.gameUI = {
uiElements,
addToLog,
updateUI,
showGameOver
// classifyAndGenerateEffectsHTMLArrays, // Можно сделать доступным, если нужно извне
// updateFighterUI,
// updateEffectsUI
};
})();
// console.log("ui.js loaded and initialized.", window.gameUI);