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,8 @@
fileFormatVersion: 2
guid: 1647a101742545fe8af6187e3a743858
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 97642050992484049b6c021481f2baf2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
using UnityEngine;
namespace NightclubArcadia.Interaction
{
/// <summary>
/// Adapts NPCStandIn to the click-to-interact pipeline without modifying
/// the existing proximity + keypress path.
/// </summary>
public sealed class ClickInteractableBridge : MonoBehaviour, IClickInteractable
{
[SerializeField] NPCStandIn npc;
public Transform ApproachPoint => transform;
public float ApproachRadius => npc != null ? Mathf.Max(0.15f, npc.interactionRange - 0.5f) : 0.15f;
void Awake()
{
if (npc == null)
{
npc = GetComponent<NPCStandIn>();
}
}
public void OnApproached()
{
npc?.Interact();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5257f07d188de44eb9a04ba4d3a65fec
@@ -0,0 +1,11 @@
using UnityEngine;
namespace NightclubArcadia.Interaction
{
public interface IClickInteractable
{
Transform ApproachPoint { get; }
float ApproachRadius { get; }
void OnApproached();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4812c7fb9cf2d41c28966640827dd0c8
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 381786de30b3e49e7a693028cc2127f5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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