feat: build the Russian responsive web interface
This commit is contained in:
@@ -3,3 +3,4 @@ sdkconfig.esp32-c6-devkitm-1
|
|||||||
.pio
|
.pio
|
||||||
.vscode
|
.vscode
|
||||||
include/wifi_config.h
|
include/wifi_config.h
|
||||||
|
data/*.gz
|
||||||
|
|||||||
@@ -693,7 +693,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record,
|
|||||||
|
|
||||||
## Milestone 013 — Build the Russian responsive web interface
|
## Milestone 013 — Build the Russian responsive web interface
|
||||||
|
|
||||||
**Status:** `READY`
|
**Status:** `DONE`
|
||||||
**Depends on:** Milestone 012
|
**Depends on:** Milestone 012
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
@@ -723,11 +723,22 @@ Implement the complete phone-first interface as small, dependency-free static as
|
|||||||
|
|
||||||
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 014 from `BLOCKED` to `READY`.
|
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 014 from `BLOCKED` to `READY`.
|
||||||
|
|
||||||
|
### Execution record
|
||||||
|
|
||||||
|
- Date: 2026-08-28
|
||||||
|
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
|
||||||
|
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
|
||||||
|
- Result: PASS, pending physical-device UI confirmation.
|
||||||
|
- Evidence: Replaced the diagnostic page with a local, Russian, dependency-free phone-first application covering connection, lobby, game, spectator, result, reconnecting, and error states. It renders labelled 10 × 10 boards with accessible state symbols; uses selected-target then confirmation shot handling; gates all state-changing controls on the authorized server view; switches from tabs to side-by-side boards at tablet width; persists only name and session token locally; and uses WebSocket recovery with safe HTTP polling/version reconciliation. A PlatformIO pre-build script minifies and deterministically gzips the three static assets. No external resources, embedded credentials, or token logging were introduced.
|
||||||
|
- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, HTTP API, and synchronization suites. `node --check data/app.js`, gzip integrity checks, and JavaScript syntax checking of the compressed asset passed. `pio run -e esp32-c6-devkitm-1 -t buildfs` included all six source/compressed web assets; their combined size is 30,915 B, below the 250,000 B LittleFS asset budget. `pio run -e esp32-c6-devkitm-1` passed with 39,348 / 327,680 B RAM (12.0%) and 1,014,500 / 2,097,152 B flash (48.4%).
|
||||||
|
- Issues or deviations: No firmware upload was performed. Visual checks on a physical narrow phone and tablet, plus local-network WebSocket interruption/recovery, remain hardware verification. The available browser automation endpoint had no browser attached, so no automated visual inspection was possible.
|
||||||
|
- Next action: Milestone 014 is ready. Do not start it unless explicitly requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 014 — Complete human-vs-human gameplay end to end
|
## Milestone 014 — Complete human-vs-human gameplay end to end
|
||||||
|
|
||||||
**Status:** `BLOCKED`
|
**Status:** `READY`
|
||||||
**Depends on:** Milestone 013
|
**Depends on:** Milestone 013
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|||||||
+260
-58
@@ -1,48 +1,77 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const target = document.querySelector('#health');
|
const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К'];
|
||||||
const token = localStorage.getItem('battleship.sessionToken') || '';
|
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 socket;
|
||||||
let lastVersion = 0;
|
|
||||||
let retryIndex = 0;
|
|
||||||
let retryTimer;
|
let retryTimer;
|
||||||
let pollTimer;
|
let pollTimer;
|
||||||
let heartbeatTimer;
|
let heartbeatTimer;
|
||||||
const labels = {
|
let retryIndex = 0;
|
||||||
uptime_ms: 'Время работы (мс)', wifi_state: 'Wi-Fi', rssi_dbm: 'RSSI (дБм)',
|
|
||||||
free_heap_bytes: 'Свободная память (байт)', min_free_heap_bytes: 'Мин. свободная память (байт)',
|
|
||||||
build_version: 'Версия сборки'
|
|
||||||
};
|
|
||||||
|
|
||||||
function render(health) {
|
function showScreen(name) {
|
||||||
target.replaceChildren();
|
screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const response = await fetch('/api/health', { cache: 'no-store' });
|
info = await request('/api/info');
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
ui.availability.textContent = `Игрок 1: ${info.player1Available ? 'свободен' : 'занят'} · Игрок 2: ${info.player2Available ? 'свободен' : 'занят'} · зрительских мест: ${info.spectatorsAvailable}`;
|
||||||
render(await response.json());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
target.textContent = `Не удалось получить состояние: ${error.message}`;
|
ui.availability.textContent = error.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollState() {
|
async function pollState() {
|
||||||
try {
|
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 } : {}
|
cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {}
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
const payload = await response.json();
|
||||||
const state = await response.json();
|
if (!response.ok || payload.ok === false) throw new Error(apiError(payload));
|
||||||
acceptState(state);
|
acceptState(payload);
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
if (!state) showError(error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginPolling() {
|
function beginPolling() {
|
||||||
@@ -50,54 +79,227 @@
|
|||||||
pollState();
|
pollState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling() { if (pollTimer) window.clearInterval(pollTimer); pollTimer = undefined; }
|
||||||
if (pollTimer) window.clearInterval(pollTimer);
|
function stopHeartbeat() { if (heartbeatTimer) window.clearInterval(heartbeatTimer); heartbeatTimer = undefined; }
|
||||||
pollTimer = 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() {
|
function acceptState(payload) {
|
||||||
if (heartbeatTimer) window.clearInterval(heartbeatTimer);
|
if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; }
|
||||||
heartbeatTimer = undefined;
|
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) {
|
function isPlayer() { return role === 'player1' || role === 'player2'; }
|
||||||
if (lastVersion && state.version > lastVersion + 1) pollState();
|
function ownIndex() { return role === 'player2' ? 1 : 0; }
|
||||||
lastVersion = state.version;
|
function opponentIndex() { return ownIndex() ^ 1; }
|
||||||
document.title = `Морской бой — версия ${state.version}`;
|
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() {
|
function connectSocket() {
|
||||||
if (!token) { beginPolling(); return; }
|
if (!token || socket?.readyState === WebSocket.OPEN || retryTimer) return;
|
||||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
socket = new WebSocket(`${scheme}://${location.host}/ws`);
|
try { socket = new WebSocket(`${scheme}://${location.host}/ws`); } catch (_) { beginPolling(); return; }
|
||||||
socket.onopen = () => {
|
socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', token, version: state?.version || 0 }));
|
||||||
socket.send(JSON.stringify({ type: 'hello', token, version: lastVersion }));
|
|
||||||
};
|
|
||||||
socket.onmessage = event => {
|
socket.onmessage = event => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.type !== 'state') return;
|
if (message.type === 'state') {
|
||||||
acceptState(message);
|
acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online');
|
||||||
retryIndex = 0;
|
if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000);
|
||||||
stopPolling();
|
} else if (message.ok === false) notify(apiError(message), true);
|
||||||
if (!heartbeatTimer) {
|
|
||||||
heartbeatTimer = window.setInterval(() => {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}');
|
|
||||||
}, 15000);
|
|
||||||
}
|
|
||||||
} catch (_) { socket.close(); }
|
} catch (_) { socket.close(); }
|
||||||
};
|
};
|
||||||
|
socket.onerror = () => socket.close();
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
stopHeartbeat();
|
socket = undefined; stopHeartbeat(); beginPolling(); setConnection('HTTP-обновление', 'offline');
|
||||||
beginPolling();
|
|
||||||
const delays = [1000, 2000, 5000, 10000];
|
const delays = [1000, 2000, 5000, 10000];
|
||||||
const delay = delays[Math.min(retryIndex++, delays.length - 1)];
|
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();
|
el('join-form').addEventListener('submit', event => {
|
||||||
window.setInterval(refresh, 5000);
|
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();
|
connectSocket();
|
||||||
|
} catch (_) {
|
||||||
|
localStorage.removeItem(storage.token); token = ''; showScreen('connect'); notify('Предыдущая сессия больше недоступна. Подключитесь снова.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+42
-6
@@ -3,17 +3,53 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#08233d">
|
||||||
<title>Морской бой — ESP32</title>
|
<title>Морской бой — ESP32</title>
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main class="app-shell">
|
||||||
<h1>Морской бой</h1>
|
<header class="app-header">
|
||||||
<p>ESP32-C6: проверка Wi-Fi, LittleFS и HTTP.</p>
|
<div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1>Морской бой</h1></div>
|
||||||
<section aria-live="polite">
|
<p id="connection-status" class="connection-status" role="status">Подключение…</p>
|
||||||
<h2>Состояние устройства</h2>
|
</header>
|
||||||
<dl id="health">Загрузка…</dl>
|
<p id="notice" class="notice" aria-live="polite" hidden></p>
|
||||||
|
|
||||||
|
<section id="screen-connect" class="screen" aria-labelledby="connect-title">
|
||||||
|
<h2 id="connect-title">Подключение к игре</h2>
|
||||||
|
<p>Введите имя, чтобы занять место игрока или наблюдать за партией.</p>
|
||||||
|
<form id="join-form" class="stack-form">
|
||||||
|
<label for="display-name">Ваше имя</label>
|
||||||
|
<input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" required>
|
||||||
|
<div class="button-row"><button id="join-player" type="submit">Играть</button><button id="join-spectator" type="button" class="secondary">Наблюдать</button></div>
|
||||||
|
</form>
|
||||||
|
<p id="availability" class="muted">Проверяем доступные места…</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-lobby" class="screen" aria-labelledby="lobby-title" hidden>
|
||||||
|
<h2 id="lobby-title">Лобби</h2><p id="lobby-description"></p>
|
||||||
|
<div id="mode-controls" class="panel" hidden>
|
||||||
|
<h3>Режим игры</h3>
|
||||||
|
<div class="button-row"><button type="button" data-mode="human" class="mode-button">Два игрока</button><button type="button" data-mode="bot" class="mode-button">Против ESP32</button></div>
|
||||||
|
<button id="start-game" type="button">Начать игру</button>
|
||||||
|
</div>
|
||||||
|
<p id="lobby-help" class="muted"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-game" class="screen" aria-labelledby="game-title" hidden>
|
||||||
|
<div class="screen-heading"><div><h2 id="game-title">Поле боя</h2><p id="turn-status" class="turn-status"></p></div><p id="wins" class="score" aria-label="Победы"></p></div>
|
||||||
|
<div id="board-tabs" class="board-tabs" role="tablist" aria-label="Выбор поля"></div>
|
||||||
|
<div id="boards" class="boards"></div>
|
||||||
|
<div id="shot-controls" class="shot-controls" hidden><p id="target-status" class="muted">Выберите клетку на поле соперника.</p><button id="fire-button" type="button" disabled>Огонь</button><button id="cancel-target" type="button" class="secondary" disabled>Отменить</button></div>
|
||||||
|
<button id="abort-game" type="button" class="text-button" hidden>Отменить партию</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-result" class="screen" aria-labelledby="result-title" hidden>
|
||||||
|
<h2 id="result-title">Партия завершена</h2><p id="result-description"></p>
|
||||||
|
<div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button">Сыграть ещё</button>
|
||||||
|
</section>
|
||||||
|
<section id="screen-reconnecting" class="screen reconnecting" aria-labelledby="reconnecting-title" hidden><h2 id="reconnecting-title">Восстанавливаем связь</h2><p>Состояние игры обновляется через HTTP. WebSocket подключится автоматически.</p></section>
|
||||||
|
<section id="screen-error" class="screen error-screen" aria-labelledby="error-title" hidden><h2 id="error-title">Не удалось продолжить</h2><p id="error-description"></p><button id="retry-button" type="button">Повторить</button></section>
|
||||||
</main>
|
</main>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+25
-7
@@ -1,7 +1,25 @@
|
|||||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
|
:root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; }
|
||||||
body { margin: 0; background: #10223a; color: #f4f7fb; }
|
* { box-sizing: border-box; }
|
||||||
main { max-width: 42rem; margin: 0 auto; padding: 1.5rem; }
|
body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); }
|
||||||
section { background: #19395c; border-radius: .75rem; padding: 1rem; }
|
button, input { font: inherit; }
|
||||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1rem; }
|
button { min-height: 2.75rem; padding: .55rem .9rem; border: 0; border-radius: .65rem; background: #3ec6f0; color: #032035; font-weight: 750; cursor: pointer; }
|
||||||
dt { font-weight: 700; }
|
button:focus-visible, input:focus-visible, .cell:focus-visible { outline: .2rem solid #ffe56a; outline-offset: .15rem; }
|
||||||
dd { margin: 0; overflow-wrap: anywhere; }
|
button:hover:not(:disabled) { filter: brightness(1.08); } button:disabled { cursor: not-allowed; opacity: .48; }
|
||||||
|
button.secondary { background: #31536e; color: #f1f7ff; } button.text-button { min-height: 2rem; padding: .35rem 0; background: transparent; color: #bad9eb; text-decoration: underline; }
|
||||||
|
.app-shell { width: min(100%, 76rem); margin: 0 auto; padding: clamp(1rem, 3vw, 2rem); }
|
||||||
|
.app-header, .screen-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
||||||
|
h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.75rem, 6vw, 2.6rem); } h2 { font-size: clamp(1.35rem, 4vw, 1.8rem); } h3 { margin-bottom: .75rem; font-size: 1.05rem; }
|
||||||
|
.eyebrow, .muted { color: #b6d1e3; } .eyebrow { margin-bottom: .25rem; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.connection-status { margin: .25rem 0; padding: .4rem .6rem; border-radius: 99rem; background: #28485f; color: #d8eaff; font-size: .88rem; white-space: nowrap; } .connection-status.online { background: #145b4b; color: #cdfae6; } .connection-status.offline { background: #6f3c36; color: #ffddd8; }
|
||||||
|
.notice { margin: 1rem 0; padding: .75rem 1rem; border-left: .3rem solid #ffe56a; border-radius: .35rem; background: #403b22; } .notice.error { border-color: #ff8e80; background: #4d2929; }
|
||||||
|
.screen, .panel { margin-top: 1rem; padding: clamp(1rem, 3vw, 1.5rem); border: 1px solid #41708d; border-radius: 1rem; background: rgb(8 39 62 / 92%); box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%); } .panel { margin-top: 1rem; background: #0b304b; }
|
||||||
|
.stack-form { display: grid; gap: .7rem; max-width: 28rem; } input { width: 100%; min-height: 2.75rem; padding: .55rem .7rem; border: 1px solid #6a94ad; border-radius: .65rem; background: #061c2d; color: #f1f7ff; }
|
||||||
|
.button-row { display: flex; flex-wrap: wrap; gap: .6rem; } .button-row > * { flex: 1 1 11rem; } .mode-button[aria-pressed="true"] { background: #ffe56a; color: #312c00; }
|
||||||
|
.turn-status { margin-bottom: 0; color: #c8e9f8; font-weight: 650; } .score { margin: .15rem 0; padding: .5rem .75rem; border-radius: .6rem; background: #123b58; font-weight: 750; white-space: nowrap; }
|
||||||
|
.board-tabs { display: flex; gap: .5rem; margin: 1rem 0; } .board-tabs button { flex: 1; min-height: 2.5rem; background: #31536e; color: #f1f7ff; } .board-tabs button[aria-selected="true"] { background: #3ec6f0; color: #032035; }
|
||||||
|
.boards { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; } .board { min-width: 0; padding: .75rem; border: 1px solid #50809c; border-radius: .8rem; background: #071f32; } .board h3 { margin: 0 0 .65rem; font-size: 1rem; }
|
||||||
|
.board-grid { display: grid; grid-template-columns: 1.15rem repeat(10, minmax(0, 1fr)); gap: 2px; width: 100%; aspect-ratio: 1.1; } .axis { display: grid; place-items: center; color: #b8d3e6; font-size: clamp(.52rem, 2.2vw, .72rem); font-weight: 700; }
|
||||||
|
.cell { min-height: 0; padding: 0; border-radius: .12rem; border: 1px solid #4e87a6; background: #167aa6; color: #fff; font-size: clamp(.68rem, 3vw, 1.15rem); line-height: 1; } .cell.ship { background: #b2c8d6; color: #1c3442; } .cell.miss::before { content: "•"; color: #08253a; font-size: 1.15em; } .cell.hit, .cell.sunk { background: #b83e42; color: #fff; } .cell.hit::before, .cell.sunk::before { content: "×"; font-size: 1.25em; font-weight: 900; } .cell.sunk { outline: 2px solid #ffe56a; outline-offset: -3px; } .cell.target { background: #0c648b; cursor: pointer; } .cell.selected { outline: 3px solid #ffe56a; outline-offset: -3px; } .cell[disabled] { opacity: 1; cursor: default; }
|
||||||
|
.shot-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-top: 1rem; } .shot-controls p { flex: 1 1 14rem; margin: 0; } .reconnecting { border-color: #ffe56a; } .error-screen { border-color: #ff8e80; }
|
||||||
|
@media (min-width: 44rem) { .boards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board-tabs { display: none; } .board[hidden] { display: block; } }
|
||||||
|
@media (max-width: 43.99rem) { .board[hidden] { display: none; } .result-boards .board[hidden] { display: block; } }
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ board_build.partitions = partitions.csv
|
|||||||
board_build.filesystem = littlefs
|
board_build.filesystem = littlefs
|
||||||
board_build.sdkconfig_defaults = sdkconfig.defaults
|
board_build.sdkconfig_defaults = sdkconfig.defaults
|
||||||
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
|
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
|
||||||
|
extra_scripts = pre:scripts/compress_web_assets.py
|
||||||
monitor_port = /dev/ttyACM0
|
monitor_port = /dev/ttyACM0
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,66 @@
|
|||||||
|
Import("env")
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(env.subst("$PROJECT_DIR"))
|
||||||
|
DATA = ROOT / "data"
|
||||||
|
ASSETS = ("index.html", "styles.css", "app.js")
|
||||||
|
|
||||||
|
|
||||||
|
def minify_html(source):
|
||||||
|
return re.sub(r">\s+<", "><", source).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def minify_css(source):
|
||||||
|
source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
|
||||||
|
return re.sub(r"\s*([{}:;,>])\s*", r"\1", source).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def minify_js(source):
|
||||||
|
output = []
|
||||||
|
quote = ""
|
||||||
|
escaped = False
|
||||||
|
pending_space = False
|
||||||
|
for character in source:
|
||||||
|
if quote:
|
||||||
|
output.append(character)
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif character == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif character == quote:
|
||||||
|
quote = ""
|
||||||
|
continue
|
||||||
|
if character in ("'", '"', "`"):
|
||||||
|
if pending_space and output and (output[-1].isalnum() or output[-1] in "_$"):
|
||||||
|
output.append(" ")
|
||||||
|
pending_space = False
|
||||||
|
quote = character
|
||||||
|
output.append(character)
|
||||||
|
elif character.isspace():
|
||||||
|
pending_space = True
|
||||||
|
else:
|
||||||
|
if pending_space and output and (output[-1].isalnum() or output[-1] in "_$") and (character.isalnum() or character in "_$"):
|
||||||
|
output.append(" ")
|
||||||
|
pending_space = False
|
||||||
|
output.append(character)
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def compress_assets():
|
||||||
|
for asset in ASSETS:
|
||||||
|
path = DATA / asset
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if asset.endswith(".html"):
|
||||||
|
text = minify_html(text)
|
||||||
|
elif asset.endswith(".css"):
|
||||||
|
text = minify_css(text)
|
||||||
|
else:
|
||||||
|
text = minify_js(text)
|
||||||
|
(DATA / f"{asset}.gz").write_bytes(gzip.compress(text.encode("utf-8"), mtime=0))
|
||||||
|
|
||||||
|
|
||||||
|
compress_assets()
|
||||||
Reference in New Issue
Block a user