补全 `CombatNode` 逻辑 + 重构 `EnemyManager`

- 添加了关卡内的难度系数与掉落
    - 难度系数:每循环一轮波次敌人血量翻倍
    - 道具掉落:按照掉落概率曲线根据波次计算当前爆率,并从新增的道具池里选择道具
    - `CombatResourceManager`:统一维护关卡内资源状态(硬币/金币/道具掉落)
- 重构 `EnemyManager`:
    - `EnemyManager`:编排子服务,不承载具体业务细节
    - `EnemySpawnDirector`:管理刷怪时序
    - `SpawnerResolver`:管理出生点与路径
    - `EnemyLifecycleTracker`:追踪敌人生命周期
    - `EnemyConfigService`:管理敌人配置与倍率
This commit is contained in:
SepComet 2026-03-02 17:23:34 +08:00
parent c576224991
commit 5ba94828a8
49 changed files with 1763 additions and 1047 deletions

View File

@ -1,6 +1,6 @@
# Id 策划备注 EntityId Name AttackSpeed PropertyType Constraint PossibleTag # Id 策划备注 EntityId Name AttackSpeed AttackSpeedPerLevel PropertyType Constraint PossibleTag
# int int string float[] AttackPropertyType string TagType[] # int int string float[] float AttackPropertyType string TagType[]
# 底座编号 实体Id 底座名 攻击速度(秒/次) 攻击属性 属性约束 可能出现的Tag # 底座编号 实体Id 底座名 各品质攻击速度(秒/次) 每级提升攻击速度 攻击属性 属性约束 可能出现的Tag
1 301 元素底座 [2,1.5,1,0.8,0.7] Fire [Fire,BurnSpread,IgniteBurst,Inferno] 1 301 元素底座 [2,1.5,1,0.8,0.7] -0.2 Fire [Fire,BurnSpread,IgniteBurst,Inferno]
2 301 控制底座 [4,3.8,3.6,3.4,3.2] Ice [Ice,FreezeMask,Shatter,AbsoluteZero] 2 301 控制底座 [4,3.8,3.6,3.4,3.2] -0.1 Ice [Ice,FreezeMask,Shatter,AbsoluteZero]
3 301 穿透底座 [1,0,8,0,6,0.4,0.2] Physics [Pierce,Crit,Overpenetrate,Execution] 3 301 穿透底座 [1,0,8,0,6,0.4,0.2] 0 Physics [Pierce,Crit,Overpenetrate,Execution]

View File

@ -1,6 +1,6 @@
# Id 策划备注 EntityId Name RotateSpeed AttackRange Constraint PossibleTag # Id 策划备注 EntityId Name RotateSpeed RotateSpeedPerLevel AttackRange AttackRangePerLevel Constraint PossibleTag
# int int string float[] float[] string TagType[] # int int string float[] float float[] float string TagType[]
# 轴承编号 实体Id 轴承名 各品质旋转速度 各品质攻击范围 属性约束 可能出现的Tag # 轴承编号 实体Id 轴承名 各品质旋转速度 每级提升旋转速度 各品质攻击范围 每级提升攻击范围 属性约束 可能出现的Tag
1 201 元素轴承 [10,12,13,14,15] [2,2,2,2,2] [Fire,BurnSpread,IgniteBurst,Inferno] 1 201 元素轴承 [10,12,13,14,15] 15 [2,2,2,2,2] 0.3 [Fire,BurnSpread,IgniteBurst,Inferno]
2 201 控制轴承 [20,25,30,32,35] [6,6.5,7,8,8] [Ice,FreezeMask,Shatter,AbsoluteZero] 2 201 控制轴承 [20,25,30,32,35] 0 [6,6.5,7,8,8] 0.2 [Ice,FreezeMask,Shatter,AbsoluteZero]
3 201 穿透轴承 [60,70,80,90,100] [4,4.5,5,5.5,6] [Pierce,Crit,Overpenetrate,Execution] 3 201 穿透轴承 [60,70,80,90,100] 20 [4,4.5,5,5.5,6] 0 [Pierce,Crit,Overpenetrate,Execution]

View File

@ -2,6 +2,6 @@
# int LevelThemeType int int VictoryType string int # int LevelThemeType int int VictoryType string int
# 关卡号 策划备注 关卡所属主题类型 基地初始生命 初始硬币 胜利条件 胜利参数 奖励金币 # 关卡号 策划备注 关卡所属主题类型 基地初始生命 初始硬币 胜利条件 胜利参数 奖励金币
1 平原1 Plain 100 100 PhasesCleared 30 1 平原1 Plain 100 100 PhasesCleared 30
2 平原2 Plain 100 100 PhasesCleared 30 2 平原2 Plain 100 120 PhasesCleared 30
3 平原3 Plain 100 100 PhasesCleared 40 3 平原3 Plain 100 110 PhasesCleared 40
4 平原4 Plain 100 100 BossDead 100 4 平原4 Plain 100 150 BossDead 100

View File

@ -1,6 +1,6 @@
# Id 策划备注 EntityId Name AttackDamage DamageRandomRate Method Constraint PossibleTag # Id 策划备注 EntityId Name AttackDamage AttackDamagePerLevel DamageRandomRate Method Constraint PossibleTag
# int int string int[] float AttackMethodType Constraint TagType[] # int int string int[] int float AttackMethodType Constraint TagType[]
# 枪口编号 策划备注 实体Id 枪口名 各品质伤害 伤害浮动 攻击方式 Constraint 可能出现的Tag # 枪口编号 策划备注 实体Id 枪口名 各品质伤害 每级伤害提升 伤害浮动 攻击方式 Constraint 可能出现的Tag
1 101 元素枪口 [20,30,40,50,80] 0.05 NormalBullet [Fire,BurnSpread,IgniteBurst,Inferno] 1 101 元素枪口 [20,30,40,50,80] 10 0.05 NormalBullet [Fire,BurnSpread,IgniteBurst,Inferno]
2 101 控制枪口 [30,50,70,90,100] 0.01 NormalBullet [Ice,FreezeMask,Shatter,AbsoluteZero] 2 101 控制枪口 [30,50,70,90,100] 20 0.01 NormalBullet [Ice,FreezeMask,Shatter,AbsoluteZero]
3 101 穿透枪口 [50,55,60,80,90] 0.02 NormalBullet [Pierce,Crit,Overpenetrate,Execution] 3 101 穿透枪口 [50,55,60,80,90] 30 0.02 NormalBullet [Pierce,Crit,Overpenetrate,Execution]

View File

@ -0,0 +1,21 @@
# Id 列1 LevelThemeType Rarity ItemType ItemId Weight MinPhase MaxPhase
# int LevelThemeType RarityType ItemType int int int int
# 道具池号 策划备注 关卡所属主题类型 道具稀有度 道具类型 道具Id 权重 出现的最低波次 出现的最高波次
1 平原战斗池 Plain White MuzzleComp 1 10 1 10
2 Plain White MuzzleComp 2 10 1 10
3 Plain White MuzzleComp 3 10 1 10
4 Plain White BearingComp 1 10 1 10
5 Plain White BearingComp 2 10 1 10
6 Plain White BearingComp 3 10 1 10
7 Plain White BaseComp 1 10 1 10
8 Plain White BaseComp 2 10 1 10
9 Plain White BaseComp 3 10 1 10
10 Plain Green MuzzleComp 1 5 1 10
11 Plain Green MuzzleComp 2 5 1 10
12 Plain Green MuzzleComp 3 5 1 10
13 Plain Green BearingComp 1 5 1 10
14 Plain Green BearingComp 2 5 1 10
15 Plain Green BearingComp 3 5 1 10
16 Plain Green BaseComp 1 5 1 10
17 Plain Green BaseComp 2 5 1 10
18 Plain Green BaseComp 3 5 1 10

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 934d71d10a1e14141a275b57b1825dfd
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c52befd7a6c741b9bcd8f4effd7879b8
timeCreated: 1772440848

View File

