Camera: Cinemachine-based rig with an anchor, a state director, point-of- interest framing, pan input, and dialogue framing volumes. Player: click-to-move over the NavMesh, a pointer input layer, and a motion arbiter that reconciles direct stick input with navigation-driven movement. The pure math is isolated in NightclubArcadia.Locomotion.Math with an EditMode suite, so the arbitration rules are testable without a scene. Interaction: IClickInteractable plus a bridge that routes pointer clicks to it. Supporting project settings: adds the Cinemachine package, the Interactable and Ground tags the raycasts depend on, and NavMesh agent dimensions matched to the player capsule (radius 0.3, height 1.8, climb 0.25). Co-Authored-By: Claude Opus 5 <[email protected]>
204 lines
5.4 KiB
C#
204 lines
5.4 KiB
C#
using System;
|
|
using NightclubArcadia.Interaction;
|
|
using NightclubArcadia.UI;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
|
|
namespace NightclubArcadia.Player
|
|
{
|
|
/// <summary>
|
|
/// Converts pointer clicks into click-to-move destinations or interactable
|
|
/// approach targets.
|
|
/// </summary>
|
|
public sealed class PointerNavigationInput : MonoBehaviour
|
|
{
|
|
const float DoubleClickWindow = 0.35f;
|
|
const float DoubleClickPixelThreshold = 40f;
|
|
|
|
[SerializeField] Camera brainCamera;
|
|
[SerializeField] LayerMask clickMask = ~0;
|
|
[SerializeField] PlayerPointerInput pointerInput;
|
|
[SerializeField] ClickToMoveController clickToMove;
|
|
[SerializeField] PlayerMotionArbiter motionArbiter;
|
|
|
|
float lastClickTime = -1f;
|
|
Vector2 lastClickPosition;
|
|
bool pendingClick;
|
|
Vector2 pendingClickPosition;
|
|
|
|
void Awake()
|
|
{
|
|
if (pointerInput == null)
|
|
{
|
|
pointerInput = GetComponent<PlayerPointerInput>();
|
|
}
|
|
|
|
if (clickToMove == null)
|
|
{
|
|
clickToMove = GetComponent<ClickToMoveController>();
|
|
}
|
|
|
|
if (motionArbiter == null)
|
|
{
|
|
motionArbiter = GetComponent<PlayerMotionArbiter>();
|
|
}
|
|
|
|
if (brainCamera == null)
|
|
{
|
|
brainCamera = Camera.main;
|
|
}
|
|
|
|
if (clickMask.value == ~0)
|
|
{
|
|
clickMask = LayerMask.GetMask("Default", "Ground", "Interactable");
|
|
}
|
|
}
|
|
|
|
void OnEnable()
|
|
{
|
|
if (pointerInput != null)
|
|
{
|
|
pointerInput.Clicked += OnClicked;
|
|
}
|
|
}
|
|
|
|
void OnDisable()
|
|
{
|
|
if (pointerInput != null)
|
|
{
|
|
pointerInput.Clicked -= OnClicked;
|
|
}
|
|
|
|
pendingClick = false;
|
|
}
|
|
|
|
// Input System callbacks fire during event processing, where
|
|
// IsPointerOverGameObject() is invalid (queries last frame's UI).
|
|
// Queue the click and resolve it in Update instead.
|
|
void OnClicked()
|
|
{
|
|
if (pointerInput == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
pendingClick = true;
|
|
pendingClickPosition = pointerInput.ScreenPosition;
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
if (!pendingClick)
|
|
{
|
|
return;
|
|
}
|
|
|
|
pendingClick = false;
|
|
ProcessClick(pendingClickPosition);
|
|
}
|
|
|
|
void ProcessClick(Vector2 screenPos)
|
|
{
|
|
if (!CanNavigate())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (brainCamera == null)
|
|
{
|
|
brainCamera = Camera.main;
|
|
}
|
|
|
|
if (brainCamera == null || clickToMove == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var ray = brainCamera.ScreenPointToRay(screenPos);
|
|
var hits = Physics.RaycastAll(ray, 200f, clickMask, QueryTriggerInteraction.Collide);
|
|
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
|
|
|
var isDoubleClick = IsDoubleClick(screenPos);
|
|
lastClickTime = Time.unscaledTime;
|
|
lastClickPosition = screenPos;
|
|
|
|
foreach (var hit in hits)
|
|
{
|
|
var interactable = FindInteractable(hit.collider);
|
|
if (interactable != null)
|
|
{
|
|
if (isDoubleClick)
|
|
{
|
|
clickToMove.RequestRun();
|
|
}
|
|
|
|
if (clickToMove.SetInteractionTarget(interactable))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var hit in hits)
|
|
{
|
|
if (isDoubleClick)
|
|
{
|
|
clickToMove.RequestRun();
|
|
}
|
|
|
|
if (clickToMove.SetDestination(hit.point))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool CanNavigate()
|
|
{
|
|
if (GetComponent<PlayerControlLock>() is { IsLocked: true })
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (UIStateController.Instance == null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return !UIStateController.Instance.IsDialogueActive
|
|
&& !UIStateController.Instance.IsMenuOpen;
|
|
}
|
|
|
|
bool IsDoubleClick(Vector2 screenPos)
|
|
{
|
|
if (lastClickTime < 0f)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var withinTime = Time.unscaledTime - lastClickTime <= DoubleClickWindow;
|
|
var withinDistance = Vector2.Distance(screenPos, lastClickPosition) <= DoubleClickPixelThreshold;
|
|
return withinTime && withinDistance;
|
|
}
|
|
|
|
static IClickInteractable FindInteractable(Collider collider)
|
|
{
|
|
var behaviours = collider.GetComponentsInParent<MonoBehaviour>();
|
|
foreach (var behaviour in behaviours)
|
|
{
|
|
if (behaviour is IClickInteractable interactable)
|
|
{
|
|
return interactable;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|