Enhance sortie formation planning

This commit is contained in:
2026-06-23 02:33:34 +09:00
parent 683357f6a7
commit 041f798475
3 changed files with 271 additions and 23 deletions

View File

@@ -71,6 +71,27 @@ type SortieUnitTacticalSummary = {
terrainLine: string;
};
type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve';
type SortieFormationSlot = {
role: SortieFormationRole;
label: string;
description: string;
unitNames: string[];
};
type SortiePlanSummary = {
selectedCount: number;
maxCount: number;
terrainScore: number;
terrainGrade: string;
activeBondCount: number;
deploymentLine: string;
objectiveLine: string;
formationSlots: SortieFormationSlot[];
warnings: string[];
};
const portraitKeys: Record<PortraitKey, string> = {
liuBei: 'portrait-liu-bei',
guanYu: 'portrait-guan-yu',
@@ -146,6 +167,12 @@ const campBattleIds = {
const requiredSortieUnitIds = new Set(['liu-bei']);
const maxSortieUnits = 6;
const sortieFormationSlotDefinitions: Omit<SortieFormationSlot, 'unitNames'>[] = [
{ role: 'front', label: '전열', description: '길목을 막고 반격을 받아냄' },
{ role: 'flank', label: '돌파', description: '측면 기동과 추격 담당' },
{ role: 'support', label: '후원', description: '원거리, 책략, 보급 지원' },
{ role: 'reserve', label: '예비', description: '빈틈 보강과 공명 연결' }
];
const campDialogues: CampDialogue[] = [
{
@@ -1343,8 +1370,8 @@ export class CampScene extends Phaser.Scene {
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
const depth = 40;
const width = 1160;
const height = 660;
const width = 1188;
const height = 676;
const x = Math.floor((this.scale.width - width) / 2);
const y = Math.floor((this.scale.height - height) / 2);
@@ -1365,16 +1392,17 @@ export class CampScene extends Phaser.Scene {
);
subtitle.setDepth(depth + 2);
this.renderSortieBriefing(x + 34, y + 112, 520, 178, depth + 2);
this.renderSortieChecklist(x + 576, y + 112, 550, 178, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 310, 1092, 252, depth + 2);
this.renderSortieRewardHint(x + 34, y + 576, 696, 52, depth + 2);
this.renderSortieBriefing(x + 34, y + 104, 520, 168, depth + 2);
this.renderSortieChecklist(x + 576, y + 104, 578, 168, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 728, 286, depth + 2);
this.renderSortieFormationPlan(x + 782, y + 292, 372, 286, depth + 2);
this.renderSortieRewardHint(x + 34, y + 596, 808, 52, depth + 2);
this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => {
this.addSortieButton('군영으로', x + width - 298, y + height - 42, 124, () => {
soundDirector.playSelect();
this.hideSortiePrep();
}, depth + 3);
this.addSortieButton('출진', x + width - 152, y + height - 44, 124, () => {
this.addSortieButton('출진', x + width - 152, y + height - 42, 124, () => {
soundDirector.playSelect();
this.startVictoryStory();
}, depth + 3);
@@ -1423,15 +1451,15 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.blue, 0.48);
const allies = this.sortieAllies();
const selectedCount = this.selectedSortieUnitIds.length;
const rowGap = allies.length > 4 ? 32 : 36;
const rowHeight = allies.length > 4 ? 31 : 32;
const rowGap = allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48;
const rowHeight = rowGap - 6;
this.trackSortie(
this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${Math.min(maxSortieUnits, allies.length)}`, 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);
allies.forEach((unit, index) => {
const rowY = y + 48 + index * rowGap;
const rowY = y + 50 + index * rowGap;
const selected = this.isSortieSelected(unit.id);
const required = requiredSortieUnitIds.has(unit.id);
const summary = this.sortieUnitTacticalSummary(unit);
@@ -1445,18 +1473,57 @@ export class CampScene extends Phaser.Scene {
const marker = selected ? '출전' : '대기';
this.trackSortie(this.add.text(x + 30, rowY, required ? '필수' : marker, this.textStyle(12, required ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 84, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 218, rowY, `${unit.className} · ${getUnitClass(unit.classKey).role}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 462, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(x + 554, rowY + 6, 86, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
this.trackSortie(this.add.text(x + 666, rowY, summary.terrainLine, this.textStyle(11, selected ? '#d8b15f' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 214, rowY, this.compactText(`${unit.className} · ${getUnitClass(unit.classKey).role}`, 19), this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 424, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(x + 514, rowY + 6, 74, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
this.trackSortie(this.add.text(x + 610, rowY, this.compactText(summary.terrainLine, 13), this.textStyle(11, selected ? '#d8b15f' : '#77818c', true))).setDepth(depth + 2);
this.renderSortieEquipmentIcons(unit, x + 84, rowY + 19, selected, depth + 2);
this.trackSortie(this.add.text(x + 162, rowY + 18, summary.equipmentLine, this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 522, rowY + 18, summary.statLine, this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 760, rowY + 18, summary.bondLine, this.textStyle(11, selected ? '#a8ffd0' : '#68727e', selected))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 162, rowY + 18, this.compactText(summary.equipmentLine, 34), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 424, rowY + 18, this.compactText(summary.statLine, 23), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 610, rowY + 18, this.compactText(summary.bondLine, 15), this.textStyle(11, selected ? '#a8ffd0' : '#68727e', selected))).setDepth(depth + 2);
});
}
private renderSortieFormationPlan(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.5);
const plan = this.sortiePlanSummary();
this.trackSortie(this.add.text(x + 18, y + 14, '전술 편성', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + width - 18, y + 17, `${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade}`, this.textStyle(12, '#d8b15f', true))
).setOrigin(1, 0).setDepth(depth + 1);
this.renderSortieDeploymentMap(x + 18, y + 46, width - 36, 92, depth + 1);
const summaryLines = [
plan.deploymentLine,
`활성 공명 ${plan.activeBondCount}개 · 평균 적성 ${plan.terrainScore}`,
plan.objectiveLine
];
summaryLines.forEach((line, index) => {
this.trackSortie(this.add.text(x + 18, y + 150 + index * 18, this.compactText(line, 31), 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 * 28;
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);
});
const warning = plan.warnings[0] ?? '전열, 돌파, 지원의 균형이 좋습니다.';
this.trackSortie(this.add.text(x + 18, y + height - 28, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1);
}
private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) {
equipmentSlots.forEach((slot, index) => {
const state = unit.equipment[slot];
@@ -1562,6 +1629,149 @@ export class CampScene extends Phaser.Scene {
return nextBattleId ? getBattleScenario(nextBattleId) : undefined;
}
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));
frame.setOrigin(0);
frame.setDepth(depth);
frame.setStrokeStyle(1, palette.blue, 0.48);
if (!scenario) {
this.trackSortie(this.add.text(x + 14, y + 34, '다음 전투 배치 정보가 없습니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
return;
}
const mapWidth = scenario.map.width;
const mapHeight = scenario.map.height;
const cell = Math.min((width - 16) / mapWidth, (height - 16) / mapHeight);
const originX = x + Math.floor((width - mapWidth * cell) / 2);
const originY = y + Math.floor((height - mapHeight * cell) / 2);
const graphics = this.trackSortie(this.add.graphics());
graphics.setDepth(depth + 1);
scenario.map.terrain.forEach((row, rowIndex) => {
row.forEach((terrain, columnIndex) => {
const rule = terrainRules[terrain];
graphics.fillStyle(rule.color, terrain === 'river' || terrain === 'cliff' ? 0.82 : 0.58);
graphics.fillRect(originX + columnIndex * cell, originY + rowIndex * cell, Math.ceil(cell), Math.ceil(cell));
});
});
const selected = new Set(this.selectedSortieUnitIds);
scenario.units.forEach((unit) => {
if (unit.faction !== 'enemy' && !selected.has(unit.id)) {
return;
}
const dotX = originX + (unit.x + 0.5) * cell;
const dotY = originY + (unit.y + 0.5) * cell;
const color = unit.faction === 'enemy' ? 0xd35f4a : unit.id === 'liu-bei' ? palette.gold : palette.green;
const alpha = unit.faction === 'enemy' ? 0.72 : 0.96;
const dot = this.trackSortie(this.add.circle(dotX, dotY, unit.faction === 'enemy' ? 2.2 : 3.7, color, alpha));
dot.setDepth(depth + 2);
if (unit.faction !== 'enemy') {
dot.setStrokeStyle(1, 0x05070a, 0.8);
}
});
this.trackSortie(this.add.text(x + 10, y + height - 16, '녹색: 출전 아군 · 붉은색: 적 배치', this.textStyle(10, '#9fb0bf'))).setDepth(depth + 2);
}
private sortiePlanSummary(): SortiePlanSummary {
const selectedUnits = this.selectedSortieUnits();
const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
const scenario = this.nextSortieScenario();
const terrainScores = selectedUnits.map((unit) => this.sortieTerrainScore(unit, scenario));
const terrainScore = terrainScores.length
? Math.round(terrainScores.reduce((sum, score) => sum + score, 0) / terrainScores.length)
: 0;
const terrainGrade = this.sortieTerrainGrade(terrainScore);
const activeBondCount = this.currentBonds().filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length;
const roleCounts = selectedUnits.reduce(
(counts, unit) => {
counts[this.sortieFormationRole(unit)] += 1;
return counts;
},
{ front: 0, flank: 0, support: 0, reserve: 0 } as Record<SortieFormationRole, number>
);
const formationSlots = sortieFormationSlotDefinitions.map((slot) => ({
...slot,
unitNames: selectedUnits.filter((unit) => this.sortieFormationRole(unit) === slot.role).map((unit) => unit.name)
}));
const warnings: string[] = [];
const maxCount = Math.min(maxSortieUnits, this.sortieAllies().length);
if (selectedUnits.length < maxCount) {
warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`);
}
if (roleCounts.front <= 0) {
warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.');
}
if (roleCounts.flank <= 0) {
warnings.push('돌파 병과가 없어 추격과 측면 압박이 느릴 수 있습니다.');
}
if (roleCounts.support <= 0) {
warnings.push('후원 병과가 없어 회복, 보급, 원거리 견제가 약합니다.');
}
if (activeBondCount <= 0 && selectedUnits.length > 1) {
warnings.push('함께 출전하는 공명 조합이 없습니다.');
}
if (terrainScore > 0 && terrainScore < 98) {
warnings.push('선택 무장의 평균 지형 적성이 낮습니다.');
}
return {
selectedCount: selectedUnits.length,
maxCount,
terrainScore,
terrainGrade,
activeBondCount,
deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`,
objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음',
formationSlots,
warnings
};
}
private sortieFormationRole(unit: UnitData): SortieFormationRole {
if (unit.classKey === 'cavalry') {
return 'flank';
}
if (unit.classKey === 'archer' || unit.classKey === 'strategist' || unit.classKey === 'quartermaster') {
return 'support';
}
if (unit.classKey === 'lord' || unit.classKey === 'infantry' || unit.classKey === 'spearman') {
return 'front';
}
return 'reserve';
}
private sortieTerrainScore(unit: UnitData, scenario: BattleScenarioDefinition | undefined) {
if (!scenario) {
return 100;
}
const terrainCounts = this.passableTerrainCounts(scenario);
const entries = Object.entries(terrainCounts) as [TerrainType, number][];
const total = entries.reduce((sum, [, count]) => sum + count, 0);
if (total <= 0) {
return 100;
}
const unitClass = getUnitClass(unit.classKey);
return Math.round(entries.reduce((sum, [terrain, count]) => sum + (unitClass.terrainRatings[terrain] ?? 0) * count, 0) / total);
}
private sortieTerrainGrade(score: number) {
if (score >= 110) {
return '최적';
}
if (score >= 102) {
return '유리';
}
if (score >= 95) {
return '보통';
}
return score > 0 ? '주의' : '미정';
}
private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9));
bg.setOrigin(0);
@@ -2418,6 +2628,13 @@ export class CampScene extends Phaser.Scene {
return object;
}
private compactText(value: string, maxLength: number) {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1))}`;
}
private clearContent() {
this.contentObjects.forEach((object) => object.destroy());
this.contentObjects = [];
@@ -2439,12 +2656,14 @@ export class CampScene extends Phaser.Scene {
selected: this.isSortieSelected(unit.id),
className: unit.className,
classRole: getUnitClass(unit.classKey).role,
formationRole: this.sortieFormationRole(unit),
statLine: summary.statLine,
equipmentLine: summary.equipmentLine,
bondLine: summary.bondLine,
terrainLine: summary.terrainLine
};
}),
sortiePlan: this.sortiePlanSummary(),
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),