feat: improve portraits and combat audio

This commit is contained in:
2026-07-21 21:56:30 +09:00
parent 99a88c7128
commit f39de4f47d
27 changed files with 1396 additions and 154 deletions

View File

@@ -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;
}
};
}