From 254dfb2b84d746f519c934f5f25b0323f18c85f3 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 12 Jul 2026 23:58:31 +0900 Subject: [PATCH] fix: stabilize battle quick navigation --- scripts/verify-release-candidate.mjs | 283 +++++++++++++++++++++++++++ src/game/scenes/BattleScene.ts | 6 +- 2 files changed, 287 insertions(+), 2 deletions(-) diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 7a72238..2529314 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -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'); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index d8ea7f0..f028630 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -6444,7 +6444,6 @@ export class BattleScene extends Phaser.Scene { const tone = this.terrainTone(terrain); const text = `${terrainRule.label}: ${this.terrainEffectText(terrain, undefined, 'compact')} · 클릭하면 상세`; this.showPointerFeedback(tile, tone, text, key, terrainRule.color, terrainRule.passable === false ? 0.16 : 0.1, 0.68); - this.renderTerrainDetail(tile.x, tile.y); } private updateMovePointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) { @@ -21938,9 +21937,12 @@ export class BattleScene extends Phaser.Scene { } private canActivateSideQuickTabs() { + const navigationPhase = + this.phase === 'idle' || + (this.phase === 'moving' && Boolean(this.selectedUnit) && !this.pendingMove); return Boolean( this.shouldRenderSideQuickTabs() && - this.phase === 'idle' && + navigationPhase && this.activeFaction === 'ally' && this.saveSlotPanelObjects.length === 0 && this.turnPromptObjects.length === 0 &&