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,81 @@
using Unity.Cinemachine;
using UnityEngine;
namespace NightclubArcadia.Cinematics
{
/// <summary>
/// Raises a reveal camera priority while the player is inside the volume.
/// </summary>
[RequireComponent(typeof(BoxCollider))]
public sealed class CameraFramingVolume : MonoBehaviour
{
[SerializeField] CinemachineCamera revealCamera;
[SerializeField] int activePriority = 20;
[SerializeField] float minDwell = 0.5f;
CameraStateDirector stateDirector;
int defaultPriority;
int occupants;
float exitTimer;
void Awake()
{
var box = GetComponent<BoxCollider>();
box.isTrigger = true;
if (revealCamera != null)
{
defaultPriority = revealCamera.Priority.Value;
}
stateDirector = FindFirstObjectByType<CameraStateDirector>();
}
void OnTriggerEnter(Collider other)
{
if (!other.CompareTag("Player") || revealCamera == null)
{
return;
}
occupants++;
exitTimer = 0f;
revealCamera.Priority.Value = activePriority;
stateDirector?.RegisterReveal();
}
void OnTriggerExit(Collider other)
{
if (!other.CompareTag("Player") || revealCamera == null)
{
return;
}
occupants = Mathf.Max(0, occupants - 1);
if (occupants == 0)
{
exitTimer = minDwell;
}
}
void Update()
{
if (occupants > 0 || revealCamera == null)
{
return;
}
if (exitTimer > 0f)
{
exitTimer -= Time.deltaTime;
return;
}
if (revealCamera.Priority.Value != defaultPriority)
{
revealCamera.Priority.Value = defaultPriority;
stateDirector?.UnregisterReveal();
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7cccc93b78eb84019b4524d65305612a
@@ -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;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f9b06ade88f2643a3b9f52a80e06081d
@@ -0,0 +1,31 @@
using UnityEngine;
namespace NightclubArcadia.Cinematics
{
/// <summary>
/// Marker on NPCs and props that the POI director may bias toward.
/// </summary>
public sealed class CameraPointOfInterest : MonoBehaviour
{
[SerializeField] float weight = 1f;
[SerializeField] float triggerRadius = 8f;
[SerializeField] float maxBias = 2.5f;
[SerializeField] float extraDistance = 1.5f;
public float Weight => weight;
public float TriggerRadius => triggerRadius;
public float MaxBias => maxBias;
public float ExtraDistance => extraDistance;
public Transform Target => transform;
void OnEnable()
{
CameraPointOfInterestDirector.Register(this);
}
void OnDisable()
{
CameraPointOfInterestDirector.Unregister(this);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7862f80331f384d28b27600075971a77
@@ -0,0 +1,161 @@
using System.Collections.Generic;
using UnityEngine;
namespace NightclubArcadia.Cinematics
{
/// <summary>
/// Scores registered POIs and writes bias onto the camera anchor.
/// </summary>
public sealed class CameraPointOfInterestDirector : MonoBehaviour
{
static readonly List<CameraPointOfInterest> Registered = new List<CameraPointOfInterest>();
[SerializeField] CameraRigAnchor anchor;
[SerializeField] CameraStateDirector stateDirector;
[SerializeField] float poiSmoothTime = 1f;
[SerializeField] float poiReleaseTime = 1.4f;
[SerializeField] float maxHeadingAngle = 100f;
Vector3 poiVelocity;
float distanceVelocity;
CameraPointOfInterest activePoi;
public bool HasActivePoi => activePoi != null;
public static void Register(CameraPointOfInterest poi)
{
if (poi != null && !Registered.Contains(poi))
{
Registered.Add(poi);
}
}
public static void Unregister(CameraPointOfInterest poi)
{
Registered.Remove(poi);
}
void Awake()
{
if (anchor == null)
{
anchor = GetComponent<CameraRigAnchor>();
}
if (stateDirector == null)
{
stateDirector = GetComponent<CameraStateDirector>();
}
}
void LateUpdate()
{
if (anchor == null || stateDirector == null)
{
return;
}
var follow = anchor.FollowTarget;
if (follow == null)
{
return;
}
if (!stateDirector.AllowsPointOfInterest)
{
return;
}
if (stateDirector.Current == CameraState.ManualPan)
{
return;
}
activePoi = FindBestPoi(follow);
if (activePoi == null)
{
ReleasePoi();
return;
}
var toPoi = activePoi.Target.position - follow.position;
toPoi.y = 0f;
var targetBias = Vector3.ClampMagnitude(toPoi, activePoi.MaxBias);
anchor.PoiBias = Vector3.SmoothDamp(
anchor.PoiBias,
targetBias,
ref poiVelocity,
poiSmoothTime);
anchor.DistanceBias = Mathf.SmoothDamp(
anchor.DistanceBias,
activePoi.ExtraDistance,
ref distanceVelocity,
poiSmoothTime);
}
CameraPointOfInterest FindBestPoi(Transform follow)
{
CameraPointOfInterest best = null;
var bestScore = 0f;
var forward = follow.forward;
foreach (var poi in Registered)
{
if (poi == null || !poi.isActiveAndEnabled)
{
continue;
}
var offset = poi.Target.position - follow.position;
offset.y = 0f;
var distance = offset.magnitude;
if (distance > poi.TriggerRadius || distance <= 0.01f)
{
continue;
}
var angle = Vector3.Angle(forward, offset);
if (angle > maxHeadingAngle)
{
continue;
}
var score = poi.Weight * (1f - distance / poi.TriggerRadius);
if (score > bestScore)
{
bestScore = score;
best = poi;
}
}
return best;
}
void ReleasePoi()
{
if (anchor == null)
{
return;
}
anchor.PoiBias = Vector3.SmoothDamp(
anchor.PoiBias,
Vector3.zero,
ref poiVelocity,
poiReleaseTime);
anchor.DistanceBias = Mathf.SmoothDamp(
anchor.DistanceBias,
0f,
ref distanceVelocity,
poiReleaseTime);
if (anchor.PoiBias.sqrMagnitude < 0.0001f && Mathf.Abs(anchor.DistanceBias) < 0.01f)
{
activePoi = null;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3343417e1de440f095b73e55c6977b6
@@ -0,0 +1,85 @@
using Unity.Cinemachine;
using UnityEngine;
namespace NightclubArcadia.Cinematics
{
/// <summary>
/// Proxy transform the follow camera tracks. Combines player follow,
/// POI bias, manual pan offset, and distance bias.
/// </summary>
public sealed class CameraRigAnchor : MonoBehaviour
{
[SerializeField] Transform followTarget;
[SerializeField] CinemachinePositionComposer composer;
[SerializeField] float followSmoothTime = 0.12f;
[SerializeField] float distanceSmoothTime = 0.8f;
float defaultCameraDistance = 10f;
float distanceVelocity;
Vector3 followVelocity;
public Vector3 PoiBias { get; set; }
public Vector3 ManualPanOffset { get; set; }
public float DistanceBias { get; set; }
public Transform FollowTarget => followTarget;
void Start()
{
if (composer == null)
{
composer = FindFirstObjectByType<CinemachinePositionComposer>();
}
if (composer != null)
{
defaultCameraDistance = composer.CameraDistance;
}
}
void LateUpdate()
{
if (followTarget == null)
{
return;
}
var targetPosition = followTarget.position + PoiBias + ManualPanOffset;
transform.position = Vector3.SmoothDamp(
transform.position,
targetPosition,
ref followVelocity,
followSmoothTime);
if (composer != null)
{
var targetDistance = defaultCameraDistance + DistanceBias;
composer.CameraDistance = Mathf.SmoothDamp(
composer.CameraDistance,
targetDistance,
ref distanceVelocity,
distanceSmoothTime);
}
}
public void Nudge(Vector3 worldDelta)
{
ManualPanOffset += worldDelta;
}
public void ClampManualPan(float maxDistance)
{
if (followTarget == null)
{
return;
}
var offset = ManualPanOffset;
offset.y = 0f;
if (offset.magnitude > maxDistance)
{
ManualPanOffset = offset.normalized * maxDistance;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 16d6717a3703a4bb3a8c3bb0bb0392de
@@ -0,0 +1,135 @@
using NightclubArcadia.UI;
using UnityEngine;
namespace NightclubArcadia.Cinematics
{
public enum CameraState
{
Follow,
ManualPan,
PointOfInterest,
Reveal,
Dialogue,
Menu
}
/// <summary>
/// Resolves which camera subsystem may write each frame.
/// </summary>
public sealed class CameraStateDirector : MonoBehaviour
{
[SerializeField] CameraRigAnchor anchor;
[SerializeField] float dialogueRecenterTime = 0.5f;
int activeRevealCount;
float dialogueRecenterTimer;
bool wasDialogueActive;
bool wasMenuOpen;
public CameraState Current { get; private set; } = CameraState.Follow;
public bool AllowsManualPan =>
Current != CameraState.Dialogue
&& Current != CameraState.Menu
&& Current != CameraState.Reveal;
public bool AllowsPointOfInterest =>
Current == CameraState.Follow || Current == CameraState.PointOfInterest;
void Awake()
{
if (anchor == null)
{
anchor = GetComponent<CameraRigAnchor>();
}
}
void Start()
{
if (UIStateController.Instance != null)
{
UIStateController.Instance.StateChanged += OnUiStateChanged;
}
ResolveState(false);
}
void OnDestroy()
{
if (UIStateController.Instance != null)
{
UIStateController.Instance.StateChanged -= OnUiStateChanged;
}
}
void Update()
{
ResolveState(true);
if (Current == CameraState.Dialogue && anchor != null)
{
dialogueRecenterTimer += Time.deltaTime;
var t = Mathf.Clamp01(dialogueRecenterTimer / dialogueRecenterTime);
anchor.ManualPanOffset = Vector3.Lerp(anchor.ManualPanOffset, Vector3.zero, t);
}
}
void OnUiStateChanged() => ResolveState(true);
public void RegisterReveal() => activeRevealCount++;
public void UnregisterReveal() => activeRevealCount = Mathf.Max(0, activeRevealCount - 1);
void ResolveState(bool allowManualPanPromotion)
{
var ui = UIStateController.Instance;
var menuOpen = ui != null && ui.IsMenuOpen;
var dialogueActive = ui != null && ui.IsDialogueActive;
if (menuOpen)
{
Current = CameraState.Menu;
}
else if (dialogueActive)
{
if (!wasDialogueActive)
{
dialogueRecenterTimer = 0f;
}
Current = CameraState.Dialogue;
}
else if (activeRevealCount > 0)
{
Current = CameraState.Reveal;
}
else if (allowManualPanPromotion && IsManualPanActive())
{
Current = CameraState.ManualPan;
}
else if (HasActivePointOfInterest())
{
Current = CameraState.PointOfInterest;
}
else
{
Current = CameraState.Follow;
}
wasDialogueActive = dialogueActive;
wasMenuOpen = menuOpen;
}
bool IsManualPanActive()
{
var pan = GetComponent<CameraPanInput>();
return pan != null && pan.IsManualPanActive;
}
bool HasActivePointOfInterest()
{
var director = GetComponent<CameraPointOfInterestDirector>();
return director != null && director.HasActivePoi;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ca0160e4d996040238dab18725a2e844
@@ -0,0 +1,72 @@
using NightclubArcadia.UI;
using Unity.Cinemachine;
using UnityEngine;
namespace NightclubArcadia.Cinematics
{
/// <summary>
/// Shifts CinemachinePositionComposer.Composition.ScreenPosition so the
/// player is centred in the screen space left of the dialogue panel.
/// </summary>
public sealed class DialogueCameraFraming : MonoBehaviour
{
[SerializeField] CinemachinePositionComposer composer;
[SerializeField] float dialogueScreenX = -0.17f;
[SerializeField] bool suppressDeadZoneXInDialogue = true;
float defaultDeadZoneX = 0.2f;
void Start()
{
if (composer == null)
{
composer = FindFirstObjectByType<CinemachinePositionComposer>();
}
if (composer != null)
{
defaultDeadZoneX = composer.Composition.DeadZone.Size.x;
}
if (UIStateController.Instance != null)
{
UIStateController.Instance.StateChanged += OnStateChanged;
}
ApplyFraming();
}
void OnDestroy()
{
if (UIStateController.Instance != null)
{
UIStateController.Instance.StateChanged -= OnStateChanged;
}
}
void OnStateChanged() => ApplyFraming();
void ApplyFraming()
{
if (composer == null)
{
return;
}
var active = UIStateController.Instance != null && UIStateController.Instance.IsDialogueActive;
var target = active ? dialogueScreenX : 0f;
var composition = composer.Composition;
composition.ScreenPosition = new Vector2(target, composition.ScreenPosition.y);
if (suppressDeadZoneXInDialogue)
{
var size = composition.DeadZone.Size;
size.x = active ? 0f : defaultDeadZoneX;
composition.DeadZone.Size = size;
}
composer.Composition = composition;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 56142a5f8795401498df8e01dc74b258
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: