feat: add battle sidebar quick navigation

This commit is contained in:
2026-07-12 18:31:24 +09:00
parent 8b2a935b85
commit c74b82edda
2 changed files with 1061 additions and 77 deletions

View File

@@ -5477,6 +5477,7 @@ async function assertLargeRosterHud(browser, url) {
assertLateBattleHeaderLayout(deploymentProbe, 'deployment');
assertLateBattleSidebarLayout(deploymentProbe, 'deployment', false);
assertLateBattleDeploymentLayout(deploymentProbe);
assertSideQuickTabsLayout(deploymentProbe, null, null, false);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-deployment.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle deployment');
@@ -5496,6 +5497,9 @@ async function assertLargeRosterHud(browser, url) {
const situationProbe = await readLargeRosterHudProbe(page);
assertLateBattleHeaderLayout(situationProbe, 'situation');
assertLateBattleSidebarLayout(situationProbe, 'situation', true);
assertSideQuickTabsLayout(situationProbe, 'situation', 'situation');
await assertFinalBattleQuickTabs(page);
await assertFinalBattleFhdDetailPanels(page);
@@ -5595,6 +5599,139 @@ async function assertLargeRosterHud(browser, url) {
}
}
async function assertFinalBattleQuickTabs(page) {
const initialProbe = await readLargeRosterHudProbe(page);
assertSideQuickTabsLayout(initialProbe, 'situation', 'situation');
const rosterTab = initialProbe.quickTabs.tabs.find((tab) => tab.id === 'roster');
await page.mouse.move(
rosterTab.bounds.x + rosterTab.bounds.width / 2,
rosterTab.bounds.y + rosterTab.bounds.height / 2
);
await page.waitForFunction(() => {
const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs;
return quickTabs?.hoveredTab === 'roster' && quickTabs.tabs?.find((tab) => tab.id === 'roster')?.visualState === 'hover';
}, undefined, { timeout: 30000 });
const hoverProbe = await readLargeRosterHudProbe(page);
assert(
hoverProbe.quickTabs.activeTab === 'situation' && hoverProbe.quickTabs.hoveredTab === 'roster',
`Expected hover feedback without changing the active quick tab: ${JSON.stringify(hoverProbe)}`
);
await page.mouse.move(initialProbe.sceneBounds.x + 20, initialProbe.sceneBounds.y + 20);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.hoveredTab === null,
undefined,
{ timeout: 30000 }
);
await page.mouse.click(
rosterTab.bounds.x + rosterTab.bounds.width / 2,
rosterTab.bounds.y + rosterTab.bounds.height / 2
);
await waitForSideQuickTabView(page, 'roster', 'roster');
const rosterProbe = await readLargeRosterHudProbe(page);
assertSideQuickTabsLayout(rosterProbe, 'roster', 'roster');
assertCompletedSidePanelTransition(rosterProbe, 'tab', 'roster');
await page.keyboard.press('1');
await waitForSideQuickTabView(page, 'situation', 'situation');
await page.keyboard.press('2');
await waitForSideQuickTabView(page, 'roster', 'roster');
const keyboardRosterProbe = await readLargeRosterHudProbe(page);
assertSideQuickTabsLayout(keyboardRosterProbe, 'roster', 'roster');
assertCompletedSidePanelTransition(keyboardRosterProbe, 'tab', 'roster');
await page.keyboard.press('3');
await waitForSideQuickTabView(page, 'bond', 'bond');
const bondProbe = await readLargeRosterHudProbe(page);
assertSideQuickTabsLayout(bondProbe, 'bond', 'bond');
assertCompletedSidePanelTransition(bondProbe, 'tab', 'bond');
await page.keyboard.press('4');
await waitForSideQuickTabView(page, 'threat', 'threat-overview');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 });
const threatProbe = await readLargeRosterHudProbe(page);
assertSideQuickTabsLayout(threatProbe, 'threat', 'threat-overview');
assert(threatProbe.markerCount > 0, `Expected the threat quick tab to paint map markers: ${JSON.stringify(threatProbe)}`);
assertCompletedSidePanelTransition(threatProbe, 'tab', 'threat-overview');
await page.keyboard.press('1');
await waitForSideQuickTabView(page, 'situation', 'situation');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount === 0, undefined, { timeout: 30000 });
const situationProbe = await readLargeRosterHudProbe(page);
assertSideQuickTabsLayout(situationProbe, 'situation', 'situation');
assert(situationProbe.markerCount === 0, `Expected another quick tab to clear threat markers: ${JSON.stringify(situationProbe)}`);
assertCompletedSidePanelTransition(situationProbe, 'tab', 'situation');
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return;
}
scene.phase = 'animating';
scene.renderSituationPanel?.('빠른 탭 입력 차단 점검');
});
await page.waitForFunction(() => {
const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs;
return quickTabs?.visible === true && quickTabs.enabled === false && quickTabs.activeTab === 'situation';
}, undefined, { timeout: 30000 });
const disabledProbe = await readLargeRosterHudProbe(page);
const disabledRosterTab = disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'roster');
assert(
disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'situation')?.visualState === 'disabled-active' &&
disabledRosterTab?.visualState === 'disabled',
`Expected a dimmed active tab and disabled alternatives while battle input is locked: ${JSON.stringify(disabledProbe)}`
);
await page.keyboard.press('2');
await page.mouse.click(
disabledRosterTab.bounds.x + disabledRosterTab.bounds.width / 2,
disabledRosterTab.bounds.y + disabledRosterTab.bounds.height / 2
);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const blockedProbe = await readLargeRosterHudProbe(page);
assert(
blockedProbe.quickTabs.activeTab === 'situation' && blockedProbe.quickTabs.currentView === 'situation',
`Expected disabled mouse and keyboard quick-tab input to be ignored: ${JSON.stringify(blockedProbe)}`
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (scene) {
scene.phase = 'idle';
}
});
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.enabled === true,
undefined,
{ timeout: 30000 }
);
const restoredProbe = await readLargeRosterHudProbe(page);
const restoredRosterTab = restoredProbe.quickTabs.tabs.find((tab) => tab.id === 'roster');
await page.mouse.click(
restoredRosterTab.bounds.x + restoredRosterTab.bounds.width / 2,
restoredRosterTab.bounds.y + restoredRosterTab.bounds.height / 2
);
await waitForSideQuickTabView(page, 'roster', 'roster');
await page.keyboard.press('1');
await waitForSideQuickTabView(page, 'situation', 'situation');
}
async function waitForSideQuickTabView(page, activeTab, currentView) {
await page.waitForFunction(
({ expectedTab, expectedView }) => {
const hud = window.__HEROS_DEBUG__?.battle()?.battleHud;
return (
hud?.quickTabs?.activeTab === expectedTab &&
hud.quickTabs.currentView === expectedView &&
!hud.panelTransition?.active &&
hud.panelTransition?.completedRevision === hud.panelTransition?.revision
);
},
{ expectedTab: activeTab, expectedView: currentView },
{ timeout: 30000 }
);
}
async function assertFinalBattleFhdDetailPanels(page) {
const allyFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -5617,6 +5754,7 @@ async function assertFinalBattleFhdDetailPanels(page) {
);
const allyDetailProbe = await readLargeRosterHudProbe(page);
assertUnitDetailPanelLayout(allyDetailProbe, 'zhuge-liang', 'ally');
assertCompletedSidePanelTransition(allyDetailProbe, 'detail', 'officer-detail');
const enemyFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -5642,6 +5780,23 @@ async function assertFinalBattleFhdDetailPanels(page) {
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-unit-detail.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle unit detail');
await page.mouse.click(
enemyDetailProbe.unitDetailPanel.returnButtonBounds.x + enemyDetailProbe.unitDetailPanel.returnButtonBounds.width / 2,
enemyDetailProbe.unitDetailPanel.returnButtonBounds.y + enemyDetailProbe.unitDetailPanel.returnButtonBounds.height / 2
);
await waitForSideQuickTabView(page, 'roster', 'roster');
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.tab === 'enemy',
undefined,
{ timeout: 30000 }
);
const returnedRosterProbe = await readLargeRosterHudProbe(page);
assert(
returnedRosterProbe.roster?.tab === 'enemy' && returnedRosterProbe.unitDetailPanel === null && returnedRosterProbe.phase === 'idle',
`Expected the shared unit-detail return to restore the matching roster: ${JSON.stringify(returnedRosterProbe)}`
);
assertCompletedSidePanelTransition(returnedRosterProbe, 'back', 'roster');
const bondFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
@@ -5725,10 +5880,24 @@ async function assertFinalBattleFhdDetailPanels(page) {
);
const threatProbe = await readLargeRosterHudProbe(page);
assertThreatPanelLayout(threatProbe, expectedThreatIds);
assertCompletedSidePanelTransition(threatProbe, 'detail', 'threat-detail');
await waitForBattleHudPaint(page);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-threat-panel.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle threat panel');
await page.mouse.click(
threatProbe.threatPanel.returnButtonBounds.x + threatProbe.threatPanel.returnButtonBounds.width / 2,
threatProbe.threatPanel.returnButtonBounds.y + threatProbe.threatPanel.returnButtonBounds.height / 2
);
await waitForSideQuickTabView(page, 'threat', 'threat-overview');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 });
const returnedThreatProbe = await readLargeRosterHudProbe(page);
assert(
returnedThreatProbe.threatPanel === null && returnedThreatProbe.markerCount > 0,
`Expected the shared threat return to restore the map range overview: ${JSON.stringify(returnedThreatProbe)}`
);
assertCompletedSidePanelTransition(returnedThreatProbe, 'back', 'threat-overview');
await page.evaluate(() => {
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderTerrainDetail?.(38, 96);
});
@@ -5738,21 +5907,40 @@ async function assertFinalBattleFhdDetailPanels(page) {
}, undefined, { timeout: 30000 });
const terrainProbe = await readLargeRosterHudProbe(page);
assertTerrainPanelLayout(terrainProbe, 'camp');
assertCompletedSidePanelTransition(terrainProbe, 'detail', 'terrain-detail');
await waitForBattleHudPaint(page);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-terrain-panel.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle terrain panel');
await page.keyboard.press('Escape');
await waitForSideQuickTabView(page, 'situation', 'situation');
const returnedSituationProbe = await readLargeRosterHudProbe(page);
assert(
returnedSituationProbe.terrainPanel === null && returnedSituationProbe.miniMap?.visible === true,
`Expected the shared terrain return to restore the situation overview: ${JSON.stringify(returnedSituationProbe)}`
);
assertCompletedSidePanelTransition(returnedSituationProbe, 'back', 'situation');
}
async function waitForBattleHudPaint(page) {
await page.waitForFunction(() => {
const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition;
return !transition || (!transition.active && transition.completedRevision === transition.revision);
}, undefined, { timeout: 30000 });
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
async function readLargeRosterHudProbe(page) {
await page.waitForFunction(() => {
const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition;
return !transition || (!transition.active && transition.completedRevision === transition.revision);
}, undefined, { timeout: 30000 });
return page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const hud = window.__HEROS_DEBUG__?.battle()?.battleHud;
const battle = window.__HEROS_DEBUG__?.battle();
const hud = battle?.battleHud;
return {
roster: hud?.rosterPanel ?? null,
recentActions: hud?.recentActions ?? null,
@@ -5764,6 +5952,11 @@ async function readLargeRosterHudProbe(page) {
bondPanel: hud?.bondPanel ?? null,
threatPanel: hud?.threatPanel ?? null,
terrainPanel: hud?.terrainPanel ?? null,
quickTabs: hud?.quickTabs ?? null,
panelTransition: hud?.panelTransition ?? null,
markerCount: battle?.markerCount ?? 0,
phase: battle?.phase ?? null,
selectedUnitId: hud?.miniMap?.selectedUnitId ?? null,
sceneBounds: scene
? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }
: null,
@@ -5774,6 +5967,70 @@ async function readLargeRosterHudProbe(page) {
});
}
function assertSideQuickTabsLayout(probe, expectedActiveTab, expectedView, expectedVisible = true) {
const quickTabs = probe.quickTabs;
if (!expectedVisible) {
assert(
quickTabs?.visible === false && quickTabs.tabs?.length === 0 && quickTabs.bounds === null,
`Expected deployment to omit side quick tabs: ${JSON.stringify(probe)}`
);
return;
}
const tabs = quickTabs?.tabs ?? [];
const activeTabs = tabs.filter((tab) => tab.active);
assert(
quickTabs?.visible === true &&
quickTabs.enabled === true &&
quickTabs.activeTab === expectedActiveTab &&
quickTabs.currentView === expectedView &&
tabs.length === 4 &&
sameJsonValue(tabs.map((tab) => tab.id), ['situation', 'roster', 'bond', 'threat']) &&
sameJsonValue(tabs.map((tab) => tab.label), ['전황', '부대', '공명', '위협']) &&
sameJsonValue(tabs.map((tab) => tab.shortcut), ['1', '2', '3', '4']) &&
activeTabs.length === 1 &&
activeTabs[0].id === expectedActiveTab &&
activeTabs[0].visualState === 'active',
`Expected one active 1-4 quick tab for ${expectedView}: ${JSON.stringify(probe)}`
);
assert(
isFiniteBounds(quickTabs.bounds) &&
boundsInside(quickTabs.bounds, probe.panelBounds) &&
boundsInside(quickTabs.bounds, probe.sceneBounds) &&
probe.header.sortieOrderBounds.y + probe.header.sortieOrderBounds.height <= quickTabs.bounds.y + 1 &&
quickTabs.bounds.y + quickTabs.bounds.height <= quickTabs.bodyTop + 1 &&
quickTabs.bodyTop === probe.sidebar.bodyTop &&
probe.sidebar.bounds.y >= quickTabs.bodyTop,
`Expected quick tabs between the FHD header and side-panel body: ${JSON.stringify(probe)}`
);
tabs.forEach((tab) => {
assert(
boundsInside(tab.bounds, quickTabs.bounds) &&
boundsInside(tab.labelBounds, tab.bounds) &&
boundsInside(tab.shortcutBounds, tab.bounds),
`Expected quick-tab content inside its FHD button: ${JSON.stringify(tab)}`
);
});
assertHorizontalBounds(tabs.map((tab) => tab.bounds), `${expectedView} quick tabs`);
}
function assertCompletedSidePanelTransition(probe, expectedTrigger, expectedView) {
const transition = probe.panelTransition;
assert(
transition?.active === false &&
transition.completedRevision === transition.revision &&
transition.revision > 0 &&
transition.last?.kind === 'fade-slide' &&
transition.last.trigger === expectedTrigger &&
transition.last.to === expectedView &&
transition.last.durationMs >= 100 &&
transition.last.durationMs <= 240 &&
transition.last.offsetX > 0 &&
transition.last.objectCount > 0,
`Expected a completed side-panel transition into ${expectedView}: ${JSON.stringify(probe)}`
);
}
function assertLateBattleHeaderLayout(probe, label) {
const { header, panelBounds, sceneBounds } = probe;
const headerBounds = [
@@ -5866,6 +6123,7 @@ function assertLateBattleDeploymentLayout(probe) {
function assertUnitDetailPanelLayout(probe, expectedUnitId, label) {
const panel = probe.unitDetailPanel;
assertLateBattleSidebarLayout(probe, `${label} unit detail`, false);
assertSideQuickTabsLayout(probe, 'roster', 'officer-detail');
assert(
panel?.unitId === expectedUnitId &&
panel.summaryBounds?.length === 3 &&
@@ -5881,6 +6139,7 @@ function assertUnitDetailPanelLayout(probe, expectedUnitId, label) {
panel.headerBounds,
panel.nameBounds,
panel.levelBadgeBounds,
panel.returnButtonBounds,
panel.hpBounds,
...panel.summaryBounds,
...panel.statBounds,
@@ -5893,6 +6152,8 @@ function assertUnitDetailPanelLayout(probe, expectedUnitId, label) {
assert(
boundsInside(panel.nameBounds, panel.headerBounds) &&
boundsInside(panel.levelBadgeBounds, panel.headerBounds) &&
boundsInside(panel.returnButtonBounds, panel.headerBounds) &&
panel.returnTarget === 'roster' &&
panel.nameBounds.x + panel.nameBounds.width <= panel.levelBadgeBounds.x + 1,
`Expected the FHD ${label} unit name to remain inside the header and left of the level badge: ${JSON.stringify(panel)}`
);
@@ -5917,6 +6178,7 @@ function assertUnitDetailPanelLayout(probe, expectedUnitId, label) {
function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedTotalCount) {
const panel = probe.bondPanel;
assertLateBattleSidebarLayout(probe, `bond page ${expectedPage + 1}`, false);
assertSideQuickTabsLayout(probe, 'bond', 'bond');
const expectedVisibleCount = Math.min(
panel?.rowsPerPage ?? 0,
Math.max(0, expectedTotalCount - expectedPage * (panel?.rowsPerPage ?? 0))
@@ -5965,6 +6227,7 @@ function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedT
function assertThreatPanelLayout(probe, expectedEnemyIds) {
const panel = probe.threatPanel;
assertLateBattleSidebarLayout(probe, 'threat detail', true);
assertSideQuickTabsLayout(probe, 'threat', 'threat-detail');
assert(
panel?.tile?.x === 38 &&
panel.tile.y === 96 &&
@@ -5978,6 +6241,7 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) {
const enemyBounds = panel.enemyRows.map((row) => row.rowBounds);
const allBounds = [
panel.headerBounds,
panel.returnButtonBounds,
...summaryBounds,
...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]),
...enemyBounds,
@@ -5985,7 +6249,11 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) {
panel.messageBounds
];
assertDetailBoundsInside(probe, allBounds, 'threat detail');
assertSituationRows(panel.summaryRows, 'threat summary');
assert(
panel.returnTarget === 'threat-overview' && boundsInside(panel.returnButtonBounds, panel.headerBounds),
`Expected threat detail to expose an in-header range return: ${JSON.stringify(panel)}`
);
assertSituationGrid(panel.summaryRows, 'threat summary', 2);
panel.enemyRows.forEach((row) => {
assert(
boundsInside(row.nameBounds, row.rowBounds) &&
@@ -5996,7 +6264,7 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) {
});
assertSequentialBounds(enemyBounds, 'threat enemy rows');
assertSequentialBounds(
[panel.headerBounds, ...summaryBounds, ...enemyBounds, panel.messageBounds],
[panel.headerBounds, boundsUnion(summaryBounds), ...enemyBounds, panel.messageBounds],
'threat detail sections'
);
assert(
@@ -6008,6 +6276,7 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) {
function assertTerrainPanelLayout(probe, expectedTerrain) {
const panel = probe.terrainPanel;
assertLateBattleSidebarLayout(probe, 'terrain detail', true);
assertSideQuickTabsLayout(probe, 'situation', 'terrain-detail');
assert(
panel?.tile?.x === 38 &&
panel.tile.y === 96 &&
@@ -6019,11 +6288,16 @@ function assertTerrainPanelLayout(probe, expectedTerrain) {
const summaryBounds = panel.summaryRows.map((row) => row.bounds);
const allBounds = [
panel.headerBounds,
panel.returnButtonBounds,
...summaryBounds,
...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]),
panel.messageBounds
];
assertDetailBoundsInside(probe, allBounds, 'terrain detail');
assert(
panel.returnTarget === 'situation' && boundsInside(panel.returnButtonBounds, panel.headerBounds),
`Expected terrain detail to expose an in-header situation return: ${JSON.stringify(panel)}`
);
assertSituationRows(panel.summaryRows, 'terrain summary');
assertSequentialBounds([panel.headerBounds, ...summaryBounds, panel.messageBounds], 'terrain detail sections');
assert(
@@ -6044,6 +6318,24 @@ function assertSituationRows(rows, label) {
assertSequentialBounds(rows.map((row) => row.bounds), `${label} rows`);
}
function assertSituationGrid(rows, label, columnCount) {
rows.forEach((row, index) => {
assert(
boundsInside(row.labelBounds, row.bounds) &&
boundsInside(row.valueBounds, row.bounds) &&
row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1,
`Expected ${label} cell ${index + 1} labels and values in separate columns: ${JSON.stringify(row)}`
);
});
const rowGroups = [];
for (let index = 0; index < rows.length; index += columnCount) {
const rowBounds = rows.slice(index, index + columnCount).map((row) => row.bounds);
assertHorizontalBounds(rowBounds, `${label} row ${rowGroups.length + 1}`);
rowGroups.push(boundsUnion(rowBounds));
}
assertSequentialBounds(rowGroups, `${label} rows`);
}
function assertDetailBoundsInside(probe, boundsList, label) {
assert(
isFiniteBounds(probe.sidebar?.bounds) &&