385 lines
17 KiB
C#
385 lines
17 KiB
C#
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<GameObject> 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<PlayerController>();
|
|
|
|
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
|
|
enemyObject.transform.position = Vector3.zero;
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
|
|
// 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<PlayerController>();
|
|
|
|
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
|
|
enemyObject.transform.position = Vector3.zero;
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
|
|
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<PlayerController>();
|
|
|
|
// 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<BoxCollider2D>();
|
|
obsCol.size = new Vector2(2f, 2f);
|
|
obstacle = obstacleObject.AddComponent<StageObstacle>();
|
|
|
|
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
|
|
enemyObject.transform.position = Vector3.zero;
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
|
|
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<PlayerController>();
|
|
|
|
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
|
|
enemyObject.transform.position = Vector3.zero;
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
|
|
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<BoxCollider2D>();
|
|
obstacleCollider.size = new Vector2(0.5f, 2f);
|
|
obstacle = obstacleObject.AddComponent<StageObstacle>();
|
|
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<PlayerController>();
|
|
|
|
obstacleObject = new GameObject("Obstacle_Pillar");
|
|
obstacleObject.transform.position = new Vector3(3f, 0f, 0f);
|
|
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
|
|
obsCol.size = new Vector2(2f, 2f);
|
|
obstacle = obstacleObject.AddComponent<StageObstacle>();
|
|
|
|
enemyObject = CreateEnemyObject("Bat", EnemyKind.Bat, isFlying: true);
|
|
enemyObject.transform.position = Vector3.zero;
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
|
|
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<PlayerController>();
|
|
|
|
// 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<BoxCollider2D>();
|
|
obsCol.size = new Vector2(0.5f, 2f);
|
|
obstacle = obstacleObject.AddComponent<StageObstacle>();
|
|
|
|
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
|
|
enemyObject.transform.position = Vector3.zero;
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
|
|
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<PlayerController>();
|
|
|
|
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
|
|
enemyObject.transform.position = Vector3.zero;
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
|
|
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<PlayerController>();
|
|
|
|
enemyObject = CreateEnemyObject("NearLeader", EnemyKind.Skeleton, isFlying: false);
|
|
enemyObject.transform.position = new Vector3(3.5f, 0f, 0f);
|
|
enemy = enemyObject.GetComponent<EnemyController>();
|
|
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<EnemyController>();
|
|
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<EnemyController>();
|
|
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<ZoneInstance>();
|
|
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<BoxCollider2D>();
|
|
obsCol.size = new Vector2(1f, 1f);
|
|
obstacle = obstacleObject.AddComponent<StageObstacle>();
|
|
|
|
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<ZoneInstance>();
|
|
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<Rigidbody2D>();
|
|
CircleCollider2D col = obj.AddComponent<CircleCollider2D>();
|
|
|
|
EnemyDefinition def = ScriptableObject.CreateInstance<EnemyDefinition>();
|
|
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<EnemyController>();
|
|
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;
|
|
}
|
|
}
|
|
}
|