feat: preview sortie lineup swaps

This commit is contained in:
2026-07-10 20:09:56 +09:00
parent 5bc2852882
commit fa57f4c1dd
2 changed files with 453 additions and 37 deletions

View File

@@ -337,6 +337,46 @@ type SortieUnitSynergyPreview = {
comparison?: SortieSynergyComparison;
};
type SortieComparisonMetric = {
key: 'attack' | 'intelligence' | 'move' | 'terrain';
label: string;
current: number;
projected: number;
delta: number;
};
type SortieFormationComparisonPreview = {
source: 'focus' | 'hover-add' | 'hover-swap' | 'hover-blocked';
mode: SortieUnitSynergyPreview['mode'] | 'swap';
unitId: string;
incomingUnitId: string | null;
incomingUnitName: string | null;
outgoingUnitId: string | null;
outgoingUnitName: string | null;
selectedUnitIds: string[];
projectedSelectedUnitIds: string[];
headline: string;
detail: string;
metrics: SortieComparisonMetric[];
current: SortieSynergySnapshot;
projected?: SortieSynergySnapshot;
comparison?: SortieSynergyComparison;
};
type SortieComparisonPanelView = {
background: Phaser.GameObjects.Rectangle;
title: Phaser.GameObjects.Text;
summary: Phaser.GameObjects.Text;
headline: Phaser.GameObjects.Text;
metrics: {
background: Phaser.GameObjects.Rectangle;
label: Phaser.GameObjects.Text;
value: Phaser.GameObjects.Text;
}[];
impact: Phaser.GameObjects.Text;
detail: Phaser.GameObjects.Text;
};
type CampaignTimelineChapter = {
id: string;
title: string;
@@ -10784,6 +10824,7 @@ export class CampScene extends Phaser.Scene {
private sortieItemAssignments: CampaignSortieItemAssignments = {};
private sortieFocusedUnitId = 'liu-bei';
private sortieHoveredUnitId?: string;
private sortieComparisonPanelView?: SortieComparisonPanelView;
private sortieRosterScroll = 0;
private sortiePlanFeedback = '';
private sortiePrepStep: SortiePrepStep = 'briefing';
@@ -10804,6 +10845,7 @@ export class CampScene extends Phaser.Scene {
this.contentObjects = [];
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
this.saveSlotObjects = [];
this.saveSlotConfirmObjects = [];
this.pendingSaveSlot = undefined;
@@ -12762,6 +12804,7 @@ export class CampScene extends Phaser.Scene {
);
card.on('pointerover', () => {
this.sortieHoveredUnitId = unit.id;
this.refreshCampaignSortieComparisonPanel();
card.setFillStyle(!availability.available ? 0x15191e : selected ? 0x20362a : 0x1d2731, 0.98);
card.setStrokeStyle(2, palette.gold, 0.98);
accent.setFillStyle(palette.gold, 0.98);
@@ -12774,6 +12817,7 @@ export class CampScene extends Phaser.Scene {
if (this.sortieHoveredUnitId === unit.id) {
this.sortieHoveredUnitId = undefined;
}
this.refreshCampaignSortieComparisonPanel();
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);
@@ -12822,46 +12866,170 @@ export class CampScene extends Phaser.Scene {
}
private renderCampaignSortieComparisonPanel(x: number, y: number, width: number, height: number, depth: number) {
const focused = this.sortieFocusedUnit();
const synergy = this.sortieSynergySnapshot();
const plan = this.sortiePlanSummary();
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.96));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, synergy.coveredRoleCount === 3 ? palette.green : palette.gold, 0.58);
this.trackSortie(this.add.text(x + 18, y + 12, focused ? `편성 변화 · ${focused.name}` : '편성 변화', this.textStyle(16, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(
x + width - 18,
y + 14,
`공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3 · 지형 ${synergy.terrainGrade}`,
this.textStyle(10, '#d8b15f', true)
)
).setOrigin(1, 0).setDepth(depth + 1);
const title = this.trackSortie(this.add.text(x + 18, y + 9, '편성 변화', this.textStyle(15, '#f2e3bf', true)));
title.setDepth(depth + 1);
const summary = this.trackSortie(this.add.text(x + width - 18, y + 11, '', this.textStyle(9, '#d8b15f', true)));
summary.setOrigin(1, 0);
summary.setDepth(depth + 1);
const headline = this.trackSortie(this.add.text(x + 18, y + 31, '', this.textStyle(11, '#c8d2dd', true)));
headline.setDepth(depth + 1);
if (!focused) {
this.trackSortie(this.add.text(x + 18, y + 44, '왼쪽 초상 카드를 선택하면 합류·해제 전후가 표시됩니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
const metricGap = 6;
const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4);
const metricLabels: SortieComparisonMetric['label'][] = ['공격', '지력', '기동', '지형'];
const metrics = metricLabels.map((label, index) => {
const metricX = x + 18 + index * (metricWidth + metricGap);
const background = this.trackSortie(this.add.rectangle(metricX, y + 51, metricWidth, 32, 0x111922, 0.9));
background.setOrigin(0);
background.setDepth(depth + 1);
background.setStrokeStyle(1, 0x53606c, 0.34);
const labelText = this.trackSortie(this.add.text(metricX + 7, y + 55, label, this.textStyle(8, '#9fb0bf', true)));
labelText.setDepth(depth + 2);
const value = this.trackSortie(this.add.text(metricX + metricWidth - 7, y + 65, '-', this.textStyle(10, '#c8d2dd', true)));
value.setOrigin(1, 0);
value.setDepth(depth + 2);
return { background, label: labelText, value };
});
const impact = this.trackSortie(this.add.text(x + 18, y + 89, '', this.textStyle(10, '#d4dce6', true)));
impact.setDepth(depth + 1);
const detail = this.trackSortie(this.add.text(x + 18, y + 106, '', this.textStyle(9, '#9fb0bf')));
detail.setDepth(depth + 1);
this.sortieComparisonPanelView = {
background: bg,
title,
summary,
headline,
metrics,
impact,
detail
};
this.refreshCampaignSortieComparisonPanel();
}
private refreshCampaignSortieComparisonPanel() {
const view = this.sortieComparisonPanelView;
if (!view) {
return;
}
const preview = this.sortieUnitSynergyPreview(focused);
const previewColor = preview.mode === 'remove'
? '#ff9d7d'
: preview.mode === 'add' || preview.mode === 'current'
? '#a8ffd0'
const preview = this.sortieFormationComparisonPreview();
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 lostRole = Boolean(comparison?.lostRoles.length);
const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length;
view.background.setStrokeStyle(
lostRole ? 2 : 1,
lostRole ? palette.red : isHoverPreview ? palette.gold : completeFormation ? palette.green : palette.gold,
lostRole ? 0.78 : isHoverPreview ? 0.72 : 0.58
);
if (!preview) {
view.title.setText('편성 변화');
view.summary.setText(`공명 ${current.activeBondCount} · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`);
view.headline.setText('출전 무장을 클릭해 교체 기준으로 고정하십시오.').setColor('#9fb0bf');
view.metrics.forEach((metric) => {
metric.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34);
metric.value.setText('-').setColor('#77818c');
});
view.impact.setText('후보 초상에 마우스를 올리면 역할·공명 변화가 표시됩니다.').setColor('#d4dce6');
view.detail.setText('미리보기는 실제 출전 명단과 보급을 변경하지 않습니다.').setColor('#9fb0bf');
return;
}
const title = preview.source === 'hover-swap'
? `가상 교체 · ${preview.outgoingUnitName}${preview.incomingUnitName}`
: preview.source === 'hover-add'
? `가상 합류 · ${preview.incomingUnitName}`
: `편성 변화 · ${this.unitName(preview.unitId)}`;
view.title.setText(this.compactText(title, 28));
view.summary.setText(
this.compactText(
`공명 ${current.activeBondCount}${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`,
38
)
);
const headlineColor = preview.source === 'hover-swap' || preview.mode === 'add'
? '#a8ffd0'
: preview.mode === 'remove'
? '#ff9d7d'
: preview.mode === 'waiting'
? '#ffdf7b'
: '#87919c';
this.trackSortie(this.add.text(x + 18, y + 39, this.compactText(preview.headline, 54), this.textStyle(12, previewColor, true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 62, this.compactText(preview.detail, 66), this.textStyle(10, '#c8d2dd'))).setDepth(depth + 1);
const feedback = this.sortiePlanFeedback || plan.warningLine;
this.trackSortie(
this.add.text(
x + 18,
y + height - 27,
this.compactText(feedback, 64),
this.textStyle(10, this.sortiePlanFeedback ? '#a8ffd0' : plan.warnings.length > 0 ? '#ffdf7b' : '#9fb0bf', true)
: preview.mode === 'blocked'
? '#87919c'
: '#d4dce6';
view.headline.setText(this.compactText(preview.headline, 64)).setColor(headlineColor);
view.metrics.forEach((metricView, index) => {
const metric = preview.metrics[index];
if (!metric) {
metricView.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34);
metricView.value.setText('-').setColor('#77818c');
return;
}
const positive = metric.delta > 0;
const negative = metric.delta < 0;
const tone = positive ? palette.green : negative ? palette.red : 0x53606c;
const fill = positive ? 0x172a22 : negative ? 0x2a1b18 : 0x111922;
const color = positive ? '#a8ffd0' : negative ? '#ff9d7d' : '#c8d2dd';
const delta = metric.delta > 0 ? `+${metric.delta}` : `${metric.delta}`;
metricView.label.setText(metric.label);
metricView.background.setFillStyle(fill, 0.92).setStrokeStyle(1, tone, positive || negative ? 0.58 : 0.34);
metricView.value
.setText(showMetricTransition ? `${metric.current}${metric.projected} ${delta}` : `${metric.current}`)
.setColor(color);
});
if (!comparison) {
view.impact
.setText(this.compactText(preview.detail, 68))
.setColor(preview.mode === 'blocked' ? '#87919c' : preview.mode === 'waiting' ? '#ffdf7b' : '#d4dce6');
view.detail
.setText('미리보기 · 실제 출전 명단·역할·보급은 변경되지 않습니다.')
.setColor('#9fb0bf');
return;
}
const gainedRoleLabels = comparison?.gainedRoles.map((role) => this.sortieFormationRoleLabel(role)) ?? [];
const lostRoleLabels = comparison?.lostRoles.map((role) => this.sortieFormationRoleLabel(role)) ?? [];
const roleChanges = [
lostRoleLabels.length > 0 ? `공백 ${lostRoleLabels.join('·')}` : '',
gainedRoleLabels.length > 0 ? `보강 ${gainedRoleLabels.join('·')}` : ''
].filter(Boolean);
const roleChange = roleChanges.join(' · ') || '역할 유지';
const bondDelta = comparison?.activeBondDelta ?? 0;
const signedBondDelta = bondDelta > 0 ? `+${bondDelta}` : `${bondDelta}`;
view.impact
.setText(
this.compactText(
`역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 공명 ${current.activeBondCount}${projected.activeBondCount} (${signedBondDelta}) · ${roleChange}`,
68
)
)
).setDepth(depth + 1);
.setColor(lostRoleLabels.length > 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || bondDelta > 0 ? '#a8ffd0' : '#d4dce6');
const gainedBonds = comparison?.gainedBonds.map((bond) => `+${bond.title}`) ?? [];
const lostBonds = comparison?.lostBonds.map((bond) => `-${bond.title}`) ?? [];
const bondLine = [gainedBonds[0], lostBonds[0], ...gainedBonds.slice(1), ...lostBonds.slice(1)]
.filter((bond): bond is string => Boolean(bond))
.slice(0, 2)
.join(' · ') || '공명 변화 없음';
const terrainDelta = projected.terrainScore - current.terrainScore;
const signedTerrainDelta = terrainDelta > 0 ? `+${terrainDelta}` : `${terrainDelta}`;
view.detail
.setText(
this.compactText(
`${bondLine} · 지형 ${current.terrainGrade} ${current.terrainScore}${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · 실제 편성 미변경`,
82
)
)
.setColor(isHoverPreview ? '#ffdf7b' : '#9fb0bf');
}
private renderFirstSortieRoleDiagram(x: number, y: number, width: number, height: number, depth: number) {
@@ -13965,6 +14133,160 @@ export class CampScene extends Phaser.Scene {
});
}
private sortieFormationComparisonPreview(): SortieFormationComparisonPreview | undefined {
const scenario = this.nextSortieScenario();
const selectedUnitIds = [...this.selectedSortieUnitIds];
const selectedIds = new Set(selectedUnitIds);
const current = this.sortieSynergySnapshot(selectedUnitIds, scenario);
const focused = this.sortieFocusedUnit();
const hovered = this.sortieHoveredUnitId
? this.sortieRosterUnits().find((unit) => unit.id === this.sortieHoveredUnitId)
: undefined;
if (hovered && hovered.id !== focused?.id && !selectedIds.has(hovered.id)) {
const availability = this.sortieUnitAvailability(hovered, scenario);
if (!availability.available) {
return {
source: 'hover-blocked',
mode: 'blocked',
unitId: hovered.id,
incomingUnitId: hovered.id,
incomingUnitName: hovered.name,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds: selectedUnitIds,
headline: `${hovered.name} 편성 불가 · ${availability.reason}`,
detail: '현재 출전 명단은 유지됩니다.',
metrics: this.sortieComparisonMetrics(hovered, hovered, scenario),
current
};
}
if (selectedUnitIds.length < this.sortieMaxUnits(scenario)) {
const projectedSelectedUnitIds = [...selectedUnitIds, hovered.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,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds,
headline: `IN ${hovered.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario))} 합류`,
detail: `빈 출전 슬롯에 합류했을 때의 가상 결과입니다.`,
metrics: this.sortieComparisonMetrics(undefined, hovered, scenario),
current,
projected,
comparison
};
}
const outgoing = focused && selectedIds.has(focused.id) && !this.isRequiredSortieUnit(focused.id)
? focused
: undefined;
if (!outgoing) {
return {
source: 'hover-blocked',
mode: 'waiting',
unitId: hovered.id,
incomingUnitId: hovered.id,
incomingUnitName: hovered.name,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds: selectedUnitIds,
headline: `교체 기준 필요 · 출전 중인 비필수 무장을 먼저 클릭하세요.`,
detail: `${hovered.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`,
metrics: this.sortieComparisonMetrics(hovered, hovered, scenario),
current
};
}
const projectedSelectedUnitIds = selectedUnitIds.map((unitId) => unitId === outgoing.id ? hovered.id : unitId);
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));
return {
source: 'hover-swap',
mode: 'swap',
unitId: hovered.id,
incomingUnitId: hovered.id,
incomingUnitName: hovered.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),
current,
projected,
comparison
};
}
if (!focused) {
return undefined;
}
const focusedPreview = this.sortieUnitSynergyPreview(focused);
const focusedSelected = selectedIds.has(focused.id);
const projected = focusedPreview.projected;
return {
source: 'focus',
mode: focusedPreview.mode,
unitId: focused.id,
incomingUnitId: focusedSelected ? null : focused.id,
incomingUnitName: focusedSelected ? null : focused.name,
outgoingUnitId: focusedSelected && !this.isRequiredSortieUnit(focused.id) ? focused.id : null,
outgoingUnitName: focusedSelected && !this.isRequiredSortieUnit(focused.id) ? focused.name : null,
selectedUnitIds,
projectedSelectedUnitIds: projected?.selectedUnitIds ?? selectedUnitIds,
headline: focusedPreview.headline,
detail: focusedPreview.detail,
metrics: focusedPreview.mode === 'add'
? this.sortieComparisonMetrics(undefined, focused, scenario)
: focusedPreview.mode === 'remove'
? this.sortieComparisonMetrics(focused, undefined, scenario)
: this.sortieComparisonMetrics(focused, focused, scenario),
current,
projected,
comparison: focusedPreview.comparison
};
}
private sortieComparisonMetrics(
currentUnit: UnitData | undefined,
projectedUnit: UnitData | undefined,
scenario = this.nextSortieScenario()
): SortieComparisonMetric[] {
const currentAttack = currentUnit ? currentUnit.attack + this.equipmentAttackBonus(currentUnit) : 0;
const projectedAttack = projectedUnit ? projectedUnit.attack + this.equipmentAttackBonus(projectedUnit) : 0;
const values: Omit<SortieComparisonMetric, 'delta'>[] = [
{ key: 'attack', label: '공격', current: currentAttack, projected: projectedAttack },
{
key: 'intelligence',
label: '지력',
current: currentUnit?.stats.intelligence ?? 0,
projected: projectedUnit?.stats.intelligence ?? 0
},
{ key: 'move', label: '기동', current: currentUnit?.move ?? 0, projected: projectedUnit?.move ?? 0 },
{
key: 'terrain',
label: '지형',
current: currentUnit ? this.sortieTerrainScore(currentUnit, scenario) : 0,
projected: projectedUnit ? this.sortieTerrainScore(projectedUnit, scenario) : 0
}
];
return values.map((metric) => ({ ...metric, delta: metric.projected - metric.current }));
}
private sortieUnitSynergyPreview(unit: UnitData): SortieUnitSynergyPreview {
const scenario = this.nextSortieScenario();
const current = this.sortieSynergySnapshot(this.selectedSortieUnitIds, scenario);
@@ -15075,6 +15397,7 @@ export class CampScene extends Phaser.Scene {
this.sortieObjects.forEach((object) => object.destroy());
this.sortieObjects = [];
this.sortieHoveredUnitId = undefined;
this.sortieComparisonPanelView = undefined;
}
private trackSortie<T extends Phaser.GameObjects.GameObject>(object: T) {
@@ -16894,6 +17217,7 @@ export class CampScene extends Phaser.Scene {
sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(),
sortieFocusedUnitId: this.sortieFocusedUnitId,
sortieHoveredUnitId: this.sortieHoveredUnitId ?? null,
sortieComparisonPreview: this.sortieFormationComparisonPreview() ?? null,
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
sortiePlanFeedback: this.sortiePlanFeedback,
sortieChecklist,