S7-03 + S7-04 + S7-05

This commit is contained in:
SepComet 2026-03-12 11:24:38 +08:00
parent 185ea43323
commit 3840f8e65a
21 changed files with 620 additions and 373 deletions

View File

@ -1,6 +1,7 @@
using GeometryTD.Definition; using GeometryTD.Definition;
using GeometryTD.Entity.EntityData; using GeometryTD.Entity.EntityData;
using GeometryTD; using GeometryTD;
using GeometryTD.Entity;
using UnityEngine; using UnityEngine;
namespace Components namespace Components
@ -76,6 +77,9 @@ namespace Components
{ {
BaseDamage = _attackDamage, BaseDamage = _attackDamage,
AttackPropertyType = attackPropertyType, AttackPropertyType = attackPropertyType,
SourceEntityId = ResolveSourceEntityId(),
ProjectileEntityId = bulletEntityId,
OriginPosition = spawnPoint.position,
TagRuntimes = CloneTagRuntimes(_tagRuntimes) TagRuntimes = CloneTagRuntimes(_tagRuntimes)
}; };
BulletData bulletData = new BulletData( BulletData bulletData = new BulletData(
@ -114,5 +118,11 @@ namespace Components
return cloned; return cloned;
} }
private int ResolveSourceEntityId()
{
TowerEntity towerEntity = GetComponentInParent<TowerEntity>();
return towerEntity != null ? towerEntity.Id : 0;
}
} }
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using GeometryTD.CustomUtility; using GeometryTD.CustomUtility;
using UnityEngine;
namespace GeometryTD.Definition namespace GeometryTD.Definition
{ {
@ -8,6 +9,9 @@ namespace GeometryTD.Definition
{ {
public int BaseDamage { get; set; } public int BaseDamage { get; set; }
public AttackPropertyType AttackPropertyType { 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 TagRuntimeData[] TagRuntimes { get; set; } = Array.Empty<TagRuntimeData>();
public AttackPayload Clone() public AttackPayload Clone()
@ -16,6 +20,9 @@ namespace GeometryTD.Definition
{ {
BaseDamage = BaseDamage, BaseDamage = BaseDamage,
AttackPropertyType = AttackPropertyType, AttackPropertyType = AttackPropertyType,
SourceEntityId = SourceEntityId,
ProjectileEntityId = ProjectileEntityId,
OriginPosition = OriginPosition,
TagRuntimes = InventoryCloneUtility.CloneTagRuntimes(TagRuntimes) TagRuntimes = InventoryCloneUtility.CloneTagRuntimes(TagRuntimes)
}; };
} }

View File

@ -1,7 +1,26 @@
using System; using System;
using GeometryTD.Entity;
using UnityEngine;
namespace GeometryTD.Definition 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] [Serializable]
public sealed class HitContext public sealed class HitContext
{ {
@ -9,5 +28,35 @@ namespace GeometryTD.Definition
public int FinalDamage { get; set; } public int FinalDamage { get; set; }
public bool IsCriticalHit { get; set; } public bool IsCriticalHit { get; set; }
public bool IsKilled { 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);
} }
} }

View File

