feat: optimize battle ui for hd displays

This commit is contained in:
2026-07-11 21:02:58 +09:00
parent a0780a7ac6
commit 7d3c49ac0b
4 changed files with 751 additions and 58 deletions

View File

@@ -41,6 +41,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await assertReleaseShellReady(page);
await assertHdPlusCanvasFit(page);
await assertHdPlusInputMapping(browser, targetUrl);
await assertLargeRosterHud(browser, targetUrl);
await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true });
await assertCanvasPainted(page, 'title first run');
@@ -60,16 +63,36 @@ try {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
const sideTexts = textValues(scene?.sidePanelObjects);
const miniMapTexts = textValues((scene?.miniMapObjects ?? []).filter((object) => object?.active && object?.visible));
const miniMapFrame = (scene?.miniMapObjects ?? []).find((object) => object?.name === 'mini-map-frame');
return {
battleId: state?.battleId,
objectiveText: scene?.objectiveTrackerText?.text,
objectiveSubText: scene?.objectiveTrackerSubText?.text,
sideTexts
sideTexts,
miniMapTexts,
actualMiniMapFrameBounds: miniMapFrame ? boundsValue(miniMapFrame.getBounds()) : null,
hud: state?.battleHud ?? null,
camera: state?.camera ?? null,
battleLog: [...(state?.battleLog ?? [])],
sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null,
panelBounds: scene
? {
x: scene.layout.panelX,
y: scene.layout.panelY,
width: scene.layout.panelWidth,
height: scene.layout.panelHeight
}
: null
};
function textValues(objects = []) {
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
}
function boundsValue(bounds) {
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
}
});
assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`);
assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`);
@@ -85,6 +108,50 @@ try {
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
);
const firstBattleMiniMap = firstBattleProbe.hud?.miniMap;
const firstBattleRecentActions = firstBattleProbe.hud?.recentActions;
assert(
firstBattleMiniMap?.visible === true &&
firstBattleMiniMap.title === '전장 지도' &&
sameJsonValue(firstBattleMiniMap.legend, ['아', '적', '목']) &&
firstBattleProbe.sceneBounds?.width === 1280 &&
firstBattleProbe.sceneBounds?.height === 720 &&
firstBattleMiniMap.cellSize >= 1 &&
firstBattleMiniMap.objectiveHighlighted === true &&
firstBattleMiniMap.selectedUnitId === null &&
firstBattleMiniMap.selectedHighlighted === false &&
firstBattleProbe.miniMapTexts.includes('전장 지도') &&
['아', '적', '목'].every((label) => firstBattleProbe.miniMapTexts.includes(label)) &&
sameJsonValue(firstBattleProbe.actualMiniMapFrameBounds, firstBattleMiniMap.frameBounds) &&
isFiniteBounds(firstBattleProbe.sceneBounds) &&
isFiniteBounds(firstBattleProbe.panelBounds) &&
isFiniteBounds(firstBattleMiniMap.frameBounds) &&
isFiniteBounds(firstBattleMiniMap.headerBounds) &&
isFiniteBounds(firstBattleMiniMap.mapBounds) &&
isFiniteBounds(firstBattleMiniMap.viewportBounds) &&
boundsInside(firstBattleProbe.panelBounds, firstBattleProbe.sceneBounds) &&
boundsInside(firstBattleMiniMap.frameBounds, firstBattleProbe.panelBounds) &&
boundsInside(firstBattleMiniMap.headerBounds, firstBattleMiniMap.frameBounds) &&
boundsInside(firstBattleMiniMap.mapBounds, firstBattleMiniMap.frameBounds) &&
boundsInside(firstBattleMiniMap.viewportBounds, firstBattleMiniMap.mapBounds, 2) &&
firstBattleMiniMap.mapBounds.width === firstBattleProbe.camera?.mapWidth * firstBattleMiniMap.cellSize &&
firstBattleMiniMap.mapBounds.height === firstBattleProbe.camera?.mapHeight * firstBattleMiniMap.cellSize &&
firstBattleMiniMap.headerBounds.y + firstBattleMiniMap.headerBounds.height <= firstBattleMiniMap.mapBounds.y,
`Expected a framed tactical minimap with title, legend, objective emphasis, and separate map hit bounds: ${JSON.stringify(firstBattleProbe)}`
);
assert(
isFiniteBounds(firstBattleRecentActions) &&
boundsInside(firstBattleRecentActions, firstBattleProbe.panelBounds) &&
boundsInside(firstBattleRecentActions, firstBattleProbe.sceneBounds) &&
firstBattleRecentActions?.compact === true &&
firstBattleRecentActions.height === 84 &&
firstBattleRecentActions.rowCount === Math.min(2, firstBattleProbe.battleLog.length) &&
firstBattleProbe.sideTexts.includes('최근 행동') &&
firstBattleRecentActions.bottom === firstBattleRecentActions.y + firstBattleRecentActions.height &&
firstBattleRecentActions.gapToMiniMap >= 6 &&
firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y,
`Expected the 720p recent-action panel to compact to two rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}`
);
await setDebugQueryParam(page, 'debugForceBondChain', '1');
const firstBattleBondChain = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -3997,6 +4064,264 @@ async function assertReleaseShellReady(page) {
assert(state.gameApiPresent === true, `Expected release QA game handle to be enabled: ${JSON.stringify(state)}`);
}
async function assertHdPlusCanvasFit(page) {
const cases = [
{
viewport: { width: 1600, height: 900 },
expectedBounds: { x: 0, y: 0, width: 1600, height: 900 }
},
{
viewport: { width: 1920, height: 1200 },
expectedBounds: { x: 0, y: 60, width: 1920, height: 1080 }
}
];
for (const testCase of cases) {
await page.setViewportSize(testCase.viewport);
await page.waitForFunction(({ expectedBounds }) => {
const canvas = document.querySelector('canvas');
if (!canvas) {
return false;
}
const bounds = canvas.getBoundingClientRect();
return (
canvas.width === 1280 &&
canvas.height === 720 &&
Math.abs(bounds.x - expectedBounds.x) <= 1 &&
Math.abs(bounds.y - expectedBounds.y) <= 1 &&
Math.abs(bounds.width - expectedBounds.width) <= 1 &&
Math.abs(bounds.height - expectedBounds.height) <= 1
);
}, testCase, { timeout: 30000 });
const state = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
const game = window.__HEROS_GAME__;
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
gameSize: game ? { width: game.scale.width, height: game.scale.height } : null,
canvas: canvas && bounds
? {
width: canvas.width,
height: canvas.height,
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
}
: null
};
});
assert(state.canvas?.width === 1280 && state.canvas?.height === 720, `Expected a fixed HD game surface: ${JSON.stringify(state)}`);
assert(state.gameSize?.width === 1280 && state.gameSize?.height === 720, `Expected fixed HD scene coordinates: ${JSON.stringify(state)}`);
assert(
boundsNear(state.canvas?.bounds, testCase.expectedBounds),
`Expected HD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}`
);
}
await page.setViewportSize({ width: 1280, height: 720 });
await page.waitForFunction(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return Boolean(
canvas &&
bounds &&
canvas.width === 1280 &&
canvas.height === 720 &&
Math.abs(bounds.x) <= 1 &&
Math.abs(bounds.y) <= 1 &&
Math.abs(bounds.width - 1280) <= 1 &&
Math.abs(bounds.height - 720) <= 1
);
}, undefined, { timeout: 30000 });
}
function boundsNear(actual, expected, tolerance = 1) {
return Boolean(
actual &&
Math.abs(actual.x - expected.x) <= tolerance &&
Math.abs(actual.y - expected.y) <= tolerance &&
Math.abs(actual.width - expected.width) <= tolerance &&
Math.abs(actual.height - expected.height) <= tolerance
);
}
async function assertHdPlusInputMapping(browser, url) {
const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } });
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
const clickPoint = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
if (!canvas || !bounds) {
return null;
}
return {
x: bounds.x + (962 / canvas.width) * bounds.width,
y: bounds.y + (240 / canvas.height) * bounds.height,
canvasBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
};
});
assert(
clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 60, width: 1920, height: 1080 }),
`Expected a centered HD canvas before testing scaled input: ${JSON.stringify(clickPoint)}`
);
await page.mouse.click(clickPoint.x, clickPoint.y);
await waitForStoryReady(page);
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
assert(activeScenes.includes('StoryScene'), `Expected scaled title input to enter the story: ${JSON.stringify(activeScenes)}`);
} finally {
await page.close();
}
}
async function assertLargeRosterHud(browser, url) {
const expectedRosterIds = [
'zhuge-liang',
'jiang-wei',
'wang-ping',
'zhao-yun',
'ma-dai',
'ma-chao',
'huang-quan',
'li-yan'
];
const battleUrl = new URL(url);
battleUrl.searchParams.set('debugBattle', 'sixty-sixth-battle-wuzhang-final');
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
try {
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === 'sixty-sixth-battle-wuzhang-final' && battle?.mapBackgroundReady === true;
}, undefined, { timeout: 90000 });
await page.evaluate((selectedSortieUnitIds) => {
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({
battleId: 'sixty-sixth-battle-wuzhang-final',
selectedSortieUnitIds
});
}, expectedRosterIds);
await page.waitForFunction((expectedIds) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === 'sixty-sixth-battle-wuzhang-final' &&
battle?.mapBackgroundReady === true &&
JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort())
);
}, expectedRosterIds, { timeout: 90000 });
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (scene?.phase === 'deployment') {
scene.confirmPreBattleDeployment?.();
}
scene?.hideBattleEventBanner?.();
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
});
await page.waitForFunction(() => {
const roster = window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel;
return roster?.tab === 'ally' && roster?.pageCount > 1;
}, undefined, { timeout: 30000 });
const firstPage = await readLargeRosterHudProbe(page);
assert(firstPage.roster.totalCount === expectedRosterIds.length, `Expected the exact eight-officer sortie roster: ${JSON.stringify(firstPage)}`);
assert(firstPage.roster.page === 0 && firstPage.roster.pageCount >= 2, `Expected paged late-battle roster: ${JSON.stringify(firstPage)}`);
assert(
firstPage.roster.visibleUnitIds.length > 0 && firstPage.roster.visibleUnitIds.length <= firstPage.roster.rowsPerPage,
`Expected only the current roster page to render: ${JSON.stringify(firstPage)}`
);
assertLargeRosterPageLayout(firstPage, 'first');
assert(firstPage.recentActions === null, `Expected the paged roster to prioritize selectable units over the action log: ${JSON.stringify(firstPage)}`);
const visibleRosterIds = new Set(firstPage.roster.visibleUnitIds);
let visibleRosterCount = firstPage.roster.visibleUnitIds.length;
let currentPage = firstPage;
for (let pageIndex = 1; pageIndex < firstPage.roster.pageCount; pageIndex += 1) {
await page.mouse.click(
currentPage.roster.navigationBounds.x + currentPage.roster.navigationBounds.width - 39,
currentPage.roster.navigationBounds.y + 15
);
await page.waitForFunction(
(expectedPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.page === expectedPage,
pageIndex,
{ timeout: 30000 }
);
currentPage = await readLargeRosterHudProbe(page);
visibleRosterCount += currentPage.roster.visibleUnitIds.length;
currentPage.roster.visibleUnitIds.forEach((unitId) => visibleRosterIds.add(unitId));
assertLargeRosterPageLayout(currentPage, `${pageIndex + 1}`);
}
assert(
visibleRosterCount === expectedRosterIds.length &&
JSON.stringify([...visibleRosterIds].sort()) === JSON.stringify([...expectedRosterIds].sort()),
`Expected roster paging to expose every selected officer exactly once: ${JSON.stringify({ expectedRosterIds, visibleRosterCount, visibleRosterIds: [...visibleRosterIds] })}`
);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-roster-last-page.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle roster last page');
await page.evaluate((selectedSortieUnitIds) => {
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({
battleId: 'sixty-sixth-battle-wuzhang-final',
selectedSortieUnitIds
});
}, expectedRosterIds);
await page.waitForFunction((expectedIds) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === 'sixty-sixth-battle-wuzhang-final' &&
battle?.mapBackgroundReady === true &&
battle?.phase === 'deployment' &&
JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort())
);
}, expectedRosterIds, { timeout: 90000 });
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.confirmPreBattleDeployment?.();
scene?.hideBattleEventBanner?.();
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
});
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.pageCount > 1, undefined, { timeout: 30000 });
const restartedPage = await readLargeRosterHudProbe(page);
assert(
restartedPage.roster.page === 0,
`Expected a restarted battle to reset the roster to page one: ${JSON.stringify(restartedPage)}`
);
} finally {
await page.close();
}
}
async function readLargeRosterHudProbe(page) {
return page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const hud = window.__HEROS_DEBUG__?.battle()?.battleHud;
return {
roster: hud?.rosterPanel ?? null,
recentActions: hud?.recentActions ?? null,
miniMap: hud?.miniMap ?? null,
panelBounds: scene
? { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight }
: null
};
});
}
function assertLargeRosterPageLayout(probe, label) {
const { roster, panelBounds } = probe;
assert(boundsInside(roster.listBounds, panelBounds), `Expected roster rows on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
assert(boundsInside(roster.navigationBounds, panelBounds), `Expected roster navigation on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
assert(boundsInside(roster.messageBounds, panelBounds), `Expected roster guidance on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
assert(
roster.listBounds.y + roster.listBounds.height <= roster.navigationBounds.y &&
roster.navigationBounds.y + roster.navigationBounds.height <= roster.messageBounds.y &&
roster.gapToMiniMap >= 6,
`Expected roster page ${label} content to stay vertically separated: ${JSON.stringify(probe)}`
);
}
async function waitForStoryReady(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];