feat: 编队2D模型 — LateralSpacing/LongitudinalSpacing + DroneEntity 纵向偏移

- RoutePlan: FormationSpacing→LateralSpacing, 新增LateralCount/LongitudinalCount/LongitudinalSpacing
- DroneEntity: 2D编队(latIdx/longIdx), 横向Y展开+纵向X落后
- DefaultFormations: 6种编队模板
- DroneEntityTests: 11个(含2D编队/2×2方队/蜂群)
- Planner: yLanes=ceil(formationWidth/cloudDiam), totalNeeded=single×lanes
This commit is contained in:
tian 2026-06-13 13:20:18 +08:00
parent 4706c4b8c1
commit a9339c36e3
4 changed files with 104 additions and 66 deletions

View File

@ -179,14 +179,14 @@ namespace CounterDrone.Core.Algorithms
// 同单元所有弹药以中心点计算飞行时间,仅通过 eventIdx 错开间隔
float centerOffset = (eventIdx + (toTake - 1) / 2f - (totalRoundsNeeded - 1) / 2f) * spacing;
var feTemplate = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, centerOffset, spacing, totalRoundsNeeded);
var feTemplate = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, centerOffset);
float baseFireTime = feTemplate[0].FireTime;
var fevents = new List<FireEvent>();
for (int ch = 0; ch < toTake; ch++)
{
float offset = (eventIdx - (totalRoundsNeeded - 1) / 2f) * spacing;
var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset, spacing, totalRoundsNeeded);
var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset);
foreach (var e in fe)
{
e.FireTime = baseFireTime + salvoDelay + eventIdx * physicsInterval;
@ -436,8 +436,7 @@ namespace CounterDrone.Core.Algorithms
// ═══════════════════════════════════════════════
private List<FireEvent> GenerateFireEventsAt(DroneGroup threat, FireUnit unit,
AerosolType ammoType, AmmunitionSpec ammo, CombatScene env, float targetOffset,
float formationY)
AerosolType ammoType, AmmunitionSpec ammo, CombatScene env, float targetOffset)
{
var events = new List<FireEvent>();

View File

@ -12,7 +12,7 @@ namespace CounterDrone.Core.Simulation
public TargetType TargetType { get; }
public PowerType PowerType { get; }
public float Wingspan { get; }
public float TypicalSpeed { get; } // km/h
public float TypicalSpeed { get; }
public List<Waypoint> Route { get; }
public int CurrentWaypointIndex { get; private set; }
public float PosX { get; private set; }
@ -20,11 +20,12 @@ namespace CounterDrone.Core.Simulation
public float PosZ { get; private set; }
public float Hp { get; private set; } = 1.0f;
public DroneStatus Status { get; private set; } = DroneStatus.Flying;
public float ExposureTime { get; set; } // 在云团中的累计暴露时间
public float ExposureTime { get; set; }
public float FormationOffsetX { get; set; }
public float FormationOffsetY { get; set; }
public DroneEntity(string id, string groupId, TargetConfig config, List<Waypoint> route, int formationIndex, float spacing, FormationMode mode)
public DroneEntity(string id, string groupId, TargetConfig config, List<Waypoint> route,
int formationIndex, float lateralSpacing, int longitudinalIndex, float longitudinalSpacing, FormationMode mode)
{
Id = id;
GroupId = groupId;
@ -41,25 +42,24 @@ namespace CounterDrone.Core.Simulation
PosZ = (float)route[0].PosZ;
}
// 编队偏移
ApplyFormationOffset(formationIndex, spacing, mode);
ApplyFormationOffset(formationIndex, lateralSpacing, longitudinalIndex, longitudinalSpacing, mode);
PosX += FormationOffsetX;
PosY += FormationOffsetY;
}
private void ApplyFormationOffset(int index, float spacing, FormationMode mode)
private void ApplyFormationOffset(int latIdx, float latSpacing, int longIdx, float longSpacing, FormationMode mode)
{
if (index == 0) return;
var offset = spacing * index;
if (latIdx == 0 && longIdx == 0) return;
switch (mode)
{
case FormationMode.Formation:
FormationOffsetY = offset; // 沿 Y 轴横队排列
FormationOffsetY = latIdx * latSpacing;
FormationOffsetX = -longIdx * longSpacing;
break;
case FormationMode.Swarm:
var rng = new Random(index * 137);
FormationOffsetX = (float)(rng.NextDouble() - 0.5) * spacing * 3;
FormationOffsetY = (float)(rng.NextDouble() - 0.5) * spacing * 3;
var rng = new Random((latIdx + longIdx * 100) * 137);
FormationOffsetX = (float)(rng.NextDouble() - 0.5) * latSpacing * 3;
FormationOffsetY = (float)(rng.NextDouble() - 0.5) * latSpacing * 3;
break;
}
}
@ -93,13 +93,11 @@ namespace CounterDrone.Core.Simulation
return;
}
// 移动方向
var ratio = step / dist;
PosX += dx * ratio;
PosY += dy * ratio;
PosZ += dz * ratio;
// 风偏
(float wx, float wy, float wz) = Kinematics.WindToVector(windDir, windSpeed);
PosX += wx * deltaTime;
PosY += wy * deltaTime;

