diff --git a/PLANS.md b/PLANS.md index 15fa9eb..92ffc04 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 diff --git a/data/app.js b/data/app.js index 9c0592e..93e87c2 100644 --- a/data/app.js +++ b/data/app.js @@ -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); diff --git a/data/game_sounds.js b/data/game_sounds.js new file mode 100644 index 0000000..e5500b0 --- /dev/null +++ b/data/game_sounds.js @@ -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; +})(); diff --git a/data/index.html b/data/index.html index 3686ab8..86cdbd1 100644 --- a/data/index.html +++ b/data/index.html @@ -77,6 +77,7 @@
+