S7-03 + S7-04 + S7-05
This commit is contained in:
parent
185ea43323
commit
3840f8e65a
|
|
@ -1,6 +1,7 @@
|
|||
using GeometryTD.Definition;
|
||||
using GeometryTD.Entity.EntityData;
|
||||
using GeometryTD;
|
||||
using GeometryTD.Entity;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Components
|
||||
|
|
@ -76,6 +77,9 @@ namespace Components
|
|||
{
|
||||
BaseDamage = _attackDamage,
|
||||
AttackPropertyType = attackPropertyType,
|
||||
SourceEntityId = ResolveSourceEntityId(),
|
||||
ProjectileEntityId = bulletEntityId,
|
||||
OriginPosition = spawnPoint.position,
|
||||
TagRuntimes = CloneTagRuntimes(_tagRuntimes)
|
||||
};
|
||||
BulletData bulletData = new BulletData(
|
||||
|
|
@ -114,5 +118,11 @@ namespace Components
|
|||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
private int ResolveSourceEntityId()
|
||||
{
|
||||
TowerEntity towerEntity = GetComponentInParent<TowerEntity>();
|
||||
return towerEntity != null ? towerEntity.Id : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using GeometryTD.CustomUtility;
|
||||
using UnityEngine;
|
||||
|
||||
namespace GeometryTD.Definition
|
||||
{
|
||||
|
|
@ -8,6 +9,9 @@ namespace GeometryTD.Definition
|
|||
{
|
||||
public int BaseDamage { get; set; }
|
||||
public AttackPropertyType AttackPropertyType { get; set; }
|
||||
public int SourceEntityId { get; set; }
|
||||
public int ProjectileEntityId { get; set; }
|
||||
public Vector3 OriginPosition { get; set; }
|
||||
public TagRuntimeData[] TagRuntimes { get; set; } = Array.Empty<TagRuntimeData>();
|
||||
|
||||
public AttackPayload Clone()
|
||||
|
|
@ -16,6 +20,9 @@ namespace GeometryTD.Definition
|
|||
{
|
||||
BaseDamage = BaseDamage,
|
||||
AttackPropertyType = AttackPropertyType,
|
||||
SourceEntityId = SourceEntityId,
|
||||
ProjectileEntityId = ProjectileEntityId,
|
||||
OriginPosition = OriginPosition,
|
||||
TagRuntimes = InventoryCloneUtility.CloneTagRuntimes(TagRuntimes)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,26 @@
|
|||
using System;
|
||||
using GeometryTD.Entity;
|
||||
using UnityEngine;
|
||||
|
||||
namespace GeometryTD.Definition
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class HitStatusModifierContext
|
||||
{
|
||||
public float BonusBurnDurationSeconds { get; set; }
|
||||
public float BonusBurnDamagePerSecond { get; set; }
|
||||
public float BonusSlowDurationSeconds { get; set; }
|
||||
public float BonusSlowRatio { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
BonusBurnDurationSeconds = 0f;
|
||||
BonusBurnDamagePerSecond = 0f;
|
||||
BonusSlowDurationSeconds = 0f;
|
||||
BonusSlowRatio = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class HitContext
|
||||
{
|
||||
|
|
@ -9,5 +28,35 @@ namespace GeometryTD.Definition
|
|||
public int FinalDamage { get; set; }
|
||||
public bool IsCriticalHit { get; set; }
|
||||
public bool IsKilled { get; set; }
|
||||
public int TargetEntityId { get; set; }
|
||||
public Vector3 TargetPosition { get; set; }
|
||||
public int TargetCurrentHealthBeforeHit { get; set; }
|
||||
public int TargetCurrentHealthAfterHit { get; set; }
|
||||
public int TargetMaxHealth { get; set; }
|
||||
public float TargetMoveSpeedMultiplierBeforeHit { get; set; } = 1f;
|
||||
public TagType[] TargetStatusTagsBeforeHit { get; set; } = Array.Empty<TagType>();
|
||||
public EnemyTagStatusRuntime TargetStatusRuntime { get; set; }
|
||||
public float? CritRoll { get; set; }
|
||||
public HitStatusModifierContext StatusModifierContext { get; set; } = new HitStatusModifierContext();
|
||||
|
||||
public bool HasTargetStatus(TagType tagType)
|
||||
{
|
||||
if (tagType == TagType.None || TargetStatusTagsBeforeHit == null || TargetStatusTagsBeforeHit.Length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < TargetStatusTagsBeforeHit.Length; i++)
|
||||
{
|
||||
if (TargetStatusTagsBeforeHit[i] == tagType)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool HasSlowStatusBeforeHit => TargetMoveSpeedMultiplierBeforeHit < 0.999f || HasTargetStatus(TagType.Ice);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ namespace GeometryTD.Definition
|
|||
{
|
||||
public static class NumericTagEffectHandler
|
||||
{
|
||||
public static void ApplyBeforeHit(
|
||||
HitContext hitContext,
|
||||
int targetCurrentHealth,
|
||||
int targetMaxHealth,
|
||||
bool targetHasSlowStatus,
|
||||
float? critRoll = null)
|
||||
public static void ApplyBeforeHit(HitContext hitContext)
|
||||
{
|
||||
if (hitContext == null || hitContext.AttackPayload == null)
|
||||
{
|
||||
|
|
@ -42,21 +37,22 @@ namespace GeometryTD.Definition
|
|||
switch (runtime.TagType)
|
||||
{
|
||||
case TagType.Crit:
|
||||
ApplyCrit(hitContext, runtime.TotalStack, definition.Config as CritTagConfig, critRoll);
|
||||
ApplyCrit(hitContext, runtime.TotalStack, definition.Config as CritTagConfig);
|
||||
break;
|
||||
case TagType.Execution:
|
||||
ApplyExecution(hitContext, runtime.TotalStack, targetCurrentHealth, targetMaxHealth, definition.Config as ExecutionTagConfig);
|
||||
ApplyExecution(hitContext, runtime.TotalStack, definition.Config as ExecutionTagConfig);
|
||||
break;
|
||||
case TagType.Shatter:
|
||||
ApplyShatter(hitContext, runtime.TotalStack, targetHasSlowStatus, definition.Config as ShatterTagConfig);
|
||||
ApplyShatter(hitContext, runtime.TotalStack, definition.Config as ShatterTagConfig);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
hitContext.IsKilled = targetCurrentHealth > 0 && hitContext.FinalDamage >= targetCurrentHealth;
|
||||
hitContext.IsKilled = hitContext.TargetCurrentHealthBeforeHit > 0 &&
|
||||
hitContext.FinalDamage >= hitContext.TargetCurrentHealthBeforeHit;
|
||||
}
|
||||
|
||||
private static void ApplyCrit(HitContext hitContext, int stack, CritTagConfig config, float? critRoll)
|
||||
private static void ApplyCrit(HitContext hitContext, int stack, CritTagConfig config)
|
||||
{
|
||||
if (config == null || !config.IsImplemented || stack <= 0)
|
||||
{
|
||||
|
|
@ -64,7 +60,7 @@ namespace GeometryTD.Definition
|
|||
}
|
||||
|
||||
float chance = Mathf.Clamp01(stack * config.CritChancePerStack);
|
||||
float resolvedCritRoll = critRoll ?? Random.value;
|
||||
float resolvedCritRoll = hitContext.CritRoll ?? Random.value;
|
||||
if (resolvedCritRoll > chance)
|
||||
{
|
||||
return;
|
||||
|
|
@ -77,16 +73,14 @@ namespace GeometryTD.Definition
|
|||
private static void ApplyExecution(
|
||||
HitContext hitContext,
|
||||
int stack,
|
||||
int targetCurrentHealth,
|
||||
int targetMaxHealth,
|
||||
ExecutionTagConfig config)
|
||||
{
|
||||
if (config == null || !config.IsImplemented || stack <= 0 || targetMaxHealth <= 0)
|
||||
if (config == null || !config.IsImplemented || stack <= 0 || hitContext.TargetMaxHealth <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float healthRatio = Mathf.Clamp01((float)Mathf.Max(0, targetCurrentHealth) / targetMaxHealth);
|
||||
float healthRatio = Mathf.Clamp01((float)Mathf.Max(0, hitContext.TargetCurrentHealthBeforeHit) / hitContext.TargetMaxHealth);
|
||||
if (healthRatio > config.TargetHealthThreshold)
|
||||
{
|
||||
return;
|
||||
|
|
@ -96,14 +90,14 @@ namespace GeometryTD.Definition
|
|||
hitContext.FinalDamage = Mathf.Max(0, Mathf.RoundToInt(hitContext.FinalDamage * multiplier));
|
||||
}
|
||||
|
||||
private static void ApplyShatter(HitContext hitContext, int stack, bool targetHasSlowStatus, ShatterTagConfig config)
|
||||
private static void ApplyShatter(HitContext hitContext, int stack, ShatterTagConfig config)
|
||||
{
|
||||
if (config == null || !config.IsImplemented || stack <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.RequiresSlowedTarget && !targetHasSlowStatus)
|
||||
if (config.RequiresSlowedTarget && !hitContext.HasSlowStatusBeforeHit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ namespace GeometryTD.Definition
|
|||
{
|
||||
public abstract TagType TagType { get; }
|
||||
|
||||
public virtual void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime,
|
||||
TagRuntimeData runtimeData)
|
||||
public virtual void Apply(HitContext hitContext, TagRuntimeData runtimeData)
|
||||
{
|
||||
_ = attackPayload;
|
||||
_ = runtime;
|
||||
_ = hitContext;
|
||||
_ = runtimeData;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ namespace GeometryTD.Definition
|
|||
{
|
||||
public override TagType TagType => TagType.Fire;
|
||||
|
||||
public override void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime,
|
||||
TagRuntimeData runtimeData)
|
||||
public override void Apply(HitContext hitContext, TagRuntimeData runtimeData)
|
||||
{
|
||||
Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition));
|
||||
Debug.Assert(hitContext != null);
|
||||
Debug.Assert(hitContext.TargetStatusRuntime != null);
|
||||
|
||||
FireTagConfig config = definition.Config as FireTagConfig;
|
||||
Debug.Assert(config != null);
|
||||
|
|
@ -20,11 +21,13 @@ namespace GeometryTD.Definition
|
|||
return;
|
||||
}
|
||||
|
||||
EnemyTagStatusRuntime runtime = hitContext.TargetStatusRuntime;
|
||||
FireTagState state = runtime.GetOrCreateState<FireTagState>(TagType);
|
||||
int infernoStack = TagEffectResolver.GetTagStack(attackPayload?.TagRuntimes, TagType.Inferno);
|
||||
int effectiveStack = Mathf.Max(0, Mathf.Min(runtimeData.TotalStack, config.MaxEffectiveStack));
|
||||
HitStatusModifierContext modifierContext = hitContext.StatusModifierContext ?? new HitStatusModifierContext();
|
||||
float burnDamagePerSecond = Mathf.Max(
|
||||
0f,
|
||||
config.BurnDamagePerSecondPerStack * runtimeData.TotalStack + GetInfernoBonusDamagePerSecond(infernoStack));
|
||||
config.BurnDamagePerSecondPerStack * effectiveStack + modifierContext.BonusBurnDamagePerSecond);
|
||||
|
||||
if (burnDamagePerSecond <= 0f)
|
||||
{
|
||||
|
|
@ -33,7 +36,7 @@ namespace GeometryTD.Definition
|
|||
|
||||
state.RemainingDuration = Mathf.Max(
|
||||
state.RemainingDuration,
|
||||
config.BurnDurationSeconds + GetInfernoBonusDuration(infernoStack));
|
||||
config.BurnDurationSeconds + modifierContext.BonusBurnDurationSeconds);
|
||||
state.DamagePerSecond = Mathf.Max(state.DamagePerSecond, burnDamagePerSecond);
|
||||
runtime.Activate(TagType);
|
||||
}
|
||||
|
|
@ -68,39 +71,5 @@ namespace GeometryTD.Definition
|
|||
state.PendingDamage = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static float GetInfernoBonusDuration(int infernoStack)
|
||||
{
|
||||
if (infernoStack <= 0 ||
|
||||
!TagDefinitionRegistry.TryGetDefinition(TagType.Inferno, out TagDefinition definition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
InfernoTagConfig infernoConfig = definition.Config as InfernoTagConfig;
|
||||
if (infernoConfig == null || !infernoConfig.IsImplemented)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return infernoStack * infernoConfig.BonusBurnDurationSeconds;
|
||||
}
|
||||
|
||||
private static float GetInfernoBonusDamagePerSecond(int infernoStack)
|
||||
{
|
||||
if (infernoStack <= 0 ||
|
||||
!TagDefinitionRegistry.TryGetDefinition(TagType.Inferno, out TagDefinition definition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
InfernoTagConfig infernoConfig = definition.Config as InfernoTagConfig;
|
||||
if (infernoConfig == null || !infernoConfig.IsImplemented)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return infernoStack * infernoConfig.BonusBurnDamagePerSecondPerStack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace GeometryTD.Definition
|
|||
public interface IEnemyStatusTagEffect
|
||||
{
|
||||
TagType TagType { get; }
|
||||
void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime, TagRuntimeData runtimeData);
|
||||
void Apply(HitContext hitContext, TagRuntimeData runtimeData);
|
||||
bool Tick(EnemyTagStatusRuntime runtime, float deltaTime, Action<int> applyDamage);
|
||||
float GetMoveSpeedMultiplier(EnemyTagStatusRuntime runtime);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ namespace GeometryTD.Definition
|
|||
{
|
||||
public override TagType TagType => TagType.Ice;
|
||||
|
||||
public override void Apply(AttackPayload attackPayload, EnemyTagStatusRuntime runtime,
|
||||
TagRuntimeData runtimeData)
|
||||
public override void Apply(HitContext hitContext, TagRuntimeData runtimeData)
|
||||
{
|
||||
_ = attackPayload;
|
||||
Debug.Assert(hitContext != null);
|
||||
Debug.Assert(hitContext.TargetStatusRuntime != null);
|
||||
Debug.Assert(runtimeData != null);
|
||||
Debug.Assert(runtimeData.TotalStack > 0);
|
||||
Debug.Assert(TagDefinitionRegistry.TryGetDefinition(TagType, out TagDefinition definition));
|
||||
|
|
@ -23,13 +23,14 @@ namespace GeometryTD.Definition
|
|||
return;
|
||||
}
|
||||
|
||||
EnemyTagStatusRuntime runtime = hitContext.TargetStatusRuntime;
|
||||
IceTagState state = runtime.GetOrCreateState<IceTagState>(TagType);
|
||||
int absoluteZeroStack = TagEffectResolver.GetTagStack(attackPayload?.TagRuntimes, TagType.AbsoluteZero);
|
||||
float slowRatio = runtimeData.TotalStack * config.SlowRatioPerStack + GetAbsoluteZeroBonusSlowRatio(absoluteZeroStack);
|
||||
HitStatusModifierContext modifierContext = hitContext.StatusModifierContext ?? new HitStatusModifierContext();
|
||||
float slowRatio = runtimeData.TotalStack * config.SlowRatioPerStack + modifierContext.BonusSlowRatio;
|
||||
float slowMultiplier = Mathf.Max(config.MinMoveSpeedMultiplier, 1f - slowRatio);
|
||||
state.RemainingDuration = Mathf.Max(
|
||||
state.RemainingDuration,
|
||||
config.SlowDurationSeconds + GetAbsoluteZeroBonusDuration(absoluteZeroStack));
|
||||
config.SlowDurationSeconds + modifierContext.BonusSlowDurationSeconds);
|
||||
state.SlowMultiplier = Mathf.Min(state.SlowMultiplier, slowMultiplier);
|
||||
runtime.Activate(TagType);
|
||||
}
|
||||
|
|
@ -49,39 +50,5 @@ namespace GeometryTD.Definition
|
|||
IceTagState state = runtime.GetState<IceTagState>(TagType);
|
||||
return state == null ? 1f : state.SlowMultiplier;
|
||||
}
|
||||
|
||||
private static float GetAbsoluteZeroBonusDuration(int absoluteZeroStack)
|
||||
{
|
||||
if (absoluteZeroStack <= 0 ||
|
||||
!TagDefinitionRegistry.TryGetDefinition(TagType.AbsoluteZero, out TagDefinition definition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
AbsoluteZeroTagConfig absoluteZeroConfig = definition.Config as AbsoluteZeroTagConfig;
|
||||
if (absoluteZeroConfig == null || !absoluteZeroConfig.IsImplemented)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return absoluteZeroStack * absoluteZeroConfig.BonusSlowDurationSeconds;
|
||||
}
|
||||
|
||||
private static float GetAbsoluteZeroBonusSlowRatio(int absoluteZeroStack)
|
||||
{
|
||||
if (absoluteZeroStack <= 0 ||
|
||||
!TagDefinitionRegistry.TryGetDefinition(TagType.AbsoluteZero, out TagDefinition definition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
AbsoluteZeroTagConfig absoluteZeroConfig = definition.Config as AbsoluteZeroTagConfig;
|
||||
if (absoluteZeroConfig == null || !absoluteZeroConfig.IsImplemented)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return absoluteZeroStack * absoluteZeroConfig.BonusSlowRatioPerStack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,37 +5,58 @@ namespace GeometryTD.Definition
|
|||
{
|
||||
public static class TagEffectResolver
|
||||
{
|
||||
public static HitContext ResolveBeforeHit(
|
||||
AttackPayload attackPayload,
|
||||
int targetCurrentHealth,
|
||||
int targetMaxHealth,
|
||||
bool targetHasSlowStatus,
|
||||
float? critRoll = null)
|
||||
private static readonly HitStatusModifierContext EmptyStatusModifierContext = new HitStatusModifierContext();
|
||||
|
||||
public static HitContext ResolveBeforeHit(HitContext hitContext)
|
||||
{
|
||||
_ = targetHasSlowStatus;
|
||||
AttackPayload resolvedPayload = attackPayload?.Clone() ?? new AttackPayload();
|
||||
HitContext hitContext = new HitContext
|
||||
if (hitContext == null)
|
||||
{
|
||||
return new HitContext
|
||||
{
|
||||
AttackPayload = new AttackPayload()
|
||||
};
|
||||
}
|
||||
|
||||
AttackPayload resolvedPayload = hitContext.AttackPayload?.Clone() ?? new AttackPayload();
|
||||
HitContext resolvedContext = new HitContext
|
||||
{
|
||||
AttackPayload = resolvedPayload,
|
||||
FinalDamage = Mathf.Max(0, resolvedPayload.BaseDamage),
|
||||
IsCriticalHit = false,
|
||||
IsKilled = targetCurrentHealth > 0 && resolvedPayload.BaseDamage >= targetCurrentHealth
|
||||
IsKilled = hitContext.TargetCurrentHealthBeforeHit > 0 &&
|
||||
resolvedPayload.BaseDamage >= hitContext.TargetCurrentHealthBeforeHit,
|
||||
TargetEntityId = hitContext.TargetEntityId,
|
||||
TargetPosition = hitContext.TargetPosition,
|
||||
TargetCurrentHealthBeforeHit = hitContext.TargetCurrentHealthBeforeHit,
|
||||
TargetCurrentHealthAfterHit = hitContext.TargetCurrentHealthAfterHit,
|
||||
TargetMaxHealth = hitContext.TargetMaxHealth,
|
||||
TargetMoveSpeedMultiplierBeforeHit = hitContext.TargetMoveSpeedMultiplierBeforeHit,
|
||||
TargetStatusTagsBeforeHit = hitContext.TargetStatusTagsBeforeHit ?? System.Array.Empty<TagType>(),
|
||||
TargetStatusRuntime = hitContext.TargetStatusRuntime,
|
||||
CritRoll = hitContext.CritRoll,
|
||||
StatusModifierContext = new HitStatusModifierContext()
|
||||
};
|
||||
|
||||
NumericTagEffectHandler.ApplyBeforeHit(
|
||||
hitContext,
|
||||
targetCurrentHealth,
|
||||
targetMaxHealth,
|
||||
targetHasSlowStatus,
|
||||
critRoll);
|
||||
return hitContext;
|
||||
NumericTagEffectHandler.ApplyBeforeHit(resolvedContext);
|
||||
return resolvedContext;
|
||||
}
|
||||
|
||||
public static void ApplyAfterHit(AttackPayload attackPayload, EnemyTagStatusRuntime targetStatusRuntime)
|
||||
public static void ApplyAfterHit(HitContext hitContext)
|
||||
{
|
||||
if (attackPayload != null && targetStatusRuntime != null)
|
||||
if (hitContext?.StatusModifierContext == null)
|
||||
{
|
||||
TagRuntimeData[] tagRuntimes = attackPayload.TagRuntimes;
|
||||
if (hitContext != null)
|
||||
{
|
||||
hitContext.StatusModifierContext = new HitStatusModifierContext();
|
||||
}
|
||||
}
|
||||
|
||||
hitContext?.StatusModifierContext?.Reset();
|
||||
ApplyStatusModifiers(hitContext);
|
||||
|
||||
if (hitContext?.AttackPayload != null && hitContext.TargetStatusRuntime != null)
|
||||
{
|
||||
TagRuntimeData[] tagRuntimes = hitContext.AttackPayload.TagRuntimes;
|
||||
if (tagRuntimes != null && tagRuntimes.Length > 0)
|
||||
{
|
||||
foreach (var tag in tagRuntimes)
|
||||
|
|
@ -55,15 +76,12 @@ namespace GeometryTD.Definition
|
|||
continue;
|
||||
}
|
||||
|
||||
effect.Apply(attackPayload, targetStatusRuntime, tag);
|
||||
effect.Apply(hitContext, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AttackShapeTagEffectHandler.Apply(new HitContext
|
||||
{
|
||||
AttackPayload = attackPayload
|
||||
}, TagTriggerPhase.OnAfterHit);
|
||||
AttackShapeTagEffectHandler.Apply(hitContext, TagTriggerPhase.OnAfterHit);
|
||||
}
|
||||
|
||||
public static void ApplyOnHit(HitContext hitContext)
|
||||
|
|
@ -95,5 +113,65 @@ namespace GeometryTD.Definition
|
|||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void ApplyStatusModifiers(HitContext hitContext)
|
||||
{
|
||||
if (hitContext?.AttackPayload?.TagRuntimes == null || hitContext.AttackPayload.TagRuntimes.Length <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HitStatusModifierContext modifierContext = hitContext.StatusModifierContext ?? EmptyStatusModifierContext;
|
||||
for (int i = 0; i < hitContext.AttackPayload.TagRuntimes.Length; i++)
|
||||
{
|
||||
TagRuntimeData runtime = hitContext.AttackPayload.TagRuntimes[i];
|
||||
if (runtime == null || runtime.TotalStack <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TagDefinitionRegistry.TryGetDefinition(runtime.TagType, out TagDefinition definition) ||
|
||||
definition == null ||
|
||||
definition.Category != TagCategory.StatusModifier ||
|
||||
definition.TriggerPhase != TagTriggerPhase.OnAfterHit ||
|
||||
definition.Config == null ||
|
||||
!definition.Config.IsImplemented)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (runtime.TagType)
|
||||
{
|
||||
case TagType.Inferno:
|
||||
ApplyInfernoModifier(runtime.TotalStack, definition.Config as InfernoTagConfig, modifierContext);
|
||||
break;
|
||||
case TagType.AbsoluteZero:
|
||||
ApplyAbsoluteZeroModifier(runtime.TotalStack, definition.Config as AbsoluteZeroTagConfig, modifierContext);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyInfernoModifier(int stack, InfernoTagConfig config, HitStatusModifierContext modifierContext)
|
||||
{
|
||||
if (config == null || stack <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
modifierContext.BonusBurnDurationSeconds += stack * config.BonusBurnDurationSeconds;
|
||||
modifierContext.BonusBurnDamagePerSecond += stack * config.BonusBurnDamagePerSecondPerStack;
|
||||
}
|
||||
|
||||
private static void ApplyAbsoluteZeroModifier(int stack, AbsoluteZeroTagConfig config, HitStatusModifierContext modifierContext)
|
||||
{
|
||||
if (config == null || stack <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
modifierContext.BonusSlowDurationSeconds += stack * config.BonusSlowDurationSeconds;
|
||||
modifierContext.BonusSlowRatio += stack * config.BonusSlowRatioPerStack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,5 @@ namespace GeometryTD.Definition
|
|||
public BurnSpreadTagConfig(bool isImplemented) : base(isImplemented)
|
||||
{
|
||||
}
|
||||
|
||||
public float SpreadRadius { get; set; } = 0f;
|
||||
public float SpreadDamageRate { get; set; } = 0f;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,5 @@ namespace GeometryTD.Definition
|
|||
public FreezeMaskTagConfig(bool isImplemented) : base(isImplemented)
|
||||
{
|
||||
}
|
||||
|
||||
public float FreezeBuildUpPerStack { get; set; } = 0f;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,5 @@ namespace GeometryTD.Definition
|
|||
public IgniteBurstTagConfig(bool isImplemented) : base(isImplemented)
|
||||
{
|
||||
}
|
||||
|
||||
public float BurstRadius { get; set; } = 0f;
|
||||
public float BurstDamageRate { get; set; } = 0f;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,5 @@ namespace GeometryTD.Definition
|
|||
public OverpenetrateTagConfig(bool isImplemented) : base(isImplemented)
|
||||
{
|
||||
}
|
||||
|
||||
public int ExtraPenetrationCount { get; set; } = 0;
|
||||
public float RemainingDamageRate { get; set; } = 0f;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,5 @@ namespace GeometryTD.Definition
|
|||
public PierceTagConfig(bool isImplemented) : base(isImplemented)
|
||||
{
|
||||
}
|
||||
|
||||
public int ExtraPierceCount { get; set; } = 2;
|
||||
}
|
||||
}
|
||||
|
|
@ -69,16 +69,16 @@ namespace GeometryTD.Definition
|
|||
return new Dictionary<TagType, TagDefinition>
|
||||
{
|
||||
[TagType.Fire] = CreateDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit, new FireTagConfig(true)),
|
||||
[TagType.BurnSpread] = CreateDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.OnKill, new BurnSpreadTagConfig(false)),
|
||||
[TagType.IgniteBurst] = CreateDefinition(TagType.IgniteBurst, TagCategory.AttackShape, TagTriggerPhase.OnKill, new IgniteBurstTagConfig(false)),
|
||||
[TagType.BurnSpread] = CreateDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.None, new BurnSpreadTagConfig(false)),
|
||||
[TagType.IgniteBurst] = CreateDefinition(TagType.IgniteBurst, TagCategory.AttackShape, TagTriggerPhase.None, new IgniteBurstTagConfig(false)),
|
||||
[TagType.Inferno] = CreateDefinition(TagType.Inferno, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit, new InfernoTagConfig(true)),
|
||||
[TagType.Ice] = CreateDefinition(TagType.Ice, TagCategory.Status, TagTriggerPhase.OnAfterHit, new IceTagConfig(true)),
|
||||
[TagType.FreezeMask] = CreateDefinition(TagType.FreezeMask, TagCategory.AttackShape, TagTriggerPhase.OnAfterHit, new FreezeMaskTagConfig(false)),
|
||||
[TagType.FreezeMask] = CreateDefinition(TagType.FreezeMask, TagCategory.AttackShape, TagTriggerPhase.None, new FreezeMaskTagConfig(false)),
|
||||
[TagType.Shatter] = CreateDefinition(TagType.Shatter, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ShatterTagConfig(true)),
|
||||
[TagType.AbsoluteZero] = CreateDefinition(TagType.AbsoluteZero, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit, new AbsoluteZeroTagConfig(true)),
|
||||
[TagType.Pierce] = CreateDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.OnHit, new PierceTagConfig(false)),
|
||||
[TagType.Pierce] = CreateDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.None, new PierceTagConfig(false)),
|
||||
[TagType.Crit] = CreateDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new CritTagConfig(true)),
|
||||
[TagType.Overpenetrate] = CreateDefinition(TagType.Overpenetrate, TagCategory.AttackShape, TagTriggerPhase.OnHit, new OverpenetrateTagConfig(false)),
|
||||
[TagType.Overpenetrate] = CreateDefinition(TagType.Overpenetrate, TagCategory.AttackShape, TagTriggerPhase.None, new OverpenetrateTagConfig(false)),
|
||||
[TagType.Execution] = CreateDefinition(TagType.Execution, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ExecutionTagConfig(true))
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,14 +115,23 @@ namespace GeometryTD.Entity
|
|||
return;
|
||||
}
|
||||
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(
|
||||
attackPayload,
|
||||
_currentHealth,
|
||||
_maxHealth,
|
||||
HasSlowStatus);
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(new HitContext
|
||||
{
|
||||
AttackPayload = attackPayload,
|
||||
TargetEntityId = Id,
|
||||
TargetPosition = CachedTransform.position,
|
||||
TargetCurrentHealthBeforeHit = _currentHealth,
|
||||
TargetCurrentHealthAfterHit = _currentHealth,
|
||||
TargetMaxHealth = _maxHealth,
|
||||
TargetMoveSpeedMultiplierBeforeHit = _tagStatusRuntime.GetMoveSpeedMultiplier(),
|
||||
TargetStatusTagsBeforeHit = _tagStatusRuntime.GetActiveTagSnapshot(),
|
||||
TargetStatusRuntime = _tagStatusRuntime
|
||||
});
|
||||
ApplyDirectDamage(hitContext.FinalDamage);
|
||||
hitContext.TargetCurrentHealthAfterHit = _currentHealth;
|
||||
hitContext.IsKilled = _currentHealth <= 0;
|
||||
TagEffectResolver.ApplyOnHit(hitContext);
|
||||
TagEffectResolver.ApplyAfterHit(hitContext.AttackPayload, _tagStatusRuntime);
|
||||
TagEffectResolver.ApplyAfterHit(hitContext);
|
||||
if (hitContext.IsKilled)
|
||||
{
|
||||
TagEffectResolver.ApplyOnKill(hitContext);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ namespace GeometryTD.Entity
|
|||
return _statesByTag.ContainsKey(tagType);
|
||||
}
|
||||
|
||||
public TagType[] GetActiveTagSnapshot()
|
||||
{
|
||||
if (_activeTags.Count <= 0)
|
||||
{
|
||||
return Array.Empty<TagType>();
|
||||
}
|
||||
|
||||
return _activeTags.ToArray();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_statesByTag.Clear();
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, false, 0.05f);
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100, 1f, null, 0.05f));
|
||||
|
||||
Assert.That(hitContext.IsCriticalHit, Is.True);
|
||||
Assert.That(hitContext.FinalDamage, Is.EqualTo(150));
|
||||
|
|
@ -43,8 +43,8 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
HitContext lowHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 30, 100, false);
|
||||
HitContext highHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 31, 100, false);
|
||||
HitContext lowHealthHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 30, 100));
|
||||
HitContext highHealthHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 31, 100));
|
||||
|
||||
Assert.That(lowHealthHit.FinalDamage, Is.EqualTo(150));
|
||||
Assert.That(highHealthHit.FinalDamage, Is.EqualTo(100));
|
||||
|
|
@ -63,8 +63,8 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
HitContext slowedHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, true);
|
||||
HitContext normalHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, false);
|
||||
HitContext slowedHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100, 0.6f, new[] { TagType.Ice }));
|
||||
HitContext normalHit = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100));
|
||||
|
||||
Assert.That(slowedHit.FinalDamage, Is.EqualTo(150));
|
||||
Assert.That(normalHit.FinalDamage, Is.EqualTo(100));
|
||||
|
|
@ -85,7 +85,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(attackPayload, 30, 100, true, 0.05f);
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 30, 100, 0.6f, new[] { TagType.Ice }, 0.05f));
|
||||
|
||||
Assert.That(hitContext.IsCriticalHit, Is.True);
|
||||
Assert.That(hitContext.FinalDamage, Is.EqualTo(281));
|
||||
|
|
@ -125,6 +125,63 @@ namespace GeometryTD.Tests.EditMode
|
|||
|
||||
Assert.That(stack, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolveBeforeHit_Uses_Context_TargetHealth_And_CritRoll()
|
||||
{
|
||||
AttackPayload attackPayload = new AttackPayload
|
||||
{
|
||||
BaseDamage = 100,
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 },
|
||||
new TagRuntimeData { TagType = TagType.Execution, TotalStack = 1 }
|
||||
}
|
||||
};
|
||||
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 30, 100, 1f, null, 0.05f));
|
||||
|
||||
Assert.That(hitContext.IsCriticalHit, Is.True);
|
||||
Assert.That(hitContext.FinalDamage, Is.EqualTo(225));
|
||||
Assert.That(hitContext.IsKilled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolveBeforeHit_Shatter_Uses_Context_SlowSnapshot()
|
||||
{
|
||||
AttackPayload attackPayload = new AttackPayload
|
||||
{
|
||||
BaseDamage = 100,
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Shatter, TotalStack = 1 }
|
||||
}
|
||||
};
|
||||
|
||||
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(CreateHitContext(attackPayload, 100, 100, 0.8f, new[] { TagType.Ice }));
|
||||
|
||||
Assert.That(hitContext.FinalDamage, Is.EqualTo(125));
|
||||
}
|
||||
|
||||
private static HitContext CreateHitContext(
|
||||
AttackPayload attackPayload,
|
||||
int targetCurrentHealthBeforeHit,
|
||||
int targetMaxHealth,
|
||||
float targetMoveSpeedMultiplierBeforeHit = 1f,
|
||||
TagType[] targetStatusTagsBeforeHit = null,
|
||||
float? critRoll = null)
|
||||
{
|
||||
return new HitContext
|
||||
{
|
||||
AttackPayload = attackPayload,
|
||||
TargetCurrentHealthBeforeHit = targetCurrentHealthBeforeHit,
|
||||
TargetCurrentHealthAfterHit = targetCurrentHealthBeforeHit,
|
||||
TargetMaxHealth = targetMaxHealth,
|
||||
TargetMoveSpeedMultiplierBeforeHit = targetMoveSpeedMultiplierBeforeHit,
|
||||
TargetStatusTagsBeforeHit = targetStatusTagsBeforeHit ?? System.Array.Empty<TagType>(),
|
||||
CritRoll = critRoll
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnemyTagStatusRuntimeTests
|
||||
|
|
@ -143,7 +200,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
};
|
||||
int totalDamage = 0;
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
Assert.That(runtime.HasStatus(TagType.Fire), Is.True);
|
||||
|
||||
runtime.Tick(1f, damage => totalDamage += damage);
|
||||
|
|
@ -154,6 +211,32 @@ namespace GeometryTD.Tests.EditMode
|
|||
Assert.That(totalDamage, Is.EqualTo(120));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyAfterHit_Fire_MaxEffectiveStack_Clamps_BaseFireStack_Only()
|
||||
{
|
||||
DRTagConfig fireRow = new DRTagConfig();
|
||||
Assert.That(fireRow.ParseDataRow("\t1\t元素\tFire\tOnAfterHit\t火焰测试描述\t{\"BurnDurationSeconds\":3,\"BurnDamagePerSecondPerStack\":20,\"MaxEffectiveStack\":2}", null), Is.True);
|
||||
TagDefinitionRegistry.LoadFromRows(new[] { fireRow });
|
||||
|
||||
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
|
||||
AttackPayload attackPayload = new AttackPayload
|
||||
{
|
||||
BaseDamage = 100,
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 4 }
|
||||
}
|
||||
};
|
||||
int totalDamage = 0;
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
runtime.Tick(3f, damage => totalDamage += damage);
|
||||
|
||||
Assert.That(totalDamage, Is.EqualTo(120));
|
||||
|
||||
TagDefinitionRegistry.ResetToDefaults();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyAfterHit_Fire_UsesElapsedDeltaTimeInsteadOfIndependentInterval()
|
||||
{
|
||||
|
|
@ -168,7 +251,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
};
|
||||
int totalDamage = 0;
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
|
||||
runtime.Tick(0.5f, damage => totalDamage += damage);
|
||||
runtime.Tick(0.5f, damage => totalDamage += damage);
|
||||
|
|
@ -191,7 +274,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
|
||||
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
|
||||
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(0.6f).Within(0.001f));
|
||||
|
|
@ -217,7 +300,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
};
|
||||
int totalDamage = 0;
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
Assert.That(runtime.HasStatus(TagType.Fire), Is.True);
|
||||
Assert.That(runtime.HasStatus(TagType.Inferno), Is.False);
|
||||
|
||||
|
|
@ -231,6 +314,35 @@ namespace GeometryTD.Tests.EditMode
|
|||
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyAfterHit_FireWithInferno_StillAppliesInfernoBonusAfterFireCap()
|
||||
{
|
||||
DRTagConfig fireRow = new DRTagConfig();
|
||||
DRTagConfig infernoRow = new DRTagConfig();
|
||||
Assert.That(fireRow.ParseDataRow("\t1\t元素\tFire\tOnAfterHit\t火焰测试描述\t{\"BurnDurationSeconds\":3,\"BurnDamagePerSecondPerStack\":20,\"MaxEffectiveStack\":2}", null), Is.True);
|
||||
Assert.That(infernoRow.ParseDataRow("\t4\t元素\tInferno\tOnAfterHit\t强化燃烧伤害或持续时间\t{\"BonusBurnDurationSeconds\":0.5,\"BonusBurnDamagePerSecondPerStack\":10}", null), Is.True);
|
||||
TagDefinitionRegistry.LoadFromRows(new[] { fireRow, infernoRow });
|
||||
|
||||
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
|
||||
AttackPayload attackPayload = new AttackPayload
|
||||
{
|
||||
BaseDamage = 100,
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 4 },
|
||||
new TagRuntimeData { TagType = TagType.Inferno, TotalStack = 1 }
|
||||
}
|
||||
};
|
||||
int totalDamage = 0;
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
runtime.Tick(3.5f, damage => totalDamage += damage);
|
||||
|
||||
Assert.That(totalDamage, Is.EqualTo(175));
|
||||
|
||||
TagDefinitionRegistry.ResetToDefaults();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyAfterHit_InfernoWithoutFire_DoesNotCreateStatus()
|
||||
{
|
||||
|
|
@ -244,12 +356,41 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
|
||||
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
|
||||
Assert.That(runtime.HasStatus(TagType.Inferno), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyAfterHit_StatusModifier_Uses_Independent_OnAfterHit_Route()
|
||||
{
|
||||
DRTagConfig infernoRow = new DRTagConfig();
|
||||
Assert.That(infernoRow.ParseDataRow("\t4\t元素\tInferno\tOnHit\t强化燃烧伤害或持续时间\t{\"BonusBurnDurationSeconds\":1,\"BonusBurnDamagePerSecondPerStack\":20}", null), Is.True);
|
||||
TagDefinitionRegistry.LoadFromRows(new[] { infernoRow });
|
||||
|
||||
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
|
||||
HitContext hitContext = CreateAfterHitContext(new AttackPayload
|
||||
{
|
||||
BaseDamage = 100,
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 },
|
||||
new TagRuntimeData { TagType = TagType.Inferno, TotalStack = 1 }
|
||||
}
|
||||
}, runtime);
|
||||
int totalDamage = 0;
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(hitContext);
|
||||
runtime.Tick(3f, damage => totalDamage += damage);
|
||||
|
||||
Assert.That(totalDamage, Is.EqualTo(60));
|
||||
Assert.That(hitContext.StatusModifierContext.BonusBurnDamagePerSecond, Is.EqualTo(0f).Within(0.001f));
|
||||
Assert.That(hitContext.StatusModifierContext.BonusBurnDurationSeconds, Is.EqualTo(0f).Within(0.001f));
|
||||
|
||||
TagDefinitionRegistry.ResetToDefaults();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyAfterHit_IceWithAbsoluteZero_IncreasesSlowStrengthAndDuration()
|
||||
{
|
||||
|
|
@ -264,7 +405,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
|
||||
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
|
||||
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False);
|
||||
|
|
@ -291,7 +432,7 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
};
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(attackPayload, runtime));
|
||||
|
||||
Assert.That(runtime.HasStatus(TagType.Ice), Is.False);
|
||||
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False);
|
||||
|
|
@ -323,12 +464,12 @@ namespace GeometryTD.Tests.EditMode
|
|||
|
||||
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
|
||||
int totalDamage = 0;
|
||||
TagEffectResolver.ApplyAfterHit(new AttackPayload
|
||||
TagEffectResolver.ApplyAfterHit(CreateAfterHitContext(new AttackPayload
|
||||
{
|
||||
BaseDamage = tower.Stats.AttackDamage[0],
|
||||
AttackPropertyType = tower.Stats.AttackPropertyType,
|
||||
TagRuntimes = tower.Stats.TagRuntimes
|
||||
}, runtime);
|
||||
}, runtime));
|
||||
|
||||
runtime.Tick(1f, damage => totalDamage += damage);
|
||||
runtime.Tick(1f, damage => totalDamage += damage);
|
||||
|
|
@ -337,6 +478,35 @@ namespace GeometryTD.Tests.EditMode
|
|||
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
|
||||
Assert.That(totalDamage, Is.EqualTo(180));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyAfterHit_Uses_TargetStatusRuntime_From_HitContext()
|
||||
{
|
||||
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
|
||||
HitContext hitContext = CreateAfterHitContext(new AttackPayload
|
||||
{
|
||||
BaseDamage = 100,
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Ice, TotalStack = 1 }
|
||||
}
|
||||
}, runtime);
|
||||
|
||||
TagEffectResolver.ApplyAfterHit(hitContext);
|
||||
|
||||
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
|
||||
Assert.That(hitContext.TargetStatusRuntime, Is.SameAs(runtime));
|
||||
}
|
||||
|
||||
private static HitContext CreateAfterHitContext(AttackPayload attackPayload, EnemyTagStatusRuntime runtime)
|
||||
{
|
||||
return new HitContext
|
||||
{
|
||||
AttackPayload = attackPayload,
|
||||
TargetStatusRuntime = runtime,
|
||||
TargetStatusTagsBeforeHit = System.Array.Empty<TagType>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnemyStatusTagRegistryTests
|
||||
|
|
@ -372,8 +542,8 @@ namespace GeometryTD.Tests.EditMode
|
|||
AssertDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit);
|
||||
AssertDefinition(TagType.Inferno, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit);
|
||||
AssertDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit);
|
||||
AssertDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.OnHit);
|
||||
AssertDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.OnKill);
|
||||
AssertDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.None);
|
||||
AssertDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.None);
|
||||
AssertDefinition(TagType.AbsoluteZero, TagCategory.StatusModifier, TagTriggerPhase.OnAfterHit);
|
||||
}
|
||||
|
||||
|
|
@ -519,10 +689,21 @@ namespace GeometryTD.Tests.EditMode
|
|||
};
|
||||
|
||||
Assert.DoesNotThrow(() => TagEffectResolver.ApplyOnHit(hitContext));
|
||||
Assert.DoesNotThrow(() => TagEffectResolver.ApplyAfterHit(hitContext.AttackPayload, new EnemyTagStatusRuntime()));
|
||||
hitContext.TargetStatusRuntime = new EnemyTagStatusRuntime();
|
||||
Assert.DoesNotThrow(() => TagEffectResolver.ApplyAfterHit(hitContext));
|
||||
Assert.DoesNotThrow(() => TagEffectResolver.ApplyOnKill(hitContext));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placeholder_AttackShape_Configs_Do_Not_Expose_Formal_Runtime_Params()
|
||||
{
|
||||
Assert.That(typeof(BurnSpreadTagConfig).GetProperty("SpreadRadius"), Is.Null);
|
||||
Assert.That(typeof(IgniteBurstTagConfig).GetProperty("BurstRadius"), Is.Null);
|
||||
Assert.That(typeof(FreezeMaskTagConfig).GetProperty("FreezeBuildUpPerStack"), Is.Null);
|
||||
Assert.That(typeof(PierceTagConfig).GetProperty("ExtraPierceCount"), Is.Null);
|
||||
Assert.That(typeof(OverpenetrateTagConfig).GetProperty("RemainingDamageRate"), Is.Null);
|
||||
}
|
||||
|
||||
private static void AssertDefinition(TagType tagType, TagCategory expectedCategory, TagTriggerPhase expectedPhase)
|
||||
{
|
||||
Assert.That(TagDefinitionRegistry.TryGetDefinition(tagType, out TagDefinition definition), Is.True);
|
||||
|
|
@ -577,6 +758,9 @@ namespace GeometryTD.Tests.EditMode
|
|||
{
|
||||
BaseDamage = 80,
|
||||
AttackPropertyType = AttackPropertyType.Fire,
|
||||
SourceEntityId = 101,
|
||||
ProjectileEntityId = 202,
|
||||
OriginPosition = new Vector3(1f, 2f, 0f),
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 }
|
||||
|
|
@ -590,6 +774,9 @@ namespace GeometryTD.Tests.EditMode
|
|||
Assert.That(receiver.LastPayload, Is.Not.Null);
|
||||
Assert.That(receiver.LastPayload.BaseDamage, Is.EqualTo(80));
|
||||
Assert.That(receiver.LastPayload.AttackPropertyType, Is.EqualTo(AttackPropertyType.Fire));
|
||||
Assert.That(receiver.LastPayload.SourceEntityId, Is.EqualTo(101));
|
||||
Assert.That(receiver.LastPayload.ProjectileEntityId, Is.EqualTo(202));
|
||||
Assert.That(receiver.LastPayload.OriginPosition, Is.EqualTo(new Vector3(1f, 2f, 0f)));
|
||||
Assert.That(receiver.LastPayload.TagRuntimes, Has.Length.EqualTo(1));
|
||||
Assert.That(receiver.LastPayload.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire));
|
||||
}
|
||||
|
|
@ -600,6 +787,30 @@ namespace GeometryTD.Tests.EditMode
|
|||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttackPayload_Clone_Preserves_Source_Metadata()
|
||||
{
|
||||
AttackPayload payload = new AttackPayload
|
||||
{
|
||||
BaseDamage = 50,
|
||||
AttackPropertyType = AttackPropertyType.Physics,
|
||||
SourceEntityId = 11,
|
||||
ProjectileEntityId = 22,
|
||||
OriginPosition = new Vector3(3f, 4f, 0f),
|
||||
TagRuntimes = new[]
|
||||
{
|
||||
new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 }
|
||||
}
|
||||
};
|
||||
|
||||
AttackPayload cloned = payload.Clone();
|
||||
|
||||
Assert.That(cloned.SourceEntityId, Is.EqualTo(11));
|
||||
Assert.That(cloned.ProjectileEntityId, Is.EqualTo(22));
|
||||
Assert.That(cloned.OriginPosition, Is.EqualTo(new Vector3(3f, 4f, 0f)));
|
||||
Assert.That(cloned.TagRuntimes, Is.Not.SameAs(payload.TagRuntimes));
|
||||
}
|
||||
|
||||
private sealed class RecordingDamageReceiver : MonoBehaviour, IDamageReceiver
|
||||
{
|
||||
public AttackPayload LastPayload { get; private set; }
|
||||
|
|
|
|||
|
|
@ -286,11 +286,11 @@
|
|||
|
||||
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|
||||
|-----|-------|------------------------------|---------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------|
|
||||
| [ ] | 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 可区分,且掉落 / 商店 / 种子共用同一随机合同 |
|
||||
| [ ] | 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/` | 保留字段要么真实生效,要么明确标注为占位且不会误导配置方 |
|
||||
| [ ] | S7-05 | 清理 Tag 设计文档中的过期草稿 | `docs/TagSystemDesign.md`<br>`docs/CodeX-TODO.md` | 正式口径、当前实现、后续预案三者分层清晰 |
|
||||
| [x] | S7-01 | 收口 Tag 配置真相源与加载顺序 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/Definition/Tag/`<br>`Assets/Tests/EditMode/` | `IsImplemented`、默认值与运行时定义不再受 DataTable 加载顺序影响 |
|
||||
| [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 可区分,且掉落 / 商店 / 种子共用同一随机合同 |
|
||||
| [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 结算统一只读上下文对象,不再继续靠散装参数扩展 |
|
||||
| [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/` | 保留字段要么真实生效,要么明确标注为占位且不会误导配置方 |
|
||||
| [x] | S7-05 | 清理 Tag 设计文档中的过期草稿 | `docs/TagSystemDesign.md`<br>`docs/CodeX-TODO.md`<br>`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` 继续保持“占位配置 + 占位路由,无真实战斗效果”的当前边界。
|
||||
|
||||
## 备注
|
||||
|
||||
|
|
|
|||
|
|
@ -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 联动与流派激活矩阵
|
||||
|
|
|
|||
|
|
@ -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` 主干
|
||||
Loading…
Reference in New Issue