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:
2026-08-25 19:03:18 +02:00
co-authored by Claude Opus 5
parent 1ef1720bae
commit 932cd0582f
43 changed files with 1625 additions and 8 deletions
@@ -0,0 +1,234 @@
using System;
using NightclubArcadia.Interaction;
using NightclubArcadia.Player.Math;
using StarterAssets;
using UnityEngine;
using UnityEngine.AI;
namespace NightclubArcadia.Player
{
/// <summary>
/// NavMeshAgent in oracle mode: computes desired move input for the arbiter
/// without moving the CharacterController directly.
/// </summary>
[DefaultExecutionOrder(-60)]
[RequireComponent(typeof(NavMeshAgent))]
public sealed class ClickToMoveController : MonoBehaviour
{
const float SampleRadius = 1.5f;
const float StallWindow = 0.4f;
const float StallDistance = 0.05f;
[SerializeField] Camera brainCamera;
NavMeshAgent agent;
ThirdPersonController locomotion;
IClickInteractable pendingInteractable;
bool forceRunForPath;
[SerializeField] float runDistanceThreshold = 6f;
Vector3 lastPosition;
float stallTimer;
bool hasPath;
public bool HasPath => hasPath;
public Vector3 Destination { get; private set; }
public Vector2 DesiredMoveInput { get; private set; }
public float RemainingDistance => agent != null ? agent.remainingDistance : 0f;
public bool WantsSprint => hasPath && (forceRunForPath || agent.remainingDistance > runDistanceThreshold);
public event Action<bool> PathFinished;
public event Action RunRequested;
void Awake()
{
agent = GetComponent<NavMeshAgent>();
locomotion = GetComponent<ThirdPersonController>();
agent.updatePosition = false;
agent.updateRotation = false;
agent.updateUpAxis = false;
agent.angularSpeed = 0f;
agent.acceleration = 1000f;
agent.autoBraking = true;
agent.stoppingDistance = 0.15f;
agent.radius = 0.3f;
agent.height = 1.8f;
if (brainCamera == null)
{
brainCamera = Camera.main;
}
SyncAgentSpeed(false);
}
void Update()
{
if (!hasPath)
{
DesiredMoveInput = Vector2.zero;
return;
}
SyncAgentSpeed(forceRunForPath || agent.remainingDistance > runDistanceThreshold);
if (brainCamera == null)
{
brainCamera = Camera.main;
}
var cameraYaw = brainCamera != null ? brainCamera.transform.eulerAngles.y : 0f;
DesiredMoveInput = MotionInputMath.WorldToCameraRelativeInput(
agent.desiredVelocity,
agent.speed,
cameraYaw);
CheckStall();
CheckArrival();
}
void LateUpdate()
{
if (agent != null)
{
agent.nextPosition = transform.position;
}
}
public void RequestRun()
{
forceRunForPath = true;
RunRequested?.Invoke();
}
public bool SetDestination(Vector3 worldPoint)
{
pendingInteractable = null;
if (!TrySampleNavMesh(worldPoint, out var hit))
{
return false;
}
agent.stoppingDistance = 0.15f;
agent.isStopped = false;
agent.SetDestination(hit.position);
Destination = hit.position;
hasPath = true;
ResetStallTracking();
return true;
}
public bool SetInteractionTarget(IClickInteractable interactable)
{
if (interactable == null || interactable.ApproachPoint == null)
{
return false;
}
pendingInteractable = interactable;
var approach = interactable.ApproachPoint.position;
if (!TrySampleNavMesh(approach, out var hit))
{
pendingInteractable = null;
return false;
}
agent.stoppingDistance = Mathf.Max(0.15f, interactable.ApproachRadius);
agent.isStopped = false;
agent.SetDestination(hit.position);
Destination = hit.position;
hasPath = true;
ResetStallTracking();
return true;
}
public void Cancel()
{
if (agent != null)
{
agent.ResetPath();
agent.isStopped = true;
}
hasPath = false;
forceRunForPath = false;
pendingInteractable = null;
DesiredMoveInput = Vector2.zero;
ResetStallTracking();
}
bool TrySampleNavMesh(Vector3 point, out NavMeshHit hit)
{
return NavMesh.SamplePosition(point, out hit, SampleRadius, NavMesh.AllAreas);
}
void CheckArrival()
{
if (!hasPath || agent.pathPending)
{
return;
}
if (agent.remainingDistance > agent.stoppingDistance)
{
return;
}
var success = agent.pathStatus != NavMeshPathStatus.PathInvalid;
hasPath = false;
agent.ResetPath();
var interactable = pendingInteractable;
pendingInteractable = null;
PathFinished?.Invoke(success);
if (success && interactable != null)
{
interactable.OnApproached();
}
}
void CheckStall()
{
var displacement = Vector3.Distance(
new Vector3(transform.position.x, 0f, transform.position.z),
new Vector3(lastPosition.x, 0f, lastPosition.z));
if (displacement < StallDistance)
{
stallTimer += Time.deltaTime;
}
else
{
stallTimer = 0f;
lastPosition = transform.position;
}
if (stallTimer >= StallWindow && agent.remainingDistance > agent.stoppingDistance)
{
Cancel();
PathFinished?.Invoke(false);
}
}
void ResetStallTracking()
{
lastPosition = transform.position;
stallTimer = 0f;
}
void SyncAgentSpeed(bool sprint)
{
if (locomotion == null)
{
return;
}
agent.speed = sprint ? locomotion.SprintSpeed : locomotion.MoveSpeed;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c2762b30721644611b9672bd4781cf15
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c32faf711a4044ed88ff1be2790915f7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,42 @@
using UnityEngine;
namespace NightclubArcadia.Player.Math
{
/// <summary>
/// Pure math helpers shared by click-to-move and camera pan.
/// </summary>
public static class MotionInputMath
{
/// <summary>
/// Converts a NavMeshAgent desired velocity into the camera-relative
/// Vector2 that ThirdPersonController expects on StarterAssetsInputs.move.
/// </summary>
public static Vector2 WorldToCameraRelativeInput(Vector3 worldVelocity, float agentSpeed, float cameraYaw)
{
if (worldVelocity.sqrMagnitude < 0.0001f || agentSpeed <= 0f)
{
return Vector2.zero;
}
var horizontal = new Vector3(worldVelocity.x, 0f, worldVelocity.z);
var magnitude = horizontal.magnitude;
var fraction = Mathf.Clamp01(magnitude / agentSpeed);
var local = Quaternion.Euler(0f, -cameraYaw, 0f) * horizontal.normalized;
return new Vector2(local.x, local.z) * fraction;
}
/// <summary>
/// Converts screen-space pointer delta into a world-plane pan offset.
/// Drag direction is inverted so the world follows the cursor.
/// </summary>
public static Vector3 ScreenDeltaToWorldPan(
Vector2 pixelDelta,
Vector3 camRight,
Vector3 camForward,
float metresPerPixel)
{
var forward = Vector3.ProjectOnPlane(camForward, Vector3.up).normalized;
return (-pixelDelta.x * camRight + -pixelDelta.y * forward) * metresPerPixel;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9af00d68e15e24c02811500cdf0dd2d4
@@ -0,0 +1,14 @@
{
"name": "NightclubArcadia.Locomotion.Math",
"rootNamespace": "NightclubArcadia.Player.Math",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 12491a5edc87346d28ffc48f9ab6562c
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,114 @@
using NightclubArcadia.Interaction;
using NightclubArcadia.UI;
using StarterAssets;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace NightclubArcadia.Player
{
public enum MotionSource
{
None,
Direct,
Navigation
}
/// <summary>
/// Sole writer of StarterAssetsInputs move/sprint. Picks one input source
/// per frame: direct keyboard/gamepad or click-to-move navigation.
/// </summary>
[DefaultExecutionOrder(-50)]
public sealed class PlayerMotionArbiter : MonoBehaviour
{
[SerializeField] float directInputCancelThreshold = 0.15f;
[SerializeField] float runDistanceThreshold = 6f;
StarterAssetsInputs playerInputs;
PlayerControlLock controlLock;
ClickToMoveController clickToMove;
#if ENABLE_INPUT_SYSTEM
PlayerInput playerInput;
#endif
public MotionSource ActiveSource { get; private set; }
void Awake()
{
playerInputs = GetComponent<StarterAssetsInputs>();
controlLock = GetComponent<PlayerControlLock>();
clickToMove = GetComponent<ClickToMoveController>();
#if ENABLE_INPUT_SYSTEM
playerInput = GetComponent<PlayerInput>();
#endif
}
public void ResetState()
{
ActiveSource = MotionSource.None;
clickToMove?.Cancel();
WriteInput(Vector2.zero, false);
}
void Update()
{
if (controlLock != null && controlLock.IsLocked)
{
clickToMove?.Cancel();
ActiveSource = MotionSource.None;
WriteInput(Vector2.zero, false);
return;
}
#if ENABLE_INPUT_SYSTEM
var raw = playerInput.actions["Move"].ReadValue<Vector2>();
#else
var raw = Vector2.zero;
#endif
if (raw.sqrMagnitude > directInputCancelThreshold * directInputCancelThreshold)
{
clickToMove?.Cancel();
ActiveSource = MotionSource.Direct;
WriteInput(raw, ReadDirectSprint());
return;
}
if (clickToMove != null && clickToMove.HasPath)
{
ActiveSource = MotionSource.Navigation;
WriteInput(clickToMove.DesiredMoveInput, ShouldNavigationSprint());
return;
}
ActiveSource = MotionSource.None;
WriteInput(Vector2.zero, false);
}
bool ReadDirectSprint()
{
#if ENABLE_INPUT_SYSTEM
return playerInput.actions["Sprint"].IsPressed();
#else
return false;
#endif
}
bool ShouldNavigationSprint()
{
return clickToMove != null && clickToMove.WantsSprint;
}
void WriteInput(Vector2 move, bool sprint)
{
if (playerInputs == null)
{
return;
}
playerInputs.MoveInput(move);
playerInputs.SprintInput(sprint);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91bf24256c6694185b4247a736593727
@@ -0,0 +1,61 @@
using System;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace NightclubArcadia.Player
{
/// <summary>
/// Receives pointer and camera-pan SendMessages from PlayerInput and
/// exposes them as C# events for navigation and camera systems.
/// </summary>
[DefaultExecutionOrder(-100)]
public sealed class PlayerPointerInput : MonoBehaviour
{
public Vector2 ScreenPosition { get; private set; }
public bool IsPanning { get; private set; }
public event Action Clicked;
public event Action PanStarted;
public event Action<Vector2> PanDelta;
public event Action PanEnded;
#if ENABLE_INPUT_SYSTEM
void OnPoint(InputValue value)
{
ScreenPosition = value.Get<Vector2>();
}
void OnClick(InputValue value)
{
if (value.isPressed)
{
Clicked?.Invoke();
}
}
void OnCameraPan(InputValue value)
{
if (value.isPressed && !IsPanning)
{
IsPanning = true;
PanStarted?.Invoke();
}
else if (!value.isPressed && IsPanning)
{
IsPanning = false;
PanEnded?.Invoke();
}
}
void OnPointerDelta(InputValue value)
{
if (IsPanning)
{
PanDelta?.Invoke(value.Get<Vector2>());
}
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff77faf7e79154095ad57ad50df8c605
@@ -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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f62460e35fda4829bd6ffeffe633a7e