using System.Collections; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class RangeAuditPlayModeTests { private static readonly BindingFlags NonPublicInstance = BindingFlags.Instance | BindingFlags.NonPublic; private static readonly EnemyKind[] HorizontalRootKinds = { EnemyKind.Skeleton, EnemyKind.ArmoredSkeleton, EnemyKind.GreatswordSkeleton, EnemyKind.Werewolf, EnemyKind.Werebear, EnemyKind.NecroGolem, EnemyKind.Necromancer, }; private bool previousGrantCatalogForTests; private bool previousProductionModeForTests; private float previousTimeScale; [SetUp] public void SetUp() { previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests; previousProductionModeForTests = RunManager.ForceProductionModeForTests; previousTimeScale = Time.timeScale; ArtifactRewardController.GrantCatalogForTests = true; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [TearDown] public void TearDown() { ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests; RunManager.ForceProductionModeForTests = previousProductionModeForTests; Time.timeScale = previousTimeScale; } [UnityTest] public IEnumerator NecroGolemAttack03_UsesActualPlayerBodyAndActiveEnd() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); PlayerController player = Object.FindAnyObjectByType(); PlayerHealth health = player?.GetComponent(); Rigidbody2D playerBody = player?.GetComponent(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); director.enabled = false; DisableSceneEnemies(); player.enabled = false; player.GetComponent().enabled = false; EnemyController golem = SpawnGolem(director, Vector2.zero); yield return null; ConfigureGolemAttack03(golem, director); golem.enabled = false; EnemyAttack attack = golem.GetComponent(); Rigidbody2D golemBody = golem.GetComponent(); AssertGolemAttack03Contract(golem); Vector2 root = golemBody.position; Vector2 rightOutside = root + new Vector2(1.8f, 1.9f); Vector2 rightInside = root + new Vector2(1.9f, 0.4f); float activeDuration = golem.ActiveDuration; PlacePlayer(playerBody, rightOutside); float healthBefore = health.CurrentHealth; BeginActiveAttack( golem, attack, Vector2.right, playerBody.position); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); attack.TickActive(activeDuration * 0.5f); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); PlacePlayer(playerBody, rightInside); attack.TickActive(activeDuration); Assert.That( health.CurrentHealth, Is.EqualTo(healthBefore), "A contact first observed at the exact active end must not hit."); attack.TickActive(activeDuration + 0.01f); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); attack.EndAttack(); SetState(golem, EnemyState.Recovery, golem.RecoveryDuration); attack.TickActive(activeDuration + 0.1f); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); PlacePlayer(playerBody, rightInside); BeginActiveAttack( golem, attack, Vector2.right, playerBody.position); float healthAfterRightHit = health.CurrentHealth; Assert.That( healthAfterRightHit, Is.LessThan(healthBefore), "Attack03 must damage the actual player body inside the box."); attack.TickActive(activeDuration * 0.8f); attack.TickActive(activeDuration); attack.TickActive(activeDuration + 0.01f); attack.EndAttack(); SetState(golem, EnemyState.Recovery, golem.RecoveryDuration); attack.TickActive(activeDuration + 0.1f); Assert.That( health.CurrentHealth, Is.EqualTo(healthAfterRightHit).Within(0.0001f), "Attack03 must damage once through active end and recovery."); yield return new WaitForSecondsRealtime( health.InvulnerabilityDuration + 0.05f); health.Heal(health.MaxHealth); Vector2 leftOutside = root + new Vector2(-1.8f, 1.9f); Vector2 leftInside = root + new Vector2(-1.9f, 0.4f); PlacePlayer(playerBody, leftOutside); float leftBefore = health.CurrentHealth; BeginActiveAttack( golem, attack, Vector2.left, playerBody.position); Assert.That(health.CurrentHealth, Is.EqualTo(leftBefore)); PlacePlayer(playerBody, leftInside); attack.TickActive(activeDuration * 0.8f); Assert.That( health.CurrentHealth, Is.LessThan(leftBefore), "The mirrored left Attack03 box must hit the actual body."); float leftAfterHit = health.CurrentHealth; attack.TickActive(activeDuration); attack.TickActive(activeDuration + 0.01f); attack.EndAttack(); SetState(golem, EnemyState.Recovery, golem.RecoveryDuration); attack.TickActive(activeDuration + 0.1f); Assert.That( health.CurrentHealth, Is.EqualTo(leftAfterHit).Within(0.0001f)); Object.Destroy(golem.gameObject); } [UnityTest] public IEnumerator HorizontalRootKinds_LockDiagonalRequestedDirection() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); director.enabled = false; DisableSceneEnemies(); for (int i = 0; i < HorizontalRootKinds.Length; i++) { EnemyKind kind = HorizontalRootKinds[i]; EnemyController enemy = Object.Instantiate( FindCatalogPrefab(director, kind), new Vector2(20f + i * 2f, 20f), Quaternion.identity); yield return null; enemy.enabled = false; EnemyAttack attack = enemy.GetComponent(); Assert.That(attack, Is.Not.Null, kind.ToString()); Assert.That( enemy.CurrentAttackPattern.DirectionMode, Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot), kind.ToString()); attack.BeginWarning( new Vector2(1f, 1f).normalized, enemy.transform.position + Vector3.one, enemy.WarningDuration); Assert.That( attack.LockedDirection, Is.EqualTo(Vector2.right), kind + " must resolve positive diagonal input horizontally."); attack.EndAttack(true); attack.BeginWarning( new Vector2(-1f, 1f).normalized, enemy.transform.position + Vector3.one, enemy.WarningDuration); Assert.That( attack.LockedDirection, Is.EqualTo(Vector2.left), kind + " must preserve the mirrored horizontal side."); attack.EndAttack(true); Object.Destroy(enemy.gameObject); yield return null; } } [UnityTest] public IEnumerator EnemyGroundAnchors_StayOnSharedPlayerBoundaryAfterSpawnWarningAndKnockback() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); PlayerController player = Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); director.enabled = false; DisableSceneEnemies(); ArenaBounds bounds = Object.FindAnyObjectByType(); EnemyController normalPrefab = FindCatalogPrefab( director, EnemyKind.Skeleton); EnemyController eventPrefab = FindCatalogPrefab( director, EnemyKind.GreatswordSkeleton); Assert.That(bounds, Is.Not.Null); Assert.That(normalPrefab, Is.Not.Null); Assert.That(eventPrefab, Is.Not.Null); Vector2 halfExtents = bounds.SharedActorClampHalfExtents; Vector2[] corners = { new(halfExtents.x, halfExtents.y), new(-halfExtents.x, -halfExtents.y), }; for (int i = 0; i < corners.Length; i++) { EnemyController enemy = Object.Instantiate( normalPrefab, corners[i], Quaternion.identity); yield return null; AssertEnemyBodyInside(bounds, enemy, "normal spawn"); Rigidbody2D body = enemy.GetComponent(); body.position = corners[i]; Physics2D.SyncTransforms(); SetState(enemy, EnemyState.Warning, 5f); yield return null; AssertEnemyBodyInside(bounds, enemy, "warning correction"); body.position = corners[i]; Physics2D.SyncTransforms(); bool damaged = enemy.TryTakeDamage( 1f, corners[i].normalized, 2f); Assert.That(damaged, Is.True, "a valid nonlethal knockback hit must be accepted"); yield return new WaitForFixedUpdate(); yield return null; AssertEnemyBodyInside(bounds, enemy, "knockback correction"); Object.Destroy(enemy.gameObject); yield return null; } EnemyController eventEnemy = Object.Instantiate( eventPrefab, corners[0], Quaternion.identity); eventEnemy.ConfigureRunEventEnemy( RunTimedEvent.MidBoss, director.GetEventEnemyTuning(RunTimedEvent.MidBoss)); yield return null; AssertEnemyBodyInside(bounds, eventEnemy, "scaled event spawn"); Object.Destroy(eventEnemy.gameObject); } private static void AssertEnemyBodyInside( ArenaBounds bounds, EnemyController enemy, string checkpoint) { Collider2D bodyCollider = enemy.GetComponent(); Assert.That(bodyCollider, Is.Not.Null, checkpoint); Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents; Vector2 groundAnchor = enemy.GroundAnchorPosition; Assert.That( groundAnchor.x, Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.001f), checkpoint); Assert.That( groundAnchor.x, Is.LessThanOrEqualTo(safeHalfExtents.x + 0.001f), checkpoint); Assert.That( groundAnchor.y, Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.001f), checkpoint); Assert.That( groundAnchor.y, Is.LessThanOrEqualTo(safeHalfExtents.y + 0.001f), checkpoint); } private static void AssertGolemAttack03Contract(EnemyController golem) { EnemyAttackPattern pattern = golem.CurrentAttackPattern; Assert.That(pattern.AttackShape, Is.EqualTo(EnemyAttackShape.Box)); Assert.That(pattern.AttackLength, Is.EqualTo(1.05f).Within(0.0001f)); Assert.That(pattern.AttackWidth, Is.EqualTo(0.55f).Within(0.0001f)); Assert.That( pattern.AttackOriginOffset, Is.EqualTo(new Vector2(0.05f, -0.05f))); Assert.That( pattern.AnimationLeadTime, Is.EqualTo(0.625f).Within(0.0001f)); Assert.That(golem.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f)); Assert.That( EnemyAttack.GetRootScale(golem.transform), Is.EqualTo(2f).Within(0.001f)); Assert.That(golem.AttackLength, Is.EqualTo(2.1f).Within(0.001f)); Assert.That(golem.AttackWidth, Is.EqualTo(1.1f).Within(0.001f)); } private static void BeginActiveAttack( EnemyController enemy, EnemyAttack attack, Vector2 direction, Vector2 target) { attack.BeginWarning(direction, target, enemy.WarningDuration); SetState(enemy, EnemyState.Warning, enemy.WarningDuration); attack.Activate(); SetState(enemy, EnemyState.Active, enemy.ActiveDuration); } private static EnemyController SpawnGolem( SpawnDirector director, Vector2 position) { EnemyController prefab = FindCatalogPrefab( director, EnemyKind.NecroGolem); Assert.That(prefab, Is.Not.Null); return Object.Instantiate(prefab, position, Quaternion.identity); } private static void ConfigureGolemAttack03( EnemyController golem, SpawnDirector director) { golem.ConfigureRunEventEnemy( RunTimedEvent.MidBoss, director.GetEventEnemyTuning(RunTimedEvent.MidBoss)); SetPrivateField(golem, "attackPatternIndex", 2); } private static void PlacePlayer( Rigidbody2D playerBody, Vector2 position) { playerBody.linearVelocity = Vector2.zero; playerBody.position = position; Physics2D.SyncTransforms(); } private static void SetState( EnemyController enemy, EnemyState state, float duration) { typeof(EnemyController) .GetMethod("EnterState", NonPublicInstance) ?.Invoke(enemy, new object[] { state, duration }); } private static void SetPrivateField( object target, string fieldName, T value) { target.GetType() .GetField(fieldName, NonPublicInstance) ?.SetValue(target, value); } private static void DisableSceneEnemies() { EnemyController[] enemies = Object.FindObjectsByType( FindObjectsSortMode.None); foreach (EnemyController enemy in enemies) { enemy.gameObject.SetActive(false); } } private static EnemyController FindCatalogPrefab( SpawnDirector director, EnemyKind kind) { string[] fieldNames = { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs", }; foreach (string fieldName in fieldNames) { FieldInfo field = typeof(SpawnDirector).GetField( fieldName, BindingFlags.Instance | BindingFlags.NonPublic); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { continue; } foreach (EnemyController prefab in prefabs) { if (prefab != null && prefab.Definition != null && prefab.Definition.Kind == kind) { return prefab; } } } return null; } private static IEnumerator LoadCombatPrototype() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; Assert.That(Time.timeScale, Is.EqualTo(1f)); } } }