fix: 多单元并行 + 多车道正确弹药计算

- 所有单元以同基准时间并行发射(offset=0)
- 通道内 ch×ChannelInterval 独立错开
- CalcRoundsNeeded 恢复纯单机,yLanes 乘法在 Solve
- Piston: 33发=11×3车道, 2/3击毁
This commit is contained in:
tian 2026-06-13 13:57:56 +08:00
parent c7792d1d4a
commit 29aa11665b
2 changed files with 62 additions and 38 deletions

View File

@ -162,13 +162,17 @@ namespace CounterDrone.Core.Algorithms
{
float salvoDelay = salvo * candidates[0].Unit.Cooldown;
// 同平台飞行时间只算一次(平台飞到投放点后所有通道齐投)
var (effectiveR, _) = ComputeEffectiveRadius(threat, ammo, env);
float cloudDiameter = effectiveR * 2f;
float droneSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
float physicsInterval = Math.Max(candidates[0].Unit.ChannelInterval,
cloudDiameter / droneSpeed);
float spacing = effectiveR * 1.5f;
// 车道参数
var (laneEffR, _) = ComputeEffectiveRadius(threat, ammo, env);
float cloudDiameter = laneEffR * 2f;
float droneSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
float physicsInterval = Math.Max(candidates[0].Unit.ChannelInterval,
cloudDiameter / droneSpeed);
float spacing = laneEffR * 1.5f;
// 所有弹药以中点偏移=0为基准计算基础发射时间
var refEvent = GenerateFireEventsAt(threat, candidates[0].Unit, neededAmmo, ammo, env, 0, 0, yLanes, formationWidth);
float globalBaseFireTime = refEvent[0].FireTime;
foreach (var c in candidates.OrderBy(c => c.EarliestInterceptTime)
.ThenByDescending(c => c.KillProbability))
@ -181,21 +185,19 @@ namespace CounterDrone.Core.Algorithms
int toTake = Math.Min(maxSalvo, totalRoundsNeeded - roundsCollected);
if (toTake <= 0) continue;
// 同单元所有弹药以中心点计算飞行时间,仅通过 eventIdx 错开间隔
float centerOffset = (eventIdx + (toTake - 1) / 2f - (totalRoundsNeeded - 1) / 2f) * spacing;
int yLane = eventIdx % yLanes;
var feTemplate = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, centerOffset, yLane, yLanes, formationWidth);
float baseFireTime = feTemplate[0].FireTime;
// 所有单元以航路中点计算相同的基准发射时间,仅通道间隔错开
var refEvt = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, 0, 0, yLanes, formationWidth);
float unitBaseTime = refEvt[0].FireTime;
var fevents = new List<FireEvent>();
for (int ch = 0; ch < toTake; ch++)
{
float offset = (eventIdx - (totalRoundsNeeded - 1) / 2f) * spacing;
int yLane2 = eventIdx % yLanes;
var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset, yLane2, yLanes, formationWidth);
int yLane = eventIdx % yLanes;
var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset, yLane, yLanes, formationWidth);
foreach (var e in fe)
{
e.FireTime = baseFireTime + salvoDelay + eventIdx * physicsInterval;
e.FireTime = unitBaseTime + salvoDelay + ch * c.Unit.ChannelInterval;
}
fevents.AddRange(fe);
eventIdx++;
@ -406,17 +408,7 @@ namespace CounterDrone.Core.Algorithms
var aerosolType = (AerosolType)ammo.AerosolType;
float neededExposure = aerosolType == AerosolType.ActiveMaterial ? 2f : 6f;
float singleCoverage = neededExposure * avgSpeed;
// 多架编队:覆盖范围需加上编队展开宽度
int quantity = Math.Max(1, threat.Target.Quantity);
float formationSpread = 0f;
if (quantity > 1 && threat.Route != null)
{
var mode = (FormationMode)threat.Route.FormationMode;
if (mode != FormationMode.Single)
formationSpread = (quantity - 1) * (float)threat.Route.LateralSpacing;
}
float requiredCoverage = singleCoverage + formationSpread;
float requiredCoverage = singleCoverage;
return Math.Max(1,
(int)Math.Ceiling((requiredCoverage - 2f * effectiveR) / spacing) + 1);

