refactor 7:

- 拆分 CombatScheduler,将公用方法与字段拆分为:
    - FlowCoordinator
    - RuntimeContext
- 地图侧现在收口为“MapData 初始快照 + CombatCoinChangedEventArgs 同步 + 独立命令桥接”
This commit is contained in:
SepComet 2026-03-07 19:42:17 +08:00
parent eb818e6295
commit b38088c3ea
25 changed files with 1031 additions and 786 deletions

View File

@ -45,7 +45,7 @@ namespace GeometryTD.CustomComponent
_currentMap = null; _currentMap = null;
} }
public bool StartLoading(DRLevel level, MapData mapData, CombatScheduler scheduler, out string errorMessage) public bool StartLoading(DRLevel level, MapEntityLoadContext mapLoadContext, CombatScheduler scheduler, out string errorMessage)
{ {
errorMessage = null; errorMessage = null;
if (_entity == null) if (_entity == null)
@ -54,7 +54,7 @@ namespace GeometryTD.CustomComponent
return false; return false;
} }
if (!TryShowMap(level, mapData, out errorMessage)) if (!TryShowMap(level, mapLoadContext, out errorMessage))
{ {
return false; return false;
} }
@ -224,7 +224,7 @@ namespace GeometryTD.CustomComponent
} }
} }
private bool TryShowMap(DRLevel level, MapData mapData, out string errorMessage) private bool TryShowMap(DRLevel level, MapEntityLoadContext mapLoadContext, out string errorMessage)
{ {
errorMessage = null; errorMessage = null;
if (level == null) if (level == null)
@ -242,10 +242,14 @@ namespace GeometryTD.CustomComponent
} }
_loadingMapEntityId = _entity.GenerateSerialId(); _loadingMapEntityId = _entity.GenerateSerialId();
MapData resolvedMapData = mapData != null MapData resolvedMapData = mapLoadContext?.InitialMapData != null
? mapData.CloneForEntity(_loadingMapEntityId, Vector3.zero) ? mapLoadContext.InitialMapData.CloneForEntity(_loadingMapEntityId, Vector3.zero)
: new MapData(_loadingMapEntityId, level.Id, Vector3.zero); : new MapData(_loadingMapEntityId, level.Id, Vector3.zero);
_entity.ShowMap(resolvedMapData, mapAssetName); MapEntityLoadContext resolvedLoadContext = new MapEntityLoadContext(
resolvedMapData,
mapLoadContext?.TryConsumeCoin,
mapLoadContext?.AddCoin);
_entity.ShowMap(resolvedLoadContext, mapAssetName);
return true; return true;
} }

View File

