feat: unify victory reward experience

This commit is contained in:
2026-07-18 14:33:29 +09:00
parent 1df324ecd2
commit 134a509938
6 changed files with 1385 additions and 129 deletions

View File

@@ -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<T extends Phaser.GameObjects.GameObject>(object: T) {
this.scaleLegacyCampUi(object);
this.victoryRewardObjects.push(object);
return object;
}
private track<T extends Phaser.GameObjects.GameObject>(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