123
This commit is contained in:
263
public/game.js
263
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 = '<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');
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
48
server.js
48
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);
|
||||
|
||||
Reference in New Issue
Block a user