feat: create a large randomized library of playful game sounds and audio combos
This commit is contained in:
@@ -1958,7 +1958,7 @@ When all criteria pass, set Milestone 024 to `DONE`, append its execution record
|
||||
|
||||
## Milestone 025 — Create a large randomized library of playful game sounds and audio combos
|
||||
|
||||
**Status:** `READY`
|
||||
**Status:** `DONE`
|
||||
**Depends on:** Milestone 024
|
||||
|
||||
### Objective
|
||||
@@ -2112,11 +2112,19 @@ Add deterministic tests using injected randomness and a fake audio clock coverin
|
||||
|
||||
When all criteria pass, set Milestone 025 to `DONE`, append its execution record, and change Milestone 026 from `BLOCKED` to `READY`. Do not start Milestone 026 in the same task unless explicitly requested.
|
||||
|
||||
### Execution record
|
||||
|
||||
- Date: 2026-08-31
|
||||
- Result: PASS.
|
||||
- Evidence: Added `data/game_sounds.js`, a bounded original synthesized-preset director with 86 parameterized cue presets: selection (4), shot (8), miss (10), hit (10), sunk (8), dodge (6), incoming damage (6), turn (6), waiting (4), start (6), victory (8), defeat/rematch (5), and five distinct calm recovery cues. It uses a three-entry anti-repeat history, browser randomness only, safe parameter ranges, version deduplication, perspective-safe public event mapping, reconnect summary handling, waiting rate limiting, reduced-intensity suppression, and bounded combo layers. Integrated it with accepted local shots, target selection, authoritative state feedback, rematch, and recovery controls; no game, transport, role, or server-state behavior changed.
|
||||
- Verification: All ten host suites passed. All seven browser suites passed, including deterministic catalog-count, anti-repeat, priority, perspective, combo, reduced-intensity, recovery, duplicate-version, and 3,000-selection stress coverage. `node --check` passed for `app.js`, `web_audio.js`, and `game_sounds.js`; `git diff --check` passed. Firmware and LittleFS builds passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,994 / 2,097,152 B flash (48.7%). `game_sounds.js` is 4,538 B raw / 1,580 B gzip; the seven compressed browser assets total 27,023 B, within the 250,000 B LittleFS budget.
|
||||
- Next action: Milestone 026 is ready. Do not start it as part of this task.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 026 — Validate Web Audio engagement, compatibility, and long-run stability on real devices
|
||||
|
||||
**Status:** `BLOCKED`
|
||||
**Status:** `READY`
|
||||
**Depends on:** Milestone 025
|
||||
|
||||
### Objective
|
||||
|
||||
+11
-8
@@ -21,6 +21,7 @@
|
||||
setEnabled: async () => false, setVolume: () => {}, setReduced: () => {}, play: () => false, setPageHidden: () => {}, cleanup: () => {}
|
||||
};
|
||||
const audio = globalThis.BattleshipAudio?.createAudioEngine?.() || silentAudio;
|
||||
const sounds = globalThis.BattleshipSounds?.createSoundDirector?.({ engine: audio }) || { play: () => false, reset: () => {} };
|
||||
let token = localStorage.getItem(storage.token) || '';
|
||||
let role = '';
|
||||
let info;
|
||||
@@ -176,7 +177,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showFeedback(event) {
|
||||
function showFeedback(event, version, summary = false) {
|
||||
const content = pickReaction(event?.type);
|
||||
if (!content) return;
|
||||
let { motion, burst } = content;
|
||||
@@ -195,6 +196,7 @@
|
||||
void document.body.offsetWidth;
|
||||
document.body.classList.add(kind === 'damage' ? 'fx-damage' : 'fx-hit');
|
||||
if (navigator.vibrate) navigator.vibrate(vibration);
|
||||
sounds.play(event.type, { version, combo: hitStreak, reduced: audio.getPreferences().reduced, summary });
|
||||
effectTimer = window.setTimeout(() => {
|
||||
ui.effects.hidden = true; document.body.classList.remove('fx-hit', 'fx-damage');
|
||||
}, kind === 'victory' ? 2200 : combo ? 1800 : 1350);
|
||||
@@ -218,7 +220,7 @@
|
||||
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; audio.cleanup();
|
||||
activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined; sounds.reset(); audio.cleanup();
|
||||
document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify('');
|
||||
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));
|
||||
@@ -246,7 +248,7 @@
|
||||
}
|
||||
|
||||
function openRecovery() {
|
||||
if (!token) return; recoveryOpener = document.activeElement; ui.recoveryDialog.hidden = false; ui.recoveryButton.setAttribute('aria-expanded', 'true');
|
||||
if (!token) return; sounds.play('recovery-open', { reduced: audio.getPreferences().reduced }); recoveryOpener = document.activeElement; ui.recoveryDialog.hidden = false; ui.recoveryButton.setAttribute('aria-expanded', 'true');
|
||||
ui.recoveryChoices.hidden = false; ui.recoveryConfirmation.hidden = true; ui.recoveryGlobal.hidden = !isPlayer(); ui.recoveryClose.focus();
|
||||
}
|
||||
|
||||
@@ -263,7 +265,7 @@
|
||||
if (action === 'leave') await request('/api/session/leave', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
|
||||
else if (action === 'profile') await request('/api/session/profile-reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
|
||||
else await request('/api/game/reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
|
||||
clearLocalProfile(action !== 'leave');
|
||||
sounds.play(`recovery-${action}`, { reduced: audio.getPreferences().reduced }); clearLocalProfile(action !== 'leave');
|
||||
} catch (error) { recoveryPending = false; ui.recoveryConfirm.disabled = false; ui.recoveryLive.textContent = 'Не получилось. Можно повторить или отменить.'; notify(error.message, true); }
|
||||
}
|
||||
|
||||
@@ -373,7 +375,7 @@
|
||||
selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined;
|
||||
document.title = `Морской бой — версия ${payload.version}`;
|
||||
render();
|
||||
showFeedback(globalThis.BattleshipTargetInteraction.detectFeedback(previousState, payload, role));
|
||||
showFeedback(globalThis.BattleshipTargetInteraction.detectFeedback(previousState, payload, role), payload.version, hadGap);
|
||||
}
|
||||
|
||||
function isPlayer() { return role === 'player1' || role === 'player2'; }
|
||||
@@ -401,6 +403,7 @@
|
||||
renderGame();
|
||||
try {
|
||||
await post('/api/game/shot', { token, gameId: state.gameId, ...target });
|
||||
sounds.play('shot', { reduced: audio.getPreferences().reduced });
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
@@ -414,7 +417,7 @@
|
||||
if (!targetActivator) {
|
||||
targetActivator = globalThis.BattleshipTargetInteraction.createTargetActivator({
|
||||
canFire: canFireTarget,
|
||||
select: nextTarget => { selectedTarget = nextTarget; renderGame(); },
|
||||
select: nextTarget => { selectedTarget = nextTarget; sounds.play('select', { reduced: audio.getPreferences().reduced }); renderGame(); },
|
||||
fire: fireTarget,
|
||||
});
|
||||
}
|
||||
@@ -710,7 +713,7 @@
|
||||
ui.fire.addEventListener('click', () => { if (selectedTarget) fireTarget(selectedTarget); });
|
||||
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 }));
|
||||
ui.rematch.addEventListener('click', () => { post('/api/game/rematch', { token, gameId: state.gameId }).then(() => sounds.play('rematch', { reduced: audio.getPreferences().reduced })).catch(() => {}); });
|
||||
el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); });
|
||||
ui.recoveryButton.addEventListener('click', openRecovery);
|
||||
ui.soundToggle.addEventListener('click', async () => {
|
||||
@@ -723,7 +726,7 @@
|
||||
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.recoveryCancel.addEventListener('click', () => { sounds.play('recovery-cancel', { reduced: audio.getPreferences().reduced }); recoveryAction = undefined; ui.recoveryConfirmation.hidden = true; ui.recoveryChoices.hidden = false; ui.recoveryClose.focus(); });
|
||||
ui.recoveryChoices.addEventListener('click', event => {
|
||||
const choice = event.target.closest('[data-recovery-action]');
|
||||
if (choice && !choice.hidden) showRecoveryConfirmation(choice.dataset.recoveryAction);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
(() => {
|
||||
const COUNTS = Object.freeze({ select: 4, shot: 8, miss: 10, hit: 10, sunk: 8, dodged: 6, damage: 6, turn: 6, waiting: 4, start: 6, victory: 8, defeat: 5, recovery: 5 });
|
||||
const PRIORITY = Object.freeze({ waiting: 0, select: 0, recovery: 1, shot: 2, turn: 3, miss: 4, dodged: 4, damage: 5, hit: 5, sunk: 6, start: 6, defeat: 7, victory: 8, summary: 8 });
|
||||
const BASE = Object.freeze({ select: [540, 100], shot: [190, 220], miss: [410, 260], hit: [155, 280], sunk: [120, 720], dodged: [460, 190], damage: [135, 260], turn: [620, 160], waiting: [350, 120], start: [250, 460], victory: [330, 850], defeat: [310, 430], recovery: [480, 150] });
|
||||
|
||||
function presetsFor(family, count) {
|
||||
const [frequency, durationMs] = BASE[family];
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `${family}-${index + 1}`, family, priority: PRIORITY[family], frequency: Math.max(100, frequency + ((index * 67) % 240) - 90),
|
||||
endFrequency: Math.max(100, frequency + ((index * 43) % 180) - 70), durationMs: Math.min(900, durationMs + (index % 4) * 35),
|
||||
filterHz: 900 + (index * 347) % 2600, gain: family === 'select' || family === 'waiting' ? 0.18 : family === 'recovery' ? 0.14 : 0.7,
|
||||
wave: ['sine', 'triangle', 'square', 'sawtooth'][index % 4], noise: ['miss', 'hit', 'sunk', 'damage', 'dodged'].includes(family) && index % 3 === 1,
|
||||
pan: ((index % 5) - 2) * 0.18, delayMs: index % 3 === 2 ? 28 : 0
|
||||
}));
|
||||
}
|
||||
const CATALOG = Object.freeze(Object.fromEntries(Object.entries(COUNTS).map(([family, count]) => [family, presetsFor(family, count)])));
|
||||
const EVENT_FAMILY = Object.freeze({ 'watch-miss': 'miss', 'watch-hit': 'hit', 'watch-sunk': 'sunk', 'sunk-damage': 'damage', finish: 'victory', rematch: 'defeat', summary: 'victory', 'recovery-open': 'recovery', 'recovery-cancel': 'recovery', 'recovery-leave': 'recovery', 'recovery-profile': 'recovery', 'recovery-game': 'recovery' });
|
||||
const RECOVERY_PRESET = Object.freeze({ 'recovery-open': 0, 'recovery-cancel': 1, 'recovery-leave': 2, 'recovery-profile': 3, 'recovery-game': 4 });
|
||||
|
||||
function createSoundDirector({ engine, random = Math.random, now = () => Date.now(), historyLimit = 3, waitingIntervalMs = 12000 } = {}) {
|
||||
const history = new Map();
|
||||
let lastVersion = -1; let lastWaitingMs = -waitingIntervalMs;
|
||||
function select(family) {
|
||||
const presets = CATALOG[family] || [];
|
||||
if (!presets.length) return undefined;
|
||||
const previous = history.get(family) || [];
|
||||
const options = presets.filter(preset => !previous.includes(preset.id));
|
||||
const pool = options.length ? options : presets.filter(preset => preset.id !== previous.at(-1));
|
||||
const preset = pool[Math.min(pool.length - 1, Math.max(0, Math.floor(random() * pool.length)))];
|
||||
history.set(family, [...previous, preset.id].slice(-historyLimit));
|
||||
return preset;
|
||||
}
|
||||
function play(event, { version, combo = 0, reduced = false, summary = false } = {}) {
|
||||
if (Number.isInteger(version)) { if (version <= lastVersion) return false; lastVersion = version; }
|
||||
let family = summary ? 'summary' : EVENT_FAMILY[event] || event;
|
||||
if (family === 'summary') family = 'victory';
|
||||
if (!CATALOG[family]) return false;
|
||||
if (family === 'waiting') { if (reduced || now() - lastWaitingMs < waitingIntervalMs) return false; lastWaitingMs = now(); }
|
||||
const forcedPreset = RECOVERY_PRESET[event];
|
||||
const preset = forcedPreset === undefined ? select(family) : CATALOG.recovery[forcedPreset];
|
||||
if (!preset) return false;
|
||||
const played = engine?.playPreset?.(preset);
|
||||
if (!played) return false;
|
||||
if (!reduced && (event === 'hit' || event === 'sunk') && combo >= 2) {
|
||||
const comboPreset = { ...select(combo >= 4 ? 'victory' : combo === 3 ? 'turn' : 'hit'), id: `combo-${combo}`, priority: PRIORITY.sunk, gain: 0.12, durationMs: combo >= 4 ? 380 : 160, delayMs: 55 };
|
||||
engine.playPreset(comboPreset);
|
||||
}
|
||||
return { preset, combo: combo >= 4 ? 'ultra' : combo === 3 ? 'mega' : combo === 2 ? 'combo' : undefined };
|
||||
}
|
||||
return { play, select, getCatalog: () => CATALOG, getHistory: family => [...(history.get(family) || [])], reset: () => { history.clear(); lastVersion = -1; lastWaitingMs = -waitingIntervalMs; }, getPriority: event => PRIORITY[EVENT_FAMILY[event] || event] || 0 };
|
||||
}
|
||||
const api = { createSoundDirector, CATALOG, COUNTS, PRIORITY };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else globalThis.BattleshipSounds = api;
|
||||
})();
|
||||
@@ -77,6 +77,7 @@
|
||||
<div id="recovery-dialog" class="recovery-overlay" role="dialog" aria-modal="true" aria-labelledby="recovery-title" hidden><section class="recovery-dialog"><button id="recovery-close" class="text-button recovery-close" type="button" aria-label="Закрыть меню">×</button><h2 id="recovery-title">Помощь с игрой</h2><p id="recovery-description" class="muted">Выберите, что нужно сделать.</p><button id="recovery-sound-mute" type="button" class="secondary recovery-sound-mute" hidden>Выключить звук</button><div id="recovery-choices" class="recovery-choices"><button type="button" data-recovery-action="leave"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Выйти из игры</strong><small>Имя останется на этом устройстве.</small></span></button><button type="button" data-recovery-action="profile"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Сбросить мой профиль</strong><small>Очистить имя и данные этого устройства.</small></span></button><button id="recovery-global-choice" type="button" data-recovery-action="game" class="recovery-global"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><span><strong>Сбросить всю игру</strong><small>Вернуть всех к началу.</small></span></button></div><div id="recovery-confirmation" class="recovery-confirmation" hidden><span id="recovery-scope-icon" class="recovery-scope-icon" aria-hidden="true"></span><h3 id="recovery-confirm-title"></h3><p id="recovery-confirm-text"></p><p id="recovery-hold-note" class="muted" hidden>Удерживайте кнопку 2 секунды.</p><div class="recovery-confirm-actions"><button id="recovery-cancel" type="button" class="secondary">Отмена</button><button id="recovery-confirm" type="button" class="recovery-confirm"><span id="recovery-confirm-label"></span><span id="recovery-hold-progress" class="recovery-hold-progress" aria-hidden="true"></span></button></div></div><p id="recovery-live" class="sr-only" aria-live="polite"></p></section></div>
|
||||
<script src="/target_interaction.js" defer></script>
|
||||
<script src="/web_audio.js" defer></script>
|
||||
<script src="/game_sounds.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+23
-10
@@ -51,20 +51,22 @@
|
||||
if (!voices.delete(voice)) return;
|
||||
timer.clearTimeout(voice.timeout);
|
||||
try { voice.source.stop(); } catch (_) { /* It may have ended naturally. */ }
|
||||
disconnect(voice.source); disconnect(voice.filter); disconnect(voice.gain);
|
||||
disconnect(voice.source); disconnect(voice.filter); disconnect(voice.gain); disconnect(voice.panner);
|
||||
}
|
||||
function makeVoice(priority, durationMs, sourceFactory) {
|
||||
function makeVoice(priority, durationMs, sourceFactory, settings = {}) {
|
||||
if (!preferences.enabled || hidden || !ensureContext() || voices.size >= MAX_VOICES && !replaceVoice(priority)) return false;
|
||||
const startedAt = context.currentTime;
|
||||
const startedAt = context.currentTime + Math.max(0, settings.delayMs || 0) / 1000;
|
||||
const duration = Math.max(0.1, Math.min(durationMs, 2500) / 1000) * (preferences.reduced ? 0.65 : 1);
|
||||
let source; let gain; let filter;
|
||||
let source; let gain; let filter; let panner;
|
||||
try {
|
||||
source = sourceFactory(); gain = context.createGain(); filter = context.createBiquadFilter();
|
||||
filter.type = 'lowpass'; filter.frequency.setValueAtTime(2400, startedAt);
|
||||
gain.gain.setValueAtTime(0.0001, startedAt); gain.gain.exponentialRampToValueAtTime(0.23, startedAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startedAt + duration);
|
||||
source.connect(filter); filter.connect(gain); gain.connect(master); source.start(startedAt); source.stop(startedAt + duration + 0.02);
|
||||
} catch (_) { disconnect(source); disconnect(filter); disconnect(gain); return false; }
|
||||
const voice = { source, filter, gain, priority, timeout: undefined };
|
||||
filter.type = 'lowpass'; filter.frequency.setValueAtTime(settings.filterHz || 2400, startedAt);
|
||||
const peak = Math.min(0.75, Math.max(0.04, settings.gain || 0.55));
|
||||
gain.gain.setValueAtTime(0.0001, startedAt); gain.gain.exponentialRampToValueAtTime(peak, startedAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startedAt + duration);
|
||||
if (typeof context.createStereoPanner === 'function') { panner = context.createStereoPanner(); panner.pan.setValueAtTime(Math.max(-0.4, Math.min(0.4, settings.pan || 0)), startedAt); }
|
||||
source.connect(filter); filter.connect(gain); if (panner) { gain.connect(panner); panner.connect(master); } else gain.connect(master); source.start(startedAt); source.stop(startedAt + duration + 0.02);
|
||||
} catch (_) { disconnect(source); disconnect(filter); disconnect(gain); disconnect(panner); return false; }
|
||||
const voice = { source, filter, gain, panner, priority, timeout: undefined };
|
||||
voice.timeout = timer.setTimeout(() => release(voice), Math.ceil(duration * 1000) + 80);
|
||||
voices.add(voice); return true;
|
||||
}
|
||||
@@ -92,13 +94,24 @@
|
||||
const oscillator = context.createOscillator(); oscillator.type = kind === 'major' ? 'triangle' : 'sine'; oscillator.frequency.setValueAtTime(kind === 'major' ? 392 : 660, context.currentTime); oscillator.frequency.exponentialRampToValueAtTime(kind === 'major' ? 196 : 440, context.currentTime + 0.16); return oscillator;
|
||||
});
|
||||
}
|
||||
function playPreset(preset) {
|
||||
if (!preset || !preferences.enabled || hidden || !ensureContext()) return false;
|
||||
return makeVoice(preset.priority || 1, preset.durationMs || 180, () => {
|
||||
if (preset.noise) { const source = context.createBufferSource(); source.buffer = ensureNoise(); return source; }
|
||||
const oscillator = context.createOscillator(); oscillator.type = preset.wave || 'sine';
|
||||
const startedAt = context.currentTime + Math.max(0, preset.delayMs || 0) / 1000;
|
||||
oscillator.frequency.setValueAtTime(Math.max(100, preset.frequency || 440), startedAt);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(Math.max(100, preset.endFrequency || preset.frequency || 440), startedAt + Math.min(0.8, (preset.durationMs || 180) / 1000));
|
||||
return oscillator;
|
||||
}, preset);
|
||||
}
|
||||
async function setPageHidden(nextHidden) {
|
||||
hidden = Boolean(nextHidden); cancel();
|
||||
if (!context) return;
|
||||
try { if (hidden) await context.suspend(); else if (preferences.enabled && context.state === 'suspended') await context.resume(); } catch (_) { /* Sound remains optional. */ }
|
||||
}
|
||||
function cleanup() { cancel(); lastVersion = -1; if (context) { try { context.close(); } catch (_) { /* Already closed. */ } } context = undefined; master = undefined; limiter = undefined; noiseBuffer = undefined; }
|
||||
return { getPreferences: () => ({ ...preferences }), enable, setEnabled, setVolume: volume => { preferences.volume = validVolume(volume); save(); updateGain(); }, setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); }, play, cancel, setPageHidden, cleanup, getVoiceCount: () => voices.size, getNoiseBuffer: () => noiseBuffer, getMaxGain: () => MAX_GAIN, now };
|
||||
return { getPreferences: () => ({ ...preferences }), enable, setEnabled, setVolume: volume => { preferences.volume = validVolume(volume); save(); updateGain(); }, setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); }, play, playPreset, cancel, setPageHidden, cleanup, getVoiceCount: () => voices.size, getNoiseBuffer: () => noiseBuffer, getMaxGain: () => MAX_GAIN, now };
|
||||
}
|
||||
|
||||
const api = { createAudioEngine, MAX_VOICES, MAX_GAIN, VOLUME_GAINS, PREFERENCES };
|
||||
|
||||
@@ -47,3 +47,10 @@ The bounded Web Audio foundation adds `web_audio.js`: 7,863 B raw and 2,072 B
|
||||
gzip. The current six compressed web assets total 24,947 B, well below the
|
||||
250,000 B LittleFS ceiling. The browser performs synthesis locally, so this
|
||||
change adds no ESP32 RAM allocation, API payload, or firmware-code cost.
|
||||
|
||||
## Milestone 025 sound-library asset measurement
|
||||
|
||||
The randomized cue director adds `game_sounds.js`: 4,538 B raw and 1,580 B
|
||||
gzip. The expanded audio engine is 9,194 B raw and 2,335 B gzip. The current
|
||||
seven compressed web assets total 27,023 B, still well below the 250,000 B
|
||||
LittleFS ceiling.
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(env.subst("$PROJECT_DIR"))
|
||||
DATA = ROOT / "data"
|
||||
ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "web_audio.js", "ship-sprite.svg")
|
||||
ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "web_audio.js", "game_sounds.js", "ship-sprite.svg")
|
||||
|
||||
|
||||
def minify_html(source):
|
||||
|
||||
@@ -361,6 +361,7 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
|
||||
}
|
||||
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0 &&
|
||||
strcmp(request->uri, "/target_interaction.js") != 0 && strcmp(request->uri, "/web_audio.js") != 0 &&
|
||||
strcmp(request->uri, "/game_sounds.js") != 0 &&
|
||||
strcmp(request->uri, "/ship-sprite.svg") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
|
||||
return send_static_file(request, request->uri);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createSoundDirector, COUNTS, CATALOG } = require('../../data/game_sounds.js');
|
||||
|
||||
function recorder() { const played = []; return { played, playPreset: preset => { played.push(preset); return true; } }; }
|
||||
|
||||
test('every required event family has its planned minimum of original synthesis presets', () => {
|
||||
Object.entries(COUNTS).forEach(([family, count]) => {
|
||||
assert.equal(CATALOG[family].length, count);
|
||||
assert.equal(new Set(CATALOG[family].map(preset => preset.id)).size, count);
|
||||
});
|
||||
});
|
||||
|
||||
test('selection is randomized without immediate repeats and keeps bounded history', () => {
|
||||
const output = recorder(); let sample = 0;
|
||||
const director = createSoundDirector({ engine: output, random: () => (sample++ % 11) / 11, historyLimit: 3 });
|
||||
const selected = [];
|
||||
for (let index = 0; index < 30; index += 1) selected.push(director.play('shot').preset.id);
|
||||
selected.slice(1).forEach((id, index) => assert.notEqual(id, selected[index]));
|
||||
assert.ok(director.getHistory('shot').length <= 3);
|
||||
});
|
||||
|
||||
test('preset parameter ranges remain safe through many randomized selections', () => {
|
||||
const output = recorder(); const director = createSoundDirector({ engine: output, random: Math.random });
|
||||
for (let index = 0; index < 3000; index += 1) director.play(Object.keys(COUNTS)[index % Object.keys(COUNTS).length]);
|
||||
output.played.forEach(preset => {
|
||||
assert.ok(preset.frequency >= 100 && preset.frequency <= 1000);
|
||||
assert.ok(preset.endFrequency >= 100 && preset.endFrequency <= 1000);
|
||||
assert.ok(preset.durationMs >= 100 && preset.durationMs <= 900);
|
||||
assert.ok(preset.filterHz >= 900 && preset.filterHz <= 3500);
|
||||
assert.ok(Math.abs(preset.pan) <= 0.4 && preset.gain <= 0.75);
|
||||
});
|
||||
});
|
||||
|
||||
test('state versions, perspectives, priority, waiting rate limit, and reconnect summaries are safe', () => {
|
||||
const output = recorder(); let clock = 0;
|
||||
const director = createSoundDirector({ engine: output, random: () => 0, now: () => clock, waitingIntervalMs: 12000 });
|
||||
assert.ok(director.getPriority('victory') > director.getPriority('sunk'));
|
||||
assert.ok(director.getPriority('sunk') > director.getPriority('hit'));
|
||||
assert.ok(director.getPriority('hit') > director.getPriority('miss'));
|
||||
assert.equal(director.play('watch-hit', { version: 4 }).preset.family, 'hit');
|
||||
assert.equal(director.play('damage', { version: 4 }), false);
|
||||
assert.equal(director.play('watch-sunk', { version: 5 }).preset.family, 'sunk');
|
||||
assert.equal(director.play('waiting').preset.family, 'waiting');
|
||||
assert.equal(director.play('waiting'), false);
|
||||
clock = 12001;
|
||||
assert.equal(director.play('waiting').preset.family, 'waiting');
|
||||
assert.equal(director.play('summary', { version: 6, summary: true }).preset.family, 'victory');
|
||||
});
|
||||
|
||||
test('combos escalate, reduced intensity suppresses extras, and recovery remains calm', () => {
|
||||
const output = recorder(); const director = createSoundDirector({ engine: output, random: () => 0.3 });
|
||||
assert.equal(director.play('hit', { combo: 2 }).combo, 'combo');
|
||||
assert.equal(director.play('hit', { combo: 3 }).combo, 'mega');
|
||||
assert.equal(director.play('sunk', { combo: 4 }).combo, 'ultra');
|
||||
const count = output.played.length;
|
||||
assert.equal(director.play('hit', { combo: 4, reduced: true }).combo, 'ultra');
|
||||
assert.equal(output.played.length, count + 1);
|
||||
assert.equal(director.play('waiting', { reduced: true }), false);
|
||||
const recovery = ['recovery-open', 'recovery-cancel', 'recovery-leave', 'recovery-profile', 'recovery-game'].map(event => director.play(event).preset);
|
||||
assert.equal(new Set(recovery.map(preset => preset.id)).size, 5);
|
||||
recovery.forEach(preset => assert.ok(preset.family === 'recovery' && preset.gain <= 0.16 && preset.durationMs <= 300));
|
||||
});
|
||||
@@ -111,11 +111,14 @@ test('the application exposes an explicit Russian sound control and cleans audio
|
||||
assert.match(page, /id="sound-reduced"/);
|
||||
assert.match(page, /id="recovery-sound-mute"/);
|
||||
assert.match(page, /src="\/web_audio\.js"/);
|
||||
assert.match(page, /src="\/game_sounds\.js"/);
|
||||
assert.match(firmware, /strcmp\(request->uri, "\/web_audio\.js"\)/);
|
||||
assert.match(firmware, /strcmp\(request->uri, "\/game_sounds\.js"\)/);
|
||||
assert.match(app, /audio\.cleanup\(\)/);
|
||||
assert.match(app, /globalThis\.BattleshipAudio\?\.createAudioEngine\?\.\(\) \|\| silentAudio/);
|
||||
assert.match(app, /visibilitychange/);
|
||||
assert.match(app, /soundKeys = new Set/);
|
||||
assert.match(app, /ui\.recoverySoundMute\.hidden = !preferences\.enabled/);
|
||||
assert.match(app, /\/api\/game\/shot/);
|
||||
assert.match(app, /sounds\.play\('shot'/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user