add camera rig, click-to-move locomotion, and click interaction
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]>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user