using UnityEngine; using Pathfinding; using AppleHills.Core.Settings; using Core; using Core.Lifecycle; using Core.SaveLoad; namespace Input { /// /// Saveable data for PlayerTouchController state /// [System.Serializable] public class PlayerSaveData { public Vector3 worldPosition; public Quaternion worldRotation; } /// /// Handles player movement in response to tap and hold input events. /// Supports both direct and pathfinding movement modes, and provides event/callbacks for arrival/cancellation. /// public class PlayerTouchController : ManagedBehaviour, ITouchInputConsumer { // --- Movement State --- private Vector3 targetPosition; private Vector3 directMoveVelocity; // default is Vector3.zero internal bool isHolding; private Vector2 lastHoldPosition; private Coroutine pathfindingDragCoroutine; private float pathfindingDragUpdateInterval = 0.1f; // Interval in seconds [Header("Collision Simulation")] public LayerMask obstacleMask; public float colliderRadius = 0.5f; // --- Settings Reference --- private IPlayerFollowerSettings _settings; // --- Movement Events --- private bool _isMoving = false; public bool IsMoving => _isMoving; public event System.Action OnMovementStarted; public event System.Action OnMovementStopped; // --- Unity/Component References --- private AIPath aiPath; // Note: String-based property lookup is flagged as inefficient, but is common in Unity for dynamic children. private Animator animator; private Transform artTransform; private SpriteRenderer spriteRenderer; // --- Last direct movement direction --- private Vector3 _lastDirectMoveDir = Vector3.right; public Vector3 LastDirectMoveDir => _lastDirectMoveDir; // --- Last movement directions for animation blend tree --- private float _lastDirX = 0f; // -1 (left) to 1 (right) private float _lastDirY = -1f; // -1 (down) to 1 (up) // --- MoveToAndNotify State --- public delegate void ArrivedAtTargetHandler(); private Coroutine moveToCoroutine; public event ArrivedAtTargetHandler OnArrivedAtTarget; public event System.Action OnMoveToCancelled; private bool interruptMoveTo; private LogVerbosity _logVerbosity = LogVerbosity.Warning; // Save system configuration public override bool AutoRegisterForSave => true; // Scene-specific SaveId - each level has its own player state public override string SaveId => $"{gameObject.scene.name}/PlayerController"; public override int ManagedAwakePriority => 100; // Player controller protected override void OnManagedStart() { aiPath = GetComponent(); artTransform = transform.Find("CharacterArt"); if (artTransform != null) animator = artTransform.GetComponent(); else animator = GetComponentInChildren(); // Cache SpriteRenderer for flipping if (artTransform != null) spriteRenderer = artTransform.GetComponent(); if (spriteRenderer == null) spriteRenderer = GetComponentInChildren(); // Initialize settings reference using GetSettingsObject _settings = GameManager.GetSettingsObject(); // Set default input consumer InputManager.Instance?.SetDefaultConsumer(this); _logVerbosity = DeveloperSettingsProvider.Instance.GetSettings().inputLogVerbosity; } /// /// Handles tap input. Always uses pathfinding to move to the tapped location. /// Cancels any in-progress MoveToAndNotify. /// public void OnTap(Vector2 worldPosition) { InterruptMoveTo(); LogDebugMessage($"OnTap at {worldPosition}"); if (aiPath != null) { aiPath.enabled = true; aiPath.canMove = true; aiPath.isStopped = false; SetTargetPosition(worldPosition); directMoveVelocity = Vector3.zero; isHolding = false; } } /// /// Handles the start of a hold input. Begins tracking the finger and uses the correct movement mode. /// Cancels any in-progress MoveToAndNotify. /// public void OnHoldStart(Vector2 worldPosition) { InterruptMoveTo(); LogDebugMessage($"OnHoldStart at {worldPosition}"); lastHoldPosition = worldPosition; isHolding = true; if (_settings.DefaultHoldMovementMode == HoldMovementMode.Pathfinding && aiPath != null) { aiPath.enabled = true; if (pathfindingDragCoroutine != null) StopCoroutine(pathfindingDragCoroutine); pathfindingDragCoroutine = StartCoroutine(PathfindingDragUpdateCoroutine()); } else // Direct movement { if (aiPath != null) aiPath.enabled = false; directMoveVelocity = Vector3.zero; } } /// /// Handles hold move input. Updates the target position for direct or pathfinding movement. /// /// public void OnHoldMove(Vector2 worldPosition) { if (!isHolding) return; lastHoldPosition = worldPosition; if (_settings.DefaultHoldMovementMode == HoldMovementMode.Direct) { if (aiPath != null && aiPath.enabled) aiPath.enabled = false; MoveDirectlyTo(worldPosition); } // If pathfinding, coroutine will update destination } /// /// Handles the end of a hold input. Stops tracking and disables movement as needed. /// public void OnHoldEnd(Vector2 worldPosition) { LogDebugMessage($"OnHoldEnd at {worldPosition}"); isHolding = false; directMoveVelocity = Vector3.zero; if (aiPath != null && _settings.DefaultHoldMovementMode == HoldMovementMode.Pathfinding) { if (pathfindingDragCoroutine != null) { StopCoroutine(pathfindingDragCoroutine); pathfindingDragCoroutine = null; } } if (aiPath != null && _settings.DefaultHoldMovementMode == HoldMovementMode.Direct) { aiPath.enabled = false; } } /// /// Sets the target position for pathfinding movement. /// private void SetTargetPosition(Vector2 worldPosition) { if (aiPath != null) { aiPath.destination = worldPosition; // Apply both speed and acceleration from settings aiPath.maxSpeed = _settings.MoveSpeed; aiPath.maxAcceleration = _settings.MaxAcceleration; aiPath.canMove = true; aiPath.isStopped = false; } } /// /// Moves the player directly towards the specified world position. /// public void MoveDirectlyTo(Vector2 worldPosition) { if (aiPath == null) { return; } Vector3 current = transform.position; Vector3 target = new Vector3(worldPosition.x, worldPosition.y, current.z); Vector3 toTarget = (target - current); Vector3 direction = toTarget.normalized; // Get speed and acceleration directly from settings float maxSpeed = _settings.MoveSpeed; float acceleration = _settings.MaxAcceleration; directMoveVelocity = Vector3.MoveTowards(directMoveVelocity, direction * maxSpeed, acceleration * Time.deltaTime); if (directMoveVelocity.magnitude > maxSpeed) { directMoveVelocity = directMoveVelocity.normalized * maxSpeed; } Vector3 move = directMoveVelocity * Time.deltaTime; if (move.magnitude > toTarget.magnitude) { move = toTarget; } // --- Collision simulation --- Vector3 adjustedVelocity = AdjustVelocityForObstacles(current, directMoveVelocity); Vector3 adjustedMove = adjustedVelocity * Time.deltaTime; if (adjustedMove.magnitude > toTarget.magnitude) { adjustedMove = toTarget; } transform.position += adjustedMove; // Cache the last direct movement direction _lastDirectMoveDir = directMoveVelocity.normalized; } /// /// Simulates collision with obstacles by raycasting in the direction of velocity and projecting the velocity if a collision is detected. /// /// Player's current position. /// Intended velocity for this frame. /// Adjusted velocity after collision simulation. private Vector3 AdjustVelocityForObstacles(Vector3 position, Vector3 velocity) { if (velocity.sqrMagnitude < 0.0001f) return velocity; float moveDistance = velocity.magnitude * Time.deltaTime; Vector2 origin = new Vector2(position.x, position.y); Vector2 dir = velocity.normalized; float rayLength = colliderRadius + moveDistance; RaycastHit2D hit = Physics2D.Raycast(origin, dir, rayLength, obstacleMask); Debug.DrawLine(origin, origin + dir * rayLength, Color.red, 0.1f); if (hit.collider != null) { // Draw normal and tangent for debug Debug.DrawLine(hit.point, hit.point + hit.normal, Color.green, 0.2f); Vector2 tangent = new Vector2(-hit.normal.y, hit.normal.x); Debug.DrawLine(hit.point, hit.point + tangent, Color.blue, 0.2f); // Project velocity onto tangent to simulate sliding float slideAmount = Vector2.Dot(velocity, tangent); Vector3 slideVelocity = tangent * slideAmount; return slideVelocity; } return velocity; } void Update() { UpdateMovementState(); if (animator != null && aiPath != null) { float normalizedSpeed = 0f; Vector3 velocity = Vector3.zero; float maxSpeed = _settings.MoveSpeed; if (isHolding && _settings.DefaultHoldMovementMode == HoldMovementMode.Direct) { normalizedSpeed = directMoveVelocity.magnitude / maxSpeed; velocity = directMoveVelocity; } else if (aiPath.enabled) { normalizedSpeed = aiPath.velocity.magnitude / maxSpeed; velocity = aiPath.velocity; } // Set speed parameter as before 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); } } } /// /// Checks if the player is currently moving and fires appropriate events when movement state changes. /// private void UpdateMovementState() { bool isCurrentlyMoving = false; // Check direct movement if (isHolding && _settings.DefaultHoldMovementMode == HoldMovementMode.Direct) { isCurrentlyMoving = directMoveVelocity.sqrMagnitude > 0.001f; } // Check pathfinding movement else if (aiPath != null && aiPath.enabled) { isCurrentlyMoving = aiPath.velocity.sqrMagnitude > 0.001f; } // Fire events only when state changes if (isCurrentlyMoving && !_isMoving) { _isMoving = true; OnMovementStarted?.Invoke(); LogDebugMessage("Movement started"); } else if (!isCurrentlyMoving && _isMoving) { _isMoving = false; OnMovementStopped?.Invoke(); LogDebugMessage("Movement stopped"); } } /// /// Coroutine for updating the AIPath destination during pathfinding hold movement. /// private System.Collections.IEnumerator PathfindingDragUpdateCoroutine() { while (isHolding && aiPath != null) { aiPath.destination = new Vector3(lastHoldPosition.x, lastHoldPosition.y, transform.position.z); yield return new WaitForSeconds(pathfindingDragUpdateInterval); } } /// /// Moves the player to a specific target position and notifies via events when arrived or cancelled. /// This is used by systems like Pickup.cs to orchestrate movement. /// public void MoveToAndNotify(Vector3 target) { // Cancel any previous move-to coroutine if (moveToCoroutine != null) { StopCoroutine(moveToCoroutine); } interruptMoveTo = false; // Ensure pathfinding is enabled for MoveToAndNotify if (aiPath != null) { aiPath.enabled = true; aiPath.canMove = true; aiPath.isStopped = false; } moveToCoroutine = StartCoroutine(MoveToTargetCoroutine(target)); } /// /// Cancels any in-progress MoveToAndNotify operation and fires the cancellation event. /// public void InterruptMoveTo() { interruptMoveTo = true; isHolding = false; directMoveVelocity = Vector3.zero; if (_settings.DefaultHoldMovementMode == HoldMovementMode.Direct && aiPath != null) aiPath.enabled = false; OnMoveToCancelled?.Invoke(); } /// /// Coroutine for moving the player to a target position and firing arrival/cancel events. /// private System.Collections.IEnumerator MoveToTargetCoroutine(Vector3 target) { if (aiPath != null) { aiPath.destination = target; aiPath.maxSpeed = _settings.MoveSpeed; aiPath.maxAcceleration = _settings.MaxAcceleration; } while (!interruptMoveTo) { Vector2 current2D = new Vector2(transform.position.x, transform.position.y); Vector2 target2D = new Vector2(target.x, target.y); float dist = Vector2.Distance(current2D, target2D); if (dist <= _settings.StopDistance + 0.2f) { break; } yield return null; } moveToCoroutine = null; if (!interruptMoveTo) { OnArrivedAtTarget?.Invoke(); } } private void LogDebugMessage(string message) { if (_logVerbosity <= LogVerbosity.Debug) { Logging.Debug($"[PlayerTouchController] {message}"); } } #region Save/Load Lifecycle Hooks protected override string OnSceneSaveRequested() { var saveData = new PlayerSaveData { worldPosition = transform.position, worldRotation = transform.rotation }; return JsonUtility.ToJson(saveData); } protected override void OnSceneRestoreRequested(string serializedData) { if (string.IsNullOrEmpty(serializedData)) { Logging.Debug("[PlayerTouchController] No saved state to restore"); return; } try { var saveData = JsonUtility.FromJson(serializedData); if (saveData != null) { transform.position = saveData.worldPosition; transform.rotation = saveData.worldRotation; Logging.Debug($"[PlayerTouchController] Restored position: {saveData.worldPosition}"); } } catch (System.Exception ex) { Logging.Warning($"[PlayerTouchController] Failed to restore state: {ex.Message}"); } } #endregion } }