feat: build a bounded browser Web Audio engine and sound controls

This commit is contained in:
2026-08-31 23:32:43 +03:00
parent d30eb59225
commit 17ccc0cb0f
10 changed files with 287 additions and 10 deletions
+32 -3
View File
@@ -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…');