945 lines
34 KiB
C#
945 lines
34 KiB
C#
|
|
using System;
|
||
|
|
using System.Collections.Generic;
|
||
|
|
using UnityEngine;
|
||
|
|
using UnityEngine.AI;
|
||
|
|
using UnityEngine.EventSystems;
|
||
|
|
using UnityEngine.InputSystem;
|
||
|
|
|
||
|
|
namespace MedievalShopkeeper
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// Player Controller designed for a Medieval Shopkeeper game.
|
||
|
|
/// Supports PC (Left-Click to Move, Right-Click to Interact) and Mobile (Tap to Move / Tap Interactable to Approach & Interact).
|
||
|
|
/// Uses NavMeshAgent for shop navigation and Unity's New Input System.
|
||
|
|
/// </summary>
|
||
|
|
[RequireComponent(typeof(NavMeshAgent))]
|
||
|
|
[SelectionBase]
|
||
|
|
public class PlayerController : MonoBehaviour
|
||
|
|
{
|
||
|
|
[Header("References")]
|
||
|
|
[Tooltip("The camera used for raycasting screen clicks. Defaults to Camera.main.")]
|
||
|
|
[SerializeField] private Camera playerCamera;
|
||
|
|
|
||
|
|
[Tooltip("NavMeshAgent component. If unassigned, will be retrieved on Awake.")]
|
||
|
|
[SerializeField] private NavMeshAgent agent;
|
||
|
|
|
||
|
|
[Tooltip("Optional Animator component for walking and carrying animations.")]
|
||
|
|
[SerializeField] private Animator animator;
|
||
|
|
|
||
|
|
[Header("Movement Settings")]
|
||
|
|
[Tooltip("Standard walking speed of the shopkeeper.")]
|
||
|
|
[SerializeField] private float walkSpeed = 4.0f;
|
||
|
|
|
||
|
|
[Tooltip("Turning speed in degrees per second.")]
|
||
|
|
[SerializeField] private float turnSpeed = 1080f;
|
||
|
|
|
||
|
|
[Tooltip("If true, turns on the spot when changing directions sharply instead of making wide circular arcs.")]
|
||
|
|
[SerializeField] private bool turnInPlace = true;
|
||
|
|
|
||
|
|
[Tooltip("Turning/angular speed in degrees per second.")]
|
||
|
|
[SerializeField] private float agentAngularSpeed = 720f;
|
||
|
|
|
||
|
|
[Tooltip("Acceleration of the agent for crisp starting and stopping.")]
|
||
|
|
[SerializeField] private float agentAcceleration = 24f;
|
||
|
|
|
||
|
|
[Tooltip("Speed multiplier applied when carrying stock boxes, crates, or heavy items.")]
|
||
|
|
[Range(0.1f, 1f)]
|
||
|
|
[SerializeField] private float carrySpeedMultiplier = 0.8f;
|
||
|
|
|
||
|
|
[Tooltip("Distance threshold to consider the destination reached.")]
|
||
|
|
[SerializeField] private float destinationThreshold = 0.35f;
|
||
|
|
|
||
|
|
[Tooltip("Shop floor plane Y-level for raycast fallback.")]
|
||
|
|
[SerializeField] private float floorYLevel = 0f;
|
||
|
|
|
||
|
|
[Tooltip("Layers representing walkable ground/floor in the shop.")]
|
||
|
|
[SerializeField] private LayerMask groundLayers = ~0;
|
||
|
|
|
||
|
|
[Header("Interaction Settings")]
|
||
|
|
[Tooltip("Layers representing interactable objects (shelves, stock crates, NPCs, counters).")]
|
||
|
|
[SerializeField] private LayerMask interactableLayers = ~0;
|
||
|
|
|
||
|
|
[Tooltip("Default interaction range if the target does not specify one.")]
|
||
|
|
[SerializeField] private float defaultInteractionDistance = 2.0f;
|
||
|
|
|
||
|
|
[Tooltip("On mobile / touch: tapping an interactable automatically moves to it and interacts.")]
|
||
|
|
[SerializeField] private bool smartTouchInteraction = true;
|
||
|
|
|
||
|
|
[Tooltip("On PC: allow left-click on an interactable to also trigger interaction.")]
|
||
|
|
[SerializeField] private bool leftClickAlsoInteracts = false;
|
||
|
|
|
||
|
|
[Header("Shopkeeper Carry System")]
|
||
|
|
[Tooltip("Transform socket (e.g. hands or chest) where carried items, boxes, or crates will be attached.")]
|
||
|
|
[SerializeField] private Transform carrySocket;
|
||
|
|
|
||
|
|
[Header("Optional Input Actions (New Input System)")]
|
||
|
|
[Tooltip("Optional: Action for movement click/tap. If unassigned, defaults to Mouse Left-Click / Touch.")]
|
||
|
|
[SerializeField] private InputActionReference moveAction;
|
||
|
|
|
||
|
|
[Tooltip("Optional: Action for interaction click. If unassigned, defaults to Mouse Right-Click.")]
|
||
|
|
[SerializeField] private InputActionReference interactAction;
|
||
|
|
|
||
|
|
[Tooltip("Optional: Action for pointer position. If unassigned, reads Mouse/Touch position.")]
|
||
|
|
[SerializeField] private InputActionReference pointerPositionAction;
|
||
|
|
|
||
|
|
[Header("Visual Feedback (Optional)")]
|
||
|
|
[Tooltip("Optional prefab instantiated or moved to show the click/destination point.")]
|
||
|
|
[SerializeField] private GameObject destinationMarkerPrefab;
|
||
|
|
|
||
|
|
[Header("Debug")]
|
||
|
|
[Tooltip("Output detailed logs comparing clicked destination vs NavMesh destination.")]
|
||
|
|
[SerializeField] private bool enableDebugLogs = true;
|
||
|
|
|
||
|
|
[Header("Animation Parameters")]
|
||
|
|
[SerializeField] private string speedAnimParam = "Speed";
|
||
|
|
[SerializeField] private string isMovingAnimParam = "IsMoving";
|
||
|
|
[SerializeField] private string isCarryingAnimParam = "IsCarrying";
|
||
|
|
|
||
|
|
// Runtime state
|
||
|
|
private IInteractable pendingTarget;
|
||
|
|
private IInteractable currentHovered;
|
||
|
|
private GameObject destinationMarkerInstance;
|
||
|
|
private GameObject currentCarriedItem;
|
||
|
|
private Vector3 lastClickedDestination;
|
||
|
|
private Vector3 lastNavMeshDestination;
|
||
|
|
private bool hasActiveMoveCommand;
|
||
|
|
private int speedHash;
|
||
|
|
private int isMovingHash;
|
||
|
|
private int isCarryingHash;
|
||
|
|
|
||
|
|
// Public properties
|
||
|
|
public NavMeshAgent Agent => agent;
|
||
|
|
public IInteractable PendingTarget => pendingTarget;
|
||
|
|
public IInteractable CurrentHovered => currentHovered;
|
||
|
|
public GameObject CurrentCarriedItem => currentCarriedItem;
|
||
|
|
public bool IsCarryingItem => currentCarriedItem != null;
|
||
|
|
public Vector3 LastClickedDestination => lastClickedDestination;
|
||
|
|
public Vector3 LastNavMeshDestination => lastNavMeshDestination;
|
||
|
|
|
||
|
|
// Events
|
||
|
|
public event Action<Vector3> OnDestinationSet;
|
||
|
|
public event Action<IInteractable> OnInteractionTargetSet;
|
||
|
|
public event Action<IInteractable> OnInteracted;
|
||
|
|
public event Action<IInteractable> OnHoverChanged;
|
||
|
|
public event Action<GameObject> OnItemPickedUp;
|
||
|
|
public event Action<GameObject> OnItemDropped;
|
||
|
|
|
||
|
|
private void Awake()
|
||
|
|
{
|
||
|
|
if (agent == null)
|
||
|
|
{
|
||
|
|
agent = GetComponent<NavMeshAgent>();
|
||
|
|
}
|
||
|
|
|
||
|
|
FindCamera();
|
||
|
|
|
||
|
|
// Ensure the camera is statically positioned overhead with TopDownCamera
|
||
|
|
if (playerCamera != null)
|
||
|
|
{
|
||
|
|
TopDownCamera topDownCam = playerCamera.GetComponent<TopDownCamera>();
|
||
|
|
if (topDownCam == null)
|
||
|
|
{
|
||
|
|
topDownCam = playerCamera.gameObject.AddComponent<TopDownCamera>();
|
||
|
|
}
|
||
|
|
topDownCam.ApplyCameraTransform();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (animator == null)
|
||
|
|
{
|
||
|
|
animator = GetComponentInChildren<Animator>();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (animator != null)
|
||
|
|
{
|
||
|
|
animator.applyRootMotion = false; // Prevent animations from fighting NavMesh movement
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for offset visual models which cause orbiting
|
||
|
|
foreach (var r in GetComponentsInChildren<Renderer>())
|
||
|
|
{
|
||
|
|
Vector3 localPos = r.transform.localPosition;
|
||
|
|
localPos.y = 0; // Ignore vertical offset
|
||
|
|
if (localPos.sqrMagnitude > 0.25f) // If offset by more than 0.5m on X/Z
|
||
|
|
{
|
||
|
|
Debug.LogWarning($"[PlayerController] WARNING: Visual model '{r.gameObject.name}' is offset from the Player center by {localPos}. This will cause the character to orbit around the pivot point instead of rotating in place! Please center the model in the prefab.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Configure agent for responsive movement without circular orbiting
|
||
|
|
agent.speed = walkSpeed;
|
||
|
|
agent.angularSpeed = agentAngularSpeed;
|
||
|
|
agent.acceleration = agentAcceleration;
|
||
|
|
agent.autoBraking = true;
|
||
|
|
agent.stoppingDistance = destinationThreshold;
|
||
|
|
agent.updateRotation = false; // We handle rotation to eliminate wide circular loops
|
||
|
|
|
||
|
|
// Cache animation hashes
|
||
|
|
speedHash = Animator.StringToHash(speedAnimParam);
|
||
|
|
isMovingHash = Animator.StringToHash(isMovingAnimParam);
|
||
|
|
isCarryingHash = Animator.StringToHash(isCarryingAnimParam);
|
||
|
|
|
||
|
|
// Instantiate destination marker or create default click disc
|
||
|
|
if (destinationMarkerPrefab != null)
|
||
|
|
{
|
||
|
|
destinationMarkerInstance = Instantiate(destinationMarkerPrefab);
|
||
|
|
foreach (var col in destinationMarkerInstance.GetComponentsInChildren<Collider>())
|
||
|
|
{
|
||
|
|
col.enabled = false;
|
||
|
|
}
|
||
|
|
destinationMarkerInstance.SetActive(false);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
destinationMarkerInstance = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
|
||
|
|
destinationMarkerInstance.name = "ClickDestinationMarker";
|
||
|
|
destinationMarkerInstance.transform.localScale = new Vector3(0.5f, 0.01f, 0.5f);
|
||
|
|
var col = destinationMarkerInstance.GetComponent<Collider>();
|
||
|
|
if (col != null) Destroy(col);
|
||
|
|
var rend = destinationMarkerInstance.GetComponent<Renderer>();
|
||
|
|
if (rend != null)
|
||
|
|
{
|
||
|
|
rend.material.color = new Color(0f, 0.85f, 1f, 0.8f);
|
||
|
|
}
|
||
|
|
destinationMarkerInstance.SetActive(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Start()
|
||
|
|
{
|
||
|
|
// Ensure the agent is cleanly snapped onto the NavMesh if placed slightly above or below
|
||
|
|
if (!agent.isOnNavMesh)
|
||
|
|
{
|
||
|
|
if (NavMesh.SamplePosition(transform.position, out NavMeshHit navHit, 5.0f, NavMesh.AllAreas))
|
||
|
|
{
|
||
|
|
agent.Warp(navHit.position);
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.Log($"[PlayerController] Agent snapped to NavMesh at: {navHit.position}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
Debug.LogWarning("[PlayerController] Player is not placed on a valid NavMesh! Please ensure the floor has a baked NavMesh.", this);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnEnable()
|
||
|
|
{
|
||
|
|
moveAction?.action?.Enable();
|
||
|
|
interactAction?.action?.Enable();
|
||
|
|
pointerPositionAction?.action?.Enable();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnDisable()
|
||
|
|
{
|
||
|
|
moveAction?.action?.Disable();
|
||
|
|
interactAction?.action?.Disable();
|
||
|
|
pointerPositionAction?.action?.Disable();
|
||
|
|
|
||
|
|
if (currentHovered != null)
|
||
|
|
{
|
||
|
|
currentHovered.OnHoverExit(this);
|
||
|
|
currentHovered = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Update()
|
||
|
|
{
|
||
|
|
// Suspend normal player interaction and movement if building mode is active
|
||
|
|
if (BuildManager.Instance != null && BuildManager.Instance.IsBuilding)
|
||
|
|
{
|
||
|
|
ClearHover(); // Remove UI prompts
|
||
|
|
return; // Let BuildManager handle input
|
||
|
|
}
|
||
|
|
|
||
|
|
HandleHover();
|
||
|
|
HandleInput();
|
||
|
|
HandleDestinationArrival();
|
||
|
|
HandlePendingInteraction();
|
||
|
|
UpdateRotation();
|
||
|
|
UpdateAnimation();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void FindCamera()
|
||
|
|
{
|
||
|
|
if (playerCamera == null)
|
||
|
|
{
|
||
|
|
playerCamera = Camera.main;
|
||
|
|
if (playerCamera == null)
|
||
|
|
{
|
||
|
|
playerCamera = FindAnyObjectByType<Camera>();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#region Input Handling
|
||
|
|
|
||
|
|
private void HandleInput()
|
||
|
|
{
|
||
|
|
Vector2 screenPos = GetPointerPosition();
|
||
|
|
|
||
|
|
// Ignore input if pointer is over UI elements
|
||
|
|
if (IsPointerOverUI(screenPos))
|
||
|
|
{
|
||
|
|
if (WasMovePressed() || WasInteractPressed())
|
||
|
|
{
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.LogWarning($"[PlayerController] Click at screenPos {screenPos} was BLOCKED by UI.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool isTouchDevice = IsTouchscreenActive();
|
||
|
|
|
||
|
|
// 1. Primary input (Left Click on PC / Screen Tap on Mobile)
|
||
|
|
if (WasMovePressed())
|
||
|
|
{
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.Log($"<color=#FFFF00>[PlayerController] CLICK DETECTED</color> at screen: {screenPos}");
|
||
|
|
}
|
||
|
|
if (isTouchDevice && smartTouchInteraction)
|
||
|
|
{
|
||
|
|
if (TryRaycastInteractable(screenPos, out IInteractable touchedInteractable))
|
||
|
|
{
|
||
|
|
CommandInteract(touchedInteractable);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else if (leftClickAlsoInteracts)
|
||
|
|
{
|
||
|
|
if (TryRaycastInteractable(screenPos, out IInteractable clickedInteractable))
|
||
|
|
{
|
||
|
|
CommandInteract(clickedInteractable);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Move to ground point
|
||
|
|
if (TryRaycastGround(screenPos, out Vector3 groundPoint))
|
||
|
|
{
|
||
|
|
CommandMove(groundPoint);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.LogWarning($"[PlayerController] Left Click at screenPos {screenPos} did NOT hit any ground or NavMesh.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Secondary input (Right Click on PC)
|
||
|
|
if (WasInteractPressed())
|
||
|
|
{
|
||
|
|
if (TryRaycastInteractable(screenPos, out IInteractable interactable))
|
||
|
|
{
|
||
|
|
CommandInteract(interactable);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.Log($"[PlayerController] Right Click at screenPos {screenPos} did NOT hit any IInteractable.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private Vector2 GetPointerPosition()
|
||
|
|
{
|
||
|
|
if (pointerPositionAction != null && pointerPositionAction.action != null && pointerPositionAction.action.enabled)
|
||
|
|
{
|
||
|
|
return pointerPositionAction.action.ReadValue<Vector2>();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Touchscreen.current != null && Touchscreen.current.primaryTouch.press.isPressed)
|
||
|
|
{
|
||
|
|
return Touchscreen.current.primaryTouch.position.ReadValue();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Mouse.current != null)
|
||
|
|
{
|
||
|
|
return Mouse.current.position.ReadValue();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Pointer.current != null)
|
||
|
|
{
|
||
|
|
return Pointer.current.position.ReadValue();
|
||
|
|
}
|
||
|
|
|
||
|
|
return Vector2.zero;
|
||
|
|
}
|
||
|
|
|
||
|
|
private bool WasMovePressed()
|
||
|
|
{
|
||
|
|
if (moveAction != null && moveAction.action != null)
|
||
|
|
{
|
||
|
|
return moveAction.action.WasPressedThisFrame();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Touchscreen.current != null && Touchscreen.current.primaryTouch.press.wasPressedThisFrame)
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame)
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private bool WasInteractPressed()
|
||
|
|
{
|
||
|
|
if (interactAction != null && interactAction.action != null)
|
||
|
|
{
|
||
|
|
return interactAction.action.WasPressedThisFrame();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Mouse.current != null && Mouse.current.rightButton.wasPressedThisFrame)
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private bool IsTouchscreenActive()
|
||
|
|
{
|
||
|
|
return Touchscreen.current != null && Touchscreen.current.primaryTouch.press.isPressed;
|
||
|
|
}
|
||
|
|
|
||
|
|
private bool IsPointerOverUI(Vector2 screenPosition)
|
||
|
|
{
|
||
|
|
if (EventSystem.current == null) return false;
|
||
|
|
|
||
|
|
if (EventSystem.current.IsPointerOverGameObject()) return true;
|
||
|
|
|
||
|
|
if (Touchscreen.current != null && Touchscreen.current.primaryTouch.press.isPressed)
|
||
|
|
{
|
||
|
|
int touchId = Touchscreen.current.primaryTouch.touchId.ReadValue();
|
||
|
|
if (EventSystem.current.IsPointerOverGameObject(touchId)) return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
PointerEventData eventData = new PointerEventData(EventSystem.current)
|
||
|
|
{
|
||
|
|
position = screenPosition
|
||
|
|
};
|
||
|
|
var results = new List<RaycastResult>();
|
||
|
|
EventSystem.current.RaycastAll(eventData, results);
|
||
|
|
return results.Count > 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Raycasting
|
||
|
|
|
||
|
|
private bool TryRaycastGround(Vector2 screenPosition, out Vector3 hitPoint)
|
||
|
|
{
|
||
|
|
hitPoint = Vector3.zero;
|
||
|
|
FindCamera();
|
||
|
|
if (playerCamera == null) return false;
|
||
|
|
|
||
|
|
Ray ray = playerCamera.ScreenPointToRay(screenPosition);
|
||
|
|
|
||
|
|
// 1. Raycast against physics colliders, excluding the player character
|
||
|
|
RaycastHit[] hits = Physics.RaycastAll(ray, 300f, groundLayers, QueryTriggerInteraction.Ignore);
|
||
|
|
if (hits.Length > 0)
|
||
|
|
{
|
||
|
|
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
||
|
|
|
||
|
|
foreach (var hit in hits)
|
||
|
|
{
|
||
|
|
if (hit.collider.transform.root == transform.root) continue;
|
||
|
|
|
||
|
|
// Sample onto NavMesh
|
||
|
|
if (NavMesh.SamplePosition(hit.point, out NavMeshHit navHit, 3.0f, NavMesh.AllAreas))
|
||
|
|
{
|
||
|
|
hitPoint = navHit.position;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
hitPoint = hit.point;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Mathematical Plane Fallback: If no collider hit, intersect with shop floor Y plane
|
||
|
|
Plane floorPlane = new Plane(Vector3.up, new Vector3(0f, floorYLevel, 0f));
|
||
|
|
if (floorPlane.Raycast(ray, out float enterDist))
|
||
|
|
{
|
||
|
|
Vector3 planePoint = ray.GetPoint(enterDist);
|
||
|
|
if (NavMesh.SamplePosition(planePoint, out NavMeshHit navHit, 5.0f, NavMesh.AllAreas))
|
||
|
|
{
|
||
|
|
hitPoint = navHit.position;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
hitPoint = planePoint;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private bool TryRaycastInteractable(Vector2 screenPosition, out IInteractable interactable)
|
||
|
|
{
|
||
|
|
interactable = null;
|
||
|
|
FindCamera();
|
||
|
|
if (playerCamera == null) return false;
|
||
|
|
|
||
|
|
Ray ray = playerCamera.ScreenPointToRay(screenPosition);
|
||
|
|
RaycastHit[] hits = Physics.RaycastAll(ray, 300f, interactableLayers, QueryTriggerInteraction.Collide);
|
||
|
|
|
||
|
|
if (hits.Length > 0)
|
||
|
|
{
|
||
|
|
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
||
|
|
|
||
|
|
foreach (var hit in hits)
|
||
|
|
{
|
||
|
|
if (hit.collider.transform.root == transform.root) continue;
|
||
|
|
|
||
|
|
var found = hit.collider.GetComponentInParent<IInteractable>();
|
||
|
|
if (found != null && found.CanInteract(this))
|
||
|
|
{
|
||
|
|
interactable = found;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Movement & Interaction Execution
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Commands the shopkeeper to walk to a position on the floor, canceling any active interaction target.
|
||
|
|
/// </summary>
|
||
|
|
public void CommandMove(Vector3 destination)
|
||
|
|
{
|
||
|
|
ClearPendingTarget();
|
||
|
|
|
||
|
|
if (!agent.isOnNavMesh)
|
||
|
|
{
|
||
|
|
if (NavMesh.SamplePosition(transform.position, out NavMeshHit navHit, 5.0f, NavMesh.AllAreas))
|
||
|
|
{
|
||
|
|
agent.Warp(navHit.position);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
Debug.LogWarning("[PlayerController] Cannot move: Agent is not on NavMesh!", this);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
lastClickedDestination = destination;
|
||
|
|
agent.stoppingDistance = destinationThreshold;
|
||
|
|
agent.isStopped = false;
|
||
|
|
agent.velocity = Vector3.zero; // Kill residual forward momentum to prevent circular turn arcs
|
||
|
|
bool setSuccess = agent.SetDestination(destination);
|
||
|
|
lastNavMeshDestination = agent.destination;
|
||
|
|
hasActiveMoveCommand = true;
|
||
|
|
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
float dist = Vector3.Distance(transform.position, destination);
|
||
|
|
Debug.Log($"<color=#00FF88><b>[PlayerController] CLICKED: {destination} --> NAVMESH: {agent.destination}</b></color> (Player at {transform.position}, Dist: {dist:F2}m, Success: {setSuccess}, PathStatus: {agent.pathStatus})");
|
||
|
|
}
|
||
|
|
|
||
|
|
ShowDestinationMarker(destination);
|
||
|
|
OnDestinationSet?.Invoke(destination);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Commands the shopkeeper to approach an interactable (shelf, crate, NPC) and interact upon arrival.
|
||
|
|
/// If already within range, interacts immediately.
|
||
|
|
/// </summary>
|
||
|
|
public void CommandInteract(IInteractable target)
|
||
|
|
{
|
||
|
|
if (target == null || !target.CanInteract(this)) return;
|
||
|
|
|
||
|
|
float distance = GetDistanceToInteractable(target);
|
||
|
|
float interactionDistance = target.InteractionDistance > 0 ? target.InteractionDistance : defaultInteractionDistance;
|
||
|
|
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.Log($"<color=#FFD700><b>[PlayerController] INTERACT COMMAND:</b></color> Target: '{target.InteractionPrompt}' at {target.InteractionTransform.position} | Current Distance: {distance:F2}m (Allowed: {interactionDistance:F2}m)");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (distance <= interactionDistance)
|
||
|
|
{
|
||
|
|
ClearPendingTarget();
|
||
|
|
agent.ResetPath();
|
||
|
|
agent.velocity = Vector3.zero;
|
||
|
|
FaceTarget(target.InteractionTransform.position);
|
||
|
|
ExecuteInteraction(target);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
pendingTarget = target;
|
||
|
|
hasActiveMoveCommand = false;
|
||
|
|
lastClickedDestination = target.InteractionTransform.position;
|
||
|
|
agent.stoppingDistance = Mathf.Max(0.5f, interactionDistance * 0.85f);
|
||
|
|
agent.isStopped = false;
|
||
|
|
agent.SetDestination(target.InteractionTransform.position);
|
||
|
|
lastNavMeshDestination = agent.destination;
|
||
|
|
|
||
|
|
ShowDestinationMarker(target.InteractionTransform.position);
|
||
|
|
OnInteractionTargetSet?.Invoke(target);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Handles stopping cleanly when reaching ground move destinations to prevent orbiting/circling.
|
||
|
|
/// </summary>
|
||
|
|
private void HandleDestinationArrival()
|
||
|
|
{
|
||
|
|
if (pendingTarget != null) return;
|
||
|
|
if (!hasActiveMoveCommand) return;
|
||
|
|
|
||
|
|
if (!agent.pathPending && agent.hasPath)
|
||
|
|
{
|
||
|
|
Vector3 flatPos = new Vector3(transform.position.x, 0, transform.position.z);
|
||
|
|
Vector3 flatDest = new Vector3(agent.destination.x, 0, agent.destination.z);
|
||
|
|
float flatDist = Vector3.Distance(flatPos, flatDest);
|
||
|
|
|
||
|
|
// Normal arrival: within stopping distance or remaining distance
|
||
|
|
if (flatDist <= agent.stoppingDistance || agent.remainingDistance <= agent.stoppingDistance)
|
||
|
|
{
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.Log($"<color=#00D4FF><b>[PlayerController] ARRIVED AT DESTINATION:</b></color> Position: {transform.position} (Dist: {flatDist:F2}m)");
|
||
|
|
}
|
||
|
|
|
||
|
|
hasActiveMoveCommand = false;
|
||
|
|
agent.ResetPath();
|
||
|
|
agent.velocity = Vector3.zero;
|
||
|
|
HideDestinationMarker();
|
||
|
|
}
|
||
|
|
// Anti-orbiting safeguard: if agent is close and moving away from destination
|
||
|
|
else if (flatDist < 0.8f && Vector3.Dot(agent.velocity.normalized, (flatDest - flatPos).normalized) < 0f)
|
||
|
|
{
|
||
|
|
if (enableDebugLogs)
|
||
|
|
{
|
||
|
|
Debug.Log($"<color=#FFAA00><b>[PlayerController] OVERSHOOT PREVENTED (STOPPED):</b></color> Position: {transform.position} (Dist: {flatDist:F2}m)");
|
||
|
|
}
|
||
|
|
|
||
|
|
hasActiveMoveCommand = false;
|
||
|
|
agent.ResetPath();
|
||
|
|
agent.velocity = Vector3.zero;
|
||
|
|
HideDestinationMarker();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void HandlePendingInteraction()
|
||
|
|
{
|
||
|
|
if (pendingTarget == null) return;
|
||
|
|
|
||
|
|
if (!pendingTarget.CanInteract(this))
|
||
|
|
{
|
||
|
|
ClearPendingTarget();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
float interactionDistance = pendingTarget.InteractionDistance > 0 ? pendingTarget.InteractionDistance : defaultInteractionDistance;
|
||
|
|
float distance = GetDistanceToInteractable(pendingTarget);
|
||
|
|
|
||
|
|
if (distance <= interactionDistance || (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance))
|
||
|
|
{
|
||
|
|
IInteractable targetToInteract = pendingTarget;
|
||
|
|
ClearPendingTarget();
|
||
|
|
|
||
|
|
agent.ResetPath();
|
||
|
|
agent.velocity = Vector3.zero;
|
||
|
|
FaceTarget(targetToInteract.InteractionTransform.position);
|
||
|
|
ExecuteInteraction(targetToInteract);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void ExecuteInteraction(IInteractable target)
|
||
|
|
{
|
||
|
|
HideDestinationMarker();
|
||
|
|
target.Interact(this);
|
||
|
|
OnInteracted?.Invoke(target);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void ClearPendingTarget()
|
||
|
|
{
|
||
|
|
pendingTarget = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private float GetDistanceToInteractable(IInteractable target)
|
||
|
|
{
|
||
|
|
Vector3 targetPos = target.InteractionTransform.position;
|
||
|
|
Vector3 playerPos = transform.position;
|
||
|
|
targetPos.y = playerPos.y;
|
||
|
|
return Vector3.Distance(playerPos, targetPos);
|
||
|
|
}
|
||
|
|
|
||
|
|
private void UpdateRotation()
|
||
|
|
{
|
||
|
|
if (agent == null) return;
|
||
|
|
|
||
|
|
Vector3 targetDirection = Vector3.zero;
|
||
|
|
|
||
|
|
if (pendingTarget != null)
|
||
|
|
{
|
||
|
|
targetDirection = pendingTarget.InteractionTransform.position - transform.position;
|
||
|
|
}
|
||
|
|
else if (hasActiveMoveCommand && agent.hasPath)
|
||
|
|
{
|
||
|
|
targetDirection = agent.steeringTarget - transform.position;
|
||
|
|
}
|
||
|
|
|
||
|
|
targetDirection.y = 0;
|
||
|
|
|
||
|
|
if (targetDirection.sqrMagnitude > 0.001f)
|
||
|
|
{
|
||
|
|
Quaternion targetRot = Quaternion.LookRotation(targetDirection.normalized);
|
||
|
|
float angleDiff = Quaternion.Angle(transform.rotation, targetRot);
|
||
|
|
|
||
|
|
// Strict Turn-in-place: if angle is large, stop all movement until facing the target
|
||
|
|
if (turnInPlace && angleDiff > 10f)
|
||
|
|
{
|
||
|
|
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRot, turnSpeed * Time.deltaTime);
|
||
|
|
agent.velocity = Vector3.zero; // Completely halt forward momentum while turning!
|
||
|
|
agent.speed = 0f;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRot, turnSpeed * Time.deltaTime);
|
||
|
|
float baseSpeed = IsCarryingItem ? (walkSpeed * carrySpeedMultiplier) : walkSpeed;
|
||
|
|
agent.speed = baseSpeed;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void FaceTarget(Vector3 targetPosition)
|
||
|
|
{
|
||
|
|
Vector3 direction = (targetPosition - transform.position);
|
||
|
|
direction.y = 0;
|
||
|
|
if (direction.sqrMagnitude > 0.001f)
|
||
|
|
{
|
||
|
|
transform.rotation = Quaternion.LookRotation(direction);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Hover Handling
|
||
|
|
|
||
|
|
private void HandleHover()
|
||
|
|
{
|
||
|
|
if (Mouse.current == null && Pointer.current == null) return;
|
||
|
|
|
||
|
|
Vector2 screenPos = GetPointerPosition();
|
||
|
|
if (IsPointerOverUI(screenPos))
|
||
|
|
{
|
||
|
|
ClearHover();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (TryRaycastInteractable(screenPos, out IInteractable hitInteractable) && hitInteractable.CanInteract(this))
|
||
|
|
{
|
||
|
|
if (currentHovered != hitInteractable)
|
||
|
|
{
|
||
|
|
ClearHover();
|
||
|
|
currentHovered = hitInteractable;
|
||
|
|
currentHovered.OnHoverEnter(this);
|
||
|
|
OnHoverChanged?.Invoke(currentHovered);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
ClearHover();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void ClearHover()
|
||
|
|
{
|
||
|
|
if (currentHovered != null)
|
||
|
|
{
|
||
|
|
currentHovered.OnHoverExit(this);
|
||
|
|
currentHovered = null;
|
||
|
|
OnHoverChanged?.Invoke(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Shopkeeper Carry System
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Attaches an item, delivery crate, or stock box into the shopkeeper's carry socket.
|
||
|
|
/// </summary>
|
||
|
|
public bool CarryItem(GameObject item)
|
||
|
|
{
|
||
|
|
if (item == null || IsCarryingItem) return false;
|
||
|
|
|
||
|
|
currentCarriedItem = item;
|
||
|
|
|
||
|
|
Transform targetParent = carrySocket != null ? carrySocket : transform;
|
||
|
|
item.transform.SetParent(targetParent);
|
||
|
|
item.transform.localPosition = Vector3.zero;
|
||
|
|
item.transform.localRotation = Quaternion.identity;
|
||
|
|
|
||
|
|
if (item.TryGetComponent(out Rigidbody rb))
|
||
|
|
{
|
||
|
|
rb.isKinematic = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (item.TryGetComponent(out Collider col))
|
||
|
|
{
|
||
|
|
col.enabled = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
UpdateMovementSpeed();
|
||
|
|
OnItemPickedUp?.Invoke(item);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Drops the currently carried item onto the ground in front of the player.
|
||
|
|
/// </summary>
|
||
|
|
public GameObject DropCarriedItem(Vector3? dropPosition = null)
|
||
|
|
{
|
||
|
|
if (!IsCarryingItem) return null;
|
||
|
|
|
||
|
|
GameObject dropped = currentCarriedItem;
|
||
|
|
currentCarriedItem = null;
|
||
|
|
|
||
|
|
dropped.transform.SetParent(null);
|
||
|
|
Vector3 targetDrop = dropPosition ?? (transform.position + transform.forward * 0.8f + Vector3.up * 0.2f);
|
||
|
|
dropped.transform.position = targetDrop;
|
||
|
|
|
||
|
|
if (dropped.TryGetComponent(out Collider col))
|
||
|
|
{
|
||
|
|
col.enabled = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (dropped.TryGetComponent(out Rigidbody rb))
|
||
|
|
{
|
||
|
|
rb.isKinematic = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
UpdateMovementSpeed();
|
||
|
|
OnItemDropped?.Invoke(dropped);
|
||
|
|
return dropped;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Removes the carried item reference without placing it in the world (e.g. placed onto a shelf).
|
||
|
|
/// </summary>
|
||
|
|
public void ClearCarriedItem()
|
||
|
|
{
|
||
|
|
currentCarriedItem = null;
|
||
|
|
UpdateMovementSpeed();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void UpdateMovementSpeed()
|
||
|
|
{
|
||
|
|
float speed = walkSpeed;
|
||
|
|
if (IsCarryingItem)
|
||
|
|
{
|
||
|
|
speed *= carrySpeedMultiplier;
|
||
|
|
}
|
||
|
|
agent.speed = speed;
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Visuals & Animation
|
||
|
|
|
||
|
|
private void ShowDestinationMarker(Vector3 position)
|
||
|
|
{
|
||
|
|
if (destinationMarkerInstance != null)
|
||
|
|
{
|
||
|
|
destinationMarkerInstance.transform.position = position + Vector3.up * 0.05f;
|
||
|
|
destinationMarkerInstance.SetActive(true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void HideDestinationMarker()
|
||
|
|
{
|
||
|
|
if (destinationMarkerInstance != null)
|
||
|
|
{
|
||
|
|
destinationMarkerInstance.SetActive(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void UpdateAnimation()
|
||
|
|
{
|
||
|
|
if (animator == null) return;
|
||
|
|
|
||
|
|
float currentSpeed = agent.velocity.magnitude;
|
||
|
|
bool isMoving = agent.velocity.sqrMagnitude > 0.05f && !agent.isStopped && agent.hasPath;
|
||
|
|
|
||
|
|
animator.SetFloat(speedHash, currentSpeed);
|
||
|
|
animator.SetBool(isMovingHash, isMoving);
|
||
|
|
animator.SetBool(isCarryingHash, IsCarryingItem);
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Editor Gizmos
|
||
|
|
|
||
|
|
private void OnDrawGizmos()
|
||
|
|
{
|
||
|
|
// Draw Clicked Point in bright RED
|
||
|
|
if (lastClickedDestination != Vector3.zero)
|
||
|
|
{
|
||
|
|
Gizmos.color = Color.red;
|
||
|
|
Gizmos.DrawSphere(lastClickedDestination, 0.25f);
|
||
|
|
Gizmos.DrawLine(transform.position, lastClickedDestination);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Draw NavMesh Agent Destination in bright GREEN
|
||
|
|
if (agent != null && (hasActiveMoveCommand || agent.hasPath))
|
||
|
|
{
|
||
|
|
Gizmos.color = Color.green;
|
||
|
|
Gizmos.DrawWireSphere(agent.destination, agent.stoppingDistance);
|
||
|
|
Gizmos.DrawLine(transform.position, agent.destination);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnDrawGizmosSelected()
|
||
|
|
{
|
||
|
|
if (agent != null && agent.hasPath)
|
||
|
|
{
|
||
|
|
Gizmos.color = Color.green;
|
||
|
|
Vector3 prev = transform.position;
|
||
|
|
foreach (Vector3 corner in agent.path.corners)
|
||
|
|
{
|
||
|
|
Gizmos.DrawLine(prev, corner);
|
||
|
|
Gizmos.DrawSphere(corner, 0.15f);
|
||
|
|
prev = corner;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (pendingTarget != null && pendingTarget.InteractionTransform != null)
|
||
|
|
{
|
||
|
|
Gizmos.color = Color.cyan;
|
||
|
|
Gizmos.DrawLine(transform.position, pendingTarget.InteractionTransform.position);
|
||
|
|
Gizmos.DrawWireSphere(pendingTarget.InteractionTransform.position, pendingTarget.InteractionDistance);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (carrySocket != null)
|
||
|
|
{
|
||
|
|
Gizmos.color = Color.magenta;
|
||
|
|
Gizmos.DrawWireCube(carrySocket.position, Vector3.one * 0.2f);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
}
|
||
|
|
}
|