using System.Reflection; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Stage; using NUnit.Framework; using UnityEditor; using UnityEngine; using System.Collections.Generic; namespace BumpCombat.Tests { public sealed class EnemySightAndPathfindingTests { private GameObject playerObject; private PlayerController player; private GameObject enemyObject; private EnemyController enemy; private GameObject obstacleObject; private StageObstacle obstacle; private readonly List additionalEnemyObjects = new(); [TearDown] public void TearDown() { if (playerObject != null) Object.DestroyImmediate(playerObject); if (enemyObject != null) Object.DestroyImmediate(enemyObject); for (int i = 0; i < additionalEnemyObjects.Count; i++) { if (additionalEnemyObjects[i] != null) { Object.DestroyImmediate(additionalEnemyObjects[i]); } } additionalEnemyObjects.Clear(); if (obstacleObject != null) Object.DestroyImmediate(obstacleObject); } [Test] public void SightRange_OutOfRange_StaysIdle() { playerObject = new GameObject("Player"); playerObject.transform.position = new Vector3(15f, 0f, 0f); player = playerObject.AddComponent(); enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false); enemyObject.transform.position = Vector3.zero; enemy = enemyObject.GetComponent(); // Trigger detection check (simulate spawn transition) bool detected = enemy.CheckPlayerDetection(); Assert.That(detected, Is.False, "Player at 15m should be outside default 8m sight range"); Assert.That(enemy.HasDetectedPlayer, Is.False); } [Test] public void SightRange_WithinRange_DirectLOS_DetectsPlayer() { playerObject = new GameObject("Player"); playerObject.transform.position = new Vector3(5f, 0f, 0f); player = playerObject.AddComponent(); enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false); enemyObject.transform.position = Vector3.zero; enemy = enemyObject.GetComponent(); Assert.That(enemy.SightRange, Is.EqualTo(8f).Within(0.001f)); bool detected = enemy.CheckPlayerDetection(); Assert.That(detected, Is.True, "Player at 5m with no obstacles should be detected"); Assert.That(enemy.HasDetectedPlayer, Is.True); } [Test] public void LineOfSight_BlockedByObstacle_GroundEnemyDoesNotDetect() { playerObject = new GameObject("Player"); playerObject.transform.position = new Vector3(6f, 0f, 0f); player = playerObject.AddComponent(); // Place an obstacle in between at x = 3 obstacleObject = new GameObject("Obstacle_Pillar"); obstacleObject.transform.position = new Vector3(3f, 0f, 0f); BoxCollider2D obsCol = obstacleObject.AddComponent(); obsCol.size = new Vector2(2f, 2f); obstacle = obstacleObject.AddComponent(); enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false); enemyObject.transform.position = Vector3.zero; enemy = enemyObject.GetComponent(); bool detected = enemy.CheckPlayerDetection(); Assert.That(detected, Is.False, "Ground enemy should be blocked by StageObstacle"); Assert.That(enemy.HasDetectedPlayer, Is.False); } [Test] public void LineOfSight_LossKeepsExistingDetectionMemory() { playerObject = new GameObject("Player"); playerObject.transform.position = new Vector3(3.5f, 0f, 0f); player = playerObject.AddComponent(); enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false); enemyObject.transform.position = Vector3.zero; enemy = enemyObject.GetComponent(); Assert.That(enemy.CheckPlayerDetection(), Is.True); enemy.Alert(); obstacleObject = new GameObject("Obstacle_AfterDetection"); obstacleObject.transform.position = new Vector3(1.75f, 0f, 0f); BoxCollider2D obstacleCollider = obstacleObject.AddComponent(); obstacleCollider.size = new Vector2(0.5f, 2f); obstacle = obstacleObject.AddComponent(); Physics2D.SyncTransforms(); MethodInfo refreshAwareness = typeof(EnemyController).GetMethod( "RefreshChaseAwareness", BindingFlags.NonPublic | BindingFlags.Instance); bool remainsInMemory = (bool)refreshAwareness.Invoke(enemy, null); Assert.That(remainsInMemory, Is.True, "A lost LOS should retain the existing short memory window"); Assert.That(enemy.HasDetectedPlayer, Is.True); } [Test] public void LineOfSight_BlockedByObstacle_FlyingEnemyDetects() { playerObject = new GameObject("Player"); playerObject.transform.position = new Vector3(6f, 0f, 0f); player = playerObject.AddComponent(); obstacleObject = new GameObject("Obstacle_Pillar"); obstacleObject.transform.position = new Vector3(3f, 0f, 0f); BoxCollider2D obsCol = obstacleObject.AddComponent(); obsCol.size = new Vector2(2f, 2f); obstacle = obstacleObject.AddComponent(); enemyObject = CreateEnemyObject("Bat", EnemyKind.Bat, isFlying: true); enemyObject.transform.position = Vector3.zero; enemy = enemyObject.GetComponent(); bool detected = enemy.CheckPlayerDetection(); Assert.That(detected, Is.True, "Flying enemy should see over StageObstacle"); Assert.That(enemy.HasDetectedPlayer, Is.True); } [Test] public void ProximityAlert_IgnoresObstacle_WithinProximityRange() { playerObject = new GameObject("Player"); playerObject.transform.position = new Vector3(1.5f, 0f, 0f); player = playerObject.AddComponent(); // Small obstacle between at x = 0.75 obstacleObject = new GameObject("Obstacle_Thin"); obstacleObject.transform.position = new Vector3(0.75f, 0f, 0f); BoxCollider2D obsCol = obstacleObject.AddComponent(); obsCol.size = new Vector2(0.5f, 2f); obstacle = obstacleObject.AddComponent(); enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false); enemyObject.transform.position = Vector3.zero; enemy = enemyObject.GetComponent(); bool detected = enemy.CheckPlayerDetection(); Assert.That(detected, Is.True, "Player within 2.0m proximity range should be detected even behind obstacle"); Assert.That(enemy.HasDetectedPlayer, Is.True); } [Test] public void DamageAlert_TriggersChase_Immediately() { playerObject = new GameObject("Player"); playerObject.transform.position = new Vector3(20f, 0f, 0f); player = playerObject.AddComponent(); enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false); enemyObject.transform.position = Vector3.zero; enemy = enemyObject.GetComponent(); Assert.That(enemy.HasDetectedPlayer, Is.False); // Taking damage triggers Alert enemy.TryTakeDamage(1f, Vector2.left, 0f); Assert.That(enemy.HasDetectedPlayer, Is.True, "Taking damage must trigger immediate detection"); } [Test] public void LimitedViewSight_AlertsOnlyTheNearbyEncounterGroup() { playerObject = new GameObject("Player"); playerObject.transform.position = Vector3.zero; player = playerObject.AddComponent(); enemyObject = CreateEnemyObject("NearLeader", EnemyKind.Skeleton, isFlying: false); enemyObject.transform.position = new Vector3(3.5f, 0f, 0f); enemy = enemyObject.GetComponent(); float stageSightRangeMultiplier = GameplayConstants.Current.Run.StageEncounterSightRangeMultiplier; Assert.That(stageSightRangeMultiplier, Is.EqualTo(0.5f).Within(0.001f)); enemy.ConfigureSightRangeMultiplier(stageSightRangeMultiplier); enemy.ConfigureEncounterGroup(101, enemyObject.transform.position); GameObject nearMemberObject = CreateEnemyObject("NearMember", EnemyKind.Skeleton, isFlying: false); nearMemberObject.transform.position = new Vector3(3.8f, 0.4f, 0f); additionalEnemyObjects.Add(nearMemberObject); EnemyController nearMember = nearMemberObject.GetComponent(); nearMember.ConfigureSightRangeMultiplier(stageSightRangeMultiplier); nearMember.ConfigureEncounterGroup(101, enemyObject.transform.position); GameObject farLeaderObject = CreateEnemyObject("FarLeader", EnemyKind.Skeleton, isFlying: false); farLeaderObject.transform.position = new Vector3(5f, 0f, 0f); additionalEnemyObjects.Add(farLeaderObject); EnemyController farLeader = farLeaderObject.GetComponent(); farLeader.ConfigureSightRangeMultiplier(stageSightRangeMultiplier); farLeader.ConfigureEncounterGroup(202, farLeaderObject.transform.position); Assert.That(enemy.SightRange, Is.EqualTo(4f).Within(0.001f), "Stage group members should use 8m * 0.5 sight range"); Assert.That(nearMember.SightRange, Is.EqualTo(4f).Within(0.001f)); Assert.That(farLeader.SightRange, Is.EqualTo(4f).Within(0.001f)); enemyObject.SetActive(false); enemyObject.SetActive(true); Assert.That(enemy.SightRange, Is.EqualTo(4f).Within(0.001f), "Re-enabling must not reset the model multiplier"); Assert.That(enemy.CheckPlayerDetection(), Is.True); Assert.That(nearMember.HasDetectedPlayer, Is.True, "Detection should alert only the same encounter group"); Assert.That(farLeader.CheckPlayerDetection(), Is.False, "A group outside the stage sight range should remain unpulled"); Assert.That(farLeader.HasDetectedPlayer, Is.False); playerObject.transform.position = new Vector3(4.5f, 0f, 0f); Assert.That(farLeader.CheckPlayerDetection(), Is.True, "The far group should detect once the player enters its sight range"); Assert.That(farLeader.HasDetectedPlayer, Is.True); } [Test] public void RoomPathfinder_FindsWaypointAroundObstacle() { RoomPreset preset = new RoomPreset { GridWidth = 5, GridHeight = 5, GridMap = new PresetCellType[5, 5], }; // Fill all with Walkable for (int x = 0; x < 5; x++) { for (int y = 0; y < 5; y++) { preset.GridMap[x, y] = PresetCellType.Walkable; } } // Place an obstacle in the middle cell (2, 2) preset.GridMap[2, 2] = PresetCellType.Obstacle; GameObject zoneObj = new GameObject("Zone_PathfindTest"); ZoneInstance zone = zoneObj.AddComponent(); FieldInfo presetField = typeof(ZoneInstance).GetField("preset", BindingFlags.NonPublic | BindingFlags.Instance); presetField?.SetValue(zone, preset); // Start at (1, 2), Target at (3, 2) with obstacle at (2, 2) Vector2 startPos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(1, 2)); Vector2 targetPos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(3, 2)); // Also place a GameObject obstacle with StageObstacle so physics Linecast sees it obstacleObject = new GameObject("TestObstacleObj"); obstacleObject.transform.position = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(2, 2)); BoxCollider2D obsCol = obstacleObject.AddComponent(); obsCol.size = new Vector2(1f, 1f); obstacle = obstacleObject.AddComponent(); bool found = RoomPathfinder.TryGetNextWaypoint(zone, startPos, targetPos, isFlying: false, out Vector2 waypoint); Assert.That(found, Is.True); // Waypoint should NOT be (2, 2) which is the obstacle! Vector2Int waypointCell = RoomPathfinder.WorldToCell(preset, zone.ZoneCenter, waypoint); Assert.That(waypointCell, Is.Not.EqualTo(new Vector2Int(2, 2)), "Waypoint must avoid the obstacle cell"); Assert.That(RoomPathfinder.IsWalkable(preset, waypointCell.x, waypointCell.y), Is.True, "Waypoint must be on a walkable cell"); Object.DestroyImmediate(zoneObj); } [Test] public void RoomPathfinder_RejectsUnreachableOrOutsideRoamTargets() { RoomPreset preset = new RoomPreset { GridWidth = 5, GridHeight = 5, GridMap = new PresetCellType[5, 5], }; for (int x = 0; x < 5; x++) { for (int y = 0; y < 5; y++) { preset.GridMap[x, y] = PresetCellType.Walkable; } } for (int y = 0; y < 5; y++) { preset.GridMap[2, y] = PresetCellType.Empty; } GameObject zoneObj = new GameObject("Zone_RoamPathTest"); ZoneInstance zone = zoneObj.AddComponent(); FieldInfo presetField = typeof(ZoneInstance).GetField( "preset", BindingFlags.NonPublic | BindingFlags.Instance); presetField?.SetValue(zone, preset); Vector2 startPos = preset.CellToWorldPosition( zone.ZoneCenter, new Vector2Int(1, 2)); Vector2 blockedTarget = preset.CellToWorldPosition( zone.ZoneCenter, new Vector2Int(3, 2)); Assert.That( RoomPathfinder.TryGetReachableRoamPoint( zone, startPos, blockedTarget, isFlying: false, out _), Is.False, "Roaming must not keep a target across disconnected terrain"); Assert.That( RoomPathfinder.TryGetReachableRoamPoint( zone, startPos, new Vector2(100f, 100f), isFlying: false, out _), Is.False, "Roaming must reject targets outside the current zone grid"); Object.DestroyImmediate(zoneObj); } private GameObject CreateEnemyObject(string name, EnemyKind kind, bool isFlying) { GameObject obj = new GameObject(name); obj.AddComponent(); CircleCollider2D col = obj.AddComponent(); EnemyDefinition def = ScriptableObject.CreateInstance(); def.Configure(kind, EnemyAttackShape.Box, 10f, 1f, 1f, 0.5f, 0.5f, 0.5f, 1f, 0f, 1f, 1f, 1f, 1); def.ConfigureFlying(isFlying); def.ConfigureSightRange(8f); EnemyController controller = obj.AddComponent(); FieldInfo defField = typeof(EnemyController).GetField("definition", BindingFlags.NonPublic | BindingFlags.Instance); defField?.SetValue(controller, def); MethodInfo configureModel = typeof(EnemyController).GetMethod("ConfigureEnemyModel", BindingFlags.NonPublic | BindingFlags.Instance); configureModel?.Invoke(controller, null); FieldInfo healthField = typeof(EnemyController).GetField("currentHealth", BindingFlags.NonPublic | BindingFlags.Instance); if (healthField != null) { healthField.SetValue(controller, 10f); } else { PropertyInfo healthProp = typeof(EnemyController).GetProperty("CurrentHealth", BindingFlags.Public | BindingFlags.Instance); healthProp?.GetSetMethod(true)?.Invoke(controller, new object[] { 10f }); } return obj; } } }