View File

@ -92,10 +92,17 @@ namespace CounterDrone.Core.Simulation
var wps = config.WaypointGroups.GetValueOrDefault(target.GroupId, new List<Waypoint>());
if (route == null || wps.Count == 0) continue;
var mode = (FormationMode)route.FormationMode;
var spacing = (float)route.LateralSpacing;
var lateralSpacing = (float)route.LateralSpacing;
var longSpacing = (float)route.LongitudinalSpacing;
int latCount = route.LateralCount ?? target.Quantity;
int longCount = route.LongitudinalCount ?? 1;
for (int i = 0; i < target.Quantity; i++)
{
int latIdx = i % latCount;
int longIdx = i / latCount;
_drones.Add(new DroneEntity($"drone_{++_entityCounter}", target.GroupId,
target, wps, i, spacing, mode));
target, wps, latIdx, lateralSpacing, longIdx, longSpacing, mode));
}
}
_platforms.Clear();

View File

@ -20,105 +20,139 @@ namespace CounterDrone.Core.Tests
};
}
private List<Waypoint> CreateRoute(float fromX, float toX, float altitude, float speed)
private List<Waypoint> CreateRoute(float fromX, float toX, float alt, float speed)
{
return new List<Waypoint>
{
new Waypoint { PosX = fromX, PosY = altitude, PosZ = 0, Altitude = altitude, Speed = speed },
new Waypoint { PosX = toX, PosY = altitude, PosZ = 0, Altitude = altitude, Speed = speed },
new Waypoint { PosX = fromX, PosY = alt, PosZ = 0, Altitude = alt, Speed = speed },
new Waypoint { PosX = toX, PosY = alt, PosZ = 0, Altitude = alt, Speed = speed },
};
}
[Fact]
public void InitialPosition_IsFirstWaypoint()
{
var route = CreateRoute(100, 500, 300, 120);
var drone = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 50, FormationMode.Single);
var drone = new DroneEntity("d1", "g1", CreateConfig(),
CreateRoute(100, 500, 300, 120), 0, 50, 0, 0, FormationMode.Single);
Assert.Equal(100, drone.PosX, 1);
Assert.Equal(300, drone.PosY, 1); // 高度 = waypoint.PosY
Assert.Equal(300, drone.PosY, 1);
}
[Fact]
public void Update_MovesTowardTarget()
{
var route = CreateRoute(0, 1000, 300, 120);
var drone = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 50, FormationMode.Single);
var drone = new DroneEntity("d1", "g1", CreateConfig(),
CreateRoute(0, 1000, 300, 120), 0, 50, 0, 0, FormationMode.Single);
float prevX = drone.PosX;
drone.Update(1f, 0, WindDirection.N);
Assert.True(drone.PosX > prevX, "无人机应向目标移动");
Assert.True(drone.PosX > prevX);
}
[Fact]
public void Update_ReachesTarget_StatusChanges()
{
var route = CreateRoute(0, 100, 300, 360); // 360 km/h = 100 m/s
var drone = new DroneEntity("d1", "g1", CreateConfig(360), route, 0, 50, FormationMode.Single);
// 100m 距离, 100m/s 速度, 1s 后应到达
var drone = new DroneEntity("d1", "g1", CreateConfig(360),
CreateRoute(0, 100, 300, 360), 0, 50, 0, 0, FormationMode.Single);
drone.Update(2f, 0, WindDirection.N);
Assert.Equal(DroneStatus.ReachedTarget, drone.Status);
}
[Fact]
public void ApplyDamage_ReducesHp()
public void Damage_ReducesHp()
{
var drone = new DroneEntity("d1", "g1", CreateConfig(),
CreateRoute(0, 100, 300, 60), 0, 50, FormationMode.Single);
CreateRoute(0, 100, 300, 60), 0, 50, 0, 0, FormationMode.Single);
drone.ApplyDamage(0.4f);
Assert.Equal(0.6f, drone.Hp, 0.01f);
Assert.Equal(DroneStatus.Flying, drone.Status);
}
[Fact]
public void ApplyDamage_ExceedingHp_DestroysDrone()
public void Damage_ExceedingHp_Destroys()
{
var drone = new DroneEntity("d1", "g1", CreateConfig(),
CreateRoute(0, 100, 300, 60), 0, 50, FormationMode.Single);
CreateRoute(0, 100, 300, 60), 0, 50, 0, 0, FormationMode.Single);
drone.ApplyDamage(1.5f);
Assert.True(drone.Hp <= 0);
Assert.Equal(DroneStatus.Destroyed, drone.Status);
}
[Fact]
public void Formation_YOffsetsDiffer()
public void Wind_AffectsPosition()
{
var drone = new DroneEntity("d1", "g1", CreateConfig(),
CreateRoute(0, 1000, 300, 60), 0, 50, 0, 0, FormationMode.Single);
float xBefore = drone.PosX;
drone.Update(1f, 20f, WindDirection.E);
Assert.True(drone.PosX > xBefore + 15f, "东风 20m/s 应增加 X");
}
// ═══════════════════════════════════════
// 编队位置
// ═══════════════════════════════════════
[Fact]
public void Formation_Lateral_YOffsetsDiffer()
{
var route = CreateRoute(0, 100, 300, 60);
var d0 = new DroneEntity("d0", "g1", CreateConfig(), route, 0, 50, FormationMode.Formation);
var d1 = new DroneEntity("d1", "g1", CreateConfig(), route, 1, 50, FormationMode.Formation);
var d0 = new DroneEntity("d0", "g1", CreateConfig(), route, 0, 50, 0, 0, FormationMode.Formation);
var d1 = new DroneEntity("d1", "g1", CreateConfig(), route, 1, 50, 0, 0, FormationMode.Formation);
var d2 = new DroneEntity("d2", "g1", CreateConfig(), route, 2, 50, 0, 0, FormationMode.Formation);
// 横向展开Y 递增
Assert.Equal(0, d0.FormationOffsetY);
Assert.Equal(50, d1.FormationOffsetY);
Assert.Equal(100, d2.FormationOffsetY);
}
Assert.NotEqual(d0.PosY, d1.PosY);
[Fact]
public void Formation_Longitudinal_XOffsetsDiffer()
{
var route = CreateRoute(0, 100, 300, 60);
var d0 = new DroneEntity("d0", "g1", CreateConfig(), route, 0, 0, 0, 100, FormationMode.Formation);
var d1 = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 0, 1, 100, FormationMode.Formation);
var d2 = new DroneEntity("d2", "g1", CreateConfig(), route, 0, 0, 2, 100, FormationMode.Formation);
// 纵向串列X 落后递增
Assert.Equal(0, d0.FormationOffsetX);
Assert.Equal(-100, d1.FormationOffsetX);
Assert.Equal(-200, d2.FormationOffsetX);
}
[Fact]
public void Formation_Mixed_2x2()
{
var route = CreateRoute(0, 100, 300, 60);
// 2横 × 2纵: index 0=(0,0), 1=(1,0), 2=(0,1), 3=(1,1)
var d0 = new DroneEntity("d0", "g1", CreateConfig(), route,
0, 50, 0, 100, FormationMode.Formation); // lat=0, long=0
var d1 = new DroneEntity("d1", "g1", CreateConfig(), route,
1, 50, 0, 100, FormationMode.Formation); // lat=1, long=0
var d2 = new DroneEntity("d2", "g1", CreateConfig(), route,
0, 50, 1, 100, FormationMode.Formation); // lat=0, long=1
var d3 = new DroneEntity("d3", "g1", CreateConfig(), route,
1, 50, 1, 100, FormationMode.Formation); // lat=1, long=1
Assert.Equal(0, d0.FormationOffsetY); Assert.Equal(0, d0.FormationOffsetX);
Assert.Equal(50, d1.FormationOffsetY); Assert.Equal(0, d1.FormationOffsetX);
Assert.Equal(0, d2.FormationOffsetY); Assert.Equal(-100, d2.FormationOffsetX);
Assert.Equal(50, d3.FormationOffsetY); Assert.Equal(-100, d3.FormationOffsetX);
}
[Fact]
public void Formation_Single_HasNoOffset()
{
var drone = new DroneEntity("d1", "g1", CreateConfig(),
CreateRoute(0, 100, 300, 60), 0, 50, 0, 0, FormationMode.Single);
Assert.Equal(0, drone.FormationOffsetX);
Assert.Equal(0, drone.FormationOffsetY);
}
[Fact]
public void Swarm_ProducesRandomOffsets()
{
var route = CreateRoute(0, 100, 300, 60);
var d0 = new DroneEntity("d0", "g1", CreateConfig(), route, 0, 50, FormationMode.Swarm);
var d1 = new DroneEntity("d1", "g1", CreateConfig(), route, 1, 50, FormationMode.Swarm);
var d2 = new DroneEntity("d2", "g1", CreateConfig(), route, 2, 50, FormationMode.Swarm);
// 蜂群模式:三架无人机位置应各不相同
var d0 = new DroneEntity("d0", "g1", CreateConfig(), route, 0, 30, 0, 0, FormationMode.Swarm);
var d1 = new DroneEntity("d1", "g1", CreateConfig(), route, 1, 30, 0, 0, FormationMode.Swarm);
Assert.True(d0.PosX != d1.PosX || d0.PosY != d1.PosY, "蜂群应有位置差异");
Assert.True(d1.PosX != d2.PosX || d1.PosY != d2.PosY, "蜂群应有位置差异");
}
[Fact]
public void Wind_AffectsPosition()
{
var route = CreateRoute(0, 1000, 300, 60);
var drone = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 50, FormationMode.Single);
float xBefore = drone.PosX;
drone.Update(1f, 20f, WindDirection.E); // 东风 20m/s
// 东风 → +X 方向偏移
Assert.True(drone.PosX > xBefore + 15f, "东风应显著增加 X 位移");
}
}
}