Start Tag System

This commit is contained in:
2026-03-10 13:11:30 +08:00
parent 34ef001ef3
commit 52f9e212b9
107 changed files with 1850 additions and 50 deletions
@@ -0,0 +1,277 @@
using Components;
using GeometryTD.Definition;
using GeometryTD.Entity;
using GeometryTD.Entity.EntityData;
using NUnit.Framework;
using UnityEngine;
namespace GeometryTD.Tests.EditMode
{
public sealed class TagEffectResolverTests
{
[Test]
public void ResolveBeforeHit_Crit_CanIncreaseDamage()
{
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
AttackPropertyType = AttackPropertyType.Physics,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 }
}
};
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, false, 0.05f);
Assert.That(hitContext.IsCriticalHit, Is.True);
Assert.That(hitContext.FinalDamage, Is.EqualTo(150));
}
[Test]
public void ResolveBeforeHit_Execution_OnlyTriggersOnLowHealthTarget()
{
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
AttackPropertyType = AttackPropertyType.Physics,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Execution, TotalStack = 1 }
}
};
HitContext lowHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 30, 100, false);
HitContext highHealthHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 31, 100, false);
Assert.That(lowHealthHit.FinalDamage, Is.EqualTo(150));
Assert.That(highHealthHit.FinalDamage, Is.EqualTo(100));
}
}
public sealed class EnemyTagStatusRuntimeTests
{
[Test]
public void ApplyAfterHit_Fire_AddsBurnDamageOverTime()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 2 }
}
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
Assert.That(runtime.HasStatus(TagType.Fire), Is.True);
runtime.Tick(1f, damage => totalDamage += damage);
runtime.Tick(1f, damage => totalDamage += damage);
runtime.Tick(1f, damage => totalDamage += damage);
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
Assert.That(totalDamage, Is.EqualTo(120));
}
[Test]
public void ApplyAfterHit_Fire_UsesElapsedDeltaTimeInsteadOfIndependentInterval()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 }
}
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
runtime.Tick(0.5f, damage => totalDamage += damage);
runtime.Tick(0.5f, damage => totalDamage += damage);
runtime.Tick(2f, damage => totalDamage += damage);
Assert.That(totalDamage, Is.EqualTo(60));
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
}
[Test]
public void ApplyAfterHit_Ice_AddsSlowAndRecoversAfterDuration()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Ice, TotalStack = 2 }
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(0.6f).Within(0.001f));
runtime.Tick(2f, null);
Assert.That(runtime.HasStatus(TagType.Ice), Is.False);
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f));
}
[Test]
public void Tick_OnlyUpdatesActivatedStatusTags()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
int totalDamage = 0;
runtime.Tick(5f, damage => totalDamage += damage);
Assert.That(totalDamage, Is.EqualTo(0));
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f));
}
}
public sealed class EnemyStatusTagRegistryTests
{
[Test]
public void Registry_Registers_All_Status_Tags()
{
Assert.That(EnemyStatusTagRegistry.TryGetEffect(TagType.Fire, out IEnemyStatusTagEffect fireEffect), Is.True);
Assert.That(fireEffect, Is.TypeOf<FireTagEffect>());
Assert.That(EnemyStatusTagRegistry.TryGetEffect(TagType.Ice, out IEnemyStatusTagEffect iceEffect), Is.True);
Assert.That(iceEffect, Is.TypeOf<IceTagEffect>());
Assert.That(EnemyStatusTagRegistry.TryGetEffect(TagType.Inferno, out IEnemyStatusTagEffect infernoEffect), Is.True);
Assert.That(infernoEffect, Is.TypeOf<InfernoTagEffect>());
Assert.That(EnemyStatusTagRegistry.TryGetEffect(TagType.AbsoluteZero, out IEnemyStatusTagEffect absoluteZeroEffect), Is.True);
Assert.That(absoluteZeroEffect, Is.TypeOf<AbsoluteZeroTagEffect>());
}
}
public sealed class TagConfigRegistryTests
{
[Test]
public void Definitions_Register_All_Twelve_Tags()
{
Assert.That(TagConfigRegistry.Definitions.Count, Is.EqualTo(12));
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Fire, out _), Is.True);
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Execution, out _), Is.True);
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Overpenetrate, out _), Is.True);
}
[Test]
public void Definitions_Assign_Documented_Categories_And_Phases()
{
AssertDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit);
AssertDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit);
AssertDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.OnHit);
AssertDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.OnKill);
}
[Test]
public void Definitions_Keep_Unimplemented_Tags_As_Explicit_NoOpPlaceholders()
{
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Shatter, out TagDefinitionData shatter), Is.True);
Assert.That(shatter.Config, Is.TypeOf<ShatterTagConfig>());
Assert.That(((ShatterTagConfig)shatter.Config).IsImplemented, Is.False);
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Inferno, out TagDefinitionData inferno), Is.True);
Assert.That(inferno.Config, Is.TypeOf<InfernoTagConfig>());
Assert.That(((InfernoTagConfig)inferno.Config).IsImplemented, Is.False);
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Pierce, out TagDefinitionData pierce), Is.True);
Assert.That(pierce.Config, Is.TypeOf<PierceTagConfig>());
Assert.That(((PierceTagConfig)pierce.Config).IsImplemented, Is.False);
}
[Test]
public void AttackShape_Placeholders_Are_Routable_Without_Runtime_Effect()
{
HitContext hitContext = new HitContext
{
AttackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Pierce, TotalStack = 1 },
new TagRuntimeData { TagType = TagType.BurnSpread, TotalStack = 1 },
new TagRuntimeData { TagType = TagType.FreezeMask, TotalStack = 1 }
}
},
FinalDamage = 100
};
Assert.DoesNotThrow(() => TagEffectResolver.ApplyOnHit(hitContext));
Assert.DoesNotThrow(() => TagEffectResolver.ApplyAfterHit(hitContext.AttackPayload, new EnemyTagStatusRuntime()));
Assert.DoesNotThrow(() => TagEffectResolver.ApplyOnKill(hitContext));
}
private static void AssertDefinition(TagType tagType, TagCategory expectedCategory, TagTriggerPhase expectedPhase)
{
Assert.That(TagConfigRegistry.TryGetDefinition(tagType, out TagDefinitionData definition), Is.True);
Assert.That(definition, Is.Not.Null);
Assert.That(definition.Category, Is.EqualTo(expectedCategory));
Assert.That(definition.TriggerPhase, Is.EqualTo(expectedPhase));
}
}
public sealed class AttackPayloadFlowTests
{
[Test]
public void ShooterBullet_PassesAttackPayloadToDamageReceiver()
{
GameObject bulletObject = new GameObject("Bullet");
GameObject targetObject = new GameObject("Target");
try
{
ShooterBullet shooterBullet = bulletObject.AddComponent<ShooterBullet>();
RecordingDamageReceiver receiver = targetObject.AddComponent<RecordingDamageReceiver>();
targetObject.transform.position = new Vector3(0.01f, 0f, 0f);
AttackPayload payload = new AttackPayload
{
BaseDamage = 80,
AttackPropertyType = AttackPropertyType.Fire,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 }
}
};
BulletData bulletData = new BulletData(1, 501, Vector3.zero, targetObject.transform, payload, 12f);
shooterBullet.OnShow(bulletData);
shooterBullet.Tick(0.02f);
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.TagRuntimes, Has.Length.EqualTo(1));
Assert.That(receiver.LastPayload.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire));
}
finally
{
Object.DestroyImmediate(bulletObject);
Object.DestroyImmediate(targetObject);
}
}
private sealed class RecordingDamageReceiver : MonoBehaviour, IDamageReceiver
{
public AttackPayload LastPayload { get; private set; }
public void TakeDamage(AttackPayload attackPayload)
{
LastPayload = attackPayload?.Clone();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 86fbd79741310d04bae44ad6ec103c84
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: