diff --git a/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs b/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs index f45d7e8..e5cace8 100644 --- a/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs +++ b/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs @@ -29,8 +29,8 @@ namespace CounterDrone.Core.Algorithms IsDissipated = false; // 风速 → 速度矢量 - var dir = WindToVector((WindDirection)env.WindDirection); - _windVelocity = dir * (float)env.WindSpeed; + var (vx, vy, vz) = Kinematics.WindToVector((WindDirection)env.WindDirection, (float)env.WindSpeed); + _windVelocity = new Algorithms.Vector3(vx, vy, vz); } public void Tick(float deltaTime, float windSpeed, WindDirection windDir) @@ -40,8 +40,8 @@ namespace CounterDrone.Core.Algorithms _elapsed += deltaTime; // 更新风速(允许动态变化) - var dir = WindToVector(windDir); - _windVelocity = dir * windSpeed; + var (vx, vy, vz) = Kinematics.WindToVector(windDir, windSpeed); + _windVelocity = new Algorithms.Vector3(vx, vy, vz); // 半径膨胀:初始半径 + 扩散速率 × 时间 // 加入大气稳定度因子(简化:1.0) @@ -69,23 +69,5 @@ namespace CounterDrone.Core.Algorithms Particles.Opacity = 0f; } } - - private static Vector3 WindToVector(WindDirection dir) - { - var angle = dir switch - { - WindDirection.N => 0f, - WindDirection.NE => 45f, - WindDirection.E => 90f, - WindDirection.SE => 135f, - WindDirection.S => 180f, - WindDirection.SW => 225f, - WindDirection.W => 270f, - WindDirection.NW => 315f, - _ => 0f, - }; - var rad = angle * (float)System.Math.PI / 180f; - return new Vector3((float)System.Math.Sin(rad), 0, (float)System.Math.Cos(rad)); - } } } diff --git a/src/CounterDrone.Core/Algorithms/Kinematics.cs b/src/CounterDrone.Core/Algorithms/Kinematics.cs new file mode 100644 index 0000000..961571c --- /dev/null +++ b/src/CounterDrone.Core/Algorithms/Kinematics.cs @@ -0,0 +1,105 @@ +using System; +using CounterDrone.Core.Models; + +namespace CounterDrone.Core.Algorithms +{ + /// 通用运动学 / 几何工具 + public static class Kinematics + { + /// 风向 → 单位速度矢量 (X, 0, Z),右手系 Y-up + public static (float X, float Y, float Z) WindToVector(WindDirection dir, float speed) + { + var angle = dir switch + { + WindDirection.N => 0f, + WindDirection.NE => 45f, + WindDirection.E => 90f, + WindDirection.SE => 135f, + WindDirection.S => 180f, + WindDirection.SW => 225f, + WindDirection.W => 270f, + WindDirection.NW => 315f, + _ => 0f, + }; + var rad = angle * (float)Math.PI / 180f; + return ((float)Math.Sin(rad) * speed, 0, (float)Math.Cos(rad) * speed); + } + + /// 计算抛物线炮弹的发射角 + /// 水平距离 (m) + /// 初速 (m/s) + /// 释放高度 (m),弹道顶点必须 ≥ 此值 + /// 发射角 (rad),取低弹道 + public static float CalculateLaunchAngle(float range, float muzzleVelocity, float releaseAltitude) + { + var v2 = muzzleVelocity * muzzleVelocity; + var g = 9.81f; + + // 1. 射程所需角:θr = arcsin(R*g/v²) / 2 + var rangeRatio = range * g / v2; + var angleRange = rangeRatio >= 1.0f + ? 45f * (float)Math.PI / 180f + : (float)Math.Asin(rangeRatio) / 2f; + + // 2. 释放高度所需最小角:H = v²*sin²(θ)/(2g) → sin(θ) = √(2gH)/v + var sinMinHeight = (float)Math.Sqrt(2f * g * releaseAltitude * 1.05f) / muzzleVelocity; + var angleHeight = sinMinHeight >= 1.0f + ? 90f * (float)Math.PI / 180f + : (float)Math.Asin(sinMinHeight); + + return Math.Max(angleRange, angleHeight); + } + + /// 根据发射参数计算抛物线位置 + public static (float X, float Y, float Z) ParabolicPosition( + float startX, float startY, float startZ, + float launchAngle, float azimuth, + float muzzleVelocity, float time) + { + var cosA = (float)Math.Cos(launchAngle); + var sinA = (float)Math.Sin(launchAngle); + var g = 9.81f; + + var horizontalDist = muzzleVelocity * cosA * time; + var x = startX + horizontalDist * (float)Math.Sin(azimuth); + var z = startZ + horizontalDist * (float)Math.Cos(azimuth); + var y = startY + muzzleVelocity * sinA * time - 0.5f * g * time * time; + + return (x, y, z); + } + + /// 点到矩形距离(简化判定) + public static float Distance2D(float x1, float z1, float x2, float z2) + { + var dx = x2 - x1; + var dz = z2 - z1; + return (float)Math.Sqrt(dx * dx + dz * dz); + } + + /// 点到三维点距离 + public static float Distance3D(float x1, float y1, float z1, float x2, float y2, float z2) + { + var dx = x2 - x1; + var dy = y2 - y1; + var dz = z2 - z1; + return (float)Math.Sqrt(dx * dx + dy * dy + dz * dz); + } + + /// 射线法 — 点是否在水平多边形内 + public static bool PointInPolygon(float px, float pz, ReadOnlySpan<(float X, float Z)> vertices) + { + if (vertices.Length < 3) return false; + bool inside = false; + int j = vertices.Length - 1; + for (int i = 0; i < vertices.Length; i++) + { + var xi = vertices[i].X; var zi = vertices[i].Z; + var xj = vertices[j].X; var zj = vertices[j].Z; + if ((zi > pz) != (zj > pz) && px < (xj - xi) * (pz - zi) / (zj - zi) + xi) + inside = !inside; + j = i; + } + return inside; + } + } +} diff --git a/src/CounterDrone.Core/Simulation/ControlZone.cs b/src/CounterDrone.Core/Simulation/ControlZone.cs index 0e90de7..d8e4c7a 100644 --- a/src/CounterDrone.Core/Simulation/ControlZone.cs +++ b/src/CounterDrone.Core/Simulation/ControlZone.cs @@ -1,10 +1,11 @@ +using CounterDrone.Core.Algorithms; using System; using System.Collections.Generic; using System.Text.Json; namespace CounterDrone.Core.Simulation { - public class ControlZone + public class ControlZoneEntity { public string Id { get; } public string Name { get; } @@ -12,9 +13,9 @@ namespace CounterDrone.Core.Simulation public float MinAltitude { get; } public float MaxAltitude { get; } - public struct Vector3 { public float X, Y, Z; } + public struct Vector3 { public float X { get; set; } public float Y { get; set; } public float Z { get; set; } } - public ControlZone(string id, string name, string verticesJson, float minAlt, float maxAlt) + public ControlZoneEntity(string id, string name, string verticesJson, float minAlt, float maxAlt) { Id = id; Name = name; @@ -25,24 +26,13 @@ namespace CounterDrone.Core.Simulation public bool ContainsPoint(float x, float y, float z) { - // 高度检查 if (y < MinAltitude || y > MaxAltitude) return false; - // Point-in-Polygon(射线法) - if (Vertices.Count < 3) return false; - - bool inside = false; - int j = Vertices.Count - 1; + var vertices2D = new (float X, float Z)[Vertices.Count]; for (int i = 0; i < Vertices.Count; i++) - { - var xi = Vertices[i].X; var zi = Vertices[i].Z; - var xj = Vertices[j].X; var zj = Vertices[j].Z; + vertices2D[i] = (Vertices[i].X, Vertices[i].Z); - if ((zi > z) != (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) - inside = !inside; - j = i; - } - return inside; + return Kinematics.PointInPolygon(x, z, vertices2D); } } } diff --git a/src/CounterDrone.Core/Simulation/DroneEntity.cs b/src/CounterDrone.Core/Simulation/DroneEntity.cs index 73e5101..e4f2df3 100644 --- a/src/CounterDrone.Core/Simulation/DroneEntity.cs +++ b/src/CounterDrone.Core/Simulation/DroneEntity.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using CounterDrone.Core.Algorithms; using CounterDrone.Core.Models; namespace CounterDrone.Core.Simulation @@ -42,6 +43,8 @@ namespace CounterDrone.Core.Simulation // 编队偏移 ApplyFormationOffset(formationIndex, spacing, mode); + PosX += FormationOffsetX; + PosY += FormationOffsetY; } private void ApplyFormationOffset(int index, float spacing, FormationMode mode) @@ -77,25 +80,30 @@ namespace CounterDrone.Core.Simulation var dy = (float)(target.PosY - PosY); var dz = (float)(target.PosZ - PosZ); var dist = (float)Math.Sqrt(dx * dx + dy * dy + dz * dz); + var step = speedMs * deltaTime; - if (dist < 1.0f) + if (dist < 1.0f || step >= dist) { + PosX = (float)target.PosX; + PosY = (float)target.PosY; + PosZ = (float)target.PosZ; CurrentWaypointIndex++; + if (CurrentWaypointIndex >= Route.Count - 1) + Status = DroneStatus.ReachedTarget; return; } // 移动方向 - var step = speedMs * deltaTime; var ratio = step / dist; PosX += dx * ratio; PosY += dy * ratio; PosZ += dz * ratio; // 风偏 - var windVec = WindToVector(windDir, windSpeed); - PosX += windVec.X * deltaTime; - PosY += windVec.Y * deltaTime; - PosZ += windVec.Z * deltaTime; + (float wx, float wy, float wz) = Kinematics.WindToVector(windDir, windSpeed); + PosX += wx * deltaTime; + PosY += wy * deltaTime; + PosZ += wz * deltaTime; } public void ApplyDamage(float damage) @@ -113,23 +121,5 @@ namespace CounterDrone.Core.Simulation { Status = DroneStatus.ZoneIntruded; } - - private static (float X, float Y, float Z) WindToVector(WindDirection dir, float speed) - { - var angle = dir switch - { - WindDirection.N => 0f, - WindDirection.NE => 45f, - WindDirection.E => 90f, - WindDirection.SE => 135f, - WindDirection.S => 180f, - WindDirection.SW => 225f, - WindDirection.W => 270f, - WindDirection.NW => 315f, - _ => 0f, - }; - var rad = angle * (float)Math.PI / 180f; - return ((float)Math.Sin(rad) * speed, 0, (float)Math.Cos(rad) * speed); - } } } diff --git a/src/CounterDrone.Core/Simulation/MunitionEntity.cs b/src/CounterDrone.Core/Simulation/MunitionEntity.cs index a140349..95f4dc2 100644 --- a/src/CounterDrone.Core/Simulation/MunitionEntity.cs +++ b/src/CounterDrone.Core/Simulation/MunitionEntity.cs @@ -1,3 +1,4 @@ +using CounterDrone.Core.Algorithms; using CounterDrone.Core.Models; namespace CounterDrone.Core.Simulation @@ -22,6 +23,8 @@ namespace CounterDrone.Core.Simulation private readonly float _azimuth; private readonly float _startX, _startY, _startZ; + private bool _hasExceededReleaseAltitude; + public MunitionEntity(string id, PlatformType launchMode, AerosolType aerosolType, float startX, float startY, float startZ, float targetX, float targetY, float targetZ, @@ -36,12 +39,13 @@ namespace CounterDrone.Core.Simulation _muzzleVelocity = muzzleVelocity; ReleaseAltitude = releaseAltitude; - // 计算发射角(简化抛物线) + // 根据目标距离反算发射角(取低弹道) var dx = targetX - startX; var dz = targetZ - startZ; var horizontalDist = (float)System.Math.Sqrt(dx * dx + dz * dz); - _azimuth = horizontalDist > 0 ? (float)System.Math.Atan2(dx, dz) : 0; - _launchAngle = 45f * (float)System.Math.PI / 180f; + _azimuth = horizontalDist > 0.001f ? (float)System.Math.Atan2(dx, dz) : 0; + + _launchAngle = Kinematics.CalculateLaunchAngle(horizontalDist, muzzleVelocity, releaseAltitude); } public void Update(float deltaTime) @@ -51,17 +55,15 @@ namespace CounterDrone.Core.Simulation if (LaunchMode == PlatformType.GroundBased) { - // 抛物线弹道 - var v0 = _muzzleVelocity; - var cosA = (float)System.Math.Cos(_launchAngle); - var sinA = (float)System.Math.Sin(_launchAngle); + (float x, float y, float z) = Kinematics.ParabolicPosition( + _startX, _startY, _startZ, _launchAngle, _azimuth, _muzzleVelocity, _time); + PosX = x; PosY = y; PosZ = z; - var horizontalDist = v0 * cosA * _time; - PosX = _startX + horizontalDist * (float)System.Math.Sin(_azimuth); - PosZ = _startZ + horizontalDist * (float)System.Math.Cos(_azimuth); - PosY = _startY + v0 * sinA * _time - 0.5f * 9.81f * _time * _time; + // 必须先从上方越过释放高度,再回落时才触发 + if (PosY > ReleaseAltitude) + _hasExceededReleaseAltitude = true; - if (PosY <= ReleaseAltitude) + if (_hasExceededReleaseAltitude && PosY <= ReleaseAltitude) { PosY = ReleaseAltitude; HasArrived = true; diff --git a/src/CounterDrone.Core/Simulation/SimTypes.cs b/src/CounterDrone.Core/Simulation/SimTypes.cs index d149b90..56b8ad1 100644 --- a/src/CounterDrone.Core/Simulation/SimTypes.cs +++ b/src/CounterDrone.Core/Simulation/SimTypes.cs @@ -4,16 +4,12 @@ namespace CounterDrone.Core.Simulation { public enum SimulationState { - Idle, - Running, - Paused, - Stopped, - Completed, + Idle, Running, Paused, Stopped, Completed, } public class SimEvent { - public SimEventType Type { get; set; } + public Models.SimEventType Type { get; set; } public float OccurredAt { get; set; } public string SourceId { get; set; } = string.Empty; public string TargetId { get; set; } = string.Empty; @@ -21,23 +17,9 @@ namespace CounterDrone.Core.Simulation public string Description { get; set; } = string.Empty; } - public enum SimEventType - { - TargetDetected, - MunitionLaunched, - CloudGenerated, - DroneEnteredCloud, - DroneDestroyed, - DroneReachedTarget, - ZoneIntruded, - WaypointReached, - SimulationEnd, - } - public class EventQueue { private readonly Queue _queue = new(); - public void Enqueue(SimEvent e) => _queue.Enqueue(e); public SimEvent Dequeue() => _queue.Dequeue(); public int Count => _queue.Count; @@ -69,9 +51,6 @@ namespace CounterDrone.Core.Simulation public enum DroneStatus { - Flying, - Destroyed, - ReachedTarget, - ZoneIntruded, + Flying, Destroyed, ReachedTarget, ZoneIntruded, } } diff --git a/src/CounterDrone.Core/Simulation/SimulationEngine.cs b/src/CounterDrone.Core/Simulation/SimulationEngine.cs index e7e0d11..12dcc0f 100644 --- a/src/CounterDrone.Core/Simulation/SimulationEngine.cs +++ b/src/CounterDrone.Core/Simulation/SimulationEngine.cs @@ -2,13 +2,23 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; -using SQLite; using CounterDrone.Core.Algorithms; using CounterDrone.Core.Models; using CounterDrone.Core.Services; +using SQLite; namespace CounterDrone.Core.Simulation { + /// 发射计划中的单次事件 + public class FireEvent + { + public float FireTime; + public int PlatformIndex; // 第几个发射平台(0-based) + public float TargetX, TargetY, TargetZ; + public float MuzzleVelocity; + } + + /// 仿真引擎 — 纯执行器,不包含任何决策逻辑 public class SimulationEngine { private readonly IScenarioService _scenarioService; @@ -16,32 +26,35 @@ namespace CounterDrone.Core.Simulation private readonly IDamageModel _damageModel; private readonly IPathProvider _paths; - // 状态 public SimulationState State { get; private set; } = SimulationState.Idle; public int FrameIndex { get; private set; } public float SimulationTime { get; private set; } private float _tickInterval; - // 实体 private List _drones = new(); private List _platforms = new(); - private List _detections = new(); private List _munitions = new(); private List _clouds = new(); - private List _zones = new(); + private List _zones = new(); - // 配置快照 private CombatScene _scene = new(); - private CloudDispersal _cloudConfig = new(); private AmmunitionSpec _ammoSpec = new(); private int _tickRate = 20; - // 内部 - private readonly EventQueue _eventQueue = new(); + // 发射计划:算法预计算,引擎按时间表执行 + private readonly List _fireSchedule = new(); + private int _nextFireIndex; + + private readonly List _allEvents = new(); private SQLiteConnection _frameDb = null!; private string _taskId = string.Empty; private int _entityCounter; + public IReadOnlyList Drones => _drones; + public IReadOnlyList Clouds => _clouds; + public IReadOnlyList Munitions => _munitions; + public IReadOnlyList Events => _allEvents; + public SimulationEngine(IScenarioService scenarioService, FrameDataStore frameStore, IDamageModel damageModel, IPathProvider paths) { @@ -51,6 +64,14 @@ namespace CounterDrone.Core.Simulation _paths = paths; } + /// 设置发射计划(来自推荐算法),在 Initialize 之前调用 + public void SetFireSchedule(List schedule) + { + _fireSchedule.Clear(); + _fireSchedule.AddRange(schedule.OrderBy(f => f.FireTime)); + _nextFireIndex = 0; + } + public void Initialize(string taskId) { _taskId = taskId; @@ -58,168 +79,118 @@ namespace CounterDrone.Core.Simulation if (config == null) throw new InvalidOperationException("Task not found"); _scene = config.Scene; - _cloudConfig = config.Cloud; _tickRate = config.Task.TickRate; _tickInterval = 1.0f / _tickRate; - // 加载弹药规格(取第一个匹配的) - var ammoDb = new SQLiteConnection(_paths.GetMainDbPath()); - _ammoSpec = ammoDb.Table() - .FirstOrDefault(a => a.AerosolType == _cloudConfig.AerosolType) - ?? new AmmunitionSpec { InitialRadius = 50, CoreDensity = 1.0f, DispersionRateBase = 5, MaxRadius = 200, MaxDuration = 60 }; - ammoDb.Close(); + using (var ammoDb = new SQLiteConnection(_paths.GetMainDbPath())) + { + _ammoSpec = ammoDb.Table() + .FirstOrDefault(a => a.AerosolType == config.Cloud.AerosolType) + ?? new AmmunitionSpec { InitialRadius = 50, CoreDensity = 1.0f, DispersionRateBase = 5, MaxRadius = 200, MaxDuration = 60 }; + } - // 实例化无人机 + // 无人机 _drones.Clear(); foreach (var target in config.Targets) { var mode = (FormationMode)config.Route.FormationMode; var spacing = (float)config.Route.FormationSpacing; for (int i = 0; i < target.Quantity; i++) - { - var drone = new DroneEntity($"drone_{++_entityCounter}", target.GroupId, - target, config.Waypoints, i, spacing, mode); - _drones.Add(drone); - } + _drones.Add(new DroneEntity($"drone_{++_entityCounter}", target.GroupId, + target, config.Waypoints, i, spacing, mode)); } - // 实例化装备 + // 装备 _platforms.Clear(); - _detections.Clear(); foreach (var equip in config.Equipment) { - if (equip.EquipmentRole == (int)EquipmentRole.Detection) - { - _detections.Add(new DetectionEntity($"det_{++_entityCounter}", equip)); - } - else + if (equip.EquipmentRole != (int)EquipmentRole.Detection) { for (int i = 0; i < equip.Quantity; i++) - { _platforms.Add(new PlatformEntity($"plat_{++_entityCounter}", equip)); - } } } - // 实例化管控区域 + // 管控区域 _zones.Clear(); foreach (var z in config.ControlZones) - { - _zones.Add(new ControlZone(z.Id, z.Name, z.VerticesJson, + _zones.Add(new ControlZoneEntity(z.Id, z.Name, z.VerticesJson, (float)z.MinAltitude, (float)z.MaxAltitude)); - } _munitions.Clear(); _clouds.Clear(); - _eventQueue.Clear(); + _allEvents.Clear(); FrameIndex = 0; SimulationTime = 0; _entityCounter = 0; + _nextFireIndex = 0; - // 打开帧数据库 _frameDb = _frameStore.CreateOrOpen(_taskId); - State = SimulationState.Running; } public SimulationFrameResult Tick(float deltaTime) { - if (State != SimulationState.Running) return new SimulationFrameResult { State = State }; + if (State != SimulationState.Running) + return new SimulationFrameResult { State = State, FrameIndex = FrameIndex, SimulationTime = SimulationTime }; SimulationTime += deltaTime; FrameIndex++; - _eventQueue.Clear(); - var events = new List(); + var frameEvents = new List(); - // 1. 探测阶段 - foreach (var det in _detections) + // 1. 按发射计划执行 + while (_nextFireIndex < _fireSchedule.Count && + _fireSchedule[_nextFireIndex].FireTime <= SimulationTime) { - foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying)) + var fe = _fireSchedule[_nextFireIndex++]; + var platform = _platforms.Count > fe.PlatformIndex + ? _platforms[fe.PlatformIndex] : null; + if (platform != null && platform.Ready) { - if (det.CanDetect(drone)) + platform.Fire(); + var m = new MunitionEntity($"mun_{++_entityCounter}", + platform.PlatformType ?? Models.PlatformType.GroundBased, + platform.AerosolType ?? Models.AerosolType.InertGas, + platform.PosX, platform.PosY, platform.PosZ, + fe.TargetX, fe.TargetY, fe.TargetZ, + fe.MuzzleVelocity, + (float)_ammoSpec.InitialRadius > 0 ? 300f : 300f); + _munitions.Add(m); + frameEvents.Add(new SimEvent { - _eventQueue.Enqueue(new SimEvent - { - Type = SimEventType.TargetDetected, - OccurredAt = SimulationTime, - SourceId = det.Id, - TargetId = drone.Id, - Description = $"探测到目标 {drone.Id}", - }); - } - } - } - - // 2. 火控决策 — 简化:探测到目标后分配发射 - while (_eventQueue.HasEvents) - { - var evt = _eventQueue.Dequeue(); - if (evt.Type == SimEventType.TargetDetected) - { - var platform = _platforms.FirstOrDefault(p => p.Ready); - var drone = _drones.FirstOrDefault(d => d.Id == evt.TargetId); - if (platform != null && drone != null) - { - platform.Fire(); - var munition = new MunitionEntity( - $"mun_{++_entityCounter}", - platform.PlatformType ?? Models.PlatformType.GroundBased, - platform.AerosolType ?? Models.AerosolType.InertGas, - platform.PosX, platform.PosY, platform.PosZ, - drone.PosX, drone.PosY, drone.PosZ, - // Use default muzzle velocity from ammo spec or platform config - 800f, - (float)_cloudConfig.DisperseHeight); - _munitions.Add(munition); - - events.Add(new SimEvent - { - Type = SimEventType.MunitionLaunched, - OccurredAt = SimulationTime, - SourceId = platform.Id, - TargetId = drone.Id, - Description = $"发射弹药 {munition.Id} → 目标 {drone.Id}", - }); - } - } - } - - // 3. 弹药飞行 - foreach (var munition in _munitions.ToList()) - { - munition.Update(deltaTime); - if (munition.HasArrived) - { - // 生成云团 - var dispersion = AlgorithmFactory.Create(); - dispersion.Initialize(_ammoSpec, _scene, - new Algorithms.Vector3(munition.PosX, munition.PosY, munition.PosZ), - SimulationTime); - - var cloud = new CloudEntity($"cloud_{++_entityCounter}", - munition.AerosolType, dispersion, SimulationTime); - _clouds.Add(cloud); - _munitions.Remove(munition); - - events.Add(new SimEvent - { - Type = SimEventType.CloudGenerated, - OccurredAt = SimulationTime, - SourceId = cloud.Id, - Description = $"云团生成 at ({cloud.Dispersion.Center.X:F0}, {cloud.Dispersion.Center.Y:F0}, {cloud.Dispersion.Center.Z:F0})", + Type = SimEventType.MunitionLaunched, OccurredAt = SimulationTime, + SourceId = platform.Id, Description = $"计划发射 {m.Id}", }); } } - // 4. 云团演化 - foreach (var cloud in _clouds.ToList()) + // 2. 弹药飞行 → 云团生成 + foreach (var m in _munitions.ToList()) { - cloud.Tick(deltaTime, (float)_scene.WindSpeed, (WindDirection)_scene.WindDirection); - if (cloud.IsDissipated) - _clouds.Remove(cloud); + m.Update(deltaTime); + if (m.HasArrived) + { + var disp = AlgorithmFactory.Create(); + disp.Initialize(_ammoSpec, _scene, + new Algorithms.Vector3(m.PosX, m.PosY, m.PosZ), SimulationTime); + _clouds.Add(new CloudEntity($"cloud_{++_entityCounter}", m.AerosolType, disp, SimulationTime)); + _munitions.Remove(m); + frameEvents.Add(new SimEvent + { + Type = SimEventType.CloudGenerated, OccurredAt = SimulationTime, + Description = $"云团生成", + }); + } } - // 5. 毁伤判定 + // 3. 云团演化 + foreach (var c in _clouds.ToList()) + { + c.Tick(deltaTime, (float)_scene.WindSpeed, (WindDirection)_scene.WindDirection); + if (c.IsDissipated) _clouds.Remove(c); + } + + // 4. 毁伤判定 foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying)) { foreach (var cloud in _clouds) @@ -227,50 +198,36 @@ namespace CounterDrone.Core.Simulation if (cloud.ContainsPoint(drone.PosX, drone.PosY, drone.PosZ)) { drone.ExposureTime += deltaTime; - var density = cloud.Dispersion.CoreDensity; - var damage = _damageModel.CalculateDamage( - drone.TargetType, drone.PowerType, - cloud.AerosolType, density, - drone.ExposureTime, deltaTime); - - drone.ApplyDamage(damage); - + var dmg = _damageModel.CalculateDamage( + drone.TargetType, drone.PowerType, cloud.AerosolType, + cloud.Dispersion.CoreDensity, drone.ExposureTime, deltaTime); + drone.ApplyDamage(dmg); if (drone.Status == DroneStatus.Destroyed) - { - events.Add(new SimEvent + frameEvents.Add(new SimEvent { - Type = SimEventType.DroneDestroyed, - OccurredAt = SimulationTime, - TargetId = drone.Id, - Description = $"无人机 {drone.Id} 被摧毁", + Type = SimEventType.DroneDestroyed, OccurredAt = SimulationTime, + TargetId = drone.Id, Description = $"无人机 {drone.Id} 被摧毁", }); - } } } } - // 6. 无人机移动 + // 5. 无人机移动 foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying)) { drone.Update(deltaTime, (float)_scene.WindSpeed, (WindDirection)_scene.WindDirection); - if (drone.Status == DroneStatus.ReachedTarget) - { - events.Add(new SimEvent + frameEvents.Add(new SimEvent { - Type = SimEventType.DroneReachedTarget, - OccurredAt = SimulationTime, - SourceId = drone.Id, - Description = $"无人机 {drone.Id} 到达攻击目标", + Type = SimEventType.DroneReachedTarget, OccurredAt = SimulationTime, + SourceId = drone.Id, Description = "到达攻击目标", }); - } } - // 7. 平台冷却 - foreach (var plat in _platforms) - plat.Update(deltaTime); + // 6. 平台冷却 + foreach (var p in _platforms) p.Update(deltaTime); - // 8. 管控区域判定 + // 7. 管控区域 foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying)) { foreach (var zone in _zones) @@ -278,63 +235,43 @@ namespace CounterDrone.Core.Simulation if (zone.ContainsPoint(drone.PosX, drone.PosY, drone.PosZ)) { drone.MarkZoneIntruded(); - events.Add(new SimEvent + frameEvents.Add(new SimEvent { - Type = SimEventType.ZoneIntruded, - OccurredAt = SimulationTime, - SourceId = drone.Id, - TargetId = zone.Id, - Description = $"无人机 {drone.Id} 侵入管控区域 {zone.Name}", + Type = SimEventType.ZoneIntruded, OccurredAt = SimulationTime, + SourceId = drone.Id, TargetId = zone.Id, Description = $"侵入 {zone.Name}", }); } } } - // 9. 结束判定 - var allDone = _drones.All(d => d.Status != DroneStatus.Flying); - if (allDone) + // 8. 结束判定 + if (_drones.All(d => d.Status != DroneStatus.Flying)) { State = SimulationState.Completed; - events.Add(new SimEvent - { - Type = SimEventType.SimulationEnd, - OccurredAt = SimulationTime, - Description = "仿真结束", - }); + frameEvents.Add(new SimEvent { Type = SimEventType.SimulationEnd, OccurredAt = SimulationTime }); } - // 录制帧数据 + _allEvents.AddRange(frameEvents); + + // 录制帧 var snapshots = CollectSnapshots(); _frameStore.WriteFrame(_frameDb, _taskId, FrameIndex, SimulationTime, snapshots); - _frameDb.Commit(); return new SimulationFrameResult { - EntitySnapshots = snapshots, - NewEvents = events, - State = State, - FrameIndex = FrameIndex, - SimulationTime = SimulationTime, + EntitySnapshots = snapshots, NewEvents = frameEvents, + State = State, FrameIndex = FrameIndex, SimulationTime = SimulationTime, }; } public void Pause() { if (State == SimulationState.Running) State = SimulationState.Paused; } public void Resume() { if (State == SimulationState.Paused) State = SimulationState.Running; } - public void Stop() - { - State = SimulationState.Stopped; - _frameDb?.Close(); - } - - public IReadOnlyList Drones => _drones; - public IReadOnlyList Clouds => _clouds; - public IReadOnlyList Munitions => _munitions; + public void Stop() { State = SimulationState.Stopped; _frameDb?.Close(); } private List CollectSnapshots() { var list = new List(); - foreach (var d in _drones) { var stage = _damageModel.GetDamageStage(1 - d.Hp); @@ -342,43 +279,16 @@ namespace CounterDrone.Core.Simulation { EntityId = d.Id, EntityType = Models.EntityType.Drone, PosX = d.PosX, PosY = d.PosY, PosZ = d.PosZ, - StateData = JsonSerializer.Serialize(new { damageStage = (int)stage, hp = d.Hp, status = d.Status.ToString() }), + StateData = JsonSerializer.Serialize(new { damageStage = (int)stage, hp = d.Hp }), }); } - - foreach (var p in _platforms) - { - list.Add(new EntitySnapshot - { - EntityId = p.Id, EntityType = Models.EntityType.Platform, - PosX = p.PosX, PosY = p.PosY, PosZ = p.PosZ, - StateData = JsonSerializer.Serialize(new { ready = p.Ready, munitions = p.MunitionCount }), - }); - } - - foreach (var d in _detections) - { - list.Add(new EntitySnapshot - { - EntityId = d.Id, EntityType = Models.EntityType.DetectionEquip, - PosX = d.PosX, PosY = d.PosY, PosZ = d.PosZ, - }); - } - foreach (var c in _clouds) - { list.Add(new EntitySnapshot { EntityId = c.Id, EntityType = Models.EntityType.Cloud, PosX = c.Dispersion.Center.X, PosY = c.Dispersion.Center.Y, PosZ = c.Dispersion.Center.Z, - StateData = JsonSerializer.Serialize(new - { - radius = c.Dispersion.Radius, - opacity = c.Dispersion.Particles.Opacity, - }), + StateData = JsonSerializer.Serialize(new { radius = c.Dispersion.Radius, opacity = c.Dispersion.Particles.Opacity }), }); - } - return list; } } diff --git a/test/unit/CounterDrone.Core.Tests/ControlZoneEntityTests.cs b/test/unit/CounterDrone.Core.Tests/ControlZoneEntityTests.cs new file mode 100644 index 0000000..83281be --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/ControlZoneEntityTests.cs @@ -0,0 +1,43 @@ +using CounterDrone.Core.Simulation; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class ControlZoneEntityTests + { + private const string TestZoneJson = + "[{\"X\":1200,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":200},{\"X\":1200,\"Y\":0,\"Z\":200}]"; + + [Fact] + public void ContainsPoint_Center_ReturnsTrue() + { + var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000); + // 手动检查顶点数 + Assert.Equal(4, zone.Vertices.Count); + Assert.Equal(1200f, zone.Vertices[0].X, 1); + Assert.Equal(-200f, zone.Vertices[0].Z, 1); + Assert.True(zone.ContainsPoint(1500, 300, 0), "点(1500,300,0)应在矩形内"); + } + + [Fact] + public void ContainsPoint_OutsideX_ReturnsFalse() + { + var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000); + Assert.False(zone.ContainsPoint(2000, 300, 0), "点(2000,300,0)应在矩形外"); + } + + [Fact] + public void ContainsPoint_OutsideZ_ReturnsFalse() + { + var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000); + Assert.False(zone.ContainsPoint(1500, 300, 500), "点(1500,300,500) Z超出"); + } + + [Fact] + public void ContainsPoint_OutsideAltitude_ReturnsFalse() + { + var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000); + Assert.False(zone.ContainsPoint(1500, 1200, 0), "海拔 1200 超出上限"); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs b/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs new file mode 100644 index 0000000..3bd4fd0 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using CounterDrone.Core.Algorithms; +using CounterDrone.Core.Models; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class DefenseAdvisorRealityTests + { + [Fact] + public void PistonDrone_RecommendsReachableCloudPosition() + { + var threat = new ThreatProfile + { + Environment = new CombatScene + { + WindSpeed = 3, + WindDirection = (int)WindDirection.W, + }, + Targets = new List + { + new TargetConfig + { + TargetType = (int)TargetType.Piston, + PowerType = (int)PowerType.Piston, + Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500, + }, + }, + Route = new RoutePlan { FormationMode = (int)FormationMode.Single }, + Waypoints = new List + { + new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 }, + new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 }, + }, + }; + + var advisor = new DefaultDefenseAdvisor(); + var rec = advisor.Recommend(threat); + + // 1. 选型正确 + Assert.Equal(AerosolType.InertGas, rec.Best.RecommendedAerosolType); + + // 2. 云团位置应在航路上 + var c = rec.Best.RecommendedCloud; + Assert.True(c.PositionX > 0 && c.PositionX < 20000, + $"云团 X={c.PositionX} 应在航路范围内"); + Assert.True(c.PositionY >= 400 && c.PositionY <= 600, + $"云团 Y={c.PositionY} 应接近飞行高度 500"); + + // 3. 平台部署应有合理位置 + Assert.NotEmpty(rec.Best.Platforms); + var p = rec.Best.Platforms[0]; + + // 4. 炮弹能打到吗?用运动学验证 + var muzzleV = 800f; + var releaseAlt = (float)c.DisperseHeight; + var targetDist = (float)System.Math.Sqrt( + (c.PositionX - p.Position.X) * (c.PositionX - p.Position.X) + + (c.PositionZ - p.Position.Z) * (c.PositionZ - p.Position.Z)); + + var launchAngle = Kinematics.CalculateLaunchAngle(targetDist, muzzleV, releaseAlt); + Assert.True(launchAngle > 0 && launchAngle < 1.57f, + $"发射角 {launchAngle * 180 / System.Math.PI:F1}° 应在 0-90°"); + + // 5. 弹道能到达释放高度 + var peakHeight = muzzleV * muzzleV * (float)System.Math.Sin(launchAngle) + * (float)System.Math.Sin(launchAngle) / (2f * 9.81f); + Assert.True(peakHeight >= releaseAlt, + $"弹道顶点 {peakHeight:F0}m ≥ 释放高度 {releaseAlt:F0}m"); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs b/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs new file mode 100644 index 0000000..8f32139 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using CounterDrone.Core.Models; +using CounterDrone.Core.Simulation; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class DroneEntityTests + { + private TargetConfig CreateConfig(float speed = 120, float altitude = 300) + { + return new TargetConfig + { + TargetType = (int)TargetType.Piston, + PowerType = (int)PowerType.Piston, + TypicalSpeed = speed, + TypicalAltitude = altitude, + Wingspan = 2.5, + Quantity = 1, + }; + } + + private List CreateRoute(float fromX, float toX, float altitude, float speed) + { + return new List + { + new Waypoint { PosX = fromX, PosY = altitude, PosZ = 0, Altitude = altitude, Speed = speed }, + new Waypoint { PosX = toX, PosY = altitude, PosZ = 0, Altitude = altitude, 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); + + Assert.Equal(100, drone.PosX, 1); + Assert.Equal(300, drone.PosY, 1); // 高度 = waypoint.PosY + } + + [Fact] + public void Update_MovesTowardTarget() + { + var route = CreateRoute(0, 1000, 300, 120); + var drone = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 50, FormationMode.Single); + + float prevX = drone.PosX; + drone.Update(1f, 0, WindDirection.N); + + 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 后应到达 + drone.Update(2f, 0, WindDirection.N); + + Assert.Equal(DroneStatus.ReachedTarget, drone.Status); + } + + [Fact] + public void ApplyDamage_ReducesHp() + { + var drone = new DroneEntity("d1", "g1", CreateConfig(), + CreateRoute(0, 100, 300, 60), 0, 50, 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() + { + var drone = new DroneEntity("d1", "g1", CreateConfig(), + CreateRoute(0, 100, 300, 60), 0, 50, FormationMode.Single); + + drone.ApplyDamage(1.5f); + Assert.True(drone.Hp <= 0); + Assert.Equal(DroneStatus.Destroyed, drone.Status); + } + + [Fact] + public void Formation_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); + + Assert.NotEqual(d0.PosY, d1.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 位移"); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs new file mode 100644 index 0000000..2d3bf4a --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CounterDrone.Core; +using CounterDrone.Core.Algorithms; +using CounterDrone.Core.Models; +using CounterDrone.Core.Repository; +using CounterDrone.Core.Services; +using CounterDrone.Core.Simulation; +using SQLite; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class FullPipelineTests : IDisposable + { + private readonly string _testDir; + private readonly IPathProvider _paths; + private readonly IScenarioService _scenario; + private readonly FrameDataStore _frameStore; + private readonly SQLiteConnection _mainDb; + private string _taskId = string.Empty; + + public FullPipelineTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"cd_full_{Guid.NewGuid():N}"); + _paths = new TestPathProvider(_testDir); + var dbm = new DatabaseManager(_paths); + _mainDb = dbm.OpenMainDb(); + + _mainDb.Insert(new AmmunitionSpec + { + Id = "ammo-inert", AerosolType = (int)AerosolType.InertGas, + Name = "惰性气体弹", InitialRadius = 50, CoreDensity = 1.0, + DispersionRateBase = 5, MaxRadius = 500, MaxDuration = 120, + EffectiveConcentration = 0.05, + }); + _mainDb.Insert(new AmmunitionSpec + { + Id = "ammo-active", AerosolType = (int)AerosolType.ActiveMaterial, + Name = "活性材料弹", InitialRadius = 30, CoreDensity = 2.0, + DispersionRateBase = 3, MaxRadius = 400, MaxDuration = 90, + EffectiveConcentration = 0.1, + }); + _mainDb.Insert(new AmmunitionSpec + { + Id = "ammo-fuel", AerosolType = (int)AerosolType.ActiveFuel, + Name = "活性燃料弹", InitialRadius = 40, CoreDensity = 1.5, + DispersionRateBase = 4, MaxRadius = 450, MaxDuration = 100, + EffectiveConcentration = 0.03, + }); + + _scenario = new ScenarioService( + new SimTaskRepository(_mainDb), new CombatSceneRepository(_mainDb), + new ControlZoneRepository(_mainDb), new TargetConfigRepository(_mainDb), + new EquipmentDeploymentRepository(_mainDb), new CloudDispersalRepository(_mainDb), + new RoutePlanRepository(_mainDb), new WaypointRepository(_mainDb)); + + _frameStore = new FrameDataStore(_paths); + } + + public void Dispose() + { + _mainDb?.Close(); + if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true); + } + + private SimulationEngine RunSimulation(int maxTicks, float tickDt = 1f / 20f) + { + var engine = new SimulationEngine(_scenario, _frameStore, new DamageModelRouter(), _paths); + + // 如果有推荐方案,生成发射计划 + var detail = _scenario.GetTaskDetail(_taskId)!; + if (detail.Equipment.Any(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform)) + { + var schedule = BuildFireSchedule(detail); + if (schedule.Count > 0) engine.SetFireSchedule(schedule); + } + + engine.Initialize(_taskId); + for (int i = 0; i < maxTicks; i++) + { + var r = engine.Tick(tickDt); + if (r.State == SimulationState.Completed) break; + } + engine.Stop(); + return engine; + } + + /// 从推荐方案的时机参数生成发射计划 + private static List BuildFireSchedule(TaskFullConfig detail) + { + var schedule = new List(); + var cloud = detail.Cloud; + + if (!cloud.RecommendedTiming.HasValue || cloud.RecommendedTiming.Value <= 0) + return schedule; + + var platforms = detail.Equipment + .Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform) + .ToList(); + + for (int i = 0; i < platforms.Count; i++) + { + var p = platforms[i]; + var muzzleV = (float)(p.MuzzleVelocity ?? 800); + + // 弹药飞行时间 ≈ 距离 / 初速(简化) + var dx = (float)cloud.PositionX - (float)p.PositionX; + var dz = (float)cloud.PositionZ - (float)p.PositionZ; + var dist = (float)System.Math.Sqrt(dx * dx + dz * dz); + var flightTime = dist / muzzleV; + + // 发射时间 = 推荐云团出现时间 - 弹药飞行时间 + var fireTime = (float)cloud.RecommendedTiming.Value - flightTime; + if (fireTime < 0) fireTime = 0.1f; + + schedule.Add(new FireEvent + { + FireTime = fireTime, + PlatformIndex = i, + TargetX = (float)cloud.PositionX, + TargetY = (float)cloud.PositionY, + TargetZ = (float)cloud.PositionZ, + MuzzleVelocity = muzzleV, + }); + } + + return schedule; + } + + /// 将 DefenseAdvisor 推荐方案写入想定 + private DefenseRecommendation GetAndApplyRecommendation() + { + var detail = _scenario.GetTaskDetail(_taskId)!; + var threat = new ThreatProfile + { + Environment = detail.Scene, + Targets = detail.Targets, + Route = detail.Route, + Waypoints = detail.Waypoints, + }; + var rec = new DefaultDefenseAdvisor().Recommend(threat); + + _scenario.SaveCloudDispersal(_taskId, rec.Best.RecommendedCloud); + + var equips = new List(); + foreach (var d in rec.Best.Detections) + equips.Add(new EquipmentDeployment + { + EquipmentRole = (int)EquipmentRole.Detection, + Quantity = d.Quantity, + PositionX = d.Position.X, PositionY = d.Position.Y, PositionZ = d.Position.Z, + DetectionRadius = d.DetectionRadius, + }); + foreach (var p in rec.Best.Platforms) + equips.Add(new EquipmentDeployment + { + EquipmentRole = (int)EquipmentRole.LaunchPlatform, + PlatformType = (int)p.Type, + Quantity = p.Quantity, + PositionX = p.Position.X, PositionY = p.Position.Y, PositionZ = p.Position.Z, + AerosolType = (int)rec.Best.RecommendedAerosolType, + MunitionCount = p.MunitionCount, + Cooldown = 5, + MuzzleVelocity = p.Type == PlatformType.GroundBased ? 800 : (double?)null, + }); + _scenario.SaveDeployment(_taskId, equips); + + return rec; + } + + // ═══════════════════════════════════════════════ + // 场景 1:无防御 — 无人机到达目标 + // ═══════════════════════════════════════════════ + + [Fact] + public void Scenario_NoDefense_DroneReachesTarget() + { + var task = _scenario.CreateTask("无防御", ""); + _taskId = task.Id; + + _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + TargetType = (int)TargetType.FixedWing, + PowerType = (int)PowerType.Jet, + Quantity = 1, TypicalSpeed = 600, TypicalAltitude = 400, + }); + _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single }, + new List + { + new Waypoint { PosX = 0, PosY = 400, PosZ = 0, Speed = 600 }, + new Waypoint { PosX = 3000, PosY = 400, PosZ = 0, Speed = 600 }, + }); + _scenario.SaveCloudDispersal(_taskId, new CloudDispersal()); + _scenario.SaveDeployment(_taskId, new List()); + + var eng = RunSimulation(400); + Assert.Equal(DroneStatus.ReachedTarget, eng.Drones[0].Status); + } + + // ═══════════════════════════════════════════════ + // 场景 2:管控区域侵入 + // ═══════════════════════════════════════════════ + + [Fact] + public void Scenario_ZoneIntrusion_DroneMarked() + { + var task = _scenario.CreateTask("管控区", ""); + _taskId = task.Id; + + _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + TargetType = (int)TargetType.Electric, + PowerType = (int)PowerType.Electric, + Quantity = 1, TypicalSpeed = 300, TypicalAltitude = 300, + }); + _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single }, + new List + { + new Waypoint { PosX = 0, PosY = 300, PosZ = 0, Speed = 300 }, + new Waypoint { PosX = 3000, PosY = 300, PosZ = 0, Speed = 300 }, + }); + _scenario.SaveCloudDispersal(_taskId, new CloudDispersal()); + _scenario.SaveDeployment(_taskId, new List()); + _scenario.SaveControlZones(_taskId, new List + { + new Models.ControlZone + { + Name = "禁飞区", + VerticesJson = "[{\"X\":1200,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":200},{\"X\":1200,\"Y\":0,\"Z\":200}]", + MinAltitude = 0, MaxAltitude = 1000, + }, + }); + + var eng = RunSimulation(400); + Assert.Equal(DroneStatus.ZoneIntruded, eng.Drones[0].Status); + } + + // ═══════════════════════════════════════════════ + // 场景 3:活塞发动机 → 算法推荐惰性气体 → 拦截成功 + // ═══════════════════════════════════════════════ + + [Fact] + public void Scenario_Piston_InertGasIntercept() + { + var task = _scenario.CreateTask("活塞拦截", ""); + _taskId = task.Id; + + _scenario.SaveScene(_taskId, new CombatScene + { + WindSpeed = 3, WindDirection = (int)WindDirection.W, + }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + TargetType = (int)TargetType.Piston, + PowerType = (int)PowerType.Piston, + Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500, + }); + _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single }, + new List + { + new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 }, + new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 }, + }); + + var rec = GetAndApplyRecommendation(); + + // 验证推荐参数和装备被正确持久化 + var saved = _scenario.GetTaskDetail(_taskId)!; + Assert.True(saved.Cloud.RecommendedTiming.HasValue, + $"Piston: RecommendedTiming={saved.Cloud.RecommendedTiming}"); + Assert.True(saved.Cloud.RecommendedTiming > 0); + Assert.NotEmpty(saved.Equipment.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform), + "应有发射平台"); + + Assert.Equal(AerosolType.InertGas, rec.Best.RecommendedAerosolType); + Assert.True(rec.Best.InterceptProbability > 0); + Assert.True(rec.Best.RecommendedCloud.RecommendedTiming > 0); + + var eng = RunSimulation(3000); + var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched); + Assert.True(launched > 0, $"Piston: 应发射弹药,实际{launched}次"); + Assert.Equal(DroneStatus.Destroyed, eng.Drones[0].Status); + } + + // ═══════════════════════════════════════════════ + // 场景 4:喷气发动机 → 算法推荐活性材料 → 拦截成功 + // ═══════════════════════════════════════════════ + + [Fact] + public void Scenario_Jet_ActiveMaterialIntercept() + { + var task = _scenario.CreateTask("喷气拦截", ""); + _taskId = task.Id; + + _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + TargetType = (int)TargetType.HighSpeed, + PowerType = (int)PowerType.Jet, + Quantity = 1, TypicalSpeed = 500, TypicalAltitude = 800, + }); + _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single }, + new List + { + new Waypoint { PosX = 0, PosY = 800, PosZ = 0, Speed = 500 }, + new Waypoint { PosX = 30000, PosY = 800, PosZ = 0, Speed = 500 }, + }); + + var rec = GetAndApplyRecommendation(); + + var saved = _scenario.GetTaskDetail(_taskId)!; + Assert.True(saved.Cloud.RecommendedTiming.HasValue, + $"Jet: RecommendedTiming={saved.Cloud.RecommendedTiming}"); + + Assert.Equal(AerosolType.ActiveMaterial, rec.Best.RecommendedAerosolType); + Assert.True(rec.Best.InterceptProbability > 0); + Assert.True(rec.Best.RecommendedCloud.RecommendedTiming > 0); + + var eng = RunSimulation(3000); + var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched); + Assert.True(launched > 0, $"Jet: 应发射弹药,实际{launched}次"); + Assert.Equal(DroneStatus.Destroyed, eng.Drones[0].Status); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/KinematicsTests.cs b/test/unit/CounterDrone.Core.Tests/KinematicsTests.cs new file mode 100644 index 0000000..a3731e5 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/KinematicsTests.cs @@ -0,0 +1,65 @@ +using CounterDrone.Core.Algorithms; +using CounterDrone.Core.Models; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class KinematicsTests + { + [Fact] + public void WindToVector_East_CorrectDirection() + { + var (x, y, z) = Kinematics.WindToVector(WindDirection.E, 10f); + Assert.True(x > 9f, "东风 → X 正方向"); + Assert.True(System.Math.Abs(z) < 1f, "东风 → Z 接近 0"); + Assert.Equal(0f, y, 3); + } + + [Fact] + public void WindToVector_North_CorrectDirection() + { + var (x, y, z) = Kinematics.WindToVector(WindDirection.N, 10f); + Assert.True(System.Math.Abs(x) < 1f); + Assert.True(z > 9f, "北风 → Z 正方向"); + } + + [Fact] + public void CalculateLaunchAngle_ReturnsValidAngle() + { + var angle = Kinematics.CalculateLaunchAngle(5000f, 500f, 300f); + Assert.True(angle > 0); + Assert.True(angle < System.Math.PI / 2); // < 90° + } + + [Fact] + public void CalculateLaunchAngle_CloseRange_UsesHeightConstraint() + { + // 近距离,高度约束应主导 + var angle = Kinematics.CalculateLaunchAngle(1000f, 300f, 300f); + Assert.True(angle > 0.05f, "近距离也有合理发射角"); + } + + [Fact] + public void ParabolicPosition_T0_IsAtStart() + { + var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, 0.5f, 0, 500f, 0); + Assert.Equal(0f, x, 1); + Assert.Equal(0f, y, 1); + Assert.Equal(0f, z, 1); + } + + [Fact] + public void PointInPolygon_Inside_ReturnsTrue() + { + var vertices = new (float X, float Z)[] { (0, 0), (10, 0), (10, 10), (0, 10) }; + Assert.True(Kinematics.PointInPolygon(5, 5, vertices)); + } + + [Fact] + public void PointInPolygon_Outside_ReturnsFalse() + { + var vertices = new (float X, float Z)[] { (0, 0), (10, 0), (10, 10), (0, 10) }; + Assert.False(Kinematics.PointInPolygon(20, 5, vertices)); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/MunitionEntityTests.cs b/test/unit/CounterDrone.Core.Tests/MunitionEntityTests.cs new file mode 100644 index 0000000..cbeb761 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/MunitionEntityTests.cs @@ -0,0 +1,58 @@ +using CounterDrone.Core.Models; +using CounterDrone.Core.Simulation; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class MunitionEntityTests + { + [Fact] + public void GroundBased_DoesNotArriveImmediately() + { + // 500 m/s, 5000m 距离, 300m 释放高度 — 可以到达 + var m = new MunitionEntity("m1", PlatformType.GroundBased, + AerosolType.InertGas, + startX: 0, startY: 0, startZ: 0, + targetX: 5000, targetY: 300, targetZ: 0, + muzzleVelocity: 500f, releaseAltitude: 300f); + + m.Update(0.05f); + Assert.False(m.HasArrived, "炮弹不能第一帧就到达"); + } + + [Fact] + public void GroundBased_ArrivesAfterPeakAndDescent() + { + var m = new MunitionEntity("m1", PlatformType.GroundBased, + AerosolType.InertGas, + startX: 0, startY: 0, startZ: 0, + targetX: 5000, targetY: 300, targetZ: 0, + muzzleVelocity: 500f, releaseAltitude: 300f); + + // 500m/s 到 5000m,约需 10-15 秒 + for (int i = 0; i < 500; i++) + { + m.Update(0.05f); + if (m.HasArrived) break; + } + + Assert.True(m.HasArrived, "炮弹应在飞行后到达释放高度"); + // 到达时高度应接近释放高度 + Assert.True(System.Math.Abs(m.PosY - 300f) < 5f, $"PosY={m.PosY} 应接近 300"); + } + + [Fact] + public void GroundBased_MovesForwardOverTime() + { + var m = new MunitionEntity("m1", PlatformType.GroundBased, + AerosolType.InertGas, + startX: 0, startY: 0, startZ: 0, + targetX: 1000, targetY: 300, targetZ: 0, + muzzleVelocity: 500f, releaseAltitude: 300f); + + m.Update(1f); + var dist = System.Math.Sqrt(m.PosX * m.PosX + m.PosZ * m.PosZ); + Assert.True(dist > 100f, "1秒后炮弹应水平移动 > 100m"); + } + } +}