Browse Source

Add enemy Steering Behaviour. Idle, Chase and Attack, for now.

CartBlanche 1 week ago
parent
commit
2d21cbd73f

+ 168 - 0
Shooter/Gameplay/AI/SteeringBehaviors.cs

@@ -0,0 +1,168 @@
+using System.Numerics;
+
+namespace Shooter.Gameplay.AI;
+
+/// <summary>
+/// Steering behaviors for AI movement.
+/// Adapted from the ChaseAndEvade sample and extended for 3D.
+///
+/// PATTERN: Steering Behaviors (Boids, Reynolds)
+/// Common AI movement patterns like seek, flee, wander, pursuit, evade.
+///
+/// UNITY COMPARISON:
+/// Unity: Often uses NavMeshAgent for automatic steering
+/// MonoGame: Manual implementation of steering behaviors gives more control
+/// </summary>
+public static class SteeringBehaviors
+{
+    /// <summary>
+    /// Smoothly turn to face a target position.
+    /// Returns the new orientation (yaw angle) after turning.
+    ///
+    /// Adapted from ChaseAndEvade sample.
+    /// </summary>
+    /// <param name="currentPosition">Current entity position</param>
+    /// <param name="targetPosition">Position to face toward</param>
+    /// <param name="currentYaw">Current yaw angle in radians</param>
+    /// <param name="turnSpeed">Maximum turn rate in radians per second</param>
+    /// <param name="deltaTime">Time since last frame in seconds</param>
+    /// <returns>New yaw angle in radians</returns>
+    public static float TurnToFace(Vector3 currentPosition, Vector3 targetPosition,
+        float currentYaw, float turnSpeed, float deltaTime)
+    {
+        // Calculate direction vector (flatten to horizontal plane)
+        Vector3 direction = targetPosition - currentPosition;
+        direction.Y = 0;
+
+        // Handle zero-length vector
+        if (direction.LengthSquared() < 0.001f)
+            return currentYaw;
+
+        direction = Vector3.Normalize(direction);
+
+        // Calculate desired yaw angle
+        float desiredYaw = MathF.Atan2(direction.X, direction.Z);
+
+        // Calculate shortest turn angle
+        float angleDifference = WrapAngle(desiredYaw - currentYaw);
+
+        // Clamp turn amount by turn speed
+        float maxTurn = turnSpeed * deltaTime;
+        float turnAmount = Math.Clamp(angleDifference, -maxTurn, maxTurn);
+
+        // Return new orientation
+        return WrapAngle(currentYaw + turnAmount);
+    }
+
+    /// <summary>
+    /// Generate a wander direction for idle movement.
+    /// Returns a direction vector for wandering behavior.
+    ///
+    /// Adapted from ChaseAndEvade sample for 3D.
+    /// </summary>
+    /// <param name="currentPosition">Current entity position</param>
+    /// <param name="currentDirection">Current heading direction (normalized)</param>
+    /// <param name="centerPosition">Center point to orbit around</param>
+    /// <param name="wanderStrength">How much random variation (0-1)</param>
+    /// <param name="deltaTime">Time since last frame</param>
+    /// <returns>New wander direction (normalized)</returns>
+    public static Vector3 Wander(Vector3 currentPosition, Vector3 currentDirection,
+        Vector3 centerPosition, float wanderStrength, float deltaTime)
+    {
+        // Add random variation to current direction
+        Random random = new Random();
+        float randomAngle = ((float)random.NextDouble() - 0.5f) * wanderStrength;
+
+        // Rotate direction by random angle (around Y axis)
+        float cos = MathF.Cos(randomAngle);
+        float sin = MathF.Sin(randomAngle);
+        Vector3 newDirection = new Vector3(
+            currentDirection.X * cos - currentDirection.Z * sin,
+            0,
+            currentDirection.X * sin + currentDirection.Z * cos
+        );
+
+        // Calculate vector toward center
+        Vector3 toCenter = centerPosition - currentPosition;
+        toCenter.Y = 0;
+        float distanceFromCenter = toCenter.Length();
+
+        // Add center attraction (stronger as we get farther from center)
+        if (distanceFromCenter > 0.1f)
+        {
+            Vector3 centerDirection = Vector3.Normalize(toCenter);
+            float centerWeight = Math.Min(distanceFromCenter * 0.1f, 1.0f);
+            newDirection = Vector3.Lerp(newDirection, centerDirection, centerWeight);
+        }
+
+        return Vector3.Normalize(newDirection);
+    }
+
+    /// <summary>
+    /// Calculate evade direction (flee from a target).
+    /// Returns a position to move toward that's away from the threat.
+    ///
+    /// Adapted from ChaseAndEvade sample's mouse evade behavior.
+    /// </summary>
+    /// <param name="currentPosition">Current entity position</param>
+    /// <param name="threatPosition">Position to flee from</param>
+    /// <returns>Target position to move toward (away from threat)</returns>
+    public static Vector3 Evade(Vector3 currentPosition, Vector3 threatPosition)
+    {
+        // Calculate escape vector (opposite side from threat)
+        // Formula: seekPosition = 2 * currentPosition - threatPosition
+        // This creates a point directly opposite the threat
+        return 2 * currentPosition - threatPosition;
+    }
+
+    /// <summary>
+    /// Wrap an angle to the range [-PI, PI].
+    /// Prevents angle accumulation issues.
+    ///
+    /// From ChaseAndEvade sample.
+    /// </summary>
+    public static float WrapAngle(float angle)
+    {
+        while (angle > MathF.PI)
+            angle -= MathF.PI * 2;
+        while (angle < -MathF.PI)
+            angle += MathF.PI * 2;
+        return angle;
+    }
+
+    /// <summary>
+    /// Calculate arrival behavior - slow down as approaching target.
+    /// Returns desired speed based on distance to target.
+    /// </summary>
+    /// <param name="distanceToTarget">Distance from target</param>
+    /// <param name="slowingRadius">Distance at which to start slowing</param>
+    /// <param name="maxSpeed">Maximum speed</param>
+    /// <returns>Desired speed (0 to maxSpeed)</returns>
+    public static float Arrival(float distanceToTarget, float slowingRadius, float maxSpeed)
+    {
+        if (distanceToTarget < slowingRadius)
+        {
+            // Slow down proportionally as we approach target
+            return maxSpeed * (distanceToTarget / slowingRadius);
+        }
+        return maxSpeed;
+    }
+
+    /// <summary>
+    /// Simple obstacle avoidance using a forward raycast.
+    /// Returns a steering force to avoid obstacles.
+    /// </summary>
+    /// <param name="currentDirection">Current heading direction</param>
+    /// <param name="hasObstacle">Whether an obstacle was detected ahead</param>
+    /// <param name="avoidanceForce">Strength of avoidance</param>
+    /// <returns>Avoidance steering vector</returns>
+    public static Vector3 AvoidObstacle(Vector3 currentDirection, bool hasObstacle, float avoidanceForce)
+    {
+        if (!hasObstacle)
+            return Vector3.Zero;
+
+        // Turn perpendicular to current direction to avoid obstacle
+        // Rotate 90 degrees to the right (around Y axis)
+        return new Vector3(currentDirection.Z, 0, -currentDirection.X) * avoidanceForce;
+    }
+}

