feat: surface officer strategies in sortie planning

This commit is contained in:
2026-07-12 12:28:51 +09:00
parent 3daad2861b
commit cb66deef9d
5 changed files with 395 additions and 162 deletions

View File

@@ -0,0 +1,218 @@
export type UsableCommand = 'strategy' | 'item';
export type UsableEffect = 'damage' | 'heal' | 'focus';
export type UsableTarget = 'enemy' | 'ally' | 'self';
export type BattleStatusKind = 'burn' | 'confusion';
export type BattleUsable = {
id: string;
command: UsableCommand;
name: string;
target: UsableTarget;
effect: UsableEffect;
range: number;
power: number;
accuracyBonus?: number;
criticalBonus?: number;
attackBonus?: number;
hitBonus?: number;
duration?: number;
statusEffect?: BattleStatusKind;
statusDuration?: number;
statusPower?: number;
description: string;
};
export const usableCatalog: Record<string, BattleUsable> = {
aid: {
id: 'aid',
command: 'strategy',
name: '응급',
target: 'ally',
effect: 'heal',
range: 2,
power: 10,
description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.'
},
encourage: {
id: 'encourage',
command: 'strategy',
name: '격려',
target: 'ally',
effect: 'focus',
range: 2,
power: 0,
attackBonus: 2,
hitBonus: 10,
criticalBonus: 4,
duration: 2,
description: '아군의 공격과 명중을 안정적으로 끌어올린다.'
},
fireTactic: {
id: 'fireTactic',
command: 'strategy',
name: '화계',
target: 'enemy',
effect: 'damage',
range: 2,
power: 10,
accuracyBonus: 4,
criticalBonus: 0,
statusEffect: 'burn',
statusDuration: 2,
statusPower: 3,
description: '적 한 부대에 책략 피해와 짧은 화상 지속 피해를 준다.'
},
roar: {
id: 'roar',
command: 'strategy',
name: '고함',
target: 'enemy',
effect: 'damage',
range: 1,
power: 7,
accuracyBonus: 10,
criticalBonus: 0,
statusEffect: 'confusion',
statusDuration: 2,
statusPower: 1,
description: '인접한 적을 위압해 다음 행동의 공격, 명중, 치명을 낮춘다.'
},
bean: {
id: 'bean',
command: 'item',
name: '콩',
target: 'ally',
effect: 'heal',
range: 1,
power: 12,
description: '인접 아군을 빠르게 소량 회복하는 기본 보급품.'
},
salve: {
id: 'salve',
command: 'item',
name: '상처약',
target: 'ally',
effect: 'heal',
range: 1,
power: 22,
description: '인접 아군의 큰 피해를 되돌리는 고가 회복품.'
},
wine: {
id: 'wine',
command: 'item',
name: '술',
target: 'self',
effect: 'focus',
range: 0,
power: 0,
attackBonus: 3,
hitBonus: 4,
criticalBonus: 12,
duration: 2,
description: '사용자 자신을 짧게 몰아붙이는 공격·치명 강화 도구.'
}
};
export const unitStrategyIds: Record<string, string[]> = {
'liu-bei': ['aid', 'encourage'],
'guan-yu': ['fireTactic'],
'zhang-fei': ['roar'],
'jian-yong': ['aid', 'encourage', 'fireTactic'],
'mi-zhu': ['aid', 'encourage'],
'sun-qian': ['aid', 'encourage', 'fireTactic'],
'zhao-yun': ['encourage'],
'zhuge-liang': ['aid', 'encourage', 'fireTactic'],
'ma-liang': ['aid', 'encourage', 'fireTactic'],
'yi-ji': ['aid', 'encourage'],
'gong-zhi': ['encourage'],
'huang-zhong': ['encourage'],
'wei-yan': ['roar'],
'pang-tong': ['aid', 'encourage', 'fireTactic'],
'fa-zheng': ['aid', 'encourage', 'fireTactic'],
'wu-yi': ['encourage'],
'yan-yan': ['encourage'],
'li-yan': ['aid', 'encourage'],
'huang-quan': ['aid', 'encourage'],
'ma-chao': ['roar', 'encourage'],
'ma-dai': ['encourage'],
'wang-ping': ['aid', 'encourage'],
'jiang-wei': ['aid', 'encourage', 'fireTactic']
};
export const strategyCoverageDefinitions = [
{ id: 'healing', label: '회복', usableId: 'aid' },
{ id: 'buff', label: '강화', usableId: 'encourage' },
{ id: 'fire', label: '화공', usableId: 'fireTactic' },
{ id: 'control', label: '제어', usableId: 'roar' }
] as const;
export type StrategyCoverageDefinition = (typeof strategyCoverageDefinitions)[number];
export type UnitStrategyCoverage = {
total: number;
count: number;
covered: StrategyCoverageDefinition[];
missing: StrategyCoverageDefinition[];
};
export type UnitStrategyCoverageComparison = {
current: UnitStrategyCoverage;
projected: UnitStrategyCoverage;
gained: StrategyCoverageDefinition[];
lost: StrategyCoverageDefinition[];
delta: number;
trend: 'gain' | 'loss' | 'even';
};
function uniqueUnitStrategyIds(unitIds: readonly string[]) {
const strategyIds = new Set<string>();
unitIds.forEach((unitId) => {
(unitStrategyIds[unitId] ?? []).forEach((strategyId) => strategyIds.add(strategyId));
});
return strategyIds;
}
export function getUnitStrategyCoverage(unitIds: readonly string[]): UnitStrategyCoverage {
const strategyIds = uniqueUnitStrategyIds(unitIds);
const covered = strategyCoverageDefinitions.filter((definition) => strategyIds.has(definition.usableId));
const missing = strategyCoverageDefinitions.filter((definition) => !strategyIds.has(definition.usableId));
return {
total: strategyCoverageDefinitions.length,
count: covered.length,
covered: [...covered],
missing: [...missing]
};
}
export function getUnitStrategyNames(unitIds: readonly string[]) {
return [...uniqueUnitStrategyIds(unitIds)]
.map((strategyId) => usableCatalog[strategyId])
.filter((usable): usable is BattleUsable => Boolean(usable))
.map((usable) => usable.name);
}
export function compareUnitStrategyCoverage(
currentUnitIds: readonly string[],
projectedUnitIds: readonly string[]
): UnitStrategyCoverageComparison {
const current = getUnitStrategyCoverage(currentUnitIds);
const projected = getUnitStrategyCoverage(projectedUnitIds);
const currentIds = new Set(current.covered.map((definition) => definition.id));
const projectedIds = new Set(projected.covered.map((definition) => definition.id));
const gained = projected.covered.filter((definition) => !currentIds.has(definition.id));
const lost = current.covered.filter((definition) => !projectedIds.has(definition.id));
const delta = projected.count - current.count;
return {
current,
projected,
gained,
lost,
delta,
trend: delta > 0 ? 'gain' : delta < 0 ? 'loss' : 'even'
};
}