@ -0,0 +1,398 @@
using System;
using System.Collections.Generic;
using GameFramework.DataTable;
using GeometryTD.DataTable;
using GeometryTD.Definition;
using UnityEngine;
using Random = UnityEngine.Random;
namespace GeometryTD.CustomComponent
{
internal sealed class CombatResourceManager
{
private const float DropChanceBase = 0.05f;
private const float DropChancePerPhase = 0.0125f;
private const float DropChanceCap = 0.45f;
private const float RarityCurveScalePhase = 30f;
private readonly List<DROutGameDropPool> _eligibleDropPoolBuffer = new();
private readonly Dictionary<RarityType, float> _rarityRollWeightBuffer = new();
private readonly BackpackInventoryData _rewardInventory = new();
private IDataTable<DROutGameDropPool> _drOutGameDropPool;
private IDataTable<DRMuzzleComp> _drMuzzleComp;
private IDataTable<DRBearingComp> _drBearingComp;
private IDataTable<DRBaseComp> _drBaseComp;
private long _nextDropItemInstanceId = 1;
public int GainedCoin { get; private set; }
public int GainedGold { get; private set; }
public void Reset()
{
GainedCoin = 0;
GainedGold = 0;
_rewardInventory.Gold = 0;
_rewardInventory.MuzzleComponents.Clear();
_rewardInventory.BearingComponents.Clear();
_rewardInventory.BaseComponents.Clear();
_rewardInventory.Towers.Clear();
_nextDropItemInstanceId = 1;
}
public BackpackInventoryData GetRewardInventorySnapshot()
{
return new BackpackInventoryData
{
Gold = _rewardInventory.Gold,
MuzzleComponents = new List<MuzzleCompItemData>(_rewardInventory.MuzzleComponents),
BearingComponents = new List<BearingCompItemData>(_rewardInventory.BearingComponents),
BaseComponents = new List<BaseCompItemData>(_rewardInventory.BaseComponents),
Towers = new List<DefenseTowerItemData>(_rewardInventory.Towers)
};
}
public void AddEnemyDefeatedReward(int gainedCoin, int gainedGold)
{
int coin = Mathf.Max(0, gainedCoin);
int gold = Mathf.Max(0, gainedGold);
GainedCoin += coin;
GainedGold += gold;
if (gold > 0)
{
_rewardInventory.Gold += gold;
}
GameEntry.CombatNode?.ApplyEnemyDropReward(coin, gold);
}
public bool TryRollOutGameItemDrop(int displayPhaseIndex, LevelThemeType themeType)
{
int phaseIndex = Mathf.Max(1, displayPhaseIndex);
float dropChance = Mathf.Clamp(DropChanceBase + (phaseIndex - 1) * DropChancePerPhase, 0f, DropChanceCap);
if (Random.value > dropChance)
{
return false;
}
if (!TryPickDropPoolRow(phaseIndex, themeType, out DROutGameDropPool selectedRow))
{
return false;
}
if (!TryBuildDropItem(selectedRow, out TowerCompItemData droppedItem))
{
return false;
}
if (droppedItem is MuzzleCompItemData muzzleCompItemData)
{
_rewardInventory.MuzzleComponents.Add(muzzleCompItemData);
return true;
}
if (droppedItem is BearingCompItemData bearingCompItemData)
{
_rewardInventory.BearingComponents.Add(bearingCompItemData);
return true;
}
if (droppedItem is BaseCompItemData baseCompItemData)
{
_rewardInventory.BaseComponents.Add(baseCompItemData);
return true;
}
return false;
}
private bool TryPickDropPoolRow(int displayPhaseIndex, LevelThemeType themeType, out DROutGameDropPool selectedRow)
{
selectedRow = null;
IDataTable<DROutGameDropPool> dropTable = EnsureOutGameDropPoolTable();
if (dropTable == null)
{
return false;
}
_eligibleDropPoolBuffer.Clear();
DROutGameDropPool[] allRows = dropTable.GetAllDataRows();
for (int i = 0; i < allRows.Length; i++)
{
DROutGameDropPool row = allRows[i];
if (row == null)
{
continue;
}
if (row.LevelThemeType != themeType)
{
continue;
}
if (displayPhaseIndex < row.MinPhase || displayPhaseIndex > row.MaxPhase)
{
continue;
}
_eligibleDropPoolBuffer.Add(row);
}
if (_eligibleDropPoolBuffer.Count <= 0)
{
return false;
}
RarityType selectedRarity = RollRarity(displayPhaseIndex, _eligibleDropPoolBuffer);
if (selectedRarity == RarityType.None)
{
return false;
}
int totalWeight = 0;
for (int i = 0; i < _eligibleDropPoolBuffer.Count; i++)
{
DROutGameDropPool row = _eligibleDropPoolBuffer[i];
if (row.Rarity != selectedRarity)
{
continue;
}
totalWeight += Mathf.Max(1, row.Weight);
}
if (totalWeight <= 0)
{
return false;
}
int randomWeight = Random.Range(1, totalWeight + 1);
int cumulativeWeight = 0;
for (int i = 0; i < _eligibleDropPoolBuffer.Count; i++)
{
DROutGameDropPool row = _eligibleDropPoolBuffer[i];
if (row.Rarity != selectedRarity)
{
continue;
}
cumulativeWeight += Mathf.Max(1, row.Weight);
if (randomWeight <= cumulativeWeight)
{
selectedRow = row;
return true;
}
}
selectedRow = _eligibleDropPoolBuffer[_eligibleDropPoolBuffer.Count - 1];
return selectedRow != null;
}
private RarityType RollRarity(int displayPhaseIndex, List<DROutGameDropPool> candidates)
{
_rarityRollWeightBuffer.Clear();
float phaseT = Mathf.Clamp01((displayPhaseIndex - 1) / RarityCurveScalePhase);
for (int i = 0; i < candidates.Count; i++)
{
DROutGameDropPool row = candidates[i];
if (row == null)
{
continue;
}
float curveWeight = GetRarityCurveWeight(row.Rarity, phaseT);
if (curveWeight <= 0f)
{
continue;
}
if (_rarityRollWeightBuffer.TryGetValue(row.Rarity, out float existingWeight))
{
_rarityRollWeightBuffer[row.Rarity] = existingWeight + Mathf.Max(1, row.Weight) * curveWeight;
}
else
{
_rarityRollWeightBuffer[row.Rarity] = Mathf.Max(1, row.Weight) * curveWeight;
}
}
float totalWeight = 0f;
foreach (var pair in _rarityRollWeightBuffer)
{
totalWeight += Mathf.Max(0f, pair.Value);
}
if (totalWeight <= 0f)
{
return RarityType.None;
}
float randomWeight = Random.value * totalWeight;
float cumulativeWeight = 0f;
foreach (var pair in _rarityRollWeightBuffer)
{
cumulativeWeight += Mathf.Max(0f, pair.Value);
if (randomWeight <= cumulativeWeight)
{
return pair.Key;
}
}
foreach (var pair in _rarityRollWeightBuffer)
{
return pair.Key;
}
return RarityType.None;
}
private static float GetRarityCurveWeight(RarityType rarityType, float phaseT)
{
float hump = Mathf.Exp(-Mathf.Pow((phaseT - 0.35f) / 0.28f, 2f));
switch (rarityType)
{
case RarityType.White:
return Mathf.Max(0.05f, 0.18f + 1.25f * hump);
case RarityType.Green:
return Mathf.Max(0.05f, 0.35f + 0.55f * hump);
case RarityType.Blue:
return 0.18f + 0.55f * phaseT;
case RarityType.Purple:
return 0.05f + 0.22f * phaseT;
case RarityType.Red:
return 0.01f + 0.08f * phaseT * phaseT;
default:
return 0f;
}
}
private IDataTable<DROutGameDropPool> EnsureOutGameDropPoolTable()
{
if (_drOutGameDropPool != null)
{
return _drOutGameDropPool;
}
_drOutGameDropPool = GameEntry.DataTable.GetDataTable<DROutGameDropPool>();
return _drOutGameDropPool;
}
private bool TryBuildDropItem(DROutGameDropPool row, out TowerCompItemData droppedItem)
{
droppedItem = null;
if (row == null || row.ItemId <= 0 || string.IsNullOrWhiteSpace(row.ItemType))
{
return false;
}
string itemType = row.ItemType.Trim();
if (itemType.Equals("MuzzleComp", StringComparison.OrdinalIgnoreCase))
{
return TryBuildMuzzleCompItem(row, out droppedItem);
}
if (itemType.Equals("BearingComp", StringComparison.OrdinalIgnoreCase))
{
return TryBuildBearingCompItem(row, out droppedItem);
}
if (itemType.Equals("BaseComp", StringComparison.OrdinalIgnoreCase))
{
return TryBuildBaseCompItem(row, out droppedItem);
}
return false;
}
private bool TryBuildMuzzleCompItem(DROutGameDropPool row, out TowerCompItemData droppedItem)
{
droppedItem = null;
_drMuzzleComp ??= GameEntry.DataTable.GetDataTable<DRMuzzleComp>();
if (_drMuzzleComp == null)
{
return false;
}
DRMuzzleComp config = _drMuzzleComp.GetDataRow(row.ItemId);
if (config == null)
{
return false;
}
droppedItem = new MuzzleCompItemData
{
InstanceId = _nextDropItemInstanceId++,
ConfigId = config.Id,
Name = config.Name,
Rarity = row.Rarity,
Endurance = 100f,
Constraint = config.Constraint,
Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty<TagType>(),
AttackDamage = config.AttackDamage != null ? (int[])config.AttackDamage.Clone() : Array.Empty<int>(),
DamageRandomRate = config.DamageRandomRate,
AttackMethodType = config.AttackMethodType
};
return true;
}
private bool TryBuildBearingCompItem(DROutGameDropPool row, out TowerCompItemData droppedItem)
{
droppedItem = null;
_drBearingComp ??= GameEntry.DataTable.GetDataTable<DRBearingComp>();
if (_drBearingComp == null)
{
return false;
}
DRBearingComp config = _drBearingComp.GetDataRow(row.ItemId);
if (config == null)
{
return false;
}
droppedItem = new BearingCompItemData
{
InstanceId = _nextDropItemInstanceId++,
ConfigId = config.Id,
Name = config.Name,
Rarity = row.Rarity,
Endurance = 100f,
Constraint = config.Constraint,
Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty<TagType>(),
RotateSpeed = config.RotateSpeed != null ? (float[])config.RotateSpeed.Clone() : Array.Empty<float>(),
AttackRange = config.AttackRange != null ? (float[])config.AttackRange.Clone() : Array.Empty<float>()
};
return true;
}
private bool TryBuildBaseCompItem(DROutGameDropPool row, out TowerCompItemData droppedItem)
{
droppedItem = null;
_drBaseComp ??= GameEntry.DataTable.GetDataTable<DRBaseComp>();
if (_drBaseComp == null)
{
return false;
}
DRBaseComp config = _drBaseComp.GetDataRow(row.ItemId);
if (config == null)
{
return false;
}
droppedItem = new BaseCompItemData
{
InstanceId = _nextDropItemInstanceId++,
ConfigId = config.Id,
Name = config.Name,
Rarity = row.Rarity,
Endurance = 100f,
Constraint = config.Constraint,
Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty<TagType>(),
AttackSpeed = config.AttackSpeed != null ? (float[])config.AttackSpeed.Clone() : Array.Empty<float>(),
AttackPropertyType = config.AttackPropertyType
};
return true;
}
}
}

View File

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

View File

@ -27,14 +27,13 @@ namespace GeometryTD.CustomComponent
private readonly PhaseLoopRuntime _phaseLoopRuntime = new(); private readonly PhaseLoopRuntime _phaseLoopRuntime = new();
private readonly CombatLoadSession _loadSession = new(); private readonly CombatLoadSession _loadSession = new();
private readonly CombatEventBridge _eventBridge = new(); private readonly CombatEventBridge _eventBridge = new();
private readonly CombatResourceManager _combatResourceManager = new();
private EntityComponent _entity; private EntityComponent _entity;
private DRLevel _currentLevel; private DRLevel _currentLevel;
private CombatFinishFormUseCase _combatFinishFormUseCase; private CombatFinishFormUseCase _combatFinishFormUseCase;
private SchedulerState _state = SchedulerState.Idle; private SchedulerState _state = SchedulerState.Idle;
private bool _initialized; private bool _initialized;
private int _gainedCoin;
private int _gainedGold;
private bool _isFinishAsVictory = true; private bool _isFinishAsVictory = true;
public bool IsRunning => _state == SchedulerState.WaitingForLoading || _state == SchedulerState.RunningPhase; public bool IsRunning => _state == SchedulerState.WaitingForLoading || _state == SchedulerState.RunningPhase;
@ -43,10 +42,11 @@ namespace GeometryTD.CustomComponent
public DRLevelPhase CurrentPhase => _phaseLoopRuntime.CurrentPhase; public DRLevelPhase CurrentPhase => _phaseLoopRuntime.CurrentPhase;
public MapEntity CurrentMap => _loadSession.CurrentMap; public MapEntity CurrentMap => _loadSession.CurrentMap;
public int DisplayPhaseIndex => _phaseLoopRuntime.DisplayPhaseIndex; public int DisplayPhaseIndex => _phaseLoopRuntime.DisplayPhaseIndex;
public int PhaseCount => _phaseLoopRuntime.PhaseCount;
public bool CanEndCombat => _phaseLoopRuntime.CanEndCombat; public bool CanEndCombat => _phaseLoopRuntime.CanEndCombat;
public int DefeatedEnemyCount => _enemyManager.DefeatedEnemyCount; public int DefeatedEnemyCount => _enemyManager.DefeatedEnemyCount;
public int GainedCoin => _gainedCoin; public int GainedCoin => _combatResourceManager.GainedCoin;
public int GainedGold => _gainedGold; public int GainedGold => _combatResourceManager.GainedGold;
public void OnInit() public void OnInit()
{ {
@ -89,8 +89,7 @@ namespace GeometryTD.CustomComponent
_enemyManager.EndPhase(); _enemyManager.EndPhase();
_enemyManager.ResetCombatStats(); _enemyManager.ResetCombatStats();
ResetRuntime(); ResetRuntime();
_gainedCoin = 0; _combatResourceManager.Reset();
_gainedGold = 0;
_isFinishAsVictory = true; _isFinishAsVictory = true;
_currentLevel = level; _currentLevel = level;
@ -229,6 +228,10 @@ namespace GeometryTD.CustomComponent
GameEntry.Event.Fire( GameEntry.Event.Fire(
this, this,
CombatProcessEventArgs.Create(_phaseLoopRuntime.DisplayPhaseIndex, _phaseLoopRuntime.PhaseCount)); CombatProcessEventArgs.Create(_phaseLoopRuntime.DisplayPhaseIndex, _phaseLoopRuntime.PhaseCount));
GameEntry.Event.Fire(
this,
CombatEnemyHpRateChangedEventArgs.Create(
ResolveEnemyHpRateMultiplier(_phaseLoopRuntime.DisplayPhaseIndex, _phaseLoopRuntime.PhaseCount)));
Log.Info( Log.Info(
"CombatScheduler phase started. Level={0}, Phase={1}, EndType={2}, Entries={3}.", "CombatScheduler phase started. Level={0}, Phase={1}, EndType={2}, Entries={3}.",
@ -241,6 +244,7 @@ namespace GeometryTD.CustomComponent
private void EnterFinishFlow(string reason, bool isVictory) private void EnterFinishFlow(string reason, bool isVictory)
{ {
int defeatedEnemyCount = _enemyManager.DefeatedEnemyCount; int defeatedEnemyCount = _enemyManager.DefeatedEnemyCount;
BackpackInventoryData rewardInventory = _combatResourceManager.GetRewardInventorySnapshot();
// Step 1: stop runtime and clear enemy entities only. // Step 1: stop runtime and clear enemy entities only.
_enemyManager.EndPhase(); _enemyManager.EndPhase();
@ -253,7 +257,7 @@ namespace GeometryTD.CustomComponent
_currentLevel != null ? _currentLevel.Id : 0, _currentLevel != null ? _currentLevel.Id : 0,
reason); reason);
OpenCombatFinishForm(defeatedEnemyCount, _gainedGold); OpenCombatFinishForm(defeatedEnemyCount, _combatResourceManager.GainedGold, rewardInventory);
} }
public void OnEnemyReachedBase(int baseDamage) public void OnEnemyReachedBase(int baseDamage)
@ -290,6 +294,7 @@ namespace GeometryTD.CustomComponent
_spawnEntriesByPhaseId.Clear(); _spawnEntriesByPhaseId.Clear();
_phaseLoopRuntime.Reset(); _phaseLoopRuntime.Reset();
_loadSession.Reset(); _loadSession.Reset();
_combatResourceManager.Reset();
_currentLevel = null; _currentLevel = null;
_isFinishAsVictory = true; _isFinishAsVictory = true;
_state = SchedulerState.Idle; _state = SchedulerState.Idle;
@ -308,22 +313,43 @@ namespace GeometryTD.CustomComponent
GameEntry.UIRouter.BindUIUseCase(UIFormType.CombatFinishForm, _combatFinishFormUseCase); GameEntry.UIRouter.BindUIUseCase(UIFormType.CombatFinishForm, _combatFinishFormUseCase);
} }
private void OpenCombatFinishForm(int defeatedEnemyCount, int gainedGold) private void OpenCombatFinishForm(int defeatedEnemyCount, int gainedGold, BackpackInventoryData rewardInventory)
{ {
EnsureCombatFinishFormUseCaseBound(); EnsureCombatFinishFormUseCaseBound();
_combatFinishFormUseCase.SetSummary( _combatFinishFormUseCase.SetSummary(
defeatedEnemyCount, defeatedEnemyCount,
gainedGold); gainedGold,
rewardInventory);
GameEntry.UIRouter.OpenUI(UIFormType.CombatFinishForm); GameEntry.UIRouter.OpenUI(UIFormType.CombatFinishForm);
} }
public void OnEnemyDefeatedRewardResolved(int gainedCoin, int gainedGold) public void OnEnemyDefeatedRewardResolved(int gainedCoin, int gainedGold)
{ {
int coin = Mathf.Max(0, gainedCoin); _combatResourceManager.AddEnemyDefeatedReward(gainedCoin, gainedGold);
int gold = Mathf.Max(0, gainedGold);
_gainedCoin += coin; if (_state != SchedulerState.RunningPhase)
_gainedGold += gold; {
GameEntry.CombatNode?.ApplyEnemyDropReward(coin, gold); return;
}
_combatResourceManager.TryRollOutGameItemDrop(
_phaseLoopRuntime.DisplayPhaseIndex,
ResolveCurrentThemeType());
}
private LevelThemeType ResolveCurrentThemeType()
{
if (_currentLevel != null)
{
return _currentLevel.LevelThemeType;
}
if (GameEntry.CombatNode != null)
{
return GameEntry.CombatNode.CurrentThemeType;
}
return LevelThemeType.None;
} }
private void CloseCombatFinishForm() private void CloseCombatFinishForm()
@ -373,6 +399,22 @@ namespace GeometryTD.CustomComponent
GameEntry.CombatNode?.OnCombatEndedByScheduler(false); GameEntry.CombatNode?.OnCombatEndedByScheduler(false);
} }
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;
}
#region Event Handlers #region Event Handlers
private void OnShowEntitySuccess(ShowEntitySuccessEventArgs args) private void OnShowEntitySuccess(ShowEntitySuccessEventArgs args)

