AStarHeightmapGrid / Assets / PlatypusIdeas / AirPath / Runtime / Modes / TargetFollowPathfindingMode.cs
TargetFollowPathfindingMode.cs
Raw
using UnityEngine;

namespace PlatypusIdeas.AirPath.Runtime.Modes {
    public class TargetFollowPathfindingMode : PathfindingModeBase {
        // Target configuration
        private Transform _targetTransform;
        private Vector2Int _lastTargetGridPos;
        private Vector2Int _lastStartGridPos;
        private bool _targetWasOutOfBounds = false;

        // Recalculation settings
        [System.Serializable]
        public class RecalculationSettings {
            public float MinRecalculationInterval = 0.5f;
            public float TargetMoveThreshold = 2f;
            public float SwarmMoveThreshold = 3f;
            public bool UseDistanceBasedThrottling = true;
            public float MaxThrottleDistance = 50f;
            public float MaxThrottleMultiplier = 3f;
            public bool ShowBoundaryWarnings = true;
        }

        private RecalculationSettings _settings;
        private float _lastRecalculationTime;

        // Visual indicators
        private GameObject _targetIndicator;
        private GameObject _boundaryWarningIndicator;

        public override string ModeName => "Target Follow";

        public TargetFollowPathfindingMode(RecalculationSettings settings) {
            _settings = settings ?? new RecalculationSettings();
        }

        public void SetTarget(Transform target) {
            if (target == _targetTransform) return;

            _targetTransform = target;

            if (_targetTransform) {
                // Use safe conversion to handle out-of-bounds targets
                _lastTargetGridPos = WorldToGridPositionSafe(_targetTransform.position, out _targetWasOutOfBounds);

                if (_targetWasOutOfBounds && _settings.ShowBoundaryWarnings) {
                    Debug.LogWarning(
                        $"[Target Follow] Target '{_targetTransform.name}' is outside grid bounds. Position will be clamped.");
                }

                if (_isActive) {
                    ForceRecalculation();
                    UpdateTargetIndicator();
                }

                UpdateInstructionText($"Following target: {_targetTransform.name}" +
                                      (_targetWasOutOfBounds ? " (clamped to grid bounds)" : ""));
            }
            else {
                UpdateInstructionText("No target set - assign a target GameObject");
                DestroyIndicators();
            }
        }

        public Transform GetTarget() => _targetTransform;

        public override void OnActivate() {
            base.OnActivate();
            _lastRecalculationTime = Time.time;

            CreateIndicators();

            if (_targetTransform) {
                ForceRecalculation();
                UpdateInstructionText($"Following target: {_targetTransform.name}");
            }
            else {
                UpdateInstructionText("Target Follow Mode: Please assign a target GameObject");
            }
        }

        public override void OnDeactivate() {
            base.OnDeactivate();
            DestroyIndicators();
        }

        public override void UpdateMode() {
            if (!_isActive || !_targetTransform) return;

            // Check if recalculation is needed
            if (ShouldRecalculatePath()) {
                RecalculatePath();
            }

            // Update visual indicators
            UpdateTargetIndicator();
            UpdateBoundaryWarning();
        }

        public override void Cleanup() {
            base.Cleanup();
            DestroyIndicators();
        }

        public override void DrawDebugVisualization() {
            if (!_isActive || _targetTransform == null) return;

            // Draw actual target position
            Gizmos.color = _targetWasOutOfBounds ? Color.red : Color.yellow;
            Gizmos.DrawWireSphere(_targetTransform.position, 2f);
            Gizmos.DrawLine(_targetTransform.position, _targetTransform.position + Vector3.up * 10f);

            // Draw clamped position if different
            if (_targetWasOutOfBounds) {
                var clampedWorldPos = GridToWorldPosition(_lastTargetGridPos, 0);
                Gizmos.color = Color.green;
                Gizmos.DrawWireSphere(clampedWorldPos, 2.5f);
                Gizmos.DrawLine(_targetTransform.position, clampedWorldPos);
            }

            // Draw recalculation threshold radius
            var targetGridWorld = GridToWorldPosition(_lastTargetGridPos);
            Gizmos.color = new Color(1f, 1f, 0f, 0.3f);
            Gizmos.DrawWireSphere(targetGridWorld,
                _settings.TargetMoveThreshold * Context.PathfindingService.WorldToGridPosition(Vector3.one * 10f).x /
                10f);
        }

        public override string GetStatusInfo() {
            if (!_targetTransform) {
                return "No target assigned";
            }

            var timeSinceRecalc = Time.time - _lastRecalculationTime;
            var throttleMultiplier = GetThrottleMultiplier();
            var boundaryStatus = _targetWasOutOfBounds ? " [OUT OF BOUNDS]" : "";

            return $"Target: {_targetTransform.name}{boundaryStatus} | " +
                   $"Last Recalc: {timeSinceRecalc:F1}s ago | " +
                   $"Throttle: {throttleMultiplier:F1}x";
        }

        /// Determines whether the pathfinding algorithm should recalculate the path based on several conditions.
        /// The conditions include:
        /// - Time since the last recalculation exceeding a minimum interval, adjusted by a throttle multiplier.
        /// - Changes in the target's position beyond a defined threshold.
        /// - Changes in the target's boundary status (in or out of bounds).
        /// Additional checks are performed for swarm movement in relation to the target position when a path is active.
        /// <returns>
        /// True if the path should be recalculated based on the defined conditions; otherwise, false.
        /// </returns>
        private bool ShouldRecalculatePath() {
            // Check time threshold with throttling
            var throttleMultiplier = GetThrottleMultiplier();
            var adjustedInterval = _settings.MinRecalculationInterval * throttleMultiplier;
    
            if (Time.time - _lastRecalculationTime < adjustedInterval) {
                return false;
            }
    
            // Check target movement threshold with safe boundary handling
            var currentTargetGridPos = WorldToGridPositionSafe(_targetTransform.position, out bool currentlyOutOfBounds);
    
            // Force recalculation if target boundary status changed
            if (currentlyOutOfBounds != _targetWasOutOfBounds) {
                _targetWasOutOfBounds = currentlyOutOfBounds;
                return true;
            }
    
            // Check if target has moved from last known position
            var targetGridDistance = Vector2Int.Distance(currentTargetGridPos, _lastTargetGridPos);
    
            // IMPORTANT: Always check target movement, even if birds are at the target
            // This ensures the system remains responsive when the target moves after birds arrive
            if (targetGridDistance >= _settings.TargetMoveThreshold) {
                return true;  // Target has moved significantly - recalculate
            }
    
            // Check swarm movement threshold only if we have an active path
            // AND the swarm hasn't reached the target yet
            if (!IsPathActive()) return true;
            var currentSwarmPos = WorldToGridPositionSafe(GetAverageBirdPosition(), out _);
        
            // Only check swarm movement if they're not already at the target
            // This prevents blocking recalculation when birds are at target but target moves
            if (currentSwarmPos != currentTargetGridPos) {
                var swarmGridDistance = Vector2Int.Distance(currentSwarmPos, _lastStartGridPos);
            
                if (swarmGridDistance < _settings.SwarmMoveThreshold) {
                    return false;  // Swarm hasn't moved enough yet
                }
            }

            return true;
        }

        /// Calculates a throttle multiplier used for adjusting the frequency of pathfinding recalculations
        /// based on the distance between the swarm and the target. The multiplier increases as the target
        /// is farther away, helping to optimize performance by reducing recalculations for distant targets.
        /// If distance-based throttling is disabled or the path is inactive, the multiplier defaults to 1.
        /// <returns>
        /// The calculated throttle multiplier, which varies from 1 to a maximum value defined by the settings.
        /// The exact value is interpolated linearly based on the distance between the swarm and the target.
        /// </returns>
        private float GetThrottleMultiplier() {
            if (!_settings.UseDistanceBasedThrottling || !IsPathActive()) {
                return 1f;
            }

            var swarmPos = GetAverageBirdPosition();
            var targetPos = _targetTransform.position;
            var distance = Vector3.Distance(swarmPos, targetPos);

            // Linear interpolation based on distance
            var t = Mathf.Clamp01(distance / _settings.MaxThrottleDistance);
            return Mathf.Lerp(1f, _settings.MaxThrottleMultiplier, t);
        }