View File

@@ -25,6 +25,13 @@ import {
loadBattleUiIcons,
type BattleUiIconKey
} from '../data/battleUiIcons';
import {
usableCatalog,
unitStrategyIds,
type BattleStatusKind,
type BattleUsable,
type UsableCommand
} from '../data/battleUsables';
import {
equipmentExpToNext,
equipmentSlotLabels,
@@ -1185,11 +1192,8 @@ type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate';
const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8];
type BattlePhase = 'deployment' | 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
type BattleCommand = 'attack' | UsableCommand | 'wait';
type DamageCommand = Exclude<BattleCommand, 'wait'>;
type UsableCommand = Extract<BattleCommand, 'strategy' | 'item'>;
type UsableEffect = 'damage' | 'heal' | 'focus';
type UsableTarget = 'enemy' | 'ally' | 'self';
type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar';
type RosterTab = 'ally' | 'enemy';
type ActiveFaction = 'ally' | 'enemy';
@@ -1614,25 +1618,6 @@ type CombatResult = CombatPreview & {
counter?: CombatResult;
};
type BattleUsable = {
id: string;
command: UsableCommand;
name: string;
target: UsableTarget;
effect: UsableEffect;
range: number;
power: number;
accuracyBonus?: number;
criticalBonus?: number;
attackBonus?: number;
hitBonus?: number;
duration?: number;
statusEffect?: BattleStatusKind;
statusDuration?: number;
statusPower?: number;
description: string;
};
type SupportPreview = {
usable: BattleUsable;
user: UnitData;
@@ -1803,8 +1788,6 @@ type TacticalInitiativeCombatEffect = {
labels: string[];
};
type BattleStatusKind = 'burn' | 'confusion';
type BattleStatusState = {
unitId: string;
kind: BattleStatusKind;
@@ -1879,123 +1862,6 @@ let battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`;
let initialBattleUnits = battleUnits.map(cloneUnitData);
let initialBattleBonds = battleBonds.map(cloneBattleBond);
const usableCatalog: Record<string, BattleUsable> = {
aid: {
id: 'aid',
command: 'strategy',
name: '응급',
target: 'ally',
effect: 'heal',
range: 2,
power: 10,
description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.'
},
encourage: {
id: 'encourage',
command: 'strategy',
name: '격려',
target: 'ally',
effect: 'focus',
range: 2,
power: 0,
attackBonus: 2,
hitBonus: 10,
criticalBonus: 4,
duration: 2,
description: '아군의 공격과 명중을 안정적으로 끌어올린다.'
},
fireTactic: {
id: 'fireTactic',
command: 'strategy',
name: '화계',
target: 'enemy',
effect: 'damage',
range: 2,
power: 10,
accuracyBonus: 4,
criticalBonus: 0,
statusEffect: 'burn',
statusDuration: 2,
statusPower: 3,
description: '적 한 부대에 책략 피해와 짧은 화상 지속 피해를 준다.'
},
roar: {
id: 'roar',
command: 'strategy',
name: '고함',
target: 'enemy',
effect: 'damage',
range: 1,
power: 7,
accuracyBonus: 10,
criticalBonus: 0,
statusEffect: 'confusion',
statusDuration: 2,
statusPower: 1,
description: '인접한 적을 위압해 다음 행동의 공격, 명중, 치명을 낮춘다.'
},
bean: {
id: 'bean',
command: 'item',
name: '콩',
target: 'ally',
effect: 'heal',
range: 1,
power: 12,
description: '인접 아군을 빠르게 소량 회복하는 기본 보급품.'
},
salve: {
id: 'salve',
command: 'item',
name: '상처약',
target: 'ally',
effect: 'heal',
range: 1,
power: 22,
description: '인접 아군의 큰 피해를 되돌리는 고가 회복품.'
},
wine: {
id: 'wine',
command: 'item',
name: '술',
target: 'self',
effect: 'focus',
range: 0,
power: 0,
attackBonus: 3,
hitBonus: 4,
criticalBonus: 12,
duration: 2,
description: '사용자 자신을 짧게 몰아붙이는 공격·치명 강화 도구.'
}
};
const unitStrategyIds: Record<string, string[]> = {
'liu-bei': ['aid', 'encourage'],
'guan-yu': ['fireTactic'],
'zhang-fei': ['roar'],
'jian-yong': ['aid', 'encourage', 'fireTactic'],
'mi-zhu': ['aid', 'encourage'],
'sun-qian': ['aid', 'encourage', 'fireTactic'],
'zhao-yun': ['encourage'],
'zhuge-liang': ['aid', 'encourage', 'fireTactic'],
'ma-liang': ['aid', 'encourage', 'fireTactic'],
'yi-ji': ['aid', 'encourage'],
'gong-zhi': ['encourage'],
'huang-zhong': ['encourage'],
'wei-yan': ['roar'],
'pang-tong': ['aid', 'encourage', 'fireTactic'],
'fa-zheng': ['aid', 'encourage', 'fireTactic'],
'wu-yi': ['encourage'],
'yan-yan': ['encourage'],
'li-yan': ['aid', 'encourage'],
'huang-quan': ['aid', 'encourage'],
'ma-chao': ['roar', 'encourage'],
'ma-dai': ['encourage'],
'wang-ping': ['aid', 'encourage'],
'jiang-wei': ['aid', 'encourage', 'fireTactic']
};
const initialItemStocks: Record<string, Record<string, number>> = {
'liu-bei': { bean: 2, salve: 1 },
'guan-yu': { bean: 1 },

View File

@@ -12,6 +12,7 @@ import {
type EquipmentSlot,
type ItemDefinition
} from '../data/battleItems';
import { compareUnitStrategyCoverage, getUnitStrategyCoverage, getUnitStrategyNames } from '../data/battleUsables';
import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules';
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
@@ -294,6 +295,7 @@ type SortieUnitTacticalSummary = {
equipmentLine: string;
bondLine: string;
terrainLine: string;
strategyLine: string;
};
type SortieFocusedUnitSummary = SortieUnitTacticalSummary & {
@@ -13268,6 +13270,14 @@ export class CampScene extends Phaser.Scene {
this.textStyle(10, availability.available ? previewColor : '#68727e', selected || Boolean(recommendation))
)
).setDepth(depth + 2);
this.trackSortie(
this.add.text(
cardX + 92,
cardY + 70,
this.compactText(this.sortieStrategyLine(unit.id), 20),
this.textStyle(8, availability.available ? '#9fc8e8' : '#68727e', true)
)
).setDepth(depth + 2);
const canToggle = availability.available && !(selected && required);
this.renderFirstSortieInlineButton(
@@ -13733,15 +13743,22 @@ export class CampScene extends Phaser.Scene {
const showMetricTransition = !isUndoAction && Boolean(preview?.projected);
const comparison = isUndoAction ? undefined : preview?.comparison;
const pursuitChanges = this.changedSortiePursuitPairs(current, projected);
const strategyCoverageComparison = compareUnitStrategyCoverage(current.selectedUnitIds, projected.selectedUnitIds);
const currentStrategyCoverage = strategyCoverageComparison.current;
const projectedStrategyCoverage = strategyCoverageComparison.projected;
const gainedStrategies = strategyCoverageComparison.gained;
const lostStrategies = strategyCoverageComparison.lost;
const strategyCoverageDelta = strategyCoverageComparison.delta;
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);
const lostStrategyCoverage = strategyCoverageDelta < 0;
const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length;
view.background.setStrokeStyle(
lostRole ? 2 : 1,
lostRole ? palette.red : isHoverPreview ? palette.gold : completeFormation ? palette.green : palette.gold,
lostRole ? 0.78 : isHoverPreview ? 0.72 : 0.58
lostRole || lostStrategyCoverage ? 2 : 1,
lostRole || lostStrategyCoverage ? palette.red : isHoverPreview ? palette.gold : completeFormation ? palette.green : palette.gold,
lostRole || lostStrategyCoverage ? 0.78 : isHoverPreview ? 0.72 : 0.58
);
if (this.sortieCoreResonanceSelectorOpen) {
@@ -13786,14 +13803,18 @@ export class CampScene extends Phaser.Scene {
return;
}
view.title.setText('편성 변화');
view.summary.setText(`추격 후보 ${pursuitChanges.currentPairs.length} · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`);
view.summary.setText(`전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}`);
view.headline.setText('출전 무장을 클릭해 교체 기준으로 고정하십시오.').setColor('#9fb0bf');
view.metrics.forEach((metric) => {
metric.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34);
metric.value.setText('-').setColor('#77818c');
});
view.impact.setText('후보 초상에 마우스를 올리면 역할·추격 후보 변화가 표시됩니다.').setColor('#d4dce6');
view.detail.setText('미리보기는 실제 출전 명단과 보급을 변경하지 않습니다.').setColor('#9fb0bf');
view.impact
.setText(`전법 ${currentStrategyCoverage.covered.map((definition) => definition.label).join('·') || '없음'} · 후보 초상에 마우스를 올려 변화를 비교하세요.`)
.setColor(currentStrategyCoverage.count === currentStrategyCoverage.total ? '#a8ffd0' : '#d4dce6');
view.detail
.setText(currentStrategyCoverage.missing.length > 0 ? `빈 전법 ${currentStrategyCoverage.missing.map((definition) => definition.label).join('·')} · 미리보기는 실제 편성을 변경하지 않습니다.` : '전법 역할이 모두 갖춰졌습니다. 미리보기는 실제 편성을 변경하지 않습니다.')
.setColor(currentStrategyCoverage.missing.length > 0 ? '#ffdf7b' : '#9fb0bf');
return;
}
@@ -13813,7 +13834,7 @@ export class CampScene extends Phaser.Scene {
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}`,
`전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total}${projectedStrategyCoverage.count}/${projectedStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}${pursuitChanges.projectedPairs.length}`,
26
)
);
@@ -13856,7 +13877,7 @@ export class CampScene extends Phaser.Scene {
if (action?.kind === 'undo-preset' && this.sortiePresetUndoState) {
view.impact
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`)
.setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`)
.setColor('#a8ffd0');
view.detail
.setText('유지 가능한 보급만 보존했습니다. 직전 적용은 한 번 되돌릴 수 있습니다.')
@@ -13866,7 +13887,7 @@ export class CampScene extends Phaser.Scene {
if (isUndoAction && this.sortieSwapUndoState) {
view.impact
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`)
.setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`)
.setColor('#a8ffd0');
view.detail
.setText('장비·보급은 자동 이전하지 않았습니다. 직전 교체는 한 번 되돌릴 수 있습니다.')
@@ -13893,14 +13914,15 @@ export class CampScene extends Phaser.Scene {
const roleChange = roleChanges.join(' · ') || '역할 유지';
const pursuitDelta = pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length;
const signedPursuitDelta = pursuitDelta > 0 ? `+${pursuitDelta}` : `${pursuitDelta}`;
const strategyCoverageChange = `전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total}${projectedStrategyCoverage.count}/${projectedStrategyCoverage.total}`;
view.impact
.setText(
this.compactText(
`역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}${pursuitChanges.projectedPairs.length}조 (${signedPursuitDelta}) · ${roleChange}`,
`${strategyCoverageChange} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}${pursuitChanges.projectedPairs.length}조 (${signedPursuitDelta}) · ${roleChange}`,
68
)
)
.setColor(lostRoleLabels.length > 0 || pursuitDelta < 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || pursuitDelta > 0 ? '#a8ffd0' : '#d4dce6');
.setColor(lostRoleLabels.length > 0 || strategyCoverageDelta < 0 || pursuitDelta < 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || strategyCoverageDelta > 0 || pursuitDelta > 0 ? '#a8ffd0' : '#d4dce6');
const gainedPursuits = pursuitChanges.gainedPairs.map((pair) => `추격 개방 ${this.sortiePursuitPairLabel(pair)}`);
const lostPursuits = pursuitChanges.lostPairs.map((pair) => `추격 해제 ${this.sortiePursuitPairLabel(pair)}`);
@@ -13910,10 +13932,14 @@ export class CampScene extends Phaser.Scene {
.join(' · ') || '추격 변화 없음';
const terrainDelta = projected.terrainScore - current.terrainScore;
const signedTerrainDelta = terrainDelta > 0 ? `+${terrainDelta}` : `${terrainDelta}`;
const strategyChanges = [
gainedStrategies.length > 0 ? `전법 보강 ${gainedStrategies.map((definition) => definition.label).join('·')}` : '',
lostStrategies.length > 0 ? `전법 공백 ${lostStrategies.map((definition) => definition.label).join('·')}` : ''
].filter(Boolean).join(' · ') || '전법 유지';
view.detail
.setText(
this.compactText(
`${pursuitLine} · 지형 ${current.terrainGrade} ${current.terrainScore}${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · ${roleChange} · 실제 편성 미변경`,
`${strategyChanges} · ${pursuitLine} · 지형 ${current.terrainGrade} ${current.terrainScore}${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · ${roleChange} · 실제 편성 미변경`,
82
)
)
@@ -14115,6 +14141,7 @@ export class CampScene extends Phaser.Scene {
})
).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
this.renderFirstSortieInlineButton(
required ? '필수 출전' : selected ? '출전 확정' : '대기',
@@ -14807,6 +14834,7 @@ export class CampScene extends Phaser.Scene {
const scenario = this.nextSortieScenario();
const hasBattle = Boolean(scenario);
const pursuit = this.sortiePursuitPairs();
const strategyCoverage = this.sortieStrategyCoverage();
const canUsePresetBook = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && !this.isFirstSortiePrepSlice();
const canUseSortieOrder = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && hasBattle;
const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && this.sortieFormationPanelMode === 'roster';
@@ -14847,8 +14875,8 @@ export class CampScene extends Phaser.Scene {
x + 18,
y + 38,
hasBattle
? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 추격 후보 ${pursuit.activePairs.length}조 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54)
: `${plan.selectedCount}/${plan.maxCount}명 · 군영 의정 · ${this.sortieRecommendedClassLine()}`,
? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 전법 ${strategyCoverage.count}/${strategyCoverage.total} · 추격 ${pursuit.activePairs.length}조 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54)
: `${plan.selectedCount}/${plan.maxCount}명 · 전법 ${strategyCoverage.count}/${strategyCoverage.total} · 군영 의정 · ${this.sortieRecommendedClassLine()}`,
this.textStyle(11, '#d8b15f', true)
)
).setDepth(depth + 1);
@@ -15026,7 +15054,12 @@ export class CampScene extends Phaser.Scene {
this.renderSortieStatChip('move', '이', unit.move, x + 252, statY, chipWidth, depth + 1);
this.trackSortie(this.add.text(x + 18, y + 137, `지형 ${terrainGrade} ${terrainScore} · ${this.sortieTerrainLine(unit)}`, this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 157, this.compactText(synergyPreview.headline, 38), this.textStyle(11, synergyColor, true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 18, y + 157, this.compactText(this.sortieStrategyLine(unit.id), 18), this.textStyle(10, '#9fc8e8', true))
).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 154, y + 157, this.compactText(synergyPreview.headline, 25), this.textStyle(10, synergyColor, true))
).setDepth(depth + 1);
equipmentSlots.forEach((slot, index) => {
const rowY = y + 174 + index * 20;
@@ -15238,10 +15271,20 @@ export class CampScene extends Phaser.Scene {
statLine: `${unit.stats.might}${unit.stats.intelligence}${unit.stats.leadership}${unit.stats.agility}${unit.stats.luck}`,
equipmentLine: this.sortieEquipmentLine(unit),
bondLine: this.sortieBondLine(unit),
terrainLine: this.sortieTerrainLine(unit)
terrainLine: this.sortieTerrainLine(unit),
strategyLine: this.sortieStrategyLine(unit.id)
};
}
private sortieStrategyLine(unitId: string) {
const names = getUnitStrategyNames([unitId]);
return names.length > 0 ? `전법 ${names.join('·')}` : '전법 없음';
}
private sortieStrategyCoverage(unitIds = this.selectedSortieUnitIds) {
return getUnitStrategyCoverage(unitIds);
}
private sortieEquipmentLine(unit: UnitData) {
return equipmentSlots
.map((slot) => {
@@ -16615,6 +16658,7 @@ export class CampScene extends Phaser.Scene {
equipmentLine: summary.equipmentLine,
bondLine: summary.bondLine,
terrainLine: summary.terrainLine,
strategyLine: summary.strategyLine,
equipment: equipmentSlots.map((slot) => {
const state = unit.equipment[slot];
const item = getItem(state.itemId);
@@ -20874,6 +20918,13 @@ export class CampScene extends Phaser.Scene {
const portraitRosterMaxScroll = Math.max(0, (portraitRosterPageCount - 1) * sortiePortraitRosterPageSize);
const sortieComparisonPreview = this.sortieFormationComparisonPreview();
const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview);
const sortieStrategyCoverage = this.sortieStrategyCoverage();
const sortieComparisonStrategyCoverage = sortieComparisonPreview
? compareUnitStrategyCoverage(
sortieComparisonPreview.current.selectedUnitIds,
sortieComparisonPreview.projected?.selectedUnitIds ?? sortieComparisonPreview.current.selectedUnitIds
)
: null;
const sortiePursuit = this.sortiePursuitPairs();
const sortieCoreResonanceCandidates = this.sortieCoreResonanceCandidates();
const sortieCoreResonanceSelection = this.campaign?.sortieResonanceSelection;
@@ -21246,6 +21297,8 @@ export class CampScene extends Phaser.Scene {
available: availability.available,
availabilityLabel: availability.label,
availabilityReason: availability.reason,
strategyNames: getUnitStrategyNames([unit.id]),
strategyLine: this.sortieStrategyLine(unit.id),
portraitTextureKey: unitPortraitTextureKey ?? null,
portraitReady: Boolean(unitPortraitTextureKey && this.textures.exists(unitPortraitTextureKey))
};
@@ -21312,7 +21365,12 @@ export class CampScene extends Phaser.Scene {
};
})
},
sortieComparisonPreview: sortieComparisonPreview ?? null,
sortieComparisonPreview: sortieComparisonPreview
? {
...sortieComparisonPreview,
strategyCoverage: sortieComparisonStrategyCoverage
}
: null,
sortieComparisonAction: sortieComparisonAction ? { ...sortieComparisonAction, enabled: true } : null,
sortieSwapUndo: this.sortieSwapUndoState
? {
@@ -21328,6 +21386,12 @@ export class CampScene extends Phaser.Scene {
}
: null,
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
sortieStrategyCoverage: {
total: sortieStrategyCoverage.total,
count: sortieStrategyCoverage.count,
covered: sortieStrategyCoverage.covered.map((definition) => ({ ...definition })),
missing: sortieStrategyCoverage.missing.map((definition) => ({ ...definition }))
},
sortiePlanFeedback: this.sortiePlanFeedback,
sortieChecklist,
sortieChecklistSummary: this.sortieChecklistSummary(sortieChecklist),
@@ -21369,7 +21433,8 @@ export class CampScene extends Phaser.Scene {
statLine: summary.statLine,
equipmentLine: summary.equipmentLine,
bondLine: summary.bondLine,
terrainLine: summary.terrainLine
terrainLine: summary.terrainLine,
strategyLine: summary.strategyLine
};
}),
equipmentInventory: this.equipmentInventoryEntries().map((entry) => ({