fix: align audiovisual and narrative feedback

This commit is contained in:
2026-07-23 23:20:50 +09:00
parent 8cf0886d7d
commit e06b15ce7a
27 changed files with 1368 additions and 142 deletions

View File

@@ -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 {