Add battle-specific sortie recommendations
This commit is contained in:
@@ -2,7 +2,7 @@ import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
|
||||
import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRules';
|
||||
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition } from '../data/battles';
|
||||
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
|
||||
import { getSortieFlow } from '../data/campaignFlow';
|
||||
import { firstBattleBonds, firstBattleUnits, xuzhouRecruitBonds, xuzhouRecruitUnits, type PortraitKey, type UnitData } from '../data/scenario';
|
||||
import {
|
||||
@@ -71,6 +71,17 @@ type SortieUnitTacticalSummary = {
|
||||
terrainLine: string;
|
||||
};
|
||||
|
||||
type SortieRecommendation = {
|
||||
unitId: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type SortieRuleDefinition = {
|
||||
maxUnits: number;
|
||||
recommended: SortieRecommendation[];
|
||||
note: string;
|
||||
};
|
||||
|
||||
type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve';
|
||||
|
||||
type SortieFormationSlot = {
|
||||
@@ -86,7 +97,10 @@ type SortiePlanSummary = {
|
||||
terrainScore: number;
|
||||
terrainGrade: string;
|
||||
activeBondCount: number;
|
||||
recommendedSelectedCount: number;
|
||||
recommendedTotal: number;
|
||||
deploymentLine: string;
|
||||
recommendationLine: string;
|
||||
objectiveLine: string;
|
||||
formationSlots: SortieFormationSlot[];
|
||||
warnings: string[];
|
||||
@@ -174,6 +188,109 @@ const sortieFormationSlotDefinitions: Omit<SortieFormationSlot, 'unitNames'>[] =
|
||||
{ role: 'reserve', label: '예비', description: '빈틈 보강과 공명 연결' }
|
||||
];
|
||||
|
||||
const defaultSortieRule: SortieRuleDefinition = {
|
||||
maxUnits: maxSortieUnits,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '패배 조건인 주장이며 공명 중심입니다.' }
|
||||
],
|
||||
note: '유비 생존을 중심에 두고 전열, 돌파, 후원을 고르게 배치하십시오.'
|
||||
};
|
||||
|
||||
const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefinition>> = {
|
||||
'second-battle-yellow-turban-pursuit': {
|
||||
maxUnits: 3,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '마을 안정과 지휘를 맡습니다.' },
|
||||
{ unitId: 'guan-yu', reason: '숲길 전열을 안정적으로 밀 수 있습니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '잔당 추격과 막타 돌파에 강합니다.' }
|
||||
],
|
||||
note: '첫 추격전은 세 형제의 공명과 기본 진형을 익히는 전장입니다.'
|
||||
},
|
||||
'third-battle-guangzong-road': {
|
||||
maxUnits: 3,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '강가 길목에서 부대를 묶어 줍니다.' },
|
||||
{ unitId: 'guan-yu', reason: '요새와 숲의 방어선을 버팁니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '좁은 길의 적 전령을 빠르게 압박합니다.' }
|
||||
],
|
||||
note: '강과 요새가 많은 길목이라 전열 유지와 빠른 추격이 모두 필요합니다.'
|
||||
},
|
||||
'fourth-battle-guangzong-camp': {
|
||||
maxUnits: 3,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '장각 격파까지 전선을 지휘합니다.' },
|
||||
{ unitId: 'guan-yu', reason: '요새 방어선을 뚫는 전열 핵심입니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '본영 측면을 흔드는 압박 담당입니다.' }
|
||||
],
|
||||
note: '황건 본영전은 세 형제 전원 출전으로 공명 피해와 전선 안정성을 확보하십시오.'
|
||||
},
|
||||
'fifth-battle-sishui-vanguard': {
|
||||
maxUnits: 3,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '반동탁 연합에서 이름을 세울 주장입니다.' },
|
||||
{ unitId: 'guan-yu', reason: '전초 요새 주변을 안정적으로 장악합니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '호진의 전열을 빠르게 흔듭니다.' }
|
||||
],
|
||||
note: '사수관 전초전은 중앙 길을 서두르되 숲길 척후를 놓치지 않는 편성이 좋습니다.'
|
||||
},
|
||||
'sixth-battle-jieqiao-relief': {
|
||||
maxUnits: 3,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '공손찬 원군로의 신뢰를 지켜야 합니다.' },
|
||||
{ unitId: 'guan-yu', reason: '강가와 진영 지형에서 방어 적성이 좋습니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '원소군 별동대 추격에 적합합니다.' }
|
||||
],
|
||||
note: '계교 원군로는 빠른 압박보다 길목 안정이 중요합니다.'
|
||||
},
|
||||
'seventh-battle-xuzhou-rescue': {
|
||||
maxUnits: 4,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '서주 원군 요청의 중심 인물입니다.' },
|
||||
{ unitId: 'guan-yu', reason: '하후돈 선봉을 받아낼 전열 핵심입니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '조조군 기병을 압박할 돌파 역할입니다.' }
|
||||
],
|
||||
note: '서주 구원전부터 출전 여유가 생깁니다. 전열을 유지하면서 한 명을 상황에 맞게 더 넣으십시오.'
|
||||
},
|
||||
'eighth-battle-xiaopei-supply-road': {
|
||||
maxUnits: 5,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '서주 방위선의 사기를 붙잡습니다.' },
|
||||
{ unitId: 'jian-yong', reason: '보급로 전투에서 책략 보조가 빛납니다.' },
|
||||
{ unitId: 'mi-zhu', reason: '소패 보급과 회복 운용에 어울립니다.' }
|
||||
],
|
||||
note: '새로 합류한 서주 인재를 시험하기 좋은 전장입니다.'
|
||||
},
|
||||
'ninth-battle-xuzhou-gate-night-raid': {
|
||||
maxUnits: 5,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '성문을 되찾는 정치적 명분의 중심입니다.' },
|
||||
{ unitId: 'guan-yu', reason: '야습 방어와 성문 전열 유지에 강합니다.' },
|
||||
{ unitId: 'mi-zhu', reason: '성내 보급 안정에 도움을 줍니다.' }
|
||||
],
|
||||
note: '성문 야습은 전열 장악과 보급 유지가 함께 필요합니다.'
|
||||
},
|
||||
'tenth-battle-xuzhou-breakout': {
|
||||
maxUnits: 5,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '탈출전의 패배 조건입니다.' },
|
||||
{ unitId: 'guan-yu', reason: '추격대를 막아 퇴로를 엽니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '실책을 만회할 돌파와 반격 담당입니다.' },
|
||||
{ unitId: 'mi-zhu', reason: '피난민과 보급을 보존합니다.' }
|
||||
],
|
||||
note: '서주 탈출전은 주력과 보급 담당을 함께 데려가 병력 손실을 줄이는 편성이 좋습니다.'
|
||||
},
|
||||
'eleventh-battle-xudu-refuge-road': {
|
||||
maxUnits: 5,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '조조의 시험을 직접 받아야 합니다.' },
|
||||
{ unitId: 'guan-yu', reason: '기령의 주력과 맞설 전열 핵심입니다.' },
|
||||
{ unitId: 'jian-yong', reason: '감시 속 군율과 책략 보조를 맡습니다.' },
|
||||
{ unitId: 'mi-zhu', reason: '허도 입성 전 보급 안정에 유리합니다.' }
|
||||
],
|
||||
note: '허도 입성로는 조조군의 감시를 받는 시험입니다. 군율과 보급을 보여줄 무장을 챙기십시오.'
|
||||
}
|
||||
};
|
||||
|
||||
const campDialogues: CampDialogue[] = [
|
||||
{
|
||||
id: 'liu-guan-after-first-battle',
|
||||
@@ -1424,6 +1541,12 @@ export class CampScene extends Phaser.Scene {
|
||||
lineSpacing: 4
|
||||
})
|
||||
).setDepth(depth + 1);
|
||||
this.trackSortie(
|
||||
this.add.text(x + 18, y + height - 30, `편성 주안점: ${this.nextSortieRule().note}`, {
|
||||
...this.textStyle(12, '#d8b15f', true),
|
||||
wordWrap: { width: width - 36, useAdvancedWrap: true }
|
||||
})
|
||||
).setDepth(depth + 1);
|
||||
}
|
||||
|
||||
private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) {
|
||||
@@ -1451,10 +1574,11 @@ export class CampScene extends Phaser.Scene {
|
||||
bg.setStrokeStyle(1, palette.blue, 0.48);
|
||||
const allies = this.sortieAllies();
|
||||
const selectedCount = this.selectedSortieUnitIds.length;
|
||||
const maxUnits = this.sortieMaxUnits();
|
||||
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))
|
||||
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);
|
||||
|
||||
@@ -1462,6 +1586,7 @@ export class CampScene extends Phaser.Scene {
|
||||
const rowY = y + 50 + index * rowGap;
|
||||
const selected = this.isSortieSelected(unit.id);
|
||||
const required = requiredSortieUnitIds.has(unit.id);
|
||||
const recommendation = this.sortieRecommendation(unit.id);
|
||||
const summary = this.sortieUnitTacticalSummary(unit);
|
||||
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
|
||||
row.setOrigin(0);
|
||||
@@ -1471,7 +1596,8 @@ export class CampScene extends Phaser.Scene {
|
||||
row.on('pointerdown', () => this.toggleSortieUnit(unit.id));
|
||||
|
||||
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);
|
||||
const markerLabel = required ? '필수' : recommendation ? '추천' : marker;
|
||||
this.trackSortie(this.add.text(x + 30, rowY, markerLabel, this.textStyle(12, required || recommendation ? '#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 + 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);
|
||||
@@ -1501,7 +1627,7 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
const summaryLines = [
|
||||
plan.deploymentLine,
|
||||
`활성 공명 ${plan.activeBondCount}개 · 평균 적성 ${plan.terrainScore}`,
|
||||
`${plan.recommendationLine} · 공명 ${plan.activeBondCount}개`,
|
||||
plan.objectiveLine
|
||||
];
|
||||
summaryLines.forEach((line, index) => {
|
||||
@@ -1629,6 +1755,21 @@ export class CampScene extends Phaser.Scene {
|
||||
return nextBattleId ? getBattleScenario(nextBattleId) : undefined;
|
||||
}
|
||||
|
||||
private nextSortieRule(scenario = this.nextSortieScenario()) {
|
||||
if (!scenario) {
|
||||
return defaultSortieRule;
|
||||
}
|
||||
return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule;
|
||||
}
|
||||
|
||||
private sortieMaxUnits(scenario = this.nextSortieScenario()) {
|
||||
return Math.min(this.nextSortieRule(scenario).maxUnits, this.sortieAllies().length);
|
||||
}
|
||||
|
||||
private sortieRecommendation(unitId: string, scenario = this.nextSortieScenario()) {
|
||||
return this.nextSortieRule(scenario).recommended.find((entry) => entry.unitId === unitId);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -1680,12 +1821,17 @@ export class CampScene extends Phaser.Scene {
|
||||
const selectedUnits = this.selectedSortieUnits();
|
||||
const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
|
||||
const scenario = this.nextSortieScenario();
|
||||
const rule = this.nextSortieRule(scenario);
|
||||
const availableIds = new Set(this.sortieAllies().map((unit) => unit.id));
|
||||
const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId));
|
||||
const missingRecommended = recommended.filter((entry) => !selectedIds.has(entry.unitId));
|
||||
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 recommendedSelectedCount = recommended.filter((entry) => selectedIds.has(entry.unitId)).length;
|
||||
const roleCounts = selectedUnits.reduce(
|
||||
(counts, unit) => {
|
||||
counts[this.sortieFormationRole(unit)] += 1;
|
||||
@@ -1698,11 +1844,14 @@ export class CampScene extends Phaser.Scene {
|
||||
unitNames: selectedUnits.filter((unit) => this.sortieFormationRole(unit) === slot.role).map((unit) => unit.name)
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
const maxCount = Math.min(maxSortieUnits, this.sortieAllies().length);
|
||||
const maxCount = this.sortieMaxUnits(scenario);
|
||||
|
||||
if (selectedUnits.length < maxCount) {
|
||||
warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`);
|
||||
}
|
||||
if (missingRecommended.length > 0) {
|
||||
warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')}이 대기 중입니다.`);
|
||||
}
|
||||
if (roleCounts.front <= 0) {
|
||||
warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.');
|
||||
}
|
||||
@@ -1725,7 +1874,10 @@ export class CampScene extends Phaser.Scene {
|
||||
terrainScore,
|
||||
terrainGrade,
|
||||
activeBondCount,
|
||||
recommendedSelectedCount,
|
||||
recommendedTotal: recommended.length,
|
||||
deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`,
|
||||
recommendationLine: recommended.length > 0 ? `추천 ${recommendedSelectedCount}/${recommended.length}` : '추천 없음',
|
||||
objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음',
|
||||
formationSlots,
|
||||
warnings
|
||||
@@ -2508,6 +2660,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private normalizedSortieUnitIds(candidateIds?: string[]) {
|
||||
const allies = this.sortieAllies();
|
||||
const allyIds = new Set(allies.map((unit) => unit.id));
|
||||
const maxUnits = this.sortieMaxUnits();
|
||||
const useDefaultSelection = !candidateIds || candidateIds.length === 0;
|
||||
const candidateSet = new Set((candidateIds ?? []).filter((id) => allyIds.has(id)));
|
||||
requiredSortieUnitIds.forEach((id) => {
|
||||
@@ -2517,16 +2670,16 @@ export class CampScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
if (useDefaultSelection) {
|
||||
allies.slice(0, maxSortieUnits).forEach((unit) => candidateSet.add(unit.id));
|
||||
allies.slice(0, maxUnits).forEach((unit) => candidateSet.add(unit.id));
|
||||
}
|
||||
|
||||
const required = allies.filter((unit) => requiredSortieUnitIds.has(unit.id)).map((unit) => unit.id);
|
||||
const optional = allies
|
||||
.filter((unit) => !requiredSortieUnitIds.has(unit.id) && candidateSet.has(unit.id))
|
||||
.slice(0, Math.max(0, maxSortieUnits - required.length))
|
||||
.slice(0, Math.max(0, maxUnits - required.length))
|
||||
.map((unit) => unit.id);
|
||||
|
||||
return [...new Set([...required, ...optional])].slice(0, maxSortieUnits);
|
||||
return [...new Set([...required, ...optional])].slice(0, maxUnits);
|
||||
}
|
||||
|
||||
private isSortieSelected(unitId: string) {
|
||||
@@ -2546,8 +2699,8 @@ export class CampScene extends Phaser.Scene {
|
||||
const selected = new Set(this.selectedSortieUnitIds);
|
||||
if (selected.has(unitId)) {
|
||||
selected.delete(unitId);
|
||||
} else if (selected.size >= maxSortieUnits) {
|
||||
this.showCampNotice(`이번 전투는 최대 ${maxSortieUnits}명까지 출전할 수 있습니다.`);
|
||||
} else if (selected.size >= this.sortieMaxUnits()) {
|
||||
this.showCampNotice(`이번 전투는 최대 ${this.sortieMaxUnits()}명까지 출전할 수 있습니다.`);
|
||||
return;
|
||||
} else {
|
||||
selected.add(unitId);
|
||||
@@ -2657,6 +2810,8 @@ export class CampScene extends Phaser.Scene {
|
||||
className: unit.className,
|
||||
classRole: getUnitClass(unit.classKey).role,
|
||||
formationRole: this.sortieFormationRole(unit),
|
||||
recommended: Boolean(this.sortieRecommendation(unit.id)),
|
||||
recommendationReason: this.sortieRecommendation(unit.id)?.reason ?? null,
|
||||
statLine: summary.statLine,
|
||||
equipmentLine: summary.equipmentLine,
|
||||
bondLine: summary.bondLine,
|
||||
|
||||
Reference in New Issue
Block a user