feat: confirm and undo sortie swaps

This commit is contained in:
2026-07-10 20:51:05 +09:00
parent fa57f4c1dd
commit 481e870321
2 changed files with 771 additions and 49 deletions

View File

@@ -346,7 +346,7 @@ type SortieComparisonMetric = {
};
type SortieFormationComparisonPreview = {
source: 'focus' | 'hover-add' | 'hover-swap' | 'hover-blocked';
source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked';
mode: SortieUnitSynergyPreview['mode'] | 'swap';
unitId: string;
incomingUnitId: string | null;
@@ -375,6 +375,42 @@ type SortieComparisonPanelView = {
}[];
impact: Phaser.GameObjects.Text;
detail: Phaser.GameObjects.Text;
actionButton: Phaser.GameObjects.Rectangle;
actionLabel: Phaser.GameObjects.Text;
};
type SortieConfigurationSnapshot = {
selectedUnitIds: string[];
formationAssignments: SortieFormationAssignments;
itemAssignments: CampaignSortieItemAssignments;
focusedUnitId: string;
planFeedback: string;
rosterScroll: number;
};
type SortieSwapProjection = {
outgoingUnit: UnitData;
incomingUnit: UnitData;
selectedUnitIds: string[];
formationAssignments: SortieFormationAssignments;
itemAssignments: CampaignSortieItemAssignments;
incomingRole: SortieFormationRole;
};
type SortieSwapUndoState = {
outgoingUnitId: string;
outgoingUnitName: string;
incomingUnitId: string;
incomingUnitName: string;
before: SortieConfigurationSnapshot;
applied: SortieConfigurationSnapshot;
};
type SortieComparisonAction = {
kind: 'confirm-swap' | 'undo-swap';
label: string;
outgoingUnitId: string;
incomingUnitId: string;
};
type CampaignTimelineChapter = {
@@ -10824,6 +10860,8 @@ export class CampScene extends Phaser.Scene {
private sortieItemAssignments: CampaignSortieItemAssignments = {};
private sortieFocusedUnitId = 'liu-bei';
private sortieHoveredUnitId?: string;
private sortiePinnedSwapCandidateUnitId?: string;
private sortieSwapUndoState?: SortieSwapUndoState;
private sortieComparisonPanelView?: SortieComparisonPanelView;
private sortieRosterScroll = 0;
private sortiePlanFeedback = '';
@@ -10846,6 +10884,8 @@ export class CampScene extends Phaser.Scene {
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieSwapUndoState = undefined;
this.saveSlotObjects = [];
this.saveSlotConfirmObjects = [];
this.pendingSaveSlot = undefined;
@@ -12194,6 +12234,8 @@ export class CampScene extends Phaser.Scene {
private saveCampToSlot(slot: number) {
soundDirector.playSelect();
const returnToSortiePrep = this.sortieObjects.length > 0;
this.sortieSwapUndoState = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.hideCampSaveSlotPanel();
this.campaign = saveCampaignState(getCampaignState(), slot);
if (returnToSortiePrep) {
@@ -12373,7 +12415,7 @@ export class CampScene extends Phaser.Scene {
private showSortiePrep() {
const wasVisible = this.sortieObjects.length > 0;
this.hideCampSaveSlotPanel();
this.hideSortiePrep();
this.hideSortiePrep(false);
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? this.report;
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
@@ -12520,6 +12562,8 @@ export class CampScene extends Phaser.Scene {
return;
}
this.sortiePrepStep = step;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieHoveredUnitId = undefined;
this.sortieRosterScroll = 0;
soundDirector.playSelect();
this.showSortiePrep();
@@ -12765,15 +12809,26 @@ export class CampScene extends Phaser.Scene {
const cardY = cardTop + rowIndex * (cardHeight + rowGap);
const selected = this.isSortieSelected(unit.id);
const focused = this.sortieFocusedUnitId === unit.id;
const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id;
const required = this.isRequiredSortieUnit(unit.id);
const recommendation = this.sortieRecommendation(unit.id);
const availability = this.sortieUnitAvailability(unit);
const role = this.sortieFormationRole(unit);
const preview = this.sortieUnitSynergyPreview(unit);
const fill = !availability.available ? 0x11151a : selected ? 0x172a22 : recommendation ? 0x241f17 : 0x151b24;
const accentColor = focused ? palette.gold : selected ? palette.green : recommendation ? palette.gold : 0x53606c;
const restingStrokeWidth = focused ? 2 : selected ? 1.5 : 1;
const restingStrokeAlpha = focused ? 0.96 : selected ? 0.68 : recommendation ? 0.52 : 0.34;
const canPinSwap = availability.available && !selected && selectedCount >= maxUnits &&
this.isSortieSelected(this.sortieFocusedUnitId) && !this.isRequiredSortieUnit(this.sortieFocusedUnitId);
const fill = !availability.available
? 0x11151a
: pinnedSwapCandidate
? 0x1d2731
: selected
? 0x172a22
: recommendation
? 0x241f17
: 0x151b24;
const accentColor = focused || pinnedSwapCandidate ? palette.gold : selected ? palette.green : recommendation ? palette.gold : 0x53606c;
const restingStrokeWidth = focused || pinnedSwapCandidate ? 2 : selected ? 1.5 : 1;
const restingStrokeAlpha = focused || pinnedSwapCandidate ? 0.96 : selected ? 0.68 : recommendation ? 0.52 : 0.34;
const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, fill, !availability.available ? 0.62 : 0.96));
card.setOrigin(0);
card.setDepth(depth + 1);
@@ -12781,6 +12836,16 @@ export class CampScene extends Phaser.Scene {
card.setInteractive({ useHandCursor: true });
card.on('wheel', handleRosterWheel);
card.on('pointerdown', () => {
const comparisonPreview = this.sortieFormationComparisonPreview();
if (
comparisonPreview?.mode === 'swap' &&
comparisonPreview.incomingUnitId === unit.id &&
(comparisonPreview.source === 'hover-swap' || comparisonPreview.source === 'pinned-swap')
) {
this.pinSortieSwapCandidate(unit.id);
return;
}
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieFocusedUnitId = unit.id;
soundDirector.playSelect();
if (!availability.available) {
@@ -12789,7 +12854,7 @@ export class CampScene extends Phaser.Scene {
this.showSortiePrep();
});
const accent = this.trackSortie(this.add.rectangle(cardX + 3, cardY + 3, 4, cardHeight - 6, accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34));
const accent = this.trackSortie(this.add.rectangle(cardX + 3, cardY + 3, 4, cardHeight - 6, accentColor, focused || selected || pinnedSwapCandidate ? 0.94 : recommendation ? 0.72 : 0.34));
accent.setOrigin(0);
accent.setDepth(depth + 2);
const portraitView = this.renderFirstSortiePortrait(
@@ -12802,6 +12867,9 @@ export class CampScene extends Phaser.Scene {
false,
accentColor
);
if (pinnedSwapCandidate && portraitView.image) {
portraitView.image.setDisplaySize(60, 60);
}
card.on('pointerover', () => {
this.sortieHoveredUnitId = unit.id;
this.refreshCampaignSortieComparisonPanel();
@@ -12818,14 +12886,22 @@ export class CampScene extends Phaser.Scene {
this.sortieHoveredUnitId = undefined;
}
this.refreshCampaignSortieComparisonPanel();
if (this.sortiePinnedSwapCandidateUnitId === unit.id) {
card.setFillStyle(0x1d2731, 0.98);
card.setStrokeStyle(2, palette.gold, 0.98);
accent.setFillStyle(palette.gold, 0.98);
portraitView.frame.setStrokeStyle(2, palette.gold, 0.98);
portraitView.image?.setDisplaySize(60, 60);
return;
}
card.setFillStyle(fill, !availability.available ? 0.62 : 0.96);
card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha);
accent.setFillStyle(accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34);
portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42);
portraitView.image?.setDisplaySize(56, 56);
});
const statusLabel = required ? '필수' : selected ? '출전' : recommendation ? '추천' : '대기';
const statusColor = required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
const statusLabel = required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기';
const statusColor = required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 8, statusLabel, this.textStyle(9, statusColor, true)));
status.setOrigin(1, 0);
status.setDepth(depth + 2);
@@ -12849,14 +12925,18 @@ export class CampScene extends Phaser.Scene {
const canToggle = availability.available && !(selected && required);
this.renderFirstSortieInlineButton(
required ? '필수 출전' : selected ? '출전 해제' : '출전 등록',
required ? '필수 출전' : selected ? '출전 해제' : pinnedSwapCandidate ? '후보 고정' : canPinSwap ? '교체 비교' : '출전 등록',
cardX + cardWidth - 80,
cardY + cardHeight - 25,
70,
20,
canToggle,
selected,
selected || pinnedSwapCandidate,
() => {
if (canPinSwap || pinnedSwapCandidate) {
this.pinSortieSwapCandidate(unit.id);
return;
}
this.sortieFocusedUnitId = unit.id;
this.toggleSortieUnit(unit.id);
},
@@ -12876,6 +12956,31 @@ export class CampScene extends Phaser.Scene {
summary.setDepth(depth + 1);
const headline = this.trackSortie(this.add.text(x + 18, y + 31, '', this.textStyle(11, '#c8d2dd', true)));
headline.setDepth(depth + 1);
const actionButton = this.trackSortie(this.add.rectangle(x + width - 108, y + 27, 90, 20, 0x263a2d, 0.98));
actionButton.setOrigin(0);
actionButton.setDepth(depth + 2);
actionButton.setStrokeStyle(1, palette.green, 0.78);
actionButton.setVisible(false);
actionButton.disableInteractive();
actionButton.on('pointerover', () => {
if (actionButton.visible) {
actionButton.setFillStyle(0x365044, 1).setStrokeStyle(1, palette.gold, 0.96);
}
});
actionButton.on('pointerout', () => {
const action = this.sortieComparisonAction();
if (actionButton.visible && action) {
const undo = action.kind === 'undo-swap';
actionButton
.setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98)
.setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78);
}
});
actionButton.on('pointerdown', () => this.handleSortieComparisonAction());
const actionLabel = this.trackSortie(this.add.text(x + width - 63, y + 37, '', this.textStyle(10, '#f2e3bf', true)));
actionLabel.setOrigin(0.5);
actionLabel.setDepth(depth + 3);
actionLabel.setVisible(false);
const metricGap = 6;
const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4);
@@ -12905,7 +13010,9 @@ export class CampScene extends Phaser.Scene {
headline,
metrics,
impact,
detail
detail,
actionButton,
actionLabel
};
this.refreshCampaignSortieComparisonPanel();
}
@@ -12917,11 +13024,14 @@ export class CampScene extends Phaser.Scene {
}
const preview = this.sortieFormationComparisonPreview();
const action = this.sortieComparisonAction(preview);
this.refreshSortieComparisonActionButton(view, action);
const isUndoAction = action?.kind === 'undo-swap';
const current = preview?.current ?? this.sortieSynergySnapshot();
const projected = preview?.projected ?? current;
const showMetricTransition = Boolean(preview?.projected);
const comparison = preview?.comparison;
const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap';
const projected = isUndoAction ? current : preview?.projected ?? current;
const showMetricTransition = !isUndoAction && Boolean(preview?.projected);
const comparison = isUndoAction ? undefined : preview?.comparison;
const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap';
const lostRole = Boolean(comparison?.lostRoles.length);
const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length;
view.background.setStrokeStyle(
@@ -12943,7 +13053,11 @@ export class CampScene extends Phaser.Scene {
return;
}
const title = preview.source === 'hover-swap'
const title = isUndoAction && this.sortieSwapUndoState
? `교체 완료 · ${this.sortieSwapUndoState.outgoingUnitName}${this.sortieSwapUndoState.incomingUnitName}`
: preview.source === 'pinned-swap'
? `교체안 고정 · ${preview.outgoingUnitName}${preview.incomingUnitName}`
: preview.source === 'hover-swap'
? `가상 교체 · ${preview.outgoingUnitName}${preview.incomingUnitName}`
: preview.source === 'hover-add'
? `가상 합류 · ${preview.incomingUnitName}`
@@ -12955,7 +13069,7 @@ export class CampScene extends Phaser.Scene {
38
)
);
const headlineColor = preview.source === 'hover-swap' || preview.mode === 'add'
const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || preview.mode === 'add'
? '#a8ffd0'
: preview.mode === 'remove'
? '#ff9d7d'
@@ -12964,7 +13078,10 @@ export class CampScene extends Phaser.Scene {
: preview.mode === 'blocked'
? '#87919c'
: '#d4dce6';
view.headline.setText(this.compactText(preview.headline, 64)).setColor(headlineColor);
const headline = isUndoAction && this.sortieSwapUndoState
? `${this.sortieSwapUndoState.incomingUnitName} 편성 완료 · 장비·보급 재점검`
: preview.headline;
view.headline.setText(this.compactText(headline, action ? 44 : 64)).setColor(headlineColor);
view.metrics.forEach((metricView, index) => {
const metric = preview.metrics[index];
@@ -12986,6 +13103,16 @@ export class CampScene extends Phaser.Scene {
.setColor(color);
});
if (isUndoAction && this.sortieSwapUndoState) {
view.impact
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`)
.setColor('#a8ffd0');
view.detail
.setText('장비·보급은 자동 이전하지 않았습니다. 직전 교체는 한 번 되돌릴 수 있습니다.')
.setColor('#ffdf7b');
return;
}
if (!comparison) {
view.impact
.setText(this.compactText(preview.detail, 68))
@@ -13032,6 +13159,65 @@ export class CampScene extends Phaser.Scene {
.setColor(isHoverPreview ? '#ffdf7b' : '#9fb0bf');
}
private refreshSortieComparisonActionButton(view: SortieComparisonPanelView, action?: SortieComparisonAction) {
const visible = Boolean(action);
view.actionButton.setVisible(visible);
view.actionLabel.setVisible(visible);
if (!action) {
view.actionButton.disableInteractive();
view.actionLabel.setText('');
return;
}
const undo = action.kind === 'undo-swap';
view.actionButton
.setInteractive({ useHandCursor: true })
.setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98)
.setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78);
view.actionLabel.setText(action.label).setColor(undo ? '#cfe8ff' : '#e6ffd5');
}
private sortieComparisonAction(
preview = this.sortieFormationComparisonPreview()
): SortieComparisonAction | undefined {
if (
preview?.source === 'pinned-swap' &&
preview.mode === 'swap' &&
preview.outgoingUnitId &&
preview.incomingUnitId &&
this.sortieSwapProjection(preview.outgoingUnitId, preview.incomingUnitId)
) {
return {
kind: 'confirm-swap',
label: '교체 확정',
outgoingUnitId: preview.outgoingUnitId,
incomingUnitId: preview.incomingUnitId
};
}
const undo = this.sortieSwapUndoState;
if (undo && this.sortiePersistedConfigurationMatches(undo.applied)) {
return {
kind: 'undo-swap',
label: '되돌리기',
outgoingUnitId: undo.outgoingUnitId,
incomingUnitId: undo.incomingUnitId
};
}
return undefined;
}
private handleSortieComparisonAction() {
const action = this.sortieComparisonAction();
if (action?.kind === 'confirm-swap') {
this.confirmPinnedSortieSwap();
return;
}
if (action?.kind === 'undo-swap') {
this.undoLastSortieSwap();
}
}
private renderFirstSortieRoleDiagram(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
@@ -14142,44 +14328,49 @@ export class CampScene extends Phaser.Scene {
const hovered = this.sortieHoveredUnitId
? this.sortieRosterUnits().find((unit) => unit.id === this.sortieHoveredUnitId)
: undefined;
const pinned = this.sortiePinnedSwapCandidateUnitId
? this.sortieRosterUnits().find((unit) => unit.id === this.sortiePinnedSwapCandidateUnitId)
: undefined;
const candidate = hovered ?? pinned;
const pinnedCandidate = candidate?.id === pinned?.id;
if (hovered && hovered.id !== focused?.id && !selectedIds.has(hovered.id)) {
const availability = this.sortieUnitAvailability(hovered, scenario);
if (candidate && candidate.id !== focused?.id && !selectedIds.has(candidate.id)) {
const availability = this.sortieUnitAvailability(candidate, scenario);
if (!availability.available) {
return {
source: 'hover-blocked',
mode: 'blocked',
unitId: hovered.id,
incomingUnitId: hovered.id,
incomingUnitName: hovered.name,
unitId: candidate.id,
incomingUnitId: candidate.id,
incomingUnitName: candidate.name,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds: selectedUnitIds,
headline: `${hovered.name} 편성 불가 · ${availability.reason}`,
headline: `${candidate.name} 편성 불가 · ${availability.reason}`,
detail: '현재 출전 명단은 유지됩니다.',
metrics: this.sortieComparisonMetrics(hovered, hovered, scenario),
metrics: this.sortieComparisonMetrics(candidate, candidate, scenario),
current
};
}
if (selectedUnitIds.length < this.sortieMaxUnits(scenario)) {
const projectedSelectedUnitIds = [...selectedUnitIds, hovered.id];
const projectedSelectedUnitIds = [...selectedUnitIds, candidate.id];
const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario);
const comparison = compareSortieSynergy(current, projected);
return {
source: 'hover-add',
mode: 'add',
unitId: hovered.id,
incomingUnitId: hovered.id,
incomingUnitName: hovered.name,
unitId: candidate.id,
incomingUnitId: candidate.id,
incomingUnitName: candidate.name,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds,
headline: `IN ${hovered.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario))} 합류`,
headline: `IN ${candidate.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(candidate, scenario))} 합류`,
detail: `빈 출전 슬롯에 합류했을 때의 가상 결과입니다.`,
metrics: this.sortieComparisonMetrics(undefined, hovered, scenario),
metrics: this.sortieComparisonMetrics(undefined, candidate, scenario),
current,
projected,
comparison
@@ -14193,38 +14384,44 @@ export class CampScene extends Phaser.Scene {
return {
source: 'hover-blocked',
mode: 'waiting',
unitId: hovered.id,
incomingUnitId: hovered.id,
incomingUnitName: hovered.name,
unitId: candidate.id,
incomingUnitId: candidate.id,
incomingUnitName: candidate.name,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds: selectedUnitIds,
headline: `교체 기준 필요 · 출전 중인 비필수 무장을 먼저 클릭하세요.`,
detail: `${hovered.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`,
metrics: this.sortieComparisonMetrics(hovered, hovered, scenario),
detail: `${candidate.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`,
metrics: this.sortieComparisonMetrics(candidate, candidate, scenario),
current
};
}
const projectedSelectedUnitIds = selectedUnitIds.map((unitId) => unitId === outgoing.id ? hovered.id : unitId);
const projection = this.sortieSwapProjection(outgoing.id, candidate.id, scenario);
if (!projection) {
return undefined;
}
const projectedSelectedUnitIds = projection.selectedUnitIds;
const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario);
const comparison = compareSortieSynergy(current, projected);
const outgoingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(outgoing, scenario));
const incomingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario));
const incomingRole = this.sortieFormationRoleLabel(projection.incomingRole);
return {
source: 'hover-swap',
source: pinnedCandidate ? 'pinned-swap' : 'hover-swap',
mode: 'swap',
unitId: hovered.id,
incomingUnitId: hovered.id,
incomingUnitName: hovered.name,
unitId: candidate.id,
incomingUnitId: candidate.id,
incomingUnitName: candidate.name,
outgoingUnitId: outgoing.id,
outgoingUnitName: outgoing.name,
selectedUnitIds,
projectedSelectedUnitIds,
headline: `OUT ${outgoing.name} ${outgoingRole} · IN ${hovered.name} ${incomingRole}`,
detail: `${outgoing.name} 대신 ${hovered.name}을 투입했을 때의 가상 결과입니다.`,
metrics: this.sortieComparisonMetrics(outgoing, hovered, scenario),
headline: `OUT ${outgoing.name} ${outgoingRole} · IN ${candidate.name} ${incomingRole}`,
detail: pinnedCandidate
? `${outgoing.name} 대신 ${candidate.name}을 투입하는 교체안을 고정했습니다.`
: `${outgoing.name} 대신 ${candidate.name}을 투입했을 때의 가상 결과입니다.`,
metrics: this.sortieComparisonMetrics(outgoing, candidate, scenario),
current,
projected,
comparison
@@ -14261,6 +14458,192 @@ export class CampScene extends Phaser.Scene {
};
}
private sortieSwapProjection(
outgoingUnitId: string,
incomingUnitId: string,
scenario = this.nextSortieScenario()
): SortieSwapProjection | undefined {
if (outgoingUnitId === incomingUnitId) {
return undefined;
}
const roster = this.sortieRosterUnits();
const outgoingUnit = roster.find((unit) => unit.id === outgoingUnitId);
const incomingUnit = roster.find((unit) => unit.id === incomingUnitId);
const selectedIds = new Set(this.selectedSortieUnitIds);
if (
!outgoingUnit ||
!incomingUnit ||
!selectedIds.has(outgoingUnitId) ||
selectedIds.has(incomingUnitId) ||
this.isRequiredSortieUnit(outgoingUnitId, scenario) ||
this.selectedSortieUnitIds.length !== this.sortieMaxUnits(scenario) ||
!this.sortieUnitAvailability(incomingUnit, scenario).available
) {
return undefined;
}
const replacedIds = this.selectedSortieUnitIds.map((unitId) => unitId === outgoingUnitId ? incomingUnitId : unitId);
const selectedUnitIds = this.normalizedSortieUnitIds(replacedIds);
const requiredIds = this.requiredSortieUnitIdsFor(scenario);
if (
selectedUnitIds.length !== this.selectedSortieUnitIds.length ||
new Set(selectedUnitIds).size !== selectedUnitIds.length ||
selectedUnitIds.includes(outgoingUnitId) ||
!selectedUnitIds.includes(incomingUnitId) ||
[...requiredIds].some((unitId) => roster.some((unit) => unit.id === unitId) && !selectedUnitIds.includes(unitId))
) {
return undefined;
}
const incomingRole = this.sortieFormationRole(incomingUnit, scenario);
const formationAssignments = { ...this.sortieFormationAssignments };
delete formationAssignments[outgoingUnitId];
delete formationAssignments[incomingUnitId];
formationAssignments[incomingUnitId] = incomingRole;
const itemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments);
delete itemAssignments[outgoingUnitId];
delete itemAssignments[incomingUnitId];
return {
outgoingUnit,
incomingUnit,
selectedUnitIds,
formationAssignments,
itemAssignments,
incomingRole
};
}
private pinSortieSwapCandidate(incomingUnitId: string) {
const projection = this.sortieSwapProjection(this.sortieFocusedUnitId, incomingUnitId);
if (!projection) {
this.sortiePinnedSwapCandidateUnitId = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('교체 기준과 후보 상태를 다시 확인하십시오.');
return;
}
this.sortiePinnedSwapCandidateUnitId = incomingUnitId;
this.sortieHoveredUnitId = undefined;
soundDirector.playSelect();
this.showSortiePrep();
}
private captureSortieConfiguration(): SortieConfigurationSnapshot {
return {
selectedUnitIds: [...this.selectedSortieUnitIds],
formationAssignments: { ...this.sortieFormationAssignments },
itemAssignments: this.cloneSortieItemAssignments(this.sortieItemAssignments),
focusedUnitId: this.sortieFocusedUnitId,
planFeedback: this.sortiePlanFeedback,
rosterScroll: this.sortieRosterScroll
};
}
private sortiePersistedConfigurationKey(
selectedUnitIds: readonly string[],
formationAssignments: SortieFormationAssignments,
itemAssignments: CampaignSortieItemAssignments
) {
return JSON.stringify({
selectedUnitIds,
formationAssignments: Object.entries(formationAssignments).sort(([left], [right]) => left.localeCompare(right)),
itemAssignments: Object.entries(itemAssignments)
.sort(([left], [right]) => left.localeCompare(right))
.map(([unitId, stocks]) => [unitId, Object.entries(stocks).sort(([left], [right]) => left.localeCompare(right))])
});
}
private sortiePersistedConfigurationMatches(snapshot: SortieConfigurationSnapshot) {
const expected = this.sortiePersistedConfigurationKey(
snapshot.selectedUnitIds,
snapshot.formationAssignments,
snapshot.itemAssignments
);
const runtime = this.sortiePersistedConfigurationKey(
this.selectedSortieUnitIds,
this.sortieFormationAssignments,
this.sortieItemAssignments
);
const campaign = this.campaign ?? getCampaignState();
const saved = this.sortiePersistedConfigurationKey(
campaign.selectedSortieUnitIds,
campaign.sortieFormationAssignments,
campaign.sortieItemAssignments
);
return runtime === expected && saved === expected;
}
private confirmPinnedSortieSwap() {
const preview = this.sortieFormationComparisonPreview();
if (
preview?.source !== 'pinned-swap' ||
preview.mode !== 'swap' ||
!preview.outgoingUnitId ||
!preview.incomingUnitId
) {
this.sortiePinnedSwapCandidateUnitId = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('고정된 교체안이 없습니다. 후보 초상 카드를 먼저 클릭하십시오.');
return;
}
const projection = this.sortieSwapProjection(preview.outgoingUnitId, preview.incomingUnitId);
if (!projection || JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(preview.projectedSelectedUnitIds)) {
this.sortiePinnedSwapCandidateUnitId = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('교체 조건이 달라졌습니다. 편성 상태를 다시 확인하십시오.');
return;
}
const before = this.captureSortieConfiguration();
this.sortieSwapUndoState = undefined;
this.selectedSortieUnitIds = [...projection.selectedUnitIds];
this.sortieFormationAssignments = { ...projection.formationAssignments };
this.sortieItemAssignments = this.cloneSortieItemAssignments(projection.itemAssignments);
this.sortieFocusedUnitId = projection.incomingUnit.id;
this.sortieHoveredUnitId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieRosterScroll = 0;
this.sortiePlanFeedback = `${projection.outgoingUnit.name}${projection.incomingUnit.name} 교체 완료 · 장비·보급 재점검`;
this.persistSortieSelection();
this.sortieSwapUndoState = {
outgoingUnitId: projection.outgoingUnit.id,
outgoingUnitName: projection.outgoingUnit.name,
incomingUnitId: projection.incomingUnit.id,
incomingUnitName: projection.incomingUnit.name,
before,
applied: this.captureSortieConfiguration()
};
soundDirector.playSelect();
this.showSortiePrep();
this.showCampNotice(`${projection.incomingUnit.name} 교체 완료 · 장비·보급은 자동 이전하지 않았습니다.`);
}
private undoLastSortieSwap() {
const undo = this.sortieSwapUndoState;
if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) {
this.sortieSwapUndoState = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('이후 편성이 변경되어 직전 교체를 되돌릴 수 없습니다.');
return;
}
this.sortieSwapUndoState = undefined;
this.selectedSortieUnitIds = [...undo.before.selectedUnitIds];
this.sortieFormationAssignments = { ...undo.before.formationAssignments };
this.sortieItemAssignments = this.cloneSortieItemAssignments(undo.before.itemAssignments);
this.sortieFocusedUnitId = undo.before.focusedUnitId;
this.sortiePlanFeedback = undo.before.planFeedback;
this.sortieRosterScroll = undo.before.rosterScroll;
this.sortieHoveredUnitId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.persistSortieSelection();
soundDirector.playSelect();
this.showSortiePrep();
this.showCampNotice(`${undo.outgoingUnitName}${undo.incomingUnitName} 교체를 되돌렸습니다.`);
}
private sortieComparisonMetrics(
currentUnit: UnitData | undefined,
projectedUnit: UnitData | undefined,
@@ -15393,10 +15776,13 @@ export class CampScene extends Phaser.Scene {
return flow.campaignStep === 'ending-complete' && !flow.nextBattleId;
}
private hideSortiePrep() {
private hideSortiePrep(clearPinnedSwap = true) {
this.sortieObjects.forEach((object) => object.destroy());
this.sortieObjects = [];
this.sortieHoveredUnitId = undefined;
if (clearPinnedSwap) {
this.sortiePinnedSwapCandidateUnitId = undefined;
}
this.sortieComparisonPanelView = undefined;
}
@@ -16915,6 +17301,8 @@ export class CampScene extends Phaser.Scene {
}
private persistSortieSelection() {
this.sortieSwapUndoState = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
const campaign = this.campaign ?? getCampaignState();
this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.sortieFormationAssignments);
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.sortieItemAssignments);
@@ -17166,6 +17554,8 @@ export class CampScene extends Phaser.Scene {
const portraitRosterVisibleCount = 8;
const portraitRosterMaxScroll = Math.max(0, portraitRosterUnits.length - portraitRosterVisibleCount);
const portraitRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, portraitRosterMaxScroll);
const sortieComparisonPreview = this.sortieFormationComparisonPreview();
const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview);
return {
scene: this.scene.key,
activeTab: this.activeTab,
@@ -17217,7 +17607,16 @@ export class CampScene extends Phaser.Scene {
sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(),
sortieFocusedUnitId: this.sortieFocusedUnitId,
sortieHoveredUnitId: this.sortieHoveredUnitId ?? null,
sortieComparisonPreview: this.sortieFormationComparisonPreview() ?? null,
sortiePinnedSwapCandidateUnitId: this.sortiePinnedSwapCandidateUnitId ?? null,
sortieComparisonPreview: sortieComparisonPreview ?? null,
sortieComparisonAction: sortieComparisonAction ? { ...sortieComparisonAction, enabled: true } : null,
sortieSwapUndo: this.sortieSwapUndoState
? {
available: sortieComparisonAction?.kind === 'undo-swap',
outgoingUnitId: this.sortieSwapUndoState.outgoingUnitId,
incomingUnitId: this.sortieSwapUndoState.incomingUnitId
}
: null,
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
sortiePlanFeedback: this.sortiePlanFeedback,
sortieChecklist,