using System.Linq; using System.Reflection; using BumpCombat.Enemies; using NUnit.Framework; using UnityEditor; using UnityEditor.Animations; using UnityEngine; namespace BumpCombat.Tests { public sealed class SummonAnimationContractTests { [TestCase(false, 1.25f)] [TestCase(true, 1.875f)] public void SummonCircle_FollowsShadowCenterAcrossFramesAndMovement(bool flip, float scale) { var sprites = AssetDatabase.LoadAllAssetsAtPath( "Assets/_Project/Art/Characters/Necromancer/Necromancer_Summon-shadow-v2.png") .OfType().ToArray(); Assert.That(sprites.Length, Is.EqualTo(10)); var owner = new GameObject("Summon anchor test"); StageEnemyEffectVisual effect = null; try { var body = owner.AddComponent(); body.sprite = sprites[0]; body.flipX = flip; owner.transform.localScale = new Vector3(scale, scale, 1f); effect = StageEnemyEffectVisual.PlaySummon(owner, owner.transform.position, 1.25f); var lateUpdate = typeof(StageEnemyEffectVisual).GetMethod("LateUpdate", BindingFlags.NonPublic | BindingFlags.Instance); for (int i = 0; i < sprites.Length; i++) { body.sprite = sprites[i]; owner.transform.position = new Vector3(i * .3f, i * -.2f, 0f); lateUpdate.Invoke(effect, null); // Measured source shadow center, relative to the imported 50px pivot. Vector3 expected = owner.transform.position + new Vector3((flip ? -.5f : .5f) / 32f, -7.5f / 32f) * scale; Assert.That(Vector3.Distance(effect.transform.position, expected), Is.LessThan(.0001f)); Assert.That(effect.transform.localScale, Is.EqualTo(Vector3.one)); } } finally { if (effect != null) Object.DestroyImmediate(effect.gameObject); Object.DestroyImmediate(owner); } } [TestCase("Skeleton", 5)] [TestCase("GreatswordSkeleton", 5)] [TestCase("ArmoredSkeleton", 5)] [TestCase("SkeletonArcher", 5)] [TestCase("Necromancer", 10)] [TestCase("Bat", 4)] [TestCase("Slime", 4)] [TestCase("Necrofire", 6)] [TestCase("Werewolf", 4)] [TestCase("Werebear", 4)] [TestCase("NecroGolem", 6)] public void Summon_PreservesEveryAuthoredFrameAndCannotExitEarly(string name, int frames) { var prefab = AssetDatabase.LoadAssetAtPath( $"Assets/_Project/Prefabs/Enemies/{name}.prefab"); var controller = prefab.GetComponent().runtimeAnimatorController as AnimatorController; Assert.That(controller, Is.Not.Null); var summon = controller.layers[0].stateMachine.states .Select(child => child.state).Single(state => state.name == "Summon"); var clip = summon.motion as AnimationClip; Assert.That(clip, Is.Not.Null); Assert.That(clip.frameRate, Is.EqualTo(8f)); Assert.That(clip.length, Is.EqualTo(frames / 8f).Within(.0001f)); Assert.That(clip.isLooping, Is.False); var binding = AnimationUtility.GetObjectReferenceCurveBindings(clip) .Single(b => b.type == typeof(SpriteRenderer) && b.propertyName == "m_Sprite"); var keys = AnimationUtility.GetObjectReferenceCurve(clip, binding); Assert.That(keys.Length, Is.EqualTo(frames)); for (int i = 0; i < keys.Length; i++) { Assert.That(keys[i].value, Is.Not.Null); Assert.That(keys[i].time, Is.EqualTo(i / 8f).Within(.0001f)); var texturePath = AssetDatabase.GetAssetPath(keys[i].value); var importer = AssetImporter.GetAtPath(texturePath) as TextureImporter; Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point)); Assert.That(importer.spritePixelsPerUnit, Is.EqualTo(32f)); if (name == "GreatswordSkeleton" || name == "ArmoredSkeleton") { var sprite = (Sprite)keys[i].value; var masks = Resources.LoadAll( "Enemies/Protection-v2/" + sprite.texture.name + "-outline"); Assert.That(masks.Any(mask => mask.rect == sprite.rect), Is.True, "Protected summons must retain their outline during every rise frame."); } } Assert.That(summon.transitions, Is.Not.Empty); foreach (var transition in summon.transitions) { Assert.That(transition.hasExitTime, Is.True); Assert.That(transition.exitTime, Is.EqualTo(1f)); Assert.That(transition.duration, Is.Zero); // Attack exits are deliberately left independent of the summon clock. foreach (var child in controller.layers[0].stateMachine.states) if (child.state != summon) Assert.That(child.state.transitions.Contains(transition), Is.False); } } [TestCase("Bat")] [TestCase("Slime")] [TestCase("Necrofire")] [TestCase("Werewolf")] [TestCase("Werebear")] [TestCase("NecroGolem")] public void DerivedRise_ReusesOriginalDeathSpritesInReverse(string name) { var prefab = AssetDatabase.LoadAssetAtPath( $"Assets/_Project/Prefabs/Enemies/{name}.prefab"); var controller = prefab.GetComponent().runtimeAnimatorController as AnimatorController; var states = controller.layers[0].stateMachine.states; var death = (AnimationClip)states.Single(s => s.state.name == "Death").state.motion; var rise = (AnimationClip)states.Single(s => s.state.name == "Summon").state.motion; var deathBinding = AnimationUtility.GetObjectReferenceCurveBindings(death).Single(); var riseBinding = AnimationUtility.GetObjectReferenceCurveBindings(rise).Single(); var deathFrames = AnimationUtility.GetObjectReferenceCurve(death, deathBinding); var riseFrames = AnimationUtility.GetObjectReferenceCurve(rise, riseBinding); Assert.That(riseFrames.Select(f => f.value), Is.EqualTo(deathFrames.Reverse().Select(f => f.value))); } } }