diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index a17e0a2..a5342d9 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -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, diff --git a/src/game/data/sortieDeployment.ts b/src/game/data/sortieDeployment.ts index cdb8825..b71e1b2 100644 --- a/src/game/data/sortieDeployment.ts +++ b/src/game/data/sortieDeployment.ts @@ -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(); @@ -60,8 +75,25 @@ export function createSortieDeploymentPlan( const plan = new Map(); const usedTiles = new Set(); + 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(tiles: T[]) { const seen = new Set(); return tiles.filter((tile) => { const key = tileKey(tile); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 3bcf502..63eb9c3 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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))); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 77b56f6..f997cc8 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -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>; 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,