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