diff --git a/docs/visual-asset-quality-audit.md b/docs/visual-asset-quality-audit.md index c49b3be..eea98fe 100644 --- a/docs/visual-asset-quality-audit.md +++ b/docs/visual-asset-quality-audit.md @@ -4,7 +4,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. ## Summary -- Target desktop viewport: 1280x720 +- Target desktop viewport: 1920x1080 - Battle map assets: 66 - Flagged battle map review rows: 43 - Flagged battle map rows with repaint reports: 3 @@ -27,7 +27,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. - Battle reason: low tile pixel density - Story background candidate: `src/assets/images/story/25-yizhou-luo-road.png` - Story reason: high-repeat story background -- Suggested workflow: capture the top battle candidate in a 1280x720 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue. +- Suggested workflow: capture the top battle candidate in a 1920x1080 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue. ## Top Battle Map Review Queue diff --git a/scripts/audit-visual-asset-quality.mjs b/scripts/audit-visual-asset-quality.mjs index 467696c..0578ac2 100644 --- a/scripts/audit-visual-asset-quality.mjs +++ b/scripts/audit-visual-asset-quality.mjs @@ -6,7 +6,7 @@ const battleMapDir = join('src', 'assets', 'images', 'battle'); const storyDir = join('src', 'assets', 'images', 'story'); const titleImagePath = join('src', 'assets', 'images', 'taoyuan-oath-title.png'); const reportPath = join('docs', 'visual-asset-quality-audit.md'); -const targetViewport = { width: 1280, height: 720 }; +const targetViewport = { width: 1920, height: 1080 }; const expectedStorySize = { width: 1672, height: 941 }; const server = await createServer({ @@ -270,7 +270,7 @@ function renderReport({ battleRows, storyRows, storyUsage }) { `- Battle reason: ${nextBattle ? nextBattle.concerns.join(', ') : 'no battle map review rows are currently flagged'}`, `- Story background candidate: ${nextStory ? `\`${shortPath(nextStory.path)}\`` : 'none flagged'}`, `- Story reason: ${nextStory ? nextStory.concerns.join(', ') : 'no story background review rows are currently flagged'}`, - '- Suggested workflow: capture the top battle candidate in a 1280x720 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue.', + '- Suggested workflow: capture the top battle candidate in a 1920x1080 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue.', '', '## Top Battle Map Review Queue', '', diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index b6e90a5..8fadb24 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -5,6 +5,8 @@ import { chromium } from 'playwright'; const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/'); const outputPath = process.env.MEASURE_OUT ?? 'dist/performance-report.json'; +const baselineViewport = { width: 1920, height: 1080 }; +const legacyUiScale = 1.5; const budgets = { titleReadyMs: readBudget('PERF_BUDGET_TITLE_READY_MS', 30000), storyReadyFromNewGameMs: readBudget('PERF_BUDGET_STORY_READY_MS', 30000), @@ -16,13 +18,39 @@ const budgets = { largestJsKB: readBudget('PERF_BUDGET_LARGEST_JS_KB', 2500) }; +async function clickLegacyUi(page, x, y, options) { + await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options); +} + +async function clickBattleDeploymentStart(page) { + const point = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const layout = scene?.layout; + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !layout || !canvas || !bounds) { + return null; + } + const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2; + const logicalY = layout.panelY + layout.panelHeight - 76 + 17; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected a runtime deployment start point: ${JSON.stringify(point)}`); + } + await page.mouse.click(point.x, point.y); +} + let serverProcess; let browser; try { serverProcess = await ensurePreviewServer(targetUrl); browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }); + const context = await browser.newContext({ viewport: baselineViewport }); await context.addInitScript(() => window.localStorage.clear()); const page = await context.newPage(); @@ -34,7 +62,7 @@ try { const titleEntries = await resourceEntries(page); const storyStartedAt = Date.now(); - await page.mouse.click(962, 240); + await clickLegacyUi(page, 962, 240); await waitForStoryReady(page); await page.waitForLoadState('networkidle', { timeout: 120000 }); const storyMs = Date.now() - storyStartedAt; @@ -50,7 +78,7 @@ try { let firstBattlePlayableMs = battleMs; if (battleReadyState.phase === 'deployment') { const playableStartedAt = Date.now(); - await page.mouse.click(1085, 637); + await clickBattleDeploymentStart(page); await waitForBattlePlayable(page, 'first-battle-zhuo-commandery'); firstBattlePlayableMs += Date.now() - playableStartedAt; } diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index f984f30..5059d3a 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -6,7 +6,7 @@ import { chromium } from 'playwright'; const cliOptions = parseCliOptions(process.argv.slice(2)); const defaultQaPort = process.env.QA_PORT ?? '41737'; -const targetUrl = process.env.QA_URL ?? `http://127.0.0.1:${defaultQaPort}/`; +const targetUrl = withCanvasRenderer(process.env.QA_URL ?? `http://127.0.0.1:${defaultQaPort}/`); const headless = process.env.QA_HEADLESS !== '0'; const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80); const cumulativeMode = process.env.QA_CUMULATIVE === '1'; @@ -15,12 +15,41 @@ const campaignStorageKey = 'heros-web:campaign-state'; const campaignSlotStorageKey = 'heros-web:campaign-state:slot-1'; const campaignSnapshotPath = process.env.QA_CAMPAIGN_SNAPSHOT_PATH; const resumeCampaignSnapshot = process.env.QA_RESUME_CAMPAIGN_SNAPSHOT === '1'; +const baselineViewport = { width: 1920, height: 1080 }; + +function withCanvasRenderer(url) { + const parsed = new URL(url); + parsed.searchParams.set('renderer', 'canvas'); + return parsed.toString(); +} const supplyLabels = { bean: '\uCF69', salve: '\uC0C1\uCC98\uC57D', wine: '\uD0C1\uC8FC' }; +async function clickBattleDeploymentStart(page) { + const point = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const layout = scene?.layout; + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !layout || !canvas || !bounds) { + return null; + } + const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2; + const logicalY = layout.panelY + layout.panelHeight - 76 + 17; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected a runtime deployment start point: ${JSON.stringify(point)}`); + } + await page.mouse.click(point.x, point.y); +} + const coreBrothers = ['liu-bei', 'guan-yu', 'zhang-fei']; const xuzhouFullSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']; const caoRefugeSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']; @@ -1148,7 +1177,7 @@ try { const browser = await chromium.launch({ headless }); try { - const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const page = await browser.newPage({ viewport: baselineViewport }); const results = []; for (const battle of representativeBattles) { @@ -1511,7 +1540,7 @@ async function startDeploymentIfNeeded(page, battleId) { return; } - await page.mouse.click(1085, 637); + await clickBattleDeploymentStart(page); await page.waitForFunction( (expectedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 1343985..d9caf30 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -2,8 +2,36 @@ import { readFileSync } from 'node:fs'; import { chromium } from 'playwright'; -const targetUrl = process.env.VERIFY_URL ?? 'http://localhost:5173/'; +const targetUrl = withCanvasRenderer(process.env.VERIFY_URL ?? 'http://localhost:5173/'); const rewardDataFiles = ['src/game/data/battles.ts', 'src/game/scenes/CampScene.ts']; +const baselineViewport = { width: 1920, height: 1080 }; +const legacyUiScale = 1.5; +const legacyUiClickPoints = new Set([ + '962,240', + '962,306', + '962,310', + '962,314', + '738,642', + '1120,38', + '966,38', + '724,38', + '1068,646', + '974,436', + '1160,38', + '576,38', + '180,315', + '798,38', + '650,38', + '974,482', + '1120,245', + '702,386' +]); + +function withCanvasRenderer(url) { + const parsed = new URL(url); + parsed.searchParams.set('renderer', 'canvas'); + return parsed.toString(); +} function assertNoLegacyNumericRewardLabels() { const legacyNumericRewardPattern = /itemRewards:\s*\[[^\]\r\n]*(["'])(쌀|금|군주)\s+\d+\1/u; @@ -30,6 +58,164 @@ function unitUsesTexture(state, unitId, textureBase) { return state?.units?.some((unit) => unit.id === unitId && unit.textureBase === textureBase && unit.actionTexture === `${textureBase}-actions`); } +async function clickLegacyUi(page, x, y, options) { + await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options); +} + +async function moveLegacyUi(page, x, y, options) { + await page.mouse.move(x * legacyUiScale, y * legacyUiScale, options); +} + +async function clickBattleDeploymentStart(page) { + const point = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const layout = scene?.layout; + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !layout || !canvas || !bounds) { + return null; + } + const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2; + const logicalY = layout.panelY + layout.panelHeight - 76 + 17; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected a runtime deployment start point: ${JSON.stringify(point)}`); + } + await page.mouse.click(point.x, point.y); +} + +async function openBattleMapMenu(page) { + const point = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const layout = scene?.layout; + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !layout || !canvas || !bounds) { + return null; + } + + const visibleColumns = Math.floor(layout.gridWidth / layout.tileSize); + const visibleRows = Math.floor(layout.gridHeight / layout.tileSize); + for (let row = 0; row < visibleRows; row += 1) { + for (let column = 0; column < visibleColumns; column += 1) { + const tileX = scene.cameraTileX + column; + const tileY = scene.cameraTileY + row; + if (!scene.isInBounds(tileX, tileY) || scene.isOccupied(tileX, tileY)) { + continue; + } + + const logicalX = layout.gridX + (column + 0.5) * layout.tileSize; + const logicalY = layout.gridY + (row + 0.5) * layout.tileSize; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + } + } + return null; + }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected a visible empty battle tile for the map menu: ${JSON.stringify(point)}`); + } + + await page.mouse.click(point.x, point.y, { button: 'right' }); + await page.waitForFunction(() => Boolean(window.__HEROS_GAME__?.scene.getScene('BattleScene')?.mapMenuLayout)); +} + +async function clickBattleMapMenuAction(page, action) { + const point = await page.evaluate((requestedAction) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const layout = scene?.mapMenuLayout; + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + const actionIndex = layout?.actions.indexOf(requestedAction) ?? -1; + if (!scene || !layout || !canvas || !bounds || actionIndex < 0) { + return null; + } + + const logicalX = layout.left + layout.width / 2; + const logicalY = layout.top + layout.padding + (actionIndex + 0.5) * layout.buttonHeight; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + }, action); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected a runtime battle map-menu point for ${action}: ${JSON.stringify(point)}`); + } + + await page.mouse.click(point.x, point.y); +} + +async function clickBattleSaveSlot(page, slot) { + const point = await page.evaluate((requestedSlot) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !canvas || !bounds || !scene.saveSlotPanelMode) { + return null; + } + + const rows = scene.saveSlotPanelObjects + .filter((object) => object.type === 'Rectangle' && object.input && object.displayWidth > 200 && object.displayHeight < 100) + .sort((left, right) => left.y - right.y); + const row = rows[requestedSlot - 1]; + const rowBounds = row?.getBounds(); + if (!rowBounds) { + return null; + } + + const logicalX = rowBounds.centerX; + const logicalY = rowBounds.centerY; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + }, slot); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected a runtime battle save-slot row for slot ${slot}: ${JSON.stringify(point)}`); + } + + await page.mouse.click(point.x, point.y); +} + +async function moveBattlePointerToRightEdge(page) { + const point = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const layout = scene?.layout; + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !layout || !canvas || !bounds) { + return null; + } + + const logicalX = layout.gridX + layout.gridWidth - 1; + const logicalY = layout.gridY + layout.gridHeight / 2; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected the runtime battle-grid right edge: ${JSON.stringify(point)}`); + } + + await page.mouse.move(point.x, point.y); +} + +function installLegacyUiClickScaling(page) { + const click = page.mouse.click.bind(page.mouse); + page.mouse.click = (x, y, options) => { + // Only authored 1280x720 controls are scaled; runtime map/minimap coordinates pass through unchanged. + const scale = legacyUiClickPoints.has(`${x},${y}`) ? legacyUiScale : 1; + return click(x * scale, y * scale, options); + }; +} + let serverProcess; let browser; @@ -39,7 +225,8 @@ try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const page = await browser.newPage({ viewport: baselineViewport }); + installLegacyUiClickScaling(page); await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => window.localStorage.clear()); @@ -110,7 +297,7 @@ try { }; }); - if (result.canvasWidth !== 1280 || result.canvasHeight !== 720) { + if (result.canvasWidth !== 1920 || result.canvasHeight !== 1080) { throw new Error(`Unexpected canvas size: ${result.canvasWidth}x${result.canvasHeight}`); } @@ -226,9 +413,8 @@ try { }); await page.waitForTimeout(700); - await page.mouse.click(260, 300, { button: 'right' }); - await page.waitForTimeout(120); - await page.mouse.click(182, 196); + await openBattleMapMenu(page); + await clickBattleMapMenuAction(page, 'threat'); await page.waitForTimeout(160); const threatState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); @@ -339,11 +525,10 @@ try { scene.hideBattleEventBanner(); }); - await page.mouse.click(260, 300, { button: 'right' }); - await page.waitForTimeout(120); - await page.mouse.click(182, 212); - await page.waitForTimeout(120); - await page.mouse.click(400, 213); + await openBattleMapMenu(page); + await clickBattleMapMenuAction(page, 'save'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'save'); + await clickBattleSaveSlot(page, 1); await page.waitForFunction(() => ( window.localStorage.getItem('heros-web:battle:first-battle-zhuo-commandery:slot-1') !== null || window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'save-overwrite-confirm' @@ -362,11 +547,10 @@ try { throw new Error(`Expected slot 1 battle save from save UI: ${JSON.stringify(battleSlotSave)}`); } - await page.mouse.click(260, 300, { button: 'right' }); - await page.waitForTimeout(120); - await page.mouse.click(182, 246); - await page.waitForTimeout(120); - await page.mouse.click(400, 213); + await openBattleMapMenu(page); + await clickBattleMapMenuAction(page, 'load'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'load'); + await clickBattleSaveSlot(page, 1); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'load-confirm'); await page.keyboard.press('Enter'); await page.waitForFunction(() => { @@ -383,7 +567,7 @@ try { throw new Error(`Expected a scrollable battle map: ${JSON.stringify(cameraBeforeScroll)}`); } - await page.mouse.move(872, 360); + await moveBattlePointerToRightEdge(page); await page.waitForTimeout(520); const cameraAfterEdgeScroll = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if (!cameraAfterEdgeScroll || cameraAfterEdgeScroll.x <= cameraBeforeScroll.x) { @@ -9645,7 +9829,7 @@ async function startDeploymentIfNeeded(page, battleId) { throw new Error(`Expected ${battleId} deployment to contain allied officers: ${JSON.stringify(state)}`); } - await page.mouse.click(1085, 637); + await clickBattleDeploymentStart(page); await page.waitForFunction((expectedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening'); @@ -10026,7 +10210,7 @@ async function clickSortieRosterUnit(page, unitId) { while (index < scroll || index >= scroll + maxVisibleRows) { const direction = index < scroll ? -1 : 1; const pointerRow = Math.max(0, Math.min(maxVisibleRows - 1, index - scroll)); - await page.mouse.move(rosterX, rosterY + pointerRow * rowGap); + await moveLegacyUi(page, rosterX, rosterY + pointerRow * rowGap); await page.mouse.wheel(0, direction * 360); await page.waitForTimeout(80); state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -10037,7 +10221,7 @@ async function clickSortieRosterUnit(page, unitId) { scroll = nextScroll; } - await page.mouse.click(rosterX, rosterY + (index - scroll) * rowGap); + await clickLegacyUi(page, rosterX, rosterY + (index - scroll) * rowGap); await page.waitForTimeout(120); } @@ -10057,6 +10241,6 @@ async function clickReserveTrainingFocus(page, focusId) { const selectorX = staged ? 448 : 574; const selectorWidth = staged ? 438 : 314; const buttonWidth = Math.floor((selectorWidth - 40) / state.reserveTrainingFocusOptions.length); - await page.mouse.click(selectorX + 12 + index * (buttonWidth + 8) + buttonWidth / 2, staged ? 548 : 655); + await clickLegacyUi(page, selectorX + 12 + index * (buttonWidth + 8) + buttonWidth / 2, staged ? 548 : 655); await page.waitForTimeout(120); } diff --git a/scripts/verify-public-deploy.mjs b/scripts/verify-public-deploy.mjs index ba46672..a5fc1b1 100644 --- a/scripts/verify-public-deploy.mjs +++ b/scripts/verify-public-deploy.mjs @@ -15,7 +15,7 @@ mkdirSync(dirname(screenshotPath), { recursive: true }); const browser = await chromium.launch({ headless: true }); try { - const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); const consoleMessages = []; const pageErrors = []; const requestFailures = []; @@ -37,7 +37,7 @@ try { await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); await page.waitForFunction(() => { const canvas = document.querySelector('canvas'); - return Boolean(canvas && canvas.width >= 960 && canvas.height >= 540); + return Boolean(canvas && canvas.width === 1920 && canvas.height === 1080); }, undefined, { timeout: 90000 }); await page.waitForTimeout(3000); await page.screenshot({ path: screenshotPath, fullPage: true }); @@ -96,7 +96,7 @@ try { assert(state.canvasCount === 1, `Expected exactly one game canvas: ${JSON.stringify(state)}`); assert(state.debugApiPresent === false, `Expected public deploy to keep debug API disabled: ${JSON.stringify(state)}`); assert(state.gameApiPresent === false, `Expected public deploy to keep game handle disabled: ${JSON.stringify(state)}`); - assert(state.canvas?.width >= 960 && state.canvas?.height >= 540, `Expected desktop-size game canvas: ${JSON.stringify(state)}`); + assert(state.canvas?.width === 1920 && state.canvas?.height === 1080, `Expected FHD game canvas: ${JSON.stringify(state)}`); assert(state.canvas?.pixelProbe?.readable === true, `Expected readable canvas pixels: ${JSON.stringify(state)}`); assert(state.canvas.pixelProbe.sampleCount >= 500, `Expected enough canvas pixel samples: ${JSON.stringify(state)}`); assert(state.canvas.pixelProbe.nonTransparentRatio > 0.5, `Expected non-empty canvas pixels: ${JSON.stringify(state)}`); diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index afbeaf8..294b7a3 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -4,6 +4,8 @@ import { chromium } from 'playwright'; const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/'); const screenshotDir = 'dist'; +const baselineViewport = { width: 1920, height: 1080 }; +const legacyUiScale = 1.5; const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138'; const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29'; const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i; @@ -11,6 +13,34 @@ const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprit let serverProcess; let browser; +async function clickLegacyUi(page, x, y, options) { + await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options); +} + +async function moveLegacyUi(page, x, y, options) { + await page.mouse.move(x * legacyUiScale, y * legacyUiScale, options); +} + +async function clickBattleDeploymentStart(page) { + const point = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const layout = scene?.layout; + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !layout || !canvas || !bounds) { + return null; + } + const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2; + const logicalY = layout.panelY + layout.panelHeight - 76 + 17; + return { + x: bounds.left + logicalX * bounds.width / scene.scale.width, + y: bounds.top + logicalY * bounds.height / scene.scale.height + }; + }); + assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a runtime deployment start point: ${JSON.stringify(point)}`); + await page.mouse.click(point.x, point.y); +} + try { runCommand('node', ['scripts/verify-static-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); @@ -18,7 +48,7 @@ try { runCommand(process.execPath, ['scripts/verify-save-retry-flow.mjs'], { VERIFY_URL: targetUrl }); browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const page = await browser.newPage({ viewport: baselineViewport }); const consoleMessages = []; const pageErrors = []; const requestFailures = []; @@ -50,7 +80,7 @@ try { const emptySave = await readCampaignSave(page); assert(!emptySave.current && !emptySave.slot1, `Expected first run without campaign save: ${JSON.stringify(emptySave)}`); - await page.mouse.click(962, 240); + await clickLegacyUi(page, 962, 240); await waitForStoryReady(page); await setDebugQueryParam(page, 'debugOrderCommandReady', '1'); await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true }); @@ -114,8 +144,8 @@ try { firstBattleMiniMap?.visible === true && firstBattleMiniMap.title === '전장 지도' && sameJsonValue(firstBattleMiniMap.legend, ['아', '적', '목']) && - firstBattleProbe.sceneBounds?.width === 1280 && - firstBattleProbe.sceneBounds?.height === 720 && + firstBattleProbe.sceneBounds?.width === 1920 && + firstBattleProbe.sceneBounds?.height === 1080 && firstBattleMiniMap.cellSize >= 1 && firstBattleMiniMap.objectiveHighlighted === true && firstBattleMiniMap.selectedUnitId === null && @@ -143,14 +173,14 @@ try { isFiniteBounds(firstBattleRecentActions) && boundsInside(firstBattleRecentActions, firstBattleProbe.panelBounds) && boundsInside(firstBattleRecentActions, firstBattleProbe.sceneBounds) && - firstBattleRecentActions?.compact === true && - firstBattleRecentActions.height === 84 && - firstBattleRecentActions.rowCount === Math.min(2, firstBattleProbe.battleLog.length) && + firstBattleRecentActions?.compact === false && + firstBattleRecentActions.height === 118 && + firstBattleRecentActions.rowCount === Math.min(3, firstBattleProbe.battleLog.length) && firstBattleProbe.sideTexts.includes('최근 행동') && firstBattleRecentActions.bottom === firstBattleRecentActions.y + firstBattleRecentActions.height && firstBattleRecentActions.gapToMiniMap >= 6 && firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y, - `Expected the 720p recent-action panel to compact to two rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}` + `Expected the FHD recent-action panel to show three noncompact rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}` ); await setDebugQueryParam(page, 'debugForceBondChain', '1'); const firstBattleBondChain = await page.evaluate(() => { @@ -1983,7 +2013,7 @@ try { await page.mouse.click(firstEvaluationClosePoint.x, firstEvaluationClosePoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 }); - await page.mouse.click(738, 642); + await clickLegacyUi(page, 738, 642); await waitForCampAfterBattleResult(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp'); @@ -2523,7 +2553,7 @@ try { firstCampSupplyMutationFixture.woundedHp < firstCampSupplyMutationFixture.maxHp, `Expected a deterministic wounded report-unit fixture for camp supply recovery: ${JSON.stringify(firstCampSupplyMutationFixture)}` ); - await page.mouse.click(798, 38); + await clickLegacyUi(page, 798, 38); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies', undefined, @@ -2590,7 +2620,7 @@ try { const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? []; const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; - await page.mouse.click(1160, 38); + await clickLegacyUi(page, 1160, 38); await waitForSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie prep'); @@ -2651,7 +2681,7 @@ try { } ]; const firstSortiePursuit = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); - const logicalViewportBounds = { x: 0, y: 0, width: 1280, height: 720 }; + const logicalViewportBounds = { x: 0, y: 0, width: 1920, height: 1080 }; assert( sameJsonValue(firstSortiePursuit?.activePairs, expectedFirstSortiePursuitPairs) && firstSortiePursuit?.selectableOpportunities?.length === 0 && @@ -3033,7 +3063,7 @@ try { `Expected the lower legacy-rate pair to be promoted and prioritized before battle launch: ${JSON.stringify(launchCoreResonance)}` ); await advanceSortiePrepStep(page, 'loadout'); - await page.mouse.click(1116, 656); + await clickLegacyUi(page, 1116, 656); await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit'); await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'second battle from first camp'); @@ -3348,7 +3378,7 @@ try { secondBattleCoreResult.resultSummary.text === secondBattleCoreResult.summary && isFiniteBounds(secondBattleCoreResult.resultSummary.bounds) && boundsInside(secondBattleCoreResult.resultSummary.bounds, logicalViewportBounds) && - secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416, + secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416 * legacyUiScale, `Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}` ); @@ -3365,7 +3395,7 @@ try { }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await page.mouse.click(962, 310); + await clickLegacyUi(page, 962, 310); await waitForCamp(page); const requiredCasualtyRecruitment = await page.evaluate(() => { @@ -3432,7 +3462,7 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await page.mouse.click(962, 310); + await clickLegacyUi(page, 962, 310); await waitForCamp(page); const requiredCasualtyCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( @@ -3738,7 +3768,7 @@ try { }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await page.mouse.click(962, 310); + await clickLegacyUi(page, 962, 310); await waitForCamp(page); await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true }); await assertCanvasPainted(page, 'mid campaign camp'); @@ -3770,7 +3800,7 @@ try { ); const midCampNextBattleId = midCampState.nextSortieBattleId; - await page.mouse.click(1160, 38); + await clickLegacyUi(page, 1160, 38); await waitForSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie prep'); @@ -3890,7 +3920,7 @@ try { isFiniteBounds(row.chipBounds) && boundsInside(row.chipBounds, midCoreResonancePageTwo.panelBounds) ), - `Expected the next-page control to expose later eligible resonance pairs inside the HD panel: ${JSON.stringify(midCoreResonancePageTwo)}` + `Expected the next-page control to expose later eligible resonance pairs inside the FHD panel: ${JSON.stringify(midCoreResonancePageTwo)}` ); const midCoreLaterCandidate = midCoreResonancePageTwo.rows[0]; await page.mouse.click( @@ -4032,7 +4062,7 @@ try { `Expected hover swap preview to preserve runtime and saved selections: ${JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true }); - await page.mouse.move(40, 710); + await moveLegacyUi(page, 40, 710); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieHoveredUnitId === null && state?.sortieComparisonPreview?.source === 'focus'; @@ -4663,7 +4693,7 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await page.mouse.click(962, 310); + await clickLegacyUi(page, 962, 310); await waitForCamp(page); const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( @@ -4694,7 +4724,7 @@ try { const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? []; const midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {}; const midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {}; - await page.mouse.click(1160, 38); + await clickLegacyUi(page, 1160, 38); await waitForSortiePrep(page); const restoredMidBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( @@ -4721,7 +4751,7 @@ try { ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-loadout.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie loadout'); - await page.mouse.click(1116, 656); + await clickLegacyUi(page, 1116, 656); await advanceUntilBattle(page, midCampNextBattleId); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp next battle'); @@ -4990,7 +5020,7 @@ try { undefined, { timeout: 30000 } ); - await page.mouse.click(738, 642); + await clickLegacyUi(page, 738, 642); await waitForCampAfterBattleResult(page); const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5143,7 +5173,7 @@ try { }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await page.mouse.click(962, 310); + await clickLegacyUi(page, 962, 310); await waitForCamp(page); const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`); @@ -5164,7 +5194,7 @@ try { await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp'); await assertFinalCampRosterPagination(page); - await page.mouse.click(1160, 38); + await clickLegacyUi(page, 1160, 38); const finalTransitionScenes = await waitForStoryOrEnding(page); assert(!finalTransitionScenes.includes('BattleScene'), `Expected final camp transition to avoid BattleScene: ${JSON.stringify(finalTransitionScenes)}`); if (finalTransitionScenes.includes('StoryScene')) { @@ -5181,7 +5211,7 @@ try { await page.keyboard.press('Enter'); await waitForTitle(page); - await page.mouse.click(962, 310); + await clickLegacyUi(page, 962, 310); await waitForEnding(page); await page.screenshot({ path: `${screenshotDir}/rc-ending-continue.png`, fullPage: true }); await assertCanvasPainted(page, 'ending continue'); @@ -5260,12 +5290,16 @@ async function assertReleaseShellReady(page) { async function assertHdPlusCanvasFit(page) { const cases = [ { - viewport: { width: 1600, height: 900 }, - expectedBounds: { x: 0, y: 0, width: 1600, height: 900 } + viewport: { width: 1920, height: 1080 }, + expectedBounds: { x: 0, y: 0, width: 1920, height: 1080 } }, { - viewport: { width: 1920, height: 1200 }, - expectedBounds: { x: 0, y: 60, width: 1920, height: 1080 } + viewport: { width: 2560, height: 1440 }, + expectedBounds: { x: 0, y: 0, width: 2560, height: 1440 } + }, + { + viewport: { width: 2560, height: 1600 }, + expectedBounds: { x: 0, y: 80, width: 2560, height: 1440 } } ]; @@ -5278,8 +5312,8 @@ async function assertHdPlusCanvasFit(page) { } const bounds = canvas.getBoundingClientRect(); return ( - canvas.width === 1280 && - canvas.height === 720 && + canvas.width === 1920 && + canvas.height === 1080 && Math.abs(bounds.x - expectedBounds.x) <= 1 && Math.abs(bounds.y - expectedBounds.y) <= 1 && Math.abs(bounds.width - expectedBounds.width) <= 1 && @@ -5303,27 +5337,27 @@ async function assertHdPlusCanvasFit(page) { : null }; }); - assert(state.canvas?.width === 1280 && state.canvas?.height === 720, `Expected a fixed HD game surface: ${JSON.stringify(state)}`); - assert(state.gameSize?.width === 1280 && state.gameSize?.height === 720, `Expected fixed HD scene coordinates: ${JSON.stringify(state)}`); + assert(state.canvas?.width === 1920 && state.canvas?.height === 1080, `Expected a fixed FHD game surface: ${JSON.stringify(state)}`); + assert(state.gameSize?.width === 1920 && state.gameSize?.height === 1080, `Expected fixed FHD scene coordinates: ${JSON.stringify(state)}`); assert( boundsNear(state.canvas?.bounds, testCase.expectedBounds), - `Expected HD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}` + `Expected FHD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}` ); } - await page.setViewportSize({ width: 1280, height: 720 }); + await page.setViewportSize(baselineViewport); await page.waitForFunction(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return Boolean( canvas && bounds && - canvas.width === 1280 && - canvas.height === 720 && + canvas.width === 1920 && + canvas.height === 1080 && Math.abs(bounds.x) <= 1 && Math.abs(bounds.y) <= 1 && - Math.abs(bounds.width - 1280) <= 1 && - Math.abs(bounds.height - 720) <= 1 + Math.abs(bounds.width - 1920) <= 1 && + Math.abs(bounds.height - 1080) <= 1 ); }, undefined, { timeout: 30000 }); } @@ -5339,28 +5373,28 @@ function boundsNear(actual, expected, tolerance = 1) { } async function assertHdPlusInputMapping(browser, url) { - const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } }); + const page = await browser.newPage({ viewport: { width: 2560, height: 1600 } }); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - const clickPoint = await page.evaluate(() => { + const clickPoint = await page.evaluate(({ logicalX, logicalY }) => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); if (!canvas || !bounds) { return null; } return { - x: bounds.x + (962 / canvas.width) * bounds.width, - y: bounds.y + (240 / canvas.height) * bounds.height, + x: bounds.x + (logicalX / canvas.width) * bounds.width, + y: bounds.y + (logicalY / canvas.height) * bounds.height, canvasBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } }; - }); + }, { logicalX: 962 * legacyUiScale, logicalY: 240 * legacyUiScale }); assert( - clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 60, width: 1920, height: 1080 }), - `Expected a centered HD canvas before testing scaled input: ${JSON.stringify(clickPoint)}` + clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 80, width: 2560, height: 1440 }), + `Expected a centered FHD canvas before testing above-FHD input: ${JSON.stringify(clickPoint)}` ); await page.mouse.click(clickPoint.x, clickPoint.y); @@ -5385,7 +5419,7 @@ async function assertLargeRosterHud(browser, url) { ]; const battleUrl = new URL(url); battleUrl.searchParams.set('debugBattle', 'sixty-sixth-battle-wuzhang-final'); - const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const page = await browser.newPage({ viewport: baselineViewport }); try { await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => { @@ -5578,7 +5612,7 @@ async function startDeploymentIfNeeded(page, battleId) { } assert(state.deployedAllyIds?.length >= 3, `Expected deployment to show the selected ally formation: ${JSON.stringify(state)}`); - await page.mouse.click(1085, 637); + await clickBattleDeploymentStart(page); await page.waitForFunction((expectedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening'); @@ -6106,16 +6140,16 @@ function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSel const listBounds = roster.listBounds; assert(isPositiveBounds(listBounds), `Expected final-camp roster list bounds on page ${expectedPage + 1}: ${JSON.stringify(roster)}`); assert( - isBoundsInside(listBounds, { x: 0, y: 0, width: 1280, height: 720 }), - `Expected final-camp roster list inside the 1280x720 canvas: ${JSON.stringify(roster)}` + isBoundsInside(listBounds, { x: 0, y: 0, width: 1920, height: 1080 }), + `Expected final-camp roster list inside the 1920x1080 canvas: ${JSON.stringify(roster)}` ); assert( isPositiveBounds(roster.navigationBounds?.container) && isPositiveBounds(roster.navigationBounds?.previous) && isPositiveBounds(roster.navigationBounds?.label) && isPositiveBounds(roster.navigationBounds?.next) && - isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1280, height: 720 }), - `Expected complete final-camp roster navigation bounds inside the HD canvas: ${JSON.stringify(roster)}` + isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1920, height: 1080 }), + `Expected complete final-camp roster navigation bounds inside the FHD canvas: ${JSON.stringify(roster)}` ); assert( isBoundsInside(roster.navigationBounds.previous, roster.navigationBounds.container) && @@ -6130,9 +6164,9 @@ function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSel row.unitId === roster.visibleUnitIds[rowIndex] && isPositiveBounds(row) && isBoundsInside(row, listBounds) && - isBoundsInside(row, { x: 0, y: 0, width: 1280, height: 720 }) && + isBoundsInside(row, { x: 0, y: 0, width: 1920, height: 1080 }) && row.y + row.height <= roster.navigationBounds.container.y, - `Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and HD canvas: ${JSON.stringify({ + `Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and FHD canvas: ${JSON.stringify({ row, listBounds, visibleUnitIds: roster.visibleUnitIds @@ -6362,7 +6396,7 @@ async function waitForSortiePrep(page) { } async function advanceSortiePrepStep(page, expectedStep) { - await page.mouse.click(1116, 656); + await clickLegacyUi(page, 1116, 656); await page.waitForFunction((step) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return camp?.sortieVisible === true && camp?.sortiePrepStep === step; @@ -6944,7 +6978,7 @@ async function readSortiePresetControlPoint(page, control, presetId) { function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitIds) { const expectedStart = expectedVisibleUnitIds.length > 0 ? expectedPage * 6 + 1 : 0; const expectedEnd = expectedPage * 6 + expectedVisibleUnitIds.length; - const hdBounds = { x: 0, y: 0, width: 1280, height: 720 }; + const hdBounds = { x: 0, y: 0, width: 1920, height: 1080 }; assert( view?.enabled === true && view.total === 23 && @@ -6972,7 +7006,7 @@ function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitI bounds.unitId === expectedVisibleUnitIds[index] && isFiniteBounds(bounds) && boundsInside(bounds, hdBounds), - `Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the HD canvas: ${JSON.stringify({ + `Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the FHD canvas: ${JSON.stringify({ bounds, expectedUnitId: expectedVisibleUnitIds[index] })}` diff --git a/scripts/verify-save-retry-flow.mjs b/scripts/verify-save-retry-flow.mjs index e27bb69..a07bdeb 100644 --- a/scripts/verify-save-retry-flow.mjs +++ b/scripts/verify-save-retry-flow.mjs @@ -1,7 +1,15 @@ import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; -const targetUrl = process.env.VERIFY_URL ?? 'http://localhost:5173/'; +const targetUrl = withCanvasRenderer(process.env.VERIFY_URL ?? 'http://localhost:5173/'); +const baselineViewport = { width: 1920, height: 1080 }; +const legacyUiScale = 1.5; + +function withCanvasRenderer(url) { + const parsed = new URL(url); + parsed.searchParams.set('renderer', 'canvas'); + return parsed.toString(); +} let serverProcess; let browser; @@ -10,7 +18,7 @@ try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const page = await browser.newPage({ viewport: baselineViewport }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => window.localStorage.clear()); @@ -48,7 +56,7 @@ try { throw new Error(`Expected defeat to save retry-ready first battle state: ${JSON.stringify(defeatSave.current)}`); } - await page.mouse.click(884, 642); + await clickLegacyUi(page, 884, 642); await waitForBattleReady(page, 'first-battle-zhuo-commandery'); const retriedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const retriedLiuBei = retriedBattle?.units?.find((unit) => unit.id === 'liu-bei'); @@ -71,7 +79,7 @@ try { await waitForBattleOutcome(page, 'victory'); await page.screenshot({ path: 'dist/verification-save-victory-result.png', fullPage: true }); - await page.mouse.click(738, 642); + await clickLegacyUi(page, 738, 642); await advanceUntilCamp(page); await page.screenshot({ path: 'dist/verification-save-victory-camp.png', fullPage: true }); @@ -191,7 +199,7 @@ async function advanceUntilCamp(page) { } if (state.activeScenes.includes('BattleScene') && state.battle?.battleOutcome === 'victory' && state.battle?.resultVisible) { - await page.mouse.click(738, 642); + await clickLegacyUi(page, 738, 642); } else { await page.keyboard.press('Space'); } @@ -263,3 +271,7 @@ async function canReach(url) { function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +async function clickLegacyUi(page, x, y, options) { + await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 3cbeec3..6db7d3e 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -100,6 +100,10 @@ import { import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; +const battleReferenceWidth = 1280; +const battleReferenceHeight = 720; +const battleFhdUiScale = 1.5; + const unitTexture: Record = { 'liu-bei': 'unit-liu-bei', 'guan-yu': 'unit-guan-yu', @@ -3380,6 +3384,7 @@ function configureBattleScenario(scenario: BattleScenarioDefinition) { } export class BattleScene extends Phaser.Scene { + private readonly pendingUiContainerSorts = new WeakSet(); private layout!: BattleLayout; private phase: BattlePhase = 'idle'; private turnNumber = 1; @@ -3412,7 +3417,9 @@ export class BattleScene extends Phaser.Scene { private fastForwardHeld = false; private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = []; private tacticalInitiativeChipObjects: Phaser.GameObjects.GameObject[] = []; + private tacticalInitiativePanelRoot?: Phaser.GameObjects.Container; private tacticalInitiativePanelObjects: Phaser.GameObjects.GameObject[] = []; + private tacticalCommandCutInRoot?: Phaser.GameObjects.Container; private tacticalCommandCutInObjects: Phaser.GameObjects.GameObject[] = []; private tacticalCommandCutInBounds?: SortieOrderHudBounds; private tacticalCommandCutInCount = 0; @@ -3436,11 +3443,13 @@ export class BattleScene extends Phaser.Scene { private turnPromptMode?: TurnPromptMode; private turnPromptPrimaryAction?: () => void; private turnPromptSecondaryAction?: () => void; + private combatCutInRoot?: Phaser.GameObjects.Container; private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private combatCutInStageActive?: CombatCutInStageSnapshot; private combatCutInStageLast?: CombatCutInStageSnapshot; private facingIndicatorObjects: Phaser.GameObjects.GameObject[] = []; private facingIndicatorUnitId?: string; + private resultRoot?: Phaser.GameObjects.Container; private resultObjects: Phaser.GameObjects.GameObject[] = []; private resultGaugeAnimations: ResultGaugeAnimation[] = []; private resultAnimationToken = 0; @@ -4551,15 +4560,15 @@ export class BattleScene extends Phaser.Scene { } private createLayout(width: number, height: number): BattleLayout { - const margin = 24; - const gap = 20; - const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360); + const margin = 36; + const gap = 30; + const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 480, 540); const mapWidth = width - panelWidth - gap - margin * 2; const mapHeight = height - margin * 2; const visibleRows = Math.min(12, battleMap.height); - const rowBasedTileSize = Math.floor((mapHeight - 36) / visibleRows); - const visibleColumns = Math.min(battleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize))); - const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 16) / visibleColumns)); + const rowBasedTileSize = Math.floor((mapHeight - 54) / visibleRows); + const visibleColumns = Math.min(battleMap.width, Math.max(12, Math.floor((mapWidth - 24) / rowBasedTileSize))); + const tileSize = Math.floor(Math.min((mapHeight - 54) / visibleRows, (mapWidth - 24) / visibleColumns)); const gridWidth = tileSize * visibleColumns; const gridHeight = tileSize * visibleRows; const gridSize = Math.min(gridWidth, gridHeight); @@ -4698,31 +4707,31 @@ export class BattleScene extends Phaser.Scene { private drawSidePanel() { const { panelX, panelY, panelWidth, panelHeight } = this.layout; this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0); - this.add.rectangle(panelX, panelY, 3, panelHeight, palette.gold, 0.82).setOrigin(0); + this.add.rectangle(panelX, panelY, this.battleUiLength(3), panelHeight, palette.gold, 0.82).setOrigin(0); - this.add.text(panelX + 24, panelY + 24, battleScenario.title, { + this.add.text(panelX + this.battleUiLength(24), panelY + this.battleUiLength(24), battleScenario.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '28px', + fontSize: this.battleUiFontSize(28), color: '#e8dfca', fontStyle: '700', - padding: { top: 4, bottom: 4 } + padding: { top: this.battleUiLength(4), bottom: this.battleUiLength(4) } }); - this.turnText = this.add.text(panelX + 24, panelY + 76, '1턴 / 아군', { + this.turnText = this.add.text(panelX + this.battleUiLength(24), panelY + this.battleUiLength(76), '1턴 / 아군', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: this.battleUiFontSize(22), color: '#d8b15f' }); - this.objectiveTrackerText = this.add.text(panelX + 24, panelY + 108, '', { + this.objectiveTrackerText = this.add.text(panelX + this.battleUiLength(24), panelY + this.battleUiLength(108), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(13), color: '#d4dce6', - wordWrap: { width: panelWidth - 48, useAdvancedWrap: true } + wordWrap: { width: panelWidth - this.battleUiLength(48), useAdvancedWrap: true } }); - this.objectiveTrackerSubText = this.add.text(panelX + 24, panelY + 126, '', { + this.objectiveTrackerSubText = this.add.text(panelX + this.battleUiLength(24), panelY + this.battleUiLength(126), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(12), color: '#9aa3ad', - wordWrap: { width: panelWidth - 48, useAdvancedWrap: true } + wordWrap: { width: panelWidth - this.battleUiLength(48), useAdvancedWrap: true } }); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); @@ -4734,10 +4743,10 @@ export class BattleScene extends Phaser.Scene { this.battleSpeedControlObjects = []; const { panelX, panelY, panelWidth } = this.layout; - const width = 112; - const height = 28; - const left = panelX + panelWidth - width - 24; - const top = panelY + 74; + const width = this.battleUiLength(112); + const height = this.battleUiLength(28); + const left = panelX + panelWidth - width - this.battleUiLength(24); + const top = panelY + this.battleUiLength(74); const isHoldingFastForward = this.activeFaction === 'enemy' && this.fastForwardHeld; const label = isHoldingFastForward ? '가속 중' : battleSpeedLabels[this.battleSpeed]; const accent = isHoldingFastForward ? palette.green : this.battleSpeed === 'normal' ? 0x5a7588 : palette.gold; @@ -4745,12 +4754,12 @@ export class BattleScene extends Phaser.Scene { const button = this.add.rectangle(left, top, width, height, 0x17232e, 0.96); button.setOrigin(0); button.setDepth(16); - button.setStrokeStyle(1, accent, 0.86); + button.setStrokeStyle(this.battleUiLength(1), accent, 0.86); button.setInteractive({ useHandCursor: true }); - const text = this.add.text(left + width / 2, top + height / 2 - 1, `속도 ${label}`, { + const text = this.add.text(left + width / 2, top + height / 2 - this.battleUiLength(1), `속도 ${label}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(13), color: '#f2e3bf', fontStyle: '700' }); @@ -4820,14 +4829,14 @@ export class BattleScene extends Phaser.Scene { this.clearSortieOrderHud(); this.sortieOrderHudSnapshot = snapshot; const { panelX, panelWidth } = this.layout; - const left = panelX + 24; - const width = panelWidth - 48; - const height = 36; + const left = panelX + this.battleUiLength(24); + const width = panelWidth - this.battleUiLength(48); + const height = this.battleUiLength(36); const trackerBottom = Math.max( this.objectiveTrackerText?.active ? this.objectiveTrackerText.y + this.objectiveTrackerText.height : 0, this.objectiveTrackerSubText?.active ? this.objectiveTrackerSubText.y + this.objectiveTrackerSubText.height : 0 ); - const top = Math.ceil(Math.max(this.layout.panelY + 140, trackerBottom) + 6); + const top = Math.ceil(Math.max(this.layout.panelY + this.battleUiLength(140), trackerBottom) + this.battleUiLength(6)); const depth = 15; const outer = this.trackSortieOrderHudObject(this.add.rectangle( left, @@ -4839,32 +4848,32 @@ export class BattleScene extends Phaser.Scene { )); outer.setOrigin(0); outer.setDepth(depth); - outer.setStrokeStyle(1, this.sortieOrderHudTone(snapshot.status), snapshot.status === 'warning' || snapshot.status === 'failed' ? 0.96 : 0.74); + outer.setStrokeStyle(this.battleUiLength(1), this.sortieOrderHudTone(snapshot.status), snapshot.status === 'warning' || snapshot.status === 'failed' ? 0.96 : 0.74); - const headerWidth = 96; + const headerWidth = this.battleUiLength(96); const header = this.trackSortieOrderHudObject(this.add.rectangle(left, top, headerWidth, height, 0x241f18, 0.98)); header.setOrigin(0); header.setDepth(depth + 1); - header.setStrokeStyle(1, this.sortieOrderHudTone(snapshot.victoryStatus), 0.7); - this.trackSortieOrderHudObject(this.add.text(left + 8, top + 4, `${snapshot.label} 군령`, { + header.setStrokeStyle(this.battleUiLength(1), this.sortieOrderHudTone(snapshot.victoryStatus), 0.7); + this.trackSortieOrderHudObject(this.add.text(left + this.battleUiLength(8), top + this.battleUiLength(4), `${snapshot.label} 군령`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700' })).setDepth(depth + 2); const commandReady = this.tacticalInitiativeReady(); const commandUsed = Boolean(this.tacticalCommand); if (commandReady) { - header.setStrokeStyle(1, palette.gold, 0.96); + header.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.96); } const commandLabel = commandUsed ? `${this.tacticalInitiativeCommandLabel(this.tacticalCommand!.role, true)} 사용` : commandReady ? '명령 가능 · T' : snapshot.victoryLabel; - const commandText = this.trackSortieOrderHudObject(this.add.text(left + 8, top + 20, commandLabel, { + const commandText = this.trackSortieOrderHudObject(this.add.text(left + this.battleUiLength(8), top + this.battleUiLength(20), commandLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '9px', + fontSize: this.battleUiFontSize(9), color: commandReady ? '#ffdf7b' : commandUsed ? '#d8b15f' : this.sortieOrderHudTextColor(snapshot.victoryStatus), fontStyle: '700' })); @@ -4889,21 +4898,34 @@ export class BattleScene extends Phaser.Scene { const bounds = { x, y: top, width: criterionWidth, height }; this.sortieOrderHudCriterionBounds[criterion.id] = bounds; if (index > 0) { - this.trackSortieOrderHudObject(this.add.rectangle(x, top + 5, 1, height - 10, 0x647485, 0.42)) + this.trackSortieOrderHudObject(this.add.rectangle( + x, + top + this.battleUiLength(5), + this.battleUiLength(1), + height - this.battleUiLength(10), + 0x647485, + 0.42 + )) .setOrigin(0) .setDepth(depth + 1); } - const dot = this.trackSortieOrderHudObject(this.add.circle(x + 8, top + 26, 3, this.sortieOrderHudTone(criterion.status), 0.96)); + const dot = this.trackSortieOrderHudObject(this.add.circle( + x + this.battleUiLength(8), + top + this.battleUiLength(26), + this.battleUiLength(3), + this.sortieOrderHudTone(criterion.status), + 0.96 + )); dot.setDepth(depth + 2); - this.trackSortieOrderHudObject(this.add.text(x + criterionWidth / 2, top + 3, criterion.label, { + this.trackSortieOrderHudObject(this.add.text(x + criterionWidth / 2, top + this.battleUiLength(3), criterion.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: snapshot.criteria.length >= 3 ? '9px' : '10px', + fontSize: this.battleUiFontSize(snapshot.criteria.length >= 3 ? 9 : 10), color: '#aeb7c2', fontStyle: '700' })).setOrigin(0.5, 0).setDepth(depth + 2); - this.trackSortieOrderHudObject(this.add.text(x + criterionWidth / 2, top + 19, criterion.value, { + this.trackSortieOrderHudObject(this.add.text(x + criterionWidth / 2, top + this.battleUiLength(19), criterion.value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: snapshot.criteria.length >= 3 ? '10px' : '11px', + fontSize: this.battleUiFontSize(snapshot.criteria.length >= 3 ? 10 : 11), color: this.sortieOrderHudTextColor(criterion.status), fontStyle: '700' })).setOrigin(0.5, 0).setDepth(depth + 2); @@ -4970,16 +4992,16 @@ export class BattleScene extends Phaser.Scene { const x = bounds.x + bounds.width / 2; const y = bounds.y + bounds.height / 2; const depth = 38; - const seal = this.add.circle(x, y, 15, 0x2b100c, 0.96); + const seal = this.add.circle(x, y, this.battleUiLength(15), 0x2b100c, 0.96); seal.setDepth(depth); - seal.setStrokeStyle(2, 0xd46a54, 0.98); - const label = this.add.text(x, y - 1, '달성', { + seal.setStrokeStyle(this.battleUiLength(2), 0xd46a54, 0.98); + const label = this.add.text(x, y - this.battleUiLength(1), '달성', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '10px', + fontSize: this.battleUiFontSize(10), color: '#ffd2c6', fontStyle: '700', stroke: '#4b140d', - strokeThickness: 2 + strokeThickness: this.battleUiLength(2) }); label.setOrigin(0.5); label.setDepth(depth + 1); @@ -5017,10 +5039,10 @@ export class BattleScene extends Phaser.Scene { } const { panelX, panelY, panelWidth } = this.layout; - const width = 82; - const height = 30; - const left = panelX + panelWidth - width - 24; - const top = panelY + 26; + const width = this.battleUiLength(82); + const height = this.battleUiLength(30); + const left = panelX + panelWidth - width - this.battleUiLength(24); + const top = panelY + this.battleUiLength(26); const points = this.tacticalInitiativePoints(); const ready = this.tacticalInitiativeReady(); const used = Boolean(this.tacticalCommand); @@ -5054,10 +5076,10 @@ export class BattleScene extends Phaser.Scene { const button = this.add.rectangle(left, top, width, height, highlighted ? 0x3a2d14 : 0x17232e, 0.98); button.setOrigin(0); button.setDepth(16); - button.setStrokeStyle(1, tone, highlighted ? 1 : 0.82); - const text = this.add.text(left + width / 2, top + height / 2 - 1, label, { + button.setStrokeStyle(this.battleUiLength(1), tone, highlighted ? 1 : 0.82); + const text = this.add.text(left + width / 2, top + height / 2 - this.battleUiLength(1), label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: used || ready ? '13px' : '12px', + fontSize: this.battleUiFontSize(used || ready ? 13 : 12), color: lost ? '#ffb6a6' : pending || ready ? '#fff2b8' : used ? '#aeb7c2' : '#d9f1ff', fontStyle: '700' }); @@ -5103,10 +5125,15 @@ export class BattleScene extends Phaser.Scene { const panelWidth = 612; const panelHeight = 248; - const left = Math.floor(this.layout.mapX + (this.layout.mapWidth - panelWidth) / 2); - const top = Math.floor(this.layout.mapY + (this.layout.mapHeight - panelHeight) / 2); + const referenceMapX = this.layout.mapX / battleFhdUiScale; + const referenceMapY = this.layout.mapY / battleFhdUiScale; + const referenceMapWidth = this.layout.mapWidth / battleFhdUiScale; + const referenceMapHeight = this.layout.mapHeight / battleFhdUiScale; + const left = Math.floor(referenceMapX + (referenceMapWidth - panelWidth) / 2); + const top = Math.floor(referenceMapY + (referenceMapHeight - panelHeight) / 2); const depth = 48; - const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.58); + this.tacticalInitiativePanelRoot = this.add.container(0, 0).setScale(battleFhdUiScale).setDepth(depth); + const shade = this.add.rectangle(0, 0, battleReferenceWidth, battleReferenceHeight, 0x020406, 0.58); shade.setOrigin(0); shade.setDepth(depth); shade.setInteractive(); @@ -5123,7 +5150,7 @@ export class BattleScene extends Phaser.Scene { panel.setStrokeStyle(2, this.tacticalInitiativeReady() ? palette.gold : 0x647485, 0.92); panel.setInteractive(); this.trackTacticalInitiativePanelObject(panel); - this.tacticalInitiativePanelBounds = { x: left, y: top, width: panelWidth, height: panelHeight }; + this.tacticalInitiativePanelBounds = this.scaleBattleReferenceBounds({ x: left, y: top, width: panelWidth, height: panelHeight }); this.tacticalInitiativeCardBounds = {}; const panelSource = this.tacticalCommandPanelSource(); @@ -5206,7 +5233,7 @@ export class BattleScene extends Phaser.Scene { const available = this.canUseTacticalInitiativeCommand(role); const definition = sortieRoleEffects[role]; const supportTarget = role === 'support' ? this.tacticalInitiativeSupportTarget() : undefined; - this.tacticalInitiativeCardBounds[role] = { x, y, width, height }; + this.tacticalInitiativeCardBounds[role] = this.scaleBattleReferenceBounds({ x, y, width, height }); const bg = this.add.rectangle(x, y, width, height, selected ? 0x3a2d14 : available ? 0x172b35 : 0x111820, 0.98); bg.setOrigin(0); bg.setDepth(depth); @@ -5405,10 +5432,10 @@ export class BattleScene extends Phaser.Scene { ) { this.hideTacticalCommandCutIn(); const token = this.tacticalCommandCutInToken; - const width = Math.min(900, Math.max(560, this.scale.width - 120)); + const width = Math.min(900, Math.max(560, battleReferenceWidth - 120)); const height = 206; - const left = Math.floor((this.scale.width - width) / 2); - const top = Math.floor((this.scale.height - height) / 2); + const left = Math.floor((battleReferenceWidth - width) / 2); + const top = Math.floor((battleReferenceHeight - height) / 2); const bounds = { x: left, y: top, width, height }; const definition = sortieRoleEffects[role]; const roleTone = definition?.tone ?? palette.gold; @@ -5416,19 +5443,20 @@ export class BattleScene extends Phaser.Scene { const sourceLabel = source === 'sortie-order' ? '군령 발동' : '전술 주도권'; const commandLabel = this.tacticalInitiativeCommandLabel(role); const depth = 92; + this.tacticalCommandCutInRoot = this.add.container(0, 0).setScale(battleFhdUiScale).setDepth(depth); - this.tacticalCommandCutInBounds = bounds; + this.tacticalCommandCutInBounds = this.scaleBattleReferenceBounds(bounds); this.tacticalCommandCutInCount += 1; this.tacticalCommandCutInLast = { source, role, actorId: actor.id, label: commandLabel, - bounds: { ...bounds } + bounds: this.scaleBattleReferenceBounds(bounds) }; const dim = this.trackTacticalCommandCutInObject( - this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.7) + this.add.rectangle(0, 0, battleReferenceWidth, battleReferenceHeight, 0x020406, 0.7) ); dim.setOrigin(0); dim.setDepth(depth); @@ -5565,6 +5593,10 @@ export class BattleScene extends Phaser.Scene { } private trackTacticalCommandCutInObject(object: T) { + if (this.tacticalCommandCutInRoot) { + this.tacticalCommandCutInRoot.add(object); + this.scheduleUiContainerDepthSort(this.tacticalCommandCutInRoot); + } this.tacticalCommandCutInObjects.push(object); return object; } @@ -5578,17 +5610,25 @@ export class BattleScene extends Phaser.Scene { } }); this.tacticalCommandCutInObjects = []; + this.tacticalCommandCutInRoot?.destroy(); + this.tacticalCommandCutInRoot = undefined; this.tacticalCommandCutInBounds = undefined; } private hideTacticalInitiativePanel() { this.tacticalInitiativePanelObjects.forEach((object) => object.destroy()); this.tacticalInitiativePanelObjects = []; + this.tacticalInitiativePanelRoot?.destroy(); + this.tacticalInitiativePanelRoot = undefined; this.tacticalInitiativePanelBounds = undefined; this.tacticalInitiativeCardBounds = {}; } private trackTacticalInitiativePanelObject(object: T) { + if (this.tacticalInitiativePanelRoot) { + this.tacticalInitiativePanelRoot.add(object); + this.scheduleUiContainerDepthSort(this.tacticalInitiativePanelRoot); + } this.tacticalInitiativePanelObjects.push(object); return object; } @@ -5730,16 +5770,19 @@ export class BattleScene extends Phaser.Scene { private drawMiniMap() { const { panelX, panelY, panelWidth, panelHeight } = this.layout; - const maxWidth = panelWidth - 48; - const maxHeight = 108; - const cellSize = Math.max(1, Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height))); + const maxWidth = panelWidth - this.battleUiLength(48); + const maxHeight = this.battleUiLength(108); + const cellSize = Math.max( + this.battleUiLength(1), + Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height)) + ); const width = battleMap.width * cellSize; const height = battleMap.height * cellSize; const x = panelX + Math.floor((panelWidth - width) / 2); - const y = panelY + panelHeight - height - 12; - const headerHeight = 26; - const frameWidth = Math.min(maxWidth, Math.max(width + 12, 190)); - const frameHeight = headerHeight + height + 6; + const y = panelY + panelHeight - height - this.battleUiLength(12); + const headerHeight = this.battleUiLength(26); + const frameWidth = Math.min(maxWidth, Math.max(width + this.battleUiLength(12), this.battleUiLength(190))); + const frameHeight = headerHeight + height + this.battleUiLength(6); const frameX = panelX + Math.floor((panelWidth - frameWidth) / 2); const frameY = y - headerHeight; this.miniMapLayout = { @@ -5757,14 +5800,14 @@ export class BattleScene extends Phaser.Scene { const frame = this.add.rectangle(frameX, frameY, frameWidth, frameHeight, 0x0a1014, 0.96); frame.setOrigin(0); - frame.setStrokeStyle(1, palette.gold, 0.78); + frame.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.78); frame.setDepth(24); frame.setName('mini-map-frame'); this.miniMapObjects.push(frame); - const title = this.add.text(frameX + 10, frameY + 6, '전장 지도', { + const title = this.add.text(frameX + this.battleUiLength(10), frameY + this.battleUiLength(6), '전장 지도', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700' }); @@ -5772,28 +5815,42 @@ export class BattleScene extends Phaser.Scene { title.setName('mini-map-title'); this.miniMapObjects.push(title); - const separator = this.add.rectangle(frameX + 5, frameY + headerHeight - 1, frameWidth - 10, 1, palette.gold, 0.34); + const separator = this.add.rectangle( + frameX + this.battleUiLength(5), + frameY + headerHeight - this.battleUiLength(1), + frameWidth - this.battleUiLength(10), + this.battleUiLength(1), + palette.gold, + 0.34 + ); separator.setOrigin(0); separator.setDepth(25); this.miniMapObjects.push(separator); - const legendStartX = frameX + frameWidth - 82; + const legendStartX = frameX + frameWidth - this.battleUiLength(82); [ { label: '아', tone: 0x4aa9ff, target: false }, { label: '적', tone: 0xff715f, target: false }, { label: '목', tone: palette.gold, target: true } ].forEach((legend, index) => { - const legendX = legendStartX + index * 27; - const dot = this.add.rectangle(legendX, frameY + 13, 7, 7, legend.target ? 0x0a1014 : legend.tone, 1); + const legendX = legendStartX + index * this.battleUiLength(27); + const dot = this.add.rectangle( + legendX, + frameY + this.battleUiLength(13), + this.battleUiLength(7), + this.battleUiLength(7), + legend.target ? 0x0a1014 : legend.tone, + 1 + ); if (legend.target) { - dot.setStrokeStyle(1, legend.tone, 1); + dot.setStrokeStyle(this.battleUiLength(1), legend.tone, 1); } dot.setDepth(27); this.miniMapObjects.push(dot); - const label = this.add.text(legendX + 6, frameY + 7, legend.label, { + const label = this.add.text(legendX + this.battleUiLength(6), frameY + this.battleUiLength(7), legend.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '9px', + fontSize: this.battleUiFontSize(9), color: legend.target ? '#ffdf7b' : '#c8d2dd', fontStyle: '700' }); @@ -5814,8 +5871,8 @@ export class BattleScene extends Phaser.Scene { const cell = this.add.rectangle( x + tileX * cellSize, y + tileY * cellSize, - Math.max(1, cellSize - 1), - Math.max(1, cellSize - 1), + Math.max(this.battleUiLength(1), cellSize - this.battleUiLength(1)), + Math.max(this.battleUiLength(1), cellSize - this.battleUiLength(1)), rule.color, rule.passable === false ? 0.92 : 0.72 ); @@ -5828,7 +5885,14 @@ export class BattleScene extends Phaser.Scene { }); battleUnits.forEach((unit) => { - const dot = this.add.rectangle(0, 0, Math.max(3, cellSize), Math.max(3, cellSize), unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, 0.96); + const dot = this.add.rectangle( + 0, + 0, + Math.max(this.battleUiLength(3), cellSize), + Math.max(this.battleUiLength(3), cellSize), + unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, + 0.96 + ); dot.setOrigin(0.5); dot.setDepth(28); this.miniMapUnitDots.set(unit.id, dot); @@ -5837,7 +5901,7 @@ export class BattleScene extends Phaser.Scene { const viewport = this.add.rectangle(0, 0, this.layout.visibleColumns * cellSize, this.layout.visibleRows * cellSize); viewport.setOrigin(0); - viewport.setStrokeStyle(2, 0xf7e6a1, 0.95); + viewport.setStrokeStyle(this.battleUiLength(2), 0xf7e6a1, 0.95); viewport.setDepth(29); this.miniMapViewport = viewport; this.miniMapObjects.push(viewport); @@ -5990,16 +6054,16 @@ export class BattleScene extends Phaser.Scene { } const selected = this.selectedUnit?.id === unit.id; const objective = unit.id === leaderUnitId; - const baseSize = Math.max(3, cellSize); - const markerSize = baseSize + (objective ? 2 : 0) + (selected ? 2 : 0); + const baseSize = Math.max(this.battleUiLength(3), cellSize); + const markerSize = baseSize + (objective ? this.battleUiLength(2) : 0) + (selected ? this.battleUiLength(2) : 0); dot.setPosition(x + unit.x * cellSize + cellSize / 2, y + unit.y * cellSize + cellSize / 2); dot.setDisplaySize(markerSize, markerSize); dot.setVisible(unit.hp > 0); dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, unit.hp > 0 ? 0.96 : 0.18); if (selected) { - dot.setStrokeStyle(2, 0xffffff, 1); + dot.setStrokeStyle(this.battleUiLength(2), 0xffffff, 1); } else if (objective) { - dot.setStrokeStyle(2, palette.gold, 1); + dot.setStrokeStyle(this.battleUiLength(2), palette.gold, 1); } else { dot.setStrokeStyle(0, 0x000000, 0); } @@ -6473,31 +6537,31 @@ export class BattleScene extends Phaser.Scene { } private renderPointerFeedbackHint(text: string, tone: number) { - const left = this.layout.panelX + 18; - const top = this.sideHeaderBottom() + 8; - const width = this.layout.panelWidth - 36; - const height = 32; + const left = this.layout.panelX + this.battleUiLength(18); + const top = this.sideHeaderBottom() + this.battleUiLength(8); + const width = this.layout.panelWidth - this.battleUiLength(36); + const height = this.battleUiLength(32); if (!this.pointerFeedbackHintBg) { this.pointerFeedbackHintBg = this.add.rectangle(left, top, width, height, 0x0b1118, 0.96); this.pointerFeedbackHintBg.setOrigin(0); this.pointerFeedbackHintBg.setDepth(28); } if (!this.pointerFeedbackHintText) { - this.pointerFeedbackHintText = this.add.text(left + 12, top + 8, '', { + this.pointerFeedbackHintText = this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(8), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700', - fixedWidth: width - 24 + fixedWidth: width - this.battleUiLength(24) }); this.pointerFeedbackHintText.setDepth(29); } this.pointerFeedbackHintBg.setPosition(left, top); this.pointerFeedbackHintBg.setSize(width, height); - this.pointerFeedbackHintBg.setStrokeStyle(1, tone, 0.62); + this.pointerFeedbackHintBg.setStrokeStyle(this.battleUiLength(1), tone, 0.62); this.pointerFeedbackHintBg.setVisible(true); - this.pointerFeedbackHintText.setPosition(left + 12, top + 8); + this.pointerFeedbackHintText.setPosition(left + this.battleUiLength(12), top + this.battleUiLength(8)); this.pointerFeedbackHintText.setText(this.truncateUiText(text, 38)); this.pointerFeedbackHintText.setVisible(true); } @@ -9177,8 +9241,8 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); const depth = 90; - const width = 430; - const height = 158; + const width = this.battleUiLength(430); + const height = this.battleUiLength(158); const left = Math.floor((this.scale.width - width) / 2); const top = Math.floor((this.scale.height - height) / 2); @@ -9190,11 +9254,11 @@ export class BattleScene extends Phaser.Scene { const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.98); panel.setOrigin(0); panel.setDepth(depth + 1); - panel.setStrokeStyle(3, palette.gold, 0.9); + panel.setStrokeStyle(this.battleUiLength(3), palette.gold, 0.9); - const text = this.add.text(left + width / 2, top + 45, message, { + const text = this.add.text(left + width / 2, top + this.battleUiLength(45), message, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '21px', + fontSize: this.battleUiFontSize(21), color: '#f2e3bf', fontStyle: '700', align: 'center' @@ -9202,14 +9266,21 @@ export class BattleScene extends Phaser.Scene { text.setOrigin(0.5, 0); text.setDepth(depth + 2); - const okButton = this.add.rectangle(left + width / 2, top + 112, 112, 34, 0x253748, 0.96); + const okButton = this.add.rectangle( + left + width / 2, + top + this.battleUiLength(112), + this.battleUiLength(112), + this.battleUiLength(34), + 0x253748, + 0.96 + ); okButton.setDepth(depth + 2); - okButton.setStrokeStyle(1, palette.blue, 0.78); + okButton.setStrokeStyle(this.battleUiLength(1), palette.blue, 0.78); okButton.setInteractive({ useHandCursor: true }); - const okText = this.add.text(left + width / 2, top + 112, '확인', { + const okText = this.add.text(left + width / 2, top + this.battleUiLength(112), '확인', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', + fontSize: this.battleUiFontSize(17), color: '#ffffff', fontStyle: '700' }); @@ -11496,8 +11567,9 @@ export class BattleScene extends Phaser.Scene { const depth = 120; const panelWidth = 1040; const panelHeight = 660; - const left = Math.floor((this.scale.width - panelWidth) / 2); - const top = Math.floor((this.scale.height - panelHeight) / 2); + const left = Math.floor((battleReferenceWidth - panelWidth) / 2); + const top = Math.floor((battleReferenceHeight - panelHeight) / 2); + this.resultRoot = this.add.container(0, 0).setScale(battleFhdUiScale).setDepth(depth); const allies = battleUnits.filter((unit) => unit.faction === 'ally'); const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; @@ -11505,7 +11577,7 @@ export class BattleScene extends Phaser.Scene { const objectives = this.resultObjectives(outcome); const rewardSnapshot = this.resultCampaignRewards(outcome); - const shade = this.trackResultObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68)); + const shade = this.trackResultObject(this.add.rectangle(0, 0, battleReferenceWidth, battleReferenceHeight, 0x020406, 0.68)); shade.setOrigin(0); shade.setDepth(depth); shade.setInteractive(); @@ -11709,6 +11781,8 @@ export class BattleScene extends Phaser.Scene { this.resultSortieOrderButton = undefined; this.resultObjects.forEach((object) => object.destroy()); this.resultObjects = []; + this.resultRoot?.destroy(); + this.resultRoot = undefined; this.resultGaugeAnimations = []; this.resultSettlementText = undefined; this.resultCoreResonanceSummaryText = undefined; @@ -12294,8 +12368,8 @@ export class BattleScene extends Phaser.Scene { const width = 820; const rowHeight = 46; const height = 178 + (result.progress.length + 1) * rowHeight; - const x = Math.floor((this.scale.width - width) / 2); - const y = Math.floor((this.scale.height - height) / 2); + const x = Math.floor((battleReferenceWidth - width) / 2); + const y = Math.floor((battleReferenceHeight - height) / 2); const accent = result.achieved ? 0x59d18c : 0xc87552; const bg = this.trackResultSortieOrder(this.add.rectangle(x, y, width, height, 0x0b1219, 0.995)); bg.setOrigin(0); @@ -12432,6 +12506,10 @@ export class BattleScene extends Phaser.Scene { } private trackResultSortieOrder(object: T) { + if (this.resultRoot) { + this.resultRoot.add(object); + this.scheduleUiContainerDepthSort(this.resultRoot); + } this.resultSortieOrderObjects.push(object); return object; } @@ -12460,7 +12538,7 @@ export class BattleScene extends Phaser.Scene { const depth = 130; const width = 980; const height = 420; - const x = Math.floor((this.scale.width - width) / 2); + const x = Math.floor((battleReferenceWidth - width) / 2); const y = 200; const bg = this.trackResultFormationReview(this.add.rectangle(x, y, width, height, 0x0b1219, 0.995)); bg.setOrigin(0); @@ -12716,6 +12794,10 @@ export class BattleScene extends Phaser.Scene { } private trackResultFormationReview(object: T) { + if (this.resultRoot) { + this.resultRoot.add(object); + this.scheduleUiContainerDepthSort(this.resultRoot); + } this.resultFormationReviewObjects.push(object); return object; } @@ -13428,7 +13510,7 @@ export class BattleScene extends Phaser.Scene { private async playResultLevelUpSpotlight(animation: ResultGaugeAnimation) { const stats = this.statGainLine(animation.statGains); const depth = animation.flashArea.depth + 12; - const container = this.trackResultObject(this.add.container(this.scale.width / 2, 146)); + const container = this.trackResultObject(this.add.container(battleReferenceWidth / 2, 146)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.9); @@ -13528,11 +13610,11 @@ export class BattleScene extends Phaser.Scene { } private createResultLevelUpBurst(x: number, y: number, depth: number) { + const halo = this.trackResultObject(this.add.circle(x, y + 4, 22, 0xffdf7b, 0.12)); + halo.setDepth(depth - 1); const ring = this.trackResultObject(this.add.circle(x, y + 2, 33)); ring.setStrokeStyle(3, 0xffdf7b, 0.84); ring.setDepth(depth); - const halo = this.trackResultObject(this.add.circle(x, y + 4, 22, 0xffdf7b, 0.12)); - halo.setDepth(depth - 1); const weaponFlash = this.trackResultObject(this.add.star(x + 22, y - 28, 5, 4, 15, 0xfff2b0, 0.92)); weaponFlash.setDepth(depth + 2); this.tweens.add({ targets: ring, angle: 180, alpha: 0, duration: 680, ease: 'Sine.easeOut' }); @@ -13604,10 +13686,27 @@ export class BattleScene extends Phaser.Scene { } private trackResultObject(object: T) { + if (this.resultRoot) { + this.resultRoot.add(object); + this.scheduleUiContainerDepthSort(this.resultRoot); + } this.resultObjects.push(object); return object; } + private scheduleUiContainerDepthSort(container: Phaser.GameObjects.Container) { + if (this.pendingUiContainerSorts.has(container)) { + return; + } + this.pendingUiContainerSorts.add(container); + queueMicrotask(() => { + this.pendingUiContainerSorts.delete(container); + if (container.active) { + container.sort('depth'); + } + }); + } + private attackableTargets(attacker: UnitData) { return this.damageableTargets(attacker, 'attack'); } @@ -15460,22 +15559,22 @@ export class BattleScene extends Phaser.Scene { this.saveSlotPanelMode = mode; this.clearPointerFeedback(); const depth = 70; - const width = 520; - const height = 312; - const left = this.layout.mapX + 118; - const top = this.layout.mapY + 82; + const width = this.battleUiLength(520); + const height = this.battleUiLength(312); + const left = this.layout.mapX + this.battleUiLength(118); + const top = this.layout.mapY + this.battleUiLength(82); const campaignSlots = listCampaignSaveSlots(); const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98); panel.setOrigin(0); panel.setDepth(depth); - panel.setStrokeStyle(2, mode === 'save' ? palette.gold : palette.blue, 0.9); + panel.setStrokeStyle(this.battleUiLength(2), mode === 'save' ? palette.gold : palette.blue, 0.9); panel.setInteractive(); this.saveSlotPanelObjects.push(panel); - const title = this.add.text(left + 22, top + 18, mode === 'save' ? '전투 저장 슬롯' : '전투 불러오기 슬롯', { + const title = this.add.text(left + this.battleUiLength(22), top + this.battleUiLength(18), mode === 'save' ? '전투 저장 슬롯' : '전투 불러오기 슬롯', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: this.battleUiFontSize(22), color: '#f2e3bf', fontStyle: '700' }); @@ -15483,12 +15582,12 @@ export class BattleScene extends Phaser.Scene { this.saveSlotPanelObjects.push(title); const guide = this.add.text( - left + 22, - top + 50, + left + this.battleUiLength(22), + top + this.battleUiLength(50), mode === 'save' ? '전투와 캠페인을 함께 저장합니다. · 1~3키' : '저장된 전투와 캠페인을 불러옵니다. · 1~3키', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: this.battleUiFontSize(13), color: '#aeb7c2' } ); @@ -15496,33 +15595,51 @@ export class BattleScene extends Phaser.Scene { this.saveSlotPanelObjects.push(guide); for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { - const rowTop = top + 82 + (slot - 1) * 62; + const rowTop = top + this.battleUiLength(82) + (slot - 1) * this.battleUiLength(62); const battleSave = this.readBattleSaveState(slot); const campaignSlot = campaignSlots.find((candidate) => candidate.slot === slot); const disabled = mode === 'load' && !battleSave; - const row = this.add.rectangle(left + 18, rowTop, width - 36, 50, disabled ? 0x121820 : 0x172230, disabled ? 0.76 : 0.94); + const row = this.add.rectangle( + left + this.battleUiLength(18), + rowTop, + width - this.battleUiLength(36), + this.battleUiLength(50), + disabled ? 0x121820 : 0x172230, + disabled ? 0.76 : 0.94 + ); row.setOrigin(0); row.setDepth(depth + 1); - row.setStrokeStyle(1, disabled ? 0x53606c : slot === getCampaignState().activeSaveSlot ? palette.gold : palette.blue, disabled ? 0.34 : 0.72); + row.setStrokeStyle( + this.battleUiLength(1), + disabled ? 0x53606c : slot === getCampaignState().activeSaveSlot ? palette.gold : palette.blue, + disabled ? 0.34 : 0.72 + ); this.saveSlotPanelObjects.push(row); - const titleText = this.add.text(left + 34, rowTop + 8, `슬롯 ${slot}`, { + const titleText = this.add.text(left + this.battleUiLength(34), rowTop + this.battleUiLength(8), `슬롯 ${slot}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: this.battleUiFontSize(16), color: disabled ? '#78818a' : '#f2e3bf', fontStyle: '700' }); titleText.setDepth(depth + 2); this.saveSlotPanelObjects.push(titleText); - const keycap = this.add.rectangle(left + 86, rowTop + 25, 26, 22, disabled ? 0x151c25 : 0x263647, disabled ? 0.58 : 0.9); + const keycap = this.add.rectangle( + left + this.battleUiLength(86), + rowTop + this.battleUiLength(25), + this.battleUiLength(26), + this.battleUiLength(22), + disabled ? 0x151c25 : 0x263647, + disabled ? 0.58 : 0.9 + ); keycap.setDepth(depth + 2); - keycap.setStrokeStyle(1, disabled ? 0x53606c : palette.blue, disabled ? 0.24 : 0.56); + keycap.setStrokeStyle(this.battleUiLength(1), disabled ? 0x53606c : palette.blue, disabled ? 0.24 : 0.56); this.saveSlotPanelObjects.push(keycap); - const keyText = this.add.text(left + 86, rowTop + 14, String(slot), { + const keyText = this.add.text(left + this.battleUiLength(86), rowTop + this.battleUiLength(14), String(slot), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(12), color: disabled ? '#737c86' : '#f2e3bf', fontStyle: '700' }); @@ -15536,11 +15653,11 @@ export class BattleScene extends Phaser.Scene { const campaignSummary = campaignSlot?.exists ? `${campaignSlot.progressMeta ?? '캠페인'} · ${campaignSlot.progressTitle ?? campaignSlot.battleTitle ?? '진행 중'} · 군자금 ${campaignSlot.gold ?? 0}` : '캠페인 저장 없음'; - const detail = this.add.text(left + 104, rowTop + 9, `${battleSummary}\n${campaignSummary}`, { + const detail = this.add.text(left + this.battleUiLength(104), rowTop + this.battleUiLength(9), `${battleSummary}\n${campaignSummary}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: this.battleUiFontSize(12), color: disabled ? '#6d7580' : '#d4dce6', - lineSpacing: 3 + lineSpacing: this.battleUiLength(3) }); detail.setDepth(depth + 2); this.saveSlotPanelObjects.push(detail); @@ -15564,9 +15681,9 @@ export class BattleScene extends Phaser.Scene { } } - const close = this.add.text(left + width - 52, top + 20, '닫기', { + const close = this.add.text(left + width - this.battleUiLength(52), top + this.battleUiLength(20), '닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: this.battleUiFontSize(14), color: '#d8b15f', fontStyle: '700' }); @@ -16696,6 +16813,23 @@ export class BattleScene extends Phaser.Scene { }; } + private scaleBattleReferenceBounds(bounds: SortieOrderHudBounds): SortieOrderHudBounds { + return { + x: bounds.x * battleFhdUiScale, + y: bounds.y * battleFhdUiScale, + width: bounds.width * battleFhdUiScale, + height: bounds.height * battleFhdUiScale + }; + } + + private battleUiLength(value: number) { + return value * battleFhdUiScale; + } + + private battleUiFontSize(value: number) { + return `${this.battleUiLength(value)}px`; + } + private createCombatCutInStage( mode: CombatCutInStageMode, actor: UnitData, @@ -16838,11 +16972,11 @@ export class BattleScene extends Phaser.Scene { focalTile, sourceCropPx: { ...sourceRect }, textureBoundsPx: { x: 0, y: 0, width: textureWidth, height: textureHeight }, - panelBounds: { ...panelBounds }, - stageBounds: { ...bounds }, + panelBounds: this.scaleBattleReferenceBounds(panelBounds), + stageBounds: this.scaleBattleReferenceBounds(bounds), sourceRect: { ...sourceRect }, crop: { ...sourceRect }, - bounds: { ...bounds }, + bounds: this.scaleBattleReferenceBounds(bounds), mapBacked }; this.combatCutInStageActive = this.cloneCombatCutInStageSnapshot(snapshot); @@ -16855,14 +16989,15 @@ export class BattleScene extends Phaser.Scene { const panelWidth = 940; const panelHeight = 560; - const left = Math.floor((this.scale.width - panelWidth) / 2); - const top = Math.floor((this.scale.height - panelHeight) / 2); + const left = Math.floor((battleReferenceWidth - panelWidth) / 2); + const top = Math.floor((battleReferenceHeight - panelHeight) / 2); const depth = 70; + this.combatCutInRoot = this.add.container(0, 0).setScale(battleFhdUiScale).setDepth(depth); const attackerX = left + 190; const defenderX = left + panelWidth - 190; const groundY = top + 208; - const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.62)); + const dim = this.trackCombatObject(this.add.rectangle(0, 0, battleReferenceWidth, battleReferenceHeight, 0x020406, 0.62)); dim.setOrigin(0); dim.setDepth(depth); @@ -17500,13 +17635,14 @@ export class BattleScene extends Phaser.Scene { const panelWidth = 760; const panelHeight = 500; - const left = Math.floor((this.scale.width - panelWidth) / 2); - const top = Math.floor((this.scale.height - panelHeight) / 2); + const left = Math.floor((battleReferenceWidth - panelWidth) / 2); + const top = Math.floor((battleReferenceHeight - panelHeight) / 2); const depth = 70; + this.combatCutInRoot = this.add.container(0, 0).setScale(battleFhdUiScale).setDepth(depth); const supportGroundY = top + 190; const supportSpriteY = supportGroundY - 48; - const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.58)); + const dim = this.trackCombatObject(this.add.rectangle(0, 0, battleReferenceWidth, battleReferenceHeight, 0x020406, 0.58)); dim.setOrigin(0); dim.setDepth(depth); @@ -18599,7 +18735,7 @@ export class BattleScene extends Phaser.Scene { const projectile = this.createSpecialProjectile(motionKind, attackerX + direction * 78, this.specialProjectileY(motionKind, groundY), direction, depth); projectile.setDepth(depth); - this.combatCutInObjects.push(projectile); + this.trackCombatObject(projectile); this.tweens.add({ targets: projectile, x: defenderX - direction * (result.hit ? 72 : 12), @@ -19604,6 +19740,10 @@ export class BattleScene extends Phaser.Scene { } private trackCombatObject(object: T) { + if (this.combatCutInRoot) { + this.combatCutInRoot.add(object); + this.scheduleUiContainerDepthSort(this.combatCutInRoot); + } this.combatCutInObjects.push(object); return object; } @@ -19615,6 +19755,8 @@ export class BattleScene extends Phaser.Scene { } }); this.combatCutInObjects = []; + this.combatCutInRoot?.destroy(); + this.combatCutInRoot = undefined; this.combatCutInStageActive = undefined; } @@ -21399,19 +21541,19 @@ export class BattleScene extends Phaser.Scene { this.hideTurnEndPrompt(); this.turnPromptMode = options.mode ?? 'turn-end'; - const width = 388; - const height = 162; + const width = this.battleUiLength(388); + const height = this.battleUiLength(162); const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2; const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2; const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96); panel.setOrigin(0); panel.setDepth(40); - panel.setStrokeStyle(2, palette.gold, 0.9); + panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9); this.turnPromptObjects.push(panel); - const title = this.add.text(left + width / 2, top + 26, options.title ?? '모든 아군의 행동이 종료되었습니다.', { + const title = this.add.text(left + width / 2, top + this.battleUiLength(26), options.title ?? '모든 아군의 행동이 종료되었습니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '20px', + fontSize: this.battleUiFontSize(20), color: '#f2e3bf', fontStyle: '700' }); @@ -21419,12 +21561,12 @@ export class BattleScene extends Phaser.Scene { title.setDepth(41); this.turnPromptObjects.push(title); - const body = this.add.text(left + width / 2, top + 58, options.body ?? this.enemyIntentTurnEndBody(), { + const body = this.add.text(left + width / 2, top + this.battleUiLength(58), options.body ?? this.enemyIntentTurnEndBody(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', + fontSize: this.battleUiFontSize(17), color: '#d4dce6', align: 'center', - wordWrap: { width: width - 44, useAdvancedWrap: true } + wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true } }); body.setOrigin(0.5); body.setDepth(41); @@ -21436,25 +21578,25 @@ export class BattleScene extends Phaser.Scene { const secondaryAction = options.secondaryAction ?? (() => this.hideTurnEndPrompt()); this.turnPromptPrimaryAction = primaryAction; this.turnPromptSecondaryAction = secondaryAction; - const buttonWidth = 136; + const buttonWidth = this.battleUiLength(136); const buttons = [ - { label: primaryLabel, keyHint: 'Enter', x: left + 119, primary: true }, - { label: secondaryLabel, keyHint: 'Esc', x: left + 269, primary: false } + { label: primaryLabel, keyHint: 'Enter', x: left + this.battleUiLength(119), primary: true }, + { label: secondaryLabel, keyHint: 'Esc', x: left + this.battleUiLength(269), primary: false } ]; buttons.forEach(({ label, keyHint, x, primary }) => { - const bg = this.add.rectangle(x, top + 116, buttonWidth, 36, 0x1a2630, 0.95); + const bg = this.add.rectangle(x, top + this.battleUiLength(116), buttonWidth, this.battleUiLength(36), 0x1a2630, 0.95); bg.setDepth(41); - bg.setStrokeStyle(1, primary ? palette.gold : palette.blue, 0.72); + bg.setStrokeStyle(this.battleUiLength(1), primary ? palette.gold : palette.blue, 0.72); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95)); bg.on('pointerdown', () => (primary ? this.activateTurnPromptPrimaryAction(true) : this.activateTurnPromptSecondaryAction(true))); this.turnPromptObjects.push(bg); - const text = this.add.text(x, top + 116, label, { + const text = this.add.text(x, top + this.battleUiLength(116), label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: this.battleUiFontSize(16), color: '#f2e3bf', fontStyle: '700' }); @@ -21464,9 +21606,9 @@ export class BattleScene extends Phaser.Scene { text.on('pointerdown', () => (primary ? this.activateTurnPromptPrimaryAction(true) : this.activateTurnPromptSecondaryAction(true))); this.turnPromptObjects.push(text); - const hint = this.add.text(x, top + 143, keyHint, { + const hint = this.add.text(x, top + this.battleUiLength(143), keyHint, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '10px', + fontSize: this.battleUiFontSize(10), color: primary ? '#cdbb8a' : '#91a7bd', fontStyle: '700' }); @@ -21617,7 +21759,7 @@ export class BattleScene extends Phaser.Scene { } private sideHeaderBottom() { - const fallbackBottom = this.layout.panelY + 144; + const fallbackBottom = this.layout.panelY + this.battleUiLength(144); const objectiveBottom = this.objectiveTrackerText?.visible ? this.objectiveTrackerText.y + this.objectiveTrackerText.height : fallbackBottom; @@ -21631,12 +21773,13 @@ export class BattleScene extends Phaser.Scene { } private sideContentTop() { - return this.sideHeaderBottom() + 20; + return this.sideHeaderBottom() + this.battleUiLength(20); } private sideContentBottom(padding = 16) { - const panelBottom = this.layout.panelY + this.layout.panelHeight - padding; - return this.miniMapVisible && this.miniMapLayout ? this.miniMapLayout.frameY - padding : panelBottom; + const scaledPadding = this.battleUiLength(padding); + const panelBottom = this.layout.panelY + this.layout.panelHeight - scaledPadding; + return this.miniMapVisible && this.miniMapLayout ? this.miniMapLayout.frameY - scaledPadding : panelBottom; } private recentActionLogPlacement(minTop: number) { @@ -23325,11 +23468,15 @@ export class BattleScene extends Phaser.Scene { cellSize: miniMap.cellSize, objectiveUnitId: leaderUnitId, objectiveHighlighted: Boolean( - objectiveDot?.visible && objectiveDot.lineWidth === 2 && objectiveDot.strokeColor === palette.gold + objectiveDot?.visible && + objectiveDot.lineWidth >= this.battleUiLength(2) && + objectiveDot.strokeColor === palette.gold ), selectedUnitId: this.selectedUnit?.id ?? null, selectedHighlighted: Boolean( - selectedDot?.visible && selectedDot.lineWidth === 2 && selectedDot.strokeColor === 0xffffff + selectedDot?.visible && + selectedDot.lineWidth >= this.battleUiLength(2) && + selectedDot.strokeColor === 0xffffff ) } : null, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 68b907d..fa5eb56 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -167,6 +167,10 @@ import { import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; +const campLegacyCanvasWidth = 1280; +const campLegacyCanvasHeight = 720; +const campFhdUiScale = 1.5; + type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress'; type CampDialogue = { @@ -11052,6 +11056,7 @@ const campVisits: CampVisitDefinition[] = [ ]; export class CampScene extends Phaser.Scene { + private readonly scaledCampUiObjects = new WeakSet(); private campaign?: CampaignState; private report?: FirstBattleReport; private selectedUnitId = 'liu-bei'; @@ -11189,21 +11194,21 @@ export class CampScene extends Phaser.Scene { const { width, height } = this.scale; this.add.image(width / 2, height / 2, 'story-militia').setDisplaySize(width * 1.08, height * 1.08).setAlpha(0.62); this.add.rectangle(0, 0, width, height, 0x06090d, 0.54).setOrigin(0); - this.add.rectangle(0, 0, width, 84, 0x06090d, 0.52).setOrigin(0); + this.scaleLegacyCampUi(this.add.rectangle(0, 0, campLegacyCanvasWidth, 84, 0x06090d, 0.52)).setOrigin(0); - this.add.text(42, 28, this.currentCampTitle(), { + this.scaleLegacyCampUi(this.add.text(42, 28, this.currentCampTitle(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '30px', color: '#f2e3bf', fontStyle: '700', stroke: '#05070a', strokeThickness: 4 - }); - this.add.text(42, 64, this.reportSummary(), { + })); + this.scaleLegacyCampUi(this.add.text(42, 64, this.reportSummary(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d4dce6' - }); + })); this.renderStaticButtons(); this.render(); @@ -11237,13 +11242,13 @@ export class CampScene extends Phaser.Scene { return; } - const loadingText = this.add.text(this.scale.width / 2, this.scale.height / 2, '군영을 정비하는 중...', { + const loadingText = this.scaleLegacyCampUi(this.add.text(campLegacyCanvasWidth / 2, campLegacyCanvasHeight / 2, '군영을 정비하는 중...', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#f5e6b8', stroke: '#05070a', strokeThickness: 4 - }); + })); loadingText.setOrigin(0.5); this.load.once('complete', () => { @@ -12294,7 +12299,7 @@ export class CampScene extends Phaser.Scene { const depth = 48; const width = 430; const height = 252; - const left = this.scale.width - width - 36; + const left = campLegacyCanvasWidth - width - 36; const top = 70; const activeSlot = this.campaign?.activeSaveSlot ?? getCampaignState().activeSaveSlot; const slots = listCampaignSaveSlots(); @@ -12304,11 +12309,11 @@ export class CampScene extends Phaser.Scene { panel.setDepth(depth); panel.setStrokeStyle(2, palette.gold, 0.86); panel.setInteractive(); - this.saveSlotObjects.push(panel); + this.trackCampSaveSlot(panel); const title = this.add.text(left + 22, top + 16, '군영 저장 슬롯', this.textStyle(22, '#f2e3bf', true)); title.setDepth(depth + 1); - this.saveSlotObjects.push(title); + this.trackCampSaveSlot(title); const guide = this.add.text( left + 22, @@ -12317,7 +12322,7 @@ export class CampScene extends Phaser.Scene { this.textStyle(13, '#aeb7c2') ); guide.setDepth(depth + 1); - this.saveSlotObjects.push(guide); + this.trackCampSaveSlot(guide); for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { const summary = slots.find((candidate) => candidate.slot === slot) ?? { slot, exists: false }; @@ -12331,7 +12336,7 @@ export class CampScene extends Phaser.Scene { soundDirector.playSelect(); this.hideCampSaveSlotPanel(); }); - this.saveSlotObjects.push(close); + this.trackCampSaveSlot(close); } private renderCampSaveSlotRow( @@ -12347,21 +12352,21 @@ export class CampScene extends Phaser.Scene { row.setOrigin(0); row.setDepth(depth); row.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.86 : 0.62); - this.saveSlotObjects.push(row); + this.trackCampSaveSlot(row); const title = this.add.text(x + 14, y + 8, `슬롯 ${summary.slot}`, this.textStyle(15, '#f2e3bf', true)); title.setDepth(depth + 1); - this.saveSlotObjects.push(title); + this.trackCampSaveSlot(title); const keycap = this.add.rectangle(x + 66, y + 22, 26, 20, active ? 0x314a38 : 0x263647, active ? 0.98 : 0.88); keycap.setDepth(depth + 1); keycap.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.76 : 0.52); - this.saveSlotObjects.push(keycap); + this.trackCampSaveSlot(keycap); const keyText = this.add.text(x + 66, y + 13, String(summary.slot), this.textStyle(11, active ? '#f2e3bf' : '#cbd6e2', true)); keyText.setOrigin(0.5, 0); keyText.setDepth(depth + 2); - this.saveSlotObjects.push(keyText); + this.trackCampSaveSlot(keyText); const detail = this.add.text( x + 92, @@ -12370,7 +12375,7 @@ export class CampScene extends Phaser.Scene { this.textStyle(12, summary.exists ? '#d4dce6' : '#87919c', summary.exists) ); detail.setDepth(depth + 1); - this.saveSlotObjects.push(detail); + this.trackCampSaveSlot(detail); const meta = this.add.text( x + 92, @@ -12379,12 +12384,12 @@ export class CampScene extends Phaser.Scene { this.textStyle(10, summary.exists ? '#9fb0bf' : '#d8b15f', true) ); meta.setDepth(depth + 1); - this.saveSlotObjects.push(meta); + this.trackCampSaveSlot(meta); const saveLabel = this.add.text(x + width - 50, y + 13, active ? '현재' : '저장', this.textStyle(12, active ? '#a8ffd0' : '#ffdf7b', true)); saveLabel.setOrigin(0.5, 0); saveLabel.setDepth(depth + 1); - this.saveSlotObjects.push(saveLabel); + this.trackCampSaveSlot(saveLabel); const run = () => this.requestSaveCampToSlot(summary.slot); [row, title, keycap, keyText, detail, meta, saveLabel].forEach((object) => { @@ -12441,25 +12446,25 @@ export class CampScene extends Phaser.Scene { const depth = 64; const width = 386; const height = 166; - const x = this.scale.width - width - 58; + const x = campLegacyCanvasWidth - width - 58; const y = 158; - const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.34); + const shade = this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.34); shade.setOrigin(0); shade.setDepth(depth - 1); shade.setInteractive(); - this.saveSlotConfirmObjects.push(shade); + this.trackCampSaveConfirm(shade); const panel = this.add.rectangle(x, y, width, height, 0x111a24, 0.99); panel.setOrigin(0); panel.setDepth(depth); panel.setStrokeStyle(2, palette.gold, 0.9); panel.setInteractive(); - this.saveSlotConfirmObjects.push(panel); + this.trackCampSaveConfirm(panel); const title = this.add.text(x + 22, y + 18, `슬롯 ${summary.slot} 덮어쓰기`, this.textStyle(20, '#f2e3bf', true)); title.setDepth(depth + 1); - this.saveSlotConfirmObjects.push(title); + this.trackCampSaveConfirm(title); const detail = this.add.text( x + 22, @@ -12469,15 +12474,15 @@ export class CampScene extends Phaser.Scene { ); detail.setDepth(depth + 1); detail.setLineSpacing(5); - this.saveSlotConfirmObjects.push(detail); + this.trackCampSaveConfirm(detail); const message = this.add.text(x + 22, y + 96, '현재 군영 진행으로 이 슬롯을 교체합니다.', this.textStyle(12, '#ffdf7b', true)); message.setDepth(depth + 1); - this.saveSlotConfirmObjects.push(message); + this.trackCampSaveConfirm(message); const hint = this.add.text(x + 22, y + 112, 'Enter 확정 · Esc 취소', this.textStyle(11, '#9fb0bf', true)); hint.setDepth(depth + 1); - this.saveSlotConfirmObjects.push(hint); + this.trackCampSaveConfirm(hint); const confirm = this.add.text(x + width - 174, y + 126, '덮어쓰기', this.textStyle(14, '#f2e3bf', true)); confirm.setDepth(depth + 1); @@ -12485,7 +12490,7 @@ export class CampScene extends Phaser.Scene { confirm.setBackgroundColor('#6d4420'); confirm.setInteractive({ useHandCursor: true }); confirm.on('pointerdown', () => this.confirmCampSaveOverwrite()); - this.saveSlotConfirmObjects.push(confirm); + this.trackCampSaveConfirm(confirm); const cancel = this.add.text(x + width - 70, y + 126, '취소', this.textStyle(14, '#d8b15f', true)); cancel.setDepth(depth + 1); @@ -12496,7 +12501,7 @@ export class CampScene extends Phaser.Scene { soundDirector.playSelect(); this.hideCampSaveConfirm(); }); - this.saveSlotConfirmObjects.push(cancel); + this.trackCampSaveConfirm(cancel); } private confirmCampSaveOverwrite() { @@ -12621,7 +12626,7 @@ export class CampScene extends Phaser.Scene { } private addTabButton(label: string, tab: CampTab, x: number, y: number) { - const bg = this.add.rectangle(x, y, 76, 34, 0x182431, 0.94); + const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, 76, 34, 0x182431, 0.94)); bg.setStrokeStyle(1, palette.blue, 0.62); bg.setInteractive({ useHandCursor: true }); bg.on('pointerdown', () => { @@ -12630,12 +12635,12 @@ export class CampScene extends Phaser.Scene { this.render(); }); - const text = this.add.text(x, y, label, { + const text = this.scaleLegacyCampUi(this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f2e3bf', fontStyle: '700' - }); + })); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); text.on('pointerdown', () => { @@ -12657,19 +12662,19 @@ export class CampScene extends Phaser.Scene { } private addCommandButton(label: string, x: number, y: number, width: number, action: () => void) { - const bg = this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96); + const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96)); bg.setStrokeStyle(1, palette.gold, 0.8); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96)); bg.on('pointerdown', action); - const text = this.add.text(x, y, label, { + const text = this.scaleLegacyCampUi(this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f2e3bf', fontStyle: '700' - }); + })); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); text.on('pointerdown', action); @@ -12729,8 +12734,8 @@ export class CampScene extends Phaser.Scene { const depth = 40; const width = 1188; const height = 676; - const x = Math.floor((this.scale.width - width) / 2); - const y = Math.floor((this.scale.height - height) / 2); + const x = Math.floor((campLegacyCanvasWidth - width) / 2); + const y = Math.floor((campLegacyCanvasHeight - height) / 2); const flow = this.currentSortieFlow(); const isBattleSortie = Boolean(flow.nextBattleId); const isFirstSortieSlice = this.isFirstSortiePrepSlice(flow); @@ -12759,7 +12764,7 @@ export class CampScene extends Phaser.Scene { ? '마지막 군영 기록을 정리하고 언제든 엔딩 화면으로 돌아갈 수 있습니다.' : '군영 대화와 장부를 정리한 뒤 다음 의정에 함께할 무장을 선택합니다.'; - const shade = this.trackSortie(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68)); + const shade = this.trackSortie(this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.68)); shade.setOrigin(0); shade.setDepth(depth); shade.setInteractive(); @@ -13040,7 +13045,7 @@ export class CampScene extends Phaser.Scene { const cropWidth = Math.min(sourceWidth, width / scale); const cropHeight = Math.min(sourceHeight, height / scale); artwork.setCrop((sourceWidth - cropWidth) / 2, (sourceHeight - cropHeight) / 2, cropWidth, cropHeight); - artwork.setScale(scale); + artwork.setScale(scale * campFhdUiScale); artwork.setTint(0xaeb9b4); artwork.setAlpha(alpha); artwork.setDepth(depth); @@ -13205,7 +13210,7 @@ export class CampScene extends Phaser.Scene { accentColor ); if (pinnedSwapCandidate && portraitView.image) { - portraitView.image.setDisplaySize(74, 74); + portraitView.image.setDisplaySize(this.campUiLength(74), this.campUiLength(74)); } card.on('pointerover', () => { this.sortieHoveredUnitId = unit.id; @@ -13215,7 +13220,7 @@ export class CampScene extends Phaser.Scene { accent.setFillStyle(palette.gold, 0.98); portraitView.frame.setStrokeStyle(2, palette.gold, 0.98); if (availability.available && portraitView.image) { - portraitView.image.setDisplaySize(74, 74); + portraitView.image.setDisplaySize(this.campUiLength(74), this.campUiLength(74)); } }); card.on('pointerout', () => { @@ -13228,14 +13233,14 @@ export class CampScene extends Phaser.Scene { card.setStrokeStyle(2, palette.gold, 0.98); accent.setFillStyle(palette.gold, 0.98); portraitView.frame.setStrokeStyle(2, palette.gold, 0.98); - portraitView.image?.setDisplaySize(74, 74); + portraitView.image?.setDisplaySize(this.campUiLength(74), this.campUiLength(74)); return; } card.setFillStyle(fill, !availability.available ? 0.62 : 0.96); card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha); accent.setFillStyle(accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34); portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42); - portraitView.image?.setDisplaySize(70, 70); + portraitView.image?.setDisplaySize(this.campUiLength(70), this.campUiLength(70)); }); const statusLabel = !availability.available ? required ? `필수 · ${availability.label}` : availability.label @@ -13291,9 +13296,12 @@ export class CampScene extends Phaser.Scene { pageSize: sortiePortraitRosterPageSize, totalCount: displayUnits.length, visibleUnitIds: visibleUnits.map((unit) => unit.id), - previousButtonBounds, - nextButtonBounds, - cardBounds, + previousButtonBounds: this.scaleCampUiBounds(previousButtonBounds), + nextButtonBounds: this.scaleCampUiBounds(nextButtonBounds), + cardBounds: cardBounds.map(({ unitId, ...bounds }) => ({ + unitId, + ...this.scaleCampUiBounds(bounds) + })), previousEnabled, nextEnabled }; @@ -14164,7 +14172,7 @@ export class CampScene extends Phaser.Scene { ).find((texture): texture is string => Boolean(texture && this.textures.exists(texture))); if (portraitTexture && this.textures.exists(portraitTexture)) { const portrait = this.trackSortie(this.add.image(x, y, portraitTexture)); - portrait.setDisplaySize(size, size); + portrait.setDisplaySize(this.campUiLength(size), this.campUiLength(size)); portrait.setAlpha(alpha); portrait.setDepth(depth + 1); return { frame, image: portrait }; @@ -15065,7 +15073,7 @@ export class CampScene extends Phaser.Scene { } const icon = this.trackSortie(this.add.image(x, y, battleUiIconTextureKey, battleUiIconFrames[iconKey])); - icon.setDisplaySize(size, size); + icon.setDisplaySize(this.campUiLength(size), this.campUiLength(size)); icon.setAlpha(alpha); icon.setDepth(depth); } @@ -15152,7 +15160,7 @@ export class CampScene extends Phaser.Scene { iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, selected ? 0.62 : 0.34); iconFrame.setDepth(depth); const icon = this.trackSortie(this.add.image(x + index * spacing, y, `item-${item.id}`)); - icon.setDisplaySize(iconSize, iconSize); + icon.setDisplaySize(this.campUiLength(iconSize), this.campUiLength(iconSize)); icon.setAlpha(selected ? 1 : 0.52); icon.setDepth(depth + 1); }); @@ -17521,6 +17529,7 @@ export class CampScene extends Phaser.Scene { } private trackSortie(object: T) { + this.scaleLegacyCampUi(object); this.sortieObjects.push(object); return object; } @@ -17587,7 +17596,7 @@ export class CampScene extends Phaser.Scene { const portraitSize = paginated ? 48 : compact ? 40 : 84; if (portraitTexture && this.textures.exists(portraitTexture)) { const portrait = this.track(this.add.image(portraitCenterX, portraitCenterY, portraitTexture)); - portrait.setDisplaySize(portraitSize, portraitSize); + portrait.setDisplaySize(this.campUiLength(portraitSize), this.campUiLength(portraitSize)); } else { this.renderUnitFallbackPortrait(portraitCenterX, portraitCenterY, paginated ? 24 : compact ? 20 : 40, unit); } @@ -17674,10 +17683,10 @@ export class CampScene extends Phaser.Scene { totalCount: allies.length, visibleUnitIds: visibleAllies.map((unit) => unit.id), listBounds: { - x, - y: listY, - width, - height: visibleAllies.length > 0 ? (visibleAllies.length - 1) * rowGap + cardHeight : 0 + x: this.campUiLength(x), + y: this.campUiLength(listY), + width: this.campUiLength(width), + height: this.campUiLength(visibleAllies.length > 0 ? (visibleAllies.length - 1) * rowGap + cardHeight : 0) }, navigationBounds, rowBounds, @@ -18227,10 +18236,10 @@ export class CampScene extends Phaser.Scene { const depth = 52; const width = 980; const height = 420; - const x = Math.floor((this.scale.width - width) / 2); + const x = Math.floor((campLegacyCanvasWidth - width) / 2); const y = 150; - const shade = this.trackReportFormationReview(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.76)); + const shade = this.trackReportFormationReview(this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.76)); shade.setOrigin(0); shade.setDepth(depth); shade.setInteractive(); @@ -18508,6 +18517,7 @@ export class CampScene extends Phaser.Scene { } private trackReportFormationReview(object: T) { + this.scaleLegacyCampUi(object); this.reportFormationReviewObjects.push(object); return object; } @@ -18560,7 +18570,7 @@ export class CampScene extends Phaser.Scene { const y = 22; const width = 1188; const height = 676; - const shade = this.trackReportFormationHistory(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.8)); + const shade = this.trackReportFormationHistory(this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.8)); shade.setOrigin(0); shade.setDepth(depth); shade.setInteractive(); @@ -19174,6 +19184,7 @@ export class CampScene extends Phaser.Scene { } private trackReportFormationHistory(object: T) { + this.scaleLegacyCampUi(object); this.reportFormationHistoryObjects.push(object); return object; } @@ -19431,7 +19442,7 @@ export class CampScene extends Phaser.Scene { portraitFrame.setStrokeStyle(2, palette.gold, 0.62); if (portraitTexture && this.textures.exists(portraitTexture)) { const portrait = this.track(this.add.image(x + 84, y + 90, portraitTexture)); - portrait.setDisplaySize(112, 112); + portrait.setDisplaySize(this.campUiLength(112), this.campUiLength(112)); } else { this.renderUnitFallbackPortrait(x + 84, y + 90, 48, unit); } @@ -19762,7 +19773,7 @@ export class CampScene extends Phaser.Scene { const iconFrame = this.track(this.add.rectangle(x + 20, y + height / 2, 28, 28, 0x0a1017, 0.92)); iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54); const icon = this.track(this.add.image(x + 20, y + height / 2, `item-${entry.item.id}`)); - icon.setDisplaySize(22, 22); + icon.setDisplaySize(this.campUiLength(22), this.campUiLength(22)); icon.setAlpha(canEquip ? 1 : 0.52); const nameColor = sameItem ? '#87919c' : entry.item.rank === 'treasure' ? '#f4dfad' : '#f2e3bf'; @@ -19798,7 +19809,7 @@ export class CampScene extends Phaser.Scene { equipped.forEach((entry, index) => { const rowY = y + 42 + index * 30; const icon = this.track(this.add.image(x + 20, rowY + 8, `item-${entry.item.id}`)); - icon.setDisplaySize(20, 20); + icon.setDisplaySize(this.campUiLength(20), this.campUiLength(20)); this.track(this.add.text(x + 36, rowY, `${equipmentSlotLabels[entry.slot]} Lv${entry.state.level}`, this.textStyle(11, '#d8b15f', true))); this.track(this.add.text(x + 108, rowY, this.compactText(this.itemBonusText(entry.item), 18), this.textStyle(11, '#d4dce6', true))); this.drawBar(x + 36, rowY + 17, width - 56, 5, entry.state.exp / equipmentExpToNext(entry.state.level), entry.item.rank === 'treasure' ? palette.gold : palette.blue); @@ -19939,7 +19950,7 @@ export class CampScene extends Phaser.Scene { const iconFrame = this.track(this.add.rectangle(x + 18, y + 18, 28, 28, 0x0a1017, 0.92)); iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54); const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}`)); - icon.setDisplaySize(21, 21); + icon.setDisplaySize(this.campUiLength(21), this.campUiLength(21)); this.track(this.add.text(x + 38, y + 7, this.compactText(`${entry.item.name} x${entry.amount}`, 8), this.textStyle(11, '#f2e3bf', true))); this.track(this.add.text(x + 38, y + 23, this.compactText(`${equipmentSlotLabels[entry.item.slot]} · ${this.itemBonusText(entry.item)}`, 11), this.textStyle(9, '#9fb0bf', true))); const actionText = this.track(this.add.text(x + 58, y + 39, canEquip ? '장착' : '장수 선택', this.textStyle(9, canEquip ? '#d8b15f' : '#7f8994', true))); @@ -20120,7 +20131,7 @@ export class CampScene extends Phaser.Scene { const iconFrame = this.track(this.add.rectangle(x + 28, y + 29, 42, 42, 0x0a1017, 0.92)); iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.72 : 0.48); const icon = this.track(this.add.image(x + 28, y + 29, `item-${item.id}`)); - icon.setDisplaySize(30, 30); + icon.setDisplaySize(this.campUiLength(30), this.campUiLength(30)); this.track(this.add.text(x + 58, y + 11, this.compactText(item.name, 8), this.textStyle(14, isTreasure ? '#f4dfad' : '#d4dce6', true))); this.track(this.add.text(x + 58, y + 34, `${equipmentSlotLabels[slot]} · ${isTreasure ? '보물' : '일반'} Lv ${state.level}`, this.textStyle(11, isTreasure ? '#d8b15f' : '#9fb0bf', true))); @@ -20275,14 +20286,14 @@ export class CampScene extends Phaser.Scene { this.dialogueObjects = []; const depth = this.sortieObjects.length > 0 ? 72 : 30; const noticeWidth = Math.min(640, Math.max(360, message.length * 13)); - const box = this.add.rectangle(this.scale.width / 2, 104, noticeWidth, 50, 0x101820, 0.96); + const box = this.scaleLegacyCampUi(this.add.rectangle(campLegacyCanvasWidth / 2, 104, noticeWidth, 50, 0x101820, 0.96)); box.setStrokeStyle(2, palette.gold, 0.84); box.setDepth(depth); - const text = this.add.text(this.scale.width / 2, 104, message, { + const text = this.scaleLegacyCampUi(this.add.text(campLegacyCanvasWidth / 2, 104, message, { ...this.textStyle(14, '#f2e3bf', true), align: 'center', wordWrap: { width: noticeWidth - 44, useAdvancedWrap: true } - }); + })); text.setOrigin(0.5); text.setDepth(depth + 1); this.dialogueObjects.push(box, text); @@ -20745,7 +20756,51 @@ export class CampScene extends Phaser.Scene { }; } + private campUiLength(value: number) { + return value * campFhdUiScale; + } + + private scaleCampUiBounds(bounds: CampRosterBounds): CampRosterBounds { + return { + x: this.campUiLength(bounds.x), + y: this.campUiLength(bounds.y), + width: this.campUiLength(bounds.width), + height: this.campUiLength(bounds.height) + }; + } + + private scaleLegacyCampUi(object: T) { + if (this.scaledCampUiObjects.has(object)) { + return object; + } + const transform = object as T & { + x: number; + y: number; + scaleX: number; + scaleY: number; + setPosition: (x: number, y: number) => T; + setScale: (x: number, y: number) => T; + }; + transform.setPosition(this.campUiLength(transform.x), this.campUiLength(transform.y)); + transform.setScale(transform.scaleX * campFhdUiScale, transform.scaleY * campFhdUiScale); + this.scaledCampUiObjects.add(object); + return object; + } + + private trackCampSaveSlot(object: T) { + this.scaleLegacyCampUi(object); + this.saveSlotObjects.push(object); + return object; + } + + private trackCampSaveConfirm(object: T) { + this.scaleLegacyCampUi(object); + this.saveSlotConfirmObjects.push(object); + return object; + } + private track(object: T) { + this.scaleLegacyCampUi(object); this.contentObjects.push(object); return object; } diff --git a/src/game/scenes/EndingScene.ts b/src/game/scenes/EndingScene.ts index 5ddfa8c..61d3f9d 100644 --- a/src/game/scenes/EndingScene.ts +++ b/src/game/scenes/EndingScene.ts @@ -5,6 +5,10 @@ import { storyBackgroundAssets } from '../data/storyAssets'; import { getCampaignState } from '../state/campaignState'; import { palette } from '../ui/palette'; +const fhdUiScale = 1.5; +const ui = (value: number) => value * fhdUiScale; +const uiPx = (value: number) => `${Math.round(ui(value))}px`; + export class EndingScene extends Phaser.Scene { private readonly backgroundKey = 'story-wuzhang-jiangwei-inheritance'; private ready = false; @@ -76,17 +80,18 @@ export class EndingScene extends Phaser.Scene { return; } + const { width, height } = this.scale; const graphics = this.make.graphics({ x: 0, y: 0 }); graphics.fillStyle(0x080b10, 1); - graphics.fillRect(0, 0, 1280, 720); + graphics.fillRect(0, 0, width, height); graphics.fillStyle(0x152331, 1); - graphics.fillRect(0, 0, 1280, 720); - graphics.lineStyle(5, palette.gold, 0.42); + graphics.fillRect(0, 0, width, height); + graphics.lineStyle(ui(5), palette.gold, 0.42); graphics.beginPath(); - graphics.moveTo(120, 570); - graphics.lineTo(1160, 170); + graphics.moveTo(width * (120 / 1280), height * (570 / 720)); + graphics.lineTo(width * (1160 / 1280), height * (170 / 720)); graphics.strokePath(); - graphics.generateTexture('ending-fallback', 1280, 720); + graphics.generateTexture('ending-fallback', width, height); graphics.destroy(); } @@ -100,11 +105,11 @@ export class EndingScene extends Phaser.Scene { const coverScale = Math.max(width / source.width, height / source.height); background.setScale(coverScale * 1.08); - background.setPosition(width / 2 - 14, height / 2 + 2); + background.setPosition(width / 2 - ui(14), height / 2 + ui(2)); this.tweens.add({ targets: background, - x: width / 2 + 14, - y: height / 2 - 6, + x: width / 2 + ui(14), + y: height / 2 - ui(6), scale: coverScale * 1.12, duration: 18000, ease: 'Sine.easeInOut', @@ -114,27 +119,27 @@ export class EndingScene extends Phaser.Scene { this.add.rectangle(0, 0, width, height, 0x04070b, 0.34).setOrigin(0); this.add.rectangle(0, 0, width, height, 0x05080c, 0.16).setOrigin(0); - this.add.rectangle(0, height - 220, width, 220, 0x05070a, 0.64).setOrigin(0); + this.add.rectangle(0, height - ui(220), width, ui(220), 0x05070a, 0.64).setOrigin(0); const completedBattles = Object.keys(campaign.battleHistory).length; - const titleY = Math.max(96, height * 0.18); + const titleY = Math.max(ui(96), height * 0.18); this.add.text(width / 2, titleY, '북벌의 끝과 남은 뜻', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: '48px', + fontSize: uiPx(48), color: '#f6e6bd', fontStyle: '700', align: 'center', - shadow: { offsetX: 0, offsetY: 3, color: '#020408', blur: 8, fill: true } + shadow: { offsetX: 0, offsetY: ui(3), color: '#020408', blur: ui(8), fill: true } }).setOrigin(0.5); - this.add.text(width / 2, titleY + 62, '촉한 군영 후일담 완료', { + this.add.text(width / 2, titleY + ui(62), '촉한 군영 후일담 완료', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: uiPx(22), color: '#d8b15f', fontStyle: '700', align: 'center', - shadow: { offsetX: 0, offsetY: 2, color: '#020408', blur: 6, fill: true } + shadow: { offsetX: 0, offsetY: ui(2), color: '#020408', blur: ui(6), fill: true } }).setOrigin(0.5); const summary = [ @@ -144,28 +149,28 @@ export class EndingScene extends Phaser.Scene { `군영 공명 ${campaign.bonds.length}개` ]; - const startX = width / 2 - 390; + const startX = width / 2 - ui(390); summary.forEach((line, index) => { - this.add.text(startX + index * 260, height - 178, line, { + this.add.text(startX + index * ui(260), height - ui(178), line, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: uiPx(18), color: '#f2e3bf', fontStyle: '700', - shadow: { offsetX: 0, offsetY: 2, color: '#020408', blur: 5, fill: true } + shadow: { offsetX: 0, offsetY: ui(2), color: '#020408', blur: ui(5), fill: true } }).setOrigin(0.5); }); - this.add.text(width / 2, height - 122, '도원에서 시작된 선택은 촉한의 깃발과 북벌의 장부를 지나, 다음 세대가 이어 받을 뜻으로 남았습니다.', { + this.add.text(width / 2, height - ui(122), '도원에서 시작된 선택은 촉한의 깃발과 북벌의 장부를 지나, 다음 세대가 이어 받을 뜻으로 남았습니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: '22px', + fontSize: uiPx(22), color: '#e8dfca', align: 'center', - wordWrap: { width: Math.min(920, width - 160), useAdvancedWrap: true }, - lineSpacing: 8, - shadow: { offsetX: 0, offsetY: 2, color: '#020408', blur: 6, fill: true } + wordWrap: { width: Math.min(ui(920), width - ui(160)), useAdvancedWrap: true }, + lineSpacing: ui(8), + shadow: { offsetX: 0, offsetY: ui(2), color: '#020408', blur: ui(6), fill: true } }).setOrigin(0.5); - this.addButton(width / 2, height - 52, '타이틀로', () => { + this.addButton(width / 2, height - ui(52), '타이틀로', () => { soundDirector.playSelect(); this.scene.start('TitleScene'); }); @@ -176,8 +181,8 @@ export class EndingScene extends Phaser.Scene { } private addButton(x: number, y: number, label: string, action: () => void) { - const bg = this.add.rectangle(x, y, 160, 42, 0x1a2630, 0.96); - bg.setStrokeStyle(1, palette.gold, 0.82); + const bg = this.add.rectangle(x, y, ui(160), ui(42), 0x1a2630, 0.96); + bg.setStrokeStyle(ui(1), palette.gold, 0.82); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96)); @@ -185,7 +190,7 @@ export class EndingScene extends Phaser.Scene { const text = this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', + fontSize: uiPx(17), color: '#f2e3bf', fontStyle: '700' }); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 03e5f59..9c2f4ab 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -31,6 +31,9 @@ type StorySceneData = { const sceneShadeDepth = 1; const cutsceneDepth = 8; const dialogDepth = 20; +const fhdUiScale = 1.5; +const ui = (value: number) => value * fhdUiScale; +const uiPx = (value: number) => `${Math.round(ui(value))}px`; const storyUnitFrameRows: Record = { south: 0, east: 1, @@ -119,10 +122,10 @@ export class StoryScene extends Phaser.Scene { this.createFallbackStoryTexture(); this.loadingText = this.add.text(width / 2, height / 2, '장면을 준비하는 중...', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: uiPx(22), color: '#f5e6b8', stroke: '#05070a', - strokeThickness: 4 + strokeThickness: ui(4) }); this.loadingText.setOrigin(0.5); @@ -194,17 +197,18 @@ export class StoryScene extends Phaser.Scene { return; } + const { width, height } = this.scale; const graphics = this.make.graphics({ x: 0, y: 0 }); graphics.fillStyle(0x10131a, 1); - graphics.fillRect(0, 0, 1280, 720); + graphics.fillRect(0, 0, width, height); graphics.fillStyle(0x1d2530, 1); - graphics.fillRect(0, 0, 1280, 180); - graphics.lineStyle(4, palette.gold, 0.5); + graphics.fillRect(0, 0, width, height * (180 / 720)); + graphics.lineStyle(ui(4), palette.gold, 0.5); graphics.beginPath(); - graphics.moveTo(120, 600); - graphics.lineTo(1160, 120); + graphics.moveTo(width * (120 / 1280), height * (600 / 720)); + graphics.lineTo(width * (1160 / 1280), height * (120 / 720)); graphics.strokePath(); - graphics.generateTexture('story-fallback', 1280, 720); + graphics.generateTexture('story-fallback', width, height); graphics.destroy(); } @@ -214,90 +218,90 @@ export class StoryScene extends Phaser.Scene { shade.fillStyle(0x030405, 0.34); shade.fillRect(0, 0, width, height); shade.fillStyle(0x030405, 0.5); - shade.fillRect(0, height - 286, width, 286); + shade.fillRect(0, height - ui(286), width, ui(286)); shade.fillStyle(0x030405, 0.34); - shade.fillRect(0, 0, width, 118); + shade.fillRect(0, 0, width, ui(118)); shade.fillStyle(0x030405, 0.2); - shade.fillRect(0, 94, width, 388); + shade.fillRect(0, ui(94), width, ui(388)); shade.fillStyle(0x030405, 0.28); - shade.fillRect(0, 0, 120, height); - shade.fillRect(width - 120, 0, 120, height); + shade.fillRect(0, 0, ui(120), height); + shade.fillRect(width - ui(120), 0, ui(120), height); } private drawDialogPanel(width: number, height: number) { - const panelY = height - 238; - const panelX = 72; - const panelW = width - 144; - const panelH = 172; + const panelY = height - ui(238); + const panelX = ui(72); + const panelW = width - ui(144); + const panelH = ui(172); const panel = this.add.graphics(); panel.setDepth(dialogDepth); panel.fillStyle(cutsceneUiColors.veil, 0.62); - panel.fillRoundedRect(panelX - 12, panelY + 12, panelW + 24, panelH + 18, 12); + panel.fillRoundedRect(panelX - ui(12), panelY + ui(12), panelW + ui(24), panelH + ui(18), ui(12)); panel.fillStyle(cutsceneUiColors.woodDark, 0.96); - panel.fillRoundedRect(panelX, panelY, panelW, panelH, 10); + panel.fillRoundedRect(panelX, panelY, panelW, panelH, ui(10)); panel.fillStyle(cutsceneUiColors.lacquer, 0.9); - panel.fillRoundedRect(panelX + 12, panelY + 14, panelW - 24, panelH - 28, 8); + panel.fillRoundedRect(panelX + ui(12), panelY + ui(14), panelW - ui(24), panelH - ui(28), ui(8)); panel.fillStyle(cutsceneUiColors.silk, 0.52); - panel.fillRoundedRect(panelX + 22, panelY + 24, panelW - 44, panelH - 48, 6); + panel.fillRoundedRect(panelX + ui(22), panelY + ui(24), panelW - ui(44), panelH - ui(48), ui(6)); panel.fillStyle(cutsceneUiColors.woodLight, 0.48); - panel.fillRoundedRect(panelX + 20, panelY - 6, panelW - 40, 14, 7); - panel.fillRoundedRect(panelX + 20, panelY + panelH - 8, panelW - 40, 14, 7); - panel.lineStyle(2, palette.gold, 0.28); - panel.strokeRoundedRect(panelX + 10, panelY + 10, panelW - 20, panelH - 20, 8); - panel.lineStyle(1, 0xf0d08a, 0.12); - panel.lineBetween(panelX + 198, panelY + 24, panelX + panelW - 30, panelY + 18); - panel.lineBetween(panelX + 198, panelY + panelH - 26, panelX + panelW - 30, panelY + panelH - 18); - panel.lineStyle(1, cutsceneUiColors.woodLight, 0.14); + panel.fillRoundedRect(panelX + ui(20), panelY - ui(6), panelW - ui(40), ui(14), ui(7)); + panel.fillRoundedRect(panelX + ui(20), panelY + panelH - ui(8), panelW - ui(40), ui(14), ui(7)); + panel.lineStyle(ui(2), palette.gold, 0.28); + panel.strokeRoundedRect(panelX + ui(10), panelY + ui(10), panelW - ui(20), panelH - ui(20), ui(8)); + panel.lineStyle(ui(1), 0xf0d08a, 0.12); + panel.lineBetween(panelX + ui(198), panelY + ui(24), panelX + panelW - ui(30), panelY + ui(18)); + panel.lineBetween(panelX + ui(198), panelY + panelH - ui(26), panelX + panelW - ui(30), panelY + panelH - ui(18)); + panel.lineStyle(ui(1), cutsceneUiColors.woodLight, 0.14); for (let index = 0; index < 4; index += 1) { - const y = panelY + 40 + index * 24; - panel.lineBetween(panelX + 198, y, panelX + panelW - 36, y + (index % 2 === 0 ? 3 : -3)); + const y = panelY + ui(40) + index * ui(24); + panel.lineBetween(panelX + ui(198), y, panelX + panelW - ui(36), y + ui(index % 2 === 0 ? 3 : -3)); } - this.chapterText = this.add.text(panelX, panelY - 42, '', { + this.chapterText = this.add.text(panelX, panelY - ui(42), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: '22px', + fontSize: uiPx(22), color: '#d8b15f', fontStyle: '700', - padding: { top: 4, bottom: 4 }, - shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 6, fill: true } + padding: { top: ui(4), bottom: ui(4) }, + shadow: { offsetX: 0, offsetY: ui(2), color: '#05070a', blur: ui(6), fill: true } }); this.chapterText.setDepth(dialogDepth + 2); - this.portraitFrame = this.add.rectangle(panelX + 86, panelY + 86, 136, 136, cutsceneUiColors.ink, 0.92); - this.portraitFrame.setStrokeStyle(2, palette.gold, 0.34); + this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + ui(86), ui(136), ui(136), cutsceneUiColors.ink, 0.92); + this.portraitFrame.setStrokeStyle(ui(2), palette.gold, 0.34); this.portraitFrame.setDepth(dialogDepth + 2); - this.portrait = this.add.image(panelX + 86, panelY + 86, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); - this.portrait.setDisplaySize(128, 128); + this.portrait = this.add.image(panelX + ui(86), panelY + ui(86), this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); + this.portrait.setDisplaySize(ui(128), ui(128)); this.portrait.setDepth(dialogDepth + 3); - this.portraitDivider = this.add.rectangle(panelX + 170, panelY + 30, 1, panelH - 60, palette.gold, 0.12).setOrigin(0); + this.portraitDivider = this.add.rectangle(panelX + ui(170), panelY + ui(30), ui(1), panelH - ui(60), palette.gold, 0.12).setOrigin(0); this.portraitDivider.setDepth(dialogDepth + 2); - this.nameText = this.add.text(panelX + 198, panelY + 28, '', { + this.nameText = this.add.text(panelX + ui(198), panelY + ui(28), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: '24px', + fontSize: uiPx(24), color: '#f1e3c2', fontStyle: '700', - padding: { top: 4, bottom: 4 } + padding: { top: ui(4), bottom: ui(4) } }); this.nameText.setDepth(dialogDepth + 2); - this.bodyText = this.add.text(panelX + 198, panelY + 78, '', { + this.bodyText = this.add.text(panelX + ui(198), panelY + ui(78), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: '25px', + fontSize: uiPx(25), color: '#e8dfca', - wordWrap: { width: panelW - 236, useAdvancedWrap: true }, - lineSpacing: 10, - padding: { top: 4, bottom: 6 }, - shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 5, fill: true } + wordWrap: { width: panelW - ui(236), useAdvancedWrap: true }, + lineSpacing: ui(10), + padding: { top: ui(4), bottom: ui(6) }, + shadow: { offsetX: 0, offsetY: ui(2), color: '#05070a', blur: ui(5), fill: true } }); this.bodyText.setDepth(dialogDepth + 2); - this.progressText = this.add.text(width - 220, panelY + panelH + 20, '', { + this.progressText = this.add.text(width - ui(220), panelY + panelH + ui(20), '', { fontFamily: '"Segoe UI", sans-serif', - fontSize: '15px', + fontSize: uiPx(15), color: '#9aa3ad' }); this.progressText.setDepth(dialogDepth + 2); @@ -360,17 +364,17 @@ export class StoryScene extends Phaser.Scene { this.portrait?.setVisible(true); this.portraitDivider?.setVisible(true); this.portrait?.setTexture(textureKey); - this.portrait?.setDisplaySize(128, 128); - this.nameText?.setPosition(270, this.scale.height - 210); - this.bodyText?.setPosition(270, this.scale.height - 160); - this.bodyText?.setWordWrapWidth(this.scale.width - 374, true); + this.portrait?.setDisplaySize(ui(128), ui(128)); + this.nameText?.setPosition(ui(270), this.scale.height - ui(210)); + this.bodyText?.setPosition(ui(270), this.scale.height - ui(160)); + this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true); } else { this.portraitFrame?.setVisible(false); this.portrait?.setVisible(false); this.portraitDivider?.setVisible(false); - this.nameText?.setPosition(96, this.scale.height - 210); - this.bodyText?.setPosition(96, this.scale.height - 160); - this.bodyText?.setWordWrapWidth(this.scale.width - 200, true); + this.nameText?.setPosition(ui(96), this.scale.height - ui(210)); + this.bodyText?.setPosition(ui(96), this.scale.height - ui(160)); + this.bodyText?.setWordWrapWidth(this.scale.width - ui(200), true); } this.nameText?.setText(page.speaker ?? '나레이션'); @@ -427,12 +431,12 @@ export class StoryScene extends Phaser.Scene { this.background.setAlpha(1); this.background.setDepth(0); this.background.setScale(coverScale * 1.07); - this.background.setPosition(width / 2 - 16, height / 2); + this.background.setPosition(width / 2 - ui(16), height / 2); this.tweens.add({ targets: this.background, - x: width / 2 + 16, - y: height / 2 - 8, + x: width / 2 + ui(16), + y: height / 2 - ui(8), scale: coverScale * 1.11, duration: 15000, ease: 'Sine.easeInOut', @@ -465,65 +469,65 @@ export class StoryScene extends Phaser.Scene { const accent = cutscene.kind === 'victory' ? palette.green : cutscene.kind === 'operation' ? palette.blue : palette.gold; const stage = this.add.graphics(); stage.fillStyle(cutsceneUiColors.veil, 0.62); - stage.fillRoundedRect(stageX - 18, stageY - 14, stageW + 36, stageH + 28, 14); + stage.fillRoundedRect(stageX - ui(18), stageY - ui(14), stageW + ui(36), stageH + ui(28), ui(14)); stage.fillStyle(cutsceneUiColors.woodDark, 0.98); - stage.fillRoundedRect(stageX, stageY, stageW, stageH, 12); + stage.fillRoundedRect(stageX, stageY, stageW, stageH, ui(12)); stage.fillStyle(cutsceneUiColors.woodMid, 0.9); - stage.fillRoundedRect(stageX + 12, stageY + 12, stageW - 24, stageH - 24, 9); + stage.fillRoundedRect(stageX + ui(12), stageY + ui(12), stageW - ui(24), stageH - ui(24), ui(9)); stage.fillStyle(cutsceneUiColors.lacquer, 0.68); - stage.fillRoundedRect(stageX + 26, stageY + 76, stageW - 52, stageH - 112, 8); + stage.fillRoundedRect(stageX + ui(26), stageY + ui(76), stageW - ui(52), stageH - ui(112), ui(8)); stage.fillStyle(cutsceneUiColors.woodLight, 0.2); - stage.fillRoundedRect(stageX + 18, stageY + 14, stageW - 36, 16, 8); + stage.fillRoundedRect(stageX + ui(18), stageY + ui(14), stageW - ui(36), ui(16), ui(8)); stage.fillStyle(cutsceneUiColors.woodDark, 0.36); - stage.fillRoundedRect(stageX + 18, stageY + stageH - 28, stageW - 36, 12, 6); - stage.lineStyle(1, accent, 0.28); - stage.strokeRoundedRect(stageX + 11, stageY + 11, stageW - 22, stageH - 22, 9); - stage.lineStyle(1, 0xf0d08a, 0.1); - stage.lineBetween(stageX + 30, stageY + 70, stageX + stageW - 30, stageY + 68); - stage.lineBetween(stageX + 30, stageY + stageH - 52, stageX + stageW - 30, stageY + stageH - 46); - stage.lineStyle(1, cutsceneUiColors.woodLight, 0.16); + stage.fillRoundedRect(stageX + ui(18), stageY + stageH - ui(28), stageW - ui(36), ui(12), ui(6)); + stage.lineStyle(ui(1), accent, 0.28); + stage.strokeRoundedRect(stageX + ui(11), stageY + ui(11), stageW - ui(22), stageH - ui(22), ui(9)); + stage.lineStyle(ui(1), 0xf0d08a, 0.1); + stage.lineBetween(stageX + ui(30), stageY + ui(70), stageX + stageW - ui(30), stageY + ui(68)); + stage.lineBetween(stageX + ui(30), stageY + stageH - ui(52), stageX + stageW - ui(30), stageY + stageH - ui(46)); + stage.lineStyle(ui(1), cutsceneUiColors.woodLight, 0.16); for (let index = 0; index < 12; index += 1) { - const y = stageY + 28 + index * 24; - stage.lineBetween(stageX + 28, y, stageX + stageW - 28, y + (index % 2 === 0 ? 4 : -3)); + const y = stageY + ui(28) + index * ui(24); + stage.lineBetween(stageX + ui(28), y, stageX + stageW - ui(28), y + ui(index % 2 === 0 ? 4 : -3)); } stage.fillStyle(0x0a0504, 0.38); - stage.fillCircle(stageX + 28, stageY + 28, 4); - stage.fillCircle(stageX + stageW - 28, stageY + 28, 4); - stage.fillCircle(stageX + 28, stageY + stageH - 28, 4); - stage.fillCircle(stageX + stageW - 28, stageY + stageH - 28, 4); + stage.fillCircle(stageX + ui(28), stageY + ui(28), ui(4)); + stage.fillCircle(stageX + stageW - ui(28), stageY + ui(28), ui(4)); + stage.fillCircle(stageX + ui(28), stageY + stageH - ui(28), ui(4)); + stage.fillCircle(stageX + stageW - ui(28), stageY + stageH - ui(28), ui(4)); const foldingScreen = this.add.graphics(); - foldingScreen.lineStyle(1, 0xd0b16e, 0.1); + foldingScreen.lineStyle(ui(1), 0xd0b16e, 0.1); foldingScreen.fillStyle(0x342216, 0.22); for (let index = 0; index < 5; index += 1) { - const panelX = stageX + 26 + index * 86; - foldingScreen.fillRoundedRect(panelX, stageY + 92, 66, stageH - 134, 4); - foldingScreen.strokeRoundedRect(panelX, stageY + 92, 66, stageH - 134, 4); + const panelX = stageX + ui(26) + index * ui(86); + foldingScreen.fillRoundedRect(panelX, stageY + ui(92), ui(66), stageH - ui(134), ui(4)); + foldingScreen.strokeRoundedRect(panelX, stageY + ui(92), ui(66), stageH - ui(134), ui(4)); } - const tableShadow = this.add.ellipse(stageX + stageW * 0.62, stageY + stageH - 34, stageW * 0.6, 38, 0x030405, 0.34); + const tableShadow = this.add.ellipse(stageX + stageW * 0.62, stageY + stageH - ui(34), stageW * 0.6, ui(38), 0x030405, 0.34); const titlePlate = this.add.graphics(); titlePlate.fillStyle(cutsceneUiColors.lacquer, 0.88); - titlePlate.fillRoundedRect(stageX + 22, stageY + 18, 366, 48, 8); + titlePlate.fillRoundedRect(stageX + ui(22), stageY + ui(18), ui(366), ui(48), ui(8)); titlePlate.fillStyle(accent, 0.14); - titlePlate.fillRoundedRect(stageX + 30, stageY + 25, 34, 34, 5); - titlePlate.lineStyle(1, accent, 0.3); - titlePlate.lineBetween(stageX + 74, stageY + 25, stageX + 374, stageY + 25); - titlePlate.lineBetween(stageX + 74, stageY + 60, stageX + 374, stageY + 60); - titlePlate.lineStyle(1, 0xf0d08a, 0.12); - titlePlate.strokeRoundedRect(stageX + 22, stageY + 18, 366, 48, 8); + titlePlate.fillRoundedRect(stageX + ui(30), stageY + ui(25), ui(34), ui(34), ui(5)); + titlePlate.lineStyle(ui(1), accent, 0.3); + titlePlate.lineBetween(stageX + ui(74), stageY + ui(25), stageX + ui(374), stageY + ui(25)); + titlePlate.lineBetween(stageX + ui(74), stageY + ui(60), stageX + ui(374), stageY + ui(60)); + titlePlate.lineStyle(ui(1), 0xf0d08a, 0.12); + titlePlate.strokeRoundedRect(stageX + ui(22), stageY + ui(18), ui(366), ui(48), ui(8)); - const title = this.add.text(stageX + 34, stageY + 18, cutscene.title, { + const title = this.add.text(stageX + ui(34), stageY + ui(18), cutscene.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: '26px', + fontSize: uiPx(26), color: '#f2e3bf', fontStyle: '700', stroke: '#05070a', - strokeThickness: 4 + strokeThickness: ui(4) }); - const subtitle = this.add.text(stageX + 36, stageY + 53, cutscene.subtitle ?? '', { + const subtitle = this.add.text(stageX + ui(36), stageY + ui(53), cutscene.subtitle ?? '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: uiPx(14), color: '#cdbb94' }); layer.add([stage, foldingScreen, tableShadow, titlePlate, title, subtitle]); @@ -532,86 +536,86 @@ export class StoryScene extends Phaser.Scene { private renderTacticalBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) { const stage = this.cutsceneStageBounds(); const boardX = stage.x + Math.round(stage.width * 0.36); - const boardY = stage.y + 78; + const boardY = stage.y + ui(78); const boardW = Math.round(stage.width * 0.58); - const boardH = stage.height - 118; + const boardH = stage.height - ui(118); const board: CutsceneBoardBounds = { x: boardX, y: boardY, width: boardW, height: boardH }; const parchment = this.add.graphics(); parchment.fillStyle(cutsceneUiColors.veil, 0.34); - parchment.fillEllipse(boardX + boardW * 0.52, boardY + boardH + 12, boardW * 0.9, 34); + parchment.fillEllipse(boardX + boardW * 0.52, boardY + boardH + ui(12), boardW * 0.9, ui(34)); parchment.fillStyle(cutsceneUiColors.woodDark, 0.92); - parchment.fillRoundedRect(boardX + 10, boardY - 10, boardW - 20, 20, 10); - parchment.fillRoundedRect(boardX + 8, boardY + boardH - 10, boardW - 16, 20, 10); + parchment.fillRoundedRect(boardX + ui(10), boardY - ui(10), boardW - ui(20), ui(20), ui(10)); + parchment.fillRoundedRect(boardX + ui(8), boardY + boardH - ui(10), boardW - ui(16), ui(20), ui(10)); parchment.fillStyle(cutsceneUiColors.woodLight, 0.35); - parchment.fillRoundedRect(boardX + 22, boardY - 5, boardW - 44, 5, 3); - parchment.fillRoundedRect(boardX + 22, boardY + boardH - 5, boardW - 44, 5, 3); + parchment.fillRoundedRect(boardX + ui(22), boardY - ui(5), boardW - ui(44), ui(5), ui(3)); + parchment.fillRoundedRect(boardX + ui(22), boardY + boardH - ui(5), boardW - ui(44), ui(5), ui(3)); parchment.fillStyle(cutsceneUiColors.parchmentDark, 0.94); - parchment.fillRoundedRect(boardX + 3, boardY + 4, boardW - 6, boardH - 8, 5); + parchment.fillRoundedRect(boardX + ui(3), boardY + ui(4), boardW - ui(6), boardH - ui(8), ui(5)); parchment.fillStyle(cutsceneUiColors.parchment, 0.92); - parchment.fillRoundedRect(boardX + 10, boardY + 12, boardW - 20, boardH - 24, 4); + parchment.fillRoundedRect(boardX + ui(10), boardY + ui(12), boardW - ui(20), boardH - ui(24), ui(4)); parchment.fillStyle(cutsceneUiColors.parchmentLight, 0.08); parchment.fillEllipse(boardX + boardW * 0.5, boardY + boardH * 0.48, boardW * 0.82, boardH * 0.66); parchment.fillStyle(cutsceneUiColors.paperEdge, 0.12); - parchment.fillRoundedRect(boardX + 10, boardY + 12, boardW - 20, 10, 4); - parchment.fillRoundedRect(boardX + 10, boardY + boardH - 22, boardW - 20, 10, 4); + parchment.fillRoundedRect(boardX + ui(10), boardY + ui(12), boardW - ui(20), ui(10), ui(4)); + parchment.fillRoundedRect(boardX + ui(10), boardY + boardH - ui(22), boardW - ui(20), ui(10), ui(4)); parchment.fillStyle(0x23140c, 0.1); parchment.fillEllipse(boardX + boardW * 0.5, boardY + boardH * 0.62, boardW * 0.72, boardH * 0.34); - parchment.lineStyle(1, cutsceneUiColors.mapInk, 0.24); - parchment.strokeRoundedRect(boardX + 8, boardY + 10, boardW - 16, boardH - 20, 5); - parchment.lineStyle(1, 0xf0d08a, 0.1); - parchment.lineBetween(boardX + 24, boardY + 20, boardX + boardW - 28, boardY + 16); - parchment.lineBetween(boardX + 26, boardY + boardH - 20, boardX + boardW - 24, boardY + boardH - 24); + parchment.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.24); + parchment.strokeRoundedRect(boardX + ui(8), boardY + ui(10), boardW - ui(16), boardH - ui(20), ui(5)); + parchment.lineStyle(ui(1), 0xf0d08a, 0.1); + parchment.lineBetween(boardX + ui(24), boardY + ui(20), boardX + boardW - ui(28), boardY + ui(16)); + parchment.lineBetween(boardX + ui(26), boardY + boardH - ui(20), boardX + boardW - ui(24), boardY + boardH - ui(24)); parchment.fillStyle(0x3d2413, 0.16); parchment.beginPath(); - parchment.moveTo(boardX + boardW - 54, boardY + 12); - parchment.lineTo(boardX + boardW - 12, boardY + 12); - parchment.lineTo(boardX + boardW - 12, boardY + 52); + parchment.moveTo(boardX + boardW - ui(54), boardY + ui(12)); + parchment.lineTo(boardX + boardW - ui(12), boardY + ui(12)); + parchment.lineTo(boardX + boardW - ui(12), boardY + ui(52)); parchment.closePath(); parchment.fillPath(); - parchment.lineStyle(1, 0x3d2413, 0.1); + parchment.lineStyle(ui(1), 0x3d2413, 0.1); for (let index = 0; index < 11; index += 1) { - const y = boardY + 38 + index * 14; - const xOffset = index % 3 === 0 ? 26 : index % 3 === 1 ? 38 : 31; - parchment.lineBetween(boardX + xOffset, y, boardX + boardW - 30, y + (index % 2 === 0 ? 2 : -2)); + const y = boardY + ui(38) + index * ui(14); + const xOffset = ui(index % 3 === 0 ? 26 : index % 3 === 1 ? 38 : 31); + parchment.lineBetween(boardX + xOffset, y, boardX + boardW - ui(30), y + ui(index % 2 === 0 ? 2 : -2)); } for (let index = 0; index < 30; index += 1) { - const x = boardX + 30 + ((index * 47) % Math.max(60, boardW - 62)); - const y = boardY + 34 + ((index * 29) % Math.max(60, boardH - 66)); + const x = boardX + ui(30) + ((index * ui(47)) % Math.max(ui(60), boardW - ui(62))); + const y = boardY + ui(34) + ((index * ui(29)) % Math.max(ui(60), boardH - ui(66))); parchment.fillStyle(index % 2 === 0 ? 0x4f3219 : 0xd2b172, 0.1); - parchment.fillCircle(x, y, index % 3 === 0 ? 1.4 : 0.9); + parchment.fillCircle(x, y, ui(index % 3 === 0 ? 1.4 : 0.9)); } layer.add(parchment); const map = this.add.graphics(); - map.lineStyle(1, cutsceneUiColors.mapInk, 0.08); + map.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.08); for (let index = 1; index < 7; index += 1) { const x = boardX + (boardW / 7) * index; - map.lineBetween(x, boardY + 24, x, boardY + boardH - 24); + map.lineBetween(x, boardY + ui(24), x, boardY + boardH - ui(24)); } for (let index = 1; index < 4; index += 1) { const y = boardY + (boardH / 4) * index; - map.lineBetween(boardX + 24, y, boardX + boardW - 24, y); + map.lineBetween(boardX + ui(24), y, boardX + boardW - ui(24), y); } map.fillStyle(0x365238, 0.18); map.fillEllipse(boardX + boardW * 0.35, boardY + boardH * 0.58, boardW * 0.42, boardH * 0.46); map.fillStyle(0x36465b, 0.14); map.fillEllipse(boardX + boardW * 0.72, boardY + boardH * 0.36, boardW * 0.28, boardH * 0.28); - map.lineStyle(8, 0x6b4826, 0.18); + map.lineStyle(ui(8), 0x6b4826, 0.18); map.beginPath(); map.moveTo(boardX + boardW * 0.16, boardY + boardH * 0.74); map.lineTo(boardX + boardW * 0.38, boardY + boardH * 0.54); map.lineTo(boardX + boardW * 0.66, boardY + boardH * 0.36); map.lineTo(boardX + boardW * 0.83, boardY + boardH * 0.24); map.strokePath(); - map.lineStyle(2, cutsceneUiColors.mapInk, 0.22); + map.lineStyle(ui(2), cutsceneUiColors.mapInk, 0.22); map.beginPath(); map.moveTo(boardX + boardW * 0.16, boardY + boardH * 0.74); map.lineTo(boardX + boardW * 0.38, boardY + boardH * 0.54); map.lineTo(boardX + boardW * 0.66, boardY + boardH * 0.36); map.lineTo(boardX + boardW * 0.83, boardY + boardH * 0.24); map.strokePath(); - map.lineStyle(1, cutsceneUiColors.mapInk, 0.18); - map.strokeRoundedRect(boardX + 26, boardY + 28, boardW - 52, boardH - 56, 4); + map.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.18); + map.strokeRoundedRect(boardX + ui(26), boardY + ui(28), boardW - ui(52), boardH - ui(56), ui(4)); layer.add(map); this.renderBoardLegend(layer, cutscene, board); @@ -625,14 +629,14 @@ export class StoryScene extends Phaser.Scene { const label = cutscene.kind === 'victory' ? 'RESULT MAP' : cutscene.kind === 'operation' ? 'TACTICAL MAP' : 'SORTIE BOARD'; const bg = this.add.graphics(); bg.fillStyle(cutsceneUiColors.mapInk, 0.18); - bg.fillRoundedRect(board.x + 24, board.y + 24, 126, 22, 4); - bg.lineStyle(1, cutsceneUiColors.mapInk, 0.18); - bg.lineBetween(board.x + 32, board.y + 43, board.x + 140, board.y + 43); + bg.fillRoundedRect(board.x + ui(24), board.y + ui(24), ui(126), ui(22), ui(4)); + bg.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.18); + bg.lineBetween(board.x + ui(32), board.y + ui(43), board.x + ui(140), board.y + ui(43)); bg.fillStyle(cutsceneUiColors.sealRed, 0.22); - bg.fillCircle(board.x + 134, board.y + 35, 8); - const text = this.add.text(board.x + 34, board.y + 28, label, { + bg.fillCircle(board.x + ui(134), board.y + ui(35), ui(8)); + const text = this.add.text(board.x + ui(34), board.y + ui(28), label, { fontFamily: '"Segoe UI", sans-serif', - fontSize: '12px', + fontSize: uiPx(12), color: '#ead6aa', fontStyle: '700' }); @@ -646,21 +650,21 @@ export class StoryScene extends Phaser.Scene { ) { const stage = this.cutsceneStageBounds(); const actors = (cutscene.actors ?? []).filter((actor) => actor.unitId !== 'rebel-leader').slice(0, 3); - const cardX = stage.x + 28; - const cardW = Math.max(250, board.x - cardX - 24); - const cardH = actors.length >= 4 ? 60 : 70; - const firstY = stage.y + 86; + const cardX = stage.x + ui(28); + const cardW = Math.max(ui(250), board.x - cardX - ui(24)); + const cardH = ui(actors.length >= 4 ? 60 : 70); + const firstY = stage.y + ui(86); actors.forEach((actor, index) => { - const y = firstY + index * (cardH + 10); + const y = firstY + index * (cardH + ui(10)); this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index); }); if ((cutscene.briefing?.lines ?? []).length > 0) { - const briefingY = firstY + actors.length * (cardH + 10) + 4; - const maxH = stage.y + stage.height - 36 - briefingY; - if (maxH >= 70) { - this.renderBriefingCard(layer, cutscene, cardX, briefingY, cardW, Math.min(126, maxH)); + const briefingY = firstY + actors.length * (cardH + ui(10)) + ui(4); + const maxH = stage.y + stage.height - ui(36) - briefingY; + if (maxH >= ui(70)) { + this.renderBriefingCard(layer, cutscene, cardX, briefingY, cardW, Math.min(ui(126), maxH)); } } } @@ -681,72 +685,72 @@ export class StoryScene extends Phaser.Scene { const card = this.add.graphics(); card.fillStyle(cutsceneUiColors.veil, 0.34); - card.fillRoundedRect(x + 5, y + 7, width, height, 8); + card.fillRoundedRect(x + ui(5), y + ui(7), width, height, ui(8)); card.fillStyle(cutsceneUiColors.woodDark, 0.94); - card.fillRoundedRect(x, y, width, height, 8); + card.fillRoundedRect(x, y, width, height, ui(8)); card.fillStyle(cutsceneUiColors.lacquerLight, 0.46); - card.fillRoundedRect(x + 7, y + 7, width - 14, height - 14, 6); + card.fillRoundedRect(x + ui(7), y + ui(7), width - ui(14), height - ui(14), ui(6)); card.fillStyle(cutsceneUiColors.lacquer, 0.42); - card.fillRoundedRect(x + 72, y + 9, width - 88, height - 18, 5); + card.fillRoundedRect(x + ui(72), y + ui(9), width - ui(88), height - ui(18), ui(5)); card.fillStyle(profile.color, 0.42); - card.fillRoundedRect(x + 11, y + 10, 4, height - 20, 2); + card.fillRoundedRect(x + ui(11), y + ui(10), ui(4), height - ui(20), ui(2)); card.fillStyle(cutsceneUiColors.paperEdge, 0.16); - card.fillRoundedRect(x + height + 4, y + 9, Math.min(130, width - height - 52), 24, 4); - card.lineStyle(1, 0xf0d08a, 0.14); - card.lineBetween(x + 22, y + 9, x + width - 24, y + 9); - card.lineBetween(x + 22, y + height - 9, x + width - 24, y + height - 9); - card.lineStyle(1, profile.color, 0.24); - card.lineBetween(x + 18, y + 15, x + 18, y + height - 15); + card.fillRoundedRect(x + height + ui(4), y + ui(9), Math.min(ui(130), width - height - ui(52)), ui(24), ui(4)); + card.lineStyle(ui(1), 0xf0d08a, 0.14); + card.lineBetween(x + ui(22), y + ui(9), x + width - ui(24), y + ui(9)); + card.lineBetween(x + ui(22), y + height - ui(9), x + width - ui(24), y + height - ui(9)); + card.lineStyle(ui(1), profile.color, 0.24); + card.lineBetween(x + ui(18), y + ui(15), x + ui(18), y + height - ui(15)); card.fillStyle(profile.color, 0.18); - card.fillCircle(x + width - 24, y + height / 2, 12); + card.fillCircle(x + width - ui(24), y + height / 2, ui(12)); card.fillStyle(cutsceneUiColors.sealRed, 0.18); - card.fillCircle(x + width - 24, y + height / 2, 7); + card.fillCircle(x + width - ui(24), y + height / 2, ui(7)); layer.add(card); const portraitTexture = this.actorPortraitTextureKey(actor, index); - const portraitX = x + 38; + const portraitX = x + ui(38); const portraitY = y + height / 2; const portraitFrame = this.add.graphics(); portraitFrame.fillStyle(cutsceneUiColors.ink, 0.92); - portraitFrame.fillRoundedRect(portraitX - (height - 14) / 2, portraitY - (height - 14) / 2, height - 14, height - 14, 5); - portraitFrame.lineStyle(1, profile.color, 0.34); - portraitFrame.strokeRoundedRect(portraitX - (height - 14) / 2, portraitY - (height - 14) / 2, height - 14, height - 14, 5); - portraitFrame.lineStyle(1, 0xf0d08a, 0.1); - portraitFrame.strokeRoundedRect(portraitX - (height - 24) / 2, portraitY - (height - 24) / 2, height - 24, height - 24, 3); + portraitFrame.fillRoundedRect(portraitX - (height - ui(14)) / 2, portraitY - (height - ui(14)) / 2, height - ui(14), height - ui(14), ui(5)); + portraitFrame.lineStyle(ui(1), profile.color, 0.34); + portraitFrame.strokeRoundedRect(portraitX - (height - ui(14)) / 2, portraitY - (height - ui(14)) / 2, height - ui(14), height - ui(14), ui(5)); + portraitFrame.lineStyle(ui(1), 0xf0d08a, 0.1); + portraitFrame.strokeRoundedRect(portraitX - (height - ui(24)) / 2, portraitY - (height - ui(24)) / 2, height - ui(24), height - ui(24), ui(3)); layer.add(portraitFrame); if (portraitTexture) { const portrait = this.add.image(portraitX, portraitY, portraitTexture); - portrait.setDisplaySize(height - 18, height - 18); + portrait.setDisplaySize(height - ui(18), height - ui(18)); layer.add(portrait); } else if (this.textures.exists(profile.unitTextureKey)) { - const sprite = this.add.sprite(portraitX, portraitY + 3, profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); - sprite.setDisplaySize(height - 8, height - 8); + const sprite = this.add.sprite(portraitX, portraitY + ui(3), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); + sprite.setDisplaySize(height - ui(8), height - ui(8)); layer.add(sprite); } - const name = this.add.text(x + height + 14, y + 11, actor.label ?? profile.name, { + const name = this.add.text(x + height + ui(14), y + ui(11), actor.label ?? profile.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: uiPx(16), color: '#f5e6c8', fontStyle: '700' }); const roleBg = this.add.graphics(); - roleBg.lineStyle(1, profile.color, 0.26); - roleBg.lineBetween(x + height + 14, y + 38, x + height + Math.min(128, width - height - 34), y + 38); - const role = this.add.text(x + height + 14, y + 42, profile.roleLabel, { + roleBg.lineStyle(ui(1), profile.color, 0.26); + roleBg.lineBetween(x + height + ui(14), y + ui(38), x + height + Math.min(ui(128), width - height - ui(34)), y + ui(38)); + const role = this.add.text(x + height + ui(14), y + ui(42), profile.roleLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: uiPx(12), color: '#ddc99f' }); layer.add([roleBg, name, role]); - if (actor.line && height >= 66) { - const line = this.add.text(x + 144, y + 50, actor.line, { + if (actor.line && height >= ui(66)) { + const line = this.add.text(x + ui(144), y + ui(50), actor.line, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: uiPx(11), color: '#d8cbb2', - wordWrap: { width: width - 166, useAdvancedWrap: true } + wordWrap: { width: width - ui(166), useAdvancedWrap: true } }); layer.add(line); } @@ -766,29 +770,29 @@ export class StoryScene extends Phaser.Scene { const y = board.y + board.height * Phaser.Math.Clamp(normalizedY, 0.18, 0.82); const sideColor = actor.unitId === 'rebel-leader' ? 0xd46a4c : profile.color; const token = this.add.container(x, y); - const shadow = this.add.ellipse(2, 12, 38, 12, 0x05070a, 0.28); - const base = this.add.ellipse(0, 6, 30, 18, cutsceneUiColors.woodDark, 0.78); - base.setStrokeStyle(1, sideColor, 0.46); - const pin = this.add.circle(0, 6, 3, sideColor, 0.86); - pin.setStrokeStyle(1, 0x05070a, 0.62); + const shadow = this.add.ellipse(ui(2), ui(12), ui(38), ui(12), 0x05070a, 0.28); + const base = this.add.ellipse(0, ui(6), ui(30), ui(18), cutsceneUiColors.woodDark, 0.78); + base.setStrokeStyle(ui(1), sideColor, 0.46); + const pin = this.add.circle(0, ui(6), ui(3), sideColor, 0.86); + pin.setStrokeStyle(ui(1), 0x05070a, 0.62); token.add([shadow, base, pin]); if (this.textures.exists(profile.unitTextureKey)) { - const sprite = this.add.sprite(0, -8, profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); - sprite.setDisplaySize(34, 34); + const sprite = this.add.sprite(0, -ui(8), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); + sprite.setDisplaySize(ui(34), ui(34)); token.add(sprite); } else { - const fallback = this.add.rectangle(0, -8, 22, 24, sideColor, 0.9); - fallback.setStrokeStyle(1, 0x05070a, 0.82); + const fallback = this.add.rectangle(0, -ui(8), ui(22), ui(24), sideColor, 0.9); + fallback.setStrokeStyle(ui(1), 0x05070a, 0.82); token.add(fallback); } - const label = this.add.text(0, 14, actor.label ?? profile.name, { + const label = this.add.text(0, ui(14), actor.label ?? profile.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '9px', + fontSize: uiPx(9), color: '#ead9b8', stroke: '#05070a', - strokeThickness: 2 + strokeThickness: ui(2) }); label.setOrigin(0.5, 0); token.add(label); @@ -804,54 +808,54 @@ export class StoryScene extends Phaser.Scene { const bg = this.add.graphics(); bg.fillStyle(cutsceneUiColors.veil, 0.28); - bg.fillRoundedRect(x + 4, y + 5, width, height, 8); + bg.fillRoundedRect(x + ui(4), y + ui(5), width, height, ui(8)); bg.fillStyle(cutsceneUiColors.lacquer, 0.68); - bg.fillRoundedRect(x, y, width, height, 8); + bg.fillRoundedRect(x, y, width, height, ui(8)); bg.fillStyle(cutsceneUiColors.paperEdge, 0.08); - bg.fillRoundedRect(x + 10, y + 10, width - 20, height - 20, 6); - bg.lineStyle(1, palette.gold, 0.18); - bg.lineBetween(x + 16, y + 13, x + width - 16, y + 13); - bg.lineBetween(x + 16, y + height - 13, x + width - 16, y + height - 13); - const title = this.add.text(x + 18, y + 16, cutscene.briefing?.title ?? '전황', { + bg.fillRoundedRect(x + ui(10), y + ui(10), width - ui(20), height - ui(20), ui(6)); + bg.lineStyle(ui(1), palette.gold, 0.18); + bg.lineBetween(x + ui(16), y + ui(13), x + width - ui(16), y + ui(13)); + bg.lineBetween(x + ui(16), y + height - ui(13), x + width - ui(16), y + height - ui(13)); + const title = this.add.text(x + ui(18), y + ui(16), cutscene.briefing?.title ?? '전황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: uiPx(18), color: '#d8b15f', fontStyle: '700' }); layer.add([bg, title]); briefingLines.slice(0, 4).forEach((line, index) => { - const bullet = this.add.circle(x + 24, y + 54 + index * 30, 3, palette.gold, 0.72); - const text = this.add.text(x + 38, y + 43 + index * 30, line, { + const bullet = this.add.circle(x + ui(24), y + ui(54) + index * ui(30), ui(3), palette.gold, 0.72); + const text = this.add.text(x + ui(38), y + ui(43) + index * ui(30), line, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: uiPx(14), color: '#d8cbb2', - wordWrap: { width: width - 58, useAdvancedWrap: true } + wordWrap: { width: width - ui(58), useAdvancedWrap: true } }); layer.add([bullet, text]); }); } private renderOperationBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) { - const boardX = this.scale.width - 560; - const boardY = 128; - const boardW = 474; - const boardH = 246; + const boardX = this.scale.width - ui(560); + const boardY = ui(128); + const boardW = ui(474); + const boardH = ui(246); const board = this.add.rectangle(boardX, boardY, boardW, boardH, 0x0b1218, 0.92).setOrigin(0); - board.setStrokeStyle(1, palette.blue, 0.5); + board.setStrokeStyle(ui(1), palette.blue, 0.5); layer.add(board); const grid = this.add.graphics(); - grid.lineStyle(1, 0x53606c, 0.26); + grid.lineStyle(ui(1), 0x53606c, 0.26); for (let i = 1; i < 6; i += 1) { const x = boardX + (boardW / 6) * i; - grid.lineBetween(x, boardY + 16, x, boardY + boardH - 16); + grid.lineBetween(x, boardY + ui(16), x, boardY + boardH - ui(16)); } for (let i = 1; i < 4; i += 1) { const y = boardY + (boardH / 4) * i; - grid.lineBetween(boardX + 16, y, boardX + boardW - 16, y); + grid.lineBetween(boardX + ui(16), y, boardX + boardW - ui(16), y); } - grid.lineStyle(3, palette.gold, 0.36); + grid.lineStyle(ui(3), palette.gold, 0.36); grid.beginPath(); grid.moveTo(boardX + boardW * 0.2, boardY + boardH * 0.74); grid.lineTo(boardX + boardW * 0.44, boardY + boardH * 0.5); @@ -859,7 +863,7 @@ export class StoryScene extends Phaser.Scene { grid.strokePath(); layer.add(grid); - this.renderBriefingCard(layer, cutscene, 82, 184, 364, 138); + this.renderBriefingCard(layer, cutscene, ui(82), ui(184), ui(364), ui(138)); (cutscene.markers ?? []).forEach((marker) => this.renderOperationMarker(layer, marker, boardX, boardY, boardW, boardH)); } @@ -875,18 +879,18 @@ export class StoryScene extends Phaser.Scene { const color = marker.side === 'ally' ? palette.blue : marker.side === 'enemy' ? 0xd46a4c : palette.gold; const x = boardX + boardW * marker.x; const y = boardY + boardH * marker.y; - const halo = this.add.circle(x, y, 9, color, 0.1); - const dot = this.add.circle(x, y, 4, color, 0.86); - dot.setStrokeStyle(1, cutsceneUiColors.mapInk, 0.72); - const stem = this.add.rectangle(x - 0.5, y + 4, 1, 13, color, 0.42).setOrigin(0, 0); - const labelX = marker.side === 'objective' ? x - 8 : x + 11; - const labelY = marker.side === 'objective' ? y - 27 : y - 10; + const halo = this.add.circle(x, y, ui(9), color, 0.1); + const dot = this.add.circle(x, y, ui(4), color, 0.86); + dot.setStrokeStyle(ui(1), cutsceneUiColors.mapInk, 0.72); + const stem = this.add.rectangle(x - ui(0.5), y + ui(4), ui(1), ui(13), color, 0.42).setOrigin(0, 0); + const labelX = marker.side === 'objective' ? x - ui(8) : x + ui(11); + const labelY = marker.side === 'objective' ? y - ui(27) : y - ui(10); const label = this.add.text(labelX, labelY, marker.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: uiPx(11), color: '#ead9b8', stroke: '#05070a', - strokeThickness: 2 + strokeThickness: ui(2) }); if (marker.side === 'objective') { label.setOrigin(0.5, 0); @@ -902,7 +906,7 @@ export class StoryScene extends Phaser.Scene { const x = this.scale.width * actor.x; const y = this.scale.height * actor.y; - const size = actor.size ?? 126; + const size = ui(actor.size ?? 126); const direction = actor.direction ?? 'south'; const shadow = this.add.ellipse(x, y + size * 0.36, size * 0.46, size * 0.14, 0x05070a, 0.56); layer.add(shadow); @@ -914,7 +918,7 @@ export class StoryScene extends Phaser.Scene { layer.add(sprite); this.tweens.add({ targets: sprite, - y: y - 4, + y: y - ui(4), duration: 1200 + index * 90, ease: 'Sine.easeInOut', yoyo: true, @@ -923,16 +927,16 @@ export class StoryScene extends Phaser.Scene { }); } else { const fallback = this.add.rectangle(x, y, size * 0.56, size * 0.86, profile.color, 0.88); - fallback.setStrokeStyle(2, 0x05070a, 0.8); + fallback.setStrokeStyle(ui(2), 0x05070a, 0.8); layer.add(fallback); } - const labelWidth = Math.max(78, Math.min(132, size * 0.86)); - const labelBg = this.add.rectangle(x, y + size * 0.54, labelWidth, 28, 0x070b10, 0.9); - labelBg.setStrokeStyle(1, profile.color, 0.64); - const label = this.add.text(x, y + size * 0.54 - 7, actor.label ?? profile.name, { + const labelWidth = Math.max(ui(78), Math.min(ui(132), size * 0.86)); + const labelBg = this.add.rectangle(x, y + size * 0.54, labelWidth, ui(28), 0x070b10, 0.9); + labelBg.setStrokeStyle(ui(1), profile.color, 0.64); + const label = this.add.text(x, y + size * 0.54 - ui(7), actor.label ?? profile.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: uiPx(13), color: '#f2e3bf', fontStyle: '700' }); @@ -942,12 +946,12 @@ export class StoryScene extends Phaser.Scene { if (actor.line) { const line = this.add.text(x, y - size * 0.58, actor.line, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: uiPx(12), color: '#e8dfca', align: 'center', - wordWrap: { width: 160, useAdvancedWrap: true }, + wordWrap: { width: ui(160), useAdvancedWrap: true }, stroke: '#05070a', - strokeThickness: 3 + strokeThickness: ui(3) }); line.setOrigin(0.5, 1); layer.add(line); @@ -961,18 +965,18 @@ export class StoryScene extends Phaser.Scene { const stage = this.cutsceneStageBounds(); const maxItems = Math.min(4, rewards.length); - const stripX = stage.x + 28; - const stripY = stage.y + stage.height - 44; - const stripW = stage.width - 56; + const stripX = stage.x + ui(28); + const stripY = stage.y + stage.height - ui(44); + const stripW = stage.width - ui(56); const itemW = Math.floor(stripW / maxItems); const bg = this.add.graphics(); bg.fillStyle(cutsceneUiColors.veil, 0.22); - bg.fillRoundedRect(stripX + 4, stripY + 5, stripW, 34, 6); + bg.fillRoundedRect(stripX + ui(4), stripY + ui(5), stripW, ui(34), ui(6)); bg.fillStyle(cutsceneUiColors.lacquer, 0.72); - bg.fillRoundedRect(stripX, stripY, stripW, 34, 6); - bg.lineStyle(1, palette.gold, 0.18); - bg.lineBetween(stripX + 12, stripY + 6, stripX + stripW - 12, stripY + 6); - bg.lineBetween(stripX + 12, stripY + 28, stripX + stripW - 12, stripY + 28); + bg.fillRoundedRect(stripX, stripY, stripW, ui(34), ui(6)); + bg.lineStyle(ui(1), palette.gold, 0.18); + bg.lineBetween(stripX + ui(12), stripY + ui(6), stripX + stripW - ui(12), stripY + ui(6)); + bg.lineBetween(stripX + ui(12), stripY + ui(28), stripX + stripW - ui(12), stripY + ui(28)); layer.add(bg); rewards.slice(0, maxItems).forEach((reward, index) => { @@ -980,17 +984,17 @@ export class StoryScene extends Phaser.Scene { const x = stripX + index * itemW; const item = this.add.graphics(); item.fillStyle(toneColor, 0.16); - item.fillCircle(x + 17, stripY + 17, 5); - item.lineStyle(1, toneColor, 0.22); + item.fillCircle(x + ui(17), stripY + ui(17), ui(5)); + item.lineStyle(ui(1), toneColor, 0.22); if (index > 0) { - item.lineBetween(x, stripY + 9, x, stripY + 25); + item.lineBetween(x, stripY + ui(9), x, stripY + ui(25)); } - const text = this.add.text(x + 30, stripY + 8, reward.label, { + const text = this.add.text(x + ui(30), stripY + ui(8), reward.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: uiPx(13), color: '#f2e3bf', fontStyle: '700', - wordWrap: { width: itemW - 40, useAdvancedWrap: true } + wordWrap: { width: itemW - ui(40), useAdvancedWrap: true } }); layer.add([item, text]); }); @@ -998,10 +1002,10 @@ export class StoryScene extends Phaser.Scene { private cutsceneStageBounds(): CutsceneStageBounds { return { - x: 58, - y: 98, - width: this.scale.width - 116, - height: 348 + x: ui(58), + y: ui(98), + width: this.scale.width - ui(116), + height: ui(348) }; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 66b200e..8a8e033 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -12,6 +12,10 @@ import { import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; +const fhdUiScale = 1.5; +const ui = (value: number) => value * fhdUiScale; +const uiPx = (value: number) => `${Math.round(ui(value))}px`; + export class TitleScene extends Phaser.Scene { private focusedButton?: Phaser.GameObjects.Text; private settingsPanel?: Phaser.GameObjects.Container; @@ -93,11 +97,11 @@ export class TitleScene extends Phaser.Scene { const coverScale = Math.max(width / texture.width, height / texture.height); background.setScale(coverScale * 1.08); - background.setPosition(width / 2 - 18, height / 2 + 4); + background.setPosition(width / 2 - ui(18), height / 2 + ui(4)); this.tweens.add({ targets: background, - x: width / 2 + 18, - y: height / 2 - 6, + x: width / 2 + ui(18), + y: height / 2 - ui(6), scale: coverScale * 1.12, duration: 18000, ease: 'Sine.easeInOut', @@ -142,11 +146,11 @@ export class TitleScene extends Phaser.Scene { } private drawAtmosphere(width: number, height: number) { - const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, 190, 0xd8b15f, 0.04); + const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, ui(190), 0xd8b15f, 0.04); this.tweens.add({ targets: mist, alpha: 0.11, - x: width / 2 + 22, + x: width / 2 + ui(22), duration: 8000, ease: 'Sine.easeInOut', yoyo: true, @@ -156,78 +160,78 @@ export class TitleScene extends Phaser.Scene { for (let index = 0; index < 34; index += 1) { const petal = this.add.image( Phaser.Math.Between(0, width), - Phaser.Math.Between(-80, height), + Phaser.Math.Between(-ui(80), height), 'petal' ); const scale = Phaser.Math.FloatBetween(0.35, 0.9); - petal.setScale(scale); + petal.setScale(scale * fhdUiScale); petal.setAlpha(Phaser.Math.FloatBetween(0.22, 0.62)); petal.setRotation(Phaser.Math.FloatBetween(-1.2, 1.2)); this.tweens.add({ targets: petal, - x: petal.x + Phaser.Math.Between(80, 220), - y: height + Phaser.Math.Between(40, 180), + x: petal.x + Phaser.Math.Between(ui(80), ui(220)), + y: height + Phaser.Math.Between(ui(40), ui(180)), rotation: petal.rotation + Phaser.Math.FloatBetween(2.4, 5.6), duration: Phaser.Math.Between(12000, 24000), delay: Phaser.Math.Between(0, 6000), ease: 'Sine.easeInOut', repeat: -1, onRepeat: () => { - petal.setPosition(Phaser.Math.Between(-120, width), Phaser.Math.Between(-180, -40)); + petal.setPosition(Phaser.Math.Between(-ui(120), width), Phaser.Math.Between(-ui(180), -ui(40))); } }); } } private drawTitleMark(width: number, height: number) { - const compact = width < 720; - const titleX = compact ? width / 2 : 92; - const title = this.add.text(titleX, height - (compact ? 190 : 204), '삼국지', { + const compact = width < ui(720); + const titleX = compact ? width / 2 : ui(92); + const title = this.add.text(titleX, height - ui(compact ? 190 : 204), '삼국지', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: compact ? '50px' : '64px', + fontSize: uiPx(compact ? 50 : 64), color: '#f1e3c2', fontStyle: '700', - padding: { top: 8, bottom: 8 }, - shadow: { offsetX: 0, offsetY: 4, color: '#080a0e', blur: 10, fill: true } + padding: { top: ui(8), bottom: ui(8) }, + shadow: { offsetX: 0, offsetY: ui(4), color: '#080a0e', blur: ui(10), fill: true } }); title.setOrigin(compact ? 0.5 : 0, 0); - const subtitle = this.add.text(compact ? width / 2 : 96, height - (compact ? 112 : 118), '세 형제의 맹세', { + const subtitle = this.add.text(compact ? width / 2 : ui(96), height - ui(compact ? 112 : 118), '세 형제의 맹세', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', - fontSize: compact ? '26px' : '34px', + fontSize: uiPx(compact ? 26 : 34), color: '#d8b15f', fontStyle: '700', - padding: { top: 10, bottom: 8 }, - shadow: { offsetX: 0, offsetY: 2, color: '#080a0e', blur: 6, fill: true } + padding: { top: ui(10), bottom: ui(8) }, + shadow: { offsetX: 0, offsetY: ui(2), color: '#080a0e', blur: ui(6), fill: true } }); subtitle.setOrigin(compact ? 0.5 : 0, 0); - const lineX = compact ? width / 2 - 110 : 98; - const lineY = height - (compact ? 76 : 70); - this.add.rectangle(lineX, lineY, compact ? 220 : 310, 2, palette.gold, 0.68).setOrigin(0, 0.5); + const lineX = compact ? width / 2 - ui(110) : ui(98); + const lineY = height - ui(compact ? 76 : 70); + this.add.rectangle(lineX, lineY, ui(compact ? 220 : 310), ui(2), palette.gold, 0.68).setOrigin(0, 0.5); } private drawMenu(width: number, height: number) { - const compact = width < 720; - const menuX = compact ? width / 2 : width - 318; + const compact = width < ui(720); + const menuX = compact ? width / 2 : width - ui(318); const menuY = height * (compact ? 0.36 : 0.43); const canContinue = hasCampaignSave(); const campaign = canContinue ? loadCampaignState() : undefined; const isEndingComplete = campaign?.step === 'ending-complete'; - const plateHeight = campaign ? 348 : 252; + const plateHeight = ui(campaign ? 348 : 252); - const plate = this.add.rectangle(menuX, menuY, 250, plateHeight, 0x111821, 0.66); - plate.setStrokeStyle(1, palette.gold, 0.45); - this.add.rectangle(menuX, menuY - plateHeight / 2, 186, 2, palette.gold, 0.7); - this.add.rectangle(menuX, menuY + plateHeight / 2, 186, 2, palette.gold, 0.36); + const plate = this.add.rectangle(menuX, menuY, ui(250), plateHeight, 0x111821, 0.66); + plate.setStrokeStyle(ui(1), palette.gold, 0.45); + this.add.rectangle(menuX, menuY - plateHeight / 2, ui(186), ui(2), palette.gold, 0.7); + this.add.rectangle(menuX, menuY + plateHeight / 2, ui(186), ui(2), palette.gold, 0.36); - this.createMenuButton(menuX, menuY - 70, '새 게임', true, () => this.requestStartGame()); + this.createMenuButton(menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame()); this.createMenuButton(menuX, menuY, isEndingComplete ? '엔딩 보기' : '이어하기', canContinue, () => this.requestContinueGame()); - this.createMenuButton(menuX, menuY + 70, '설정', true, () => this.openSettingsPanel(menuX, menuY)); + this.createMenuButton(menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY)); if (campaign) { - this.drawCampaignSaveBadge(menuX, menuY + 132, campaign, isEndingComplete); + this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete); } } @@ -238,34 +242,34 @@ export class TitleScene extends Phaser.Scene { isEndingComplete: boolean ) { const badge = this.add.container(x, y); - const background = this.add.rectangle(0, 0, 216, 78, 0x21180f, 0.92); - background.setStrokeStyle(1, palette.gold, 0.52); + const background = this.add.rectangle(0, 0, ui(216), ui(78), 0x21180f, 0.92); + background.setStrokeStyle(ui(1), palette.gold, 0.52); - const title = this.add.text(0, -24, isEndingComplete ? '완결 저장' : '진행 저장', { + const title = this.add.text(0, -ui(24), isEndingComplete ? '완결 저장' : '진행 저장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: uiPx(18), color: '#f6e6bd', fontStyle: '700', - fixedWidth: 198, + fixedWidth: ui(198), align: 'center', - padding: { top: 3, bottom: 3 } + padding: { top: ui(3), bottom: ui(3) } }); title.setOrigin(0.5); const detail = this.add.text(0, 0, this.campaignSaveDetail(campaign, isEndingComplete), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', + fontSize: uiPx(14), color: '#d8b15f', - fixedWidth: 198, + fixedWidth: ui(198), align: 'center' }); detail.setOrigin(0.5); - const meta = this.add.text(0, 24, this.campaignSaveMeta(campaign), { + const meta = this.add.text(0, ui(24), this.campaignSaveMeta(campaign), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', + fontSize: uiPx(12), color: '#9fb0bf', - fixedWidth: 198, + fixedWidth: ui(198), align: 'center' }); meta.setOrigin(0.5); @@ -311,21 +315,21 @@ export class TitleScene extends Phaser.Scene { onSelect: () => void ) { const text = this.add.text(x, y, label, { - fontSize: '30px', + fontSize: uiPx(30), color: enabled ? '#f1e3c2' : '#7d8189', fontStyle: '700', - fixedWidth: 190, + fixedWidth: ui(190), align: 'center', - padding: { top: 10, bottom: 10 } + padding: { top: ui(10), bottom: ui(10) } }); text.setOrigin(0.5); - text.setShadow(0, 3, '#080a0e', 8, true, true); + text.setShadow(0, ui(3), '#080a0e', ui(8), true, true); if (!enabled) { return text; } - const hitArea = this.add.zone(x, y, 216, 58); + const hitArea = this.add.zone(x, y, ui(216), ui(58)); hitArea.setOrigin(0.5); hitArea.setInteractive({ useHandCursor: true }); hitArea.on('pointerover', () => { @@ -366,38 +370,38 @@ export class TitleScene extends Phaser.Scene { this.closeNewGameConfirmPanel(); this.closeSaveSlotPanel(); - const panel = this.add.container(x, y + 238); - const backdrop = this.add.rectangle(0, 0, 286, 204, 0x0e151d, 0.9); - backdrop.setStrokeStyle(1, palette.gold, 0.52); + const panel = this.add.container(x, y + ui(238)); + const backdrop = this.add.rectangle(0, 0, ui(286), ui(204), 0x0e151d, 0.9); + backdrop.setStrokeStyle(ui(1), palette.gold, 0.52); - const title = this.add.text(0, -74, '설정', { - fontSize: '24px', + const title = this.add.text(0, -ui(74), '설정', { + fontSize: uiPx(24), color: '#f1e3c2', fontStyle: '700', - fixedWidth: 210, + fixedWidth: ui(210), align: 'center' }); title.setOrigin(0.5); - const soundText = this.createSettingsButton(0, -16, this.soundLabel()); + const soundText = this.createSettingsButton(0, -ui(16), this.soundLabel()); soundText.on('pointerdown', () => { soundDirector.setMuted(!soundDirector.isMuted()); soundText.setText(this.soundLabel()); soundDirector.playSelect(); }); - const close = this.createSettingsButton(0, 42, '닫기'); + const close = this.createSettingsButton(0, ui(42), '닫기'); close.on('pointerdown', () => { soundDirector.playSelect(); this.closeSettingsPanel(); }); - const hint = this.add.text(0, 78, 'Esc 닫기', { + const hint = this.add.text(0, ui(78), 'Esc 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: uiPx(11), color: '#9fb0bf', fontStyle: '700', - fixedWidth: 210, + fixedWidth: ui(210), align: 'center' }); hint.setOrigin(0.5); @@ -405,16 +409,16 @@ export class TitleScene extends Phaser.Scene { panel.add([backdrop, title, soundText, close, hint]); panel.setAlpha(0); this.settingsPanel = panel; - this.tweens.add({ targets: panel, alpha: 1, y: y + 228, duration: 160, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: panel, alpha: 1, y: y + ui(228), duration: 160, ease: 'Sine.easeOut' }); } private createSettingsButton(x: number, y: number, label: string) { const text = this.add.text(x, y, label, { - fontSize: '20px', + fontSize: uiPx(20), color: '#d8b15f', - fixedWidth: 210, + fixedWidth: ui(210), align: 'center', - padding: { top: 8, bottom: 8 } + padding: { top: ui(8), bottom: ui(8) } }); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); @@ -445,8 +449,8 @@ export class TitleScene extends Phaser.Scene { } const { width, height } = this.scale; - const compact = width < 720; - const menuX = compact ? width / 2 : width - 318; + const compact = width < ui(720); + const menuX = compact ? width / 2 : width - ui(318); const menuY = height * (compact ? 0.36 : 0.43); this.openSaveSlotPanel(menuX, menuY); } @@ -461,33 +465,33 @@ export class TitleScene extends Phaser.Scene { this.closeSettingsPanel(); this.closeNewGameConfirmPanel(); - const panel = this.add.container(x, y + 252); - const backdrop = this.add.rectangle(0, 0, 318, 280, 0x0e151d, 0.95); - backdrop.setStrokeStyle(1, palette.gold, 0.58); + const panel = this.add.container(x, y + ui(252)); + const backdrop = this.add.rectangle(0, 0, ui(318), ui(280), 0x0e151d, 0.95); + backdrop.setStrokeStyle(ui(1), palette.gold, 0.58); - const title = this.add.text(0, -116, '이어갈 기록 선택 (1~3)', { + const title = this.add.text(0, -ui(116), '이어갈 기록 선택 (1~3)', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '22px', + fontSize: uiPx(22), color: '#f1e3c2', fontStyle: '700', - fixedWidth: 266, + fixedWidth: ui(266), align: 'center' }); title.setOrigin(0.5); - const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -82 + index * 58)); - const close = this.createSettingsButton(0, 96, '닫기'); + const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -ui(82) + index * ui(58))); + const close = this.createSettingsButton(0, ui(96), '닫기'); close.on('pointerdown', () => { soundDirector.playSelect(); this.closeSaveSlotPanel(); }); - const hint = this.add.text(0, 126, '숫자키 1~3 선택 · Esc 닫기', { + const hint = this.add.text(0, ui(126), '숫자키 1~3 선택 · Esc 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: uiPx(11), color: '#9fb0bf', fontStyle: '700', - fixedWidth: 260, + fixedWidth: ui(260), align: 'center' }); hint.setOrigin(0.5); @@ -495,50 +499,50 @@ export class TitleScene extends Phaser.Scene { panel.add([backdrop, title, ...rows, close, hint]); panel.setAlpha(0); this.saveSlotPanel = panel; - this.tweens.add({ targets: panel, alpha: 1, y: y + 242, duration: 160, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: panel, alpha: 1, y: y + ui(242), duration: 160, ease: 'Sine.easeOut' }); } private createSaveSlotRow(slot: CampaignSaveSlotSummary, y: number) { const row = this.add.container(0, y); const enabled = slot.exists; - const background = this.add.rectangle(0, 0, 270, 48, enabled ? 0x1b2530 : 0x121820, enabled ? 0.9 : 0.56); - background.setStrokeStyle(1, enabled ? palette.gold : 0x56616d, enabled ? 0.42 : 0.2); + const background = this.add.rectangle(0, 0, ui(270), ui(48), enabled ? 0x1b2530 : 0x121820, enabled ? 0.9 : 0.56); + background.setStrokeStyle(ui(1), enabled ? palette.gold : 0x56616d, enabled ? 0.42 : 0.2); const campaign = enabled ? loadCampaignState(slot.slot) : undefined; const isEndingComplete = campaign?.step === 'ending-complete'; - const title = this.add.text(-122, -12, `슬롯 ${slot.slot}`, { + const title = this.add.text(-ui(122), -ui(12), `슬롯 ${slot.slot}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: uiPx(16), color: enabled ? '#f6e6bd' : '#7d8189', fontStyle: '700', - fixedWidth: 70, + fixedWidth: ui(70), align: 'left' }); title.setOrigin(0, 0.5); - const detail = this.add.text(-42, -12, campaign ? this.campaignSaveDetail(campaign, isEndingComplete) : '저장 없음', { + const detail = this.add.text(-ui(42), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete) : '저장 없음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: uiPx(13), color: enabled ? '#d8b15f' : '#747c86', - fixedWidth: 154, + fixedWidth: ui(154), align: 'left' }); detail.setOrigin(0, 0.5); - const meta = this.add.text(-42, 12, campaign ? this.campaignSaveMeta(campaign) : '빈 슬롯', { + const meta = this.add.text(-ui(42), ui(12), campaign ? this.campaignSaveMeta(campaign) : '빈 슬롯', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: uiPx(11), color: enabled ? '#9fb0bf' : '#626b75', - fixedWidth: 154, + fixedWidth: ui(154), align: 'left' }); meta.setOrigin(0, 0.5); - const keycap = this.add.rectangle(112, 0, 28, 28, enabled ? 0x263647 : 0x151c25, enabled ? 0.92 : 0.58); - keycap.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.54 : 0.24); - const keyText = this.add.text(112, 0, String(slot.slot), { + const keycap = this.add.rectangle(ui(112), 0, ui(28), ui(28), enabled ? 0x263647 : 0x151c25, enabled ? 0.92 : 0.58); + keycap.setStrokeStyle(ui(1), enabled ? palette.gold : 0x53606c, enabled ? 0.54 : 0.24); + const keyText = this.add.text(ui(112), 0, String(slot.slot), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', + fontSize: uiPx(15), color: enabled ? '#f2e3bf' : '#737c86', fontStyle: '700' }); @@ -550,12 +554,12 @@ export class TitleScene extends Phaser.Scene { background.setInteractive({ useHandCursor: true }); background.on('pointerover', () => { background.setFillStyle(0xd8b15f, 0.22); - background.setStrokeStyle(1, palette.gold, 0.72); + background.setStrokeStyle(ui(1), palette.gold, 0.72); soundDirector.playHover(); }); background.on('pointerout', () => { background.setFillStyle(0x1b2530, 0.9); - background.setStrokeStyle(1, palette.gold, 0.42); + background.setStrokeStyle(ui(1), palette.gold, 0.42); }); background.on('pointerdown', () => { this.activateSaveSlot(slot.slot); @@ -637,8 +641,8 @@ export class TitleScene extends Phaser.Scene { } const { width, height } = this.scale; - const compact = width < 720; - const menuX = compact ? width / 2 : width - 318; + const compact = width < ui(720); + const menuX = compact ? width / 2 : width - ui(318); const menuY = height * (compact ? 0.36 : 0.43); this.openNewGameConfirmPanel(menuX, menuY); } @@ -653,48 +657,48 @@ export class TitleScene extends Phaser.Scene { this.closeSettingsPanel(); this.closeSaveSlotPanel(); - const panel = this.add.container(x, y + 240); - const backdrop = this.add.rectangle(0, 0, 286, 198, 0x0e151d, 0.94); - backdrop.setStrokeStyle(1, palette.gold, 0.58); + const panel = this.add.container(x, y + ui(240)); + const backdrop = this.add.rectangle(0, 0, ui(286), ui(198), 0x0e151d, 0.94); + backdrop.setStrokeStyle(ui(1), palette.gold, 0.58); - const title = this.add.text(0, -72, '새로 시작', { + const title = this.add.text(0, -ui(72), '새로 시작', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '23px', + fontSize: uiPx(23), color: '#f1e3c2', fontStyle: '700', - fixedWidth: 220, + fixedWidth: ui(220), align: 'center' }); title.setOrigin(0.5); - const message = this.add.text(0, -32, '현재 저장을 덮고\n처음부터 시작합니다.', { + const message = this.add.text(0, -ui(32), '현재 저장을 덮고\n처음부터 시작합니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', + fontSize: uiPx(15), color: '#bfc8d3', - fixedWidth: 236, + fixedWidth: ui(236), align: 'center', - lineSpacing: 4 + lineSpacing: ui(4) }); message.setOrigin(0.5); - const hint = this.add.text(0, 13, 'Enter 새 게임 · Esc 취소', { + const hint = this.add.text(0, ui(13), 'Enter 새 게임 · Esc 취소', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '11px', + fontSize: uiPx(11), color: '#9fb0bf', fontStyle: '700', - fixedWidth: 220, + fixedWidth: ui(220), align: 'center' }); hint.setOrigin(0.5); - const confirm = this.createSettingsButton(0, 48, '새 게임 시작'); + const confirm = this.createSettingsButton(0, ui(48), '새 게임 시작'); confirm.setColor('#f1e3c2'); confirm.on('pointerdown', () => { this.closeNewGameConfirmPanel(); this.startGame(); }); - const cancel = this.createSettingsButton(0, 88, '취소'); + const cancel = this.createSettingsButton(0, ui(88), '취소'); cancel.on('pointerdown', () => { soundDirector.playSelect(); this.closeNewGameConfirmPanel(); @@ -703,7 +707,7 @@ export class TitleScene extends Phaser.Scene { panel.add([backdrop, title, message, hint, confirm, cancel]); panel.setAlpha(0); this.newGameConfirmPanel = panel; - this.tweens.add({ targets: panel, alpha: 1, y: y + 230, duration: 160, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: panel, alpha: 1, y: y + ui(230), duration: 160, ease: 'Sine.easeOut' }); } private closeNewGameConfirmPanel() { diff --git a/src/main.ts b/src/main.ts index 5bf7765..94c4cf2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,8 +46,8 @@ const rendererType = const config: Phaser.Types.Core.GameConfig = { type: rendererType, parent: 'game', - width: 1280, - height: 720, + width: 1920, + height: 1080, backgroundColor: '#10131a', scale: { mode: Phaser.Scale.FIT,