@ -11,65 +11,54 @@ namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler public partial class CombatScheduler
{ {
private readonly List<DRLevelPhase> _phaseBuffer = new(); private readonly CombatSchedulerRuntimeContext _context = new();
private readonly Dictionary<int, IReadOnlyList<DRLevelSpawnEntry>> _spawnEntriesByPhaseId = new(); private readonly CombatSchedulerFlowCoordinator _flowCoordinator;
private readonly EnemyManager _enemyManager = new();
private readonly PhaseLoopRuntime _phaseLoopRuntime = new();
private readonly CombatLoadSession _loadSession = new();
private readonly CombatEventBridge _eventBridge = new();
private readonly CombatInRunResourceManager _combatInRunResourceManager = new();
private readonly EnemyDropResolver _enemyDropResolver = new();
private readonly CombatSettlementFlowService _settlementFlowService = new();
private EntityComponent _entity;
private DRLevel _currentLevel;
private CombatFinishFormUseCase _combatFinishFormUseCase;
private RewardSelectFormUseCase _rewardSelectFormUseCase;
private CombatStateBase _currentState;
private bool _initialized; private bool _initialized;
private bool _isFinishAsVictory = true;
private bool _isCompleted; public CombatScheduler()
private bool _nodeEnterFired; {
private CombatSettlementContext _settlementContext; _flowCoordinator = new CombatSchedulerFlowCoordinator(this, _context);
}
public bool IsRunning => public bool IsRunning =>
_currentState is CombatLoadingState or CombatRunningPhaseState or CombatWaitingForPhaseEndState; _context.CurrentState is CombatLoadingState or CombatRunningPhaseState or CombatWaitingForPhaseEndState;
public bool IsCompleted => _isCompleted; public bool IsCompleted => _context.IsCompleted;
public DRLevel CurrentLevel => _currentLevel; public DRLevel CurrentLevel => _context.CurrentLevel;
public DRLevelPhase CurrentPhase => _phaseLoopRuntime.CurrentPhase; public DRLevelPhase CurrentPhase => _context.PhaseLoopRuntime.CurrentPhase;
public MapEntity CurrentMap => _loadSession.CurrentMap; public MapEntity CurrentMap => _context.LoadSession.CurrentMap;
public int DisplayPhaseIndex => _phaseLoopRuntime.DisplayPhaseIndex; public int DisplayPhaseIndex => _context.PhaseLoopRuntime.DisplayPhaseIndex;
public int PhaseCount => _phaseLoopRuntime.PhaseCount; public int PhaseCount => _context.PhaseLoopRuntime.PhaseCount;
public bool CanEndCombat => _phaseLoopRuntime.CanEndCombat; public bool CanEndCombat => _context.PhaseLoopRuntime.CanEndCombat;
public int CurrentCoin => _combatInRunResourceManager.CurrentCoin; public int CurrentCoin => _context.CombatInRunResourceManager.CurrentCoin;
public int CurrentGold => _combatInRunResourceManager.CurrentGold; public int CurrentGold => _context.CombatInRunResourceManager.CurrentGold;
public int CurrentBaseHp => _combatInRunResourceManager.CurrentBaseHp; public int CurrentBaseHp => _context.CombatInRunResourceManager.CurrentBaseHp;
public int CurrentBuildTowerCount => _combatInRunResourceManager.CurrentBuildTowerCount; public int CurrentBuildTowerCount => _context.CombatInRunResourceManager.CurrentBuildTowerCount;
public int DefeatedEnemyCount => _enemyManager.DefeatedEnemyCount; public int DefeatedEnemyCount => _context.EnemyManager.DefeatedEnemyCount;
public int GainedCoin => _combatInRunResourceManager.GainedCoin; public int GainedCoin => _context.CombatInRunResourceManager.GainedCoin;
public int GainedGold => _combatInRunResourceManager.GainedGold; public int GainedGold => _context.CombatInRunResourceManager.GainedGold;
public void OnInit() public void OnInit()
{ {
if (!_initialized) if (!_initialized)
{ {
_entity = GameEntry.Entity; _context.Entity = GameEntry.Entity;
_eventBridge.Bind( _context.EventBridge.Bind(
OnShowEntitySuccess, OnShowEntitySuccess,
OnShowEntityFailure, OnShowEntityFailure,
OnHideEntityComplete, OnHideEntityComplete,
OnOpenUIFormSuccess, OnOpenUIFormSuccess,
OnOpenUIFormFailure, OnOpenUIFormFailure,
OnCloseUIFormComplete); OnCloseUIFormComplete);
_enemyManager.OnInit(this); _context.EnemyManager.OnInit(this);
_loadSession.OnInit(_entity); _context.LoadSession.OnInit(_context.Entity);
EnsureCombatFinishFormUseCaseBound(); _flowCoordinator.EnsureCombatFinishFormUseCaseBound();
EnsureRewardSelectFormUseCaseBound(); _flowCoordinator.EnsureRewardSelectFormUseCaseBound();
_initialized = true; _initialized = true;
} }
ResetRuntime(); _flowCoordinator.ResetRuntime();
} }
public bool Start( public bool Start(
@ -77,32 +66,33 @@ namespace GeometryTD.CustomComponent
IReadOnlyList<DRLevelPhase> phases, IReadOnlyList<DRLevelPhase> phases,
IReadOnlyDictionary<int, IReadOnlyList<DRLevelSpawnEntry>> spawnEntriesByPhaseId) IReadOnlyDictionary<int, IReadOnlyList<DRLevelSpawnEntry>> spawnEntriesByPhaseId)
{ {
if (!_initialized || _entity == null) if (!_initialized || _context.Entity == null)
{ {
return HandleStartFailure("CombatScheduler start failed. Runtime is not initialized."); return _flowCoordinator.HandleStartFailure("CombatScheduler start failed. Runtime is not initialized.");
} }
if (level == null || phases == null || phases.Count <= 0) if (level == null || phases == null || phases.Count <= 0)
{ {
return HandleStartFailure("CombatScheduler start failed. Invalid level or phase data."); return _flowCoordinator.HandleStartFailure("CombatScheduler start failed. Invalid level or phase data.");
} }
CleanupAllCombatEntities(); _flowCoordinator.CleanupAllCombatEntities();
CloseCombatFinishForm(); _flowCoordinator.CloseCombatFinishForm();
CloseRewardSelectForm(); _flowCoordinator.CloseRewardSelectForm();
_enemyManager.EndPhase(); _flowCoordinator.CloseDialogForm();
_enemyManager.ResetCombatStats(); _context.EnemyManager.EndPhase();
ResetRuntime(); _context.EnemyManager.ResetCombatStats();
_isFinishAsVictory = true; _flowCoordinator.ResetRuntime();
_context.IsFinishAsVictory = true;
_currentLevel = level; _context.CurrentLevel = level;
_combatInRunResourceManager.InitializeForCombat(level); _context.CombatInRunResourceManager.InitializeForCombat(level);
for (int i = 0; i < phases.Count; i++) for (int i = 0; i < phases.Count; i++)
{ {
DRLevelPhase phase = phases[i]; DRLevelPhase phase = phases[i];
if (phase != null) if (phase != null)
{ {
_phaseBuffer.Add(phase); _context.PhaseBuffer.Add(phase);
} }
} }
@ -110,27 +100,27 @@ namespace GeometryTD.CustomComponent
{ {
foreach (var pair in spawnEntriesByPhaseId) foreach (var pair in spawnEntriesByPhaseId)
{ {
_spawnEntriesByPhaseId[pair.Key] = pair.Value; _context.SpawnEntriesByPhaseId[pair.Key] = pair.Value;
} }
} }
_phaseLoopRuntime.SetPhases(_phaseBuffer); _context.PhaseLoopRuntime.SetPhases(_context.PhaseBuffer);
if (_phaseLoopRuntime.PhaseCount <= 0) if (_context.PhaseLoopRuntime.PhaseCount <= 0)
{ {
return HandleStartFailure($"CombatScheduler start failed. Level '{level.Id}' has no phase data."); return _flowCoordinator.HandleStartFailure($"CombatScheduler start failed. Level '{level.Id}' has no phase data.");
} }
ChangeState(new CombatLoadingState(this)); ChangeState(new CombatLoadingState(_context, _flowCoordinator));
Log.Info( Log.Info(
"CombatScheduler started. Level={0}, PhaseCount={1}.", "CombatScheduler started. Level={0}, PhaseCount={1}.",
_currentLevel.Id, _context.CurrentLevel.Id,
_phaseLoopRuntime.PhaseCount); _context.PhaseLoopRuntime.PhaseCount);
return true; return true;
} }
public void OnUpdate(float elapseSeconds, float realElapseSeconds) public void OnUpdate(float elapseSeconds, float realElapseSeconds)
{ {
_currentState?.OnUpdate(elapseSeconds, realElapseSeconds); _context.CurrentState?.OnUpdate(elapseSeconds, realElapseSeconds);
} }
public void OnDestroy() public void OnDestroy()
@ -140,31 +130,32 @@ namespace GeometryTD.CustomComponent
return; return;
} }
_currentState?.OnExit(); _context.CurrentState?.OnExit();
_currentState?.OnDestroy(); _context.CurrentState?.OnDestroy();
_currentState = null; _context.CurrentState = null;
CleanupAllCombatEntities(); _flowCoordinator.CleanupAllCombatEntities();
CloseCombatFinishForm(); _flowCoordinator.CloseCombatFinishForm();
CloseRewardSelectForm(); _flowCoordinator.CloseRewardSelectForm();
_enemyManager.OnDestroy(); _flowCoordinator.CloseDialogForm();
ResetRuntime(); _context.EnemyManager.OnDestroy();
_eventBridge.Unbind(); _flowCoordinator.ResetRuntime();
_combatFinishFormUseCase = null; _context.EventBridge.Unbind();
_rewardSelectFormUseCase = null; _context.CombatFinishFormUseCase = null;
_context.RewardSelectFormUseCase = null;
_entity = null; _context.Entity = null;
_initialized = false; _initialized = false;
} }
public bool TryEndCombatByPlayer() public bool TryEndCombatByPlayer()
{ {
if (_currentState is not CombatRunningPhaseState && if (_context.CurrentState is not CombatRunningPhaseState &&
_currentState is not CombatWaitingForPhaseEndState) _context.CurrentState is not CombatWaitingForPhaseEndState)
{ {
return false; return false;
} }
return _phaseLoopRuntime.TryRequestEndCombat(); return _context.PhaseLoopRuntime.TryRequestEndCombat();
} }
public void OnEnemyReachedBase(DREnemy enemy) public void OnEnemyReachedBase(DREnemy enemy)
@ -180,7 +171,7 @@ namespace GeometryTD.CustomComponent
return; return;
} }
ApplyBaseDamage(resolvedBaseDamage); _flowCoordinator.ApplyBaseDamage(resolvedBaseDamage);
} }
public void OnEnemyDefeated(DREnemy enemy) public void OnEnemyDefeated(DREnemy enemy)
@ -192,24 +183,24 @@ namespace GeometryTD.CustomComponent
EnemyDropResolveContext context = new( EnemyDropResolveContext context = new(
enemy, enemy,
_phaseLoopRuntime.DisplayPhaseIndex, _context.PhaseLoopRuntime.DisplayPhaseIndex,
ResolveCurrentThemeType()); _flowCoordinator.ResolveCurrentThemeType());
EnemyDropResolveResult result = _enemyDropResolver.Resolve(context); EnemyDropResolveResult result = _context.EnemyDropResolver.Resolve(context);
_combatInRunResourceManager.AddEnemyDefeatedReward(result.Coin, result.Gold); _context.CombatInRunResourceManager.AddEnemyDefeatedReward(result.Coin, result.Gold);
if (!result.ShouldRollOutGameItem) if (!result.ShouldRollOutGameItem)
{ {
return; return;
} }
_combatInRunResourceManager.TryRollOutGameItemDrop( _context.CombatInRunResourceManager.TryRollOutGameItemDrop(
context.DisplayPhaseIndex, context.DisplayPhaseIndex,
context.ThemeType); context.ThemeType);
} }
public bool OnCombatFinishReturnRequested() public bool OnCombatFinishReturnRequested()
{ {
if (_currentState is not CombatWaitingForReturnState waitingForReturnState) if (_context.CurrentState is not CombatWaitingForReturnState waitingForReturnState)
{ {
return false; return false;
} }
@ -220,294 +211,63 @@ namespace GeometryTD.CustomComponent
public bool TryConsumeCoin(int coin) public bool TryConsumeCoin(int coin)
{ {
return _combatInRunResourceManager.TryConsumeCoin(coin); return _context.CombatInRunResourceManager.TryConsumeCoin(coin);
} }
public void AddCoin(int coin) public void AddCoin(int coin)
{ {
_combatInRunResourceManager.AddCoin(coin); _context.CombatInRunResourceManager.AddCoin(coin);
} }
public bool TryGetBuildTowerStats(int buildIndex, out TowerStatsData stats) public bool TryGetBuildTowerStats(int buildIndex, out TowerStatsData stats)
{ {
return _combatInRunResourceManager.TryGetBuildTowerStats(buildIndex, out stats); return _context.CombatInRunResourceManager.TryGetBuildTowerStats(buildIndex, out stats);
} }
public bool TryDebugFail(string errorMessage) public bool TryDebugFail(string errorMessage)
{ {
if (_isCompleted || _currentState == null || _currentState is CombatFailedState) if (_context.IsCompleted || _context.CurrentState == null || _context.CurrentState is CombatFailedState)
{ {
return false; return false;
} }
EnterFailureFallback(string.IsNullOrWhiteSpace(errorMessage) _flowCoordinator.EnterFailureFallback(string.IsNullOrWhiteSpace(errorMessage)
? "Manual debug fail." ? "Manual debug fail."
: errorMessage); : errorMessage);
return _currentState is CombatFailedState; return _context.CurrentState is CombatFailedState;
} }
private void ResetRuntime() internal void ChangeState(CombatStateBase nextState)
{ {
_currentState = null; if (ReferenceEquals(_context.CurrentState, nextState))
_phaseBuffer.Clear();
_spawnEntriesByPhaseId.Clear();
_phaseLoopRuntime.Reset();
_loadSession.Reset();
_combatInRunResourceManager.Reset();
_settlementContext = null;
_currentLevel = null;
_isFinishAsVictory = true;
_isCompleted = false;
_nodeEnterFired = false;
}
private void CleanupAllCombatEntities()
{
_loadSession.Cleanup();
_enemyManager.CleanupTrackedEnemies();
}
private void EnsureCombatFinishFormUseCaseBound()
{
_combatFinishFormUseCase ??= new CombatFinishFormUseCase(this);
GameEntry.UIRouter.BindUIUseCase(UIFormType.CombatFinishForm, _combatFinishFormUseCase);
}
private void EnsureRewardSelectFormUseCaseBound()
{
_rewardSelectFormUseCase ??= new RewardSelectFormUseCase();
GameEntry.UIRouter.BindUIUseCase(UIFormType.RewardSelectForm, _rewardSelectFormUseCase);
}
private void OpenCombatFailureDialog(string errorMessage)
{
CloseDialogForm();
GameEntry.UIRouter.OpenUI(UIFormType.DialogForm, new DialogFormRawData
{
Mode = 1,
Title = "Combat Error",
Message = string.IsNullOrWhiteSpace(errorMessage) ? "Combat failed unexpectedly." : errorMessage,
PauseGame = false,
ConfirmText = "Return Menu",
OnClickConfirm = OnCombatFailureDialogConfirmed
});
}
private void ChangeState(CombatStateBase nextState)
{
if (ReferenceEquals(_currentState, nextState))
{ {
return; return;
} }
_currentState?.OnExit(); _context.CurrentState?.OnExit();
_currentState?.OnDestroy(); _context.CurrentState?.OnDestroy();
_currentState = nextState; _context.CurrentState = nextState;
_currentState?.OnInit(); _context.CurrentState?.OnInit();
_currentState?.OnEnter(); _context.CurrentState?.OnEnter();
}
private bool TryBeginNextPhase()
{
if (!_phaseLoopRuntime.TryEnterNextPhase(out DRLevelPhase nextPhase))
{
ChangeState(new CombatSettlementState(this, "Combat ended after loop completion.", true));
return false;
}
_spawnEntriesByPhaseId.TryGetValue(nextPhase.Id, out IReadOnlyList<DRLevelSpawnEntry> spawnEntries);
ChangeState(new CombatRunningPhaseState(this, nextPhase, spawnEntries));
return true;
}
private void EnterWaitingForPhaseEnd()
{
ChangeState(new CombatWaitingForPhaseEndState(this));
}
private void CompleteCurrentPhase()
{
_enemyManager.EndPhase();
Log.Info(
"CombatScheduler phase completed. Level={0}, Phase={1}, Elapsed={2:F2}s.",
_currentLevel != null ? _currentLevel.Id : 0,
_phaseLoopRuntime.CurrentPhase != null ? _phaseLoopRuntime.CurrentPhase.Id : 0,
_phaseLoopRuntime.CurrentPhaseElapsed);
TryBeginNextPhase();
}
private bool ShouldEnterSettlementFromActiveState(out string reason, out bool isVictory)
{
if (GetCurrentBaseHp() <= 0)
{
reason = "Combat ended because base HP reached zero.";
isVictory = false;
return true;
}
if (_phaseLoopRuntime.IsEndCombatRequested)
{
reason = "Combat ended by player.";
isVictory = true;
return true;
}
reason = null;
isVictory = true;
return false;
}
private void OnFullBaseHpRewardSelected(RewardSelectItemRawData selectedReward)
{
if (_currentState is not CombatRewardSelectionState || _settlementContext == null)
{
return;
}
_settlementFlowService.ApplySelectedReward(_settlementContext, selectedReward);
ChangeState(new CombatFinishFormState(this));
}
private void OnFullBaseHpRewardGiveUp()
{
if (_currentState is not CombatRewardSelectionState || _settlementContext == null)
{
return;
}
ChangeState(new CombatFinishFormState(this));
}
private LevelThemeType ResolveCurrentThemeType()
{
if (_currentLevel != null)
{
return _currentLevel.LevelThemeType;
}
if (GameEntry.CombatNode != null)
{
return GameEntry.CombatNode.CurrentThemeType;
}
return LevelThemeType.None;
}
private int ApplyBaseDamage(int damage)
{
return _combatInRunResourceManager.ApplyBaseDamage(damage);
}
private int GetCurrentBaseHp()
{
return Mathf.Max(0, _combatInRunResourceManager.CurrentBaseHp);
}
private void CloseCombatFinishForm()
{
GameEntry.UIRouter.CloseUI(UIFormType.CombatFinishForm);
}
private void CloseRewardSelectForm()
{
GameEntry.UIRouter.CloseUI(UIFormType.RewardSelectForm);
}
private void CloseDialogForm()
{
GameEntry.UIRouter.CloseUI(UIFormType.DialogForm);
}
private void CompleteCombat(bool succeeded)
{
_isCompleted = true;
_currentState = null;
_combatInRunResourceManager.MarkCombatEnded();
GameEntry.CombatNode?.OnCombatEndedByScheduler(succeeded);
}
private void CompleteNormalCombatAndNotify(bool succeeded)
{
CompleteCombat(succeeded);
GameEntry.Event.Fire(this, NodeCompleteEventArgs.Create());
}
private void CompleteFailureCombatAndNotify()
{
CleanupAllCombatEntities();
CloseCombatFinishForm();
CloseRewardSelectForm();
CloseDialogForm();
CompleteCombat(false);
GameEntry.Event.Fire(this, CombatFailureReturnEventArgs.Create());
}
private bool HandleStartFailure(string errorMessage)
{
Log.Warning("{0}", errorMessage);
_enemyManager.EndPhase();
CleanupAllCombatEntities();
CloseCombatFinishForm();
CloseRewardSelectForm();
ResetRuntime();
return false;
}
private void EnterFailureFallback(string errorMessage)
{
if (_currentState is CombatFailedState || _isCompleted)
{
return;
}
ChangeState(new CombatFailedState(this, errorMessage));
}
private static int ResolveEnemyHpRateMultiplier(int displayPhaseIndex, int phaseCount)
{
if (displayPhaseIndex <= 0 || phaseCount <= 0)
{
return 1;
}
int completedLoopCount = Mathf.Max(0, (displayPhaseIndex - 1) / phaseCount);
if (completedLoopCount >= 30)
{
return int.MaxValue;
}
return 1 << completedLoopCount;
}
private void OnCombatFailureDialogConfirmed(object userData)
{
_ = userData;
if (_currentState is not CombatFailedState || _isCompleted)
{
return;
}
CompleteFailureCombatAndNotify();
} }
#region Event Handlers #region Event Handlers
private void OnShowEntitySuccess(ShowEntitySuccessEventArgs args) private void OnShowEntitySuccess(ShowEntitySuccessEventArgs args)
{ {
var status = _loadSession.HandleShowEntitySuccess(args, out string errorMessage); var status = _context.LoadSession.HandleShowEntitySuccess(args, out string errorMessage);
if (status == CombatLoadSession.EventHandleStatus.Failed) if (status == CombatLoadSession.EventHandleStatus.Failed)
{ {
EnterFailureFallback(errorMessage); _flowCoordinator.EnterFailureFallback(errorMessage);
return; return;
} }
if (status == CombatLoadSession.EventHandleStatus.Succeeded) if (status == CombatLoadSession.EventHandleStatus.Succeeded)
{ {
MapEntity map = _loadSession.CurrentMap; MapEntity map = _context.LoadSession.CurrentMap;
Log.Info( Log.Info(
"Map ready. LevelId={0}, PathCells={1}, FoundationCells={2}, Spawners={3}, House={4}.", "Map ready. LevelId={0}, PathCells={1}, FoundationCells={2}, Spawners={3}, House={4}.",
_currentLevel != null ? _currentLevel.Id : 0, _context.CurrentLevel != null ? _context.CurrentLevel.Id : 0,
map.PathCells.Count, map.PathCells.Count,
map.FoundationCells.Count, map.FoundationCells.Count,
map.Spawners.Length, map.Spawners.Length,
@ -517,39 +277,39 @@ namespace GeometryTD.CustomComponent
private void OnShowEntityFailure(ShowEntityFailureEventArgs args) private void OnShowEntityFailure(ShowEntityFailureEventArgs args)
{ {
var status = _loadSession.HandleShowEntityFailure(args, out string errorMessage); var status = _context.LoadSession.HandleShowEntityFailure(args, out string errorMessage);
if (status == CombatLoadSession.EventHandleStatus.Failed) if (status == CombatLoadSession.EventHandleStatus.Failed)
{ {
EnterFailureFallback(errorMessage); _flowCoordinator.EnterFailureFallback(errorMessage);
} }
} }
private void OnHideEntityComplete(HideEntityCompleteEventArgs args) private void OnHideEntityComplete(HideEntityCompleteEventArgs args)
{ {
_loadSession.HandleHideEntityComplete(args); _context.LoadSession.HandleHideEntityComplete(args);
} }
private void OnOpenUIFormSuccess(OpenUIFormSuccessEventArgs args) private void OnOpenUIFormSuccess(OpenUIFormSuccessEventArgs args)
{ {
var status = _loadSession.HandleOpenUIFormSuccess(args, out string errorMessage); var status = _context.LoadSession.HandleOpenUIFormSuccess(args, out string errorMessage);
if (status == CombatLoadSession.EventHandleStatus.Failed) if (status == CombatLoadSession.EventHandleStatus.Failed)
{ {
EnterFailureFallback(errorMessage); _flowCoordinator.EnterFailureFallback(errorMessage);
} }
} }
private void OnOpenUIFormFailure(OpenUIFormFailureEventArgs args) private void OnOpenUIFormFailure(OpenUIFormFailureEventArgs args)
{ {
var status = _loadSession.HandleOpenUIFormFailure(args, out string errorMessage); var status = _context.LoadSession.HandleOpenUIFormFailure(args, out string errorMessage);
if (status == CombatLoadSession.EventHandleStatus.Failed) if (status == CombatLoadSession.EventHandleStatus.Failed)
{ {
EnterFailureFallback(errorMessage); _flowCoordinator.EnterFailureFallback(errorMessage);
} }
} }
private void OnCloseUIFormComplete(CloseUIFormCompleteEventArgs args) private void OnCloseUIFormComplete(CloseUIFormCompleteEventArgs args)
{ {
_loadSession.HandleCloseUIFormComplete(args); _context.LoadSession.HandleCloseUIFormComplete(args);
} }
#endregion #endregion

View File

@ -0,0 +1,256 @@
using System.Collections.Generic;
using GeometryTD.CustomEvent;
using GeometryTD.DataTable;
using GeometryTD.Definition;
using GeometryTD.UI;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent
{
internal sealed class CombatSchedulerFlowCoordinator
{
private readonly CombatScheduler _scheduler;
private readonly CombatSchedulerRuntimeContext _context;
public CombatScheduler Scheduler => _scheduler;
public CombatSchedulerFlowCoordinator(CombatScheduler scheduler, CombatSchedulerRuntimeContext context)
{
_scheduler = scheduler;
_context = context;
}
public int ResolveEnemyHpRateMultiplier(int displayPhaseIndex, int phaseCount)
{
if (displayPhaseIndex <= 0 || phaseCount <= 0)
{
return 1;
}
int completedLoopCount = Mathf.Max(0, (displayPhaseIndex - 1) / phaseCount);
if (completedLoopCount >= 30)
{
return int.MaxValue;
}
return 1 << completedLoopCount;
}
public void ResetRuntime()
{
_context.CurrentState = null;
_context.PhaseBuffer.Clear();
_context.SpawnEntriesByPhaseId.Clear();
_context.PhaseLoopRuntime.Reset();
_context.LoadSession.Reset();
_context.CombatInRunResourceManager.Reset();
_context.SettlementContext = null;
_context.CurrentLevel = null;
_context.IsFinishAsVictory = true;
_context.IsCompleted = false;
_context.NodeEnterFired = false;
}
public void CleanupAllCombatEntities()
{
_context.LoadSession.Cleanup();
_context.EnemyManager.CleanupTrackedEnemies();
}
public void EnsureCombatFinishFormUseCaseBound()
{
_context.CombatFinishFormUseCase ??= new CombatFinishFormUseCase(_scheduler);
GameEntry.UIRouter.BindUIUseCase(UIFormType.CombatFinishForm, _context.CombatFinishFormUseCase);
}
public void EnsureRewardSelectFormUseCaseBound()
{
_context.RewardSelectFormUseCase ??= new RewardSelectFormUseCase();
GameEntry.UIRouter.BindUIUseCase(UIFormType.RewardSelectForm, _context.RewardSelectFormUseCase);
}
public void OpenCombatFailureDialog(string errorMessage)
{
CloseDialogForm();
GameEntry.UIRouter.OpenUI(UIFormType.DialogForm, new DialogFormRawData
{
Mode = 1,
Title = "Combat Error",
Message = string.IsNullOrWhiteSpace(errorMessage) ? "Combat failed unexpectedly." : errorMessage,
PauseGame = false,
ConfirmText = "Return Menu",
OnClickConfirm = OnCombatFailureDialogConfirmed
});
}
public bool TryBeginNextPhase()
{
if (!_context.PhaseLoopRuntime.TryEnterNextPhase(out DRLevelPhase nextPhase))
{
_scheduler.ChangeState(new CombatSettlementState(_context, this, "Combat ended after loop completion.", true));
return false;
}
_context.SpawnEntriesByPhaseId.TryGetValue(nextPhase.Id, out IReadOnlyList<DRLevelSpawnEntry> spawnEntries);
_scheduler.ChangeState(new CombatRunningPhaseState(_context, this, nextPhase, spawnEntries));
return true;
}
public void EnterWaitingForPhaseEnd()
{
_scheduler.ChangeState(new CombatWaitingForPhaseEndState(_context, this));
}
public void CompleteCurrentPhase()
{
_context.EnemyManager.EndPhase();
Log.Info(
"CombatScheduler phase completed. Level={0}, Phase={1}, Elapsed={2:F2}s.",
_context.CurrentLevel != null ? _context.CurrentLevel.Id : 0,
_context.PhaseLoopRuntime.CurrentPhase != null ? _context.PhaseLoopRuntime.CurrentPhase.Id : 0,
_context.PhaseLoopRuntime.CurrentPhaseElapsed);
TryBeginNextPhase();
}
public bool ShouldEnterSettlementFromActiveState(out string reason, out bool isVictory)
{
if (GetCurrentBaseHp() <= 0)
{
reason = "Combat ended because base HP reached zero.";
isVictory = false;
return true;
}
if (_context.PhaseLoopRuntime.IsEndCombatRequested)
{
reason = "Combat ended by player.";
isVictory = true;
return true;
}
reason = null;
isVictory = true;
return false;
}
public void OnFullBaseHpRewardSelected(RewardSelectItemRawData selectedReward)
{
if (_context.CurrentState is not CombatRewardSelectionState || _context.SettlementContext == null)
{
return;
}
_context.SettlementFlowService.ApplySelectedReward(_context.SettlementContext, selectedReward);
_scheduler.ChangeState(new CombatFinishFormState(_context, this));
}
public void OnFullBaseHpRewardGiveUp()
{
if (_context.CurrentState is not CombatRewardSelectionState || _context.SettlementContext == null)
{
return;
}
_scheduler.ChangeState(new CombatFinishFormState(_context, this));
}
public LevelThemeType ResolveCurrentThemeType()
{
if (_context.CurrentLevel != null)
{
return _context.CurrentLevel.LevelThemeType;
}
if (GameEntry.CombatNode != null)
{
return GameEntry.CombatNode.CurrentThemeType;
}
return LevelThemeType.None;
}
public int ApplyBaseDamage(int damage)
{
return _context.CombatInRunResourceManager.ApplyBaseDamage(damage);
}
public int GetCurrentBaseHp()
{
return Mathf.Max(0, _context.CombatInRunResourceManager.CurrentBaseHp);
}
public void CloseCombatFinishForm()
{
GameEntry.UIRouter.CloseUI(UIFormType.CombatFinishForm);
}
public void CloseRewardSelectForm()
{
GameEntry.UIRouter.CloseUI(UIFormType.RewardSelectForm);
}
public void CloseDialogForm()
{
GameEntry.UIRouter.CloseUI(UIFormType.DialogForm);
}
public void CompleteNormalCombatAndNotify(bool succeeded)
{
CompleteCombat(succeeded);
GameEntry.Event.Fire(this, NodeCompleteEventArgs.Create());
}
public void CompleteFailureCombatAndNotify()
{
CleanupAllCombatEntities();
CloseCombatFinishForm();
CloseRewardSelectForm();
CloseDialogForm();
CompleteCombat(false);
GameEntry.Event.Fire(this, CombatFailureReturnEventArgs.Create());
}
public bool HandleStartFailure(string errorMessage)
{
Log.Warning("{0}", errorMessage);
_context.EnemyManager.EndPhase();
CleanupAllCombatEntities();
CloseCombatFinishForm();
CloseRewardSelectForm();
CloseDialogForm();
ResetRuntime();
return false;
}
public void EnterFailureFallback(string errorMessage)
{
if (_context.CurrentState is CombatFailedState || _context.IsCompleted)
{
return;
}
_scheduler.ChangeState(new CombatFailedState(_context, this, errorMessage));
}
public void OnCombatFailureDialogConfirmed(object userData)
{
_ = userData;
if (_context.CurrentState is not CombatFailedState || _context.IsCompleted)
{
return;
}
CompleteFailureCombatAndNotify();
}
private void CompleteCombat(bool succeeded)
{
_context.IsCompleted = true;
_context.CurrentState = null;
_context.CombatInRunResourceManager.MarkCombatEnded();
GameEntry.CombatNode?.OnCombatEndedByScheduler(succeeded);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 672e14aaea7d4135874ef7f990eb78f2
timeCreated: 1772865001

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
using GeometryTD.DataTable;
using GeometryTD.Entity;
using GeometryTD.UI;
using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent
{
internal sealed class CombatSchedulerRuntimeContext
{
public List<DRLevelPhase> PhaseBuffer { get; } = new();
public Dictionary<int, IReadOnlyList<DRLevelSpawnEntry>> SpawnEntriesByPhaseId { get; } = new();
public EnemyManager EnemyManager { get; } = new();
public PhaseLoopRuntime PhaseLoopRuntime { get; } = new();
public CombatLoadSession LoadSession { get; } = new();
public CombatEventBridge EventBridge { get; } = new();
public CombatInRunResourceManager CombatInRunResourceManager { get; } = new();
public EnemyDropResolver EnemyDropResolver { get; } = new();
public CombatSettlementFlowService SettlementFlowService { get; } = new();
public EntityComponent Entity { get; set; }
public DRLevel CurrentLevel { get; set; }
public CombatFinishFormUseCase CombatFinishFormUseCase { get; set; }
public RewardSelectFormUseCase RewardSelectFormUseCase { get; set; }
public CombatStateBase CurrentState { get; set; }
public bool IsFinishAsVictory { get; set; } = true;
public bool IsCompleted { get; set; }
public bool NodeEnterFired { get; set; }
public CombatSettlementContext SettlementContext { get; set; }
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ebdf96ddef6945efa0e1446597a9c776
timeCreated: 1772865000

View File

@ -2,33 +2,33 @@ using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatFailedState : CombatStateBase
{ {
private sealed class CombatFailedState : CombatStateBase private readonly string _errorMessage;
public CombatFailedState(
CombatSchedulerRuntimeContext context,
CombatSchedulerFlowCoordinator flow,
string errorMessage) : base(context, flow)
{ {
private readonly string _errorMessage; _errorMessage = errorMessage;
}
public CombatFailedState(CombatScheduler scheduler, string errorMessage) : base(scheduler) public override void OnEnter()
{ {
_errorMessage = errorMessage; Log.Error(
} "CombatScheduler failed. LevelId={0}, {1}",
Context.CurrentLevel != null ? Context.CurrentLevel.Id : 0,
_errorMessage);
Context.EnemyManager.EndPhase();
Flow.CloseCombatFinishForm();
Flow.CloseRewardSelectForm();
Flow.OpenCombatFailureDialog(_errorMessage);
}
public override void OnEnter() public override void OnExit()
{ {
Log.Error( Flow.CloseDialogForm();
"CombatScheduler failed. LevelId={0}, {1}",
Scheduler._currentLevel != null ? Scheduler._currentLevel.Id : 0,
_errorMessage);
Scheduler._enemyManager.EndPhase();
Scheduler.CloseCombatFinishForm();
Scheduler.CloseRewardSelectForm();
Scheduler.OpenCombatFailureDialog(_errorMessage);
}
public override void OnExit()
{
Scheduler.CloseDialogForm();
}
} }
} }
} }

View File

@ -1,28 +1,26 @@
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatFinishFormState : CombatStateBase
{ {
private sealed class CombatFinishFormState : CombatStateBase public CombatFinishFormState(CombatSchedulerRuntimeContext context, CombatSchedulerFlowCoordinator flow)
: base(context, flow)
{ {
public CombatFinishFormState(CombatScheduler scheduler) : base(scheduler) }
public override void OnEnter()
{
if (Context.SettlementContext == null)
{ {
Flow.EnterFailureFallback("Combat finish form failed. Settlement context is missing.");
return;
} }
public override void OnEnter() Context.SettlementFlowService.CommitSettlementInventory(Context.SettlementContext);
{ Flow.EnsureCombatFinishFormUseCaseBound();
if (Scheduler._settlementContext == null) Context.SettlementFlowService.OpenCombatFinishForm(
{ Context.SettlementContext,
Scheduler.EnterFailureFallback("Combat finish form failed. Settlement context is missing."); Context.CombatFinishFormUseCase);
return; Flow.Scheduler.ChangeState(new CombatWaitingForReturnState(Context, Flow));
}
Scheduler._settlementFlowService.CommitSettlementInventory(Scheduler._settlementContext);
Scheduler.EnsureCombatFinishFormUseCaseBound();
Scheduler._settlementFlowService.OpenCombatFinishForm(
Scheduler._settlementContext,
Scheduler._combatFinishFormUseCase);
Scheduler.ChangeState(new CombatWaitingForReturnState(Scheduler));
}
} }
} }
} }

View File

@ -6,70 +6,67 @@ using UnityEngine;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatLoadingState : CombatStateBase
{ {
private sealed class CombatLoadingState : CombatStateBase public CombatLoadingState(CombatSchedulerRuntimeContext context, CombatSchedulerFlowCoordinator flow)
: base(context, flow)
{ {
public CombatLoadingState(CombatScheduler scheduler) : base(scheduler) }
public override void OnEnter()
{
if (Context.CurrentLevel == null)
{ {
Flow.EnterFailureFallback("Combat loading failed. Current level is null.");
return;
} }
public override void OnEnter() MapEntityLoadContext mapLoadContext = BuildMapLoadContext();
if (!Context.LoadSession.StartLoading(Context.CurrentLevel, mapLoadContext, Flow.Scheduler, out string errorMessage))
{ {
if (Scheduler._currentLevel == null) Flow.EnterFailureFallback($"Combat loading failed. {errorMessage}");
{ }
Scheduler.EnterFailureFallback("Combat loading failed. Current level is null."); }
return;
}
MapData mapData = BuildMapData(); public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
if (!Scheduler._loadSession.StartLoading(Scheduler._currentLevel, mapData, Scheduler, out string errorMessage)) {
_ = elapseSeconds;
_ = realElapseSeconds;
if (!Context.LoadSession.IsReady)
{
return;
}
Flow.TryBeginNextPhase();
}
private MapEntityLoadContext BuildMapLoadContext()
{
List<TowerStatsData> buildTowerStatsSnapshot = new();
for (int i = 0; i < Context.CombatInRunResourceManager.CurrentBuildTowerCount; i++)
{
if (Context.CombatInRunResourceManager.TryGetBuildTowerStats(i, out TowerStatsData stats) &&
stats != null)
{ {
Scheduler.EnterFailureFallback($"Combat loading failed. {errorMessage}"); buildTowerStatsSnapshot.Add(stats);
} }
} }
public override void OnUpdate(float elapseSeconds, float realElapseSeconds) MapData mapData = new MapData(
{ entityId: 0,
_ = elapseSeconds; typeId: 0,
_ = realElapseSeconds; levelId: Context.CurrentLevel.Id,
position: Vector3.zero,
if (!Scheduler._loadSession.IsReady) initialCoin: Context.CombatInRunResourceManager.CurrentCoin,
{ buildTowerStatsSnapshot: buildTowerStatsSnapshot,
return; inventorySnapshot: GameEntry.PlayerInventory != null
} ? GameEntry.PlayerInventory.GetInventorySnapshot()
: null,
Scheduler.TryBeginNextPhase(); participantTowerSnapshot: GameEntry.PlayerInventory != null
} ? GameEntry.PlayerInventory.GetParticipantTowerSnapshot()
: null);
private MapData BuildMapData() return new MapEntityLoadContext(mapData, Flow.Scheduler.TryConsumeCoin, Flow.Scheduler.AddCoin);
{
List<TowerStatsData> buildTowerStatsSnapshot = new();
for (int i = 0; i < Scheduler._combatInRunResourceManager.CurrentBuildTowerCount; i++)
{
if (Scheduler._combatInRunResourceManager.TryGetBuildTowerStats(i, out TowerStatsData stats) &&
stats != null)
{
buildTowerStatsSnapshot.Add(stats);
}
}
return new MapData(
entityId: 0,
typeId: 0,
levelId: Scheduler._currentLevel.Id,
position: Vector3.zero,
initialCoin: Scheduler._combatInRunResourceManager.CurrentCoin,
buildTowerStatsSnapshot: buildTowerStatsSnapshot,
inventorySnapshot: GameEntry.PlayerInventory != null
? GameEntry.PlayerInventory.GetInventorySnapshot()
: null,
participantTowerSnapshot: GameEntry.PlayerInventory != null
? GameEntry.PlayerInventory.GetParticipantTowerSnapshot()
: null,
tryConsumeCoin: Scheduler.TryConsumeCoin,
addCoin: Scheduler.AddCoin);
}
} }
} }
} }

View File

@ -1,39 +1,37 @@
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatRewardSelectionState : CombatStateBase
{ {
private sealed class CombatRewardSelectionState : CombatStateBase public CombatRewardSelectionState(CombatSchedulerRuntimeContext context, CombatSchedulerFlowCoordinator flow)
: base(context, flow)
{ {
public CombatRewardSelectionState(CombatScheduler scheduler) : base(scheduler) }
public override void OnEnter()
{
if (Context.SettlementContext == null)
{ {
Flow.EnterFailureFallback("Combat reward selection failed. Settlement context is missing.");
return;
} }
public override void OnEnter() Flow.EnsureRewardSelectFormUseCaseBound();
if (!Context.SettlementFlowService.TryPrepareRewardSelection(
Context.SettlementContext,
Context.CombatInRunResourceManager,
Context.PhaseLoopRuntime.DisplayPhaseIndex,
Flow.ResolveCurrentThemeType(),
Context.RewardSelectFormUseCase,
Flow.OnFullBaseHpRewardSelected,
Flow.OnFullBaseHpRewardGiveUp))
{ {
if (Scheduler._settlementContext == null) Flow.Scheduler.ChangeState(new CombatFinishFormState(Context, Flow));
{
Scheduler.EnterFailureFallback("Combat reward selection failed. Settlement context is missing.");
return;
}
Scheduler.EnsureRewardSelectFormUseCaseBound();
if (!Scheduler._settlementFlowService.TryPrepareRewardSelection(
Scheduler._settlementContext,
Scheduler._combatInRunResourceManager,
Scheduler._phaseLoopRuntime.DisplayPhaseIndex,
Scheduler.ResolveCurrentThemeType(),
Scheduler._rewardSelectFormUseCase,
Scheduler.OnFullBaseHpRewardSelected,
Scheduler.OnFullBaseHpRewardGiveUp))
{
Scheduler.ChangeState(new CombatFinishFormState(Scheduler));
}
} }
}
public override void OnExit() public override void OnExit()
{ {
Scheduler.CloseRewardSelectForm(); Flow.CloseRewardSelectForm();
}
} }
} }
} }

View File

@ -5,72 +5,70 @@ using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatRunningPhaseState : CombatStateBase
{ {
private sealed class CombatRunningPhaseState : CombatStateBase private readonly DRLevelPhase _phase;
private readonly IReadOnlyList<DRLevelSpawnEntry> _spawnEntries;
public CombatRunningPhaseState(
CombatSchedulerRuntimeContext context,
CombatSchedulerFlowCoordinator flow,
DRLevelPhase phase,
IReadOnlyList<DRLevelSpawnEntry> spawnEntries) : base(context, flow)
{ {
private readonly DRLevelPhase _phase; _phase = phase;
private readonly IReadOnlyList<DRLevelSpawnEntry> _spawnEntries; _spawnEntries = spawnEntries;
}
public CombatRunningPhaseState( public override void OnEnter()
CombatScheduler scheduler, {
DRLevelPhase phase, Context.EnemyManager.BeginPhase(_phase, _spawnEntries);
IReadOnlyList<DRLevelSpawnEntry> spawnEntries) : base(scheduler) GameEntry.Event.Fire(
Flow,
CombatProcessEventArgs.Create(
Context.PhaseLoopRuntime.DisplayPhaseIndex,
Context.PhaseLoopRuntime.PhaseCount));
GameEntry.Event.Fire(
Flow,
CombatEnemyHpRateChangedEventArgs.Create(
Flow.ResolveEnemyHpRateMultiplier(
Context.PhaseLoopRuntime.DisplayPhaseIndex,
Context.PhaseLoopRuntime.PhaseCount)));
if (!Context.NodeEnterFired)
{ {
_phase = phase; Context.NodeEnterFired = true;
_spawnEntries = spawnEntries; GameEntry.Event.Fire(Flow, NodeEnterEventArgs.Create());
} }
public override void OnEnter() Log.Info(
"CombatScheduler phase started. Level={0}, Phase={1}, EndType={2}, Entries={3}.",
Context.CurrentLevel != null ? Context.CurrentLevel.Id : 0,
Context.PhaseLoopRuntime.DisplayPhaseIndex,
_phase.EndType,
_spawnEntries != null ? _spawnEntries.Count : 0);
}
public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
if (Context.PhaseLoopRuntime.CurrentPhase == null)
{ {
Scheduler._enemyManager.BeginPhase(_phase, _spawnEntries); Flow.EnterFailureFallback("CombatScheduler update failed. Current phase is null.");
GameEntry.Event.Fire( return;
Scheduler,
CombatProcessEventArgs.Create(
Scheduler._phaseLoopRuntime.DisplayPhaseIndex,
Scheduler._phaseLoopRuntime.PhaseCount));
GameEntry.Event.Fire(
Scheduler,
CombatEnemyHpRateChangedEventArgs.Create(
ResolveEnemyHpRateMultiplier(
Scheduler._phaseLoopRuntime.DisplayPhaseIndex,
Scheduler._phaseLoopRuntime.PhaseCount)));
if (!Scheduler._nodeEnterFired)
{
Scheduler._nodeEnterFired = true;
GameEntry.Event.Fire(Scheduler, NodeEnterEventArgs.Create());
}
Log.Info(
"CombatScheduler phase started. Level={0}, Phase={1}, EndType={2}, Entries={3}.",
Scheduler._currentLevel != null ? Scheduler._currentLevel.Id : 0,
Scheduler._phaseLoopRuntime.DisplayPhaseIndex,
_phase.EndType,
_spawnEntries != null ? _spawnEntries.Count : 0);
} }
public override void OnUpdate(float elapseSeconds, float realElapseSeconds) Context.PhaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds);
Context.EnemyManager.OnUpdate(elapseSeconds, realElapseSeconds);
if (Flow.ShouldEnterSettlementFromActiveState(out string reason, out bool isVictory))
{ {
if (Scheduler._phaseLoopRuntime.CurrentPhase == null) Flow.Scheduler.ChangeState(new CombatSettlementState(Context, Flow, reason, isVictory));
{ return;
Scheduler.EnterFailureFallback("CombatScheduler update failed. Current phase is null."); }
return;
}
Scheduler._phaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds); if (Context.EnemyManager.IsPhaseSpawnCompleted)
Scheduler._enemyManager.OnUpdate(elapseSeconds, realElapseSeconds); {
Flow.EnterWaitingForPhaseEnd();
if (Scheduler.ShouldEnterSettlementFromActiveState(out string reason, out bool isVictory))
{
Scheduler.ChangeState(new CombatSettlementState(Scheduler, reason, isVictory));
return;
}
if (Scheduler._enemyManager.IsPhaseSpawnCompleted)
{
Scheduler.EnterWaitingForPhaseEnd();
}
} }
} }
} }

View File

@ -1,37 +1,38 @@
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatSettlementState : CombatStateBase
{ {
private sealed class CombatSettlementState : CombatStateBase private readonly string _reason;
private readonly bool _isVictory;
public CombatSettlementState(
CombatSchedulerRuntimeContext context,
CombatSchedulerFlowCoordinator flow,
string reason,
bool isVictory) : base(context, flow)
{ {
private readonly string _reason; _reason = reason;
private readonly bool _isVictory; _isVictory = isVictory;
}
public CombatSettlementState(CombatScheduler scheduler, string reason, bool isVictory) : base(scheduler) public override void OnEnter()
{
Context.EnemyManager.EndPhase();
Context.EnemyManager.CleanupTrackedEnemies();
Context.IsFinishAsVictory = _isVictory;
Context.SettlementContext = Context.SettlementFlowService.BuildSettlementContext(
_reason,
_isVictory,
Context.CurrentLevel,
Context.EnemyManager.DefeatedEnemyCount,
Context.CombatInRunResourceManager);
if (Context.SettlementContext.ShouldOpenRewardSelection)
{ {
_reason = reason; Flow.Scheduler.ChangeState(new CombatRewardSelectionState(Context, Flow));
_isVictory = isVictory; return;
} }
public override void OnEnter() Flow.Scheduler.ChangeState(new CombatFinishFormState(Context, Flow));
{
Scheduler._enemyManager.EndPhase();
Scheduler._enemyManager.CleanupTrackedEnemies();
Scheduler._isFinishAsVictory = _isVictory;
Scheduler._settlementContext = Scheduler._settlementFlowService.BuildSettlementContext(
_reason,
_isVictory,
Scheduler._currentLevel,
Scheduler._enemyManager.DefeatedEnemyCount,
Scheduler._combatInRunResourceManager);
if (Scheduler._settlementContext.ShouldOpenRewardSelection)
{
Scheduler.ChangeState(new CombatRewardSelectionState(Scheduler));
return;
}
Scheduler.ChangeState(new CombatFinishFormState(Scheduler));
}
} }
} }
} }

