feat: connect Xuzhou equipment to battle outcomes

This commit is contained in:
2026-07-27 15:39:11 +09:00
parent adf9e65668
commit 2737354e30
13 changed files with 5664 additions and 68 deletions

View File

@@ -59,8 +59,15 @@ import {
findCityStayAfterBattle,
type CityEquipmentOffer,
type CityResonanceDialogueChoice,
type CityStayDefinition
type CityStayDefinition,
type CityStayId
} from '../data/cityStays';
import {
cityEquipmentPreparationSelectionRecordId,
createCityEquipmentPreparationSnapshot,
findCanonicalCityEquipmentOffer,
resolveCityEquipmentPreparation
} from '../data/cityEquipmentPreparation';
import {
campaignPortraitKeysByUnitId,
portraitAssetEntriesForKey,
@@ -380,10 +387,25 @@ type EquipmentSwapRequest = {
type EquipmentInventoryRowView = {
itemId: string;
canEquip: boolean;
focusedCityPurchase: boolean;
background: Phaser.GameObjects.Rectangle;
actionButton: Phaser.GameObjects.Rectangle;
};
type CityEquipmentContext = {
selectionRecordId: typeof cityEquipmentPreparationSelectionRecordId;
cityStayId: CityStayId;
cityName: string;
offerId: string;
itemId: string;
itemName: string;
slot: EquipmentSlot;
unitId: string;
unitName: string;
purchaseCount: number;
inventoryPage: number;
};
type CampRosterBounds = {
x: number;
y: number;
@@ -907,6 +929,8 @@ type CampaignTimelineChapter = {
type CampSceneData = {
openSortiePrep?: boolean;
openSortieImprovement?: boolean;
openEquipmentCityStayId?: CityStayId;
openEquipmentOfferId?: string;
retryBattleId?: BattleScenarioId;
skipIntroStory?: boolean;
completedAftermathBattleId?: BattleScenarioId;
@@ -11451,6 +11475,11 @@ export class CampScene extends Phaser.Scene {
private equipmentInventoryNextButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryVisibleItemIds: string[] = [];
private equipmentInventoryRowViews: EquipmentInventoryRowView[] = [];
private openEquipmentCityStayId?: CityStayId;
private openEquipmentOfferId?: string;
private cityEquipmentContext?: CityEquipmentContext;
private cityEquipmentContextBanner?: Phaser.GameObjects.Rectangle;
private sortieCityEquipmentSummaryText?: Phaser.GameObjects.Text;
private equipmentSwapConfirmObjects: Phaser.GameObjects.GameObject[] = [];
private pendingEquipmentSwap?: EquipmentSwapRequest;
private equipmentSwapConfirmPanel?: Phaser.GameObjects.Rectangle;
@@ -11487,6 +11516,7 @@ export class CampScene extends Phaser.Scene {
private reportFormationHistoryView?: ReportFormationHistoryView;
private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState;
private tabButtons: CampTabButtonView[] = [];
private sortieCommandButton?: Phaser.GameObjects.Rectangle;
private sortiePrimaryActionButton?: CampActionButtonView;
private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle;
private sortieNextActionGuideText?: Phaser.GameObjects.Text;
@@ -11555,6 +11585,8 @@ export class CampScene extends Phaser.Scene {
}
this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep);
this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement);
this.openEquipmentCityStayId = data?.openEquipmentCityStayId;
this.openEquipmentOfferId = data?.openEquipmentOfferId;
this.retrySortieBattleId = data?.retryBattleId;
this.skipIntroStoryForSortie = Boolean(data?.skipIntroStory);
}
@@ -11621,6 +11653,9 @@ export class CampScene extends Phaser.Scene {
this.equipmentSwapConfirmPanel = undefined;
this.equipmentSwapConfirmButton = undefined;
this.equipmentSwapCancelButton = undefined;
this.cityEquipmentContext = undefined;
this.cityEquipmentContextBanner = undefined;
this.sortieCityEquipmentSummaryText = undefined;
this.reportFormationReviewObjects = [];
this.reportFormationReviewVisible = false;
this.reportFormationReviewFeedback = '';
@@ -11707,6 +11742,7 @@ export class CampScene extends Phaser.Scene {
this.selectedVisitId = pendingDirectExplorationVisit.id;
this.activeTab = 'visit';
}
this.initializeCityEquipmentContext();
soundDirector.playSoundscape({
musicKey: this.campSoundscape.music.trackKey,
ambienceKey: this.campSoundscape.ambience.trackKey,
@@ -13068,7 +13104,7 @@ export class CampScene extends Phaser.Scene {
});
const flow = this.currentSortieFlow();
const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '출진 준비';
this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => {
this.sortieCommandButton = this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => {
soundDirector.playSelect();
if (this.isFinalEpilogueFlow(flow)) {
this.startVictoryStory();
@@ -14074,6 +14110,7 @@ export class CampScene extends Phaser.Scene {
target.on('pointerout', () => setHovered(false));
target.on('pointerdown', action);
});
return bg;
}
private render() {
@@ -14137,6 +14174,7 @@ export class CampScene extends Phaser.Scene {
this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments);
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments);
this.ensureSortieFocus();
this.sortieCityEquipmentSummaryText = undefined;
const depth = 40;
const width = 1188;
@@ -16714,6 +16752,7 @@ export class CampScene extends Phaser.Scene {
const synergy = this.sortieSynergySnapshot();
const pursuit = this.sortiePursuitPairs();
const selectedOrder = this.currentSortieOrderDefinition();
const cityEquipment = this.cityEquipmentSortieStatus();
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -16729,17 +16768,69 @@ export class CampScene extends Phaser.Scene {
)
).setDepth(depth + 1);
this.renderSortieBattleUiIcon('focus', x + 22, y + 44, 22, depth + 1, 0.74);
this.trackSortie(
const secondaryLine = cityEquipment?.line ??
`${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · 추격 후보 ${pursuit.activePairs.length}조 · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}`;
const secondaryText = this.trackSortie(
this.add.text(
x + 44,
y + 35,
this.compactText(
`${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · 추격 후보 ${pursuit.activePairs.length}조 · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}`,
35
),
this.textStyle(12, reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', true)
this.compactText(secondaryLine, 35),
this.textStyle(
12,
cityEquipment
? cityEquipment.active ? '#a8ffd0' : '#ffdf7b'
: reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf',
true
)
)
).setDepth(depth + 1);
if (cityEquipment) {
this.sortieCityEquipmentSummaryText = secondaryText;
}
}
private cityEquipmentSortieStatus() {
const campaign = this.campaign;
const battleId = this.currentSortieFlow().nextBattleId;
if (!campaign?.cityEquipmentPreparation || !battleId) {
return undefined;
}
const resolved = resolveCityEquipmentPreparation(
campaign.cityEquipmentPreparation,
{
battleId,
roster: campaign.roster,
purchaseCounts: campaign.cityEquipmentPurchaseCounts
}
);
if (!resolved) {
return undefined;
}
const selected = this.selectedSortieUnitIds.includes(resolved.unit.id);
const delta = [
resolved.statDelta.attack > 0 ? `공격 +${resolved.statDelta.attack}` : '',
resolved.statDelta.defense > 0 ? `방어 +${resolved.statDelta.defense}` : '',
resolved.statDelta.strategy > 0 ? `책략 +${resolved.statDelta.strategy}` : ''
].filter(Boolean).join(' · ');
return {
selectionRecordId: resolved.snapshot.selectionRecordId,
sourceBattleId: resolved.snapshot.sourceBattleId,
targetBattleId: resolved.snapshot.targetBattleId,
cityStayId: resolved.snapshot.cityStayId,
offerId: resolved.snapshot.offerId,
itemId: resolved.item.id,
itemName: resolved.item.name,
unitId: resolved.unit.id,
unitName: resolved.unit.name,
statDelta: { ...resolved.statDelta },
unitSelected: selected,
equipmentValid: true,
active: selected,
status: selected ? 'active' as const : 'unit-not-selected' as const,
line: selected
? `${resolved.cityStay.city.name} 구입 장비 · ${resolved.unit.name} · ${resolved.item.name} · ${delta}`
: `${resolved.cityStay.city.name} 구입 장비 · ${resolved.unit.name} 미출전 · 미반영`
};
}
private renderSortieBriefing(x: number, y: number, width: number, height: number, depth: number) {
@@ -20978,6 +21069,7 @@ export class CampScene extends Phaser.Scene {
this.sortieNextActionGuideBackground = undefined;
this.sortieNextActionGuideText = undefined;
this.sortieBriefingScoutSummaryText = undefined;
this.sortieCityEquipmentSummaryText = undefined;
this.firstBattleFollowupCtaButton = undefined;
this.firstBattleFollowupCard = undefined;
this.firstBattleFollowupSummaryText = undefined;
@@ -21074,6 +21166,14 @@ export class CampScene extends Phaser.Scene {
bg.on('pointerdown', () => {
soundDirector.playSelect();
this.selectedUnitId = unit.id;
if (this.cityEquipmentContext && this.activeTab === 'equipment') {
this.cityEquipmentContext = {
...this.cityEquipmentContext,
unitId: unit.id,
unitName: unit.name
};
this.persistCityEquipmentIntentUnit(unit.id);
}
this.render();
});
const bounds = bg.getBounds();
@@ -24003,6 +24103,126 @@ export class CampScene extends Phaser.Scene {
this.track(this.add.text(x + 14, y + 66, `${informationLabel} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
}
private initializeCityEquipmentContext() {
const campaign = this.campaign;
const persistedIntent = campaign?.cityEquipmentEquipIntent;
const persistedPreparation =
campaign?.cityEquipmentPreparation;
const cityStayId =
this.openEquipmentCityStayId ??
persistedIntent?.cityStayId ??
persistedPreparation?.cityStayId;
const offerId =
this.openEquipmentOfferId ??
persistedIntent?.offerId ??
persistedPreparation?.offerId;
const canonical = findCanonicalCityEquipmentOffer(
cityStayId,
offerId
);
const cityStay = canonical?.cityStay;
const offer = canonical?.offer;
const purchaseCount = offer
? campaign?.cityEquipmentPurchaseCounts?.[offer.id] ?? 0
: 0;
if (
!campaign ||
!cityStay ||
!offer ||
purchaseCount <= 0 ||
cityStay.nextBattleId !== this.currentSortieFlow().nextBattleId
) {
return;
}
const item = canonical.item;
const allies = campaign.roster.filter((unit) => unit.faction === 'ally');
const persistedTargetId =
persistedIntent?.cityStayId === cityStay.id &&
persistedIntent.offerId === offer.id
? persistedIntent.unitId
: undefined;
const preparedTargetId =
persistedPreparation?.cityStayId === cityStay.id &&
persistedPreparation.offerId === offer.id
? persistedPreparation.unitId
: undefined;
const preferred =
allies.find(
(unit) =>
unit.id ===
(persistedTargetId ?? preparedTargetId)
) ??
(cityStay.id === 'xuzhou'
? allies.find((unit) => unit.id === 'mi-zhu')
: undefined);
const target = preferred ?? [...allies].sort((left, right) => {
const leftCurrent = getItem(left.equipment[item.slot].itemId);
const rightCurrent = getItem(right.equipment[item.slot].itemId);
const leftDelta = this.itemTotalBonus(item) - this.itemTotalBonus(leftCurrent);
const rightDelta = this.itemTotalBonus(item) - this.itemTotalBonus(rightCurrent);
return rightDelta - leftDelta || left.name.localeCompare(right.name, 'ko-KR');
})[0];
if (!target) {
return;
}
const entries = this.equipmentInventoryEntries();
const itemIndex = entries.findIndex((entry) => entry.item.id === item.id);
const inventoryPage = itemIndex >= 0
? Math.floor(itemIndex / equipmentInventoryPageSize)
: 0;
this.selectedUnitId = target.id;
this.activeTab = 'equipment';
this.equipmentInventoryPage = inventoryPage;
this.cityEquipmentContext = {
selectionRecordId: cityEquipmentPreparationSelectionRecordId,
cityStayId: cityStay.id,
cityName: cityStay.city.name,
offerId: offer.id,
itemId: item.id,
itemName: item.name,
slot: item.slot,
unitId: target.id,
unitName: target.name,
purchaseCount,
inventoryPage
};
}
private persistCityEquipmentIntentUnit(unitId: string) {
const context = this.cityEquipmentContext;
const snapshot = getCampaignState();
const intent = snapshot.cityEquipmentEquipIntent;
if (
!context ||
!intent ||
intent.cityStayId !== context.cityStayId ||
intent.offerId !== context.offerId ||
intent.unitId === unitId
) {
return;
}
const updated = structuredClone(snapshot);
updated.cityEquipmentEquipIntent = {
...intent,
unitId
};
try {
this.campaign = saveCampaignState(updated);
} catch {
try {
saveCampaignState(snapshot);
} catch {
// The in-memory snapshot is still restored by the rollback attempt.
}
this.campaign = snapshot;
this.showCampNotice(
'선택한 장수를 저장하지 못했습니다. 현재 화면에서는 계속 비교할 수 있습니다.'
);
}
}
private renderEquipmentPanel() {
const unit = this.selectedUnit();
if (!unit) {
@@ -24018,11 +24238,71 @@ export class CampScene extends Phaser.Scene {
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
this.equipmentInventoryRowViews = [];
this.cityEquipmentContextBanner = undefined;
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
this.track(this.add.text(x + 24, y + 22, '장비 관리', this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 58, '후보를 고르면 현재 장비와 수치를 비교합니다. 교체 확정 전에는 장부에 반영되지 않습니다.', this.textStyle(14, '#d4dce6')));
if (this.cityEquipmentContext) {
const context = this.cityEquipmentContext;
const target = this.campaign?.roster.find((unit) => unit.id === context.unitId);
const equipped = target?.equipment[context.slot].itemId === context.itemId;
const resolvedPreparation = this.campaign
? resolveCityEquipmentPreparation(
this.campaign.cityEquipmentPreparation,
{
battleId: this.currentSortieFlow().nextBattleId,
roster: this.campaign.roster,
purchaseCounts:
this.campaign.cityEquipmentPurchaseCounts
}
)
: undefined;
const preparationReady = Boolean(
resolvedPreparation &&
resolvedPreparation.snapshot.cityStayId === context.cityStayId &&
resolvedPreparation.snapshot.offerId === context.offerId &&
resolvedPreparation.snapshot.unitId === context.unitId
);
const bannerState = preparationReady
? {
label: '전투 준비 완료',
fill: 0x173023,
stroke: palette.green,
color: '#a8ffd0'
}
: equipped
? {
label: '장착됨 · 추가 전투 기여 없음',
fill: 0x202938,
stroke: palette.blue,
color: '#b8d8f2'
}
: {
label: '장착 비교',
fill: 0x2c2517,
stroke: palette.gold,
color: '#f2e3bf'
};
const contextBanner = this.track(this.add.rectangle(x + 18, y + 48, width - 36, 30, bannerState.fill, 0.96));
this.cityEquipmentContextBanner = contextBanner;
contextBanner.setOrigin(0);
contextBanner.setStrokeStyle(1, bannerState.stroke, 0.72);
this.track(this.add.text(
x + 34,
y + 55,
`${context.cityName} 시장에서 구입 · ${context.unitName}에게 ${context.itemName} · ${bannerState.label}`,
this.textStyle(13, bannerState.color, true)
));
this.track(this.add.text(
x + width - 28,
y + 55,
`구매 ${context.purchaseCount}회 · ${context.inventoryPage + 1}`,
this.textStyle(11, '#9fb0bf', true)
)).setOrigin(1, 0);
} else {
this.track(this.add.text(x + 24, y + 58, '후보를 고르면 현재 장비와 수치를 비교합니다. 교체 확정 전에는 장부에 반영되지 않습니다.', this.textStyle(14, '#d4dce6')));
}
this.renderEquipmentUnitSummary(unit, x + 24, y + 86, width - 48, 48);
@@ -24136,12 +24416,32 @@ export class CampScene extends Phaser.Scene {
const currentItem = getItem(unit.equipment[entry.item.slot].itemId);
const sameItem = currentItem.id === entry.item.id;
const deltaScore = this.itemTotalBonus(entry.item) - this.itemTotalBonus(currentItem);
const canEquip = !sameItem;
const rowColor = sameItem ? 0x111922 : deltaScore > 0 ? 0x172a22 : deltaScore < 0 ? 0x241b1b : 0x151f2a;
const strokeColor = sameItem ? 0x53606c : entry.item.rank === 'treasure' ? palette.gold : deltaScore > 0 ? palette.green : palette.blue;
const focusedCityPurchase = this.cityEquipmentContext?.itemId === entry.item.id;
const guidedSwapHasBattleValue = deltaScore > 0;
const canEquip =
!sameItem &&
(!focusedCityPurchase || guidedSwapHasBattleValue);
const rowColor = focusedCityPurchase
? 0x2c2517
: sameItem
? 0x111922
: deltaScore > 0
? 0x172a22
: deltaScore < 0
? 0x241b1b
: 0x151f2a;
const strokeColor = focusedCityPurchase
? palette.gold
: sameItem
? 0x53606c
: entry.item.rank === 'treasure'
? palette.gold
: deltaScore > 0
? palette.green
: palette.blue;
const bg = this.track(this.add.rectangle(x, y, width, height, rowColor, canEquip ? 0.94 : 0.72));
bg.setOrigin(0);
bg.setStrokeStyle(1, strokeColor, canEquip ? 0.58 : 0.3);
bg.setStrokeStyle(focusedCityPurchase ? 2 : 1, strokeColor, focusedCityPurchase ? 0.9 : canEquip ? 0.58 : 0.3);
const iconFrame = this.track(this.add.rectangle(x + 20, y + height / 2, 28, 28, 0x0a1017, 0.92));
iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54);
@@ -24150,13 +24450,25 @@ export class CampScene extends Phaser.Scene {
icon.setAlpha(canEquip ? 1 : 0.52);
const nameColor = sameItem ? '#87919c' : entry.item.rank === 'treasure' ? '#f4dfad' : '#f2e3bf';
this.track(this.add.text(x + 42, y + 5, `${entry.item.name} x${entry.amount}`, this.textStyle(13, nameColor, true)));
this.track(this.add.text(
x + 42,
y + 5,
`${entry.item.name} x${entry.amount}${focusedCityPurchase ? ' · 시장 구입' : ''}`,
this.textStyle(13, focusedCityPurchase ? '#ffdf7b' : nameColor, true)
));
this.track(this.add.text(x + 42, y + 22, `${equipmentSlotLabels[entry.item.slot]} · ${this.equipmentDeltaText(entry.item, currentItem)}`, this.textStyle(10, deltaScore > 0 ? '#a8ffd0' : deltaScore < 0 ? '#ff9d7d' : '#9fb0bf', true)));
this.track(this.add.text(x + 244, y + 7, this.compactText(entry.item.effects[0] ?? entry.item.description, 13), this.textStyle(10, '#c8d2dd')));
const button = this.track(this.add.rectangle(x + width - 34, y + height / 2, 52, 24, canEquip ? 0x1a2630 : 0x121922, canEquip ? 0.96 : 0.62));
button.setStrokeStyle(1, canEquip ? palette.gold : 0x53606c, canEquip ? 0.68 : 0.3);
const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '비교' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true)));
const actionLabel = canEquip
? '비교'
: sameItem
? '착용'
: focusedCityPurchase
? '개선 없음'
: '불가';
const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, actionLabel, this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true)));
buttonText.setOrigin(0.5, 0);
if (canEquip) {
@@ -24173,6 +24485,7 @@ export class CampScene extends Phaser.Scene {
this.equipmentInventoryRowViews.push({
itemId: entry.item.id,
canEquip,
focusedCityPurchase,
background: bg,
actionButton: button
});
@@ -25322,7 +25635,10 @@ export class CampScene extends Phaser.Scene {
}
private applyUnitEquipmentSwap(request: EquipmentSwapRequest) {
const campaign = this.campaign ?? getCampaignState();
const snapshot = structuredClone(
this.campaign ?? getCampaignState()
);
const campaign = structuredClone(snapshot);
const target = campaign.roster.find((unit) => unit.id === request.unitId);
if (!target) {
this.showCampNotice('장비를 바꿀 장수 장부를 찾지 못했습니다.');
@@ -25339,6 +25655,21 @@ export class CampScene extends Phaser.Scene {
this.showCampNotice(`${nextItem.name} 보유량이 없습니다.`);
return;
}
const cityEquipmentContext = this.cityEquipmentContext;
const guidedCitySwap =
cityEquipmentContext &&
request.candidateItemId === cityEquipmentContext.itemId &&
request.slot === cityEquipmentContext.slot &&
request.unitId === cityEquipmentContext.unitId;
if (
guidedCitySwap &&
this.itemTotalBonus(nextItem) <= this.itemTotalBonus(currentItem)
) {
this.showCampNotice(
`${target.name}${currentItem.name}보다 전투 수치가 좋아지지 않아 준비 장비로 기록하지 않았습니다.`
);
return;
}
campaign.inventory[nextItem.name] -= 1;
if (campaign.inventory[nextItem.name] <= 0) {
@@ -25350,12 +25681,61 @@ export class CampScene extends Phaser.Scene {
level: currentState.level,
exp: currentState.exp
};
this.syncReportUnitEquipment(campaign, target.id, target.equipment);
this.campaign = saveCampaignState(campaign);
let cityPreparationApplied = false;
if (
guidedCitySwap
) {
const purchaseNumber =
campaign.cityEquipmentPurchaseCounts?.[cityEquipmentContext.offerId] ?? 0;
if (purchaseNumber > 0) {
const preparation = createCityEquipmentPreparationSnapshot({
cityStayId: cityEquipmentContext.cityStayId,
offerId: cityEquipmentContext.offerId,
purchaseNumber,
unitId: target.id,
previousItemId: currentItem.id,
roster: campaign.roster,
purchaseCounts: campaign.cityEquipmentPurchaseCounts
});
if (preparation) {
campaign.cityEquipmentPreparation = preparation;
cityPreparationApplied = true;
if (
campaign.cityEquipmentEquipIntent?.cityStayId ===
cityEquipmentContext.cityStayId &&
campaign.cityEquipmentEquipIntent.offerId ===
cityEquipmentContext.offerId
) {
delete campaign.cityEquipmentEquipIntent;
}
}
}
}
try {
this.campaign = saveCampaignState(campaign);
} catch {
try {
saveCampaignState(snapshot);
} catch {
// The rollback call restores the prior in-memory snapshot before persistence.
}
this.campaign = snapshot;
this.report =
this.campaign.firstBattleReport ?? this.report;
this.showCampNotice(
'장비 교체를 저장하지 못해 변경을 되돌렸습니다. 잠시 후 다시 시도하세요.'
);
this.render();
return;
}
this.report = this.campaign.firstBattleReport ?? this.report;
soundDirector.playSelect();
this.showCampNotice(`${target.name} ${equipmentSlotLabels[request.slot]} 교체 · ${currentItem.name}${nextItem.name} (${this.itemBonusText(nextItem)})`);
const swapNotice = cityPreparationApplied
? `${target.name} ${equipmentSlotLabels[request.slot]} 교체 완료 · ${currentItem.name}${nextItem.name} · 교체 변화 · ${this.equipmentDeltaText(nextItem, currentItem)
.replaceAll(' / ', ' · ')
.replace(/([+-]\d+)/g, ' $1')}`
: `${target.name} ${equipmentSlotLabels[request.slot]} 교체 · ${currentItem.name}${nextItem.name} (${this.itemBonusText(nextItem)})`;
this.showCampNotice(swapNotice);
if (request.returnToSortie || this.sortieObjects.length > 0) {
this.showSortiePrep();
} else {
@@ -25376,13 +25756,6 @@ export class CampScene extends Phaser.Scene {
return this.equipmentInventoryEntries(slot).find((entry) => entry.item.id !== currentItemId)?.item;
}
private syncReportUnitEquipment(campaign: CampaignState, unitId: string, equipment: UnitData['equipment']) {
const reportUnit = campaign.firstBattleReport?.units.find((unit) => unit.id === unitId);
if (reportUnit) {
reportUnit.equipment = JSON.parse(JSON.stringify(equipment)) as UnitData['equipment'];
}
}
private statWithBonus(base: number, bonus: number) {
return bonus > 0 ? `${base}+${bonus}` : `${base}`;
}
@@ -25667,6 +26040,7 @@ export class CampScene extends Phaser.Scene {
const cityDialogueComplete = cityStay
? this.completedCampDialogues().includes(cityStay.dialogue.id)
: false;
const cityEquipmentSortieStatus = this.cityEquipmentSortieStatus();
const firstBattleCampFollowup = this.firstBattleCampFollowup();
const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory();
const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue();
@@ -25738,6 +26112,26 @@ export class CampScene extends Phaser.Scene {
cancelInteractive: Boolean(this.equipmentSwapCancelButton?.input?.enabled)
}
: null,
cityEquipmentContext: this.cityEquipmentContext
? {
...this.cityEquipmentContext,
preparationRecord: this.campaign?.cityEquipmentPreparation
? { ...this.campaign.cityEquipmentPreparation }
: null,
bannerBounds: this.sortieObjectBoundsDebug(this.cityEquipmentContextBanner),
focusedRowBounds: this.sortieObjectBoundsDebug(
this.equipmentInventoryRowViews.find((row) => row.focusedCityPurchase)?.background
),
focusedActionButtonBounds: this.sortieObjectBoundsDebug(
this.equipmentInventoryRowViews.find((row) => row.focusedCityPurchase)?.actionButton
),
activeTab: this.activeTab,
selectedUnitId: this.selectedUnitId,
currentItemId:
this.campaign?.roster.find((unit) => unit.id === this.cityEquipmentContext?.unitId)
?.equipment[this.cityEquipmentContext.slot].itemId ?? null
}
: null,
activeTab: this.activeTab,
campTabs: this.tabButtons.map((button) => ({
id: button.tab,
@@ -25753,6 +26147,10 @@ export class CampScene extends Phaser.Scene {
strokeColor: button.bg.strokeColor,
lineWidth: button.bg.lineWidth
})),
sortieCommand: {
bounds: this.sortieObjectBoundsDebug(this.sortieCommandButton),
interactive: Boolean(this.sortieCommandButton?.input?.enabled)
},
firstVictoryFollowup: firstBattleCampFollowup
? {
available: true,
@@ -26477,6 +26875,15 @@ export class CampScene extends Phaser.Scene {
bounds: this.sortieObjectBoundsDebug(this.sortieNextActionGuideBackground)
}
: null,
sortieCityEquipmentPreparation: cityEquipmentSortieStatus
? {
...cityEquipmentSortieStatus,
statDelta: { ...cityEquipmentSortieStatus.statDelta },
visible: Boolean(this.sortieCityEquipmentSummaryText?.active),
bounds: this.sortieTextBoundsDebug(this.sortieCityEquipmentSummaryText),
displayedText: this.sortieCityEquipmentSummaryText?.text ?? null
}
: null,
sortiePortraitRosterView: {
enabled: stagedSortiePrep && !this.isFirstSortiePrepSlice(),
total: portraitRosterUnits.length,
@@ -26814,6 +27221,7 @@ export class CampScene extends Phaser.Scene {
rows: this.equipmentInventoryRowViews.map((row) => ({
itemId: row.itemId,
canEquip: row.canEquip,
focusedCityPurchase: row.focusedCityPurchase,
bounds: this.sortieObjectBoundsDebug(row.background),
actionButtonBounds: this.sortieObjectBoundsDebug(row.actionButton),
interactive: Boolean(row.background.input?.enabled && row.actionButton.input?.enabled)
@@ -26976,6 +27384,14 @@ export class CampScene extends Phaser.Scene {
step: this.campaign.step,
gold: this.campaign.gold,
inventory: this.campaign.inventory,
cityEquipmentPurchaseCounts: { ...this.campaign.cityEquipmentPurchaseCounts },
cityEquipmentEquipIntent:
this.campaign.cityEquipmentEquipIntent
? { ...this.campaign.cityEquipmentEquipIntent }
: undefined,
cityEquipmentPreparation: this.campaign.cityEquipmentPreparation
? { ...this.campaign.cityEquipmentPreparation }
: null,
selectedSortieUnitIds: this.campaign.selectedSortieUnitIds,
sortieFormationAssignments: this.campaign.sortieFormationAssignments,
sortieItemAssignments: this.campaign.sortieItemAssignments,