+ 94 - 23
Shooter/Gameplay/Components/EnemyAI.cs

@@ -2,6 +2,7 @@ using Shooter.Core.Components;
 using Shooter.Core.Entities;
 using Shooter.Core.Entities;
 using Shooter.Core.Services;
 using Shooter.Core.Services;
 using Shooter.Gameplay.Systems;
 using Shooter.Gameplay.Systems;
+using Shooter.Gameplay.AI;
 using System.Numerics;
 using System.Numerics;
 using System.Linq;
 using System.Linq;
 
 
@@ -57,13 +58,14 @@ public class EnemyAI : EntityComponent
     // State machine
     // State machine
     private AIState _currentState = AIState.Idle;
     private AIState _currentState = AIState.Idle;
     private float _stateTimer = 0f;
     private float _stateTimer = 0f;
-    
+
     // Target tracking
     // Target tracking
     private Entity? _target;
     private Entity? _target;
     private Transform3D? _targetTransform;
     private Transform3D? _targetTransform;
     private Transform3D? _transform;
     private Transform3D? _transform;
     private Health? _health;
     private Health? _health;
     private EnemyController? _enemyController;
     private EnemyController? _enemyController;
+    private NavigationModule? _navigationModule;
 
 
     // AI parameters
     // AI parameters
     private float _detectionRange = 20f;
     private float _detectionRange = 20f;