@ -4,12 +4,7 @@ namespace GeometryTD.Definition
{ {
public static class NumericTagEffectHandler public static class NumericTagEffectHandler
{ {
public static void ApplyBeforeHit( public static void ApplyBeforeHit(HitContext hitContext)
HitContext hitContext,
int targetCurrentHealth,
int targetMaxHealth,
bool targetHasSlowStatus,
float? critRoll = null)
{ {
if (hitContext == null || hitContext.AttackPayload == null) if (hitContext == null || hitContext.AttackPayload == null)
{ {
@ -42,21 +37,22 @@ namespace GeometryTD.Definition
switch (runtime.TagType) switch (runtime.TagType)
{ {
case TagType.Crit: case TagType.Crit:
ApplyCrit(hitContext, runtime.TotalStack, definition.Config as CritTagConfig, critRoll); ApplyCrit(hitContext, runtime.TotalStack, definition.Config as CritTagConfig);
break; break;
case TagType.Execution: case TagType.Execution:
ApplyExecution(hitContext, runtime.TotalStack, targetCurrentHealth, targetMaxHealth, definition.Config as ExecutionTagConfig); ApplyExecution(hitContext, runtime.TotalStack, definition.Config as ExecutionTagConfig);
break; break;
case TagType.Shatter: case TagType.Shatter:
ApplyShatter(hitContext, runtime.TotalStack, targetHasSlowStatus, definition.Config as ShatterTagConfig); ApplyShatter(hitContext, runtime.TotalStack, definition.Config as ShatterTagConfig);
break; 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) if (config == null || !config.IsImplemented || stack <= 0)
{ {
@ -64,7 +60,7 @@ namespace GeometryTD.Definition
} }
float chance = Mathf.Clamp01(stack * config.CritChancePerStack); float chance = Mathf.Clamp01(stack * config.CritChancePerStack);
float resolvedCritRoll = critRoll ?? Random.value; float resolvedCritRoll = hitContext.CritRoll ?? Random.value;
if (resolvedCritRoll > chance) if (resolvedCritRoll > chance)
{ {
return; return;
@ -77,16 +73,14 @@ namespace GeometryTD.Definition
private static void ApplyExecution( private static void ApplyExecution(
HitContext hitContext, HitContext hitContext,
int stack, int stack,
int targetCurrentHealth,
int targetMaxHealth,
ExecutionTagConfig config) ExecutionTagConfig config)
{ {
if (config == null || !config.IsImplemented || stack <= 0 || targetMaxHealth <= 0) if (config == null || !config.IsImplemented || stack <= 0 || hitContext.TargetMaxHealth <= 0)
{ {
return; 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) if (healthRatio > config.TargetHealthThreshold)
{ {
return; return;
@ -96,14 +90,14 @@ namespace GeometryTD.Definition
hitContext.FinalDamage = Mathf.Max(0, Mathf.RoundToInt(hitContext.FinalDamage * multiplier)); 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) if (config == null || !config.IsImplemented || stack <= 0)
{ {
return; return;
} }
if (config.RequiresSlowedTarget && !targetHasSlowStatus) if (config.RequiresSlowedTarget && !hitContext.HasSlowStatusBeforeHit)
{ {
return; return;
} }

View File

@ -7,11 +7,9 @@ namespace GeometryTD.Definition
{ {
public abstract TagType TagType { get; } public abstract TagType TagType { get; }
public virtual void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime, public virtual void Apply(HitContext hitContext, TagRuntimeData runtimeData)
TagRuntimeData runtimeData)
{ {
_ = attackPayload; _ = hitContext;
_ = runtime;
_ = runtimeData; _ = runtimeData;
} }

View File

@ -8,10 +8,11 @@ namespace GeometryTD.Definition
{ {
public override TagType TagType => TagType.Fire; public override TagType TagType => TagType.Fire;
public override void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime, public override void Apply(HitContext hitContext, TagRuntimeData runtimeData)
TagRuntimeData runtimeData)
{ {
Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition)); Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition));
Debug.Assert(hitContext != null);
Debug.Assert(hitContext.TargetStatusRuntime != null);
FireTagConfig config = definition.Config as FireTagConfig; FireTagConfig config = definition.Config as FireTagConfig;
Debug.Assert(config != null); Debug.Assert(config != null);
@ -20,11 +21,13 @@ namespace GeometryTD.Definition
return; return;
} }
EnemyTagStatusRuntime runtime = hitContext.TargetStatusRuntime;
FireTagState state = runtime.GetOrCreateState<FireTagState>(TagType); 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( float burnDamagePerSecond = Mathf.Max(
0f, 0f,
config.BurnDamagePerSecondPerStack * runtimeData.TotalStack + GetInfernoBonusDamagePerSecond(infernoStack)); config.BurnDamagePerSecondPerStack * effectiveStack + modifierContext.BonusBurnDamagePerSecond);
if (burnDamagePerSecond <= 0f) if (burnDamagePerSecond <= 0f)
{ {
@ -33,7 +36,7 @@ namespace GeometryTD.Definition
state.RemainingDuration = Mathf.Max( state.RemainingDuration = Mathf.Max(
state.RemainingDuration, state.RemainingDuration,
config.BurnDurationSeconds + GetInfernoBonusDuration(infernoStack)); config.BurnDurationSeconds + modifierContext.BonusBurnDurationSeconds);
state.DamagePerSecond = Mathf.Max(state.DamagePerSecond, burnDamagePerSecond); state.DamagePerSecond = Mathf.Max(state.DamagePerSecond, burnDamagePerSecond);
runtime.Activate(TagType); runtime.Activate(TagType);
} }
@ -68,39 +71,5 @@ namespace GeometryTD.Definition
state.PendingDamage = 0f; state.PendingDamage = 0f;
return false; 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;
}
} }
} }

View File

@ -6,7 +6,7 @@ namespace GeometryTD.Definition
public interface IEnemyStatusTagEffect public interface IEnemyStatusTagEffect
{ {
TagType TagType { get; } 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); bool Tick(EnemyTagStatusRuntime runtime, float deltaTime, Action<int> applyDamage);
float GetMoveSpeedMultiplier(EnemyTagStatusRuntime runtime); float GetMoveSpeedMultiplier(EnemyTagStatusRuntime runtime);
} }

View File

@ -8,10 +8,10 @@ namespace GeometryTD.Definition
{ {
public override TagType TagType => TagType.Ice; public override TagType TagType => TagType.Ice;
public override void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime, public override void Apply(HitContext hitContext, TagRuntimeData runtimeData)
TagRuntimeData runtimeData)
{ {
_ = attackPayload; Debug.Assert(hitContext != null);
Debug.Assert(hitContext.TargetStatusRuntime != null);
Debug.Assert(runtimeData != null); Debug.Assert(runtimeData != null);
Debug.Assert(runtimeData.TotalStack > 0); Debug.Assert(runtimeData.TotalStack > 0);
Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition)); Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition));
@ -23,13 +23,14 @@ namespace GeometryTD.Definition
return; return;
} }
EnemyTagStatusRuntime runtime = hitContext.TargetStatusRuntime;
IceTagState state = runtime.GetOrCreateState<IceTagState>(TagType); IceTagState state = runtime.GetOrCreateState<IceTagState>(TagType);
int absoluteZeroStack = TagEffectResolver.GetTagStack(attackPayload?.TagRuntimes, TagType.AbsoluteZero); HitStatusModifierContext modifierContext = hitContext.StatusModifierContext ?? new HitStatusModifierContext();
float slowRatio = runtimeData.TotalStack * config.SlowRatioPerStack + GetAbsoluteZeroBonusSlowRatio(absoluteZeroStack); float slowRatio = runtimeData.TotalStack * config.SlowRatioPerStack + modifierContext.BonusSlowRatio;
float slowMultiplier = Mathf.Max(config.MinMoveSpeedMultiplier, 1f - slowRatio); float slowMultiplier = Mathf.Max(config.MinMoveSpeedMultiplier, 1f - slowRatio);
state.RemainingDuration = Mathf.Max( state.RemainingDuration = Mathf.Max(
state.RemainingDuration, state.RemainingDuration,
config.SlowDurationSeconds + GetAbsoluteZeroBonusDuration(absoluteZeroStack)); config.SlowDurationSeconds + modifierContext.BonusSlowDurationSeconds);
state.SlowMultiplier = Mathf.Min(state.SlowMultiplier, slowMultiplier); state.SlowMultiplier = Mathf.Min(state.SlowMultiplier, slowMultiplier);
runtime.Activate(TagType); runtime.Activate(TagType);
} }
@ -49,39 +50,5 @@ namespace GeometryTD.Definition
IceTagState state = runtime.GetState<IceTagState>(TagType); IceTagState state = runtime.GetState<IceTagState>(TagType);
return state == null ? 1f : state.SlowMultiplier; 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;
}
} }
} }