View File

@ -1,35 +1,34 @@
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal abstract class CombatStateBase
{ {
private abstract class CombatStateBase protected CombatSchedulerRuntimeContext Context { get; }
protected CombatSchedulerFlowCoordinator Flow { get; }
protected CombatStateBase(CombatSchedulerRuntimeContext context, CombatSchedulerFlowCoordinator flow)
{ {
protected CombatScheduler Scheduler { get; } Context = context;
Flow = flow;
}
protected CombatStateBase(CombatScheduler scheduler) public virtual void OnInit()
{ {
Scheduler = scheduler; }
}
public virtual void OnInit() public virtual void OnEnter()
{ {
} }
public virtual void OnEnter() public virtual void OnExit()
{ {
} }
public virtual void OnExit() public virtual void OnUpdate(float elapseSeconds, float realElapseSeconds)
{ {
} }
public virtual void OnUpdate(float elapseSeconds, float realElapseSeconds) public virtual void OnDestroy()
{ {
}
public virtual void OnDestroy()
{
}
} }
} }
} }

View File

@ -1,46 +1,46 @@
using GeometryTD.DataTable;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatWaitingForPhaseEndState : CombatStateBase
{ {
private sealed class CombatWaitingForPhaseEndState : CombatStateBase public CombatWaitingForPhaseEndState(CombatSchedulerRuntimeContext context, CombatSchedulerFlowCoordinator flow)
: base(context, flow)
{ {
public CombatWaitingForPhaseEndState(CombatScheduler scheduler) : base(scheduler) }
public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
_ = realElapseSeconds;
DRLevelPhase currentPhase = Context.PhaseLoopRuntime.CurrentPhase;
if (currentPhase == null)
{ {
Flow.EnterFailureFallback("CombatScheduler waiting phase failed. Current phase is null.");
return;
} }
public override void OnUpdate(float elapseSeconds, float realElapseSeconds) Context.PhaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds);
if (Flow.ShouldEnterSettlementFromActiveState(out string reason, out bool isVictory))
{ {
_ = realElapseSeconds; Flow.Scheduler.ChangeState(new CombatSettlementState(Context, Flow, reason, isVictory));
return;
var currentPhase = Scheduler._phaseLoopRuntime.CurrentPhase;
if (currentPhase == null)
{
Scheduler.EnterFailureFallback("CombatScheduler waiting phase failed. Current phase is null.");
return;
}
Scheduler._phaseLoopRuntime.AdvancePhaseElapsed(elapseSeconds);
if (Scheduler.ShouldEnterSettlementFromActiveState(out string reason, out bool isVictory))
{
Scheduler.ChangeState(new CombatSettlementState(Scheduler, reason, isVictory));
return;
}
PhaseEndConditionContext context = new(
currentPhase,
Scheduler._phaseLoopRuntime.CurrentPhaseElapsed,
Scheduler._enemyManager.IsPhaseSpawnCompleted,
Scheduler._enemyManager.AliveEnemyCount,
Scheduler._enemyManager.HasAliveBoss);
IPhaseEndCondition endCondition = PhaseEndConditionFactory.Create(currentPhase.EndType);
if (!endCondition.ShouldExit(context))
{
return;
}
Scheduler.CompleteCurrentPhase();
} }
PhaseEndConditionContext conditionContext = new(
currentPhase,
Context.PhaseLoopRuntime.CurrentPhaseElapsed,
Context.EnemyManager.IsPhaseSpawnCompleted,
Context.EnemyManager.AliveEnemyCount,
Context.EnemyManager.HasAliveBoss);
IPhaseEndCondition endCondition = PhaseEndConditionFactory.Create(currentPhase.EndType);
if (!endCondition.ShouldExit(conditionContext))
{
return;
}
Flow.CompleteCurrentPhase();
} }
} }
} }

