feat: refine exploration presentation and audio flow
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
213
scripts/verify-dialogue-portrait-layout.mjs
Normal file
213
scripts/verify-dialogue-portrait-layout.mjs
Normal 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.`
|
||||
);
|
||||
}
|
||||
@@ -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\(/,
|
||||
|
||||
@@ -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 Yong’s first line.'
|
||||
);
|
||||
assert.equal(
|
||||
exploration.dialogue.sourceNpcId,
|
||||
'jian-yong',
|
||||
'The arrival guard must keep Jian Yong’s 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, '간옹');
|
||||
|
||||
@@ -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 Jing’s first line.'
|
||||
);
|
||||
assert.equal(
|
||||
militiaCamp.dialogue.sourceNpcId,
|
||||
'zou-jing',
|
||||
'The arrival guard must keep Zou Jing’s 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 &&
|
||||
|
||||
@@ -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 = '강가 매복 흔적을 쫓는다';
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user