using System; using System.Collections; using System.Linq; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Constants; 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 Defense25DCombatPopupPlayModeTests { private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; private Defense25DComparison world; private Defense25DComparisonSettings settings; private float previousTimeScale; [UnitySetUp] public IEnumerator SetUp() { previousTimeScale = Time.timeScale; Time.timeScale = 1f; settings = Object.Instantiate( AssetDatabase.LoadAssetAtPath(SettingsPath)); Assert.That(settings, Is.Not.Null); settings.Enemy = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Enemies/Slime.asset"); settings.EnemyArt = ReadCharacter(); world = new GameObject("2.5D combat popup test world") .AddComponent(); world.Initialize(settings); world.AutoSpawn = false; world.ReadInput = false; yield return null; } [UnityTearDown] public IEnumerator TearDown() { if (world != null) Object.Destroy(world.gameObject); if (settings != null) Object.Destroy(settings); Time.timeScale = previousTimeScale; yield return null; } [UnityTest] public IEnumerator ValidHitPopups_MatchNormalSideBackAndArtifactContracts() { GameObject target = world.Enemies[0].gameObject; RaiseHit(target, HitSide.Front, 31.9f); AssertPopup(0, "31", Color.white); world.ResetRun(); target = world.Enemies[0].gameObject; RaiseHit(target, HitSide.Side, 22.8f); AssertPopup(0, "SIDE ×1.2 22", new Color(0.65f, 1f, 0.3f)); world.ResetRun(); target = world.Enemies[0].gameObject; RaiseHit(target, HitSide.Back, 9.8f); AssertPopup(0, "BACK ×1.5 9", new Color(1f, 0.75f, 0.2f)); world.ResetRun(); target = world.Enemies[0].gameObject; CombatEvents.RaiseValidHit(new CombatHitResult( world.Player.gameObject, target, true, HitSide.Front, 13f, Vector2.right, 0f, Vector2.zero, launchSucceeded: true)); AssertPopup(0, "LAUNCH 13", new Color(0.95f, 0.55f, 1f)); world.ResetRun(); target = world.Enemies[0].gameObject; ActiveArtifactDefinition dash = world.Loadout.FindArtifact(ActiveArtifactEffect.Dash); Assert.That(dash, Is.Not.Null); CombatEvents.RaiseValidHit(new CombatHitResult( world.Player.gameObject, target, true, HitSide.Front, 15f, Vector2.right, 0f, Vector2.zero, sourceId: dash.ArtifactId, isArtifactHit: true, artifactEffect: ActiveArtifactEffect.Dash)); AssertPopup(0, $"{dash.DisplayName} 15", ActiveArtifactDefinition.GetArtifactPaletteColor(dash.ArtifactColor)); world.ResetRun(); target = world.Enemies[0].gameObject; ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone); Assert.That(cyclone, Is.Not.Null); CombatEvents.RaiseValidHit(CombatHitResult.ForArtifactContact( world.Player.gameObject, target, ActiveArtifactEffect.Cyclone, false, HitSide.Front, Vector2.right, Vector2.zero, cyclone.DamageTag, cyclone.ArtifactId)); AssertPopup(0, $"{cyclone.DisplayName} 0", ActiveArtifactDefinition.GetArtifactPaletteColor(cyclone.ArtifactColor)); yield return null; } [UnityTest] public IEnumerator PopupEvents_AreWorldScopedResettableAndRemovedOnDisable() { GameObject worldTarget = world.Enemies[0].gameObject; GameObject unrelatedTarget = new("Unrelated popup target"); GameObject unrelatedAttacker = new("Unrelated popup attacker"); try { RaiseHit(unrelatedTarget, HitSide.Front, 10f); Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero); CombatEvents.RaiseValidHit(new CombatHitResult( unrelatedAttacker, worldTarget, false, HitSide.Front, 10f, Vector2.right, 0f, Vector2.zero)); Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero, "Hits from another attacker against this world's enemy are ignored."); RaiseHit(worldTarget, HitSide.Front, 10f); Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1)); world.ResetRun(); Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero); world.enabled = false; RaiseHit(worldTarget, HitSide.Front, 10f); world.NotifyPlayerDamagePopup(10f, Vector3.zero); world.RecordTowerDamagePopup(10f, Color.cyan, Vector3.one); world.enabled = true; Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero, "A disabled comparison must reject event and direct popup additions."); RaiseHit(world.Enemies[0].gameObject, HitSide.Front, 10f); Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1), "Re-enabling the comparison must restore one event subscription."); } finally { Object.Destroy(unrelatedTarget); Object.Destroy(unrelatedAttacker); } yield return null; } [UnityTest] public IEnumerator PlayerAndTowerDamagePopups_ShareTwelveReusableUnscaledSlots() { for (int i = 0; i < 13; i++) world.NotifyPlayerDamagePopup(12.9f, Vector3.zero); Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(12)); AssertPopup(0, "-12", new Color(244f / 255f, 117f / 255f, 112f / 255f)); world.RecordTowerDamagePopup(8.9f, Color.cyan, Vector3.one); Assert.That(world.ActiveTowerDamagePopupCount, Is.Zero, "An active-set overflow must be rejected for towers as well."); Time.timeScale = 0f; yield return new WaitForSecondsRealtime(0.65f); Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero, "Popup expiry uses unscaled time, including while paused."); world.NotifyPlayerDamagePopup(14.5f, Vector3.zero); world.RecordTowerDamagePopup(8.9f, Color.cyan, Vector3.one); Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(2)); Assert.That(world.ActiveTowerDamagePopupCount, Is.EqualTo(1)); AssertPopup(0, "-14", new Color(244f / 255f, 117f / 255f, 112f / 255f)); AssertPopup(1, "-8", Color.cyan); world.NotifyPlayerDamagePopup(0f, Vector3.zero); Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(2), "The public player damage API ignores zero actual HP loss."); } private void RaiseHit(GameObject target, HitSide side, float damage) { CombatEvents.RaiseValidHit(new CombatHitResult( world.Player.gameObject, target, false, side, damage, Vector2.right, 0f, Vector2.zero, isOffsetHit: false)); } private void AssertPopup(int index, string text, Color color) { FieldInfo popupArrayField = typeof(Defense25DComparison).GetField( "combatDamagePopups", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(popupArrayField, Is.Not.Null); Array popupArray = (Array)popupArrayField.GetValue(world); object popup = popupArray.GetValue(index); Type popupType = popup.GetType(); Assert.That((string)popupType.GetField("Text").GetValue(popup), Is.EqualTo(text)); Color actualColor = (Color)popupType.GetField("Color").GetValue(popup); Assert.That(actualColor.r, Is.EqualTo(color.r).Within(0.002f)); Assert.That(actualColor.g, Is.EqualTo(color.g).Within(0.002f)); Assert.That(actualColor.b, Is.EqualTo(color.b).Within(0.002f)); } private static ComparisonSpriteFrames ReadCharacter() { Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( $"Assets/_Project/Art/Characters/Slime/Slime_{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"), }; } } }