feat: build the Russian responsive web interface
This commit is contained in:
+261
-59
@@ -1,48 +1,77 @@
|
||||
(() => {
|
||||
const target = document.querySelector('#health');
|
||||
const token = localStorage.getItem('battleship.sessionToken') || '';
|
||||
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 lastVersion = 0;
|
||||
let retryIndex = 0;
|
||||
let retryTimer;
|
||||
let pollTimer;
|
||||
let heartbeatTimer;
|
||||
const labels = {
|
||||
uptime_ms: 'Время работы (мс)', wifi_state: 'Wi-Fi', rssi_dbm: 'RSSI (дБм)',
|
||||
free_heap_bytes: 'Свободная память (байт)', min_free_heap_bytes: 'Мин. свободная память (байт)',
|
||||
build_version: 'Версия сборки'
|
||||
};
|
||||
let retryIndex = 0;
|
||||
|
||||
function render(health) {
|
||||
target.replaceChildren();
|
||||
for (const [key, label] of Object.entries(labels)) {
|
||||
const term = document.createElement('dt');
|
||||
const value = document.createElement('dd');
|
||||
term.textContent = label;
|
||||
value.textContent = health[key] ?? 'недоступно';
|
||||
target.append(term, value);
|
||||
}
|
||||
function showScreen(name) {
|
||||
screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; });
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
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 {
|
||||
const response = await fetch('/api/health', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
render(await response.json());
|
||||
info = await request('/api/info');
|
||||
ui.availability.textContent = `Игрок 1: ${info.player1Available ? 'свободен' : 'занят'} · Игрок 2: ${info.player2Available ? 'свободен' : 'занят'} · зрительских мест: ${info.spectatorsAvailable}`;
|
||||
} catch (error) {
|
||||
target.textContent = `Не удалось получить состояние: ${error.message}`;
|
||||
ui.availability.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollState() {
|
||||
try {
|
||||
const response = await fetch(`/api/state?version=${lastVersion}`, {
|
||||
const response = await fetch(`/api/state?version=${state?.version || 0}`, {
|
||||
cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {}
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const state = await response.json();
|
||||
acceptState(state);
|
||||
} catch (_) {}
|
||||
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() {
|
||||
@@ -50,54 +79,227 @@
|
||||
pollState();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
pollTimer = undefined;
|
||||
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' && /^[0123]{100}$/.test(board)) &&
|
||||
typeof payload.version === 'number' && typeof payload.gameId === 'number';
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) window.clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = undefined;
|
||||
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 acceptState(state) {
|
||||
if (lastVersion && state.version > lastVersion + 1) pollState();
|
||||
lastVersion = state.version;
|
||||
document.title = `Морской бой — версия ${state.version}`;
|
||||
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', 'Попадание'];
|
||||
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';
|
||||
ui.result.textContent = 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) { beginPolling(); return; }
|
||||
if (!token || socket?.readyState === WebSocket.OPEN || retryTimer) return;
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${scheme}://${location.host}/ws`);
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ type: 'hello', token, version: lastVersion }));
|
||||
};
|
||||
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') return;
|
||||
acceptState(message);
|
||||
retryIndex = 0;
|
||||
stopPolling();
|
||||
if (!heartbeatTimer) {
|
||||
heartbeatTimer = window.setInterval(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}');
|
||||
}, 15000);
|
||||
}
|
||||
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 = () => {
|
||||
stopHeartbeat();
|
||||
beginPolling();
|
||||
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(connectSocket, delay);
|
||||
retryTimer = window.setTimeout(() => { retryTimer = undefined; connectSocket(); }, delay);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
}
|
||||
|
||||
refresh();
|
||||
window.setInterval(refresh, 5000);
|
||||
connectSocket();
|
||||
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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user