Files
nightclub-arcadia/NightclubArcadia/Assets/Scripts/Player/PointerNavigationInput.cs
T
lennartandClaude Opus 5 932cd0582f 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]>
2026-08-25 19:03:18 +02:00

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;
}
}
}