View File

@ -1,566 +0,0 @@
using System.Collections.Generic;
using GameFramework.DataTable;
using GameFramework.Event;
using GeometryTD.DataTable;
using GeometryTD.Definition;
using GeometryTD.Entity;
using GeometryTD.Entity.EntityData;
using GeometryTD.Map;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent
{
public class EnemyManager
{
private sealed class SpawnEntryRuntime
{
public DRLevelSpawnEntry Entry;
public bool Completed;
public float NextTriggerTime;
public float EndTime;
public int RemainingCount;
}
private const int DefaultEnemyConfigId = 1;
private const float MinStreamInterval = 0.05f;
private const float MinBurstGap = 0.01f;
private readonly List<Spawner> _spawners = new List<Spawner>();
private readonly Dictionary<int, Spawner> _spawnerByOrder = new Dictionary<int, Spawner>();
private readonly List<Vector3> _pathBuffer = new List<Vector3>();
private readonly List<SpawnEntryRuntime> _spawnRuntimes = new List<SpawnEntryRuntime>();
private readonly HashSet<int> _trackedEnemyEntityIds = new HashSet<int>();
private readonly List<int> _trackedEnemyIdBuffer = new List<int>();
private readonly Dictionary<int, DREnemy> _trackedEnemyConfigByEntityId = new Dictionary<int, DREnemy>();
private CombatScheduler _combatScheduler;
private EntityComponent _entity;
private IDataTable<DREnemy> _drEnemy;
private int _spawnEnemyMaxCount = 5000;
private int _currentEnemyCount;
private int _defeatedEnemyCount;
private int _nextSpawnerIndex;
private int _currentMapEntityId;
private bool _initialized;
private bool _enemyConfigMissingLogged;
private float _phaseElapsed;
private bool _isPhaseRunning;
public int AliveEnemyCount => _currentEnemyCount;
public int DefeatedEnemyCount => _defeatedEnemyCount;
public bool IsPhaseSpawnCompleted { get; private set; } = true;
public bool IsPhaseRunning => _isPhaseRunning;
public void OnInit(CombatScheduler combatScheduler)
{
_combatScheduler = combatScheduler;
if (_initialized)
{
return;
}
_entity = GameEntry.Entity;
_drEnemy = GameEntry.DataTable.GetDataTable<DREnemy>();
_currentEnemyCount = 0;
_defeatedEnemyCount = 0;
_nextSpawnerIndex = 0;
_currentMapEntityId = 0;
_enemyConfigMissingLogged = false;
_spawners.Clear();
_spawnerByOrder.Clear();
_pathBuffer.Clear();
_spawnRuntimes.Clear();
_trackedEnemyEntityIds.Clear();
_trackedEnemyIdBuffer.Clear();
_trackedEnemyConfigByEntityId.Clear();
_phaseElapsed = 0f;
_isPhaseRunning = false;
IsPhaseSpawnCompleted = true;
GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
GameEntry.Event.Subscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure);
GameEntry.Event.Subscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
_initialized = true;
}
public void BeginPhase(DRLevelPhase phase, IReadOnlyList<DRLevelSpawnEntry> spawnEntries)
{
if (!_initialized || _combatScheduler == null)
{
return;
}
_ = phase;
EndPhase();
_phaseElapsed = 0f;
_isPhaseRunning = true;
IsPhaseSpawnCompleted = false;
RefreshSpawnerCache(true);
if (spawnEntries != null)
{
for (int i = 0; i < spawnEntries.Count; i++)
{
SpawnEntryRuntime runtime = BuildSpawnRuntime(spawnEntries[i]);
if (runtime != null)
{
_spawnRuntimes.Add(runtime);
}
}
}
IsPhaseSpawnCompleted = _spawnRuntimes.Count <= 0;
}
public void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
if (!_initialized || _combatScheduler == null || !_isPhaseRunning)
{
return;
}
RefreshSpawnerCache(false);
_phaseElapsed += elapseSeconds;
UpdateSpawnRuntimes();
}
public void EndPhase()
{
_isPhaseRunning = false;
_phaseElapsed = 0f;
_spawnRuntimes.Clear();
IsPhaseSpawnCompleted = true;
}
public void OnDestroy()
{
if (!_initialized)
{
_combatScheduler = null;
return;
}
CleanupTrackedEnemies();
EndPhase();
GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
GameEntry.Event.Unsubscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure);
GameEntry.Event.Unsubscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
_spawners.Clear();
_spawnerByOrder.Clear();
_pathBuffer.Clear();
_trackedEnemyEntityIds.Clear();
_trackedEnemyIdBuffer.Clear();
_trackedEnemyConfigByEntityId.Clear();
_currentEnemyCount = 0;
_defeatedEnemyCount = 0;
_currentMapEntityId = 0;
_nextSpawnerIndex = 0;
_combatScheduler = null;
_initialized = false;
}
public void ResetCombatStats()
{
_defeatedEnemyCount = 0;
}
public void CleanupTrackedEnemies()
{
if (_trackedEnemyEntityIds.Count <= 0)
{
_currentEnemyCount = 0;
return;
}
_trackedEnemyIdBuffer.Clear();
foreach (int trackedEnemyEntityId in _trackedEnemyEntityIds)
{
_trackedEnemyIdBuffer.Add(trackedEnemyEntityId);
}
_trackedEnemyEntityIds.Clear();
_trackedEnemyConfigByEntityId.Clear();
_currentEnemyCount = 0;
if (_entity == null)
{
return;
}
for (int i = 0; i < _trackedEnemyIdBuffer.Count; i++)
{
int trackedEnemyEntityId = _trackedEnemyIdBuffer[i];
if (_entity.HasEntity(trackedEnemyEntityId) || _entity.IsLoadingEntity(trackedEnemyEntityId))
{
_entity.HideEntity(trackedEnemyEntityId);
}
}
}
private SpawnEntryRuntime BuildSpawnRuntime(DRLevelSpawnEntry entry)
{
if (entry == null || entry.EntryType == EntryType.None)
{
return null;
}
SpawnEntryRuntime runtime = new SpawnEntryRuntime
{
Entry = entry,
Completed = false,
NextTriggerTime = Mathf.Max(0f, entry.StartTime),
EndTime = Mathf.Max(0f, entry.StartTime),
RemainingCount = Mathf.Max(0, entry.Count)
};
switch (entry.EntryType)
{
case EntryType.Stream:
{
float duration = Mathf.Max(0f, entry.Duration);
runtime.EndTime = duration > 0f ? runtime.NextTriggerTime + duration : runtime.NextTriggerTime;
runtime.Completed = entry.Count <= 0;
return runtime;
}
case EntryType.Burst:
case EntryType.Boss:
runtime.Completed = runtime.RemainingCount <= 0;
return runtime;
default:
return null;
}
}
private void UpdateSpawnRuntimes()
{
bool allCompleted = true;
for (int i = 0; i < _spawnRuntimes.Count; i++)
{
SpawnEntryRuntime runtime = _spawnRuntimes[i];
if (runtime.Completed)
{
continue;
}
switch (runtime.Entry.EntryType)
{
case EntryType.Stream:
ProcessStreamRuntime(runtime);
break;
case EntryType.Burst:
case EntryType.Boss:
ProcessBurstRuntime(runtime);
break;
default:
runtime.Completed = true;
break;
}
if (!runtime.Completed)
{
allCompleted = false;
}
}
IsPhaseSpawnCompleted = allCompleted;
}
private void ProcessStreamRuntime(SpawnEntryRuntime runtime)
{
if (_phaseElapsed < runtime.NextTriggerTime)
{
return;
}
int countPerWave = Mathf.Max(0, runtime.Entry.Count);
if (countPerWave <= 0)
{
runtime.Completed = true;
return;
}
float interval = runtime.Entry.Interval > 0f ? runtime.Entry.Interval : MinStreamInterval;
while (_phaseElapsed >= runtime.NextTriggerTime && runtime.NextTriggerTime <= runtime.EndTime)
{
SpawnEnemies(runtime.Entry, countPerWave);
runtime.NextTriggerTime += interval;
}
if (runtime.NextTriggerTime > runtime.EndTime)
{
runtime.Completed = true;
}
}
private void ProcessBurstRuntime(SpawnEntryRuntime runtime)
{
if (_phaseElapsed < runtime.NextTriggerTime)
{
return;
}
if (runtime.RemainingCount <= 0)
{
runtime.Completed = true;
return;
}
float gap = runtime.Entry.Gap;
if (gap <= 0f)
{
SpawnEnemies(runtime.Entry, runtime.RemainingCount);
runtime.RemainingCount = 0;
runtime.Completed = true;
return;
}
gap = Mathf.Max(gap, MinBurstGap);
while (_phaseElapsed >= runtime.NextTriggerTime && runtime.RemainingCount > 0)
{
SpawnEnemies(runtime.Entry, 1);
runtime.RemainingCount--;
runtime.NextTriggerTime += gap;
}
runtime.Completed = runtime.RemainingCount <= 0;
}
private void SpawnEnemies(DRLevelSpawnEntry entry, int spawnCount)
{
if (spawnCount <= 0)
{
return;
}
Spawner spawner = ResolveSpawner(entry.SpawnPointId);
if (spawner == null)
{
return;
}
MapEntity currentMap = _combatScheduler != null ? _combatScheduler.CurrentMap : null;
if (currentMap == null ||
!currentMap.TryFindPathWorldPoints(spawner, null, _pathBuffer) ||
_pathBuffer.Count <= 0)
{
return;
}
DREnemy enemyConfig = GetEnemyConfig(entry.EnemyId);
if (enemyConfig == null)
{
return;
}
for (int i = 0; i < spawnCount; i++)
{
if (_currentEnemyCount >= _spawnEnemyMaxCount)
{
break;
}
int enemyEntityId = _entity.GenerateSerialId();
_trackedEnemyEntityIds.Add(enemyEntityId);
_trackedEnemyConfigByEntityId[enemyEntityId] = enemyConfig;
EnemyData enemyData = new EnemyData(
enemyEntityId,
enemyConfig.EntityId,
_pathBuffer[0],
enemyConfig.BaseHp,
enemyConfig.Speed,
_pathBuffer);
_entity.ShowEnemy(enemyData);
}
}
private DREnemy GetEnemyConfig(int enemyId)
{
if (_drEnemy == null)
{
_drEnemy = GameEntry.DataTable.GetDataTable<DREnemy>();
if (_drEnemy == null)
{
if (!_enemyConfigMissingLogged)
{
Log.Warning("EnemyManagerComponent can not find DREnemy data table.");
_enemyConfigMissingLogged = true;
}
return null;
}
}
if (enemyId > 0)
{
DREnemy targetConfig = _drEnemy.GetDataRow(enemyId);
if (targetConfig != null)
{
return targetConfig;
}
}
DREnemy defaultConfig = _drEnemy.GetDataRow(DefaultEnemyConfigId);
if (defaultConfig != null)
{
return defaultConfig;
}
DREnemy[] allConfigs = _drEnemy.GetAllDataRows();
if (allConfigs.Length > 0)
{
return allConfigs[0];
}
if (!_enemyConfigMissingLogged)
{
Log.Warning("EnemyManagerComponent found no enemy configs.");
_enemyConfigMissingLogged = true;
}
return null;
}
private Spawner ResolveSpawner(int spawnPointId)
{
if (spawnPointId > 0 && _spawnerByOrder.TryGetValue(spawnPointId, out Spawner mappedSpawner))
{
return mappedSpawner;
}
if (_spawners.Count <= 0)
{
return null;
}
Spawner fallbackSpawner = _spawners[_nextSpawnerIndex % _spawners.Count];
_nextSpawnerIndex++;
return fallbackSpawner;
}
private void RefreshSpawnerCache(bool force)
{
MapEntity currentMap = _combatScheduler != null ? _combatScheduler.CurrentMap : null;
if (currentMap == null)
{
_spawners.Clear();
_spawnerByOrder.Clear();
_currentMapEntityId = 0;
_nextSpawnerIndex = 0;
return;
}
if (!force && _currentMapEntityId == currentMap.Id && _spawners.Count > 0)
{
return;
}
_spawners.Clear();
_spawnerByOrder.Clear();
_nextSpawnerIndex = 0;
_currentMapEntityId = currentMap.Id;
Spawner[] mapSpawners = currentMap.Spawners;
for (int i = 0; i < mapSpawners.Length; i++)
{
Spawner spawner = mapSpawners[i];
if (spawner == null)
{
continue;
}
if (!currentMap.TryGetDefaultPathCells(spawner, out _))
{
continue;
}
_spawners.Add(spawner);
if (spawner.SpawnOrder > 0 && !_spawnerByOrder.ContainsKey(spawner.SpawnOrder))
{
_spawnerByOrder[spawner.SpawnOrder] = spawner;
}
}
_spawners.Sort((left, right) => left.SpawnOrder.CompareTo(right.SpawnOrder));
}
private void OnShowEntitySuccess(object sender, GameEventArgs e)
{
if (!(e is ShowEntitySuccessEventArgs ne)) return;
if (ne.EntityLogicType == typeof(EnemyEntity) &&
_trackedEnemyEntityIds.Contains(ne.Entity.Id))
{
_currentEnemyCount++;
}
}
private void OnShowEntityFailure(object sender, GameEventArgs e)
{
if (!(e is ShowEntityFailureEventArgs ne))
{
return;
}
if (ne.EntityLogicType != typeof(EnemyEntity))
{
return;
}
_trackedEnemyEntityIds.Remove(ne.EntityId);
_trackedEnemyConfigByEntityId.Remove(ne.EntityId);
}
private void OnHideEntityComplete(object sender, GameEventArgs e)
{
if (!(e is HideEntityCompleteEventArgs ne))
{
return;
}
if (!_trackedEnemyEntityIds.Remove(ne.EntityId))
{
_trackedEnemyConfigByEntityId.Remove(ne.EntityId);
return;
}
_currentEnemyCount = Mathf.Max(0, _currentEnemyCount - 1);
bool wasKilled = EnemyEntity.TryConsumeKilledFlag(ne.EntityId);
bool isCombatRunning = _combatScheduler != null && _combatScheduler.IsRunning;
int baseDamage = 0;
int droppedCoin = 0;
int droppedGold = 0;
if (_trackedEnemyConfigByEntityId.TryGetValue(ne.EntityId, out DREnemy enemyConfig) && enemyConfig != null)
{
baseDamage = Mathf.Max(0, enemyConfig.BaseDamage);
if (wasKilled)
{
droppedCoin = Mathf.Max(0, enemyConfig.DropCoin);
float dropRate = enemyConfig.DropPercent > 1f
? Mathf.Clamp01(enemyConfig.DropPercent * 0.01f)
: Mathf.Clamp01(enemyConfig.DropPercent);
if (enemyConfig.DropGold > 0 && dropRate > 0f && Random.value <= dropRate)
{
droppedGold = Mathf.Max(0, enemyConfig.DropGold);
}
}
}
if (isCombatRunning && wasKilled)
{
_defeatedEnemyCount++;
_combatScheduler.OnEnemyDefeatedRewardResolved(droppedCoin, droppedGold);
}
else if (isCombatRunning && baseDamage > 0)
{
_combatScheduler.OnEnemyReachedBase(baseDamage);
}
_trackedEnemyConfigByEntityId.Remove(ne.EntityId);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d8e23ddc10c742359bfeb8c6bd765ee2
timeCreated: 1772440818

View File

@ -0,0 +1,104 @@
using System;
using GameFramework.DataTable;
using GeometryTD.DataTable;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent
{
public sealed class EnemyConfigService
{
private const int DefaultEnemyConfigId = 1;
private IDataTable<DREnemy> _drEnemy;
private bool _enemyConfigMissingLogged;
public void Reset()
{
_drEnemy = null;
_enemyConfigMissingLogged = false;
}
public DREnemy GetEnemyConfig(int enemyId)
{
if (_drEnemy == null)
{
_drEnemy = GameEntry.DataTable.GetDataTable<DREnemy>();
if (_drEnemy == null)
{
if (!_enemyConfigMissingLogged)
{
Log.Warning("EnemyManagerComponent can not find DREnemy data table.");
_enemyConfigMissingLogged = true;
}
return null;
}
}
if (enemyId > 0)
{
DREnemy targetConfig = _drEnemy.GetDataRow(enemyId);
if (targetConfig != null)
{
return targetConfig;
}
}
DREnemy defaultConfig = _drEnemy.GetDataRow(DefaultEnemyConfigId);
if (defaultConfig != null)
{
return defaultConfig;
}
DREnemy[] allConfigs = _drEnemy.GetAllDataRows();
if (allConfigs.Length > 0)
{
return allConfigs[0];
}
if (!_enemyConfigMissingLogged)
{
Log.Warning("EnemyManagerComponent found no enemy configs.");
_enemyConfigMissingLogged = true;
}
return null;
}
public int ResolveScaledEnemyBaseHp(int baseHp, CombatScheduler combatScheduler)
{
int resolvedBaseHp = Mathf.Max(1, baseHp);
int completedLoopCount = ResolveCompletedLoopCount(combatScheduler);
if (completedLoopCount <= 0)
{
return resolvedBaseHp;
}
double scaled = resolvedBaseHp * Math.Pow(2d, completedLoopCount);
if (scaled >= int.MaxValue)
{
return int.MaxValue;
}
return Math.Max(1, (int)Math.Round(scaled));
}
private static int ResolveCompletedLoopCount(CombatScheduler combatScheduler)
{
if (combatScheduler == null)
{
return 0;
}
int phaseCount = combatScheduler.PhaseCount;
int displayPhaseIndex = combatScheduler.DisplayPhaseIndex;
if (phaseCount <= 0 || displayPhaseIndex <= 0)
{
return 0;
}
return Mathf.Max(0, (displayPhaseIndex - 1) / phaseCount);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: be15abd89ef8487eabfe09816f202a98
timeCreated: 1772441787

View File

@ -0,0 +1,75 @@
using System.Collections.Generic;
using GeometryTD.DataTable;
using UnityEngine;
namespace GeometryTD.CustomComponent
{
internal sealed class EnemyLifecycleTracker
{
private readonly HashSet<int> _trackedEnemyEntityIds = new();
private readonly Dictionary<int, DREnemy> _trackedEnemyConfigByEntityId = new();
public int AliveEnemyCount { get; private set; }
public void Reset()
{
_trackedEnemyEntityIds.Clear();
_trackedEnemyConfigByEntityId.Clear();
AliveEnemyCount = 0;
}
public void TrackEnemy(int entityId, DREnemy enemyConfig)
{
_trackedEnemyEntityIds.Add(entityId);
_trackedEnemyConfigByEntityId[entityId] = enemyConfig;
}
public bool Contains(int entityId)
{
return _trackedEnemyEntityIds.Contains(entityId);
}
public void HandleShowSuccess(int entityId)
{
if (_trackedEnemyEntityIds.Contains(entityId))
{
AliveEnemyCount++;
}
}
public void HandleShowFailure(int entityId)
{
_trackedEnemyEntityIds.Remove(entityId);
_trackedEnemyConfigByEntityId.Remove(entityId);
}
public bool TryHandleHideComplete(int entityId, out DREnemy enemyConfig)
{
enemyConfig = null;
if (!_trackedEnemyEntityIds.Remove(entityId))
{
_trackedEnemyConfigByEntityId.Remove(entityId);
return false;
}
_trackedEnemyConfigByEntityId.TryGetValue(entityId, out enemyConfig);
_trackedEnemyConfigByEntityId.Remove(entityId);
AliveEnemyCount = Mathf.Max(0, AliveEnemyCount - 1);
return true;
}
public void CopyTrackedEntityIdsTo(List<int> buffer)
{
if (buffer == null)
{
return;
}
buffer.Clear();
foreach (int trackedEnemyEntityId in _trackedEnemyEntityIds)
{
buffer.Add(trackedEnemyEntityId);
}
}
}
}

View File

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

View File

@ -0,0 +1,241 @@
using System.Collections.Generic;
using GameFramework.Event;
using GeometryTD.DataTable;
using GeometryTD.Definition;
using GeometryTD.Entity;
using GeometryTD.Entity.EntityData;
using UnityEngine;
using UnityGameFramework.Runtime;
using Random = UnityEngine.Random;
namespace GeometryTD.CustomComponent
{
public class EnemyManager
{
private readonly List<int> _trackedEnemyIdBuffer = new();
private readonly EnemySpawnDirector _enemySpawnDirector = new();
private readonly EnemyConfigService _enemyConfigService = new();
private readonly SpawnerResolver _spawnerResolver = new();
private readonly EnemyLifecycleTracker _enemyLifecycleTracker = new();
private CombatScheduler _combatScheduler;
private EntityComponent _entity;
private int _defeatedEnemyCount;
private bool _initialized;
public int AliveEnemyCount => _enemyLifecycleTracker.AliveEnemyCount;
public int DefeatedEnemyCount => _defeatedEnemyCount;
public bool IsPhaseSpawnCompleted => _enemySpawnDirector.IsPhaseSpawnCompleted;
public bool IsPhaseRunning => _enemySpawnDirector.IsPhaseRunning;
public void OnInit(CombatScheduler combatScheduler)
{
_combatScheduler = combatScheduler;
if (_initialized)
{
return;
}
_entity = GameEntry.Entity;
_defeatedEnemyCount = 0;
_enemySpawnDirector.Reset();
_enemyConfigService.Reset();
_spawnerResolver.Reset();
_trackedEnemyIdBuffer.Clear();
_enemyLifecycleTracker.Reset();
GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
GameEntry.Event.Subscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure);
GameEntry.Event.Subscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
_initialized = true;
}
public void BeginPhase(DRLevelPhase phase, IReadOnlyList<DRLevelSpawnEntry> spawnEntries)
{
if (!_initialized || _combatScheduler == null)
{
return;
}
_ = phase;
EndPhase();
_spawnerResolver.RefreshCache(_combatScheduler, true);
_enemySpawnDirector.BeginPhase(spawnEntries);
}
public void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
if (!_initialized || _combatScheduler == null || !_enemySpawnDirector.IsPhaseRunning)
{
return;
}
_spawnerResolver.RefreshCache(_combatScheduler, false);
_enemySpawnDirector.OnUpdate(elapseSeconds, SpawnEnemies);
}
public void EndPhase()
{
_enemySpawnDirector.EndPhase();
}
public void OnDestroy()
{
if (!_initialized)
{
_combatScheduler = null;
return;
}
CleanupTrackedEnemies();
EndPhase();
GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
GameEntry.Event.Unsubscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure);
GameEntry.Event.Unsubscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
_spawnerResolver.Reset();
_trackedEnemyIdBuffer.Clear();
_enemyLifecycleTracker.Reset();
_defeatedEnemyCount = 0;
_enemyConfigService.Reset();
_combatScheduler = null;
_initialized = false;
}
public void ResetCombatStats()
{
_defeatedEnemyCount = 0;
}
public void CleanupTrackedEnemies()
{
_enemyLifecycleTracker.CopyTrackedEntityIdsTo(_trackedEnemyIdBuffer);
if (_trackedEnemyIdBuffer.Count <= 0)
{
return;
}
_enemyLifecycleTracker.Reset();
if (_entity == null)
{
return;
}
for (int i = 0; i < _trackedEnemyIdBuffer.Count; i++)
{
int trackedEnemyEntityId = _trackedEnemyIdBuffer[i];
if (_entity.HasEntity(trackedEnemyEntityId) || _entity.IsLoadingEntity(trackedEnemyEntityId))
{
_entity.HideEntity(trackedEnemyEntityId);
}
}
}
private void SpawnEnemies(DRLevelSpawnEntry entry, int spawnCount)
{
if (spawnCount <= 0)
{
return;
}
if (!_spawnerResolver.TryResolveSpawnPath(_combatScheduler, entry.SpawnPointId, out IReadOnlyList<Vector3> pathPoints))
{
return;
}
DREnemy enemyConfig = _enemyConfigService.GetEnemyConfig(entry.EnemyId);
if (enemyConfig == null)
{
return;
}
int scaledBaseHp = _enemyConfigService.ResolveScaledEnemyBaseHp(enemyConfig.BaseHp, _combatScheduler);
for (int i = 0; i < spawnCount; i++)
{
int enemyEntityId = _entity.GenerateSerialId();
_enemyLifecycleTracker.TrackEnemy(enemyEntityId, enemyConfig);
EnemyData enemyData = new EnemyData(
enemyEntityId,
enemyConfig.EntityId,
pathPoints[0],
scaledBaseHp,
enemyConfig.Speed,
pathPoints);
_entity.ShowEnemy(enemyData);
}
}
private void OnShowEntitySuccess(object sender, GameEventArgs e)
{
if (!(e is ShowEntitySuccessEventArgs ne)) return;
if (ne.EntityLogicType == typeof(EnemyEntity) &&
_enemyLifecycleTracker.Contains(ne.Entity.Id))
{
_enemyLifecycleTracker.HandleShowSuccess(ne.Entity.Id);
}
}
private void OnShowEntityFailure(object sender, GameEventArgs e)
{
if (!(e is ShowEntityFailureEventArgs ne))
{
return;
}
if (ne.EntityLogicType != typeof(EnemyEntity))
{
return;
}
_enemyLifecycleTracker.HandleShowFailure(ne.EntityId);
}
private void OnHideEntityComplete(object sender, GameEventArgs e)
{
if (!(e is HideEntityCompleteEventArgs ne))
{
return;
}
if (!_enemyLifecycleTracker.TryHandleHideComplete(ne.EntityId, out DREnemy enemyConfig))
{
return;
}
bool wasKilled = EnemyEntity.TryConsumeKilledFlag(ne.EntityId);
bool isCombatRunning = _combatScheduler != null && _combatScheduler.IsRunning;
int baseDamage = 0;
int droppedCoin = 0;
int droppedGold = 0;
if (enemyConfig != null)
{
baseDamage = Mathf.Max(0, enemyConfig.BaseDamage);
if (wasKilled)
{
droppedCoin = Mathf.Max(0, enemyConfig.DropCoin);
float dropRate = enemyConfig.DropPercent > 1f
? Mathf.Clamp01(enemyConfig.DropPercent * 0.01f)
: Mathf.Clamp01(enemyConfig.DropPercent);
if (enemyConfig.DropGold > 0 && dropRate > 0f && Random.value <= dropRate)
{
droppedGold = Mathf.Max(0, enemyConfig.DropGold);
}
}
}
if (isCombatRunning && wasKilled)
{
_defeatedEnemyCount++;
_combatScheduler.OnEnemyDefeatedRewardResolved(droppedCoin, droppedGold);
}
else if (isCombatRunning && baseDamage > 0)
{
_combatScheduler.OnEnemyReachedBase(baseDamage);
}
}
}
}

View File

@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using GeometryTD.DataTable;
using GeometryTD.Definition;
using UnityEngine;
namespace GeometryTD.CustomComponent
{
internal sealed class EnemySpawnDirector
{
private sealed class SpawnEntryRuntime
{
public DRLevelSpawnEntry Entry;
public bool Completed;
public float NextTriggerTime;
public float EndTime;
public int RemainingCount;
}
private const float MinStreamInterval = 0.05f;
private const float MinBurstGap = 0.01f;
private readonly List<SpawnEntryRuntime> _spawnRuntimes = new();
private float _phaseElapsed;
public bool IsPhaseSpawnCompleted { get; private set; } = true;
public bool IsPhaseRunning { get; private set; }
public void Reset()
{
EndPhase();
}
public void BeginPhase(IReadOnlyList<DRLevelSpawnEntry> spawnEntries)
{
EndPhase();
_phaseElapsed = 0f;
IsPhaseRunning = true;
IsPhaseSpawnCompleted = false;
if (spawnEntries != null)
{
for (int i = 0; i < spawnEntries.Count; i++)
{
SpawnEntryRuntime runtime = BuildSpawnRuntime(spawnEntries[i]);
if (runtime != null)
{
_spawnRuntimes.Add(runtime);
}
}
}
IsPhaseSpawnCompleted = _spawnRuntimes.Count <= 0;
}
public void OnUpdate(float elapseSeconds, Action<DRLevelSpawnEntry, int> spawnAction)
{
if (!IsPhaseRunning || spawnAction == null)
{
return;
}
_phaseElapsed += elapseSeconds;
UpdateSpawnRuntimes(spawnAction);
}
public void EndPhase()
{
IsPhaseRunning = false;
_phaseElapsed = 0f;
_spawnRuntimes.Clear();
IsPhaseSpawnCompleted = true;
}
private SpawnEntryRuntime BuildSpawnRuntime(DRLevelSpawnEntry entry)
{
if (entry == null || entry.EntryType == EntryType.None)
{
return null;
}
SpawnEntryRuntime runtime = new SpawnEntryRuntime
{
Entry = entry,
Completed = false,
NextTriggerTime = Mathf.Max(0f, entry.StartTime),
EndTime = Mathf.Max(0f, entry.StartTime),
RemainingCount = Mathf.Max(0, entry.Count)
};
switch (entry.EntryType)
{
case EntryType.Stream:
{
float duration = Mathf.Max(0f, entry.Duration);
runtime.EndTime = duration > 0f ? runtime.NextTriggerTime + duration : runtime.NextTriggerTime;
runtime.Completed = entry.Count <= 0;
return runtime;
}
case EntryType.Burst:
case EntryType.Boss:
runtime.Completed = runtime.RemainingCount <= 0;
return runtime;
default:
return null;
}
}
private void UpdateSpawnRuntimes(Action<DRLevelSpawnEntry, int> spawnAction)
{
bool allCompleted = true;
for (int i = 0; i < _spawnRuntimes.Count; i++)
{
SpawnEntryRuntime runtime = _spawnRuntimes[i];
if (runtime.Completed)
{
continue;
}
switch (runtime.Entry.EntryType)
{
case EntryType.Stream:
ProcessStreamRuntime(runtime, spawnAction);
break;
case EntryType.Burst:
case EntryType.Boss:
ProcessBurstRuntime(runtime, spawnAction);
break;
default:
runtime.Completed = true;
break;
}
if (!runtime.Completed)
{
allCompleted = false;
}
}
IsPhaseSpawnCompleted = allCompleted;
}
private void ProcessStreamRuntime(SpawnEntryRuntime runtime, Action<DRLevelSpawnEntry, int> spawnAction)
{
if (_phaseElapsed < runtime.NextTriggerTime)
{
return;
}
int countPerWave = Mathf.Max(0, runtime.Entry.Count);
if (countPerWave <= 0)
{
runtime.Completed = true;
return;
}
float interval = runtime.Entry.Interval > 0f ? runtime.Entry.Interval : MinStreamInterval;
while (_phaseElapsed >= runtime.NextTriggerTime && runtime.NextTriggerTime <= runtime.EndTime)
{
spawnAction(runtime.Entry, countPerWave);
runtime.NextTriggerTime += interval;
}
if (runtime.NextTriggerTime > runtime.EndTime)
{
runtime.Completed = true;
}
}
private void ProcessBurstRuntime(SpawnEntryRuntime runtime, Action<DRLevelSpawnEntry, int> spawnAction)
{
if (_phaseElapsed < runtime.NextTriggerTime)
{
return;
}
if (runtime.RemainingCount <= 0)
{
runtime.Completed = true;
return;
}
float gap = runtime.Entry.Gap;
if (gap <= 0f)
{
spawnAction(runtime.Entry, runtime.RemainingCount);
runtime.RemainingCount = 0;
runtime.Completed = true;
return;
}
gap = Mathf.Max(gap, MinBurstGap);
while (_phaseElapsed >= runtime.NextTriggerTime && runtime.RemainingCount > 0)
{
spawnAction(runtime.Entry, 1);
runtime.RemainingCount--;
runtime.NextTriggerTime += gap;
}
runtime.Completed = runtime.RemainingCount <= 0;
}
}
}

View File

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

View File

@ -0,0 +1,110 @@
using System.Collections.Generic;
using GeometryTD.Entity;
using GeometryTD.Map;
using UnityEngine;
namespace GeometryTD.CustomComponent
{
internal sealed class SpawnerResolver
{
private readonly List<Spawner> _spawners = new();
private readonly Dictionary<int, Spawner> _spawnerByOrder = new();
private readonly List<Vector3> _pathBuffer = new();
private int _nextSpawnerIndex;
private int _currentMapEntityId;
public void Reset()
{
_spawners.Clear();
_spawnerByOrder.Clear();
_pathBuffer.Clear();
_nextSpawnerIndex = 0;
_currentMapEntityId = 0;
}
public void RefreshCache(CombatScheduler combatScheduler, bool force)
{
MapEntity currentMap = combatScheduler != null ? combatScheduler.CurrentMap : null;
if (currentMap == null)
{
Reset();
return;
}
if (!force && _currentMapEntityId == currentMap.Id && _spawners.Count > 0)
{
return;
}
_spawners.Clear();
_spawnerByOrder.Clear();
_nextSpawnerIndex = 0;
_currentMapEntityId = currentMap.Id;
Spawner[] mapSpawners = currentMap.Spawners;
for (int i = 0; i < mapSpawners.Length; i++)
{
Spawner spawner = mapSpawners[i];
if (spawner == null)
{
continue;
}
if (!currentMap.TryGetDefaultPathCells(spawner, out _))
{
continue;
}
_spawners.Add(spawner);
if (spawner.SpawnOrder > 0 && !_spawnerByOrder.ContainsKey(spawner.SpawnOrder))
{
_spawnerByOrder[spawner.SpawnOrder] = spawner;
}
}
_spawners.Sort((left, right) => left.SpawnOrder.CompareTo(right.SpawnOrder));
}
public bool TryResolveSpawnPath(CombatScheduler combatScheduler, int spawnPointId, out IReadOnlyList<Vector3> pathPoints)
{
pathPoints = null;
MapEntity currentMap = combatScheduler != null ? combatScheduler.CurrentMap : null;
if (currentMap == null)
{
return false;
}
Spawner spawner = ResolveSpawner(spawnPointId);
if (spawner == null)
{
return false;
}
if (!currentMap.TryFindPathWorldPoints(spawner, null, _pathBuffer) || _pathBuffer.Count <= 0)
{
return false;
}
pathPoints = _pathBuffer;
return true;
}
private Spawner ResolveSpawner(int spawnPointId)
{
if (spawnPointId > 0 && _spawnerByOrder.TryGetValue(spawnPointId, out Spawner mappedSpawner))
{
return mappedSpawner;
}
if (_spawners.Count <= 0)
{
return null;
}
Spawner fallbackSpawner = _spawners[_nextSpawnerIndex % _spawners.Count];
_nextSpawnerIndex++;
return fallbackSpawner;
}
}
}

View File

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

View File

@ -28,10 +28,15 @@ namespace GeometryTD.DataTable
public string Name { get; private set; } public string Name { get; private set; }
/// <summary> /// <summary>
/// 获取底座组件攻击速度数组 /// 获取底座组件攻击速度数组(秒/次)
/// </summary> /// </summary>
public float[] AttackSpeed { get; private set; } public float[] AttackSpeed { get; private set; }
/// <summary>
/// 获取底座组件每级提升攻击速度(值为负数)
/// </summary>
public float AttackSpeedPerLevel { get; private set; }
/// <summary> /// <summary>
/// 获取攻击属性 /// 获取攻击属性
/// </summary> /// </summary>
@ -62,6 +67,7 @@ namespace GeometryTD.DataTable
EntityId = int.Parse(columnStrings[index++]); EntityId = int.Parse(columnStrings[index++]);
Name = columnStrings[index++]; Name = columnStrings[index++];
AttackSpeed = GenerateAttackSpeed(columnStrings[index++]); AttackSpeed = GenerateAttackSpeed(columnStrings[index++]);
AttackSpeedPerLevel = float.Parse(columnStrings[index++]);
AttackPropertyType = EnumUtility<AttackPropertyType>.Get(columnStrings[index++]); AttackPropertyType = EnumUtility<AttackPropertyType>.Get(columnStrings[index++]);
Constraint = columnStrings[index++]; Constraint = columnStrings[index++];
PossibleTag = GeneratePossibleTag(columnStrings[index++]); PossibleTag = GeneratePossibleTag(columnStrings[index++]);

View File

@ -32,11 +32,21 @@ namespace GeometryTD.DataTable
/// </summary> /// </summary>
public float[] RotateSpeed { get; private set; } public float[] RotateSpeed { get; private set; }
/// <summary>
/// 获取轴承组件每级提升旋转速度
/// </summary>
public float RotateSpeedPerLevel { get; private set; }
/// <summary> /// <summary>
/// 获取攻击范围 /// 获取攻击范围
/// </summary> /// </summary>
public float[] AttackRange { get; private set; } public float[] AttackRange { get; private set; }
/// <summary>
/// 获取每级提升攻击范围
/// </summary>
public float AttackRangePerLevel { get; private set; }
/// <summary> /// <summary>
/// 获取属性约束 /// 获取属性约束
/// </summary> /// </summary>
@ -62,7 +72,9 @@ namespace GeometryTD.DataTable
EntityId = int.Parse(columnStrings[index++]); EntityId = int.Parse(columnStrings[index++]);
Name = columnStrings[index++]; Name = columnStrings[index++];
RotateSpeed = GenerateFloatArray(columnStrings[index++]); RotateSpeed = GenerateFloatArray(columnStrings[index++]);
RotateSpeedPerLevel = float.Parse(columnStrings[index++]);
AttackRange = GenerateFloatArray(columnStrings[index++]); AttackRange = GenerateFloatArray(columnStrings[index++]);
AttackRangePerLevel = float.Parse(columnStrings[index++]);
Constraint = columnStrings[index++]; Constraint = columnStrings[index++];
PossibleTag = GeneratePossibleTag(columnStrings[index++]); PossibleTag = GeneratePossibleTag(columnStrings[index++]);

View File

@ -32,6 +32,11 @@ namespace GeometryTD.DataTable
/// </summary> /// </summary>
public int[] AttackDamage { get; private set; } public int[] AttackDamage { get; private set; }
/// <summary>
/// 获取枪口组件每级提升攻击伤害值
/// </summary>
public int AttackDamagePerLevel { get; private set; }
/// <summary> /// <summary>
/// 获取攻击伤害浮动 /// 获取攻击伤害浮动
/// </summary> /// </summary>
@ -67,6 +72,7 @@ namespace GeometryTD.DataTable
EntityId = int.Parse(columnStrings[index++]); EntityId = int.Parse(columnStrings[index++]);
Name = columnStrings[index++]; Name = columnStrings[index++];
AttackDamage = GenerateAttackDamage(columnStrings[index++]); AttackDamage = GenerateAttackDamage(columnStrings[index++]);
AttackDamagePerLevel = int.Parse(columnStrings[index++]);
DamageRandomRate = float.Parse(columnStrings[index++]); DamageRandomRate = float.Parse(columnStrings[index++]);
AttackMethodType = EnumUtility<AttackMethodType>.Get(columnStrings[index++]); AttackMethodType = EnumUtility<AttackMethodType>.Get(columnStrings[index++]);
Constraint = columnStrings[index++]; Constraint = columnStrings[index++];

View File

@ -0,0 +1,72 @@
using GeometryTD.CustomUtility;
using GeometryTD.Definition;
using UnityGameFramework.Runtime;
namespace GeometryTD.DataTable
{
/// <summary>
/// 局外道具掉落池配置表。
/// </summary>
public class DROutGameDropPool : DataRowBase
{
private int m_Id = 0;
public override int Id => m_Id;
public LevelThemeType LevelThemeType { get; private set; }
public RarityType Rarity { get; private set; }
public string ItemType { get; private set; }
public int ItemId { get; private set; }
public int Weight { get; private set; }
public int MinPhase { get; private set; }
public int MaxPhase { get; private set; }
public override bool ParseDataRow(string dataRowString, object userData)
{
string[] columnStrings = dataRowString.Split(DataTableExtension.DataSplitSeparators);
for (int i = 0; i < columnStrings.Length; i++)
{
columnStrings[i] = columnStrings[i].Trim(DataTableExtension.DataTrimSeparators);
}
int index = 0;
index++;
m_Id = int.Parse(columnStrings[index++]);
index++;
LevelThemeType = EnumUtility<LevelThemeType>.Get(columnStrings[index++]);
Rarity = EnumUtility<RarityType>.Get(columnStrings[index++]);
ItemType = columnStrings[index++];
ItemId = ParseIntOrDefault(columnStrings[index++], 0);
Weight = ParseIntOrDefault(columnStrings[index++], 1);
MinPhase = ParseIntOrDefault(columnStrings[index++], 1);
MaxPhase = ParseIntOrDefault(columnStrings[index++], int.MaxValue);
if (Weight <= 0)
{
Weight = 1;
}
if (MinPhase <= 0)
{
MinPhase = 1;
}
if (MaxPhase < MinPhase)
{
MaxPhase = MinPhase;
}
return true;
}
private static int ParseIntOrDefault(string raw, int fallbackValue)
{
if (int.TryParse(raw, out int parsed))
{
return parsed;
}
return fallbackValue;
}
}
}

View File

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

View File

@ -0,0 +1,31 @@
using GameFramework;
using GameFramework.Event;
namespace GeometryTD.CustomEvent
{
public class CombatEnemyHpRateChangedEventArgs : GameEventArgs
{
public static int EventId => typeof(CombatEnemyHpRateChangedEventArgs).GetHashCode();
public override int Id => EventId;
public int EnemyHpRateMultiplier { get; private set; }
public CombatEnemyHpRateChangedEventArgs()
{
EnemyHpRateMultiplier = 1;
}
public static CombatEnemyHpRateChangedEventArgs Create(int enemyHpRateMultiplier)
{
var args = ReferencePool.Acquire<CombatEnemyHpRateChangedEventArgs>();
args.EnemyHpRateMultiplier = enemyHpRateMultiplier > 0 ? enemyHpRateMultiplier : 1;
return args;
}
public override void Clear()
{
EnemyHpRateMultiplier = 1;
}
}
}

View File

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

View File

@ -31,6 +31,7 @@ namespace GeometryTD.Procedure
"ShopPrice", "ShopPrice",
"Sound", "Sound",
"Tag", "Tag",
"OutGameDropPool",
"UIForm", "UIForm",
"UISound", "UISound",
}; };

