feat: improve portraits and combat audio
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Audio Sources
|
||||
|
||||
Downloaded from Pixabay on 2026-06-22 and used under the Pixabay Content License.
|
||||
Downloaded from Pixabay on 2026-06-22 and 2026-07-21 and used under the Pixabay Content License.
|
||||
|
||||
License reference: https://pixabay.com/service/license-summary/
|
||||
|
||||
@@ -35,7 +35,16 @@ The following files are generated deterministically by `scripts/generate-camp-au
|
||||
| --- | --- | --- | --- |
|
||||
| `src/assets/audio/sfx/ui-select.mp3` | Ui, Click, Retro sound effect | SoundShelfStudio | https://pixabay.com/sound-effects/film-special-effects-ui-click-retro-514601/ |
|
||||
| `src/assets/audio/sfx/sword-slash.mp3` | Sword, Slash, Swing sound effect | DavidDumaisAudio | https://pixabay.com/sound-effects/film-special-effects-sword-slash-and-swing-185432/ |
|
||||
| `src/assets/audio/sfx/sword-swing-variant.mp3` | Video game Sword swing SFX | OxidVideos | https://pixabay.com/sound-effects/film-special-effects-video-game-sword-swing-sfx-409364/ |
|
||||
| `src/assets/audio/sfx/combat-impact.mp3` | Sword, Slash, Weapon sound effect | DavidDumaisAudio | https://pixabay.com/sound-effects/film-special-effects-sword-slash-with-metal-shield-impact-185433/ |
|
||||
| `src/assets/audio/sfx/armor-impact.mp3` | Armor Impact from Sword | DRAGON-STUDIO | https://pixabay.com/sound-effects/film-special-effects-armor-impact-from-sword-393843/ |
|
||||
| `src/assets/audio/sfx/shield-block.mp3` | Shield Block Shortsword | SectionSound | https://pixabay.com/sound-effects/film-special-effects-shield-block-shortsword-143940/ |
|
||||
| `src/assets/audio/sfx/bow-release.mp3` | Bow Release (Bow and Arrow) 4 | Ali_6868 (Freesound) | https://pixabay.com/sound-effects/film-special-effects-bow-release-bow-and-arrow-4-101936/ |
|
||||
| `src/assets/audio/sfx/arrow-swish.mp3` | Arrow Swish_03 | DJARTMUSIC | https://pixabay.com/sound-effects/film-special-effects-arrow-swish-03-306040/ |
|
||||
| `src/assets/audio/sfx/arrow-body-impact.mp3` | Arrow Body Impact | DennisH18 | https://pixabay.com/sound-effects/film-special-effects-arrow-body-impact-146419/ |
|
||||
| `src/assets/audio/sfx/arrow-wood-impact.mp3` | Arrow Wood Impact | DennisH18 | https://pixabay.com/sound-effects/film-special-effects-arrow-wood-impact-146418/ |
|
||||
| `src/assets/audio/sfx/critical-impact.mp3` | Combat Impact | Universfield | https://pixabay.com/sound-effects/film-special-effects-combat-impact-352458/ |
|
||||
| `src/assets/audio/sfx/critical-boom.mp3` | Cinematic Impact Boom 05 | Universfield | https://pixabay.com/sound-effects/film-special-effects-cinematic-impact-boom-05-352465/ |
|
||||
| `src/assets/audio/sfx/strategy-cast.mp3` | Designed, Magic spell, Projectile sound effect | RescopicSound | https://pixabay.com/sound-effects/film-special-effects-elemental-magic-spell-impact-outgoing-228342/ |
|
||||
| `src/assets/audio/sfx/cart-roll.mp3` | Cart, Wheel, Cobblestones sound effect | Fronbondi_Skegs | https://pixabay.com/sound-effects/film-special-effects-foley-heavily-laden-cart-moving-over-cobblestones-sound-effect-246657/ |
|
||||
| `src/assets/audio/sfx/exp-gain.mp3` | Level up, Level up sound, Game level up sound effect | Universfield | https://pixabay.com/sound-effects/film-special-effects-level-up-06-370051/ |
|
||||
|
||||
@@ -19,7 +19,9 @@ const server = await createServer({
|
||||
});
|
||||
|
||||
try {
|
||||
const { musicTracks, ambienceTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const audioAssets = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const { musicTracks, ambienceTracks, effectTracks } = audioAssets;
|
||||
const effectPools = audioAssets.effectPools ?? {};
|
||||
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const errors = [];
|
||||
@@ -27,9 +29,11 @@ try {
|
||||
validateTrackMap(errors, 'music', musicTracks, 'bgm');
|
||||
validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience');
|
||||
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
|
||||
validateEffectPools(errors, effectPools, effectTracks);
|
||||
validateEffectPoolRegistration(errors, effectPools);
|
||||
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
|
||||
validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks);
|
||||
validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks);
|
||||
validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks, effectPools);
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`Audio asset data verification failed with ${errors.length} issue(s):`);
|
||||
@@ -41,7 +45,7 @@ try {
|
||||
} else {
|
||||
console.log(
|
||||
`Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` +
|
||||
`${Object.keys(effectTracks).length} effect tracks, ` +
|
||||
`${Object.keys(effectTracks).length} effect tracks, ${Object.keys(effectPools).length} effect pools, ` +
|
||||
`${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.`
|
||||
);
|
||||
}
|
||||
@@ -49,6 +53,41 @@ try {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function validateEffectPools(errors, effectPools, effectTracks) {
|
||||
const effectKeys = new Set(Object.keys(effectTracks));
|
||||
|
||||
Object.entries(effectPools).forEach(([poolKey, trackKeys]) => {
|
||||
assertNonEmptyString(errors, poolKey, 'effect pool key');
|
||||
if (!Array.isArray(trackKeys) || trackKeys.length === 0) {
|
||||
errors.push(`effect pool "${poolKey}" must contain at least one track key`);
|
||||
return;
|
||||
}
|
||||
|
||||
const seenTrackKeys = new Set();
|
||||
trackKeys.forEach((trackKey, index) => {
|
||||
assertNonEmptyString(errors, trackKey, `effect pool "${poolKey}" track ${index}`);
|
||||
if (!effectKeys.has(trackKey)) {
|
||||
errors.push(`effect pool "${poolKey}" references unknown effect track "${trackKey}"`);
|
||||
}
|
||||
if (seenTrackKeys.has(trackKey)) {
|
||||
errors.push(`effect pool "${poolKey}" repeats effect track "${trackKey}"`);
|
||||
}
|
||||
seenTrackKeys.add(trackKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function validateEffectPoolRegistration(errors, effectPools) {
|
||||
if (Object.keys(effectPools).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainSource = readFileSync(join('src', 'main.ts'), 'utf8');
|
||||
if (!/\bregisterEffectPools\s*\(\s*effectPools\s*\)/.test(mainSource)) {
|
||||
errors.push('src/main.ts must register exported effectPools with SoundDirector');
|
||||
}
|
||||
}
|
||||
|
||||
function validateTrackMap(errors, label, tracks, expectedFolder) {
|
||||
Object.entries(tracks).forEach(([key, url]) => {
|
||||
assertNonEmptyString(errors, key, `${label} track key`);
|
||||
@@ -97,10 +136,10 @@ function validateStoryBgmReferences(errors, scenarioModule, battleScenarios, mus
|
||||
});
|
||||
}
|
||||
|
||||
function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks) {
|
||||
function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks, effectPools) {
|
||||
const musicKeys = new Set(Object.keys(musicTracks));
|
||||
const ambienceKeys = new Set(Object.keys(ambienceTracks));
|
||||
const effectKeys = new Set(Object.keys(effectTracks));
|
||||
const effectKeys = new Set([...Object.keys(effectTracks), ...Object.keys(effectPools)]);
|
||||
|
||||
sourceFilesToScan.forEach((path) => {
|
||||
const source = readFileSync(path, 'utf8');
|
||||
@@ -116,7 +155,7 @@ function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTr
|
||||
});
|
||||
collectLiteralCallKeys(source, 'playEffect').forEach(({ key, index }) => {
|
||||
if (!effectKeys.has(key)) {
|
||||
errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track "${key}"`);
|
||||
errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track or pool "${key}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -165,13 +204,88 @@ function isStoryPage(page) {
|
||||
|
||||
function collectLiteralCallKeys(source, callName) {
|
||||
const keys = [];
|
||||
const pattern = new RegExp(`\\.${callName}\\(\\s*['"]([^'"]+)['"]`, 'g');
|
||||
const pattern = new RegExp(`\\.${callName}\\s*\\(`, 'g');
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
keys.push({ key: match[1], index: match.index ?? 0 });
|
||||
const callIndex = match.index ?? 0;
|
||||
const argumentStart = callIndex + match[0].length;
|
||||
const argumentEnd = firstArgumentEnd(source, argumentStart);
|
||||
const argumentSource = source.slice(argumentStart, argumentEnd);
|
||||
const ternaryIndex = firstTernaryIndex(argumentSource);
|
||||
const stringPattern = /(['"])([^'"\\]*(?:\\.[^'"\\]*)*)\1/g;
|
||||
for (const stringMatch of argumentSource.matchAll(stringPattern)) {
|
||||
if (ternaryIndex >= 0 && (stringMatch.index ?? 0) < ternaryIndex) {
|
||||
continue;
|
||||
}
|
||||
if (stringMatch[2].includes('\\')) {
|
||||
continue;
|
||||
}
|
||||
keys.push({ key: stringMatch[2], index: argumentStart + (stringMatch.index ?? 0) });
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function firstTernaryIndex(source) {
|
||||
let quote = '';
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quote) {
|
||||
if (character === '\\') {
|
||||
index += 1;
|
||||
} else if (character === quote) {
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"' || character === '`') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === '?' && source[index - 1] !== '?' && source[index + 1] !== '?' && source[index + 1] !== '.') {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function firstArgumentEnd(source, startIndex) {
|
||||
let nestedDepth = 0;
|
||||
let quote = '';
|
||||
|
||||
for (let index = startIndex; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quote) {
|
||||
if (character === '\\') {
|
||||
index += 1;
|
||||
} else if (character === quote) {
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "'" || character === '"' || character === '`') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === '(' || character === '[' || character === '{') {
|
||||
nestedDepth += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === ')' || character === ']' || character === '}') {
|
||||
if (nestedDepth === 0) {
|
||||
return index;
|
||||
}
|
||||
nestedDepth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (character === ',' && nestedDepth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return source.length;
|
||||
}
|
||||
|
||||
function assetUrlToPath(url) {
|
||||
const marker = 'src/assets/audio/';
|
||||
const index = url.indexOf(marker);
|
||||
|
||||
@@ -139,6 +139,7 @@ try {
|
||||
await waitForStoryReady(page);
|
||||
await setDebugQueryParam(page, 'debugOrderCommandReady', '1');
|
||||
const initialStoryProgress = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
|
||||
assertStoryDialogLayout(initialStoryProgress, 'initial story page');
|
||||
assert(
|
||||
initialStoryProgress?.pageIndex === 0 &&
|
||||
initialStoryProgress?.totalPages > 1 &&
|
||||
@@ -3313,7 +3314,7 @@ try {
|
||||
await page.waitForFunction(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
return (scene?.dialogueObjects ?? []).filter((object) => object?.active).length === 0;
|
||||
}, undefined, { timeout: 1500 });
|
||||
}, undefined, { timeout: 2500 });
|
||||
|
||||
const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? [];
|
||||
const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? [];
|
||||
@@ -8205,6 +8206,8 @@ async function advanceUntilBattle(page, battleId, options = {}) {
|
||||
};
|
||||
}, battleId);
|
||||
|
||||
assertStoryDialogLayout(probe.story, `${battleId} story page`);
|
||||
|
||||
if (
|
||||
captureLastStory &&
|
||||
probe.story?.isLastPage &&
|
||||
@@ -9249,6 +9252,7 @@ async function waitForCampAfterBattleResult(page) {
|
||||
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
||||
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
|
||||
}));
|
||||
assertStoryDialogLayout(probe.story, 'post-battle story page');
|
||||
if (probe.story?.isLastPage && probe.story?.progressBounds && probe.story?.progressPanelBounds) {
|
||||
lastStory = probe.story;
|
||||
}
|
||||
@@ -10199,6 +10203,23 @@ function boundsInside(inner, outer, tolerance = 1) {
|
||||
inner.y + inner.height <= outer.y + outer.height + tolerance;
|
||||
}
|
||||
|
||||
function assertStoryDialogLayout(story, label) {
|
||||
if (!story?.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(
|
||||
boundsWithinFhdViewport(story.bodyBounds) &&
|
||||
boundsWithinFhdViewport(story.dialogPanelBounds) &&
|
||||
boundsInside(story.bodyBounds, story.dialogPanelBounds) &&
|
||||
(!isFiniteBounds(story.progressPanelBounds) ||
|
||||
story.bodyBounds.y + story.bodyBounds.height <= story.progressPanelBounds.y + 1) &&
|
||||
(!isFiniteBounds(story.cutsceneStageBounds) ||
|
||||
story.cutsceneStageBounds.y + story.cutsceneStageBounds.height <= story.dialogPanelBounds.y + 1),
|
||||
`Expected ${label} dialogue body to remain inside its FHD panel: ${JSON.stringify(story)}`
|
||||
);
|
||||
}
|
||||
|
||||
function boundsCover(outer, inner, tolerance = 1) {
|
||||
return isFiniteBounds(outer) && isFiniteBounds(inner) &&
|
||||
outer.x <= inner.x + tolerance &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,13 @@ try {
|
||||
storyBackgroundKeysForPages,
|
||||
storyBackgroundPageHasFixedAssignment
|
||||
} = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
|
||||
const { portraitAssetEntriesForKey, portraitKeyForSpeaker } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
|
||||
const {
|
||||
portraitAssets,
|
||||
portraitAssetEntriesForKey,
|
||||
portraitAssetEntriesForStoryContext,
|
||||
portraitTextureKeysForKey,
|
||||
portraitKeyForSpeaker
|
||||
} = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
|
||||
const { storyCutsceneActorProfileFor } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts');
|
||||
const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
|
||||
|
||||
@@ -46,6 +52,14 @@ try {
|
||||
let pageCount = 0;
|
||||
let cutsceneCount = 0;
|
||||
|
||||
validatePortraitVersionSelection({
|
||||
errors,
|
||||
portraitAssets,
|
||||
portraitAssetEntriesForKey,
|
||||
portraitAssetEntriesForStoryContext,
|
||||
portraitTextureKeysForKey
|
||||
});
|
||||
|
||||
storySources.forEach(({ source, pages }) => {
|
||||
if (!Array.isArray(pages) || pages.length === 0) {
|
||||
errors.push(`${source}: story page list must not be empty`);
|
||||
@@ -86,6 +100,7 @@ try {
|
||||
storyBackgroundAssets,
|
||||
storyBackgroundKeysFor,
|
||||
portraitAssetEntriesForKey,
|
||||
portraitAssetEntriesForStoryContext,
|
||||
portraitKeyForSpeaker,
|
||||
storyCutsceneActorProfileFor,
|
||||
hasUnitSheetAsset
|
||||
@@ -124,6 +139,152 @@ try {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function validatePortraitVersionSelection(context) {
|
||||
const {
|
||||
errors,
|
||||
portraitAssets,
|
||||
portraitAssetEntriesForKey,
|
||||
portraitAssetEntriesForStoryContext,
|
||||
portraitTextureKeysForKey
|
||||
} = context;
|
||||
const zhugeLiangTextureKeys = portraitTextureKeysForKey('zhugeLiang');
|
||||
const requiredZhugeLiangTextureKeys = [
|
||||
'portrait-zhuge-liang',
|
||||
'portrait-zhuge-liang-campaign-v2',
|
||||
'portrait-zhuge-liang-northern-v2',
|
||||
'portrait-zhuge-liang-redcliffs-v2'
|
||||
];
|
||||
const supersededZhugeLiangTextureKeys = [
|
||||
'portrait-zhuge-liang-campaign',
|
||||
'portrait-zhuge-liang-northern',
|
||||
'portrait-zhuge-liang-redcliffs'
|
||||
];
|
||||
|
||||
requiredZhugeLiangTextureKeys.forEach((textureKey) => {
|
||||
if (!zhugeLiangTextureKeys.includes(textureKey)) {
|
||||
errors.push(`portrait version selection: missing current Zhuge Liang portrait "${textureKey}"`);
|
||||
}
|
||||
});
|
||||
supersededZhugeLiangTextureKeys.forEach((textureKey) => {
|
||||
if (zhugeLiangTextureKeys.includes(textureKey)) {
|
||||
errors.push(`portrait version selection: superseded Zhuge Liang portrait remains selectable "${textureKey}"`);
|
||||
}
|
||||
});
|
||||
|
||||
const zhugeLiangStoryCases = [
|
||||
{
|
||||
context: { id: 'seventeenth-zhuge-liang-counsel', background: 'story-wolong' },
|
||||
expected: 'portrait-zhuge-liang'
|
||||
},
|
||||
{
|
||||
context: { id: 'eighteenth-red-cliffs-wind', background: 'story-wolong' },
|
||||
expected: 'portrait-zhuge-liang-redcliffs-v2'
|
||||
},
|
||||
{
|
||||
context: { id: 'twenty-first-zhuge-wind', background: 'story-red-cliffs' },
|
||||
expected: 'portrait-zhuge-liang-redcliffs-v2'
|
||||
},
|
||||
{
|
||||
context: { id: 'fiftieth-third-capture-plan', background: 'story-nanzhong-captures' },
|
||||
expected: 'portrait-zhuge-liang-campaign-v2'
|
||||
},
|
||||
{
|
||||
context: { id: 'thirty-fifth-hanzhong-front', background: 'story-northern' },
|
||||
expected: 'portrait-zhuge-liang-campaign-v2'
|
||||
},
|
||||
{
|
||||
context: { id: 'northern-prep-back-secured', background: 'story-northern' },
|
||||
expected: 'portrait-zhuge-liang-northern-v2'
|
||||
},
|
||||
{
|
||||
context: { id: 'fifty-fifth-qishan-road-opens', background: 'story-northern' },
|
||||
expected: 'portrait-zhuge-liang-northern-v2'
|
||||
},
|
||||
{
|
||||
context: { id: 'sixtieth-wudu-yinping-order', background: 'story-wudu-yinping' },
|
||||
expected: 'portrait-zhuge-liang-northern-v2'
|
||||
}
|
||||
];
|
||||
|
||||
zhugeLiangStoryCases.forEach(({ context: storyContext, expected }) => {
|
||||
const selected = portraitAssetEntriesForStoryContext('zhugeLiang', storyContext).map(
|
||||
({ textureKey }) => textureKey
|
||||
);
|
||||
if (selected.length !== 1 || selected[0] !== expected) {
|
||||
errors.push(
|
||||
`portrait story context: ${storyContext.id} should select "${expected}", received [${selected.join(', ')}]`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const zhugeLiangBaseRevisionFixture = 'zhuge-liang-v2';
|
||||
const previousBaseRevision = portraitAssets[zhugeLiangBaseRevisionFixture];
|
||||
portraitAssets[zhugeLiangBaseRevisionFixture] = `virtual:${zhugeLiangBaseRevisionFixture}`;
|
||||
try {
|
||||
const selected = portraitAssetEntriesForStoryContext('zhugeLiang', {
|
||||
id: 'seventeenth-zhuge-liang-counsel',
|
||||
background: 'story-wolong'
|
||||
}).map(({ textureKey }) => textureKey);
|
||||
if (selected.length !== 1 || selected[0] !== 'portrait-zhuge-liang-v2') {
|
||||
errors.push(
|
||||
`portrait story context: a revised base portrait should remain in the base route, received [${selected.join(', ')}]`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (previousBaseRevision === undefined) {
|
||||
delete portraitAssets[zhugeLiangBaseRevisionFixture];
|
||||
} else {
|
||||
portraitAssets[zhugeLiangBaseRevisionFixture] = previousBaseRevision;
|
||||
}
|
||||
}
|
||||
|
||||
const fixtureSlug = 'portrait-version-fixture';
|
||||
const fixtureSlugs = [
|
||||
fixtureSlug,
|
||||
`${fixtureSlug}-campaign`,
|
||||
`${fixtureSlug}-campaign-v2`,
|
||||
`${fixtureSlug}-campaign-v10`,
|
||||
`${fixtureSlug}-northern`,
|
||||
`${fixtureSlug}-redcliffs-v2`,
|
||||
`${fixtureSlug}-redcliffs-v3`,
|
||||
`${fixtureSlug}-vanguard`,
|
||||
`${fixtureSlug}-veteran`
|
||||
];
|
||||
const expectedFixtureTextureKeys = [
|
||||
`portrait-${fixtureSlug}`,
|
||||
`portrait-${fixtureSlug}-campaign-v10`,
|
||||
`portrait-${fixtureSlug}-northern`,
|
||||
`portrait-${fixtureSlug}-redcliffs-v3`,
|
||||
`portrait-${fixtureSlug}-vanguard`,
|
||||
`portrait-${fixtureSlug}-veteran`
|
||||
];
|
||||
|
||||
fixtureSlugs.forEach((slug) => {
|
||||
portraitAssets[slug] = `virtual:${slug}`;
|
||||
});
|
||||
|
||||
try {
|
||||
const entryTextureKeys = portraitAssetEntriesForKey(fixtureSlug).map((entry) => entry.textureKey);
|
||||
const textureKeys = portraitTextureKeysForKey(fixtureSlug);
|
||||
if (entryTextureKeys.join('\n') !== expectedFixtureTextureKeys.join('\n')) {
|
||||
errors.push(
|
||||
`portrait version selection: entries should keep independent variants and only the highest numeric -vN suffix; ` +
|
||||
`received [${entryTextureKeys.join(', ')}]`
|
||||
);
|
||||
}
|
||||
if (textureKeys.join('\n') !== expectedFixtureTextureKeys.join('\n')) {
|
||||
errors.push(
|
||||
`portrait version selection: texture keys should keep independent variants and only the highest numeric -vN suffix; ` +
|
||||
`received [${textureKeys.join(', ')}]`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fixtureSlugs.forEach((slug) => {
|
||||
delete portraitAssets[slug];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectStorySources(scenarioModule, battleScenarios) {
|
||||
const sources = [];
|
||||
const seenArrays = new WeakSet();
|
||||
@@ -350,13 +511,13 @@ function validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundU
|
||||
}
|
||||
|
||||
function validatePortrait(context, pageContext) {
|
||||
const { errors, page, usedPortraitKeys, portraitAssetEntriesForKey, portraitKeyForSpeaker } = context;
|
||||
const { errors, page, usedPortraitKeys, portraitAssetEntriesForStoryContext, portraitKeyForSpeaker } = context;
|
||||
const portraitKey = page.portrait ?? portraitKeyForSpeaker(page.speaker);
|
||||
if (!portraitKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = portraitAssetEntriesForKey(portraitKey);
|
||||
const entries = portraitAssetEntriesForStoryContext(portraitKey, page);
|
||||
if (entries.length === 0) {
|
||||
errors.push(`${pageContext}: missing portrait asset "${portraitKey}"`);
|
||||
}
|
||||
|
||||
BIN
src/assets/audio/sfx/armor-impact.mp3
Normal file
BIN
src/assets/audio/sfx/armor-impact.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/arrow-body-impact.mp3
Normal file
BIN
src/assets/audio/sfx/arrow-body-impact.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/arrow-swish.mp3
Normal file
BIN
src/assets/audio/sfx/arrow-swish.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/arrow-wood-impact.mp3
Normal file
BIN
src/assets/audio/sfx/arrow-wood-impact.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/bow-release.mp3
Normal file
BIN
src/assets/audio/sfx/bow-release.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/critical-boom.mp3
Normal file
BIN
src/assets/audio/sfx/critical-boom.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/critical-impact.mp3
Normal file
BIN
src/assets/audio/sfx/critical-impact.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/shield-block.mp3
Normal file
BIN
src/assets/audio/sfx/shield-block.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/sword-swing-variant.mp3
Normal file
BIN
src/assets/audio/sfx/sword-swing-variant.mp3
Normal file
Binary file not shown.
BIN
src/assets/images/portraits/zhuge-liang-campaign-v2.png
Normal file
BIN
src/assets/images/portraits/zhuge-liang-campaign-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
BIN
src/assets/images/portraits/zhuge-liang-northern-v2.png
Normal file
BIN
src/assets/images/portraits/zhuge-liang-northern-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
src/assets/images/portraits/zhuge-liang-redcliffs-v2.png
Normal file
BIN
src/assets/images/portraits/zhuge-liang-redcliffs-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1,5 +1,11 @@
|
||||
type AudioTrackMap = Record<string, string>;
|
||||
|
||||
export type AudioCategory = 'music' | 'effects' | 'ambience';
|
||||
|
||||
export type AudioCategoryMultipliers = Record<AudioCategory, number>;
|
||||
|
||||
export type EffectPoolMap = Record<string, readonly string[]>;
|
||||
|
||||
export type SoundscapeOptions = {
|
||||
musicKey?: string;
|
||||
ambienceKey?: string;
|
||||
@@ -7,7 +13,7 @@ export type SoundscapeOptions = {
|
||||
ambienceVolume?: number;
|
||||
};
|
||||
|
||||
type LoopChannelName = 'music' | 'ambience';
|
||||
type LoopChannelName = Exclude<AudioCategory, 'effects'>;
|
||||
|
||||
type LoopChannelTransition = {
|
||||
revision: number;
|
||||
@@ -32,26 +38,74 @@ type LoopChannel = {
|
||||
hasPendingRequest: boolean;
|
||||
outgoing: Set<HTMLAudioElement>;
|
||||
outgoingStartVolumes: Map<HTMLAudioElement, number>;
|
||||
elementBaseVolumes: Map<HTMLAudioElement, number>;
|
||||
fadeTimer?: number;
|
||||
elementRevision: number;
|
||||
transition: LoopChannelTransition;
|
||||
};
|
||||
|
||||
type PlayEffectOptions = {
|
||||
export type PlayEffectOptions = {
|
||||
volume?: number;
|
||||
rate?: number;
|
||||
stopAfterMs?: number;
|
||||
};
|
||||
|
||||
export type MusicDuckOptions = {
|
||||
multiplier?: number;
|
||||
attackMs?: number;
|
||||
holdMs?: number;
|
||||
releaseMs?: number;
|
||||
};
|
||||
|
||||
type MusicDuckState = {
|
||||
multiplier: number;
|
||||
timer?: number;
|
||||
revision: number;
|
||||
completedRevision: number;
|
||||
active: boolean;
|
||||
endsAt: number;
|
||||
last?: {
|
||||
multiplier: number;
|
||||
attackMs: number;
|
||||
holdMs: number;
|
||||
releaseMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
export class SoundDirector {
|
||||
private context?: AudioContext;
|
||||
private master?: GainNode;
|
||||
private effectsBus?: GainNode;
|
||||
private started = false;
|
||||
private muted = false;
|
||||
private effectTracks: AudioTrackMap = {};
|
||||
private effectPools: Record<string, string[]> = {};
|
||||
private readonly lastEffectPoolSelections = new Map<string, string>();
|
||||
private readonly activeEffects = new Set<HTMLAudioElement>();
|
||||
private readonly effectBaseVolumes = new Map<HTMLAudioElement, number>();
|
||||
private readonly musicChannel = this.createLoopChannel('music', 0.22, 900);
|
||||
private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200);
|
||||
private lastEffect?: { key: string; at: number; volume: number; rate: number };
|
||||
private readonly categoryMultipliers: AudioCategoryMultipliers = {
|
||||
music: 1,
|
||||
effects: 1,
|
||||
ambience: 1
|
||||
};
|
||||
private readonly musicDuck: MusicDuckState = {
|
||||
multiplier: 1,
|
||||
revision: 0,
|
||||
completedRevision: 0,
|
||||
active: false,
|
||||
endsAt: 0
|
||||
};
|
||||
private lastEffect?: {
|
||||
key: string;
|
||||
trackKey: string;
|
||||
poolKey: string | null;
|
||||
at: number;
|
||||
baseVolume: number;
|
||||
volume: number;
|
||||
rate: number;
|
||||
};
|
||||
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
|
||||
private readonly effectVolume = 0.42;
|
||||
|
||||
@@ -67,6 +121,16 @@ export class SoundDirector {
|
||||
this.effectTracks = tracks;
|
||||
}
|
||||
|
||||
registerEffectPools(pools: EffectPoolMap) {
|
||||
this.effectPools = Object.fromEntries(
|
||||
Object.entries(pools).map(([key, trackKeys]) => [
|
||||
key,
|
||||
[...new Set(trackKeys.filter((trackKey) => typeof trackKey === 'string' && trackKey.length > 0))]
|
||||
])
|
||||
);
|
||||
this.lastEffectPoolSelections.clear();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) {
|
||||
this.resume();
|
||||
@@ -81,6 +145,9 @@ export class SoundDirector {
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = this.muted ? 0 : 0.3;
|
||||
this.master.connect(this.context.destination);
|
||||
this.effectsBus = this.context.createGain();
|
||||
this.effectsBus.gain.value = this.categoryMultipliers.effects;
|
||||
this.effectsBus.connect(this.master);
|
||||
}
|
||||
|
||||
this.started = true;
|
||||
@@ -105,6 +172,9 @@ export class SoundDirector {
|
||||
for (const element of this.loopElements()) {
|
||||
element.muted = muted;
|
||||
}
|
||||
for (const effect of this.activeEffects) {
|
||||
effect.muted = muted;
|
||||
}
|
||||
|
||||
if (!this.context || !this.master) {
|
||||
return;
|
||||
@@ -118,6 +188,91 @@ export class SoundDirector {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
setCategoryMultiplier(category: AudioCategory, multiplier: number) {
|
||||
const normalizedMultiplier = this.normalizeUnitInterval(multiplier, this.categoryMultipliers[category]);
|
||||
this.categoryMultipliers[category] = normalizedMultiplier;
|
||||
|
||||
if (category === 'effects') {
|
||||
if (this.effectsBus) {
|
||||
this.effectsBus.gain.value = normalizedMultiplier;
|
||||
}
|
||||
this.refreshActiveEffectVolumes();
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshLoopChannelVolumes(category === 'music' ? this.musicChannel : this.ambienceChannel);
|
||||
}
|
||||
|
||||
setCategoryMultipliers(multipliers: Partial<AudioCategoryMultipliers>) {
|
||||
(['music', 'effects', 'ambience'] as const).forEach((category) => {
|
||||
const multiplier = multipliers[category];
|
||||
if (multiplier !== undefined) {
|
||||
this.setCategoryMultiplier(category, multiplier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
duckMusic({ multiplier = 0.42, attackMs = 48, holdMs = 180, releaseMs = 280 }: MusicDuckOptions = {}) {
|
||||
this.cancelMusicDuckTimer();
|
||||
|
||||
const normalizedMultiplier = this.normalizeUnitInterval(multiplier, 0.42);
|
||||
const normalizedAttackMs = this.normalizeDuration(attackMs, 48);
|
||||
const normalizedHoldMs = this.normalizeDuration(holdMs, 180);
|
||||
const normalizedReleaseMs = this.normalizeDuration(releaseMs, 280);
|
||||
const fromMultiplier = this.musicDuck.multiplier;
|
||||
const targetMultiplier = Math.min(fromMultiplier, normalizedMultiplier);
|
||||
const startedAt = performance.now();
|
||||
const previousEndsAt = this.musicDuck.active ? this.musicDuck.endsAt : startedAt;
|
||||
const previousReleaseMs = this.musicDuck.active ? this.musicDuck.last?.releaseMs ?? 0 : 0;
|
||||
const effectiveReleaseMs = Math.max(normalizedReleaseMs, previousReleaseMs);
|
||||
const requestedEndsAt = startedAt + normalizedAttackMs + normalizedHoldMs + normalizedReleaseMs;
|
||||
const endsAt = Math.max(previousEndsAt, requestedEndsAt);
|
||||
const releaseStartsAt = Math.max(startedAt + normalizedAttackMs + normalizedHoldMs, endsAt - effectiveReleaseMs);
|
||||
|
||||
this.musicDuck.revision += 1;
|
||||
this.musicDuck.active = true;
|
||||
this.musicDuck.endsAt = endsAt;
|
||||
this.musicDuck.last = {
|
||||
multiplier: normalizedMultiplier,
|
||||
attackMs: normalizedAttackMs,
|
||||
holdMs: normalizedHoldMs,
|
||||
releaseMs: normalizedReleaseMs
|
||||
};
|
||||
|
||||
const revision = this.musicDuck.revision;
|
||||
const update = () => {
|
||||
if (revision !== this.musicDuck.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const elapsedMs = Math.max(0, now - startedAt);
|
||||
let nextMultiplier = targetMultiplier;
|
||||
|
||||
if (normalizedAttackMs > 0 && elapsedMs < normalizedAttackMs) {
|
||||
const progress = elapsedMs / normalizedAttackMs;
|
||||
nextMultiplier = fromMultiplier + (targetMultiplier - fromMultiplier) * progress;
|
||||
} else if (now >= releaseStartsAt) {
|
||||
if (effectiveReleaseMs <= 0) {
|
||||
nextMultiplier = 1;
|
||||
} else {
|
||||
const releaseProgress = Math.min(1, (now - releaseStartsAt) / effectiveReleaseMs);
|
||||
nextMultiplier = targetMultiplier + (1 - targetMultiplier) * releaseProgress;
|
||||
}
|
||||
}
|
||||
|
||||
this.setMusicDuckMultiplier(nextMultiplier);
|
||||
if (now >= endsAt) {
|
||||
this.completeMusicDuck(revision);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
if (endsAt > startedAt && this.musicDuck.active) {
|
||||
this.musicDuck.timer = window.setInterval(update, 16);
|
||||
}
|
||||
}
|
||||
|
||||
playHover() {
|
||||
this.playTone(540, 0.04, 0.018, 'sine');
|
||||
}
|
||||
@@ -168,30 +323,62 @@ export class SoundDirector {
|
||||
}
|
||||
|
||||
playMeleeSwing(hit: boolean, critical: boolean) {
|
||||
this.playEffect('sword-slash', {
|
||||
this.playEffect('melee-swing', {
|
||||
volume: critical ? 0.62 : hit ? 0.52 : 0.38,
|
||||
rate: critical ? 0.94 : hit ? 1 : 1.1,
|
||||
stopAfterMs: 640
|
||||
stopAfterMs: 1700
|
||||
});
|
||||
this.playTone(critical ? 180 : 220, 0.04, critical ? 0.03 : 0.02, 'sawtooth');
|
||||
}
|
||||
|
||||
playArrowShot() {
|
||||
this.playEffect('sword-slash', { volume: 0.18, rate: 1.58, stopAfterMs: 280 });
|
||||
this.playEffect('bow-release', { volume: 0.34, rate: 1, stopAfterMs: 420 });
|
||||
this.playTone(760, 0.05, 0.012, 'triangle');
|
||||
window.setTimeout(() => this.playTone(1180, 0.045, 0.01, 'sine'), 46);
|
||||
}
|
||||
|
||||
playArrowImpact(hit: boolean, critical: boolean) {
|
||||
if (hit) {
|
||||
this.playEffect('combat-impact', { volume: critical ? 0.5 : 0.34, rate: critical ? 1.06 : 1.18, stopAfterMs: 420 });
|
||||
this.playEffect('arrow-impact', {
|
||||
volume: critical ? 0.5 : 0.38,
|
||||
rate: critical ? 0.94 : 1,
|
||||
stopAfterMs: 760
|
||||
});
|
||||
this.playTone(critical ? 260 : 340, 0.05, critical ? 0.026 : 0.018, 'square');
|
||||
if (critical) {
|
||||
this.playCriticalAccent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.playEffect('arrow-miss', { volume: 0.28, rate: 1.04, stopAfterMs: 500 });
|
||||
this.playTone(940, 0.06, 0.011, 'sine');
|
||||
}
|
||||
|
||||
playCombatImpact(critical: boolean, restrained = false) {
|
||||
this.playEffect('melee-impact', {
|
||||
volume: restrained ? (critical ? 0.4 : 0.26) : critical ? 0.58 : 0.46,
|
||||
rate: critical ? 0.94 : 1,
|
||||
stopAfterMs: 1700
|
||||
});
|
||||
if (critical) {
|
||||
this.playCriticalAccent();
|
||||
}
|
||||
}
|
||||
|
||||
playBattleOutcome(outcome: 'victory' | 'defeat') {
|
||||
this.duckMusic({
|
||||
multiplier: outcome === 'victory' ? 0.34 : 0.24,
|
||||
attackMs: 90,
|
||||
holdMs: 820,
|
||||
releaseMs: 520
|
||||
});
|
||||
this.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', {
|
||||
volume: outcome === 'victory' ? 0.34 : 0.26,
|
||||
stopAfterMs: 1200
|
||||
});
|
||||
}
|
||||
|
||||
playStrategyPulse() {
|
||||
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
|
||||
this.playTone(460, 0.08, 0.012, 'sine');
|
||||
@@ -208,33 +395,50 @@ export class SoundDirector {
|
||||
return false;
|
||||
}
|
||||
|
||||
const src = this.effectTracks[key];
|
||||
if (!src) {
|
||||
const resolvedTrack = this.resolveEffectTrack(key);
|
||||
if (!resolvedTrack) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const effect = new Audio(src);
|
||||
const effect = new Audio(resolvedTrack.src);
|
||||
const baseVolume = this.normalizeUnitInterval(options.volume ?? this.effectVolume, this.effectVolume);
|
||||
effect.preload = 'auto';
|
||||
effect.volume = options.volume ?? this.effectVolume;
|
||||
effect.volume = this.effectOutputVolume(baseVolume);
|
||||
effect.playbackRate = options.rate ?? 1;
|
||||
effect.muted = this.muted;
|
||||
this.lastEffect = { key, at: performance.now(), volume: effect.volume, rate: effect.playbackRate };
|
||||
this.activeEffects.add(effect);
|
||||
this.effectBaseVolumes.set(effect, baseVolume);
|
||||
this.lastEffect = {
|
||||
key,
|
||||
trackKey: resolvedTrack.trackKey,
|
||||
poolKey: resolvedTrack.poolKey,
|
||||
at: performance.now(),
|
||||
baseVolume,
|
||||
volume: effect.volume,
|
||||
rate: effect.playbackRate
|
||||
};
|
||||
effect.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
effect.src = '';
|
||||
this.retireEffectElement(effect);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
effect.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
this.retireEffectElement(effect);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
if (options.stopAfterMs) {
|
||||
window.setTimeout(() => {
|
||||
effect.pause();
|
||||
effect.src = '';
|
||||
this.retireEffectElement(effect);
|
||||
}, options.stopAfterMs);
|
||||
}
|
||||
|
||||
void effect.play().catch(() => undefined);
|
||||
void effect.play().catch(() => this.retireEffectElement(effect));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -252,6 +456,19 @@ export class SoundDirector {
|
||||
pendingAmbienceKey: ambience.pendingKey,
|
||||
music,
|
||||
ambience,
|
||||
categoryMultipliers: { ...this.categoryMultipliers },
|
||||
musicDuck: {
|
||||
multiplier: this.musicDuck.multiplier,
|
||||
revision: this.musicDuck.revision,
|
||||
completedRevision: this.musicDuck.completedRevision,
|
||||
active: this.musicDuck.active,
|
||||
endsAt: this.musicDuck.endsAt,
|
||||
last: this.musicDuck.last ? { ...this.musicDuck.last } : null
|
||||
},
|
||||
effectPools: Object.fromEntries(
|
||||
Object.entries(this.effectPools).map(([key, trackKeys]) => [key, [...trackKeys]])
|
||||
),
|
||||
activeEffectCount: this.activeEffects.size,
|
||||
lastEffect: this.lastEffect ?? null,
|
||||
lastMovementStep: this.lastMovementStep ?? null
|
||||
};
|
||||
@@ -302,11 +519,19 @@ export class SoundDirector {
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(this.master ?? this.context.destination);
|
||||
gain.connect(this.effectsBus ?? this.master ?? this.context.destination);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.04);
|
||||
}
|
||||
|
||||
private playCriticalAccent() {
|
||||
this.duckMusic({ multiplier: 0.52, attackMs: 32, holdMs: 140, releaseMs: 280 });
|
||||
this.playEffect('critical-impact', { volume: 0.3, rate: 1, stopAfterMs: 800 });
|
||||
window.setTimeout(() => {
|
||||
this.playEffect('critical-boom', { volume: 0.16, rate: 1.04, stopAfterMs: 2000 });
|
||||
}, 34);
|
||||
}
|
||||
|
||||
private playStepPulse(mounted: boolean, stepIndex: number, volume = 0.2) {
|
||||
if (!this.context || this.muted) {
|
||||
return;
|
||||
@@ -347,7 +572,7 @@ export class SoundDirector {
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(this.master ?? this.context.destination);
|
||||
gain.connect(this.effectsBus ?? this.master ?? this.context.destination);
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + duration + 0.025);
|
||||
}
|
||||
@@ -362,6 +587,7 @@ export class SoundDirector {
|
||||
hasPendingRequest: false,
|
||||
outgoing: new Set(),
|
||||
outgoingStartVolumes: new Map(),
|
||||
elementBaseVolumes: new Map(),
|
||||
elementRevision: 0,
|
||||
transition: {
|
||||
revision: 0,
|
||||
@@ -408,7 +634,7 @@ export class SoundDirector {
|
||||
if (channel.current) {
|
||||
channel.current.muted = this.muted;
|
||||
if (!channel.transition.active) {
|
||||
channel.current.volume = channel.targetVolume;
|
||||
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
|
||||
}
|
||||
void channel.current.play().catch(() => undefined);
|
||||
}
|
||||
@@ -433,7 +659,7 @@ export class SoundDirector {
|
||||
const fromKey = channel.currentKey ?? null;
|
||||
if (previous) {
|
||||
channel.outgoing.add(previous);
|
||||
channel.outgoingStartVolumes.set(previous, previous.volume);
|
||||
channel.outgoingStartVolumes.set(previous, channel.elementBaseVolumes.get(previous) ?? channel.targetVolume);
|
||||
}
|
||||
|
||||
channel.current = undefined;
|
||||
@@ -444,7 +670,7 @@ export class SoundDirector {
|
||||
next.loop = true;
|
||||
next.preload = 'auto';
|
||||
next.muted = this.muted;
|
||||
next.volume = 0;
|
||||
this.setLoopElementBaseVolume(channel, next, 0);
|
||||
channel.current = next;
|
||||
channel.currentKey = key;
|
||||
channel.elementRevision += 1;
|
||||
@@ -468,11 +694,11 @@ export class SoundDirector {
|
||||
|
||||
const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs));
|
||||
if (channel.current) {
|
||||
channel.current.volume = channel.targetVolume * progress;
|
||||
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume * progress);
|
||||
}
|
||||
channel.outgoing.forEach((element) => {
|
||||
const startVolume = channel.outgoingStartVolumes.get(element) ?? element.volume;
|
||||
element.volume = startVolume * (1 - progress);
|
||||
const startVolume = channel.outgoingStartVolumes.get(element) ?? channel.elementBaseVolumes.get(element) ?? 0;
|
||||
this.setLoopElementBaseVolume(channel, element, startVolume * (1 - progress));
|
||||
});
|
||||
|
||||
if (progress >= 1) {
|
||||
@@ -489,7 +715,7 @@ export class SoundDirector {
|
||||
this.cancelLoopTransitionTimer(channel);
|
||||
this.retireOutgoingElements(channel);
|
||||
if (channel.current) {
|
||||
channel.current.volume = channel.targetVolume;
|
||||
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
|
||||
}
|
||||
channel.transition.active = false;
|
||||
channel.transition.completedRevision = transitionRevision;
|
||||
@@ -503,14 +729,15 @@ export class SoundDirector {
|
||||
}
|
||||
|
||||
private retireOutgoingElements(channel: LoopChannel) {
|
||||
channel.outgoing.forEach((element) => this.retireLoopElement(element));
|
||||
channel.outgoing.forEach((element) => this.retireLoopElement(channel, element));
|
||||
channel.outgoing.clear();
|
||||
channel.outgoingStartVolumes.clear();
|
||||
}
|
||||
|
||||
private retireLoopElement(element: HTMLAudioElement) {
|
||||
private retireLoopElement(channel: LoopChannel, element: HTMLAudioElement) {
|
||||
element.pause();
|
||||
element.src = '';
|
||||
channel.elementBaseVolumes.delete(element);
|
||||
}
|
||||
|
||||
private loopElements() {
|
||||
@@ -532,6 +759,9 @@ export class SoundDirector {
|
||||
elementRevision: channel.elementRevision,
|
||||
activeElementCount: (channel.current ? 1 : 0) + channel.outgoing.size,
|
||||
targetVolume: channel.targetVolume,
|
||||
categoryMultiplier: this.categoryMultipliers[channel.name],
|
||||
duckMultiplier: channel.name === 'music' ? this.musicDuck.multiplier : 1,
|
||||
effectiveTargetVolume: this.loopOutputVolume(channel, channel.targetVolume),
|
||||
transition: {
|
||||
revision: channel.transition.revision,
|
||||
completedRevision: channel.transition.completedRevision,
|
||||
@@ -540,6 +770,104 @@ export class SoundDirector {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private resolveEffectTrack(key: string) {
|
||||
const pool = this.effectPools[key];
|
||||
if (pool?.length) {
|
||||
const availableTrackKeys = pool.filter((trackKey) => Boolean(this.effectTracks[trackKey]));
|
||||
if (availableTrackKeys.length) {
|
||||
const lastSelection = this.lastEffectPoolSelections.get(key);
|
||||
const candidates =
|
||||
availableTrackKeys.length > 1
|
||||
? availableTrackKeys.filter((trackKey) => trackKey !== lastSelection)
|
||||
: availableTrackKeys;
|
||||
const randomIndex = Math.floor(Math.random() * candidates.length);
|
||||
const trackKey = candidates[randomIndex] ?? candidates[0];
|
||||
this.lastEffectPoolSelections.set(key, trackKey);
|
||||
return { trackKey, src: this.effectTracks[trackKey], poolKey: key };
|
||||
}
|
||||
}
|
||||
|
||||
const src = this.effectTracks[key];
|
||||
return src ? { trackKey: key, src, poolKey: null } : undefined;
|
||||
}
|
||||
|
||||
private refreshActiveEffectVolumes() {
|
||||
this.activeEffects.forEach((effect) => {
|
||||
const baseVolume = this.effectBaseVolumes.get(effect);
|
||||
if (baseVolume !== undefined) {
|
||||
effect.volume = this.effectOutputVolume(baseVolume);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private effectOutputVolume(baseVolume: number) {
|
||||
return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers.effects, 0);
|
||||
}
|
||||
|
||||
private retireEffectElement(effect: HTMLAudioElement) {
|
||||
effect.pause();
|
||||
effect.src = '';
|
||||
this.activeEffects.delete(effect);
|
||||
this.effectBaseVolumes.delete(effect);
|
||||
}
|
||||
|
||||
private setLoopElementBaseVolume(channel: LoopChannel, element: HTMLAudioElement, baseVolume: number) {
|
||||
const normalizedBaseVolume = this.normalizeUnitInterval(baseVolume, 0);
|
||||
channel.elementBaseVolumes.set(element, normalizedBaseVolume);
|
||||
element.volume = this.loopOutputVolume(channel, normalizedBaseVolume);
|
||||
}
|
||||
|
||||
private refreshLoopChannelVolumes(channel: LoopChannel) {
|
||||
if (channel.current) {
|
||||
this.refreshLoopElementVolume(channel, channel.current);
|
||||
}
|
||||
channel.outgoing.forEach((element) => this.refreshLoopElementVolume(channel, element));
|
||||
}
|
||||
|
||||
private refreshLoopElementVolume(channel: LoopChannel, element: HTMLAudioElement) {
|
||||
const baseVolume = channel.elementBaseVolumes.get(element);
|
||||
if (baseVolume !== undefined) {
|
||||
element.volume = this.loopOutputVolume(channel, baseVolume);
|
||||
}
|
||||
}
|
||||
|
||||
private loopOutputVolume(channel: LoopChannel, baseVolume: number) {
|
||||
const duckMultiplier = channel.name === 'music' ? this.musicDuck.multiplier : 1;
|
||||
return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers[channel.name] * duckMultiplier, 0);
|
||||
}
|
||||
|
||||
private setMusicDuckMultiplier(multiplier: number) {
|
||||
this.musicDuck.multiplier = this.normalizeUnitInterval(multiplier, 1);
|
||||
this.refreshLoopChannelVolumes(this.musicChannel);
|
||||
}
|
||||
|
||||
private completeMusicDuck(revision: number) {
|
||||
if (revision !== this.musicDuck.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelMusicDuckTimer();
|
||||
this.setMusicDuckMultiplier(1);
|
||||
this.musicDuck.active = false;
|
||||
this.musicDuck.endsAt = 0;
|
||||
this.musicDuck.completedRevision = revision;
|
||||
}
|
||||
|
||||
private cancelMusicDuckTimer() {
|
||||
if (this.musicDuck.timer !== undefined) {
|
||||
window.clearInterval(this.musicDuck.timer);
|
||||
this.musicDuck.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeUnitInterval(value: number, fallback: number) {
|
||||
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
|
||||
}
|
||||
|
||||
private normalizeDuration(value: number, fallback: number) {
|
||||
return Number.isFinite(value) ? Math.min(10_000, Math.max(0, value)) : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -7,16 +7,25 @@ import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3';
|
||||
import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3';
|
||||
import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3';
|
||||
import titleThemeUrl from '../../assets/audio/bgm/title-theme.mp3';
|
||||
import armorImpactUrl from '../../assets/audio/sfx/armor-impact.mp3';
|
||||
import arrowBodyImpactUrl from '../../assets/audio/sfx/arrow-body-impact.mp3';
|
||||
import arrowSwishUrl from '../../assets/audio/sfx/arrow-swish.mp3';
|
||||
import arrowWoodImpactUrl from '../../assets/audio/sfx/arrow-wood-impact.mp3';
|
||||
import bondResonanceUrl from '../../assets/audio/sfx/bond-resonance.wav';
|
||||
import bowReleaseUrl from '../../assets/audio/sfx/bow-release.mp3';
|
||||
import cartRollUrl from '../../assets/audio/sfx/cart-roll.mp3';
|
||||
import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3';
|
||||
import criticalBoomUrl from '../../assets/audio/sfx/critical-boom.mp3';
|
||||
import criticalImpactUrl from '../../assets/audio/sfx/critical-impact.mp3';
|
||||
import defeatStingUrl from '../../assets/audio/sfx/defeat-sting.wav';
|
||||
import expGainUrl from '../../assets/audio/sfx/exp-gain.mp3';
|
||||
import footstepWalkUrl from '../../assets/audio/sfx/footstep-walk.mp3';
|
||||
import horseGallopUrl from '../../assets/audio/sfx/horse-gallop.mp3';
|
||||
import levelUpUrl from '../../assets/audio/sfx/level-up.wav';
|
||||
import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3';
|
||||
import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3';
|
||||
import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3';
|
||||
import swordSwingVariantUrl from '../../assets/audio/sfx/sword-swing-variant.mp3';
|
||||
import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3';
|
||||
import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav';
|
||||
import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3';
|
||||
@@ -46,7 +55,16 @@ export const ambienceTracks = {
|
||||
export const effectTracks = {
|
||||
'ui-select': uiSelectUrl,
|
||||
'sword-slash': swordSlashUrl,
|
||||
'sword-swing-variant': swordSwingVariantUrl,
|
||||
'combat-impact': combatImpactUrl,
|
||||
'armor-impact': armorImpactUrl,
|
||||
'shield-block': shieldBlockUrl,
|
||||
'bow-release': bowReleaseUrl,
|
||||
'arrow-swish': arrowSwishUrl,
|
||||
'arrow-body-impact': arrowBodyImpactUrl,
|
||||
'arrow-wood-impact': arrowWoodImpactUrl,
|
||||
'critical-impact': criticalImpactUrl,
|
||||
'critical-boom': criticalBoomUrl,
|
||||
'strategy-cast': strategyCastUrl,
|
||||
'cart-roll': cartRollUrl,
|
||||
'exp-gain': expGainUrl,
|
||||
@@ -58,6 +76,14 @@ export const effectTracks = {
|
||||
'horse-gallop': horseGallopUrl
|
||||
} as const;
|
||||
|
||||
export const effectPools = {
|
||||
'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']
|
||||
} as const;
|
||||
|
||||
export type MusicTrackKey = keyof typeof musicTracks;
|
||||
export type AmbienceTrackKey = keyof typeof ambienceTracks;
|
||||
export type EffectTrackKey = keyof typeof effectTracks;
|
||||
export type EffectPoolKey = keyof typeof effectPools;
|
||||
|
||||
@@ -96,6 +96,23 @@ export type PortraitAssetEntry = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type StoryPortraitContext = {
|
||||
id: string;
|
||||
background: string;
|
||||
};
|
||||
|
||||
const zhugeLiangNorthernBackgrounds = new Set([
|
||||
'story-tianshui-front',
|
||||
'story-jieting-crisis',
|
||||
'story-chencang-siege',
|
||||
'story-wudu-yinping',
|
||||
'story-hanzhong-rain',
|
||||
'story-qishan-renewed',
|
||||
'story-lucheng-pursuit',
|
||||
'story-weishui-camps',
|
||||
'story-weishui-northbank'
|
||||
]);
|
||||
|
||||
export function portraitSlugForKey(key: string) {
|
||||
return legacyPortraitSlugs[key] ?? camelToKebab(key);
|
||||
}
|
||||
@@ -121,28 +138,73 @@ export function portraitCardAssetEntryForKey(key: string): PortraitAssetEntry |
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] {
|
||||
function portraitAssetSlugsForKey(key: string) {
|
||||
const slug = portraitSlugForKey(key);
|
||||
const prefix = `${slug}-`;
|
||||
return Object.keys(portraitAssets)
|
||||
const latestByVariant = new Map<string, { slug: string; version: number }>();
|
||||
|
||||
Object.keys(portraitAssets)
|
||||
.filter((candidate) => candidate === slug || candidate.startsWith(prefix))
|
||||
.sort()
|
||||
.map((candidate) => ({
|
||||
textureKey: `portrait-${candidate}`,
|
||||
url: portraitAssets[candidate]
|
||||
}));
|
||||
.forEach((candidate) => {
|
||||
// Only a trailing numeric -vN marks a revision; other suffixes remain distinct portrait variants.
|
||||
const versionMatch = candidate.match(/^(.*)-v([1-9]\d*)$/);
|
||||
const variant = versionMatch?.[1] ?? candidate;
|
||||
const version = versionMatch ? Number.parseInt(versionMatch[2], 10) : 0;
|
||||
const latest = latestByVariant.get(variant);
|
||||
|
||||
if (!latest || version > latest.version) {
|
||||
latestByVariant.set(variant, { slug: candidate, version });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(latestByVariant.values(), ({ slug: candidate }) => candidate).sort();
|
||||
}
|
||||
|
||||
export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] {
|
||||
return portraitAssetSlugsForKey(key).map((candidate) => ({
|
||||
textureKey: `portrait-${candidate}`,
|
||||
url: portraitAssets[candidate]
|
||||
}));
|
||||
}
|
||||
|
||||
export function portraitAssetEntriesForStoryContext(key: string, context: StoryPortraitContext) {
|
||||
const entries = portraitAssetEntriesForKey(key);
|
||||
if (portraitSlugForKey(key) !== 'zhuge-liang' || entries.length <= 1) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const variant = zhugeLiangStoryVariant(context);
|
||||
const textureKeyPrefix = variant === 'base'
|
||||
? 'portrait-zhuge-liang'
|
||||
: `portrait-zhuge-liang-${variant}`;
|
||||
const preferredEntries = entries.filter(({ textureKey }) =>
|
||||
textureKey === textureKeyPrefix ||
|
||||
(textureKey.startsWith(textureKeyPrefix) && /^-v[1-9]\d*$/.test(textureKey.slice(textureKeyPrefix.length)))
|
||||
);
|
||||
return preferredEntries.length > 0 ? preferredEntries : entries;
|
||||
}
|
||||
|
||||
export function portraitTextureKeysForKey(key: string) {
|
||||
const slug = portraitSlugForKey(key);
|
||||
const prefix = `${slug}-`;
|
||||
const slugs = Object.keys(portraitAssets)
|
||||
.filter((candidate) => candidate === slug || candidate.startsWith(prefix))
|
||||
.sort();
|
||||
|
||||
return Array.from(new Set(slugs.map((candidate) => `portrait-${candidate}`)));
|
||||
return portraitAssetSlugsForKey(key).map((candidate) => `portrait-${candidate}`);
|
||||
}
|
||||
|
||||
export function portraitKeyForSpeaker(speaker?: string) {
|
||||
return speaker ? speakerPortraitKeys[speaker] : undefined;
|
||||
}
|
||||
|
||||
function zhugeLiangStoryVariant({ id, background }: StoryPortraitContext) {
|
||||
if (background === 'story-red-cliffs' || id === 'eighteenth-red-cliffs-wind') {
|
||||
return 'redcliffs';
|
||||
}
|
||||
if (background === 'story-wolong') {
|
||||
return 'base';
|
||||
}
|
||||
if (
|
||||
id === 'northern-prep-back-secured' ||
|
||||
id.startsWith('fifty-fifth-') ||
|
||||
zhugeLiangNorthernBackgrounds.has(background)
|
||||
) {
|
||||
return 'northern';
|
||||
}
|
||||
return 'campaign';
|
||||
}
|
||||
|
||||
17
src/game/data/unitActionAssets.ts
Normal file
17
src/game/data/unitActionAssets.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
const unitActionSheetModules = import.meta.glob('../../assets/images/units/unit-*-actions.webp', {
|
||||
eager: true,
|
||||
import: 'default'
|
||||
}) as Record<string, string>;
|
||||
|
||||
function textureKeyFromPath(path: string) {
|
||||
const fileName = path.split('/').pop() ?? path;
|
||||
return fileName.replace(/\.(?:png|webp)$/i, '');
|
||||
}
|
||||
|
||||
const unitActionSheetUrls = new Map(
|
||||
Object.entries(unitActionSheetModules).map(([path, url]) => [textureKeyFromPath(path), url])
|
||||
);
|
||||
|
||||
export function unitActionSheetUrlForKey(actionKey: string) {
|
||||
return unitActionSheetUrls.get(actionKey);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ type UnitSheetAsset = {
|
||||
frameSize: number;
|
||||
format: 'png' | 'webp';
|
||||
actionKey: string;
|
||||
actionUrl: string;
|
||||
actionFrameSize: number;
|
||||
actionFormat: 'png' | 'webp';
|
||||
};
|
||||
@@ -41,7 +40,10 @@ export type UnitActionSheetLoadOptions = {
|
||||
|
||||
const defaultUnitActionSheetWatchdogMs = 15_000;
|
||||
|
||||
const unitWebpSheetModules = import.meta.glob('../../assets/images/units/unit-*.webp', {
|
||||
const unitWebpSheetModules = import.meta.glob([
|
||||
'../../assets/images/units/unit-*.webp',
|
||||
'!../../assets/images/units/unit-*-actions.webp'
|
||||
], {
|
||||
eager: true,
|
||||
import: 'default'
|
||||
}) as Record<string, string>;
|
||||
@@ -62,29 +64,16 @@ const unitSheetEntries = Object.entries(unitSheetModules).map(([path, url]) => (
|
||||
url,
|
||||
format: unitSheetFormatFromPath(path)
|
||||
}));
|
||||
const unitSheetEntriesByKey = new Map(unitSheetEntries.map((entry) => [entry.key, entry]));
|
||||
|
||||
const unitSheets = unitSheetEntries
|
||||
.filter(({ key }) => !key.endsWith('-actions'))
|
||||
.map(({ key, url }) => {
|
||||
const actionKey = `${key}-actions`;
|
||||
const actionEntry = unitSheetEntriesByKey.get(actionKey);
|
||||
return actionEntry
|
||||
? {
|
||||
key,
|
||||
url,
|
||||
frameSize: unitActionAssetPolicy.optimizedFrameSize,
|
||||
format: unitActionAssetPolicy.format,
|
||||
actionKey,
|
||||
actionUrl: actionEntry.url,
|
||||
actionFrameSize: actionEntry.format === unitActionAssetPolicy.format
|
||||
? unitActionAssetPolicy.optimizedFrameSize
|
||||
: unitSheetFrameSize,
|
||||
actionFormat: actionEntry.format
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((sheet): sheet is UnitSheetAsset => Boolean(sheet))
|
||||
.map(({ key, url, format }) => ({
|
||||
key,
|
||||
url,
|
||||
frameSize: unitActionAssetPolicy.optimizedFrameSize,
|
||||
format,
|
||||
actionKey: `${key}-actions`,
|
||||
actionFrameSize: unitActionAssetPolicy.optimizedFrameSize,
|
||||
actionFormat: 'webp' as const
|
||||
}))
|
||||
.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
const unitSheetAssetsByKey = new Map(unitSheets.map((sheet) => [sheet.key, sheet]));
|
||||
@@ -118,13 +107,18 @@ export function unitBaseSheetAssetInfo(key: string): UnitBaseSheetAssetInfo | un
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function unitActionSheetAssetInfo(key: string): UnitActionSheetAssetInfo | undefined {
|
||||
export async function unitActionSheetAssetInfo(key: string): Promise<UnitActionSheetAssetInfo | undefined> {
|
||||
const sheet = unitSheetAssetsByKey.get(key);
|
||||
return sheet
|
||||
if (!sheet) {
|
||||
return undefined;
|
||||
}
|
||||
const { unitActionSheetUrlForKey } = await import('./unitActionAssets');
|
||||
const url = unitActionSheetUrlForKey(sheet.actionKey);
|
||||
return url
|
||||
? {
|
||||
key: sheet.key,
|
||||
actionKey: sheet.actionKey,
|
||||
url: sheet.actionUrl,
|
||||
url,
|
||||
frameSize: sheet.actionFrameSize,
|
||||
format: sheet.actionFormat
|
||||
}
|
||||
@@ -278,12 +272,12 @@ export function loadUnitActionSheets(
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingLoads: Array<{ key: string; url: string; frameSize: number }> = [];
|
||||
const pendingLoads: Array<{ key: string; frameSize: number }> = [];
|
||||
uniqueKeys.forEach((key) => {
|
||||
const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key);
|
||||
if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) {
|
||||
pendingUnitTextureKeys.add(sheet.actionKey);
|
||||
pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl, frameSize: sheet.actionFrameSize });
|
||||
pendingLoads.push({ key: sheet.actionKey, frameSize: sheet.actionFrameSize });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -328,7 +322,11 @@ export function loadUnitActionSheets(
|
||||
const actionKey = `${key}-actions`;
|
||||
return scene.textures.exists(actionKey) || failedActionKeys.has(actionKey);
|
||||
});
|
||||
const handleComplete = () => settle();
|
||||
const handleComplete = () => {
|
||||
if (allRequestsSettled()) {
|
||||
settle();
|
||||
}
|
||||
};
|
||||
const handleLoadError = (file: Phaser.Loader.File) => {
|
||||
const key = String(file.key);
|
||||
if (!actionKeys.has(key)) {
|
||||
@@ -342,8 +340,6 @@ export function loadUnitActionSheets(
|
||||
const handleShutdown = () => settle(false);
|
||||
|
||||
scene.events.once('shutdown', handleShutdown);
|
||||
scene.load.on('complete', handleComplete);
|
||||
scene.load.on('loaderror', handleLoadError);
|
||||
pollTimer = setInterval(() => {
|
||||
if (unitActionSheetsReady(scene, uniqueKeys)) {
|
||||
settle();
|
||||
@@ -355,14 +351,39 @@ export function loadUnitActionSheets(
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLoads.forEach(({ key, url, frameSize }) => {
|
||||
scene.load.spritesheet(key, url, {
|
||||
frameWidth: frameSize,
|
||||
frameHeight: frameSize
|
||||
void import('./unitActionAssets').then(({ unitActionSheetUrlForKey }) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedLoads = pendingLoads.flatMap(({ key, frameSize }) => {
|
||||
const url = unitActionSheetUrlForKey(key);
|
||||
if (!url) {
|
||||
failedActionKeys.add(key);
|
||||
return [];
|
||||
}
|
||||
return [{ key, url, frameSize }];
|
||||
});
|
||||
|
||||
if (resolvedLoads.length <= 0) {
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
|
||||
scene.load.on('complete', handleComplete);
|
||||
scene.load.on('loaderror', handleLoadError);
|
||||
resolvedLoads.forEach(({ key, url, frameSize }) => {
|
||||
scene.load.spritesheet(key, url, {
|
||||
frameWidth: frameSize,
|
||||
frameHeight: frameSize
|
||||
});
|
||||
});
|
||||
ownedLoaderFiles = captureLoaderFiles(scene, resolvedLoads.map(({ key }) => key));
|
||||
scene.load.start();
|
||||
}).catch(() => {
|
||||
pendingLoads.forEach(({ key }) => failedActionKeys.add(key));
|
||||
settle();
|
||||
});
|
||||
ownedLoaderFiles = captureLoaderFiles(scene, pendingLoads.map(({ key }) => key));
|
||||
scene.load.start();
|
||||
}
|
||||
|
||||
function normalizeUnitSheetWatchdogMs(value: number | undefined) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences';
|
||||
import { battleMapAssets } from '../data/battleMapAssets';
|
||||
import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
|
||||
import {
|
||||
@@ -3434,7 +3435,7 @@ const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
|
||||
situation: '전황',
|
||||
speed: '속도',
|
||||
presentation: '전투 연출',
|
||||
bgm: 'BGM',
|
||||
bgm: '음향',
|
||||
close: '닫기'
|
||||
};
|
||||
|
||||
@@ -12703,10 +12704,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.renderSituationPanel(message);
|
||||
this.publishFirstBattleReport(outcome);
|
||||
const revealResult = () => {
|
||||
soundDirector.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', {
|
||||
volume: outcome === 'victory' ? 0.34 : 0.26,
|
||||
stopAfterMs: 1200
|
||||
});
|
||||
soundDirector.playBattleOutcome(outcome);
|
||||
this.showBattleResult(outcome);
|
||||
};
|
||||
if (resultDelayMs > 0) {
|
||||
@@ -16528,7 +16526,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.phase === 'idle' && this.activeFaction === 'ally' && this.turnNumber === 1) {
|
||||
if (
|
||||
this.phase === 'idle' &&
|
||||
this.activeFaction === 'ally' &&
|
||||
this.turnNumber === 1 &&
|
||||
this.battleEventObjects.length === 0
|
||||
) {
|
||||
this.startFirstBattleTutorial();
|
||||
return;
|
||||
}
|
||||
@@ -16536,7 +16539,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.time.delayedCall(180, () => attemptStart(attemptsRemaining - 1));
|
||||
}
|
||||
};
|
||||
attemptStart(12);
|
||||
attemptStart(30);
|
||||
}
|
||||
|
||||
private startFirstBattleTutorial() {
|
||||
@@ -17788,8 +17791,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
if (action === 'bgm') {
|
||||
soundDirector.setMuted(!soundDirector.isMuted());
|
||||
this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
|
||||
const muted = !soundDirector.isMuted();
|
||||
soundDirector.setMuted(muted);
|
||||
saveAudioPreferences({ ...loadAudioPreferences(), muted });
|
||||
this.renderSituationPanel(`음향을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19458,16 +19463,21 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
const rangedAttack = this.isRangedAttack(result);
|
||||
if (result.action === 'strategy') {
|
||||
soundDirector.playStrategyPulse();
|
||||
} else if (result.action === 'item') {
|
||||
soundDirector.playItemLaunch();
|
||||
} else if (result.attacker.classKey === 'archer' || this.attackRange(result.attacker) > 1) {
|
||||
} else if (rangedAttack) {
|
||||
soundDirector.playArrowShot();
|
||||
soundDirector.playArrowImpact(result.hit, result.critical);
|
||||
} else {
|
||||
soundDirector.playMeleeSwing(result.hit, result.critical);
|
||||
}
|
||||
if (rangedAttack) {
|
||||
soundDirector.playArrowImpact(result.hit, result.critical);
|
||||
} else if (result.hit) {
|
||||
soundDirector.playCombatImpact(result.critical, result.action === 'strategy');
|
||||
}
|
||||
|
||||
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.2, this.layout.tileSize * 0.28);
|
||||
ring.setStrokeStyle(result.critical || result.defeated ? 6 : 4, accent, 0.94);
|
||||
@@ -19714,14 +19724,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
groundY,
|
||||
depth + 10,
|
||||
defenderDirection,
|
||||
stage.shadows[0]
|
||||
stage.shadows[0],
|
||||
() => {
|
||||
if (result.hit) {
|
||||
this.showCombatImpact(result, defenderX, groundY - 46, depth + 14);
|
||||
void this.showSortieDefenseCombatEffect(result, defenderX, groundY - 70, depth + 17);
|
||||
} else {
|
||||
this.showCombatMiss(defenderX, groundY - 70, depth + 14);
|
||||
}
|
||||
}
|
||||
);
|
||||
if (result.hit) {
|
||||
this.showCombatImpact(result, defenderX, groundY - 46, depth + 14);
|
||||
void this.showSortieDefenseCombatEffect(result, defenderX, groundY - 70, depth + 17);
|
||||
} else {
|
||||
this.showCombatMiss(defenderX, groundY - 70, depth + 14);
|
||||
}
|
||||
const outcomeCard = this.renderCombatOutcomeCard(result, left + panelWidth / 2 - 226, top + 184, 452, depth + 16);
|
||||
|
||||
const hpAfterBaseAttack = result.bondChain?.previousDefenderHp ?? result.defender.hp;
|
||||
@@ -21138,7 +21150,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
groundY: number,
|
||||
depth: number,
|
||||
defenderDirection: UnitDirection,
|
||||
attackerShadow?: Phaser.GameObjects.Ellipse
|
||||
attackerShadow?: Phaser.GameObjects.Ellipse,
|
||||
onContact?: () => void
|
||||
) {
|
||||
const direction = attackerX <= defenderX ? 1 : -1;
|
||||
if (result.action === 'attack') {
|
||||
@@ -21153,7 +21166,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
depth,
|
||||
defenderDirection,
|
||||
direction,
|
||||
attackerShadow
|
||||
attackerShadow,
|
||||
onContact
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -21168,12 +21182,24 @@ export class BattleScene extends Phaser.Scene {
|
||||
depth,
|
||||
defenderDirection,
|
||||
direction,
|
||||
attackerShadow
|
||||
attackerShadow,
|
||||
onContact
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.playSpecialActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction);
|
||||
await this.playSpecialActionMotion(
|
||||
result,
|
||||
attackerSprite,
|
||||
defenderSprite,
|
||||
attackerX,
|
||||
defenderX,
|
||||
groundY,
|
||||
depth,
|
||||
defenderDirection,
|
||||
direction,
|
||||
onContact
|
||||
);
|
||||
}
|
||||
|
||||
private isRangedAttack(result: Pick<CombatResult, 'action' | 'attacker'>) {
|
||||
@@ -21190,7 +21216,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
depth: number,
|
||||
defenderDirection: UnitDirection,
|
||||
direction: number,
|
||||
attackerShadow?: Phaser.GameObjects.Ellipse
|
||||
attackerShadow?: Phaser.GameObjects.Ellipse,
|
||||
onContact?: () => void
|
||||
) {
|
||||
const mounted = result.attacker.classKey === 'cavalry';
|
||||
const windupX = attackerX - direction * 28;
|
||||
@@ -21244,6 +21271,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
await this.delay(mounted ? 170 : 205);
|
||||
|
||||
soundDirector.playMeleeSwing(result.hit, result.critical);
|
||||
onContact?.();
|
||||
this.setUnitActionFrame(attackerSprite, result.attacker, 'east', 'attack', this.unitActionImpactFrame('attack'));
|
||||
this.createSlashArc(defenderX - direction * 68, groundY - 48, direction, depth + 2, result.critical);
|
||||
|
||||
@@ -21298,7 +21326,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
depth: number,
|
||||
defenderDirection: UnitDirection,
|
||||
direction: number,
|
||||
attackerShadow?: Phaser.GameObjects.Ellipse
|
||||
attackerShadow?: Phaser.GameObjects.Ellipse,
|
||||
onContact?: () => void
|
||||
) {
|
||||
void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', 'attack', 88);
|
||||
this.createBowDrawEffect(attackerX + direction * 44, groundY - 52, direction, depth + 2);
|
||||
@@ -21334,6 +21363,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
ease: result.hit ? 'Cubic.easeIn' : 'Sine.easeOut'
|
||||
});
|
||||
await this.delay(result.hit ? 332 : 382);
|
||||
onContact?.();
|
||||
|
||||
if (result.hit) {
|
||||
void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70);
|
||||
@@ -21367,7 +21397,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
groundY: number,
|
||||
depth: number,
|
||||
defenderDirection: UnitDirection,
|
||||
direction: number
|
||||
direction: number,
|
||||
onContact?: () => void
|
||||
) {
|
||||
const motionKind = this.specialDamageMotionKind(result);
|
||||
if (result.action === 'item') {
|
||||
@@ -21402,6 +21433,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
ease: 'Cubic.easeIn'
|
||||
});
|
||||
await this.delay(450);
|
||||
onContact?.();
|
||||
if (result.hit) {
|
||||
void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70);
|
||||
this.createSpecialImpact(defenderX - direction * 68, this.specialImpactY(motionKind, groundY), depth + 4, motionKind);
|
||||
@@ -22039,9 +22071,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
bondChain: undefined,
|
||||
counter: undefined
|
||||
};
|
||||
const contactDelay = motion === 'ranged'
|
||||
? this.scaledBattleDuration(150) + this.scaledBattleDuration(332, 90)
|
||||
: this.scaledBattleDuration(115) + this.scaledBattleDuration(partner.classKey === 'cavalry' ? 170 : 205);
|
||||
let resolveContact: (() => void) | undefined;
|
||||
const contactPromise = new Promise<void>((resolve) => {
|
||||
resolveContact = resolve;
|
||||
});
|
||||
const motionPromise = this.playCombatActionMotion(
|
||||
followUpResult,
|
||||
supporterSprite,
|
||||
@@ -22051,12 +22084,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
groundY,
|
||||
depth + 1,
|
||||
'west',
|
||||
supporterShadow
|
||||
supporterShadow,
|
||||
() => {
|
||||
onImpact?.();
|
||||
this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5);
|
||||
resolveContact?.();
|
||||
}
|
||||
);
|
||||
await new Promise<void>((resolve) => this.time.delayedCall(contactDelay, resolve));
|
||||
|
||||
onImpact?.();
|
||||
this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5);
|
||||
await contactPromise;
|
||||
|
||||
const chainTitle = chain.coreResonance ? '핵심 공명' : '공명';
|
||||
const finishTitle = chain.defeated ? `${chainTitle} 격파 · ${chain.partnerName}` : `${chainTitle} 추격 · ${chain.partnerName}`;
|
||||
@@ -22245,7 +22280,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (this.isRangedAttack(result)) {
|
||||
soundDirector.playArrowImpact(true, result.critical);
|
||||
} else {
|
||||
soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 });
|
||||
soundDirector.playCombatImpact(result.critical, result.action === 'strategy');
|
||||
}
|
||||
|
||||
const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, result.critical ? 54 : 42, this.combatImpactColor(result), 0.92));
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
loadBattleUiIcons,
|
||||
type BattleUiIconKey
|
||||
} from '../data/battleUiIcons';
|
||||
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
|
||||
import {
|
||||
portraitAssetEntriesForKey,
|
||||
portraitAssetEntriesForStoryContext,
|
||||
portraitKeyForSpeaker,
|
||||
type PortraitAssetEntry
|
||||
} from '../data/portraitAssets';
|
||||
import {
|
||||
storyBackgroundAssets,
|
||||
storyBackgroundKeyForPage,
|
||||
@@ -60,6 +65,13 @@ const dialogDepth = 20;
|
||||
const fhdUiScale = 1.5;
|
||||
const ui = (value: number) => value * fhdUiScale;
|
||||
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
|
||||
const storyDialogLayout = {
|
||||
sideInset: 72,
|
||||
topOffset: 270,
|
||||
height: 204,
|
||||
nameTopOffset: 261,
|
||||
bodyTopOffset: 225
|
||||
} as const;
|
||||
const storyUnitFrameRows: Record<UnitDirection, number> = {
|
||||
south: 0,
|
||||
east: 1,
|
||||
@@ -183,8 +195,16 @@ export class StoryScene extends Phaser.Scene {
|
||||
|
||||
getDebugState() {
|
||||
const page = this.pages[this.pageIndex];
|
||||
const bodyBounds = this.bodyText?.active ? this.bodyText.getBounds() : undefined;
|
||||
const progressBounds = this.progressText?.active ? this.progressText.getBounds() : undefined;
|
||||
const progressPanelBounds = this.progressBackground?.active ? this.progressBackground.getBounds() : undefined;
|
||||
const cutsceneStageBounds = page?.cutscene ? this.cutsceneStageBounds() : undefined;
|
||||
const dialogPanelBounds = {
|
||||
x: ui(storyDialogLayout.sideInset),
|
||||
y: this.scale.height - ui(storyDialogLayout.topOffset),
|
||||
width: this.scale.width - ui(storyDialogLayout.sideInset * 2),
|
||||
height: ui(storyDialogLayout.height)
|
||||
};
|
||||
const isLastPage = this.pageIndex >= this.pages.length - 1;
|
||||
|
||||
return {
|
||||
@@ -199,6 +219,11 @@ export class StoryScene extends Phaser.Scene {
|
||||
activeTexture: this.background?.texture.key ?? null,
|
||||
portraitKey: page ? this.pagePortraitKey(page) ?? null : null,
|
||||
portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null,
|
||||
bodyBounds: bodyBounds
|
||||
? { x: bodyBounds.x, y: bodyBounds.y, width: bodyBounds.width, height: bodyBounds.height }
|
||||
: null,
|
||||
dialogPanelBounds,
|
||||
cutsceneStageBounds: cutsceneStageBounds ?? null,
|
||||
progressLabel: this.progressText?.text ?? '',
|
||||
progressBounds: progressBounds
|
||||
? { x: progressBounds.x, y: progressBounds.y, width: progressBounds.width, height: progressBounds.height }
|
||||
@@ -556,10 +581,10 @@ export class StoryScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private drawDialogPanel(width: number, height: number) {
|
||||
const panelY = height - ui(238);
|
||||
const panelX = ui(72);
|
||||
const panelW = width - ui(144);
|
||||
const panelH = ui(172);
|
||||
const panelY = height - ui(storyDialogLayout.topOffset);
|
||||
const panelX = ui(storyDialogLayout.sideInset);
|
||||
const panelW = width - ui(storyDialogLayout.sideInset * 2);
|
||||
const panelH = ui(storyDialogLayout.height);
|
||||
|
||||
const panel = this.add.graphics();
|
||||
panel.setDepth(dialogDepth);
|
||||
@@ -595,11 +620,11 @@ export class StoryScene extends Phaser.Scene {
|
||||
});
|
||||
this.chapterText.setDepth(dialogDepth + 2);
|
||||
|
||||
this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + ui(86), ui(136), ui(136), cutsceneUiColors.ink, 0.92);
|
||||
this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + panelH / 2, ui(136), ui(136), cutsceneUiColors.ink, 0.92);
|
||||
this.portraitFrame.setStrokeStyle(ui(2), palette.gold, 0.34);
|
||||
this.portraitFrame.setDepth(dialogDepth + 2);
|
||||
|
||||
this.portrait = this.add.image(panelX + ui(86), panelY + ui(86), this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback');
|
||||
this.portrait = this.add.image(panelX + ui(86), panelY + panelH / 2, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback');
|
||||
this.portrait.setDisplaySize(ui(128), ui(128));
|
||||
this.portrait.setDepth(dialogDepth + 3);
|
||||
|
||||
@@ -615,7 +640,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
});
|
||||
this.nameText.setDepth(dialogDepth + 2);
|
||||
|
||||
this.bodyText = this.add.text(panelX + ui(198), panelY + ui(78), '', {
|
||||
this.bodyText = this.add.text(panelX + ui(198), panelY + ui(45), '', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
|
||||
fontSize: uiPx(25),
|
||||
color: '#e8dfca',
|
||||
@@ -704,15 +729,15 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.portraitDivider?.setVisible(true);
|
||||
this.portrait?.setTexture(textureKey);
|
||||
this.portrait?.setDisplaySize(ui(128), ui(128));
|
||||
this.nameText?.setPosition(ui(270), this.scale.height - ui(210));
|
||||
this.bodyText?.setPosition(ui(270), this.scale.height - ui(160));
|
||||
this.nameText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.nameTopOffset));
|
||||
this.bodyText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.bodyTopOffset));
|
||||
this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true);
|
||||
} else {
|
||||
this.portraitFrame?.setVisible(false);
|
||||
this.portrait?.setVisible(false);
|
||||
this.portraitDivider?.setVisible(false);
|
||||
this.nameText?.setPosition(ui(96), this.scale.height - ui(210));
|
||||
this.bodyText?.setPosition(ui(96), this.scale.height - ui(160));
|
||||
this.nameText?.setPosition(ui(96), this.scale.height - ui(storyDialogLayout.nameTopOffset));
|
||||
this.bodyText?.setPosition(ui(96), this.scale.height - ui(storyDialogLayout.bodyTopOffset));
|
||||
this.bodyText?.setWordWrapWidth(this.scale.width - ui(200), true);
|
||||
}
|
||||
|
||||
@@ -752,7 +777,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = portraitAssetEntriesForKey(portraitKey);
|
||||
const entries = portraitAssetEntriesForStoryContext(portraitKey, page);
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
nextCombatPresentationMode,
|
||||
saveCombatPresentationMode
|
||||
} from '../settings/combatPresentation';
|
||||
import {
|
||||
audioLevelLabel,
|
||||
loadAudioPreferences,
|
||||
nextAudioLevel,
|
||||
saveAudioPreferences,
|
||||
updateAudioPreference
|
||||
} from '../settings/audioPreferences';
|
||||
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
|
||||
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
|
||||
import {
|
||||
@@ -49,6 +56,14 @@ export class TitleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
const debugStoryPageId = this.debugStoryPageId();
|
||||
if (debugStoryPageId) {
|
||||
this.time.delayedCall(0, () => {
|
||||
void this.openDebugStoryPage(debugStoryPageId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldOpenDebugSortiePrep()) {
|
||||
this.time.delayedCall(0, () => {
|
||||
void this.navigateTo('CampScene', {
|
||||
@@ -98,6 +113,48 @@ export class TitleScene extends Phaser.Scene {
|
||||
return params.get('debugBattle')?.trim() || undefined;
|
||||
}
|
||||
|
||||
private debugStoryPageId() {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.has('debugStory') || (!params.has('debug') && !import.meta.env.DEV)) {
|
||||
return undefined;
|
||||
}
|
||||
return params.get('debugStory')?.trim() || undefined;
|
||||
}
|
||||
|
||||
private async openDebugStoryPage(pageId: string) {
|
||||
const scenarioModule = await import('../data/scenario');
|
||||
let selectedPage: unknown;
|
||||
|
||||
for (const value of Object.values(scenarioModule)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
selectedPage = value.find(
|
||||
(candidate) =>
|
||||
candidate &&
|
||||
typeof candidate === 'object' &&
|
||||
'id' in candidate &&
|
||||
candidate.id === pageId
|
||||
);
|
||||
if (selectedPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedPage) {
|
||||
console.error(`Unknown debug story page: ${pageId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.navigateTo('StoryScene', {
|
||||
pages: [selectedPage],
|
||||
nextScene: 'TitleScene'
|
||||
});
|
||||
}
|
||||
|
||||
private drawBackground(width: number, height: number) {
|
||||
const background = this.add.image(width / 2, height / 2, 'title-taoyuan');
|
||||
const texture = this.textures.get('title-taoyuan').getSourceImage();
|
||||
@@ -396,27 +453,40 @@ export class TitleScene extends Phaser.Scene {
|
||||
this.closeNewGameConfirmPanel();
|
||||
this.closeSaveSlotPanel();
|
||||
|
||||
const panel = this.add.container(x, y + ui(238));
|
||||
const backdrop = this.add.rectangle(0, 0, ui(286), ui(250), 0x0e151d, 0.9);
|
||||
const panel = this.add.container(x, y + ui(126));
|
||||
const backdrop = this.add.rectangle(0, 0, ui(320), ui(420), 0x0b1118, 1);
|
||||
backdrop.setStrokeStyle(ui(1), palette.gold, 0.52);
|
||||
backdrop.setInteractive();
|
||||
|
||||
const title = this.add.text(0, -ui(96), '설정', {
|
||||
const title = this.add.text(0, -ui(150), '설정', {
|
||||
fontSize: uiPx(24),
|
||||
color: '#f1e3c2',
|
||||
fontStyle: '700',
|
||||
fixedWidth: ui(210),
|
||||
fixedWidth: ui(244),
|
||||
align: 'center'
|
||||
});
|
||||
title.setOrigin(0.5);
|
||||
|
||||
const soundText = this.createSettingsButton(0, -ui(38), this.soundLabel());
|
||||
const soundText = this.createSettingsButton(0, -ui(102), this.soundLabel());
|
||||
soundText.on('pointerdown', () => {
|
||||
soundDirector.setMuted(!soundDirector.isMuted());
|
||||
const preferences = loadAudioPreferences();
|
||||
const muted = !soundDirector.isMuted();
|
||||
soundDirector.setMuted(muted);
|
||||
saveAudioPreferences({ ...preferences, muted });
|
||||
soundText.setText(this.soundLabel());
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const presentationText = this.createSettingsButton(0, ui(16), this.combatPresentationLabel());
|
||||
const musicText = this.createSettingsButton(0, -ui(60), this.audioCategoryLabel('music'));
|
||||
musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText));
|
||||
|
||||
const effectsText = this.createSettingsButton(0, -ui(18), this.audioCategoryLabel('effects'));
|
||||
effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText));
|
||||
|
||||
const ambienceText = this.createSettingsButton(0, ui(24), this.audioCategoryLabel('ambience'));
|
||||
ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText));
|
||||
|
||||
const presentationText = this.createSettingsButton(0, ui(66), this.combatPresentationLabel());
|
||||
presentationText.on('pointerdown', () => {
|
||||
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
|
||||
saveCombatPresentationMode(nextMode);
|
||||
@@ -424,26 +494,26 @@ export class TitleScene extends Phaser.Scene {
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const close = this.createSettingsButton(0, ui(68), '닫기');
|
||||
const close = this.createSettingsButton(0, ui(110), '닫기');
|
||||
close.on('pointerdown', () => {
|
||||
soundDirector.playSelect();
|
||||
this.closeSettingsPanel();
|
||||
});
|
||||
|
||||
const hint = this.add.text(0, ui(104), '연출 항목을 눌러 단계 변경 · Esc 닫기', {
|
||||
const hint = this.add.text(0, ui(150), '항목을 눌러 단계 변경 · Esc 닫기', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: uiPx(11),
|
||||
color: '#9fb0bf',
|
||||
fontStyle: '700',
|
||||
fixedWidth: ui(210),
|
||||
fixedWidth: ui(244),
|
||||
align: 'center'
|
||||
});
|
||||
hint.setOrigin(0.5);
|
||||
|
||||
panel.add([backdrop, title, soundText, presentationText, close, hint]);
|
||||
panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, close, hint]);
|
||||
panel.setAlpha(0);
|
||||
this.settingsPanel = panel;
|
||||
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(228), duration: 160, ease: 'Sine.easeOut' });
|
||||
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(116), duration: 160, ease: 'Sine.easeOut' });
|
||||
}
|
||||
|
||||
private createSettingsButton(x: number, y: number, label: string) {
|
||||
@@ -466,6 +536,25 @@ export class TitleScene extends Phaser.Scene {
|
||||
return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`;
|
||||
}
|
||||
|
||||
private audioCategoryLabel(category: 'music' | 'effects' | 'ambience') {
|
||||
const labels = {
|
||||
music: '음악',
|
||||
effects: '효과음',
|
||||
ambience: '환경음'
|
||||
} as const;
|
||||
return `${labels[category]}: ${audioLevelLabel(loadAudioPreferences()[category])}`;
|
||||
}
|
||||
|
||||
private cycleAudioLevel(category: 'music' | 'effects' | 'ambience', text: Phaser.GameObjects.Text) {
|
||||
const preferences = loadAudioPreferences();
|
||||
const nextLevel = nextAudioLevel(preferences[category]);
|
||||
const nextPreferences = updateAudioPreference(preferences, category, nextLevel);
|
||||
saveAudioPreferences(nextPreferences);
|
||||
soundDirector.setCategoryMultiplier(category, nextLevel);
|
||||
text.setText(this.audioCategoryLabel(category));
|
||||
soundDirector.playSelect();
|
||||
}
|
||||
|
||||
private combatPresentationLabel() {
|
||||
return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`;
|
||||
}
|
||||
|
||||
89
src/game/settings/audioPreferences.ts
Normal file
89
src/game/settings/audioPreferences.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { AudioCategory, AudioCategoryMultipliers } from '../audio/SoundDirector';
|
||||
|
||||
export const audioPreferencesStorageKey = 'heros-web:audio-preferences';
|
||||
|
||||
export const audioLevelSteps = [1, 0.75, 0.5, 0.25] as const;
|
||||
|
||||
export type AudioPreferences = AudioCategoryMultipliers & {
|
||||
muted: boolean;
|
||||
};
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
||||
|
||||
export const defaultAudioPreferences: AudioPreferences = {
|
||||
muted: false,
|
||||
music: 1,
|
||||
effects: 1,
|
||||
ambience: 1
|
||||
};
|
||||
|
||||
export function loadAudioPreferences(storage = resolveStorage()): AudioPreferences {
|
||||
if (!storage) {
|
||||
return { ...defaultAudioPreferences };
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(storage.getItem(audioPreferencesStorageKey) ?? 'null') as Partial<AudioPreferences> | null;
|
||||
if (!stored || typeof stored !== 'object') {
|
||||
return { ...defaultAudioPreferences };
|
||||
}
|
||||
|
||||
return {
|
||||
muted: typeof stored.muted === 'boolean' ? stored.muted : defaultAudioPreferences.muted,
|
||||
music: normalizeAudioLevel(stored.music, defaultAudioPreferences.music),
|
||||
effects: normalizeAudioLevel(stored.effects, defaultAudioPreferences.effects),
|
||||
ambience: normalizeAudioLevel(stored.ambience, defaultAudioPreferences.ambience)
|
||||
};
|
||||
} catch {
|
||||
return { ...defaultAudioPreferences };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAudioPreferences(preferences: AudioPreferences, storage = resolveStorage()) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(audioPreferencesStorageKey, JSON.stringify(preferences));
|
||||
} catch {
|
||||
// Local storage can be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function nextAudioLevel(level: number) {
|
||||
const index = audioLevelSteps.indexOf(level as (typeof audioLevelSteps)[number]);
|
||||
return audioLevelSteps[(index + 1) % audioLevelSteps.length];
|
||||
}
|
||||
|
||||
export function updateAudioPreference(
|
||||
preferences: AudioPreferences,
|
||||
category: AudioCategory,
|
||||
level: number
|
||||
): AudioPreferences {
|
||||
return {
|
||||
...preferences,
|
||||
[category]: normalizeAudioLevel(level, preferences[category])
|
||||
};
|
||||
}
|
||||
|
||||
export function audioLevelLabel(level: number) {
|
||||
return `${Math.round(normalizeAudioLevel(level, 1) * 100)}%`;
|
||||
}
|
||||
|
||||
function normalizeAudioLevel(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && audioLevelSteps.includes(value as (typeof audioLevelSteps)[number])
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function resolveStorage(): StorageLike | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import Phaser from 'phaser';
|
||||
import { ambienceTracks, effectTracks, musicTracks } from './game/audio/audioAssets';
|
||||
import { ambienceTracks, effectPools, effectTracks, musicTracks } from './game/audio/audioAssets';
|
||||
import { soundDirector } from './game/audio/SoundDirector';
|
||||
import { BootScene } from './game/scenes/BootScene';
|
||||
import { startLazySceneFromGame } from './game/scenes/lazyScenes';
|
||||
import { TitleScene } from './game/scenes/TitleScene';
|
||||
import { loadAudioPreferences } from './game/settings/audioPreferences';
|
||||
import './styles/global.css';
|
||||
|
||||
declare global {
|
||||
@@ -63,6 +64,10 @@ const config: Phaser.Types.Core.GameConfig = {
|
||||
soundDirector.registerMusicTracks(musicTracks);
|
||||
soundDirector.registerAmbienceTracks(ambienceTracks);
|
||||
soundDirector.registerEffectTracks(effectTracks);
|
||||
soundDirector.registerEffectPools(effectPools);
|
||||
const audioPreferences = loadAudioPreferences();
|
||||
soundDirector.setMuted(audioPreferences.muted);
|
||||
soundDirector.setCategoryMultipliers(audioPreferences);
|
||||
|
||||
const game = new Phaser.Game(config);
|
||||
const debugEnabled = import.meta.env.DEV || searchParams.has('debug');
|
||||
|
||||
Reference in New Issue
Block a user