Generalize battle deployment setup
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<SortieFormationAssignments>((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<string>();
|
||||
const deployedAllyIds = new Set(this.deploymentEligibleUnits().map((unit) => unit.id));
|
||||
|
||||
const addSlot = (slot: SortieDeploymentSlot | DeploymentSlot, fallback?: Partial<DeploymentSlot>) => {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user