diff --git a/package.json b/package.json index f2a7f08..75a787b 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,10 @@ "verify:desktop-viewport": "node scripts/verify-desktop-browser-viewport.mjs", "verify:static-data": "node scripts/verify-static-data.mjs", "verify:flow": "node scripts/verify-flow.mjs", + "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index 3477fea..db893e3 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -141,12 +141,21 @@ function validateCampInteractionUxGuards() { ); checkedGuardCount += 1; + const navigationLockIndex = navigationMethod.indexOf('this.navigationPending = true;'); + const navigationBlockerIndex = navigationMethod.indexOf('this.showCampNavigationBlocker();'); + const lazySceneReadyIndex = navigationMethod.indexOf('await ensureLazyScene(this.game, key);'); + const stateCommitIndex = navigationMethod.indexOf('commitState();'); + const sceneStartIndex = navigationMethod.indexOf('this.scene.start(key, data);'); expectCampUx( navigationMethod.includes('if (this.navigationPending)') && - navigationMethod.includes('this.navigationPending = true;') && - navigationMethod.includes('startLazyScene(this, key, data)') && - navigationMethod.includes('this.navigationPending = false;'), - 'lazy camp navigation must lock before loading and unlock after a failed transition' + navigationLockIndex >= 0 && + navigationBlockerIndex > navigationLockIndex && + lazySceneReadyIndex > navigationBlockerIndex && + stateCommitIndex > lazySceneReadyIndex && + sceneStartIndex > stateCommitIndex && + navigationMethod.includes('this.navigationPending = false;') && + navigationMethod.includes('this.hideCampNavigationBlocker();'), + 'lazy camp navigation must lock the screen, finish loading, commit state, and then start the scene' ); checkedGuardCount += 1; diff --git a/scripts/verify-interaction-ux.mjs b/scripts/verify-interaction-ux.mjs new file mode 100644 index 0000000..088cfae --- /dev/null +++ b/scripts/verify-interaction-ux.mjs @@ -0,0 +1,795 @@ +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; +import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; + +const targetUrl = withDebugOptions( + process.env.VERIFY_INTERACTION_UX_URL ?? 'http://127.0.0.1:41783/' +); +const expectedBattleId = 'second-battle-yellow-turban-pursuit'; + +let serverProcess; +let browser; + +try { + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ headless: process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0' }); + const context = await browser.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); + + let delayedBattleModuleRequests = 0; + await page.route('**/src/game/scenes/BattleScene.ts*', async (route) => { + delayedBattleModuleRequests += 1; + await delay(1200); + await route.continue(); + }); + + await verifyCampModalAndNavigation(page); + await verifyBattlePointerFlow(page); + + if (pageErrors.length > 0) { + throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`); + } + if (delayedBattleModuleRequests !== 1) { + throw new Error( + `Expected one delayed BattleScene module request during single-flight navigation, received ${delayedBattleModuleRequests}.` + ); + } + + console.log( + `Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` + + 'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' + + 'battle event overlays block edge-scroll and hover feedback, ' + + 'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' + + 'the persistent turn-end action reopens it, and the right-click menu follows the pointer.' + ); +} finally { + await browser?.close(); + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } +} + +function withDebugOptions(url) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', 'canvas'); + parsed.searchParams.set('debugCombatAssetWatchdogMs', '250'); + return parsed.toString(); +} + +async function verifyCampModalAndNavigation(page) { + const campUrl = new URL(targetUrl); + campUrl.searchParams.set('debugSortiePrep', '1'); + + await page.goto(campUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.waitForFunction(() => { + const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const camp = window.__HEROS_DEBUG__?.camp(); + return scenes.includes('CampScene') && camp?.scene === 'CampScene' && camp?.sortieVisible === true; + }, undefined, { timeout: 90000 }); + await assertDesktopViewport(page); + + const saveButtonBounds = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const candidates = (scene?.children.list ?? []) + .filter((object) => ( + object.type === 'Text' && + object.text === '저장' && + object.active && + object.visible && + object.input?.enabled + )) + .map((object) => ({ depth: object.depth, bounds: object.getBounds() })) + .sort((left, right) => right.depth - left.depth || right.bounds.y - left.bounds.y); + const bounds = candidates[0]?.bounds; + return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; + }); + assert(saveButtonBounds, 'Expected a visible camp save button in sortie preparation.'); + + const campBeforeModal = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + await clickSceneBounds(page, 'CampScene', saveButtonBounds); + await page.waitForFunction(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return (scene?.saveSlotObjects?.length ?? 0) > 0; + }); + + const campSaveModal = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const rectangles = (scene?.saveSlotObjects ?? []) + .filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled) + .map((object) => { + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth }; + }); + const blocker = rectangles.find((bounds) => ( + bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98 + )); + return { + blocker: blocker ?? null, + objectCount: scene?.saveSlotObjects?.length ?? 0 + }; + }); + assert( + campSaveModal.blocker, + `Expected the camp save panel to include a full-screen interactive blocker: ${JSON.stringify(campSaveModal)}` + ); + + const coveredCampAction = campBeforeModal?.sortiePrimaryAction; + assert(coveredCampAction?.bounds, 'Expected the covered sortie primary action for camp click-through QA.'); + await clickSceneBounds(page, 'CampScene', coveredCampAction.bounds); + await page.waitForTimeout(120); + const campAfterCoveredClick = await page.evaluate(() => ({ + state: window.__HEROS_DEBUG__?.camp(), + savePanelObjectCount: window.__HEROS_GAME__?.scene.getScene('CampScene')?.saveSlotObjects?.length ?? 0 + })); + assert( + campAfterCoveredClick.state?.sortiePrepStep === campBeforeModal?.sortiePrepStep, + `Camp save modal click-through changed the underlying sortie step: ${JSON.stringify({ + before: campBeforeModal?.sortiePrepStep, + after: campAfterCoveredClick.state?.sortiePrepStep, + savePanelObjectCount: campAfterCoveredClick.savePanelObjectCount + })}` + ); + + const launchAction = await prepareCampLaunch(page); + const navigationBaseline = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const camp = window.__HEROS_DEBUG__?.camp(); + window.__HEROS_INTERACTION_UX__ = { battleSceneStarts: 0 }; + const originalStart = scene.scene.start; + scene.scene.start = function (key, data) { + if (key === 'BattleScene') { + window.__HEROS_INTERACTION_UX__.battleSceneStarts += 1; + } + return originalStart.call(this, key, data); + }; + return { + campaignStep: camp?.campaign?.step ?? null, + sortiePrepStep: camp?.sortiePrepStep ?? null, + selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])] + }; + }); + + const launchPoint = await sceneBoundsCenter(page, 'CampScene', launchAction.bounds); + await page.mouse.click(launchPoint.x, launchPoint.y); + await page.waitForFunction(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return scene?.navigationPending === true && (scene?.navigationBlockerObjects?.length ?? 0) > 0; + }, undefined, { timeout: 3000 }); + + const navigationPendingState = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const camp = window.__HEROS_DEBUG__?.camp(); + const blocker = (scene?.navigationBlockerObjects ?? []) + .filter((object) => object.type === 'Rectangle' && object.input?.enabled) + .map((object) => object.getBounds()) + .find((bounds) => bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98); + return { + pending: scene?.navigationPending ?? false, + blocker: blocker ? { x: blocker.x, y: blocker.y, width: blocker.width, height: blocker.height } : null, + campaignStep: camp?.campaign?.step ?? null, + sortieVisible: camp?.sortieVisible ?? false, + selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])] + }; + }); + assert( + navigationPendingState.blocker && navigationPendingState.campaignStep === navigationBaseline.campaignStep, + `Camp navigation must block input and defer the campaign-step commit until loading succeeds: ${JSON.stringify({ + baseline: navigationBaseline, + pending: navigationPendingState + })}` + ); + + await page.keyboard.press('Escape'); + await page.mouse.click(launchPoint.x, launchPoint.y); + const coveredTabBounds = (await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campTabs?.find((tab) => tab.id === 'dialogue')?.bounds)) ?? null; + if (coveredTabBounds) { + await clickSceneBounds(page, 'CampScene', coveredTabBounds); + } + await page.waitForTimeout(120); + + const navigationAfterRepeatedInput = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const camp = window.__HEROS_DEBUG__?.camp(); + return { + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + pending: scene?.navigationPending ?? false, + blockerCount: scene?.navigationBlockerObjects?.length ?? 0, + campaignStep: camp?.campaign?.step ?? null, + sortiePrepStep: camp?.sortiePrepStep ?? null, + selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])], + battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0 + }; + }); + assert( + navigationAfterRepeatedInput.activeScenes.includes('CampScene') && + navigationAfterRepeatedInput.pending && + navigationAfterRepeatedInput.blockerCount > 0 && + navigationAfterRepeatedInput.campaignStep === navigationBaseline.campaignStep && + navigationAfterRepeatedInput.sortiePrepStep === navigationBaseline.sortiePrepStep && + sameStrings(navigationAfterRepeatedInput.selectedSortieUnitIds, navigationBaseline.selectedSortieUnitIds) && + navigationAfterRepeatedInput.battleSceneStarts === 0, + `Repeated input changed camp state during lazy navigation: ${JSON.stringify({ + baseline: navigationBaseline, + after: navigationAfterRepeatedInput + })}` + ); + + await page.waitForFunction(() => { + const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const battle = window.__HEROS_DEBUG__?.battle(); + return scenes.includes('BattleScene') && battle?.scene === 'BattleScene'; + }, undefined, { timeout: 90000 }); + const navigationCompleteState = await page.evaluate(() => ({ + battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0, + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [] + })); + assert( + navigationCompleteState.battleSceneStarts === 1 && !navigationCompleteState.activeScenes.includes('CampScene'), + `Camp navigation must start BattleScene exactly once: ${JSON.stringify(navigationCompleteState)}` + ); +} + +async function prepareCampLaunch(page) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert(state?.sortieVisible, `Expected sortie preparation while finding launch action: ${JSON.stringify(state)}`); + + if (state.sortiePrepStep === 'formation' && state.sortieOperationOrder?.required && !state.sortieOperationOrder.complete) { + if (!state.sortieOperationOrder.open) { + assert(state.sortieOperationOrder.toggleButtonBounds, 'Expected the operation-order picker button.'); + await clickSceneBounds(page, 'CampScene', state.sortieOperationOrder.toggleButtonBounds); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === true); + } + const orderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder); + const order = orderState?.cards?.find((card) => card.cardBounds); + assert(order?.cardBounds, `Expected a selectable operation order: ${JSON.stringify(orderState)}`); + await clickSceneBounds(page, 'CampScene', order.cardBounds); + await page.waitForFunction((orderId) => ( + window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.selectedId === orderId + ), order.id); + const selectedOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder); + if (selectedOrderState?.open && selectedOrderState.closeButtonBounds) { + await clickSceneBounds(page, 'CampScene', selectedOrderState.closeButtonBounds); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === false); + } + continue; + } + + const action = state.sortiePrimaryAction; + assert(action?.bounds && action.interactive, `Expected an interactive sortie primary action: ${JSON.stringify(action)}`); + if (action.kind === 'launch') { + return action; + } + + const previousStep = state.sortiePrepStep; + await clickSceneBounds(page, 'CampScene', action.bounds); + await page.waitForFunction((step) => window.__HEROS_DEBUG__?.camp()?.sortiePrepStep !== step, previousStep); + } + + throw new Error('Could not reach the sortie launch action within the expected three-step flow.'); +} + +async function verifyBattlePointerFlow(page) { + await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), expectedBattleId); + await page.waitForFunction((battleId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.battleId === battleId && + ['deployment', 'idle'].includes(battle.phase) && + battle.mapBackgroundReady === true && + battle.combatAssets?.status !== 'loading' + ); + }, expectedBattleId, { timeout: 90000 }); + await startDeploymentIfNeeded(page, expectedBattleId); + await verifyBattleEventOverlayInputBlock(page); + + const lastAllySetup = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const units = scene?.debugBattleUnits?.() ?? []; + const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0); + const lastAlly = allies.find((unit) => scene.reachableTiles(unit).some((tile) => tile.x !== unit.x || tile.y !== unit.y)); + if (!scene || !lastAlly) { + return null; + } + + scene.hideBattleEventBanner(); + scene.hideTurnEndPrompt(); + scene.hideSaveSlotPanel(); + scene.hideMapMenu(); + scene.hideCommandMenu(); + scene.clearMarkers(); + scene.activeFaction = 'ally'; + scene.phase = 'idle'; + scene.selectedUnit = undefined; + scene.pendingMove = undefined; + scene.targetingAction = undefined; + scene.selectedUsable = undefined; + scene.actedUnitIds.clear(); + allies.filter((unit) => unit.id !== lastAlly.id).forEach((unit) => scene.actedUnitIds.add(unit.id)); + scene.centerCameraOnTile(lastAlly.x, lastAlly.y); + scene.renderSituationPanel(); + + const hitZone = scene.unitViews.get(lastAlly.id)?.hitZone; + const bounds = hitZone?.getBounds(); + return bounds + ? { + unitId: lastAlly.id, + unitTile: { x: lastAlly.x, y: lastAlly.y }, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } + } + : null; + }); + assert(lastAllySetup?.bounds, 'Expected a visible final allied unit hit area.'); + await page.waitForTimeout(80); + + await clickSceneBounds(page, 'BattleScene', lastAllySetup.bounds); + await page.waitForFunction((unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.selectedUnitId === unitId && battle?.phase === 'moving'; + }, lastAllySetup.unitId); + const selectedBeforeMove = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + assert( + !selectedBeforeMove.turnPromptVisible, + `Selecting the final ally must not show the turn-end prompt: ${JSON.stringify(selectedBeforeMove)}` + ); + + const movementTarget = await page.evaluate((unitId) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const unit = scene?.debugUnitById?.(unitId); + const destination = unit + ? scene.reachableTiles(unit).find((tile) => tile.x !== unit.x || tile.y !== unit.y) + : undefined; + if (!scene || !destination) { + return null; + } + return { + tile: { x: destination.x, y: destination.y }, + point: { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) }, + farPoint: { + x: destination.x - scene.cameraTileX < scene.layout.visibleColumns / 2 + ? scene.layout.gridX + scene.layout.gridWidth - 12 + : scene.layout.gridX + 12, + y: destination.y - scene.cameraTileY < scene.layout.visibleRows / 2 + ? scene.layout.gridY + scene.layout.gridHeight - 12 + : scene.layout.gridY + 12 + } + }; + }, lastAllySetup.unitId); + assert(movementTarget?.point, 'Expected a reachable non-origin tile for the final ally.'); + + const movementScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.point); + const farScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.farPoint); + await page.mouse.click(movementScreenPoint.x, movementScreenPoint.y); + await page.mouse.move(farScreenPoint.x, farScreenPoint.y); + const duringMovement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + assert( + !duringMovement?.turnPromptVisible && !duringMovement?.actedUnitIds?.includes(lastAllySetup.unitId), + `Movement of the final ally must not complete the action or show the prompt: ${JSON.stringify(duringMovement)}` + ); + + await page.waitForFunction((unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.phase === 'command' && battle?.pendingMove?.unitId === unitId && battle?.commandMenuVisible === true; + }, lastAllySetup.unitId); + const postMovement = await page.evaluate(({ unitId, destination }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const state = window.__HEROS_DEBUG__?.battle(); + const activeObjects = (scene?.commandMenuObjects ?? []).filter((object) => object.active); + const objectBounds = activeObjects.map((object) => object.getBounds()); + const left = Math.min(...objectBounds.map((bounds) => bounds.x)); + const top = Math.min(...objectBounds.map((bounds) => bounds.y)); + const right = Math.max(...objectBounds.map((bounds) => bounds.right)); + const bottom = Math.max(...objectBounds.map((bounds) => bounds.bottom)); + const destinationPoint = { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) }; + const waitButton = scene.commandButtons.find((button) => button.command === 'wait')?.background; + const waitBounds = waitButton?.getBounds(); + return { + state, + unitId, + menuBounds: { x: left, y: top, width: right - left, height: bottom - top }, + destinationPoint, + waitBounds: waitBounds + ? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height } + : null + }; + }, { unitId: lastAllySetup.unitId, destination: movementTarget.tile }); + assert( + !postMovement.state?.turnPromptVisible && + postMovement.state?.phase === 'command' && + !postMovement.state?.actedUnitIds?.includes(lastAllySetup.unitId), + `Arrival must keep command selection open before showing the turn-end prompt: ${JSON.stringify(postMovement.state)}` + ); + const destinationMenuDistance = pointToBoundsDistance(postMovement.destinationPoint, postMovement.menuBounds); + assert( + destinationMenuDistance <= 48, + `The movement command menu drifted away from the clicked destination (${destinationMenuDistance.toFixed(1)}px): ${JSON.stringify(postMovement)}` + ); + assert(postMovement.waitBounds, 'Expected the wait command button after movement.'); + + await clickSceneBounds(page, 'BattleScene', postMovement.waitBounds); + await page.waitForFunction((unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.turnPromptVisible === true && + battle?.turnPromptMode === 'turn-end' && + battle?.actedUnitIds?.includes(unitId) + ); + }, lastAllySetup.unitId); + + await clickTurnPromptSecondary(page); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); + const persistentTurnEndBounds = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const text = (scene?.sidePanelObjects ?? []).find((object) => ( + object.type === 'Text' && object.active && object.visible && object.input?.enabled && object.text === '턴 종료' + )); + const bounds = text?.getBounds(); + return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; + }); + assert(persistentTurnEndBounds, 'Expected a persistent turn-end action after choosing battlefield review.'); + await clickSceneBounds(page, 'BattleScene', persistentTurnEndBounds); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true && + window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'turn-end' + )); + await clickTurnPromptSecondary(page); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); + + const mapMenuPointer = await findRightmostEmptyBattleTile(page); + await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' }); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true); + const mapMenu = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return scene?.mapMenuLayout ? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] } : null; + }); + assert(mapMenu, 'Expected the right-click battlefield menu.'); + const horizontalPointerGap = mapMenuPointer.x < mapMenu.left + ? mapMenu.left - mapMenuPointer.x + : mapMenuPointer.x > mapMenu.left + mapMenu.width + ? mapMenuPointer.x - (mapMenu.left + mapMenu.width) + : 0; + assert( + horizontalPointerGap <= 24, + `The right-click menu did not open next to the pointer (${horizontalPointerGap.toFixed(1)}px): ${JSON.stringify({ mapMenuPointer, mapMenu })}` + ); + + const saveActionIndex = mapMenu.actions.indexOf('save'); + assert(saveActionIndex >= 0, `Expected a save action in the map menu: ${JSON.stringify(mapMenu)}`); + await clickSceneBounds(page, 'BattleScene', { + x: mapMenu.left, + y: mapMenu.top + mapMenu.padding + saveActionIndex * mapMenu.buttonHeight, + width: mapMenu.width, + height: mapMenu.buttonHeight + }); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'save'); + + const battleModalProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const rectangles = (scene?.saveSlotPanelObjects ?? []) + .filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled) + .map((object) => { + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth }; + }); + const blocker = rectangles.find((bounds) => ( + bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98 + )); + const panel = rectangles + .filter((bounds) => bounds.width < scene.scale.width * 0.9 && bounds.height > 200) + .sort((left, right) => right.width * right.height - left.width * left.height)[0]; + + const tryCamera = (x, y) => { + scene.setCameraTilePosition(x, y); + return (scene.debugBattleUnits?.() ?? []) + .filter((unit) => unit.faction === 'ally' && unit.hp > 0) + .map((unit) => ({ unit, zone: scene.unitViews.get(unit.id)?.hitZone })) + .find(({ zone }) => { + if (!zone?.visible || !zone.input?.enabled) { + return false; + } + const bounds = zone.getBounds(); + const point = { x: bounds.centerX, y: bounds.centerY }; + return !panel || !pointInsideBounds(point, panel); + }); + }; + + const candidate = tryCamera(0, 0) ?? tryCamera(scene.maxCameraTileX(), scene.maxCameraTileY()); + const candidateBounds = candidate?.zone?.getBounds(); + const persistentTurnEndAction = (scene?.sidePanelObjects ?? []).find((object) => ( + object.type === 'Text' && + object.active && + object.visible && + object.input?.enabled && + object.text === '턴 종료' + )); + const persistentActionBounds = persistentTurnEndAction?.getBounds(); + const coveredBounds = candidateBounds ?? persistentActionBounds; + return { + blocker: blocker ?? null, + panel: panel ?? null, + unitId: candidate?.unit.id ?? null, + targetKind: candidateBounds ? 'unit' : persistentActionBounds ? 'turn-end-action' : null, + coveredBounds: coveredBounds + ? { x: coveredBounds.x, y: coveredBounds.y, width: coveredBounds.width, height: coveredBounds.height } + : null, + selectedUnitId: scene.selectedUnit?.id ?? null + }; + + function pointInsideBounds(point, bounds) { + return ( + point.x >= bounds.x && point.x <= bounds.x + bounds.width && + point.y >= bounds.y && point.y <= bounds.y + bounds.height + ); + } + }); + assert( + battleModalProbe.blocker && battleModalProbe.coveredBounds, + `Expected a full-screen battle save blocker and an interactive control behind it: ${JSON.stringify(battleModalProbe)}` + ); + await clickSceneBounds(page, 'BattleScene', battleModalProbe.coveredBounds); + await page.waitForTimeout(120); + const battleAfterCoveredClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + assert( + battleAfterCoveredClick?.saveSlotPanelMode === 'save' && + battleAfterCoveredClick?.selectedUnitId === battleModalProbe.selectedUnitId && + battleAfterCoveredClick?.turnPromptVisible === false, + `Battle save modal allowed an underlying control click: ${JSON.stringify({ + before: battleModalProbe, + after: { + saveSlotPanelMode: battleAfterCoveredClick?.saveSlotPanelMode, + selectedUnitId: battleAfterCoveredClick?.selectedUnitId, + turnPromptVisible: battleAfterCoveredClick?.turnPromptVisible + } + })}` + ); +} + +async function verifyBattleEventOverlayInputBlock(page) { + const before = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene || (scene.battleEventObjects?.length ?? 0) === 0) { + return null; + } + + const maxX = scene.maxCameraTileX(); + const maxY = scene.maxCameraTileY(); + const edge = 2; + const point = scene.cameraTileX < maxX + ? { x: scene.layout.gridX + scene.layout.gridWidth - edge, y: scene.layout.gridY + scene.layout.gridHeight / 2 } + : scene.cameraTileX > 0 + ? { x: scene.layout.gridX + edge, y: scene.layout.gridY + scene.layout.gridHeight / 2 } + : scene.cameraTileY < maxY + ? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + scene.layout.gridHeight - edge } + : scene.cameraTileY > 0 + ? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + edge } + : null; + return { + eventObjectCount: scene.battleEventObjects.length, + cameraTileX: scene.cameraTileX, + cameraTileY: scene.cameraTileY, + point + }; + }); + assert(before?.point, `Expected a visible battle event overlay and a scrollable map edge: ${JSON.stringify(before)}`); + + const edgePoint = await sceneLogicalPoint(page, 'BattleScene', before.point); + await page.mouse.move(edgePoint.x, edgePoint.y); + await page.waitForTimeout(360); + + const after = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return { + eventObjectCount: scene?.battleEventObjects?.length ?? 0, + cameraTileX: scene?.cameraTileX ?? null, + cameraTileY: scene?.cameraTileY ?? null, + pointerFeedbackKey: scene?.lastPointerFeedbackKey ?? null, + pointerFeedbackVisible: scene?.pointerFeedbackMarker?.visible ?? false + }; + }); + assert( + after.eventObjectCount > 0 && + after.cameraTileX === before.cameraTileX && + after.cameraTileY === before.cameraTileY && + after.pointerFeedbackKey === null && + after.pointerFeedbackVisible === false, + `Battle event overlay allowed edge-scroll or pointer feedback through: ${JSON.stringify({ before, after })}` + ); + + const safePoint = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return scene + ? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + scene.layout.gridHeight / 2 } + : null; + }); + assert(safePoint, 'Expected a safe battlefield pointer position after overlay QA.'); + const safeScreenPoint = await sceneLogicalPoint(page, 'BattleScene', safePoint); + await page.mouse.move(safeScreenPoint.x, safeScreenPoint.y); +} + +async function startDeploymentIfNeeded(page, battleId) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + if (state?.phase === 'idle') { + return; + } + assert( + state?.battleId === battleId && state?.phase === 'deployment' && state?.battleHud?.deploymentPanel?.startButtonBounds, + `Expected ${battleId} deployment controls: ${JSON.stringify(state)}` + ); + await clickSceneBounds(page, 'BattleScene', state.battleHud.deploymentPanel.startButtonBounds); + await page.waitForFunction((expectedId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.battleId === expectedId && battle?.phase === 'idle'; + }, battleId, { timeout: 30000 }); + await page.waitForTimeout(100); +} + +async function clickTurnPromptSecondary(page) { + const bounds = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const buttons = (scene?.turnPromptObjects ?? []) + .filter((object) => ( + object.type === 'Rectangle' && + object.active && + object.input?.enabled && + object.displayWidth < 500 && + object.displayHeight < 100 + )) + .map((object) => object.getBounds()) + .sort((left, right) => right.centerX - left.centerX); + const button = buttons[0]; + return button ? { x: button.x, y: button.y, width: button.width, height: button.height } : null; + }); + assert(bounds, 'Expected the battlefield-review button in the turn-end prompt.'); + await clickSceneBounds(page, 'BattleScene', bounds); +} + +async function findRightmostEmptyBattleTile(page) { + const logicalPoint = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + return null; + } + for (let column = scene.layout.visibleColumns - 1; column >= 0; column -= 1) { + for (let row = 0; row < scene.layout.visibleRows; row += 1) { + const x = scene.cameraTileX + column; + const y = scene.cameraTileY + row; + if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) { + return { x: scene.tileCenterX(x), y: scene.tileCenterY(y) }; + } + } + } + return null; + }); + assert(logicalPoint, 'Expected a visible empty battlefield tile for the right-click menu.'); + return sceneLogicalPoint(page, 'BattleScene', logicalPoint); +} + +async function waitForDebugApi(page) { + await page.waitForFunction(() => ( + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined + ), undefined, { timeout: 90000 }); +} + +async function assertDesktopViewport(page) { + const viewport = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + canvas: (() => { + const canvas = document.querySelector('canvas'); + return canvas ? { width: canvas.width, height: canvas.height } : null; + })() + })); + assert( + viewport.width === desktopBrowserViewport.width && + viewport.height === desktopBrowserViewport.height && + viewport.dpr === 1 && + viewport.canvas?.width === desktopBrowserViewport.width && + viewport.canvas?.height === desktopBrowserViewport.height, + `Expected the required 1920x1080 CSS viewport at 100% zoom: ${JSON.stringify(viewport)}` + ); +} + +async function clickSceneBounds(page, sceneKey, bounds, options) { + const point = await sceneBoundsCenter(page, sceneKey, bounds); + await page.mouse.click(point.x, point.y, options); + return point; +} + +async function sceneBoundsCenter(page, sceneKey, bounds) { + return sceneLogicalPoint(page, sceneKey, { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2 + }); +} + +async function sceneLogicalPoint(page, sceneKey, logicalPoint) { + const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => { + const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds || !requestedPoint) { + return null; + } + return { + x: canvasBounds.left + requestedPoint.x * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + requestedPoint.y * canvasBounds.height / scene.scale.height + }; + }, { requestedSceneKey: sceneKey, requestedPoint: logicalPoint }); + assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`); + return point; +} + +function pointToBoundsDistance(point, bounds) { + const dx = Math.max(bounds.x - point.x, 0, point.x - (bounds.x + bounds.width)); + const dy = Math.max(bounds.y - point.y, 0, point.y - (bounds.y + bounds.height)); + return Math.hypot(dx, dy); +} + +function sameStrings(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn( + process.execPath, + ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '41783'], + { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] } + ); + child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); + child.stdout.on('data', () => {}); + + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + child.kill(); + throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`); +} + +async function canReach(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + return response.ok; + } catch { + return false; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index cdf5d52..e5863a1 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -3945,6 +3945,13 @@ export class BattleScene extends Phaser.Scene { if (this.tacticalCommandCutInObjects.length > 0) { return; } + if (this.battleEventObjects.length > 0) { + this.hideBattleEventBanner(); + return; + } + if (this.saveSlotPanelObjects.length > 0) { + return; + } if (this.tacticalInitiativePanelObjects.length > 0) { return; } @@ -3961,6 +3968,11 @@ export class BattleScene extends Phaser.Scene { if (this.tacticalCommandCutInObjects.length > 0) { return; } + if (this.battleEventObjects.length > 0) { + soundDirector.playSelect(); + this.hideBattleEventBanner(); + return; + } if (this.resultSortieOrderVisible) { soundDirector.playSelect(); this.hideResultSortieOrder(); @@ -4029,9 +4041,15 @@ export class BattleScene extends Phaser.Scene { } }); this.input.keyboard?.on('keydown', (event: KeyboardEvent) => { + if (this.battleEventObjects.length > 0) { + return; + } if (this.handleSaveSlotPanelKey(event)) { return; } + if (this.saveSlotPanelObjects.length > 0) { + return; + } if (this.handleTacticalInitiativeKey(event)) { return; } @@ -4041,7 +4059,14 @@ export class BattleScene extends Phaser.Scene { this.handleSideQuickTabKey(event); }); this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => { - if (this.activeFaction !== 'enemy' || this.fastForwardHeld) { + if ( + this.battleEventObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 || + this.activeFaction !== 'enemy' || + this.fastForwardHeld + ) { return; } event.preventDefault(); @@ -4056,7 +4081,12 @@ export class BattleScene extends Phaser.Scene { this.renderBattleSpeedControl(); }); this.input.keyboard?.on('keydown-PERIOD', () => { - if (this.tacticalInitiativePanelObjects.length > 0 || this.tacticalCommandCutInObjects.length > 0) { + if ( + this.battleEventObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 + ) { return; } this.cycleBattleSpeed(); @@ -6553,9 +6583,11 @@ export class BattleScene extends Phaser.Scene { this.battleOutcome || this.phase === 'animating' || this.phase === 'resolved' || + this.battleEventObjects.length > 0 || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || this.tacticalInitiativePanelObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 || this.saveSlotPanelObjects.length > 0 || this.turnPromptObjects.length > 0 || this.combatCutInObjects.length > 0 || @@ -6913,9 +6945,11 @@ export class BattleScene extends Phaser.Scene { this.battleOutcome || this.phase === 'animating' || this.phase === 'resolved' || + this.battleEventObjects.length > 0 || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || this.tacticalInitiativePanelObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 || this.saveSlotPanelObjects.length > 0 || this.turnPromptObjects.length > 0 || this.combatCutInObjects.length > 0 || @@ -7214,6 +7248,7 @@ export class BattleScene extends Phaser.Scene { private handleBattleKeyboardNavigation(event: KeyboardEvent) { if ( (this.phase !== 'moving' && this.phase !== 'targeting') || + this.battleEventObjects.length > 0 || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || this.tacticalInitiativePanelObjects.length > 0 || @@ -7357,7 +7392,7 @@ export class BattleScene extends Phaser.Scene { return; } - if (this.turnPromptMode) { + if (this.battleEventObjects.length > 0 || this.saveSlotPanelObjects.length > 0 || this.turnPromptMode) { return; } @@ -7520,6 +7555,9 @@ export class BattleScene extends Phaser.Scene { } private async moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) { + if (this.battleEventObjects.length > 0) { + return; + } if (this.isFirstBattleTutorialActive()) { const path = this.firstBattleTutorialPath!; if (this.firstBattleTutorialStep !== 'move') { @@ -7567,6 +7605,8 @@ export class BattleScene extends Phaser.Scene { const targetX = this.tileCenterX(x); const targetY = this.tileCenterY(y); + const commandX = pointer?.x ?? targetX; + const commandY = pointer?.y ?? targetY; if (!stayingInPlace) { await this.moveUnitViewAsync(unit, x, y, view, 260, fromX, fromY); if (this.battleOutcome || this.selectedUnit?.id !== unit.id || this.pendingMove?.unit.id !== unit.id) { @@ -7574,8 +7614,6 @@ export class BattleScene extends Phaser.Scene { } this.phase = 'command'; } - const commandX = pointer?.x ?? targetX; - const commandY = pointer?.y ?? targetY; this.showCommandMenu(unit, commandX, commandY); this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소'); @@ -12873,22 +12911,24 @@ export class BattleScene extends Phaser.Scene { ); } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { - soundDirector.playSelect(); - this.scene.restart({ - battleId: battleScenario.id, - selectedSortieUnitIds: [...this.launchSortieUnitIds], - sortieFormationAssignments: { ...this.launchSortieFormationAssignments }, - sortieItemAssignments: this.normalizeLaunchSortieItemAssignments(this.launchSortieItemAssignments), - sortieOrderId: this.launchSortieOrderId, - sortieResonanceBondId: this.launchSortieResonanceBondId, - sortieRecommendation: this.launchSortieRecommendation - ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) - : undefined + this.beginResultNavigation(() => { + this.scene.restart({ + battleId: battleScenario.id, + selectedSortieUnitIds: [...this.launchSortieUnitIds], + sortieFormationAssignments: { ...this.launchSortieFormationAssignments }, + sortieItemAssignments: this.normalizeLaunchSortieItemAssignments(this.launchSortieItemAssignments), + sortieOrderId: this.launchSortieOrderId, + sortieResonanceBondId: this.launchSortieResonanceBondId, + sortieRecommendation: this.launchSortieRecommendation + ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) + : undefined + }); }); }, depth + 3, outcome === 'defeat' ? 'primary' : 'secondary', 'retry'); this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { - soundDirector.playSelect(); - this.scene.start('TitleScene'); + this.beginResultNavigation(() => { + this.scene.start('TitleScene'); + }); }, depth + 3, 'ghost', 'title'); void this.playResultSettlementAnimation(animationToken); @@ -12911,13 +12951,53 @@ export class BattleScene extends Phaser.Scene { this.resultContinueButton?.label.setText(this.resultContinueCtaLabel()); } + private setResultNavigationButtonsEnabled(enabled: boolean) { + this.resultActionButtons.forEach((button) => { + if (!button.background.active) { + return; + } + if (enabled) { + button.background.setInteractive({ useHandCursor: true }); + } else { + button.background.disableInteractive(); + } + button.background.setAlpha(enabled ? 1 : 0.72); + button.label.setAlpha(enabled ? 1 : 0.72); + }); + } + + private recoverResultNavigation() { + this.resultNavigationPending = false; + this.setResultNavigationButtonsEnabled(true); + } + + private beginResultNavigation(navigate: () => void | Promise) { + if (this.resultNavigationPending) { + return false; + } + + this.resultNavigationPending = true; + this.setResultNavigationButtonsEnabled(false); + soundDirector.playSelect(); + try { + const pending = navigate(); + if (pending instanceof Promise) { + void pending.catch(() => this.recoverResultNavigation()); + } + } catch (error) { + this.recoverResultNavigation(); + throw error; + } + return true; + } + private handleResultContinue() { if (this.resultNavigationPending) { return; } - soundDirector.playSelect(); if (this.resultSettlementStatus !== 'complete') { + soundDirector.playSelect(); this.completeResultSettlement(true); this.resultContinueLockedUntil = this.time.now + 420; const button = this.resultContinueButton; @@ -12942,31 +13022,14 @@ export class BattleScene extends Phaser.Scene { } const { destination } = this.resultContinueRoute(); - this.resultNavigationPending = true; - this.resultActionButtons.forEach((button) => { - button.background.disableInteractive(); - button.background.setAlpha(0.72); - button.label.setAlpha(0.72); - }); - const recoverNavigation = () => { - this.resultNavigationPending = false; - this.resultActionButtons.forEach((button) => { - if (!button.background.active) { - return; - } - button.background.setInteractive({ useHandCursor: true }); - button.background.setAlpha(1); - button.label.setAlpha(1); - }); - }; if (destination === 'victory-story') { - void startLazyScene(this, 'StoryScene', { + this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', { pages: firstBattleVictoryPages, nextScene: 'CampScene' - }).catch(recoverNavigation); + })); return; } - void startLazyScene(this, 'CampScene').catch(recoverNavigation); + this.beginResultNavigation(() => startLazyScene(this, 'CampScene')); } private hideBattleResult() { @@ -13225,11 +13288,13 @@ export class BattleScene extends Phaser.Scene { } private openResultSortieImprovement(outcome: BattleOutcome) { + if (this.resultNavigationPending) { + return; + } const targetBattleId = this.resultSortieImprovementTargetBattleId(outcome); if (!targetBattleId || !this.launchSortieRecommendation) { return; } - soundDirector.playSelect(); const campSceneData = { openSortiePrep: true, openSortieImprovement: true, @@ -13240,15 +13305,16 @@ export class BattleScene extends Phaser.Scene { } : {}) }; + this.hideResultFormationReview(); if (outcome === 'victory' && battleScenario.id === 'first-battle-zhuo-commandery') { - void startLazyScene(this, 'StoryScene', { + this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', { pages: firstBattleVictoryPages, nextScene: 'CampScene', nextSceneData: campSceneData - }); + })); return; } - void startLazyScene(this, 'CampScene', campSceneData); + this.beginResultNavigation(() => startLazyScene(this, 'CampScene', campSceneData)); } private publishFirstBattleReport(outcome: BattleOutcome) { @@ -16484,6 +16550,7 @@ export class BattleScene extends Phaser.Scene { this.firstBattleTutorialFeedback = ''; this.firstBattleTutorialTargetPreviewLocked = false; this.firstBattleTutorialCompletion = undefined; + this.hideBattleEventBanner(); this.ensureFirstBattleTutorialPathVisible(); this.renderFirstBattleTutorial(); } @@ -16987,10 +17054,16 @@ export class BattleScene extends Phaser.Scene { const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); const top = this.layout.gridY + 18; const depth = 45; + const inputShade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x000000, 0.001); + inputShade.setOrigin(0); + inputShade.setDepth(depth - 1); + inputShade.setInteractive(); + this.battleEventObjects.push(inputShade); const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.94); panel.setOrigin(0); panel.setDepth(depth); panel.setStrokeStyle(2, palette.gold, 0.86); + panel.setInteractive(); this.battleEventObjects.push(panel); const titleText = this.add.text(left + 20, top + 14, title, { @@ -17096,10 +17169,16 @@ export class BattleScene extends Phaser.Scene { const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); const top = this.layout.gridY + 18; const depth = 45; + const inputShade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x000000, 0.001); + inputShade.setOrigin(0); + inputShade.setDepth(depth - 1); + inputShade.setInteractive(); + this.battleEventObjects.push(inputShade); const panel = this.add.rectangle(left, top, width, height, 0x0c141b, 0.97); panel.setOrigin(0); panel.setDepth(depth); panel.setStrokeStyle(2, palette.gold, 0.9); + panel.setInteractive(); this.battleEventObjects.push(panel); const commandSeal = this.add.rectangle(left + 29, top + 28, 30, 30, 0x651f1b, 0.98); @@ -17414,6 +17493,11 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.battleEventObjects.length > 0) { + this.hideBattleEventBanner(); + return; + } + if (this.tacticalInitiativePanelObjects.length > 0) { soundDirector.playSelect(); this.hideTacticalInitiativePanel(); @@ -17473,6 +17557,11 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.battleEventObjects.length > 0) { + this.hideBattleEventBanner(); + return; + } + if (this.tacticalInitiativePanelObjects.length > 0) { return; } @@ -17597,7 +17686,13 @@ export class BattleScene extends Phaser.Scene { const buttonHeight = 34; const padding = 8; const menuHeight = padding * 2 + actions.length * buttonHeight; - const left = this.layout.mapX + 84; + const mapLeft = this.layout.mapX + 16; + const mapRight = this.layout.mapX + this.layout.mapWidth - 16; + const pointerGap = 12; + const preferredLeft = pointerX + pointerGap + menuWidth <= mapRight + ? pointerX + pointerGap + : pointerX - menuWidth - pointerGap; + const left = Phaser.Math.Clamp(preferredLeft, mapLeft, mapRight - menuWidth); const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16); this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions }; @@ -17605,6 +17700,7 @@ export class BattleScene extends Phaser.Scene { panel.setOrigin(0); panel.setDepth(20); panel.setStrokeStyle(2, palette.gold, 0.86); + panel.setInteractive(); this.mapMenuObjects.push(panel); actions.forEach((action, index) => { @@ -17789,6 +17885,12 @@ export class BattleScene extends Phaser.Scene { const top = this.layout.mapY + this.battleUiLength(82); const campaignSlots = listCampaignSaveSlots(); + const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.54); + shade.setOrigin(0); + shade.setDepth(depth - 1); + shade.setInteractive(); + this.saveSlotPanelObjects.push(shade); + const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98); panel.setOrigin(0); panel.setDepth(depth); @@ -18569,8 +18671,7 @@ export class BattleScene extends Phaser.Scene { await this.delay(180); await this.presentCombatResult(result.counter); } - await this.showCombatExchangeMapResults(result); - await this.delay(result.counter ? 220 : 160); + await this.showCombatExchangeMapResults(result, true); return this.formatEnemyCombatResult(result, plan.behavior); } @@ -18589,8 +18690,8 @@ export class BattleScene extends Phaser.Scene { this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2)); const result = this.resolveCombatAction(enemy, target, usable.command, false, usable); await this.presentCombatResult(result); - this.showCombatMapResult(result); - await this.delay(160); + const popupDuration = this.showCombatMapResult(result); + await this.waitSceneDuration(popupDuration); return this.formatEnemyCombatResult(result, behavior); } @@ -22323,6 +22424,12 @@ export class BattleScene extends Phaser.Scene { }); } + private waitSceneDuration(ms: number) { + return new Promise((resolve) => { + this.time.delayedCall(ms, () => resolve()); + }); + } + private moveUnitViewAsync( unit: UnitData, x: number, @@ -22330,7 +22437,8 @@ export class BattleScene extends Phaser.Scene { view = this.unitViews.get(unit.id), duration = 260, fromX = unit.x, - fromY = unit.y + fromY = unit.y, + showSortieTrail = true ) { return new Promise((resolve) => { if (!view) { @@ -22345,7 +22453,9 @@ export class BattleScene extends Phaser.Scene { const direction = this.directionFromDelta(x - fromX, y - fromY); const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y); const movementDuration = this.movementDuration(duration, movementDistance); - this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); + if (showSortieTrail) { + this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); + } this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]); if (view.roleSeal) { this.tweens.killTweensOf(view.roleSeal); @@ -23191,7 +23301,7 @@ export class BattleScene extends Phaser.Scene { const view = this.unitViews.get(unit.id); unit.x = fromX; unit.y = fromY; - this.phase = 'moving'; + this.phase = 'animating'; this.selectedUnit = unit; this.pendingMove = undefined; this.hideCommandMenu(); @@ -23201,7 +23311,29 @@ export class BattleScene extends Phaser.Scene { this.refreshEnemyIntentForecast(); soundDirector.playSelect(); - this.moveUnitView(unit, fromX, fromY, view, 180, toX, toY, false); + this.clearMarkers(); + this.renderUnitDetail( + unit, + `이동을 취소하는 중입니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌아갑니다.` + ); + void this.completePendingMoveCancellation(unit, fromX, fromY, toX, toY, view); + return true; + } + + private async completePendingMoveCancellation( + unit: UnitData, + fromX: number, + fromY: number, + toX: number, + toY: number, + view?: UnitView + ) { + await this.moveUnitViewAsync(unit, fromX, fromY, view, 180, toX, toY, false); + if (this.battleOutcome || this.phase !== 'animating' || this.selectedUnit?.id !== unit.id) { + return; + } + + this.phase = 'moving'; this.clearMarkers(); this.showMoveRange(unit); this.renderUnitDetail( @@ -23211,7 +23343,6 @@ export class BattleScene extends Phaser.Scene { if (this.firstBattleTutorialStep === 'attack-command') { this.setFirstBattleTutorialStep('move', '이동을 취소해 권장 이동칸 단계로 돌아왔습니다.'); } - return true; } private moveUnitView( @@ -23907,19 +24038,25 @@ export class BattleScene extends Phaser.Scene { }); } - private async showCombatExchangeMapResults(result: CombatResult) { - this.showCombatMapResult(result); + private async showCombatExchangeMapResults(result: CombatResult, waitUntilReadable = false) { + let popupDuration = this.showCombatMapResult(result); if (!result.counter) { + if (waitUntilReadable) { + await this.waitSceneDuration(popupDuration); + } return; } await this.delay(110); - this.showCombatMapResult(result.counter); + popupDuration = this.showCombatMapResult(result.counter); + if (waitUntilReadable) { + await this.waitSceneDuration(popupDuration); + } } private showCombatMapResult(result: CombatResult) { if (!result.hit) { - this.showMapResultPopup( + return this.showMapResultPopup( result.defender, [this.missFeedbackLabel(result.action, result.usable)], '#d9f1ff', @@ -23927,7 +24064,6 @@ export class BattleScene extends Phaser.Scene { 19, 26 ); - return; } const effectLine = [ @@ -23948,7 +24084,7 @@ export class BattleScene extends Phaser.Scene { this.truncateUiText(effectLine, 28) ].filter(Boolean); - this.showMapResultPopup( + return this.showMapResultPopup( result.defender, lines, result.critical ? '#ff8f5f' : '#ffdf7b', @@ -24003,12 +24139,12 @@ export class BattleScene extends Phaser.Scene { private showMapResultPopup(unit: UnitData, lines: string[], color: string, stroke: string, fontSize: number, lift: number) { const view = this.unitViews.get(unit.id); if (!view) { - return; + return 0; } const visibleLines = lines.filter(Boolean); if (visibleLines.length === 0) { - return; + return 0; } const popup = this.add.text( @@ -24078,6 +24214,7 @@ export class BattleScene extends Phaser.Scene { } } }); + return duration + delay; } private showObjectiveMapFeedback( diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 0d12fc6..539b21b 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -204,7 +204,7 @@ import { import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; -import { startLazyScene, type LazySceneKey } from './lazyScenes'; +import { ensureLazyScene, type LazySceneKey } from './lazyScenes'; const campLegacyCanvasWidth = 1280; const campLegacyCanvasHeight = 720; @@ -11321,6 +11321,7 @@ export class CampScene extends Phaser.Scene { private sortieObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = []; + private navigationBlockerObjects: Phaser.GameObjects.GameObject[] = []; private pendingSaveSlot?: number; private pendingVictoryRewardReport?: CampaignVictoryRewardReport; private victoryRewardObjects: Phaser.GameObjects.GameObject[] = []; @@ -11442,6 +11443,7 @@ export class CampScene extends Phaser.Scene { this.sortieOrderToggleButton = undefined; this.saveSlotObjects = []; this.saveSlotConfirmObjects = []; + this.navigationBlockerObjects = []; this.pendingSaveSlot = undefined; this.pendingVictoryRewardReport = undefined; this.victoryRewardObjects = []; @@ -12739,6 +12741,10 @@ export class CampScene extends Phaser.Scene { } private showCampSaveSlotPanel() { + if (this.navigationPending) { + return; + } + if (this.saveSlotObjects.length > 0) { this.hideCampSaveSlotPanel(); return; @@ -12752,6 +12758,35 @@ export class CampScene extends Phaser.Scene { const activeSlot = this.campaign?.activeSaveSlot ?? getCampaignState().activeSaveSlot; const slots = listCampaignSaveSlots(); + const shade = this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.28); + shade.setOrigin(0); + shade.setDepth(depth - 1); + shade.setInteractive(); + shade.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => { + event?.stopPropagation(); + soundDirector.playSelect(); + this.hideCampSaveSlotPanel(); + } + ); + shade.on( + 'wheel', + ( + _pointer: Phaser.Input.Pointer, + _deltaX: number, + _deltaY: number, + _deltaZ: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); + this.trackCampSaveSlot(shade); + const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98); panel.setOrigin(0); panel.setDepth(depth); @@ -12980,6 +13015,11 @@ export class CampScene extends Phaser.Scene { } private handleSaveSlotKey(event: KeyboardEvent) { + if (this.navigationPending) { + event.preventDefault(); + return; + } + if (this.victoryRewardObjects.length > 0) { return; } @@ -13491,6 +13531,10 @@ export class CampScene extends Phaser.Scene { } private handleEscapeKey() { + if (this.navigationPending) { + return; + } + if (this.victoryRewardObjects.length > 0) { this.completeVictoryRewardAcknowledgement('close'); return; @@ -19877,19 +19921,26 @@ export class CampScene extends Phaser.Scene { } const previousStep = getCampaignState().step; - markCampaignStep(step); - this.campaign = getCampaignState(); - this.startCampNavigation(key, data, () => { - if (getCampaignState().step === step) { - markCampaignStep(previousStep); + this.startCampNavigation( + key, + data, + () => { + markCampaignStep(step); + this.campaign = getCampaignState(); + }, + () => { + if (getCampaignState().step === step) { + markCampaignStep(previousStep); + } + this.campaign = getCampaignState(); } - this.campaign = getCampaignState(); - }); + ); } private startCampNavigation( key: LazySceneKey, data?: Record, + commitState?: () => void, recoverState?: () => void ) { if (this.navigationPending) { @@ -19897,14 +19948,94 @@ export class CampScene extends Phaser.Scene { } this.navigationPending = true; - void startLazyScene(this, key, data).catch((error: unknown) => { - this.navigationPending = false; - recoverState?.(); - console.error(`[CampScene] ${key} 전환에 실패했습니다.`, error); - if (this.sys.isActive()) { - this.showCampNotice('다음 장면을 불러오지 못했습니다. 잠시 후 다시 시도하십시오.'); + this.showCampNavigationBlocker(); + let stateCommitted = false; + void (async () => { + try { + await ensureLazyScene(this.game, key); + if (!this.sys.isActive()) { + this.navigationPending = false; + this.hideCampNavigationBlocker(); + return; + } + + if (commitState) { + stateCommitted = true; + commitState(); + } + this.scene.start(key, data); + } catch (error: unknown) { + this.navigationPending = false; + this.hideCampNavigationBlocker(); + if (stateCommitted) { + recoverState?.(); + } + console.error(`[CampScene] ${key} 전환에 실패했습니다.`, error); + if (this.sys.isActive()) { + this.showCampNotice('다음 장면을 불러오지 못했습니다. 잠시 후 다시 시도하십시오.'); + } } - }); + })(); + } + + private showCampNavigationBlocker() { + this.hideCampNavigationBlocker(); + const depth = 120; + const shade = this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.68); + shade.setOrigin(0); + shade.setDepth(depth); + shade.setInteractive(); + shade.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); + shade.on( + 'wheel', + ( + _pointer: Phaser.Input.Pointer, + _deltaX: number, + _deltaY: number, + _deltaZ: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); + this.trackCampNavigationBlocker(shade); + + const panel = this.add.rectangle(campLegacyCanvasWidth / 2, campLegacyCanvasHeight / 2, 330, 104, 0x111a24, 0.99); + panel.setDepth(depth + 1); + panel.setStrokeStyle(2, palette.gold, 0.9); + panel.setInteractive(); + this.trackCampNavigationBlocker(panel); + + const title = this.add.text( + campLegacyCanvasWidth / 2, + campLegacyCanvasHeight / 2 - 18, + '불러오는 중…', + this.textStyle(22, '#f2e3bf', true) + ); + title.setOrigin(0.5); + title.setDepth(depth + 2); + this.trackCampNavigationBlocker(title); + + const guide = this.add.text( + campLegacyCanvasWidth / 2, + campLegacyCanvasHeight / 2 + 20, + '편성과 진행을 안전하게 준비하고 있습니다.', + this.textStyle(12, '#aeb7c2') + ); + guide.setOrigin(0.5); + guide.setDepth(depth + 2); + this.trackCampNavigationBlocker(guide); + } + + private hideCampNavigationBlocker() { + this.navigationBlockerObjects.forEach((object) => object.destroy()); + this.navigationBlockerObjects = []; } private isFinalEpilogueFlow(flow = this.currentSortieFlow()) { @@ -23745,6 +23876,12 @@ export class CampScene extends Phaser.Scene { return object; } + private trackCampNavigationBlocker(object: T) { + this.scaleLegacyCampUi(object); + this.navigationBlockerObjects.push(object); + return object; + } + private trackVictoryReward(object: T) { this.scaleLegacyCampUi(object); this.victoryRewardObjects.push(object); diff --git a/src/game/scenes/EndingScene.ts b/src/game/scenes/EndingScene.ts index 61d3f9d..2697937 100644 --- a/src/game/scenes/EndingScene.ts +++ b/src/game/scenes/EndingScene.ts @@ -12,6 +12,7 @@ const uiPx = (value: number) => `${Math.round(ui(value))}px`; export class EndingScene extends Phaser.Scene { private readonly backgroundKey = 'story-wuzhang-jiangwei-inheritance'; private ready = false; + private navigating = false; constructor() { super('EndingScene'); @@ -19,6 +20,9 @@ export class EndingScene extends Phaser.Scene { create() { this.ready = false; + this.navigating = false; + this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.returnToTitle(event)); + this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => this.returnToTitle(event)); this.createFallbackTexture(); this.renderEnding(); this.ensureBackgroundLoaded(() => this.renderEnding()); @@ -170,16 +174,20 @@ export class EndingScene extends Phaser.Scene { shadow: { offsetX: 0, offsetY: ui(2), color: '#020408', blur: ui(6), fill: true } }).setOrigin(0.5); - this.addButton(width / 2, height - ui(52), '타이틀로', () => { - soundDirector.playSelect(); - this.scene.start('TitleScene'); - }); - - this.input.keyboard?.on('keydown-ENTER', () => this.scene.start('TitleScene')); - this.input.keyboard?.on('keydown-SPACE', () => this.scene.start('TitleScene')); + this.addButton(width / 2, height - ui(52), '타이틀로', () => this.returnToTitle()); soundDirector.playMusic('oath-theme'); } + private returnToTitle(event?: KeyboardEvent) { + if (event?.repeat || this.navigating) { + return; + } + + this.navigating = true; + soundDirector.playSelect(); + this.scene.start('TitleScene'); + } + private addButton(x: number, y: number, label: string, action: () => void) { const bg = this.add.rectangle(x, y, ui(160), ui(42), 0x1a2630, 0.96); bg.setStrokeStyle(ui(1), palette.gold, 0.82); diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 1e2a9a3..a74f44a 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -74,7 +74,7 @@ export class TitleScene extends Phaser.Scene { this.input.once('pointerdown', unlockAudio); this.input.keyboard?.once('keydown', unlockAudio); - this.input.keyboard?.on('keydown-ENTER', () => this.handleEnterKey()); + this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleEnterKey(event)); this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event)); this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey()); } @@ -636,7 +636,11 @@ export class TitleScene extends Phaser.Scene { this.saveSlotPanel = undefined; } - private handleEnterKey() { + private handleEnterKey(event?: KeyboardEvent) { + if (event?.repeat || this.navigating) { + return; + } + if (this.saveSlotPanel) { return; } @@ -764,6 +768,9 @@ export class TitleScene extends Phaser.Scene { } private startGame() { + if (this.navigating) { + return; + } soundDirector.start(); soundDirector.resume(); soundDirector.playSelect(); @@ -772,6 +779,9 @@ export class TitleScene extends Phaser.Scene { } private continueGame(slot?: number) { + if (this.navigating) { + return; + } soundDirector.start(); soundDirector.resume(); soundDirector.playSelect();