View File

@ -5,37 +5,58 @@ namespace GeometryTD.Definition
{ {
public static class TagEffectResolver public static class TagEffectResolver
{ {
public static HitContext ResolveBeforeHit( private static readonly HitStatusModifierContext EmptyStatusModifierContext = new HitStatusModifierContext();
AttackPayload attackPayload,
int targetCurrentHealth, public static HitContext ResolveBeforeHit(HitContext hitContext)
int targetMaxHealth,
bool targetHasSlowStatus,
float? critRoll = null)
{ {
_ = targetHasSlowStatus; if (hitContext == null)
AttackPayload resolvedPayload = attackPayload?.Clone() ?? new AttackPayload(); {
HitContext hitContext = new HitContext return new HitContext
{
AttackPayload = new AttackPayload()
};
}
AttackPayload resolvedPayload = hitContext.AttackPayload?.Clone() ?? new AttackPayload();
HitContext resolvedContext = new HitContext
{ {
AttackPayload = resolvedPayload, AttackPayload = resolvedPayload,
FinalDamage = Mathf.Max(0, resolvedPayload.BaseDamage), FinalDamage = Mathf.Max(0, resolvedPayload.BaseDamage),
IsCriticalHit = false, 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( NumericTagEffectHandler.ApplyBeforeHit(resolvedContext);
hitContext, return resolvedContext;
targetCurrentHealth,
targetMaxHealth,
targetHasSlowStatus,
critRoll);
return hitContext;
} }
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) if (tagRuntimes != null && tagRuntimes.Length > 0)
{ {
foreach (var tag in tagRuntimes) foreach (var tag in tagRuntimes)
@ -55,15 +76,12 @@ namespace GeometryTD.Definition
continue; continue;
} }
effect.Apply(attackPayload, targetStatusRuntime, tag); effect.Apply(hitContext, tag);
} }
} }
} }
AttackShapeTagEffectHandler.Apply(new HitContext AttackShapeTagEffectHandler.Apply(hitContext, TagTriggerPhase.OnAfterHit);
{
AttackPayload = attackPayload
}, TagTriggerPhase.OnAfterHit);
} }
public static void ApplyOnHit(HitContext hitContext) public static void ApplyOnHit(HitContext hitContext)
@ -95,5 +113,65 @@ namespace GeometryTD.Definition
return 0; 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;
}
} }
} }

View File

@ -8,8 +8,5 @@ namespace GeometryTD.Definition
public BurnSpreadTagConfig(bool isImplemented) : base(isImplemented) public BurnSpreadTagConfig(bool isImplemented) : base(isImplemented)
{ {
} }
public float SpreadRadius { get; set; } = 0f;
public float SpreadDamageRate { get; set; } = 0f;
} }
} }

View File

@ -8,7 +8,5 @@ namespace GeometryTD.Definition
public FreezeMaskTagConfig(bool isImplemented) : base(isImplemented) public FreezeMaskTagConfig(bool isImplemented) : base(isImplemented)
{ {
} }
public float FreezeBuildUpPerStack { get; set; } = 0f;
} }
} }

View File

@ -8,8 +8,5 @@ namespace GeometryTD.Definition
public IgniteBurstTagConfig(bool isImplemented) : base(isImplemented) public IgniteBurstTagConfig(bool isImplemented) : base(isImplemented)
{ {
} }
public float BurstRadius { get; set; } = 0f;
public float BurstDamageRate { get; set; } = 0f;
} }
} }

View File

@ -8,8 +8,5 @@ namespace GeometryTD.Definition
public OverpenetrateTagConfig(bool isImplemented) : base(isImplemented) public OverpenetrateTagConfig(bool isImplemented) : base(isImplemented)
{ {
} }
public int ExtraPenetrationCount { get; set; } = 0;
public float RemainingDamageRate { get; set; } = 0f;
} }
} }

View File

@ -8,7 +8,5 @@ namespace GeometryTD.Definition
public PierceTagConfig(bool isImplemented) : base(isImplemented) public PierceTagConfig(bool isImplemented) : base(isImplemented)
{ {
} }
public int ExtraPierceCount { get; set; } = 2;
} }
} }

View File