        private void RecalculatePath() {
            Vector2Int startPos;
            Vector2Int endPos;
            var startOutOfBounds = false;
            var endOutOfBounds = false;

            // Determine start position with boundary safety
            if (IsPathActive()) {
                startPos = WorldToGridPositionSafe(GetAverageBirdPosition(), out startOutOfBounds);
            }
            else {
                startPos = WorldToGridPositionSafe(Context.TerrainController.transform.position, out startOutOfBounds);
            }

            // Get current target position with boundary safety
            endPos = WorldToGridPositionSafe(_targetTransform.position, out endOutOfBounds);
            _targetWasOutOfBounds = endOutOfBounds;

            // Log boundary violations if needed
            if ((startOutOfBounds || endOutOfBounds) && _settings.ShowBoundaryWarnings) {
                var warning = "[Target Follow] Boundary violation detected: ";
                if (startOutOfBounds) warning += "Swarm is outside grid. ";
                if (endOutOfBounds) warning += $"Target '{_targetTransform.name}' is outside grid. ";
                warning += "Positions clamped to valid bounds.";
                Debug.LogWarning(warning);
            }

            // Store last positions BEFORE the early return check
            _lastStartGridPos = startPos;
            _lastTargetGridPos = endPos;
            _lastRecalculationTime = Time.time;

            // Check if start and end are the same (birds have reached the target)
            // but don't prevent future recalculations - just skip this one
            if (startPos == endPos) {
                Debug.Log($"[Target Follow] Birds are at target position ({endPos.x}, {endPos.y})");
                // Don't return early - still visualize and keep the system responsive
                // Just skip the actual path request

                // Still update visualization to show current state
                ClearVisualization();
                SetCellColor(startPos, startOutOfBounds ? WarningColor : StartColor);

                // Important: Don't request a path to the same position
                // but keep the system active for when the target moves again
                return;
            }

            // Visualize with warning colors if out of bounds
            ClearVisualization();
            SetCellColor(startPos, startOutOfBounds ? WarningColor : StartColor);
            SetCellColor(endPos, endOutOfBounds ? WarningColor : EndColor);

            // Use the base class RequestPath method which will invoke the event properly
            RequestPath(startPos, endPos);
        }

        private void ForceRecalculation() {
            _lastRecalculationTime = 0; // Force time check to pass
            RecalculatePath();
        }

        private void CreateIndicators() {
            // Create target indicator
            if (!_targetIndicator) {
                _targetIndicator = GameObject.CreatePrimitive(PrimitiveType.Sphere);
                _targetIndicator.name = "TargetIndicator";
                _targetIndicator.transform.localScale = Vector3.one * 3f;
                var renderer = _targetIndicator.GetComponent<MeshRenderer>();
                if (renderer) {
                    renderer.material.color = Color.yellow;
                }

                var collider = _targetIndicator.GetComponent<Collider>();
                if (collider) Object.Destroy(collider);
                _targetIndicator.SetActive(false);
            }

            // Create boundary warning indicator
            if (!_boundaryWarningIndicator) {
                _boundaryWarningIndicator = GameObject.CreatePrimitive(PrimitiveType.Cube);
                _boundaryWarningIndicator.name = "BoundaryWarningIndicator";
                _boundaryWarningIndicator.transform.localScale = new Vector3(1f, 5f, 1f);
                var renderer = _boundaryWarningIndicator.GetComponent<MeshRenderer>();
                if (renderer) {
                    renderer.material.color = WarningColor;
                }

                var collider = _boundaryWarningIndicator.GetComponent<Collider>();
                if (collider) Object.Destroy(collider);
                _boundaryWarningIndicator.SetActive(false);
            }
        }

        private void UpdateTargetIndicator() {
            if (_targetIndicator is not null && _targetTransform) {
                // Show indicator at clamped position if out of bounds
                if (_targetWasOutOfBounds) {
                    var clampedPos = GridToWorldPosition(_lastTargetGridPos, 2f);
                    _targetIndicator.transform.position = clampedPos;
                    var renderer = _targetIndicator.GetComponent<MeshRenderer>();
                    if (renderer) {
                        renderer.material.color = WarningColor;
                    }
                }
                else {
                    _targetIndicator.transform.position = _targetTransform.position + Vector3.up * 2f;
                    var renderer = _targetIndicator.GetComponent<MeshRenderer>();
                    if (renderer) {
                        renderer.material.color = Color.yellow;
                    }
                }

                _targetIndicator.SetActive(true);
            }
        }

        private void UpdateBoundaryWarning() {
            if (_boundaryWarningIndicator && _targetTransform) {
                if (_targetWasOutOfBounds) {
                    var clampedPos = GridToWorldPosition(_lastTargetGridPos, 5f);
                    _boundaryWarningIndicator.transform.position = clampedPos;
                    _boundaryWarningIndicator.SetActive(true);

                    // Pulse effect for warning
                    var scale = _boundaryWarningIndicator.transform.localScale;
                    scale.y = 5f + Mathf.Sin(Time.time * 3f) * 0.5f;
                    _boundaryWarningIndicator.transform.localScale = scale;
                }
                else {
                    _boundaryWarningIndicator.SetActive(false);
                }
            }
        }

        private void DestroyIndicators() {
            if (_targetIndicator) {
                if (Application.isPlaying) Object.Destroy(_targetIndicator);
                else Object.DestroyImmediate(_targetIndicator);
                _targetIndicator = null;
            }

            if (_boundaryWarningIndicator) {
                if (Application.isPlaying) Object.Destroy(_boundaryWarningIndicator);
                else Object.DestroyImmediate(_boundaryWarningIndicator);
                _boundaryWarningIndicator = null;
            }
        }
    }
}