feat: refine exploration presentation and audio flow

This commit is contained in:
2026-07-28 05:00:24 +09:00
parent 1773bffbf2
commit 495f98372c
20 changed files with 2295 additions and 354 deletions

View File

@@ -284,10 +284,16 @@ try {
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));
assertClose(firstStoneStep.playbackRate, 0.94 * 0.985, 'first footstep deterministic rate variation');
assertClose(firstStoneStep.volume, 0.2 * 0.96, 'first footstep deterministic gain variation');
assertClose(debug.lastMovementStep.rate, firstStoneStep.playbackRate, 'movement debug rate should expose variation');
assertClose(debug.lastMovementStep.volume, firstStoneStep.volume, 'movement debug volume should expose variation');
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');
assertClose(secondStoneStep.playbackRate, 1.04 * 1.015, 'second footstep deterministic rate variation');
assertClose(secondStoneStep.volume, 0.2 * 1.03, 'second footstep deterministic gain variation');
assert.equal(
director.playMovementStep(false, 2, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
false,
@@ -385,6 +391,7 @@ try {
assert.equal(director.playEffect('ui-select'), true, 'effect should be tracked until an error or end event');
const erroredEffect = audioHarness.instances.at(-1);
assertClose(erroredEffect.playbackRate, 1, 'UI effects must not receive pooled combat rate variation');
erroredEffect.emit('error');
debug = director.getDebugState();
assert.equal(debug.activeEffectCount, 0, 'errored effects should be released from active tracking');
@@ -409,8 +416,65 @@ try {
);
assert.equal(debug.music.targetVolume, 0.18, 'same-key music should update its desired target volume');
assert.equal(debug.ambience.targetVolume, 0.05, 'same-key ambience should update its desired target volume');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.18, 'same-key music element volume');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.05, 'same-key ambience element volume');
assert.equal(debug.music.volumeRamp.active, true, 'same-key music should start a gain ramp');
assert.equal(debug.ambience.volumeRamp.active, true, 'same-key ambience should start a gain ramp');
assert.deepEqual(
debug.music.volumeRamp.last,
{ fromVolume: 0.22, toVolume: 0.18, durationMs: 400 },
'same-key music ramp should retain its endpoints'
);
assert.deepEqual(
debug.ambience.volumeRamp.last,
{ fromVolume: 0.08, toVolume: 0.05, durationMs: 400 },
'same-key ambience ramp should retain its endpoints'
);
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'same-key music must not jump at ramp start');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'same-key ambience must not jump at ramp start');
clock.advance(192);
debug = director.getDebugState();
assert.equal(debug.music.transition.revision, musicRevisionBeforeSameKey, 'music gain ramp must not revise loop transition');
assert.equal(debug.ambience.transition.revision, ambienceRevisionBeforeSameKey, 'ambience gain ramp must not revise loop transition');
assert.equal(audioHarness.instances.length, countBeforeSameKey, 'gain ramps must retain the current loop elements');
assertClose(
audioHarness.byOriginalSrc('/audio/music-a.ogg').volume,
0.22 + (0.18 - 0.22) * 0.48,
'same-key music midpoint'
);
assertClose(
audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume,
0.08 + (0.05 - 0.08) * 0.48,
'same-key ambience midpoint'
);
const musicRampRevisionAtMidpoint = debug.music.volumeRamp.revision;
const ambienceRampRevisionAtMidpoint = debug.ambience.volumeRamp.revision;
director.playSoundscape({
musicKey: 'music-a',
ambienceKey: 'ambience-a',
musicVolume: 0.18,
ambienceVolume: 0.05
});
debug = director.getDebugState();
assert.equal(
debug.music.volumeRamp.revision,
musicRampRevisionAtMidpoint,
'an identical same-key request must not restart the active music ramp'
);
assert.equal(
debug.ambience.volumeRamp.revision,
ambienceRampRevisionAtMidpoint,
'an identical same-key request must not restart the active ambience ramp'
);
assert.equal(audioHarness.instances.length, countBeforeSameKey, 'an identical ramp request must retain loop elements');
clock.advance(208);
debug = director.getDebugState();
assert.equal(debug.music.volumeRamp.active, false, 'same-key music ramp should complete after 400ms');
assert.equal(debug.ambience.volumeRamp.active, false, 'same-key ambience ramp should complete after 400ms');
assert.equal(debug.music.volumeRamp.completedRevision, 1, 'music gain ramp completion revision');
assert.equal(debug.ambience.volumeRamp.completedRevision, 1, 'ambience gain ramp completion revision');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.18, 'same-key music settled volume');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.05, 'same-key ambience settled volume');
director.playSoundscape({
musicKey: 'music-b',
@@ -479,6 +543,183 @@ try {
assertCompletedChannel(debug.ambience, 3, 0.04, 'rapid ambience fade');
assert.equal(audioHarness.activeInstances().length, 2, 'only the current music and ambience may remain active');
director.playSoundscape({
musicKey: 'music-b',
ambienceKey: 'ambience-b',
musicVolume: 0.2,
ambienceVolume: 0.06
});
director.playSoundscape({
musicKey: 'music-b',
ambienceKey: 'ambience-b',
musicVolume: 0.17,
ambienceVolume: 0.045
});
debug = director.getDebugState();
assert.equal(
debug.music.transition.active,
true,
'same-key music retargeting during a crossfade must keep the transition active'
);
assert.equal(
debug.ambience.transition.active,
true,
'same-key ambience retargeting during a crossfade must keep the transition active'
);
assert.equal(
debug.music.volumeRamp.active,
false,
'music retargeting must wait until the active crossfade completes'
);
assert.equal(
debug.ambience.volumeRamp.active,
false,
'ambience retargeting must wait until the active crossfade completes'
);
assert.equal(debug.music.targetVolume, 0.17, 'music must retain the newest deferred target');
assert.equal(debug.ambience.targetVolume, 0.045, 'ambience must retain the newest deferred target');
clock.advance(1300);
debug = director.getDebugState();
assert.equal(debug.music.transition.active, false, 'deferred music crossfade should complete');
assert.equal(debug.ambience.transition.active, false, 'deferred ambience crossfade should complete');
assert.equal(
debug.music.volumeRamp.active,
true,
'music must begin a deferred gain ramp after its crossfade'
);
assert.equal(
debug.ambience.volumeRamp.active,
true,
'ambience must begin a deferred gain ramp after its crossfade'
);
const delayedMusicB = audioHarness.instances
.filter((element) => element.originalSrc === '/audio/music-b.ogg')
.at(-1);
const delayedAmbienceB = audioHarness.instances
.filter((element) => element.originalSrc === '/audio/ambience-b.ogg')
.at(-1);
assert(
delayedMusicB.volume > 0.17 && delayedMusicB.volume < 0.2,
`deferred music ramp must interpolate from the transition target: ${delayedMusicB.volume}`
);
assert(
delayedAmbienceB.volume > 0.045 && delayedAmbienceB.volume < 0.06,
`deferred ambience ramp must interpolate from the transition target: ${delayedAmbienceB.volume}`
);
clock.advance(420);
debug = director.getDebugState();
assert.equal(debug.music.volumeRamp.active, false, 'deferred music ramp should settle');
assert.equal(debug.ambience.volumeRamp.active, false, 'deferred ambience ramp should settle');
assertClose(delayedMusicB.volume, 0.17, 'deferred music settled volume');
assertClose(delayedAmbienceB.volume, 0.045, 'deferred ambience settled volume');
director.playSoundscape({
musicKey: 'music-b',
ambienceKey: 'ambience-b',
musicVolume: 0.14,
ambienceVolume: 0.035
});
clock.advance(96);
const interruptedMusicVolume = delayedMusicB.volume;
const interruptedAmbienceVolume = delayedAmbienceB.volume;
debug = director.getDebugState();
assert.equal(debug.music.volumeRamp.active, true, 'music interruption fixture must have an active ramp');
assert.equal(debug.ambience.volumeRamp.active, true, 'ambience interruption fixture must have an active ramp');
director.playSoundscape({
musicKey: 'music-c',
ambienceKey: 'ambience-c',
musicVolume: 0.16,
ambienceVolume: 0.04
});
debug = director.getDebugState();
assert.equal(
debug.music.volumeRamp.active,
false,
'a new music key must cancel the superseded gain ramp'
);
assert.equal(
debug.ambience.volumeRamp.active,
false,
'a new ambience key must cancel the superseded gain ramp'
);
assertClose(
delayedMusicB.volume,
interruptedMusicVolume,
'the interrupted music ramp must hand its current gain to the outgoing fade'
);
assertClose(
delayedAmbienceB.volume,
interruptedAmbienceVolume,
'the interrupted ambience ramp must hand its current gain to the outgoing fade'
);
clock.advance(1300);
debug = director.getDebugState();
assertRetired(delayedMusicB, 'interrupted music ramp outgoing retirement');
assertRetired(delayedAmbienceB, 'interrupted ambience ramp outgoing retirement');
assertCompletedChannel(debug.music, 5, 0.16, 'interrupted music ramp transition');
assertCompletedChannel(debug.ambience, 5, 0.04, 'interrupted ambience ramp transition');
assert.equal(
audioHarness.activeInstances().length,
2,
'ramp interruption verification must leave one music and one ambience loop'
);
const resumedMusicC = audioHarness.instances
.filter((element) => element.originalSrc === '/audio/music-c.ogg')
.at(-1);
const resumedAmbienceC = audioHarness.instances
.filter((element) => element.originalSrc === '/audio/ambience-c.ogg')
.at(-1);
director.playSoundscape({
musicKey: 'music-c',
ambienceKey: 'ambience-c',
musicVolume: 0.12,
ambienceVolume: 0.03
});
clock.advance(96);
director.playSoundscape({
musicKey: 'music-late',
ambienceKey: 'ambience-late',
musicVolume: 0.71,
ambienceVolume: 0.33
});
debug = director.getDebugState();
assert.equal(debug.pendingMusicKey, 'music-late', 'an unregistered music request must remain pending');
assert.equal(debug.pendingAmbienceKey, 'ambience-late', 'an unregistered ambience request must remain pending');
assert.equal(debug.music.volumeRamp.active, true, 'the current music ramp must continue while a future key is pending');
assert.equal(debug.ambience.volumeRamp.active, true, 'the current ambience ramp must continue while a future key is pending');
clock.advance(420);
debug = director.getDebugState();
assert.equal(debug.music.volumeRamp.active, false, 'the current music ramp must settle while a future key remains pending');
assert.equal(debug.ambience.volumeRamp.active, false, 'the current ambience ramp must settle while a future key remains pending');
assertClose(
resumedMusicC.volume,
0.12,
'a pending unregistered music target must not replace the active ramp endpoint'
);
assertClose(
resumedAmbienceC.volume,
0.03,
'a pending unregistered ambience target must not replace the active ramp endpoint'
);
director.playSoundscape({
musicKey: 'music-c',
ambienceKey: 'ambience-c',
musicVolume: 0.16,
ambienceVolume: 0.04
});
clock.advance(420);
debug = director.getDebugState();
assert.equal(debug.pendingMusicKey, null, 'returning to the current music key must clear the stale pending request');
assert.equal(debug.pendingAmbienceKey, null, 'returning to the current ambience key must clear the stale pending request');
assertClose(resumedMusicC.volume, 0.16, 'music should ramp back after clearing an unresolved request');
assertClose(resumedAmbienceC.volume, 0.04, 'ambience should ramp back after clearing an unresolved request');
director.stopAmbience();
debug = director.getDebugState();
assert.equal(debug.currentAmbienceKey, null, 'stopAmbience should clear the current ambience key immediately');
@@ -492,7 +733,7 @@ try {
debug = director.getDebugState();
assert.equal(debug.ambience.transition.active, false, 'ambience stop transition should complete');
assert.equal(debug.ambience.activeElementCount, 0, 'ambience stop must retire its final element');
assertRetired(audioHarness.byOriginalSrc('/audio/ambience-c.ogg'), 'stopped ambience');
assertRetired(resumedAmbienceC, 'stopped ambience');
assert.equal(debug.music.activeElementCount, 1, 'stopping ambience must not disturb music');
director.playAmbience('battle-fire', 0.13);
@@ -522,12 +763,19 @@ try {
assert.equal(debug.currentAmbienceKey, null, 'legacy playMusic must clear camp ambience');
clock.advance(1300);
debug = director.getDebugState();
assert.equal(debug.music.volumeRamp.active, false, 'legacy same-key music ramp should settle');
assertClose(resumedMusicC.volume, 0.22, 'legacy music settled volume');
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 tacticalCueStart = audioHarness.instances.length;
assert.equal(director.playAllyTurnCue(), true, 'the first allied turn cue should play');
assertClose(audioHarness.byOriginalSrc('/audio/ally-turn.ogg').volume, 0.32, 'allied turn cue volume');
assertClose(
audioHarness.byOriginalSrc('/audio/ally-turn.ogg').playbackRate,
1,
'semantic cues must not receive pooled combat rate variation'
);
assert.equal(director.playAllyTurnCue(), false, 'a repeated allied turn cue should be coalesced');
assert.equal(director.playEnemyTurnCue(), false, 'turn cue cooldown should be shared across factions');
@@ -637,9 +885,18 @@ try {
const semanticEffectStart = audioHarness.instances.length;
director.playMeleeSwing(true, false);
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
const firstMeleeSwing = director.getDebugState().lastEffect;
assert.equal(firstMeleeSwing?.key, 'melee-swing', 'melee API should use the swing pool');
assertClose(firstMeleeSwing?.rate, 0.99, 'first pooled melee swing rate variation');
director.playMeleeSwing(true, false);
const secondMeleeSwing = director.getDebugState().lastEffect;
assert.equal(secondMeleeSwing?.key, 'melee-swing', 'repeated melee API should keep using the swing pool');
assertClose(secondMeleeSwing?.rate, 1.015, 'second pooled melee swing rate variation');
assert.notEqual(firstMeleeSwing?.rate, secondMeleeSwing?.rate, 'pooled combat rate variation should advance by logical key');
director.playArrowShot();
assert.equal(director.getDebugState().lastEffect?.key, 'bow-release', 'arrow API should play the bow release');
const arrowShot = director.getDebugState().lastEffect;
assert.equal(arrowShot?.key, 'bow-release', 'arrow API should play the bow release');
assertClose(arrowShot?.rate, 1, 'direct combat effects must not receive pooled variation');
director.playArrowImpact(false, false);
assert.equal(director.getDebugState().lastEffect?.key, 'arrow-miss', 'missed arrows should use the miss pool');
director.playArrowImpact(true, true);
@@ -696,10 +953,12 @@ try {
1,
`semantic API verification should leave only music active after ${audioHarness.instances.length - semanticEffectStart} effects`
);
assert.equal(clock.timers.size, 0, 'completed and superseded audio ramps must not leave timers behind');
console.log(
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
`${debug.ambience.elementRevision} ambience element revisions, peak-aware loop gain, gain-balanced no-repeat effect pools, ` +
'400ms same-key gain ramps, deterministic movement and pooled-combat micro-variation, ' +
'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.'
);