View File

@ -6,6 +6,7 @@ namespace GeometryTD.UI
public string LevelPhaseText; public string LevelPhaseText;
public string CoinText; public string CoinText;
public string BaseHpText; public string BaseHpText;
public string EnemyHpRateText;
public bool CanPause; public bool CanPause;
public bool CanEnd; public bool CanEnd;
} }

View File

@ -23,6 +23,7 @@ namespace GeometryTD.UI
GameEntry.Event.Subscribe(CombatProcessEventArgs.EventId, OnCombatProcessChanged); GameEntry.Event.Subscribe(CombatProcessEventArgs.EventId, OnCombatProcessChanged);
GameEntry.Event.Subscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged); GameEntry.Event.Subscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged);
GameEntry.Event.Subscribe(CombatBaseHpChangedEventArgs.EventId, OnCombatBaseHpChanged); GameEntry.Event.Subscribe(CombatBaseHpChangedEventArgs.EventId, OnCombatBaseHpChanged);
GameEntry.Event.Subscribe(CombatEnemyHpRateChangedEventArgs.EventId, OnCombatEnemyHpRateChanged);
} }
protected override void UnsubscribeCustomEvents() protected override void UnsubscribeCustomEvents()
@ -32,6 +33,7 @@ namespace GeometryTD.UI
GameEntry.Event.Unsubscribe(CombatProcessEventArgs.EventId, OnCombatProcessChanged); GameEntry.Event.Unsubscribe(CombatProcessEventArgs.EventId, OnCombatProcessChanged);
GameEntry.Event.Unsubscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged); GameEntry.Event.Unsubscribe(CombatCoinChangedEventArgs.EventId, OnCombatCoinChanged);
GameEntry.Event.Unsubscribe(CombatBaseHpChangedEventArgs.EventId, OnCombatBaseHpChanged); GameEntry.Event.Unsubscribe(CombatBaseHpChangedEventArgs.EventId, OnCombatBaseHpChanged);
GameEntry.Event.Unsubscribe(CombatEnemyHpRateChangedEventArgs.EventId, OnCombatEnemyHpRateChanged);
} }
public int? OpenUI(CombatInfoFormRawData rawData) public int? OpenUI(CombatInfoFormRawData rawData)
@ -92,6 +94,7 @@ namespace GeometryTD.UI
LevelPhaseText = BuildPhaseText(rawData.CurrentPhaseIndex, rawData.TotalPhaseCount), LevelPhaseText = BuildPhaseText(rawData.CurrentPhaseIndex, rawData.TotalPhaseCount),
CoinText = BuildCoinText(rawData.Coin), CoinText = BuildCoinText(rawData.Coin),
BaseHpText = BuildBaseHpText(rawData.BaseHp, rawData.BaseHpMax), BaseHpText = BuildBaseHpText(rawData.BaseHp, rawData.BaseHpMax),
EnemyHpRateText = BuildEnemyHpRateText(rawData.EnemyHpRateMultiplier),
CanPause = rawData.CanPause, CanPause = rawData.CanPause,
CanEnd = rawData.CanEnd CanEnd = rawData.CanEnd
}; };
@ -140,6 +143,12 @@ namespace GeometryTD.UI
return $"\u57FA\u5730\uFF1A{percent}%"; return $"\u57FA\u5730\uFF1A{percent}%";
} }
private static string BuildEnemyHpRateText(int enemyHpRateMultiplier)
{
int resolvedMultiplier = enemyHpRateMultiplier > 0 ? enemyHpRateMultiplier : 1;
return $"\u96BE\u5EA6\uFF1A{resolvedMultiplier}x";
}
private void OnCombatPauseButtonClicked(object sender, GameEventArgs e) private void OnCombatPauseButtonClicked(object sender, GameEventArgs e)
{ {
if (!(sender is CombatInfoForm) || !(e is CombatPauseEventArgs)) if (!(sender is CombatInfoForm) || !(e is CombatPauseEventArgs))
@ -197,6 +206,16 @@ namespace GeometryTD.UI
RefreshFromUseCase(); RefreshFromUseCase();
} }
private void OnCombatEnemyHpRateChanged(object sender, GameEventArgs e)
{
if (!(e is CombatEnemyHpRateChangedEventArgs))
{
return;
}
RefreshFromUseCase();
}
private void RefreshFromUseCase() private void RefreshFromUseCase()
{ {
if (_useCase == null) if (_useCase == null)

View File

@ -11,6 +11,7 @@ namespace GeometryTD.UI
public int Coin; public int Coin;
public int BaseHp; public int BaseHp;
public int BaseHpMax; public int BaseHpMax;
public int EnemyHpRateMultiplier;
public bool CanPause; public bool CanPause;
public bool CanEnd; public bool CanEnd;
} }

