S7-03 + S7-04 + S7-05

This commit is contained in:
2026-03-12 11:24:38 +08:00
parent 185ea43323
commit 3840f8e65a
21 changed files with 620 additions and 373 deletions
@@ -1,6 +1,7 @@
using GeometryTD.Definition;
using GeometryTD.Entity.EntityData;
using GeometryTD;
using GeometryTD.Entity;
using UnityEngine;
namespace Components
@@ -76,6 +77,9 @@ namespace Components
{
BaseDamage = _attackDamage,
AttackPropertyType = attackPropertyType,
SourceEntityId = ResolveSourceEntityId(),
ProjectileEntityId = bulletEntityId,
OriginPosition = spawnPoint.position,
TagRuntimes = CloneTagRuntimes(_tagRuntimes)
};
BulletData bulletData = new BulletData(
@@ -114,5 +118,11 @@ namespace Components
return cloned;
}
private int ResolveSourceEntityId()
{
TowerEntity towerEntity = GetComponentInParent<TowerEntity>();
return towerEntity != null ? towerEntity.Id : 0;
}
}
}
@@ -1,5 +1,6 @@
using System;
using GeometryTD.CustomUtility;
using UnityEngine;
namespace GeometryTD.Definition
{
@@ -8,6 +9,9 @@ namespace GeometryTD.Definition
{
public int BaseDamage { get; set; }
public AttackPropertyType AttackPropertyType { get; set; }
public int SourceEntityId { get; set; }
public int ProjectileEntityId { get; set; }
public Vector3 OriginPosition { get; set; }
public TagRuntimeData[] TagRuntimes { get; set; } = Array.Empty<TagRuntimeData>();
public AttackPayload Clone()
@@ -16,6 +20,9 @@ namespace GeometryTD.Definition
{
BaseDamage = BaseDamage,
AttackPropertyType = AttackPropertyType,
SourceEntityId = SourceEntityId,
ProjectileEntityId = ProjectileEntityId,
OriginPosition = OriginPosition,
TagRuntimes = InventoryCloneUtility.CloneTagRuntimes(TagRuntimes)
};
}
@@ -1,7 +1,26 @@
using System;
using GeometryTD.Entity;
using UnityEngine;
namespace GeometryTD.Definition
{
[Serializable]
public sealed class HitStatusModifierContext
{
public float BonusBurnDurationSeconds { get; set; }
public float BonusBurnDamagePerSecond { get; set; }
public float BonusSlowDurationSeconds { get; set; }
public float BonusSlowRatio { get; set; }
public void Reset()
{
BonusBurnDurationSeconds = 0f;
BonusBurnDamagePerSecond = 0f;
BonusSlowDurationSeconds = 0f;
BonusSlowRatio = 0f;
}
}
[Serializable]
public sealed class HitContext
{
@@ -9,5 +28,35 @@ namespace GeometryTD.Definition
public int FinalDamage { get; set; }
public bool IsCriticalHit { get; set; }
public bool IsKilled { get; set; }
public int TargetEntityId { get; set; }
public Vector3 TargetPosition { get; set; }
public int TargetCurrentHealthBeforeHit { get; set; }
public int TargetCurrentHealthAfterHit { get; set; }
public int TargetMaxHealth { get; set; }
public float TargetMoveSpeedMultiplierBeforeHit { get; set; } = 1f;
public TagType[] TargetStatusTagsBeforeHit { get; set; } = Array.Empty<TagType>();
public EnemyTagStatusRuntime TargetStatusRuntime { get; set; }
public float? CritRoll { get; set; }
public HitStatusModifierContext StatusModifierContext { get; set; } = new HitStatusModifierContext();
public bool HasTargetStatus(TagType tagType)
{
if (tagType == TagType.None || TargetStatusTagsBeforeHit == null || TargetStatusTagsBeforeHit.Length <= 0)
{
return false;
}
for (int i = 0; i < TargetStatusTagsBeforeHit.Length; i++)
{
if (TargetStatusTagsBeforeHit[i] == tagType)
{
return true;
}
}
return false;
}
public bool HasSlowStatusBeforeHit => TargetMoveSpeedMultiplierBeforeHit < 0.999f || HasTargetStatus(TagType.Ice);
}
}
@@ -4,12 +4,7 @@ namespace GeometryTD.Definition
{
public static class NumericTagEffectHandler
{
public static void ApplyBeforeHit(
HitContext hitContext,
int targetCurrentHealth,
int targetMaxHealth,
bool targetHasSlowStatus,
float? critRoll = null)
public static void ApplyBeforeHit(HitContext hitContext)
{
if (hitContext == null || hitContext.AttackPayload == null)
{
@@ -42,21 +37,22 @@ namespace GeometryTD.Definition
switch (runtime.TagType)
{
case TagType.Crit:
ApplyCrit(hitContext, runtime.TotalStack, definition.Config as CritTagConfig, critRoll);
ApplyCrit(hitContext, runtime.TotalStack, definition.Config as CritTagConfig);
break;
case TagType.Execution:
ApplyExecution(hitContext, runtime.TotalStack, targetCurrentHealth, targetMaxHealth, definition.Config as ExecutionTagConfig);
ApplyExecution(hitContext, runtime.TotalStack, definition.Config as ExecutionTagConfig);
break;
case TagType.Shatter:
ApplyShatter(hitContext, runtime.TotalStack, targetHasSlowStatus, definition.Config as ShatterTagConfig);
ApplyShatter(hitContext, runtime.TotalStack, definition.Config as ShatterTagConfig);
break;
}
}
hitContext.IsKilled = targetCurrentHealth > 0 && hitContext.FinalDamage >= targetCurrentHealth;
hitContext.IsKilled = hitContext.TargetCurrentHealthBeforeHit > 0 &&
hitContext.FinalDamage >= hitContext.TargetCurrentHealthBeforeHit;
}
private static void ApplyCrit(HitContext hitContext, int stack, CritTagConfig config, float? critRoll)
private static void ApplyCrit(HitContext hitContext, int stack, CritTagConfig config)
{
if (config == null || !config.IsImplemented || stack <= 0)
{
@@ -64,7 +60,7 @@ namespace GeometryTD.Definition
}
float chance = Mathf.Clamp01(stack * config.CritChancePerStack);
float resolvedCritRoll = critRoll ?? Random.value;
float resolvedCritRoll = hitContext.CritRoll ?? Random.value;
if (resolvedCritRoll > chance)
{
return;
@@ -77,16 +73,14 @@ namespace GeometryTD.Definition
private static void ApplyExecution(
HitContext hitContext,
int stack,
int targetCurrentHealth,
int targetMaxHealth,
ExecutionTagConfig config)
{
if (config == null || !config.IsImplemented || stack <= 0 || targetMaxHealth <= 0)
if (config == null || !config.IsImplemented || stack <= 0 || hitContext.TargetMaxHealth <= 0)
{
return;
}
float healthRatio = Mathf.Clamp01((float)Mathf.Max(0, targetCurrentHealth) / targetMaxHealth);
float healthRatio = Mathf.Clamp01((float)Mathf.Max(0, hitContext.TargetCurrentHealthBeforeHit) / hitContext.TargetMaxHealth);
if (healthRatio > config.TargetHealthThreshold)
{
return;
@@ -96,14 +90,14 @@ namespace GeometryTD.Definition
hitContext.FinalDamage = Mathf.Max(0, Mathf.RoundToInt(hitContext.FinalDamage * multiplier));
}
private static void ApplyShatter(HitContext hitContext, int stack, bool targetHasSlowStatus, ShatterTagConfig config)
private static void ApplyShatter(HitContext hitContext, int stack, ShatterTagConfig config)
{
if (config == null || !config.IsImplemented || stack <= 0)
{
return;
}
if (config.RequiresSlowedTarget && !targetHasSlowStatus)
if (config.RequiresSlowedTarget && !hitContext.HasSlowStatusBeforeHit)
{
return;
}
@@ -7,11 +7,9 @@ namespace GeometryTD.Definition
{
public abstract TagType TagType { get; }
public virtual void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime,
TagRuntimeData runtimeData)
public virtual void Apply(HitContext hitContext, TagRuntimeData runtimeData)
{
_ = attackPayload;
_ = runtime;
_ = hitContext;
_ = runtimeData;
}
@@ -29,4 +27,4 @@ namespace GeometryTD.Definition
return 1f;
}
}
}
}
@@ -8,10 +8,11 @@ namespace GeometryTD.Definition
{
public override TagType TagType => TagType.Fire;
public override void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime,
TagRuntimeData runtimeData)
public override void Apply(HitContext hitContext, TagRuntimeData runtimeData)
{
Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition));
Debug.Assert(hitContext != null);
Debug.Assert(hitContext.TargetStatusRuntime != null);
FireTagConfig config = definition.Config as FireTagConfig;
Debug.Assert(config != null);
@@ -20,11 +21,13 @@ namespace GeometryTD.Definition
return;
}
EnemyTagStatusRuntime runtime = hitContext.TargetStatusRuntime;
FireTagState state = runtime.GetOrCreateState<FireTagState>(TagType);
int infernoStack = TagEffectResolver.GetTagStack(attackPayload?.TagRuntimes, TagType.Inferno);
int effectiveStack = Mathf.Max(0, Mathf.Min(runtimeData.TotalStack, config.MaxEffectiveStack));
HitStatusModifierContext modifierContext = hitContext.StatusModifierContext ?? new HitStatusModifierContext();
float burnDamagePerSecond = Mathf.Max(
0f,
config.BurnDamagePerSecondPerStack * runtimeData.TotalStack + GetInfernoBonusDamagePerSecond(infernoStack));
config.BurnDamagePerSecondPerStack * effectiveStack + modifierContext.BonusBurnDamagePerSecond);
if (burnDamagePerSecond <= 0f)
{
@@ -33,7 +36,7 @@ namespace GeometryTD.Definition
state.RemainingDuration = Mathf.Max(
state.RemainingDuration,
config.BurnDurationSeconds + GetInfernoBonusDuration(infernoStack));
config.BurnDurationSeconds + modifierContext.BonusBurnDurationSeconds);
state.DamagePerSecond = Mathf.Max(state.DamagePerSecond, burnDamagePerSecond);
runtime.Activate(TagType);
}
@@ -68,39 +71,5 @@ namespace GeometryTD.Definition
state.PendingDamage = 0f;
return false;
}
private static float GetInfernoBonusDuration(int infernoStack)
{
if (infernoStack <= 0 ||
!TagDefinitionRegistry.TryGetDefinition(TagType.Inferno, out TagDefinition definition))
{
return 0f;
}
InfernoTagConfig infernoConfig = definition.Config as InfernoTagConfig;
if (infernoConfig == null || !infernoConfig.IsImplemented)
{
return 0f;
}
return infernoStack * infernoConfig.BonusBurnDurationSeconds;
}
private static float GetInfernoBonusDamagePerSecond(int infernoStack)
{
if (infernoStack <= 0 ||
!TagDefinitionRegistry.TryGetDefinition(TagType.Inferno, out TagDefinition definition))
{
return 0f;
}
InfernoTagConfig infernoConfig = definition.Config as InfernoTagConfig;
if (infernoConfig == null || !infernoConfig.IsImplemented)
{
return 0f;
}
return infernoStack * infernoConfig.BonusBurnDamagePerSecondPerStack;
}
}
}
@@ -6,8 +6,8 @@ namespace GeometryTD.Definition
public interface IEnemyStatusTagEffect
{
TagType TagType { get; }
void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime, TagRuntimeData runtimeData);
void Apply(HitContext hitContext, TagRuntimeData runtimeData);
bool Tick(EnemyTagStatusRuntime runtime, float deltaTime, Action<int> applyDamage);
float GetMoveSpeedMultiplier(EnemyTagStatusRuntime runtime);
}
}
}
@@ -8,10 +8,10 @@ namespace GeometryTD.Definition
{
public override TagType TagType => TagType.Ice;
public override void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime,
TagRuntimeData runtimeData)
public override void Apply(HitContext hitContext, TagRuntimeData runtimeData)
{
_ = attackPayload;
Debug.Assert(hitContext != null);
Debug.Assert(hitContext.TargetStatusRuntime != null);
Debug.Assert(runtimeData != null);
Debug.Assert(runtimeData.TotalStack > 0);
Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition));
@@ -23,13 +23,14 @@ namespace GeometryTD.Definition
return;
}
EnemyTagStatusRuntime runtime = hitContext.TargetStatusRuntime;
IceTagState state = runtime.GetOrCreateState<IceTagState>(TagType);
int absoluteZeroStack = TagEffectResolver.GetTagStack(attackPayload?.TagRuntimes, TagType.AbsoluteZero);
float slowRatio = runtimeData.TotalStack * config.SlowRatioPerStack + GetAbsoluteZeroBonusSlowRatio(absoluteZeroStack);
HitStatusModifierContext modifierContext = hitContext.StatusModifierContext ?? new HitStatusModifierContext();
float slowRatio = runtimeData.TotalStack * config.SlowRatioPerStack + modifierContext.BonusSlowRatio;
float slowMultiplier = Mathf.Max(config.MinMoveSpeedMultiplier, 1f - slowRatio);
state.RemainingDuration = Mathf.Max(
state.RemainingDuration,
config.SlowDurationSeconds + GetAbsoluteZeroBonusDuration(absoluteZeroStack));
config.SlowDurationSeconds + modifierContext.BonusSlowDurationSeconds);
state.SlowMultiplier = Mathf.Min(state.SlowMultiplier, slowMultiplier);
runtime.Activate(TagType);
}
@@ -49,39 +50,5 @@ namespace GeometryTD.Definition
IceTagState state = runtime.GetState<IceTagState>(TagType);
return state == null ? 1f : state.SlowMultiplier;
}
private static float GetAbsoluteZeroBonusDuration(int absoluteZeroStack)
{
if (absoluteZeroStack <= 0 ||
!TagDefinitionRegistry.TryGetDefinition(TagType.AbsoluteZero, out TagDefinition definition))
{
return 0f;
}
AbsoluteZeroTagConfig absoluteZeroConfig = definition.Config as AbsoluteZeroTagConfig;
if (absoluteZeroConfig == null || !absoluteZeroConfig.IsImplemented)
{
return 0f;
}
return absoluteZeroStack * absoluteZeroConfig.BonusSlowDurationSeconds;
}
private static float GetAbsoluteZeroBonusSlowRatio(int absoluteZeroStack)
{
if (absoluteZeroStack <= 0 ||
!TagDefinitionRegistry.TryGetDefinition(TagType.AbsoluteZero, out TagDefinition definition))
{
return 0f;
}
AbsoluteZeroTagConfig absoluteZeroConfig = definition.Config as AbsoluteZeroTagConfig;
if (absoluteZeroConfig == null || !absoluteZeroConfig.IsImplemented)
{
return 0f;
}
return absoluteZeroStack * absoluteZeroConfig.BonusSlowRatioPerStack;
}
}
}
@@ -5,37 +5,58 @@ namespace GeometryTD.Definition
{
public static class TagEffectResolver
{
public static HitContext ResolveBeforeHit(
AttackPayload attackPayload,
int targetCurrentHealth,
int targetMaxHealth,
bool targetHasSlowStatus,
float? critRoll = null)
private static readonly HitStatusModifierContext EmptyStatusModifierContext = new HitStatusModifierContext();
public static HitContext ResolveBeforeHit(HitContext hitContext)
{
_ = targetHasSlowStatus;
AttackPayload resolvedPayload = attackPayload?.Clone() ?? new AttackPayload();
HitContext hitContext = new HitContext
if (hitContext == null)
{
return new HitContext
{
AttackPayload = new AttackPayload()
};
}
AttackPayload resolvedPayload = hitContext.AttackPayload?.Clone() ?? new AttackPayload();
HitContext resolvedContext = new HitContext
{
AttackPayload = resolvedPayload,
FinalDamage = Mathf.Max(0, resolvedPayload.BaseDamage),
IsCriticalHit = false,
IsKilled = targetCurrentHealth > 0 && resolvedPayload.BaseDamage >= targetCurrentHealth
IsKilled = hitContext.TargetCurrentHealthBeforeHit > 0 &&
resolvedPayload.BaseDamage >= hitContext.TargetCurrentHealthBeforeHit,
TargetEntityId = hitContext.TargetEntityId,
TargetPosition = hitContext.TargetPosition,
TargetCurrentHealthBeforeHit = hitContext.TargetCurrentHealthBeforeHit,
TargetCurrentHealthAfterHit = hitContext.TargetCurrentHealthAfterHit,
TargetMaxHealth = hitContext.TargetMaxHealth,
TargetMoveSpeedMultiplierBeforeHit = hitContext.TargetMoveSpeedMultiplierBeforeHit,
TargetStatusTagsBeforeHit = hitContext.TargetStatusTagsBeforeHit ?? System.Array.Empty<TagType>(),
TargetStatusRuntime = hitContext.TargetStatusRuntime,
CritRoll = hitContext.CritRoll,
StatusModifierContext = new HitStatusModifierContext()
};
NumericTagEffectHandler.ApplyBeforeHit(
hitContext,
targetCurrentHealth,
targetMaxHealth,
targetHasSlowStatus,
critRoll);
return hitContext;
NumericTagEffectHandler.ApplyBeforeHit(resolvedContext);
return resolvedContext;
}
public static void ApplyAfterHit(AttackPayload attackPayload, EnemyTagStatusRuntime targetStatusRuntime)
public static void ApplyAfterHit(HitContext hitContext)
{
if (attackPayload != null && targetStatusRuntime != null)
if (hitContext?.StatusModifierContext == null)
{
TagRuntimeData[] tagRuntimes = attackPayload.TagRuntimes;
if (hitContext != null)
{
hitContext.StatusModifierContext = new HitStatusModifierContext();
}
}
hitContext?.StatusModifierContext?.Reset();
ApplyStatusModifiers(hitContext);
if (hitContext?.AttackPayload != null && hitContext.TargetStatusRuntime != null)
{
TagRuntimeData[] tagRuntimes = hitContext.AttackPayload.TagRuntimes;
if (tagRuntimes != null && tagRuntimes.Length > 0)
{
foreach (var tag in tagRuntimes)
@@ -55,15 +76,12 @@ namespace GeometryTD.Definition
continue;
}
effect.Apply(attackPayload, targetStatusRuntime, tag);
effect.Apply(hitContext, tag);
}
}
}
AttackShapeTagEffectHandler.Apply(new HitContext
{
AttackPayload = attackPayload
}, TagTriggerPhase.OnAfterHit);
AttackShapeTagEffectHandler.Apply(hitContext, TagTriggerPhase.OnAfterHit);
}
public static void ApplyOnHit(HitContext hitContext)
@@ -95,5 +113,65 @@ namespace GeometryTD.Definition
return 0;
}
private static void ApplyStatusModifiers(HitContext hitContext)
{
if (hitContext?.AttackPayload?.TagRuntimes == null || hitContext.AttackPayload.TagRuntimes.Length <= 0)
{
return;
}
HitStatusModifierContext modifierContext = hitContext.StatusModifierContext ?? EmptyStatusModifierContext;
for (int i = 0; i < hitContext.AttackPayload.TagRuntimes.Length; i++)
{
TagRuntimeData runtime = hitContext.AttackPayload.TagRuntimes[i];
if (runtime == null || runtime.TotalStack <= 0)
{
continue;
}
if (!TagDefinitionRegistry.TryGetDefinition(runtime.TagType, out TagDefinition definition) ||
definition == null ||
definition.Category != TagCategory.StatusModifier ||
definition.TriggerPhase != TagTriggerPhase.OnAfterHit ||
definition.Config == null ||
!definition.Config.IsImplemented)
{
continue;
}
switch (runtime.TagType)
{
case TagType.Inferno:
ApplyInfernoModifier(runtime.TotalStack, definition.Config as InfernoTagConfig, modifierContext);
break;
case TagType.AbsoluteZero:
ApplyAbsoluteZeroModifier(runtime.TotalStack, definition.Config as AbsoluteZeroTagConfig, modifierContext);
break;
}
}
}
private static void ApplyInfernoModifier(int stack, InfernoTagConfig config, HitStatusModifierContext modifierContext)
{
if (config == null || stack <= 0)
{
return;
}
modifierContext.BonusBurnDurationSeconds += stack * config.BonusBurnDurationSeconds;
modifierContext.BonusBurnDamagePerSecond += stack * config.BonusBurnDamagePerSecondPerStack;
}
private static void ApplyAbsoluteZeroModifier(int stack, AbsoluteZeroTagConfig config, HitStatusModifierContext modifierContext)
{
if (config == null || stack <= 0)
{
return;
}
modifierContext.BonusSlowDurationSeconds += stack * config.BonusSlowDurationSeconds;
modifierContext.BonusSlowRatio += stack * config.BonusSlowRatioPerStack;
}
}
}
@@ -8,8 +8,5 @@ namespace GeometryTD.Definition
public BurnSpreadTagConfig(bool isImplemented) : base(isImplemented)
{
}
public float SpreadRadius { get; set; } = 0f;
public float SpreadDamageRate { get; set; } = 0f;
}
}
}
@@ -8,7 +8,5 @@ namespace GeometryTD.Definition
public FreezeMaskTagConfig(bool isImplemented) : base(isImplemented)
{
}
public float FreezeBuildUpPerStack { get; set; } = 0f;
}
}
}
@@ -8,8 +8,5 @@ namespace GeometryTD.Definition
public IgniteBurstTagConfig(bool isImplemented) : base(isImplemented)
{
}
public float BurstRadius { get; set; } = 0f;
public float BurstDamageRate { get; set; } = 0f;
}
}
}
@@ -8,8 +8,5 @@ namespace GeometryTD.Definition
public OverpenetrateTagConfig(bool isImplemented) : base(isImplemented)
{
}
public int ExtraPenetrationCount { get; set; } = 0;
public float RemainingDamageRate { get; set; } = 0f;
}
}
}
@@ -8,7 +8,5 @@ namespace GeometryTD.Definition
public PierceTagConfig(bool isImplemented) : base(isImplemented)
{
}
public int ExtraPierceCount { get; set; } = 2;
}
}
}
@@ -69,16 +69,16 @@ namespace GeometryTD.Definition
return new Dictionary<TagType, TagDefinition>
{
[TagType.Fire] = CreateDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit, new FireTagConfig(true)),
[TagType.BurnSpread] = CreateDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.OnKill, new BurnSpreadTagConfig(false)),
[TagType.IgniteBurst] = CreateDefinition(TagType.IgniteBurst, TagCategory.AttackShape, TagTriggerPhase.OnKill, new IgniteBurstTagConfig(false)),
[TagType.BurnSpread] = CreateDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.None, new BurnSpreadTagConfig(false)),
[TagType.IgniteBurst] = CreateDefinition(TagType.IgniteBurst, TagCategory.AttackShape, TagTriggerPhase.None, new IgniteBurstTagConfig(false)),
[TagType.Inferno] = CreateDefinition(TagType.Inferno, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit, new InfernoTagConfig(true)),
[TagType.Ice] = CreateDefinition(TagType.Ice, TagCategory.Status, TagTriggerPhase.OnAfterHit, new IceTagConfig(true)),
[TagType.FreezeMask] = CreateDefinition(TagType.FreezeMask, TagCategory.AttackShape, TagTriggerPhase.OnAfterHit, new FreezeMaskTagConfig(false)),
[TagType.FreezeMask] = CreateDefinition(TagType.FreezeMask, TagCategory.AttackShape, TagTriggerPhase.None, new FreezeMaskTagConfig(false)),
[TagType.Shatter] = CreateDefinition(TagType.Shatter, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ShatterTagConfig(true)),
[TagType.AbsoluteZero] = CreateDefinition(TagType.AbsoluteZero, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit, new AbsoluteZeroTagConfig(true)),
[TagType.Pierce] = CreateDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.OnHit, new PierceTagConfig(false)),
[TagType.Pierce] = CreateDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.None, new PierceTagConfig(false)),
[TagType.Crit] = CreateDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new CritTagConfig(true)),
[TagType.Overpenetrate] = CreateDefinition(TagType.Overpenetrate, TagCategory.AttackShape, TagTriggerPhase.OnHit, new OverpenetrateTagConfig(false)),
[TagType.Overpenetrate] = CreateDefinition(TagType.Overpenetrate, TagCategory.AttackShape, TagTriggerPhase.None, new OverpenetrateTagConfig(false)),
[TagType.Execution] = CreateDefinition(TagType.Execution, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ExecutionTagConfig(true))
};
}
@@ -115,14 +115,23 @@ namespace GeometryTD.Entity
return;
}
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(
attackPayload,
_currentHealth,
_maxHealth,
HasSlowStatus);
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(new HitContext
{
AttackPayload = attackPayload,
TargetEntityId = Id,
TargetPosition = CachedTransform.position,
TargetCurrentHealthBeforeHit = _currentHealth,
TargetCurrentHealthAfterHit = _currentHealth,
TargetMaxHealth = _maxHealth,
TargetMoveSpeedMultiplierBeforeHit = _tagStatusRuntime.GetMoveSpeedMultiplier(),
TargetStatusTagsBeforeHit = _tagStatusRuntime.GetActiveTagSnapshot(),
TargetStatusRuntime = _tagStatusRuntime
});
ApplyDirectDamage(hitContext.FinalDamage);
hitContext.TargetCurrentHealthAfterHit = _currentHealth;
hitContext.IsKilled = _currentHealth <= 0;
TagEffectResolver.ApplyOnHit(hitContext);
TagEffectResolver.ApplyAfterHit(hitContext.AttackPayload, _tagStatusRuntime);
TagEffectResolver.ApplyAfterHit(hitContext);
if (hitContext.IsKilled)
{
TagEffectResolver.ApplyOnKill(hitContext);
@@ -15,6 +15,16 @@ namespace GeometryTD.Entity
return _statesByTag.ContainsKey(tagType);
}
public TagType[] GetActiveTagSnapshot()
{
if (_activeTags.Count <= 0)
{
return Array.Empty<TagType>();
}
return _activeTags.ToArray();
}
public void Reset()
{
_statesByTag.Clear();
+229 -18
View File
@@ -24,7 +24,7 @@ namespace GeometryTD.Tests.EditMode
}
};
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, false, 0.05f);
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100, 1f, null, 0.05f));
Assert.That(hitContext.IsCriticalHit, Is.True);
Assert.That(hitContext.FinalDamage, Is.EqualTo(150));
@@ -43,8 +43,8 @@ namespace GeometryTD.Tests.EditMode
}
};
HitContext lowHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 30, 100, false);
HitContext highHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 31, 100, false);
HitContext lowHealthHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 30, 100));
HitContext highHealthHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 31, 100));
Assert.That(lowHealthHit.FinalDamage, Is.EqualTo(150));
Assert.That(highHealthHit.FinalDamage, Is.EqualTo(100));
@@ -63,8 +63,8 @@ namespace GeometryTD.Tests.EditMode
}
};
HitContext slowedHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, true);
HitContext normalHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, false);
HitContext slowedHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100, 0.6f, new[] { TagType.Ice }));
HitContext normalHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100));
Assert.That(slowedHit.FinalDamage, Is.EqualTo(150));
Assert.That(normalHit.FinalDamage, Is.EqualTo(100));
@@ -85,7 +85,7 @@ namespace GeometryTD.Tests.EditMode
}
};
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(attackPayload, 30, 100, true, 0.05f);
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 30, 100, 0.6f, new[] { TagType.Ice }, 0.05f));
Assert.That(hitContext.IsCriticalHit, Is.True);
Assert.That(hitContext.FinalDamage, Is.EqualTo(281));
@@ -125,6 +125,63 @@ namespace GeometryTD.Tests.EditMode
Assert.That(stack, Is.EqualTo(2));
}
[Test]
public void ResolveBeforeHit_Uses_Context_TargetHealth_And_CritRoll()
{
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 },
new TagRuntimeData { TagType = TagType.Execution, TotalStack = 1 }
}
};
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 30, 100, 1f, null, 0.05f));
Assert.That(hitContext.IsCriticalHit, Is.True);
Assert.That(hitContext.FinalDamage, Is.EqualTo(225));
Assert.That(hitContext.IsKilled, Is.True);
}
[Test]
public void ResolveBeforeHit_Shatter_Uses_Context_SlowSnapshot()
{
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Shatter, TotalStack = 1 }
}
};
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100, 0.8f, new[] { TagType.Ice }));
Assert.That(hitContext.FinalDamage, Is.EqualTo(125));
}
private static HitContext CreateHitContext(
AttackPayload attackPayload,
int targetCurrentHealthBeforeHit,
int targetMaxHealth,
float targetMoveSpeedMultiplierBeforeHit = 1f,
TagType[] targetStatusTagsBeforeHit = null,
float? critRoll = null)
{
return new HitContext
{
AttackPayload = attackPayload,
TargetCurrentHealthBeforeHit = targetCurrentHealthBeforeHit,
TargetCurrentHealthAfterHit = targetCurrentHealthBeforeHit,
TargetMaxHealth = targetMaxHealth,
TargetMoveSpeedMultiplierBeforeHit = targetMoveSpeedMultiplierBeforeHit,
TargetStatusTagsBeforeHit = targetStatusTagsBeforeHit ?? System.Array.Empty<TagType>(),
CritRoll = critRoll
};
}
}
public sealed class EnemyTagStatusRuntimeTests
@@ -143,7 +200,7 @@ namespace GeometryTD.Tests.EditMode
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
Assert.That(runtime.HasStatus(TagType.Fire), Is.True);
runtime.Tick(1f, damage => totalDamage += damage);
@@ -154,6 +211,32 @@ namespace GeometryTD.Tests.EditMode
Assert.That(totalDamage, Is.EqualTo(120));
}
[Test]
public void ApplyAfterHit_Fire_MaxEffectiveStack_Clamps_BaseFireStack_Only()
{
DRTagConfig fireRow = new DRTagConfig();
Assert.That(fireRow.ParseDataRow("\t1\t元素\tFire\tOnAfterHit\t火焰测试描述\t{\"BurnDurationSeconds\":3,\"BurnDamagePerSecondPerStack\":20,\"MaxEffectiveStack\":2}", null), Is.True);
TagDefinitionRegistry.LoadFromRows(new[] { fireRow });
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 4 }
}
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
runtime.Tick(3f, damage => totalDamage += damage);
Assert.That(totalDamage, Is.EqualTo(120));
TagDefinitionRegistry.ResetToDefaults();
}
[Test]
public void ApplyAfterHit_Fire_UsesElapsedDeltaTimeInsteadOfIndependentInterval()
{
@@ -168,7 +251,7 @@ namespace GeometryTD.Tests.EditMode
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
runtime.Tick(0.5f, damage => totalDamage += damage);
runtime.Tick(0.5f, damage => totalDamage += damage);
@@ -191,7 +274,7 @@ namespace GeometryTD.Tests.EditMode
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(0.6f).Within(0.001f));
@@ -217,7 +300,7 @@ namespace GeometryTD.Tests.EditMode
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
Assert.That(runtime.HasStatus(TagType.Fire), Is.True);
Assert.That(runtime.HasStatus(TagType.Inferno), Is.False);
@@ -231,6 +314,35 @@ namespace GeometryTD.Tests.EditMode
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
}
[Test]
public void ApplyAfterHit_FireWithInferno_StillAppliesInfernoBonusAfterFireCap()
{
DRTagConfig fireRow = new DRTagConfig();
DRTagConfig infernoRow = new DRTagConfig();
Assert.That(fireRow.ParseDataRow("\t1\t元素\tFire\tOnAfterHit\t火焰测试描述\t{\"BurnDurationSeconds\":3,\"BurnDamagePerSecondPerStack\":20,\"MaxEffectiveStack\":2}", null), Is.True);
Assert.That(infernoRow.ParseDataRow("\t4\t元素\tInferno\tOnAfterHit\t强化燃烧伤害或持续时间\t{\"BonusBurnDurationSeconds\":0.5,\"BonusBurnDamagePerSecondPerStack\":10}", null), Is.True);
TagDefinitionRegistry.LoadFromRows(new[] { fireRow, infernoRow });
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 4 },
new TagRuntimeData { TagType = TagType.Inferno, TotalStack = 1 }
}
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
runtime.Tick(3.5f, damage => totalDamage += damage);
Assert.That(totalDamage, Is.EqualTo(175));
TagDefinitionRegistry.ResetToDefaults();
}
[Test]
public void ApplyAfterHit_InfernoWithoutFire_DoesNotCreateStatus()
{
@@ -244,12 +356,41 @@ namespace GeometryTD.Tests.EditMode
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
Assert.That(runtime.HasStatus(TagType.Inferno), Is.False);
}
[Test]
public void ApplyAfterHit_StatusModifier_Uses_Independent_OnAfterHit_Route()
{
DRTagConfig infernoRow = new DRTagConfig();
Assert.That(infernoRow.ParseDataRow("\t4\t元素\tInferno\tOnHit\t强化燃烧伤害或持续时间\t{\"BonusBurnDurationSeconds\":1,\"BonusBurnDamagePerSecondPerStack\":20}", null), Is.True);
TagDefinitionRegistry.LoadFromRows(new[] { infernoRow });
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
HitContext hitContext = CreateAfterHitContext(new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 },
new TagRuntimeData { TagType = TagType.Inferno, TotalStack = 1 }
}
}, runtime);
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(hitContext);
runtime.Tick(3f, damage => totalDamage += damage);
Assert.That(totalDamage, Is.EqualTo(60));
Assert.That(hitContext.StatusModifierContext.BonusBurnDamagePerSecond, Is.EqualTo(0f).Within(0.001f));
Assert.That(hitContext.StatusModifierContext.BonusBurnDurationSeconds, Is.EqualTo(0f).Within(0.001f));
TagDefinitionRegistry.ResetToDefaults();
}
[Test]
public void ApplyAfterHit_IceWithAbsoluteZero_IncreasesSlowStrengthAndDuration()
{
@@ -264,7 +405,7 @@ namespace GeometryTD.Tests.EditMode
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False);
@@ -291,7 +432,7 @@ namespace GeometryTD.Tests.EditMode
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
Assert.That(runtime.HasStatus(TagType.Ice), Is.False);
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False);
@@ -323,12 +464,12 @@ namespace GeometryTD.Tests.EditMode
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(new AttackPayload
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(new AttackPayload
{
BaseDamage = tower.Stats.AttackDamage[0],
AttackPropertyType = tower.Stats.AttackPropertyType,
TagRuntimes = tower.Stats.TagRuntimes
}, runtime);
}, runtime));
runtime.Tick(1f, damage => totalDamage += damage);
runtime.Tick(1f, damage => totalDamage += damage);
@@ -337,6 +478,35 @@ namespace GeometryTD.Tests.EditMode
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
Assert.That(totalDamage, Is.EqualTo(180));
}
[Test]
public void ApplyAfterHit_Uses_TargetStatusRuntime_From_HitContext()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
HitContext hitContext = CreateAfterHitContext(new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Ice, TotalStack = 1 }
}
}, runtime);
TagEffectResolver.ApplyAfterHit(hitContext);
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
Assert.That(hitContext.TargetStatusRuntime, Is.SameAs(runtime));
}
private static HitContext CreateAfterHitContext(AttackPayload attackPayload, EnemyTagStatusRuntime runtime)
{
return new HitContext
{
AttackPayload = attackPayload,
TargetStatusRuntime = runtime,
TargetStatusTagsBeforeHit = System.Array.Empty<TagType>()
};
}
}
public sealed class EnemyStatusTagRegistryTests
@@ -372,8 +542,8 @@ namespace GeometryTD.Tests.EditMode
AssertDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit);
AssertDefinition(TagType.Inferno, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit);
AssertDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit);
AssertDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.OnHit);
AssertDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.OnKill);
AssertDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.None);
AssertDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.None);
AssertDefinition(TagType.AbsoluteZero, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit);
}
@@ -519,10 +689,21 @@ namespace GeometryTD.Tests.EditMode
};
Assert.DoesNotThrow(() => TagEffectResolver.ApplyOnHit(hitContext));
Assert.DoesNotThrow(() => TagEffectResolver.ApplyAfterHit(hitContext.AttackPayload, new EnemyTagStatusRuntime()));
hitContext.TargetStatusRuntime = new EnemyTagStatusRuntime();
Assert.DoesNotThrow(() => TagEffectResolver.ApplyAfterHit(hitContext));
Assert.DoesNotThrow(() => TagEffectResolver.ApplyOnKill(hitContext));
}
[Test]
public void Placeholder_AttackShape_Configs_Do_Not_Expose_Formal_Runtime_Params()
{
Assert.That(typeof(BurnSpreadTagConfig).GetProperty("SpreadRadius"), Is.Null);
Assert.That(typeof(IgniteBurstTagConfig).GetProperty("BurstRadius"), Is.Null);
Assert.That(typeof(FreezeMaskTagConfig).GetProperty("FreezeBuildUpPerStack"), Is.Null);
Assert.That(typeof(PierceTagConfig).GetProperty("ExtraPierceCount"), Is.Null);
Assert.That(typeof(OverpenetrateTagConfig).GetProperty("RemainingDamageRate"), Is.Null);
}
private static void AssertDefinition(TagType tagType, TagCategory expectedCategory, TagTriggerPhase expectedPhase)
{
Assert.That(TagDefinitionRegistry.TryGetDefinition(tagType, out TagDefinition definition), Is.True);
@@ -577,6 +758,9 @@ namespace GeometryTD.Tests.EditMode
{
BaseDamage = 80,
AttackPropertyType = AttackPropertyType.Fire,
SourceEntityId = 101,
ProjectileEntityId = 202,
OriginPosition = new Vector3(1f, 2f, 0f),
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 }
@@ -590,6 +774,9 @@ namespace GeometryTD.Tests.EditMode
Assert.That(receiver.LastPayload, Is.Not.Null);
Assert.That(receiver.LastPayload.BaseDamage, Is.EqualTo(80));
Assert.That(receiver.LastPayload.AttackPropertyType, Is.EqualTo(AttackPropertyType.Fire));
Assert.That(receiver.LastPayload.SourceEntityId, Is.EqualTo(101));
Assert.That(receiver.LastPayload.ProjectileEntityId, Is.EqualTo(202));
Assert.That(receiver.LastPayload.OriginPosition, Is.EqualTo(new Vector3(1f, 2f, 0f)));
Assert.That(receiver.LastPayload.TagRuntimes, Has.Length.EqualTo(1));
Assert.That(receiver.LastPayload.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire));
}
@@ -600,6 +787,30 @@ namespace GeometryTD.Tests.EditMode
}
}
[Test]
public void AttackPayload_Clone_Preserves_Source_Metadata()
{
AttackPayload payload = new AttackPayload
{
BaseDamage = 50,
AttackPropertyType = AttackPropertyType.Physics,
SourceEntityId = 11,
ProjectileEntityId = 22,
OriginPosition = new Vector3(3f, 4f, 0f),
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 }
}
};
AttackPayload cloned = payload.Clone();
Assert.That(cloned.SourceEntityId, Is.EqualTo(11));
Assert.That(cloned.ProjectileEntityId, Is.EqualTo(22));
Assert.That(cloned.OriginPosition, Is.EqualTo(new Vector3(3f, 4f, 0f)));
Assert.That(cloned.TagRuntimes, Is.Not.SameAs(payload.TagRuntimes));
}
private sealed class RecordingDamageReceiver : MonoBehaviour, IDamageReceiver
{
public AttackPayload LastPayload { get; private set; }