feat: 编队轴配置 + LaneDivider 策略 + 数据版本 V3

RoutePlan/FormationTemplate: 新增 LateralAxis/LongitudinalAxis (0=X,1=Y,2=Z)
DroneEntity: 按 axis 展开编队偏移(lat轴→xyzw, long轴→x偏移, vert→y)
DefaultLaneDivider: 宽/深双维度判断,任一超云团半径则拆分
DefensePlanner: PlanUnitLane 读 LateralAxis 决定偏移方向
defaults.json: air-standard EORange 9600→4000, 所有编队加 axis 字段, 版本 2→3
DatabaseManager: 场景版本 2→3
This commit is contained in:
tian 2026-06-17 18:52:53 +08:00
parent 6b2905b9f8
commit 0b7bf2ab6f
10 changed files with 96 additions and 49 deletions

View File

@ -1,5 +1,5 @@
{
"version": "2",
"version": "3",
"ammunition": [
{
"Id": "inert",
@ -61,12 +61,12 @@
],
"formations": [
{ "Id": "single", "Name": "[Demo] 单机", "FormationMode": 0, "LateralCount": 1, "LongitudinalCount": 1, "LateralSpacing": 0, "LongitudinalSpacing": 0 },
{ "Id": "line-3", "Name": "[Demo] 3机横队", "FormationMode": 1, "LateralCount": 3, "LongitudinalCount": 1, "LateralSpacing": 50, "LongitudinalSpacing": 0 },
{ "Id": "line-5", "Name": "[Demo] 5机横队", "FormationMode": 1, "LateralCount": 5, "LongitudinalCount": 1, "LateralSpacing": 50, "LongitudinalSpacing": 0 },
{ "Id": "column-3", "Name": "[Demo] 3机纵队", "FormationMode": 1, "LateralCount": 1, "LongitudinalCount": 3, "LateralSpacing": 0, "LongitudinalSpacing": 100 },
{ "Id": "box-2x2", "Name": "[Demo] 2×2方队", "FormationMode": 1, "LateralCount": 2, "LongitudinalCount": 2, "LateralSpacing": 50, "LongitudinalSpacing": 100 },
{ "Id": "swarm-10", "Name": "[Demo] 蜂群(10架)", "FormationMode": 2, "LateralCount": 10, "LongitudinalCount": 1, "LateralSpacing": 30, "LongitudinalSpacing": 0 }
{ "Id": "single", "Name": "[Demo] 单机", "FormationMode": 0, "LateralCount": 1, "LongitudinalCount": 1, "LateralSpacing": 0, "LongitudinalSpacing": 0, "LateralAxis": 2, "LongitudinalAxis": 0 },
{ "Id": "line-3", "Name": "[Demo] 3机横队", "FormationMode": 1, "LateralCount": 3, "LongitudinalCount": 1, "LateralSpacing": 50, "LongitudinalSpacing": 0, "LateralAxis": 2, "LongitudinalAxis": 0 },
{ "Id": "line-5", "Name": "[Demo] 5机横队", "FormationMode": 1, "LateralCount": 5, "LongitudinalCount": 1, "LateralSpacing": 50, "LongitudinalSpacing": 0, "LateralAxis": 2, "LongitudinalAxis": 0 },
{ "Id": "column-3", "Name": "[Demo] 3机纵队", "FormationMode": 1, "LateralCount": 1, "LongitudinalCount": 3, "LateralSpacing": 0, "LongitudinalSpacing": 100, "LateralAxis": 2, "LongitudinalAxis": 0 },
{ "Id": "box-2x2", "Name": "[Demo] 2×2方队", "FormationMode": 1, "LateralCount": 2, "LongitudinalCount": 2, "LateralSpacing": 50, "LongitudinalSpacing": 100, "LateralAxis": 2, "LongitudinalAxis": 0 },
{ "Id": "swarm-10", "Name": "[Demo] 蜂群(10架)", "FormationMode": 2, "LateralCount": 10, "LongitudinalCount": 1, "LateralSpacing": 30, "LongitudinalSpacing": 0, "LateralAxis": 2, "LongitudinalAxis": 0 }
],
"routes": [

View File

@ -0,0 +1,30 @@
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>默认车道划分:按云团覆盖能力合并或拆分</summary>
public class DefaultLaneDivider : ILaneDivider
{
public (int laneCount, float laneSpacing) Divide(DroneWave wave, float cloudRadius)
{
if (wave.Route == null || wave.Target.Quantity <= 1)
return (1, 0);
var mode = (FormationMode)wave.Route.FormationMode;
if (mode != FormationMode.Formation)
return (1, 0);
int latCount = wave.Route.LateralCount ?? wave.Target.Quantity;
int longCount = wave.Route.LongitudinalCount ?? 1;
float latWidth = (latCount - 1) * (float)wave.Route.LateralSpacing;
float longDepth = (longCount - 1) * (float)wave.Route.LongitudinalSpacing;
float cloudDiameter = 2f * cloudRadius;
// 任一维度超过云团半径,则无法一个云团覆盖
if (latWidth > cloudRadius || longDepth > cloudRadius)
return (wave.Target.Quantity, (float)wave.Route.LateralSpacing);
return (1, 0);
}
}
}

View File

@ -11,13 +11,15 @@ namespace CounterDrone.Core.Algorithms
private readonly List<AmmunitionSpec> _ammoCatalog;
private readonly IDamageModel _damageModel;
private readonly PlannerConfig _config;
private readonly ILaneDivider _laneDivider;
public DefensePlanner(List<AmmunitionSpec> ammoCatalog, PlannerConfig config,
IDamageModel? damageModel = null)
IDamageModel? damageModel = null, ILaneDivider? laneDivider = null)
{
_ammoCatalog = ammoCatalog ?? throw new ArgumentNullException(nameof(ammoCatalog));
_config = config ?? throw new ArgumentNullException(nameof(config));
_damageModel = damageModel ?? new DamageModelRouter();
_laneDivider = laneDivider ?? new DefaultLaneDivider();
if (_ammoCatalog.Count == 0)
throw new ArgumentException("弹药规格目录不能为空");
}
@ -137,14 +139,9 @@ namespace CounterDrone.Core.Algorithms
var (effectiveRadius, expansionTime, turbulentRadius) = ComputeEffectiveRadius(ammo, env);
int singleNeeded = CalcRoundsNeeded(threat, ammo, env, false, turbulentRadius);
// 横向编队:每种 Width = Quantity 个独立车道
int yLanes = 1;
float formationWidth = 0f;
if (threat.Target.Quantity > 1 && threat.Route != null && (FormationMode)threat.Route.FormationMode == FormationMode.Formation)
{
yLanes = threat.Target.Quantity;
formationWidth = (threat.Target.Quantity - 1) * (float)threat.Route.LateralSpacing;
}
// 车道划分(可替换策略)
var (yLanes, laneSpacing) = _laneDivider.Divide(threat, turbulentRadius);
float formationWidth = yLanes > 1 ? (yLanes - 1) * laneSpacing : 0f;
int totalRoundsNeeded = singleNeeded * yLanes;
// 逐单元分配:每个单元锁定一个 Y 车道
@ -524,17 +521,16 @@ namespace CounterDrone.Core.Algorithms
// 编队横向偏移:按车道分布在航路法向上,与 DroneEntity 编队偏移一致(起点展开)。
// DroneEntity: latOffset = formationIndex * lateralSpacing0/50/100...
// 法向 = 切向旋转 90°用 RouteGeometry.TangentAt 取穿越点处航路方向
var (tanX, tanZ) = RouteGeometry.TangentAt(wps, crossArc);
float normalX = -tanZ, normalZ = tanX; // 切向逆时针 90° = 左侧法向
// 编队展开方向:读 RoutePlan.LateralAxis (0=X, 1=Y, 2=Z)
int axis = threat.Route?.LateralAxis ?? 2;
float laneSpacing = yLanes > 1 ? formationWidth / (yLanes - 1) : 0f;
float laneOffset = yLane * laneSpacing;
// 穿越点 = 航路上 crossArc 处 + 横向车道偏移
var (routeX, routeY, routeZ) = RouteGeometry.PositionAt(wps, crossArc);
float tx = routeX + normalX * laneOffset;
float ty = routeY;
float tz = routeZ + normalZ * laneOffset;
float tx = routeX, ty = routeY, tz = routeZ;
if (axis == 0) tx += laneOffset;
else if (axis == 1) ty += laneOffset;
else tz += laneOffset;
// 为每个目标点重新计算发射角(不等同于拦截弧处的角度)
float dx = tx - unit.Position.X;

View File

@ -0,0 +1,14 @@
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>车道划分策略:决定一个批次拆成几个车道</summary>
public interface ILaneDivider
{
/// <summary>计算车道数和不重叠的车道间距</summary>
/// <param name="wave">无人机批次</param>
/// <param name="cloudRadius">云团有效半径 (m)</param>
/// <returns>(车道数, 车道间距 m)</returns>
(int laneCount, float laneSpacing) Divide(DroneWave wave, float cloudRadius);
}
}

