补全 S1 测试

- 调整 CombatNode 正常结束与异常的变量语义,现在是 DidCombatWin 与 Exception
- 补充测试样例
- 完成 S1 目标
This commit is contained in:
SepComet 2026-03-08 21:10:03 +08:00
parent 1023239880
commit 793a87c171
29 changed files with 393 additions and 113 deletions

View File

@ -22,6 +22,7 @@ namespace UnityGameFramework.Editor
"UnityGameFramework.Runtime", "UnityGameFramework.Runtime",
#endif #endif
"Assembly-CSharp", "Assembly-CSharp",
"GeometryTD.Runtime"
}; };
private static readonly string[] RuntimeOrEditorAssemblyNames = private static readonly string[] RuntimeOrEditorAssemblyNames =

View File

@ -2,6 +2,7 @@
"name": "GeometryTD.Runtime", "name": "GeometryTD.Runtime",
"rootNamespace": "GeometryTD", "rootNamespace": "GeometryTD",
"references": [ "references": [
"UnityGameFramework.Editor",
"UnityGameFramework.Runtime", "UnityGameFramework.Runtime",
"Unity.InputSystem", "Unity.InputSystem",
"Unity.TextMeshPro" "Unity.TextMeshPro"

View File

@ -91,7 +91,7 @@ namespace GeometryTD.CustomComponent
_runtime.EnemyManager.EndPhase(); _runtime.EnemyManager.EndPhase();
_runtime.EnemyManager.ResetCombatStats(); _runtime.EnemyManager.ResetCombatStats();
_coordinator.ResetRuntime(); _coordinator.ResetRuntime();
_runtime.IsFinishAsVictory = true; _runtime.DidCombatWin = true;
_runtime.CurrentLevel = level; _runtime.CurrentLevel = level;
_runtime.RunId = runId; _runtime.RunId = runId;

View File

@ -53,7 +53,7 @@ namespace GeometryTD.CustomComponent
_runtime.EnemyDropResolver.Reset(); _runtime.EnemyDropResolver.Reset();
_runtime.SettlementContext = null; _runtime.SettlementContext = null;
_runtime.CurrentLevel = null; _runtime.CurrentLevel = null;
_runtime.IsFinishAsVictory = true; _runtime.DidCombatWin = true;
_runtime.IsCompleted = false; _runtime.IsCompleted = false;
_runtime.NodeEnterFired = false; _runtime.NodeEnterFired = false;
_runtime.RunId = null; _runtime.RunId = null;
@ -124,21 +124,21 @@ namespace GeometryTD.CustomComponent
TryBeginNextPhase(); TryBeginNextPhase();
} }
public bool ShouldEnterSettlementFromActiveState(out bool isVictory) public bool ShouldEnterSettlementFromActiveState(out bool didCombatWin)
{ {
if (GetCurrentBaseHp() <= 0) if (GetCurrentBaseHp() <= 0)
{ {
isVictory = false; didCombatWin = false;
return true; return true;
} }
if (_runtime.PhaseLoopRuntime.IsEndCombatRequested) if (_runtime.PhaseLoopRuntime.IsEndCombatRequested)
{ {
isVictory = true; didCombatWin = true;
return true; return true;
} }
isVictory = true; didCombatWin = true;
return false; return false;
} }
@ -198,9 +198,9 @@ namespace GeometryTD.CustomComponent
GameEntry.UIRouter.CloseUI(UIFormType.DialogForm); GameEntry.UIRouter.CloseUI(UIFormType.DialogForm);
} }
public void CompleteNormalCombatAndNotify(bool succeeded) public void CompleteNormalCombatAndNotify(bool didCombatWin)
{ {
CompleteCombat(succeeded); CompleteCombat(didCombatWin);
GameEntry.Event.Fire( GameEntry.Event.Fire(
this, this,
NodeCompleteEventArgs.Create( NodeCompleteEventArgs.Create(
@ -208,7 +208,8 @@ namespace GeometryTD.CustomComponent
_runtime.NodeId, _runtime.NodeId,
_runtime.NodeType, _runtime.NodeType,
_runtime.SequenceIndex, _runtime.SequenceIndex,
succeeded, RunNodeCompletionStatus.Completed,
didCombatWin,
GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null)); GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null));
} }
@ -219,7 +220,16 @@ namespace GeometryTD.CustomComponent
CloseRewardSelectForm(); CloseRewardSelectForm();
CloseDialogForm(); CloseDialogForm();
CompleteCombat(false); CompleteCombat(false);
GameEntry.Event.Fire(this, CombatFailureReturnEventArgs.Create()); GameEntry.Event.Fire(
this,
NodeCompleteEventArgs.Create(
_runtime.RunId,
_runtime.NodeId,
_runtime.NodeType,
_runtime.SequenceIndex,
RunNodeCompletionStatus.Exception,
false,
GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null));
} }
public bool HandleStartFailure(string errorMessage) public bool HandleStartFailure(string errorMessage)

View File

@ -26,7 +26,7 @@ namespace GeometryTD.CustomComponent
public RewardSelectFormUseCase RewardSelectFormUseCase { get; set; } public RewardSelectFormUseCase RewardSelectFormUseCase { get; set; }
public CombatStateBase CurrentState { get; set; } public CombatStateBase CurrentState { get; set; }
public Action<bool> CombatEndedCallback { get; set; } public Action<bool> CombatEndedCallback { get; set; }
public bool IsFinishAsVictory { get; set; } = true; public bool DidCombatWin { get; set; } = true;
public bool IsCompleted { get; set; } public bool IsCompleted { get; set; }
public bool NodeEnterFired { get; set; } public bool NodeEnterFired { get; set; }
public CombatSettlementContext SettlementContext { get; set; } public CombatSettlementContext SettlementContext { get; set; }

View File

