feat: preview resonance pursuit pairs in formation

This commit is contained in:
2026-07-11 12:50:34 +09:00
parent 9025d74398
commit 307040ef00
4 changed files with 714 additions and 67 deletions

View File

@@ -60,6 +60,36 @@ export type SortieSynergyComparison = {
lostRoles: (typeof coreSortieSynergyRoles)[number][];
};
export type SortiePursuitOpportunity = SortieSynergyActiveBond & {
selectedPartnerId: string;
selectedPartnerName: string;
candidateUnitId: string;
candidateUnitName: string;
};
export type SortiePursuitPairs = {
activePairs: SortieSynergyActiveBond[];
selectableOpportunities: SortiePursuitOpportunity[];
};
function compareSortieActiveBonds(left: SortieSynergyActiveBond, right: SortieSynergyActiveBond) {
return right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id);
}
function strongestSortieBondPairs<T extends SortieSynergyActiveBond>(bonds: readonly T[]) {
const seenPairs = new Set<string>();
return [...bonds]
.sort(compareSortieActiveBonds)
.filter((bond) => {
const pairKey = [...bond.unitIds].sort().join('\u0000');
if (seenPairs.has(pairKey)) {
return false;
}
seenPairs.add(pairKey);
return true;
});
}
export function sortieBondBonusForLevel(level: number) {
const normalizedLevel = Math.max(0, Math.floor(Number.isFinite(level) ? level : 0));
return {
@@ -115,7 +145,7 @@ export function evaluateSortieSynergy(options: {
...bonus
};
})
.sort((left, right) => right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id));
.sort(compareSortieActiveBonds);
const firstPursuitTrinityConfigured = options.battleId === firstPursuitSynergyBattleId &&
firstPursuitBrotherUnitIds.every((unitId) => selectedIds.has(unitId)) &&
coreSortieSynergyRoles.every((role) => firstPursuitBrotherUnitIds.some((unitId) => memberById.get(unitId)?.role === role));
@@ -134,6 +164,48 @@ export function evaluateSortieSynergy(options: {
};
}
export function evaluateSortiePursuitPairs(options: {
members: readonly SortieSynergyMember[];
bonds: readonly SortieSynergyBond[];
selectedUnitIds: readonly string[];
selectableUnitIds?: readonly string[];
battleId?: string;
}): SortiePursuitPairs {
const current = evaluateSortieSynergy(options);
const selectedIds = new Set(current.selectedUnitIds);
const selectableUnitIds = [...new Set(options.selectableUnitIds ?? options.members.map((member) => member.id))]
.filter((unitId) => !selectedIds.has(unitId));
const selectableOpportunities = strongestSortieBondPairs(selectableUnitIds.flatMap<SortiePursuitOpportunity>((candidateUnitId) => {
const projected = evaluateSortieSynergy({
...options,
selectedUnitIds: [...current.selectedUnitIds, candidateUnitId]
});
return projected.activeBonds.flatMap<SortiePursuitOpportunity>((bond) => {
if (bond.chainRate <= 0 || !bond.unitIds.includes(candidateUnitId)) {
return [];
}
const selectedPartnerId = bond.unitIds.find((unitId) => selectedIds.has(unitId));
if (!selectedPartnerId) {
return [];
}
const selectedPartnerIndex = bond.unitIds.indexOf(selectedPartnerId);
const candidateUnitIndex = bond.unitIds.indexOf(candidateUnitId);
return [{
...bond,
selectedPartnerId,
selectedPartnerName: bond.unitNames[selectedPartnerIndex],
candidateUnitId,
candidateUnitName: bond.unitNames[candidateUnitIndex]
}];
});
}));
return {
activePairs: strongestSortieBondPairs(current.activeBonds.filter((bond) => bond.chainRate > 0)),
selectableOpportunities
};
}
export function compareSortieSynergy(current: SortieSynergySnapshot, projected: SortieSynergySnapshot): SortieSynergyComparison {
const currentBondIds = new Set(current.activeBonds.map((bond) => bond.id));
const projectedBondIds = new Set(projected.activeBonds.map((bond) => bond.id));

View File

@@ -33,8 +33,11 @@ import {
import {
compareSortieSynergy,
coreSortieSynergyRoles,
evaluateSortiePursuitPairs,
evaluateSortieSynergy,
sortieRoleEffectSummaries,
type SortiePursuitPairs,
type SortieSynergyActiveBond,
type SortieSynergyComparison,
type SortieSynergySnapshot
} from '../data/sortieSynergy';
@@ -407,6 +410,18 @@ type SortieComparisonPanelView = {
actionLabel: Phaser.GameObjects.Text;
};
type SortiePursuitPanelView = {
background: Phaser.GameObjects.Rectangle;
summaryText: Phaser.GameObjects.Text;
ruleText: Phaser.GameObjects.Text;
rows: {
state: 'candidate';
text: string;
background: Phaser.GameObjects.Rectangle;
label: Phaser.GameObjects.Text;
}[];
};
type SortieConfigurationSnapshot = {
selectedUnitIds: string[];
formationAssignments: SortieFormationAssignments;
@@ -11022,6 +11037,7 @@ export class CampScene extends Phaser.Scene {
private sortieOrderBrowserView?: SortieOrderBrowserView;
private sortieOrderToggleButton?: Phaser.GameObjects.Rectangle;
private sortieComparisonPanelView?: SortieComparisonPanelView;
private sortiePursuitPanelView?: SortiePursuitPanelView;
private sortieRosterScroll = 0;
private sortiePlanFeedback = '';
private sortiePrepStep: SortiePrepStep = 'briefing';
@@ -11043,6 +11059,7 @@ export class CampScene extends Phaser.Scene {
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
this.sortiePursuitPanelView = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieSwapUndoState = undefined;
this.sortieFormationPanelMode = 'roster';
@@ -13509,6 +13526,7 @@ export class CampScene extends Phaser.Scene {
const projected = isUndoAction ? current : preview?.projected ?? current;
const showMetricTransition = !isUndoAction && Boolean(preview?.projected);
const comparison = isUndoAction ? undefined : preview?.comparison;
const pursuitChanges = this.changedSortiePursuitPairs(current, projected);
const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap' ||
preview?.source === 'hover-preset' || preview?.source === 'pinned-preset';
const lostRole = Boolean(comparison?.lostRoles.length);
@@ -13535,13 +13553,13 @@ export class CampScene extends Phaser.Scene {
return;
}
view.title.setText('편성 변화');
view.summary.setText(`공명 ${current.activeBondCount} · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`);
view.summary.setText(`추격 후보 ${pursuitChanges.currentPairs.length} · 역할 ${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.impact.setText('후보 초상에 마우스를 올리면 역할·추격 후보 변화가 표시됩니다.').setColor('#d4dce6');
view.detail.setText('미리보기는 실제 출전 명단과 보급을 변경하지 않습니다.').setColor('#9fb0bf');
return;
}
@@ -13562,7 +13580,7 @@ export class CampScene extends Phaser.Scene {
view.title.setText(this.compactText(title, 28));
view.summary.setText(
this.compactText(
`공명 ${current.activeBondCount}${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`,
`추격 후보 ${pursuitChanges.currentPairs.length}${pursuitChanges.projectedPairs.length} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`,
38
)
);
@@ -13605,7 +13623,7 @@ export class CampScene extends Phaser.Scene {
if (action?.kind === 'undo-preset' && this.sortiePresetUndoState) {
view.impact
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`)
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length} · 지형 ${current.terrainGrade}`)
.setColor('#a8ffd0');
view.detail
.setText('유지 가능한 보급만 보존했습니다. 직전 적용은 한 번 되돌릴 수 있습니다.')
@@ -13615,7 +13633,7 @@ export class CampScene extends Phaser.Scene {
if (isUndoAction && this.sortieSwapUndoState) {
view.impact
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`)
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length} · 지형 ${current.terrainGrade}`)
.setColor('#a8ffd0');
view.detail
.setText('장비·보급은 자동 이전하지 않았습니다. 직전 교체는 한 번 되돌릴 수 있습니다.')
@@ -13640,29 +13658,29 @@ export class CampScene extends Phaser.Scene {
gainedRoleLabels.length > 0 ? `보강 ${gainedRoleLabels.join('·')}` : ''
].filter(Boolean);
const roleChange = roleChanges.join(' · ') || '역할 유지';
const bondDelta = comparison?.activeBondDelta ?? 0;
const signedBondDelta = bondDelta > 0 ? `+${bondDelta}` : `${bondDelta}`;
const pursuitDelta = pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length;
const signedPursuitDelta = pursuitDelta > 0 ? `+${pursuitDelta}` : `${pursuitDelta}`;
view.impact
.setText(
this.compactText(
`역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 공명 ${current.activeBondCount}${projected.activeBondCount} (${signedBondDelta}) · ${roleChange}`,
`역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}${pursuitChanges.projectedPairs.length} (${signedPursuitDelta}) · ${roleChange}`,
68
)
)
.setColor(lostRoleLabels.length > 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || bondDelta > 0 ? '#a8ffd0' : '#d4dce6');
.setColor(lostRoleLabels.length > 0 || pursuitDelta < 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || pursuitDelta > 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))
const gainedPursuits = pursuitChanges.gainedPairs.map((pair) => `추격 개방 ${this.sortiePursuitPairLabel(pair)}`);
const lostPursuits = pursuitChanges.lostPairs.map((pair) => `추격 해제 ${this.sortiePursuitPairLabel(pair)}`);
const pursuitLine = [gainedPursuits[0], lostPursuits[0], ...gainedPursuits.slice(1), ...lostPursuits.slice(1)]
.filter((entry): entry is string => Boolean(entry))
.slice(0, 2)
.join(' · ') || '공명 변화 없음';
.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}) · 실제 편성 미변경`,
`${pursuitLine} · 지형 ${current.terrainGrade} ${current.terrainScore}${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · ${roleChange} · 실제 편성 미변경`,
82
)
)
@@ -13863,7 +13881,7 @@ export class CampScene extends Phaser.Scene {
lineSpacing: 2
})
).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(12, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
this.renderFirstSortieInlineButton(
required ? '필수 출전' : selected ? '출전 확정' : '대기',
@@ -13964,30 +13982,48 @@ export class CampScene extends Phaser.Scene {
private renderFirstSortieSynergyPanel(x: number, y: number, width: number, height: number, depth: number) {
const synergy = this.sortieSynergySnapshot();
const pursuit = this.sortiePursuitPairs();
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, synergy.firstPursuitTrinityConfigured ? palette.green : palette.gold, 0.62);
this.trackSortie(
const summaryText = this.trackSortie(
this.add.text(
x + 18,
y + 12,
`조합 시너지 · 공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3`,
this.textStyle(17, '#f2e3bf', true)
y + 9,
`조합 시너지 · 추격 후보 ${pursuit.activePairs.length} · 역할 ${synergy.coveredRoleCount}/3`,
this.textStyle(15, '#f2e3bf', true)
)
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 15, `지형 ${synergy.terrainGrade}`, this.textStyle(11, '#d8b15f', true))).setOrigin(1, 0).setDepth(depth + 1);
const activeBondLine = synergy.activeBonds.length > 0
? `${synergy.activeBonds.slice(0, 2).map((bond) => `${bond.title} Lv${bond.level}`).join(' · ')}${synergy.activeBonds.length > 2 ? ` · 외 ${synergy.activeBonds.length - 2}` : ''}`
: '활성 공명 없음';
this.trackSortie(this.add.text(x + 18, y + 40, this.compactText(activeBondLine, 38), this.textStyle(12, synergy.activeBonds.length > 0 ? '#a8ffd0' : '#ffdf7b', true))).setDepth(depth + 1);
const strongest = synergy.strongestBond;
const strongestLine = strongest
? `최강 공명 · 조건 충족 시 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%`
: '공명 파트너를 함께 편성하면 전투 연계가 열립니다.';
this.trackSortie(
this.add.text(x + 18, y + 64, this.compactText(strongestLine, 44), this.textStyle(11, strongest ? '#d4dce6' : '#9fb0bf'))
).setDepth(depth + 1);
);
summaryText.setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 12, `지형 ${synergy.terrainGrade}`, this.textStyle(10, '#d8b15f', true))).setOrigin(1, 0).setDepth(depth + 1);
const visiblePairs = pursuit.activePairs.slice(0, 3);
const chipGap = 6;
const availableChipWidth = Math.floor((width - 36 - chipGap * Math.max(0, visiblePairs.length - 1)) / Math.max(1, visiblePairs.length));
const chipWidth = Math.min(118, availableChipWidth);
const rows: SortiePursuitPanelView['rows'] = visiblePairs.map((pair, index) => {
const chipX = x + 18 + index * (chipWidth + chipGap);
const chipText = `${pair.chainRate}% ${pair.unitNames[0]}${pair.unitNames[1]}`;
const strongest = index === 0;
const background = this.trackSortie(this.add.rectangle(chipX, y + 34, chipWidth, 22, strongest ? 0x3b3020 : 0x173329, 0.98));
background.setOrigin(0);
background.setDepth(depth + 1);
background.setStrokeStyle(1, strongest ? palette.gold : palette.green, strongest ? 0.88 : 0.7);
const label = this.trackSortie(
this.add.text(chipX + chipWidth / 2, y + 45, this.compactText(chipText, 13), this.textStyle(10, strongest ? '#ffdf7b' : '#a8ffd0', true))
);
label.setOrigin(0.5);
label.setDepth(depth + 2);
return { state: 'candidate' as const, text: chipText, background, label };
});
if (rows.length === 0) {
this.trackSortie(this.add.text(x + 18, y + 38, '추격 후보 없음 · 공명 파트너를 함께 편성하십시오.', this.textStyle(11, '#ffdf7b', true))).setDepth(depth + 1);
}
const ruleText = this.trackSortie(
this.add.text(x + 18, y + 62, '같은 적을 먼저 친 공명 장수가 지원 거리 안에서 표시 확률로 추격합니다.', this.textStyle(9, '#d4dce6', true))
);
ruleText.setDepth(depth + 1);
const missingRoleLabels: Record<(typeof coreSortieSynergyRoles)[number], string> = {
front: '전열',
flank: '돌파',
@@ -14002,6 +14038,7 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(
this.add.text(x + 18, y + 88, this.compactText(bottomLine, 47), this.textStyle(11, synergy.firstPursuitTrinityConfigured ? '#a8ffd0' : '#ffdf7b', true))
).setDepth(depth + 1);
this.sortiePursuitPanelView = { background: bg, summaryText, ruleText, rows };
}
private renderFirstSortieLoadoutStep(
@@ -14124,6 +14161,7 @@ export class CampScene extends Phaser.Scene {
const maxUnits = this.sortieMaxUnits();
const hasBattle = Boolean(this.nextSortieScenario());
const synergy = this.sortieSynergySnapshot();
const pursuit = this.sortiePursuitPairs();
const selectedOrder = this.currentSortieOrderDefinition();
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78));
bg.setOrigin(0);
@@ -14145,7 +14183,7 @@ export class CampScene extends Phaser.Scene {
x + 44,
y + 35,
this.compactText(
`${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}`,
`${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)
@@ -14429,6 +14467,7 @@ export class CampScene extends Phaser.Scene {
const plan = this.sortiePlanSummary();
const scenario = this.nextSortieScenario();
const hasBattle = Boolean(scenario);
const pursuit = this.sortiePursuitPairs();
const canUsePresetBook = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && !this.isFirstSortiePrepSlice();
const canUseSortieOrder = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && hasBattle;
const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && this.sortieFormationPanelMode === 'roster';
@@ -14469,7 +14508,7 @@ export class CampScene extends Phaser.Scene {
x + 18,
y + 38,
hasBattle
? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54)
? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 추격 후보 ${pursuit.activePairs.length}조 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54)
: `${plan.selectedCount}/${plan.maxCount}명 · 군영 의정 · ${this.sortieRecommendedClassLine()}`,
this.textStyle(11, '#d8b15f', true)
)
@@ -14531,6 +14570,18 @@ export class CampScene extends Phaser.Scene {
const roleLabel = this.sortieFormationRoleLabel(role, hasBattle);
this.trackSortie(this.add.text(slotX + 42, slotY + 4, this.compactText(unit.name, 8), this.textStyle(11, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(slotX + 42, slotY + 17, roleLabel, this.textStyle(8, '#9fb0bf', true))).setDepth(depth + 1);
const pursuitPartner = hasBattle ? this.sortiePursuitPartnerForUnit(unit.id) : undefined;
if (pursuitPartner) {
const extra = pursuitPartner.additionalCount > 0 ? ` +${pursuitPartner.additionalCount}` : '';
this.trackSortie(
this.add.text(
slotX + slotWidth - 7,
slotY + 5,
`${pursuitPartner.partnerName} ${pursuitPartner.pair.chainRate}%${extra}`,
this.textStyle(9, '#a8ffd0', true)
)
).setOrigin(1, 0).setDepth(depth + 1);
}
} else {
this.trackSortie(this.add.text(slotX + 28, slotY + 8, hasBattle ? '빈 슬롯' : '빈 동행', this.textStyle(10, '#7f8994', true))).setDepth(depth + 1);
}
@@ -14863,6 +14914,11 @@ export class CampScene extends Phaser.Scene {
}
private sortieBondLine(unit: UnitData) {
const activePursuit = this.sortiePursuitPartnerForUnit(unit.id);
if (activePursuit) {
return `추격 후보 ${activePursuit.partnerName} ${activePursuit.pair.chainRate}% · 공명 피해 +${activePursuit.pair.damageBonus}%`;
}
const selected = new Set(this.selectedSortieUnitIds);
const activeBonds = this.currentBonds()
.filter((bond) => bond.unitIds.includes(unit.id))
@@ -14919,6 +14975,94 @@ export class CampScene extends Phaser.Scene {
});
}
private sortiePursuitPairs(
selectedUnitIds: readonly string[] = this.selectedSortieUnitIds,
scenario = this.nextSortieScenario(),
formationAssignments: SortieFormationAssignments = this.sortieFormationAssignments,
selectableUnitIds?: readonly string[]
): SortiePursuitPairs {
if (!scenario) {
return { activePairs: [], selectableOpportunities: [] };
}
const rule = this.nextSortieRule(scenario);
const members = this.sortieRosterUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available);
const availableIds = new Set(members.map((unit) => unit.id));
return evaluateSortiePursuitPairs({
members: members.map((unit) => ({
id: unit.id,
name: unit.name,
role: this.sortieFormationRole(unit, scenario, formationAssignments),
terrainScore: this.sortieTerrainScore(unit, scenario)
})),
bonds: this.sortieSynergyBonds(scenario),
selectedUnitIds: selectedUnitIds.filter((unitId) => availableIds.has(unitId)),
selectableUnitIds: (selectableUnitIds ?? [...availableIds]).filter((unitId) => availableIds.has(unitId)),
battleId: scenario?.id
});
}
private sortiePursuitPairKey(pair: Pick<SortieSynergyActiveBond, 'unitIds'>) {
return [...pair.unitIds].sort().join('\u0000');
}
private activeSortiePursuitPairs(snapshot: SortieSynergySnapshot) {
const seenPairs = new Set<string>();
return snapshot.activeBonds.filter((bond) => {
const pairKey = this.sortiePursuitPairKey(bond);
if (bond.chainRate <= 0 || seenPairs.has(pairKey)) {
return false;
}
seenPairs.add(pairKey);
return true;
});
}
private sortiePursuitPartnerForUnit(unitId: string, pairs = this.sortiePursuitPairs().activePairs) {
const partners = pairs
.filter((pair) => pair.unitIds.includes(unitId))
.map((pair) => {
const unitIndex = pair.unitIds.indexOf(unitId);
const partnerIndex = unitIndex === 0 ? 1 : 0;
return {
pair,
partnerId: pair.unitIds[partnerIndex],
partnerName: pair.unitNames[partnerIndex]
};
})
.sort((left, right) =>
right.pair.chainRate - left.pair.chainRate ||
right.pair.level - left.pair.level ||
left.partnerName.localeCompare(right.partnerName)
);
return partners[0]
? { ...partners[0], additionalCount: Math.max(0, partners.length - 1) }
: undefined;
}
private sortiePursuitPairLabel(pair: SortieSynergyActiveBond) {
return `${pair.unitNames[0]}${pair.unitNames[1]} ${pair.chainRate}%`;
}
private compareSortiePursuitPairs(left: SortieSynergyActiveBond, right: SortieSynergyActiveBond) {
return right.chainRate - left.chainRate || right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id);
}
private changedSortiePursuitPairs(current: SortieSynergySnapshot, projected: SortieSynergySnapshot) {
if (!this.nextSortieScenario()) {
return { currentPairs: [], projectedPairs: [], gainedPairs: [], lostPairs: [] };
}
const currentPairs = [...this.activeSortiePursuitPairs(current)].sort((left, right) => this.compareSortiePursuitPairs(left, right));
const projectedPairs = [...this.activeSortiePursuitPairs(projected)].sort((left, right) => this.compareSortiePursuitPairs(left, right));
const currentKeys = new Set(currentPairs.map((pair) => this.sortiePursuitPairKey(pair)));
const projectedKeys = new Set(projectedPairs.map((pair) => this.sortiePursuitPairKey(pair)));
return {
currentPairs,
projectedPairs,
gainedPairs: projectedPairs.filter((pair) => !currentKeys.has(this.sortiePursuitPairKey(pair))),
lostPairs: currentPairs.filter((pair) => !projectedKeys.has(this.sortiePursuitPairKey(pair)))
};
}
private sortieFormationPresets(): CampaignSortieFormationPresets {
return this.campaign?.sortieFormationPresets ?? getCampaignState().sortieFormationPresets;
}
@@ -15720,6 +15864,7 @@ export class CampScene extends Phaser.Scene {
private sortieUnitSynergyPreview(unit: UnitData): SortieUnitSynergyPreview {
const scenario = this.nextSortieScenario();
const current = this.sortieSynergySnapshot(this.selectedSortieUnitIds, scenario);
const currentPursuitPairs = scenario ? this.activeSortiePursuitPairs(current) : [];
const availability = this.sortieUnitAvailability(unit, scenario);
const selected = this.isSortieSelected(unit.id);
const role = this.sortieFormationRole(unit, scenario);
@@ -15735,37 +15880,84 @@ export class CampScene extends Phaser.Scene {
};
}
if (!scenario) {
const relatedBonds = current.activeBonds.filter((bond) => bond.unitIds.includes(unit.id));
if (selected && this.isRequiredSortieUnit(unit.id)) {
return {
mode: 'current',
headline: `필수 동행 · 공명 ${relatedBonds.length}개 연결 · ${roleLabel} 유지`,
detail: `군영 의정 동행 · 지형 ${current.terrainGrade} ${current.terrainScore}`,
compactLabel: `필수 · 공명 ${relatedBonds.length}`,
current
};
}
if (!selected && this.selectedSortieUnitIds.length >= this.sortieMaxUnits()) {
return {
mode: 'waiting',
headline: '교대 후보 · 동행 슬롯을 먼저 비우십시오.',
detail: `${roleLabel} 후보 · 군영 의정 동행`,
compactLabel: '교대 · 동행 대기',
current
};
}
const projectedIds = selected
? this.selectedSortieUnitIds.filter((unitId) => unitId !== unit.id)
: [...this.selectedSortieUnitIds, unit.id];
const projected = this.sortieSynergySnapshot(projectedIds, scenario);
const comparison = compareSortieSynergy(current, projected);
return {
mode: selected ? 'remove' : 'add',
headline: `${selected ? '해제 시' : '합류 시'} 공명 ${current.activeBondCount}${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3`,
detail: `${roleLabel} · 군영 의정 동행 · 실제 명단 미변경`,
compactLabel: `공명 ${comparison.activeBondDelta > 0 ? '+' : ''}${comparison.activeBondDelta} · 역할 ${comparison.roleCoverageDelta > 0 ? '+' : ''}${comparison.roleCoverageDelta}`,
current,
projected,
comparison
};
}
if (selected && this.isRequiredSortieUnit(unit.id)) {
const relatedBonds = current.activeBonds.filter((bond) => bond.unitIds.includes(unit.id));
const strongest = relatedBonds[0];
const strongestBond = relatedBonds[0];
const pursuitPartner = this.sortiePursuitPartnerForUnit(unit.id, currentPursuitPairs);
return {
mode: 'current',
headline: `필수 편성 · 공명 ${relatedBonds.length}개 연결 · ${roleLabel} 유지`,
detail: strongest
? `${strongest.title} Lv${strongest.level} · 조건 충족 시 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%`
: `현재 활성 공명 없음 · 지형 ${current.terrainGrade} ${current.terrainScore}`,
compactLabel: `필수 · 공명 ${relatedBonds.length}`,
headline: pursuitPartner
? `필수 편성 · 추격 후보 ${pursuitPartner.partnerName} ${pursuitPartner.pair.chainRate}% · ${roleLabel} 유지`
: `필수 편성 · 추격 후보 없음 · 공명 ${relatedBonds.length}개 · ${roleLabel} 유지`,
detail: pursuitPartner
? `추격 후보 ${this.sortiePursuitPairLabel(pursuitPartner.pair)} · 지원 조건 충족 시 확률 판정 · 공명 피해 +${pursuitPartner.pair.damageBonus}% · 지형 ${current.terrainGrade} ${current.terrainScore}`
: strongestBond
? `공명 ${strongestBond.title} Lv${strongestBond.level} · 공명 피해 +${strongestBond.damageBonus}% · 추격 미개방 · 지형 ${current.terrainGrade} ${current.terrainScore}`
: `현재 활성 공명 없음 · 지형 ${current.terrainGrade} ${current.terrainScore}`,
compactLabel: pursuitPartner
? `${pursuitPartner.partnerName} ${pursuitPartner.pair.chainRate}% · 후보 ${currentPursuitPairs.filter((pair) => pair.unitIds.includes(unit.id)).length}`
: `필수 · 추격 후보 0조 · 공명 ${relatedBonds.length}`,
current
};
}
if (!selected && this.selectedSortieUnitIds.length >= this.sortieMaxUnits(scenario)) {
const selectedIds = new Set(this.selectedSortieUnitIds);
const potentialBonds = this.sortieSynergyBonds(scenario)
.filter((bond) => bond.unitIds.includes(unit.id) && bond.unitIds.some((unitId) => unitId !== unit.id && selectedIds.has(unitId)))
.sort((left, right) => right.level - left.level || left.title.localeCompare(right.title));
const strongest = potentialBonds[0];
const strongestPartnerId = strongest?.unitIds.find((unitId) => unitId !== unit.id);
const opportunities = this.sortiePursuitPairs(
this.selectedSortieUnitIds,
scenario,
this.sortieFormationAssignments,
[unit.id]
).selectableOpportunities
.filter((opportunity) => opportunity.candidateUnitId === unit.id)
.sort((left, right) => this.compareSortiePursuitPairs(left, right));
const strongest = opportunities[0];
const topRate = strongest?.chainRate ?? 0;
const terrainScore = this.sortieTerrainScore(unit, scenario);
return {
mode: 'waiting',
headline: strongest
? `교대 후보 · ${potentialBonds.length}개 공명 연결 가능`
: `교대 후보 · 연결 가능한 공명 없음`,
? `교대 후보 · 추격 기회 ${opportunities.length}조 · 최고 ${topRate}%`
: '교대 후보 · 추격 기회 0조',
detail: strongest
? `${this.unitName(strongestPartnerId ?? '')}${strongest.title} Lv${strongest.level} · ${roleLabel} · 지형 ${this.sortieTerrainGrade(terrainScore)}`
? `추격 기회 ${this.sortiePursuitPairLabel(strongest)} · ${roleLabel} · 지형 ${this.sortieTerrainGrade(terrainScore)} ${terrainScore}`
: `${roleLabel} 후보 · 지형 ${this.sortieTerrainGrade(terrainScore)} ${terrainScore} · 먼저 슬롯을 비우십시오.`,
compactLabel: `교대 · 공명 ${potentialBonds.length}`,
compactLabel: `추격 기회 ${opportunities.length}조 · 최고 ${topRate}%`,
current
};
}
@@ -15775,25 +15967,31 @@ export class CampScene extends Phaser.Scene {
: [...this.selectedSortieUnitIds, unit.id];
const projected = this.sortieSynergySnapshot(projectedIds, scenario);
const comparison = compareSortieSynergy(current, projected);
const pursuitChanges = this.changedSortiePursuitPairs(current, projected);
const prefix = selected ? '해제 시' : '합류 시';
const changedBonds = selected ? comparison.lostBonds : comparison.gainedBonds;
const bondMark = selected ? '' : '+';
const bondDetail = changedBonds.length > 0
? `${bondMark} ${changedBonds.slice(0, 2).map((bond) => `${bond.title} Lv${bond.level}`).join(' · ')}`
: '공명 변화 없음';
const strongest = projected.strongestBond;
const changedPairs = selected ? pursuitChanges.lostPairs : pursuitChanges.gainedPairs;
const changeLabel = selected ? '추격 해제' : '추격 개방';
const pursuitDetail = changedPairs.length > 0
? changedPairs.slice(0, 2).map((pair) => `${changeLabel} ${this.sortiePursuitPairLabel(pair)}`).join(' · ')
: '추격 변화 없음';
const strongest = [...(selected ? pursuitChanges.currentPairs : pursuitChanges.projectedPairs)]
.sort((left, right) => this.compareSortiePursuitPairs(left, right))[0];
const currentPartner = selected ? this.sortiePursuitPartnerForUnit(unit.id, pursuitChanges.currentPairs) : undefined;
const terrainLine = `지형 ${current.terrainGrade} ${current.terrainScore}${projected.terrainGrade} ${projected.terrainScore}`;
const strongestLine = strongest
? `강 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%`
: '활성 공명 없음';
const signedBondDelta = comparison.activeBondDelta > 0 ? `+${comparison.activeBondDelta}` : `${comparison.activeBondDelta}`;
const signedRoleDelta = comparison.roleCoverageDelta > 0 ? `+${comparison.roleCoverageDelta}` : `${comparison.roleCoverageDelta}`;
? `${strongest.chainRate}% · 공명 피해 +${strongest.damageBonus}%`
: '추격 후보 없음';
const topRate = changedPairs[0]?.chainRate ?? 0;
return {
mode: selected ? 'remove' : 'add',
headline: `${prefix} 공명 ${current.activeBondCount}${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3`,
detail: `${bondDetail} · ${strongestLine} · ${terrainLine}`,
compactLabel: `공명 ${signedBondDelta} · 역할 ${signedRoleDelta}`,
headline: `${prefix} 추격 후보 ${pursuitChanges.currentPairs.length}${pursuitChanges.projectedPairs.length} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3`,
detail: `${pursuitDetail} · ${strongestLine} · ${roleLabel} · ${terrainLine}`,
compactLabel: selected
? currentPartner
? `${currentPartner.partnerName} ${currentPartner.pair.chainRate}% · 추격 해제 ${changedPairs.length}`
: `추격 해제 ${changedPairs.length}조 · 최고 ${topRate}%`
: `추격 개방 ${changedPairs.length}조 · 최고 ${topRate}%`,
current,
projected,
comparison
@@ -16856,6 +17054,7 @@ export class CampScene extends Phaser.Scene {
this.sortieFormationPanelMode = 'roster';
}
this.sortieComparisonPanelView = undefined;
this.sortiePursuitPanelView = undefined;
this.sortiePresetBrowserView = undefined;
this.sortiePresetToggleButton = undefined;
this.sortieOrderBrowserView = undefined;
@@ -20000,6 +20199,14 @@ export class CampScene extends Phaser.Scene {
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
}
private sortieTextBoundsDebug(object?: Phaser.GameObjects.Text) {
if (!object?.active) {
return null;
}
const bounds = object.getBounds();
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
}
getDebugState() {
const sortieChecklist = this.sortieChecklist();
const sortieScenario = this.nextSortieScenario();
@@ -20016,6 +20223,7 @@ export class CampScene extends Phaser.Scene {
const portraitRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, portraitRosterMaxScroll);
const sortieComparisonPreview = this.sortieFormationComparisonPreview();
const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview);
const sortiePursuit = this.sortiePursuitPairs();
const recommendedPresetId = this.recommendedSortiePresetId();
const sortieFormationPresets = this.sortieFormationPresets();
const sortieOrderId = this.currentSortieOrderId();
@@ -20483,6 +20691,40 @@ export class CampScene extends Phaser.Scene {
})),
sortiePlan: this.sortiePlanSummary(),
sortieSynergyPreview: this.sortieSynergySnapshot(),
sortiePursuitPreview: {
activePairs: sortiePursuit.activePairs.map((pair) => ({
id: pair.id,
unitIds: [...pair.unitIds],
unitNames: [...pair.unitNames],
title: pair.title,
level: pair.level,
chainRate: pair.chainRate,
damageBonus: pair.damageBonus
})),
selectableOpportunities: sortiePursuit.selectableOpportunities.map((opportunity) => ({
id: opportunity.id,
unitIds: [...opportunity.unitIds],
unitNames: [...opportunity.unitNames],
title: opportunity.title,
level: opportunity.level,
chainRate: opportunity.chainRate,
damageBonus: opportunity.damageBonus,
selectedPartnerId: opportunity.selectedPartnerId,
selectedPartnerName: opportunity.selectedPartnerName,
candidateUnitId: opportunity.candidateUnitId,
candidateUnitName: opportunity.candidateUnitName
})),
panelBounds: this.sortieObjectBoundsDebug(this.sortiePursuitPanelView?.background),
summaryText: this.sortiePursuitPanelView?.summaryText.text ?? null,
ruleText: this.sortiePursuitPanelView?.ruleText.text ?? null,
ruleBounds: this.sortieTextBoundsDebug(this.sortiePursuitPanelView?.ruleText),
rows: this.sortiePursuitPanelView?.rows.map((row) => ({
state: row.state,
text: row.text,
bounds: this.sortieObjectBoundsDebug(row.background),
textBounds: this.sortieTextBoundsDebug(row.label)
})) ?? []
},
sortieFocusedSynergyPreview: focusedSortieUnit
? this.sortieUnitSynergyPreview(focusedSortieUnit)
: null,