using System.Collections; using System.Reflection; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using BumpCombat.UI; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class FiniteArenaPlayModeTests { [TearDown] public void Cleanup() { ArtifactRewardController.GrantCatalogForTests = false; Time.timeScale = 1f; } [UnityTest] public IEnumerator PauseFreezesCameraFollowUntilPlayResumes() { yield return LoadAndStartRun(); RunManager runManager = RunManager.Instance; PlayerController player = Object.FindAnyObjectByType(); ArenaCameraFollow follow = Object.FindAnyObjectByType(); ArenaBounds bounds = Object.FindAnyObjectByType(); Assert.That(player, Is.Not.Null); Assert.That(follow, Is.Not.Null); Assert.That(bounds, Is.Not.Null); player.TryReposition(bounds.SharedActorClampHalfExtents); yield return null; Assert.That(runManager.TryOpenPause(), Is.True); Vector2 pausedCenter = follow.CurrentCenter; yield return new WaitForSecondsRealtime(0.3f); Assert.That(follow.CurrentCenter, Is.EqualTo(pausedCenter)); Assert.That(runManager.ClosePause(), Is.True); yield return new WaitForSecondsRealtime(0.8f); Assert.That(follow.CurrentCenter.x, Is.GreaterThan(0.1f)); Assert.That(follow.CurrentCenter.y, Is.GreaterThan(0.1f)); } [UnityTest] public IEnumerator RangedEnemyAtCornerApproachesBeforeAttackStart() { yield return LoadAndStartRun(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); PlayerHealth playerHealth = Object.FindAnyObjectByType(); Camera camera = Camera.main; ArenaCameraFollow follow = Object.FindAnyObjectByType(); ArenaBounds bounds = Object.FindAnyObjectByType(); Assert.That(playerHealth, Is.Not.Null); Assert.That(camera, Is.Not.Null); Assert.That(follow, Is.Not.Null); Assert.That(bounds, Is.Not.Null); playerHealth.GrantInvulnerability(10f); yield return DestroyActiveEnemies(); camera.aspect = 16f / 9f; EnemyController rangedPrefab = FindRangedPrefab(director); Assert.That(rangedPrefab, Is.Not.Null, "A ranged prefab is required for this regression."); Vector2 halfExtents = bounds.SharedActorClampHalfExtents; Vector2[] edgePositions = { new(halfExtents.x, 0f), new(-halfExtents.x, 0f), new(0f, halfExtents.y), new(0f, -halfExtents.y), }; for (int edgeIndex = 0; edgeIndex < edgePositions.Length; edgeIndex++) { Vector2 edgePosition = edgePositions[edgeIndex]; player.TryReposition(edgePosition); // Let the real follow path settle before the enemy is created // so the visible gate is measured from that edge camera. yield return new WaitForSecondsRealtime(0.9f); EnemyController ranged = Object.Instantiate( rangedPrefab, edgePosition, Quaternion.identity); yield return null; float gateDeadline = Time.realtimeSinceStartup + 3f; while (!ranged.IsRangedAttackStartAllowed && Time.realtimeSinceStartup < gateDeadline) { yield return new WaitForFixedUpdate(); } Vector2 position = ranged.GetComponent().position; Assert.That( ranged.IsRangedAttackStartAllowed, Is.True, $"Ranged enemy remained outside its attack gate at edge={edgeIndex}, " + $"position={position}; camera={camera.transform.position}."); Vector2 rangedGateHalfExtents = bounds.GetReachableHalfExtents(0.75f); Assert.That( Mathf.Abs(position.x), Is.LessThanOrEqualTo(rangedGateHalfExtents.x + 0.001f)); Assert.That( Mathf.Abs(position.y), Is.LessThanOrEqualTo(rangedGateHalfExtents.y + 0.001f)); float attackDeadline = Time.realtimeSinceStartup + 1.5f; while (ranged.State != EnemyState.Warning && ranged.State != EnemyState.Active && Time.realtimeSinceStartup < attackDeadline) { yield return null; } Assert.That( ranged.State == EnemyState.Warning || ranged.State == EnemyState.Active, Is.True, $"Ranged enemy entered the interior gate but did not start an attack " + $"(edge={edgeIndex}, state={ranged.State}, position={position})."); Object.Destroy(ranged.gameObject); yield return null; } } [UnityTest] public IEnumerator LargeHorizontalMeleeAtFiniteArenaEdges_ReachesActiveAttack() { yield return LoadAndStartRun(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); PlayerHealth playerHealth = Object.FindAnyObjectByType(); Rigidbody2D playerBody = player?.GetComponent(); ArenaBounds bounds = Object.FindAnyObjectByType(); Assert.That(player, Is.Not.Null); Assert.That(playerHealth, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); Assert.That(bounds, Is.Not.Null); playerHealth.GrantInvulnerability(60f); yield return DestroyActiveEnemies(); Vector2 halfExtents = bounds.SharedActorClampHalfExtents; Vector2[] playerPositions = { new(0f, -halfExtents.y), new(0f, halfExtents.y), new(-halfExtents.x, 0f), new(halfExtents.x, 0f), new(-halfExtents.x, -halfExtents.y), new(halfExtents.x, -halfExtents.y), new(-halfExtents.x, halfExtents.y), new(halfExtents.x, halfExtents.y), }; EnemyKind[] kinds = { EnemyKind.ArmoredSkeleton, EnemyKind.GreatswordSkeleton, }; RunTimedEvent[] roles = { RunTimedEvent.Elite, RunTimedEvent.MidBoss, }; float[] scaleMultipliers = { 1.25f, 1.6f }; for (int kindIndex = 0; kindIndex < kinds.Length; kindIndex++) { EnemyController prefab = FindCatalogPrefab( director, kinds[kindIndex]); Assert.That( prefab, Is.Not.Null, $"Missing edge melee prefab for {kinds[kindIndex]}."); for (int positionIndex = 0; positionIndex < playerPositions.Length; positionIndex++) { Vector2 playerPosition = playerPositions[positionIndex]; Assert.That(player.TryReposition(playerPosition), Is.True); yield return new WaitForFixedUpdate(); playerHealth.GrantInvulnerability(10f); Vector2 inward = new Vector2( Mathf.Sign(-playerPosition.x), Mathf.Sign(-playerPosition.y)); if (inward.sqrMagnitude <= 0.0001f) { inward = Vector2.up; } inward.Normalize(); EnemyController enemy = Object.Instantiate( prefab, playerPosition + inward * 3f, Quaternion.identity); enemy.ConfigureRunEventEnemy( roles[kindIndex], CreateEdgeEventTuning( kinds[kindIndex], scaleMultipliers[kindIndex])); yield return null; float warningDeadline = Time.realtimeSinceStartup + 5f; while (enemy.State != EnemyState.Warning && enemy.State != EnemyState.Active && Time.realtimeSinceStartup < warningDeadline) { yield return new WaitForFixedUpdate(); } Assert.That( enemy.State == EnemyState.Warning || enemy.State == EnemyState.Active, Is.True, $"{kinds[kindIndex]} stalled before attack at position " + $"{playerPosition}; enemy={enemy.GroundAnchorPosition}, " + $"range={enemy.AttackRange}."); float activeDeadline = Time.realtimeSinceStartup + 2f; while (enemy.State != EnemyState.Active && Time.realtimeSinceStartup < activeDeadline) { yield return new WaitForFixedUpdate(); } Assert.That( enemy.State, Is.EqualTo(EnemyState.Active), $"{kinds[kindIndex]} did not reach its active hit window " + $"at position {playerPosition}."); Assert.That( Vector2.Distance(playerBody.position, playerPosition), Is.LessThan(0.02f), "The stationary edge target moved before the melee hit window."); EnemyAttack attack = enemy.GetComponent(); Assert.That(attack, Is.Not.Null); Assert.That(attack.IsContactWindowActive(0f), Is.True); Object.Destroy(enemy.gameObject); yield return null; } } } [UnityTest] public IEnumerator SpawnSampling_StaysReachableSafeAndOutsideVisibleView() { yield return LoadAndStartRun(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); ArenaBounds bounds = Object.FindAnyObjectByType(); Camera camera = Camera.main; Assert.That(bounds, Is.Not.Null); Assert.That(camera, Is.Not.Null); camera.aspect = 16f / 9f; Vector2 halfExtents = bounds.SharedActorClampHalfExtents; Vector2[] corners = { Vector2.zero, new Vector2(-halfExtents.x, halfExtents.y), new Vector2(halfExtents.x, halfExtents.y), new Vector2(halfExtents.x, -halfExtents.y), new Vector2(-halfExtents.x, -halfExtents.y), }; for (int cornerIndex = 0; cornerIndex < corners.Length; cornerIndex++) { Assert.That(player.TryReposition(corners[cornerIndex]), Is.True); yield return new WaitForSecondsRealtime(0.35f); Bounds expandedVisible = bounds.GetCameraVisibleBounds(camera); expandedVisible.Expand(1f); for (int sample = 0; sample < 16; sample++) { Vector2 position = director.GetSpawnPositionForTests(); Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True); Assert.That( Vector2.Distance(position, corners[cornerIndex]), Is.GreaterThanOrEqualTo(4f)); bool insideExpandedVisible = position.x >= expandedVisible.min.x && position.x <= expandedVisible.max.x && position.y >= expandedVisible.min.y && position.y <= expandedVisible.max.y; Assert.That(insideExpandedVisible, Is.False); } } } private static IEnumerator DestroyActiveEnemies() { EnemyController[] enemies = Object.FindObjectsByType( FindObjectsSortMode.None); for (int i = 0; i < enemies.Length; i++) { if (enemies[i] != null) { Object.Destroy(enemies[i].gameObject); } } yield return null; } private static EnemyController FindRangedPrefab(SpawnDirector director) { FieldInfo field = typeof(SpawnDirector).GetField( "enemyPrefabs", BindingFlags.Instance | BindingFlags.NonPublic); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { return null; } for (int i = 0; i < prefabs.Length; i++) { if (prefabs[i] != null && prefabs[i].Definition != null && prefabs[i].Definition.IsRanged) { return prefabs[i]; } } return null; } private static EnemyController FindCatalogPrefab( SpawnDirector director, EnemyKind kind) { string[] fieldNames = { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs", }; for (int fieldIndex = 0; fieldIndex < fieldNames.Length; fieldIndex++) { FieldInfo field = typeof(SpawnDirector).GetField( fieldNames[fieldIndex], BindingFlags.Instance | BindingFlags.NonPublic); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { continue; } for (int prefabIndex = 0; prefabIndex < prefabs.Length; prefabIndex++) { EnemyController prefab = prefabs[prefabIndex]; if (prefab != null && prefab.Definition != null && prefab.Definition.Kind == kind) { return prefab; } } } return null; } private static RunEventEnemyTuning CreateEdgeEventTuning( EnemyKind kind, float scale) { return RunEventEnemyTuning.Create( kind, 1f, 0, scale, Color.white, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1, 1f, 1f, 1, 1f, false); } private static IEnumerator LoadAndStartRun() { ArtifactRewardController.GrantCatalogForTests = false; AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; RunManager runManager = RunManager.Instance; if (runManager.IsTitleScreen) { Assert.That(runManager.BeginRun(), Is.True); } ArtifactRewardController reward = Object.FindAnyObjectByType(); Assert.That(reward, Is.Not.Null); for (int selection = 0; selection < 3; selection++) { float deadline = Time.realtimeSinceStartup + 2f; while (!reward.DebugIsSelectionVisible && Time.realtimeSinceStartup < deadline) { yield return null; } Assert.That(reward.DebugIsSelectionVisible, Is.True); Assert.That(reward.DebugChooseOption(0), Is.True); yield return null; } // The first artifact acquisition queues the tutorial modal. This // helper is used by gameplay tests, so acknowledge any queued // guidance before waiting on gameplay-time coroutines. RunTutorialController tutorials = Object.FindAnyObjectByType(); for (int tutorial = 0; tutorials != null && tutorials.DebugIsVisible && tutorial < 4; tutorial++) { Assert.That(tutorials.DebugContinue(), Is.True); yield return null; } Assert.That( RunManager.GameplayInputEnabled, Is.True, "Finite arena tests require startup tutorial guidance to be closed."); } } }