' +
@@ -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 `
${escapeHtml(name)}${isDead ? ' ✝' : ''}
@@ -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 = '
Режим наблюдателя — вы не играете
';
+ } 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 = '
Режим наблюдателя — вы не можете играть
';
+ } 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 = '
Нет доступных игр
';
+ 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 `
+
+
+
${escapeHtml(room.code)}
+
${status}
+
+
+
Игроки: ${room.playerCount}/${room.maxPlayers}
+ ${room.spectators > 0 ? `
Наблюдателей: ${room.spectators}
` : ''}
+ ${room.hasAI ? '
🤖 ИИ
' : ''}
+
+
+
${escapeHtml(playersText)}
+
+
`;
+ }).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');
});
diff --git a/public/index.html b/public/index.html
index 8f0d1ed..a5cdd52 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,6 +35,7 @@
+
@@ -87,6 +88,15 @@
+
+
+
+
+
+
Нажмите "Обновить список" для поиска игр
+
+
+