Update CombatNodeArchitecture.md

This commit is contained in:
SepComet 2026-03-06 21:02:40 +08:00
parent 8a478982f8
commit 34446ae42a
5 changed files with 694 additions and 229 deletions

View File

@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using GeometryTD.CustomUtility;
using GeometryTD.Definition;
using GeometryTD.UI;
using UnityEngine;
namespace GeometryTD.Entity
{
public sealed class CombatSelectUseCaseConfigurator
{
private const int BuildOptionCount = 4;
private readonly BuildTowerVisualInfo[] _buildTowerVisualInfos = new BuildTowerVisualInfo[BuildOptionCount];
public void Configure(
CombatSelectFormUseCase useCase,
Func<int> coinProvider,
Func<int, Func<bool>> buildActionFactory,
Func<bool> upgradeAction,
int upgradeCost,
Func<bool> destroyAction,
int destroyGain,
int[] buildTowerCosts,
int currentBuildTowerCount,
BackpackInventoryData inventorySnapshot,
IReadOnlyList<TowerItemData> participantTowers)
{
if (useCase == null)
{
return;
}
useCase.SetCoinProvider(coinProvider);
Dictionary<long, MuzzleCompItemData> muzzleMap = BuildComponentMap(inventorySnapshot?.MuzzleComponents);
Dictionary<long, BearingCompItemData> bearingMap = BuildComponentMap(inventorySnapshot?.BearingComponents);
Dictionary<long, BaseCompItemData> baseMap = BuildComponentMap(inventorySnapshot?.BaseComponents);
int availableBuildCount = Mathf.Clamp(currentBuildTowerCount, 0, BuildOptionCount);
for (int i = 0; i < BuildOptionCount; i++)
{
bool isBuildAvailable = i < availableBuildCount;
BuildTowerVisualInfo buildVisual = ResolveBuildTowerVisual(participantTowers, i, muzzleMap, bearingMap, baseMap);
_buildTowerVisualInfos[i] = buildVisual;
useCase.SetBuildAction(
i,
isBuildAvailable && buildActionFactory != null ? buildActionFactory.Invoke(i) : null,
GetBuildTowerCost(buildTowerCosts, i),
null,
isBuildAvailable,
buildVisual.BaseColor,
buildVisual.BearingColor,
buildVisual.MuzzleColor);
useCase.SetBuildVisible(i, isBuildAvailable);
}
useCase.SetUpgradeAction(upgradeAction, Mathf.Max(0, upgradeCost));
useCase.SetDestroyAction(destroyAction, Mathf.Max(0, destroyGain));
}
public BuildTowerVisualInfo GetBuildVisualInfo(int buildIndex)
{
if (buildIndex < 0 || buildIndex >= _buildTowerVisualInfos.Length)
{
return BuildTowerVisualInfo.Default;
}
return _buildTowerVisualInfos[buildIndex];
}
private static int GetBuildTowerCost(int[] buildTowerCosts, int buildIndex)
{
if (buildTowerCosts == null || buildIndex < 0 || buildIndex >= buildTowerCosts.Length)
{
return 0;
}
return Mathf.Max(0, buildTowerCosts[buildIndex]);
}
private static BuildTowerVisualInfo ResolveBuildTowerVisual(
IReadOnlyList<TowerItemData> participantTowers,
int buildIndex,
IReadOnlyDictionary<long, MuzzleCompItemData> muzzleMap,
IReadOnlyDictionary<long, BearingCompItemData> bearingMap,
IReadOnlyDictionary<long, BaseCompItemData> baseMap)
{
if (participantTowers == null || buildIndex < 0 || buildIndex >= participantTowers.Count)
{
return BuildTowerVisualInfo.Default;
}
TowerItemData tower = participantTowers[buildIndex];
if (tower == null)
{
return BuildTowerVisualInfo.Default;
}
Color muzzleColor = ResolveComponentColor(muzzleMap, tower.MuzzleComponentInstanceId);
Color bearingColor = ResolveComponentColor(bearingMap, tower.BearingComponentInstanceId);
Color baseColor = ResolveComponentColor(baseMap, tower.BaseComponentInstanceId);
return new BuildTowerVisualInfo(muzzleColor, bearingColor, baseColor);
}
private static Color ResolveComponentColor<TComp>(IReadOnlyDictionary<long, TComp> componentMap, long componentId)
where TComp : TowerCompItemData
{
if (componentMap == null || componentId <= 0)
{
return Color.white;
}
return componentMap.TryGetValue(componentId, out TComp component) && component != null
? IconColorGenerator.GenerateForComponent(component)
: Color.white;
}
private static Dictionary<long, TComp> BuildComponentMap<TComp>(IReadOnlyList<TComp> items)
where TComp : TowerCompItemData
{
Dictionary<long, TComp> map = new Dictionary<long, TComp>();
if (items == null)
{
return map;
}
for (int i = 0; i < items.Count; i++)
{
TComp item = items[i];
if (item == null || item.InstanceId <= 0)
{
continue;
}
map[item.InstanceId] = item;
}
return map;
}
}
public readonly struct BuildTowerVisualInfo
{
public static BuildTowerVisualInfo Default => new BuildTowerVisualInfo(Color.white, Color.white, Color.white);
public BuildTowerVisualInfo(Color muzzleColor, Color bearingColor, Color baseColor)
{
MuzzleColor = muzzleColor;
BearingColor = bearingColor;
BaseColor = baseColor;
}
public Color MuzzleColor { get; }
public Color BearingColor { get; }
public Color BaseColor { get; }
}
}