@ -18,7 +18,7 @@ namespace GeometryTD.CustomComponent
internal sealed class CombatSettlementResult internal sealed class CombatSettlementResult
{ {
public bool IsVictory; public bool DidCombatWin;
public int FinalCoin; public int FinalCoin;
public int FinalBaseHp; public int FinalBaseHp;
public int MaxBaseHp; public int MaxBaseHp;

View File

@ -18,14 +18,14 @@ namespace GeometryTD.CustomComponent
private const float LowBaseHpTowerEndurancePenalty = 10f; private const float LowBaseHpTowerEndurancePenalty = 10f;
public CombatSettlementContext BuildSettlementContext( public CombatSettlementContext BuildSettlementContext(
bool isVictory, bool didCombatWin,
DRLevel currentLevel, DRLevel currentLevel,
int defeatedEnemyCount, int defeatedEnemyCount,
CombatRunResourceStore resourceStore) CombatRunResourceStore resourceStore)
{ {
bool shouldOpenFullBaseHpRewardSelect = false; bool shouldOpenFullBaseHpRewardSelect = false;
ResolveSettlementByBaseHp( ResolveSettlementByBaseHp(
isVictory, didCombatWin,
currentLevel, currentLevel,
resourceStore, resourceStore,
out int currentBaseHp, out int currentBaseHp,
@ -39,7 +39,7 @@ namespace GeometryTD.CustomComponent
CombatSettlementContext settlementContext = new CombatSettlementContext CombatSettlementContext settlementContext = new CombatSettlementContext
{ {
}; };
settlementContext.Result.IsVictory = isVictory; settlementContext.Result.DidCombatWin = didCombatWin;
settlementContext.Result.FinalCoin = Mathf.Max(0, resourceStore != null ? resourceStore.CurrentCoin : 0); settlementContext.Result.FinalCoin = Mathf.Max(0, resourceStore != null ? resourceStore.CurrentCoin : 0);
settlementContext.Result.FinalBaseHp = currentBaseHp; settlementContext.Result.FinalBaseHp = currentBaseHp;
settlementContext.Result.MaxBaseHp = maxBaseHp; settlementContext.Result.MaxBaseHp = maxBaseHp;
@ -173,7 +173,7 @@ namespace GeometryTD.CustomComponent
} }
private static void ResolveSettlementByBaseHp( private static void ResolveSettlementByBaseHp(
bool isVictory, bool didCombatWin,
DRLevel currentLevel, DRLevel currentLevel,
CombatRunResourceStore resourceStore, CombatRunResourceStore resourceStore,
out int currentBaseHp, out int currentBaseHp,
@ -197,7 +197,7 @@ namespace GeometryTD.CustomComponent
appliedLowBaseHpPenalty = false; appliedLowBaseHpPenalty = false;
shouldOpenFullBaseHpRewardSelect = false; shouldOpenFullBaseHpRewardSelect = false;
if (!isVictory || resourceStore == null) if (!didCombatWin || resourceStore == null)
{ {
return; return;
} }

View File

@ -66,9 +66,9 @@ namespace GeometryTD.CustomComponent
Runtime.PhaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds); Runtime.PhaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds);
Runtime.EnemyManager.OnUpdate(elapseSeconds, realElapseSeconds); Runtime.EnemyManager.OnUpdate(elapseSeconds, realElapseSeconds);
if (Coordinator.ShouldEnterSettlementFromActiveState(out bool isVictory)) if (Coordinator.ShouldEnterSettlementFromActiveState(out bool didCombatWin))
{ {
Coordinator.ChangeState(new CombatSettlementState(Runtime, Coordinator, isVictory)); Coordinator.ChangeState(new CombatSettlementState(Runtime, Coordinator, didCombatWin));
return; return;
} }

View File

@ -2,23 +2,23 @@ namespace GeometryTD.CustomComponent
{ {
internal sealed class CombatSettlementState : CombatStateBase internal sealed class CombatSettlementState : CombatStateBase
{ {
private readonly bool _isVictory; private readonly bool _didCombatWin;
public CombatSettlementState( public CombatSettlementState(
CombatSchedulerRuntime runtime, CombatSchedulerRuntime runtime,
CombatSchedulerCoordinator coordinator, CombatSchedulerCoordinator coordinator,
bool isVictory) : base(runtime, coordinator) bool didCombatWin) : base(runtime, coordinator)
{ {
_isVictory = isVictory; _didCombatWin = didCombatWin;
} }
public override void OnEnter() public override void OnEnter()
{ {
Runtime.EnemyManager.EndPhase(); Runtime.EnemyManager.EndPhase();
Runtime.EnemyManager.CleanupTrackedEnemies(); Runtime.EnemyManager.CleanupTrackedEnemies();
Runtime.IsFinishAsVictory = _isVictory; Runtime.DidCombatWin = _didCombatWin;
Runtime.SettlementContext = Runtime.CombatSettlementService.BuildSettlementContext( Runtime.SettlementContext = Runtime.CombatSettlementService.BuildSettlementContext(
_isVictory, _didCombatWin,
Runtime.CurrentLevel, Runtime.CurrentLevel,
Runtime.EnemyManager.DefeatedEnemyCount, Runtime.EnemyManager.DefeatedEnemyCount,
Runtime.CombatRunResourceStore); Runtime.CombatRunResourceStore);

View File

@ -22,9 +22,9 @@ namespace GeometryTD.CustomComponent
Runtime.PhaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds); Runtime.PhaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds);
if (Coordinator.ShouldEnterSettlementFromActiveState(out bool isVictory)) if (Coordinator.ShouldEnterSettlementFromActiveState(out bool didCombatWin))
{ {
Coordinator.ChangeState(new CombatSettlementState(Runtime, Coordinator, isVictory)); Coordinator.ChangeState(new CombatSettlementState(Runtime, Coordinator, didCombatWin));
return; return;
} }

View File

@ -34,7 +34,7 @@ namespace GeometryTD.CustomComponent
Runtime.LoadSession.Cleanup(); Runtime.LoadSession.Cleanup();
Coordinator.CloseCombatFinishForm(); Coordinator.CloseCombatFinishForm();
Coordinator.CloseRewardSelectForm(); Coordinator.CloseRewardSelectForm();
Coordinator.CompleteNormalCombatAndNotify(Runtime.IsFinishAsVictory); Coordinator.CompleteNormalCombatAndNotify(Runtime.DidCombatWin);
} }
} }
} }

View File

@ -94,6 +94,7 @@ namespace GeometryTD.CustomComponent
_activeNodeId, _activeNodeId,
_activeNodeType, _activeNodeType,
_activeSequenceIndex, _activeSequenceIndex,
RunNodeCompletionStatus.Completed,
true, true,
GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null)); GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null));
ClearActiveNodeContext(); ClearActiveNodeContext();

View File

@ -59,6 +59,7 @@ namespace GeometryTD.CustomComponent
_activeNodeId, _activeNodeId,
_activeNodeType, _activeNodeType,
_activeSequenceIndex, _activeSequenceIndex,
RunNodeCompletionStatus.Completed,
true, true,
GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null)); GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null));
ClearActiveNodeContext(); ClearActiveNodeContext();

View File

@ -114,12 +114,10 @@ namespace GeometryTD.Entity
{ {
GameEntry.HPBar?.ShowHPBar(this, (float)previousHealth / _maxHealth, GameEntry.HPBar?.ShowHPBar(this, (float)previousHealth / _maxHealth,
(float)_currentHealth / _maxHealth); (float)_currentHealth / _maxHealth);
Log.Info($"ShowBar: {_currentHealth}/{_maxHealth}");
} }
if (_currentHealth <= 0) if (_currentHealth <= 0)
{ {
Log.Info("Enemy Dead");
_killedEnemyEntityIds.Add(Id); _killedEnemyEntityIds.Add(Id);
RequestDespawn(); RequestDespawn();
} }

View File

