feat: complete campaign flow and optimize battle assets

This commit is contained in:
2026-07-19 21:31:07 +09:00
parent f83296db7b
commit 31e1396ac8
211 changed files with 4219 additions and 281 deletions

View File

@@ -175,7 +175,9 @@ import {
ensureCampaignRosterUnits,
getCampaignState,
getFirstBattleReport,
getPendingCampaignVictoryRewardBattleIds,
getPendingCampaignVictoryRewardReport,
getPendingCampaignVictoryRewardCategories,
markCampaignStep,
listCampaignSaveSlots,
campaignSaveSlotCount,
@@ -195,8 +197,12 @@ import {
type CampaignSortiePerformanceSnapshot,
type CampaignSortiePresetId,
type CampaignSortieRecommendationSnapshot,
type CampaignVictoryRewardCategoryId,
type CampaignVictoryRewardReport,
type FirstBattleReport
} from '../state/campaignState';
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
import { startLazyScene } from './lazyScenes';
@@ -255,7 +261,7 @@ type CampTabButtonView = {
hovered: boolean;
};
type VictoryRewardCardId = 'gold' | 'equipment' | 'supplies' | 'reputation' | 'recruits' | 'unlocks';
type VictoryRewardCardId = CampaignVictoryRewardCategoryId;
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
@@ -11276,6 +11282,7 @@ export class CampScene extends Phaser.Scene {
private campSkinTransitionRevision = 0;
private campSkinTransitionCompletedRevision = 0;
private campSkinTransitionActive = false;
private ownedCampTextureKeys = new Set<string>();
private campSkinTransitionLast?: {
kind: 'backdrop-fade';
durationMs: number;
@@ -11310,7 +11317,7 @@ export class CampScene extends Phaser.Scene {
private saveSlotObjects: Phaser.GameObjects.GameObject[] = [];
private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = [];
private pendingSaveSlot?: number;
private pendingVictoryRewardReport?: FirstBattleReport;
private pendingVictoryRewardReport?: CampaignVictoryRewardReport;
private victoryRewardObjects: Phaser.GameObjects.GameObject[] = [];
private victoryRewardCards: VictoryRewardCardView[] = [];
private victoryRewardActions: VictoryRewardActionView[] = [];
@@ -11382,6 +11389,7 @@ export class CampScene extends Phaser.Scene {
}
init(data?: CampSceneData) {
this.ownedCampTextureKeys.clear();
this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep);
this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement);
this.retrySortieBattleId = data?.retryBattleId;
@@ -11389,6 +11397,7 @@ export class CampScene extends Phaser.Scene {
}
create() {
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures());
this.contentObjects = [];
this.campRosterPage = 0;
this.campRosterLayout = undefined;
@@ -11667,15 +11676,27 @@ export class CampScene extends Phaser.Scene {
});
if (missingCampBackdrop) {
this.ownedCampTextureKeys.add(selection.skin.textureKey);
this.load.image(selection.skin.textureKey, selection.skin.assetUrl);
}
if (missingFirstSortieArtwork) {
this.ownedCampTextureKeys.add('story-first-sortie');
this.load.image('story-first-sortie', storyFirstSortieUrl);
}
missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url));
missingPortraits.forEach(({ textureKey, url }) => {
this.ownedCampTextureKeys.add(textureKey);
this.load.image(textureKey, url);
});
this.load.start();
}
private releaseOwnedCampTextures() {
this.ownedCampTextureKeys.forEach((textureKey) => {
releaseTextureSource(this, textureKey);
});
this.ownedCampTextureKeys.clear();
}
private createFallbackReport(): FirstBattleReport {
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally');
return {
@@ -12944,6 +12965,7 @@ export class CampScene extends Phaser.Scene {
this.sortiePresetUndoState = undefined;
this.closeSortiePresetBrowserState();
this.hideCampSaveSlotPanel();
clearCampaignBattleSavesForSlot(slot);
this.campaign = saveCampaignState(getCampaignState(), slot);
if (returnToSortiePrep) {
this.showSortiePrep();
@@ -12992,15 +13014,28 @@ export class CampScene extends Phaser.Scene {
}
private showVictoryRewardAcknowledgement() {
const report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport();
let report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport();
if (!report || report.outcome !== 'victory' || this.sortieObjects.length > 0) {
return;
}
this.pendingVictoryRewardReport = report;
this.hideVictoryRewardAcknowledgement();
getPendingCampaignVictoryRewardBattleIds().forEach((battleId) => {
const passiveCategories = getPendingCampaignVictoryRewardCategories(battleId)
.filter((category) => category === 'gold' || category === 'reputation');
if (passiveCategories.length > 0) {
this.campaign = acknowledgeCampaignVictoryReward(battleId, passiveCategories);
}
});
report = getPendingCampaignVictoryRewardReport();
if (!report) {
this.pendingVictoryRewardReport = undefined;
return;
}
this.pendingVictoryRewardReport = report;
const rewards = report.campaignRewards;
const presentation = campaignVictoryRewardPresentation(report);
const pendingCategorySet = new Set(getPendingCampaignVictoryRewardCategories(report.battleId));
const categorizedRewardItems = new Set([
...(rewards?.supplies ?? []),
...(rewards?.equipment ?? []),
@@ -13097,7 +13132,7 @@ export class CampScene extends Phaser.Scene {
icon: equipmentItem ? { kind: 'item', itemId: equipmentItem.id } : { kind: 'battle-ui', iconKey: 'sword' },
actionId: 'equipment',
actionHint: '눌러서 장비 비교',
isNew: Boolean(equipmentReward)
isNew: Boolean(equipmentReward && pendingCategorySet.has('equipment'))
},
{
id: 'supplies',
@@ -13106,7 +13141,8 @@ export class CampScene extends Phaser.Scene {
accent: palette.green,
icon: { kind: 'battle-ui', iconKey: supply?.usableId ?? 'bean' },
actionId: 'supplies',
actionHint: '눌러서 보급 확인'
actionHint: '눌러서 보급 확인',
isNew: pendingCategorySet.has('supplies')
},
{
id: 'reputation',
@@ -13124,7 +13160,7 @@ export class CampScene extends Phaser.Scene {
icon: recruit ? { kind: 'portrait', unitId: recruit.unitId } : { kind: 'battle-ui', iconKey: 'leadership' },
actionId: 'sortie',
actionHint: '눌러서 편성에 배치',
isNew: Boolean(recruit)
isNew: Boolean(recruit && pendingCategorySet.has('recruits'))
},
{
id: 'unlocks',
@@ -13134,7 +13170,7 @@ export class CampScene extends Phaser.Scene {
icon: { kind: 'battle-ui', iconKey: 'terrain' },
actionId: 'sortie',
actionHint: '눌러서 다음 전장 준비',
isNew: Boolean(rewards?.unlocks.length)
isNew: Boolean(rewards?.unlocks.length && pendingCategorySet.has('unlocks'))
}
];
const cardWidth = 252;
@@ -13200,7 +13236,7 @@ export class CampScene extends Phaser.Scene {
background.setStrokeStyle(2, definition.accent, 0.78);
iconPlate.setStrokeStyle(1, definition.accent, 0.72);
});
background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!));
background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!, [definition.id]));
}
this.victoryRewardCards.push({
id: definition.id,
@@ -13260,7 +13296,10 @@ export class CampScene extends Phaser.Scene {
label.setDepth(depth + 3);
label.setInteractive({ useHandCursor: true });
const run = () => this.completeVictoryRewardAcknowledgement(definition.id);
const run = () => this.completeVictoryRewardAcknowledgement(
definition.id,
this.victoryRewardCategoriesForAction(definition.id)
);
[button, label].forEach((target) => {
target.on('pointerover', () => button.setFillStyle(hoverFill, 0.99));
target.on('pointerout', () => button.setFillStyle(restingFill, 0.99));
@@ -13270,7 +13309,10 @@ export class CampScene extends Phaser.Scene {
});
}
private completeVictoryRewardAcknowledgement(action: VictoryRewardActionId) {
private completeVictoryRewardAcknowledgement(
action: VictoryRewardActionId,
categories = this.victoryRewardCategoriesForAction(action)
) {
const report = this.pendingVictoryRewardReport;
const rewardedRecruitId = report?.campaignRewards?.recruits[0]?.unitId;
if (action === 'sortie' && rewardedRecruitId && this.sortieAllies().some((unit) => unit.id === rewardedRecruitId)) {
@@ -13279,12 +13321,7 @@ export class CampScene extends Phaser.Scene {
if (action === 'equipment' && report) {
this.focusVictoryRewardEquipment(report);
}
if (report) {
this.campaign = acknowledgeCampaignVictoryReward(report.battleId);
if (this.campaign.acknowledgedVictoryRewardBattleIds.includes(report.battleId)) {
this.pendingVictoryRewardReport = undefined;
}
}
this.acknowledgePendingVictoryRewardCategories(categories);
soundDirector.playSelect();
this.hideVictoryRewardAcknowledgement();
this.updateTabButtons();
@@ -13304,6 +13341,33 @@ export class CampScene extends Phaser.Scene {
}
}
private victoryRewardCategoriesForAction(action: VictoryRewardActionId): CampaignVictoryRewardCategoryId[] {
if (action === 'equipment') {
return ['equipment'];
}
if (action === 'supplies') {
return ['supplies'];
}
if (action === 'sortie') {
return ['recruits', 'unlocks'];
}
return [];
}
private acknowledgePendingVictoryRewardCategories(categories: readonly CampaignVictoryRewardCategoryId[]) {
if (categories.length === 0) {
return;
}
getPendingCampaignVictoryRewardBattleIds().forEach((battleId) => {
const relevantCategories = getPendingCampaignVictoryRewardCategories(battleId)
.filter((category) => categories.includes(category));
if (relevantCategories.length > 0) {
this.campaign = acknowledgeCampaignVictoryReward(battleId, relevantCategories);
}
});
this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardReport();
}
private showRequestedSortiePrep() {
if (this.openSortieImprovementOnCreate) {
this.prepareGuidedSortieImprovement();
@@ -13332,7 +13396,7 @@ export class CampScene extends Phaser.Scene {
return campSupplyByLabel.get(this.rewardInventoryLabel(label));
}
private focusVictoryRewardEquipment(report: FirstBattleReport) {
private focusVictoryRewardEquipment(report: CampaignVictoryRewardReport) {
const rewardItemIds = new Set(
(report.campaignRewards?.equipment ?? [])
.map((label) => this.itemForRewardLabel(label)?.id)
@@ -13540,6 +13604,7 @@ export class CampScene extends Phaser.Scene {
private updateTabButtons() {
const accentColor = this.campSkinSelection?.skin.accentColor ?? palette.gold;
const pendingCategories = new Set(getPendingCampaignVictoryRewardCategories());
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);
@@ -13549,7 +13614,8 @@ export class CampScene extends Phaser.Scene {
indicator.setVisible(active || hovered);
indicator.setAlpha(active ? 1 : 0.66);
newBadge?.setVisible(Boolean(
this.pendingVictoryRewardReport && (tab === 'supplies' || tab === 'equipment')
(tab === 'supplies' && pendingCategories.has('supplies')) ||
(tab === 'equipment' && pendingCategories.has('equipment'))
));
});
}
@@ -13586,6 +13652,11 @@ export class CampScene extends Phaser.Scene {
this.hideReportFormationHistory();
this.hideCampSaveSlotPanel();
this.clearContent();
if (this.activeTab === 'equipment') {
this.acknowledgePendingVictoryRewardCategories(['equipment']);
} else if (this.activeTab === 'supplies') {
this.acknowledgePendingVictoryRewardCategories(['supplies']);
}
this.visitedTabs.add(this.activeTab);
this.updateTabButtons();
this.renderUnitColumn();
@@ -13621,6 +13692,7 @@ export class CampScene extends Phaser.Scene {
private showSortiePrep() {
const wasVisible = this.sortieObjects.length > 0;
this.acknowledgePendingVictoryRewardCategories(['recruits', 'unlocks']);
this.hideReportFormationReview();
this.hideReportFormationHistory();
this.hideCampSaveSlotPanel();