### 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
508 lines
19 KiB
C#
508 lines
19 KiB
C#
using UnityEngine;
|
|
using Pathfinding;
|
|
using AppleHills.Core.Settings;
|
|
using Core;
|
|
using Core.SaveLoad;
|
|
using Bootstrap;
|
|
|
|
namespace Input
|
|
{
|
|
/// <summary>
|
|
/// Saveable data for PlayerTouchController state
|
|
/// </summary>
|
|
[System.Serializable]
|
|
public class PlayerSaveData
|
|
{
|
|
public Vector3 worldPosition;
|
|
public Quaternion worldRotation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class PlayerTouchController : MonoBehaviour, ITouchInputConsumer, ISaveParticipant
|
|
{
|
|
// --- 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 tracking
|
|
private bool hasBeenRestored;
|
|
|
|
void Awake()
|
|
{
|
|
aiPath = GetComponent<AIPath>();
|
|
artTransform = transform.Find("CharacterArt");
|
|
if (artTransform != null)
|
|
animator = artTransform.GetComponent<Animator>();
|
|
else
|
|
animator = GetComponentInChildren<Animator>();
|
|
// Cache SpriteRenderer for flipping
|
|
if (artTransform != null)
|
|
spriteRenderer = artTransform.GetComponent<SpriteRenderer>();
|
|
if (spriteRenderer == null)
|
|
spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
|
|
|
// Initialize settings reference using GetSettingsObject
|
|
_settings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
|
|
|
|
// Register for post-boot initialization
|
|
BootCompletionService.RegisterInitAction(InitializePostBoot);
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
InputManager.Instance?.SetDefaultConsumer(this);
|
|
_logVerbosity = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().inputLogVerbosity;
|
|
}
|
|
|
|
private void InitializePostBoot()
|
|
{
|
|
// Register with save system after boot
|
|
if (SaveLoadManager.Instance != null)
|
|
{
|
|
SaveLoadManager.Instance.RegisterParticipant(this);
|
|
Logging.Debug("[PlayerTouchController] Registered with SaveLoadManager");
|
|
}
|
|
else
|
|
{
|
|
Logging.Warning("[PlayerTouchController] SaveLoadManager not available for registration");
|
|
}
|
|
}
|
|
|
|
void OnDestroy()
|
|
{
|
|
// Unregister from save system
|
|
if (SaveLoadManager.Instance != null)
|
|
{
|
|
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles tap input. Always uses pathfinding to move to the tapped location.
|
|
/// Cancels any in-progress MoveToAndNotify.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the start of a hold input. Begins tracking the finger and uses the correct movement mode.
|
|
/// Cancels any in-progress MoveToAndNotify.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles hold move input. Updates the target position for direct or pathfinding movement.
|
|
/// /// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the end of a hold input. Stops tracking and disables movement as needed.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the target position for pathfinding movement.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the player directly towards the specified world position.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simulates collision with obstacles by raycasting in the direction of velocity and projecting the velocity if a collision is detected.
|
|
/// </summary>
|
|
/// <param name="position">Player's current position.</param>
|
|
/// <param name="velocity">Intended velocity for this frame.</param>
|
|
/// <returns>Adjusted velocity after collision simulation.</returns>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the player is currently moving and fires appropriate events when movement state changes.
|
|
/// </summary>
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Coroutine for updating the AIPath destination during pathfinding hold movement.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancels any in-progress MoveToAndNotify operation and fires the cancellation event.
|
|
/// </summary>
|
|
public void InterruptMoveTo()
|
|
{
|
|
interruptMoveTo = true;
|
|
isHolding = false;
|
|
directMoveVelocity = Vector3.zero;
|
|
if (_settings.DefaultHoldMovementMode == HoldMovementMode.Direct && aiPath != null)
|
|
aiPath.enabled = false;
|
|
OnMoveToCancelled?.Invoke();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Coroutine for moving the player to a target position and firing arrival/cancel events.
|
|
/// </summary>
|
|
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 ISaveParticipant Implementation
|
|
|
|
public bool HasBeenRestored => hasBeenRestored;
|
|
|
|
public string GetSaveId()
|
|
{
|
|
return "PlayerController";
|
|
}
|
|
|
|
public string SerializeState()
|
|
{
|
|
var saveData = new PlayerSaveData
|
|
{
|
|
worldPosition = transform.position,
|
|
worldRotation = transform.rotation
|
|
};
|
|
return JsonUtility.ToJson(saveData);
|
|
}
|
|
|
|
public void RestoreState(string serializedData)
|
|
{
|
|
if (string.IsNullOrEmpty(serializedData))
|
|
{
|
|
Logging.Debug("[PlayerTouchController] No saved state to restore");
|
|
hasBeenRestored = true;
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var saveData = JsonUtility.FromJson<PlayerSaveData>(serializedData);
|
|
if (saveData != null)
|
|
{
|
|
transform.position = saveData.worldPosition;
|
|
transform.rotation = saveData.worldRotation;
|
|
hasBeenRestored = true;
|
|
Logging.Debug($"[PlayerTouchController] Restored position: {saveData.worldPosition}");
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Logging.Warning($"[PlayerTouchController] Failed to restore state: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|