Files

2451 lines
107 KiB
C#

using System.Reflection;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Spawning;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class ActiveArtifactControllerTests
{
private GameObject owner;
private GameObject runManagerObject;
private RunManager previousRunManagerInstance;
private ActiveArtifactController controller;
[SetUp]
public void SetUp()
{
RunManager existingRunManager = RunManager.Instance;
previousRunManagerInstance = existingRunManager == null
? null
: existingRunManager;
RunManager.ForceProductionModeForTests = false;
owner = new GameObject("Active Artifact Tests");
owner.AddComponent<PlayerStats>();
owner.AddComponent<Rigidbody2D>();
controller = owner.AddComponent<ActiveArtifactController>();
typeof(ActiveArtifactController)
.GetField(
"maxOwnedArtifacts",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(controller, 6);
typeof(ActiveArtifactController)
.GetField(
"startingGauge",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(controller, 100f);
typeof(ActiveArtifactController)
.GetMethod(
"Awake",
BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(controller, null);
}
[TearDown]
public void TearDown()
{
RunManager.ForceProductionModeForTests = false;
Object.DestroyImmediate(owner);
if (runManagerObject != null)
{
Object.DestroyImmediate(runManagerObject);
runManagerObject = null;
}
RestoreRunManagerInstance(previousRunManagerInstance);
previousRunManagerInstance = null;
}
[Test]
public void DebugGrantCatalogArtifacts_TestHarnessAcceptsSixAndRestoresThreeSlotCap()
{
ActiveArtifactDefinition first = CreatePulse("first", 25f, 60f);
ActiveArtifactDefinition duplicate = CreatePulse("first", 10f, 20f);
ActiveArtifactDefinition second = CreatePulse(
"second",
25f,
60f,
ActiveArtifactEffect.Dash);
ActiveArtifactDefinition third = CreatePulse(
"third",
25f,
60f,
ActiveArtifactEffect.ThunderCrash);
ActiveArtifactDefinition fourth = CreatePulse(
"fourth",
25f,
60f,
ActiveArtifactEffect.Cyclone);
ActiveArtifactDefinition fifth = CreatePulse(
"fifth",
25f,
60f,
ActiveArtifactEffect.Phoenix);
ActiveArtifactDefinition sixth = CreatePulse(
"sixth",
25f,
60f,
ActiveArtifactEffect.ChainLightning);
controller.Configure(
new[] { first, second, third, fourth, fifth, sixth });
controller.DebugGrantCatalogArtifacts();
Assert.That(controller.OwnedArtifactCount, Is.EqualTo(6));
Assert.That(controller.MaxOwnedArtifacts, Is.EqualTo(3));
Assert.That(controller.MaxArtifactsPerColor, Is.EqualTo(1));
Assert.That(controller.CurrentGauge, Is.EqualTo(controller.MaxGauge));
Assert.That(controller.TryAddArtifact(fourth), Is.False);
Assert.That(controller.TryAddArtifact(duplicate), Is.False);
DestroyDefinitions(
first,
duplicate,
second,
third,
fourth,
fifth,
sixth);
}
[Test]
public void EditorDebugBuild_EnablesArtifactStartupSelection()
{
GameObject runManagerObject = new("Debug Run Manager");
try
{
RunManager runManager = runManagerObject.AddComponent<RunManager>();
typeof(RunManager)
.GetField(
"useDebugEventTimes",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(runManager, false);
typeof(RunManager)
.GetMethod(
"Awake",
BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(runManager, null);
Assert.That(RunManager.Instance.UseDebugEventTimes, Is.False);
Assert.That(Debug.isDebugBuild, Is.True);
Assert.That(RunManager.Instance.UseDebugArtifactSelection, Is.True);
}
finally
{
Object.DestroyImmediate(runManagerObject);
}
}
[Test]
public void SelectNext_CyclesAcrossAllTestArtifacts()
{
// This is the explicit six-item debug harness; normal ownership
// remains one artifact per color and three total.
typeof(ActiveArtifactController)
.GetField(
"maxArtifactsPerColor",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(controller, 6);
ActiveArtifactDefinition[] definitions =
{
CreatePulse("first", 25f, 60f),
CreatePulse("second", 25f, 60f),
CreatePulse("third", 25f, 60f),
CreatePulse("fourth", 25f, 60f),
CreatePulse("fifth", 25f, 60f),
CreatePulse("sixth", 25f, 60f),
};
foreach (ActiveArtifactDefinition definition in definitions)
{
controller.TryAddArtifact(definition);
}
Assert.That(controller.CurrentArtifact, Is.SameAs(definitions[0]));
for (int i = 1; i < definitions.Length; i++)
{
Assert.That(controller.SelectNext(), Is.True);
Assert.That(
controller.CurrentArtifact,
Is.SameAs(definitions[i]));
}
Assert.That(controller.SelectNext(), Is.True);
Assert.That(controller.CurrentArtifact, Is.SameAs(definitions[0]));
DestroyDefinitions(definitions);
}
[Test]
public void NormalAndChargedUse_SpendOneSharedGauge()
{
ActiveArtifactDefinition pulse = CreatePulse("pulse", 25f, 60f);
controller.TryAddArtifact(pulse);
Assert.That(controller.TryUseCurrent(false), Is.True);
Assert.That(controller.CurrentGauge, Is.EqualTo(75f));
Assert.That(controller.TryUseCurrent(true), Is.True);
Assert.That(controller.CurrentGauge, Is.EqualTo(15f));
Assert.That(controller.TryUseCurrent(false), Is.False);
Assert.That(controller.CurrentGauge, Is.EqualTo(15f));
DestroyDefinitions(pulse);
}
[Test]
public void ProductionChargeLocked_HoldOnlyUsesNormalArtifactOnceOnRelease()
{
RunManager runManager = CreateRunManager(RunMode.Production);
ActiveArtifactDefinition pulse = CreatePulse("locked-pulse", 25f, 60f);
try
{
Assert.That(runManager.IsArtifactChargeUnlocked, Is.False);
Assert.That(controller.TryAddArtifact(pulse), Is.True);
int chargeStartedCount = 0;
int useCount = 0;
bool lastUseWasCharged = true;
controller.OnArtifactChargeStarted += _ => chargeStartedCount++;
controller.OnArtifactUseSucceeded += (_, charged) =>
{
useCount++;
lastUseWasCharged = charged;
};
InvokeControllerMethod("HandleArtifactButtonPressed");
Assert.That(controller.IsCharging, Is.False);
Assert.That(controller.ChargeProgress, Is.Zero);
Assert.That(chargeStartedCount, Is.Zero);
Assert.That(controller.CurrentGauge, Is.EqualTo(100f));
Assert.That(controller.TryUseCurrent(true), Is.False);
Assert.That(controller.CurrentGauge, Is.EqualTo(100f));
InvokeControllerMethod("HandleArtifactButtonReleased");
InvokeControllerMethod("HandleArtifactButtonReleased");
Assert.That(controller.CurrentGauge, Is.EqualTo(75f));
Assert.That(useCount, Is.EqualTo(1));
Assert.That(lastUseWasCharged, Is.False);
Assert.That(controller.IsCharging, Is.False);
}
finally
{
DestroyDefinitions(pulse);
}
}
[Test]
public void ProductionChargeUnlock_RestoresChargedHoldUse()
{
RunManager runManager = CreateRunManager(RunMode.Production);
ActiveArtifactDefinition pulse = CreatePulse("unlocked-pulse", 25f, 60f);
try
{
Assert.That(controller.TryAddArtifact(pulse), Is.True);
Assert.That(runManager.IsArtifactChargeUnlocked, Is.False);
runManager.UnlockArtifactCharge();
Assert.That(runManager.IsArtifactChargeUnlocked, Is.True);
int chargedUseCount = 0;
controller.OnArtifactUseSucceeded += (_, charged) =>
{
if (charged)
{
chargedUseCount++;
}
};
InvokeControllerMethod("HandleArtifactButtonPressed");
Assert.That(controller.IsCharging, Is.True);
typeof(ActiveArtifactController)
.GetField(
"chargeStartedAt",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(
controller,
Time.time - pulse.ChargeDuration - 0.01f);
InvokeControllerMethod("HandleArtifactButtonReleased");
Assert.That(controller.IsCharging, Is.False);
Assert.That(controller.CurrentGauge, Is.EqualTo(40f));
Assert.That(chargedUseCount, Is.EqualTo(1));
}
finally
{
DestroyDefinitions(pulse);
}
}
[Test]
public void DevelopmentRun_KeepsArtifactChargingUnlocked()
{
RunManager runManager = CreateRunManager(RunMode.Development);
ActiveArtifactDefinition pulse = CreatePulse("development-pulse", 25f, 60f);
try
{
Assert.That(runManager.IsArtifactChargeUnlocked, Is.True);
Assert.That(controller.TryAddArtifact(pulse), Is.True);
InvokeControllerMethod("HandleArtifactButtonPressed");
Assert.That(controller.IsCharging, Is.True);
}
finally
{
DestroyDefinitions(pulse);
}
}
[Test]
public void ProductionChargeLocked_SwitchingArtifactCancelsPendingNormalUse()
{
CreateRunManager(RunMode.Production);
typeof(ActiveArtifactController)
.GetField(
"maxArtifactsPerColor",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(controller, 2);
ActiveArtifactDefinition first = CreatePulse("locked-first", 25f, 60f);
ActiveArtifactDefinition second = CreatePulse("locked-second", 25f, 60f);
try
{
Assert.That(controller.TryAddArtifact(first), Is.True);
Assert.That(controller.TryAddArtifact(second), Is.True);
int useCount = 0;
controller.OnArtifactUseSucceeded += (_, _) => useCount++;
InvokeControllerMethod("HandleArtifactButtonPressed");
Assert.That(controller.SelectNext(), Is.True);
InvokeControllerMethod("HandleArtifactButtonReleased");
Assert.That(controller.CurrentGauge, Is.EqualTo(100f));
Assert.That(useCount, Is.Zero);
}
finally
{
DestroyDefinitions(first, second);
}
}
[Test]
public void MovementAndNormalHit_AddConfiguredGaugeAmounts()
{
ActiveArtifactDefinition pulse = CreatePulse("pulse", 100f, 100f);
controller.TryAddArtifact(pulse);
Assert.That(controller.TryUseCurrent(false), Is.True);
Assert.That(controller.CurrentGauge, Is.Zero);
controller.AddMovementCharge(1f);
controller.AddNormalHitCharge();
Assert.That(controller.CurrentGauge, Is.EqualTo(6f));
DestroyDefinitions(pulse);
}
[Test]
public void PrototypeDefinitions_PhoenixPiercesAndThunderUsesFrontEffect()
{
ActiveArtifactDefinition phoenix =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/PhoenixArtifact.asset");
ActiveArtifactDefinition thunder =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset");
Assert.That(phoenix, Is.Not.Null);
Assert.That(phoenix.Effect, Is.EqualTo(ActiveArtifactEffect.Phoenix));
Assert.That(phoenix.DisplayName, Is.EqualTo("잿불의 마검"));
Assert.That(phoenix.PlaceholderSymbol, Is.EqualTo("SR"));
Assert.That(phoenix.NormalRange, Is.EqualTo(3f));
Assert.That(phoenix.NormalWidth, Is.EqualTo(0.4375f));
Assert.That(phoenix.NormalDamage, Is.EqualTo(35f));
Assert.That(phoenix.ChargedDamage, Is.EqualTo(70f));
Assert.That(phoenix.NormalKnockback, Is.EqualTo(0.2f));
Assert.That(phoenix.ChargedKnockback, Is.EqualTo(0.3f));
Assert.That(phoenix.ChargedRange, Is.EqualTo(6f));
Assert.That(phoenix.ChargedWidth, Is.EqualTo(0.875f));
Assert.That(phoenix.ChargedDuration, Is.EqualTo(0.3f));
Assert.That(phoenix.ChargedMaxHitsPerTarget, Is.EqualTo(1));
Assert.That(phoenix.NormalIgniteDuration, Is.EqualTo(3f));
Assert.That(phoenix.NormalIgniteInterval, Is.EqualTo(0.5f));
Assert.That(phoenix.NormalIgniteTickDamage, Is.EqualTo(3f));
Assert.That(phoenix.ChargedIgniteTickDamage, Is.EqualTo(5f));
Assert.That(thunder, Is.Not.Null);
Assert.That(
thunder.Effect,
Is.EqualTo(ActiveArtifactEffect.ThunderCrash));
Assert.That(thunder.NormalRange, Is.EqualTo(2f));
Assert.That(thunder.ChargedRange, Is.EqualTo(3f));
Assert.That(thunder.NormalDuration, Is.EqualTo(0.3f));
Assert.That(thunder.ChargedDuration, Is.EqualTo(0.55f));
Assert.That(thunder.NormalKnockback, Is.Zero);
Assert.That(thunder.ChargedKnockback, Is.Zero);
}
[Test]
public void PrototypeArtifactDefinitions_UseConfirmedEquipmentDisplayNames()
{
string[] paths =
{
"Assets/_Project/Constants/Artifacts/DashArtifact.asset",
"Assets/_Project/Constants/Artifacts/PulseArtifact.asset",
"Assets/_Project/Constants/Artifacts/PhoenixArtifact.asset",
"Assets/_Project/Constants/Artifacts/CycloneArtifact.asset",
"Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset",
"Assets/_Project/Constants/Artifacts/ChainLightningArtifact.asset",
};
string[] expectedNames =
{
"척후의 장화",
"군주의 방패",
"잿불의 마검",
"바람깃 브로치",
"뇌격의 수갑",
"창뢰의 보주",
};
ActiveArtifactEffect[] expectedEffects =
{
ActiveArtifactEffect.Dash,
ActiveArtifactEffect.Pulse,
ActiveArtifactEffect.Phoenix,
ActiveArtifactEffect.Cyclone,
ActiveArtifactEffect.ThunderCrash,
ActiveArtifactEffect.ChainLightning,
};
for (int i = 0; i < paths.Length; i++)
{
ActiveArtifactDefinition definition =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(paths[i]);
Assert.That(definition, Is.Not.Null, paths[i]);
Assert.That(definition.Effect, Is.EqualTo(expectedEffects[i]));
Assert.That(definition.DisplayName, Is.EqualTo(expectedNames[i]));
}
}
[Test]
public void PrototypeDefinition_PulseUsesRepelAndDefenseValues()
{
ActiveArtifactDefinition pulse =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/PulseArtifact.asset");
Assert.That(pulse, Is.Not.Null);
Assert.That(pulse.Effect, Is.EqualTo(ActiveArtifactEffect.Pulse));
Assert.That(pulse.NormalDamage, Is.Zero);
Assert.That(pulse.ChargedDamage, Is.Zero);
Assert.That(pulse.NormalKnockback, Is.EqualTo(1.5f));
Assert.That(pulse.ChargedKnockback, Is.EqualTo(2f));
Assert.That(pulse.NormalDuration, Is.EqualTo(0.2f));
Assert.That(pulse.ChargedDuration, Is.EqualTo(0.3f));
Assert.That(pulse.NormalDamageReduction, Is.EqualTo(0.2f));
Assert.That(pulse.ChargedDamageReduction, Is.EqualTo(0.35f));
Assert.That(pulse.NormalBuffDuration, Is.EqualTo(3f));
Assert.That(pulse.ChargedBuffDuration, Is.EqualTo(4f));
}
[Test]
public void PrototypeDefinition_CycloneUsesUpdatedNormalDuration()
{
ActiveArtifactDefinition cyclone =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/CycloneArtifact.asset");
Assert.That(cyclone, Is.Not.Null);
Assert.That(cyclone.Effect, Is.EqualTo(ActiveArtifactEffect.Cyclone));
Assert.That(cyclone.NormalDamage, Is.EqualTo(6f));
Assert.That(cyclone.ChargedDamage, Is.EqualTo(8f));
Assert.That(cyclone.NormalDuration, Is.EqualTo(0.36f));
Assert.That(cyclone.ChargedDuration, Is.EqualTo(0.65f));
Assert.That(cyclone.NormalMoveSpeedIncrease, Is.EqualTo(0.15f));
Assert.That(cyclone.ChargedMoveSpeedIncrease, Is.EqualTo(0.25f));
Assert.That(cyclone.NormalKnockback, Is.EqualTo(0.3f));
Assert.That(cyclone.ChargedKnockback, Is.EqualTo(0.5f));
}
[Test]
public void PrototypeDefinitions_UseThunderAndChainSecondaryEffects()
{
ActiveArtifactDefinition thunder =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset");
ActiveArtifactDefinition chain =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/ChainLightningArtifact.asset");
Assert.That(thunder.NormalDamage, Is.EqualTo(4f));
Assert.That(thunder.ChargedDamage, Is.EqualTo(6f));
Assert.That(thunder.NormalVulnerabilityIncrease, Is.EqualTo(0.25f));
Assert.That(thunder.ChargedVulnerabilityIncrease, Is.EqualTo(0.4f));
Assert.That(thunder.NormalVulnerabilityDuration, Is.EqualTo(3f));
Assert.That(thunder.ChargedVulnerabilityDuration, Is.EqualTo(4f));
Assert.That(chain.NormalDamage, Is.EqualTo(10f));
Assert.That(chain.ChargedDamage, Is.EqualTo(14f));
Assert.That(chain.DamageMultiplierPerChain, Is.EqualTo(1.2f));
Assert.That(chain.NormalShockChance, Is.EqualTo(0.2f));
Assert.That(chain.ChargedShockChance, Is.EqualTo(0.35f));
Assert.That(chain.ChargedShockIncrease, Is.EqualTo(0.3f));
}
[Test]
public void ScorchingRayVisual_UsesMeasuredBodyScaleAndNormalizedPivots()
{
string[] paths =
{
"Assets/_Project/Resources/Artifacts/ScorchingRay/"
+ "ScorchingRay-Ghostfire-Normal-v1.png",
"Assets/_Project/Resources/Artifacts/ScorchingRay/"
+ "ScorchingRay-Ghostfire-Charged-v1.png",
};
int[] widths = { 768, 1536 };
int[] heights = { 64, 128 };
for (int i = 0; i < paths.Length; i++)
{
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(paths[i]);
TextureImporter importer = AssetImporter.GetAtPath(paths[i]) as TextureImporter;
Assert.That(texture, Is.Not.Null, paths[i]);
Assert.That(texture.width, Is.EqualTo(widths[i]));
Assert.That(texture.height, Is.EqualTo(heights[i]));
Assert.That(importer, Is.Not.Null, paths[i]);
Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point));
Assert.That(importer.mipmapEnabled, Is.False);
Assert.That(
importer.textureCompression,
Is.EqualTo(TextureImporterCompression.Uncompressed));
}
FieldInfo durations = typeof(ActiveArtifactController).GetField(
"ScorchingRayFrameDurations",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(
(float[])durations.GetValue(null),
Is.EqualTo(new[] { 0.02f, 0.03f, 0.03f, 0.03f, 0.02f, 0.02f }));
MethodInfo load = typeof(ActiveArtifactController).GetMethod(
"TryLoadScorchingRayFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo create = typeof(ActiveArtifactController).GetMethod(
"CreateScorchingRaySprite",
BindingFlags.Instance | BindingFlags.NonPublic);
FieldInfo normalField = typeof(ActiveArtifactController).GetField(
"scorchingRayNormalFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
FieldInfo chargedField = typeof(ActiveArtifactController).GetField(
"scorchingRayChargedFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(load.Invoke(controller, new object[] { false }), Is.True);
Assert.That(load.Invoke(controller, new object[] { true }), Is.True);
Sprite[] normal = (Sprite[])normalField.GetValue(controller);
Sprite[] charged = (Sprite[])chargedField.GetValue(controller);
Assert.That(normal[0].pivot, Is.EqualTo(new Vector2(8f, 32f)));
Assert.That(charged[0].pivot, Is.EqualTo(new Vector2(16f, 64f)));
GameObject normalVisual = new("Normal Scorching Ray Visual Test");
GameObject chargedVisual = new("Charged Scorching Ray Visual Test");
try
{
SpriteRenderer normalRenderer = (SpriteRenderer)create.Invoke(
controller,
new object[] {
normalVisual, false, Vector2.right, 3f, 0.4375f });
SpriteRenderer chargedRenderer = (SpriteRenderer)create.Invoke(
controller,
new object[] {
chargedVisual, true, Vector2.right, 6f, 0.875f });
Assert.That(normalRenderer.transform.localScale, Is.EqualTo(Vector3.one));
Assert.That(chargedRenderer.transform.localScale, Is.EqualTo(Vector3.one));
}
finally
{
Object.DestroyImmediate(normalVisual);
Object.DestroyImmediate(chargedVisual);
typeof(ActiveArtifactController)
.GetMethod(
"DestroyScorchingRayFrames",
BindingFlags.Instance | BindingFlags.NonPublic)
.Invoke(controller, null);
}
}
[Test]
public void ScheduledHitCount_UsesDurationAndIntervalBoundaries()
{
MethodInfo getCount = typeof(ActiveArtifactController).GetMethod(
"GetScheduledHitCount",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(getCount.Invoke(null, new object[] { 0.36f, 0.14f }), Is.EqualTo(3));
Assert.That(getCount.Invoke(null, new object[] { 0.65f, 0.14f }), Is.EqualTo(5));
Assert.That(getCount.Invoke(null, new object[] { 0.30f, 0.13f }), Is.EqualTo(3));
Assert.That(getCount.Invoke(null, new object[] { 0.55f, 0.13f }), Is.EqualTo(5));
ActiveArtifactDefinition thunder = AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset");
Assert.That(thunder, Is.Not.Null);
Assert.That(
getCount.Invoke(null, new object[] { thunder.NormalDuration, thunder.HitInterval }),
Is.EqualTo(3));
Assert.That(
getCount.Invoke(null, new object[] { thunder.ChargedDuration, thunder.HitInterval }),
Is.EqualTo(5));
}
[Test]
public void EnemyIgniteTicksUseDamageTakenEffectsAtEachScheduledTime()
{
EnemyController enemy = CreateEnemyTarget(
"Timed Status Target",
Vector2.zero);
typeof(EnemyController)
.GetProperty("CurrentHealth")
?.SetValue(enemy, 100f);
Assert.That(enemy.ApplyShock(0.75f, 0.2f), Is.True);
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 5.5f), Is.True);
MethodInfo updateTimedEffects = typeof(EnemyController).GetMethod(
"UpdateTimedEffects",
BindingFlags.Instance | BindingFlags.NonPublic);
updateTimedEffects.Invoke(enemy, new object[] { 1f });
Assert.That(
enemy.CurrentHealth,
Is.EqualTo(89f).Within(0.001f),
"The 0.5s ignite tick receives shock before its own damage floor; the 1.0s tick does not.");
}
[Test]
public void PulseRingRetouchedSheets_UseSevenFramesAndExpectedImport()
{
string[] paths = {
"Assets/_Project/Resources/Artifacts/PulseRing/PulseRing-normal-retouched-v1.png",
"Assets/_Project/Resources/Artifacts/PulseRing/PulseRing-charged-retouched-v1.png" };
int[] widths = { 1120, 1792 };
int[] heights = { 100, 104 };
for (int i = 0; i < paths.Length; i++)
{
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(paths[i]);
TextureImporter importer = AssetImporter.GetAtPath(paths[i]) as TextureImporter;
Assert.That(texture, Is.Not.Null);
Assert.That(texture.width, Is.EqualTo(widths[i]));
Assert.That(texture.height, Is.EqualTo(heights[i]));
Assert.That(texture.filterMode, Is.EqualTo(FilterMode.Point));
Assert.That(importer, Is.Not.Null);
Assert.That(importer.textureCompression, Is.EqualTo(TextureImporterCompression.Uncompressed));
Assert.That(importer.mipmapEnabled, Is.False);
Assert.That(importer.alphaIsTransparency, Is.True);
Assert.That(texture.width / (i == 0 ? 160 : 256), Is.EqualTo(7));
}
MethodInfo load = typeof(ActiveArtifactController).GetMethod(
"TryLoadPulseRingFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo destroy = typeof(ActiveArtifactController).GetMethod(
"DestroyPulseRingFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
FieldInfo normalFrames = typeof(ActiveArtifactController).GetField(
"pulseRingNormalFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
FieldInfo chargedFrames = typeof(ActiveArtifactController).GetField(
"pulseRingChargedFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(load.Invoke(controller, new object[] { false }), Is.True);
Assert.That(load.Invoke(controller, new object[] { true }), Is.True);
Sprite[] normal = (Sprite[])normalFrames.GetValue(controller);
Sprite[] charged = (Sprite[])chargedFrames.GetValue(controller);
Assert.That(normal, Has.Length.EqualTo(7));
Assert.That(charged, Has.Length.EqualTo(7));
Assert.That(normal[0].rect, Is.EqualTo(new Rect(0f, 0f, 160f, 100f)));
Assert.That(charged[0].rect, Is.EqualTo(new Rect(0f, 0f, 256f, 104f)));
Assert.That(normal[0].pixelsPerUnit, Is.EqualTo(32f));
Assert.That(charged[0].pixelsPerUnit, Is.EqualTo(32f));
Assert.That(normal[0].pivot, Is.EqualTo(new Vector2(80f, 50.5f)));
Assert.That(charged[0].pivot, Is.EqualTo(new Vector2(128f, 52f)));
destroy.Invoke(controller, null);
Assert.That(normalFrames.GetValue(controller), Is.Null);
Assert.That(chargedFrames.GetValue(controller), Is.Null);
}
[Test]
public void Charging_DoesNotCreatePreviewOrPreliminaryRange()
{
ActiveArtifactDefinition pulse = CreatePulse("charge-test", 25f, 25f);
try
{
Assert.That(controller.TryAddArtifact(pulse), Is.True);
MethodInfo beginCharge = typeof(ActiveArtifactController).GetMethod(
"BeginCharge",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo cancelCharge = typeof(ActiveArtifactController).GetMethod(
"CancelCharge",
BindingFlags.Instance | BindingFlags.NonPublic);
beginCharge.Invoke(controller, null);
Assert.That(controller.IsCharging, Is.True);
Assert.That(
owner.GetComponentsInChildren<LineRenderer>(true),
Is.Empty);
Assert.That(
owner.transform.Find("Artifact Charge Preview"),
Is.Null);
cancelCharge.Invoke(controller, null);
Assert.That(controller.IsCharging, Is.False);
}
finally
{
Object.DestroyImmediate(pulse);
}
}
[Test]
public void ChainLightningArc_UsesEightCroppedPointSampledFrames()
{
const string path =
"Assets/_Project/Resources/Artifacts/ChainLightning/"
+ "Ghostfire-Arc-edit-v1.png";
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
TextureImporter importer =
AssetImporter.GetAtPath(path) as TextureImporter;
Assert.That(texture, Is.Not.Null);
Assert.That(texture.width, Is.EqualTo(800));
Assert.That(texture.height, Is.EqualTo(100));
Assert.That(importer, Is.Not.Null);
Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point));
Assert.That(importer.textureCompression,
Is.EqualTo(TextureImporterCompression.Uncompressed));
Assert.That(importer.mipmapEnabled, Is.False);
MethodInfo load = typeof(ActiveArtifactController).GetMethod(
"TryLoadChainLightningArcFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo destroy = typeof(ActiveArtifactController).GetMethod(
"DestroyChainLightningArcFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
FieldInfo frames = typeof(ActiveArtifactController).GetField(
"chainLightningArcFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(load.Invoke(controller, null), Is.True);
Sprite[] loadedFrames = (Sprite[])frames.GetValue(controller);
Assert.That(loadedFrames, Has.Length.EqualTo(8));
Assert.That(loadedFrames[0].rect, Is.EqualTo(new Rect(8f, 50f, 81f, 15f)));
Assert.That(loadedFrames[7].rect, Is.EqualTo(new Rect(708f, 50f, 81f, 15f)));
destroy.Invoke(controller, null);
Assert.That(frames.GetValue(controller), Is.Null);
}
[Test]
public void CycloneOrbitVisual_UsesFourSheetsAndMatchesOrbitBounds()
{
string root =
"Assets/_Project/Resources/Artifacts/ThreeColor-v1/"
+ "CycloneOrbit-v3/";
string[] assets =
{
"Cyclone-Normal-Back-v3.png",
"Cyclone-Normal-Front-v3.png",
"Cyclone-Charged-Back-v3.png",
"Cyclone-Charged-Front-v3.png",
};
foreach (string asset in assets)
{
string path = root + asset;
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;
Assert.That(texture, Is.Not.Null, path);
Assert.That(texture.width, Is.EqualTo(800), path);
Assert.That(texture.height, Is.EqualTo(100), path);
Assert.That(importer, Is.Not.Null, path);
Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point), path);
Assert.That(importer.mipmapEnabled, Is.False, path);
Assert.That(importer.textureCompression,
Is.EqualTo(TextureImporterCompression.Uncompressed), path);
AssertCyclonePixelsInsideOrbit(
texture,
asset.Contains("Charged"),
path);
}
MethodInfo load = typeof(ActiveArtifactController).GetMethod(
"TryLoadCycloneOrbitFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo destroy = typeof(ActiveArtifactController).GetMethod(
"DestroyCycloneOrbitFrames",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
System.Type.EmptyTypes,
null);
Assert.That((bool)load.Invoke(controller, new object[] { false }), Is.True);
Assert.That((bool)load.Invoke(controller, new object[] { true }), Is.True);
AssertCycloneFrameSet("cycloneNormalBackFrames");
AssertCycloneFrameSet("cycloneNormalFrontFrames");
AssertCycloneFrameSet("cycloneChargedBackFrames");
AssertCycloneFrameSet("cycloneChargedFrontFrames");
owner.transform.localScale = Vector3.one * 1.25f;
SpriteRenderer playerRenderer = owner.AddComponent<SpriteRenderer>();
playerRenderer.sortingOrder = 120;
YSortRenderer ySort = owner.AddComponent<YSortRenderer>();
GameObject visual = new("Cyclone Orbit Layer Test");
MethodInfo create = typeof(ActiveArtifactController).GetMethod(
"CreateCycloneSpriteLayers",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo setFrame = typeof(ActiveArtifactController).GetMethod(
"SetCycloneSpriteFrame",
BindingFlags.Instance | BindingFlags.NonPublic);
object[] normalArgs = { visual, false, 1f, null, null };
create.Invoke(controller, normalArgs);
SpriteRenderer normalBack = (SpriteRenderer)normalArgs[3];
SpriteRenderer normalFront = (SpriteRenderer)normalArgs[4];
Assert.That(normalBack, Is.Not.Null);
Assert.That(normalFront, Is.Not.Null);
Assert.That(normalBack.sortingOrder, Is.EqualTo(999));
Assert.That(normalFront.sortingOrder, Is.EqualTo(1001));
AssertUniformScale(normalBack.transform.lossyScale, 1f);
AssertUniformScale(normalFront.transform.lossyScale, 1f);
owner.transform.position = new Vector3(0f, 0.25f, 0f);
typeof(ActiveArtifactController).GetMethod(
"UpdateCycloneSorting",
BindingFlags.Instance | BindingFlags.NonPublic)
.Invoke(controller, new object[] { normalBack, normalFront });
Assert.That(ySort.CalculateSortingOrder(), Is.EqualTo(975));
Assert.That(normalBack.sortingOrder, Is.EqualTo(974));
Assert.That(normalFront.sortingOrder, Is.EqualTo(976));
GameObject chargedVisual = new("Charged Cyclone Orbit Layer Test");
object[] chargedArgs = { chargedVisual, true, 1f, null, null };
create.Invoke(controller, chargedArgs);
SpriteRenderer chargedBack = (SpriteRenderer)chargedArgs[3];
SpriteRenderer chargedFront = (SpriteRenderer)chargedArgs[4];
setFrame.Invoke(
controller,
new object[] { normalBack, normalFront, false, 0.1f });
setFrame.Invoke(
controller,
new object[] { chargedBack, chargedFront, true, 0.15f });
Assert.That(
normalBack.sprite,
Is.EqualTo(GetCycloneFrames("cycloneNormalBackFrames")[2]));
Assert.That(
normalFront.sprite,
Is.EqualTo(GetCycloneFrames("cycloneNormalFrontFrames")[2]));
Assert.That(
chargedBack.sprite,
Is.EqualTo(GetCycloneFrames("cycloneChargedBackFrames")[3]));
Assert.That(
chargedFront.sprite,
Is.EqualTo(GetCycloneFrames("cycloneChargedFrontFrames")[3]));
AssertUniformScale(chargedBack.transform.lossyScale, 1f);
AssertUniformScale(chargedFront.transform.lossyScale, 1f);
MethodInfo scale = typeof(ActiveArtifactController).GetMethod(
"GetCycloneVisualScale",
BindingFlags.Static | BindingFlags.NonPublic);
MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod(
"GetCycloneHitCenterOffset",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod(
"GetCycloneHitRadii",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That((float)scale.Invoke(null, new object[] { 0.85f, false }), Is.EqualTo(1f));
Assert.That((float)scale.Invoke(null, new object[] { 1.25f, true }), Is.EqualTo(1f));
Assert.That((float)scale.Invoke(null, new object[] { 1.7f, false }), Is.EqualTo(2f));
Vector2 normalCenterOffset = (Vector2)getOffset.Invoke(
controller,
new object[] { 0.85f, false });
Vector2 chargedCenterOffset = (Vector2)getOffset.Invoke(
controller,
new object[] { 1.25f, true });
Vector2 expectedShadowOffset = new Vector2(0f, -7.5f / 32f * 1.25f);
AssertVector2Close(
normalCenterOffset,
expectedShadowOffset + Vector2.up * (5f / 32f));
AssertVector2Close(chargedCenterOffset, normalCenterOffset);
Vector2 normalRadii = (Vector2)getRadii.Invoke(
null,
new object[] { 0.85f, false });
Vector2 chargedRadii = (Vector2)getRadii.Invoke(
null,
new object[] { 1.25f, true });
AssertVector2Close(normalRadii, new Vector2(38.0625f, 15.03125f) / 32f);
AssertVector2Close(chargedRadii, new Vector2(46f, 18f) / 32f);
Assert.That(chargedRadii.x, Is.GreaterThan(normalRadii.x));
MethodInfo evaluate = typeof(ActiveArtifactController).GetMethod(
"EvaluateCycloneFrameIndex",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(evaluate.Invoke(null, new object[] { 0f }), Is.EqualTo(0));
Assert.That(evaluate.Invoke(null, new object[] { 0.05f }), Is.EqualTo(1));
Assert.That(evaluate.Invoke(null, new object[] { 0.39999f }), Is.EqualTo(7));
Assert.That(evaluate.Invoke(null, new object[] { 0.4f }), Is.EqualTo(0));
Object.DestroyImmediate(visual);
Object.DestroyImmediate(chargedVisual);
destroy.Invoke(controller, null);
Assert.That(GetCycloneFrames("cycloneNormalBackFrames"), Is.Null);
Assert.That(GetCycloneFrames("cycloneNormalFrontFrames"), Is.Null);
Assert.That(GetCycloneFrames("cycloneChargedBackFrames"), Is.Null);
Assert.That(GetCycloneFrames("cycloneChargedFrontFrames"), Is.Null);
}
private void AssertCycloneFrameSet(string fieldName)
{
Sprite[] frames = GetCycloneFrames(fieldName);
Assert.That(frames, Has.Length.EqualTo(8), fieldName);
Assert.That(frames[0].pixelsPerUnit, Is.EqualTo(32f), fieldName);
Assert.That(Vector2.Distance(frames[0].pivot, new Vector2(50f, 30f)), Is.LessThan(0.001f), fieldName);
Assert.That(frames[7].rect, Is.EqualTo(new Rect(700f, 0f, 100f, 100f)), fieldName);
}
private Sprite[] GetCycloneFrames(string fieldName)
{
return (Sprite[])typeof(ActiveArtifactController).GetField(
fieldName,
BindingFlags.Instance | BindingFlags.NonPublic).GetValue(controller);
}
private static void AssertCyclonePixelsInsideOrbit(
Texture2D texture,
bool charged,
string context)
{
float radiusX = charged ? 46f : 38.0625f;
float radiusY = charged ? 18f : 15.03125f;
Color32[] pixels = texture.GetPixels32();
int visiblePixelCount = 0;
for (int y = 0; y < texture.height; y++)
{
float topY = texture.height - 1 - y;
for (int x = 0; x < texture.width; x++)
{
if (pixels[y * texture.width + x].a == 0)
{
continue;
}
visiblePixelCount++;
float localX = x % 100;
float nextX = localX + 1f;
float nextY = topY + 1f;
AssertCycloneCornerInside(localX, topY, radiusX, radiusY, context);
AssertCycloneCornerInside(nextX, topY, radiusX, radiusY, context);
AssertCycloneCornerInside(localX, nextY, radiusX, radiusY, context);
AssertCycloneCornerInside(nextX, nextY, radiusX, radiusY, context);
}
}
Assert.That(visiblePixelCount, Is.GreaterThan(0), context);
}
private static void AssertCycloneCornerInside(
float x,
float y,
float radiusX,
float radiusY,
string context)
{
float dx = x - 50f;
float dy = y - 65f;
float ratio = dx * dx / (radiusX * radiusX)
+ dy * dy / (radiusY * radiusY);
Assert.That(ratio, Is.LessThanOrEqualTo(1f), context);
}
[Test]
public void ThunderCrashVisuals_UseThreeColorDedicatedSheetAndExpandedDefinitionScale()
{
const string path = "Assets/_Project/Resources/Artifacts/ThreeColor-v1/ThunderCrash/ThunderCrash-Combined-v6.png";
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
Assert.That(texture, Is.Not.Null);
Assert.That(texture.width, Is.EqualTo(600));
Assert.That(texture.height, Is.EqualTo(100));
Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point));
Assert.That(importer.mipmapEnabled, Is.False);
Assert.That(importer.textureCompression, Is.EqualTo(TextureImporterCompression.Uncompressed));
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
var type = typeof(ActiveArtifactController);
Assert.That(type.GetMethod("TryLoadThunderCrashFrames", flags).Invoke(controller, null), Is.True);
var field = type.GetField("thunderCrashFrames", flags);
var frames = (Sprite[])field.GetValue(controller);
Assert.That(frames, Has.Length.EqualTo(6));
Assert.That(frames[0].texture, Is.SameAs(texture));
Assert.That(frames[0].pixelsPerUnit, Is.EqualTo(32f));
Assert.That(frames[0].pivot, Is.EqualTo(new Vector2(50.5f,42.5f)));
Assert.That(frames[5].rect, Is.EqualTo(new Rect(500f,0f,100f,100f)));
var definition = AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset");
Assert.That(definition.NormalRange, Is.EqualTo(2f));
Assert.That(definition.ChargedRange, Is.EqualTo(3f));
foreach (float range in new[] { definition.NormalRange, definition.ChargedRange })
{
var owner = new GameObject("Thunder Single Sheet Test");
var renderer = (SpriteRenderer)type.GetMethod("CreateThunderCrashSprite", flags)
.Invoke(controller, new object[] { owner, frames, range });
Assert.That(owner.GetComponentsInChildren<SpriteRenderer>(), Has.Length.EqualTo(1));
Assert.That(renderer.transform.localPosition, Is.EqualTo(Vector3.zero));
AssertUniformScale(renderer.transform.localScale, 1.05f * range);
Assert.That(renderer.sortingOrder, Is.EqualTo(211));
var setFrame = type.GetMethod("SetThunderCrashSpriteFrame", flags);
setFrame.Invoke(controller, new object[] { renderer, frames, .02f });
Assert.That(renderer.sprite, Is.SameAs(frames[1]));
setFrame.Invoke(controller, new object[] { renderer, frames, .13f });
Assert.That(renderer.sprite, Is.SameAs(frames[5]));
setFrame.Invoke(controller, new object[] { renderer, frames, .16f });
Assert.That(renderer.enabled, Is.False);
Object.DestroyImmediate(owner);
}
type.GetMethod("DestroyThunderCrashFrames", flags).Invoke(controller, null);
Assert.That(field.GetValue(controller), Is.Null);
}
[Test]
public void ThunderCrashGroundFootprint_ContainsEveryVisibleGroundPixelCorner()
{
const string path = "Assets/_Project/Resources/Artifacts/ThunderCrash/ThunderCrash-Combined-v6.png";
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
var pixels = texture.GetPixels32();
const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic;
var type = typeof(ActiveArtifactController);
foreach (float range in new[] { 1f, 1.5f })
{
var center = (Vector2)type.GetMethod("GetThunderCrashHitCenterOffset", flags)
.Invoke(null, new object[] { range, Vector2.right });
var radii = (Vector2)type.GetMethod("GetThunderCrashHitRadii", flags)
.Invoke(null, new object[] { range, Vector2.right });
int samples = 0;
for (int frame = 0; frame < 6; frame++)
for (int y = 38; y < 48; y++) // Source ground rows 52..61 from top.
for (int x = 0; x < 100; x++)
{
if (pixels[y * texture.width + frame * 100 + x].a == 0) continue;
for (int dy = 0; dy <= 1; dy++)
for (int dx = 0; dx <= 1; dx++)
{
Vector2 point = (new Vector2(x + dx, y + dy)
- new Vector2(50.5f, 42.5f)) / 32f * (1.05f * range);
Vector2 offset = point - center;
float distance = offset.x * offset.x / (radii.x * radii.x)
+ offset.y * offset.y / (radii.y * radii.y);
Assert.That(distance, Is.LessThanOrEqualTo(1.0001f),
$"Visible ground pixel outside hit area: frame {frame}, ({x},{y}), range {range}");
samples++;
}
}
Assert.That(samples, Is.GreaterThan(0));
}
}
[Test]
public void ThunderCrashDirection_UsesFacingAndAuthoredGroundAnchor()
{
GameObject actor = new("Thunder Direction Test");
try
{
actor.AddComponent<PlayerStats>();
actor.AddComponent<Rigidbody2D>();
actor.AddComponent<SpriteRenderer>();
actor.AddComponent<Animator>();
PlayerController player = actor.AddComponent<PlayerController>();
ActiveArtifactController artifacts =
actor.AddComponent<ActiveArtifactController>();
BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
FieldInfo facing = typeof(PlayerController).GetField(
"<FacingDirection>k__BackingField", flags);
FieldInfo moving = typeof(PlayerController).GetField(
"<MoveDirection>k__BackingField", flags);
MethodInfo resolveDirection = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashDirection", flags);
MethodInfo resolveOrigin = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashOrigin", flags);
typeof(ActiveArtifactController).GetField(
"playerController", flags).SetValue(artifacts, player);
typeof(ActiveArtifactController).GetField(
"body", flags).SetValue(
artifacts,
actor.GetComponent<Rigidbody2D>());
Assert.That(facing, Is.Not.Null);
Assert.That(moving, Is.Not.Null);
Assert.That(resolveDirection, Is.Not.Null);
Assert.That(resolveOrigin, Is.Not.Null);
Vector2[] directions =
{
Vector2.right,
Vector2.left,
Vector2.up,
Vector2.down,
new Vector2(1f, 1f).normalized,
new Vector2(-1f, 1f).normalized,
new Vector2(1f, -1f).normalized,
new Vector2(-1f, -1f).normalized,
};
foreach (Vector2 direction in directions)
{
facing.SetValue(player, direction);
// A held movement input can be changed while the cast
// locks movement; the cast must retain the facing value.
moving.SetValue(player, -direction);
Vector2 resolved = (Vector2)resolveDirection.Invoke(
artifacts, null);
Assert.That(
Vector2.Distance(resolved, direction),
Is.LessThan(0.0001f),
$"Thunder Crash direction changed for {direction}");
}
facing.SetValue(player, new Vector2(-0.17f, 0.98f));
moving.SetValue(player, Vector2.right);
Assert.That(
Vector2.Distance(
(Vector2)resolveDirection.Invoke(artifacts, null),
Vector2.up),
Is.LessThan(0.0001f));
Rigidbody2D body = actor.GetComponent<Rigidbody2D>();
CircleCollider2D collider = actor.AddComponent<CircleCollider2D>();
actor.transform.localScale = Vector3.one * 1.25f;
collider.offset = new Vector2(0.125f, -0.28f);
body.position = new Vector2(2.5f, -1.75f);
Vector2 expectedOrigin = body.position
+ (Vector2)actor.transform.TransformVector(
new Vector2(0f, -0.1f));
Vector2 resolvedOrigin = (Vector2)resolveOrigin.Invoke(
artifacts, null);
Assert.That(
Vector2.Distance(resolvedOrigin, expectedOrigin),
Is.LessThan(0.0001f));
collider.offset = new Vector2(-0.75f, 0.9f);
Vector2 offsetIndependentOrigin = (Vector2)resolveOrigin.Invoke(
artifacts, null);
Assert.That(
Vector2.Distance(offsetIndependentOrigin, expectedOrigin),
Is.LessThan(0.0001f));
}
finally
{
Object.DestroyImmediate(actor);
}
}
[Test]
public void CycloneAndThunderFootprints_UseAuthoredEllipseBoundaries()
{
MethodInfo getCycloneOffset = typeof(ActiveArtifactController).GetMethod(
"GetCycloneHitCenterOffset",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo getCycloneRadii = typeof(ActiveArtifactController).GetMethod(
"GetCycloneHitRadii",
BindingFlags.Static | BindingFlags.NonPublic);
MethodInfo getThunderOffset = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitCenterOffset",
BindingFlags.Static | BindingFlags.NonPublic);
MethodInfo getThunderRadii = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitRadii",
BindingFlags.Static | BindingFlags.NonPublic);
MethodInfo distance = typeof(ActiveArtifactController).GetMethod(
"GetEllipseDistance",
BindingFlags.Static | BindingFlags.NonPublic);
owner.transform.localScale = Vector3.one * 1.25f;
Vector2 cycloneOffset = (Vector2)getCycloneOffset.Invoke(
controller, new object[] { 0.85f, false });
Vector2 cycloneRadii = (Vector2)getCycloneRadii.Invoke(
null, new object[] { 0.85f, false });
Vector2 chargedCycloneOffset = (Vector2)getCycloneOffset.Invoke(
controller, new object[] { 1.25f, true });
Vector2 chargedCycloneRadii = (Vector2)getCycloneRadii.Invoke(
null, new object[] { 1.25f, true });
Vector2 expectedCenterOffset = new Vector2(
0f,
-7.5f / 32f * 1.25f + 5f / 32f);
AssertVector2Close(cycloneOffset, expectedCenterOffset);
AssertVector2Close(chargedCycloneOffset, expectedCenterOffset);
AssertVector2Close(
cycloneRadii,
new Vector2(38.0625f, 15.03125f) / 32f);
AssertVector2Close(
chargedCycloneRadii,
new Vector2(46f, 18f) / 32f);
Assert.That(chargedCycloneRadii.x, Is.GreaterThan(cycloneRadii.x));
AssertEllipseBoundary(distance, cycloneRadii);
AssertEllipseBoundary(distance, chargedCycloneRadii);
Vector2[] directions =
{
Vector2.right,
Vector2.left,
Vector2.up,
Vector2.down,
new Vector2(1f, 1f),
new Vector2(-1f, 1f),
new Vector2(1f, -1f),
new Vector2(-1f, -1f),
};
float[] ranges = { 1f, 1.5f };
foreach (float range in ranges)
{
foreach (Vector2 direction in directions)
{
Vector2 thunderOffset = (Vector2)getThunderOffset.Invoke(
null,
new object[] { range, direction });
Vector2 thunderRadii = (Vector2)getThunderRadii.Invoke(
null,
new object[] { range, direction });
Vector2 expectedOffset = new Vector2(-0.5f, 0.5f)
/ 32f
* (1.05f * range);
Vector2 expectedRadii = new Vector2(17f, 7.25f)
/ 32f
* (1.05f * range);
AssertVector2Close(thunderOffset, expectedOffset);
AssertVector2Close(thunderRadii, expectedRadii);
AssertEllipseBoundary(distance, thunderRadii);
}
}
}
[Test]
public void ThunderCrashBodyContact_UsesExpandedEllipseAndRejectsOutsideBody()
{
MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitCenterOffset",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(float), typeof(Vector2) },
null);
MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitRadii",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(float), typeof(Vector2) },
null);
MethodInfo overlap = typeof(ActiveArtifactController).GetMethod(
"DoesBodyColliderOverlapEllipse",
BindingFlags.Static | BindingFlags.NonPublic);
MethodInfo distance = typeof(ActiveArtifactController).GetMethod(
"GetEllipseDistance",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(getOffset, Is.Not.Null);
Assert.That(getRadii, Is.Not.Null);
Assert.That(overlap, Is.Not.Null);
Assert.That(distance, Is.Not.Null);
const float range = 1f;
Vector2 direction = Vector2.right;
Vector2 effectCenter = direction * 0.45f;
Vector2 hitCenter = effectCenter
+ (Vector2)getOffset.Invoke(
null,
new object[] { range, direction });
Vector2 hitRadii = (Vector2)getRadii.Invoke(
null,
new object[] { range, direction });
EnemyController enemy = CreateEnemyTarget(
"Thunder Crash Body Contact",
hitCenter + Vector2.right * (hitRadii.x + 0.1f));
CircleCollider2D collider = enemy.GetComponent<CircleCollider2D>();
collider.radius = 0.2f;
Physics2D.SyncTransforms();
Assert.That(
(float)distance.Invoke(
null,
new object[]
{
enemy.GroundAnchorPosition - hitCenter,
hitRadii,
}),
Is.GreaterThan(1f),
"The ground anchor is outside the authored Thunder ellipse.");
Assert.That(
(bool)overlap.Invoke(
null,
new object[] { enemy, hitCenter, hitRadii }),
Is.True,
"A body touching the expanded Thunder ellipse must hit.");
enemy.transform.position += Vector3.right * 0.4f;
Physics2D.SyncTransforms();
Assert.That(
(bool)overlap.Invoke(
null,
new object[] { enemy, hitCenter, hitRadii }),
Is.False,
"A body fully outside the expanded Thunder ellipse must miss.");
}
[Test]
public void ThunderCrashHitQuery_IncludesFrontContactAcrossEightDirections()
{
MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitCenterOffset",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(float), typeof(Vector2) },
null);
MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitRadii",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(float), typeof(Vector2) },
null);
MethodInfo hit = typeof(ActiveArtifactController).GetMethod(
"HitEnemiesInEllipse",
BindingFlags.Instance | BindingFlags.NonPublic);
ActiveArtifactDefinition definition = CreateThunderCrashDefinition(
"thunder_front_contact");
Vector2[] directions =
{
Vector2.right,
Vector2.left,
Vector2.up,
Vector2.down,
new Vector2(1f, 1f),
new Vector2(-1f, 1f),
new Vector2(1f, -1f),
new Vector2(-1f, -1f),
};
float[] ranges = { 1f, 1.5f };
try
{
int caseIndex = 0;
foreach (float range in ranges)
{
foreach (Vector2 direction in directions)
{
Vector2 normalized = direction.normalized;
Vector2 effectCenter = normalized * 0.45f;
Vector2 hitCenter = effectCenter
+ (Vector2)getOffset.Invoke(
null,
new object[] { range, direction });
Vector2 radii = (Vector2)getRadii.Invoke(
null,
new object[] { range, direction });
EnemyController enemy = CreateEnemyTarget(
$"Thunder Front Contact {caseIndex}",
hitCenter - normalized * (0.35f * range));
typeof(EnemyController)
.GetField(
"<CurrentHealth>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(enemy, 100f);
try
{
Physics2D.SyncTransforms();
int hitCount = (int)hit.Invoke(
controller,
new object[]
{
definition,
hitCenter,
radii,
1f,
0f,
5,
false,
false,
1000 + caseIndex,
true,
});
Assert.That(
hitCount,
Is.EqualTo(1),
$"Direction {direction}, range {range}");
Assert.That(enemy.CurrentHealth, Is.EqualTo(99f).Within(0.001f));
Assert.That(enemy.BumpVulnerabilityRemaining, Is.GreaterThan(0f));
Assert.That(enemy.BumpVulnerabilityIncrease, Is.EqualTo(0.25f));
}
finally
{
Object.DestroyImmediate(enemy.gameObject);
}
caseIndex++;
}
}
}
finally
{
Object.DestroyImmediate(definition);
}
}
[Test]
public void ThunderCrashHitQuery_DeduplicatesMultipleCollidersPerEnemy()
{
ActiveArtifactDefinition definition = CreateThunderCrashDefinition(
"thunder_query");
EnemyController enemy = CreateEnemyTarget(
"Thunder Crash Duplicate Collider",
Vector2.zero);
typeof(EnemyController)
.GetField(
"<CurrentHealth>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(enemy, 100f);
GameObject duplicate = new("Duplicate Collider");
duplicate.transform.SetParent(enemy.transform, false);
CircleCollider2D duplicateCollider =
duplicate.AddComponent<CircleCollider2D>();
duplicateCollider.radius = 0.25f;
Physics2D.SyncTransforms();
MethodInfo hit = typeof(ActiveArtifactController).GetMethod(
"HitEnemiesInEllipse",
BindingFlags.Instance | BindingFlags.NonPublic);
try
{
int hitCount = (int)hit.Invoke(
controller,
new object[]
{
definition,
Vector2.zero,
new Vector2(1f, 1f),
1f,
0f,
5,
false,
false,
123,
true,
});
Assert.That(hitCount, Is.EqualTo(1));
Assert.That(enemy.CurrentHealth, Is.EqualTo(99f).Within(0.001f));
Assert.That(enemy.BumpVulnerabilityRemaining, Is.GreaterThan(0f));
}
finally
{
Object.DestroyImmediate(duplicate);
Object.DestroyImmediate(definition);
}
}
[Test]
public void ThunderCrashHitQuery_RejectsOutsideBodyWithoutDamage()
{
ActiveArtifactDefinition definition = CreateThunderCrashDefinition(
"thunder_outside");
EnemyController enemy = CreateEnemyTarget(
"Thunder Crash Outside Body",
new Vector2(0.68f, 0f));
CircleCollider2D collider = enemy.GetComponent<CircleCollider2D>();
collider.radius = 0.1f;
typeof(EnemyController)
.GetField(
"<CurrentHealth>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(enemy, 100f);
Physics2D.SyncTransforms();
MethodInfo hit = typeof(ActiveArtifactController).GetMethod(
"HitEnemiesInEllipse",
BindingFlags.Instance | BindingFlags.NonPublic);
try
{
int hitCount = (int)hit.Invoke(
controller,
new object[]
{
definition,
Vector2.zero,
new Vector2(17f, 7.25f) / 32f * 1.05f,
1f,
0f,
5,
false,
false,
124,
true,
});
Assert.That(hitCount, Is.EqualTo(0));
Assert.That(enemy.CurrentHealth, Is.EqualTo(100f));
Assert.That(enemy.BumpVulnerabilityRemaining, Is.EqualTo(0f));
}
finally
{
Object.DestroyImmediate(definition);
Object.DestroyImmediate(enemy.gameObject);
}
}
[Test]
public void ThunderCrashHitQuery_ContactsVisibleLowerGroundOverlap()
{
ActiveArtifactDefinition definition = CreateThunderCrashDefinition(
"thunder_lower_ground_contact");
Vector2 effectCenter = Vector2.right * 0.45f;
MethodInfo getOffset = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitCenterOffset",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(float), typeof(Vector2) },
null);
MethodInfo getRadii = typeof(ActiveArtifactController).GetMethod(
"GetThunderCrashHitRadii",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(float), typeof(Vector2) },
null);
Vector2 direction = Vector2.right;
Vector2 hitCenter = effectCenter
+ (Vector2)getOffset.Invoke(
null,
new object[] { 1f, direction });
Vector2 hitRadii = (Vector2)getRadii.Invoke(
null,
new object[] { 1f, direction });
EnemyController enemy = CreateEnemyTarget(
"Thunder Crash Lower Ground Contact",
new Vector2(hitCenter.x, effectCenter.y - 0.06f));
CircleCollider2D collider = enemy.GetComponent<CircleCollider2D>();
collider.offset = new Vector2(0f, -0.28f);
collider.radius = 0.22f;
typeof(EnemyController)
.GetField(
"<CurrentHealth>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(enemy, 100f);
float colliderTop = enemy.transform.position.y
+ collider.offset.y
+ collider.radius;
float previousEllipseBottom = effectCenter.y
+ (1.5f - 4f) / 32f * 1.05f;
Assert.That(
colliderTop,
Is.LessThan(previousEllipseBottom),
"This target reproduces the visible lower-ground gap from the previous ellipse.");
Physics2D.SyncTransforms();
MethodInfo hit = typeof(ActiveArtifactController).GetMethod(
"HitEnemiesInEllipse",
BindingFlags.Instance | BindingFlags.NonPublic);
try
{
int hitCount = (int)hit.Invoke(
controller,
new object[]
{
definition,
hitCenter,
hitRadii,
1f,
0f,
5,
false,
false,
126,
true,
});
Assert.That(hitCount, Is.EqualTo(1));
Assert.That(enemy.CurrentHealth, Is.EqualTo(99f).Within(0.001f));
Assert.That(enemy.BumpVulnerabilityRemaining, Is.GreaterThan(0f));
}
finally
{
Object.DestroyImmediate(definition);
Object.DestroyImmediate(enemy.gameObject);
}
}
[Test]
public void ThunderCrashWrongColorContact_IsShieldedWithoutDamage()
{
EnemyDefinition enemyDefinition =
CreateProtectedEnemyDefinition();
GameObject enemyObject = null;
ActiveArtifactDefinition definition = CreateThunderCrashDefinition(
"thunder_protected");
try
{
EnemyController enemy = CreateProtectedEnemy(
"Thunder Crash Protected Target",
enemyDefinition,
out enemyObject);
typeof(EnemyController)
.GetField(
"<CurrentHealth>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(enemy, 100f);
Assert.That(enemy.IsDamageInvulnerable, Is.True);
Physics2D.SyncTransforms();
MethodInfo hit = typeof(ActiveArtifactController).GetMethod(
"HitEnemiesInEllipse",
BindingFlags.Instance | BindingFlags.NonPublic);
int hitCount = (int)hit.Invoke(
controller,
new object[]
{
definition,
Vector2.zero,
new Vector2(17f, 7.25f) / 32f * 1.05f,
1f,
0f,
5,
false,
false,
125,
true,
});
Assert.That(hitCount, Is.EqualTo(0));
Assert.That(enemy.CurrentHealth, Is.EqualTo(100f));
Assert.That(definition.ArtifactColor, Is.EqualTo(ArtifactColor.Blue));
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green));
Assert.That(enemy.IsGroggy, Is.False);
Assert.That(enemy.IsDamageInvulnerable, Is.True);
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1));
Assert.That(enemy.BumpVulnerabilityRemaining, Is.EqualTo(0f));
}
finally
{
Object.DestroyImmediate(definition);
Object.DestroyImmediate(enemyObject);
Object.DestroyImmediate(enemyDefinition);
}
}
[Test]
public void CycloneBodyOverlap_PreservesTouchingAndVisibleGapBehavior()
{
EnemyController enemy = CreateEnemyTarget(
"Cyclone Geometry Target",
new Vector2(1.21f, 0f));
CircleCollider2D collider = enemy.GetComponent<CircleCollider2D>();
collider.radius = 0.2f;
Physics2D.SyncTransforms();
MethodInfo overlap = typeof(ActiveArtifactController).GetMethod(
"DoesBodyColliderOverlapEllipse",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(
(bool)overlap.Invoke(
null,
new object[]
{
enemy,
Vector2.zero,
new Vector2(1f, 0.5f),
}),
Is.False,
"A root body with a visible gap must remain outside Cyclone.");
enemy.transform.position = new Vector2(1.2f, 0f);
Physics2D.SyncTransforms();
Assert.That(
(bool)overlap.Invoke(
null,
new object[]
{
enemy,
Vector2.zero,
new Vector2(1f, 0.5f),
}),
Is.True,
"A root body touching the authored ellipse boundary must hit.");
}
[Test]
public void CycloneFollowsAndThunderCrashRemainsFixedAtVisualRoot()
{
MethodInfo createVisual = typeof(ActiveArtifactController).GetMethod(
"CreateVisual",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo destroyVisual = typeof(ActiveArtifactController).GetMethod(
"DestroyVisual",
BindingFlags.Instance | BindingFlags.NonPublic);
Rigidbody2D body = owner.GetComponent<Rigidbody2D>();
body.position = new Vector2(2f, 3f);
GameObject cyclone = (GameObject)createVisual.Invoke(
controller, new object[] { "Cyclone Test", true });
GameObject thunder = (GameObject)createVisual.Invoke(
controller, new object[] { "Thunder Test", false });
try
{
Assert.That(cyclone.transform.parent, Is.EqualTo(owner.transform));
Assert.That(thunder.transform.parent, Is.Null);
Assert.That(thunder.transform.position,
Is.EqualTo(new Vector3(2f, 3f, 0f)));
body.position = new Vector2(4f, 3f);
Assert.That(cyclone.transform.parent, Is.EqualTo(owner.transform));
Assert.That(thunder.transform.position,
Is.EqualTo(new Vector3(2f, 3f, 0f)));
}
finally
{
destroyVisual.Invoke(controller, new object[] { cyclone });
destroyVisual.Invoke(controller, new object[] { thunder });
}
}
[Test]
public void ChainLightningArc_MapsEndpointScaleAndExpiresAfterAuthoredDuration()
{
MethodInfo load = typeof(ActiveArtifactController).GetMethod(
"TryLoadChainLightningArcFrames",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo getFrame = typeof(ActiveArtifactController).GetMethod(
"GetChainLightningArcFrame",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo setProgress = typeof(ActiveArtifactController).GetMethod(
"SetChainLightningArcProgress",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(load.Invoke(controller, null), Is.True);
Assert.That(getFrame.Invoke(controller, new object[] { 0.299f }), Is.Not.Null);
Assert.That(getFrame.Invoke(controller, new object[] { 0.3001f }), Is.Null);
GameObject segment = new("Arc Scale Test");
SpriteRenderer renderer = segment.AddComponent<SpriteRenderer>();
setProgress.Invoke(null, new object[] {
renderer,
new Vector2(1f, 2f),
new Vector2(3f, 2f),
Vector2.zero,
1f,
1.35f,
});
Assert.That(renderer.transform.localPosition, Is.EqualTo(new Vector3(1f, 2f, 0f)));
Assert.That(renderer.transform.localScale.x, Is.EqualTo(2f / (81f / 32f)).Within(0.0001f));
Assert.That(renderer.transform.localScale.y, Is.EqualTo(1.35f).Within(0.0001f));
Object.DestroyImmediate(segment);
}
[Test]
public void PulseRingPalette_VisibleBoundsMatchNormalAndChargedRanges()
{
GameObject visual = new GameObject("Pulse Palette Test");
try
{
SpriteRenderer renderer = visual.AddComponent<SpriteRenderer>();
MethodInfo configure = typeof(ActiveArtifactController).GetMethod(
"ConfigurePulseRingPaletteVisual",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
new[] { typeof(SpriteRenderer), typeof(float), typeof(bool) },
null);
configure.Invoke(controller, new object[] { renderer, 1.25f, false });
Assert.That(renderer.transform.localScale.x, Is.EqualTo(1f));
Assert.That(renderer.transform.localScale.y, Is.EqualTo(1.0f * 32.5f / 33f).Within(0.0001f));
configure.Invoke(controller, new object[] { renderer, 2f, true });
Assert.That(renderer.transform.localScale.x, Is.EqualTo(1f));
Assert.That(renderer.transform.localScale.y, Is.EqualTo(1f).Within(0.0001f));
}
finally
{
Object.DestroyImmediate(visual);
}
}
[Test]
public void PulseRingEllipse_UsesHorizontalRangeAndSpriteAspect()
{
MethodInfo distance = typeof(ActiveArtifactController).GetMethod(
"GetPulseEllipseDistance",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(distance, Is.Not.Null);
float normal = (float)distance.Invoke(
null, new object[] { new Vector2(1.25f, 0f), 1.25f, 0.5078125f });
float normalVertical = (float)distance.Invoke(
null, new object[] { new Vector2(0f, 0.5078125f), 1.25f, 0.5078125f });
float normalOutside = (float)distance.Invoke(
null, new object[] { new Vector2(0f, 0.508f), 1.25f, 0.5078125f });
Assert.That(normal, Is.EqualTo(1f).Within(0.0001f));
Assert.That(normalVertical, Is.EqualTo(1f).Within(0.0001f));
Assert.That(normalOutside, Is.GreaterThan(1f));
float chargedDiagonal = (float)distance.Invoke(
null, new object[] { new Vector2(2f / Mathf.Sqrt(2f),
0.8125f / Mathf.Sqrt(2f)), 2f, 0.8125f });
Assert.That(chargedDiagonal, Is.EqualTo(1f).Within(0.0001f));
}
[Test]
public void PrototypeDefinitions_NormalAndChargedGaugeCostsMatch()
{
string[] paths =
{
"Assets/_Project/Constants/Artifacts/DashArtifact.asset",
"Assets/_Project/Constants/Artifacts/PulseArtifact.asset",
"Assets/_Project/Constants/Artifacts/PhoenixArtifact.asset",
"Assets/_Project/Constants/Artifacts/CycloneArtifact.asset",
"Assets/_Project/Constants/Artifacts/ThunderCrashArtifact.asset",
"Assets/_Project/Constants/Artifacts/ChainLightningArtifact.asset",
};
float[] expectedCosts = { 35f, 25f, 25f, 30f, 35f, 30f };
for (int i = 0; i < paths.Length; i++)
{
string path = paths[i];
ActiveArtifactDefinition definition =
AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>(path);
Assert.That(definition, Is.Not.Null, path);
Assert.That(
definition.ChargedGaugeCost,
Is.EqualTo(definition.NormalGaugeCost),
definition.DisplayName);
Assert.That(
definition.NormalGaugeCost,
Is.EqualTo(expectedCosts[i]),
definition.DisplayName);
}
}
[Test]
public void ChainLightning_InitialTargetIsNearestRegardlessOfDirection()
{
EnemyController nearerBehind = CreateEnemyTarget(
"Nearer Behind",
new Vector2(-1f, 0f));
CreateEnemyTarget("Farther Ahead", new Vector2(2f, 0f));
Physics2D.SyncTransforms();
MethodInfo findInitialTarget = typeof(ActiveArtifactController)
.GetMethod(
"FindInitialChainTarget",
BindingFlags.Instance | BindingFlags.NonPublic);
EnemyController selected = (EnemyController)findInitialTarget.Invoke(
controller,
new object[] { Vector2.zero, 5f });
Assert.That(selected, Is.SameAs(nearerBehind));
}
[Test]
public void ChainLightningTargetSearch_UsesBodyOriginWhenTransformDiffers()
{
Rigidbody2D body = owner.GetComponent<Rigidbody2D>();
EnemyController bodySideTarget = null;
EnemyController transformSideTarget = null;
try
{
bodySideTarget = CreateEnemyTarget(
"Body Origin Target",
new Vector2(2.5f, 0f));
transformSideTarget = CreateEnemyTarget(
"Transform Origin Target",
new Vector2(-1.5f, 0f));
bodySideTarget.transform.SetParent(null);
transformSideTarget.transform.SetParent(null);
Physics2D.SyncTransforms();
owner.transform.position = new Vector2(-2f, 0f);
body.position = new Vector2(2f, 0f);
Assert.That(
(Vector2)owner.transform.position,
Is.Not.EqualTo(body.position));
MethodInfo getOrigin = typeof(ActiveArtifactController)
.GetMethod(
"GetCurrentArtifactOrigin",
BindingFlags.Instance | BindingFlags.NonPublic);
MethodInfo findInitialTarget = typeof(ActiveArtifactController)
.GetMethod(
"FindInitialChainTarget",
BindingFlags.Instance | BindingFlags.NonPublic);
Vector2 origin = (Vector2)getOrigin.Invoke(controller, null);
EnemyController selected =
(EnemyController)findInitialTarget.Invoke(
controller,
new object[] { origin, 1f });
Assert.That(origin, Is.EqualTo(body.position));
Assert.That(selected, Is.SameAs(bodySideTarget));
}
finally
{
if (bodySideTarget != null)
{
Object.DestroyImmediate(bodySideTarget.gameObject);
}
if (transformSideTarget != null)
{
Object.DestroyImmediate(transformSideTarget.gameObject);
}
}
}
[Test]
public void ProjectileProgress_UsesNormalizedExponentialAcceleration()
{
float start = ActiveArtifactController.EvaluateProjectileProgress(0f);
float firstQuarter =
ActiveArtifactController.EvaluateProjectileProgress(0.25f);
float midpoint =
ActiveArtifactController.EvaluateProjectileProgress(0.5f);
float thirdQuarter =
ActiveArtifactController.EvaluateProjectileProgress(0.75f);
float end = ActiveArtifactController.EvaluateProjectileProgress(1f);
Assert.That(start, Is.EqualTo(0f).Within(0.0001f));
Assert.That(end, Is.EqualTo(1f).Within(0.0001f));
Assert.That(midpoint, Is.EqualTo(0.2689f).Within(0.001f));
Assert.That(firstQuarter - start, Is.LessThan(midpoint - firstQuarter));
Assert.That(
midpoint - firstQuarter,
Is.LessThan(thirdQuarter - midpoint));
Assert.That(
thirdQuarter - midpoint,
Is.LessThan(end - thirdQuarter));
}
private RunManager CreateRunManager(RunMode mode)
{
bool previousGrantCatalogForTests =
ArtifactRewardController.GrantCatalogForTests;
ArtifactRewardController.GrantCatalogForTests = false;
try
{
runManagerObject = new GameObject("Artifact Charge Run Manager");
RunManager runManager = runManagerObject.AddComponent<RunManager>();
if (RunManager.Instance != runManager)
{
typeof(RunManager)
.GetMethod(
"Awake",
BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(runManager, null);
}
Assert.That(runManager.BeginRun(mode), Is.True);
return runManager;
}
finally
{
ArtifactRewardController.GrantCatalogForTests =
previousGrantCatalogForTests;
}
}
private void InvokeControllerMethod(string methodName)
{
typeof(ActiveArtifactController)
.GetMethod(
methodName,
BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(controller, null);
}
private static void RestoreRunManagerInstance(RunManager runManager)
{
if (runManager == null)
{
runManager = null;
}
typeof(RunManager)
.GetProperty(
nameof(RunManager.Instance),
BindingFlags.Static | BindingFlags.Public)
?.GetSetMethod(true)
?.Invoke(null, new object[] { runManager });
}
private static ActiveArtifactDefinition CreatePulse(
string id,
float normalCost,
float chargedCost,
ActiveArtifactEffect effect = ActiveArtifactEffect.Pulse)
{
ActiveArtifactDefinition definition =
ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
definition.Configure(
id,
id,
"O",
effect,
DamageTag.Collision,
Color.white,
Color.magenta,
normalCost,
chargedCost,
0.8f,
15f,
30f,
1.25f,
2f,
1.5f,
3f);
return definition;
}
private static ActiveArtifactDefinition CreateThunderCrashDefinition(
string id)
{
ActiveArtifactDefinition definition =
ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
definition.Configure(
id,
id,
"TH",
ActiveArtifactEffect.ThunderCrash,
DamageTag.Collision,
Color.white,
Color.yellow,
0f,
0f,
0.9f,
4f,
6f,
1f,
1.5f,
0f,
0f,
normalEffectWidth: 1f,
chargedEffectWidth: 1.5f,
normalEffectDuration: 0.3f,
chargedEffectDuration: 0.55f,
multiHitInterval: 0.13f,
normalTargetLimit: 12,
chargedTargetLimit: 20,
normalInvulnerability: 0.16f,
chargedInvulnerability: 0.24f,
normalBumpVulnerabilityIncrease: 0.25f,
chargedBumpVulnerabilityIncrease: 0.4f,
normalBumpVulnerabilityDuration: 3f,
chargedBumpVulnerabilityDuration: 4f);
return definition;
}
private EnemyController CreateEnemyTarget(
string name,
Vector2 position)
{
GameObject enemy = new(name);
enemy.transform.SetParent(owner.transform);
enemy.transform.position = position;
enemy.AddComponent<CircleCollider2D>();
EnemyController controller = enemy.AddComponent<EnemyController>();
typeof(EnemyController)
.GetMethod("Awake", BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(controller, null);
return controller;
}
private static void AssertEllipseBoundary(
MethodInfo distance,
Vector2 radii)
{
float inside = (float)distance.Invoke(
null, new object[] { new Vector2(radii.x * 0.5f, 0f), radii });
float edge = (float)distance.Invoke(
null, new object[] { new Vector2(radii.x, 0f), radii });
float outside = (float)distance.Invoke(
null, new object[] { new Vector2(radii.x * 1.001f, 0f), radii });
Assert.That(inside, Is.LessThan(1f));
Assert.That(edge, Is.EqualTo(1f).Within(0.0001f));
Assert.That(outside, Is.GreaterThan(1f));
}
private static void AssertVector2Close(Vector2 actual, Vector2 expected)
{
Assert.That(actual.x, Is.EqualTo(expected.x).Within(0.0001f));
Assert.That(actual.y, Is.EqualTo(expected.y).Within(0.0001f));
}
private static void AssertUniformScale(Vector3 actual, float expected)
{
Assert.That(actual.x, Is.EqualTo(expected).Within(0.0001f));
Assert.That(actual.y, Is.EqualTo(expected).Within(0.0001f));
Assert.That(actual.z, Is.EqualTo(expected).Within(0.0001f));
}
private static void DestroyDefinitions(
params ActiveArtifactDefinition[] definitions)
{
foreach (ActiveArtifactDefinition definition in definitions)
{
Object.DestroyImmediate(definition);
}
}
private static EnemyDefinition CreateProtectedEnemyDefinition()
{
EnemyDefinition definition =
ScriptableObject.CreateInstance<EnemyDefinition>();
definition.Configure(
EnemyKind.ArmoredSkeleton,
EnemyAttackShape.Box,
10f,
1f,
1f,
0.5f,
0.2f,
0.5f,
1f,
0f,
1f,
1f,
0f,
0);
definition.ConfigureRole(EnemyRole.Normal);
return definition;
}
private static EnemyController CreateProtectedEnemy(
string name,
EnemyDefinition definition,
out GameObject enemyObject)
{
enemyObject = new GameObject(name);
enemyObject.AddComponent<Rigidbody2D>();
enemyObject.AddComponent<CircleCollider2D>();
enemyObject.AddComponent<SpriteRenderer>();
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
enemy.Configure(definition, null);
EnemyAttack enemyAttack = enemyObject.GetComponent<EnemyAttack>();
BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
enemyAttack.GetType().GetMethod("Awake", flags)?.Invoke(
enemyAttack,
null);
enemy.GetType().GetMethod("Awake", flags)?.Invoke(enemy, null);
return enemy;
}
}
public sealed class ProgressionAndRunFlowTests
{
private static readonly string[] ModifierPaths =
{
"Assets/_Project/Constants/LevelUp/CollisionDamage.asset",
"Assets/_Project/Constants/LevelUp/MoveSpeed.asset",
"Assets/_Project/Constants/LevelUp/MaxHealth.asset",
"Assets/_Project/Constants/LevelUp/ArtifactGaugeGain.asset",
};
[Test]
public void ModifierCatalog_IsDataDrivenAndUsesUniqueSourceIds()
{
HashSet<string> ids = new();
foreach (string path in ModifierPaths)
{
ModifierDefinition definition =
AssetDatabase.LoadAssetAtPath<ModifierDefinition>(path);
Assert.That(definition, Is.Not.Null, path);
Assert.That(ids.Add(definition.ModifierId), Is.True);
Assert.That(definition.DisplayName, Is.Not.Empty);
}
}
[Test]
public void ModifierDefinition_StopsAtConfiguredMaximumStacks()
{
GameObject owner = new("Modifier Test");
ModifierDefinition definition =
ScriptableObject.CreateInstance<ModifierDefinition>();
try
{
PlayerStats stats = owner.AddComponent<PlayerStats>();
definition.Configure(
"test.move-speed",
"Move speed",
CharacterStat.MoveSpeed,
ModifierOperation.Increased,
0.1f,
2);
Assert.That(definition.TryApply(stats, null), Is.True);
Assert.That(definition.TryApply(stats, null), Is.True);
Assert.That(definition.TryApply(stats, null), Is.False);
Assert.That(
stats.GetStackCount("test.move-speed", CharacterStat.MoveSpeed),
Is.EqualTo(2));
}
finally
{
Object.DestroyImmediate(definition);
Object.DestroyImmediate(owner);
}
}
[Test]
public void FormalPlayerPrefab_UsesSixItemCatalogAndThreeColorOwnershipCap()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
"Assets/_Project/Prefabs/Player/Player.prefab");
ActiveArtifactController artifacts =
prefab.GetComponent<ActiveArtifactController>();
Assert.That(artifacts, Is.Not.Null);
Assert.That(artifacts.CatalogCount, Is.EqualTo(6));
Assert.That(artifacts.MaxOwnedArtifacts, Is.EqualTo(3));
Assert.That(artifacts.MaxArtifactsPerColor, Is.EqualTo(1));
Assert.That(artifacts.OwnedArtifactCount, Is.Zero);
Assert.That(GameplayConstants.Current.Artifacts.StartingGauge, Is.Zero);
Assert.That(
GameplayConstants.Current.Artifacts.NormalBumpGaugeGain,
Is.EqualTo(4f));
Assert.That(
GameplayConstants.Current.Artifacts.MovementGaugeGainPerSecond,
Is.EqualTo(2f));
}
[Test]
public void RunTimingAndEnemyTargets_UseFormalProductionValues()
{
Assert.That(
RunManager.GetEventTime(RunTimedEvent.Elite, false),
Is.EqualTo(180f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.MidBoss, false),
Is.EqualTo(600f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.FinalBoss, false),
Is.EqualTo(1200f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.Elite, true),
Is.EqualTo(36f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.MidBoss, true),
Is.EqualTo(120f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.FinalBoss, true),
Is.EqualTo(240f));
Assert.That(
RunManager.GetProgressionTime(60f, true),
Is.EqualTo(300f));
Assert.That(
RunManager.GetProgressionTime(60f, false),
Is.EqualTo(60f));
Assert.That(SpawnDirector.TargetAliveCountAt(0f), Is.EqualTo(6));
Assert.That(SpawnDirector.TargetAliveCountAt(60f), Is.EqualTo(10));
Assert.That(SpawnDirector.TargetAliveCountAt(180f), Is.EqualTo(16));
Assert.That(SpawnDirector.TargetAliveCountAt(300f), Is.EqualTo(24));
Assert.That(SpawnDirector.TargetAliveCountAt(600f), Is.EqualTo(32));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(0f), Is.EqualTo(3));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(60f), Is.EqualTo(6));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(180f), Is.EqualTo(12));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(300f), Is.EqualTo(19));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(600f), Is.EqualTo(27));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(0f), Is.EqualTo(3));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(60f), Is.EqualTo(4));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(180f), Is.EqualTo(4));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(300f), Is.EqualTo(5));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(600f), Is.EqualTo(5));
}
[Test]
public void RunEventEnemies_UseDistinctCombatSequencesAndArtifactRoles()
{
GameObject owner = new("Run Event Tuning Test");
try
{
SpawnDirector director = owner.AddComponent<SpawnDirector>();
RunEventEnemyTuning elite =
director.GetEventEnemyTuning(RunTimedEvent.Elite);
RunEventEnemyTuning midBoss =
director.GetEventEnemyTuning(RunTimedEvent.MidBoss);
RunEventEnemyTuning finalBoss =
director.GetEventEnemyTuning(RunTimedEvent.FinalBoss);
Assert.That(elite.PrefabKind, Is.EqualTo(EnemyKind.ArmoredSkeleton));
Assert.That(elite.AttacksPerSequence, Is.EqualTo(2));
Assert.That(elite.CanBeLaunched, Is.True);
EnemyKind[] expectedKinds =
{
EnemyKind.ArmoredSkeleton,
EnemyKind.Werewolf,
EnemyKind.Werebear,
EnemyKind.ArmoredSkeleton,
EnemyKind.Werewolf,
EnemyKind.Werebear,
};
int[] expectedSequences = { 2, 2, 3, 2, 2, 3 };
for (int ordinal = 0; ordinal < expectedKinds.Length; ordinal++)
{
RunEventEnemyTuning scheduledElite =
director.GetEventEnemyTuning(RunTimedEvent.Elite, ordinal);
Assert.That(scheduledElite.PrefabKind, Is.EqualTo(expectedKinds[ordinal]));
Assert.That(scheduledElite.AttacksPerSequence, Is.EqualTo(expectedSequences[ordinal]));
}
Assert.That(
midBoss.PrefabKind == EnemyKind.GreatswordSkeleton
|| midBoss.PrefabKind == EnemyKind.NecroGolem,
Is.True,
"The mid-boss tuning must select one of the two requested kinds.");
Assert.That(midBoss.AttacksPerSequence, Is.EqualTo(3));
Assert.That(midBoss.CanBeLaunched, Is.False);
Assert.That(
finalBoss.PrefabKind,
Is.EqualTo(EnemyKind.Necromancer));
Assert.That(finalBoss.AttacksPerSequence, Is.GreaterThanOrEqualTo(1));
Assert.That(finalBoss.CanBeLaunched, Is.False);
}
finally
{
Object.DestroyImmediate(owner);
}
}
[Test]
public void PrototypeEnemies_UseRequestedCrowdAndNormalCatalog()
{
string[] crowdNames = { "Bat", "Slime" };
foreach (string crowdName in crowdNames)
{
EnemyDefinition definition =
AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
$"Assets/_Project/Constants/Enemies/{crowdName}.asset");
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
$"Assets/_Project/Prefabs/Enemies/{crowdName}.prefab");
Assert.That(definition, Is.Not.Null, crowdName);
Assert.That(prefab, Is.Not.Null, crowdName);
Assert.That(definition.Role, Is.EqualTo(EnemyRole.Crowd), crowdName);
Assert.That(definition.MaxHealth, Is.GreaterThan(0f), crowdName);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1f), crowdName);
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1f), crowdName);
Assert.That(prefab.GetComponent<Collider2D>().isTrigger, Is.True, crowdName);
}
string[] normalNames = { "Skeleton", "Necrofire", "SkeletonArcher" };
foreach (string normalName in normalNames)
{
EnemyDefinition definition =
AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
$"Assets/_Project/Constants/Enemies/{normalName}.asset");
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
$"Assets/_Project/Prefabs/Enemies/{normalName}.prefab");
Assert.That(definition, Is.Not.Null, normalName);
Assert.That(definition.Role, Is.EqualTo(EnemyRole.Normal), normalName);
Assert.That(prefab, Is.Not.Null, normalName);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f).Within(0.0001f), normalName);
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f).Within(0.0001f), normalName);
Assert.That(prefab.GetComponent<Collider2D>().isTrigger, Is.False, normalName);
}
Assert.That(
AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Lancer.asset"),
Is.Not.Null,
"The legacy Lancer definition remains available for regressions.");
}
[Test]
public void CrowdAttackTokens_AreIndependentAndRateLimited()
{
GameObject managerObject = new("Attack Token Test");
GameObject firstCrowdObject = null;
GameObject secondCrowdObject = null;
GameObject normalObject = null;
EnemyDefinition crowdDefinition = null;
EnemyDefinition normalDefinition = null;
try
{
AttackTokenManager manager =
managerObject.AddComponent<AttackTokenManager>();
crowdDefinition = CreateEnemyDefinition(EnemyRole.Crowd);
normalDefinition = CreateEnemyDefinition(EnemyRole.Normal);
EnemyController firstCrowd = CreateEnemy(
"First Crowd",
crowdDefinition,
out firstCrowdObject);
EnemyController secondCrowd = CreateEnemy(
"Second Crowd",
crowdDefinition,
out secondCrowdObject);
EnemyController normal = CreateEnemy(
"Normal",
normalDefinition,
out normalObject);
Assert.That(manager.TryAcquire(firstCrowd), Is.True);
Assert.That(manager.TryAcquire(normal), Is.True);
Assert.That(manager.TryAcquire(secondCrowd), Is.False);
Assert.That(manager.ActiveCrowdAttackers, Is.EqualTo(1));
manager.Release(firstCrowd);
Assert.That(manager.TryAcquire(secondCrowd), Is.False);
Assert.That(manager.ActiveCrowdAttackers, Is.Zero);
Assert.That(
AttackTokenManager.GetCrowdAttackLimit(179f, 1, 2, 180f),
Is.EqualTo(1));
Assert.That(
AttackTokenManager.GetCrowdAttackLimit(180f, 1, 2, 180f),
Is.EqualTo(2));
}
finally
{
Object.DestroyImmediate(firstCrowdObject);
Object.DestroyImmediate(secondCrowdObject);
Object.DestroyImmediate(normalObject);
Object.DestroyImmediate(crowdDefinition);
Object.DestroyImmediate(normalDefinition);
Object.DestroyImmediate(managerObject);
}
}
private static EnemyDefinition CreateEnemyDefinition(EnemyRole role)
{
EnemyDefinition definition =
ScriptableObject.CreateInstance<EnemyDefinition>();
definition.Configure(
EnemyKind.Skeleton,
EnemyAttackShape.Box,
10f,
1f,
1f,
0.5f,
0.2f,
0.5f,
1f,
0f,
1f,
1f,
0f,
0);
definition.ConfigureRole(role);
return definition;
}
private static EnemyController CreateEnemy(
string name,
EnemyDefinition definition,
out GameObject enemyObject)
{
enemyObject = new GameObject(name);
enemyObject.SetActive(false);
enemyObject.AddComponent<SpriteRenderer>();
enemyObject.AddComponent<Rigidbody2D>();
enemyObject.AddComponent<CircleCollider2D>();
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
enemy.Configure(definition, null);
enemyObject.SetActive(true);
return enemy;
}
}
}