feat: deepen audiovisual story immersion

This commit is contained in:
2026-07-23 10:57:07 +09:00
parent a9e401aedf
commit 8cf0886d7d
38 changed files with 2037 additions and 144 deletions

View File

@@ -64,7 +64,8 @@ const server = await createServer({
});
try {
const { SoundDirector } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts');
const { SoundDirector, movementSurfaceForTerrain } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts');
const { effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts');
verifyAudioPreferences(audioPreferencesModule);
const clock = new FakeClock();
@@ -107,7 +108,23 @@ try {
'burn-tick': '/audio/burn-tick.ogg',
'reward-reveal': '/audio/reward-reveal.ogg',
'strategy-cast': '/audio/strategy-cast.ogg',
'ui-select': '/audio/ui-select.ogg'
'ui-select': '/audio/ui-select.ogg',
'footstep-earth-1': '/audio/footstep-earth-1.ogg',
'footstep-earth-2': '/audio/footstep-earth-2.ogg',
'footstep-stone-1': '/audio/footstep-stone-1.ogg',
'footstep-stone-2': '/audio/footstep-stone-2.ogg',
'footstep-wood-1': '/audio/footstep-wood-1.ogg',
'footstep-wood-2': '/audio/footstep-wood-2.ogg',
'footstep-wet-1': '/audio/footstep-wet-1.ogg',
'footstep-wet-2': '/audio/footstep-wet-2.ogg',
'hoof-earth-1': '/audio/hoof-earth-1.ogg',
'hoof-earth-2': '/audio/hoof-earth-2.ogg',
'hoof-stone-1': '/audio/hoof-stone-1.ogg',
'hoof-stone-2': '/audio/hoof-stone-2.ogg',
'hoof-wood-1': '/audio/hoof-wood-1.ogg',
'hoof-wood-2': '/audio/hoof-wood-2.ogg',
'hoof-wet-1': '/audio/hoof-wet-1.ogg',
'hoof-wet-2': '/audio/hoof-wet-2.ogg'
});
director.registerEffectPools({
impact: ['sword-slash', 'combat-impact', 'sword-slash'],
@@ -115,6 +132,14 @@ try {
'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'],
'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'],
'arrow-miss': ['arrow-swish'],
'movement-foot-earth': ['footstep-earth-1', 'footstep-earth-2'],
'movement-foot-stone': ['footstep-stone-1', 'footstep-stone-2'],
'movement-foot-wood': ['footstep-wood-1', 'footstep-wood-2'],
'movement-foot-wet': ['footstep-wet-1', 'footstep-wet-2'],
'movement-hoof-earth': ['hoof-earth-1', 'hoof-earth-2'],
'movement-hoof-stone': ['hoof-stone-1', 'hoof-stone-2'],
'movement-hoof-wood': ['hoof-wood-1', 'hoof-wood-2'],
'movement-hoof-wet': ['hoof-wet-1', 'hoof-wet-2'],
fallback: ['missing-track']
});
@@ -171,9 +196,25 @@ try {
);
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();
const [firstImpactMix, secondImpactMix] = debug.recentEffects.slice(-2);
assertClose(
firstImpact.volume,
firstImpactMix.compensatedBaseVolume * 0.5,
'effect category multiplier on first gain-balanced variation'
);
assertClose(
secondImpact.volume,
secondImpactMix.compensatedBaseVolume * 0.5,
'effect category multiplier on second gain-balanced variation'
);
[firstImpactMix, secondImpactMix].forEach((mix) => {
assert.equal(
mix.trackGain,
effectTrackGainCompensation[mix.trackKey],
`${mix.trackKey} should expose its measured pool compensation`
);
});
assert.deepEqual(
debug.effectPools.impact,
['sword-slash', 'combat-impact'],
@@ -188,10 +229,11 @@ try {
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');
assertClose(firstImpact.volume, firstImpactMix.compensatedBaseVolume * 0.25, 'active effect should follow category multiplier changes');
assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume * 0.25, '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(firstImpact.volume, firstImpactMix.compensatedBaseVolume, 'active effect should restore its compensated volume');
assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume, 'all active effects should restore their compensated 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);
@@ -200,6 +242,42 @@ try {
assertRetired(firstImpact, 'first pooled impact cleanup');
assertRetired(secondImpact, 'second pooled impact cleanup');
assert.deepEqual(
['plain', 'road', 'forest', 'bridge', 'river', 'marsh', 'unknown'].map((terrain) =>
movementSurfaceForTerrain(terrain)
),
['earth', 'stone', 'wood', 'wood', 'wet', 'wet', 'earth'],
'battle terrain names should resolve to the four movement sound surfaces'
);
assert.equal(
director.playMovementStep(false, 0, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
true,
'foot movement should accept a terrain without breaking the legacy argument order'
);
const firstStoneStep = audioHarness.instances.at(-1);
debug = director.getDebugState();
assert.equal(debug.lastMovementStep.key, 'movement-foot-stone', 'road movement should use the stone foot pool');
assert.equal(debug.lastMovementStep.surface, 'stone', 'movement debug state should expose the resolved surface');
assert.equal(debug.lastMovementStep.terrain, 'road', 'movement debug state should retain the caller terrain');
assert(['footstep-stone-1', 'footstep-stone-2'].includes(debug.lastMovementStep.trackKey));
director.playMovementStep(false, 1, { terrain: 'road', volume: 0.2, rate: 1.04, stopAfterMs: 120 });
const secondStoneStep = audioHarness.instances.at(-1);
assert.notEqual(firstStoneStep.originalSrc, secondStoneStep.originalSrc, 'terrain movement pools should avoid immediate repetition');
director.playMovementStep(true, 2, { terrain: 'river', volume: 0.24, stopAfterMs: 140 });
debug = director.getDebugState();
assert.equal(debug.lastMovementStep.key, 'movement-hoof-wet', 'mounted river movement should use the wet hoof pool');
assert.equal(debug.lastMovementStep.surface, 'wet', 'mounted movement should expose the wet surface');
assert(['hoof-wet-1', 'hoof-wet-2'].includes(debug.lastMovementStep.trackKey));
director.playMeleeRush(false, 'bridge');
debug = director.getDebugState();
assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools');
clock.advance(600);
assert.equal(director.getDebugState().activeEffectCount, 0, 'terrain movement one-shots should retire without leaks');
director.duckMusic({ multiplier: 0.4, attackMs: 64, holdMs: 64, releaseMs: 96 });
clock.advance(64);
debug = director.getDebugState();
@@ -550,7 +628,8 @@ try {
console.log(
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
`${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` +
`${debug.ambience.elementRevision} ambience element revisions, gain-balanced no-repeat effect pools, ` +
'terrain-aware foot and hoof movement, ' +
'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.'
);
} finally {