fix: align audiovisual and narrative feedback
This commit is contained in:
@@ -65,7 +65,10 @@ const server = await createServer({
|
||||
|
||||
try {
|
||||
const { SoundDirector, movementSurfaceForTerrain } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts');
|
||||
const { effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const { ambienceTrackGainCompensation, effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const { movementAudioTerrainForPulse, movementAudioTerrainForTile } = await server.ssrLoadModule(
|
||||
'/src/game/audio/movementAudio.ts'
|
||||
);
|
||||
const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts');
|
||||
verifyAudioPreferences(audioPreferencesModule);
|
||||
const clock = new FakeClock();
|
||||
@@ -82,7 +85,8 @@ try {
|
||||
director.registerAmbienceTracks({
|
||||
'ambience-a': '/audio/ambience-a.ogg',
|
||||
'ambience-b': '/audio/ambience-b.ogg',
|
||||
'ambience-c': '/audio/ambience-c.ogg'
|
||||
'ambience-c': '/audio/ambience-c.ogg',
|
||||
'battle-fire': '/audio/battle-fire.ogg'
|
||||
});
|
||||
director.registerEffectTracks({
|
||||
'sword-slash': '/audio/sword-slash.ogg',
|
||||
@@ -249,6 +253,25 @@ try {
|
||||
['earth', 'stone', 'wood', 'wood', 'wet', 'wet', 'earth'],
|
||||
'battle terrain names should resolve to the four movement sound surfaces'
|
||||
);
|
||||
const terrainFixture = [
|
||||
['plain', 'road', 'river'],
|
||||
['forest', 'plain', 'plain']
|
||||
];
|
||||
assert.equal(
|
||||
movementAudioTerrainForTile(terrainFixture, 1, 0, false),
|
||||
'wet',
|
||||
'a passable river-bank/ford tile should make the wet movement pool reachable'
|
||||
);
|
||||
assert.equal(
|
||||
movementAudioTerrainForTile(terrainFixture, 0, 1, true),
|
||||
'wet',
|
||||
'rain should wet otherwise dry passable terrain'
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 5 }, (_, index) => movementAudioTerrainForPulse(['plain', 'road', 'forest'], index, 5)),
|
||||
['plain', 'road', 'road', 'forest', 'forest'],
|
||||
'movement pulses should sample the full route instead of repeating only the destination surface'
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
director.playMovementStep(false, 0, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
|
||||
@@ -265,16 +288,57 @@ try {
|
||||
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');
|
||||
assert.equal(
|
||||
director.playMovementStep(false, 2, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
|
||||
false,
|
||||
'a third overlapping footstep should be skipped instead of cutting an audible sample'
|
||||
);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(firstStoneStep.paused, false, 'voice limiting must let the oldest footstep reach its natural release');
|
||||
assert.notEqual(firstStoneStep.src, '', 'voice limiting must not clear a playing footstep source');
|
||||
assert.equal(debug.movementVoices.cap, 2, 'movement voice policy should expose the two-voice ceiling');
|
||||
assert.equal(debug.movementVoices.foot.activeCount, 2, 'foot movement must never exceed two active voices');
|
||||
assert.equal(
|
||||
new Set(debug.movementVoices.foot.trackKeys).size,
|
||||
debug.movementVoices.foot.activeCount,
|
||||
'active foot movement voices must use distinct source clips'
|
||||
);
|
||||
const activeEffectsBeforeRush = debug.activeEffectCount;
|
||||
assert.equal(
|
||||
director.playMeleeRush(false, 'bridge'),
|
||||
true,
|
||||
'melee rush must remain audible while the foot movement voice group is saturated'
|
||||
);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools');
|
||||
assert.equal(
|
||||
debug.activeEffectCount,
|
||||
activeEffectsBeforeRush + 1,
|
||||
'melee rush should use a priority effect voice outside the movement-step ceiling'
|
||||
);
|
||||
assert.equal(debug.movementVoices.foot.activeCount, 2, 'melee rush must not consume a movement-step voice');
|
||||
|
||||
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');
|
||||
director.playMovementStep(true, 3, { terrain: 'river', volume: 0.24, stopAfterMs: 140 });
|
||||
assert.equal(
|
||||
director.playMovementStep(true, 4, { terrain: 'river', volume: 0.24, stopAfterMs: 140 }),
|
||||
false,
|
||||
'a third overlapping hoof beat should be skipped instead of cutting an audible sample'
|
||||
);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools');
|
||||
assert.equal(debug.movementVoices.mounted.activeCount, 2, 'mounted movement must never exceed two active voices');
|
||||
assert.equal(
|
||||
new Set(debug.movementVoices.mounted.trackKeys).size,
|
||||
debug.movementVoices.mounted.activeCount,
|
||||
'active mounted movement voices must use distinct source clips'
|
||||
);
|
||||
|
||||
firstStoneStep.emit('ended');
|
||||
secondStoneStep.emit('ended');
|
||||
clock.advance(600);
|
||||
assert.equal(director.getDebugState().activeEffectCount, 0, 'terrain movement one-shots should retire without leaks');
|
||||
|
||||
@@ -431,8 +495,15 @@ try {
|
||||
assertRetired(audioHarness.byOriginalSrc('/audio/ambience-c.ogg'), 'stopped ambience');
|
||||
assert.equal(debug.music.activeElementCount, 1, 'stopping ambience must not disturb music');
|
||||
|
||||
director.playAmbience('ambience-a', 0.03);
|
||||
director.playAmbience('battle-fire', 0.13);
|
||||
clock.advance(1300);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.ambience.trackGain, ambienceTrackGainCompensation['battle-fire'], 'fire ambience should expose its peak trim');
|
||||
assertClose(
|
||||
audioHarness.byOriginalSrc('/audio/battle-fire.ogg').volume,
|
||||
0.13 * ambienceTrackGainCompensation['battle-fire'],
|
||||
'fire ambience should apply its track-specific loop gain'
|
||||
);
|
||||
const audioCountBeforeLegacyMusic = audioHarness.instances.length;
|
||||
const musicRevisionBeforeLegacyMusic = director.getDebugState().music.transition.revision;
|
||||
director.playMusic('music-c');
|
||||
@@ -628,8 +699,8 @@ try {
|
||||
|
||||
console.log(
|
||||
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
||||
`${debug.ambience.elementRevision} ambience element revisions, gain-balanced no-repeat effect pools, ` +
|
||||
'terrain-aware foot and hoof movement, ' +
|
||||
`${debug.ambience.elementRevision} ambience element revisions, peak-aware loop gain, gain-balanced no-repeat effect pools, ` +
|
||||
'route-sampled terrain movement with bounded foot and hoof voices, ' +
|
||||
'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user