Enhance sortie formation planning
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user