- 调整优化了 CombatNodeComponent 相关实体的生命周期管理

- 总结了一份 CombatNodeComponent 文档 CombatNodeArchitecture.md
This commit is contained in:
SepComet 2026-02-28 20:20:02 +08:00
parent dfd37778b6
commit 26dc1a5600
10 changed files with 364 additions and 73 deletions

View File

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

View File

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

View File

@ -23,9 +23,9 @@ namespace GeometryTD.CustomComponent
Failed = 4
}
private readonly List<DRLevelPhase> _phaseBuffer = new List<DRLevelPhase>();
private readonly Dictionary<int, IReadOnlyList<DRLevelSpawnEntry>> _spawnEntriesByPhaseId =
new Dictionary<int, IReadOnlyList<DRLevelSpawnEntry>>();
private readonly List<DRLevelPhase> _phaseBuffer = new();
private readonly Dictionary<int, IReadOnlyList<DRLevelSpawnEntry>> _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)

View File

@ -29,6 +29,8 @@ namespace GeometryTD.CustomComponent
private readonly Dictionary<int, Spawner> _spawnerByOrder = new Dictionary<int, Spawner>();
private readonly List<Vector3> _pathBuffer = new List<Vector3>();
private readonly List<SpawnEntryRuntime> _spawnRuntimes = new List<SpawnEntryRuntime>();
private readonly HashSet<int> _trackedEnemyEntityIds = new HashSet<int>();
private readonly List<int> _trackedEnemyIdBuffer = new List<int>();
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)
@ -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 (ne.EntityLogicType == typeof(EnemyEntity))
if (!(e is ShowEntitySuccessEventArgs ne)) return;
if (ne.EntityLogicType == typeof(EnemyEntity) &&
_trackedEnemyEntityIds.Contains(ne.Entity.Id))
{
_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;
}

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using GameFramework.DataTable;
using GeometryTD.CustomEvent;
using GeometryTD.DataTable;
using GeometryTD.Definition;
using Newtonsoft.Json.Linq;
@ -75,6 +76,13 @@ namespace GeometryTD.CustomComponent
_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)

View File

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

View File

@ -118,7 +118,6 @@ namespace GeometryTD.UI
}
m_UseCase.SelectOption(args.SelectedItemId);
CloseUI();
}
}
}

View File

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

View File

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

View File

@ -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% 战斗节点行为。