diff --git a/Assets/GameMain/Scripts/Components/ShooterMuzzleComp.cs b/Assets/GameMain/Scripts/Components/ShooterMuzzleComp.cs index d8f8f52..efd23c2 100644 --- a/Assets/GameMain/Scripts/Components/ShooterMuzzleComp.cs +++ b/Assets/GameMain/Scripts/Components/ShooterMuzzleComp.cs @@ -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(); + return towerEntity != null ? towerEntity.Id : 0; + } } } diff --git a/Assets/GameMain/Scripts/Definition/DataStruct/AttackPayload.cs b/Assets/GameMain/Scripts/Definition/DataStruct/AttackPayload.cs index 6a98e4a..51cacd3 100644 --- a/Assets/GameMain/Scripts/Definition/DataStruct/AttackPayload.cs +++ b/Assets/GameMain/Scripts/Definition/DataStruct/AttackPayload.cs @@ -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(); public AttackPayload Clone() @@ -16,6 +20,9 @@ namespace GeometryTD.Definition { BaseDamage = BaseDamage, AttackPropertyType = AttackPropertyType, + SourceEntityId = SourceEntityId, + ProjectileEntityId = ProjectileEntityId, + OriginPosition = OriginPosition, TagRuntimes = InventoryCloneUtility.CloneTagRuntimes(TagRuntimes) }; } diff --git a/Assets/GameMain/Scripts/Definition/DataStruct/HitContext.cs b/Assets/GameMain/Scripts/Definition/DataStruct/HitContext.cs index 02e2f66..debc4ea 100644 --- a/Assets/GameMain/Scripts/Definition/DataStruct/HitContext.cs +++ b/Assets/GameMain/Scripts/Definition/DataStruct/HitContext.cs @@ -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(); + 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); } } diff --git a/Assets/GameMain/Scripts/Definition/Tag/Combat/Handlers/NumericTagEffectHandler.cs b/Assets/GameMain/Scripts/Definition/Tag/Combat/Handlers/NumericTagEffectHandler.cs index 27067ab..6ab3232 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Combat/Handlers/NumericTagEffectHandler.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Combat/Handlers/NumericTagEffectHandler.cs @@ -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; } diff --git a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/EnemyStatusTagEffectBase.cs b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/EnemyStatusTagEffectBase.cs index a2dd86e..6938d57 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/EnemyStatusTagEffectBase.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/EnemyStatusTagEffectBase.cs @@ -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; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/FireTagEffect.cs b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/FireTagEffect.cs index a13a819..91de299 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/FireTagEffect.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/FireTagEffect.cs @@ -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(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; - } } } diff --git a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IEnemyStatusTagEffect.cs b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IEnemyStatusTagEffect.cs index d91056e..2a3bc0b 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IEnemyStatusTagEffect.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IEnemyStatusTagEffect.cs @@ -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 applyDamage); float GetMoveSpeedMultiplier(EnemyTagStatusRuntime runtime); } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IceTagEffect.cs b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IceTagEffect.cs index c731930..f0b1f14 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IceTagEffect.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Combat/StatusEffects/IceTagEffect.cs @@ -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(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(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; - } } } diff --git a/Assets/GameMain/Scripts/Definition/Tag/Combat/TagEffectResolver.cs b/Assets/GameMain/Scripts/Definition/Tag/Combat/TagEffectResolver.cs index 2efaa4f..1c0d3f0 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Combat/TagEffectResolver.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Combat/TagEffectResolver.cs @@ -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(), + 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; + } } } diff --git a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/BurnSpreadTagConfig.cs b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/BurnSpreadTagConfig.cs index f2789b7..03943ac 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/BurnSpreadTagConfig.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/BurnSpreadTagConfig.cs @@ -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; } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/FreezeMaskTagConfig.cs b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/FreezeMaskTagConfig.cs index 904d210..3d0b381 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/FreezeMaskTagConfig.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/FreezeMaskTagConfig.cs @@ -8,7 +8,5 @@ namespace GeometryTD.Definition public FreezeMaskTagConfig(bool isImplemented) : base(isImplemented) { } - - public float FreezeBuildUpPerStack { get; set; } = 0f; } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/IgniteBurstTagConfig.cs b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/IgniteBurstTagConfig.cs index eb5695c..a042c3c 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/IgniteBurstTagConfig.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/IgniteBurstTagConfig.cs @@ -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; } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/OverpenetrateTagConfig.cs b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/OverpenetrateTagConfig.cs index 5578b3b..b3b20a2 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/OverpenetrateTagConfig.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/OverpenetrateTagConfig.cs @@ -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; } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/PierceTagConfig.cs b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/PierceTagConfig.cs index b7b9b93..d68fd57 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/PierceTagConfig.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Metadata/Config/PierceTagConfig.cs @@ -8,7 +8,5 @@ namespace GeometryTD.Definition public PierceTagConfig(bool isImplemented) : base(isImplemented) { } - - public int ExtraPierceCount { get; set; } = 2; } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Tag/Metadata/TagDefinitionRegistry.cs b/Assets/GameMain/Scripts/Definition/Tag/Metadata/TagDefinitionRegistry.cs index 188fb13..9b3de3e 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Metadata/TagDefinitionRegistry.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Metadata/TagDefinitionRegistry.cs @@ -69,16 +69,16 @@ namespace GeometryTD.Definition return new Dictionary { [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)) }; } diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyEntity.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyEntity.cs index c547018..d554ba7 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyEntity.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyEntity.cs @@ -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); diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyTagStatusRuntime.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyTagStatusRuntime.cs index aba5176..9618fde 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyTagStatusRuntime.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/EnemyTagStatusRuntime.cs @@ -15,6 +15,16 @@ namespace GeometryTD.Entity return _statesByTag.ContainsKey(tagType); } + public TagType[] GetActiveTagSnapshot() + { + if (_activeTags.Count <= 0) + { + return Array.Empty(); + } + + return _activeTags.ToArray(); + } + public void Reset() { _statesByTag.Clear(); diff --git a/Assets/Tests/EditMode/TagCombatRuntimeTests.cs b/Assets/Tests/EditMode/TagCombatRuntimeTests.cs index 8f276ae..fb160ba 100644 --- a/Assets/Tests/EditMode/TagCombatRuntimeTests.cs +++ b/Assets/Tests/EditMode/TagCombatRuntimeTests.cs @@ -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(), + 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() + }; + } } 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; } diff --git a/docs/CodeX-TODO.md b/docs/CodeX-TODO.md index 33eef2f..ed74f18 100644 --- a/docs/CodeX-TODO.md +++ b/docs/CodeX-TODO.md @@ -286,11 +286,11 @@ | 状态 | ID | 任务 | 交付物路径 | 验收标准 | |-----|-------|------------------------------|---------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------| -| [ ] | S7-01 | 收口 Tag 配置真相源与加载顺序 | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/Definition/Tag/`
`Assets/Tests/EditMode/` | `IsImplemented`、默认值与运行时定义不再受 DataTable 加载顺序影响 | -| [ ] | S7-02 | 补齐组件 Tag 随机上下文与 `RunSeed` 合同 | `Assets/GameMain/Scripts/Definition/Tag/Generation/`
`Assets/GameMain/Scripts/UI/Shop/`
`Assets/GameMain/Scripts/CustomComponent/CombatNode/`
`Assets/Tests/EditMode/` | 同 Run 可复现、跨 Run 可区分,且掉落 / 商店 / 种子共用同一随机合同 | -| [ ] | S7-03 | 收口 `AttackPayload / HitContext` 结算合同 | `Assets/GameMain/Scripts/Definition/DataStruct/`
`Assets/GameMain/Scripts/Definition/Tag/Combat/`
`Assets/GameMain/Scripts/Entity/`
`Assets/Tests/EditMode/` | Tag 结算统一只读上下文对象,不再继续靠散装参数扩展 | -| [ ] | S7-04 | 对齐 `TagConfig` 字段与运行时真实消费 | `Assets/GameMain/DataTables/TagConfig.txt`
`Assets/GameMain/Scripts/Definition/Tag/Metadata/`
`Assets/GameMain/Scripts/Definition/Tag/Combat/`
`Assets/Tests/EditMode/` | 保留字段要么真实生效,要么明确标注为占位且不会误导配置方 | -| [ ] | S7-05 | 清理 Tag 设计文档中的过期草稿 | `docs/TagSystemDesign.md`
`docs/CodeX-TODO.md` | 正式口径、当前实现、后续预案三者分层清晰 | +| [x] | S7-01 | 收口 Tag 配置真相源与加载顺序 | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/Definition/Tag/`
`Assets/Tests/EditMode/` | `IsImplemented`、默认值与运行时定义不再受 DataTable 加载顺序影响 | +| [x] | S7-02 | 补齐组件 Tag 随机上下文与 `RunSeed` 合同 | `Assets/GameMain/Scripts/Definition/Tag/Generation/`
`Assets/GameMain/Scripts/UI/Shop/`
`Assets/GameMain/Scripts/CustomComponent/CombatNode/`
`Assets/Tests/EditMode/` | 同 Run 可复现、跨 Run 可区分,且掉落 / 商店 / 种子共用同一随机合同 | +| [x] | S7-03 | 收口 `AttackPayload / HitContext` 结算合同 | `Assets/GameMain/Scripts/Definition/DataStruct/`
`Assets/GameMain/Scripts/Definition/Tag/Combat/`
`Assets/GameMain/Scripts/Entity/`
`Assets/Tests/EditMode/` | Tag 结算统一只读上下文对象,不再继续靠散装参数扩展 | +| [x] | S7-04 | 对齐 `TagConfig` 字段与运行时真实消费 | `Assets/GameMain/DataTables/TagConfig.txt`
`Assets/GameMain/Scripts/Definition/Tag/Metadata/`
`Assets/GameMain/Scripts/Definition/Tag/Combat/`
`Assets/Tests/EditMode/` | 保留字段要么真实生效,要么明确标注为占位且不会误导配置方 | +| [x] | S7-05 | 清理 Tag 设计文档中的过期草稿 | `docs/TagSystemDesign.md`
`docs/CodeX-TODO.md`
`docs/TagSystemRoadmap.md` | 正式口径、当前实现、后续预案三者分层清晰 | ### S7 立项原因 @@ -298,59 +298,13 @@ - 如果现在直接继续做 `BurnSpread`、`Pierce`、`Overpenetrate` 一类高侵入 Tag,会把当前加载顺序、随机上下文和命中结算接口里的隐患一起放大。 - 因此 `S7` 的目标不是新增玩法,而是先把 Tag 系统的配置真相源、随机合同、战斗上下文合同与文档口径收稳。 -### S7-01 整改口径 +> 2026-03-12 更新:`S7-01 ~ S7-05` 已全部完成。当前 `Tag.txt` 已成为 `IsImplemented` 的正式真相源,`ProcedurePreload` 已通过组合重载消除 `Tag` / `TagConfig` 的加载顺序影响;组件 Tag 生成已统一到 `RunSeed + InventoryTagRandomContext` 合同;战斗链已收口到 `AttackPayload -> HitContext -> TagEffectResolver`;`TagConfig` 中保留字段已对齐到真实运行时消费;`TagSystemDesign.md` 与新的 `TagSystemRoadmap.md` 已拆分为“当前正式口径”和“长期扩展预案”两份文档。 -- `Tag.txt`、`TagConfig.txt`、registry 默认值三处不能再各自持有一份“是否首发 / 是否启用”的真相。 -- `ProcedurePreload` 里 `Tag` 与 `TagConfig` 的加载完成顺序不能再影响 `TagDefinitionRegistry` 最终状态。 -- 至少补一组 EditMode 测试覆盖“先加载 `Tag` 再加载 `TagConfig`”与“先加载 `TagConfig` 再加载 `Tag`”两种顺序,确认结果一致。 -- 本项完成后,`ComponentTagGenerationService` 的首发过滤与展示 / 战斗的定义读取必须共享同一份最终状态。 +### S7 完成结论 -### S7-02 整改口径 - -- `ComponentTagGenerationService` 需要正式接收并消费 `RunSeed` 或等价的显式随机上下文,而不是只依赖 `rarity + sourceType + itemInstanceId + configId`。 -- 掉落、商店、初始种子、事件奖励四条链路必须明确各自如何构造同构的 Tag 随机上下文。 -- `EnemyDropResolver` 当前按 `Reset()` 把掉落实例 Id 重置为 `1` 的做法,只能作为局部实现细节,不能继续承担跨 Run 区分责任。 -- 本项通过标准是:同一 Run 下同一实例上下文结果稳定;不同 Run 即使出现相同配置与实例序号,也能由 `RunSeed` 拉开结果空间。 - -### S7-03 整改口径 - -- `HitContext` 需要补齐当前文档已经承诺的统一结算上下文职责,例如目标状态、击杀结果、攻击来源与后续第二批 Tag 需要的命中信息。 -- `TagEffectResolver.ResolveBeforeHit` 不再继续新增 `targetCurrentHealth`、`targetHasSlowStatus` 这类散装参数。 -- 数值类、状态类、攻击形态类 Tag 统一从上下文对象读取所需信息,避免每补一个 Tag 就再改一轮函数签名。 -- 本项优先级高于第二批 Tag 落地;在 `S7-03` 完成前,不推进 `BurnSpread`、`Pierce`、`Overpenetrate` 的真实战斗效果。 - -### S7-04 整改口径 - -- `TagConfig.txt` 中当前保留的字段必须逐项确认:哪些已经是正式运行时字段,哪些只是占位。 -- 例如 `Fire.MaxEffectiveStack` 这类已经进入表与配置类、但尚未参与实际结算的字段,需要二选一: -- 要么接入真实逻辑; -- 要么从当前正式口径中移除,避免形成“表能改、逻辑不跟”的假配置。 -- `Inferno`、`AbsoluteZero` 这类强化 Tag 也需要明确:它们是继续走独立 `TriggerPhase` 路由,还是正式定义为“依附主状态 Tag 的修饰器”。 -- 本项完成后,`TagConfig.txt`、`TagDefinitionRegistry`、`TagEffectResolver` 与实际战斗行为必须一一对应。 - -### S7-05 整改口径 - -- `docs/TagSystemDesign.md` 需要把“当前正式口径”“已实现状态”“后续预案 / 历史草稿”彻底拆开。 -- 当前正式口径只保留 M1 已收口与 `S7` 审计确认后的内容,不再在同一文件主路径里混写旧的 12 Tag 流派设计草稿。 -- `CodeX-TODO.md` 继续只记录执行顺序与整改项,不重复承担大段玩法设计说明。 -- 本项完成后,后续继续改 Tag 时,开发与策划应能只看文档主干就拿到当前真实执行口径。 - -## 推荐执行顺序 - -1. `S1 ~ S3` 已完成,不再作为当前主阻塞项。 -2. `S4` 已完成,当前不再把三表方案作为 M1 主阻塞项。 -3. `S5` 已完成,当前无需继续按旧耐久设计拆任务。 -4. `S6` 已完成本轮“测试补强 + 文档清理”收尾。 -5. 当前优先进入 `S7-01 ~ S7-03`,先收口 Tag 的配置真相源、随机合同与战斗上下文合同。 -6. 再推进 `S7-04 ~ S7-05`,把配置字段与文档口径收稳。 -7. `S7` 完成前,不提前展开 `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 的真实战斗效果。 -8. 后续若继续推进维修、自动销毁、耐久折价、更多 Tag 元数据配置化等长期设计,应作为新的增强阶段单独拆项。 - -## 本周建议开工顺序 - -1. 先做 `S7-01`,把 `Tag.txt / TagConfig.txt / registry` 的真相源与加载顺序问题收掉 -2. 再做 `S7-02 ~ S7-03`,补齐 `RunSeed` 随机合同和命中上下文合同 -3. 然后做 `S7-04 ~ S7-05`,把配置字段与设计文档统一回写 +- 当前 Tag 系统已经具备继续扩展的稳定基础,但第二批攻击形态 Tag 仍未进入真实战斗效果。 +- 后续继续改 Tag 时,应以 `TagSystemDesign.md` 查看当前正式口径,以 `TagSystemRoadmap.md` 查看长期扩展预案。 +- 在第二批 Tag 正式立项前,`BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 继续保持“占位配置 + 占位路由,无真实战斗效果”的当前边界。 ## 备注 diff --git a/docs/TagSystemDesign.md b/docs/TagSystemDesign.md index 32529a2..2b04f4b 100644 --- a/docs/TagSystemDesign.md +++ b/docs/TagSystemDesign.md @@ -3,8 +3,8 @@ 最后更新:2026-03-12 > 目标:这是 GeometryTD 当前 Tag 系统的唯一正式口径。 -> 本文档只记录当前真实实现、已确认的问题与后续整改边界。 -> 历史流派草稿不再作为主文档内容。 +> 本文档只记录当前真实实现、当前边界与正式规则。 +> 长期扩展预案见 `docs/TagSystemRoadmap.md`;若两者冲突,以本文件为准。 ## 1. 当前范围 @@ -40,17 +40,18 @@ M1 已完成 Tag 最小闭环: - Tag 等级成长 - `TagGroup` 运行时规则 -## 2. 正式决策 +## 2. 正式规则 1. Tag 在组件实例创建时随机;组塔阶段只汇总,不重新随机。 2. 组件表的 `PossibleTag` 只表示候选池,不代表实例最终持有结果。 3. 组塔后重复 Tag 不丢弃,而是转为塔级 `Stack`。 4. 配置层正式采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表结构。 -5. 战斗中的 Tag 统一挂在 `AttackPayload -> HitContext -> TagEffectResolver` 链路上。 -6. 新逻辑应优先使用 `TagRuntimes`;`Tags` 只保留兼容投影职责。 -7. `TagGroup` 当前只作为展示元数据,不进入生成、汇总或战斗规则。 +5. `Tag.txt` 是 `IsImplemented` 的正式真相源;`TagConfig.txt` 只负责触发阶段、描述与运行时参数。 +6. 战斗中的 Tag 统一挂在 `AttackPayload -> HitContext -> TagEffectResolver` 链路上。 +7. 新逻辑应优先使用 `TagRuntimes`;`Tags` 只保留兼容投影职责。 +8. `TagGroup` 当前只作为展示元数据,不进入生成、汇总或战斗规则。 -## 3. 配置模型 +## 3. 配置与生成 ### 3.1 三表职责 @@ -72,13 +73,12 @@ M1 已完成 Tag 最小闭环: ### 3.2 当前消费链 - `Tag.txt -> DRTag -> TagGenerationRuleRegistry` -- 当前负责 `MinRarity` 与 `Weight` -- `Tag.txt -> DRTag -> TagDefinitionRegistry.ApplyTagRows` -- 当前负责把 `IsImplemented` 同步到定义层 +- 当前负责 `MinRarity`、`Weight` +- `Tag.txt + TagConfig.txt -> TagDefinitionRegistry.ReloadFromRows(...)` +- 当前负责 `IsImplemented`、`TriggerPhase`、`Description` 与正式运行时参数 +- `ProcedurePreload` 在 `Tag` 或 `TagConfig` 任一表加载完成后,都会基于当前已加载的两张表组合重建定义层 - `RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry` - 当前负责按品质读取 `MinCount / MaxCount` -- `TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry` -- 当前负责 `TriggerPhase`、`Description` 与各 Tag 参数配置 ### 3.3 运行时结构 @@ -95,18 +95,16 @@ public sealed class TagRuntimeData } ``` -### 3.4 当前最小战斗结构 +### 3.4 组件 Tag 生成 -- `AttackPayload` -- 当前字段:`BaseDamage`、`AttackPropertyType`、`TagRuntimes` -- `HitContext` -- 当前字段:`AttackPayload`、`FinalDamage`、`IsCriticalHit`、`IsKilled` -- 这套结构已经足够承载首发 7 个 Tag -- 但还不足以稳定承载第二批攻击形态类 Tag,详见第 7 节审计问题 +当前统一入口是 `ComponentTagGenerationService`,以下链路共用同一套规则: -## 4. 组件 Tag 生成 +- `InventorySeedUtility` +- `ShopFormUseCase` +- `EnemyDropResolver` +- 结算奖励候选组件 -### 4.1 当前流程 +当前流程: 1. 读取组件配置的 `PossibleTag` 2. 读取 `Tag.txt` 中对应的生成规则 @@ -117,168 +115,112 @@ public sealed class TagRuntimeData 7. 单组件内不重复抽取同一 Tag 8. 候选池不足时允许少于预算,不强行补满 -### 4.2 当前统一入口 +### 3.5 可复现合同 -- `InventorySeedUtility` -- `ShopFormUseCase` -- `EnemyDropResolver` -- 事件奖励后续如生成组件,也必须走同一入口 -- 当前统一入口是 `ComponentTagGenerationService` - -### 4.3 可复现合同 - -- 设计目标要求 Tag 结果对同一 Run 可复现,并在存档读档后保持一致 - -正式随机上下文应包含: +Tag 随机结果的正式上下文为: - `RunSeed` -- `ItemInstanceId` -- `ConfigId` -- `SourceType` - -当前实现已经使用: - -- `Rarity` - `SourceType` - `ItemInstanceId` - `ConfigId` -- 当前实现尚未把 `RunSeed` 作为显式输入接进 `ComponentTagGenerationService` -- 这是当前已确认的问题,不应视为已完成能力 -## 5. 塔级汇总与展示 +当前运行时通过 `InventoryTagRandomContext` 统一承载上述字段。 -### 5.1 汇总规则 +各来源构造口径: + +- `Seed`:使用真实 `RunSeed` 与初始组件实例 Id +- `Shop`:使用 `RunSeed + nodeSequenceIndex + goodsIndex + configId` +- `Drop`:使用 `RunSeed + nodeSequenceIndex + dropOrdinal + configId` +- `Reward`:使用 `RunSeed + nodeSequenceIndex + rewardOrdinal + configId` + +## 4. 汇总、展示与战斗 + +### 4.1 汇总与展示 - 同一组件内不允许重复同一个 Tag - 不同组件之间允许重复 - 组塔时不做重新随机 - 汇总时按 `TagType` 分组并累加 `Stack` - -### 5.2 展示规则 - - 组件展示仍显示组件实例自己的 `Tags` - 塔展示优先显示 `TagRuntimes` - 若缺少 `TagRuntimes`,允许通过 `Tags` 回退构建兼容结果 - 重复 Tag 以 `xN` 文本显示,例如 `Fire x2` +- `TowerStatsData.Tags` 不是新的真相源,只用于兼容旧展示链路与旧数据 -### 5.3 兼容边界 +### 4.2 战斗上下文 -- `TowerStatsData.Tags` 不是新的真相源 -- 它只用于兼容旧展示链路与旧数据 -- 后续新增逻辑应优先读取 `TagRuntimes` +`AttackPayload` 当前字段: -## 6. 战斗模型 +- `BaseDamage` +- `AttackPropertyType` +- `SourceEntityId` +- `ProjectileEntityId` +- `OriginPosition` +- `TagRuntimes` -### 6.1 分类与触发阶段 +`HitContext` 当前字段: + +- `AttackPayload` +- `FinalDamage` +- `IsCriticalHit` +- `IsKilled` +- `TargetEntityId` +- `TargetPosition` +- `TargetCurrentHealthBeforeHit` +- `TargetCurrentHealthAfterHit` +- `TargetMaxHealth` +- `TargetMoveSpeedMultiplierBeforeHit` +- `TargetStatusTagsBeforeHit` +- `TargetStatusRuntime` +- `CritRoll` +- `StatusModifierContext` + +`HitContext` 当前还提供: + +- `HasTargetStatus(TagType)` +- `HasSlowStatusBeforeHit` + +### 4.3 分类与触发阶段 | 分类 | 说明 | 当前主触发阶段 | |------|------|----------------| | `Status` | 命中后在敌人身上形成可持续状态 | `OnAfterHit` | | `NumericModifier` | 命中前修正最终伤害 | `OnBeforeHit` | | `AttackShape` | 穿透、传播、爆炸等攻击形态变化 | `OnHit` / `OnKill` | -| `StatusModifier` | 强化同次命中的状态类 Tag,但不独立生成敌人持有状态 | 当前随主状态一起消费 | +| `StatusModifier` | 强化同次命中的状态类 Tag,但不独立生成敌人持有状态 | `OnAfterHit` | -### 6.2 当前已实现的首发 7 Tag +### 4.4 当前已实现的首发 7 Tag | Tag | 分类 | 配置阶段 | 当前真实行为 | |-----|------|----------|--------------| -| `Fire` | `Status` | `OnAfterHit` | 命中后施加燃烧 DOT | +| `Fire` | `Status` | `OnAfterHit` | 命中后施加燃烧 DOT,且 `MaxEffectiveStack` 已进入基础层数结算 | | `Ice` | `Status` | `OnAfterHit` | 命中后施加减速 | | `Crit` | `NumericModifier` | `OnBeforeHit` | 按概率暴击并放大伤害 | | `Execution` | `NumericModifier` | `OnBeforeHit` | 对低血量目标增伤 | | `Shatter` | `NumericModifier` | `OnBeforeHit` | 对已减速目标增伤 | -| `Inferno` | `StatusModifier` | `OnAfterHit` | 强化同次命中的 `Fire` 时长与伤害 | -| `AbsoluteZero` | `StatusModifier` | `OnAfterHit` | 强化同次命中的 `Ice` 时长与减速强度 | +| `Inferno` | `StatusModifier` | `OnAfterHit` | 先由 resolver 解析为命中期修饰,再强化同次命中的 `Fire` 时长与伤害 | +| `AbsoluteZero` | `StatusModifier` | `OnAfterHit` | 先由 resolver 解析为命中期修饰,再强化同次命中的 `Ice` 时长与减速强度 | -### 6.3 当前已后移的 5 Tag +### 4.5 当前已后移的 5 Tag | Tag | 分类 | 当前状态 | |-----|------|----------| -| `BurnSpread` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | -| `IgniteBurst` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | -| `FreezeMask` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | -| `Pierce` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | -| `Overpenetrate` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | +| `BurnSpread` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 | +| `IgniteBurst` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 | +| `FreezeMask` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 | +| `Pierce` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 | +| `Overpenetrate` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 | -### 6.4 当前运行时边界 +## 5. 当前运行时边界 -- 数值类 Tag 当前在 `ResolveBeforeHit` 阶段生效 -- 状态类 Tag 当前通过 `EnemyTagStatusRuntime` 管理敌人持有状态 -- `Inferno` 与 `AbsoluteZero` 当前不是独立状态,而是在 `FireTagEffect`、`IceTagEffect` 中读取同次命中的强化层数 -- 攻击形态类 Tag 当前只有路由骨架,没有真实战斗效果 +- 数值类 Tag 在 `ResolveBeforeHit` 阶段生效 +- 状态类 Tag 通过 `EnemyTagStatusRuntime` 管理敌人持有状态 +- `StatusModifier` 已独立按 `OnAfterHit` 路由,并写入 `StatusModifierContext` +- `Inferno` 与 `AbsoluteZero` 不生成敌人持有状态;没有 `Fire` / `Ice` 时不会单独产生效果 +- 后移 5 个攻击形态 Tag 的 `TriggerPhase` 当前正式口径为 `None` +- 后移 5 个攻击形态 Tag 的 `ParamJson` 当前只视为占位说明,不是正式运行时字段 -## 7. 已确认审计问题 - -### 7.1 配置真相源与加载顺序不稳定 - -- `IsImplemented` 当前同时存在于 `Tag.txt` 与定义层默认值 -- `Tag` 与 `TagConfig` 的 DataTable 加载先后会影响 `TagDefinitionRegistry` 最终状态 -- 这意味着“Tag 是否首发启用”还没有成为稳定的唯一真相源 - -### 7.2 Tag 随机合同未完整落地 - -- 文档口径要求随机上下文包含 `RunSeed` -- 当前 `ComponentTagGenerationService` 还没有显式接收 `RunSeed` -- 当前掉落链路的实例 Id 也会在局部重置,不能继续承担跨 Run 区分责任 - -### 7.3 战斗上下文合同仍偏弱 - -- `HitContext` 当前只够承载首发数值修正与最小状态挂载 -- 数值类 Tag 仍依赖 `targetCurrentHealth`、`targetHasSlowStatus` 这类散装参数 -- 如果直接继续做 `Pierce`、`BurnSpread` 等第二批 Tag,函数签名会继续膨胀 - -### 7.4 配置字段与真实行为还没有完全对齐 - -- 当前 `TagConfig.txt` 里的部分字段已经进入表与配置类 -- 但并非所有字段都成为真实运行时行为 -- 例如 `Fire.MaxEffectiveStack` 当前并未进入实际结算 -- 这类字段要么接入运行时,要么移出当前正式口径 - -### 7.5 主文档曾混入历史草稿 - -- 本文件过去同时包含正式收口口径与旧的 12 Tag 流派草稿 -- 这种写法会让“当前真实实现”和“历史预案”混淆 -- 从本次更新开始,主文档只保留当前正式口径 - -## 8. 审计整改顺序 - -### S7-01 收口配置真相源与加载顺序 - -- 统一 `IsImplemented` 的最终真相源 -- 保证 `Tag.txt` 与 `TagConfig.txt` 的加载顺序不再影响最终定义结果 -- 补加载顺序相关 EditMode 测试 - -### S7-02 补齐随机上下文与 `RunSeed` - -- 给 `ComponentTagGenerationService` 增加显式随机上下文 -- 把 `RunSeed` 纳入正式输入 -- 统一掉落、商店、种子、事件奖励四条链路的上下文构造方式 - -### S7-03 收口 `AttackPayload / HitContext` 合同 - -- 把当前散装战斗输入收回统一上下文对象 -- 为第二批攻击形态类 Tag 预留稳定上下文 -- 在本项完成前,不推进第二批 Tag 的真实战斗效果 - -### S7-04 对齐 `TagConfig` 与运行时真实消费 - -- 清点每个 `ParamJson` 字段是否真的被运行时消费 -- 已保留字段必须真实生效,或明确标记为占位 -- 明确 `StatusModifier` 的正式消费方式 - -### S7-05 保持文档主干单一口径 - -- `TagSystemDesign.md` 只保留当前真实实现与整改边界 -- 新的长期预案应进入独立文档,而不是回写到本文件主干 - -## 9. 当前默认边界 - -- 在 `S7-01 ~ S7-04` 完成前,不新增第二批 Tag 的真实战斗效果 -- 在配置真相源收稳前,不继续增加新的“是否启用”字段来源 -- 在 `HitContext` 收稳前,不继续依赖更多散装参数扩展 Tag 逻辑 -- 在 `TagConfig` 消费对齐前,不继续向 `ParamJson` 追加没有运行时消费者的字段 - -## 10. 非当前范围 +## 6. 非当前范围 - `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 的真实战斗实现 - 更复杂的多 Tag 联动与流派激活矩阵 diff --git a/docs/TagSystemRoadmap.md b/docs/TagSystemRoadmap.md new file mode 100644 index 0000000..4461222 --- /dev/null +++ b/docs/TagSystemRoadmap.md @@ -0,0 +1,62 @@ +# Tag System Roadmap + +最后更新:2026-03-12 + +> 目标:记录 Tag 系统的长期扩展预案。 +> 本文档不是当前实现真相源;若与 `docs/TagSystemDesign.md` 冲突,以 `docs/TagSystemDesign.md` 为准。 + +## 1. 文档定位 + +- `TagSystemDesign.md` 负责当前正式口径 +- 本文档只负责后续扩展方向 +- 这里出现的能力都不代表当前已经实现,也不代表已经排进最近一期开发 + +## 2. 可继续推进的方向 + +### 2.1 第二批攻击形态 Tag + +后续可基于现有 `AttackPayload -> HitContext -> TagEffectResolver` 合同继续推进: + +- `BurnSpread` +- `IgniteBurst` +- `FreezeMask` +- `Pierce` +- `Overpenetrate` + +进入实现前提: + +- 不破坏当前 `HitContext` 主干 +- 优先补上下文字段,不回退到散装参数扩展 +- 每个 Tag 进入真实战斗效果前,都要先明确它需要的命中信息与回归测试 + +### 2.2 更复杂的多 Tag 联动 + +可考虑的后续方向: + +- 更深的状态连锁 +- 击杀传播或爆炸传播链 +- 多 Tag 组合触发的特殊行为 + +进入实现前提: + +- 先定义联动的真相源与优先级 +- 不把展示元数据直接升级成运行时规则 + +### 2.3 成长与元数据扩展 + +可考虑的后续方向: + +- Tag 等级成长 +- 更多 Tag 元数据扩展表 +- 运行时需要时再讨论 `TagGroup` 是否升级为规则输入 + +进入实现前提: + +- 当前三表职责仍保持清晰 +- 新字段必须同时明确配置真相源、运行时消费者与展示口径 + +## 3. 当前默认约束 + +- 第二批 Tag 未进入真实战斗效果前,继续保持占位配置与占位路由 +- `TagGroup` 继续只作为展示元数据,不提前接入运行时 +- 新预案优先进入本文件,不回写到 `TagSystemDesign.md` 主干