From 29021bc18db4c27009db3e732d5daed2452306dc Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 4 Jul 2026 02:44:04 +0900 Subject: [PATCH] Generalize battle deployment setup --- src/game/data/battles.ts | 20 +++ src/game/data/sortieDeployment.ts | 4 +- src/game/scenes/BattleScene.ts | 282 +++++++++++++++++++++++++----- 3 files changed, 263 insertions(+), 43 deletions(-) diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 3495848..587f555 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -420,6 +420,26 @@ export const firstBattleScenario: BattleScenarioDefinition = { '유비가 퇴각하면 의용군은 전투를 지속할 수 없습니다.', '마을 지형을 밟아 보급을 확보하고, 다친 장수는 콩과 탁주로 회복하십시오.' ], + 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: 2, y: 15, label: '정면 돌파' }, + { role: 'flank', unitId: 'zhang-fei', x: 1, y: 16, label: '측면 압박' }, + { role: 'support', unitId: 'liu-bei', x: 1, y: 15, label: '후방 지휘' } + ], + note: '관우는 정면 돌파, 장비는 측면 압박, 유비는 후방 지휘가 기본 전열입니다. 시작 구역 안에서 위치를 바꿀 수 있습니다.' + }, map: firstBattleMap, units: firstBattleUnits, bonds: firstBattleBonds, diff --git a/src/game/data/sortieDeployment.ts b/src/game/data/sortieDeployment.ts index b71e1b2..e09c0d6 100644 --- a/src/game/data/sortieDeployment.ts +++ b/src/game/data/sortieDeployment.ts @@ -77,9 +77,7 @@ export function createSortieDeploymentPlan( 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)) - ); + const tile = scoredTiles.find((candidate) => candidate.unitId === unit.id && !usedTiles.has(tileKey(candidate))); if (!tile) { return; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 709f22d..411cbbb 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -37,6 +37,7 @@ import { createSortieDeploymentPlan, normalizeSortieFormationAssignments, type SortieFormationAssignments, + type SortieDeploymentSlot, type SortieFormationRole } from '../data/sortieDeployment'; import { @@ -3420,10 +3421,23 @@ export class BattleScene extends Phaser.Scene { return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? []; } + private scenarioRecommendedSortieFormationAssignments() { + return (battleScenario.sortie?.recommendedUnits ?? []).reduce((assignments, recommendation) => { + assignments[recommendation.unitId] = recommendation.role; + return assignments; + }, {}); + } + private effectiveSortieFormationAssignments(campaign?: CampaignState) { - return Object.keys(this.launchSortieFormationAssignments).length > 0 + const scenarioAssignments = this.scenarioRecommendedSortieFormationAssignments(); + const persistedAssignments = Object.keys(this.launchSortieFormationAssignments).length > 0 ? this.launchSortieFormationAssignments : campaign?.sortieFormationAssignments ?? {}; + + return { + ...scenarioAssignments, + ...persistedAssignments + }; } private effectiveSortieItemAssignments(campaign?: CampaignState) { @@ -4643,7 +4657,7 @@ export class BattleScene extends Phaser.Scene { } private shouldShowPreBattleDeployment() { - return battleScenario.id === 'first-battle-zhuo-commandery' && battleUnits.some((unit) => unit.faction === 'ally' && unit.hp > 0); + return Boolean(battleScenario.sortie?.deploymentSlots?.length) && this.deploymentEligibleUnits().length > 0; } private showPreBattleDeployment() { @@ -4654,20 +4668,217 @@ export class BattleScene extends Phaser.Scene { this.targetingAction = undefined; this.selectedUsable = undefined; this.lockedTargetPreview = undefined; - this.deploymentSelectedUnitId = battleUnits.find((unit) => unit.id === 'guan-yu' && unit.hp > 0)?.id; - this.deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.'; + this.deploymentSelectedUnitId = this.deploymentDefaultSelectedUnit()?.id; + this.deploymentNotice = this.deploymentDefaultNotice(); this.clearMarkers(); this.hideCommandMenu(); this.hideMapMenu(); this.hideTurnEndPrompt(); this.hideBattleEventBanner(); - this.centerCameraOnTile(2, 16); + const center = this.deploymentCameraCenter(); + this.centerCameraOnTile(center.x, center.y); this.renderDeploymentMap(); this.renderDeploymentPanel(); this.refreshUnitLegibilityStyles(); this.updatePointerFeedback(this.input.activePointer, true); } + private deploymentEligibleUnits() { + return battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0); + } + + private deploymentDefaultSelectedUnit() { + const allies = this.deploymentEligibleUnits(); + const recommendedFront = battleScenario.sortie?.recommendedUnits.find((entry) => entry.role === 'front'); + return allies.find((unit) => unit.id === recommendedFront?.unitId) ?? allies.find((unit) => unit.id === 'guan-yu') ?? allies[0]; + } + + private deploymentDefaultNotice() { + return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.'; + } + + private deploymentSubtitle() { + return battleScenario.sortie?.note ?? `${battleScenario.title}의 시작 구역을 확인하고 전열을 정합니다.`; + } + + private deploymentCameraCenter() { + const slots = this.deploymentSlots(); + if (!slots.length) { + const unit = this.deploymentDefaultSelectedUnit(); + return { x: unit?.x ?? 0, y: unit?.y ?? 0 }; + } + + const total = slots.reduce((sum, slot) => ({ x: sum.x + slot.x, y: sum.y + slot.y }), { x: 0, y: 0 }); + return { x: total.x / slots.length, y: total.y / slots.length }; + } + + private deploymentSlots(): DeploymentSlot[] { + const slots: DeploymentSlot[] = []; + const seen = new Set(); + const deployedAllyIds = new Set(this.deploymentEligibleUnits().map((unit) => unit.id)); + + const addSlot = (slot: SortieDeploymentSlot | DeploymentSlot, fallback?: Partial) => { + if (!this.isInBounds(slot.x, slot.y)) { + return; + } + if (getTerrainRule(battleMap.terrain[slot.y][slot.x]).passable === false) { + return; + } + if (battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0 && unit.x === slot.x && unit.y === slot.y)) { + return; + } + const key = `${slot.x},${slot.y}`; + if (seen.has(key)) { + return; + } + seen.add(key); + const role = slot.role ?? fallback?.role ?? 'reserve'; + const unitId = slot.unitId && deployedAllyIds.has(slot.unitId) ? slot.unitId : fallback?.unitId; + slots.push({ + x: slot.x, + y: slot.y, + role, + unitId, + label: slot.label ?? fallback?.label ?? this.deploymentRoleLabel(role), + note: fallback?.note ?? this.deploymentRoleNote(role, unitId), + icon: fallback?.icon ?? this.deploymentRoleIcon(role, unitId), + tone: fallback?.tone ?? this.deploymentRoleTone(role) + }); + }; + + battleScenario.sortie?.deploymentSlots.forEach((slot) => addSlot(slot)); + + initialBattleUnits + .filter((unit) => unit.faction === 'ally' && deployedAllyIds.has(unit.id)) + .forEach((unit) => { + const role = this.deploymentRoleForUnit(unit); + addSlot( + { x: unit.x, y: unit.y, role, unitId: unit.id, label: this.deploymentRoleLabel(role) }, + { unitId: unit.id, role, label: this.deploymentRoleLabel(role), note: `${unit.name}의 추천 시작 위치입니다.` } + ); + }); + + this.deploymentReserveCandidates(slots).forEach((candidate) => { + addSlot(candidate, { + role: 'reserve', + label: '예비 자리', + note: '시작 구역 안에서 전열을 조정합니다.', + icon: 'move', + tone: this.deploymentRoleTone('reserve') + }); + }); + + return slots; + } + + private deploymentReserveCandidates(sourceSlots: DeploymentSlot[]) { + if (!sourceSlots.length) { + return []; + } + + const minX = Math.min(...sourceSlots.map((slot) => slot.x)); + const maxX = Math.max(...sourceSlots.map((slot) => slot.x)); + const minY = Math.min(...sourceSlots.map((slot) => slot.y)); + const maxY = Math.max(...sourceSlots.map((slot) => slot.y)); + const center = this.deploymentCameraCenterFromSlots(sourceSlots); + const occupied = new Set(sourceSlots.map((slot) => `${slot.x},${slot.y}`)); + const candidates: SortieDeploymentSlot[] = []; + + for (let y = minY - 1; y <= maxY + 2; y += 1) { + for (let x = minX - 1; x <= maxX + 2; x += 1) { + const key = `${x},${y}`; + if (occupied.has(key) || !this.isInBounds(x, y)) { + continue; + } + if (getTerrainRule(battleMap.terrain[y][x]).passable === false) { + continue; + } + if (battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0 && unit.x === x && unit.y === y)) { + continue; + } + candidates.push({ x, y, role: 'reserve', label: '예비 자리' }); + } + } + + return candidates + .sort((a, b) => Math.hypot(a.x - center.x, a.y - center.y) - Math.hypot(b.x - center.x, b.y - center.y)) + .slice(0, Math.max(2, Math.min(4, this.deploymentEligibleUnits().length + 1))); + } + + private deploymentCameraCenterFromSlots(slots: DeploymentSlot[]) { + const total = slots.reduce((sum, slot) => ({ x: sum.x + slot.x, y: sum.y + slot.y }), { x: 0, y: 0 }); + return { x: total.x / slots.length, y: total.y / slots.length }; + } + + private deploymentRoleForUnit(unit: UnitData): SortieFormationRole { + const campaign = getCampaignState(); + return this.effectiveSortieFormationAssignments(campaign)[unit.id] ?? this.defaultSortieFormationRole(unit); + } + + private deploymentRoleLabel(role: SortieFormationRole) { + if (role === 'front') { + return '전열'; + } + if (role === 'flank') { + return '측면'; + } + if (role === 'support') { + return '후방'; + } + return '예비'; + } + + private deploymentRoleNote(role: SortieFormationRole, unitId?: string) { + const unit = unitId ? battleUnits.find((candidate) => candidate.id === unitId) : undefined; + const name = unit?.name ?? '장수'; + if (role === 'front') { + return `${name}이 적 전열을 먼저 받아냅니다.`; + } + if (role === 'flank') { + return `${name}이 측면에서 적 진형을 흔듭니다.`; + } + if (role === 'support') { + return `${name}이 후방에서 생존과 지원을 맡습니다.`; + } + return '전열을 조정할 수 있는 예비 시작 위치입니다.'; + } + + private deploymentRoleIcon(role: SortieFormationRole, unitId?: string): BattleUiIconKey { + const unit = unitId ? battleUnits.find((candidate) => candidate.id === unitId) : undefined; + if (unit?.classKey === 'archer') { + return 'bow'; + } + if (unit?.classKey === 'cavalry') { + return 'horse'; + } + if (unit?.classKey === 'strategist') { + return 'strategy'; + } + if (role === 'front') { + return 'spear'; + } + if (role === 'flank') { + return 'attack'; + } + if (role === 'support') { + return 'leadership'; + } + return 'move'; + } + + private deploymentRoleTone(role: SortieFormationRole) { + if (role === 'front') { + return 0xd8b15f; + } + if (role === 'flank') { + return 0xc76542; + } + if (role === 'support') { + return 0x5f95d8; + } + return 0x7d8a96; + } + private firstBattleDeploymentSlots(): DeploymentSlot[] { const slots: DeploymentSlot[] = [ { @@ -4714,18 +4925,18 @@ export class BattleScene extends Phaser.Scene { } private isDeploymentTile(x: number, y: number) { - return this.firstBattleDeploymentSlots().some((slot) => slot.x === x && slot.y === y); + return this.deploymentSlots().some((slot) => slot.x === x && slot.y === y); } private deploymentSlotAt(x: number, y: number) { - return this.firstBattleDeploymentSlots().find((slot) => slot.x === x && slot.y === y); + return this.deploymentSlots().find((slot) => slot.x === x && slot.y === y); } private renderDeploymentMap() { this.clearDeploymentObjects(); const selected = this.deploymentSelectedUnit(); - this.firstBattleDeploymentSlots().forEach((slot) => { + this.deploymentSlots().forEach((slot) => { const occupant = this.unitAtTile(slot.x, slot.y); const recommendedUnit = slot.unitId ? battleUnits.find((unit) => unit.id === slot.unitId) : undefined; const selectedTile = selected?.x === slot.x && selected?.y === slot.y; @@ -4844,7 +5055,7 @@ export class BattleScene extends Phaser.Scene { color: '#f4dfad', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 16, top + 48, '황건 잔당의 보병 전열을 보고 시작 위치를 정합니다.', { + this.trackSideObject(this.add.text(left + 16, top + 48, this.deploymentSubtitle(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#c8d2dd', @@ -4903,32 +5114,23 @@ export class BattleScene extends Phaser.Scene { } private deploymentRoleSummaries() { - return [ - { - unitId: 'guan-yu', - name: this.unitName('guan-yu'), - role: '정면 돌파', - text: '길목의 황건 보병을 먼저 열어 전열을 만듭니다.', - icon: 'spear' as BattleUiIconKey, - tone: 0xd8b15f - }, - { - unitId: 'zhang-fei', - name: this.unitName('zhang-fei'), - role: '측면 압박', - text: '아래쪽에서 궁병 엄호와 잔당을 흔듭니다.', - icon: 'attack' as BattleUiIconKey, - tone: 0xc76542 - }, - { - unitId: 'liu-bei', - name: this.unitName('liu-bei'), - role: '후방 지휘', - text: '유비를 무리시키지 않고 생존과 회복 여지를 남깁니다.', - icon: 'leadership' as BattleUiIconKey, - tone: 0x5f95d8 - } - ]; + const recommendationByUnitId = new Map((battleScenario.sortie?.recommendedUnits ?? []).map((entry) => [entry.unitId, entry])); + const orderByUnitId = new Map(this.deploymentSlots().filter((slot) => slot.unitId).map((slot, index) => [slot.unitId, index])); + + return this.deploymentEligibleUnits() + .sort((a, b) => (orderByUnitId.get(a.id) ?? 99) - (orderByUnitId.get(b.id) ?? 99)) + .slice(0, 3) + .map((unit) => { + const role = recommendationByUnitId.get(unit.id)?.role ?? this.deploymentRoleForUnit(unit); + return { + unitId: unit.id, + name: unit.name, + role: this.deploymentRoleLabel(role), + text: recommendationByUnitId.get(unit.id)?.reason ?? this.deploymentRoleNote(role, unit.id), + icon: this.deploymentRoleIcon(role, unit.id), + tone: this.deploymentRoleTone(role) + }; + }); } private renderDeploymentButton( @@ -5013,7 +5215,7 @@ export class BattleScene extends Phaser.Scene { } if (!this.isDeploymentTile(x, y)) { - this.deploymentNotice = '첫 전투에서는 표시된 시작 구역 안에서만 전열을 바꿀 수 있습니다.'; + this.deploymentNotice = '표시된 시작 구역 안에서만 전열을 바꿀 수 있습니다.'; this.renderDeploymentPanel(); return; } @@ -5053,7 +5255,7 @@ export class BattleScene extends Phaser.Scene { } private restoreRecommendedDeployment() { - const recommended = this.firstBattleDeploymentSlots().filter((slot) => slot.unitId); + const recommended = this.deploymentSlots().filter((slot) => slot.unitId); recommended.forEach((slot) => { const unit = battleUnits.find((candidate) => candidate.id === slot.unitId && candidate.hp > 0); if (!unit) { @@ -5065,8 +5267,8 @@ export class BattleScene extends Phaser.Scene { unit.y = slot.y; this.moveUnitView(unit, slot.x, slot.y, this.unitViews.get(unit.id), 130, fromX, fromY); }); - this.deploymentSelectedUnitId = 'guan-yu'; - this.deploymentNotice = '추천 배치로 되돌렸습니다. 관우는 정면, 장비는 측면, 유비는 후방에 섭니다.'; + this.deploymentSelectedUnitId = this.deploymentDefaultSelectedUnit()?.id; + this.deploymentNotice = '추천 배치로 되돌렸습니다. 전투 시작 전 역할에 맞춰 전열을 다시 확인하세요.'; this.updateMiniMap(); this.renderDeploymentMap(); this.renderDeploymentPanel(); @@ -5085,7 +5287,7 @@ export class BattleScene extends Phaser.Scene { this.pendingMove = undefined; this.phase = 'idle'; this.refreshUnitLegibilityStyles(); - this.renderRosterPanel('ally', '전열 배치가 끝났습니다. 관우로 길목을 열고, 장비로 측면을 압박하세요.'); + this.renderRosterPanel('ally', '전열 배치가 끝났습니다. 선택한 시작 위치에서 전투를 시작합니다.'); this.showOpeningBattleEvent(); soundDirector.playSelect(); }