(() => { 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'), modeDescription: el('mode-description'), 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 lobbyInfoTimer; let firePending = false; let targetActivator; 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'); const availabilityItem = (text) => { const item = document.createElement('span'); item.className = 'availability-item'; item.textContent = text; return item; }; ui.availability.replaceChildren( availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : role === 'player1' ? 'Вы' : info.player1Name || 'занят'}`), availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : role === 'player2' ? 'Вы' : info.player2Name || 'занят'}`), availabilityItem(`зрительских мест: ${info.spectatorsAvailable}`), ); if (state?.phase === 'lobby') renderLobby(); } 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 startLobbyInfoPolling() { if (!lobbyInfoTimer) { lobbyInfoTimer = window.setInterval(refreshInfo, 2000); refreshInfo(); } } function stopLobbyInfoPolling() { if (lobbyInfoTimer) window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = 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) && Array.isArray(payload.players) && payload.players.length === 2 && payload.players.every(name => typeof name === 'string' && name.length <= 80) && 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 canFireTarget(target) { return !firePending && canShoot() && Number.isInteger(target?.x) && Number.isInteger(target?.y) && target.x >= 0 && target.x < 10 && target.y >= 0 && target.y < 10 && state.boards[opponentIndex()][target.y * 10 + target.x] === '0'; } function playerName(index) { const name = state?.players?.[index]; return typeof name === 'string' && name.trim() ? name : `Игрок ${index + 1}`; } function playerLabel(index) { if (index === 1 && state?.mode === 'bot') return 'ESP32'; return isPlayer() && index === ownIndex() ? 'Вы' : playerName(index); } async function fireTarget(target) { if (!canFireTarget(target)) return false; firePending = true; selectedTarget = undefined; renderGame(); try { await post('/api/game/shot', { token, gameId: state.gameId, ...target }); return true; } catch (_) { return false; } finally { firePending = false; if (state?.phase === 'in_progress') renderGame(); } } function activateTarget(target, keyboard = false) { if (!targetActivator) { targetActivator = globalThis.BattleshipTargetInteraction.createTargetActivator({ canFire: canFireTarget, select: nextTarget => { selectedTarget = nextTarget; renderGame(); }, fire: fireTarget, }); } return keyboard ? targetActivator.keyboard(target) : targetActivator.tap(target); } function cellInfo(value) { if (value === '1') return ['ship', 'Корабль']; if (value === '2') return ['miss', 'Промах']; if (value === '3') return ['hit', 'Попадание']; if (value === '4') return ['sunk', 'Потопленный корабль']; return ['', 'Вода']; } const fleetClasses = [ { length: 4, count: 1, symbol: 'ship-4-battleship', label: 'Линкор', className: 'battleship' }, { length: 3, count: 2, symbol: 'ship-3-cruiser', label: 'Крейсер', className: 'cruiser' }, { length: 2, count: 3, symbol: 'ship-2-destroyer', label: 'Эсминец', className: 'destroyer' }, { length: 1, count: 4, symbol: 'ship-1-cutter', label: 'Катер', className: 'cutter' }, ]; function sunkShipLengths(board) { const visited = new Set(); const lengths = []; for (let start = 0; start < board.length; start += 1) { if (board[start] !== '4' || visited.has(start)) continue; let length = 0; const pending = [start]; visited.add(start); while (pending.length) { const index = pending.pop(); length += 1; const x = index % 10; const y = Math.floor(index / 10); [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]].forEach(([nextX, nextY]) => { const next = nextY * 10 + nextX; if (nextX >= 0 && nextX < 10 && nextY >= 0 && nextY < 10 && board[next] === '4' && !visited.has(next)) { visited.add(next); pending.push(next); } }); } lengths.push(length); } return lengths; } function fleetStatusElement(index, title) { const sunkLengths = sunkShipLengths(state.boards[index]); const fleet = document.createElement('section'); fleet.className = 'fleet-status'; fleet.setAttribute('aria-label', `Флот: ${title}`); fleetClasses.forEach(shipClass => { const row = document.createElement('div'); row.className = 'fleet-row'; row.append(Object.assign(document.createElement('span'), { className: 'fleet-class', textContent: shipClass.label })); const ships = document.createElement('div'); ships.className = 'fleet-ships'; for (let number = 0; number < shipClass.count; number += 1) { const sunkAt = sunkLengths.indexOf(shipClass.length); const sunk = sunkAt !== -1; if (sunk) sunkLengths.splice(sunkAt, 1); const ship = document.createElement('span'); ship.className = `fleet-ship ${shipClass.className}${sunk ? ' sunk' : ''}`; ship.setAttribute('role', 'img'); ship.setAttribute('aria-label', `${shipClass.label} — ${sunk ? 'потоплен' : 'цел'}`); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${shipClass.symbol}`); svg.append(use); ship.append(svg); ships.append(ship); } row.append(ships); fleet.append(row); }); return fleet; } 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' || firePending; 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'); const target = { x, y }; cell.addEventListener('pointerup', event => { if (event.button !== 0) return; event.preventDefault(); activateTarget(target); }); cell.addEventListener('click', event => { if (event.detail === 0) activateTarget(target, true); }); cell.addEventListener('dblclick', event => { event.preventDefault(); if (targetActivator) targetActivator.doubleActivate(target); }); } else if (value === '0') { cell.classList.add(index === ownIndex() && isPlayer() ? 'own-water' : 'unavailable'); } grid.append(cell); } } board.append(grid); board.append(fleetStatusElement(index, title)); if (result) board.hidden = false; return board; } function boardDefinitions(result = false) { if (role === 'spectator' || result) return [ { index: 0, title: `Поле: ${playerLabel(0)}`, key: 'player1', targetable: false }, { index: 1, title: `Поле: ${playerLabel(1)}`, key: 'player2', targetable: false } ]; return [ { index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false }, { index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, 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.key === 'opponent' ? playerLabel(definition.index) : definition.title; tab.title = definition.title; tab.setAttribute('aria-label', 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'; const opponent = state.mode === 'bot' ? 'ESP32' : playerName(opponentIndex()); ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : state.mode === 'human' && info?.player2Available ? 'Ожидаем соперника.' : `Ожидаем готовности ${opponent}.`; ui.modeControls.hidden = !playerOne; if (playerOne) startLobbyInfoPolling(); else stopLobbyInfoPolling(); const playerTwoReady = info?.player2Available === false; const canStart = playerOne && (state.mode === 'bot' || playerTwoReady); document.querySelectorAll('.mode-button').forEach(button => { const selected = button.dataset.mode === state.mode; button.setAttribute('aria-checked', String(selected)); button.disabled = !playerOne; }); ui.start.disabled = !canStart; ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? `${playerName(1)} готов. Можно начать партию.` : 'Ожидаем соперника.') : 'Игра против ESP32. Можно начать сразу.'; ui.lobbyHelp.textContent = playerOne ? '' : `${playerName(0)} выбирает режим и запускает партию.`; } function renderGame() { if (!state) return; stopLobbyInfoPolling(); showScreen('game'); if (canShoot() && !selectedTarget) activeBoard = 'opponent'; ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`; ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`; renderBoards(ui.boards); const available = canShoot(); ui.shotControls.hidden = !isPlayer(); ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.'; ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget); ui.fire.textContent = firePending ? 'Выстрел…' : 'Огонь'; ui.cancel.disabled = !selectedTarget || firePending; ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress'); } function renderResult() { stopLobbyInfoPolling(); showScreen('result'); if (statisticsGameId !== state.gameId) refreshStatistics(); const waiting = state.phase === 'rematch_wait'; const winner = state.winner === ownIndex() && isPlayer() ? 'Вы победили. ' : 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) { stopLobbyInfoPolling(); 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(''); setConnection('Подключено', 'online'); 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-обновление', 'polling'); 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', () => { if (selectedTarget) fireTarget(selectedTarget); }); 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(); })();