feat: improve portraits and combat audio

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}"`);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

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

View File

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

View File

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

View 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);
}

View File

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

View File

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

View File

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

View File

@@ -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())}`;
}

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

View File

@@ -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');