(() => { const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К']; const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' }; const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error']; const el = id => document.getElementById(id); const ui = { status: el('connection-status'), notice: el('notice'), name: el('display-name'), availability: el('availability'), lobbyDescription: el('lobby-description'), lobbyHelp: el('lobby-help'), modeControls: el('mode-controls'), start: el('start-game'), boards: el('boards'), resultBoards: el('result-boards'), tabs: el('board-tabs'), turn: el('turn-status'), wins: el('wins'), shotControls: el('shot-controls'), target: el('target-status'), fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'), result: el('result-description'), error: el('error-description') }; let token = localStorage.getItem(storage.token) || ''; let role = ''; let info; let state; let selectedTarget; let cumulativeStatistics; let statisticsGameId; let activeBoard = 'own'; let socket; let retryTimer; let pollTimer; let heartbeatTimer; let retryIndex = 0; function showScreen(name) { screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; }); } function setConnection(text, status) { ui.status.textContent = text; ui.status.className = `connection-status ${status || ''}`; } function notify(message, error = false) { ui.notice.hidden = !message; ui.notice.textContent = message || ''; ui.notice.classList.toggle('error', error); } function apiError(payload, fallback) { return payload?.message || fallback || 'Сервер временно недоступен.'; } async function request(path, options = {}) { const response = await fetch(path, { cache: 'no-store', ...options }); let payload; try { payload = await response.json(); } catch (_) { throw new Error('Сервер вернул некорректный ответ.'); } if (!response.ok || payload.ok === false) throw new Error(apiError(payload)); return payload; } function headers() { return { 'Content-Type': 'application/json' }; } async function refreshInfo() { try { info = await request('/api/info'); ui.availability.textContent = `Игрок 1: ${info.player1Available ? 'свободен' : 'занят'} · Игрок 2: ${info.player2Available ? 'свободен' : 'занят'} · зрительских мест: ${info.spectatorsAvailable}`; } catch (error) { ui.availability.textContent = error.message; } } async function pollState() { try { const response = await fetch(`/api/state?version=${state?.version || 0}`, { cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {} }); const payload = await response.json(); if (!response.ok || payload.ok === false) throw new Error(apiError(payload)); acceptState(payload); } catch (error) { if (!state) showError(error.message); } } function beginPolling() { if (!pollTimer) pollTimer = window.setInterval(pollState, 2000); pollState(); } function stopPolling() { if (pollTimer) window.clearInterval(pollTimer); pollTimer = undefined; } function stopHeartbeat() { if (heartbeatTimer) window.clearInterval(heartbeatTimer); heartbeatTimer = undefined; } function safeState(payload) { return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 && payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) && typeof payload.version === 'number' && typeof payload.gameId === 'number' && (payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) && payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 && entry.every(value => Number.isInteger(value) && value >= 0)); } function acceptState(payload) { if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; } const hadGap = state && payload.version > state.version + 1; state = payload; role = payload.viewer; if (hadGap) pollState(); selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined; document.title = `Морской бой — версия ${payload.version}`; render(); } function isPlayer() { return role === 'player1' || role === 'player2'; } function ownIndex() { return role === 'player2' ? 1 : 0; } function opponentIndex() { return ownIndex() ^ 1; } function canShoot() { return state?.phase === 'in_progress' && state.turn === role && isPlayer(); } function playerLabel(index) { return index === 1 && state?.mode === 'bot' ? 'ESP32' : `Игрок ${index + 1}`; } function cellInfo(value) { if (value === '1') return ['ship', 'Корабль']; if (value === '2') return ['miss', 'Промах']; if (value === '3') return ['hit', 'Попадание']; if (value === '4') return ['sunk', 'Потопленный корабль']; return ['', 'Вода']; } function boardElement(index, title, targetable, result) { const board = document.createElement('section'); board.className = 'board'; board.dataset.board = String(index); const heading = document.createElement('h3'); heading.textContent = title; board.append(heading); const grid = document.createElement('div'); grid.className = 'board-grid'; grid.setAttribute('role', 'grid'); grid.setAttribute('aria-label', title); grid.append(Object.assign(document.createElement('span'), { className: 'axis' })); columns.forEach(column => grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: column }))); for (let y = 0; y < 10; y += 1) { grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: String(y + 1) })); for (let x = 0; x < 10; x += 1) { const value = state.boards[index][y * 10 + x]; const [kind, label] = cellInfo(value); const cell = document.createElement('button'); cell.type = 'button'; cell.className = `cell ${kind}`.trim(); cell.disabled = !targetable || value !== '0'; cell.setAttribute('aria-label', `${columns[x]}${y + 1}: ${label}`); if (targetable && value === '0') { cell.classList.add('target'); if (selectedTarget?.x === x && selectedTarget?.y === y) cell.classList.add('selected'); cell.addEventListener('click', () => { selectedTarget = { x, y }; renderGame(); }); } grid.append(cell); } } board.append(grid); if (result) board.hidden = false; return board; } function boardDefinitions(result = false) { if (role === 'spectator' || result) return [ { index: 0, title: 'Поле игрока 1', key: 'player1', targetable: false }, { index: 1, title: 'Поле игрока 2', key: 'player2', targetable: false } ]; return [ { index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false }, { index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : 'Поле соперника', key: 'opponent', targetable: canShoot() } ]; } function renderBoards(container, result = false) { container.replaceChildren(); const definitions = boardDefinitions(result); definitions.forEach(definition => { const board = boardElement(definition.index, definition.title, definition.targetable, result); board.hidden = !result && definition.key !== activeBoard; container.append(board); }); if (!result) { ui.tabs.replaceChildren(); definitions.forEach(definition => { const tab = document.createElement('button'); tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.title; tab.setAttribute('aria-selected', String(definition.key === activeBoard)); tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); }); ui.tabs.append(tab); }); } } function renderLobby() { showScreen('lobby'); const playerOne = role === 'player1'; ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : 'Ожидайте готовности партии или настройте режим.'; ui.modeControls.hidden = !playerOne; document.querySelectorAll('.mode-button').forEach(button => { const selected = button.dataset.mode === state.mode; button.setAttribute('aria-pressed', String(selected)); button.disabled = !playerOne; }); ui.start.disabled = !playerOne; ui.lobbyHelp.textContent = playerOne ? (state.mode === 'human' ? 'Для игры вдвоём дождитесь второго игрока.' : 'ESP32 станет вашим соперником.') : 'Игрок 1 выбирает режим и запускает партию.'; } function renderGame() { if (!state) return; showScreen('game'); ui.turn.textContent = state.turn === role ? 'Ваш ход. Выберите клетку соперника.' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}.`; ui.wins.textContent = `Победы: ${state.wins[0]} : ${state.wins[1]}`; renderBoards(ui.boards); const available = canShoot(); ui.shotControls.hidden = !isPlayer(); ui.target.textContent = available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}. Подтвердите выстрел.` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.'; ui.fire.disabled = !available || !selectedTarget; ui.cancel.disabled = !selectedTarget; ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress'); } function renderResult() { showScreen('result'); if (statisticsGameId !== state.gameId) refreshStatistics(); const waiting = state.phase === 'rematch_wait'; const winner = state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : ''; const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' '); const cumulative = cumulativeStatistics?.cumulative?.map((entry, index) => `${playerLabel(index)} всего: игр ${entry[0]}, побед ${entry[1]}, поражений ${entry[2]}, выстрелов ${entry[4]}, попаданий ${entry[5]}, промахов ${entry[6]}, потоплено ${entry[3]}.`).join(' ') || ''; ui.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`; ui.rematch.hidden = !isPlayer(); ui.rematch.disabled = !isPlayer(); ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё'; renderBoards(ui.resultBoards, true); } function render() { if (!state) return; if (state.phase === 'lobby' || state.phase === 'preparing') renderLobby(); else if (state.phase === 'in_progress') renderGame(); else renderResult(); } function showError(message) { ui.error.textContent = message; setConnection('Нет связи', 'offline'); showScreen('error'); } async function post(path, body) { try { const response = await request(path, { method: 'POST', headers: headers(), body: JSON.stringify(body) }); await pollState(); return response; } catch (error) { notify(error.message, true); throw error; } } async function refreshStatistics() { const requestedGameId = state?.gameId; try { const response = await request('/api/statistics', { cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {} }); if (state?.gameId === requestedGameId && response.gameId === requestedGameId && Array.isArray(response.cumulative)) { cumulativeStatistics = response; statisticsGameId = requestedGameId; renderResult(); } } catch (_) { /* The state snapshot still renders the current-match statistics. */ } } async function join(requestedRole) { const name = ui.name.value.trim(); if (!name) { notify('Введите имя.', true); ui.name.focus(); return; } try { const joined = await request('/api/session/join', { method: 'POST', headers: headers(), body: JSON.stringify({ name, requestedRole }) }); token = joined.token; role = joined.role; localStorage.setItem(storage.token, token); localStorage.setItem(storage.name, name); notify('Подключено.'); await pollState(); connectSocket(); } catch (error) { notify(error.message, true); } } function connectSocket() { if (!token || socket?.readyState === WebSocket.OPEN || retryTimer) return; const scheme = location.protocol === 'https:' ? 'wss' : 'ws'; try { socket = new WebSocket(`${scheme}://${location.host}/ws`); } catch (_) { beginPolling(); return; } socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', token, version: state?.version || 0 })); socket.onmessage = event => { try { const message = JSON.parse(event.data); if (message.type === 'state') { acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online'); if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000); } else if (message.ok === false) notify(apiError(message), true); } catch (_) { socket.close(); } }; socket.onerror = () => socket.close(); socket.onclose = () => { socket = undefined; stopHeartbeat(); beginPolling(); setConnection('HTTP-обновление', 'offline'); const delays = [1000, 2000, 5000, 10000]; const delay = delays[Math.min(retryIndex++, delays.length - 1)]; retryTimer = window.setTimeout(() => { retryTimer = undefined; connectSocket(); }, delay); }; } el('join-form').addEventListener('submit', event => { event.preventDefault(); const requestedRole = info?.player1Available ? 'player1' : info?.player2Available ? 'player2' : 'spectator'; join(requestedRole); }); el('join-spectator').addEventListener('click', () => join('spectator')); document.querySelectorAll('.mode-button').forEach(button => button.addEventListener('click', () => post('/api/game/config', { token, gameId: state.gameId, mode: button.dataset.mode }))); ui.start.addEventListener('click', () => post('/api/game/start', { token, gameId: state.gameId })); ui.fire.addEventListener('click', async () => { if (!selectedTarget) return; await post('/api/game/shot', { token, gameId: state.gameId, ...selectedTarget }); selectedTarget = undefined; }); ui.cancel.addEventListener('click', () => { selectedTarget = undefined; renderGame(); }); ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId })); ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId })); el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); }); async function boot() { ui.name.value = localStorage.getItem(storage.name) || ''; setConnection('Проверяем ESP32…'); await refreshInfo(); if (!token) { showScreen('connect'); setConnection('Готово к подключению'); return; } try { const resumed = await request('/api/session/resume', { method: 'POST', headers: headers(), body: JSON.stringify({ token }) }); role = resumed.role; await pollState(); connectSocket(); } catch (_) { localStorage.removeItem(storage.token); token = ''; showScreen('connect'); notify('Предыдущая сессия больше недоступна. Подключитесь снова.'); } } boot(); })();