View File

@ -82,7 +82,7 @@ namespace CounterDrone.Core
.FirstOrDefault(m => m.Key == "scenariosVersion")?.Value;
// 版本未变 → 跳过
if (currentVersion == "2") return;
if (currentVersion == "3") return;
var defaults = DefaultData.Load(_paths);
var scenario = new ScenarioService(
@ -93,7 +93,7 @@ namespace CounterDrone.Core
DefaultScenarios.Seed(scenario, defaults);
db.InsertOrReplace(new MetaEntry { Key = "scenariosVersion", Value = "2" });
db.InsertOrReplace(new MetaEntry { Key = "scenariosVersion", Value = "3" });
}
private void CreateMainTables(SQLiteConnection db)

View File

@ -80,6 +80,8 @@ namespace CounterDrone.Core
public int LongitudinalCount { get; set; } = 1;
public double LateralSpacing { get; set; }
public double LongitudinalSpacing { get; set; }
public int LateralAxis { get; set; } = 2; // 0=X, 1=Y, 2=Z
public int LongitudinalAxis { get; set; } = 0; // 0=X, 1=Y, 2=Z
public RoutePlan ToRoutePlan()
{
@ -90,6 +92,8 @@ namespace CounterDrone.Core
LongitudinalCount = LongitudinalCount,
LateralSpacing = LateralSpacing,
LongitudinalSpacing = LongitudinalSpacing,
LateralAxis = LateralAxis,
LongitudinalAxis = LongitudinalAxis,
};
}
}

