fix: stabilize battle quick navigation

This commit is contained in:
2026-07-12 23:58:31 +09:00
parent c74b82edda
commit 254dfb2b84
2 changed files with 287 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
const screenshotDir = 'dist';
const baselineViewport = { width: 1920, height: 1080 };
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';
@@ -74,6 +75,7 @@ try {
await assertHdPlusCanvasFit(page);
await assertHdPlusInputMapping(browser, targetUrl);
await assertLargeRosterHud(browser, targetUrl);
await assertWebglBattleQuickTabSmoke(browser, targetUrl, 'first-battle-zhuo-commandery');
await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true });
await assertCanvasPainted(page, 'title first run');
@@ -5599,6 +5601,157 @@ async function assertLargeRosterHud(browser, url) {
}
}
async function assertWebglBattleQuickTabSmoke(browser, url, battleId) {
const battleUrl = new URL(url);
battleUrl.searchParams.set('renderer', 'webgl');
battleUrl.searchParams.set('debugBattle', battleId);
const page = await browser.newPage({ viewport: baselineViewport });
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
try {
await page.addInitScript(() => window.localStorage.clear());
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
await waitForBattleReady(page, battleId);
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.phase === 'idle' && battle.battleHud?.quickTabs?.enabled === true;
}, undefined, { timeout: 90000 });
const rendererProbe = await page.evaluate(() => {
const game = window.__HEROS_GAME__;
const scene = game?.scene.getScene('BattleScene');
return {
type: game?.renderer?.type ?? null,
name: game?.renderer?.constructor?.name ?? null,
sceneWidth: scene?.scale.width ?? null,
sceneHeight: scene?.scale.height ?? null
};
});
assert(
rendererProbe.type === phaserWebglRendererType &&
rendererProbe.sceneWidth === baselineViewport.width &&
rendererProbe.sceneHeight === baselineViewport.height,
`Expected the ${battleId} input smoke to use the FHD WebGL renderer: ${JSON.stringify(rendererProbe)}`
);
let actionableUnitPoint = await readActionableBattleUnitPoint(page);
await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y);
await page.waitForFunction(
(unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.phase === 'moving' &&
battle.selectedUnitId === unitId &&
battle.pendingMove === null &&
battle.battleHud?.quickTabs?.enabled === true
);
},
actionableUnitPoint.unitId,
{ timeout: 30000 }
);
await page.keyboard.press('3');
await waitForSideQuickTabView(page, 'bond', 'bond');
const keyboardReturnState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
keyboardReturnState?.phase === 'idle' &&
keyboardReturnState.selectedUnitId === null &&
keyboardReturnState.pendingMove === null &&
keyboardReturnState.markerCount === 0,
`Expected WebGL keyboard navigation to cancel an uncommitted selection: ${JSON.stringify(keyboardReturnState)}`
);
actionableUnitPoint = await readActionableBattleUnitPoint(page);
await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y);
await page.waitForFunction(
(unitId) => window.__HEROS_DEBUG__?.battle()?.phase === 'moving' && window.__HEROS_DEBUG__?.battle()?.selectedUnitId === unitId,
actionableUnitPoint.unitId,
{ timeout: 30000 }
);
await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y);
await page.waitForFunction(
(unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.phase === 'command' && battle.selectedUnitId === unitId && battle.commandMenuVisible === true;
},
actionableUnitPoint.unitId,
{ timeout: 30000 }
);
const commandLockState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
commandLockState?.pendingMove?.unitId === actionableUnitPoint.unitId &&
commandLockState.battleHud?.quickTabs?.enabled === false,
`Expected committed movement and its command menu to keep quick tabs locked: ${JSON.stringify(commandLockState)}`
);
const waitCommandPoint = await readBattleCommandControlPoint(page, 'wait');
await page.mouse.click(waitCommandPoint.x, waitCommandPoint.y);
await page.waitForFunction(
(completedUnitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.phase === 'moving' &&
battle.selectedUnitId &&
battle.selectedUnitId !== completedUnitId &&
battle.actedUnitIds?.includes(completedUnitId) &&
battle.battleHud?.quickTabs?.enabled === true
);
},
actionableUnitPoint.unitId,
{ timeout: 90000 }
);
const rosterTabPoint = await readSideQuickTabControlPoint(page, 'roster');
await page.mouse.click(rosterTabPoint.x, rosterTabPoint.y);
await waitForSideQuickTabView(page, 'roster', 'roster');
const autoFocusReturnState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
autoFocusReturnState?.phase === 'idle' &&
autoFocusReturnState.selectedUnitId === null &&
autoFocusReturnState.pendingMove === null &&
autoFocusReturnState.markerCount === 0 &&
autoFocusReturnState.actedUnitIds?.includes(actionableUnitPoint.unitId),
`Expected a WebGL mouse tab click to leave auto-focused movement safely: ${JSON.stringify(autoFocusReturnState)}`
);
const emptyTilePoint = await readEmptyBattleTilePoint(page);
const transitionRevision = autoFocusReturnState.battleHud?.panelTransition?.revision;
await page.mouse.move(emptyTilePoint.x, emptyTilePoint.y);
await page.waitForFunction(
({ tileX, tileY }) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.pointerFeedbackVisible === true &&
battle.pointerFeedbackTile?.x === tileX &&
battle.pointerFeedbackTile?.y === tileY &&
battle.battleHud?.quickTabs?.activeTab === 'roster' &&
battle.battleHud.quickTabs.currentView === 'roster'
);
},
{ tileX: emptyTilePoint.tile.x, tileY: emptyTilePoint.tile.y },
{ timeout: 30000 }
);
const hoverState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
hoverState?.battleHud?.terrainPanel === null &&
hoverState.battleHud.panelTransition?.revision === transitionRevision,
`Expected WebGL terrain hover to preserve the roster panel: ${JSON.stringify(hoverState)}`
);
await page.mouse.click(emptyTilePoint.x, emptyTilePoint.y);
await waitForSideQuickTabView(page, 'situation', 'terrain-detail');
await page.keyboard.press('Escape');
await waitForSideQuickTabView(page, 'situation', 'situation');
const screenshot = await page.screenshot({ path: `${screenshotDir}/rc-webgl-${battleId}-quick-tabs.png`, fullPage: true });
assert(
screenshot.byteLength >= 100_000,
`Expected a visibly painted ${battleId} WebGL frame: ${JSON.stringify({ byteLength: screenshot.byteLength })}`
);
assert(pageErrors.length === 0, `Unexpected ${battleId} WebGL runtime errors: ${JSON.stringify(pageErrors)}`);
} finally {
await page.close();
}
}
async function assertFinalBattleQuickTabs(page) {
const initialProbe = await readLargeRosterHudProbe(page);
assertSideQuickTabsLayout(initialProbe, 'situation', 'situation');
@@ -5732,6 +5885,136 @@ async function waitForSideQuickTabView(page, activeTab, currentView) {
);
}
async function readSideQuickTabControlPoint(page, tabId) {
const point = await page.evaluate((expectedTabId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const tab = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.tabs?.find((candidate) => candidate.id === expectedTabId);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !tab?.bounds || !canvas || !canvasBounds) {
return null;
}
const logicalX = tab.bounds.x + tab.bounds.width / 2;
const logicalY = tab.bounds.y + tab.bounds.height / 2;
return {
tabId: expectedTabId,
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
};
}, tabId);
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a visible ${tabId} quick-tab control: ${JSON.stringify(point)}`);
return point;
}
async function readBattleCommandControlPoint(page, command) {
const point = await page.evaluate((expectedCommand) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const entry = scene?.commandButtons?.find((candidate) => candidate.command === expectedCommand);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
const bounds = entry?.background?.getBounds?.();
if (!scene || !bounds || !canvas || !canvasBounds) {
return null;
}
const logicalX = bounds.x + bounds.width / 2;
const logicalY = bounds.y + bounds.height / 2;
return {
command: expectedCommand,
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
};
}, command);
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a visible ${command} command control: ${JSON.stringify(point)}`);
return point;
}
async function readActionableBattleUnitPoint(page) {
const point = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const battle = window.__HEROS_DEBUG__?.battle();
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !battle || !canvas || !canvasBounds) {
return null;
}
const unit = battle.units?.find((candidate) => {
const view = scene.unitViews?.get(candidate.id);
return candidate.faction === 'ally' && candidate.hp > 0 && !candidate.acted && view?.sprite?.visible && view.sprite.active;
});
const sprite = unit ? scene.unitViews?.get(unit.id)?.sprite : null;
const bounds = sprite?.getBounds?.();
if (!unit || !bounds) {
return null;
}
const logicalX = bounds.x + bounds.width / 2;
const logicalY = bounds.y + bounds.height / 2;
return {
unitId: unit.id,
tile: { x: unit.x, y: unit.y },
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
};
});
assert(
point?.unitId && Number.isFinite(point.x) && Number.isFinite(point.y),
`Expected a visible actionable ally for real-input QA: ${JSON.stringify(point)}`
);
return point;
}
async function readEmptyBattleTilePoint(page) {
const point = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const battle = window.__HEROS_DEBUG__?.battle();
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
const camera = battle?.camera;
if (!scene || !battle || !camera || !canvas || !canvasBounds) {
return null;
}
const occupiedTiles = new Set(
(battle.units ?? []).filter((unit) => unit.hp > 0).map((unit) => `${unit.x},${unit.y}`)
);
const markerTiles = new Set(
(scene.markers ?? [])
.filter((marker) => marker?.active && marker?.visible)
.map((marker) => `${marker.getData('tileX')},${marker.getData('tileY')}`)
);
const candidates = [];
const maxX = Math.min(camera.mapWidth, camera.x + camera.visibleColumns);
const maxY = Math.min(camera.mapHeight, camera.y + camera.visibleRows);
const centerX = camera.x + camera.visibleColumns / 2;
const centerY = camera.y + camera.visibleRows / 2;
for (let y = camera.y; y < maxY; y += 1) {
for (let x = camera.x; x < maxX; x += 1) {
const key = `${x},${y}`;
if (occupiedTiles.has(key) || markerTiles.has(key)) {
continue;
}
candidates.push({ x, y, distance: Math.abs(x - centerX) + Math.abs(y - centerY) });
}
}
candidates.sort((left, right) => left.distance - right.distance || left.y - right.y || left.x - right.x);
const tile = candidates[0];
if (!tile) {
return null;
}
const logicalX = scene.tileCenterX(tile.x);
const logicalY = scene.tileCenterY(tile.y);
return {
tile: { x: tile.x, y: tile.y },
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
};
});
assert(
point?.tile && Number.isFinite(point.x) && Number.isFinite(point.y),
`Expected an empty visible battle tile for pointer QA: ${JSON.stringify(point)}`
);
return point;
}
async function assertFinalBattleFhdDetailPanels(page) {
const allyFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');