@ -1,21 +0,0 @@
using GameFramework;
using GameFramework.Event;
namespace GeometryTD.CustomEvent
{
public class CombatFailureReturnEventArgs : GameEventArgs
{
public static int EventId => typeof(CombatFailureReturnEventArgs).GetHashCode();
public override int Id => EventId;
public static CombatFailureReturnEventArgs Create()
{
return ReferencePool.Acquire<CombatFailureReturnEventArgs>();
}
public override void Clear()
{
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 7db1bf4f6f5f435d8d3086510e178d1d
timeCreated: 1772863200

View File

@ -20,7 +20,9 @@ namespace GeometryTD.CustomEvent
public int SequenceIndex { get; private set; } public int SequenceIndex { get; private set; }
public bool Succeeded { get; private set; } public RunNodeCompletionStatus CompletionStatus { get; private set; }
public bool CombatWon { get; private set; }
public BackpackInventoryData InventorySnapshotAfterNode { get; private set; } public BackpackInventoryData InventorySnapshotAfterNode { get; private set; }
@ -30,7 +32,7 @@ namespace GeometryTD.CustomEvent
public static NodeCompleteEventArgs Create() public static NodeCompleteEventArgs Create()
{ {
return Create(null, 0, RunNodeType.None, -1, true, null); return Create(null, 0, RunNodeType.None, -1, RunNodeCompletionStatus.Completed, true, null);
} }
public static NodeCompleteEventArgs Create( public static NodeCompleteEventArgs Create(
@ -38,7 +40,8 @@ namespace GeometryTD.CustomEvent
int nodeId, int nodeId,
RunNodeType nodeType, RunNodeType nodeType,
int sequenceIndex, int sequenceIndex,
bool succeeded, RunNodeCompletionStatus completionStatus,
bool combatWon,
BackpackInventoryData inventorySnapshotAfterNode) BackpackInventoryData inventorySnapshotAfterNode)
{ {
var args = ReferencePool.Acquire<NodeCompleteEventArgs>(); var args = ReferencePool.Acquire<NodeCompleteEventArgs>();
@ -46,7 +49,8 @@ namespace GeometryTD.CustomEvent
args.NodeId = nodeId; args.NodeId = nodeId;
args.NodeType = nodeType; args.NodeType = nodeType;
args.SequenceIndex = sequenceIndex; args.SequenceIndex = sequenceIndex;
args.Succeeded = succeeded; args.CompletionStatus = completionStatus;
args.CombatWon = combatWon;
args.InventorySnapshotAfterNode = InventoryCloneUtility.CloneInventory(inventorySnapshotAfterNode); args.InventorySnapshotAfterNode = InventoryCloneUtility.CloneInventory(inventorySnapshotAfterNode);
return args; return args;
@ -58,7 +62,8 @@ namespace GeometryTD.CustomEvent
NodeId = 0; NodeId = 0;
NodeType = RunNodeType.None; NodeType = RunNodeType.None;
SequenceIndex = -1; SequenceIndex = -1;
Succeeded = false; CompletionStatus = RunNodeCompletionStatus.None;
CombatWon = false;
InventorySnapshotAfterNode = null; InventorySnapshotAfterNode = null;
} }
} }

View File

@ -18,19 +18,26 @@ namespace GeometryTD.Procedure
public enum ProcedureMainRunAdvanceResult public enum ProcedureMainRunAdvanceResult
{ {
NoChange = 0, NoChange = 0,
NodeFailed = 1, NodeException = 1,
AdvancedToNextNode = 2, AdvancedToNextNode = 2,
RunCompleted = 3 RunCompleted = 3
} }
public enum ProcedureMainRunCompletionResult
{
NoChange = 0,
ShowCompletionDialog = 1,
ReturnToMenu = 2
}
public static class ProcedureMainRunFlowService public static class ProcedureMainRunFlowService
{ {
public static ProcedureMainRunAdvanceResult TryAdvanceRun( public static ProcedureMainRunAdvanceResult TryAdvanceRun(
RunState runState, RunState runState,
bool succeeded, RunNodeCompletionStatus completionStatus,
BackpackInventoryData snapshot) BackpackInventoryData snapshot)
{ {
if (!RunStateAdvanceService.TryCompleteCurrentNode(runState, succeeded, snapshot)) if (!RunStateAdvanceService.TryCompleteCurrentNode(runState, completionStatus, snapshot))
{ {
return ProcedureMainRunAdvanceResult.NoChange; return ProcedureMainRunAdvanceResult.NoChange;
} }
@ -40,9 +47,31 @@ namespace GeometryTD.Procedure
return ProcedureMainRunAdvanceResult.RunCompleted; return ProcedureMainRunAdvanceResult.RunCompleted;
} }
return succeeded return completionStatus == RunNodeCompletionStatus.Exception
? ProcedureMainRunAdvanceResult.AdvancedToNextNode ? ProcedureMainRunAdvanceResult.NodeException
: ProcedureMainRunAdvanceResult.NodeFailed; : ProcedureMainRunAdvanceResult.AdvancedToNextNode;
}
}
public static class ProcedureMainRunCompletionService
{
public static ProcedureMainRunCompletionResult TryEnterCompletedPendingFinish(bool isCompletionDialogShown)
{
return isCompletionDialogShown
? ProcedureMainRunCompletionResult.NoChange
: ProcedureMainRunCompletionResult.ShowCompletionDialog;
}
public static ProcedureMainRunCompletionResult TryConfirmReturnToMenu(
ProcedureMainFlowPhase flowPhase,
bool isReturnToMenuPending)
{
if (flowPhase != ProcedureMainFlowPhase.RunCompletedPendingFinish || isReturnToMenuPending)
{
return ProcedureMainRunCompletionResult.NoChange;
}
return ProcedureMainRunCompletionResult.ReturnToMenu;
} }
} }
@ -54,6 +83,8 @@ namespace GeometryTD.Procedure
private NodeMapFormUseCase _nodeMapFormUseCase; private NodeMapFormUseCase _nodeMapFormUseCase;
private RunState _currentRunState; private RunState _currentRunState;
private ProcedureMainFlowPhase _flowPhase = ProcedureMainFlowPhase.Hub; private ProcedureMainFlowPhase _flowPhase = ProcedureMainFlowPhase.Hub;
private bool _isRunCompleteDialogShown;
private bool _isReturnToMenuPending;
#region FSM #region FSM
@ -61,7 +92,6 @@ namespace GeometryTD.Procedure
{ {
base.OnEnter(procedureOwner); base.OnEnter(procedureOwner);
GameEntry.Event.Subscribe(CombatFailureReturnEventArgs.EventId, OnCombatFailureReturn);
GameEntry.Event.Subscribe(NodeCompleteEventArgs.EventId, OnNodeComplete); GameEntry.Event.Subscribe(NodeCompleteEventArgs.EventId, OnNodeComplete);
GameEntry.Event.Subscribe(NodeEnterEventArgs.EventId, OnNodeEnter); GameEntry.Event.Subscribe(NodeEnterEventArgs.EventId, OnNodeEnter);
GameEntry.Event.Subscribe(NodeMapNodeEnterRequestedEventArgs.EventId, OnNodeMapNodeEnterRequested); GameEntry.Event.Subscribe(NodeMapNodeEnterRequestedEventArgs.EventId, OnNodeMapNodeEnterRequested);
@ -83,6 +113,8 @@ namespace GeometryTD.Procedure
_nodeMapFormUseCase = new NodeMapFormUseCase(); _nodeMapFormUseCase = new NodeMapFormUseCase();
_nodeMapFormUseCase.SetRunState(_currentRunState); _nodeMapFormUseCase.SetRunState(_currentRunState);
GameEntry.UIRouter.BindUIUseCase(UIFormType.NodeMapForm, _nodeMapFormUseCase); GameEntry.UIRouter.BindUIUseCase(UIFormType.NodeMapForm, _nodeMapFormUseCase);
_isRunCompleteDialogShown = false;
_isReturnToMenuPending = false;
EnterHubFlow(); EnterHubFlow();
} }
@ -92,6 +124,14 @@ namespace GeometryTD.Procedure
{ {
base.OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds); base.OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds);
if (_isReturnToMenuPending)
{
_isReturnToMenuPending = false;
procedureOwner.SetData<VarInt32>("NextSceneId", (int)SceneType.Menu);
ChangeState<ProcedureChangeScene>(procedureOwner);
return;
}
GameEntry.CombatNode.OnUpdate(elapseSeconds, realElapseSeconds); GameEntry.CombatNode.OnUpdate(elapseSeconds, realElapseSeconds);
} }
@ -99,16 +139,18 @@ namespace GeometryTD.Procedure
{ {
GameEntry.CombatNode.OnShutdown(); GameEntry.CombatNode.OnShutdown();
GameEntry.Event.Unsubscribe(NodeMapNodeEnterRequestedEventArgs.EventId, OnNodeMapNodeEnterRequested); GameEntry.Event.Unsubscribe(NodeMapNodeEnterRequestedEventArgs.EventId, OnNodeMapNodeEnterRequested);
GameEntry.Event.Unsubscribe(CombatFailureReturnEventArgs.EventId, OnCombatFailureReturn);
GameEntry.Event.Unsubscribe(NodeEnterEventArgs.EventId, OnNodeEnter); GameEntry.Event.Unsubscribe(NodeEnterEventArgs.EventId, OnNodeEnter);
GameEntry.Event.Unsubscribe(NodeCompleteEventArgs.EventId, OnNodeComplete); GameEntry.Event.Unsubscribe(NodeCompleteEventArgs.EventId, OnNodeComplete);
GameEntry.UIRouter.CloseUI(UIFormType.RepoForm); GameEntry.UIRouter.CloseUI(UIFormType.RepoForm);
GameEntry.UIRouter.CloseUI(UIFormType.NodeMapForm); GameEntry.UIRouter.CloseUI(UIFormType.NodeMapForm);
GameEntry.UIRouter.CloseUI(UIFormType.MainForm); GameEntry.UIRouter.CloseUI(UIFormType.MainForm);
GameEntry.UIRouter.CloseUI(UIFormType.DialogForm);
_repoFormUseCase = null; _repoFormUseCase = null;
_nodeMapFormUseCase = null; _nodeMapFormUseCase = null;
_currentRunState = null; _currentRunState = null;
_flowPhase = ProcedureMainFlowPhase.Hub; _flowPhase = ProcedureMainFlowPhase.Hub;
_isRunCompleteDialogShown = false;
_isReturnToMenuPending = false;
base.OnLeave(procedureOwner, isShutdown); base.OnLeave(procedureOwner, isShutdown);
} }
@ -178,26 +220,23 @@ namespace GeometryTD.Procedure
return; return;
} }
Log.Info(
"ProcedureMain.OnNodeComplete() accepted. RunId={0}, NodeId={1}, NodeType={2}, SequenceIndex={3}, CompletionStatus={4}, CombatWon={5}.",
string.IsNullOrWhiteSpace(args.RunId) ? _currentRunState?.RunId : args.RunId,
args.NodeId,
args.NodeType,
args.SequenceIndex,
args.CompletionStatus,
args.CombatWon);
BackpackInventoryData snapshot = args.InventorySnapshotAfterNode; BackpackInventoryData snapshot = args.InventorySnapshotAfterNode;
if (snapshot == null && GameEntry.PlayerInventory != null) if (snapshot == null && GameEntry.PlayerInventory != null)
{ {
snapshot = GameEntry.PlayerInventory.GetInventorySnapshot(); snapshot = GameEntry.PlayerInventory.GetInventorySnapshot();
} }
HandleRunAdvanceResult(ProcedureMainRunFlowService.TryAdvanceRun(_currentRunState, args.Succeeded, snapshot)); HandleRunAdvanceResult(
} ProcedureMainRunFlowService.TryAdvanceRun(_currentRunState, args.CompletionStatus, snapshot));
private void OnCombatFailureReturn(object sender, GameEventArgs e)
{
if (!(e is CombatFailureReturnEventArgs))
{
return;
}
BackpackInventoryData snapshot = GameEntry.PlayerInventory != null
? GameEntry.PlayerInventory.GetInventorySnapshot()
: null;
HandleRunAdvanceResult(ProcedureMainRunFlowService.TryAdvanceRun(_currentRunState, false, snapshot));
} }
private void OnNodeMapNodeEnterRequested(object sender, GameEventArgs e) private void OnNodeMapNodeEnterRequested(object sender, GameEventArgs e)
@ -275,9 +314,9 @@ namespace GeometryTD.Procedure
{ {
case ProcedureMainRunAdvanceResult.NoChange: case ProcedureMainRunAdvanceResult.NoChange:
return; return;
case ProcedureMainRunAdvanceResult.NodeFailed: case ProcedureMainRunAdvanceResult.NodeException:
Log.Info( Log.Info(
"ProcedureMain current node failed. RunId={0}, NodeId={1}, NodeType={2}, SequenceIndex={3}.", "ProcedureMain current node entered exception state. RunId={0}, NodeId={1}, NodeType={2}, SequenceIndex={3}.",
_currentRunState?.RunId, _currentRunState?.RunId,
_currentRunState?.CurrentNode?.NodeId ?? 0, _currentRunState?.CurrentNode?.NodeId ?? 0,
_currentRunState?.CurrentNode?.NodeType ?? RunNodeType.None, _currentRunState?.CurrentNode?.NodeType ?? RunNodeType.None,
@ -322,6 +361,14 @@ namespace GeometryTD.Procedure
_nodeMapFormUseCase?.SetRunState(_currentRunState); _nodeMapFormUseCase?.SetRunState(_currentRunState);
CloseHubUI(); CloseHubUI();
Log.Info("ProcedureMain run completed. RunId={0}", _currentRunState?.RunId); Log.Info("ProcedureMain run completed. RunId={0}", _currentRunState?.RunId);
ProcedureMainRunCompletionResult result =
ProcedureMainRunCompletionService.TryEnterCompletedPendingFinish(_isRunCompleteDialogShown);
if (result == ProcedureMainRunCompletionResult.ShowCompletionDialog)
{
_isRunCompleteDialogShown = true;
OpenRunCompleteDialog();
}
} }
private void CloseHubUI() private void CloseHubUI()
@ -345,5 +392,37 @@ namespace GeometryTD.Procedure
nextNode.SequenceIndex, nextNode.SequenceIndex,
nextNode.LinkedLevelId); nextNode.LinkedLevelId);
} }
private void OpenRunCompleteDialog()
{
GameEntry.UIRouter.OpenUI(UIFormType.DialogForm, new DialogFormRawData
{
Mode = 1,
Title = "Run Complete",
Message = "Boss node completed. This run is finished and will return to the main menu.",
PauseGame = false,
ConfirmText = "Return to Menu",
OnClickConfirm = OnRunCompleteDialogConfirmed
});
}
private void OnRunCompleteDialogConfirmed(object userData)
{
_ = userData;
ProcedureMainRunCompletionResult result = ProcedureMainRunCompletionService.TryConfirmReturnToMenu(
_flowPhase,
_isReturnToMenuPending);
if (result != ProcedureMainRunCompletionResult.ReturnToMenu)
{
Log.Warning(
"ProcedureMain ignored run completion confirm. FlowPhase={0}, ReturnPending={1}.",
_flowPhase,
_isReturnToMenuPending);
return;
}
_isReturnToMenuPending = true;
}
} }
} }

View File

@ -19,10 +19,17 @@ namespace GeometryTD.Procedure
Locked = 0, Locked = 0,
Available = 1, Available = 1,
Completed = 2, Completed = 2,
Failed = 3, Exception = 3,
Skipped = 4 Skipped = 4
} }
public enum RunNodeCompletionStatus
{
None = 0,
Completed = 1,
Exception = 2
}
[Serializable] [Serializable]
public sealed class RunNodeSeed public sealed class RunNodeSeed
{ {

View File

@ -6,7 +6,7 @@ namespace GeometryTD.Procedure
{ {
public static bool TryCompleteCurrentNode( public static bool TryCompleteCurrentNode(
RunState runState, RunState runState,
bool succeeded, RunNodeCompletionStatus completionStatus,
BackpackInventoryData inventorySnapshotAfterNode) BackpackInventoryData inventorySnapshotAfterNode)
{ {
if (runState == null || runState.IsCompleted) if (runState == null || runState.IsCompleted)
@ -22,12 +22,17 @@ namespace GeometryTD.Procedure
runState.ReplaceInventorySnapshot(inventorySnapshotAfterNode); runState.ReplaceInventorySnapshot(inventorySnapshotAfterNode);
if (!succeeded) if (completionStatus == RunNodeCompletionStatus.Exception)
{ {
currentNode.Status = RunNodeStatus.Failed; currentNode.Status = RunNodeStatus.Exception;
return true; return true;
} }
if (completionStatus != RunNodeCompletionStatus.Completed)
{
return false;
}
currentNode.Status = RunNodeStatus.Completed; currentNode.Status = RunNodeStatus.Completed;
int nextIndex = runState.CurrentNodeIndex + 1; int nextIndex = runState.CurrentNodeIndex + 1;

View File

@ -10,7 +10,7 @@ namespace GeometryTD.UI
public bool IsLocked; public bool IsLocked;
public bool IsCurrent; public bool IsCurrent;
public bool IsCompleted; public bool IsCompleted;
public bool IsFailed; public bool IsException;
public bool CanClick; public bool CanClick;
} }
} }

View File

@ -143,7 +143,7 @@ namespace GeometryTD.UI
IsLocked = node != null && node.Status == RunNodeStatus.Locked, IsLocked = node != null && node.Status == RunNodeStatus.Locked,
IsCurrent = node != null && node.IsCurrentNode, IsCurrent = node != null && node.IsCurrentNode,
IsCompleted = node != null && node.Status == RunNodeStatus.Completed, IsCompleted = node != null && node.Status == RunNodeStatus.Completed,
IsFailed = node != null && node.Status == RunNodeStatus.Failed, IsException = node != null && node.Status == RunNodeStatus.Exception,
CanClick = node != null && node.CanEnter CanClick = node != null && node.CanEnter
}; };
} }

View File

@ -144,8 +144,8 @@ namespace GeometryTD.UI
return "可进入"; return "可进入";
case RunNodeStatus.Completed: case RunNodeStatus.Completed:
return "已完成"; return "已完成";
case RunNodeStatus.Failed: case RunNodeStatus.Exception:
return "失败"; return "异常";
case RunNodeStatus.Skipped: case RunNodeStatus.Skipped:
return "跳过"; return "跳过";
default: default:

View File

@ -19,7 +19,7 @@ namespace GeometryTD.UI
[SerializeField] private Color _lockedColor = Color.gray; [SerializeField] private Color _lockedColor = Color.gray;
[SerializeField] private Color _activeColor = Color.cyan; [SerializeField] private Color _activeColor = Color.cyan;
[SerializeField] private Color _passedColor = Color.green; [SerializeField] private Color _passedColor = Color.green;
[SerializeField] private Color _failedColor = Color.red; [SerializeField] private Color _exceptionColor = Color.red;
[SerializeField] private Color _defaultIconColor = Color.white; [SerializeField] private Color _defaultIconColor = Color.white;
[SerializeField] private Color _lockedIconColor = Color.gray; [SerializeField] private Color _lockedIconColor = Color.gray;
@ -44,9 +44,9 @@ namespace GeometryTD.UI
{ {
targetColor = _passedColor; targetColor = _passedColor;
} }
else if (context.IsFailed) else if (context.IsException)
{ {
targetColor = _failedColor; targetColor = _exceptionColor;
} }
else if (context.IsCurrent) else if (context.IsCurrent)
{ {

View File

@ -0,0 +1,139 @@
using GeometryTD.Definition;
using GeometryTD.Procedure;
using NUnit.Framework;
namespace GeometryTD.Tests.EditMode
{
public sealed class ProcedureMainServicesTests
{
[Test]
public void TryAdvanceRun_Returns_AdvancedToNextNode_For_Normal_Completion()
{
RunState runState = CreateTwoNodeRun();
ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun(
runState,
RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 88 });
Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.AdvancedToNextNode));
Assert.That(runState.IsCompleted, Is.False);
Assert.That(runState.Nodes[0].Status, Is.EqualTo(RunNodeStatus.Completed));
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1));
Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Available));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(88));
}
[Test]
public void TryAdvanceRun_Returns_NodeException_For_Exception_Fallback()
{
RunState runState = CreateTwoNodeRun();
ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun(
runState,
RunNodeCompletionStatus.Exception,
new BackpackInventoryData { Gold = 5 });
Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.NodeException));
Assert.That(runState.IsCompleted, Is.False);
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0));
Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Exception));
Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Locked));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(5));
}
[Test]
public void TryAdvanceRun_Returns_RunCompleted_When_Last_Node_Finishes()
{
RunState runState = RunStateFactory.Create(
LevelThemeType.Plain,
new BackpackInventoryData { Gold = 30 },
new[]
{
new RunNodeSeed { NodeId = 901, NodeType = RunNodeType.BossCombat, LinkedLevelId = 4 }
});
ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun(
runState,
RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 123 });
Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.RunCompleted));
Assert.That(runState.IsCompleted, Is.True);
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1));
Assert.That(runState.CompletedNodeCount, Is.EqualTo(1));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(123));
}
[Test]
public void TryAdvanceRun_Returns_NoChange_When_Run_Cannot_Advance()
{
ProcedureMainRunAdvanceResult nullRunResult = ProcedureMainRunFlowService.TryAdvanceRun(
null,
RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 1 });
Assert.That(nullRunResult, Is.EqualTo(ProcedureMainRunAdvanceResult.NoChange));
RunState completedRun = RunStateFactory.Create(
LevelThemeType.Plain,
new BackpackInventoryData { Gold = 10 },
new RunNodeSeed[0]);
ProcedureMainRunAdvanceResult completedRunResult = ProcedureMainRunFlowService.TryAdvanceRun(
completedRun,
RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 20 });
Assert.That(completedRunResult, Is.EqualTo(ProcedureMainRunAdvanceResult.NoChange));
Assert.That(completedRun.RunInventorySnapshot.Gold, Is.EqualTo(10));
}
[Test]
public void TryEnterCompletedPendingFinish_Shows_Dialog_Only_Once()
{
ProcedureMainRunCompletionResult firstResult =
ProcedureMainRunCompletionService.TryEnterCompletedPendingFinish(false);
ProcedureMainRunCompletionResult secondResult =
ProcedureMainRunCompletionService.TryEnterCompletedPendingFinish(true);
Assert.That(firstResult, Is.EqualTo(ProcedureMainRunCompletionResult.ShowCompletionDialog));
Assert.That(secondResult, Is.EqualTo(ProcedureMainRunCompletionResult.NoChange));
}
[Test]
public void TryConfirmReturnToMenu_Returns_Menu_Only_In_Completed_Pending_Finish()
{
ProcedureMainRunCompletionResult validResult =
ProcedureMainRunCompletionService.TryConfirmReturnToMenu(
ProcedureMainFlowPhase.RunCompletedPendingFinish,
false);
ProcedureMainRunCompletionResult hubResult =
ProcedureMainRunCompletionService.TryConfirmReturnToMenu(
ProcedureMainFlowPhase.Hub,
false);
ProcedureMainRunCompletionResult pendingResult =
ProcedureMainRunCompletionService.TryConfirmReturnToMenu(
ProcedureMainFlowPhase.RunCompletedPendingFinish,
true);
Assert.That(validResult, Is.EqualTo(ProcedureMainRunCompletionResult.ReturnToMenu));
Assert.That(hubResult, Is.EqualTo(ProcedureMainRunCompletionResult.NoChange));
Assert.That(pendingResult, Is.EqualTo(ProcedureMainRunCompletionResult.NoChange));
}
private static RunState CreateTwoNodeRun()
{
return RunStateFactory.Create(
LevelThemeType.Plain,
new BackpackInventoryData { Gold = 50 },
new[]
{
new RunNodeSeed { NodeId = 101, NodeType = RunNodeType.Combat, LinkedLevelId = 1 },
new RunNodeSeed { NodeId = 102, NodeType = RunNodeType.Event }
});
}
}
}

