Expand sortie prep battle data
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user