From 8b2a935b85d50692ec9f8f7af5a9c3e440d9a10c Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 12 Jul 2026 14:40:00 +0900 Subject: [PATCH] feat: unify FHD battle detail panels --- scripts/verify-release-candidate.mjs | 389 ++++++++++++- src/game/scenes/BattleScene.ts | 826 +++++++++++++++++++-------- 2 files changed, 987 insertions(+), 228 deletions(-) diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 47289db..6f27531 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -5497,6 +5497,8 @@ async function assertLargeRosterHud(browser, url) { assertLateBattleHeaderLayout(situationProbe, 'situation'); assertLateBattleSidebarLayout(situationProbe, 'situation', true); + await assertFinalBattleFhdDetailPanels(page); + await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.'); @@ -5593,6 +5595,160 @@ async function assertLargeRosterHud(browser, url) { } } +async function assertFinalBattleFhdDetailPanels(page) { + const allyFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const unit = scene?.debugUnitById?.('zhuge-liang'); + const message = '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.'; + if (!scene || !unit) { + return null; + } + scene.renderUnitDetail?.(unit, message); + return { unitId: unit.id, unitName: unit.name, messageLineCount: message.split('\n').length }; + }); + assert( + allyFixture?.unitId === 'zhuge-liang' && allyFixture.messageLineCount === 3, + `Expected the late-battle ally detail fixture to render Zhuge Liang with the real three-line command-cancel guidance: ${JSON.stringify(allyFixture)}` + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'zhuge-liang', + undefined, + { timeout: 30000 } + ); + const allyDetailProbe = await readLargeRosterHudProbe(page); + assertUnitDetailPanelLayout(allyDetailProbe, 'zhuge-liang', 'ally'); + + const enemyFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const unit = scene?.debugUnitById?.('northern-twelfth-scout-e'); + if (!scene || !unit) { + return null; + } + scene.renderUnitDetail?.(unit, scene.enemyDetailMessage?.(unit) ?? '적군 전력을 확인하세요.'); + return { unitId: unit.id, unitName: unit.name }; + }); + assert( + enemyFixture?.unitId === 'northern-twelfth-scout-e' && enemyFixture.unitName?.length >= 8, + `Expected the late-battle enemy detail fixture to exercise a long officer name: ${JSON.stringify(enemyFixture)}` + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'northern-twelfth-scout-e', + undefined, + { timeout: 30000 } + ); + const enemyDetailProbe = await readLargeRosterHudProbe(page); + assertUnitDetailPanelLayout(enemyDetailProbe, 'northern-twelfth-scout-e', 'long enemy'); + await waitForBattleHudPaint(page); + await page.screenshot({ path: `${screenshotDir}/rc-final-battle-unit-detail.png`, fullPage: true }); + await assertCanvasPainted(page, 'final battle unit detail'); + + const bondFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + return null; + } + const expectedBondIds = scene.activeSortieBonds?.().map((bond) => bond.id) ?? []; + scene.bondPage = 0; + scene.renderBondPanel?.(); + return { expectedBondIds }; + }); + assert( + bondFixture?.expectedBondIds?.length > 4, + `Expected the exact eight-officer final sortie to exercise paged active bonds: ${JSON.stringify(bondFixture)}` + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === 0, + undefined, + { timeout: 30000 } + ); + + const firstBondProbe = await readLargeRosterHudProbe(page); + const bondPageCount = firstBondProbe.bondPanel?.pageCount ?? 0; + assert( + bondPageCount > 1 && firstBondProbe.bondPanel?.totalCount === bondFixture.expectedBondIds.length, + `Expected paged final-battle bond debug state to match the active sortie bonds: ${JSON.stringify({ bondFixture, bondPanel: firstBondProbe.bondPanel })}` + ); + const visibleBondIds = []; + for (let pageIndex = 0; pageIndex < bondPageCount; pageIndex += 1) { + if (pageIndex > 0) { + await page.evaluate((targetPage) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + return; + } + scene.bondPage = targetPage; + scene.renderBondPanel?.(); + }, pageIndex); + await page.waitForFunction( + (targetPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === targetPage, + pageIndex, + { timeout: 30000 } + ); + } + const bondProbe = pageIndex === 0 ? firstBondProbe : await readLargeRosterHudProbe(page); + assertBondPanelLayout(bondProbe, pageIndex, bondPageCount, bondFixture.expectedBondIds.length); + visibleBondIds.push(...bondProbe.bondPanel.visibleBondIds); + } + assert( + visibleBondIds.length === bondFixture.expectedBondIds.length && + new Set(visibleBondIds).size === visibleBondIds.length && + sameJsonValue([...visibleBondIds].sort(), [...bondFixture.expectedBondIds].sort()), + `Expected every active final-battle bond exactly once across all pages: ${JSON.stringify({ expected: bondFixture.expectedBondIds, actual: visibleBondIds })}` + ); + await waitForBattleHudPaint(page); + await page.screenshot({ path: `${screenshotDir}/rc-final-battle-bond-panel.png`, fullPage: true }); + await assertCanvasPainted(page, 'final battle bond panel'); + + const expectedThreatIds = [ + 'northern-twelfth-scout-e', + 'northern-twelfth-camp-guard-e', + 'northern-twelfth-strategist-c', + 'northern-twelfth-cavalry-e' + ]; + const threatFixture = await page.evaluate((enemyIds) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const enemies = enemyIds.map((unitId) => scene?.debugUnitById?.(unitId)).filter(Boolean); + if (!scene || enemies.length !== enemyIds.length) { + return null; + } + scene.renderThreatDetail?.({ x: 38, y: 96, enemies, strongestBehavior: 'aggressive' }); + return { enemyIds: enemies.map((enemy) => enemy.id), enemyNames: enemies.map((enemy) => enemy.name) }; + }, expectedThreatIds); + assert( + sameJsonValue(threatFixture?.enemyIds, expectedThreatIds) && threatFixture.enemyNames.every((name) => name.length >= 8), + `Expected the final-battle threat fixture to use four real long-name enemies: ${JSON.stringify(threatFixture)}` + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.battleHud?.threatPanel?.enemyRows?.length === 4, + undefined, + { timeout: 30000 } + ); + const threatProbe = await readLargeRosterHudProbe(page); + assertThreatPanelLayout(threatProbe, expectedThreatIds); + await waitForBattleHudPaint(page); + await page.screenshot({ path: `${screenshotDir}/rc-final-battle-threat-panel.png`, fullPage: true }); + await assertCanvasPainted(page, 'final battle threat panel'); + + await page.evaluate(() => { + window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderTerrainDetail?.(38, 96); + }); + await page.waitForFunction(() => { + const panel = window.__HEROS_DEBUG__?.battle()?.battleHud?.terrainPanel; + return panel?.tile?.x === 38 && panel?.tile?.y === 96; + }, undefined, { timeout: 30000 }); + const terrainProbe = await readLargeRosterHudProbe(page); + assertTerrainPanelLayout(terrainProbe, 'camp'); + await waitForBattleHudPaint(page); + await page.screenshot({ path: `${screenshotDir}/rc-final-battle-terrain-panel.png`, fullPage: true }); + await assertCanvasPainted(page, 'final battle terrain panel'); +} + +async function waitForBattleHudPaint(page) { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +} + async function readLargeRosterHudProbe(page) { return page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -5604,6 +5760,10 @@ async function readLargeRosterHudProbe(page) { header: hud?.header ?? null, sidebar: hud?.sidebar ?? null, deploymentPanel: hud?.deploymentPanel ?? null, + unitDetailPanel: hud?.unitDetailPanel ?? null, + bondPanel: hud?.bondPanel ?? null, + threatPanel: hud?.threatPanel ?? null, + terrainPanel: hud?.terrainPanel ?? null, sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null, @@ -5661,7 +5821,7 @@ function assertLateBattleSidebarLayout(probe, label, expectMiniMap) { `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)}`); + assert(miniMap?.visible === false, `Expected late-battle ${label} to reserve the full sidebar by hiding the minimap: ${JSON.stringify(probe)}`); return; } assert( @@ -5703,6 +5863,233 @@ function assertLateBattleDeploymentLayout(probe) { ); } +function assertUnitDetailPanelLayout(probe, expectedUnitId, label) { + const panel = probe.unitDetailPanel; + assertLateBattleSidebarLayout(probe, `${label} unit detail`, false); + assert( + panel?.unitId === expectedUnitId && + panel.summaryBounds?.length === 3 && + panel.statBounds?.length === 5 && + panel.effectBounds?.length > 0 && + panel.effectBounds.length <= 3 && + panel.equipmentBounds?.length === 3 && + isFiniteBounds(panel.messageBounds), + `Expected complete FHD ${label} unit-detail sections: ${JSON.stringify(probe)}` + ); + + const allBounds = [ + panel.headerBounds, + panel.nameBounds, + panel.levelBadgeBounds, + panel.hpBounds, + ...panel.summaryBounds, + ...panel.statBounds, + panel.statusBounds, + ...panel.effectBounds, + ...panel.equipmentBounds, + panel.messageBounds + ]; + assertDetailBoundsInside(probe, allBounds, `${label} unit detail`); + assert( + boundsInside(panel.nameBounds, panel.headerBounds) && + boundsInside(panel.levelBadgeBounds, panel.headerBounds) && + panel.nameBounds.x + panel.nameBounds.width <= panel.levelBadgeBounds.x + 1, + `Expected the FHD ${label} unit name to remain inside the header and left of the level badge: ${JSON.stringify(panel)}` + ); + assertHorizontalBounds(panel.summaryBounds, `${label} unit summary cards`); + assertHorizontalBounds(panel.effectBounds, `${label} unit effect cards`); + assertSequentialBounds(panel.statBounds, `${label} unit stat rows`); + assertSequentialBounds(panel.equipmentBounds, `${label} unit equipment rows`); + + const sectionBounds = [ + panel.headerBounds, + panel.hpBounds, + boundsUnion(panel.summaryBounds), + boundsUnion(panel.statBounds), + panel.statusBounds, + boundsUnion(panel.effectBounds), + boundsUnion(panel.equipmentBounds), + panel.messageBounds + ]; + assertSequentialBounds(sectionBounds, `${label} unit-detail sections`); +} + +function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedTotalCount) { + const panel = probe.bondPanel; + assertLateBattleSidebarLayout(probe, `bond page ${expectedPage + 1}`, false); + const expectedVisibleCount = Math.min( + panel?.rowsPerPage ?? 0, + Math.max(0, expectedTotalCount - expectedPage * (panel?.rowsPerPage ?? 0)) + ); + assert( + panel?.page === expectedPage && + panel.pageCount === expectedPageCount && + panel.totalCount === expectedTotalCount && + panel.rowsPerPage > 0 && + panel.visibleBondIds?.length === expectedVisibleCount && + panel.rowLayouts?.length === expectedVisibleCount && + new Set(panel.visibleBondIds).size === panel.visibleBondIds.length && + sameJsonValue(panel.rowLayouts.map((row) => row.bondId), panel.visibleBondIds) && + isFiniteBounds(panel.navigationBounds) && + isFiniteBounds(panel.messageBounds), + `Expected complete paged FHD bond state on page ${expectedPage + 1}: ${JSON.stringify(probe)}` + ); + + const rowBounds = panel.rowLayouts.map((row) => row.rowBounds); + const allBounds = [ + panel.headerBounds, + ...rowBounds, + ...panel.rowLayouts.flatMap((row) => [row.nameBounds, row.levelBounds, row.titleBounds, row.gaugeBounds]), + panel.navigationBounds, + panel.messageBounds + ]; + assertDetailBoundsInside(probe, allBounds, `bond page ${expectedPage + 1}`); + panel.rowLayouts.forEach((row) => { + assert( + boundsInside(row.nameBounds, row.rowBounds) && + boundsInside(row.levelBounds, row.rowBounds) && + boundsInside(row.titleBounds, row.rowBounds) && + boundsInside(row.gaugeBounds, row.rowBounds) && + row.nameBounds.x + row.nameBounds.width <= row.levelBounds.x + 1 && + row.titleBounds.x + row.titleBounds.width <= row.gaugeBounds.x + 1, + `Expected bond ${row.bondId} names, levels, titles, and gauges in separate FHD columns: ${JSON.stringify(row)}` + ); + }); + assertSequentialBounds(rowBounds, `bond rows on page ${expectedPage + 1}`); + assertSequentialBounds( + [panel.headerBounds, ...rowBounds, panel.navigationBounds, panel.messageBounds], + `bond sections on page ${expectedPage + 1}` + ); +} + +function assertThreatPanelLayout(probe, expectedEnemyIds) { + const panel = probe.threatPanel; + assertLateBattleSidebarLayout(probe, 'threat detail', true); + assert( + panel?.tile?.x === 38 && + panel.tile.y === 96 && + panel.summaryRows?.length === 4 && + panel.enemyRows?.length === expectedEnemyIds.length && + sameJsonValue(panel.enemyRows.map((row) => row.unitId), expectedEnemyIds) && + isFiniteBounds(panel.messageBounds), + `Expected the four-enemy FHD threat fixture at the final-camp tile: ${JSON.stringify(probe)}` + ); + const summaryBounds = panel.summaryRows.map((row) => row.bounds); + const enemyBounds = panel.enemyRows.map((row) => row.rowBounds); + const allBounds = [ + panel.headerBounds, + ...summaryBounds, + ...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]), + ...enemyBounds, + ...panel.enemyRows.flatMap((row) => [row.nameBounds, row.valueBounds]), + panel.messageBounds + ]; + assertDetailBoundsInside(probe, allBounds, 'threat detail'); + assertSituationRows(panel.summaryRows, 'threat summary'); + panel.enemyRows.forEach((row) => { + assert( + boundsInside(row.nameBounds, row.rowBounds) && + boundsInside(row.valueBounds, row.rowBounds) && + !boundsOverlap(row.nameBounds, row.valueBounds, 2), + `Expected threat names and metrics on separate readable lines for ${row.unitId}: ${JSON.stringify(row)}` + ); + }); + assertSequentialBounds(enemyBounds, 'threat enemy rows'); + assertSequentialBounds( + [panel.headerBounds, ...summaryBounds, ...enemyBounds, panel.messageBounds], + 'threat detail sections' + ); + assert( + panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1, + `Expected the threat guidance above the FHD minimap: ${JSON.stringify(probe)}` + ); +} + +function assertTerrainPanelLayout(probe, expectedTerrain) { + const panel = probe.terrainPanel; + assertLateBattleSidebarLayout(probe, 'terrain detail', true); + assert( + panel?.tile?.x === 38 && + panel.tile.y === 96 && + panel.terrain === expectedTerrain && + panel.summaryRows?.length === 6 && + isFiniteBounds(panel.messageBounds), + `Expected complete FHD ${expectedTerrain} terrain details at the final-camp tile: ${JSON.stringify(probe)}` + ); + const summaryBounds = panel.summaryRows.map((row) => row.bounds); + const allBounds = [ + panel.headerBounds, + ...summaryBounds, + ...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]), + panel.messageBounds + ]; + assertDetailBoundsInside(probe, allBounds, 'terrain detail'); + assertSituationRows(panel.summaryRows, 'terrain summary'); + assertSequentialBounds([panel.headerBounds, ...summaryBounds, panel.messageBounds], 'terrain detail sections'); + assert( + panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1, + `Expected the terrain guidance above the FHD minimap: ${JSON.stringify(probe)}` + ); +} + +function assertSituationRows(rows, label) { + rows.forEach((row, index) => { + assert( + boundsInside(row.labelBounds, row.bounds) && + boundsInside(row.valueBounds, row.bounds) && + row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1, + `Expected ${label} row ${index + 1} labels and values in separate FHD columns: ${JSON.stringify(row)}` + ); + }); + assertSequentialBounds(rows.map((row) => row.bounds), `${label} rows`); +} + +function assertDetailBoundsInside(probe, boundsList, label) { + assert( + isFiniteBounds(probe.sidebar?.bounds) && + isFiniteBounds(probe.panelBounds) && + isFiniteBounds(probe.sceneBounds) && + boundsList.length > 0 && + boundsList.every((bounds) => + boundsInside(bounds, probe.sidebar.bounds) && + boundsInside(bounds, probe.panelBounds) && + boundsInside(bounds, probe.sceneBounds) + ), + `Expected every ${label} bound inside the sidebar, panel, and FHD scene: ${JSON.stringify({ boundsList, probe })}` + ); +} + +function assertSequentialBounds(boundsList, label) { + assert( + boundsList.length > 0 && + boundsList.every(isFiniteBounds) && + boundsList.every((bounds, index) => + index === 0 || boundsList[index - 1].y + boundsList[index - 1].height <= bounds.y + 1 + ), + `Expected non-overlapping sequential ${label}: ${JSON.stringify(boundsList)}` + ); +} + +function assertHorizontalBounds(boundsList, label) { + const ordered = [...boundsList].sort((left, right) => left.x - right.x); + assert( + ordered.length > 0 && + ordered.every(isFiniteBounds) && + ordered.every((bounds, index) => + index === 0 || ordered[index - 1].x + ordered[index - 1].width <= bounds.x + 1 + ), + `Expected non-overlapping horizontal ${label}: ${JSON.stringify(ordered)}` + ); +} + +function boundsUnion(boundsList) { + const left = Math.min(...boundsList.map((bounds) => bounds.x)); + const top = Math.min(...boundsList.map((bounds) => bounds.y)); + const right = Math.max(...boundsList.map((bounds) => bounds.x + bounds.width)); + const bottom = Math.max(...boundsList.map((bounds) => bounds.y + bounds.height)); + return { x: left, y: top, width: right - left, height: bottom - top }; +} + 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)}`); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 13b01ef..21eff1e 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1133,6 +1133,14 @@ type BattleLayout = { panelHeight: number; }; +type HudBounds = { x: number; y: number; width: number; height: number }; + +type SituationLineLayout = { + bounds: HudBounds; + labelBounds: HudBounds; + valueBounds: HudBounds; +}; + type TerrainTileView = { x: number; y: number; @@ -1193,6 +1201,60 @@ type DeploymentPanelLayout = { startButtonBounds: { x: number; y: number; width: number; height: number }; }; +type UnitDetailPanelLayout = { + unitId: string; + headerBounds: HudBounds; + nameBounds: HudBounds; + levelBadgeBounds: HudBounds; + hpBounds: HudBounds; + summaryBounds: HudBounds[]; + statBounds: HudBounds[]; + statusBounds: HudBounds; + effectBounds: HudBounds[]; + equipmentBounds: HudBounds[]; + messageBounds: HudBounds | null; +}; + +type BondPanelLayout = { + page: number; + pageCount: number; + rowsPerPage: number; + totalCount: number; + visibleBondIds: string[]; + headerBounds: HudBounds; + rowLayouts: Array<{ + bondId: string; + rowBounds: HudBounds; + nameBounds: HudBounds; + levelBounds: HudBounds; + titleBounds: HudBounds; + gaugeBounds: HudBounds; + }>; + navigationBounds: HudBounds | null; + messageBounds: HudBounds; +}; + +type ThreatPanelLayout = { + tile: { x: number; y: number }; + headerBounds: HudBounds; + summaryRows: SituationLineLayout[]; + enemyRows: Array<{ + unitId: string; + rowBounds: HudBounds; + nameBounds: HudBounds; + valueBounds: HudBounds; + }>; + messageBounds: HudBounds; +}; + +type TerrainPanelLayout = { + tile: { x: number; y: number }; + terrain: TerrainType; + headerBounds: HudBounds; + summaryRows: SituationLineLayout[]; + messageBounds: HudBounds; +}; + type UnitView = { sprite: Phaser.GameObjects.Sprite; hitZone: Phaser.GameObjects.Zone; @@ -3291,8 +3353,13 @@ export class BattleScene extends Phaser.Scene { private objectiveMarkerObjects: Phaser.GameObjects.GameObject[] = []; private rosterTab: RosterTab = 'ally'; private rosterPage = 0; + private bondPage = 0; private rosterPanelLayout?: RosterPanelLayout; private deploymentPanelLayout?: DeploymentPanelLayout; + private unitDetailPanelLayout?: UnitDetailPanelLayout; + private bondPanelLayout?: BondPanelLayout; + private threatPanelLayout?: ThreatPanelLayout; + private terrainPanelLayout?: TerrainPanelLayout; private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private commandButtons: CommandButton[] = []; private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; @@ -3417,7 +3484,12 @@ export class BattleScene extends Phaser.Scene { configureBattleScenario(getBattleScenario(data?.battleId)); this.rosterTab = 'ally'; this.rosterPage = 0; + this.bondPage = 0; this.rosterPanelLayout = undefined; + this.unitDetailPanelLayout = undefined; + this.bondPanelLayout = undefined; + this.threatPanelLayout = undefined; + this.terrainPanelLayout = undefined; const campaign = getCampaignState(); const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id ? campaign.sortieOrderSelection.orderId @@ -15404,6 +15476,7 @@ export class BattleScene extends Phaser.Scene { return; } if (action === 'bond') { + this.bondPage = 0; this.renderBondPanel(); return; } @@ -21823,60 +21896,168 @@ export class BattleScene extends Phaser.Scene { private renderBondPanel(message?: string) { this.clearSidePanelContent(); + this.setMiniMapVisible(false); 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 bottom = this.sideContentBottom(6); + const headerHeight = this.battleUiLength(40); + const rowHeight = this.battleUiLength(60); + const rowStep = this.battleUiLength(64); + const rowsPerPage = 4; + const navigationHeight = this.battleUiLength(24); + const messageHeight = this.battleUiLength(40); + const messageY = bottom - messageHeight; this.trackSideObject(this.add.text(left, top, '공명 관계', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: this.battleUiFontSize(20), color: '#f2e3bf', fontStyle: '700' })); - this.trackSideObject(this.add.text(left, top + 34, '같은 적을 노리거나 전투 사이 이벤트로 상승합니다.', { + const activeBonds = this.activeSortieBonds().sort((leftBond, rightBond) => ( + Number(this.isCoreSortieResonanceBond(rightBond)) - Number(this.isCoreSortieResonanceBond(leftBond)) || + rightBond.level - leftBond.level || + leftBond.title.localeCompare(rightBond.title) || + leftBond.id.localeCompare(rightBond.id) + )); + this.trackSideObject(this.add.text(left, top + this.battleUiLength(24), `현재 출전 공명 ${activeBonds.length}개 · 핵심 공명을 우선 표시합니다.`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', - color: '#9fb0bf' + fontSize: this.battleUiFontSize(10), + color: '#9fb0bf', + fixedWidth: width })); - Array.from(this.bondStates.values()).forEach((bond, index) => { - const y = top + 70 + index * 104; - const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); - const bg = this.trackSideObject(this.add.rectangle(left, y, width, 92, 0x101820, 0.9)); - bg.setOrigin(0); - bg.setStrokeStyle(1, bond.level >= 70 ? palette.gold : palette.blue, 0.66); + const pageCount = Math.max(1, Math.ceil(activeBonds.length / rowsPerPage)); + this.bondPage = Phaser.Math.Clamp(this.bondPage, 0, pageCount - 1); + const visibleBonds = activeBonds.slice(this.bondPage * rowsPerPage, (this.bondPage + 1) * rowsPerPage); + const listTop = top + this.battleUiLength(44); + const rowLayouts: BondPanelLayout['rowLayouts'] = []; - this.trackSideObject(this.add.text(left + 12, y + 8, `${first} · ${second}`, { + visibleBonds.forEach((bond, index) => { + const y = listTop + index * rowStep; + const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); + const coreResonance = this.isCoreSortieResonanceBond(bond); + const tone = coreResonance || bond.level >= 70 ? palette.gold : palette.blue; + const bg = this.trackSideObject(this.add.rectangle(left, y, width, rowHeight, coreResonance ? 0x1f2430 : 0x101820, 0.94)); + bg.setOrigin(0); + bg.setStrokeStyle(this.battleUiLength(1), tone, coreResonance ? 0.88 : 0.62); + + const levelText = this.trackSideObject(this.add.text(left + width - this.battleUiLength(8), y + this.battleUiLength(5), `Lv ${bond.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', - color: '#e8dfca', - fontStyle: '700' - })); - const levelText = this.trackSideObject(this.add.text(left + width - 12, y + 8, `${bond.level}`, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700' })); levelText.setOrigin(1, 0); - - this.trackSideObject(this.add.text(left + 12, y + 33, bond.title, { + const levelBounds = levelText.getBounds(); + const nameX = left + this.battleUiLength(8); + const nameText = this.trackSideObject(this.add.text(nameX, y + this.battleUiLength(5), `${first} · ${second}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', - color: '#d8b15f' + fontSize: this.battleUiFontSize(12), + color: '#e8dfca', + fontStyle: '700' })); - this.drawGauge(left + 86, y + 39, width - 148, 8, bond.exp / 100, bond.level >= 70 ? 0xd8b15f : 0x58aee0); + this.fitTextToWidth(nameText, `${first} · ${second}`, levelBounds.x - this.battleUiLength(8) - nameX); + + const gaugeX = left + this.battleUiLength(96); + const gaugeY = y + this.battleUiLength(31); + const gaugeWidth = width - this.battleUiLength(104); + const titleX = left + this.battleUiLength(8); + const titleLabel = coreResonance ? `핵심 · ${bond.title}` : bond.title; + const titleText = this.trackSideObject(this.add.text(titleX, y + this.battleUiLength(23), titleLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(10), + color: coreResonance ? '#f4dfad' : '#d8b15f', + fontStyle: '700' + })); + this.fitTextToWidth(titleText, titleLabel, gaugeX - this.battleUiLength(8) - titleX); + this.drawGauge(gaugeX, gaugeY, gaugeWidth, this.battleUiLength(4), bond.exp / 100, coreResonance || bond.level >= 70 ? 0xd8b15f : 0x58aee0); const bonus = this.bondBonus(bond); - this.trackSideObject(this.add.text(left + 12, y + 59, `피해 +${bonus.damageBonus}% 연계 ${bonus.chainRate}% 전투 +${bond.battleExp}`, { + const chainRate = this.bondChainRateForBond(bond); + const bonusText = this.trackSideObject(this.add.text(left + this.battleUiLength(8), y + this.battleUiLength(43), `피해 +${bonus.damageBonus}% · 연계 ${chainRate}% · 전투 +${bond.battleExp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: this.battleUiFontSize(9), color: '#d4dce6' })); + this.fitTextToWidth(bonusText, bonusText.text, width - this.battleUiLength(16)); + + const nameBounds = nameText.getBounds(); + const titleBounds = titleText.getBounds(); + rowLayouts.push({ + bondId: bond.id, + rowBounds: { x: left, y, width, height: rowHeight }, + nameBounds: { x: nameBounds.x, y: nameBounds.y, width: nameBounds.width, height: nameBounds.height }, + levelBounds: { x: levelBounds.x, y: levelBounds.y, width: levelBounds.width, height: levelBounds.height }, + titleBounds: { x: titleBounds.x, y: titleBounds.y, width: titleBounds.width, height: titleBounds.height }, + gaugeBounds: { x: gaugeX, y: gaugeY, width: gaugeWidth, height: this.battleUiLength(4) } + }); }); - this.renderPanelMessage(message ?? '공격 명령에서 같은 예상 목표를 노리면 공명 경험치가 쌓입니다.', left, top + 396, width); + let navigationBounds: HudBounds | null = null; + if (pageCount > 1) { + const navigationY = messageY - this.battleUiLength(6) - navigationHeight; + navigationBounds = this.renderBondPageNavigation(left, navigationY, width, pageCount, message); + } + const panelMessage = message ?? ( + activeBonds.length > 0 + ? '같은 적을 함께 노리면 공명 경험치가 쌓이고 연계 공격 확률이 오릅니다.' + : '현재 출전 장수 사이에 활성화된 공명이 없습니다.' + ); + this.renderPanelMessage(panelMessage, left, messageY, width, messageHeight, true); + this.bondPanelLayout = { + page: this.bondPage, + pageCount, + rowsPerPage, + totalCount: activeBonds.length, + visibleBondIds: visibleBonds.map((bond) => bond.id), + headerBounds: { x: left, y: top, width, height: headerHeight }, + rowLayouts, + navigationBounds, + messageBounds: { x: left, y: messageY, width, height: messageHeight } + }; + } + + private renderBondPageNavigation(x: number, y: number, width: number, pageCount: number, message?: string) { + const height = this.battleUiLength(24); + const background = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0b1118, 0.96)); + background.setOrigin(0); + background.setStrokeStyle(this.battleUiLength(1), 0x647485, 0.62); + + const renderControl = (controlX: number, label: string, enabled: boolean, nextPage: number) => { + 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.setStrokeStyle(this.battleUiLength(1), enabled ? palette.gold : 0x53606c, enabled ? 0.78 : 0.36); + if (enabled) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerdown', () => { + this.bondPage = nextPage; + this.renderBondPanel(message); + }); + } + const text = this.trackSideObject(this.add.text(controlX + controlWidth / 2, y + height / 2, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(10), + color: enabled ? '#f4dfad' : '#77828c', + fontStyle: '700' + })); + text.setOrigin(0.5); + }; + + renderControl(x + this.battleUiLength(2), '이전', this.bondPage > 0, this.bondPage - 1); + renderControl(x + width - this.battleUiLength(54), '다음', this.bondPage < pageCount - 1, this.bondPage + 1); + const indicator = this.trackSideObject(this.add.text(x + width / 2, y + height / 2, `${this.bondPage + 1} / ${pageCount}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(11), + color: '#d4dce6', + fontStyle: '700' + })); + indicator.setOrigin(0.5); + return { x, y, width, height }; } private renderSituationPanel(message?: string) { @@ -22166,57 +22347,85 @@ export class BattleScene extends Phaser.Scene { private renderThreatDetail(tile: ThreatTile) { this.clearSidePanelContent(); + this.setMiniMapVisible(true); const terrain = battleMap.terrain[tile.y][tile.x]; const terrainRule = getTerrainRule(terrain); 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 bottom = this.sideContentBottom(6); const strongest = [...tile.enemies].sort((a, b) => this.actionPower(b, 'attack') - this.actionPower(a, 'attack'))[0]; + const summaryRows = [ + this.renderSituationLine('좌표', `${tile.x + 1}, ${tile.y + 1}`, left, top + this.battleUiLength(28), width, battleFhdUiScale), + this.renderSituationLine('지형', `${terrainRule.label} / 방어 ${this.formatSignedValue(terrainRule.defenseBonus)}`, left, top + this.battleUiLength(56), width, battleFhdUiScale), + this.renderSituationLine('위협 수', `${tile.enemies.length}`, left, top + this.battleUiLength(84), width, battleFhdUiScale), + this.renderSituationLine('최대 태세', this.enemyBehaviorLabel(tile.strongestBehavior), left, top + this.battleUiLength(112), width, battleFhdUiScale) + ]; this.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('좌표', `${tile.x + 1}, ${tile.y + 1}`, left, top + 48, width); - this.renderSituationLine('지형', `${terrainRule.label} / 방어 ${this.formatSignedValue(terrainRule.defenseBonus)}`, left, top + 86, width); - this.renderSituationLine('위협 수', `${tile.enemies.length}`, left, top + 124, width); - this.renderSituationLine('최대 태세', this.enemyBehaviorLabel(tile.strongestBehavior), left, top + 162, width); - - this.trackSideObject(this.add.text(left, top + 206, '위협 부대', { + const visibleEnemies = tile.enemies.slice(0, 4); + const hiddenCount = Math.max(0, tile.enemies.length - visibleEnemies.length); + this.trackSideObject(this.add.text(left, top + this.battleUiLength(142), hiddenCount > 0 ? `위협 부대 · 외 ${hiddenCount}` : '위협 부대', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700' })); - tile.enemies.slice(0, 4).forEach((enemy, index) => { - const rowY = top + 232 + index * 28; + const enemyRows: ThreatPanelLayout['enemyRows'] = []; + const rowHeight = this.battleUiLength(23); + const rowStep = this.battleUiLength(25); + visibleEnemies.forEach((enemy, index) => { + const rowY = top + this.battleUiLength(158) + index * rowStep; const unitClass = getUnitClass(enemy.classKey); - const bg = this.trackSideObject(this.add.rectangle(left, rowY, width, 24, 0x101820, 0.86)); + const bg = this.trackSideObject(this.add.rectangle(left, rowY, width, rowHeight, 0x101820, 0.9)); bg.setOrigin(0); - bg.setStrokeStyle(1, this.threatColor(this.enemyBehavior(enemy)), 0.52); - this.trackSideObject(this.add.text(left + 10, rowY + 5, `${enemy.name} · ${unitClass.name}`, { + bg.setStrokeStyle(this.battleUiLength(1), this.threatColor(this.enemyBehavior(enemy)), 0.56); + const nameX = left + this.battleUiLength(8); + const nameText = this.trackSideObject(this.add.text(nameX, rowY + this.battleUiLength(2), `${enemy.name} · ${unitClass.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(9), color: '#d4dce6', fontStyle: '700' })); - const value = this.trackSideObject(this.add.text(left + width - 10, rowY + 5, `${this.enemyBehaviorLabel(this.enemyBehavior(enemy))} / 공 ${this.actionPower(enemy, 'attack')} / 사 ${this.attackRange(enemy)}`, { + this.fitTextToWidth(nameText, nameText.text, width - this.battleUiLength(16)); + const valueText = this.trackSideObject(this.add.text(left + width - this.battleUiLength(8), rowY + this.battleUiLength(13), `${this.enemyBehaviorLabel(this.enemyBehavior(enemy))} · 공 ${this.actionPower(enemy, 'attack')} · 사 ${this.attackRange(enemy)}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(8), color: '#f4dfad' })); - value.setOrigin(1, 0); + valueText.setOrigin(1, 0); + this.fitTextToWidth(valueText, valueText.text, width - this.battleUiLength(16)); + const nameBounds = nameText.getBounds(); + const valueBounds = valueText.getBounds(); + enemyRows.push({ + unitId: enemy.id, + rowBounds: { x: left, y: rowY, width, height: rowHeight }, + nameBounds: { x: nameBounds.x, y: nameBounds.y, width: nameBounds.width, height: nameBounds.height }, + valueBounds: { x: valueBounds.x, y: valueBounds.y, width: valueBounds.width, height: valueBounds.height } + }); }); const message = strongest ? `${strongest.name} 최대 위협. 방어 보정 지형을 활용하세요.` : '현재 이 칸을 위협하는 적이 없습니다.'; - this.renderPanelMessage(message, left, top + 348, width, 46); + const messageHeight = this.battleUiLength(36); + const messageY = bottom - messageHeight; + this.renderPanelMessage(message, left, messageY, width, messageHeight, true); + this.threatPanelLayout = { + tile: { x: tile.x, y: tile.y }, + headerBounds: { x: left, y: top, width, height: this.battleUiLength(22) }, + summaryRows, + enemyRows, + messageBounds: { x: left, y: messageY, width, height: messageHeight } + }; } private renderTerrainDetail(x: number, y: number) { @@ -22225,9 +22434,10 @@ export class BattleScene extends Phaser.Scene { const terrain = battleMap.terrain[y][x]; const terrainRule = getTerrainRule(terrain); 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 bottom = this.sideContentBottom(6); const attackDefense = Math.floor(terrainRule.defenseBonus / 3); const strategyDefense = Math.floor(terrainRule.defenseBonus / 4); const hitPenalty = Math.max(0, Math.floor(terrainRule.defenseBonus / 2)); @@ -22236,47 +22446,61 @@ export class BattleScene extends Phaser.Scene { terrainRule.moraleBonus ? `사기 +${terrainRule.moraleBonus}` : '' ].filter(Boolean).join(' / ') || '없음'; - const header = this.trackSideObject(this.add.rectangle(left, top, width, 58, 0x101820, 0.94)); + const headerHeight = this.battleUiLength(48); + const header = this.trackSideObject(this.add.rectangle(left, top, width, headerHeight, 0x101820, 0.94)); header.setOrigin(0); - header.setStrokeStyle(1, this.terrainTone(terrain), 0.72); - const iconFrame = this.trackSideObject(this.add.rectangle(left + 28, top + 29, 44, 44, 0x0a0f14, 0.92)); - iconFrame.setStrokeStyle(1, this.terrainTone(terrain), 0.58); - this.trackSideIcon(left + 28, top + 29, 'terrain', 40); + header.setStrokeStyle(this.battleUiLength(1), this.terrainTone(terrain), 0.72); + const iconFrame = this.trackSideObject(this.add.rectangle(left + this.battleUiLength(24), top + this.battleUiLength(24), this.battleUiLength(36), this.battleUiLength(36), 0x0a0f14, 0.92)); + iconFrame.setStrokeStyle(this.battleUiLength(1), this.terrainTone(terrain), 0.58); + this.trackSideIcon(left + this.battleUiLength(24), top + this.battleUiLength(24), 'terrain', this.battleUiLength(34)); - this.trackSideObject(this.add.text(left + 58, top + 7, '지형 정보', { + this.trackSideObject(this.add.text(left + this.battleUiLength(48), top + this.battleUiLength(6), '지형 정보', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(10), color: '#9fb0bf', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 58, top + 27, terrainRule.label, { + this.trackSideObject(this.add.text(left + this.battleUiLength(48), top + this.battleUiLength(21), terrainRule.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: this.battleUiFontSize(18), color: '#f2e3bf', fontStyle: '700' })); - this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 76, width); - this.renderSituationLine('이동', terrainRule.passable === false ? '진입 불가' : `기본 비용 ${terrainRule.moveCost}`, left, top + 120, width); - this.renderSituationLine( + const summaryRows = [ + this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + this.battleUiLength(52), width, battleFhdUiScale), + this.renderSituationLine('이동', terrainRule.passable === false ? '진입 불가' : `기본 비용 ${terrainRule.moveCost}`, left, top + this.battleUiLength(80), width, battleFhdUiScale), + this.renderSituationLine( '전투', terrainRule.defenseBonus === 0 ? '보정 없음' : `방어 ${this.formatSignedValue(terrainRule.defenseBonus)} / 명중 -${hitPenalty}`, - left, - top + 164, - width - ); - this.renderSituationLine( + left, + top + this.battleUiLength(108), + width, + battleFhdUiScale + ), + this.renderSituationLine( '피해 완화', terrainRule.defenseBonus === 0 ? '없음' : `공격 -${attackDefense} / 책략 -${strategyDefense}`, - left, - top + 208, - width - ); - this.renderSituationLine('턴 시작', turnEffect, left, top + 252, width); - this.renderSituationLine('핵심 효과', this.terrainEffectText(terrain, undefined, 'compact'), left, top + 296, width); - this.renderPanelMessage(this.terrainTacticalNote(terrain), left, top + 350, width, 64); + left, + top + this.battleUiLength(136), + width, + battleFhdUiScale + ), + this.renderSituationLine('턴 시작', turnEffect, left, top + this.battleUiLength(164), width, battleFhdUiScale), + this.renderSituationLine('핵심 효과', this.terrainEffectText(terrain, undefined, 'compact'), left, top + this.battleUiLength(192), width, battleFhdUiScale) + ]; + const messageHeight = this.battleUiLength(40); + const messageY = bottom - messageHeight; + this.renderPanelMessage(this.terrainTacticalNote(terrain), left, messageY, width, messageHeight, true); + this.terrainPanelLayout = { + tile: { x, y }, + terrain, + headerBounds: { x: left, y: top, width, height: headerHeight }, + summaryRows, + messageBounds: { x: left, y: messageY, width, height: messageHeight } + }; } private formatSignedValue(value: number) { @@ -22292,7 +22516,7 @@ export class BattleScene extends Phaser.Scene { const bg = this.trackSideObject(this.add.rectangle(x, y, width, length(fhd ? 26 : 34), 0x101820, 0.86)); bg.setOrigin(0); bg.setStrokeStyle(length(1), 0x53606c, 0.46); - this.trackSideObject(this.add.text(x + length(fhd ? 8 : 12), y + length(fhd ? 5 : 8), label, { + const labelText = 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: `${length(fhd ? 12 : 15)}px`, color: '#9fb0bf' @@ -22304,6 +22528,18 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700' })); valueText.setOrigin(1, 0); + const labelBounds = labelText.getBounds(); + this.fitTextToWidth( + valueText, + value, + x + width - length(fhd ? 8 : 12) - labelBounds.x - labelBounds.width - length(8) + ); + const valueBounds = valueText.getBounds(); + return { + bounds: { x, y, width, height: length(fhd ? 26 : 34) }, + labelBounds: { x: labelBounds.x, y: labelBounds.y, width: labelBounds.width, height: labelBounds.height }, + valueBounds: { x: valueBounds.x, y: valueBounds.y, width: valueBounds.width, height: valueBounds.height } + }; } private bondBonus(bond: BondState) { @@ -22561,8 +22797,8 @@ export class BattleScene extends Phaser.Scene { this.setMiniMapVisible(false); 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 acted = this.actedUnitIds.has(unit.id); const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; @@ -22572,135 +22808,158 @@ export class BattleScene extends Phaser.Scene { const terrainRating = unitClass.terrainRatings[terrain]; const direction = this.currentUnitDirection(unit); - const header = this.trackSideObject(this.add.rectangle(left, top, width, 64, 0x101820, 0.96)); + const headerHeight = this.battleUiLength(52); + const header = this.trackSideObject(this.add.rectangle(left, top, width, headerHeight, 0x101820, 0.96)); header.setOrigin(0); - header.setStrokeStyle(1, factionColor, 0.76); + header.setStrokeStyle(this.battleUiLength(1), factionColor, 0.76); - const classIcon = this.trackSideIcon(left + 28, top + 32, this.unitMoveIcon(unit), 42); + const classIcon = this.trackSideIcon(left + this.battleUiLength(22), top + this.battleUiLength(26), this.unitMoveIcon(unit), this.battleUiLength(36)); classIcon.setAlpha(acted ? 0.86 : 0.96); - const headerTextX = left + 58; - this.trackSideObject(this.add.text(headerTextX, top + 7, `${rosterLabels[unit.faction]} / ${unitClass.family}`, { + const headerTextX = left + this.battleUiLength(44); + this.trackSideObject(this.add.text(headerTextX, top + this.battleUiLength(5), `${rosterLabels[unit.faction]} / ${unitClass.family}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(9), color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4', fontStyle: '700' })); - this.trackSideObject(this.add.text(headerTextX, top + 27, `${unit.name} ${unitClass.name}`, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '21px', - color: acted ? '#bdbdbd' : '#f2e3bf', - fontStyle: '700', - fixedWidth: width - 154 - })); - const levelBadge = this.trackSideObject(this.add.rectangle(left + width - 84, top + 8, 70, 26, 0x1b2834, 0.94)); + const levelBadge = this.trackSideObject(this.add.rectangle(left + width - this.battleUiLength(56), top + this.battleUiLength(5), this.battleUiLength(48), this.battleUiLength(18), 0x1b2834, 0.94)); levelBadge.setOrigin(0); - levelBadge.setStrokeStyle(1, factionColor, 0.6); - const levelText = this.trackSideObject(this.add.text(left + width - 49, top + 21, `Lv ${unit.level}`, { + levelBadge.setStrokeStyle(this.battleUiLength(1), factionColor, 0.6); + const levelText = this.trackSideObject(this.add.text(left + width - this.battleUiLength(32), top + this.battleUiLength(14), `Lv ${unit.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: this.battleUiFontSize(11), color: acted ? '#d8cfb5' : '#f4dfad', fontStyle: '700' })); levelText.setOrigin(0.5); + const unitNameLabel = `${unit.name} · ${unitClass.name}`; + const unitNameText = this.trackSideObject(this.add.text(headerTextX, top + this.battleUiLength(20), unitNameLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(16), + color: acted ? '#bdbdbd' : '#f2e3bf', + fontStyle: '700' + })); + this.fitTextToWidth(unitNameText, unitNameLabel, levelBadge.x - this.battleUiLength(6) - headerTextX); + if (this.phase !== 'command') { - const backBg = this.trackSideObject(this.add.rectangle(left + width - 72, top + 38, 58, 22, 0x223142, 0.95)); + const backBg = this.trackSideObject(this.add.rectangle(left + width - this.battleUiLength(50), top + this.battleUiLength(32), this.battleUiLength(42), this.battleUiLength(15), 0x223142, 0.95)); backBg.setOrigin(0); - backBg.setStrokeStyle(1, palette.gold, 0.72); + backBg.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.72); backBg.setInteractive({ useHandCursor: true }); backBg.on('pointerdown', () => this.returnToRosterPanel(unit.faction)); - const backText = this.trackSideObject(this.add.text(left + width - 43, top + 49, '목록', { + const backText = this.trackSideObject(this.add.text(left + width - this.battleUiLength(29), top + this.battleUiLength(39.5), '목록', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(9), color: '#f4dfad', fontStyle: '700' })); backText.setOrigin(0.5); } else { - const actionText = this.trackSideObject(this.add.text(left + width - 49, top + 48, acted ? '행동완료' : `EXP ${unit.exp}/100`, { + const actionText = this.trackSideObject(this.add.text(left + width - this.battleUiLength(32), top + this.battleUiLength(39), acted ? '행동완료' : `EXP ${unit.exp}/100`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(8), color: acted ? '#bdbdbd' : '#c8d2dd', fontStyle: '700' })); actionText.setOrigin(0.5); } - const hpPanel = this.trackSideObject(this.add.rectangle(left, top + 72, width, 34, 0x101820, 0.92)); + const hpTop = top + this.battleUiLength(58); + const hpHeight = this.battleUiLength(26); + const hpPanel = this.trackSideObject(this.add.rectangle(left, hpTop, width, hpHeight, 0x101820, 0.92)); hpPanel.setOrigin(0); - hpPanel.setStrokeStyle(1, 0x40515e, 0.58); - this.trackSideIcon(left + 18, top + 89, 'hp', 29); - this.trackSideObject(this.add.text(left + 40, top + 79, '병력', { + hpPanel.setStrokeStyle(this.battleUiLength(1), 0x40515e, 0.58); + this.trackSideIcon(left + this.battleUiLength(15), hpTop + hpHeight / 2, 'hp', this.battleUiLength(20)); + this.trackSideObject(this.add.text(left + this.battleUiLength(30), hpTop + this.battleUiLength(5), '병력', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', + fontSize: this.battleUiFontSize(12), color: '#d4dce6', fontStyle: '700' })); - const hpValue = this.trackSideObject(this.add.text(left + width - 12, top + 79, `${unit.hp} / ${unit.maxHp}`, { + const hpValue = this.trackSideObject(this.add.text(left + width - this.battleUiLength(8), hpTop + this.battleUiLength(5), `${unit.hp} / ${unit.maxHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: this.battleUiFontSize(13), color: '#f0e4c8', fontStyle: '700' })); hpValue.setOrigin(1, 0); - this.drawGauge(left + 104, top + 93, width - 168, 10, unit.hp / unit.maxHp, 0x59d18c); + this.drawGauge(left + this.battleUiLength(78), hpTop + this.battleUiLength(18), width - this.battleUiLength(88), this.battleUiLength(4), unit.hp / unit.maxHp, 0x59d18c); const attackBonus = this.equipmentAttackBonus(unit); - const summaryGap = 6; + const summaryTop = hpTop + this.battleUiLength(30); + const summaryGap = this.battleUiLength(4); const summaryBoxWidth = (width - summaryGap * 2) / 3; - this.renderCompactValueBox(left, top + 114, summaryBoxWidth, 'attack', '공격', `${unit.attack + attackBonus}`); - this.renderCompactValueBox(left + summaryBoxWidth + summaryGap, top + 114, summaryBoxWidth, 'defense', '방어+', `${this.equipmentDefenseBonus(unit)}`); + const summaryBounds = [ + this.renderCompactValueBox(left, summaryTop, summaryBoxWidth, 'attack', '공격', `${unit.attack + attackBonus}`), + this.renderCompactValueBox(left + summaryBoxWidth + summaryGap, summaryTop, summaryBoxWidth, 'defense', '방어+', `${this.equipmentDefenseBonus(unit)}`) + ]; const intentCounts = this.enemyIntentTargetCounts(unit.id); const showIntentSummary = unit.faction === 'ally' && this.enemyIntentForecastEnabled(); - this.renderCompactValueBox( + summaryBounds.push(this.renderCompactValueBox( left + (summaryBoxWidth + summaryGap) * 2, - top + 114, + summaryTop, summaryBoxWidth, showIntentSummary ? (intentCounts.immediate > 0 ? 'counter' : intentCounts.approach > 0 ? 'move' : 'success') : this.unitMoveIcon(unit), showIntentSummary ? '적 예상' : '방향', showIntentSummary ? this.enemyIntentTargetCompactValue(unit.id) : `${this.directionSymbol(direction)} ${this.unitDirectionLabel(direction)}` - ); + )); const effects = this.unitEffectSummaries(unit); - const hasEffects = effects.length > 0; - const statTop = top + (hasEffects ? 154 : 164); - const statGap = hasEffects ? 21 : 24; - statLabels.forEach((stat, index) => { - this.renderStatRow(stat.key, stat.label, unit.stats[stat.key], left, statTop + index * statGap, width); - }); + const statTop = summaryTop + this.battleUiLength(38); + const statStep = this.battleUiLength(17); + const statBounds = statLabels.map((stat, index) => ( + this.renderStatRow(stat.key, stat.label, unit.stats[stat.key], left, statTop + index * statStep, width) + )); const moveAllowance = this.movementAllowance(unit); const moveText = moveAllowance > unit.move ? `${moveAllowance} (편성 +${moveAllowance - unit.move})` : `${unit.move}`; const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} · ${terrainRule.label} ${terrainRating}% · 이동 ${moveText}${acted ? ' · 행동완료' : ''}`; - const statusTop = top + (hasEffects ? 264 : 288); - const statusPanel = this.trackSideObject(this.add.rectangle(left, statusTop, width, 32, 0x101820, 0.9)); + const statusTop = statTop + statLabels.length * statStep + this.battleUiLength(4); + const statusHeight = this.battleUiLength(28); + const statusPanel = this.trackSideObject(this.add.rectangle(left, statusTop, width, statusHeight, 0x101820, 0.9)); statusPanel.setOrigin(0); - statusPanel.setStrokeStyle(1, 0x40515e, 0.5); - this.trackSideIcon(left + 17, statusTop + 16, acted ? 'counter' : this.unitMoveIcon(unit), 25); - this.trackSideObject(this.add.text(left + 36, statusTop + 6, positionText, { + statusPanel.setStrokeStyle(this.battleUiLength(1), 0x40515e, 0.5); + this.trackSideIcon(left + this.battleUiLength(15), statusTop + statusHeight / 2, acted ? 'counter' : this.unitMoveIcon(unit), this.battleUiLength(20)); + const positionX = left + this.battleUiLength(30); + const position = this.trackSideObject(this.add.text(positionX, statusTop + this.battleUiLength(8), positionText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', - color: acted ? '#bdbdbd' : '#9fb0bf', - wordWrap: { width: width - 48, useAdvancedWrap: true }, - lineSpacing: 2 + fontSize: this.battleUiFontSize(9), + color: acted ? '#bdbdbd' : '#9fb0bf' })); + this.fitTextToWidth(position, positionText, width - this.battleUiLength(38)); - const effectTop = top + (hasEffects ? 302 : 326); - const equipmentTop = hasEffects ? top + 360 : effectTop; - if (hasEffects) { - this.renderUnitEffectCards(effects, left, effectTop, width); - } - this.renderEquipmentSummary(unit, left, equipmentTop, width, hasEffects); + const effectTop = statusTop + this.battleUiLength(32); + const effectBounds = this.renderUnitEffectCards(effects, left, effectTop, width); + const equipmentTop = effectTop + this.battleUiLength(46); + const equipmentBounds = this.renderEquipmentSummary(unit, left, equipmentTop, width, true); + let messageBounds: HudBounds | null = null; if (message) { const resultLine = this.actionResultSummaryParts(this.firstMessageLine(message)); + const messageTop = equipmentTop + this.battleUiLength(106); + const messageHeight = this.battleUiLength(40); if (resultLine) { - this.renderPanelActionResultMessage(resultLine, left, equipmentTop + 150, width); + messageBounds = this.renderPanelActionResultMessage(resultLine, left, messageTop, width); } else { - this.renderPanelMessage(message, left, equipmentTop + 156, width, 42); + messageBounds = this.renderPanelMessage(message, left, messageTop, width, messageHeight, true); } } + const nameBounds = unitNameText.getBounds(); + this.unitDetailPanelLayout = { + unitId: unit.id, + headerBounds: { x: left, y: top, width, height: headerHeight }, + nameBounds: { x: nameBounds.x, y: nameBounds.y, width: nameBounds.width, height: nameBounds.height }, + levelBadgeBounds: { x: levelBadge.x, y: levelBadge.y, width: levelBadge.width, height: levelBadge.height }, + hpBounds: { x: left, y: hpTop, width, height: hpHeight }, + summaryBounds, + statBounds, + statusBounds: { x: left, y: statusTop, width, height: statusHeight }, + effectBounds, + equipmentBounds, + messageBounds + }; this.showFacingIndicator(unit); } @@ -22785,69 +23044,82 @@ export class BattleScene extends Phaser.Scene { private renderUnitEffectCards(effects: UnitEffectSummary[], x: number, y: number, width: number) { const visible = effects.slice(0, 3); - const gap = 6; - const cardHeight = 50; + const gap = this.battleUiLength(4); + const cardHeight = this.battleUiLength(40); const cardWidth = (width - gap * (visible.length - 1)) / Math.max(1, visible.length); - visible.forEach((effect, index) => { + const bounds = visible.map((effect, index) => { const cardX = x + index * (cardWidth + gap); const fill = effect.polarity === 'debuff' ? 0x1d1516 : effect.polarity === 'terrain' ? 0x101d18 : 0x101820; const bg = this.trackSideObject(this.add.rectangle(cardX, y, cardWidth, cardHeight, fill, 0.98)); bg.setOrigin(0); - bg.setStrokeStyle(1, effect.tone, effect.polarity === 'debuff' ? 0.78 : 0.62); - const iconFrame = this.trackSideObject(this.add.rectangle(cardX + 23, y + 25, 36, 36, 0x0a0f14, 0.94)); - iconFrame.setStrokeStyle(1, effect.tone, 0.56); - this.trackSideIcon(cardX + 23, y + 25, effect.icon, 34); - this.trackSideObject(this.add.text(cardX + 47, y + 6, effect.label, { + bg.setStrokeStyle(this.battleUiLength(1), effect.tone, effect.polarity === 'debuff' ? 0.78 : 0.62); + const iconX = cardX + this.battleUiLength(18); + const iconY = y + cardHeight / 2; + const iconFrame = this.trackSideObject(this.add.rectangle(iconX, iconY, this.battleUiLength(28), this.battleUiLength(28), 0x0a0f14, 0.94)); + iconFrame.setStrokeStyle(this.battleUiLength(1), effect.tone, 0.56); + this.trackSideIcon(iconX, iconY, effect.icon, this.battleUiLength(26)); + const textX = cardX + this.battleUiLength(38); + const labelText = this.trackSideObject(this.add.text(textX, y + this.battleUiLength(5), effect.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: this.battleUiFontSize(8), color: effect.polarity === 'debuff' ? '#ffb9a8' : '#9fb0bf', - fontStyle: '700', - fixedWidth: Math.max(36, cardWidth - 56) + fontStyle: '700' })); - this.trackSideObject(this.add.text(cardX + 47, y + 25, this.truncateUiText(effect.value, visible.length === 1 ? 30 : 15), { + const cardTextWidth = Math.max( + this.battleUiLength(18), + cardWidth - this.battleUiLength(effect.turns ? 62 : 44) + ); + this.fitTextToWidth(labelText, effect.label, cardTextWidth); + const valueText = this.trackSideObject(this.add.text(textX, y + this.battleUiLength(21), effect.value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(9), color: '#f2e3bf', - fontStyle: '700', - fixedWidth: Math.max(36, cardWidth - 56) + fontStyle: '700' })); + this.fitTextToWidth(valueText, effect.value, Math.max(this.battleUiLength(24), cardWidth - this.battleUiLength(44))); if (effect.turns) { - const badge = this.trackSideObject(this.add.rectangle(cardX + cardWidth - 20, y + 16, 30, 19, 0x211624, 0.96)); - badge.setStrokeStyle(1, effect.tone, 0.78); - const turnText = this.trackSideObject(this.add.text(cardX + cardWidth - 20, y + 16, `${effect.turns}T`, { + const badgeX = cardX + cardWidth - this.battleUiLength(14); + const badgeY = y + this.battleUiLength(12); + const badge = this.trackSideObject(this.add.rectangle(badgeX, badgeY, this.battleUiLength(20), this.battleUiLength(13), 0x211624, 0.96)); + badge.setStrokeStyle(this.battleUiLength(1), effect.tone, 0.78); + const turnText = this.trackSideObject(this.add.text(badgeX, badgeY, `${effect.turns}T`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '10px', + fontSize: this.battleUiFontSize(7), color: '#fff2b8', fontStyle: '700' })); turnText.setOrigin(0.5); } + return { x: cardX, y, width: cardWidth, height: cardHeight }; }); if (effects.length > visible.length) { - const extraText = this.trackSideObject(this.add.text(x + width - 8, y + 4, `+${effects.length - visible.length}`, { + const extraText = this.trackSideObject(this.add.text(x + width - this.battleUiLength(6), y + this.battleUiLength(3), `+${effects.length - visible.length}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: this.battleUiFontSize(8), color: '#f4dfad', fontStyle: '700' })); extraText.setOrigin(1, 0); } + return bounds; } private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number, compact = false) { - this.trackSideIcon(x + 15, y + 12, 'mastery', compact ? 25 : 30); - this.trackSideObject(this.add.text(x + 32, y, '장비 / 숙련도', { + const titleIconX = x + (compact ? this.battleUiLength(10) : 15); + const titleIconY = y + (compact ? this.battleUiLength(9) : 12); + this.trackSideIcon(titleIconX, titleIconY, 'mastery', compact ? this.battleUiLength(18) : 30); + this.trackSideObject(this.add.text(x + (compact ? this.battleUiLength(22) : 32), y, '장비 / 숙련도', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '14px' : '17px', + fontSize: compact ? this.battleUiFontSize(10) : '17px', color: '#f2e3bf', fontStyle: '700' })); - equipmentSlots.forEach((slot, index) => { - const rowTop = y + (compact ? 20 : 28) + index * (compact ? 30 : 40); - this.renderEquipmentRow(unit, slot, x, rowTop, width, compact); + return equipmentSlots.map((slot, index) => { + const rowTop = y + (compact ? this.battleUiLength(18) : 28) + index * (compact ? this.battleUiLength(28) : 40); + return this.renderEquipmentRow(unit, slot, x, rowTop, width, compact); }); } @@ -22856,56 +23128,69 @@ export class BattleScene extends Phaser.Scene { const item = getItem(state.itemId); const next = equipmentExpToNext(state.level); const isTreasure = item.rank === 'treasure'; - const rowHeight = compact ? 28 : 38; + const rowHeight = compact ? this.battleUiLength(26) : 38; const bg = this.trackSideObject(this.add.rectangle(x, y, width, rowHeight, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.98 : 0.95)); bg.setOrigin(0); - bg.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.66 : 0.5); + bg.setStrokeStyle(compact ? this.battleUiLength(1) : 1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.66 : 0.5); - const iconSize = compact ? 26 : 36; - const iconFrame = this.trackSideObject(this.add.rectangle(x + (compact ? 19 : 21), y + rowHeight / 2, iconSize, iconSize, 0x0a0f14, 0.92)); - iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.78 : 0.54); + const iconX = x + (compact ? this.battleUiLength(15) : 21); + const iconSize = compact ? this.battleUiLength(22) : 36; + const iconFrame = this.trackSideObject(this.add.rectangle(iconX, y + rowHeight / 2, iconSize, iconSize, 0x0a0f14, 0.92)); + iconFrame.setStrokeStyle(compact ? this.battleUiLength(1) : 1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.78 : 0.54); - this.trackSideIcon(x + (compact ? 19 : 21), y + rowHeight / 2, this.itemIcon(item, slot), compact ? 24 : 34); + this.trackSideIcon(iconX, y + rowHeight / 2, this.itemIcon(item, slot), compact ? this.battleUiLength(20) : 34); - this.trackSideObject(this.add.text(x + 40, y + (compact ? 1 : 3), equipmentSlotLabels[slot], { + const textX = x + (compact ? this.battleUiLength(30) : 40); + this.trackSideObject(this.add.text(textX, y + (compact ? this.battleUiLength(1) : 3), equipmentSlotLabels[slot], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '10px' : '11px', + fontSize: compact ? this.battleUiFontSize(7) : '11px', color: '#9fb0bf', fontStyle: '700' })); const bonusText = this.itemBonusText(item); - this.trackSideObject(this.add.text(x + 78, y + (compact ? 1 : 3), bonusText, { + const bonusX = x + (compact ? this.battleUiLength(58) : 78); + const bonus = this.trackSideObject(this.add.text(bonusX, y + (compact ? this.battleUiLength(1) : 3), bonusText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '10px' : '11px', - color: '#aeb7c2', - fixedWidth: width - 170 - })); - this.trackSideObject(this.add.text(x + 40, y + (compact ? 12 : 17), item.name, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '12px' : '15px', - color: isTreasure ? '#f4dfad' : '#d4dce6', - fontStyle: '700', - fixedWidth: width - 150 + fontSize: compact ? this.battleUiFontSize(7) : '11px', + color: '#aeb7c2' })); - const levelText = this.trackSideObject(this.add.text(x + width - 10, y + (compact ? 2 : 5), `Lv ${state.level}`, { + const levelText = this.trackSideObject(this.add.text(x + width - (compact ? this.battleUiLength(7) : 10), y + (compact ? this.battleUiLength(2) : 5), `Lv ${state.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '12px' : '15px', + fontSize: compact ? this.battleUiFontSize(9) : '15px', color: isTreasure ? '#f2e3bf' : '#d4dce6', fontStyle: '700' })); levelText.setOrigin(1, 0); + const levelBounds = levelText.getBounds(); + this.fitTextToWidth(bonus, bonusText, levelBounds.x - this.battleUiLength(5) - bonusX); - this.trackSideIcon(x + width - 76, y + (compact ? 20 : 27), 'mastery', compact ? 16 : 20); - const expText = this.trackSideObject(this.add.text(x + width - 10, y + (compact ? 17 : 24), `${state.exp}/${next}`, { + const itemName = this.trackSideObject(this.add.text(textX, y + (compact ? this.battleUiLength(11) : 17), item.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: compact ? '10px' : '11px', + fontSize: compact ? this.battleUiFontSize(9) : '15px', + color: isTreasure ? '#f4dfad' : '#d4dce6', + fontStyle: '700' + })); + this.fitTextToWidth(itemName, item.name, width - (textX - x) - (compact ? this.battleUiLength(74) : 150)); + + this.trackSideIcon(x + width - (compact ? this.battleUiLength(50) : 76), y + (compact ? this.battleUiLength(20) : 27), 'mastery', compact ? this.battleUiLength(12) : 20); + const expText = this.trackSideObject(this.add.text(x + width - (compact ? this.battleUiLength(7) : 10), y + (compact ? this.battleUiLength(16) : 24), `${state.exp}/${next}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: compact ? this.battleUiFontSize(7) : '11px', color: '#c8d2dd', fontStyle: '700' })); expText.setOrigin(1, 0); - this.drawGauge(x + 40, y + (compact ? 24 : 32), width - 124, compact ? 4 : 5, state.exp / next, isTreasure ? 0xd8b15f : 0x58aee0); + this.drawGauge( + textX, + y + (compact ? this.battleUiLength(22) : 32), + width - (textX - x) - (compact ? this.battleUiLength(52) : 84), + compact ? this.battleUiLength(3) : 5, + state.exp / next, + isTreasure ? 0xd8b15f : 0x58aee0 + ); + return { x, y, width, height: rowHeight }; } private createBattleUiIcon(x: number, y: number, icon: BattleUiIconKey, size = 22) { @@ -23030,47 +23315,55 @@ export class BattleScene extends Phaser.Scene { } private renderCompactValueBox(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string) { - const bg = this.trackSideObject(this.add.rectangle(x, y, width, 42, 0x16212d, 0.97)); + const height = this.battleUiLength(34); + const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x16212d, 0.97)); bg.setOrigin(0); - bg.setStrokeStyle(1, 0x647485, 0.72); - const iconFrame = this.trackSideObject(this.add.rectangle(x + 17, y + 21, 30, 30, 0x0a0f14, 0.86)); - iconFrame.setStrokeStyle(1, 0x53606c, 0.52); - this.trackSideIcon(x + 17, y + 21, icon, 28); - this.trackSideObject(this.add.text(x + 36, y + 5, label, { + bg.setStrokeStyle(this.battleUiLength(1), 0x647485, 0.72); + const iconX = x + this.battleUiLength(14); + const iconY = y + height / 2; + const iconFrame = this.trackSideObject(this.add.rectangle(iconX, iconY, this.battleUiLength(24), this.battleUiLength(24), 0x0a0f14, 0.86)); + iconFrame.setStrokeStyle(this.battleUiLength(1), 0x53606c, 0.52); + this.trackSideIcon(iconX, iconY, icon, this.battleUiLength(22)); + const textX = x + this.battleUiLength(28); + const labelText = this.trackSideObject(this.add.text(textX, y + this.battleUiLength(4), label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', - color: '#9fb0bf', - fixedWidth: Math.max(36, width - 42) + fontSize: this.battleUiFontSize(8), + color: '#9fb0bf' })); - const valueText = this.trackSideObject(this.add.text(x + width - 7, y + 29, value, { + this.fitTextToWidth(labelText, label, Math.max(this.battleUiLength(18), width - this.battleUiLength(34))); + const valueText = this.trackSideObject(this.add.text(x + width - this.battleUiLength(5), y + this.battleUiLength(22), value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: this.battleUiFontSize(11), color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0.5); + this.fitTextToWidth(valueText, value, Math.max(this.battleUiLength(18), width - this.battleUiLength(34))); + return { x, y, width, height }; } private renderStatRow(key: keyof UnitStats, label: string, value: number, x: number, y: number, width: number) { - const rowBg = this.trackSideObject(this.add.rectangle(x, y - 1, width, 22, 0x101820, 0.5)); + const height = this.battleUiLength(15); + const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x101820, 0.5)); rowBg.setOrigin(0); - this.trackSideIcon(x + 14, y + 10, this.statIcon(key), 23); - this.trackSideObject(this.add.text(x + 34, y + 1, label, { + this.trackSideIcon(x + this.battleUiLength(11), y + height / 2, this.statIcon(key), this.battleUiLength(16)); + this.trackSideObject(this.add.text(x + this.battleUiLength(24), y + this.battleUiLength(2), label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: this.battleUiFontSize(10), color: '#d4dce6', fontStyle: '700' })); - this.drawGauge(x + 104, y + 7, width - 158, 8, value / 100, value >= 80 ? 0xd8b15f : 0x58aee0); + this.drawGauge(x + this.battleUiLength(76), y + this.battleUiLength(6), width - this.battleUiLength(108), this.battleUiLength(4), value / 100, value >= 80 ? 0xd8b15f : 0x58aee0); - const valueText = this.trackSideObject(this.add.text(x + width - 8, y - 1, `${value}`, { + const valueText = this.trackSideObject(this.add.text(x + width - this.battleUiLength(6), y + this.battleUiLength(1), `${value}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: this.battleUiFontSize(11), color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0); + return { x, y, width, height }; } private drawGauge(x: number, y: number, width: number, height: number, ratio: number, color: number) { @@ -23089,52 +23382,58 @@ export class BattleScene extends Phaser.Scene { y: number, width: number ) { - const height = 54; - const safeY = Math.max(this.sideContentTop(), Math.min(y, this.sideContentBottom() - height)); + const height = this.battleUiLength(40); + const safeY = Math.max(this.sideContentTop(), Math.min(y, this.sideContentBottom(6) - height)); const visual = this.battleLogVisual(`0T ${result.subject} | ${result.action} | ${result.outcome}`); const bg = this.trackSideObject(this.add.rectangle(x, safeY, width, height, 0x101820, 0.97)); bg.setOrigin(0); - bg.setStrokeStyle(1, visual.tone, 0.72); + bg.setStrokeStyle(this.battleUiLength(1), visual.tone, 0.72); - const iconFrame = this.trackSideObject(this.add.rectangle(x + 24, safeY + 27, 34, 34, 0x0a0f14, 0.94)); - iconFrame.setStrokeStyle(1, visual.tone, 0.64); - const icon = this.trackSideIcon(x + 24, safeY + 27, visual.icon, 31); + const iconX = x + this.battleUiLength(18); + const iconY = safeY + height / 2; + const iconFrame = this.trackSideObject(this.add.rectangle(iconX, iconY, this.battleUiLength(24), this.battleUiLength(24), 0x0a0f14, 0.94)); + iconFrame.setStrokeStyle(this.battleUiLength(1), visual.tone, 0.64); + const icon = this.trackSideIcon(iconX, iconY, visual.icon, this.battleUiLength(22)); icon.setDepth(33); - const title = this.trackSideObject(this.add.text(x + 48, safeY + 7, this.truncateBattleLogText(`${result.subject} · ${result.action}`, 31), { + const textX = x + this.battleUiLength(34); + const titleLabel = `${result.subject} · ${result.action}`; + const title = this.trackSideObject(this.add.text(textX, safeY + this.battleUiLength(5), titleLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(9), color: '#9fb0bf', - fontStyle: '700', - fixedWidth: width - 62 + fontStyle: '700' })); + this.fitTextToWidth(title, titleLabel, width - this.battleUiLength(42)); title.setDepth(33); - const outcome = this.trackSideObject(this.add.text(x + 48, safeY + 27, this.truncateBattleLogText(result.outcome, 34), { + const outcome = this.trackSideObject(this.add.text(textX, safeY + this.battleUiLength(21), result.outcome, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: this.battleUiFontSize(11), color: '#f2e3bf', - fontStyle: '700', - fixedWidth: width - 62 + fontStyle: '700' })); + this.fitTextToWidth(outcome, result.outcome, width - this.battleUiLength(42)); outcome.setDepth(33); + return { x, y: safeY, width, height }; } 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 compactThreshold = fhd ? this.battleUiLength(40) : 60; + const contentBottom = fhd ? this.sideContentBottom(6) : this.sideContentBottom(); + const safeY = Math.max(this.sideContentTop(), Math.min(y, contentBottom - height)); + const compactThreshold = fhd ? this.battleUiLength(44) : 60; const compact = height < compactThreshold; - const verticalPadding = fhd ? this.battleUiLength(10) : 16; - const lineHeight = fhd ? this.battleUiLength(compact ? 13 : 16) : compact ? 17 : 22; + const verticalPadding = fhd ? this.battleUiLength(compact ? 6 : 10) : 16; + const lineHeight = fhd ? this.battleUiLength(compact ? 11 : 16) : compact ? 17 : 22; const horizontalPadding = fhd ? this.battleUiLength(8) : 12; - const topPadding = fhd ? this.battleUiLength(6) : 10; + const topPadding = fhd ? this.battleUiLength(compact ? 4 : 6) : 10; const bg = this.trackSideObject(this.add.rectangle(x, safeY, width, height, 0x101820, 0.94)); bg.setOrigin(0); 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: fhd ? this.battleUiFontSize(compact ? 10 : 12) : compact ? '13px' : '15px', + fontSize: fhd ? this.battleUiFontSize(compact ? 9 : 12) : compact ? '13px' : '15px', color: '#d4dce6', wordWrap: { width: width - horizontalPadding * 2, useAdvancedWrap: true }, lineSpacing: fhd ? this.battleUiLength(compact ? 1 : 2) : compact ? 2 : 4, @@ -23142,6 +23441,7 @@ export class BattleScene extends Phaser.Scene { })); bg.setDepth(32); messageText.setDepth(33); + return { x, y: safeY, width, height }; } private returnToRosterPanel(tab: RosterTab) { @@ -23161,6 +23461,10 @@ export class BattleScene extends Phaser.Scene { this.recentActionLogLayout = undefined; this.rosterPanelLayout = undefined; this.deploymentPanelLayout = undefined; + this.unitDetailPanelLayout = undefined; + this.bondPanelLayout = undefined; + this.threatPanelLayout = undefined; + this.terrainPanelLayout = undefined; this.bondChainReadyFocusedPreview = undefined; this.clearFacingIndicator(); } @@ -23488,11 +23792,79 @@ export class BattleScene extends Phaser.Scene { startButtonBounds: { ...this.deploymentPanelLayout.startButtonBounds } } : null; + const unitDetailPanel = this.unitDetailPanelLayout + ? { + ...this.unitDetailPanelLayout, + headerBounds: { ...this.unitDetailPanelLayout.headerBounds }, + nameBounds: { ...this.unitDetailPanelLayout.nameBounds }, + levelBadgeBounds: { ...this.unitDetailPanelLayout.levelBadgeBounds }, + hpBounds: { ...this.unitDetailPanelLayout.hpBounds }, + summaryBounds: this.unitDetailPanelLayout.summaryBounds.map((bounds) => ({ ...bounds })), + statBounds: this.unitDetailPanelLayout.statBounds.map((bounds) => ({ ...bounds })), + statusBounds: { ...this.unitDetailPanelLayout.statusBounds }, + effectBounds: this.unitDetailPanelLayout.effectBounds.map((bounds) => ({ ...bounds })), + equipmentBounds: this.unitDetailPanelLayout.equipmentBounds.map((bounds) => ({ ...bounds })), + messageBounds: this.unitDetailPanelLayout.messageBounds ? { ...this.unitDetailPanelLayout.messageBounds } : null + } + : null; + const bondPanel = this.bondPanelLayout + ? { + ...this.bondPanelLayout, + visibleBondIds: [...this.bondPanelLayout.visibleBondIds], + headerBounds: { ...this.bondPanelLayout.headerBounds }, + rowLayouts: this.bondPanelLayout.rowLayouts.map((row) => ({ + ...row, + rowBounds: { ...row.rowBounds }, + nameBounds: { ...row.nameBounds }, + levelBounds: { ...row.levelBounds }, + titleBounds: { ...row.titleBounds }, + gaugeBounds: { ...row.gaugeBounds } + })), + navigationBounds: this.bondPanelLayout.navigationBounds ? { ...this.bondPanelLayout.navigationBounds } : null, + messageBounds: { ...this.bondPanelLayout.messageBounds } + } + : null; + const threatPanel = this.threatPanelLayout + ? { + ...this.threatPanelLayout, + tile: { ...this.threatPanelLayout.tile }, + headerBounds: { ...this.threatPanelLayout.headerBounds }, + summaryRows: this.threatPanelLayout.summaryRows.map((row) => ({ + bounds: { ...row.bounds }, + labelBounds: { ...row.labelBounds }, + valueBounds: { ...row.valueBounds } + })), + enemyRows: this.threatPanelLayout.enemyRows.map((row) => ({ + ...row, + rowBounds: { ...row.rowBounds }, + nameBounds: { ...row.nameBounds }, + valueBounds: { ...row.valueBounds } + })), + messageBounds: { ...this.threatPanelLayout.messageBounds } + } + : null; + const terrainPanel = this.terrainPanelLayout + ? { + ...this.terrainPanelLayout, + tile: { ...this.terrainPanelLayout.tile }, + headerBounds: { ...this.terrainPanelLayout.headerBounds }, + summaryRows: this.terrainPanelLayout.summaryRows.map((row) => ({ + bounds: { ...row.bounds }, + labelBounds: { ...row.labelBounds }, + valueBounds: { ...row.valueBounds } + })), + messageBounds: { ...this.terrainPanelLayout.messageBounds } + } + : null; return { header, sidebar, deploymentPanel, + unitDetailPanel, + bondPanel, + threatPanel, + terrainPanel, miniMap: miniMap ? { visible: this.miniMapVisible,