Files
battleship/data/app.js
T

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