(() => { 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'), lobbyIcon: el('lobby-icon'), resultIcon: el('result-icon'), cue: el('action-cue'), cueIcon: el('action-cue-icon'), cueText: el('action-cue-text'), effects: el('game-effects'), effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge'), recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live') }; 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; let effectTimer; let hitStreak = 0; let recoveryAction; let recoveryPending = false; let recoveryHoldTimer; let recoveryOpener; const reaction = (kind, icon, text, motion = 'pop', burst = 'none', vibration = [45]) => ({ kind, icon, text, motion, burst, vibration }); const feedback = { start: [ reaction('start', 'ship-4-battleship', 'Бой начинается!', 'swoop', 'bubbles', [80]), reaction('start', 'icon-rocket', 'Полный вперёд!', 'zoom', 'sparks', [50, 30, 90]), reaction('start', 'ship-3-cruiser', 'Поднять якоря!', 'bounce', 'bubbles', [90]), reaction('start', 'icon-target', 'К бою готовы!', 'spin', 'stars', [60, 30, 60]), ], turn: [ reaction('turn', 'icon-target', 'Твой ход!', 'pop', 'sparks', [80, 40, 80]), reaction('turn', 'icon-blast', 'Капитан, выбирай!', 'bounce', 'stars', [70, 30, 70]), reaction('turn', 'icon-target', 'Пора пулять!', 'zoom', 'sparks', [90]), reaction('turn', 'ship-1-cutter', 'Твоя очередь!', 'swoop', 'bubbles', [60, 30, 80]), reaction('turn', 'icon-target', 'Где прячется корабль?', 'spin', 'stars', [60]), reaction('turn', 'icon-target', 'Твой мув!', 'zoom', 'sparks', [70]), ], hit: [ reaction('hit', 'icon-blast', 'Попадание!', 'pop', 'sparks', [110]), reaction('hit', 'icon-target', 'Точно в цель!', 'zoom', 'stars', [80, 25, 120]), reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [130]), reaction('hit', 'ship-1-cutter', 'Есть контакт!', 'swoop', 'stars', [70, 30, 100]), reaction('hit', 'icon-blast', 'Мочный выстрел!', 'bounce', 'stars', [90, 30, 120]), reaction('hit', 'icon-target', 'Прямо в яички!', 'zoom', 'sparks', [120]), reaction('hit', 'icon-blast', 'КРИТ!', 'zoom', 'sparks', [90, 20, 130]), reaction('hit', 'icon-trophy', 'ИМБА!', 'spin', 'stars', [80, 30, 120]), reaction('hit', 'icon-target', 'Лютый пострил!', 'bounce', 'stars', [100]), reaction('hit', 'icon-blast', 'Опасное попадание!', 'swoop', 'sparks', [100]), ], sunk: [ reaction('hit', 'icon-trophy', 'Этот уже не похилится!', 'spin', 'stars', [90, 40, 140]), reaction('hit', 'icon-blast', 'Минус один!', 'zoom', 'sparks', [100, 40, 150]), reaction('hit', 'icon-trophy', 'Вот это капитан!', 'bounce', 'stars', [80, 30, 80, 30, 140]), reaction('hit', 'icon-wave', 'Лодка буль-буль!', 'swoop', 'bubbles', [120, 50, 150]), reaction('hit', 'ship-4-battleship', 'Победа будет наша!', 'pop', 'stars', [90, 40, 130]), reaction('hit', 'icon-trophy', 'Ты Босс!', 'zoom', 'stars', [100, 40, 160]), reaction('hit', 'icon-blast', 'Ультра-урон!', 'spin', 'sparks', [110, 40, 150]), reaction('hit', 'icon-trophy', 'Это было Жопично!', 'bounce', 'stars', [100, 40, 150]), ], miss: [ reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [45]), reaction('miss', 'icon-wave', 'Буль-буль!', 'bounce', 'bubbles', [35]), reaction('miss', 'icon-target', 'Чуть-чуть не попал!', 'pop', 'bubbles', [40]), reaction('miss', 'icon-wave', 'Рыбки увернулись!', 'spin', 'bubbles', [30, 20, 30]), reaction('miss', 'icon-wave', 'Волна поймала снаряд!', 'zoom', 'bubbles', [45]), reaction('miss', 'icon-target', 'Не угадал!', 'swoop', 'none', [35]), reaction('miss', 'icon-wave', 'А там ничего нет!', 'spin', 'bubbles', [40]), reaction('miss', 'icon-target', 'Почти хайлайт!', 'zoom', 'none', [35]), reaction('miss', 'icon-wave', 'Вода: 1. Снаряд: 0.', 'bounce', 'bubbles', [35]), ], dodged: [ reaction('miss', 'icon-wave', 'Не попали!', 'swoop', 'bubbles', [40]), reaction('miss', 'ship-1-cutter', 'В этот раз повезло!', 'bounce', 'stars', [35, 20, 35]), reaction('miss', 'icon-wave', 'Дуракам везет!', 'pop', 'bubbles', [45]), reaction('miss', 'ship-2-destroyer', 'Косоглазые!', 'zoom', 'bubbles', [35]), ], damage: [ reaction('damage', 'icon-blast', 'В нас попали!', 'zoom', 'sparks', [160]), reaction('damage', 'ship-2-destroyer', 'Нас вычислили!', 'swoop', 'sparks', [140, 40, 100]), reaction('damage', 'icon-blast', 'Спасайся кто может!', 'spin', 'sparks', [170]), reaction('damage', 'ship-1-cutter', 'A-a-a ранен!', 'bounce', 'none', [150]), ], 'sunk-damage': [ reaction('damage', 'icon-wave', 'Наш корабль потоплен!', 'swoop', 'bubbles', [180, 60, 180]), reaction('damage', 'ship-4-battleship', 'Еще не все пропало!', 'bounce', 'sparks', [150, 50, 130]), reaction('damage', 'icon-repeat', 'Не сдаёмся!', 'zoom', 'stars', [130, 40, 160]), reaction('damage', 'icon-blast', 'Мы отомстим!', 'spin', 'sparks', [160]), ], victory: [ reaction('victory', 'icon-trophy', 'Победа!', 'pop', 'stars', [80, 40, 80, 40, 180]), reaction('victory', 'icon-trophy', 'БОСС моря!', 'spin', 'stars', [70, 30, 70, 30, 170]), reaction('victory', 'ship-4-battleship', 'Мы их всех утопили!', 'swoop', 'bubbles', [90, 40, 160]), reaction('victory', 'icon-trophy', 'Вот это победа!', 'bounce', 'stars', [80, 30, 90, 30, 180]), reaction('victory', 'icon-rocket', 'Капитан — суперзвезда!', 'zoom', 'sparks', [100, 40, 180]), reaction('victory', 'icon-trophy', 'ЛЕГЕНДА!', 'spin', 'stars', [90, 30, 90, 30, 190]), reaction('victory', 'icon-trophy', 'Вот это скилл!', 'bounce', 'stars', [80, 30, 180]), reaction('victory', 'icon-rocket', 'GG! Красиво!', 'zoom', 'sparks', [100, 40, 180]), ], defeat: [ reaction('damage', 'icon-repeat', 'Поиграем еще раз?', 'spin', 'stars', [120]), reaction('damage', 'ship-3-cruiser', 'В следующий раз порву!', 'swoop', 'bubbles', [100, 40, 120]), reaction('damage', 'icon-repeat', 'В плен не сдаемся!', 'bounce', 'stars', [110]), reaction('damage', 'icon-target', 'Попробуем ещё раз!', 'zoom', 'sparks', [100]), ], finish: [ reaction('victory', 'icon-trophy', 'Бой окончен!', 'pop', 'stars', [80]), reaction('victory', 'ship-4-battleship', 'Вот это битва!', 'swoop', 'bubbles', [80]), reaction('victory', 'icon-trophy', 'Морской бой завершён!', 'spin', 'stars', [80]), ], 'watch-hit': [ reaction('hit', 'icon-blast', 'Уууууу!', 'pop', 'sparks', [80]), reaction('hit', 'icon-target', 'Попал!', 'zoom', 'stars', [80]), reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [80]), ], 'watch-sunk': [ reaction('hit', 'icon-trophy', 'Корабль потоплен!', 'bounce', 'stars', [100]), reaction('hit', 'icon-wave', 'Ушёл под воду!', 'swoop', 'bubbles', [100]), reaction('hit', 'icon-blast', 'Вот это выстрел!', 'zoom', 'sparks', [100]), ], 'watch-miss': [ reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [40]), reaction('miss', 'icon-wave', 'Буль!', 'bounce', 'bubbles', [40]), reaction('miss', 'icon-target', 'Почти попал!', 'pop', 'none', [40]), ], }; const pickReaction = globalThis.BattleshipTargetInteraction.createReactionPicker(feedback); function setSpriteIcon(container, symbol) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'ui-icon'); svg.setAttribute('aria-hidden', 'true'); const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${symbol}`); svg.append(use); container.replaceChildren(svg); } function fillBurst(burst) { ui.effectBurst.replaceChildren(); ui.effectBurst.className = `effect-burst burst-${burst}`; if (burst === 'none') return; const symbols = burst === 'bubbles' ? ['○', 'o', '·'] : burst === 'sparks' ? ['✦', '+', '*'] : ['★', '✦', '*']; const count = burst === 'bubbles' ? 14 : burst === 'stars' ? 22 : 18; for (let index = 0; index < count; index += 1) { const particle = document.createElement('span'); const angle = (Math.PI * 2 * index) / count; const distance = 35 + (index % 4) * 12; particle.textContent = symbols[index % symbols.length]; particle.style.setProperty('--x', `${Math.cos(angle) * distance}vw`); particle.style.setProperty('--y', `${Math.sin(angle) * distance}vh`); particle.style.setProperty('--spin', `${(index % 2 ? 1 : -1) * (140 + index * 13)}deg`); particle.style.setProperty('--rotate', `${index * 23}deg`); particle.style.setProperty('--delay', `${(index % 5) * 35}ms`); ui.effectBurst.append(particle); } } function showFeedback(event) { const content = pickReaction(event?.type); if (!content) return; let { motion, burst } = content; const { kind, icon, text, vibration } = content; if (event.type === 'hit' || event.type === 'sunk') hitStreak += 1; else if (event.type === 'miss' || event.type === 'start' || event.type === 'victory' || event.type === 'defeat') hitStreak = 0; const combo = hitStreak >= 2; if (combo) { motion = hitStreak >= 3 ? 'combo' : motion; burst = 'stars'; } window.clearTimeout(effectTimer); ui.effects.className = `game-effects effect-${kind} motion-${motion}${combo ? ' combo-power' : ''}`; setSpriteIcon(ui.effectIcon, icon); ui.effectText.textContent = text; ui.effectBadge.hidden = !combo; ui.effectBadge.textContent = combo ? (hitStreak >= 4 ? `УЛЬТРА-КОМБО ×${hitStreak}` : hitStreak === 3 ? 'МЕГА-КОМБО ×3' : 'КОМБО ×2') : ''; fillBurst(burst); ui.effects.hidden = false; document.body.classList.remove('fx-hit', 'fx-damage'); void document.body.offsetWidth; document.body.classList.add(kind === 'damage' ? 'fx-damage' : 'fx-hit'); if (navigator.vibrate) navigator.vibrate(vibration); effectTimer = window.setTimeout(() => { ui.effects.hidden = true; document.body.classList.remove('fx-hit', 'fx-damage'); }, kind === 'victory' ? 2200 : combo ? 1800 : 1350); } function showScreen(name) { screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; }); ui.recoveryButton.hidden = name === 'connect' || !token; } function clearRecoveryTimers() { window.clearTimeout(retryTimer); retryTimer = undefined; window.clearInterval(pollTimer); pollTimer = undefined; window.clearInterval(heartbeatTimer); heartbeatTimer = undefined; window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = undefined; window.clearTimeout(effectTimer); effectTimer = undefined; window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; } function clearLocalProfile(profile) { clearRecoveryTimers(); if (socket) { socket.onclose = null; socket.close(); socket = undefined; } selectedTarget = undefined; firePending = false; cumulativeStatistics = undefined; statisticsGameId = undefined; activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined; document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify(''); Object.keys(localStorage).filter(key => key.startsWith('battleship.')).forEach(key => localStorage.removeItem(key)); if (!profile) { const rememberedName = ui.name.value.trim(); if (rememberedName) localStorage.setItem(storage.name, rememberedName); } token = ''; ui.name.value = profile ? '' : (localStorage.getItem(storage.name) || ''); closeRecovery(); showScreen('connect'); setConnection('Готово к подключению'); } function closeRecovery() { window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; recoveryAction = undefined; recoveryPending = false; ui.recoveryDialog.hidden = true; ui.recoveryButton.setAttribute('aria-expanded', 'false'); if (recoveryOpener) recoveryOpener.focus(); } function recoveryIcon(symbol) { setSpriteIcon(ui.recoveryScopeIcon, symbol); } function showRecoveryConfirmation(action) { recoveryAction = action; ui.recoveryChoices.hidden = true; ui.recoveryConfirmation.hidden = false; const game = action === 'game'; const profile = action === 'profile'; recoveryIcon(game ? 'icon-players' : 'icon-person'); ui.recoveryConfirmTitle.textContent = game ? 'Сбросить всю игру?' : profile ? 'Сбросить мой профиль?' : 'Выйти из игры?'; ui.recoveryConfirmText.textContent = game ? 'Все игроки вернутся к началу.' : profile ? 'На этом устройстве очистятся имя и данные.' : 'Вы вернётесь к подключению. Имя останется.'; ui.recoveryConfirmLabel.textContent = game ? 'Удерживать для сброса' : profile ? 'Сбросить профиль' : 'Выйти'; ui.recoveryHoldNote.hidden = !game; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryConfirm.disabled = false; ui.recoveryCancel.focus(); } function openRecovery() { if (!token) return; recoveryOpener = document.activeElement; ui.recoveryDialog.hidden = false; ui.recoveryButton.setAttribute('aria-expanded', 'true'); ui.recoveryChoices.hidden = false; ui.recoveryConfirmation.hidden = true; ui.recoveryGlobal.hidden = !isPlayer(); ui.recoveryClose.focus(); } async function submitRecovery() { if (!recoveryAction || recoveryPending) return; recoveryPending = true; ui.recoveryConfirm.disabled = true; ui.recoveryLive.textContent = 'Выполняем действие…'; const action = recoveryAction; if (action === 'profile' && navigator.onLine === false) { clearLocalProfile(true); notify('Профиль очищен на этом устройстве. Место на ESP32 освободится после восстановления связи или таймаута.'); return; } try { if (action === 'leave') await request('/api/session/leave', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) }); else if (action === 'profile') await request('/api/session/profile-reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) }); else await request('/api/game/reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) }); clearLocalProfile(action !== 'leave'); } catch (error) { recoveryPending = false; ui.recoveryConfirm.disabled = false; ui.recoveryLive.textContent = 'Не получилось. Можно повторить или отменить.'; notify(error.message, true); } } 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; const previousState = state; 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(); showFeedback(globalThis.BattleshipTargetInteraction.detectFeedback(previousState, payload, role)); } 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', viewBox: '0 0 192 50', label: 'Линкор', className: 'battleship' }, { length: 3, count: 2, symbol: 'ship-3-cruiser', viewBox: '0 0 144 40', label: 'Крейсер', className: 'cruiser' }, { length: 2, count: 3, symbol: 'ship-2-destroyer', viewBox: '0 0 96 30', label: 'Эсминец', className: 'destroyer' }, { length: 1, count: 4, symbol: 'ship-1-cutter', viewBox: '0 0 48 26', 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'); svg.setAttribute('viewBox', shipClass.viewBox); svg.setAttribute('preserveAspectRatio', 'xMidYMax meet'); const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${shipClass.symbol}`); use.setAttribute('width', '100%'); use.setAttribute('height', '100%'); 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${targetable ? ' is-action' : ''}`; 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 = state.players[1].trim() !== '' || info?.player2Available === false; const canStart = playerOne && (state.mode === 'bot' || playerTwoReady); setSpriteIcon(ui.lobbyIcon, canStart ? 'icon-check' : 'icon-hourglass'); 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.cue.classList.toggle('is-ready', available && !selectedTarget); ui.cue.classList.toggle('has-target', Boolean(available && selectedTarget)); setSpriteIcon(ui.cueIcon, available ? (selectedTarget ? 'icon-blast' : 'icon-target') : 'icon-hourglass'); ui.cueText.textContent = available ? (selectedTarget ? 'Жми «Огонь»!' : 'Твой ход! Выбери клетку') : 'Ждём соперника…'; ui.shotControls.hidden = !isPlayer(); ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.'; ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget); ui.fire.querySelector('span:last-child').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)}. ` : ''; setSpriteIcon(ui.resultIcon, isPlayer() && state.winner === ownIndex() ? 'icon-trophy' : 'icon-repeat'); 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.querySelector('span:last-child').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.type === 'reset') { clearLocalProfile(true); } 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(); join('player'); }); document.querySelectorAll('.avatar-button').forEach(button => button.addEventListener('click', () => { ui.name.value = button.dataset.name; document.querySelectorAll('.avatar-button').forEach(option => option.setAttribute('aria-pressed', String(option === button))); el('join-player').focus(); })); 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(); }); ui.recoveryButton.addEventListener('click', openRecovery); ui.recoveryClose.addEventListener('click', closeRecovery); ui.recoveryCancel.addEventListener('click', () => { recoveryAction = undefined; ui.recoveryConfirmation.hidden = true; ui.recoveryChoices.hidden = false; ui.recoveryClose.focus(); }); ui.recoveryChoices.addEventListener('click', event => { const choice = event.target.closest('[data-recovery-action]'); if (choice && !choice.hidden) showRecoveryConfirmation(choice.dataset.recoveryAction); }); ui.recoveryConfirm.addEventListener('click', event => { if (recoveryAction !== 'game' || event.detail === 0) submitRecovery(); }); ui.recoveryConfirm.addEventListener('pointerdown', event => { if (recoveryAction !== 'game' || event.button !== 0 || recoveryPending) return; ui.recoveryConfirm.classList.add('holding'); recoveryHoldTimer = window.setTimeout(submitRecovery, 2000); }); ['pointerup', 'pointercancel', 'pointerleave'].forEach(type => ui.recoveryConfirm.addEventListener(type, () => { if (recoveryHoldTimer) { window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryLive.textContent = 'Удерживание отменено.'; } })); document.addEventListener('keydown', event => { if (ui.recoveryDialog.hidden) return; if (event.key === 'Escape') { event.preventDefault(); closeRecovery(); } else if (event.key === 'Tab') { const items = [...ui.recoveryDialog.querySelectorAll('button:not([hidden]):not(:disabled)')]; const first = items[0]; const last = items[items.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } }); async function boot() { ui.name.value = localStorage.getItem(storage.name) || ''; document.querySelectorAll('.avatar-button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.name === ui.name.value))); 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(); })();