Add focused sortie officer analysis

This commit is contained in:
2026-06-23 18:28:00 +09:00
parent dd6d85ffe9
commit dac4ed0f5a
3 changed files with 154 additions and 17 deletions

View File

@@ -143,6 +143,30 @@ type SortieUnitTacticalSummary = {
terrainLine: string;
};
type SortieFocusedUnitSummary = SortieUnitTacticalSummary & {
id: string;
name: string;
selected: boolean;
required: boolean;
recommended: boolean;
recommendationReason: string | null;
className: string;
classRole: string;
formationRole: SortieFormationRole;
terrainScore: number;
terrainGrade: string;
equipment: {
slot: EquipmentSlot;
label: string;
itemName: string;
level: number;
exp: number;
next: number;
rank: string;
bonusText: string;
}[];
};
type SortieTerrainCounts = Partial<Record<TerrainType, number>>;
type SortieRecommendation = {
@@ -6218,6 +6242,7 @@ export class CampScene extends Phaser.Scene {
private tabButtons: CampTabButtonView[] = [];
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
private sortieFocusedUnitId = 'liu-bei';
private terrainCountCache = new Map<BattleScenarioId, SortieTerrainCounts>();
constructor() {
@@ -7235,6 +7260,7 @@ export class CampScene extends Phaser.Scene {
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? this.report;
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
this.ensureSortieFocus();
const depth = 40;
const width = 1188;
@@ -7332,7 +7358,7 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(
this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${maxUnits}`, this.textStyle(18, '#f2e3bf', true))
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 분석 및 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1);
allies.forEach((unit, index) => {
const rowY = y + (denseRoster ? 48 : 50) + index * rowGap;
@@ -7345,7 +7371,10 @@ export class CampScene extends Phaser.Scene {
row.setOrigin(0);
row.setDepth(depth + 1);
row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38);
row.setInteractive({ useHandCursor: !required });
row.setInteractive({ useHandCursor: true });
row.on('pointerover', () => {
this.sortieFocusedUnitId = unit.id;
});
row.on('pointerdown', () => this.toggleSortieUnit(unit.id));
const marker = selected ? '출전' : '대기';
@@ -7389,23 +7418,48 @@ export class CampScene extends Phaser.Scene {
plan.objectiveLine
];
summaryLines.forEach((line, index) => {
this.trackSortie(this.add.text(x + 18, y + 146 + index * 16, this.compactText(line, 32), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 142 + index * 15, this.compactText(line, 32), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1);
});
plan.formationSlots.forEach((slot, index) => {
const column = index % 2;
const row = Math.floor(index / 2);
const rowX = x + 18 + column * 176;
const rowY = y + 208 + row * 24;
const names = slot.unitNames.length > 0 ? slot.unitNames.join(', ') : '미배치';
const color = slot.unitNames.length > 0 ? '#a8ffd0' : '#9fb0bf';
this.trackSortie(this.add.text(rowX, rowY, slot.label, this.textStyle(12, color, true))).setDepth(depth + 1);
this.trackSortie(this.add.text(rowX + 42, rowY, this.compactText(names, 13), this.textStyle(11, '#c8d2dd'))).setDepth(depth + 1);
this.trackSortie(this.add.text(rowX + 42, rowY + 13, this.compactText(slot.description, 14), this.textStyle(10, '#87919c'))).setDepth(depth + 1);
});
this.renderSortieFocusPanel(x + 18, y + 190, width - 36, 86, depth + 1);
}
const warning = plan.warnings[0] ?? plan.reserveLine;
this.trackSortie(this.add.text(x + 18, y + height - 24, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1);
private renderSortieFocusPanel(x: number, y: number, width: number, height: number, depth: number) {
const unit = this.sortieFocusedUnit();
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.42);
if (!unit) {
this.trackSortie(this.add.text(x + 12, y + 18, '선택 가능한 무장이 없습니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
return;
}
const status = this.unitRosterStatus(unit);
const summary = this.sortieUnitTacticalSummary(unit);
const recommendation = this.sortieRecommendation(unit.id);
const roleLabel = sortieFormationSlotDefinitions.find((slot) => slot.role === this.sortieFormationRole(unit))?.label ?? '예비';
const terrainScore = this.sortieTerrainScore(unit, this.nextSortieScenario());
const terrainGrade = this.sortieTerrainGrade(terrainScore);
const statusColor = status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
const headline = `${unit.name} Lv${unit.level} · ${unit.className} · ${roleLabel}`;
const equipLine = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
const item = getItem(state.itemId);
return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`;
})
.join(' / ');
const reason = recommendation?.reason ?? (this.isSortieSelected(unit.id) ? '현재 출전 편성에 포함되어 있습니다.' : '대기 훈련으로 성장시키거나 전장 역할에 맞춰 교체할 수 있습니다.');
this.trackSortie(this.add.text(x + 12, y + 7, '선택 무장 분석', this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 104, y + 6, this.compactText(headline, 24), this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 12, y + 7, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 12, y + 24, this.compactText(`${summary.statLine} · 지형 ${terrainGrade}(${terrainScore})`, 44), this.textStyle(10, '#c8d2dd'))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 12, y + 39, this.compactText(equipLine, 44), this.textStyle(10, '#c8d2dd'))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 12, y + 54, this.compactText(summary.bondLine, 44), this.textStyle(10, '#a8ffd0', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 12, y + height - 13, this.compactText(reason, 44), this.textStyle(10, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)))).setDepth(depth + 1);
}
private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) {
@@ -7542,6 +7596,69 @@ export class CampScene extends Phaser.Scene {
return this.nextSortieRule(scenario).recommended.find((entry) => entry.unitId === unitId);
}
private ensureSortieFocus() {
const allies = this.sortieAllies();
if (allies.some((unit) => unit.id === this.sortieFocusedUnitId)) {
return;
}
const selectedIds = new Set(this.selectedSortieUnitIds);
const recommended = this.nextSortieRule()
.recommended
.find((entry) => allies.some((unit) => unit.id === entry.unitId));
this.sortieFocusedUnitId =
recommended?.unitId ??
allies.find((unit) => selectedIds.has(unit.id))?.id ??
allies[0]?.id ??
'liu-bei';
}
private sortieFocusedUnit() {
return this.sortieAllies().find((unit) => unit.id === this.sortieFocusedUnitId) ?? this.selectedSortieUnits()[0] ?? this.sortieAllies()[0];
}
private sortieFocusedUnitSummary(): SortieFocusedUnitSummary | null {
const unit = this.sortieFocusedUnit();
if (!unit) {
return null;
}
const summary = this.sortieUnitTacticalSummary(unit);
const recommendation = this.sortieRecommendation(unit.id);
const scenario = this.nextSortieScenario();
const terrainScore = this.sortieTerrainScore(unit, scenario);
return {
id: unit.id,
name: unit.name,
selected: this.isSortieSelected(unit.id),
required: this.isRequiredSortieUnit(unit.id),
recommended: Boolean(recommendation),
recommendationReason: recommendation?.reason ?? null,
className: unit.className,
classRole: getUnitClass(unit.classKey).role,
formationRole: this.sortieFormationRole(unit),
terrainScore,
terrainGrade: this.sortieTerrainGrade(terrainScore),
statLine: summary.statLine,
equipmentLine: summary.equipmentLine,
bondLine: summary.bondLine,
terrainLine: summary.terrainLine,
equipment: equipmentSlots.map((slot) => {
const state = unit.equipment[slot];
const item = getItem(state.itemId);
return {
slot,
label: equipmentSlotLabels[slot],
itemName: item.name,
level: state.level,
exp: state.exp,
next: equipmentExpToNext(state.level),
rank: item.rank,
bonusText: this.itemBonusText(item)
};
})
};
}
private renderSortieDeploymentMap(x: number, y: number, width: number, height: number, depth: number) {
const scenario = this.nextSortieScenario();
const frame = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.9));
@@ -9010,6 +9127,7 @@ export class CampScene extends Phaser.Scene {
if (!unit) {
return;
}
this.sortieFocusedUnitId = unitId;
if (this.isRequiredSortieUnit(unitId)) {
this.showCampNotice(`${unit.name}는 반드시 출전해야 합니다.`);
return;
@@ -9134,6 +9252,8 @@ export class CampScene extends Phaser.Scene {
selectedDialogueId: this.selectedDialogueId,
selectedVisitId: this.selectedVisitId,
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
sortieFocusedUnitId: this.sortieFocusedUnitId,
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
reserveTrainingFocus: this.reserveTrainingFocusDefinition(),
reserveTrainingFocusOptions: campaignReserveTrainingFocusDefinitions.map((focus) => ({ ...focus })),
sortieRoster: this.sortieAllies().map((unit) => {