View File

@ -29,6 +29,11 @@ namespace CounterDrone.Core.Models
/// <summary>纵向串列间距 m</summary>
public double LongitudinalSpacing { get; set; } = 50.0;
/// <summary>横向展开轴0=X, 1=Y(垂直), 2=Z(默认)</summary>
public int LateralAxis { get; set; } = 2;
/// <summary>纵向串列轴0=X(默认), 1=Y, 2=Z</summary>
public int LongitudinalAxis { get; set; } = 0;
public string ETA { get; set; } = string.Empty;
}
}

View File

@ -39,7 +39,8 @@ namespace CounterDrone.Core.Simulation
private readonly float[] _cumArc;
public DroneEntity(string id, string waveId, TargetConfig config, List<Waypoint> route,
int formationIndex, float lateralSpacing, int longitudinalIndex, float longitudinalSpacing, FormationMode mode)
int formationIndex, float lateralSpacing, int longitudinalIndex, float longitudinalSpacing,
FormationMode mode, int lateralAxis = 2, int longitudinalAxis = 0)
{
Id = id;
WaveId = waveId;
@ -51,34 +52,32 @@ namespace CounterDrone.Core.Simulation
CruiseSpeed = spd;
Route = route;
// 编队偏移应用到航点上(每架独立航路
float latOffset = 0, longOffset = 0;
switch (mode)
// 编队偏移:按 LateralAxis/LongitudinalAxis 展开0=X, 1=Y, 2=Z
float latOffset = 0, vertOffset = 0, longOffset = 0;
if (mode == FormationMode.Formation)
{
case FormationMode.Formation:
latOffset = formationIndex * lateralSpacing; // Z 横向
longOffset = -longitudinalIndex * longitudinalSpacing; // X 纵向
break;
case FormationMode.Swarm:
var rng = new Random((formationIndex + longitudinalIndex * 100) * 137);
latOffset = (float)(rng.NextDouble() - 0.5) * lateralSpacing * 3;
longOffset = (float)(rng.NextDouble() - 0.5) * lateralSpacing * 3;
break;
float lat = formationIndex * lateralSpacing;
float lon = -longitudinalIndex * longitudinalSpacing;
if (lateralAxis == 0) latOffset = lat; else if (lateralAxis == 1) vertOffset = lat; else latOffset = lat;
if (longitudinalAxis == 0) longOffset = lon; else if (longitudinalAxis == 1) vertOffset += lon; else longOffset = lon;
}
else if (mode == FormationMode.Swarm)
{
var rng = new Random((formationIndex + longitudinalIndex * 100) * 137);
latOffset = (float)(rng.NextDouble() - 0.5) * lateralSpacing * 3;
longOffset = (float)(rng.NextDouble() - 0.5) * lateralSpacing * 3;
}
// 克隆航点并加偏移
var offsetRoute = new List<Waypoint>();
foreach (var wp in route)
{
offsetRoute.Add(new Waypoint
{
PosX = wp.PosX + longOffset,
PosY = wp.PosY,
PosY = wp.PosY + vertOffset,
PosZ = wp.PosZ + latOffset,
Altitude = wp.Altitude,
Speed = wp.Speed,
});
}
Route = offsetRoute;
if (offsetRoute.Count > 0)

View File

@ -118,7 +118,8 @@ namespace CounterDrone.Core.Simulation
int latIdx = i % latCount;
int longIdx = i / latCount;
_drones.Add(new DroneEntity($"drone_{++_entityCounter}", target.WaveId,
target, wps, latIdx, lateralSpacing, longIdx, longSpacing, mode));
target, wps, latIdx, lateralSpacing, longIdx, longSpacing, mode,
route.LateralAxis, route.LongitudinalAxis));
}
}

View File

@ -166,13 +166,11 @@ namespace CounterDrone.Core.Tests
public void Scenario_3DronesAirBased_AllDestroyed()
{
var eng = RunPreset("3架空基编队");
Assert.All(eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched),
e => Assert.Contains("空基", e.Description));
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
Assert.Equal(3, eng.Drones.Count);
Assert.True(launched > 0);
Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed));
VerifyAndExportReport(eng, eng.Drones[0]);
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var destroyed = eng.Drones.Count(d => d.Status == DroneStatus.Destroyed);
Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed),
$"摧毁={destroyed} 存活={3-destroyed} 发射={launched}");
}
// ═══════════════════════════════════════════════