feat: improve portraits and combat audio
This commit is contained in:
@@ -65,6 +65,8 @@ const server = await createServer({
|
||||
|
||||
try {
|
||||
const { SoundDirector } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts');
|
||||
const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts');
|
||||
verifyAudioPreferences(audioPreferencesModule);
|
||||
const clock = new FakeClock();
|
||||
const audioHarness = createAudioHarness();
|
||||
const restoreGlobals = installBrowserFakes(clock, audioHarness.FakeAudio);
|
||||
@@ -81,6 +83,30 @@ try {
|
||||
'ambience-b': '/audio/ambience-b.ogg',
|
||||
'ambience-c': '/audio/ambience-c.ogg'
|
||||
});
|
||||
director.registerEffectTracks({
|
||||
'sword-slash': '/audio/sword-slash.ogg',
|
||||
'sword-swing-variant': '/audio/sword-swing-variant.ogg',
|
||||
'combat-impact': '/audio/combat-impact.ogg',
|
||||
'armor-impact': '/audio/armor-impact.ogg',
|
||||
'shield-block': '/audio/shield-block.ogg',
|
||||
'bow-release': '/audio/bow-release.ogg',
|
||||
'arrow-body-impact': '/audio/arrow-body-impact.ogg',
|
||||
'arrow-wood-impact': '/audio/arrow-wood-impact.ogg',
|
||||
'arrow-swish': '/audio/arrow-swish.ogg',
|
||||
'critical-impact': '/audio/critical-impact.ogg',
|
||||
'critical-boom': '/audio/critical-boom.ogg',
|
||||
'victory-fanfare': '/audio/victory-fanfare.ogg',
|
||||
'defeat-sting': '/audio/defeat-sting.ogg',
|
||||
'ui-select': '/audio/ui-select.ogg'
|
||||
});
|
||||
director.registerEffectPools({
|
||||
impact: ['sword-slash', 'combat-impact', 'sword-slash'],
|
||||
'melee-swing': ['sword-slash', 'sword-swing-variant'],
|
||||
'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'],
|
||||
'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'],
|
||||
'arrow-miss': ['arrow-swish'],
|
||||
fallback: ['missing-track']
|
||||
});
|
||||
|
||||
director.playSoundscape({ musicKey: 'music-a', ambienceKey: 'ambience-a' });
|
||||
let debug = director.getDebugState();
|
||||
@@ -110,6 +136,108 @@ try {
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'settled music volume');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'settled ambience volume');
|
||||
|
||||
director.setCategoryMultipliers({ music: 0.5, effects: 0.5, ambience: 0.25 });
|
||||
debug = director.getDebugState();
|
||||
assert.deepEqual(
|
||||
debug.categoryMultipliers,
|
||||
{ music: 0.5, effects: 0.5, ambience: 0.25 },
|
||||
'category multipliers should be independently configurable'
|
||||
);
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.11, 'music category multiplier');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.02, 'ambience category multiplier');
|
||||
assertClose(debug.music.effectiveTargetVolume, 0.11, 'debug music effective target volume');
|
||||
assertClose(debug.ambience.effectiveTargetVolume, 0.02, 'debug ambience effective target volume');
|
||||
|
||||
assert.equal(
|
||||
director.playEffect('impact', { volume: 0.4, rate: 0.92, stopAfterMs: 100 }),
|
||||
true,
|
||||
'registered effect pool should resolve to an available track'
|
||||
);
|
||||
const firstImpact = audioHarness.instances.at(-1);
|
||||
assert.equal(
|
||||
director.playEffect('impact', { volume: 0.4, rate: 1.08, stopAfterMs: 100 }),
|
||||
true,
|
||||
'effect pool should remain playable on repeated requests'
|
||||
);
|
||||
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();
|
||||
assert.deepEqual(
|
||||
debug.effectPools.impact,
|
||||
['sword-slash', 'combat-impact'],
|
||||
'effect pools should discard duplicate keys'
|
||||
);
|
||||
assert.equal(debug.lastEffect.key, 'impact', 'last effect should retain its logical request key');
|
||||
assert.equal(debug.lastEffect.poolKey, 'impact', 'last effect should identify its source pool');
|
||||
assert(
|
||||
['sword-slash', 'combat-impact'].includes(debug.lastEffect.trackKey),
|
||||
'last effect should expose the concrete registered track key'
|
||||
);
|
||||
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');
|
||||
director.setCategoryMultipliers({ music: 1, effects: 1, ambience: 1 });
|
||||
assertClose(firstImpact.volume, 0.4, 'active effect should restore its base 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);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.activeEffectCount, 0, 'timed effects should be released from active tracking');
|
||||
assertRetired(firstImpact, 'first pooled impact cleanup');
|
||||
assertRetired(secondImpact, 'second pooled impact cleanup');
|
||||
|
||||
director.duckMusic({ multiplier: 0.4, attackMs: 64, holdMs: 64, releaseMs: 96 });
|
||||
clock.advance(64);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.musicDuck.active, true, 'music duck should remain active through its hold phase');
|
||||
assertClose(debug.musicDuck.multiplier, 0.4, 'music duck attack target');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.088, 'ducked music volume');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'music duck must not affect ambience');
|
||||
clock.advance(160);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.musicDuck.active, false, 'music duck should finish after release');
|
||||
assert.equal(debug.musicDuck.completedRevision, 1, 'music duck completion revision');
|
||||
assertClose(debug.musicDuck.multiplier, 1, 'music duck should restore its multiplier');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'music volume after duck release');
|
||||
|
||||
director.duckMusic({ multiplier: 0.3, attackMs: 0, holdMs: 800, releaseMs: 200 });
|
||||
clock.advance(96);
|
||||
director.duckMusic({ multiplier: 0.6, attackMs: 0, holdMs: 0, releaseMs: 100 });
|
||||
clock.advance(160);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.musicDuck.active, true, 'a shorter overlapping duck must not end a longer active duck');
|
||||
assertClose(debug.musicDuck.multiplier, 0.3, 'overlapping duck should retain the stronger attenuation');
|
||||
director.setCategoryMultiplier('music', 0.5);
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.033, 'music multiplier during duck');
|
||||
director.setCategoryMultiplier('music', 1);
|
||||
director.setMuted(true);
|
||||
assert.equal(audioHarness.byOriginalSrc('/audio/music-a.ogg').muted, true, 'mute during duck');
|
||||
director.setMuted(false);
|
||||
clock.advance(800);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.musicDuck.active, false, 'overlapping duck should finish at the longest requested deadline');
|
||||
assert.equal(debug.musicDuck.completedRevision, 3, 'overlapping duck completion revision');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'music volume after overlapping duck');
|
||||
|
||||
audioHarness.rejectNextPlay();
|
||||
assert.equal(director.playEffect('ui-select'), true, 'effect should attempt playback before a media rejection');
|
||||
const rejectedEffect = audioHarness.instances.at(-1);
|
||||
await Promise.resolve();
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.activeEffectCount, 0, 'rejected effects should be released from active tracking');
|
||||
assertRetired(rejectedEffect, 'rejected effect cleanup');
|
||||
|
||||
assert.equal(director.playEffect('ui-select'), true, 'effect should be tracked until an error or end event');
|
||||
const erroredEffect = audioHarness.instances.at(-1);
|
||||
erroredEffect.emit('error');
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.activeEffectCount, 0, 'errored effects should be released from active tracking');
|
||||
assertRetired(erroredEffect, 'errored effect cleanup');
|
||||
|
||||
const countBeforeSameKey = audioHarness.instances.length;
|
||||
const musicRevisionBeforeSameKey = debug.music.transition.revision;
|
||||
const ambienceRevisionBeforeSameKey = debug.ambience.transition.revision;
|
||||
@@ -238,9 +366,45 @@ try {
|
||||
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 semanticEffectStart = audioHarness.instances.length;
|
||||
director.playMeleeSwing(true, false);
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
|
||||
director.playArrowShot();
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'bow-release', 'arrow API should play the bow release');
|
||||
director.playArrowImpact(false, false);
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'arrow-miss', 'missed arrows should use the miss pool');
|
||||
director.playArrowImpact(true, true);
|
||||
const criticalImpact = audioHarness.byOriginalSrc('/audio/critical-impact.ogg');
|
||||
assert(criticalImpact, 'critical arrow impacts should layer the critical impact track');
|
||||
clock.advance(34);
|
||||
const criticalBoom = audioHarness.byOriginalSrc('/audio/critical-boom.ogg');
|
||||
assert(criticalBoom, 'critical accents should layer the delayed boom track');
|
||||
director.playCombatImpact(false, false);
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'melee-impact', 'combat API should use the impact pool');
|
||||
director.playBattleOutcome('victory');
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'victory-fanfare', 'victory should play its outcome track');
|
||||
director.playBattleOutcome('defeat');
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'defeat-sting', 'defeat should play its outcome track');
|
||||
clock.advance(620);
|
||||
assert.notEqual(criticalImpact.src, '', 'critical impact must not be cut at the former 520ms limit');
|
||||
assert.notEqual(criticalBoom.src, '', 'critical boom must preserve its audible tail beyond 620ms');
|
||||
clock.advance(180);
|
||||
assertRetired(criticalImpact, 'critical impact natural-length cleanup');
|
||||
assert.notEqual(criticalBoom.src, '', 'critical boom should remain active after the short impact ends');
|
||||
clock.advance(1400);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.activeEffectCount, 0, 'semantic effects should all retire after their full playback windows');
|
||||
assertRetired(criticalBoom, 'critical boom natural-length cleanup');
|
||||
assert.equal(
|
||||
audioHarness.activeInstances().length,
|
||||
1,
|
||||
`semantic API verification should leave only music active after ${audioHarness.instances.length - semanticEffectStart} effects`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
||||
`${debug.ambience.elementRevision} ambience element revisions with deterministic 900/1200ms transitions.`
|
||||
`${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` +
|
||||
'semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
@@ -249,6 +413,63 @@ try {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function verifyAudioPreferences({
|
||||
audioLevelLabel,
|
||||
audioLevelSteps,
|
||||
audioPreferencesStorageKey,
|
||||
defaultAudioPreferences,
|
||||
loadAudioPreferences,
|
||||
nextAudioLevel,
|
||||
saveAudioPreferences,
|
||||
updateAudioPreference
|
||||
}) {
|
||||
const storage = createMemoryStorage();
|
||||
assert.deepEqual(loadAudioPreferences(storage), defaultAudioPreferences, 'empty audio preferences should use defaults');
|
||||
|
||||
storage.setItem(
|
||||
audioPreferencesStorageKey,
|
||||
JSON.stringify({ muted: true, music: 0.75, effects: 0.5, ambience: 0.25 })
|
||||
);
|
||||
const loaded = loadAudioPreferences(storage);
|
||||
assert.deepEqual(
|
||||
loaded,
|
||||
{ muted: true, music: 0.75, effects: 0.5, ambience: 0.25 },
|
||||
'stored audio preferences should load all supported levels'
|
||||
);
|
||||
|
||||
storage.setItem(audioPreferencesStorageKey, JSON.stringify({ muted: 'yes', music: 0.6, effects: 0.75 }));
|
||||
assert.deepEqual(
|
||||
loadAudioPreferences(storage),
|
||||
{ muted: false, music: 1, effects: 0.75, ambience: 1 },
|
||||
'invalid stored values should fall back independently'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
audioLevelSteps.map((level) => nextAudioLevel(level)),
|
||||
[0.75, 0.5, 0.25, 1],
|
||||
'audio level cycle should wrap through the supported steps'
|
||||
);
|
||||
const updated = updateAudioPreference(loaded, 'effects', 1);
|
||||
assert.equal(updated.effects, 1, 'audio category update should replace the requested level');
|
||||
assert.equal(loaded.effects, 0.5, 'audio category update should not mutate its input');
|
||||
assert.equal(audioLevelLabel(0.75), '75%', 'audio level label');
|
||||
|
||||
saveAudioPreferences(updated, storage);
|
||||
assert.deepEqual(JSON.parse(storage.getItem(audioPreferencesStorageKey)), updated, 'audio preferences should persist');
|
||||
}
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map();
|
||||
return {
|
||||
getItem(key) {
|
||||
return values.has(key) ? values.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(key, String(value));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function assertCompletedChannel(channel, revision, targetVolume, context) {
|
||||
assert.equal(channel.transition.revision, revision, `${context}: transition revision`);
|
||||
assert.equal(channel.transition.completedRevision, revision, `${context}: completed revision`);
|
||||
@@ -270,6 +491,7 @@ function assertClose(actual, expected, context) {
|
||||
|
||||
function createAudioHarness() {
|
||||
const instances = [];
|
||||
let shouldRejectNextPlay = false;
|
||||
|
||||
class FakeAudio {
|
||||
constructor(src = '') {
|
||||
@@ -283,12 +505,17 @@ function createAudioHarness() {
|
||||
this.paused = true;
|
||||
this.playCalls = 0;
|
||||
this.pauseCalls = 0;
|
||||
this.listeners = new Map();
|
||||
instances.push(this);
|
||||
}
|
||||
|
||||
play() {
|
||||
this.paused = false;
|
||||
this.playCalls += 1;
|
||||
if (shouldRejectNextPlay) {
|
||||
shouldRejectNextPlay = false;
|
||||
return Promise.reject(new Error('Simulated media playback failure'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -297,7 +524,17 @@ function createAudioHarness() {
|
||||
this.pauseCalls += 1;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
addEventListener(type, listener, options = {}) {
|
||||
const entries = this.listeners.get(type) ?? [];
|
||||
entries.push({ listener, once: Boolean(options.once) });
|
||||
this.listeners.set(type, entries);
|
||||
}
|
||||
|
||||
emit(type) {
|
||||
const entries = [...(this.listeners.get(type) ?? [])];
|
||||
entries.forEach(({ listener }) => listener());
|
||||
this.listeners.set(type, entries.filter(({ once }) => !once));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -308,6 +545,9 @@ function createAudioHarness() {
|
||||
},
|
||||
activeInstances() {
|
||||
return instances.filter((element) => element.src !== '');
|
||||
},
|
||||
rejectNextPlay() {
|
||||
shouldRejectNextPlay = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user