From 7d5769ac8b8db34aa1a7af1bbf4397b346b73bbd Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Tue, 27 Jan 2026 21:43:42 +0300 Subject: [PATCH] 123 --- public/game.js | 263 ++++++++++++++++++++++++++++++++++++---------- public/index.html | 15 ++- public/styles.css | 28 +++++ server.js | 48 ++++++++- 4 files changed, 295 insertions(+), 59 deletions(-) diff --git a/public/game.js b/public/game.js index c87b28f..3ed7339 100644 --- a/public/game.js +++ b/public/game.js @@ -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 = '
' + @@ -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 @@
+ +
@@ -241,7 +251,10 @@
diff --git a/public/styles.css b/public/styles.css index 30452ef..2d7fadc 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; diff --git a/server.js b/server.js index 8e40932..a689634 100644 --- a/server.js +++ b/server.js @@ -27,7 +27,7 @@ function getCard(room, cardId) { return cardDb[cardId]; } -const rooms = new Map(); // code -> { lobby, gameState, gameStarted, faction, turnTimerInterval, turnTimeLeft, turnTimerWarnFired } +const rooms = new Map(); // code -> { lobby, gameState, gameStarted, faction, turnTimerInterval, turnTimeLeft, turnTimerWarnFired, spectators: [] } const TURN_SECONDS = 90; const TURN_WARN_AT = 15; @@ -433,6 +433,7 @@ function getRoomBySocket(socketId) { for (const room of rooms.values()) { if (room.lobby?.some((p) => p.id === socketId)) return room; if (room.gameState?.players?.some((p) => p.id === socketId)) return room; + if (room.spectators?.some((s) => s.id === socketId)) return room; } return null; } @@ -1835,6 +1836,7 @@ io.on('connection', (socket) => { turnTimerInterval: null, turnTimeLeft: TURN_SECONDS, turnTimerWarnFired: false, + spectators: [], }; rooms.set(code, room); socket.join(code); @@ -1849,6 +1851,19 @@ io.on('connection', (socket) => { cleanupEmptyRooms(); }); + socket.on('getRoomsList', () => { + const roomsList = Array.from(rooms.entries()).map(([code, room]) => ({ + code, + playerCount: room.lobby?.length || 0, + maxPlayers: MAX_PLAYERS, + gameStarted: room.gameStarted || false, + hasAI: room.aiMode || false, + players: room.lobby?.map(p => p.name) || [], + spectators: (room.spectators || []).length + })); + socket.emit('roomsList', roomsList); + }); + socket.on('joinRoom', (data) => { const { code, name } = data || {}; if (!code) { @@ -1860,10 +1875,18 @@ io.on('connection', (socket) => { socket.emit('error', 'Комната не найдена'); return; } + if (room.gameStarted) { - socket.emit('error', 'Игра уже началась'); + // Игра уже началась - подключаемся как наблюдатель + if (!room.spectators) room.spectators = []; + room.spectators.push({ id: socket.id, name: name || 'Наблюдатель' }); + socket.join(code); + socket.emit('joinedAsSpectator', { code, gameState: room.gameState }); + broadcastGameState(room); // Отправляем текущее состояние игры return; } + + // Игра не началась - подключаемся как игрок if (room.lobby.length >= MAX_PLAYERS) { socket.emit('error', 'Комната заполнена'); return; @@ -2070,15 +2093,34 @@ io.on('connection', (socket) => { const hostId = room.gameState.players[0].id; if (socket.id !== hostId) return; clearTurnTimer(room); + // Сохраняем список игроков для новой игры room.lobby = room.gameState.players.map((p) => ({ id: p.id, name: p.name })); + // Сохраняем настройки комнаты + const savedFaction = room.faction; + const savedAIMode = room.aiMode; room.gameState = null; room.gameStarted = false; - io.to(room.code).emit('backToLobby'); + room.faction = savedFaction; + room.aiMode = savedAIMode; + // Очищаем наблюдателей + if (room.spectators) room.spectators = []; + io.to(room.code).emit('backToLobby', { lobby: room.lobby }); }); socket.on('disconnect', () => { const room = getRoomBySocket(socket.id); if (!room) return; + + // Проверяем, был ли это наблюдатель + if (room.spectators) { + const specIdx = room.spectators.findIndex(s => s.id === socket.id); + if (specIdx >= 0) { + room.spectators.splice(specIdx, 1); + socket.leave(room.code); + return; + } + } + if (!room.gameStarted) { room.lobby = room.lobby.filter((p) => p.id !== socket.id); socket.leave(room.code);