415 lines
20 KiB
JavaScript
415 lines
20 KiB
JavaScript
(() => {
|
||
const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К'];
|
||
const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' };
|
||
const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error'];
|
||
const el = id => document.getElementById(id);
|
||
const ui = {
|
||
status: el('connection-status'), notice: el('notice'), name: el('display-name'), availability: el('availability'),
|
||
lobbyDescription: el('lobby-description'), lobbyHelp: el('lobby-help'), modeControls: el('mode-controls'), modeDescription: el('mode-description'),
|
||
start: el('start-game'), boards: el('boards'), resultBoards: el('result-boards'), tabs: el('board-tabs'),
|
||
turn: el('turn-status'), wins: el('wins'), shotControls: el('shot-controls'), target: el('target-status'),
|
||
fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'),
|
||
result: el('result-description'), error: el('error-description')
|
||
};
|
||
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;
|
||
|
||
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 ? 'свободен' : role === 'player1' ? 'Вы' : info.player1Name || 'занят'}`),
|
||
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : role === 'player2' ? 'Вы' : info.player2Name || 'занят'}`),
|
||
availabilityItem(`зрительских мест: ${info.spectatorsAvailable}`),
|
||
);
|
||
if (state?.phase === 'lobby') renderLobby();
|
||
} catch (error) {
|
||
ui.availability.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
async function pollState() {
|
||
try {
|
||
const response = await fetch(`/api/state?version=${state?.version || 0}`, {
|
||
cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {}
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || payload.ok === false) throw new Error(apiError(payload));
|
||
acceptState(payload);
|
||
} catch (error) {
|
||
if (!state) showError(error.message);
|
||
}
|
||
}
|
||
|
||
function beginPolling() {
|
||
if (!pollTimer) pollTimer = window.setInterval(pollState, 2000);
|
||
pollState();
|
||
}
|
||
|
||
function stopPolling() { if (pollTimer) window.clearInterval(pollTimer); pollTimer = undefined; }
|
||
function stopHeartbeat() { if (heartbeatTimer) window.clearInterval(heartbeatTimer); heartbeatTimer = undefined; }
|
||
function startLobbyInfoPolling() {
|
||
if (!lobbyInfoTimer) {
|
||
lobbyInfoTimer = window.setInterval(refreshInfo, 2000);
|
||
refreshInfo();
|
||
}
|
||
}
|
||
function stopLobbyInfoPolling() { if (lobbyInfoTimer) window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = undefined; }
|
||
|
||
function safeState(payload) {
|
||
return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 &&
|
||
payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) &&
|
||
typeof payload.version === 'number' && typeof payload.gameId === 'number' &&
|
||
(payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) &&
|
||
Array.isArray(payload.players) && payload.players.length === 2 && payload.players.every(name => typeof name === 'string' && name.length <= 80) &&
|
||
payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 &&
|
||
entry.every(value => Number.isInteger(value) && value >= 0));
|
||
}
|
||
|
||
function acceptState(payload) {
|
||
if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; }
|
||
const hadGap = state && payload.version > state.version + 1;
|
||
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 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 ['', 'Вода'];
|
||
}
|
||
|
||
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' || 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);
|
||
if (result) board.hidden = false;
|
||
return board;
|
||
}
|
||
|
||
function boardDefinitions(result = false) {
|
||
if (role === 'spectator' || result) return [
|
||
{ index: 0, title: `Поле: ${playerLabel(0)}`, key: 'player1', targetable: false },
|
||
{ index: 1, title: `Поле: ${playerLabel(1)}`, key: 'player2', targetable: false }
|
||
];
|
||
return [
|
||
{ index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false },
|
||
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: 'opponent', targetable: canShoot() }
|
||
];
|
||
}
|
||
|
||
function renderBoards(container, result = false) {
|
||
container.replaceChildren();
|
||
const definitions = boardDefinitions(result);
|
||
definitions.forEach(definition => {
|
||
const board = boardElement(definition.index, definition.title, definition.targetable, result);
|
||
board.hidden = !result && definition.key !== activeBoard;
|
||
container.append(board);
|
||
});
|
||
if (!result) {
|
||
ui.tabs.replaceChildren();
|
||
definitions.forEach(definition => {
|
||
const tab = document.createElement('button');
|
||
tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === 'opponent' ? playerLabel(definition.index) : definition.title;
|
||
tab.title = definition.title; tab.setAttribute('aria-label', definition.title);
|
||
tab.setAttribute('aria-selected', String(definition.key === activeBoard));
|
||
tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); });
|
||
ui.tabs.append(tab);
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderLobby() {
|
||
showScreen('lobby');
|
||
const playerOne = role === 'player1';
|
||
const opponent = state.mode === 'bot' ? 'ESP32' : playerName(opponentIndex());
|
||
ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : state.mode === 'human' && info?.player2Available ? 'Ожидаем соперника.' : `Ожидаем готовности ${opponent}.`;
|
||
ui.modeControls.hidden = !playerOne;
|
||
if (playerOne) startLobbyInfoPolling();
|
||
else stopLobbyInfoPolling();
|
||
const playerTwoReady = info?.player2Available === false;
|
||
const canStart = playerOne && (state.mode === 'bot' || playerTwoReady);
|
||
document.querySelectorAll('.mode-button').forEach(button => {
|
||
const selected = button.dataset.mode === state.mode;
|
||
button.setAttribute('aria-checked', String(selected));
|
||
button.disabled = !playerOne;
|
||
});
|
||
ui.start.disabled = !canStart;
|
||
ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? `${playerName(1)} готов. Можно начать партию.` : 'Ожидаем соперника.') : 'Игра против ESP32. Можно начать сразу.';
|
||
ui.lobbyHelp.textContent = playerOne ? '' : `${playerName(0)} выбирает режим и запускает партию.`;
|
||
}
|
||
|
||
function renderGame() {
|
||
if (!state) return;
|
||
stopLobbyInfoPolling();
|
||
showScreen('game');
|
||
if (canShoot() && !selectedTarget) activeBoard = 'opponent';
|
||
ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`;
|
||
ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`;
|
||
renderBoards(ui.boards);
|
||
const available = canShoot();
|
||
ui.shotControls.hidden = !isPlayer();
|
||
ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.';
|
||
ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget);
|
||
ui.fire.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)}. ` : '';
|
||
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) {
|
||
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.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();
|
||
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', () => { 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(); });
|
||
|
||
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();
|
||
})();
|