View File

@ -1,35 +1,33 @@
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
public partial class CombatScheduler internal sealed class CombatWaitingForReturnState : CombatStateBase
{ {
private sealed class CombatWaitingForReturnState : CombatStateBase private bool _returnRequested;
public CombatWaitingForReturnState(CombatSchedulerRuntimeContext context, CombatSchedulerFlowCoordinator flow)
: base(context, flow)
{ {
private bool _returnRequested; }
public CombatWaitingForReturnState(CombatScheduler scheduler) : base(scheduler) public void RequestReturn()
{
_returnRequested = true;
}
public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
_ = elapseSeconds;
_ = realElapseSeconds;
if (!_returnRequested)
{ {
return;
} }
public void RequestReturn() Context.LoadSession.Cleanup();
{ Flow.CloseCombatFinishForm();
_returnRequested = true; Flow.CloseRewardSelectForm();
} Flow.CompleteNormalCombatAndNotify(Context.IsFinishAsVictory);
public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
_ = elapseSeconds;
_ = realElapseSeconds;
if (!_returnRequested)
{
return;
}
Scheduler._loadSession.Cleanup();
Scheduler.CloseCombatFinishForm();
Scheduler.CloseRewardSelectForm();
Scheduler.CompleteNormalCombatAndNotify(Scheduler._isFinishAsVictory);
}
} }
} }
} }

