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

@@ -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) => ({