feat: add selectable core resonance pairs
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { SortieFormationRole } from './sortieDeployment';
|
||||
|
||||
export const coreSortieSynergyRoles = ['front', 'flank', 'support'] as const;
|
||||
export const coreSortieResonanceMinLevel = 30;
|
||||
export const firstPursuitSynergyBattleId = 'second-battle-yellow-turban-pursuit';
|
||||
export const firstPursuitBrotherUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei'] as const;
|
||||
export const sortieRoleEffectSummaries: Partial<Record<SortieFormationRole, string>> = {
|
||||
@@ -60,7 +61,12 @@ export type SortieSynergyComparison = {
|
||||
lostRoles: (typeof coreSortieSynergyRoles)[number][];
|
||||
};
|
||||
|
||||
export type SortiePursuitOpportunity = SortieSynergyActiveBond & {
|
||||
export type SortiePursuitPair = SortieSynergyActiveBond & {
|
||||
baseChainRate: number;
|
||||
core: boolean;
|
||||
};
|
||||
|
||||
export type SortiePursuitOpportunity = SortiePursuitPair & {
|
||||
selectedPartnerId: string;
|
||||
selectedPartnerName: string;
|
||||
candidateUnitId: string;
|
||||
@@ -68,7 +74,7 @@ export type SortiePursuitOpportunity = SortieSynergyActiveBond & {
|
||||
};
|
||||
|
||||
export type SortiePursuitPairs = {
|
||||
activePairs: SortieSynergyActiveBond[];
|
||||
activePairs: SortiePursuitPair[];
|
||||
selectableOpportunities: SortiePursuitOpportunity[];
|
||||
};
|
||||
|
||||
@@ -76,10 +82,13 @@ function compareSortieActiveBonds(left: SortieSynergyActiveBond, right: SortieSy
|
||||
return right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function strongestSortieBondPairs<T extends SortieSynergyActiveBond>(bonds: readonly T[]) {
|
||||
function strongestSortiePursuitPairs<T extends SortiePursuitPair>(bonds: readonly T[]) {
|
||||
const seenPairs = new Set<string>();
|
||||
return [...bonds]
|
||||
.sort(compareSortieActiveBonds)
|
||||
.sort((left, right) =>
|
||||
Number(right.core) - Number(left.core) ||
|
||||
compareSortieActiveBonds(left, right)
|
||||
)
|
||||
.filter((bond) => {
|
||||
const pairKey = [...bond.unitIds].sort().join('\u0000');
|
||||
if (seenPairs.has(pairKey)) {
|
||||
@@ -98,6 +107,22 @@ export function sortieBondBonusForLevel(level: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function coreSortieResonanceChainRateForLevel(level: number) {
|
||||
const normalizedLevel = Math.max(0, Math.floor(Number.isFinite(level) ? level : 0));
|
||||
return normalizedLevel >= 70 ? 28 : normalizedLevel >= 50 ? 18 : normalizedLevel >= coreSortieResonanceMinLevel ? 8 : 0;
|
||||
}
|
||||
|
||||
function sortiePursuitPairForBond(bond: SortieSynergyActiveBond, coreBondId?: string): SortiePursuitPair {
|
||||
const baseChainRate = bond.chainRate;
|
||||
const coreChainRate = bond.id === coreBondId ? coreSortieResonanceChainRateForLevel(bond.level) : 0;
|
||||
return {
|
||||
...bond,
|
||||
baseChainRate,
|
||||
chainRate: coreChainRate > 0 ? coreChainRate : baseChainRate,
|
||||
core: coreChainRate > 0
|
||||
};
|
||||
}
|
||||
|
||||
export function sortieTerrainGrade(score: number) {
|
||||
if (score >= 110) {
|
||||
return '최적';
|
||||
@@ -170,17 +195,23 @@ export function evaluateSortiePursuitPairs(options: {
|
||||
selectedUnitIds: readonly string[];
|
||||
selectableUnitIds?: readonly string[];
|
||||
battleId?: string;
|
||||
coreBondId?: string;
|
||||
}): SortiePursuitPairs {
|
||||
const current = evaluateSortieSynergy(options);
|
||||
const activeCoreBondId = current.activeBonds.some((bond) =>
|
||||
bond.id === options.coreBondId && bond.level >= coreSortieResonanceMinLevel
|
||||
) ? options.coreBondId : undefined;
|
||||
const activePairs = current.activeBonds.map((bond) => sortiePursuitPairForBond(bond, activeCoreBondId));
|
||||
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 selectableOpportunities = strongestSortiePursuitPairs(selectableUnitIds.flatMap<SortiePursuitOpportunity>((candidateUnitId) => {
|
||||
const projected = evaluateSortieSynergy({
|
||||
...options,
|
||||
selectedUnitIds: [...current.selectedUnitIds, candidateUnitId]
|
||||
});
|
||||
return projected.activeBonds.flatMap<SortiePursuitOpportunity>((bond) => {
|
||||
return projected.activeBonds.flatMap<SortiePursuitOpportunity>((activeBond) => {
|
||||
const bond = sortiePursuitPairForBond(activeBond, activeCoreBondId);
|
||||
if (bond.chainRate <= 0 || !bond.unitIds.includes(candidateUnitId)) {
|
||||
return [];
|
||||
}
|
||||
@@ -201,7 +232,7 @@ export function evaluateSortiePursuitPairs(options: {
|
||||
}));
|
||||
|
||||
return {
|
||||
activePairs: strongestSortieBondPairs(current.activeBonds.filter((bond) => bond.chainRate > 0)),
|
||||
activePairs: strongestSortiePursuitPairs(activePairs.filter((bond) => bond.chainRate > 0)),
|
||||
selectableOpportunities
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
type SortieFormationRole
|
||||
} from '../data/sortieDeployment';
|
||||
import {
|
||||
coreSortieResonanceChainRateForLevel,
|
||||
coreSortieResonanceMinLevel,
|
||||
coreSortieSynergyRoles,
|
||||
firstPursuitBrotherUnitIds,
|
||||
firstPursuitSynergyBattleId,
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
import {
|
||||
normalizeBattleSaveSlot,
|
||||
parseBattleSaveState,
|
||||
type BattleSaveCoreResonanceStats,
|
||||
type BattleSaveState,
|
||||
type BattleSaveTacticalCommandRole,
|
||||
type BattleSaveTacticalCommandState,
|
||||
@@ -1273,6 +1276,7 @@ type BattleSceneData = {
|
||||
sortieFormationAssignments?: SortieFormationAssignments;
|
||||
sortieItemAssignments?: CampaignSortieItemAssignments;
|
||||
sortieOrderId?: SortieOrderId;
|
||||
sortieResonanceBondId?: string;
|
||||
};
|
||||
|
||||
type CommandButton = {
|
||||
@@ -1314,6 +1318,7 @@ type BondChainCandidate = {
|
||||
partner: UnitData;
|
||||
rate: number;
|
||||
damage: number;
|
||||
coreResonance: boolean;
|
||||
};
|
||||
|
||||
type BondChainResult = {
|
||||
@@ -1325,6 +1330,7 @@ type BondChainResult = {
|
||||
damage: number;
|
||||
previousDefenderHp: number;
|
||||
defeated: boolean;
|
||||
coreResonance: boolean;
|
||||
};
|
||||
|
||||
type BondChainAttempt = {
|
||||
@@ -1335,6 +1341,7 @@ type BondChainAttempt = {
|
||||
rate: number;
|
||||
expectedDamage: number;
|
||||
succeeded: boolean;
|
||||
coreResonance: boolean;
|
||||
};
|
||||
|
||||
type BondChainJudgementSnapshot = {
|
||||
@@ -1351,6 +1358,7 @@ type BondChainJudgementSnapshot = {
|
||||
completed: boolean;
|
||||
motion: 'melee' | 'ranged';
|
||||
bounds: SortieOrderHudBounds | null;
|
||||
coreResonance: boolean;
|
||||
};
|
||||
|
||||
type UnitBattleStats = {
|
||||
@@ -1550,6 +1558,7 @@ type CombatPreview = {
|
||||
bondChainPartnerId?: string;
|
||||
bondChainPartnerName?: string;
|
||||
bondChainLabel?: string;
|
||||
bondChainCoreResonance: boolean;
|
||||
equipmentDamageBonus: number;
|
||||
equipmentDamageReduction: number;
|
||||
equipmentHitBonus: number;
|
||||
@@ -1660,6 +1669,7 @@ type BondChainReadyMarkerView = {
|
||||
damage: number;
|
||||
text: string;
|
||||
context: string;
|
||||
coreResonance: boolean;
|
||||
background: Phaser.GameObjects.Rectangle;
|
||||
label: Phaser.GameObjects.Text;
|
||||
};
|
||||
@@ -1673,6 +1683,7 @@ type BondChainReadyFocusedPreview = {
|
||||
locked: boolean;
|
||||
text: string;
|
||||
context: string;
|
||||
coreResonance: boolean;
|
||||
background: Phaser.GameObjects.Rectangle;
|
||||
titleText: Phaser.GameObjects.Text;
|
||||
contextText: Phaser.GameObjects.Text;
|
||||
@@ -3407,6 +3418,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private resultGaugeAnimations: ResultGaugeAnimation[] = [];
|
||||
private resultAnimationToken = 0;
|
||||
private resultSettlementText?: Phaser.GameObjects.Text;
|
||||
private resultCoreResonanceSummaryText?: Phaser.GameObjects.Text;
|
||||
private resultFormationReviewObjects: Phaser.GameObjects.GameObject[] = [];
|
||||
private resultFormationReviewVisible = false;
|
||||
private resultFormationReviewFeedback = '';
|
||||
@@ -3467,6 +3479,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
private launchSortieFormationAssignments: SortieFormationAssignments = {};
|
||||
private launchSortieItemAssignments: CampaignSortieItemAssignments = {};
|
||||
private launchSortieOrderId: SortieOrderId = 'elite';
|
||||
private launchSortieResonanceBondId?: string;
|
||||
private coreResonancePursuitAttempts = 0;
|
||||
private coreResonancePursuitSuccesses = 0;
|
||||
private coreResonancePursuitFailures = 0;
|
||||
private coreResonancePursuitDamage = 0;
|
||||
|
||||
constructor() {
|
||||
super('BattleScene');
|
||||
@@ -3481,10 +3498,17 @@ export class BattleScene extends Phaser.Scene {
|
||||
const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id
|
||||
? campaign.sortieOrderSelection.orderId
|
||||
: undefined;
|
||||
const selectedResonanceBondId = campaign.sortieResonanceSelection?.battleId === battleScenario.id
|
||||
? campaign.sortieResonanceSelection.bondId
|
||||
: undefined;
|
||||
this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(data?.selectedSortieUnitIds);
|
||||
this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(data?.sortieFormationAssignments);
|
||||
this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(data?.sortieItemAssignments);
|
||||
this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(data?.sortieOrderId ?? selectedOrderId);
|
||||
this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId(
|
||||
data?.sortieResonanceBondId ?? selectedResonanceBondId
|
||||
);
|
||||
this.resetCoreResonancePursuitStats();
|
||||
}
|
||||
|
||||
create() {
|
||||
@@ -3513,6 +3537,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
false
|
||||
);
|
||||
this.bondStates = this.createBondStates();
|
||||
this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId(
|
||||
this.launchSortieResonanceBondId
|
||||
);
|
||||
this.resetCoreResonancePursuitStats();
|
||||
this.itemStocks = this.createItemStocks(campaign);
|
||||
this.battleBuffs.clear();
|
||||
this.battleStatuses.clear();
|
||||
@@ -3958,6 +3986,52 @@ export class BattleScene extends Phaser.Scene {
|
||||
return orderId === 'mobile' || orderId === 'siege' ? orderId : 'elite';
|
||||
}
|
||||
|
||||
private normalizeLaunchSortieResonanceBondId(bondId?: string) {
|
||||
const normalized = bondId?.trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
private validatedLaunchSortieResonanceBondId(bondId?: string) {
|
||||
if (!bondId) {
|
||||
return undefined;
|
||||
}
|
||||
const bond = this.bondStates.get(bondId);
|
||||
if (!bond || bond.level < coreSortieResonanceMinLevel) {
|
||||
return undefined;
|
||||
}
|
||||
const deployedAllies = new Set(
|
||||
battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)
|
||||
);
|
||||
return bond.unitIds.every((unitId) => deployedAllies.has(unitId)) ? bond.id : undefined;
|
||||
}
|
||||
|
||||
private resetCoreResonancePursuitStats() {
|
||||
this.coreResonancePursuitAttempts = 0;
|
||||
this.coreResonancePursuitSuccesses = 0;
|
||||
this.coreResonancePursuitFailures = 0;
|
||||
this.coreResonancePursuitDamage = 0;
|
||||
}
|
||||
|
||||
private coreResonancePursuitStatsSnapshot(): BattleSaveCoreResonanceStats {
|
||||
return {
|
||||
attempts: this.coreResonancePursuitAttempts,
|
||||
successes: this.coreResonancePursuitSuccesses,
|
||||
failures: this.coreResonancePursuitFailures,
|
||||
additionalDamage: this.coreResonancePursuitDamage
|
||||
};
|
||||
}
|
||||
|
||||
private restoreCoreResonancePursuitStats(stats?: BattleSaveCoreResonanceStats) {
|
||||
if (!stats) {
|
||||
this.resetCoreResonancePursuitStats();
|
||||
return;
|
||||
}
|
||||
this.coreResonancePursuitAttempts = stats.attempts;
|
||||
this.coreResonancePursuitSuccesses = stats.successes;
|
||||
this.coreResonancePursuitFailures = stats.failures;
|
||||
this.coreResonancePursuitDamage = stats.additionalDamage;
|
||||
}
|
||||
|
||||
private effectiveSortieUnitIds(campaign?: CampaignState) {
|
||||
return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? [];
|
||||
}
|
||||
@@ -4094,10 +4168,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (this.firstPursuitTrinityConfigured()) {
|
||||
lines.push('도원 공명 · 세 형제 생존 중 공명 지원 거리 2칸');
|
||||
}
|
||||
const strongestBond = this.activeSortieBonds()[0];
|
||||
const activeBonds = this.activeSortieBonds();
|
||||
const strongestBond = activeBonds.find((bond) => this.isCoreSortieResonanceBond(bond)) ?? activeBonds[0];
|
||||
if (strongestBond) {
|
||||
const bonus = sortieBondBonusForLevel(strongestBond.level);
|
||||
lines.push(`공명 ${strongestBond.title} Lv${strongestBond.level} · 피해 +${bonus.damageBonus}%${bonus.chainRate > 0 ? ` · 연계 ${bonus.chainRate}%` : ''}`);
|
||||
const chainRate = this.bondChainRateForBond(strongestBond);
|
||||
const resonanceLabel = this.isCoreSortieResonanceBond(strongestBond) ? '핵심 공명' : '공명';
|
||||
lines.push(`${resonanceLabel} ${strongestBond.title} Lv${strongestBond.level} · 피해 +${bonus.damageBonus}%${chainRate > 0 ? ` · 연계 ${chainRate}%` : ''}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -9356,7 +9433,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
icon: endsBattleWithPendingBonus ? 'failure' : this.previewDamageIcon(preview),
|
||||
name: target.name,
|
||||
meta: bondChainReady
|
||||
? `${getUnitClass(target.classKey).name} · 추격 후보 ${bondChainReady.partnerName} ${bondChainReady.rate}%`
|
||||
? `${getUnitClass(target.classKey).name} · ${bondChainReady.coreResonance ? '핵심 추격' : '추격 후보'} ${bondChainReady.partnerName} ${bondChainReady.rate}%`
|
||||
: `${getUnitClass(target.classKey).name} · 거리 ${distance}/${range}`,
|
||||
primary: `${preview.damage} 피해`,
|
||||
secondary: warningText ?? bondChainReady?.text ?? `${preview.hitRate}% ${successLabel} · ${counterLabel}`,
|
||||
@@ -9367,7 +9444,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
endsBattleWithPendingBonus
|
||||
? { icon: 'failure', label: '보조', value: '미확보', tone: 0xd8732c }
|
||||
: bondChainReady
|
||||
? { icon: 'leadership', label: '추격', value: `${bondChainReady.rate}% · +${bondChainReady.damage}`, tone: palette.gold }
|
||||
? { icon: 'leadership', label: bondChainReady.coreResonance ? '핵심' : '추격', value: `${bondChainReady.rate}% · +${bondChainReady.damage}`, tone: palette.gold }
|
||||
: { icon: 'counter', label: '반격', value: preview.counterAvailable ? '주의' : '없음', tone: preview.counterAvailable ? 0xd8732c : 0x59d18c }
|
||||
]
|
||||
};
|
||||
@@ -9386,7 +9463,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
],
|
||||
candidates,
|
||||
overflowCount: Math.max(0, targets.length - candidates.length),
|
||||
footer: hasBondChainReady ? '금색 공명 · 명중 후 생존 시 확률 판정' : '표시된 적 선택 · 확정 전 예측 확인',
|
||||
footer: hasBondChainReady
|
||||
? orderedTargets.some(({ bondChainReady }) => bondChainReady?.coreResonance)
|
||||
? '핵심 공명 우선 · 명중 후 생존 시 확률 판정'
|
||||
: '금색 공명 · 명중 후 생존 시 확률 판정'
|
||||
: '표시된 적 선택 · 확정 전 예측 확인',
|
||||
notice: notice ?? objectiveWarningNotice
|
||||
});
|
||||
}
|
||||
@@ -10009,8 +10090,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
partnerName: partner.name,
|
||||
rate: preview.bondChainRate,
|
||||
damage: preview.bondChainDamage,
|
||||
text: `추격 후보 ${partner.name} ${preview.bondChainRate}% · 예상 +${preview.bondChainDamage}`,
|
||||
context: '명중 후 대상 생존 시 · 지원 거리 충족'
|
||||
text: `${preview.bondChainCoreResonance ? '핵심 추격' : '추격 후보'} ${partner.name} ${preview.bondChainRate}% · 예상 +${preview.bondChainDamage}`,
|
||||
context: preview.bondChainCoreResonance
|
||||
? '핵심 공명 우선 · 명중 후 대상 생존 시'
|
||||
: '명중 후 대상 생존 시 · 지원 거리 충족',
|
||||
coreResonance: preview.bondChainCoreResonance
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10087,7 +10171,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const label = this.add.text(
|
||||
this.tileTopLeftX(preview.defender.x) + offsetX - width / 2,
|
||||
this.tileTopLeftY(preview.defender.y) + offsetY + height / 2,
|
||||
'공명',
|
||||
ready.coreResonance ? '핵심' : '공명',
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '10px',
|
||||
@@ -11477,6 +11561,23 @@ export class BattleScene extends Phaser.Scene {
|
||||
hiddenSummary.setDepth(depth + 2);
|
||||
}
|
||||
|
||||
if (this.launchSortieResonanceBondId) {
|
||||
const coreResonanceSummary = this.trackResultObject(this.add.text(
|
||||
left + panelWidth - 44,
|
||||
top + 372,
|
||||
`핵심 공명 · 시도 ${this.coreResonancePursuitAttempts} · 성공 ${this.coreResonancePursuitSuccesses} · 추가 피해 +${this.coreResonancePursuitDamage}`,
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '10px',
|
||||
color: '#ffe79a',
|
||||
fontStyle: '700'
|
||||
}
|
||||
));
|
||||
coreResonanceSummary.setOrigin(1, 0);
|
||||
coreResonanceSummary.setDepth(depth + 3);
|
||||
this.resultCoreResonanceSummaryText = coreResonanceSummary;
|
||||
}
|
||||
|
||||
visibleAllies.forEach((unit, index) => {
|
||||
this.renderResultUnitRow(unit, left + 44, top + 386 + index * 70, panelWidth - 88, depth + 2);
|
||||
});
|
||||
@@ -11546,7 +11647,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => {
|
||||
soundDirector.playSelect();
|
||||
this.scene.restart({ battleId: battleScenario.id, sortieOrderId: this.launchSortieOrderId });
|
||||
this.scene.restart({
|
||||
battleId: battleScenario.id,
|
||||
sortieOrderId: this.launchSortieOrderId,
|
||||
sortieResonanceBondId: this.launchSortieResonanceBondId
|
||||
});
|
||||
}, depth + 3);
|
||||
this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => {
|
||||
soundDirector.playSelect();
|
||||
@@ -11573,6 +11678,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.resultObjects = [];
|
||||
this.resultGaugeAnimations = [];
|
||||
this.resultSettlementText = undefined;
|
||||
this.resultCoreResonanceSummaryText = undefined;
|
||||
}
|
||||
|
||||
private objectiveCategory(objective: BattleObjectiveDefinition): BattleObjectiveResult['category'] {
|
||||
@@ -13652,6 +13758,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
bondChainPartnerId: bondChainCandidate?.partner.id,
|
||||
bondChainPartnerName: bondChainCandidate?.partner.name,
|
||||
bondChainLabel: bondChainCandidate?.label,
|
||||
bondChainCoreResonance: bondChainCandidate?.coreResonance ?? false,
|
||||
equipmentDamageBonus: equipmentEffect.damageBonus,
|
||||
equipmentDamageReduction: equipmentEffect.damageReduction,
|
||||
equipmentHitBonus: equipmentEffect.hitBonus,
|
||||
@@ -13790,9 +13897,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
partnerName: partner.name,
|
||||
rate: preview.bondChainRate,
|
||||
expectedDamage: preview.bondChainDamage,
|
||||
succeeded
|
||||
succeeded,
|
||||
coreResonance: preview.bondChainCoreResonance
|
||||
};
|
||||
if (attempt.coreResonance) {
|
||||
this.coreResonancePursuitAttempts += 1;
|
||||
}
|
||||
if (!succeeded) {
|
||||
if (attempt.coreResonance) {
|
||||
this.coreResonancePursuitFailures += 1;
|
||||
}
|
||||
return { attempt };
|
||||
}
|
||||
|
||||
@@ -13802,6 +13916,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
const defeated = preview.defender.hp <= 0;
|
||||
this.recordBondChainStats(partner, preview.defender, damage, defeated);
|
||||
this.faceUnitToward(partner, preview.defender);
|
||||
if (attempt.coreResonance) {
|
||||
this.coreResonancePursuitSuccesses += 1;
|
||||
this.coreResonancePursuitDamage += damage;
|
||||
}
|
||||
|
||||
return {
|
||||
attempt,
|
||||
@@ -13813,7 +13931,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
rate: preview.bondChainRate,
|
||||
damage,
|
||||
previousDefenderHp,
|
||||
defeated
|
||||
defeated,
|
||||
coreResonance: attempt.coreResonance
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14212,8 +14331,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
|
||||
const summaryBondChain = result.bondChainAttempt
|
||||
? result.bondChain
|
||||
? `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 일치\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}`
|
||||
: `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
|
||||
? `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 일치\n${result.bondChain.coreResonance ? '핵심 공명' : '공명'} 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}`
|
||||
: `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
|
||||
: '';
|
||||
const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
|
||||
return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`;
|
||||
@@ -14246,7 +14365,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private bondChainMapCueLabel(result: CombatResult) {
|
||||
if (result.bondChainAttempt) {
|
||||
return `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%`;
|
||||
return `${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%`;
|
||||
}
|
||||
if (result.bondExtendedRange) {
|
||||
return '삼재진 · 2칸 공명';
|
||||
@@ -15569,6 +15688,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
state.sortieOrderId ?? (
|
||||
campaign.sortieOrderSelection?.battleId === battleScenario.id
|
||||
? campaign.sortieOrderSelection.orderId
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId(
|
||||
state.sortieResonanceBondId ?? (
|
||||
campaign.sortieResonanceSelection?.battleId === battleScenario.id
|
||||
? campaign.sortieResonanceSelection.bondId
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
@@ -15585,6 +15711,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
battleId: battleScenario.id,
|
||||
campaignStep: getCampaignState().step,
|
||||
sortieOrderId: this.launchSortieOrderId,
|
||||
sortieResonanceBondId: this.launchSortieResonanceBondId,
|
||||
coreResonanceStats: this.launchSortieResonanceBondId
|
||||
? { ...this.coreResonancePursuitStatsSnapshot() }
|
||||
: undefined,
|
||||
savedAt: new Date().toISOString(),
|
||||
turnNumber: this.turnNumber,
|
||||
activeFaction: this.activeFaction,
|
||||
@@ -15641,6 +15771,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.phase = 'idle';
|
||||
|
||||
this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(state.sortieOrderId ?? this.launchSortieOrderId);
|
||||
this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId(
|
||||
state.sortieResonanceBondId ?? this.launchSortieResonanceBondId
|
||||
);
|
||||
this.resetCoreResonancePursuitStats();
|
||||
this.turnNumber = Math.max(1, state.turnNumber || 1);
|
||||
this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally';
|
||||
this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally';
|
||||
@@ -15692,6 +15826,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.restoreUnitView(unit, savedUnit.direction);
|
||||
});
|
||||
|
||||
this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId(
|
||||
this.launchSortieResonanceBondId
|
||||
);
|
||||
if (this.launchSortieResonanceBondId) {
|
||||
this.restoreCoreResonancePursuitStats(state.coreResonanceStats);
|
||||
}
|
||||
|
||||
const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0];
|
||||
if (cameraFocus) {
|
||||
this.centerCameraOnTile(cameraFocus.x, cameraFocus.y);
|
||||
@@ -16795,7 +16936,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
return { icon: 'counter', label: '퇴각', tone: 0xd8732c };
|
||||
}
|
||||
if (result.bondChainAttempt) {
|
||||
return { icon: 'leadership', label: `판정 ${result.bondChainAttempt.rate}%`, tone: palette.gold };
|
||||
return {
|
||||
icon: 'leadership',
|
||||
label: `${result.bondChainAttempt.coreResonance ? '핵심 ' : ''}판정 ${result.bondChainAttempt.rate}%`,
|
||||
tone: palette.gold
|
||||
};
|
||||
}
|
||||
if (this.combatCounterWillTrigger(result)) {
|
||||
return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 };
|
||||
@@ -18542,7 +18687,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
line.strokePath();
|
||||
}
|
||||
const partnerLabel = result.bondChainAttempt
|
||||
? `${partner.name} · 판정 후보`
|
||||
? `${partner.name} · ${result.bondChainAttempt.coreResonance ? '핵심 판정' : '판정 후보'}`
|
||||
: result.bondExtendedRange
|
||||
? `${partner.name} · 2칸`
|
||||
: `${partner.name} ♥ 공명`;
|
||||
@@ -18645,7 +18790,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const width = 372;
|
||||
const height = 66;
|
||||
const tone = succeeded ? palette.gold : 0xa58f69;
|
||||
const title = `공명 판정 ${attempt.rate}%`;
|
||||
const title = `${attempt.coreResonance ? '핵심 공명' : '공명'} 판정 ${attempt.rate}%`;
|
||||
const detail = `${attempt.partnerName} · ${succeeded ? '호흡 일치' : '호흡 불발'}`;
|
||||
const card = this.trackCombatObject(this.add.container(textX, textY));
|
||||
card.setDepth(depth + 4);
|
||||
@@ -18687,7 +18832,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
presented: true,
|
||||
completed: false,
|
||||
motion,
|
||||
bounds: { x: textX - width / 2, y: textY - height / 2, width, height }
|
||||
bounds: { x: textX - width / 2, y: textY - height / 2, width, height },
|
||||
coreResonance: attempt.coreResonance
|
||||
};
|
||||
|
||||
soundDirector.playEffect('bond-resonance', {
|
||||
@@ -18762,7 +18908,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const bannerIconFrame = this.add.circle(-bannerWidth / 2 + 39, 0, 22, 0x0a0f14, 0.96);
|
||||
bannerIconFrame.setStrokeStyle(1, palette.gold, 0.82);
|
||||
const bannerIcon = this.createBattleUiIcon(-bannerWidth / 2 + 39, 0, 'leadership', 39);
|
||||
const bannerTitle = this.add.text(-bannerWidth / 2 + 72, -23, `공명 추격 · ${chain.partnerName}`, {
|
||||
const bannerTitle = this.add.text(-bannerWidth / 2 + 72, -23, `${chain.coreResonance ? '핵심 공명' : '공명'} 추격 · ${chain.partnerName}`, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '20px',
|
||||
color: '#fff2b8',
|
||||
@@ -18828,7 +18974,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
onImpact?.();
|
||||
this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5);
|
||||
|
||||
const finishTitle = chain.defeated ? `공명 격파 · ${chain.partnerName}` : `공명 추격 · ${chain.partnerName}`;
|
||||
const chainTitle = chain.coreResonance ? '핵심 공명' : '공명';
|
||||
const finishTitle = chain.defeated ? `${chainTitle} 격파 · ${chain.partnerName}` : `${chainTitle} 추격 · ${chain.partnerName}`;
|
||||
const finishDetail = `추격 -${chain.damage}`;
|
||||
bannerTitle.setText(finishTitle);
|
||||
bannerDetail.setText(finishDetail);
|
||||
@@ -18849,7 +18996,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const damageText = this.trackCombatObject(this.add.text(
|
||||
defenderX - 12,
|
||||
groundY - 112,
|
||||
chain.defeated ? `공명 격파\n추격 -${chain.damage}` : `추격 -${chain.damage}`,
|
||||
chain.defeated ? `${chain.coreResonance ? '핵심 격파' : '공명 격파'}\n추격 -${chain.damage}` : `${chain.coreResonance ? '핵심 ' : ''}추격 -${chain.damage}`,
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: chain.defeated ? '26px' : '23px',
|
||||
@@ -19720,6 +19867,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private isCoreSortieResonanceBond(bond: Pick<BondState, 'id' | 'level'>) {
|
||||
return bond.id === this.launchSortieResonanceBondId && bond.level >= coreSortieResonanceMinLevel;
|
||||
}
|
||||
|
||||
private bondChainRateForBond(bond: Pick<BondState, 'id' | 'level'>) {
|
||||
return this.isCoreSortieResonanceBond(bond)
|
||||
? coreSortieResonanceChainRateForLevel(bond.level)
|
||||
: sortieBondBonusForLevel(bond.level).chainRate;
|
||||
}
|
||||
|
||||
private bondChainPartnerEligible(
|
||||
attacker: UnitData,
|
||||
partner: UnitData,
|
||||
@@ -19739,7 +19896,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
attacker.hp <= 0 ||
|
||||
partner.hp <= 0 ||
|
||||
defender.hp <= 0 ||
|
||||
sortieBondBonusForLevel(bond.level).chainRate <= 0 ||
|
||||
this.bondChainRateForBond(bond) <= 0 ||
|
||||
!actedUnitIds.has(partner.id) ||
|
||||
!intents.some((intent) => intent.attackerId === partner.id && intent.targetId === defender.id)
|
||||
) {
|
||||
@@ -19769,10 +19926,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (!partner || !this.bondChainPartnerEligible(attacker, partner, defender, bond, action, isCounter)) {
|
||||
return undefined;
|
||||
}
|
||||
return { bond, partner };
|
||||
return { bond, partner, coreResonance: this.isCoreSortieResonanceBond(bond) };
|
||||
})
|
||||
.filter((candidate): candidate is { bond: BondState; partner: UnitData } => Boolean(candidate))
|
||||
.sort((left, right) => right.bond.level - left.bond.level || left.bond.id.localeCompare(right.bond.id));
|
||||
.filter((candidate): candidate is { bond: BondState; partner: UnitData; coreResonance: boolean } => Boolean(candidate))
|
||||
.sort((left, right) => (
|
||||
Number(right.coreResonance) - Number(left.coreResonance) ||
|
||||
right.bond.level - left.bond.level ||
|
||||
left.bond.id.localeCompare(right.bond.id)
|
||||
));
|
||||
|
||||
const strongest = candidates[0];
|
||||
if (!strongest) {
|
||||
@@ -19782,10 +19943,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
const [first, second] = strongest.bond.unitIds.map((id) => this.unitName(id));
|
||||
return {
|
||||
bondId: strongest.bond.id,
|
||||
label: `${strongest.bond.title}: ${first}·${second}`,
|
||||
label: `${strongest.coreResonance ? '핵심 공명 · ' : ''}${strongest.bond.title}: ${first}·${second}`,
|
||||
partner: strongest.partner,
|
||||
rate: sortieBondBonusForLevel(strongest.bond.level).chainRate,
|
||||
damage: this.bondChainSupportDamage(strongest.partner, defender)
|
||||
rate: this.bondChainRateForBond(strongest.bond),
|
||||
damage: this.bondChainSupportDamage(strongest.partner, defender),
|
||||
coreResonance: strongest.coreResonance
|
||||
} satisfies BondChainCandidate;
|
||||
}
|
||||
|
||||
@@ -20764,9 +20926,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
const effectLine = [
|
||||
result.bondChain
|
||||
? `${result.bondChain.defeated ? '공명 격파' : '공명 추격'} ${result.bondChain.partnerName} +${result.bondChain.damage}`
|
||||
? `${result.bondChain.coreResonance ? '핵심 ' : '공명 '}${result.bondChain.defeated ? '격파' : '추격'} ${result.bondChain.partnerName} +${result.bondChain.damage}`
|
||||
: result.bondChainAttempt
|
||||
? `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
|
||||
? `${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
|
||||
: '',
|
||||
this.statusEffectMapText(result.statusApplied),
|
||||
this.combatSortieContributionText(result),
|
||||
@@ -20793,6 +20955,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
private combatMapDamageTitle(result: CombatResult) {
|
||||
if (result.bondChain) {
|
||||
const totalDamage = result.damage + result.bondChain.damage;
|
||||
if (result.bondChain.coreResonance) {
|
||||
return result.bondChain.defeated ? `핵심 공명 격파 -${totalDamage}` : `핵심 합동 피해 -${totalDamage}`;
|
||||
}
|
||||
return result.bondChain.defeated ? `공명 격파 -${totalDamage}` : `합동 피해 -${totalDamage}`;
|
||||
}
|
||||
const damage = `-${result.damage}`;
|
||||
@@ -22622,6 +22787,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
partnerName: view.partnerName,
|
||||
rate: view.rate,
|
||||
damage: view.damage,
|
||||
coreResonance: view.coreResonance,
|
||||
text: view.text,
|
||||
context: view.context,
|
||||
targetMarkerId: view.background.name,
|
||||
@@ -22652,6 +22818,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
partnerName: this.bondChainReadyFocusedPreview.partnerName,
|
||||
rate: this.bondChainReadyFocusedPreview.rate,
|
||||
damage: this.bondChainReadyFocusedPreview.damage,
|
||||
coreResonance: this.bondChainReadyFocusedPreview.coreResonance,
|
||||
locked: this.bondChainReadyFocusedPreview.locked,
|
||||
text: this.bondChainReadyFocusedPreview.text,
|
||||
context: this.bondChainReadyFocusedPreview.context,
|
||||
@@ -22673,6 +22840,43 @@ export class BattleScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
private coreSortieResonanceDebugState() {
|
||||
const selectedBondId = this.launchSortieResonanceBondId;
|
||||
const bond = selectedBondId ? this.bondStates.get(selectedBondId) : undefined;
|
||||
const active = Boolean(
|
||||
bond &&
|
||||
this.isCoreSortieResonanceBond(bond) &&
|
||||
bond.unitIds.every((unitId) => (
|
||||
battleUnits.some((unit) => unit.id === unitId && unit.faction === 'ally' && unit.hp > 0)
|
||||
))
|
||||
);
|
||||
return {
|
||||
selected: Boolean(selectedBondId),
|
||||
selectedBondId: selectedBondId ?? null,
|
||||
valid: Boolean(bond),
|
||||
active,
|
||||
bondId: bond?.id ?? null,
|
||||
title: bond?.title ?? null,
|
||||
unitIds: bond ? [...bond.unitIds] : [],
|
||||
unitNames: bond ? bond.unitIds.map((unitId) => this.unitName(unitId)) : [],
|
||||
level: bond?.level ?? null,
|
||||
rate: bond ? this.bondChainRateForBond(bond) : 0,
|
||||
stats: {
|
||||
attempts: this.coreResonancePursuitAttempts,
|
||||
successes: this.coreResonancePursuitSuccesses,
|
||||
failures: this.coreResonancePursuitFailures,
|
||||
additionalDamage: this.coreResonancePursuitDamage
|
||||
},
|
||||
resultSummary: this.resultCoreResonanceSummaryText?.active
|
||||
? {
|
||||
visible: this.resultCoreResonanceSummaryText.visible,
|
||||
text: this.resultCoreResonanceSummaryText.text,
|
||||
bounds: this.gameObjectBoundsDebug(this.resultCoreResonanceSummaryText)
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private bondChainJudgementDebugState() {
|
||||
return this.bondChainJudgementLast
|
||||
? {
|
||||
@@ -22839,6 +23043,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
? this.resultFormationSourcePresetIds(resultFormationReview.snapshot)
|
||||
: [];
|
||||
const sortieOrderDebug = this.resultSortieOrderDebugState();
|
||||
const sortieResonanceDebug = this.coreSortieResonanceDebugState();
|
||||
|
||||
return {
|
||||
scene: this.scene.key,
|
||||
@@ -22851,6 +23056,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
sortieItemAssignments,
|
||||
sortieOrder: sortieOrderDebug,
|
||||
sortieOperationOrder: sortieOrderDebug,
|
||||
sortieResonance: sortieResonanceDebug,
|
||||
coreSortieResonance: sortieResonanceDebug,
|
||||
battleHud: this.battleHudDebugState(),
|
||||
sortieDoctrine: this.debugSortieDoctrine(),
|
||||
firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(),
|
||||
@@ -23132,7 +23339,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private debugCombatMechanicsProbe() {
|
||||
const bond = Array.from(this.bondStates.values()).find((candidate) => {
|
||||
const units = candidate.unitIds.map((unitId) => battleUnits.find((unit) => unit.id === unitId && unit.hp > 0));
|
||||
return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && sortieBondBonusForLevel(candidate.level).chainRate > 0;
|
||||
return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && this.bondChainRateForBond(candidate) > 0;
|
||||
});
|
||||
const attacker = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[0] && unit.hp > 0) : undefined;
|
||||
const partner = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[1] && unit.hp > 0) : undefined;
|
||||
@@ -23164,11 +23371,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
const counterIncomingPreview = this.combatPreview(enemy, attacker, 'attack', undefined, true);
|
||||
const matchingIntent = [{ attackerId: syntheticPartner.id, targetId: syntheticEnemy.id }];
|
||||
const syntheticActedUnitIds = new Set([syntheticPartner.id]);
|
||||
const chainBonus = sortieBondBonusForLevel(bond.level);
|
||||
const chainRate = this.bondChainRateForBond(bond);
|
||||
const supportDamage = this.bondChainSupportDamage(partner, enemy);
|
||||
const chainGatePreview: CombatPreview = {
|
||||
...adjacentPreview,
|
||||
bondChainRate: chainBonus.chainRate,
|
||||
bondChainRate: chainRate,
|
||||
bondChainDamage: supportDamage,
|
||||
bondChainBondId: bond.id,
|
||||
bondChainPartnerId: syntheticPartner.id,
|
||||
@@ -23191,7 +23398,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
normalDamage: normalIncomingPreview.damage,
|
||||
counterDamage: counterIncomingPreview.damage,
|
||||
chain: {
|
||||
rate: chainBonus.chainRate,
|
||||
rate: chainRate,
|
||||
coreResonance: this.isCoreSortieResonanceBond(bond),
|
||||
expectedSupporterId: syntheticPartner.id,
|
||||
supportDamage,
|
||||
ineligibleWithoutPriorIntent: !this.bondChainPartnerEligible(
|
||||
@@ -23237,8 +23445,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
missedBaseBlocked: !this.canResolveBondChain(chainGatePreview, false, syntheticEnemy.hp),
|
||||
lethalBaseBlocked: !this.canResolveBondChain(chainGatePreview, true, 0),
|
||||
survivingBaseAllowed: this.canResolveBondChain(chainGatePreview, true, syntheticEnemy.hp),
|
||||
forcedSuccessTriggers: this.bondChainRollSucceeded(chainBonus.chainRate, true),
|
||||
forcedFailureBlocked: !this.bondChainRollSucceeded(chainBonus.chainRate, false),
|
||||
forcedSuccessTriggers: this.bondChainRollSucceeded(chainRate, true),
|
||||
forcedFailureBlocked: !this.bondChainRollSucceeded(chainRate, false),
|
||||
statCredit,
|
||||
followUpKillCancelsCounter: !this.canCounterAttack(
|
||||
{ ...syntheticEnemy, hp: 0 },
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
} from '../data/sortieDeployment';
|
||||
import {
|
||||
compareSortieSynergy,
|
||||
coreSortieResonanceChainRateForLevel,
|
||||
coreSortieResonanceMinLevel,
|
||||
coreSortieSynergyRoles,
|
||||
evaluateSortiePursuitPairs,
|
||||
evaluateSortieSynergy,
|
||||
@@ -146,6 +148,7 @@ import {
|
||||
listCampaignSaveSlots,
|
||||
campaignSaveSlotCount,
|
||||
saveCampaignState,
|
||||
setCampaignSortieResonanceSelection,
|
||||
setCampaignSortieOrderSelection,
|
||||
setCampaignReserveTrainingAssignment,
|
||||
setCampaignReserveTrainingFocus,
|
||||
@@ -414,14 +417,32 @@ type SortiePursuitPanelView = {
|
||||
background: Phaser.GameObjects.Rectangle;
|
||||
summaryText: Phaser.GameObjects.Text;
|
||||
ruleText: Phaser.GameObjects.Text;
|
||||
page: number;
|
||||
pageCount: number;
|
||||
prevEnabled: boolean;
|
||||
nextEnabled: boolean;
|
||||
prevButton: Phaser.GameObjects.Rectangle;
|
||||
prevLabel: Phaser.GameObjects.Text;
|
||||
nextButton: Phaser.GameObjects.Rectangle;
|
||||
nextLabel: Phaser.GameObjects.Text;
|
||||
pageText: Phaser.GameObjects.Text;
|
||||
rows: {
|
||||
state: 'candidate';
|
||||
bondId: string;
|
||||
level: number;
|
||||
chainRate: number;
|
||||
selected: boolean;
|
||||
state: 'selected' | 'candidate';
|
||||
text: string;
|
||||
background: Phaser.GameObjects.Rectangle;
|
||||
label: Phaser.GameObjects.Text;
|
||||
}[];
|
||||
};
|
||||
|
||||
type SortieCoreResonanceCandidate = SortieSynergyActiveBond & {
|
||||
coreChainRate: number;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
type SortieConfigurationSnapshot = {
|
||||
selectedUnitIds: string[];
|
||||
formationAssignments: SortieFormationAssignments;
|
||||
@@ -11038,6 +11059,10 @@ export class CampScene extends Phaser.Scene {
|
||||
private sortieOrderToggleButton?: Phaser.GameObjects.Rectangle;
|
||||
private sortieComparisonPanelView?: SortieComparisonPanelView;
|
||||
private sortiePursuitPanelView?: SortiePursuitPanelView;
|
||||
private sortieCoreResonancePage = 0;
|
||||
private sortieCoreResonanceSelectorOpen = false;
|
||||
private sortieCoreResonanceToggleButton?: Phaser.GameObjects.Rectangle;
|
||||
private sortieCoreResonanceToggleLabel?: Phaser.GameObjects.Text;
|
||||
private sortieRosterScroll = 0;
|
||||
private sortiePlanFeedback = '';
|
||||
private sortiePrepStep: SortiePrepStep = 'briefing';
|
||||
@@ -11060,6 +11085,10 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortieObjects = [];
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
this.sortiePursuitPanelView = undefined;
|
||||
this.sortieCoreResonancePage = 0;
|
||||
this.sortieCoreResonanceSelectorOpen = false;
|
||||
this.sortieCoreResonanceToggleButton = undefined;
|
||||
this.sortieCoreResonanceToggleLabel = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.sortieSwapUndoState = undefined;
|
||||
this.sortieFormationPanelMode = 'roster';
|
||||
@@ -12787,6 +12816,10 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
this.sortiePrepStep = step;
|
||||
if (step !== 'formation') {
|
||||
this.sortieCoreResonanceSelectorOpen = false;
|
||||
this.sortieCoreResonancePage = 0;
|
||||
}
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
this.closeSortieFormationPanelBrowser();
|
||||
@@ -13449,6 +13482,30 @@ export class CampScene extends Phaser.Scene {
|
||||
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 toggleWidth = 96;
|
||||
const toggleX = x + Math.floor(width / 2) - Math.floor(toggleWidth / 2);
|
||||
const toggleFill = this.sortieCoreResonanceSelectorOpen ? 0x304333 : 0x18232e;
|
||||
const toggleStroke = this.sortieCoreResonanceSelectorOpen ? palette.green : palette.blue;
|
||||
const toggleButton = this.trackSortie(this.add.rectangle(toggleX, y + 6, toggleWidth, 19, toggleFill, 0.98));
|
||||
toggleButton.setOrigin(0);
|
||||
toggleButton.setDepth(depth + 2);
|
||||
toggleButton.setStrokeStyle(1, toggleStroke, this.sortieCoreResonanceSelectorOpen ? 0.88 : 0.64);
|
||||
toggleButton.setInteractive({ useHandCursor: true });
|
||||
toggleButton.on('pointerover', () => toggleButton.setFillStyle(this.sortieCoreResonanceSelectorOpen ? 0x3a5540 : 0x263442, 1).setStrokeStyle(1, palette.gold, 0.96));
|
||||
toggleButton.on('pointerout', () => toggleButton.setFillStyle(toggleFill, 0.98).setStrokeStyle(1, toggleStroke, this.sortieCoreResonanceSelectorOpen ? 0.88 : 0.64));
|
||||
toggleButton.on('pointerdown', () => this.toggleCampaignSortieCoreResonanceSelector());
|
||||
const toggleLabel = this.trackSortie(
|
||||
this.add.text(
|
||||
toggleX + toggleWidth / 2,
|
||||
y + 15.5,
|
||||
this.sortieCoreResonanceSelectorOpen ? '편성 비교' : '핵심 공명조',
|
||||
this.textStyle(9, this.sortieCoreResonanceSelectorOpen ? '#e7ffd9' : '#d8ecff', true)
|
||||
)
|
||||
);
|
||||
toggleLabel.setOrigin(0.5);
|
||||
toggleLabel.setDepth(depth + 3);
|
||||
this.sortieCoreResonanceToggleButton = toggleButton;
|
||||
this.sortieCoreResonanceToggleLabel = toggleLabel;
|
||||
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));
|
||||
@@ -13497,6 +13554,16 @@ export class CampScene extends Phaser.Scene {
|
||||
impact.setDepth(depth + 1);
|
||||
const detail = this.trackSortie(this.add.text(x + 18, y + 106, '', this.textStyle(9, '#9fb0bf')));
|
||||
detail.setDepth(depth + 1);
|
||||
const coreCandidates = this.sortieCoreResonanceCandidates();
|
||||
const pursuitPageView = this.renderSortieCoreResonanceChips(
|
||||
coreCandidates,
|
||||
x + 18,
|
||||
y + 51,
|
||||
width - 36,
|
||||
depth + 2,
|
||||
x + width - 84,
|
||||
y + 104
|
||||
);
|
||||
|
||||
this.sortieComparisonPanelView = {
|
||||
background: bg,
|
||||
@@ -13509,17 +13576,66 @@ export class CampScene extends Phaser.Scene {
|
||||
actionButton,
|
||||
actionLabel
|
||||
};
|
||||
this.sortiePursuitPanelView = {
|
||||
background: bg,
|
||||
summaryText: title,
|
||||
ruleText: detail,
|
||||
...pursuitPageView
|
||||
};
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
}
|
||||
|
||||
private toggleCampaignSortieCoreResonanceSelector() {
|
||||
this.sortieCoreResonanceSelectorOpen = !this.sortieCoreResonanceSelectorOpen;
|
||||
if (this.sortieCoreResonanceSelectorOpen) {
|
||||
this.closeSortieFormationPanelBrowser();
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
}
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private setCampaignSortieCoreSelectorVisible(visible: boolean) {
|
||||
const pursuitView = this.sortiePursuitPanelView;
|
||||
pursuitView?.rows.forEach((row) => {
|
||||
row.background.setVisible(visible);
|
||||
row.label.setVisible(visible);
|
||||
if (visible) {
|
||||
row.background.setInteractive({ useHandCursor: true });
|
||||
} else {
|
||||
row.background.disableInteractive();
|
||||
}
|
||||
});
|
||||
if (pursuitView) {
|
||||
[pursuitView.prevButton, pursuitView.prevLabel, pursuitView.nextButton, pursuitView.nextLabel, pursuitView.pageText]
|
||||
.forEach((object) => object.setVisible(visible));
|
||||
if (visible && pursuitView.prevEnabled) {
|
||||
pursuitView.prevButton.setInteractive({ useHandCursor: true });
|
||||
} else {
|
||||
pursuitView.prevButton.disableInteractive();
|
||||
}
|
||||
if (visible && pursuitView.nextEnabled) {
|
||||
pursuitView.nextButton.setInteractive({ useHandCursor: true });
|
||||
} else {
|
||||
pursuitView.nextButton.disableInteractive();
|
||||
}
|
||||
}
|
||||
this.sortieComparisonPanelView?.metrics.forEach((metric) => {
|
||||
metric.background.setVisible(!visible);
|
||||
metric.label.setVisible(!visible);
|
||||
metric.value.setVisible(!visible);
|
||||
});
|
||||
}
|
||||
|
||||
private refreshCampaignSortieComparisonPanel() {
|
||||
const view = this.sortieComparisonPanelView;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = this.sortieFormationComparisonPreview();
|
||||
const action = this.sortieComparisonAction(preview);
|
||||
const preview = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieFormationComparisonPreview();
|
||||
const action = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieComparisonAction(preview);
|
||||
this.refreshSortieComparisonActionButton(view, action);
|
||||
const isUndoAction = action?.kind === 'undo-swap' || action?.kind === 'undo-preset';
|
||||
const current = preview?.current ?? this.sortieSynergySnapshot();
|
||||
@@ -13527,6 +13643,7 @@ export class CampScene extends Phaser.Scene {
|
||||
const showMetricTransition = !isUndoAction && Boolean(preview?.projected);
|
||||
const comparison = isUndoAction ? undefined : preview?.comparison;
|
||||
const pursuitChanges = this.changedSortiePursuitPairs(current, projected);
|
||||
this.setCampaignSortieCoreSelectorVisible(false);
|
||||
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);
|
||||
@@ -13537,6 +13654,32 @@ export class CampScene extends Phaser.Scene {
|
||||
lostRole ? 0.78 : isHoverPreview ? 0.72 : 0.58
|
||||
);
|
||||
|
||||
if (this.sortieCoreResonanceSelectorOpen) {
|
||||
const coreSnapshot = this.sortieSynergySnapshot();
|
||||
const coreCandidates = this.sortieCoreResonanceCandidates(coreSnapshot);
|
||||
const selectedCore = coreCandidates.find((candidate) => candidate.selected);
|
||||
this.setCampaignSortieCoreSelectorVisible(true);
|
||||
view.background.setStrokeStyle(selectedCore ? 2 : 1, selectedCore ? palette.green : palette.gold, selectedCore ? 0.78 : 0.62);
|
||||
view.title.setText('핵심 공명조');
|
||||
view.summary.setText(selectedCore ? `지정 ${selectedCore.coreChainRate}% · 후보 ${coreCandidates.length}조` : `미지정 · 후보 ${coreCandidates.length}조`);
|
||||
view.headline
|
||||
.setText(
|
||||
selectedCore
|
||||
? `${selectedCore.unitNames[0]}↔${selectedCore.unitNames[1]} · Lv${selectedCore.level} 핵심 추격 준비`
|
||||
: coreCandidates.length > 0
|
||||
? '아래 인연 칩을 눌러 이번 전투의 핵심 한 조를 정하십시오.'
|
||||
: `Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성하면 후보가 열립니다.`
|
||||
)
|
||||
.setColor(selectedCore ? '#a8ffd0' : coreCandidates.length > 0 ? '#ffdf7b' : '#9fb0bf');
|
||||
view.impact
|
||||
.setText(this.compactText(this.sortiePlanFeedback || (selectedCore ? '다시 누르면 지정을 해제합니다.' : '정확히 한 조만 지정되며 다른 조를 누르면 즉시 교체됩니다.'), 68))
|
||||
.setColor(this.sortiePlanFeedback || selectedCore ? '#a8ffd0' : '#d4dce6');
|
||||
view.detail
|
||||
.setText(`Lv${coreSortieResonanceMinLevel}+ · 핵심 추격 8/18/28% · 일반 추격 확률은 그대로 유지`)
|
||||
.setColor('#9fb0bf');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
if (this.sortieOrderBrowserOpen) {
|
||||
const order = this.currentSortieOrderDefinition();
|
||||
@@ -13577,11 +13720,11 @@ export class CampScene extends Phaser.Scene {
|
||||
: preview.source === 'hover-add'
|
||||
? `가상 합류 · ${preview.incomingUnitName}`
|
||||
: `편성 변화 · ${this.unitName(preview.unitId)}`;
|
||||
view.title.setText(this.compactText(title, 28));
|
||||
view.title.setText(this.compactText(title, 18));
|
||||
view.summary.setText(
|
||||
this.compactText(
|
||||
`추격 후보 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조 · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`,
|
||||
38
|
||||
26
|
||||
)
|
||||
);
|
||||
const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' ||
|
||||
@@ -13980,48 +14123,145 @@ export class CampScene extends Phaser.Scene {
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private renderSortieCoreResonanceChips(
|
||||
candidates: readonly SortieCoreResonanceCandidate[],
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
depth: number,
|
||||
pagerX: number,
|
||||
pagerY: number
|
||||
): Pick<
|
||||
SortiePursuitPanelView,
|
||||
'rows' | 'page' | 'pageCount' | 'prevEnabled' | 'nextEnabled' | 'prevButton' | 'prevLabel' | 'nextButton' | 'nextLabel' | 'pageText'
|
||||
> {
|
||||
const pageCount = Math.max(1, Math.ceil(candidates.length / 3));
|
||||
this.sortieCoreResonancePage = Phaser.Math.Clamp(this.sortieCoreResonancePage, 0, pageCount - 1);
|
||||
const page = this.sortieCoreResonancePage;
|
||||
const visibleCandidates = candidates.slice(page * 3, page * 3 + 3);
|
||||
const chipGap = 6;
|
||||
const availableChipWidth = Math.floor(
|
||||
(width - chipGap * Math.max(0, visibleCandidates.length - 1)) / Math.max(1, visibleCandidates.length)
|
||||
);
|
||||
const chipWidth = Math.min(148, availableChipWidth);
|
||||
const rows: SortiePursuitPanelView['rows'] = visibleCandidates.map((candidate, index) => {
|
||||
const chipX = x + index * (chipWidth + chipGap);
|
||||
const selected = candidate.selected;
|
||||
const chipText = `${selected ? '핵심' : '지정'} ${candidate.coreChainRate}% ${candidate.unitNames[0]}↔${candidate.unitNames[1]}`;
|
||||
const fill = selected ? 0x493719 : 0x173329;
|
||||
const stroke = selected ? palette.gold : palette.green;
|
||||
const background = this.trackSortie(this.add.rectangle(chipX, y, chipWidth, 22, fill, 0.98));
|
||||
background.setOrigin(0);
|
||||
background.setDepth(depth);
|
||||
background.setStrokeStyle(selected ? 2 : 1, stroke, selected ? 0.96 : 0.68);
|
||||
background.setInteractive({ useHandCursor: true });
|
||||
background.on('pointerover', () => {
|
||||
background.setFillStyle(selected ? 0x604b25 : 0x244b3d, 1).setStrokeStyle(2, palette.gold, 0.98);
|
||||
});
|
||||
background.on('pointerout', () => {
|
||||
background.setFillStyle(fill, 0.98).setStrokeStyle(selected ? 2 : 1, stroke, selected ? 0.96 : 0.68);
|
||||
});
|
||||
background.on('pointerdown', () => this.toggleSortieCoreResonance(candidate.id));
|
||||
const label = this.trackSortie(
|
||||
this.add.text(
|
||||
chipX + chipWidth / 2,
|
||||
y + 11,
|
||||
this.compactText(chipText, chipWidth >= 140 ? 18 : 15),
|
||||
this.textStyle(9, selected ? '#ffdf7b' : '#a8ffd0', true)
|
||||
)
|
||||
);
|
||||
label.setOrigin(0.5);
|
||||
label.setDepth(depth + 1);
|
||||
return {
|
||||
bondId: candidate.id,
|
||||
level: candidate.level,
|
||||
chainRate: candidate.coreChainRate,
|
||||
selected,
|
||||
state: selected ? 'selected' as const : 'candidate' as const,
|
||||
text: chipText,
|
||||
background,
|
||||
label
|
||||
};
|
||||
});
|
||||
|
||||
const prevEnabled = page > 0;
|
||||
const nextEnabled = page + 1 < pageCount;
|
||||
const prevButton = this.trackSortie(this.add.rectangle(pagerX, pagerY, 18, 16, prevEnabled ? 0x263a2d : 0x111820, prevEnabled ? 0.98 : 0.62));
|
||||
prevButton.setOrigin(0);
|
||||
prevButton.setDepth(depth);
|
||||
prevButton.setStrokeStyle(1, prevEnabled ? palette.green : 0x53606c, prevEnabled ? 0.72 : 0.3);
|
||||
if (prevEnabled) {
|
||||
prevButton.setInteractive({ useHandCursor: true });
|
||||
prevButton.on('pointerdown', () => this.changeSortieCoreResonancePage(-1));
|
||||
}
|
||||
const prevLabel = this.trackSortie(this.add.text(pagerX + 9, pagerY + 8, '◀', this.textStyle(7, prevEnabled ? '#e7ffd9' : '#77818c', true)));
|
||||
prevLabel.setOrigin(0.5);
|
||||
prevLabel.setDepth(depth + 1);
|
||||
const pageText = this.trackSortie(this.add.text(pagerX + 34, pagerY + 8, `${page + 1}/${pageCount}`, this.textStyle(8, '#d8b15f', true)));
|
||||
pageText.setOrigin(0.5);
|
||||
pageText.setDepth(depth + 1);
|
||||
const nextButton = this.trackSortie(this.add.rectangle(pagerX + 50, pagerY, 18, 16, nextEnabled ? 0x263a2d : 0x111820, nextEnabled ? 0.98 : 0.62));
|
||||
nextButton.setOrigin(0);
|
||||
nextButton.setDepth(depth);
|
||||
nextButton.setStrokeStyle(1, nextEnabled ? palette.green : 0x53606c, nextEnabled ? 0.72 : 0.3);
|
||||
if (nextEnabled) {
|
||||
nextButton.setInteractive({ useHandCursor: true });
|
||||
nextButton.on('pointerdown', () => this.changeSortieCoreResonancePage(1));
|
||||
}
|
||||
const nextLabel = this.trackSortie(this.add.text(pagerX + 59, pagerY + 8, '▶', this.textStyle(7, nextEnabled ? '#e7ffd9' : '#77818c', true)));
|
||||
nextLabel.setOrigin(0.5);
|
||||
nextLabel.setDepth(depth + 1);
|
||||
return {
|
||||
rows,
|
||||
page,
|
||||
pageCount,
|
||||
prevEnabled,
|
||||
nextEnabled,
|
||||
prevButton,
|
||||
prevLabel,
|
||||
nextButton,
|
||||
nextLabel,
|
||||
pageText
|
||||
};
|
||||
}
|
||||
|
||||
private renderFirstSortieSynergyPanel(x: number, y: number, width: number, height: number, depth: number) {
|
||||
const synergy = this.sortieSynergySnapshot();
|
||||
const pursuit = this.sortiePursuitPairs();
|
||||
const coreCandidates = this.sortieCoreResonanceCandidates(synergy);
|
||||
const selectedCore = coreCandidates.find((candidate) => candidate.selected);
|
||||
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);
|
||||
bg.setStrokeStyle(selectedCore ? 2 : 1, selectedCore ? palette.green : palette.gold, selectedCore ? 0.78 : 0.62);
|
||||
const summaryText = this.trackSortie(
|
||||
this.add.text(
|
||||
x + 18,
|
||||
y + 9,
|
||||
`조합 시너지 · 추격 후보 ${pursuit.activePairs.length}조 · 역할 ${synergy.coveredRoleCount}/3`,
|
||||
this.textStyle(15, '#f2e3bf', true)
|
||||
`핵심 공명조 · ${selectedCore ? '지정' : '미지정'} · 후보 ${coreCandidates.length}조`,
|
||||
this.textStyle(14, '#f2e3bf', true)
|
||||
)
|
||||
);
|
||||
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);
|
||||
this.trackSortie(
|
||||
this.add.text(x + width - 18, y + 12, `역할 ${synergy.coveredRoleCount}/3 · 지형 ${synergy.terrainGrade}`, this.textStyle(9, '#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 pursuitPageView = this.renderSortieCoreResonanceChips(
|
||||
coreCandidates,
|
||||
x + 18,
|
||||
y + 34,
|
||||
width - 36,
|
||||
depth + 1,
|
||||
x + width - 84,
|
||||
y + 60
|
||||
);
|
||||
if (pursuitPageView.rows.length === 0) {
|
||||
this.trackSortie(
|
||||
this.add.text(x + 18, y + 38, `후보 없음 · Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성하십시오.`, this.textStyle(10, '#ffdf7b', true))
|
||||
).setDepth(depth + 1);
|
||||
}
|
||||
const ruleText = this.trackSortie(
|
||||
this.add.text(x + 18, y + 62, '같은 적을 먼저 친 공명 장수가 지원 거리 안에서 표시 확률로 추격합니다.', this.textStyle(9, '#d4dce6', true))
|
||||
this.add.text(x + 18, y + 62, `Lv${coreSortieResonanceMinLevel}+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제`, this.textStyle(9, '#d4dce6', true))
|
||||
);
|
||||
ruleText.setDepth(depth + 1);
|
||||
const missingRoleLabels: Record<(typeof coreSortieSynergyRoles)[number], string> = {
|
||||
@@ -14038,7 +14278,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 };
|
||||
this.sortiePursuitPanelView = { background: bg, summaryText, ruleText, ...pursuitPageView };
|
||||
}
|
||||
|
||||
private renderFirstSortieLoadoutStep(
|
||||
@@ -14975,6 +15215,77 @@ export class CampScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private currentSortieResonanceBondId(scenario = this.nextSortieScenario()) {
|
||||
const selection = this.campaign?.sortieResonanceSelection;
|
||||
return scenario && selection?.battleId === scenario.id ? selection.bondId : undefined;
|
||||
}
|
||||
|
||||
private sortieCoreResonanceCandidates(
|
||||
snapshot = this.sortieSynergySnapshot(),
|
||||
scenario = this.nextSortieScenario()
|
||||
): SortieCoreResonanceCandidate[] {
|
||||
const selectedBondId = this.currentSortieResonanceBondId(scenario);
|
||||
const seenPairs = new Set<string>();
|
||||
return snapshot.activeBonds
|
||||
.filter((bond) => bond.level >= coreSortieResonanceMinLevel)
|
||||
.map((bond) => ({
|
||||
...bond,
|
||||
coreChainRate: coreSortieResonanceChainRateForLevel(bond.level),
|
||||
selected: bond.id === selectedBondId
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
Number(right.selected) - Number(left.selected) ||
|
||||
right.coreChainRate - left.coreChainRate ||
|
||||
right.level - left.level ||
|
||||
left.title.localeCompare(right.title) ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.filter((bond) => {
|
||||
const pairKey = this.sortiePursuitPairKey(bond);
|
||||
if (seenPairs.has(pairKey)) {
|
||||
return false;
|
||||
}
|
||||
seenPairs.add(pairKey);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private changeSortieCoreResonancePage(delta: number) {
|
||||
const pageCount = Math.max(1, Math.ceil(this.sortieCoreResonanceCandidates().length / 3));
|
||||
const nextPage = Phaser.Math.Clamp(this.sortieCoreResonancePage + Math.sign(delta), 0, pageCount - 1);
|
||||
if (nextPage === this.sortieCoreResonancePage) {
|
||||
return;
|
||||
}
|
||||
this.sortieCoreResonancePage = nextPage;
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private toggleSortieCoreResonance(bondId: string) {
|
||||
const scenario = this.nextSortieScenario();
|
||||
if (!scenario) {
|
||||
this.showCampNotice('현재 지정할 수 있는 핵심 공명조가 없습니다.');
|
||||
return;
|
||||
}
|
||||
const candidate = this.sortieCoreResonanceCandidates(undefined, scenario).find((bond) => bond.id === bondId);
|
||||
if (!candidate) {
|
||||
this.showCampNotice(`Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성해야 핵심 공명조로 지정할 수 있습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.persistSortieSelection();
|
||||
const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id;
|
||||
this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id);
|
||||
const pairLabel = `${candidate.unitNames[0]}↔${candidate.unitNames[1]}`;
|
||||
this.sortiePlanFeedback = clearing
|
||||
? `핵심 공명조 해제 · ${pairLabel}`
|
||||
: `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`;
|
||||
this.sortieCoreResonancePage = 0;
|
||||
soundDirector.playSelect();
|
||||
this.showCampNotice(this.sortiePlanFeedback);
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private sortiePursuitPairs(
|
||||
selectedUnitIds: readonly string[] = this.selectedSortieUnitIds,
|
||||
scenario = this.nextSortieScenario(),
|
||||
@@ -14997,7 +15308,8 @@ export class CampScene extends Phaser.Scene {
|
||||
bonds: this.sortieSynergyBonds(scenario),
|
||||
selectedUnitIds: selectedUnitIds.filter((unitId) => availableIds.has(unitId)),
|
||||
selectableUnitIds: (selectableUnitIds ?? [...availableIds]).filter((unitId) => availableIds.has(unitId)),
|
||||
battleId: scenario?.id
|
||||
battleId: scenario?.id,
|
||||
coreBondId: this.currentSortieResonanceBondId(scenario)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15007,14 +15319,25 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
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;
|
||||
});
|
||||
const coreBondId = this.currentSortieResonanceBondId();
|
||||
return snapshot.activeBonds
|
||||
.map((bond) => {
|
||||
const core = bond.id === coreBondId && bond.level >= coreSortieResonanceMinLevel;
|
||||
return {
|
||||
...bond,
|
||||
baseChainRate: bond.chainRate,
|
||||
chainRate: core ? coreSortieResonanceChainRateForLevel(bond.level) : bond.chainRate,
|
||||
core
|
||||
};
|
||||
})
|
||||
.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) {
|
||||
@@ -16487,6 +16810,7 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
const rule = this.nextSortieRule(scenario);
|
||||
const selectedCore = this.sortieCoreResonanceCandidates(undefined, scenario).find((candidate) => candidate.selected);
|
||||
const allies = this.sortieAllies();
|
||||
const availableById = new Map(allies.map((unit) => [unit.id, unit]));
|
||||
const orderedIds: string[] = [];
|
||||
@@ -16521,6 +16845,9 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(nextAssignments);
|
||||
this.sortieFocusedUnitId = nextSelectedIds[0] ?? this.sortieFocusedUnitId;
|
||||
this.sortiePlanFeedback = this.recommendedSortieFeedback(nextSelectedIds, nextAssignments, hasBattle);
|
||||
if (selectedCore && !selectedCore.unitIds.every((unitId) => nextSelectedIds.includes(unitId))) {
|
||||
this.sortiePlanFeedback += ' · 핵심 공명조 자동 해제';
|
||||
}
|
||||
this.persistSortieSelection();
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
@@ -17022,13 +17349,17 @@ export class CampScene extends Phaser.Scene {
|
||||
const sortieFormationAssignments = { ...this.sortieFormationAssignments };
|
||||
const sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments);
|
||||
const sortieOrderId = this.currentSortieOrderId();
|
||||
const sortieResonanceBondId = this.campaign?.sortieResonanceSelection?.battleId === flow.nextBattleId
|
||||
? this.campaign.sortieResonanceSelection.bondId
|
||||
: undefined;
|
||||
if (this.skipIntroStoryForSortie) {
|
||||
void startLazyScene(this, 'BattleScene', {
|
||||
battleId: flow.nextBattleId,
|
||||
selectedSortieUnitIds,
|
||||
sortieFormationAssignments,
|
||||
sortieItemAssignments,
|
||||
sortieOrderId
|
||||
sortieOrderId,
|
||||
sortieResonanceBondId
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -17036,7 +17367,14 @@ export class CampScene extends Phaser.Scene {
|
||||
void startLazyScene(this, 'StoryScene', {
|
||||
pages: flow.pages,
|
||||
nextScene: 'BattleScene',
|
||||
nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, sortieItemAssignments, sortieOrderId }
|
||||
nextSceneData: {
|
||||
battleId: flow.nextBattleId,
|
||||
selectedSortieUnitIds,
|
||||
sortieFormationAssignments,
|
||||
sortieItemAssignments,
|
||||
sortieOrderId,
|
||||
sortieResonanceBondId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17052,9 +17390,13 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.closeSortiePresetBrowserState();
|
||||
this.sortieFormationPanelMode = 'roster';
|
||||
this.sortieCoreResonanceSelectorOpen = false;
|
||||
this.sortieCoreResonancePage = 0;
|
||||
}
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
this.sortiePursuitPanelView = undefined;
|
||||
this.sortieCoreResonanceToggleButton = undefined;
|
||||
this.sortieCoreResonanceToggleLabel = undefined;
|
||||
this.sortiePresetBrowserView = undefined;
|
||||
this.sortiePresetToggleButton = undefined;
|
||||
this.sortieOrderBrowserView = undefined;
|
||||
@@ -19899,6 +20241,7 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
const synergyPreview = this.sortieUnitSynergyPreview(unit);
|
||||
const selectedCore = this.sortieCoreResonanceCandidates().find((candidate) => candidate.selected);
|
||||
if (this.isRequiredSortieUnit(unitId)) {
|
||||
this.showCampNotice(`${unit.name}는 반드시 ${hasBattle ? '출전' : '동행'}해야 합니다.`);
|
||||
return;
|
||||
@@ -19927,6 +20270,9 @@ export class CampScene extends Phaser.Scene {
|
||||
const selectionLabel = hasBattle ? '출전' : '동행';
|
||||
const synergyResultLine = synergyPreview.headline.replace(/^(합류|해제) 시 /, '');
|
||||
this.sortiePlanFeedback = `${unit.name} ${selected.has(unitId) ? `${selectionLabel} 추가` : `${selectionLabel} 해제`} · ${synergyResultLine}`;
|
||||
if (selectedCore && !selectedCore.unitIds.every((selectedUnitId) => selected.has(selectedUnitId))) {
|
||||
this.sortiePlanFeedback += ' · 핵심 공명조 자동 해제';
|
||||
}
|
||||
this.persistSortieSelection();
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
@@ -20199,6 +20545,14 @@ export class CampScene extends Phaser.Scene {
|
||||
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
||||
}
|
||||
|
||||
private sortieInteractiveObjectBoundsDebug(object?: Phaser.GameObjects.Rectangle) {
|
||||
if (!object?.active || !object.visible || !object.input?.enabled) {
|
||||
return null;
|
||||
}
|
||||
const bounds = object.getBounds();
|
||||
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
||||
}
|
||||
|
||||
private sortieTextBoundsDebug(object?: Phaser.GameObjects.Text) {
|
||||
if (!object?.active) {
|
||||
return null;
|
||||
@@ -20224,6 +20578,8 @@ export class CampScene extends Phaser.Scene {
|
||||
const sortieComparisonPreview = this.sortieFormationComparisonPreview();
|
||||
const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview);
|
||||
const sortiePursuit = this.sortiePursuitPairs();
|
||||
const sortieCoreResonanceCandidates = this.sortieCoreResonanceCandidates();
|
||||
const sortieCoreResonanceSelection = this.campaign?.sortieResonanceSelection;
|
||||
const recommendedPresetId = this.recommendedSortiePresetId();
|
||||
const sortieFormationPresets = this.sortieFormationPresets();
|
||||
const sortieOrderId = this.currentSortieOrderId();
|
||||
@@ -20692,6 +21048,22 @@ export class CampScene extends Phaser.Scene {
|
||||
sortiePlan: this.sortiePlanSummary(),
|
||||
sortieSynergyPreview: this.sortieSynergySnapshot(),
|
||||
sortiePursuitPreview: {
|
||||
selectorOpen: this.sortieCoreResonanceSelectorOpen,
|
||||
toggleBounds: this.sortieObjectBoundsDebug(this.sortieCoreResonanceToggleButton),
|
||||
toggleClickBounds: this.sortieInteractiveObjectBoundsDebug(this.sortieCoreResonanceToggleButton),
|
||||
toggleLabel: this.sortieCoreResonanceToggleLabel?.text ?? null,
|
||||
selectionBattleId: sortieCoreResonanceSelection?.battleId ?? null,
|
||||
selectedCoreBondId: this.currentSortieResonanceBondId() ?? null,
|
||||
coreCandidates: sortieCoreResonanceCandidates.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
unitIds: [...candidate.unitIds],
|
||||
unitNames: [...candidate.unitNames],
|
||||
title: candidate.title,
|
||||
level: candidate.level,
|
||||
baseChainRate: candidate.chainRate,
|
||||
coreChainRate: candidate.coreChainRate,
|
||||
selected: candidate.selected
|
||||
})),
|
||||
activePairs: sortiePursuit.activePairs.map((pair) => ({
|
||||
id: pair.id,
|
||||
unitIds: [...pair.unitIds],
|
||||
@@ -20699,6 +21071,8 @@ export class CampScene extends Phaser.Scene {
|
||||
title: pair.title,
|
||||
level: pair.level,
|
||||
chainRate: pair.chainRate,
|
||||
baseChainRate: pair.baseChainRate,
|
||||
core: pair.core,
|
||||
damageBonus: pair.damageBonus
|
||||
})),
|
||||
selectableOpportunities: sortiePursuit.selectableOpportunities.map((opportunity) => ({
|
||||
@@ -20708,6 +21082,8 @@ export class CampScene extends Phaser.Scene {
|
||||
title: opportunity.title,
|
||||
level: opportunity.level,
|
||||
chainRate: opportunity.chainRate,
|
||||
baseChainRate: opportunity.baseChainRate,
|
||||
core: opportunity.core,
|
||||
damageBonus: opportunity.damageBonus,
|
||||
selectedPartnerId: opportunity.selectedPartnerId,
|
||||
selectedPartnerName: opportunity.selectedPartnerName,
|
||||
@@ -20718,10 +21094,25 @@ export class CampScene extends Phaser.Scene {
|
||||
summaryText: this.sortiePursuitPanelView?.summaryText.text ?? null,
|
||||
ruleText: this.sortiePursuitPanelView?.ruleText.text ?? null,
|
||||
ruleBounds: this.sortieTextBoundsDebug(this.sortiePursuitPanelView?.ruleText),
|
||||
page: this.sortiePursuitPanelView?.page ?? 0,
|
||||
pageCount: this.sortiePursuitPanelView?.pageCount ?? 1,
|
||||
prevEnabled: this.sortiePursuitPanelView?.prevEnabled ?? false,
|
||||
nextEnabled: this.sortiePursuitPanelView?.nextEnabled ?? false,
|
||||
prevBounds: this.sortieObjectBoundsDebug(this.sortiePursuitPanelView?.prevButton),
|
||||
nextBounds: this.sortieObjectBoundsDebug(this.sortiePursuitPanelView?.nextButton),
|
||||
prevClickBounds: this.sortieInteractiveObjectBoundsDebug(this.sortiePursuitPanelView?.prevButton),
|
||||
nextClickBounds: this.sortieInteractiveObjectBoundsDebug(this.sortiePursuitPanelView?.nextButton),
|
||||
pageText: this.sortiePursuitPanelView?.pageText.text ?? null,
|
||||
pageTextBounds: this.sortieTextBoundsDebug(this.sortiePursuitPanelView?.pageText),
|
||||
rows: this.sortiePursuitPanelView?.rows.map((row) => ({
|
||||
bondId: row.bondId,
|
||||
level: row.level,
|
||||
chainRate: row.chainRate,
|
||||
selected: row.selected,
|
||||
state: row.state,
|
||||
text: row.text,
|
||||
bounds: this.sortieObjectBoundsDebug(row.background),
|
||||
bounds: this.sortieInteractiveObjectBoundsDebug(row.background),
|
||||
chipBounds: this.sortieInteractiveObjectBoundsDebug(row.background),
|
||||
textBounds: this.sortieTextBoundsDebug(row.label)
|
||||
})) ?? []
|
||||
},
|
||||
@@ -20747,6 +21138,7 @@ export class CampScene extends Phaser.Scene {
|
||||
selectedSortieUnitIds: this.campaign.selectedSortieUnitIds,
|
||||
sortieFormationAssignments: this.campaign.sortieFormationAssignments,
|
||||
sortieItemAssignments: this.campaign.sortieItemAssignments,
|
||||
sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null,
|
||||
activeSaveSlot: this.campaign.activeSaveSlot,
|
||||
reserveTrainingAssignments: this.campaign.reserveTrainingAssignments,
|
||||
reserveTrainingFocus: this.campaign.reserveTrainingFocus,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isCampaignStep, type CampaignStep } from './campaignState';
|
||||
import type { UnitDirection } from '../data/unitAssets';
|
||||
import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems';
|
||||
import type { SortieOrderId } from '../data/sortieOrders';
|
||||
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
|
||||
|
||||
export type BattleSaveFaction = 'ally' | 'enemy';
|
||||
export type BattleSaveRosterTab = 'ally' | 'enemy';
|
||||
@@ -69,11 +70,20 @@ export type BattleSaveTacticalCommandState = {
|
||||
effectLost?: boolean;
|
||||
};
|
||||
|
||||
export type BattleSaveCoreResonanceStats = {
|
||||
attempts: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
additionalDamage: number;
|
||||
};
|
||||
|
||||
export type BattleSaveState = {
|
||||
version: 1;
|
||||
battleId: string;
|
||||
campaignStep?: CampaignStep;
|
||||
sortieOrderId?: SortieOrderId;
|
||||
sortieResonanceBondId?: string;
|
||||
coreResonanceStats?: BattleSaveCoreResonanceStats;
|
||||
savedAt: string;
|
||||
turnNumber: number;
|
||||
activeFaction: BattleSaveFaction;
|
||||
@@ -148,12 +158,25 @@ export function parseBattleSaveState(raw: string | null | undefined, options: Ba
|
||||
|
||||
try {
|
||||
const state = JSON.parse(raw) as unknown;
|
||||
return isValidBattleSaveState(state, options) ? state : undefined;
|
||||
return normalizeBattleSaveState(state, options);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBattleSaveState(
|
||||
state: unknown,
|
||||
options: BattleSaveValidationOptions = {}
|
||||
): BattleSaveState | undefined {
|
||||
const cloned = cloneJsonValue(state);
|
||||
if (!isRecord(cloned)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
normalizeCoreResonanceSaveFields(cloned, options);
|
||||
return isValidBattleSaveState(cloned, options) ? cloned : undefined;
|
||||
}
|
||||
|
||||
export function isValidBattleSaveState(state: unknown, options: BattleSaveValidationOptions = {}): state is BattleSaveState {
|
||||
if (!isRecord(state) || state.version !== 1 || !Array.isArray(state.units)) {
|
||||
return false;
|
||||
@@ -183,6 +206,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isOptionalCoreResonanceState(state, options)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attackIntents = state.attackIntents;
|
||||
const units = state.units;
|
||||
|
||||
@@ -238,6 +265,118 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue(value: unknown): unknown {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? undefined : JSON.parse(serialized) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCoreResonanceSaveFields(
|
||||
state: Record<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
) {
|
||||
const requestedBondId = typeof state.sortieResonanceBondId === 'string'
|
||||
? state.sortieResonanceBondId.trim()
|
||||
: '';
|
||||
const bonds = Array.isArray(state.bonds) ? state.bonds : [];
|
||||
const units = Array.isArray(state.units) ? state.units : [];
|
||||
const deployedUnitIds = new Set(
|
||||
units.filter(isRecord).map((unit) => unit.id).filter((unitId): unitId is string => typeof unitId === 'string')
|
||||
);
|
||||
const bond = bonds.find((candidate) => (
|
||||
isRecord(candidate) &&
|
||||
candidate.id === requestedBondId &&
|
||||
Number.isInteger(candidate.level) &&
|
||||
Number(candidate.level) >= coreSortieResonanceMinLevel &&
|
||||
Array.isArray(candidate.unitIds) &&
|
||||
candidate.unitIds.length === 2 &&
|
||||
candidate.unitIds.every((unitId) => (
|
||||
typeof unitId === 'string' &&
|
||||
deployedUnitIds.has(unitId) &&
|
||||
(!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId))
|
||||
))
|
||||
));
|
||||
|
||||
if (!requestedBondId || !bond) {
|
||||
delete state.sortieResonanceBondId;
|
||||
delete state.coreResonanceStats;
|
||||
return;
|
||||
}
|
||||
|
||||
state.sortieResonanceBondId = requestedBondId;
|
||||
if (state.coreResonanceStats === undefined) {
|
||||
return;
|
||||
}
|
||||
const rawStats = isRecord(state.coreResonanceStats) ? state.coreResonanceStats : {};
|
||||
const rawSuccesses = normalizeCoreResonanceStatValue(rawStats.successes);
|
||||
const rawFailures = normalizeCoreResonanceStatValue(rawStats.failures);
|
||||
const attempts = Math.min(
|
||||
maxBattleUnitStatValue,
|
||||
Math.max(normalizeCoreResonanceStatValue(rawStats.attempts), rawSuccesses + rawFailures)
|
||||
);
|
||||
const successes = Math.min(rawSuccesses, attempts);
|
||||
state.coreResonanceStats = {
|
||||
attempts,
|
||||
successes,
|
||||
failures: attempts - successes,
|
||||
additionalDamage: normalizeCoreResonanceStatValue(rawStats.additionalDamage)
|
||||
} satisfies BattleSaveCoreResonanceStats;
|
||||
}
|
||||
|
||||
function normalizeCoreResonanceStatValue(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(maxBattleUnitStatValue, Math.max(0, Math.floor(value)));
|
||||
}
|
||||
|
||||
function isOptionalCoreResonanceState(
|
||||
state: Record<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
) {
|
||||
const bondId = state.sortieResonanceBondId;
|
||||
const stats = state.coreResonanceStats;
|
||||
if (bondId === undefined) {
|
||||
return stats === undefined;
|
||||
}
|
||||
if (typeof bondId !== 'string' || bondId.length === 0 || !Array.isArray(state.bonds) || !Array.isArray(state.units)) {
|
||||
return false;
|
||||
}
|
||||
const deployedUnitIds = new Set(
|
||||
state.units.filter(isRecord).map((unit) => unit.id).filter((unitId): unitId is string => typeof unitId === 'string')
|
||||
);
|
||||
const bond = state.bonds.find((candidate) => (
|
||||
isRecord(candidate) &&
|
||||
candidate.id === bondId &&
|
||||
Number.isInteger(candidate.level) &&
|
||||
Number(candidate.level) >= coreSortieResonanceMinLevel &&
|
||||
Array.isArray(candidate.unitIds) &&
|
||||
candidate.unitIds.length === 2 &&
|
||||
candidate.unitIds.every((unitId) => (
|
||||
typeof unitId === 'string' &&
|
||||
deployedUnitIds.has(unitId) &&
|
||||
(!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId))
|
||||
))
|
||||
));
|
||||
if (!bond) {
|
||||
return false;
|
||||
}
|
||||
if (stats === undefined) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isRecord(stats) &&
|
||||
isBattleUnitStatValue(stats.attempts) &&
|
||||
isBattleUnitStatValue(stats.successes) &&
|
||||
isBattleUnitStatValue(stats.failures) &&
|
||||
isBattleUnitStatValue(stats.additionalDamage) &&
|
||||
Number(stats.attempts) === Number(stats.successes) + Number(stats.failures)
|
||||
);
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown) {
|
||||
return Number.isInteger(value) && Number(value) >= 1;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unitClasses } from '../data/battleRules';
|
||||
import { battleScenarios, type BattleScenarioId } from '../data/battles';
|
||||
import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario';
|
||||
import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment';
|
||||
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
|
||||
import {
|
||||
isSortieOrderId,
|
||||
sortieOrderCheckIdsByOrder,
|
||||
@@ -395,6 +396,11 @@ export type CampaignSortieOrderSelection = {
|
||||
orderId: CampaignSortiePresetId;
|
||||
};
|
||||
|
||||
export type CampaignSortieResonanceSelection = {
|
||||
battleId: BattleScenarioId;
|
||||
bondId: string;
|
||||
};
|
||||
|
||||
export type CampaignSortieOrderHistory = Partial<
|
||||
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
|
||||
>;
|
||||
@@ -413,6 +419,7 @@ export type CampaignState = {
|
||||
sortieItemAssignments: CampaignSortieItemAssignments;
|
||||
sortieFormationPresets: CampaignSortieFormationPresets;
|
||||
sortieOrderSelection?: CampaignSortieOrderSelection;
|
||||
sortieResonanceSelection?: CampaignSortieResonanceSelection;
|
||||
sortieOrderHistory: CampaignSortieOrderHistory;
|
||||
claimedSortieOrderRewardIds: string[];
|
||||
reserveTrainingFocus: CampaignReserveTrainingFocusId;
|
||||
@@ -722,6 +729,32 @@ export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orde
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
export function setCampaignSortieResonanceSelection(battleId: BattleScenarioId, bondId?: string) {
|
||||
const state = ensureCampaignState();
|
||||
if (!hasCampaignBattleScenario(battleId)) {
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
if (bondId) {
|
||||
const normalizedBondId = normalizeKeyString(bondId);
|
||||
const bond = resolveCampaignSortieResonanceBond(battleId, normalizedBondId, state.bonds);
|
||||
const selectedUnitIds = new Set(state.selectedSortieUnitIds);
|
||||
if (
|
||||
!bond ||
|
||||
bond.level < coreSortieResonanceMinLevel ||
|
||||
!bond.unitIds.every((unitId) => selectedUnitIds.has(unitId))
|
||||
) {
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
state.sortieResonanceSelection = { battleId, bondId: bond.id };
|
||||
} else if (!state.sortieResonanceSelection || state.sortieResonanceSelection.battleId === battleId) {
|
||||
delete state.sortieResonanceSelection;
|
||||
}
|
||||
state.updatedAt = new Date().toISOString();
|
||||
persistCampaignState(state);
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) {
|
||||
const state = ensureCampaignState();
|
||||
const normalizedFocusId = normalizeReserveTrainingFocusId(focusId);
|
||||
@@ -890,6 +923,9 @@ export function setFirstBattleReport(report: FirstBattleReport) {
|
||||
if (state.sortieOrderSelection?.battleId === battleId) {
|
||||
delete state.sortieOrderSelection;
|
||||
}
|
||||
if (state.sortieResonanceSelection?.battleId === battleId) {
|
||||
delete state.sortieResonanceSelection;
|
||||
}
|
||||
state.latestBattleId = battleId;
|
||||
state.updatedAt = new Date().toISOString();
|
||||
|
||||
@@ -1089,6 +1125,14 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
|
||||
if (!normalized.sortieOrderSelection) {
|
||||
delete normalized.sortieOrderSelection;
|
||||
}
|
||||
normalized.sortieResonanceSelection = normalizeCampaignSortieResonanceSelection(
|
||||
normalized.sortieResonanceSelection,
|
||||
normalized.bonds,
|
||||
normalized.selectedSortieUnitIds
|
||||
);
|
||||
if (!normalized.sortieResonanceSelection) {
|
||||
delete normalized.sortieResonanceSelection;
|
||||
}
|
||||
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(normalized.sortieOrderHistory);
|
||||
normalized.claimedSortieOrderRewardIds = normalizeCampaignSortieOrderRewardClaims(
|
||||
normalized.claimedSortieOrderRewardIds,
|
||||
@@ -1266,6 +1310,53 @@ function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOr
|
||||
return { battleId: battleId as BattleScenarioId, orderId: value.orderId };
|
||||
}
|
||||
|
||||
function normalizeCampaignSortieResonanceSelection(
|
||||
value: unknown,
|
||||
bonds: readonly CampBondSnapshot[],
|
||||
selectedSortieUnitIds: readonly string[]
|
||||
): CampaignSortieResonanceSelection | undefined {
|
||||
if (!isPlainObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const battleId = typeof value.battleId === 'string' ? value.battleId : '';
|
||||
const bondId = normalizeKeyString(value.bondId);
|
||||
if (!hasCampaignBattleScenario(battleId) || !bondId) {
|
||||
return undefined;
|
||||
}
|
||||
const bond = resolveCampaignSortieResonanceBond(battleId, bondId, bonds);
|
||||
const selectedUnitIds = new Set(selectedSortieUnitIds);
|
||||
if (
|
||||
!bond ||
|
||||
bond.level < coreSortieResonanceMinLevel ||
|
||||
!bond.unitIds.every((unitId) => selectedUnitIds.has(unitId))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { battleId: battleId as BattleScenarioId, bondId };
|
||||
}
|
||||
|
||||
function resolveCampaignSortieResonanceBond(
|
||||
battleId: string,
|
||||
bondId: string,
|
||||
campaignBonds: readonly CampBondSnapshot[]
|
||||
): BattleBond | undefined {
|
||||
if (!hasCampaignBattleScenario(battleId)) {
|
||||
return undefined;
|
||||
}
|
||||
const scenarioBond = battleScenarios[battleId].bonds.find((bond) => bond.id === bondId);
|
||||
if (!scenarioBond) {
|
||||
return undefined;
|
||||
}
|
||||
const campaignBond = campaignBonds.find((bond) => bond.id === bondId);
|
||||
return campaignBond
|
||||
? { ...scenarioBond, level: campaignBond.level, exp: campaignBond.exp }
|
||||
: scenarioBond;
|
||||
}
|
||||
|
||||
function hasCampaignBattleScenario(battleId: string): battleId is BattleScenarioId {
|
||||
return Object.prototype.hasOwnProperty.call(battleScenarios, battleId);
|
||||
}
|
||||
|
||||
export function normalizeCampaignSortieOrderResultSnapshot(
|
||||
value: unknown,
|
||||
outcome?: FirstBattleReport['outcome'],
|
||||
|
||||
Reference in New Issue
Block a user