@ -69,16 +69,16 @@ namespace GeometryTD.Definition
return new Dictionary<TagType, TagDefinition> return new Dictionary<TagType, TagDefinition>
{ {
[TagType.Fire] = CreateDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit, new FireTagConfig(true)), [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.BurnSpread] = CreateDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.None, new BurnSpreadTagConfig(false)),
[TagType.IgniteBurst] = CreateDefinition(TagType.IgniteBurst, TagCategory.AttackShape, TagTriggerPhase.OnKill, new IgniteBurstTagConfig(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.Inferno] = CreateDefinition(TagType.Inferno, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit, new InfernoTagConfig(true)),
[TagType.Ice] = CreateDefinition(TagType.Ice, TagCategory.Status, TagTriggerPhase.OnAfterHit, new IceTagConfig(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.Shatter] = CreateDefinition(TagType.Shatter, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ShatterTagConfig(true)),
[TagType.AbsoluteZero] = CreateDefinition(TagType.AbsoluteZero, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit, new AbsoluteZeroTagConfig(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.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)) [TagType.Execution] = CreateDefinition(TagType.Execution, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ExecutionTagConfig(true))
}; };
} }

View File

@ -115,14 +115,23 @@ namespace GeometryTD.Entity
return; return;
} }
HitContext hitContext = TagEffectResolver.ResolveBeforeHit( HitContext hitContext = TagEffectResolver.ResolveBeforeHit(new HitContext
attackPayload, {
_currentHealth, AttackPayload = attackPayload,
_maxHealth, TargetEntityId = Id,
HasSlowStatus); TargetPosition = CachedTransform.position,
TargetCurrentHealthBeforeHit = _currentHealth,
TargetCurrentHealthAfterHit = _currentHealth,
TargetMaxHealth = _maxHealth,
TargetMoveSpeedMultiplierBeforeHit = _tagStatusRuntime.GetMoveSpeedMultiplier(),
TargetStatusTagsBeforeHit = _tagStatusRuntime.GetActiveTagSnapshot(),
TargetStatusRuntime = _tagStatusRuntime
});
ApplyDirectDamage(hitContext.FinalDamage); ApplyDirectDamage(hitContext.FinalDamage);
hitContext.TargetCurrentHealthAfterHit = _currentHealth;
hitContext.IsKilled = _currentHealth <= 0;
TagEffectResolver.ApplyOnHit(hitContext); TagEffectResolver.ApplyOnHit(hitContext);
TagEffectResolver.ApplyAfterHit(hitContext.AttackPayload, _tagStatusRuntime); TagEffectResolver.ApplyAfterHit(hitContext);
if (hitContext.IsKilled) if (hitContext.IsKilled)
{ {
TagEffectResolver.ApplyOnKill(hitContext); TagEffectResolver.ApplyOnKill(hitContext);

View File

@ -15,6 +15,16 @@ namespace GeometryTD.Entity
return _statesByTag.ContainsKey(tagType); return _statesByTag.ContainsKey(tagType);
} }
public TagType[] GetActiveTagSnapshot()
{
if (_activeTags.Count <= 0)
{
return Array.Empty<TagType>();
}
return _activeTags.ToArray();
}
public void Reset() public void Reset()
{ {
_statesByTag.Clear(); _statesByTag.Clear();

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.IsCriticalHit, Is.True);
Assert.That(hitContext.FinalDamage, Is.EqualTo(150)); Assert.That(hitContext.FinalDamage, Is.EqualTo(150));
@ -43,8 +43,8 @@ namespace GeometryTD.Tests.EditMode
} }
}; };
HitContext lowHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 30, 100, false); HitContext lowHealthHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 30, 100));
HitContext highHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 31, 100, false); HitContext highHealthHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 31, 100));
Assert.That(lowHealthHit.FinalDamage, Is.EqualTo(150)); Assert.That(lowHealthHit.FinalDamage, Is.EqualTo(150));
Assert.That(highHealthHit.FinalDamage, Is.EqualTo(100)); Assert.That(highHealthHit.FinalDamage, Is.EqualTo(100));
@ -63,8 +63,8 @@ namespace GeometryTD.Tests.EditMode
} }
}; };
HitContext slowedHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, true); HitContext slowedHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100, 0.6f, new[] { TagType.Ice }));
HitContext normalHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, false); HitContext normalHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100));
Assert.That(slowedHit.FinalDamage, Is.EqualTo(150)); Assert.That(slowedHit.FinalDamage, Is.EqualTo(150));
Assert.That(normalHit.FinalDamage, Is.EqualTo(100)); 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.IsCriticalHit, Is.True);
Assert.That(hitContext.FinalDamage, Is.EqualTo(281)); Assert.That(hitContext.FinalDamage, Is.EqualTo(281));
@ -125,6 +125,63 @@ namespace GeometryTD.Tests.EditMode
Assert.That(stack, Is.EqualTo(2)); 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 public sealed class EnemyTagStatusRuntimeTests
@ -143,7 +200,7 @@ namespace GeometryTD.Tests.EditMode
}; };
int totalDamage = 0; 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.Fire), Is.True);
runtime.Tick(1f, damage => totalDamage += damage); runtime.Tick(1f, damage => totalDamage += damage);
@ -154,6 +211,32 @@ namespace GeometryTD.Tests.EditMode
Assert.That(totalDamage, Is.EqualTo(120)); 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] [Test]
public void ApplyAfterHit_Fire_UsesElapsedDeltaTimeInsteadOfIndependentInterval() public void ApplyAfterHit_Fire_UsesElapsedDeltaTimeInsteadOfIndependentInterval()
{ {
@ -168,7 +251,7 @@ namespace GeometryTD.Tests.EditMode
}; };
int totalDamage = 0; 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);
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.HasStatus(TagType.Ice), Is.True);
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(0.6f).Within(0.001f)); Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(0.6f).Within(0.001f));
@ -217,7 +300,7 @@ namespace GeometryTD.Tests.EditMode
}; };
int totalDamage = 0; 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.Fire), Is.True);
Assert.That(runtime.HasStatus(TagType.Inferno), Is.False); Assert.That(runtime.HasStatus(TagType.Inferno), Is.False);
@ -231,6 +314,35 @@ namespace GeometryTD.Tests.EditMode
Assert.That(runtime.HasStatus(TagType.Fire), Is.False); 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] [Test]
public void ApplyAfterHit_InfernoWithoutFire_DoesNotCreateStatus() 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.Fire), Is.False);
Assert.That(runtime.HasStatus(TagType.Inferno), 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] [Test]
public void ApplyAfterHit_IceWithAbsoluteZero_IncreasesSlowStrengthAndDuration() 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.Ice), Is.True);
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False); 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.Ice), Is.False);
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False); Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False);
@ -323,12 +464,12 @@ namespace GeometryTD.Tests.EditMode
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime(); EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
int totalDamage = 0; int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(new AttackPayload TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(new AttackPayload
{ {
BaseDamage = tower.Stats.AttackDamage[0], BaseDamage = tower.Stats.AttackDamage[0],
AttackPropertyType = tower.Stats.AttackPropertyType, AttackPropertyType = tower.Stats.AttackPropertyType,
TagRuntimes = tower.Stats.TagRuntimes TagRuntimes = tower.Stats.TagRuntimes
}, runtime); }, runtime));
runtime.Tick(1f, damage => totalDamage += damage); runtime.Tick(1f, damage => totalDamage += damage);
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(runtime.HasStatus(TagType.Fire), Is.False);
Assert.That(totalDamage, Is.EqualTo(180)); 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 public sealed class EnemyStatusTagRegistryTests
@ -372,8 +542,8 @@ namespace GeometryTD.Tests.EditMode
AssertDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit); AssertDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit);
AssertDefinition(TagType.Inferno, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit); AssertDefinition(TagType.Inferno, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit);
AssertDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit); AssertDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit);
AssertDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.OnHit); AssertDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.None);
AssertDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.OnKill); AssertDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.None);
AssertDefinition(TagType.AbsoluteZero, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit); AssertDefinition(TagType.AbsoluteZero, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit);
} }
@ -519,10 +689,21 @@ namespace GeometryTD.Tests.EditMode
}; };
Assert.DoesNotThrow(() => TagEffectResolver.ApplyOnHit(hitContext)); 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)); 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) private static void AssertDefinition(TagType tagType, TagCategory expectedCategory, TagTriggerPhase expectedPhase)
{ {
Assert.That(TagDefinitionRegistry.TryGetDefinition(tagType, out TagDefinition definition), Is.True); Assert.That(TagDefinitionRegistry.TryGetDefinition(tagType, out TagDefinition definition), Is.True);
@ -577,6 +758,9 @@ namespace GeometryTD.Tests.EditMode
{ {
BaseDamage = 80, BaseDamage = 80,
AttackPropertyType = AttackPropertyType.Fire, AttackPropertyType = AttackPropertyType.Fire,
SourceEntityId = 101,
ProjectileEntityId = 202,
OriginPosition = new Vector3(1f, 2f, 0f),
TagRuntimes = new[] TagRuntimes = new[]
{ {
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 } 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, Is.Not.Null);
Assert.That(receiver.LastPayload.BaseDamage, Is.EqualTo(80)); Assert.That(receiver.LastPayload.BaseDamage, Is.EqualTo(80));
Assert.That(receiver.LastPayload.AttackPropertyType, Is.EqualTo(AttackPropertyType.Fire)); 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, Has.Length.EqualTo(1));
Assert.That(receiver.LastPayload.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire)); 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 private sealed class RecordingDamageReceiver : MonoBehaviour, IDamageReceiver
{ {
public AttackPayload LastPayload { get; private set; } public AttackPayload LastPayload { get; private set; }

View File

@ -286,11 +286,11 @@
| 状态 | ID | 任务 | 交付物路径 | 验收标准 | | 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|-----|-------|------------------------------|---------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------| |-----|-------|------------------------------|---------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------|
| [ ] | S7-01 | 收口 Tag 配置真相源与加载顺序 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/Definition/Tag/`<br>`Assets/Tests/EditMode/` | `IsImplemented`、默认值与运行时定义不再受 DataTable 加载顺序影响 | | [x] | S7-01 | 收口 Tag 配置真相源与加载顺序 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/Definition/Tag/`<br>`Assets/Tests/EditMode/` | `IsImplemented`、默认值与运行时定义不再受 DataTable 加载顺序影响 |
| [ ] | S7-02 | 补齐组件 Tag 随机上下文与 `RunSeed` 合同 | `Assets/GameMain/Scripts/Definition/Tag/Generation/`<br>`Assets/GameMain/Scripts/UI/Shop/`<br>`Assets/GameMain/Scripts/CustomComponent/CombatNode/`<br>`Assets/Tests/EditMode/` | 同 Run 可复现、跨 Run 可区分,且掉落 / 商店 / 种子共用同一随机合同 | | [x] | S7-02 | 补齐组件 Tag 随机上下文与 `RunSeed` 合同 | `Assets/GameMain/Scripts/Definition/Tag/Generation/`<br>`Assets/GameMain/Scripts/UI/Shop/`<br>`Assets/GameMain/Scripts/CustomComponent/CombatNode/`<br>`Assets/Tests/EditMode/` | 同 Run 可复现、跨 Run 可区分,且掉落 / 商店 / 种子共用同一随机合同 |
| [ ] | S7-03 | 收口 `AttackPayload / HitContext` 结算合同 | `Assets/GameMain/Scripts/Definition/DataStruct/`<br>`Assets/GameMain/Scripts/Definition/Tag/Combat/`<br>`Assets/GameMain/Scripts/Entity/`<br>`Assets/Tests/EditMode/` | Tag 结算统一只读上下文对象,不再继续靠散装参数扩展 | | [x] | S7-03 | 收口 `AttackPayload / HitContext` 结算合同 | `Assets/GameMain/Scripts/Definition/DataStruct/`<br>`Assets/GameMain/Scripts/Definition/Tag/Combat/`<br>`Assets/GameMain/Scripts/Entity/`<br>`Assets/Tests/EditMode/` | Tag 结算统一只读上下文对象,不再继续靠散装参数扩展 |
| [ ] | S7-04 | 对齐 `TagConfig` 字段与运行时真实消费 | `Assets/GameMain/DataTables/TagConfig.txt`<br>`Assets/GameMain/Scripts/Definition/Tag/Metadata/`<br>`Assets/GameMain/Scripts/Definition/Tag/Combat/`<br>`Assets/Tests/EditMode/` | 保留字段要么真实生效,要么明确标注为占位且不会误导配置方 | | [x] | S7-04 | 对齐 `TagConfig` 字段与运行时真实消费 | `Assets/GameMain/DataTables/TagConfig.txt`<br>`Assets/GameMain/Scripts/Definition/Tag/Metadata/`<br>`Assets/GameMain/Scripts/Definition/Tag/Combat/`<br>`Assets/Tests/EditMode/` | 保留字段要么真实生效,要么明确标注为占位且不会误导配置方 |
| [ ] | S7-05 | 清理 Tag 设计文档中的过期草稿 | `docs/TagSystemDesign.md`<br>`docs/CodeX-TODO.md` | 正式口径、当前实现、后续预案三者分层清晰 | | [x] | S7-05 | 清理 Tag 设计文档中的过期草稿 | `docs/TagSystemDesign.md`<br>`docs/CodeX-TODO.md`<br>`docs/TagSystemRoadmap.md` | 正式口径、当前实现、后续预案三者分层清晰 |
### S7 立项原因 ### S7 立项原因
@ -298,59 +298,13 @@
- 如果现在直接继续做 `BurnSpread`、`Pierce`、`Overpenetrate` 一类高侵入 Tag会把当前加载顺序、随机上下文和命中结算接口里的隐患一起放大。 - 如果现在直接继续做 `BurnSpread`、`Pierce`、`Overpenetrate` 一类高侵入 Tag会把当前加载顺序、随机上下文和命中结算接口里的隐患一起放大。
- 因此 `S7` 的目标不是新增玩法,而是先把 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 默认值三处不能再各自持有一份“是否首发 / 是否启用”的真相。 ### S7 完成结论
- `ProcedurePreload``Tag``TagConfig` 的加载完成顺序不能再影响 `TagDefinitionRegistry` 最终状态。
- 至少补一组 EditMode 测试覆盖“先加载 `Tag` 再加载 `TagConfig`”与“先加载 `TagConfig` 再加载 `Tag`”两种顺序,确认结果一致。
- 本项完成后,`ComponentTagGenerationService` 的首发过滤与展示 / 战斗的定义读取必须共享同一份最终状态。
### S7-02 整改口径 - 当前 Tag 系统已经具备继续扩展的稳定基础,但第二批攻击形态 Tag 仍未进入真实战斗效果。
- 后续继续改 Tag 时,应以 `TagSystemDesign.md` 查看当前正式口径,以 `TagSystemRoadmap.md` 查看长期扩展预案。
- `ComponentTagGenerationService` 需要正式接收并消费 `RunSeed` 或等价的显式随机上下文,而不是只依赖 `rarity + sourceType + itemInstanceId + configId` - 在第二批 Tag 正式立项前,`BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 继续保持“占位配置 + 占位路由,无真实战斗效果”的当前边界。
- 掉落、商店、初始种子、事件奖励四条链路必须明确各自如何构造同构的 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`,把配置字段与设计文档统一回写
## 备注 ## 备注

View File

@ -3,8 +3,8 @@
最后更新2026-03-12 最后更新2026-03-12
> 目标:这是 GeometryTD 当前 Tag 系统的唯一正式口径。 > 目标:这是 GeometryTD 当前 Tag 系统的唯一正式口径。
> 本文档只记录当前真实实现、已确认的问题与后续整改边界 > 本文档只记录当前真实实现、当前边界与正式规则
> 历史流派草稿不再作为主文档内容 > 长期扩展预案见 `docs/TagSystemRoadmap.md`;若两者冲突,以本文件为准
## 1. 当前范围 ## 1. 当前范围
@ -40,17 +40,18 @@ M1 已完成 Tag 最小闭环:
- Tag 等级成长 - Tag 等级成长
- `TagGroup` 运行时规则 - `TagGroup` 运行时规则
## 2. 正式决策 ## 2. 正式规则
1. Tag 在组件实例创建时随机;组塔阶段只汇总,不重新随机。 1. Tag 在组件实例创建时随机;组塔阶段只汇总,不重新随机。
2. 组件表的 `PossibleTag` 只表示候选池,不代表实例最终持有结果。 2. 组件表的 `PossibleTag` 只表示候选池,不代表实例最终持有结果。
3. 组塔后重复 Tag 不丢弃,而是转为塔级 `Stack` 3. 组塔后重复 Tag 不丢弃,而是转为塔级 `Stack`
4. 配置层正式采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表结构。 4. 配置层正式采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表结构。
5. 战斗中的 Tag 统一挂在 `AttackPayload -> HitContext -> TagEffectResolver` 链路上。 5. `Tag.txt``IsImplemented` 的正式真相源;`TagConfig.txt` 只负责触发阶段、描述与运行时参数。
6. 新逻辑应优先使用 `TagRuntimes``Tags` 只保留兼容投影职责。 6. 战斗中的 Tag 统一挂在 `AttackPayload -> HitContext -> TagEffectResolver` 链路上。
7. `TagGroup` 当前只作为展示元数据,不进入生成、汇总或战斗规则。 7. 新逻辑应优先使用 `TagRuntimes``Tags` 只保留兼容投影职责。
8. `TagGroup` 当前只作为展示元数据,不进入生成、汇总或战斗规则。
## 3. 配置模型 ## 3. 配置与生成
### 3.1 三表职责 ### 3.1 三表职责
@ -72,13 +73,12 @@ M1 已完成 Tag 最小闭环:
### 3.2 当前消费链 ### 3.2 当前消费链
- `Tag.txt -> DRTag -> TagGenerationRuleRegistry` - `Tag.txt -> DRTag -> TagGenerationRuleRegistry`
- 当前负责 `MinRarity``Weight` - 当前负责 `MinRarity`、`Weight`
- `Tag.txt -> DRTag -> TagDefinitionRegistry.ApplyTagRows` - `Tag.txt + TagConfig.txt -> TagDefinitionRegistry.ReloadFromRows(...)`
- 当前负责把 `IsImplemented` 同步到定义层 - 当前负责 `IsImplemented`、`TriggerPhase`、`Description` 与正式运行时参数
- `ProcedurePreload``Tag``TagConfig` 任一表加载完成后,都会基于当前已加载的两张表组合重建定义层
- `RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry` - `RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry`
- 当前负责按品质读取 `MinCount / MaxCount` - 当前负责按品质读取 `MinCount / MaxCount`
- `TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry`
- 当前负责 `TriggerPhase`、`Description` 与各 Tag 参数配置
### 3.3 运行时结构 ### 3.3 运行时结构
@ -95,18 +95,16 @@ public sealed class TagRuntimeData
} }
``` ```
### 3.4 当前最小战斗结构 ### 3.4 组件 Tag 生成
- `AttackPayload` 当前统一入口是 `ComponentTagGenerationService`,以下链路共用同一套规则:
- 当前字段:`BaseDamage`、`AttackPropertyType`、`TagRuntimes`
- `HitContext`
- 当前字段:`AttackPayload`、`FinalDamage`、`IsCriticalHit`、`IsKilled`
- 这套结构已经足够承载首发 7 个 Tag
- 但还不足以稳定承载第二批攻击形态类 Tag详见第 7 节审计问题
## 4. 组件 Tag 生成 - `InventorySeedUtility`
- `ShopFormUseCase`
- `EnemyDropResolver`
- 结算奖励候选组件
### 4.1 当前流程 当前流程
1. 读取组件配置的 `PossibleTag` 1. 读取组件配置的 `PossibleTag`
2. 读取 `Tag.txt` 中对应的生成规则 2. 读取 `Tag.txt` 中对应的生成规则
@ -117,168 +115,112 @@ public sealed class TagRuntimeData
7. 单组件内不重复抽取同一 Tag 7. 单组件内不重复抽取同一 Tag
8. 候选池不足时允许少于预算,不强行补满 8. 候选池不足时允许少于预算,不强行补满
### 4.2 当前统一入口 ### 3.5 可复现合同
- `InventorySeedUtility` Tag 随机结果的正式上下文为:
- `ShopFormUseCase`
- `EnemyDropResolver`
- 事件奖励后续如生成组件,也必须走同一入口
- 当前统一入口是 `ComponentTagGenerationService`
### 4.3 可复现合同
- 设计目标要求 Tag 结果对同一 Run 可复现,并在存档读档后保持一致
正式随机上下文应包含:
- `RunSeed` - `RunSeed`
- `ItemInstanceId`
- `ConfigId`
- `SourceType`
当前实现已经使用:
- `Rarity`
- `SourceType` - `SourceType`
- `ItemInstanceId` - `ItemInstanceId`
- `ConfigId` - `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 - 同一组件内不允许重复同一个 Tag
- 不同组件之间允许重复 - 不同组件之间允许重复
- 组塔时不做重新随机 - 组塔时不做重新随机
- 汇总时按 `TagType` 分组并累加 `Stack` - 汇总时按 `TagType` 分组并累加 `Stack`
### 5.2 展示规则
- 组件展示仍显示组件实例自己的 `Tags` - 组件展示仍显示组件实例自己的 `Tags`
- 塔展示优先显示 `TagRuntimes` - 塔展示优先显示 `TagRuntimes`
- 若缺少 `TagRuntimes`,允许通过 `Tags` 回退构建兼容结果 - 若缺少 `TagRuntimes`,允许通过 `Tags` 回退构建兼容结果
- 重复 Tag 以 `xN` 文本显示,例如 `Fire x2` - 重复 Tag 以 `xN` 文本显示,例如 `Fire x2`
- `TowerStatsData.Tags` 不是新的真相源,只用于兼容旧展示链路与旧数据
### 5.3 兼容边界 ### 4.2 战斗上下文
- `TowerStatsData.Tags` 不是新的真相源 `AttackPayload` 当前字段:
- 它只用于兼容旧展示链路与旧数据
- 后续新增逻辑应优先读取 `TagRuntimes`
## 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` | | `Status` | 命中后在敌人身上形成可持续状态 | `OnAfterHit` |
| `NumericModifier` | 命中前修正最终伤害 | `OnBeforeHit` | | `NumericModifier` | 命中前修正最终伤害 | `OnBeforeHit` |
| `AttackShape` | 穿透、传播、爆炸等攻击形态变化 | `OnHit` / `OnKill` | | `AttackShape` | 穿透、传播、爆炸等攻击形态变化 | `OnHit` / `OnKill` |
| `StatusModifier` | 强化同次命中的状态类 Tag但不独立生成敌人持有状态 | 当前随主状态一起消费 | | `StatusModifier` | 强化同次命中的状态类 Tag但不独立生成敌人持有状态 | `OnAfterHit` |
### 6.2 当前已实现的首发 7 Tag ### 4.4 当前已实现的首发 7 Tag
| Tag | 分类 | 配置阶段 | 当前真实行为 | | Tag | 分类 | 配置阶段 | 当前真实行为 |
|-----|------|----------|--------------| |-----|------|----------|--------------|
| `Fire` | `Status` | `OnAfterHit` | 命中后施加燃烧 DOT | | `Fire` | `Status` | `OnAfterHit` | 命中后施加燃烧 DOT,且 `MaxEffectiveStack` 已进入基础层数结算 |
| `Ice` | `Status` | `OnAfterHit` | 命中后施加减速 | | `Ice` | `Status` | `OnAfterHit` | 命中后施加减速 |
| `Crit` | `NumericModifier` | `OnBeforeHit` | 按概率暴击并放大伤害 | | `Crit` | `NumericModifier` | `OnBeforeHit` | 按概率暴击并放大伤害 |
| `Execution` | `NumericModifier` | `OnBeforeHit` | 对低血量目标增伤 | | `Execution` | `NumericModifier` | `OnBeforeHit` | 对低血量目标增伤 |
| `Shatter` | `NumericModifier` | `OnBeforeHit` | 对已减速目标增伤 | | `Shatter` | `NumericModifier` | `OnBeforeHit` | 对已减速目标增伤 |
| `Inferno` | `StatusModifier` | `OnAfterHit` | 强化同次命中的 `Fire` 时长与伤害 | | `Inferno` | `StatusModifier` | `OnAfterHit` | 先由 resolver 解析为命中期修饰,再强化同次命中的 `Fire` 时长与伤害 |
| `AbsoluteZero` | `StatusModifier` | `OnAfterHit` | 强化同次命中的 `Ice` 时长与减速强度 | | `AbsoluteZero` | `StatusModifier` | `OnAfterHit` | 先由 resolver 解析为命中期修饰,再强化同次命中的 `Ice` 时长与减速强度 |
### 6.3 当前已后移的 5 Tag ### 4.5 当前已后移的 5 Tag
| Tag | 分类 | 当前状态 | | Tag | 分类 | 当前状态 |
|-----|------|----------| |-----|------|----------|
| `BurnSpread` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | | `BurnSpread` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 |
| `IgniteBurst` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | | `IgniteBurst` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 |
| `FreezeMask` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | | `FreezeMask` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 |
| `Pierce` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | | `Pierce` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 |
| `Overpenetrate` | `AttackShape` | 仅保留元数据与占位路由,未实际生效 | | `Overpenetrate` | `AttackShape` | 仅保留元数据、占位配置与占位路由,未实际生效 |
### 6.4 当前运行时边界 ## 5. 当前运行时边界
- 数值类 Tag 当前在 `ResolveBeforeHit` 阶段生效 - 数值类 Tag 在 `ResolveBeforeHit` 阶段生效
- 状态类 Tag 当前通过 `EnemyTagStatusRuntime` 管理敌人持有状态 - 状态类 Tag 通过 `EnemyTagStatusRuntime` 管理敌人持有状态
- `Inferno``AbsoluteZero` 当前不是独立状态,而是在 `FireTagEffect`、`IceTagEffect` 中读取同次命中的强化层数 - `StatusModifier` 已独立按 `OnAfterHit` 路由,并写入 `StatusModifierContext`
- 攻击形态类 Tag 当前只有路由骨架,没有真实战斗效果 - `Inferno``AbsoluteZero` 不生成敌人持有状态;没有 `Fire` / `Ice` 时不会单独产生效果
- 后移 5 个攻击形态 Tag 的 `TriggerPhase` 当前正式口径为 `None`
- 后移 5 个攻击形态 Tag 的 `ParamJson` 当前只视为占位说明,不是正式运行时字段
## 7. 已确认审计问题 ## 6. 非当前范围
### 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. 非当前范围
- `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 的真实战斗实现 - `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 的真实战斗实现
- 更复杂的多 Tag 联动与流派激活矩阵 - 更复杂的多 Tag 联动与流派激活矩阵

62
docs/TagSystemRoadmap.md Normal file
View File

@ -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` 主干