View File

@ -35,6 +35,9 @@ namespace GeometryTD.UI
LevelThemeType themeType = level != null ? level.LevelThemeType : GameEntry.CombatNode.CurrentThemeType; LevelThemeType themeType = level != null ? level.LevelThemeType : GameEntry.CombatNode.CurrentThemeType;
int levelId = level != null ? level.Id : 0; int levelId = level != null ? level.Id : 0;
int baseHpMax = level != null ? level.BaseHp : 0; int baseHpMax = level != null ? level.BaseHp : 0;
int enemyHpRateMultiplier = ResolveEnemyHpRateMultiplier(
GameEntry.CombatNode.CurrentPhaseIndex,
GameEntry.CombatNode.CurrentLevelPhaseCount);
return new CombatInfoFormRawData return new CombatInfoFormRawData
{ {
@ -45,9 +48,26 @@ namespace GeometryTD.UI
Coin = GameEntry.CombatNode.CurrentCoin, Coin = GameEntry.CombatNode.CurrentCoin,
BaseHp = GameEntry.CombatNode.CurrentBaseHp, BaseHp = GameEntry.CombatNode.CurrentBaseHp,
BaseHpMax = baseHpMax, BaseHpMax = baseHpMax,
EnemyHpRateMultiplier = enemyHpRateMultiplier,
CanPause = true, CanPause = true,
CanEnd = GameEntry.CombatNode.CanEndCombat CanEnd = GameEntry.CombatNode.CanEndCombat
}; };
} }
private static int ResolveEnemyHpRateMultiplier(int currentPhaseIndex, int totalPhaseCount)
{
if (currentPhaseIndex <= 0 || totalPhaseCount <= 0)
{
return 1;
}
int completedLoopCount = UnityEngine.Mathf.Max(0, (currentPhaseIndex - 1) / totalPhaseCount);
if (completedLoopCount >= 30)
{
return int.MaxValue;
}
return 1 << completedLoopCount;
}
} }
} }

