Expand sortie prep battle data

This commit is contained in:
2026-07-03 22:53:30 +09:00
parent 6fd5615bfd
commit aafe210977
4 changed files with 304 additions and 23 deletions

View File

@@ -268,6 +268,8 @@ import {
type StoryPage,
type UnitData
} from './scenario';
import type { UnitClassKey } from './battleRules';
import type { SortieDeploymentSlot, SortieFormationRole } from './sortieDeployment';
export type BattleScenarioId =
| 'first-battle-zhuo-commandery'
@@ -354,12 +356,35 @@ export type BattleDefeatConditionDefinition = {
unitId: string;
};
export type BattleSortieUnitRecommendation = {
unitId: string;
reason: string;
role?: SortieFormationRole;
};
export type BattleSortieClassRecommendation = {
label: string;
classKeys: UnitClassKey[];
reason: string;
};
export type BattleSortieDefinition = {
sortieLimit: number;
requiredUnits?: string[];
excludedUnits?: string[];
recommendedUnits: BattleSortieUnitRecommendation[];
recommendedClasses: BattleSortieClassRecommendation[];
deploymentSlots: SortieDeploymentSlot[];
note: string;
};
export type BattleScenarioDefinition = {
id: BattleScenarioId;
title: string;
victoryConditionLabel: string;
defeatConditionLabel: string;
openingObjectiveLines: string[];
sortie?: BattleSortieDefinition;
map: BattleMap;
units: UnitData[];
bonds: BattleBond[];
@@ -437,6 +462,26 @@ export const secondBattleScenario: BattleScenarioDefinition = {
'중앙 나루를 건너기 전에 길목의 습격조를 먼저 정리하면 북쪽 마을을 안전하게 확보할 수 있습니다.',
'세 형제를 모두 출전시키고 14턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '주장 생존이 패배 조건입니다. 뒤에서 지휘하며 마을 확보를 맡깁니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '나루 앞 숲길을 버티며 적 보병을 안정적으로 밀어냅니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '도망치는 잔당과 궁병을 빠르게 압박하기 좋습니다.' }
],
recommendedClasses: [
{ label: '보병 전열', classKeys: ['infantry'], reason: '좁은 나루와 숲길에서 먼저 맞받아칠 전열이 필요합니다.' },
{ label: '창병 돌파', classKeys: ['spearman'], reason: '북쪽으로 달아나는 적을 따라붙을 돌파 담당이 필요합니다.' },
{ label: '군주 후원', classKeys: ['lord'], reason: '유비는 패배 조건이므로 후방 지휘 위치가 안전합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 15, label: '나루 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 16, label: '남쪽 돌파' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 15, label: '후방 지휘' }
],
note: '세 형제 전원 출전이 가장 안정적입니다. 관우를 전열, 장비를 돌파, 유비를 후원에 두면 시작 위치가 의도대로 잡힙니다.'
},
map: secondBattleMap,
units: secondBattleUnits,
bonds: secondBattleBonds,
@@ -490,6 +535,26 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
'강가 길목과 요새 주변의 궁병을 방치하면 전선이 오래 묶입니다. 지형 보정을 살펴 진군하십시오.',
'18턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '강가 길목에서 무리하지 않고 전선을 묶어 주는 지휘가 필요합니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '요새와 숲의 방어선을 정면에서 받아내기 좋습니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '전령을 놓치지 않도록 좁은 길을 빠르게 압박합니다.' }
],
recommendedClasses: [
{ label: '전열 보병', classKeys: ['infantry'], reason: '요새 주변에서 궁병 견제를 버틸 전열이 필요합니다.' },
{ label: '돌파 창병', classKeys: ['spearman'], reason: '전령 추격과 좁은 길 돌파에 힘을 싣습니다.' },
{ label: '군주 후원', classKeys: ['lord'], reason: '유비는 뒤에서 생존과 공명 연결을 책임지는 편이 안전합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 16, label: '강가 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 17, label: '추격 출발' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 16, label: '후방 지휘' }
],
note: '강가 길목은 오래 버티는 전열과 빠른 추격이 모두 필요합니다. 유비는 뒤에 두고 관우와 장비를 앞세우십시오.'
},
map: thirdBattleMap,
units: thirdBattleUnits,
bonds: thirdBattleBonds,
@@ -543,6 +608,26 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
'요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.',
'16턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '장각 격파까지 전선이 무너지지 않도록 후방 지휘가 필요합니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '본영 정면의 요새 방어선을 뚫는 핵심 전열입니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '측면 궁병과 기병을 흔들어 본영 진입 시간을 줄입니다.' }
],
recommendedClasses: [
{ label: '강한 전열', classKeys: ['infantry'], reason: '요새와 궁병 앞에서 버티며 중앙 길을 여는 역할입니다.' },
{ label: '측면 돌파', classKeys: ['spearman'], reason: '본영 측면을 흔들어 장각까지 빠르게 접근합니다.' },
{ label: '후방 지휘', classKeys: ['lord'], reason: '패배 조건인 유비를 안전하게 두고 공명 보정을 유지합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 15, label: '본영 입구' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 16, label: '측면 압박' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 15, label: '후방 지휘' }
],
note: '황건 본영은 세 형제 전원 출전과 역할 분담이 중요합니다. 장비를 돌파로 두면 측면 압박이 빨라집니다.'
},
map: fourthBattleMap,
units: fourthBattleUnits,
bonds: fourthBattleBonds,
@@ -596,6 +681,26 @@ export const fifthBattleScenario: BattleScenarioDefinition = {
'중앙 길은 빠르지만 궁병과 기병의 반격을 받기 쉽습니다. 숲길 척후를 처리하고 요새 주변을 차근히 압박하십시오.',
'14턴 이내에 승리하면 공손찬 진영에서 세 형제의 무공이 더 크게 알려집니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '반동탁 연합에서 명분을 세워야 하므로 생존과 지휘가 우선입니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '전초 요새 앞 보병과 궁병의 반격을 안정적으로 받아냅니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '호진의 전열을 흔들고 숲길 척후를 빠르게 정리합니다.' }
],
recommendedClasses: [
{ label: '요새 전열', classKeys: ['infantry'], reason: '중앙 길의 궁병 반격을 견딜 단단한 전열이 필요합니다.' },
{ label: '숲길 돌파', classKeys: ['spearman'], reason: '측면 척후를 잡고 호진에게 접근할 돌파 담당이 좋습니다.' },
{ label: '지휘 후원', classKeys: ['lord'], reason: '유비는 후방에서 세 형제 공명과 생존 조건을 유지합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 15, label: '요새 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 16, label: '숲길 돌파' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 15, label: '후방 지휘' }
],
note: '사수관 전초전은 중앙 길을 서두르되 숲길 척후를 놓치지 않는 편성이 좋습니다.'
},
map: fifthBattleMap,
units: fifthBattleUnits,
bonds: fifthBattleBonds,
@@ -649,6 +754,26 @@ export const sixthBattleScenario: BattleScenarioDefinition = {
'강가의 궁병과 측면 기병이 동시에 압박합니다. 유비는 뒤에 두고 관우와 장비로 중앙 길을 확보하십시오.',
'15턴 이내에 승리하면 공손찬 진영에서 세 형제의 신뢰가 커집니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '공손찬 원군로의 명분을 지키되, 강가 압박에서 보호해야 합니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '강가와 진영 지형에서 적 선봉을 받아내는 전열이 됩니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '원소군 별동대와 측면 기병을 빠르게 압박합니다.' }
],
recommendedClasses: [
{ label: '강가 전열', classKeys: ['infantry'], reason: '궁병 견제를 맞으며 중앙 길을 확보해야 합니다.' },
{ label: '측면 돌파', classKeys: ['spearman'], reason: '별동대와 기병 압박을 끊어 내는 역할이 필요합니다.' },
{ label: '안전 지휘', classKeys: ['lord'], reason: '유비는 후방 배치로 생존과 지휘를 동시에 챙깁니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 16, label: '강가 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 17, label: '측면 대응' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 16, label: '후방 지휘' }
],
note: '계교 원군로는 빠른 압박보다 길목 안정이 중요합니다. 유비를 보호하고 관우와 장비로 먼저 맞받으십시오.'
},
map: sixthBattleMap,
units: sixthBattleUnits,
bonds: sixthBattleBonds,

