实现单局 Run 模型的基础落地

- 核心改动在 RunModel.cs、RunStateFactory.cs 和 RunStateAdvanceService.cs。现在项目里有了 RunNodeType / RunNodeStatus / RunNodeSeed / RunNodeState / RunState,并支持用预置节点序列创建 Run,以及在节点完成后推进、更新局内库存快照、标记通关或失败。

- 库存边界也补上了。PlayerInventoryComponent.cs 新增了 ReplaceInventorySnapshot(...),底层通过 PlayerInventoryStateStore.cs 重建工作副本,避免后续把 Run 真值硬塞回 PlayerInventory 内部状态。

- 节点事件载体已经扩展为可承载 Run 上下文,同时保留原来的无参 Create() 以兼容现有调用点。相关改动在 NodeEnterEventArgs.cs 和 NodeCompleteEventArgs.cs。

- 我还补了纯模型编辑器测试,覆盖 Run 创建、节点推进、失败标记和事件快照克隆,文件在 RunStateTests.cs。
This commit is contained in:
SepComet 2026-03-07 21:45:13 +08:00
parent 380f901c1a
commit 5afcaafff7
16 changed files with 654 additions and 293 deletions

View File

@ -45,6 +45,12 @@ namespace GeometryTD.CustomComponent
return _queryModel.GetSnapshot();
}
public void ReplaceInventorySnapshot(BackpackInventoryData inventorySnapshot)
{
EnsureServices();
_commandModel.ReplaceInventorySnapshot(inventorySnapshot, MaxParticipantTowerCount);
}
public IReadOnlyList<TowerItemData> GetParticipantTowerSnapshot()
{
EnsureInitialized();

View File

@ -89,6 +89,11 @@ namespace GeometryTD.CustomComponent
_state.IsInitialized = true;
}
public void ReplaceInventorySnapshot(BackpackInventoryData sourceInventory, int maxParticipantTowerCount)
{
Initialize(sourceInventory, maxParticipantTowerCount);
}
public PlayerInventoryMergeSummary MergeInventory(BackpackInventoryData gainedInventory)
{
PlayerInventoryMergeSummary summary = default;

View File

@ -1,5 +1,8 @@
using GameFramework;
using GameFramework.Event;
using GeometryTD.CustomUtility;
using GeometryTD.Definition;
using GeometryTD.Procedure;
namespace GeometryTD.CustomEvent
{
@ -9,19 +12,49 @@ namespace GeometryTD.CustomEvent
public override int Id => EventId;
public string RunId { get; private set; }
public int NodeId { get; private set; }
public RunNodeType NodeType { get; private set; }
public bool Succeeded { get; private set; }
public BackpackInventoryData InventorySnapshotAfterNode { get; private set; }
public NodeCompleteEventArgs()
{
}
public static NodeCompleteEventArgs Create()
{
return Create(null, 0, RunNodeType.None, true, null);
}
public static NodeCompleteEventArgs Create(
string runId,
int nodeId,
RunNodeType nodeType,
bool succeeded,
BackpackInventoryData inventorySnapshotAfterNode)
{
var args = ReferencePool.Acquire<NodeCompleteEventArgs>();
args.RunId = runId;
args.NodeId = nodeId;
args.NodeType = nodeType;
args.Succeeded = succeeded;
args.InventorySnapshotAfterNode = InventoryCloneUtility.CloneInventory(inventorySnapshotAfterNode);
return args;
}
public override void Clear()
{
RunId = null;
NodeId = 0;
NodeType = RunNodeType.None;
Succeeded = false;
InventorySnapshotAfterNode = null;
}
}
}

View File

@ -1,5 +1,6 @@
using GameFramework;
using GameFramework.Event;
using GeometryTD.Procedure;
namespace GeometryTD.CustomEvent
{
@ -9,19 +10,40 @@ namespace GeometryTD.CustomEvent
public override int Id => EventId;
public string RunId { get; private set; }
public int NodeId { get; private set; }
public RunNodeType NodeType { get; private set; }
public int SequenceIndex { get; private set; }
public NodeEnterEventArgs()
{
}
public static NodeEnterEventArgs Create()
{
return Create(null, 0, RunNodeType.None, -1);
}
public static NodeEnterEventArgs Create(string runId, int nodeId, RunNodeType nodeType, int sequenceIndex)
{
var args = ReferencePool.Acquire<NodeEnterEventArgs>();
args.RunId = runId;
args.NodeId = nodeId;
args.NodeType = nodeType;
args.SequenceIndex = sequenceIndex;
return args;
}
public override void Clear()
{
RunId = null;
NodeId = 0;
NodeType = RunNodeType.None;
SequenceIndex = -1;
}
}
}

View File

