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

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