View File

@ -15,9 +15,6 @@ namespace GeometryTD.Entity.EntityData
[SerializeField] private BackpackInventoryData _inventorySnapshot; [SerializeField] private BackpackInventoryData _inventorySnapshot;
[SerializeField] private TowerItemData[] _participantTowerSnapshot = Array.Empty<TowerItemData>(); [SerializeField] private TowerItemData[] _participantTowerSnapshot = Array.Empty<TowerItemData>();
[NonSerialized] private Func<int, bool> _tryConsumeCoin;
[NonSerialized] private Action<int> _addCoin;
public MapData(int entityId, int levelId, Vector3 position) : this(entityId, 0, levelId, position) public MapData(int entityId, int levelId, Vector3 position) : this(entityId, 0, levelId, position)
{ {
} }
@ -36,9 +33,7 @@ namespace GeometryTD.Entity.EntityData
int initialCoin, int initialCoin,
IReadOnlyList<TowerStatsData> buildTowerStatsSnapshot, IReadOnlyList<TowerStatsData> buildTowerStatsSnapshot,
BackpackInventoryData inventorySnapshot, BackpackInventoryData inventorySnapshot,
IReadOnlyList<TowerItemData> participantTowerSnapshot, IReadOnlyList<TowerItemData> participantTowerSnapshot) : base(entityId, typeId)
Func<int, bool> tryConsumeCoin,
Action<int> addCoin) : base(entityId, typeId)
{ {
_levelId = levelId; _levelId = levelId;
Position = position; Position = position;
@ -48,8 +43,6 @@ namespace GeometryTD.Entity.EntityData
? InventoryCloneUtility.CloneInventory(inventorySnapshot) ? InventoryCloneUtility.CloneInventory(inventorySnapshot)
: null; : null;
SetParticipantTowerSnapshot(participantTowerSnapshot); SetParticipantTowerSnapshot(participantTowerSnapshot);
_tryConsumeCoin = tryConsumeCoin;
_addCoin = addCoin;
} }
public int LevelId public int LevelId
@ -70,34 +63,6 @@ namespace GeometryTD.Entity.EntityData
: null; : null;
public IReadOnlyList<TowerItemData> ParticipantTowerSnapshot => _participantTowerSnapshot; public IReadOnlyList<TowerItemData> ParticipantTowerSnapshot => _participantTowerSnapshot;
public void BindCombatCallbacks(Func<int, bool> tryConsumeCoin, Action<int> addCoin)
{
_tryConsumeCoin = tryConsumeCoin;
_addCoin = addCoin;
}
public bool TryConsumeCoin(int coin)
{
int requiredCoin = Mathf.Max(0, coin);
if (requiredCoin <= 0)
{
return true;
}
return _tryConsumeCoin != null && _tryConsumeCoin.Invoke(requiredCoin);
}
public void AddCoin(int coin)
{
int amount = Mathf.Max(0, coin);
if (amount <= 0)
{
return;
}
_addCoin?.Invoke(amount);
}
public bool TryGetBuildTowerStats(int buildIndex, out TowerStatsData stats) public bool TryGetBuildTowerStats(int buildIndex, out TowerStatsData stats)
{ {
stats = null; stats = null;
@ -141,9 +106,7 @@ namespace GeometryTD.Entity.EntityData
_initialCoin, _initialCoin,
_buildTowerStatsSnapshot, _buildTowerStatsSnapshot,
_inventorySnapshot, _inventorySnapshot,
_participantTowerSnapshot, _participantTowerSnapshot);
_tryConsumeCoin,
_addCoin);
} }
public void SetParticipantTowerSnapshot(IReadOnlyList<TowerItemData> participantTowerSnapshot) public void SetParticipantTowerSnapshot(IReadOnlyList<TowerItemData> participantTowerSnapshot)

