diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 45b8b18..47289db 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -24,14 +24,14 @@ async function moveLegacyUi(page, x, y, options) { async function clickBattleDeploymentStart(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); - const layout = scene?.layout; + const startButton = window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel?.startButtonBounds; const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); - if (!scene || !layout || !canvas || !bounds) { + if (!scene || !startButton || !canvas || !bounds) { return null; } - const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2; - const logicalY = layout.panelY + layout.panelHeight - 76 + 17; + const logicalX = startButton.x + startButton.width / 2; + const logicalY = startButton.y + startButton.height / 2; return { x: bounds.left + logicalX * bounds.width / scene.scale.width, y: bounds.top + logicalY * bounds.height / scene.scale.height @@ -174,8 +174,10 @@ try { boundsInside(firstBattleRecentActions, firstBattleProbe.panelBounds) && boundsInside(firstBattleRecentActions, firstBattleProbe.sceneBounds) && firstBattleRecentActions?.compact === false && - firstBattleRecentActions.height === 118 && + firstBattleRecentActions.height === 126 && firstBattleRecentActions.rowCount === Math.min(3, firstBattleProbe.battleLog.length) && + firstBattleRecentActions.textBounds.length === firstBattleRecentActions.rowCount && + firstBattleRecentActions.textBounds.every((bounds) => boundsInside(bounds, firstBattleRecentActions)) && firstBattleProbe.sideTexts.includes('최근 행동') && firstBattleRecentActions.bottom === firstBattleRecentActions.y + firstBattleRecentActions.height && firstBattleRecentActions.gapToMiniMap >= 6 && @@ -5470,12 +5472,33 @@ async function assertLargeRosterHud(browser, url) { JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort()) ); }, expectedRosterIds, { timeout: 90000 }); + + const deploymentProbe = await readLargeRosterHudProbe(page); + assertLateBattleHeaderLayout(deploymentProbe, 'deployment'); + assertLateBattleSidebarLayout(deploymentProbe, 'deployment', false); + assertLateBattleDeploymentLayout(deploymentProbe); + await page.screenshot({ path: `${screenshotDir}/rc-final-battle-deployment.png`, fullPage: true }); + await assertCanvasPainted(page, 'final battle deployment'); + await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (scene?.phase === 'deployment') { scene.confirmPreBattleDeployment?.(); } scene?.hideBattleEventBanner?.(); + scene?.renderSituationPanel?.(); + }); + await page.waitForFunction(() => { + const hud = window.__HEROS_DEBUG__?.battle()?.battleHud; + return hud?.miniMap?.visible === true && hud?.sidebar?.bounds && hud?.deploymentPanel === null; + }, undefined, { timeout: 30000 }); + + const situationProbe = await readLargeRosterHudProbe(page); + assertLateBattleHeaderLayout(situationProbe, 'situation'); + assertLateBattleSidebarLayout(situationProbe, 'situation', true); + + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.'); }); await page.waitForFunction(() => { @@ -5519,6 +5542,25 @@ async function assertLargeRosterHud(browser, url) { await page.screenshot({ path: `${screenshotDir}/rc-final-battle-roster-last-page.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle roster last page'); + await page.evaluate(() => { + window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderRosterPanel?.('enemy', '적군 전력을 확인하세요.'); + }); + await page.waitForFunction(() => { + const roster = window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel; + return roster?.tab === 'enemy' && roster?.pageCount > 1; + }, undefined, { timeout: 30000 }); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + const enemyPage = await readLargeRosterHudProbe(page); + assert( + enemyPage.roster.rowLayouts.some((row) => row.unitName.length >= 8), + `Expected the enemy roster fixture to exercise a long unit name: ${JSON.stringify(enemyPage)}` + ); + assertLargeRosterPageLayout(enemyPage, 'enemy first'); + await page.screenshot({ path: `${screenshotDir}/rc-final-battle-enemy-roster.png`, fullPage: true }); + await assertCanvasPainted(page, 'final battle enemy roster'); + await page.evaluate((selectedSortieUnitIds) => { window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({ battleId: 'sixty-sixth-battle-wuzhang-final', @@ -5559,6 +5601,12 @@ async function readLargeRosterHudProbe(page) { roster: hud?.rosterPanel ?? null, recentActions: hud?.recentActions ?? null, miniMap: hud?.miniMap ?? null, + header: hud?.header ?? null, + sidebar: hud?.sidebar ?? null, + deploymentPanel: hud?.deploymentPanel ?? null, + sceneBounds: scene + ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } + : null, panelBounds: scene ? { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight } : null @@ -5566,11 +5614,113 @@ async function readLargeRosterHudProbe(page) { }); } +function assertLateBattleHeaderLayout(probe, label) { + const { header, panelBounds, sceneBounds } = probe; + const headerBounds = [ + header?.titleBounds, + header?.turnBounds, + header?.speedBounds, + header?.objectiveBounds, + header?.defeatBounds, + header?.sortieOrderBounds + ]; + assert( + isFiniteBounds(panelBounds) && + isFiniteBounds(sceneBounds) && + boundsInside(panelBounds, sceneBounds) && + headerBounds.every((bounds) => boundsInside(bounds, panelBounds) && boundsInside(bounds, sceneBounds)), + `Expected every late-battle ${label} header element inside the FHD side panel and scene: ${JSON.stringify(probe)}` + ); + assert( + header.titleBounds.y + header.titleBounds.height <= Math.min(header.turnBounds.y, header.speedBounds.y) && + header.turnBounds.x + header.turnBounds.width <= header.speedBounds.x && + Math.max( + header.turnBounds.y + header.turnBounds.height, + header.speedBounds.y + header.speedBounds.height + ) <= header.objectiveBounds.y && + header.objectiveBounds.y + header.objectiveBounds.height <= header.defeatBounds.y && + header.defeatBounds.y + header.defeatBounds.height <= header.sortieOrderBounds.y && + header.sortieOrderBounds.y + header.sortieOrderBounds.height <= header.contentTop, + `Expected the wrapped late-battle ${label} objective, defeat condition, and sortie order to remain separated: ${JSON.stringify(header)}` + ); +} + +function assertLateBattleSidebarLayout(probe, label, expectMiniMap) { + const { sidebar, miniMap, header, panelBounds, sceneBounds } = probe; + assert( + isFiniteBounds(sidebar?.bounds) && + boundsInside(sidebar.bounds, panelBounds) && + boundsInside(sidebar.bounds, sceneBounds) && + sidebar.contentTop === header?.contentTop && + sidebar.bounds.y >= sidebar.contentTop && + sidebar.bounds.y + sidebar.bounds.height <= sidebar.contentBottom, + `Expected the late-battle ${label} sidebar inside its FHD content region: ${JSON.stringify(probe)}` + ); + assert( + header.sortieOrderBounds.y + header.sortieOrderBounds.height <= sidebar.bounds.y, + `Expected the late-battle ${label} sidebar below the sortie-order header: ${JSON.stringify(probe)}` + ); + if (!expectMiniMap) { + assert(miniMap?.visible === false, `Expected deployment to reserve the full sidebar by hiding the minimap: ${JSON.stringify(probe)}`); + return; + } + assert( + miniMap?.visible === true && + boundsInside(miniMap.frameBounds, panelBounds) && + boundsInside(miniMap.frameBounds, sceneBounds) && + sidebar.bounds.y + sidebar.bounds.height <= miniMap.frameBounds.y, + `Expected the late-battle ${label} sidebar to stay above the FHD minimap: ${JSON.stringify(probe)}` + ); +} + +function assertLateBattleDeploymentLayout(probe) { + const { deploymentPanel, sidebar, panelBounds, sceneBounds } = probe; + const roleBounds = deploymentPanel?.roleBounds ?? []; + const deploymentBounds = [ + deploymentPanel?.headerBounds, + deploymentPanel?.noticeBounds, + ...roleBounds, + deploymentPanel?.restoreButtonBounds, + deploymentPanel?.startButtonBounds + ]; + assert( + roleBounds.length > 0 && deploymentBounds.every((bounds) => + boundsInside(bounds, sidebar?.bounds) && + boundsInside(bounds, panelBounds) && + boundsInside(bounds, sceneBounds) + ) && boundsInside(deploymentPanel?.noticeTextBounds, deploymentPanel?.noticeBounds), + `Expected every late-battle deployment section inside the FHD sidebar and scene: ${JSON.stringify(probe)}` + ); + assert( + deploymentPanel.headerBounds.y + deploymentPanel.headerBounds.height <= deploymentPanel.noticeBounds.y && + deploymentPanel.noticeBounds.y + deploymentPanel.noticeBounds.height <= roleBounds[0].y && + roleBounds.every((bounds, index) => + index === 0 || roleBounds[index - 1].y + roleBounds[index - 1].height <= bounds.y + ) && + roleBounds.at(-1).y + roleBounds.at(-1).height <= deploymentPanel.restoreButtonBounds.y && + deploymentPanel.restoreButtonBounds.y + deploymentPanel.restoreButtonBounds.height <= deploymentPanel.startButtonBounds.y, + `Expected the late-battle deployment header, notice, roles, and actions not to overlap: ${JSON.stringify(deploymentPanel)}` + ); +} + function assertLargeRosterPageLayout(probe, label) { const { roster, panelBounds } = probe; assert(boundsInside(roster.listBounds, panelBounds), `Expected roster rows on page ${label} inside the side panel: ${JSON.stringify(probe)}`); assert(boundsInside(roster.navigationBounds, panelBounds), `Expected roster navigation on page ${label} inside the side panel: ${JSON.stringify(probe)}`); assert(boundsInside(roster.messageBounds, panelBounds), `Expected roster guidance on page ${label} inside the side panel: ${JSON.stringify(probe)}`); + assert( + roster.rowLayouts.length === roster.visibleUnitIds.length && + roster.rowLayouts.every((row) => + boundsInside(row.rowBounds, roster.listBounds) && + boundsInside(row.nameBounds, row.rowBounds) && + boundsInside(row.metaBounds, row.rowBounds) && + boundsInside(row.hpBounds, row.rowBounds) && + boundsInside(row.gaugeBounds, row.rowBounds) && + row.nameBounds.x + row.nameBounds.width <= row.hpBounds.x && + row.metaBounds.x + row.metaBounds.width <= row.gaugeBounds.x + ), + `Expected roster page ${label} names, classes, HP, and gauges to remain in separate columns: ${JSON.stringify(probe)}` + ); assert( roster.listBounds.y + roster.listBounds.height <= roster.navigationBounds.y && roster.navigationBounds.y + roster.navigationBounds.height <= roster.messageBounds.y && diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 3f4946b..13b01ef 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1159,6 +1159,7 @@ type RecentActionLogLayout = { height: number; compact: boolean; rowCount: number; + textBounds: Array<{ x: number; y: number; width: number; height: number }>; }; type RosterPanelLayout = { @@ -1168,11 +1169,30 @@ type RosterPanelLayout = { rowsPerPage: number; totalCount: number; visibleUnitIds: string[]; + rowLayouts: Array<{ + unitId: string; + unitName: string; + displayName: string; + rowBounds: { x: number; y: number; width: number; height: number }; + nameBounds: { x: number; y: number; width: number; height: number }; + metaBounds: { x: number; y: number; width: number; height: number }; + hpBounds: { x: number; y: number; width: number; height: number }; + gaugeBounds: { x: number; y: number; width: number; height: number }; + }>; listBounds: { x: number; y: number; width: number; height: number }; navigationBounds: { x: number; y: number; width: number; height: number } | null; messageBounds: { x: number; y: number; width: number; height: number } | null; }; +type DeploymentPanelLayout = { + headerBounds: { x: number; y: number; width: number; height: number }; + noticeBounds: { x: number; y: number; width: number; height: number }; + noticeTextBounds: { x: number; y: number; width: number; height: number }; + roleBounds: Array<{ x: number; y: number; width: number; height: number }>; + restoreButtonBounds: { x: number; y: number; width: number; height: number }; + startButtonBounds: { x: number; y: number; width: number; height: number }; +}; + type UnitView = { sprite: Phaser.GameObjects.Sprite; hitZone: Phaser.GameObjects.Zone; @@ -3264,6 +3284,7 @@ export class BattleScene extends Phaser.Scene { private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview; private bondChainJudgementLast?: BondChainJudgementSnapshot; private turnText?: Phaser.GameObjects.Text; + private battleTitleText?: Phaser.GameObjects.Text; private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; @@ -3271,6 +3292,7 @@ export class BattleScene extends Phaser.Scene { private rosterTab: RosterTab = 'ally'; private rosterPage = 0; private rosterPanelLayout?: RosterPanelLayout; + private deploymentPanelLayout?: DeploymentPanelLayout; private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private commandButtons: CommandButton[] = []; private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; @@ -4575,7 +4597,7 @@ export class BattleScene extends Phaser.Scene { this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0); this.add.rectangle(panelX, panelY, this.battleUiLength(3), panelHeight, palette.gold, 0.82).setOrigin(0); - this.add.text(panelX + this.battleUiLength(24), panelY + this.battleUiLength(24), battleScenario.title, { + this.battleTitleText = this.add.text(panelX + this.battleUiLength(24), panelY + this.battleUiLength(24), battleScenario.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(28), color: '#e8dfca', @@ -7237,10 +7259,11 @@ export class BattleScene extends Phaser.Scene { }); if (recommendedUnit) { + const labelOffsetY = this.battleUiLength(2); const label = this.trackDeploymentObject( - this.add.text(this.tileCenterX(slot.x), this.tileTopLeftY(slot.y) + 3, slot.label, { + this.add.text(this.tileCenterX(slot.x), this.tileTopLeftY(slot.y) + labelOffsetY, slot.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '10px', + fontSize: this.battleUiFontSize(8), color: '#fff1c8', fontStyle: '700', stroke: '#05070a', @@ -7251,7 +7274,7 @@ export class BattleScene extends Phaser.Scene { label.setData('tileX', slot.x); label.setData('tileY', slot.y); label.setData('tileOffsetX', this.layout.tileSize / 2); - label.setData('tileOffsetY', 3); + label.setData('tileOffsetY', labelOffsetY); label.setOrigin(0.5, 0); label.setDepth(11.2); if (this.mapMask) { @@ -7302,59 +7325,74 @@ export class BattleScene extends Phaser.Scene { this.setMiniMapVisible(false); const { panelX, panelY, panelWidth, panelHeight } = this.layout; - const left = panelX + 24; - const width = panelWidth - 48; + const left = panelX + this.battleUiLength(24); + const width = panelWidth - this.battleUiLength(48); const top = this.sideContentTop(); const selected = this.deploymentSelectedUnit(); + const headerHeight = this.battleUiLength(88); + const noticeHeight = this.battleUiLength(52); + const roleRowHeight = this.battleUiLength(40); + const buttonHeight = this.battleUiLength(30); - const header = this.trackSideObject(this.add.rectangle(left, top, width, 108, 0x101820, 0.96)); + const header = this.trackSideObject(this.add.rectangle(left, top, width, headerHeight, 0x101820, 0.96)); header.setOrigin(0); - header.setStrokeStyle(1, palette.gold, 0.78); - this.trackSideObject(this.add.text(left + 16, top + 13, '전열 배치', { + header.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.78); + this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(8), '전열 배치', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: this.battleUiFontSize(18), color: '#f4dfad', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 16, top + 43, this.deploymentSubtitle(), { + this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), this.deploymentSubtitle(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(10), color: '#c8d2dd', - wordWrap: { width: width - 32, useAdvancedWrap: true }, + wordWrap: { width: width - this.battleUiLength(24), useAdvancedWrap: true }, maxLines: 2 })); - this.renderDeploymentObjectiveBrief(left + 16, top + 76, width - 32); + this.renderDeploymentObjectiveBrief( + left + this.battleUiLength(12), + top + this.battleUiLength(58), + width - this.battleUiLength(24) + ); - const noticeTop = top + 120; - const notice = this.trackSideObject(this.add.rectangle(left, noticeTop, width, 48, 0x0b1118, 0.94)); + const noticeTop = top + headerHeight + this.battleUiLength(8); + const notice = this.trackSideObject(this.add.rectangle(left, noticeTop, width, noticeHeight, 0x0b1118, 0.94)); notice.setOrigin(0); - notice.setStrokeStyle(1, selected ? palette.blue : 0x647485, selected ? 0.74 : 0.56); - this.trackSideObject(this.add.text(left + 14, noticeTop + 8, selected ? `선택: ${selected.name}` : '장수를 선택하세요.', { + notice.setStrokeStyle(this.battleUiLength(1), selected ? palette.blue : 0x647485, selected ? 0.74 : 0.56); + this.trackSideObject(this.add.text(left + this.battleUiLength(10), noticeTop + this.battleUiLength(5), selected ? `선택: ${selected.name}` : '장수를 선택하세요.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: this.battleUiFontSize(12), color: selected ? '#f2e3bf' : '#d4dce6', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 14, noticeTop + 27, this.deploymentNotice, { + const noticeText = this.trackSideObject(this.add.text(left + this.battleUiLength(10), noticeTop + this.battleUiLength(22), this.deploymentNotice, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: this.battleUiFontSize(9), color: '#9fb0bf', - fixedWidth: width - 28 + wordWrap: { width: width - this.battleUiLength(20), useAdvancedWrap: true }, + maxLines: 2, + lineSpacing: this.battleUiLength(1) })); - const roleTop = noticeTop + 60; + const roleTop = noticeTop + noticeHeight + this.battleUiLength(8); const roleSummaries = this.deploymentRoleSummaries(); - const rowHeight = 48; - const buttonTop = panelY + panelHeight - 116; + const startButtonTop = panelY + panelHeight - this.battleUiLength(50); + const restoreButtonTop = panelY + panelHeight - this.battleUiLength(84); const roleStep = roleSummaries.length > 1 - ? Phaser.Math.Clamp(Math.floor((buttonTop - roleTop - rowHeight - 8) / (roleSummaries.length - 1)), rowHeight + 4, 62) - : rowHeight + 4; + ? Phaser.Math.Clamp( + Math.floor((restoreButtonTop - roleTop - roleRowHeight - this.battleUiLength(8)) / (roleSummaries.length - 1)), + this.battleUiLength(44), + this.battleUiLength(52) + ) + : this.battleUiLength(44); + const roleBounds: DeploymentPanelLayout['roleBounds'] = []; roleSummaries.forEach((summary, index) => { const rowTop = roleTop + index * roleStep; const active = selected?.id === summary.unitId; - const row = this.trackSideObject(this.add.rectangle(left, rowTop, width, rowHeight, active ? 0x21364a : 0x101820, active ? 0.98 : 0.92)); + const row = this.trackSideObject(this.add.rectangle(left, rowTop, width, roleRowHeight, active ? 0x21364a : 0x101820, active ? 0.98 : 0.92)); row.setOrigin(0); - row.setStrokeStyle(1, active ? summary.tone : 0x53606c, active ? 0.86 : 0.52); + row.setStrokeStyle(this.battleUiLength(1), active ? summary.tone : 0x53606c, active ? 0.86 : 0.52); row.setInteractive({ useHandCursor: true }); row.on('pointerdown', () => { const unit = battleUnits.find((candidate) => candidate.id === summary.unitId && candidate.hp > 0); @@ -7362,24 +7400,38 @@ export class BattleScene extends Phaser.Scene { this.handleDeploymentUnitClick(unit); } }); - const icon = this.trackSideIcon(left + 29, rowTop + 24, summary.icon, 38); + const icon = this.trackSideIcon( + left + this.battleUiLength(19), + rowTop + roleRowHeight / 2, + summary.icon, + this.battleUiLength(25) + ); icon.setDepth(34); - this.trackSideObject(this.add.text(left + 54, rowTop + 6, `${summary.name} · ${summary.role}`, { + this.trackSideObject(this.add.text(left + this.battleUiLength(36), rowTop + this.battleUiLength(4), `${summary.name} · ${summary.role}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', + fontSize: this.battleUiFontSize(12), color: active ? '#f4dfad' : '#e7edf7', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 54, rowTop + 28, summary.text, { + this.trackSideObject(this.add.text(left + this.battleUiLength(36), rowTop + this.battleUiLength(22), summary.text, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: this.battleUiFontSize(9), color: '#aeb7c2', - fixedWidth: width - 64 + fixedWidth: width - this.battleUiLength(43) })); + roleBounds.push({ x: left, y: rowTop, width, height: roleRowHeight }); }); - this.renderDeploymentButton(left, panelY + panelHeight - 76, width, '전투 시작', palette.gold, () => this.confirmPreBattleDeployment()); - this.renderDeploymentButton(left, panelY + panelHeight - 116, width, '추천 배치 복원', 0x647485, () => this.restoreRecommendedDeployment()); + this.renderDeploymentButton(left, startButtonTop, width, '전투 시작', palette.gold, () => this.confirmPreBattleDeployment()); + this.renderDeploymentButton(left, restoreButtonTop, width, '추천 배치 복원', 0x647485, () => this.restoreRecommendedDeployment()); + this.deploymentPanelLayout = { + headerBounds: { x: left, y: top, width, height: headerHeight }, + noticeBounds: { x: left, y: noticeTop, width, height: noticeHeight }, + noticeTextBounds: this.gameObjectBoundsDebug(noticeText)!, + roleBounds, + restoreButtonBounds: { x: left, y: restoreButtonTop, width, height: buttonHeight }, + startButtonBounds: { x: left, y: startButtonTop, width, height: buttonHeight } + }; } private renderDeploymentObjectiveBrief(x: number, y: number, width: number) { @@ -7394,16 +7446,16 @@ export class BattleScene extends Phaser.Scene { const primaryText = this.trackSideObject(this.add.text(x, y, this.truncateBattleLogText(primary, 34), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(10), color: '#f4dfad', fontStyle: '700', fixedWidth: width })); primaryText.setDepth(32); - const bonusText = this.trackSideObject(this.add.text(x, y + 18, bonus ? this.truncateBattleLogText(`보조: ${bonus}`, 36) : '보조: 전장 상황에 맞춰 추가 공훈을 노립니다.', { + const bonusText = this.trackSideObject(this.add.text(x, y + this.battleUiLength(14), bonus ? this.truncateBattleLogText(`보조: ${bonus}`, 36) : '보조: 전장 상황에 맞춰 추가 공훈을 노립니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: this.battleUiFontSize(9), color: '#9fb0bf', fixedWidth: width })); @@ -7440,9 +7492,10 @@ export class BattleScene extends Phaser.Scene { tone: number, action: () => void ) { - const button = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x17232e, 0.96)); + const height = this.battleUiLength(30); + const button = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x17232e, 0.96)); button.setOrigin(0); - button.setStrokeStyle(1, tone, 0.86); + button.setStrokeStyle(this.battleUiLength(1), tone, 0.86); button.setInteractive({ useHandCursor: true }); button.on('pointerover', () => button.setFillStyle(0x243746, 0.98)); button.on('pointerout', () => button.setFillStyle(0x17232e, 0.96)); @@ -7451,9 +7504,9 @@ export class BattleScene extends Phaser.Scene { action(); }); - const text = this.trackSideObject(this.add.text(x + width / 2, y + 17, label, { + const text = this.trackSideObject(this.add.text(x + width / 2, y + height / 2, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700' })); @@ -10560,6 +10613,32 @@ export class BattleScene extends Phaser.Scene { return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text; } + private fitTextToWidth(textObject: Phaser.GameObjects.Text, fullText: string, maxWidth: number) { + const characters = Array.from(fullText); + textObject.setText(fullText); + if (textObject.width <= maxWidth) { + return textObject; + } + + let lower = 1; + let upper = characters.length; + let best = '…'; + while (lower <= upper) { + const length = Math.floor((lower + upper) / 2); + const prefix = characters.slice(0, Math.max(0, length - 1)).join('').trimEnd(); + const candidate = `${prefix}…`; + textObject.setText(candidate); + if (textObject.width <= maxWidth) { + best = candidate; + lower = length + 1; + } else { + upper = length - 1; + } + } + textObject.setText(best); + return textObject; + } + private previewActionLabel(preview: CombatPreview) { return preview.usable?.name ?? commandLabels[preview.action]; } @@ -21651,34 +21730,36 @@ export class BattleScene extends Phaser.Scene { private recentActionLogPlacement(minTop: number) { const bottom = this.sideContentBottom(6); const availableHeight = bottom - minTop; - const height = availableHeight >= 118 ? 118 : availableHeight >= 84 ? 84 : 0; + const fullHeight = this.battleUiLength(84); + const compactHeight = this.battleUiLength(60); + const height = availableHeight >= fullHeight ? fullHeight : availableHeight >= compactHeight ? compactHeight : 0; return height > 0 - ? { y: bottom - height, height, compact: height < 100 } + ? { y: bottom - height, height, compact: height < fullHeight } : undefined; } - private renderRecentActionLogPanel(x: number, y: number, width: number, height = 118) { - const compact = height < 100; - const headerHeight = compact ? 28 : 32; - const rowHeight = compact ? 21 : 24; - const rowStep = compact ? 24 : 28; + private renderRecentActionLogPanel(x: number, y: number, width: number, height = this.battleUiLength(84)) { + const compact = height < this.battleUiLength(84); + const headerHeight = this.battleUiLength(compact ? 18 : 22); + const rowHeight = this.battleUiLength(compact ? 16 : 18); + const rowStep = this.battleUiLength(compact ? 18 : 20); const maxRows = compact ? 2 : 3; const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0b1118, 0.96)); bg.setOrigin(0); bg.setDepth(30); - bg.setStrokeStyle(1, 0x647485, 0.68); + bg.setStrokeStyle(this.battleUiLength(1), 0x647485, 0.68); - const title = this.trackSideObject(this.add.text(x + 12, y + (compact ? 6 : 8), '최근 행동', { + const title = this.trackSideObject(this.add.text(x + this.battleUiLength(8), y + this.battleUiLength(compact ? 4 : 5), '최근 행동', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '14px' : '15px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700' })); title.setDepth(31); - const countText = this.trackSideObject(this.add.text(x + width - 12, y + (compact ? 7 : 9), `${this.battleLog.length}`, { + const countText = this.trackSideObject(this.add.text(x + width - this.battleUiLength(8), y + this.battleUiLength(compact ? 5 : 6), `${this.battleLog.length}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '11px' : '12px', + fontSize: this.battleUiFontSize(10), color: '#9fb0bf', fontStyle: '700' })); @@ -21686,11 +21767,12 @@ export class BattleScene extends Phaser.Scene { countText.setDepth(31); const entries = this.battleLog.slice(0, maxRows); - this.recentActionLogLayout = { x, y, width, height, compact, rowCount: entries.length }; + const textBounds: RecentActionLogLayout['textBounds'] = []; + this.recentActionLogLayout = { x, y, width, height, compact, rowCount: entries.length, textBounds }; if (entries.length === 0) { - const emptyText = this.trackSideObject(this.add.text(x + 12, y + (compact ? 37 : 45), '아직 기록된 행동이 없습니다.', { + const emptyText = this.trackSideObject(this.add.text(x + this.battleUiLength(8), y + headerHeight + this.battleUiLength(6), '아직 기록된 행동이 없습니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '12px' : '13px', + fontSize: this.battleUiFontSize(compact ? 10 : 11), color: '#9fb0bf' })); emptyText.setDepth(31); @@ -21701,37 +21783,41 @@ export class BattleScene extends Phaser.Scene { const rowY = y + headerHeight + index * rowStep; const { turn, text } = this.battleLogDisplayParts(entry); const visual = this.battleLogVisual(entry); - const row = this.trackSideObject(this.add.rectangle(x + 8, rowY, width - 16, rowHeight, index === 0 ? 0x17232e : 0x101820, index === 0 ? 0.98 : 0.88)); + const rowInset = this.battleUiLength(6); + const row = this.trackSideObject(this.add.rectangle(x + rowInset, rowY, width - rowInset * 2, rowHeight, index === 0 ? 0x17232e : 0x101820, index === 0 ? 0.98 : 0.88)); row.setOrigin(0); row.setDepth(31); - row.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.34); + row.setStrokeStyle(this.battleUiLength(1), visual.tone, index === 0 ? 0.72 : 0.34); - const iconX = x + (compact ? 21 : 23); + const iconX = x + this.battleUiLength(compact ? 14 : 15); const iconY = rowY + rowHeight / 2; - const iconSize = compact ? 18 : 22; + const iconSize = this.battleUiLength(compact ? 14 : 18); const iconFrame = this.trackSideObject(this.add.rectangle(iconX, iconY, iconSize, iconSize, 0x0a0f14, 0.96)); iconFrame.setDepth(32); - iconFrame.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.52); - const icon = this.trackSideIcon(iconX, iconY, visual.icon, compact ? 17 : 21); + iconFrame.setStrokeStyle(this.battleUiLength(1), visual.tone, index === 0 ? 0.72 : 0.52); + const icon = this.trackSideIcon(iconX, iconY, visual.icon, this.battleUiLength(compact ? 13 : 17)); icon.setDepth(33); - const turnText = this.trackSideObject(this.add.text(x + width - 12, rowY + (compact ? 4 : 6), turn, { + const turnText = this.trackSideObject(this.add.text(x + width - this.battleUiLength(8), rowY + this.battleUiLength(compact ? 3 : 4), turn, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '9px' : '10px', + fontSize: this.battleUiFontSize(compact ? 8 : 9), color: '#9fb0bf', fontStyle: '700' })); turnText.setOrigin(1, 0); turnText.setDepth(32); - const eventText = this.trackSideObject(this.add.text(x + (compact ? 37 : 42), rowY + (compact ? 3 : 5), this.truncateBattleLogText(text, compact ? 32 : index === 0 ? 39 : 34), { + const eventTextX = x + this.battleUiLength(compact ? 25 : 28); + const eventTextMaxWidth = width - (eventTextX - x) - this.battleUiLength(30); + const eventText = this.trackSideObject(this.add.text(eventTextX, rowY + this.battleUiLength(compact ? 2 : 3), text, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? (index === 0 ? '12px' : '11px') : index === 0 ? '13px' : '12px', + fontSize: this.battleUiFontSize(compact ? (index === 0 ? 10 : 9) : index === 0 ? 11 : 10), color: index === 0 ? '#e8dfca' : '#c8d2dd', - fontStyle: index === 0 ? '700' : '400', - fixedWidth: width - (compact ? 84 : 92) + fontStyle: index === 0 ? '700' : '400' })); + this.fitTextToWidth(eventText, text, eventTextMaxWidth); eventText.setDepth(32); + textBounds.push(this.gameObjectBoundsDebug(eventText)!); }); } @@ -21797,43 +21883,46 @@ export class BattleScene extends Phaser.Scene { this.clearSidePanelContent(); this.setMiniMapVisible(true); const { panelX, panelWidth } = this.layout; - const left = panelX + 24; - const width = panelWidth - 48; + const left = panelX + this.battleUiLength(24); + const width = panelWidth - this.battleUiLength(48); const top = this.sideContentTop(); 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; const objectiveStates = this.objectiveStates(this.battleOutcome); - const logPlacement = this.recentActionLogPlacement(top + 232); + const logPlacement = this.recentActionLogPlacement(top + this.battleUiLength(210)); const logTop = logPlacement?.y ?? this.sideContentBottom(6); - const messageTop = top + 168; - const messageHeight = Math.max(52, Math.min(72, logTop - messageTop - 12)); + const messageTop = top + this.battleUiLength(120); + const messageHeight = Math.max( + this.battleUiLength(44), + Math.min(this.battleUiLength(72), logTop - messageTop - this.battleUiLength(8)) + ); this.trackSideObject(this.add.text(left, top, '전황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '24px', + fontSize: this.battleUiFontSize(20), color: '#f2e3bf', fontStyle: '700' })); - this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 46, width); - this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 84, width); - this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 122, width); + this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + this.battleUiLength(32), width, battleFhdUiScale); + this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + this.battleUiLength(60), width, battleFhdUiScale); + this.renderSituationLine('남은 적', `${enemyCount}`, left, top + this.battleUiLength(88), width, battleFhdUiScale); if (message) { - this.renderPanelMessage(this.compactSituationMessage(message), left, messageTop, width, messageHeight); + this.renderPanelMessage(this.compactSituationMessage(message), left, messageTop, width, messageHeight, true); if (logPlacement) { this.renderRecentActionLogPanel(left, logPlacement.y, width, logPlacement.height); } return; } - const compactGuide = logTop - messageTop < 178; + const compactGuide = logTop - messageTop < this.battleUiLength(118); const guideHeight = this.renderTacticalGuideCard(left, messageTop, width, compactGuide); - const objectiveTop = messageTop + (guideHeight > 0 ? guideHeight + 12 : 0); - const objectiveHeight = logTop - objectiveTop - 8; + const objectiveTop = messageTop + (guideHeight > 0 ? guideHeight + this.battleUiLength(8) : 0); + const objectiveHeight = logTop - objectiveTop - this.battleUiLength(4); - if (objectiveHeight >= 58) { + if (objectiveHeight >= this.battleUiLength(46)) { this.renderObjectiveStateGroups(objectiveStates, left, objectiveTop, width, objectiveHeight); } if (logPlacement) { @@ -21855,15 +21944,15 @@ export class BattleScene extends Phaser.Scene { return 0; } - const height = compact ? 56 : 96; + const height = this.battleUiLength(compact ? 32 : 64); const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x101820, 0.92)); bg.setOrigin(0); bg.setDepth(30); - bg.setStrokeStyle(1, palette.gold, 0.5); + bg.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.5); - const title = this.trackSideObject(this.add.text(x + 12, y + (compact ? 7 : 8), '작전 흐름', { + const title = this.trackSideObject(this.add.text(x + this.battleUiLength(8), y + this.battleUiLength(compact ? 3 : 5), '작전 흐름', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '14px' : '15px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700' })); @@ -21871,15 +21960,15 @@ export class BattleScene extends Phaser.Scene { if (compact) { const summary = this.trackSideObject(this.add.text( - x + 12, - y + 30, + x + this.battleUiLength(8), + y + this.battleUiLength(18), this.truncateBattleLogText(`진군 ${guide.route} · ${guide.focus}`, 42), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: this.battleUiFontSize(10), color: '#d4dce6', fontStyle: '700', - fixedWidth: width - 24, + fixedWidth: width - this.battleUiLength(16), maxLines: 1 } )); @@ -21887,25 +21976,25 @@ export class BattleScene extends Phaser.Scene { return height; } - const route = this.trackSideObject(this.add.text(x + 12, y + 31, this.truncateBattleLogText(`진군: ${guide.route}`, 34), { + const route = this.trackSideObject(this.add.text(x + this.battleUiLength(8), y + this.battleUiLength(20), this.truncateBattleLogText(`진군: ${guide.route}`, 34), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(10), color: '#d8b15f', fontStyle: '700' })); route.setDepth(31); - const focus = this.trackSideObject(this.add.text(x + 12, y + 52, this.truncateBattleLogText(guide.focus, 34), { + const focus = this.trackSideObject(this.add.text(x + this.battleUiLength(8), y + this.battleUiLength(34), this.truncateBattleLogText(guide.focus, 34), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(10), color: '#d4dce6', maxLines: 1 })); focus.setDepth(31); - const roles = this.trackSideObject(this.add.text(x + 12, y + 74, this.truncateBattleLogText(guide.roles.join(' · '), 36), { + const roles = this.trackSideObject(this.add.text(x + this.battleUiLength(8), y + this.battleUiLength(49), this.truncateBattleLogText(guide.roles.join(' · '), 36), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: this.battleUiFontSize(9), color: '#9fb0bf', maxLines: 1 })); @@ -21947,6 +22036,9 @@ export class BattleScene extends Phaser.Scene { ? defeatText : routeText ? `진군: ${routeText}\n${defeatText}${bonusText}` : `${defeatText}${bonusText}` ); + this.objectiveTrackerSubText.setY( + Math.ceil(this.objectiveTrackerText.y + this.objectiveTrackerText.height + this.battleUiLength(4)) + ); this.renderSortieOrderHud(); } @@ -21963,8 +22055,8 @@ export class BattleScene extends Phaser.Scene { ].filter((group) => group.objectives.length > 0); let cursorY = y; let remaining = maxHeight; - const headerHeight = 22; - const rowHeight = 36; + const headerHeight = this.battleUiLength(18); + const rowHeight = this.battleUiLength(28); groups.forEach((group, groupIndex) => { if (remaining < headerHeight + rowHeight) { @@ -21973,7 +22065,7 @@ export class BattleScene extends Phaser.Scene { const title = this.trackSideObject(this.add.text(x, cursorY, group.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: this.battleUiFontSize(12), color: groupIndex === 0 ? '#f2e3bf' : '#d8b15f', fontStyle: '700' })); @@ -21990,46 +22082,46 @@ export class BattleScene extends Phaser.Scene { remaining -= rowHeight; }); - cursorY += 5; - remaining -= 5; + cursorY += this.battleUiLength(3); + remaining -= this.battleUiLength(3); }); } private renderObjectiveStateRow(objective: BattleObjectiveState, x: number, y: number, width: number) { - const bg = this.trackSideObject(this.add.rectangle(x, y, width, 32, 0x101820, 0.88)); + const bg = this.trackSideObject(this.add.rectangle(x, y, width, this.battleUiLength(25), 0x101820, 0.88)); bg.setOrigin(0); - bg.setStrokeStyle(1, this.objectiveStatusStroke(objective), 0.5); + bg.setStrokeStyle(this.battleUiLength(1), this.objectiveStatusStroke(objective), 0.5); const categoryColor = objective.category === 'primary' ? palette.gold : objective.category === 'required' ? palette.red : palette.blue; - const categoryBg = this.trackSideObject(this.add.rectangle(x + 28, y + 16, 48, 20, 0x0a0f14, 0.92)); - categoryBg.setStrokeStyle(1, categoryColor, 0.58); + const categoryBg = this.trackSideObject(this.add.rectangle(x + this.battleUiLength(19), y + this.battleUiLength(12), this.battleUiLength(32), this.battleUiLength(14), 0x0a0f14, 0.92)); + categoryBg.setStrokeStyle(this.battleUiLength(1), categoryColor, 0.58); - const categoryText = this.trackSideObject(this.add.text(x + 28, y + 16, this.objectiveCategoryLabel(objective.category), { + const categoryText = this.trackSideObject(this.add.text(x + this.battleUiLength(19), y + this.battleUiLength(12), this.objectiveCategoryLabel(objective.category), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '10px', + fontSize: this.battleUiFontSize(8), color: objective.category === 'bonus' ? '#b9d7ff' : '#f4dfad', fontStyle: '700' })); categoryText.setOrigin(0.5); - this.trackSideObject(this.add.text(x + 62, y + 4, objective.label, { + this.trackSideObject(this.add.text(x + this.battleUiLength(42), y + this.battleUiLength(1), objective.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(10), color: '#d4dce6', fontStyle: '700', - fixedWidth: width - 136 + fixedWidth: width - this.battleUiLength(91) })); - this.trackSideObject(this.add.text(x + 62, y + 18, this.truncateBattleLogText(objective.failureReason ?? objective.detail, 28), { + this.trackSideObject(this.add.text(x + this.battleUiLength(42), y + this.battleUiLength(13), this.truncateBattleLogText(objective.failureReason ?? objective.detail, 28), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '10px', + fontSize: this.battleUiFontSize(8), color: objective.failureReason ? '#ffb6a6' : '#9fb0bf', - fixedWidth: width - 136 + fixedWidth: width - this.battleUiLength(91) })); - const status = this.trackSideObject(this.add.text(x + width - 10, y + 16, this.objectiveStatusText(objective), { + const status = this.trackSideObject(this.add.text(x + width - this.battleUiLength(7), y + this.battleUiLength(12), this.objectiveStatusText(objective), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(10), color: this.objectiveStatusColor(objective), fontStyle: '700' })); @@ -22194,18 +22286,20 @@ export class BattleScene extends Phaser.Scene { return `${value}`; } - private renderSituationLine(label: string, value: string, x: number, y: number, width: number) { - const bg = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x101820, 0.86)); + private renderSituationLine(label: string, value: string, x: number, y: number, width: number, uiScale = 1) { + const length = (value: number) => value * uiScale; + const fhd = uiScale > 1; + const bg = this.trackSideObject(this.add.rectangle(x, y, width, length(fhd ? 26 : 34), 0x101820, 0.86)); bg.setOrigin(0); - bg.setStrokeStyle(1, 0x53606c, 0.46); - this.trackSideObject(this.add.text(x + 12, y + 8, label, { + bg.setStrokeStyle(length(1), 0x53606c, 0.46); + this.trackSideObject(this.add.text(x + length(fhd ? 8 : 12), y + length(fhd ? 5 : 8), label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', + fontSize: `${length(fhd ? 12 : 15)}px`, color: '#9fb0bf' })); - const valueText = this.trackSideObject(this.add.text(x + width - 12, y + 7, value, { + const valueText = this.trackSideObject(this.add.text(x + width - length(fhd ? 8 : 12), y + length(fhd ? 4 : 7), value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: `${length(fhd ? 13 : 16)}px`, color: '#f2e3bf', fontStyle: '700' })); @@ -22239,25 +22333,26 @@ export class BattleScene extends Phaser.Scene { this.clearSidePanelContent(); this.setMiniMapVisible(true); - const { panelX, panelY, panelWidth, panelHeight } = this.layout; - const left = panelX + 24; - const width = panelWidth - 48; + const { panelX, panelWidth } = this.layout; + const left = panelX + this.battleUiLength(24); + const width = panelWidth - this.battleUiLength(48); const top = this.sideContentTop(); - const tabGap = 8; + const tabGap = this.battleUiLength(6); + const tabHeight = this.battleUiLength(30); const tabWidth = (width - tabGap) / 2; (['ally', 'enemy'] as RosterTab[]).forEach((targetTab, index) => { const active = targetTab === tab; const x = left + index * (tabWidth + tabGap); - const tabBg = this.trackSideObject(this.add.rectangle(x, top, tabWidth, 38, active ? 0x25384a : 0x141e29, active ? 0.98 : 0.86)); + const tabBg = this.trackSideObject(this.add.rectangle(x, top, tabWidth, tabHeight, active ? 0x25384a : 0x141e29, active ? 0.98 : 0.86)); tabBg.setOrigin(0); - tabBg.setStrokeStyle(1, active ? palette.gold : 0x53606c, active ? 0.92 : 0.62); + tabBg.setStrokeStyle(this.battleUiLength(1), active ? palette.gold : 0x53606c, active ? 0.92 : 0.62); tabBg.setInteractive({ useHandCursor: true }); tabBg.on('pointerdown', () => this.renderRosterPanel(targetTab)); - const tabText = this.trackSideObject(this.add.text(x + tabWidth / 2, top + 19, rosterLabels[targetTab], { + const tabText = this.trackSideObject(this.add.text(x + tabWidth / 2, top + tabHeight / 2, rosterLabels[targetTab], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: this.battleUiFontSize(16), color: active ? '#f4dfad' : '#aeb7c2', fontStyle: '700' })); @@ -22265,25 +22360,29 @@ export class BattleScene extends Phaser.Scene { }); const units = battleUnits.filter((unit) => unit.faction === tab); - const listTop = top + 54; + const listTop = top + tabHeight + this.battleUiLength(8); const contentBottom = this.sideContentBottom(); - const fullListBottom = listTop + units.length * 50; - const needsPagination = fullListBottom + (message ? 64 : 0) > contentBottom; + const regularRowStep = this.battleUiLength(38); + const tailGap = this.battleUiLength(8); + const messageMinHeight = this.battleUiLength(44); + const navigationHeight = this.battleUiLength(24); + const navigationLeadingGap = this.battleUiLength(4); + const navigationGap = this.battleUiLength(6); + const fullListBottom = listTop + units.length * regularRowStep; + const messageTailReserve = message ? tailGap + messageMinHeight : 0; + const needsPagination = fullListBottom + messageTailReserve > contentBottom; let visibleUnits = units; let rowsPerPage = Math.max(1, units.length); let pageCount = 1; let navigationBounds: RosterPanelLayout['navigationBounds'] = null; let messageBounds: RosterPanelLayout['messageBounds'] = null; - let rowStep = 50; + let rowStep = regularRowStep; if (needsPagination) { - const navigationHeight = 30; - const navigationGap = 8; - const messageReserve = message ? 60 : 0; - rowStep = 46; + const messageReserve = message ? navigationGap + messageMinHeight : 0; rowsPerPage = Math.max( 1, - Math.floor((contentBottom - listTop - navigationHeight - navigationGap - messageReserve) / rowStep) + Math.floor((contentBottom - listTop - navigationLeadingGap - navigationHeight - messageReserve) / rowStep) ); pageCount = Math.max(1, Math.ceil(units.length / rowsPerPage)); this.rosterPage = Phaser.Math.Clamp(this.rosterPage, 0, pageCount - 1); @@ -22293,32 +22392,32 @@ export class BattleScene extends Phaser.Scene { this.rosterPage = 0; } - visibleUnits.forEach((unit, index) => { - this.renderRosterRow(unit, left, listTop + index * rowStep, width); - }); + const rowLayouts = visibleUnits.map((unit, index) => ( + this.renderRosterRow(unit, left, listTop + index * rowStep, width) + )); const listBottom = listTop + visibleUnits.length * rowStep; if (pageCount > 1) { - const navigationY = listBottom + 4; - navigationBounds = { x: left, y: navigationY, width, height: 30 }; + const navigationY = listBottom + navigationLeadingGap; + navigationBounds = { x: left, y: navigationY, width, height: navigationHeight }; this.renderRosterPageNavigation(left, navigationY, width, pageCount, message); if (message) { - const messageY = navigationY + navigationBounds.height + 8; - const messageHeight = Math.min(52, contentBottom - messageY); - if (messageHeight >= 44) { - this.renderPanelMessage(message, left, messageY, width, messageHeight); + const messageY = navigationY + navigationBounds.height + navigationGap; + const messageHeight = Math.min(this.battleUiLength(52), contentBottom - messageY); + if (messageHeight >= messageMinHeight) { + this.renderPanelMessage(message, left, messageY, width, messageHeight, true); messageBounds = { x: left, y: messageY, width, height: messageHeight }; } } } else { - const tailTop = listBottom + 12; + const tailTop = listBottom + tailGap; if (message) { - const logPlacement = this.recentActionLogPlacement(tailTop + 62); - const messageBottomLimit = logPlacement ? logPlacement.y - 10 : contentBottom; - const messageHeight = Math.min(70, messageBottomLimit - tailTop); - if (messageHeight >= 44) { - this.renderPanelMessage(message, left, tailTop, width, messageHeight); + const logPlacement = this.recentActionLogPlacement(tailTop + messageMinHeight + navigationGap); + const messageBottomLimit = logPlacement ? logPlacement.y - navigationGap : contentBottom; + const messageHeight = Math.min(this.battleUiLength(52), messageBottomLimit - tailTop); + if (messageHeight >= messageMinHeight) { + this.renderPanelMessage(message, left, tailTop, width, messageHeight, true); messageBounds = { x: left, y: tailTop, width, height: messageHeight }; } if (logPlacement) { @@ -22339,6 +22438,7 @@ export class BattleScene extends Phaser.Scene { rowsPerPage, totalCount: units.length, visibleUnitIds: visibleUnits.map((unit) => unit.id), + rowLayouts, listBounds: { x: left, y: listTop, width, height: visibleUnits.length * rowStep }, navigationBounds, messageBounds @@ -22346,17 +22446,19 @@ export class BattleScene extends Phaser.Scene { } private renderRosterPageNavigation(x: number, y: number, width: number, pageCount: number, message?: string) { - const background = this.trackSideObject(this.add.rectangle(x, y, width, 30, 0x0b1118, 0.96)); + const height = this.battleUiLength(24); + const background = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0b1118, 0.96)); background.setOrigin(0); background.setDepth(30); - background.setStrokeStyle(1, 0x647485, 0.62); + background.setStrokeStyle(this.battleUiLength(1), 0x647485, 0.62); const renderControl = (controlX: number, label: string, enabled: boolean, nextPage: number) => { - const controlWidth = 72; - const button = this.trackSideObject(this.add.rectangle(controlX, y + 3, controlWidth, 24, enabled ? 0x25384a : 0x141b22, 0.96)); + const controlWidth = this.battleUiLength(52); + const controlHeight = this.battleUiLength(20); + const button = this.trackSideObject(this.add.rectangle(controlX, y + this.battleUiLength(2), controlWidth, controlHeight, enabled ? 0x25384a : 0x141b22, 0.96)); button.setOrigin(0); button.setDepth(31); - button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.78 : 0.36); + button.setStrokeStyle(this.battleUiLength(1), enabled ? palette.gold : 0x53606c, enabled ? 0.78 : 0.36); if (enabled) { button.setInteractive({ useHandCursor: true }); button.on('pointerdown', () => { @@ -22365,9 +22467,9 @@ export class BattleScene extends Phaser.Scene { }); } - const text = this.trackSideObject(this.add.text(controlX + controlWidth / 2, y + 15, label, { + const text = this.trackSideObject(this.add.text(controlX + controlWidth / 2, y + height / 2, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(10), color: enabled ? '#f4dfad' : '#77828c', fontStyle: '700' })); @@ -22375,12 +22477,12 @@ export class BattleScene extends Phaser.Scene { text.setDepth(32); }; - renderControl(x + 3, '이전', this.rosterPage > 0, this.rosterPage - 1); - renderControl(x + width - 75, '다음', this.rosterPage < pageCount - 1, this.rosterPage + 1); + renderControl(x + this.battleUiLength(2), '이전', this.rosterPage > 0, this.rosterPage - 1); + renderControl(x + width - this.battleUiLength(54), '다음', this.rosterPage < pageCount - 1, this.rosterPage + 1); - const indicator = this.trackSideObject(this.add.text(x + width / 2, y + 15, `${this.rosterPage + 1} / ${pageCount}`, { + const indicator = this.trackSideObject(this.add.text(x + width / 2, y + height / 2, `${this.rosterPage + 1} / ${pageCount}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(11), color: '#d4dce6', fontStyle: '700' })); @@ -22392,44 +22494,66 @@ export class BattleScene extends Phaser.Scene { const acted = this.actedUnitIds.has(unit.id); const active = this.selectedUnit?.id === unit.id; const unitClass = getUnitClass(unit.classKey); - const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, 42, active ? 0x2f4050 : 0x101820, acted ? 0.82 : 0.94)); + const rowHeight = this.battleUiLength(34); + const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, rowHeight, active ? 0x2f4050 : 0x101820, acted ? 0.82 : 0.94)); rowBg.setOrigin(0); - rowBg.setStrokeStyle(1, active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52); + rowBg.setStrokeStyle(this.battleUiLength(1), active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52); rowBg.setInteractive({ useHandCursor: true }); rowBg.on('pointerdown', () => this.selectUnit(unit)); - const nameColor = acted ? '#a8a8a8' : unit.faction === 'ally' ? '#e9f0f8' : '#ffe0d8'; - this.trackSideObject(this.add.text(x + 12, y + 7, unit.name, { + const hpText = this.trackSideObject(this.add.text(x + width - this.battleUiLength(8), y + this.battleUiLength(4), `${unit.hp}/${unit.maxHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', - color: nameColor, - fontStyle: '700' - })); - this.trackSideObject(this.add.text(x + 88, y + 9, unitClass.name, - { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', - color: acted ? '#b7bec8' : '#9fb0bf' - })); - - const hpText = this.trackSideObject(this.add.text(x + width - 12, y + 7, `${unit.hp}/${unit.maxHp}`, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: this.battleUiFontSize(13), color: acted ? '#d8cfb5' : '#f0e4c8', fontStyle: '700' })); hpText.setOrigin(1, 0); - this.drawGauge(x + 88, y + 29, width - 100, 6, unit.hp / unit.maxHp, acted ? 0x9a9a9a : 0x59d18c); + const nameColor = acted ? '#a8a8a8' : unit.faction === 'ally' ? '#e9f0f8' : '#ffe0d8'; + const nameX = x + this.battleUiLength(8); + const hpBounds = hpText.getBounds(); + const nameText = this.trackSideObject(this.add.text(nameX, y + this.battleUiLength(4), unit.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(14), + color: nameColor, + fontStyle: '700' + })); + this.fitTextToWidth(nameText, unit.name, hpBounds.x - this.battleUiLength(8) - nameX); - if (acted) { - const actedText = this.trackSideObject(this.add.text(x + 12, y + 27, '행동완료', { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', - color: '#bdbdbd' - })); - actedText.setAlpha(0.95); - } + const gaugeX = x + this.battleUiLength(108); + const gaugeY = y + this.battleUiLength(24); + const gaugeWidth = width - this.battleUiLength(116); + const metaX = x + this.battleUiLength(8); + const metaLabel = acted ? `완료 · ${unitClass.name}` : unitClass.name; + const metaText = this.trackSideObject(this.add.text(metaX, y + this.battleUiLength(22), metaLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(10), + color: acted ? '#b7bec8' : '#9fb0bf', + fontStyle: acted ? '700' : '400' + })); + this.fitTextToWidth(metaText, metaLabel, gaugeX - this.battleUiLength(8) - metaX); + + this.drawGauge( + gaugeX, + gaugeY, + gaugeWidth, + this.battleUiLength(4), + unit.hp / unit.maxHp, + acted ? 0x9a9a9a : 0x59d18c + ); + + const nameBounds = nameText.getBounds(); + const metaBounds = metaText.getBounds(); + return { + unitId: unit.id, + unitName: unit.name, + displayName: nameText.text, + rowBounds: { x, y, width, height: rowHeight }, + nameBounds: { x: nameBounds.x, y: nameBounds.y, width: nameBounds.width, height: nameBounds.height }, + metaBounds: { x: metaBounds.x, y: metaBounds.y, width: metaBounds.width, height: metaBounds.height }, + hpBounds: { x: hpBounds.x, y: hpBounds.y, width: hpBounds.width, height: hpBounds.height }, + gaugeBounds: { x: gaugeX, y: gaugeY, width: gaugeWidth, height: this.battleUiLength(4) } + }; } private renderUnitDetail(unit: UnitData, message?: string) { @@ -22996,19 +23120,24 @@ export class BattleScene extends Phaser.Scene { outcome.setDepth(33); } - private renderPanelMessage(text: string, x: number, y: number, width: number, height = 74) { + private renderPanelMessage(text: string, x: number, y: number, width: number, height = 74, fhd = false) { const safeY = Math.max(this.sideContentTop(), Math.min(y, this.sideContentBottom() - height)); - const compact = height < 60; - const maxLines = Math.max(1, Math.floor((height - 16) / (compact ? 17 : 22))); + const compactThreshold = fhd ? this.battleUiLength(40) : 60; + const compact = height < compactThreshold; + const verticalPadding = fhd ? this.battleUiLength(10) : 16; + const lineHeight = fhd ? this.battleUiLength(compact ? 13 : 16) : compact ? 17 : 22; + const horizontalPadding = fhd ? this.battleUiLength(8) : 12; + const topPadding = fhd ? this.battleUiLength(6) : 10; const bg = this.trackSideObject(this.add.rectangle(x, safeY, width, height, 0x101820, 0.94)); bg.setOrigin(0); - bg.setStrokeStyle(1, 0x647485, 0.66); - const messageText = this.trackSideObject(this.add.text(x + 12, safeY + 10, text, { + bg.setStrokeStyle(fhd ? this.battleUiLength(1) : 1, 0x647485, 0.66); + const maxLines = Math.max(1, Math.floor((height - verticalPadding) / lineHeight)); + const messageText = this.trackSideObject(this.add.text(x + horizontalPadding, safeY + topPadding, text, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '13px' : '15px', + fontSize: fhd ? this.battleUiFontSize(compact ? 10 : 12) : compact ? '13px' : '15px', color: '#d4dce6', - wordWrap: { width: width - 24, useAdvancedWrap: true }, - lineSpacing: compact ? 2 : 4, + wordWrap: { width: width - horizontalPadding * 2, useAdvancedWrap: true }, + lineSpacing: fhd ? this.battleUiLength(compact ? 1 : 2) : compact ? 2 : 4, maxLines })); bg.setDepth(32); @@ -23031,6 +23160,7 @@ export class BattleScene extends Phaser.Scene { this.sidePanelObjects = []; this.recentActionLogLayout = undefined; this.rosterPanelLayout = undefined; + this.deploymentPanelLayout = undefined; this.bondChainReadyFocusedPreview = undefined; this.clearFacingIndicator(); } @@ -23066,6 +23196,21 @@ export class BattleScene extends Phaser.Scene { return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; } + private gameObjectCollectionBoundsDebug(objects: Phaser.GameObjects.GameObject[]) { + const bounds = objects + .filter((object) => object.active && (object as Phaser.GameObjects.GameObject & { visible?: boolean }).visible !== false) + .map((object) => this.gameObjectBoundsDebug(object)) + .filter((value): value is { x: number; y: number; width: number; height: number } => Boolean(value)); + if (bounds.length === 0) { + return null; + } + const left = Math.min(...bounds.map((value) => value.x)); + const top = Math.min(...bounds.map((value) => value.y)); + const right = Math.max(...bounds.map((value) => value.x + value.width)); + const bottom = Math.max(...bounds.map((value) => value.y + value.height)); + return { x: left, y: top, width: right - left, height: bottom - top }; + } + private bondChainReadyDebugState() { const targetingBasicAttack = this.phase === 'targeting' && @@ -23280,6 +23425,7 @@ export class BattleScene extends Phaser.Scene { const recentActions = this.recentActionLogLayout ? { ...this.recentActionLogLayout, + textBounds: this.recentActionLogLayout.textBounds.map((bounds) => ({ ...bounds })), bottom: this.recentActionLogLayout.y + this.recentActionLogLayout.height, gapToMiniMap: miniMap ? miniMap.frameY - (this.recentActionLogLayout.y + this.recentActionLogLayout.height) @@ -23290,6 +23436,14 @@ export class BattleScene extends Phaser.Scene { ? { ...this.rosterPanelLayout, visibleUnitIds: [...this.rosterPanelLayout.visibleUnitIds], + rowLayouts: this.rosterPanelLayout.rowLayouts.map((row) => ({ + ...row, + rowBounds: { ...row.rowBounds }, + nameBounds: { ...row.nameBounds }, + metaBounds: { ...row.metaBounds }, + hpBounds: { ...row.hpBounds }, + gaugeBounds: { ...row.gaugeBounds } + })), listBounds: { ...this.rosterPanelLayout.listBounds }, navigationBounds: this.rosterPanelLayout.navigationBounds ? { ...this.rosterPanelLayout.navigationBounds } @@ -23310,8 +23464,35 @@ export class BattleScene extends Phaser.Scene { : null } : null; + const header = { + titleBounds: this.gameObjectBoundsDebug(this.battleTitleText), + turnBounds: this.gameObjectBoundsDebug(this.turnText), + speedBounds: this.gameObjectCollectionBoundsDebug(this.battleSpeedControlObjects), + objectiveBounds: this.gameObjectBoundsDebug(this.objectiveTrackerText), + defeatBounds: this.gameObjectBoundsDebug(this.objectiveTrackerSubText), + sortieOrderBounds: this.sortieOrderHudBounds ? { ...this.sortieOrderHudBounds } : null, + contentTop: this.sideContentTop() + }; + const sidebar = { + bounds: this.gameObjectCollectionBoundsDebug(this.sidePanelObjects), + contentTop: this.sideContentTop(), + contentBottom: this.sideContentBottom(6) + }; + const deploymentPanel = this.deploymentPanelLayout + ? { + headerBounds: { ...this.deploymentPanelLayout.headerBounds }, + noticeBounds: { ...this.deploymentPanelLayout.noticeBounds }, + noticeTextBounds: { ...this.deploymentPanelLayout.noticeTextBounds }, + roleBounds: this.deploymentPanelLayout.roleBounds.map((bounds) => ({ ...bounds })), + restoreButtonBounds: { ...this.deploymentPanelLayout.restoreButtonBounds }, + startButtonBounds: { ...this.deploymentPanelLayout.startButtonBounds } + } + : null; return { + header, + sidebar, + deploymentPanel, miniMap: miniMap ? { visible: this.miniMapVisible,