using BumpCombat.Combat; using BumpCombat.Enemies; using NUnit.Framework; using UnityEditor; using UnityEditor.Animations; using UnityEngine; namespace BumpCombat.Tests.EditMode { public sealed class InteractionFeedbackTests { [TestCase("Slime/Slime_Idle", 46)] [TestCase("Bat/Bat_Idle-shadow-v2", 40)] [TestCase("ArmoredSkeleton/ArmoredSkeleton_Idle-shadow-v2", 39)] [TestCase("NecroGolem/NecroGolem_Idle-shadow-v2", 29)] public void CrowdControlHeadAnchor_IgnoresHiddenRendererAndTracksScaledHead(string asset, int top) { var texture = AssetDatabase.LoadAssetAtPath("Assets/_Project/Art/Characters/" + asset + ".png"); Assert.That(texture, Is.Not.Null); var sprite = Sprite.Create(texture, new Rect(0, 0, 100, 100), new Vector2(.5f, .5f), 32f); var target = new GameObject("CC Head Test"); var renderer = target.AddComponent(); renderer.sprite = sprite; renderer.enabled = false; foreach (float scale in new[] { 1f, 1.25f, 2f }) { target.transform.position = new Vector3(3f, -2f, 0f); target.transform.localScale = Vector3.one * scale; Vector3 actual = target.transform.TransformPoint(CombatFeedback.CalculateStunMarkerLocalPosition(target.transform, renderer)); Assert.That(actual.x, Is.EqualTo(3f).Within(.0001f)); Assert.That(actual.y, Is.EqualTo(-2f + (50f - top) / 32f * scale + (4f + 5f * scale) / 32f).Within(.0001f)); } Object.DestroyImmediate(target); Object.DestroyImmediate(sprite); } [Test] public void CrowdControlSpiral_UsesDistinctPalettesAndLoopsEightFrames() { Sprite first = CrowdControlMarkerArt.Frame(0f, false); Assert.That(first, Is.Not.Null); Assert.That(first.rect.size, Is.EqualTo(new Vector2(32f, 24f))); Assert.That(first.texture.filterMode, Is.EqualTo(FilterMode.Point)); Assert.That(CrowdControlMarkerArt.Frame(.07f, false).rect.x, Is.EqualTo(32f)); Assert.That(CrowdControlMarkerArt.Frame(.481f, false), Is.SameAs(first)); Assert.That(CrowdControlMarkerArt.Frame(0f, true).texture, Is.Not.SameAs(first.texture)); } [Test] public void StrongDashLaunchResult_IsDistinctFromStrongDashIntent() { CombatHitResult resisted = new( null, null, true, HitSide.Front, 1f, Vector2.right, 1f, Vector2.zero, false, true, DamageTag.Collision, "Dash", true, true, false); CombatHitResult launched = new( null, null, true, HitSide.Front, 1f, Vector2.right, 1f, Vector2.zero, false, true, DamageTag.Collision, "Dash", true, true, true); Assert.That(resisted.IsStrongDash, Is.True); Assert.That(resisted.LaunchSucceeded, Is.False); Assert.That(launched.IsStrongDash, Is.True); Assert.That(launched.LaunchSucceeded, Is.True); } [TestCase(0.09f, false)] [TestCase(0.1f, true)] [TestCase(0.5f, true)] public void PulseGatherDistance_UsesMinimumStreakThreshold(float distance, bool expectedStreak) { Assert.That(CombatFeedback.ShouldShowPulseStreak(distance), Is.EqualTo(expectedStreak)); } [TestCase(0.49f, false)] [TestCase(0.5f, true)] public void KnockbackDistance_UsesMinimumDustThreshold(float distance, bool expectedDust) { Assert.That(CombatFeedback.ShouldShowKnockbackDust(distance), Is.EqualTo(expectedDust)); } [TestCase(true, false, true)] [TestCase(true, true, false)] [TestCase(false, false, false)] public void LaunchResult_SeparatesResistFromDeath( bool canBeLaunched, bool isDead, bool expectedLaunch) { Assert.That( BumpCombatResolver.CanLaunchAfterHit(canBeLaunched, isDead), Is.EqualTo(expectedLaunch)); Assert.That( BumpCombatResolver.ShouldShowLaunchResist(canBeLaunched), Is.EqualTo(!canBeLaunched)); } [TestCase(false, true, false, true)] [TestCase(true, true, false, false)] [TestCase(false, false, false, false)] [TestCase(false, true, true, false)] public void LineTransientRecycle_RejectsPendingOrDuplicateEntries( bool destroyPending, bool activeRegistrationRemoved, bool alreadyPooled, bool expected) { Assert.That( CombatFeedback.CanRecycleLineTransient( destroyPending, activeRegistrationRemoved, alreadyPooled), Is.EqualTo(expected)); Assert.That( CombatFeedback.MaxConcurrentHeavyEffects, Is.EqualTo(12)); } [Test] public void WorldStreakRenderer_ResetsRingLoopState() { GameObject host = new("Line Renderer State Test"); LineRenderer line = host.AddComponent(); line.loop = true; line.useWorldSpace = false; CombatFeedback.ConfigureWorldStreakRenderer( line, null, Color.white, 0.04f, Vector2.zero, Vector2.right); Assert.That(line.loop, Is.False); Assert.That(line.useWorldSpace, Is.True); Assert.That(line.positionCount, Is.EqualTo(2)); Object.DestroyImmediate(host); } [TestCase(false, false, true, true)] [TestCase(false, true, true, false)] [TestCase(false, false, false, false)] [TestCase(true, false, true, false)] public void LineTransientAcquire_RejectsPendingOrInvalidEntries( bool unityNull, bool destroyPending, bool hasLineRenderer, bool expected) { Assert.That( CombatFeedback.CanAcquireLineTransient( unityNull, destroyPending, hasLineRenderer), Is.EqualTo(expected)); } [Test] public void StunMarkerAnchor_FallbackKeepsClearanceAndScalesWithCharacter() { GameObject target = new("Stun Marker Anchor Test"); SpriteRenderer targetRenderer = target.AddComponent(); Sprite sprite = Sprite.Create( Texture2D.whiteTexture, new Rect(0f, 0f, 1f, 1f), new Vector2(0.5f, 0.5f), 32f); targetRenderer.sprite = sprite; Vector3 runeLocalScale = new(0.11f, 0.035f, 1f); foreach (float targetScale in new[] { 0.8f, 1f, 1.25f, 1.6f, 2f, 2.5f, 3.5f }) { Vector3 expectedWorldScale = Vector3.Scale(new Vector3(.5f * targetScale, .5f * targetScale, 1f), runeLocalScale); target.transform.localScale = Vector3.one * targetScale; Vector3 localAnchor = CombatFeedback.CalculateStunMarkerLocalPosition( target.transform, targetRenderer); Vector3 worldAnchor = target.transform.TransformPoint(localAnchor); Assert.That(worldAnchor.x, Is.EqualTo(target.transform.position.x).Within(0.0001f)); Assert.That( worldAnchor.y, Is.EqualTo(targetRenderer.bounds.max.y + (4f + 5f * targetScale) / 32f).Within(0.0001f)); Vector3 baseLocalScale = CombatFeedback.CalculateStunMarkerBaseLocalScale( target.transform.lossyScale); Vector3 worldScale = Vector3.Scale( target.transform.lossyScale, Vector3.Scale(baseLocalScale, runeLocalScale)); Assert.That(worldScale.x, Is.EqualTo(expectedWorldScale.x).Within(0.0001f)); Assert.That(worldScale.y, Is.EqualTo(expectedWorldScale.y).Within(0.0001f)); } Object.DestroyImmediate(sprite); Object.DestroyImmediate(target); } [TestCase(485)] [TestCase(1000)] [TestCase(1515)] public void StunMarkerSortingOrder_StaysAheadOfTarget(int targetSortingOrder) { Assert.That( CombatFeedback.CalculateStunMarkerSortingOrder(targetSortingOrder), Is.GreaterThan(targetSortingOrder)); } [Test] public void BumpImpactGrades_UseHitSideAndFixedFrameTiming() { Assert.That( CombatFeedback.GetBumpImpactResourcePath(HitSide.Front), Is.EqualTo("Combat/BumpImpact-Weak-v1")); Assert.That( CombatFeedback.GetBumpImpactResourcePath(HitSide.Side), Is.EqualTo("Combat/BumpImpact-Medium-v1")); Assert.That( CombatFeedback.GetBumpImpactResourcePath(HitSide.Back), Is.EqualTo("Combat/BumpImpact-Strong-v1")); Assert.That( CombatFeedback.GetBumpImpactFrameDuration(0), Is.EqualTo(0.03f)); Assert.That( CombatFeedback.GetBumpImpactFrameDuration(1), Is.EqualTo(0.04f)); Assert.That( CombatFeedback.GetBumpImpactFrameDuration(2), Is.EqualTo(0.04f)); Assert.That( CombatFeedback.GetBumpImpactFrameDuration(3), Is.EqualTo(0.03f)); } [Test] public void BumpImpactResources_ContainFourCenteredWhitePixelFrames() { foreach (HitSide side in System.Enum.GetValues(typeof(HitSide))) { Sprite[] frames = Resources.LoadAll( CombatFeedback.GetBumpImpactResourcePath(side)); System.Array.Sort( frames, (left, right) => System.String.CompareOrdinal(left.name, right.name)); Assert.That(frames, Has.Length.EqualTo(CombatFeedback.BumpImpactFrameCount)); for (int i = 0; i < frames.Length; i++) { Assert.That(frames[i].name, Does.EndWith($"_{i}")); Assert.That(frames[i].rect.width, Is.EqualTo(64f)); Assert.That(frames[i].rect.height, Is.EqualTo(64f)); Assert.That(frames[i].pixelsPerUnit, Is.EqualTo(32f)); Assert.That(frames[i].pivot.x, Is.EqualTo(32f)); Assert.That(frames[i].pivot.y, Is.EqualTo(32f)); Assert.That(frames[i].texture, Is.Not.Null); } } } [Test] public void SwordsmanController_WiresCombatStatesToExpectedClipLengths() { const string controllerPath = "Assets/_Project/Art/Characters/Swordsman/Animations/Swordsman.controller"; AnimatorController controller = AssetDatabase.LoadAssetAtPath(controllerPath); Assert.That(controller, Is.Not.Null); AnimatorState bumpAttack = FindState(controller, "BumpAttack"); AnimatorState artifactUse = FindState(controller, "ArtifactUse"); Assert.That(bumpAttack, Is.Not.Null); Assert.That(artifactUse, Is.Not.Null); Assert.That((bumpAttack.motion as AnimationClip).length, Is.EqualTo(0.24f).Within(0.0001f)); Assert.That((artifactUse.motion as AnimationClip).length, Is.EqualTo(0.4f).Within(0.0001f)); Assert.That(bumpAttack.transitions, Has.Length.EqualTo(2)); Assert.That(artifactUse.transitions, Has.Length.EqualTo(2)); foreach (AnimatorStateTransition transition in bumpAttack.transitions) { Assert.That(transition.hasExitTime, Is.True); Assert.That(transition.exitTime, Is.EqualTo(1f)); } foreach (AnimatorStateTransition transition in artifactUse.transitions) { Assert.That(transition.hasExitTime, Is.True); Assert.That(transition.exitTime, Is.EqualTo(1f)); } foreach (AnimatorControllerParameter parameter in controller.parameters) { Assert.That(parameter.name, Is.Not.EqualTo("BumpAttack")); Assert.That(parameter.name, Is.Not.EqualTo("ArtifactUse")); } } private static AnimatorState FindState( AnimatorController controller, string stateName) { foreach (ChildAnimatorState child in controller.layers[0].stateMachine.states) { if (child.state.name == stateName) { return child.state; } } return null; } } }