View File

@ -0,0 +1,18 @@
using System;
namespace GeometryTD.Entity.EntityData
{
public sealed class MapEntityLoadContext
{
public MapEntityLoadContext(MapData initialMapData, Func<int, bool> tryConsumeCoin, Action<int> addCoin)
{
InitialMapData = initialMapData;
TryConsumeCoin = tryConsumeCoin;
AddCoin = addCoin;
}
public MapData InitialMapData { get; }
public Func<int, bool> TryConsumeCoin { get; }
public Action<int> AddCoin { get; }
}
}

View File

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

View File

@ -56,6 +56,17 @@ namespace GeometryTD
public static void ShowMap(this EntityComponent entityComponent, MapData data, string mapAssetName) public static void ShowMap(this EntityComponent entityComponent, MapData data, string mapAssetName)
{ {
ShowMap(entityComponent, new MapEntityLoadContext(data, null, null), mapAssetName);
}
public static void ShowMap(this EntityComponent entityComponent, MapEntityLoadContext loadContext)
{
ShowMap(entityComponent, loadContext, null);
}
public static void ShowMap(this EntityComponent entityComponent, MapEntityLoadContext loadContext, string mapAssetName)
{
MapData data = loadContext?.InitialMapData;
if (data == null) if (data == null)
{ {
Log.Warning("Map data is invalid."); Log.Warning("Map data is invalid.");
@ -64,7 +75,7 @@ namespace GeometryTD
string resolvedMapAssetName = string.IsNullOrEmpty(mapAssetName) ? data.LevelId.ToString() : mapAssetName; string resolvedMapAssetName = string.IsNullOrEmpty(mapAssetName) ? data.LevelId.ToString() : mapAssetName;
string mapAssetPath = AssetUtility.GetLevelMapAsset(resolvedMapAssetName); string mapAssetPath = AssetUtility.GetLevelMapAsset(resolvedMapAssetName);
entityComponent.ShowEntity(data.Id, typeof(MapEntity), mapAssetPath, "Map", Constant.AssetPriority.MapAsset, data); entityComponent.ShowEntity(data.Id, typeof(MapEntity), mapAssetPath, "Map", Constant.AssetPriority.MapAsset, loadContext);
} }
private static void ShowEntity(this EntityComponent entityComponent, Type logicType, string entityGroup, private static void ShowEntity(this EntityComponent entityComponent, Type logicType, string entityGroup,

View File

@ -1,8 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using GeometryTD.CustomComponent; using GeometryTD.CustomComponent;
using GeometryTD.CustomEvent;
using GameFramework.Event;
using GeometryTD.Map; using GeometryTD.Map;
using GeometryTD.Definition; using GeometryTD.Definition;
using GeometryTD.Entity.EntityData; using GeometryTD.Entity.EntityData;
@ -37,10 +35,8 @@ namespace GeometryTD.Entity
private CombatSelectInputService _combatSelectInputService; private CombatSelectInputService _combatSelectInputService;
private TowerPlacementService _towerPlacementService; private TowerPlacementService _towerPlacementService;
private TowerSelectionPresenter _towerSelectionPresenter; private TowerSelectionPresenter _towerSelectionPresenter;
private CombatSelectUseCaseConfigurator _combatSelectUseCaseConfigurator; private CombatSelectUseCaseConfigurator _combatSelectUseCaseConfigurator;
private int _currentCoin; private MapCombatRuntimeBridge _combatRuntimeBridge;
private bool _isCoinEventSubscribed;
public IReadOnlyList<Vector3Int> PathCells => _mapTopologyService != null public IReadOnlyList<Vector3Int> PathCells => _mapTopologyService != null
? _mapTopologyService.PathCells ? _mapTopologyService.PathCells
@ -110,22 +106,21 @@ namespace GeometryTD.Entity
InitializeMapTopologyService(); InitializeMapTopologyService();
InitializeTowerPlacementService(); InitializeTowerPlacementService();
InitializeTowerSelectionPresenter(); InitializeTowerSelectionPresenter();
InitializeCombatRuntimeBridge();
} }
protected override void OnShow(object userData) protected override void OnShow(object userData)
{ {
base.OnShow(userData); MapEntityLoadContext loadContext = ResolveLoadContext(userData);
_mapData = loadContext?.InitialMapData;
_mapData = userData as MapData;
if (_mapData == null) if (_mapData == null)
{ {
Log.Warning("MapData is invalid for map entity '{0}'.", Id); Log.Warning("MapData is invalid for map entity '{0}'.", Id);
} }
else
{ base.OnShow(_mapData);
_currentCoin = Mathf.Max(0, _mapData.InitialCoin);
SubscribeCombatEvents(); _combatRuntimeBridge?.Initialize(loadContext, name);
}
RefreshTiles(); RefreshTiles();
ConfigureCombatSelectUseCase(); ConfigureCombatSelectUseCase();
@ -134,7 +129,7 @@ namespace GeometryTD.Entity
protected override void OnHide(bool isShutdown, object userData) protected override void OnHide(bool isShutdown, object userData)
{ {
UnsubscribeCombatEvents(); _combatRuntimeBridge?.Reset();
HideCombatSelectForm(); HideCombatSelectForm();
_towerPlacementService?.HideAndClearAllPlacedTowers(); _towerPlacementService?.HideAndClearAllPlacedTowers();
ClearSelectionState(); ClearSelectionState();
@ -240,6 +235,14 @@ namespace GeometryTD.Entity
} }
} }
private void InitializeCombatRuntimeBridge()
{
if (_combatRuntimeBridge == null)
{
_combatRuntimeBridge = new MapCombatRuntimeBridge();
}
}
private void ConfigureCombatSelectUseCase() private void ConfigureCombatSelectUseCase()
{ {
_combatSelectFormUseCase.SetCoinProvider(GetCurrentCoin); _combatSelectFormUseCase.SetCoinProvider(GetCurrentCoin);
@ -257,6 +260,21 @@ namespace GeometryTD.Entity
_mapData != null ? _mapData.ParticipantTowerSnapshot : null); _mapData != null ? _mapData.ParticipantTowerSnapshot : null);
} }
private MapEntityLoadContext ResolveLoadContext(object userData)
{
if (userData is MapEntityLoadContext loadContext)
{
return loadContext;
}
if (userData is MapData legacyMapData)
{
return new MapEntityLoadContext(legacyMapData, null, null);
}
return null;
}
private void HandleCombatSelectInput() private void HandleCombatSelectInput()
{ {
if (!_enableCombatSelectInput || !Input.GetMouseButtonDown(0)) if (!_enableCombatSelectInput || !Input.GetMouseButtonDown(0))
@ -379,38 +397,6 @@ namespace GeometryTD.Entity
GameEntry.UIRouter.CloseUI(UIFormType.CombatSelectForm); GameEntry.UIRouter.CloseUI(UIFormType.CombatSelectForm);
} }
private void SubscribeCombatEvents()
{
if (_isCoinEventSubscribed)
{
return;
}
GameEntry.Event.Subscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged);
_isCoinEventSubscribed = true;
}
private void UnsubscribeCombatEvents()
{
if (!_isCoinEventSubscribed)
{
return;
}
GameEntry.Event.Unsubscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged);
_isCoinEventSubscribed = false;
}
private void OnCombatCoinChanged(object sender, GameEventArgs e)
{
if (e is not CombatCoinChangedEventArgs args)
{
return;
}
_currentCoin = Mathf.Max(0, args.CurrentCoin);
}
private int GetCurrentBuildTowerCount() private int GetCurrentBuildTowerCount()
{ {
if (_mapData == null) if (_mapData == null)
@ -434,12 +420,12 @@ namespace GeometryTD.Entity
return false; return false;
} }
return _mapData.TryConsumeCoin(requiredCoin); return _combatRuntimeBridge != null && _combatRuntimeBridge.TryConsumeCoin(requiredCoin);
} }
private int GetCurrentCoin() private int GetCurrentCoin()
{ {
return Mathf.Max(0, _currentCoin); return Mathf.Max(0, _combatRuntimeBridge != null ? _combatRuntimeBridge.CurrentCoin : 0);
} }
private void AddCoin(int coin) private void AddCoin(int coin)
@ -450,7 +436,7 @@ namespace GeometryTD.Entity
return; return;
} }
_mapData?.AddCoin(amount); _combatRuntimeBridge?.AddCoin(amount);
} }
} }
} }

