diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 206bc1c..63bcef9 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -102,12 +102,75 @@ try { initialStoryProgress.advanceHint === 'next' && initialStoryProgress.isLastPage === false && boundsWithinFhdViewport(initialStoryProgress.progressBounds) && - boundsWithinFhdViewport(initialStoryProgress.progressPanelBounds), + boundsWithinFhdViewport(initialStoryProgress.progressPanelBounds) && + boundsInside(initialStoryProgress.progressBounds, initialStoryProgress.progressPanelBounds), `Expected localized, FHD-safe story progress guidance: ${JSON.stringify(initialStoryProgress)}` ); await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true }); await assertCanvasPainted(page, 'new game story'); - await advanceUntilBattle(page, 'first-battle-zhuo-commandery'); + const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', { + startDeployment: false, + captureLastStory: true + }); + assert( + firstBattleTransition.lastStory?.isLastPage === true && + firstBattleTransition.lastStory?.nextScene === 'BattleScene' && + firstBattleTransition.lastStory?.advanceHint === 'battle-deployment' && + firstBattleTransition.lastStory?.advanceLabel === '전투 배치' && + firstBattleTransition.lastStory?.progressLabel?.includes('전투 배치') && + boundsWithinFhdViewport(firstBattleTransition.lastStory?.progressBounds) && + boundsWithinFhdViewport(firstBattleTransition.lastStory?.progressPanelBounds) && + boundsInside( + firstBattleTransition.lastStory?.progressBounds, + firstBattleTransition.lastStory?.progressPanelBounds + ), + `Expected the final prologue page to name the next action as battle deployment: ${JSON.stringify(firstBattleTransition)}` + ); + const firstBattleDeployment = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const firstDeploymentPanel = firstBattleDeployment?.battleHud?.deploymentPanel; + assert( + firstBattleDeployment?.phase === 'deployment' && + firstDeploymentPanel?.recommended === true && + firstDeploymentPanel?.changeCount === 0 && + boundsInside(firstDeploymentPanel?.statusBounds, firstDeploymentPanel?.headerBounds), + `Expected first deployment to show an FHD-safe recommended-formation status chip: ${JSON.stringify(firstBattleDeployment)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-first-battle-deployment.png`, fullPage: true }); + await assertCanvasPainted(page, 'first battle deployment'); + + const changedFirstDeployment = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const recommended = scene?.deploymentSlots?.().filter((slot) => slot.unitId) ?? []; + const first = recommended[0]?.unitId ? scene?.debugUnitById?.(recommended[0].unitId) : undefined; + const second = recommended[1]?.unitId ? scene?.debugUnitById?.(recommended[1].unitId) : undefined; + if (!scene || !first || !second) { + return null; + } + scene.swapDeploymentUnits(first, second); + return window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel ?? null; + }); + assert( + changedFirstDeployment?.recommended === false && + changedFirstDeployment?.changeCount >= 2 && + boundsInside(changedFirstDeployment?.statusBounds, changedFirstDeployment?.headerBounds), + `Expected a changed first deployment to expose a restore-ready status: ${JSON.stringify(changedFirstDeployment)}` + ); + await page.waitForTimeout(220); + const restoredFirstDeployment = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.restoreRecommendedDeployment?.(); + return window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel ?? null; + }); + assert( + restoredFirstDeployment?.recommended === true && restoredFirstDeployment?.changeCount === 0, + `Expected one restore action to return the first deployment to the recommended formation: ${JSON.stringify(restoredFirstDeployment)}` + ); + await page.waitForTimeout(180); + await clickBattleDeploymentStart(page); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.battleId === 'first-battle-zhuo-commandery' && state?.phase === 'idle' && state?.triggeredBattleEvents?.includes('opening'); + }, undefined, { timeout: 30000 }); await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle'); @@ -115,6 +178,7 @@ try { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const sideTexts = textValues(scene?.sidePanelObjects); + const bannerTexts = textValues(scene?.battleEventObjects); const miniMapTexts = textValues((scene?.miniMapObjects ?? []).filter((object) => object?.active && object?.visible)); const miniMapFrame = (scene?.miniMapObjects ?? []).find((object) => object?.name === 'mini-map-frame'); return { @@ -122,6 +186,7 @@ try { objectiveText: scene?.objectiveTrackerText?.text, objectiveSubText: scene?.objectiveTrackerSubText?.text, sideTexts, + bannerTexts, miniMapTexts, actualMiniMapFrameBounds: miniMapFrame ? boundsValue(miniMapFrame.getBounds()) : null, hud: state?.battleHud ?? null, @@ -156,9 +221,10 @@ try { `Expected the compact objective subtext to retain only the defeat condition without duplicate route or bonus guidance: ${JSON.stringify(firstBattleProbe)}` ); assert( - firstBattleProbe.sideTexts.some((text) => text.includes('출진 군세')) && - !firstBattleProbe.sideTexts.some((text) => text.includes('...')), - `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` + firstBattleProbe.bannerTexts.some((text) => text.includes('출진 군세')) && + firstBattleProbe.bannerTexts.some((text) => text.includes('첫 행동')) && + !firstBattleProbe.bannerTexts.some((text) => text.includes('...')), + `Expected sortie doctrine and first-action guidance without ASCII truncation: ${JSON.stringify(firstBattleProbe.bannerTexts)}` ); const firstBattleMiniMap = firstBattleProbe.hud?.miniMap; const firstBattleRecentActions = firstBattleProbe.hud?.recentActions; @@ -206,6 +272,93 @@ try { firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y, `Expected the FHD recent-action panel to show three noncompact rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}` ); + const firstBattleMapFeedback = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const unit = scene?.debugUnitById?.('liu-bei'); + const view = unit ? scene?.unitViews?.get(unit.id) : undefined; + if (!scene || !unit || !view) { + return null; + } + + const originalX = view.sprite.x; + const originalY = view.sprite.y; + const originalSpeed = scene.battleSpeed; + scene.battleSpeed = 'normal'; + view.sprite.x = scene.layout.mapX + 1; + view.sprite.y = scene.layout.mapY + 1; + scene.showMapResultPopup(unit, ['치명 피해 -999', 'HP 999→1', '공명 추격 +99'], '#ff8f5f', '#220909', 20, 38); + const normal = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback; + const normalActualBounds = (scene.mapResultPopupObjects ?? []) + .filter((object) => object?.active) + .map((object) => boundsValue(object.getBounds())); + scene.battleSpeed = 'very-fast'; + for (let index = 0; index < 6; index += 1) { + scene.showMapResultPopup(unit, [`연속 판정 ${index + 1}`, 'HP 경계 확인'], '#ffdf7b', '#220909', 18, 30); + } + const fast = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback; + const fastActualBounds = (scene.mapResultPopupObjects ?? []) + .filter((object) => object?.active) + .map((object) => boundsValue(object.getBounds())); + view.sprite.x = originalX; + view.sprite.y = originalY; + scene.battleSpeed = originalSpeed; + scene.renderBattleSpeedControl?.(); + return { + normal: { ...normal, actualBounds: normalActualBounds }, + fast: { ...fast, actualBounds: fastActualBounds } + }; + + function boundsValue(bounds) { + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + } + }); + assert( + firstBattleMapFeedback?.normal?.last?.duration === 680 && + firstBattleMapFeedback.normal.last.delay === 80 && + boundsInside(firstBattleMapFeedback.normal.last.bounds, firstBattleMapFeedback.normal.mapBounds) && + boundsInside(firstBattleMapFeedback.normal.last.endBounds, firstBattleMapFeedback.normal.mapBounds) && + firstBattleMapFeedback.fast?.last?.duration >= 260 && + firstBattleMapFeedback.fast.last.duration < firstBattleMapFeedback.normal.last.duration && + firstBattleMapFeedback.fast.last.delay >= 30 && + firstBattleMapFeedback.fast.last.delay < firstBattleMapFeedback.normal.last.delay && + boundsInside(firstBattleMapFeedback.fast.last.bounds, firstBattleMapFeedback.fast.mapBounds) && + boundsInside(firstBattleMapFeedback.fast.last.endBounds, firstBattleMapFeedback.fast.mapBounds) && + firstBattleMapFeedback.normal.actualBounds.length === 1 && + firstBattleMapFeedback.normal.actualBounds.every((bounds) => + boundsInside(bounds, firstBattleMapFeedback.normal.mapBounds) + ) && + firstBattleMapFeedback.fast.actualBounds.length > 0 && + firstBattleMapFeedback.fast.actualBounds.every((bounds) => + boundsInside(bounds, firstBattleMapFeedback.fast.mapBounds) + ) && + firstBattleMapFeedback.fast.activeCount <= firstBattleMapFeedback.fast.maxActive && + firstBattleMapFeedback.fast.peakCount <= firstBattleMapFeedback.fast.maxActive, + `Expected bounded, speed-aware, capped map result feedback at FHD: ${JSON.stringify(firstBattleMapFeedback)}` + ); + await page.waitForTimeout(120); + const animatingMapFeedback = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const state = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback; + return { + mapBounds: state?.mapBounds ?? null, + activeBounds: (scene?.mapResultPopupObjects ?? []) + .filter((object) => object?.active) + .map((object) => { + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + }) + }; + }); + assert( + animatingMapFeedback.activeBounds.length > 0 && + animatingMapFeedback.activeBounds.every((bounds) => boundsInside(bounds, animatingMapFeedback.mapBounds)), + `Expected animated map result feedback to remain inside the map: ${JSON.stringify(animatingMapFeedback)}` + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.mapResultFeedback?.activeCount === 0, + undefined, + { timeout: 3000 } + ); await setDebugQueryParam(page, 'debugForceBondChain', '1'); const firstBattleBondChain = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -2057,7 +2210,21 @@ try { await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 }); await clickLegacyUi(page, 738, 642); - await waitForCampAfterBattleResult(page); + const firstCampTransition = await waitForCampAfterBattleResult(page); + assert( + firstCampTransition.lastStory?.isLastPage === true && + firstCampTransition.lastStory?.nextScene === 'CampScene' && + firstCampTransition.lastStory?.advanceHint === 'camp-return' && + firstCampTransition.lastStory?.advanceLabel === '군영으로' && + firstCampTransition.lastStory?.progressLabel?.includes('군영으로') && + boundsWithinFhdViewport(firstCampTransition.lastStory?.progressBounds) && + boundsWithinFhdViewport(firstCampTransition.lastStory?.progressPanelBounds) && + boundsInside( + firstCampTransition.lastStory?.progressBounds, + firstCampTransition.lastStory?.progressPanelBounds + ), + `Expected the post-battle story to name the next action as returning to camp: ${JSON.stringify(firstCampTransition)}` + ); await waitForCampSkinTransition(page, 'yellow-turban'); await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience'); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); @@ -2643,6 +2810,21 @@ try { undefined, { timeout: 30000 } ); + const firstCampSecondaryPanels = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels); + const firstCampSuppliesPanel = firstCampSecondaryPanels?.supplies; + const firstCampReportPanel = firstCampSecondaryPanels?.report; + assert( + firstCampSuppliesPanel?.visible === true && + firstCampReportPanel?.visible === true && + boundsWithinFhdViewport(firstCampSuppliesPanel.bounds) && + boundsWithinFhdViewport(firstCampReportPanel.bounds) && + firstCampSuppliesPanel.equipmentBoxBounds?.length > 0 && + firstCampSuppliesPanel.trophyBoxBounds?.length > 0 && + firstCampSuppliesPanel.equipmentBoxBounds?.every((bounds) => boundsInside(bounds, firstCampSuppliesPanel.bounds)) && + firstCampSuppliesPanel.trophyBoxBounds.every((bounds) => boundsInside(bounds, firstCampSuppliesPanel.bounds)) && + firstCampSuppliesPanel.bounds.y + firstCampSuppliesPanel.bounds.height <= firstCampReportPanel.bounds.y, + `Expected supply cards inside their FHD panel without overlapping the battle report: ${JSON.stringify(firstCampSecondaryPanels)}` + ); const firstCampTabAudioState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.audio); assert( firstCampTabAudioState?.music?.currentKey === firstCampProbe.state?.audio?.music?.currentKey && @@ -2712,6 +2894,96 @@ try { })}` ); + const equipmentPaginationFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene?.campaign) { + return null; + } + const originalInventory = { ...scene.campaign.inventory }; + ['황건도', '단궁', '두령부', '철창', '포의', '찰갑'].forEach((label) => { + scene.campaign.inventory[label] = Math.max(1, scene.campaign.inventory[label] ?? 0); + }); + scene.activeTab = 'equipment'; + scene.equipmentInventoryPage = 0; + scene.render(); + return { + originalInventory, + panel: window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment ?? null + }; + }); + assert( + equipmentPaginationFixture?.panel?.visible === true && + equipmentPaginationFixture.panel.page === 0 && + equipmentPaginationFixture.panel.pageCount >= 2 && + equipmentPaginationFixture.panel.visibleItemIds?.length === equipmentPaginationFixture.panel.pageSize && + equipmentPaginationFixture.panel.nextEnabled === true && + boundsWithinFhdViewport(equipmentPaginationFixture.panel.bounds) && + boundsInside(equipmentPaginationFixture.panel.nextButtonBounds, equipmentPaginationFixture.panel.bounds), + `Expected the equipment manager to expose the first of multiple inventory pages: ${JSON.stringify(equipmentPaginationFixture)}` + ); + const equipmentNextBounds = equipmentPaginationFixture.panel.nextButtonBounds; + await page.mouse.click( + equipmentNextBounds.x + equipmentNextBounds.width / 2, + equipmentNextBounds.y + equipmentNextBounds.height / 2 + ); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment?.page === 1, undefined, { timeout: 30000 }); + const equipmentSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment); + assert( + equipmentSecondPage?.visible === true && + equipmentSecondPage.page === 1 && + equipmentSecondPage.page === equipmentSecondPage.pageCount - 1 && + equipmentSecondPage.previousEnabled === true && + equipmentSecondPage.nextEnabled === false && + equipmentSecondPage.visibleItemIds?.length > 0 && + equipmentSecondPage.visibleItemIds.every((itemId) => !equipmentPaginationFixture.panel.visibleItemIds.includes(itemId)) && + boundsInside(equipmentSecondPage.previousButtonBounds, equipmentSecondPage.bounds), + `Expected equipment pagination to reach distinct lower-priority items: ${JSON.stringify({ first: equipmentPaginationFixture.panel, second: equipmentSecondPage })}` + ); + const equipmentClampedPage = await page.evaluate((originalInventory) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene?.campaign) { + return null; + } + scene.campaign.inventory = { ...originalInventory }; + scene.render(); + return window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment ?? null; + }, equipmentPaginationFixture.originalInventory); + assert( + equipmentClampedPage?.page === Math.max(0, equipmentClampedPage?.pageCount - 1) && + equipmentClampedPage?.page < equipmentClampedPage?.pageCount, + `Expected equipment pagination to clamp after the inventory shrinks: ${JSON.stringify(equipmentClampedPage)}` + ); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene) { + return; + } + scene.equipmentInventoryPage = 0; + scene.activeTab = 'supplies'; + scene.render(); + }); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene?.showCampNotice?.('첫 번째 정비 알림'); + scene?.time?.delayedCall(400, () => scene?.showCampNotice?.('두 번째 응답 알림')); + }); + await page.waitForTimeout(1750); + const replacementNotice = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return (scene?.dialogueObjects ?? []) + .filter((object) => object?.active) + .map((object) => object?.text) + .filter(Boolean); + }); + assert( + replacementNotice.some((text) => text.includes('두 번째 응답 알림')), + `Expected a replacement camp notice to survive the earlier notice cleanup: ${JSON.stringify(replacementNotice)}` + ); + await page.waitForFunction(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return (scene?.dialogueObjects ?? []).filter((object) => object?.active).length === 0; + }, undefined, { timeout: 1500 }); + const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? []; const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; await clickLegacyUi(page, 1160, 38); @@ -7240,31 +7512,48 @@ async function waitForStoryOrEnding(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); } -async function advanceUntilBattle(page, battleId) { +async function advanceUntilBattle(page, battleId, options = {}) { + const { startDeployment = true, captureLastStory = false } = options; + let lastStory = null; for (let i = 0; i < 48; i += 1) { - const ready = await page.evaluate((expectedBattleId) => { + const probe = await page.evaluate((expectedBattleId) => { const state = window.__HEROS_DEBUG__?.battle(); - return ( - state?.scene === 'BattleScene' && - state?.battleId === expectedBattleId && - (state?.phase === 'deployment' || state?.phase === 'idle') && - state?.mapBackgroundReady === true - ); + return { + ready: ( + state?.scene === 'BattleScene' && + state?.battleId === expectedBattleId && + (state?.phase === 'deployment' || state?.phase === 'idle') && + state?.mapBackgroundReady === true + ), + story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null + }; }, battleId); - if (ready) { - await startDeploymentIfNeeded(page, battleId); - return; + if ( + captureLastStory && + probe.story?.isLastPage && + probe.story?.progressBounds && + probe.story?.progressPanelBounds + ) { + lastStory = probe.story; + } + + if (probe.ready) { + if (startDeployment) { + await startDeploymentIfNeeded(page, battleId); + } + return { lastStory }; } await page.keyboard.press('Space'); await page.waitForTimeout(220); } - await waitForBattleReady(page, battleId); + await waitForBattleReady(page, battleId, startDeployment); + return { lastStory }; } -async function waitForBattleReady(page, battleId) { +async function waitForBattleReady(page, battleId, startDeployment = true) { await page.waitForFunction((expectedBattleId) => { const state = window.__HEROS_DEBUG__?.battle(); return ( @@ -7274,7 +7563,9 @@ async function waitForBattleReady(page, battleId) { state?.mapBackgroundReady === true ); }, battleId, { timeout: 90000 }); - await startDeploymentIfNeeded(page, battleId); + if (startDeployment) { + await startDeploymentIfNeeded(page, battleId); + } } async function startDeploymentIfNeeded(page, battleId) { @@ -8204,17 +8495,25 @@ async function waitForCampAfterBattleResult(page) { return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene'); }, undefined, { timeout: 90000 }); + let lastStory = null; for (let i = 0; i < 48; i += 1) { - const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); - if (activeScenes.includes('CampScene')) { + const probe = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null + })); + if (probe.story?.isLastPage && probe.story?.progressBounds && probe.story?.progressPanelBounds) { + lastStory = probe.story; + } + if (probe.activeScenes.includes('CampScene')) { await waitForCamp(page); - return; + return { lastStory }; } await page.keyboard.press('Space'); await page.waitForTimeout(240); } await waitForCamp(page); + return { lastStory }; } async function advanceStoryUntilEnding(page) { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 3ad6a31..6eb32af 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1261,6 +1261,9 @@ type RosterPanelLayout = { type DeploymentPanelLayout = { headerBounds: { x: number; y: number; width: number; height: number }; + statusBounds: { x: number; y: number; width: number; height: number }; + changeCount: number; + recommended: boolean; noticeBounds: { x: number; y: number; width: number; height: number }; noticeTextBounds: { x: number; y: number; width: number; height: number }; roleBounds: Array<{ x: number; y: number; width: number; height: number }>; @@ -3510,6 +3513,17 @@ export class BattleScene extends Phaser.Scene { private turnPromptMode?: TurnPromptMode; private turnPromptPrimaryAction?: () => void; private turnPromptSecondaryAction?: () => void; + private mapResultPopupObjects: Phaser.GameObjects.Text[] = []; + private mapResultPopupPeakCount = 0; + private mapResultPopupLast?: { + unitId: string; + lines: string[]; + bounds: HudBounds; + endBounds: HudBounds; + duration: number; + delay: number; + lift: number; + }; private combatCutInRoot?: Phaser.GameObjects.Container; private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private combatCutInStageActive?: CombatCutInStageSnapshot; @@ -3647,6 +3661,10 @@ export class BattleScene extends Phaser.Scene { this.hideBattleResult(); this.hideTacticalCommandCutIn(); this.hideCombatCutIn(); + this.mapResultPopupObjects.forEach((object) => object.active && object.destroy()); + this.mapResultPopupObjects = []; + this.mapResultPopupPeakCount = 0; + this.mapResultPopupLast = undefined; this.combatCutInStageActive = undefined; this.combatCutInStageLast = undefined; this.tacticalCommandCutInCount = 0; @@ -3655,6 +3673,8 @@ export class BattleScene extends Phaser.Scene { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.hideTacticalCommandCutIn(); this.hideCombatCutIn(); + this.mapResultPopupObjects.forEach((object) => object.active && object.destroy()); + this.mapResultPopupObjects = []; }); this.resultFormationReviewVisible = false; this.resultFormationReviewFeedback = ''; @@ -7244,6 +7264,15 @@ export class BattleScene extends Phaser.Scene { return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.'; } + private deploymentChangeCount() { + return this.deploymentSlots() + .filter((slot): slot is DeploymentSlot & { unitId: string } => Boolean(slot.unitId)) + .filter((slot) => { + const unit = battleUnits.find((candidate) => candidate.id === slot.unitId && candidate.hp > 0); + return !unit || unit.x !== slot.x || unit.y !== slot.y; + }).length; + } + private deploymentSubtitle() { if (this.launchSortieRecommendation) { return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`; @@ -7648,6 +7677,8 @@ export class BattleScene extends Phaser.Scene { const noticeHeight = this.battleUiLength(52); const roleRowHeight = this.battleUiLength(40); const buttonHeight = this.battleUiLength(30); + const deploymentChangeCount = this.deploymentChangeCount(); + const recommendedDeployment = deploymentChangeCount === 0; const header = this.trackSideObject(this.add.rectangle(left, top, width, headerHeight, 0x101820, 0.96)); header.setOrigin(0); @@ -7658,6 +7689,26 @@ export class BattleScene extends Phaser.Scene { color: '#f4dfad', fontStyle: '700' })); + const statusWidth = this.battleUiLength(122); + const statusHeight = this.battleUiLength(20); + const statusLeft = left + width - statusWidth - this.battleUiLength(10); + const statusTop = top + this.battleUiLength(8); + const statusTone = recommendedDeployment ? palette.green : palette.gold; + const status = this.trackSideObject(this.add.rectangle(statusLeft, statusTop, statusWidth, statusHeight, recommendedDeployment ? 0x15261f : 0x2b2114, 0.96)); + status.setOrigin(0); + status.setStrokeStyle(this.battleUiLength(1), statusTone, recommendedDeployment ? 0.72 : 0.92); + const statusText = this.trackSideObject(this.add.text( + statusLeft + statusWidth / 2, + statusTop + statusHeight / 2, + recommendedDeployment ? '권장 배치 유지' : `변경 ${deploymentChangeCount}명 · 복원 가능`, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(9), + color: recommendedDeployment ? '#a8ffd0' : '#ffdf7b', + fontStyle: '700' + } + )); + statusText.setOrigin(0.5); this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), this.deploymentSubtitle(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(10), @@ -7738,9 +7789,19 @@ export class BattleScene extends Phaser.Scene { }); this.renderDeploymentButton(left, startButtonTop, width, '전투 시작', palette.gold, () => this.confirmPreBattleDeployment()); - this.renderDeploymentButton(left, restoreButtonTop, width, '추천 배치 복원', 0x647485, () => this.restoreRecommendedDeployment()); + this.renderDeploymentButton( + left, + restoreButtonTop, + width, + recommendedDeployment ? '추천 배치 유지 중' : `추천 배치 복원 · ${deploymentChangeCount}명`, + recommendedDeployment ? 0x647485 : palette.gold, + () => this.restoreRecommendedDeployment() + ); this.deploymentPanelLayout = { headerBounds: { x: left, y: top, width, height: headerHeight }, + statusBounds: { x: statusLeft, y: statusTop, width: statusWidth, height: statusHeight }, + changeCount: deploymentChangeCount, + recommended: recommendedDeployment, noticeBounds: { x: left, y: noticeTop, width, height: noticeHeight }, noticeTextBounds: this.gameObjectBoundsDebug(noticeText)!, roleBounds, @@ -10706,10 +10767,7 @@ export class BattleScene extends Phaser.Scene { await this.delay(180); await this.playCombatCutIn(result.counter); } - this.showCombatMapResult(result); - if (result.counter) { - this.showCombatMapResult(result.counter); - } + await this.showCombatExchangeMapResults(result); await this.delay(result.counter ? 220 : 160); this.finishUnitAction(attacker, this.formatCombatResult(result), { defeatedEnemyIds: result.defeated ? [result.defender.id] : [] @@ -14632,9 +14690,9 @@ export class BattleScene extends Phaser.Scene { const bondChain = bondChainResolution?.result; this.faceUnitToward(attacker, defender); if (hit) { - this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp); + this.flashDamage(defender, bondChain?.previousDefenderHp ?? defender.hp); } else { - this.flashMiss(defender, this.missFeedbackLabel(action, usable)); + this.flashMiss(defender); } if (defender.hp <= 0) { @@ -14725,7 +14783,7 @@ export class BattleScene extends Phaser.Scene { if (healAmount > 0) { target.hp = Math.min(target.maxHp, target.hp + healAmount); this.syncUnitMotion(target); - this.flashSupport(target, `회복 +${healAmount}`, '#a8ffd0', `HP ${previousTargetHp}→${target.hp}`); + this.flashSupport(target); this.updateMiniMap(); } @@ -14739,7 +14797,7 @@ export class BattleScene extends Phaser.Scene { criticalBonus: usable.criticalBonus ?? 0 }; this.battleBuffs.set(target.id, buff); - this.flashSupport(target, `강화 ${usable.duration ?? 1}턴`, '#ffdf7b', this.supportBuffCompactText(buff)); + this.flashSupport(target); } const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable)); @@ -15251,6 +15309,9 @@ export class BattleScene extends Phaser.Scene { ...(this.isFirstPursuitRoleEffectBattle() ? [`적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`] : []), + ...(battleScenario.id === 'first-battle-zhuo-commandery' + ? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택'] + : []), `작전 목표 · ${objectiveLines[0]}` ]); return; @@ -15387,7 +15448,8 @@ export class BattleScene extends Phaser.Scene { const doctrineLines = lines.filter((line) => ( line.startsWith('도원 공명') || line.startsWith('공명 ') || - line.startsWith('적 의도') + line.startsWith('적 의도') || + line.startsWith('첫 행동') )); if (doctrineLines.length === 0) { doctrineLines.push('활성 공명 없음 · 역할 효과로 전선을 운용합니다.'); @@ -15499,7 +15561,7 @@ export class BattleScene extends Phaser.Scene { const doctrineText = this.add.text(left + 18, top + 145 + index * 20, this.truncateBattleLogText(line, 54), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', - color: line.startsWith('적 의도') ? '#b9d9ec' : '#d8b15f', + color: line.startsWith('적 의도') ? '#b9d9ec' : line.startsWith('첫 행동') ? '#a8ffd0' : '#d8b15f', fontStyle: '700', fixedWidth: width - 36 }); @@ -16810,10 +16872,7 @@ export class BattleScene extends Phaser.Scene { await this.delay(180); await this.playCombatCutIn(result.counter); } - this.showCombatMapResult(result); - if (result.counter) { - this.showCombatMapResult(result.counter); - } + await this.showCombatExchangeMapResults(result); await this.delay(result.counter ? 220 : 160); return this.formatEnemyCombatResult(result, plan.behavior); } @@ -19126,7 +19185,10 @@ export class BattleScene extends Phaser.Scene { if (result.hit) { void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70); - this.cameras.main.shake(result.critical ? 130 : 90, result.critical ? 0.006 : 0.0035); + this.cameras.main.shake( + this.scaledBattleDuration(result.critical ? 130 : 90, 55), + result.critical ? 0.006 : 0.0035 + ); this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); await this.delay(result.critical ? 130 : 92); } else { @@ -21815,18 +21877,14 @@ export class BattleScene extends Phaser.Scene { this.syncUnitMotion(unit, view, direction); } - private flashDamage(unit: UnitData, damage: number, critical = false, previousHp?: number, displayedHp = unit.hp) { + private flashDamage(unit: UnitData, displayedHp = unit.hp) { const view = this.unitViews.get(unit.id); if (!view) { return; } - const title = displayedHp <= 0 ? `${critical ? '치명 ' : ''}퇴각 -${damage}` : `${critical ? '치명 ' : '피해 '}-${damage}`; - const detail = previousHp === undefined ? '' : `HP ${previousHp}→${displayedHp}`; - this.showMapResultPopup(unit, [title, detail], critical ? '#ff8f5f' : '#ffdf7b', '#220909', critical ? 20 : 18, critical ? 34 : 30); - view.sprite.setTintFill(0xffe0a3); - this.time.delayedCall(120, () => { + this.time.delayedCall(this.scaledBattleDuration(120, 55), () => { if (displayedHp <= 0) { this.applyDefeatedStyle(unit); } else { @@ -21842,40 +21900,46 @@ export class BattleScene extends Phaser.Scene { }); } - private flashMiss(unit: UnitData, label = '회피') { + private flashMiss(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { return; } - this.showMapResultPopup(unit, [label], '#d9f1ff', '#071623', label.length > 4 ? 17 : 19, 26); - this.tweens.add({ targets: view.roleSeal ? [view.sprite, view.label, view.roleSeal] : [view.sprite, view.label], x: `+=${unit.faction === 'ally' ? -8 : 8}`, - duration: 90, + duration: this.scaledBattleDuration(90, 55), yoyo: true, ease: 'Sine.easeOut' }); } - private flashSupport(unit: UnitData, label: string, color: string, detail = '') { + private flashSupport(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { return; } - this.showMapResultPopup(unit, [label, detail], color, '#071623', 18, 28); - this.tweens.add({ targets: view.sprite, scale: view.sprite.scale * 1.08, - duration: 120, + duration: this.scaledBattleDuration(120, 55), yoyo: true, ease: 'Sine.easeOut' }); } + private async showCombatExchangeMapResults(result: CombatResult) { + this.showCombatMapResult(result); + if (!result.counter) { + return; + } + + await this.delay(110); + this.showCombatMapResult(result.counter); + } + private showCombatMapResult(result: CombatResult) { if (!result.hit) { this.showMapResultPopup( @@ -21966,17 +22030,13 @@ export class BattleScene extends Phaser.Scene { } const visibleLines = lines.filter(Boolean); - const widestLength = Math.max(...visibleLines.map((line) => line.length), 1); - const estimatedWidth = Math.min(this.layout.mapWidth - 20, widestLength * fontSize * 0.78 + 16); - const popupX = Phaser.Math.Clamp( - view.sprite.x, - this.layout.mapX + estimatedWidth / 2 + 6, - this.layout.mapX + this.layout.mapWidth - estimatedWidth / 2 - 6 - ); + if (visibleLines.length === 0) { + return; + } const popup = this.add.text( - popupX, - view.sprite.y - this.layout.tileSize * 0.64, + view.sprite.x, + view.sprite.y, visibleLines.join('\n'), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -21986,19 +22046,60 @@ export class BattleScene extends Phaser.Scene { strokeThickness: 4, fontStyle: '700', align: 'center', - lineSpacing: -3 + lineSpacing: -3, + wordWrap: { width: Math.max(80, this.layout.mapWidth - 24), useAdvancedWrap: true } } ); popup.setOrigin(0.5, 1); popup.setDepth(35); + + const mapLeft = this.layout.mapX + 6; + const mapRight = this.layout.mapX + this.layout.mapWidth - 6; + const mapTop = this.layout.mapY + 6; + const mapBottom = this.layout.mapY + this.layout.mapHeight - 6; + const safeLift = Math.min(Math.max(0, lift), Math.max(0, this.layout.mapHeight - popup.height - 12)); + const minBottomY = mapTop + popup.height + safeLift; + const popupX = Phaser.Math.Clamp(view.sprite.x, mapLeft + popup.width / 2, mapRight - popup.width / 2); + const requestedBottomY = view.sprite.y - this.layout.tileSize * 0.64; + const popupY = Phaser.Math.Clamp(requestedBottomY, minBottomY, mapBottom); + popup.setPosition(popupX, popupY); + + this.mapResultPopupObjects = this.mapResultPopupObjects.filter((object) => object.active); + while (this.mapResultPopupObjects.length >= 4) { + const oldest = this.mapResultPopupObjects.shift(); + if (oldest?.active) { + this.tweens.killTweensOf(oldest); + oldest.destroy(); + } + } + this.mapResultPopupObjects.push(popup); + this.mapResultPopupPeakCount = Math.max(this.mapResultPopupPeakCount, this.mapResultPopupObjects.length); + + const duration = this.scaledBattleDuration(680, 260); + const delay = this.scaledBattleDuration(80, 30); + const bounds = popup.getBounds(); + this.mapResultPopupLast = { + unitId: unit.id, + lines: [...visibleLines], + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + endBounds: { x: bounds.x, y: bounds.y - safeLift, width: bounds.width, height: bounds.height }, + duration, + delay, + lift: safeLift + }; this.tweens.add({ targets: popup, - y: popup.y - lift, + y: popup.y - safeLift, alpha: 0, - duration: 680, - delay: 80, + duration, + delay, ease: 'Sine.easeOut', - onComplete: () => popup.destroy() + onComplete: () => { + this.mapResultPopupObjects = this.mapResultPopupObjects.filter((object) => object !== popup && object.active); + if (popup.active) { + popup.destroy(); + } + } }); } @@ -24864,7 +24965,10 @@ export class BattleScene extends Phaser.Scene { }; const deploymentPanel = this.deploymentPanelLayout ? { + changeCount: this.deploymentPanelLayout.changeCount, + recommended: this.deploymentPanelLayout.recommended, headerBounds: { ...this.deploymentPanelLayout.headerBounds }, + statusBounds: { ...this.deploymentPanelLayout.statusBounds }, noticeBounds: { ...this.deploymentPanelLayout.noticeBounds }, noticeTextBounds: { ...this.deploymentPanelLayout.noticeTextBounds }, roleBounds: this.deploymentPanelLayout.roleBounds.map((bounds) => ({ ...bounds })), @@ -25177,6 +25281,26 @@ export class BattleScene extends Phaser.Scene { : null, turnPromptVisible: this.turnPromptObjects.length > 0, turnPromptMode: this.turnPromptMode ?? null, + mapResultFeedback: { + activeCount: this.mapResultPopupObjects.filter((object) => object.active).length, + peakCount: this.mapResultPopupPeakCount, + maxActive: 4, + speed: this.battleSpeed, + mapBounds: { + x: this.layout.mapX, + y: this.layout.mapY, + width: this.layout.mapWidth, + height: this.layout.mapHeight + }, + last: this.mapResultPopupLast + ? { + ...this.mapResultPopupLast, + lines: [...this.mapResultPopupLast.lines], + bounds: { ...this.mapResultPopupLast.bounds }, + endBounds: { ...this.mapResultPopupLast.endBounds } + } + : null + }, pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible), pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null, bondChainReadyPreview: this.bondChainReadyDebugState(), diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 3b843e0..f185c70 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -774,7 +774,7 @@ const campSupplies: CampSupplyDefinition[] = [ label: '탁주', usableId: 'wine', title: '탁주', - description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.', + description: '선택 장수의 병력을 8 회복하고 연결된 모든 공명 경험치를 각각 2 올립니다.', healHp: 8, bondExp: 2 }, @@ -789,6 +789,7 @@ const campSupplies: CampSupplyDefinition[] = [ ]; const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply])); +const equipmentInventoryPageSize = 4; const merchantItems: MerchantItemDefinition[] = [ { @@ -11234,6 +11235,15 @@ export class CampScene extends Phaser.Scene { private activeTab: CampTab = 'status'; private selectedDialogueId = campDialogues[0].id; private selectedVisitId = campVisits[0].id; + private equipmentInventoryPage = 0; + private equipmentPanelBackground?: Phaser.GameObjects.Rectangle; + private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle; + private equipmentInventoryNextButton?: Phaser.GameObjects.Rectangle; + private equipmentInventoryVisibleItemIds: string[] = []; + private suppliesPanelBackground?: Phaser.GameObjects.Rectangle; + private suppliesEquipmentBoxes: Phaser.GameObjects.Rectangle[] = []; + private suppliesTrophyBoxes: Phaser.GameObjects.Rectangle[] = []; + private reportPanelBackground?: Phaser.GameObjects.Rectangle; private contentObjects: Phaser.GameObjects.GameObject[] = []; private dialogueObjects: Phaser.GameObjects.GameObject[] = []; private sortieObjects: Phaser.GameObjects.GameObject[] = []; @@ -19409,6 +19419,7 @@ export class CampScene extends Phaser.Scene { const x = 42; const y = 590; const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86)); + this.reportPanelBackground = bg; bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.48); this.track(this.add.text(x + 18, y + 14, '전투 보고', this.textStyle(18, '#f2e3bf', true))); @@ -21470,6 +21481,9 @@ export class CampScene extends Phaser.Scene { const width = 828; const height = 464; const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.91)); + this.equipmentPanelBackground = bg; + this.equipmentInventoryPreviousButton = undefined; + this.equipmentInventoryNextButton = undefined; bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.58); @@ -21483,7 +21497,39 @@ export class CampScene extends Phaser.Scene { }); const inventoryEntries = this.equipmentInventoryEntries(); - this.track(this.add.text(x + 24, y + 266, `보유 장비 ${inventoryEntries.length}`, this.textStyle(18, '#f2e3bf', true))); + const inventoryPageCount = Math.max(1, Math.ceil(inventoryEntries.length / equipmentInventoryPageSize)); + this.equipmentInventoryPage = Phaser.Math.Clamp(this.equipmentInventoryPage, 0, inventoryPageCount - 1); + const inventoryStart = this.equipmentInventoryPage * equipmentInventoryPageSize; + const visibleInventoryEntries = inventoryEntries.slice(inventoryStart, inventoryStart + equipmentInventoryPageSize); + this.equipmentInventoryVisibleItemIds = visibleInventoryEntries.map((entry) => entry.item.id); + this.track(this.add.text( + x + 24, + y + 266, + `보유 장비 ${inventoryEntries.length}${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}` : ''}`, + this.textStyle(18, '#f2e3bf', true) + )); + if (inventoryPageCount > 1) { + this.equipmentInventoryPreviousButton = this.renderEquipmentInventoryPageButton( + x + 424, + y + 278, + '‹', + this.equipmentInventoryPage > 0, + () => { + this.equipmentInventoryPage -= 1; + this.render(); + } + ); + this.equipmentInventoryNextButton = this.renderEquipmentInventoryPageButton( + x + 462, + y + 278, + '›', + this.equipmentInventoryPage < inventoryPageCount - 1, + () => { + this.equipmentInventoryPage += 1; + this.render(); + } + ); + } this.track(this.add.text(x + 516, y + 268, '장비 효과 요약', this.textStyle(17, '#f2e3bf', true))); if (inventoryEntries.length === 0) { @@ -21493,7 +21539,7 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 44, y + 324, '교체 가능한 보유 장비가 없습니다.', this.textStyle(15, '#9fb0bf', true))); this.track(this.add.text(x + 44, y + 352, '전투 보상이나 군영 상인을 통해 장비를 확보하면 이곳에서 비교할 수 있습니다.', this.textStyle(12, '#77818c'))); } else { - inventoryEntries.slice(0, 4).forEach((entry, index) => { + visibleInventoryEntries.forEach((entry, index) => { this.renderEquipmentCompareRow(entry, unit, x + 24, y + 298 + index * 40, 468, 36); }); } @@ -21502,6 +21548,31 @@ export class CampScene extends Phaser.Scene { this.renderEquipmentInventorySummary(inventoryEntries, x + 516, y + 432, 280); } + private renderEquipmentInventoryPageButton( + x: number, + y: number, + label: string, + enabled: boolean, + action: () => void + ) { + const bg = this.track(this.add.rectangle(x, y, 30, 24, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68)); + bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.72 : 0.34); + const text = this.track(this.add.text(x, y - 1, label, this.textStyle(18, enabled ? '#f2e3bf' : '#6f7882', true))); + text.setOrigin(0.5); + if (!enabled) { + return bg; + } + + const setHovered = (hovered: boolean) => bg.setFillStyle(hovered ? 0x654c25 : 0x1a2630, hovered ? 0.99 : 0.96); + [bg, text].forEach((target) => { + target.setInteractive({ useHandCursor: true }); + target.on('pointerover', () => setHovered(true)); + target.on('pointerout', () => setHovered(false)); + target.on('pointerdown', action); + }); + return bg; + } + private renderEquipmentUnitSummary(unit: UnitData, x: number, y: number, width: number, height: number) { const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); bg.setOrigin(0); @@ -21611,7 +21682,10 @@ export class CampScene extends Phaser.Scene { private renderSuppliesPanel() { const x = 394; const y = 120; - const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9)); + const bg = this.track(this.add.rectangle(x, y, 828, 466, 0x101820, 0.9)); + this.suppliesPanelBackground = bg; + this.suppliesEquipmentBoxes = []; + this.suppliesTrophyBoxes = []; bg.setOrigin(0); bg.setStrokeStyle(1, palette.blue, 0.56); this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true))); @@ -21628,23 +21702,23 @@ export class CampScene extends Phaser.Scene { this.renderMerchantPanel(x + 24, y + 202, 342); - this.track(this.add.text(x + 390, y + 318, '보유 장비', this.textStyle(18, '#f2e3bf', true))); + this.track(this.add.text(x + 390, y + 312, '보유 장비', this.textStyle(18, '#f2e3bf', true))); const equipmentEntries = this.equipmentInventoryEntries(); if (equipmentEntries.length === 0) { this.track(this.add.text(x + 390, y + 346, '교체 가능한 장비 없음', this.textStyle(12, '#9fb0bf'))); } else { equipmentEntries.slice(0, 3).forEach((entry, index) => { - this.renderEquipmentInventoryBox(entry, x + 390 + index * 128, y + 344); + this.suppliesEquipmentBoxes.push(this.renderEquipmentInventoryBox(entry, x + 390 + index * 128, y + 336)); }); } - this.track(this.add.text(x + 390, y + 410, '전리품과 명성', this.textStyle(17, '#f2e3bf', true))); + this.track(this.add.text(x + 390, y + 394, '전리품과 명성', this.textStyle(17, '#f2e3bf', true))); const trophies = this.nonEquipmentInventoryLabels(); if (trophies.length === 0) { this.track(this.add.text(x + 390, y + 438, '장부/명성 전리품 없음', this.textStyle(12, '#9fb0bf'))); } else { trophies.slice(0, 3).forEach((reward, index) => { - this.renderSupplyBox(reward, x + 390 + index * 128, y + 436); + this.suppliesTrophyBoxes.push(this.renderSupplyBox(reward, x + 390 + index * 128, y + 418)); }); } } @@ -21708,6 +21782,7 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.52); this.track(this.add.text(x + 10, y + 14, this.compactText(label, 9), this.textStyle(13, '#f2e3bf', true))); + return bg; } private renderEquipmentInventoryBox(entry: { label: string; amount: number; item: ItemDefinition }, x: number, y: number) { @@ -21734,6 +21809,7 @@ export class CampScene extends Phaser.Scene { actionText.setInteractive({ useHandCursor: true }); actionText.on('pointerdown', action); } + return bg; } private renderSupplyTargetCard(unit: UnitData, x: number, y: number, width: number, height: number) { @@ -21823,7 +21899,7 @@ export class CampScene extends Phaser.Scene { soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 }); const hpText = hpGain > 0 ? `병력 +${hpGain}` : ''; - const bondText = bondGain > 0 ? `공명 +${supply.bondExp}` : ''; + const bondText = bondGain > 0 ? `연결 공명 총 +${bondGain}` : ''; this.showCampNotice(`${target.name} ${supply.title} 사용 · ${[hpText, bondText].filter(Boolean).join(' / ')}`); this.render(); } @@ -22052,7 +22128,8 @@ export class CampScene extends Phaser.Scene { } private showCampNotice(message: string) { - this.dialogueObjects.forEach((object) => object.destroy()); + this.tweens.killTweensOf(this.dialogueObjects); + this.dialogueObjects.forEach((object) => object.active && object.destroy()); this.dialogueObjects = []; const depth = this.sortieObjects.length > 0 ? 72 : 30; const noticeWidth = Math.min(640, Math.max(360, message.length * 13)); @@ -22066,15 +22143,18 @@ export class CampScene extends Phaser.Scene { })); text.setOrigin(0.5); text.setDepth(depth + 1); - this.dialogueObjects.push(box, text); + const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text]; + this.dialogueObjects = noticeObjects; this.tweens.add({ - targets: this.dialogueObjects, + targets: noticeObjects, alpha: 0, delay: 1300, duration: 320, onComplete: () => { - this.dialogueObjects.forEach((object) => object.destroy()); - this.dialogueObjects = []; + noticeObjects.forEach((object) => object.active && object.destroy()); + if (this.dialogueObjects === noticeObjects) { + this.dialogueObjects = []; + } } }); } @@ -22610,6 +22690,14 @@ export class CampScene extends Phaser.Scene { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; this.campRosterLayout = undefined; + this.equipmentPanelBackground = undefined; + this.equipmentInventoryPreviousButton = undefined; + this.equipmentInventoryNextButton = undefined; + this.equipmentInventoryVisibleItemIds = []; + this.suppliesPanelBackground = undefined; + this.suppliesEquipmentBoxes = []; + this.suppliesTrophyBoxes = []; + this.reportPanelBackground = undefined; this.reportFormationReviewToggleButton = undefined; this.reportFormationHistoryToggleButton = undefined; this.reportSortieOrderRewardBadge = undefined; @@ -22728,6 +22816,8 @@ export class CampScene extends Phaser.Scene { 1, Math.ceil(reportFormationHistoryRecords.length / reportFormationHistoryPageSize) ); + const equipmentInventory = this.equipmentInventoryEntries(); + const equipmentInventoryPageCount = Math.max(1, Math.ceil(equipmentInventory.length / equipmentInventoryPageSize)); return { scene: this.scene.key, activeTab: this.activeTab, @@ -23422,7 +23512,35 @@ export class CampScene extends Phaser.Scene { strategyLine: summary.strategyLine }; }), - equipmentInventory: this.equipmentInventoryEntries().map((entry) => ({ + secondaryPanels: { + report: { + visible: Boolean(this.reportPanelBackground?.active), + bounds: this.sortieObjectBoundsDebug(this.reportPanelBackground) + }, + supplies: { + visible: this.activeTab === 'supplies' && Boolean(this.suppliesPanelBackground?.active), + bounds: this.sortieObjectBoundsDebug(this.suppliesPanelBackground), + equipmentBoxBounds: this.suppliesEquipmentBoxes + .map((box) => this.sortieObjectBoundsDebug(box)) + .filter((bounds) => bounds !== null), + trophyBoxBounds: this.suppliesTrophyBoxes + .map((box) => this.sortieObjectBoundsDebug(box)) + .filter((bounds) => bounds !== null) + }, + equipment: { + visible: this.activeTab === 'equipment' && Boolean(this.equipmentPanelBackground?.active), + bounds: this.sortieObjectBoundsDebug(this.equipmentPanelBackground), + page: this.equipmentInventoryPage, + pageCount: equipmentInventoryPageCount, + pageSize: equipmentInventoryPageSize, + visibleItemIds: [...this.equipmentInventoryVisibleItemIds], + previousButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryPreviousButton), + nextButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryNextButton), + previousEnabled: Boolean(this.equipmentInventoryPreviousButton?.input?.enabled), + nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled) + } + }, + equipmentInventory: equipmentInventory.map((entry) => ({ label: entry.label, amount: entry.amount, itemId: entry.item.id, diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index a416835..b5846fd 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -123,7 +123,16 @@ export class StoryScene extends Phaser.Scene { ? { x: progressPanelBounds.x, y: progressPanelBounds.y, width: progressPanelBounds.width, height: progressPanelBounds.height } : null, isLastPage, - advanceHint: isLastPage ? 'continue' : 'next', + advanceHint: + isLastPage && this.nextScene === 'BattleScene' + ? 'battle-deployment' + : isLastPage && this.nextScene === 'CampScene' + ? 'camp-return' + : isLastPage + ? 'continue' + : 'next', + advanceLabel: this.progressActionLabel(isLastPage), + nextScene: this.nextScene, cutsceneKind: page?.cutscene?.kind ?? null, cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [] }; @@ -312,9 +321,9 @@ export class StoryScene extends Phaser.Scene { }); this.bodyText.setDepth(dialogDepth + 2); - const progressX = width - ui(174); + const progressX = width - ui(214); const progressY = panelY + panelH + ui(20); - this.progressBackground = this.add.rectangle(progressX, progressY, ui(300), ui(34), cutsceneUiColors.ink, 0.82); + this.progressBackground = this.add.rectangle(progressX, progressY, ui(380), ui(34), cutsceneUiColors.ink, 0.82); this.progressBackground.setStrokeStyle(ui(1), palette.gold, 0.42); this.progressBackground.setDepth(dialogDepth + 1); @@ -324,7 +333,7 @@ export class StoryScene extends Phaser.Scene { color: '#d8b15f', fontStyle: '700', align: 'center', - fixedWidth: ui(276) + fixedWidth: ui(356) }); this.progressText.setOrigin(0.5); this.progressText.setDepth(dialogDepth + 2); @@ -404,10 +413,23 @@ export class StoryScene extends Phaser.Scene { this.bodyText?.setText(page.text); const isLastPage = this.pageIndex >= this.pages.length - 1; this.progressText?.setText( - `${this.pageIndex + 1} / ${this.pages.length} · 클릭 / 스페이스·엔터 · ${isLastPage ? '계속' : '다음'}` + `${this.pageIndex + 1} / ${this.pages.length} · 클릭 / 스페이스·엔터 · ${this.progressActionLabel(isLastPage)}` ); } + private progressActionLabel(isLastPage = this.pageIndex >= this.pages.length - 1) { + if (!isLastPage) { + return '다음'; + } + if (this.nextScene === 'BattleScene') { + return '전투 배치'; + } + if (this.nextScene === 'CampScene') { + return '군영으로'; + } + return '계속'; + } + private pagePortraitKey(page: StoryPage) { return page.portrait ?? portraitKeyForSpeaker(page.speaker); }