@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using GeometryTD.CustomUtility;
using GeometryTD.Definition;
namespace GeometryTD.Procedure
{
public enum RunNodeType
{
None = 0,
Combat = 1,
Event = 2,
Shop = 3,
BossCombat = 4
}
public enum RunNodeStatus
{
Locked = 0,
Available = 1,
Completed = 2,
Failed = 3,
Skipped = 4
}
[Serializable]
public sealed class RunNodeSeed
{
public int NodeId { get; set; }
public RunNodeType NodeType { get; set; }
public LevelThemeType ThemeType { get; set; }
public int LinkedLevelId { get; set; }
public int SequenceIndex { get; set; } = -1;
}
[Serializable]
public sealed class RunNodeState
{
public int NodeId { get; internal set; }
public RunNodeType NodeType { get; internal set; }
public LevelThemeType ThemeType { get; internal set; }
public int LinkedLevelId { get; internal set; }
public RunNodeStatus Status { get; internal set; }
public int SequenceIndex { get; internal set; }
internal RunNodeState Clone()
{
return new RunNodeState
{
NodeId = NodeId,
NodeType = NodeType,
ThemeType = ThemeType,
LinkedLevelId = LinkedLevelId,
Status = Status,
SequenceIndex = SequenceIndex
};
}
}
[Serializable]
public sealed class RunState
{
private readonly List<RunNodeState> _nodes;
internal RunState(
string runId,
LevelThemeType themeType,
List<RunNodeState> nodes,
BackpackInventoryData runInventorySnapshot)
{
RunId = string.IsNullOrWhiteSpace(runId) ? Guid.NewGuid().ToString("N") : runId;
ThemeType = themeType;
_nodes = nodes ?? new List<RunNodeState>();
RunInventorySnapshot = InventoryCloneUtility.CloneInventory(runInventorySnapshot);
CurrentNodeIndex = _nodes.Count > 0 ? 0 : -1;
IsCompleted = _nodes.Count <= 0;
}
public string RunId { get; internal set; }
public LevelThemeType ThemeType { get; internal set; }
public int CurrentNodeIndex { get; internal set; }
public bool IsCompleted { get; internal set; }
public BackpackInventoryData RunInventorySnapshot { get; internal set; }
public IReadOnlyList<RunNodeState> Nodes => _nodes;
public RunNodeState CurrentNode
{
get
{
if (CurrentNodeIndex < 0 || CurrentNodeIndex >= _nodes.Count)
{
return null;
}
return _nodes[CurrentNodeIndex];
}
}
public bool CanEnterCurrentNode => !IsCompleted && CurrentNode != null && CurrentNode.Status == RunNodeStatus.Available;
public int CompletedNodeCount
{
get
{
int count = 0;
foreach (var nodeState in _nodes)
{
if (nodeState.Status == RunNodeStatus.Completed)
{
count++;
}
}
return count;
}
}
internal void ReplaceInventorySnapshot(BackpackInventoryData inventorySnapshot)
{
RunInventorySnapshot = InventoryCloneUtility.CloneInventory(inventorySnapshot);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4de7f8a43a9f4be8b0e1a5d6c7f80911
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,51 @@
using GeometryTD.Definition;
namespace GeometryTD.Procedure
{
public static class RunStateAdvanceService
{
public static bool TryCompleteCurrentNode(
RunState runState,
bool succeeded,
BackpackInventoryData inventorySnapshotAfterNode)
{
if (runState == null || runState.IsCompleted)
{
return false;
}
RunNodeState currentNode = runState.CurrentNode;
if (currentNode == null || currentNode.Status != RunNodeStatus.Available)
{
return false;
}
runState.ReplaceInventorySnapshot(inventorySnapshotAfterNode);
if (!succeeded)
{
currentNode.Status = RunNodeStatus.Failed;
return true;
}
currentNode.Status = RunNodeStatus.Completed;
int nextIndex = runState.CurrentNodeIndex + 1;
if (nextIndex >= runState.Nodes.Count)
{
runState.CurrentNodeIndex = runState.Nodes.Count;
runState.IsCompleted = true;
return true;
}
runState.CurrentNodeIndex = nextIndex;
RunNodeState nextNode = runState.CurrentNode;
if (nextNode != null && nextNode.Status == RunNodeStatus.Locked)
{
nextNode.Status = RunNodeStatus.Available;
}
return true;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f1c5db7a94a44b9aa5f7745ca0c2202
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,41 @@
using System.Collections.Generic;
using GeometryTD.Definition;
namespace GeometryTD.Procedure
{
public static class RunStateFactory
{
public static RunState Create(
LevelThemeType themeType,
BackpackInventoryData initialInventorySnapshot,
IEnumerable<RunNodeSeed> nodeSeeds,
string runId = null)
{
List<RunNodeState> nodes = new List<RunNodeState>();
if (nodeSeeds != null)
{
int sequenceIndex = 0;
foreach (RunNodeSeed seed in nodeSeeds)
{
if (seed == null)
{
continue;
}
nodes.Add(new RunNodeState
{
NodeId = seed.NodeId > 0 ? seed.NodeId : sequenceIndex + 1,
NodeType = seed.NodeType,
ThemeType = seed.ThemeType == LevelThemeType.None ? themeType : seed.ThemeType,
LinkedLevelId = seed.LinkedLevelId,
SequenceIndex = seed.SequenceIndex >= 0 ? seed.SequenceIndex : sequenceIndex,
Status = sequenceIndex == 0 ? RunNodeStatus.Available : RunNodeStatus.Locked
});
sequenceIndex++;
}
}
return new RunState(runId, themeType, nodes, initialInventorySnapshot);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9b14d5010eb149678d2f4b269d6b8401
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c1a8e1a80db2487ea5ef3e8f700c4203
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a3d90b95afbf46d1be5e59f90b2f3104
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,131 @@
using System.Collections.Generic;
using GeometryTD.CustomEvent;
using GeometryTD.Definition;
using GeometryTD.Procedure;
using NUnit.Framework;
namespace GeometryTD.Tests.Editor
{
public sealed class RunStateTests
{
[Test]
public void Factory_Creates_RunState_With_First_Node_Available()
{
BackpackInventoryData sourceInventory = new BackpackInventoryData
{
Gold = 120
};
sourceInventory.ParticipantTowerInstanceIds.Add(11);
RunState runState = RunStateFactory.Create(
LevelThemeType.Plain,
sourceInventory,
new List<RunNodeSeed>
{
new RunNodeSeed { NodeType = RunNodeType.Combat, LinkedLevelId = 1 },
new RunNodeSeed { NodeType = RunNodeType.Event },
new RunNodeSeed { NodeType = RunNodeType.BossCombat, LinkedLevelId = 4 }
},
"run-test");
Assert.That(runState.RunId, Is.EqualTo("run-test"));
Assert.That(runState.ThemeType, Is.EqualTo(LevelThemeType.Plain));
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0));
Assert.That(runState.IsCompleted, Is.False);
Assert.That(runState.Nodes.Count, Is.EqualTo(3));
Assert.That(runState.Nodes[0].Status, Is.EqualTo(RunNodeStatus.Available));
Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Locked));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(120));
sourceInventory.Gold = 1;
sourceInventory.ParticipantTowerInstanceIds[0] = 99;
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(120));
Assert.That(runState.RunInventorySnapshot.ParticipantTowerInstanceIds[0], Is.EqualTo(11));
}
[Test]
public void AdvanceService_Completes_Run_And_Unlocks_Next_Node()
{
RunState runState = RunStateFactory.Create(
LevelThemeType.Plain,
new BackpackInventoryData { Gold = 50 },
new[]
{
new RunNodeSeed { NodeId = 101, NodeType = RunNodeType.Combat, LinkedLevelId = 1 },
new RunNodeSeed { NodeId = 102, NodeType = RunNodeType.Shop },
new RunNodeSeed { NodeId = 103, NodeType = RunNodeType.BossCombat, LinkedLevelId = 4 }
});
bool firstCompleted = RunStateAdvanceService.TryCompleteCurrentNode(
runState,
true,
new BackpackInventoryData { Gold = 80 });
Assert.That(firstCompleted, Is.True);
Assert.That(runState.Nodes[0].Status, Is.EqualTo(RunNodeStatus.Completed));
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1));
Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Available));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(80));
RunStateAdvanceService.TryCompleteCurrentNode(runState, true, new BackpackInventoryData { Gold = 95 });
RunStateAdvanceService.TryCompleteCurrentNode(runState, true, new BackpackInventoryData { Gold = 130 });
Assert.That(runState.IsCompleted, Is.True);
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(3));
Assert.That(runState.CompletedNodeCount, Is.EqualTo(3));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(130));
}
[Test]
public void AdvanceService_Failure_Marks_Current_Node_Failed_Without_Completing_Run()
{
RunState runState = RunStateFactory.Create(
LevelThemeType.Plain,
new BackpackInventoryData { Gold = 20 },
new[]
{
new RunNodeSeed { NodeType = RunNodeType.Combat }
});
bool result = RunStateAdvanceService.TryCompleteCurrentNode(
runState,
false,
new BackpackInventoryData { Gold = 5 });
Assert.That(result, Is.True);
Assert.That(runState.IsCompleted, Is.False);
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0));
Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Failed));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(5));
}
[Test]
public void NodeCompleteEventArgs_Clones_Inventory_And_Clears_State()
{
BackpackInventoryData inventory = new BackpackInventoryData { Gold = 66 };
NodeCompleteEventArgs eventArgs = NodeCompleteEventArgs.Create(
"run-1",
7,
RunNodeType.Shop,
true,
inventory);
inventory.Gold = 1;
Assert.That(eventArgs.RunId, Is.EqualTo("run-1"));
Assert.That(eventArgs.NodeId, Is.EqualTo(7));
Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.Shop));
Assert.That(eventArgs.Succeeded, Is.True);
Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(66));
eventArgs.Clear();
Assert.That(eventArgs.RunId, Is.Null);
Assert.That(eventArgs.NodeId, Is.EqualTo(0));
Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.None));
Assert.That(eventArgs.Succeeded, Is.False);
Assert.That(eventArgs.InventorySnapshotAfterNode, Is.Null);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3d3e6fd8ec274118b59f66c6efc41405
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -4,322 +4,206 @@
## 当前目标
`docs/CombatNodeArchitecture.md` 继续收敛 `CombatNode` 域职责。当前骨架已经基本到位,后续重点是:
- 继续保持 `CombatScheduler` 作为唯一状态机边界,避免把新业务重新堆回本体。
- 在 `MapData + Event` 已收口的基础上,继续保持 `MapEntity` 不反查 `CombatNode` 域运行时。
- 稳定 `CombatSettlementContext` 的模型边界,避免流程控制字段和展示摘要继续混杂增长。
- 补 Unity 编译、PlayMode 和失败路径回归验证,把这轮结构调整真正跑通。
`docs/TODO.md` 的 M1 现状推进最小可玩闭环。当前代码已经具备:
- 基础战斗节点玩法
- 事件节点基础占位
- 仓库/组装 UI 骨架
- 局内掉落与战斗结算的一部分
但离“可从开始游戏一路完成一个大关”的 M1 验收还有明显缺口。后续重点是:
- 补真正的单局 Run 流程,而不是继续依赖 `TestMenuForm` 手动点节点。
- 把 10 节点固定大关、节点推进、Boss 终点串成闭环。
- 把组装、品质、Tag、耐久从“有数据结构/有 UI”补成“实际影响战斗结果”的规则闭环。
- 补商店节点真实实现,否则 M1 主流程无法跑通。
## 已完成
### 1. 状态类已收口到 CombatScheduler/CombatStates
- 状态类当前位于 `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/`
- `CombatStateBase` 已统一改为 `Context + Flow` 双引用模式。
- 各状态不再直接访问 `CombatScheduler._xxx` 私有字段,而是通过:
- `CombatSchedulerRuntimeContext`
- `CombatSchedulerFlowCoordinator`
### 1. 战斗主链路已经能独立跑通一场
- `CombatNodeComponent` 能加载关卡、阶段和出怪表,并启动 `CombatScheduler`
- `CombatScheduler` 已经具备加载地图、推进 phase、结算与返回的基础流程。
- 战斗内已有基地血量、建塔花费、敌人到家扣血、胜负结束等基础逻辑。
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatStateBase.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/*.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntimeContext.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerFlowCoordinator.cs`
### 2. 第一轮目标命名、骨架与接线已建立
- `CombatResourceManager` 已重命名为 `CombatInRunResourceManager`
- 已新增掉落解析骨架:
- `EnemyDropResolveContext`
- `EnemyDropResolveResult`
- `EnemyDropResolver`
- 已新增 phase end 骨架并接入等待退出状态:
- `IPhaseEndCondition`
- `PhaseEndConditionContext`
- `PhaseEndConditionFactory`
- `NonePhaseEndCondition`
- `TimeElapsedPhaseEndCondition`
- `EnemiesClearedPhaseEndCondition`
- `BossDeadPhaseEndCondition`
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatInRunResourceManager.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDropResolver.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseEndConditions/`
### 3. 局内资源真值已迁到 CombatInRunResourceManager
目前 `CombatInRunResourceManager` 已经接管:
- `CurrentCoin`
- `CurrentBaseHp`
- `MaxBaseHp`
- `GainedCoin`
- `GainedGold`
- 本局 `BuildTowerStats` 快照
- 奖励背包快照
已实现的资源接口:
- `InitializeForCombat(DRLevel level)`
- `MarkCombatEnded()`
- `Reset()`
- `TryConsumeCoin(int coin)`
- `AddCoin(int coin)`
- `ApplyBaseDamage(int damage)`
- `TryGetBuildTowerStats(int buildIndex, out TowerStatsData stats)`
- `AddEnemyDefeatedReward(int gainedCoin, int gainedGold)`
- `AddSettlementGold(int gainedGold)`
- `GetRewardInventorySnapshot()`
附带完成:
- `CombatCoinChangedEventArgs` 增加了 `DeltaCoin`
- `CombatBaseHpChangedEventArgs` 增加了 `DeltaBaseHp`
- coin/baseHp 变化事件现在由 `CombatInRunResourceManager` 发布,而不是 `CombatNodeComponent`
- `Reset()` 现在会清理 `ParticipantTowerInstanceIds`
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatInRunResourceManager.cs`
- `Assets/GameMain/Scripts/Event/Combat/CombatCoinChangedEventArgs.cs`
- `Assets/GameMain/Scripts/Event/Combat/CombatBaseHpChangedEventArgs.cs`
### 4. CombatScheduler / CombatNodeComponent 已做一轮收口
#### CombatScheduler
- 启动时会先初始化 `CombatInRunResourceManager`
- 提供资源转发属性:
- `CurrentCoin`
- `CurrentGold`
- `CurrentBaseHp`
- `CurrentBuildTowerCount`
- 提供资源转发方法:
- `TryConsumeCoin(...)`
- `AddCoin(...)`
- `TryGetBuildTowerStats(...)`
- 失败调试入口:
- `TryDebugFail(...)`
- 不再通过 `CombatNodeComponent` 读写 baseHp/coin 真值
- 当前主职责已收紧为:
- 生命周期入口
- 状态切换入口
- 对外公开查询/操作接口
- 敌人事件公共入口
- 事件桥回调入口
#### CombatScheduler 内部实现细化
- 已新增 `CombatSchedulerRuntimeContext`
- 承载共享运行时字段与共享服务引用
- 已新增 `CombatSchedulerFlowCoordinator`
- 承载多个状态共用的流程辅助方法
- 当前 `CombatScheduler` 本体不再直接堆所有共享字段和公用流程辅助
#### CombatNodeComponent
- 不再持有这些字段:
- `_currentCoin`
- `_currentGold`
- `_currentBaseHp`
- `_currentBuildTowerStats`
- 当前只保留:
- 关卡数据缓存
- `CurrentLevel / CurrentThemeType`
- 战斗启动/结束协调
- 最终结算摘要字段(`LastDefeatedEnemyCount / LastGainedCoin / LastGainedGold`
- 对 `CombatScheduler` 的只读/操作转发
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntimeContext.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerFlowCoordinator.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs`
### 5. EnemyManager 事件边界已改为只上报敌人事实
- `EnemyManager` 现在只向 `CombatScheduler` 上报:
- `OnEnemyDefeated(DREnemy enemy)`
- `OnEnemyReachedBase(DREnemy enemy)`
- coin/gold/baseDamage 的公共副作用已统一收口到 `CombatScheduler`
- `EnemyDropResolver + CombatInRunResourceManager` 已接到公共敌人事件入口上
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyManager.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDropResolver.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
### 6. PhaseEndCondition 已正式接入 WaitingForPhaseEnd
- `CombatWaitingForPhaseEndState` 已改为通过:
- `PhaseEndConditionFactory.Create(...)`
- `IPhaseEndCondition.ShouldExit(...)`
判定 phase 结束
- `PhaseLoopRuntime` 当前只保留 phase runtime 数据与 phase 进入逻辑,不再负责 `PhaseEndType` 判定
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatWaitingForPhaseEndState.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseEndConditions/`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseLoopRuntime.cs`
### 7. 结束链与异常失败链已做协议收口
- 正常结束:
- `NodeCompleteEventArgs` 只在 `WaitingForReturn` 完成后发布
- 异常失败:
- 使用 `CombatFailedState`
- 弹出单按钮 `DialogForm`
- 点击 `Return Menu` 后发布 `CombatFailureReturnEventArgs`
- `ProcedureMenu` 已区分正常结束与异常失败两条返回协议
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatWaitingForReturnState.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatFailedState.cs`
- `Assets/GameMain/Scripts/Event/Combat/CombatFailureReturnEventArgs.cs`
- `Assets/GameMain/Scripts/Procedure/ProcedureMenu.cs`
### 8. CombatSettlementContext 已收紧为结束链共享上下文
- 当前由 `Settlement -> RewardSelection -> FinishForm -> WaitingForReturn` 共享
- 已显式承载:
- 结算事实
- 奖励背包
- 奖励选择相关流程标记
- 低血惩罚相关事实
- 低血惩罚已改为“先记录事实,提交阶段再统一落库”,不再在结算构造期直接写库存
- 结算背包合并当前发生在 `WaitingForReturn` 的真正退出点,而不是 `FinishForm`
- `CombatFinishForm` 当前只消费它真正展示需要的摘要,不再把额外结算事实继续灌进 UI Context
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementFlowService.cs`
- `Assets/GameMain/Scripts/UI/Combat/UseCase/CombatFinishFormUseCase.cs`
### 9. CombatInfoForm 已补手动失败测试入口
- `CombatInfoForm.OnFailButtonClick()` 可手动触发异常失败链
- 新增 `CombatDebugFailEventArgs`
- 当前可直接用于测试:
- `CombatFailedState`
- 失败 Dialog
- `CombatFailureReturnEventArgs`
关键文件:
- `Assets/GameMain/Scripts/UI/Combat/View/CombatInfoForm.cs`
- `Assets/GameMain/Scripts/UI/Combat/Controller/CombatInfoFormController.cs`
- `Assets/GameMain/Scripts/UI/Combat/UseCase/CombatInfoFormUseCase.cs`
- `Assets/GameMain/Scripts/Event/Combat/CombatDebugFailEventArgs.cs`
### 10. MapData + Event 解耦已完成一轮收口
- `MapData` 已收口为纯初始化快照,不再承载 coin 写接口委托
- 已新增 `MapEntityLoadContext`
- 用于把 `MapData` 快照与 coin 命令通道拆开传给地图加载
- `CombatLoadingState` 现在会组装:
- `MapData`
- `MapEntityLoadContext`
- `CombatLoadSession` / `EntityExtension.ShowMap(...)` 已切到 `MapEntityLoadContext`
- `MapEntity` 当前通过:
- `MapEntityLoadContext` 获取初始快照与 coin 命令通道
- `CombatCoinChangedEventArgs` 同步后续 coin 变化
- 已新增 `MapCombatRuntimeBridge`
- 收口地图侧 coin 当前值、命令调用与事件订阅
- `MapEntity` 不再自己维护 `_currentCoin` 和 coin 事件订阅样板
当前结论:
- 地图侧已经完成“`MapData` 初始快照 + Event 同步 + 独立命令桥接”的接线
- 当前未发现 `MapEntity` / 地图侧服务对 `CombatNodeComponent``CombatScheduler` 的运行时反查
关键文件:
- `Assets/GameMain/Scripts/Entity/EntityData/MapData.cs`
- `Assets/GameMain/Scripts/Entity/EntityData/MapEntityLoadContext.cs`
- `Assets/GameMain/Scripts/Scene/Map/MapCombatRuntimeBridge.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/`
- `Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatLoadingState.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatLoadSession.cs`
- `Assets/GameMain/Scripts/Entity/EntityExtension.cs`
- `Assets/GameMain/Scripts/Scene/Map/`
### 11. FlowCoordinator 已切到极小宿主接口
- 已新增 `ICombatSchedulerHost`
- `CombatSchedulerFlowCoordinator` 不再直接依赖 `CombatScheduler` 具体类,而是只依赖:
- 状态切换入口
- coin 命令转发
- CombatInfo / FinishForm 所需的只读查询与回调
- `CombatLoadSession``CombatFinishFormUseCase` 也已切到 `ICombatSchedulerHost`
- 状态类当前通过 `Flow.ChangeState(...)``Flow` 上的轻量转发访问宿主,不再持有 `CombatScheduler` 具体类型引用
### 2. 战斗结算、掉落与奖励选择已有基础实现
- 战斗内敌人被击败后,已能发放 coin / gold / 组件掉落。
- `CombatInRunResourceManager` 已维护本场奖励背包快照。
- 结算链已经包含奖励计算、奖励选择 UI、FinishForm 返回等基础骨架。
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/ICombatSchedulerHost.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerFlowCoordinator.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatLoadSession.cs`
- `Assets/GameMain/Scripts/UI/Combat/UseCase/CombatFinishFormUseCase.cs`
### 12. CombatSettlementContext 已拆成 Flow / Result / Summary 分层
- `CombatSettlementContext` 当前明确区分:
- `Flow`
- `Result`
- `Summary`
- 流程控制字段现在集中在 `Flow`
- 结算事实与惩罚事实现在集中在 `Result`
- `CombatFinishFormUseCase` 当前只消费 `Summary`
- 已去掉单独的结束原因字段,结束链不再依赖 `CombatEndReason` 风格数据
- 奖励选择与延迟提交惩罚已同步切到分层后的上下文访问路径
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatInRunResourceManager.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementFlowService.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatSettlementState.cs`
- `Assets/GameMain/Scripts/UI/Combat/UseCase/CombatFinishFormUseCase.cs`
- `Assets/GameMain/Scripts/UI/Combat/`
### 3. 背包与组装基础能力已经存在
- `PlayerInventoryComponent` 已有库存快照、金币读写、参战塔名单、组装塔入口。
- `RepoForm` 已能展示组件、塔、参战区和组装区。
- `CombineArea` 已有三槽约束,只有枪口/轴承/底座齐备时才会发起组合请求。
- `PlayerInventoryTowerAssemblyService` 已能基于三组件生成 `TowerItemData``TowerStatsData`
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs`
- `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerAssemblyService.cs`
- `Assets/GameMain/Scripts/UI/Game/View/RepoForm.cs`
- `Assets/GameMain/Scripts/UI/Game/View/CombineArea.cs`
- `Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs`
### 4. 事件节点有基础占位实现
- `EventNodeComponent` 已能读取 `Event.txt`,解析事件选项并随机打开一个事件。
- 事件完成后会发出 `NodeCompleteEventArgs` 返回上层流程。
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs`
- `Assets/GameMain/Scripts/UI/Game/UseCase/EventFormUseCase.cs`
- `Assets/GameMain/Scripts/UI/Game/Controller/EventFormController.cs`
## 还没完成
### 1. 需要补实际运行验证
当前改动已覆盖:
- 状态机结构
- 结束链/失败链协议
- 手动失败测试入口
- `CombatSchedulerRuntimeContext + CombatSchedulerFlowCoordinator + ICombatSchedulerHost`
- `CombatSettlementContext` 分层与延迟提交惩罚
- `MapData + Event + MapCombatRuntimeBridge`
### 1. M1 真正的 Run 流程还不存在
- 当前入口仍是 `ProcedureMenu` 里打开 `MainForm + TestMenuForm`
- 玩家目前是手动点击 `战斗 / 事件 / 商店` 按钮进入节点,不是通过单局大关顺序推进。
- 代码里没有明确的“当前节点 / 节点列表 / 节点索引 / 大关完成”运行时模型。
- `docs/TODO.md``P0-04 / P0-05 / P0-06` 从验收标准看都还没有完整落地。
但仍缺:
- Unity 编译验证
- PlayMode/手点验证
- 失败路径回收验证
- 新开局后无残留状态验证
直接证据:
- `ProcedureMenu` 进入后直接打开测试菜单。
- `TestMenuFormController` 直接转发到 `StartCombat / StartEvent / StartShop`
关键文件:
- `Assets/GameMain/Scripts/Procedure/ProcedureMenu.cs`
- `Assets/GameMain/Scripts/UI/Menu/Controller/TestMenuFormController.cs`
### 2. 10 节点地图生成与 Boss 终点未实现
- 当前没有发现“固定 10 节点”或“第 10 节点固定 Boss”的节点生成器。
- `CombatNodeComponent.StartCombat()` 仍是从当前主题关卡池里随机抽一个 `DRLevel` 开战。
- 现有 Boss 相关逻辑只存在于战斗内部的 `EntryType.Boss / BossDead`,不是大关节点层面的第 10 节点约束。
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemySpawnDirector.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseEndConditions/BossDeadPhaseEndCondition.cs`
### 3. 商店节点基本未实现,当前是主流程硬缺口
- `ShopNodeComponent.StartShop()` 仍然是空方法。
- 旧版 `ShopFormUseCase / ShopFormController / ShopForm` 基本都是整文件注释状态,不能视为可用实现。
- 因此 `战斗 -> 事件 -> 商店 -> 组装 -> 下一节点` 这条 M1 主链路实际上卡在商店节点。
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/ShopNodeComponent.cs`
- `Assets/GameMain/Scripts/UI/Templates/GameScene/UseCase/ShopFormUseCase.cs`
- `Assets/GameMain/Scripts/UI/Templates/GameScene/Controller/ShopFormController.cs`
- `Assets/GameMain/Scripts/UI/Templates/GameScene/View/ShopForm.cs`
### 4. 三组件约束只做到了“组装时”,还没做到“出战时”
- `CombineArea` 已经要求枪口/轴承/底座三槽都填满才能发起组装。
- 但战斗入口并没有校验“玩家是否拥有可参战的完整塔”。
- 当前可以在没有任何参战塔时直接调用 `StartCombat()`,不符合 `P0-10` 的“未满足三组件时禁止出战”。
关键文件:
- `Assets/GameMain/Scripts/UI/Game/View/CombineArea.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
### 5. 品质/槽位/Tag 规则只做了最浅一层
- 塔品质目前只按三组件平均品质四舍五入得到。
- `TowerItemData` 里没有“配件槽位数量”之类的落地字段,槽位规则没有真正实现。
- Tag 当前不是按品质概率、数量、等级生成,而是:
- 掉落时直接复制 `PossibleTag`
- 组塔时直接把三组件 Tag 做并集
- `DRTag` 虽然存在,但没有看到被用于实际掉落/组装规则计算。
- `RarityType` 仍包含 `Red`,而 `docs/MVP-Scope.md.md` 里写的是 M1 不做红色,这里也还没统一。
关键文件:
- `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerAssemblyService.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs`
- `Assets/GameMain/Scripts/DataTable/DRTag.cs`
- `Assets/GameMain/Scripts/Definition/DataStruct/TowerItemData.cs`
- `Assets/GameMain/Scripts/Definition/Enum/RarityType.cs`
### 6. 耐久只停留在展示和扣数值,没有真正生效
- 当前组件耐久会显示在仓库描述和 UI 颜色上。
- 低血通关后会调用 `ReduceAllTowerEndurance(...)` 扣耐久。
- 但塔的真实战斗属性是组装时固化到 `TowerStatsData` 的,不会随着耐久变化动态衰减。
- 组件耐久归 0 后没有销毁、移除库存、拆塔或失效处理。
- 所以 `P0-12` 的“耐久影响属性,归零后物品移除”目前未完成。
关键文件:
- `Assets/GameMain/Scripts/Utility/ItemDescUtility.cs`
- `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs`
- `Assets/GameMain/Scripts/Definition/DataStruct/TowerItemData.cs`
- `Assets/GameMain/Scripts/Definition/DataStruct/TowerCompItemData.cs`
### 7. M1 文档与当前实现还有几处范围不一致
- `docs/MVP-Scope.md.md` 写的是固定 10 节点与固定顺序,但当前代码没有对应系统。
- 文档写的是商店节点可购买/出售组件,但当前商店未实现。
- 文档写的是 M1 不做耐久系统,但 `docs/TODO.md` 又把耐久列在 M1 的 `P0-12`;当前代码只做了半套,需要先统一目标再继续推进。
- 文档写的是 M1 不做红色品质,但数据与枚举仍保留 `Red`
关键文件:
- `docs/TODO.md`
- `docs/MVP-Scope.md.md`
- `docs/GameDesign.md`
## 推荐的后续执行顺序
1. 补 Unity 编译与手动回归验证
2. 重点验证正常结束链、异常失败链、新开局无残留状态
3. 若验证中暴露边界问题,再继续做小步收口
1. 先补单局 Run 模型
- 明确 `当前节点索引 / 节点列表 / 当前主题 / 大关完成状态`
- 把 `ProcedureMenu + TestMenuForm` 临时入口替换成真实节点推进入口
2. 再补 10 节点固定大关
- 先做最小版本:固定顺序 10 节点
- 最后一个节点固定 Boss 战斗
- 节点完成后推进到下一节点
3. 立刻补商店节点最小实现
- 先只做 3 个随机组件、购买、返回
- 不提前做复杂定价与出售扩展
4. 收口组装到出战闭环
- 没有完整塔时禁止开始战斗
- 只有参战塔列表合法时才允许进入战斗节点
5. 最后补品质/Tag/耐久规则
- 先把 M1 真实要做的规则重新对齐
- 再决定是否保留红色品质、耐久和槽位到本阶段
## 当前做变更时要记住的约束
- 状态切换只能通过 `CombatScheduler.ChangeState(...)`
- 不要把新业务继续堆回 `CombatScheduler.cs`,优先考虑:
- 状态私有逻辑
- `CombatSchedulerFlowCoordinator`
- 现有独立服务
- `CombatNodeComponent` 现在应该保持轻量 facade不要再把 coin/baseHp/build snapshot 回流到它
- coin/baseHp 变化事件应继续由 `CombatInRunResourceManager` 发布
- `Failed` 只处理异常失败,不处理“基地血量归零”的正常失败路径
- 状态类应继续只通过 `CombatSchedulerRuntimeContext + CombatSchedulerFlowCoordinator` 访问共享状态与共享流程
- 不要继续把 `TestMenuForm` 当作正式节点流程。
- 优先补“流程闭环”,不要先做 M2 的深度系统。
- 商店、节点推进、出战校验属于当前阻塞项,优先级高于数值打磨。
- 如果 M1 最终决定不做完整耐久/红色品质,要先同步更新文档,再改代码目标。
- 若继续保留 `PlayerInventory` 作为局内外共用库存入口,需要避免把单局 Run 状态也硬塞进去。
## 当前关键入口文件速查
- 状态机宿主:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
- 共享运行时承载:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntimeContext.cs`
- 共享流程协调:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerFlowCoordinator.cs`
- 局内资源真值:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatInRunResourceManager.cs`
- 状态类:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/`
- 加载服务:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatLoadSession.cs`
- phase runtime
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseLoopRuntime.cs`
- phase end 条件骨架:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseEndConditions/`
- 敌人域 facade
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyManager.cs`
- CombatNode 入口 facade
- 流程入口:
- `Assets/GameMain/Scripts/Procedure/ProcedureMenu.cs`
- 临时节点菜单:
- `Assets/GameMain/Scripts/UI/Menu/Controller/TestMenuFormController.cs`
- 战斗节点 facade
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs`
- 结束链共享上下文:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs`
- 异常失败返回事件:
- `Assets/GameMain/Scripts/Event/Combat/CombatFailureReturnEventArgs.cs`
- 战斗状态机:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
- 事件节点:
- `Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs`
- 商店节点:
- `Assets/GameMain/Scripts/CustomComponent/ShopNodeComponent.cs`
- 背包与组装:
- `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
- 仓库 UI
- `Assets/GameMain/Scripts/UI/Game/`
- 战斗 UI
- `Assets/GameMain/Scripts/UI/Combat/`
## 备注
- 当前环境没有可用的本地 C# 编译器(`dotnet / mcs / csc / msbuild / xbuild` 都不可用),所以本轮改动主要依赖文本级检查。
- 仓库目前有一些与本任务无关的脏文件,继续改动时只聚焦 `CombatNode` 相关文件即可,不要顺手回滚无关改动。
- 这份清单按“当前仓库真实实现状态”整理,不沿用旧版 `CodeX-TODO.md` 的 CombatNode 收口主题。
- 当前判断主要基于代码静态检查,没有跑 Unity Editor / PlayMode。
- 如果下一步要继续推进,最值得先做的是:`RunState + 10 节点流程 + ShopNode 最小可用版`。