View File

@ -0,0 +1,85 @@
using System;
using GameFramework.Event;
using GeometryTD.CustomEvent;
using GeometryTD.Entity.EntityData;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace GeometryTD.Map
{
public sealed class MapCombatRuntimeBridge
{
private Func<int, bool> _tryConsumeCoin;
private Action<int> _addCoin;
private bool _isCoinEventSubscribed;
public int CurrentCoin { get; private set; }
public void Initialize(MapEntityLoadContext loadContext, string mapName)
{
Reset();
MapData mapData = loadContext?.InitialMapData;
CurrentCoin = Mathf.Max(0, mapData != null ? mapData.InitialCoin : 0);
_tryConsumeCoin = loadContext?.TryConsumeCoin;
_addCoin = loadContext?.AddCoin;
if (_tryConsumeCoin == null || _addCoin == null)
{
Log.Warning(
"Map combat runtime bridge has incomplete callbacks. Map='{0}', TryConsumeCoin={1}, AddCoin={2}.",
mapName,
_tryConsumeCoin != null,
_addCoin != null);
}
GameEntry.Event.Subscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged);
_isCoinEventSubscribed = true;
}
public void Reset()
{
if (_isCoinEventSubscribed)
{
GameEntry.Event.Unsubscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged);
_isCoinEventSubscribed = false;
}
_tryConsumeCoin = null;
_addCoin = null;
CurrentCoin = 0;
}
public bool TryConsumeCoin(int cost)
{
int requiredCoin = Mathf.Max(0, cost);
if (requiredCoin <= 0)
{
return true;
}
return _tryConsumeCoin != null && _tryConsumeCoin.Invoke(requiredCoin);
}
public void AddCoin(int coin)
{
int amount = Mathf.Max(0, coin);
if (amount <= 0)
{
return;
}
_addCoin?.Invoke(amount);
}
private void OnCombatCoinChanged(object sender, GameEventArgs e)
{
if (e is not CombatCoinChangedEventArgs args)
{
return;
}
CurrentCoin = Mathf.Max(0, args.CurrentCoin);
}
}
}

View File

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

View File

