933 lines
33 KiB
C#
933 lines
33 KiB
C#
using Interactions;
|
|
using UnityEngine;
|
|
using Pathfinding;
|
|
using Utils;
|
|
using AppleHills.Core.Settings;
|
|
using Core;
|
|
using Core.Lifecycle;
|
|
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; // ItemId of the PickupItemData (for fallback restoration)
|
|
}
|
|
|
|
/// <summary>
|
|
/// Controls the follower character, including following the player, handling pickups, and managing held items.
|
|
/// </summary>
|
|
public class FollowerController : ManagedBehaviour
|
|
{
|
|
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)
|
|
|
|
// 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;
|
|
|
|
/// <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 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<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>();
|
|
}
|
|
|
|
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<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
|
|
|
|
// 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
|
|
|
|
/// <summary>
|
|
/// Try to pickup an item. If already holding something, optionally drop it first.
|
|
/// </summary>
|
|
/// <param name="itemObject">The GameObject to pick up (must have Pickup component)</param>
|
|
/// <param name="itemData">The item data (redundant - can be extracted from GameObject)</param>
|
|
/// <param name="dropItem">Whether to drop currently held item before picking up new one</param>
|
|
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<Pickup>();
|
|
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<Pickup>();
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set held item from a GameObject. Extracts Pickup component and sets up visuals.
|
|
/// Centralizes held item state management including animator.
|
|
/// </summary>
|
|
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;
|
|
_animator.SetBool("IsCarrying", true); // Centralized animator management
|
|
}
|
|
else
|
|
{
|
|
ClearHeldItem();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear the currently held item. Centralizes state cleanup including animator.
|
|
/// </summary>
|
|
public void ClearHeldItem()
|
|
{
|
|
_cachedPickupObject = null;
|
|
_currentlyHeldItemData = null;
|
|
_animator.SetBool("IsCarrying", false); // Centralized animator management
|
|
|
|
if (heldObjectRenderer != null)
|
|
{
|
|
heldObjectRenderer.sprite = null;
|
|
heldObjectRenderer.enabled = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drop the currently held item at the specified position.
|
|
/// </summary>
|
|
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<Pickup>();
|
|
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<Pickup>();
|
|
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<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);
|
|
}
|
|
|
|
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 in the scene (e.g., dynamically spawned combined item),
|
|
/// spawns it from the itemData.
|
|
/// </summary>
|
|
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<Pickup>();
|
|
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);
|
|
}
|
|
|
|
/// <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.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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes ownership of a pickup during restoration. Called by both restoration paths.
|
|
/// </summary>
|
|
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})");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Static method to find the FollowerController instance in the scene.
|
|
/// Used by Pickup during bilateral restoration.
|
|
/// </summary>
|
|
public static FollowerController FindInstance()
|
|
{
|
|
return FindFirstObjectByType<FollowerController>();
|
|
}
|
|
|
|
#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
|
|
}
|