diff --git a/NightclubArcadia/Assets/Scripts/Camera.meta b/NightclubArcadia/Assets/Scripts/Camera.meta
new file mode 100644
index 0000000..202508f
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1647a101742545fe8af6187e3a743858
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraFramingVolume.cs b/NightclubArcadia/Assets/Scripts/Camera/CameraFramingVolume.cs
new file mode 100644
index 0000000..4c0dce9
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraFramingVolume.cs
@@ -0,0 +1,81 @@
+using Unity.Cinemachine;
+using UnityEngine;
+
+namespace NightclubArcadia.Cinematics
+{
+ ///
+ /// Raises a reveal camera priority while the player is inside the volume.
+ ///
+ [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();
+ box.isTrigger = true;
+
+ if (revealCamera != null)
+ {
+ defaultPriority = revealCamera.Priority.Value;
+ }
+
+ stateDirector = FindFirstObjectByType();
+ }
+
+ 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();
+ }
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraFramingVolume.cs.meta b/NightclubArcadia/Assets/Scripts/Camera/CameraFramingVolume.cs.meta
new file mode 100644
index 0000000..e39e1c7
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraFramingVolume.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7cccc93b78eb84019b4524d65305612a
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraPanInput.cs b/NightclubArcadia/Assets/Scripts/Camera/CameraPanInput.cs
new file mode 100644
index 0000000..3f042b3
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraPanInput.cs
@@ -0,0 +1,162 @@
+using NightclubArcadia.Player;
+using NightclubArcadia.Player.Math;
+using UnityEngine;
+
+namespace NightclubArcadia.Cinematics
+{
+ ///
+ /// Converts mouse drag-pan into CameraRigAnchor manual offset with recenter rules.
+ ///
+ 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();
+ }
+
+ if (stateDirector == null)
+ {
+ stateDirector = GetComponent();
+ }
+
+ if (brainCamera == null)
+ {
+ brainCamera = Camera.main;
+ }
+ }
+
+ void Start()
+ {
+ if (pointerInput == null)
+ {
+ var player = GameObject.FindGameObjectWithTag("Player");
+ if (player != null)
+ {
+ pointerInput = player.GetComponent();
+ }
+ }
+
+ 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();
+ 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;
+ }
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraPanInput.cs.meta b/NightclubArcadia/Assets/Scripts/Camera/CameraPanInput.cs.meta
new file mode 100644
index 0000000..9003668
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraPanInput.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f9b06ade88f2643a3b9f52a80e06081d
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterest.cs b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterest.cs
new file mode 100644
index 0000000..c58e13f
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterest.cs
@@ -0,0 +1,31 @@
+using UnityEngine;
+
+namespace NightclubArcadia.Cinematics
+{
+ ///
+ /// Marker on NPCs and props that the POI director may bias toward.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterest.cs.meta b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterest.cs.meta
new file mode 100644
index 0000000..6d5e681
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7862f80331f384d28b27600075971a77
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterestDirector.cs b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterestDirector.cs
new file mode 100644
index 0000000..6d679d3
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterestDirector.cs
@@ -0,0 +1,161 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace NightclubArcadia.Cinematics
+{
+ ///
+ /// Scores registered POIs and writes bias onto the camera anchor.
+ ///
+ public sealed class CameraPointOfInterestDirector : MonoBehaviour
+ {
+ static readonly List Registered = new List();
+
+ [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();
+ }
+
+ if (stateDirector == null)
+ {
+ stateDirector = GetComponent();
+ }
+ }
+
+ 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;
+ }
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterestDirector.cs.meta b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterestDirector.cs.meta
new file mode 100644
index 0000000..cf7acaa
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraPointOfInterestDirector.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e3343417e1de440f095b73e55c6977b6
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraRigAnchor.cs b/NightclubArcadia/Assets/Scripts/Camera/CameraRigAnchor.cs
new file mode 100644
index 0000000..b071726
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraRigAnchor.cs
@@ -0,0 +1,85 @@
+using Unity.Cinemachine;
+using UnityEngine;
+
+namespace NightclubArcadia.Cinematics
+{
+ ///
+ /// Proxy transform the follow camera tracks. Combines player follow,
+ /// POI bias, manual pan offset, and distance bias.
+ ///
+ 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();
+ }
+
+ 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;
+ }
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraRigAnchor.cs.meta b/NightclubArcadia/Assets/Scripts/Camera/CameraRigAnchor.cs.meta
new file mode 100644
index 0000000..0d59f85
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraRigAnchor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 16d6717a3703a4bb3a8c3bb0bb0392de
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraStateDirector.cs b/NightclubArcadia/Assets/Scripts/Camera/CameraStateDirector.cs
new file mode 100644
index 0000000..691c86f
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraStateDirector.cs
@@ -0,0 +1,135 @@
+using NightclubArcadia.UI;
+using UnityEngine;
+
+namespace NightclubArcadia.Cinematics
+{
+ public enum CameraState
+ {
+ Follow,
+ ManualPan,
+ PointOfInterest,
+ Reveal,
+ Dialogue,
+ Menu
+ }
+
+ ///
+ /// Resolves which camera subsystem may write each frame.
+ ///
+ 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();
+ }
+ }
+
+ 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();
+ return pan != null && pan.IsManualPanActive;
+ }
+
+ bool HasActivePointOfInterest()
+ {
+ var director = GetComponent();
+ return director != null && director.HasActivePoi;
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Camera/CameraStateDirector.cs.meta b/NightclubArcadia/Assets/Scripts/Camera/CameraStateDirector.cs.meta
new file mode 100644
index 0000000..c7c1fbd
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/CameraStateDirector.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ca0160e4d996040238dab18725a2e844
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Camera/DialogueCameraFraming.cs b/NightclubArcadia/Assets/Scripts/Camera/DialogueCameraFraming.cs
new file mode 100644
index 0000000..2cfd0ca
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/DialogueCameraFraming.cs
@@ -0,0 +1,72 @@
+using NightclubArcadia.UI;
+using Unity.Cinemachine;
+using UnityEngine;
+
+namespace NightclubArcadia.Cinematics
+{
+ ///
+ /// Shifts CinemachinePositionComposer.Composition.ScreenPosition so the
+ /// player is centred in the screen space left of the dialogue panel.
+ ///
+ 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();
+ }
+
+ 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;
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Camera/DialogueCameraFraming.cs.meta b/NightclubArcadia/Assets/Scripts/Camera/DialogueCameraFraming.cs.meta
new file mode 100644
index 0000000..4bea4b4
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Camera/DialogueCameraFraming.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 56142a5f8795401498df8e01dc74b258
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Assets/Scripts/Interaction.meta b/NightclubArcadia/Assets/Scripts/Interaction.meta
new file mode 100644
index 0000000..5a80f90
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Interaction.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 97642050992484049b6c021481f2baf2
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Assets/Scripts/Interaction/ClickInteractableBridge.cs b/NightclubArcadia/Assets/Scripts/Interaction/ClickInteractableBridge.cs
new file mode 100644
index 0000000..d3fe85e
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Interaction/ClickInteractableBridge.cs
@@ -0,0 +1,29 @@
+using UnityEngine;
+
+namespace NightclubArcadia.Interaction
+{
+ ///
+ /// Adapts NPCStandIn to the click-to-interact pipeline without modifying
+ /// the existing proximity + keypress path.
+ ///
+ 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();
+ }
+ }
+
+ public void OnApproached()
+ {
+ npc?.Interact();
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Interaction/ClickInteractableBridge.cs.meta b/NightclubArcadia/Assets/Scripts/Interaction/ClickInteractableBridge.cs.meta
new file mode 100644
index 0000000..79b5084
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Interaction/ClickInteractableBridge.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5257f07d188de44eb9a04ba4d3a65fec
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Interaction/IClickInteractable.cs b/NightclubArcadia/Assets/Scripts/Interaction/IClickInteractable.cs
new file mode 100644
index 0000000..5b0010e
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Interaction/IClickInteractable.cs
@@ -0,0 +1,11 @@
+using UnityEngine;
+
+namespace NightclubArcadia.Interaction
+{
+ public interface IClickInteractable
+ {
+ Transform ApproachPoint { get; }
+ float ApproachRadius { get; }
+ void OnApproached();
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Interaction/IClickInteractable.cs.meta b/NightclubArcadia/Assets/Scripts/Interaction/IClickInteractable.cs.meta
new file mode 100644
index 0000000..a4142ee
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Interaction/IClickInteractable.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4812c7fb9cf2d41c28966640827dd0c8
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Player.meta b/NightclubArcadia/Assets/Scripts/Player.meta
new file mode 100644
index 0000000..11df161
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 381786de30b3e49e7a693028cc2127f5
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Assets/Scripts/Player/ClickToMoveController.cs b/NightclubArcadia/Assets/Scripts/Player/ClickToMoveController.cs
new file mode 100644
index 0000000..4186e6a
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/ClickToMoveController.cs
@@ -0,0 +1,234 @@
+using System;
+using NightclubArcadia.Interaction;
+using NightclubArcadia.Player.Math;
+using StarterAssets;
+using UnityEngine;
+using UnityEngine.AI;
+
+namespace NightclubArcadia.Player
+{
+ ///
+ /// NavMeshAgent in oracle mode: computes desired move input for the arbiter
+ /// without moving the CharacterController directly.
+ ///
+ [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 PathFinished;
+ public event Action RunRequested;
+
+ void Awake()
+ {
+ agent = GetComponent();
+ locomotion = GetComponent();
+
+ 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;
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Player/ClickToMoveController.cs.meta b/NightclubArcadia/Assets/Scripts/Player/ClickToMoveController.cs.meta
new file mode 100644
index 0000000..6853c39
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/ClickToMoveController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c2762b30721644611b9672bd4781cf15
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Player/Math.meta b/NightclubArcadia/Assets/Scripts/Player/Math.meta
new file mode 100644
index 0000000..b55f745
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/Math.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c32faf711a4044ed88ff1be2790915f7
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Assets/Scripts/Player/Math/MotionInputMath.cs b/NightclubArcadia/Assets/Scripts/Player/Math/MotionInputMath.cs
new file mode 100644
index 0000000..3a9d9e8
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/Math/MotionInputMath.cs
@@ -0,0 +1,42 @@
+using UnityEngine;
+
+namespace NightclubArcadia.Player.Math
+{
+ ///
+ /// Pure math helpers shared by click-to-move and camera pan.
+ ///
+ public static class MotionInputMath
+ {
+ ///
+ /// Converts a NavMeshAgent desired velocity into the camera-relative
+ /// Vector2 that ThirdPersonController expects on StarterAssetsInputs.move.
+ ///
+ 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;
+ }
+
+ ///
+ /// Converts screen-space pointer delta into a world-plane pan offset.
+ /// Drag direction is inverted so the world follows the cursor.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Player/Math/MotionInputMath.cs.meta b/NightclubArcadia/Assets/Scripts/Player/Math/MotionInputMath.cs.meta
new file mode 100644
index 0000000..fa43a7c
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/Math/MotionInputMath.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9af00d68e15e24c02811500cdf0dd2d4
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Player/Math/NightclubArcadia.Locomotion.Math.asmdef b/NightclubArcadia/Assets/Scripts/Player/Math/NightclubArcadia.Locomotion.Math.asmdef
new file mode 100644
index 0000000..834f26c
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/Math/NightclubArcadia.Locomotion.Math.asmdef
@@ -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
+}
diff --git a/NightclubArcadia/Assets/Scripts/Player/Math/NightclubArcadia.Locomotion.Math.asmdef.meta b/NightclubArcadia/Assets/Scripts/Player/Math/NightclubArcadia.Locomotion.Math.asmdef.meta
new file mode 100644
index 0000000..0b22fd8
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/Math/NightclubArcadia.Locomotion.Math.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 12491a5edc87346d28ffc48f9ab6562c
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Assets/Scripts/Player/PlayerMotionArbiter.cs b/NightclubArcadia/Assets/Scripts/Player/PlayerMotionArbiter.cs
new file mode 100644
index 0000000..f725852
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/PlayerMotionArbiter.cs
@@ -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
+ }
+
+ ///
+ /// Sole writer of StarterAssetsInputs move/sprint. Picks one input source
+ /// per frame: direct keyboard/gamepad or click-to-move navigation.
+ ///
+ [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();
+ controlLock = GetComponent();
+ clickToMove = GetComponent();
+#if ENABLE_INPUT_SYSTEM
+ playerInput = GetComponent();
+#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();
+#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);
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Player/PlayerMotionArbiter.cs.meta b/NightclubArcadia/Assets/Scripts/Player/PlayerMotionArbiter.cs.meta
new file mode 100644
index 0000000..b60d8d3
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/PlayerMotionArbiter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 91bf24256c6694185b4247a736593727
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Player/PlayerPointerInput.cs b/NightclubArcadia/Assets/Scripts/Player/PlayerPointerInput.cs
new file mode 100644
index 0000000..f51401c
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/PlayerPointerInput.cs
@@ -0,0 +1,61 @@
+using System;
+using UnityEngine;
+#if ENABLE_INPUT_SYSTEM
+using UnityEngine.InputSystem;
+#endif
+
+namespace NightclubArcadia.Player
+{
+ ///
+ /// Receives pointer and camera-pan SendMessages from PlayerInput and
+ /// exposes them as C# events for navigation and camera systems.
+ ///
+ [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 PanDelta;
+ public event Action PanEnded;
+
+#if ENABLE_INPUT_SYSTEM
+ void OnPoint(InputValue value)
+ {
+ ScreenPosition = value.Get();
+ }
+
+ 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());
+ }
+ }
+#endif
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Player/PlayerPointerInput.cs.meta b/NightclubArcadia/Assets/Scripts/Player/PlayerPointerInput.cs.meta
new file mode 100644
index 0000000..ee19db6
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/PlayerPointerInput.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ff77faf7e79154095ad57ad50df8c605
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Scripts/Player/PointerNavigationInput.cs b/NightclubArcadia/Assets/Scripts/Player/PointerNavigationInput.cs
new file mode 100644
index 0000000..ef120c0
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/PointerNavigationInput.cs
@@ -0,0 +1,203 @@
+using System;
+using NightclubArcadia.Interaction;
+using NightclubArcadia.UI;
+using UnityEngine;
+using UnityEngine.EventSystems;
+
+namespace NightclubArcadia.Player
+{
+ ///
+ /// Converts pointer clicks into click-to-move destinations or interactable
+ /// approach targets.
+ ///
+ 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();
+ }
+
+ if (clickToMove == null)
+ {
+ clickToMove = GetComponent();
+ }
+
+ if (motionArbiter == null)
+ {
+ motionArbiter = GetComponent();
+ }
+
+ 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() 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();
+ foreach (var behaviour in behaviours)
+ {
+ if (behaviour is IClickInteractable interactable)
+ {
+ return interactable;
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Scripts/Player/PointerNavigationInput.cs.meta b/NightclubArcadia/Assets/Scripts/Player/PointerNavigationInput.cs.meta
new file mode 100644
index 0000000..b74f650
--- /dev/null
+++ b/NightclubArcadia/Assets/Scripts/Player/PointerNavigationInput.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5f62460e35fda4829bd6ffeffe633a7e
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Tests/EditMode/Locomotion.meta b/NightclubArcadia/Assets/Tests/EditMode/Locomotion.meta
new file mode 100644
index 0000000..16dee58
--- /dev/null
+++ b/NightclubArcadia/Assets/Tests/EditMode/Locomotion.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 4392b20735bef4228be2723270edbb9b
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Assets/Tests/EditMode/Locomotion/MotionInputMathTests.cs b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/MotionInputMathTests.cs
new file mode 100644
index 0000000..4806a56
--- /dev/null
+++ b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/MotionInputMathTests.cs
@@ -0,0 +1,64 @@
+using NightclubArcadia.Player.Math;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace NightclubArcadia.Locomotion.Math.Tests
+{
+ public class MotionInputMathTests
+ {
+ [Test]
+ public void WorldToCameraRelativeInput_ZeroVelocity_ReturnsZero()
+ {
+ var result = MotionInputMath.WorldToCameraRelativeInput(Vector3.zero, 2f, 0f);
+ Assert.AreEqual(Vector2.zero, result);
+ }
+
+ [Test]
+ public void WorldToCameraRelativeInput_YawZero_PreservesForward()
+ {
+ var world = new Vector3(0f, 0f, 2f);
+ var result = MotionInputMath.WorldToCameraRelativeInput(world, 2f, 0f);
+ Assert.AreEqual(new Vector2(0f, 1f), result);
+ }
+
+ [Test]
+ public void WorldToCameraRelativeInput_Yaw90_RotatesCorrectly()
+ {
+ var world = new Vector3(0f, 0f, 2f);
+ var result = MotionInputMath.WorldToCameraRelativeInput(world, 2f, 90f);
+ Assert.That(result.x, Is.EqualTo(1f).Within(0.001f));
+ Assert.That(result.y, Is.EqualTo(0f).Within(0.001f));
+ }
+
+ [Test]
+ public void WorldToCameraRelativeInput_ClampFractionToOne()
+ {
+ var world = new Vector3(0f, 0f, 10f);
+ var result = MotionInputMath.WorldToCameraRelativeInput(world, 2f, 0f);
+ Assert.That(result.magnitude, Is.EqualTo(1f).Within(0.001f));
+ }
+
+ [Test]
+ public void WorldToCameraRelativeInput_RoundTripsThroughAtan2()
+ {
+ var world = new Vector3(1f, 0f, 2f).normalized;
+ var cameraYaw = 15f;
+ var input = MotionInputMath.WorldToCameraRelativeInput(world, 1f, cameraYaw);
+ var targetRotation = Mathf.Atan2(input.x, input.y) * Mathf.Rad2Deg + cameraYaw;
+ var reconstructed = Quaternion.Euler(0f, targetRotation, 0f) * Vector3.forward;
+ var flatWorld = new Vector3(world.x, 0f, world.z).normalized;
+
+ Assert.That(Vector3.Dot(reconstructed, flatWorld), Is.EqualTo(1f).Within(0.001f));
+ }
+
+ [Test]
+ public void ScreenDeltaToWorldPan_UsesCameraBasis()
+ {
+ var right = Vector3.right;
+ var forward = Vector3.forward;
+ var delta = new Vector2(10f, 0f);
+ var result = MotionInputMath.ScreenDeltaToWorldPan(delta, right, forward, 0.02f);
+ Assert.AreEqual(new Vector3(-0.2f, 0f, 0f), result);
+ }
+ }
+}
diff --git a/NightclubArcadia/Assets/Tests/EditMode/Locomotion/MotionInputMathTests.cs.meta b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/MotionInputMathTests.cs.meta
new file mode 100644
index 0000000..dcad1e5
--- /dev/null
+++ b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/MotionInputMathTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f3e10736152304d63ab3fc46cfdb2873
\ No newline at end of file
diff --git a/NightclubArcadia/Assets/Tests/EditMode/Locomotion/NightclubArcadia.Locomotion.Math.Tests.asmdef b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/NightclubArcadia.Locomotion.Math.Tests.asmdef
new file mode 100644
index 0000000..82bab85
--- /dev/null
+++ b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/NightclubArcadia.Locomotion.Math.Tests.asmdef
@@ -0,0 +1,24 @@
+{
+ "name": "NightclubArcadia.Locomotion.Math.Tests",
+ "rootNamespace": "NightclubArcadia.Locomotion.Math.Tests",
+ "references": [
+ "NightclubArcadia.Locomotion.Math",
+ "UnityEngine.TestRunner",
+ "UnityEditor.TestRunner"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/NightclubArcadia/Assets/Tests/EditMode/Locomotion/NightclubArcadia.Locomotion.Math.Tests.asmdef.meta b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/NightclubArcadia.Locomotion.Math.Tests.asmdef.meta
new file mode 100644
index 0000000..0a2e1dc
--- /dev/null
+++ b/NightclubArcadia/Assets/Tests/EditMode/Locomotion/NightclubArcadia.Locomotion.Math.Tests.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: caa98c93c07f34ac6aedf23ee4f45c2a
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/NightclubArcadia/Packages/manifest.json b/NightclubArcadia/Packages/manifest.json
index bd729ca..3d1bc22 100644
--- a/NightclubArcadia/Packages/manifest.json
+++ b/NightclubArcadia/Packages/manifest.json
@@ -1,6 +1,7 @@
{
"dependencies": {
"com.unity.ai.navigation": "2.0.14",
+ "com.unity.cinemachine": "3.1.7",
"com.unity.collab-proxy": "2.13.5",
"com.unity.ide.rider": "3.0.38",
"com.unity.ide.visualstudio": "2.0.26",
diff --git a/NightclubArcadia/Packages/packages-lock.json b/NightclubArcadia/Packages/packages-lock.json
index 6c933f1..8e1d5e1 100644
--- a/NightclubArcadia/Packages/packages-lock.json
+++ b/NightclubArcadia/Packages/packages-lock.json
@@ -21,7 +21,7 @@
},
"com.unity.cinemachine": {
"version": "3.1.7",
- "depth": 1,
+ "depth": 0,
"source": "registry",
"dependencies": {
"com.unity.splines": "2.0.0",
@@ -84,7 +84,7 @@
},
"com.unity.mathematics": {
"version": "1.4.0",
- "depth": 3,
+ "depth": 2,
"source": "builtin",
"dependencies": {}
},
@@ -168,7 +168,7 @@
},
"com.unity.splines": {
"version": "2.9.0",
- "depth": 2,
+ "depth": 1,
"source": "registry",
"dependencies": {
"com.unity.mathematics": "1.2.1",
diff --git a/NightclubArcadia/ProjectSettings/NavMeshAreas.asset b/NightclubArcadia/ProjectSettings/NavMeshAreas.asset
index 3b0b7c3..4812238 100644
--- a/NightclubArcadia/ProjectSettings/NavMeshAreas.asset
+++ b/NightclubArcadia/ProjectSettings/NavMeshAreas.asset
@@ -73,10 +73,10 @@ NavMeshProjectSettings:
m_Settings:
- serializedVersion: 2
agentTypeID: 0
- agentRadius: 0.5
- agentHeight: 2
+ agentRadius: 0.3
+ agentHeight: 1.8
agentSlope: 45
- agentClimb: 0.75
+ agentClimb: 0.25
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
diff --git a/NightclubArcadia/ProjectSettings/TagManager.asset b/NightclubArcadia/ProjectSettings/TagManager.asset
index 6413d11..4e18114 100644
--- a/NightclubArcadia/ProjectSettings/TagManager.asset
+++ b/NightclubArcadia/ProjectSettings/TagManager.asset
@@ -11,8 +11,8 @@ TagManager:
-
- Water
- UI
- -
- -
+ - Interactable
+ - Ground
-
-
-