using Interactions; using UnityEngine; using Pathfinding; using Utils; using AppleHills.Core.Settings; using Core; using Core.Lifecycle; using UnityEngine.Events; /// /// Saveable data for FollowerController state /// [System.Serializable] public class FollowerSaveData { public Vector3 worldPosition; public Quaternion worldRotation; public string heldItemSaveId; // Save ID of held pickup (if any) public string heldItemDataAssetPath; // ItemId of the PickupItemData (for fallback restoration) } /// /// Controls the follower character, including following the player, handling pickups, and managing held items. /// public class FollowerController : ManagedBehaviour { private static readonly int CombineTrigger = Animator.StringToHash("Combine"); [Header("Follower Settings")] public bool debugDrawTarget = true; /// /// How often to update follow logic. /// public float followUpdateInterval = 0.1f; /// /// Smoothing factor for manual movement. /// public float manualMoveSmooth = 8f; // Settings reference private IPlayerFollowerSettings _settings; private IInteractionSettings _interactionSettings; private GameObject _playerRef; private Transform _playerTransform; private AIPath _playerAIPath; private AIPath _aiPath; private Vector3 _targetPoint; private float _timer; private bool _isManualFollowing = true; private Vector3 _lastMoveDir = Vector3.right; // Direction variables for 2D blend tree animation private float _lastDirX = 0f; // -1 (left) to 1 (right) private float _lastDirY = -1f; // -1 (down) to 1 (up) // Save system configuration public override bool AutoRegisterForSave => true; // Scene-specific SaveId - each level has its own follower state public override string SaveId => $"{gameObject.scene.name}/FollowerController"; private float _currentSpeed = 0f; private Animator _animator; private Transform _artTransform; private SpriteRenderer _spriteRenderer; private PickupItemData _currentlyHeldItemData; public PickupItemData CurrentlyHeldItemData => _currentlyHeldItemData; private GameObject _cachedPickupObject = null; public bool justCombined = false; /// /// Renderer for the held item icon. /// public SpriteRenderer heldObjectRenderer; // Stationary animation state private bool _isPlayingStationaryAnimation = false; private Coroutine _stationaryAnimationCoroutine; private System.Action _stationaryAnimationCallback; private bool _isReturningToPlayer = false; private float _playerMaxSpeed = 5f; private float _followerMaxSpeed = 6f; private float _defaultFollowerMaxSpeed = 6f; // Pickup events public delegate void FollowerPickupHandler(); /// /// Event fired when the follower arrives at a pickup. /// public event FollowerPickupHandler OnPickupArrived; /// /// Event fired when the follower returns to the player after a pickup. /// public event FollowerPickupHandler OnPickupReturned; private Coroutine _pickupCoroutine; /// /// Event fired when Pulver is combining stuff /// public UnityEvent PulverIsCombining; private Input.PlayerTouchController _playerTouchController; // Save system tracking for bilateral restoration private bool _hasRestoredHeldItem; // Track if held item restoration completed private string _expectedHeldItemSaveId; // Expected saveId during restoration internal override void OnManagedStart() { _aiPath = GetComponent(); // Find art prefab and animator _artTransform = transform.Find("CharacterArt"); if (_artTransform != null) { _animator = _artTransform.GetComponent(); _spriteRenderer = _artTransform.GetComponent(); } else { _animator = GetComponentInChildren(); // fallback _spriteRenderer = GetComponentInChildren(); } // Initialize settings references _settings = GameManager.GetSettingsObject(); _interactionSettings = GameManager.GetSettingsObject(); } internal override void OnSceneReady() { // Find player reference when scene is ready (called for every scene load) FindPlayerReference(); } void Update() { if (_playerTransform == null) { return; } // Skip all movement logic when playing a stationary animation if (_isPlayingStationaryAnimation) { // Still update animator with zero speed to maintain idle state if (_animator != null) { _animator.SetFloat("Speed", 0f); _animator.SetFloat("DirX", _lastDirX); _animator.SetFloat("DirY", _lastDirY); } return; } _timer += Time.deltaTime; if (_timer >= _settings.FollowUpdateInterval) { _timer = 0f; UpdateFollowTarget(); } if (_isManualFollowing) { Vector2 current2D = new Vector2(transform.position.x, transform.position.y); Vector2 target2D = new Vector2(_targetPoint.x, _targetPoint.y); float dist = Vector2.Distance(current2D, target2D); float minSpeed = _followerMaxSpeed * 0.3f; float lerpFactor = _settings.ManualMoveSmooth * Time.deltaTime; float targetSpeed = 0f; if (dist > _settings.StopThreshold) { if (dist > _settings.ThresholdFar) { targetSpeed = _followerMaxSpeed; } else if (dist > _settings.ThresholdNear && dist <= _settings.ThresholdFar) { targetSpeed = _followerMaxSpeed; } else if (dist > _settings.StopThreshold && dist <= _settings.ThresholdNear) { targetSpeed = minSpeed; } _currentSpeed = Mathf.Lerp(_currentSpeed, targetSpeed, lerpFactor); if (dist > _settings.StopThreshold && dist <= _settings.ThresholdNear) { _currentSpeed = Mathf.Max(_currentSpeed, minSpeed); } Vector3 dir = (_targetPoint - transform.position).normalized; transform.position += dir * _currentSpeed * Time.deltaTime; } else { _currentSpeed = 0f; } } if (_isReturningToPlayer && _aiPath != null && _aiPath.enabled && _playerTransform != null) { _aiPath.destination = _playerTransform.position; } if (_animator != null) { float normalizedSpeed = 0f; Vector3 velocity = Vector3.zero; if (_isManualFollowing) { normalizedSpeed = _currentSpeed / _followerMaxSpeed; // Calculate direction vector for manual movement if (_currentSpeed > 0.01f) { velocity = (_targetPoint - transform.position).normalized * _currentSpeed; } } else if (_aiPath != null) { normalizedSpeed = _aiPath.velocity.magnitude / _followerMaxSpeed; velocity = _aiPath.velocity; } // Set speed parameter for idle/walk transitions _animator.SetFloat("Speed", Mathf.Clamp01(normalizedSpeed)); // Calculate and set X and Y directions for 2D blend tree if (velocity.sqrMagnitude > 0.01f) { // Normalize the velocity vector to get direction Vector3 normalizedVelocity = velocity.normalized; // Update the stored directions when actively moving _lastDirX = normalizedVelocity.x; _lastDirY = normalizedVelocity.y; // Set the animator parameters _animator.SetFloat("DirX", _lastDirX); _animator.SetFloat("DirY", _lastDirY); } else { // When not moving, keep using the last direction _animator.SetFloat("DirX", _lastDirX); _animator.SetFloat("DirY", _lastDirY); } } } void FindPlayerReference() { GameObject playerObj = GameObject.FindGameObjectWithTag("Player"); if (playerObj != null) { _playerRef = playerObj; _playerTransform = playerObj.transform; _playerAIPath = playerObj.GetComponent(); _playerTouchController = playerObj.GetComponent(); if (_playerAIPath != null) { _playerMaxSpeed = _playerAIPath.maxSpeed; _defaultFollowerMaxSpeed = _playerMaxSpeed; _followerMaxSpeed = _playerMaxSpeed * _settings.FollowerSpeedMultiplier; } } else { _playerTransform = null; _playerAIPath = null; _playerTouchController = null; } } #region Movement /// /// Updates the follower's target point to follow the player at a specified distance, /// using the player's current movement direction if available. Disables pathfinding /// when in manual following mode. /// void UpdateFollowTarget() { if (_playerTransform == null) { FindPlayerReference(); if (_playerTransform == null) return; } if (_isManualFollowing) { Vector3 playerPos = _playerTransform.position; Vector3 moveDir = Vector3.zero; if (_playerAIPath != null && _playerAIPath.velocity.magnitude > 0.01f) { moveDir = _playerAIPath.velocity.normalized; _lastMoveDir = moveDir; } else if (_playerTouchController != null && _playerTouchController.isHolding && _playerTouchController.LastDirectMoveDir.sqrMagnitude > 0.01f) { moveDir = _playerTouchController.LastDirectMoveDir; _lastMoveDir = moveDir; } else { moveDir = _lastMoveDir; } // Use settings for followDistance _targetPoint = playerPos - moveDir * _settings.FollowDistance; _targetPoint.z = 0; if (_aiPath != null) { _aiPath.enabled = false; } } } /// /// Make the follower move to a specific point only. Will not automatically return. /// /// The position to move to. public void GoToPoint(Vector2 targetPosition) { if (_pickupCoroutine != null) StopCoroutine(_pickupCoroutine); if (_aiPath != null) _aiPath.maxSpeed = _followerMaxSpeed; _pickupCoroutine = StartCoroutine(GoToPointSequence(targetPosition)); } /// /// Command follower to go to a specific point and return to player after a brief delay. /// Legacy method that combines GoToPoint and ReturnToPlayer for backward compatibility. /// /// The position of the item to pick up. /// The transform of the player. public void GoToPointAndReturn(Vector2 itemPosition, Transform playerTransform) { if (_pickupCoroutine != null) StopCoroutine(_pickupCoroutine); if (_aiPath != null) _aiPath.maxSpeed = _followerMaxSpeed; _pickupCoroutine = StartCoroutine(PickupSequence(itemPosition, playerTransform)); } /// /// Make the follower return to the player after it has reached a point. /// /// The transform of the player to return to. public void ReturnToPlayer(Transform playerTransform) { if (_pickupCoroutine != null) StopCoroutine(_pickupCoroutine); if (_aiPath != null) _aiPath.maxSpeed = _followerMaxSpeed; _pickupCoroutine = StartCoroutine(ReturnToPlayerSequence(playerTransform)); } private System.Collections.IEnumerator PickupSequence(Vector2 itemPosition, Transform playerTransform) { _isManualFollowing = false; _isReturningToPlayer = false; if (_aiPath != null) { _aiPath.enabled = true; _aiPath.maxSpeed = _followerMaxSpeed; _aiPath.destination = new Vector3(itemPosition.x, itemPosition.y, 0); } // Wait until follower reaches item (2D distance) while (Vector2.Distance(new Vector2(transform.position.x, transform.position.y), new Vector2(itemPosition.x, itemPosition.y)) > _settings.StopThreshold) { yield return null; } OnPickupArrived?.Invoke(); // Brief pause at the item before returning yield return new WaitForSeconds(_interactionSettings.FollowerPickupDelay); // Reset follower speed to normal after pickup _followerMaxSpeed = _defaultFollowerMaxSpeed; if (_aiPath != null) _aiPath.maxSpeed = _followerMaxSpeed; // Immediately resume normal following behavior _isManualFollowing = true; if (_aiPath != null) _aiPath.enabled = false; _pickupCoroutine = null; } private System.Collections.IEnumerator GoToPointSequence(Vector2 targetPosition) { _isManualFollowing = false; _isReturningToPlayer = false; if (_aiPath != null) { _aiPath.enabled = true; _aiPath.maxSpeed = _followerMaxSpeed; _aiPath.destination = new Vector3(targetPosition.x, targetPosition.y, 0); } // Wait until follower reaches target while (Vector2.Distance(new Vector2(transform.position.x, transform.position.y), new Vector2(targetPosition.x, targetPosition.y)) > _settings.StopThreshold) { yield return null; } // Signal arrival OnPickupArrived?.Invoke(); // Reset follower speed to normal after reaching the point _followerMaxSpeed = _defaultFollowerMaxSpeed; if (_aiPath != null) _aiPath.maxSpeed = _followerMaxSpeed; // Immediately resume normal following behavior _isManualFollowing = true; if (_aiPath != null) _aiPath.enabled = false; _pickupCoroutine = null; } private System.Collections.IEnumerator ReturnToPlayerSequence(Transform playerTransform) { if (_aiPath != null && playerTransform != null) { _aiPath.maxSpeed = _followerMaxSpeed; _aiPath.destination = playerTransform.position; } _isReturningToPlayer = true; // Wait until follower returns to player while (playerTransform != null && Vector2.Distance(new Vector2(transform.position.x, transform.position.y), new Vector2(playerTransform.position.x, playerTransform.position.y)) > _settings.StopThreshold) { yield return null; } _isReturningToPlayer = false; OnPickupReturned?.Invoke(); // Reset follower speed to normal after pickup _followerMaxSpeed = _defaultFollowerMaxSpeed; if (_aiPath != null) _aiPath.maxSpeed = _followerMaxSpeed; _isManualFollowing = true; if (_aiPath != null) _aiPath.enabled = false; _pickupCoroutine = null; } #endregion Movement #region StationaryAnimations /// /// Pauses all movement. Can be called directly from Timeline, Animation Events, or code. /// Call ResumeMovement() to resume. /// public void PauseMovement() { _isPlayingStationaryAnimation = true; _currentSpeed = 0f; Logging.Debug("[FollowerController] Movement paused"); } /// /// Resumes movement after being paused. Can be called from Timeline, Animation Events, or code. /// public void ResumeMovement() { _isPlayingStationaryAnimation = false; Logging.Debug("[FollowerController] Movement resumed"); } /// /// Plays an animation while pausing all movement. Resumes movement when animation completes. /// Uses a hybrid approach: Animation Events (if set up) OR timeout fallback (if not). /// To use Animation Events: Add an event at the end of your animation that calls OnStationaryAnimationComplete(). /// /// The animator trigger name to activate /// Maximum time to wait before auto-resuming (fallback if no Animation Event) /// Optional callback to invoke when animation completes public void PlayAnimationStationary(string triggerName, float maxDuration = 2f, System.Action onComplete = null) { if (_animator == null) { Logging.Warning("[FollowerController] Cannot play stationary animation - no Animator found"); onComplete?.Invoke(); return; } // Stop any existing stationary animation if (_stationaryAnimationCoroutine != null) { StopCoroutine(_stationaryAnimationCoroutine); } _isPlayingStationaryAnimation = true; _stationaryAnimationCallback = onComplete; _currentSpeed = 0f; // Immediately stop movement // Trigger the animation _animator.SetTrigger(triggerName); // Start timeout coroutine (will be stopped early if Animation Event fires) _stationaryAnimationCoroutine = StartCoroutine(StationaryAnimationTimeoutCoroutine(maxDuration)); } private System.Collections.IEnumerator StationaryAnimationTimeoutCoroutine(float maxDuration) { yield return new WaitForSeconds(maxDuration); // If we reach here, the Animation Event didn't fire - use fallback Logging.Debug($"[FollowerController] Stationary animation timeout reached ({maxDuration}s) - resuming movement"); ResumeMovementAfterAnimation(); } /// /// Public method to be called by Animation Events at the end of stationary animations. /// Add this as an Animation Event in your animation clip for frame-perfect timing. /// public void OnStationaryAnimationComplete() { Logging.Debug("[FollowerController] Stationary animation completed via Animation Event"); ResumeMovementAfterAnimation(); } private void ResumeMovementAfterAnimation() { if (!_isPlayingStationaryAnimation) return; // Already resumed // Stop the timeout coroutine if it's still running if (_stationaryAnimationCoroutine != null) { StopCoroutine(_stationaryAnimationCoroutine); _stationaryAnimationCoroutine = null; } _isPlayingStationaryAnimation = false; // Invoke callback if provided var callback = _stationaryAnimationCallback; _stationaryAnimationCallback = null; callback?.Invoke(); } #endregion StationaryAnimations #region ItemInteractions // TODO: Move TryCombineItems to ItemManager/InteractionHelpers // This is currently interaction logic living in a movement controller. // Pros of moving: Separates game logic from character logic, easier to test // Cons: More coordination needed, follower still needs animation callbacks /// /// Try to pickup an item. If already holding something, optionally drop it first. /// /// The GameObject to pick up (must have Pickup component) /// The item data (redundant - can be extracted from GameObject) /// Whether to drop currently held item before picking up new one public void TryPickupItem(GameObject itemObject, PickupItemData itemData, bool dropItem = true) { if (itemObject == null) return; // Drop current item if holding something if (_currentlyHeldItemData != null && _cachedPickupObject != null && dropItem) { DropHeldItemAt(transform.position); } // Use helper to set held item (handles data extraction, caching, animator) SetHeldItemFromObject(itemObject); itemObject.SetActive(false); } public enum CombinationResult { Successful, Unsuccessful, NotApplicable } public CombinationResult TryCombineItems(Pickup pickupA, out GameObject newItem) { _animator.ResetTrigger(CombineTrigger); newItem = null; // Validation if (_cachedPickupObject == null) return CombinationResult.NotApplicable; Pickup pickupB = _cachedPickupObject.GetComponent(); if (pickupA == null || pickupB == null) return CombinationResult.NotApplicable; // Find combination rule CombinationRule matchingRule = _interactionSettings.GetCombinationRule(pickupA.itemData, pickupB.itemData); if (matchingRule == null || matchingRule.resultPrefab == null) return CombinationResult.Unsuccessful; // Execute combination Vector3 spawnPos = pickupA.gameObject.transform.position; newItem = Instantiate(matchingRule.resultPrefab, spawnPos, Quaternion.identity); var resultPickup = newItem.GetComponent(); // Mark items as picked up before disabling (for save system) pickupA.IsPickedUp = true; pickupB.IsPickedUp = true; // Disable instead of destroying immediately so they can save their state // The save system will mark them as picked up and won't restore them pickupA.gameObject.SetActive(false); pickupB.gameObject.SetActive(false); // Pickup the result (don't drop it!) TryPickupItem(newItem, resultPickup.itemData, dropItem: false); // Visual feedback PlayAnimationStationary("Combine", 10.0f); PulverIsCombining.Invoke(); return CombinationResult.Successful; } /// /// Set the item held by the follower, copying all visual properties from the Pickup's SpriteRenderer. /// /// The item data to set. /// The SpriteRenderer from the Pickup to copy appearance from. public void SetHeldItem(PickupItemData itemData, SpriteRenderer pickupRenderer = null) { _currentlyHeldItemData = itemData; if (heldObjectRenderer != null) { if (_currentlyHeldItemData != null && pickupRenderer != null) { AppleHillsUtils.CopySpriteRendererProperties(pickupRenderer, heldObjectRenderer); } else { heldObjectRenderer.sprite = null; heldObjectRenderer.enabled = false; } } } public GameObject GetHeldPickupObject() { return _cachedPickupObject; } /// /// Gets the transform of the held item's visual representation. /// Used for animating the held item sprite. /// public Transform GetHeldItemTransform() { return heldObjectRenderer?.transform; } /// /// Set held item from a GameObject. Extracts Pickup component and sets up visuals. /// Centralizes held item state management including animator. /// public void SetHeldItemFromObject(GameObject obj) { if (obj == null) { ClearHeldItem(); return; } var pickup = obj.GetComponent(); if (pickup != null) { SetHeldItem(pickup.itemData, pickup.iconRenderer); _cachedPickupObject = obj; _animator.SetBool("IsCarrying", true); // Centralized animator management } else { ClearHeldItem(); } } /// /// Clear the currently held item. Centralizes state cleanup including animator. /// public void ClearHeldItem() { _cachedPickupObject = null; _currentlyHeldItemData = null; _animator.SetBool("IsCarrying", false); // Centralized animator management if (heldObjectRenderer != null) { heldObjectRenderer.sprite = null; heldObjectRenderer.enabled = false; } } /// /// Drop the currently held item at the specified position. /// public void DropHeldItemAt(Vector3 position) { var item = GetHeldPickupObject(); if (item == null) return; // Place item in world item.transform.position = position; item.transform.SetParent(null); item.SetActive(true); // Reset pickup state so it can be picked up again var pickup = item.GetComponent(); if (pickup != null) { pickup.ResetPickupState(); } // Clear held item state (includes animator) ClearHeldItem(); } #endregion ItemInteractions #region Save/Load Lifecycle Hooks internal override string OnSceneSaveRequested() { var saveData = new FollowerSaveData { worldPosition = transform.position, worldRotation = transform.rotation }; // Save held item if any if (_cachedPickupObject != null) { var pickup = _cachedPickupObject.GetComponent(); if (pickup is SaveableInteractable saveable) { saveData.heldItemSaveId = saveable.SaveId; } // Save the itemId for build-compatible restoration if (_currentlyHeldItemData != null) { saveData.heldItemDataAssetPath = _currentlyHeldItemData.itemId; } } return JsonUtility.ToJson(saveData); } internal override void OnSceneRestoreRequested(string serializedData) { if (string.IsNullOrEmpty(serializedData)) { Logging.Debug("[FollowerController] No saved state to restore"); return; } try { var saveData = JsonUtility.FromJson(serializedData); if (saveData != null) { // Restore position and rotation transform.position = saveData.worldPosition; transform.rotation = saveData.worldRotation; // Try bilateral restoration of held item if (!string.IsNullOrEmpty(saveData.heldItemSaveId)) { _expectedHeldItemSaveId = saveData.heldItemSaveId; TryRestoreHeldItem(saveData.heldItemSaveId, saveData.heldItemDataAssetPath); } Logging.Debug($"[FollowerController] Restored position: {saveData.worldPosition}"); } } catch (System.Exception ex) { Logging.Warning($"[FollowerController] Failed to restore state: {ex.Message}"); } } /// /// Bilateral restoration: Follower tries to find and claim the held item. /// If pickup doesn't exist in the scene (e.g., dynamically spawned combined item), /// spawns it from the itemData. /// private void TryRestoreHeldItem(string heldItemSaveId, string itemDataId) { if (_hasRestoredHeldItem) { Logging.Debug("[FollowerController] Held item already restored"); return; } // Try to find the pickup in the scene by SaveId GameObject heldObject = ItemManager.Instance?.FindPickupBySaveId(heldItemSaveId); if (heldObject == null && !string.IsNullOrEmpty(itemDataId)) { // Item not found in scene - it might be a dynamically spawned combined item // Try to spawn it from the itemDataId Logging.Debug($"[FollowerController] Held item not found in scene: {heldItemSaveId}, attempting to spawn from itemId: {itemDataId}"); GameObject prefab = _interactionSettings?.FindPickupPrefabByItemId(itemDataId); if (prefab != null) { // Spawn the item (inactive, since it's being held) heldObject = Instantiate(prefab, transform.position, Quaternion.identity); heldObject.SetActive(false); Logging.Debug($"[FollowerController] Successfully spawned combined item: {itemDataId}"); } else { Logging.Warning($"[FollowerController] Could not find prefab for itemId: {itemDataId}"); return; } } else if (heldObject == null) { Logging.Debug($"[FollowerController] Held item not found yet: {heldItemSaveId}, waiting for pickup to restore"); return; // Pickup will find us when it restores } var pickup = heldObject.GetComponent(); if (pickup == null) { Logging.Warning($"[FollowerController] Found/spawned object but no Pickup component: {heldItemSaveId}"); if (heldObject != null) Destroy(heldObject); return; } // Claim the pickup TakeOwnership(pickup, itemDataId); } /// /// Bilateral restoration entry point: Pickup calls this to offer itself to the Follower. /// Returns true if claim was successful, false if Follower already has an item or wrong pickup. /// public bool TryClaimHeldItem(Pickup pickup) { if (pickup == null) return false; if (_hasRestoredHeldItem) { Logging.Debug("[FollowerController] Already restored held item, rejecting claim"); return false; } // Verify this is the expected pickup if (pickup is SaveableInteractable saveable) { if (saveable.SaveId != _expectedHeldItemSaveId) { Logging.Warning($"[FollowerController] Pickup tried to claim but saveId mismatch: {saveable.SaveId} != {_expectedHeldItemSaveId}"); return false; } } // Claim the pickup TakeOwnership(pickup, null); return true; } /// /// Takes ownership of a pickup during restoration. Called by both restoration paths. /// private void TakeOwnership(Pickup pickup, string itemDataIdOrPath) { if (_hasRestoredHeldItem) return; // Already claimed // Get the item data from the pickup PickupItemData heldData = pickup.itemData; // Fallback: If pickup doesn't have itemData, log detailed error if (heldData == null) { Logging.Warning($"[FollowerController] Pickup {pickup.gameObject.name} has null itemData!"); Logging.Warning($"[FollowerController] Expected itemId: {itemDataIdOrPath}"); Logging.Warning($"[FollowerController] This pickup prefab may be missing its PickupItemData reference."); return; } // Verify itemId matches if we have it (additional safety check) if (!string.IsNullOrEmpty(itemDataIdOrPath) && heldData.itemId != itemDataIdOrPath) { Logging.Warning($"[FollowerController] ItemId mismatch! Pickup has '{heldData.itemId}' but expected '{itemDataIdOrPath}'"); } // Setup the held item _cachedPickupObject = pickup.gameObject; _cachedPickupObject.SetActive(false); // Held items should be hidden SetHeldItem(heldData, pickup.iconRenderer); _animator.SetBool("IsCarrying", true); _hasRestoredHeldItem = true; Logging.Debug($"[FollowerController] Successfully restored held item: {heldData.itemName} (itemId: {heldData.itemId})"); } /// /// Static method to find the FollowerController instance in the scene. /// Used by Pickup during bilateral restoration. /// public static FollowerController FindInstance() { return FindFirstObjectByType(); } #endregion Save/Load Lifecycle Hooks #if UNITY_EDITOR void OnDrawGizmos() { if (debugDrawTarget && Application.isPlaying) { Gizmos.color = Color.cyan; Gizmos.DrawSphere(_targetPoint, 0.2f); Gizmos.color = Color.yellow; Gizmos.DrawLine(transform.position, _targetPoint); } } #endif }