771 lines
45 KiB
JavaScript
771 lines
45 KiB
JavaScript
(() => {
|
||
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();
|
||
})();
|