@@ -71,12 +73,17 @@ public class EnemyAI : EntityComponent
     private float _attackStopDistanceRatio = 0.5f; // Stop at 50% of attack range (Unity default)
     private float _attackStopDistanceRatio = 0.5f; // Stop at 50% of attack range (Unity default)
     private float _moveSpeed = 3f;
     private float _moveSpeed = 3f;
     private float _turnSpeed = 5f;
     private float _turnSpeed = 5f;
-    
+
     // Line of sight
     // Line of sight
     private float _losCheckInterval = 0.5f;
     private float _losCheckInterval = 0.5f;
     private float _losCheckTimer = 0f;
     private float _losCheckTimer = 0f;
     private bool _hasLineOfSight = false;
     private bool _hasLineOfSight = false;
 
 
+    // Steering and orientation
+    private float _currentYaw = 0f; // Current rotation in radians
+    private Vector3 _wanderDirection = Vector3.UnitZ; // Current wander heading
+    private Vector3 _spawnPosition = Vector3.Zero; // For wander center point
+
     /// <summary>
     /// <summary>
     /// Current AI state
     /// Current AI state
     /// </summary>
     /// </summary>
@@ -155,6 +162,25 @@ public class EnemyAI : EntityComponent
         _transform = Owner?.GetComponent<Transform3D>();
         _transform = Owner?.GetComponent<Transform3D>();
         _health = Owner?.GetComponent<Health>();
         _health = Owner?.GetComponent<Health>();
         _enemyController = Owner?.GetComponent<EnemyController>();
         _enemyController = Owner?.GetComponent<EnemyController>();
+        _navigationModule = Owner?.GetComponent<NavigationModule>();
+
+        // Store spawn position for wander behavior
+        if (_transform != null)
+        {
+            _spawnPosition = _transform.Position;
+
+            // Initialize yaw from current rotation
+            var rotation = _transform.Rotation;
+            _currentYaw = MathF.Atan2(2 * (rotation.W * rotation.Y + rotation.X * rotation.Z),
+                1 - 2 * (rotation.Y * rotation.Y + rotation.Z * rotation.Z));
+        }
+
+        // Use NavigationModule movement speed if available
+        if (_navigationModule != null)
+        {
+            _moveSpeed = _navigationModule.MoveSpeed;
+            _turnSpeed = _navigationModule.AngularSpeed * (MathF.PI / 180f); // Convert degrees to radians
+        }
 
 
         // Subscribe to death event
         // Subscribe to death event
         if (_health != null)
         if (_health != null)
