实现单局 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:
2026-03-07 21:45:13 +08:00
parent 380f901c1a
commit 5afcaafff7
16 changed files with 654 additions and 293 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
# 场景编号 备注 资源名称 背景音乐编号
1 菜单场景 Menu 1
2 战斗场景 Main 2
3 压力测试场景 StressTest 0
3 压力测试场景 StressTest 0
@@ -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();
@@ -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;
@@ -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;
}
}
}
}
@@ -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;
}
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4de7f8a43a9f4be8b0e1a5d6c7f80911
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f1c5db7a94a44b9aa5f7745ca0c2202
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9b14d5010eb149678d2f4b269d6b8401
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c1a8e1a80db2487ea5ef3e8f700c4203
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a3d90b95afbf46d1be5e59f90b2f3104
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3d3e6fd8ec274118b59f66c6efc41405
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: