feat: add selectable core resonance pairs

This commit is contained in:
2026-07-11 22:05:33 +09:00
parent 7d3c49ac0b
commit 51529e51c2
9 changed files with 1693 additions and 100 deletions

View File

@@ -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
};
}

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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'],