@ -109,9 +109,8 @@ namespace GeometryTD.Map
} }
float minDistance = float.MaxValue; float minDistance = float.MaxValue;
for (int i = 0; i < _pathCells.Count; i++) foreach (var candidate in _pathCells)
{ {
Vector3Int candidate = _pathCells[i];
float distance = (tilemap.GetCellCenterWorld(candidate) - worldPosition).sqrMagnitude; float distance = (tilemap.GetCellCenterWorld(candidate) - worldPosition).sqrMagnitude;
if (distance >= minDistance) if (distance >= minDistance)
{ {

View File

@ -4,32 +4,35 @@
## 当前目标 ## 当前目标
`docs/CombatNodeArchitecture.md` 收敛 `CombatNode` 域职责,重点是: `docs/CombatNodeArchitecture.md` 继续收敛 `CombatNode` 域职责。当前骨架已经基本到位,后续重点是:
- `CombatScheduler` 收敛为“状态机管理器”,不再继续堆业务细节。 - 继续保持 `CombatScheduler` 作为唯一状态机边界,避免把新业务重新堆回本体。
- 局内 `Coin / Gold / BaseHp / Loot Backpack / BuildTowerSnapshots``CombatInRunResourceManager` 作为唯一真值来源。 - 继续完成 `MapData + Event` 解耦收尾,确认 `MapEntity` 不再反查 `CombatNode` 域运行时。
- `EnemyManager` 只上报敌人事件,不直接决定资源入账或状态切换。 - 稳定 `CombatSettlementContext` 的模型边界,避免流程控制字段和展示摘要继续混杂增长。
- `PhaseEndType` 退出条件从 `PhaseLoopRuntime` 中抽离到 `IPhaseEndCondition` 实现类。 - 补 Unity 编译、PlayMode 和失败路径回归验证,把这轮结构调整真正跑通。
- 结束链、加载链、奖励选择链逐步从 `CombatScheduler.cs` 本体挪到状态类或专用服务。
## 已完成 ## 已完成
### 1. 状态类拆分完成 ### 1. 状态类已收口到 CombatScheduler/CombatStates
- `CombatScheduler.cs` 内部的嵌套状态类已经迁移到 `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatStates/` - 状态类当前位于 `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/`
- 保留了 `partial class CombatScheduler + 嵌套类` 的结构。 - `CombatStateBase` 已统一改为 `Context + Flow` 双引用模式。
- 旧 `CombatState` 已统一替换为 `CombatStateBase` - 各状态不再直接访问 `CombatScheduler._xxx` 私有字段,而是通过:
- `CombatSchedulerRuntimeContext`
- `CombatSchedulerFlowCoordinator`
关键文件: 关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatStates/CombatStateBase.cs` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatStateBase.cs`
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatStates/*.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. 第一轮目标命名和骨架已建立 ### 2. 第一轮目标命名、骨架与接线已建立
- `CombatResourceManager` 已重命名为 `CombatInRunResourceManager` - `CombatResourceManager` 已重命名为 `CombatInRunResourceManager`
- 已新增掉落解析骨架: - 已新增掉落解析骨架:
- `EnemyDropResolveContext` - `EnemyDropResolveContext`
- `EnemyDropResolveResult` - `EnemyDropResolveResult`
- `EnemyDropResolver` - `EnemyDropResolver`
- 已新增 phase end 骨架: - 已新增 phase end 骨架并接入等待退出状态
- `IPhaseEndCondition` - `IPhaseEndCondition`
- `PhaseEndConditionContext` - `PhaseEndConditionContext`
- `PhaseEndConditionFactory` - `PhaseEndConditionFactory`
@ -88,7 +91,22 @@
- `TryConsumeCoin(...)` - `TryConsumeCoin(...)`
- `AddCoin(...)` - `AddCoin(...)`
- `TryGetBuildTowerStats(...)` - `TryGetBuildTowerStats(...)`
- 失败调试入口:
- `TryDebugFail(...)`
- 不再通过 `CombatNodeComponent` 读写 baseHp/coin 真值 - 不再通过 `CombatNodeComponent` 读写 baseHp/coin 真值
- 当前主职责已收紧为:
- 生命周期入口
- 状态切换入口
- 对外公开查询/操作接口
- 敌人事件公共入口
- 事件桥回调入口
#### CombatScheduler 内部实现细化
- 已新增 `CombatSchedulerRuntimeContext`
- 承载共享运行时字段与共享服务引用
- 已新增 `CombatSchedulerFlowCoordinator`
- 承载多个状态共用的流程辅助方法
- 当前 `CombatScheduler` 本体不再直接堆所有共享字段和公用流程辅助
#### CombatNodeComponent #### CombatNodeComponent
- 不再持有这些字段: - 不再持有这些字段:
@ -105,85 +123,161 @@
关键文件: 关键文件:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs` - `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` - `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` 共享
- 已显式承载:
- 结算事实
- 奖励背包
- 奖励选择相关流程标记
- 低血惩罚相关事实
- 低血惩罚已改为“先记录事实,提交阶段再统一落库”,不再在结算构造期直接写库存
- `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`
## 还没完成 ## 还没完成
### 1. EnemyManager 事件边界还没改干净 ### 1. MapData + Event 解耦还没完全收口
当前仍然存在的问题:
- `EnemyManager` 还在自己计算 `droppedCoin / droppedGold / baseDamage`
- 然后把这些原始数值直接传给 `CombatScheduler`
目标状态:
- `EnemyManager` 只上报:
- `OnEnemyDefeated(DREnemy enemy)`
- `OnEnemyReachedBase(DREnemy enemy)`
- `CombatScheduler` 统一调用:
- `EnemyDropResolver`
- `CombatInRunResourceManager`
下一步建议:
- 先改 `EnemyManager` 上报签名
- 再把 coin/gold/baseDamage 的公共副作用挪到 `CombatScheduler`
### 2. PhaseEndCondition 骨架已建,但还没接入
当前仍然是旧逻辑:
- `PhaseLoopRuntime.ShouldEndCurrentPhase(...)` 还在使用
- `CombatWaitingForPhaseEndState` 还没有改成通过 `IPhaseEndCondition` 判定
目标状态:
- `PhaseLoopRuntime` 只保留 phase runtime 数据
- `CombatWaitingForPhaseEndState` 通过 `PhaseEndConditionFactory.Create(...)` 获取当前判定器
- `PhaseEndType` 的规则不再写在 `PhaseLoopRuntime`
### 3. CombatScheduler 本体仍然过重
当前仍然还在 `CombatScheduler.cs` 里的业务:
- 结算上下文构建
- 基地血量结算修正
- 奖励选择 UI 准备
- FinishForm 打开逻辑
- 一部分加载和清理编排
目标状态:
- `Loading` 负责加载和 `MapData` 组装
- `Settlement` 负责结算上下文和奖励准入判断
- `RewardSelection` 只处理选择流程
- `FinishForm` 只处理展示
- `WaitingForReturn` 只处理回退和最终清理
### 4. MapData + Event 解耦还没开始
当前: 当前:
- `MapData` 只有 `LevelId` - `CombatLoadingState` 已经会组装更完整的 `MapData`
- `MapEntity` 仍通过 `GameEntry.CombatNode` 反查 coin / build stats - `MapEntity` 已经主要从 `MapData` 取初始上下文
- 但还需要继续确认地图侧事件和运行时依赖是否已经完全摆脱对 `CombatNode` 域的反查
目标状态: 目标状态:
- `CombatLoadingState``CombatInRunResourceManager` 读取快照 - `CombatLoadingState``CombatInRunResourceManager` 读取快照
- `CombatLoadingState` 组装更完整的 `MapData` - `CombatLoadingState` 组装更完整的 `MapData`
- `MapEntity` 后续通过 `MapData + Event` 获取上下文,而不是反查 `CombatNode` - `MapEntity` 后续通过 `MapData + Event` 获取上下文,而不是反查 `CombatNode`
### 2. CombatScheduler 本体仍然还有收紧空间
当前:
- 生命周期入口、状态切换入口、对外公开接口、事件桥回调入口仍在 `CombatScheduler.cs`
- 共享字段和共享流程已经拆到了 `RuntimeContext + FlowCoordinator`
- 但 `FlowCoordinator` 目前仍直接依赖 `CombatScheduler` 宿主对象
目标状态:
- 对外公开接口与内部宿主职责进一步分离
- 若继续收紧,可评估是否引入极小宿主接口,只暴露:
- `ChangeState(...)`
- 必要的回调/转发入口
- 继续避免把新业务重新塞回 `CombatScheduler.cs`
### 3. CombatSettlementContext 还可以继续整理字段分层
当前:
- `CombatSettlementContext` 已能支撑结束链
- 但字段仍是平铺增长
目标状态:
- 更明确地区分:
- 流程控制字段
- 结算事实字段
- 展示摘要字段
- 避免后续继续把 UI 需求和流程控制混在一起
### 4. 需要补实际运行验证
当前改动已覆盖:
- 状态机结构
- 结束链/失败链协议
- 手动失败测试入口
- `CombatSchedulerRuntimeContext + CombatSchedulerFlowCoordinator`
- `CombatSettlementContext` 与延迟提交惩罚
但仍缺:
- Unity 编译验证
- PlayMode/手点验证
- 失败路径回收验证
- 新开局后无残留状态验证
## 推荐的后续执行顺序 ## 推荐的后续执行顺序
1. 改 `EnemyManager` 上报边界,接入 `EnemyDropResolver` 1. `MapData + Event` 解耦收尾,排查 `MapEntity` 和地图侧事件是否还反查 `CombatNode`
2. 接入 `IPhaseEndCondition`,移除 `PhaseLoopRuntime.ShouldEndCurrentPhase(...)` 2. 评估是否给 `FlowCoordinator` 引入极小宿主接口,继续收紧 `CombatScheduler` 本体
3. 把结算链逻辑从 `CombatScheduler.cs` 继续剥离到状态类 3. 继续整理 `CombatSettlementContext` 的字段分层
4. 开始做 `MapData + Event` 解耦 4. 补 Unity 编译与手动回归验证
## 当前做变更时要记住的约束 ## 当前做变更时要记住的约束
- 状态切换只能通过 `CombatScheduler.ChangeState(...)` - 状态切换只能通过 `CombatScheduler.ChangeState(...)`
- 不要把新业务继续堆回 `CombatScheduler.cs` - 不要把新业务继续堆回 `CombatScheduler.cs`,优先考虑:
- 状态私有逻辑
- `CombatSchedulerFlowCoordinator`
- 现有独立服务
- `CombatNodeComponent` 现在应该保持轻量 facade不要再把 coin/baseHp/build snapshot 回流到它 - `CombatNodeComponent` 现在应该保持轻量 facade不要再把 coin/baseHp/build snapshot 回流到它
- coin/baseHp 变化事件应继续由 `CombatInRunResourceManager` 发布 - coin/baseHp 变化事件应继续由 `CombatInRunResourceManager` 发布
- `Failed` 只处理异常失败,不处理“基地血量归零”的正常失败路径 - `Failed` 只处理异常失败,不处理“基地血量归零”的正常失败路径
- 状态类应继续只通过 `CombatSchedulerRuntimeContext + CombatSchedulerFlowCoordinator` 访问共享状态与共享流程
## 当前关键入口文件速查 ## 当前关键入口文件速查
- 状态机宿主: - 状态机宿主:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs` - `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/CombatInRunResourceManager.cs`
- 状态类: - 状态类:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatStates/` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/`
- 加载服务: - 加载服务:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatLoadSession.cs` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatLoadSession.cs`
- phase runtime - phase runtime
@ -194,6 +288,10 @@
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyManager.cs` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyManager.cs`
- CombatNode 入口 facade - CombatNode 入口 facade
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs`
- 结束链共享上下文:
- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs`
- 异常失败返回事件:
- `Assets/GameMain/Scripts/Event/Combat/CombatFailureReturnEventArgs.cs`
## 备注 ## 备注

View File

@ -65,8 +65,12 @@
- `CombatFailedState` - `CombatFailedState`
实现约束: 实现约束:
- 上述状态类作为 `CombatScheduler` 的嵌套类实现。 - 上述状态类可以作为 `CombatScheduler` 的嵌套类实现,也可以拆成独立文件;但必须只服务于 `CombatScheduler` 状态机,不形成独立业务边界。
- 共享数据与共享服务统一放在 `CombatScheduler` 上。 - 共享数据与共享服务统一收口到 `CombatScheduler` 内部持有的运行时承载体,不允许散落在各状态类中。
- 若 `CombatScheduler` 体量过大,允许在其内部实现中继续拆出:
- `CombatSchedulerRuntimeContext`:承载共享运行时字段与共享服务引用
- `CombatSchedulerFlowCoordinator`:承载多个状态共用的流程辅助方法
- 上述拆分只属于 `CombatScheduler` 的内部实现细化,不改变 `CombatScheduler` 作为唯一状态机边界的职责。
- 所有状态切换只能通过 `CombatScheduler.ChangeState(...)` 完成。 - 所有状态切换只能通过 `CombatScheduler.ChangeState(...)` 完成。
- 状态类不能彼此直接操控。 - 状态类不能彼此直接操控。
@ -223,6 +227,19 @@
- 跟踪加载成功/失败状态。 - 跟踪加载成功/失败状态。
- 对外提供 `CurrentMap``IsReady` - 对外提供 `CurrentMap``IsReady`
### 4.1.x CombatSchedulerRuntimeContext / CombatSchedulerFlowCoordinator实现细化
当前实现允许:
- 用 `CombatSchedulerRuntimeContext` 承载所有状态共享的运行时字段与共享服务引用。
- 用 `CombatSchedulerFlowCoordinator` 承载多个状态共用的流程辅助逻辑。
约束:
- 两者都必须由 `CombatScheduler` 持有并统一管理生命周期。
- 两者都不替代 `CombatScheduler` 对外暴露状态机边界。
- `RuntimeContext` 不负责状态切换。
- `FlowCoordinator` 不持有独立业务真值,只能围绕共享运行时做编排辅助。
- 状态类只允许通过 `RuntimeContext + FlowCoordinator` 访问共享状态与共享流程,不应再直接耦合其他状态实现细节。
### 4.2 PhaseLoopRuntime ### 4.2 PhaseLoopRuntime
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseLoopRuntime.cs` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/PhaseLoopRuntime.cs`