View File

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using GeometryTD.CustomComponent; using GeometryTD.CustomComponent;
using GeometryTD.CustomUtility;
using GeometryTD.Map; using GeometryTD.Map;
using GeometryTD.Definition; using GeometryTD.Definition;
using GeometryTD.Entity.EntityData; using GeometryTD.Entity.EntityData;
@ -37,7 +36,7 @@ namespace GeometryTD.Entity
private TowerPlacementService _towerPlacementService; private TowerPlacementService _towerPlacementService;
private TowerSelectionPresenter _towerSelectionPresenter; private TowerSelectionPresenter _towerSelectionPresenter;
private readonly BuildTowerVisualInfo[] _buildTowerVisualInfos = new BuildTowerVisualInfo[4]; private CombatSelectUseCaseConfigurator _combatSelectUseCaseConfigurator;
public IReadOnlyList<Vector3Int> PathCells => _mapTopologyService != null public IReadOnlyList<Vector3Int> PathCells => _mapTopologyService != null
? _mapTopologyService.PathCells ? _mapTopologyService.PathCells
@ -102,6 +101,7 @@ namespace GeometryTD.Entity
} }
InitializeCombatSelectUseCase(); InitializeCombatSelectUseCase();
InitializeCombatSelectUseCaseConfigurator();
InitializeCombatSelectInputService(); InitializeCombatSelectInputService();
InitializeMapTopologyService(); InitializeMapTopologyService();
InitializeTowerPlacementService(); InitializeTowerPlacementService();
@ -127,7 +127,9 @@ namespace GeometryTD.Entity
{ {
HideCombatSelectForm(); HideCombatSelectForm();
_towerPlacementService?.HideAndClearAllPlacedTowers(); _towerPlacementService?.HideAndClearAllPlacedTowers();
ClearRuntimeData(); ClearSelectionState();
ClearTowerTracking();
ClearMapTopology();
base.OnHide(isShutdown, userData); base.OnHide(isShutdown, userData);
} }
@ -139,7 +141,9 @@ namespace GeometryTD.Entity
private void RefreshTiles() private void RefreshTiles()
{ {
ClearRuntimeData(); ClearMapTopology();
ClearTowerTracking();
ClearSelectionState();
if (_mapDataRefs == null) if (_mapDataRefs == null)
{ {
@ -161,10 +165,18 @@ namespace GeometryTD.Entity
_mapTopologyService?.Refresh(tilemap, Spawners, House, name, _mapData != null ? _mapData.LevelId : 0); _mapTopologyService?.Refresh(tilemap, Spawners, House, name, _mapData != null ? _mapData.LevelId : 0);
} }
private void ClearRuntimeData() private void ClearMapTopology()
{ {
_mapTopologyService?.Clear(); _mapTopologyService?.Clear();
}
private void ClearTowerTracking()
{
_towerPlacementService?.ClearTracking(); _towerPlacementService?.ClearTracking();
}
private void ClearSelectionState()
{
_towerSelectionPresenter?.ClearSelectedObject(); _towerSelectionPresenter?.ClearSelectedObject();
} }
@ -178,6 +190,14 @@ namespace GeometryTD.Entity
GameEntry.UIRouter.BindUIUseCase(UIFormType.CombatSelectForm, _combatSelectFormUseCase); GameEntry.UIRouter.BindUIUseCase(UIFormType.CombatSelectForm, _combatSelectFormUseCase);
} }
private void InitializeCombatSelectUseCaseConfigurator()
{
if (_combatSelectUseCaseConfigurator == null)
{
_combatSelectUseCaseConfigurator = new CombatSelectUseCaseConfigurator();
}
}
private void InitializeTowerSelectionPresenter() private void InitializeTowerSelectionPresenter()
{ {
if (_towerSelectionPresenter == null) if (_towerSelectionPresenter == null)
@ -212,11 +232,6 @@ namespace GeometryTD.Entity
private void ConfigureCombatSelectUseCase() private void ConfigureCombatSelectUseCase()
{ {
if (_combatSelectFormUseCase == null)
{
return;
}
_combatSelectFormUseCase.SetCoinProvider(GetCurrentCoin); _combatSelectFormUseCase.SetCoinProvider(GetCurrentCoin);
BackpackInventoryData inventorySnapshot = GameEntry.PlayerInventory != null BackpackInventoryData inventorySnapshot = GameEntry.PlayerInventory != null
? GameEntry.PlayerInventory.GetInventorySnapshot() ? GameEntry.PlayerInventory.GetInventorySnapshot()
@ -224,32 +239,18 @@ namespace GeometryTD.Entity
IReadOnlyList<TowerItemData> participantTowers = GameEntry.PlayerInventory != null IReadOnlyList<TowerItemData> participantTowers = GameEntry.PlayerInventory != null
? GameEntry.PlayerInventory.GetParticipantTowerSnapshot() ? GameEntry.PlayerInventory.GetParticipantTowerSnapshot()
: null; : null;
Dictionary<long, MuzzleCompItemData> muzzleMap = BuildComponentMap(inventorySnapshot?.MuzzleComponents); _combatSelectUseCaseConfigurator?.Configure(
Dictionary<long, BearingCompItemData> bearingMap = BuildComponentMap(inventorySnapshot?.BearingComponents); _combatSelectFormUseCase,
Dictionary<long, BaseCompItemData> baseMap = BuildComponentMap(inventorySnapshot?.BaseComponents); GetCurrentCoin,
buildIndex => () => TryBuildTower(buildIndex),
int currentBuildTowerCount = GetCurrentBuildTowerCount(); TryUpgradeTower,
for (int i = 0; i < 4; i++) _upgradeCost,
{ TryDestroyTower,
int buildIndex = i; _destroyGain,
int buildCost = GetBuildTowerCost(buildIndex); _buildTowerCosts,
bool isBuildAvailable = buildIndex < currentBuildTowerCount; GetCurrentBuildTowerCount(),
BuildTowerVisualInfo buildVisual = ResolveBuildTowerVisual(participantTowers, buildIndex, muzzleMap, bearingMap, baseMap); inventorySnapshot,
_buildTowerVisualInfos[buildIndex] = buildVisual; participantTowers);
_combatSelectFormUseCase.SetBuildAction(
buildIndex,
isBuildAvailable ? () => TryBuildTower(buildIndex) : (Func<bool>)null,
buildCost,
null,
isBuildAvailable,
buildVisual.BaseColor,
buildVisual.BearingColor,
buildVisual.MuzzleColor);
_combatSelectFormUseCase.SetBuildVisible(buildIndex, isBuildAvailable);
}
_combatSelectFormUseCase.SetUpgradeAction(TryUpgradeTower, Mathf.Max(0, _upgradeCost));
_combatSelectFormUseCase.SetDestroyAction(TryDestroyTower, Mathf.Max(0, _destroyGain));
} }
private void HandleCombatSelectInput() private void HandleCombatSelectInput()
@ -306,8 +307,8 @@ namespace GeometryTD.Entity
return false; return false;
} }
BuildTowerVisualInfo buildVisual = buildIndex >= 0 && buildIndex < _buildTowerVisualInfos.Length BuildTowerVisualInfo buildVisual = _combatSelectUseCaseConfigurator != null
? _buildTowerVisualInfos[buildIndex] ? _combatSelectUseCaseConfigurator.GetBuildVisualInfo(buildIndex)
: BuildTowerVisualInfo.Default; : BuildTowerVisualInfo.Default;
if (!_towerPlacementService.TryBuildTower(selectedFoundationCell, IsFoundationCell, buildIndex, _buildTowerCosts, if (!_towerPlacementService.TryBuildTower(selectedFoundationCell, IsFoundationCell, buildIndex, _buildTowerCosts,
@ -375,16 +376,6 @@ namespace GeometryTD.Entity
GameEntry.UIRouter.CloseUI(UIFormType.CombatSelectForm); GameEntry.UIRouter.CloseUI(UIFormType.CombatSelectForm);
} }
private int GetBuildTowerCost(int buildIndex)
{
if (_buildTowerCosts == null || buildIndex < 0 || buildIndex >= _buildTowerCosts.Length)
{
return 0;
}
return Mathf.Max(0, _buildTowerCosts[buildIndex]);
}
private static int GetCurrentBuildTowerCount() private static int GetCurrentBuildTowerCount()
{ {
if (GameEntry.CombatNode == null) if (GameEntry.CombatNode == null)
@ -395,67 +386,6 @@ namespace GeometryTD.Entity
return Mathf.Clamp(GameEntry.CombatNode.CurrentBuildTowerCount, 0, 4); return Mathf.Clamp(GameEntry.CombatNode.CurrentBuildTowerCount, 0, 4);
} }
private static BuildTowerVisualInfo ResolveBuildTowerVisual(
IReadOnlyList<TowerItemData> participantTowers,
int buildIndex,
IReadOnlyDictionary<long, MuzzleCompItemData> muzzleMap,
IReadOnlyDictionary<long, BearingCompItemData> bearingMap,
IReadOnlyDictionary<long, BaseCompItemData> baseMap)
{
if (participantTowers == null || buildIndex < 0 || buildIndex >= participantTowers.Count)
{
return BuildTowerVisualInfo.Default;
}
TowerItemData tower = participantTowers[buildIndex];
if (tower == null)
{
return BuildTowerVisualInfo.Default;
}
Color muzzleColor = ResolveComponentColor(muzzleMap, tower.MuzzleComponentInstanceId);
Color bearingColor = ResolveComponentColor(bearingMap, tower.BearingComponentInstanceId);
Color baseColor = ResolveComponentColor(baseMap, tower.BaseComponentInstanceId);
return new BuildTowerVisualInfo(muzzleColor, bearingColor, baseColor);
}
private static Color ResolveComponentColor<TComp>(IReadOnlyDictionary<long, TComp> componentMap, long componentId)
where TComp : TowerCompItemData
{
if (componentMap == null || componentId <= 0)
{
return Color.white;
}
return componentMap.TryGetValue(componentId, out TComp component) && component != null
? IconColorGenerator.GenerateForComponent(component)
: Color.white;
}
private static Dictionary<long, TComp> BuildComponentMap<TComp>(IReadOnlyList<TComp> items)
where TComp : TowerCompItemData
{
Dictionary<long, TComp> map = new Dictionary<long, TComp>();
if (items == null)
{
return map;
}
for (int i = 0; i < items.Count; i++)
{
TComp item = items[i];
if (item == null || item.InstanceId <= 0)
{
continue;
}
map[item.InstanceId] = item;
}
return map;
}
private static bool TryConsumeCoin(int cost) private static bool TryConsumeCoin(int cost)
{ {
int requiredCoin = Mathf.Max(0, cost); int requiredCoin = Mathf.Max(0, cost);
@ -477,22 +407,6 @@ namespace GeometryTD.Entity
return GameEntry.CombatNode != null ? Mathf.Max(0, GameEntry.CombatNode.CurrentCoin) : 0; return GameEntry.CombatNode != null ? Mathf.Max(0, GameEntry.CombatNode.CurrentCoin) : 0;
} }
private readonly struct BuildTowerVisualInfo
{
public static BuildTowerVisualInfo Default => new BuildTowerVisualInfo(Color.white, Color.white, Color.white);
public BuildTowerVisualInfo(Color muzzleColor, Color bearingColor, Color baseColor)
{
MuzzleColor = muzzleColor;
BearingColor = bearingColor;
BaseColor = baseColor;
}
public Color MuzzleColor { get; }
public Color BearingColor { get; }
public Color BaseColor { get; }
}
private static void AddCoin(int coin) private static void AddCoin(int coin)
{ {
int amount = Mathf.Max(0, coin); int amount = Mathf.Max(0, coin);

View File

@ -1,127 +1,497 @@
# CombatNode Architecture # CombatNode 设计规范(开发约束)
最后更新2026-03-02 最后更新2026-03-06
## 1. 总览 ## 1. 适用范围与目标
CombatNode 当前分为三层:
- `CombatNodeComponent`:入口与关卡数据装配。
- `CombatScheduler`:战斗状态机与阶段推进,以及关卡内资源结算。
- `EnemyManager`:敌人系统 Facade对外接口保持稳定内部由多个子服务协作
本文重点记录 `EnemyManager``CombatScheduler` 的职责边界(尤其资源管理收口后的模型) 本文描述 `CombatNode` 域的后续开发标准。
## 2. EnemyManager 相关类 说明:
- 本文是“目标架构约束”,不要求当前代码已经完全达成。
- 后续新增功能、重构、拆分类、review 职责边界时,以本文为准。
- 如果当前实现与本文不一致,新增代码优先向本文收敛,而不是继续扩大旧结构。
核心目标:
- `CombatScheduler` 收敛为“状态机管理器”,不再继续堆积加载、结算、奖励选择等业务细节。
- 战斗内资源收口到独立资源服务,由内部管理,不再由 `CombatNodeComponent` 直接持有真值。
- `MapEntity` 通过 `MapData + Event` 获取战斗上下文,不反查 `CombatNode` 域内部状态。
---
## 2. 架构总览
### 2.1 CombatNodeComponent入口 Facade
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs`
长期职责:
- 读取并缓存 `DRLevel / DRLevelPhase / DRLevelSpawnEntry`
- 按主题筛选关卡。
- 启动/停止 `CombatScheduler`
- 对外暴露只读运行时属性。
- 提供少量用户入口,例如 `StartCombat`、`TryEndCombatByPlayer`。
长期不负责:
- 不直接持有 `Coin / Gold / BaseHp / Loot Backpack` 的真值。
- 不直接缓存本局建塔属性快照。
- 不直接发布战斗流程事件。
- 不直接处理敌人掉落、结算、奖励选择、地图加载。
### 2.2 CombatScheduler状态机管理器
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
长期职责:
- 持有共享运行时数据与共享服务实例。
- 管理状态实例。
- 提供统一的 `ChangeState(...)` 状态迁移入口。
- 提供敌人事件的公共处理入口。
- 作为状态机生命周期边界,统一做运行时重置。
长期不负责:
- 不直接硬编码加载流程。
- 不直接硬编码结算流程。
- 不直接硬编码奖励选择 UI 逻辑。
- 不直接硬编码 `PhaseEndType` 结束条件。
推荐状态类命名:
- `CombatLoadingState`
- `CombatRunningPhaseState`
- `CombatWaitingForPhaseEndState`
- `CombatSettlementState`
- `CombatRewardSelectionState`
- `CombatFinishFormState`
- `CombatWaitingForReturnState`
- `CombatFailedState`
实现约束:
- 上述状态类作为 `CombatScheduler` 的嵌套类实现。
- 共享数据与共享服务统一放在 `CombatScheduler` 上。
- 所有状态切换只能通过 `CombatScheduler.ChangeState(...)` 完成。
- 状态类不能彼此直接操控。
### 2.3 EnemyManager敌人域 Facade
### 2.1 EnemyManagerFacade
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyManager.cs` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyManager.cs`
长期职责:
- 对状态机提供统一敌人域接口。
- 编排敌人子服务。
- 暴露只读事实:
- `AliveEnemyCount`
- `IsPhaseSpawnCompleted`
- `HasAliveBoss`
- 在敌人死亡或到家时,通过公共入口向 `CombatScheduler` 上报:
- `OnEnemyDefeated(DREnemy enemy)`
- `OnEnemyReachedBase(DREnemy enemy)`
长期不负责:
- 不直接给资源入账。
- 不直接扣基地血量。
- 不直接决定状态切换。
---
## 3. 状态机模型
### 3.1 状态列表(目标)
- `Loading`
- `RunningPhase`
- `WaitingForPhaseEnd`
- `Settlement`
- `RewardSelection`
- `FinishForm`
- `WaitingForReturn`
- `Failed`
说明:
- 正常结束流只有一条状态链:
- `Settlement -> RewardSelection(可选) -> FinishForm -> WaitingForReturn`
- 正常通关、玩家主动结束、基地血量归零都走同一条结束链。
- `Failed` 仅用于异常失败,不用于“基地被击破”这类正常战斗失败。
### 3.2 CombatLoadingState
职责: 职责:
- 对 `CombatScheduler` 提供统一接口:`OnInit / BeginPhase / OnUpdate / EndPhase / OnDestroy`。 - 通过 `CombatLoadSession` 执行地图与基础战斗 UI 加载。
- 编排敌人域子服务,不承载具体业务细节。 - 从局内资源管理器读取本局快照。
- 转发状态:`AliveEnemyCount`、`IsPhaseRunning`、`IsPhaseSpawnCompleted`。 - 组装 `MapData` 并发起 `ShowEntity(MapEntity)`
- 处理敌人实体事件Show/Hide后的结果上报
- 击杀:上报 `coin/gold``CombatScheduler.OnEnemyDefeatedRewardResolved(...)` 约束:
- 到家:上报 `baseDamage``CombatScheduler.OnEnemyReachedBase(...)` - 只负责加载,不负责初始化局内资源。
- 局内资源必须在进入状态机前初始化完成。
### 3.3 CombatRunningPhaseState
职责:
- 执行当前 `DRLevelPhase` 的行为。
- 推进 `SpawnEntry` 时序与出怪。
- 管理 `EnemySpawnDirector` 的阶段级初始化与重置。
- 在新 phase 开始时发布:
- `CombatProcessEventArgs`
- `CombatEnemyHpRateChangedEventArgs`
退出条件:
- 当前 phase 的所有 `SpawnEntry` 已执行完毕时,进入 `WaitingForPhaseEnd`
- 若共享“结束战斗请求标记”已置位,也可结束当前运行态并转入正常结束链。
不负责: 不负责:
- 不直接维护刷怪时间轴。 - 不根据 `PhaseEndType` 判断 phase 是否真正结束。
- 不直接维护出生点缓存与路径查询。 - 不直接根据 `BaseHp` 或敌人死亡事件切状态。
- 不直接维护敌人追踪结构。
- 不直接维护掉落池抽样与背包库存。 ### 3.4 CombatWaitingForPhaseEndState
- 不直接维护敌人配置兜底和血量倍率算法。
职责:
- 不再生成新敌人。
- 根据 `PhaseEndType` 判断当前 phase 是否结束。
约束:
- `PhaseEndType` 的判断由独立判定服务负责,不在状态内硬编码。
- 每种 `PhaseEndType` 对应一个实现类。
- 该判定服务为本状态专用,不作为全局共享服务常驻在 `CombatScheduler` 上。
### 3.5 CombatSettlementState
职责:
- 进入时统一构造结算上下文。
- 根据共享资源状态完成结算修正。
- 决定后续进入 `RewardSelection` 还是 `FinishForm`
负责的逻辑包括:
- 基地血量奖励/惩罚。
- 满血奖励选择的准入判断。
- 生成最终展示摘要。
- 准备待合并的结算背包快照。
约束:
- 不依赖单独的 `CombatEndReason` 字段。
- `BaseHp <= 0` 表示基地被击破。
- 正常通关与玩家主动结束在结算产出上不区分原因。
### 3.6 CombatRewardSelectionState
职责:
- 绑定、配置、打开、关闭 `RewardSelectForm`
- 处理奖励选择过程。
- 将奖励选择结果写入“结束状态链持有的结算上下文”。
约束:
- 不重新判断“是否应该出现奖励选择”。
- 只处理选择过程本身。
### 3.7 CombatFinishFormState
职责:
- 绑定、配置、打开、关闭 `CombatFinishForm`
- 读取结算上下文并展示最终结算结果。
### 3.8 CombatWaitingForReturnState
职责:
- 等待玩家从结算返回。
- 完成地图与战斗基础 UI 清理。
- 完成正常退出收尾。
- 在整场战斗真正退出时发布 `NodeCompleteEventArgs`
### 3.9 CombatFailedState
职责:
- 表示异常失败。
- 保存并展示错误信息。
- 执行异常收尾与剩余资源回收。
约束:
- `Failed` 只处理异常路径。
- “基地血量为 0”不进入 `Failed`
---
## 4. 共享服务与推荐命名
### 4.1 CombatLoadSession
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatLoadSession.cs`
长期定位:
- 长期保留的独立加载服务。
- 专门负责地图与战斗内基础 UI 的加载/清理。
职责:
- 加载地图实体。
- 打开/关闭 `CombatInfoForm`
- 跟踪加载成功/失败状态。
- 对外提供 `CurrentMap``IsReady`
### 4.2 PhaseLoopRuntime
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseLoopRuntime.cs`
长期定位:
- 长期保留的独立 phase runtime 服务。
职责:
- 维护当前 `DRLevelPhase`
- 维护 `DisplayPhaseIndex`、`PhaseCount`。
- 维护统一的 phase 时间基准,例如 `phaseElapsedTime``phaseStartTime`
- 负责进入下一 phase。
- 持有统一“请求结束战斗”标记。
约束:
- 只做 phase 运行时数据管理,不直接切状态。
### 4.3 CombatInRunResourceManager推荐命名
当前相关文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatResourceManager.cs`
目标职责:
- 持有本局 `Coin` 真值。
- 持有本局累计 `Gold` 真值。
- 持有本局 `BaseHp` 真值。
- 持有本局战利品背包。
- 持有本局建塔属性快照。
- 提供只读快照给结束状态链与加载状态使用。
- 发布资源变化事件:
- `CombatCoinChangedEventArgs`
- `CombatBaseHpChangedEventArgs`
初始化约束:
- 在进入状态机前完成初始化。
- 由内部从 `PlayerInventory` 获取并缓存本局建塔快照。
事件约束:
- `Coin / BaseHp` 变化事件同时携带“当前值”和“变化量”。
- `Gold` 只是结算累计值,不要求战斗内实时事件驱动。
### 4.4 EnemyDropResolver推荐命名
当前相关文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatResourceManager.cs`
目标职责:
- 只负责敌人死亡后的掉落判定。
- 输入:
- `DREnemy`
- 当前阶段索引
- 当前主题或关卡上下文
- 输出:
- 掉落结果对象(`coin / gold / loot`
约束:
- 不直接修改资源状态。
- 不直接读取 `CombatNodeComponent`、`MapEntity`、`EnemyManager` 内部状态。
### 4.5 IPhaseEndCondition推荐命名
目标职责:
- 作为 `PhaseEndType` 判定接口。
- 每种 `PhaseEndType` 对应一个实现类。
只读输入:
- 当前 `DRLevelPhase`
- phase 时间信息
- `AliveEnemyCount`
- `IsPhaseSpawnCompleted`
- `HasAliveBoss`
输出:
- `bool ShouldExit`
约束:
- 不直接切状态。
- 不直接发事件。
- 不直接改资源。
---
## 5. EnemyManager 子服务边界
### 5.1 EnemySpawnDirector
### 2.2 EnemySpawnDirector刷怪时序
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemySpawnDirector.cs` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemySpawnDirector.cs`
职责: 职责:
- 管理 `DRLevelSpawnEntry` 的运行时实例(`SpawnEntryRuntime`)。 - 长期保留为独立服务。
- 按时间推进 `Stream / Burst / Boss` 生成逻辑。 - 基于 `spawnEntries + phase time` 计算当前应执行的刷怪行为。
- 输出“当前应生成多少敌人”,通过回调交给 `EnemyManager.SpawnEnemies` 执行。 - 提供“当前 phase 的 `SpawnEntry` 是否已全部执行完”的事实。
- 维护阶段刷怪状态:`IsPhaseRunning`、`IsPhaseSpawnCompleted`。
生命周期:
- 由 `CombatRunningPhaseState` 在状态进入/退出时初始化与重置。
### 5.2 SpawnerResolver
### 2.3 SpawnerResolver出生点与路径
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/SpawnerResolver.cs` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/SpawnerResolver.cs`
职责: 职责:
- 缓存当前地图可用 `Spawner` - 缓存当前地图可用 `Spawner`
- 支持 `SpawnOrder` 映射与 fallback 轮询。 - 提供出生点与路径解析
- 对外提供 `TryResolveSpawnPath(...)`,返回世界坐标路径点。
- 在地图切换时刷新缓存,避免每次刷怪全量扫描。 ### 5.3 EnemyLifecycleTracker
### 2.4 EnemyLifecycleTracker敌人生命周期追踪
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyLifecycleTracker.cs` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyLifecycleTracker.cs`
职责: 职责:
- 追踪本局敌人 `entityId -> DREnemy` - 维护 `AliveEnemyCount` 真值。
- 维护 `AliveEnemyCount` - 维护 `HasAliveBoss` 真值。
- 处理 `ShowSuccess / ShowFailure / HideComplete` 对追踪状态的变更。 - 追踪本局 tracked 敌人。
- 提供批量导出 tracked ids用于 `CleanupTrackedEnemies` 清场。 - 导出 tracked ids 供清场使用。
Boss 识别规则:
- Boss 身份由 `DRLevelSpawnEntry.EntryType == Boss` 决定。
- 不由 `DREnemy` 自身类型决定。
### 5.4 EnemyConfigService
### 2.5 EnemyConfigService敌人配置与倍率
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyConfigService.cs` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyConfigService.cs`
职责: 职责:
- 读取 `DREnemy`,处理默认配置兜底。 - 读取 `DREnemy`
- 计算循环周目下的基础血量倍率(按 `displayPhaseIndex / phaseCount` 推导 loop - 处理默认配置兜底
- 缓存数据表引用并在 `Reset` 时清理。 - 计算循环周目下的基础血量倍率
## 3. CombatScheduler 资源收口 ---
### 3.1 CombatResourceManager关卡内资源统一管理 ## 6. 事件与数据流规范
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatResourceManager.cs`
职责: ### 6.1 MapEntity 与 Combat 域解耦
- 统一维护关卡内资源状态:
- `GainedCoin`、`GainedGold`
- `BackpackInventoryData`(结算背包快照)
- 处理击杀奖励入账:`AddEnemyDefeatedReward(...)`
- 处理局外掉落抽样与物品构建:`TryRollOutGameItemDrop(...)`
- 对结算 UI 提供只读快照:`GetRewardInventorySnapshot()`
### 3.2 CombatScheduler 与资源类关系 必须保持:
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs` - `MapEntity` 不直接查询 `CombatNodeComponent` 的运行时资源字段。
- 战斗初始上下文通过 `MapData` 注入。
- `Coin` 初值通过 `MapData` 传入。
- 后续 `Coin` 变化通过 `CombatCoinChangedEventArgs` 同步。
- `TowerStatsData` 等本局不变量直接放进 `MapData`
- `MapEntity` 不反查 Combat 域内部服务。
当前模型: `MapData` 组装规则:
- `CombatScheduler` 持有 `_combatResourceManager` - 由 `CombatLoadingState` 从局内资源管理器读取快照。
- `GainedCoin/GainedGold` 属性透传资源类。 - 由 `CombatLoadingState` 打包成 `MapData` 后再 `ShowEntity(MapEntity)`
- `OnEnemyDefeatedRewardResolved(...)` 统一触发:
- 货币入账
- 关卡内掉落判定
- `EnterFinishFlow(...)` 从资源类取 `BackpackInventoryData` 作为结算数据。
## 4. 协作流程(简版) ### 6.2 敌人事件处理
1. `CombatScheduler.BeginNextPhase()``EnemyManager.BeginPhase(...)`
2. `EnemyManager` 刷新 `SpawnerResolver` 缓存,并通知 `EnemySpawnDirector.BeginPhase(...)`
3. 每帧 `EnemyManager.OnUpdate(...)`
- `SpawnerResolver.RefreshCache(...)`
- `EnemySpawnDirector.OnUpdate(..., SpawnEnemies)`
4. `SpawnEnemies(...)` 执行实际生成:
- `SpawnerResolver.TryResolveSpawnPath(...)`
- `EnemyConfigService.GetEnemyConfig(...)`
- `EnemyConfigService.ResolveScaledEnemyBaseHp(...)`
- `EnemyLifecycleTracker.TrackEnemy(...)`
- `GameEntry.Entity.ShowEnemy(...)`
5. 敌人回收时 `EnemyManager.OnHideEntityComplete(...)`
- `EnemyLifecycleTracker.TryHandleHideComplete(...)`
- 若击杀:上报 `coin/gold``CombatScheduler.OnEnemyDefeatedRewardResolved(...)`
- 若到家:上报 `baseDamage``CombatScheduler.OnEnemyReachedBase(...)`
6. `CombatScheduler.OnEnemyDefeatedRewardResolved(...)`
- `CombatResourceManager.AddEnemyDefeatedReward(...)`
- `CombatResourceManager.TryRollOutGameItemDrop(...)`
7. `CombatScheduler.EnterFinishFlow(...)`
- 读取 `CombatResourceManager.GetRewardInventorySnapshot()`
- 打开结算 UI
## 5. 关键不变量 统一边界:
- 存活敌人数以 `EnemyLifecycleTracker.AliveEnemyCount` 为唯一真值来源。 - `EnemyManager` 只上报:
- 刷怪阶段状态以 `EnemySpawnDirector` 为唯一真值来源。 - `OnEnemyDefeated(DREnemy enemy)`
- 关卡内资源以 `CombatResourceManager` 为唯一真值来源。 - `OnEnemyReachedBase(DREnemy enemy)`
- `EnemyManager` 只做敌人域编排与结果上报,不再持有奖励库存。 - `CombatScheduler` 公共层负责处理敌人事件的通用副作用:
- 清场必须只作用于本局 tracked 敌人,避免误伤其他实体。 - 击杀:调用 `EnemyDropResolver`,再调用局内资源管理器入账。
- 到家:调用局内资源管理器扣减 `BaseHp`
## 6. 维护建议 约束:
- 新增刷怪类型:优先改 `EnemySpawnDirector` - 敌人事件入口不直接调用 `ChangeState(...)`
- 新增路径/出生规则:优先改 `SpawnerResolver` - `BaseHp <= 0` 的判断由当前状态在 `OnUpdate` 中处理。
- 新增敌人追踪策略:优先改 `EnemyLifecycleTracker`
- 新增敌人配置兜底或倍率策略:优先改 `EnemyConfigService` ### 6.3 战斗流程事件
- 新增货币/掉落/结算背包规则:优先改 `CombatResourceManager`
发布边界:
- 资源变化事件由局内资源管理器发布。
- 流程/阶段事件由状态机或具体状态发布。
发布时间:
- `NodeEnterEventArgs``Loading` 完成并正式进入首个 `RunningPhase` 时。
- `CombatProcessEventArgs`:新 phase 的 `RunningPhase.OnEnter`
- `CombatEnemyHpRateChangedEventArgs`:与 `CombatProcessEventArgs` 同时发布。
- `NodeCompleteEventArgs``WaitingForReturn` 完成清理、整场战斗真正退出时。
---
## 7. 结束链与结算上下文
### 7.1 统一结束链
正常结束统一走:
- `Settlement`
- `RewardSelection`(可选)
- `FinishForm`
- `WaitingForReturn`
### 7.2 结算上下文
归属:
- 作为 `CombatScheduler` 上的共享字段存在。
- `Settlement``OnEnter` 时统一构造。
- `RewardSelection` 只追加奖励结果。
- `FinishForm``WaitingForReturn` 只读取。
最小字段集合:
- 最终结算的 `Gold/Coin` 结果
- 待合并的背包快照
- `BaseHp` 结算结果
- 是否进入过奖励选择
- `FinishForm` 所需摘要数据
奖励选择约束:
- 满血奖励选择结果只写入结算上下文。
- 不直接写入局内资源管理器。
- 最终由结束状态链统一合并到玩家背包。
---
## 8. 核心不变量(必须保持)
1. `CombatScheduler` 只做状态机管理与共享运行时收口,不继续吸收具体业务细节。
2. `CombatNodeComponent` 不再持有战斗内资源真值。
3. 局内 `Coin / Gold / BaseHp / Loot Backpack / BuildTowerSnapshots``CombatInRunResourceManager` 为唯一真值来源。
4. 敌人死亡掉落判定以 `EnemyDropResolver` 为唯一判定入口。
5. 存活敌人数与 `HasAliveBoss``EnemyLifecycleTracker` 为唯一真值来源。
6. Phase 运行时信息与统一结束标记以 `PhaseLoopRuntime` 为唯一真值来源。
7. `PhaseEndType` 的退出条件以 `IPhaseEndCondition` 实现类为唯一判定入口。
8. 状态切换只能通过 `CombatScheduler.ChangeState(...)` 完成。
9. 敌人事件处理入口不直接切状态,状态只能在自己的 `OnUpdate` 中决定迁移。
10. `MapEntity` 通过 `MapData + Event` 获取战斗上下文,不反查 Combat 域内部运行时。
---
## 9. 清理职责
- 敌人清理:`EnemyManager`,且只清理本局 tracked 敌人。
- 地图与战斗基础 UI 清理:`CombatLoadSession`。
- 结算/奖励 UI 清理:结束状态链或 `Failed` 状态。
- 运行时数据重置:`CombatScheduler` 在状态机生命周期边界统一执行。
---
## 10. 扩展开发规范
### 10.1 新增刷怪类型或 SpawnEntry 行为
优先改 `EnemySpawnDirector`,不要把时序细节塞进 `CombatRunningPhaseState`
### 10.2 新增 Phase 结束条件
新增 `IPhaseEndCondition` 实现类,不要在 `CombatWaitingForPhaseEndState` 里写大分支。
### 10.3 新增敌人掉落规则
优先改 `EnemyDropResolver`,不要在 `EnemyManager` 或状态类里直接计算掉落。
### 10.4 新增战斗内资源或建塔快照规则
优先改 `CombatInRunResourceManager`,不要回流到 `CombatNodeComponent`
### 10.5 新增地图/战斗基础 UI 加载规则
优先改 `CombatLoadSession``CombatLoadingState`,不要把加载细节塞回 `CombatScheduler` 本体。
### 10.6 新增强化结算、奖励选择、结算 UI 逻辑
优先改结束状态链:
- `CombatSettlementState`
- `CombatRewardSelectionState`
- `CombatFinishFormState`
- `CombatWaitingForReturnState`
### 10.7 新增战斗流程事件
优先由具体状态或局内资源管理器发布,不要回流到 `CombatNodeComponent`
---
## 11. 代码变更检查清单PR 自检)
1. 新逻辑是否落在正确的状态或服务,而不是继续堆进 `CombatScheduler` 本体?
2. `CombatNodeComponent` 是否仍然保持为轻量入口 Facade
3. 是否破坏了局内资源、掉落判定、phase runtime、phase end 判定的唯一真值来源?
4. 敌人事件处理是否仍然只做公共副作用,而不直接切状态?
5. 状态迁移是否仍然统一走 `ChangeState(...)`
6. `MapEntity` 是否仍然只通过 `MapData + Event` 获取战斗上下文?
7. 清理是否仍按“敌人 / 地图基础 UI / 结算 UI / 运行时数据”分工执行?

View File

@ -1,6 +1,6 @@
# MapEntity 设计规范(开发约束) # MapEntity 设计规范(开发约束)
最后更新2026-03-02 最后更新2026-03-06
## 1. 目标与边界 ## 1. 目标与边界
@ -14,6 +14,7 @@
- 防御塔映射字典的增删改查细节。 - 防御塔映射字典的增删改查细节。
- 选中状态与攻击范围显示细节。 - 选中状态与攻击范围显示细节。
- 鼠标拾取与 `CombatSelectFormUserData` 组装细节。 - 鼠标拾取与 `CombatSelectFormUserData` 组装细节。
- 战斗选择 UI 的库存快照解析、颜色映射与 Build 选项配置细节。
--- ---
@ -28,10 +29,11 @@
- 子服务初始化与清理 - 子服务初始化与清理
- UI 用例绑定(`CombatSelectFormUseCase` - UI 用例绑定(`CombatSelectFormUseCase`
- 将输入结果分发到选择器/建造器 - 将输入结果分发到选择器/建造器
- 收集运行时上下文并委托给专用服务配置战斗选择 UI
### 2.2 MapTopologyService地图拓扑层 ### 2.2 MapTopologyService地图拓扑层
当前文件:`Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs`(同文件内) 文件:`Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs`
职责: 职责:
- 扫描 Tilemap构建 `PathCells` / `FoundationCells` - 扫描 Tilemap构建 `PathCells` / `FoundationCells`
@ -58,7 +60,21 @@
- 只读上下文,不改变游戏状态。 - 只读上下文,不改变游戏状态。
- Foundation/Tower 点击时UI 定位由 Cell 中心决定(稳定定位)。 - Foundation/Tower 点击时UI 定位由 Cell 中心决定(稳定定位)。
### 2.4 TowerPlacementService塔部署层 ### 2.4 CombatSelectUseCaseConfigurator战斗选择 UI 配置层)
文件:`Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectUseCaseConfigurator.cs`
职责:
- 读取库存快照与参战塔快照
- 构建组件映射与塔外观颜色
- 配置 `CombatSelectFormUseCase` 的 Build/Upgrade/Destroy action 与显示参数
- 缓存当前 Build 槽位对应的视觉信息,供建塔流程复用
约束:
- 只负责 UI 选项配置与只读数据组装,不直接改变战斗状态。
- 不处理鼠标输入、地图拓扑、塔实体生命周期。
### 2.5 TowerPlacementService塔部署层
当前文件:`Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs` 当前文件:`Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs`
@ -73,13 +89,13 @@
约束: 约束:
- 仅处理塔生命周期与映射,不处理选中态和 UI。 - 仅处理塔生命周期与映射,不处理选中态和 UI。
### 2.5 TowerSelectionPresenter选择展示层 ### 2.6 TowerSelectionPresenter选择展示层
文件:`Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs` 文件:`Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs`
职责: 职责:
- 维护当前选中对象 - 维护当前选中对象
- 根据选中状态切换攻击范围显示(通过 `DefenseTowerEntity.SetAttackRangeVisible` - 根据选中状态切换攻击范围显示(通过 `TowerEntity.SetAttackRangeVisible`
约束: 约束:
- 不做建造/升级/销毁。 - 不做建造/升级/销毁。
@ -88,12 +104,14 @@
## 3. 运行时主流程(简版) ## 3. 运行时主流程(简版)
1. `MapEntity.OnInit` 初始化 4 个服务 1. `MapEntity.OnInit` 初始化并绑定 6 个对象
- `MapTopologyService` - `MapTopologyService`
- `CombatSelectInputService` - `CombatSelectInputService`
- `CombatSelectUseCaseConfigurator`
- `TowerPlacementService` - `TowerPlacementService`
- `TowerSelectionPresenter` - `TowerSelectionPresenter`
2. `MapEntity.OnShow` 刷新地图拓扑,配置 UI action。 - `CombatSelectFormUseCase`
2. `MapEntity.OnShow` 刷新地图拓扑,配置 UI action 与 Build 视觉数据。
3. 每帧 `OnUpdate` 3. 每帧 `OnUpdate`
- 采集输入InputService - 采集输入InputService
- 更新选中对象SelectionPresenter - 更新选中对象SelectionPresenter
@ -103,8 +121,9 @@
- 通过 SelectionPresenter 同步选中和范围显示 - 通过 SelectionPresenter 同步选中和范围显示
5. `OnHide` 5. `OnHide`
- 关闭 UI - 关闭 UI
- 清理塔实体 - 隐藏并清理塔实体
- 清理拓扑/选择/映射运行时状态 - 清理选择态与塔映射运行时状态
- 清理拓扑缓存
--- ---
@ -114,7 +133,7 @@
2. `TowerPlacementService` 是塔映射状态的唯一写入口。 2. `TowerPlacementService` 是塔映射状态的唯一写入口。
3. `MapTopologyService` 是 Path/Foundation 数据的唯一来源。 3. `MapTopologyService` 是 Path/Foundation 数据的唯一来源。
4. “当前可见攻击范围”同一时刻最多一个塔。 4. “当前可见攻击范围”同一时刻最多一个塔。
5. 清场顺序固定:先隐藏塔,再清空映射与选择,再清理拓扑缓存。 5. 清场顺序固定:先关闭 UI隐藏塔,再清空选择与映射,再清理拓扑缓存。
--- ---
@ -132,7 +151,11 @@
优先改 `CombatSelectInputService` 优先改 `CombatSelectInputService`
### 5.4 新增选中表现(特效、描边、信息面板联动) ### 5.4 新增 Build 选项展示、塔外观颜色或库存驱动 UI 配置
优先改 `CombatSelectUseCaseConfigurator`
### 5.5 新增选中表现(特效、描边、信息面板联动)
优先改 `TowerSelectionPresenter` 优先改 `TowerSelectionPresenter`