View File

@ -95,15 +95,48 @@ namespace CounterDrone.Core.Tests
// ═══════════════════════════════════════
[Fact]
public void Ground_FireTime_VariesPerRound()
public void Ground_FireTime_VariesPerUnitPosition()
{
var result = Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(speed: 120));
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
Assert.True(events.Count >= 3);
// 地基弹道目标更近的弹飞行时间更短FireTime 更晚(更接近 recommendedTiming
float first = events[0].FireTime;
float last = events[^1].FireTime;
Assert.True(last > first, "远目标 FireTime 应 > 近目标");
// 两个单元不同位置 → 飞行时间不同 → 发射时间不同
var u0 = MakeGroundUnit("u0", 5000);
var u1 = MakeGroundUnit("u1", 8000);
var threat = MakeThreat(speed: 120);
var r0 = Plan(new() { u0 }, threat);
var r1 = Plan(new() { u1 }, threat);
float t0 = r0.Best.MergedSchedule[0].FireTime;
float t1 = r1.Best.MergedSchedule[0].FireTime;
Assert.NotEqual(t0, t1); // 位置不同 → 弹道不同 → 发射时刻不同
}
[Fact]
public void MultiUnit_Parallel_ShorterThanSingle()
{
var threat = MakeThreat(speed: 200);
threat.Waypoints.Clear();
threat.Waypoints.Add(new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 });
threat.Waypoints.Add(new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 });
// 每个单元只有 6 通道,单机需求 11 发 → 一个不够,必须两个并射
var unitSmall = new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
Position = new Vector3(5000, 0, 50), GunCount = 1, ChannelsPerGun = 6,
MuzzleVelocity = 800, TotalMunitions = 11, Cooldown = 5f,
AmmoTypes = new() { AerosolType.InertGas } };
var unitSmall2 = new FireUnit { Id = "u1", Type = PlatformType.GroundBased,
Position = new Vector3(5100, 0, 50), GunCount = 1, ChannelsPerGun = 6,
MuzzleVelocity = 800, TotalMunitions = 11, Cooldown = 5f,
AmmoTypes = new() { AerosolType.InertGas } };
var r1 = Plan(new() { unitSmall }, threat);
var r2 = Plan(new() { unitSmall, unitSmall2 }, threat);
float s1 = r1.Best.MergedSchedule.Max(e => e.FireTime) - r1.Best.MergedSchedule.Min(e => e.FireTime);
float s2 = r2.Best.MergedSchedule.Max(e => e.FireTime) - r2.Best.MergedSchedule.Min(e => e.FireTime);
// 2 单元并射应短于 1 单元
var msg = $"2单元={s2:F1}s({r2.Best.MergedSchedule.Count}发) 1单元={s1:F1}s({r1.Best.MergedSchedule.Count}发)";
Assert.True(s2 < s1, msg);
}
[Fact]
@ -146,13 +179,12 @@ namespace CounterDrone.Core.Tests
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
Assert.True(events.Count >= 3);
// 间隔应稳定 ≈ physicsInterval1.2s),不应因目标位置变化而波动
// physicsInterval = max(1, 40.38/33.33) = 1.21s
// 间隔应稳定 ≈ ChannelInterval1s不应因目标位置变化而波动
for (int i = 1; i < events.Count; i++)
{
float gap = events[i].FireTime - events[i - 1].FireTime;
Assert.True(Math.Abs(gap - 1.21f) < 0.2f,
$"gap[{i}]={gap:F3}, expected 1.21±0.2");
Assert.True(Math.Abs(gap - 1.0f) < 0.2f,
$"gap[{i}]={gap:F3}, expected 1.0±0.2");
}
}