CounterDroneBackend/src/CounterDrone.Core/Algorithms/DefensePlanner.cs
tian 92d1131dc1 fix: 统一选择远点命中 + FlightDuration 传递,喷气式通过
PlanUnitLane: ParabolicMotion.GetFlightTimes 选远点(离无人机近)
FireEvent: 新增 FlightDuration 字段
MunitionEntity: 接收 flightDuration → _useTimeArrival,按时间到达
地基: FlightDuration = interceptShellTime(保持 InterceptCalculator 一致性)
空基: FlightDuration = ParabolicMotion 选出的远点 tof

238 全部通过
2026-06-18 10:04:30 +08:00

659 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Linq;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>防御规划器 — 五步流水线,全部调用与引擎共享的物理工具类</summary>
public class DefensePlanner : IDefensePlanner
{
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, 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("弹药规格目录不能为空");
}
// ═══════════════════════════════════════════════
// 五步流水线
// ═══════════════════════════════════════════════
public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneWave> threats,
CombatScene environment, List<DetectionSource> detectionSources)
{
var result = new PlannerResult();
if (threats.Count == 0)
{
result.Best.Summary = "无威胁目标";
return result;
}
if (fireUnits.Count == 0)
{
result.Best.Summary = "无可用火力单元";
result.Best.ThreatsUnengaged = threats.Count;
return result;
}
// 探测信息:每个威胁的最早发现弧长 + 精度(基于统一信息网络)
// 无探测设备时 detectArc=0上帝视角从航路起点算、精度用配置默认值
float visibility = (float)environment.Visibility;
foreach (var t in threats)
{
var (detectArc, accuracy) = DetectionCalculator.EarliestDetection(
t.Waypoints, detectionSources, visibility);
t.DetectArc = detectArc;
t.DetectAccuracy = accuracy == float.MaxValue
? _config.DefaultDetectionAccuracy
: accuracy;
}
// Step 1: 威胁排序
foreach (var t in threats)
{
t.ArrivalTime = t.GetArrivalTime();
t.ThreatIndex = CalcThreatIndex(_config, t.Target);
}
var sorted = threats.OrderByDescending(t => t.Priority).ToList();
// Step 2-4: 贪心分配求解
// 预先检查:所有需要的弹药类型都在目录中
var neededTypes = sorted
.Select(t => MatchAmmo(_config, (PowerType)t.Target.PowerType))
.Distinct()
.ToList();
foreach (var t in neededTypes)
{
if (!_ammoCatalog.Any(a => a.AerosolType == (int)t))
throw new InvalidOperationException($"弹药规格目录中缺少类型: {t}");
}
result.Best = Solve(sorted, fireUnits, environment);
// Step 5: 临界方案
result.Critical = DeriveCritical(result.Best);
return result;
}
// ═══════════════════════════════════════════════
// Step 1: 威胁指数
// ═══════════════════════════════════════════════
private static float CalcThreatIndex(PlannerConfig config, TargetConfig target)
{
float typeCoef = config.TypeCoefficient.GetValueOrDefault((TargetType)target.TargetType, 1f);
float speedCoef = (float)target.TypicalSpeed / 60f;
return typeCoef * speedCoef;
}
// ═══════════════════════════════════════════════
// Step 2: 弹药匹配
// ═══════════════════════════════════════════════
private static AerosolType MatchAmmo(PlannerConfig config, PowerType power)
{
return config.AmmoMatch.GetValueOrDefault(power, AerosolType.InertGas);
}
// ═══════════════════════════════════════════════
// Step 3-4: 候选生成 → 贪心分配
// ═══════════════════════════════════════════════
private DefensePlan Solve(List<DroneWave> sortedThreats,
List<FireUnit> fireUnits, CombatScene env)
{
var plan = new DefensePlan();
var remainingMunitions = new Dictionary<string, int>();
foreach (var u in fireUnits)
remainingMunitions[u.Id] = u.TotalMunitions;
foreach (var threat in sortedThreats)
{
var available = fireUnits
.Where(u => remainingMunitions.GetValueOrDefault(u.Id, 0) > 0)
.ToList();
var candidates = GenerateCandidates(threat, available, env);
if (candidates.Count == 0)
{
plan.ThreatsUnengaged++;
continue;
}
// 计算总弹药需求
var neededAmmo = MatchAmmo(_config, (PowerType)threat.Target.PowerType);
var ammo = _ammoCatalog.First(a => a.AerosolType == (int)neededAmmo);
// 单机需求
var (effectiveRadius, expansionTime, turbulentRadius) = ComputeEffectiveRadius(ammo, env);
int singleNeeded = CalcRoundsNeeded(threat, ammo, env, false, turbulentRadius);
// 车道划分(可替换策略)
var (yLanes, laneSpacing) = _laneDivider.Divide(threat, turbulentRadius);
float formationWidth = yLanes > 1 ? (yLanes - 1) * laneSpacing : 0f;
int totalRoundsNeeded = singleNeeded * yLanes;
// 逐单元分配:每个单元锁定一个 Y 车道
// 云团间距 = 2R × (1 - 重叠比例),重叠由配置驱动,保证有效区互相覆盖
float spacing = 2f * turbulentRadius * (1f - _config.CloudOverlapRatio);
int[] laneNeeded = new int[yLanes];
float[] laneBaseTime = new float[yLanes];
bool[] laneBaseSet = new bool[yLanes];
for (int l = 0; l < yLanes; l++) laneNeeded[l] = singleNeeded;
int currentLane = 0;
int roundsCollected = 0;
var assignedUnits = new List<(FireUnit unit, int rounds, List<FireEvent> events)>();
foreach (var c in candidates.OrderBy(c => c.EarliestInterceptTime)
.ThenByDescending(c => c.KillProbability))
{
if (roundsCollected >= totalRoundsNeeded) break;
int remaining = remainingMunitions.GetValueOrDefault(c.Unit.Id, 0);
if (remaining <= 0) continue;
while (currentLane < yLanes && laneNeeded[currentLane] <= 0) currentLane++;
if (currentLane >= yLanes) break;
int toTake = Math.Min(Math.Min(c.Unit.TotalChannels, remaining), laneNeeded[currentLane]);
if (toTake <= 0) continue;
int yLane = currentLane;
// 车道第一个单元设基准时间,后续单元以此为准保证间距均匀
if (!laneBaseSet[yLane])
{
var (refEvt, refRej) = TryGenerateFireEvents(threat, c.Unit, c.AmmoType, ammo, env, 0, yLane, yLanes, formationWidth);
if (refEvt.Count == 0) { plan.RejectReason ??= refRej; continue; }
laneBaseTime[yLane] = refEvt[0].FireTime;
laneBaseSet[yLane] = true;
}
float stagger = Kinematics.CloudCoverInterval(turbulentRadius * 2f, GetDroneSpeedKph(threat), c.Unit.ChannelInterval);
var fevents = new List<FireEvent>();
int unitIdx = fireUnits.IndexOf(c.Unit);
int baseRoundInLane = singleNeeded - laneNeeded[currentLane];
for (int ch = 0; ch < toTake; ch++)
{
// offset 基于车道内发序(不是全局 eventIdx使每车道独立分布在同一航路段
// 多车道重叠覆盖而非沿航路连成长链
int roundInLane = baseRoundInLane + ch;
float offset = (roundInLane - (singleNeeded - 1) / 2f) * spacing;
var (fe, feRej) = TryGenerateFireEvents(threat, c.Unit, c.AmmoType, ammo, env, offset, yLane, yLanes, formationWidth);
if (fe.Count == 0) { plan.RejectReason ??= feRej; continue; }
foreach (var e in fe)
{
e.FireTime = laneBaseTime[yLane] + roundInLane * stagger;
// TargetX/Z 保留 GenerateFireEventsAt 算出的风偏补偿后抛撒点 cloudGenX/Z
e.PlatformIndex = unitIdx * c.Unit.TotalChannels + ch;
}
fevents.AddRange(fe);
}
assignedUnits.Add((c.Unit, toTake, fevents));
remainingMunitions[c.Unit.Id] -= toTake;
laneNeeded[currentLane] -= toTake;
roundsCollected += toTake;
}
if (roundsCollected <= 0) {
plan.RejectReason ??= $"候选{candidates.Count}个,弹药需求{totalRoundsNeeded}发实际收集0发";
plan.ThreatsUnengaged++; continue;
}
foreach (var (unit, rounds, fireEvents) in assignedUnits)
{
plan.Assignments.Add(new UnitAssignment
{
FireUnitId = unit.Id,
DroneWaveId = threat.WaveId,
AmmoType = neededAmmo,
RoundsFired = rounds,
FirstFireTime = fireEvents.Count > 0 ? fireEvents[0].FireTime : 0,
FireEvents = fireEvents,
});
plan.MergedSchedule.AddRange(fireEvents);
}
plan.ThreatsEngaged++;
}
plan.MergedSchedule.Sort((a, b) => a.FireTime.CompareTo(b.FireTime));
// 按威胁汇总概率
var threatProbs = new List<float>();
foreach (var threat in sortedThreats)
{
var a2 = _ammoCatalog.FirstOrDefault(s =>
s.AerosolType == (int)MatchAmmo(_config, (PowerType)threat.Target.PowerType));
int totalRounds = plan.Assignments
.Where(a => a.DroneWaveId == threat.WaveId)
.Sum(a => a.RoundsFired);
if (totalRounds > 0)
threatProbs.Add(ComputeInterceptProbability(threat, a2, totalRounds, env));
}
plan.OverallProbability = threatProbs.Count > 0 ? threatProbs.Average() : 0f;
// 失败原因 + 建议值
var reasons = new List<string>();
foreach (var threat in sortedThreats)
{
bool engaged = plan.Assignments.Any(a => a.DroneWaveId == threat.WaveId);
if (engaged) continue;
var ammo = MatchAmmo(_config, (PowerType)threat.Target.PowerType);
var matching = fireUnits.Where(u => u.AmmoTypes.Contains(ammo)).ToList();
if (matching.Count == 0)
reasons.Add($"威胁 {threat.WaveId}: 无火力单元装载 {ammo} 弹药");
else
{
var (_, expTime, _) = ComputeEffectiveRadius(_ammoCatalog.First(a => a.AerosolType == (int)ammo), env);
float avgSpd = GetDroneSpeedMs(threat);
float midArc = RouteGeometry.ArcLengthNearestTo(threat.Waypoints,
(float)(threat.Waypoints[0].PosX + threat.Waypoints[^1].PosX) / 2f,
(float)(threat.Waypoints[0].PosZ + threat.Waypoints[^1].PosZ) / 2f);
float travel = midArc - threat.DetectArc;
if (travel <= 0)
{
var (mx, _, mz) = RouteGeometry.PositionAt(threat.Waypoints, midArc);
float r = matching.Min(u => MathF.Sqrt((mx - u.Position.X) * (mx - u.Position.X) + (mz - u.Position.Z) * (mz - u.Position.Z)));
reasons.Add($"威胁 {threat.WaveId}: 探测边界在拦截点之后,建议探测范围≥{r:F0}m");
}
else if (travel / avgSpd <= expTime)
{
float maxDA = Math.Max(0, midArc - avgSpd * (expTime + _config.TimingSafetyMargin));
var (nx, _, nz) = RouteGeometry.PositionAt(threat.Waypoints, maxDA);
float r = matching.Min(u => MathF.Sqrt((nx - u.Position.X) * (nx - u.Position.X) + (nz - u.Position.Z) * (nz - u.Position.Z)));
reasons.Add($"威胁 {threat.WaveId}: 拦截窗口不足({travel / avgSpd:F1}s<膨胀{expTime:F1}s建议探测范围≥{r:F0}m最晚弧长{maxDA:F0}m");
}
else
{
string detail = plan.RejectReason != null ? $"{plan.RejectReason}" : "";
reasons.Add($"威胁 {threat.WaveId}: 候选存在但分配失败{detail}");
}
}
}
plan.Summary = plan.ThreatsEngaged > 0
? $"分配 {plan.ThreatsEngaged} 个威胁,{plan.ThreatsUnengaged} 个无方案"
+ (reasons.Count > 0 ? "。原因: " + string.Join("; ", reasons) : "")
: "无威胁被分配拦截方案"
+ (reasons.Count > 0 ? "。原因: " + string.Join("; ", reasons) : "");
return plan;
}
// ═══════════════════════════════════════════════
// 拦截窗口可行性检查
// ═══════════════════════════════════════════════
/// <summary>无人机速度km/h从航路第一个 waypoint 读取</summary>
private static float GetDroneSpeedKph(DroneWave threat)
{
float spd = (float)threat.Waypoints[0].Speed;
if (spd <= 0) throw new InvalidOperationException($"威胁 {threat.WaveId}: 航路点速度必须 > 0");
return spd;
}
/// <summary>无人机速度m/s</summary>
private static float GetDroneSpeedMs(DroneWave threat)
=> GetDroneSpeedKph(threat) / 3.6f;
internal static bool HasInterceptWindow(DroneWave threat, float midArc,
float expansionTime, float deliveryTime)
{
float travelArc = midArc - threat.DetectArc;
if (travelArc <= 0) return false;
float timeAvailable = travelArc / GetDroneSpeedMs(threat);
return timeAvailable > expansionTime + deliveryTime;
}
// ═══════════════════════════════════════════════
// 候选生成(使用真实物理)
// ═══════════════════════════════════════════════
private List<InterceptCandidate> GenerateCandidates(DroneWave threat,
List<FireUnit> availableUnits, CombatScene env)
{
var candidates = new List<InterceptCandidate>();
var neededAmmo = MatchAmmo(_config, (PowerType)threat.Target.PowerType);
var ammo = _ammoCatalog.First(a => a.AerosolType == (int)neededAmmo);
var (ammoEff, expansionTime, _) = ComputeEffectiveRadius(ammo, env);
var mid = ThreatMidpoint(threat);
float midArc = RouteGeometry.ArcLengthNearestTo(threat.Waypoints, mid.X, mid.Z);
foreach (var unit in availableUnits)
{
if (!unit.AmmoTypes.Contains(neededAmmo)) continue;
if (unit.Type == PlatformType.AirBased)
{
var c = BuildAirBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, expansionTime, mid, midArc, env);
if (c != null) candidates.Add(c);
}
else
{
var c = BuildGroundBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, expansionTime, mid, midArc, env);
if (c != null) candidates.Add(c);
}
}
return candidates;
}
private InterceptCandidate? BuildGroundBasedCandidate(DroneWave threat,
FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
float effectiveR, float expansionTime,
Vector3 mid, float midArc, CombatScene env)
{
float dx = mid.X - unit.Position.X;
float dz = mid.Z - unit.Position.Z;
float dist = (float)Math.Sqrt(dx * dx + dz * dz);
if (unit.MuzzleVelocity <= 0) return null;
float maxRange = unit.MuzzleVelocity * unit.MuzzleVelocity / 9.81f;
if (dist > maxRange) return null;
float shellTime = dist / unit.MuzzleVelocity;
if (!HasInterceptWindow(threat, midArc, expansionTime, shellTime)) return null;
float interceptTime = threat.ArrivalTime;
float avgSpeed = GetDroneSpeedMs(threat);
float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, ammoType);
float actualExposure = effectiveR * 2f / avgSpeed;
float prob = Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure);
return new InterceptCandidate
{
Unit = unit,
AmmoType = ammoType,
EarliestInterceptTime = interceptTime,
KillProbability = prob,
};
}
private InterceptCandidate? BuildAirBasedCandidate(DroneWave threat,
FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
float effectiveR, float expansionTime,
Vector3 mid, float midArc, CombatScene env)
{
if (unit.ReleaseAltitude <= 0 || unit.CruiseSpeed <= 0) return null;
float releaseAlt = unit.ReleaseAltitude;
float flightDist = Kinematics.Distance3D(
unit.Position.X, unit.Position.Y, unit.Position.Z,
mid.X, releaseAlt, mid.Z);
float flightTime = flightDist / unit.CruiseSpeed;
float fallTime = Kinematics.AirDropFallTime(releaseAlt, (float)threat.Target.TypicalAltitude);
float deliveryTime = flightTime + fallTime;
float interceptTime = threat.ArrivalTime;
if (deliveryTime > interceptTime) return null;
if (!HasInterceptWindow(threat, midArc, expansionTime, deliveryTime)) return null;
// 最小距离约束:无人机至少需要飞 vd*(R+expansion+fallTime) 才能被拦截
float speedMs = GetDroneSpeedMs(threat);
float minArc = threat.DetectArc + speedMs * (_config.ReactionTime + expansionTime + fallTime);
float totalArc = RouteGeometry.TotalLength(threat.Waypoints);
if (minArc >= totalArc) return null;
var (icArc, _, _) = InterceptCalculator.ComputeHorizontal(
threat.Waypoints, threat.DetectArc, GetDroneSpeedKph(threat),
_config.ReactionTime + expansionTime, unit.Position, unit.CruiseSpeed,
unit.ReleaseAltitude, (float)threat.Target.TypicalAltitude);
if (icArc <= 0) return null;
float avgSpeed = GetDroneSpeedMs(threat);
float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, ammoType);
float actualExposure = effectiveR * 2f / avgSpeed;
float prob = Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure);
return new InterceptCandidate
{
Unit = unit,
AmmoType = ammoType,
EarliestInterceptTime = interceptTime,
KillProbability = prob,
};
}
// ═══════════════════════════════════════════════
// 弹药计算(使用 AmmunitionSpec + 环境参数)
// ═══════════════════════════════════════════════
private (float effectiveRadius, float expansionTime, float rPhase2) ComputeEffectiveRadius(
AmmunitionSpec ammo, CombatScene env)
{
var model = new CloudExpansionModel(ammo, env);
float rPhase2 = model.RadiusAt(30f);
float expansionTime = model.TimeToReach(rPhase2);
// 云团被穿过时的真实年龄 = expansionTime云团生成后膨胀到有效半径的时长
// 这是云团自身的物理量,与无人机飞行时间无关。
float effectiveR = model.RadiusAt(expansionTime);
return (effectiveR, expansionTime, rPhase2);
}
private int CalcRoundsNeeded(DroneWave threat, AmmunitionSpec ammo,
CombatScene env, bool isAirBased, float turbulentRadius)
{
var cloudModel = new CloudExpansionModel(ammo, env);
float avgSpeed = GetDroneSpeedMs(threat);
float neededExposure = _damageModel.RequiredExposureSeconds(
(TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, (AerosolType)ammo.AerosolType);
float requiredCoverage = neededExposure * avgSpeed;
// 间距由配置的重叠比例驱动,公式在 CloudExpansionModel共享
float spacing = 2f * turbulentRadius * (1f - _config.CloudOverlapRatio);
return cloudModel.RoundsNeeded(requiredCoverage, spacing);
}
private float ComputeInterceptProbability(DroneWave threat,
AmmunitionSpec ammo, int rounds, CombatScene env)
{
float avgSpeed = GetDroneSpeedMs(threat);
var (effectiveR, _, _) = ComputeEffectiveRadius(ammo, env);
float spacing = 2f * effectiveR;
float actualCoverage = spacing * (rounds - 1) + 2f * effectiveR;
float actualExposure = actualCoverage / avgSpeed;
var aerosolType = (AerosolType)ammo.AerosolType;
float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, aerosolType);
return Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure);
}
// ═══════════════════════════════════════════════
// 发射事件生成(真实物理)
// ═══════════════════════════════════════════════
private (List<FireEvent> Events, string? RejectReason) TryGenerateFireEvents(
DroneWave threat, FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
CombatScene env, float targetOffset, int yLane, int yLanes, float formationWidth)
{
var events = new List<FireEvent>();
var wps = threat.Waypoints;
if (wps == null || wps.Count < 2) return (events, "航路无效");
var cloudModel = new CloudExpansionModel(ammo, env);
// 云龄 = expansionTime云团生成后膨胀到有效半径的时长
float effectiveAge = cloudModel.TimeToReach(cloudModel.TurbulentRadius) * _config.ExpansionFactor;
float effectiveR = cloudModel.RadiusAt(effectiveAge);
float expansionTime = cloudModel.TimeToReach(effectiveR);
float densityAtPassage = cloudModel.DensityAt(expansionTime);
if (densityAtPassage < (float)ammo.EffectiveConcentration)
return (events, $"云团密度{densityAtPassage:E2}<有效阈值{ammo.EffectiveConcentration:E2}");
float typicalSpeed = GetDroneSpeedKph(threat);
if (typicalSpeed <= 0) return (events, "目标速度无效");
// 用 InterceptCalculator 计算拦截弧长和发射角
var mid = ThreatMidpoint(threat);
float midArc = RouteGeometry.ArcLengthNearestTo(wps, mid.X, mid.Z);
float crossArc;
float launchAngle = 0;
float interceptShellTime = 0;
if (unit.Type == PlatformType.GroundBased)
{
var (ia, st, la) = InterceptCalculator.Compute(
wps, threat.DetectArc, typicalSpeed,
_config.ReactionTime + expansionTime, unit.Position, unit.MuzzleVelocity);
crossArc = (ia > 0 ? ia : midArc) + targetOffset;
launchAngle = la;
interceptShellTime = st;
}
else
{
var (ia, _, la) = InterceptCalculator.ComputeHorizontal(
wps, threat.DetectArc, typicalSpeed,
_config.ReactionTime + expansionTime, unit.Position, unit.CruiseSpeed,
unit.Position.Y, (float)threat.Target.TypicalAltitude);
crossArc = ia + targetOffset;
launchAngle = la;
}
// 编队横向偏移:按车道分布在航路法向上,与 DroneEntity 编队偏移一致(起点展开)。
// DroneEntity: latOffset = formationIndex * lateralSpacing0/50/100...
// 编队展开方向:读 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;
var (routeX, routeY, routeZ) = RouteGeometry.PositionAt(wps, crossArc);
float tx = routeX, ty = routeY, tz = routeZ;
if (axis == 0) tx += laneOffset;
else if (axis == 1) ty += laneOffset;
else tz += laneOffset;
// 风偏补偿:先算云团生成点,再按实际位置算发射角
var (wx, _, wz) = Kinematics.WindToVector((WindDirection)env.WindDirection, (float)env.WindSpeed);
float cloudGenX = tx - wx * expansionTime;
float cloudGenZ = tz - wz * expansionTime;
float dx = cloudGenX - unit.Position.X;
float dz = cloudGenZ - unit.Position.Z;
float targetDist = (float)Math.Sqrt(dx * dx + dz * dz);
float mv = unit.Type == PlatformType.AirBased ? unit.CruiseSpeed : unit.MuzzleVelocity;
if (mv <= 0)
throw new InvalidOperationException($"单元 {unit.Id}: 速度={mv} 必须>0");
float heightDiff = ty - unit.Position.Y;
if (unit.Type == PlatformType.AirBased && heightDiff >= 0)
return (events, $"空基单元 {unit.Id}: 平台高度({unit.Position.Y:F0})≤目标高度({ty:F0}),无法重力下落");
// 每发独立计算发射角,选离无人机更近的命中点(更早拦截)
launchAngle = Kinematics.CalculateLaunchAngle(targetDist, mv, heightDiff);
var motion = new ParabolicMotion(mv, launchAngle);
var times = motion.GetFlightTimes(heightDiff);
if (times.Length == 0)
return (events, $"目标超出弹道射程: dist={targetDist:F0}m");
// 两个解时:远点更靠近无人机(较早拦截),优先选远点
float tof = times.Length == 2 ? times[1] : times[0];
float range = mv * (float)Math.Cos(launchAngle) * tof;
// 无人机到达穿越点的时间:基于探测边界,而非航路起点。
// planner 是参谋,只能基于探测信息规划。威胁从探测边界被发现,
// 飞行时间 = (拦截弧长 - 探测弧长) / 速度。无探测时 DetectArc=0回退到起点
float travelArc = crossArc - threat.DetectArc;
if (travelArc < 0) return (events, $"探测边界在拦截点之后({threat.DetectArc:F0}m≥{crossArc:F0}m");
float txArrival = RouteGeometry.TravelTimeTo(wps, travelArc, typicalSpeed);
float recommendedTiming = txArrival - expansionTime;
if (recommendedTiming <= 0f) return (events, $"膨胀{expansionTime:F1}s≥到达{txArrival:F1}s");
// cloudGenX/Z 已在上面计算(含风偏),直接使用
float deliveryTime;
if (unit.Type == PlatformType.AirBased)
deliveryTime = tof;
else if (interceptShellTime <= 0)
return (events, "拦截计算未得出有效飞行时间");
else
{
deliveryTime = interceptShellTime;
tof = interceptShellTime;
}
float fireTime = recommendedTiming - deliveryTime;
if (fireTime <= 0f)
return (events, $"发射时机{fireTime:F1}s≤0到达{deliveryTime:F1}s>窗口{recommendedTiming:F1}s");
events.Add(new FireEvent
{
FireTime = fireTime,
PlatformIndex = 0,
TargetX = cloudGenX,
TargetY = ty,
TargetZ = cloudGenZ,
MuzzleVelocity = mv,
LaunchAngle = launchAngle,
FlightDuration = tof,
});
return (events, null);
}
// ═══════════════════════════════════════════════
// Step 5: 临界方案(概率阈值 50%
// ═══════════════════════════════════════════════
private DefensePlan DeriveCritical(DefensePlan best)
{
var critical = new DefensePlan
{
ThreatsEngaged = best.ThreatsEngaged,
ThreatsUnengaged = best.ThreatsUnengaged,
};
foreach (var assignment in best.Assignments)
{
int rounds = assignment.RoundsFired;
while (rounds > 1)
{
// 简化:弹药减半 → 概率减半
if ((float)rounds / assignment.RoundsFired < _config.CriticalProbabilityThreshold) break;
rounds--;
}
var reduced = new UnitAssignment
{
FireUnitId = assignment.FireUnitId,
DroneWaveId = assignment.DroneWaveId,
AmmoType = assignment.AmmoType,
RoundsFired = rounds,
FirstFireTime = assignment.FirstFireTime,
FireEvents = assignment.FireEvents.Take(rounds).ToList(),
};
critical.Assignments.Add(reduced);
critical.MergedSchedule.AddRange(reduced.FireEvents);
}
critical.MergedSchedule.Sort((a, b) => a.FireTime.CompareTo(b.FireTime));
critical.OverallProbability = _config.CriticalProbabilityThreshold;
critical.Summary = $"临界方案:刚好满足 50% 拦截概率";
return critical;
}
// ═══════════════════════════════════════════════
// 辅助
// ═══════════════════════════════════════════════
private static Vector3 ThreatMidpoint(DroneWave threat)
{
if (threat.Waypoints.Count < 2)
return new Vector3(0, (float)threat.Target.TypicalAltitude, 0);
var s = threat.Waypoints[0];
var e = threat.Waypoints[^1];
return new Vector3(
(float)(s.PosX + e.PosX) / 2f,
(float)(s.PosY + e.PosY) / 2f,
(float)(s.PosZ + e.PosZ) / 2f);
}
}
}