diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 590497a..a331a72 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process'; -import { mkdirSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { chromium } from 'playwright'; const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/'); @@ -2001,6 +2001,17 @@ try { } }); assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`); + assert( + firstCampProbe.state?.campRoster?.pageCount === 1 && + firstCampProbe.state.campRoster.totalCount === 3 && + firstCampProbe.state.campRoster.visibleUnitIds.length === 3 && + firstCampProbe.state.campRoster.rowBounds.length === 3 && + firstCampProbe.state.campRoster.rowBounds.every((row) => row.height >= 100) && + firstCampProbe.state.campRoster.navigationBounds === null && + firstCampProbe.state.campRoster.previousEnabled === false && + firstCampProbe.state.campRoster.nextEnabled === false, + `Expected the three-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}` + ); assert( firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}` @@ -4670,8 +4681,8 @@ try { Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0, `Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}` ); - await page.screenshot({ path: `${screenshotDir}/rc-final-camp.png`, fullPage: true }); - await assertCanvasPainted(page, 'final camp'); + await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp'); + await assertFinalCampRosterPagination(page); await page.mouse.click(1160, 38); const finalTransitionScenes = await waitForStoryOrEnding(page); @@ -5389,6 +5400,404 @@ async function waitForCamp(page) { }, undefined, { timeout: 90000 }); } +async function waitForCampVisualFrame(page, expectedPage) { + await page.waitForFunction( + (pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber, + expectedPage, + { timeout: 15000 } + ); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + await page.waitForFunction(() => { + const canvas = document.querySelector('canvas'); + const context = canvas?.getContext('2d', { willReadFrequently: true }); + if (!canvas || !context) { + return false; + } + + const colorBuckets = new Set(); + let litSamples = 0; + for (let y = 110; y <= 560; y += 30) { + for (let x = 400; x <= 1200; x += 30) { + const [r, g, b, a] = context.getImageData(x, y, 1, 1).data; + if (a > 8 && r + g + b > 54) { + litSamples += 1; + } + colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`); + } + } + return litSamples >= 18 && colorBuckets.size >= 12; + }, undefined, { timeout: 15000 }); +} + +async function captureStableCampScreenshot(page, path, expectedPage, label) { + await waitForCampVisualFrame(page, expectedPage); + const capture = await page.evaluate(() => new Promise((resolve) => { + const game = window.__HEROS_GAME__; + const canvas = document.querySelector('canvas'); + if (!game?.loop || !game.renderer || !canvas) { + resolve({ loopAvailable: false, dataUrl: null }); + return; + } + game.renderer.once('postrender', () => { + game.loop.sleep(); + resolve({ loopAvailable: true, dataUrl: canvas.toDataURL('image/png') }); + }); + })); + try { + assert( + capture.dataUrl?.startsWith('data:image/png;base64,'), + `Expected a complete canvas capture for ${label}: ${JSON.stringify({ loopAvailable: capture.loopAvailable, hasDataUrl: Boolean(capture.dataUrl) })}` + ); + writeFileSync(path, Buffer.from(capture.dataUrl.split(',')[1], 'base64')); + await assertCanvasPainted(page, label); + } finally { + if (capture.loopAvailable) { + await page.evaluate(() => new Promise((resolve) => { + window.__HEROS_GAME__?.loop?.wake(); + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + } + } +} + +async function assertFinalCampRosterPagination(page) { + const initialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const initialRoster = initialState?.campRoster; + const initialSelectedUnitId = initialState?.selectedUnitId ?? null; + const initialSave = await readCampaignSave(page); + const expectedUnitIds = (initialState?.campaign?.roster ?? []).map((unit) => unit.id); + + assert( + initialRoster?.page === 0 && + initialRoster.pageCount === 4 && + initialRoster.rowsPerPage === 6 && + initialRoster.totalCount === 23 && + expectedUnitIds.length === 23 && + new Set(expectedUnitIds).size === 23 && + expectedUnitIds.includes(initialSelectedUnitId), + `Expected the final camp roster to open as four six-row pages for 23 unique officers: ${JSON.stringify({ + campRoster: initialRoster, + expectedUnitIds + })}` + ); + + const visitedUnitIds = []; + let verificationError; + + try { + for (let expectedPage = 0; expectedPage < initialRoster.pageCount; expectedPage += 1) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const roster = state?.campRoster; + assertCampRosterPageLayout(roster, expectedPage, initialRoster.pageCount, initialSelectedUnitId, state?.selectedUnitId); + + const expectedVisibleCount = expectedPage === initialRoster.pageCount - 1 + ? initialRoster.totalCount - initialRoster.rowsPerPage * (initialRoster.pageCount - 1) + : initialRoster.rowsPerPage; + assert( + roster.visibleUnitIds.length === expectedVisibleCount && + roster.rowBounds.length === expectedVisibleCount && + sameJsonValue(roster.rowBounds.map((row) => row.unitId), roster.visibleUnitIds), + `Expected final camp roster page ${expectedPage + 1} to expose ${expectedVisibleCount} matching rows: ${JSON.stringify(roster)}` + ); + + roster.visibleUnitIds.forEach((unitId) => { + assert( + expectedUnitIds.includes(unitId) && !visitedUnitIds.includes(unitId), + `Expected each final camp officer to appear on exactly one roster page: ${JSON.stringify({ + expectedPage, + unitId, + visitedUnitIds, + expectedUnitIds + })}` + ); + visitedUnitIds.push(unitId); + }); + + if (expectedPage === 1) { + const selectionTargetUnitId = roster.visibleUnitIds[0]; + assert( + selectionTargetUnitId && selectionTargetUnitId !== initialSelectedUnitId, + `Expected the second roster page to expose a selectable officer other than the initial detail: ${JSON.stringify(roster)}` + ); + const selectionPoint = await readCampRosterRowPoint(page, selectionTargetUnitId); + await page.mouse.click(selectionPoint.x, selectionPoint.y); + await page.waitForFunction( + ({ pageNumber, unitId }) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId; + }, + { pageNumber: expectedPage, unitId: selectionTargetUnitId }, + { 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 } + ); + const restorePoint = await readCampRosterRowPoint(page, initialSelectedUnitId); + await page.mouse.click(restorePoint.x, restorePoint.y); + await page.waitForFunction( + (unitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === unitId, + 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 } + ); + } + + if (expectedPage === initialRoster.pageCount - 1) { + assert( + roster.visibleUnitIds.length === 5 && roster.previousEnabled === true && roster.nextEnabled === false, + `Expected the final roster page to contain five officers with only previous navigation enabled: ${JSON.stringify(roster)}` + ); + await captureStableCampScreenshot( + page, + `${screenshotDir}/rc-final-camp-roster-last-page.png`, + expectedPage, + 'final camp roster last 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 } + ); + } + + assert( + visitedUnitIds.length === 23 && + new Set(visitedUnitIds).size === 23 && + sameJsonValue([...visitedUnitIds].sort(), [...expectedUnitIds].sort()), + `Expected pagination to expose all 23 final-camp officers exactly once: ${JSON.stringify({ + visitedUnitIds, + expectedUnitIds + })}` + ); + + 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 } + ); + } + } catch (error) { + verificationError = error; + } finally { + const currentPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster?.page ?? 0); + 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 } + ); + } + } + + if (verificationError) { + throw verificationError; + } + + const restoredState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const restoredSave = await readCampaignSave(page); + assert( + restoredState?.campRoster?.page === initialRoster.page && + restoredState?.selectedUnitId === initialSelectedUnitId && + sameJsonValue(restoredSave, initialSave), + `Expected the final-camp roster fixture to restore its page, selected detail, and saves: ${JSON.stringify({ + initialPage: initialRoster.page, + restoredPage: restoredState?.campRoster?.page, + initialSelectedUnitId, + restoredSelectedUnitId: restoredState?.selectedUnitId, + saveUnchanged: sameJsonValue(restoredSave, initialSave) + })}` + ); +} + +function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSelectedUnitId, actualSelectedUnitId) { + assert( + roster?.page === expectedPage && + roster.pageCount === pageCount && + roster.rowsPerPage === 6 && + roster.totalCount === 23 && + actualSelectedUnitId === expectedSelectedUnitId, + `Expected stable final-camp roster state on page ${expectedPage + 1}: ${JSON.stringify({ + roster, + expectedSelectedUnitId, + actualSelectedUnitId + })}` + ); + assert( + roster.previousEnabled === (expectedPage > 0) && + roster.nextEnabled === (expectedPage < pageCount - 1), + `Expected correct final-camp roster navigation state on page ${expectedPage + 1}: ${JSON.stringify(roster)}` + ); + + const listBounds = roster.listBounds; + assert(isPositiveBounds(listBounds), `Expected final-camp roster list bounds on page ${expectedPage + 1}: ${JSON.stringify(roster)}`); + assert( + isBoundsInside(listBounds, { x: 0, y: 0, width: 1280, height: 720 }), + `Expected final-camp roster list inside the 1280x720 canvas: ${JSON.stringify(roster)}` + ); + assert( + isPositiveBounds(roster.navigationBounds?.container) && + isPositiveBounds(roster.navigationBounds?.previous) && + isPositiveBounds(roster.navigationBounds?.label) && + isPositiveBounds(roster.navigationBounds?.next) && + isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1280, height: 720 }), + `Expected complete final-camp roster navigation bounds inside the HD canvas: ${JSON.stringify(roster)}` + ); + assert( + isBoundsInside(roster.navigationBounds.previous, roster.navigationBounds.container) && + isBoundsInside(roster.navigationBounds.label, roster.navigationBounds.container) && + isBoundsInside(roster.navigationBounds.next, roster.navigationBounds.container) && + listBounds.y + listBounds.height <= roster.navigationBounds.container.y, + `Expected final-camp roster controls to stay inside their navigation bar and below every row: ${JSON.stringify(roster)}` + ); + + roster.rowBounds.forEach((row, rowIndex) => { + assert( + row.unitId === roster.visibleUnitIds[rowIndex] && + isPositiveBounds(row) && + isBoundsInside(row, listBounds) && + isBoundsInside(row, { x: 0, y: 0, width: 1280, height: 720 }) && + row.y + row.height <= roster.navigationBounds.container.y, + `Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and HD canvas: ${JSON.stringify({ + row, + listBounds, + visibleUnitIds: roster.visibleUnitIds + })}` + ); + }); + + for (let leftIndex = 0; leftIndex < roster.rowBounds.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < roster.rowBounds.length; rightIndex += 1) { + assert( + !boundsOverlap(roster.rowBounds[leftIndex], roster.rowBounds[rightIndex]), + `Expected final-camp roster rows on page ${expectedPage + 1} not to overlap: ${JSON.stringify({ + left: roster.rowBounds[leftIndex], + right: roster.rowBounds[rightIndex] + })}` + ); + } + } +} + +async function readCampRosterNavigationPoint(page, direction) { + const probe = await page.evaluate((navigationDirection) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const roster = window.__HEROS_DEBUG__?.camp()?.campRoster; + const bounds = roster?.navigationBounds?.[navigationDirection]; + const enabled = navigationDirection === 'previous' ? roster?.previousEnabled : roster?.nextEnabled; + if (!scene || !canvas || !bounds || !enabled) { + return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, roster }; + } + 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 final-camp roster ${direction} control: ${JSON.stringify(probe)}` + ); + return probe; +} + +async function readCampRosterRowPoint(page, unitId) { + const probe = await page.evaluate((targetUnitId) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const roster = window.__HEROS_DEBUG__?.camp()?.campRoster; + const bounds = roster?.rowBounds?.find((row) => row.unitId === targetUnitId); + if (!scene || !canvas || !bounds) { + return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, roster }; + } + 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 final-camp roster row for ${unitId}: ${JSON.stringify(probe)}` + ); + return probe; +} + +function isPositiveBounds(bounds) { + return Boolean( + bounds && + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0 + ); +} + +function isBoundsInside(inner, outer, tolerance = 0.01) { + return ( + isPositiveBounds(inner) && + isPositiveBounds(outer) && + inner.x >= outer.x - tolerance && + inner.y >= outer.y - tolerance && + inner.x + inner.width <= outer.x + outer.width + tolerance && + inner.y + inner.height <= outer.y + outer.height + tolerance + ); +} + +function boundsOverlap(left, right, tolerance = 0.01) { + return ( + left.x < right.x + right.width - tolerance && + left.x + left.width > right.x + tolerance && + left.y < right.y + right.height - tolerance && + left.y + left.height > right.y + tolerance + ); +} + async function waitForSortiePrep(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index dadf96f..b56acc9 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -215,6 +215,31 @@ type CampTabButtonView = { text: Phaser.GameObjects.Text; }; +type CampRosterBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +type CampRosterLayout = { + page: number; + pageCount: number; + rowsPerPage: number; + totalCount: number; + visibleUnitIds: string[]; + listBounds: CampRosterBounds; + navigationBounds: { + container: CampRosterBounds; + previous: CampRosterBounds; + label: CampRosterBounds; + next: CampRosterBounds; + } | null; + rowBounds: Array; + previousEnabled: boolean; + nextEnabled: boolean; +}; + type CampSupplyDefinition = { label: string; usableId: string; @@ -11015,6 +11040,8 @@ export class CampScene extends Phaser.Scene { private campaign?: CampaignState; private report?: FirstBattleReport; private selectedUnitId = 'liu-bei'; + private campRosterPage = 0; + private campRosterLayout?: CampRosterLayout; private activeTab: CampTab = 'status'; private selectedDialogueId = campDialogues[0].id; private selectedVisitId = campVisits[0].id; @@ -11081,6 +11108,8 @@ export class CampScene extends Phaser.Scene { create() { this.contentObjects = []; + this.campRosterPage = 0; + this.campRosterLayout = undefined; this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; @@ -17419,18 +17448,35 @@ export class CampScene extends Phaser.Scene { private renderUnitColumn() { const allies = this.currentUnits().filter((unit) => unit.faction === 'ally'); + const rowsPerPage = 6; + const pageCount = Math.max(1, Math.ceil(allies.length / rowsPerPage)); + const paginated = pageCount > 1; + this.campRosterPage = Phaser.Math.Clamp(this.campRosterPage, 0, pageCount - 1); + const visibleAllies = allies.slice( + this.campRosterPage * rowsPerPage, + (this.campRosterPage + 1) * rowsPerPage + ); + const x = 42; + const listY = 120; + const width = 318; const availableHeight = 462; - const rowGap = allies.length > 1 ? Math.min(132, Math.floor(availableHeight / allies.length)) : 132; - const cardHeight = Math.min(118, Math.max(48, rowGap - 8)); - const compact = cardHeight < 88; + const rowGap = paginated + ? 70 + : allies.length > 1 + ? Math.min(132, Math.floor(availableHeight / allies.length)) + : 132; + const cardHeight = paginated ? 64 : Math.min(118, Math.max(48, rowGap - 8)); + const compact = paginated || cardHeight < 88; + const navigationY = 542; + const navigationHeight = 28; + const rowBounds: CampRosterLayout['rowBounds'] = []; this.renderRosterColumnSummary(42, 86, 318, allies); - allies.forEach((unit, index) => { - const x = 42; - const y = 120 + index * rowGap; + visibleAllies.forEach((unit, index) => { + const y = listY + index * rowGap; const active = this.selectedUnitId === unit.id; const rosterStatus = this.unitRosterStatus(unit); const training = this.latestReserveTraining(unit.id); - const bg = this.track(this.add.rectangle(x, y, 318, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86)); + const bg = this.track(this.add.rectangle(x, y, width, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86)); bg.setOrigin(0); bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46); bg.setInteractive({ useHandCursor: true }); @@ -17439,6 +17485,8 @@ export class CampScene extends Phaser.Scene { this.selectedUnitId = unit.id; this.render(); }); + const bounds = bg.getBounds(); + rowBounds.push({ unitId: unit.id, x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }); const portraitKey = campaignPortraitKeysByUnitId[unit.id]; const portraitTexture = portraitKey @@ -17446,27 +17494,120 @@ export class CampScene extends Phaser.Scene { (texture): texture is string => Boolean(texture && this.textures.exists(texture)) ) : undefined; + const portraitCenterX = paginated ? x + 38 : x + 58; const portraitCenterY = y + cardHeight / 2; - const portraitSize = compact ? 40 : 84; + const portraitSize = paginated ? 48 : compact ? 40 : 84; if (portraitTexture && this.textures.exists(portraitTexture)) { - const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitTexture)); + const portrait = this.track(this.add.image(portraitCenterX, portraitCenterY, portraitTexture)); portrait.setDisplaySize(portraitSize, portraitSize); } else { - this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 20 : 40, unit); + this.renderUnitFallbackPortrait(portraitCenterX, portraitCenterY, paginated ? 24 : compact ? 20 : 40, unit); } - const textX = compact ? x + 102 : x + 116; - this.track(this.add.text(textX, y + (compact ? 7 : 17), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 14 : 20, '#f2e3bf', true))); - this.track(this.add.text(textX, y + (compact ? 27 : 47), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 10 : 13, '#d4dce6'))); + const textX = paginated ? x + 70 : compact ? x + 102 : x + 116; + const nameY = paginated ? y + 6 : y + (compact ? 7 : 17); + const detailY = paginated ? y + 26 : y + (compact ? 27 : 47); + const expY = paginated ? y + 42 : y + (compact ? 40 : 72); + this.track(this.add.text(textX, nameY, `${unit.name} Lv ${unit.level}`, this.textStyle(paginated ? 15 : compact ? 14 : 20, '#f2e3bf', true))); + this.track(this.add.text(textX, detailY, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(paginated || compact ? 10 : 13, '#d4dce6'))); const expLine = training ? `경험 ${unit.exp}/100 · ${training.focusLabel ?? '대기'} +${training.expGained}` : `경험 ${unit.exp}/100`; - this.track(this.add.text(textX, y + (compact ? 40 : 72), expLine, this.textStyle(compact ? 10 : 13, training ? '#a8ffd0' : '#d8b15f', true))); - this.drawBar(textX, y + cardHeight - (compact ? 10 : 17), compact ? 142 : 176, compact ? 5 : 8, unit.exp / 100, training ? palette.green : palette.gold); + this.track( + this.add.text( + textX, + expY, + paginated ? this.compactText(expLine, 28) : expLine, + this.textStyle(paginated || compact ? 10 : 13, training ? '#a8ffd0' : '#d8b15f', true) + ) + ); + this.drawBar( + textX, + paginated ? y + 57 : y + cardHeight - (compact ? 10 : 17), + paginated ? width - 82 : compact ? 142 : 176, + paginated ? 4 : compact ? 5 : 8, + unit.exp / 100, + training ? palette.green : palette.gold + ); const badgeColor = rosterStatus.tone === 'selected' ? '#a8ffd0' : rosterStatus.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf'; - const badge = this.track(this.add.text(x + 302, y + (compact ? 7 : 17), rosterStatus.label, this.textStyle(compact ? 10 : 11, badgeColor, true))); + const badge = this.track( + this.add.text( + paginated ? x + width - 12 : x + 302, + paginated ? y + 7 : y + (compact ? 7 : 17), + rosterStatus.label, + this.textStyle(paginated || compact ? 10 : 11, badgeColor, true) + ) + ); badge.setOrigin(1, 0); }); + + const debugBounds = (object: Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text): CampRosterBounds => { + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + }; + const previousEnabled = paginated && this.campRosterPage > 0; + const nextEnabled = paginated && this.campRosterPage < pageCount - 1; + let navigationBounds: CampRosterLayout['navigationBounds'] = null; + if (paginated) { + const navigation = this.track(this.add.rectangle(x, navigationY, width, navigationHeight, 0x0b1118, 0.96)); + navigation.setOrigin(0); + navigation.setStrokeStyle(1, 0x647485, 0.62); + const renderNavigationButton = (buttonX: number, label: string, enabled: boolean, page: number) => { + const button = this.track(this.add.rectangle(buttonX, navigationY + 2, 70, 24, enabled ? 0x25384a : 0x141b22, 0.96)); + button.setOrigin(0); + button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.78 : 0.36); + if (enabled) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x31475a, 0.98)); + button.on('pointerout', () => button.setFillStyle(0x25384a, 0.96)); + button.on('pointerdown', () => this.setCampRosterPage(page)); + } + const text = this.track(this.add.text(buttonX + 35, navigationY + 14, label, this.textStyle(12, enabled ? '#f4dfad' : '#77828c', true))); + text.setOrigin(0.5); + return button; + }; + + const previousButton = renderNavigationButton(x + 2, '이전', previousEnabled, this.campRosterPage - 1); + const nextButton = renderNavigationButton(x + width - 72, '다음', nextEnabled, this.campRosterPage + 1); + const pageLabel = this.track(this.add.text(x + width / 2, navigationY + 14, `${this.campRosterPage + 1} / ${pageCount}`, this.textStyle(13, '#d4dce6', true))); + pageLabel.setOrigin(0.5); + navigationBounds = { + container: debugBounds(navigation), + previous: debugBounds(previousButton), + label: debugBounds(pageLabel), + next: debugBounds(nextButton) + }; + } + + this.campRosterLayout = { + page: this.campRosterPage, + pageCount, + rowsPerPage, + totalCount: allies.length, + visibleUnitIds: visibleAllies.map((unit) => unit.id), + listBounds: { + x, + y: listY, + width, + height: visibleAllies.length > 0 ? (visibleAllies.length - 1) * rowGap + cardHeight : 0 + }, + navigationBounds, + rowBounds, + previousEnabled, + nextEnabled + }; + } + + private setCampRosterPage(page: number) { + const allies = this.currentUnits().filter((unit) => unit.faction === 'ally'); + const pageCount = Math.max(1, Math.ceil(allies.length / 6)); + const nextPage = Phaser.Math.Clamp(page, 0, pageCount - 1); + if (nextPage === this.campRosterPage) { + return; + } + soundDirector.playSelect(); + this.campRosterPage = nextPage; + this.render(); } private renderRosterColumnSummary(x: number, y: number, width: number, allies: UnitData[]) { @@ -20524,6 +20665,7 @@ export class CampScene extends Phaser.Scene { private clearContent() { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; + this.campRosterLayout = undefined; this.reportFormationReviewToggleButton = undefined; this.reportFormationHistoryToggleButton = undefined; this.reportSortieOrderRewardBadge = undefined; @@ -20609,6 +20751,22 @@ export class CampScene extends Phaser.Scene { return { scene: this.scene.key, activeTab: this.activeTab, + campRoster: this.campRosterLayout + ? { + ...this.campRosterLayout, + visibleUnitIds: [...this.campRosterLayout.visibleUnitIds], + listBounds: { ...this.campRosterLayout.listBounds }, + navigationBounds: this.campRosterLayout.navigationBounds + ? { + container: { ...this.campRosterLayout.navigationBounds.container }, + previous: { ...this.campRosterLayout.navigationBounds.previous }, + label: { ...this.campRosterLayout.navigationBounds.label }, + next: { ...this.campRosterLayout.navigationBounds.next } + } + : null, + rowBounds: this.campRosterLayout.rowBounds.map((bounds) => ({ ...bounds })) + } + : null, sortieOperationOrder: { available: Boolean(sortieScenario), required: Boolean(sortieScenario && stagedSortiePrep),