Add AI components and systems for enhanced enemy behavior and pathfinding

This commit is contained in:
2025-10-30 21:53:20 +01:00
parent f65277e6b4
commit c7739de7d9
11 changed files with 370 additions and 6 deletions

View File

@@ -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<AIComponent>();
foreach (var entity in entities)
{
if (world.GetComponent<PlayerComponent>(entity) != null)
{
world.Logger.Warn("AI entity has PlayerComponent; skipping AI aim processing.");
continue;
}
var ai = world.GetComponent<AIComponent>(entity);
var input = world.GetComponent<InputStateComponent>(entity);
var pos = world.GetComponent<PositionComponent>(entity);
var weapon = world.GetComponent<WeaponComponent>(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;
}
}
}

View File

@@ -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;
}

View File

@@ -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<AIComponent>();
foreach (var entity in entities)
{
if (world.GetComponent<PlayerComponent>(entity) != null) continue;
var ai = world.GetComponent<AIComponent>(entity);
var input = world.GetComponent<InputStateComponent>(entity);
var pos = world.GetComponent<PositionComponent>(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<PatrolComponent>(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.");
}
}
}
}

View File

@@ -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<PlayerComponent>().ToList();
if (!playerEntities.Any())
{
world.Logger.Warn("No player entity found for AI perception.");
return;
}
var playerEntity = playerEntities.First();
var playerPos = world.GetComponent<PositionComponent>(playerEntity);
if (playerPos == null)
{
world.Logger.Warn("No player position found for AI perception.");
return;
}
var aiEntities = world.GetEntitiesWith<AIComponent>();
foreach (var entity in aiEntities)
{
if (world.GetComponent<PlayerComponent>(entity) != null)
{
world.Logger.Warn("AI Perception System: Skipping player entity.");
continue;
}
var ai = world.GetComponent<AIComponent>(entity);
var aiPos = world.GetComponent<PositionComponent>(entity);
var aiRot = world.GetComponent<RotationComponent>(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<PatrolComponent>(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<PatrolComponent>(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<PlayerComponent>(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;
}
}

9
GameCore/AI/AIState.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace GameCore.AI;
public enum AIState
{
Idle,
Patrol,
Chase,
Attack
}

View File

@@ -0,0 +1,11 @@
using GameCore.ECS.Interfaces;
using GameCore.Math;
namespace GameCore.AI;
public class PatrolComponent : IComponent
{
public List<Vector3> PatrolPoints { get; set; } = [];
public int CurrentPatrolIndex { get; set; } = 0;
public bool IsLooping { get; set; } = false;
}