Files
battleship/data/app.js
T

338 lines
17 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 cumulativeStatistics;
let statisticsGameId;
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');
const availabilityItem = (text) => {
const item = document.createElement('span');
item.className = 'availability-item';
item.textContent = text;
return item;
};
ui.availability.replaceChildren(
availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : 'занят'}`),
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : 'занят'}`),
availabilityItem(`зрительских мест: ${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 && state?.mode === 'bot' ? 'ESP32' : `Игрок ${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: state.mode === 'bot' ? 'Поле ESP32' : 'Поле соперника', 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 ? 'Ваш ход. Выберите клетку соперника.' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}.`;
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');
if (statisticsGameId !== state.gameId) refreshStatistics();
const waiting = state.phase === 'rematch_wait';
const winner = state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : '';
const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' ');
const cumulative = cumulativeStatistics?.cumulative?.map((entry, index) => `${playerLabel(index)} всего: игр ${entry[0]}, побед ${entry[1]}, поражений ${entry[2]}, выстрелов ${entry[4]}, попаданий ${entry[5]}, промахов ${entry[6]}, потоплено ${entry[3]}.`).join(' ') || '';
ui.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`;
ui.rematch.hidden = !isPlayer();
ui.rematch.disabled = !isPlayer();
ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё';
renderBoards(ui.resultBoards, true);
}
function render() {
if (!state) return;
if (state.phase === 'lobby' || state.phase === 'preparing') renderLobby();
else if (state.phase === 'in_progress') renderGame();
else renderResult();
}
function showError(message) {
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('Подключено.');
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();
})();