using System.Reflection; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Spawning; using NUnit.Framework; using UnityEngine; namespace BumpCombat.Tests { public sealed class FiniteArenaGeometryTests { [TearDown] public void Cleanup() { ArenaBounds[] bounds = Object.FindObjectsByType( FindObjectsSortMode.None); for (int i = 0; i < bounds.Length; i++) { Object.DestroyImmediate(bounds[i].gameObject); } SpawnDirector[] directors = Object.FindObjectsByType( FindObjectsSortMode.None); for (int i = 0; i < directors.Length; i++) { if (directors[i] != null) { Object.DestroyImmediate(directors[i].gameObject); } } ArenaBounds.Instance?.gameObject.SetActive(false); } [Test] public void DefaultGeometry_UsesFullFloorWithSmallSharedActorClearance() { GameObject root = CreateBounds(); ArenaBounds bounds = root.GetComponent(); Assert.That(bounds.BackgroundHalfExtents.x, Is.EqualTo(15f).Within(0.0001f)); Assert.That(bounds.BackgroundHalfExtents.y, Is.EqualTo(8.4375f).Within(0.0001f)); Assert.That(bounds.PlayableHalfExtents, Is.EqualTo(bounds.BackgroundHalfExtents)); Assert.That(bounds.PlayerPadding, Is.EqualTo(new Vector2(0.5f, 0.625f))); Assert.That(bounds.PlayerClampHalfExtents.x, Is.EqualTo(14.5f).Within(0.0001f)); Assert.That(bounds.PlayerClampHalfExtents.y, Is.EqualTo(7.8125f).Within(0.0001f)); Assert.That(bounds.SharedActorClampHalfExtents, Is.EqualTo(bounds.PlayerClampHalfExtents)); Assert.That(bounds.CameraCenterHalfExtents.x, Is.EqualTo(5f).Within(0.0001f)); Assert.That(bounds.CameraCenterHalfExtents.y, Is.EqualTo(2.8125f).Within(0.0001f)); } [Test] public void ClampsPlayerEnemyAndCameraInsideFiniteMap() { GameObject root = CreateBounds(); ArenaBounds bounds = root.GetComponent(); Vector2 playerPosition = bounds.ClampPlayerPosition(new Vector2(99f, -99f)); Assert.That(playerPosition.x, Is.EqualTo(14.5f).Within(0.0001f)); Assert.That(playerPosition.y, Is.EqualTo(-7.8125f).Within(0.0001f)); Vector2 enemyPosition = bounds.ClampEnemyPosition(new Vector2(-99f, 99f)); Assert.That(enemyPosition.x, Is.EqualTo(-14.5f).Within(0.0001f)); Assert.That(enemyPosition.y, Is.EqualTo(7.8125f).Within(0.0001f)); Assert.That( bounds.ClampEnemyPosition(new Vector2(99f, -99f)), Is.EqualTo(bounds.ClampPlayerPosition(new Vector2(99f, -99f)))); Vector2 cameraPosition = bounds.ClampCameraCenter(new Vector2(99f, -99f)); Assert.That(cameraPosition.x, Is.EqualTo(5f).Within(0.0001f)); Assert.That(cameraPosition.y, Is.EqualTo(-2.8125f).Within(0.0001f)); GameObject cameraObject = new("Arena Bounds Camera Test"); Camera camera = cameraObject.AddComponent(); camera.orthographic = true; camera.orthographicSize = 5.625f; camera.aspect = 16f / 9f; Vector2 cameraCenter = bounds.ClampCameraCenter( new Vector2(99f, 99f), bounds.GetCameraVisibleHalfExtents(camera)); camera.transform.position = cameraCenter; Bounds visibleBounds = bounds.GetCameraVisibleBounds(camera); Assert.That(visibleBounds.min.x, Is.GreaterThanOrEqualTo(-15f)); Assert.That(visibleBounds.max.x, Is.EqualTo(15f).Within(0.0001f)); Assert.That(visibleBounds.min.y, Is.GreaterThanOrEqualTo(-8.4375f)); Assert.That(visibleBounds.max.y, Is.EqualTo(8.4375f).Within(0.0001f)); cameraCenter = bounds.ClampCameraCenter( new Vector2(-99f, -99f), bounds.GetCameraVisibleHalfExtents(camera)); camera.transform.position = cameraCenter; visibleBounds = bounds.GetCameraVisibleBounds(camera); Assert.That(visibleBounds.min.x, Is.EqualTo(-15f).Within(0.0001f)); Assert.That(visibleBounds.max.x, Is.LessThanOrEqualTo(15f)); Assert.That(visibleBounds.min.y, Is.EqualTo(-8.4375f).Within(0.0001f)); Assert.That(visibleBounds.max.y, Is.LessThanOrEqualTo(8.4375f)); Object.DestroyImmediate(cameraObject); } [Test] public void DeadZone_HoldsUntilTargetCrossesEachAxis() { Assert.That( ArenaCameraFollow.GetDeadZoneTargetCenter( Vector2.zero, new Vector2(0.5f, 0.3f), new Vector2(0.5f, 0.3f)), Is.EqualTo(Vector2.zero)); Vector2 movedCenter = ArenaCameraFollow.GetDeadZoneTargetCenter( Vector2.zero, new Vector2(0.7f, -0.5f), new Vector2(0.5f, 0.3f)); Assert.That(movedCenter.x, Is.EqualTo(0.2f).Within(0.0001f)); Assert.That(movedCenter.y, Is.EqualTo(-0.2f).Within(0.0001f)); } [Test] public void SpawnPositionsStayReachableAndClearOfPlayer() { GameObject boundsObject = CreateBounds(); ArenaBounds bounds = boundsObject.GetComponent(); GameObject directorObject = new("Spawn Director Test"); SpawnDirector director = directorObject.AddComponent(); for (int i = 0; i < 64; i++) { Vector2 position = director.GetSpawnPositionForTests(); Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True); Assert.That(position.magnitude, Is.GreaterThanOrEqualTo(4f)); } } [Test] public void EnemyGroundAnchors_StayOnSharedPlayerBoundaryAfterEdgeAndCornerKnockback() { GameObject boundsObject = CreateBounds(); ArenaBounds bounds = boundsObject.GetComponent(); EnemyController normalEnemy = CreateEnemy( new Vector2(0.72f, 0.72f), new Vector2(0.1f, -0.28f), 1f); EnemyController eventEnemy = CreateEnemy( new Vector2(0.72f, 0.72f), new Vector2(-0.18f, -0.28f), 1.25f); try { MethodInfo clampMethod = typeof(EnemyController).GetMethod( "ClampEnemyPosition", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(clampMethod, Is.Not.Null); EnemyController[] enemies = { normalEnemy, eventEnemy }; for (int enemyIndex = 0; enemyIndex < enemies.Length; enemyIndex++) { EnemyController enemy = enemies[enemyIndex]; Collider2D collider = enemy.GetComponent(); Rigidbody2D body = enemy.GetComponent(); Vector2[] knockbackDestinations = { new(100f, 100f), new(-100f, 100f), new(100f, -100f), new(-100f, -100f), }; for (int i = 0; i < knockbackDestinations.Length; i++) { Vector2 clamped = (Vector2)clampMethod.Invoke( enemy, new object[] { knockbackDestinations[i] }); body.position = clamped; Physics2D.SyncTransforms(); Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents; Assert.That(body.position.x, Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.0001f)); Assert.That(body.position.x, Is.LessThanOrEqualTo(safeHalfExtents.x + 0.0001f)); Assert.That(body.position.y, Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.0001f)); Assert.That(body.position.y, Is.LessThanOrEqualTo(safeHalfExtents.y + 0.0001f)); Bounds colliderBounds = collider.bounds; // The collider can overhang the ground-anchor edge by // its art footprint; only the anchor defines movement. Assert.That(colliderBounds.size.x, Is.GreaterThan(0f)); Assert.That(colliderBounds.size.y, Is.GreaterThan(0f)); } } } finally { Object.DestroyImmediate(normalEnemy.gameObject); Object.DestroyImmediate(eventEnemy.gameObject); } } [Test] public void EnemyGroundAnchor_PostPhysicsCorrectionRestoresSharedBoundary() { CreateBounds(); EnemyController enemy = CreateEnemy( new Vector2(0.72f, 0.72f), new Vector2(-0.18f, -0.28f), 1.25f); try { Rigidbody2D body = enemy.GetComponent(); Collider2D collider = enemy.GetComponent(); body.position = new Vector2(100f, -100f); Physics2D.SyncTransforms(); MethodInfo correctionMethod = typeof(EnemyController).GetMethod( "KeepBodyInsideArena", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(correctionMethod, Is.Not.Null); correctionMethod.Invoke(enemy, null); Physics2D.SyncTransforms(); Bounds colliderBounds = collider.bounds; ArenaBounds bounds = ArenaBounds.Resolve(); Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents; Assert.That(body.position.x, Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.0001f)); Assert.That(body.position.x, Is.LessThanOrEqualTo(safeHalfExtents.x + 0.0001f)); Assert.That(body.position.y, Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.0001f)); Assert.That(body.position.y, Is.LessThanOrEqualTo(safeHalfExtents.y + 0.0001f)); Assert.That(colliderBounds.size.x, Is.GreaterThan(0f)); } finally { Object.DestroyImmediate(enemy.gameObject); } } private static EnemyController CreateEnemy( Vector2 colliderSize, Vector2 colliderOffset, float rootScale) { GameObject enemyObject = new("Boundary Test Enemy"); Rigidbody2D body = enemyObject.AddComponent(); body.bodyType = RigidbodyType2D.Kinematic; body.gravityScale = 0f; BoxCollider2D collider = enemyObject.AddComponent(); collider.size = colliderSize; collider.offset = colliderOffset; enemyObject.AddComponent(); enemyObject.transform.localScale = Vector3.one * rootScale; EnemyController enemy = enemyObject.AddComponent(); // Unity does not invoke runtime Awake in EditMode. Populate the // same private references that Awake would cache so this test // exercises the controller's real clamp path. SetPrivateField(enemy, "body", body); SetPrivateField(enemy, "bodyCollider", collider); Physics2D.SyncTransforms(); return enemy; } private static void SetPrivateField( object target, string fieldName, T value) { typeof(EnemyController) .GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(target, value); } private static GameObject CreateBounds() { GameObject root = new("Arena Bounds Test"); root.AddComponent(); return root; } } }