diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 6f27531..7a72238 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -5477,6 +5477,7 @@ async function assertLargeRosterHud(browser, url) { assertLateBattleHeaderLayout(deploymentProbe, 'deployment'); assertLateBattleSidebarLayout(deploymentProbe, 'deployment', false); assertLateBattleDeploymentLayout(deploymentProbe); + assertSideQuickTabsLayout(deploymentProbe, null, null, false); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-deployment.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle deployment'); @@ -5496,6 +5497,9 @@ async function assertLargeRosterHud(browser, url) { const situationProbe = await readLargeRosterHudProbe(page); assertLateBattleHeaderLayout(situationProbe, 'situation'); assertLateBattleSidebarLayout(situationProbe, 'situation', true); + assertSideQuickTabsLayout(situationProbe, 'situation', 'situation'); + + await assertFinalBattleQuickTabs(page); await assertFinalBattleFhdDetailPanels(page); @@ -5595,6 +5599,139 @@ async function assertLargeRosterHud(browser, url) { } } +async function assertFinalBattleQuickTabs(page) { + const initialProbe = await readLargeRosterHudProbe(page); + assertSideQuickTabsLayout(initialProbe, 'situation', 'situation'); + + const rosterTab = initialProbe.quickTabs.tabs.find((tab) => tab.id === 'roster'); + await page.mouse.move( + rosterTab.bounds.x + rosterTab.bounds.width / 2, + rosterTab.bounds.y + rosterTab.bounds.height / 2 + ); + await page.waitForFunction(() => { + const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs; + return quickTabs?.hoveredTab === 'roster' && quickTabs.tabs?.find((tab) => tab.id === 'roster')?.visualState === 'hover'; + }, undefined, { timeout: 30000 }); + const hoverProbe = await readLargeRosterHudProbe(page); + assert( + hoverProbe.quickTabs.activeTab === 'situation' && hoverProbe.quickTabs.hoveredTab === 'roster', + `Expected hover feedback without changing the active quick tab: ${JSON.stringify(hoverProbe)}` + ); + await page.mouse.move(initialProbe.sceneBounds.x + 20, initialProbe.sceneBounds.y + 20); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.hoveredTab === null, + undefined, + { timeout: 30000 } + ); + + await page.mouse.click( + rosterTab.bounds.x + rosterTab.bounds.width / 2, + rosterTab.bounds.y + rosterTab.bounds.height / 2 + ); + await waitForSideQuickTabView(page, 'roster', 'roster'); + const rosterProbe = await readLargeRosterHudProbe(page); + assertSideQuickTabsLayout(rosterProbe, 'roster', 'roster'); + assertCompletedSidePanelTransition(rosterProbe, 'tab', 'roster'); + + await page.keyboard.press('1'); + await waitForSideQuickTabView(page, 'situation', 'situation'); + await page.keyboard.press('2'); + await waitForSideQuickTabView(page, 'roster', 'roster'); + const keyboardRosterProbe = await readLargeRosterHudProbe(page); + assertSideQuickTabsLayout(keyboardRosterProbe, 'roster', 'roster'); + assertCompletedSidePanelTransition(keyboardRosterProbe, 'tab', 'roster'); + + await page.keyboard.press('3'); + await waitForSideQuickTabView(page, 'bond', 'bond'); + const bondProbe = await readLargeRosterHudProbe(page); + assertSideQuickTabsLayout(bondProbe, 'bond', 'bond'); + assertCompletedSidePanelTransition(bondProbe, 'tab', 'bond'); + + await page.keyboard.press('4'); + await waitForSideQuickTabView(page, 'threat', 'threat-overview'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 }); + const threatProbe = await readLargeRosterHudProbe(page); + assertSideQuickTabsLayout(threatProbe, 'threat', 'threat-overview'); + assert(threatProbe.markerCount > 0, `Expected the threat quick tab to paint map markers: ${JSON.stringify(threatProbe)}`); + assertCompletedSidePanelTransition(threatProbe, 'tab', 'threat-overview'); + + await page.keyboard.press('1'); + await waitForSideQuickTabView(page, 'situation', 'situation'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount === 0, undefined, { timeout: 30000 }); + const situationProbe = await readLargeRosterHudProbe(page); + assertSideQuickTabsLayout(situationProbe, 'situation', 'situation'); + assert(situationProbe.markerCount === 0, `Expected another quick tab to clear threat markers: ${JSON.stringify(situationProbe)}`); + assertCompletedSidePanelTransition(situationProbe, 'tab', 'situation'); + + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + return; + } + scene.phase = 'animating'; + scene.renderSituationPanel?.('빠른 탭 입력 차단 점검'); + }); + await page.waitForFunction(() => { + const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs; + return quickTabs?.visible === true && quickTabs.enabled === false && quickTabs.activeTab === 'situation'; + }, undefined, { timeout: 30000 }); + const disabledProbe = await readLargeRosterHudProbe(page); + const disabledRosterTab = disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'roster'); + assert( + disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'situation')?.visualState === 'disabled-active' && + disabledRosterTab?.visualState === 'disabled', + `Expected a dimmed active tab and disabled alternatives while battle input is locked: ${JSON.stringify(disabledProbe)}` + ); + await page.keyboard.press('2'); + await page.mouse.click( + disabledRosterTab.bounds.x + disabledRosterTab.bounds.width / 2, + disabledRosterTab.bounds.y + disabledRosterTab.bounds.height / 2 + ); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const blockedProbe = await readLargeRosterHudProbe(page); + assert( + blockedProbe.quickTabs.activeTab === 'situation' && blockedProbe.quickTabs.currentView === 'situation', + `Expected disabled mouse and keyboard quick-tab input to be ignored: ${JSON.stringify(blockedProbe)}` + ); + + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (scene) { + scene.phase = 'idle'; + } + }); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.enabled === true, + undefined, + { timeout: 30000 } + ); + const restoredProbe = await readLargeRosterHudProbe(page); + const restoredRosterTab = restoredProbe.quickTabs.tabs.find((tab) => tab.id === 'roster'); + await page.mouse.click( + restoredRosterTab.bounds.x + restoredRosterTab.bounds.width / 2, + restoredRosterTab.bounds.y + restoredRosterTab.bounds.height / 2 + ); + await waitForSideQuickTabView(page, 'roster', 'roster'); + await page.keyboard.press('1'); + await waitForSideQuickTabView(page, 'situation', 'situation'); +} + +async function waitForSideQuickTabView(page, activeTab, currentView) { + await page.waitForFunction( + ({ expectedTab, expectedView }) => { + const hud = window.__HEROS_DEBUG__?.battle()?.battleHud; + return ( + hud?.quickTabs?.activeTab === expectedTab && + hud.quickTabs.currentView === expectedView && + !hud.panelTransition?.active && + hud.panelTransition?.completedRevision === hud.panelTransition?.revision + ); + }, + { expectedTab: activeTab, expectedView: currentView }, + { timeout: 30000 } + ); +} + async function assertFinalBattleFhdDetailPanels(page) { const allyFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -5617,6 +5754,7 @@ async function assertFinalBattleFhdDetailPanels(page) { ); const allyDetailProbe = await readLargeRosterHudProbe(page); assertUnitDetailPanelLayout(allyDetailProbe, 'zhuge-liang', 'ally'); + assertCompletedSidePanelTransition(allyDetailProbe, 'detail', 'officer-detail'); const enemyFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -5642,6 +5780,23 @@ async function assertFinalBattleFhdDetailPanels(page) { await page.screenshot({ path: `${screenshotDir}/rc-final-battle-unit-detail.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle unit detail'); + await page.mouse.click( + enemyDetailProbe.unitDetailPanel.returnButtonBounds.x + enemyDetailProbe.unitDetailPanel.returnButtonBounds.width / 2, + enemyDetailProbe.unitDetailPanel.returnButtonBounds.y + enemyDetailProbe.unitDetailPanel.returnButtonBounds.height / 2 + ); + await waitForSideQuickTabView(page, 'roster', 'roster'); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.tab === 'enemy', + undefined, + { timeout: 30000 } + ); + const returnedRosterProbe = await readLargeRosterHudProbe(page); + assert( + returnedRosterProbe.roster?.tab === 'enemy' && returnedRosterProbe.unitDetailPanel === null && returnedRosterProbe.phase === 'idle', + `Expected the shared unit-detail return to restore the matching roster: ${JSON.stringify(returnedRosterProbe)}` + ); + assertCompletedSidePanelTransition(returnedRosterProbe, 'back', 'roster'); + const bondFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { @@ -5725,10 +5880,24 @@ async function assertFinalBattleFhdDetailPanels(page) { ); const threatProbe = await readLargeRosterHudProbe(page); assertThreatPanelLayout(threatProbe, expectedThreatIds); + assertCompletedSidePanelTransition(threatProbe, 'detail', 'threat-detail'); await waitForBattleHudPaint(page); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-threat-panel.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle threat panel'); + await page.mouse.click( + threatProbe.threatPanel.returnButtonBounds.x + threatProbe.threatPanel.returnButtonBounds.width / 2, + threatProbe.threatPanel.returnButtonBounds.y + threatProbe.threatPanel.returnButtonBounds.height / 2 + ); + await waitForSideQuickTabView(page, 'threat', 'threat-overview'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 }); + const returnedThreatProbe = await readLargeRosterHudProbe(page); + assert( + returnedThreatProbe.threatPanel === null && returnedThreatProbe.markerCount > 0, + `Expected the shared threat return to restore the map range overview: ${JSON.stringify(returnedThreatProbe)}` + ); + assertCompletedSidePanelTransition(returnedThreatProbe, 'back', 'threat-overview'); + await page.evaluate(() => { window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderTerrainDetail?.(38, 96); }); @@ -5738,21 +5907,40 @@ async function assertFinalBattleFhdDetailPanels(page) { }, undefined, { timeout: 30000 }); const terrainProbe = await readLargeRosterHudProbe(page); assertTerrainPanelLayout(terrainProbe, 'camp'); + assertCompletedSidePanelTransition(terrainProbe, 'detail', 'terrain-detail'); await waitForBattleHudPaint(page); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-terrain-panel.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle terrain panel'); + + await page.keyboard.press('Escape'); + await waitForSideQuickTabView(page, 'situation', 'situation'); + const returnedSituationProbe = await readLargeRosterHudProbe(page); + assert( + returnedSituationProbe.terrainPanel === null && returnedSituationProbe.miniMap?.visible === true, + `Expected the shared terrain return to restore the situation overview: ${JSON.stringify(returnedSituationProbe)}` + ); + assertCompletedSidePanelTransition(returnedSituationProbe, 'back', 'situation'); } async function waitForBattleHudPaint(page) { + await page.waitForFunction(() => { + const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition; + return !transition || (!transition.active && transition.completedRevision === transition.revision); + }, undefined, { timeout: 30000 }); await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); })); } async function readLargeRosterHudProbe(page) { + await page.waitForFunction(() => { + const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition; + return !transition || (!transition.active && transition.completedRevision === transition.revision); + }, undefined, { timeout: 30000 }); return page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); - const hud = window.__HEROS_DEBUG__?.battle()?.battleHud; + const battle = window.__HEROS_DEBUG__?.battle(); + const hud = battle?.battleHud; return { roster: hud?.rosterPanel ?? null, recentActions: hud?.recentActions ?? null, @@ -5764,6 +5952,11 @@ async function readLargeRosterHudProbe(page) { bondPanel: hud?.bondPanel ?? null, threatPanel: hud?.threatPanel ?? null, terrainPanel: hud?.terrainPanel ?? null, + quickTabs: hud?.quickTabs ?? null, + panelTransition: hud?.panelTransition ?? null, + markerCount: battle?.markerCount ?? 0, + phase: battle?.phase ?? null, + selectedUnitId: hud?.miniMap?.selectedUnitId ?? null, sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null, @@ -5774,6 +5967,70 @@ async function readLargeRosterHudProbe(page) { }); } +function assertSideQuickTabsLayout(probe, expectedActiveTab, expectedView, expectedVisible = true) { + const quickTabs = probe.quickTabs; + if (!expectedVisible) { + assert( + quickTabs?.visible === false && quickTabs.tabs?.length === 0 && quickTabs.bounds === null, + `Expected deployment to omit side quick tabs: ${JSON.stringify(probe)}` + ); + return; + } + + const tabs = quickTabs?.tabs ?? []; + const activeTabs = tabs.filter((tab) => tab.active); + assert( + quickTabs?.visible === true && + quickTabs.enabled === true && + quickTabs.activeTab === expectedActiveTab && + quickTabs.currentView === expectedView && + tabs.length === 4 && + sameJsonValue(tabs.map((tab) => tab.id), ['situation', 'roster', 'bond', 'threat']) && + sameJsonValue(tabs.map((tab) => tab.label), ['전황', '부대', '공명', '위협']) && + sameJsonValue(tabs.map((tab) => tab.shortcut), ['1', '2', '3', '4']) && + activeTabs.length === 1 && + activeTabs[0].id === expectedActiveTab && + activeTabs[0].visualState === 'active', + `Expected one active 1-4 quick tab for ${expectedView}: ${JSON.stringify(probe)}` + ); + assert( + isFiniteBounds(quickTabs.bounds) && + boundsInside(quickTabs.bounds, probe.panelBounds) && + boundsInside(quickTabs.bounds, probe.sceneBounds) && + probe.header.sortieOrderBounds.y + probe.header.sortieOrderBounds.height <= quickTabs.bounds.y + 1 && + quickTabs.bounds.y + quickTabs.bounds.height <= quickTabs.bodyTop + 1 && + quickTabs.bodyTop === probe.sidebar.bodyTop && + probe.sidebar.bounds.y >= quickTabs.bodyTop, + `Expected quick tabs between the FHD header and side-panel body: ${JSON.stringify(probe)}` + ); + tabs.forEach((tab) => { + assert( + boundsInside(tab.bounds, quickTabs.bounds) && + boundsInside(tab.labelBounds, tab.bounds) && + boundsInside(tab.shortcutBounds, tab.bounds), + `Expected quick-tab content inside its FHD button: ${JSON.stringify(tab)}` + ); + }); + assertHorizontalBounds(tabs.map((tab) => tab.bounds), `${expectedView} quick tabs`); +} + +function assertCompletedSidePanelTransition(probe, expectedTrigger, expectedView) { + const transition = probe.panelTransition; + assert( + transition?.active === false && + transition.completedRevision === transition.revision && + transition.revision > 0 && + transition.last?.kind === 'fade-slide' && + transition.last.trigger === expectedTrigger && + transition.last.to === expectedView && + transition.last.durationMs >= 100 && + transition.last.durationMs <= 240 && + transition.last.offsetX > 0 && + transition.last.objectCount > 0, + `Expected a completed side-panel transition into ${expectedView}: ${JSON.stringify(probe)}` + ); +} + function assertLateBattleHeaderLayout(probe, label) { const { header, panelBounds, sceneBounds } = probe; const headerBounds = [ @@ -5866,6 +6123,7 @@ function assertLateBattleDeploymentLayout(probe) { function assertUnitDetailPanelLayout(probe, expectedUnitId, label) { const panel = probe.unitDetailPanel; assertLateBattleSidebarLayout(probe, `${label} unit detail`, false); + assertSideQuickTabsLayout(probe, 'roster', 'officer-detail'); assert( panel?.unitId === expectedUnitId && panel.summaryBounds?.length === 3 && @@ -5881,6 +6139,7 @@ function assertUnitDetailPanelLayout(probe, expectedUnitId, label) { panel.headerBounds, panel.nameBounds, panel.levelBadgeBounds, + panel.returnButtonBounds, panel.hpBounds, ...panel.summaryBounds, ...panel.statBounds, @@ -5893,6 +6152,8 @@ function assertUnitDetailPanelLayout(probe, expectedUnitId, label) { assert( boundsInside(panel.nameBounds, panel.headerBounds) && boundsInside(panel.levelBadgeBounds, panel.headerBounds) && + boundsInside(panel.returnButtonBounds, panel.headerBounds) && + panel.returnTarget === 'roster' && panel.nameBounds.x + panel.nameBounds.width <= panel.levelBadgeBounds.x + 1, `Expected the FHD ${label} unit name to remain inside the header and left of the level badge: ${JSON.stringify(panel)}` ); @@ -5917,6 +6178,7 @@ function assertUnitDetailPanelLayout(probe, expectedUnitId, label) { function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedTotalCount) { const panel = probe.bondPanel; assertLateBattleSidebarLayout(probe, `bond page ${expectedPage + 1}`, false); + assertSideQuickTabsLayout(probe, 'bond', 'bond'); const expectedVisibleCount = Math.min( panel?.rowsPerPage ?? 0, Math.max(0, expectedTotalCount - expectedPage * (panel?.rowsPerPage ?? 0)) @@ -5965,6 +6227,7 @@ function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedT function assertThreatPanelLayout(probe, expectedEnemyIds) { const panel = probe.threatPanel; assertLateBattleSidebarLayout(probe, 'threat detail', true); + assertSideQuickTabsLayout(probe, 'threat', 'threat-detail'); assert( panel?.tile?.x === 38 && panel.tile.y === 96 && @@ -5978,6 +6241,7 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) { const enemyBounds = panel.enemyRows.map((row) => row.rowBounds); const allBounds = [ panel.headerBounds, + panel.returnButtonBounds, ...summaryBounds, ...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]), ...enemyBounds, @@ -5985,7 +6249,11 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) { panel.messageBounds ]; assertDetailBoundsInside(probe, allBounds, 'threat detail'); - assertSituationRows(panel.summaryRows, 'threat summary'); + assert( + panel.returnTarget === 'threat-overview' && boundsInside(panel.returnButtonBounds, panel.headerBounds), + `Expected threat detail to expose an in-header range return: ${JSON.stringify(panel)}` + ); + assertSituationGrid(panel.summaryRows, 'threat summary', 2); panel.enemyRows.forEach((row) => { assert( boundsInside(row.nameBounds, row.rowBounds) && @@ -5996,7 +6264,7 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) { }); assertSequentialBounds(enemyBounds, 'threat enemy rows'); assertSequentialBounds( - [panel.headerBounds, ...summaryBounds, ...enemyBounds, panel.messageBounds], + [panel.headerBounds, boundsUnion(summaryBounds), ...enemyBounds, panel.messageBounds], 'threat detail sections' ); assert( @@ -6008,6 +6276,7 @@ function assertThreatPanelLayout(probe, expectedEnemyIds) { function assertTerrainPanelLayout(probe, expectedTerrain) { const panel = probe.terrainPanel; assertLateBattleSidebarLayout(probe, 'terrain detail', true); + assertSideQuickTabsLayout(probe, 'situation', 'terrain-detail'); assert( panel?.tile?.x === 38 && panel.tile.y === 96 && @@ -6019,11 +6288,16 @@ function assertTerrainPanelLayout(probe, expectedTerrain) { const summaryBounds = panel.summaryRows.map((row) => row.bounds); const allBounds = [ panel.headerBounds, + panel.returnButtonBounds, ...summaryBounds, ...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]), panel.messageBounds ]; assertDetailBoundsInside(probe, allBounds, 'terrain detail'); + assert( + panel.returnTarget === 'situation' && boundsInside(panel.returnButtonBounds, panel.headerBounds), + `Expected terrain detail to expose an in-header situation return: ${JSON.stringify(panel)}` + ); assertSituationRows(panel.summaryRows, 'terrain summary'); assertSequentialBounds([panel.headerBounds, ...summaryBounds, panel.messageBounds], 'terrain detail sections'); assert( @@ -6044,6 +6318,24 @@ function assertSituationRows(rows, label) { assertSequentialBounds(rows.map((row) => row.bounds), `${label} rows`); } +function assertSituationGrid(rows, label, columnCount) { + rows.forEach((row, index) => { + assert( + boundsInside(row.labelBounds, row.bounds) && + boundsInside(row.valueBounds, row.bounds) && + row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1, + `Expected ${label} cell ${index + 1} labels and values in separate columns: ${JSON.stringify(row)}` + ); + }); + const rowGroups = []; + for (let index = 0; index < rows.length; index += columnCount) { + const rowBounds = rows.slice(index, index + columnCount).map((row) => row.bounds); + assertHorizontalBounds(rowBounds, `${label} row ${rowGroups.length + 1}`); + rowGroups.push(boundsUnion(rowBounds)); + } + assertSequentialBounds(rowGroups, `${label} rows`); +} + function assertDetailBoundsInside(probe, boundsList, label) { assert( isFiniteBounds(probe.sidebar?.bounds) && diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 21eff1e..d8ea7f0 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1135,6 +1135,55 @@ type BattleLayout = { type HudBounds = { x: number; y: number; width: number; height: number }; +type SideQuickTabId = 'situation' | 'roster' | 'bond' | 'threat'; +type SidePanelView = + | 'situation' + | 'roster' + | 'officer-detail' + | 'bond' + | 'threat-overview' + | 'threat-detail' + | 'terrain-detail'; +type SidePanelTransitionTrigger = 'system' | 'tab' | 'detail' | 'back'; +type SideQuickTabVisualState = 'active' | 'idle' | 'hover' | 'disabled-active' | 'disabled'; + +type SideQuickTabLayout = { + visible: boolean; + enabled: boolean; + activeTab: SideQuickTabId; + hoveredTab?: SideQuickTabId; + currentView: SidePanelView; + bounds: HudBounds; + bodyTop: number; + tabs: Array<{ + id: SideQuickTabId; + label: string; + shortcut: string; + bounds: HudBounds; + labelBounds: HudBounds; + shortcutBounds: HudBounds; + active: boolean; + visualState: SideQuickTabVisualState; + }>; +}; + +type SidePanelTransitionRequest = { + from: SidePanelView | null; + to: SidePanelView; + trigger: SidePanelTransitionTrigger; + animate: boolean; +}; + +type SidePanelTransitionSnapshot = { + from: SidePanelView | null; + to: SidePanelView; + kind: 'fade-slide'; + trigger: Exclude; + durationMs: number; + offsetX: number; + objectCount: number; +}; + type SituationLineLayout = { bounds: HudBounds; labelBounds: HudBounds; @@ -1206,6 +1255,8 @@ type UnitDetailPanelLayout = { headerBounds: HudBounds; nameBounds: HudBounds; levelBadgeBounds: HudBounds; + returnButtonBounds: HudBounds; + returnTarget: 'roster'; hpBounds: HudBounds; summaryBounds: HudBounds[]; statBounds: HudBounds[]; @@ -1237,6 +1288,8 @@ type BondPanelLayout = { type ThreatPanelLayout = { tile: { x: number; y: number }; headerBounds: HudBounds; + returnButtonBounds: HudBounds; + returnTarget: 'threat-overview'; summaryRows: SituationLineLayout[]; enemyRows: Array<{ unitId: string; @@ -1251,6 +1304,8 @@ type TerrainPanelLayout = { tile: { x: number; y: number }; terrain: TerrainType; headerBounds: HudBounds; + returnButtonBounds: HudBounds; + returnTarget: 'situation'; summaryRows: SituationLineLayout[]; messageBounds: HudBounds; }; @@ -1287,6 +1342,19 @@ type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; type TurnPromptMode = 'turn-end' | 'post-move' | 'load-confirm' | 'save-overwrite-confirm'; +const sideQuickTabDefinitions: ReadonlyArray<{ + id: SideQuickTabId; + label: string; + shortcut: string; + code: 'Digit1' | 'Digit2' | 'Digit3' | 'Digit4'; + icon: BattleUiIconKey; +}> = [ + { id: 'situation', label: '전황', shortcut: '1', code: 'Digit1', icon: 'leadership' }, + { id: 'roster', label: '부대', shortcut: '2', code: 'Digit2', icon: 'hp' }, + { id: 'bond', label: '공명', shortcut: '3', code: 'Digit3', icon: 'focus' }, + { id: 'threat', label: '위협', shortcut: '4', code: 'Digit4', icon: 'critical' } +]; + const battleSpeedStorageKey = 'heros-web:battle-speed'; const sortieRoleOrder: SortieFormationRole[] = [...coreSortieSynergyRoles]; const tacticalInitiativeThreshold = 3; @@ -3361,6 +3429,15 @@ export class BattleScene extends Phaser.Scene { private threatPanelLayout?: ThreatPanelLayout; private terrainPanelLayout?: TerrainPanelLayout; private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; + private sideQuickTabObjects: Phaser.GameObjects.GameObject[] = []; + private activeSideQuickTab: SideQuickTabId = 'roster'; + private hoveredSideQuickTab?: SideQuickTabId; + private currentSidePanelView?: SidePanelView; + private sideQuickTabLayout?: SideQuickTabLayout; + private sidePanelTransitionRevision = 0; + private sidePanelTransitionCompletedRevision = 0; + private sidePanelTransitionActive = false; + private sidePanelTransitionLast?: SidePanelTransitionSnapshot; private commandButtons: CommandButton[] = []; private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; private deploymentObjects: Phaser.GameObjects.GameObject[] = []; @@ -3490,6 +3567,15 @@ export class BattleScene extends Phaser.Scene { this.bondPanelLayout = undefined; this.threatPanelLayout = undefined; this.terrainPanelLayout = undefined; + this.sideQuickTabObjects = []; + this.activeSideQuickTab = 'roster'; + this.hoveredSideQuickTab = undefined; + this.currentSidePanelView = undefined; + this.sideQuickTabLayout = undefined; + this.sidePanelTransitionRevision = 0; + this.sidePanelTransitionCompletedRevision = 0; + this.sidePanelTransitionActive = false; + this.sidePanelTransitionLast = undefined; const campaign = getCampaignState(); const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id ? campaign.sortieOrderSelection.orderId @@ -3652,18 +3738,32 @@ export class BattleScene extends Phaser.Scene { } if (this.commandMenuObjects.length > 0) { + const detailUnit = battleUnits.find((unit) => unit.id === this.unitDetailPanelLayout?.unitId); soundDirector.playSelect(); this.hideCommandMenu(); this.phase = 'idle'; this.selectedUnit = undefined; this.refreshUnitLegibilityStyles(); + if (detailUnit) { + this.renderUnitDetail(detailUnit, '명령 선택을 취소했습니다.', 'system'); + } else { + this.renderSideQuickTabs(); + } + return; + } + + if (this.returnFromSideDetail()) { + soundDirector.playSelect(); } }); this.input.keyboard?.on('keydown', (event: KeyboardEvent) => { if (this.handleSaveSlotPanelKey(event)) { return; } - this.handleTacticalInitiativeKey(event); + if (this.handleTacticalInitiativeKey(event)) { + return; + } + this.handleSideQuickTabKey(event); }); this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => { if (this.activeFaction !== 'enemy' || this.fastForwardHeld) { @@ -5617,6 +5717,18 @@ export class BattleScene extends Phaser.Scene { return true; } + private handleSideQuickTabKey(event: KeyboardEvent) { + if (event.repeat || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return false; + } + const definition = sideQuickTabDefinitions.find((tab) => tab.code === event.code); + if (!definition || !this.canActivateSideQuickTabs()) { + return false; + } + event.preventDefault(); + return this.activateSideQuickTab(definition.id); + } + private isBattleSpeed(value: string | null): value is BattleSpeed { return battleSpeedOptions.includes(value as BattleSpeed); } @@ -5726,6 +5838,7 @@ export class BattleScene extends Phaser.Scene { update(_time: number, delta: number) { this.updateEdgeScroll(delta); this.positionEnemyIntentVisuals(); + this.refreshSideQuickTabAvailability(); } private drawMiniMap() { @@ -6498,13 +6611,16 @@ export class BattleScene extends Phaser.Scene { private renderPointerFeedbackHint(text: string, tone: number) { const left = this.layout.panelX + this.battleUiLength(18); - const top = this.sideHeaderBottom() + this.battleUiLength(8); const width = this.layout.panelWidth - this.battleUiLength(36); const height = this.battleUiLength(32); + const quickTabsVisible = this.sideQuickTabLayout?.visible === true; + const top = quickTabsVisible + ? Math.max(this.sidePanelBodyTop(), this.sideContentBottom(6) - height) + : this.sideHeaderBottom() + this.battleUiLength(8); + const backgroundDepth = quickTabsVisible ? 40 : 28; if (!this.pointerFeedbackHintBg) { this.pointerFeedbackHintBg = this.add.rectangle(left, top, width, height, 0x0b1118, 0.96); this.pointerFeedbackHintBg.setOrigin(0); - this.pointerFeedbackHintBg.setDepth(28); } if (!this.pointerFeedbackHintText) { this.pointerFeedbackHintText = this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(8), '', { @@ -6514,13 +6630,14 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700', fixedWidth: width - this.battleUiLength(24) }); - this.pointerFeedbackHintText.setDepth(29); } + this.pointerFeedbackHintBg.setDepth(backgroundDepth); this.pointerFeedbackHintBg.setPosition(left, top); this.pointerFeedbackHintBg.setSize(width, height); this.pointerFeedbackHintBg.setStrokeStyle(this.battleUiLength(1), tone, 0.62); this.pointerFeedbackHintBg.setVisible(true); + this.pointerFeedbackHintText.setDepth(backgroundDepth + 1); this.pointerFeedbackHintText.setPosition(left + this.battleUiLength(12), top + this.battleUiLength(8)); this.pointerFeedbackHintText.setText(this.truncateUiText(text, 38)); this.pointerFeedbackHintText.setVisible(true); @@ -7393,6 +7510,7 @@ export class BattleScene extends Phaser.Scene { } private renderDeploymentPanel() { + this.clearSideQuickTabs(); this.clearSidePanelContent(); this.setMiniMapVisible(false); @@ -7765,7 +7883,7 @@ export class BattleScene extends Phaser.Scene { this.showPointerFeedback(tile, 0x9b4d45, '표시된 시작 구역 안에서만 배치할 수 있습니다.', key, 0x9b4d45, 0.08, 0.66); } - private showEnemyThreatRange() { + private showEnemyThreatRange(transitionTrigger: SidePanelTransitionTrigger = 'system') { this.clearMarkers(); const threatTiles = this.enemyThreatTiles(); threatTiles.forEach((tile) => this.renderThreatMarker(tile)); @@ -7780,7 +7898,9 @@ export class BattleScene extends Phaser.Scene { holdCount > 0 ? `고수 ${holdCount}칸` : '' ] .filter(Boolean) - .join('\n') + .join('\n'), + 'threat', + transitionTrigger ); } @@ -9632,6 +9752,7 @@ export class BattleScene extends Phaser.Scene { metrics: TargetingGuideMetric[]; hint: string; }) { + this.clearSideQuickTabs(); this.clearSidePanelContent(); this.setMiniMapVisible(false); @@ -9731,6 +9852,7 @@ export class BattleScene extends Phaser.Scene { footer: string; notice?: TargetingGuideNotice; }) { + this.clearSideQuickTabs(); this.clearSidePanelContent(); this.setMiniMapVisible(false); @@ -10492,6 +10614,7 @@ export class BattleScene extends Phaser.Scene { } private renderAttackPreview(preview: CombatPreview, locked = false) { + this.clearSideQuickTabs(); this.clearSidePanelContent(); this.setMiniMapVisible(false); this.renderCombatPreviewCard(preview, locked); @@ -10515,6 +10638,7 @@ export class BattleScene extends Phaser.Scene { } private renderSupportPreview(preview: SupportPreview, locked = false) { + this.clearSideQuickTabs(); this.clearSidePanelContent(); this.setMiniMapVisible(false); this.renderSupportPreviewCard(preview, locked); @@ -15460,7 +15584,7 @@ export class BattleScene extends Phaser.Scene { return; } if (action === 'threat') { - this.showEnemyThreatRange(); + this.activateSideQuickTab('threat', false); return; } if (action === 'save') { @@ -15472,16 +15596,16 @@ export class BattleScene extends Phaser.Scene { return; } if (action === 'roster') { - this.renderRosterPanel('ally', '부대 일람입니다.'); + this.rosterTab = 'ally'; + this.activateSideQuickTab('roster', false); return; } if (action === 'bond') { - this.bondPage = 0; - this.renderBondPanel(); + this.activateSideQuickTab('bond', false); return; } if (action === 'situation') { - this.renderSituationPanel(); + this.activateSideQuickTab('situation', false); return; } if (action === 'speed') { @@ -21790,6 +21914,345 @@ export class BattleScene extends Phaser.Scene { return Math.max(fallbackBottom, objectiveBottom, objectiveSubBottom, sortieOrderBottom); } + private sideQuickTabTop() { + return this.sideHeaderBottom() + this.battleUiLength(3); + } + + private sideQuickTabHeight() { + return this.battleUiLength(21); + } + + private sidePanelBodyTop() { + return this.sideQuickTabLayout?.visible + ? this.sideQuickTabLayout.bodyTop + : this.sideContentTop(); + } + + private shouldRenderSideQuickTabs() { + return Boolean( + this.currentSidePanelView && + this.phase !== 'deployment' && + this.phase !== 'resolved' && + !this.battleOutcome + ); + } + + private canActivateSideQuickTabs() { + return Boolean( + this.shouldRenderSideQuickTabs() && + this.phase === 'idle' && + this.activeFaction === 'ally' && + this.saveSlotPanelObjects.length === 0 && + this.turnPromptObjects.length === 0 && + this.tacticalInitiativePanelObjects.length === 0 && + this.tacticalCommandCutInObjects.length === 0 && + this.combatCutInObjects.length === 0 && + this.battleEventObjects.length === 0 && + this.resultObjects.length === 0 && + this.mapMenuObjects.length === 0 + ); + } + + private refreshSideQuickTabAvailability() { + if (!this.sideQuickTabLayout?.visible) { + return; + } + if (this.sideQuickTabLayout.enabled !== this.canActivateSideQuickTabs()) { + this.renderSideQuickTabs(); + } + } + + private prepareSidePanelView( + view: SidePanelView, + activeTab: SideQuickTabId, + trigger: SidePanelTransitionTrigger + ): SidePanelTransitionRequest { + if (this.sidePanelTransitionActive) { + this.sidePanelTransitionActive = false; + this.sidePanelTransitionCompletedRevision = this.sidePanelTransitionRevision; + } + const from = this.currentSidePanelView ?? null; + this.currentSidePanelView = view; + this.activeSideQuickTab = activeTab; + this.renderSideQuickTabs(); + return { + from, + to: view, + trigger, + animate: trigger !== 'system' && from !== null && from !== view + }; + } + + private clearSideQuickTabs(resetView = true) { + if (this.sidePanelTransitionActive) { + this.sidePanelTransitionActive = false; + this.sidePanelTransitionCompletedRevision = this.sidePanelTransitionRevision; + } + this.sideQuickTabObjects.forEach((object) => object.destroy()); + this.sideQuickTabObjects = []; + this.sideQuickTabLayout = undefined; + this.hoveredSideQuickTab = undefined; + if (resetView) { + this.currentSidePanelView = undefined; + } + } + + private trackSideQuickTabObject(object: T) { + this.sideQuickTabObjects.push(object); + return object; + } + + private renderSideQuickTabs() { + this.clearSideQuickTabs(false); + if (!this.shouldRenderSideQuickTabs() || !this.currentSidePanelView) { + return; + } + + const { panelX, panelWidth } = this.layout; + const left = panelX + this.battleUiLength(24); + const width = panelWidth - this.battleUiLength(48); + const top = this.sideQuickTabTop(); + const height = this.sideQuickTabHeight(); + const gap = this.battleUiLength(4); + const tabWidth = (width - gap * (sideQuickTabDefinitions.length - 1)) / sideQuickTabDefinitions.length; + const enabled = this.canActivateSideQuickTabs(); + const bodyTop = top + height + this.battleUiLength(2); + const tabs: SideQuickTabLayout['tabs'] = []; + + sideQuickTabDefinitions.forEach((definition, index) => { + const x = left + index * (tabWidth + gap); + const active = definition.id === this.activeSideQuickTab; + const background = this.trackSideQuickTabObject(this.add.rectangle(x, top, tabWidth, height, 0x141e29, 0.86)); + background.setName(`side-quick-tab-${definition.id}`); + background.setOrigin(0); + background.setDepth(36); + + const icon = this.trackSideQuickTabObject(this.createBattleUiIcon( + x + this.battleUiLength(9), + top + height / 2, + definition.icon, + this.battleUiLength(14) + )); + icon.setDepth(38); + + const label = this.trackSideQuickTabObject(this.add.text( + x + this.battleUiLength(33), + top + height / 2, + definition.label, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(11), + color: '#aeb7c2', + fontStyle: '700' + } + )); + label.setOrigin(0.5); + label.setDepth(38); + + const shortcutSize = this.battleUiLength(10); + const shortcutX = x + tabWidth - this.battleUiLength(7); + const shortcutY = top + this.battleUiLength(6); + const shortcutBg = this.trackSideQuickTabObject(this.add.rectangle( + shortcutX, + shortcutY, + shortcutSize, + shortcutSize, + 0x0a0f14, + 0.9 + )); + shortcutBg.setDepth(37); + const shortcutText = this.trackSideQuickTabObject(this.add.text(shortcutX, shortcutY, definition.shortcut, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(7), + color: '#9fb0bf', + fontStyle: '700' + })); + shortcutText.setOrigin(0.5); + shortcutText.setDepth(38); + + const activeLine = this.trackSideQuickTabObject(this.add.rectangle( + x, + top + height - this.battleUiLength(2), + tabWidth, + this.battleUiLength(2), + palette.gold, + active ? 0.96 : 0 + )); + activeLine.setOrigin(0); + activeLine.setDepth(39); + + const labelBounds = label.getBounds(); + const layoutEntry: SideQuickTabLayout['tabs'][number] = { + id: definition.id, + label: definition.label, + shortcut: definition.shortcut, + bounds: { x, y: top, width: tabWidth, height }, + labelBounds: { x: labelBounds.x, y: labelBounds.y, width: labelBounds.width, height: labelBounds.height }, + shortcutBounds: { + x: shortcutX - shortcutSize / 2, + y: shortcutY - shortcutSize / 2, + width: shortcutSize, + height: shortcutSize + }, + active, + visualState: enabled ? (active ? 'active' : 'idle') : active ? 'disabled-active' : 'disabled' + }; + tabs.push(layoutEntry); + + const applyVisual = (hovered: boolean) => { + const available = this.canActivateSideQuickTabs(); + const state: SideQuickTabVisualState = !available + ? active ? 'disabled-active' : 'disabled' + : active + ? 'active' + : hovered + ? 'hover' + : 'idle'; + if (state === 'active') { + background.setFillStyle(0x25384a, 0.98); + background.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.92); + label.setColor('#f4dfad'); + icon.setAlpha(1); + shortcutBg.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.66); + shortcutText.setColor('#f4dfad'); + activeLine.setAlpha(0.96); + } else if (state === 'hover') { + background.setFillStyle(0x243746, 0.98); + background.setStrokeStyle(this.battleUiLength(1), palette.blue, 0.86); + label.setColor('#f2e3bf'); + icon.setAlpha(1); + shortcutBg.setStrokeStyle(this.battleUiLength(1), palette.blue, 0.62); + shortcutText.setColor('#d9efff'); + activeLine.setAlpha(0); + } else if (state === 'disabled-active') { + background.setFillStyle(0x172029, 0.7); + background.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.4); + label.setColor('#9d9277'); + icon.setAlpha(0.46); + shortcutBg.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.34); + shortcutText.setColor('#9d9277'); + activeLine.setAlpha(0.38); + } else if (state === 'disabled') { + background.setFillStyle(0x101820, 0.58); + background.setStrokeStyle(this.battleUiLength(1), 0x40515e, 0.28); + label.setColor('#65717d'); + icon.setAlpha(0.35); + shortcutBg.setStrokeStyle(this.battleUiLength(1), 0x40515e, 0.26); + shortcutText.setColor('#65717d'); + activeLine.setAlpha(0); + } else { + background.setFillStyle(0x141e29, 0.86); + background.setStrokeStyle(this.battleUiLength(1), 0x53606c, 0.62); + label.setColor('#aeb7c2'); + icon.setAlpha(0.72); + shortcutBg.setStrokeStyle(this.battleUiLength(1), 0x53606c, 0.46); + shortcutText.setColor('#9fb0bf'); + activeLine.setAlpha(0); + } + layoutEntry.visualState = state; + }; + + applyVisual(false); + background.setInteractive({ useHandCursor: enabled }); + background.on('pointerover', () => { + this.hoveredSideQuickTab = definition.id; + if (this.sideQuickTabLayout) { + this.sideQuickTabLayout.hoveredTab = definition.id; + } + applyVisual(true); + }); + background.on('pointerout', () => { + if (this.hoveredSideQuickTab === definition.id) { + this.hoveredSideQuickTab = undefined; + } + if (this.sideQuickTabLayout?.hoveredTab === definition.id) { + this.sideQuickTabLayout.hoveredTab = undefined; + } + applyVisual(false); + }); + background.on('pointerdown', () => { + if (!this.activateSideQuickTab(definition.id)) { + applyVisual(false); + } + }); + }); + + this.sideQuickTabLayout = { + visible: true, + enabled, + activeTab: this.activeSideQuickTab, + hoveredTab: this.hoveredSideQuickTab, + currentView: this.currentSidePanelView, + bounds: { x: left, y: top, width, height }, + bodyTop, + tabs + }; + } + + private playSidePanelTransition(request: SidePanelTransitionRequest) { + if (!request.animate || request.trigger === 'system') { + return; + } + const objects = this.sidePanelObjects.filter((object) => object.active) as Array Phaser.GameObjects.GameObject; + setAlpha: (value: number) => Phaser.GameObjects.GameObject; + }>; + if (objects.length === 0) { + return; + } + + const durationMs = 140; + const offsetX = this.battleUiLength(8); + const revision = this.sidePanelTransitionRevision + 1; + const originalAlphas = objects.map((object) => object.alpha); + this.sidePanelTransitionRevision = revision; + this.sidePanelTransitionLast = { + from: request.from, + to: request.to, + kind: 'fade-slide', + trigger: request.trigger, + durationMs, + offsetX, + objectCount: objects.length + }; + + const reducedMotion = typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reducedMotion) { + this.sidePanelTransitionActive = false; + this.sidePanelTransitionCompletedRevision = revision; + return; + } + + this.sidePanelTransitionActive = true; + objects.forEach((object, index) => { + object.setX(object.x + offsetX); + object.setAlpha(originalAlphas[index] * 0.35); + }); + this.tweens.add({ + targets: objects, + x: `-=${offsetX}`, + alpha: 1, + duration: durationMs, + ease: 'Cubic.easeOut', + onComplete: () => { + if (revision !== this.sidePanelTransitionRevision) { + return; + } + objects.forEach((object, index) => { + if (object.active) { + object.setAlpha(originalAlphas[index]); + } + }); + this.sidePanelTransitionActive = false; + this.sidePanelTransitionCompletedRevision = revision; + } + }); + } + private sideContentTop() { return this.sideHeaderBottom() + this.battleUiLength(20); } @@ -21894,13 +22357,14 @@ export class BattleScene extends Phaser.Scene { }); } - private renderBondPanel(message?: string) { + private renderBondPanel(message?: string, transitionTrigger: SidePanelTransitionTrigger = 'system') { + const transition = this.prepareSidePanelView('bond', 'bond', transitionTrigger); this.clearSidePanelContent(); this.setMiniMapVisible(false); const { panelX, panelWidth } = this.layout; const left = panelX + this.battleUiLength(24); const width = panelWidth - this.battleUiLength(48); - const top = this.sideContentTop(); + const top = this.sidePanelBodyTop(); const bottom = this.sideContentBottom(6); const headerHeight = this.battleUiLength(40); const rowHeight = this.battleUiLength(60); @@ -22018,6 +22482,7 @@ export class BattleScene extends Phaser.Scene { navigationBounds, messageBounds: { x: left, y: messageY, width, height: messageHeight } }; + this.playSidePanelTransition(transition); } private renderBondPageNavigation(x: number, y: number, width: number, pageCount: number, message?: string) { @@ -22060,13 +22525,19 @@ export class BattleScene extends Phaser.Scene { return { x, y, width, height }; } - private renderSituationPanel(message?: string) { + private renderSituationPanel( + message?: string, + activeTab: SideQuickTabId = 'situation', + transitionTrigger: SidePanelTransitionTrigger = 'system' + ) { + const view: SidePanelView = activeTab === 'threat' ? 'threat-overview' : 'situation'; + const transition = this.prepareSidePanelView(view, activeTab, transitionTrigger); this.clearSidePanelContent(); this.setMiniMapVisible(true); const { panelX, panelWidth } = this.layout; const left = panelX + this.battleUiLength(24); const width = panelWidth - this.battleUiLength(48); - const top = this.sideContentTop(); + const top = this.sidePanelBodyTop(); const actedCount = battleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length; const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length; const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; @@ -22079,7 +22550,7 @@ export class BattleScene extends Phaser.Scene { Math.min(this.battleUiLength(72), logTop - messageTop - this.battleUiLength(8)) ); - this.trackSideObject(this.add.text(left, top, '전황', { + this.trackSideObject(this.add.text(left, top, activeTab === 'threat' ? '위협 범위' : '전황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(20), color: '#f2e3bf', @@ -22095,6 +22566,7 @@ export class BattleScene extends Phaser.Scene { if (logPlacement) { this.renderRecentActionLogPanel(left, logPlacement.y, width, logPlacement.height); } + this.playSidePanelTransition(transition); return; } @@ -22109,6 +22581,7 @@ export class BattleScene extends Phaser.Scene { if (logPlacement) { this.renderRecentActionLogPanel(left, logPlacement.y, width, logPlacement.height); } + this.playSidePanelTransition(transition); } private compactSituationMessage(message: string) { @@ -22345,7 +22818,11 @@ export class BattleScene extends Phaser.Scene { return palette.gold; } - private renderThreatDetail(tile: ThreatTile) { + private renderThreatDetail( + tile: ThreatTile, + transitionTrigger: SidePanelTransitionTrigger = 'detail' + ) { + const transition = this.prepareSidePanelView('threat-detail', 'threat', transitionTrigger); this.clearSidePanelContent(); this.setMiniMapVisible(true); const terrain = battleMap.terrain[tile.y][tile.x]; @@ -22353,14 +22830,30 @@ export class BattleScene extends Phaser.Scene { const { panelX, panelWidth } = this.layout; const left = panelX + this.battleUiLength(24); const width = panelWidth - this.battleUiLength(48); - const top = this.sideContentTop(); + const top = this.sidePanelBodyTop(); const bottom = this.sideContentBottom(6); const strongest = [...tile.enemies].sort((a, b) => this.actionPower(b, 'attack') - this.actionPower(a, 'attack'))[0]; + const summaryGap = this.battleUiLength(4); + const summaryWidth = (width - summaryGap) / 2; const summaryRows = [ - this.renderSituationLine('좌표', `${tile.x + 1}, ${tile.y + 1}`, left, top + this.battleUiLength(28), width, battleFhdUiScale), - this.renderSituationLine('지형', `${terrainRule.label} / 방어 ${this.formatSignedValue(terrainRule.defenseBonus)}`, left, top + this.battleUiLength(56), width, battleFhdUiScale), - this.renderSituationLine('위협 수', `${tile.enemies.length}`, left, top + this.battleUiLength(84), width, battleFhdUiScale), - this.renderSituationLine('최대 태세', this.enemyBehaviorLabel(tile.strongestBehavior), left, top + this.battleUiLength(112), width, battleFhdUiScale) + this.renderSituationLine('좌표', `${tile.x + 1}, ${tile.y + 1}`, left, top + this.battleUiLength(26), summaryWidth, battleFhdUiScale), + this.renderSituationLine( + '지형', + `${terrainRule.label} / 방어 ${this.formatSignedValue(terrainRule.defenseBonus)}`, + left + summaryWidth + summaryGap, + top + this.battleUiLength(26), + summaryWidth, + battleFhdUiScale + ), + this.renderSituationLine('위협 수', `${tile.enemies.length}`, left, top + this.battleUiLength(52), summaryWidth, battleFhdUiScale), + this.renderSituationLine( + '최대 태세', + this.enemyBehaviorLabel(tile.strongestBehavior), + left + summaryWidth + summaryGap, + top + this.battleUiLength(52), + summaryWidth, + battleFhdUiScale + ) ]; this.trackSideObject(this.add.text(left, top, '위험 범위', { @@ -22369,10 +22862,15 @@ export class BattleScene extends Phaser.Scene { color: '#f2e3bf', fontStyle: '700' })); + const returnButtonBounds = this.renderSideBackButton( + left + width - this.battleUiLength(60), + top + this.battleUiLength(1), + '범위로' + ); const visibleEnemies = tile.enemies.slice(0, 4); const hiddenCount = Math.max(0, tile.enemies.length - visibleEnemies.length); - this.trackSideObject(this.add.text(left, top + this.battleUiLength(142), hiddenCount > 0 ? `위협 부대 · 외 ${hiddenCount}` : '위협 부대', { + this.trackSideObject(this.add.text(left, top + this.battleUiLength(82), hiddenCount > 0 ? `위협 부대 · 외 ${hiddenCount}` : '위협 부대', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(12), color: '#f2e3bf', @@ -22381,9 +22879,9 @@ export class BattleScene extends Phaser.Scene { const enemyRows: ThreatPanelLayout['enemyRows'] = []; const rowHeight = this.battleUiLength(23); - const rowStep = this.battleUiLength(25); + const rowStep = this.battleUiLength(24); visibleEnemies.forEach((enemy, index) => { - const rowY = top + this.battleUiLength(158) + index * rowStep; + const rowY = top + this.battleUiLength(98) + index * rowStep; const unitClass = getUnitClass(enemy.classKey); const bg = this.trackSideObject(this.add.rectangle(left, rowY, width, rowHeight, 0x101820, 0.9)); bg.setOrigin(0); @@ -22422,13 +22920,21 @@ export class BattleScene extends Phaser.Scene { this.threatPanelLayout = { tile: { x: tile.x, y: tile.y }, headerBounds: { x: left, y: top, width, height: this.battleUiLength(22) }, + returnButtonBounds, + returnTarget: 'threat-overview', summaryRows, enemyRows, messageBounds: { x: left, y: messageY, width, height: messageHeight } }; + this.playSidePanelTransition(transition); } - private renderTerrainDetail(x: number, y: number) { + private renderTerrainDetail( + x: number, + y: number, + transitionTrigger: SidePanelTransitionTrigger = 'detail' + ) { + const transition = this.prepareSidePanelView('terrain-detail', 'situation', transitionTrigger); this.clearSidePanelContent(); this.setMiniMapVisible(true); const terrain = battleMap.terrain[y][x]; @@ -22436,7 +22942,7 @@ export class BattleScene extends Phaser.Scene { const { panelX, panelWidth } = this.layout; const left = panelX + this.battleUiLength(24); const width = panelWidth - this.battleUiLength(48); - const top = this.sideContentTop(); + const top = this.sidePanelBodyTop(); const bottom = this.sideContentBottom(6); const attackDefense = Math.floor(terrainRule.defenseBonus / 3); const strategyDefense = Math.floor(terrainRule.defenseBonus / 4); @@ -22466,6 +22972,11 @@ export class BattleScene extends Phaser.Scene { color: '#f2e3bf', fontStyle: '700' })); + const returnButtonBounds = this.renderSideBackButton( + left + width - this.battleUiLength(60), + top + this.battleUiLength(27), + '전황으로' + ); const summaryRows = [ this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + this.battleUiLength(52), width, battleFhdUiScale), @@ -22498,9 +23009,12 @@ export class BattleScene extends Phaser.Scene { tile: { x, y }, terrain, headerBounds: { x: left, y: top, width, height: headerHeight }, + returnButtonBounds, + returnTarget: 'situation', summaryRows, messageBounds: { x: left, y: messageY, width, height: messageHeight } }; + this.playSidePanelTransition(transition); } private formatSignedValue(value: number) { @@ -22560,19 +23074,24 @@ export class BattleScene extends Phaser.Scene { return getCampaignState().roster.find((unit) => unit.id === unitId)?.name ?? unitId; } - private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) { + private renderRosterPanel( + tab: RosterTab = this.rosterTab, + message?: string, + transitionTrigger: SidePanelTransitionTrigger = 'system' + ) { const tabChanged = this.rosterTab !== tab; this.rosterTab = tab; if (tabChanged) { this.rosterPage = 0; } + const transition = this.prepareSidePanelView('roster', 'roster', transitionTrigger); this.clearSidePanelContent(); this.setMiniMapVisible(true); const { panelX, panelWidth } = this.layout; const left = panelX + this.battleUiLength(24); const width = panelWidth - this.battleUiLength(48); - const top = this.sideContentTop(); + const top = this.sidePanelBodyTop(); const tabGap = this.battleUiLength(6); const tabHeight = this.battleUiLength(30); const tabWidth = (width - tabGap) / 2; @@ -22679,6 +23198,7 @@ export class BattleScene extends Phaser.Scene { navigationBounds, messageBounds }; + this.playSidePanelTransition(transition); } private renderRosterPageNavigation(x: number, y: number, width: number, pageCount: number, message?: string) { @@ -22792,14 +23312,19 @@ export class BattleScene extends Phaser.Scene { }; } - private renderUnitDetail(unit: UnitData, message?: string) { + private renderUnitDetail( + unit: UnitData, + message?: string, + transitionTrigger: SidePanelTransitionTrigger = 'detail' + ) { + const transition = this.prepareSidePanelView('officer-detail', 'roster', transitionTrigger); this.clearSidePanelContent(); this.setMiniMapVisible(false); const { panelX, panelWidth } = this.layout; const left = panelX + this.battleUiLength(24); const width = panelWidth - this.battleUiLength(48); - const top = this.sideContentTop(); + const top = this.sidePanelBodyTop(); const acted = this.actedUnitIds.has(unit.id); const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; const unitClass = getUnitClass(unit.classKey); @@ -22841,34 +23366,21 @@ export class BattleScene extends Phaser.Scene { color: acted ? '#bdbdbd' : '#f2e3bf', fontStyle: '700' })); - this.fitTextToWidth(unitNameText, unitNameLabel, levelBadge.x - this.battleUiLength(6) - headerTextX); + const headerActionLeft = left + width - this.battleUiLength(66); + this.fitTextToWidth( + unitNameText, + unitNameLabel, + Math.min(levelBadge.x, headerActionLeft) - this.battleUiLength(6) - headerTextX + ); - if (this.phase !== 'command') { - const backBg = this.trackSideObject(this.add.rectangle(left + width - this.battleUiLength(50), top + this.battleUiLength(32), this.battleUiLength(42), this.battleUiLength(15), 0x223142, 0.95)); - backBg.setOrigin(0); - backBg.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.72); - backBg.setInteractive({ useHandCursor: true }); - backBg.on('pointerdown', () => this.returnToRosterPanel(unit.faction)); + const returnButtonBounds = this.renderSideBackButton( + left + width - this.battleUiLength(60), + top + this.battleUiLength(32), + '목록으로' + ); - const backText = this.trackSideObject(this.add.text(left + width - this.battleUiLength(29), top + this.battleUiLength(39.5), '목록', { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: this.battleUiFontSize(9), - color: '#f4dfad', - fontStyle: '700' - })); - backText.setOrigin(0.5); - } else { - const actionText = this.trackSideObject(this.add.text(left + width - this.battleUiLength(32), top + this.battleUiLength(39), acted ? '행동완료' : `EXP ${unit.exp}/100`, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: this.battleUiFontSize(8), - color: acted ? '#bdbdbd' : '#c8d2dd', - fontStyle: '700' - })); - actionText.setOrigin(0.5); - } - - const hpTop = top + this.battleUiLength(58); - const hpHeight = this.battleUiLength(26); + const hpTop = top + this.battleUiLength(56); + const hpHeight = this.battleUiLength(24); const hpPanel = this.trackSideObject(this.add.rectangle(left, hpTop, width, hpHeight, 0x101820, 0.92)); hpPanel.setOrigin(0); hpPanel.setStrokeStyle(this.battleUiLength(1), 0x40515e, 0.58); @@ -22889,7 +23401,7 @@ export class BattleScene extends Phaser.Scene { this.drawGauge(left + this.battleUiLength(78), hpTop + this.battleUiLength(18), width - this.battleUiLength(88), this.battleUiLength(4), unit.hp / unit.maxHp, 0x59d18c); const attackBonus = this.equipmentAttackBonus(unit); - const summaryTop = hpTop + this.battleUiLength(30); + const summaryTop = hpTop + this.battleUiLength(28); const summaryGap = this.battleUiLength(4); const summaryBoxWidth = (width - summaryGap * 2) / 3; const summaryBounds = [ @@ -22908,8 +23420,8 @@ export class BattleScene extends Phaser.Scene { )); const effects = this.unitEffectSummaries(unit); - const statTop = summaryTop + this.battleUiLength(38); - const statStep = this.battleUiLength(17); + const statTop = summaryTop + this.battleUiLength(36); + const statStep = this.battleUiLength(16); const statBounds = statLabels.map((stat, index) => ( this.renderStatRow(stat.key, stat.label, unit.stats[stat.key], left, statTop + index * statStep, width) )); @@ -22917,8 +23429,8 @@ export class BattleScene extends Phaser.Scene { const moveAllowance = this.movementAllowance(unit); const moveText = moveAllowance > unit.move ? `${moveAllowance} (편성 +${moveAllowance - unit.move})` : `${unit.move}`; const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} · ${terrainRule.label} ${terrainRating}% · 이동 ${moveText}${acted ? ' · 행동완료' : ''}`; - const statusTop = statTop + statLabels.length * statStep + this.battleUiLength(4); - const statusHeight = this.battleUiLength(28); + const statusTop = statTop + statLabels.length * statStep + this.battleUiLength(3); + const statusHeight = this.battleUiLength(26); const statusPanel = this.trackSideObject(this.add.rectangle(left, statusTop, width, statusHeight, 0x101820, 0.9)); statusPanel.setOrigin(0); statusPanel.setStrokeStyle(this.battleUiLength(1), 0x40515e, 0.5); @@ -22931,14 +23443,14 @@ export class BattleScene extends Phaser.Scene { })); this.fitTextToWidth(position, positionText, width - this.battleUiLength(38)); - const effectTop = statusTop + this.battleUiLength(32); + const effectTop = statusTop + this.battleUiLength(29); const effectBounds = this.renderUnitEffectCards(effects, left, effectTop, width); - const equipmentTop = effectTop + this.battleUiLength(46); + const equipmentTop = effectTop + this.battleUiLength(40); const equipmentBounds = this.renderEquipmentSummary(unit, left, equipmentTop, width, true); let messageBounds: HudBounds | null = null; if (message) { const resultLine = this.actionResultSummaryParts(this.firstMessageLine(message)); - const messageTop = equipmentTop + this.battleUiLength(106); + const messageTop = equipmentTop + this.battleUiLength(98); const messageHeight = this.battleUiLength(40); if (resultLine) { messageBounds = this.renderPanelActionResultMessage(resultLine, left, messageTop, width); @@ -22952,6 +23464,8 @@ export class BattleScene extends Phaser.Scene { headerBounds: { x: left, y: top, width, height: headerHeight }, nameBounds: { x: nameBounds.x, y: nameBounds.y, width: nameBounds.width, height: nameBounds.height }, levelBadgeBounds: { x: levelBadge.x, y: levelBadge.y, width: levelBadge.width, height: levelBadge.height }, + returnButtonBounds, + returnTarget: 'roster', hpBounds: { x: left, y: hpTop, width, height: hpHeight }, summaryBounds, statBounds, @@ -22961,6 +23475,7 @@ export class BattleScene extends Phaser.Scene { messageBounds }; this.showFacingIndicator(unit); + this.playSidePanelTransition(transition); } private unitEffectSummaries(unit: UnitData): UnitEffectSummary[] { @@ -23045,7 +23560,7 @@ export class BattleScene extends Phaser.Scene { private renderUnitEffectCards(effects: UnitEffectSummary[], x: number, y: number, width: number) { const visible = effects.slice(0, 3); const gap = this.battleUiLength(4); - const cardHeight = this.battleUiLength(40); + const cardHeight = this.battleUiLength(36); const cardWidth = (width - gap * (visible.length - 1)) / Math.max(1, visible.length); const bounds = visible.map((effect, index) => { const cardX = x + index * (cardWidth + gap); @@ -23118,7 +23633,7 @@ export class BattleScene extends Phaser.Scene { })); return equipmentSlots.map((slot, index) => { - const rowTop = y + (compact ? this.battleUiLength(18) : 28) + index * (compact ? this.battleUiLength(28) : 40); + const rowTop = y + (compact ? this.battleUiLength(16) : 28) + index * (compact ? this.battleUiLength(26) : 40); return this.renderEquipmentRow(unit, slot, x, rowTop, width, compact); }); } @@ -23128,7 +23643,7 @@ export class BattleScene extends Phaser.Scene { const item = getItem(state.itemId); const next = equipmentExpToNext(state.level); const isTreasure = item.rank === 'treasure'; - const rowHeight = compact ? this.battleUiLength(26) : 38; + const rowHeight = compact ? this.battleUiLength(24) : 38; const bg = this.trackSideObject(this.add.rectangle(x, y, width, rowHeight, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.98 : 0.95)); bg.setOrigin(0); bg.setStrokeStyle(compact ? this.battleUiLength(1) : 1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.66 : 0.5); @@ -23165,7 +23680,7 @@ export class BattleScene extends Phaser.Scene { const levelBounds = levelText.getBounds(); this.fitTextToWidth(bonus, bonusText, levelBounds.x - this.battleUiLength(5) - bonusX); - const itemName = this.trackSideObject(this.add.text(textX, y + (compact ? this.battleUiLength(11) : 17), item.name, { + const itemName = this.trackSideObject(this.add.text(textX, y + (compact ? this.battleUiLength(10) : 17), item.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? this.battleUiFontSize(9) : '15px', color: isTreasure ? '#f4dfad' : '#d4dce6', @@ -23173,8 +23688,8 @@ export class BattleScene extends Phaser.Scene { })); this.fitTextToWidth(itemName, item.name, width - (textX - x) - (compact ? this.battleUiLength(74) : 150)); - this.trackSideIcon(x + width - (compact ? this.battleUiLength(50) : 76), y + (compact ? this.battleUiLength(20) : 27), 'mastery', compact ? this.battleUiLength(12) : 20); - const expText = this.trackSideObject(this.add.text(x + width - (compact ? this.battleUiLength(7) : 10), y + (compact ? this.battleUiLength(16) : 24), `${state.exp}/${next}`, { + this.trackSideIcon(x + width - (compact ? this.battleUiLength(50) : 76), y + (compact ? this.battleUiLength(18) : 27), 'mastery', compact ? this.battleUiLength(12) : 20); + const expText = this.trackSideObject(this.add.text(x + width - (compact ? this.battleUiLength(7) : 10), y + (compact ? this.battleUiLength(15) : 24), `${state.exp}/${next}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? this.battleUiFontSize(7) : '11px', color: '#c8d2dd', @@ -23184,7 +23699,7 @@ export class BattleScene extends Phaser.Scene { this.drawGauge( textX, - y + (compact ? this.battleUiLength(22) : 32), + y + (compact ? this.battleUiLength(21) : 32), width - (textX - x) - (compact ? this.battleUiLength(52) : 84), compact ? this.battleUiLength(3) : 5, state.exp / next, @@ -23444,7 +23959,149 @@ export class BattleScene extends Phaser.Scene { return { x, y: safeY, width, height }; } - private returnToRosterPanel(tab: RosterTab) { + private renderSideBackButton(x: number, y: number, label: string) { + const width = this.battleUiLength(60); + const height = this.battleUiLength(18); + const enabled = this.canReturnFromSideDetail(); + const background = this.trackSideObject(this.add.rectangle( + x, + y, + width, + height, + enabled ? 0x223142 : 0x101820, + enabled ? 0.95 : 0.58 + )); + background.setOrigin(0); + background.setDepth(34); + background.setStrokeStyle( + this.battleUiLength(1), + enabled ? palette.gold : 0x40515e, + enabled ? 0.72 : 0.28 + ); + const text = this.trackSideObject(this.add.text(x + width / 2, y + height / 2, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(9), + color: enabled ? '#f4dfad' : '#65717d', + fontStyle: '700' + })); + text.setOrigin(0.5); + text.setDepth(35); + + const applyVisual = (hovered: boolean) => { + const available = this.canReturnFromSideDetail(); + if (available && hovered) { + background.setFillStyle(0x2b4052, 0.98); + background.setStrokeStyle(this.battleUiLength(1), palette.blue, 0.86); + text.setColor('#f2e3bf'); + } else if (available) { + background.setFillStyle(0x223142, 0.95); + background.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.72); + text.setColor('#f4dfad'); + } else { + background.setFillStyle(0x101820, 0.58); + background.setStrokeStyle(this.battleUiLength(1), 0x40515e, 0.28); + text.setColor('#65717d'); + } + }; + applyVisual(false); + background.setInteractive({ useHandCursor: enabled }); + background.on('pointerover', () => applyVisual(true)); + background.on('pointerout', () => applyVisual(false)); + background.on('pointerdown', () => { + if (this.returnFromSideDetail()) { + soundDirector.playSelect(); + } else { + applyVisual(false); + } + }); + return { x, y, width, height }; + } + + private canReturnFromSideDetail() { + const detailView = + this.currentSidePanelView === 'officer-detail' || + this.currentSidePanelView === 'terrain-detail' || + this.currentSidePanelView === 'threat-detail'; + return Boolean( + detailView && + this.phase !== 'deployment' && + this.phase !== 'command' && + this.phase !== 'targeting' && + this.phase !== 'animating' && + this.phase !== 'resolved' && + this.activeFaction === 'ally' && + !this.battleOutcome && + this.saveSlotPanelObjects.length === 0 && + this.turnPromptObjects.length === 0 && + this.tacticalInitiativePanelObjects.length === 0 && + this.tacticalCommandCutInObjects.length === 0 && + this.combatCutInObjects.length === 0 && + this.battleEventObjects.length === 0 && + this.mapMenuObjects.length === 0 && + this.resultObjects.length === 0 + ); + } + + private returnFromSideDetail() { + if (!this.canReturnFromSideDetail()) { + return false; + } + + if (this.currentSidePanelView === 'officer-detail') { + const unit = battleUnits.find((candidate) => candidate.id === this.unitDetailPanelLayout?.unitId); + this.returnToRosterPanel(unit?.faction ?? this.rosterTab, 'back'); + return true; + } + + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.targetingAction = undefined; + this.selectedUsable = undefined; + this.phase = 'idle'; + this.clearMarkers(); + this.hideCommandMenu(); + this.updateMiniMap(); + this.refreshUnitLegibilityStyles(); + if (this.currentSidePanelView === 'threat-detail') { + this.showEnemyThreatRange('back'); + } else { + this.renderSituationPanel(undefined, 'situation', 'back'); + } + return true; + } + + private activateSideQuickTab(tab: SideQuickTabId, playSound = true) { + if (!this.canActivateSideQuickTabs()) { + return false; + } + if (playSound) { + soundDirector.playSelect(); + } + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.targetingAction = undefined; + this.selectedUsable = undefined; + this.phase = 'idle'; + this.clearMarkers(); + this.hideCommandMenu(); + this.hideTurnEndPrompt(); + this.updateMiniMap(); + this.refreshUnitLegibilityStyles(); + + if (tab === 'situation') { + this.renderSituationPanel(undefined, 'situation', 'tab'); + } else if (tab === 'roster') { + this.renderRosterPanel(this.rosterTab, '부대 일람입니다.', 'tab'); + } else if (tab === 'bond') { + this.bondPage = 0; + this.renderBondPanel(undefined, 'tab'); + } else { + this.showEnemyThreatRange('tab'); + } + return true; + } + + private returnToRosterPanel(tab: RosterTab, transitionTrigger: SidePanelTransitionTrigger = 'back') { this.selectedUnit = undefined; this.pendingMove = undefined; this.phase = 'idle'; @@ -23452,10 +24109,11 @@ export class BattleScene extends Phaser.Scene { this.hideCommandMenu(); this.updateMiniMap(); this.refreshUnitLegibilityStyles(); - this.renderRosterPanel(tab); + this.renderRosterPanel(tab, undefined, transitionTrigger); } private clearSidePanelContent() { + this.tweens?.killTweensOf(this.sidePanelObjects); this.sidePanelObjects.forEach((object) => object.destroy()); this.sidePanelObjects = []; this.recentActionLogLayout = undefined; @@ -23780,8 +24438,37 @@ export class BattleScene extends Phaser.Scene { const sidebar = { bounds: this.gameObjectCollectionBoundsDebug(this.sidePanelObjects), contentTop: this.sideContentTop(), + bodyTop: this.sidePanelBodyTop(), contentBottom: this.sideContentBottom(6) }; + const quickTabs = this.sideQuickTabLayout + ? { + ...this.sideQuickTabLayout, + hoveredTab: this.hoveredSideQuickTab ?? null, + bounds: { ...this.sideQuickTabLayout.bounds }, + tabs: this.sideQuickTabLayout.tabs.map((tab) => ({ + ...tab, + bounds: { ...tab.bounds }, + labelBounds: { ...tab.labelBounds }, + shortcutBounds: { ...tab.shortcutBounds } + })) + } + : { + visible: false, + enabled: false, + activeTab: this.activeSideQuickTab, + hoveredTab: null, + currentView: this.currentSidePanelView ?? null, + bounds: null, + bodyTop: this.sideContentTop(), + tabs: [] + }; + const panelTransition = { + revision: this.sidePanelTransitionRevision, + completedRevision: this.sidePanelTransitionCompletedRevision, + active: this.sidePanelTransitionActive, + last: this.sidePanelTransitionLast ? { ...this.sidePanelTransitionLast } : null + }; const deploymentPanel = this.deploymentPanelLayout ? { headerBounds: { ...this.deploymentPanelLayout.headerBounds }, @@ -23798,6 +24485,7 @@ export class BattleScene extends Phaser.Scene { headerBounds: { ...this.unitDetailPanelLayout.headerBounds }, nameBounds: { ...this.unitDetailPanelLayout.nameBounds }, levelBadgeBounds: { ...this.unitDetailPanelLayout.levelBadgeBounds }, + returnButtonBounds: { ...this.unitDetailPanelLayout.returnButtonBounds }, hpBounds: { ...this.unitDetailPanelLayout.hpBounds }, summaryBounds: this.unitDetailPanelLayout.summaryBounds.map((bounds) => ({ ...bounds })), statBounds: this.unitDetailPanelLayout.statBounds.map((bounds) => ({ ...bounds })), @@ -23829,6 +24517,7 @@ export class BattleScene extends Phaser.Scene { ...this.threatPanelLayout, tile: { ...this.threatPanelLayout.tile }, headerBounds: { ...this.threatPanelLayout.headerBounds }, + returnButtonBounds: { ...this.threatPanelLayout.returnButtonBounds }, summaryRows: this.threatPanelLayout.summaryRows.map((row) => ({ bounds: { ...row.bounds }, labelBounds: { ...row.labelBounds }, @@ -23848,6 +24537,7 @@ export class BattleScene extends Phaser.Scene { ...this.terrainPanelLayout, tile: { ...this.terrainPanelLayout.tile }, headerBounds: { ...this.terrainPanelLayout.headerBounds }, + returnButtonBounds: { ...this.terrainPanelLayout.returnButtonBounds }, summaryRows: this.terrainPanelLayout.summaryRows.map((row) => ({ bounds: { ...row.bounds }, labelBounds: { ...row.labelBounds }, @@ -23860,6 +24550,8 @@ export class BattleScene extends Phaser.Scene { return { header, sidebar, + quickTabs, + panelTransition, deploymentPanel, unitDetailPanel, bondPanel,