feat: deepen audiovisual story immersion
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user