diff --git a/docs/audio-sources.md b/docs/audio-sources.md index 38005a0..5aea730 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -1,6 +1,6 @@ # Audio Sources -Downloaded from Pixabay on 2026-06-22 and used under the Pixabay Content License. +Downloaded from Pixabay on 2026-06-22 and 2026-07-21 and used under the Pixabay Content License. License reference: https://pixabay.com/service/license-summary/ @@ -35,7 +35,16 @@ The following files are generated deterministically by `scripts/generate-camp-au | --- | --- | --- | --- | | `src/assets/audio/sfx/ui-select.mp3` | Ui, Click, Retro sound effect | SoundShelfStudio | https://pixabay.com/sound-effects/film-special-effects-ui-click-retro-514601/ | | `src/assets/audio/sfx/sword-slash.mp3` | Sword, Slash, Swing sound effect | DavidDumaisAudio | https://pixabay.com/sound-effects/film-special-effects-sword-slash-and-swing-185432/ | +| `src/assets/audio/sfx/sword-swing-variant.mp3` | Video game Sword swing SFX | OxidVideos | https://pixabay.com/sound-effects/film-special-effects-video-game-sword-swing-sfx-409364/ | | `src/assets/audio/sfx/combat-impact.mp3` | Sword, Slash, Weapon sound effect | DavidDumaisAudio | https://pixabay.com/sound-effects/film-special-effects-sword-slash-with-metal-shield-impact-185433/ | +| `src/assets/audio/sfx/armor-impact.mp3` | Armor Impact from Sword | DRAGON-STUDIO | https://pixabay.com/sound-effects/film-special-effects-armor-impact-from-sword-393843/ | +| `src/assets/audio/sfx/shield-block.mp3` | Shield Block Shortsword | SectionSound | https://pixabay.com/sound-effects/film-special-effects-shield-block-shortsword-143940/ | +| `src/assets/audio/sfx/bow-release.mp3` | Bow Release (Bow and Arrow) 4 | Ali_6868 (Freesound) | https://pixabay.com/sound-effects/film-special-effects-bow-release-bow-and-arrow-4-101936/ | +| `src/assets/audio/sfx/arrow-swish.mp3` | Arrow Swish_03 | DJARTMUSIC | https://pixabay.com/sound-effects/film-special-effects-arrow-swish-03-306040/ | +| `src/assets/audio/sfx/arrow-body-impact.mp3` | Arrow Body Impact | DennisH18 | https://pixabay.com/sound-effects/film-special-effects-arrow-body-impact-146419/ | +| `src/assets/audio/sfx/arrow-wood-impact.mp3` | Arrow Wood Impact | DennisH18 | https://pixabay.com/sound-effects/film-special-effects-arrow-wood-impact-146418/ | +| `src/assets/audio/sfx/critical-impact.mp3` | Combat Impact | Universfield | https://pixabay.com/sound-effects/film-special-effects-combat-impact-352458/ | +| `src/assets/audio/sfx/critical-boom.mp3` | Cinematic Impact Boom 05 | Universfield | https://pixabay.com/sound-effects/film-special-effects-cinematic-impact-boom-05-352465/ | | `src/assets/audio/sfx/strategy-cast.mp3` | Designed, Magic spell, Projectile sound effect | RescopicSound | https://pixabay.com/sound-effects/film-special-effects-elemental-magic-spell-impact-outgoing-228342/ | | `src/assets/audio/sfx/cart-roll.mp3` | Cart, Wheel, Cobblestones sound effect | Fronbondi_Skegs | https://pixabay.com/sound-effects/film-special-effects-foley-heavily-laden-cart-moving-over-cobblestones-sound-effect-246657/ | | `src/assets/audio/sfx/exp-gain.mp3` | Level up, Level up sound, Game level up sound effect | Universfield | https://pixabay.com/sound-effects/film-special-effects-level-up-06-370051/ | diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index b0f4e55..bcc26ad 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -19,7 +19,9 @@ const server = await createServer({ }); try { - const { musicTracks, ambienceTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + const audioAssets = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + const { musicTracks, ambienceTracks, effectTracks } = audioAssets; + const effectPools = audioAssets.effectPools ?? {}; const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const errors = []; @@ -27,9 +29,11 @@ try { validateTrackMap(errors, 'music', musicTracks, 'bgm'); validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience'); validateTrackMap(errors, 'effect', effectTracks, 'sfx'); + validateEffectPools(errors, effectPools, effectTracks); + validateEffectPoolRegistration(errors, effectPools); validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks); validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks); - validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks); + validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks, effectPools); if (errors.length) { console.error(`Audio asset data verification failed with ${errors.length} issue(s):`); @@ -41,7 +45,7 @@ try { } else { console.log( `Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` + - `${Object.keys(effectTracks).length} effect tracks, ` + + `${Object.keys(effectTracks).length} effect tracks, ${Object.keys(effectPools).length} effect pools, ` + `${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.` ); } @@ -49,6 +53,41 @@ try { await server.close(); } +function validateEffectPools(errors, effectPools, effectTracks) { + const effectKeys = new Set(Object.keys(effectTracks)); + + Object.entries(effectPools).forEach(([poolKey, trackKeys]) => { + assertNonEmptyString(errors, poolKey, 'effect pool key'); + if (!Array.isArray(trackKeys) || trackKeys.length === 0) { + errors.push(`effect pool "${poolKey}" must contain at least one track key`); + return; + } + + const seenTrackKeys = new Set(); + trackKeys.forEach((trackKey, index) => { + assertNonEmptyString(errors, trackKey, `effect pool "${poolKey}" track ${index}`); + if (!effectKeys.has(trackKey)) { + errors.push(`effect pool "${poolKey}" references unknown effect track "${trackKey}"`); + } + if (seenTrackKeys.has(trackKey)) { + errors.push(`effect pool "${poolKey}" repeats effect track "${trackKey}"`); + } + seenTrackKeys.add(trackKey); + }); + }); +} + +function validateEffectPoolRegistration(errors, effectPools) { + if (Object.keys(effectPools).length === 0) { + return; + } + + const mainSource = readFileSync(join('src', 'main.ts'), 'utf8'); + if (!/\bregisterEffectPools\s*\(\s*effectPools\s*\)/.test(mainSource)) { + errors.push('src/main.ts must register exported effectPools with SoundDirector'); + } +} + function validateTrackMap(errors, label, tracks, expectedFolder) { Object.entries(tracks).forEach(([key, url]) => { assertNonEmptyString(errors, key, `${label} track key`); @@ -97,10 +136,10 @@ function validateStoryBgmReferences(errors, scenarioModule, battleScenarios, mus }); } -function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks) { +function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks, effectPools) { const musicKeys = new Set(Object.keys(musicTracks)); const ambienceKeys = new Set(Object.keys(ambienceTracks)); - const effectKeys = new Set(Object.keys(effectTracks)); + const effectKeys = new Set([...Object.keys(effectTracks), ...Object.keys(effectPools)]); sourceFilesToScan.forEach((path) => { const source = readFileSync(path, 'utf8'); @@ -116,7 +155,7 @@ function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTr }); collectLiteralCallKeys(source, 'playEffect').forEach(({ key, index }) => { if (!effectKeys.has(key)) { - errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track "${key}"`); + errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track or pool "${key}"`); } }); }); @@ -165,13 +204,88 @@ function isStoryPage(page) { function collectLiteralCallKeys(source, callName) { const keys = []; - const pattern = new RegExp(`\\.${callName}\\(\\s*['"]([^'"]+)['"]`, 'g'); + const pattern = new RegExp(`\\.${callName}\\s*\\(`, 'g'); for (const match of source.matchAll(pattern)) { - keys.push({ key: match[1], index: match.index ?? 0 }); + const callIndex = match.index ?? 0; + const argumentStart = callIndex + match[0].length; + const argumentEnd = firstArgumentEnd(source, argumentStart); + const argumentSource = source.slice(argumentStart, argumentEnd); + const ternaryIndex = firstTernaryIndex(argumentSource); + const stringPattern = /(['"])([^'"\\]*(?:\\.[^'"\\]*)*)\1/g; + for (const stringMatch of argumentSource.matchAll(stringPattern)) { + if (ternaryIndex >= 0 && (stringMatch.index ?? 0) < ternaryIndex) { + continue; + } + if (stringMatch[2].includes('\\')) { + continue; + } + keys.push({ key: stringMatch[2], index: argumentStart + (stringMatch.index ?? 0) }); + } } return keys; } +function firstTernaryIndex(source) { + let quote = ''; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (character === '\\') { + index += 1; + } else if (character === quote) { + quote = ''; + } + continue; + } + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + if (character === '?' && source[index - 1] !== '?' && source[index + 1] !== '?' && source[index + 1] !== '.') { + return index; + } + } + return -1; +} + +function firstArgumentEnd(source, startIndex) { + let nestedDepth = 0; + let quote = ''; + + for (let index = startIndex; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (character === '\\') { + index += 1; + } else if (character === quote) { + quote = ''; + } + continue; + } + + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + if (character === '(' || character === '[' || character === '{') { + nestedDepth += 1; + continue; + } + if (character === ')' || character === ']' || character === '}') { + if (nestedDepth === 0) { + return index; + } + nestedDepth -= 1; + continue; + } + if (character === ',' && nestedDepth === 0) { + return index; + } + } + + return source.length; +} + function assetUrlToPath(url) { const marker = 'src/assets/audio/'; const index = url.indexOf(marker); diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 382b7fb..28f69be 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -139,6 +139,7 @@ try { await waitForStoryReady(page); await setDebugQueryParam(page, 'debugOrderCommandReady', '1'); const initialStoryProgress = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); + assertStoryDialogLayout(initialStoryProgress, 'initial story page'); assert( initialStoryProgress?.pageIndex === 0 && initialStoryProgress?.totalPages > 1 && @@ -3313,7 +3314,7 @@ try { await page.waitForFunction(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return (scene?.dialogueObjects ?? []).filter((object) => object?.active).length === 0; - }, undefined, { timeout: 1500 }); + }, undefined, { timeout: 2500 }); const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? []; const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; @@ -8205,6 +8206,8 @@ async function advanceUntilBattle(page, battleId, options = {}) { }; }, battleId); + assertStoryDialogLayout(probe.story, `${battleId} story page`); + if ( captureLastStory && probe.story?.isLastPage && @@ -9249,6 +9252,7 @@ async function waitForCampAfterBattleResult(page) { activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null })); + assertStoryDialogLayout(probe.story, 'post-battle story page'); if (probe.story?.isLastPage && probe.story?.progressBounds && probe.story?.progressPanelBounds) { lastStory = probe.story; } @@ -10199,6 +10203,23 @@ function boundsInside(inner, outer, tolerance = 1) { inner.y + inner.height <= outer.y + outer.height + tolerance; } +function assertStoryDialogLayout(story, label) { + if (!story?.ready) { + return; + } + + assert( + boundsWithinFhdViewport(story.bodyBounds) && + boundsWithinFhdViewport(story.dialogPanelBounds) && + boundsInside(story.bodyBounds, story.dialogPanelBounds) && + (!isFiniteBounds(story.progressPanelBounds) || + story.bodyBounds.y + story.bodyBounds.height <= story.progressPanelBounds.y + 1) && + (!isFiniteBounds(story.cutsceneStageBounds) || + story.cutsceneStageBounds.y + story.cutsceneStageBounds.height <= story.dialogPanelBounds.y + 1), + `Expected ${label} dialogue body to remain inside its FHD panel: ${JSON.stringify(story)}` + ); +} + function boundsCover(outer, inner, tolerance = 1) { return isFiniteBounds(outer) && isFiniteBounds(inner) && outer.x <= inner.x + tolerance && diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index bea7013..2aa438b 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -65,6 +65,8 @@ const server = await createServer({ try { const { SoundDirector } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts'); + const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts'); + verifyAudioPreferences(audioPreferencesModule); const clock = new FakeClock(); const audioHarness = createAudioHarness(); const restoreGlobals = installBrowserFakes(clock, audioHarness.FakeAudio); @@ -81,6 +83,30 @@ try { 'ambience-b': '/audio/ambience-b.ogg', 'ambience-c': '/audio/ambience-c.ogg' }); + director.registerEffectTracks({ + 'sword-slash': '/audio/sword-slash.ogg', + 'sword-swing-variant': '/audio/sword-swing-variant.ogg', + 'combat-impact': '/audio/combat-impact.ogg', + 'armor-impact': '/audio/armor-impact.ogg', + 'shield-block': '/audio/shield-block.ogg', + 'bow-release': '/audio/bow-release.ogg', + 'arrow-body-impact': '/audio/arrow-body-impact.ogg', + 'arrow-wood-impact': '/audio/arrow-wood-impact.ogg', + 'arrow-swish': '/audio/arrow-swish.ogg', + 'critical-impact': '/audio/critical-impact.ogg', + 'critical-boom': '/audio/critical-boom.ogg', + 'victory-fanfare': '/audio/victory-fanfare.ogg', + 'defeat-sting': '/audio/defeat-sting.ogg', + 'ui-select': '/audio/ui-select.ogg' + }); + director.registerEffectPools({ + impact: ['sword-slash', 'combat-impact', 'sword-slash'], + 'melee-swing': ['sword-slash', 'sword-swing-variant'], + 'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'], + 'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'], + 'arrow-miss': ['arrow-swish'], + fallback: ['missing-track'] + }); director.playSoundscape({ musicKey: 'music-a', ambienceKey: 'ambience-a' }); let debug = director.getDebugState(); @@ -110,6 +136,108 @@ try { assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'settled music volume'); assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'settled ambience volume'); + director.setCategoryMultipliers({ music: 0.5, effects: 0.5, ambience: 0.25 }); + debug = director.getDebugState(); + assert.deepEqual( + debug.categoryMultipliers, + { music: 0.5, effects: 0.5, ambience: 0.25 }, + 'category multipliers should be independently configurable' + ); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.11, 'music category multiplier'); + assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.02, 'ambience category multiplier'); + assertClose(debug.music.effectiveTargetVolume, 0.11, 'debug music effective target volume'); + assertClose(debug.ambience.effectiveTargetVolume, 0.02, 'debug ambience effective target volume'); + + assert.equal( + director.playEffect('impact', { volume: 0.4, rate: 0.92, stopAfterMs: 100 }), + true, + 'registered effect pool should resolve to an available track' + ); + const firstImpact = audioHarness.instances.at(-1); + assert.equal( + director.playEffect('impact', { volume: 0.4, rate: 1.08, stopAfterMs: 100 }), + true, + 'effect pool should remain playable on repeated requests' + ); + const secondImpact = audioHarness.instances.at(-1); + assert.notEqual(firstImpact.originalSrc, secondImpact.originalSrc, 'effect pools should avoid immediate track repetition'); + assertClose(firstImpact.volume, 0.2, 'effect category multiplier on first variation'); + assertClose(secondImpact.volume, 0.2, 'effect category multiplier on second variation'); + debug = director.getDebugState(); + assert.deepEqual( + debug.effectPools.impact, + ['sword-slash', 'combat-impact'], + 'effect pools should discard duplicate keys' + ); + assert.equal(debug.lastEffect.key, 'impact', 'last effect should retain its logical request key'); + assert.equal(debug.lastEffect.poolKey, 'impact', 'last effect should identify its source pool'); + assert( + ['sword-slash', 'combat-impact'].includes(debug.lastEffect.trackKey), + 'last effect should expose the concrete registered track key' + ); + assert.equal(debug.activeEffectCount, 2, 'active pooled effects should be tracked'); + + director.setCategoryMultiplier('effects', 0.25); + assertClose(firstImpact.volume, 0.1, 'active effect should follow category multiplier changes'); + assertClose(secondImpact.volume, 0.1, 'all active effects should follow category multiplier changes'); + director.setCategoryMultipliers({ music: 1, effects: 1, ambience: 1 }); + assertClose(firstImpact.volume, 0.4, 'active effect should restore its base volume'); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'restored music category multiplier'); + assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'restored ambience category multiplier'); + clock.advance(120); + debug = director.getDebugState(); + assert.equal(debug.activeEffectCount, 0, 'timed effects should be released from active tracking'); + assertRetired(firstImpact, 'first pooled impact cleanup'); + assertRetired(secondImpact, 'second pooled impact cleanup'); + + director.duckMusic({ multiplier: 0.4, attackMs: 64, holdMs: 64, releaseMs: 96 }); + clock.advance(64); + debug = director.getDebugState(); + assert.equal(debug.musicDuck.active, true, 'music duck should remain active through its hold phase'); + assertClose(debug.musicDuck.multiplier, 0.4, 'music duck attack target'); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.088, 'ducked music volume'); + assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'music duck must not affect ambience'); + clock.advance(160); + debug = director.getDebugState(); + assert.equal(debug.musicDuck.active, false, 'music duck should finish after release'); + assert.equal(debug.musicDuck.completedRevision, 1, 'music duck completion revision'); + assertClose(debug.musicDuck.multiplier, 1, 'music duck should restore its multiplier'); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'music volume after duck release'); + + director.duckMusic({ multiplier: 0.3, attackMs: 0, holdMs: 800, releaseMs: 200 }); + clock.advance(96); + director.duckMusic({ multiplier: 0.6, attackMs: 0, holdMs: 0, releaseMs: 100 }); + clock.advance(160); + debug = director.getDebugState(); + assert.equal(debug.musicDuck.active, true, 'a shorter overlapping duck must not end a longer active duck'); + assertClose(debug.musicDuck.multiplier, 0.3, 'overlapping duck should retain the stronger attenuation'); + director.setCategoryMultiplier('music', 0.5); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.033, 'music multiplier during duck'); + director.setCategoryMultiplier('music', 1); + director.setMuted(true); + assert.equal(audioHarness.byOriginalSrc('/audio/music-a.ogg').muted, true, 'mute during duck'); + director.setMuted(false); + clock.advance(800); + debug = director.getDebugState(); + assert.equal(debug.musicDuck.active, false, 'overlapping duck should finish at the longest requested deadline'); + assert.equal(debug.musicDuck.completedRevision, 3, 'overlapping duck completion revision'); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'music volume after overlapping duck'); + + audioHarness.rejectNextPlay(); + assert.equal(director.playEffect('ui-select'), true, 'effect should attempt playback before a media rejection'); + const rejectedEffect = audioHarness.instances.at(-1); + await Promise.resolve(); + debug = director.getDebugState(); + assert.equal(debug.activeEffectCount, 0, 'rejected effects should be released from active tracking'); + assertRetired(rejectedEffect, 'rejected effect cleanup'); + + assert.equal(director.playEffect('ui-select'), true, 'effect should be tracked until an error or end event'); + const erroredEffect = audioHarness.instances.at(-1); + erroredEffect.emit('error'); + debug = director.getDebugState(); + assert.equal(debug.activeEffectCount, 0, 'errored effects should be released from active tracking'); + assertRetired(erroredEffect, 'errored effect cleanup'); + const countBeforeSameKey = audioHarness.instances.length; const musicRevisionBeforeSameKey = debug.music.transition.revision; const ambienceRevisionBeforeSameKey = debug.ambience.transition.revision; @@ -238,9 +366,45 @@ try { assert.equal(debug.ambience.activeElementCount, 0, 'legacy playMusic ambience cleanup should complete'); assert.equal(audioHarness.activeInstances().length, 1, 'the verifier must finish without orphaned loop elements'); + const semanticEffectStart = audioHarness.instances.length; + director.playMeleeSwing(true, false); + assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool'); + director.playArrowShot(); + assert.equal(director.getDebugState().lastEffect?.key, 'bow-release', 'arrow API should play the bow release'); + director.playArrowImpact(false, false); + assert.equal(director.getDebugState().lastEffect?.key, 'arrow-miss', 'missed arrows should use the miss pool'); + director.playArrowImpact(true, true); + const criticalImpact = audioHarness.byOriginalSrc('/audio/critical-impact.ogg'); + assert(criticalImpact, 'critical arrow impacts should layer the critical impact track'); + clock.advance(34); + const criticalBoom = audioHarness.byOriginalSrc('/audio/critical-boom.ogg'); + assert(criticalBoom, 'critical accents should layer the delayed boom track'); + director.playCombatImpact(false, false); + assert.equal(director.getDebugState().lastEffect?.key, 'melee-impact', 'combat API should use the impact pool'); + director.playBattleOutcome('victory'); + assert.equal(director.getDebugState().lastEffect?.key, 'victory-fanfare', 'victory should play its outcome track'); + director.playBattleOutcome('defeat'); + assert.equal(director.getDebugState().lastEffect?.key, 'defeat-sting', 'defeat should play its outcome track'); + clock.advance(620); + assert.notEqual(criticalImpact.src, '', 'critical impact must not be cut at the former 520ms limit'); + assert.notEqual(criticalBoom.src, '', 'critical boom must preserve its audible tail beyond 620ms'); + clock.advance(180); + assertRetired(criticalImpact, 'critical impact natural-length cleanup'); + assert.notEqual(criticalBoom.src, '', 'critical boom should remain active after the short impact ends'); + clock.advance(1400); + debug = director.getDebugState(); + assert.equal(debug.activeEffectCount, 0, 'semantic effects should all retire after their full playback windows'); + assertRetired(criticalBoom, 'critical boom natural-length cleanup'); + assert.equal( + audioHarness.activeInstances().length, + 1, + `semantic API verification should leave only music active after ${audioHarness.instances.length - semanticEffectStart} effects` + ); + console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + - `${debug.ambience.elementRevision} ambience element revisions with deterministic 900/1200ms transitions.` + `${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` + + 'semantic combat effects, category multipliers, and deterministic music ducking.' ); } finally { restoreGlobals(); @@ -249,6 +413,63 @@ try { await server.close(); } +function verifyAudioPreferences({ + audioLevelLabel, + audioLevelSteps, + audioPreferencesStorageKey, + defaultAudioPreferences, + loadAudioPreferences, + nextAudioLevel, + saveAudioPreferences, + updateAudioPreference +}) { + const storage = createMemoryStorage(); + assert.deepEqual(loadAudioPreferences(storage), defaultAudioPreferences, 'empty audio preferences should use defaults'); + + storage.setItem( + audioPreferencesStorageKey, + JSON.stringify({ muted: true, music: 0.75, effects: 0.5, ambience: 0.25 }) + ); + const loaded = loadAudioPreferences(storage); + assert.deepEqual( + loaded, + { muted: true, music: 0.75, effects: 0.5, ambience: 0.25 }, + 'stored audio preferences should load all supported levels' + ); + + storage.setItem(audioPreferencesStorageKey, JSON.stringify({ muted: 'yes', music: 0.6, effects: 0.75 })); + assert.deepEqual( + loadAudioPreferences(storage), + { muted: false, music: 1, effects: 0.75, ambience: 1 }, + 'invalid stored values should fall back independently' + ); + + assert.deepEqual( + audioLevelSteps.map((level) => nextAudioLevel(level)), + [0.75, 0.5, 0.25, 1], + 'audio level cycle should wrap through the supported steps' + ); + const updated = updateAudioPreference(loaded, 'effects', 1); + assert.equal(updated.effects, 1, 'audio category update should replace the requested level'); + assert.equal(loaded.effects, 0.5, 'audio category update should not mutate its input'); + assert.equal(audioLevelLabel(0.75), '75%', 'audio level label'); + + saveAudioPreferences(updated, storage); + assert.deepEqual(JSON.parse(storage.getItem(audioPreferencesStorageKey)), updated, 'audio preferences should persist'); +} + +function createMemoryStorage() { + const values = new Map(); + return { + getItem(key) { + return values.has(key) ? values.get(key) : null; + }, + setItem(key, value) { + values.set(key, String(value)); + } + }; +} + function assertCompletedChannel(channel, revision, targetVolume, context) { assert.equal(channel.transition.revision, revision, `${context}: transition revision`); assert.equal(channel.transition.completedRevision, revision, `${context}: completed revision`); @@ -270,6 +491,7 @@ function assertClose(actual, expected, context) { function createAudioHarness() { const instances = []; + let shouldRejectNextPlay = false; class FakeAudio { constructor(src = '') { @@ -283,12 +505,17 @@ function createAudioHarness() { this.paused = true; this.playCalls = 0; this.pauseCalls = 0; + this.listeners = new Map(); instances.push(this); } play() { this.paused = false; this.playCalls += 1; + if (shouldRejectNextPlay) { + shouldRejectNextPlay = false; + return Promise.reject(new Error('Simulated media playback failure')); + } return Promise.resolve(); } @@ -297,7 +524,17 @@ function createAudioHarness() { this.pauseCalls += 1; } - addEventListener() {} + addEventListener(type, listener, options = {}) { + const entries = this.listeners.get(type) ?? []; + entries.push({ listener, once: Boolean(options.once) }); + this.listeners.set(type, entries); + } + + emit(type) { + const entries = [...(this.listeners.get(type) ?? [])]; + entries.forEach(({ listener }) => listener()); + this.listeners.set(type, entries.filter(({ once }) => !once)); + } } return { @@ -308,6 +545,9 @@ function createAudioHarness() { }, activeInstances() { return instances.filter((element) => element.src !== ''); + }, + rejectNextPlay() { + shouldRejectNextPlay = true; } }; } diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs index 78c1c41..4e59223 100644 --- a/scripts/verify-story-asset-data.mjs +++ b/scripts/verify-story-asset-data.mjs @@ -25,7 +25,13 @@ try { storyBackgroundKeysForPages, storyBackgroundPageHasFixedAssignment } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); - const { portraitAssetEntriesForKey, portraitKeyForSpeaker } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); + const { + portraitAssets, + portraitAssetEntriesForKey, + portraitAssetEntriesForStoryContext, + portraitTextureKeysForKey, + portraitKeyForSpeaker + } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); const { storyCutsceneActorProfileFor } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts'); const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); @@ -46,6 +52,14 @@ try { let pageCount = 0; let cutsceneCount = 0; + validatePortraitVersionSelection({ + errors, + portraitAssets, + portraitAssetEntriesForKey, + portraitAssetEntriesForStoryContext, + portraitTextureKeysForKey + }); + storySources.forEach(({ source, pages }) => { if (!Array.isArray(pages) || pages.length === 0) { errors.push(`${source}: story page list must not be empty`); @@ -86,6 +100,7 @@ try { storyBackgroundAssets, storyBackgroundKeysFor, portraitAssetEntriesForKey, + portraitAssetEntriesForStoryContext, portraitKeyForSpeaker, storyCutsceneActorProfileFor, hasUnitSheetAsset @@ -124,6 +139,152 @@ try { await server.close(); } +function validatePortraitVersionSelection(context) { + const { + errors, + portraitAssets, + portraitAssetEntriesForKey, + portraitAssetEntriesForStoryContext, + portraitTextureKeysForKey + } = context; + const zhugeLiangTextureKeys = portraitTextureKeysForKey('zhugeLiang'); + const requiredZhugeLiangTextureKeys = [ + 'portrait-zhuge-liang', + 'portrait-zhuge-liang-campaign-v2', + 'portrait-zhuge-liang-northern-v2', + 'portrait-zhuge-liang-redcliffs-v2' + ]; + const supersededZhugeLiangTextureKeys = [ + 'portrait-zhuge-liang-campaign', + 'portrait-zhuge-liang-northern', + 'portrait-zhuge-liang-redcliffs' + ]; + + requiredZhugeLiangTextureKeys.forEach((textureKey) => { + if (!zhugeLiangTextureKeys.includes(textureKey)) { + errors.push(`portrait version selection: missing current Zhuge Liang portrait "${textureKey}"`); + } + }); + supersededZhugeLiangTextureKeys.forEach((textureKey) => { + if (zhugeLiangTextureKeys.includes(textureKey)) { + errors.push(`portrait version selection: superseded Zhuge Liang portrait remains selectable "${textureKey}"`); + } + }); + + const zhugeLiangStoryCases = [ + { + context: { id: 'seventeenth-zhuge-liang-counsel', background: 'story-wolong' }, + expected: 'portrait-zhuge-liang' + }, + { + context: { id: 'eighteenth-red-cliffs-wind', background: 'story-wolong' }, + expected: 'portrait-zhuge-liang-redcliffs-v2' + }, + { + context: { id: 'twenty-first-zhuge-wind', background: 'story-red-cliffs' }, + expected: 'portrait-zhuge-liang-redcliffs-v2' + }, + { + context: { id: 'fiftieth-third-capture-plan', background: 'story-nanzhong-captures' }, + expected: 'portrait-zhuge-liang-campaign-v2' + }, + { + context: { id: 'thirty-fifth-hanzhong-front', background: 'story-northern' }, + expected: 'portrait-zhuge-liang-campaign-v2' + }, + { + context: { id: 'northern-prep-back-secured', background: 'story-northern' }, + expected: 'portrait-zhuge-liang-northern-v2' + }, + { + context: { id: 'fifty-fifth-qishan-road-opens', background: 'story-northern' }, + expected: 'portrait-zhuge-liang-northern-v2' + }, + { + context: { id: 'sixtieth-wudu-yinping-order', background: 'story-wudu-yinping' }, + expected: 'portrait-zhuge-liang-northern-v2' + } + ]; + + zhugeLiangStoryCases.forEach(({ context: storyContext, expected }) => { + const selected = portraitAssetEntriesForStoryContext('zhugeLiang', storyContext).map( + ({ textureKey }) => textureKey + ); + if (selected.length !== 1 || selected[0] !== expected) { + errors.push( + `portrait story context: ${storyContext.id} should select "${expected}", received [${selected.join(', ')}]` + ); + } + }); + + const zhugeLiangBaseRevisionFixture = 'zhuge-liang-v2'; + const previousBaseRevision = portraitAssets[zhugeLiangBaseRevisionFixture]; + portraitAssets[zhugeLiangBaseRevisionFixture] = `virtual:${zhugeLiangBaseRevisionFixture}`; + try { + const selected = portraitAssetEntriesForStoryContext('zhugeLiang', { + id: 'seventeenth-zhuge-liang-counsel', + background: 'story-wolong' + }).map(({ textureKey }) => textureKey); + if (selected.length !== 1 || selected[0] !== 'portrait-zhuge-liang-v2') { + errors.push( + `portrait story context: a revised base portrait should remain in the base route, received [${selected.join(', ')}]` + ); + } + } finally { + if (previousBaseRevision === undefined) { + delete portraitAssets[zhugeLiangBaseRevisionFixture]; + } else { + portraitAssets[zhugeLiangBaseRevisionFixture] = previousBaseRevision; + } + } + + const fixtureSlug = 'portrait-version-fixture'; + const fixtureSlugs = [ + fixtureSlug, + `${fixtureSlug}-campaign`, + `${fixtureSlug}-campaign-v2`, + `${fixtureSlug}-campaign-v10`, + `${fixtureSlug}-northern`, + `${fixtureSlug}-redcliffs-v2`, + `${fixtureSlug}-redcliffs-v3`, + `${fixtureSlug}-vanguard`, + `${fixtureSlug}-veteran` + ]; + const expectedFixtureTextureKeys = [ + `portrait-${fixtureSlug}`, + `portrait-${fixtureSlug}-campaign-v10`, + `portrait-${fixtureSlug}-northern`, + `portrait-${fixtureSlug}-redcliffs-v3`, + `portrait-${fixtureSlug}-vanguard`, + `portrait-${fixtureSlug}-veteran` + ]; + + fixtureSlugs.forEach((slug) => { + portraitAssets[slug] = `virtual:${slug}`; + }); + + try { + const entryTextureKeys = portraitAssetEntriesForKey(fixtureSlug).map((entry) => entry.textureKey); + const textureKeys = portraitTextureKeysForKey(fixtureSlug); + if (entryTextureKeys.join('\n') !== expectedFixtureTextureKeys.join('\n')) { + errors.push( + `portrait version selection: entries should keep independent variants and only the highest numeric -vN suffix; ` + + `received [${entryTextureKeys.join(', ')}]` + ); + } + if (textureKeys.join('\n') !== expectedFixtureTextureKeys.join('\n')) { + errors.push( + `portrait version selection: texture keys should keep independent variants and only the highest numeric -vN suffix; ` + + `received [${textureKeys.join(', ')}]` + ); + } + } finally { + fixtureSlugs.forEach((slug) => { + delete portraitAssets[slug]; + }); + } +} + function collectStorySources(scenarioModule, battleScenarios) { const sources = []; const seenArrays = new WeakSet(); @@ -350,13 +511,13 @@ function validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundU } function validatePortrait(context, pageContext) { - const { errors, page, usedPortraitKeys, portraitAssetEntriesForKey, portraitKeyForSpeaker } = context; + const { errors, page, usedPortraitKeys, portraitAssetEntriesForStoryContext, portraitKeyForSpeaker } = context; const portraitKey = page.portrait ?? portraitKeyForSpeaker(page.speaker); if (!portraitKey) { return; } - const entries = portraitAssetEntriesForKey(portraitKey); + const entries = portraitAssetEntriesForStoryContext(portraitKey, page); if (entries.length === 0) { errors.push(`${pageContext}: missing portrait asset "${portraitKey}"`); } diff --git a/src/assets/audio/sfx/armor-impact.mp3 b/src/assets/audio/sfx/armor-impact.mp3 new file mode 100644 index 0000000..60fc0be Binary files /dev/null and b/src/assets/audio/sfx/armor-impact.mp3 differ diff --git a/src/assets/audio/sfx/arrow-body-impact.mp3 b/src/assets/audio/sfx/arrow-body-impact.mp3 new file mode 100644 index 0000000..ea55a32 Binary files /dev/null and b/src/assets/audio/sfx/arrow-body-impact.mp3 differ diff --git a/src/assets/audio/sfx/arrow-swish.mp3 b/src/assets/audio/sfx/arrow-swish.mp3 new file mode 100644 index 0000000..033d386 Binary files /dev/null and b/src/assets/audio/sfx/arrow-swish.mp3 differ diff --git a/src/assets/audio/sfx/arrow-wood-impact.mp3 b/src/assets/audio/sfx/arrow-wood-impact.mp3 new file mode 100644 index 0000000..24a689f Binary files /dev/null and b/src/assets/audio/sfx/arrow-wood-impact.mp3 differ diff --git a/src/assets/audio/sfx/bow-release.mp3 b/src/assets/audio/sfx/bow-release.mp3 new file mode 100644 index 0000000..0b6136e Binary files /dev/null and b/src/assets/audio/sfx/bow-release.mp3 differ diff --git a/src/assets/audio/sfx/critical-boom.mp3 b/src/assets/audio/sfx/critical-boom.mp3 new file mode 100644 index 0000000..f822205 Binary files /dev/null and b/src/assets/audio/sfx/critical-boom.mp3 differ diff --git a/src/assets/audio/sfx/critical-impact.mp3 b/src/assets/audio/sfx/critical-impact.mp3 new file mode 100644 index 0000000..eb5a3ff Binary files /dev/null and b/src/assets/audio/sfx/critical-impact.mp3 differ diff --git a/src/assets/audio/sfx/shield-block.mp3 b/src/assets/audio/sfx/shield-block.mp3 new file mode 100644 index 0000000..b1af81d Binary files /dev/null and b/src/assets/audio/sfx/shield-block.mp3 differ diff --git a/src/assets/audio/sfx/sword-swing-variant.mp3 b/src/assets/audio/sfx/sword-swing-variant.mp3 new file mode 100644 index 0000000..f7eadbd Binary files /dev/null and b/src/assets/audio/sfx/sword-swing-variant.mp3 differ diff --git a/src/assets/images/portraits/zhuge-liang-campaign-v2.png b/src/assets/images/portraits/zhuge-liang-campaign-v2.png new file mode 100644 index 0000000..c5a2c83 Binary files /dev/null and b/src/assets/images/portraits/zhuge-liang-campaign-v2.png differ diff --git a/src/assets/images/portraits/zhuge-liang-northern-v2.png b/src/assets/images/portraits/zhuge-liang-northern-v2.png new file mode 100644 index 0000000..49234c1 Binary files /dev/null and b/src/assets/images/portraits/zhuge-liang-northern-v2.png differ diff --git a/src/assets/images/portraits/zhuge-liang-redcliffs-v2.png b/src/assets/images/portraits/zhuge-liang-redcliffs-v2.png new file mode 100644 index 0000000..91a1ee0 Binary files /dev/null and b/src/assets/images/portraits/zhuge-liang-redcliffs-v2.png differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index d11b21d..4a02fc7 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -1,5 +1,11 @@ type AudioTrackMap = Record; +export type AudioCategory = 'music' | 'effects' | 'ambience'; + +export type AudioCategoryMultipliers = Record; + +export type EffectPoolMap = Record; + export type SoundscapeOptions = { musicKey?: string; ambienceKey?: string; @@ -7,7 +13,7 @@ export type SoundscapeOptions = { ambienceVolume?: number; }; -type LoopChannelName = 'music' | 'ambience'; +type LoopChannelName = Exclude; type LoopChannelTransition = { revision: number; @@ -32,26 +38,74 @@ type LoopChannel = { hasPendingRequest: boolean; outgoing: Set; outgoingStartVolumes: Map; + elementBaseVolumes: Map; fadeTimer?: number; elementRevision: number; transition: LoopChannelTransition; }; -type PlayEffectOptions = { +export type PlayEffectOptions = { volume?: number; rate?: number; stopAfterMs?: number; }; +export type MusicDuckOptions = { + multiplier?: number; + attackMs?: number; + holdMs?: number; + releaseMs?: number; +}; + +type MusicDuckState = { + multiplier: number; + timer?: number; + revision: number; + completedRevision: number; + active: boolean; + endsAt: number; + last?: { + multiplier: number; + attackMs: number; + holdMs: number; + releaseMs: number; + }; +}; + export class SoundDirector { private context?: AudioContext; private master?: GainNode; + private effectsBus?: GainNode; private started = false; private muted = false; private effectTracks: AudioTrackMap = {}; + private effectPools: Record = {}; + private readonly lastEffectPoolSelections = new Map(); + private readonly activeEffects = new Set(); + private readonly effectBaseVolumes = new Map(); private readonly musicChannel = this.createLoopChannel('music', 0.22, 900); private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200); - private lastEffect?: { key: string; at: number; volume: number; rate: number }; + private readonly categoryMultipliers: AudioCategoryMultipliers = { + music: 1, + effects: 1, + ambience: 1 + }; + private readonly musicDuck: MusicDuckState = { + multiplier: 1, + revision: 0, + completedRevision: 0, + active: false, + endsAt: 0 + }; + private lastEffect?: { + key: string; + trackKey: string; + poolKey: string | null; + at: number; + baseVolume: number; + volume: number; + rate: number; + }; private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number }; private readonly effectVolume = 0.42; @@ -67,6 +121,16 @@ export class SoundDirector { this.effectTracks = tracks; } + registerEffectPools(pools: EffectPoolMap) { + this.effectPools = Object.fromEntries( + Object.entries(pools).map(([key, trackKeys]) => [ + key, + [...new Set(trackKeys.filter((trackKey) => typeof trackKey === 'string' && trackKey.length > 0))] + ]) + ); + this.lastEffectPoolSelections.clear(); + } + start() { if (this.started) { this.resume(); @@ -81,6 +145,9 @@ export class SoundDirector { this.master = this.context.createGain(); this.master.gain.value = this.muted ? 0 : 0.3; this.master.connect(this.context.destination); + this.effectsBus = this.context.createGain(); + this.effectsBus.gain.value = this.categoryMultipliers.effects; + this.effectsBus.connect(this.master); } this.started = true; @@ -105,6 +172,9 @@ export class SoundDirector { for (const element of this.loopElements()) { element.muted = muted; } + for (const effect of this.activeEffects) { + effect.muted = muted; + } if (!this.context || !this.master) { return; @@ -118,6 +188,91 @@ export class SoundDirector { return this.muted; } + setCategoryMultiplier(category: AudioCategory, multiplier: number) { + const normalizedMultiplier = this.normalizeUnitInterval(multiplier, this.categoryMultipliers[category]); + this.categoryMultipliers[category] = normalizedMultiplier; + + if (category === 'effects') { + if (this.effectsBus) { + this.effectsBus.gain.value = normalizedMultiplier; + } + this.refreshActiveEffectVolumes(); + return; + } + + this.refreshLoopChannelVolumes(category === 'music' ? this.musicChannel : this.ambienceChannel); + } + + setCategoryMultipliers(multipliers: Partial) { + (['music', 'effects', 'ambience'] as const).forEach((category) => { + const multiplier = multipliers[category]; + if (multiplier !== undefined) { + this.setCategoryMultiplier(category, multiplier); + } + }); + } + + duckMusic({ multiplier = 0.42, attackMs = 48, holdMs = 180, releaseMs = 280 }: MusicDuckOptions = {}) { + this.cancelMusicDuckTimer(); + + const normalizedMultiplier = this.normalizeUnitInterval(multiplier, 0.42); + const normalizedAttackMs = this.normalizeDuration(attackMs, 48); + const normalizedHoldMs = this.normalizeDuration(holdMs, 180); + const normalizedReleaseMs = this.normalizeDuration(releaseMs, 280); + const fromMultiplier = this.musicDuck.multiplier; + const targetMultiplier = Math.min(fromMultiplier, normalizedMultiplier); + const startedAt = performance.now(); + const previousEndsAt = this.musicDuck.active ? this.musicDuck.endsAt : startedAt; + const previousReleaseMs = this.musicDuck.active ? this.musicDuck.last?.releaseMs ?? 0 : 0; + const effectiveReleaseMs = Math.max(normalizedReleaseMs, previousReleaseMs); + const requestedEndsAt = startedAt + normalizedAttackMs + normalizedHoldMs + normalizedReleaseMs; + const endsAt = Math.max(previousEndsAt, requestedEndsAt); + const releaseStartsAt = Math.max(startedAt + normalizedAttackMs + normalizedHoldMs, endsAt - effectiveReleaseMs); + + this.musicDuck.revision += 1; + this.musicDuck.active = true; + this.musicDuck.endsAt = endsAt; + this.musicDuck.last = { + multiplier: normalizedMultiplier, + attackMs: normalizedAttackMs, + holdMs: normalizedHoldMs, + releaseMs: normalizedReleaseMs + }; + + const revision = this.musicDuck.revision; + const update = () => { + if (revision !== this.musicDuck.revision) { + return; + } + + const now = performance.now(); + const elapsedMs = Math.max(0, now - startedAt); + let nextMultiplier = targetMultiplier; + + if (normalizedAttackMs > 0 && elapsedMs < normalizedAttackMs) { + const progress = elapsedMs / normalizedAttackMs; + nextMultiplier = fromMultiplier + (targetMultiplier - fromMultiplier) * progress; + } else if (now >= releaseStartsAt) { + if (effectiveReleaseMs <= 0) { + nextMultiplier = 1; + } else { + const releaseProgress = Math.min(1, (now - releaseStartsAt) / effectiveReleaseMs); + nextMultiplier = targetMultiplier + (1 - targetMultiplier) * releaseProgress; + } + } + + this.setMusicDuckMultiplier(nextMultiplier); + if (now >= endsAt) { + this.completeMusicDuck(revision); + } + }; + + update(); + if (endsAt > startedAt && this.musicDuck.active) { + this.musicDuck.timer = window.setInterval(update, 16); + } + } + playHover() { this.playTone(540, 0.04, 0.018, 'sine'); } @@ -168,30 +323,62 @@ export class SoundDirector { } playMeleeSwing(hit: boolean, critical: boolean) { - this.playEffect('sword-slash', { + this.playEffect('melee-swing', { volume: critical ? 0.62 : hit ? 0.52 : 0.38, rate: critical ? 0.94 : hit ? 1 : 1.1, - stopAfterMs: 640 + stopAfterMs: 1700 }); this.playTone(critical ? 180 : 220, 0.04, critical ? 0.03 : 0.02, 'sawtooth'); } playArrowShot() { - this.playEffect('sword-slash', { volume: 0.18, rate: 1.58, stopAfterMs: 280 }); + this.playEffect('bow-release', { volume: 0.34, rate: 1, stopAfterMs: 420 }); this.playTone(760, 0.05, 0.012, 'triangle'); window.setTimeout(() => this.playTone(1180, 0.045, 0.01, 'sine'), 46); } playArrowImpact(hit: boolean, critical: boolean) { if (hit) { - this.playEffect('combat-impact', { volume: critical ? 0.5 : 0.34, rate: critical ? 1.06 : 1.18, stopAfterMs: 420 }); + this.playEffect('arrow-impact', { + volume: critical ? 0.5 : 0.38, + rate: critical ? 0.94 : 1, + stopAfterMs: 760 + }); this.playTone(critical ? 260 : 340, 0.05, critical ? 0.026 : 0.018, 'square'); + if (critical) { + this.playCriticalAccent(); + } return; } + this.playEffect('arrow-miss', { volume: 0.28, rate: 1.04, stopAfterMs: 500 }); this.playTone(940, 0.06, 0.011, 'sine'); } + playCombatImpact(critical: boolean, restrained = false) { + this.playEffect('melee-impact', { + volume: restrained ? (critical ? 0.4 : 0.26) : critical ? 0.58 : 0.46, + rate: critical ? 0.94 : 1, + stopAfterMs: 1700 + }); + if (critical) { + this.playCriticalAccent(); + } + } + + playBattleOutcome(outcome: 'victory' | 'defeat') { + this.duckMusic({ + multiplier: outcome === 'victory' ? 0.34 : 0.24, + attackMs: 90, + holdMs: 820, + releaseMs: 520 + }); + this.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', { + volume: outcome === 'victory' ? 0.34 : 0.26, + stopAfterMs: 1200 + }); + } + playStrategyPulse() { this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 }); this.playTone(460, 0.08, 0.012, 'sine'); @@ -208,33 +395,50 @@ export class SoundDirector { return false; } - const src = this.effectTracks[key]; - if (!src) { + const resolvedTrack = this.resolveEffectTrack(key); + if (!resolvedTrack) { return false; } - const effect = new Audio(src); + const effect = new Audio(resolvedTrack.src); + const baseVolume = this.normalizeUnitInterval(options.volume ?? this.effectVolume, this.effectVolume); effect.preload = 'auto'; - effect.volume = options.volume ?? this.effectVolume; + effect.volume = this.effectOutputVolume(baseVolume); effect.playbackRate = options.rate ?? 1; effect.muted = this.muted; - this.lastEffect = { key, at: performance.now(), volume: effect.volume, rate: effect.playbackRate }; + this.activeEffects.add(effect); + this.effectBaseVolumes.set(effect, baseVolume); + this.lastEffect = { + key, + trackKey: resolvedTrack.trackKey, + poolKey: resolvedTrack.poolKey, + at: performance.now(), + baseVolume, + volume: effect.volume, + rate: effect.playbackRate + }; effect.addEventListener( 'ended', () => { - effect.src = ''; + this.retireEffectElement(effect); + }, + { once: true } + ); + effect.addEventListener( + 'error', + () => { + this.retireEffectElement(effect); }, { once: true } ); if (options.stopAfterMs) { window.setTimeout(() => { - effect.pause(); - effect.src = ''; + this.retireEffectElement(effect); }, options.stopAfterMs); } - void effect.play().catch(() => undefined); + void effect.play().catch(() => this.retireEffectElement(effect)); return true; } @@ -252,6 +456,19 @@ export class SoundDirector { pendingAmbienceKey: ambience.pendingKey, music, ambience, + categoryMultipliers: { ...this.categoryMultipliers }, + musicDuck: { + multiplier: this.musicDuck.multiplier, + revision: this.musicDuck.revision, + completedRevision: this.musicDuck.completedRevision, + active: this.musicDuck.active, + endsAt: this.musicDuck.endsAt, + last: this.musicDuck.last ? { ...this.musicDuck.last } : null + }, + effectPools: Object.fromEntries( + Object.entries(this.effectPools).map(([key, trackKeys]) => [key, [...trackKeys]]) + ), + activeEffectCount: this.activeEffects.size, lastEffect: this.lastEffect ?? null, lastMovementStep: this.lastMovementStep ?? null }; @@ -302,11 +519,19 @@ export class SoundDirector { gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); oscillator.connect(gain); - gain.connect(this.master ?? this.context.destination); + gain.connect(this.effectsBus ?? this.master ?? this.context.destination); oscillator.start(now); oscillator.stop(now + duration + 0.04); } + private playCriticalAccent() { + this.duckMusic({ multiplier: 0.52, attackMs: 32, holdMs: 140, releaseMs: 280 }); + this.playEffect('critical-impact', { volume: 0.3, rate: 1, stopAfterMs: 800 }); + window.setTimeout(() => { + this.playEffect('critical-boom', { volume: 0.16, rate: 1.04, stopAfterMs: 2000 }); + }, 34); + } + private playStepPulse(mounted: boolean, stepIndex: number, volume = 0.2) { if (!this.context || this.muted) { return; @@ -347,7 +572,7 @@ export class SoundDirector { gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); oscillator.connect(gain); - gain.connect(this.master ?? this.context.destination); + gain.connect(this.effectsBus ?? this.master ?? this.context.destination); oscillator.start(start); oscillator.stop(start + duration + 0.025); } @@ -362,6 +587,7 @@ export class SoundDirector { hasPendingRequest: false, outgoing: new Set(), outgoingStartVolumes: new Map(), + elementBaseVolumes: new Map(), elementRevision: 0, transition: { revision: 0, @@ -408,7 +634,7 @@ export class SoundDirector { if (channel.current) { channel.current.muted = this.muted; if (!channel.transition.active) { - channel.current.volume = channel.targetVolume; + this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume); } void channel.current.play().catch(() => undefined); } @@ -433,7 +659,7 @@ export class SoundDirector { const fromKey = channel.currentKey ?? null; if (previous) { channel.outgoing.add(previous); - channel.outgoingStartVolumes.set(previous, previous.volume); + channel.outgoingStartVolumes.set(previous, channel.elementBaseVolumes.get(previous) ?? channel.targetVolume); } channel.current = undefined; @@ -444,7 +670,7 @@ export class SoundDirector { next.loop = true; next.preload = 'auto'; next.muted = this.muted; - next.volume = 0; + this.setLoopElementBaseVolume(channel, next, 0); channel.current = next; channel.currentKey = key; channel.elementRevision += 1; @@ -468,11 +694,11 @@ export class SoundDirector { const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs)); if (channel.current) { - channel.current.volume = channel.targetVolume * progress; + this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume * progress); } channel.outgoing.forEach((element) => { - const startVolume = channel.outgoingStartVolumes.get(element) ?? element.volume; - element.volume = startVolume * (1 - progress); + const startVolume = channel.outgoingStartVolumes.get(element) ?? channel.elementBaseVolumes.get(element) ?? 0; + this.setLoopElementBaseVolume(channel, element, startVolume * (1 - progress)); }); if (progress >= 1) { @@ -489,7 +715,7 @@ export class SoundDirector { this.cancelLoopTransitionTimer(channel); this.retireOutgoingElements(channel); if (channel.current) { - channel.current.volume = channel.targetVolume; + this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume); } channel.transition.active = false; channel.transition.completedRevision = transitionRevision; @@ -503,14 +729,15 @@ export class SoundDirector { } private retireOutgoingElements(channel: LoopChannel) { - channel.outgoing.forEach((element) => this.retireLoopElement(element)); + channel.outgoing.forEach((element) => this.retireLoopElement(channel, element)); channel.outgoing.clear(); channel.outgoingStartVolumes.clear(); } - private retireLoopElement(element: HTMLAudioElement) { + private retireLoopElement(channel: LoopChannel, element: HTMLAudioElement) { element.pause(); element.src = ''; + channel.elementBaseVolumes.delete(element); } private loopElements() { @@ -532,6 +759,9 @@ export class SoundDirector { elementRevision: channel.elementRevision, activeElementCount: (channel.current ? 1 : 0) + channel.outgoing.size, targetVolume: channel.targetVolume, + categoryMultiplier: this.categoryMultipliers[channel.name], + duckMultiplier: channel.name === 'music' ? this.musicDuck.multiplier : 1, + effectiveTargetVolume: this.loopOutputVolume(channel, channel.targetVolume), transition: { revision: channel.transition.revision, completedRevision: channel.transition.completedRevision, @@ -540,6 +770,104 @@ export class SoundDirector { } }; } + + private resolveEffectTrack(key: string) { + const pool = this.effectPools[key]; + if (pool?.length) { + const availableTrackKeys = pool.filter((trackKey) => Boolean(this.effectTracks[trackKey])); + if (availableTrackKeys.length) { + const lastSelection = this.lastEffectPoolSelections.get(key); + const candidates = + availableTrackKeys.length > 1 + ? availableTrackKeys.filter((trackKey) => trackKey !== lastSelection) + : availableTrackKeys; + const randomIndex = Math.floor(Math.random() * candidates.length); + const trackKey = candidates[randomIndex] ?? candidates[0]; + this.lastEffectPoolSelections.set(key, trackKey); + return { trackKey, src: this.effectTracks[trackKey], poolKey: key }; + } + } + + const src = this.effectTracks[key]; + return src ? { trackKey: key, src, poolKey: null } : undefined; + } + + private refreshActiveEffectVolumes() { + this.activeEffects.forEach((effect) => { + const baseVolume = this.effectBaseVolumes.get(effect); + if (baseVolume !== undefined) { + effect.volume = this.effectOutputVolume(baseVolume); + } + }); + } + + private effectOutputVolume(baseVolume: number) { + return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers.effects, 0); + } + + private retireEffectElement(effect: HTMLAudioElement) { + effect.pause(); + effect.src = ''; + this.activeEffects.delete(effect); + this.effectBaseVolumes.delete(effect); + } + + private setLoopElementBaseVolume(channel: LoopChannel, element: HTMLAudioElement, baseVolume: number) { + const normalizedBaseVolume = this.normalizeUnitInterval(baseVolume, 0); + channel.elementBaseVolumes.set(element, normalizedBaseVolume); + element.volume = this.loopOutputVolume(channel, normalizedBaseVolume); + } + + private refreshLoopChannelVolumes(channel: LoopChannel) { + if (channel.current) { + this.refreshLoopElementVolume(channel, channel.current); + } + channel.outgoing.forEach((element) => this.refreshLoopElementVolume(channel, element)); + } + + private refreshLoopElementVolume(channel: LoopChannel, element: HTMLAudioElement) { + const baseVolume = channel.elementBaseVolumes.get(element); + if (baseVolume !== undefined) { + element.volume = this.loopOutputVolume(channel, baseVolume); + } + } + + private loopOutputVolume(channel: LoopChannel, baseVolume: number) { + const duckMultiplier = channel.name === 'music' ? this.musicDuck.multiplier : 1; + return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers[channel.name] * duckMultiplier, 0); + } + + private setMusicDuckMultiplier(multiplier: number) { + this.musicDuck.multiplier = this.normalizeUnitInterval(multiplier, 1); + this.refreshLoopChannelVolumes(this.musicChannel); + } + + private completeMusicDuck(revision: number) { + if (revision !== this.musicDuck.revision) { + return; + } + + this.cancelMusicDuckTimer(); + this.setMusicDuckMultiplier(1); + this.musicDuck.active = false; + this.musicDuck.endsAt = 0; + this.musicDuck.completedRevision = revision; + } + + private cancelMusicDuckTimer() { + if (this.musicDuck.timer !== undefined) { + window.clearInterval(this.musicDuck.timer); + this.musicDuck.timer = undefined; + } + } + + private normalizeUnitInterval(value: number, fallback: number) { + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback; + } + + private normalizeDuration(value: number, fallback: number) { + return Number.isFinite(value) ? Math.min(10_000, Math.max(0, value)) : fallback; + } } declare global { diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index 6382d72..200890e 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -7,16 +7,25 @@ import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3'; import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3'; import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3'; import titleThemeUrl from '../../assets/audio/bgm/title-theme.mp3'; +import armorImpactUrl from '../../assets/audio/sfx/armor-impact.mp3'; +import arrowBodyImpactUrl from '../../assets/audio/sfx/arrow-body-impact.mp3'; +import arrowSwishUrl from '../../assets/audio/sfx/arrow-swish.mp3'; +import arrowWoodImpactUrl from '../../assets/audio/sfx/arrow-wood-impact.mp3'; import bondResonanceUrl from '../../assets/audio/sfx/bond-resonance.wav'; +import bowReleaseUrl from '../../assets/audio/sfx/bow-release.mp3'; import cartRollUrl from '../../assets/audio/sfx/cart-roll.mp3'; import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3'; +import criticalBoomUrl from '../../assets/audio/sfx/critical-boom.mp3'; +import criticalImpactUrl from '../../assets/audio/sfx/critical-impact.mp3'; import defeatStingUrl from '../../assets/audio/sfx/defeat-sting.wav'; import expGainUrl from '../../assets/audio/sfx/exp-gain.mp3'; import footstepWalkUrl from '../../assets/audio/sfx/footstep-walk.mp3'; import horseGallopUrl from '../../assets/audio/sfx/horse-gallop.mp3'; import levelUpUrl from '../../assets/audio/sfx/level-up.wav'; +import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3'; import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3'; import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3'; +import swordSwingVariantUrl from '../../assets/audio/sfx/sword-swing-variant.mp3'; import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3'; import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav'; import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3'; @@ -46,7 +55,16 @@ export const ambienceTracks = { export const effectTracks = { 'ui-select': uiSelectUrl, 'sword-slash': swordSlashUrl, + 'sword-swing-variant': swordSwingVariantUrl, 'combat-impact': combatImpactUrl, + 'armor-impact': armorImpactUrl, + 'shield-block': shieldBlockUrl, + 'bow-release': bowReleaseUrl, + 'arrow-swish': arrowSwishUrl, + 'arrow-body-impact': arrowBodyImpactUrl, + 'arrow-wood-impact': arrowWoodImpactUrl, + 'critical-impact': criticalImpactUrl, + 'critical-boom': criticalBoomUrl, 'strategy-cast': strategyCastUrl, 'cart-roll': cartRollUrl, 'exp-gain': expGainUrl, @@ -58,6 +76,14 @@ export const effectTracks = { 'horse-gallop': horseGallopUrl } as const; +export const effectPools = { + 'melee-swing': ['sword-slash', 'sword-swing-variant'], + 'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'], + 'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'], + 'arrow-miss': ['arrow-swish'] +} as const; + export type MusicTrackKey = keyof typeof musicTracks; export type AmbienceTrackKey = keyof typeof ambienceTracks; export type EffectTrackKey = keyof typeof effectTracks; +export type EffectPoolKey = keyof typeof effectPools; diff --git a/src/game/data/portraitAssets.ts b/src/game/data/portraitAssets.ts index 45ef2c7..2d7cb3b 100644 --- a/src/game/data/portraitAssets.ts +++ b/src/game/data/portraitAssets.ts @@ -96,6 +96,23 @@ export type PortraitAssetEntry = { url: string; }; +export type StoryPortraitContext = { + id: string; + background: string; +}; + +const zhugeLiangNorthernBackgrounds = new Set([ + 'story-tianshui-front', + 'story-jieting-crisis', + 'story-chencang-siege', + 'story-wudu-yinping', + 'story-hanzhong-rain', + 'story-qishan-renewed', + 'story-lucheng-pursuit', + 'story-weishui-camps', + 'story-weishui-northbank' +]); + export function portraitSlugForKey(key: string) { return legacyPortraitSlugs[key] ?? camelToKebab(key); } @@ -121,28 +138,73 @@ export function portraitCardAssetEntryForKey(key: string): PortraitAssetEntry | : undefined; } -export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] { +function portraitAssetSlugsForKey(key: string) { const slug = portraitSlugForKey(key); const prefix = `${slug}-`; - return Object.keys(portraitAssets) + const latestByVariant = new Map(); + + Object.keys(portraitAssets) .filter((candidate) => candidate === slug || candidate.startsWith(prefix)) - .sort() - .map((candidate) => ({ - textureKey: `portrait-${candidate}`, - url: portraitAssets[candidate] - })); + .forEach((candidate) => { + // Only a trailing numeric -vN marks a revision; other suffixes remain distinct portrait variants. + const versionMatch = candidate.match(/^(.*)-v([1-9]\d*)$/); + const variant = versionMatch?.[1] ?? candidate; + const version = versionMatch ? Number.parseInt(versionMatch[2], 10) : 0; + const latest = latestByVariant.get(variant); + + if (!latest || version > latest.version) { + latestByVariant.set(variant, { slug: candidate, version }); + } + }); + + return Array.from(latestByVariant.values(), ({ slug: candidate }) => candidate).sort(); +} + +export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] { + return portraitAssetSlugsForKey(key).map((candidate) => ({ + textureKey: `portrait-${candidate}`, + url: portraitAssets[candidate] + })); +} + +export function portraitAssetEntriesForStoryContext(key: string, context: StoryPortraitContext) { + const entries = portraitAssetEntriesForKey(key); + if (portraitSlugForKey(key) !== 'zhuge-liang' || entries.length <= 1) { + return entries; + } + + const variant = zhugeLiangStoryVariant(context); + const textureKeyPrefix = variant === 'base' + ? 'portrait-zhuge-liang' + : `portrait-zhuge-liang-${variant}`; + const preferredEntries = entries.filter(({ textureKey }) => + textureKey === textureKeyPrefix || + (textureKey.startsWith(textureKeyPrefix) && /^-v[1-9]\d*$/.test(textureKey.slice(textureKeyPrefix.length))) + ); + return preferredEntries.length > 0 ? preferredEntries : entries; } export function portraitTextureKeysForKey(key: string) { - const slug = portraitSlugForKey(key); - const prefix = `${slug}-`; - const slugs = Object.keys(portraitAssets) - .filter((candidate) => candidate === slug || candidate.startsWith(prefix)) - .sort(); - - return Array.from(new Set(slugs.map((candidate) => `portrait-${candidate}`))); + return portraitAssetSlugsForKey(key).map((candidate) => `portrait-${candidate}`); } export function portraitKeyForSpeaker(speaker?: string) { return speaker ? speakerPortraitKeys[speaker] : undefined; } + +function zhugeLiangStoryVariant({ id, background }: StoryPortraitContext) { + if (background === 'story-red-cliffs' || id === 'eighteenth-red-cliffs-wind') { + return 'redcliffs'; + } + if (background === 'story-wolong') { + return 'base'; + } + if ( + id === 'northern-prep-back-secured' || + id.startsWith('fifty-fifth-') || + zhugeLiangNorthernBackgrounds.has(background) + ) { + return 'northern'; + } + return 'campaign'; +} diff --git a/src/game/data/unitActionAssets.ts b/src/game/data/unitActionAssets.ts new file mode 100644 index 0000000..20ba88f --- /dev/null +++ b/src/game/data/unitActionAssets.ts @@ -0,0 +1,17 @@ +const unitActionSheetModules = import.meta.glob('../../assets/images/units/unit-*-actions.webp', { + eager: true, + import: 'default' +}) as Record; + +function textureKeyFromPath(path: string) { + const fileName = path.split('/').pop() ?? path; + return fileName.replace(/\.(?:png|webp)$/i, ''); +} + +const unitActionSheetUrls = new Map( + Object.entries(unitActionSheetModules).map(([path, url]) => [textureKeyFromPath(path), url]) +); + +export function unitActionSheetUrlForKey(actionKey: string) { + return unitActionSheetUrls.get(actionKey); +} diff --git a/src/game/data/unitAssets.ts b/src/game/data/unitAssets.ts index 65b7913..0a868f5 100644 --- a/src/game/data/unitAssets.ts +++ b/src/game/data/unitAssets.ts @@ -22,7 +22,6 @@ type UnitSheetAsset = { frameSize: number; format: 'png' | 'webp'; actionKey: string; - actionUrl: string; actionFrameSize: number; actionFormat: 'png' | 'webp'; }; @@ -41,7 +40,10 @@ export type UnitActionSheetLoadOptions = { const defaultUnitActionSheetWatchdogMs = 15_000; -const unitWebpSheetModules = import.meta.glob('../../assets/images/units/unit-*.webp', { +const unitWebpSheetModules = import.meta.glob([ + '../../assets/images/units/unit-*.webp', + '!../../assets/images/units/unit-*-actions.webp' +], { eager: true, import: 'default' }) as Record; @@ -62,29 +64,16 @@ const unitSheetEntries = Object.entries(unitSheetModules).map(([path, url]) => ( url, format: unitSheetFormatFromPath(path) })); -const unitSheetEntriesByKey = new Map(unitSheetEntries.map((entry) => [entry.key, entry])); - const unitSheets = unitSheetEntries - .filter(({ key }) => !key.endsWith('-actions')) - .map(({ key, url }) => { - const actionKey = `${key}-actions`; - const actionEntry = unitSheetEntriesByKey.get(actionKey); - return actionEntry - ? { - key, - url, - frameSize: unitActionAssetPolicy.optimizedFrameSize, - format: unitActionAssetPolicy.format, - actionKey, - actionUrl: actionEntry.url, - actionFrameSize: actionEntry.format === unitActionAssetPolicy.format - ? unitActionAssetPolicy.optimizedFrameSize - : unitSheetFrameSize, - actionFormat: actionEntry.format - } - : undefined; - }) - .filter((sheet): sheet is UnitSheetAsset => Boolean(sheet)) + .map(({ key, url, format }) => ({ + key, + url, + frameSize: unitActionAssetPolicy.optimizedFrameSize, + format, + actionKey: `${key}-actions`, + actionFrameSize: unitActionAssetPolicy.optimizedFrameSize, + actionFormat: 'webp' as const + })) .sort((left, right) => left.key.localeCompare(right.key)); const unitSheetAssetsByKey = new Map(unitSheets.map((sheet) => [sheet.key, sheet])); @@ -118,13 +107,18 @@ export function unitBaseSheetAssetInfo(key: string): UnitBaseSheetAssetInfo | un : undefined; } -export function unitActionSheetAssetInfo(key: string): UnitActionSheetAssetInfo | undefined { +export async function unitActionSheetAssetInfo(key: string): Promise { const sheet = unitSheetAssetsByKey.get(key); - return sheet + if (!sheet) { + return undefined; + } + const { unitActionSheetUrlForKey } = await import('./unitActionAssets'); + const url = unitActionSheetUrlForKey(sheet.actionKey); + return url ? { key: sheet.key, actionKey: sheet.actionKey, - url: sheet.actionUrl, + url, frameSize: sheet.actionFrameSize, format: sheet.actionFormat } @@ -278,12 +272,12 @@ export function loadUnitActionSheets( return; } - const pendingLoads: Array<{ key: string; url: string; frameSize: number }> = []; + const pendingLoads: Array<{ key: string; frameSize: number }> = []; uniqueKeys.forEach((key) => { const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key); if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) { pendingUnitTextureKeys.add(sheet.actionKey); - pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl, frameSize: sheet.actionFrameSize }); + pendingLoads.push({ key: sheet.actionKey, frameSize: sheet.actionFrameSize }); } }); @@ -328,7 +322,11 @@ export function loadUnitActionSheets( const actionKey = `${key}-actions`; return scene.textures.exists(actionKey) || failedActionKeys.has(actionKey); }); - const handleComplete = () => settle(); + const handleComplete = () => { + if (allRequestsSettled()) { + settle(); + } + }; const handleLoadError = (file: Phaser.Loader.File) => { const key = String(file.key); if (!actionKeys.has(key)) { @@ -342,8 +340,6 @@ export function loadUnitActionSheets( const handleShutdown = () => settle(false); scene.events.once('shutdown', handleShutdown); - scene.load.on('complete', handleComplete); - scene.load.on('loaderror', handleLoadError); pollTimer = setInterval(() => { if (unitActionSheetsReady(scene, uniqueKeys)) { settle(); @@ -355,14 +351,39 @@ export function loadUnitActionSheets( return; } - pendingLoads.forEach(({ key, url, frameSize }) => { - scene.load.spritesheet(key, url, { - frameWidth: frameSize, - frameHeight: frameSize + void import('./unitActionAssets').then(({ unitActionSheetUrlForKey }) => { + if (settled) { + return; + } + + const resolvedLoads = pendingLoads.flatMap(({ key, frameSize }) => { + const url = unitActionSheetUrlForKey(key); + if (!url) { + failedActionKeys.add(key); + return []; + } + return [{ key, url, frameSize }]; }); + + if (resolvedLoads.length <= 0) { + settle(); + return; + } + + scene.load.on('complete', handleComplete); + scene.load.on('loaderror', handleLoadError); + resolvedLoads.forEach(({ key, url, frameSize }) => { + scene.load.spritesheet(key, url, { + frameWidth: frameSize, + frameHeight: frameSize + }); + }); + ownedLoaderFiles = captureLoaderFiles(scene, resolvedLoads.map(({ key }) => key)); + scene.load.start(); + }).catch(() => { + pendingLoads.forEach(({ key }) => failedActionKeys.add(key)); + settle(); }); - ownedLoaderFiles = captureLoaderFiles(scene, pendingLoads.map(({ key }) => key)); - scene.load.start(); } function normalizeUnitSheetWatchdogMs(value: number | undefined) { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index e5863a1..cb9b942 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; +import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences'; import { battleMapAssets } from '../data/battleMapAssets'; import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; import { @@ -3434,7 +3435,7 @@ const mapMenuLabels: Partial> = { situation: '전황', speed: '속도', presentation: '전투 연출', - bgm: 'BGM', + bgm: '음향', close: '닫기' }; @@ -12703,10 +12704,7 @@ export class BattleScene extends Phaser.Scene { this.renderSituationPanel(message); this.publishFirstBattleReport(outcome); const revealResult = () => { - soundDirector.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', { - volume: outcome === 'victory' ? 0.34 : 0.26, - stopAfterMs: 1200 - }); + soundDirector.playBattleOutcome(outcome); this.showBattleResult(outcome); }; if (resultDelayMs > 0) { @@ -16528,7 +16526,12 @@ export class BattleScene extends Phaser.Scene { ) { return; } - if (this.phase === 'idle' && this.activeFaction === 'ally' && this.turnNumber === 1) { + if ( + this.phase === 'idle' && + this.activeFaction === 'ally' && + this.turnNumber === 1 && + this.battleEventObjects.length === 0 + ) { this.startFirstBattleTutorial(); return; } @@ -16536,7 +16539,7 @@ export class BattleScene extends Phaser.Scene { this.time.delayedCall(180, () => attemptStart(attemptsRemaining - 1)); } }; - attemptStart(12); + attemptStart(30); } private startFirstBattleTutorial() { @@ -17788,8 +17791,10 @@ export class BattleScene extends Phaser.Scene { return; } if (action === 'bgm') { - soundDirector.setMuted(!soundDirector.isMuted()); - this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`); + const muted = !soundDirector.isMuted(); + soundDirector.setMuted(muted); + saveAudioPreferences({ ...loadAudioPreferences(), muted }); + this.renderSituationPanel(`음향을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`); } } @@ -19458,16 +19463,21 @@ export class BattleScene extends Phaser.Scene { }); } + const rangedAttack = this.isRangedAttack(result); if (result.action === 'strategy') { soundDirector.playStrategyPulse(); } else if (result.action === 'item') { soundDirector.playItemLaunch(); - } else if (result.attacker.classKey === 'archer' || this.attackRange(result.attacker) > 1) { + } else if (rangedAttack) { soundDirector.playArrowShot(); - soundDirector.playArrowImpact(result.hit, result.critical); } else { soundDirector.playMeleeSwing(result.hit, result.critical); } + if (rangedAttack) { + soundDirector.playArrowImpact(result.hit, result.critical); + } else if (result.hit) { + soundDirector.playCombatImpact(result.critical, result.action === 'strategy'); + } const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.2, this.layout.tileSize * 0.28); ring.setStrokeStyle(result.critical || result.defeated ? 6 : 4, accent, 0.94); @@ -19714,14 +19724,16 @@ export class BattleScene extends Phaser.Scene { groundY, depth + 10, defenderDirection, - stage.shadows[0] + stage.shadows[0], + () => { + if (result.hit) { + this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); + void this.showSortieDefenseCombatEffect(result, defenderX, groundY - 70, depth + 17); + } else { + this.showCombatMiss(defenderX, groundY - 70, depth + 14); + } + } ); - if (result.hit) { - this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); - void this.showSortieDefenseCombatEffect(result, defenderX, groundY - 70, depth + 17); - } else { - this.showCombatMiss(defenderX, groundY - 70, depth + 14); - } const outcomeCard = this.renderCombatOutcomeCard(result, left + panelWidth / 2 - 226, top + 184, 452, depth + 16); const hpAfterBaseAttack = result.bondChain?.previousDefenderHp ?? result.defender.hp; @@ -21138,7 +21150,8 @@ export class BattleScene extends Phaser.Scene { groundY: number, depth: number, defenderDirection: UnitDirection, - attackerShadow?: Phaser.GameObjects.Ellipse + attackerShadow?: Phaser.GameObjects.Ellipse, + onContact?: () => void ) { const direction = attackerX <= defenderX ? 1 : -1; if (result.action === 'attack') { @@ -21153,7 +21166,8 @@ export class BattleScene extends Phaser.Scene { depth, defenderDirection, direction, - attackerShadow + attackerShadow, + onContact ); return; } @@ -21168,12 +21182,24 @@ export class BattleScene extends Phaser.Scene { depth, defenderDirection, direction, - attackerShadow + attackerShadow, + onContact ); return; } - await this.playSpecialActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction); + await this.playSpecialActionMotion( + result, + attackerSprite, + defenderSprite, + attackerX, + defenderX, + groundY, + depth, + defenderDirection, + direction, + onContact + ); } private isRangedAttack(result: Pick) { @@ -21190,7 +21216,8 @@ export class BattleScene extends Phaser.Scene { depth: number, defenderDirection: UnitDirection, direction: number, - attackerShadow?: Phaser.GameObjects.Ellipse + attackerShadow?: Phaser.GameObjects.Ellipse, + onContact?: () => void ) { const mounted = result.attacker.classKey === 'cavalry'; const windupX = attackerX - direction * 28; @@ -21244,6 +21271,7 @@ export class BattleScene extends Phaser.Scene { await this.delay(mounted ? 170 : 205); soundDirector.playMeleeSwing(result.hit, result.critical); + onContact?.(); this.setUnitActionFrame(attackerSprite, result.attacker, 'east', 'attack', this.unitActionImpactFrame('attack')); this.createSlashArc(defenderX - direction * 68, groundY - 48, direction, depth + 2, result.critical); @@ -21298,7 +21326,8 @@ export class BattleScene extends Phaser.Scene { depth: number, defenderDirection: UnitDirection, direction: number, - attackerShadow?: Phaser.GameObjects.Ellipse + attackerShadow?: Phaser.GameObjects.Ellipse, + onContact?: () => void ) { void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', 'attack', 88); this.createBowDrawEffect(attackerX + direction * 44, groundY - 52, direction, depth + 2); @@ -21334,6 +21363,7 @@ export class BattleScene extends Phaser.Scene { ease: result.hit ? 'Cubic.easeIn' : 'Sine.easeOut' }); await this.delay(result.hit ? 332 : 382); + onContact?.(); if (result.hit) { void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70); @@ -21367,7 +21397,8 @@ export class BattleScene extends Phaser.Scene { groundY: number, depth: number, defenderDirection: UnitDirection, - direction: number + direction: number, + onContact?: () => void ) { const motionKind = this.specialDamageMotionKind(result); if (result.action === 'item') { @@ -21402,6 +21433,7 @@ export class BattleScene extends Phaser.Scene { ease: 'Cubic.easeIn' }); await this.delay(450); + onContact?.(); if (result.hit) { void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70); this.createSpecialImpact(defenderX - direction * 68, this.specialImpactY(motionKind, groundY), depth + 4, motionKind); @@ -22039,9 +22071,10 @@ export class BattleScene extends Phaser.Scene { bondChain: undefined, counter: undefined }; - const contactDelay = motion === 'ranged' - ? this.scaledBattleDuration(150) + this.scaledBattleDuration(332, 90) - : this.scaledBattleDuration(115) + this.scaledBattleDuration(partner.classKey === 'cavalry' ? 170 : 205); + let resolveContact: (() => void) | undefined; + const contactPromise = new Promise((resolve) => { + resolveContact = resolve; + }); const motionPromise = this.playCombatActionMotion( followUpResult, supporterSprite, @@ -22051,12 +22084,14 @@ export class BattleScene extends Phaser.Scene { groundY, depth + 1, 'west', - supporterShadow + supporterShadow, + () => { + onImpact?.(); + this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5); + resolveContact?.(); + } ); - await new Promise((resolve) => this.time.delayedCall(contactDelay, resolve)); - - onImpact?.(); - this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5); + await contactPromise; const chainTitle = chain.coreResonance ? '핵심 공명' : '공명'; const finishTitle = chain.defeated ? `${chainTitle} 격파 · ${chain.partnerName}` : `${chainTitle} 추격 · ${chain.partnerName}`; @@ -22245,7 +22280,7 @@ export class BattleScene extends Phaser.Scene { if (this.isRangedAttack(result)) { soundDirector.playArrowImpact(true, result.critical); } else { - soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); + soundDirector.playCombatImpact(result.critical, result.action === 'strategy'); } const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, result.critical ? 54 : 42, this.combatImpactColor(result), 0.92)); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 33592f4..b168bdb 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -8,7 +8,12 @@ import { loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; -import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; +import { + portraitAssetEntriesForKey, + portraitAssetEntriesForStoryContext, + portraitKeyForSpeaker, + type PortraitAssetEntry +} from '../data/portraitAssets'; import { storyBackgroundAssets, storyBackgroundKeyForPage, @@ -60,6 +65,13 @@ const dialogDepth = 20; const fhdUiScale = 1.5; const ui = (value: number) => value * fhdUiScale; const uiPx = (value: number) => `${Math.round(ui(value))}px`; +const storyDialogLayout = { + sideInset: 72, + topOffset: 270, + height: 204, + nameTopOffset: 261, + bodyTopOffset: 225 +} as const; const storyUnitFrameRows: Record = { south: 0, east: 1, @@ -183,8 +195,16 @@ export class StoryScene extends Phaser.Scene { getDebugState() { const page = this.pages[this.pageIndex]; + const bodyBounds = this.bodyText?.active ? this.bodyText.getBounds() : undefined; const progressBounds = this.progressText?.active ? this.progressText.getBounds() : undefined; const progressPanelBounds = this.progressBackground?.active ? this.progressBackground.getBounds() : undefined; + const cutsceneStageBounds = page?.cutscene ? this.cutsceneStageBounds() : undefined; + const dialogPanelBounds = { + x: ui(storyDialogLayout.sideInset), + y: this.scale.height - ui(storyDialogLayout.topOffset), + width: this.scale.width - ui(storyDialogLayout.sideInset * 2), + height: ui(storyDialogLayout.height) + }; const isLastPage = this.pageIndex >= this.pages.length - 1; return { @@ -199,6 +219,11 @@ export class StoryScene extends Phaser.Scene { activeTexture: this.background?.texture.key ?? null, portraitKey: page ? this.pagePortraitKey(page) ?? null : null, portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null, + bodyBounds: bodyBounds + ? { x: bodyBounds.x, y: bodyBounds.y, width: bodyBounds.width, height: bodyBounds.height } + : null, + dialogPanelBounds, + cutsceneStageBounds: cutsceneStageBounds ?? null, progressLabel: this.progressText?.text ?? '', progressBounds: progressBounds ? { x: progressBounds.x, y: progressBounds.y, width: progressBounds.width, height: progressBounds.height } @@ -556,10 +581,10 @@ export class StoryScene extends Phaser.Scene { } private drawDialogPanel(width: number, height: number) { - const panelY = height - ui(238); - const panelX = ui(72); - const panelW = width - ui(144); - const panelH = ui(172); + const panelY = height - ui(storyDialogLayout.topOffset); + const panelX = ui(storyDialogLayout.sideInset); + const panelW = width - ui(storyDialogLayout.sideInset * 2); + const panelH = ui(storyDialogLayout.height); const panel = this.add.graphics(); panel.setDepth(dialogDepth); @@ -595,11 +620,11 @@ export class StoryScene extends Phaser.Scene { }); this.chapterText.setDepth(dialogDepth + 2); - this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + ui(86), ui(136), ui(136), cutsceneUiColors.ink, 0.92); + this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + panelH / 2, ui(136), ui(136), cutsceneUiColors.ink, 0.92); this.portraitFrame.setStrokeStyle(ui(2), palette.gold, 0.34); this.portraitFrame.setDepth(dialogDepth + 2); - this.portrait = this.add.image(panelX + ui(86), panelY + ui(86), this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); + this.portrait = this.add.image(panelX + ui(86), panelY + panelH / 2, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); this.portrait.setDisplaySize(ui(128), ui(128)); this.portrait.setDepth(dialogDepth + 3); @@ -615,7 +640,7 @@ export class StoryScene extends Phaser.Scene { }); this.nameText.setDepth(dialogDepth + 2); - this.bodyText = this.add.text(panelX + ui(198), panelY + ui(78), '', { + this.bodyText = this.add.text(panelX + ui(198), panelY + ui(45), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: uiPx(25), color: '#e8dfca', @@ -704,15 +729,15 @@ export class StoryScene extends Phaser.Scene { this.portraitDivider?.setVisible(true); this.portrait?.setTexture(textureKey); this.portrait?.setDisplaySize(ui(128), ui(128)); - this.nameText?.setPosition(ui(270), this.scale.height - ui(210)); - this.bodyText?.setPosition(ui(270), this.scale.height - ui(160)); + this.nameText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.nameTopOffset)); + this.bodyText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.bodyTopOffset)); this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true); } else { this.portraitFrame?.setVisible(false); this.portrait?.setVisible(false); this.portraitDivider?.setVisible(false); - this.nameText?.setPosition(ui(96), this.scale.height - ui(210)); - this.bodyText?.setPosition(ui(96), this.scale.height - ui(160)); + this.nameText?.setPosition(ui(96), this.scale.height - ui(storyDialogLayout.nameTopOffset)); + this.bodyText?.setPosition(ui(96), this.scale.height - ui(storyDialogLayout.bodyTopOffset)); this.bodyText?.setWordWrapWidth(this.scale.width - ui(200), true); } @@ -752,7 +777,7 @@ export class StoryScene extends Phaser.Scene { return undefined; } - const entries = portraitAssetEntriesForKey(portraitKey); + const entries = portraitAssetEntriesForStoryContext(portraitKey, page); if (entries.length === 0) { return undefined; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index a74f44a..fa4207b 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -6,6 +6,13 @@ import { nextCombatPresentationMode, saveCombatPresentationMode } from '../settings/combatPresentation'; +import { + audioLevelLabel, + loadAudioPreferences, + nextAudioLevel, + saveAudioPreferences, + updateAudioPreference +} from '../settings/audioPreferences'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage'; import { @@ -49,6 +56,14 @@ export class TitleScene extends Phaser.Scene { return; } + const debugStoryPageId = this.debugStoryPageId(); + if (debugStoryPageId) { + this.time.delayedCall(0, () => { + void this.openDebugStoryPage(debugStoryPageId); + }); + return; + } + if (this.shouldOpenDebugSortiePrep()) { this.time.delayedCall(0, () => { void this.navigateTo('CampScene', { @@ -98,6 +113,48 @@ export class TitleScene extends Phaser.Scene { return params.get('debugBattle')?.trim() || undefined; } + private debugStoryPageId() { + if (typeof window === 'undefined') { + return undefined; + } + const params = new URLSearchParams(window.location.search); + if (!params.has('debugStory') || (!params.has('debug') && !import.meta.env.DEV)) { + return undefined; + } + return params.get('debugStory')?.trim() || undefined; + } + + private async openDebugStoryPage(pageId: string) { + const scenarioModule = await import('../data/scenario'); + let selectedPage: unknown; + + for (const value of Object.values(scenarioModule)) { + if (!Array.isArray(value)) { + continue; + } + selectedPage = value.find( + (candidate) => + candidate && + typeof candidate === 'object' && + 'id' in candidate && + candidate.id === pageId + ); + if (selectedPage) { + break; + } + } + + if (!selectedPage) { + console.error(`Unknown debug story page: ${pageId}`); + return; + } + + await this.navigateTo('StoryScene', { + pages: [selectedPage], + nextScene: 'TitleScene' + }); + } + private drawBackground(width: number, height: number) { const background = this.add.image(width / 2, height / 2, 'title-taoyuan'); const texture = this.textures.get('title-taoyuan').getSourceImage(); @@ -396,27 +453,40 @@ export class TitleScene extends Phaser.Scene { this.closeNewGameConfirmPanel(); this.closeSaveSlotPanel(); - const panel = this.add.container(x, y + ui(238)); - const backdrop = this.add.rectangle(0, 0, ui(286), ui(250), 0x0e151d, 0.9); + const panel = this.add.container(x, y + ui(126)); + const backdrop = this.add.rectangle(0, 0, ui(320), ui(420), 0x0b1118, 1); backdrop.setStrokeStyle(ui(1), palette.gold, 0.52); + backdrop.setInteractive(); - const title = this.add.text(0, -ui(96), '설정', { + const title = this.add.text(0, -ui(150), '설정', { fontSize: uiPx(24), color: '#f1e3c2', fontStyle: '700', - fixedWidth: ui(210), + fixedWidth: ui(244), align: 'center' }); title.setOrigin(0.5); - const soundText = this.createSettingsButton(0, -ui(38), this.soundLabel()); + const soundText = this.createSettingsButton(0, -ui(102), this.soundLabel()); soundText.on('pointerdown', () => { - soundDirector.setMuted(!soundDirector.isMuted()); + const preferences = loadAudioPreferences(); + const muted = !soundDirector.isMuted(); + soundDirector.setMuted(muted); + saveAudioPreferences({ ...preferences, muted }); soundText.setText(this.soundLabel()); soundDirector.playSelect(); }); - const presentationText = this.createSettingsButton(0, ui(16), this.combatPresentationLabel()); + const musicText = this.createSettingsButton(0, -ui(60), this.audioCategoryLabel('music')); + musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText)); + + const effectsText = this.createSettingsButton(0, -ui(18), this.audioCategoryLabel('effects')); + effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText)); + + const ambienceText = this.createSettingsButton(0, ui(24), this.audioCategoryLabel('ambience')); + ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText)); + + const presentationText = this.createSettingsButton(0, ui(66), this.combatPresentationLabel()); presentationText.on('pointerdown', () => { const nextMode = nextCombatPresentationMode(loadCombatPresentationMode()); saveCombatPresentationMode(nextMode); @@ -424,26 +494,26 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); }); - const close = this.createSettingsButton(0, ui(68), '닫기'); + const close = this.createSettingsButton(0, ui(110), '닫기'); close.on('pointerdown', () => { soundDirector.playSelect(); this.closeSettingsPanel(); }); - const hint = this.add.text(0, ui(104), '연출 항목을 눌러 단계 변경 · Esc 닫기', { + const hint = this.add.text(0, ui(150), '항목을 눌러 단계 변경 · Esc 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: '#9fb0bf', fontStyle: '700', - fixedWidth: ui(210), + fixedWidth: ui(244), align: 'center' }); hint.setOrigin(0.5); - panel.add([backdrop, title, soundText, presentationText, close, hint]); + panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, close, hint]); panel.setAlpha(0); this.settingsPanel = panel; - this.tweens.add({ targets: panel, alpha: 1, y: y + ui(228), duration: 160, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: panel, alpha: 1, y: y + ui(116), duration: 160, ease: 'Sine.easeOut' }); } private createSettingsButton(x: number, y: number, label: string) { @@ -466,6 +536,25 @@ export class TitleScene extends Phaser.Scene { return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`; } + private audioCategoryLabel(category: 'music' | 'effects' | 'ambience') { + const labels = { + music: '음악', + effects: '효과음', + ambience: '환경음' + } as const; + return `${labels[category]}: ${audioLevelLabel(loadAudioPreferences()[category])}`; + } + + private cycleAudioLevel(category: 'music' | 'effects' | 'ambience', text: Phaser.GameObjects.Text) { + const preferences = loadAudioPreferences(); + const nextLevel = nextAudioLevel(preferences[category]); + const nextPreferences = updateAudioPreference(preferences, category, nextLevel); + saveAudioPreferences(nextPreferences); + soundDirector.setCategoryMultiplier(category, nextLevel); + text.setText(this.audioCategoryLabel(category)); + soundDirector.playSelect(); + } + private combatPresentationLabel() { return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`; } diff --git a/src/game/settings/audioPreferences.ts b/src/game/settings/audioPreferences.ts new file mode 100644 index 0000000..62c84e2 --- /dev/null +++ b/src/game/settings/audioPreferences.ts @@ -0,0 +1,89 @@ +import type { AudioCategory, AudioCategoryMultipliers } from '../audio/SoundDirector'; + +export const audioPreferencesStorageKey = 'heros-web:audio-preferences'; + +export const audioLevelSteps = [1, 0.75, 0.5, 0.25] as const; + +export type AudioPreferences = AudioCategoryMultipliers & { + muted: boolean; +}; + +type StorageLike = Pick; + +export const defaultAudioPreferences: AudioPreferences = { + muted: false, + music: 1, + effects: 1, + ambience: 1 +}; + +export function loadAudioPreferences(storage = resolveStorage()): AudioPreferences { + if (!storage) { + return { ...defaultAudioPreferences }; + } + + try { + const stored = JSON.parse(storage.getItem(audioPreferencesStorageKey) ?? 'null') as Partial | null; + if (!stored || typeof stored !== 'object') { + return { ...defaultAudioPreferences }; + } + + return { + muted: typeof stored.muted === 'boolean' ? stored.muted : defaultAudioPreferences.muted, + music: normalizeAudioLevel(stored.music, defaultAudioPreferences.music), + effects: normalizeAudioLevel(stored.effects, defaultAudioPreferences.effects), + ambience: normalizeAudioLevel(stored.ambience, defaultAudioPreferences.ambience) + }; + } catch { + return { ...defaultAudioPreferences }; + } +} + +export function saveAudioPreferences(preferences: AudioPreferences, storage = resolveStorage()) { + if (!storage) { + return; + } + + try { + storage.setItem(audioPreferencesStorageKey, JSON.stringify(preferences)); + } catch { + // Local storage can be unavailable in private or restricted browser contexts. + } +} + +export function nextAudioLevel(level: number) { + const index = audioLevelSteps.indexOf(level as (typeof audioLevelSteps)[number]); + return audioLevelSteps[(index + 1) % audioLevelSteps.length]; +} + +export function updateAudioPreference( + preferences: AudioPreferences, + category: AudioCategory, + level: number +): AudioPreferences { + return { + ...preferences, + [category]: normalizeAudioLevel(level, preferences[category]) + }; +} + +export function audioLevelLabel(level: number) { + return `${Math.round(normalizeAudioLevel(level, 1) * 100)}%`; +} + +function normalizeAudioLevel(value: unknown, fallback: number) { + return typeof value === 'number' && audioLevelSteps.includes(value as (typeof audioLevelSteps)[number]) + ? value + : fallback; +} + +function resolveStorage(): StorageLike | undefined { + if (typeof window === 'undefined') { + return undefined; + } + try { + return window.localStorage; + } catch { + return undefined; + } +} diff --git a/src/main.ts b/src/main.ts index 1d3e8a4..4891c84 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ import Phaser from 'phaser'; -import { ambienceTracks, effectTracks, musicTracks } from './game/audio/audioAssets'; +import { ambienceTracks, effectPools, effectTracks, musicTracks } from './game/audio/audioAssets'; import { soundDirector } from './game/audio/SoundDirector'; import { BootScene } from './game/scenes/BootScene'; import { startLazySceneFromGame } from './game/scenes/lazyScenes'; import { TitleScene } from './game/scenes/TitleScene'; +import { loadAudioPreferences } from './game/settings/audioPreferences'; import './styles/global.css'; declare global { @@ -63,6 +64,10 @@ const config: Phaser.Types.Core.GameConfig = { soundDirector.registerMusicTracks(musicTracks); soundDirector.registerAmbienceTracks(ambienceTracks); soundDirector.registerEffectTracks(effectTracks); +soundDirector.registerEffectPools(effectPools); +const audioPreferences = loadAudioPreferences(); +soundDirector.setMuted(audioPreferences.muted); +soundDirector.setCategoryMultipliers(audioPreferences); const game = new Phaser.Game(config); const debugEnabled = import.meta.env.DEV || searchParams.has('debug');