diff --git a/Assets/GameMain/DataTables/UIForm.txt b/Assets/GameMain/DataTables/UIForm.txt index 969d161..139fbb9 100644 --- a/Assets/GameMain/DataTables/UIForm.txt +++ b/Assets/GameMain/DataTables/UIForm.txt @@ -7,6 +7,6 @@ 101 设置 SettingForm Default False True 102 关于 AboutForm Default False True 103 主UI MainForm Default False True - 150 测试UI TestMenuForm Default False False + 150 测试UI TestMenuForm Test False False 200 事件UI EventForm Default False False 201 背包UI RepoForm Default False True diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs index 28d25d0..14d4b4a 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using GameFramework.DataTable; +using GeometryTD.CustomEvent; using GeometryTD.DataTable; using GeometryTD.Definition; using UnityEngine; @@ -65,9 +66,8 @@ namespace GeometryTD.CustomComponent } DRLevelPhase[] levelPhases = dtLevelPhase.GetAllDataRows(); - for (int i = 0; i < levelPhases.Length; i++) + foreach (var phase in levelPhases) { - DRLevelPhase phase = levelPhases[i]; int levelId = phase.Id / 1000; if (!_levelsById.ContainsKey(levelId)) { @@ -85,9 +85,8 @@ namespace GeometryTD.CustomComponent } DRLevelSpawnEntry[] spawnEntries = dtSpawnEntry.GetAllDataRows(); - for (int i = 0; i < spawnEntries.Length; i++) + foreach (var spawnEntry in spawnEntries) { - DRLevelSpawnEntry spawnEntry = spawnEntries[i]; int phaseId = spawnEntry.Id / 1000; int levelId = phaseId / 1000; if (!_levelsById.ContainsKey(levelId)) @@ -171,6 +170,12 @@ namespace GeometryTD.CustomComponent CurrentLevel = selectedLevel; _combatScheduler.Start(selectedLevel, phaseList, _selectedSpawnEntriesByPhaseId); + GameEntry.Event.Fire(this, NodeEnterEventArgs.Create()); + } + + public void EndCombat() + { + GameEntry.Event.Fire(this, NodeCompleteEventArgs.Create()); } public void OnUpdate(float elapseSeconds, float realElapseSeconds) @@ -252,4 +257,4 @@ namespace GeometryTD.CustomComponent return count; } } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler.cs index f08aec6..a6dc3ac 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler.cs @@ -23,9 +23,9 @@ namespace GeometryTD.CustomComponent Failed = 4 } - private readonly List _phaseBuffer = new List(); - private readonly Dictionary> _spawnEntriesByPhaseId = - new Dictionary>(); + private readonly List _phaseBuffer = new(); + + private readonly Dictionary> _spawnEntriesByPhaseId = new(); private readonly EnemyManager _enemyManager = new EnemyManager(); @@ -79,8 +79,8 @@ namespace GeometryTD.CustomComponent return; } + CleanupBattleEntities(); _enemyManager.EndPhase(); - HideCurrentMapIfNeeded(); ResetRuntime(); _currentLevel = level; @@ -145,8 +145,8 @@ namespace GeometryTD.CustomComponent return; } + CleanupBattleEntities(); _enemyManager.OnDestroy(); - HideCurrentMapIfNeeded(); ResetRuntime(); UnsubscribeEntityEvents(); _entity = null; @@ -253,7 +253,12 @@ namespace GeometryTD.CustomComponent { _state = SchedulerState.Completed; _currentPhase = null; + if (_currentMap != null) + { + _entity.HideEntity(_currentMap); + } Log.Info("CombatScheduler level completed. Level={0}.", _currentLevel != null ? _currentLevel.Id : 0); + GameEntry.CombatNode.EndCombat(); return; } @@ -273,39 +278,6 @@ namespace GeometryTD.CustomComponent spawnEntries != null ? spawnEntries.Count : 0); } - private void HideCurrentMapIfNeeded() - { - if (_entity == null) - { - _currentMap = null; - _loadingMapEntityId = 0; - _loadedMapEntityId = 0; - return; - } - - if (_loadingMapEntityId != 0) - { - EntityBase loadingMap = _entity.GetGameEntity(_loadingMapEntityId); - if (loadingMap != null) - { - _entity.HideEntity(loadingMap); - } - } - - if (_loadedMapEntityId != 0) - { - EntityBase loadedMap = _entity.GetGameEntity(_loadedMapEntityId); - if (loadedMap != null) - { - _entity.HideEntity(loadedMap); - } - } - - _loadingMapEntityId = 0; - _loadedMapEntityId = 0; - _currentMap = null; - } - private void EnsureEntityEventSubscribed() { if (_isEntityEventSubscribed) @@ -340,9 +312,40 @@ namespace GeometryTD.CustomComponent _currentPhase = null; _currentPhaseIndex = -1; _currentPhaseElapsed = 0f; + _loadingMapEntityId = 0; + _loadedMapEntityId = 0; + _currentMap = null; _state = SchedulerState.Idle; } + private void CleanupBattleEntities() + { + if (_entity == null) + { + return; + } + + if (_loadingMapEntityId != 0 && _entity.IsLoadingEntity(_loadingMapEntityId)) + { + _entity.HideEntity(_loadingMapEntityId); + } + + if (_currentMap != null) + { + _entity.HideEntity(_currentMap); + } + else if (_loadedMapEntityId != 0) + { + UnityGameFramework.Runtime.Entity loadedMapEntity = _entity.GetEntity(_loadedMapEntityId); + if (loadedMapEntity != null) + { + _entity.HideEntity(loadedMapEntity); + } + } + + _enemyManager.CleanupTrackedEnemies(); + } + #region Event Handlers private void OnShowEntitySuccess(object sender, GameEventArgs e) diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs index 752d290..1cf2854 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs @@ -29,6 +29,8 @@ namespace GeometryTD.CustomComponent private readonly Dictionary _spawnerByOrder = new Dictionary(); private readonly List _pathBuffer = new List(); private readonly List _spawnRuntimes = new List(); + private readonly HashSet _trackedEnemyEntityIds = new HashSet(); + private readonly List _trackedEnemyIdBuffer = new List(); private CombatScheduler _combatScheduler; private EntityComponent _entity; @@ -66,6 +68,8 @@ namespace GeometryTD.CustomComponent _spawnerByOrder.Clear(); _pathBuffer.Clear(); _spawnRuntimes.Clear(); + _trackedEnemyEntityIds.Clear(); + _trackedEnemyIdBuffer.Clear(); _phaseElapsed = 0f; _isPhaseRunning = false; IsPhaseSpawnCompleted = true; @@ -134,6 +138,7 @@ namespace GeometryTD.CustomComponent return; } + CleanupTrackedEnemies(); EndPhase(); GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess); GameEntry.Event.Unsubscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure); @@ -142,12 +147,47 @@ namespace GeometryTD.CustomComponent _spawners.Clear(); _spawnerByOrder.Clear(); _pathBuffer.Clear(); + _trackedEnemyEntityIds.Clear(); + _trackedEnemyIdBuffer.Clear(); + _currentEnemyCount = 0; _currentMapEntityId = 0; _nextSpawnerIndex = 0; _combatScheduler = null; _initialized = false; } + public void CleanupTrackedEnemies() + { + if (_trackedEnemyEntityIds.Count <= 0) + { + _currentEnemyCount = 0; + return; + } + + _trackedEnemyIdBuffer.Clear(); + foreach (int trackedEnemyEntityId in _trackedEnemyEntityIds) + { + _trackedEnemyIdBuffer.Add(trackedEnemyEntityId); + } + + _trackedEnemyEntityIds.Clear(); + _currentEnemyCount = 0; + + if (_entity == null) + { + return; + } + + for (int i = 0; i < _trackedEnemyIdBuffer.Count; i++) + { + int trackedEnemyEntityId = _trackedEnemyIdBuffer[i]; + if (_entity.HasEntity(trackedEnemyEntityId) || _entity.IsLoadingEntity(trackedEnemyEntityId)) + { + _entity.HideEntity(trackedEnemyEntityId); + } + } + } + private SpawnEntryRuntime BuildSpawnRuntime(DRLevelSpawnEntry entry) { if (entry == null || entry.EntryType == EntryType.None) @@ -167,12 +207,12 @@ namespace GeometryTD.CustomComponent switch (entry.EntryType) { case EntryType.Stream: - { - float duration = Mathf.Max(0f, entry.Duration); - runtime.EndTime = duration > 0f ? runtime.NextTriggerTime + duration : runtime.NextTriggerTime; - runtime.Completed = entry.Count <= 0; - return runtime; - } + { + float duration = Mathf.Max(0f, entry.Duration); + runtime.EndTime = duration > 0f ? runtime.NextTriggerTime + duration : runtime.NextTriggerTime; + runtime.Completed = entry.Count <= 0; + return runtime; + } case EntryType.Burst: case EntryType.Boss: runtime.Completed = runtime.RemainingCount <= 0; @@ -311,6 +351,7 @@ namespace GeometryTD.CustomComponent } int enemyEntityId = _entity.GenerateSerialId(); + _trackedEnemyEntityIds.Add(enemyEntityId); EnemyData enemyData = new EnemyData( enemyEntityId, enemyConfig.EntityId, @@ -434,17 +475,28 @@ namespace GeometryTD.CustomComponent private void OnShowEntitySuccess(object sender, GameEventArgs e) { - if (e is ShowEntitySuccessEventArgs ne) + if (!(e is ShowEntitySuccessEventArgs ne)) return; + + if (ne.EntityLogicType == typeof(EnemyEntity) && + _trackedEnemyEntityIds.Contains(ne.Entity.Id)) { - if (ne.EntityLogicType == typeof(EnemyEntity)) - { - _currentEnemyCount++; - } + _currentEnemyCount++; } } private void OnShowEntityFailure(object sender, GameEventArgs e) { + if (!(e is ShowEntityFailureEventArgs ne)) + { + return; + } + + if (ne.EntityLogicType != typeof(EnemyEntity)) + { + return; + } + + _trackedEnemyEntityIds.Remove(ne.EntityId); } private void OnHideEntityComplete(object sender, GameEventArgs e) @@ -454,7 +506,7 @@ namespace GeometryTD.CustomComponent return; } - if (ne.EntityGroup.Name != "Enemy") + if (!_trackedEnemyEntityIds.Remove(ne.EntityId)) { return; } @@ -462,4 +514,4 @@ namespace GeometryTD.CustomComponent _currentEnemyCount = Mathf.Max(0, _currentEnemyCount - 1); } } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs b/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs index 1d3786c..d8f0315 100644 --- a/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using GameFramework.DataTable; +using GeometryTD.CustomEvent; using GeometryTD.DataTable; using GeometryTD.Definition; using Newtonsoft.Json.Linq; @@ -72,9 +73,16 @@ namespace GeometryTD.CustomComponent Log.Warning("EventNodeComponent StartEvent failed. Event form is not initialized."); return; } - + _eventFormUseCase.SetCurrentEvent(randomEvent); _eventFormController.OpenUI(); + GameEntry.Event.Fire(this, NodeEnterEventArgs.Create()); + } + + public void EndEvent() + { + GameEntry.UIRouter.CloseUI(UIFormType.EventForm); + GameEntry.Event.Fire(this, NodeCompleteEventArgs.Create()); } private static EventOption[] ParseOptions(string optionsRaw) @@ -169,4 +177,4 @@ namespace GeometryTD.CustomComponent return effects.ToArray(); } } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMenu.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMenu.cs index 2b8a130..53c251c 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMenu.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMenu.cs @@ -74,6 +74,7 @@ namespace GeometryTD.Procedure if (!(e is NodeEnterEventArgs)) return; GameEntry.UIRouter.CloseUI(UIFormType.TestMenuForm); + GameEntry.UIRouter.CloseUI(UIFormType.MainForm); } private void OnNodeComplete(object sender, GameEventArgs e) @@ -81,6 +82,7 @@ namespace GeometryTD.Procedure if (!(e is NodeCompleteEventArgs)) return; GameEntry.UIRouter.OpenUI(UIFormType.TestMenuForm); + GameEntry.UIRouter.OpenUI(UIFormType.MainForm); } } } \ No newline at end of file diff --git a/Assets/GameMain/Scripts/UI/GameScene/Controller/EventFormController.cs b/Assets/GameMain/Scripts/UI/GameScene/Controller/EventFormController.cs index a3ebf36..7838a4e 100644 --- a/Assets/GameMain/Scripts/UI/GameScene/Controller/EventFormController.cs +++ b/Assets/GameMain/Scripts/UI/GameScene/Controller/EventFormController.cs @@ -118,7 +118,6 @@ namespace GeometryTD.UI } m_UseCase.SelectOption(args.SelectedItemId); - CloseUI(); } } } \ No newline at end of file diff --git a/Assets/GameMain/Scripts/UI/GameScene/UseCase/EventFormUseCase.cs b/Assets/GameMain/Scripts/UI/GameScene/UseCase/EventFormUseCase.cs index 2eb7b8e..17e3197 100644 --- a/Assets/GameMain/Scripts/UI/GameScene/UseCase/EventFormUseCase.cs +++ b/Assets/GameMain/Scripts/UI/GameScene/UseCase/EventFormUseCase.cs @@ -1,3 +1,4 @@ +using GeometryTD.CustomComponent; using GeometryTD.CustomEvent; using GeometryTD.Definition; using UnityGameFramework.Runtime; @@ -6,42 +7,42 @@ namespace GeometryTD.UI { public class EventFormUseCase : IUIUseCase { - private EventItem m_CurrentEvent; + private EventItem _currentEvent; public void SetCurrentEvent(EventItem eventItem) { - m_CurrentEvent = eventItem; + _currentEvent = eventItem; } public EventFormRawData CreateInitialModel() { - if (m_CurrentEvent == null) + if (_currentEvent == null) { return null; } return new EventFormRawData { - EventItem = m_CurrentEvent + EventItem = _currentEvent }; } public EventOption SelectOption(int optionIndex) { - if (m_CurrentEvent == null || m_CurrentEvent.Options == null) + if (_currentEvent == null || _currentEvent.Options == null) { return null; } - if (optionIndex < 0 || optionIndex >= m_CurrentEvent.Options.Length) + if (optionIndex < 0 || optionIndex >= _currentEvent.Options.Length) { Log.Warning("EventFormUseCase.SelectOption() option index is invalid: {0}", optionIndex); return null; } // TODO: 执行 Requirements 校验、CostEffects/RewardEffects 结算与后续节点推进。 - GameEntry.Event.Fire(this, NodeCompleteEventArgs.Create()); - return m_CurrentEvent.Options[optionIndex]; + GameEntry.EventNode.EndEvent(); + return _currentEvent.Options[optionIndex]; } } } \ No newline at end of file diff --git a/Assets/Launcher.unity b/Assets/Launcher.unity index bbec1eb..15e2617 100644 --- a/Assets/Launcher.unity +++ b/Assets/Launcher.unity @@ -490,7 +490,7 @@ PrefabInstance: objectReference: {fileID: 1576232203} - target: {fileID: 11454530, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_UIGroups.Array.size - value: 1 + value: 2 objectReference: {fileID: 0} - target: {fileID: 11454530, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_UIGroupHelperTypeName @@ -502,7 +502,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 11454530, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_UIGroups.Array.data[1].m_Name - value: Default + value: Test objectReference: {fileID: 0} - target: {fileID: 11454530, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_UIGroups.Array.data[0].m_Depth @@ -510,7 +510,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 11454530, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_UIGroups.Array.data[1].m_Depth - value: 0 + value: 2 objectReference: {fileID: 0} - target: {fileID: 11461470, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_LocalizationHelperTypeName @@ -522,7 +522,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_EntityGroups.Array.size - value: 5 + value: 4 objectReference: {fileID: 0} - target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_EntityGroups.Array.data[0].m_Name @@ -538,7 +538,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_EntityGroups.Array.data[3].m_Name - value: Player + value: Map objectReference: {fileID: 0} - target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_EntityGroups.Array.data[4].m_Name diff --git a/docs/CombatNodeArchitecture.md b/docs/CombatNodeArchitecture.md new file mode 100644 index 0000000..5333881 --- /dev/null +++ b/docs/CombatNodeArchitecture.md @@ -0,0 +1,221 @@ +# CombatNode 架构摘要 + +最后更新:2026-02-28 + +## 1. 目标与边界 + +CombatNode 子系统的目标是把“战斗节点”拆成三个稳定层: + +- `CombatNodeComponent`:节点入口与配置缓存(门面层) +- `CombatScheduler`:单局战斗状态机(编排层) +- `EnemyManager`:刷怪与敌人生命周期(执行层) + +边界约束: + +- 只负责战斗节点,不负责菜单流程切换,不负责 UI 细节。 +- 只依赖数据表和 Entity 系统,不直接持有场景流程 FSM。 +- 地图寻路由 `MapEntity` 提供能力,敌人移动由 `EnemyEntity` 自治。 + +--- + +## 2. 模块职责划分 + +## 2.1 CombatNodeComponent(门面层) + +文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs` + +职责: + +- 读取并缓存 `DRLevel / DRLevelPhase / DRLevelSpawnEntry`。 +- 按主题筛选关卡,维护 `Level -> Phase -> SpawnEntry` 映射。 +- 提供 `OnInit / StartCombat / OnUpdate / OnShutdown` 外部接口。 +- 触发节点事件: + - `NodeEnterEventArgs` + - `NodeCompleteEventArgs` + +不做的事: + +- 不做阶段推进。 +- 不做刷怪时序。 +- 不做实体显示/隐藏细节。 + +## 2.2 CombatScheduler(编排层) + +文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler.cs` + +职责: + +- 管理战斗状态机:`Idle -> WaitingForMap -> RunningPhase -> Completed/Failed`。 +- 加载地图实体,接收地图 show/hide 成功失败事件。 +- 按 phase 结束条件推进到下一阶段。 +- 在开始新战斗或销毁时做统一清场(地图 + 本局敌人)。 + +不做的事: + +- 不直接计算每条刷怪规则的触发细节(交给 `EnemyManager`)。 + +## 2.3 EnemyManager(执行层) + +文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs` + +职责: + +- 将 `DRLevelSpawnEntry` 转为运行时任务(stream/burst/boss)。 +- 按时间推进刷怪任务并生成 `EnemyData`。 +- 维护本局敌人数量 `AliveEnemyCount`。 +- 维护“本局生成敌人 ID 集合”,用于准确计数与清场。 + +关键点: + +- 生命周期归属按 `entityId` 跟踪,不再按实体组名粗粒度统计。 +- 清场可覆盖“已加载 + 加载中”的本局敌人。 + +--- + +## 3. 关键实体职责 + +## 3.1 MapEntity + +文件:`Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs` + +职责: + +- 读取 Tilemap,识别 Path/Foundation 格子。 +- 为每个 `Spawner` 缓存默认可达路径。 +- 提供: + - `TryGetDefaultPathCells` + - `TryFindPathCells` + - `TryFindPathWorldPoints` + +## 3.2 EnemyEntity + +文件:`Assets/GameMain/Scripts/Entity/EntityLogic/EnemyEntity.cs` + +职责: + +- 按路径点移动。 +- 到达终点后调用 `HideEntity` 自行退出。 + +--- + +## 4. 运行时时序(简版) + +1. 菜单触发战斗:`TestMenuFormController -> GameEntry.CombatNode.StartCombat()` +2. `CombatNodeComponent.StartCombat()` 选关并把关卡配置交给 `CombatScheduler.Start()` +3. `CombatScheduler.Start()` 先清理上局残留,再 `ShowMap(...)`,状态进入 `WaitingForMap` +4. 地图加载成功事件到达,`_currentMap` 就绪 +5. `BeginNextPhase()` 进入 `RunningPhase`,`EnemyManager.BeginPhase(...)` +6. 每帧 `OnUpdate`: + - `CombatScheduler` 更新时间与结束条件 + - `EnemyManager` 推进刷怪任务并创建敌人 +7. 当前 phase 满足结束条件,`CompleteCurrentPhase()`,然后进入下一 phase +8. 全 phase 完成后隐藏地图,`GameEntry.CombatNode.EndCombat()`,抛 `NodeComplete` + +--- + +## 5. 数据契约与 ID 规则 + +数据来源: + +- `DRLevel` +- `DRLevelPhase` +- `DRLevelSpawnEntry` + +当前约定(依赖 ID 编码): + +- `levelId = phaseId / 1000` +- `phaseId = spawnEntryId / 1000` + +影响: + +- 新增配置时必须遵守该编码,否则 phase 和 entry 无法被正确归属。 + +建议: + +- 若后续改数据结构,优先改成显式外键字段,减少 ID 推导耦合。 + +--- + +## 6. 结束条件模型 + +枚举:`PhaseEndType` + +- `TimeElapsed`:按 `EndParam`(可解析 float)或 `DurationSeconds` +- `EnemiesCleared`:`IsPhaseSpawnCompleted && AliveEnemyCount <= 0` +- `BossDead`:当前与 `EnemiesCleared` 同判定 +- `None`:有 duration 走 duration,否则退化为清怪判定 + +实现点:`CombatScheduler.ShouldEndCurrentPhase()` + +--- + +## 7. 生命周期与清场策略(当前实现) + +## 7.1 地图生命周期 + +- 通过 `ShowMap` 异步加载。 +- 通过 `ShowEntitySuccess/Failure` 绑定运行态地图引用。 +- 完成关卡时隐藏地图。 +- 新战斗开始和调度器销毁前都会尝试清理地图实体。 + +## 7.2 敌人生命周期 + +- 创建时登记 `entityId` 到 `_trackedEnemyEntityIds`。 +- `ShowEntitySuccess` 仅对 tracked id 计数。 +- `HideEntityComplete` 仅对 tracked id 减计数并移除跟踪。 +- 清场时按 tracked id 隐藏(覆盖加载中和已加载实体)。 + +## 7.3 设计目标 + +- 防止跨局残留实体污染新局状态。 +- 防止其他系统 `Enemy` 组实体干扰战斗计数。 + +--- + +## 8. 扩展指南(按需求) + +## 8.1 新增阶段结束条件 + +- 改 `PhaseEndType` 枚举。 +- 在 `ShouldEndCurrentPhase()` 增加分支。 + +## 8.2 新增刷怪条目类型 + +- 改 `EntryType` 枚举。 +- 在 `EnemyManager.BuildSpawnRuntime()` 与 `UpdateSpawnRuntimes()` 增加处理。 + +## 8.3 改选关策略 + +- 当前是随机选关:`TrySelectRandomLevel()`。 +- 可改为按难度、解锁进度、权重池。 + +## 8.4 动态阻挡/重算路径 + +- 当前 `SpawnEnemies` 调 `TryFindPathWorldPoints(spawner, null, ...)`。 +- 若支持塔阻挡,传入 `blockedCells` 并处理“无路可走”策略。 + +## 8.5 结算与奖励 + +- 推荐挂在 `CombatScheduler` 完成分支(所有 phase 完成处),不要塞进 `EnemyManager`。 + +--- + +## 9. 维护时的硬性不变量 + +- `CombatNodeComponent` 只做缓存和入口,不写战斗细节。 +- `CombatScheduler` 是唯一 phase 状态机,不在别处推进 phase。 +- `AliveEnemyCount` 必须由 tracked id 驱动,不回退到组名统计。 +- 新战斗开始前必须清场。 +- `ResetRuntime()` 必须清空地图相关运行态引用和 id。 + +--- + +## 10. 快速阅读路径(新人 10 分钟) + +1. `CombatNodeComponent.StartCombat()` +2. `CombatScheduler.Start() / OnUpdate() / BeginNextPhase()` +3. `EnemyManager.BeginPhase() / OnUpdate() / SpawnEnemies()` +4. `MapEntity.TryFindPathWorldPoints()` +5. `EnemyEntity.DespawnOnReachHouse()` + +这 5 个点读完,基本能覆盖 90% 战斗节点行为。