@@ -227,7 +253,7 @@ public class EnemyAI : EntityComponent
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// Update idle state - look for player
+    /// Update idle state - wander around spawn point and look for player
     /// </summary>
     /// </summary>
     private void UpdateIdle(float deltaTime)
     private void UpdateIdle(float deltaTime)
     {
     {
@@ -247,43 +273,80 @@ public class EnemyAI : EntityComponent
         if (distanceToTarget <= _detectionRange && _hasLineOfSight)
         if (distanceToTarget <= _detectionRange && _hasLineOfSight)
         {
         {
             CurrentState = AIState.Chase;
             CurrentState = AIState.Chase;
+            return;
         }
         }
+
+        // Wander behavior - patrol around spawn point
+        _wanderDirection = SteeringBehaviors.Wander(
+            _transform.Position,
+            _wanderDirection,
+            _spawnPosition,
+            0.25f,  // Wander strength
+            deltaTime
+        );
+
+        // Smooth turn toward wander direction
+        Vector3 wanderTarget = _transform.Position + _wanderDirection * 10f;
+        _currentYaw = SteeringBehaviors.TurnToFace(
+            _transform.Position,
+            wanderTarget,
+            _currentYaw,
+            _turnSpeed,
+            deltaTime
+        );
+
+        // Move in current direction
+        Vector3 heading = new Vector3(MathF.Sin(_currentYaw), 0, MathF.Cos(_currentYaw));
+        float wanderSpeed = _moveSpeed * 0.5f; // Move slower when wandering
+        _transform.Position += heading * wanderSpeed * deltaTime;
+
+        // Update rotation
+        _transform.Rotation = Quaternion.CreateFromYawPitchRoll(_currentYaw, 0, 0);
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// Update chase state - move toward player
+    /// Update chase state - move toward player with smooth rotation
     /// </summary>
     /// </summary>
     private void UpdateChase(float deltaTime)
     private void UpdateChase(float deltaTime)
     {
     {
         if (_targetTransform == null || _transform == null)
         if (_targetTransform == null || _transform == null)
             return;
             return;
-        
+
         float distanceToTarget = Vector3.Distance(_transform.Position, _targetTransform.Position);
         float distanceToTarget = Vector3.Distance(_transform.Position, _targetTransform.Position);
-        
+
         // If player is in attack range, switch to attack
         // If player is in attack range, switch to attack
         if (distanceToTarget <= _attackRange)
         if (distanceToTarget <= _attackRange)
         {
         {
             CurrentState = AIState.Attack;
             CurrentState = AIState.Attack;
             return;
             return;
         }
         }
-        
+
         // If player is too far or not visible, return to idle
         // If player is too far or not visible, return to idle
         if (distanceToTarget > _detectionRange || !_hasLineOfSight)
         if (distanceToTarget > _detectionRange || !_hasLineOfSight)
         {
         {
             CurrentState = AIState.Idle;
             CurrentState = AIState.Idle;
             return;
             return;
         }
         }
-        
-        // Move toward target
-        Vector3 direction = Vector3.Normalize(_targetTransform.Position - _transform.Position);
-        _transform.Position += direction * _moveSpeed * deltaTime;
-        
-        // Rotate toward target
-        LookAt(_targetTransform.Position, deltaTime);
+
+        // Smooth turn toward player using steering behavior
+        _currentYaw = SteeringBehaviors.TurnToFace(
+            _transform.Position,
+            _targetTransform.Position,
+            _currentYaw,
+            _turnSpeed,
+            deltaTime
+        );
+
+        // Move toward target with acceleration
+        Vector3 heading = new Vector3(MathF.Sin(_currentYaw), 0, MathF.Cos(_currentYaw));
+        _transform.Position += heading * _moveSpeed * deltaTime;
+
+        // Update rotation
+        _transform.Rotation = Quaternion.CreateFromYawPitchRoll(_currentYaw, 0, 0);
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// Update attack state - shoot at player
+    /// Update attack state - shoot at player with smooth rotation
     /// </summary>
     /// </summary>
     private void UpdateAttack(float deltaTime)
     private void UpdateAttack(float deltaTime)
     {
     {
@@ -302,30 +365,38 @@ public class EnemyAI : EntityComponent
         // Calculate stop distance based on attack range ratio
         // Calculate stop distance based on attack range ratio
         float stopDistance = _attackRange * _attackStopDistanceRatio;
         float stopDistance = _attackRange * _attackStopDistanceRatio;
 
 
+        // Smooth turn toward player using steering behavior (faster when attacking)
+        _currentYaw = SteeringBehaviors.TurnToFace(
+            _transform.Position,
+            _targetTransform.Position,
+            _currentYaw,
+            _turnSpeed * 2f, // Turn faster when attacking
+            deltaTime
+        );
+
         // Move toward or away to maintain optimal attack distance
         // Move toward or away to maintain optimal attack distance
+        Vector3 heading = new Vector3(MathF.Sin(_currentYaw), 0, MathF.Cos(_currentYaw));
+
         if (distanceToTarget > stopDistance)
         if (distanceToTarget > stopDistance)
         {
         {
             // Move closer
             // Move closer
-            Vector3 direction = Vector3.Normalize(_targetTransform.Position - _transform.Position);
-            _transform.Position += direction * _moveSpeed * deltaTime;
+            _transform.Position += heading * _moveSpeed * deltaTime;
         }
         }
         else if (distanceToTarget < stopDistance * 0.8f)
         else if (distanceToTarget < stopDistance * 0.8f)
         {
         {
             // Back away slightly (too close)
             // Back away slightly (too close)
-            Vector3 direction = Vector3.Normalize(_transform.Position - _targetTransform.Position);
-            _transform.Position += direction * _moveSpeed * 0.5f * deltaTime;
+            _transform.Position -= heading * _moveSpeed * 0.5f * deltaTime;
         }
         }
 
 
+        // Update rotation
+        _transform.Rotation = Quaternion.CreateFromYawPitchRoll(_currentYaw, 0, 0);
+
         // Aim and fire at target
         // Aim and fire at target
         if (_enemyController != null)
         if (_enemyController != null)
         {
         {
             // Calculate aim point (target's center mass)
             // Calculate aim point (target's center mass)
             Vector3 aimPoint = _targetTransform.Position + new Vector3(0, 0.5f, 0);
             Vector3 aimPoint = _targetTransform.Position + new Vector3(0, 0.5f, 0);
 
 
-            // Orient toward target
-            _enemyController.OrientTowards(aimPoint);
-            _enemyController.OrientWeaponsTowards(aimPoint);
-
             // Try to shoot (weapon handles its own fire rate)
             // Try to shoot (weapon handles its own fire rate)
             _enemyController.TryAttack(aimPoint);
             _enemyController.TryAttack(aimPoint);
         }
         }

+ 70 - 0
Shooter/Gameplay/Components/NavigationModule.cs

@@ -0,0 +1,70 @@
+using Shooter.Core.Components;
+using Shooter.Core.Entities;
+
+namespace Shooter.Gameplay.Components;
+
+/// <summary>
+/// Navigation module that stores movement parameters for AI-controlled entities.
+/// Based on Unity's NavigationModule pattern from the FPS sample.
+///
+/// UNITY COMPARISON:
+/// Unity: NavigationModule as configuration component for NavMeshAgent
+/// MonoGame: NavigationModule as configuration for manual movement system
+/// </summary>
+public class NavigationModule : EntityComponent
+{
+    /// <summary>
+    /// Maximum movement speed in units per second.
+    /// Unity default: varies by enemy type (HoverBot: 5.0)
+    /// </summary>
+    public float MoveSpeed { get; set; } = 5.0f;
+
+    /// <summary>
+    /// Maximum rotation speed in degrees per second.
+    /// Unity default: 120 degrees/sec
+    /// </summary>
+    public float AngularSpeed { get; set; } = 120f;
+
+    /// <summary>
+    /// How quickly the entity accelerates to max speed (units per second squared).
+    /// Unity default: 50.0
+    /// </summary>
+    public float Acceleration { get; set; } = 50.0f;
+
+    /// <summary>
+    /// Current velocity (managed by movement system).
+    /// Unity equivalent: NavMeshAgent.velocity
+    /// </summary>
+    public float CurrentSpeed { get; set; } = 0f;
+
+    /// <summary>
+    /// Distance at which entity considers it has reached its destination.
+    /// Unity default: 2.0 units
+    /// </summary>
+    public float PathReachingRadius { get; set; } = 2.0f;
+
+    /// <summary>
+    /// Apply acceleration to current speed, clamped to MoveSpeed.
+    /// </summary>
+    public void UpdateSpeed(float deltaTime, bool isMoving)
+    {
+        if (isMoving)
+        {
+            // Accelerate toward max speed
+            CurrentSpeed += Acceleration * deltaTime;
+            CurrentSpeed = Math.Min(CurrentSpeed, MoveSpeed);
+        }
+        else
+        {
+            // Decelerate to stop
+            CurrentSpeed -= Acceleration * deltaTime * 2f; // Decelerate faster than accelerate
+            CurrentSpeed = Math.Max(CurrentSpeed, 0f);
+        }
+    }
+
+    public override void Initialize()
+    {
+        base.Initialize();
+        CurrentSpeed = 0f;
+    }
+}

+ 16 - 3
Shooter/Platforms/Desktop/Game.cs

@@ -157,10 +157,16 @@ public class ShooterGame : Game
         // Add Health to player
         // Add Health to player
         var playerHealth = playerEntity.AddComponent<Gameplay.Components.Health>();
         var playerHealth = playerEntity.AddComponent<Gameplay.Components.Health>();
         playerHealth.MaxHealth = 100f;
         playerHealth.MaxHealth = 100f;
-        
+
+        // Add Rigidbody for physics collision (so enemies can hit the player)
+        var playerRigidbody = playerEntity.AddComponent<Core.Components.Rigidbody>();
+        playerRigidbody.BodyType = Core.Plugins.Physics.BodyType.Kinematic; // Kinematic so FPS controller handles movement
+        playerRigidbody.Shape = new Core.Plugins.Physics.CapsuleShape(0.5f, 1.8f); // Capsule: radius 0.5, height 1.8
+        playerRigidbody.Mass = 70.0f;
+
         // Add HUD component to player
         // Add HUD component to player
         var hud = playerEntity.AddComponent<Gameplay.Components.HUD>();
         var hud = playerEntity.AddComponent<Gameplay.Components.HUD>();
-        
+
         scene.AddEntity(playerEntity);
         scene.AddEntity(playerEntity);
         
         
         // Initialize camera AFTER entity is added and components are initialized
         // Initialize camera AFTER entity is added and components are initialized
@@ -284,6 +290,13 @@ public class ShooterGame : Game
             };
             };
         }
         }
 
 
+        // Add NavigationModule for movement parameters (Unity pattern)
+        var navigationModule = entity.AddComponent<Gameplay.Components.NavigationModule>();
+        navigationModule.MoveSpeed = 5.0f; // Unity HoverBot default
+        navigationModule.AngularSpeed = 120f; // Unity default rotation speed
+        navigationModule.Acceleration = 50.0f; // Unity default
+        navigationModule.PathReachingRadius = 2.0f; // Unity default
+
         // Add EnemyController for weapon management
         // Add EnemyController for weapon management
         var enemyController = entity.AddComponent<Gameplay.Components.EnemyController>();
         var enemyController = entity.AddComponent<Gameplay.Components.EnemyController>();
 
 
@@ -293,7 +306,7 @@ public class ShooterGame : Game
         enemyAI.DetectionRange = 20f; // Unity default
         enemyAI.DetectionRange = 20f; // Unity default
         enemyAI.AttackRange = 10f; // Unity default
         enemyAI.AttackRange = 10f; // Unity default
         enemyAI.AttackStopDistanceRatio = 0.5f; // Stop at 50% of attack range
         enemyAI.AttackStopDistanceRatio = 0.5f; // Stop at 50% of attack range
-        enemyAI.MoveSpeed = 2.5f;
+        enemyAI.MoveSpeed = 5.0f; // Will be overridden by NavigationModule
 
 
         // Subscribe to death event (after enemyAI is created so we can reference it)
         // Subscribe to death event (after enemyAI is created so we can reference it)
         health.OnDeath += (damageInfo) =>
         health.OnDeath += (damageInfo) =>