View File

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

View File

@ -59,7 +59,7 @@ namespace GeometryTD.Tests.EditMode
bool firstCompleted = RunStateAdvanceService.TryCompleteCurrentNode( bool firstCompleted = RunStateAdvanceService.TryCompleteCurrentNode(
runState, runState,
true, RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 80 }); new BackpackInventoryData { Gold = 80 });
Assert.That(firstCompleted, Is.True); Assert.That(firstCompleted, Is.True);
@ -68,8 +68,14 @@ namespace GeometryTD.Tests.EditMode
Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Available)); Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Available));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(80)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(80));
RunStateAdvanceService.TryCompleteCurrentNode(runState, true, new BackpackInventoryData { Gold = 95 }); RunStateAdvanceService.TryCompleteCurrentNode(
RunStateAdvanceService.TryCompleteCurrentNode(runState, true, new BackpackInventoryData { Gold = 130 }); runState,
RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 95 });
RunStateAdvanceService.TryCompleteCurrentNode(
runState,
RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 130 });
Assert.That(runState.IsCompleted, Is.True); Assert.That(runState.IsCompleted, Is.True);
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(3)); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(3));
@ -78,7 +84,7 @@ namespace GeometryTD.Tests.EditMode
} }
[Test] [Test]
public void AdvanceService_Failure_Marks_Current_Node_Failed_Without_Completing_Run() public void AdvanceService_Exception_Marks_Current_Node_Exception_Without_Completing_Run()
{ {
RunState runState = RunStateFactory.Create( RunState runState = RunStateFactory.Create(
LevelThemeType.Plain, LevelThemeType.Plain,
@ -90,13 +96,13 @@ namespace GeometryTD.Tests.EditMode
bool result = RunStateAdvanceService.TryCompleteCurrentNode( bool result = RunStateAdvanceService.TryCompleteCurrentNode(
runState, runState,
false, RunNodeCompletionStatus.Exception,
new BackpackInventoryData { Gold = 5 }); new BackpackInventoryData { Gold = 5 });
Assert.That(result, Is.True); Assert.That(result, Is.True);
Assert.That(runState.IsCompleted, Is.False); Assert.That(runState.IsCompleted, Is.False);
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0)); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0));
Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Failed)); Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Exception));
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(5)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(5));
} }
@ -109,6 +115,7 @@ namespace GeometryTD.Tests.EditMode
7, 7,
RunNodeType.Shop, RunNodeType.Shop,
3, 3,
RunNodeCompletionStatus.Completed,
true, true,
inventory); inventory);
@ -118,7 +125,8 @@ namespace GeometryTD.Tests.EditMode
Assert.That(eventArgs.NodeId, Is.EqualTo(7)); Assert.That(eventArgs.NodeId, Is.EqualTo(7));
Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.Shop)); Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.Shop));
Assert.That(eventArgs.SequenceIndex, Is.EqualTo(3)); Assert.That(eventArgs.SequenceIndex, Is.EqualTo(3));
Assert.That(eventArgs.Succeeded, Is.True); Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.Completed));
Assert.That(eventArgs.CombatWon, Is.True);
Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(66)); Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(66));
eventArgs.Clear(); eventArgs.Clear();
@ -127,7 +135,8 @@ namespace GeometryTD.Tests.EditMode
Assert.That(eventArgs.NodeId, Is.EqualTo(0)); Assert.That(eventArgs.NodeId, Is.EqualTo(0));
Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.None)); Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.None));
Assert.That(eventArgs.SequenceIndex, Is.EqualTo(-1)); Assert.That(eventArgs.SequenceIndex, Is.EqualTo(-1));
Assert.That(eventArgs.Succeeded, Is.False); Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.None));
Assert.That(eventArgs.CombatWon, Is.False);
Assert.That(eventArgs.InventorySnapshotAfterNode, Is.Null); Assert.That(eventArgs.InventorySnapshotAfterNode, Is.Null);
} }
@ -189,7 +198,7 @@ namespace GeometryTD.Tests.EditMode
{ {
bool advanced = RunStateAdvanceService.TryCompleteCurrentNode( bool advanced = RunStateAdvanceService.TryCompleteCurrentNode(
runState, runState,
true, RunNodeCompletionStatus.Completed,
new BackpackInventoryData { Gold = 100 + i }); new BackpackInventoryData { Gold = 100 + i });
Assert.That(advanced, Is.True); Assert.That(advanced, Is.True);

View File

@ -16,9 +16,46 @@
| 状态 | ID | 任务 | 交付物路径 | 验收标准 | | 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|-----|-------|--------------------------------|----------------------------------------------------------------------------|--------------------------| |-----|-------|--------------------------------|----------------------------------------------------------------------------|--------------------------|
| [x] | S1-01 | 统一梳理 M1 当前状态与文档口径 | `docs/CodeX-TODO.md`<br>`docs/TODO.md` | 当前实现、目标状态、未完成项三者口径一致 | | [x] | S1-01 | 统一梳理 M1 当前状态与文档口径 | `docs/CodeX-TODO.md`<br>`docs/TODO.md` | 当前实现、目标状态、未完成项三者口径一致 |
| [ ] | S1-02 | 收口 `ProcedureMain` 的 Run 推进主链路 | `Assets/GameMain/Scripts/Procedure/` | 从开始游戏到 Boss 结算可稳定走完整条链 | | [x] | S1-02 | 收口 `ProcedureMain` 的 Run 推进主链路 | `Assets/GameMain/Scripts/Procedure/` | 从开始游戏到 Boss 结算可稳定走完整条链 |
| [ ] | S1-03 | 收口节点进入、完成、失败后的统一回流 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/Event/` | 战斗 / 事件 / 商店都能统一推进当前 Run | | [x] | S1-03 | 收口节点进入、完成、失败后的统一回流 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/Event/` | 战斗 / 事件 / 商店都能统一推进当前 Run |
| [ ] | S1-04 | 收口 Run 完成后的正式结束态 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Game/` | 10 节点完成后有明确的结束表现与收尾逻辑 | | [x] | S1-04 | 收口 Run 完成后的正式结束态 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Game/` | 10 节点完成后有明确的结束表现与收尾逻辑 |
## S1 验收清单
### 手工流程验收
- [x] 从主菜单进入游戏后,能进入 `ProcedureMain`,并看到 `NodeMapForm + MainForm`
- [x] 当前节点可进入,非当前节点不可进入。
- [x] 普通战斗节点胜利后,会返回节点地图;当前节点变 `Completed`,下一节点变可进入。
- [x] 普通战斗节点在正常非胜利结算后(例如基地血量归零),会返回节点地图;当前节点仍按 `Completed` 处理,并推进到下一节点。
- [x] 战斗节点只在出现异常回退时,才会返回节点地图并把当前节点标记为 `Exception`;不会错误推进到下一节点。
- [x] 事件节点完成后,会返回节点地图,并推进到下一节点。
- [x] 商店节点退出后,会返回节点地图,并推进到下一节点。
- [x] 连续完成到第 10 个 Boss 节点后,不会再回普通 `NodeMapForm`
- [x] Boss 完成后,会弹出正式结束对话框,而不是只停在流程内部状态。
- [x] 点击结束对话框确认后,会切回 `Menu` 场景并重新进入主菜单。
- [x] 回到主菜单后,不残留 `NodeMapForm`、`MainForm`、`DialogForm`。
### 运行时状态验收
- [x] `Hub` 阶段下,只允许当前节点进入。
- [x] `NodeActive` 阶段下Hub UI 已关闭,不能重复从节点地图进入节点。
- [x] 普通节点成功后,会回到 `Hub`
- [x] 节点发生异常回退后,会回到 `Hub`,但不会推进下一节点。
- [x] Boss 成功完成后,会进入 `RunCompletedPendingFinish`,并由正式结束态接管。
- [x] 正式结束态确认后,会走 `ProcedureChangeScene -> ProcedureMenu` 的回菜单链路。
### 回归测试验收
- [x] Unity Test Runner 的 `Assets/Tests/EditMode` 全部通过。
- [x] `RunState` 创建、固定序列、成功推进、异常标记相关测试全部通过。
- [x] `NodeCompleteEventArgs` 正常完成、正常非胜利、异常回退三种语义与库存快照 clone 测试全部通过。
- [x] `ProcedureMainRunFlowService` 的成功推进、异常回退、`RunCompleted`、`NoChange` 分支测试已补齐并全部通过。
- [x] `ProcedureMainRunCompletionService` 的“只弹一次结束对话框”和“只在完成态允许回菜单”测试已补齐并全部通过。
### S1 通过标准
- [x] 从主菜单开始,一条 Run 可以稳定经历“节点进入 -> 节点完成 / 异常回流 -> Boss 完成 -> 正式结束态 -> 返回主菜单”且过程中不会出现错误推进、重复进入、Boss 后回到普通 Hub、或 UI 残留。
## S1-01 对齐结论 ## S1-01 对齐结论

View File

@ -11,8 +11,8 @@
## M1 当前口径2026-03-08 ## M1 当前口径2026-03-08
- 当前仓库已经具备 `ProcedureMain + NodeMapForm + CombatNode + EventNode + ShopNode`临时 Run 闭环,可以创建固定 10 节点流程并推进到各类节点入口 - 当前仓库已经具备 `ProcedureMain + NodeMapForm + CombatNode + EventNode + ShopNode`主流程 Run 闭环,可以从主菜单进入游戏,完成固定 10 节点流程,并在 Boss 结算后进入正式结束态并回到主菜单
- M1 现在的真实缺口,不是“有没有 Run 雏形”,而是“能否稳定完成 Boss 后收尾、统一节点回流、把节点地图与合法出战规则收口成正式口径”。 - M1 现在的真实缺口,不是“有没有 Run 雏形”,而是“节点地图表现是否收口为正式口径,以及合法出战 / 品质 / Tag / 耐久规则是否真正统一收口”。
- `P0-10 ~ P0-12` 在代码里都已有局部实现,因此文档里统一按“部分完成但未收口”处理;只有满足最终验收标准后才可改成完成。 - `P0-10 ~ P0-12` 在代码里都已有局部实现,因此文档里统一按“部分完成但未收口”处理;只有满足最终验收标准后才可改成完成。
## 里程碑 M1P0- 最小可玩闭环 ## 里程碑 M1P0- 最小可玩闭环
@ -22,9 +22,9 @@
| [x] | P0-01 | 冻结 MVP 范围(只保留:战斗节点/事件节点/商店节点/节点后组装) | `docs/MVP-Scope.md` | 明确“做/不做”清单,团队评审通过 | | [x] | P0-01 | 冻结 MVP 范围(只保留:战斗节点/事件节点/商店节点/节点后组装) | `docs/MVP-Scope.md` | 明确“做/不做”清单,团队评审通过 |
| [x] | P0-02 | 补齐数据表:组件、配件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 | | [x] | P0-02 | 补齐数据表:组件、配件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 |
| [x] | P0-03 | 新增/完善 DataRow 解析类 | `Assets/GameMain/Scripts/DataTable/*.cs` | 每个新增表都有对应 DR 类并成功解析 | | [x] | P0-03 | 新增/完善 DataRow 解析类 | `Assets/GameMain/Scripts/DataTable/*.cs` | 每个新增表都有对应 DR 类并成功解析 |
| [~] | P0-04 | 建立单局 Run 状态模型(金币、生命、库存、当前节点) | `Assets/GameMain/Scripts/Procedure/` | 已有 `RunState` / 推进模型 / 测试,并已接入 `ProcedureMain + NodeMapForm` 的临时 Run 闭环 | | [x] | P0-04 | 建立单局 Run 状态模型(金币、生命、库存、当前节点) | `Assets/GameMain/Scripts/Procedure/` | 已有 `RunState` / 推进模型 / 固定序列 / EditMode 测试,并已接入 `ProcedureMain` 主流程闭环 |
| [~] | P0-05 | 实现 10 节点地图生成(最后节点固定 Boss | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/Scene/` | 已有固定 10 节点序列 builder 与测试,且已由 `NodeMapForm` 驱动节点入口,但节点事件上下文与地图表现层仍未完成 | | [~] | P0-05 | 实现 10 节点地图生成(最后节点固定 Boss | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/Scene/` | 已有固定 10 节点序列、当前节点限制与 Boss 终点链路,但 `NodeMapForm` 表现层仍未收口为正式节点地图 |
| [~] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | `ProcedureMain + NodeMapForm` 的临时闭环已可推进战斗/事件/商店,但仍缺完整地图表现、正式结算收尾与节点上下文回流 | | [x] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | 战斗 / 事件 / 商店已统一接入节点进入、完成、异常回流Boss 完成后会进入正式结束态并返回主菜单 |
| [x] | P0-07 | 战斗节点基础玩法:放置塔、出怪、基地扣血、胜负判定 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Scene/` | 可完整打一场并得到胜利/失败结果 | | [x] | P0-07 | 战斗节点基础玩法:放置塔、出怪、基地扣血、胜负判定 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Scene/` | 可完整打一场并得到胜利/失败结果 |
| [x] | P0-08 | 胜利波次与结算规则100/80/50/<50 | `Assets/GameMain/Scripts/Procedure/` | 结算奖励与惩罚严格匹配设计文档 | | [x] | P0-08 | 胜利波次与结算规则100/80/50/<50 | `Assets/GameMain/Scripts/Procedure/` | 结算奖励与惩罚严格匹配设计文档 |
| [x] | P0-09 | 敌人掉落与关卡奖励(组件/配件/金币) | `Assets/GameMain/Scripts/Entity/` | 战斗结束能发放掉落并写入库存 | | [x] | P0-09 | 敌人掉落与关卡奖励(组件/配件/金币) | `Assets/GameMain/Scripts/Entity/` | 战斗结束能发放掉落并写入库存 |
@ -56,12 +56,12 @@
| [ ] | P2-03 | 组装/拆解交互优化(预览属性变化) | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 改装前后差异可视化展示 | | [ ] | P2-03 | 组装/拆解交互优化(预览属性变化) | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 改装前后差异可视化展示 |
| [ ] | P2-04 | 存档与读档(局外货币、库存、解锁进度) | `Assets/GameMain/Scripts/Utility/`<br>`Assets/GameMain/Scripts/Procedure/` | 重启游戏后进度一致恢复 | | [ ] | P2-04 | 存档与读档(局外货币、库存、解锁进度) | `Assets/GameMain/Scripts/Utility/`<br>`Assets/GameMain/Scripts/Procedure/` | 重启游戏后进度一致恢复 |
| [ ] | P2-05 | 平衡性首轮调参(敌人曲线、经济曲线、掉落曲线) | `Assets/GameMain/DataTables/*.txt` | 3 局平均时长与胜率落在预期区间 | | [ ] | P2-05 | 平衡性首轮调参(敌人曲线、经济曲线、掉落曲线) | `Assets/GameMain/DataTables/*.txt` | 3 局平均时长与胜率落在预期区间 |
| [ ] | P2-06 | 最低限度自动化测试(公式与关键流程) | `Assets/` 下新增 `Editor`/`Runtime` 测试 | 关键计算与流程回归可自动验证 | | [~] | P2-06 | 最低限度自动化测试(公式与关键流程) | `Assets/` 下新增 `Editor`/`Runtime` 测试 | 已有 `Assets/Tests/EditMode` 覆盖 `RunState`、`NodeCompleteEventArgs`、`ProcedureMain` 关键服务;更广的公式与流程回归仍待补齐 |
| [ ] | P2-07 | 性能与稳定性检查(长局、内存、异常日志) | `docs/PerformanceReport.md` | 连续游玩 30 分钟无阻断性问题 | | [ ] | P2-07 | 性能与稳定性检查(长局、内存、异常日志) | `docs/PerformanceReport.md` | 连续游玩 30 分钟无阻断性问题 |
## 本周建议开工顺序 ## 本周建议开工顺序
1. 先把 `P0-04` ~ `P0-06` 从“`NodeMapForm` 临时闭环”收口成正式主流程,并补齐 Boss 完成后的结束态与节点回流口径 1. 先把 `P0-05` 的 `NodeMapForm` 表现层从当前占位地图收口成正式节点地图
2. 完成 `P0-10`(把“至少有参战塔”提升为“满足完整合法参战条件才能出战”) 2. 完成 `P0-10`(把“至少有参战塔”提升为“满足完整合法参战条件才能出战”)
3. 再处理 `P0-11` ~ `P0-12`(先统一 M1 范围,再决定是完整收口还是同步缩范围) 3. 再处理 `P0-11` ~ `P0-12`(先统一 M1 范围,再决定是完整收口还是同步缩范围)