ESP32-C6 · локальная игра
Морской бой
Подключение…
Подключение…
Восстанавливаем связь
Состояние игры обновляется через HTTP. WebSocket подключится автоматически.
diff --git a/PLANS.md b/PLANS.md
index 2a2b698..15fa9eb 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -1848,7 +1848,7 @@ When all criteria pass, set Milestone 023 to `DONE` and append its execution rec
## Milestone 024 — Build a bounded browser Web Audio engine and sound controls
-**Status:** `BLOCKED`
+**Status:** `DONE`
**Depends on:** Milestone 023
### Objective
@@ -1946,11 +1946,19 @@ Add browser/unit tests with a fake or instrumented audio context covering at lea
When all criteria pass, set Milestone 024 to `DONE`, append its execution record, and change Milestone 025 from `BLOCKED` to `READY`. Do not start Milestone 025 in the same task unless explicitly requested.
+### Execution record
+
+- Date: 2026-08-31
+- Result: PASS.
+- Evidence: Added a framework-free `web_audio.js` module with deferred user-gesture activation, one reusable noise buffer, an eight-voice limit with priority replacement, capped master gain and compressor, duplicate-version suppression, page-visibility handling, cancellation/reset cleanup, and a silent unsupported-browser fallback. Added Russian sound on/off, volume, reduced-intensity, and recovery-screen mute controls without changing game transport or role behavior. The ESP32 static-file allowlist now serves `/web_audio.js`; the user confirmed HTTP 200 delivery after the firmware and LittleFS update.
+- Verification: All ten host suites passed. All six browser suites, including fake/instrumented AudioContext coverage, passed; `node --check data/app.js` and `node --check data/web_audio.js` passed; `git diff --check` passed. Firmware and LittleFS builds passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,956 / 2,097,152 B flash (48.7%). `web_audio.js` is 7,863 B raw and 2,072 B gzip; all compressed browser assets total 24,947 B, within the 250,000 B LittleFS budget.
+- Next action: Milestone 025 is ready. Do not start Milestone 026 as part of this task.
+
---
## Milestone 025 — Create a large randomized library of playful game sounds and audio combos
-**Status:** `BLOCKED`
+**Status:** `READY`
**Depends on:** Milestone 024
### Objective
diff --git a/data/app.js b/data/app.js
index 5f0b7c8..9c0592e 100644
--- a/data/app.js
+++ b/data/app.js
@@ -13,8 +13,14 @@
result: el('result-description'), error: el('error-description'), lobbyIcon: el('lobby-icon'), resultIcon: el('result-icon'),
cue: el('action-cue'), cueIcon: el('action-cue-icon'), cueText: el('action-cue-text'), effects: el('game-effects'),
effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge'),
- recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live')
+ recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live'),
+ soundToggle: el('sound-toggle'), soundToggleLabel: el('sound-toggle-label'), soundSettings: el('sound-settings'), soundVolume: el('sound-volume'), soundReduced: el('sound-reduced'), recoverySoundMute: el('recovery-sound-mute')
};
+ const silentAudio = {
+ getPreferences: () => ({ enabled: false, volume: 'normal', reduced: false }),
+ setEnabled: async () => false, setVolume: () => {}, setReduced: () => {}, play: () => false, setPageHidden: () => {}, cleanup: () => {}
+ };
+ const audio = globalThis.BattleshipAudio?.createAudioEngine?.() || silentAudio;
let token = localStorage.getItem(storage.token) || '';
let role = '';
let info;
@@ -212,9 +218,10 @@
clearRecoveryTimers();
if (socket) { socket.onclose = null; socket.close(); socket = undefined; }
selectedTarget = undefined; firePending = false; cumulativeStatistics = undefined; statisticsGameId = undefined;
- activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined;
+ activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined; audio.cleanup();
document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify('');
- Object.keys(localStorage).filter(key => key.startsWith('battleship.')).forEach(key => localStorage.removeItem(key));
+ const soundKeys = new Set(['battleship.soundEnabled', 'battleship.soundVolume', 'battleship.soundReduced']);
+ Object.keys(localStorage).filter(key => key.startsWith('battleship.') && !soundKeys.has(key)).forEach(key => localStorage.removeItem(key));
if (!profile) { const rememberedName = ui.name.value.trim(); if (rememberedName) localStorage.setItem(storage.name, rememberedName); }
token = ''; ui.name.value = profile ? '' : (localStorage.getItem(storage.name) || '');
closeRecovery(); showScreen('connect'); setConnection('Готово к подключению');
@@ -271,6 +278,16 @@
ui.notice.classList.toggle('error', error);
}
+ function syncSoundControls() {
+ const preferences = audio.getPreferences();
+ ui.soundToggle.setAttribute('aria-pressed', String(preferences.enabled));
+ ui.soundToggleLabel.textContent = preferences.enabled ? 'Звук включён' : 'Звук выключен';
+ ui.soundSettings.hidden = !preferences.enabled;
+ ui.recoverySoundMute.hidden = !preferences.enabled;
+ ui.soundVolume.value = preferences.volume;
+ ui.soundReduced.checked = preferences.reduced;
+ }
+
function apiError(payload, fallback) {
return payload?.message || fallback || 'Сервер временно недоступен.';
}
@@ -696,6 +713,15 @@
ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId }));
el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); });
ui.recoveryButton.addEventListener('click', openRecovery);
+ ui.soundToggle.addEventListener('click', async () => {
+ const nextEnabled = !audio.getPreferences().enabled;
+ await audio.setEnabled(nextEnabled);
+ if (nextEnabled) audio.play('test');
+ syncSoundControls();
+ });
+ ui.soundVolume.addEventListener('change', () => { audio.setVolume(ui.soundVolume.value); syncSoundControls(); });
+ ui.soundReduced.addEventListener('change', () => { audio.setReduced(ui.soundReduced.checked); syncSoundControls(); });
+ ui.recoverySoundMute.addEventListener('click', async () => { await audio.setEnabled(false); syncSoundControls(); });
ui.recoveryClose.addEventListener('click', closeRecovery);
ui.recoveryCancel.addEventListener('click', () => { recoveryAction = undefined; ui.recoveryConfirmation.hidden = true; ui.recoveryChoices.hidden = false; ui.recoveryClose.focus(); });
ui.recoveryChoices.addEventListener('click', event => {
@@ -720,8 +746,11 @@
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
+ document.addEventListener('visibilitychange', () => { audio.setPageHidden(document.hidden); });
+ window.addEventListener('pagehide', () => audio.cleanup(), { once: true });
async function boot() {
+ syncSoundControls();
ui.name.value = localStorage.getItem(storage.name) || '';
document.querySelectorAll('.avatar-button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.name === ui.name.value)));
setConnection('Проверяем ESP32…');
diff --git a/data/index.html b/data/index.html
index 3851d8f..3686ab8 100644
--- a/data/index.html
+++ b/data/index.html
@@ -15,8 +15,9 @@
ESP32-C6 · локальная игра Подключение… Подключение… Состояние игры обновляется через HTTP. WebSocket подключится автоматически. Морской бой
Восстанавливаем связь
Не удалось продолжить