diff --git a/.gitignore b/.gitignore
index 44fbda7..bceabc2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ sdkconfig.esp32-c6-devkitm-1
.pio
.vscode
include/wifi_config.h
+data/*.gz
diff --git a/PLANS.md b/PLANS.md
index bf6f6b9..cce5448 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -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
-**Status:** `READY`
+**Status:** `DONE`
**Depends on:** Milestone 012
### 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`.
+### 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
-**Status:** `BLOCKED`
+**Status:** `READY`
**Depends on:** Milestone 013
### Objective
diff --git a/data/app.js b/data/app.js
index f0cc0ed..9dc6213 100644
--- a/data/app.js
+++ b/data/app.js
@@ -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();
})();
diff --git a/data/index.html b/data/index.html
index edb6ad4..0b7b74a 100644
--- a/data/index.html
+++ b/data/index.html
@@ -3,17 +3,53 @@
+
Морской бой — ESP32
-
- Морской бой
- ESP32-C6: проверка Wi-Fi, LittleFS и HTTP.
-
- Состояние устройства
- Загрузка…
+
+
+
+
+
+ Подключение к игре
+ Введите имя, чтобы занять место игрока или наблюдать за партией.
+
+ Проверяем доступные места…
+
+
+ Лобби
+
+
Режим игры
+
+
+
+
+
+
+
+
+
+
+ Выберите клетку на поле соперника.
+
+
+
+
+ Партия завершена
+
+
+ Восстанавливаем связь
Состояние игры обновляется через HTTP. WebSocket подключится автоматически.
+ Не удалось продолжить
diff --git a/data/styles.css b/data/styles.css
index 510c515..a56898d 100644
--- a/data/styles.css
+++ b/data/styles.css
@@ -1,7 +1,25 @@
-:root { color-scheme: dark; font-family: system-ui, sans-serif; }
-body { margin: 0; background: #10223a; color: #f4f7fb; }
-main { max-width: 42rem; margin: 0 auto; padding: 1.5rem; }
-section { background: #19395c; border-radius: .75rem; padding: 1rem; }
-dl { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1rem; }
-dt { font-weight: 700; }
-dd { margin: 0; overflow-wrap: anywhere; }
+:root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; }
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); }
+button, input { font: inherit; }
+button { min-height: 2.75rem; padding: .55rem .9rem; border: 0; border-radius: .65rem; background: #3ec6f0; color: #032035; font-weight: 750; cursor: pointer; }
+button:focus-visible, input:focus-visible, .cell:focus-visible { outline: .2rem solid #ffe56a; outline-offset: .15rem; }
+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; } }
diff --git a/platformio.ini b/platformio.ini
index e5bf145..a469341 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -16,5 +16,6 @@ board_build.partitions = partitions.csv
board_build.filesystem = littlefs
board_build.sdkconfig_defaults = sdkconfig.defaults
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_speed = 115200
diff --git a/scripts/__pycache__/compress_web_assets.cpython-313.pyc b/scripts/__pycache__/compress_web_assets.cpython-313.pyc
new file mode 100644
index 0000000..033a6e6
Binary files /dev/null and b/scripts/__pycache__/compress_web_assets.cpython-313.pyc differ
diff --git a/scripts/compress_web_assets.py b/scripts/compress_web_assets.py
new file mode 100644
index 0000000..549aa86
--- /dev/null
+++ b/scripts/compress_web_assets.py
@@ -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()