Phase 4 重构: 仿真引擎改为纯执行器 + Kinematics工具类 + 4个端到端场景(2个通过)

This commit is contained in:
tian 2026-06-11 15:09:53 +08:00
parent 3dd0ea6cc9
commit 2ef16d083c
13 changed files with 945 additions and 308 deletions

View File

@ -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));
}
}
}

View File

@ -0,0 +1,105 @@
using System;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>通用运动学 / 几何工具</summary>
public static class Kinematics
{
/// <summary>风向 → 单位速度矢量 (X, 0, Z),右手系 Y-up</summary>
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);
}
/// <summary>计算抛物线炮弹的发射角</summary>
/// <param name="range">水平距离 (m)</param>
/// <param name="muzzleVelocity">初速 (m/s)</param>
/// <param name="releaseAltitude">释放高度 (m),弹道顶点必须 ≥ 此值</param>
/// <returns>发射角 (rad),取低弹道</returns>
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);
}
/// <summary>根据发射参数计算抛物线位置</summary>
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);
}
/// <summary>点到矩形距离(简化判定)</summary>
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);
}
/// <summary>点到三维点距离</summary>
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);
}
/// <summary>射线法 — 点是否在水平多边形内</summary>
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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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<SimEvent> _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,
}
}

View File

@ -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
{
/// <summary>发射计划中的单次事件</summary>
public class FireEvent
{
public float FireTime;
public int PlatformIndex; // 第几个发射平台0-based
public float TargetX, TargetY, TargetZ;
public float MuzzleVelocity;
}
/// <summary>仿真引擎 — 纯执行器,不包含任何决策逻辑</summary>
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<DroneEntity> _drones = new();
private List<PlatformEntity> _platforms = new();
private List<DetectionEntity> _detections = new();
private List<MunitionEntity> _munitions = new();
private List<CloudEntity> _clouds = new();
private List<ControlZone> _zones = new();
private List<ControlZoneEntity> _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<FireEvent> _fireSchedule = new();
private int _nextFireIndex;
private readonly List<SimEvent> _allEvents = new();
private SQLiteConnection _frameDb = null!;
private string _taskId = string.Empty;
private int _entityCounter;
public IReadOnlyList<DroneEntity> Drones => _drones;
public IReadOnlyList<CloudEntity> Clouds => _clouds;
public IReadOnlyList<MunitionEntity> Munitions => _munitions;
public IReadOnlyList<SimEvent> Events => _allEvents;
public SimulationEngine(IScenarioService scenarioService, FrameDataStore frameStore,
IDamageModel damageModel, IPathProvider paths)
{
@ -51,6 +64,14 @@ namespace CounterDrone.Core.Simulation
_paths = paths;
}
/// <summary>设置发射计划(来自推荐算法),在 Initialize 之前调用</summary>
public void SetFireSchedule(List<FireEvent> 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<AmmunitionSpec>()
.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<AmmunitionSpec>()
.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<SimEvent>();
var frameEvents = new List<SimEvent>();
// 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<ICloudDispersionModel>();
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<ICloudDispersionModel>();
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<DroneEntity> Drones => _drones;
public IReadOnlyList<CloudEntity> Clouds => _clouds;
public IReadOnlyList<MunitionEntity> Munitions => _munitions;
public void Stop() { State = SimulationState.Stopped; _frameDb?.Close(); }
private List<EntitySnapshot> CollectSnapshots()
{
var list = new List<EntitySnapshot>();
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;
}
}

View File

@ -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 超出上限");
}
}
}

View File

@ -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<TargetConfig>
{
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<Waypoint>
{
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");
}
}
}

View File

@ -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<Waypoint> CreateRoute(float fromX, float toX, float altitude, 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 },
};
}
[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 位移");
}
}
}

View File

@ -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;
}
/// <summary>从推荐方案的时机参数生成发射计划</summary>
private static List<FireEvent> BuildFireSchedule(TaskFullConfig detail)
{
var schedule = new List<FireEvent>();
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;
}
/// <summary>将 DefenseAdvisor 推荐方案写入想定</summary>
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<EquipmentDeployment>();
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<Waypoint>
{
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<EquipmentDeployment>());
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<Waypoint>
{
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<EquipmentDeployment>());
_scenario.SaveControlZones(_taskId, new List<Models.ControlZone>
{
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<Waypoint>
{
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<Waypoint>
{
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);
}
}
}

View File

@ -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));
}
}
}

View File

@ -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");
}
}
}