View File

@ -15,6 +15,8 @@ namespace GeometryTD.UI
[SerializeField] private TMP_Text _baseHpText; [SerializeField] private TMP_Text _baseHpText;
[SerializeField] private TMP_Text _enemyHpRateText;
[SerializeField] private CommonButton _pauseButton; [SerializeField] private CommonButton _pauseButton;
[SerializeField] private CommonButton _endButton; [SerializeField] private CommonButton _endButton;
@ -45,6 +47,11 @@ namespace GeometryTD.UI
_baseHpText.text = context?.BaseHpText ?? string.Empty; _baseHpText.text = context?.BaseHpText ?? string.Empty;
} }
if (_enemyHpRateText != null)
{
_enemyHpRateText.text = context?.EnemyHpRateText ?? string.Empty;
}
if (_pauseButton != null) if (_pauseButton != null)
{ {
_pauseButton.Interactive = context?.CanPause ?? false; _pauseButton.Interactive = context?.CanPause ?? false;
@ -113,6 +120,11 @@ namespace GeometryTD.UI
_baseHpText.text = string.Empty; _baseHpText.text = string.Empty;
} }
if (_enemyHpRateText != null)
{
_enemyHpRateText.text = string.Empty;
}
base.OnClose(isShutdown, userData); base.OnClose(isShutdown, userData);
} }
} }

