This commit is contained in:
2026-01-27 21:43:42 +03:00
parent 1f18abbc33
commit 7d5769ac8b
4 changed files with 295 additions and 59 deletions

View File

@ -163,10 +163,29 @@
renderPlayerList('player-list', lobby);
renderPlayerList('connect-player-list', lobby);
});
socket.on('backToLobby', () => {
socket.on('roomsList', (roomsList) => {
if (typeof window.handleRoomsList === 'function') window.handleRoomsList(roomsList);
});
socket.on('joinedAsSpectator', (data) => {
if (data.gameState) {
gameState = data.gameState;
gameState.isSpectator = true;
gameState.yourIndex = -1;
showScreen('game');
renderGame(gameState);
}
});
socket.on('backToLobby', (data) => {
showScreen('lobby');
clearError();
setLobbyPanel('host');
if (data && data.lobby) {
setLobbyPanel('room');
renderPlayerList('player-list', data.lobby);
} else {
setLobbyPanel('host');
}
const hp = $('host-panel');
if (hp) hp.classList.add('active');
});
@ -201,8 +220,8 @@
forgePreviewPendingIds = null;
return;
}
const you = gameState?.players?.[gameState?.yourIndex];
const canCraft = you && (you.mana || 0) >= 2;
const you = gameState?.isSpectator ? null : (gameState?.players?.[gameState?.yourIndex] || null);
const canCraft = !gameState?.isSpectator && you && (you.mana || 0) >= 2;
if (wrap) {
const art = typeof getCardArt === 'function' ? getCardArt(meta) : '';
wrap.innerHTML = '<div class="card-wrap forge-preview-card" style="width:110px;height:154px;"><div class="card faction-' + (meta.faction || 'neutral') + '">' +
@ -257,6 +276,23 @@
gameState = state;
cardDb = state.cardDb || {};
yourIndex = state.yourIndex ?? 0;
// Режим наблюдателя
if (state.isSpectator) {
yourIndex = -1;
const spectatorBadge = $('spectator-badge');
if (!spectatorBadge) {
const badge = document.createElement('div');
badge.id = 'spectator-badge';
badge.style.cssText = 'position: fixed; top: 1rem; right: 1rem; background: rgba(212,168,75,0.9); color: #000; padding: 0.5rem 1rem; border-radius: 8px; font-weight: 700; z-index: 200;';
badge.textContent = '👁 НАБЛЮДАТЕЛЬ';
document.body.appendChild(badge);
}
} else {
const spectatorBadge = $('spectator-badge');
if (spectatorBadge) spectatorBadge.remove();
}
if (state.phase === 'ended') {
showGameOver(state);
renderGameEnded(state);
@ -268,8 +304,8 @@
socket.on('stealCardsRequest', (data) => {
if (!gameState) return;
// Находим handIndex заклинания "Грабеж" в текущей руке
const you = gameState.players[gameState.yourIndex];
if (!you || !you.hand) return;
const you = gameState.isSpectator ? null : (gameState.players[gameState.yourIndex] || null);
if (!you || !you.hand || gameState.isSpectator) return;
// Ищем заклинание "Грабеж" в руке
let handIndex = -1;
@ -338,6 +374,7 @@
const modal = $('modal-overlay');
const title = $('modal-title');
const body = $('modal-body');
const newGameBtn = $('btn-new-game');
if (!modal || !title || !body) return;
if (winner && winner.id === socket?.id) {
title.textContent = 'Победа!';
@ -354,6 +391,12 @@
title.style.color = 'var(--cyan)';
body.textContent = 'Победитель не определён.';
}
// Показываем кнопку "Новая игра" только хосту (не наблюдателю)
if (newGameBtn && !state.isSpectator && state.yourIndex === 0) {
newGameBtn.style.display = 'block';
} else if (newGameBtn) {
newGameBtn.style.display = 'none';
}
modal.classList.remove('hidden');
}
@ -363,7 +406,7 @@
if (!notification || !notificationText) return;
const currentPlayer = state.players[state.currentPlayerIndex];
const isYourTurn = state.currentPlayerIndex === state.yourIndex;
const isYourTurn = !state.isSpectator && state.currentPlayerIndex === state.yourIndex;
const playerName = currentPlayer?.name || `Игрок ${state.currentPlayerIndex + 1}`;
if (isYourTurn) {
@ -382,21 +425,20 @@
}
function renderBoards(state) {
const you = state.players[yourIndex];
if (!you) return;
const you = state.isSpectator ? null : (state.players[yourIndex] || null);
const opponentsArea = $('opponents-area');
const yourBoard = $('your-board');
if (opponentsArea) {
opponentsArea.innerHTML = state.players
.map((p, i) => {
if (i === yourIndex) return '';
if (!state.isSpectator && i === yourIndex) return '';
const isCurrent = state.currentPlayerIndex === i;
const isDead = p.health <= 0 || p.isDead;
const name = p.name || 'Игрок ' + (i + 1);
const minions = (p.board || []).map((m, j) => renderBoardMinion(m, i, j, state, true, false));
const heroBar = renderHeroTarget(i, state);
const heroDrop = renderHeroDropZone(i, state);
const canSteal = state.currentPlayerIndex === state.yourIndex && spellMode.active && spellMode.cardId && cardDb[spellMode.cardId]?.spellEffect === 'steal_cards';
const canSteal = !state.isSpectator && state.currentPlayerIndex === state.yourIndex && spellMode.active && spellMode.cardId && cardDb[spellMode.cardId]?.spellEffect === 'steal_cards';
return `
<div class="opponent-block ${isCurrent ? 'current-turn' : ''} ${canSteal ? 'steal-target' : ''} ${isDead ? 'defeated' : ''}" data-opponent-index="${i}" data-player-index="${i}">
<div class="opponent-name ${isDead ? 'defeated-name' : ''}">${escapeHtml(name)}${isDead ? ' <span class="defeated-badge">✝</span>' : ''}</div>
@ -412,11 +454,15 @@
.join('');
}
if (yourBoard) {
yourBoard.innerHTML = (you.board || []).map((m, j) => {
const isNew = !seenMinions.has(m.id);
if (isNew) seenMinions.add(m.id);
return renderBoardMinion(m, yourIndex, j, state, false, isNew);
}).join('');
if (state.isSpectator) {
yourBoard.innerHTML = '<div style="padding: 1rem; text-align: center; color: #94a3b8; font-size: 0.85rem;">Режим наблюдателя — вы не играете</div>';
} else if (you) {
yourBoard.innerHTML = (you.board || []).map((m, j) => {
const isNew = !seenMinions.has(m.id);
if (isNew) seenMinions.add(m.id);
return renderBoardMinion(m, yourIndex, j, state, false, isNew);
}).join('');
}
}
}
@ -717,18 +763,18 @@
}
function renderGame(state) {
const you = state.players[yourIndex];
if (!you) return;
const you = state.isSpectator ? null : (state.players[yourIndex] || null);
$('your-mana').textContent = you.mana ?? 0;
$('your-max-mana').textContent = you.maxMana ?? 0;
$('your-mana').textContent = state.isSpectator ? '—' : (you?.mana ?? 0);
$('your-max-mana').textContent = state.isSpectator ? '—' : (you?.maxMana ?? 10);
const healthEl = $('your-health');
if (healthEl) {
const newHealth = you.health ?? 30;
const oldHealth = parseInt(healthEl.textContent) || 30;
const newHealth = state.isSpectator ? '—' : (you?.health ?? 30);
const oldHealthText = healthEl.textContent;
const oldHealth = state.isSpectator ? '—' : (parseInt(oldHealthText) || 30);
healthEl.textContent = newHealth;
// Добавляем визуальный эффект при изменении HP
if (newHealth !== oldHealth) {
if (!state.isSpectator && typeof newHealth === 'number' && typeof oldHealth === 'number' && newHealth !== oldHealth) {
healthEl.classList.add('health-changed');
setTimeout(() => healthEl.classList.remove('health-changed'), 500);
}
@ -785,14 +831,17 @@
}
if (lastLog?.type === 'attackHero' && typeof window.Sounds !== 'undefined') window.Sounds.attack();
const isYourTurn = state.currentPlayerIndex === yourIndex;
const isYourTurn = state.currentPlayerIndex === yourIndex && !state.isSpectator;
const endBtn = $('btn-end-turn');
if (endBtn) endBtn.disabled = !isYourTurn;
if (endBtn) {
endBtn.disabled = !isYourTurn || state.isSpectator;
if (state.isSpectator) endBtn.title = 'Режим наблюдателя';
}
const heroBtn = $('btn-hero-ability');
if (heroBtn) {
var canHero = isYourTurn && (you.mana ?? 0) >= 2 && !state.yourHeroAbilityUsed && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
heroBtn.disabled = !canHero;
heroBtn.classList.toggle('hidden', !isYourTurn);
var canHero = isYourTurn && !state.isSpectator && (you.mana ?? 0) >= 2 && !state.yourHeroAbilityUsed && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
heroBtn.disabled = !canHero || state.isSpectator;
heroBtn.classList.toggle('hidden', !isYourTurn || state.isSpectator);
}
const resetBtnEl = $('btn-reset-lobby');
if (resetBtnEl) resetBtnEl.classList.add('hidden');
@ -820,36 +869,40 @@
renderBoards(state);
}
const hand = you.hand || [];
var justDrew = hand.length > lastHandLength && lastHandLength > 0;
const hand = state.isSpectator ? [] : (you.hand || []);
var justDrew = !state.isSpectator && hand.length > lastHandLength && lastHandLength > 0;
if (justDrew && typeof window.Sounds !== 'undefined') window.Sounds.drawCard();
lastHandLength = hand.length;
const handEl = $('your-hand');
if (handEl) {
handEl.innerHTML = hand
.map((cid, i) => {
var meta = cardDb[cid];
var cost = meta?.cost ?? 0;
var playable = false;
var notEnoughMana = false;
if (isYourTurn) {
if (state.isSpectator) {
handEl.innerHTML = '<div style="padding: 2rem; text-align: center; color: #94a3b8; font-size: 0.9rem;">Режим наблюдателя — вы не можете играть</div>';
} else {
handEl.innerHTML = hand
.map((cid, i) => {
var meta = cardDb[cid];
var cost = meta?.cost ?? 0;
var playable = false;
var notEnoughMana = false;
if (isYourTurn) {
if (meta?.type === 'minion') {
playable = you.mana >= cost && (you.board?.length ?? 0) < 7;
notEnoughMana = you.mana < cost;
playable = you && you.mana >= cost && (you.board?.length ?? 0) < 7;
notEnoughMana = you && you.mana < cost;
} else if (meta?.type === 'spell') {
playable = you.mana >= cost && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
notEnoughMana = you.mana < cost;
playable = you && you.mana >= cost && !attackMode.active && !spellMode.active && !heroAbilityMode.active;
notEnoughMana = you && you.mana < cost;
}
}
var drawAnim = justDrew && i === hand.length - 1;
return renderHandCard(cid, i, hand.length, playable, drawAnim, notEnoughMana);
})
.join('');
}
var drawAnim = justDrew && i === hand.length - 1;
return renderHandCard(cid, i, hand.length, playable, drawAnim, notEnoughMana);
})
.join('');
}
}
const deckCount = state.yourDeckCount ?? you.deck?.length ?? 0;
const canDraw = isYourTurn && !state.yourManualDrawUsed && deckCount > 0 && (you.hand?.length ?? 0) < 10;
const deckCount = state.isSpectator ? 0 : (state.yourDeckCount ?? you?.deck?.length ?? 0);
const canDraw = isYourTurn && !state.isSpectator && !state.yourManualDrawUsed && deckCount > 0 && (you.hand?.length ?? 0) < 10;
renderDeckFan(deckCount, justDrew, canDraw);
bindGameEvents(state);
}
@ -1126,10 +1179,10 @@
const handIndex = parseInt(wrap.dataset.handIndex, 10);
const meta = cardDb[wrap.dataset.cardId];
const isMinion = meta && meta.type === 'minion';
const canPlay = isYourTurn && !attackMode.active && !heroAbilityMode.active && meta && you.mana >= (meta.cost || 0);
const canPlay = isYourTurn && !state.isSpectator && !attackMode.active && !heroAbilityMode.active && meta && you && you.mana >= (meta.cost || 0);
// Если кузница открыта и карта - миньон, делаем её перетаскиваемой для кузницы
if (isForgeOpen && isMinion) {
if (isForgeOpen && isMinion && !state.isSpectator) {
wrap.draggable = true;
wrap.ondragstart = function (e) {
e.dataTransfer.setData('text/plain', 'hand:' + handIndex);
@ -1139,7 +1192,7 @@
wrap.ondragend = function () {
wrap.classList.remove('dragging');
};
} else if (isMinion && canPlay && (you.board?.length ?? 0) < 7) {
} else if (isMinion && canPlay && !state.isSpectator && you && (you.board?.length ?? 0) < 7) {
wrap.draggable = true;
wrap.ondragstart = function (e) {
if (!canPlay) return;
@ -1173,7 +1226,7 @@
return;
}
if (meta.type === 'spell') {
if (spellMode.active || heroAbilityMode.active || stealCardsMode.active) return;
if (state.isSpectator || spellMode.active || heroAbilityMode.active || stealCardsMode.active) return;
var needTarget = meta.spellTarget && meta.spellTarget !== 'none';
// Специальная обработка для Грабежа
@ -1685,17 +1738,17 @@
var heroAbilityBtn = $('btn-hero-ability');
if (heroAbilityBtn) heroAbilityBtn.onclick = function () {
if (heroAbilityBtn.disabled || heroAbilityMode.active) return;
if (state.isSpectator || heroAbilityBtn.disabled || heroAbilityMode.active) return;
heroAbilityMode = { active: true };
$('hero-ability-mode')?.classList.remove('hidden');
renderGame(state);
};
var forgeBtn = $('btn-forge');
if (forgeBtn && isYourTurn && you.mana >= 2) {
if (forgeBtn && isYourTurn && !state.isSpectator && you && you.mana >= 2) {
forgeBtn.disabled = false;
forgeBtn.onclick = function () {
if (attackMode.active || spellMode.active || heroAbilityMode.active) return;
if (state.isSpectator || attackMode.active || spellMode.active || heroAbilityMode.active) return;
const sidebar = $('forge-sidebar');
if (sidebar) {
sidebar.classList.remove('hidden');
@ -1891,11 +1944,13 @@
function initLobby() {
const hostTab = document.querySelector('.tab[data-tab="host"]');
const joinTab = document.querySelector('.tab[data-tab="join"]');
const browseTab = document.querySelector('.tab[data-tab="browse"]');
hostTab?.addEventListener('click', () => {
$all('.tab').forEach((t) => t.classList.remove('active'));
hostTab.classList.add('active');
$('host-panel')?.classList.add('active');
$('join-panel')?.classList.remove('active');
$('browse-panel')?.classList.remove('active');
clearError();
});
joinTab?.addEventListener('click', () => {
@ -1903,8 +1958,27 @@
joinTab.classList.add('active');
$('join-panel')?.classList.add('active');
$('host-panel')?.classList.remove('active');
$('browse-panel')?.classList.remove('active');
clearError();
});
browseTab?.addEventListener('click', () => {
$all('.tab').forEach((t) => t.classList.remove('active'));
browseTab.classList.add('active');
$('browse-panel')?.classList.add('active');
$('host-panel')?.classList.remove('active');
$('join-panel')?.classList.remove('active');
clearError();
// Подключаемся к серверу если еще не подключены
if (!socket || !socket.connected) {
const url = window.location.origin;
connect(url);
socket.once('connect', () => {
socket.emit('getRoomsList');
});
} else {
socket.emit('getRoomsList');
}
});
const btnHost = $('btn-host');
if (btnHost) {
@ -2056,7 +2130,86 @@
$('btn-modal-close')?.addEventListener('click', () => {
$('modal-overlay')?.classList.add('hidden');
const newGameBtn = $('btn-new-game');
if (newGameBtn) newGameBtn.style.display = 'none';
});
$('btn-new-game')?.addEventListener('click', () => {
if (socket && socket.connected) {
socket.emit('resetToLobby');
$('modal-overlay')?.classList.add('hidden');
const newGameBtn = $('btn-new-game');
if (newGameBtn) newGameBtn.style.display = 'none';
}
});
$('btn-refresh-rooms')?.addEventListener('click', () => {
if (socket && socket.connected) {
socket.emit('getRoomsList');
} else {
const url = window.location.origin;
connect(url);
socket.once('connect', () => {
socket.emit('getRoomsList');
});
}
});
// Обработчик списка комнат
window.handleRoomsList = function(roomsList) {
const roomsListEl = $('rooms-list');
if (!roomsListEl) return;
if (!roomsList || roomsList.length === 0) {
roomsListEl.innerHTML = '<p class="hint" style="text-align: center; padding: 2rem; color: #94a3b8;">Нет доступных игр</p>';
return;
}
roomsListEl.innerHTML = roomsList.map((room) => {
const status = room.gameStarted ? 'Игра идёт' : 'Ожидание игроков';
const statusColor = room.gameStarted ? 'var(--amber)' : 'var(--cyan)';
const playersText = room.players.length > 0 ? room.players.join(', ') : 'Нет игроков';
const canJoin = !room.gameStarted && room.playerCount < room.maxPlayers;
const actionText = room.gameStarted ? '👁 Наблюдать' : (canJoin ? '▶ Подключиться' : 'Полная');
const actionClass = room.gameStarted ? 'btn-ghost' : (canJoin ? 'btn-primary' : 'btn-ghost');
const actionDisabled = !room.gameStarted && !canJoin;
return `<div class="room-item" style="padding: 1rem; margin-bottom: 0.75rem; background: rgba(0,0,0,0.3); border: 2px solid rgba(212,168,75,0.2); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<div>
<div style="font-weight: 700; color: var(--cyan); font-size: 1.1rem; letter-spacing: 0.1em;">${escapeHtml(room.code)}</div>
<div style="font-size: 0.85rem; color: ${statusColor}; margin-top: 0.25rem;">${status}</div>
</div>
<div style="text-align: right; font-size: 0.85rem; color: #94a3b8;">
<div>Игроки: ${room.playerCount}/${room.maxPlayers}</div>
${room.spectators > 0 ? `<div>Наблюдателей: ${room.spectators}</div>` : ''}
${room.hasAI ? '<div style="color: var(--amber);">🤖 ИИ</div>' : ''}
</div>
</div>
<div style="font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.5rem;">${escapeHtml(playersText)}</div>
<button type="button" class="btn ${actionClass}" data-room-code="${room.code}" ${actionDisabled ? 'disabled' : ''} style="width: 100%;">${actionText}</button>
</div>`;
}).join('');
// Обработчики подключения к комнатам
$all('.room-item button').forEach(btn => {
btn.addEventListener('click', function() {
const code = this.dataset.roomCode;
const name = ($('browse-name')?.value || '').trim() || 'Игрок';
if (!code) return;
if (!socket || !socket.connected) {
const url = window.location.origin;
connect(url);
socket.once('connect', () => {
socket.emit('joinRoom', { code, name });
});
} else {
socket.emit('joinRoom', { code, name });
}
});
});
};
$('btn-instructions')?.addEventListener('click', () => {
$('instructions-overlay')?.classList.remove('hidden');
});

View File

@ -35,6 +35,7 @@
<div class="lobby-tabs">
<button type="button" class="tab active" data-tab="host">Создать игру</button>
<button type="button" class="tab" data-tab="join">Подключиться</button>
<button type="button" class="tab" data-tab="browse">Лобби</button>
</div>
<div id="host-panel" class="panel active">
@ -87,6 +88,15 @@
<button type="button" id="btn-leave-join" class="btn btn-ghost">Выйти</button>
</div>
<div id="browse-panel" class="panel hidden">
<label>Ваше имя</label>
<input type="text" id="browse-name" placeholder="Игрок" maxlength="20" />
<button type="button" id="btn-refresh-rooms" class="btn btn-ghost" style="margin-top: 0.5rem;">🔄 Обновить список</button>
<div id="rooms-list" class="rooms-list" style="margin-top: 1rem; max-height: 400px; overflow-y: auto;">
<p class="hint" style="text-align: center; padding: 2rem; color: #94a3b8;">Нажмите "Обновить список" для поиска игр</p>
</div>
</div>
<p id="lobby-error" class="error hidden"></p>
</div>
</section>
@ -241,7 +251,10 @@
<div class="modal">
<h2 id="modal-title">Результат</h2>
<p id="modal-body"></p>
<button type="button" id="btn-modal-close" class="btn btn-primary">OK</button>
<div class="modal-actions" style="display: flex; gap: 0.5rem; margin-top: 1rem; justify-content: center;">
<button type="button" id="btn-new-game" class="btn btn-primary" style="display: none;">🔄 Новая игра</button>
<button type="button" id="btn-modal-close" class="btn btn-ghost">Закрыть</button>
</div>
</div>
</div>

View File

@ -2071,6 +2071,34 @@ html, body {
.instructions-body .faction-n { color: #c4a574; }
.instructions-body .instructions-icon { font-size: 1.1em; vertical-align: middle; margin: 0 0.15em; opacity: 0.9; }
/* Rooms list (Лобби) */
.rooms-list {
margin-top: 1rem;
}
.room-item {
transition: all 0.2s;
}
.room-item:hover {
border-color: rgba(212,168,75,0.4) !important;
background: rgba(0,0,0,0.4) !important;
}
.room-item button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#spectator-badge {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Attack mode bar */
.attack-mode {
position: fixed;