feat: refine exploration presentation and audio flow

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

View File

@@ -63,11 +63,22 @@ const server = await createServer({
});
try {
const { campMusicGroups, campAmbienceDefinitions, campSoundscapeMapping, getCampSoundscape } =
const {
campMusicGroups,
campAmbienceDefinitions,
campSoundscapeMapping,
getCampSoundscape,
campMusicVolumeForTrackKey,
musicVolumeMatchedToCampTrack
} =
await server.ssrLoadModule('/src/game/data/campSoundscapes.ts');
const { selectCampSkin } = await server.ssrLoadModule('/src/game/data/campSkins.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const {
musicTracks,
ambienceTracks,
musicTrackIntegratedLoudnessLufs
} = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const expectedSkinIds = Object.keys(expectedMapping);
const musicEntries = Object.entries(campMusicGroups);
@@ -83,6 +94,38 @@ try {
validateDefinitions(musicEntries, expectedMusicGroups, musicTracks, 'music group', 0.05, 0.4);
validateDefinitions(ambienceEntries, expectedAmbiences, ambienceTracks, 'ambience', 0.03, 0.3);
musicEntries.forEach(([, definition]) => {
assertEqual(
campMusicVolumeForTrackKey(definition.trackKey),
definition.volume,
`${definition.trackKey}: shared exploration music volume`
);
});
assertEqual(
campMusicVolumeForTrackKey('unknown-track', 0.19),
0.19,
'unknown exploration music fallback volume'
);
assertEqual(
musicVolumeMatchedToCampTrack('militia-theme'),
0.124,
'militia theme volume matched to the camp-rally perceptual level'
);
const militiaEffectiveLoudness =
musicTrackIntegratedLoudnessLufs['militia-theme'] +
20 * Math.log10(musicVolumeMatchedToCampTrack('militia-theme'));
const campRallyEffectiveLoudness =
musicTrackIntegratedLoudnessLufs['camp-rally'] +
20 * Math.log10(campMusicVolumeForTrackKey('camp-rally'));
assert(
Math.abs(
militiaEffectiveLoudness - campRallyEffectiveLoudness
) <= 0.1,
`militia and camp-rally effective loudness must match within 0.1 LU; received ${(
militiaEffectiveLoudness - campRallyEffectiveLoudness
).toFixed(2)} LU`
);
validateExplorationSceneMixes();
validateCampAudioAssets(
musicEntries.map(([, definition]) => ({ key: definition.trackKey, url: musicTracks[definition.trackKey], kind: 'music' })),
ambienceEntries.map(([, definition]) => ({ key: definition.trackKey, url: ambienceTracks[definition.trackKey], kind: 'ambience' }))
@@ -143,6 +186,34 @@ try {
await server.close();
}
function validateExplorationSceneMixes() {
const citySource = readFileSync('src/game/scenes/CityStayScene.ts', 'utf8');
const villageSource = readFileSync('src/game/scenes/PrologueVillageScene.ts', 'utf8');
const campVisitSource = readFileSync(
'src/game/scenes/CampVisitExplorationScene.ts',
'utf8'
);
assert(
citySource.includes('musicVolume: campMusicVolumeForTrackKey(this.profile.musicKey)'),
'city stays must reuse the calibrated camp music level for their selected track'
);
assert(
villageSource.includes(
"musicVolume: musicVolumeMatchedToCampTrack('militia-theme')"
) &&
!villageSource.includes('musicVolume: 0.72') &&
!villageSource.includes('musicVolume: 0.22'),
'the prologue village must loudness-match its hotter source track to the calibrated camp mix'
);
assertEqual(
campVisitSource.match(
/musicVolume:\s*campMusicVolumeForTrackKey\('camp-rally'\)/g
)?.length ?? 0,
2,
'camp visits must reuse the calibrated camp-rally level in both exploration variants'
);
}
function validateDefinitions(entries, expectedDefinitions, registeredTracks, context, minimumVolume, maximumVolume) {
const expectedIds = Object.keys(expectedDefinitions);
const registeredKeys = new Set(Object.keys(registeredTracks));

View File

@@ -533,6 +533,13 @@ function verifyExplorationLayout(city, expectedCityStayId) {
assert.equal(city.progress.marketOptional, true);
assert.equal(city.progress.exitUnlocked, true);
assert.equal(city.actors.length, 3);
const companion = city.actors.find((actor) => actor.kind === 'dialogue');
assert(companion, `${expectedCityStayId} must expose its companion actor.`);
assert.equal(
city.objective?.dialogueUnitNames,
`유비 · ${companion.name}`,
`${expectedCityStayId} objective card must use authored Korean character names instead of roster or unit IDs.`
);
assert.deepEqual(
[...city.actors.map((actor) => actor.kind)].sort(),
['dialogue', 'information', 'market']
@@ -824,10 +831,18 @@ async function verifyCompanionResonance(page, cityCase, screenshotPath) {
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
));
const initialDialogueState = await readCity(page);
verifyActiveDialoguePortrait(
await readCity(page),
initialDialogueState,
`${cityCase.cityStayId} companion dialogue`
);
const initialCompanionFacing = initialDialogueState.actors.find(
(actor) => actor.kind === 'dialogue'
)?.animationKey;
assert(
initialCompanionFacing,
`${cityCase.cityStayId} companion must expose its facing animation during dialogue.`
);
await advanceDialogueUntilChoice(page);
const choiceState = await readCity(page);
verifyChoiceLayout(choiceState);
@@ -849,6 +864,21 @@ async function verifyCompanionResonance(page, cityCase, screenshotPath) {
});
const afterChoice = await readCity(page);
assert.equal(
afterChoice.dialogue.active,
true,
'Selecting a resonance response must open its acknowledgement dialogue.'
);
assert.equal(
afterChoice.dialogue.lineIndex,
0,
'The pointer event that selects a response must not also skip the first acknowledgement line.'
);
assert.equal(
afterChoice.actors.find((actor) => actor.kind === 'dialogue')?.animationKey,
initialCompanionFacing,
'The companion must turn back toward the player for the acknowledgement dialogue.'
);
assert.equal(
afterChoice.campaign.completedCampDialogues.filter((id) => id === cityCase.dialogueId).length,
1,

View File

@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const projectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const helperPath = path.join(
projectRoot,
'src/game/ui/dialoguePortrait.ts'
);
const helperSource = await readFile(helperPath, 'utf8');
const transpiledHelper = ts.transpileModule(helperSource, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
target: ts.ScriptTarget.ES2022
},
fileName: helperPath
}).outputText;
const helperModule = await import(
`data:text/javascript;base64,${Buffer.from(transpiledHelper).toString('base64')}`
);
const {
calculateDialogueFacePortraitLayout,
fitDialogueFacePortrait
} = helperModule;
const squareLayout = calculateDialogueFacePortraitLayout(
1254,
1254,
220
);
assert.deepEqual(
{
cropX: squareLayout.cropX,
cropY: squareLayout.cropY,
cropSize: squareLayout.cropSize
},
{
cropX: 251,
cropY: 44,
cropSize: 752
},
'The standard portrait must use the established square face-and-shoulders crop.'
);
assertCenteredCrop(squareLayout, 1254, 1254, 220);
const landscapeLayout = calculateDialogueFacePortraitLayout(
1600,
1000,
192
);
assert.deepEqual(
{
cropX: landscapeLayout.cropX,
cropY: landscapeLayout.cropY,
cropSize: landscapeLayout.cropSize
},
{
cropX: 500,
cropY: 35,
cropSize: 600
},
'Landscape sources must still produce a square, horizontally centered face crop.'
);
assertCenteredCrop(landscapeLayout, 1600, 1000, 192);
const portraitLayout = calculateDialogueFacePortraitLayout(
1000,
1600,
218
);
assert.deepEqual(
{
cropX: portraitLayout.cropX,
cropY: portraitLayout.cropY,
cropSize: portraitLayout.cropSize
},
{
cropX: 200,
cropY: 56,
cropSize: 600
},
'Portrait-oriented sources must preserve the upper face-and-shoulders framing.'
);
assertCenteredCrop(portraitLayout, 1000, 1600, 218);
assert.throws(
() => calculateDialogueFacePortraitLayout(0, 1254, 220),
RangeError,
'Invalid source dimensions must fail explicitly.'
);
assert.throws(
() => calculateDialogueFacePortraitLayout(1254, 1254, Number.NaN),
RangeError,
'Invalid display dimensions must fail explicitly.'
);
const imageCalls = [];
const image = {
frame: {
realWidth: 1254,
realHeight: 1254
},
setCrop(...values) {
imageCalls.push(['crop', ...values]);
return this;
},
setOrigin(...values) {
imageCalls.push(['origin', ...values]);
return this;
},
setScale(...values) {
imageCalls.push(['scale', ...values]);
return this;
}
};
const appliedLayout = fitDialogueFacePortrait(image, 220);
assert.deepEqual(
imageCalls.map(([operation]) => operation),
['crop', 'origin', 'scale'],
'Applying the layout must crop, center the crop origin, and then scale it.'
);
assert.deepEqual(
imageCalls[0],
[
'crop',
appliedLayout.cropX,
appliedLayout.cropY,
appliedLayout.cropSize,
appliedLayout.cropSize
]
);
assert.deepEqual(
imageCalls[1],
['origin', appliedLayout.originX, appliedLayout.originY]
);
assert.deepEqual(
imageCalls[2],
['scale', appliedLayout.scale]
);
await assertSceneIntegration(
'src/game/scenes/StoryScene.ts',
3
);
await assertSceneIntegration(
'src/game/scenes/CityStayScene.ts',
1
);
await assertSceneIntegration(
'src/game/scenes/PrologueVillageScene.ts',
1
);
await assertSceneIntegration(
'src/game/scenes/PrologueMilitiaCampScene.ts',
1
);
await assertSceneIntegration(
'src/game/scenes/CampVisitExplorationScene.ts',
1
);
console.log(
'Verified shared dialogue portrait face crops, centered origins, retained display sizes, and all story/exploration dialogue integrations.'
);
function assertCenteredCrop(
layout,
sourceWidth,
sourceHeight,
displaySize
) {
const centeredX =
layout.cropX +
layout.cropSize / 2 -
layout.originX * sourceWidth;
const centeredY =
layout.cropY +
layout.cropSize / 2 -
layout.originY * sourceHeight;
assert.ok(
Math.abs(centeredX) < 1e-9 &&
Math.abs(centeredY) < 1e-9,
'The visible crop center must remain anchored to the image position.'
);
assert.ok(
Math.abs(layout.cropSize * layout.scale - displaySize) <
1e-9,
'The cropped portrait must retain its requested display size.'
);
}
async function assertSceneIntegration(relativePath, minimumCalls) {
const source = await readFile(
path.join(projectRoot, relativePath),
'utf8'
);
assert.match(
source,
/import\s+\{\s*fitDialogueFacePortrait\s*\}\s+from\s+'\.\.\/ui\/dialoguePortrait';/,
`${relativePath} must import the shared dialogue portrait helper.`
);
const callCount =
source.match(/fitDialogueFacePortrait\s*\(/g)?.length ?? 0;
assert.ok(
callCount >= minimumCalls,
`${relativePath} must apply the shared helper at every portrait rendering path.`
);
}

View File

@@ -85,6 +85,21 @@ assert.match(
/export function ensureExplorationCharacterAnimations\(/,
'The module must expose exploration animation registration.'
);
assert.match(
moduleSource,
/export function explorationCharacterFrameFor\(/,
'The module must expose deterministic direction and motion frame lookup.'
);
assert.match(
moduleSource,
/export function applyExplorationCharacterMotion\(/,
'The module must expose shared reduced-motion-aware animation application.'
);
assert.match(
moduleSource,
/if \(reducedMotion && motion === 'idle'\)\s*\{\s*sprite\.anims\.stop\(\);\s*sprite\.setFrame\(explorationCharacterFrameFor\('idle', direction\)\);/s,
'Reduced motion must freeze only decorative idle loops on the matching direction frame.'
);
assert.match(
moduleSource,
/export function releaseExplorationCharacterTextures\(/,

View File

@@ -296,6 +296,8 @@ try {
const jianYong = actorById(exploration, 'jian-yong');
const clickStart = { ...exploration.player };
const autoInteractionCountBefore =
exploration.movement.autoInteraction.triggeredCount;
await clickScenePoint(page, jianYong.x, jianYong.y);
await page.waitForFunction(
({ key, actorId, startX, startY }) => {
@@ -333,6 +335,14 @@ try {
{ key: sceneKey, actorId: 'jian-yong' },
{ timeout: 30000 }
);
await page.waitForFunction(
(key) => (
window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong'
),
sceneKey
);
exploration = await readExploration(page);
assert(
actorById(exploration, 'jian-yong').distance <=
@@ -340,19 +350,30 @@ try {
`Click movement must stop inside Jian Yong's interaction radius: ${JSON.stringify(exploration.interaction)}`
);
assertActorsFixed(exploration, 'after click navigation');
await page.keyboard.press('e');
await page.waitForFunction(
(key) => {
const state = window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.();
return (
state?.dialogue?.active === true &&
state.dialogue.sourceNpcId === 'jian-yong'
);
},
sceneKey
assert.equal(
exploration.movement.autoInteraction.triggeredCount,
autoInteractionCountBefore + 1,
'Arriving at Jian Yong must open dialogue exactly once.'
);
const protectedArrivalLineIndex = exploration.dialogue.lineIndex;
await page.mouse.click(960, 850);
await page.waitForTimeout(80);
exploration = await readExploration(page);
assert.equal(
exploration.dialogue.lineIndex,
protectedArrivalLineIndex,
'A follow-up click inside the arrival guard must not skip Jian Yongs first line.'
);
assert.equal(
exploration.dialogue.sourceNpcId,
'jian-yong',
'The arrival guard must keep Jian Yongs newly opened dialogue active.'
);
await page.waitForTimeout(160);
assert.equal(
(await readExploration(page)).movement.autoInteraction.triggeredCount,
autoInteractionCountBefore + 1,
'Jian Yong dialogue must not retrigger on a later update.'
);
exploration = await readExploration(page);
assert.equal(exploration.dialogue.speaker, '간옹');

View File

@@ -40,6 +40,12 @@ try {
await waitForDebugApi(page);
await waitForTitle(page);
await assertDesktopViewport(page);
await verifyReducedMotionExploration(page);
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await waitForTitle(page);
await assertDesktopViewport(page);
await clickLegacyTitleControl(page, 962, 240);
await waitForStoryReady(page);
@@ -174,8 +180,13 @@ try {
walkingZhangFei.x > 360 && walkingZhangFei.x < 1000,
`Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}`
);
const queuedGuanTarget = villageNpc(village, 'guan-yu');
await page.mouse.click(queuedGuanTarget.x, queuedGuanTarget.y);
const queuedVillagerTarget = villageNpc(village, 'market-villager');
const queuedVillagerAutoCount =
village.movement.autoInteraction.triggeredCount;
await page.mouse.click(
queuedVillagerTarget.x,
queuedVillagerTarget.y
);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
@@ -188,15 +199,45 @@ try {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.appliedQueuedIntentCount >= 1 &&
state?.movement?.targetNpcId === 'guan-yu'
state?.movement?.targetNpcId === 'market-villager'
);
});
await waitForVillageInteractionTarget(page, 'guan-yu');
await waitForVillageInteractionTarget(page, 'market-villager');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId ===
'market-villager'
));
village = await readVillage(page);
assert.equal(
village.movement.autoInteraction.triggeredCount,
queuedVillagerAutoCount + 1,
'Queued pointer navigation must open the villager dialogue exactly once on arrival.'
);
const protectedVillagerLineIndex = village.dialogue.lineIndex;
await page.mouse.click(960, 850);
await page.waitForTimeout(80);
village = await readVillage(page);
assert.equal(
village.dialogue.lineIndex,
protectedVillagerLineIndex,
'A follow-up click inside the arrival guard must not skip the first villager line.'
);
assert.equal(
village.dialogue.sourceNpcId,
'market-villager',
'The arrival guard must keep the newly opened villager dialogue active.'
);
const tavernZhangFei = villageNpc(village, 'zhang-fei');
assert.equal(tavernZhangFei.moving, false);
assert.equal(tavernZhangFei.animationKey, 'exploration-zhang-fei-idle-east');
await captureStableScreenshot(page, 'dist/verification-prologue-village-dialogue.png');
await page.waitForTimeout(160);
assert.equal(
(await readVillage(page)).movement.autoInteraction.triggeredCount,
queuedVillagerAutoCount + 1,
'Arrival must not retrigger the same villager dialogue on a later update.'
);
await advanceDialogueUntilClosed(page);
await teleportTo(page, 'make-oath');
await page.keyboard.press('e');
@@ -260,6 +301,8 @@ try {
`Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}`
);
const queuedClerkTarget = villageNpc(village, 'recruiting-clerk');
const queuedClerkAutoCount =
village.movement.autoInteraction.triggeredCount;
await page.mouse.click(queuedClerkTarget.x, queuedClerkTarget.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
@@ -273,14 +316,18 @@ try {
window.__HEROS_DEBUG__?.village?.()?.movement?.targetNpcId === 'recruiting-clerk'
));
await waitForVillageInteractionTarget(page, 'recruiting-clerk');
village = await readVillage(page);
assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
));
village = await readVillage(page);
assert.equal(
village.movement.autoInteraction.triggeredCount,
queuedClerkAutoCount + 1,
'Queued pointer navigation must open the recruiting dialogue exactly once.'
);
assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei');
await advanceDialogueUntilClosed(page);
village = await readVillage(page);
@@ -407,7 +454,74 @@ try {
'Militia camp interaction outside the radius must not open dialogue.'
);
const campPlayerBeforeMove = (await readMilitiaCamp(page)).player;
militiaCamp = await readMilitiaCamp(page);
const pointerZouJing = militiaCamp.npcs.find(
({ id }) => id === 'zou-jing'
);
assert(pointerZouJing, 'Zou Jing must be available as a pointer navigation target.');
const campAutoInteractionBefore =
militiaCamp.movement.autoInteraction.triggeredCount;
const campDetourCountBefore = militiaCamp.movement.detourCount;
await page.mouse.click(pointerZouJing.x, pointerZouJing.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.militiaCamp?.();
return (
state?.movement?.targetNpcId === 'zou-jing' &&
state?.movement?.detourCount > 0
);
});
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId ===
'zou-jing'
));
militiaCamp = await readMilitiaCamp(page);
assert(
militiaCamp.movement.detourCount > campDetourCountBefore,
'Clicking Zou Jing from the entrance must route around the central fire.'
);
assert.equal(
militiaCamp.movement.autoInteraction.triggeredCount,
campAutoInteractionBefore + 1,
'Militia camp pointer arrival must open dialogue exactly once.'
);
const protectedCampLineIndex = militiaCamp.dialogue.lineIndex;
await page.mouse.click(960, 850);
await page.waitForTimeout(80);
militiaCamp = await readMilitiaCamp(page);
assert.equal(
militiaCamp.dialogue.lineIndex,
protectedCampLineIndex,
'A follow-up click inside the arrival guard must not skip Zou Jings first line.'
);
assert.equal(
militiaCamp.dialogue.sourceNpcId,
'zou-jing',
'The arrival guard must keep Zou Jings newly opened dialogue active.'
);
await page.waitForTimeout(160);
assert.equal(
(await readMilitiaCamp(page)).movement.autoInteraction.triggeredCount,
campAutoInteractionBefore + 1,
'Militia camp arrival must not retrigger dialogue on a later update.'
);
await advanceMilitiaCampDialogueUntilClosed(page);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.waitForFunction(() =>
window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')
);
await clickLegacyTitleControl(page, 962, 310);
await waitForMilitiaCampReady(page);
await page.waitForTimeout(380);
militiaCamp = await readMilitiaCamp(page);
assert.equal(
militiaCamp.dialogue.active,
false,
'The pointer-route smoke reload must not replay the one-time camp introduction.'
);
const campPlayerBeforeMove = militiaCamp.player;
await page.keyboard.down('ArrowUp');
await page.waitForTimeout(360);
await page.keyboard.up('ArrowUp');
@@ -906,7 +1020,7 @@ try {
console.log(
`Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, distance-gated dialogue, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, obstacle detours, single-fire arrival dialogue, ` +
'autosave/reload, legacy-save routing, locked oath and command, camp preparation, optional promise memory, ' +
'departure story, and first deployment.'
);
@@ -924,6 +1038,62 @@ function withDebugOptions(url) {
return parsed.toString();
}
async function verifyReducedMotionExploration(page) {
await page.evaluate(() => {
window.localStorage.setItem(
'heros-web:visual-motion-mode',
'reduced'
);
});
await page.evaluate(async () => {
await window.__HEROS_DEBUG__?.goToVillage();
});
await waitForVillageReady(page);
await page.waitForTimeout(380);
let village = await readVillage(page);
assert.equal(
village.visualMotionReduced,
true,
'The playable village must honor the reduced-motion preference.'
);
assert.equal(
village.player.animationPlaying,
false,
'Reduced motion must keep the idle player on a stable directional frame.'
);
assert(
village.npcs.every((npc) => npc.animationPlaying === false),
`Reduced motion must freeze NPC idle loops: ${JSON.stringify(village.npcs)}`
);
await advanceDialogueUntilClosed(page);
await page.keyboard.down('d');
try {
await page.waitForFunction(() => {
const player = window.__HEROS_DEBUG__?.village?.()?.player;
return (
player?.moving === true &&
player?.animationPlaying === true &&
player?.animationKey?.includes('-walk-')
);
});
} finally {
await page.keyboard.up('d');
}
await page.waitForFunction(() => {
const player = window.__HEROS_DEBUG__?.village?.()?.player;
return player?.moving === false && player?.animationPlaying === false;
});
village = await readVillage(page);
assert.equal(
village.player.animationPlaying,
false,
'Reduced motion must return the player to a stable frame after walking.'
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(() => (
document.querySelector('canvas') !== null &&

View File

@@ -14,7 +14,7 @@ const baselineViewport = desktopBrowserViewport;
const phaserWebglRendererType = 2;
const legacyUiScale = 1.5;
const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138';
const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29';
const firstBattleUnlockLabel = '\uD55C\uC11D \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29';
const firstPursuitScoutVisitId = 'first-pursuit-scout-tent';
const firstPursuitScoutChoiceId = 'trace-river-ambush';
const firstPursuitScoutChoiceLabel = '강가 매복 흔적을 쫓는다';

View File

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

View File

@@ -44,6 +44,7 @@ const checks = [
'scripts/verify-story-asset-data.mjs',
'scripts/verify-story-environment-profiles.mjs',
'scripts/verify-story-image-asset-data.mjs',
'scripts/verify-dialogue-portrait-layout.mjs',
'scripts/verify-visual-asset-data.mjs',
'scripts/verify-unit-sprite-asset-data.mjs',
'scripts/verify-battle-action-asset-budgets.mjs',

View File

@@ -160,7 +160,73 @@ try {
'Reduced story motion must stop background panning and particles while retaining one static haze layer.'
);
console.info('Verified visual motion defaults, persistence, title controls, and story/battle motion guards.');
const explorationScenePaths = [
'src/game/scenes/PrologueVillageScene.ts',
'src/game/scenes/PrologueMilitiaCampScene.ts',
'src/game/scenes/CityStayScene.ts',
'src/game/scenes/CampVisitExplorationScene.ts'
];
explorationScenePaths.forEach((scenePath) => {
const sceneSource = readFileSync(scenePath, 'utf8');
assert(
sceneSource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';") &&
sceneSource.includes('this.visualMotionReduced = isVisualMotionReduced();'),
`${scenePath} must load the shared reduced-motion preference on creation.`
);
assert(
sceneSource.includes('applyExplorationCharacterMotion(') &&
sceneSource.includes('this.visualMotionReduced'),
`${scenePath} must use the shared motion helper so idle loops freeze without teleporting walks.`
);
assert(
sceneSource.includes('if (!this.visualMotionReduced)') &&
sceneSource.includes('if (this.visualMotionReduced)'),
`${scenePath} must suppress ambient loops and replace transition motion with static feedback.`
);
assert(
sceneSource.includes('if (this.completionToast === toast)') &&
sceneSource.includes('targets: toast'),
`${scenePath} must bind toast cleanup to the toast that created the timer or tween.`
);
assert(
/this\.autoDialogueInputReadyAt\s*=\s*this\.time\.now \+ inputCarryoverGuardMs;/.test(
sceneSource
) &&
sceneSource.includes(
'time >= this.autoDialogueInputReadyAt'
) &&
sceneSource.includes(
'this.time.now < this.autoDialogueInputReadyAt'
),
`${scenePath} must guard automatic-arrival dialogue from carry-over pointer and keyboard input without blocking modal controls.`
);
});
assert(
readFileSync(
'src/game/scenes/PrologueVillageScene.ts',
'utf8'
).includes('this.time.delayedCall(1510, clearToast)') &&
readFileSync(
'src/game/scenes/PrologueMilitiaCampScene.ts',
'utf8'
).includes('this.time.delayedCall(1510, clearToast)') &&
readFileSync(
'src/game/scenes/CityStayScene.ts',
'utf8'
).includes('this.time.delayedCall(1880, clearToast)') &&
readFileSync(
'src/game/scenes/CampVisitExplorationScene.ts',
'utf8'
).includes(
'this.time.delayedCall(duration + 380, clearToast)'
),
'Reduced-motion toasts must preserve the same total reading time as their animated variants.'
);
console.info(
'Verified visual motion defaults, persistence, title controls, and story/battle/exploration motion guards.'
);
} finally {
await server.close();
}