View File

@ -183,40 +183,25 @@ namespace GeometryTD.UI
private void RefreshConfirmText(string confirmText) private void RefreshConfirmText(string confirmText)
{ {
if (string.IsNullOrEmpty(confirmText)) foreach (var text in _confirmTexts)
{ {
confirmText = GameEntry.Localization.GetString("Dialog.ConfirmButton"); text.text = confirmText;
}
for (int i = 0; i < _confirmTexts.Length; i++)
{
_confirmTexts[i].text = confirmText;
} }
} }
private void RefreshCancelText(string cancelText) private void RefreshCancelText(string cancelText)
{ {
if (string.IsNullOrEmpty(cancelText)) foreach (var text in _cancelTexts)
{ {
cancelText = GameEntry.Localization.GetString("Dialog.CancelButton"); text.text = cancelText;
}
for (int i = 0; i < _cancelTexts.Length; i++)
{
_cancelTexts[i].text = cancelText;
} }
} }
private void RefreshOtherText(string otherText) private void RefreshOtherText(string otherText)
{ {
if (string.IsNullOrEmpty(otherText)) foreach (var text in _otherTexts)
{ {
otherText = GameEntry.Localization.GetString("Dialog.OtherButton"); text.text = otherText;
}
for (int i = 0; i < _otherTexts.Length; i++)
{
_otherTexts[i].text = otherText;
} }
} }
} }

View File

@ -1505,7 +1505,7 @@ MonoBehaviour:
m_Horizontal: 0 m_Horizontal: 0
m_Vertical: 1 m_Vertical: 1
m_MovementType: 1 m_MovementType: 1
m_Elasticity: 0.1 m_Elasticity: 0
m_Inertia: 1 m_Inertia: 1
m_DecelerationRate: 0.135 m_DecelerationRate: 0.135
m_ScrollSensitivity: 1 m_ScrollSensitivity: 1

View File

@ -459,6 +459,7 @@ MonoBehaviour:
_levelPhaseText: {fileID: 8225082572338647983} _levelPhaseText: {fileID: 8225082572338647983}
_coinText: {fileID: 8462322658551354788} _coinText: {fileID: 8462322658551354788}
_baseHpText: {fileID: 1807917018366299772} _baseHpText: {fileID: 1807917018366299772}
_enemyHpRateText: {fileID: 205084553981986984}
_pauseButton: {fileID: 7428757802831321808} _pauseButton: {fileID: 7428757802831321808}
_endButton: {fileID: 5538219017489576212} _endButton: {fileID: 5538219017489576212}
--- !u!1 &7271289036404717568 --- !u!1 &7271289036404717568
@ -629,6 +630,7 @@ RectTransform:
- {fileID: 1726761505281868741} - {fileID: 1726761505281868741}
- {fileID: 4169170613063708458} - {fileID: 4169170613063708458}
- {fileID: 1569478724942140920} - {fileID: 1569478724942140920}
- {fileID: 5154404242222297291}
m_Father: {fileID: 8136671500363545760} m_Father: {fileID: 8136671500363545760}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1} m_AnchorMin: {x: 0, y: 1}
@ -662,6 +664,141 @@ MonoBehaviour:
m_ChildScaleWidth: 0 m_ChildScaleWidth: 0
m_ChildScaleHeight: 0 m_ChildScaleHeight: 0
m_ReverseArrangement: 0 m_ReverseArrangement: 0
--- !u!1 &7780165276013755249
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5154404242222297291}
- component: {fileID: 8116375402155163259}
- component: {fileID: 205084553981986984}
m_Layer: 5
m_Name: EnemyHpRateText
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5154404242222297291
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7780165276013755249}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5356006041861078243}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8116375402155163259
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7780165276013755249}
m_CullTransparentMesh: 1
--- !u!114 &205084553981986984
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7780165276013755249}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "\u96BE\u5EA6\uFF1A2x"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 99d811b0183246646a2ce8df996f4bca, type: 2}
m_sharedMaterial: {fileID: -1106088975554028259, guid: 99d811b0183246646a2ce8df996f4bca,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 50
m_fontSizeBase: 50
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 256
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &8284139370021084558 --- !u!1 &8284139370021084558
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

View File

@ -842,12 +842,12 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.x propertyPath: m_SizeDelta.x
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.y propertyPath: m_SizeDelta.y
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -892,7 +892,7 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_AnchoredPosition.y propertyPath: m_AnchoredPosition.y
value: -140 value: -100
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -978,12 +978,12 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.x propertyPath: m_SizeDelta.x
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.y propertyPath: m_SizeDelta.y
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -1154,12 +1154,12 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.x propertyPath: m_SizeDelta.x
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.y propertyPath: m_SizeDelta.y
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -1204,7 +1204,7 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_AnchoredPosition.y propertyPath: m_AnchoredPosition.y
value: -335 value: -300
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -1417,22 +1417,22 @@ PrefabInstance:
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
propertyPath: m_AnchorMax.x propertyPath: m_AnchorMax.x
value: 0.5 value: 1
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
propertyPath: m_AnchorMax.y propertyPath: m_AnchorMax.y
value: 0.5 value: 1
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
propertyPath: m_AnchorMin.x propertyPath: m_AnchorMin.x
value: 0.5 value: 1
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
propertyPath: m_AnchorMin.y propertyPath: m_AnchorMin.y
value: 0.5 value: 1
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
@ -1482,12 +1482,12 @@ PrefabInstance:
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
propertyPath: m_AnchoredPosition.x propertyPath: m_AnchoredPosition.x
value: 1030 value: -400
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
propertyPath: m_AnchoredPosition.y propertyPath: m_AnchoredPosition.y
value: 700 value: -100
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
@ -1601,12 +1601,12 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.x propertyPath: m_SizeDelta.x
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.y propertyPath: m_SizeDelta.y
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -1819,7 +1819,7 @@ PrefabInstance:
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
propertyPath: m_AnchoredPosition.y propertyPath: m_AnchoredPosition.y
value: -600 value: -500
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc, - target: {fileID: 4491355866364659447, guid: 2307f223279813546a43b221ddd496cc,
type: 3} type: 3}
@ -1933,12 +1933,12 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.x propertyPath: m_SizeDelta.x
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.y propertyPath: m_SizeDelta.y
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -1983,7 +1983,7 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_AnchoredPosition.y propertyPath: m_AnchoredPosition.y
value: -140 value: -100
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -2109,12 +2109,12 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.x propertyPath: m_SizeDelta.x
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.y propertyPath: m_SizeDelta.y
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
@ -2285,12 +2285,12 @@ PrefabInstance:
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.x propertyPath: m_SizeDelta.x
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}
propertyPath: m_SizeDelta.y propertyPath: m_SizeDelta.y
value: 250 value: 200
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532, - target: {fileID: 7862994607866941546, guid: e40294c47302bf140a51489f95814532,
type: 3} type: 3}

View File

@ -134,7 +134,7 @@ MonoBehaviour:
m_EditorClassIdentifier: m_EditorClassIdentifier:
m_Material: {fileID: 0} m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1} m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1 m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1 m_Maskable: 1
m_OnCullStateChanged: m_OnCullStateChanged:

View File

@ -819,79 +819,6 @@ MonoBehaviour:
_buildInfoTextAsset: {fileID: 4900000, guid: 0f949a7f73d128547b1314a7e471f19f, type: 3} _buildInfoTextAsset: {fileID: 4900000, guid: 0f949a7f73d128547b1314a7e471f19f, type: 3}
_updateResourceFormTemplate: {fileID: 11487720, guid: a6c731de80e9d824d8f657301a357269, _updateResourceFormTemplate: {fileID: 11487720, guid: a6c731de80e9d824d8f657301a357269,
type: 3} type: 3}
--- !u!1001 &571687048
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
serializedVersion: 3
m_TransformParent: {fileID: 0}
m_Modifications:
- target: {fileID: 7091080056390424735, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_Name
value: Level1
objectReference: {fileID: 0}
- target: {fileID: 7091080056390424735, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_IsActive
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalPosition.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalRotation.w
value: 1
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalRotation.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalRotation.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalRotation.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalEulerAnglesHint.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7948599801963745741, guid: 0cc71b1087c7dfd42a8233a7101fc27e,
type: 3}
propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
m_AddedComponents: []
m_SourcePrefab: {fileID: 100100000, guid: 0cc71b1087c7dfd42a8233a7101fc27e, type: 3}
--- !u!850595691 &819759088 --- !u!850595691 &819759088
LightingSettings: LightingSettings:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -956,134 +883,6 @@ LightingSettings:
m_PVRTiledBaking: 0 m_PVRTiledBaking: 0
m_NumRaysToShootPerTexel: -1 m_NumRaysToShootPerTexel: -1
m_RespectSceneVisibilityWhenBakingGI: 0 m_RespectSceneVisibilityWhenBakingGI: 0
--- !u!1 &1376238055
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1376238059}
- component: {fileID: 1376238058}
- component: {fileID: 1376238056}
m_Layer: 0
m_Name: Camera
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!114 &1376238056
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1376238055}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier:
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_VolumeFrameworkUpdateModeOption: 2
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_AllowHDROutput: 1
m_UseScreenCoordOverride: 0
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_Version: 2
m_TaaSettings:
m_Quality: 3
m_FrameInfluence: 0.1
m_JitterScale: 1
m_MipBias: 0
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
--- !u!20 &1376238058
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1376238055}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 1000
field of view: 60
orthographic: 1
orthographic size: 7.5
m_Depth: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &1376238059
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1376238055}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1454214586 --- !u!1 &1454214586
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -1299,7 +1098,7 @@ Canvas:
m_Enabled: 1 m_Enabled: 1
serializedVersion: 3 serializedVersion: 3
m_RenderMode: 1 m_RenderMode: 1
m_Camera: {fileID: 1376238058} m_Camera: {fileID: 0}
m_PlaneDistance: 100 m_PlaneDistance: 100
m_PixelPerfect: 0 m_PixelPerfect: 0
m_ReceivesEvents: 1 m_ReceivesEvents: 1
@ -1500,5 +1299,3 @@ SceneRoots:
m_Roots: m_Roots:
- {fileID: 1852670053} - {fileID: 1852670053}
- {fileID: 120093242} - {fileID: 120093242}
- {fileID: 1376238059}
- {fileID: 571687048}

View File

