feat: preview resonance pursuit pairs in formation
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user