using System.Collections; using System.Linq; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Player; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace BumpCombat.Tests { public sealed class Defense25DCombatFeedbackPlayModeTests { private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; private Defense25DComparison world; private Defense25DComparisonSettings settings; private Defense25DView view; private float previousTimeScale; private float previousBlinkInterval; [UnitySetUp] public IEnumerator SetUp() { previousTimeScale = Time.timeScale; previousBlinkInterval = GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval; Time.timeScale = 1f; settings = Object.Instantiate( AssetDatabase.LoadAssetAtPath(SettingsPath)); Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first."); settings.Enemy = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Enemies/Slime.asset"); settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack"); GameObject viewObject = new("Feedback render camera"); Camera camera = viewObject.AddComponent(); camera.orthographic = true; camera.orthographicSize = 6.5f; camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(.07f, .09f, .11f, 1f); view = viewObject.AddComponent(); world = new GameObject("Combat feedback world under test").AddComponent(); world.Initialize(settings, view); camera.transform.position = new Vector3(0f, 12f, -12f); camera.transform.LookAt(new Vector3(0f, settings.GroundY, 0f)); world.AutoSpawn = false; world.ReadInput = false; world.Player.ResetAt(Vector2.zero); yield return new WaitForFixedUpdate(); } [UnityTearDown] public IEnumerator TearDown() { if (world != null) Object.Destroy(world.gameObject); if (view != null) Object.Destroy(view.gameObject); if (settings != null) Object.Destroy(settings); GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval = previousBlinkInterval; Time.timeScale = previousTimeScale; yield return null; } [UnityTest] public IEnumerator HiddenArtifactContactCreatesOnlyTheAuthoredContactImpact() { Time.timeScale = .37f; Defense25DCombatFeedback feedback = world.CombatFeedback; Defense25DEnemy target = world.Enemies[0]; CombatHitResult contact = CombatHitResult.ForArtifactContact( world.Player.gameObject, target.gameObject, ActiveArtifactEffect.Pulse, false, HitSide.Front, Vector2.right, target.Actor.Position, DamageTag.Collision, "feedback-contact-test"); feedback.NotifyValidHit(contact); Assert.That(feedback.ActiveArtifactImpactCount, Is.EqualTo(1)); Assert.That(Time.timeScale, Is.EqualTo(.37f), "A hidden/zero-damage contact gets its source sprite but must not request hit-stop."); SpriteRenderer impact = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name.StartsWith("Artifact Impact ")); Assert.That(impact, Is.Not.Null); Sprite[] authoredFrames = Resources.LoadAll( CombatFeedback.GetArtifactImpactResourcePath(ActiveArtifactEffect.Pulse, false)); System.Array.Sort(authoredFrames, (a, b) => string.CompareOrdinal(a.name, b.name)); Assert.That(authoredFrames, Has.Length.EqualTo(CombatFeedback.ArtifactImpactFrameCount)); Assert.That(impact.sprite, Is.SameAs(authoredFrames[0])); Assert.That(impact.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); yield return null; } [UnityTest] public IEnumerator PlayerAlpha035UsesTransparentMaterialAndRendersIt() { Defense25DCombatFeedback feedback = world.CombatFeedback; CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback; float blinkInterval = tuning.InvulnerabilityBlinkInterval; SpriteRenderer player = world.Player.Visual.Renderer; Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null); Assert.That(feedback.TransparentEffectMaterial.GetFloat("_Cutoff"), Is.EqualTo(.001f).Within(.0001f)); Assert.That(feedback.TransparentEffectMaterial.GetColor("_BaseColor"), Is.EqualTo(Color.white)); feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right, GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold); // With the authored blink interval temporarily disabled, the same hurt/flash scene // is the opaque capture baseline. Re-enabling it produces the source alpha-.35 phase. tuning.InvulnerabilityBlinkInterval = 0f; feedback.TickFeedback(0f, 0f); yield return null; Color[] opaqueCapture = CaptureView(); tuning.InvulnerabilityBlinkInterval = blinkInterval; feedback.TickFeedback(0f, 0f); yield return null; Assert.That(player.color.a, Is.EqualTo(.35f).Within(.001f)); Assert.That(player.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial), "The alpha test must select the unclipped, cloned transparent effect material."); Color[] fadedCapture = CaptureView(); int changedPixels = 0; for (int i = 0; i < opaqueCapture.Length; i++) { Color a = opaqueCapture[i]; Color b = fadedCapture[i]; if (Mathf.Abs(a.r - b.r) + Mathf.Abs(a.g - b.g) + Mathf.Abs(a.b - b.b) > .08f) changedPixels++; } Assert.That(changedPixels, Is.GreaterThan(0), "A camera render capture must show the player changing at alpha .35; cutout clipping is not acceptable."); tuning.InvulnerabilityBlinkInterval = blinkInterval; feedback.ResetFeedback(); yield return null; } [UnityTest] public IEnumerator HurtUsesSourceKnockbackDirectionForImpactOriginAndOwnsHitStop() { Defense25DCombatFeedback feedback = world.CombatFeedback; Vector2 playerPosition = world.Player.Position; Vector2 knockbackDirection = new(.6f, .8f); Vector3 worldPosition = world.Player.transform.position; float heavyThreshold = GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold; feedback.NotifyPlayerHurt(worldPosition, knockbackDirection, heavyThreshold); SpriteRenderer impact = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name == "Player Hurt Impact"); Assert.That(impact, Is.Not.Null); Vector2 expectedOrigin = playerPosition - knockbackDirection.normalized * .18f; Assert.That(impact.transform.position.x, Is.EqualTo(expectedOrigin.x).Within(.001f)); Assert.That(impact.transform.position.z, Is.EqualTo(expectedOrigin.y).Within(.001f)); Assert.That(feedback.ActivePlayerHurtImpactCount, Is.EqualTo(1)); Assert.That(Time.timeScale, Is.Zero); feedback.ResetFeedback(); Assert.That(Time.timeScale, Is.EqualTo(1f), "Reset releases only the hit-stop owned by this feedback component."); yield return null; } [UnityTest] public IEnumerator ResetDoesNotChangeAnExternalPauseAndClearsOwnedVisuals() { Defense25DCombatFeedback feedback = world.CombatFeedback; Time.timeScale = 0f; feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right, 0f); feedback.ResetFeedback(); Assert.That(Time.timeScale, Is.Zero, "Reset must not claim or restore a time scale that was already externally paused."); Assert.That(feedback.ActiveEffectCount, Is.Zero); Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null, "Reset keeps the bound world's cloned material for the next event."); yield return null; } [UnityTest] public IEnumerator ChargeReadyFlashSwitchAndReleaseUseTheirAuthoredSheets() { Defense25DCombatFeedback feedback = world.CombatFeedback; ActiveArtifactDefinition artifact = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse); Assert.That(artifact, Is.Not.Null); Assert.That(world.Loadout.TryAddArtifact(artifact), Is.True); feedback.NotifyChargeChanged(artifact, .5f, false); Assert.That(feedback.IsChargingVisualVisible, Is.True); feedback.NotifyChargeChanged(artifact, 1f, true); Assert.That(feedback.IsReadyFlashVisible, Is.True, "The four-frame Flash sheet is validated against FlashFrameCount, not the six-frame aura count."); Texture2D flashSheet = Resources.Load( Defense25DCombatFeedbackConstants.ChargeResourceRoot + artifact.Effect + Defense25DCombatFeedbackConstants.ChargeFlashSuffix); Assert.That(flashSheet, Is.Not.Null); Assert.That(flashSheet.width, Is.EqualTo(ArtifactChargeVisual.CellSize * ArtifactChargeVisual.FlashFrameCount)); Assert.That(flashSheet.height, Is.EqualTo(ArtifactChargeVisual.CellSize)); SpriteRenderer flash = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name == "Artifact Charge " + Defense25DCombatFeedbackConstants.ChargeFlashSuffix); Assert.That(flash, Is.Not.Null); Assert.That(flash.sprite.rect.width, Is.EqualTo(ArtifactChargeVisual.CellSize)); feedback.NotifyArtifactSwitched(artifact); SpriteRenderer switchPulse = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name.Contains("Artifact Charge ") && renderer.gameObject.name.Contains(Defense25DCombatFeedbackConstants.ChargeReleaseSuffix)); Assert.That(switchPulse, Is.Not.Null); Assert.That(switchPulse.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); feedback.NotifyArtifactReleased(artifact, world.Player.Position, true); SpriteRenderer release = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name == "Artifact Release"); Assert.That(release, Is.Not.Null); Assert.That(feedback.ActiveEffectCount, Is.GreaterThan(0)); feedback.ResetFeedback(); Assert.That(feedback.ActiveEffectCount, Is.Zero); yield return null; } [UnityTest] public IEnumerator GuardBlockedBumpAndTowerFeedbackUseOriginalSprites() { Defense25DCombatFeedback feedback = world.CombatFeedback; Defense25DEnemy target = world.Enemies[0]; feedback.NotifyGuardImpact(Vector2.left); feedback.NotifyBlockedBump(target.Actor.Position, Vector2.right); feedback.NotifyTowerImpact(DefenseTowerKind.Direct, target, target.Actor.Position); SpriteRenderer guard = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name == "Player Guard Block Impact"); SpriteRenderer blocked = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name == "Blocked Bump"); SpriteRenderer tower = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(renderer => renderer.gameObject.name == "Tower Impact Direct"); Assert.That(guard, Is.Not.Null); Assert.That(blocked, Is.Not.Null); Assert.That(blocked.flipX, Is.False); Assert.That(tower, Is.Not.Null); Assert.That(guard.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); Assert.That(blocked.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); Assert.That(tower.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); Sprite[] towerFrames = Resources.LoadAll(CombatFeedback.GetTowerImpactResourcePath(DefenseTowerKind.Direct)); Assert.That(towerFrames, Has.Length.EqualTo(CombatFeedback.BumpImpactFrameCount)); Assert.That(tower.sprite, Is.SameAs(towerFrames[0])); feedback.ResetFeedback(); Assert.That(feedback.ActiveEffectCount, Is.Zero); yield return null; } [UnityTest] public IEnumerator ShieldPulseAndEnemyDustEffectsRemainWithinHeavyEffectCap() { Defense25DCombatFeedback feedback = world.CombatFeedback; Defense25DEnemy target = world.Enemies[0]; Vector2 start = target.Actor.Position; Vector2 finish = start + Vector2.right * .65f; feedback.NotifyShield(.2f); feedback.NotifyPulseGather(target, start, finish); feedback.NotifyKnockbackCompleted(target.Actor, start, finish); for (int i = 0; i < CombatFeedback.MaxConcurrentHeavyEffects + 4; i++) feedback.NotifyShield(.2f); Assert.That(feedback.ActiveHeavyEffectCount, Is.LessThanOrEqualTo(CombatFeedback.MaxConcurrentHeavyEffects)); Assert.That(Object.FindObjectsByType(FindObjectsSortMode.None) .Any(line => line.gameObject.name == "Interaction Ring"), Is.True); Assert.That(Object.FindObjectsByType(FindObjectsSortMode.None) .Any(renderer => renderer.gameObject.name == "Pulse Gather Afterimage"), Is.True); feedback.ResetFeedback(); Assert.That(feedback.ActiveHeavyEffectCount, Is.Zero); yield return null; } [UnityTest] public IEnumerator ShieldRingStartsAndFollowsThePlayerVisualGroundAnchor() { Defense25DCombatFeedback feedback = world.CombatFeedback; world.SetPaused(false); Vector3 foot = world.Player.Visual.FootPosition; Vector2 initialAnchor = new(foot.x, foot.z); Assert.That(Vector2.Distance(world.Player.Position, initialAnchor), Is.GreaterThan(.01f), "Fixture must retain the player's authored foot-to-capsule offset so the old body anchor would fail."); feedback.NotifyShield(.5f); LineRenderer ring = Object.FindObjectsByType(FindObjectsSortMode.None) .FirstOrDefault(line => line.gameObject.name == "Interaction Ring"); Assert.That(ring, Is.Not.Null); Assert.That(ring.transform.position.x, Is.EqualTo(initialAnchor.x).Within(.001f)); Assert.That(ring.transform.position.z, Is.EqualTo(initialAnchor.y).Within(.001f)); Vector2 movedPosition = world.Player.Position + new Vector2(1.1f, -.65f); world.Player.ResetAt(movedPosition); Physics.SyncTransforms(); yield return null; yield return null; Vector3 movedFoot = world.Player.Visual.FootPosition; feedback.TickFeedback(.016f, .016f); Assert.That(ring.transform.position.x, Is.EqualTo(movedFoot.x).Within(.001f)); Assert.That(ring.transform.position.z, Is.EqualTo(movedFoot.z).Within(.001f)); Vector2 ringGround = Defense25DActor.Ground(ring.transform.position); Assert.That(Vector2.Distance(ringGround, world.Player.Position), Is.GreaterThan(.01f)); yield return null; } private Color[] CaptureView() { RenderTexture previous = RenderTexture.active; Texture2D capture = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGBA32, false); view.Camera.Render(); RenderTexture.active = view.Texture; capture.ReadPixels(new Rect(0f, 0f, Defense25DView.Width, Defense25DView.Height), 0, 0); capture.Apply(); Color[] pixels = capture.GetPixels(); RenderTexture.active = previous; Object.Destroy(capture); return pixels; } private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack) { Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( $"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png") .OfType() .OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0) .ToArray(); return new ComparisonSpriteFrames { Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack), Hurt = Read("Hurt"), Death = Read("Death"), }; } } }