diff --git a/src/CounterDrone.Core/Simulation/FrameDataStore.cs b/src/CounterDrone.Core/Simulation/FrameDataStore.cs
index 53a8737..688b37d 100644
--- a/src/CounterDrone.Core/Simulation/FrameDataStore.cs
+++ b/src/CounterDrone.Core/Simulation/FrameDataStore.cs
@@ -6,33 +6,39 @@ using SQLite;
namespace CounterDrone.Core.Simulation
{
+ /// 帧数据存储 — 内存缓存,仿真结束时批量写入 SQLite
public class FrameDataStore
{
private readonly IPathProvider _paths;
+ private readonly List _buffer = new();
+ private string _taskId = string.Empty;
/// 帧数据保留天数,默认 180 天(6 个月)
public int RetentionDays { get; set; } = 180;
+ public int BufferedFrameCount => _buffer.Count > 0
+ ? _buffer[^1].FrameIndex + 1
+ : 0;
public FrameDataStore(IPathProvider paths)
{
_paths = paths;
}
- public SQLiteConnection CreateOrOpen(string taskId)
+ /// 开始录制。清空缓冲区,准备接收帧数据。
+ public void BeginRecording(string taskId)
{
- var dbPath = Path.Combine(_paths.GetFramesDir(), $"{taskId}.db");
- var db = new SQLiteConnection(dbPath);
- db.CreateTable();
- db.CreateIndex("SimFrameRecord", new[] { "TaskId", "FrameIndex" });
- return db;
+ _taskId = taskId;
+ _buffer.Clear();
}
- public void WriteFrame(SQLiteConnection db, string taskId, int frameIdx, float timestamp, List snapshots)
+ /// 记录一帧所有实体的快照(仅内存,不写磁盘)
+ public void RecordFrame(int frameIdx, float timestamp, List snapshots)
{
- foreach (var s in snapshots)
+ for (int i = 0; i < snapshots.Count; i++)
{
- db.Insert(new SimFrameRecord
+ var s = snapshots[i];
+ _buffer.Add(new SimFrameRecord
{
- TaskId = taskId,
+ TaskId = _taskId,
FrameIndex = frameIdx,
Timestamp = timestamp,
EntityId = s.EntityId,
@@ -48,8 +54,26 @@ namespace CounterDrone.Core.Simulation
}
}
- public List ReadFrames(SQLiteConnection db, string taskId, int fromFrame, int toFrame)
+ /// 将缓冲区所有帧数据批量写入 SQLite(事务级别,单次 IO)
+ public void Flush()
{
+ if (_buffer.Count == 0) return;
+ var dbPath = Path.Combine(_paths.GetFramesDir(), $"{_taskId}.db");
+ Directory.CreateDirectory(_paths.GetFramesDir());
+ using var db = new SQLiteConnection(dbPath);
+ db.CreateTable();
+ db.CreateIndex("SimFrameRecord", new[] { "TaskId", "FrameIndex" });
+ db.InsertAll(_buffer);
+ _buffer.Clear();
+ }
+
+ public void Discard() => _buffer.Clear();
+
+ public List ReadFrames(string taskId, int fromFrame, int toFrame)
+ {
+ var dbPath = Path.Combine(_paths.GetFramesDir(), $"{taskId}.db");
+ if (!File.Exists(dbPath)) return new List();
+ using var db = new SQLiteConnection(dbPath);
return db.Table()
.Where(f => f.TaskId == taskId && f.FrameIndex >= fromFrame && f.FrameIndex <= toFrame)
.OrderBy(f => f.FrameIndex)
@@ -63,25 +87,20 @@ namespace CounterDrone.Core.Simulation
}
/// 清理过期的帧数据库文件
- /// 被删除的文件数量
public int CleanupExpired()
{
var dir = _paths.GetFramesDir();
if (!Directory.Exists(dir)) return 0;
-
var cutoff = DateTime.Now.AddDays(-RetentionDays);
var deleted = 0;
-
foreach (var file in Directory.GetFiles(dir, "*.db"))
{
- var creationTime = File.GetCreationTime(file);
- if (creationTime < cutoff)
+ if (File.GetCreationTime(file) < cutoff)
{
File.Delete(file);
deleted++;
}
}
-
return deleted;
}
}
diff --git a/src/CounterDrone.Core/Simulation/SimulationEngine.cs b/src/CounterDrone.Core/Simulation/SimulationEngine.cs
index fbc922c..8332aa1 100644
--- a/src/CounterDrone.Core/Simulation/SimulationEngine.cs
+++ b/src/CounterDrone.Core/Simulation/SimulationEngine.cs
@@ -20,6 +20,8 @@ namespace CounterDrone.Core.Simulation
public int FrameIndex { get; private set; }
public float SimulationTime { get; private set; }
public float TimeScale { get; set; } = 1f;
+ /// 是否录制逐帧数据(回放用)。默认开启。
+ public bool RecordFrames { get; set; } = true;
private float _tickInterval;
public IReadOnlyList Drones => _drones;
@@ -46,7 +48,6 @@ namespace CounterDrone.Core.Simulation
private readonly List _fireSchedule = new();
private int _nextFireIndex;
private readonly List _allEvents = new();
- private SQLiteConnection _frameDb = null!;
private string _taskId = string.Empty;
private readonly IDefensePlanner _planner;
private int _entityCounter;
@@ -156,7 +157,8 @@ namespace CounterDrone.Core.Simulation
}
}
- _frameDb = _frameStore.CreateOrOpen(_taskId);
+ if (RecordFrames)
+ _frameStore.BeginRecording(_taskId);
State = SimulationState.Running;
}
@@ -335,11 +337,16 @@ namespace CounterDrone.Core.Simulation
_allEvents.AddRange(_frameEventPool);
- var snapshots = CollectSnapshots();
- _frameStore.WriteFrame(_frameDb, _taskId, FrameIndex, SimulationTime, snapshots);
- if (FrameIndex % 50 == 0) _frameDb.Commit();
-
- _frameResultPool.EntitySnapshots = snapshots;
+ if (RecordFrames)
+ {
+ var snapshots = CollectSnapshots();
+ _frameStore.RecordFrame(FrameIndex, SimulationTime, snapshots);
+ _frameResultPool.EntitySnapshots = snapshots;
+ }
+ else
+ {
+ _frameResultPool.EntitySnapshots = null!;
+ }
_frameResultPool.NewEvents = _frameEventPool;
_frameResultPool.State = State;
_frameResultPool.FrameIndex = FrameIndex;
@@ -349,7 +356,7 @@ namespace CounterDrone.Core.Simulation
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 void Stop() { State = SimulationState.Stopped; _frameStore.Flush(); }
private static List BuildFireUnits(TaskFullConfig config)
{
diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll
index 2c70c3d..42c66ea 100644
Binary files a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll and b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll differ
diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.pdb b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.pdb
index 6c179ab..7fb997f 100644
Binary files a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.pdb and b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.pdb differ
diff --git a/src/Unity/Assets/Scripts/Managers/ReplayController.cs b/src/Unity/Assets/Scripts/Managers/ReplayController.cs
index f33ced2..0d4e496 100644
--- a/src/Unity/Assets/Scripts/Managers/ReplayController.cs
+++ b/src/Unity/Assets/Scripts/Managers/ReplayController.cs
@@ -25,19 +25,12 @@ namespace CounterDrone.Unity
public void LoadReplay(string taskId)
{
- var db = _frameStore.CreateOrOpen(taskId);
- CurrentFrames = db.Table()
- .Where(f => f.TaskId == taskId)
- .OrderBy(f => f.FrameIndex)
- .ToList();
- db.Close();
+ CurrentFrames = _frameStore.ReadFrames(taskId, 0, int.MaxValue);
TotalFrames = CurrentFrames.Count > 0
? CurrentFrames.Max(f => f.FrameIndex) + 1
: 0;
CurrentFrameIndex = 0;
-
- Debug.Log($"Replay loaded: {CurrentFrames.Count} records, {TotalFrames} frames");
}
/// 获取指定帧的所有实体快照
diff --git a/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs b/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs
index 1e1bddb..e145713 100644
--- a/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs
+++ b/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs
@@ -15,8 +15,6 @@ namespace CounterDrone.Unity
{
public class SimulationRunner : MonoBehaviour
{
- private bool _loggedStart;
-
[SerializeField] private GameObject _dronePrefab;
[SerializeField] private GameObject _cloudPrefab;
[SerializeField] private GameObject _platformPrefab;
@@ -67,17 +65,8 @@ namespace CounterDrone.Unity
{
if (_engine == null || _engine.State != SimulationState.Running) return;
- if (!_loggedStart) { Debug.Log($"Ticking: drones={_engine.Drones.Count}, timescale={_engine.TimeScale}"); _loggedStart = true; }
-
var result = _engine.Tick(Time.deltaTime);
- // 每秒输出无人机位置
- if (Time.frameCount % 60 == 0 && _engine.Drones.Count > 0)
- {
- var d = _engine.Drones[0];
- Debug.Log($"Drone: ({d.PosX:F0}, {d.PosY:F0}, {d.PosZ:F0}) Status={d.Status}");
- }
-
foreach (var snap in result.EntitySnapshots)
{
if (!_entityVisuals.TryGetValue(snap.EntityId, out var go) || go == null)
@@ -145,8 +134,6 @@ namespace CounterDrone.Unity
}
}
- if (result.State == SimulationState.Completed)
- Debug.Log($"Simulation done. Drone: {_engine.Drones[0].Status}, HP: {_engine.Drones[0].Hp:F2}");
}
public void Stop() => _engine?.Stop();
diff --git a/test/unit/CounterDrone.Core.Tests/FrameDataStoreTests.cs b/test/unit/CounterDrone.Core.Tests/FrameDataStoreTests.cs
index 84c2736..cadcd92 100644
--- a/test/unit/CounterDrone.Core.Tests/FrameDataStoreTests.cs
+++ b/test/unit/CounterDrone.Core.Tests/FrameDataStoreTests.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.IO;
using CounterDrone.Core;
+using CounterDrone.Core.Models;
using CounterDrone.Core.Simulation;
using Xunit;
@@ -23,22 +25,22 @@ namespace CounterDrone.Core.Tests
if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
}
+ private void CreateFakeDb(string taskId)
+ {
+ _store.BeginRecording(taskId);
+ _store.RecordFrame(0, 0f, new List { new EntitySnapshot { EntityId = "d1" } });
+ _store.Flush();
+ }
+
[Fact]
public void CleanupExpired_DeletesOldFiles()
{
- // 设置为 0 天保留,所有文件都是"过期"的
_store.RetentionDays = 0;
-
- // 创建一些假帧数据库
- var db1 = _store.CreateOrOpen("task_old_1");
- db1.Close();
- var db2 = _store.CreateOrOpen("task_old_2");
- db2.Close();
+ CreateFakeDb("task_old_1");
+ CreateFakeDb("task_old_2");
var deleted = _store.CleanupExpired();
Assert.Equal(2, deleted);
-
- // 文件应该被删除
Assert.False(File.Exists(Path.Combine(_testDir, "frames", "task_old_1.db")));
Assert.False(File.Exists(Path.Combine(_testDir, "frames", "task_old_2.db")));
}
@@ -46,48 +48,57 @@ namespace CounterDrone.Core.Tests
[Fact]
public void CleanupExpired_KeepsRecentFiles()
{
- _store.RetentionDays = 365; // 保留 1 年
+ _store.RetentionDays = 365;
+ CreateFakeDb("task_new");
- var db = _store.CreateOrOpen("task_new");
- db.Close();
-
- // 手动修改创建时间为"最近"
var filePath = Path.Combine(_testDir, "frames", "task_new.db");
File.SetCreationTime(filePath, DateTime.Now);
var deleted = _store.CleanupExpired();
Assert.Equal(0, deleted);
-
- // 文件应该保留
Assert.True(File.Exists(filePath));
}
[Fact]
public void CleanupExpired_EmptyDir_ReturnsZero()
{
- var deleted = _store.CleanupExpired();
- Assert.Equal(0, deleted);
+ Assert.Equal(0, _store.CleanupExpired());
}
[Fact]
- public void ReadFrames_ReturnsCorrectRange()
+ public void RecordAndFlush_ThenReadFrames()
{
- var db = _store.CreateOrOpen("task_read");
- var snapshots = new System.Collections.Generic.List
+ _store.BeginRecording("task_rw");
+ var snapshots = new List
{
new EntitySnapshot { EntityId = "d1", EntityType = Models.EntityType.Drone, PosX = 100 },
};
+ _store.RecordFrame(0, 0f, snapshots);
+ _store.RecordFrame(1, 0.05f, snapshots);
+ _store.RecordFrame(2, 0.10f, snapshots);
+ _store.Flush();
- _store.WriteFrame(db, "task_read", 0, 0f, snapshots);
- _store.WriteFrame(db, "task_read", 1, 0.05f, snapshots);
- _store.WriteFrame(db, "task_read", 2, 0.10f, snapshots);
-
- var frames = _store.ReadFrames(db, "task_read", 1, 2);
+ var frames = _store.ReadFrames("task_rw", 1, 2);
Assert.Equal(2, frames.Count);
Assert.Equal(1, frames[0].FrameIndex);
Assert.Equal(2, frames[1].FrameIndex);
+ }
- db.Close();
+ [Fact]
+ public void Flush_EmptyBuffer_DoesNothing()
+ {
+ _store.BeginRecording("task_empty");
+ _store.Flush(); // no crash
+ Assert.False(File.Exists(Path.Combine(_testDir, "frames", "task_empty.db")));
+ }
+
+ [Fact]
+ public void Discard_ClearsBuffer()
+ {
+ _store.BeginRecording("task_discard");
+ _store.RecordFrame(0, 0f, new List { new EntitySnapshot { EntityId = "d1" } });
+ _store.Discard();
+ Assert.Equal(0, _store.BufferedFrameCount);
}
}
}
diff --git a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs
index 93a7b2c..33e8aca 100644
--- a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs
+++ b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs
@@ -136,6 +136,7 @@ namespace CounterDrone.Core.Tests
SetupScenario();
_engine.Initialize(_taskId);
for (int i = 0; i < 10; i++) _engine.Tick(1f / 20f);
+ _engine.Stop(); // 触发 Flush
var framePath = Path.Combine(_testDir, "frames", $"{_taskId}.db");
Assert.True(File.Exists(framePath));