Link sortie roles to battle deployment

This commit is contained in:
2026-07-03 00:31:20 +09:00
parent 40769348ad
commit a3858db201
5 changed files with 356 additions and 17 deletions

View File

@@ -0,0 +1,187 @@
import type { UnitData } from './scenario';
export type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve';
export type SortieFormationAssignments = Partial<Record<string, SortieFormationRole>>;
export const sortieFormationRoles: SortieFormationRole[] = ['front', 'flank', 'support', 'reserve'];
export type SortieDeploymentPlanEntry = {
unitId: string;
role: SortieFormationRole;
x: number;
y: number;
};
type ScoredTile = {
x: number;
y: number;
forward: number;
lateral: number;
enemyDistance: number;
};
export function isSortieFormationRole(value: unknown): value is SortieFormationRole {
return sortieFormationRoles.includes(value as SortieFormationRole);
}
export function normalizeSortieFormationAssignments(
assignments?: Record<string, unknown>,
allowedUnitIds?: Set<string>
): SortieFormationAssignments {
return Object.fromEntries(
Object.entries(assignments ?? {}).filter(([unitId, role]) => {
return (!allowedUnitIds || allowedUnitIds.has(unitId)) && isSortieFormationRole(role);
})
) as SortieFormationAssignments;
}
export function createSortieDeploymentPlan(
deployedUnits: UnitData[],
sourceUnits: UnitData[],
selectedUnitIds: string[],
assignments: SortieFormationAssignments,
fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole
) {
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 })));
if (deployedAllies.length === 0 || sourceAllyTiles.length === 0) {
return new Map<string, SortieDeploymentPlanEntry>();
}
const enemyTiles = sourceUnits.filter((unit) => unit.faction === 'enemy').map((unit) => ({ x: unit.x, y: unit.y }));
const allyCenter = averagePoint(sourceAllyTiles);
const enemyCenter = enemyTiles.length ? averagePoint(enemyTiles) : { x: allyCenter.x + 1, y: allyCenter.y };
const direction = normalizePoint({ x: enemyCenter.x - allyCenter.x, y: enemyCenter.y - allyCenter.y });
const scoredTiles = sourceAllyTiles.map((tile) => scoreTile(tile, allyCenter, enemyCenter, direction));
const plan = new Map<string, SortieDeploymentPlanEntry>();
const usedTiles = new Set<string>();
sortieFormationRoles.forEach((role) => {
const roleUnits = deployedAllies.filter((unit) => roleForUnit(unit, assignments, fallbackRoleForUnit) === role);
roleUnits.forEach((unit) => {
const tile = bestTileForRole(role, scoredTiles, usedTiles);
if (!tile) {
return;
}
usedTiles.add(tileKey(tile));
plan.set(unit.id, {
unitId: unit.id,
role,
x: tile.x,
y: tile.y
});
});
});
deployedAllies.forEach((unit) => {
if (plan.has(unit.id)) {
return;
}
const tile = bestTileForRole('reserve', scoredTiles, usedTiles);
if (!tile) {
return;
}
usedTiles.add(tileKey(tile));
plan.set(unit.id, {
unitId: unit.id,
role: roleForUnit(unit, assignments, fallbackRoleForUnit),
x: tile.x,
y: tile.y
});
});
return plan;
}
export function applySortieDeploymentPlan(units: UnitData[], plan: Map<string, SortieDeploymentPlanEntry>) {
return units.map((unit) => {
const entry = plan.get(unit.id);
if (!entry || unit.faction !== 'ally') {
return unit;
}
return {
...unit,
x: entry.x,
y: entry.y
};
});
}
function roleForUnit(
unit: UnitData,
assignments: SortieFormationAssignments,
fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole
) {
return assignments[unit.id] ?? fallbackRoleForUnit(unit);
}
function bestTileForRole(role: SortieFormationRole, tiles: ScoredTile[], usedTiles: Set<string>) {
return [...tiles]
.filter((tile) => !usedTiles.has(tileKey(tile)))
.sort((a, b) => compareTilesForRole(role, a, b))[0];
}
function compareTilesForRole(role: SortieFormationRole, a: ScoredTile, b: ScoredTile) {
if (role === 'front') {
return b.forward - a.forward || a.enemyDistance - b.enemyDistance || a.lateral - b.lateral;
}
if (role === 'flank') {
return b.lateral - a.lateral || b.forward - a.forward || a.enemyDistance - b.enemyDistance;
}
if (role === 'support') {
return a.forward - b.forward || b.enemyDistance - a.enemyDistance || a.lateral - b.lateral;
}
return a.lateral - b.lateral || a.forward - b.forward || b.enemyDistance - a.enemyDistance;
}
function scoreTile(
tile: { x: number; y: number },
allyCenter: { x: number; y: number },
enemyCenter: { x: number; y: number },
direction: { x: number; y: number }
): ScoredTile {
const relative = { x: tile.x - allyCenter.x, y: tile.y - allyCenter.y };
return {
x: tile.x,
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)
};
}
function uniqueTiles(tiles: { x: number; y: number }[]) {
const seen = new Set<string>();
return tiles.filter((tile) => {
const key = tileKey(tile);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function averagePoint(points: { x: number; y: number }[]) {
const total = points.reduce((sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }), { x: 0, y: 0 });
return {
x: total.x / points.length,
y: total.y / points.length
};
}
function normalizePoint(point: { x: number; y: number }) {
const length = Math.hypot(point.x, point.y) || 1;
return {
x: point.x / length,
y: point.y / length
};
}
function tileKey(tile: { x: number; y: number }) {
return `${tile.x},${tile.y}`;
}