Files
battleship/data/app.js
T

771 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К'];
const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' };
const boardKeys = Object.freeze({ own: 'own', opponent: 'opponent', player1: 'player1', player2: 'player2' });
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'),
soundToggle: el('sound-toggle'), soundToggleLabel: el('sound-toggle-label'), soundSettings: el('sound-settings'), soundVolume: el('sound-volume'), soundReduced: el('sound-reduced'), recoverySoundMute: el('recovery-sound-mute')
};
const silentAudio = {
getPreferences: () => ({ enabled: false, volume: 'normal', reduced: false }),
setEnabled: async () => false, setVolume: () => {}, setReduced: () => {}, play: () => false, setPageHidden: () => {}, cleanup: () => {}
};
const audio = globalThis.BattleshipAudio?.createAudioEngine?.() || silentAudio;
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; audio.cleanup();
document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify('');
const soundKeys = new Set(['battleship.soundEnabled', 'battleship.soundVolume', 'battleship.soundReduced']);
Object.keys(localStorage).filter(key => key.startsWith('battleship.') && !soundKeys.has(key)).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 syncSoundControls() {
const preferences = audio.getPreferences();
ui.soundToggle.setAttribute('aria-pressed', String(preferences.enabled));
ui.soundToggleLabel.textContent = preferences.enabled ? 'Звук включён' : 'Звук выключен';
ui.soundSettings.hidden = !preferences.enabled;
ui.recoverySoundMute.hidden = !preferences.enabled;
ui.soundVolume.value = preferences.volume;
ui.soundReduced.checked = preferences.reduced;
}
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' &&
['player1', 'player2', 'spectator'].includes(payload.viewer) &&
(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;
const validBoardKeys = role === 'spectator' ? [boardKeys.player1, boardKeys.player2] : [boardKeys.own, boardKeys.opponent];
if (!validBoardKeys.includes(activeBoard)) activeBoard = validBoardKeys[0];
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: boardKeys.player1, targetable: false },
{ index: 1, title: `Поле: ${playerLabel(1)}`, key: boardKeys.player2, targetable: false }
];
return [
{ index: ownIndex(), title: 'Моё поле', key: boardKeys.own, targetable: false },
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: boardKeys.opponent, targetable: canShoot() }
];
}
function renderBoards(container, result = false) {
container.replaceChildren();
const definitions = boardDefinitions(result);
if (!result && !definitions.some(definition => definition.key === activeBoard)) activeBoard = definitions[0].key;
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 === boardKeys.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 = boardKeys.opponent;
const turnPlayer = playerLabel(state.turn === 'player2' ? 1 : 0);
const watching = role === 'spectator';
ui.turn.textContent = canShoot() ? 'Ваш ход' : watching ? `Наблюдаем за боем · Ходит ${turnPlayer}` : `Ходит ${turnPlayer}`;
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 ? 'Жми «Огонь»!' : 'Твой ход! Выбери клетку') : watching ? 'Наблюдаем за боем' : 'Ждём соперника…';
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.soundToggle.addEventListener('click', async () => {
const nextEnabled = !audio.getPreferences().enabled;
await audio.setEnabled(nextEnabled);
if (nextEnabled) audio.play('test');
syncSoundControls();
});
ui.soundVolume.addEventListener('change', () => { audio.setVolume(ui.soundVolume.value); syncSoundControls(); });
ui.soundReduced.addEventListener('change', () => { audio.setReduced(ui.soundReduced.checked); syncSoundControls(); });
ui.recoverySoundMute.addEventListener('click', async () => { await audio.setEnabled(false); syncSoundControls(); });
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(); }
}
});
document.addEventListener('visibilitychange', () => { audio.setPageHidden(document.hidden); });
window.addEventListener('pagehide', () => audio.cleanup(), { once: true });
async function boot() {
syncSoundControls();
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();
})();