Files
AppleHillsProduction/Assets/Scripts/Movement/FollowerController.cs
tschesky 011901eb8f Refactoring of the interaction system and preliminary integration of save/load functionality across the game. (#44)
### Interactables Architecture Refactor
- Converted composition to inheritance, moved from component-based to class-based interactables. No more requirement for chain of "Interactable -> Item" etc.
- Created `InteractableBase` abstract base class with common functionality that replaces the old component
- Specialized child classes: `Pickup`, `ItemSlot`, `LevelSwitch`, `MinigameSwitch`, `CombinationItem`, `OneClickInteraction` are now children classes
- Light updates to the interactable inspector, moved some things arround, added collapsible inspector sections in the  UI for better editor experience

### State Machine Integration
- Custom `AppleMachine` inheritong from Pixelplacement's StateMachine which implements our own interface for saving, easy place for future improvements
- Replaced all previous StateMachines by `AppleMachine`
- Custom `AppleState`  extends from default `State`. Added serialization, split state logic into "EnterState", "RestoreState", "ExitState" allowing for separate logic when triggering in-game vs loading game
- Restores directly to target state without triggering transitional logic
- Migration tool converts existing instances

### Prefab Organization
- Saved changes from scenes into prefabs
- Cleaned up duplicated components, confusing prefabs hierarchies
- Created prefab variants where possible
- Consolidated Environment prefabs and moved them out of Placeholders subfolder into main Environment folder
- Organized item prefabs from PrefabsPLACEHOLDER into proper Items folder
- Updated prefab references - All scene references updated to new locations
- Removed placeholder files from Characters, Levels, UI, and Minigames folders

### Scene Updates
- Quarry scene with major updates
- Saved multiple working versions (Quarry, Quarry_Fixed, Quarry_OLD)
- Added proper lighting data
- Updated all interactable components to new architecture

### Minor editor tools
- New tool for testing cards from an editor window (no in-scene object required)
- Updated Interactable Inspector
- New debug option to opt in-and-out of the save/load system
- Tooling for easier migration

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Reviewed-on: #44
2025-11-03 10:12:51 +00:00

928 lines
31 KiB
C#

using Interactions;
using UnityEngine;
using Pathfinding;
using UnityEngine.SceneManagement;
using Utils;
using AppleHills.Core.Settings;
using Core;
using Core.SaveLoad;
using Bootstrap;
using UnityEngine.Events;
/// <summary>
/// Saveable data for FollowerController state
/// </summary>
[System.Serializable]
public class FollowerSaveData
{
public Vector3 worldPosition;
public Quaternion worldRotation;
public string heldItemSaveId; // Save ID of held pickup (if any)
public string heldItemDataAssetPath; // Asset path to PickupItemData
}
/// <summary>
/// Controls the follower character, including following the player, handling pickups, and managing held items.
/// </summary>
public class FollowerController : MonoBehaviour, ISaveParticipant
{
private static readonly int CombineTrigger = Animator.StringToHash("Combine");
[Header("Follower Settings")]
public bool debugDrawTarget = true;
/// <summary>
/// How often to update follow logic.
/// </summary>
public float followUpdateInterval = 0.1f;
/// <summary>
/// Smoothing factor for manual movement.
/// </summary>
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)
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;
/// <summary>
/// Renderer for the held item icon.
/// </summary>
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();
/// <summary>
/// Event fired when the follower arrives at a pickup.
/// </summary>
public event FollowerPickupHandler OnPickupArrived;
/// <summary>
/// Event fired when the follower returns to the player after a pickup.
/// </summary>
public event FollowerPickupHandler OnPickupReturned;
private Coroutine _pickupCoroutine;
/// <summary>
/// Event fired when Pulver is combining stuff
/// </summary>
public UnityEvent PulverIsCombining;
private Input.PlayerTouchController _playerTouchController;
// Save system tracking
private bool hasBeenRestored;
private bool _hasRestoredHeldItem; // Track if held item restoration completed
private string _expectedHeldItemSaveId; // Expected saveId during restoration
void Awake()
{
_aiPath = GetComponent<AIPath>();
// Find art prefab and animator
_artTransform = transform.Find("CharacterArt");
if (_artTransform != null)
{
_animator = _artTransform.GetComponent<Animator>();
_spriteRenderer = _artTransform.GetComponent<SpriteRenderer>();
}
else
{
_animator = GetComponentInChildren<Animator>(); // fallback
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
}
// Initialize settings references
_settings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
// Register for post-boot initialization
BootCompletionService.RegisterInitAction(InitializePostBoot);
}
private void InitializePostBoot()
{
// Register with save system after boot
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
Logging.Debug("[FollowerController] Registered with SaveLoadManager");
}
else
{
Logging.Warning("[FollowerController] SaveLoadManager not available for registration");
}
}
void OnEnable()
{
SceneManager.sceneLoaded += OnSceneLoaded;
FindPlayerReference();
}
void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
// Unregister from save system
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
FindPlayerReference();
}
void Update()
{
if (_playerTransform == null)
{
FindPlayerReference();
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<AIPath>();
_playerTouchController = playerObj.GetComponent<Input.PlayerTouchController>();
if (_playerAIPath != null)
{
_playerMaxSpeed = _playerAIPath.maxSpeed;
_defaultFollowerMaxSpeed = _playerMaxSpeed;
_followerMaxSpeed = _playerMaxSpeed * _settings.FollowerSpeedMultiplier;
}
}
else
{
_playerTransform = null;
_playerAIPath = null;
_playerTouchController = null;
}
}
#region Movement
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
/// <summary>
/// Make the follower move to a specific point only. Will not automatically return.
/// </summary>
/// <param name="targetPosition">The position to move to.</param>
public void GoToPoint(Vector2 targetPosition)
{
if (_pickupCoroutine != null)
StopCoroutine(_pickupCoroutine);
if (_aiPath != null)
_aiPath.maxSpeed = _followerMaxSpeed;
_pickupCoroutine = StartCoroutine(GoToPointSequence(targetPosition));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="itemPosition">The position of the item to pick up.</param>
/// <param name="playerTransform">The transform of the player.</param>
public void GoToPointAndReturn(Vector2 itemPosition, Transform playerTransform)
{
if (_pickupCoroutine != null)
StopCoroutine(_pickupCoroutine);
if (_aiPath != null)
_aiPath.maxSpeed = _followerMaxSpeed;
_pickupCoroutine = StartCoroutine(PickupSequence(itemPosition, playerTransform));
}
/// <summary>
/// Make the follower return to the player after it has reached a point.
/// </summary>
/// <param name="playerTransform">The transform of the player to return to.</param>
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
/// <summary>
/// Pauses all movement. Can be called directly from Timeline, Animation Events, or code.
/// Call ResumeMovement() to resume.
/// </summary>
public void PauseMovement()
{
_isPlayingStationaryAnimation = true;
_currentSpeed = 0f;
Logging.Debug("[FollowerController] Movement paused");
}
/// <summary>
/// Resumes movement after being paused. Can be called from Timeline, Animation Events, or code.
/// </summary>
public void ResumeMovement()
{
_isPlayingStationaryAnimation = false;
Logging.Debug("[FollowerController] Movement resumed");
}
/// <summary>
/// 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().
/// </summary>
/// <param name="triggerName">The animator trigger name to activate</param>
/// <param name="maxDuration">Maximum time to wait before auto-resuming (fallback if no Animation Event)</param>
/// <param name="onComplete">Optional callback to invoke when animation completes</param>
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();
}
/// <summary>
/// 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.
/// </summary>
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
public void TryPickupItem(GameObject itemObject, PickupItemData itemData, bool dropItem = true)
{
if (_currentlyHeldItemData != null && _cachedPickupObject != null && dropItem)
{
// Drop the currently held item at the current position
DropHeldItemAt(transform.position);
}
// Pick up the new item
SetHeldItem(itemData, itemObject.GetComponent<SpriteRenderer>());
_animator.SetBool("IsCarrying", true);
_cachedPickupObject = itemObject;
_cachedPickupObject.SetActive(false);
}
public enum CombinationResult
{
Successful,
Unsuccessful,
NotApplicable
}
public CombinationResult TryCombineItems(Pickup pickupA, out GameObject newItem)
{
_animator.ResetTrigger(CombineTrigger);
newItem = null;
if (_cachedPickupObject == null)
{
return CombinationResult.NotApplicable;
}
Pickup pickupB = _cachedPickupObject.GetComponent<Pickup>();
if (pickupA == null || pickupB == null)
{
return CombinationResult.NotApplicable;
}
// Use the InteractionSettings directly instead of GameManager
CombinationRule matchingRule = _interactionSettings.GetCombinationRule(pickupA.itemData, pickupB.itemData);
Vector3 spawnPos = pickupA.gameObject.transform.position;
if (matchingRule != null && matchingRule.resultPrefab != null)
{
newItem = Instantiate(matchingRule.resultPrefab, spawnPos, Quaternion.identity);
var resultPickup = newItem.GetComponent<Pickup>();
PickupItemData itemData = resultPickup.itemData;
// Mark the base items as picked up before destroying them
// (This ensures they save correctly if the game is saved during the combination animation)
pickupA.IsPickedUp = true;
pickupB.IsPickedUp = true;
Destroy(pickupA.gameObject);
Destroy(pickupB.gameObject);
TryPickupItem(newItem, itemData);
PlayAnimationStationary("Combine", 10.0f);
PulverIsCombining.Invoke();
return CombinationResult.Successful;
}
// If no combination found, return Unsuccessful
return CombinationResult.Unsuccessful;
}
/// <summary>
/// Set the item held by the follower, copying all visual properties from the Pickup's SpriteRenderer.
/// </summary>
/// <param name="itemData">The item data to set.</param>
/// <param name="pickupRenderer">The SpriteRenderer from the Pickup to copy appearance from.</param>
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;
}
public void SetHeldItemFromObject(GameObject obj)
{
if (obj == null)
{
ClearHeldItem();
return;
}
var pickup = obj.GetComponent<Pickup>();
if (pickup != null)
{
SetHeldItem(pickup.itemData, pickup.iconRenderer);
_cachedPickupObject = obj;
}
else
{
ClearHeldItem();
}
}
public void ClearHeldItem()
{
_cachedPickupObject = null;
_currentlyHeldItemData = null;
_animator.SetBool("IsCarrying", false);
if (heldObjectRenderer != null)
{
heldObjectRenderer.sprite = null;
heldObjectRenderer.enabled = false;
}
}
public void DropItem(FollowerController follower, Vector3 position)
{
var item = follower.GetHeldPickupObject();
if (item == null) return;
item.transform.position = position;
item.transform.SetParent(null);
item.SetActive(true);
// Reset the pickup state so it can be picked up again and saves correctly
var pickup = item.GetComponent<Pickup>();
if (pickup != null)
{
pickup.ResetPickupState();
}
follower.ClearHeldItem();
_animator.SetBool("IsCarrying", false);
// Optionally: fire event, update UI, etc.
}
public void DropHeldItemAt(Vector3 position)
{
DropItem(this, position);
}
#endregion ItemInteractions
#region ISaveParticipant Implementation
public bool HasBeenRestored => hasBeenRestored;
public string GetSaveId()
{
return "FollowerController";
}
public string SerializeState()
{
var saveData = new FollowerSaveData
{
worldPosition = transform.position,
worldRotation = transform.rotation
};
// Save held item if any
if (_cachedPickupObject != null)
{
var pickup = _cachedPickupObject.GetComponent<Pickup>();
if (pickup is SaveableInteractable saveable)
{
saveData.heldItemSaveId = saveable.GetSaveId();
}
if (_currentlyHeldItemData != null)
{
#if UNITY_EDITOR
saveData.heldItemDataAssetPath = UnityEditor.AssetDatabase.GetAssetPath(_currentlyHeldItemData);
#endif
}
}
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string serializedData)
{
if (string.IsNullOrEmpty(serializedData))
{
Logging.Debug("[FollowerController] No saved state to restore");
hasBeenRestored = true;
return;
}
try
{
var saveData = JsonUtility.FromJson<FollowerSaveData>(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);
}
hasBeenRestored = true;
Logging.Debug($"[FollowerController] Restored position: {saveData.worldPosition}");
}
}
catch (System.Exception ex)
{
Logging.Warning($"[FollowerController] Failed to restore state: {ex.Message}");
}
}
/// <summary>
/// Bilateral restoration: Follower tries to find and claim the held item.
/// If pickup doesn't exist yet, it will try to claim us when it restores.
/// </summary>
private void TryRestoreHeldItem(string heldItemSaveId, string heldItemDataAssetPath)
{
if (_hasRestoredHeldItem)
{
Logging.Debug("[FollowerController] Held item already restored");
return;
}
// Try to find the pickup immediately
GameObject heldObject = ItemManager.Instance?.FindPickupBySaveId(heldItemSaveId);
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<Pickup>();
if (pickup == null)
{
Logging.Warning($"[FollowerController] Found object but no Pickup component: {heldItemSaveId}");
return;
}
// Claim the pickup
TakeOwnership(pickup, heldItemDataAssetPath);
}
/// <summary>
/// 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.
/// </summary>
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.GetSaveId() != _expectedHeldItemSaveId)
{
Logging.Warning($"[FollowerController] Pickup tried to claim but saveId mismatch: {saveable.GetSaveId()} != {_expectedHeldItemSaveId}");
return false;
}
}
// Claim the pickup
TakeOwnership(pickup, null);
return true;
}
/// <summary>
/// Takes ownership of a pickup during restoration. Called by both restoration paths.
/// </summary>
private void TakeOwnership(Pickup pickup, string itemDataAssetPath)
{
if (_hasRestoredHeldItem)
return; // Already claimed
// Get the item data
PickupItemData heldData = pickup.itemData;
#if UNITY_EDITOR
// Try loading from asset path if available and pickup doesn't have data
if (heldData == null && !string.IsNullOrEmpty(itemDataAssetPath))
{
heldData = UnityEditor.AssetDatabase.LoadAssetAtPath<PickupItemData>(itemDataAssetPath);
}
#endif
if (heldData == null)
{
Logging.Warning($"[FollowerController] Could not get item data for pickup: {pickup.gameObject.name}");
return;
}
// 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}");
}
/// <summary>
/// Static method to find the FollowerController instance in the scene.
/// Used by Pickup during bilateral restoration.
/// </summary>
public static FollowerController FindInstance()
{
return FindObjectOfType<FollowerController>();
}
#endregion ISaveParticipant Implementation
#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
}