View File

@@ -12,12 +12,22 @@ export type SortieDeploymentPlanEntry = {
y: number;
};
export type SortieDeploymentSlot = {
x: number;
y: number;
role?: SortieFormationRole;
unitId?: string;
label?: string;
};
type ScoredTile = {
x: number;
y: number;
forward: number;
lateral: number;
enemyDistance: number;
role?: SortieFormationRole;
unitId?: string;
};
export function isSortieFormationRole(value: unknown): value is SortieFormationRole {
@@ -40,13 +50,18 @@ export function createSortieDeploymentPlan(
sourceUnits: UnitData[],
selectedUnitIds: string[],
assignments: SortieFormationAssignments,
fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole
fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole,
deploymentSlots?: SortieDeploymentSlot[]
) {
const selectedOrder = new Map(selectedUnitIds.map((unitId, index) => [unitId, index]));
const deployedAllies = deployedUnits
.filter((unit) => unit.faction === 'ally')
.sort((a, b) => (selectedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (selectedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
const sourceAllyTiles = uniqueTiles(sourceUnits.filter((unit) => unit.faction === 'ally').map((unit) => ({ x: unit.x, y: unit.y })));
const sourceAllyTiles = uniqueTiles(
deploymentSlots?.length
? deploymentSlots.map((slot) => ({ x: slot.x, y: slot.y, role: slot.role, unitId: slot.unitId }))
: sourceUnits.filter((unit) => unit.faction === 'ally').map((unit) => ({ x: unit.x, y: unit.y }))
);
if (deployedAllies.length === 0 || sourceAllyTiles.length === 0) {
return new Map<string, SortieDeploymentPlanEntry>();
@@ -60,8 +75,25 @@ export function createSortieDeploymentPlan(
const plan = new Map<string, SortieDeploymentPlanEntry>();
const usedTiles = new Set<string>();
deployedAllies.forEach((unit) => {
const role = roleForUnit(unit, assignments, fallbackRoleForUnit);
const tile = scoredTiles.find(
(candidate) => candidate.unitId === unit.id && (!candidate.role || candidate.role === role) && !usedTiles.has(tileKey(candidate))
);
if (!tile) {
return;
}
usedTiles.add(tileKey(tile));
plan.set(unit.id, {
unitId: unit.id,
role,
x: tile.x,
y: tile.y
});
});
sortieFormationRoles.forEach((role) => {
const roleUnits = deployedAllies.filter((unit) => roleForUnit(unit, assignments, fallbackRoleForUnit) === role);
const roleUnits = deployedAllies.filter((unit) => !plan.has(unit.id) && roleForUnit(unit, assignments, fallbackRoleForUnit) === role);
roleUnits.forEach((unit) => {
const tile = bestTileForRole(role, scoredTiles, usedTiles);
if (!tile) {
@@ -126,6 +158,11 @@ function bestTileForRole(role: SortieFormationRole, tiles: ScoredTile[], usedTil
}
function compareTilesForRole(role: SortieFormationRole, a: ScoredTile, b: ScoredTile) {
const aRoleMatch = a.role === role ? 1 : 0;
const bRoleMatch = b.role === role ? 1 : 0;
if (aRoleMatch !== bRoleMatch) {
return bRoleMatch - aRoleMatch;
}
if (role === 'front') {
return b.forward - a.forward || a.enemyDistance - b.enemyDistance || a.lateral - b.lateral;
}
@@ -139,7 +176,7 @@ function compareTilesForRole(role: SortieFormationRole, a: ScoredTile, b: Scored
}
function scoreTile(
tile: { x: number; y: number },
tile: { x: number; y: number; role?: SortieFormationRole; unitId?: string },
allyCenter: { x: number; y: number },
enemyCenter: { x: number; y: number },
direction: { x: number; y: number }
@@ -150,11 +187,13 @@ function scoreTile(
y: tile.y,
forward: relative.x * direction.x + relative.y * direction.y,
lateral: Math.abs(relative.x * -direction.y + relative.y * direction.x),
enemyDistance: Math.hypot(tile.x - enemyCenter.x, tile.y - enemyCenter.y)
enemyDistance: Math.hypot(tile.x - enemyCenter.x, tile.y - enemyCenter.y),
role: tile.role,
unitId: tile.unitId
};
}
function uniqueTiles(tiles: { x: number; y: number }[]) {
function uniqueTiles<T extends { x: number; y: number }>(tiles: T[]) {
const seen = new Set<string>();
return tiles.filter((tile) => {
const key = tileKey(tile);

View File

@@ -3283,7 +3283,8 @@ export class BattleScene extends Phaser.Scene {
initialBattleUnits,
effectiveSortieUnitIds,
assignments,
(unit) => this.defaultSortieFormationRole(unit)
(unit) => this.defaultSortieFormationRole(unit),
battleScenario.sortie?.deploymentSlots
);
battleUnits.splice(0, battleUnits.length, ...applySortieDeploymentPlan(nextUnits, deploymentPlan));
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));

View File

@@ -3,13 +3,14 @@ import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png'
import { soundDirector } from '../audio/SoundDirector';
import { battleUiIconFrames, battleUiIconTextureKey, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons';
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRules';
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';
import { portraitAssetEntriesForKey, portraitTextureKey, type PortraitAssetEntry } from '../data/portraitAssets';
import {
createSortieDeploymentPlan,
normalizeSortieFormationAssignments,
type SortieDeploymentSlot,
type SortieFormationAssignments,
type SortieFormationRole
} from '../data/sortieDeployment';
@@ -210,6 +211,13 @@ type SortieTerrainCounts = Partial<Record<TerrainType, number>>;
type SortieRecommendation = {
unitId: string;
reason: string;
role?: SortieFormationRole;
};
type SortieClassRecommendation = {
label: string;
classKeys: UnitClassKey[];
reason: string;
};
type SortieRuleDefinition = {
@@ -217,6 +225,8 @@ type SortieRuleDefinition = {
requiredUnitIds?: string[];
excludedUnitIds?: string[];
recommended: SortieRecommendation[];
recommendedClasses?: SortieClassRecommendation[];
deploymentSlots?: SortieDeploymentSlot[];
note: string;
};
@@ -11285,7 +11295,8 @@ export class CampScene extends Phaser.Scene {
const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
const reserveUnits = this.sortieAllies().filter((unit) => !selectedIds.has(unit.id));
const selectedNames = selectedUnits.map((unit) => unit.name).join(', ');
const reserveNames = reserveUnits.map((unit) => unit.name).join(', ');
const recommendedClassLine = this.sortieRecommendedClassLine();
const maxUnits = this.sortieMaxUnits();
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -11296,7 +11307,7 @@ export class CampScene extends Phaser.Scene {
this.add.text(
x + 44,
y + 10,
this.compactText(`출전 ${selectedUnits.length}: ${selectedNames || '없음'}`, 35),
this.compactText(`출전 ${selectedUnits.length}/${maxUnits}: ${selectedNames || '없음'}`, 35),
this.textStyle(13, '#f2e3bf', true)
)
).setDepth(depth + 1);
@@ -11305,7 +11316,7 @@ export class CampScene extends Phaser.Scene {
this.add.text(
x + 44,
y + 35,
this.compactText(`대기 ${reserveUnits.length}: ${reserveNames || '없음'}`, 35),
this.compactText(`추천 ${recommendedClassLine} · 대기 ${reserveUnits.length}`, 35),
this.textStyle(12, reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', true)
)
).setDepth(depth + 1);
@@ -11322,7 +11333,7 @@ export class CampScene extends Phaser.Scene {
const objective = scenario ? scenario.victoryConditionLabel : '군영 의정 진행';
const terrain = scenario ? this.sortieTerrainSummary(scenario) : '군영';
const enemies = scenario ? this.sortieEnemySummary(scenario) : '전투 없음';
const maxUnits = this.sortieMaxUnits(scenario);
const recommendedClasses = this.sortieRecommendedClassLine(scenario);
this.trackSortie(this.add.text(x + 18, y + 12, briefing.eyebrow, this.textStyle(15, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 34, briefing.title, this.textStyle(22, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(
@@ -11337,7 +11348,7 @@ export class CampScene extends Phaser.Scene {
this.renderSortieInfoChip('목표', objective, x + 18, chipY, chipWidth, depth + 1);
this.renderSortieInfoChip('지형', terrain, x + 24 + chipWidth, chipY, chipWidth, depth + 1);
this.renderSortieInfoChip('적', enemies, x + 30 + chipWidth * 2, chipY, chipWidth, depth + 1);
this.renderSortieInfoChip('출전', `최대 ${maxUnits}`, x + 36 + chipWidth * 3, chipY, chipWidth, depth + 1);
this.renderSortieInfoChip('추천', recommendedClasses, x + 36 + chipWidth * 3, chipY, chipWidth, depth + 1);
}
private renderSortieInfoChip(label: string, value: string, x: number, y: number, width: number, depth: number) {
@@ -11549,11 +11560,12 @@ export class CampScene extends Phaser.Scene {
const plan = this.sortiePlanSummary();
this.trackSortie(this.add.text(x + 18, y + 14, '출전 슬롯', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.renderSortiePanelButton('추천 편성', x + width - 104, y + 10, 86, 22, Boolean(this.nextSortieScenario()), false, () => this.applyRecommendedSortiePlan(), 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.add.text(x + 18, y + 38, `${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, this.textStyle(11, '#d8b15f', true))
).setDepth(depth + 1);
this.renderSortieDeploymentMap(x + 16, y + 44, width - 32, 72, depth + 1);
this.renderSortieDeploymentMap(x + 16, y + 58, width - 32, 62, depth + 1);
this.renderSortieSlotCards(x + 16, y + 126, width - 32, 104, plan, depth + 1);
this.trackSortie(
this.add.text(
@@ -11678,13 +11690,20 @@ export class CampScene extends Phaser.Scene {
const availability = this.sortieUnitAvailability(unit);
const recommendation = this.sortieRecommendation(unit.id);
const roleLabel = sortieFormationSlotDefinitions.find((slot) => slot.role === this.sortieFormationRole(unit))?.label ?? '예비';
const recommendedRoleLabel = recommendation?.role
? sortieFormationSlotDefinitions.find((slot) => slot.role === recommendation.role)?.label
: undefined;
const terrainScore = this.sortieTerrainScore(unit, this.nextSortieScenario());
const terrainGrade = this.sortieTerrainGrade(terrainScore);
const statusColor = status.tone === 'disabled' ? '#77818c' : status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
const selected = this.isSortieSelected(unit.id);
const reason = !availability.available
? availability.reason
: recommendation?.reason ?? (selected ? `${roleLabel} 배치 중입니다. 역할 버튼으로 시작 위치 성향을 바꿀 수 있습니다.` : '명단이나 버튼을 눌러 출전 슬롯에 배치할 수 있습니다.');
: recommendation
? `${recommendation.reason}${recommendedRoleLabel ? ` · 추천 ${recommendedRoleLabel}` : ''}`
: selected
? `${roleLabel} 배치 중입니다. 역할 버튼으로 시작 위치 성향을 바꿀 수 있습니다.`
: '명단이나 버튼을 눌러 출전 슬롯에 배치할 수 있습니다.';
this.trackSortie(this.add.text(x + 18, y + 14, '선택 무장 상세', this.textStyle(17, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 16, y + 16, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1);
@@ -11702,7 +11721,7 @@ 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(reason, 46), this.textStyle(11, recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : availability.available ? '#c8d2dd' : '#87919c', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 157, this.compactText(reason, 38), this.textStyle(11, recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : availability.available ? '#c8d2dd' : '#87919c', true))).setDepth(depth + 1);
equipmentSlots.forEach((slot, index) => {
const rowY = y + 178 + index * 22;
@@ -11954,13 +11973,29 @@ export class CampScene extends Phaser.Scene {
private nextSortieRule(scenario = this.nextSortieScenario()) {
if (scenario) {
return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule;
const scenarioRule = this.sortieRuleFromScenario(scenario);
return scenarioRule ?? sortieRulesByBattleId[scenario.id] ?? defaultSortieRule;
}
const step = this.campaign?.step;
const flowStep = this.currentSortieFlow().campaignStep;
return (step && sortieRulesByCampaignStep[step]) || (flowStep && sortieRulesByCampaignStep[flowStep]) || defaultSortieRule;
}
private sortieRuleFromScenario(scenario: BattleScenarioDefinition): SortieRuleDefinition | undefined {
if (!scenario.sortie) {
return undefined;
}
return {
maxUnits: scenario.sortie.sortieLimit,
requiredUnitIds: scenario.sortie.requiredUnits,
excludedUnitIds: scenario.sortie.excludedUnits,
recommended: scenario.sortie.recommendedUnits,
recommendedClasses: scenario.sortie.recommendedClasses,
deploymentSlots: scenario.sortie.deploymentSlots,
note: scenario.sortie.note
};
}
private requiredSortieUnitIdsFor(scenario = this.nextSortieScenario()) {
return new Set(this.nextSortieRule(scenario).requiredUnitIds ?? defaultRequiredSortieUnitIds);
}
@@ -11977,6 +12012,23 @@ export class CampScene extends Phaser.Scene {
return this.nextSortieRule(scenario).recommended.find((entry) => entry.unitId === unitId);
}
private sortieRecommendedClasses(scenario = this.nextSortieScenario()) {
return this.nextSortieRule(scenario).recommendedClasses ?? [];
}
private sortieRecommendedClassLine(scenario = this.nextSortieScenario()) {
const recommendations = this.sortieRecommendedClasses(scenario);
return recommendations.length > 0 ? recommendations.map((entry) => entry.label).join(' / ') : '균형 편성';
}
private coversRecommendedClasses(units: UnitData[], scenario = this.nextSortieScenario()) {
const recommendations = this.sortieRecommendedClasses(scenario);
if (recommendations.length === 0) {
return true;
}
return recommendations.every((entry) => units.some((unit) => entry.classKeys.includes(unit.classKey)));
}
private ensureSortieFocus() {
const allies = this.sortieAllies();
if (allies.some((unit) => unit.id === this.sortieFocusedUnitId)) {
@@ -12110,7 +12162,8 @@ export class CampScene extends Phaser.Scene {
scenario.units,
this.selectedSortieUnitIds,
this.sortieFormationAssignments,
(unit) => this.defaultSortieFormationRole(unit)
(unit) => this.defaultSortieFormationRole(unit),
this.nextSortieRule(scenario).deploymentSlots
);
}
@@ -12138,6 +12191,9 @@ export class CampScene extends Phaser.Scene {
const availableIds = new Set(allAllies.map((unit) => unit.id));
const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId));
const missingRecommended = recommended.filter((entry) => !selectedIds.has(entry.unitId));
const missingClassRecommendations = (rule.recommendedClasses ?? []).filter(
(entry) => !selectedUnits.some((unit) => entry.classKeys.includes(unit.classKey))
);
const recruitedUnits = allAllies.filter((unit) => !foundingSortieUnitIds.has(unit.id));
const selectedRecruitedCount = recruitedUnits.filter((unit) => selectedIds.has(unit.id)).length;
const reserveUnits = allAllies.filter((unit) => !selectedIds.has(unit.id));
@@ -12169,6 +12225,9 @@ export class CampScene extends Phaser.Scene {
if (missingRecommended.length > 0) {
warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')} 대기 중입니다.`);
}
if (missingClassRecommendations.length > 0) {
warnings.push(`추천 병종 ${missingClassRecommendations.map((entry) => entry.label).join(', ')} 부족.`);
}
if (roleCounts.front <= 0) {
warnings.push('전열 담당이 없어 길목을 버티기 어렵습니다.');
}
@@ -12197,7 +12256,9 @@ export class CampScene extends Phaser.Scene {
recommendedSelectedCount,
recommendedTotal: recommended.length,
deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`,
recommendationLine: recommended.length > 0 ? `추천 ${recommendedSelectedCount}/${recommended.length}` : '추천 없음',
recommendationLine: recommended.length > 0
? `추천 무장 ${recommendedSelectedCount}/${recommended.length} · 병종 ${this.sortieRecommendedClassLine(scenario)}`
: `추천 병종 ${this.sortieRecommendedClassLine(scenario)}`,
recruitedLine:
recruitedUnits.length > 0
? `합류 무장 ${selectedRecruitedCount}/${recruitedUnits.length} · 대기 ${reserveUnits.length}`
@@ -12257,6 +12318,53 @@ export class CampScene extends Phaser.Scene {
this.showSortiePrep();
}
private applyRecommendedSortiePlan() {
const scenario = this.nextSortieScenario();
if (!scenario) {
this.showCampNotice('다음 전투가 정해지면 추천 편성을 사용할 수 있습니다.');
return;
}
const rule = this.nextSortieRule(scenario);
const allies = this.sortieAllies();
const availableById = new Map(allies.map((unit) => [unit.id, unit]));
const orderedIds: string[] = [];
const addUnit = (unitId: string) => {
if (availableById.has(unitId) && !orderedIds.includes(unitId)) {
orderedIds.push(unitId);
}
};
(rule.requiredUnitIds ?? defaultRequiredSortieUnitIds).forEach(addUnit);
rule.recommended.forEach((entry) => addUnit(entry.unitId));
(rule.recommendedClasses ?? []).forEach((entry) => {
allies
.filter((unit) => entry.classKeys.includes(unit.classKey))
.forEach((unit) => addUnit(unit.id));
});
allies.forEach((unit) => addUnit(unit.id));
const maxUnits = this.sortieMaxUnits(scenario);
const nextSelectedIds = orderedIds.slice(0, maxUnits);
const recommendationById = new Map(rule.recommended.map((entry) => [entry.unitId, entry]));
const nextAssignments: SortieFormationAssignments = {};
nextSelectedIds.forEach((unitId) => {
const unit = availableById.get(unitId);
if (!unit) {
return;
}
nextAssignments[unitId] = recommendationById.get(unitId)?.role ?? this.defaultSortieFormationRole(unit);
});
this.selectedSortieUnitIds = nextSelectedIds;
this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(nextAssignments);
this.sortieFocusedUnitId = nextSelectedIds[0] ?? this.sortieFocusedUnitId;
this.persistSortieSelection();
soundDirector.playSelect();
this.showCampNotice(`추천 편성 적용: ${nextSelectedIds.map((id) => this.unitName(id)).join(', ')}`);
this.showSortiePrep();
}
private sortieTerrainScore(unit: UnitData, scenario: BattleScenarioDefinition | undefined) {
if (!scenario) {
return 100;
@@ -12300,18 +12408,19 @@ export class CampScene extends Phaser.Scene {
const inventorySummary =
inventoryLabels.length > 0 ? `${inventoryLabels.slice(0, 3).join(', ')}${inventoryLabels.length > 3 ? ' 외' : ''}` : '없음';
const reward = this.currentSortieFlow().rewardHint;
const sortieNote = this.nextSortieRule().note;
const readyLine = [
`대화 ${completedDialogues}/${availableDialogues.length}`,
`방문 ${completedVisits}/${availableVisits.length}`,
`출전 ${selectedSummary}`,
`보유 ${inventorySummary}`
].join(' · ');
this.trackSortie(this.add.text(x + 16, y + 9, this.compactText(reward, 32), this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 9, this.compactText(`편성 메모: ${sortieNote}`, 42), this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(
x + 16,
y + 30,
this.compactText(`현재 준비: ${readyLine}`, 48),
this.compactText(`${reward} · ${readyLine}`, 48),
this.textStyle(12, '#d4dce6')
)
).setDepth(depth + 1);
@@ -12399,6 +12508,8 @@ export class CampScene extends Phaser.Scene {
const availableVisits = this.availableCampVisits();
const completedVisits = this.completedAvailableVisits().length;
const selected = this.selectedSortieUnits();
const recommendedClassLine = this.sortieRecommendedClassLine();
const recommendedClassComplete = this.coversRecommendedClasses(selected);
return [
{
label: requiredLabel,
@@ -12415,6 +12526,11 @@ export class CampScene extends Phaser.Scene {
complete: requiredUnitIds.every((id) => selected.some((unit) => unit.id === id)) && selected.length > 0,
detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : '출전 무장 선택 필요'
},
{
label: '추천 병종',
complete: recommendedClassComplete,
detail: recommendedClassLine
},
{
label: '소모품 보유',
complete: supplyCount > 0,