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 - - -