@ -1,221 +1,127 @@
# CombatNode 架构摘要 # CombatNode Architecture
最后更新2026-02-28 最后更新2026-03-02
## 1. 目标与边界 ## 1. 总览
CombatNode 当前分为三层:
- `CombatNodeComponent`:入口与关卡数据装配。
- `CombatScheduler`:战斗状态机与阶段推进,以及关卡内资源结算。
- `EnemyManager`:敌人系统 Facade对外接口保持稳定内部由多个子服务协作
CombatNode 子系统的目标是把“战斗节点”拆成三个稳定层: 本文重点记录 `EnemyManager``CombatScheduler` 的职责边界(尤其资源管理收口后的模型)。
- `CombatNodeComponent`:节点入口与配置缓存(门面层) ## 2. EnemyManager 相关类
- `CombatScheduler`:单局战斗状态机(编排层)
- `EnemyManager`:刷怪与敌人生命周期(执行层)
边界约束: ### 2.1 EnemyManagerFacade
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyManager.cs`
- 只负责战斗节点,不负责菜单流程切换,不负责 UI 细节。
- 只依赖数据表和 Entity 系统,不直接持有场景流程 FSM。
- 地图寻路由 `MapEntity` 提供能力,敌人移动由 `EnemyEntity` 自治。
---
## 2. 模块职责划分
## 2.1 CombatNodeComponent门面层
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs`
职责: 职责:
- 对 `CombatScheduler` 提供统一接口:`OnInit / BeginPhase / OnUpdate / EndPhase / OnDestroy`。
- 编排敌人域子服务,不承载具体业务细节。
- 转发状态:`AliveEnemyCount`、`IsPhaseRunning`、`IsPhaseSpawnCompleted`。
- 处理敌人实体事件Show/Hide后的结果上报
- 击杀:上报 `coin/gold``CombatScheduler.OnEnemyDefeatedRewardResolved(...)`
- 到家:上报 `baseDamage``CombatScheduler.OnEnemyReachedBase(...)`
- 读取并缓存 `DRLevel / DRLevelPhase / DRLevelSpawnEntry` 不负责:
- 按主题筛选关卡,维护 `Level -> Phase -> SpawnEntry` 映射。 - 不直接维护刷怪时间轴。
- 提供 `OnInit / StartCombat / OnUpdate / OnShutdown` 外部接口。 - 不直接维护出生点缓存与路径查询
- 触发节点事件: - 不直接维护敌人追踪结构。
- `NodeEnterEventArgs` - 不直接维护掉落池抽样与背包库存。
- `NodeCompleteEventArgs` - 不直接维护敌人配置兜底和血量倍率算法。
不做的事: ### 2.2 EnemySpawnDirector刷怪时序
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemySpawnDirector.cs`
- 不做阶段推进。
- 不做刷怪时序。
- 不做实体显示/隐藏细节。
## 2.2 CombatScheduler编排层
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler.cs`
职责: 职责:
- 管理 `DRLevelSpawnEntry` 的运行时实例(`SpawnEntryRuntime`)。
- 按时间推进 `Stream / Burst / Boss` 生成逻辑。
- 输出“当前应生成多少敌人”,通过回调交给 `EnemyManager.SpawnEnemies` 执行。
- 维护阶段刷怪状态:`IsPhaseRunning`、`IsPhaseSpawnCompleted`。
- 管理战斗状态机:`Idle -> WaitingForMap -> RunningPhase -> Completed/Failed`。 ### 2.3 SpawnerResolver出生点与路径
- 加载地图实体,接收地图 show/hide 成功失败事件。 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/SpawnerResolver.cs`
- 按 phase 结束条件推进到下一阶段。
- 在开始新战斗或销毁时做统一清场(地图 + 本局敌人)。
不做的事:
- 不直接计算每条刷怪规则的触发细节(交给 `EnemyManager`)。
## 2.3 EnemyManager执行层
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs`
职责: 职责:
- 缓存当前地图可用 `Spawner`
- 支持 `SpawnOrder` 映射与 fallback 轮询。
- 对外提供 `TryResolveSpawnPath(...)`,返回世界坐标路径点。
- 在地图切换时刷新缓存,避免每次刷怪全量扫描。
- 将 `DRLevelSpawnEntry` 转为运行时任务stream/burst/boss ### 2.4 EnemyLifecycleTracker敌人生命周期追踪
- 按时间推进刷怪任务并生成 `EnemyData` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyLifecycleTracker.cs`
- 维护本局敌人数量 `AliveEnemyCount`
- 维护“本局生成敌人 ID 集合”,用于准确计数与清场。
关键点:
- 生命周期归属按 `entityId` 跟踪,不再按实体组名粗粒度统计。
- 清场可覆盖“已加载 + 加载中”的本局敌人。
---
## 3. 关键实体职责
## 3.1 MapEntity
文件:`Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs`
职责: 职责:
- 追踪本局敌人 `entityId -> DREnemy`
- 维护 `AliveEnemyCount`
- 处理 `ShowSuccess / ShowFailure / HideComplete` 对追踪状态的变更。
- 提供批量导出 tracked ids用于 `CleanupTrackedEnemies` 清场。
- 读取 Tilemap识别 Path/Foundation 格子。 ### 2.5 EnemyConfigService敌人配置与倍率
- 为每个 `Spawner` 缓存默认可达路径。 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager/EnemyConfigService.cs`
- 提供:
- `TryGetDefaultPathCells`
- `TryFindPathCells`
- `TryFindPathWorldPoints`
## 3.2 EnemyEntity
文件:`Assets/GameMain/Scripts/Entity/EntityLogic/EnemyEntity.cs`
职责: 职责:
- 读取 `DREnemy`,处理默认配置兜底。
- 计算循环周目下的基础血量倍率(按 `displayPhaseIndex / phaseCount` 推导 loop
- 缓存数据表引用并在 `Reset` 时清理。
- 按路径点移动。 ## 3. CombatScheduler 资源收口
- 到达终点后调用 `HideEntity` 自行退出。
--- ### 3.1 CombatResourceManager关卡内资源统一管理
文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatResourceManager.cs`
## 4. 运行时时序(简版) 职责:
- 统一维护关卡内资源状态:
- `GainedCoin`、`GainedGold`
- `BackpackInventoryData`(结算背包快照)
- 处理击杀奖励入账:`AddEnemyDefeatedReward(...)`
- 处理局外掉落抽样与物品构建:`TryRollOutGameItemDrop(...)`
- 对结算 UI 提供只读快照:`GetRewardInventorySnapshot()`
1. 菜单触发战斗:`TestMenuFormController -> GameEntry.CombatNode.StartCombat()` ### 3.2 CombatScheduler 与资源类关系
2. `CombatNodeComponent.StartCombat()` 选关并把关卡配置交给 `CombatScheduler.Start()` 文件:`Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs`
3. `CombatScheduler.Start()` 先清理上局残留,再 `ShowMap(...)`,状态进入 `WaitingForMap`
4. 地图加载成功事件到达,`_currentMap` 就绪
5. `BeginNextPhase()` 进入 `RunningPhase``EnemyManager.BeginPhase(...)`
6. 每帧 `OnUpdate`
- `CombatScheduler` 更新时间与结束条件
- `EnemyManager` 推进刷怪任务并创建敌人
7. 当前 phase 满足结束条件,`CompleteCurrentPhase()`,然后进入下一 phase
8. 全 phase 完成后隐藏地图,`GameEntry.CombatNode.EndCombat()`,抛 `NodeComplete`
--- 当前模型:
- `CombatScheduler` 持有 `_combatResourceManager`
- `GainedCoin/GainedGold` 属性透传资源类。
- `OnEnemyDefeatedRewardResolved(...)` 统一触发:
- 货币入账
- 关卡内掉落判定
- `EnterFinishFlow(...)` 从资源类取 `BackpackInventoryData` 作为结算数据。
## 5. 数据契约与 ID 规则 ## 4. 协作流程(简版)
1. `CombatScheduler.BeginNextPhase()``EnemyManager.BeginPhase(...)`
2. `EnemyManager` 刷新 `SpawnerResolver` 缓存,并通知 `EnemySpawnDirector.BeginPhase(...)`
3. 每帧 `EnemyManager.OnUpdate(...)`
- `SpawnerResolver.RefreshCache(...)`
- `EnemySpawnDirector.OnUpdate(..., SpawnEnemies)`
4. `SpawnEnemies(...)` 执行实际生成:
- `SpawnerResolver.TryResolveSpawnPath(...)`
- `EnemyConfigService.GetEnemyConfig(...)`
- `EnemyConfigService.ResolveScaledEnemyBaseHp(...)`
- `EnemyLifecycleTracker.TrackEnemy(...)`
- `GameEntry.Entity.ShowEnemy(...)`
5. 敌人回收时 `EnemyManager.OnHideEntityComplete(...)`
- `EnemyLifecycleTracker.TryHandleHideComplete(...)`
- 若击杀:上报 `coin/gold``CombatScheduler.OnEnemyDefeatedRewardResolved(...)`
- 若到家:上报 `baseDamage``CombatScheduler.OnEnemyReachedBase(...)`
6. `CombatScheduler.OnEnemyDefeatedRewardResolved(...)`
- `CombatResourceManager.AddEnemyDefeatedReward(...)`
- `CombatResourceManager.TryRollOutGameItemDrop(...)`
7. `CombatScheduler.EnterFinishFlow(...)`
- 读取 `CombatResourceManager.GetRewardInventorySnapshot()`
- 打开结算 UI
数据来源: ## 5. 关键不变量
- 存活敌人数以 `EnemyLifecycleTracker.AliveEnemyCount` 为唯一真值来源。
- 刷怪阶段状态以 `EnemySpawnDirector` 为唯一真值来源。
- 关卡内资源以 `CombatResourceManager` 为唯一真值来源。
- `EnemyManager` 只做敌人域编排与结果上报,不再持有奖励库存。
- 清场必须只作用于本局 tracked 敌人,避免误伤其他实体。
- `DRLevel` ## 6. 维护建议
- `DRLevelPhase` - 新增刷怪类型:优先改 `EnemySpawnDirector`
- `DRLevelSpawnEntry` - 新增路径/出生规则:优先改 `SpawnerResolver`
- 新增敌人追踪策略:优先改 `EnemyLifecycleTracker`
当前约定(依赖 ID 编码): - 新增敌人配置兜底或倍率策略:优先改 `EnemyConfigService`
- 新增货币/掉落/结算背包规则:优先改 `CombatResourceManager`
- `levelId = phaseId / 1000`
- `phaseId = spawnEntryId / 1000`
影响:
- 新增配置时必须遵守该编码,否则 phase 和 entry 无法被正确归属。
建议:
- 若后续改数据结构,优先改成显式外键字段,减少 ID 推导耦合。
---
## 6. 结束条件模型
枚举:`PhaseEndType`
- `TimeElapsed`:按 `EndParam`(可解析 float`DurationSeconds`
- `EnemiesCleared``IsPhaseSpawnCompleted && AliveEnemyCount <= 0`
- `BossDead`:当前与 `EnemiesCleared` 同判定
- `None`:有 duration 走 duration否则退化为清怪判定
实现点:`CombatScheduler.ShouldEndCurrentPhase()`
---
## 7. 生命周期与清场策略(当前实现)
## 7.1 地图生命周期
- 通过 `ShowMap` 异步加载。
- 通过 `ShowEntitySuccess/Failure` 绑定运行态地图引用。
- 完成关卡时隐藏地图。
- 新战斗开始和调度器销毁前都会尝试清理地图实体。
## 7.2 敌人生命周期
- 创建时登记 `entityId``_trackedEnemyEntityIds`
- `ShowEntitySuccess` 仅对 tracked id 计数。
- `HideEntityComplete` 仅对 tracked id 减计数并移除跟踪。
- 清场时按 tracked id 隐藏(覆盖加载中和已加载实体)。
## 7.3 设计目标
- 防止跨局残留实体污染新局状态。
- 防止其他系统 `Enemy` 组实体干扰战斗计数。
---
## 8. 扩展指南(按需求)
## 8.1 新增阶段结束条件
- 改 `PhaseEndType` 枚举。
- 在 `ShouldEndCurrentPhase()` 增加分支。
## 8.2 新增刷怪条目类型
- 改 `EntryType` 枚举。
- 在 `EnemyManager.BuildSpawnRuntime()``UpdateSpawnRuntimes()` 增加处理。
## 8.3 改选关策略
- 当前是随机选关:`TrySelectRandomLevel()`。
- 可改为按难度、解锁进度、权重池。
## 8.4 动态阻挡/重算路径
- 当前 `SpawnEnemies``TryFindPathWorldPoints(spawner, null, ...)`
- 若支持塔阻挡,传入 `blockedCells` 并处理“无路可走”策略。
## 8.5 结算与奖励
- 推荐挂在 `CombatScheduler` 完成分支(所有 phase 完成处),不要塞进 `EnemyManager`
---
## 9. 维护时的硬性不变量
- `CombatNodeComponent` 只做缓存和入口,不写战斗细节。
- `CombatScheduler` 是唯一 phase 状态机,不在别处推进 phase。
- `AliveEnemyCount` 必须由 tracked id 驱动,不回退到组名统计。
- 新战斗开始前必须清场。
- `ResetRuntime()` 必须清空地图相关运行态引用和 id。
---
## 10. 快速阅读路径(新人 10 分钟)
1. `CombatNodeComponent.StartCombat()`
2. `CombatScheduler.Start() / OnUpdate() / BeginNextPhase()`
3. `EnemyManager.BeginPhase() / OnUpdate() / SpawnEnemies()`
4. `MapEntity.TryFindPathWorldPoints()`
5. `EnemyEntity.DespawnOnReachHouse()`
这 5 个点读完,基本能覆盖 90% 战斗节点行为。