diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index a331a72..f69c11e 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -3411,11 +3411,11 @@ try { ); assert( midBriefingState?.sortiePortraitRosterView?.enabled === true && - midBriefingState?.sortiePortraitRosterView?.total > 8 && - midBriefingState?.sortiePortraitRosterView?.visibleCount === 8 && + midBriefingState?.sortiePortraitRosterView?.total === 23 && + midBriefingState?.sortiePortraitRosterView?.visibleCount === 6 && midBriefingState?.sortiePortraitRosterView?.start === 1 && - midBriefingState?.sortiePortraitRosterView?.end === 8, - `Expected the mid-campaign staged formation to expose an eight-card portrait roster window: ${JSON.stringify(midBriefingState?.sortiePortraitRosterView)}` + midBriefingState?.sortiePortraitRosterView?.end === 6, + `Expected the mid-campaign staged formation to expose the first six-card portrait roster page: ${JSON.stringify(midBriefingState?.sortiePortraitRosterView)}` ); await advanceSortiePrepStep(page, 'formation'); @@ -3574,7 +3574,8 @@ try { preservedUnitId: midFormationSwapFixture.preservedUnitId })}` ); - await page.mouse.click(210, 447); + const maDaiCardPoint = await readSortiePortraitRosterCardPoint(page, 'ma-dai'); + await page.mouse.click(maDaiCardPoint.x, maDaiCardPoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieFocusedUnitId === 'ma-dai', undefined, { timeout: 30000 }); const swapFocusState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( @@ -3583,7 +3584,8 @@ try { `Expected focusing Ma Dai to preserve the actual sortie roster: ${JSON.stringify(swapFocusState?.selectedSortieUnitIds)}` ); - await page.mouse.move(530, 544); + const guanYuHoverPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu'); + await page.mouse.move(guanYuHoverPoint.x, guanYuHoverPoint.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap'; @@ -3688,12 +3690,13 @@ try { `Expected pointerout to restore the visible Ma Dai focus preview: ${JSON.stringify(afterSwapPreviewProbe)}` ); - await page.mouse.move(530, 544); + const guanYuSwapPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu'); + await page.mouse.move(guanYuSwapPoint.x, guanYuSwapPoint.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap'; }, undefined, { timeout: 30000 }); - await page.mouse.click(530, 544); + await page.mouse.click(guanYuSwapPoint.x, guanYuSwapPoint.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortiePinnedSwapCandidateUnitId === 'guan-yu' && @@ -4127,19 +4130,121 @@ try { { timeout: 30000 } ); - const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); - await page.mouse.click(658, 196); - await page.waitForFunction((previousScroll) => { - const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; - return view?.scroll > previousScroll; - }, midFormationFirstPage?.scroll ?? 0, { timeout: 30000 }); - const midFormationSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); + const midPortraitUndoRestoredPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); assert( - midFormationSecondPage?.start > 1 && midFormationSecondPage?.end > 8, - `Expected the portrait roster next-page control to reveal later officers: ${JSON.stringify(midFormationSecondPage)}` + midPortraitUndoRestoredPage?.page === 1 && + midPortraitUndoRestoredPage.scroll === midPortraitUndoRestoredPage.pageSize && + midPortraitUndoRestoredPage.previousEnabled === true && + isFiniteBounds(midPortraitUndoRestoredPage.previousButtonBounds), + `Expected swap undo to restore the second portrait page before the independent paging roundtrip: ${JSON.stringify(midPortraitUndoRestoredPage)}` + ); + const midPortraitPagingResetPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous'); + await page.mouse.click(midPortraitPagingResetPoint.x, midPortraitPagingResetPoint.y); + await page.waitForFunction(() => { + const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; + return view?.page === 0 && + view.scroll === 0 && + view.previousEnabled === false && + view.visibleUnitIds?.length === 6 && + view.cardBounds?.length === 6; + }, undefined, { timeout: 30000 }); + + const midPortraitPagingBaseline = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return { + view: state?.sortiePortraitRosterView ?? null, + allUnitIds: state?.sortiePortraitRoster?.map((unit) => unit.id) ?? [], + focusedUnitId: state?.sortieFocusedUnitId ?? null, + selectedUnitIds: state?.selectedSortieUnitIds ?? [], + campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [], + formationAssignments: state?.sortieFormationAssignments ?? {}, + itemAssignments: state?.sortieItemAssignments ?? {} + }; + }); + const midPortraitPagingSaveBefore = await readCampaignSave(page); + assert( + midPortraitPagingBaseline.allUnitIds.length === 23 && + new Set(midPortraitPagingBaseline.allUnitIds).size === 23, + `Expected the portrait paging fixture to contain exactly 23 unique campaign officers: ${JSON.stringify(midPortraitPagingBaseline)}` + ); + + const midPortraitForwardUnitIds = []; + let midPortraitCurrentView = midPortraitPagingBaseline.view; + for (let expectedPage = 0; expectedPage < 4; expectedPage += 1) { + const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6); + assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds); + midPortraitForwardUnitIds.push(...midPortraitCurrentView.visibleUnitIds); + if (expectedPage < 3) { + const nextPoint = await readSortiePortraitRosterNavigationPoint(page, 'next'); + await page.mouse.click(nextPoint.x, nextPoint.y); + await page.waitForFunction( + ({ pageIndex, visibleUnitIds }) => { + const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; + return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds); + }, + { pageIndex: expectedPage + 1, visibleUnitIds: midPortraitPagingBaseline.allUnitIds.slice((expectedPage + 1) * 6, (expectedPage + 2) * 6) }, + { timeout: 30000 } + ); + midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); + } + } + assert( + midPortraitForwardUnitIds.length === 23 && + new Set(midPortraitForwardUnitIds).size === 23 && + sameJsonValue(midPortraitForwardUnitIds, midPortraitPagingBaseline.allUnitIds) && + midPortraitCurrentView.visibleUnitIds.length === 5, + `Expected four portrait pages to expose all 23 officers exactly once and finish with five officers: ${JSON.stringify({ + expected: midPortraitPagingBaseline.allUnitIds, + actual: midPortraitForwardUnitIds, + lastPage: midPortraitCurrentView + })}` + ); + await page.waitForTimeout(300); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-roster-last-page.png`, fullPage: true }); + await assertCanvasPainted(page, 'mid camp sortie portrait roster last page'); + + for (let expectedPage = 2; expectedPage >= 0; expectedPage -= 1) { + const previousPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous'); + await page.mouse.click(previousPoint.x, previousPoint.y); + const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6); + await page.waitForFunction( + ({ pageIndex, visibleUnitIds }) => { + const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; + return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds); + }, + { pageIndex: expectedPage, visibleUnitIds: expectedVisibleUnitIds }, + { timeout: 30000 } + ); + midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); + assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds); + } + + const midPortraitPagingAfter = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return { + focusedUnitId: state?.sortieFocusedUnitId ?? null, + selectedUnitIds: state?.selectedSortieUnitIds ?? [], + campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [], + formationAssignments: state?.sortieFormationAssignments ?? {}, + itemAssignments: state?.sortieItemAssignments ?? {} + }; + }); + const midPortraitPagingSaveAfter = await readCampaignSave(page); + assert( + midPortraitCurrentView?.page === 0 && + midPortraitPagingAfter.focusedUnitId === midPortraitPagingBaseline.focusedUnitId && + sameJsonValue(midPortraitPagingAfter.selectedUnitIds, midPortraitPagingBaseline.selectedUnitIds) && + sameJsonValue(midPortraitPagingAfter.campaignSelectedUnitIds, midPortraitPagingBaseline.campaignSelectedUnitIds) && + sameJsonValue(midPortraitPagingAfter.formationAssignments, midPortraitPagingBaseline.formationAssignments) && + sameJsonValue(midPortraitPagingAfter.itemAssignments, midPortraitPagingBaseline.itemAssignments) && + sameJsonValue(midPortraitPagingSaveAfter, midPortraitPagingSaveBefore), + `Expected portrait paging to return to page one without changing focus, selection, formation, supplies, or saves: ${JSON.stringify({ + before: midPortraitPagingBaseline, + after: midPortraitPagingAfter, + saveBefore: midPortraitPagingSaveBefore, + saveAfter: midPortraitPagingSaveAfter + })}` ); - await page.mouse.click(610, 196); - await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.scroll === 0, undefined, { timeout: 30000 }); const fullRosterCandidateProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -5532,16 +5637,7 @@ async function assertFinalCampRosterPagination(page) { { timeout: 30000 } ); - const previousPoint = await readCampRosterNavigationPoint(page, 'previous'); - await page.mouse.click(previousPoint.x, previousPoint.y); - await page.waitForFunction( - ({ pageNumber, unitId }) => { - const state = window.__HEROS_DEBUG__?.camp(); - return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId; - }, - { pageNumber: 0, unitId: selectionTargetUnitId }, - { timeout: 30000 } - ); + await navigateCampRosterPage(page, 'previous', 0, selectionTargetUnitId); const restorePoint = await readCampRosterRowPoint(page, initialSelectedUnitId); await page.mouse.click(restorePoint.x, restorePoint.y); await page.waitForFunction( @@ -5549,16 +5645,7 @@ async function assertFinalCampRosterPagination(page) { initialSelectedUnitId, { timeout: 30000 } ); - const nextPoint = await readCampRosterNavigationPoint(page, 'next'); - await page.mouse.click(nextPoint.x, nextPoint.y); - await page.waitForFunction( - ({ pageNumber, unitId }) => { - const state = window.__HEROS_DEBUG__?.camp(); - return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId; - }, - { pageNumber: expectedPage, unitId: initialSelectedUnitId }, - { timeout: 30000 } - ); + await navigateCampRosterPage(page, 'next', expectedPage, initialSelectedUnitId); } if (expectedPage === initialRoster.pageCount - 1) { @@ -5575,13 +5662,7 @@ async function assertFinalCampRosterPagination(page) { continue; } - const nextPoint = await readCampRosterNavigationPoint(page, 'next'); - await page.mouse.click(nextPoint.x, nextPoint.y); - await page.waitForFunction( - (pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber, - expectedPage + 1, - { timeout: 30000 } - ); + await navigateCampRosterPage(page, 'next', expectedPage + 1, initialSelectedUnitId); } assert( @@ -5595,29 +5676,16 @@ async function assertFinalCampRosterPagination(page) { ); for (let expectedPage = initialRoster.pageCount - 2; expectedPage >= 0; expectedPage -= 1) { - const previousPoint = await readCampRosterNavigationPoint(page, 'previous'); - await page.mouse.click(previousPoint.x, previousPoint.y); - await page.waitForFunction( - ({ pageNumber, selectedUnitId }) => { - const state = window.__HEROS_DEBUG__?.camp(); - return state?.campRoster?.page === pageNumber && state?.selectedUnitId === selectedUnitId; - }, - { pageNumber: expectedPage, selectedUnitId: initialSelectedUnitId }, - { timeout: 30000 } - ); + await navigateCampRosterPage(page, 'previous', expectedPage, initialSelectedUnitId); } } catch (error) { verificationError = error; } finally { - const currentPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster?.page ?? 0); + const restoreState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const currentPage = restoreState?.campRoster?.page ?? 0; + const restoreSelectedUnitId = restoreState?.selectedUnitId ?? null; for (let pageNumber = currentPage; pageNumber > 0; pageNumber -= 1) { - const previousPoint = await readCampRosterNavigationPoint(page, 'previous'); - await page.mouse.click(previousPoint.x, previousPoint.y); - await page.waitForFunction( - (expectedPage) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === expectedPage, - pageNumber - 1, - { timeout: 30000 } - ); + await navigateCampRosterPage(page, 'previous', pageNumber - 1, restoreSelectedUnitId); } } @@ -5739,6 +5807,81 @@ async function readCampRosterNavigationPoint(page, direction) { return probe; } +async function navigateCampRosterPage(page, direction, expectedPage, expectedSelectedUnitId) { + const maxAttempts = 3; + const attemptTimeout = 4000; + let lastError; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const before = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return { + page: state?.campRoster?.page ?? null, + selectedUnitId: state?.selectedUnitId ?? null, + campRoster: state?.campRoster ?? null + }; + }); + if (before.page === expectedPage && before.selectedUnitId === expectedSelectedUnitId) { + return before; + } + if (before.selectedUnitId !== expectedSelectedUnitId) { + throw new Error( + `Final-camp roster ${direction} navigation changed the selected detail before attempt ${attempt}: ${JSON.stringify({ + expectedPage, + expectedSelectedUnitId, + before + })}` + ); + } + + try { + const point = await readCampRosterNavigationPoint(page, direction); + await page.mouse.click(point.x, point.y); + await page.waitForFunction( + ({ pageNumber, selectedUnitId }) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.campRoster?.page === pageNumber && state?.selectedUnitId === selectedUnitId; + }, + { pageNumber: expectedPage, selectedUnitId: expectedSelectedUnitId }, + { timeout: attemptTimeout } + ); + return await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return { + page: state?.campRoster?.page ?? null, + selectedUnitId: state?.selectedUnitId ?? null, + campRoster: state?.campRoster ?? null + }; + }); + } catch (error) { + lastError = error; + if (attempt < maxAttempts) { + await page.waitForTimeout(120); + } + } + } + + const finalState = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return { + page: state?.campRoster?.page ?? null, + selectedUnitId: state?.selectedUnitId ?? null, + campRoster: state?.campRoster ?? null + }; + }); + const lastErrorSummary = lastError instanceof Error + ? `${lastError.name}: ${lastError.message}` + : String(lastError ?? 'unknown input failure'); + throw new Error( + `Failed final-camp roster ${direction} navigation to page ${expectedPage + 1} after ${maxAttempts} attempts: ${JSON.stringify({ + expectedPage, + expectedSelectedUnitId, + finalState, + lastError: lastErrorSummary + })}` + ); +} + async function readCampRosterRowPoint(page, unitId) { const probe = await page.evaluate((targetUnitId) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -6284,6 +6427,149 @@ async function readSortiePresetControlPoint(page, control, presetId) { return probe; } +function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitIds) { + const expectedStart = expectedVisibleUnitIds.length > 0 ? expectedPage * 6 + 1 : 0; + const expectedEnd = expectedPage * 6 + expectedVisibleUnitIds.length; + const hdBounds = { x: 0, y: 0, width: 1280, height: 720 }; + assert( + view?.enabled === true && + view.total === 23 && + view.page === expectedPage && + view.pageCount === 4 && + view.pageSize === 6 && + view.visibleCount === expectedVisibleUnitIds.length && + view.start === expectedStart && + view.end === expectedEnd && + sameJsonValue(view.visibleUnitIds, expectedVisibleUnitIds) && + view.previousEnabled === (expectedPage > 0) && + view.nextEnabled === (expectedPage < 3) && + isFiniteBounds(view.previousButtonBounds) && + isFiniteBounds(view.nextButtonBounds) && + boundsInside(view.previousButtonBounds, hdBounds) && + boundsInside(view.nextButtonBounds, hdBounds) && + view.cardBounds?.length === expectedVisibleUnitIds.length, + `Expected exact six-officer portrait page ${expectedPage + 1}/4 metadata and navigation state: ${JSON.stringify({ + expectedVisibleUnitIds, + view + })}` + ); + view.cardBounds.forEach((bounds, index) => { + assert( + bounds.unitId === expectedVisibleUnitIds[index] && + isFiniteBounds(bounds) && + boundsInside(bounds, hdBounds), + `Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the HD canvas: ${JSON.stringify({ + bounds, + expectedUnitId: expectedVisibleUnitIds[index] + })}` + ); + }); +} + +async function readSortiePortraitRosterNavigationPoint(page, direction) { + const probe = await page.evaluate((navigationDirection) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; + const bounds = navigationDirection === 'previous' ? view?.previousButtonBounds : view?.nextButtonBounds; + const enabled = navigationDirection === 'previous' ? view?.previousEnabled : view?.nextEnabled; + if (!scene || !canvas || !bounds || !enabled) { + return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, view }; + } + + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + direction: navigationDirection, + enabled, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, direction); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected an enabled portrait roster ${direction} control with a canvas-scaled click point: ${JSON.stringify(probe)}` + ); + return probe; +} + +async function readSortiePortraitRosterCardPoint(page, unitId) { + const target = await page.evaluate((targetUnitId) => { + const state = window.__HEROS_DEBUG__?.camp(); + const view = state?.sortiePortraitRosterView; + const unitIndex = state?.sortiePortraitRoster?.findIndex((unit) => unit.id === targetUnitId) ?? -1; + return { + ready: Boolean(view?.enabled && unitIndex >= 0 && view.pageSize > 0), + unitId: targetUnitId, + unitIndex, + currentPage: view?.page ?? null, + targetPage: unitIndex >= 0 && view?.pageSize > 0 ? Math.floor(unitIndex / view.pageSize) : null, + pageCount: view?.pageCount ?? null + }; + }, unitId); + assert( + target.ready === true && + Number.isInteger(target.currentPage) && + Number.isInteger(target.targetPage) && + target.targetPage >= 0 && + target.targetPage < target.pageCount, + `Expected ${unitId} to belong to a valid portrait roster page: ${JSON.stringify(target)}` + ); + + let currentPage = target.currentPage; + while (currentPage !== target.targetPage) { + const direction = currentPage < target.targetPage ? 'next' : 'previous'; + const navigationPoint = await readSortiePortraitRosterNavigationPoint(page, direction); + await page.mouse.click(navigationPoint.x, navigationPoint.y); + const expectedPage = currentPage + (direction === 'next' ? 1 : -1); + await page.waitForFunction( + (pageIndex) => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.page === pageIndex, + expectedPage, + { timeout: 30000 } + ); + currentPage = expectedPage; + } + await page.waitForFunction( + ({ pageIndex, targetUnitId }) => { + const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; + return view?.page === pageIndex && + view.visibleUnitIds?.includes(targetUnitId) && + view.cardBounds?.some((card) => card.unitId === targetUnitId); + }, + { pageIndex: target.targetPage, targetUnitId: unitId }, + { timeout: 30000 } + ); + + const probe = await page.evaluate((targetUnitId) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; + const bounds = view?.cardBounds?.find((card) => card.unitId === targetUnitId); + if (!scene || !canvas || !bounds) { + return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, view }; + } + + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + unitId: targetUnitId, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, unitId); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible portrait roster card for ${unitId} with a canvas-scaled pointer point: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readBattleDoctrineProbe(page) { return page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index b56acc9..ddc1777 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -240,6 +240,19 @@ type CampRosterLayout = { nextEnabled: boolean; }; +type SortiePortraitRosterLayout = { + page: number; + pageCount: number; + pageSize: number; + totalCount: number; + visibleUnitIds: string[]; + previousButtonBounds: CampRosterBounds; + nextButtonBounds: CampRosterBounds; + cardBounds: Array; + previousEnabled: boolean; + nextEnabled: boolean; +}; + type CampSupplyDefinition = { label: string; usableId: string; @@ -475,6 +488,7 @@ type SortieConfigurationSnapshot = { focusedUnitId: string; planFeedback: string; rosterScroll: number; + portraitRosterPage: number; }; type SortieSwapProjection = { @@ -907,6 +921,7 @@ const sortiePresetDefinitions: { ]; const reportFormationHistoryPageSize = 7; +const sortiePortraitRosterPageSize = 6; const defaultSortieRule: SortieRuleDefinition = { maxUnits: maxSortieUnits, @@ -11091,6 +11106,8 @@ export class CampScene extends Phaser.Scene { private sortieCoreResonanceToggleButton?: Phaser.GameObjects.Rectangle; private sortieCoreResonanceToggleLabel?: Phaser.GameObjects.Text; private sortieRosterScroll = 0; + private sortiePortraitRosterPage = 0; + private sortiePortraitRosterLayout?: SortiePortraitRosterLayout; private sortiePlanFeedback = ''; private sortiePrepStep: SortiePrepStep = 'briefing'; private terrainCountCache = new Map(); @@ -11118,6 +11135,9 @@ export class CampScene extends Phaser.Scene { this.sortieCoreResonanceSelectorOpen = false; this.sortieCoreResonanceToggleButton = undefined; this.sortieCoreResonanceToggleLabel = undefined; + this.sortieRosterScroll = 0; + this.sortiePortraitRosterPage = 0; + this.sortiePortraitRosterLayout = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieSwapUndoState = undefined; this.sortieFormationPanelMode = 'roster'; @@ -12717,6 +12737,7 @@ export class CampScene extends Phaser.Scene { const usesStagedPrep = this.usesStagedSortiePrep(flow); if (usesStagedPrep && !wasVisible) { this.sortiePrepStep = 'briefing'; + this.sortiePortraitRosterPage = 0; } const checklist = this.sortieChecklist(); const prepTitle = isBattleSortie ? '출진 준비' : '군영 의정'; @@ -12856,6 +12877,7 @@ export class CampScene extends Phaser.Scene { this.sortieFormationPanelMode = 'order'; } this.sortieRosterScroll = 0; + this.sortiePortraitRosterPage = 0; soundDirector.playSelect(); this.showSortiePrep(); } @@ -13052,31 +13074,35 @@ export class CampScene extends Phaser.Scene { const rosterUnits = this.sortieRosterUnits(); const displayUnits = this.sortieRosterDisplayUnits(rosterUnits); - const visibleCount = 8; - const maxScroll = Math.max(0, displayUnits.length - visibleCount); - this.sortieRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, maxScroll); - const visibleUnits = displayUnits.slice(this.sortieRosterScroll, this.sortieRosterScroll + visibleCount); + const pageCount = Math.max(1, Math.ceil(displayUnits.length / sortiePortraitRosterPageSize)); + this.sortiePortraitRosterPage = Phaser.Math.Clamp(this.sortiePortraitRosterPage, 0, pageCount - 1); + const pageStart = this.sortiePortraitRosterPage * sortiePortraitRosterPageSize; + const visibleUnits = displayUnits.slice(pageStart, pageStart + sortiePortraitRosterPageSize); const selectedCount = this.selectedSortieUnitIds.length; const maxUnits = this.sortieMaxUnits(); - const rangeEnd = this.sortieRosterScroll + visibleUnits.length; + const rangeEnd = pageStart + visibleUnits.length; + const previousEnabled = this.sortiePortraitRosterPage > 0; + const nextEnabled = this.sortiePortraitRosterPage < pageCount - 1; + const previousButtonBounds = { x: x + width - 100, y: y + 9, width: 40, height: 30 }; + const nextButtonBounds = { x: x + width - 52, y: y + 9, width: 40, height: 30 }; this.trackSortie(this.add.text(x + 18, y + 13, `무장 선택 · 출전 ${selectedCount}/${maxUnits}`, this.textStyle(19, '#f2e3bf', true))).setDepth(depth + 1); const rangeText = this.trackSortie( this.add.text( x + width - 116, y + 18, - `${displayUnits.length > 0 ? this.sortieRosterScroll + 1 : 0}-${rangeEnd}/${displayUnits.length}`, + `${displayUnits.length > 0 ? pageStart + 1 : 0}-${rangeEnd}/${displayUnits.length} · ${this.sortiePortraitRosterPage + 1}/${pageCount}`, this.textStyle(11, '#9fb0bf', true) ) ); rangeText.setOrigin(1, 0); rangeText.setDepth(depth + 1); - this.renderFirstSortieInlineButton('‹', x + width - 100, y + 9, 40, 30, this.sortieRosterScroll > 0, false, () => { + this.renderFirstSortieInlineButton('‹', previousButtonBounds.x, previousButtonBounds.y, previousButtonBounds.width, previousButtonBounds.height, previousEnabled, false, () => { soundDirector.playSelect(); - this.updateSortieRosterScroll(this.sortieRosterScroll - visibleCount, maxScroll); + this.setSortiePortraitRosterPage(this.sortiePortraitRosterPage - 1, pageCount); }, depth + 1); - this.renderFirstSortieInlineButton('›', x + width - 52, y + 9, 40, 30, this.sortieRosterScroll < maxScroll, false, () => { + this.renderFirstSortieInlineButton('›', nextButtonBounds.x, nextButtonBounds.y, nextButtonBounds.width, nextButtonBounds.height, nextEnabled, false, () => { soundDirector.playSelect(); - this.updateSortieRosterScroll(this.sortieRosterScroll + visibleCount, maxScroll); + this.setSortiePortraitRosterPage(this.sortiePortraitRosterPage + 1, pageCount); }, depth + 1); const handleRosterWheel = ( @@ -13088,26 +13114,28 @@ export class CampScene extends Phaser.Scene { ) => { event?.stopPropagation(); const direction = Math.sign(deltaY); - if (direction === 0 || maxScroll <= 0) { + if (direction === 0 || pageCount <= 1) { return; } - this.updateSortieRosterScroll(this.sortieRosterScroll + direction * 2, maxScroll); + this.setSortiePortraitRosterPage(this.sortiePortraitRosterPage + direction, pageCount); }; - if (maxScroll > 0) { + if (pageCount > 1) { bg.setInteractive({ useHandCursor: false }); bg.on('wheel', handleRosterWheel); } const columnGap = 10; - const rowGap = 8; + const rowGap = 10; const cardWidth = Math.floor((width - 36 - columnGap) / 2); const cardTop = y + 50; - const cardHeight = Math.floor((height - 64 - rowGap * 3) / 4); + const cardHeight = Math.floor((height - 64 - rowGap * 2) / 3); + const cardBounds: SortiePortraitRosterLayout['cardBounds'] = []; visibleUnits.forEach((unit, index) => { const column = index % 2; const rowIndex = Math.floor(index / 2); const cardX = x + 18 + column * (cardWidth + columnGap); const cardY = cardTop + rowIndex * (cardHeight + rowGap); + cardBounds.push({ unitId: unit.id, x: cardX, y: cardY, width: cardWidth, height: cardHeight }); const selected = this.isSortieSelected(unit.id); const focused = this.sortieFocusedUnitId === unit.id; const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id; @@ -13159,9 +13187,9 @@ export class CampScene extends Phaser.Scene { accent.setOrigin(0); accent.setDepth(depth + 2); const portraitView = this.renderFirstSortiePortrait( - cardX + 39, + cardX + 45, cardY + cardHeight / 2, - 56, + 70, unit, depth + 2, availability.available ? 1 : 0.42, @@ -13169,7 +13197,7 @@ export class CampScene extends Phaser.Scene { accentColor ); if (pinnedSwapCandidate && portraitView.image) { - portraitView.image.setDisplaySize(60, 60); + portraitView.image.setDisplaySize(74, 74); } card.on('pointerover', () => { this.sortieHoveredUnitId = unit.id; @@ -13179,7 +13207,7 @@ export class CampScene extends Phaser.Scene { accent.setFillStyle(palette.gold, 0.98); portraitView.frame.setStrokeStyle(2, palette.gold, 0.98); if (availability.available && portraitView.image) { - portraitView.image.setDisplaySize(60, 60); + portraitView.image.setDisplaySize(74, 74); } }); card.on('pointerout', () => { @@ -13192,22 +13220,22 @@ export class CampScene extends Phaser.Scene { card.setStrokeStyle(2, palette.gold, 0.98); accent.setFillStyle(palette.gold, 0.98); portraitView.frame.setStrokeStyle(2, palette.gold, 0.98); - portraitView.image?.setDisplaySize(60, 60); + portraitView.image?.setDisplaySize(74, 74); return; } card.setFillStyle(fill, !availability.available ? 0.62 : 0.96); card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha); accent.setFillStyle(accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34); portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42); - portraitView.image?.setDisplaySize(56, 56); + portraitView.image?.setDisplaySize(70, 70); }); const statusLabel = required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기'; const statusColor = required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf'; - const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 8, statusLabel, this.textStyle(9, statusColor, true))); + const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 9, statusLabel, this.textStyle(10, statusColor, true))); status.setOrigin(1, 0); status.setDepth(depth + 2); - this.trackSortie(this.add.text(cardX + 75, cardY + 8, `${unit.name} Lv${unit.level}`, this.textStyle(14, availability.available ? '#f2e3bf' : '#77818c', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(cardX + 75, cardY + 28, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(9, availability.available ? '#c8d2dd' : '#68727e', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 92, cardY + 9, `${unit.name} Lv${unit.level}`, this.textStyle(15, availability.available ? '#f2e3bf' : '#77818c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 92, cardY + 33, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(11, availability.available ? '#c8d2dd' : '#68727e', true))).setDepth(depth + 2); const previewColor = preview.mode === 'remove' ? '#ff9d7d' : preview.mode === 'add' || preview.mode === 'current' @@ -13217,20 +13245,20 @@ export class CampScene extends Phaser.Scene { : '#87919c'; this.trackSortie( this.add.text( - cardX + 75, - cardY + 45, - this.compactText(`${this.sortieFormationRoleLabel(role)} · ${preview.compactLabel}`, 28), - this.textStyle(9, availability.available ? previewColor : '#68727e', selected || Boolean(recommendation)) + cardX + 92, + cardY + 55, + this.compactText(`${this.sortieFormationRoleLabel(role)} · ${preview.compactLabel}`, 24), + this.textStyle(10, availability.available ? previewColor : '#68727e', selected || Boolean(recommendation)) ) ).setDepth(depth + 2); const canToggle = availability.available && !(selected && required); this.renderFirstSortieInlineButton( required ? '필수 출전' : selected ? '출전 해제' : pinnedSwapCandidate ? '후보 고정' : canPinSwap ? '교체 비교' : '출전 등록', - cardX + cardWidth - 80, - cardY + cardHeight - 25, - 70, - 20, + cardX + cardWidth - 94, + cardY + cardHeight - 32, + 84, + 24, canToggle, selected || pinnedSwapCandidate, () => { @@ -13244,6 +13272,19 @@ export class CampScene extends Phaser.Scene { depth + 3 ); }); + + this.sortiePortraitRosterLayout = { + page: this.sortiePortraitRosterPage, + pageCount, + pageSize: sortiePortraitRosterPageSize, + totalCount: displayUnits.length, + visibleUnitIds: visibleUnits.map((unit) => unit.id), + previousButtonBounds, + nextButtonBounds, + cardBounds, + previousEnabled, + nextEnabled + }; } private renderCampaignSortieOrderBrowser(x: number, y: number, width: number, height: number, depth: number) { @@ -14705,6 +14746,15 @@ export class CampScene extends Phaser.Scene { this.showSortiePrep(); } + private setSortiePortraitRosterPage(nextPage: number, pageCount: number) { + const clamped = Phaser.Math.Clamp(Math.round(nextPage), 0, Math.max(0, pageCount - 1)); + if (clamped === this.sortiePortraitRosterPage) { + return; + } + this.sortiePortraitRosterPage = clamped; + this.showSortiePrep(); + } + private sortieRosterViewState(totalUnits: number, height = 286) { const denseRoster = totalUnits > 12; const rowGap = denseRoster ? 22 : totalUnits > 7 ? 30 : totalUnits > 5 ? 38 : totalUnits > 4 ? 43 : 48; @@ -15959,6 +16009,7 @@ export class CampScene extends Phaser.Scene { ? this.sortieFocusedUnitId : projection.selectedUnitIds[0]; this.sortieRosterScroll = 0; + this.sortiePortraitRosterPage = 0; const definition = this.sortiePresetDefinition(presetId); this.sortiePlanFeedback = `${definition.label} 편성책 적용 완료 · 장비·보급 재점검`; this.persistSortieSelection(); @@ -15989,6 +16040,7 @@ export class CampScene extends Phaser.Scene { this.sortieFocusedUnitId = undo.before.focusedUnitId; this.sortiePlanFeedback = undo.before.planFeedback; this.sortieRosterScroll = undo.before.rosterScroll; + this.sortiePortraitRosterPage = undo.before.portraitRosterPage; this.sortieHoveredUnitId = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; const definition = this.sortiePresetDefinition(undo.presetId); @@ -16077,7 +16129,8 @@ export class CampScene extends Phaser.Scene { itemAssignments: this.cloneSortieItemAssignments(this.sortieItemAssignments), focusedUnitId: this.sortieFocusedUnitId, planFeedback: this.sortiePlanFeedback, - rosterScroll: this.sortieRosterScroll + rosterScroll: this.sortieRosterScroll, + portraitRosterPage: this.sortiePortraitRosterPage }; } @@ -16146,6 +16199,7 @@ export class CampScene extends Phaser.Scene { this.sortieHoveredUnitId = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieRosterScroll = 0; + this.sortiePortraitRosterPage = 0; this.sortiePlanFeedback = `${projection.outgoingUnit.name} → ${projection.incomingUnit.name} 교체 완료 · 장비·보급 재점검`; this.persistSortieSelection(); this.sortieSwapUndoState = { @@ -16178,6 +16232,7 @@ export class CampScene extends Phaser.Scene { this.sortieFocusedUnitId = undo.before.focusedUnitId; this.sortiePlanFeedback = undo.before.planFeedback; this.sortieRosterScroll = undo.before.rosterScroll; + this.sortiePortraitRosterPage = undo.before.portraitRosterPage; this.sortieHoveredUnitId = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.persistSortieSelection(); @@ -17414,6 +17469,7 @@ export class CampScene extends Phaser.Scene { private hideSortiePrep(clearPinnedSwap = true) { this.sortieObjects.forEach((object) => object.destroy()); this.sortieObjects = []; + this.sortiePortraitRosterLayout = undefined; this.sortieHoveredUnitId = undefined; if (clearPinnedSwap) { this.sortiePinnedSwapCandidateUnitId = undefined; @@ -18991,6 +19047,7 @@ export class CampScene extends Phaser.Scene { ? this.sortieFocusedUnitId : projection.selectedUnitIds[0]; this.sortieRosterScroll = 0; + this.sortiePortraitRosterPage = 0; this.sortiePlanFeedback = `${bestRecord.battleTitle} 최고 편성 적용 · 장비·보급 재점검`; this.persistSortieSelection(); this.reportFormationHistoryUndoState = { @@ -19025,6 +19082,7 @@ export class CampScene extends Phaser.Scene { this.sortieFocusedUnitId = undo.before.focusedUnitId; this.sortiePlanFeedback = undo.before.planFeedback; this.sortieRosterScroll = undo.before.rosterScroll; + this.sortiePortraitRosterPage = undo.before.portraitRosterPage; this.persistSortieSelection(); this.reportFormationHistoryFeedback = '최고 편성 적용을 되돌리고 이전 명단·역할·보급으로 복원했습니다.'; soundDirector.playSelect(); @@ -20408,6 +20466,7 @@ export class CampScene extends Phaser.Scene { this.selectedSortieUnitIds = this.normalizedSortieUnitIds(Array.from(selected)); this.sortieRosterScroll = 0; + this.sortiePortraitRosterPage = 0; const selectionLabel = hasBattle ? '출전' : '동행'; const synergyResultLine = synergyPreview.headline.replace(/^(합류|해제) 시 /, ''); this.sortiePlanFeedback = `${unit.name} ${selected.has(unitId) ? `${selectionLabel} 추가` : `${selectionLabel} 해제`} · ${synergyResultLine}`; @@ -20714,9 +20773,14 @@ export class CampScene extends Phaser.Scene { const sortieStepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep); const nextSortiePrepStep = stagedSortiePrep ? firstSortiePrepSteps[sortieStepIndex + 1]?.id ?? null : null; const portraitRosterUnits = this.sortieRosterDisplayUnits(this.sortieRosterUnits()); - const portraitRosterVisibleCount = 8; - const portraitRosterMaxScroll = Math.max(0, portraitRosterUnits.length - portraitRosterVisibleCount); - const portraitRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, portraitRosterMaxScroll); + const portraitRosterPageCount = Math.max(1, Math.ceil(portraitRosterUnits.length / sortiePortraitRosterPageSize)); + const portraitRosterPage = Phaser.Math.Clamp(this.sortiePortraitRosterPage, 0, portraitRosterPageCount - 1); + const portraitRosterScroll = portraitRosterPage * sortiePortraitRosterPageSize; + const portraitRosterVisibleUnits = portraitRosterUnits.slice( + portraitRosterScroll, + portraitRosterScroll + sortiePortraitRosterPageSize + ); + const portraitRosterMaxScroll = Math.max(0, (portraitRosterPageCount - 1) * sortiePortraitRosterPageSize); const sortieComparisonPreview = this.sortieFormationComparisonPreview(); const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); const sortiePursuit = this.sortiePursuitPairs(); @@ -21058,11 +21122,24 @@ export class CampScene extends Phaser.Scene { sortiePortraitRosterView: { enabled: stagedSortiePrep && !this.isFirstSortiePrepSlice(), total: portraitRosterUnits.length, - visibleCount: Math.min(portraitRosterVisibleCount, portraitRosterUnits.length), + visibleCount: portraitRosterVisibleUnits.length, start: portraitRosterUnits.length > 0 ? portraitRosterScroll + 1 : 0, - end: portraitRosterScroll + Math.min(portraitRosterVisibleCount, portraitRosterUnits.length - portraitRosterScroll), + end: portraitRosterScroll + portraitRosterVisibleUnits.length, maxScroll: portraitRosterMaxScroll, - scroll: portraitRosterScroll + scroll: portraitRosterScroll, + page: portraitRosterPage, + pageCount: portraitRosterPageCount, + pageSize: sortiePortraitRosterPageSize, + visibleUnitIds: portraitRosterVisibleUnits.map((unit) => unit.id), + previousEnabled: portraitRosterPage > 0, + nextEnabled: portraitRosterPage < portraitRosterPageCount - 1, + previousButtonBounds: this.sortiePortraitRosterLayout + ? { ...this.sortiePortraitRosterLayout.previousButtonBounds } + : null, + nextButtonBounds: this.sortiePortraitRosterLayout + ? { ...this.sortiePortraitRosterLayout.nextButtonBounds } + : null, + cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map((bounds) => ({ ...bounds })) ?? [] }, sortiePortraitRoster: portraitRosterUnits.map((unit) => { const portraitKey = campaignPortraitKeysByUnitId[unit.id];