Generalize battle deployment setup

This commit is contained in:
2026-07-04 02:44:04 +09:00
parent 59bcd96e61
commit 29021bc18d
3 changed files with 263 additions and 43 deletions

View File

@@ -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();
}