From c7739de7d9ec4d7cc845ca21d303a054487e1344 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 30 Oct 2025 21:53:20 +0100 Subject: [PATCH] Add AI components and systems for enhanced enemy behavior and pathfinding --- GameCore/AI/AIAimSystem.cs | 54 ++++++++++ GameCore/AI/AIComponent.cs | 20 ++++ GameCore/AI/AIPathfindingSystem.cs | 97 ++++++++++++++++++ GameCore/AI/AIPerceptionSystem.cs | 130 +++++++++++++++++++++++++ GameCore/AI/AIState.cs | 9 ++ GameCore/AI/PatrolComponent.cs | 11 +++ GameCore/ECS/Interfaces/IWorldQuery.cs | 1 + GameCore/Input/PlayerInputSystem.cs | 21 +++- GameCore/Math/Vector3.cs | 5 + GameCore/Movement/GravitySystem.cs | 5 +- GameCore/Movement/RotationSystem.cs | 23 ++++- 11 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 GameCore/AI/AIAimSystem.cs create mode 100644 GameCore/AI/AIComponent.cs create mode 100644 GameCore/AI/AIPathfindingSystem.cs create mode 100644 GameCore/AI/AIPerceptionSystem.cs create mode 100644 GameCore/AI/AIState.cs create mode 100644 GameCore/AI/PatrolComponent.cs diff --git a/GameCore/AI/AIAimSystem.cs b/GameCore/AI/AIAimSystem.cs new file mode 100644 index 0000000..1c06ff3 --- /dev/null +++ b/GameCore/AI/AIAimSystem.cs @@ -0,0 +1,54 @@ +using GameCore.Combat; +using GameCore.ECS; +using GameCore.ECS.Interfaces; +using GameCore.Input; +using GameCore.Physics; +using GameCore.Player; + +namespace GameCore.AI; + +public class AIAimSystem : ISystem +{ + private const float MinAimDistance = 0.5f; + + public void Update(World world, float deltaTime) + { + var entities = world.GetEntitiesWith(); + foreach (var entity in entities) + { + if (world.GetComponent(entity) != null) + { + world.Logger.Warn("AI entity has PlayerComponent; skipping AI aim processing."); + continue; + } + + var ai = world.GetComponent(entity); + var input = world.GetComponent(entity); + var pos = world.GetComponent(entity); + var weapon = world.GetComponent(entity); + + if (ai == null || input == null || pos == null || weapon == null) + { + if (input != null) input.IsFiring = false; + continue; + } + + input.IsFiring = false; + + if (ai.CurrentState == AIState.Chase || ai.CurrentState == AIState.Attack) + { + var vectorToTarget = ai.LastKnownTargetPosition - input.MuzzlePosition; + var distanceToTarget = vectorToTarget.Length(); + + if (distanceToTarget > MinAimDistance) + { + var directionToTarget = vectorToTarget.Normalize(); + input.MuzzleDirection = directionToTarget; + input.LookDirection = directionToTarget; + } + } + + if (ai.CurrentState == AIState.Attack) input.IsFiring = true; + } + } +} \ No newline at end of file diff --git a/GameCore/AI/AIComponent.cs b/GameCore/AI/AIComponent.cs new file mode 100644 index 0000000..6b70962 --- /dev/null +++ b/GameCore/AI/AIComponent.cs @@ -0,0 +1,20 @@ +using GameCore.ECS; +using GameCore.ECS.Interfaces; +using GameCore.Math; + +namespace GameCore.AI; + +public class AIComponent : IComponent +{ + public AIState CurrentState { get; set; } = AIState.Idle; + public Entity? Target { get; set; } + public float StateTimer { get; set; } = 0.0f; + public Vector3 LastKnownTargetPosition { get; set; } = Vector3.Zero; + + public float SightRange { get; set; } = 20.0f; + public float FieldOfView { get; set; } = 90.0f; // in degrees + public float AttackRange { get; set; } = 10.0f; + public float ChaseGiveUpTime { get; set; } = 5.0f; + public float ReactionTime { get; set; } = 0.5f; + public float AttackRangeBufferPercent { get; set; } = 0.2f; +} \ No newline at end of file diff --git a/GameCore/AI/AIPathfindingSystem.cs b/GameCore/AI/AIPathfindingSystem.cs new file mode 100644 index 0000000..8f0db77 --- /dev/null +++ b/GameCore/AI/AIPathfindingSystem.cs @@ -0,0 +1,97 @@ +using GameCore.ECS; +using GameCore.ECS.Interfaces; +using GameCore.Input; +using GameCore.Math; +using GameCore.Physics; +using GameCore.Player; + +namespace GameCore.AI; + +public class AIPathfindingSystem : ISystem +{ + private const float WaypointReachThreshold = 0.5f; + + public void Update(World world, float deltaTime) + { + var entities = world.GetEntitiesWith(); + foreach (var entity in entities) + { + if (world.GetComponent(entity) != null) continue; + + var ai = world.GetComponent(entity); + var input = world.GetComponent(entity); + var pos = world.GetComponent(entity); + if (ai == null || input == null || pos == null) continue; + + input.MoveDirection = Vector3.Zero; + Vector3? targetPosition = null; + + switch (ai.CurrentState) + { + case AIState.Chase: + targetPosition = ai.LastKnownTargetPosition; + world.Logger.Debug($"AI Entity {entity.Id} chasing target at {targetPosition}"); + break; + case AIState.Patrol: + var patrol = world.GetComponent(entity); + if (patrol == null || patrol.PatrolPoints.Count == 0) + { + ai.CurrentState = AIState.Idle; + world.Logger.Debug($"AI Entity {entity.Id} has no patrol points, switching to Idle state."); + continue; + } + + targetPosition = patrol.PatrolPoints[patrol.CurrentPatrolIndex]; + var horizontalDiff = new Vector3(targetPosition.Value.X - pos.Position.X, 0f, + targetPosition.Value.Z - pos.Position.Z); + var distanceToPatrolPoint = horizontalDiff.Length(); + + if (distanceToPatrolPoint < WaypointReachThreshold) + { + patrol.CurrentPatrolIndex++; + if (patrol.CurrentPatrolIndex >= patrol.PatrolPoints.Count) + patrol.CurrentPatrolIndex = patrol.IsLooping ? 0 : patrol.PatrolPoints.Count - 1; + targetPosition = patrol.PatrolPoints[patrol.CurrentPatrolIndex]; + world.Logger.Debug( + $"AI Entity {entity.Id} reached patrol point, moving to next point at {targetPosition}"); + } + + break; + case AIState.Idle: + case AIState.Attack: + world.Logger.Debug($"AI Entity {entity.Id} is in state {ai.CurrentState}, not moving."); + continue; + } + + if (targetPosition == null) + { + world.Logger.Debug($"AI Entity {entity.Id} has no target position."); + continue; + } + + var path = world.WorldQuery.GetPath(pos.Position, targetPosition.Value); + world.Logger.Debug($"Path for AI Entity {entity.Id}: {string.Join(" -> ", path)}"); + + if (path.Count > 0) + { + var nextWaypoint = path[0]; + var horizontalDiffToWaypoint = + new Vector3(nextWaypoint.X - pos.Position.X, 0f, nextWaypoint.Z - pos.Position.Z); + if (horizontalDiffToWaypoint.Length() < 0.1f && path.Count > 1) + { + nextWaypoint = path[1]; + world.Logger.Debug($"AI Entity {entity.Id} skipping first waypoint, moving to {nextWaypoint}"); + } + + var directionVector = nextWaypoint - pos.Position; + input.MoveDirection = new Vector3(directionVector.X, 0f, directionVector.Z).Normalize(); + world.Logger.Debug( + $"AI Entity {entity.Id} moving towards waypoint at {nextWaypoint} with direction {input.MoveDirection}"); + } + else + { + world.Logger.Debug($"AI Entity {entity.Id} has no path."); + } + } + } +} \ No newline at end of file diff --git a/GameCore/AI/AIPerceptionSystem.cs b/GameCore/AI/AIPerceptionSystem.cs new file mode 100644 index 0000000..72a9d3b --- /dev/null +++ b/GameCore/AI/AIPerceptionSystem.cs @@ -0,0 +1,130 @@ +using GameCore.ECS; +using GameCore.ECS.Interfaces; +using GameCore.Math; +using GameCore.Movement; +using GameCore.Physics; +using GameCore.Player; + +namespace GameCore.AI; + +public class AIPerceptionSystem : ISystem +{ + private readonly Vector3 _aiEyeOffset = new(0f, 1.5f, 0f); + private readonly Vector3 _playerCenterOffset = new(0f, 1.0f, 0f); + + public void Update(World world, float deltaTime) + { + var playerEntities = world.GetEntitiesWith().ToList(); + if (!playerEntities.Any()) + { + world.Logger.Warn("No player entity found for AI perception."); + return; + } + + var playerEntity = playerEntities.First(); + var playerPos = world.GetComponent(playerEntity); + if (playerPos == null) + { + world.Logger.Warn("No player position found for AI perception."); + return; + } + + var aiEntities = world.GetEntitiesWith(); + foreach (var entity in aiEntities) + { + if (world.GetComponent(entity) != null) + { + world.Logger.Warn("AI Perception System: Skipping player entity."); + continue; + } + + var ai = world.GetComponent(entity); + var aiPos = world.GetComponent(entity); + var aiRot = world.GetComponent(entity); + if (ai == null || aiPos == null || aiRot == null) + { + world.Logger.Warn("AI Perception System: Skipping AI perception. Missing components."); + continue; + } + + ai.StateTimer += deltaTime; + + var aiEyePos = aiPos.Position + _aiEyeOffset; + var playerCenterPos = playerPos.Position + _playerCenterOffset; + + var vectorToPlayer = playerCenterPos - aiEyePos; + var distanceToPlayer = vectorToPlayer.Length(); + var directionToPlayer = vectorToPlayer.Normalize(); + + if (distanceToPlayer < ai.SightRange && + IsInFieldOfView(world, ai, aiRot, directionToPlayer) && + HasLineOfSight(world, aiEyePos, playerCenterPos, entity)) + { + ai.Target = playerEntity; + ai.LastKnownTargetPosition = playerPos.Position; + ai.StateTimer = 0.0f; + + var attackRangeBuffer = ai.AttackRange * ai.AttackRangeBufferPercent; + if (distanceToPlayer <= ai.AttackRange) + { + if (ai.CurrentState != AIState.Attack) + { + ai.CurrentState = AIState.Attack; + world.Logger.Debug($"AI Entity {entity.Id} switching to Attack state."); + } + } + else if (distanceToPlayer > ai.AttackRange + attackRangeBuffer) + { + if (ai.CurrentState != AIState.Chase) + { + ai.CurrentState = AIState.Chase; + world.Logger.Debug($"AI Entity {entity.Id} switching to Chase state."); + } + } + } + else + { + if (ai.Target != null) + { + if (ai.StateTimer > ai.ChaseGiveUpTime) + { + ai.Target = null; + ai.CurrentState = world.GetComponent(entity) != null + ? AIState.Patrol + : AIState.Idle; + world.Logger.Debug($"AI Entity {entity.Id} lost target, switching to {ai.CurrentState} state."); + } + else + { + ai.CurrentState = AIState.Chase; + world.Logger.Debug($"AI Entity {entity.Id} continuing to chase last known target position."); + } + } + else + { + ai.CurrentState = world.GetComponent(entity) != null + ? AIState.Patrol + : AIState.Idle; + world.Logger.Debug($"AI Entity {entity.Id} has no target, switching to {ai.CurrentState} state."); + } + } + } + } + + private bool HasLineOfSight(World world, Vector3 from, Vector3 to, Entity owner) + { + var hit = world.WorldQuery.Raycast(from, to, owner); + return hit.DidHit && hit.HitEntity.HasValue && world.GetComponent(hit.HitEntity.Value) != null; + } + + private bool IsInFieldOfView(World world, AIComponent ai, RotationComponent aiRot, Vector3 directionToPlayer) + { + var aiForward = world.WorldQuery.RotateVectorByYaw(new Vector3(0f, 0f, 1f), aiRot.Rotation.Y); + + var dotProduct = Vector3.Dot(aiForward, directionToPlayer); + var fovRadians = ai.FieldOfView * (float)System.Math.PI / 180f; + var fovThreshold = (float)System.Math.Cos(fovRadians / 2f); + + return dotProduct >= fovThreshold; + } +} \ No newline at end of file diff --git a/GameCore/AI/AIState.cs b/GameCore/AI/AIState.cs new file mode 100644 index 0000000..319c0e7 --- /dev/null +++ b/GameCore/AI/AIState.cs @@ -0,0 +1,9 @@ +namespace GameCore.AI; + +public enum AIState +{ + Idle, + Patrol, + Chase, + Attack +} \ No newline at end of file diff --git a/GameCore/AI/PatrolComponent.cs b/GameCore/AI/PatrolComponent.cs new file mode 100644 index 0000000..f37a9b5 --- /dev/null +++ b/GameCore/AI/PatrolComponent.cs @@ -0,0 +1,11 @@ +using GameCore.ECS.Interfaces; +using GameCore.Math; + +namespace GameCore.AI; + +public class PatrolComponent : IComponent +{ + public List PatrolPoints { get; set; } = []; + public int CurrentPatrolIndex { get; set; } = 0; + public bool IsLooping { get; set; } = false; +} \ No newline at end of file diff --git a/GameCore/ECS/Interfaces/IWorldQuery.cs b/GameCore/ECS/Interfaces/IWorldQuery.cs index 81d2223..d621c9b 100644 --- a/GameCore/ECS/Interfaces/IWorldQuery.cs +++ b/GameCore/ECS/Interfaces/IWorldQuery.cs @@ -7,4 +7,5 @@ public interface IWorldQuery HitResult Raycast(Vector3 from, Vector3 to, Entity? ownerToExclude = null); Vector3 RotateVectorByYaw(Vector3 vector, float yawRadians); IEnumerable OverlapSphere(Vector3 position, float radius, Entity? ownerToExclude = null); + List GetPath(Vector3 start, Vector3 end); } \ No newline at end of file diff --git a/GameCore/Input/PlayerInputSystem.cs b/GameCore/Input/PlayerInputSystem.cs index d1843d2..5b6c7a0 100644 --- a/GameCore/Input/PlayerInputSystem.cs +++ b/GameCore/Input/PlayerInputSystem.cs @@ -1,3 +1,4 @@ +using GameCore.AI; using GameCore.ECS; using GameCore.ECS.Interfaces; using GameCore.Player; @@ -8,12 +9,26 @@ public class PlayerInputSystem : ISystem { public void Update(World world, float deltaTime) { - var playerEntities = world.GetEntitiesWith(); - if (!playerEntities.Any()) return; + var playerEntities = world.GetEntitiesWith().ToList(); + if (!playerEntities.Any()) + { + world.Logger.Warn("No player entity found for input processing."); + return; + } var playerEntity = playerEntities.First(); + if (world.GetComponent(playerEntity) != null) + { + world.Logger.Warn("Player entity has AIComponent; skipping input processing."); + return; + } + var inputState = world.GetComponent(playerEntity); - if (inputState == null) return; + if (inputState == null) + { + world.Logger.Warn("Player entity is missing InputStateComponent; cannot process input."); + return; + } inputState.MoveDirection = world.InputService.MoveDirection.Normalize(); inputState.LookDirection = world.InputService.LookDirection; diff --git a/GameCore/Math/Vector3.cs b/GameCore/Math/Vector3.cs index 6157dd9..56f85b9 100644 --- a/GameCore/Math/Vector3.cs +++ b/GameCore/Math/Vector3.cs @@ -76,4 +76,9 @@ public struct Vector3 return new Vector3(X / length, Y / length, Z / length); } + + public static float Dot(Vector3 a, Vector3 b) + { + return a.X * b.X + a.Y * b.Y + a.Z * b.Z; + } } \ No newline at end of file diff --git a/GameCore/Movement/GravitySystem.cs b/GameCore/Movement/GravitySystem.cs index 71201a7..f6803df 100644 --- a/GameCore/Movement/GravitySystem.cs +++ b/GameCore/Movement/GravitySystem.cs @@ -8,14 +8,17 @@ public class GravitySystem : ISystem { public void Update(World world, float deltaTime) { - var entities = world.GetEntitiesWith(); + var entities = world.GetEntitiesWith(); foreach (var entity in entities) { var velocity = world.GetComponent(entity); var characterState = world.GetComponent(entity); if (velocity == null || characterState == null) + { + world.Logger.Warn("GravitySystem: Missing VelocityComponent or CharacterStateComponent."); continue; + } if (!characterState.IsOnFloor) velocity.DesiredVelocity.Y -= world.Config.GravityStrength * deltaTime; } diff --git a/GameCore/Movement/RotationSystem.cs b/GameCore/Movement/RotationSystem.cs index 6fae4d8..60893c1 100644 --- a/GameCore/Movement/RotationSystem.cs +++ b/GameCore/Movement/RotationSystem.cs @@ -1,6 +1,7 @@ using GameCore.ECS; using GameCore.ECS.Interfaces; using GameCore.Input; +using GameCore.Player; namespace GameCore.Movement; @@ -18,10 +19,28 @@ public class RotationSystem : ISystem var rotation = world.GetComponent(entity); if (input == null || rotation == null) + { + world.Logger.Warn("RotationSystem: Missing InputStateComponent or RotationComponent."); continue; + } + + if (world.GetComponent(entity) != null) + { + rotation.Rotation.Y += input.LookDirection.Y; + rotation.Rotation.X += input.LookDirection.X; + } + else + { + if (input.LookDirection.Length() > 0.001f) + { + var lookDir = input.LookDirection; + rotation.Rotation.Y = (float)System.Math.Atan2(lookDir.X, lookDir.Z); + + var horizontalDist = (float)System.Math.Sqrt(lookDir.X * lookDir.X + lookDir.Z * lookDir.Z); + rotation.Rotation.X = -(float)System.Math.Atan2(lookDir.Y, horizontalDist); + } + } - rotation.Rotation.Y += input.LookDirection.Y; - rotation.Rotation.X += input.LookDirection.X; rotation.Rotation.X = System.Math.Clamp(rotation.Rotation.X, MinPitch, MaxPitch); }