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,162 @@
using NightclubArcadia.Player;
using NightclubArcadia.Player.Math;
using UnityEngine;
namespace NightclubArcadia.Cinematics
{
/// <summary>
/// Converts mouse drag-pan into CameraRigAnchor manual offset with recenter rules.
/// </summary>
public sealed class CameraPanInput : MonoBehaviour
{
[SerializeField] Camera brainCamera;
[SerializeField] CameraRigAnchor anchor;
[SerializeField] CameraStateDirector stateDirector;
[SerializeField] PlayerPointerInput pointerInput;
[SerializeField] float panMetresPerPixel = 0.02f;
[SerializeField] float maxPanDistance = 12f;
[SerializeField] float recenterDelay = 2.5f;
[SerializeField] float recenterSmoothTime = 0.8f;
[SerializeField] float movementRecenterDelay = 0.4f;
float releaseTimer = float.MaxValue;
Vector3 recenterVelocity;
Vector3 camRight;
Vector3 camForward;
bool basisInitialized;
public bool IsManualPanActive => pointerInput != null && pointerInput.IsPanning || releaseTimer < recenterDelay;
void Awake()
{
if (anchor == null)
{
anchor = GetComponent<CameraRigAnchor>();
}
if (stateDirector == null)
{
stateDirector = GetComponent<CameraStateDirector>();
}
if (brainCamera == null)
{
brainCamera = Camera.main;
}
}
void Start()
{
if (pointerInput == null)
{
var player = GameObject.FindGameObjectWithTag("Player");
if (player != null)
{
pointerInput = player.GetComponent<PlayerPointerInput>();
}
}
if (pointerInput != null)
{
pointerInput.PanDelta += OnPanDelta;
pointerInput.PanEnded += OnPanEnded;
}
}
void OnDestroy()
{
if (pointerInput != null)
{
pointerInput.PanDelta -= OnPanDelta;
pointerInput.PanEnded -= OnPanEnded;
}
}
void Update()
{
if (anchor == null || stateDirector == null)
{
return;
}
if (stateDirector.AllowsManualPan && pointerInput != null && pointerInput.IsPanning)
{
releaseTimer = 0f;
}
else if (releaseTimer < recenterDelay)
{
releaseTimer += Time.deltaTime;
}
if (ShouldShortenRecenter())
{
releaseTimer = Mathf.Max(releaseTimer, recenterDelay - movementRecenterDelay);
}
if (!stateDirector.AllowsManualPan)
{
return;
}
if (releaseTimer >= recenterDelay && (pointerInput == null || !pointerInput.IsPanning))
{
anchor.ManualPanOffset = Vector3.SmoothDamp(
anchor.ManualPanOffset,
Vector3.zero,
ref recenterVelocity,
recenterSmoothTime);
}
anchor.ClampManualPan(maxPanDistance);
}
void OnPanDelta(Vector2 delta)
{
if (stateDirector == null || !stateDirector.AllowsManualPan || anchor == null)
{
return;
}
EnsureCameraBasis();
anchor.Nudge(MotionInputMath.ScreenDeltaToWorldPan(delta, camRight, camForward, panMetresPerPixel));
anchor.ClampManualPan(maxPanDistance);
}
void OnPanEnded()
{
releaseTimer = 0f;
}
bool ShouldShortenRecenter()
{
var player = GameObject.FindGameObjectWithTag("Player");
if (player == null)
{
return false;
}
var arbiter = player.GetComponent<PlayerMotionArbiter>();
return arbiter != null && arbiter.ActiveSource != MotionSource.None;
}
void EnsureCameraBasis()
{
if (brainCamera == null)
{
brainCamera = Camera.main;
}
if (brainCamera == null)
{
return;
}
if (!basisInitialized)
{
camRight = brainCamera.transform.right;
camForward = brainCamera.transform.forward;
basisInitialized = true;
}
}
}
}