diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 29432fd..ecc4630 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -26,10 +26,13 @@ const server = await createServer({ try { const { + acknowledgeCampaignVictoryReward, + campaignVictoryRewardPresentation, campaignStorageKey, applyCampBondExp, applyCampVisitReward, getCampaignState, + getPendingCampaignVictoryRewardReport, hasCampaignSave, loadCampaignState, normalizeCampaignReserveTrainingAssignments, @@ -999,6 +1002,32 @@ try { repeatedSettlement.battleHistory[firstScenario.id]?.campaignRewards?.unlocks[0]?.battleId === 'second-battle-yellow-turban-pursuit', `Expected campaign unlock rewards to survive repeated battle report settlement: ${JSON.stringify(repeatedSettlement)}` ); + const pendingVictoryRewardReport = getPendingCampaignVictoryRewardReport(); + assert( + pendingVictoryRewardReport?.battleId === firstScenario.id && + pendingVictoryRewardReport.rewardGold === firstBattleReport.rewardGold && + pendingVictoryRewardReport.campaignRewards?.equipment[0] === firstBattleReport.campaignRewards.equipment[0], + `Expected an unacknowledged victory to expose its persisted reward report: ${JSON.stringify(pendingVictoryRewardReport)}` + ); + const acknowledgedVictoryReward = acknowledgeCampaignVictoryReward(firstScenario.id); + const repeatedVictoryRewardAcknowledgement = acknowledgeCampaignVictoryReward(firstScenario.id); + const acknowledgedVictoryRewardSave = JSON.parse(storage.get(campaignStorageKey)); + assert( + JSON.stringify(acknowledgedVictoryReward.acknowledgedVictoryRewardBattleIds) === JSON.stringify([firstScenario.id]) && + JSON.stringify(repeatedVictoryRewardAcknowledgement.acknowledgedVictoryRewardBattleIds) === JSON.stringify([firstScenario.id]) && + getPendingCampaignVictoryRewardReport() === undefined && + acknowledgedVictoryRewardSave.acknowledgedVictoryRewardBattleIds.includes(firstScenario.id), + `Expected victory reward acknowledgement to persist once and suppress repeat arrival prompts: ${JSON.stringify({ acknowledgedVictoryReward, repeatedVictoryRewardAcknowledgement, acknowledgedVictoryRewardSave })}` + ); + const corruptedFutureAcknowledgementSave = JSON.parse(storage.get(campaignStorageKey)); + corruptedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.push('second-battle-yellow-turban-pursuit'); + storage.set(campaignStorageKey, JSON.stringify(corruptedFutureAcknowledgementSave)); + const normalizedFutureAcknowledgementSave = loadCampaignState(); + assert( + normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes(firstScenario.id) && + !normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes('second-battle-yellow-turban-pursuit'), + `Expected acknowledgement normalization to retain recorded victories while rejecting future known battle ids without a victory: ${JSON.stringify(normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds)}` + ); setFirstBattleReport({ ...firstBattleReport, rewardGold: 140, @@ -1243,8 +1272,13 @@ try { setCampaignSortieOrderSelection(firstScenario.id, 'elite'); const firstOrderCanonicalReport = setFirstBattleReport(orderBattleReport); const firstOrderSettlement = getCampaignState(); + const firstOrderRewardPresentation = campaignVictoryRewardPresentation(firstOrderCanonicalReport); assert( firstOrderCanonicalReport?.sortieOrder?.rewardGranted === true && + firstOrderRewardPresentation.baseRewardGold === 100 && + firstOrderRewardPresentation.rewardGold === 280 && + firstOrderRewardPresentation.sortieOrderBonus?.rewardGold === 180 && + firstOrderRewardPresentation.itemRewards.includes('상처약 1') && firstOrderSettlement.gold === 280 && firstOrderSettlement.inventory.Bean === 2 && firstOrderSettlement.inventory['Iron Sword'] === 1 && diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 80ee9ee..f3f3077 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2018,6 +2018,48 @@ try { await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); + const initialFirstSettlement = await page.evaluate(() => ({ + settlement: window.__HEROS_DEBUG__?.battle()?.resultSettlement, + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [] + })); + if (initialFirstSettlement.settlement?.total > 0) { + assert( + ['pending', 'animating'].includes(initialFirstSettlement.settlement.status) && + initialFirstSettlement.settlement.skipped === false && + initialFirstSettlement.settlement.cta?.state === 'settlement' && + initialFirstSettlement.settlement.cta.label === '정산 완료하기' && + initialFirstSettlement.settlement.cta.destination === null && + initialFirstSettlement.settlement.cta.interactive === true && + boundsWithinFhdViewport(initialFirstSettlement.settlement.cta.bounds), + `Expected a growing victory result to begin as an explicit settlement action: ${JSON.stringify(initialFirstSettlement)}` + ); + await clickLegacyUi(page, 738, 642); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement?.status === 'complete', undefined, { timeout: 30000 }); + const skippedFirstSettlement = await page.evaluate(() => ({ + settlement: window.__HEROS_DEBUG__?.battle()?.resultSettlement, + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [] + })); + assert( + skippedFirstSettlement.settlement?.skipped === true && + skippedFirstSettlement.settlement.progress === skippedFirstSettlement.settlement.total && + skippedFirstSettlement.settlement.cta?.state === 'navigation' && + skippedFirstSettlement.settlement.cta.label === '승리 이야기로' && + skippedFirstSettlement.settlement.cta.destination === 'victory-story' && + skippedFirstSettlement.activeScenes.includes('BattleScene') && + !skippedFirstSettlement.activeScenes.includes('StoryScene'), + `Expected the first CTA click to finish settlement without leaving battle: ${JSON.stringify(skippedFirstSettlement)}` + ); + } else { + assert( + initialFirstSettlement.settlement?.status === 'complete' && + initialFirstSettlement.settlement.progress === 0 && + initialFirstSettlement.settlement.cta?.state === 'navigation' && + initialFirstSettlement.settlement.cta.label === '승리 이야기로' && + initialFirstSettlement.settlement.cta.destination === 'victory-story' && + initialFirstSettlement.activeScenes.includes('BattleScene'), + `Expected a no-growth victory to expose its completed story CTA immediately: ${JSON.stringify(initialFirstSettlement)}` + ); + } await page.screenshot({ path: `${screenshotDir}/rc-first-battle-result.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle result'); @@ -2035,10 +2077,13 @@ try { }); assert(resultProbe.outcome === 'victory', `Expected forced victory result: ${JSON.stringify(resultProbe)}`); assert(resultProbe.resultTexts.includes('목표 정산'), `Expected result objective settlement title: ${JSON.stringify(resultProbe.resultTexts)}`); - assert(resultProbe.resultTexts.some((text) => text.includes('목표 보상')), `Expected reward panel to name objective rewards: ${JSON.stringify(resultProbe.resultTexts)}`); assert( - resultProbe.resultTexts.some((text) => text.includes(`해금 ${firstBattleUnlockLabel}`)), - `Expected result reward panel to surface first battle unlock label: ${JSON.stringify(resultProbe.resultTexts)}` + ['전투 수입', '최고 전공', '획득 물자', '새 전력 · 해금'].every((label) => resultProbe.resultTexts.includes(label)), + `Expected the result reward panel to expose four scannable reward cards: ${JSON.stringify(resultProbe.resultTexts)}` + ); + assert( + resultProbe.resultTexts.some((text) => text.includes('해금')), + `Expected result reward cards to surface progression unlocks: ${JSON.stringify(resultProbe.resultTexts)}` ); assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`); @@ -2049,7 +2094,7 @@ try { const firstResultTitleAction = firstResultState?.resultActions?.find((action) => action.kind === 'title'); assert( firstResultPrimaryActions.length === 1 && - firstResultContinueAction?.label === '군영으로' && + firstResultContinueAction?.label === '승리 이야기로' && firstResultContinueAction.primary === true && firstResultContinueAction.tone === 'primary' && firstResultContinueAction.lineWidth >= 2 && @@ -2057,12 +2102,17 @@ try { boundsWithinFhdViewport(firstResultContinueAction.bounds) && firstResultRetryAction?.tone === 'secondary' && firstResultTitleAction?.tone === 'ghost', - `Expected the victory result to expose one clear continue CTA: ${JSON.stringify(firstResultState?.resultActions)}` + `Expected the completed victory result to expose one correctly named story CTA: ${JSON.stringify(firstResultState?.resultActions)}` ); const firstResultSave = await readCampaignSave(page); const firstSortieOrder = firstResultState?.sortieOperationOrder; const firstSortieOrderResult = firstSortieOrder?.result; const firstSortieOrderCommand = firstSortieOrderResult?.command; + const firstSortieOrderBonus = firstSortieOrderResult?.rewardGranted + ? { rewardGold: 180, rewardItems: ['상처약 1'] } + : { rewardGold: 0, rewardItems: [] }; + const expectedFirstDisplayedRewardGold = + (firstResultSave.current?.firstBattleReport?.rewardGold ?? 0) + firstSortieOrderBonus.rewardGold; assert( firstSortieOrder?.selectedId === 'elite' && firstSortieOrder?.open === false && @@ -2071,6 +2121,10 @@ try { firstSortieOrderResult?.progress?.map((entry) => entry.id).join(',') === 'victory,elite-grade,elite-survival', `Expected the scripted first battle to expose an explicit elite order result: ${JSON.stringify(firstSortieOrder)}` ); + assert( + resultProbe.resultTexts.includes(`군자금 +${expectedFirstDisplayedRewardGold}`), + `Expected the result reward card to include the newly granted sortie-order merit reward: ${JSON.stringify({ expectedFirstDisplayedRewardGold, resultTexts: resultProbe.resultTexts })}` + ); assert( firstSortieOrderCommand?.source === 'sortie-order' && firstSortieOrderCommand.role === 'support' && @@ -2231,8 +2285,52 @@ try { ), `Expected the post-battle story to name the next action as returning to camp: ${JSON.stringify(firstCampTransition)}` ); + const firstRewardDisplaysByPage = Object.fromEntries( + firstCampTransition.rewardDisplays.map((display) => [display.pageId, display]) + ); + const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards; + const expectedFirstStorySupplies = [ + ...(persistedFirstRewards?.supplies ?? []), + ...firstSortieOrderBonus.rewardItems + ]; + assert( + firstCampTransition.rewardDisplays.length === 5 && + firstCampTransition.rewardDisplays.every((display) => ( + display.source === 'campaign' && display.battleId === 'first-battle-zhuo-commandery' + )) && + firstRewardDisplaysByPage['first-victory-village']?.rewardGold === expectedFirstDisplayedRewardGold && + sameJsonValue( + firstRewardDisplaysByPage['first-victory-village']?.items.filter((item) => item.category === 'supply').map((item) => item.label), + expectedFirstStorySupplies + ) && + sameJsonValue( + firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'equipment').map((item) => item.label), + persistedFirstRewards?.equipment + ) && + sameJsonValue( + firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'reputation').map((item) => item.label), + persistedFirstRewards?.reputation + ) && + sameJsonValue( + [ + ...(firstRewardDisplaysByPage['first-victory-guan-yu']?.items ?? []), + ...(firstRewardDisplaysByPage['first-victory-zhang-fei']?.items ?? []) + ].filter((item) => item.category === 'recruit').map((item) => item.id.replace('recruit-', '')), + persistedFirstRewards?.recruits.map((recruit) => recruit.unitId) + ) && + sameJsonValue( + firstRewardDisplaysByPage['first-victory-next']?.items.filter((item) => item.category === 'unlock').map((item) => item.id.replace('unlock-', '')), + persistedFirstRewards?.unlocks.map((unlock) => unlock.battleId) + ), + `Expected every first-victory story reward card to match the persisted battle report: ${JSON.stringify({ displays: firstCampTransition.rewardDisplays, persistedFirstRewards })}` + ); await waitForCampSkinTransition(page, 'yellow-turban'); await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience'); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true, + undefined, + { timeout: 30000 } + ); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp'); @@ -2249,6 +2347,35 @@ try { } }); assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`); + const firstArrivalReward = firstCampProbe.state?.victoryRewardAcknowledgement; + assert( + firstArrivalReward?.visible === true && + firstArrivalReward.battleId === 'first-battle-zhuo-commandery' && + firstArrivalReward.cards.length === 6 && + firstArrivalReward.cards.find((card) => card.id === 'gold')?.items.includes(`+${expectedFirstDisplayedRewardGold} 군자금`) && + firstArrivalReward.cards.find((card) => card.id === 'supplies')?.items.includes('상처약 1') && + firstArrivalReward.cards.every((card) => boundsWithinFhdViewport(card.bounds)) && + firstArrivalReward.actions.map((action) => action.id).join(',') === 'equipment,supplies,sortie,close' && + firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) && + firstCampProbe.state?.campTabs?.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === true), + `Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify(firstArrivalReward)}` + ); + const firstArrivalEquipmentPoint = await readCampVictoryRewardControlPoint(page, 'equipment'); + await page.mouse.click(firstArrivalEquipmentPoint.x, firstArrivalEquipmentPoint.y); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp(); + return camp?.victoryRewardAcknowledgement?.visible === false && camp?.activeTab === 'equipment'; + }, undefined, { timeout: 30000 }); + const acknowledgedFirstArrival = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const acknowledgedFirstArrivalSave = await readCampaignSave(page); + assert( + acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && + acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && + acknowledgedFirstArrival.campTabs.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === false), + `Expected a reward deep link to persist acknowledgement, hide NEW badges, and avoid duplicate prompts: ${JSON.stringify({ acknowledgedFirstArrival, acknowledgedFirstArrivalSave })}` + ); + await clickLegacyUi(page, 576, 38); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'status', undefined, { timeout: 30000 }); assertCampSkinState(firstCampProbe.state, 'yellow-turban', 'first camp'); assertCampSoundscapeState(firstCampProbe.state, { musicKey: 'camp-rally', @@ -2927,6 +3054,9 @@ try { boundsInside(equipmentPaginationFixture.panel.nextButtonBounds, equipmentPaginationFixture.panel.bounds), `Expected the equipment manager to expose the first of multiple inventory pages: ${JSON.stringify(equipmentPaginationFixture)}` ); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); const equipmentNextBounds = equipmentPaginationFixture.panel.nextButtonBounds; await page.mouse.click( equipmentNextBounds.x + equipmentNextBounds.width / 2, @@ -5723,11 +5853,38 @@ try { ); const midImprovementPoint = await readBattleResultEvaluationControlPoint(page, 'improvement'); await page.mouse.click(midImprovementPoint.x, midImprovementPoint.y); + await waitForCamp(page); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true, + undefined, + { timeout: 30000 } + ); + const pendingMidImprovementReward = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const pendingMidImprovementSave = await readCampaignSave(page); + const midImprovementCampEntrySaveUnchanged = sameJsonValue( + campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave), + campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) + ); + assert( + pendingMidImprovementReward?.openSortiePrepOnCreate === true && + pendingMidImprovementReward.sortieVisible === false && + pendingMidImprovementReward.victoryRewardAcknowledgement?.battleId === midCampNextBattleId && + midImprovementCampEntrySaveUnchanged, + `Expected next-sortie improvement to preserve its requested prep while showing the unacknowledged victory reward first: ${JSON.stringify(pendingMidImprovementReward)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-improvement-victory-reward.png`, fullPage: true }); + const midImprovementSortiePoint = await readCampVictoryRewardControlPoint(page, 'sortie'); + await page.mouse.click(midImprovementSortiePoint.x, midImprovementSortiePoint.y); await waitForSortiePrep(page); const guidedPreviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const guidedPreviewSave = await readCampaignSave(page); const guidedPreview = guidedPreviewState?.sortieGuidedImprovement; + const guidedPreviewAcknowledgementOnlySaveDelta = campaignSaveMatchesVictoryAcknowledgementDelta( + pendingMidImprovementSave, + guidedPreviewSave, + midCampNextBattleId + ); assert( guidedPreviewState?.sortiePrepStep === 'formation' && guidedPreviewState.nextSortieBattleId === midImprovementTargetBattleId && @@ -5740,20 +5897,15 @@ try { guidedPreviewState.sortieComparisonAction?.kind === 'confirm-improvement' && isFiniteBounds(guidedPreviewState.sortieComparisonAction?.buttonBounds) && boundsInside(guidedPreviewState.sortieComparisonAction.buttonBounds, fhdViewportBounds) && - sameJsonValue( - campaignSaveWithoutCampRecruitmentEffects(guidedPreviewSave), - campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) - ), + guidedPreviewAcknowledgementOnlySaveDelta, `Expected a non-destructive one-officer improvement preview for the next battle: ${JSON.stringify({ prepStep: guidedPreviewState?.sortiePrepStep, nextBattleId: guidedPreviewState?.nextSortieBattleId, guidedImprovement: guidedPreview, comparisonPreviewSource: guidedPreviewState?.sortieComparisonPreview?.source, comparisonAction: guidedPreviewState?.sortieComparisonAction, - stableSaveUnchanged: sameJsonValue( - campaignSaveWithoutCampRecruitmentEffects(guidedPreviewSave), - campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) - ) + campEntrySaveUnchanged: midImprovementCampEntrySaveUnchanged, + acknowledgementOnlySaveDelta: guidedPreviewAcknowledgementOnlySaveDelta })}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-sortie-guided-improvement-preview.png`, fullPage: true }); @@ -8014,10 +8166,14 @@ async function waitForCamp(page) { const scene = window.__HEROS_DEBUG__?.scene('CampScene'); return ( activeScenes.includes('CampScene') && + !activeScenes.includes('BattleScene') && window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' && (scene?.contentObjects?.length ?? 0) > 0 ); }, undefined, { timeout: 90000 }); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); } async function waitForCampSkinTransition(page, expectedSkinId) { @@ -8629,6 +8785,34 @@ async function readSortieOrderControlPoint(page, control, orderId) { return probe; } +async function readCampVictoryRewardControlPoint(page, actionId) { + const probe = await page.evaluate((requestedActionId) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const acknowledgement = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement; + const action = acknowledgement?.actions?.find((candidate) => candidate.id === requestedActionId); + const bounds = action?.bounds; + if (!scene || !canvas || !bounds || !action?.interactive) { + return { ready: false, actionId: requestedActionId, acknowledgement, bounds: bounds ?? null }; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + actionId: requestedActionId, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, actionId); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible camp victory-reward ${actionId} control: ${JSON.stringify(probe)}` + ); + return probe; +} + async function waitForCampAfterBattleResult(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -8636,6 +8820,7 @@ async function waitForCampAfterBattleResult(page) { }, undefined, { timeout: 90000 }); let lastStory = null; + const rewardDisplays = new Map(); for (let i = 0; i < 48; i += 1) { const probe = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], @@ -8644,16 +8829,22 @@ async function waitForCampAfterBattleResult(page) { if (probe.story?.isLastPage && probe.story?.progressBounds && probe.story?.progressPanelBounds) { lastStory = probe.story; } + if ( + probe.story?.rewardDisplay?.source === 'campaign' && + probe.story.rewardDisplay.pageId === probe.story.currentPageId + ) { + rewardDisplays.set(probe.story.rewardDisplay.pageId, probe.story.rewardDisplay); + } if (probe.activeScenes.includes('CampScene')) { await waitForCamp(page); - return { lastStory }; + return { lastStory, rewardDisplays: [...rewardDisplays.values()] }; } await page.keyboard.press('Space'); await page.waitForTimeout(240); } await waitForCamp(page); - return { lastStory }; + return { lastStory, rewardDisplays: [...rewardDisplays.values()] }; } async function advanceStoryUntilEnding(page) { @@ -9380,6 +9571,9 @@ async function seedCampaignSave(page, options) { selectedSortieUnitIds: seed.selectedSortieUnitIds, latestBattleId: seed.battleId, firstBattleReport: report, + acknowledgedVictoryRewardBattleIds: [ + ...new Set([...(current.acknowledgedVictoryRewardBattleIds ?? []), seed.battleId]) + ], battleHistory: { ...(current.battleHistory ?? {}), [seed.battleId]: settlement @@ -9667,6 +9861,38 @@ function campaignSaveWithoutCampRecruitmentEffects(save) { }; } +function campaignSaveMatchesVictoryAcknowledgementDelta(beforeSave, afterSave, battleId) { + const stateMatches = (beforeState, afterState) => { + if (!beforeState || !afterState) { + return beforeState === afterState; + } + const beforeIds = beforeState.acknowledgedVictoryRewardBattleIds ?? []; + const afterIds = afterState.acknowledgedVictoryRewardBattleIds ?? []; + const { + updatedAt: beforeUpdatedAt, + acknowledgedVictoryRewardBattleIds: _beforeIds, + ...beforeStable + } = beforeState; + const { + updatedAt: afterUpdatedAt, + acknowledgedVictoryRewardBattleIds: _afterIds, + ...afterStable + } = afterState; + const beforeTimestamp = Date.parse(beforeUpdatedAt); + const afterTimestamp = Date.parse(afterUpdatedAt); + return !beforeIds.includes(battleId) && + sameJsonValue(afterIds, [...beforeIds, battleId]) && + sameJsonValue(afterStable, beforeStable) && + Number.isFinite(beforeTimestamp) && + Number.isFinite(afterTimestamp) && + afterTimestamp >= beforeTimestamp; + }; + + return stateMatches(beforeSave?.current, afterSave?.current) && + stateMatches(beforeSave?.slot1, afterSave?.slot1) && + afterSave?.current?.updatedAt === afterSave?.slot1?.updatedAt; +} + function recommendationFeedbackSemantic(feedback) { if (!feedback) { return null; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 6eb32af..384e866 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1614,6 +1614,8 @@ type ResultButtonView = { type ResultButtonTone = 'primary' | 'secondary' | 'utility' | 'ghost'; type ResultButtonKind = 'continue' | 'retry' | 'title' | 'detail'; +type ResultSettlementStatus = 'hidden' | 'pending' | 'animating' | 'complete'; +type ResultContinueDestination = 'victory-story' | 'camp' | 'epilogue'; type ThreatTile = { x: number; @@ -1729,6 +1731,8 @@ type ResultGaugeAnimation = { next: number; leveled: boolean; statGains?: LevelUpStatGain[]; + valueColor?: string; + levelColor?: string; flashArea: { x: number; y: number; @@ -3535,6 +3539,12 @@ export class BattleScene extends Phaser.Scene { private resultGaugeAnimations: ResultGaugeAnimation[] = []; private resultAnimationToken = 0; private resultSettlementText?: Phaser.GameObjects.Text; + private resultSettlementStatus: ResultSettlementStatus = 'hidden'; + private resultSettlementSkipped = false; + private resultSettlementProgress = 0; + private resultSettlementTweens: Phaser.Tweens.Tween[] = []; + private resultSettlementTransientObjects: Phaser.GameObjects.GameObject[] = []; + private resultContinueButton?: ResultButtonView; private resultCoreResonanceSummaryText?: Phaser.GameObjects.Text; private resultFormationReviewObjects: Phaser.GameObjects.GameObject[] = []; private resultFormationReviewVisible = false; @@ -11942,7 +11952,7 @@ export class BattleScene extends Phaser.Scene { this.renderResultMetric('소요 턴', `${this.turnNumber}턴`, left + 44, metricTop, metricWidth, depth + 2); this.renderResultMetric('생존 아군', `${aliveAllies} / ${allies.length}`, left + 44 + (metricWidth + metricGap), metricTop, metricWidth, depth + 2); this.renderResultMetric('격파 적군', `${defeatedEnemies} / ${totalEnemies}`, left + 44 + (metricWidth + metricGap) * 2, metricTop, metricWidth, depth + 2); - this.renderResultMetric('전투 보상', outcome === 'victory' ? `군자금 ${this.resultGold(outcome)}` : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); + this.renderResultMetric('전투 보상', outcome === 'victory' ? `군자금 ${this.resultDisplayedGold(outcome)}` : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); this.renderResultObjectivePanel(objectives, left + 44, top + 178, 456, depth + 2); this.renderResultRewardPanel(outcome, objectives, rewardSnapshot, left + 526, top + 178, 470, depth + 2); @@ -12015,12 +12025,21 @@ export class BattleScene extends Phaser.Scene { this.renderResultUnitRow(unit, left + 44, top + 386 + index * 70, panelWidth - 88, depth + 2); }); - this.resultSettlementText = this.trackResultObject(this.add.text(left + 44, top + 604, '성장 정산 대기', { + const hasGrowthSettlement = this.resultGaugeAnimations.length > 0; + this.resultSettlementStatus = hasGrowthSettlement ? 'pending' : 'complete'; + this.resultSettlementSkipped = false; + this.resultSettlementProgress = 0; + this.resultSettlementText = this.trackResultObject(this.add.text( + left + 44, + top + 604, + hasGrowthSettlement ? '성장 정산 대기' : '성장 변화 없음', + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d8b15f', fontStyle: '700' - })); + } + )); this.resultSettlementText.setDepth(depth + 2); if (this.resultSortieOrder) { @@ -12058,26 +12077,16 @@ export class BattleScene extends Phaser.Scene { } if (outcome === 'victory') { - const isFinalBattle = battleScenario.id === 'sixty-sixth-battle-wuzhang-final'; - this.addResultButton(isFinalBattle ? '에필로그로' : '군영으로', left + panelWidth - 422, top + 612, 132, () => { - soundDirector.playSelect(); - if (isFinalBattle) { - markCampaignStep('ending-complete'); - void startLazyScene(this, 'StoryScene', { - pages: [...battleScenario.victoryPages, ...endingEpiloguePages], - nextScene: 'EndingScene' - }); - return; - } - if (battleScenario.id === 'first-battle-zhuo-commandery') { - void startLazyScene(this, 'StoryScene', { - pages: firstBattleVictoryPages, - nextScene: 'CampScene' - }); - return; - } - void startLazyScene(this, 'CampScene'); - }, depth + 3, 'primary', 'continue'); + this.resultContinueButton = this.addResultButton( + this.resultContinueCtaLabel(), + left + panelWidth - 422, + top + 612, + 132, + () => this.handleResultContinue(), + depth + 3, + 'primary', + 'continue' + ); } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { soundDirector.playSelect(); @@ -12101,8 +12110,56 @@ export class BattleScene extends Phaser.Scene { void this.playResultSettlementAnimation(animationToken); } + private resultContinueRoute(): { destination: ResultContinueDestination; label: string } { + if (battleScenario.id === 'sixty-sixth-battle-wuzhang-final') { + return { destination: 'epilogue', label: '에필로그로' }; + } + if (battleScenario.id === 'first-battle-zhuo-commandery') { + return { destination: 'victory-story', label: '승리 이야기로' }; + } + return { destination: 'camp', label: '군영으로' }; + } + + private resultContinueCtaLabel() { + return this.resultSettlementStatus === 'complete' + ? this.resultContinueRoute().label + : '정산 완료하기'; + } + + private refreshResultContinueCta() { + this.resultContinueButton?.label.setText(this.resultContinueCtaLabel()); + } + + private handleResultContinue() { + soundDirector.playSelect(); + if (this.resultSettlementStatus !== 'complete') { + this.completeResultSettlement(true); + return; + } + + const { destination } = this.resultContinueRoute(); + if (destination === 'epilogue') { + markCampaignStep('ending-complete'); + void startLazyScene(this, 'StoryScene', { + pages: [...battleScenario.victoryPages, ...endingEpiloguePages], + nextScene: 'EndingScene' + }); + return; + } + if (destination === 'victory-story') { + void startLazyScene(this, 'StoryScene', { + pages: firstBattleVictoryPages, + nextScene: 'CampScene' + }); + return; + } + void startLazyScene(this, 'CampScene'); + } + private hideBattleResult() { this.resultAnimationToken += 1; + this.stopResultSettlementTweens(); + this.clearResultSettlementTransientObjects(); this.clearSortieOrderHud(true); this.sortieOrderHudSnapshot = undefined; this.hideResultSortieOrder(); @@ -12121,6 +12178,10 @@ export class BattleScene extends Phaser.Scene { this.resultRoot = undefined; this.resultGaugeAnimations = []; this.resultSettlementText = undefined; + this.resultSettlementStatus = 'hidden'; + this.resultSettlementSkipped = false; + this.resultSettlementProgress = 0; + this.resultContinueButton = undefined; this.resultCoreResonanceSummaryText = undefined; } @@ -12256,6 +12317,17 @@ export class BattleScene extends Phaser.Scene { return baseVictoryGold + this.resultObjectives(outcome).reduce((total, objective) => total + (objective.achieved ? objective.rewardGold : 0), 0); } + private resultGrantedSortieOrderReward(outcome: BattleOutcome) { + if (outcome !== 'victory' || !this.resultSortieOrder?.rewardGranted) { + return undefined; + } + return sortieOrderDefinition(this.resultSortieOrder.orderId); + } + + private resultDisplayedGold(outcome: BattleOutcome) { + return this.resultGold(outcome) + (this.resultGrantedSortieOrderReward(outcome)?.rewardGold ?? 0); + } + private resultMvp() { return battleUnits .filter((unit) => unit.faction === 'ally') @@ -13380,35 +13452,150 @@ export class BattleScene extends Phaser.Scene { .map((bond) => `${this.unitName(bond.unitIds[0])}·${this.unitName(bond.unitIds[1])} +${bond.battleExp}`) .join(' '); const achievedCount = objectives.filter((objective) => objective.achieved).length; - const itemRewards = this.resultItemRewards(outcome); - const itemLine = [ - rewards.supplies.length > 0 ? `보급 ${rewards.supplies.join(', ')}` : '', - rewards.equipment.length > 0 ? `장비 ${rewards.equipment.join(', ')}` : '', - rewards.reputation.length > 0 ? `명성 ${rewards.reputation.join(', ')}` : '' - ].filter(Boolean).join(' · '); - const progressLine = [ - rewards.recruits.length > 0 ? `합류 ${rewards.recruits.map((recruit) => recruit.name).join(', ')}` : '', - rewards.unlocks.length > 0 ? `해금 ${rewards.unlocks.map((unlock) => unlock.title).join(', ')}` : '' - ].filter(Boolean).join(' · '); - const lines = [ - outcome === 'victory' ? `군자금 ${this.resultGold(outcome)} 목표 보상 ${achievedCount}/${objectives.length}` : '패배: 보상 없음', - mvp ? `MVP ${mvp.unit.name}: 피해 ${mvp.stats.damageDealt}, 격파 ${mvp.stats.defeats}` : 'MVP 없음', - outcome === 'victory' ? (itemLine || `전리품 ${itemRewards.join(', ') || '없음'}`) : '재도전하면 목표 보상을 다시 노릴 수 있습니다.', - outcome === 'victory' ? (progressLine || rewards.note || '새 해금 없음') : (bondSummary ? `공명 성장 ${bondSummary}` : '공명 성장 없음') + const sortieOrderReward = this.resultGrantedSortieOrderReward(outcome); + const itemRewards = [ + ...this.resultItemRewards(outcome), + ...(sortieOrderReward?.rewardItems ?? []) ]; - - lines.forEach((line, index) => { - const text = this.trackResultObject(this.add.text(x + 16, y + 42 + index * 21, line, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', - color: index === 0 ? '#d8b15f' : '#d4dce6', - fontStyle: index === 0 ? '700' : '400', - wordWrap: { width: width - 32, useAdvancedWrap: true } - })); - text.setDepth(depth + 1); + const itemLine = [ + ...rewards.supplies, + ...(sortieOrderReward?.rewardItems ?? []), + ...rewards.equipment, + ...rewards.reputation + ].join(' · '); + const progressLine = [ + rewards.recruits.length > 0 ? `${rewards.recruits.map((recruit) => recruit.name).join('·')} 합류` : '', + rewards.unlocks.length > 0 ? `${rewards.unlocks.map((unlock) => unlock.title).join('·')} 해금` : '' + ].filter(Boolean).join(' · '); + const rewardCards: Array<{ + label: string; + value: string; + badge: string; + icon: BattleUiIconKey; + accent: number; + }> = outcome === 'victory' + ? [ + { + label: '전투 수입', + value: `군자금 +${this.resultDisplayedGold(outcome)}`, + badge: sortieOrderReward ? `${sortieOrderReward.label} 공훈 포함` : `목표 ${achievedCount}/${objectives.length}`, + icon: 'leadership', + accent: palette.gold + }, + { + label: '최고 전공', + value: mvp?.unit.name ?? 'MVP 없음', + badge: mvp ? `피해 ${mvp.stats.damageDealt} · 격파 ${mvp.stats.defeats}` : '기록 없음', + icon: 'critical', + accent: 0xffa45f + }, + { + label: '획득 물자', + value: itemLine || `전리품 ${itemRewards.join(', ') || '없음'}`, + badge: rewards.equipment.length > 0 ? `장비 ${rewards.equipment.length}` : '보급·전리품', + icon: 'accessory', + accent: palette.blue + }, + { + label: '새 전력 · 해금', + value: progressLine || rewards.note || '새 해금 없음', + badge: rewards.recruits.length + rewards.unlocks.length > 0 ? 'NEW' : '변화 없음', + icon: 'success', + accent: palette.green + } + ] + : [ + { + label: '전투 수입', + value: '보상 없음', + badge: `목표 ${achievedCount}/${objectives.length}`, + icon: 'failure', + accent: palette.red + }, + { + label: '최고 전공', + value: mvp?.unit.name ?? 'MVP 없음', + badge: mvp ? `피해 ${mvp.stats.damageDealt} · 격파 ${mvp.stats.defeats}` : '기록 없음', + icon: 'critical', + accent: 0xffa45f + }, + { + label: '재도전 안내', + value: '목표 보상을 다시 노리십시오', + badge: '다시 하기', + icon: 'strategy', + accent: palette.blue + }, + { + label: '공명 성장', + value: bondSummary || '공명 성장 없음', + badge: bondSummary ? '정산 반영' : '변화 없음', + icon: 'leadership', + accent: palette.gold + } + ]; + const cardGap = 8; + const cardWidth = Math.floor((width - 28 - cardGap) / 2); + const cardHeight = 38; + rewardCards.forEach((card, index) => { + this.renderResultRewardCard( + card, + x + 14 + (index % 2) * (cardWidth + cardGap), + y + 40 + Math.floor(index / 2) * (cardHeight + 7), + cardWidth, + cardHeight, + depth + 1 + ); }); } + private renderResultRewardCard( + card: { label: string; value: string; badge: string; icon: BattleUiIconKey; accent: number }, + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x15222c, 0.96)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, card.accent, 0.48); + + const accent = this.trackResultObject(this.add.rectangle(x, y, 3, height, card.accent, 0.92)); + accent.setOrigin(0); + accent.setDepth(depth + 1); + + const icon = this.trackResultObject(this.createBattleUiIcon(x + 19, y + height / 2, card.icon, 26)); + icon.setDepth(depth + 2); + + const label = this.trackResultObject(this.add.text(x + 38, y + 4, card.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '9px', + color: '#9fb0bf', + fontStyle: '700' + })); + label.setDepth(depth + 2); + + const badge = this.trackResultObject(this.add.text(x + width - 8, y + 4, this.resultCompactText(card.badge, 16), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '9px', + color: card.badge === 'NEW' ? '#a8ffd0' : '#d8b15f', + fontStyle: '700' + })); + badge.setOrigin(1, 0); + badge.setDepth(depth + 2); + + const value = this.trackResultObject(this.add.text(x + 38, y + 18, this.resultCompactText(card.value, 14), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f2e3bf', + fontStyle: '700' + })); + value.setCrop(0, 0, Math.max(0, width - 46), Math.max(0, height - 18)); + value.setDepth(depth + 2); + } + private renderResultBondStrip(x: number, y: number, width: number, depth: number) { const bonds = Array.from(this.bondStates.values()); const bg = this.trackResultObject(this.add.rectangle(x, y, width, 30, 0x101820, 0.9)); @@ -13871,75 +14058,208 @@ export class BattleScene extends Phaser.Scene { } private queueResultGaugeAnimation(animation: ResultGaugeAnimation) { - this.resultGaugeAnimations.push(animation); + this.resultGaugeAnimations.push({ + ...animation, + valueColor: typeof animation.valueText.style.color === 'string' ? animation.valueText.style.color : '#f0e4c8', + levelColor: typeof animation.levelText.style.color === 'string' ? animation.levelText.style.color : '#f4dfad' + }); } private async playResultSettlementAnimation(token: number) { - await this.delay(360); - if (token !== this.resultAnimationToken) { + if (this.resultGaugeAnimations.length === 0) { return; } - if (this.resultGaugeAnimations.length === 0) { - this.resultSettlementText?.setText('성장 변화 없음'); + await this.delay(360); + if (!this.resultSettlementActive(token)) { return; } + this.resultSettlementStatus = 'animating'; + for (let index = 0; index < this.resultGaugeAnimations.length; index += 1) { - if (token !== this.resultAnimationToken) { + if (!this.resultSettlementActive(token)) { return; } this.resultSettlementText?.setText(`성장 정산 ${index + 1} / ${this.resultGaugeAnimations.length}`); - await this.animateResultGauge(this.resultGaugeAnimations[index], 280); + await this.animateResultGauge(this.resultGaugeAnimations[index], 280, token); + if (!this.resultSettlementActive(token)) { + return; + } + this.resultSettlementProgress = index + 1; await this.delay(44); } - if (token === this.resultAnimationToken) { - this.resultSettlementText?.setText('성장 정산 완료'); + if (this.resultSettlementActive(token)) { + this.completeResultSettlement(false); } } - private async animateResultGauge(animation: ResultGaugeAnimation, duration: number) { + private resultSettlementActive(token: number) { + return token === this.resultAnimationToken && this.resultSettlementStatus !== 'complete' && this.resultSettlementStatus !== 'hidden'; + } + + private completeResultSettlement(skipped: boolean) { + if (this.resultSettlementStatus === 'complete' || this.resultSettlementStatus === 'hidden') { + return; + } + + if (skipped) { + this.resultAnimationToken += 1; + this.stopResultSettlementTweens(); + } + this.resultGaugeAnimations.forEach((animation) => this.applyResultGaugeFinalState(animation)); + this.clearResultSettlementTransientObjects(); + this.resultSettlementStatus = 'complete'; + this.resultSettlementSkipped = this.resultSettlementSkipped || skipped; + this.resultSettlementProgress = this.resultGaugeAnimations.length; + this.resultSettlementText?.setText(skipped ? '성장 정산 즉시 완료' : '성장 정산 완료'); + this.refreshResultContinueCta(); + } + + private applyResultGaugeFinalState(animation: ResultGaugeAnimation) { + animation.fill.setScale(Phaser.Math.Clamp(animation.exp / animation.next, 0, 1), 1); + animation.valueText.setText(`${animation.exp} / ${animation.next}`); + animation.levelText.setText(`${animation.levelPrefix}${animation.level}`); + if (animation.valueColor) { + animation.valueText.setColor(animation.valueColor); + } + if (animation.levelColor) { + animation.levelText.setColor(animation.levelColor); + } + animation.eventText.setText('').setAlpha(0); + } + + private stopResultSettlementTweens() { + const tweens = [...this.resultSettlementTweens]; + this.resultSettlementTweens = []; + tweens.forEach((tween) => tween.stop()); + } + + private clearResultSettlementTransientObjects() { + const transientObjects = new Set(this.resultSettlementTransientObjects); + this.resultSettlementTransientObjects = []; + transientObjects.forEach((object) => { + if (object.active) { + object.destroy(); + } + }); + this.resultObjects = this.resultObjects.filter((object) => !transientObjects.has(object)); + } + + private animateResultGaugeProgress( + animation: ResultGaugeAnimation, + fromRatio: number, + toRatio: number, + fromValue: number, + toValue: number, + next: number, + duration: number, + token: number + ) { + animation.fill.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); + animation.valueText.setText(`${fromValue} / ${next}`); + return new Promise((resolve) => { + if (!this.resultSettlementActive(token)) { + resolve(); + return; + } + + const tracker = { ratio: Phaser.Math.Clamp(fromRatio, 0, 1), value: fromValue }; + let settled = false; + let tween: Phaser.Tweens.Tween; + const finish = () => { + if (settled) { + return; + } + settled = true; + this.resultSettlementTweens = this.resultSettlementTweens.filter((candidate) => candidate !== tween); + resolve(); + }; + tween = this.tweens.add({ + targets: tracker, + ratio: Phaser.Math.Clamp(toRatio, 0, 1), + value: toValue, + duration: this.scaledBattleDuration(duration, 120), + ease: 'Sine.easeInOut', + onUpdate: () => { + if (!this.resultSettlementActive(token)) { + tween.stop(); + return; + } + animation.fill.setScale(tracker.ratio, 1); + animation.valueText.setText(`${Math.round(tracker.value)} / ${next}`); + }, + onComplete: () => { + if (this.resultSettlementActive(token)) { + animation.fill.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1); + animation.valueText.setText(`${toValue} / ${next}`); + } + finish(); + }, + onStop: finish + }); + this.resultSettlementTweens.push(tween); + }); + } + + private async animateResultGauge(animation: ResultGaugeAnimation, duration: number, token: number) { + soundDirector.playGrowthTick(); if (animation.leveled) { - await this.animateGrowthGauge( - animation.fill, - animation.valueText, + await this.animateResultGaugeProgress( + animation, animation.previousExp / animation.previousNext, 1, animation.previousExp, animation.previousNext, animation.previousNext, - false, - duration + duration, + token ); + if (!this.resultSettlementActive(token)) { + return; + } animation.levelText.setText(`${animation.levelPrefix}${animation.level}`); animation.fill.setScale(0, 1); animation.valueText.setText(`0 / ${animation.next}`); this.showGrowthEventText(animation.eventText, this.levelUpEventLabel(animation)); this.flashResultGauge(animation); soundDirector.playLevelUp(); - await this.playResultLevelUpCelebration(animation); - await this.animateGrowthGauge(animation.fill, animation.valueText, 0, animation.exp / animation.next, 0, animation.exp, animation.next, false, duration); + await this.playResultLevelUpCelebration(animation, token); + if (!this.resultSettlementActive(token)) { + return; + } + await this.animateResultGaugeProgress( + animation, + 0, + animation.exp / animation.next, + 0, + animation.exp, + animation.next, + duration, + token + ); return; } - await this.animateGrowthGauge( - animation.fill, - animation.valueText, + await this.animateResultGaugeProgress( + animation, animation.previousExp / animation.previousNext, animation.exp / animation.next, animation.previousExp, animation.exp, animation.next, - false, - duration + duration, + token ); - animation.levelText.setText(`${animation.levelPrefix}${animation.level}`); + if (this.resultSettlementActive(token)) { + animation.levelText.setText(`${animation.levelPrefix}${animation.level}`); + } } private flashResultGauge(animation: ResultGaugeAnimation) { const { x, y, width, height, depth } = animation.flashArea; - const flash = this.trackResultObject(this.add.rectangle(x + width / 2, y + height / 2, width, height, 0xffdf7b, 0.16)); + const flash = this.trackResultSettlementTransientObject(this.add.rectangle(x + width / 2, y + height / 2, width, height, 0xffdf7b, 0.16)); flash.setDepth(depth); this.tweens.add({ targets: flash, @@ -13952,12 +14272,12 @@ export class BattleScene extends Phaser.Scene { this.flashTextColor(animation.valueText, '#fff2b8'); } - private async playResultLevelUpCelebration(animation: ResultGaugeAnimation) { + private async playResultLevelUpCelebration(animation: ResultGaugeAnimation, token: number) { const { x, y, width, height, depth } = animation.flashArea; const spriteX = Math.min(x + width - 52, animation.valueText.x + 40); const spriteY = y + height / 2 + 2; this.createResultLevelUpBurst(spriteX, spriteY, depth + 4); - const banner = this.trackResultObject(this.add.text(spriteX, spriteY - 44, 'LEVEL UP', { + const banner = this.trackResultSettlementTransientObject(this.add.text(spriteX, spriteY - 44, 'LEVEL UP', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#fff2b8', @@ -13970,14 +14290,17 @@ export class BattleScene extends Phaser.Scene { let sprite: Phaser.GameObjects.Sprite | undefined; if (animation.unit) { - sprite = this.trackResultObject( + sprite = this.trackResultSettlementTransientObject( this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit))) ); sprite.setDisplaySize(70, 70); sprite.setDepth(depth + 6); } - await this.playResultLevelUpSpotlight(animation); + await this.playResultLevelUpSpotlight(animation, token); + if (!this.resultSettlementActive(token)) { + return; + } if (sprite && animation.unit) { await this.playUnitCelebrationFrames(sprite, animation.unit); } @@ -13988,10 +14311,10 @@ export class BattleScene extends Phaser.Scene { return stats ? `Lv ${animation.level} 달성! ${stats}` : `Lv ${animation.level} 달성!`; } - private async playResultLevelUpSpotlight(animation: ResultGaugeAnimation) { + private async playResultLevelUpSpotlight(animation: ResultGaugeAnimation, token: number) { const stats = this.statGainLine(animation.statGains); const depth = animation.flashArea.depth + 12; - const container = this.trackResultObject(this.add.container(battleReferenceWidth / 2, 146)); + const container = this.trackResultSettlementTransientObject(this.add.container(battleReferenceWidth / 2, 146)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.9); @@ -14045,6 +14368,9 @@ export class BattleScene extends Phaser.Scene { this.tweens.add({ targets: burst, angle: 240, scale: 1.34, alpha: 0.16, duration: 760, ease: 'Sine.easeOut' }); this.tweens.add({ targets: container, alpha: 1, scale: 1.04, duration: 180, ease: 'Back.easeOut' }); await this.delay(760); + if (!this.resultSettlementActive(token) || !container.active) { + return; + } this.tweens.add({ targets: container, alpha: 0, @@ -14091,12 +14417,12 @@ 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)); + const halo = this.trackResultSettlementTransientObject(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)); + const ring = this.trackResultSettlementTransientObject(this.add.circle(x, y + 2, 33)); ring.setStrokeStyle(3, 0xffdf7b, 0.84); ring.setDepth(depth); - const weaponFlash = this.trackResultObject(this.add.star(x + 22, y - 28, 5, 4, 15, 0xfff2b0, 0.92)); + const weaponFlash = this.trackResultSettlementTransientObject(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' }); this.tweens.add({ targets: halo, alpha: 0.02, duration: 680, ease: 'Sine.easeOut' }); @@ -14239,6 +14565,11 @@ export class BattleScene extends Phaser.Scene { return object; } + private trackResultSettlementTransientObject(object: T) { + this.resultSettlementTransientObjects.push(object); + return this.trackResultObject(object); + } + private scheduleUiContainerDepthSort(container: Phaser.GameObjects.Container) { if (this.pendingUiContainerSorts.has(container)) { return; @@ -24643,6 +24974,29 @@ export class BattleScene extends Phaser.Scene { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } + private resultSettlementDebugState() { + const button = this.resultContinueButton; + const route = this.battleOutcome === 'victory' ? this.resultContinueRoute() : undefined; + const ctaState = !button + ? 'hidden' + : this.resultSettlementStatus === 'complete' + ? 'navigation' + : 'settlement'; + return { + status: this.resultSettlementStatus, + skipped: this.resultSettlementSkipped, + progress: this.resultSettlementProgress, + total: this.resultGaugeAnimations.length, + cta: { + state: ctaState, + label: button?.label.text ?? null, + destination: ctaState === 'navigation' ? route?.destination ?? null : null, + interactive: Boolean(button?.background.input?.enabled), + bounds: this.resultObjectBoundsDebug(button?.background) + } + }; + } + private gameObjectBoundsDebug(object?: Phaser.GameObjects.GameObject) { if (!object?.active) { return null; @@ -25160,6 +25514,7 @@ export class BattleScene extends Phaser.Scene { saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, saveSlotPanelMode: this.saveSlotPanelMode ?? null, resultVisible: this.resultObjects.length > 0, + resultSettlement: this.resultSettlementDebugState(), resultActions: this.resultActionButtons .filter((button) => button.background.active && button.label.active) .map((button) => { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index f185c70..9a44387 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -158,14 +158,17 @@ import { type UnitData } from '../data/scenario'; import { + acknowledgeCampaignVictoryReward, applyCampVisitReward, applyCampBondExp, + campaignVictoryRewardPresentation, campaignSortiePresetIds, campaignReserveTrainingFocusDefinitions, defaultCampaignReserveTrainingFocusId, ensureCampaignRosterUnits, getCampaignState, getFirstBattleReport, + getPendingCampaignVictoryRewardReport, markCampaignStep, listCampaignSaveSlots, campaignSaveSlotCount, @@ -241,9 +244,27 @@ type CampTabButtonView = { bg: Phaser.GameObjects.Rectangle; text: Phaser.GameObjects.Text; indicator: Phaser.GameObjects.Rectangle; + newBadge?: Phaser.GameObjects.Text; hovered: boolean; }; +type VictoryRewardCardId = 'gold' | 'equipment' | 'supplies' | 'reputation' | 'recruits' | 'unlocks'; + +type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close'; + +type VictoryRewardCardView = { + id: VictoryRewardCardId; + label: string; + items: string[]; + background: Phaser.GameObjects.Rectangle; +}; + +type VictoryRewardActionView = { + id: VictoryRewardActionId; + label: string; + button: Phaser.GameObjects.Rectangle; +}; + type CampActionButtonView = { background: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text; @@ -11250,6 +11271,10 @@ export class CampScene extends Phaser.Scene { private saveSlotObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = []; private pendingSaveSlot?: number; + private pendingVictoryRewardReport?: FirstBattleReport; + private victoryRewardObjects: Phaser.GameObjects.GameObject[] = []; + private victoryRewardCards: VictoryRewardCardView[] = []; + private victoryRewardActions: VictoryRewardActionView[] = []; private reportFormationReviewObjects: Phaser.GameObjects.GameObject[] = []; private reportFormationReviewVisible = false; private reportFormationReviewFeedback = ''; @@ -11359,6 +11384,10 @@ export class CampScene extends Phaser.Scene { this.saveSlotObjects = []; this.saveSlotConfirmObjects = []; this.pendingSaveSlot = undefined; + this.pendingVictoryRewardReport = undefined; + this.victoryRewardObjects = []; + this.victoryRewardCards = []; + this.victoryRewardActions = []; this.reportFormationReviewObjects = []; this.reportFormationReviewVisible = false; this.reportFormationReviewFeedback = ''; @@ -11379,6 +11408,7 @@ export class CampScene extends Phaser.Scene { this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); + this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardReport(); if (!this.retrySortieBattleId) { this.ensureCurrentCampRecruitment(); } @@ -11529,12 +11559,11 @@ export class CampScene extends Phaser.Scene { this.renderStaticButtons(); this.render(); - if (this.openSortiePrepOnCreate) { + if (this.pendingVictoryRewardReport) { + this.time.delayedCall(0, () => this.showVictoryRewardAcknowledgement()); + } else if (this.openSortiePrepOnCreate) { this.time.delayedCall(0, () => { - if (this.openSortieImprovementOnCreate) { - this.prepareGuidedSortieImprovement(); - } - this.showSortiePrep(); + this.showRequestedSortiePrep(); }); } } @@ -12862,6 +12891,10 @@ export class CampScene extends Phaser.Scene { } private handleSaveSlotKey(event: KeyboardEvent) { + if (this.victoryRewardObjects.length > 0) { + return; + } + if (this.saveSlotConfirmObjects.length > 0) { if (event.key === 'Enter') { event.preventDefault(); @@ -12895,7 +12928,228 @@ export class CampScene extends Phaser.Scene { this.pendingSaveSlot = undefined; } + private showVictoryRewardAcknowledgement() { + const report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport(); + if (!report || report.outcome !== 'victory' || this.sortieObjects.length > 0) { + return; + } + + this.pendingVictoryRewardReport = report; + this.hideVictoryRewardAcknowledgement(); + const rewards = report.campaignRewards; + const presentation = campaignVictoryRewardPresentation(report); + const categorizedRewardItems = new Set([ + ...(rewards?.supplies ?? []), + ...(rewards?.equipment ?? []), + ...(rewards?.reputation ?? []) + ]); + const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item)); + const supplyItems = rewards + ? [ + ...rewards.supplies, + ...legacyExtraItems, + ...(presentation.sortieOrderBonus?.rewardItems ?? []) + ] + : [...presentation.itemRewards]; + const depth = 80; + const panelX = 198; + const panelY = 90; + const panelWidth = 884; + const panelHeight = 546; + + const shade = this.trackVictoryReward( + this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.78) + ); + shade.setOrigin(0); + shade.setDepth(depth); + shade.setInteractive(); + + const panel = this.trackVictoryReward( + this.add.rectangle(panelX, panelY, panelWidth, panelHeight, 0x0d151e, 0.995) + ); + panel.setOrigin(0); + panel.setDepth(depth + 1); + panel.setStrokeStyle(3, this.campSkinSelection?.skin.accentColor ?? palette.gold, 0.96); + panel.setInteractive(); + + const eyebrow = this.trackVictoryReward( + this.add.text(panelX + 34, panelY + 24, 'VICTORY REWARD', this.textStyle(11, '#d8b15f', true)) + ); + eyebrow.setDepth(depth + 2); + + const title = this.trackVictoryReward( + this.add.text(panelX + 34, panelY + 45, '이번 승전 반영', this.textStyle(30, '#f2e3bf', true)) + ); + title.setDepth(depth + 2); + + const closeHint = this.trackVictoryReward( + this.add.text(panelX + panelWidth - 34, panelY + 28, 'Esc 닫기', this.textStyle(12, '#9fb0bf', true)) + ); + closeHint.setOrigin(1, 0); + closeHint.setDepth(depth + 2); + + const subtitle = this.trackVictoryReward( + this.add.text( + panelX + 34, + panelY + 88, + `${report.battleTitle} 승리 보상이 군영 자산에 반영되었습니다.`, + this.textStyle(14, '#d4dce6', true) + ) + ); + subtitle.setDepth(depth + 2); + + const cardDefinitions: Array<{ id: VictoryRewardCardId; label: string; items: string[] }> = [ + { + id: 'gold', + label: '군자금', + items: [ + `+${presentation.rewardGold} 군자금`, + ...(presentation.sortieOrderBonus + ? [`${presentation.sortieOrderBonus.label} 첫 공훈 +${presentation.sortieOrderBonus.rewardGold} 포함`] + : []) + ] + }, + { id: 'equipment', label: '장비', items: [...(rewards?.equipment ?? [])] }, + { id: 'supplies', label: '보급', items: supplyItems }, + { id: 'reputation', label: '명성·기록', items: [...(rewards?.reputation ?? [])] }, + { id: 'recruits', label: '합류 무장', items: rewards?.recruits.map((recruit) => recruit.name) ?? [] }, + { id: 'unlocks', label: '새 전장', items: rewards?.unlocks.map((unlock) => unlock.title) ?? [] } + ]; + const cardWidth = 252; + const cardHeight = 104; + cardDefinitions.forEach((definition, index) => { + const column = index % 3; + const row = Math.floor(index / 3); + const cardX = panelX + 34 + column * 268; + const cardY = panelY + 128 + row * 120; + const background = this.trackVictoryReward( + this.add.rectangle(cardX, cardY, cardWidth, cardHeight, definition.id === 'gold' ? 0x2d2619 : 0x141f2a, 0.98) + ); + background.setOrigin(0); + background.setDepth(depth + 2); + background.setStrokeStyle(1, definition.id === 'gold' ? palette.gold : palette.blue, definition.items.length > 0 ? 0.78 : 0.38); + + const label = this.trackVictoryReward( + this.add.text(cardX + 16, cardY + 13, definition.label, this.textStyle(13, definition.id === 'gold' ? '#ffdf7b' : '#9fc8e8', true)) + ); + label.setDepth(depth + 3); + + const itemText = definition.items.length > 0 ? definition.items.join(' · ') : '변동 없음'; + const value = this.trackVictoryReward( + this.add.text(cardX + 16, cardY + 40, itemText, { + ...this.textStyle(definition.id === 'gold' ? 17 : 12, definition.items.length > 0 ? '#f2e3bf' : '#77818c', true), + wordWrap: { width: cardWidth - 32, useAdvancedWrap: true }, + lineSpacing: 4 + }) + ); + value.setDepth(depth + 3); + this.victoryRewardCards.push({ ...definition, background }); + }); + + const note = rewards?.note?.trim(); + const noteText = this.trackVictoryReward( + this.add.text( + panelX + 34, + panelY + 378, + note ? `전황 기록 · ${note}` : '보상 확인 후 필요한 정비 화면으로 바로 이동할 수 있습니다.', + { + ...this.textStyle(12, note ? '#ffdf7b' : '#9fb0bf', Boolean(note)), + wordWrap: { width: panelWidth - 68, useAdvancedWrap: true } + } + ) + ); + noteText.setDepth(depth + 2); + + const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> = [ + { id: 'equipment', label: '장비 확인' }, + { id: 'supplies', label: '보급 확인' }, + { id: 'sortie', label: '출진 준비', primary: true }, + { id: 'close', label: '닫기' } + ]; + const buttonWidth = 158; + const buttonHeight = 48; + const actionGap = 14; + const totalActionWidth = actionDefinitions.length * buttonWidth + (actionDefinitions.length - 1) * actionGap; + const actionStartX = panelX + Math.floor((panelWidth - totalActionWidth) / 2); + actionDefinitions.forEach((definition, index) => { + const buttonX = actionStartX + index * (buttonWidth + actionGap); + const buttonY = panelY + panelHeight - 76; + const restingFill = definition.primary ? 0x654c25 : 0x1a2834; + const hoverFill = definition.primary ? 0x7a5b2b : 0x294052; + const button = this.trackVictoryReward( + this.add.rectangle(buttonX, buttonY, buttonWidth, buttonHeight, restingFill, 0.99) + ); + button.setOrigin(0); + button.setDepth(depth + 2); + button.setStrokeStyle(definition.primary ? 2 : 1, palette.gold, definition.primary ? 0.96 : 0.68); + button.setInteractive({ useHandCursor: true }); + + const label = this.trackVictoryReward( + this.add.text(buttonX + buttonWidth / 2, buttonY + buttonHeight / 2, definition.label, this.textStyle(14, definition.primary ? '#fff2b8' : '#f2e3bf', true)) + ); + label.setOrigin(0.5); + label.setDepth(depth + 3); + label.setInteractive({ useHandCursor: true }); + + const run = () => this.completeVictoryRewardAcknowledgement(definition.id); + [button, label].forEach((target) => { + target.on('pointerover', () => button.setFillStyle(hoverFill, 0.99)); + target.on('pointerout', () => button.setFillStyle(restingFill, 0.99)); + target.on('pointerdown', run); + }); + this.victoryRewardActions.push({ id: definition.id, label: definition.label, button }); + }); + } + + private completeVictoryRewardAcknowledgement(action: VictoryRewardActionId) { + const report = this.pendingVictoryRewardReport; + if (report) { + this.campaign = acknowledgeCampaignVictoryReward(report.battleId); + if (this.campaign.acknowledgedVictoryRewardBattleIds.includes(report.battleId)) { + this.pendingVictoryRewardReport = undefined; + } + } + soundDirector.playSelect(); + this.hideVictoryRewardAcknowledgement(); + this.updateTabButtons(); + + if (action === 'equipment') { + this.activeTab = 'equipment'; + this.render(); + return; + } + if (action === 'supplies') { + this.activeTab = 'supplies'; + this.render(); + return; + } + if (action === 'sortie' || (action === 'close' && this.openSortiePrepOnCreate)) { + this.showRequestedSortiePrep(); + } + } + + private showRequestedSortiePrep() { + if (this.openSortieImprovementOnCreate) { + this.prepareGuidedSortieImprovement(); + } + this.openSortiePrepOnCreate = false; + this.openSortieImprovementOnCreate = false; + this.showSortiePrep(); + } + + private hideVictoryRewardAcknowledgement() { + this.victoryRewardObjects.forEach((object) => object.destroy()); + this.victoryRewardObjects = []; + this.victoryRewardCards = []; + this.victoryRewardActions = []; + } + private handleEscapeKey() { + if (this.victoryRewardObjects.length > 0) { + this.completeVictoryRewardAcknowledgement('close'); + return; + } + if (this.saveSlotConfirmObjects.length > 0) { soundDirector.playSelect(); this.hideCampSaveConfirm(); @@ -12936,6 +13190,7 @@ export class CampScene extends Phaser.Scene { private handleSortieEnterKey(event: KeyboardEvent) { if ( this.sortieObjects.length === 0 || + this.victoryRewardObjects.length > 0 || this.saveSlotObjects.length > 0 || this.saveSlotConfirmObjects.length > 0 || !this.usesStagedSortiePrep() @@ -12977,7 +13232,14 @@ export class CampScene extends Phaser.Scene { })); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); - const view: CampTabButtonView = { tab, bg, text, indicator, hovered: false }; + const newBadge = tab === 'supplies' || tab === 'equipment' + ? this.scaleLegacyCampUi(this.add.text(x + 29, y - 17, 'NEW', { + ...this.textStyle(9, '#fff2b8', true), + backgroundColor: '#8a3f25', + padding: { x: 4, y: 2 } + })).setOrigin(0.5).setDepth(6) + : undefined; + const view: CampTabButtonView = { tab, bg, text, indicator, newBadge, hovered: false }; const setHovered = (hovered: boolean) => { view.hovered = hovered; this.updateTabButtons(); @@ -12993,7 +13255,7 @@ export class CampScene extends Phaser.Scene { private updateTabButtons() { const accentColor = this.campSkinSelection?.skin.accentColor ?? palette.gold; - this.tabButtons.forEach(({ tab, bg, text, indicator, hovered }) => { + this.tabButtons.forEach(({ tab, bg, text, indicator, newBadge, hovered }) => { const active = this.activeTab === tab; bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94); bg.setStrokeStyle(active || hovered ? 2 : 1, active || hovered ? accentColor : palette.blue, active ? 0.96 : hovered ? 0.82 : 0.62); @@ -13001,6 +13263,9 @@ export class CampScene extends Phaser.Scene { indicator.setFillStyle(accentColor, 1); indicator.setVisible(active || hovered); indicator.setAlpha(active ? 1 : 0.66); + newBadge?.setVisible(Boolean( + this.pendingVictoryRewardReport && (tab === 'supplies' || tab === 'equipment') + )); }); } @@ -22673,6 +22938,12 @@ export class CampScene extends Phaser.Scene { return object; } + private trackVictoryReward(object: T) { + this.scaleLegacyCampUi(object); + this.victoryRewardObjects.push(object); + return object; + } + private track(object: T) { this.scaleLegacyCampUi(object); this.contentObjects.push(object); @@ -22820,6 +23091,22 @@ export class CampScene extends Phaser.Scene { const equipmentInventoryPageCount = Math.max(1, Math.ceil(equipmentInventory.length / equipmentInventoryPageSize)); return { scene: this.scene.key, + victoryRewardAcknowledgement: { + visible: this.victoryRewardObjects.length > 0, + battleId: this.pendingVictoryRewardReport?.battleId ?? null, + cards: this.victoryRewardCards.map((card) => ({ + id: card.id, + label: card.label, + items: [...card.items], + bounds: this.sortieObjectBoundsDebug(card.background) + })), + actions: this.victoryRewardActions.map((action) => ({ + id: action.id, + label: action.label, + bounds: this.sortieObjectBoundsDebug(action.button), + interactive: Boolean(action.button.input?.enabled) + })) + }, activeTab: this.activeTab, campTabs: this.tabButtons.map((button) => ({ id: button.tab, @@ -22829,6 +23116,7 @@ export class CampScene extends Phaser.Scene { bounds: this.sortieObjectBoundsDebug(button.bg), interactive: Boolean(button.bg.input?.enabled && button.text.input?.enabled), indicatorVisible: button.indicator.visible, + newBadgeVisible: Boolean(button.newBadge?.visible), fillColor: button.bg.fillColor, strokeColor: button.bg.strokeColor, lineWidth: button.bg.lineWidth diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 5ef591f..524639e 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1,5 +1,12 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; +import { equipmentItemIdForInventoryLabel } from '../data/battleItems'; +import { + battleUiIconFrames, + battleUiIconMicroTextureKey, + battleUiIconTextureKey, + type BattleUiIconKey +} from '../data/battleUiIcons'; import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets'; import { @@ -17,6 +24,7 @@ import { type StoryCutsceneReward } from '../data/storyCutscenes'; import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; +import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; @@ -79,6 +87,40 @@ type CutsceneStageBounds = { type CutsceneBoardBounds = CutsceneStageBounds; +type StoryRewardCategory = 'gold' | 'supply' | 'equipment' | 'reputation' | 'recruit' | 'unlock' | 'fallback'; +type StoryRewardSource = 'campaign' | 'fallback' | 'none'; + +type StoryRewardIcon = + | { kind: 'item'; textureKey: string; fallbackMark: string } + | { kind: 'battle-ui'; iconKey: BattleUiIconKey; fallbackMark: string } + | { kind: 'mark'; fallbackMark: string }; + +type StoryRewardDisplayItem = { + id: string; + category: StoryRewardCategory; + eyebrow: string; + label: string; + tone: StoryCutsceneReward['tone']; + icon: StoryRewardIcon; +}; + +type StoryRewardDisplayModel = { + pageId: string | null; + source: StoryRewardSource; + battleId: string | null; + rewardGold: number | null; + items: StoryRewardDisplayItem[]; +}; + +const firstBattleVictoryPageIds = new Set([ + 'first-victory-village', + 'first-victory-liu-bei', + 'first-victory-guan-yu', + 'first-victory-zhang-fei', + 'first-victory-next' +]); +const firstBattleId = 'first-battle-zhuo-commandery'; + export class StoryScene extends Phaser.Scene { private pageIndex = 0; private pages: StoryPage[] = prologuePages; @@ -103,6 +145,7 @@ export class StoryScene extends Phaser.Scene { private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void; private storyAssetLoadGeneration = 0; private storyAssetFailures = new Set(); + private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay(); constructor() { super('StoryScene'); @@ -120,6 +163,7 @@ export class StoryScene extends Phaser.Scene { this.activeStoryLoadErrorHandler = undefined; this.storyAssetFailures.clear(); this.storyAssetLoadGeneration += 1; + this.rewardDisplay = this.emptyRewardDisplay(); } getDebugState() { @@ -160,6 +204,10 @@ export class StoryScene extends Phaser.Scene { nextScene: this.nextScene, cutsceneKind: page?.cutscene?.kind ?? null, cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [], + rewardDisplay: { + ...this.rewardDisplay, + items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } })) + }, assetStreaming: { currentPageReady: this.readyStoryPageIndexes.has(this.pageIndex), nextPageIndex: this.pageIndex + 1 < this.pages.length ? this.pageIndex + 1 : null, @@ -581,7 +629,7 @@ export class StoryScene extends Phaser.Scene { private applyPage(page: StoryPage) { soundDirector.playMusic(page.bgm); this.applyBackground(page); - this.renderCutscene(page.cutscene); + this.renderCutscene(page); this.chapterText?.setText(page.chapter); const textureKey = this.pagePortraitTextureKey(page); @@ -692,10 +740,13 @@ export class StoryScene extends Phaser.Scene { }); } - private renderCutscene(cutscene?: StoryCutscene) { + private renderCutscene(page: StoryPage) { this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child)); this.cutsceneLayer?.destroy(); this.cutsceneLayer = undefined; + this.rewardDisplay = this.rewardDisplayForPage(page); + + const cutscene = page.cutscene; if (!cutscene) { return; @@ -708,7 +759,7 @@ export class StoryScene extends Phaser.Scene { this.renderCutsceneStage(layer, cutscene); const boardBounds = this.renderTacticalBoard(layer, cutscene); this.renderCutsceneActorCards(layer, cutscene, boardBounds); - this.renderCutsceneRewards(layer, cutscene.rewards ?? []); + this.renderCutsceneRewards(layer, this.rewardDisplay); } private renderCutsceneStage(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) { @@ -1205,48 +1256,264 @@ export class StoryScene extends Phaser.Scene { } } - private renderCutsceneRewards(layer: Phaser.GameObjects.Container, rewards: StoryCutsceneReward[]) { - if (rewards.length === 0) { + private renderCutsceneRewards(layer: Phaser.GameObjects.Container, model: StoryRewardDisplayModel) { + if (model.items.length === 0) { return; } const stage = this.cutsceneStageBounds(); - const maxItems = Math.min(4, rewards.length); + const visibleItems = model.items.slice(0, 4); + const maxItems = visibleItems.length; const stripX = stage.x + ui(28); - const stripY = stage.y + stage.height - ui(44); + const stripY = stage.y + stage.height - ui(62); const stripW = stage.width - ui(56); - const itemW = Math.floor(stripW / maxItems); + const gap = ui(8); + const itemW = (stripW - gap * (maxItems - 1)) / maxItems; + const itemH = ui(54); const bg = this.add.graphics(); - bg.fillStyle(cutsceneUiColors.veil, 0.22); - bg.fillRoundedRect(stripX + ui(4), stripY + ui(5), stripW, ui(34), ui(6)); - bg.fillStyle(cutsceneUiColors.lacquer, 0.72); - bg.fillRoundedRect(stripX, stripY, stripW, ui(34), ui(6)); + bg.fillStyle(cutsceneUiColors.veil, 0.42); + bg.fillRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9)); 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)); + bg.strokeRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9)); layer.add(bg); - rewards.slice(0, maxItems).forEach((reward, index) => { + visibleItems.forEach((reward, index) => { const toneColor = reward.tone === 'next' ? palette.blue : reward.tone === 'bond' ? palette.green : palette.gold; - const x = stripX + index * itemW; + const card = this.add.container(stripX + index * (itemW + gap), stripY + ui(6)); const item = this.add.graphics(); - item.fillStyle(toneColor, 0.16); - item.fillCircle(x + ui(17), stripY + ui(17), ui(5)); - item.lineStyle(ui(1), toneColor, 0.22); - if (index > 0) { - item.lineBetween(x, stripY + ui(9), x, stripY + ui(25)); - } - const text = this.add.text(x + ui(30), stripY + ui(8), reward.label, { + item.fillStyle(cutsceneUiColors.veil, 0.5); + item.fillRoundedRect(ui(3), ui(4), itemW, itemH, ui(7)); + item.fillStyle(cutsceneUiColors.lacquer, 0.94); + item.fillRoundedRect(0, 0, itemW, itemH, ui(7)); + item.fillStyle(toneColor, 0.12); + item.fillRoundedRect(ui(5), ui(5), itemW - ui(10), itemH - ui(10), ui(5)); + item.fillStyle(toneColor, 0.2); + item.fillRoundedRect(ui(7), ui(7), ui(42), itemH - ui(14), ui(5)); + item.lineStyle(ui(1), toneColor, 0.42); + item.strokeRoundedRect(0, 0, itemW, itemH, ui(7)); + item.lineStyle(ui(1), 0xf0d08a, 0.12); + item.lineBetween(ui(58), ui(29), itemW - ui(12), ui(29)); + + const icon = this.renderRewardIcon(reward.icon, ui(28), itemH / 2, ui(31), toneColor); + const eyebrow = this.add.text(ui(60), ui(8), reward.eyebrow, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: uiPx(13), + fontSize: uiPx(11), + color: reward.tone === 'next' ? '#9fc3e8' : reward.tone === 'bond' ? '#a9ddb9' : '#d8b15f', + fontStyle: '700' + }); + const text = this.add.text(ui(60), ui(31), reward.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(14), color: '#f2e3bf', fontStyle: '700', - wordWrap: { width: itemW - ui(40), useAdvancedWrap: true } + wordWrap: { width: itemW - ui(74), useAdvancedWrap: true } + }); + text.setOrigin(0, 0.5); + card.add([item, icon, eyebrow, text]); + card.setAlpha(0); + layer.add(card); + this.tweens.add({ + targets: card, + y: stripY, + alpha: 1, + duration: 220, + delay: 60 + index * 70, + ease: 'Cubic.easeOut' }); - layer.add([item, text]); }); } + private rewardDisplayForPage(page: StoryPage): StoryRewardDisplayModel { + const fallback = this.fallbackRewardDisplay(page); + if (!firstBattleVictoryPageIds.has(page.id)) { + return fallback; + } + + const report = getFirstBattleReport(); + if (!report || report.battleId !== firstBattleId || report.outcome !== 'victory') { + return fallback; + } + + const rewards = report.campaignRewards; + const presentation = campaignVictoryRewardPresentation(report); + const categorizedRewardItems = new Set([ + ...(rewards?.supplies ?? []), + ...(rewards?.equipment ?? []), + ...(rewards?.reputation ?? []) + ]); + const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item)); + const supplyItems = rewards + ? [ + ...rewards.supplies, + ...legacyExtraItems, + ...(presentation.sortieOrderBonus?.rewardItems ?? []) + ] + : [...presentation.itemRewards]; + if (!rewards && page.id !== 'first-victory-village') { + return fallback; + } + + let items: StoryRewardDisplayItem[] = []; + if (page.id === 'first-victory-village') { + items = [this.goldRewardItem(presentation.rewardGold), ...supplyItems.map((label, index) => this.supplyRewardItem(label, index))]; + } else if (page.id === 'first-victory-liu-bei' && rewards) { + items = [ + ...rewards.equipment.map((label, index) => this.equipmentRewardItem(label, index)), + ...rewards.reputation.map((label, index) => this.reputationRewardItem(label, index)) + ]; + } else if (page.id === 'first-victory-guan-yu' && rewards) { + items = rewards.recruits + .filter((recruit) => recruit.unitId === 'guan-yu') + .map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name)); + } else if (page.id === 'first-victory-zhang-fei' && rewards) { + items = rewards.recruits + .filter((recruit) => recruit.unitId !== 'guan-yu') + .map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name)); + } else if (page.id === 'first-victory-next' && rewards) { + items = rewards.unlocks.map((unlock) => this.unlockRewardItem(unlock.battleId, unlock.title)); + } + + return { + pageId: page.id, + source: 'campaign', + battleId: report.battleId, + rewardGold: presentation.rewardGold, + items + }; + } + + private fallbackRewardDisplay(page: StoryPage): StoryRewardDisplayModel { + const rewards = page.cutscene?.rewards ?? []; + return { + pageId: page.id, + source: rewards.length > 0 ? 'fallback' : 'none', + battleId: null, + rewardGold: null, + items: rewards.map((reward, index) => ({ + id: `fallback-${index}`, + category: 'fallback', + eyebrow: reward.tone === 'next' ? '다음 목표' : reward.tone === 'bond' ? '전과' : '획득 정보', + label: reward.label, + tone: reward.tone, + icon: { kind: 'mark', fallbackMark: reward.tone === 'next' ? '路' : reward.tone === 'bond' ? '功' : '賞' } + })) + }; + } + + private emptyRewardDisplay(): StoryRewardDisplayModel { + return { pageId: null, source: 'none', battleId: null, rewardGold: null, items: [] }; + } + + private goldRewardItem(amount: number): StoryRewardDisplayItem { + return { + id: 'gold', + category: 'gold', + eyebrow: '군자금', + label: `+${Math.max(0, amount).toLocaleString('ko-KR')}`, + tone: 'reward', + icon: { kind: 'mark', fallbackMark: '金' } + }; + } + + private supplyRewardItem(label: string, index: number): StoryRewardDisplayItem { + const iconKey: BattleUiIconKey = /탁주/.test(label) ? 'wine' : /상처약/.test(label) ? 'salve' : 'bean'; + return { + id: `supply-${index}-${label}`, + category: 'supply', + eyebrow: '보급품', + label, + tone: 'reward', + icon: { kind: 'battle-ui', iconKey, fallbackMark: /탁주/.test(label) ? '酒' : /상처약/.test(label) ? '藥' : '糧' } + }; + } + + private equipmentRewardItem(label: string, index: number): StoryRewardDisplayItem { + const inventoryLabel = label.replace(/\s+(?:[x×]\s*)?\d+\s*$/u, '').trim(); + const itemId = equipmentItemIdForInventoryLabel(inventoryLabel); + return { + id: `equipment-${index}-${label}`, + category: 'equipment', + eyebrow: '장비 획득', + label, + tone: 'reward', + icon: itemId + ? { kind: 'item', textureKey: `item-${itemId}`, fallbackMark: '具' } + : { kind: 'battle-ui', iconKey: 'sword', fallbackMark: '具' } + }; + } + + private reputationRewardItem(label: string, index: number): StoryRewardDisplayItem { + return { + id: `reputation-${index}-${label}`, + category: 'reputation', + eyebrow: '명성 상승', + label, + tone: 'bond', + icon: { kind: 'mark', fallbackMark: '名' } + }; + } + + private recruitRewardItem(unitId: string, name: string): StoryRewardDisplayItem { + return { + id: `recruit-${unitId}`, + category: 'recruit', + eyebrow: '신규 합류', + label: `${name} 출전 가능`, + tone: 'bond', + icon: { kind: 'mark', fallbackMark: '將' } + }; + } + + private unlockRewardItem(battleId: string, title: string): StoryRewardDisplayItem { + return { + id: `unlock-${battleId}`, + category: 'unlock', + eyebrow: '전장 해금', + label: title, + tone: 'next', + icon: { kind: 'battle-ui', iconKey: 'success', fallbackMark: '開' } + }; + } + + private renderRewardIcon(icon: StoryRewardIcon, x: number, y: number, size: number, toneColor: number) { + if (icon.kind === 'item' && this.textures.exists(icon.textureKey)) { + const image = this.add.image(x, y, icon.textureKey); + image.setDisplaySize(size, size); + return image; + } + + if (icon.kind === 'battle-ui') { + const textureKey = this.textures.exists(battleUiIconTextureKey) + ? battleUiIconTextureKey + : this.textures.exists(battleUiIconMicroTextureKey) + ? battleUiIconMicroTextureKey + : undefined; + if (textureKey) { + const image = this.add.image(x, y, textureKey, battleUiIconFrames[icon.iconKey]); + image.setDisplaySize(size, size); + return image; + } + } + + const fallback = this.add.container(x, y); + const seal = this.add.graphics(); + seal.fillStyle(cutsceneUiColors.ink, 0.9); + seal.fillCircle(0, 0, size * 0.48); + seal.lineStyle(ui(1), toneColor, 0.72); + seal.strokeCircle(0, 0, size * 0.46); + seal.lineStyle(ui(1), 0xf0d08a, 0.16); + seal.strokeCircle(0, 0, size * 0.35); + const mark = this.add.text(0, 0, icon.fallbackMark, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', + fontSize: uiPx(18), + color: '#f2e3bf', + fontStyle: '700' + }); + mark.setOrigin(0.5); + fallback.add([seal, mark]); + return fallback; + } + private cutsceneStageBounds(): CutsceneStageBounds { return { x: ui(58), diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 70b2802..67958b2 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -134,6 +134,18 @@ export type FirstBattleReport = { createdAt: string; }; +export type CampaignVictoryRewardPresentation = { + baseRewardGold: number; + rewardGold: number; + itemRewards: string[]; + sortieOrderBonus?: { + orderId: SortieOrderId; + label: string; + rewardGold: number; + rewardItems: string[]; + }; +}; + export type CampaignStep = | 'new' | 'prologue' @@ -448,6 +460,7 @@ export type CampaignState = { reserveTrainingAssignments: CampaignReserveTrainingAssignments; completedCampDialogues: string[]; completedCampVisits: string[]; + acknowledgedVictoryRewardBattleIds: string[]; battleHistory: Record; latestBattleId?: string; firstBattleReport?: FirstBattleReport; @@ -652,6 +665,7 @@ export function createInitialCampaignState(): CampaignState { reserveTrainingAssignments: {}, completedCampDialogues: [], completedCampVisits: [], + acknowledgedVictoryRewardBattleIds: [], battleHistory: {} }; } @@ -986,6 +1000,66 @@ export function getFirstBattleReport() { return report ? cloneReport(report) : undefined; } +export function campaignVictoryRewardPresentation(report: FirstBattleReport): CampaignVictoryRewardPresentation { + const definition = report.outcome === 'victory' && report.sortieOrder?.rewardGranted + ? sortieOrderDefinition(report.sortieOrder.orderId) + : undefined; + const sortieOrderBonus = definition + ? { + orderId: definition.id, + label: definition.label, + rewardGold: definition.rewardGold, + rewardItems: [...definition.rewardItems] + } + : undefined; + return { + baseRewardGold: report.rewardGold, + rewardGold: report.rewardGold + (sortieOrderBonus?.rewardGold ?? 0), + itemRewards: [...report.itemRewards, ...(sortieOrderBonus?.rewardItems ?? [])], + sortieOrderBonus + }; +} + +export function getPendingCampaignVictoryRewardReport() { + const state = ensureCampaignState(); + const report = state.firstBattleReport; + if ( + !report || + report.outcome !== 'victory' || + state.acknowledgedVictoryRewardBattleIds.includes(report.battleId) + ) { + return undefined; + } + return cloneReport(report); +} + +export function acknowledgeCampaignVictoryReward(battleId: string) { + const state = ensureCampaignState(); + const normalizedBattleId = normalizeKeyString(battleId); + const report = state.firstBattleReport; + const settlement = state.battleHistory[normalizedBattleId]; + const hasVictory = settlement?.outcome === 'victory' || ( + report?.battleId === normalizedBattleId && report.outcome === 'victory' + ); + if ( + !normalizedBattleId || + !(normalizedBattleId in battleScenarios) || + !hasVictory + ) { + return cloneCampaignState(state); + } + + if (!state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId)) { + state.acknowledgedVictoryRewardBattleIds = [ + ...state.acknowledgedVictoryRewardBattleIds, + normalizedBattleId + ]; + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + } + return cloneCampaignState(state); +} + export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) { const state = ensureCampaignState(); if (!state.firstBattleReport) { @@ -1158,6 +1232,8 @@ function normalizeCampaignState(state: Partial): CampaignState { .filter((bond): bond is CampBondSnapshot => Boolean(bond)); normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues); normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits); + normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds) + .filter((battleId) => battleId in battleScenarios); normalized.inventory = normalizeInventory(normalized.inventory); const rosterUnitIds = normalizedRosterUnitIds(normalized.roster); const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; @@ -1216,6 +1292,16 @@ function normalizeCampaignState(state: Partial): CampaignState { if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); } + const recordedVictoryBattleIds = new Set( + Object.entries(normalized.battleHistory) + .filter(([, settlement]) => settlement.outcome === 'victory') + .map(([battleId]) => battleId) + ); + if (normalized.firstBattleReport?.outcome === 'victory') { + recordedVictoryBattleIds.add(normalized.firstBattleReport.battleId); + } + normalized.acknowledgedVictoryRewardBattleIds = normalized.acknowledgedVictoryRewardBattleIds + .filter((battleId) => recordedVictoryBattleIds.has(battleId)); normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory( normalized.sortieOrderHistory, normalized.battleHistory,